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

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 代替
posted @ 2024-10-27 22:48  Astron_fjh  阅读(9)  评论(0编辑  收藏  举报