rocr-runtime 相关专题:内存序与 host-device 一致性
本文是 23-Signal 信号系统 的配套专题。信号的本质是同步而同步的底层是「可见性 顺序」。这块牵涉编译器重排、CPU 内存模型、HSA 内存作用域、fine-grain/coarse-grain 一致性、PTE MTYPE 等一连串概念单列一篇讲透避免主线章节被淹没。难度级别: 高级预计阅读时间: 60分钟前置C/C 基础、原子操作概念、23-Signal 信号系统、细粒度与粗粒度内存学习目标读完本文你应能回答原子性和内存序到底各自解决什么问题为什么两者缺一不可C11 的六种内存序分别约束什么release/acquire 如何配对内存序只约束 CPU这句话对不对那 GPU 那一端靠什么fine-grain 与 coarse-grain 一致性内存有什么区别为什么信号必须放在 fine-grain 里一次 CPU→GPU 的信号同步从写数据到 GPU 看到完整链路是怎样的1. 三个层次的乱序我们默认程序按写下的顺序执行但这只是一种假象。从源码到真正对另一个观察者可见中间有三层可能打乱顺序源码顺序编译器重排寄存器缓存/指令调度CPU 乱序执行store buffer/乱序提交跨核/跨设备可见顺序cache 一致性/互连编译器层优化器可以把无依赖的读写重新排序甚至把变量缓存在寄存器里不写回内存。CPU 层现代 CPU 有 store buffer、乱序执行、推测执行一个核发出的写别的核不一定按同样顺序看到。系统层多核之间、CPU 与 GPU 之间谁先看到谁由 cache 一致性协议和互连PCIe / XGMI决定。内存序memory order就是程序员向这三层下达的约束告诉编译器和硬件这几个访存相对于这个原子操作必须保持某种顺序并在某个时刻对其他观察者可见。2. 原子性 ≠ 内存序这是最容易混淆的一对概念务必分清原子性 (atomicity)内存序 (memory order)解决的问题单个变量的读写不被撕裂不会读到一半该操作之外的其他访存相对它的顺序与可见性保护对象信号值本身信号值周围那批配套数据没有它会怎样读到半新半旧的值看到信号变了但配套数据还是旧的脏读一句话原子性保证信号值自己是完整的内存序保证看到信号时配套数据也确实就绪且可见。二者正交atomic::Store(v, x, release)同时提供了两者。3. C11 的六种内存序std::memory_order定义了六个取值HSA 的信号 API 后缀_relaxed/_scacquire/_screlease/_scacq_screl与它一一对应memory_orderHSA 后缀语义典型用途relaxed_relaxed只保证原子性不约束任何顺序纯计数、doorbell 递增acquire_scacquire之后的读写不能被重排到它之前读标志消费者release_screlease之前的读写不能被重排到它之后写标志生产者acq_rel_scacq_screl读-改-写同时具备 acquire releaseCAS / Add 等 RMWconsume—HSA 不用acquire 的弱化版仅约束数据依赖极少使用多数实现退化为 acquireseq_cst—默认最强在 acq_rel 基础上再加一个全局单一顺序需要全局顺序的少数场景HSA 只暴露 relaxed / acquire / release / acq_rel 四档正好覆盖信号同步的全部需求不提供 seq_cst是因为它最贵而热路径上用不到见第 6 节。release / acquire 是半屏障关键直觉——它们都是单向的**Acquire 读只挡上方**允许下移前面的操作LoadAcquire 标志...后面的读写...**Release 写只挡下方**允许上移...前面的读写...StoreRelease 标志后面的操作Release像一道闸门挡住上方的写不让漏到标志之后但下方的操作可以往上穿。Acquire挡住下方的读不让提前到标志之前但上方的操作可以往下穿。正因为是半屏障比 seq_cst 的全屏障便宜。4. happens-before配对才有意义单独一个 release 或一个 acquire毫无用处——它们必须配对消费者线程生产者线程synchronizes-withhappens-before写 dataStoreRelease 标志1LoadAcquire 标志1读 data规则C11 内存模型当消费者的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 有自己的执行流水线、自己的 cacheC 的 release 无法直接命令 GPU shader你要按序读。5.2 那 CPU↔GPU 怎么同步答案一致性域 硬件内存序的定义其实是相对于其他观察者的可见性而谁算观察者取决于这块内存落在哪个一致性域同步方向CPU 端靠什么对端靠什么CPU ↔ CPUC 内存序CPU cache 一致性协议MESI 等CPU ↔ GPUC 内存序把数据发布进 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 不是抽象概念它一路落到页表项的 MTYPEHsaMemFlags.CoarseGrain 位fine-grain清零/带 COHERENTThunk fmm.c翻译成 KFD_IOC ALLOC 标志UAPI kfd_ioctl.hCOHERENT/UNCACHED/EXT_COHERENT 位KFD gpuvmAMDGPU_GEM_CREATE_COHERENT 等GMC per-ASICget_coherence_flags页表项 MTYPECC 一致 / NC 非一致 / UC 不缓存fine-grainCOHERENT内存倾向映射为MTYPE_CCcache coherent或按 ASIC 规则处理使 CPU 与 GPU 对该页的访问彼此可见coarse-grain默认多为MTYPE_NC / RWcache 更自由但需要在同步点做 buffer 级的 flush/invalidate。细节见 细粒度与粗粒度内存。这里只需记住结论信号内存必须是 fine-grain 的否则 GPU 写的信号值 CPU 不能及时看到忙等就会死等。7. 完整链路一次 CPU→GPU 信号同步把前面所有概念串起来看一次真实的生产者-消费者CPU 备数据、GPU 消费GPUfine-grain 一致内存CPU 线程GPUfine-grain 一致内存CPU 线程StoreRelease 信号值release 保证 data 先落盘再写信号因为是 fine-grain 一致内存硬件保证写对 GPU 可见一致性协议保证此时 data 已是新值写 data普通写原子写 amd_signal.value 1轮询/被 doorbell 唤醒后读信号值读到 1随后读 data反过来 GPU→CPUkernel 完成通知 CPU也是对称的GPU 原子写信号值 → 可选地写event_mailbox_ptr触发中断 → CPU 的InterruptSignal被 KMD 唤醒 →WaitAcquire的 acquire 栅栏保证 CPU 随后读到的 kernel 结果是新的。amd_signal_t里的硬件接口信号结构中有几个字段专门服务于GPU 那一半说明它天生是软硬协同的字段作用value/hardware_doorbell_ptrunionUSER 信号存值DOORBELL 信号存硬件 doorbell 地址写它直接踢 GPUevent_mailbox_ptrevent_idGPU 写 mailbox 触发中断唤醒等待的 CPUInterruptSignal 路径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 按序执行”内存序只约束发出它的 CPUGPU 侧靠硬件一致性“信号放哪块内存都行”必须是 fine-grain 一致内存否则跨设备看不到“全用 seq_cst 最保险”热路径上 seq_cst 昂贵release/acquire 已足够且便宜“acquire 或 release 单独用就行”必须成对才能建立 happens-before10. 回到信号系统现在再看 23-Signal 信号系统 里的这些设计就都有了根为什么每个操作都有四种内存序版本 → 让调用者按场景选最便宜的正确档位为什么WaitAcquire WaitRelaxed acquire 栅栏→ 用最小代价建立 happens-before为什么SharedSignal/amd_signal_t要放在特定内存池、要 64 字节对齐、要带硬件字段 → 因为它必须坐落在 fine-grain 一致域且要能被 GPU 硬件直接访问。内存序不是信号的附加选项而是信号之所以能同步的根本前提。思考题如果把信号内存错误地分配成 coarse-grainCPU 忙等 GPU 写的信号值会发生什么为什么hsa_signal_add_relaxed存在的意义是什么举一个用 relaxed 完全正确的场景。为什么 CAS 用acq_rel而不是单独的 acquire 或 releaseHSA 只暴露四档内存序而不给 seq_cst会不会有场景因此写不出正确代码为什么。关联阅读第23 章Signal 信号系统rocr-runtime 的 signal 实现分析细粒度与粗粒度内存分析了内存一致性