两个 GPU block,各自写一个数,等对方写完,再一起算 1 + 2。预期结果是 [3, 3],实际却出现了 [1, 3]。
这是我在 TileLang 的 CuTeDSL 后端修的一个同步问题。原因很短:用 tid.x == 0 选取 block 代表线程,在二维、三维线程布局里,会选出不止一个。
PR #3400 已于 2026 年 10 月 10 日合并。改动不大,但这个失败用例能说明:同步代码里,一个"只执行一次"的判断是怎么失效的。
tid.x == 0,到底选中了几个线程?
这段 sync_grid() 用全局计数器记录已经到达 barrier 的 block 数量。每个 block 派一个线程,把计数加一。最后到达的 block 重置计数器,并推进 generation,通知其他 block 继续执行。
每个 block 必须只上报一次。
一维 block 里,tid.x == 0 没问题。换成 (32, 2),下面两个线程都满足这个条件:
text
(tid.x, tid.y, tid.z) = (0, 0, 0)
(tid.x, tid.y, tid.z) = (0, 1, 0)
于是,同一个 block 会增加两次计数。如果整个 grid 只有两个 block,它就可能独自把"人到齐了"的条件凑出来,另一个 block 此时还没写数据。
原子加法挡不住这个问题。它能保证两次更新都被正确计入,却不会判断这两次更新是不是来自同一个 block。
用两个整数,把提前放行测出来
回归用例只启动两个 block。输入初始为零,block 0 写入 1,block 1 写入 2,同步后各自读取两者的和。
关键是让 block 1 晚一点:在它写数据之前,用有限的 device-clock 循环延迟,给 block 0 留出先到达 barrier 的时间。
下面是简化流程,省略了实际的线程判断、计时和编译代码:
text
block 0:A[0] = 1 → sync_grid() → B[0] = A[0] + A[1]
block 1:延迟 → A[1] = 2 → sync_grid() → B[1] = A[0] + A[1]
预期:B = [3, 3]
当时在 RTX 5090 上运行原生 CuTeDSL 测试,基线的一维配置通过;换成 (32, 2) 后,得到了 [1, 3]。block 0 提前读到了仍为零的 A[1]。
这里需要故意错开到达时间。如果两个 block 碰巧都写完了,即使 barrier 有问题,答案也可能正确。测试中的读写还限定在 (0, 0, 0),避免同一个 block 内多个线程抢着写结果,让问题集中在同步行为上。
修复只动 leader 的选择
修复后的代表线程是 (0, 0, 0)。实际 PTX 把 %tid.x、%tid.y、%tid.z 按位或,再判断结果是否为零。三个坐标都为零,才允许上报。
写成容易读的伪代码,就是:
python
# 原来的判断
is_leader = tid_x == 0
# 修复后的语义
is_leader = (tid_x == 0 and tid_y == 0 and tid_z == 0)
原有的计数器与 generation 协议、内存 fence 和 cooperative launch 要求都保留。这次只修正多维 block 选出多个 leader 的问题。实际修改和回归测试见 PR diff。
验证时,缓存也要关掉
回归测试覆盖四种线程布局:
32:保留原本正常的一维路径。(32, 2):在 y 方向展开。(32, 1, 2):在 z 方向展开。(16, 2, 2):同时在 y、z 方向展开。
每种配置调用 kernel 八次,每次清零输入输出,再检查结果。这检查了 barrier 状态在多次调用间的复用;如果不清零,提前读取上一轮留下的正确数据,也可能让错误漏过去。
修复后四种配置全部通过,并在第二个新进程中再次通过。基线用了 -x,在二维配置失败后就停止了,没有跑完另外两种配置。
最终 A/B 关闭了 kernel 缓存,避免修改 helper 后仍加载旧 cubin。环境是 RTX 5090、Python 3.12、Torch 2.11.0+cu130 和 CuTeDSL 4.7.0。两组都使用 TileLang 0.1.15 的原生编译器与运行时;基线 helper 与当时 main 的 994b44e 一致,候选组只替换修改过的 Python DSL helper。
这包含真实的编译和 cooperative GPU launch,但没有完整重编 main 的 C++ 部分。以上是 原 PR 中的历史测试记录,本文整理时没有重新跑 GPU 测试,也没有做性能比较。并发 stream 共用 barrier 状态是另一个问题,不在这次修复范围内。
这份回归最后只用了两个 block、两个整数。它要抓住的也只有一件事:数据还没准备好时,谁先越过了同步点。
原文发布于我的个人博客;本文使用 AI 辅助整理,代码和历史测试结果可在上述 PR 中核对。