首页 > 系统相关 >CUDA编程学习 (3)——内存和数据定位

CUDA编程学习 (3)——内存和数据定位

时间:2024-10-27 22:44:04浏览次数:1  
标签:thread 矩阵 编程 内存 WIDTH CUDA memory TILE 加载

1. CUDA Memories

1.1 GPU 性能如何

  • 所有 thread 都会访问 global memory,以获取输入的矩阵元素

    • 在执行一次浮点加法时,需要进行一次内存访问,每次访问传输 4 字节(即 32 位浮点数)
    • 1 FLOP(浮点运算)对应 4 字节的内存带宽
  • 假设的 GPU 性能:

    • 该 GPU 的峰值浮点计算能力为 1,600 GFLOPS(十亿次浮点运算/秒),内存带宽为 600 GB/s
    • 理论上,若要达到 1,600 GFLOPS 的峰值计算能力,需要 4*1,600 = 6,400 GB/s 的内存带宽,但实际内存带宽只有 600 GB/s
  • 性能瓶颈:由于内存带宽的限制,实际执行速度只能达到 150 GFLOPS,这仅仅是 GPU 峰值浮点性能的 9.3%(150/1600)

  • 减少内存访问:为了接近 GPU 的 1,600 GFLOPS 峰值性能,必须大幅减少对内存的访问量。

1.2 示例——矩阵乘法

image-20240923002616901
__global__ void MatrixMulKernel(float* M, float* N, float* P, int Width){
	// Calculate the row index of the P element and M
 	int Row = blockIdx.y * blockDim.y + threadIdx.y;
 	// Calculate the column index of P and N
 	int Col = blockIdx.x * blockDim.x + threadIdx.x;
    
    if ((Row < Width) && (Col < Width)) {
         float Pvalue = 0;
         // each thread computes one element of the block sub-matrix
         for (int k = 0; k < Width; ++k) {
         	Pvalue += M[Row * Width + k] * N[k * Width + Col];
         }
         P[Row * Width + Col] = Pvalue;
	}
}

1.3 thread 到 P 的数据映射

image-20240923003649752

1.4 \(P_{0,0}\) 和 \(P_{0,1}\) 的计算

image-20240923003826886

1.5 程序视角的 CUDA 内存

image-20240923003947550

1.5.1 声明 CUDA 变量

image-20240923004151592
  • 当与 __shared____constant__ 一起使用时,__device__ 是可选项
  • 自动变量(automatic variables)位于 register
    • 除位于 global memory 中的 per-thread 数组外

Shared Memory 变量声明:

void blurKernel(unsigned char* in, unsigned char* out, int w, int h){
	__shared__ float ds_in[TILE_WIDTH][TILE_WIDTH];
}

1.5.2 在何处声明变量

image-20240923005544064
  • 全局(global)声明:在任何函数外部声明的变量,通常是 global memory,可以被所有 thread 访问。host 不能直接访问这些变量,需要通过 device 端操作。

  • 常量(constant)声明:同样是在函数外部声明,但这些变量是只读的,并且通常用于需要在 device 的多个 thread 中共享不变的数据。host 可以访问常量内存,通过 cudaMemcpyToSymbol 等函数将数据从主机拷贝到常量内存。

  • 寄存器(register)声明:在 kernel 函数内部声明,存储在 register 中,属于每个 thread 的私有变量,访问速度非常快。host 不能访问 register 变量

  • 共享内存(shared)声明:在 kernel 函数内部声明的共享内存,存储在 device 上,可以被同一个 block 中的所有 thread 共享。host 无法直接访问共享内存,只在 device block 内部有效。

1.5.3 CUDA 中的共享内存(Shared Memory)

一种特殊类型的内存,其内容在 kernel 源代码中显式定义并使用

  • 每个 SM(流式多处理器)有一份:每个流式多处理器(SM)都有一块 shared memory 供 block 使用。
  • 高访问速度:与 global memory 相比,访问 shared memory 的速度(延迟和吞吐量)要快得多。
  • 访问范围和共享范围:shared memory 仅在 block 之间共享,并且只能由同一个 block 内的 thread 访问。
  • 生命周期:shared memory 的生命周期仅限于 block,当 block 执行完毕后,内存内容就会消失。
  • 通过加载/存储指令访问:shared memory 通过显式的内存加载和存储指令进行访问。
  • Scratchpad memory:在计算机架构中,shared memory 可以视为一种临时存储区域,类似于 scratchpad memory

2. Tiled 并行算法

  • 基本矩阵乘法 kernel 的 global memory 访问模式
image-20240923144955573
  • 在拥堵的交通系统中,大幅减少车辆数量可以显著改善所有车辆的延迟
    • 为通勤者提供拼车服务
    • Tiling 用于 global memory 访问
      • 驾驶员 = 访问其内存数据操作数的 thread
      • 汽车 = 内存访问请求
image-20240923145701919

2.1 Tiling 的挑战

  • 有些拼车可能比其他拼车更容易:有些计算任务的 Tiling 更容易,而有些则较难

  • 拼车的参与者需要有类似的工作时间表:就像拼车中的乘客需要有相似的时间安排一样,在 thread 并行处理中,任务需要有相似的计算特征或数据访问模式才能有效使用 Tiling

  • 拼车需要同步

    • Good:当人们的日程安排相似时

    • image-20240923151100185
      • Bad:当人们的日程安排大相径庭时
      • image-20240923151303315
  • Tiling 也是一样

    • Good:当 thread 具有相似的访问时序时
    • Bad:当 thread 的时间非常不同时

2.2 Tiling 技术概要

  • 识别一个由多个 thread 访问的 global memory tile:找到 global memory 中的一个 tile 数据,这个 tile 数据将会被多个 thread 同时访问
  • 将这个 tile 从 global memory 加载到片上 memory:把这个 tile 数据从 global memory 中移到片上更快的memory(如shared memory)中
  • 使用屏障同步确保所有 thread 都准备好开始当前阶段:通过同步机制,确保所有 thread 都达到了一个同步点,准备好下一步操作
  • 让多个 thread 从片上 memory 中访问它们的数据:所有 thread 从这个更快速的片上 memory 中读取数据,避免重复访问慢速的 global memory
  • 再次使用屏障同步确保所有 thread 完成当前阶段:确保所有 thread 都完成了当前阶段的工作,避免提前进入下一步
  • **继续处理下一个 tile **:一旦当前 tile 的处理完成,重复上述步骤处理下一个 tile

3. Tiled 矩阵乘法

3.1 矩阵乘法

image-20240923155250622
  • 数据访问模式
    • 每个 thread - 负责矩阵 \(M\) 的一行和矩阵 \(N\) 的一列的计算。这意味着每个 thread 会处理两个矩阵的部分数据,以完成其对应位置的矩阵乘积结果。
    • 每个 thread block - 负责矩阵 \(M\) 的一条 “带状(strip)” 区域和矩阵 \(N\) 的一条 “带状(strip)” 区域的计算。这指的是,thread block 并不是处理整个矩阵,而是划分矩阵成多条 “带状(strip)” 形式,每个 thread block 处理其中的一部分,以提高计算的并行度。

3.2 Tiled 矩阵乘法

image-20240923155635788
  • 将每个 thread 的执行分成几个阶段
  • thread block 在每个阶段的数据访问都集中在 M 的一个 tile 和 N 的一个 tile 上
  • 这些 tile 的大小是每个维度中 BLOCK_SIZE 的元素数量。

3.3 加载 Tile

  • block 中的所有 thread 都参与加载:每个 block 中的所有 thread 共同完成 tile 的数据加载任务
    • 每个 thread 负责加载一个 M 矩阵元素和一个 N 矩阵元素
  1. 阶段 0 的 Block(0,0) 加载
image-20240923162704698
  1. 阶段 0 的 Block(0,0) 计算 (iteration 0)
image-20240923163052059
  1. 阶段 0 的 Block(0,0) 计算 (iteration 1)
image-20240923163132762
  1. 阶段 1 的 Block(0,0) 加载
image-20240923163405256
  1. 阶段 1 的 Block(0,0) 计算 (iteration 0)
image-20240923163914364
  1. 阶段 1 的 Block(0,0) 计算 (iteration 1)
image-20240923164630763
  • 执行阶段
image-20240923170033357

3.4 障碍同步(Barrier Synchronization)

同步 block 中的所有 thread:通过同步机制,确保 block 中的所有 thread 在继续执行前都处于同一进度。

  • __syncthreads():在同一个 block 中的所有 thread 必须在到达 __syncthreads() 后,才能继续执行后续的指令。这意味着,thread 只有在所有 thread 到达同步点时,才能同时继续。

  • 最佳用法是协调分阶段执行的分 tile 算法__syncthreads() 最适用于分 tile 算法的阶段性执行,确保所有 thread 在正确的时间协同工作。

    • 确保 tile 中的所有元素在每个阶段开始时被加载:使用 __syncthreads() 确保在每个阶段开始时,tile 内的所有元素都已经加载完毕。

    • 确保 tile 中的所有元素在每个阶段结束时被使用:使用 __syncthreads()确保在进入下一阶段之前,当前阶段的所有数据都被 thread 处理完毕。

4. Tiled 矩阵乘法 kernel

4.1加载 M、N 的输入 Tile 0(阶段 0)

image-20240923173153342
  • int Row = by * blockDim.y + ty
  • int Col = bx * blockDim.x + tx
  • 对矩阵M:M[Row][tx]
  • 对矩阵N:N[ty][Col]

bybx 是块的索引,分别对应Y和X方向的块。

blockDim.yblockDim.x 是每个块中的线程数量,tytx 是线程在当前块中的局部索引。

每个 thread 从M和N矩阵中加载与其P矩阵元素相对应的元素。图示中:

  • M和N的整个矩阵以方块形式显示,每个方块中的点表示 thread 正在处理的元素。
  • P矩阵的一个小块正被计算,每个 thread 会从M和N中选取对应的元素来参与计算。

4.2 加载 M、N 的输入 Tile 1(阶段 1)

image-20240923175119867
  • 对矩阵M:M[Row][1*TILE_WIDTH + tx]
  • 对矩阵N:N[1*TILE_WIDTH + ty][Col]

4.3 M 和 N 是动态分配的——使用 1D 索引

  • M[Row][p * TILE_WIDTTH + tx] -> M[Row * WIDTH + p * TILE_WIDTH + tx]
  • N[p * TILE_WIDTH + ty][Col] -> N[(p * TILE_WIDTH + ty) * WIDTH + Col]

其中,p 是当前阶段的序列号

4.4 Tiled 矩阵乘法 kernel

__global__ void MatrixMulKernel(float* M, float* N, float* P, int Width)
{
    __shared__ float ds_M[TILE_WIDTH][TILE_WIDTH];
	__shared__ float ds_N[TILE_WIDTH][TILE_WIDTH];
    
    int bx = blockIdx.x;
    int by = blockIdx.y;
 	int tx = threadIdx.x;
    int ty = threadIdx.y;
    
    int Row = by * blockDim.y + ty;
	int Col = bx * blockDim.x + tx;
 	float Pvalue = 0;
    
    // 循环计算 P 元素所需的 M 和 N tile
    for (int p = 0; p < n/TILE_WIDTH; ++p){
    	// 将 M 和 N tile 协同载入 shared memory
        ds_M[ty][tx] = M[Row*WIDTH + p*TILE_WIDTH + tx];
        ds_N[ty][tx] = N[(p*TILE_WIDTH + ty)*WIDTH + Col];
        __syncthreads();
        
        for (int i = 0; i < TILE_WIDTH; ++i)
            Pvalue += ds_M[ty][i] * ds_N[i][tx];
        __synchthreads();
    }
    P[Row * Width + Col] = Pvalue;
}

4.5 Tile(Thread Block)大小

每个 thread block 应包含多个 thread

  • 如果 TILE_WIDTH 有 16,则有 \(16*16 = 256\) threads
  • 如果 TILE_WIDTH 有 32,则有 \(32*32 = 1024\) threads

对于 16,在每个阶段,每个 block 从 global memory 执行 \(2*256 = 512\) 次浮点加载,进行 \(256 * (2*16)=8,192\) 次乘加操作。(每个 memory 加载 16 次浮点运算)

对于 32,在每个阶段,每个 block 从 global memory 执行 \(2*1024 = 2048\) 次浮点加载,进行 \(1024 * (2*32) = 65,536\) 次乘加操作。(每个 memory 加载 32 次浮点运算)

4.6 Shared Memory 和 Threading

对于具有 16KB shared memory 的 SM

  • shared memory 大小取决于执行情况

  • TILE_WIDTH = 16 时,每个 thread block 使用 \(2*256*4\ Byte = 2K\ Byte\) (一个 \(16*16\) 的 M 和 一个 \(16*16\) 的 N,其中一个元素占据 4 Byte)的 shared memory

  • 对于 16KB shared memory,最多可能有 8 个 thread block 在执行

    • 这允许多达 \(8*2*256=4,096\) 个待加载。(每个线程有 2 个,每个区块有 256 个线程)
  • 下一个 TILE_WIDTH 为 32,将导致每个 thread block 使用 \(2*32*32*4\ Byte=8K\ Byte\) 的 shared memory,允许 2 个 thread block 同时工作

    • 然而,在 GPU 中,每个 SM 的 thread 数限制为 1536 个,因此每个 SM 的 block 数减少为一个!

每个 __syncthread() 都能减少一个 block 的活动 thread 数

  • 更多的 thread blocks 可能更有优势

5. 在 Tiled 算法中处理任意大小矩阵

5.1 处理任意大小的矩阵

到目前为止,我们介绍的 tiled 矩阵乘法 kernel 只能处理尺寸(Width)是 tile 宽度(TILE_WIDTH)倍数的正方形矩阵。

  • 然而,实际应用需要处理任意大小的矩阵。
  • 我们可以将 row 和 column 填充(添加元素)为 tile 大小的倍数,但这将产生巨大的空间和数据传输时间开销。我们将采取不同的方法。

5.1.1 阶段 1 加载 3x3 的 Block(0,0)

image-20240923220542957
  • 在加载 N tile 时 Threads (1,0) 和 (1,1) 需要特殊处理
  • 在加载 M tile 时 Threads (0,1) 和 (1,1) 需要特殊处理

5.1.2 阶段 1 的 Block(0,0) 计算 (iteration 0)

image-20240923221105648

5.1.3 阶段 1 的 Block(0,0) 计算 (iteration 1)

image-20240923221249272
  • 所有 thread 都需要特殊处理,所有 thread 都不应为其 P 元素引入无效贡献。

5.1.3 阶段 0 加载 3x3 的 Block(1,1)

image-20240923221908891

Thread(1,0) 和 (1,1) 在加载 M tile 时需要特殊处理

5.1.4 上述例子的要点

  • 不计算有效P元素的 thread

    • 即使某些 thread 不直接计算有效的输出矩阵 P 的元素,它们仍然需要参与加载输入矩阵的 tile 数据。

    • 例子1:Block(1,1) 的 Phase 0 阶段

      • Thread(1,0) 被分配去计算一个不存在的P矩阵元素 P[3,2],但仍需参与加载输入矩阵N的元素 N[1,2]

      • 这意味着,即使这个 thread 不会直接计算 P 中的有效结果,它仍需要协助完成输入数据(这里是N矩阵)的加载。

  • 计算有效P元素的 thread

    • 即使某些 thread 被分配去计算有效的 P 矩阵元素,它们在加载输入数据时可能会尝试加载不存在的元素。
    • 例子2:Block(0,0) 的 Phase 0 阶段
      • Thread(1,0) 被分配去计算有效的 P 矩阵元素 P[1,0],但在加载输入时,它可能尝试去加载不存在的N矩阵元素 N[3,0]

5.1.5 简单的解决方法

  • 当 thread 要加载任何输入元素时,测试该元素是否在有效索引范围内

    • 如果有效,则继续加载
    • 否则,不加载,只写入 0
  • 理由:0 值将确保乘加步骤不会影响输出元素的最终值

  • 加载输入元素时测试的条件,与计算输出 P 元素时测试的条件不同

    • 即使一个 thread 不计算有效的 P 元素,它仍然可以参与加载输入数据块的元素
  • 阶段 1 的 Block(0,0) 计算 (iteration 1)

image-20240923224855070

5.2 边界条件

5.2.1 输入 M tile 的边界条件

image-20240923225313020
  • 每个线程加载

    • M[Row][p*TILE_WIDTH + tx]
    • M[Row*WIDTH + p*TILE_WIDTH + tx]
  • 需要进行测试

    • (Row < Width) && (p*TILE_WIDTH + tx < WIDTH)
    • 如果为 true,加载 M 元素
    • 否则,加载 0

5.2.2 输入 N tile 的边界条件

image-20240923225708598
  • 每个线程加载

    • N[p*TILE_WIDTH + ty][Col]
    • N[(p*TILE_WIDTH + ty)*WIDTH + col]
  • 需要进行测试

    • (p*TILE_WIDTH + ty < WIDTH) && (Col < WIDTH)
    • 如果为 true,加载 N 元素
    • 否则,加载 0

5.2.3 加载元素 - 带边界检查

for(int p = 0; p < (WIDTH-1) / TILE_WIDTH + 1; ++p){
    if(Row < WIDTH && p*TILE_WIDTH + tx < WIDTH){
        ds_M[ty][tx] = M[Row*WIDTH + p*TILE_WIDTH + tx];
    } else {
        ds_M[ty][tx] = 0.0;
    }
    
    if(p*TILE_WIDTH + ty < WIDTH && Col < WIDTH){
        ds_N[ty][tx] = N[(p*TILE_WIDTH + ty) * WIDTH + Col];
    } else {
        ds_N[ty][tx] = 0.0;
    }
    __syncthreads();
    
    if(Row < WIDTH && Col < WIDTH) {
    	for(int i = 0; i < TILE_WIDTH; ++i) {
        	Pvalue += ds_M[ty][i] * ds_N[i][tx];
        }
        __syncthreads();
    }
    if (Row < WIDTH && Col < WIDTH) 
		P[Row*WIDTH + Col] = Pvalue;
}

5.2.4 一些重点

每个 thread 的条件都不同

  • 加载 M 元素
  • 加载 N 元素
  • 计算并存储输出元素

5.2.5 处理一般矩形矩阵

一般来说,矩阵乘法是用矩形矩阵定义的:

  • \(j \times k\) \(M\) 矩阵与 \(k \times l\) \(N\) 矩阵相乘,得到 \(j \times l\) \(P\) 矩阵

上面我们介绍了正方形矩阵乘法,这是一种特殊情况。

kernel 函数需要通用化,以处理一般矩形矩阵:

  • Width 参数由三个参数代替:j、k、l
  • 当 Width 用于指 M 的高度或 P 的高度时,用 j 代替
  • 当 Width 用于指 M 的宽度或 N 的高度时,用 k 代替
  • 当 Width 用于指 N 的宽度或 P 的宽度时,用 l 代替

标签:thread,矩阵,编程,内存,WIDTH,CUDA,memory,TILE,加载
From: https://www.cnblogs.com/Astron-fjh/p/18509163

相关文章

  • CUDA编程学习 (4)——thread执行效率
    1.Warp和SIMD硬件1.1作为调度单位的Warp每个block分为32-threadwarp在CUDA编程模型中,虽然warp不是显式编程的一部分,但在硬件实现上,每个block会被自动划分成若干个包含32个线程的warp。warp作为SM中的调度单元:SM(StreamingMultiprocessor)会以warp......
  • java计算机毕业设计基于web的青少年编程课程评价系统(开题+程序+论文)
    本系统(程序+源码)带文档lw万字以上 文末可获取一份本项目的java源码和数据库参考。系统程序文件列表开题报告内容一、研究背景随着信息技术的迅猛发展,编程教育在青少年教育中的地位日益凸显。如今,编程被视为一项关键技能,对青少年的逻辑思维、创造力和问题解决能力有着积极......
  • JAVA 面向对象编程
    随着软件系统的复杂性不断增加,传统的过程式编程方法已经难以满足需求。面向对象编程提供了一种更自然的方式来映射现实世界的问题域到计算机程序中,使得代码更加易于理解、维护和扩展。面向对象编程(Object-OrientedProgramming,OOP)是一种编程范式,它使用“对象”来设计软件和实......
  • C++ 模板编程:解锁高效编程的神秘密码
     快来参与讨论......
  • 【MySQL】实战篇—应用开发:使用MySQL与编程语言(如Python、Java、PHP等)进行交互
    MySQL是存储和管理数据的强大工具,而编程语言(如Python、Java、PHP等)则用于开发应用程序和处理业务逻辑。将这两者结合起来,可以实现数据的存储、查询、更新和管理,进而构建功能强大的应用程序。2.重要性和实际应用场景在软件开发中,数据库与编程语言的交互至关重要,以下是一些常......
  • PLC 编程和 IIOT 工业物联网的区别是什么
    PLC编程和IIOT工业物联网的区别:1.技术定义和应用范围;2.功能特性对比;3.系统架构差异;4.数据处理方式;5.未来发展趋势。PLC主要负责现场控制和设备直接管理,而IIOT则担负着将设备数据互联互通、优化整体生产流程的任务。1.技术定义和应用范围PLC编程:是指利用一种专门的编程......
  • 探索C++的奥秘之C/C++内存管理
    一个程序当中的数据主要有以下几类:局部数据、静态数据和全局数据、常量数据、动态申请数据。1.C/C++内存分布1.栈又叫堆栈--非静态局部变量/函数参数/返回值等等,栈是向下增长的。2.内存映射段是高效的I/O映射方式,用于装载一个共享的动态内存库。用户可使用系......
  • cpu访问寄存器的速度为什么比内存快
    cpu访问寄存器的速度比内存快主要是因为寄存器直接位于cpu内部,无需通过数据总线进行数据传输,减少了数据访问的延迟。同时,寄存器使用的是高速硅基技术,其存取速度远超内存,使得CPU能够更快速地读写数据。CPU、寄存器和内存是计算机系统中的关键组成部分,它们共同作用,支持计算机进行高......
  • 实验2 类和对象_基础编程
    1.实验任务一:t.h:#pragmaonce#include<string>//类T:声明classT{//对象属性、方法public:T(intx=0,inty=0);//普通构造函数T(constT&t);//复制构造函数T(T&&t);//移动构造函数~T();//析构函数vo......
  • 阅读笔记一:《代码大全2》的宏观视角与编程艺术
    《代码大全2》是一部编程领域的经典之作,它不仅是一部技术指南,更是一部关于编程艺术的深刻探讨。在阅读这本书的过程中,我首先被其宏观的视角所吸引。作者并没有局限于某一种编程语言或技术框架,而是从更广泛的角度探讨了编程的基本原则、最佳实践和常见问题。书中强调了代码的可读性......