CUDA编程实战12:原子操作与高性能直方图——从正确累加到低冲突并行更新

CUDA编程实战12:原子操作与高性能直方图------从正确累加到低冲突并行更新

适合读者:开始处理计数器、直方图、图节点度数、任务队列等"许多线程更新少量共享结果"问题,既担心结果错误,又想降低原子冲突的 CUDA 学习者。

本篇目标:先用原子操作建立可验证的正确基线,再用 Warp 聚合、Block 私有化、Warp 私有化与两阶段归并降低冲突;最后依据输入分布和 Profiler 证据选择方案。

配套源码cuda-notes/blogs/code/12/,包含 atomic_counter.cuhistogram_bench.cu 两个可独立运行的 CUDA 程序。

预计实践时间:阅读约 80 分钟;运行计数与直方图实验、扫描不同分布并观察性能约 90~120 分钟。

系列定位:这是 CUDA 编程实战第 12 篇。它把"并行正确性"和"性能优化"放到同一个真实问题中:先保证每一次更新都没有丢失,再为热点更新建立分层的低冲突路径。


当成千上万个 GPU 线程各自读取数据时,并行通常很自然;可一旦它们要把结果累加到同一个计数器、同一组直方图 Bin、同一个图节点度数或同一条任务队列,问题就突然变了:普通的 counter++ 为什么会丢失更新?换成 atomicAdd 后虽然正确,为什么数据越集中反而越慢?共享内存私有化为什么能加速,又为什么不是任何输入上的固定冠军?

本篇要解决一个非常实际的诉求:面对"许多线程更新少量共享结果"的 CUDA 任务,怎样先用原子操作建立正确基线,再根据冲突分布选择 Warp 聚合、Block 私有化、Warp 私有化或两阶段归并,并用正确性与 Profiler 证据验证优化。

读完后,你不仅能写出正确直方图,还会知道何时保留简单全局原子,何时值得支付共享内存、同步和归并成本,以及怎样避免"错误版本因为少做了更新所以看起来更快"的性能陷阱。


写在前面:原子操作不是性能罪人,失控的共享才是

并行程序最容易让人不安的时刻,不是 Kernel 编译失败,而是它能运行、速度也很快,却悄悄漏掉了更新。普通的 counter++ 在单线程里理所当然,在 GPU 上却可能让成千上万次贡献相互覆盖。atomicAdd 能把结果救回来,但当所有线程盯着同一个地址时,正确性又会变成可观的争用成本。

这一篇不会把"少用原子"当成一句口号。你将从一个必然正确的原子基线出发,在不同数据分布下比较四类策略的真实代价,并学会用正确性检查、采样数据和 Profiler 证据判断:此刻应该保留简单实现,还是值得引入更复杂的私有化与归并。

一、这一篇为什么是 CUDA 正确性与性能的交叉点

前面的向量加法、矩阵转置和许多逐元素 Kernel 有一个舒服的特点:线程 i 读取自己的输入,写自己的输出,线程之间几乎互不干扰。只要索引和边界正确,并行结果通常容易推理。

直方图完全不同。假设输入中许多元素都属于 Bin 零,那么大量线程会同时执行:

cpp 复制代码
histogram[0] += 1;

这行代码在单线程程序里毫无问题,在并行程序里却包含三个步骤:

  1. 从内存读取旧值;
  2. 在寄存器中加一;
  3. 把新值写回内存。

如果线程甲和线程乙都在写回之前读到了零,它们都会计算一,最后无论谁覆盖谁,内存里都可能只留下一个一。两次有效输入只被计数一次,这叫丢失更新。

初学者有时会把它看成"概率很小的边缘问题"。实际上,线程越多、热点越集中,错误越容易出现。更危险的是,数据竞争的结果没有可靠保证:今天运行得到错误,明天换架构、换编译选项、换输入后可能偶尔得到正确数字。一次正确不能证明程序没有竞争。

原子操作把一个指定内存对象上的读---修改---写作为不可分割更新提交。它首先解决"结果会不会丢",但不能消除所有线程争抢同一地址的物理事实。当一百万个线程都要向同一个计数器加一时,一百万次贡献都必须被保留;硬件可以优化执行路径,却不能把语义上必要的更新直接丢弃。

所以本篇一直分开两个问题:

  • 正确性问题:同一对象的并发更新是否会丢失?
  • 性能问题:正确更新集中到什么层级、多少地址、产生多大争用?

配套目录如下:

text 复制代码
cuda-notes/blogs/
├─ CUDA编程实战12_原子操作与高性能直方图_从正确累加到低冲突并行更新.md
├─ assets/12/
│  ├─ 01-hero-atomic-histogram.png
│  ├─ 02-race-vs-atomic.svg
│  ├─ 03-atomic-scope-order.svg
│  ├─ 04-contention-distributions.svg
│  ├─ 05-histogram-privatization.svg
│  ├─ 06-four-strategies.svg
│  └─ 07-optimization-decision.svg
└─ code/12/
   ├─ CMakeLists.txt
   ├─ atomic_counter.cu
   └─ histogram_bench.cu

本文所有块公式仍采用 Typora 兼容的严格三行格式,第一行与第三行只有 $$,完整公式只占中间一行。


二、先定义问题:直方图的固定工作量与正确结果

设输入共有 N N N 个元素,每个元素 x i x_i xi 取值范围为零到 B − 1 B-1 B−1。第 b b b 个 Bin 的正确计数定义为:

H b = ∑ i = 0 N − 1 x i = b H_b=\sum_{i=0}^{N-1}x_i=b Hb=i=0∑N−1xi=b

方括号表示条件成立时贡献一,否则贡献零。所有 Bin 之和必须等于输入元素数量:

∑ b = 0 B − 1 H b = N \sum_{b=0}^{B-1}H_b=N b=0∑B−1Hb=N

这两个关系给了我们两层正确性检查:

  1. 总和检查:所有 Bin 相加是否等于 N N N;
  2. 逐 Bin 检查:每个 H b H_b Hb 是否与 CPU 参考完全一致。

只有总和正确仍不够。如果一个线程把 Bin 三错加到 Bin 四,另一个线程又发生相反错误,总和可能保持不变,但分布已经错误。本文程序因此逐 Bin 对照 CPU 参考,并在第一个不一致位置打印实际值和期望值。

直方图还有一个对性能至关重要、却常被基准忽略的变量:输入分布。定义最大 Bin 占比:

p m a x = max ⁡ b H b N p_\mathrm{max}=\frac{\max_b H_b}{N} pmax=NmaxbHb

均匀分布时, p m a x p_\mathrm{max} pmax 大约接近 1 / B 1/B 1/B;高热点分布中可能接近零点九;单一热点中等于一。它们使用相同 Kernel、相同 N N N 和相同 Bin 数,却会产生完全不同的原子竞争。


三、先建立正确基线:原子操作解决什么、竞争从哪里来

考虑两个线程同时把计数器从零增加到二。理想顺序是:

text 复制代码
线程甲:读取 0,计算 1,写回 1
线程乙:读取 1,计算 2,写回 2

但普通代码允许这样交错:

text 复制代码
线程甲:读取 0
线程乙:读取 0
线程甲:计算 1
线程乙:计算 1
线程甲:写回 1
线程乙:写回 1

两次更新只留下了一次。表达式看起来只有一行,并不代表生成的内存读改写不可分割。volatile 也不能修复它。volatile 主要影响编译器对访问的处理与可见访问行为,不把普通读改写升级为跨线程原子事务。

本文第一个 Kernel 故意保留错误:

cpp 复制代码
__global__ void racy_counter_kernel(unsigned int* counter,
                                    std::size_t count) {
    const std::size_t index =
        static_cast<std::size_t>(blockIdx.x) * blockDim.x + threadIdx.x;
    if (index < count) {
        volatile unsigned int* visible_counter = counter;
        const unsigned int old_value = *visible_counter;
        *visible_counter = old_value + 1U;
    }
}

它不是给生产代码使用,而是用于建立直觉。程序不会要求错误版本必须得到某个固定错误值,因为数据竞争没有这种保证;它只把结果标为:

text 复制代码
LOST UPDATES OBSERVED

或者:

text 复制代码
INCONCLUSIVE (a race may appear correct)

后者的意思是"这一次碰巧正确,不能证明无竞争"。整体程序的 PASS 只取决于三个正确版本。


3.1 atomicAdd 到底保证什么

最直接修复是:

cpp 复制代码
atomicAdd(counter, 1U);

概念上,它会以原子方式读取旧值、加上参数、写回新值,并返回更新前的旧值。若只关心计数,返回值可以不用;若实现票号分配器或并发队列位置,旧值可作为该线程获得的唯一位置。

传统 CUDA 原子 API 支持的类型与操作会随计算能力和 Toolkit 版本变化。本文使用最普遍的三十二位无符号整数 atomicAdd,可以在本机 CUDA 11.6 与 sm_75 上直接构建。浮点、双精度、半精度、向量或更宽类型不能脱离目标架构假定支持,应该查阅与你 Toolkit 对应的 CUDA Programming Guide

当前官方指南强调,传统原子函数的语义要同时考虑操作对象、线程作用域与内存顺序。对于常见无后缀 atomicAdd,其原子可见范围通常是 Device;_block_system 变体在满足架构和地址条件时提供其他作用域。更新共享内存地址时,参与者天然位于同一 Block 的共享区域。

更重要的是:传统原子读改写并不自动把你对其他地址的普通读写组成一次事务,也不应该被当作万能内存栅栏。只做计数时,我们需要的是同一个计数器更新不丢失;如果还要先写一份数据,再用原子标志通知其他线程读取,就需要匹配的作用域、同步与 acquire/release 语义。

本文直方图只共享计数器,不构造跨地址消息传递,因此重点是原子性与竞争。理解这条边界,能避免把一个简单计数例子错误推广成完整并发通信方案。


3.2 原子操作为什么会竞争

设所有线程一共执行 A A A 次原子更新。如果每次更新均匀落在大量独立地址上,硬件可以并行处理许多请求;如果它们集中到同一地址,该地址上的贡献必须保持一个一致原子顺序,执行并行度会下降。

最简单全局直方图每个元素执行一次全局原子,因此:

A g l o b a l = N A_\mathrm{global}=N Aglobal=N

原子次数相同,并不意味着成本相同。均匀的二百五十六 Bin 与单一 Bin 都执行 N N N 次 atomicAdd,但后者把所有请求集中到一个地址。性能模型还需要分布、缓存层级、硬件原子吞吐、Warp 请求形态和其他指令。

这也是为什么只用均匀随机输入测试直方图很危险。生产数据可能在背景颜色、默认类别、零值、空节点或常见标签上形成强热点。均匀基准会把更新摊开,让一个在真实数据上严重退化的算法看起来非常漂亮。

本文提供三种固定分布:

  • uniform:伪随机值均匀映射到所有 Bin;
  • hot:约九成元素进入 Bin 零,其余进入其他 Bin;
  • single:全部元素进入 Bin 零。

它们分别代表常规、真实热点与压力测试。优化结论至少要说明在哪种分布下成立。


四、开始前准备:构建与运行配套程序

进入目录:

powershell 复制代码
cd cuda-notes\blogs\code\12

配置并构建:

powershell 复制代码
cmake -S . -B build -DCMAKE_CUDA_ARCHITECTURES=75
cmake --build build --config Release -j 4

75 对应本文 RTX 2060。请为自己的 GPU 设置正确目标架构。代码在 Release 配置中使用 -O3,并添加 -lineinfo 方便第十一篇介绍的 Nsight Compute Source 关联。

完整源文件:

Windows 可执行文件通常位于:

text 复制代码
build\Release\atomic_counter.exe
build\Release\histogram_bench.exe

先直接运行:

powershell 复制代码
.\build\Release\atomic_counter.exe
.\build\Release\histogram_bench.exe uniform
.\build\Release\histogram_bench.exe hot
.\build\Release\histogram_bench.exe single

所有正确策略最后都应输出 PASS。普通运行失败时,先处理驱动、架构、参数、内存或代码错误,不要立即启动 Profiler。

本机验证环境:

text 复制代码
Windows 10
Visual Studio 2022 / MSVC 19.34
CUDA Toolkit 11.6
GeForce RTX 2060
Release / sm_75

五、Demo 1:用四种计数方法看见正确性与争用

atomic_counter.cu 比较:

  1. 错误普通读改写;
  2. 每线程一次全局原子;
  3. 每 Warp 聚合后一次全局原子;
  4. 每 Block 在共享内存聚合,再一次全局原子。

默认每轮有一千六百七十七万余线程,重复五轮,正确累计结果是八千三百八十八万余。

本机一次实测:

text 复制代码
racy ++
value=2932
expected=83886080
average=15.4555 ms
LOST UPDATES OBSERVED

global atomic
value=83886080
average=0.5673 ms
PASS

warp aggregated
value=83886080
average=0.5674 ms
PASS

block aggregated
value=83886080
average=0.3270 ms
PASS

这个结果有三个重要启示。

第一,错误版本丢失了绝大多数更新。它的输出不能用于业务。

第二,错误版本并没有更快。本机中普通 volatile 读写形成了极差访问行为,耗时反而远高于硬件原子。删除原子不是可靠优化方法,既可能错误,也可能更慢。

第三,手写 Warp 聚合在这台设备和这个简单计数上几乎没有胜过全局原子。现代架构、编译器和原子路径可能已经对同 Warp 同地址更新做得很好,而手写聚合还增加了 Ballot、Popcount 与控制逻辑。优化技巧必须测量,不能因为理论原子次数下降就宣布胜利。

Block 聚合本机更快,但这也不是跨设备保证。后续会拆解它支付的共享原子、同步与资源成本。


5.1 Warp 聚合怎样保证尾部正确

Warp 聚合的想法是:同一 Warp 中所有有效线程不再各做一次全局原子,而由一个 Leader 把有效 Lane 数量一次加入:

cpp 复制代码
__global__ void warp_aggregated_counter_kernel(unsigned int* counter,
                                               std::size_t count) {
    const std::size_t index =
        static_cast<std::size_t>(blockIdx.x) * blockDim.x + threadIdx.x;
    const bool valid = index < count;

    const unsigned int valid_mask =
        __ballot_sync(0xffffffffU, valid);

    if (!valid) {
        return;
    }

    const int lane = static_cast<int>(threadIdx.x & 31U);
    const int leader = __ffs(static_cast<int>(valid_mask)) - 1;

    if (lane == leader) {
        atomicAdd(counter,
                  static_cast<unsigned int>(__popc(valid_mask)));
    }
}

所有 Lane 先参与 __ballot_sync,生成有效线程掩码。__popc 统计有效位数量,__ffs 找到第一个有效 Lane 作为 Leader。最后一个 Warp 即使不足三十二个有效元素,也只加入真实数量。

如果简单写成:

cpp 复制代码
if (lane == 0) {
    atomicAdd(counter, 32);
}

非整块尾部就会多计数,而且当 Lane 零无效、其他 Lane 有效的复杂掩码场景也可能漏更新。掩码是 Warp 原语正确性的组成部分,不是装饰。

理论上,全局原子次数从每元素一次降到大约每 Warp 一次:

A w a r p ≈ ⌈ N 32 ⌉ A_\mathrm{warp}\approx\left\lceil\frac{N}{32}\right\rceil Awarp≈⌈32N⌉

但总时间还包含投票、位计数、Leader 选择、分支和原子执行,因此实际加速必须测量。


5.2 Block 聚合把全局竞争缩小到网格块数量

Block 版本使用一个共享计数器:

cpp 复制代码
__global__ void block_aggregated_counter_kernel(
    unsigned int* counter,
    std::size_t count) {
    __shared__ unsigned int block_counter;

    if (threadIdx.x == 0) {
        block_counter = 0U;
    }
    __syncthreads();

    const std::size_t index =
        static_cast<std::size_t>(blockIdx.x) * blockDim.x + threadIdx.x;

    if (index < count) {
        atomicAdd(&block_counter, 1U);
    }
    __syncthreads();

    if (threadIdx.x == 0 && block_counter != 0U) {
        atomicAdd(counter, block_counter);
    }
}

第一次 __syncthreads 保证所有线程看到清零后的共享计数器;第二次保证所有共享原子完成后,线程零才读取并提交。缺少任意一次都可能产生竞争。

全局原子次数最多约等于实际启动 Block 数:

A b l o c k ≈ G A_\mathrm{block}\approx G Ablock≈G

其中 G G G 是网格 Block 数。但每个有效线程仍执行一次共享内存原子,且同 Block 线程争抢同一共享地址。它不是消灭竞争,而是把大量设备范围竞争分割成多个 Block 局部竞争,最后少量归并。

这种层级化思想正是高性能直方图的基础。


六、Demo 2:从单计数器推广到高性能直方图

最直接直方图 Kernel 是:

cpp 复制代码
__global__ void histogram_global_atomic_kernel(
    const std::uint32_t* input,
    std::size_t count,
    unsigned int* histogram) {
    const std::size_t start =
        static_cast<std::size_t>(blockIdx.x) * blockDim.x + threadIdx.x;
    const std::size_t stride =
        static_cast<std::size_t>(blockDim.x) * gridDim.x;

    for (std::size_t index = start; index < count; index += stride) {
        atomicAdd(&histogram[input[index]], 1U);
    }
}

代码使用 Grid-stride 循环,让有限网格反复处理大输入。本文把 Block 数限制在 SM 数量的若干倍,而不是为每二百五十六个元素创建一个 Block。这样 Partial 直方图的额外内存可控,也便于不同策略使用相同网格。

全局原子版本具有很强的基线价值:

  • 代码短,容易审核;
  • 没有共享内存初始化与同步;
  • 没有 Partial 缓冲;
  • 对 Bin 很多、更新较分散的输入可能已经足够;
  • 可以帮助衡量私有化到底省了多少。

不要因为知道"共享内存快",就拒绝建立全局原子基线。私有化有固定开销,小输入或低冲突时可能不值得。


6.1 三种输入生成方式

程序用固定线性同余状态生成可复现输入:

cpp 复制代码
state = state * 1664525U + 1013904223U;

均匀模式:

cpp 复制代码
input[index] = state % bins;

热点模式:

cpp 复制代码
input[index] =
    (state % 10U < 9U)
        ? 0U
        : 1U + (state % static_cast<std::uint32_t>(bins - 1));

单一热点:

cpp 复制代码
input[index] = 0U;

固定生成方式让各策略处理完全相同输入。基准比较中,如果每次都重新随机生成且分布有波动,原子冲突差异可能被输入差异污染。

真实项目应保存具有代表性的输入集,而不是只依赖合成数据。合成均匀用于观察基础吞吐,高热点用于复现生产倾斜,单一热点用于压力测试最坏竞争。三者回答的问题不同。


6.2 Benchmark 如何避免把错误版本当成快版本

histogram_bench 每个策略都执行:

  1. 在 Profiler 范围外预热;
  2. 每轮清零输出直方图;
  3. 启动完整策略;
  4. 用 CUDA Event 测量多轮平均;
  5. 复制最终结果;
  6. 与同一份 CPU 参考逐 Bin 比较。

每轮清零被包含在测量区间中,因为真实重复请求通常也需要干净输出。直方图只有一千零二十四字节时,清零开销很小;Bin 数变大时,它会成为真实成本。若只想研究 Kernel 内核,可以单独测量,但必须明确指标边界。

错误计数器版本没有被放进性能冠军排名。它丢失更新,相当于少做了绝大多数工作,和正确版本没有可比性。任何性能对比都必须满足:

text 复制代码
相同输入
相同输出语义
相同正确性标准
相同测量边界

如果优化版让某些线程跳过更新,它当然可能更快,但那是工作量变化,不是优化。


6.3 本机三种分布的实测结果

默认参数:

text 复制代码
elements = 16777216
bins = 256
blocks = 240
iterations = 10

均匀输入:

text 复制代码
global atomic           9.7373 ms  PASS
block shared private    0.3514 ms  PASS
warp private            0.4518 ms  PASS
partial two-phase       0.3671 ms  PASS

高热点输入:

text 复制代码
global atomic          10.5048 ms  PASS
block shared private    0.5545 ms  PASS
warp private            0.5875 ms  PASS
partial two-phase       0.7878 ms  PASS

单一热点输入:

text 复制代码
global atomic          10.5428 ms  PASS
block shared private    0.8382 ms  PASS
warp private            0.6064 ms  PASS
partial two-phase       0.9443 ms  PASS

这些结果只代表本文环境,但趋势很有教学价值:

  • 全局原子在三种分布下都明显慢于局部私有化;
  • Block 共享私有化在均匀输入最快;
  • 热点增强后,共享直方图内部竞争加重,时间上升;
  • 单一热点中,Warp 私有化超过 Block 单副本,因为它把跨 Warp 竞争拆开;
  • 两阶段没有全局原子,却不是最快,因为写 Partial 与第二阶段读取归并也有成本;
  • 没有一个策略在所有分布上固定获胜。

优化结论因此必须写成"在什么输入、Bin 数、网格与设备上",不能只写"共享内存版本快二十七倍"。


6.4 Block 共享内存私有化的完整结构

核心思路是每个 Block 建一份局部直方图。Block 内线程先更新自己的共享副本,完成后再把局部结果加入最终全局直方图。

初始化:

cpp 复制代码
extern __shared__ unsigned int local_histogram[];

for (int bin = threadIdx.x; bin < bins; bin += blockDim.x) {
    local_histogram[bin] = 0U;
}
__syncthreads();

线程协作清零所有 Bin。不能假设线程数等于 Bin 数,因此使用跨步循环。第一次同步确保没有线程在其他线程尚未清零时开始累加。

局部累加:

cpp 复制代码
const std::size_t start =
    static_cast<std::size_t>(blockIdx.x) * blockDim.x + threadIdx.x;
const std::size_t stride =
    static_cast<std::size_t>(blockDim.x) * gridDim.x;

for (std::size_t index = start; index < count; index += stride) {
    atomicAdd(&local_histogram[input[index]], 1U);
}
__syncthreads();

第二次同步保证所有局部原子完成。这里仍然需要原子:同一 Block 的多个线程可能同时更新同一个共享 Bin。把数组放进共享内存不会自动消除数据竞争。

归并:

cpp 复制代码
for (int bin = threadIdx.x; bin < bins; bin += blockDim.x) {
    const unsigned int value = local_histogram[bin];
    if (value != 0U) {
        atomicAdd(&histogram[bin], value);
    }
}

全局原子上界从每元素一次降为每 Block 每 Bin 至多一次:

A m e r g e ≤ G × B A_\mathrm{merge}\leq G\times B Amerge≤G×B

实际只提交非零局部 Bin,分布稀疏时会更少。默认实验中 N N N 约一千六百七十万, G G G 为二百四十, B B B 为二百五十六。每元素全局原子约一千六百七十万次,而归并上界约六万余次,数量级差异巨大。

代价也很明确:

  • 每 Block 需要 B B B 个计数器的共享内存;
  • 每次启动要协作清零;
  • 需要两次 Block 同步;
  • Block 内仍存在共享原子竞争;
  • 最后需要全局归并;
  • 共享内存使用可能降低 Occupancy。

私有化是用空间、同步与额外阶段换取更小竞争范围。


6.5 为什么共享内存仍然可能出现热点

共享内存位于 SM,带宽和延迟通常优于全局内存,但原子语义没有消失。同一 Warp 的三十二个 Lane 如果都执行:

cpp 复制代码
atomicAdd(&local_histogram[0], 1U);

三十二个贡献都必须保留。硬件可能聚合或优化请求,但逻辑上不能像普通广播读取一样只服务一次。热点越集中,共享原子路径越难并行处理独立地址。

共享内存还分成 Bank。对普通不同地址访问,多个线程映射同一 Bank 会形成 Bank Conflict;连续三十二位字通常分布到连续 Bank。可是直方图冲突比普通 Bank Conflict 更强:线程不是访问同 Bank 的不同字,而是对同一个字做原子读改写。即使地址映射讨论正确,也不能用"同地址广播"规则解释原子写入。

本机结果显示:

text 复制代码
Block shared private
uniform  0.3514 ms
hot      0.5545 ms
single   0.8382 ms

代码、Grid、Block 与 Bin 数都没有变化,只有输入分布变化。时间随热点增强而上升,是局部竞争仍存在的直接现象。后续 Profiler 应进一步验证共享原子指令、Warp 等待与吞吐是否支持这一解释。


6.6 Warp 私有化为什么可能缓解极端热点

Block 共享版本中,一个 Block 的八个 Warp 共用一份直方图。Warp 私有版本为每个 Warp 分配一份:

cpp 复制代码
extern __shared__ unsigned int warp_histograms[];

const int warp = static_cast<int>(threadIdx.x >> 5);
unsigned int* warp_histogram =
    warp_histograms + warp * bins;

每个线程只更新所在 Warp 的副本:

cpp 复制代码
atomicAdd(&warp_histogram[input[index]], 1U);

局部完成后,由 Block 内线程把八份副本按 Bin 相加,再少量提交到全局直方图。

这不会消除同一 Warp 内的热点,三十二个 Lane 仍可能争抢本 Warp 的 Bin 零;但不同 Warp 不再争抢同一共享地址,竞争范围缩小。单一热点中,本机 Warp 私有约零点六零六毫秒,优于 Block 单副本的零点八三八毫秒。

共享内存需求却扩大为:

M w a r p = W b l o c k × B × sizeof ⁡ ( c o u n t e r ) M_\mathrm{warp}=W_\mathrm{block}\times B\times\operatorname{sizeof}(\mathrm{counter}) Mwarp=Wblock×B×sizeof(counter)

默认八个 Warp、二百五十六 Bin、四字节计数器,需要八千一百九十二字节,仍在设备限制内。如果 Bin 增加到二千零四十八,需要六万五千五百三十六字节,超过本机每 Block 默认共享内存上限,程序会跳过 Warp 私有策略并明确输出:

text 复制代码
warp private SKIPPED (shared memory limit)

这比让 Kernel 因资源配置失败更友好,也提醒我们:副本层级越细,空间成本越大。


6.7 两阶段 Partial 为什么能做到零全局原子

第一阶段仍为每个 Block 建立共享直方图,但完成后不原子加入最终结果,而是写入自己独占的全局 Partial 区域:

cpp 复制代码
unsigned int* block_histogram =
    partial_histograms +
    static_cast<std::size_t>(blockIdx.x) * bins;

for (int bin = threadIdx.x; bin < bins; bin += blockDim.x) {
    block_histogram[bin] = local_histogram[bin];
}

每个 Block 写不同地址,因此不需要全局原子。第二个 Kernel 为每个 Bin 遍历所有 Block Partial:

cpp 复制代码
__global__ void histogram_reduce_partials_kernel(
    const unsigned int* partial_histograms,
    unsigned int* histogram,
    int blocks,
    int bins) {
    const int bin = blockIdx.x * blockDim.x + threadIdx.x;
    if (bin >= bins) {
        return;
    }

    unsigned long long sum = 0ULL;
    for (int block = 0; block < blocks; ++block) {
        sum += partial_histograms[
            static_cast<std::size_t>(block) * bins + bin];
    }
    histogram[bin] = static_cast<unsigned int>(sum);
}

额外全局存储为:

M p a r t i a l = G × B × sizeof ⁡ ( c o u n t e r ) M_\mathrm{partial}=G\times B\times\operatorname{sizeof}(\mathrm{counter}) Mpartial=G×B×sizeof(counter)

默认约二百四十乘二百五十六乘四字节,不到四分之一兆字节,成本不大;当 Grid 与 Bin 都很大时,Partial 缓冲会迅速膨胀。

两阶段策略消除了全局原子归并,却增加:

  • 第一阶段写出所有 Partial Bin;
  • 第二阶段重新读取这些 Partial;
  • 第二个 Kernel 的启动;
  • 更多全局内存流量;
  • Partial 缓冲分配与生命周期管理。

所以本机均匀输入中,它约零点三六七毫秒,接近共享原子归并但没有胜过;单一热点中也不是最快。消灭某类指令不等于消灭总成本。


6.8 四种策略的成本放在一张表里

策略 每元素更新位置 全局原子数量 局部竞争 额外空间
全局原子 全局直方图 N N N 全设备 最少
Block 私有 Block 共享副本 至多 G B G B GB Block 内 每 Block 共享 B B B
Warp 私有 Warp 共享副本 至多 G B G B GB Warp 内 每 Block 共享 W B W B WB
两阶段 Block 共享副本 Block 内 全局 G B G B GB Partial

选择时不要只问"原子次数谁最少",还要问:

  • Bin 数是否能装进共享内存?
  • 输入分布是否形成强热点?
  • 网格 Block 数是多少?
  • 初始化与归并占总时间多少?
  • 共享内存是否限制同时驻留 Block?
  • 第二阶段全局流量是否超过原子归并成本?
  • 小输入中固定开销是否主导?
  • 当前 GPU 的全局与共享原子路径能力如何?

一个很实用的起点是:先写全局原子正确基线;确认它确实是热点后,测试 Block 私有化;只有证据显示 Block 内热点仍重,才测试 Warp 私有;若最终全局原子归并仍受限,再比较 Partial 两阶段。


6.9 Bin 数怎样改变结论

Bin 很少时,每个地址承受更多更新,竞争通常更强;局部直方图占用小,私有化容易。Bin 很多时,更新可能分散,全局原子竞争下降,但共享副本与 Partial 空间增加。

用近似均匀分布时,每个 Bin 平均更新量为:

H ˉ = N B \bar{H}=\frac{N}{B} Hˉ=BN

但平均值会掩盖热点。两个数据集可以有相同 N N N 与 B B B,一个接近均匀,另一个九成集中到 Bin 零,它们的最大竞争完全不同。

你可以直接实验:

powershell 复制代码
.\build\Release\histogram_bench.exe uniform 16777216 16 10
.\build\Release\histogram_bench.exe uniform 16777216 256 10
.\build\Release\histogram_bench.exe uniform 16777216 1024 10
.\build\Release\histogram_bench.exe hot 16777216 16 10
.\build\Release\histogram_bench.exe hot 16777216 256 10
.\build\Release\histogram_bench.exe hot 16777216 1024 10

记录每种策略时间、共享内存字节与是否跳过 Warp 私有。不要从一条曲线外推所有 Bin 数。

对于超大 Bin,常见方向包括:

  • 分段处理部分 Bin;
  • 使用稀疏局部结构;
  • 先排序再 Reduce By Key;
  • 让每 Block 只私有化高频区域;
  • 使用哈希或分层表;
  • 直接使用 CUB、Thrust 或领域库的成熟原语。

共享内存私有化不是无限扩展方案。


6.10 网格大小为何也会改变竞争

本文把 Block 数限制为:

cpp 复制代码
blocks = min(required_blocks, multiProcessorCount * 8);

Grid-stride 循环让这些 Block 覆盖全部输入。较少 Block 有几个影响:

  • Partial 缓冲更小;
  • 最终全局归并原子更少;
  • 每 Block 处理更多元素,局部热点更新更多;
  • 可调度 Block 数可能减少并行余量。

较多 Block 则相反:

  • 更多局部副本并行;
  • 每个副本处理元素更少;
  • 全局归并次数与 Partial 空间增加;
  • 启动和调度规模更大。

Block 私有版本的全局归并上界 G B G B GB 直接随网格增长。两阶段额外空间也随 G B G B GB 增长。网格不只是占用率参数,还改变算法成本。

合理调参应该固定输入与正确性,扫描:

text 复制代码
SM 数 × 2
SM 数 × 4
SM 数 × 8
SM 数 × 16

记录端到端时间、Kernel 时间、全局原子归并、共享原子竞争、活跃 Block 与 Occupancy。不要默认"Block 越多越能吃满 GPU"。


6.11 共享内存与 Occupancy 的交换

假设每 Block 使用共享内存 M b l o c k M_\mathrm{block} Mblock,每个 SM 可用于这些 Block 的共享容量为 M S M M_\mathrm{SM} MSM,仅从共享内存角度看,驻留 Block 上界约为:

R s h a r e d ≤ ⌊ M S M M b l o c k ⌋ R_\mathrm{shared}\leq\left\lfloor\frac{M_\mathrm{SM}}{M_\mathrm{block}}\right\rfloor Rshared≤⌊MblockMSM⌋

实际还受寄存器、线程、Warp 与架构 Block 上限共同限制。Warp 私有把共享内存扩大八倍,可能降低每 SM 同时驻留 Block 数;如果原本需要更多 Warp 隐藏延迟,局部竞争下降的收益可能被 Occupancy 下降抵消。

这说明为什么要用第十一篇的 Nsight Compute:

  • Launch Statistics 确认动态共享内存;
  • Occupancy 确认理论与实际活跃 Warp;
  • Scheduler Statistics 查看是否缺少 Eligible Warp;
  • Warp State 查看共享原子相关等待是否减少;
  • Speed Of Light 查看计算与内存方向;
  • Source/SASS 确认原子指令与循环归并成本。

不要因为 Warp 私有"副本更多、冲突更少"就跳过资源检查。


6.12 小输入为什么经常不值得复杂私有化

在一千零三个元素、七个 Bin、高热点输入上,本机得到:

text 复制代码
global atomic           0.0038 ms
block shared private    0.0039 ms
warp private            0.0036 ms
partial two-phase       0.0050 ms

四种方法几乎处于微秒级,复杂策略的清零、同步、归并和第二 Kernel 启动足以抵消竞争收益。两阶段最慢也很合理,因为固定成本相对工作量太大。

小输入优化应该首先问:

  • 这个直方图能否与上下游 Kernel 融合?
  • 是否批量处理多个样本?
  • 是否由 CPU 完成反而更简单?
  • 是否启动开销占主导,可使用 CUDA Graph?
  • 用户延迟是否真的受这几微秒影响?

把一个微秒级 Kernel 写成难以维护的复杂模板,可能只提升实验室数字,不提升产品。


6.13 极端热点为什么要单独测试

单一热点不是为了模拟所有业务,而是用来暴露算法最坏竞争。它能回答:

  • 全局原子路径面对完全同地址更新会怎样?
  • Block 私有内部热点是否成为新瓶颈?
  • Warp 私有能否通过多副本分散跨 Warp 竞争?
  • 两阶段归并是否受全局原子影响,还是受局部阶段影响?

但不能把单一热点的冠军自动当作真实数据冠军。真实输入可能是多峰、长尾或随时间变化。至少保留:

text 复制代码
均匀合成
真实生产样本
受控高热点
单一热点压力测试

若真实分布每天变化,还应该监控 p m a x p_\mathrm{max} pmax、非零 Bin 数和熵等分布统计,让策略选择不依赖一次离线快照。


七、用 Profiler 证明策略为何有效:从 Systems 到 Compute

程序在预热后调用:

cpp 复制代码
cudaProfilerStart();

完成四种策略后调用:

cpp 复制代码
cudaProfilerStop();

可按第十一篇方法采集:

powershell 复制代码
New-Item -ItemType Directory -Force -Path reports | Out-Null

nsys profile `
  --sample=none `
  --cpuctxsw=none `
  --trace=cuda `
  --capture-range=cudaProfilerApi `
  --capture-range-end=stop `
  --force-overwrite=true `
  -o reports\histogram_hot `
  .\build\Release\histogram_bench.exe hot 16777216 256 10

在时间线中确认:

  1. 每种策略前是否出现输出 cudaMemsetAsync
  2. 全局原子策略有一个 Kernel;
  3. Block 与 Warp 私有各有一个 Kernel;
  4. Partial 策略有局部统计与归并两个 Kernel;
  5. 两阶段的第二 Kernel 启动与额外时间是否值得;
  6. Kernel 次数、平均值和稳定性是否符合程序迭代数;
  7. 端到端瓶颈是否真的在直方图,而不是输入传输或 Host 生成。

Systems 适合比较完整策略总窗口。不要只比较 Partial 第一阶段与单 Kernel 方案,否则漏掉第二阶段归并。


7.1 用 Nsight Compute 精确选择目标 Kernel

全局原子:

powershell 复制代码
ncu `
  --set detailed `
  --profile-from-start off `
  --kernel-name-base function `
  --kernel-name "regex:.*histogram_global_atomic_kernel.*" `
  --launch-count 1 `
  --force-overwrite `
  -o reports\global_atomic_hot `
  .\build\Release\histogram_bench.exe hot 16777216 256 2

Block 共享私有:

powershell 复制代码
ncu `
  --set detailed `
  --profile-from-start off `
  --kernel-name-base function `
  --kernel-name "regex:.*histogram_shared_private_kernel.*" `
  --launch-count 1 `
  --force-overwrite `
  -o reports\shared_private_hot `
  .\build\Release\histogram_bench.exe hot 16777216 256 2

Warp 私有:

powershell 复制代码
ncu `
  --set detailed `
  --profile-from-start off `
  --kernel-name-base function `
  --kernel-name "regex:.*histogram_warp_private_kernel.*" `
  --launch-count 1 `
  --force-overwrite `
  -o reports\warp_private_hot `
  .\build\Release\histogram_bench.exe hot 16777216 256 2

将迭代数降到二,减少未被过滤但仍由程序执行的工作。--launch-count 1 只收一个匹配实例。不同工具版本参数可能变化,先运行 ncu --help

若出现 ERR_NVGPUCTRPERM,这是性能计数器权限问题,不是直方图错误。按 NVIDIA 官方权限指南由管理员配置,不能擅自绕过组织安全策略。


7.2 Profiler 中应该找怎样的证据

不要期待一个叫"Atomic Contention"的万能百分比。不同架构和 Nsight Compute 版本会提供不同原始指标与规则。推荐组合以下证据:

7.2.1 先确认样本

检查 Kernel 名、输入、Grid、Block、动态共享内存、Duration 与启动实例。Warp 私有报告应显示比 Block 私有更高动态共享内存。

7.2.2 比较指令与内存路径

全局原子版本应出现大量全局原子更新;共享版本把主要更新移到共享地址,仅归并阶段写全局原子;Partial 第一阶段使用共享原子与普通全局写。

不要只数源码中的 atomicAdd,Grid-stride 循环让同一线程执行多次。使用 Source、PTX、SASS 与指令统计理解动态执行。

7.2.3 比较 Warp 调度

热点加剧时,观察 Eligible Warp、发射效率与相关等待是否变化。原子竞争可能表现为内存依赖、管线节流或其他架构相关状态,不能预先指定唯一 Stall 名称。

7.2.4 比较 Occupancy

Warp 私有减少竞争,但共享内存更多。确认活跃 Block 与 Warp 是否下降,并判断调度器是否因此缺少可执行工作。

7.2.5 回到无 Profiler 时间

Compute 可能重放 Kernel。最终加速必须使用无 Profiler 的 CUDA Event 或端到端基准;报告负责解释,不负责替代产品性能测量。


7.3 为什么手写 Warp 聚合没有自动加速

从原子次数看,每线程全局原子约执行 N N N 次,Warp 聚合约执行 N / 32 N/32 N/32 次,直觉上应该大幅加速。可本机两者都是约零点五六七毫秒。

出现这种结果时,不要说"理论错了",也不要说"测试一定有问题"。理论只统计了某一种操作数量,没有包含全部成本和硬件实现。

可能因素包括:

  • 同一 Warp、同一地址的原子更新可能被硬件或编译路径高效处理;
  • 全局计数器位于缓存与原子单元容易处理的位置;
  • Warp 聚合增加了 Ballot、Popcount、寻找 Leader 和分支;
  • 计数 Kernel 的其他成本很少,新增指令占比变得明显;
  • 两个版本都已经短到受固定执行或调度因素影响;
  • 当前输入所有线程都更新同一地址,恰好触发某种优化路径;
  • 不同 GPU 架构的原子吞吐与聚合能力不同。

下一步不是删除 Warp 聚合,而是设计能区分因素的实验:

  1. 比较单计数器与多个分散计数器;
  2. 比较同 Warp 同地址与同 Warp 多地址;
  3. 查看 SASS 是否生成预期原子指令;
  4. 比较动态原子指令、Warp 指令与 Duration;
  5. 在另一代 GPU 上复测;
  6. 增加每线程其他计算,观察聚合开销占比;
  7. 测试只有部分 Lane 有效的掩码。

这说明"减少源码中的原子调用"不是最终目标。最终目标是在相同语义下减少实际瓶颈。


7.4 atomicAdd 的返回值能做什么

atomicAdd(address, value) 返回更新前的旧值。直方图只需要副作用,没有使用返回值;许多并发结构却依赖它分配唯一位置。

例如多个线程向输出队列追加一个元素:

cpp 复制代码
unsigned int position = atomicAdd(queue_size, 1U);
queue[position] = my_value;

每个线程获得不同旧值,因此写不同位置。但这个例子仍有边界问题:

  • 队列容量是否足够?
  • 超出容量时怎样回滚或丢弃?
  • 其他线程何时可以读取 queue[position]
  • queue_size 的增加是否被错误当作"该位置数据已经发布"?
  • 作用域是否覆盖生产者与消费者?

如果消费者与生产者在不同 Block 并发通信,仅靠上面的计数原子不自动建立 queue[position] 写入与消费者读取之间的正确发布顺序。应使用经过设计的队列协议、匹配的 acquire/release 原子或成熟并发原语。

另一个用途是票号:

cpp 复制代码
unsigned int ticket = atomicAdd(next_ticket, 1U);

票号唯一,但获得票号的全局顺序不等于线程完成业务的顺序。线程拿到较小票号后可能被延迟,较大票号线程先完成。原子返回值提供唯一序号,不自动提供整个程序的串行执行。


7.5 atomicCAS 为什么是通用构件

CAS 表示 Compare And Swap。概念是:当地址当前值等于比较值时,写入新值;无论是否交换,都返回观察到的旧值。许多缺少直接硬件或 API 支持的原子更新可以用 CAS 循环构造:

cpp 复制代码
do {
    assumed = old;
    desired = transform(assumed);
    old = atomicCAS(address, assumed, desired);
} while (old != assumed);

如果其他线程在本线程计算期间修改了地址,CAS 失败,循环读取新状态并重试。它可以实现某些自定义状态转换、浮点变体或带条件的更新。

但 CAS 循环也会竞争。热点地址越繁忙,失败重试越多,每次失败都做了没有提交的工作。不要因为"CAS 可以实现任何原子操作"就用它替代已有高效 atomicAddatomicMin 或库原语。

还要注意浮点特殊值与位比较。官方旧版双精度 atomicAdd 的 CAS 示例使用整数位模式比较,避免浮点 NaN 的 NaN != NaN 让循环无法退出。自行实现底层原子时,数据表示、对齐、支持架构与内存模型都必须认真核对。

本文没有让初学者重写已有原子,而是希望你理解:CAS 提供机制,不替你解决竞争与协议设计。


7.6 原子作用域为什么影响正确性也影响成本

CUDA 线程层级包括线程、Block、Device,较新架构还可能包括 Cluster,并可与 System 范围交互。作用域描述哪些参与者处于原子一致与同步覆盖范围。

直观理解:

  • Thread 范围只涉及本线程,通常不是跨线程共享更新;
  • Block 范围覆盖同一 Block;
  • Device 范围覆盖同一 GPU;
  • System 范围可能覆盖 CPU 与其他设备,但需要满足平台和地址条件。

范围不能比实际通信参与者更窄。Block 零使用 Block 范围原子发布标志,Block 一读取同一标志,不构成正确跨 Block 同步;它们不在同一个 Block 作用域中。

范围也不应该无缘无故扩大。若计数器只在一个 Block 的共享内存中使用,Device 或 System 范围没有必要。选择最小且足够的范围,既表达意图,也给实现更多优化空间。

传统 atomicAddatomicAdd_blockatomicAdd_system 的具体可用性受类型、架构与 Toolkit 影响。现代 CUDA 还提供 cuda::atomiccuda::atomic_ref 等接近标准 C++ 的接口,可显式指定线程作用域和内存顺序。本文代码以 CUDA 11.6 兼容传统无符号整数原子为基线,不把新版 API 强行写进旧环境。

写跨平台库时,应在编译期检查 CUDA 版本和 __CUDA_ARCH__,为目标架构提供受支持实现,并在运行时或构建配置中明确最低能力。


7.7 原子性不等于内存顺序

假设生产者先写数据,再把标志设为一:

cpp 复制代码
payload[index] = value;
atomicExch(flag, 1);

消费者看到标志为一后读取数据:

cpp 复制代码
if (atomicAdd(flag, 0) == 1) {
    use(payload[index]);
}

仅凭"标志访问是原子的"不能在所有模型和作用域下证明消费者一定按预期看到 payload。原子性保证标志本身没有撕裂或丢失更新;payload 是另一个对象,它的写与读需要恰当的 happens-before 关系。

现代 C++ 风格原子用 release 写与 acquire 读表达发布和获取;CUDA 还要求双方作用域互相覆盖。复杂通信可能需要 Fence、Barrier 或 Cooperative Groups 协议。

直方图简单得多:每个 Bin 就是被更新的结果对象,Kernel 结束后 Host 通过 Stream 顺序和同步复制结果。我们没有在 Kernel 内用一个计数器发布另一份非原子数据,因此 relaxed 原子读改写足以完成计数任务。

把问题缩小到实际需要,是并发编程的重要能力。不要为简单计数引入不必要的强顺序,也不要把简单计数经验误用到消息发布。


八、选择策略:一张可执行的原子优化决策树

拿到共享更新任务时,可以按以下顺序:

8.1 第一步:确认是否真的需要共享更新

输出能否改成每线程独占,后续再归约?能否用前缀和分配位置?能否融合到已有归约?如果能消除共享写,通常比优化原子更根本。

8.2 第二步:建立原子正确基线

使用最简单且受支持的原子 API,逐元素或逐 Bin 对照 CPU 参考。不要从错误 ++ 版本开始做性能结论。

8.3 第三步:测量冲突分布

记录 Bin 数、最大 Bin 占比、非零 Bin、真实输入与压力输入。用 Systems 确认该阶段确实重要,用 Compute 判断原子路径是否限制执行。

8.4 第四步:低冲突时保留简单方案

如果 Bin 多、更新分散、全局原子已经很快,私有化固定成本可能不值得。简单代码更容易维护和扩展。

8.5 第五步:测试 Block 私有化

当局部直方图能装入共享内存时,它是常见优化起点。比较清零、同步、局部原子与归并总成本。

8.6 第六步:根据剩余热点继续细分

Block 内热点仍强时测试 Warp 私有;全局归并仍受限时测试 Partial 两阶段。每次只改变一个主要层级。

8.7 第七步:检查资源反作用

共享内存、寄存器、Partial 空间、第二 Kernel、缓存流量和 Occupancy 是否引入新瓶颈。

8.8 第八步:回归用户指标

正确结果、无 Profiler 时间和硬件证据必须一起验证。


九、工程边界:正确性、同步与内存布局

9.1 正确性验证:输出范围

先确保所有输入值都小于 Bin 数。本文输入生成器保证范围;真实程序必须检查映射和量化,防止越界原子写破坏其他内存。

9.2 正确性验证:总和

所有计数之和应等于输入数量。它能快速发现大量丢更新或遗漏。

9.3 正确性验证:逐 Bin

与 CPU 参考逐项相等。整数计数没有浮点容差问题,应要求完全相同。

9.4 正确性验证:多次与边界

测试:

text 复制代码
一个元素
少于一个 Warp
刚好一个 Warp
非整 Warp
刚好一个 Block
非整 Block
Bin 数为一
Bin 数不是二的幂
热点与均匀
空或非法输入的接口策略

本文命令:

powershell 复制代码
.\build\Release\atomic_counter.exe 1003 3
.\build\Release\histogram_bench.exe hot 1003 7 3
.\build\Release\histogram_bench.exe single 1003 1 3

全部通过。1003 既不整除三十二,也不整除二百五十六;七个 Bin 也不是二的幂,能检查尾部与通用取模路径。

程序参数要求正整数,因此没有把零长度当作普通测试输入。生产 API 若允许空输入,应在 Host 端直接返回全零直方图,避免启动零网格或无意义 Kernel。


9.5 计数器溢出是正确性的一部分

三十二位无符号计数最大约四十二亿。单次输入数量不超过它,并不代表多轮累计不会溢出。

若每轮计数 N N N,累计 K K K 轮,必须满足:

K × N ≤ 2 32 − 1 K\times N\leq 2^{32}-1 K×N≤232−1

atomic_counter 在分配和启动前检查这个条件。下面命令会被安全拒绝:

powershell 复制代码
.\build\Release\atomic_counter.exe 4294967295 2

输出说明累计超出三十二位范围,而不是让设备计数静默回绕。

直方图程序每轮都会清零,单个 Bin 最大不超过单轮元素数量,因此限制 count 不超过三十二位。若真实业务长期累计,应该:

  • 使用受目标架构支持的六十四位原子;
  • 分批归并到 Host 或更宽计数;
  • 定期检测与清零;
  • 明确饱和、回绕或报错语义;
  • 检查六十四位原子的性能与支持。

换成更宽类型增加存储和原子成本,但默默溢出不是可接受的性能优化。


9.6 三个最容易漏掉的同步点

共享直方图通常有三阶段:初始化、累加、归并。它们之间需要 Block 级同步。

9.6.1 初始化后同步

如果线程零刚清 Bin 零,另一个线程已经开始累加,而第三个线程稍后又执行 Bin 零清零,刚完成的计数会被擦掉。

9.6.2 累加后同步

如果负责归并 Bin 五的线程先读取局部值,而其他线程仍在增加 Bin 五,后续更新不会进入全局结果。

9.6.3 Kernel 之间顺序

Partial 第一阶段和归并第二阶段在同一 Stream 启动,因此 Stream 顺序保证归并在 Partial 完成后执行。若放到不同 Stream,必须使用 Event 或其他依赖明确协调,不能假设提交顺序就是跨 Stream 执行顺序。

__syncthreads() 要由 Block 中所有存活线程以一致控制流到达。不能这样写:

cpp 复制代码
if (index < count) {
    atomicAdd(...);
    __syncthreads();
}

最后一个 Block 中无效线程跳过同步,有效线程等待,可能造成未定义行为或死锁。本文把边界条件只包住原子更新,把同步放在所有线程共同路径。


9.7 动态共享内存的字节数必须匹配布局

Block 私有启动:

cpp 复制代码
const std::size_t shared_bytes =
    static_cast<std::size_t>(bins) * sizeof(unsigned int);

histogram_shared_private_kernel<<<
    blocks, kThreads, shared_bytes, stream>>>(...);

Warp 私有需要乘以每 Block Warp 数:

cpp 复制代码
const std::size_t warp_shared_bytes =
    shared_bytes * kWarpsPerBlock;

常见错误包括:

  • 忘记乘 sizeof(counter)
  • Warp 私有忘记乘 Warp 数;
  • Bin 数来自用户参数却没有检查设备上限;
  • Host 与 Kernel 使用不同计数器类型;
  • 动态共享内存布局没有满足对齐;
  • 只检查可配置上限,没有检查当前函数是否需要 Opt-in;
  • 增大共享内存后没有重新检查 Occupancy。

本文读取 cudaDeviceProp::sharedMemPerBlock,超出 Block 私有需求时直接拒绝;Warp 私有超出时只跳过该策略,其他可运行策略继续验证。

在支持更大 Opt-in 共享内存的架构上,可以使用相应函数属性请求更高动态共享容量,但这必须根据当前 Toolkit 和设备能力实现,不能假定所有机器可用。


9.8 为什么两阶段归并的访问布局也值得优化

Partial 数据按:

text 复制代码
partial[block][bin]

存储。第一阶段每个 Block 的线程连续写自己的 Bin,布局自然。第二阶段一个线程负责一个 Bin,沿 Block 维读取:

cpp 复制代码
partial[0][bin]
partial[1][bin]
partial[2][bin]
...

相邻线程负责相邻 Bin。对固定 Block,Warp 的线程读取连续 Bin;然后一起移动到下一个 Block,因此访问通常具有良好合并性。

如果把布局改成:

text 复制代码
partial[bin][block]

第二阶段每个线程沿连续 Block 读取可能更适合单线程局部性,但同一 Warp 在同一步访问不同 Bin 的相隔区域,合并行为会变化。第一阶段写布局也相应改变。

选择布局应同时考虑生产阶段与归并阶段。不要只让第二个 Kernel 看起来漂亮,却让第一个 Kernel 写出变得离散。

当 G G G 很大时,单线程循环所有 Partial 还可能形成长依赖与工作不均。可使用分层归约、每 Bin 多线程协作或库 Reduce。本文实现故意保持清晰,为中等 G B G B GB 提供可运行基线。


十、用实验矩阵连接真实业务与算法选择

不要只采一份 hot 报告。建立如下矩阵:

实验 分布 策略 主要问题
E1 uniform global 低热点全局原子基线
E2 hot global 热点怎样改变全局原子
E3 single global 最坏同地址路径
E4 uniform block 局部竞争低时的共享成本
E5 hot block Block 内热点证据
E6 single warp Warp 副本是否缓解跨 Warp 竞争
E7 hot partial 零全局原子是否被流量抵消

每份记录:

text 复制代码
Kernel Duration
Grid / Block
动态共享内存
理论与实际 Occupancy
原子或相关指令执行
L1 / L2 / DRAM 流量
Eligible Warp 与发射
主要 Warp 等待
源代码或 SASS 对应位置

然后写关系,不只写数值。例如:

从 E4 到 E5,动态共享内存和 Launch 配置不变,但 Duration 与共享更新相关等待上升;输入最大 Bin 占比从接近均匀变为约九成,支持 Block 内热点竞争增加的假设。

这种对照比单份报告中某个红色警告更有解释力。


10.1 原子优化在真实业务中的对应场景

10.1.1 图像与视频

亮度、颜色、方向梯度与标签直方图。自然图像可能在暗部、背景或特定颜色形成热点,不能只测均匀像素。

10.1.2 图与稀疏计算

统计节点度数、边桶、访问次数或稀疏矩阵行贡献。幂律图会让少数超级节点形成极端热点,平均度数无法反映竞争。

10.1.3 深度学习

类别计数、Embedding 梯度散射、稀疏更新、量化校准和 Top-K 辅助统计。热门 Token 或类别会导致严重倾斜。

10.1.4 粒子与网格

把粒子映射到空间 Cell,统计每个 Cell 数量。聚团现象会产生局部热点,随后通常还要用前缀和分配存储位置。

10.1.5 并发队列与内存分配

用原子计数分配槽位、工作项或内存偏移。这里不仅需要计数,还涉及容量、发布顺序和生命周期,比直方图协议复杂。

10.1.6 监控与统计

设备端事件计数、错误类别和采样统计。如果更新频率极高,可以先做线程、Warp 或 Block 局部累计,降低全局压力。

这些场景的共同点是"共享目标少于生产更新的线程数"。区别在于结果结构、分布和通信语义,因此不能机械复制一个直方图 Kernel。


10.2 什么时候应该改用排序、归约或库算法

原子直方图适合 Bin 范围已知、计数结构相对小的场景。以下情况值得考虑其他算法:

  • Bin 数非常大,局部直方图无法容纳;
  • 输入键稀疏,绝大多数 Bin 永远为空;
  • 热点极端且原子路径持续受限;
  • 需要同时聚合复杂值而不只是加一;
  • 键需要排序或后续本来就按键分组;
  • 稳定顺序或确定性有额外要求;
  • 生产项目需要跨架构稳定性能。

一种路线是先按键排序,再做 Reduce By Key。排序成本不小,但归约阶段不需要所有线程争抢少量地址,且排序结果可复用于后续按键处理。

另一种路线是使用 CUB 的 DeviceHistogram、DeviceReduce、DeviceRadixSort 或相关原语,或使用 Thrust 的 sortreduce_by_key。成熟库通常包含架构特化、临时存储查询和大量边界处理。

学习阶段手写本篇算法是为了理解冲突与层级,不代表生产项目应该永远维护自制版本。合理工程决策是:

  1. 用简单正确实现定义语义;
  2. 用库版本建立高质量性能参考;
  3. 只有业务结构特殊且证据充分时,维护自定义 Kernel;
  4. 对 CUDA 与架构升级做持续回归。

十一、避坑清单:十二种常见误区

误区一:volatile 可以代替原子

它不能让读改写不可分割。

误区二:错误版本偶尔正确,所以可以使用

一次运行不能证明无数据竞争。

误区三:删除原子一定更快

错误版本可能少做工作,也可能产生更差访问;本机错误版本反而更慢。

误区四:原子操作一定慢

低冲突或现代硬件上,简单原子可能已经足够快。先测量占比。

误区五:原子次数减少三十二倍,时间也减少三十二倍

聚合引入投票、分支、局部操作与固定成本,硬件原路径也可能优化。

误区六:共享内存天然没有竞争

共享地址仍需要原子,同地址热点仍要保留每次贡献。

误区七:Warp 私有一定比 Block 私有快

它使用更多共享内存,增加初始化与归并,并可能降低 Occupancy。

误区八:零全局原子一定最快

Partial 写出、第二 Kernel、二次读取和额外内存都要付费。

误区九:均匀随机输入足够代表生产

真实热点可能完全改变赢家。

误区十:总和正确就代表直方图正确

Bin 间错误可能互相抵消,必须逐 Bin 对照。

误区十一:atomicAdd 自动发布其他数据

原子对象正确不等于其他地址具有所需内存顺序。

误区十二:一台 GPU 的冠军可以固定写进库

架构、Bin、分布、网格和资源限制都会变化,需要回归与调度策略。


十二、编译与运行排障

找不到 atomicAdd 重载

检查指针类型、参数类型、目标架构与 Toolkit 支持。不要用强制转换掩盖类型不匹配。

Too many resources requested

线程数、寄存器或共享内存超过限制。打印动态共享字节,查看设备属性并降低 Bin 副本。

invalid configuration argument

检查 Block、Grid、动态共享内存、零长度输入和整数转换。每次 Kernel 后立即 cudaGetLastError

结果少于 CPU 参考

检查是否遗漏原子、是否缺少同步、尾部线程是否跳过、计数器是否溢出,以及每轮是否意外清零。

结果多于 CPU 参考

检查 Grid-stride 是否重复覆盖、Warp 聚合尾部是否固定加三十二、Partial 是否重复归并、输出是否在多轮间累计。

只有最后一个 Block 卡住

常见原因是 __syncthreads() 被放在 if (index < count) 内,导致分歧到达。

Warp 私有被跳过

这是共享内存容量保护,不是程序失败。减小 Bin、减少每 Block Warp、使用 Block 私有或分段。

Nsight Compute 无指标

检查 Kernel 过滤、Profiler API 范围、性能计数器权限和工具版本。程序普通运行通过与工具有权采集是两个独立条件。


十三、走向工程:跨架构复测与自动策略选择

至少记录:

text 复制代码
GPU 型号与计算能力
SM 数量
每 Block 与每 SM 共享内存
Warp 大小
驱动与 CUDA Toolkit
Nsight Systems 与 Compute
目标架构与编译选项
输入数量、Bin 与分布
Grid、Block、动态共享字节
普通运行统计
Profiler 报告

为什么重要?新 GPU 可能改进全局原子、共享原子、缓存、调度或编译器聚合,旧设备上的私有化收益会缩小;也可能共享内存更大,让 Warp 私有在更多 Bin 上可行。

手写 Warp 聚合在本文 RTX 2060 没有收益,并不证明它在所有 GPU 无用。正确表述是:

在本机、当前编译器和单计数器输入下,Warp 聚合与直接全局原子时间接近,因此没有足够证据保留更复杂版本。

移植到另一设备后重新测量,比背一条永久规则更可靠。


13.1 怎样做自动策略选择

如果应用输入规模和分布变化很大,可以在 Host 端选择策略,而不是强迫一个 Kernel 覆盖所有情况。

可能规则:

text 复制代码
输入很小 → 全局原子
Bin 很少且热点强 → Warp 或 Block 私有
Bin 中等且共享可容纳 → Block 私有
Bin 较大、冲突低 → 全局原子
全局归并仍受限 → Partial
Bin 超大或稀疏 → 排序 / Reduce By Key / 库

规则不能只根据 Bin 数。最好从真实样本建立阈值,并在设备启动时根据属性选择:

  • sharedMemPerBlock
  • SM 数;
  • 可用 Toolkit 与库;
  • 输入数量;
  • 估计热点;
  • 延迟还是吞吐目标。

若获取真实热点本身需要完整遍历,就可能得不偿失。可以使用上一批统计、业务元数据、小样本或固定场景配置。自动选择器也要纳入测试,防止阈值附近抖动。


13.2 一次完整优化记录应该怎样写

示例:

text 复制代码
目标:
降低 16777216 个元素、256 Bin 高热点直方图延迟。

正确基线:
global atomic,逐 Bin 与 CPU 完全一致。

Systems:
直方图 Kernel 占稳定阶段主要 GPU 时间。

Compute:
高热点相较均匀输入 Duration 上升;
原子相关更新集中,Eligible Warp 与等待证据发生变化。

假设:
设备范围同地址竞争是主要成本。

修改:
每 Block 使用共享直方图,最后每非零 Bin 一次全局原子。

预期:
全局原子动态数量大幅下降;
Kernel Duration 下降;
共享原子与同步成本上升但小于收益。

结果:
本机从约 10.50 ms 降到约 0.55 ms;
逐 Bin 完全一致;
Profiler 证据按预期从全局更新转移到共享阶段。

限制:
极端单热点下 Warp 私有更快;
Bin 过大时共享与 Occupancy 约束需要重新评估。

这样的记录包含问题、证据、假设、修改、预期、结果与边界。只贴一张"加速十九倍"柱状图不够。


十四、动手练习:把原子优化变成可复用能力

14.1 练习一:观察数据竞争

运行不同数量:

powershell 复制代码
.\build\Release\atomic_counter.exe 31 3
.\build\Release\atomic_counter.exe 32 3
.\build\Release\atomic_counter.exe 33 3
.\build\Release\atomic_counter.exe 1003 3
.\build\Release\atomic_counter.exe 16777216 5

记录错误版本是否丢更新。若某次碰巧正确,解释为什么仍不能使用。

14.2 练习二:扫描 Bin

对均匀和热点输入扫描十六、二百五十六、一千零二十四与二千零四十八 Bin。记录 Warp 私有何时被跳过。

14.3 练习三:扫描分布

修改输入生成器,让 Bin 零占比分别为零、一成、五成、九成和全部,绘制四种策略时间随 p m a x p_\mathrm{max} pmax 的变化。

14.4 练习四:扫描网格

SM * 8 改成若干候选,记录局部阶段、归并阶段、Partial 空间与总时间。

14.5 练习五:Profiler 证据

为全局原子与 Block 私有各采均匀、热点报告,写一张四格对照表。不要只报告 Duration。

14.6 练习六:实现混合策略

只为最热的若干 Bin 建共享副本,其他 Bin 直接全局原子。验证在长尾分布上是否比完整私有化节省共享内存。

完成这些练习后,你会真正理解"冲突分布决定策略",而不是只记住一段共享直方图模板。


十五、本篇验收记录

源代码已在本文环境重新配置并编译:

text 复制代码
atomic_counter:Release 构建通过
histogram_bench:Release 构建通过
-O3:启用
-lineinfo:启用
目标架构:sm_75

运行验证:

text 复制代码
atomic 默认规模:三个正确版本 PASS
atomic 1003 元素:三个正确版本 PASS
histogram uniform 默认规模:四个版本 PASS
histogram hot 默认规模:四个版本 PASS
histogram single 默认规模:四个版本 PASS
histogram hot 1003 元素、7 Bin:四个版本 PASS
histogram single 1003 元素、1 Bin:四个版本 PASS
histogram 65537 元素、2048 Bin:
  可运行版本 PASS,Warp 私有按共享限制安全跳过
非法分布:安全拒绝
累计溢出参数:安全拒绝

错误 racy ++ 在默认和边界输入都观察到大量丢失更新,但程序没有把特定错误值写成断言。所有性能表只比较正确策略。

配图包含一张生成式主图和六张技术 SVG。SVG 已完成 XML 与浏览器渲染检查;主图表达"全局热点---局部直方图---分层归并",没有伪造软件截图。


十六、一页速查

text 复制代码
共享更新会丢吗?
  普通读改写会;先用原子建立正确基线。

全局原子一定慢吗?
  不一定;取决于更新数量与地址冲突分布。

怎样判断冲突?
  保留真实输入,比较均匀、热点、单热点;
  用 Systems 看占比,用 Compute 看执行证据。

Block 私有化做什么?
  把设备范围热点拆成多个 Block 局部热点,
  最后每 Block 每非零 Bin 少量归并。

Warp 私有化何时考虑?
  Block 内跨 Warp 热点仍强,且共享内存足够时。

Partial 为什么不一定最快?
  它消除全局原子,却增加写出、二次读取、
  第二 Kernel 与额外内存。

如何证明优化有效?
  相同输入、逐 Bin 正确、无 Profiler 更快、
  Profiler 证据按假设变化。

十七、本篇总结:原子操作不是敌人,失控的共享才是

许多初学者听到"原子慢",第一反应是想办法删除 atomicAdd。本篇实验告诉我们,正确顺序应该反过来:

  1. 先承认结果确实被多个线程共享;
  2. 用原子操作建立可审核的正确版本;
  3. 测量共享更新是否真的是热点;
  4. 观察更新集中在多少地址;
  5. 按线程层级建立局部副本;
  6. 减少更大作用域中的竞争;
  7. 同时计算私有化的空间、同步和归并成本;
  8. 用真实分布与边界输入回归。

原子操作让并发更新具有明确语义,是正确性工具。私有化、聚合、分层归并与算法改写是在正确语义之上减少竞争。跳过第一层,性能数字没有可信基础;忽略第二层,正确代码又可能在热点数据上严重退化。

你现在应该能够面对一个共享累加任务,先回答:

是所有线程都必须更新同一个对象,还是可以先在更小作用域内汇总?

这个问题会自然引向下一篇。《CUDA编程实战13:并行前缀和与流压缩------从 Scan 到高效筛选》将研究另一种共享协调方式:线程不再争抢一个全局队列计数器,而是先计算每个线程的保留标志,再通过前缀和得到无冲突写入位置。它会把本篇的"原子分配槽位"进一步改写成结构化并行算法。

如果本文让你第一次把"正确性原子"和"性能竞争"分开理解,建议保留本篇决策树。后面的稀疏输出、流压缩、排序与图算法都会反复使用这套层级化思维。


官方资料

官方当前文档适合核对最新语义;维护旧 Toolkit 项目时,还应打开对应版本归档,因为类型支持、作用域 API、Cluster 能力和工具指标都会随版本变化。


十八、补充问答:真正写项目时还会遇到什么

18.1 能否让一个线程负责一个 Bin,从而完全不用原子

可以,但这个线程必须寻找输入中所有属于该 Bin 的元素。如果每个 Bin 都完整扫描输入,总工作量会从 N N N 增长到近似 N B N B NB,通常得不偿失。某些数据已经按 Bin 分组,或 Bin 很少且输入布局特殊时,这种反向映射可能有价值。关键仍是比较总工作,而不是只比较原子数量。

18.2 能否让每个线程拥有完整私有直方图

理论上最少共享竞争,空间却是线程数乘 Bin 数,通常无法放进寄存器或共享内存。过大的线程私有数组还可能溢出到 Local Memory,最终落到全局内存路径。常见层级选择是 Warp 或 Block 副本,而不是每线程完整副本。

18.3 共享内存计数器要不要声明 volatile

本文在阶段边界使用 __syncthreads(),更新使用共享原子,不需要靠 volatile 修复同步。volatile 不能代替原子和 Barrier。涉及特定 Warp 同步与编译器可见性时应使用相应 Warp 原语和内存模型,不要把旧式 volatile shared 模板当作通用答案。

18.4 原子加法的结果是否确定

整数加法在不溢出的前提下,最终计数确定;原子操作的具体先后顺序可能不同,但交换顺序不改变整数和。浮点原子累加会受非结合性影响,不同执行顺序可能产生低位差异。此时正确性标准、精度和可重复性要求要单独设计。

18.5 多个 Stream 可以同时更新同一直方图吗

设备范围原子能避免对应计数器丢更新,但你仍要定义清零、开始、结束和读取的依赖。一个 Stream 正在清零时另一个 Stream 更新会破坏结果;Host 复制也必须等待所有生产 Stream。可用 Event 建立顺序,或为每个 Stream 分配私有结果后归并。

18.6 为什么程序把 Block 数限制为 SM 数的八倍

这是一个可解释的实验起点,不是最佳常数。它让 Grid-stride 循环保持足够并行,同时控制 Partial 空间和归并数量。不同 GPU、Bin、输入和策略应扫描网格倍数。生产代码可以根据设备属性和离线调优选择。

18.7 为什么 CPU 参考使用完全相同的输入

直方图性能高度依赖分布。如果 CPU 参考重新生成数据,即使使用同一随机规则,也可能因状态、种子或实现差异比较了另一份输入。程序先生成一份 Host 数组,CPU 与所有 GPU 策略共同消费,消除这一变量。

18.8 能否把清零融合进直方图 Kernel

Block 局部清零已经融合在共享 Kernel 中;全局输出清零若与统计放在同一 Kernel,需要跨 Block 保证所有全局 Bin 清零完成后才能开始更新。普通 Kernel 没有任意 Grid 全局 Barrier,贸然融合会竞争。可以使用独立 Memset、前序 Kernel、Cooperative Launch 或版本化计数等方案,但必须证明同步语义和收益。

18.9 为什么 Partial 归并使用六十四位局部和

每个 Partial 是三十二位,遍历很多 Block 时中间累加使用更宽类型更安全,最后程序已保证总输入不超过三十二位再写回。若业务允许更大总数,最终直方图也必须改成六十四位,不能只扩大临时变量。

18.10 怎样判断该停止优化

当直方图不再是端到端热点、复杂版本收益小于噪声、共享资源损害其他阶段、维护成本超过业务价值,或实现已接近设备与算法上界时,就应该停止。性能工程的目标不是消灭所有原子,而是在可维护和正确的前提下满足用户指标。


十九、本篇最终能力清单

读完并完成实验后,你应该能够:

  • 用交错执行解释普通 counter++ 为什么丢更新;
  • 说明原子对象、作用域与内存顺序的区别;
  • 写出正确的整数全局原子计数;
  • 使用 Ballot 与有效掩码完成尾部安全的 Warp 聚合;
  • 使用两次 Block 同步构建共享计数;
  • 定义直方图并进行总和与逐 Bin 双重校验;
  • 构造均匀、高热点和单一热点测试;
  • 实现全局原子、Block 私有、Warp 私有与 Partial 两阶段;
  • 计算共享内存、Partial 空间与原子次数上界;
  • 识别私有化对 Occupancy 的反作用;
  • 使用 Systems 比较完整策略窗口;
  • 使用 Compute 组合原子、调度、内存与资源证据;
  • 拒绝错误版本、溢出参数和超限共享配置;
  • 根据真实分布选择简单原子、分层私有化或库算法;
  • 写出包含适用边界的性能结论。

如果这些问题都能独立回答,你已经不再把 atomicAdd 当成一条需要背诵的语法,而是掌握了共享更新算法最核心的正确性与性能权衡。

相关推荐
yyds_yyd_100861 小时前
877. 石子游戏(2026.08.02)& 486. 预测赢家(2026.08.01)
c++·leetcode
计算机魔术师1 小时前
Replit Design 发布:AI 赋能设计愿景
人工智能·ai编程·编程语言
wangxin2081 小时前
同一只股票三个 AI 写出三种段位:CoordClaw 多智能体如何产出投研级股评
人工智能·多智能体·团队协作·管理学·组织管理·coordclaw·ai数字社会
程序员cxuan1 小时前
Codex 接入 DeepSeek-V4-Flash,丝滑的一批
人工智能·后端·程序员
大鱼>1 小时前
DSPy:LLM程序自动编译与提示词优化
开发语言·人工智能·python·深度学习
AI科技星1 小时前
全域光速运动理论体系 (GAQ-UFT)——范式重构、核心方程与传统物理的本质分野
人工智能·线性代数·机器学习·重构·数据挖掘·回归·ai科技星
2401_843253701 小时前
金融智能:AI如何重构银行业未来
人工智能·python·金融
一路向北North2 小时前
Spring AI(9) :解决百炼平台兼容性问题
java·人工智能·spring
叫我Paul就好2 小时前
RAG 入门到精通 - 生成的优化
人工智能·软件工程·rag