CUDA系统拆解-07-同步、原子操作与内存一致性:并发正确性怎么保证
CUDA系统拆解-07-同步、原子操作与内存一致性:并发正确性怎么保证
本文是「CUDA系统拆解」系列第 07 篇。
系列导读:CUDA系统拆解-00-导读:从编程模型到 AI 推理系统的学习路线
上一篇:CUDA系统拆解-06-共享内存与Coalescing:访存优化先抓哪几个抓手
下一篇:CUDA系统拆解-08-Occupancy、寄存器压力与Launch参数:调优不是把占用率拉满
1. 这篇解决什么问题
- 为什么并行程序比串行程序更容易出错。
- 同步在 CUDA 里到底在保护什么。
__syncthreads()真正的作用是什么,为什么它只在 block 内有效。- 原子操作解决了什么问题,为什么它又常常带来性能代价。
- 什么叫可见性和顺序性,为什么这些问题不能只靠“感觉”判断。
2. 先记住的核心结论
- 并行程序更难,不是因为线程多,而是因为多个线程会同时读写共享状态,导致协调、冲突、可见性问题。
__syncthreads()是 block 级 barrier,它的意义不只是“等一下”,而是建立 block 内的阶段边界。__syncthreads()只在 block 内有效,不能拿它做跨 block 的同步。- 原子操作解决的是共享地址上的并发写冲突,但通常会把热点更新串行化,所以正确不等于高性能。
- block 独立是 CUDA 的重要设计,这让调度和扩展更灵活,但也意味着很多全局协作要拆成多阶段。
- 内存一致性和顺序性要单独理解,因为“某线程已经写了”不等于“其他线程现在一定能按你期望的顺序看到”。
3. 正文讲解
3.1 为什么并行程序更难
串行程序里,你通常有一种很强的默认直觉:
- 先执行 A
- 再执行 B
- 再执行 C
所以只要代码顺序写出来了,你就会自然相信后面的语句能看到前面的结果。
并行程序不是这样。到了 CUDA 里:
- 很多线程同时执行
- 不同线程推进速度未必一样
- 不同 warp 之间没有天然顺序
- 不同 block 之间更没有默认同步
于是问题就变成了:
当多个线程同时读写同一份数据时,谁先写、谁后读、谁能看到什么,不再是天然确定的。
所以并行程序真正难的地方,不是把工作分出去,而是把共享状态管对。
3.2 并行正确性里最核心的三类问题
这一篇最重要的总图可以压成三类。
第一类,协调问题。
也就是线程之间要不要在某个阶段对齐。
典型情况是:
- 有的线程还没写完
- 另一些线程已经准备开始读
第二类,冲突问题。
也就是多个线程同时更新同一个地址,导致结果丢失或不确定。
第三类,可见性和顺序问题。
也就是一个线程已经写了数据,但另外的线程什么时候、以什么顺序能可靠看到这次写入。
同步、atomic 和一致性,本质上都在处理这三类问题。
3.3 什么是 race condition
race condition 不是说“大家都在竞争 GPU 资源”,而是:
程序结果依赖于多个线程的执行先后顺序,而这个顺序本身又不受控制。
最经典的例子就是很多线程一起做:
- 读一个共享变量
- 改它
- 再写回去
如果两个线程都先读到旧值,再分别写回,就会发生“丢更新”。
所以你要建立一个很稳的判断:
只要多个线程可能同时写同一个地址,就要立刻警惕 race condition。
3.4 同步到底在解决什么问题
很多人把同步理解成“让线程停一下,等别人”。
这只是表面。
同步更本质的作用有两层:
- 让一组线程在时间上对齐,形成阶段边界
- 让这个阶段边界之前的写入,在边界之后对相关线程可可靠使用
也就是说,同步不只是控制时间,更是在建立数据依赖。
这就是为什么同步通常出现在:
- shared memory 协作
- 分阶段归约
- tile 搬运之后
- 多轮迭代中间
3.5 __syncthreads() 真正是什么意思
__syncthreads() 是 CUDA 里最重要的 block 级 barrier。
它的直觉可以理解成:
同一个 block 内的线程,都必须先走到这里,之后才能继续往后执行。
但更重要的是它的语义含义:
- 它建立了 block 内的阶段边界
- 让 barrier 前的 shared memory 写入,可以在 barrier 后被同 block 线程可靠消费
所以 __syncthreads() 最典型的使用场景,不是“为了好看加一个同步”,而是:
- 先把一块数据搬进 shared memory
- 同步
- 再让别的线程读取和复用
3.6 为什么 __syncthreads() 只在 block 内有效
这是这篇必须吃透的点。
__syncthreads() 只能同步:
- 同一个 block 内的线程
它不能同步:
- 不同 block
- 整个 grid
- 跨 kernel 的阶段
原因不是 API 做得不够强,而是 CUDA 的设计就是这样:
block 是独立调度单位。
不同 block:
- 可能在不同 SM 上
- 可能不是同时运行
- 甚至可能前后分批被调度
如果 CUDA 默认支持轻松的跨 block barrier,调度成本和硬件复杂度都会高很多。
所以 block 独立不是“限制”,而是 CUDA 可扩展和可调度的基础。
3.7 为什么 block 独立是重要设计
block 独立至少带来三件好事:
- 调度更灵活,SM 可以自由接活
- 同一 kernel 更容易适配不同规模 GPU
- 编程模型更清晰,天然鼓励“局部协作、全局分阶段”
这也是为什么很多 CUDA 算法的结构都长这样:
- block 内先做局部处理
- block 内同步
- 产出局部结果
- 用下一阶段 kernel 再做更高层合并
所以你以后看到 reduction、prefix sum、softmax 分阶段实现时,要知道这不是“写得麻烦”,而是在适配 CUDA 的协作边界。
3.8 __syncthreads() 的一个典型陷阱
__syncthreads() 不是随便塞进分支里就行。
最危险的情况是:
- block 内只有一部分线程会执行到 barrier
- 另一部分线程永远到不了
这样就会产生死锁或未定义行为。
所以非常重要的一条规则是:
对同一个 block 来说,凡是要到达
__syncthreads()的控制流,必须保证相关线程一致地到达。
这一点在写条件分支、边界处理和循环时尤其容易踩坑。
3.9 原子操作到底解决什么问题
同步解决的是“何时协作”。
原子操作解决的是“同时更新同一个地址怎么办”。
例如一个共享计数器、一个全局和、一个直方图桶,如果很多线程都对同一位置做读改写,那么普通写法会丢更新。
原子操作的含义就是:
对某个共享地址上的读改写过程,提供不可分割的并发保护。
所以 atomicAdd、atomicMax 这类原语的本质,不是“更高级的加法”,而是:
- 正确处理共享热点更新
- 防止多个线程互相覆盖结果
3.10 为什么原子操作对,但常常慢
原子操作能保证正确,但它常常会引入一个新的现实:
热点地址上的更新会变得高度串行化。
如果几千个线程都在抢同一个地址做 atomicAdd,那就意味着:
- 逻辑上结果是对的
- 但硬件上大家都在争同一个共享点
于是吞吐会明显下降。
所以原子操作的成熟理解应该是:
- 它是正确性工具
- 不是默认高性能方案
高性能实现通常会想办法减少 atomic 的次数和冲突范围。
3.11 为什么常见优化是“先局部,再全局”
这条思路你以后会反复看到。
最差的做法通常是:
- 每个线程直接对同一个全局地址做 atomic
更好的做法通常是:
- warp 内先聚合
- block 内再聚合
- 最后只让少量线程做全局 atomic
这样做的意义非常直接:
- 降低全局热点
- 减少 atomic 次数
- 让大部分协作先在更近、更便宜的层级完成
所以很多高性能 reduction、softmax、统计类 kernel 的主线,本质上都是:
先局部合并,再少量全局提交。
3.12 内存一致性和顺序性到底在说什么
这个词听起来抽象,但你可以先把它翻译成一个非常具体的问题:
一个线程写了数据,别的线程什么时候能看到?看到时顺序是不是你想要的那个顺序?
这里要特别警惕两个误解:
- 写了,不等于别的线程立刻可靠可见
- 看到了,不等于顺序就是你以为的顺序
所以一致性和顺序性要单独理解。
它们不是“补充知识”,而是并行正确性的基础。
对当前阶段来说,你至少要建立下面这个直觉:
- shared memory 协作通常要靠 barrier 建立“先写后读”的阶段边界
- 跨 block 的复杂协作,通常不该在一个 kernel 内硬做
- kernel 边界本身就是很强的全局阶段边界
3.13 为什么很多跨 block 协作要拆成多个 kernel
这是 CUDA 很重要的工程哲学。
因为 block 间没有天然 barrier,所以很多算法会故意拆成多阶段:
- 第一个 kernel 做局部工作
- kernel 结束,形成全局阶段边界
- 第二个 kernel 再读取前一阶段结果
这种写法的好处是:
- 正确性更清晰
- 调度更稳定
- 更符合 CUDA 的 block 独立模型
所以以后看到“多 kernel 分阶段实现”,不要第一反应觉得它笨。很多时候,这恰恰是更对的设计。
4. 和 AI 推理的关系
这篇和 AI 推理的关系非常直接,因为推理里的很多 kernel 都有局部协作和共享状态问题。
典型场景包括:
- softmax、LayerNorm、RMSNorm:要做 block 或 warp 内归约
- attention tile 计算:先搬数据、同步,再进行阶段性计算
- 统计、计数、buffer slot 分配:经常涉及 atomic
- 推理框架里的共享元数据更新:本质上也是并发读写协调问题
所以做 AI infra / 推理时,你不能只把这些当成“CUDA 基础题”,而要知道:
- barrier 放错了会直接错
- atomic 用多了会直接拖性能
- block 边界理解错了,算法结构就会从根上出问题
5. 常见误区
- 加了
__syncthreads()就一定安全。不是,它只解决 block 内 barrier 问题。 __syncthreads()可以拿来同步整个 kernel。不是,它不跨 block。- atomic 能解决所有并发问题。不是,它只解决特定共享更新的正确性问题。
- 为了保险,多加同步总没错。不是,过多 barrier 会拖慢执行,而且不一定真解决逻辑问题。
- 结果偶尔对,说明写法大体没问题。不是,并发 bug 最危险的就是不稳定和偶发。
- 多 kernel 分阶段实现说明实现不高级。不是,这往往正是在适配 CUDA 的同步边界。
6. 复习自测
- 为什么并行程序比串行程序更难?
- 同步在 CUDA 里到底在保护什么?
__syncthreads()的真实作用是什么,为什么它只在 block 内有效?- 为什么 block 独立是 CUDA 的重要设计?
- 原子操作到底解决什么问题,为什么它常常会带来性能代价?
- 为什么高性能实现通常更喜欢“先局部聚合,再少量全局提交”?
- 内存一致性和顺序性为什么要单独理解?
- 为什么很多跨 block 协作更适合拆成多个 kernel 分阶段完成?


