CUDA学习之路[9]:CUB 归约的分层设计

10744 字
54 分钟
CUDA学习之路[9]:CUB 归约的分层设计
更新中

0. 回顾:我们手写了哪些优化?#

在上一章中,我们从 v0 到 v7 逐步引入了这些技术:

版本引入的技术解决了什么问题
v0基础树形归约让归约跑起来
v1用乘加代替取模消除昂贵的整数除法
v2翻转循环方向消除 Bank Conflict
v3每线程读 2 个元素减少线程数,提高计算密度
v4手动展开最后一个 warp消除最后 5 轮的同步开销
v5模板常量展开消除运行时循环,零开销抽象
v6Grid Loop让线程反复干活,减少 atomicAdd 竞争
v7float4 向量化一条指令读 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库的代码量,大家可以看一下。

Terminal window
(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 297413
CUDA 1784 60954 89331 278742
C++ 3669 52108 58422 266717
Python 281 9148 7617 30786
reStructuredText 403 12076 29327 16784
CMake 158 1906 2585 13700
Bourne Shell 97 1312 778 6798
YAML 52 657 921 6210
JSON 67 0 0 5579
Markdown 34 983 4 2349
Cython 1 315 123 2211
PowerShell 23 278 161 1177
JavaScript 2 60 14 415
XML 1 0 46 407
Bourne Again Shell 7 74 54 283
TOML 3 39 27 204
HTML 3 18 0 98
CSS 1 4 0 37
Text 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来辅助阅读。

如果有同学感兴趣如何配置Claude Code或者是如何调用API的话,后续我也会写一个配置指导,希望大家call我!

在读的时候,我会让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选策略,推导类型hostcub/device/device_reduce.cuh
Dispatch决定单 pass 还是双 passhostcub/device/dispatch/dispatch_reduce.cuh
Kernel启动 GPU 线程,实例化 Agentcub/device/dispatch/kernels/kernel_reduce.cuh
Agent加载数据,协调 BlockReduceGlobal Memorycub/agent/agent_reduce.cuh
BlockBlock 内跨 warp 归约Shared Memorycub/block/block_reduce.cuh
WarpWarp 内跨线程归约SHFL / Shared Memorycub/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层#

Talk is cheap, show me your code.

3.1. 为什么CUB中全是头文件?#

在CUB中,所有的代码都采用头文件的方式实现。 实际上全头文件是模板编程的必然结果。

  1. 编译期可见。CUB大量使用模板代码,如果放在.cu文件中,编译时根本不知道用户会传入什么参数,无法生成代码。
  2. __device__函数必须和调用方在同一个单元。GPU Kernel及其调用的所有device函数,最终都要编译成为同一个PTX二进制。否则的话会链接找不到。
  3. 编译期决策需要看到全部代码。这样可以消除分支选择,因为决策树在编译期就可以被消除。
那么,代价呢?

代价主要是编译时间与二进制文件体积,但是换来的是极强的运行时性能。

3.2. 公开API#

cub/thread/thread_reduce.cuh 是整个归约栈的最底层。它解决的问题极其简单:

一个线程手里有 N 个元素,用一个二元操作把它们归约成 1 个值。

这个文件的核心只有两块:三种树算法 + 一个编译期分发器

ThreadReduce 对外有两个版本,区别是要不要把初始值也参与归约

源代码:

// 版本 A:纯归约
template <typename Input, typename ReductionOp,
typename ValueT, typename AccumT>
_CCCL_DEVICE _CCCL_FORCEINLINE AccumT
ThreadReduce(const Input& input, ReductionOp reduction_op);
// 版本 B:带初始值(CUB 叫 prefix)
template <typename Input, typename ReductionOp,
typename PrefixT, typename ValueT, typename AccumT>
_CCCL_DEVICE _CCCL_FORCEINLINE AccumT
ThreadReduce(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
CUB 怎么实现版本 B 的?

把 prefix 塞到数组最前面,然后直接调版本 A。底层归约逻辑是同一套,版本 B 只是比版本 A 多了一个元素。干净利落。

3.3. 串行算法#

// cub/thread/thread_reduce.cuh 第 274-284 行
template <typename AccumT, typename Input, typename ReductionOp>
_CCCL_DEVICE _CCCL_FORCEINLINE AccumT
ThreadReduceSequential(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 保证严格的从左到右顺序,可以保证语义正确性。

结合律指的是:(a ⊕ b) ⊕ c == a ⊕ (b ⊕ c)。

常见的不满足结合律的算子有:

  1. 极大数和极小数的运算,例如1e11和1相加减,可能会由于运算先后顺序吞掉精度。
  2. 自定义求平均的算子
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 结果不同
  1. 带有状态的算子
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 AccumT
ThreadReduceBinaryTree(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 AccumT
ThreadReduceTernaryTree(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 AccumT
ThreadReduce(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:用共享内存做蝴蝶交换
为什么非 2 的幂不能用 SHFL?

因为 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 _Tp
ReduceStep(_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 T
ReduceStep(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_END

5.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归约,非确定性
};

这四个值在编译期被选中后,编译器会根据它们生成完全不同的代码路径

Block的总体成本计算公式如下:

总体成本 = Warp内归约成本 + Warp间合并成本

算法的差异主要在第二项的取舍上。

  1. BLOCK_REDUCE_WARP_REDUCTIONS 默认。
  • 把跨Warp的合并也看作一次归约,直接复用WarpReduce。在每个Warp独立归约之后,所有Warp的0号现成再组合成一个逻辑Warp,再最一轮WarpReduce。总体的归约总数会略多,但是因为没有额外的共享内存布局开销,所以延迟最低。
  1. BLOCK_REDUCE_WARP_REDUCTIONS_NONDETERMINISTIC
  • 用原子操作代替第二轮规约,可以省去一次同步。用各自的Warp的0号线程,直接使用atomicAdd指令把自己的部分和累加到一个共享内存地址。这样会导致结果非确定(累加顺序由运行时调度决定),主要适用于对顺序无要求且原子操作带宽充裕的情况。
  1. BLOCK_REDUCE_RAKING(通用、高吞吐)
  • 这种做法不让所有 Warp 的线程都参与合并,而是只派一个 Warp 去“耙取”所有 Warp 的局部结果。所有 Warp 将局部结果写入共享内存的栅格,然后第一个 Warp 的线程遍历这些结果并串行归约。增加了写入共享内存和遍历的开销,延迟略高,但是总的归约操作次数最少,天然支持不可交换算子。
  1. 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省掉一轮同步

经验法则:先使用默认算法,如果归约成为瓶颈,再根据算子的可交换性尝试 RAKINGRAKING_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要进行的操作是:

  1. 加载数据
  2. 排序
  3. 求和。

那么如果我们每次都用这种申请方式,编译器会这么认为:

  • 加载:需要一块内存
  • 排序:需要一块内存
  • 归约:需要一块内存

这三块内存在执行过程中是同时存在的,虽然实际上是先加载后排序,最终求和,永远都不会同时用到三块,但是编译器不知道,会都留着。

后果就是一个block占用了三倍的共享内存,导致GPU同时能够跑的Block数量减少,吞吐降低。

统一管理#

CUB提供的写法是在外部申请一块内存,并且使用union。告诉编译器这个东西我分时用,你可以把他们叠在一起。

__shared__ union{
BlockLoad 的内存需求;
BlockSort 的内存需求;
BlockReduce 的内存需求;
} 公共内存

union会告诉编译器这三个东西虽然名字不同,但是并不会同时用,所以可以把它们放在同一个物理地址,谁用谁占。

原本的流程变成了:

  1. 加载阶段:这块内存当”加载白板”用
  2. __syncthreads() —— 所有人都加载完了
  3. 排序阶段:同一块物理内存,现在当”排序白板”用
  4. __syncthreads() —— 所有人都排完了
  5. 归约阶段:同一块物理内存,现在当”归约白板”用

原本要三块内存,现在只需要一块最大的那个大小。节省了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。

为什么不每 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 边界#

首 Tilethread_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 个
2Tuning 选择根据数据类型、算子和 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 做底层归约计算。

后续大家可以进一步对照着库的学习,希望对大家有所收获。

您的赞助也是我更新最大的动力!

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
CUDA学习之路[9]:CUB 归约的分层设计
https://dlog.com.cn/posts/cuda09/cub/
作者
杜子源
发布于
2026-05-02
许可协议
CC BY-NC-SA 4.0
最后更新于 2026-05-02,距今已过 55 天

部分内容可能已过时

Profile Image of the Author
杜子源
都是风景,幸会
公告
请狠狠地打赏我,打赏一次,爆更一篇!!
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
29
分类
8
标签
11
总字数
81,272
运行时长
0
最后活动
0 天前

目录