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 示例——矩阵乘法

__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 的数据映射

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

1.5 程序视角的 CUDA 内存

1.5.1 声明 CUDA 变量

  • 当与 __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 在何处声明变量

  • 全局(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 访问模式

  • 在拥堵的交通系统中,大幅减少车辆数量可以显著改善所有车辆的延迟

    • 为通勤者提供拼车服务
    • Tiling 用于 global memory 访问
      • 驾驶员 = 访问其内存数据操作数的 thread
      • 汽车 = 内存访问请求

2.1 Tiling 的挑战

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

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

  • 拼车需要同步

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

      • Bad:当人们的日程安排大相径庭时
  • 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 矩阵乘法

  • 数据访问模式

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

3.2 Tiled 矩阵乘法

  • 将每个 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) 加载

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

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

  1. 阶段 1 的 Block(0,0) 加载

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

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

  • 执行阶段

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)

  • 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)

  • 对矩阵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)

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

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

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

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

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

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)

5.2 边界条件

5.2.1 输入 M tile 的边界条件

  • 每个线程加载

    • 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 的边界条件

  • 每个线程加载

    • 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 代替

CUDA编程学习 (3)——内存和数据定位的更多相关文章

  1. CUDA编程模型之内存管理

    CUDA编程模型假设系统是由一个主机和一个设备组成的,而且各自拥有独立的内存. 主机:CPU及其内存(主机内存),主机内存中的变量名以h_为前缀,主机代码按照ANSI C标准进行编写 设备:GPU及其 ...

  2. CUDA编程学习笔记1

    CUDA编程模型是一个异构模型,需要CPU和GPU协同工作. host和device host和device是两个重要的概念 host指代CPU及其内存 device指代GPU及其内存 __globa ...

  3. CUDA编程学习相关

    1. CUDA编程之快速入门:https://www.cnblogs.com/skyfsm/p/9673960.html 2. CUDA编程入门极简教程:https://blog.csdn.net/x ...

  4. cuda编程学习6——点积dot

    __shared__ float cache[threadPerBlock];//声明共享内存缓冲区,__shared__ __syncthreads();//对线程块中的线程进行同步,只有都完成前面 ...

  5. cuda编程学习3——VectorSum

    这个程序是把两个向量相加 add<<<N,1>>>(dev_a,dev_b,dev_c);//<N,1>,第一个参数N代表block的数量,第二个参数1 ...

  6. cuda编程学习2——add

    cudaMalloc()分配的指针有使用限制,设备指针的使用限制总结如下: 1.可以将其传递给在设备上执行的函数 2.可以在设备代码中使用其进行内存的读写操作 3.可以将其传递给在主机上执行的函数 4 ...

  7. C++编程学习(二) 数据

    博主已经有一些基础了,所以写的东西可能是容易错的,或者以前没记住的,或者是对理解知识点有帮助的.因此如果有纯小白看到了这篇博文,不懂的地方请自行百度啦~ 另外,本系列所有内容的图片均来自于西北工业大学 ...

  8. CUDA编程学习(二)

    将数据加载到GPU后,如何在grid下的block进行并行计算(一个grid包含多个block) /****How do we run code in parallel on the device** ...

  9. CUDA编程学习(一)

    /****c code****/ #include<stdio.h> int main() { printf("Hello world!\n); ; } /****CUDA co ...

  10. cuda编程学习5——波纹ripple

    /共有DIM×DIM个像素,每个像素对应一个线程dim3 blocks(DIM/16,DIM/16);//2维dim3 threads(16,16);//2维kernel<<<blo ...

随机推荐

  1. ABC304Ex Constrained Topological Sort 题解

    https://atcoder.jp/contests/abc304/tasks/abc304_h [CSP-S 2023] 种树后半部分的加强版 对于边 \((u,v)\),不妨令 $r[u]$ 对 ...

  2. keepalived编译安装-麒麟v10Arm64

    环境信息 操作系统: Kylin Linux Advanced Server V10 (Lance) 架构:Arm keepalived版本:2.3.1 编译 wget https://www.kee ...

  3. Microsoft Build Next-Gen Windows Dev (placeholder)

    This is a placeholder page for Microsoft Build after party in next Month. Will update once I get det ...

  4. MFC中CString转int,double

    CString str=L"123"; int n=_wtoi(str); //n=123 double d=_wtof(str); //d=123.0000 Vs2012中编译

  5. redux开发中的一个小坑

    redux的actiontypes,不可以重名,重名将会报错 When called with an action of type "inserttoken", the slice ...

  6. Ubuntu 安裝 RIME 輸入法

    RIME (Rime Input Method Engine,中州韻,中州韵)是一款很火的輸入法,虽然我目前还不知道它为什么火,不过先用用再说. 首先要吐槽一下 RIME 的说明文档,我感觉有点乱,第 ...

  7. 使用 nuxi build 命令构建你的 Nuxt 应用程序

    title: 使用 nuxi build 命令构建你的 Nuxt 应用程序 date: 2024/8/30 updated: 2024/8/30 author: cmdragon excerpt: n ...

  8. keycloak~scope客户端模板的使用

    scope为何物? scope在oauth2中表示授权的范围,另外也可以理解为,根据认证时scope的参数,在构建jwt时,返回更多的信息:比如在keycloak中,你的可选scope(optiona ...

  9. vuejs怎样封装一个插件(以封装vue-toast为例扩展)

    插件介绍 插件通常会为 Vue 添加全局功能.插件的范围没有限制--一般有下面几种: 1.添加全局方法或者属性,如: vue-custom-element 2.添加全局资源:指令/过滤器/过渡等,如  ...

  10. 小tips:使用JSON.parse(JSON.stringify(object))实现深拷贝的局限及扩展

    使用JSON.parse(JSON.stringify(object))实现深拷贝局限 大部分情况我们都可以使用JSON.parse(JSON.stringify(object))来实现深拷贝,但该方 ...