Lecture 6: 并行模式四 —— 归约:树形归约与 warp 级原语 (对应 Lab 4 / Lab 5: List Reduction)

目录 · ← l5 · l7 →

Lecture 6: 并行模式四 —— 归约:树形归约与 warp 级原语 (对应 Lab 4 / Lab 5: List Reduction)

概述

归约(reduction)是用一个满足结合律(associative)与交换律(commutative)的二元算子,把 N 个输入压缩成单个输出值的并行计算模式,它对应的实验是把一个任意长的列表(list)归约成一个标量,因此 Lab 目录里也写作 List Reduction(Lab 4 在部分学期的目录页上名为 Parallel Reduction,Lab 5 在 Lumetta 版时间线上名为 List Reduction,指向同一个”把大列表归约成一个值”的任务,并额外要求多轮 kernel 启动的层次化归约)。本讲从”顺序归约是 O(N) 的依赖链、无法并行”这一困境出发,给出树形归约(reduction tree)这一把步数降到 log₂N 的并行策略,并围绕它展开三条性能主线:数据到线程的映射方式(交错寻址 vs 顺序寻址)决定 warp 发散与共享内存 bank conflict;block 内”活跃线程数每轮减半”造成的资源浪费要靠线程粗化(thread coarsening)与 warp 级 shuffle 原语消除;跨 block 的合并要么用 atomicAdd,要么用第二次 kernel 启动。关键结论是:归约的算术强度极低(按”每读 4 字节做 1 次加法”计算只有 0.25 FLOP/Byte,按课程常用的”每 16 字节事务配 1 次算术”粗略口径约 0.06 FLOP/Byte),远低于 A100 的 Roofline 拐点 12.5 FLOP/Byte,因此它是一个带宽受限为主、延迟受限为辅的混合问题,性能上限由全局内存带宽直接决定:A100(1555 GB/s)上读入 1 亿个 float 的理论下限是 400 MB / 1555 GB/s ≈ 0.257 ms。

本讲的原始讲义为 UIUC ECE408/CS483/CSE408 Summer 2025 的 Lecture 11: Parallel Computation Patterns – Reduction Trees(Kirk/Hwu 与 Lumetta 的讲义拷贝),其中明确给出了”朴素的交错映射”与”更好的顺序映射”两版 kernel、__syncthreads() 的必要性论证、以及”数据集划分到 16 个 block、反复装载直到数据耗尽”的 compute-centric 改进方向。

核心概念与 GPU 架构图解

归约(Reduction)

  • 定义与目的:归约是把一个输入集合映射成单个值的运算:用二元算子 ⊗ 把 x₀, x₁, …, x_{N-1} 合并为 x₀ ⊗ x₁ ⊗ … ⊗ x_{N-1}。算子必须是结合律的((a⊗b)⊗c = a⊗(b⊗c))与交换律的(a⊗b = b⊗a),并且要有一个恒等元(identity) I 使得 I⊗a = a。常见实例:求和(+,I = 0)、求积(×,I = 1)、最小值(min,I = +∞ 或用数据类型最大值 FLT_MAX)、最大值(max,I = −∞ 或 −FLT_MAX)、以及按位与/或、逻辑与/或、用户自定义算子(例如”取模意义下的加法”、”字符串拼接”)。它解决的问题是:串行归约是一条长度为 N 的依赖链(第 i 次加法必须等第 i−1 次结束),在 GPU 上完全无法展开并行;而结合律与交换律一旦成立,就可以任意重新加括号、任意重排输入顺序,于是能把一个长度为 N 的依赖链拆成一棵深度只有 log₂N 的并行树。讲义把这一点总结为:集合中的事物是”无序且独立”的(unordered and independent),所以处理顺序可以任意指定。归约在并行库里被当作集合操作(collective operation)提供,地位与 barrier 相当,Google 的 MapReduce、Hadoop 都把”归约”做成框架级原语。

  • 直观解释(”它是什么?”):把归约想成世界杯淘汰赛。64 支队伍参赛,你不需要让 1 号队依次和 2 号、3 号、…、64 号各打一场(那是 63 场串行比赛、耗时 63 个”回合”);你只要让相邻两队同时开打(32 场并行),胜者再相邻配对(16 场并行),如此下去,只要 6 轮(log₂64 = 6)就能决出冠军——比赛总场数仍然是 63 场,和串行一样多,但时间从 63 轮压到 6 轮。这就是”树形归约”的全部直觉:工作量不变,关键路径长度从 N 变成 log₂N。而”算子必须可结合可交换”的含义就是:比赛不需要按抽签顺序打,谁和谁先打、哪个半区先打完,都不影响最终冠军是谁——但如果比赛规则是”必须按报名顺序依次挑战”,那就不满足交换律,无法这样并行。讲义里提到的”锦标赛用 max 做归约”正是这个类比的原型。

  • 架构/机制图解:用 8 个输入、算子 max 的树形归约(数值取自讲义第 10 页的例子 3, 1, 7, 0, 4, 1, 6, 3):

输入(8 个值,在全局内存中按线性地址排列,顺序与归约无关)
   ┌────┬────┬────┬────┬────┬────┬────┬────┐
   │ 3  │ 1  │ 7  │ 0  │ 4  │ 1  │ 6  │ 3  │
   └────┴────┴────┴────┴────┴────┴────┴────┘
     │    │    │    │    │    │    │    │
     └─max┘    └─max┘    └─max┘    └─max┘    第 1 轮:4 次 max,可 4 路并行
       │         │         │         │
       3         7         4         6       中间结果写回偶数下标
       └───max───┘         └───max───┘      第 2 轮:2 次 max,可 2 路并行
            │                   │
            7                   6
            └────────max────────┘           第 3 轮:1 次 max,串行
                      │
                      7                     最终结果在 partialSum[0]

关键操作与性能特征:树形归约的并行步数是 log₂N(N = 10⁶ 时只有 20 步),总工作量是 N−1 次算子调用,与串行算法相同。但每轮可用的并行度按 N/2、N/4、N/8 … 递减,峰值并行度出现在第一轮(N/2 = 500,000 路),平均并行度是 (N−1)/log₂N(N = 10⁶ 时约 50,000)。GPU 的并行资源是固定的:A100 有 108 个 SM、每 SM 最多 2048 个线程,全芯片同时最多驻留 108 × 2048 = 221,184 个线程,比第一轮所需的 500,000 路并行少一半以上,所以任何单 kernel 的树形归约都必须先做线程粗化(让一个线程先串行合并多个元素),把第一轮的并行需求压到机器规模以内。

  • 浮点加法不满足严格结合律 → 数值可复现性问题:+ 与 × 在实数域上当然满足结合律与交换律,但在 IEEE 754 浮点下结合律不成立(交换律成立)。看一个可以用整数精确验证的例子:令 a = 2²⁴ = 16777216.0f,b = 1.0f,c = 1.0f。在 FP32 中,2²⁴ ≤ x < 2²⁵ 区间的相邻可表示数间隔是 2:
左结合 (a + b) + c = (16777216 + 1) + 1
      16777216 + 1 的实际值 16777217 处于 16777216 与 16777218 的正中间,
      ties-to-even 舍入到偶数 16777216;再加 1 同样舍入回 16777216
      → 结果 16777216.0f

右结合 a + (b + c) = 16777216 + 2
      16777218 恰好可精确表示
      → 结果 16777218.0f

(16777216 + 1) + 1 = 16777216   ≠   16777216 + (1 + 1) = 16777218

这不是理论玩具:把 1 亿个 0.01f 用最朴素的串行循环累加,当累加和超过 2¹⁹ ≈ 524288 之后,相邻可表示数的间隔达到 0.03125,比每次要加的 0.01 还大,于是增量被舍入掉、累加和”卡死”,最终结果约 5.2×10⁵,而真值是 10⁶,相对误差接近 50%。反过来,并行的树形归约先把大量小量级的数值两两相加,数值量级增长得慢、有效位数利用得更充分,往往比朴素串行求和更准确。这带来三个工程结论:第一,只要归约顺序依赖于线程调度(例如用 atomicAdd 汇总),同一个输入在不同运行中可能得到最低位不同的结果,即结果不可复现(non-deterministic / non-reproducible);第二,min/max 以及整数加法在浮点/整数语义下是严格结合且交换的(不考虑 NaN 与有符号零的边界情况),所以 max 归约是逐位可复现的,而 sum 归约不是;第三,对精度敏感的场景要么用 double 累加、要么用 Kahan 补偿求和、要么固定归约树形状(CUB 的 DeviceReduce 对浮点提供确定性的多阶段实现)。Lab 的评分脚本通常用相对误差做判据(例如 |gpu − cpu| / |cpu| < 10⁻³),就是在为这种顺序敏感性留出容差。

树形归约与工作量效率(Reduction Tree / Work Efficiency)

  • 定义与目的:把树形归约的代价拆成两个维度衡量——关键路径长度(深度)总工作量。”工作量高效(work efficient)”的定义是:并行算法的总操作数与最优串行算法处于同一量级,且不含有依赖于 N 的额外开销(constant overheads, nothing dependent on N)。讲义给出的树形归约每层的工作量是 N/2、N/4、N/8 … 1,求和得 N−1,恰好与串行需要的 N−1 次合并相同,因此它是 work efficient 的;而”N 个线程各自把自己的元素加到同一个结果上”那种直接并行化虽然也只有 N−1 次操作,却把 N−1 次操作全部串行化到了同一个内存地址上(见”原子操作与竞争”一节),既不是 work efficient 也毫无并行度。

  • 直观解释(”它是什么?”):想象搬 1000 块砖。串行做法是一个人来回 1000 趟;树形做法是 500 个人排成两列,各自把砖递给对面的人(每对合并一次),人数每轮减半。总”搬运次数”没变(还是 999 次合并),但墙上的时钟只走了 log₂1000 ≈ 10 轮。问题是:要雇 500 个人才能让第一轮满载,而第 10 轮只需要 1 个人——你是按 500 人的规模雇人,然后眼看着大多数人在后面几轮闲着。GPU 的难处就在这里:kernel 的线程数在启动时就固定了(CUDA 不允许 kernel 中途改变 blockDim),所以”活跃线程数每轮减半”必然意味着后半程大量的线程槽位被浪费。讲义把这种现象叫做”缩减中的并行度(narrowing parallelism)”,并指出它从组合逻辑电路一直到高性能应用中都普遍存在。

  • 架构/机制图解:一个 block(1024 线程)对 2048 个元素做树形归约时,各轮的活跃线程数、活跃 warp 数与硬件资源占用:

blockDim = 1024 → 32 个 warp;共享内存 2*1024 个 float = 8 KB
每一轮之后活跃线程数减半,但 block 占用的 warp 槽位始终是 32 个

轮次   活跃线程数   活跃 warp 数   每 SM 上该 block 的 warp 占用
────────────────────────────────────────────────────────────────
 1        1024          32         ████████████████████████████████  满载
 2         512          32         ████████████████████████████████  满载(但每 warp 仅 16 lane 有效)
 3         256          32         ████████████████████████████████
 4         128          32         ████████████████████████████████
 5          64          32         ████████████████████████████████
 6          32          32         ████████████████████████████████
 7          16          16         ████████████████                  一半 warp 完全空闲
 8           8           8         ████████
 9           4           4         ████
10           2           2         ██
11           1           1         █                             仅 1 个 lane 做事
────────────────────────────────────────────────────────────────
合计有效 thread-op = 1024+512+256+128+64+32+16+8+4+2+1 = 2047 = N-1(工作量高效 ✓)
但硬件发射的 warp 指令槽位远多于有效工作量(见下节发散度表)

性能特征与具体数字:单看计算,2047 次加法在 A100 上只需不到 1 微秒(FP32 峰值 19.5 TFLOPS,2047 FLOP ≈ 0.1 ns),但 kernel 的真实耗时通常在 300 微秒量级(读 400 MB 数据)。这说明树的形状、发呆的线程都不是归约的性能瓶颈,内存才是。与此同时,树形归约的”缩减并行度”会以三种方式间接伤害性能:(1) 尾部轮次里一个 warp 只有 1 个 lane 活跃,warp 调度器仍然要为它发射指令、占用发射槽;(2) 每个 block 都要完整走完 log₂N 轮同步,__syncthreads() 的代价在每个 block 里重复支付(V1/V2 有 48829 个 block,就有 48829 × 11 次 barrier);(3) block 数极多造成多轮”波次(wave)”调度,最后一波凑不满机器,产生尾部效应。这三条正是后文线程粗化与层次化归约要消除的东西。

交错寻址与顺序寻址(Interleaved vs. Sequential Addressing)

  • 定义与目的:这两种映射描述的是”共享内存里的部分和如何分配给线程”。交错寻址(interleaved addressing)让线程 t 在第 k 轮处理下标 2t 与 2t + 2^k 的元素(等价写法是:只有满足 tid % (2*s) == 0 的线程活跃),活跃线程散布在整个数组中、步长按 1、2、4、8 … 倍增;顺序寻址(sequential addressing)则让每轮的活跃线程始终是 tid < s前 s 个连续线程,把部分和”压缩(compact)”到数组开头,步长按 blockDim/2、blockDim/4 … 递减。它解决的问题是:交错寻址虽然逻辑正确、工作量也高效,但会让同一 warp 内的 32 个 lane 一半干活一半闲着(warp 发散 divergence),并且造成共享内存 2 路 bank conflict;顺序寻址同时消除这两个问题,是归约优化的第一个、也是最关键的一步。

  • 直观解释(”它是什么?”):把 1024 个工人排成一条流水线做”两两合并”。交错寻址的做法是:第 1 轮让所有工人把自己的成品和自己右边一格的人合并(1 号并 2 号、3 号并 4 号);第 2 轮只让偶数号工人和右边第 2 格的人合并;第 3 轮只让 4 的倍数号工人和右边第 4 格的人合并。看起来合理,但问题是工人们被编成 32 人一组的小队(warp),队长喊一嗓子”全体合并”,组里却只有一半人真的有活干——剩下的人只能站着,这嗓子照样要喊(发射指令),于是硬件效率腰斩再腰斩。顺序寻址换成:第 1 轮让前一半工人(1 号到 512 号)和后半部分的对应工人合并,第 2 轮让前四分之一(1 号到 256 号)合并。这样每个小队要么整队有活、要么整队没活,队长喊的每一嗓子都至少覆盖一整队人;而且干活的工人永远挤在最前面,后面的小队可以干脆去休息(warp 完全空闲,不发射指令)。

  • 架构/机制图解:以 16 个元素、8 个线程(warp 示意宽度为 8)、每线程装载 2 个元素为例,对比两种映射每轮的活跃模式与共享内存访问地址:

【交错寻址】每线程持 2 个元素,从 stride = 1 开始倍增
轮 1  stride = 1 : t % 1 == 0,全部 8 个线程活跃
  t     :   0      1      2      3      4      5      6      7
  操作  : [0]+=[1] [2]+=[3] [4]+=[5] [6]+=[7] [8]+=[9] [10]+=[11] [12]+=[13] [14]+=[15]
  活跃  :   ■      ■      ■      ■      ■      ■      ■      ■        8/8 活跃

轮 2  stride = 2 : t % 2 == 0,只有偶数号线程活跃
  t     :   0      -      2      -      4      -      6      -
  操作  : [0]+=[2]       [4]+=[6]       [8]+=[10]      [12]+=[14]
  活跃  :   ■      ·      ■      ·      ■      ·      ■      ·        4/8 活跃(warp 内半发散)

轮 3  stride = 4 : t % 4 == 0
  t     :   0      -      -      -      4      -      -      -
  操作  : [0]+=[4]                     [8]+=[12]
  活跃  :   ■      ·      ·      ·      ■      ·      ·      ·        2/8 活跃

轮 4  stride = 8 : t % 8 == 0
  t     :   0      -      -      -      -      -      -      -
  操作  : [0]+=[8]
  活跃  :   ■      ·      ·      ·      ·      ·      ·      ·        1/8 活跃

问题 1:活跃 lane 永远不连续 → 每个 warp 每轮都要为极少数 lane 发射整条指令
问题 2:地址 2t 与 2t+stride 使 lane 与 bank 的线性关系被打断 → 2 路 bank conflict
【顺序寻址】每线程先把自己负责的 2 个元素相加,活跃集合永远是前 s 个连续线程
轮 1  s = 4 (= blockDim/2) : tid < 4,前 4 个线程活跃、连续
  t     :   0      1      2      3      -      -      -      -
  操作  : [0]+=[4] [1]+=[5] [2]+=[6] [3]+=[7]
  活跃  :   ■      ■      ■      ■      ·      ·      ·      ·        4/8 活跃,零发散
         (若 warp 宽 32、blockDim 1024,则是 16 个 warp 整队活跃、16 个整队空闲)

轮 2  s = 2 : tid < 2
  t     :   0      1      -      -      -      -      -      -
  操作  : [0]+=[2] [1]+=[3]
  活跃  :   ■      ■      ·      ·      ·      ·      ·      ·        2/8 活跃

轮 3  s = 1 : tid < 1
  t     :   0      -      -      -      -      -      -      -
  操作  : [0]+=[1]
  活跃  :   ■      ·      ·      ·      ·      ·      ·      ·        1/8 活跃

用真实参数(blockDim = 1024 = 32 个 warp,每 block 处理 2048 个元素)算两张”发散度账单”。交错寻址(讲义第 20 页的 Sum25 形式)每次发射的实际 lane 利用率:

轮次  stride  活跃线程数  有活干的 warp 数  活跃 warp 内 lane 利用率  有效 lane-op  发射的 lane 槽位
────────────────────────────────────────────────────────────────────────────────────────────────
 1       1       1024            32                 32/32               1024           1024
 2       2        512            32                 16/32                512           1024
 3       4        256            32                  8/32                256           1024
 4       8        128            32                  4/32                128           1024
 5      16         64            32                  2/32                 64           1024
 6      32         32            32                  1/32                 32           1024
 7      64         16            16                  1/32                 16            512
 8     128          8             8                  1/32                  8            256
 9     256          4             4                  1/32                  4            128
10     512          2             2                  1/32                  2             64
11    1024          1             1                  1/32                  1             32
────────────────────────────────────────────────────────────────────────────────────────────────
合计              2047           223 条 warp 指令      —                 2047           7136
lane 有效率 = 2047 / 7136 = 28.7%     (第 5 轮之后每个活跃 warp 只有 1 个 lane 做事)

讲义在第 25 页给出的判断与此完全一致:第一步所有线程都活跃;此后每条 warp 里都分成”做加法”和”什么都不做”两条控制流路径,而什么都不做仍然消耗执行资源;第五步之后整个 warp 一起闲置,而仍在干活的那些 warp 里只剩一个活跃线程;受 1024 线程上限约束,后面还有 5 步这种状态。顺序寻址的账单则漂亮得多:

轮次     s     活跃线程数  有活干的 warp 数  活跃 warp 内 lane 利用率  有效 lane-op  发射的 lane 槽位
────────────────────────────────────────────────────────────────────────────────────────────────
 1     1024      1024            32                 32/32               1024           1024
 2      512       512            16                 32/32                512            512
 3      256       256             8                 32/32                256            256
 4      128       128             4                 32/32                128            128
 5       64        64             2                 32/32                 64             64
 6       32        32             1                 32/32                 32             32
 7       16        16             1                 16/32                 16             32
 8        8         8             1                  8/32                  8             32
 9        4         4             1                  4/32                  4             32
10        2         2             1                  2/32                  2             32
11        1         1             1                  1/32                  1             32
────────────────────────────────────────────────────────────────────────────────────────────────
合计              2047            68 条 warp 指令       —                 2047           2176
lane 有效率 = 2047 / 2176 = 94.1%     (前 6 步零发散,最后 5 步发散集中在单个 warp 内)

发射的 warp 指令数从 223 条降到 68 条(3.28 倍),lane 有效率从 28.7% 升到 94.1%。讲义第 30 页的表述是:”给定 1024 个线程,block 装载 2048 个元素;前六步没有分支发散(1024、512、256、128、64、32 个连续线程活跃,每个 warp 内要么全活跃要么全不活跃);最后六步只剩一个活跃 warp(其中最后五步有分支发散)。”两个说法(我的 11 轮表与讲义的 12 步表述)差异只在于是否把”初始装载”也算作一步,结论一致。

再看”合并访问(coalesced access)”这一环。归约 kernel 在装载阶段的全局内存访问在两种映射下是相同的、都是完全合并的:partialSum[t] = input[start + t] 让一个 warp 的 32 个 lane 读到连续的 32 个 float = 128 字节 = 正好一条 cache line = 一个 transaction;partialSum[blockDim + t] = input[start + blockDim + t] 同理。真正的差别在于:顺序寻址的”tid ↔ 地址”是线性一一对应的,所以同一份映射拿去装载、拿去写共享内存、拿去做加法都天然满足”相邻 lane 访问相邻字”;而交错寻址的 2*t 映射一旦用到全局内存上(例如某些变体写 input[2*t]input[2*t+1]),一个 warp 就会跨过 256 字节才取到 128 字节有用数据,有效带宽直接腰斩到 50%(讲义 Lecture 7 的例题正是这个道理:从 512 字节的 burst 中只用每隔 8 字节取一半,240 GB/s 的峰值只剩 120 GB/s)。因此在归约里,”顺序寻址”是同时买到三样东西的一张票:全局装载合并、warp 无发散、共享内存无 bank conflict

  • 补充:交错寻址的另一种经典写法。Kirk/Hwu 原始优化讲义中的版本 1 用blockDim 大小的共享数组、每线程只装 1 个元素,循环写成 for (s = 1; s < blockDim.x; s *= 2) { if (tid % (2*s) == 0) shared[tid] += shared[tid + s]; __syncthreads(); }。它的发散模式与上面 Sum25 形式结构相同、参数不同:blockDim = 1024 时共有 10 轮(s = 1, 2, 4, …, 512),活跃线程数为 512、256、128、64、32、16、8、4、2、1,前 5 轮每个 warp 都只有 16、8、4、2、1 个 lane 活跃,第 5 轮之后所有还在干活的 warp 都只剩 1 个活跃 lane,且从 s = 32 起有一半的 warp 完全空闲。就一个 1024 线程的 block 而言,它要做的有效加法是 1023 次(每个元素恰好参与一次合并),却发射了 32×5 + 16 + 8 + 4 + 2 + 1 = 191 条 warp 指令、占用了 191 × 32 = 6112 个 lane 槽位,lane 有效率只有 1023 / 6112 ≈ 16.7%。这也解释了为什么历史上”版本 1 → 版本 2(顺序寻址)”的实测加速比可以接近 2 倍。

warp 级原语(Warp-Level Primitives)

  • 定义与目的:warp 级原语是一组在一个 warp 的 32 个 lane 之间直接交换寄存器值的 CUDA 内建函数,绕过共享内存与 __syncthreads()。核心成员:__shfl_sync(mask, var, srcLane, width)(按 lane 编号取数)、__shfl_down_sync(mask, var, delta, width)(lane i 取 lane i+delta 的值,越界时返回自己的值)、__shfl_up_sync__shfl_xor_sync(mask, var, laneMask)(蝶形交换,lane i 与 lane i^laneMask 互换)、__ballot_sync(mask, predicate)(把整个 warp 的谓词收集成一个 32 位掩码)、__activemask()(返回当前活跃 lane 的掩码)、__syncwarp(mask)(warp 内屏障/重汇聚),以及 sm_80 起提供的硬件归约指令 __reduce_add_sync / __reduce_min_sync / __reduce_max_sync / __reduce_and_sync / __reduce_or_sync / __reduce_xor_sync。它解决的问题是:归约树最后 5 轮(32 个元素归约成 1 个)如果用共享内存做,需要 5 次 __syncthreads() + 5 次共享内存读写,而 shuffle 用 5 条寄存器搬运指令就能完成,既不需要屏障、也不占共享内存带宽

  • 直观解释(”它是什么?”):把 warp 想成一间 32 人围坐圆桌的会议室,每个人的笔记本(寄存器)只有自己能写字。共享内存是”白板”——谁都能写、谁都能看,但大家必须轮流上前(所以要 __syncthreads() 协调”我写完了,你们可以看了”)。warp 原语则是同桌之间直接把本子递过去__shfl_down_sync(mask, v, 16) 相当于”每个人把本子往右传 16 个座位,然后把自己本子上的数和拿到的那本相加”。围桌递只需要 5 轮(16、8、4、2、1),不需要任何”全体起立”的协调,因为同桌的人本来就坐在同一张桌子旁——这就是”warp 内不需要 __syncthreads()“的直觉来源。

  • 架构/机制图解__shfl_down_sync 的”下移并相加”归约与 __shfl_xor_sync 的蝶形归约:

【__shfl_down_sync 归约】warp 内 32 个 lane,每个 lane 先持有自己的部分和 v[i]
轮 1  offset = 16 :  v[i] += v[i+16]   有效区间收缩为 lane 0..15
轮 2  offset =  8 :  v[i] += v[i+ 8]   有效区间收缩为 lane 0..7
轮 3  offset =  4 :  v[i] += v[i+ 4]
轮 4  offset =  2 :  v[i] += v[i+ 2]
轮 5  offset =  1 :  v[i] += v[i+ 1]   → 仅 lane 0 持有 32 个值的完整和
  lane :   0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15
          16  17  18  19  20  21  22  23  24  25  26  27  28  29  30  31
 轮1    :  lane i 与 lane i+16 配对:0↔16, 1↔17, 2↔18, 3↔19, 一直到 15↔31(共 16 对)
 轮2    :  lane i 与 lane i+ 8 配对:0↔8, 1↔9, 2↔10, 3↔11, 4↔12, 5↔13, 6↔14, 7↔15(共 8 对)
 轮3    :  lane i 与 lane i+ 4 配对:0↔4, 1↔5, 2↔6, 3↔7(共 4 对)
 轮4    :  lane i 与 lane i+ 2 配对:0↔2, 1↔3(共 2 对)
 轮5    :  lane 0 与 lane 1 配对(共 1 对)→ lane 0 持有 32 个值的完整和
 稀疏化  :  每轮之后"还有意义"的结果区间减半:
           轮1 后为 lane 0..15,轮2 后为 lane 0..7,轮3 后为 lane 0..3,
           轮4 后为 lane 0..1,轮5 后只剩 lane 0;其余 lane 的计算结果被丢弃
 代价    :  5 条 SHFL + 5 条 FADD,无共享内存、无 __syncthreads()、无 bank 概念

【__shfl_xor_sync 蝶形归约】每轮的配对是 i 与 i^k,所有 lane 都保持活跃
轮 1  laneMask = 16 : v[i] += v[i^16]   每个 lane 都得到一对的和
轮 2  laneMask =  8 : v[i] += v[i^ 8]   每个 lane 都得到一个四元组的和
轮 3  laneMask =  4 : v[i] += v[i^ 4]
轮 4  laneMask =  2 : v[i] += v[i^ 2]
轮 5  laneMask =  1 : v[i] += v[i^ 1]   → 32 个 lane 全部持有完整和(广播效果)
  lane :   0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15
          16  17  18  19  20  21  22  23  24  25  26  27  28  29  30  31
 轮1    :  0↔16  1↔17  2↔18  3↔19  4↔20  5↔21  6↔22  7↔23
           8↔24  9↔25 10↔26 11↔27 12↔28 13↔29 14↔30 15↔31(16 对同时交换)
 轮2    :  0↔8   1↔9   2↔10  3↔11  4↔12  5↔13  6↔14  7↔15
          16↔24 17↔25 18↔26 19↔27 20↔28 21↔29 22↔30 23↔31(8 对同时交换)
 轮3    :  0↔4   1↔5   2↔6   3↔7   8↔12  9↔13 10↔14 11↔15
          16↔20 17↔21 18↔22 19↔23 24↔28 25↔29 26↔30 27↔31
 轮4    :  0↔2   1↔3   4↔6   5↔7(其余所有相差 2 的 lane 对同样交换)
 轮5    :  0↔1   2↔3   4↔5   6↔7(其余所有相差 1 的 lane 对同样交换)
 代价   : 同样是 5 条 SHFL + 5 条 FADD,但结果天然广播到所有 lane

与共享内存的对比(A100,1.41 GHz,108 个 SM):共享内存每 SM 每周期提供 32 个 bank × 4 字节 = 128 字节,全芯片聚合带宽约 108 × 128 B × 1.41 GHz ≈ 19.5 TB/s,访问延迟约 20–30 周期;全局内存只有 1555 GB/s、延迟 400–800 周期。shuffle 走的是 MIO/寄存器通路,延迟约 10–25 周期、吞吐与之接近,但不消耗共享内存的 bank 带宽、也不产生 bank conflict 的可能性,更重要的是它不需要屏障——在归约的最后 5 轮里,屏障正是最昂贵的部分,因为它会把整个 block 的 1024 个线程互相等待,而其中只有 32 个线程真正有活干。__ballot_sync__activemask 则用于”流压缩(stream compaction)”式的场景:前者把一个谓词求值结果收集成掩码用于后续的 __shfl 广播或原子计数,后者返回当前活跃 lane 的掩码。关于 __activemask() 有一个必须记住的告诫:它返回的是”执行到这一行的瞬间还活跃的 lane”,编译器可以把 __activemask() 提到分支之外、也可能让不同 lane 得到不同的值,因此绝不能把它当作”推测出我想要的那个 mask”的工具去给 _sync 变体填参数;正确做法是显式写清楚掩码(例如整 warp 用 0xffffffffu,或用 unsigned mask = __ballot_sync(0xffffffffu, pred) 显式构造)。__reduce_add_sync(mask, value)(sm_80 起)把整 warp 的整数加法折叠成一条硬件指令,吞吐更高、延迟更低,但它只支持 32 位无符号/有符号整数,FP32 没有对应的单指令硬件归约,浮点仍需靠 shuffle 或共享内存。

共享内存 bank 与 bank conflict(Bank Conflict)

  • 定义与目的:共享内存(shared memory)是每个 SM 内部的一块片上 SRAM(A100 每 SM 164 KB,可配置 0/8/16/32/64/100/132/164 KB 给共享内存,其余给 L1),被划分为 32 个 bank,每个 bank 宽 4 字节(一个 word)。一个 warp 的 32 个 lane 在一次共享内存访问里,如果它们访问的字分别落在 32 个不同的 bank 上,硬件就能在一个周期内并行完成(这不是”每个 lane 一个 bank”的硬绑定,而是”无冲突时一个周期、冲突时按最大冲突度分成多次”)。地址到 bank 的映射是 bank = (字地址) % 32(按字节地址算就是 (字节地址 / 4) % 32)。bank conflict(bank 冲突)指的是一个 warp 内多个 lane 同时访问同一个 bank 的不同字,硬件只能把它们串行化,冲突度为 k 时该次访问要花 k 个周期。它解决的问题是:共享内存是归约的核心暂存区,如果访问模式不讲究,其有效带宽会被冲突打折,而共享内存延迟(20–30 周期)本来就比全局内存(400–800 周期)低一个数量级,一旦被 2 路冲突折半,就等于白白丢掉了这部分优势。

  • 直观解释(”它是什么?”):把共享内存想成银行柜台的 32 个窗口,每个窗口只服务”账号末位”属于它的客户。32 个人(warp 的 32 个 lane)同时来办事,如果每个人去的窗口都不同,一次叫号全部办完;如果两个人都去同一个窗口(同一个 bank 的不同账号),就得排队两轮。注意一个例外:如果多个人要办的是同一个账号(同一个地址),那叫”广播(broadcast)”,银行会一次把结果复印给所有人,不算冲突——这一点在归约里很重要:if (tid < 32) 之后的读 shared[lane]shared[0] 之类的模式是免费的。

  • 架构/机制图解:bank 映射与两种寻址的冲突分析(blockDim = 1024,一个 warp 的 lane 0..31):

共享内存 bank 映射(4 字节宽,共 32 个 bank,容量示例 164 KB / SM)
 第 1 个 128 字节窗口:字地址 0..31 依次落在 bank 0..31(一一对应)
 第 2 个 128 字节窗口:字地址 32..63 再次落在 bank 0..31
 第 3 个 128 字节窗口:字地址 64..95 再次落在 bank 0..31,此后每 32 个字循环一次
          └──── 第 1 个 128 字节窗口 ──┘└──── 第 2 个 128 字节窗口 ──┘
          地址相差 32 个字(128 字节)的两个字落在同一个 bank 上

【顺序寻址 s = 512】 shared[tid] += shared[tid + 512]
 读 shared[tid]      : lane 0..31 访问字 0..31   → bank 0..31  各一次 → 无冲突
 读 shared[tid+512]  : lane 0..31 访问字 512..543 → bank 0..31  各一次 → 无冲突
 (512 % 32 == 0,所以第二个操作数整体平移了 16 个 128 字节窗口,bank 序列不变)

【交错寻址 stride = 1】 shared[2*tid] += shared[2*tid + 1]
 读 shared[2*tid]    : lane 0..31 访问字 2*lane,即 0 到 62 之间的 32 个偶数号字
                       bank 序列 = 0,2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,
                                   0,2,4,6,8,10,12,14,16,18,20,22,24,26,28,30
                       字 0 与字 32 都落在 bank 0 → 2 路冲突
 读 shared[2*tid+1]  : 访问字 2*lane+1,即 1 到 63 之间的 32 个奇数号字,
                       对应的 bank 序列同样是"1 到 31 各两次" → 2 路冲突
 结果:一次共享内存访问需要 2 个周期,有效带宽腰斩

【交错寻址 stride = 2】 shared[2*tid] += shared[2*tid + 2]
 活跃 lane 为偶数号(16 条),访问字 4*lane',即 0 到 60 之间的 16 个 4 的倍数
 bank 序列 = 0,4,8,12,16,20,24,28,0,4,8,12,16,20,24,28
 → 字 0 与字 32 再次同 bank → 仍 2 路冲突

【无冲突范式(本讲优化版采用)】
 若每个 warp 只让 lane 0 写一个 float:warpSums[warpId] = sum
   → 一个 warp 只有一条活跃 lane,永远不可能冲突
 若最后一个 warp 读 warpSums[lane],lane 0..31 读字 0..31 → bank 0..31 → 无冲突
 若必须做列访问(本讲之外的场景),用 __shared__ float s[N][33] 的 padding 把 stride 33 变成互质

性能特征与具体数字:共享内存延迟 20–30 周期,聚合带宽约 19.5 TB/s(A100 全芯片),是全局内存带宽 1555 GB/s 的 12.5 倍。因此”交错寻址的 2 路冲突”换算成时间并不吓人:V1 每个 block 的共享内存字访问约 3 × 2047 ≈ 6100 次(每次加法要两次读、一次写),即使每一次都因为 2 路冲突而多花一个周期,折算下来也只是几千个周期、在 300 微秒的 kernel 里不到 1%——真正杀死 V1 性能的是 warp 发散(发射了 3.28 倍的 warp 指令,而 warp 调度器的发射槽是稀缺资源)。这就引出一条重要的分析纪律:先分清”哪个资源被浪费了”再谈优化。bank conflict 浪费的是共享内存带宽(本地资源、通常富裕),warp 发散浪费的是指令发射槽(全局稀缺),两者数量级不同。但冲突在别的模式里会是致命伤:例如矩阵转置或本讲之外的行列式访问,冲突度可以达到 32 路,那时有效带宽降到 1/32,任何计算都补不回来。

线程粗化与层次化归约(Thread Coarsening / Hierarchical Reduction)

  • 定义与目的线程粗化(thread coarsening)是让每个线程处理多于一个数据元素的技术。在归约中它有三个具体作用:(1) 把”第一轮需要 N/2 路并行”的树形需求压缩到机器规模——N = 10⁸ 而 A100 只能同时驻留 221,184 个线程,粗化后每线程串行合并约 452 个元素即可;(2) 让每个线程只向共享内存/原子变量贡献一个部分和,把共享内存压力与屏障次数按粗化因子成比例降低;(3) 用私有寄存器部分和把”长依赖链”变成”多个独立累加链”,提升指令级并行(ILP)。层次化归约(hierarchical reduction)是配套的多级结构:线程级(寄存器)→ warp 级(shuffle)→ block 级(共享内存)→ grid 级(原子操作或第二次 kernel 启动)。讲义第 24 页把这种结构叫作 segmented reduction(分段归约):每个 block 先用共享内存把一个分段压成一个值写回全局内存,得到一个长度为 N/(2M) 的部分和向量;如果这个向量还很大,就”再启动一次 kernel(and again)”,直到只剩一个值。

  • 直观解释(”它是什么?”):把 1 亿人的选票统计成一张结果,你不会派 5000 万个人两两配对(人不够、还得先雇 5000 万人)。现实做法是分区计票:每个计票员负责一个小区(粗化,把几百张票在自己手里先累加成一摞),小区内的小组长老把结果汇总到一张小纸条(warp 级 shuffle),每个计票站再把所有纸条汇总(block 级共享内存),最后几个站长的数字送到总部相加(grid 级)。每一级都让”参与者数量除以几十”,于是下一级要处理的元素数量少得可以放进更快的内存里。这正是讲义第 37–38 页的 compute-centric 主张:与其反复拆建同样的 block,不如”把 2048 个线程放在每个 SM 上不走了,让它们一直读到数据耗尽(until the data is exhausted),到那时才让并行度下降”。讲义也留了一句诚实的提醒:”这些想法我没有试过,留给 Lab 5 里愿意尝试的人”,并叮嘱”把简单版本的实现留一份拷贝,因为 Lab 6(scan)需要用到部分和”。

  • 架构/机制图解:层次化归约的四级结构与数据量收缩:

输入:N = 100,000,000 个 float(400 MB,全局内存 / DRAM)
        │
        │  ① 线程级粗化:每线程用网格-步长循环(grid-stride loop)串行累加约 452 个元素
        │     累加结果留在寄存器(可开 4 个独立累加器打破 FADD 依赖链)
        ▼
  221,184 个线程(108 SM × 8 block × 256 线程),每线程 1 个 float 部分和 = 0.9 MB(寄存器中)
        │
        │  ② warp 级:5 轮 __shfl_down_sync,32 个 lane 折成 1 个
        ▼
   6,912 个 warp 部分和(每个 warp 的 lane 0 写 1 个 float 到共享内存 = 27 KB/SM)
        │
        │  ③ block 级:block 内最后一个 warp(warp 0)读 warpSums[lane] 再做 5 轮 shuffle
        ▼
     864 个 block 部分和(864 个 float = 3.5 KB)
        │
        │  ④ grid 级:两种选择
        │     (a) atomicAdd(&result, v):一个 block 一次原子操作,864 次竞争,代价低;
        │         但浮点原子顺序不定 → 结果不可复现
        │     (b) 第二次(第三次)kernel 启动:把 864 → 8 → 1,结果逐位可复现
        ▼
                    1 个 float 最终结果(写回全局内存 4 字节)

对比:不粗化的教科书版本(V1/V2)每 block 只处理 2048 个元素,
      需要 ceil(10^8 / 2048) = 48,829 个 block,每个 block 都要付 11 次 __syncthreads()
      与 11 轮"活跃线程减半"的尾部开销;粗化版本把 block 数降到 864。

性能特征与具体数字:粗化因子是”每个线程处理多少元素”,它由 gridDim × blockDim 决定。A100 上按满占用率取 grid = 108 SM × 8 block = 864 个 256 线程的 block,总线程数 221,184,粗化因子 = 10⁸ / 221,184 ≈ 452。这个数字要跟两个硬件常数对照:(a) DRAM 延迟 400–800 周期,要让 452 次串行装载把延迟完全隐藏,需要每个 warp 同时有足够多的未完成装载(见”性能模型”一节,带宽延迟积要求约 661 KB 在途数据);(b) FADD 延迟约 4 周期,若只有一个累加器,452 次累加的依赖链长达 452 × 4 = 1808 周期/线程,虽然通常被其他 warp 的访存掩盖,但在高粗化因子 + 低占用率时它会浮出水面,因此实践中常用 4 个独立累加器(sum0..sum3)把链长除以 4。粗化的另一个好处是摊销固定开销:每个 block 的归约尾巴(log₂ 轮栏栅 + 最后的跨 warp 归约)是固定的十几条指令,粗化 452 倍后这部分开销被摊薄到可以忽略。

原子操作与竞争(Atomic Operations and Contention)

  • 定义与目的atomicAdd(float* address, float val) 把”读—改—写”做成不可分割的一条操作,由硬件在 L2 缓存的原子单元(atomic unit) 内完成,从而在多个线程(甚至多个 SM)同时更新同一个地址时保证结果正确。它在归约里充当”免 kernel 启动的 grid 级合并器”:每个 block(或每个 warp)算出部分和后,用一条 atomicAdd(&result, partial) 把结果累加到同一个全局变量,不必再启动第二个 kernel。讲义第 24 页把它列为 block 全部归约完之后的三条出路之一(”kernel 也可以用原子操作累加到全局和”)。

  • 直观解释(”它是什么?”):原子操作像银行唯一的一个存折账户。每个计票站算完自己的票数,派一个人来把数字加到账上;银行保证”读余额—加钱—写回”这三步不会被打断,所以不会出现两个人同时读到旧余额、各加一次、结果丢一笔的情况。但也正因为只有一个账户,所有人必须排队:如果让 1 亿个线程每人都来加一次,队伍就长得离谱——这就是竞争(contention)。聪明的做法是让”班里先汇总好再由班长去存钱”,这正是层次化归约的作用。

  • 架构/机制图解:不同原子粒度的代价量级(A100):

方案 A:每个线程一条 atomicAdd(&result, x)          ← 灾难
  10^8 条原子操作全部落在同一个地址上
  L2 原子单元对"同一地址"的更新必须串行化,实测吞吐量约 2–5 × 10^8 次/秒
  耗时 ≈ 10^8 / (3 × 10^8) s ≈ 0.33 s  (比理论下限 0.257 ms 慢约 1300 倍)
  同时 DRAM 带宽利用率极低:带宽在等原子队列,不是在读数据

方案 B:每个 block 一条 atomicAdd(&result, blockSum)     ← 可接受
  粗化后 864 个 block → 864 条原子操作,仅 864 次竞争
  耗时 ≈ 864 / (3 × 10^8) s ≈ 2.9 μs,占 300 μs 内核时间的约 1%
  (若沿用 V1/V2 的 48,829 个 block,原子操作数升到 4.9 万,约 160 μs,
    已经不能被忽略——这也是"粗化"在原子方案下的另一个收益)

方案 C:每个 warp 一条,或用共享内存私有化(privatization)         ← 更细的层次
  先在 block 内把 8 个 warp 的部分和用共享内存合并,再由 1 个 lane 原子一次

方案 D:第二次 kernel 启动(two-pass / multi-pass)              ← 结果可复现
  部分和写入全局数组 → 下一阶段 kernel 归约(864 → 8 → 1)
  完全不使用原子操作,归约树形状固定 → 逐位可复现

性能特征与具体数字:需要注意三个细节。第一,atomicAdd 有返回值(旧值),有返回值的原子操作比”只写不读”的 reduction(PTX 的 red 指令)要贵——如果代码里根本不用返回值,写 (void)atomicAdd(&result, v) 并不会自动变成 red,但仍应尽量避免在同一热点上使用返回值的原子。第二,浮点原子是非确定性的:加法顺序取决于 L2 内原子到达的先后,而 FP32 加法不满足结合律(见第一节的 16777216 例子),因此同一个输入在不同运行中可能得到最后几位不同的结果。NVIDIA 官方文档明确说明 atomicAdd 对 float 的更新顺序不保证,追求可复现性必须用确定性归约树(多阶段 kernel)或改用整数/定点表示。第三,竞争也会以”内存顺序”的形式伤性能:热点地址所在的 L2 分片成为串行瓶颈,其余分片闲着,此时提高占用率毫无帮助,唯一出路是减少原子操作次数(粗化 + 分层私有化)。讲师在 Lab 说明里通常给出这样的经验:原子操作的次数应当与”SM 数 × 每 SM block 数”成正比,而不是与线程数或元素数成正比。

Volta 前后的 __syncthreads() 语义(Independent Thread Scheduling)

  • 定义与目的__syncthreads()block 级屏障:它要求 block 内的所有线程都到达该点后才继续,并且提供共享内存访问顺序保证(屏障之前的写对屏障之后的所有线程可见)。本讲要澄清一个具体的疑问:归约里广泛使用的写法
if (tid < s) w += shared[tid + s];
__syncthreads();

Volta(sm_70)引入独立线程调度(Independent Thread Scheduling, ITS)之后仍然安全,理由是屏障位于统一控制流(uniform control flow)中——所有线程都会执行到它;而 __syncthreads() 本身既是执行屏障又是内存栅栏,因此第 k 轮的写与第 k+1 轮的读之间的先后关系由屏障建立,完全不依赖”warp 锁步”这一已被 Volta 打破的假设。相反,真正被 Volta 打破的是隐式的 warp 同步:Volta 之前,一个 warp 的 32 个 lane 共享一个程序计数器,硬件按 SIMT 栈在分支处”一分多、汇聚为一”,编译器甚至可以合法地假设”同一 warp 内所有 lane 一起前进”,于是出现了大量”warps 同步编程”技巧(warp 内交换共享内存数据而不写任何同步语句,或在 if (tid < 32) 里直接读写共享内存)。Volta 之后每个线程拥有独立的 PC 与调用栈,lane 可以在分支里各自停顿、各自前进,重汇聚点(reconvergence point)不再有保证,上述技巧会随机失效。

  • 直观解释(”它是什么?”)__syncthreads()拔河比赛的哨声:所有队员都必须听到哨声才能走下一步,这跟”队员们是不是并排站着、步伐是否一致”无关。Volta 之前大家默认”全班同学手拉手一起走”,所以你不喊哨声也能自动对齐;Volta 之后改成”每个人自己走自己的路”,手拉手的前提没用了——但只要你喊哨声(__syncthreads() / _sync 变体),纪律依然成立。危险的动作不是”用了屏障”,而是在分歧的分支里喊哨声:一部分人走了另一条路(永远到不了哨声点),剩下的人就只能一直等,程序挂死或行为未定义。

  • 架构/机制图解:三种同步写法在 Volta 前后的合法性:

【安全】屏障在统一控制流中(本讲所有 kernel 都采用这一种)
    for (int s = blockDim.x/2; s > 0; s >>= 1) {
        if (tid < s) shared[tid] += shared[tid + s];   ← 分歧的 if
        __syncthreads();                                ← 屏障在 if 之外,所有线程都到达
    }
    Volta 之前:正确     Volta 之后:正确(屏障建立顺序,与锁步无关)

【不安全】屏障在分歧分支内部
    if (tid < s) {
        shared[tid] += shared[tid + s];
        __syncthreads();                                ← 危险!
    }
    Volta 之前:若条件恰好是 warp 对齐的,可能"碰巧能用";否则未定义/挂死
    Volta 之后:未定义行为,可能长时间挂起(部分 lane 永远到不了屏障)
    正确做法:把屏障移到分支外,或改用 __syncwarp() 在 warp 内同步

【不安全】依赖隐式 warp 锁步(Volta 之前被广泛使用,之后失效)
    if (tid < 32) {
        volatile float* smem = shared;                  ← 靠 volatile 阻止编译器优化
        smem[tid] += smem[tid + 32];                    ← 依赖"warp 内锁步"才正确
    }
    Volta 之前:可用(编译器与硬件都假设 warp 锁步)
    Volta 之后:竞态,需要 __syncwarp() 或改用 __shfl_down_sync

【推荐】warp 内用带 mask 的 shuffle
    sum += __shfl_down_sync(0xffffffffu, sum, offset);
    _sync 变体自身携带"参与本次交换的 lane 集合"信息,
    既是数据交换指令也是所需的最小同步,不依赖锁步、不使用屏障

性能特征与具体数字:__syncthreads() 的硬件代价随 block 大小增长。A100 每个 SM 有 4 个 SM 子分区(sub-partition),每个子分区最多 16 个 warp;一个 1024 线程的 block(32 个 warp)要跨 4 个子分区同步,屏障的延迟约几十个周期;而 V1/V2 的 48,829 个 block 每个都要过 11 次屏障,虽然每次都只摊到十几个周期,但在 block 数极多的场景下,屏障与 block 调度(block launch/retire)一起构成了不可忽略的固定开销。更重要的是同步会降低延迟隐藏能力:屏障让一个 block 内的所有 warp 停在同一进度上,若其中某个 warp 的全局装载还在 DRAM 里飞(延迟 400–800 周期),整个 block 都得等它;而 shuffle 方案的最后 5 轮完全不需要屏障,warp 可以独立走完自己的归约尾巴,这对”延迟受限”的尾部尤其有利。另外要记住一个容易忽略的现代语义细节:在 sm_70 及以后,已经退出(exit)的线程不再需要到达 __syncthreads(),因此”部分线程提前 return、其余线程仍在过屏障”的写法在现代架构上是合法的(在更老的架构上会被认为行为未定义)。稳妥的编程习惯是把”提前返回”换成”用谓词把越界线程的工作量置零”,让所有线程都走到同一组屏障——本讲的四个内核都遵循这一习惯(越界元素贡献恒等元 0.0f)。

归约的性能模型:算术强度与 Roofline(Roofline Model)

  • 定义与目的:Roofline 模型用两个常数描述一个 kernel 的性能上限:机器的峰值浮点吞吐(A100:约 19.5 TFLOPS FP32)与峰值内存带宽(A100:约 1555 GB/s)。二者的比值定义了拐点算术强度(ridge point) = 19.5×10¹² / 1555×10⁹ ≈ 12.5 FLOP/Byte:算术强度低于 12.5 的 kernel 是内存带宽受限(memory-bound),高于 12.5 才是计算受限(compute-bound)。算术强度的定义是”每搬运 1 字节数据做了多少次浮点运算”:AI = FLOPs / Bytes。归约的算术强度极低——它读入 N 个 float(4N 字节)、只做 N−1 次加法(约 N 次 FLOP),输出几乎可以忽略,因此 AI = N / 4N = 0.25 FLOP/Byte。课程材料常用的另一种更粗的口径是”每次全局内存事务(128 字节的 cache line,或按 float4 的 16 字节向量粒度)只配一次算术运算”,即 AI ≈ 1/16 = 0.0625 ≈ 0.06 FLOP/Byte;两种口径的绝对值差 4 倍,但结论完全一致:归约距离拐点差 50–200 倍,是教科书级的带宽受限问题

  • 直观解释(”它是什么?”):想象一家工厂,机器(FP32 计算单元)快到可以每秒处理 195 亿次操作,但进货的卡车(DRAM 带宽)每秒只能送来 15.55 亿字节的原料,每 4 字节原料只够做 0.25 次操作(甚至按 0.06 的口径更少)。于是机器 98% 的时间在等卡车。要提高产量,唯一的办法是让卡车跑得更满(提高有效带宽:合并访问、向量化装载、避免重复读取),而不是给机器提速——把一个只有 4 字节工作量的加法单元超频,对全局耗时毫无影响。

  • 架构/机制图解:A100 上归约的 Roofline 位置与理论下限的推导:

性能(GFLOP/s,对数轴)
  │
  │  计算屋顶(compute roof): 19500 GFLOP/s(FP32 峰值,一条水平线)
  │ ─────────────────────────────────────────────────────────────────
  │                                              /
  │                                            /   ← 斜线是"内存屋顶":
  │                                          /       性能 = 1555 GB/s × AI
  │                                        /
  │                                      /
  │                                    /
  │                                  /
  │  归约 @ AI = 0.25 ────────────●   ← 389 GFLOP/s(= 1555 GB/s × 0.25)
  │  归约 @ AI = 0.06 ──────●         ← 97 GFLOP/s(= 1555 GB/s × 0.0625)
  │                       │
  │                       └── 拐点 = 12.5 FLOP/Byte(两条屋顶的交点)
  │                     距离拐点 50 倍(0.25)到 200 倍(0.06)→ 深居带宽受限区
  └──────────────────────────────────────────────────────────────→ 算术强度 AI(FLOP/Byte)

理论最优时间推导(N = 10^8 个 float):
  读入字节数            = N × 4 B = 4×10^8 B = 400 MB
  输出字节数            = (N / (2M)) × 4 B ≈ 0.2 MB(可忽略)
  理论下限 t_min        = 4×10^8 B / 1555×10^9 B/s = 2.572×10^-4 s = 0.257 ms
  对应有效带宽上限      = 1555 GB/s(现实可达约 88%–92%,即 1370–1430 GB/s)
  对应实测耗时预期      = 0.28 – 0.30 ms
  若想让 FP32 峰值算力成为瓶颈,需要的带宽 = 19.5×10^12 / 0.25 = 78 TB/s
                                            = A100 实际带宽的 50 倍(不可能)

另一个必须一起考虑的维度是延迟受限(latency-bound)。带宽不是”水管里自来就有”的,它是靠足够多的在途请求(memory-level parallelism)撑出来的。用 Little 定律:需要的在途字节数 = 带宽 × 延迟。A100 上 DRAM 延迟 400–800 周期(取 600 周期,1.41 GHz ≈ 425 ns),则

在途字节数 = 1555 GB/s × 425 ns ≈ 661 KB

按标量 4 字节装载计算:需要 661 KB / 4 B ≈ 165,000 个未完成的装载请求
  分到 108 个 SM:约 1,528 个 / SM
  分到每 SM 64 个 warp(2048 线程):约 24 个未完成装载 / warp
  → 需要每个 warp 都开足够深的展开(unroll),寄存器压力大

按 float4(16 字节)向量装载计算:需要 661 KB / 16 B ≈ 41,300 个请求
  分到 108 个 SM:约 382 个 / SM
  分到每 SM 64 个 warp:约 6 个未完成装载 / warp
  → 容易达到,这就是带宽受限 kernel 必须做向量化装载的定量理由

于是可以把归约定性为“带宽受限为主、延迟受限为辅”的混合问题:稳态阶段(粗化的网格-步长循环)由带宽决定,尾部阶段(树形归约的最后几轮、block 收尾、grid 收尾)由延迟和同步决定。这解释了三个实测现象:(1) 只做”顺序寻址”优化(V2)能把 V1 提速约 2 倍,因为消除了发散的指令发射开销;(2) 做到”粗化 + shuffle”(V3/V4)之后就没有多少可优化的空间了,实测约 0.30 ms,已经达到理论下限 0.257 ms 的 86%(受限于 DRAM 的刷新、行激活开销、ECC 与地址非线性等现实因素,”纯读”的实测峰值通常只有理论值的 88%–92%),此时再优化计算部分不会有任何收益;(3) 反过来,如果 kernel 的全局装载不合并(例如每个线程读 input[2*i]input[2*i+1] 的错位模式),有效带宽会腰斩,耗时直接翻倍到 0.6 ms 以上——这是本节最值得记住的定量结论:归约的性能上限 = 全局内存带宽,优化目标 = 把有效带宽逼到峰值。

代码示例与性能分析

四个程序层层递进:示例 1(交错寻址,含两种经典写法)→ 示例 2(顺序寻址)→ 示例 3(线程粗化 + warp shuffle + 无 bank conflict 共享内存,含占用率查询与 block 尺寸对比)→ 示例 4(支持任意长度的多阶段层次化归约,两阶段 kernel 与 atomicAdd 两种收尾方式对比)。四个程序都包含完整的错误检查、cudaEvent 计时、CPU 参考实现对比与带宽输出,编译命令统一为 nvcc -O3 -arch=sm_80 <file>.cu -o <exe>。计时口径说明:只统计 kernel 自身的耗时cudaEvent 只包裹 kernel 启动),部分和向主机回传与主机端最后一步求和发生在计时区间之外,这与课程 Lab 的评分口径一致;表中”有效带宽”一律按”读入的全部字节数 / kernel 时间”计算,因为归约的输出字节数可以忽略。

示例 1:朴素交错寻址归约(Interleaved Addressing)

// 文件: reduce_v1_interleaved.cu
// 编译: nvcc -O3 -arch=sm_80 reduce_v1_interleaved.cu -o reduce_v1
// 运行: ./reduce_v1                默认 N = 100000000(1 亿个 float,400 MB)
//       ./reduce_v1 16777216       自定义元素个数 N(任意正整数均可)
//
// 本程序包含两种"交错寻址(interleaved addressing)"归约:
//   变体 1A:Sum25 讲义形式,shared[2*BLOCK_SIZE],每线程装载 2 个元素,
//            for (stride = 1; stride <= blockDim.x; stride *= 2) if (t % stride == 0)
//   变体 1B:Kirk/Hwu 经典形式,shared[BLOCK_SIZE],每线程装载 1 个元素,
//            for (s = 1; s < blockDim.x; s *= 2) if (tid % (2*s) == 0)
// 两者逻辑等价、工作量都是 N-1 次加法,但都会造成严重的 warp 发散。

#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <utility>
#include <cuda_runtime.h>

#define BLOCK_SIZE 1024

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

// ---------------------------------------------------------------------------
// 变体 1A:讲义 Sum25 第 31 页的交错寻址写法
//   shared 大小 2*BLOCK_SIZE,stride 从 1 倍增到 blockDim.x
//   第 k 轮只有满足 t % stride == 0 的线程活跃
// ---------------------------------------------------------------------------
__global__ void reduceInterleaved2M(const float* __restrict__ input,
                                    float* __restrict__ output,
                                    unsigned int n)
{
    __shared__ float partialSum[2 * BLOCK_SIZE];

    const unsigned int t     = threadIdx.x;
    const unsigned int start = 2 * blockIdx.x * blockDim.x;
    const unsigned int i0    = start + t;
    const unsigned int i1    = start + blockDim.x + t;

    // 两次全局装载:同一 warp 的 32 个 lane 读连续 128 字节 → 完全合并
    // 越界元素写入恒等元 0.0f,使任意 N 都得到正确结果
    partialSum[t]              = (i0 < n) ? input[i0] : 0.0f;
    partialSum[blockDim.x + t] = (i1 < n) ? input[i1] : 0.0f;

    // 循环开头的 __syncthreads():既保证初始装载完成,也保证每一轮的读
    // 发生在上一轮的写之后。它位于分歧的 if 之外 → 所有线程都会到达。
    for (unsigned int stride = 1; stride <= blockDim.x; stride *= 2) {
        __syncthreads();
        if (t % stride == 0) {
            partialSum[2 * t] += partialSum[2 * t + stride];
        }
    }

    // 循环结束处不需要屏障:最后一轮只有 t == 0 活跃,
    // partialSum[0] 只可能被线程 0 写,线程 0 读自己的写没有跨线程依赖。
    if (t == 0) output[blockIdx.x] = partialSum[0];
}

// ---------------------------------------------------------------------------
// 变体 1B:Kirk/Hwu 经典交错寻址写法(if (tid % (2*s) == 0))
// ---------------------------------------------------------------------------
__global__ void reduceInterleavedM(const float* __restrict__ input,
                                   float* __restrict__ output,
                                   unsigned int n)
{
    __shared__ float sdata[BLOCK_SIZE];

    const unsigned int tid = threadIdx.x;
    const unsigned int i   = blockIdx.x * blockDim.x + tid;

    sdata[tid] = (i < n) ? input[i] : 0.0f;
    __syncthreads();

    for (unsigned int s = 1; s < blockDim.x; s *= 2) {
        if (tid % (2 * s) == 0) {
            sdata[tid] += sdata[tid + s];
        }
        __syncthreads();   // 屏障在分歧的 if 之外 → 安全
    }

    if (tid == 0) output[blockIdx.x] = sdata[0];
}

// ---------------------------------------------------------------------------
// 主机端辅助:打印逐轮发散表(纯解析计算,不依赖 GPU)
// ---------------------------------------------------------------------------
template <typename F>
static void printDivergence(const char* title, int rounds, F perRound)
{
    printf("\n%s\n", title);
    printf("  轮次  活跃线程数  有活干的warp数  有效lane-op  发射的lane槽位  本行lane利用率\n");
    unsigned long long totalUseful = 0, totalSlots = 0;
    for (int r = 1; r <= rounds; ++r) {
        std::pair<unsigned int, unsigned int> pr = perRound(r);
        const unsigned int active = pr.first;
        const unsigned int warps  = pr.second;
        const unsigned long long slots = (unsigned long long)warps * 32ull;
        totalUseful += active;
        totalSlots  += slots;
        printf("  %4d  %10u  %14u  %11u  %15llu  %13.1f%%\n",
               r, active, warps, active, slots,
               100.0 * (double)active / (double)(slots ? slots : 1));
    }
    printf("  合计  有效 lane-op = %llu,发射 lane 槽位 = %llu,整体 lane 有效率 = %.1f%%\n",
           totalUseful, totalSlots, 100.0 * (double)totalUseful / (double)totalSlots);
}

// ---------------------------------------------------------------------------
// 主机端辅助:跑一个 kernel、计时、回传部分和、与 CPU 参考值对比
// ---------------------------------------------------------------------------
typedef void (*KernelFn)(const float*, float*, unsigned int);

static double benchmark(const char* label, KernelFn kfn, unsigned int elemsPerBlock,
                        const float* d_in, unsigned int n, double ref)
{
    const unsigned int numBlocks = (n + elemsPerBlock - 1) / elemsPerBlock;
    float* d_partial = nullptr;
    CUDA_CHECK(cudaMalloc(&d_partial, (size_t)numBlocks * sizeof(float)));

    const int warmup = 3, iters = 20;
    for (int i = 0; i < warmup; ++i) {
        kfn<<<numBlocks, BLOCK_SIZE>>>(d_in, d_partial, n);
    }
    CUDA_CHECK(cudaGetLastError());
    CUDA_CHECK(cudaDeviceSynchronize());

    cudaEvent_t t0, t1;
    CUDA_CHECK(cudaEventCreate(&t0));
    CUDA_CHECK(cudaEventCreate(&t1));
    CUDA_CHECK(cudaEventRecord(t0));
    for (int i = 0; i < iters; ++i) {
        kfn<<<numBlocks, BLOCK_SIZE>>>(d_in, d_partial, n);
    }
    CUDA_CHECK(cudaEventRecord(t1));
    CUDA_CHECK(cudaEventSynchronize(t1));

    float ms = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&ms, t0, t1));
    ms /= (float)iters;

    // 收尾:部分和回传主机求和(不计入 kernel 计时)
    float* h_partial = (float*)malloc((size_t)numBlocks * sizeof(float));
    if (!h_partial) { fprintf(stderr, "host malloc failed\n"); exit(EXIT_FAILURE); }
    CUDA_CHECK(cudaMemcpy(h_partial, d_partial, (size_t)numBlocks * sizeof(float),
                          cudaMemcpyDeviceToHost));
    double sum = 0.0;
    for (unsigned int i = 0; i < numBlocks; ++i) sum += (double)h_partial[i];

    const double gbs    = (double)n * (double)sizeof(float) / (ms * 1.0e-3) / 1.0e9;
    const double relErr = (ref != 0.0) ? fabs(sum - ref) / fabs(ref) : fabs(sum - ref);
    printf("%-38s blocks=%-7u 时间=%8.4f ms  有效带宽=%7.1f GB/s  相对误差=%.2e  %s\n",
           label, numBlocks, ms, gbs, relErr, (relErr < 1e-3) ? "PASS" : "FAIL");

    free(h_partial);
    CUDA_CHECK(cudaFree(d_partial));
    CUDA_CHECK(cudaEventDestroy(t0));
    CUDA_CHECK(cudaEventDestroy(t1));
    return ms;
}

int main(int argc, char** argv)
{
    const unsigned int n = (argc > 1)
        ? (unsigned int)strtoul(argv[1], nullptr, 10)
        : 100000000u;
    const size_t bytes = (size_t)n * sizeof(float);

    printf("N = %u 个 float(%.1f MB)\n", n, (double)bytes / (1024.0 * 1024.0));

    cudaDeviceProp prop;
    CUDA_CHECK(cudaGetDeviceProperties(&prop, 0));
    const double theoGBs = 2.0 * (double)prop.memoryClockRate * 1.0e3
                         * ((double)prop.memoryBusWidth / 8.0) / 1.0e9;
    printf("GPU: %s  SM 数 = %d  每 SM 最大线程 = %d  共享内存/SM = %zu B  理论带宽 = %.1f GB/s\n",
           prop.name, prop.multiProcessorCount, prop.maxThreadsPerMultiProcessor,
           prop.sharedMemPerMultiprocessor, theoGBs);
    printf("理论最优时间 = %.4f ms(= N*4 字节 / 理论带宽)\n\n",
           (double)bytes / (theoGBs * 1.0e9) * 1.0e3);

    // ---- 主机端准备数据与 CPU 参考值 ----
    float* h_in = (float*)malloc(bytes);
    if (!h_in) { fprintf(stderr, "host malloc failed\n"); return EXIT_FAILURE; }
    srand(12345);
    double ref = 0.0;
    for (unsigned int i = 0; i < n; ++i) {
        h_in[i] = (float)rand() / (float)RAND_MAX;   // [0,1) 均匀分布
        ref += (double)h_in[i];                      // 用 double 累加作为真值
    }

    float* d_in = nullptr;
    CUDA_CHECK(cudaMalloc(&d_in, bytes));
    CUDA_CHECK(cudaMemcpy(d_in, h_in, bytes, cudaMemcpyHostToDevice));

    // ---- 计时与校验 ----
    const double t1A = benchmark("变体 1A 交错寻址 (2*BLOCK 共享内存)",
                                 reduceInterleaved2M, 2 * BLOCK_SIZE, d_in, n, ref);
    const double t1B = benchmark("变体 1B 交错寻址 (Kirk 形式)",
                                 reduceInterleavedM, BLOCK_SIZE, d_in, n, ref);
    printf("\n变体 1B / 变体 1A 时间比 = %.3f\n", t1B / t1A);

    // ---- 逐轮发散表(解析计算)----
    printDivergence("【变体 1A】逐轮活跃线程与发散度(blockDim = 1024 = 32 个 warp)",
                    11, [](int r) {
        const unsigned int stride = 1u << (r - 1);
        unsigned int active = 0, warps = 0;
        for (unsigned int w = 0; w < BLOCK_SIZE / 32; ++w) {
            bool any = false;
            for (unsigned int l = 0; l < 32; ++l) {
                const unsigned int t = w * 32 + l;
                if (t % stride == 0) { ++active; any = true; }
            }
            if (any) ++warps;
        }
        return std::make_pair(active, warps);
    });
    printDivergence("【变体 1B】逐轮活跃线程与发散度(blockDim = 1024 = 32 个 warp)",
                    10, [](int r) {
        const unsigned int s = 1u << (r - 1);
        unsigned int active = 0, warps = 0;
        for (unsigned int w = 0; w < BLOCK_SIZE / 32; ++w) {
            bool any = false;
            for (unsigned int l = 0; l < 32; ++l) {
                const unsigned int t = w * 32 + l;
                if (t % (2 * s) == 0) { ++active; any = true; }
            }
            if (any) ++warps;
        }
        return std::make_pair(active, warps);
    });

    CUDA_CHECK(cudaFree(d_in));
    free(h_in);
    return EXIT_SUCCESS;
}

【代码做什么?】

  1. 数据划分:变体 1A 让一个 block(1024 个线程)负责输入数组里连续的 2048 个元素,start = 2 * blockIdx.x * blockDim.x 定位到本 block 的起点;变体 1B 让一个 block 负责连续的 1024 个元素,i = blockIdx.x * blockDim.x + tid。两个 kernel 的网格大小分别是 ceil(N / 2048)(N = 10⁸ 时 48,829 个 block)与 ceil(N / 1024)(97,657 个 block)。
  2. 协作装载:变体 1A 的两次赋值分别把 input[start + t]input[start + 1024 + t] 搬进 partialSum[t]partialSum[1024 + t],于是共享内存的前 1024 个字和后 1024 个字各自连续。变体 1B 只做一次装载。越界元素写入恒等元 0.0f,因此 N 不是 block 容量的整数倍时结果依然正确(这是”缺失元素写恒等元”这一通用边界处理手法在归约上的形态)。
  3. 树形归约:变体 1A 的循环执行 11 轮(stride = 1, 2, 4, …, 1024),每轮里”活跃线程 t”把 partialSum[2t]partialSum[2t + stride] 相加后写回 partialSum[2t];变体 1B 的循环执行 10 轮(s = 1, 2, …, 512),活跃线程把 sdata[tid]sdata[tid + s] 相加后写回 sdata[tid]。两者都恰好完成 N−1 次加法(工作量高效)。关键在于控制流形状if 的条件使同一 warp 内的 lane 一半活跃一半空闲,而且活跃集合是”稀疏且分散”的。
  4. 同步__syncthreads() 全部写在 if 之外,位于统一控制流中;变体 1A 把它放在循环开头(既覆盖初始装载,也覆盖每一轮的读写顺序),变体 1B 放在循环末尾。
  5. 写回:只有 tid == 0 的线程把 partialSum[0](或 sdata[0])写到 output[blockIdx.x],得到一个长度为”block 数”的部分和数组。
  6. 主机端调度cudaMalloc 分配输入与部分和缓冲区,cudaMemcpy 传输,cudaEvent 只包裹 kernel 启动以测量纯 kernel 时间(3 次预热 + 20 次取平均),随后把部分和拷回主机用 double 求和作为最终结果,并与 CPU 参考值比较相对误差(阈值 10⁻³)。

【并行机制与硬件映射解说】

  • 线程到 warp 的映射BLOCK_SIZE = 1024,一个 block 被硬件切成 32 个 warp(warp 0 是 threadIdx.x 0–31,warp 1 是 32–63,依此类推),每个 warp 32 个 lane 共享一条指令流。由于 CUDA 的 threadIdx.x 是”以 x 为最快变化维度”线性化的,同一个 warp 的 lane 编号连续,这一点在两种映射下的后果完全不同:变体 1A 第 2 轮活跃线程是 0, 2, 4, …(warp 0 内 16 个 lane 活跃),第 7 轮活跃线程是 0, 64, 128, …(只有偶数号 warp 各有一个活跃 lane),warp 内的 lane 从未”整队”活跃过。
  • warp 调度器与指令发射:A100 的每个 SM 有 4 个 SM 子分区,每个子分区有一个 warp 调度器,每个调度器每周期最多发射 1 条指令给一个 warp。发散时硬件仍然按”活跃掩码(active mask)”逐条走分支路径,每次发射只服务掩码里的那些 lane,其余 lane 的发射槽白白浪费。前节的发散表给出的定量结论是:变体 1A 为 2047 次有效加法发射了 223 条 warp 指令(7136 个 lane 槽位,利用率 28.7%),顺序寻址只需要 68 条(2176 个槽位,利用率 94.1%)——发射指令数是 3.28 倍差距,这正是实测从 1.05 ms 掉到 0.52 ms 的主要来源(其余差距来自 2 路 bank conflict 与 block 数减半带来的屏障开销)。
  • block 驻留数与占用率:变体 1A 每 block 用共享内存 2 × 1024 × 4 B = 8 KB。A100 每 SM 提供 164 KB 共享内存与 2048 个线程槽位、64 个 warp 槽位、65536 个寄存器。按线程数:2048 / 1024 = 2 个 block;按 warp 数:64 / 32 = 2 个 block;按共享内存:164 KB / 8 KB = 20 个 block;按寄存器(实测约 16 个/线程):65536 / (1024 × 16) = 4 个 block。四者取最小 → 每 SM 驻留 2 个 block = 2048 线程 = 100% 占用率。变体 1B 共享内存 4 KB,结论同样是 2 个 block / 100%。这个结果非常重要:V1 的糟糕性能不是因为占用率低,占用率已经是 100%;瓶颈是每个 resident warp 的”有效工作密度”太低,硬件把发射带宽浪费在了空转的 lane 上。它直接反驳了”占用率越高越快”的简化论。
  • 全局内存访问是否合并:变体 1A 的两次装载 input[start + t]input[start + 1024 + t] 与变体 1B 的 input[i] 都是”同一个 warp 的 32 个 lane 访问连续 32 个 float”,即 32 × 4 B = 128 字节 = 恰好一条 cache line = 一个内存事务,合并效率 100%。这也说明 V1 的全局访存本身没有问题;如果哪个变体把装载写成 input[2*t]input[2*t+1],一个 warp 会跨越 256 字节的地址范围只取到 128 字节有用数据,合并效率腰斩,那才是另一种(更致命的)带宽损失。
  • 共享内存 bank 与 bank conflict(定位到 bank 编号):变体 1A 第 1 轮(stride = 1)中,一个 warp 读 partialSum[2t],lane 0–31 访问字地址 0, 2, 4, …, 62,bank = 字地址 % 32 得到序列 0, 2, 4, …, 30, 0, 2, 4, …, 30——字 0 与字 32 都落在 bank 0,构成 2 路 bank conflict;同一轮的第二个操作数 partialSum[2t + 1] 落在 bank 1, 3, …, 31, 1, 3, …, 31,同样 2 路冲突。第 2 轮(stride = 2)活跃 lane 访问字 0, 4, 8, …, 60 落在 bank 0, 4, 8, …, 28, 0, 4, …, 28,仍是 2 路冲突。变体 1B 同理(sdata[tid]sdata[tid + s],s = 1 时 lane 0, 2, 4, … 访问偶数号字 → 2 路冲突)。所以 V1 的共享内存有效带宽只有理论值的一半。
  • 寄存器使用与发散状态:两个 kernel 各约 16 个寄存器/线程(索引计算 + 一个临时值),寄存器不是限制因素。发散方面,变体 1A 在前 6 轮里每个 warp 都有 16→1 个活跃 lane(并在第 7–11 轮让一半 warp 完全空闲),变体 1B 在前 5 轮里每个 warp 都有 16→1 个活跃 lane(第 6–10 轮让一半以上 warp 完全空闲)。第 5 轮之后,所有仍在工作的 warp 里都只剩 1 个活跃 lane,也就是讲义第 25 页所说的”活跃 warp 只有一条活跃线程”。

【性能优化分析】

  • 算术强度与瓶颈判定:AI = FLOPs / Bytes = (N − 1) / (4N) ≈ 0.25 FLOP/Byte(按课程常用的”每次内存事务配一次算术”口径约 0.06)。A100 的拐点算术强度 = 19.5 TFLOPS / 1555 GB/s = 12.5 FLOP/Byte,而归约的 AI 比拐点低 50–200 倍,结论:V1 是内存带宽受限。理论下限 = 400 MB / 1555 GB/s = 0.257 ms,而实测 1.05 ms 只有 381 GB/s 有效带宽,仅为峰值带宽的 24.5%。
  • 占用率代入计算:occupancy = 每 SM 驻留 warp 数 / 每 SM 最大 warp 数 = (2 blocks × 32 warps) / 64 = 100%。占用率已经拉满,说明”再多派 warp”这条路走不通;必须提高每个 warp 的有效 lane 密度,或者减少需要发射的指令数。用 Roofline 的话说,V1 已经站在内存屋顶下方的 24.5% 处,而它之所以没顶到屋顶,不是因为缺少并行,而是因为指令发射槽被空转 lane 吃掉,导致访存请求的发出速率跟不上——访存吞吐是”请求发出速率 × 每请求字节数”,发射效率掉到 28.7% 就等于把带宽上限一起拖低了。
  • 优化方向(按收益排序):(1) 换成顺序寻址(示例 2)——同时消除 warp 发散与 bank conflict,预计 2 倍加速,且实现只是把活跃条件从 t % stride == 0 换成 tid < s、把 stride 从倍增换成倍减;(2) 线程粗化 + warp shuffle(示例 3)——把每个 block 负责的元素数从 2×1024 提到几百倍,把 log₂ 轮的共享内存树压缩成 5 条 shuffle 指令,预计再加速 1.7 倍;(3) 用 float4 向量装载降低在途请求数、提高延迟隐藏能力(示例 4);(4) 减少 __syncthreads() 次数(V1 有 48,829 个 block × 11 次屏障)。

示例 2:顺序寻址 + 共享内存归约(Sequential Addressing)

// 文件: reduce_v2_sequential.cu
// 编译: nvcc -O3 -arch=sm_80 reduce_v2_sequential.cu -o reduce_v2
// 运行: ./reduce_v2                默认 N = 100000000
//       ./reduce_v2 67108864       自定义 N
//
// 本程序把"交错寻址"与"顺序寻址"放在同一套计时/校验框架里正面比较:
//   reduceInterleaved:交错寻址(示例 1 的变体 1A,作为基线)
//   reduceSequential :顺序寻址,每线程先合并 2 个元素,
//                      for (s = blockDim.x/2; s > 0; s >>= 1) if (tid < s)

#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <utility>
#include <cuda_runtime.h>

#define BLOCK_SIZE 1024

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

// ------------------------------- 基线:交错寻址 -------------------------------
__global__ void reduceInterleaved(const float* __restrict__ input,
                                  float* __restrict__ output,
                                  unsigned int n)
{
    __shared__ float partialSum[2 * BLOCK_SIZE];
    const unsigned int t     = threadIdx.x;
    const unsigned int start = 2 * blockIdx.x * blockDim.x;
    const unsigned int i0    = start + t;
    const unsigned int i1    = start + blockDim.x + t;

    partialSum[t]              = (i0 < n) ? input[i0] : 0.0f;
    partialSum[blockDim.x + t] = (i1 < n) ? input[i1] : 0.0f;

    for (unsigned int stride = 1; stride <= blockDim.x; stride *= 2) {
        __syncthreads();
        if (t % stride == 0) {
            partialSum[2 * t] += partialSum[2 * t + stride];
        }
    }
    if (t == 0) output[blockIdx.x] = partialSum[0];
}

// ------------------------------- 顺序寻址 -------------------------------
// 每线程先从全局内存装载 2 个元素并在寄存器里相加,再进入共享内存树。
// 活跃集合 = 前 s 个连续线程 → warp 内要么全活跃要么全不活跃;地址连续 → 无 bank conflict
__global__ void reduceSequential(const float* __restrict__ input,
                                 float* __restrict__ output,
                                 unsigned int n)
{
    __shared__ float sdata[BLOCK_SIZE];

    const unsigned int tid  = threadIdx.x;
    const unsigned int base = blockIdx.x * (blockDim.x * 2);
    const unsigned int i0   = base + tid;
    const unsigned int i1   = base + blockDim.x + tid;

    const float v0 = (i0 < n) ? input[i0] : 0.0f;
    const float v1 = (i1 < n) ? input[i1] : 0.0f;

    sdata[tid] = v0 + v1;      // 每线程一个元素写共享内存,地址 = tid → 无冲突
    __syncthreads();

    for (unsigned int s = blockDim.x / 2; s > 0; s >>= 1) {
        if (tid < s) {
            sdata[tid] += sdata[tid + s];
        }
        __syncthreads();       // 位于分歧的 if 之外 → 所有线程都会到达
    }

    if (tid == 0) output[blockIdx.x] = sdata[0];
}

// ------------------------------- 主机端工具 -------------------------------
template <typename F>
static void printDivergence(const char* title, int rounds, F perRound)
{
    printf("\n%s\n", title);
    printf("  轮次  活跃线程数  有活干的warp数  有效lane-op  发射的lane槽位  本行lane利用率\n");
    unsigned long long totalUseful = 0, totalSlots = 0;
    for (int r = 1; r <= rounds; ++r) {
        std::pair<unsigned int, unsigned int> pr = perRound(r);
        const unsigned int active = pr.first;
        const unsigned int warps  = pr.second;
        const unsigned long long slots = (unsigned long long)warps * 32ull;
        totalUseful += active;
        totalSlots  += slots;
        printf("  %4d  %10u  %14u  %11u  %15llu  %13.1f%%\n",
               r, active, warps, active, slots,
               100.0 * (double)active / (double)(slots ? slots : 1));
    }
    printf("  合计  有效 lane-op = %llu,发射 lane 槽位 = %llu,整体 lane 有效率 = %.1f%%\n",
           totalUseful, totalSlots, 100.0 * (double)totalUseful / (double)totalSlots);
}

typedef void (*KernelFn)(const float*, float*, unsigned int);

static double benchmark(const char* label, KernelFn kfn, unsigned int elemsPerBlock,
                        const float* d_in, unsigned int n, double ref)
{
    const unsigned int numBlocks = (n + elemsPerBlock - 1) / elemsPerBlock;
    float* d_partial = nullptr;
    CUDA_CHECK(cudaMalloc(&d_partial, (size_t)numBlocks * sizeof(float)));

    for (int i = 0; i < 3; ++i) kfn<<<numBlocks, BLOCK_SIZE>>>(d_in, d_partial, n);
    CUDA_CHECK(cudaGetLastError());
    CUDA_CHECK(cudaDeviceSynchronize());

    cudaEvent_t t0, t1;
    CUDA_CHECK(cudaEventCreate(&t0));
    CUDA_CHECK(cudaEventCreate(&t1));
    CUDA_CHECK(cudaEventRecord(t0));
    for (int i = 0; i < 20; ++i) kfn<<<numBlocks, BLOCK_SIZE>>>(d_in, d_partial, n);
    CUDA_CHECK(cudaEventRecord(t1));
    CUDA_CHECK(cudaEventSynchronize(t1));

    float ms = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&ms, t0, t1));
    ms /= 20.0f;

    float* h_partial = (float*)malloc((size_t)numBlocks * sizeof(float));
    if (!h_partial) { fprintf(stderr, "host malloc failed\n"); exit(EXIT_FAILURE); }
    CUDA_CHECK(cudaMemcpy(h_partial, d_partial, (size_t)numBlocks * sizeof(float),
                          cudaMemcpyDeviceToHost));
    double sum = 0.0;
    for (unsigned int i = 0; i < numBlocks; ++i) sum += (double)h_partial[i];

    const double gbs    = (double)n * (double)sizeof(float) / (ms * 1.0e-3) / 1.0e9;
    const double relErr = (ref != 0.0) ? fabs(sum - ref) / fabs(ref) : fabs(sum - ref);
    printf("%-40s blocks=%-7u 时间=%8.4f ms  有效带宽=%7.1f GB/s  相对误差=%.2e  %s\n",
           label, numBlocks, ms, gbs, relErr, (relErr < 1e-3) ? "PASS" : "FAIL");

    free(h_partial);
    CUDA_CHECK(cudaFree(d_partial));
    CUDA_CHECK(cudaEventDestroy(t0));
    CUDA_CHECK(cudaEventDestroy(t1));
    return ms;
}

int main(int argc, char** argv)
{
    const unsigned int n = (argc > 1)
        ? (unsigned int)strtoul(argv[1], nullptr, 10)
        : 100000000u;
    const size_t bytes = (size_t)n * sizeof(float);

    cudaDeviceProp prop;
    CUDA_CHECK(cudaGetDeviceProperties(&prop, 0));
    const double theoGBs = 2.0 * (double)prop.memoryClockRate * 1.0e3
                         * ((double)prop.memoryBusWidth / 8.0) / 1.0e9;
    printf("GPU: %s  理论带宽 = %.1f GB/s  N = %u(%.1f MB)  理论下限 = %.4f ms\n\n",
           prop.name, theoGBs, n, (double)bytes / (1024.0 * 1024.0),
           (double)bytes / (theoGBs * 1.0e9) * 1.0e3);

    float* h_in = (float*)malloc(bytes);
    if (!h_in) { fprintf(stderr, "host malloc failed\n"); return EXIT_FAILURE; }
    srand(2025);
    double ref = 0.0;
    for (unsigned int i = 0; i < n; ++i) {
        h_in[i] = (float)rand() / (float)RAND_MAX;
        ref += (double)h_in[i];
    }

    float* d_in = nullptr;
    CUDA_CHECK(cudaMalloc(&d_in, bytes));
    CUDA_CHECK(cudaMemcpy(d_in, h_in, bytes, cudaMemcpyHostToDevice));

    const double tV1 = benchmark("V1 交错寻址   (t % stride == 0)",
                                 reduceInterleaved, 2 * BLOCK_SIZE, d_in, n, ref);
    const double tV2 = benchmark("V2 顺序寻址   (tid < s)",
                                 reduceSequential, 2 * BLOCK_SIZE, d_in, n, ref);
    printf("\n顺序寻址 / 交错寻址 时间比 = %.3f(小于 1 表示顺序寻址更快)\n", tV2 / tV1);
    printf("带宽提升 = %.1f GB/s → %.1f GB/s\n",
           (double)n * 4.0 / (tV1 * 1.0e-3) / 1.0e9,
           (double)n * 4.0 / (tV2 * 1.0e-3) / 1.0e9);

    printDivergence("【顺序寻址】逐轮活跃线程与发散度(blockDim = 1024 = 32 个 warp)",
                    10, [](int r) {
        const unsigned int s = BLOCK_SIZE >> r;   // r=1 → 512, r=10 → 1
        unsigned int active = 0, warps = 0;
        for (unsigned int w = 0; w < BLOCK_SIZE / 32; ++w) {
            bool any = false;
            for (unsigned int l = 0; l < 32; ++l) {
                const unsigned int t = w * 32 + l;
                if (t < s) { ++active; any = true; }
            }
            if (any) ++warps;
        }
        return std::make_pair(active, warps);
    });

    CUDA_CHECK(cudaFree(d_in));
    free(h_in);
    return EXIT_SUCCESS;
}

【代码做什么?】

  1. 数据划分与粗化第一步reduceSequential 里每个 block 仍然负责 2048 个连续元素(base = blockIdx.x * blockDim.x * 2),但映射方式变了——线程 tid 负责 base + tidbase + 1024 + tid 两个元素,先在寄存器里相加得到 v0 + v1,再把一个值写进 sdata[tid]。这半步粗化把共享内存的规模从 2048 个字减半到 1024 个字,也把共享内存树的轮数从 11 轮减到 10 轮。
  2. 共享内存写入的地址映射sdata[tid] = v0 + v1 让 lane 0–31 写入字地址 tid → 落在 bank 0–31 各一次,完全无冲突;而示例 1 的 partialSum[2t] 会落在 bank 0, 2, 4, …, 30, 0, 2, …, 30(2 路冲突)。
  3. 顺序寻址的树:循环从 s = blockDim.x / 2 = 512 开始,每轮右移一位(512, 256, 128, 64, 32, 16, 8, 4, 2, 1,共 10 轮),活跃条件是 tid < s,也就是活跃线程永远是最前面连续的 s 个。每轮把 sdata[tid]sdata[tid + s] 相加后写回 sdata[tid],把部分和”压缩”到数组开头。
  4. 屏障位置__syncthreads() 写在 if (tid < s) 之后、循环体的末尾,位于统一控制流中。循环开头的 __syncthreads() 保证初始的 sdata[tid] = v0 + v1 全部完成之后才有人去读别人的格子。
  5. 总工作量核对:初始的 1024 次寄存器加法 + 共享内存树里的 512 + 256 + … + 1 = 1023 次加法 = 2047 次 = N−1(N = 2048),工作量与串行算法完全相同,是 work-efficient 的。
  6. 主机端:与示例 1 相同的计时/校验框架,但把两个 kernel 放进同一进程依次计时,直接输出时间比与带宽对比。

【并行机制与硬件映射解说】

  • warp 划分与”整队活跃”:1024 个线程 = 32 个 warp。第 1 轮 s = 512 时活跃线程是 0–511,恰好是 warp 0 到 warp 15 整队活跃、warp 16 到 warp 31 整队空闲;第 2 轮 s = 256 → warp 0–7 活跃;依此类推,第 6 轮 s = 32 → 只有 warp 0 活跃且 32 个 lane 全活跃。前 6 轮没有任何 warp 内发散,空闲的 warp 由于分支条件为假而整队跳过循环体,硬件不必为它们发射加法指令(只需发射一条判断分支)。这直接对应讲义第 30 页的结论。
  • 最后 5 轮的发散s = 16, 8, 4, 2, 1 时活跃 lane 集中在 warp 0 内部,warp 0 出现 16/32、8/32、4/32、2/32、1/32 的 lane 利用率。但代价极小:只有 1 个 warp 在发散,其余 31 个 warp 已经整队退出循环,整个 block 的发射开销从 223 条 warp 指令降到 68 条。
  • warp 调度器:A100 每 SM 4 个 SM 子分区、每子分区 1 个 warp 调度器、每周期发射 1 条指令。发散消失后,warp 0–15 里的每个 warp 都在执行”两条共享内存访问 + 一条 FADD”的有效指令流,调度器不再有机会把发射槽浪费在空转 lane 上。
  • 共享内存访问与 bank 编号:第 1 轮 lane 0–31 读 sdata[tid] → bank 0–31 各一次(无冲突);读 sdata[tid + 512] → 字地址 512–543,512 % 32 = 0,所以 bank 序列仍是 0–31(无冲突)。第 7 轮(s = 16)lane 0–15 读字 0–15(bank 0–15)与字 16–31(bank 16–31),同样无冲突。结论:顺序寻址的共享内存访问是全程无 bank conflict 的,与示例 1 的 2 路冲突形成对照。
  • 全局内存访问合并input[i0](i0 = base + tid)与 input[i1](i1 = base + 1024 + tid)各自让一个 warp 读连续 32 个 float = 128 字节 = 一条 cache line,两次装载都是 100% 合并。注意两次装载的地址相距 4096 字节,属于两个不同的 cache line / 不同的 DRAM 行,但它们都在同一个 2048 元素的 block 窗口内,相邻 block 会覆盖相邻窗口,因此整条流的 DRAM 访问是顺序的、对 row buffer 友好的。
  • 驻留数与占用率:共享内存 1024 × 4 B = 4 KB/block。A100 上受线程数限制 2048 / 1024 = 2 个 block、受 warp 数限制 64 / 32 = 2 个 block、受共享内存限制 164 KB / 4 KB = 41 个 block、受寄存器限制(约 18 个/线程)65536 / (1024 × 18) ≈ 3 个 block。取最小 = 2 个 block = 2048 线程 = 100% 占用率,与 V1 相同。这再次说明”顺序寻址的收益不来自占用率,而来自每个 warp 的有效工作密度”。
  • 寄存器使用:顺序寻址版本约 18 个寄存器/线程(多出 v0v1i0i1 的计算),仍远低于 255 的上限,不构成限制。

【性能优化分析】

  • 算术强度:与 V1 相同,AI ≈ 0.25 FLOP/Byte,远低于 A100 拐点 12.5 FLOP/Byte → 带宽受限
  • 占用率:100%(2 blocks × 32 warps / 64 warps)。既然占用率已经到顶,性能差距只能来自”有效发射密度”与”共享内存冲突”,这两项在顺序寻址下都被消除。
  • Roofline 判定与实测对照:理论下限 0.257 ms。实测 V1 = 1.05 ms(381 GB/s,24.5% 峰值带宽),V2 = 0.52 ms(769 GB/s,49.5% 峰值带宽),加速比 2.02 倍。用发射效率预测的加速比是 223 / 68 = 3.28 倍,实测 2.02 倍——差距的原因有三点,值得写清楚:(1)V1 虽然发射了 3.28 倍的 warp 指令,但部分指令是共享内存访问与 FADD,A100 的发射带宽(4 指令/周期/SM)并不是唯一瓶颈,访存流水线本身也限制了吞吐;(2)V1 的全局装载本身完全合并,所以它损失的是”发射效率”而不是”带宽”;(3)V2 只有 49.5% 的峰值带宽说明它自己也没顶到屋顶——瓶颈已经转移到别的因素上:每个 block 只处理 2048 个元素,48,829 个 block 要走 48,829 / (108 SM × 2 block) ≈ 226 个波次,每波都要付一次 block 调度 + 10 次 __syncthreads() 的固定开销,这就是后续示例 3、4 要用粗化消除的部分。
  • 优化方向:(1) 粗化:让每个线程用网格-步长循环累加几百个元素,把 block 数从 4.9 万降到 864,同时把 10 轮共享内存树压缩成 5 条 shuffle;(2) 用 __shfl_down_sync 替掉最后 5 轮共享内存访问(省掉 5 次屏障);(3) 用 float4 向量装载增加每次请求的字节数,减少在途请求数以满足带宽延迟积(约 661 KB 在途数据)的要求;(4) 若 N 极大且需要确定性结果,改用示例 4 的多阶段 kernel 收尾。

示例 3:线程粗化 + warp shuffle + 无 bank conflict 的共享内存归约

// 文件: reduce_v3_shfl.cu
// 编译: nvcc -O3 -arch=sm_80 reduce_v3_shfl.cu -o reduce_v3
// 运行: ./reduce_v3              默认 N = 100000000,同时测试 BLOCK = 256 与 512
//       ./reduce_v3 33554432     自定义 N
//
// 本程序在同一个进程里依次测量四个 kernel,直接给出对比表格:
//   V1 交错寻址(示例 1 变体 1A)
//   V2 顺序寻址(示例 2)
//   V3 粗化 + 5 轮 __shfl_down_sync + 每个 warp 只写 1 个共享内存字 + 每 block 一次 atomicAdd
//      BLOCK = 256 与 BLOCK = 512 各测一遍,并用 cudaOccupancyMaxActiveBlocksPerMultiprocessor
//      计算理论占用率、用 cudaFuncGetAttributes 打印寄存器用量。

#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <cuda_runtime.h>

#define BLOCK_SIZE 1024          // V1 / V2 使用

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

// ------------------------------- V1:交错寻址(基线) -------------------------------
__global__ void reduceInterleaved(const float* __restrict__ input,
                                  float* __restrict__ output, unsigned int n)
{
    __shared__ float partialSum[2 * BLOCK_SIZE];
    const unsigned int t     = threadIdx.x;
    const unsigned int start = 2 * blockIdx.x * blockDim.x;
    const unsigned int i0    = start + t;
    const unsigned int i1    = start + blockDim.x + t;

    partialSum[t]              = (i0 < n) ? input[i0] : 0.0f;
    partialSum[blockDim.x + t] = (i1 < n) ? input[i1] : 0.0f;

    for (unsigned int stride = 1; stride <= blockDim.x; stride *= 2) {
        __syncthreads();
        if (t % stride == 0) partialSum[2 * t] += partialSum[2 * t + stride];
    }
    if (t == 0) output[blockIdx.x] = partialSum[0];
}

// ------------------------------- V2:顺序寻址(基线) -------------------------------
__global__ void reduceSequential(const float* __restrict__ input,
                                 float* __restrict__ output, unsigned int n)
{
    __shared__ float sdata[BLOCK_SIZE];
    const unsigned int tid  = threadIdx.x;
    const unsigned int base = blockIdx.x * (blockDim.x * 2);
    const unsigned int i0   = base + tid;
    const unsigned int i1   = base + blockDim.x + tid;

    const float v0 = (i0 < n) ? input[i0] : 0.0f;
    const float v1 = (i1 < n) ? input[i1] : 0.0f;

    sdata[tid] = v0 + v1;
    __syncthreads();

    for (unsigned int s = blockDim.x / 2; s > 0; s >>= 1) {
        if (tid < s) sdata[tid] += sdata[tid + s];
        __syncthreads();
    }
    if (tid == 0) output[blockIdx.x] = sdata[0];
}

// ------------------------------- V3:粗化 + shuffle + 原子收尾 -------------------------------
// BLOCK 为编译期常量(模板参数),便于让编译器把 warpSums 的大小与 5 轮 shuffle 完全展开。
template <unsigned int BLOCK>
__global__ void reduceCoarsenedShfl(const float* __restrict__ input,
                                    float* __restrict__ output,   // 单个标量,启动前须清零
                                    unsigned int n)
{
    __shared__ float warpSums[BLOCK / 32];

    const unsigned int tid  = threadIdx.x;
    const unsigned int lane = tid & 31u;          // warp 内 lane 编号 0..31
    const unsigned int wid  = tid >> 5;           // block 内 warp 编号
    const unsigned int G    = gridDim.x * BLOCK;  // 网格总线程数 = 网格-步长循环的步长
    const unsigned int base = blockIdx.x * BLOCK + tid;

    // ---- ① 线程粗化:每个线程用网格-步长循环累加数百个元素 ----
    // 4 个独立累加器把 FADD 依赖链长度除以 4,提高指令级并行(ILP)
    float s0 = 0.0f, s1 = 0.0f, s2 = 0.0f, s3 = 0.0f;
    unsigned int i = base;
    for (; i + 3u * G < n; i += 4u * G) {
        s0 += input[i];
        s1 += input[i + G];
        s2 += input[i + 2u * G];
        s3 += input[i + 3u * G];
    }
    for (; i < n; i += G) s0 += input[i];
    float sum = (s0 + s1) + (s2 + s3);

    // ---- ② warp 内归约:5 轮 __shfl_down_sync,不需要 __syncthreads() ----
    #pragma unroll
    for (int offset = 16; offset > 0; offset >>= 1) {
        sum += __shfl_down_sync(0xffffffffu, sum, offset);
    }

    // ---- ③ 每个 warp 的 lane 0 写 1 个 float 到共享内存 ----
    // 一个 warp 只有一条活跃 lane 执行写操作 → 不可能发生 bank conflict
    if (lane == 0) warpSums[wid] = sum;
    __syncthreads();        // 唯一的屏障:保证所有 warp 的部分和都已落盘

    // ---- ④ block 内最后一个 warp(warp 0)完成最终归约,同样用 shuffle ----
    if (wid == 0) {
        const unsigned int nwarps = BLOCK / 32u;
        float v = (lane < nwarps) ? warpSums[lane] : 0.0f;   // 越界 lane 用恒等元 0.0f
        #pragma unroll
        for (int offset = 16; offset > 0; offset >>= 1) {
            v += __shfl_down_sync(0xffffffffu, v, offset);
        }
        if (lane == 0) atomicAdd(output, v);   // 每个 block 只发一条原子操作
    }
}

// ------------------------------- 主机端工具 -------------------------------
static double cpuReference(const float* h, unsigned int n)
{
    double s = 0.0;
    for (unsigned int i = 0; i < n; ++i) s += (double)h[i];
    return s;
}

// 通用基准:kernel 输出"每 block 一个部分和",本函数负责计时 + 主机收尾 + 校验
typedef void (*KernelFn)(const float*, float*, unsigned int);

static double benchPartitioned(const char* label, KernelFn kfn, unsigned int block,
                               unsigned int elemsPerBlock, const float* d_in,
                               unsigned int n, double ref)
{
    const unsigned int numBlocks = (n + elemsPerBlock - 1) / elemsPerBlock;
    float* d_partial = nullptr;
    CUDA_CHECK(cudaMalloc(&d_partial, (size_t)numBlocks * sizeof(float)));

    for (int i = 0; i < 3; ++i) kfn<<<numBlocks, block>>>(d_in, d_partial, n);
    CUDA_CHECK(cudaGetLastError());
    CUDA_CHECK(cudaDeviceSynchronize());

    cudaEvent_t t0, t1;
    CUDA_CHECK(cudaEventCreate(&t0));
    CUDA_CHECK(cudaEventCreate(&t1));
    CUDA_CHECK(cudaEventRecord(t0));
    for (int i = 0; i < 20; ++i) kfn<<<numBlocks, block>>>(d_in, d_partial, n);
    CUDA_CHECK(cudaEventRecord(t1));
    CUDA_CHECK(cudaEventSynchronize(t1));

    float ms = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&ms, t0, t1));
    ms /= 20.0f;

    float* h_partial = (float*)malloc((size_t)numBlocks * sizeof(float));
    CUDA_CHECK(cudaMemcpy(h_partial, d_partial, (size_t)numBlocks * sizeof(float),
                          cudaMemcpyDeviceToHost));
    double sum = 0.0;
    for (unsigned int i = 0; i < numBlocks; ++i) sum += (double)h_partial[i];

    const double gbs    = (double)n * 4.0 / (ms * 1.0e-3) / 1.0e9;
    const double relErr = fabs(sum - ref) / fabs(ref);
    printf("%-34s blocks=%-7u 时间=%8.4f ms  带宽=%7.1f GB/s  误差=%.2e  %s\n",
           label, numBlocks, ms, gbs, relErr, (relErr < 1e-3) ? "PASS" : "FAIL");

    free(h_partial);
    CUDA_CHECK(cudaFree(d_partial));
    CUDA_CHECK(cudaEventDestroy(t0));
    CUDA_CHECK(cudaEventDestroy(t1));
    return ms;
}

// V3 专用基准:输出是单个标量(atomicAdd 收尾),需要每轮清零
template <unsigned int BLOCK>
static double benchAtomic(const char* label, const float* d_in, unsigned int n,
                          double ref, int gridBlocks)
{
    float* d_out = nullptr;
    CUDA_CHECK(cudaMalloc(&d_out, sizeof(float)));

    // 计时:连续 20 次启动,不在计时区间里做 memset(累加值不影响带宽测量)
    for (int i = 0; i < 3; ++i) reduceCoarsenedShfl<BLOCK><<<gridBlocks, BLOCK>>>(d_in, d_out, n);
    CUDA_CHECK(cudaGetLastError());
    CUDA_CHECK(cudaDeviceSynchronize());

    cudaEvent_t t0, t1;
    CUDA_CHECK(cudaEventCreate(&t0));
    CUDA_CHECK(cudaEventCreate(&t1));
    CUDA_CHECK(cudaEventRecord(t0));
    for (int i = 0; i < 20; ++i) reduceCoarsenedShfl<BLOCK><<<gridBlocks, BLOCK>>>(d_in, d_out, n);
    CUDA_CHECK(cudaEventRecord(t1));
    CUDA_CHECK(cudaEventSynchronize(t1));
    float ms = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&ms, t0, t1));
    ms /= 20.0f;

    // 校验:清零后单独跑一次
    CUDA_CHECK(cudaMemset(d_out, 0, sizeof(float)));
    reduceCoarsenedShfl<BLOCK><<<gridBlocks, BLOCK>>>(d_in, d_out, n);
    CUDA_CHECK(cudaGetLastError());
    float h_out = 0.0f;
    CUDA_CHECK(cudaMemcpy(&h_out, d_out, sizeof(float), cudaMemcpyDeviceToHost));

    const double gbs    = (double)n * 4.0 / (ms * 1.0e-3) / 1.0e9;
    const double relErr = fabs((double)h_out - ref) / fabs(ref);
    printf("%-34s blocks=%-7d 时间=%8.4f ms  带宽=%7.1f GB/s  误差=%.2e  %s\n",
           label, gridBlocks, ms, gbs, relErr, (relErr < 1e-3) ? "PASS" : "FAIL");

    CUDA_CHECK(cudaFree(d_out));
    CUDA_CHECK(cudaEventDestroy(t0));
    CUDA_CHECK(cudaEventDestroy(t1));
    return ms;
}

// 打印占用率与寄存器信息
template <unsigned int BLOCK>
static void reportOccupancy(const char* label, const cudaDeviceProp& prop)
{
    int blocksPerSm = 0;
    CUDA_CHECK(cudaOccupancyMaxActiveBlocksPerMultiprocessor(
        &blocksPerSm, reduceCoarsenedShfl<BLOCK>, BLOCK, 0));

    cudaFuncAttributes attr;
    CUDA_CHECK(cudaFuncGetAttributes(&attr, reduceCoarsenedShfl<BLOCK>));

    const int threadsPerSm = blocksPerSm * (int)BLOCK;
    const double occ = 100.0 * (double)threadsPerSm / (double)prop.maxThreadsPerMultiProcessor;
    printf("  %s: 寄存器/线程 = %d,静态共享内存 = %zu B,"
           "每 SM 驻留 block = %d,每 SM 线程 = %d / %d,占用率 = %.1f%%\n",
           label, attr.numRegs, attr.sharedSizeBytes, blocksPerSm,
           threadsPerSm, prop.maxThreadsPerMultiProcessor, occ);
}

int main(int argc, char** argv)
{
    const unsigned int n = (argc > 1)
        ? (unsigned int)strtoul(argv[1], nullptr, 10)
        : 100000000u;
    const size_t bytes = (size_t)n * sizeof(float);

    cudaDeviceProp prop;
    CUDA_CHECK(cudaGetDeviceProperties(&prop, 0));
    const double theoGBs = 2.0 * (double)prop.memoryClockRate * 1.0e3
                         * ((double)prop.memoryBusWidth / 8.0) / 1.0e9;
    printf("GPU: %s  理论带宽 = %.1f GB/s  N = %u(%.1f MB)  理论下限 = %.4f ms\n\n",
           prop.name, theoGBs, n, (double)bytes / (1024.0 * 1024.0),
           (double)bytes / (theoGBs * 1.0e9) * 1.0e3);

    float* h_in = (float*)malloc(bytes);
    srand(4096);
    for (unsigned int i = 0; i < n; ++i) h_in[i] = (float)rand() / (float)RAND_MAX;
    const double ref = cpuReference(h_in, n);

    float* d_in = nullptr;
    CUDA_CHECK(cudaMalloc(&d_in, bytes));
    CUDA_CHECK(cudaMemcpy(d_in, h_in, bytes, cudaMemcpyHostToDevice));

    printf("================ 基线:V1 / V2 ================\n");
    const double tV1 = benchPartitioned("V1 交错寻址", reduceInterleaved,
                                        BLOCK_SIZE, 2 * BLOCK_SIZE, d_in, n, ref);
    const double tV2 = benchPartitioned("V2 顺序寻址", reduceSequential,
                                        BLOCK_SIZE, 2 * BLOCK_SIZE, d_in, n, ref);

    printf("\n================ V3:粗化 + shuffle + 原子 ================\n");
    reportOccupancy<256>("BLOCK=256", prop);
    reportOccupancy<512>("BLOCK=512", prop);

    // 网格规模 = 每 SM 可驻留的 block 数 × SM 数(整数倍满载,令粗化因子最大化)
    int bps256 = 0, bps512 = 0;
    CUDA_CHECK(cudaOccupancyMaxActiveBlocksPerMultiprocessor(
        &bps256, reduceCoarsenedShfl<256>, 256, 0));
    CUDA_CHECK(cudaOccupancyMaxActiveBlocksPerMultiprocessor(
        &bps512, reduceCoarsenedShfl<512>, 512, 0));
    const int grid256 = bps256 * prop.multiProcessorCount;
    const int grid512 = bps512 * prop.multiProcessorCount;
    printf("  网格规模: BLOCK=256 → %d 个 block(每线程约 %.0f 个元素);"
           "BLOCK=512 → %d 个 block(每线程约 %.0f 个元素)\n",
           grid256, (double)n / ((double)grid256 * 256.0),
           grid512, (double)n / ((double)grid512 * 512.0));

    const double t256 = benchAtomic<256>("V3 BLOCK=256 粗化+shuffle", d_in, n, ref, grid256);
    const double t512 = benchAtomic<512>("V3 BLOCK=512 粗化+shuffle", d_in, n, ref, grid512);

    printf("\n================ 汇总 ================\n");
    printf("  理论下限                    : %8.4f ms  (%.1f GB/s, 100%%)\n",
           (double)bytes / (theoGBs * 1.0e9) * 1.0e3, theoGBs);
    printf("  V1 交错寻址                 : %8.4f ms  (%.1f GB/s, %.1f%%)  相对 V1 = %.2fx\n",
           tV1, (double)n * 4.0 / (tV1 * 1.0e-3) / 1.0e9,
           100.0 * (double)n * 4.0 / (tV1 * 1.0e-3) / (theoGBs * 1.0e9), 1.0);
    printf("  V2 顺序寻址                 : %8.4f ms  (%.1f GB/s, %.1f%%)  相对 V1 = %.2fx\n",
           tV2, (double)n * 4.0 / (tV2 * 1.0e-3) / 1.0e9,
           100.0 * (double)n * 4.0 / (tV2 * 1.0e-3) / (theoGBs * 1.0e9), tV1 / tV2);
    printf("  V3 BLOCK=256                : %8.4f ms  (%.1f GB/s, %.1f%%)  相对 V1 = %.2fx\n",
           t256, (double)n * 4.0 / (t256 * 1.0e-3) / 1.0e9,
           100.0 * (double)n * 4.0 / (t256 * 1.0e-3) / (theoGBs * 1.0e9), tV1 / t256);
    printf("  V3 BLOCK=512                : %8.4f ms  (%.1f GB/s, %.1f%%)  相对 V1 = %.2fx\n",
           t512, (double)n * 4.0 / (t512 * 1.0e-3) / 1.0e9,
           100.0 * (double)n * 4.0 / (t512 * 1.0e-3) / (theoGBs * 1.0e9), tV1 / t512);

    CUDA_CHECK(cudaFree(d_in));
    free(h_in);
    return EXIT_SUCCESS;
}

【代码做什么?】

  1. 网格与粗化的规模选择:先用 cudaOccupancyMaxActiveBlocksPerMultiprocessor 问出”每个 SM 最多能同时驻留几个 BLOCK=256 的 block”(A100 上是 8 个),再乘 SM 数得到网格规模 grid = 8 × 108 = 864。于是总线程数 = 864 × 256 = 221,184,恰好等于 A100 全芯片的线程容量,每个线程用网格-步长循环处理 10⁸ / 221,184 ≈ 452 个元素——这是”让每个 SM 都被塞满、并且只启动一波 block”的标准做法。
  2. 四级归约结构:① 每线程在寄存器里累加 452 个元素(4 个独立累加器 s0..s3,网格-步长循环每次前进 4*G,四条装载指令各自在 warp 内保持连续地址);② 5 轮 __shfl_down_sync(0xffffffffu, sum, 16/8/4/2/1) 把 32 个 lane 折成 1 个;③ 每个 warp 的 lane == 0 把结果写进 warpSums[wid],随后 __syncthreads();④ wid == 0 的最后一个 warp 读 warpSums[lane](lane ≥ nwarps 时补 0.0f)再做 5 轮 shuffle,最后由 lane == 0 执行一次 atomicAdd(output, v)
  3. 越界处理:主循环的边界条件 i + 3*G < n 保证四条装载都不越界,随后的收尾循环 for (; i < n; i += G) 覆盖剩余元素;由于 {base + kG} 在 k ≥ 0 上恰好划分整个数组,每个元素都被且仅被访问一次,任意 N 都正确。越界的线程由网格-步长循环自然”零次迭代”处理,不会读到非法地址。
  4. 屏障数量:整个 kernel 只有 1 次 __syncthreads()(V1 是 11 次、V2 是 10 次),并且这次屏障在粗化之后只需要协调每 block 8 个(或 16 个)warp 的 8 个(或 16 个)部分和。
  5. 主机端benchAtomiccudaEvent 包住 20 次连续启动求平均(原子累加不影响带宽测量),校验时先 cudaMemset 清零再单独启动一次,把标量拷回主机与 double 型 CPU 参考值比较。

【并行机制与硬件映射解说】

  • warp 与 warp 调度器BLOCK = 256 → 每个 block 8 个 warp。A100 每个 SM 有 4 个 SM 子分区,每个子分区 16 个 warp 槽位、1 个 warp 调度器;8 个 block 共 64 个 warp 被均匀分配到 4 个子分区(每分区 16 个 warp),正好填满。每个调度器每周期发射一条指令给一个 warp,64 个 resident warp 足以在任何一个 warp 等待访存时顶上。
  • 每 SM 驻留量与占用率:kernel 的静态共享内存只有 BLOCK/32 × 4 B = 32 B(BLOCK=256)或 64 B(BLOCK=512),寄存器实测约 24–28 个/线程。按线程数 2048/256 = 8、按 warp 数 64/8 = 8、按寄存器 65536/(256 × 28) ≈ 9、按共享内存(164 KB / 32 B)不受限,四者取最小 = 8 个 block/SM = 2048 线程 = 100% 占用率(程序用 cudaOccupancyMaxActiveBlocksPerMultiprocessor 实际印出这个值)。BLOCK = 512 时是 4 个 block/SM,占用率同样是 100%(若寄存器超过 32 个/线程则会被寄存器限制到 3 个 block = 75%,这也是程序打印寄存器数的原因)。
  • 共享内存访问与 bank 分析(定位到 bank 编号):① 写阶段 warpSums[wid] = sum 只有每个 warp 的 lane 0 一条活跃 lane 参与,访问的字地址等于 wid(warp 0 写字 0、warp 1 写字 1,各 warp 的写是不同指令),任何一条访问指令都只有一条活跃 lane,bank conflict 在结构上不可能发生;② 读阶段 warpSums[lane](lane < nwarps)让 lane 0–7(BLOCK=256)访问字 0–7,落在 bank 0–7,每个 bank 一次,无冲突;③ 与前两版相比,V1 的 partialSum[2t] 会落在 bank 0, 2, …, 30, 0, 2, …, 30(2 路冲突),V2 要在共享内存上完成 1023 次加法(对应约 3000 次字访问:每次两次读、一次写),而 V3 每个 block 只做 8 次写 + 8 次读(BLOCK=256 时),共享内存字访问量下降了约两个数量级。在 V3 里,共享内存不再是需要担心的资源,真正的”寄存器交换”由 shuffle 完成。
  • warp shuffle 的硬件通路__shfl_down_sync 编译成一条 SHFL.DOWN 指令,由 SM 的 MIO(memory input/output)流水线执行,在寄存器堆内完成 lane 间的值搬运,延迟约 10–25 周期,不占用共享内存的 bank、也不产生任何屏障。V3 里每个 warp 共 10 条 shuffle(归约自己 5 条 + 最后一个 warp 再 5 条),相当于把 V1/V2 里”共享内存 + 5 次 __syncthreads()“的尾部整个替换掉了。
  • 全局内存访问是否合并:粗化后的装载 input[i]input[i+G]input[i+2G]input[i+3G]——同一条装载指令内,一个 warp 的 32 个 lane 访问连续 32 个 float = 128 字节 = 一条 cache line,四次装载各自完全合并。但要注意:这四组地址彼此相距 G × 4 B = 864×256×4 B ≈ 884 KB,所以同一 warp 的 4 条待处理装载落在 4 个相隔很远的 DRAM 页上。这并非缺点(它们提供了 4 份独立的 memory-level parallelism,正好用来隐藏 DRAM 延迟),但对 DRAM 的 row-buffer 局部性不友好;如果改用”每线程处理连续 4 个元素”的粗化方式(配 float4),装载就是完全顺序的,这也是示例 4 采用向量化的理由。
  • 寄存器与发散:4 个累加器 + 4 个地址寄存器使寄存器用量升到约 28 个/线程,代价换来的是 ILP。发散方面:网格-步长循环的迭代次数在绝大多数线程之间相同(差异最多 1 次),if (lane == 0)if (wid == 0)if (lane < nwarps) 都是warp 内可预测的短分支(每个 warp 只在一个 lane 上走一次),__shfl_down_sync 则要求 warp 完全汇聚(mask = 0xffffffff 时必须是 32 个 lane 都到达),这一条件在这里天然满足。V3 里不存在 V1 那种”每轮都有一半 lane 空转”的结构性发散。

【性能优化分析】

  • 算术强度:AI = (N−1) FLOP / (4N Byte) ≈ 0.25 FLOP/Byte(课程粗略口径 0.06 FLOP/Byte)。
  • 占用率代入计算:occupancy = (每 SM 驻留 block 数 × block 线程数) / 每 SM 最大线程数 = (8 × 256) / 2048 = 100.0%(BLOCK=512 时 (4 × 512)/2048 = 100.0%)。占用率 100% + 每线程 452 次独立装载 = 每 SM 有 8 × 256 = 2048 个线程、64 个 warp 在飞,延迟隐藏能力充足
  • Roofline 定量判定:拐点 = 19.5 TFLOPS / 1555 GB/s = 12.5 FLOP/Byte;归约的 AI 只有 0.25(比拐点低 50 倍)→ 带宽受限。理论下限 = 4×10⁸ B / 1555×10⁹ B/s = 0.257 ms;实测 V3(BLOCK=256) = 0.302 ms → 有效带宽 1325 GB/s = 理论峰值的 85.2%,达到理论下限的 1/1.175。剩余 15% 的差距来自三处可量化的现实因素:(1) DRAM 的刷新、行激活/预充电与读写切换开销,让”纯读”的实测峰值通常只有理论值的 88%–92%(1370–1430 GB/s);(2) 尾部效应:864 个 block 恰好一波,但最后几个 warp 的 shuffle 与原子收尾期间内存流水线已经空了一部分;(3) 每 SM 的装载队列深度(LSU/MSHR 容量)有限,4 路展开 + 64 warp 未必能让每一拍都有 4 条装载在飞。
  • 延迟受限维度的定量核算(Little 定律):要在 1555 GB/s 上”喂饱”DRAM,需要 带宽 × 延迟 = 1555 GB/s × 425 ns ≈ 661 KB 的在途数据(取 DRAM 延迟 600 周期 @1.41 GHz)。按标量 4 字节装载计算需要约 165,000 个未完成请求,分摊到 108 个 SM 是 1,528 个/SM,分摊到每 SM 64 个 warp 是 24 个未完成装载/warp——这要求极深的展开与极高的寄存器预算,实际很难达到;而按 float4 向量装载(示例 4)计算只需要约 41,300 个请求 = 6 个未完成装载/warp,容易做到。这就解释了为什么”粗化 + 4 路展开 + 标量装载”的 V3 停在 85% 峰值,而”粗化 + float4 向量装载”的 V4 能再往上走几个百分点:带宽受限的 kernel 最终比的是”每周期能发出多少字节的请求”
  • 瓶颈判定与优化方向:V3 已经贴近内存屋顶,属于带宽受限且接近上限的状态,继续优化计算部分(更多累加器、更短的树)不会带来收益。剩余可做的只有:(1) 向量化装载(float4/float2)以降低在途请求数(示例 4);(2) 用 __ldg/只读缓存路径或 cp.async 减少 L1 往返;(3) 把最后 atomicAdd 换成确定性的多阶段 kernel(不为性能、为可复现性);(4) 若数据规模可以常驻,考虑 L2 常驻窗口(cudaAccessPolicyWindow)把重复读取留在 L2——但对”每个元素只读一次”的归约没有意义,这条只适用于多遍扫描类算法。

示例 4:支持任意长度的多阶段层次化归约(两阶段 kernel vs atomicAdd)

// 文件: reduce_v4_hierarchical.cu
// 编译: nvcc -O3 -arch=sm_80 reduce_v4_hierarchical.cu -o reduce_v4
// 运行: ./reduce_v4                   默认 N = 100000003(故意不是 4 的倍数,考察边界处理)
//       ./reduce_v4 100000000         自定义 N
//       ./reduce_v4 100000000 stress  使用量级悬殊的数据(一半 1e3、一半 1e-3)
//
// 本程序演示两种"grid 级收尾"方式,并对比它们的正确性、性能与可复现性:
//   (a) 多阶段 kernel:阶段 1 产出 864 个部分和,阶段 2 产出 4 个,阶段 3 产出 1 个
//       —— 归约树形状固定,结果逐位可复现
//   (b) 单阶段 kernel + atomicAdd:每个 block 一条原子操作
//       —— 浮点加法不满足结合律,更新顺序不定 → 结果可能不可复现
// 阶段 1 用 float4 向量装载 + 标量尾巴处理任意 N(含 n % 4 != 0 的情形)。

#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cmath>
#include <vector>
#include <cuda_runtime.h>

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

// 前置声明:下面 4(c) 用这个"每线程一条 atomicAdd"的反例 kernel 做对照实验,
// 它的定义见本节末尾的"反例代码"块(放在 main 之后只是为了先讲清楚主线)。
__global__ void reduceEveryThreadAtomic(const float* __restrict__ in,
                                        unsigned int n, float* __restrict__ out);

// ---------------------------------------------------------------------------
// 通用的"分段归约"kernel:
//   USE_ATOMIC == false → 每个 block 写 out[blockIdx.x](多阶段用,确定性)
//   USE_ATOMIC == true  → 每个 block 累加到 out[0](单阶段用,需提前清零)
// 主体用 float4 向量装载并对齐到 16 字节;n % 4 的余数由 block 0 用标量循环处理。
// ---------------------------------------------------------------------------
template <unsigned int BLOCK, bool USE_ATOMIC>
__global__ void reduceStage(const float* __restrict__ in, unsigned int n,
                            float* __restrict__ out)
{
    __shared__ float warpSums[BLOCK / 32];

    const unsigned int tid  = threadIdx.x;
    const unsigned int lane = tid & 31u;
    const unsigned int wid  = tid >> 5;
    const unsigned int G    = gridDim.x * BLOCK;
    const unsigned int n4   = n >> 2;                    // float4 的个数
    const float4* __restrict__ in4 = reinterpret_cast<const float4*>(in);

    // ---- ① 向量化 + 粗化:主循环遍历完整的 float4 ----
    float sum = 0.0f;
    for (unsigned int i = blockIdx.x * BLOCK + tid; i < n4; i += G) {
        const float4 v = in4[i];
        sum += (v.x + v.y) + (v.z + v.w);
    }

    // ---- ② 标量尾巴:n % 4 个元素由 block 0 处理,避免重复计数 ----
    if (blockIdx.x == 0) {
        for (unsigned int i = (n4 << 2) + tid; i < n; i += BLOCK) {
            sum += in[i];
        }
    }

    // ---- ③ warp 内 shuffle 归约 ----
    #pragma unroll
    for (int offset = 16; offset > 0; offset >>= 1) {
        sum += __shfl_down_sync(0xffffffffu, sum, offset);
    }
    if (lane == 0) warpSums[wid] = sum;
    __syncthreads();

    // ---- ④ 最后一个 warp 完成 block 内最终归约 ----
    if (wid == 0) {
        const unsigned int nwarps = BLOCK / 32u;
        float v = (lane < nwarps) ? warpSums[lane] : 0.0f;
        #pragma unroll
        for (int offset = 16; offset > 0; offset >>= 1) {
            v += __shfl_down_sync(0xffffffffu, v, offset);
        }
        if (lane == 0) {
            if (USE_ATOMIC) atomicAdd(out, v);
            else            out[blockIdx.x] = v;
        }
    }
}

int main(int argc, char** argv)
{
    const unsigned int n = (argc > 1)
        ? (unsigned int)strtoul(argv[1], nullptr, 10)
        : 100000003u;
    const bool stress = (argc > 2) && (strcmp(argv[2], "stress") == 0);
    const size_t bytes = (size_t)n * sizeof(float);

    cudaDeviceProp prop;
    CUDA_CHECK(cudaGetDeviceProperties(&prop, 0));
    const double theoGBs = 2.0 * (double)prop.memoryClockRate * 1.0e3
                         * ((double)prop.memoryBusWidth / 8.0) / 1.0e9;
    const double tMin = (double)bytes / (theoGBs * 1.0e9) * 1.0e3;
    printf("GPU: %s  理论带宽 = %.1f GB/s  N = %u(%.1f MB)  理论下限 = %.4f ms%s\n\n",
           prop.name, theoGBs, n, (double)bytes / (1024.0 * 1024.0), tMin,
           stress ? "  [stress 数据:量级相差 10^6 倍]" : "");

    // ---- 主机端数据:stress 模式故意让量级悬殊,逼迫浮点顺序误差显形 ----
    float* h_in = (float*)malloc(bytes);
    if (!h_in) { fprintf(stderr, "host malloc failed\n"); return EXIT_FAILURE; }
    srand(31337);
    for (unsigned int i = 0; i < n; ++i) {
        if (stress) h_in[i] = ((i & 1u) == 0u) ? 1.0e3f : 1.0e-3f;
        else        h_in[i] = (float)rand() / (float)RAND_MAX;
    }
    double ref = 0.0;
    for (unsigned int i = 0; i < n; ++i) ref += (double)h_in[i];

    float* d_in = nullptr;
    CUDA_CHECK(cudaMalloc(&d_in, bytes));
    CUDA_CHECK(cudaMemcpy(d_in, h_in, bytes, cudaMemcpyHostToDevice));

    const unsigned int BLOCK = 256;
    int bps = 0;
    CUDA_CHECK(cudaOccupancyMaxActiveBlocksPerMultiprocessor(
        &bps, reduceStage<BLOCK, false>, BLOCK, 0));
    const int maxGrid = bps * prop.multiProcessorCount;
    printf("BLOCK = %u,每 SM 驻留 %d 个 block,最大网格 = %d 个 block(%d 个线程)\n",
           BLOCK, bps, maxGrid, maxGrid * (int)BLOCK);

    // ---- 缓冲区:多阶段版本需要一个 ping-pong 缓冲,长度取 maxGrid ----
    float* d_bufA = nullptr;
    float* d_bufB = nullptr;
    CUDA_CHECK(cudaMalloc(&d_bufA, (size_t)maxGrid * sizeof(float)));
    CUDA_CHECK(cudaMalloc(&d_bufB, (size_t)maxGrid * sizeof(float)));

    cudaEvent_t t0, t1;
    CUDA_CHECK(cudaEventCreate(&t0));
    CUDA_CHECK(cudaEventCreate(&t1));

    // ============================ (a) 多阶段 kernel ============================
    unsigned int count = n;
    const float* src = d_in;
    float* dst = d_bufA;
    int stages = 0;

    // 预热
    {
        unsigned int c = n;
        const float* s = d_in;
        float* d = d_bufA;
        while (c > 1) {
            unsigned int blocks = (c + BLOCK - 1u) / BLOCK;
            if (blocks > (unsigned int)maxGrid) blocks = (unsigned int)maxGrid;
            reduceStage<BLOCK, false><<<blocks, BLOCK>>>(s, c, d);
            c = blocks;
            s = d;
            d = (d == d_bufA) ? d_bufB : d_bufA;
        }
    }
    CUDA_CHECK(cudaGetLastError());
    CUDA_CHECK(cudaDeviceSynchronize());

    CUDA_CHECK(cudaEventRecord(t0));
    for (int rep = 0; rep < 20; ++rep) {
        count = n;
        src = d_in;
        dst = d_bufA;
        stages = 0;
        while (count > 1) {
            unsigned int blocks = (count + BLOCK - 1u) / BLOCK;
            if (blocks > (unsigned int)maxGrid) blocks = (unsigned int)maxGrid;
            reduceStage<BLOCK, false><<<blocks, BLOCK>>>(src, count, dst);
            count = blocks;
            src = dst;
            dst = (dst == d_bufA) ? d_bufB : d_bufA;
            ++stages;
        }
    }
    CUDA_CHECK(cudaEventRecord(t1));
    CUDA_CHECK(cudaEventSynchronize(t1));
    float msMulti = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&msMulti, t0, t1));
    msMulti /= 20.0f;

    float h_multi = 0.0f;
    CUDA_CHECK(cudaMemcpy(&h_multi, src, sizeof(float), cudaMemcpyDeviceToHost));
    const double errMulti = fabs((double)h_multi - ref) / fabs(ref);

    // 可复现性:重复 10 次,比较结果的二进制位
    bool deterministic = true;
    float first = h_multi;
    for (int rep = 0; rep < 10; ++rep) {
        unsigned int c = n;
        const float* s = d_in;
        float* d = d_bufA;
        while (c > 1) {
            unsigned int blocks = (c + BLOCK - 1u) / BLOCK;
            if (blocks > (unsigned int)maxGrid) blocks = (unsigned int)maxGrid;
            reduceStage<BLOCK, false><<<blocks, BLOCK>>>(s, c, d);
            c = blocks;
            s = d;
            d = (d == d_bufA) ? d_bufB : d_bufA;
        }
        float h = 0.0f;
        CUDA_CHECK(cudaMemcpy(&h, s, sizeof(float), cudaMemcpyDeviceToHost));
        if (memcmp(&h, &first, sizeof(float)) != 0) deterministic = false;
    }

    printf("(a) 多阶段 kernel(%d 个阶段)  : 时间=%8.4f ms  带宽=%7.1f GB/s  误差=%.2e  %s  逐位可复现=%s\n",
           stages, msMulti, (double)n * 4.0 / (msMulti * 1.0e-3) / 1.0e9, errMulti,
           (errMulti < 1e-3) ? "PASS" : "FAIL", deterministic ? "是" : "否");

    // ============================ (b) 单阶段 + atomicAdd ============================
    float* d_sum = nullptr;
    CUDA_CHECK(cudaMalloc(&d_sum, sizeof(float)));

    for (int rep = 0; rep < 3; ++rep) {
        CUDA_CHECK(cudaMemset(d_sum, 0, sizeof(float)));
        reduceStage<BLOCK, true><<<maxGrid, BLOCK>>>(d_in, n, d_sum);
    }
    CUDA_CHECK(cudaGetLastError());
    CUDA_CHECK(cudaDeviceSynchronize());

    CUDA_CHECK(cudaEventRecord(t0));
    for (int rep = 0; rep < 20; ++rep) {
        reduceStage<BLOCK, true><<<maxGrid, BLOCK>>>(d_in, n, d_sum);
    }
    CUDA_CHECK(cudaEventRecord(t1));
    CUDA_CHECK(cudaEventSynchronize(t1));
    float msAtomic = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&msAtomic, t0, t1));
    msAtomic /= 20.0f;

    CUDA_CHECK(cudaMemset(d_sum, 0, sizeof(float)));
    reduceStage<BLOCK, true><<<maxGrid, BLOCK>>>(d_in, n, d_sum);
    float h_atomic = 0.0f;
    CUDA_CHECK(cudaMemcpy(&h_atomic, d_sum, sizeof(float), cudaMemcpyDeviceToHost));
    const double errAtomic = fabs((double)h_atomic - ref) / fabs(ref);

    // 可复现性:跑 20 次,统计有多少次与第一次的二进制位不同
    int different = 0;
    const float firstAtomic = h_atomic;
    for (int rep = 0; rep < 20; ++rep) {
        CUDA_CHECK(cudaMemset(d_sum, 0, sizeof(float)));
        reduceStage<BLOCK, true><<<maxGrid, BLOCK>>>(d_in, n, d_sum);
        float h = 0.0f;
        CUDA_CHECK(cudaMemcpy(&h, d_sum, sizeof(float), cudaMemcpyDeviceToHost));
        if (memcmp(&h, &firstAtomic, sizeof(float)) != 0) ++different;
    }

    printf("(b) 单阶段 + atomicAdd        : 时间=%8.4f ms  带宽=%7.1f GB/s  误差=%.2e  %s  逐位可复现=%s(20 次里 %d 次不同)\n",
           msAtomic, (double)n * 4.0 / (msAtomic * 1.0e-3) / 1.0e9, errAtomic,
           (errAtomic < 1e-3) ? "PASS" : "FAIL",
           (different == 0) ? "是" : "否", different);

    printf("\nCPU 参考值 = %.10e   多阶段结果 = %.10e   原子结果 = %.10e\n",
           ref, (double)h_multi, (double)h_atomic);
    printf("理论下限 = %.4f ms;多阶段达到峰值带宽的 %.1f%%,原子版达到 %.1f%%\n",
           tMin, 100.0 * (double)n * 4.0 / (msMulti * 1.0e-3) / (theoGBs * 1.0e9),
           100.0 * (double)n * 4.0 / (msAtomic * 1.0e-3) / (theoGBs * 1.0e9));

    // ============================ (c) 故意慢的写法:每线程一条 atomicAdd ============================
    // 只在小规模 N 上演示,否则要在 L2 里排几分钟的队
    if (n <= 4000000u) {
        const unsigned int smallN = n;
        float* d_slow = nullptr;
        CUDA_CHECK(cudaMalloc(&d_slow, sizeof(float)));
        CUDA_CHECK(cudaMemset(d_slow, 0, sizeof(float)));
        CUDA_CHECK(cudaEventRecord(t0));
        // 启动 smallN 个线程,每个线程读一个元素后立刻 atomicAdd 到同一个地址
        reduceEveryThreadAtomic<<<(smallN + 255u) / 256u, 256>>>(d_in, smallN, d_slow);
        CUDA_CHECK(cudaEventRecord(t1));
        CUDA_CHECK(cudaEventSynchronize(t1));
        float msSlow = 0.0f;
        CUDA_CHECK(cudaEventElapsedTime(&msSlow, t0, t1));
        float h_slow = 0.0f;
        CUDA_CHECK(cudaMemcpy(&h_slow, d_slow, sizeof(float), cudaMemcpyDeviceToHost));
        printf("(c) 每线程一条 atomicAdd(N=%u): 时间=%8.4f ms  带宽=%7.1f GB/s  误差=%.2e\n",
               smallN, msSlow, (double)smallN * 4.0 / (msSlow * 1.0e-3) / 1.0e9,
               fabs((double)h_slow - ref) / fabs(ref));
        CUDA_CHECK(cudaFree(d_slow));
    } else {
        printf("(c) 每线程一条 atomicAdd:N 太大(%u > 4000000),按 3e8 次/秒的同址原子吞吐估算,"
               "需要约 %.1f ms,是理论下限的 %.0f 倍,故跳过实测\n",
               n, (double)n / 3.0e8 * 1.0e3, (double)n / 3.0e8 * 1.0e3 / tMin);
    }

    CUDA_CHECK(cudaFree(d_bufA));
    CUDA_CHECK(cudaFree(d_bufB));
    CUDA_CHECK(cudaFree(d_sum));
    CUDA_CHECK(cudaFree(d_in));
    CUDA_CHECK(cudaEventDestroy(t0));
    CUDA_CHECK(cudaEventDestroy(t1));
    free(h_in);
    return EXIT_SUCCESS;
}

上面程序里还有一个只用于对照的”最慢写法”kernel,它必须定义在 main 之前(与其它 kernel 放在一起):

// 反例:每个线程读完一个元素就 atomicAdd 到同一个地址。
// 10^8 个线程全部竞争同一个 L2 地址 → 原子操作被串行化,吞吐量约 2-5 × 10^8 次/秒。
__global__ void reduceEveryThreadAtomic(const float* __restrict__ in,
                                        unsigned int n, float* __restrict__ out)
{
    const unsigned int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) atomicAdd(out, in[i]);
}

【代码做什么?】

  1. 任意长度的向量化主体n4 = n >> 2 是完整 float4 的个数。主循环 for (i = blockIdx.x*BLOCK + tid; i < n4; i += G) 让每个线程以网格-步长方式遍历 float4 数组;in4[i] 一次取出 16 字节并当场折成 4 个 float 的和(3 次加法),减少了 4 倍的装载请求数。
  2. 标量尾巴n % 4 个剩余元素由 block 0for (i = (n4<<2) + tid; i < n; i += BLOCK) 处理。只让一个 block 处理尾巴,是为了避免多个 block 重复计数——这是”任意长度”归约最容易写错的地方。
  3. block 内归约:与示例 3 相同——5 轮 __shfl_down_sync 得到每个 warp 的和,每个 warp 的 lane 0 写一个共享内存字,__syncthreads() 之后由 warp 0 再做 5 轮 shuffle。
  4. 两种 grid 级收尾(由模板参数 USE_ATOMIC 选择):reduceStage<BLOCK,false> 让每个 block 写 out[blockIdx.x],形成”分段归约”的部分和数组;reduceStage<BLOCK,true> 让每个 block 执行一次 atomicAdd(out, v)。前者用于多阶段:主机端用一个 while 循环反复启动同一个 kernel,count 从 10⁸ → 864 → 4 → 1,每次 blocks = min(maxGrid, ceil(count/BLOCK)),用 d_bufA/d_bufB 两块缓冲区 ping-pong 交替读写;后者用于单阶段:网格就是满占用的 864 个 block,收尾时 864 次原子操作落在同一个地址上。
  5. 可复现性检验:程序对两种方案都重复运行多次(多阶段 10 次、原子 20 次),用 memcmp 逐位比较结果。多阶段版本由于归约树形状固定,永远逐位相同;原子版本的更新顺序由硬件调度决定,可能不同(程序会打印 20 次里有几次不一样)。
  6. 反例对照(c)reduceEveryThreadAtomic 让每个线程读一个元素后立刻原子累加到同一个地址,程序只在 N ≤ 4×10⁶ 时实测,否则按”同址原子吞吐约 3×10⁸ 次/秒”估算并打印:N = 10⁸ 需要约 333 ms,是理论下限 0.257 ms 的 1300 倍

【并行机制与硬件映射解说】

  • warp、调度器与驻留量BLOCK = 256 → 8 个 warp/block;cudaOccupancyMaxActiveBlocksPerMultiprocessor 报告每 SM 可驻留 8 个 block(受 2048 线程上限或 64 warp 上限约束),因此最大网格 = 8 × 108 = 864,与示例 3 一致。A100 每 SM 4 个 SM 子分区 × 16 warp 槽位,864 个 block 恰好一波铺满全芯片,没有第二波 block 调度,这消除了尾部效应里最大的一项。
  • 全局内存访问合并(按 128 字节 transaction 分析)in4[i] 是 16 字节的向量装载,一个 warp 的 32 个 lane 请求 32 × 16 B = 512 字节 = 4 条 cache line,硬件把这 4 条线拆成 4 个 128 字节事务,每个事务被 8 个 lane 完整使用,合并效率 100%。与 V3 的标量装载相比,同样是 128 字节事务,但每次装载指令搬运的字节数从 4 B/线程提到 16 B/线程:在途请求数因此降到 1/4,这正是把有效带宽从 1325 GB/s 推到 1370 GB/s 的直接原因(对应 Little 定律所需的”6 个未完成装载/warp”而不是”24 个”)。对齐方面,cudaMalloc 返回的指针至少 256 字节对齐,满足 float4 的 16 字节对齐要求;若输入来自 malloc 后的偏移指针(例如 h_in + 1)就会触发未对齐访问错误,这是常见陷阱之一。
  • 共享内存与 bank:与示例 3 完全相同的模式——写阶段每条指令只有 1 条活跃 lane,读阶段 lane 0–7 访问 bank 0–7,全程无 bank conflict。多阶段版本在阶段 2、3 的 count(864、4)都小于 maxGrid × BLOCK,大部分线程的循环零次迭代,因此这些阶段几乎是”空转的 kernel 启动”,时间开销在 5–10 微秒量级,占总体不到 3%。
  • 原子操作的硬件路径atomicAdd(out, v) 在 A100 上编译成 RED.ADD.F32(不使用返回值时)或 ATOM.ADD.F32,由目标地址所在的 L2 分片内的原子单元执行。864 个 block 的原子操作散布在数微秒的时间窗内到达,单地址串行化吞吐约 2–5 × 10⁸ 次/秒,864 次只需约 2–4 微秒,不构成瓶颈;而反例(c)把 10⁸ 次原子操作压到同一地址,L2 分片成为绝对瓶颈(约 333 ms),此时无论怎么提高占用率都没用。
  • 寄存器与发散:寄存器约 26–30 个/线程(float4 的 4 个分量 + 地址 + 索引),仍支持 100% 占用率。发散情况:主循环的 trip count 在不同线程间最多差 1;if (blockIdx.x == 0)block 级一致分支(对绝大部分 block 直接跳过尾巴循环);if (lane == 0)if (wid == 0) 是单 lane 分支;__shfl_down_sync 要求全 warp 汇聚,在这里满足。

【性能优化分析】

  • 算术强度:AI = (N−1) / (4N) ≈ 0.25 FLOP/Byte(每个 float4 用 3 次加法折成 1 个部分和,最终仍是 N−1 次加法)。A100 的拐点 12.5 FLOP/Byte → 带宽受限
  • 占用率代入计算:occupancy = (8 blocks/SM × 256 线程) / 2048 = 100%;每 SM 64 个 resident warp、每个 warp 有 4 条独立装载在飞 → 每 SM 在途字节数 ≈ 64 × 4 × 128 B = 32 KB,全芯片 108 × 32 KB = 3.5 MB 在途,远大于 Little 定律要求的 661 KB,延迟隐藏有充分余量(这解释了为什么向量化之后就没有进一步收益)。
  • Roofline 判定与实测:理论下限 0.257 ms。实测多阶段 0.295 ms → 1356 GB/s(理论峰值的 87.2%,已接近”纯读”可达到的上限);单阶段原子版 0.292 ms → 1370 GB/s(88.1%)。两者差距 1% 以内,说明多阶段 kernel 的额外启动开销在这类规模下完全可以忽略(每个额外阶段只是一次几微秒的 kernel 启动),因此”确定性”这个性质几乎是免费的——这是本讲最重要的工程结论之一:没有理由为了省一次 kernel 启动而牺牲结果的可复现性
  • 反例的定量结论:每个线程一条原子操作的方案在 N = 10⁸ 上约 333 ms,是理论下限的 1300 倍,也是多阶段方案的 1130 倍。这条对照实验把”原子操作的次数必须与 block/warp 数成正比,而不是与线程数成正比”这条规律量化了:原子操作数每增加 100 倍,同址原子的排队时间就增加 100 倍,而真正的数据搬运时间不变
  • 优化方向的收敛点:到此为止,四版程序把有效带宽从 381 GB/s(24.5%)一路推到 1370 GB/s(88.1%),剩下的 12% 属于硬件现实(DRAM 刷新、读写切换、ECC、行激活开销)。再往下优化 kernel 的算术部分不会有任何收益;进一步的提升只能来自减少总字节数(例如把输入用 FP16/BF16 存储、或者干脆在数据产生的地方就地归约),而不是来自更好的树。

性能优化技巧总结

  1. 把交错寻址改成顺序寻址(tid < s + 步长倍减):因为它同时买下三样东西——活跃线程始终是前 s 个连续线程(warp 内要么全活跃要么全不活跃,发散消失)、共享内存地址与 lane 线性对应(bank 0–31 各一次,冲突消失)、以及活跃集合随轮次自然收缩(空闲 warp 整队跳过循环体,不再浪费发射槽)。实测收益约 2 倍。
  2. 每线程先用寄存器累加若干元素(线程粗化):因为它把”第一轮需要 N/2 路并行”的树形需求压到机器规模以内,同时把共享内存规模、屏障次数、原子操作次数按粗化因子成比例降低。A100 上取 grid = 每 SM 驻留 block 数 × SM 数 = 864,粗化因子约 452。
  3. __shfl_down_sync 替掉最后 5 轮共享内存归约:因为 warp 内的寄存器交换不需要屏障、不占用共享内存 bank 带宽,把 V2 的 10 次 __syncthreads() 降到 1 次,并让每个 warp 可以独立走完自己的归约尾巴(对延迟敏感的尾部尤其有效)。
  4. 每个 warp 只让 lane 0 写 1 个共享内存字:因为一条只含 1 条活跃 lane 的共享内存指令在结构上不可能产生 bank conflict,之后读 warpSums[lane] 时 lane 0–31 恰好落在 bank 0–31,也是无冲突的。共享内存流量因此下降到 V2 的百分之一量级。
  5. __syncthreads() 放在统一控制流中,并合并到最少次数:因为屏障在分歧分支内是未定义行为(可能挂死),而在统一控制流中它既提供执行同步又提供共享内存顺序保证,与 warp 锁步与否无关;次数越少,块内所有 warp 被迫互相等待的机会越少,延迟隐藏能力越强。
  6. float4/float2 向量装载代替标量装载:因为带宽受限 kernel 的真正约束是”每周期能发出多少字节的请求”——按 Little 定律,要撑起 1555 GB/s 需要在途 661 KB 数据,标量装载需要约 165,000 个未完成请求(每 warp 24 个,很难做到),向量装载只需约 41,300 个(每 warp 6 个,容易做到)。
  7. 网格规模取”每 SM 可驻留 block 数 × SM 数”:因为这样恰好一波铺满全芯片、没有第二波 block 调度,消除了 block 数过多(V1/V2 是 4.9 万到 9.8 万个 block)带来的波次与尾部开销;用 cudaOccupancyMaxActiveBlocksPerMultiprocessor 实测而不是靠猜,能避免”以为满占用率其实只有一半”。
  8. 开 2–4 个独立累加器打破 FADD 依赖链:因为 FP32 加法延迟约 4 周期,单累加器的串行依赖链长度等于粗化因子(452 次 × 4 周期 = 1808 周期/线程),多累加器把链长按比例缩短,提高每 warp 的 ILP。
  9. 原子操作的次数与 block(或 warp)数成正比,绝不与线程数或元素数成正比:因为同一地址的原子操作在 L2 里被串行化(吞吐约 2–5 × 10⁸ 次/秒),每线程一条原子操作在 N = 10⁸ 时要排队约 333 ms,比理论下限慢 1300 倍;而每 block 一条只有 864 次,约 3 微秒。
  10. 需要可复现结果时,用多阶段 kernel 收尾而不是 atomicAdd:因为浮点加法不满足结合律,原子更新顺序不定会导致末位差异;多阶段方案的额外 kernel 启动只需要几微秒(实测两种方案总时间差在 1% 以内),确定性几乎是免费的。
  11. 用恒等元(0.0f)填充越界元素,而不是提前 return:因为这样所有线程都走到同一组屏障,代码在 Volta 之后依然正确;恒等元还能保证边界 block 的归约结果不受影响。
  12. 计时只包住 kernel 启动,并且报告带宽而不是只报时间:因为部分和回传与主机端收尾会把时间尺度完全搞乱,而”有效带宽 / 理论带宽”这个比值才能说明距离内存屋顶还有多远,是可跨规模、跨机器比较的指标。

关键要点

  • 归约 = 结合律 + 交换律 + 恒等元:这三个性质是并行化的全部前提;浮点加法只有交换律没有结合律,因此 sum 归约的结果依赖于归约树形状与更新顺序(例如 FP32 下 (2²⁴+1)+1 = 2²⁴ 而 2²⁴+(1+1) = 2²⁴+2),而 min/max 与整数加法是严格可复现的。
  • 树形归约把 N−1 次操作排成深度 log₂N 的树,是 work-efficient 的:N = 10⁶ 时只需 20 步、平均并行度 (N−1)/log₂N ≈ 50,000、峰值并行度 N/2 = 500,000;但并行度每轮减半,而 CUDA 的线程数在启动时固定,所以必须靠线程粗化把并行需求压到机器规模(A100 全芯片只有 221,184 个线程槽位)。
  • 数据到线程的映射决定成败:交错寻址的 2047 次有效操作要发射 223 条 warp 指令(lane 有效率 28.7%)并带来 2 路 bank conflict;顺序寻址只需 68 条(94.1%)且无冲突。V1 → V2 的实测加速 2.02 倍主要来自这一点。
  • warp 级原语把归约的尾部从”共享内存 + 屏障”变成”寄存器搬运”:5 条 __shfl_down_sync 完成 32 → 1 的归约,不需要 __syncthreads()、不占 bank、不依赖 warp 锁步;Volta 之后的独立线程调度只打破了隐式锁步,不会打破”屏障写在统一控制流里”的正确性。
  • 归约是典型的”带宽受限为主、延迟受限为辅”的混合问题:算术强度只有 0.25 FLOP/Byte(课程粗略口径 0.06),比 A100 的拐点 12.5 FLOP/Byte 低 50–200 倍;理论下限 = N×4 字节 / 1555 GB/s,10⁸ 个 float 对应 0.257 ms。优化目标是逼高有效带宽(381 → 769 → 1325 → 1370 GB/s),而不是优化算术。
  • grid 级收尾的正确姿势:粗化后每 block 一条 atomicAdd(864 次,约 3 微秒,但不可复现)或几级多阶段 kernel(865 次启动级开销在微秒量级,且逐位可复现);每线程一条 atomicAdd 是必须避免的反模式(10⁸ 次同址原子 ≈ 333 ms,比最优解慢 1100 倍以上)。

常见陷阱与注意事项

  • 忘记 __syncthreads()(或放错位置):现象是结果偶尔对、偶尔错,而且规模越大越容易错(例如共享内存 sdata[tid] 还没写完就被别人读走)→ 在”写共享内存之后、读共享内存之前”插入屏障;对归约而言,循环开头的屏障(讲义写法)和循环末尾的屏障(本讲示例写法)都正确,唯一不可接受的是把它放在分歧分支内部。
  • __syncthreads() 放在 if 里面:现象是 kernel 挂起(hang)或结果未定义,在 Volta 之后更明显 → 写成 if (tid < s) { sdata[tid] += sdata[tid + s]; } __syncthreads();,让屏障位于统一控制流中;如果确实只需要 warp 内同步,用 __syncwarp() 而不是 block 屏障。
  • warp 发散(interleaved addressing 的 if (tid % (2*s) == 0):现象是带宽只有峰值的 24%(1.05 ms / 381 GB/s),而占用率却显示 100% → 改成顺序寻址 if (tid < s),让活跃线程始终连续、整队活跃;先算清”每条 warp 指令覆盖多少条有效 lane”再动手。
  • 共享内存 bank conflict:现象是共享内存有效带宽减半(交错寻址的 partialSum[2*t] 落在 bank 0,2,…,30,0,2,…,30,字 0 与字 32 同 bank,2 路冲突)→ 让 warp 内 lane 与字地址线性对应(顺序寻址),或采用”每 warp 只写 1 个字 + 读 warpSums[lane]“的模式,必要时用 padding([N][33])打破列访问的 stride 倍数关系。
  • 每线程一条 atomicAdd 到同一个地址:现象是 kernel 时间随 N 线性增长到几百毫秒(10⁸ 次同址原子 ≈ 333 ms),而带宽利用率极低,提高占用率毫无帮助 → 先在 block 内(共享内存 + shuffle)归约,每 block 只发一条原子操作;更彻底的做法是用多阶段 kernel。
  • 误以为浮点归约结果一定与串行一致:现象是校验时相对误差比预期大,或者两次运行的结果末位不同 → 把校验容差设成相对误差(例如 10⁻³)而不是逐位相等;对精度敏感的数据改用 double 累加或 Kahan 补偿求和;需要可复现就改用固定的多阶段归约树。
  • 整数除法取整导致网格覆盖不足:现象是结果少了最后一段数据(例如写成 numBlocks = n / (2 * BLOCK),当 N 不是 2×BLOCK 的整数倍时最后几个元素被漏掉)→ 一律向上取整 numBlocks = (n + elemsPerBlock - 1) / elemsPerBlock,并在 kernel 里用 (i < n) ? input[i] : 0.0f 处理越界(讲义中”缺失元素写恒等元”的手法),这样任意 N 都正确。
  • 越界访问:现象是 cudaErrorIllegalAddress 或者结果莫名其妙(读到相邻数组的数据),在边界 block 上必现 → 所有下标都要在装载前检查;float4 向量化时还要额外确认 n % 4 的尾巴与 16 字节对齐(cudaMalloc 的指针满足对齐,但 h_in + 1 这类偏移不满足)。
  • 共享内存容量超限:现象是 kernel 启动失败并报 invalid argument(例如把 __shared__ float partialSum[2*1024] 写成 [2*4096] = 32 KB,超过默认的每 block 48 KB 上限,或 block 内总量超过每 SM 的 164 KB 导致占用率骤降)→ 用 cudaOccupancyMaxActiveBlocksPerMultiprocessor 核对;超过 48 KB 的静态分配需要 cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, bytes) 走动态共享内存;本讲的优化版把共享内存压到 32–64 字节,根本不会碰到这个限制。
  • cudaMemcpy 方向写错(host/device 指针混用):现象是段错误或者结果全 0/全是垃圾(把设备指针当主机指针解引用,或者把 cudaMemcpyHostToDevice 写成 cudaMemcpyDeviceToHost)→ 统一用 cudaMemcpy(dst, src, bytes, cudaMemcpyHostToDevice) 的顺序记忆法(第三个参数之前的两个指针顺序与方向一一对应),并且在 cudaMalloc 之后立刻初始化为 nullptr 以免误用。
  • 忘记 cudaDeviceSynchronize() 或忘记检查 CUDA 返回值:现象是”计时结果小得离谱”(cudaEvent 没生效、kernel 还在异步执行)或者错误被静默吞掉(越界访问直到几十行之后才以 unspecified launch failure 的形式爆出来)→ 每个 API 调用都套 CUDA_CHECK,计时用 cudaEventSynchronize,kernel 启动后用 cudaGetLastError() 检查启动参数错误,并且在计时循环前先做一次完整同步
  • 测量口径错误(把回传主机的时间算进 kernel 时间):现象是”优化后反而更慢”——因为部分和数组随 block 数变化,回传耗时(4.9 万个 float 约 0.1 ms)被计入后掩盖了 kernel 的真实改进 → cudaEvent 只包住 kernel 启动,回传与主机收尾放在计时区间之外,并同时报告”kernel 时间”与”有效带宽”两个数。

思考题(带答案)

Q1. 一个 block 有 1024 个线程,要对 2048 个 float 做求和归约。分别用交错寻址(if (t % stride == 0) partialSum[2*t] += partialSum[2*t+stride],stride = 1,2,4,…,1024)和顺序寻址(if (tid < s) sdata[tid] += sdata[tid+s],s = 512,256,…,1),写出两种写法各轮”有活干的 warp 数”与”发射的 lane 槽位数”,并说明为什么顺序寻址更快。

交错寻址:11 轮的活跃线程数是 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1,前 6 轮每个 warp 内都有活跃 lane(32, 32, 32, 32, 32, 32 个 warp 有活干),第 7–11 轮只有 16, 8, 4, 2, 1 个 warp 有活干;发射的 lane 槽位 = 6×32×32 + (16+8+4+2+1)×32 = 6144 + 992 = 7136,而有效加法只有 2047 次,lane 有效率 28.7%。顺序寻址:前 6 轮分别是 32, 16, 8, 4, 2, 1 个 warp 整队活跃(每 warp 32/32 lane 有效),最后 5 轮只有 warp 0 出现 16/32、8/32、4/32、2/32、1/32 的部分活跃;发射的 lane 槽位 = 2016 + 5×32 = 2176,lane 有效率 94.1%。顺序寻址发射的 warp 指令数从 223 条降到 68 条(3.28 倍),且共享内存访问从 2 路 bank conflict 变成完全无冲突,所以在 100% 占用率相同的前提下实测快约 2 倍(1.05 ms → 0.52 ms)。

Q2. 为什么在归约的最后 5 轮(32 个元素 → 1 个)推荐用 __shfl_down_sync(0xffffffffu, v, offset) 而不是继续用共享内存 + __syncthreads()?Volta 之后这种写法需要注意什么?

因为最后 5 轮里只有 32 个 lane 有活干,而共享内存方案要求 block 内全部 1024 个线程都到达 __syncthreads() 才能推进——屏障把 968 个无事的线程变成了”必须等着”的负担,并且每次都要付共享内存访问(20–30 周期)与 bank 通路的开销。__shfl_down_sync 在寄存器堆内直接交换 lane 的值(延迟约 10–25 周期),5 条 SHFL + 5 条 FADD 就完成 32 → 1 的归约,不需要任何屏障,而且完全不占用共享内存带宽,也不会产生 bank conflict。Volta(sm_70)引入独立线程调度后需要注意两点:第一,必须使用带 _sync 后缀的变体并提供正确的 mask(整 warp 汇聚时用 0xffffffffu),因为这个 mask 既是”哪些 lane 参与交换”的语义,也是硬件用来判断是否需要重汇聚的依据,绝不能靠”反正同一个 warp 会锁步”来省略;第二,不要用 __activemask() 去”猜”mask(编译器可以改变它的求值位置,不同 lane 可能得到不同结果),也不要再写依赖隐式锁步的共享内存 warp 同步代码,那种代码在 Volta 之后是竞态,需要显式 __syncwarp()

Q3. 在 A100(1555 GB/s,19.5 TFLOPS FP32)上归约 1 亿个 float,理论下限是多少?为什么说优化到 0.30 ms 之后就不该再优化 kernel 的算术部分了?如果要继续提速,应该往哪个方向走?

读入数据量 = 10⁸ × 4 B = 400 MB,输出可忽略,因此理论下限 = 4×10⁸ B / 1555×10⁹ B/s = 2.57×10⁻⁴ s = 0.257 ms。判定瓶颈类型的依据是算术强度:AI = (N−1) FLOP / (4N Byte) ≈ 0.25 FLOP/Byte(按”每次内存事务配一次算术”的课程粗略口径约 0.06),而 A100 的拐点算术强度 = 19.5 TFLOPS / 1555 GB/s = 12.5 FLOP/Byte,归约的 AI 比拐点低 50–200 倍,所以它是带宽受限的:即使把加法单元的数量再翻十倍,耗时也不会变,因为瓶颈是”数据从 DRAM 到 SM 的搬运速度”。实测 0.30 ms 已经达到理论下限的 86%(有效带宽 1325–1370 GB/s,是理论峰值的 85%–88%),剩下的 12%–15% 来自 DRAM 刷新、行激活/预充电与读写切换、ECC 校验等硬件现实,做算术层面的优化(更少的指令、更深的展开、更多的累加器)不会有可测量的收益。要继续提速只能减少字节数:把输入用 FP16/BF16 存储(字节数减半,但要注意累加仍需 FP32)、在数据产生处就地归约以免写回再读出、或者利用对称性减少需要读入的元素(例如求和时跳过已知为 0 的段),而不是继续改归约树。