Lecture 8: 并行模式六 —— 分块卷积:常量内存与边界处理 (对应 Lab 6 / Lab 4: 3D Convolution)

目录 · ← l7 · l9 →

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 = 5Mask_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(也叫 apronghost 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 = 16MASK_WIDTH = 5r = 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 = 8r = 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 cellsapron 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

性能特征的三条要点:

  1. 减少倍数只取决于 TILE 与 MASK_WIDTH,与图像大小无关(忽略边界瓦片)。这就是为什么”每个 block 负责的瓦片越大、mask 越大,分块越值”。
  2. 每个输出对应的输入加载量(halo 放大比)A = (TILE+2r)²/TILE²,与减少倍数满足 R = MASK_WIDTH²/A。例如 TILE=16, MW=5A = 1.5625R = 25/1.5625 = 16
  3. 边界瓦片的比例会变(讲义 Problem Solving):如果输入是 32×32、输出瓦片 16×16、mask 5×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 = 16MASK_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×M mask 的卷积,输出尺寸为 (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=8MASK_W=5r=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;
}
  • 【代码做什么?】
    1. 准备 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)。
    2. 分配并初始化cudaMalloc 两块 Width*Height*4 字节的设备内存 d_Nd_PcudaMemcpy 把输入从主机拷到设备。
    3. 线程映射dim3 block(16,16) 共 256 个线程;grid = (Width/16, Height/16)。线程 (threadIdx.x, threadIdx.y) 负责输出 P[row*Width + col],其中 col = blockIdx.x*16 + threadIdx.xrow = blockIdx.y*16 + threadIdx.y。这是最简单的”一个线程一个输出”映射。
    4. 计算:外层 i 遍历 mask 的 5 行,nrow = row - 2 + i;若越界则 continue(等价于该行贡献 0)。内存 j 遍历 5 列,同样越界 continue。内层累计 Pvalue += N[nrow*Width + ncol] * Mc[i*5+j]
    5. 写回与验证P[row*Width+col] = Pvalue 一次写入;主机取回 h_P,用三重循环的 CPU 参考实现(用同一份归一化 mask 的主机副本 h_Mck)逐元素对比,误差阈值 1e-3
    6. 计时:先跑一次预热,再用 cudaEventRecord 包裹 5 次 kernel 执行,取平均毫秒数,换算 GFLOP/s 和有效带宽。
  • 【并行机制与硬件映射解说】
    • 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 字节有效数据。由于 ij 的偏移只改变 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×5 mask、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 可轮换)。
  • 【性能优化分析】
    • 算术强度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;
}
  • 【代码做什么?】
    1. 版本 (A) 朴素版与示例 1 相同:block 16×16,一个线程一个输出,mask 走常量内存,边界用 continue 跳过,作为加速比的基准。
    2. 版本 (B) 分块版dim3 block(20,20) = 400 线程,每线程负责输入 tile 中的一个元素。row_i = blockIdx.y*16 - 2 + tycol_i = blockIdx.x*16 - 2 + tx 把”输出瓦片的坐标”整体向左上平移 2(即 MASK_RADIUS),从而得到输入 tile 的坐标。若该坐标超出图像范围(只有图像边界的 block 会遇到),v 保持 0——这就是在加载阶段一次性完成的 zero paddingNs[ty][tx] = v; 写在 if 之外,保证所有 400 个线程都参与、且不会有人跳过后面的 __syncthreads()。同步之后,前 16×16 = 256 个线程(ty < 16 && tx < 16)各自从共享内存取 5×5 窗口、与常量 mask 做 25 次 FMA,写回一个输出。计算循环里没有任何 if
    3. 版本 (C) 分块 + 粗化版dim3 block(8,8) = 64 线程。加载阶段用带步长的循环 for (l = tid; l < 400; l += 64) 把 400 个元素搬进来(每线程 6-7 次),仍然在越界时补零。计算阶段每个线程维护 acc[2][2] 四个寄存器累加器,外层 ij 遍历 mask 时只读一次 Mc[i*5+j],然后用同一个 m 更新四个输出;这样 25 次常量读就喂出了 100 次 FMA。
    4. 主机端:统一的 CUDA_CHECKcudaMalloc/cudaMemcpytime_kernel() 辅助函数(预热 + 5 次取平均)、verify() 逐元素对比 CPU 参考实现,最后打印三个版本的耗时、GFLOP/s、有效带宽与加速比,以及分块的理论减少倍数。
    5. 收尾:销毁 event、cudaFreefree
  • 【并行机制与硬件映射解说】
    • 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..191*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*tyox0 = 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)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%。
    • warp 发散:版本 (B) 的计算阶段零发散——256 个参与计算的线程全在同一段直线代码里执行 25 次 FMA。发散只出现在 if (row_o < Height && col_o < Width) 这一句(仅边界 block 触发)。更要紧的是版本 (B) 的计算阶段活跃 lane 比例只有 256/400 = 64%ty ≥ 16tx ≥ 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 的上限。
  • 【性能优化分析】
    • 算术强度(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.25AI = 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 倍
    • 占用率与延迟隐藏:版本 (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 次平均)
   +------------------+-----------+-----------+------------------+----------------+
   | 版本              | 耗时 (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;
}
  • 【代码做什么?】
    1. 数据布局与线性化:三维数组按行主序展平成 idx = x + y*W + z*W*H(输出侧用 Ws/Hs)。cudaMalloc 一次分配 W*H*D*4 字节,cudaMemcpy 一次传输。这就是”三维的线性化”——CUDA 没有三维数组类型,cudaMalloc3D 也只是”带行对齐的一维分配 + 一个 pitch 描述符”。
    2. 两种输出约定用同一个 kernel 支持:参数 origin0 时是 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 参考实现。
    3. mask 与主机端初始化h_Mc3[i][j][k] = g1[i]*g1[j]*g1[k]g1 = {1,4,6,4,1}),做三维归一化后 cudaMemcpyToSymbol(Mc3, h_Mc3, 500) 送进常量内存。
    4. 分块版 (B)__shared__ float Ns[12][12][12](6912 字节)。for (l = tid; l < 1728; l += 256) 协作加载:lx = l % SXly = (l/SX) % SYlz = l/(SX*SY) 把线性下标还原成三维局部坐标,加上 block 起点得到全局坐标,越界填 0。同步后每个线程维护 acc[2],对 i,j,k 三重循环里读一次 Mc3[i][j][k] 就更新两个 z 方向的相邻输出。
    5. 写回ox = blockIdx.x*TILE_X + txoy = blockIdx.y*TILE_Y + tyoz = blockIdx.z*TILE_Z + tz*COARSE_Z + c;用 if (ox < Ws && oy < Hs && oz < Ds) 处理非整块边界。
    6. pitched 版本 (C)cudaMalloc3D + make_cudaExtent + cudaMemcpy3DParms 完成三维分配与拷贝,kernel 用 Wp = pitch/4 作为行内步长;最后再用 cudaMemcpy3D 把结果拷回紧凑的主机数组并验证。
    7. 验证与收尾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 = tidtid+256tid+512 逐轮递增,直到 1728),一个 warp 的 32 个 lane 访问连续 32 个 float → bank 0..31 各一次 → 完全无冲突(这也是”用一维线性下标循环加载”比”三重循环加载”更好的原因)。
      • 计算阶段(本配置):一个 warp 内 tz 固定、ty = 0..3tx = 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 = 12blockDim.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 = 312312 % 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 略差)。
    • 寄存器压力:本 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 次/输出。
  • 【性能优化分析】
    • 算术强度(Roofline)
      • 配置:TILE_X=TILE_Y=TILE_Z=8MASK_W=5r=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.5AI = 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 MB108e6/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 约定)
   +---------------------+-----------+-----------+----------------+---------------+
   \| 版本                 \| 耗时 (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) 增大 TILETILE=16r=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 形状需重算 |
  +-------------------------+-------------+-------------+--------------+--------------+

性能优化技巧总结

  1. mask 一律放 __constant__ 常量内存:为什么有效——warp 内 32 个 lane 读同一个地址时,常量缓存只需 1 个周期完成广播,等价于一次标量读,而放全局内存会产生”128B 事务只用到 4B”的 3.1% 利用率灾难。
  2. 把输入 tile(含 halo)一次性协作加载进共享内存,再让计算阶段从共享内存取数:为什么有效——同一个输入元素在卷积中被 25(2D)或 125(3D)个输出使用,共享内存把 400-800 周期的全局延迟替换成约 5 个周期的片上访问,全局访问减少 TILE²MW²/(TILE+MW-1)² 倍。
  3. 在加载阶段完成边界判断,把 halo 预填零,使计算阶段无分支:为什么有效——把 25 次带 if 的迭代变成 25 条纯 FMA,消除了每个输出的谓词计算与 warp 发散(__syncthreads() 也必须放在分支之外)。
  4. 选择”输入并行”或”输出并行”取决于 mask 大小:为什么有效——输入并行(block 大小 = 输入 tile 大小)在加载阶段零发散,但当 MASK_WIDTH 很大时(如 9×9、16×16 block 只剩 8×8 = 64 个线程算输出)计算阶段活跃 lane 比例暴跌;此时改用输出并行(block = TILE×TILE,输入 tile 由多轮加载完成)更划算。
  5. 用线程粗化(每线程多个输出)摊薄常量读、地址计算与共享内存遍历:为什么有效——粗化 C 倍后,每次常量读喂 C 次 FMA、每轮地址计算服务 C 个输出,指令数下降接近 C 倍,同时把”加载完就闲置”的线程变成有效算力。
  6. 让 warp 的全局访问尽量落在一行内连续 32 个元素上:为什么有效——一个 warp 的合并访问 = 128 字节 = 一条 cache line;把加载循环写成”线性下标 + 步长为线程数”的循环(而不是三重循环)能最大化连续段长度,减少 sector 浪费。
  7. 共享内存行长加 1(padding)或选择非 32 倍数的行长:为什么有效——当 warp 的 lane 跨越最内层以外的维度时,跨维步长 % 32 == 0 会导致所有 lane 撞到同一批 bank(N 路冲突);行长补 1 使步长变奇数即可散开,代价仅 3-8% 的共享内存。
  8. 按 32 的倍数对齐 tile 尺寸与图像宽度:为什么有效——避免 warp 跨 cache line 边界造成的写回事务翻倍;同时避免 400 线程 = 12.5 warp 这种”半个 warp 浪费”的 block 尺寸。
  9. 用 Roofline 判断该优化访存还是优化计算:为什么有效——当 AI < 平衡点(A100 的 12.5 FLOP/Byte)时,任何算术层面的优化都是徒劳的,只有减少字节数(分块、粗化、向量化)才有收益;反之则应提高 ILP 与占用率。
  10. 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+4tx+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 = 32R = 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/Byte9.88 < 12.5仍然是带宽受限(带宽可支撑 9.88×1555 = 15.4 TFLOP/s,是峰值的 79%)。要真正转为计算受限,需要更大的 mask(MW = 9TILE = 32R = 51.8AI = 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 = 312312 % 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²/2MW = 11 时就已经达到 60 FLOP/Byte ≈ 53.7,足够填满 A40。这也解释了为什么 3D 卷积(AI = MW³/2MW = 5 时 62.5 FLOP/Byte)在理论上更容易达到计算受限,真正的瓶颈反而转移到共享内存带宽与寄存器压力上。