3. 同步、原子与内存模型
CPU 程序员有三件套:mutex、条件变量、std::atomic。GPU 上只剩最后一件,而且语义更弱。这一节讲清楚 __syncthreads() 到底保证了什么、原子什么时候会变成瓶颈,以及为什么“跨 block 自旋等待”是个陷阱。
3.1 block 内的 barrier
__syncthreads() 做两件事:所有线程到达后才继续,以及此前的 shared/全局写对 block 内其他线程可见。它相当于“barrier + block 范围的 fence”。
exchange.cu
__shared__ float s[256];
int t = threadIdx.x;
s[t] = x[i];
__syncthreads(); // 少这一行,读到的可能是旧值
float left = s[(t + 255) & 255]; // 取左邻居,边界回绕
两个硬性限制:
- 只能同步 block 内。不同 block 之间没有 barrier;
__syncthreads()不会让 block 1 等 block 0。 - 必须在所有线程都到达的位置。如果写在
if (threadIdx.x < 16) { … __syncthreads(); }里,有的 lane 到了、有的没到,行为是未定义(常见表现是挂死或结果随机)。条件必须块内一致。
还有一个容易忽略的点:__syncthreads() 之后再次写同一块 shared 之前,往往需要第二个 __syncthreads()——否则快线程可能把慢线程还没读的数据覆盖掉。归约、转置、双缓冲都踩过这个坑。
3.2 原子操作:能用,但要少用
atomicAdd、atomicSub、atomicMin/Max、atomicExch、atomicCAS、按位与/或/异或,都返回旧值。它们是设备范围的,落在 L2 上完成。
count.cu
__global__ void count_positive(const float* x, int n, int* counter) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n && x[i] > 0.0f)
atomicAdd(counter, 1);
}
这段代码是对的,但很慢:如果数据里正数很多,所有 block 都在抢同一个地址,L2 上的一次原子要串行几十到几百个周期。正确做法是先在 block 内归约,再让每个 block 做一次原子(4.1);或者用 shared 私有化(4.4)。
原子还有第二个代价:不可复现。到达顺序由调度决定,而浮点加法不满足结合律——同一份输入跑两次,结果可能差几个 ULP(机制在 3.3 末尾)。所以不要把「汇总」交给原子:要可复现,就换成固定树形,写法在 4.1 末尾。
用 atomicCAS 可以实现一个“锁”,但 GPU 上没有调度器帮你把自旋的 warp 换下去——它仍然占着发射槽,还会拖慢同 SM 的其他 warp。更糟的是跨 block 的锁可能永远拿不到(持有者所在的 block 还没被调度)。需要互斥时,先问能不能改成“每个线程写自己的槽位,最后归约”。
3.3 弱内存模型与 fence
GPU 的内存模型是弱(relaxed)的:没有 fence 时,编译器和硬件都可以重排你的读写。下面这段“发布数据”的代码,如果去掉 __threadfence(),其他 SM 可能先看到 g_ready == 1,再看到旧的 *data:
publish.cu
__device__ int g_ready = 0;
__global__ void publish(float* data) {
if (blockIdx.x == 0 && threadIdx.x == 0) {
*data = 1.0f;
__threadfence(); // 先把 data 的写推到设备可见
atomicExch(&g_ready, 1); // 再发布标志
}
}
消费端的顺序相反:先读标志,再读数据,并且需要 acquire 语义。干净的写法是用 libcu++ 的显式内存序:
#include <cuda/atomic>
cuda::atomic_ref<int, cuda::thread_scope_device> flag(g_ready);
while (flag.load(cuda::memory_order_acquire) == 0) { }
float v = *data; // 现在能保证看到 publish 写入的值
三个 fence 的范围:__threadfence_block()(block 内)、__threadfence()(设备内)、__threadfence_system()(含主机)。它们只做排序,不做互斥。
跨 block 自旋等待可能死锁。硬件不保证所有 block 同时驻留;如果你等待的那个 block 还没被调度,而 SM 已经被“等待者”占满,就永远转不下去。需要全局同步时,用 cooperative groups 的 grid.sync()(要求 cooperative launch,并受常驻 block 数限制),或者干脆拆成两个 kernel。
这也是为什么“原子操作的结果不可复现”:atomicAdd(float) 的到达顺序由调度决定,浮点加法不满足结合律,每次运行的舍入都不同。CPU 多线程累加也有同样的问题,只是 GPU 上更容易暴露。
3.4 warp 级原语
warp 内的 32 个 lane 可以直接交换寄存器,不需要 shared memory,也不需要 __syncthreads():
__inline__ __device__ float warpSum(float v) {
#pragma unroll
for (int off = 16; off > 0; off >>= 1)
v += __shfl_down_sync(0xffffffffu, v, off); // lane i 取 i+off 的值
return v; // lane 0 得到总和
}
常用的一组:
| 原语 | 作用 |
|---|---|
__shfl_sync(mask, v, src) | 读 lane src 的寄存器 |
__shfl_down_sync(mask, v, d) | 读 lane i+d 的值(归约常用) |
__shfl_up_sync(mask, v, d) | 读 lane i-d 的值(扫描常用) |
__ballot_sync(mask, pred) | 返回 32 位掩码,第 i 位是 lane i 的条件 |
__any_sync / __all_sync | warp 内是否有 / 是否全都满足 |
__popc / __ffs | 数 1 的个数 / 找最低位的 1 |
__syncwarp(mask) | warp 内重新汇合(Volta 之后需要显式写) |
mask 必须是当前活跃 lane 的集合。标准写法是全 1,但那要求 32 个 lane 都在执行这条指令。如果 blockDim.x 不是 32 的倍数,最后一个 warp 只有部分 lane 活跃,全 1 的 mask 就是错的——这是 1.7 里提到的那个坑。
unsigned m = __ballot_sync(0xffffffffu, v > 0.0f);
int leader = __ffs(m) - 1; // 最低位满足条件的 lane
int npos = __popc(m); // 满足条件的 lane 数
3.5 和 CPU 的对照
| CPU | GPU | 说明 |
|---|---|---|
| pthread_barrier | __syncthreads() | 只作用于一个 block |
| std::mutex | 没有 | 用“私有化 + 归约”替代,不要自旋 |
| std::condition_variable | 没有 | 用原子轮询也可以,但要保证对方能跑 |
| std::atomic<int> | atomicAdd / atomicCAS | GPU 的默认序是 relaxed |
| std::memory_order | __threadfence 或 cuda::atomic_ref | 三级范围:block / device / system |
| thread_local | 寄存器(溢出到 local memory) | local memory 在显存里,慢 |
3.6 容易写错的地方
- 以为
__syncthreads()能同步 block 之间。不能。跨 block 只能拆 kernel、用原子/fence,或 cooperative launch。 - 把
volatile当同步。它只阻止编译器优化单次访存,不提供跨线程的顺序保证。 - 写完 shared 只同步一次。“写 → 同步 → 读 → 同步 → 再写”里的第二个同步经常被漏掉。
- 在发散的分支里调用
__syncthreads()。未定义行为,常见表现是挂死。 - 用原子做“顺序”保证。原子只保证单次操作的原子性,不保证其他访存的顺序,需要配套 fence。
- 跨 block 自旋。可能死锁;也不要依赖“block 0 一定先跑”。
- 忘记原子浮点求和的非确定性。需要可复现时,用固定的树形归约(4.1),不要用
atomicAdd汇总。