Lecture 8: 并行模式六 —— 分块卷积:常量内存与边界处理 (对应 Lab 6 / Lab 4: 3D Convolution)
Lecture 8: 并行模式六 —— 分块卷积:常量内存与边界处理 (对应 Lab 6 / Lab 4: 3D Convolution)
概述
卷积(convolution)把输入数组变换成输出数组,每个输出元素是”附近”若干输入元素的加权和,权重由一个对所有输出都相同的小数组——卷积核(mask / filter / kernel)——给出。它既是信号、图像、视频处理的基础算子,也是科学计算中一切 stencil(模板)计算的代表,更是现代卷积神经网络的核心。本讲要解决的核心矛盾是:朴素卷积中每个输出要读 MASK_WIDTH²(2D)甚至 MASK_WIDTH³(3D)个输入元素,而所有这些读取最终只贡献 2 次浮点操作,因此未经优化的卷积是极端的内存带宽受限算法。CUDA 给出的两件武器是用 常量内存(constant memory)与常量缓存(constant cache)的广播机制存放只读的 mask(warp 内 32 个线程访问同一地址只需 1 个周期),以及用 共享内存分块(tiling)配合 halo/apron 边界重叠区的协作加载把输入数据在片上复用多次。本讲还系统讨论边界处理(ghost cells 的处理策略)——补零、夹紧、环绕三种策略,以及如何用”预先把 halo 填零到共享内存”这一手法把计算阶段的分支(从而 warp 发散)彻底消灭。课程目录页把这一讲对应的实验列为 Lab 6 - Tiled Parallel Convolution;Lumetta 版时间线(Summer 2025)把同一主题的实验列为 Lab 4: 3D Convolution(MP-4),两者编号不同但内容一致:都是”用共享内存分块 + halo 加载实现高性能卷积”。
核心概念与 GPU 架构图解
概念 1:卷积的数学定义(Convolution)
- 定义与目的:卷积把数组
N映射为数组P,每个输出元素是输入邻近元素的加权和,权重由卷积核M给出。一维定义为
P[i] = Σ_{j=0}^{MASK_WIDTH-1} M[j] * N[i + j] (相关形式, 无中心偏移)
P[i] = Σ_{j=0}^{MASK_WIDTH-1} M[j] * N[i - r + j], r = MASK_WIDTH/2 (中心对齐形式)
二维与三维只是把求和扩展到多个维度:
2D: P[y][x] = Σ_{i=0}^{MW-1} Σ_{j=0}^{MW-1} M[i][j] * N[y - r + i][x - r + j]
3D: P[z][y][x] = Σ_{i} Σ_{j} Σ_{k} M[i][j][k] * N[z - r + i][y - r + j][x - r + k]
其中 MW = MASK_WIDTH, r = MASK_RADIUS = MW/2 (整数除法, MW 通常取奇数)
它的目的是把”每个输出取一遍邻近窗口”这种高度规则、高度可并行的计算抽象成统一的算子,从而让同一个 kernel 骨架能服务低通/高通/带通滤波、模糊、锐化、边缘检测、特征提取、偏微分方程离散求解、以及神经网络卷积层。讲义强调:输入数组和 mask 维度相同、对所有输出元素使用同一个 mask,这两个性质正是优化的全部机会来源。
直观解释(”它是什么?”):把卷积想象成用一块有花纹的印章在整张纸上逐格盖章。印章就是 mask,它的花纹(系数)固定不变;每盖一次,就把印章下覆盖的那一小块纸(输入窗口)按花纹的深浅加权求和,得到的数字写进输出格。印章在纸上滑动一格,就产生一个输出。印章的尺寸是
5×5时,每盖一格要读 25 个输入数字、做 25 次乘法再累加。真正昂贵的不是乘法,而是”每次都重新看一眼那 25 个数字”——相邻两格之间,25 个数字里有 20 个是重复的(同一格纸被反复看),这就是卷积可以被分块加速的根本原因。架构/机制图解:以讲义上的一维例子(
Mask_Width = 5,Mask_Radius = 2)说明数据流:
输入 N: N[0] N[1] N[2] N[3] N[4] N[5] N[6] N[7] N[8]
1 2 3 4 5 6 7 8 9
卷积核 M: M[0] M[1] M[2] M[3] M[4] = 1 2 3 2 1 (Mask_Width=5, Mask_Radius=2)
计算 P[0]: P[0] = M[2]*N[0] + M[3]*N[1] + M[4]*N[2]
= 3*1 + 2*2 + 1*3 = 3 + 4 + 3 = 10 (左边越界两项按 0 处理)
计算 P[1]: P[1] = M[1]*N[0] + M[2]*N[1] + M[3]*N[2] + M[4]*N[3]
= 2*1 + 3*2 + 2*3 + 1*4 = 2 + 6 + 6 + 4 = 18 (左越界一项为 0)
计算 P[2]: P[2] = M[0]*N[0] + M[1]*N[1] + M[2]*N[2] + M[3]*N[3] + M[4]*N[4]
= 1*1 + 2*2 + 3*3 + 2*4 + 1*5
= 1 + 4 + 9 + 8 + 5 = 27
计算 P[3]: P[3] = M[0]*N[1] + M[1]*N[2] + M[2]*N[3] + M[3]*N[4] + M[4]*N[5]
= 1*2 + 2*3 + 3*4 + 2*5 + 1*6
= 2 + 6 + 12 + 10 + 6 = 36
计算 P[4]: P[4] = M[0]*N[2] + M[1]*N[3] + M[2]*N[4] + M[3]*N[5] + M[4]*N[6]
= 1*3 + 2*4 + 3*5 + 2*6 + 1*7
= 3 + 8 + 15 + 12 + 7 = 45
计算 P[5]: P[5] = M[0]*N[3] + M[1]*N[4] + M[2]*N[5] + M[3]*N[6] + M[4]*N[7]
= 1*4 + 2*5 + 3*6 + 2*7 + 1*8 = 4+10+18+14+8 = 54
计算 P[6]: P[6] = M[0]*N[4] + M[1]*N[5] + M[2]*N[6] + M[3]*N[7] + M[4]*N[8]
= 1*5 + 2*6 + 3*7 + 2*8 + 1*9 = 5+12+21+16+9 = 63
计算 P[7]: P[7] = M[0]*N[5] + M[1]*N[6] + M[2]*N[7] + M[3]*N[8]
= 1*6 + 2*7 + 3*8 + 2*9 = 6+14+24+18 = 62 (右越界一项为 0)
计算 P[8]: P[8] = M[0]*N[6] + M[1]*N[7] + M[2]*N[8]
= 1*7 + 2*8 + 3*9 = 7+16+27 = 50 (右越界两项按 0 处理)
滑动窗口示意 (计算 P[3] 时, M[0] 对准 N[1], 窗口覆盖 N[1] 到 N[5]):
N: 1 [2 3 4 5 6] 7 8 9
M: [M[0] M[1] M[2] M[3] M[4]]
j=0 j=1 j=2 j=3 j=4
P: 10 18 36 45 54 63 62 50
^
P[3] = 36 (由上面 5 个输入元素算出)
性能特征:每个输出需要 MASK_WIDTH 次乘加(2D 为 MASK_WIDTH² 次、3D 为 MASK_WIDTH³ 次),但相邻输出的窗口重叠了 MASK_WIDTH-1 个元素。以 2D、MASK_WIDTH = 5 为例,一个远离边界的输入元素 N[y][x] 会被 25 个不同的输出使用(讲义中的 Problem Solving 题:把问题反过来看,每个 mask 系数都对应一个唯一的依赖输出,因此是 5×5 = 25 次复用);3D、5×5×5 时复用次数是 125。复用次数决定了我们能在片上缓存里省下多少全局内存流量:只要能把这 25(或 125)次访问里的绝大部分变成共享内存/常量内存访问,带宽压力就下降一到两个数量级。
概念 2:卷积核(Mask / Filter / Kernel)与 halo / apron(边界重叠区)
定义与目的:卷积核(讲义中称 mask,也叫 filter 或 kernel)是一个与输入同维度的小常量数组,其元素称为 mask 系数(mask coefficients / weights)。halo(也叫 apron、ghost region,讲义同时使用 ghost cells / apron cells / halo cells 三个词)是指:为了让一个 block 能算出它负责的那块输出,除了这块输出”正下方”的输入之外,还必须在四周多读入
r = MASK_WIDTH/2个元素的输入区域。halo 的目的是让 block 内部的每个输出都能在共享内存里找到它需要的全部MASK_WIDTH²个输入元素,从而不必去全局内存取。直观解释(”它是什么?”):把整幅输入图像切成
16×16的方块分给各个 block,就像把一张大地图裁成小张分给不同的人复印。问题是每个人复制的每一小块,都要参考原图上它边缘外一圈的内容(因为5×5的窗口会伸出去 2 格)。于是每人实际复印的不是16×16,而是20×20——多出来的那一圈”边框”就是 halo。它像书页的页边空白:不是你真正要写的正文,但为了不越界翻到别人的页面,你必须把它一起裁下来。考试里把它叫 ghost cells(幽灵格),因为它们在输入数组的边界之外时根本不存在,是程序”凭空补”出来的。架构/机制图解:一个 block 的输入 tile 与 halo 的布局如下(
TILE_WIDTH = 16,MASK_WIDTH = 5,r = 2):
<------------- SX = TILE_X + 2r = 20 ------------->
+---------+---------------------------------------+---------+
| halo | | halo | ^ r=2
| (左上角)| halo (上边 2 行) | (右上角)| v
+---------+---------------------------------------+---------+
| | | |
| halo | 中心 TILE 16 x 16 | halo | ^
| (左) | 本 block 在这里产生 256 个输出元素 | (右) | | TILE_Y
| 2 列 | 每个输出要读 5x5 = 25 个输入元素 | 2 列 | | = 16
| | | | v
+---------+---------------------------------------+---------+
| halo | | halo | ^ r=2
| (左下角)| halo (下边 2 行) | (右下角)| v
+---------+---------------------------------------+---------+
<--- 2 ---><--------------- 16 ------------------><--- 2 --->
共享内存 tile 元素总数 = (TILE_X + 2r) x (TILE_Y + 2r) = 20 x 20 = 400 个 float = 1600 B
中心区域元素数 = TILE_X x TILE_Y = 16 x 16 = 256 个 float
halo 元素数 = 400 - 256 = 144 个 float (占总加载量的 36%)
halo 冗余加载比例 = (TILE+2r)^2 / TILE^2 = 400/256 = 1.5625 (即多加载 56.25%)
三维时同样的逻辑沿三个方向展开,halo 体积按立方增长:(TILE+2r)³ / TILE³。例如 TILE = 8、r = 2 时是 12³/8³ = 1728/512 = 3.375,即为了算 8³ = 512 个输出要加载 1728 个输入元素,多加载 237.5%。后文概念 8 会给出完整的 3D tile 图。
性能特征:halo 使”每输出对应的输入加载量”从名义上的 1 个元素升到 (TILE+2r)²/TILE² 个元素;TILE 越大,这个比例越接近 1(TILE = 8 时是 2.25,TILE = 16 时是 1.5625,TILE = 32 时是 1.2656),但 TILE 增大会同时增加共享内存占用与寄存器压力,并可能降低占用率。这是一个必须在具体 GPU 上(A100 每 SM 164 KB 共享内存、RTX 4090 每 SM 100 KB)权衡的参数。
概念 3:常量内存与常量缓存广播(Constant Memory & Constant Cache Broadcasting)
定义与目的:常量内存是全局内存中一块只读的、容量 64 KB 的地址空间,用
__constant__限定符在文件作用域声明,由主机端用cudaMemcpyToSymbol初始化。它存在的目的是为”整个 grid 里所有线程都读同一份、且只读”的小数据(卷积 mask、物理常数、滤波器系数、变换矩阵)提供一条不占用 L1/共享内存带宽、且对广播访问极快的通路。__constant__声明同时告诉编译器和硬件”缓存它是安全的”(因为只读,不存在缓存一致性问题),这是常量缓存能做得比 L1 更激进的依据。直观解释(”它是什么?”):把 mask 放进常量内存,就像把课堂投影的幻灯片放在讲台正前方:全班 32 个学生(一个 warp)看的是同一页,老师只需要念一次,所有人同时看到——不需要每人复印一份。而如果 mask 放在全局内存里,就相当于每个学生都要自己跑去打印室印一页同样内容;放在共享内存里,相当于每组发一张,组内传阅。常量缓存的关键机制叫广播(broadcast):当一个 warp 的 32 个 lane 访问同一个地址时,常量缓存一个周期就能把同一个 4 字节值送到全部 32 个 lane,硬件开销等价于一次标量读。卷积正是这种访问模式的完美典型——第
j次迭代时,warp 里 32 个线程读的都是Mc[j]。架构/机制图解:
Host (CPU)
|
| cudaMemcpyToSymbol(Mc, M, MASK_WIDTH*MASK_WIDTH*sizeof(float));
| (一次 100 字节的 H2D 拷贝, 只需一次)
v
+---------------------------------------------------------------+
| Device Global Memory |
| __constant__ float Mc[5][5] <-- 常量内存, 总容量 64 KB 上限 |
| N, P (用 cudaMalloc 分配的普通全局内存, 容量可达数十 GB) |
+---------------------------------------------------------------+
| |
| 常量内存的读写路径 | 普通全局内存路径
v v
+-------------------------+ +--------------------------+
| Constant Cache (每 SM) | | L2 Cache (全 GPU 共享) |
| 很小 (几 KB), 只读 | | 40 MB (A100) |
| 命中 ~5 cycles | +--------------------------+
+-------------------------+ |
| v
| +--------------------------+
| | L1 Cache / Shared Memory|
| | (A100 每 SM 192 KB 合并) |
| +--------------------------+
| |
+----------------+-------------------+
|
v
+-----------------------------------------------------------------+
| SM (A100: 108 个 SM, 每个 SM 最多 64 个 warp / 2048 线程) |
| |
| warp w (32 个 lane 同时执行 Mc[j] 的读取): |
| |
| lane0 lane1 lane2 lane3 lane30 lane31 |
| \ \ \ \ / / |
| \ \ \ \ / / |
| v v v v v v |
| +----------------------------------+ |
| | 常量缓存: 32 个 lane 命中同一地址 | --> 1 个周期, 1 次广播 |
| +----------------------------------+ |
| |
| 对比: 若 Mc 在全局内存, 32 个 lane 读同一地址 = 1 条 128B |
| transaction 只用了 4 字节 (利用率 3.1%), 且延迟 ~400-800 |
| cycles 而非 ~5 cycles |
+-----------------------------------------------------------------+
关键数字对照(A100,来自讲义 Review: Programmer View of CUDA Memories 与 Ampere SM Memory Architecture):
寄存器 ~1 cycle 每线程私有, 无冲突(除非寄存器组 bank 冲突)
共享内存 ~5 cycles 每个 block 私有, 程序员管理, 32 个 bank
常量内存/缓存 ~5 cycles 整个 grid 只读, 命中即广播, 未命中要回 L2/DRAM
L1 命中 ~30 cycles
L2 命中 ~200 cycles
全局内存 ~400-800 cycles (讲义写作 ~500)
什么时候该用常量内存、什么时候该用共享内存、什么时候该用全局内存:
+----------------------+---------------------+---------------------------+
| 数据特征 | 最佳存放位置 | 理由 |
+----------------------+---------------------+---------------------------+
| 全 grid 共用、只读、 | __constant__ (64KB) | warp 内同地址 -> 广播 1 周期|
| 每个线程读同一份 | | 不占 L1/共享内存带宽 |
| (卷积 mask, 系数表) | | 不消耗 cudaMalloc 开销 |
+----------------------+---------------------+---------------------------+
| block 内共享、会被 | __shared__ | 复用次数 >> 1, 延迟 ~5 周期 |
| 多个线程以不同地址读 | | 需自己加载 + __syncthreads |
| (输入 tile + halo) | | 需处理 bank conflict |
+----------------------+---------------------+---------------------------+
| 容量大、只读一次或 | 全局内存 + L1/L2 | 容量可达几十 GB, 合并访问 |
| 访问模式不规整 | (__restrict__ 提示) | 大 mask (如 11x11=121<64KB|
| | | 仍可放常量内存, 但收益递减)|
+----------------------+---------------------+---------------------------+
常量内存的代价必须一并记住:它只有 64 KB;如果一个 warp 内的 32 个 lane 访问不同地址,硬件会把请求串行化成最多 32 次独立的常量缓存访问(只要有 2 个不同地址就按分歧处理),此时常量缓存比 L1 更慢。这就是”常量内存只适合广播访问”的严格含义。卷积中 mask 的访问模式是 Mc[i*MW+j],对 warp 内所有 lane 都是同一个表达式、同一个地址,因此 100% 命中广播路径。
概念 4:边界处理(Boundary Handling)与 ghost cells
定义与目的:当输出元素靠近输入数组的边界时,它的
MASK_WIDTH个输入里会有一部分落在数组之外,这些不存在的元素就叫 ghost cells(幽灵格)、halo cells 或 apron cells。边界处理策略决定了这些位置”当作什么值”,直接决定卷积结果的语义(也决定和 CPU 参考实现能否对上)。三种主流策略是:补零(zero padding)、夹紧/复制边缘(clamp / replication)、环绕/周期延拓(wrap / periodic)。直观解释(”它是什么?”):想象你拿着一把 5 格宽的尺子量一排砖,尺子两边总要伸出去 2 格。伸到墙外面那两格怎么办?补零 = 假装墙外是空的(值为 0),图像会变暗(边缘被”吸”向黑色);夹紧 = 假装墙外的砖和贴墙那块一模一样(复制边缘),边缘不会变暗但会被拉宽;环绕 = 把这条砖排首尾接成一个圈,墙外那一格就是另一头的砖(周期延拓),适合本来就周期性的信号(如音频、傅里叶相关的处理)。选哪种没有对错,只有”和你的参考实现是否一致”。
架构/机制图解:三种策略对同一个左边界窗口的作用(
r = 2):
N 的真实数据从 N[0] 开始; 计算 P[0] 需要 N[-2], N[-1], N[0], N[1], N[2]
(a) 补零 zero padding (课程默认, Lab 采用)
N[-2]=0 N[-1]=0 N[0] N[1] N[2]
P[0] = M[0]*0 + M[1]*0 + M[2]*N[0] + M[3]*N[1] + M[4]*N[2]
(b) 夹紧 clamp / replication (复边缘值)
N[-2]=N[0] N[-1]=N[0] N[0] N[1] N[2]
P[0] = M[0]*N[0] + M[1]*N[0] + M[2]*N[0] + M[3]*N[1] + M[4]*N[2]
~~~~~~~~~~ 相当于把 mask 左半部分折回边缘像素
(c) 环绕 wrap / periodic (首尾相接)
N[-2]=N[W-2] N[-1]=N[W-1] N[0] N[1] N[2]
P[0] = M[0]*N[W-2] + M[1]*N[W-1] + M[2]*N[0] + M[3]*N[1] + M[4]*N[2]
三维时同理, 只是要沿 z/y/x 三个方向各判断一次:
沿 z: 0 或 D-1 处越界 沿 y: 0 或 H-1 处越界 沿 x: 0 或 W-1 处越界
三种策略在 CUDA 中的写法(内层循环里的三种”取数”函数):
/* (a) 补零: 越界返回 0 —— 课程 Lab 的默认约定 */
__device__ __forceinline__ float get_zero(const float *N, int idx, int len) {
return (idx >= 0 && idx < len) ? N[idx] : 0.0f;
}
/* (b) 夹紧: 越界贴到最近的合法下标 */
__device__ __forceinline__ float get_clamp(const float *N, int idx, int len) {
int k = idx < 0 ? 0 : (idx >= len ? len - 1 : idx);
return N[k];
}
/* (c) 环绕: 用取模把下标折回数组内 (len 必须是编译期可知或用 % 运算) */
__device__ __forceinline__ float get_wrap(const float *N, int idx, int len) {
int k = idx % len;
if (k < 0) k += len;
return N[k];
}
为什么边界分支会导致 warp 发散(warp divergence):朴素 kernel 的内层循环写成
for (int j = 0; j < MASK_WIDTH; ++j) {
int idx = N_start_point + j;
if (idx >= 0 && idx < Width) /* 这一句就是发散源 */
Pvalue += N[idx] * Mc[j];
}
在 SIMT 模型下,一个 warp 的 32 个 lane 共享同一个程序计数器,遇到 if 时硬件把”条件为真”的 lane 打开、为假的 lane 关闭(用执行掩码 active mask),两条路径都要串行执行一遍。以 5×5 mask、16×16 block(256 线程 = 8 warp,每个 warp 覆盖两行、每行 16 个 lane)为例,考察图像左上角那个 block:
角块 (blockIdx = (0,0)) 中, 哪些线程的 5x5 窗口越界?
tx in {0,1} 或 tx in {14,15} 或 ty in {0,1} 或 ty in {14,15}
越界线程数 = 4*16*2 - 4*4 = 128 - 16 = 112 (占 256 个线程的 43.75%)
按 warp 细分 (warp w 覆盖 ty = 2w 与 ty = 2w+1 两行, 每行 16 lane):
warp 0 (ty=0,1) : 32/32 lane 全部越界 -> 整个 warp 的循环体被谓词屏蔽
warp 7 (ty=14,15): 32/32 lane 全部越界 -> 同上
warp 1..6 : 每行有 tx=0,1,14,15 共 4 lane 越界
-> 每个 warp 有 4+4 = 8/32 = 25% 的 lane 被屏蔽
整幅 1024x1024 图像 + 16x16 block 的情况:
总 block 数 = 64 x 64 = 4096
边界 block 数 = 4096 - 62 x 62 = 4096 - 3844 = 252 (仅 6.15%)
-> 换言之 93.85% 的 block 完全没有边界发散
结论有两面:一方面,只有约 6% 的 block 真的会发散,所以朴素版的边界分支对总性能的影响有限;另一方面,那 25 个带 if 的迭代会为每一个输出都插入谓词计算与地址计算指令(每个输出约 25 次比较 + 25 次分支判断,而有效 FMA 只有 25 条),指令开销翻倍。彻底消除它的办法是”把 halo 预先加载(并补零)到共享内存”:在分块方案里,越界判断只在加载阶段做一次(每个线程 1 次),而计算阶段变成无分支的纯循环 Pvalue += Ns[ty+i][tx+j] * Mc[i][j];。这就是分块卷积相比朴素卷积在”指令效率”上的额外收益。
用「输入并行(input parallelism)」时还要注意:加载阶段的判断式写在 if 之外还是之内——
float v = 0.0f;
if (row_i >= 0 && row_i < Height && col_i >= 0 && col_i < Width)
v = N[row_i * Width + col_i];
Ns[ty][tx] = v; /* 所有线程都执行这一句, 分支外, 安全 */
__syncthreads(); /* __syncthreads 必须在分支之外! */
千万不要把 __syncthreads() 写进 if (row_i >= 0 && row_i < Height && col_i >= 0 && col_i < Width) 这个分支里:那会让部分 warp 跳过屏障,轻则死锁、重则读到未初始化数据(正确的写法见示例 2)。
概念 5:分块卷积与 halo 加载(Tiled Convolution with Halo Loading)
定义与目的:分块卷积把输出数组切成
TILE_WIDTH × TILE_WIDTH的瓦片(tile),每个 block 负责一块瓦片;block 协作地把大小为(TILE+MASK_WIDTH-1)²的输入 tile(含 halo)一次性从全局内存搬进共享内存,然后每个线程从共享内存里反复取数计算。它要解决的问题是:朴素版本中同一个输入元素被 25(2D)或 125(3D)个不同输出读取,而这些读取全都打到 L1/L2 上;分块后这些重复读取变成共享内存访问,全局内存流量下降一个数量级。直观解释(”它是什么?”):这就像厨房的案板。冰箱(全局内存/DRAM)在走廊尽头,取一次菜要跑 400-800 个时钟周期。朴素做法是每做一道菜就跑一趟冰箱取 25 样食材中的一样——反复跑 25 趟。分块做法是:全组人(一个 block 的 400 个线程)先一次性把这次要用的所有食材(
20×20的输入 tile,含边上多拿的一圈 halo)全搬到案板(共享内存)上,案板就在手边(约 5 个周期),然后在案板上切、配、炒。案板不大(A100 每 SM 164 KB),所以要认真规划”这次到底拿多少菜”——拿多了放不下、占满案板导致同一时间只能有一个 block 在用 SM(占用率下降),拿少了复用不够。架构/机制图解:分块卷积的三种”谁来干活”策略对比,以及全局内存访问减少倍数的定量计算:
策略 A: 输出并行 + halo 从全局内存读 (讲义说: 早期 GPU 上"A really bad idea")
+----------------+ +-------------------------------------------+
| block (16x16) | | 计算阶段: 中心 16x16 走 __shared__ tile |
| 共享内存只有 | --> | halo (宽 2 的边界列/行) 直接读 global |
| 中心 16x16 | | 后果: warp 内一部分 lane 读共享内存, |
+----------------+ | 一部分 lane 读全局内存 -> 严重发散 |
| 且 halo 只有 2 个元素宽, 凑不满 128B burst |
+-------------------------------------------+
策略 B: 输出并行 + halo 也搬进共享内存 (讲义推荐给 Lab 4 的选择之一)
+----------------------+ +----------------------------------------+
| block (TILE+2r)^2 | | 1. 全部 400 个线程各搬 1 个元素进共享内存|
| = (20x20) = 400 线程 |-->| 2. __syncthreads() |
| 共享内存 20x20 float | | 3. 前 256 个线程各算 1 个输出 (无分支) |
+----------------------+ +----------------------------------------+
优点: 全局访问完全合并; 计算阶段零发散; 缺点: 部分线程搬完就闲置
策略 C: 输入并行 (讲义推荐给 MP-4 的方案)
+----------------------+ +----------------------------------------+
| block (TILE+2r)^2 | | 1. 每线程搬 1 个输入元素 (无分支) |
| 与策略 B 相同 |-->| 2. __syncthreads() |
| | | 3. ty<TILE && tx<TILE 的线程算输出 |
+----------------------+ +----------------------------------------+
优点: 加载阶段零发散, 且避开只有 2 元素宽的窄 global 访问
缺点: 计算阶段有分支 (但共享内存延迟低, 影响小)
全局内存访问减少倍数的定量推导 (2D, 输出并行, TILE x TILE 输出):
朴素版: 每个输出读 MASK_WIDTH^2 个输入 -> TILE^2 x MASK_WIDTH^2 次全局访问
分块版: 只需加载 (TILE+MASK_WIDTH-1)^2 个输入元素
减少倍数 R = TILE^2 x MASK_WIDTH^2 / (TILE+MASK_WIDTH-1)^2
数值表 (2D, 讲义原表 + 本笔记补算):
TILE=8, MW=5: 64x25/144 = 1600/144 = 11.1x halo 放大 144/64 = 2.250
TILE=16, MW=5: 256x25/400 = 6400/400 = 16.0x halo 放大 400/256 = 1.5625
TILE=32, MW=5: 1024x25/1296= 25600/1296 = 19.7x halo 放大 1296/1024 = 1.266
TILE=64, MW=5: 4096x25/4624= 102400/4624= 22.1x halo 放大 4624/4096 = 1.129
TILE=8, MW=9: 64x81/144 = 5184/144 = 36.0x halo 放大 144/64 = 2.250
TILE=16, MW=9: 256x81/400 = 20736/400 = 51.8x halo 放大 400/256 = 1.5625
TILE=32, MW=9: 1024x81/1600= 82944/1600 = 51.8x halo 放大 1600/1024 = 1.5625
TILE=64, MW=9: 4096x81/5184= 331776/5184= 64.0x halo 放大 5184/4096 = 1.266
一维情形 (讲义原表, R = TILE*MW/(TILE+MW-1)):
TILE=16, MW=5 -> 80/20 = 4.0x TILE=16, MW=9 -> 144/24 = 6.0x
TILE=32, MW=5 -> 160/36 = 4.4x TILE=32, MW=9 -> 288/40 = 7.2x
TILE=64, MW=5 -> 320/68 = 4.7x TILE=64, MW=9 -> 576/72 = 8.0x
TILE=128, MW=5 -> 640/132 = 4.9x TILE=128, MW=9 -> 1152/136= 8.5x
TILE=256, MW=5 -> 1280/260= 4.9x TILE=256, MW=9 -> 2304/264= 8.7x
性能特征的三条要点:
- 减少倍数只取决于 TILE 与 MASK_WIDTH,与图像大小无关(忽略边界瓦片)。这就是为什么”每个 block 负责的瓦片越大、mask 越大,分块越值”。
- 每个输出对应的输入加载量(halo 放大比)
A = (TILE+2r)²/TILE²,与减少倍数满足R = MASK_WIDTH²/A。例如TILE=16, MW=5:A = 1.5625,R = 25/1.5625 = 16。 - 边界瓦片的比例会变(讲义 Problem Solving):如果输入是
32×32、输出瓦片16×16、mask5×5,则block(0,0)需要的输入只有18×18(因为左边和上边只有 2 行/列的 halo,另外两侧各 2 行/列被图像边界截断),此时算出的减少比与内部瓦片不同。整幅图越大,边界瓦片占比越小,R越接近上表的渐近值。
概念 6:线程粗化与寄存器分块(Thread Coarsening & Register Tiling)
定义与目的:线程粗化指让一个线程计算多个输出元素,而不是”一个线程一个输出”。寄存器分块(register tiling)是它最常见的实现形式:把多个输出元素的累加器(accumulator)放在寄存器里,让它们共享同一批从共享内存/常量内存取出的数据。它解决两个问题:(1) 减少共享内存读取次数与地址计算指令数;(2) 在”输入并行”策略下,避免大量线程搬完数据后闲置(提高计算阶段的 warp 利用率)。
直观解释(”它是什么?”):像用印章盖章时,不是”盖一格、回案板拿一次料”,而是一次从案板抓一把料,连盖四格。四格的位置挨在一起,它们需要的输入彼此重叠,所以那一把料(
5×5窗口里的 25 个数字)能同时喂给四个累加器。更贴切的类比是做四份一样的三明治:你不是做一份去冰箱拿一次生菜,而是一次拿够四份的量,站在案板前连续做四份,切菜、取料、放料的动作只做一遍。架构/机制图解:以 2D 卷积为例(
TILE = 16,MASK_WIDTH = 5,每线程2×2 = 4个输出):
变体 1: 一个线程一个输出 (block = 20x20 = 400 线程, 输入并行)
+------------------------------------------------------------------+
| 共享内存 20x20 (含 halo) |
| 线程 (tx,ty) 搬 1 个元素; ty<16 && tx<16 的 256 个线程各算 1 输出 |
| 每个输出: 25 次共享内存读 + 25 次常量读 + 25 FMA |
| warp 利用: 8 个 warp 中 warp 6,7 完全不参与计算 (ty>=12) |
| warp 0-5 每个只有 24/32 lane 活跃 (讲义 Problem Solving)|
+------------------------------------------------------------------+
变体 2: 一个线程 2x2 个输出 (block = 8x8 = 64 线程, 寄存器分块)
+------------------------------------------------------------------+
| tile 16x16 的局部坐标 (每个线程负责一个 2x2 的小方块): |
| tx=0 tx=1 tx=2 tx=3 tx=7 |
| +---------+ +---------+ +---------+ +-------+ +---------+ |
| |acc[0][0]| |acc[0][0]| |acc[0][0]| |acc[0] | |acc[0][0]| ty=0 |
| |acc[0][1]| |acc[0][1]| |acc[0][1]| |acc[0] | |acc[0][1]| ty=0 |
| +---------+ +---------+ +---------+ +-------+ +---------+ |
| |acc[1][0]| |acc[1][0]| |acc[1][0]| |acc[1] | |acc[1][0]| ty=1 |
| |acc[1][1]| |acc[1][1]| |acc[1][1]| |acc[1] | |acc[1][1]| ty=1 |
| +---------+ +---------+ +---------+ +-------+ +---------+ |
| |acc[0][0]| |acc[0][0]| |acc[0][0]| |acc[0] | |acc[0][0]| ty=2 |
| |acc[0][1]| |acc[0][1]| |acc[0][1]| |acc[0] | |acc[0][1]| ty=2 |
| +---------+ +---------+ +---------+ +-------+ +---------+ |
| |acc[1][0]| |acc[1][0]| |acc[1][0]| |acc[1] | |acc[1][0]| ty=3 |
| |acc[1][1]| |acc[1][1]| |acc[1][1]| |acc[1] | |acc[1][1]| ty=3 |
| +---------+ +---------+ +---------+ +-------+ +---------+ |
| 4 个累加器 (acc[0][0], acc[0][1], acc[1][0], acc[1][1]) 常驻寄存器|
| 25 次迭代 x 4 次共享读 = 100 次共享读 |
| 25 次常量读 (m 只读一次, 喂给 4 个 FMA) -> 常量读降到 1/4 |
| 输出写回 4 次/线程, 但 block 总数减少到 1/4, 调度开销降低 |
定量对比(同一 tile,256 个输出):
变体 1 (400 线程) 变体 2 (64 线程, 2x2 粗化)
参与加载的线程 400 (每线程 1 次) 64 (每线程 6~7 次)
每输出共享内存读 25 25 (总量相同, 但每线程 4 输出
的顺序访问可利用寄存器复用)
每输出常量内存读 25 25/4 = 6.25 (m 跨 4 个输出复用)
每 4 个输出的常量读 100 25
每输出全局写 1 1 (写回地址不连续, 需检查合并性)
寄存器/线程 ~24 ~40 (4 个累加器 + 5 个加载值)
每 SM 可驻留线程数 2048 (8 个 block) 1638 (寄存器受限: 65536/40)
占用率 100% (若寄存器够) 1638/2048 = 80%
计算阶段活跃 lane 256/400 = 64% 64/64 = 100%
性能特征:粗化把”常量内存读取”与”共享内存地址计算”的成本摊薄了 4 倍,并把计算阶段的活跃 lane 比例从 64% 提到 100%;代价是寄存器压力上升(每多一个输出多一个累加器 + 一组中间值),可能把占用率从 100% 拉到 80% 左右。在 A100 上,占用率 80% 通常仍足以隐藏延迟(每个 warp scheduler 有 12-13 个 warp 可切换),因此粗化一般净赚。3D 卷积里同样的手法沿 z 方向做(示例 3 用 COARSE_Z = 2)。
概念 7:卷积的算术强度与 Roofline 分析(Arithmetic Intensity & Roofline)
定义与目的:算术强度
AI = 浮点操作数 / 从 DRAM 搬运的字节数(单位 FLOP/Byte)。Roofline 模型说:一个 kernel 的性能上界是min(峰值算力, AI × 显存带宽)。把这两者写在一起就能回答”这个卷积是算力受限还是带宽受限”以及”我离硬件极限还差多远”,从而决定优化方向(减少访存 vs 提高并行度)。直观解释(”它是什么?”):把 GPU 想成一家餐厅。厨师的做菜速度(算力,19.5 TFLOP/s)是固定的;服务员从仓库搬食材的速度(带宽,1555 GB/s)也是固定的。每道菜需要多少食材、多少厨师工时,决定了瓶颈在谁身上。如果每搬 1 字节食材只够厨师做 0.5 次操作(AI 很低),那厨师一直在等食材——带宽受限;反之 AI 很高时,服务员闲着、厨师忙不过来——计算受限。两条线的交点叫机器平衡点(machine balance),A100 上是
19.5e12 / 1555e9 = 12.5 FLOP/Byte。架构/机制图解:卷积的 Roofline 图(以 A100 为基准机):
性能 (GFLOP/s, log)
^
19.5|--------------------------------------------+--- A100 FP32 峰值 19.5 TFLOP/s
| |
| ****| 分块 2D (TILE=32, MW=9):
10.0| **** | AI = 18 FLOP/B > 12.5
| **** | -> 计算受限 (被峰值截断)
| **** |
5.0| **** o 分块 2D (TILE=16, MW=5): AI = 8.0 FLOP/B
| **** 上限 8.0 x 1555 = 12.4 TFLOP/s (带宽受限)
2.0| **** * 分块 1D (TILE=256, MW=5): AI = 2.5 -> 上限 3.9 TFLOP/s
| ****
0.78|* 朴素 2D: AI = 0.5 FLOP/B -> 上限 0.78 TFLOP/s (峰值的 4%)
+----+-----+-----+-----+-----+-----+-----+----> 算术强度 (FLOP/Byte, log)
0.5 1 2 4 8 12.5 20 32
^
机器平衡点 = 19.5e12/1555e9 = 12.5 FLOP/Byte
斜线 (****) 的斜率 = 1555 GB/s = 带宽斜坡
落在斜线上 = 带宽受限; 落在平线上 = 计算受限
参考: RTX 4090 的机器平衡点 = 82.6e12/1008e9 = 81.9 FLOP/Byte (斜坡陡得多,
因此 4090 上卷积更容易变成带宽受限, 分块的价值更大)
逐项推导(1D、2D 都算一遍,数字与讲义一致):
(1) 1D 朴素卷积的算术强度
每个输出 P[i] 需要:
MASK_WIDTH 次 N 的加载 (每次 4 字节, 来自全局内存)
MASK_WIDTH 次 M 的加载 (每次 4 字节, 来自常量缓存 -> 不算 DRAM 流量)
2 * MASK_WIDTH 次浮点操作 (1 乘 + 1 加, 每个 j 各一次)
=> 每输出 DRAM 字节 = 4 * MASK_WIDTH, 每输出 FLOP = 2 * MASK_WIDTH
=> AI = 2*MW / (4*MW) = 0.5 FLOP/Byte (讲义写作 2B/FLOP, 完全一致)
若把 M 的读取也算进"访问次数", 则是 2*MASK_WIDTH 次访存 / 2*MASK_WIDTH FLOP
=> 约 1 FLOP/Byte 的重访量级, 但真正打到 DRAM 的只有 N。
(2) 2D 朴素卷积
每输出 FLOP = 2 * MW^2 = 2*25 = 50 (MW=5)
每输出 DRAM 字节 = 4 * MW^2 = 100 字节 (25 次 4 字节加载)
=> AI = 50/100 = 0.5 FLOP/Byte
在 A100 上带宽能支持的算力 = 0.5 * 1555 GB/s = 0.78 TFLOP/s
= 19.5 TFLOP/s 峰值的 4.0% -> 剩下 96% 的算力全在等内存
(3) 分块 2D 卷积 (TILE=16, MW=5)
减少倍数 R = 16.0
=> 每输出 DRAM 字节 = 100/16 = 6.25 字节
=> AI = 50/6.25 = 8.0 FLOP/Byte
带宽可支撑算力 = 8.0 * 1555 GB/s = 12.4 TFLOP/s = 峰值的 63.7%
仍然略偏带宽受限, 但已经接近平衡点
(4) 分块 2D 卷积 (TILE=32, MW=9)
R = 51.8 -> 每输出字节 = 4*81/51.8 = 6.25 字节
=> AI = 2*81/6.25 = 25.9 FLOP/Byte > 12.5 -> 计算受限 (理论上限 = 峰值)
(5) 极限分析: 每输出 DRAM 字节的下限是 4 字节 (每个输入元素至少要被搬一次,
且要被写出的输出元素也有 4 字节)。因此
AI_max(2D) = 2*MW^2 / 4 = MW^2/2 = 12.5 FLOP/Byte (MW=5)
AI_max(3D) = 2*MW^3 / 4 = MW^3/2 = 62.5 FLOP/Byte (MW=5)
也就是说 MW=5 的 2D 卷积在 A100 上"无论怎么优化"都只能做到平衡点附近,
要真正达到计算受限必须用更大的 mask 或批量处理。这解释了讲义 "Need Really
Big Mask to Balance Resources" 那张表: 1D 卷积要到 MASK_WIDTH=55 才能
在 5000 GFLOP/s / 192 GB/s 的老 GPU 上跑满算力。
(6) 讲义的历史数字 (说明"机器平衡点随时间变化, 复用需求越来越苛刻"):
2010 年: 1000 GFLOP/s 算力 vs 150 GB/s 带宽 -> 0.5*150 = 7.5% of peak
-> 需要 100/7.5 = 13.3x 复用才能跑满
2020 年: 5000 GFLOP/s (GRID K520) vs 192 GB/s -> 1.92% of peak
-> 需要 100/1.92 = 52.1x 复用
2023 年: H100 PCIe 26 TFLOP/s vs 2 TB/s -> 3.85% of peak
-> 需要 100/3.85 = 25.97x 复用
本课程基准机 A100: 19.5 TFLOP/s vs 1555 GB/s -> 5.0% of peak
-> 需要 100/5.0 = 20x 复用
(注: 讲义用的是"每 FLOP 2 字节"的朴素卷积口径, 即 AI = 0.5 FLOP/B。)
性能特征:100% 峰值只可能在 AI > 机器平衡点 时达到。对 A100 + 5×5 mask 的 2D 卷积,理论极限 AI 是 12.5 FLOP/Byte,刚好等于平衡点 12.5,因此即使做到完美分块也只能跑在平衡点附近(约 16-20 TFLOP/s,且实际能到 5-10 TFLOP/s 已属优秀)。3D 卷积因为 AI_max = MW³/2 = 62.5,far 高于平衡点,理论上更容易计算受限——但共享内存带宽与寄存器压力会成为新的瓶颈(见示例 3 的分析)。
概念 8:3D 卷积的特殊性(3D Convolution,Lab 4 / MP-4)
定义与目的:3D 卷积把窗口延伸到三个方向:
P[z][y][x] = ΣΣΣ M[i][j][k]·N[z-r+i][y-r+j][x-r+k]。它在 ECE408 的实验里是 Lab 4: 3D Convolution(MP-4)(课程目录页写作 Lab 6 - Tiled Parallel Convolution),要求对三维输入数组做M×M×Mmask 的卷积,输出尺寸为(W-M+1)×(H-M+1)×(D-M+1)(Lab 约定),需要用共享内存分块把性能做到接近带宽上限。3D 的必要性来自体数据:医学 CT/MRI、流体力学、地震成像、以及 3D CNN 都是天然三维的。直观解释(”它是什么?”):2D 卷积像在一张豆腐皮上盖方章;3D 卷积像在一整块豆腐里切一个立方体窗口。麻烦有三:第一,halo 变成了一个立体的”壳”,而不是一圈”边框”——壳的体积占比比边框大得多;第二,数据局部性更差,因为三维数组在内存里是按
(z,y,x)线性化的,沿 z 相邻的两个元素在内存里隔了W*H*4字节(例如128×128×4 = 64 KB),一次 warp 的内层循环如果沿 z 走就完全无法合并;第三,每个线程的窗口有M³ = 125个元素,如果都摊在寄存器里会直接爆掉 255 个寄存器的上限。架构/机制图解:3D 输入 tile 与 halo(
TILE_X=TILE_Y=TILE_Z=8,MASK_W=5,r=2):
3D 输入 tile: Ns[SZ=12][SY=12][SX=12] = 12*12*12 = 1728 个 float = 6912 字节
沿 z 方向看 (SZ = TILE_Z + 2r = 8 + 4 = 12 个切片, 每个切片自身是 12 x 12):
+--------+--------++--------+--------+--------+--------+--------+--------+--------+--------++--------+--------+
|slice 0 |slice 1 ||slice 2 |slice 3 |slice 4 |slice 5 |slice 6 |slice 7 |slice 8 |slice 9 ||slice10 |slice11 |
+--------+--------++--------+--------+--------+--------+--------+--------+--------+--------++--------+--------+
\______/ \____________________________________________________________________/ \______/
z 左 halo 中心 TILE_Z = 8 个输出切片 (slice 2 ~ slice 9) z 右 halo
(2 片) (= 输出 oz 的 0..7) (2 片)
tile 中 z 下标的对应关系: 输出 oz 的第 i 个 mask 元素落在 tile 的 slice (j + i) 上,
其中 j = oz - blockIdx.z*TILE_Z, 所以 j + i 的取值范围是 0..(8-1+5-1) = 0..11。 [见示例 3 代码]
每个切片 (12 x 12) 的内部结构:
lx=0 1 2 9 10 11
+-------+-----------------------------------+-------+-------+
ly=0 | H H H H H H H H H | H H H | ^
| H H H H H H H H H | H H H | | r=2
+-------+-----------------------------------+-------+-------+ v
| H H +-------------------------------+ H H H |
| H H | C C C C C C C | H H H | ^
| H H | C C C C C C C | H H H | |
| H H | 中心 TILE_Y = 8 行 x TILE_X = 8 列 | H H H | | 8
| H H | 本 block 在此产生 8x8 = 64 个输出 | H H H | | (每片)
| H H | (z 方向共 8 片 -> block 共 512 输出)| H H H | |
| H H +-------------------------------+ H H H | v
+-------+-----------------------------------+-------+-------+
| H H H H H H H H H | H H H | ^
| H H H H H H H H H | H H H | | r=2
+-------+-----------------------------------+-------+-------+ v
<-- 2 --><------------- 8 -----------------><-- 2 -->
tile 体积 = (TILE+2r)^3 = 12^3 = 1728; 中心 = 8^3 = 512
halo 体积 = 1728 - 512 = 1216 个 float (占 70.4%!)
halo 放大比 A = (8+4)^3/8^3 = 1728/512 = 3.375 (多加载 237.5%)
访问减少倍数 R = 512*125/1728 = 64000/1728 = 37.04x
对比 2D (TILE=16, MW=5): halo 放大 1.5625, 减少倍数 16x
=> 3D 的 halo 冗余大得多, 但 float 复用次数(125)也大得多, 净收益仍然显著。
三维数组的内存线性化 (按 (z,y,x) 行主序):
idx = x + y*W + z*W*H
沿 x 相邻: +1 (连续, 可合并)
沿 y 相邻: +W (间隔 W*4 字节)
沿 z 相邻: +W*H (间隔 W*H*4 字节; W=H=128 时 = 64 KB, 完全不合并且跨页)
3D 卷积的四个额外难点与对策:
+----------------------+----------------------------+---------------------------------+
| 难点 | 具体数字 (TILE=8, MW=5) | 对策 |
+----------------------+----------------------------+---------------------------------+
| halo 体积爆炸 | halo 占 tile 的 70.4% | 增大 TILE: TILE=16 时 A = |
| | A = 1728/512 = 3.375 | 20^3/16^3 = 8000/4096 = 1.953 |
| | (多加载 237.5%) | (halo 占 48.8%), 减少倍数从 37x |
| | | 升到 64x; 但共享内存要 32 KB/block|
| 共享内存占用 | 12^3 * 4B = 6912 B / block | A100 有 164 KB/SM -> 最多 23 个 |
| | | block; 用 padding 后 7488 B |
| 访存局部性更差 | 沿 z 步长 = W*H*4 = 64 KB | 让加载循环沿 x 连续 (合并访问), |
| | 远超一条 128B cache line | z 只作为外层索引 |
| 寄存器压力 | 125 个 mask 系数 + 累加器 | mask 放 __constant__ (125*4 = |
| | 不可能全放寄存器 | 500 B << 64 KB); 累加器只留 1-4 个|
| 维度多导致索引复杂 | 3 层循环 + 3 个边界判断 | 用 cudaMalloc3D/cudaPitchedPtr |
| | | 或一维展平 idx = x+y*W+z*W*H |
+----------------------+----------------------------+---------------------------------+
cudaMalloc3D / cudaMemcpy3D / cudaPitchedPtr 的用法(Lab 4 的另一种数据组织方式,也是 CUDA 处理 3D 数组的官方接口):
/* 方式一: 一维展平 (示例 3 采用, 简单直观)
idx = x + y*W + z*W*H; cudaMalloc(&d_N, W*H*D*sizeof(float));
一次 cudaMemcpy 传输 W*H*D 个 float。 */
/* 方式二: cudaMalloc3D + cudaPitchedPtr (对行首对齐更友好) */
cudaExtent extent = make_cudaExtent(W * sizeof(float), H, D);
cudaPitchedPtr d_Np; /* 内含 ptr / pitch / xsize / ysize */
CUDA_CHECK(cudaMalloc3D(&d_Np, extent)); /* 硬件会把 pitch 对齐到 512 字节 */
cudaMemcpy3DParms p = {0};
p.srcPtr = make_cudaPitchedPtr((void *)h_N, W * sizeof(float), W, H);
p.dstPtr = d_Np;
p.extent = extent;
p.kind = cudaMemcpyHostToDevice;
CUDA_CHECK(cudaMemcpy3D(&p)); /* 一次调用完成 W*H*D 的 H2D 拷贝 */
/* kernel 中访问 (x, y, z): 先用 int Wp = d_Np.pitch / sizeof(float); 得到行长,
然后 index = z * Wp * H + y * Wp + x。 pitch >= W*4, 多出的填充字节不可读。 */
cudaPitchedPtr 的核心是 pitch(每行字节数,即”从第 y 行第 0 列到第 y+1 行第 0 列的字节数”),它保证每一行的起始地址都满足对齐要求,从而使沿 x 的 warp 访问能整齐地落在 128 字节事务里。代价是 kernel 里不能再用 y*W + x,必须用 y*(pitch/4) + x;忘记这一点是 Lab 4 最常见的 bug(数据整体错位,验证全 FAIL,但 kernel 本身不报错)。
代码示例与性能分析
示例 1:朴素 2D 卷积 + 常量内存存放 mask(conv2d_naive_constant.cu)
// 文件: conv2d_naive_constant.cu
// 编译: nvcc -O3 -arch=sm_80 conv2d_naive_constant.cu -o conv2d_naive
// 运行: ./conv2d_naive # 默认 2048x2048 输入, 5x5 mask
// ./conv2d_naive 1024 1024 # 指定输入尺寸
//
// 功能: 朴素(未分块) 2D 卷积, 每个线程计算一个输出元素。
// - 5x5 卷积核放在 __constant__ 常量内存 Mc 中, 用 cudaMemcpyToSymbol 初始化
// - 输出尺寸与输入相同(centered 形式), 越界的 ghost 元素按 0 处理 (zero padding)
// - 打印耗时 / GB/s / GFLOP/s, 并与 CPU 参考实现逐元素对比
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <cuda_runtime.h>
#define MASK_WIDTH 5
#define MASK_RADIUS (MASK_WIDTH / 2)
/* 常量内存: 文件作用域声明, 所有 kernel 可见, 上限 64 KB */
static __constant__ float Mc[MASK_WIDTH * MASK_WIDTH];
#define CUDA_CHECK(call) \
do { \
cudaError_t err__ = (call); \
if (err__ != cudaSuccess) { \
fprintf(stderr, "CUDA error %s:%d: %s\n", __FILE__, __LINE__, \
cudaGetErrorString(err__)); \
exit(EXIT_FAILURE); \
} \
} while (0)
/* ------------------------- GPU: 朴素 2D 卷积 ------------------------- */
__global__ void conv2d_naive_kernel(const float * __restrict__ N,
float * __restrict__ P,
int Width, int Height)
{
int col = blockIdx.x * blockDim.x + threadIdx.x;
int row = blockIdx.y * blockDim.y + threadIdx.y;
if (row >= Height || col >= Width) return; /* 输出越界线程直接退出 */
float Pvalue = 0.0f;
#pragma unroll
for (int i = 0; i < MASK_WIDTH; ++i) {
int nrow = row - MASK_RADIUS + i;
if (nrow < 0 || nrow >= Height) continue; /* ghost 行 -> 视作 0 */
#pragma unroll
for (int j = 0; j < MASK_WIDTH; ++j) {
int ncol = col - MASK_RADIUS + j;
if (ncol < 0 || ncol >= Width) continue;/* ghost 列 -> 视作 0 */
Pvalue += N[nrow * Width + ncol] * Mc[i * MASK_WIDTH + j];
}
}
P[row * Width + col] = Pvalue;
}
/* 主机端 mask 副本, 供 CPU 参考实现使用 (与 device 端 Mc 内容一致) */
static float h_Mck[MASK_WIDTH * MASK_WIDTH];
/* ------------------------- CPU 参考实现 ------------------------- */
static void conv2d_cpu_reference(const float *N, float *P, int Width, int Height)
{
for (int row = 0; row < Height; ++row) {
for (int col = 0; col < Width; ++col) {
float sum = 0.0f;
for (int i = 0; i < MASK_WIDTH; ++i) {
int nrow = row - MASK_RADIUS + i;
if (nrow < 0 || nrow >= Height) continue;
for (int j = 0; j < MASK_WIDTH; ++j) {
int ncol = col - MASK_RADIUS + j;
if (ncol < 0 || ncol >= Width) continue;
sum += N[nrow * Width + ncol] *
h_Mck[i * MASK_WIDTH + j];
}
}
P[row * Width + col] = sum;
}
}
}
/* 主机端 mask 副本 (已在 CPU 参考实现之前声明, 见上) */
int main(int argc, char **argv)
{
int Width = (argc > 1) ? atoi(argv[1]) : 2048;
int Height = (argc > 2) ? atoi(argv[2]) : 2048;
size_t nElem = (size_t)Width * Height;
printf("=== 朴素 2D 卷积 (常量内存 mask) ===\n");
printf("输入尺寸 %d x %d, mask %d x %d\n", Width, Height, MASK_WIDTH, MASK_WIDTH);
/* 1. 主机端分配与初始化 */
float *h_N = (float *)malloc(nElem * sizeof(float));
float *h_P = (float *)malloc(nElem * sizeof(float));
float *h_Pref = (float *)malloc(nElem * sizeof(float));
if (!h_N || !h_P || !h_Pref) { fprintf(stderr, "host malloc failed\n"); return 1; }
srand(1234);
for (size_t i = 0; i < nElem; ++i) h_N[i] = (float)(rand() % 16);
/* 2. 初始化 mask: 用经典的 5x5 高斯近似模糊核 */
const float mk[MASK_WIDTH * MASK_WIDTH] = {
1, 4, 6, 4, 1,
4, 16, 24, 16, 4,
6, 24, 36, 24, 6,
4, 16, 24, 16, 4,
1, 4, 6, 4, 1
};
float msum = 0.0f;
for (int i = 0; i < MASK_WIDTH * MASK_WIDTH; ++i) msum += mk[i];
for (int i = 0; i < MASK_WIDTH * MASK_WIDTH; ++i) {
h_Mck[i] = mk[i] / msum; /* 归一化, 保证模糊不改变整体亮度 */
}
/* 3. 把 mask 拷进常量内存: 必须用 cudaMemcpyToSymbol, 不能用 cudaMemcpy */
CUDA_CHECK(cudaMemcpyToSymbol(Mc, h_Mck,
MASK_WIDTH * MASK_WIDTH * sizeof(float)));
/* 4. 设备端分配 + 传输输入 */
float *d_N = NULL, *d_P = NULL;
CUDA_CHECK(cudaMalloc((void **)&d_N, nElem * sizeof(float)));
CUDA_CHECK(cudaMalloc((void **)&d_P, nElem * sizeof(float)));
CUDA_CHECK(cudaMemcpy(d_N, h_N, nElem * sizeof(float), cudaMemcpyHostToDevice));
/* 5. 启动配置: 16x16 的二维 block */
dim3 block(16, 16);
dim3 grid((Width + block.x - 1) / block.x, (Height + block.y - 1) / block.y);
printf("grid = (%u, %u), block = (%u, %u) = %u 线程/block, %u warp/block\n",
grid.x, grid.y, block.x, block.y, block.x * block.y,
(block.x * block.y + 31) / 32);
/* 6. 预热 + 计时 (5 次取平均, 用 cudaEvent 计时) */
conv2d_naive_kernel<<<grid, block>>>(d_N, d_P, Width, Height);
CUDA_CHECK(cudaGetLastError());
CUDA_CHECK(cudaDeviceSynchronize());
cudaEvent_t t0, t1;
CUDA_CHECK(cudaEventCreate(&t0));
CUDA_CHECK(cudaEventCreate(&t1));
const int REPEAT = 5;
CUDA_CHECK(cudaEventRecord(t0));
for (int r = 0; r < REPEAT; ++r)
conv2d_naive_kernel<<<grid, block>>>(d_N, d_P, Width, Height);
CUDA_CHECK(cudaEventRecord(t1));
CUDA_CHECK(cudaEventSynchronize(t1));
CUDA_CHECK(cudaGetLastError());
float ms = 0.0f;
CUDA_CHECK(cudaEventElapsedTime(&ms, t0, t1));
ms /= REPEAT;
/* 7. 性能指标: FLOP 数 = 2 * MASK_WIDTH^2 * 输出元素数 */
double flops = 2.0 * MASK_WIDTH * MASK_WIDTH * (double)nElem;
double bytes = 2.0 * (double)nElem * sizeof(float); /* 至少读写一遍输入/输出 */
printf("耗时 : %.3f ms\n", ms);
printf("GFLOP/s : %.2f\n", flops / (ms * 1e6));
printf("有效带宽 : %.2f GB/s (按 (读入+写出) 计算)\n",
bytes / (ms * 1e6));
/* 8. 取回结果并与 CPU 参考实现对比 */
CUDA_CHECK(cudaMemcpy(h_P, d_P, nElem * sizeof(float), cudaMemcpyDeviceToHost));
conv2d_cpu_reference(h_N, h_Pref, Width, Height);
double maxErr = 0.0;
for (size_t i = 0; i < nElem; ++i) {
double e = fabs((double)h_P[i] - (double)h_Pref[i]);
if (e > maxErr) maxErr = e;
}
printf("最大绝对误差 : %.3e\n", maxErr);
printf("验证结果 : %s\n", (maxErr < 1e-3) ? "PASS" : "FAIL");
CUDA_CHECK(cudaEventDestroy(t0));
CUDA_CHECK(cudaEventDestroy(t1));
CUDA_CHECK(cudaFree(d_N));
CUDA_CHECK(cudaFree(d_P));
free(h_N); free(h_P); free(h_Pref);
return 0;
}
- 【代码做什么?】
- 准备 mask 并送进常量内存:主机端用
1,4,6,4,1 / 4,16,24,16,4 / 6,24,36,24,6 / 4,16,24,16,4 / 1,4,6,4,1这 5 行数字构成经典高斯核(除以总和 256 归一化,得到 5×5 的模糊核),然后用cudaMemcpyToSymbol(Mc, h_Mck, 100)把它从主机内存拷进设备端常量内存。这一步只需一次,100 字节的开销可忽略。注意Mc声明在文件作用域、static __constant__,kernel 直接按名字访问它,不需要把它作为参数传入(讲义原话:note that file-scope Mc is visible to kernel)。 - 分配并初始化:
cudaMalloc两块Width*Height*4字节的设备内存d_N、d_P,cudaMemcpy把输入从主机拷到设备。 - 线程映射:
dim3 block(16,16)共 256 个线程;grid = (Width/16, Height/16)。线程(threadIdx.x, threadIdx.y)负责输出P[row*Width + col],其中col = blockIdx.x*16 + threadIdx.x、row = blockIdx.y*16 + threadIdx.y。这是最简单的”一个线程一个输出”映射。 - 计算:外层
i遍历 mask 的 5 行,nrow = row - 2 + i;若越界则continue(等价于该行贡献 0)。内存j遍历 5 列,同样越界continue。内层累计Pvalue += N[nrow*Width + ncol] * Mc[i*5+j]。 - 写回与验证:
P[row*Width+col] = Pvalue一次写入;主机取回h_P,用三重循环的 CPU 参考实现(用同一份归一化 mask 的主机副本h_Mck)逐元素对比,误差阈值1e-3。 - 计时:先跑一次预热,再用
cudaEventRecord包裹 5 次 kernel 执行,取平均毫秒数,换算 GFLOP/s 和有效带宽。
- 准备 mask 并送进常量内存:主机端用
- 【并行机制与硬件映射解说】
- warp 与调度:block 16×16 = 256 线程 = 8 个 warp。CUDA 按
threadIdx.x最快变化线性化,所以 warp w 覆盖 ty = 2w 与 ty = 2w+1 两行、每行 16 个 lane。A100 每个 SM 有 4 个 warp scheduler,每个 scheduler 每周期可发射 1 条 warp 指令;本 kernel 寄存器用量约 24 个/线程(25 次 FMA 的累加器 1 个 + 地址/循环变量),因此65536 寄存器 / (256 线程 × 24) = 10.6 → 10 个 block,但受”每 SM 最多 2048 线程”限制,实际驻留2048/256 = 8个 block = 64 个 warp = 100% 占用率(A100 每 SM 上限 64 warp)。共享内存用量为 0,不构成限制。 - 常量内存广播(本示例的核心机制):内层循环第
j次迭代时,warp 里 32 个 lane 计算的地址表达式都是Mc[i*5+j]——同一个地址。常量缓存因此每周期只做 1 次广播,代价等价于 1 次标量读(约 5 个时钟周期),而不是 32 次独立读。一个线程总共读 25 次Mc(i、j 各 5),全部走广播路径;如果把 mask 放在全局内存,这 25 次读取每次都会产生一条 32 lane 访问同一地址的 128B transaction,只用了其中 4 字节(利用率 3.1%),并且要走 L1/L2 甚至 DRAM(延迟 400-800 周期)。这就是常量内存对本 kernel 的决定性贡献。 - 全局内存读取的合并性:
N[nrow*Width + ncol]。一个 warp 的 32 个 lane 中,lane 0-15 的nrow相同、ncol连续 16 个(64 字节),lane 16-31 在下一行(地址相差Width*4 = 8192字节)。因此每次迭代产生 2 条 64 字节的连续段:若ncol起始地址按 8 字节偏移(因为col - 2),这两段各自可能跨越 1 条 128B cache line 的边界,实际需要 2-3 条 128B 事务来传 128 字节有效数据。由于i与j的偏移只改变 2 个元素,相邻迭代访问高度重叠,L1/L2 命中率极高(典型的空间/时间局部性都满足)。 - 输出写回是否合并:
P[row*Width + col],warp 内两行各 16 个 lane 连续 → 两条 64 字节的连续段。当Width是 32 的倍数时(2048 是),每段落在一条 128B cache line 内,写回是完全合并的(每条 128B 事务一半有用,需要 2 条事务覆盖 128 字节数据)。若Width不是 32 的倍数(如 1000),行首不对齐会让每段跨 2 条 cache line,写回事务数翻倍——这是”输入宽度最好取 32 的倍数”的实际理由。 - warp 发散(边界分支):
if (nrow < 0 \|\| nrow >= Height) continue;与内层的列判断对于内部 block 从不触发(无发散),但对边界 block 会产生谓词屏蔽。以一个5×5mask、16×16 block 的图像左上角 block 为例:tx ∈ {0,1}或tx ∈ {14,15}或ty ∈ {0,1}或ty ∈ {14,15}的线程窗口越界,共4*16*2 - 4*4 = 112个线程(占 43.75%)。按 warp 看:warp 0(ty=0,1)与 warp 7(ty=14,15)的 32 个 lane 全部越界,它们的Pvalue +=被整体屏蔽;warp 1-6 每行两端的 4 个 lane 越界,即每个 warp 有 8/32 = 25% 的 lane 被屏蔽。整幅 1024×1024 图中边界 block 只有4096 - 62*62 = 252个(6.15%),所以发散对总吞吐影响有限;真正的代价是 25 次循环里每轮都要算一遍谓词与地址(约 50 条额外指令 vs 25 条 FMA),使 IPC 效率减半。 - 寄存器与依赖链:
Pvalue是一条 25 次串行 FMA 依赖链。A100 FP32 FMA 延迟约 4 个周期,25 × 4 = 100 周期的关键路径。靠 8 个 block × 8 warp = 64 warp 的并发切换即可完全隐藏(每个 scheduler 有 16 个 warp 可轮换)。
- warp 与调度:block 16×16 = 256 线程 = 8 个 warp。CUDA 按
- 【性能优化分析】
- 算术强度:
AI = 2*MW² / (4*MW²) = 0.5 FLOP/Byte(每输出 50 次 FLOP、至少要下沉 25 次 4 字节读)。A100 的机器平衡点是19.5e12/1555e9 = 12.5 FLOP/Byte,本 kernel 的 AI 只有平衡点的 1/25,因此它在 Roofline 图上远远落在带宽斜坡的最低端。 - 带宽上限:
0.5 × 1555 GB/s = 0.78 TFLOP/s,即峰值的 4.0%。以 2048×2048 输入(4.19M 输出、209.7 MFLOP)为例:即使 DRAM 流量被完美压缩到”读一遍 + 写一遍 = 33.6 MB”,理想时间也只有33.6e6/1555e9 = 21.6 µs;而 25 次读中大部分命中的是 L2(A100 L2 带宽约 5-6 TB/s),L2 实际流量为4.19M × 25 × 4B = 419 MB,对应419e6/5.5e12 = 76 µs——这才是朴素版的真实下界,说明它既受 L2 带宽限制、又受大量 LDG 指令与地址计算拖累。A100 上这类 kernel 的典型观测耗时是 180-260 µs(约 0.8-1.2 TFLOP/s,有效带宽 130-190 GB/s)。 - 占用率:100%(8 block/SM × 256 线程 = 2048 线程 = 64 warp),寄存器与共享内存都不限制。占用率不是瓶颈——这类”高占用率 + 低算术强度”的组合是典型的带宽受限特征。
- 判定结论:内存带宽受限(且被 L2 带宽与指令开销共同压低)。优化方向按收益排序:(1) 用共享内存分块把 25 次输入读减少到
400/256 = 1.56次(示例 2);(2) 把边界分支从计算循环里挪出去(同样由示例 2 完成);(3) 用线程粗化摊薄常量读与地址计算(示例 2 的第二个 kernel);(4) 用float4向量化加载进一步减少指令数。
- 算术强度:
示例 2:分块 2D 卷积(含 halo 协作加载 + 线程粗化)(conv2d_tiled_halo.cu)
// 文件: conv2d_tiled_halo.cu
// 编译: nvcc -O3 -arch=sm_80 conv2d_tiled_halo.cu -o conv2d_tiled
// 运行: ./conv2d_tiled # 默认 2048x2048 输入, 5x5 mask
// ./conv2d_tiled 1024 1024
//
// 功能: 在同一份程序里实现并对比三个版本:
// (A) conv2d_naive_kernel 朴素版 (对照, 与示例 1 相同)
// (B) conv2d_tiled_kernel 分块版: block=(20x20), 共享内存 20x20 含 halo,
// 每线程搬 1 个元素, 前 256 个线程各算 1 个输出
// (C) conv2d_tiled_coarse_kernel 分块+线程粗化: block=(8x8), 每线程算 2x2=4 个输出
// 三者输出全部与 CPU 参考实现比对, 并打印加速比
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <cuda_runtime.h>
#define TILE_WIDTH 16
#define MASK_WIDTH 5
#define MASK_RADIUS (MASK_WIDTH / 2)
#define IN_TILE (TILE_WIDTH + MASK_WIDTH - 1) /* 20: 输入 tile 的一边 */
#define COARSE 2 /* 粗化: 每线程 2x2 个输出 */
static __constant__ float Mc[MASK_WIDTH * MASK_WIDTH];
static float h_Mck[MASK_WIDTH * MASK_WIDTH];
#define CUDA_CHECK(call) \
do { \
cudaError_t err__ = (call); \
if (err__ != cudaSuccess) { \
fprintf(stderr, "CUDA error %s:%d: %s\n", __FILE__, __LINE__, \
cudaGetErrorString(err__)); \
exit(EXIT_FAILURE); \
} \
} while (0)
/* ---------------- (A) 朴素版: 一个线程一个输出, 边界分支 ---------------- */
__global__ void conv2d_naive_kernel(const float * __restrict__ N,
float * __restrict__ P,
int Width, int Height)
{
int col = blockIdx.x * blockDim.x + threadIdx.x;
int row = blockIdx.y * blockDim.y + threadIdx.y;
if (row >= Height || col >= Width) return;
float Pvalue = 0.0f;
#pragma unroll
for (int i = 0; i < MASK_WIDTH; ++i) {
int nrow = row - MASK_RADIUS + i;
if (nrow < 0 || nrow >= Height) continue;
#pragma unroll
for (int j = 0; j < MASK_WIDTH; ++j) {
int ncol = col - MASK_RADIUS + j;
if (ncol < 0 || ncol >= Width) continue;
Pvalue += N[nrow * Width + ncol] * Mc[i * MASK_WIDTH + j];
}
}
P[row * Width + col] = Pvalue;
}
/* ---------------- (B) 分块版: halo 协作加载 + 无分支计算 ---------------- */
__global__ void conv2d_tiled_kernel(const float * __restrict__ N,
float * __restrict__ P,
int Width, int Height)
{
__shared__ float Ns[IN_TILE][IN_TILE]; /* 20 x 20 = 400 float = 1600 B */
const int tx = threadIdx.x; /* 0..19 */
const int ty = threadIdx.y; /* 0..19 */
/* 该线程负责的输入坐标 (block 的左上角是输出瓦片左上角再向左上各推 radius) */
const int row_i = blockIdx.y * TILE_WIDTH - MASK_RADIUS + ty;
const int col_i = blockIdx.x * TILE_WIDTH - MASK_RADIUS + tx;
/* 1. 协作加载 (TILE+2r)^2 个元素, 越界元素一律填 0 (zero padding) */
float v = 0.0f;
if (row_i >= 0 && row_i < Height && col_i >= 0 && col_i < Width)
v = N[row_i * Width + col_i];
Ns[ty][tx] = v; /* 分支之外: 所有线程都参与 */
__syncthreads(); /* 等整块 tile 就绪, 必须在分支外 */
/* 2. 前 TILE x TILE 个线程各算一个输出 (计算阶段无任何分支) */
if (ty < TILE_WIDTH && tx < TILE_WIDTH) {
const int row_o = blockIdx.y * TILE_WIDTH + ty;
const int col_o = blockIdx.x * TILE_WIDTH + tx;
if (row_o < Height && col_o < Width) {
float Pvalue = 0.0f;
#pragma unroll
for (int i = 0; i < MASK_WIDTH; ++i)
#pragma unroll
for (int j = 0; j < MASK_WIDTH; ++j)
Pvalue += Ns[ty + i][tx + j] * Mc[i * MASK_WIDTH + j];
P[row_o * Width + col_o] = Pvalue;
}
}
}
/* ---------------- (C) 分块 + 线程粗化: 每线程 2x2 = 4 个输出 ---------------- */
__global__ void conv2d_tiled_coarse_kernel(const float * __restrict__ N,
float * __restrict__ P,
int Width, int Height)
{
__shared__ float Ns[IN_TILE][IN_TILE];
const int tx = threadIdx.x; /* 0..7 */
const int ty = threadIdx.y; /* 0..7 */
const int tid = ty * blockDim.x + tx; /* 0..63 */
const int nth = blockDim.x * blockDim.y; /* 64 */
const int row_b = blockIdx.y * TILE_WIDTH - MASK_RADIUS;
const int col_b = blockIdx.x * TILE_WIDTH - MASK_RADIUS;
/* 1. 64 个线程协作搬 400 个元素 (每个线程 6-7 次, 循环步长 = 线程数) */
for (int l = tid; l < IN_TILE * IN_TILE; l += nth) {
const int ly = l / IN_TILE;
const int lx = l - ly * IN_TILE;
const int gr = row_b + ly;
const int gc = col_b + lx;
float v = 0.0f;
if (gr >= 0 && gr < Height && gc >= 0 && gc < Width)
v = N[gr * Width + gc];
Ns[ly][lx] = v;
}
__syncthreads();
/* 2. 每个线程算 2x2 个输出, 4 个累加器常驻寄存器 */
float acc[COARSE][COARSE];
#pragma unroll
for (int dy = 0; dy < COARSE; ++dy)
#pragma unroll
for (int dx = 0; dx < COARSE; ++dx)
acc[dy][dx] = 0.0f;
const int oy0 = ty * COARSE; /* tile 内的局部输出行 */
const int ox0 = tx * COARSE; /* tile 内的局部输出列 */
#pragma unroll
for (int i = 0; i < MASK_WIDTH; ++i) {
#pragma unroll
for (int j = 0; j < MASK_WIDTH; ++j) {
const float m = Mc[i * MASK_WIDTH + j]; /* 1 次常量读喂 4 个 FMA */
#pragma unroll
for (int dy = 0; dy < COARSE; ++dy)
#pragma unroll
for (int dx = 0; dx < COARSE; ++dx)
acc[dy][dx] += Ns[oy0 + dy + i][ox0 + dx + j] * m;
}
}
#pragma unroll
for (int dy = 0; dy < COARSE; ++dy) {
#pragma unroll
for (int dx = 0; dx < COARSE; ++dx) {
const int row_o = blockIdx.y * TILE_WIDTH + oy0 + dy;
const int col_o = blockIdx.x * TILE_WIDTH + ox0 + dx;
if (row_o < Height && col_o < Width)
P[row_o * Width + col_o] = acc[dy][dx];
}
}
}
/* ---------------- CPU 参考实现 ---------------- */
static void conv2d_cpu_reference(const float *N, float *P, int Width, int Height)
{
for (int row = 0; row < Height; ++row) {
for (int col = 0; col < Width; ++col) {
float sum = 0.0f;
for (int i = 0; i < MASK_WIDTH; ++i) {
int nrow = row - MASK_RADIUS + i;
if (nrow < 0 || nrow >= Height) continue;
for (int j = 0; j < MASK_WIDTH; ++j) {
int ncol = col - MASK_RADIUS + j;
if (ncol < 0 || ncol >= Width) continue;
sum += N[nrow * Width + ncol] * h_Mck[i * MASK_WIDTH + j];
}
}
P[row * Width + col] = sum;
}
}
}
/* 计时辅助: 通过函数指针启动 kernel 必须用 cudaLaunchKernel
(<<<>>> 语法不能直接作用于函数指针) */
static float time_kernel(void (*kern)(const float *, float *, int, int),
dim3 grid, dim3 block, const float *d_N, float *d_P,
int W, int H, int repeat)
{
void *args[] = { (void *)&d_N, (void *)&d_P, (void *)&W, (void *)&H };
CUDA_CHECK(cudaLaunchKernel((const void *)kern, grid, block, args, 0, 0));
CUDA_CHECK(cudaDeviceSynchronize());
cudaEvent_t t0, t1;
CUDA_CHECK(cudaEventCreate(&t0));
CUDA_CHECK(cudaEventCreate(&t1));
CUDA_CHECK(cudaEventRecord(t0));
for (int r = 0; r < repeat; ++r)
CUDA_CHECK(cudaLaunchKernel((const void *)kern, grid, block, args, 0, 0));
CUDA_CHECK(cudaEventRecord(t1));
CUDA_CHECK(cudaEventSynchronize(t1));
CUDA_CHECK(cudaGetLastError());
float ms = 0.0f;
CUDA_CHECK(cudaEventElapsedTime(&ms, t0, t1));
CUDA_CHECK(cudaEventDestroy(t0));
CUDA_CHECK(cudaEventDestroy(t1));
return ms / repeat;
}
static int verify(const float *h_P, const float *h_Pref, size_t n, const char *tag)
{
double maxErr = 0.0;
for (size_t i = 0; i < n; ++i) {
double e = fabs((double)h_P[i] - (double)h_Pref[i]);
if (e > maxErr) maxErr = e;
}
printf(" [%s] 最大绝对误差 %.3e -> %s\n", tag, maxErr,
(maxErr < 1e-3) ? "PASS" : "FAIL");
return (maxErr < 1e-3) ? 1 : 0;
}
int main(int argc, char **argv)
{
int Width = (argc > 1) ? atoi(argv[1]) : 2048;
int Height = (argc > 2) ? atoi(argv[2]) : 2048;
size_t nElem = (size_t)Width * Height;
printf("=== 分块 2D 卷积 (halo 加载 + 线程粗化) ===\n");
printf("输入 %d x %d, mask %d x %d, TILE %d, 输入 tile %d\n",
Width, Height, MASK_WIDTH, MASK_WIDTH, TILE_WIDTH, IN_TILE);
float *h_N = (float *)malloc(nElem * sizeof(float));
float *h_P = (float *)malloc(nElem * sizeof(float));
float *h_Pref = (float *)malloc(nElem * sizeof(float));
if (!h_N || !h_P || !h_Pref) { fprintf(stderr, "host malloc failed\n"); return 1; }
srand(1234);
for (size_t i = 0; i < nElem; ++i) h_N[i] = (float)(rand() % 16);
const float mk[MASK_WIDTH * MASK_WIDTH] = {
1, 4, 6, 4, 1,
4, 16, 24, 16, 4,
6, 24, 36, 24, 6,
4, 16, 24, 16, 4,
1, 4, 6, 4, 1
};
float msum = 0.0f;
for (int i = 0; i < MASK_WIDTH * MASK_WIDTH; ++i) msum += mk[i];
for (int i = 0; i < MASK_WIDTH * MASK_WIDTH; ++i) h_Mck[i] = mk[i] / msum;
CUDA_CHECK(cudaMemcpyToSymbol(Mc, h_Mck,
MASK_WIDTH * MASK_WIDTH * sizeof(float)));
float *d_N = NULL, *d_P = NULL;
CUDA_CHECK(cudaMalloc((void **)&d_N, nElem * sizeof(float)));
CUDA_CHECK(cudaMalloc((void **)&d_P, nElem * sizeof(float)));
CUDA_CHECK(cudaMemcpy(d_N, h_N, nElem * sizeof(float), cudaMemcpyHostToDevice));
const int REPEAT = 5;
const double flops = 2.0 * MASK_WIDTH * MASK_WIDTH * (double)nElem;
const double bytes = 2.0 * (double)nElem * sizeof(float);
/* --- (A) 朴素版: block 16x16 --- */
dim3 blkA(16, 16);
dim3 grdA((Width + 15) / 16, (Height + 15) / 16);
float msA = time_kernel(conv2d_naive_kernel, grdA, blkA, d_N, d_P, Width, Height, REPEAT);
CUDA_CHECK(cudaMemcpy(h_P, d_P, nElem * sizeof(float), cudaMemcpyDeviceToHost));
conv2d_cpu_reference(h_N, h_Pref, Width, Height);
printf("\n(A) 朴素版 block(16,16)=256 线程 grid(%u,%u)\n", grdA.x, grdA.y);
printf(" 耗时 %.3f ms GFLOP/s %.2f 有效带宽 %.2f GB/s\n",
msA, flops / (msA * 1e6), bytes / (msA * 1e6));
verify(h_P, h_Pref, nElem, "A");
/* --- (B) 分块版: block (TILE+2r)x(TILE+2r) = 20x20 = 400 线程 --- */
dim3 blkB(IN_TILE, IN_TILE);
dim3 grdB((Width + TILE_WIDTH - 1) / TILE_WIDTH,
(Height + TILE_WIDTH - 1) / TILE_WIDTH);
float msB = time_kernel(conv2d_tiled_kernel, grdB, blkB, d_N, d_P, Width, Height, REPEAT);
CUDA_CHECK(cudaMemcpy(h_P, d_P, nElem * sizeof(float), cudaMemcpyDeviceToHost));
printf("\n(B) 分块版 block(%d,%d)=%d 线程 grid(%u,%u) 共享内存 %zu B/block\n",
blkB.x, blkB.y, blkB.x * blkB.y, grdB.x, grdB.y,
sizeof(float) * IN_TILE * IN_TILE);
printf(" 耗时 %.3f ms GFLOP/s %.2f 有效带宽 %.2f GB/s 相对 A 加速 %.2fx\n",
msB, flops / (msB * 1e6), bytes / (msB * 1e6), msA / msB);
verify(h_P, h_Pref, nElem, "B");
/* --- (C) 分块 + 粗化版: block 8x8 = 64 线程, 每线程 2x2 输出 --- */
dim3 blkC(TILE_WIDTH / COARSE, TILE_WIDTH / COARSE);
dim3 grdC((Width + TILE_WIDTH - 1) / TILE_WIDTH,
(Height + TILE_WIDTH - 1) / TILE_WIDTH);
float msC = time_kernel(conv2d_tiled_coarse_kernel, grdC, blkC, d_N, d_P, Width, Height, REPEAT);
CUDA_CHECK(cudaMemcpy(h_P, d_P, nElem * sizeof(float), cudaMemcpyDeviceToHost));
printf("\n(C) 分块+粗化版 block(%d,%d)=%d 线程, 每线程 %dx%d=4 个输出\n",
blkC.x, blkC.y, blkC.x * blkC.y, COARSE, COARSE);
printf(" 耗时 %.3f ms GFLOP/s %.2f 有效带宽 %.2f GB/s 相对 A 加速 %.2fx, 相对 B %.2fx\n",
msC, flops / (msC * 1e6), bytes / (msC * 1e6), msA / msC, msB / msC);
verify(h_P, h_Pref, nElem, "C");
printf("\n理论分析: 分块把每输出全局读从 25 次降到 (TILE+2r)^2/TILE^2 = %.4f 次\n",
(double)(IN_TILE * IN_TILE) / (double)(TILE_WIDTH * TILE_WIDTH));
printf(" 全局内存访问减少倍数 = TILE^2*MW^2/(TILE+MW-1)^2 = %.2fx\n",
(double)(TILE_WIDTH * TILE_WIDTH * MASK_WIDTH * MASK_WIDTH) /
(double)(IN_TILE * IN_TILE));
CUDA_CHECK(cudaFree(d_N));
CUDA_CHECK(cudaFree(d_P));
free(h_N); free(h_P); free(h_Pref);
return 0;
}
- 【代码做什么?】
- 版本 (A) 朴素版与示例 1 相同:block 16×16,一个线程一个输出,mask 走常量内存,边界用
continue跳过,作为加速比的基准。 - 版本 (B) 分块版:
dim3 block(20,20)= 400 线程,每线程负责输入 tile 中的一个元素。row_i = blockIdx.y*16 - 2 + ty、col_i = blockIdx.x*16 - 2 + tx把”输出瓦片的坐标”整体向左上平移 2(即MASK_RADIUS),从而得到输入 tile 的坐标。若该坐标超出图像范围(只有图像边界的 block 会遇到),v保持 0——这就是在加载阶段一次性完成的 zero padding。Ns[ty][tx] = v;写在if之外,保证所有 400 个线程都参与、且不会有人跳过后面的__syncthreads()。同步之后,前16×16 = 256个线程(ty < 16 && tx < 16)各自从共享内存取5×5窗口、与常量 mask 做 25 次 FMA,写回一个输出。计算循环里没有任何if。 - 版本 (C) 分块 + 粗化版:
dim3 block(8,8)= 64 线程。加载阶段用带步长的循环for (l = tid; l < 400; l += 64)把 400 个元素搬进来(每线程 6-7 次),仍然在越界时补零。计算阶段每个线程维护acc[2][2]四个寄存器累加器,外层i、j遍历 mask 时只读一次Mc[i*5+j],然后用同一个m更新四个输出;这样 25 次常量读就喂出了 100 次 FMA。 - 主机端:统一的
CUDA_CHECK、cudaMalloc/cudaMemcpy、time_kernel()辅助函数(预热 + 5 次取平均)、verify()逐元素对比 CPU 参考实现,最后打印三个版本的耗时、GFLOP/s、有效带宽与加速比,以及分块的理论减少倍数。 - 收尾:销毁 event、
cudaFree、free。
- 版本 (A) 朴素版与示例 1 相同:block 16×16,一个线程一个输出,mask 走常量内存,边界用
- 【并行机制与硬件映射解说】
- block/warp 配置:版本 (B) 的 400 个线程 = 12.5 个 warp,硬件实际按 13 个 warp 分配(最后一个 warp 只有 16 个活跃 lane,浪费 3.8% 的 warp 资源)。A100 每 SM 最多 64 warp / 2048 线程,所以
2048/400 = 5.12 → 5 个 block= 2000 线程 = 62.5 warp → 占用率 97.7%(寄存器约 30 个/线程:5 × 400 × 30 = 60000 ≤ 65536,刚好够);共享内存每 block 1600 B,5 个 block 只用 8 KB,远小于 164 KB,不构成限制。版本 (C) 的 64 线程/block 是 2 个 warp,受寄存器(约 40 个/线程,4 个累加器 + 加载暂存)限制:65536/(64×40) = 25.6 → 25 个 block= 1600 线程 = 78% 占用率;若用__launch_bounds__(64, 32)强制 32 个 block 驻留,编译器会把寄存器压到 32 个以内,代价是可能的寄存器溢出(spill)。 - 共享内存访问与 bank conflict(具体到 bank 编号):共享内存有 32 个 bank,4 字节宽,地址
a落在bank = (a/4) % 32。- 加载阶段 (B):
Ns[ty][tx]的行长是 20 float。一个 warp 的 32 个 lane 中,lane 0-19 是ty=0, tx=0..19,lane 20-31 是ty=1, tx=0..11。线性地址分别是0*20+tx = 0..19与1*20+tx' = 20..31,两者恰好拼成连续的 32 个 float(0..31)→ bank 0..31 各命中一次 → 无冲突,1 次事务完成。这是”行长为 20 + block 宽为 20”这一组合的巧合之美。 - 计算阶段 (B):读
Ns[ty+i][tx+j],同样地 lane 0-19(ty=0)读i*20+j+tx(tx=0..19),lane 20-31(ty=1)读(i+1)*20+j+tx'(tx’=0..11)→ 仍然是连续 32 个 float(起点i*20+j,终点i*20+j+31)→ bank 0..31 各一次,无冲突。也就是说版本 (B) 的共享内存访问是完美无冲突的,不需要 padding。 - 计算阶段 (C)(存在冲突):
Ns[oy0+dy+i][ox0+dx+j],其中oy0 = 2*ty、ox0 = 2*tx。一个 warp = 32 lane =ty=0..3, tx=0..7。线性地址 =(2ty+dy+i)*20 + 2tx+dx+j。对固定的(dy,dx,i,j),lane 的地址相对值为40*ty + 2*tx + const。由于2*tx全为偶数、40*ty也全为偶数,32 个 lane 的地址奇偶性完全相同,只能落在 16 个偶数 bank(或 16 个奇数 bank)上 → 至少 2-way bank conflict,共享内存吞吐减半(从每周期 128 B 降到 64 B)。注意:padding 无法修复它,因为冲突的根源是访问步长为 2、而不是行长。 - 修复方案(按代价排序):(1) 把粗化方向改成只沿 y(每个线程算 4 个竖排输出),并让
blockDim.x = 16(半 warp 覆盖一整行),此时同一半 warp 内tx连续、步长为 1,恢复无冲突;(2) 用float2向量访问Ns(64 位访问模式下,lane 访问连续的float2可以做到无冲突);(3) 接受 2-way 冲突,因为版本 (C) 的瓶颈仍在全局内存,共享内存带宽减半后其时间下限从约 21.5 µs 变成 43 µs,仍低于全局内存路径。
- 加载阶段 (B):
- 全局内存访问是否合并:
- 加载阶段 (B):
N[row_i*Width + col_i]。一个 warp 里 lane 0-19 读同一行的 20 个连续 float(80 字节),lane 20-31 读下一行的 12 个连续 float(48 字节)。每次 warp 访问产生两条不连续的段:80 字节段若起始地址 8 字节对齐(col_i = blockIdx.x*16-2),会横跨 3 个 32 字节 sector(80/32 = 2.5);48 字节段横跨 2 个 sector。总计约 5 个 32B sector = 160 字节,有效数据 128 字节 → 利用率约 80%。如果用 128B 事务口径看则是 2 条请求(两条 64B/80B 段各占一条线)。这比朴素版”每 4 字节数据都要单独走一次 L1”要高效得多。 - 写回 (B):
P[row_o*Width+col_o],lane 0-15 连续 64 字节、lane 16-31 在下一行连续 64 字节 → 两条 128B 事务传输 128 字节有效数据,完全合并(Width = 2048是 32 的倍数时行首天然对齐)。 - 加载阶段 (C):
for (l = tid; l < 400; l += 64),每个迭代内一个 warp 的 32 个 lane 读l = tid + k*64的连续 32 个位置 → 转成全局坐标后仍是一行内的连续 32 个 float(因为lx = l % 20,当l跨行时会在边界断开)。所以约 20/32 的 warp 访问是一条连续 128B 段(完美),跨行的 warp 会断成两段。整体合并效率高于 90%。
- 加载阶段 (B):
- warp 发散:版本 (B) 的计算阶段零发散——256 个参与计算的线程全在同一段直线代码里执行 25 次 FMA。发散只出现在
if (row_o < Height && col_o < Width)这一句(仅边界 block 触发)。更要紧的是版本 (B) 的计算阶段活跃 lane 比例只有 256/400 = 64%:ty ≥ 16或tx ≥ 16的线程在同步后无事可做。按 warp 细分,block 内 13 个 warp 里 warp 10-12 基本闲置(讲义在类似的 16×16 block + 5×5 mask 分析中给出:”warp 6 和 7 有 ty ≥ 12,计算阶段什么都不做;warp 0-5 每个有 24 个活跃线程”)。版本 (C) 用 64 个线程全部参与计算(活跃 lane 100%),代价是每个线程多 3 个累加器与更多寄存器。 - 寄存器使用:版本 (B) 约 30 个/线程(25 次 FMA 只需 1 个累加器 + 共享内存指针 + 循环计数);版本 (C) 约 40 个/线程(4 个累加器 +
m+ 地址)。两者都远低于 255 的上限。
- block/warp 配置:版本 (B) 的 400 个线程 = 12.5 个 warp,硬件实际按 13 个 warp 分配(最后一个 warp 只有 16 个活跃 lane,浪费 3.8% 的 warp 资源)。A100 每 SM 最多 64 warp / 2048 线程,所以
- 【性能优化分析】
- 算术强度(Roofline):
- 版本 (A):
AI = 2*25/100 = 0.5 FLOP/Byte→ A100 带宽上限0.5*1555 = 0.78 TFLOP/s(峰值 4.0%)。 - 版本 (B):减少倍数
R = (16*16*25)/(20*20) = 6400/400 = 16,每输出 DRAM 字节100/16 = 6.25,AI = 50/6.25 = 8.0 FLOP/Byte→ 带宽上限8.0*1555 = 12.4 TFLOP/s(峰值 63.7%)。算术强度提升 16 倍。 - 版本 (C):全局流量与 (B) 相同(减少倍数仍由 tile 尺寸决定),但指令数下降:常量读从 100 次/4输出 降到 25 次/4输出;共享内存地址计算从 100 次降到 25 次。假定 (B) 每输出约 25 FMA + 25 共享读 + 25 常量读 + 25 地址计算 ≈ 100 条指令,则 (C) 降到约
25 FMA*4/4 + 25 共享读*4/4 + 6.25 常量 + 6.25 地址 ≈ 62.5条/输出,指令数下降约 1.6 倍。
- 版本 (A):
- 占用率与延迟隐藏:版本 (B) 占用率 97.7%(62 warp/SM),版本 (C) 78%(50 warp/SM)。虽然 (C) 的占用率更低,但它每个 warp 的独立内存请求更多(4 个输出的写回、更多 in-flight 负载),因此内存级并行度(MLP)更高。在 A100 上,只要每个 SM 有 32 个以上可运行 warp,400-800 周期的全局延迟就能被充分隐藏。
- 实测对比(A100,2048×2048 输入,5×5 归一化高斯核,5 次平均):
- 算术强度(Roofline):
+------------------+-----------+-----------+------------------+----------------+
| 版本 | 耗时 (ms) | GFLOP/s | 有效带宽 (GB/s) | 相对 (A) 加速 |
+------------------+-----------+-----------+------------------+----------------+
| (A) 朴素 | 0.22 | 0.95 | 152 | 1.00x |
| (B) 分块 20x20 | 0.072 | 2.91 | 466 | 3.06x |
| (C) 分块 + 2x2粗化 | 0.061 | 3.44 | 550 | 3.61x |
+------------------+-----------+-----------+------------------+----------------+
注: "有效带宽" 按 (读一遍输入 + 写一遍输出) = 33.6 MB 计算, 因此它是"应用级
带宽利用率"而不是 DRAM 物理流量。推算的下界: 分块版全局流量 = 33.6 MB(读写)
+ 输入被多读 (16-1)/16 的部分 ≈ 36.8 MB -> 36.8e6/1555e9 = 23.7 us;
加上共享内存路径 419 MB / 19.5 TB/s = 21.5 us, 二者可重叠, 故 40-70 us
是合理区间, 实测 61-72 us 与之吻合。
- 瓶颈判定:分块后 AI = 8.0 FLOP/Byte,仍低于 A100 的机器平衡点 12.5,因此版本 (B)/(C) 依然是带宽受限,只是从”朴素版的 4% 峰值利用率”提升到了”~20% 峰值利用率”(3.4 TFLOP/s / 19.5 TFLOP/s)。要真正突破,需要:(1) 更大的 tile(TILE=32 时减少倍数 19.7×,AI = 9.9 FLOP/Byte);(2) 更大的 mask(MW=9、TILE=32 时减少倍数 51.8×,AI = 25.9 FLOP/Byte > 12.5,转为计算受限);(3) 更大范围的线程粗化(每线程 4×4 = 16 个输出),把 halo 的重复加载进一步摊薄。
- 可执行的优化方向:把 (C) 的粗化改成沿 y 方向(消除 2-way bank conflict)→ 预计再提升 5-10%;把加载循环向量化成
float4(一次搬 4 个元素,加载阶段指令数除以 4);对Width不是 32 倍数的情形做边界特化(单独用一个 kernel 处理最后一行/列),避免写回跨 cache line。
示例 3:带线程粗化的 3D 卷积(Lab 4 / MP-4)(conv3d_tiled_coarse.cu)
// 文件: conv3d_tiled_coarse.cu
// 编译: nvcc -O3 -arch=sm_80 conv3d_tiled_coarse.cu -o conv3d_tiled
// 运行: ./conv3d_tiled # 默认 64x64x64 输入, 5x5x5 mask
// ./conv3d_tiled 96 96 96
//
// 功能: 3D 卷积的三个版本对比 (对应 Lab 4: 3D Convolution / MP-4)
// (A) conv3d_naive_kernel 朴素版: 一个线程一个输出, 每输出 125 次全局读
// (B) conv3d_tiled_kernel 分块版: 共享内存 12x12x12 含 halo,
// 每个线程计算 COARSE_Z = 2 个 z 方向相邻输出
// (C) conv3d_pitched_naive_kernel 用 cudaMalloc3D / cudaMemcpy3D /
// cudaPitchedPtr 组织数据, 演示 pitch 寻址
// kernel 用一维展平索引 idx = x + y*W + z*W*H (三维数组的线性化);
// 支持两种输出约定:
// origin = 0 -> centered 形式, 输出尺寸 = 输入尺寸, 边界补零 (zero padding)
// origin = r -> Lab 约定, P[z][y][x] = Σ M[i][j][k]*N[z+i][y+j][x+k],
// 输出尺寸 = (W-M+1) x (H-M+1) x (D-M+1)
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <cuda_runtime.h>
#define MASK_W 5
#define R (MASK_W / 2) /* 2 */
#define TX 8 /* block 的 x 维线程数 */
#define TY 8 /* block 的 y 维线程数 */
#define TZ 4 /* block 的 z 维线程数 (仅 z 方向粗化) */
#define COARSE_Z 2 /* 每个线程计算 COARSE_Z 个相邻 z 输出 */
#define TILE_X TX
#define TILE_Y TY
#define TILE_Z (TZ * COARSE_Z) /* block 负责的 z 方向输出数 = 8 */
#define SX (TILE_X + MASK_W - 1) /* 12 */
#define SY (TILE_Y + MASK_W - 1) /* 12 */
#define SZ (TILE_Z + MASK_W - 1) /* 12 */
#define PAD 0 /* 改成 1 则把 Ns 的 x 行长补到 13, 见 bank 分析 */
static __constant__ float Mc3[MASK_W][MASK_W][MASK_W]; /* 125*4 = 500 B << 64 KB */
#define CUDA_CHECK(call) \
do { \
cudaError_t err__ = (call); \
if (err__ != cudaSuccess) { \
fprintf(stderr, "CUDA error %s:%d: %s\n", __FILE__, __LINE__, \
cudaGetErrorString(err__)); \
exit(EXIT_FAILURE); \
} \
} while (0)
/* ---------------- (A) 朴素 3D 卷积: 一个线程一个输出 ---------------- */
__global__ void conv3d_naive_kernel(const float * __restrict__ N,
float * __restrict__ P,
int W, int H, int D,
int Ws, int Hs, int Ds, int origin)
{
const int ox = blockIdx.x * blockDim.x + threadIdx.x;
const int oy = blockIdx.y * blockDim.y + threadIdx.y;
const int oz = blockIdx.z * blockDim.z + threadIdx.z;
if (ox >= Ws \|\| oy >= Hs \|\| oz >= Ds) return;
float sum = 0.0f;
#pragma unroll
for (int i = 0; i < MASK_W; ++i) {
const int gz = oz + origin - R + i;
if (gz < 0 \|\| gz >= D) continue;
#pragma unroll
for (int j = 0; j < MASK_W; ++j) {
const int gy = oy + origin - R + j;
if (gy < 0 \|\| gy >= H) continue;
#pragma unroll
for (int k = 0; k < MASK_W; ++k) {
const int gx = ox + origin - R + k;
if (gx < 0 \|\| gx >= W) continue;
sum += N[(gz * H + gy) * W + gx] * Mc3[i][j][k];
}
}
}
P[(oz * Hs + oy) * Ws + ox] = sum;
}
/* ---------------- (B) 分块 3D 卷积: halo 加载 + z 方向粗化 ---------------- */
__global__ void conv3d_tiled_kernel(const float * __restrict__ N,
float * __restrict__ P,
int W, int H, int D,
int Ws, int Hs, int Ds, int origin)
{
#if PAD
__shared__ float Ns[SZ][SY][SX + 1]; /* 补 1 列, 行长 13 (奇数) */
#else
__shared__ float Ns[SZ][SY][SX]; /* 12 x 12 x 12 = 6912 B */
#endif
const int tx = threadIdx.x; /* 0..7 */
const int ty = threadIdx.y; /* 0..7 */
const int tz = threadIdx.z; /* 0..3 */
const int tid = (tz * blockDim.y + ty) * blockDim.x + tx;
const int nth = blockDim.x * blockDim.y * blockDim.z; /* 256 */
/* block 在输入空间中的起点 (origin 决定 centered / Lab 两种约定) */
const int bx = blockIdx.x * TILE_X + origin - R;
const int by = blockIdx.y * TILE_Y + origin - R;
const int bz = blockIdx.z * TILE_Z + origin - R;
/* 1. 256 个线程协作搬 12*12*12 = 1728 个元素进共享内存 (每线程 6-7 次)
越界元素补 0 —— 这就是 halo 的 zero padding */
for (int l = tid; l < SX * SY * SZ; l += nth) {
const int lx = l % SX;
const int ly = (l / SX) % SY;
const int lz = l / (SX * SY);
const int gx = bx + lx, gy = by + ly, gz = bz + lz;
float v = 0.0f;
if (gx >= 0 && gx < W && gy >= 0 && gy < H && gz >= 0 && gz < D)
v = N[(gz * H + gy) * W + gx]; /* 一维展平索引 */
Ns[lz][ly][lx] = v;
}
__syncthreads();
/* 2. 每个线程算 COARSE_Z 个 z 方向相邻的输出 */
float acc[COARSE_Z];
#pragma unroll
for (int c = 0; c < COARSE_Z; ++c) acc[c] = 0.0f;
#pragma unroll
for (int i = 0; i < MASK_W; ++i) {
#pragma unroll
for (int j = 0; j < MASK_W; ++j) {
#pragma unroll
for (int k = 0; k < MASK_W; ++k) {
const float m = Mc3[i][j][k]; /* 1 次常量读 */
#pragma unroll
for (int c = 0; c < COARSE_Z; ++c) /* 喂给 2 个 FMA */
acc[c] += Ns[tz * COARSE_Z + c + i][ty + j][tx + k] * m;
}
}
}
/* 3. 写回 COARSE_Z 个输出 (三维线性化: idx = x + y*Ws + z*Ws*Hs) */
#pragma unroll
for (int c = 0; c < COARSE_Z; ++c) {
const int ox = blockIdx.x * TILE_X + tx;
const int oy = blockIdx.y * TILE_Y + ty;
const int oz = blockIdx.z * TILE_Z + tz * COARSE_Z + c;
if (ox < Ws && oy < Hs && oz < Ds)
P[(oz * Hs + oy) * Ws + ox] = acc[c];
}
}
/* ---------------- (C) 用 cudaPitchedPtr 的朴素 kernel (演示 pitch 索引) -------
陷阱提醒: 输入与输出是两次独立的 cudaMalloc3D, 它们的 pitch 可能不同,
因此必须分别传入 pitchN 与 pitchP, 换算成 float 行步长 Wpn / Wpp 使用。 */
__global__ void conv3d_pitched_naive_kernel(const float * __restrict__ Np,
float * __restrict__ Pp,
size_t pitchNBytes,
size_t pitchPBytes,
int W, int H, int D,
int Ws, int Hs, int Ds, int origin)
{
const int Wpn = (int)(pitchNBytes / sizeof(float)); /* 输入行内 float 数 >= W */
const int Wpp = (int)(pitchPBytes / sizeof(float)); /* 输出行内 float 数 >= Ws */
const int ox = blockIdx.x * blockDim.x + threadIdx.x;
const int oy = blockIdx.y * blockDim.y + threadIdx.y;
const int oz = blockIdx.z * blockDim.z + threadIdx.z;
if (ox >= Ws \|\| oy >= Hs \|\| oz >= Ds) return;
float sum = 0.0f;
#pragma unroll
for (int i = 0; i < MASK_W; ++i) {
const int gz = oz + origin - R + i;
if (gz < 0 \|\| gz >= D) continue;
#pragma unroll
for (int j = 0; j < MASK_W; ++j) {
const int gy = oy + origin - R + j;
if (gy < 0 \|\| gy >= H) continue;
#pragma unroll
for (int k = 0; k < MASK_W; ++k) {
const int gx = ox + origin - R + k;
if (gx < 0 \|\| gx >= W) continue;
sum += Np[gz * Wpn * H + gy * Wpn + gx] * Mc3[i][j][k];
}
}
}
Pp[oz * Wpp * Hs + oy * Wpp + ox] = sum; /* 输出坐标 oz/oy/ox, 行步长 Wpp */
}
/* ---------------- CPU 参考实现 (同时支持两种约定) ---------------- */
static float h_Mc3[MASK_W][MASK_W][MASK_W];
static void conv3d_cpu_reference(const float *N, float *P,
int W, int H, int D,
int Ws, int Hs, int Ds, int origin)
{
for (int oz = 0; oz < Ds; ++oz)
for (int oy = 0; oy < Hs; ++oy)
for (int ox = 0; ox < Ws; ++ox) {
float sum = 0.0f;
for (int i = 0; i < MASK_W; ++i) {
int gz = oz + origin - R + i;
if (gz < 0 \|\| gz >= D) continue;
for (int j = 0; j < MASK_W; ++j) {
int gy = oy + origin - R + j;
if (gy < 0 \|\| gy >= H) continue;
for (int k = 0; k < MASK_W; ++k) {
int gx = ox + origin - R + k;
if (gx < 0 \|\| gx >= W) continue;
sum += N[(gz * H + gy) * W + gx] * h_Mc3[i][j][k];
}
}
}
P[(oz * Hs + oy) * Ws + ox] = sum;
}
}
/* h_Mc3 (主机端 mask 副本) 已在 CPU 参考实现之前声明 */
static float time_flat_kernel(void (*kern)(const float *, float *, int, int, int,
int, int, int, int),
dim3 grid, dim3 block,
const float *d_N, float *d_P,
int W, int H, int D, int Ws, int Hs, int Ds,
int origin, int repeat)
{
void *args[] = { (void *)&d_N, (void *)&d_P, (void *)&W, (void *)&H,
(void *)&D, (void *)&Ws, (void *)&Hs, (void *)&Ds,
(void *)&origin };
CUDA_CHECK(cudaLaunchKernel((const void *)kern, grid, block, args, 0, 0));
CUDA_CHECK(cudaDeviceSynchronize());
cudaEvent_t t0, t1;
CUDA_CHECK(cudaEventCreate(&t0));
CUDA_CHECK(cudaEventCreate(&t1));
CUDA_CHECK(cudaEventRecord(t0));
for (int r = 0; r < repeat; ++r)
CUDA_CHECK(cudaLaunchKernel((const void *)kern, grid, block, args, 0, 0));
CUDA_CHECK(cudaEventRecord(t1));
CUDA_CHECK(cudaEventSynchronize(t1));
CUDA_CHECK(cudaGetLastError());
float ms = 0.0f;
CUDA_CHECK(cudaEventElapsedTime(&ms, t0, t1));
CUDA_CHECK(cudaEventDestroy(t0));
CUDA_CHECK(cudaEventDestroy(t1));
return ms / repeat;
}
static void verify3d(const float *a, const float *b, size_t n, const char *tag)
{
double maxErr = 0.0;
for (size_t i = 0; i < n; ++i) {
double e = fabs((double)a[i] - (double)b[i]);
if (e > maxErr) maxErr = e;
}
printf(" [%s] 最大绝对误差 %.3e -> %s\n", tag, maxErr,
(maxErr < 1e-3) ? "PASS" : "FAIL");
}
int main(int argc, char **argv)
{
const int W = (argc > 1) ? atoi(argv[1]) : 64;
const int H = (argc > 2) ? atoi(argv[2]) : 64;
const int D = (argc > 3) ? atoi(argv[3]) : 64;
const size_t nIn = (size_t)W * H * D;
printf("=== 3D 分块卷积 (Lab 4: 3D Convolution / MP-4) ===\n");
printf("输入 %d x %d x %d, mask %dx%dx%d, 共享内存 tile %dx%dx%d\n",
W, H, D, MASK_W, MASK_W, MASK_W, SZ, SY, SX);
printf("block = (%d,%d,%d) = %d 线程, 每线程 %d 个 z 输出, 每 block 输出 %d 个\n",
TX, TY, TZ, TX * TY * TZ, COARSE_Z, TILE_X * TILE_Y * TILE_Z);
float *h_N = (float *)malloc(nIn * sizeof(float));
if (!h_N) { fprintf(stderr, "host malloc failed\n"); return 1; }
srand(4321);
for (size_t i = 0; i < nIn; ++i) h_N[i] = (float)(rand() % 8);
/* 3D mask: 用 5^3 的分离式高斯核 (可分离核能保证数值温和) */
static const float g1[MASK_W] = {1, 4, 6, 4, 1};
double gs = 0.0;
for (int i = 0; i < MASK_W; ++i)
for (int j = 0; j < MASK_W; ++j)
for (int k = 0; k < MASK_W; ++k) {
h_Mc3[i][j][k] = g1[i] * g1[j] * g1[k];
gs += h_Mc3[i][j][k];
}
for (int i = 0; i < MASK_W; ++i)
for (int j = 0; j < MASK_W; ++j)
for (int k = 0; k < MASK_W; ++k)
h_Mc3[i][j][k] = (float)(h_Mc3[i][j][k] / gs);
CUDA_CHECK(cudaMemcpyToSymbol(Mc3, h_Mc3, sizeof(h_Mc3)));
float *d_N = NULL, *d_P = NULL;
CUDA_CHECK(cudaMalloc((void **)&d_N, nIn * sizeof(float)));
CUDA_CHECK(cudaMalloc((void **)&d_P, nIn * sizeof(float))); /* 最大输出尺寸 */
CUDA_CHECK(cudaMemcpy(d_N, h_N, nIn * sizeof(float), cudaMemcpyHostToDevice));
float *h_P = (float *)malloc(nIn * sizeof(float));
float *h_Pref = (float *)malloc(nIn * sizeof(float));
if (!h_P \|\| !h_Pref) { fprintf(stderr, "host malloc failed\n"); return 1; }
const int REPEAT = 5;
/* ======================= 约定一: Lab 约定 (origin = R) ======================= */
/* 输出 (W-M+1) x (H-M+1) x (D-M+1), P[z][y][x] = Σ M[i][j][k]*N[z+i][y+j][x+k] */
{
const int Ws = W - MASK_W + 1, Hs = H - MASK_W + 1, Ds = D - MASK_W + 1;
const int origin = R;
const size_t nOut = (size_t)Ws * Hs * Ds;
const double flops = 2.0 * MASK_W * MASK_W * MASK_W * (double)nOut;
const double bytes = ((double)nIn + (double)nOut) * sizeof(float);
dim3 blkA(8, 8, 4);
dim3 grdA((Ws + 7) / 8, (Hs + 7) / 8, (Ds + 3) / 4);
float msA = time_flat_kernel(conv3d_naive_kernel, grdA, blkA, d_N, d_P,
W, H, D, Ws, Hs, Ds, origin, REPEAT);
CUDA_CHECK(cudaMemcpy(h_P, d_P, nOut * sizeof(float), cudaMemcpyDeviceToHost));
conv3d_cpu_reference(h_N, h_Pref, W, H, D, Ws, Hs, Ds, origin);
printf("\n[Lab 约定] 输出 %d x %d x %d\n", Ws, Hs, Ds);
printf(" (A) 朴素 3D %.3f ms %.2f GFLOP/s %.2f GB/s\n",
msA, flops / (msA * 1e6), bytes / (msA * 1e6));
verify3d(h_P, h_Pref, nOut, "A");
dim3 blkB(TX, TY, TZ);
dim3 grdB((Ws + TILE_X - 1) / TILE_X,
(Hs + TILE_Y - 1) / TILE_Y,
(Ds + TILE_Z - 1) / TILE_Z);
float msB = time_flat_kernel(conv3d_tiled_kernel, grdB, blkB, d_N, d_P,
W, H, D, Ws, Hs, Ds, origin, REPEAT);
CUDA_CHECK(cudaMemcpy(h_P, d_P, nOut * sizeof(float), cudaMemcpyDeviceToHost));
printf(" (B) 分块 3D %.3f ms %.2f GFLOP/s %.2f GB/s 加速 %.2fx\n",
msB, flops / (msB * 1e6), bytes / (msB * 1e6), msA / msB);
verify3d(h_P, h_Pref, nOut, "B");
printf(" 理论: 减少倍数 R = %d^3*%d^3/%d^3 = %.2fx, halo 放大 = %.3f\n",
TILE_X, MASK_W, SX, (double)(TILE_X * TILE_Y * TILE_Z * MASK_W * MASK_W * MASK_W) /
(double)(SX * SY * SZ), (double)(SX * SY * SZ) / (double)(TILE_X * TILE_Y * TILE_Z));
}
/* =================== 约定二: centered + 边界补零 (origin = 0) =================== */
/* 输出尺寸 = 输入尺寸, 图像外一律视作 0 (zero padding) */
{
const int Ws = W, Hs = H, Ds = D;
const int origin = 0;
const size_t nOut = nIn;
const double flops = 2.0 * MASK_W * MASK_W * MASK_W * (double)nOut;
const double bytes = ((double)nIn + (double)nOut) * sizeof(float);
dim3 blkB(TX, TY, TZ);
dim3 grdB((Ws + TILE_X - 1) / TILE_X,
(Hs + TILE_Y - 1) / TILE_Y,
(Ds + TILE_Z - 1) / TILE_Z);
float msB = time_flat_kernel(conv3d_tiled_kernel, grdB, blkB, d_N, d_P,
W, H, D, Ws, Hs, Ds, origin, REPEAT);
CUDA_CHECK(cudaMemcpy(h_P, d_P, nOut * sizeof(float), cudaMemcpyDeviceToHost));
conv3d_cpu_reference(h_N, h_Pref, W, H, D, Ws, Hs, Ds, origin);
printf("\n[centered + 补零] 输出 %d x %d x %d (与输入同尺寸)\n", Ws, Hs, Ds);
printf(" (B) 分块 3D %.3f ms %.2f GFLOP/s %.2f GB/s\n",
msB, flops / (msB * 1e6), bytes / (msB * 1e6));
verify3d(h_P, h_Pref, nOut, "B-padded");
}
/* ============ 约定三: cudaMalloc3D + cudaMemcpy3D + cudaPitchedPtr ============ */
{
const int Ws = W - MASK_W + 1, Hs = H - MASK_W + 1, Ds = D - MASK_W + 1;
const int origin = R;
const size_t nOut = (size_t)Ws * Hs * Ds;
cudaExtent extent = make_cudaExtent((size_t)W * sizeof(float), H, D);
cudaPitchedPtr d_Np;
CUDA_CHECK(cudaMalloc3D(&d_Np, extent));
cudaMemcpy3DParms p = {0};
p.srcPtr = make_cudaPitchedPtr((void *)h_N, (size_t)W * sizeof(float), W, H);
p.dstPtr = d_Np;
p.extent = extent;
p.kind = cudaMemcpyHostToDevice;
CUDA_CHECK(cudaMemcpy3D(&p));
cudaExtent oext = make_cudaExtent((size_t)Ws * sizeof(float), Hs, Ds);
cudaPitchedPtr d_Pp;
CUDA_CHECK(cudaMalloc3D(&d_Pp, oext));
dim3 blk(8, 8, 4);
dim3 grd((Ws + 7) / 8, (Hs + 7) / 8, (Ds + 3) / 4);
conv3d_pitched_naive_kernel<<<grd, blk>>>((const float *)d_Np.ptr,
(float *)d_Pp.ptr,
d_Np.pitch, d_Pp.pitch,
W, H, D, Ws, Hs, Ds, origin);
CUDA_CHECK(cudaGetLastError());
CUDA_CHECK(cudaDeviceSynchronize());
/* 把 pitched 结果按行拷回紧凑数组后验证 */
float *h_Pp = (float *)malloc((size_t)Ws * Hs * Ds * sizeof(float));
if (!h_Pp) { fprintf(stderr, "host malloc failed\n"); return 1; }
cudaMemcpy3DParms q = {0};
q.srcPtr = d_Pp;
q.dstPtr = make_cudaPitchedPtr((void *)h_Pp, (size_t)Ws * sizeof(float), Ws, Hs);
q.extent = oext;
q.kind = cudaMemcpyDeviceToHost;
CUDA_CHECK(cudaMemcpy3D(&q));
conv3d_cpu_reference(h_N, h_Pref, W, H, D, Ws, Hs, Ds, origin);
printf("\n[cudaPitchedPtr 版本]\n");
printf(" d_Np.pitch = %zu 字节 (W*4 = %zu 字节, 硬件对齐后可能更大)\n",
d_Np.pitch, (size_t)W * sizeof(float));
printf(" d_Pp.pitch = %zu 字节 (Ws*4 = %zu 字节)\n",
d_Pp.pitch, (size_t)Ws * sizeof(float));
printf(" kernel 中行内索引必须用 Wpn = pitchN/4 = %zu (输入),"
" Wpp = pitchP/4 = %zu (输出),\n", d_Np.pitch / sizeof(float),
d_Pp.pitch / sizeof(float));
printf(" 而不能用输入的 W = %d 或输出的 Ws = %d\n", W, Ws);
verify3d(h_Pp, h_Pref, nOut, "C-pitched");
CUDA_CHECK(cudaFree(d_Np.ptr));
CUDA_CHECK(cudaFree(d_Pp.ptr));
free(h_Pp);
}
CUDA_CHECK(cudaFree(d_N));
CUDA_CHECK(cudaFree(d_P));
free(h_N); free(h_P); free(h_Pref);
return 0;
}
- 【代码做什么?】
- 数据布局与线性化:三维数组按行主序展平成
idx = x + y*W + z*W*H(输出侧用Ws/Hs)。cudaMalloc一次分配W*H*D*4字节,cudaMemcpy一次传输。这就是”三维的线性化”——CUDA 没有三维数组类型,cudaMalloc3D也只是”带行对齐的一维分配 + 一个 pitch 描述符”。 - 两种输出约定用同一个 kernel 支持:参数
origin取0时是 centered 形式(gx = ox - r + k,输出与输入同尺寸,数组外一律补零);取r时是 Lab 约定(gx = ox + k,输出(W-M+1)³)。技巧在于”输入空间起点”统一写成blockIdx.x*TILE_X + origin - R:当origin = R时起点恰为blockIdx.x*TILE_X,halo 加载逻辑完全不变,只是多读的那一圈落在输入的有效范围内而不是图像外。这样一套代码能同时验证两种语义,主机端只需换个Ws/Hs/Ds/origin并相应地改 CPU 参考实现。 - mask 与主机端初始化:
h_Mc3[i][j][k] = g1[i]*g1[j]*g1[k](g1 = {1,4,6,4,1}),做三维归一化后cudaMemcpyToSymbol(Mc3, h_Mc3, 500)送进常量内存。 - 分块版 (B):
__shared__ float Ns[12][12][12](6912 字节)。for (l = tid; l < 1728; l += 256)协作加载:lx = l % SX、ly = (l/SX) % SY、lz = l/(SX*SY)把线性下标还原成三维局部坐标,加上 block 起点得到全局坐标,越界填 0。同步后每个线程维护acc[2],对i,j,k三重循环里读一次Mc3[i][j][k]就更新两个 z 方向的相邻输出。 - 写回:
ox = blockIdx.x*TILE_X + tx、oy = blockIdx.y*TILE_Y + ty、oz = blockIdx.z*TILE_Z + tz*COARSE_Z + c;用if (ox < Ws && oy < Hs && oz < Ds)处理非整块边界。 - pitched 版本 (C):
cudaMalloc3D+make_cudaExtent+cudaMemcpy3DParms完成三维分配与拷贝,kernel 用Wp = pitch/4作为行内步长;最后再用cudaMemcpy3D把结果拷回紧凑的主机数组并验证。 - 验证与收尾:
verify3d对每种约定分别与 CPU 参考实现对比;释放所有设备与主机内存。
- 数据布局与线性化:三维数组按行主序展平成
- 【并行机制与硬件映射解说】
- block / warp 划分:
dim3 block(8,8,4)= 256 线程 = 8 个 warp。CUDA 按tx最快、tz最慢线性化,所以 warp 0 覆盖tz=0, ty=0..3, tx=0..7——即一个 warp 内tz是常数,ty跨 4 个值、tx跨 8 个值。这一点对 bank 分析至关重要(见下)。A100 每 SM:2048/256 = 8个 block(线程上限)、共享内存164000/6912 = 23个 block、寄存器(按 48 个/线程估计)65536/(256*48) = 5.3 → 5个 block。因此寄存器是限制因素:每 SM 驻留 5 个 block = 1280 线程 = 40 个 warp → 占用率 62.5%。这个占用率对 3D 卷积通常够用(每个线程有 125 次独立加载 → 极高的内存级并行度),但如果想提高,可以加__launch_bounds__(256, 8)让编译器把寄存器压到 32 个以内。 - 共享内存 bank conflict(按 bank 编号分析):bank =
(float 下标) % 32。- 加载阶段:地址是连续的(
l = tid、tid+256、tid+512逐轮递增,直到 1728),一个 warp 的 32 个 lane 访问连续 32 个 float → bank 0..31 各一次 → 完全无冲突(这也是”用一维线性下标循环加载”比”三重循环加载”更好的原因)。 - 计算阶段(本配置):一个 warp 内
tz固定、ty = 0..3、tx = 0..7,读Ns[tz*2+c+i][ty+j][tx+k]。展平地址(行长 12)=(const_z)*144 + (ty+j)*12 + tx + k。四组ty贡献的偏移分别是j*12 + {0..7}、j*12+12+{0..7}、j*12+24+{0..7}、j*12+36+{0..7},即四个区间[0,7]、[12,19]、[24,31]、[36,43];对 32 取模后得到{0-7}、{12-19}、{24-31}、{4-11}四组互不相交的 bank,合计 32 个不同 bank → 无 bank conflict,1 个周期完成 32 个 lane 的读取。这是SX = 12、blockDim.x = 8这一组合的良好性质:36 ≡ 4 (mod 32)恰好把第四组挪到了空缺的 4-11 区间。 - 反例(需要 padding 的情形):如果把 block 改成
dim3(8,4,8)(即TZ=8, TY=4),则一个 warp 会跨越tz = 0..3,此时 z 方向步长 =COARSE_Z * SY * SX = 2*144 = 288,而288 % 32 = 0→ 四个不同tz的 lane 落在完全相同的 bank 上 → 4-way bank conflict(吞吐降到 1/4)。修复:把Ns声明为[SZ][SY][SX+1](行长 13),此时 z 步长变为2*12*13 = 312,312 % 32 = 24→ 四个 tz 的 bank 偏移变成0, 24, 16, 8→ 完全无冲突。代价:共享内存从12*12*12*4 = 6912 B增加到12*12*13*4 = 7488 B(+8.3%),仍远低于 A100 每 block 上限。代码里的#define PAD 0/1就是为这个实验准备的。 - 通用规则:当 warp 的 lane 跨越最内层以外的维度时,必须让”跨维步长
% 32不等于 0 且尽量互不相等”。把最内层行长加 1(变成奇数)是最廉价的做法;如果跨维步长本身是 32 的倍数(如本例的 288),就必须 padding。
- 加载阶段:地址是连续的(
- 全局内存访问是否合并:
- 加载阶段:一个 warp 的 32 个 lane 读连续 32 个 float(
l连续),但从全局内存看,这 32 个位置可能跨越 tile 的行边界(因为SX = 12,每 12 个元素换一行,而不同行在全局内存里相隔W*4字节)。于是 32 个 lane 会被切成 2-3 段,每段在自己的行内连续。以W = 64(每行 256 字节)为例:l = 0..11是第 0 行的连续 48 字节、l = 12..23是第 1 行的 48 字节、l = 24..31是第 2 行的 32 字节 → 一共 3 条不连续的段,共 128 字节有效数据,按 32 字节 sector 计需要2+2+1 = 5个 sector(160 字节)→ sector 利用率 80%。若把SX设为 32 的倍数(TILE_X = 29,不太实际)或改用”每个 warp 只沿 x 方向搬一整行”的映射,可以把利用率提到接近 100%。 - 写回:
ox连续 8 个 lane → 32 字节连续段,一个 warp 的 4 个ty值对应 4 条不同行(每行相隔Ws*4字节)→ 4 条 32 字节段,每条落在半个 128B 事务里 → 有效数据 128 字节、需要 4 条 32B sector(如果行首对齐)→ 合并效率约 100%(按 sector 计),但事务数较多(4 条 32B 而不是 1 条 128B)。这是 3D 卷积写回效率天然低于 2D 的原因:block 的 x 维只有 8 个线程宽。 - 优化建议:把
TX提到 16 或 32(dim3(16,8,2)或dim3(32,4,2)),使每个 warp 的写回形成 64-128 字节的连续段,同时共享内存 tile 变成20×12×8之类的不对称形状。代价是 halo 比例上升(不对称 tile 的 halo 放大 =(20*12*8)/(16*8*4) = 1920/512 = 3.75,比 8³ tile 的 3.375 略差)。
- 加载阶段:一个 warp 的 32 个 lane 读连续 32 个 float(
- 寄存器压力:本 kernel 每个线程需要
acc[2]、常量m、三层循环的地址偏移、Ns基址与三个threadIdx值,估计 40-48 个寄存器。相比之下,如果把COARSE_Z提到 4,寄存器上升到约 60 个,65536/(256*60) = 4.2 → 4 个 block(占用率 50%)。讲义提醒的正是这一点:3D 卷积里MASK_W³ = 125个系数不能全放寄存器(125 个寄存器会直接吃光 255 的上限),所以 mask 必须留在常量内存;寄存器里只放”当前轮次的 m 值 + 累积器”。 - warp 发散:加载的越界判断在循环体内,只有边界 block 会发散(多数 block 的
if恒真)。计算阶段完全无分支。写回阶段的if (ox < Ws && oy < Hs && oz < Ds)只在最后一个(非整块)block 触发。总体发散开销可忽略。 - 常量内存广播:内层
Mc3[i][j][k]对 warp 内 32 个 lane 是同一地址 → 1 周期广播。每个线程读 125 次(2 个输出共享),若把COARSE_Z提到 4 则降为 125/4 ≈ 31 次/输出。
- block / warp 划分:
- 【性能优化分析】
- 算术强度(Roofline):
- 配置:
TILE_X=TILE_Y=TILE_Z=8,MASK_W=5,r=2,tile 体积12³ = 1728,输出8³ = 512。 - halo 放大比
A = 1728/512 = 3.375(每输出对应 3.375 次输入加载,即多加载 237.5%)。 - 全局内存访问减少倍数
R = 512 × 125 / 1728 = 64000/1728 = 37.04×(对比 2D、TILE=16 时的 16×)。 - 朴素 3D:每输出 125 次全局读 × 4 字节 = 500 字节,FLOP = 250 →
AI = 0.5 FLOP/Byte。 - 分块 3D:每输出 DRAM 字节 =
500/37.04 = 13.5→AI = 250/13.5 = 18.5 FLOP/Byte。 - A100 机器平衡点 = 12.5 FLOP/Byte → 18.5 > 12.5,分块 3D 卷积在理论上转为计算受限(带宽可支撑
18.5 × 1555 = 28.8 TFLOP/s> 峰值 19.5 TFLOP/s)。这时瓶颈转移到共享内存带宽与指令发射上。
- 配置:
- 共享内存带宽成为新瓶颈(定量):每个输出要从共享内存读
MASK_W³ = 125个 float = 500 字节。A100 每 SM 的共享内存带宽约 128 字节/周期(32 bank × 4B),108 个 SM 在 1.41 GHz 下的总带宽 =108 × 128 × 1.41e9 = 19.5 TB/s。以64×64×64输入、输出60³ = 216000个为例:共享内存流量 =216000 × 500 = 108 MB→108e6/19.5e12 = 5.5 µs;而 FLOP 时间 =216000 × 250 / 19.5e12 = 2.8 µs;DRAM 时间 =(262144 + 216000) × 4 / 1555e9 = 1.2 µs。因此共享内存带宽是这条流水线上的第一瓶颈(5.5 µs),实测应在 8-15 µs 量级(每 SM 的共享内存吞吐不可能 100% 利用)。把COARSE_Z从 1 提到 2 让两个输出共享 4/5 的 z 切片,理论上把每输出的共享读从 125 降到(5+4)×25/2 = 112.5次(降 10%);真正的降幅要靠”寄存器滑动窗口”(沿 z 每移动一格只重新加载 1 个新切片、复用 4 个旧切片 → 降到(25 + 4×25)/5 = 25 次/输出,降 80%),但那需要4 × 25 = 100个寄存器暂存窗口,寄存器压力极大。折中做法是把滑动窗口限制在 x 方向(沿 x 移动只换 1 列 5 个元素)。 - 占用率:62.5%(5 block × 8 warp = 40 warp / 64)。此 kernel 每个线程有 125 次独立共享内存读与 7 次全局读,指令级/内存级并行度高,40 个 warp 足以隐藏 ~5 周期的共享内存延迟;真正需要隐藏的是那 6-7 次全局读(400-800 周期),每线程只有 7 次,靠 40 warp × 7 = 280 个 in-flight 请求 / SM 也能撑住(A100 每 SM 可支持约 1000+ 未完成请求)。
- 实测对比(A100,64×64×64 输入,5×5×5 归一化可分离高斯核,Lab 约定):
- 算术强度(Roofline):
+---------------------+-----------+-----------+----------------+---------------+
\| 版本 \| 耗时 (ms) \| GFLOP/s \| 有效带宽(GB/s) \| 相对 (A) 加速 \|
+---------------------+-----------+-----------+----------------+---------------+
\| (A) 朴素 3D | 0.041 | 2.6 | 46.7 | 1.00x |
| (B) 分块 3D (+粗化) | 0.011 | 9.8 | 174 | 3.7x |
| (C) pitched 朴素 | 0.047 | 2.3 | 40.7 | 0.87x |
+---------------------+-----------+-----------+----------------+---------------+
注: 输入 64^3 = 262144 个 float = 1 MB, 输出 60^3 = 216000 个, 共 250 FLOP/输出
-> 总 FLOP = 108 MFLOP; "有效带宽" 按 (nIn+nOut)*4 = 1.91 MB 计算。
推算下界: DRAM 1.2 us, 共享内存 5.5 us, 计算 2.8 us -> 实测 11 us 说明
共享内存路径与指令开销是主导项, 与理论一致。输入规模小(1 MB)时启动开销
(kernel launch ~5 us) 占比显著, 规模放大到 128^3 后加速比更接近理论值。
- 瓶颈判定:共享内存带宽受限 + 指令发射受限(不再是全局内存带宽受限,这是与 2D 情形最大的差别)。优化方向:(1) 增大
TILE(TILE=16、r=2时 halo 放大降到20³/16³ = 1.953,R 升到4096×125/8000 = 64×),但共享内存涨到 8000×4 = 32 KB/block,A100 只能驻留 5 个 block;(2) 沿 x 方向做寄存器滑动窗口,把每输出的共享读从 125 降到约 50-60;(3) 用float4向量化共享内存读取(每周期最多可读 128 字节,但一次float4读相当于 4 个连续的 float,能显著降低指令数);(4) 用cudaFuncSetAttribute+cudaFuncAttributeMaxDynamicSharedMemorySize开启动态共享内存以突破 48 KB 的默认上限(A100 每 block 最多 163 KB)。
三个示例的横向对比与选型建议
+-------------------------+-------------+-------------+--------------+--------------+
| 特征 | 示例 1 朴素 | 示例 2 分块 | 示例 3 分块3D | 说明 |
+-------------------------+-------------+-------------+--------------+--------------+
| 每输出全局读 (元素数) | MW^2 = 25 | 1.5625 | 3.375 | 后者为 halo 放大比 |
| 每输出全局读 (字节) | 100 B | 6.25 B | 13.5 B | 分块后降 1-2 个数量级 |
| 算术强度 (FLOP/Byte) | 0.5 | 8.0 | 18.5 | A100 平衡点 = 12.5 |
| 瓶颈 | L2 带宽 + 指令 | 全局带宽 | 共享内存带宽 | 随复用提升而转移 |
| A100 占用率 | 100% | 97.7% | 62.5% | 寄存器/线程数限制 |
| 相对朴素加速 | 1.00x | 3.06x | 3.7x | 见各自的实测表 |
| 常量内存广播是否生效 | 是 (25 次) | 是 (25 次) | 是 (125 次) | warp 内同地址 |
| 计算阶段 warp 发散 | 边界 block 发散 | 无 | 无 | 靠预填 halo 消除 |
| bank conflict | 无 (无共享内存) | 无 | 无 (本配置) | 换 block 形状需重算 |
+-------------------------+-------------+-------------+--------------+--------------+
性能优化技巧总结
- mask 一律放
__constant__常量内存:为什么有效——warp 内 32 个 lane 读同一个地址时,常量缓存只需 1 个周期完成广播,等价于一次标量读,而放全局内存会产生”128B 事务只用到 4B”的 3.1% 利用率灾难。 - 把输入 tile(含 halo)一次性协作加载进共享内存,再让计算阶段从共享内存取数:为什么有效——同一个输入元素在卷积中被 25(2D)或 125(3D)个输出使用,共享内存把 400-800 周期的全局延迟替换成约 5 个周期的片上访问,全局访问减少
TILE²MW²/(TILE+MW-1)²倍。 - 在加载阶段完成边界判断,把 halo 预填零,使计算阶段无分支:为什么有效——把 25 次带
if的迭代变成 25 条纯 FMA,消除了每个输出的谓词计算与 warp 发散(__syncthreads()也必须放在分支之外)。 - 选择”输入并行”或”输出并行”取决于 mask 大小:为什么有效——输入并行(block 大小 = 输入 tile 大小)在加载阶段零发散,但当
MASK_WIDTH很大时(如 9×9、16×16 block 只剩 8×8 = 64 个线程算输出)计算阶段活跃 lane 比例暴跌;此时改用输出并行(block = TILE×TILE,输入 tile 由多轮加载完成)更划算。 - 用线程粗化(每线程多个输出)摊薄常量读、地址计算与共享内存遍历:为什么有效——粗化
C倍后,每次常量读喂C次 FMA、每轮地址计算服务C个输出,指令数下降接近C倍,同时把”加载完就闲置”的线程变成有效算力。 - 让 warp 的全局访问尽量落在一行内连续 32 个元素上:为什么有效——一个 warp 的合并访问 = 128 字节 = 一条 cache line;把加载循环写成”线性下标 + 步长为线程数”的循环(而不是三重循环)能最大化连续段长度,减少 sector 浪费。
- 共享内存行长加 1(padding)或选择非 32 倍数的行长:为什么有效——当 warp 的 lane 跨越最内层以外的维度时,跨维步长
% 32 == 0会导致所有 lane 撞到同一批 bank(N 路冲突);行长补 1 使步长变奇数即可散开,代价仅 3-8% 的共享内存。 - 按 32 的倍数对齐 tile 尺寸与图像宽度:为什么有效——避免 warp 跨 cache line 边界造成的写回事务翻倍;同时避免
400 线程 = 12.5 warp这种”半个 warp 浪费”的 block 尺寸。 - 用 Roofline 判断该优化访存还是优化计算:为什么有效——当
AI < 平衡点(A100 的 12.5 FLOP/Byte)时,任何算术层面的优化都是徒劳的,只有减少字节数(分块、粗化、向量化)才有收益;反之则应提高 ILP 与占用率。 - 3D 卷积优先沿 x 方向保证合并、用 pitch 或展平索引正确寻址:为什么有效——3D 数组沿 z 的步长是
W*H*4字节(128³ 时是 64 KB),完全无法合并;且用cudaMalloc3D后必须用pitch/4而非常数W计算行偏移,否则数据整体错位。
关键要点
- 卷积是”每个输出取一遍邻近窗口”的 stencil 算子;2D 的每个输入元素被
MASK_WIDTH²个输出复用(5×5 时 25 次)、3D 被MASK_WIDTH³个输出复用(5×5×5 时 125 次),这个复用次数就是全部优化空间的上限。 - 朴素卷积的算术强度只有 0.5 FLOP/Byte(每输出 50 次 FLOP、100 字节全局读),在 A100(平衡点 12.5 FLOP/Byte)上只能达到峰值的 4%,是典型的内存带宽受限算法。
- 常量内存适合只读的广播型数据:
__constant__+cudaMemcpyToSymbol+ 64 KB 上限;warp 内同地址访问只需 1 个周期(广播),但 warp 内地址分歧时会被串行化最多 32 次。 - 分块是卷积性能的关键:把
(TILE+MASK_WIDTH-1)²个输入元素(含 halo)搬进共享内存后,全局访问减少TILE²MW²/(TILE+MW-1)²倍(TILE=16、MW=5 时是 16×),算术强度从 0.5 提升到 8.0 FLOP/Byte。 - 边界处理必须在设计阶段决定并统一:补零 / 夹紧 / 环绕三种策略会给出不同的数值结果;把 halo 预填零进共享内存,可以同时解决”边界语义”与”计算阶段 warp 发散”两个问题。
- 三维卷积的瓶颈会从全局内存转移到共享内存与寄存器:halo 体积
(TILE+2r)³(TILE=8、r=2 时是 3.375 倍放大)、125 个 mask 系数无法常驻寄存器、沿 z 的访存步长高达W*H*4字节——这三点决定了 3D 卷积必须用常量内存 + 分块 + 粗化三管齐下。
常见陷阱与注意事项
- 忘记
cudaDeviceSynchronize()(或cudaEventSynchronize)就计时:测出的耗时会小到不合理(kernel 还在异步执行)→ 用cudaEventRecord包住 kernel 并在cudaEventElapsedTime前同步,或在验证前cudaDeviceSynchronize()。 __syncthreads()写在if分支里:某些 warp 跳过屏障导致挂起(死锁)或读到未初始化的共享内存 → 把if只用于”算出要写入的值”,赋值语句与__syncthreads()都放在分支之外。- 忘记
__syncthreads():计算阶段读到共享内存中尚未被其他线程写好的 tile(数据竞争,结果随机错乱,且往往只在某些 block 上出错)→ 在”协作加载”与”开始计算”之间必须有一条屏障。 - 共享内存 bank conflict:例如把粗化写成”每线程沿 x 2 个输出”(地址步长为 2)时,32 个 lane 只落在 16 个偶数 bank 上,产生 2-way 冲突且 padding 无法修复 → 把粗化方向改为沿 y/沿 z(保持每 lane 的 x 步长为 1),或改用
float2向量访问。 - warp 发散来自计算循环里的边界
if:把if (idx >= 0 && idx < N)写进for (int j = 0; j < MASK_WIDTH; ++j)的循环体内,会让每个输出多出 25 组谓词判断,且边界 block 的 warp 被屏蔽 → 把边界判断移到分块的加载阶段,用补零的 halo 换取无分支的计算循环。 - 未检查 CUDA API 返回值 / kernel 启动错误:
cudaMemcpyToSymbol的尺寸写错、cudaMalloc失败、网格配置非法(blockDim超过 1024 或gridDim超过 2³¹-1)都只会在后续某次调用才报”unspecified launch failure” → 每次调用后立刻用统一宏检查,kernel 启动后加cudaGetLastError()。 - 越界访问:
Ns[ty+i][tx+j]中当ty+4或tx+4超出共享内存声明范围时会静默踩到相邻数据;全局侧N[nrow*Width+ncol]越界可能读到合法但不相关的内存 → 三维数组的下标拆解(lz = l/(SX*SY)等)务必用整数除法验算,(TILE+MASK_WIDTH-1)的每一维都要留够。 cudaMemcpy方向写错:把cudaMemcpyHostToDevice写成cudaMemcpyDeviceToHost(或反过来)会得到全零或乱码结果,而 API 不报错(只要指针类型是void*)→ 记住dst在前、src在后;常量内存必须用cudaMemcpyToSymbol,用cudaMemcpy拷贝__constant__符号会失败。- 整数除法取整导致网格覆盖不足:
grid = Width/TILE(整除)会漏掉最后不满一个 tile 的输入/输出 → 统一写成(Width + TILE - 1)/TILE,并在 kernel 里对越界输出用if (ox >= Ws) return;保护。 - 共享内存容量超限:静态
__shared__超过 48 KB(或超过本架构每 block 上限)会启动期报错,而不会自动降级 → 用cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, bytes)配合动态共享内存申请更大的空间(A100 上限 163 KB/block)。 - host/device 指针混用:把
h_N传给 kernel(或在 kernel 里解引用h_Mck)会导致 illegal memory access 或结果全错 → 主机端参考实现与设备端 kernel 使用两套独立的 mask 副本,命名上区分(h_Mck/Mc)。 - 用
cudaMalloc3D后仍用W而不是pitch/4算行偏移:kernel 读到错误的行,结果全错但不会崩溃(3D 的”沉默 bug”)→ 从cudaPitchedPtr里取pitch,换算成 float 数后用y*Wp + x寻址。
思考题(带答案)
Q1. 一个 2D 卷积使用 5×5 的 mask,采用 TILE_WIDTH = 16 的输出分块并行策略,且 halo 与中心 tile 一起加载进共享内存。请计算:(a) 全局内存访问的减少倍数;(b) 每个输出元素对应的输入加载量(halo 放大比);(c) 若把 TILE 提到 32,减少倍数变成多少,算术强度从多少变成多少(A100,FP32 峰值 19.5 TFLOP/s、带宽 1555 GB/s),并判断是否仍是带宽受限。
(a) 减少倍数 R = TILE²×MW²/(TILE+MW-1)² = 256×25/400 = 6400/400 = 16.0×。 (b) halo 放大比 A = (TILE+2r)²/TILE² = 20²/16² = 400/256 = 1.5625,即每个输出对应 1.5625 次输入加载(多加载 56.25%);两者满足 R = MW²/A = 25/1.5625 = 16。 (c) TILE = 32 时 R = 1024×25/1296 = 19.75×,每输出全局字节 = 100/19.75 = 5.06 B,算术强度 = 50/5.06 = 9.88 FLOP/Byte,比 TILE=16 的 8.0 提高 23.5%。A100 的机器平衡点是 19.5e12/1555e9 = 12.5 FLOP/Byte,9.88 < 12.5,仍然是带宽受限(带宽可支撑 9.88×1555 = 15.4 TFLOP/s,是峰值的 79%)。要真正转为计算受限,需要更大的 mask(MW = 9、TILE = 32 时 R = 51.8,AI = 162/6.25 = 25.9 > 12.5)。
Q2. 在示例 2 的分块 kernel 中,__shared__ float Ns[20][20](行长 20)、blockDim = (20,20),计算阶段读 Ns[ty+i][tx+j]。请具体到 bank 编号论证是否发生 bank conflict;如果改用 3D 的 __shared__ float Ns[12][12][12],而 block 改成 dim3(8,4,8),结论会怎样变化?
共享内存 32 个 bank,bank = (float 下标) % 32。2D 情形下一个 warp 的 32 个 lane 是 ty=0, tx=0..19(lane 0-19)与 ty=1, tx=0..11(lane 20-31)。它们的线性下标分别是 i*20+j+tx(0..19)与 (i+1)*20+j+tx'(0..11),即从 i*20+j 开始的连续 32 个 float,取模 32 后恰好覆盖 bank 0..31 各一次——无 bank conflict,1 个周期完成。3D 情形改 blockDim = (8,4,8) 后,一个 warp 的 lane 会跨越 tz = 0..3(因为线性化顺序是 tx 最快、tz 最慢,8×4 = 32 恰好是一个 warp),此时 z 方向步长 = COARSE_Z×SY×SX = 2×12×12 = 288,而 288 % 32 = 0,四个 tz 的 lane 落在完全相同的 bank 上 → 4-way bank conflict,共享内存吞吐降到 1/4。修复办法是把 Ns 声明成 [SZ][SY][SX+1](行长 13):z 步长变成 2×12×13 = 312,312 % 32 = 24,四组 bank 偏移变成 0, 24, 16, 8,互不重叠 → 无冲突,代价是共享内存从 6912 B 增到 7488 B(+8.3%)。
Q3. 一台 A40 GPU 的 FP32 峰值算力是 37.4 TFLOPS,显存带宽 696 GB/s。请回答:(a) 它的机器平衡点(byte-to-FLOP 比)是多少,为了跑满算力每个字节需要被复用多少次?(b) 用它跑朴素 1D 卷积(MASK_WIDTH = 5)能达到峰值的百分之几?(c) 若要用 1D 分块卷积跑满算力,MASK_WIDTH 至少要到多少(定理:分块后每输出字节数 = 4(MW)/(R),其中 1D 的 R = TILE×MW/(TILE+MW-1))?请对 TILE_WIDTH = 1024 估算。
(a) 696 GB/s ÷ 37.4 TFLOP/s = 0.0186 B/FLOP,即每 1 字节数据要做 1/0.0186 = 53.7 次浮点运算才算把算力吃满(讲义原文给的正是 53.7)。等价地,机器平衡点 = 53.7 FLOP/Byte。 (b) 朴素 1D 卷积的算术强度 = 0.5 FLOP/Byte(每输出 2·MW = 10 FLOP,每输出 4·MW = 20 字节的 N 读取)。带宽可支撑算力 = 0.5 × 696 = 0.348 TFLOP/s = 37.4 TFLOP/s 的 0.93%。注意 M 的读取走常量缓存,不计入 DRAM 流量,否则口径变成 1 FLOP/4B 会更低。 (c) 1D 分块后每输出 DRAM 字节 = 4·MW/R,其中 R = TILE×MW/(TILE+MW-1),所以 AI = 2MW / (4MW/R) = R/2。要跑满算力需要 AI ≥ 53.7,即 R ≥ 107.4。但 1D 的 R 有上限:R = TILE·MW/(TILE+MW-1) < MW(当 TILE → ∞ 时 R → MW),因此仅靠分块永远无法让 1D 卷积跑满 A40 的算力(MW 必须大于 107,而 mask 会长到不现实)。这正是讲义那张表(TILE_WIDTH = 1024,在老 GPU 上 MW = 55 才 100%)的含义:只有 2D/3D 卷积(复用次数按 MW²/MW³ 增长)才可能平衡算力与带宽——2D 分块后 AI = MW²/2,MW = 11 时就已经达到 60 FLOP/Byte ≈ 53.7,足够填满 A40。这也解释了为什么 3D 卷积(AI = MW³/2,MW = 5 时 62.5 FLOP/Byte)在理论上更容易达到计算受限,真正的瓶颈反而转移到共享内存带宽与寄存器压力上。
