CUDA学习之路[9]:CUB 归约的分层设计
0. 回顾:我们手写了哪些优化?
在上一章中,我们从 v0 到 v7 逐步引入了这些技术:
| 版本 | 引入的技术 | 解决了什么问题 |
|---|---|---|
| v0 | 基础树形归约 | 让归约跑起来 |
| v1 | 用乘加代替取模 | 消除昂贵的整数除法 |
| v2 | 翻转循环方向 | 消除 Bank Conflict |
| v3 | 每线程读 2 个元素 | 减少线程数,提高计算密度 |
| v4 | 手动展开最后一个 warp | 消除最后 5 轮的同步开销 |
| v5 | 模板常量展开 | 消除运行时循环,零开销抽象 |
| v6 | Grid Loop | 让线程反复干活,减少 atomicAdd 竞争 |
| v7 | float4 向量化 | 一条指令读 4 个 float,压榨带宽 |
问题来了:如果让你把这 7 种技术整合成一个”库”,给全世界的开发者使用,你会怎么组织代码?
1. 使用AI学习的杂谈
很多同学都说要看源码,看库,那我们究竟要看什么呢?
CUB是英伟达官方维护的GPU并行算法最基础的库。 它提供了经过极致优化的、可在 device 端直接调用的集体操作原语,例如归约、扫描、排序等。
CCCL是英伟达将三个C++库合并后的统一名称:
CCCL (CUDA C++ Core Libraries)├── libcu++ — CUDA C++ 标准库(类似 host 端的 libstdc++)├── CUB — 并行算法基础库 ← 本章学这个└── Thrust — 高层并行算法库(类似 C++ STL 的风格)简单来说:CCCL 是总称,CUB 是其中的并行算法部分。 实际上,例如PyTorch还有其他上层库,多少都会依赖CCCL,因此学习这个库在我看来还是比较权威的。
1.1. 为什么要看CUB?
很多同学会说,我自己写的v7版本也挺牛逼的呀,那为什么还要学习英伟达的库呢?
是的,你写的代码确实很强,但是再强的代码,终归是要给人用的,是要团队协作的。
因此软件工程,或者说架构设计这一套内容本身,要比写某个算子更加重要。
而英伟达,尤其是CUDA的那一帮子人,在我看来是一群极致的软件工程美学大师。
正是因为他们设计了门槛较低的一系列算子库,才能够让整个生态枝繁叶茂。反观OpenCL、OpenGL,同样是并行编程框架,怎么就没有CUDA这么广泛的生态呢?
我们手写代码是知其然,而学库的写法是知其所以然。双管齐下,能力提升自然更快。
1.2. 读大规模库的正确姿势
我使用cloc统计了CCCL库的代码量,大家可以看一下。
(mycuda) cccl [main] cloc . 9042 text files. 8678 unique files. 395 files ignored.
github.com/AlDanial/cloc v 1.98 T=1.89 s (4584.1 files/s, 731350.7 lines/s)--------------------------------------------------------------------------------Language files blank comment code--------------------------------------------------------------------------------C/C++ Header 2090 44265 80957 297413CUDA 1784 60954 89331 278742C++ 3669 52108 58422 266717Python 281 9148 7617 30786reStructuredText 403 12076 29327 16784CMake 158 1906 2585 13700Bourne Shell 97 1312 778 6798YAML 52 657 921 6210JSON 67 0 0 5579Markdown 34 983 4 2349Cython 1 315 123 2211PowerShell 23 278 161 1177JavaScript 2 60 14 415XML 1 0 46 407Bourne Again Shell 7 74 54 283TOML 3 39 27 204HTML 3 18 0 98CSS 1 4 0 37Text 2 0 0 13--------------------------------------------------------------------------------SUM: 8678 184197 270367 929923--------------------------------------------------------------------------------(mycuda) cccl [main]九十多万行代码,我们要从什么地方看起呢?有同学说,我用AI一把梭全喂进去——但是几十万行 token 灌进去,AI 真的能帮你抓住核心吗?
面对大规模库,我们的核心目标并不是”读完”,而是提取设计思想。
在我这里,AI主要扮演几种角色:
1. 搜索引擎。2. 核心代码力扣化。3. 定向问答工具。4. 外围代码生成器。下面分享一些我阅读大规模库的小技巧(抛砖引玉)。
1.2.1. 搜索引擎
使用生成式AI学习最大的问题是没有学习边界。
我们在学习某些课,或者知识本身被产生的时候,一定是有边界的。
例如软件工程和并行计算,一定是两门课。但是在AI这里,它们的边界是模糊的,随着你不断的提问,知识的边界会被模糊掉,它会给你越扯越远。
所以我现在的策略是:把AI当成大号搜索引擎,但知识本身的学习,还是交给最原始的课程、论文、代码。AI咀嚼过的东西,养分已经流失了。
对于源码项目,我一般会使用Claude code来辅助阅读。
在读的时候,我会让AI首先帮我生成整个项目的目录。比如我今天关注的是reduce,那我就只盯着reduce模块分析,其余部分暂时当成黑盒。
本质上把AI当做搜索工具,而不是学习工具。学习和阅读还是由我们自己来。
1.2.2. 不要沉没在细节中
在阅读库和我们手写代码最大的区别在于,库中会加入极其多的边界条件。 因为它们在实现的过程会考虑各种极端情况,往往是几个部门的人反复推敲才设计出来的。 它们的实现路线常常是这样的:
a
阿巴
阿巴阿巴
叽里咕噜歪比巴卜本来核心代码可能就几行,但是最终呈现出来的却极其复杂。 这种时候,AI就可以帮我们剥掉这些”洋葱皮”。
一个很实用的技巧是:让AI把某段代码翻译成力扣风格
例如LeetCode那种具有明确输入输出的模式。甚至你可以直接让AI把某一段代码生成力扣风格的内容,这样能够极其清晰地定位出这个函数的含义,降低阅读门槛。
1.2.3. 记录/实现/对照
在我看来,人类和AI学习最大的不同是,人类会提出各种千奇百怪的疑问,并且这些疑问往往是带有直觉性质的。
我们可能也不知道为什么要问这个问题,但是直觉告诉我们,就是它!
而这些千奇百怪的问题反而是我们学习最最最关键和最核心的。
那我们怎么对AI提问呢?
❌ "帮我看懂 dispatch_reduce.cuh"✅ "invoke_passes() 中为什么要分配 max_blocks * sizeof(AccumT) 的临时内存?"每次只问一个确定的、你不懂的问题,然后再问下一个,而不是寄希望于它直接得出结果。
并且学习大规模库,最好的方式就是把它的核心功能摘出来,自己再实现一遍,外围代码可以让AI辅助实现,自己一行一行试着手敲,这样子学习的进展会非常快。
1.2.4. 读工业库的5个原则
注释本身就是高质量的教程。
第一次接触某个库时,只关注两样东西:公开的方法签名(接口),对应的数据结构。
对于大规模库,调用关系通常都非常深,非常复杂;可能充满各种回调、嵌套、递归等等。此时我们只需要停留在某一个点,理解输入输出、理解接口语义,关注调用方与被调用方。
模板编程的核心其实就是不断地抽象或特例。思考与实现是反着来的:大家先有了大量重复实例,发现不能一直写无聊的重复代码,于是选择抽象;而把所有代码抽象后,又发现有一些特殊情况需要特别对待,于是特例化。那我们读代码的时候反过来想就顺畅了。
工业库 80% 的代码量是在做兼容和适配,和你要学的核心算法无关。初学阶段,看到以下内容直接跳过:宏、条件编译、日志、版本兼容API。20%的代码就可以掌握99%的设计思想。剩下80%是给公司和领导看的,不是给你看的。
1.2.5. AI不适合做什么?
它不适合帮你动脑子,但凡你觉得要动脑子了,一定要自己来,否则一定会出错。
2. Reduce版本的OSI
我们先看一下CUB库中的包含reduce的所有的路径。
| 层 | 职责 | 通信介质 | 关键文件 |
|---|---|---|---|
| Device | 选策略,推导类型 | host | cub/device/device_reduce.cuh |
| Dispatch | 决定单 pass 还是双 pass | host | cub/device/dispatch/dispatch_reduce.cuh |
| Kernel | 启动 GPU 线程,实例化 Agent | — | cub/device/dispatch/kernels/kernel_reduce.cuh |
| Agent | 加载数据,协调 BlockReduce | Global Memory | cub/agent/agent_reduce.cuh |
| Block | Block 内跨 warp 归约 | Shared Memory | cub/block/block_reduce.cuh |
| Warp | Warp 内跨线程归约 | SHFL / Shared Memory | cub/warp/warp_reduce.cuh |
| Thread | 单线程串行归约 | 寄存器 | cub/thread/thread_reduce.cuh |
回顾上一节,我们手写了 7 个版本的 reduce,最终在 4090 上跑到 955 GB/s。 CUB 做的事情本质上和我们一样——但组织方式完全不同。
首先,我们所有的优化都塞在一个__global__函数里。但除了 sum,还有 max、min、argmin 等各种归约算子,CUB需要对这些算子都有良好的扩展性。
其次,我们目前只支持 float,而 CUB 可以自动推导累加器类型,适配各种归约算子。
此外,我们用 atomicAdd 做最后合并,实际上在 block 数量多的时候竞争很激烈,CUB 有更好的处理方式。
这一节我们核心学习它是怎么把能力边界进行隔离的。 换不同的算子只影响 Warp 层的指令选择,换 GPU 只影响 Block 层的算法选择,换数据类型只影响 Thread 层的累加器推导。通过这种方式,我们逐渐培养工程性的能力。
3. Thread层
3.1. 为什么CUB中全是头文件?
在CUB中,所有的代码都采用头文件的方式实现。 实际上全头文件是模板编程的必然结果。
- 编译期可见。CUB大量使用模板代码,如果放在
.cu文件中,编译时根本不知道用户会传入什么参数,无法生成代码。 - __device__函数必须和调用方在同一个单元。GPU Kernel及其调用的所有device函数,最终都要编译成为同一个PTX二进制。否则的话会链接找不到。
- 编译期决策需要看到全部代码。这样可以消除分支选择,因为决策树在编译期就可以被消除。
代价主要是编译时间与二进制文件体积,但是换来的是极强的运行时性能。
3.2. 公开API
cub/thread/thread_reduce.cuh 是整个归约栈的最底层。它解决的问题极其简单:
这个文件的核心只有两块:三种树算法 + 一个编译期分发器。
ThreadReduce 对外有两个版本,区别是要不要把初始值也参与归约:
源代码:
// 版本 A:纯归约template <typename Input, typename ReductionOp, typename ValueT, typename AccumT>_CCCL_DEVICE _CCCL_FORCEINLINE AccumTThreadReduce(const Input& input, ReductionOp reduction_op);
// 版本 B:带初始值(CUB 叫 prefix)template <typename Input, typename ReductionOp, typename PrefixT, typename ValueT, typename AccumT>_CCCL_DEVICE _CCCL_FORCEINLINE AccumTThreadReduce(const Input& input, ReductionOp reduction_op, PrefixT prefix){ constexpr int length = static_size_v<Input>; AccumT array[length + 1]; // 比 input 多 1 格 array[0] = prefix; // 第 0 格放 prefix _CCCL_PRAGMA_UNROLL_FULL() for (int i = 0; i < length; ++i) array[i + 1] = input[i]; // input 整体后移一格 return cub::ThreadReduce(array, reduction_op); // 复用版本 A}伪代码:
# 版本 A:纯归约,没有初始值def ThreadReduce(input_array, op): # input_array = [a0, a1, a2, a3, a4, a5, a6, a7] # 返回: a0 ⊕ a1 ⊕ a2 ⊕ ... ⊕ a7
# 版本 B:带初始值(CUB 叫 prefix)def ThreadReduce(input_array, op, prefix): # input_array = [a0, a1, a2, a3, a4, a5, a6, a7] # prefix = init_val # 返回: init_val ⊕ a0 ⊕ a1 ⊕ ... ⊕ a7把 prefix 塞到数组最前面,然后直接调版本 A。底层归约逻辑是同一套,版本 B 只是比版本 A 多了一个元素。干净利落。
3.3. 串行算法
// cub/thread/thread_reduce.cuh 第 274-284 行template <typename AccumT, typename Input, typename ReductionOp>_CCCL_DEVICE _CCCL_FORCEINLINE AccumTThreadReduceSequential(const Input& input, ReductionOp reduction_op){ auto retval = static_cast<AccumT>(input[0]); _CCCL_PRAGMA_UNROLL_FULL() // ← 关键:强制编译器完全展开 for (int i = 1; i < static_size_v<Input>; ++i) retval = reduction_op(retval, input[i]); return retval;}元素: [a0] → [a1] → [a2] → [a3] → ... → [a7] │⊕ │ │ │ │ └──────┘ │ │ │ └──────┘ │ │ └──────┘ │ └──────┘依赖链长度: N-1 = 7 次串行加法当算子不满足结合律(如自定义非结合算子),或元素类型 ≥ 8 字节导致寄存器压力过大时,树形重排会改变运算顺序,这对不可交换的算子会导致结果错误。 Sequential 保证严格的从左到右顺序,可以保证语义正确性。
常见的不满足结合律的算子有:
- 极大数和极小数的运算,例如1e11和1相加减,可能会由于运算先后顺序吞掉精度。
- 自定义求平均的算子
auto running_avg = [](float a, float b) { return (a + b) / 2.0f;};
// ((1+2)/2 + 3) / 2 = 2.25// (1 + (2+3) / 2) / 2 = 1.75 结果不同- 带有状态的算子
auto first = [](int a, int b) { return a != 0 ? a : b; // 既没有交换律,也没有结合律}3.4. 默认BinaryTree算法
// cub/thread/thread_reduce.cuh 第 286-301 行template <typename AccumT, typename Input, typename ReductionOp>_CCCL_DEVICE _CCCL_FORCEINLINE AccumTThreadReduceBinaryTree(const Input& input, ReductionOp reduction_op){ constexpr auto length = static_size_v<Input>; auto array = cub::detail::to_array<AccumT>(input); // 拷贝到局部数组 _CCCL_PRAGMA_UNROLL_FULL() for (int i = 1; i < length; i *= 2) // 外层:步长 1,2,4,... { _CCCL_PRAGMA_UNROLL_FULL() for (int j = 0; j + i < length; j += i * 2) // 内层:相邻对合并 array[j] = reduction_op(array[j], array[j + i]); } return array[0];}之前我们提到过,在 Block 层的共享内存里,步长要”从大到小”走,才能避免 Bank Conflict。那这里为什么步长又是”从小到大”?写得如此简单?
因为它是完全站在单个线程的角度去思考的。
一个线程,N 个寄存器,没有 Bank,没有冲突。
线程视角的核心目标只有一个:缩短依赖链,提高 ILP。寄存器每个端口都是独立访问的,根本不存在 Bank Conflict 这个概念。所以步长方向从大到小、从小到大随意选,CUB 选”从小到大”纯粹是因为二叉树这么写最自然。
以 8 个元素为例,编译期完全展开后等价于:
原始: [a0] [a1] [a2] [a3] [a4] [a5] [a6] [a7]
i=1: [a0⊕a1] [a2⊕a3] [a4⊕a5] [a6⊕a7] ← 4 个加法,可并行i=2: [a0⊕...⊕a3] [a4⊕...⊕a7] ← 2 个加法i=4: [a0⊕...⊕a7] ← 1 个加法
总加法数: 7 次,依赖链: 3对比串行算法的 7 层依赖链,BinaryTree 直接砍到了 3 层。 GPU 的指令流水线可以并行发射同一轮内互不依赖的加法,实际延迟大幅降低。 这个算法是默认选项,覆盖 90% 的场景。
3.5. ILP拉满的TernaryTree 三叉树
// cub/thread/thread_reduce.cuh 第 303-319 行template <typename AccumT, typename Input, typename ReductionOp>_CCCL_DEVICE _CCCL_FORCEINLINE AccumTThreadReduceTernaryTree(const Input& input, ReductionOp reduction_op){ constexpr auto length = static_size_v<Input>; auto array = cub::detail::to_array<AccumT>(input); _CCCL_PRAGMA_UNROLL_FULL() for (int i = 1; i < length; i *= 3) // 外层:步长 1,3,9,... { _CCCL_PRAGMA_UNROLL_FULL() for (int j = 0; j + i < length; j += i * 3) // 内层:每 3 个合并 { auto value = reduction_op(array[j], array[j + i]); array[j] = (j + i * 2 < length) // 边界检查:第三个元素存在吗? ? reduction_op(value, array[j + i * 2]) : value; } } return array[0];}以 9 个元素为例:
原始: [a0] [a1] [a2] [a3] [a4] [a5] [a6] [a7] [a8]
i=1: [a0⊕a1⊕a2] [a3⊕a4⊕a5] [a6⊕a7⊕a8] ← 6 个加法,但只有 2 层依赖i=3: [a0⊕...⊕a8] ← 2 个加法
总加法数: 8 次,依赖链: 2(二叉树需要 4 层)这个方法每轮可以做更多的加法,换来更短的关键路径。
GPU在同一个时钟周期可以发射多个互不依赖的加法指令,更短的关键路径代表着更少的流水线停顿。
每轮做 3 路合并,需要额外寄存器存放中间值。这就是为什么 TernaryTree 只在 长度 >= 6 且 整数类型(≤4B) + plus/bitwise 时才启用。浮点的寄存器压力本来就大,3 路合并会溢出到 local memory,反而更慢。
3.6. 分发器
上述三种算法最终都由ThreadReduce公开入口根据类型、算子、长度、SM架构来在编译期进行确定。
// cub/thread/thread_reduce.cuh 第 410-471 行(简化,只保留核心分支)template <typename Input, typename ReductionOp, typename ValueT, typename AccumT>_CCCL_DEVICE _CCCL_FORCEINLINE AccumTThreadReduce(const Input& input, ReductionOp reduction_op){ constexpr auto length = static_size_v<Input>;
// 1. 只有 1 个元素:直接返回 if constexpr (length == 1) return static_cast<AccumT>(input[0]);
// 2. 不支持 SIMD 或 元素 >= 8 字节 → 兜底 if constexpr (!is_simd_enabled_cuda_operator<ReductionOp, ValueT> || sizeof(ValueT) >= 8) return ThreadReduceSequential<AccumT>(input, reduction_op);
// 3. SM90 + int16 + min/max + length >= 10 → DPX 硬件 SIMD if constexpr (enable_sm90_simd_reduction_v<ValueT, ReductionOp, length>) NV_IF_TARGET(NV_PROVIDES_SM_90, (return ThreadReduceSimd(input, reduction_op);))
// 4. SM80 + half/bf16 + min/max/sum + length >= 4 → half2 SIMD if constexpr (enable_sm80_simd_reduction_v<ValueT, ReductionOp, length>) NV_IF_TARGET(NV_PROVIDES_SM_80, (return ThreadReduceSimd(input, reduction_op);))
// 5. length >= 6 且 SM50+ 整数 plus/bitwise → TernaryTree(ILP 最优) if constexpr (length >= 6 && enable_ternary_reduction_sm50_v<ValueT, ReductionOp>) return ThreadReduceTernaryTree<PromT>(input, reduction_op);
// 6. 默认:BinaryTree return ThreadReduceBinaryTree<PromT>(input, reduction_op);}我们会发现这里使用的是if constexpr而不是if。
这是因为编译器只会保留关键的路径,而其余的路径则是会被全部丢弃,最终PTX里只有某一个路径上的指令,这就是大名鼎鼎的零运行时开销!
3.7. 和我们手写的关系
我们之前提到过,在从内存读取之前可以做一次加法,在寄存器里串行加:
float val1 = in[idx];float val2 = in[idx + blockDim.x];smem[tid] = val1 + val2; // 硬编码:固定 2 个元素,固定 plus实际上CUB把这件事情进行了泛化。
- 长度泛化:
ITEMS_PER_THREAD可以是任意编译期常量(tuning 表里 SM90 用到 16) - 算法泛化:自动从 3 种树 + SIMD 中选最优
- 算子泛化:plus/max/min/bitwise 各自走到不同的硬件加速路径
只做寄存器内部的归约,以及ILP的优化,至于Warp和Block的优化,那是上层的事情。
4. Warp层
线程上边就是Warp。
Warp是GPU调度和执行的最小单元。 32个线程共享一个程序计数器,锁步执行一条指令。 这意味着Warp内的32个线程是天然同步的,并不需要使用同步指令。
并且更为关键的是,英伟达提供了一组**SHFL(shuffle)**指令,让 Warp 内的线程可以直接读其他线程的寄存器,延迟只有 1 个时钟周期左右。这比共享内存还快一个数量级。
SHFL就是Warp层的核心优化方案。
4.1. WarpReduce的公开API
CUB 的 WarpReduce 是一个模板类,定义在 cub/warp/warp_reduce.cuh:
template <typename T, int LogicalWarpThreads = 32>class WarpReduce注意 LogicalWarpThreads 不一定是 32,你可以构造一个只8个或16个线程的逻辑 warp。这为后续的 Block 层提供了灵活性。
公开接口非常丰富:
| 方法 | 说明 |
|---|---|
Sum(input) | Warp 内求和 |
Max(input) / Min(input) | 最大值/最小值 |
Reduce(input, op) | 泛型归约,支持任意二元算子 |
HeadSegmentedReduce(input, flag, op) | 按头标志分段归约 |
TailSegmentedReduce(input, flag, op) | 按尾标志分段归约 |
所有方法都只在 lane 0 返回有效结果,其他 lane 拿到的值未定义。
4.2. 选SHFL 还是Shared Memory?
实际上在Warp这一层就要考虑到访存模式的方案了。
// cub/warp/warp_reduce.cuh 第 136-145 行static constexpr bool is_full_warp = (LogicalWarpThreads == 32);static constexpr bool is_power_of_two = ::cuda::is_power_of_two(LogicalWarpThreads);
using InternalWarpReduce = ::cuda::std:: _If<is_power_of_two, detail::WarpReduceShfl<T, LogicalWarpThreads>, // 2的幂 → SHFL detail::WarpReduceSmem<T, LogicalWarpThreads>>; // 非2的幂 → 共享内存英伟达给出的规则是:
- 2 的幂(2, 4, 8, 16, 32)→
WarpReduceShfl:用 SHFL 指令,不需要共享内存 - 非 2 的幂(如 24, 28)→
WarpReduceSmem:用共享内存做蝴蝶交换
因为 SHFL 的蝴蝶交换要求每一步的 offset 都是 2 的幂,非 2 的幂的 warp 会在某一步越界。
且看我后续讲解
4.2. SHFL
这是 Warp 层最精彩的部分。文件在 cub/warp/specializations/warp_reduce_shfl.cuh。
核心数据结构非常简单:
template <typename T, int LOGICAL_WARP_THREADS>struct WarpReduceShfl{ static constexpr int STEPS = Log2<LOGICAL_WARP_THREADS>::VALUE; // 32线程 → 5步
using TempStorage = NullType; // ← 不需要共享内存!};SHFL是如何实现蝴蝶交换的呢? 或者它为什么要叫这个名称呢?
// 一次蝴蝶交换template <typename _Tp, typename ReductionOp>_CCCL_DEVICE _CCCL_FORCEINLINE _TpReduceStep(_Tp input, ReductionOp reduction_op, int last_lane, int offset){ _Tp output = input;
// 核心:从 offset 之外的邻居那里"拽"数据过来 _Tp temp = ShuffleDown<LOGICAL_WARP_THREADS>(output, offset, last_lane, member_mask);
// 如果这个邻居在有效范围内,就参与归约 if (offset + lane_id <= last_lane) output = reduction_op(input, temp);
return output;}我们来看一下

def reduce_warp(threads, op): """ threads: 长度为 32 的数组,threads[i] 是 lane i 手里的值 op: 二元操作,比如 lambda a,b: a+b 返回: threads[0] 装着 32 个值的归约结果 """ for offset in [1, 2, 4, 8, 16]: # 步长逐轮翻倍 for i in range(32): # 所有线程同时干活 src = i + offset # 我要从谁那里拽数据 if src < 32: # 邻居在范围内吗? threads[i] = op(threads[i], threads[src]) return threads[0]5 轮循环,每轮 offset 翻倍。32 个线程同时执行,每个线程从 i+offset 号邻居的寄存器里把值拽过来,和自己的值做一次运算。
ShuffleDown 是硬件指令。
当前线程直接读 lane_id + offset 号线程的寄存器,不经过共享内存,延迟约 1 个周期。
CUB 用模板递归把 5 轮循环在编译期完全展开,最终 PTX 里就是 5 条展开的 shfl.down 指令序列。
这和我们手写的Warp Unrolling很像,但是这里通过模板递归+SHFL指令,跳过了共享内存,跳过了volatile,因为SHFL走的是寄存器到寄存器的直连通道。
4.3. 硬件原语
对于最常见的计算,plus/max/min/bitwise,实际上CUB直接调用硬件原语:
template <bool ALL_LANES_VALID, typename ReductionOp>_CCCL_DEVICE _CCCL_FORCEINLINE T Reduce(T input, int valid_items, ReductionOp reduction_op){ // 如果条件满足:直接用硬件 warp reduce 指令 if constexpr (ALL_LANES_VALID && is_integral && sizeof(T) <= 4 && (is_plus || is_min || is_max || is_bitwise)) { NV_IF_TARGET(NV_PROVIDES_SM_80, (return reduce_op_sync(input, member_mask, reduction_op);)) } // 否则走 butterfly 模板展开 ...}reduce_op_sync 内部直接映射到 PTX 指令:
| C++ 算子 | PTX 指令 |
|---|---|
plus<> | __reduce_add_sync(mask, input) |
minimum<> | __reduce_min_sync(mask, input) |
maximum<> | __reduce_max_sync(mask, input) |
bit_and<> | __reduce_and_sync(mask, input) |
bit_or<> | __reduce_or_sync(mask, input) |
bit_xor<> | __reduce_xor_sync(mask, input) |
一条 PTX 指令,32 个线程的归约就完成了。 这比手动写 5 轮 SHFL 还快。 因为硬件内部有专门的 reduction network,可以在更少的周期内完成。
4.4. 共享内存路径
当现在逻辑线程并不是2的幂次,SHFL无法工作,CUB就需要回退到共享内存的方案。
文件在 cub/warp/specializations/warp_reduce_smem.cuh。
template <typename T, int LOGICAL_WARP_THREADS>struct WarpReduceSmem{ static constexpr int WARP_SMEM_ELEMENTS = LOGICAL_WARP_THREADS + HALF_WARP_THREADS;
struct _TempStorage { T reduce[WARP_SMEM_ELEMENTS]; // ← 需要共享内存了! };};和 SHFL 版本唯一的区别是通信介质从寄存器直连变成了共享内存 + __syncwarp。
每一步需要写→同步→读→同步,而 SHFL 版本只需一条 shfl.down 指令。
这就是为什么 SHFL 路径能快那么多。
template <bool ALL_LANES_VALID, typename ReductionOp, int STEP>_CCCL_DEVICE _CCCL_FORCEINLINE TReduceStep(T input, int valid_items, ReductionOp reduction_op, constant_t<STEP>){ constexpr int OFFSET = 1 << STEP;
temp_storage.reduce[lane_id] = input; // 写共享内存 __syncwarp(member_mask); // warp 内同步 // 读邻居的数据 if (lane_id + OFFSET < valid_items) { T peer_addend = temp_storage.reduce[lane_id + OFFSET]; input = reduction_op(input, peer_addend); } __syncwarp(member_mask); return ReduceStep<...>(input, ...); // 模板递归下一步}__syncwarp vs __syncthreads__syncwarp(mask) 只同步一个 warp 内的线程,比 __syncthreads()(同步整个 block)轻量得多。在 Warp 层我们不需要让其他 warp 等我们。
5. Block层
Block 层是整个归约栈中最关键的一层。 它的职责非常明确:把一个 Block 里所有 Warp 的归约结果再合并成一个最终结果。
这一层的难点不在于“怎么加”,而在于“怎么设计”,如何用零运行时开销的方式,把好几种完全不同的算法封装成同一个接口,让调用者无需关心内部细节。
源代码有非常多的注释与边界条件,这是我精简过之后的版本。
#pragma once
#include <cub/config.cuh>
#include <cub/block/specializations/block_reduce_raking.cuh>#include <cub/block/specializations/block_reduce_raking_commutative_only.cuh>#include <cub/block/specializations/block_reduce_warp_reductions.cuh>#include <cub/thread/thread_operators.cuh>#include <cub/util_ptx.cuh>#include <cub/util_type.cuh>
#include <cuda/std/__functional/operations.h>#include <cuda/std/__host_stdlib/ostream>#include <cuda/std/__type_traits/conditional.h>
CUB_NAMESPACE_BEGIN
enum BlockReduceAlgorithm{ BLOCK_REDUCE_RAKING_COMMUTATIVE_ONLY, BLOCK_REDUCE_RAKING, BLOCK_REDUCE_WARP_REDUCTIONS, BLOCK_REDUCE_WARP_REDUCTIONS_NONDETERMINISTIC,};
#if _CCCL_HOSTED() && !defined(_CCCL_DOXYGEN_INVOKED)inline ::std::ostream& operator<<(::std::ostream& os, const BlockReduceAlgorithm& alg){ switch (alg) { case BlockReduceAlgorithm::BLOCK_REDUCE_RAKING_COMMUTATIVE_ONLY: return os << "BLOCK_REDUCE_RAKING_COMMUTATIVE_ONLY"; case BlockReduceAlgorithm::BLOCK_REDUCE_RAKING: return os << "BLOCK_REDUCE_RAKING"; case BlockReduceAlgorithm::BLOCK_REDUCE_WARP_REDUCTIONS: return os << "BLOCK_REDUCE_WARP_REDUCTIONS"; case BlockReduceAlgorithm::BLOCK_REDUCE_WARP_REDUCTIONS_NONDETERMINISTIC: return os << "BLOCK_REDUCE_WARP_REDUCTIONS_NONDETERMINISTIC"; default: return os << "<unknown BlockReduceAlgorithm: " << static_cast<int>(alg) << ">"; }}#endif
template <typename T, int BlockDimX, BlockReduceAlgorithm Algorithm = BLOCK_REDUCE_WARP_REDUCTIONS, int BlockDimY = 1, int BlockDimZ = 1>class BlockReduce{private: static constexpr int BLOCK_THREADS = BlockDimX * BlockDimY * BlockDimZ;
using WarpReductions = detail::BlockReduceWarpReductions<T, BlockDimX, BlockDimY, BlockDimZ>; using WarpReductionsNondeterministic = detail::BlockReduceWarpReductions<T, BlockDimX, BlockDimY, BlockDimZ, false>; using RakingCommutativeOnly = detail::BlockReduceRakingCommutativeOnly<T, BlockDimX, BlockDimY, BlockDimZ>; using Raking = detail::BlockReduceRaking<T, BlockDimX, BlockDimY, BlockDimZ>;
using InternalBlockReduce = ::cuda::std::_If<Algorithm == BLOCK_REDUCE_WARP_REDUCTIONS, WarpReductions, ::cuda::std::_If<Algorithm == BLOCK_REDUCE_WARP_REDUCTIONS_NONDETERMINISTIC, WarpReductionsNondeterministic, ::cuda::std::_If<Algorithm == BLOCK_REDUCE_RAKING_COMMUTATIVE_ONLY, RakingCommutativeOnly, Raking>>>;
using _TempStorage = typename InternalBlockReduce::TempStorage;
_CCCL_DEVICE _CCCL_FORCEINLINE _TempStorage& PrivateStorage() { __shared__ _TempStorage private_storage; return private_storage; }
_TempStorage& temp_storage; unsigned int linear_tid;
public: struct TempStorage : Uninitialized<_TempStorage> {};
_CCCL_DEVICE _CCCL_FORCEINLINE BlockReduce() : temp_storage(PrivateStorage()) , linear_tid(RowMajorTid(BlockDimX, BlockDimY, BlockDimZ)) {}
_CCCL_DEVICE _CCCL_FORCEINLINE BlockReduce(TempStorage& temp_storage) : temp_storage(temp_storage.Alias()) , linear_tid(RowMajorTid(BlockDimX, BlockDimY, BlockDimZ)) {}
template <typename ReductionOp> _CCCL_DEVICE _CCCL_FORCEINLINE T Reduce(T input, ReductionOp reduction_op) { return InternalBlockReduce(temp_storage).template Reduce<true>(input, BLOCK_THREADS, reduction_op); }
template <int ITEMS_PER_THREAD, typename ReductionOp> _CCCL_DEVICE _CCCL_FORCEINLINE T Reduce(T (&inputs)[ITEMS_PER_THREAD], ReductionOp reduction_op) { T partial = cub::ThreadReduce(inputs, reduction_op); return Reduce(partial, reduction_op); }
template <typename ReductionOp> _CCCL_DEVICE _CCCL_FORCEINLINE T Reduce(T input, ReductionOp reduction_op, int num_valid) { if (num_valid >= BLOCK_THREADS) { return InternalBlockReduce(temp_storage).template Reduce<true>(input, num_valid, reduction_op); } else { return InternalBlockReduce(temp_storage).template Reduce<false>(input, num_valid, reduction_op); } }
_CCCL_DEVICE _CCCL_FORCEINLINE T Sum(T input) { return InternalBlockReduce(temp_storage).template Sum<true>(input, BLOCK_THREADS); }
template <int ITEMS_PER_THREAD> _CCCL_DEVICE _CCCL_FORCEINLINE T Sum(T (&inputs)[ITEMS_PER_THREAD]) { T partial = cub::ThreadReduce(inputs, ::cuda::std::plus<>{}); return Sum(partial); }
_CCCL_DEVICE _CCCL_FORCEINLINE T Sum(T input, int num_valid) { if (num_valid >= BLOCK_THREADS) { return InternalBlockReduce(temp_storage).template Sum<true>(input, num_valid); } else { return InternalBlockReduce(temp_storage).template Sum<false>(input, num_valid); } }};
CUB_NAMESPACE_END5.1. 四种算法变体
打开 block_reduce.cuh,最先看到的就是算法枚举:
enum BlockReduceAlgorithm{ BLOCK_REDUCE_RAKING_COMMUTATIVE_ONLY, // 耙式归约,仅可交换算子 BLOCK_REDUCE_RAKING, // 耙式归约,支持不可交换算子 BLOCK_REDUCE_WARP_REDUCTIONS, // Warp归约,低延迟(默认) BLOCK_REDUCE_WARP_REDUCTIONS_NONDETERMINISTIC, // Warp归约,非确定性};这四个值在编译期被选中后,编译器会根据它们生成完全不同的代码路径。
总体成本 = Warp内归约成本 + Warp间合并成本
算法的差异主要在第二项的取舍上。
- BLOCK_REDUCE_WARP_REDUCTIONS 默认。
- 把跨Warp的合并也看作一次归约,直接复用WarpReduce。在每个Warp独立归约之后,所有Warp的0号现成再组合成一个逻辑Warp,再最一轮WarpReduce。总体的归约总数会略多,但是因为没有额外的共享内存布局开销,所以延迟最低。
- BLOCK_REDUCE_WARP_REDUCTIONS_NONDETERMINISTIC
- 用原子操作代替第二轮规约,可以省去一次同步。用各自的Warp的0号线程,直接使用
atomicAdd指令把自己的部分和累加到一个共享内存地址。这样会导致结果非确定(累加顺序由运行时调度决定),主要适用于对顺序无要求且原子操作带宽充裕的情况。
- BLOCK_REDUCE_RAKING(通用、高吞吐)
- 这种做法不让所有 Warp 的线程都参与合并,而是只派一个 Warp 去“耙取”所有 Warp 的局部结果。所有 Warp 将局部结果写入共享内存的栅格,然后第一个 Warp 的线程遍历这些结果并串行归约。增加了写入共享内存和遍历的开销,延迟略高,但是总的归约操作次数最少,天然支持不可交换算子。
- BLOCK_REDUCE_RAKING_COMMUTATIVE_ONLY(可交换专用,通信最少)
- 既然算子可交换,合并的顺序就不重要。让第一个 Warp 通过 SHFL 直接“拽走”其他 Warp 的结果,无需它们写共享内存。非首 Warp 只通过
WarpReduce算出局部结果,首 Warp 用 SHFL 读走这些结果。这个要求Block大小是Warp的整数倍,通信轮次最少,在很多场景下是性能最优解。
5.2. 如何选择合适的变体?
在实际开发中,不必每次都手动指定算法。默认的 BLOCK_REDUCE_WARP_REDUCTIONS 在绝大多数情况下都能提供优异的性能。但如果你有明确的优化目标,可以遵循以下准则:
| 目标 | 推荐变体 | 原因 |
|---|---|---|
| 降低延迟(GPU 占用不足) | WARP_REDUCTIONS | 无额外 SMEM 布局,两轮 SHFL 直达 |
| 最大化吞吐(GPU 满载) | RAKING_COMMUTATIVE_ONLY(若算子可交换) | 通信最少,归约次数最少 |
| 算子不可交换 | RAKING | 保证从左到右的归约顺序 |
| 不在意确定性,追求极致延迟 | WARP_REDUCTIONS_NONDETERMINISTIC | 省掉一轮同步 |
经验法则:先使用默认算法,如果归约成为瓶颈,再根据算子的可交换性尝试 RAKING 或 RAKING_COMMUTATIVE_ONLY。
5.3. 编译器策略分叉
Block 层的核心是一个编译期类型选择器。来看代码:
template <typename T, int BlockDimX, BlockReduceAlgorithm Algorithm = BLOCK_REDUCE_WARP_REDUCTIONS, int BlockDimY = 1, int BlockDimZ = 1>class BlockReduce{private: static constexpr int BLOCK_THREADS = BlockDimX * BlockDimY * BlockDimZ;
// 四个具体实现类的别名: using WarpReductions = detail::BlockReduceWarpReductions<T, BlockDimX, BlockDimY, BlockDimZ>; using WarpReductionsNondeterministic = detail::BlockReduceWarpReductions<T, BlockDimX, BlockDimY, BlockDimZ, false>; using RakingCommutativeOnly = detail::BlockReduceRakingCommutativeOnly<T, BlockDimX, BlockDimY, BlockDimZ>; using Raking = detail::BlockReduceRaking<T, BlockDimX, BlockDimY, BlockDimZ>;
// 编译期三级决策树(注意 _If 是 cuda::std:: 的类型萃取器) using InternalBlockReduce = ::cuda::std::_If<Algorithm == BLOCK_REDUCE_WARP_REDUCTIONS, WarpReductions, ::cuda::std::_If<Algorithm == BLOCK_REDUCE_WARP_REDUCTIONS_NONDETERMINISTIC, WarpReductionsNondeterministic, ::cuda::std::_If<Algorithm == BLOCK_REDUCE_RAKING_COMMUTATIVE_ONLY, RakingCommutativeOnly, Raking>>>;};::cuda::std::_If 是 CUB 的编译期条件类型选择器,用法类似于 std::conditional。它在编译期根据第一个参数选择 WarpReductions 或后续分支,等价于一个不在运行时执行的 if-else 链。编译器会把所有没有被选中的分支完全丢弃,最终 PTX 里只有被选中那一个具体实现类的代码。这就是零运行时开销的类型派发。
也就是说,当用户写 cub::BlockReduce<float, 128> 时(默认算法为 BLOCK_REDUCE_WARP_REDUCTIONS),InternalBlockReduce 就等价于 detail::BlockReduceWarpReductions<float, 128, 1, 1>。整个编译期的决策树在编译期间就“折叠”成了具体类。
5.4. 共享内存
5.4.1. Naive内存分配方法
内存管理在GPU中是极其重要的一环。
如果要开辟共享内存,我们常见的办法是这样子的:
extern __shared__ T sdata[];我们需要声明一个动态大小的共享内存数组,编译器并不会分配具体的空间,而是由启动时的参数决定。
size_t smem = 256 * sizeof(float);kernel<<<gridSize, blockSize, smem>>>(...);一个经典的算法就这样子写:
__global__ void reduce(const float* in, float* out) { extern __shared__ float sdata[];
int tid = threadIdx.x; sdata[tid] = in[tid];
__syncthreads();
for (int s = blockDim.x / 2; s > 0; s >>= 1) { if (tid < s) { sdata[tid] += sdata[tid + s]; } __syncthreads(); } if (tid == 0) out[0] = sdata[0];}上一个版本中,我是直接开了1024大小的共享内存,但是这样子会浪费掉很多内存。 而现在这个版本使用extern来动态分配,但是需要手动计算偏移和对齐,非常容易出错。 并且对于这种规约来说,资源占用非常大。
之前在Warp级别已经有了shuffle,可以极大地减少共享内存的占用。
假设block大小为256,warp大小是32,总共8个warp。
- 第一步:所有的8个warp通过shuffle进行规约。这一步完全在寄存器内完成,不需要共享内存和同步开销。
- 第二步:产生的8个局部和写入共享内存。此时共享内存只需要8个元素,并非256个。
- 第三步:warp0线程再次读取这8个值,并且再进行一次shuffle。
5.4.2. 编译期分配内存
实际上,上述通过warp shuffle可以极大地减少内存分配的开销。
但是大家还记得吗,block是给提供了4种不同的block级别方案。那么对于这四种方法,它们需要的共享内存大小是不同的,此时应该怎么办呢?
私有内存
这个地方可以说是一个坑,或者是真正优化的关键。 如果你写:
cub::BlockReduce<...> reduce;CUB内部会帮你申请好一块共享内存,用户是看不到的,并且也控制不了。
假设你现在写的kernel要进行的操作是:
- 加载数据
- 排序
- 求和。
那么如果我们每次都用这种申请方式,编译器会这么认为:
- 加载:需要一块内存
- 排序:需要一块内存
- 归约:需要一块内存
这三块内存在执行过程中是同时存在的,虽然实际上是先加载后排序,最终求和,永远都不会同时用到三块,但是编译器不知道,会都留着。
后果就是一个block占用了三倍的共享内存,导致GPU同时能够跑的Block数量减少,吞吐降低。
统一管理
CUB提供的写法是在外部申请一块内存,并且使用union。告诉编译器这个东西我分时用,你可以把他们叠在一起。
__shared__ union{ BlockLoad 的内存需求; BlockSort 的内存需求; BlockReduce 的内存需求;} 公共内存union会告诉编译器这三个东西虽然名字不同,但是并不会同时用,所以可以把它们放在同一个物理地址,谁用谁占。
原本的流程变成了:
- 加载阶段:这块内存当”加载白板”用
__syncthreads()—— 所有人都加载完了- 排序阶段:同一块物理内存,现在当”排序白板”用
__syncthreads()—— 所有人都排完了- 归约阶段:同一块物理内存,现在当”归约白板”用
原本要三块内存,现在只需要一块最大的那个大小。节省了2/3的空间占用。
5.4.3. 测试一下
#include <cub/block/block_reduce.cuh>#include <cub/block/block_scan.cuh>#include <cub/block/block_radix_sort.cuh>#include <cuda/std/functional> // ::cuda::std::plus#include <cuda_runtime.h>#include <cstdio>
constexpr int BLOCK_DIM = 512;constexpr int ITEMS_PER_THREAD = 1;
// 私有存储:三个算法各自独立分配 TempStorage__global__ void __launch_bounds__(BLOCK_DIM)multi_private(const float* in, float* out) { cub::BlockRadixSort<float, BLOCK_DIM, ITEMS_PER_THREAD> sorter; cub::BlockScan<float, BLOCK_DIM> scanner; cub::BlockReduce<float, BLOCK_DIM> reducer;
float keys[ITEMS_PER_THREAD] = { in[threadIdx.x] }; sorter.Sort(keys);
float scanned; scanner.InclusiveScan(keys[0], scanned, ::cuda::std::plus<>{});
float sum = reducer.Sum(scanned); if (threadIdx.x == 0) out[blockIdx.x] = sum;}
// Union 分时复用:同一块内存在三个阶段之间重用__global__ void __launch_bounds__(BLOCK_DIM)multi_union(const float* in, float* out) { union Storage { typename cub::BlockRadixSort<float, BLOCK_DIM, 1>::TempStorage sort; typename cub::BlockScan<float, BLOCK_DIM>::TempStorage scan; typename cub::BlockReduce<float, BLOCK_DIM>::TempStorage reduce; }; __shared__ Storage storage;
float keys[1] = { in[threadIdx.x] };
cub::BlockRadixSort<float, BLOCK_DIM, 1> sorter(storage.sort); sorter.Sort(keys); __syncthreads();
cub::BlockScan<float, BLOCK_DIM> scanner(storage.scan); float scanned; scanner.InclusiveScan(keys[0], scanned, ::cuda::std::plus<>{}); __syncthreads();
cub::BlockReduce<float, BLOCK_DIM> reducer(storage.reduce); float sum = reducer.Sum(scanned); if (threadIdx.x == 0) out[blockIdx.x] = sum;}
void analyze(const char* name, const void* kernel) { cudaFuncAttributes attr; cudaFuncGetAttributes(&attr, kernel); int max_blocks; cudaOccupancyMaxActiveBlocksPerMultiprocessor(&max_blocks, kernel, BLOCK_DIM, 0); printf("%-20s 静态共享%5zu B → 活跃块: %d\n", name, attr.sharedSizeBytes, max_blocks);}
int main() { cudaDeviceProp prop; cudaGetDeviceProperties(&prop, 0); printf("设备: %s, SM共享内存上限: %zu KB\n\n", prop.name, prop.sharedMemPerMultiprocessor/1024);
using S = cub::BlockRadixSort<float, BLOCK_DIM, 1>::TempStorage; using C = cub::BlockScan<float, BLOCK_DIM>::TempStorage; using R = cub::BlockReduce<float, BLOCK_DIM>::TempStorage;
printf("排序:%zu B 扫描:%zu B 归约:%zu B\n", sizeof(S), sizeof(C), sizeof(R)); printf("私有总存储: %zu B\n", sizeof(S) + sizeof(C) + sizeof(R)); printf("Union 最大存储: %zu B\n\n", (sizeof(S) > sizeof(C)) ? (sizeof(S) > sizeof(R) ? sizeof(S) : sizeof(R)) : (sizeof(C) > sizeof(R) ? sizeof(C) : sizeof(R)));
analyze("多算法+私有", (const void*)multi_private); analyze("多算法+union", (const void*)multi_union);
return 0;}6. Agent层
假设我现在一共有10亿个数据要规约,每个block有256个线程,每个线程处理8个元素,那么一个tile就是2048个元素。Device层可能分给这个block 100万个元素,那就进行循环 100/2048次。
每次迭代只做三件事:加载 tile → 变换 → 线程内归约。循环结束才做一次跨线程的 BlockReduce。
因为 BlockReduce 要走 shared memory + __syncthreads,开销远大于寄存器里的 ThreadReduce。把累加留在寄存器里,最后汇总一次,效率最高。
6.1. Tile 循环
// ConsumeFullTileRange 的骨架ConsumeFullTile<true>(aggregate, block_offset);
block_offset += block_stride;
while (block_offset <= block_end - TILE_ITEMS) // 中间的完整 tile{ ConsumeFullTile<false>(aggregate, block_offset); // 后续 tile(累加到 aggregate) block_offset += block_stride;}
if (block_offset < block_end) // 最后一个不完整的 tile ConsumePartialTile<false>(aggregate, block_offset, block_end - block_offset);6.2. Striped 加载 + 寄存器归约
Tile 的数据分布(striped,而非 contiguous):
Thread 0 拿: d_in[0], d_in[256], d_in[512], d_in[768] ... Thread 1 拿: d_in[1], d_in[257], d_in[513], d_in[769] ... ...
不是 Thread 0 拿 d_in[0..7], Thread 1 拿 d_in[8..15](contiguous)这个就是我们之前提到的,防止访存冲突,所以它的取值方式是跳着取,可以看到thread0每间隔256个去取一组,thread1也是同理。 Striped 方式让相邻线程访问的 global memory 地址连续,保证合并访问。
每个线程拿到 ITEMS_PER_THREAD 个元素后:
Step 1: 向量化加载(优先 float4/uint4,减少 load 指令数)Step 2: transform_op 逐元素变换(可选,如 x → x*x)Step 3: ThreadReduce(寄存器数组, reduction_op) → 一个寄存器值Step 4: 这个值累加到 thread_aggregate(寄存器中)整个路径是 global memory → 向量化 load → 寄存器 → transform → ThreadReduce → 寄存器。在跨线程通信之前,一切都在寄存器里完成。
到最后做 BlockReduce 时,每个线程只贡献 1 个值(它的 thread_aggregate),shared memory 用量极小。
6.3. 首 Tile 特判 + 末 Tile 边界
首 Tile:thread_aggregate 是空的,不能对空值做 reduction_op。
// 第一个 tile:直接赋值thread_aggregate = ThreadReduce(items, reduction_op);
// 后续 tile:与已有值累加thread_aggregate = ThreadReduce(items, reduction_op, thread_aggregate);末 Tile(Partial Tile):当 block_end - block_offset < TILE_ITEMS 时,不是所有线程都有有效数据。
// 只有前 valid_items 个"线程槽位"有数据int thread_offset = lane_id;
// 第一个有效元素直接赋值(不能用 reduction_op)if (thread_offset < valid_items){ thread_aggregate = transform_op(d_in[block_offset + thread_offset]); thread_offset += NumThreads;}
// 后续有效元素用 reduction_op 累加while (thread_offset < valid_items){ thread_aggregate = reduction_op(thread_aggregate, transform_op(d_in[block_offset + thread_offset])); thread_offset += NumThreads;}做完 partial tile 的线程内归约后,BlockReduce/WarpReduce 需要知道多少线程有有效数据。BlockReduce 通过用 identity 值填充无效线程的 shared memory 槽位来隐式处理;WarpReduce 需要显式传 valid_items。
6.4. 汇总
Agent 的核心流程就是这两行:
AccumT ConsumeRange(GridEvenShare<OffsetT>& even_share){ AccumT thread_aggregate{};
// 循环吃 tile:加载 → 变换 → 线程内归约 → 累加 ConsumeFullTileRange(thread_aggregate, even_share);
// 最后做一次跨线程归约,得到这个 block 的部分结果 return BlockReduce(temp_storage.reduce).Reduce(thread_aggregate, reduction_op);}Agent 的本质:把”划分 tile → 向量化加载 → 寄存器归约 → 累加 → 集体汇总”这一整套固定流程,抽象成一个编译期参数化的模板。
这个阶段基本上就可以把我们之前学到的7个版本全都用上了。
7. Device + Dispatch 层
Device 层是用户所用的 API,Dispatch 层是它的实现。 两者解决一个问题:把 N 个元素归约成 1 个结果,用多个 block 并行做,最后汇总。
| # | 概念 | 解释 |
|---|---|---|
| 1 | 两趟归约(Two-Pass) | 第一趟:M 个 block 各自归约 → M 个部分结果;第二趟:1 个 block 把 M 个归约成 1 个 |
| 2 | Tuning 选择 | 根据数据类型、算子和 SM 架构,编译期+运行期选出最优的 Agent policy |
| 3 | 三种确定性模式 | run-to-run(默认)、gpu-to-gpu(可重现浮点)、non-deterministic(原子操作最快) |
7.1. Two Pass
N 个元素(可能 1 亿) │ ┌──────────────┼──────────────┐ │ │ │ Block 0 Block 1 ... Block M-1 ← 第一趟(并行) 归约 [0..K) 归约 [K..2K) 归约 [...N) │ │ │ 部分结果 0 部分结果 1 部分结果 M-1 │ │ │ └──────────────┼──────────────┘ │ 1 个 Block ← 第二趟(串行) 归约 M 个部分结果 │ 最终结果 1 个如果数据量比较小,直接单个block就可以实现,但是如果数据规模比较大,就需要进行上边提到的Two Pass规约。
// dispatch_reduce.cuh — 顶层入口auto dispatch(...){ reduce_policy active_policy = policy_selector(compute_capability);
// 一个 tile 就装下了 → 直接单 block 搞定 if (num_items <= single_tile_capacity) return invoke_single_tile(); // DeviceReduceSingleTileKernel<<<1, N, 0>>>
// 大问题:两趟 return invoke_passes();}
// 第一趟:多 block 并行归约auto invoke_passes(...){ // 1. 算需要多少 block int max_blocks = sm_occupancy * sm_count * subscription_factor;
// 2. GridEvenShare 把数据均匀分给各 block GridEvenShare even_share; even_share.DispatchInit(num_items, max_blocks, tile_size); int grid_size = even_share.grid_size;
// 3. 分配临时存储:每个 block 放一个部分结果 AccumT* d_block_reductions = allocate(max_blocks * sizeof(AccumT));
// 4. 发射第一趟 kernel DeviceReduceKernel<<<grid_size, block_threads, 0, stream>>>( d_in, d_block_reductions, num_items, even_share, reduction_op, transform_op);
// ===== 第二趟:单 block 汇总 M 个部分结果 ===== DeviceReduceSingleTileKernel<<<1, block_threads, 0, stream>>>( d_block_reductions, d_out, grid_size, reduction_op, init, identity);}7.2. Tune
同一个 DeviceReduce 调用,在 SM 5.0(GTX Titan)和 SM 10.0(Blackwell)上应该用不同的 block 配置,这个地方同样在编译期进行选择。
这里是官方调试之后的参数,性能相对来说比较好。
一个巨大的 switch-case,输入是 (类型, 算子, 硬件架构),输出是一组数字(block 线程数、每线程 item 数、向量化宽度…)。简单吧~
主要确定以下内容:
struct reduce_policy{ agent_reduce_policy reduce; // 正常数据量 agent_reduce_policy single_tile; // 小数据量(一个 tile 能装下) agent_reduce_policy reduce_nondeterministic; // 非确定性快速路径};
// agent_reduce_policy — 就是 AgentReducePolicy 的运行期版本struct agent_reduce_policy{ int block_threads; // 每个 block 多少线程 int items_per_thread; // 每线程处理多少元素 int vec_size; // 向量化加载宽度 BlockReduceAlgorithm block_algorithm; // BLOCK_REDUCE_RAKING 还是 WARP_REDUCTIONS CacheLoadModifier load_modifier; // LOAD_LDG(绕过 L1)还是 LOAD_DEFAULT};7.3. 三种确定性模式
DeviceReduce 支持三种确定性,它们在 dispatch 路径上分叉:
| 模式 | 确定性 | 算法 | 适用场景 |
|---|---|---|---|
run_to_run | 同一 GPU 同次运行可重复 | 标准两趟 | 默认,通用 |
gpu_to_gpu | 不同 GPU 也能得到逐 bit 相同结果 | 两趟 + 软件浮点累加器 | 需要跨设备可重现 |
not_guaranteed | 不保证 | 多 block 用 atomicAdd 写同一地址 | 最快,仅 plus |
Device 层路由
// device_reduce.cuh — reduce_impl 的三个重载
// 重载 1: run_to_run(默认)reduce_impl(..., run_to_run_t, ...){ return detail::reduce::dispatch(...); // 标准两趟}
// 重载 2: gpu_to_gpu(可重现浮点)reduce_impl(..., gpu_to_gpu_t, ...){ return detail::rfa::dispatch(...); // 用 ReproducibleFloatingAccumulator 累加}
// 重载 3: not_guaranteed(非确定,最快)reduce_impl(..., not_guaranteed_t, ...){ return detail::reduce::dispatch_nondeterministic(...); // 原子操作}非确定性路径的差异
// 非确定性 kernel:每个 block 做完后直接用 atomicAdd 写最终结果__global__ void NondeterministicDeviceReduceAtomicKernel(d_in, d_out, ...){ // ... 同样的 AgentReduce 流程 ... AccumT block_aggregate = AgentReduceT(...).ConsumeTiles(even_share);
if (threadIdx.x == 0) { // 关键区别:不是写 d_out[blockIdx.x](各自独立位置) // 而是 atomicAdd 到同一个 d_out[0] atomic_ref<AccumT, device_scope> target(d_out[0]); target.fetch_add(blockIdx.x == 0 ? reduction_op(init, block_aggregate) // block 0 负责加 init : block_aggregate); }}非确定性路径省掉了第二趟 kernel,代价是:
- 只能用
plus(原子操作限制) - 数据类型 >= 4B(小类型的 atomic 是软件模拟的,反而更慢)
- 结果的 bit pattern 依赖于 block 的调度顺序
总结
还有一些边角,例如Grid层,包括每一层的细节,由于时间关系没办法讲解这么详细,请大家见谅。
我理解的完整调用关系如下:
Device 定义接口 → Dispatch 决定发射策略 → Tuning 选择硬件最佳参数 → Kernel 承载 GPU 执行 → Agent 编排单 block 流程 → Grid 分配数据段 → Block/Warp/Thread 做底层归约计算。
后续大家可以进一步对照着库的学习,希望对大家有所收获。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!
部分内容可能已过时