本文是 23-Signal 信号系统 的配套专题。信号的本质是同步,而同步的底层是「可见性 + 顺序」。这块牵涉编译器重排、CPU 内存模型、HSA 内存作用域、fine-grain/coarse-grain 一致性、PTE MTYPE 等一连串概念,单列一篇讲透,避免主线章节被淹没。
难度级别: 🟡🔴 高级
预计阅读时间: 60分钟前置:C/C++ 基础、原子操作概念、23-Signal 信号系统、细粒度与粗粒度内存
学习目标
读完本文你应能回答:
- 原子性和内存序到底各自解决什么问题,为什么两者缺一不可?
- C++11 的六种内存序分别约束什么?release/acquire 如何配对?
- 内存序"只约束 CPU"这句话对不对?那 GPU 那一端靠什么?
- fine-grain 与 coarse-grain 一致性内存有什么区别,为什么信号必须放在 fine-grain 里?
- 一次 CPU→GPU 的信号同步,从写数据到 GPU 看到,完整链路是怎样的?
1. 三个层次的"乱序"
我们默认程序"按写下的顺序执行",但这只是一种假象。从源码到真正对另一个观察者可见,中间有三层可能打乱顺序:
- 编译器层:优化器可以把无依赖的读写重新排序,甚至把变量缓存在寄存器里不写回内存。
- CPU 层:现代 CPU 有 store buffer、乱序执行、推测执行,一个核发出的写,别的核不一定按同样顺序看到。
- 系统层:多核之间、CPU 与 GPU 之间,"谁先看到谁"由 cache 一致性协议和互连(PCIe / XGMI)决定。
内存序(memory order)就是程序员向这三层下达的约束:告诉编译器和硬件"这几个访存,相对于这个原子操作,必须保持某种顺序,并在某个时刻对其他观察者可见"。
2. 原子性 ≠ 内存序
这是最容易混淆的一对概念,务必分清:
| 原子性 (atomicity) | 内存序 (memory order) | |
|---|---|---|
| 解决的问题 | 单个变量的读写不被撕裂(不会读到一半) | 该操作之外的其他访存相对它的顺序与可见性 |
| 保护对象 | 信号值本身 | 信号值周围那批配套数据 |
| 没有它会怎样 | 读到"半新半旧"的值 | 看到信号变了,但配套数据还是旧的(脏读) |
一句话:原子性保证"信号值自己是完整的",内存序保证"看到信号时,配套数据也确实就绪且可见"。二者正交,atomic::Store(&v, x, release)同时提供了两者。
3. C++11 的六种内存序
std::memory_order定义了六个取值,HSA 的信号 API 后缀(_relaxed/_scacquire/_screlease/_scacq_screl)与它一一对应:
| memory_order | HSA 后缀 | 语义 | 典型用途 |
|---|---|---|---|
relaxed | _relaxed | 只保证原子性,不约束任何顺序 | 纯计数、doorbell 递增 |
acquire | _scacquire | 之后的读写不能被重排到它之前 | 读标志(消费者) |
release | _screlease | 之前的读写不能被重排到它之后 | 写标志(生产者) |
acq_rel | _scacq_screl | 读-改-写同时具备 acquire + release | CAS / Add 等 RMW |
consume | —(HSA 不用) | acquire 的弱化版,仅约束数据依赖 | 极少使用,多数实现退化为 acquire |
seq_cst | —(默认最强) | 在 acq_rel 基础上再加一个全局单一顺序 | 需要全局顺序的少数场景 |
HSA 只暴露 relaxed / acquire / release / acq_rel 四档,正好覆盖信号同步的全部需求;不提供 seq_cst,是因为它最贵而热路径上用不到(见第 6 节)。
release / acquire 是"半屏障"
关键直觉——它们都是单向的:
`
- Release:像一道"闸门",挡住上方的写不让漏到标志之后;但下方的操作可以往上穿。
- Acquire:挡住下方的读不让提前到标志之前;但上方的操作可以往下穿。
正因为是半屏障,比 seq_cst 的"全屏障"便宜。
4. happens-before:配对才有意义
单独一个 release 或一个 acquire毫无用处——它们必须配对:
规则(C++11 内存模型):
- 当消费者的acquire 读读到了生产者release 写写入的那个值,二者建立synchronizes-with关系;
- 于是生产者在 release之前的所有写(
A),都happens-before消费者在 acquire之后的所有读(D); - 结论:消费者读
data一定能看到生产者写的新值。没有脏读、没有重排。
这就是信号系统里WaitAcquire = WaitRelaxed + std::atomic_thread_fence(memory_order_acquire)的理论依据:等到值(relaxed 只保证原子性),再补一道 acquire 栅栏建立 happens-before,让等待返回后读到的数据一定是新的。
5. 关键澄清:内存序"只约束 CPU 这一端"
这是本文的核心,也是最容易误解的地方。
5.1 语言层面:内存序约束的是"执行它的处理器"
C++ 的memory_order是一个语言构造,编译器把它翻译成目标 CPU 的屏障指令(x86 的mfence/隐式屏障、ARM 的dmb等)。所以它约束的是:
- 编译器:不要把某些访存重排越过这个点;
- 执行这条指令的那颗 CPU:按要求插入屏障、刷 store buffer。
它不是一条发给 GPU 的命令。GPU 有自己的执行流水线、自己的 cache,C++ 的 release 无法直接命令 GPU shader"你要按序读"。
5.2 那 CPU↔GPU 怎么同步?答案:一致性域 + 硬件
内存序的定义其实是"相对于其他观察者的可见性",而谁算观察者,取决于这块内存落在哪个一致性域:
| 同步方向 | CPU 端靠什么 | 对端靠什么 |
|---|---|---|
| CPU ↔ CPU | C++ 内存序 | CPU cache 一致性协议(MESI 等) |
| CPU ↔ GPU | C++ 内存序,把数据发布进 fine-grain 一致域 | GPU 硬件一致性协议 +amd_signal_t硬件字段 |
拆成两半理解:
- CPU 这一半:
release保证——把data的写刷进一个 CPU 与 GPU共享的、细粒度一致的内存域,并且排在信号写之前。这一半是内存序的职责。 - GPU 这一半:GPU 通过硬件路径读这块内存。因为内存是 fine-grain coherent 的,GPU 一旦读到新信号值,一致性协议保证配套数据也已在域内可见。这一半是硬件的职责,跟 C++ 内存序无关。
一句话:内存序管 CPU 端的顺序与发布时机;硬件一致性管 GPU 端的可见。两半合起来,才是完整的 host-device 同步。
6. 一致性域:fine-grain vs coarse-grain
上面反复提到"fine-grain 一致域",这正是信号能跨设备工作的物理基础。HSA/ROCm 内存按一致性粒度分两类:
| 细粒度 fine-grain | 粗粒度 coarse-grain | |
|---|---|---|
| 一致性粒度 | 单次原子访问即对彼此可见 | 整个 buffer,需在同步点显式刷新 |
| CPU/GPU 并发访问 | 可以边算边看到对方的写 | 一段时间内归一方所有,交接时才同步 |
| 典型用途 | 信号、doorbell、细粒度同步变量 | 大块计算数据(性能更好) |
| 代价 | cache 行为受限,稍慢 | cache 友好,吞吐高 |
从标志到硬件的落地链路(AMD 实现)
fine-grain 不是抽象概念,它一路落到页表项的 MTYPE:
- fine-grain(COHERENT)内存倾向映射为MTYPE_CC(cache coherent)或按 ASIC 规则处理,使 CPU 与 GPU 对该页的访问彼此可见;
- coarse-grain(默认)多为MTYPE_NC / RW,cache 更自由,但需要在同步点做 buffer 级的 flush/invalidate。
细节见 细粒度与粗粒度内存。这里只需记住结论:信号内存必须是 fine-grain 的,否则 GPU 写的信号值 CPU 不能及时看到,忙等就会死等。
7. 完整链路:一次 CPU→GPU 信号同步
把前面所有概念串起来,看一次真实的生产者-消费者(CPU 备数据、GPU 消费):
反过来 GPU→CPU(kernel 完成通知 CPU)也是对称的:GPU 原子写信号值 → 可选地写event_mailbox_ptr触发中断 → CPU 的InterruptSignal被 KMD 唤醒 →WaitAcquire的 acquire 栅栏保证 CPU 随后读到的 kernel 结果是新的。
amd_signal_t里的硬件"接口"
信号结构中有几个字段专门服务于"GPU 那一半",说明它天生是软硬协同的:
| 字段 | 作用 |
|---|---|
value/hardware_doorbell_ptr(union) | USER 信号存值;DOORBELL 信号存硬件 doorbell 地址,写它直接踢 GPU |
event_mailbox_ptr+event_id | GPU 写 mailbox 触发中断,唤醒等待的 CPU(InterruptSignal 路径) |
queue_ptr | 关联队列,用于错误/调试上下文 |
这些字段是AMD 实现细节(不属于 HSA 规范的公开语义),但正是它们让"一块普通的一致性内存"变成了 CPU 与 GPU 都能识别的同步原语。
8. 为什么不一律用最强内存序
既然 seq_cst 最安全,为何不全用它?因为越强越贵,而信号在 host-device 同步的热路径上(每个 kernel 完成都发一次),屏障开销直接吃掉吞吐:
| 内存序 | 相对开销 | 适用 |
|---|---|---|
| relaxed | 最低(仅原子性) | 纯计数、doorbell 递增 |
| acquire / release | 中(单向半屏障) | 生产者-消费者、kernel 完成通知(绝大多数) |
| acq_rel | 中(RMW 两侧) | CAS / Add 等读-改-写 |
| seq_cst | 最高(全局单序) | 需要全局顺序的极少数场景 |
这解释了为什么 ROCr 为每个操作 × 每种内存序都提供独立版本(hsa_signal_add_relaxed/_scacquire/_screlease/_scacq_screl……):把"选择权 = 性能"交给调用者。
9. 常见误区速查
| 误区 | 纠正 |
|---|---|
| “用了原子操作就不会脏读” | 原子性只保证信号值不撕裂;配套数据的可见性要靠内存序 |
| “内存序能命令 GPU 按序执行” | 内存序只约束发出它的 CPU;GPU 侧靠硬件一致性 |
| “信号放哪块内存都行” | 必须是 fine-grain 一致内存,否则跨设备看不到 |
| “全用 seq_cst 最保险” | 热路径上 seq_cst 昂贵;release/acquire 已足够且便宜 |
| “acquire 或 release 单独用就行” | 必须成对,才能建立 happens-before |
10. 回到信号系统
现在再看 23-Signal 信号系统 里的这些设计,就都有了根:
- 为什么每个操作都有四种内存序版本 → 让调用者按场景选最便宜的正确档位;
- 为什么
WaitAcquire = WaitRelaxed + acquire 栅栏→ 用最小代价建立 happens-before; - 为什么
SharedSignal/amd_signal_t要放在特定内存池、要 64 字节对齐、要带硬件字段 → 因为它必须坐落在 fine-grain 一致域,且要能被 GPU 硬件直接访问。
内存序不是信号的"附加选项",而是"信号之所以能同步"的根本前提。
思考题
- 如果把信号内存错误地分配成 coarse-grain,CPU 忙等 GPU 写的信号值,会发生什么?为什么?
hsa_signal_add_relaxed存在的意义是什么?举一个用 relaxed 完全正确的场景。- 为什么 CAS 用
acq_rel而不是单独的 acquire 或 release? - HSA 只暴露四档内存序而不给 seq_cst,会不会有场景因此写不出正确代码?为什么。
关联阅读
- 第23 章:Signal 信号系统:rocr-runtime 的 signal 实现分析
- 细粒度与粗粒度内存:分析了内存一致性