☰
跨卡无锁同步三剑客:pub_release、wait_ge_bounded 与 acq_fence 实战解析
2026/10/14 16:10:50 网站建设 项目流程

做多卡异构计算的人,大概率都经历过“一同步就慢一半”的时刻。我在调一套跨卡生产-消费流水线时,最开始的两版方案分别用了设备级全局同步和锁同步,结果吞吐始终上不去。后来换成“跨卡无锁同步三剑客”:pub_release 负责发布数据并让信号对其他设备可见,wait_ge_bounded 负责在消费端做有界等待,acq_fence 负责在信号到达后重建内存可见性。这三件事拆开看都不复杂,合在一起就能在无需锁、无需整卡停摆的前提下,实现卡间细粒度同步。这篇是系列第二篇,主要记录这套组合的原理、落地参数和我踩过的坑,适合已经能用多卡跑模型、但对卡间同步开销敏感的开发者。

1. 需求场景:什么时候非用跨卡无锁同步不可

1.1 从“全卡同步”到“跨卡同步”的痛点

在讲三把剑之前,先把场景交代清楚。假设我有两张计算卡,一张做数据预处理,另一张做模型推理,中间隔着一块共享内存缓冲区。最朴素的实现是:生产者卡计算完一批数据,调用一次设备同步接口,确保所有写入都已结束;消费者卡再启动,读那批数据。这个方案的问题在实测中非常突出:设备同步接口会把整张卡上所有的流、所有的并发任务全部排空,哪怕其他流上还有不相关的计算,也都要等。如果每产出一批数据就调用一次,生产者和消费者的计算时间会被同步时间完全掩盖。后来我翻性能分析数据,发现同步相关的等待占了接近一半的耗时,而真正读写共享数据的时间微乎其微。

锁同步是另一条路:在共享内存上放一把锁,生产者拿锁、写数据、放锁,消费者拿锁、读数据、放锁。听起来很合理,但在多卡场景下锁的代价比单卡大得多。一次加锁解锁涉及到跨设备原子操作,如果锁竞争稍微激烈一点,等待时间会明显上升。更重要的是,锁把“写完后读”这个并发关系强行变成了串行关系,本质上还是在排队。我的业务场景里,大多数时候生产者和消费者的时间窗口并不重叠,锁的大部分开销都花在了准备“可能冲突”上,而不是解决真正的数据依赖。

于是我把目光放到原子计数器和内存栅栏上。所谓无锁同步,就是不用排他锁,而是用一个不断递增的计数器告诉对方“我进行到哪一步了”。这种思路在单机多线程里很常见,迁到跨卡场景的关键在于:怎么让“计数器可见”这件事足够快,怎么在“看到计数器”的同时不丢失数据可见性。这正好引出了三剑客的分工。

1.2 生产–消费模型里的三个时间点

跨卡同步本质上是在回答三个时间点的问题。

第一个时间点是“数据何时真正可读”。生产者写完一批数据,数据可能还残留在寄存器或缓存里,如果不做任何处理,消费者启动后读到的可能是旧版本。这个时间点由发布操作决定,也就是 pub_release 的职责。它要保证的不只是“我写了数据”,还包括“我写的数据其他人能看到”。

第二个时间点是“消费者何时知道该读了”。消费者不能一直盲猜,它需要观察某个共享状态。这个状态最合适的形式就是一个单调递增的计数器,当计数器数值大于等于某个目标,说明本次数据已经发布。这个等待动作必须是有界的,否则一旦生产者异常,消费者就会永远在轮询,白白占满 GPU 线程。这就是 wait_ge_bounded 存在的理由。

第三个时间点是“知道信号之后,如何保证读到的是新数据”。计数器到达目标值,只说明“生产者执行到了原子递增那一步”,并不自动说明业务数据已经全部可见。消费者必须在读业务数据之前插入一个内存栅栏,也就是 acq_fence,把之前的观察动作变成真正的数据同步点。这三个时间点分别对应三剑客,顺序上是发布、等待、获取,逻辑上是一条完整的因果链:发布方产生因果,等待方观察因果,获取方消费因果。

1.3 三剑客选型对比

有人会问:为什么不直接用更高级的同步原语,比如事件对象或者消息队列?事件对象在部分 GPU 编程框架里确实存在,也能做跨设备同步,但它通常只保证“设备 A 完成后设备 B 才能开始”,粒度太粗,无法精确到中间数据块的边界。消息队列则引入额外的内存拷贝和锁机制,不适合高频小数据量同步。

为什么不用“自旋锁 + 条件变量”?条件变量在设备端很难实现,轮询等待已经是设备端最轻量的做法。三剑客的优势在于发布侧只有一次系统域原子操作,等待侧只有一次系统域原子加载,获取侧只有一次系统域内存栅栏。与全卡同步的“排空所有流”相比,它只影响本 kernel 内需要等待的那些线程,其他流完全不受干扰。与锁相比,它没有临界区,生产者和消费者可以真正并行。实时性上,三剑客属于忙等,适合生产者频繁、稳定释放数据的场景。如果生产者可能长时间不产出,就需要在有界等待参数中加超时或回退路径,这就是 wait_ge_bounded 中 bounded 的价值,后面我会重点讲这个度怎么把握。

2. 三剑客各自的本职工作

2.1 pub_release:发布动作与内存可见性的双重要求

pub_release 可以拆成两个词来看:pub 表示发布,release 表示释放语义。发布是指把计数器递增上去,让别的设备上的消费者看到新数值;释放语义则约束内存序,确保在此之前的所有写操作都在这次发布动作之前全局可见。

为什么释放语义如此重要?先看一个反例。如果我在生产者核函数里直接调用系统域原子加,把计数器从 0 加到 1,但业务数据是用普通写操作写入的。由于缓存和内存层级的存在,业务数据的写入可能还停留在本设备的缓存中。消费者看到计数器等于 1,立刻去读业务数据,有可能读到旧值。这不是原子加本身的问题,而是“计数的可见”和“数据的可见”没有绑定在同一个因果链上。

正确的做法是:先写业务数据,再在业务数据写操作和原子加之间插入一个发布侧内存栅栏,或者直接使用带 release 语义的系统域原子操作。这样,业务数据的写操作会在原子加之前被刷到对等设备可见的层级。用生活化类比来说:你不能先按门铃再往门口放快递,应该是快递先落地、门铃后响,不然客人开门却拿不到东西。

实现上,发布侧的关键是这两点:业务数据的写入必须在系统域栅栏之前完成;系统域原子加必须在栅栏之后发起。很多人在写代码时搞反顺序,把原子加放在前,然后才做栅栏,这就把一个“发布”变成了“未发布的预写”。如果你的框架支持带内存序的原子加,也可以把 release 参数直接写进去;如果只支持普通原子加,就用“先栅栏、再原子加”的兼容写法。

2.2 wait_ge_bounded:有界等待到底有多重要

wait_ge_bounded 这个名字信息量很大:wait 是等,ge 是大于等于,bounded 是有界。连起来就是“等计数器大于等于目标,但最多等这么久”。

先说“大于等于”而不是“等于”。多生产者场景下,计数器可能一口气从 0 加到 5,消费者想等第 3 批数据,如果只判断“等于 3”,那一瞬间可能已经错过,条件永远不成立。所以判断条件必须是 >= 目标值。这一点在单生产者单消费者时没什么区别,一旦进入多卡流水线,用 == 判断几乎必死。

再说“有界”。设备端线程等待时,最直觉的写法是 while(atomicLoad(counter) < target);。如果没有上限,生产者一旦崩溃,消费者 kernel 会占着所有线程空转到超时被系统杀掉。就算生产者没崩溃,只是偶尔慢了一次,这种忙等也会让 GPU 占用率居高不下,实际上是在用功耗换同步延迟。有界等待的正确姿态是:设一个最大重试次数,每次轮询之间加一个很短的时间退避,让出部分总线带宽,超过次数就返回超时错误码,走回退路径。

这里有个很容易踩的坑:很多人把 bounded 的数值定得过大,比如让消费者转几十万次才放弃。这样做的坏处不是逻辑错误,而是超时时间太长,系统已经进入异常状态,前端流程早就该报错了,后端还在空转。我一般会把最大重试次数调到一个“正常路径只需几十次、异常路径最多几十毫秒”的量级,宁可误报一次超时,也不要让异常场景拖太久。

2.3 acq_fence:获取侧的内存栅栏为什么不能省

消费者等到计数器 >= 目标之后,第一反应是直接读数据。严谨地说,这时候还不能直接读。计数器的加载只完成了“我看到信号了”这一步,但信号本身的观测顺序并不保证业务数据也是最新。想象一下,生产者的发布语义保证了“数据先于信号”;消费者如果想要读到这份因果,就必须在“观察信号”和“读取数据”之间建立对应的获取语义。

acq_fence 做的就是这件事。它告诉内存系统:在它之后的所有读操作,必须等到它之前的观测结果已经同步完成。用一句话总结,就是“先签收、再拆箱”。顺序上至少要满足:原子加载计数器并观察到目标值,执行获取栅栏,然后读取业务数据。

还有一个细节值得单独说:获取栅栏要和发布栅栏成对出现。release 侧保证数据写在信号前,acquire 侧保证信号后的读操作不会越过信号。如果只有 release 没有 acquire,消费者读取数据时可能由于自己的缓存乱序,把业务数据先读了,然后再去确认信号,那因果就反了。如果只有 acquire 没有 release,生产者那边数据没有刷出,消费者这边再怎么获取也没有用。所以这三把剑一定是一套组合拳,不能只上其中一把。

2.4 一个计数器是怎么把三者串起来的

梳理一下完整的数据流。生产者执行:写入业务数据 -> 发布栅栏(release)-> 计数器原子加。消费者执行:循环读取计数器 -> 观测到 >= 目标 -> 获取栅栏(acquire)-> 读取业务数据。两个核函数之间没有锁,没有事件等待,没有整卡排空,只有一个 32 位的计数器作为信使。

计数器本身既承担了“数据块编号”的信息,也承担了“同步信号”的角色。因为它的值从 N 变成 N+1,不仅意味着第 N 块数据已经发布,还意味着第 N 块数据之前的所有写操作都已经全局可见。这里有个推论:消费者拿到目标值后,不需要针对每一块数据单独同步,之前的块数据也都一并可见了。这个特性在做批处理流水线时特别有用,比如生产者已经发布了第 100 块,消费者直接从第 50 块开始读,读到第 100 块都不需要再同步。

这里隐含一个前提:计数器必须使用系统域原子操作。如果写成设备域原子,它只对当前设备可见,其他设备上的线程根本看不到更新。这是我在一次移植脚本时踩过的坑,等会放到常见问题里细说。

3. 工程落地:在我这边环境里的完整实现

3.1 环境准备与可行性检查

在写代码之前,先确认两件事:硬件是否支持跨设备系统域原子操作,以及运行时是否允许映射对等设备内存。

我的测试环境是内部一个 8 卡节点,卡与卡之间通过高速互连总线连接。我手头的这批卡支持系统作用域原子操作,但并不是所有商业 GPU 都支持。检查方式很简单:用设备属性里的原子系统相关字段查一下。如果不支持,系统域原子会退化成驱动层模拟,性能可能比锁还差,那三剑客就没有意义了。

第二个条件是内存可见性。要共享业务数据和计数器,最省事的方案是使用托管内存,让它自动映射到所有设备。不过托管内存的首次访问会触发页面迁移,如果数据量很大,迁移开销会盖过同步收益。另一种方案是显式分配两块等大内存,再用对等访问能力互相映射。我这次用前者,因为业务数据是每批定额分配、总量可控,页面迁移发生一次后就稳定了。

一个容易被忽略的问题是:计数器必须落在所有设备都能访问的地址空间。如果只有生产者卡把它当全局内存、消费者卡没有映射,那消费者的原子加载根本访问不到。解决方法是在每张卡上先做一次 touch,让它成为常驻映射。

3.2 生产者核函数:发数据、发信号

生产者核函数的伪代码如下,注意顺序不能乱:

__global__ void producer_kernel(float* buf, uint32_t* counter, int len) { // 第一步:写入业务数据 for (int i = threadIdx.x; i < len; i += blockDim.x) { buf[i] = compute_value(i); } __syncthreads(); // 第二步:发布栅栏 + 原子递增,计数器 +1 if (threadIdx.x == 0) { threadfence_system(); // release 侧栅栏 atomic_add_system(counter, 1); // 发布信号 } }

这里让一个线程做原子加,比所有线程同时加要干净很多。如果让 1024 个线程同时加,计数器会直接跳到 1024,消费端拿到的数值不可控,数据块编号也就失去意义。还有一些实现会先把块内结果归约出来,再由块内某个线程统一发布,这在多 block 场景下更合理。

threadfence_system()和atomic_add_system()的顺序为什么是先栅栏后原子加?因为业务数据是普通内存操作,不会因为原子加自带 release 语义就自动可见。普通数据的写操作必须在原子加之前先刷出。一旦这个顺序颠倒,消费者可能先看到计数器变化,再看到数据变化,正好违反了因果。如果你手头框架支持atomic_add_release_system,那就不需要单独调栅栏,但大多数兼容写法还是先栅栏再加。

3.3 消费者核函数:等信号、取数据

消费者侧的逻辑分三段:轮询等待、超时判断、获取后读取。伪代码如下:

__global__ void consumer_kernel(float* buf, uint32_t* counter, int len, uint32_t target, int max_tries) { uint32_t seen = 0; for (int t = 0; t < max_tries; ++t) { seen = atomic_load_system(counter); if (seen >= target) break; nanosleep_device(50); // 短暂退避,避免把互连总线打满 } if (seen < target) { // 超时:走回退路径,比如重新排队或上报主控 return; } // 获取栅栏:确保后续读到的业务数据是发布后的新值 threadfence_system(); // acquire 侧栅栏 for (int i = threadIdx.x; i < len; i += blockDim.x) { consume_value(buf[i]); } }

有两点值得注意。第一,atomic_load_system(counter)不是每个 GPU 编程框架都提供的原子读 API,有些框架只有原子加减和比较交换。如果没有原子读,可以用atomic_cas_system(counter, 0, 0)来实现“假装修改、实际读取”。这个技巧在老框架里很实用,但要注意它对缓存线的冲击比单纯读要大,不能用在超高频率的轮询里。第二,nanosleep_device是设备端暂停指令,能有效降低忙等时的带宽占用。我测试过不同退避粒度:完全不退避时,消费者会把互连带宽占满,生产者的写操作甚至会被延迟;退避不超过 100 纳秒时,同步延迟几乎不变,带宽占用却下降一个量级。所以这个退避不是可有可无的优化,而是忙等方案的默认操作。

3.4 有界等待的参数怎么定

wait_ge_bounded 的 bounded 在工程上就是 max_tries 和退避时间。这两个值没有绝对标准,但我归纳了一套可行的调参顺序。

第一步,先测出正常路径的重试次数。方法是将 max_tries 设大,在循环里记录 seen 的变化,观察从生产者发布到消费者观察到,通常需要几次轮询。我的环境中,PCIe 互联情况下通常 5 到 20 次,高速互连的情况下会少一些。第二步,把这个正常次数放大到 3 到 5 倍作为 max_tries 的下限,保证小抖动不误报。第三步,根据业务容忍度决定上限:如果超时后果只是重算一批数据,那可以放宽到 100 倍;如果超时会导致整个流水线重建,那宁可收紧,让错误尽早暴露。

还有一个地方很容易忽视:max_tries 和退避时间是相乘关系,不是相加。如果退避 50 纳秒、max_tries 1000,总等待时间是 50 微秒;如果把退避改成 500 纳秒,总等待时间就变成 500 微秒。调参时只改 max_tries 不看退避,会以为“我给了足够时间”,实际上一改退避就全变了。我习惯先固定退避,只调 max_tries,确定单变量之后再微调退避。如果计数器可能回绕,也就是接近无符号整数上限,这时用 >= 判断会出问题,需要改判差值或者升级成 64 位计数器,这是常见问题表之外的另一个备查点。

3.5 多卡流水线里的组合用法

单卡对单卡是最简场景,三剑客的价值在“多对多”流水线里更加明显。我的业务里有两张生产者卡、一张消费者卡,每个生产者卡上的两个流交替产出数据。此时不能只用单一计数器,需要给每个流分配独立的计数器槽位,消费者对不同槽位分别 wait_ge_bounded。为什么不能共用一个计数器?因为两个生产者的发布次数会相互累积,消费者无法判断某个数据块到底来自哪一张卡。

另一种常见结构是“生产者卡 -> 消费者卡 -> 下一级生产者”。这种情况下,消费者在读完数据之后,还需要作为下一级的发布者再执行 pub_release。也就是说,同一张卡既要有 acq_fence 也要有 pub_release,顺序是:等待上一级信号、获取栅栏、读取数据、写到下一级共享区、发布栅栏、原子加下一级计数器。这个链式结构让我深刻体会到三剑客为什么是“组合拳”:它完整体现了同步原语一对一的对称性,前一级的释放正好匹配下一级的获取。

4. 我在实际项目中踩过的坑

4.1 典型问题速查表

现象可能原因解决方法
消费者读到旧数据,计数器却已更新缺 acq_fence,或释放侧顺序错误获取栅栏放在读数据前,确认发布侧先写后发
消费者永远等不到信号原子操作用的是设备域而非系统域检查是否有 system scope 参数,改成系统域原子
计数器数值跳得特别大多个线程同时原子加块内归约后由单一线程发布
生产者正常,消费者频繁超时退避时间太长缩短退避时间或调低误报阈值
互连带宽被占满,整体吞吐下降消费者轮询过密加 50~100 纳秒退避,观察吞吐变化
所有卡跑得都慢,像串行业务数据常驻托管内存反复迁移预先 touch 页面,或改用对等映射显存

这个表里其实有一个共同的检查思路:先分清楚是“可见性问题”还是“性能问题”。可见性问题表现为数据错误、同步卡死,性能问题表现为吞吐上不去但结果正确。两者的排查方向完全不同,前者查栅栏和原子作用域,后者查退避和内存映射。如果你能先确认结果对不对,再谈性能,通常能少走一半弯路。不要一上来就怀疑硬件或编译器,先把这三个时间点的顺序打印出来,大部分问题当场就能定位。

4.2 一组有参考价值的性能数据

为了说明三剑客的效果,我在 8 卡集群上做过一次对照实验,单独看“一批 64 MB 数据从卡 A 传到卡 B 并完成消费”的整体耗时。第一版用设备级全卡同步,整体耗时约 1.2 ms;第二版用锁同步,由于锁竞争和额外指令,耗时约 0.8 ms;第三版用三剑客,耗时约 0.35 ms。不要把这个数字当作绝对标准,因为硬件互连速度、数据量、kernel 占用率都会影响结果,但相对关系在多次重复下很稳定:三剑客比锁同步快一倍以上,比全卡同步快三倍以上。

更有意思的是可扩展性数据。当生产者从 1 张卡加到 4 张卡时,锁同步的耗时几乎线性上涨,因为锁竞争随着发布频率增加而变激烈;三剑客的耗时只增加了约 20%,因为每个槽位是独立计数器,互不干扰。这个特性让我在后续设计里更愿意把输入拆成多个独立槽位,而不是集中在一个大缓冲区上排队。这也是无锁方案在实打实的高频并发上最值得投入的地方:它的退化曲线比锁平缓得多。

4.3 几个调试小技巧

遇到同步问题,最忌讳直接盯着业务数据看。我第一个动作永远是加一个独立调试计数器,在发布侧、等待侧、获取侧分别记录状态。比如让生产者在发布前把状态值写进调试数组,让消费者在观察到信号后也写一行“已签收”。然后跑一个小规模用例,把调试数组打出来,看三个时间点的先后顺序是否符合预期。如果发布侧已经记录“已发布”,但等待侧迟迟不记录“已签收”,问题大概率在可见性,也就是作用域或栅栏。

第二个技巧是把“等待成功”做成可观测事件,而不是只在内部跳过去。可以在消费者核函数里用原子加记录成功次数和超时次数,主控端每跑完一个批次读一次。这样我从宿主机上就能看到正常路径的成功次数是否稳定、超时次数是不是 0。一旦超时次数开始增长,立刻能定位到是退避太长还是生产者真的异常,不用每次都重新跑一遍全流程。

第三个技巧是刻意制造异常来验证有界等待。比如让生产者核函数故意在写完一小部分数据后退出,消费者应该触发超时回退,而不是空转到底。如果杀进程前消费者已经自行返回错误码,说明 bounded 逻辑是可靠的。这个测试真的能帮大忙,因为很多有界等待写到最后都变成了“伪有界”,边界条件根本没触发过。我在一次代码评审里看到别人的实现,max_tries 设了 1000 万次,表面上有界,实际上异常时要空转几十秒,和死循环已经没有本质区别。

5. 写在最后:这套方案还能怎么扩展

三剑客并不只适用于双卡通讯。把它稍微变形一下,可以用来实现跨设备的屏障同步:所有参与者各持一个槽位,全部发布完成后,最后一个到达者负责唤醒策略。也可以用多个计数器实现优先级:消费者先等低优先级槽位到目标,再等高优先级槽位,形成简单的调度语义。我在后续项目里还把它用在了“卡间梯度聚合”的同步点上,效果比直接全卡同步稳定得多。

每次有人问我要跨卡同步的通用模板,我会先反问一句:你真正需要的是同步,还是只是需要避免还没准备好的数据被读到?如果是后者,三剑客确实是性价比很高的选择;如果是前者,建议在它的基础上再包一层超时管理和错误回退。项目里没有银弹,但至少这套方案的每一步都能被观测、被调参、被替换,这对维护异构计算代码的人来说,比什么都重要。我个人的体会是,任何同步方案最终都要落到“可控的失败”上——无锁可以让正常路径跑得飞快,但边界路径越简单,线上救火就越容易。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询