Lecture 6: 并行模式四 —— 归约:树形归约与 warp 级原语 (对应 Lab 4 / Lab 5: List Reduction)
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;
}
【代码做什么?】
- 数据划分:变体 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)。 - 协作装载:变体 1A 的两次赋值分别把
input[start + t]与input[start + 1024 + t]搬进partialSum[t]与partialSum[1024 + t],于是共享内存的前 1024 个字和后 1024 个字各自连续。变体 1B 只做一次装载。越界元素写入恒等元0.0f,因此 N 不是 block 容量的整数倍时结果依然正确(这是”缺失元素写恒等元”这一通用边界处理手法在归约上的形态)。 - 树形归约:变体 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 一半活跃一半空闲,而且活跃集合是”稀疏且分散”的。 - 同步:
__syncthreads()全部写在if之外,位于统一控制流中;变体 1A 把它放在循环开头(既覆盖初始装载,也覆盖每一轮的读写顺序),变体 1B 放在循环末尾。 - 写回:只有
tid == 0的线程把partialSum[0](或sdata[0])写到output[blockIdx.x],得到一个长度为”block 数”的部分和数组。 - 主机端调度:
cudaMalloc分配输入与部分和缓冲区,cudaMemcpy传输,cudaEvent只包裹 kernel 启动以测量纯 kernel 时间(3 次预热 + 20 次取平均),随后把部分和拷回主机用double求和作为最终结果,并与 CPU 参考值比较相对误差(阈值 10⁻³)。
【并行机制与硬件映射解说】
- 线程到 warp 的映射:
BLOCK_SIZE = 1024,一个 block 被硬件切成 32 个 warp(warp 0 是threadIdx.x0–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;
}
【代码做什么?】
- 数据划分与粗化第一步:
reduceSequential里每个 block 仍然负责 2048 个连续元素(base = blockIdx.x * blockDim.x * 2),但映射方式变了——线程tid负责base + tid与base + 1024 + tid两个元素,先在寄存器里相加得到v0 + v1,再把一个值写进sdata[tid]。这半步粗化把共享内存的规模从 2048 个字减半到 1024 个字,也把共享内存树的轮数从 11 轮减到 10 轮。 - 共享内存写入的地址映射:
sdata[tid] = v0 + v1让 lane 0–31 写入字地址tid→ 落在 bank 0–31 各一次,完全无冲突;而示例 1 的partialSum[2t]会落在 bank 0, 2, 4, …, 30, 0, 2, …, 30(2 路冲突)。 - 顺序寻址的树:循环从
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],把部分和”压缩”到数组开头。 - 屏障位置:
__syncthreads()写在if (tid < s)之后、循环体的末尾,位于统一控制流中。循环开头的__syncthreads()保证初始的sdata[tid] = v0 + v1全部完成之后才有人去读别人的格子。 - 总工作量核对:初始的 1024 次寄存器加法 + 共享内存树里的 512 + 256 + … + 1 = 1023 次加法 = 2047 次 = N−1(N = 2048),工作量与串行算法完全相同,是 work-efficient 的。
- 主机端:与示例 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 个寄存器/线程(多出
v0、v1与i0、i1的计算),仍远低于 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;
}
【代码做什么?】
- 网格与粗化的规模选择:先用
cudaOccupancyMaxActiveBlocksPerMultiprocessor问出”每个 SM 最多能同时驻留几个BLOCK=256的 block”(A100 上是 8 个),再乘 SM 数得到网格规模grid = 8 × 108 = 864。于是总线程数 = 864 × 256 = 221,184,恰好等于 A100 全芯片的线程容量,每个线程用网格-步长循环处理 10⁸ / 221,184 ≈ 452 个元素——这是”让每个 SM 都被塞满、并且只启动一波 block”的标准做法。 - 四级归约结构:① 每线程在寄存器里累加 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)。 - 越界处理:主循环的边界条件
i + 3*G < n保证四条装载都不越界,随后的收尾循环for (; i < n; i += G)覆盖剩余元素;由于 {base + kG} 在 k ≥ 0 上恰好划分整个数组,每个元素都被且仅被访问一次,任意 N 都正确。越界的线程由网格-步长循环自然”零次迭代”处理,不会读到非法地址。 - 屏障数量:整个 kernel 只有 1 次
__syncthreads()(V1 是 11 次、V2 是 10 次),并且这次屏障在粗化之后只需要协调每 block 8 个(或 16 个)warp 的 8 个(或 16 个)部分和。 - 主机端:
benchAtomic用cudaEvent包住 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]);
}
【代码做什么?】
- 任意长度的向量化主体:
n4 = n >> 2是完整float4的个数。主循环for (i = blockIdx.x*BLOCK + tid; i < n4; i += G)让每个线程以网格-步长方式遍历float4数组;in4[i]一次取出 16 字节并当场折成 4 个 float 的和(3 次加法),减少了 4 倍的装载请求数。 - 标量尾巴:
n % 4个剩余元素由 block 0 的for (i = (n4<<2) + tid; i < n; i += BLOCK)处理。只让一个 block 处理尾巴,是为了避免多个 block 重复计数——这是”任意长度”归约最容易写错的地方。 - block 内归约:与示例 3 相同——5 轮
__shfl_down_sync得到每个 warp 的和,每个 warp 的 lane 0 写一个共享内存字,__syncthreads()之后由 warp 0 再做 5 轮 shuffle。 - 两种 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 次原子操作落在同一个地址上。 - 可复现性检验:程序对两种方案都重复运行多次(多阶段 10 次、原子 20 次),用
memcmp逐位比较结果。多阶段版本由于归约树形状固定,永远逐位相同;原子版本的更新顺序由硬件调度决定,可能不同(程序会打印 20 次里有几次不一样)。 - 反例对照(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 存储、或者干脆在数据产生的地方就地归约),而不是来自更好的树。
性能优化技巧总结
- 把交错寻址改成顺序寻址(
tid < s+ 步长倍减):因为它同时买下三样东西——活跃线程始终是前 s 个连续线程(warp 内要么全活跃要么全不活跃,发散消失)、共享内存地址与 lane 线性对应(bank 0–31 各一次,冲突消失)、以及活跃集合随轮次自然收缩(空闲 warp 整队跳过循环体,不再浪费发射槽)。实测收益约 2 倍。 - 每线程先用寄存器累加若干元素(线程粗化):因为它把”第一轮需要 N/2 路并行”的树形需求压到机器规模以内,同时把共享内存规模、屏障次数、原子操作次数按粗化因子成比例降低。A100 上取 grid = 每 SM 驻留 block 数 × SM 数 = 864,粗化因子约 452。
- 用
__shfl_down_sync替掉最后 5 轮共享内存归约:因为 warp 内的寄存器交换不需要屏障、不占用共享内存 bank 带宽,把 V2 的 10 次__syncthreads()降到 1 次,并让每个 warp 可以独立走完自己的归约尾巴(对延迟敏感的尾部尤其有效)。 - 每个 warp 只让 lane 0 写 1 个共享内存字:因为一条只含 1 条活跃 lane 的共享内存指令在结构上不可能产生 bank conflict,之后读
warpSums[lane]时 lane 0–31 恰好落在 bank 0–31,也是无冲突的。共享内存流量因此下降到 V2 的百分之一量级。 - 把
__syncthreads()放在统一控制流中,并合并到最少次数:因为屏障在分歧分支内是未定义行为(可能挂死),而在统一控制流中它既提供执行同步又提供共享内存顺序保证,与 warp 锁步与否无关;次数越少,块内所有 warp 被迫互相等待的机会越少,延迟隐藏能力越强。 - 用
float4/float2向量装载代替标量装载:因为带宽受限 kernel 的真正约束是”每周期能发出多少字节的请求”——按 Little 定律,要撑起 1555 GB/s 需要在途 661 KB 数据,标量装载需要约 165,000 个未完成请求(每 warp 24 个,很难做到),向量装载只需约 41,300 个(每 warp 6 个,容易做到)。 - 网格规模取”每 SM 可驻留 block 数 × SM 数”:因为这样恰好一波铺满全芯片、没有第二波 block 调度,消除了 block 数过多(V1/V2 是 4.9 万到 9.8 万个 block)带来的波次与尾部开销;用
cudaOccupancyMaxActiveBlocksPerMultiprocessor实测而不是靠猜,能避免”以为满占用率其实只有一半”。 - 开 2–4 个独立累加器打破 FADD 依赖链:因为 FP32 加法延迟约 4 周期,单累加器的串行依赖链长度等于粗化因子(452 次 × 4 周期 = 1808 周期/线程),多累加器把链长按比例缩短,提高每 warp 的 ILP。
- 原子操作的次数与 block(或 warp)数成正比,绝不与线程数或元素数成正比:因为同一地址的原子操作在 L2 里被串行化(吞吐约 2–5 × 10⁸ 次/秒),每线程一条原子操作在 N = 10⁸ 时要排队约 333 ms,比理论下限慢 1300 倍;而每 block 一条只有 864 次,约 3 微秒。
- 需要可复现结果时,用多阶段 kernel 收尾而不是
atomicAdd:因为浮点加法不满足结合律,原子更新顺序不定会导致末位差异;多阶段方案的额外 kernel 启动只需要几微秒(实测两种方案总时间差在 1% 以内),确定性几乎是免费的。 - 用恒等元(0.0f)填充越界元素,而不是提前
return:因为这样所有线程都走到同一组屏障,代码在 Volta 之后依然正确;恒等元还能保证边界 block 的归约结果不受影响。 - 计时只包住 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 的段),而不是继续改归约树。
