§ 00 · The phenomenonPrologue
§ 00 · 现象序言
A kernel pull request usually arrives with two pieces of evidence: a green unit test and a speedup table. Both are real. Then the kernel ships inside a model, and one of three things happens. The model answers in garbage: on gfx1250, one server loaded, served, and printed “!” for every token, scoring 0.0 on GSM8K with no error anywhere in its log (ATOM #1750). A multi-GPU run hangs, or finishes and returns stale data. Or nothing happens for a week, until one request takes a path nobody tried. People who maintain kernel libraries learn to say it in one line: it passed the unit test and failed in the model.
一个 kernel PR 通常带着两样证据: 一个通过的单测, 一张加速比表格。 两样都是真的。 然后这个 kernel 进入模型, 接下来会发生三种情况之一。 模型开始输出乱码: 在 gfx1250 上, 有一个服务器正常加载、 正常服务, 却对每个 token 都输出 「!」, GSM8K 得分 0.0, 日志里没有任何报错(ATOM #1750)。 多卡运行挂住, 或者正常结束却返回陈旧的数据。 或者一周都相安无事, 直到某个请求走进一条没人试过的路径。 维护 kernel 库的人会把它总结成一句话: 单测过了, 跑模型出问题。
This is not one bug. It is a gap in what the test covers. A unit test checks a kernel at a handful of points: a few shapes, random inputs, one GPU, one stream, a fresh process. A model run is a distribution over a much larger space. The test proves correctness only at the points it checks, and a model spends almost all of its time somewhere else.
这不是某一个 bug, 而是测试覆盖范围上的缺口。 单测只在少数几个点上检查 kernel: 几个形状、 随机输入、 一张卡、 一个 stream、 一个新进程。 模型运行则是一个大得多的空间上的分布。 测试只能证明它检查过的点正确, 而模型几乎所有时间都在别处。
This edition takes its cases from the place where the gap is widest: a new architecture. Every case dates from June to October 2026. In those months, kernels for gfx1250, the architecture of AMD's MI450 series, were brought up across the kernel libraries, and FlyDSL went from its v0.3 roadmap, which named “full gfx1250 support, validated end-to-end” as a focus, to the v0.4.0 release on 14 October. Ported kernels met a new wave size, new matrix instructions, a new way to move data into LDS, new wait counters, a young compiler and almost no CI. Every case below is a public pull request or issue in ROCm/FlyDSL, ROCm/aiter, ROCm/ATOM or SGLang.
这一版的案例来自缺口最大的地方: 新架构。 每个案例都发生在 2026 年 6 月到 10 月之间。 这几个月里, 各个 kernel 库在为 gfx1250(AMD MI450 系列的架构) 移植和上线 kernel, FlyDSL 则从 v0.3 路线图(其重点之一是 「完整支持 gfx1250, 并做端到端验证」) 走到了 10 月 14 日发布的 v0.4.0。 移植过来的 kernel 遇到了新的波宽、 新的矩阵指令、 新的 LDS 搬运方式、 新的等待计数器、 年轻的编译器, 以及几乎没有的 CI。 下面每个案例都是 ROCm/FlyDSL、 ROCm/aiter、 ROCm/ATOM 或 SGLang 里的公开 PR 或 issue。
This guide does three things. It names the seven dimensions along which a test and a model differ. For each one, it explains the mechanism from first principles and shows it with public gfx1250 and FlyDSL bugs. Then it turns the seven gaps into a validation ladder: what to test, in what order, and what each rung catches that the rung below cannot.
这篇 guide 做三件事。 第一, 列出测试和模型不同的七个维度。 第二, 对每个维度从第一性原理解释机制, 并用 gfx1250 和 FlyDSL 的公开 bug 说明。 第三, 把七个缺口变成一套验证阶梯: 测什么、 按什么顺序测、 每一级能抓到下一级抓不到的哪类问题。
A unit test pins every dimension it does not vary. Every pinned dimension is a place where production can differ, and a place where a bug can hide in plain sight.
单测会固定所有它没有改变的维度。 每一个被固定的维度, 都是生产环境可能不同的地方, 也是 bug 可以明目张胆藏起来的地方。
§ 01 · A test is a point, a model is a distributionThe gap
§ 01 · 测试是点, 模型是分布缺口在哪里
Start from what must be true of any kernel. Its output depends on more than the values of its arguments. It depends on the shape, which decides the code paths and tile tails that run; on the values, which decide the numeric regimes it hits; on the layout and dtype the caller hands over; on the execution context, such as graph capture, streams and caches; on timing, meaning which wave arrives first; on how many GPUs take part; and on the software and hardware it is built and run with. These are the seven dimensions.
先从任何 kernel 都成立的事实出发。 kernel 的输出不只取决于参数的值, 还取决于: 形状, 它决定哪些代码路径和 tile 尾块会执行; 数值, 它决定会碰到哪些数值区间; 调用方传进来的布局和 dtype; 执行环境, 例如 graph 捕获、 stream 和缓存; 时序, 也就是哪个 wave 先到; 参与的 GPU 数量; 以及编译和运行它的软件与硬件。 这就是七个维度。
A unit test is cheap because it pins most of them to convenient values: small tile-aligned shapes, torch.randn inputs, layouts produced by its own helper, eager mode on the default stream, one call, one GPU, the repository's own build. For a fast test, that is the right trade-off. The cost is that each pinned value hides a regime where the kernel's behavior changes. Plate I puts the two sides next to each other.
单测之所以便宜, 是因为它把大多数维度固定在方便的值上: 小的、 按 tile 对齐的形状, torch.randn 生成的输入, 测试自己的辅助函数生成的布局, 默认 stream 上的 eager 模式, 调用一次, 一张卡, 仓库自己的构建。 对快速测试来说, 这是正确的取舍。 代价是: 每个被固定的值都藏起了一个 kernel 行为会改变的区间。 图 I 把两边并排放在一起。
A unit test pins each dimension to a convenient value; a model run sweeps it. Each arrow is a gap where a public gfx1250 or FlyDSL bug has lived.
单测把每个维度固定在方便的值上, 模型运行则把它整个扫一遍。 每一条箭头都是一个 gfx1250 或 FlyDSL 的真实 bug 待过的缺口。
Why not test everything? Because the space is a product. Ten shapes times five value distributions times three layouts times four execution modes times three GPU counts is already 1,800 configurations, before timing is even considered, and timing is not a parameter you can set. Exhaustive testing is not the goal. The goal is to place test points where behavior changes: at boundaries, at the extremes of the data, at the contracts between kernels, and in modes only production uses.
为什么不全部测一遍? 因为这个空间是乘积。 10 种形状 × 5 种数值分布 × 3 种布局 × 4 种执行模式 × 3 种 GPU 数量, 已经是 1,800 种配置, 这还没算时序, 而时序根本不是一个能设置的参数。 目标不是穷举, 而是把测试点放在行为会改变的地方: 边界、 数据的极端、 kernel 之间的契约, 以及只有生产环境才用的模式。
§ 02 · one port, every gapWhy gfx1250 opens every gap at once
§ 02 · 一次移植, 所有缺口为什么 gfx1250 让所有缺口同时变大
Every gap in this guide exists on mature hardware. A port to a new architecture opens all of them at once, for a first-principles reason: a kernel encodes facts about the machine it was written for. It assumes how many lanes a wave has, which matrix instruction shapes exist, how data moves into LDS, what a barrier waits for, and what a cache bit means. It also assumes facts about its toolchain and its fleet: what the compiler adds, which machines run the tests, which shapes have tuned configs. When the target changes, each assumption becomes a place where code can compile, pass on the old hardware, and be wrong on the new one.
本文的每个缺口在成熟硬件上都存在。 移植到新架构会让它们同时变大, 原因可以从第一性原理推出来: kernel 里写死了它所针对的那台机器的事实。 它假设一个 wave 有几个 lane、 有哪些矩阵指令形状、 数据怎样搬进 LDS、 barrier 等待什么、 一个 cache 位是什么意思。 它还假设了工具链和机群的事实: 编译器会额外加什么、 测试在哪些机器上跑、 哪些形状有调优配置。 目标一换, 每个假设都会变成一个能编译、 在旧硬件上能通过、 到新硬件上却出错的地方。
gfx1250 changed most of these at once. Its waves have 32 lanes, although it is not an RDNA part, so every “not RDNA, therefore 64 lanes” rule is wrong on it. Matrix work goes through WMMA, whose FP8 form is 128 deep in K. Copies into LDS use the tensor data mover (TDM) or async global loads, and the old buffer-to-LDS load cannot be lowered. Waits are split into separate counters, and a plain barrier does not wait for LDS stores to land. Plate II lists what the ported code assumed, what gfx1250 does instead, and the public bug each change produced.
gfx1250 一次改变了其中大部分。 它的 wave 有 32 个 lane, 但它不是 RDNA, 所以每一条 「不是 RDNA, 就是 64 lane」 的规则在它上面都是错的。 矩阵运算走 WMMA, 其中 FP8 形式在 K 方向深 128。 拷贝进 LDS 用 tensor data mover(TDM) 或 async global load, 旧的 buffer 直写 LDS 的 load 无法 lower。 等待被拆成了多个独立的计数器, 一个普通的 barrier 也不会等 LDS 写入完成。 图 II 列出了移植过来的代码假设了什么、 gfx1250 实际怎么做, 以及每一处变化产生的公开 bug。
A kernel encodes facts about the machine it was written for. On gfx1250 many of those facts changed at once. Each row is an assumption that compiled, passed on the old hardware or in the old CI, and broke on the new part.
kernel 里写死了它所针对的那台机器的事实。 在 gfx1250 上, 很多事实同时变了。 每一行都是一个能编译、 在旧硬件或旧 CI 上能通过、 到新芯片上就出错的假设。
Two structural changes widen the gaps further. The first is the fleet. aiter's default PR CI runs its op tests on gfx942 and gfx950; gfx1250 runs only through an opt-in Triton suite, as the CI guide posted on each aiter PR lists (for example on aiter #6348). FlyDSL's gfx1250 PRs say the same in their test notes: “Tested on gfx1250 (no gfx1250 CI runner)” in FlyDSL #1199, and “CI skips these tests on its runners” in FlyDSL #1264. Correctness on gfx1250 rests on each author's own runs, on each author's machine. The second is the machines themselves. In FlyDSL #1228, two gfx1250 machines with the same pinned LLVM but different driver versions passed the same correctness tests, while one split-K fence cost about 0.03 µs per workgroup on one machine and about 0.3 µs on the other. A result from one machine says less about the next one than it seems.
还有两处结构性的变化让缺口进一步变大。 第一是机群。 aiter 默认的 PR CI 只在 gfx942 和 gfx950 上跑算子测试; gfx1250 只能通过一个可选的 Triton 测试集来跑, 每个 aiter PR 上自动贴出的 CI 说明都这样列出(例如 aiter #6348)。 FlyDSL 的 gfx1250 PR 在测试说明里也写着同样的事: FlyDSL #1199 写 「在 gfx1250 上测试(没有 gfx1250 CI runner)」, FlyDSL #1264 写 「CI 在它的 runner 上跳过这些测试」。 gfx1250 上的正确性只能靠每个作者自己在自己的机器上跑。 第二是机器本身。 在 FlyDSL #1228 里, 两台 gfx1250 机器用同一个锁定的 LLVM, 驱动版本不同, 同样的正确性测试都通过了, 但同一个 split-K fence 在一台机器上每个 workgroup 约 0.03 µs, 在另一台上约 0.3 µs。 一台机器上的结果, 对下一台机器的说明力比看上去要弱。
None of the bugs below is a new kind of bug. Each one is an old gap, opened by one of these changes. The next seven sections take the gaps in order.
下面没有一个 bug 是新类型的 bug。 每一个都是旧的缺口, 被上面某一处变化撑大了。 接下来七节按顺序讲这七个缺口。
§ 03 · Gap ① · boundaries the test never reachesShapes and sizes
§ 03 · 缺口 ① · 测试碰不到的边界形状与尺寸
Why do shapes matter at all? Because a kernel is written for the common case and patched for the rest. It computes full tiles fast and handles the last partial tile, the tail, with masks or clamps. It computes addresses in 32-bit arithmetic, because 64-bit address math costs registers and instructions on every load. Both choices are correct inside a range. Outside it, they fail without an error.
形状为什么重要? 因为 kernel 是为常见情况写的, 其余情况靠补丁。 它高速处理完整的 tile, 最后那个不完整的 tile, 也就是尾块, 用 mask 或 clamp 处理。 它用 32 位算术计算地址, 因为 64 位地址运算在每次读取时都要多花寄存器和指令。 这两个选择在一个范围内都是对的。 超出范围, 它们会出错, 而且不报错。
The 32-bit choice has two cliffs. A signed 32-bit index wraps at 2³¹. A 32-bit byte offset from a buffer's base wraps at 2³² bytes, which is 4 GiB. A test at typical sizes never comes near either. A long prefill through a vocabulary projection does. In aiter #6402, the gfx1250 BF16 GEMM that the default config selects stores C at offsets from the start of C. At 7067 × 151936 the output is 2,147,463,424 bytes, just under 2³¹, and every row is right. One row more, and every row from 7067 on is never written. Those rows keep whatever the buffer held: 8192 × 151936 returned 332,463 non-finite values, and no error. In the sweep of 1146 untuned shapes in aiter #6397, all 6 shapes with an output above 2³¹ bytes came out wrong.
32 位的选择有两道悬崖。 有符号 32 位索引在 2³¹ 处回绕。 相对 buffer 起点的 32 位字节偏移在 2³² 字节, 也就是 4 GiB 处回绕。 典型尺寸的测试离两道悬崖都很远, 一次经过词表投影的长 prefill 却不远。 在 aiter #6402 里, 默认配置在 gfx1250 上选中的 BF16 GEMM, 按相对 C 起点的偏移写 C。 7067 × 151936 的输出是 2,147,463,424 字节, 刚好低于 2³¹, 每一行都对。 多一行, 第 7067 行之后的每一行就都不会被写。 这些行保留 buffer 里原来的内容: 8192 × 151936 返回了 332,463 个非有限值, 却没有任何报错。 在 aiter #6397 对 1146 个未调优形状的扫描里, 输出超过 2³¹ 字节的 6 个形状全部出错。
The FlyDSL kernel in the same issue fails at the other cliff, in a subtler way. Its stores take a 32-bit byte offset from the start of C, and it relies on the buffer's record count to drop the rows of the last tile that lie past M. At M = 14134 the output is 4,294,926,848 bytes, about 40 KB under 2³². The tensor fits. The last 256-row tile does not: its 202 rows past M cross 2³², wrap to the start of C, land inside the record count, and overwrite rows 0 to 201. Plate III draws both cliffs.
同一个 issue 里的 FlyDSL kernel 撞上了另一道悬崖, 方式更隐蔽。 它的 store 用相对 C 起点的 32 位字节偏移, 并依靠 buffer 的 record count 丢掉最后一个 tile 里超出 M 的行。 M = 14134 时输出是 4,294,926,848 字节, 比 2³² 少约 40 KB。 张量放得下, 最后一个 256 行的 tile 放不下: 它超出 M 的 202 行越过 2³², 回绕到 C 的起点, 落进 record count 之内, 覆盖了第 0 到 201 行。 图 III 画出了两道悬崖。
One GEMM, two kernels, two cliffs. The Gluon kernel that the default config selects stops writing at byte 2³¹. The FlyDSL kernel's output fits under 2³², but the padding rows of its last tile do not: their offsets wrap and overwrite the first rows. Neither reports an error.
同一个 GEMM, 两个 kernel, 两道悬崖。 默认配置选中的 Gluon kernel 在第 2³¹ 字节处停止写入。 FlyDSL kernel 的输出在 2³² 以内, 但最后一个 tile 的填充行不在: 它们的偏移回绕, 覆盖了最前面的几行。 两者都不报错。
A cliff can also wait for the process to age. In ATOM #2533, the Triton MLA decode on gfx1250 multiplied each int32 KV row id by the row stride, 576, in int32. Rows from 3,728,270 on, that is 2³¹ / 576, wrap. A fresh server never reaches them. On Kimi-K3 with eight data-parallel ranks, prefix caching filled each pool past 90%. Then decodes on every rank went NaN at the first token of a block above 233,016 and answered “!!!!”. The writes stayed correct, because the cache kernels compute slots in int64. Only the reads wrapped.
悬崖也可以等进程 「变老」 才出现。 在 ATOM #2533 里, gfx1250 上的 Triton MLA decode 把每个 int32 的 KV 行号乘以行步长 576, 乘法也在 int32 里做。 从第 3,728,270 行(也就是 2³¹ / 576) 起就会回绕。 刚启动的服务器永远走不到这些行。 在 8 个数据并行 rank 的 Kimi-K3 上, prefix caching 把每个 rank 的池填到 90% 以上。 之后每个 rank 的 decode 都在第 233,016 个 block 之后的第一个 token 处变成 NaN, 并输出 「!!!!」。 写入是对的, 因为 cache kernel 用 int64 计算槽位。 只有读取回绕了。
Tails fail the same quiet way. In FlyDSL #1199, the gfx1250 A8W4 MoE GEMM computed its K-loop count as K // tile_k. With K = 640 and tile_k = 256 it dropped the last 128 columns of K: the output matched a reference that uses only the first 512 columns (rel L2 0.17%) and was 60% off the full-K reference. FlyDSL #1230 found the same floor in the gfx1250 BF16 GEMM, at a real model width. gpt-oss uses K = 2880, a multiple of 64 but not of 128, so on main only 64-wide K tiles gave correct results. The docstring stated the rule. Nothing checked it, and no test used a K that the tile does not divide. aiter #6344 is the same story for N: the gfx1250 RMSNorm never wrote the last N % 8 elements of a row, for every such N tried, from 510 to 8190.
尾块也是这样悄悄出错的。 在 FlyDSL #1199 里, gfx1250 的 A8W4 MoE GEMM 用 K // tile_k 计算 K 循环次数。 K = 640、 tile_k = 256 时, 它丢掉了 K 的最后 128 列: 输出和一个只用前 512 列的参考实现吻合(rel L2 0.17%), 和完整 K 的参考实现相差 60%。 FlyDSL #1230 在 gfx1250 的 BF16 GEMM 里发现了同样的向下取整, 而且是在真实模型的宽度上。 gpt-oss 的 K = 2880, 是 64 的倍数, 但不是 128 的倍数, 所以在 main 上只有 64 宽的 K tile 结果正确。 docstring 写了这条规则, 但没有任何代码检查它, 也没有测试用过 tile 不能整除的 K。 aiter #6344 是 N 方向的同一个故事: gfx1250 上的 RMSNorm 从不写一行的最后 N % 8 个元素, 从 510 到 8190, 试过的每一个这样的 N 都是如此。
A config can also fall outside the hardware's grid. FP8 WMMA on gfx1250 is 128 deep in K, yet the FP8 GEMM launchers in FlyDSL #1264 accepted a tile_k of 64. Such a kernel issues no WMMA and stores zeros: on 9728 × 8192 × 8192, t256x256x64 returned an all-zero C, without an error. No existing test or smoke config used such a tile, and CI skips this test file on its runners.
配置也可能落在硬件的网格之外。 gfx1250 上的 FP8 WMMA 在 K 方向深 128, 但 FlyDSL #1264 里的 FP8 GEMM launcher 照样接受 64 的 tile_k。 这样的 kernel 一条 WMMA 都不发, 只写零: 在 9728 × 8192 × 8192 上, t256x256x64 返回了全零的 C, 不报任何错。 现有的测试和冒烟配置都没用过这样的 tile, CI 也在它的 runner 上跳过这个测试文件。
Every tuned kernel library has a structural version of this gap. A shape without a table entry falls back to a default or heuristic config, so the fallback is often the least-tested code that production runs. On gfx1250 the tables are new and sparse, so more traffic takes it. The 2³¹ bug above lives on exactly that path: BF16 shapes without a tuned entry run the default kernel. In aiter #4987, DeepSeek-V4-Flash on gfx1250 had no tuned FlyDSL config for its MoE shape, so aiter built the heuristic fallback. That kernel used a buffer-to-LDS load that the gfx1250 LLVM backend cannot lower, and the process aborted during CUDA-graph capture. On gfx1250 the MoE path is FlyDSL-only, so there was no other route.
每个带调优表的 kernel 库都有这个缺口的结构性版本。 表里没有的形状会退回默认配置或启发式配置, 所以回退配置往往是生产环境在跑的代码里测得最少的一段。 gfx1250 的调优表又新又稀疏, 走回退路径的流量更多。 上面的 2³¹ bug 正好在这条路径上: 没有调优表项的 BF16 形状跑的就是默认 kernel。 在 aiter #4987 里, gfx1250 上的 DeepSeek-V4-Flash 找不到 MoE 形状的 FlyDSL 调优配置, 于是 aiter 构建了启发式回退 kernel。 这个 kernel 用了 gfx1250 的 LLVM 后端无法 lower 的 buffer 直写 LDS 的 load, 进程在 CUDA graph 捕获时直接退出。 gfx1250 上的 MoE 路径只有 FlyDSL, 没有别的路可走。
It is the safe default, but it costs registers and instructions on the hottest loops, so kernels keep 32-bit offsets where they can prove the range. The fix proposed in aiter #6402 keeps the fast path: move the buffer base of C to each tile's first row, so every 32-bit offset stays small. The rule is not “never use 32 bits”. The rule is “prove the range for the largest deployment, including the last tile, not for the test”.
它是安全的默认选择, 但会在最热的循环里多花寄存器和指令, 所以 kernel 在能证明范围的地方保留 32 位偏移。 aiter #6402 提出的修复保留了快速路径: 把 C 的 buffer 起点移到每个 tile 的第一行, 让每个 32 位偏移都保持很小。 规则不是 「永远别用 32 位」, 而是 「按最大的部署证明范围, 包括最后一个 tile, 而不是按测试」。
Test that catches it: one test per cliff, with outputs just below and just above 231 and 232 bytes, counting the last tile's padding rows; K and N values that the tile does not divide, at real model widths such as K = 2880; every tile config the launcher accepts, not only the tuned ones; and an explicit list of production shapes that have no tuning entry.
能抓住它的测试: 每道悬崖一个测试, 输出刚好低于和刚好高于 231、 232 字节, 并把最后一个 tile 的填充行算进去; tile 不能整除的 K 和 N, 用真实模型的宽度, 例如 K = 2880; launcher 接受的每一种 tile 配置, 而不只是调优过的那些; 以及一份明确列出 「没有调优表项的生产形状」 的清单。
§ 04 · Gap ② · real numbers are not randomReal data
§ 04 · 缺口 ② · 真实的数不是随机的真实数据
A unit test draws inputs from torch.randn at a few convenient lengths. Real traffic is not like that. Prompts are short and long. Router scores cluster, so two experts often score almost the same. Trained attention is peaked. Biases are not zero. Warmup traffic can be identical tokens, so every token routes to the same expert. Code that is exact for the test's inputs can fail on each of these.
单测从 torch.randn 里取输入, 只用几个方便的长度。 真实流量不是这样。 prompt 有短有长。 路由分数会扎堆, 两个 expert 经常得分几乎一样。 训练过的 attention 很尖锐。 bias 不是零。 预热流量可能全是相同的 token, 于是所有 token 都路由到同一个 expert。 对测试输入精确无误的代码, 在这几种情况下都可能出错。
ATOM #1750 is the clearest gfx1250 case. The KDA chunked-prefill kernel returned NaN on gfx1250 for prompts shorter than its chunk size, and its state layout could differ from what the decode kernel reads, so the first decode step went NaN too. NaN logits make argmax pick token id 0. The server loaded and served, and it answered “!” forever: GSM8K 0.0, with no error anywhere in the log. Only prompts shorter than one chunk trigger it. After the fix, which routes prefill through the recurrent kernel behind an environment switch, GSM8K on gfx1250 was 0.9575.
ATOM #1750 是最清楚的 gfx1250 案例。 在 gfx1250 上, 比 chunk 长度短的 prompt 会让 KDA 的分块 prefill kernel 返回 NaN; 它输出的状态布局也可能和 decode kernel 读取的布局不一致, 于是第一步 decode 也变成 NaN。 NaN 的 logits 让 argmax 选中 token id 0。 服务器正常加载、 正常服务, 却一直输出 「!」: GSM8K 0.0, 日志里没有任何报错。 只有比一个 chunk 短的 prompt 才会触发。 修复用一个环境变量开关, 把 prefill 改走 recurrent kernel, 之后 gfx1250 上的 GSM8K 是 0.9575。
Values hide indexing bugs, too. In aiter #5801, the gfx1250 TDM grouped GEMM in FlyDSL left the N-tile index out of its per-expert bias pointer, so every output tile after the first read the first tile's biases. The fix restored gpt-oss-120b accuracy on a vLLM server on gfx1250. The review asked for a regression test with several N-tiles and distinct, non-negligible biases. With one N-tile, or with the same bias everywhere, the wrong pointer reads the right values.
数值也会藏起索引 bug。 在 aiter #5801 里, gfx1250 上 FlyDSL 写的 TDM grouped GEMM, 在每个 expert 的 bias 指针里漏了 N-tile 编号, 于是第一个之后的每个输出 tile 都读到了第一个 tile 的 bias。 修复让 gfx1250 上 vLLM 服务的 gpt-oss-120b 精度恢复正常。 评审要求补一个回归测试: 多个 N-tile, 互不相同且不可忽略的 bias。 只有一个 N-tile, 或者所有 bias 都一样时, 错误的指针读到的恰好是正确的值。
Routing is the classic case. FlyDSL #1213 fixed the GLM-5 MonoKernel from #1204 one day after it merged. Validation with real GLM checkpoints in ATOM found that packing expert IDs into router scores could pick the wrong expert when scores were close: a captured TP8 routing replay needed expert 226, not 92. Random scores almost never come that close. Both PRs shipped in v0.4.0, and the gfx1250 port of the kernel, FlyDSL #1214, carries the fix.
路由是经典案例。 FlyDSL #1213 在 #1204 的 GLM-5 MonoKernel 合入一天后修复了它。 在 ATOM 里用真实的 GLM checkpoint 验证时发现: 把 expert ID 打包进路由分数, 在分数接近时会选错 expert; 一次抓取的 TP8 路由回放需要 expert 226, 而不是 92。 随机分数几乎不会接近到这种程度。 两个 PR 都进了 v0.4.0, 这个 kernel 的 gfx1250 移植 FlyDSL #1214 也带上了这个修复。
The numeric side follows the same pattern. In FlyDSL #1020, shipped in v0.3.4.1, an FP8 attention kernel cast softmax probabilities to e4m3 before the PV product. With thousands of keys most probabilities underflow, while the normalizer still counts them. With V set to ones the output should be 1.0; the kernel returned 0.637. The PR says it plainly: only peaked attention shows this, and a random-init model will not.
数值的一面也是同一个模式。 在 v0.3.4.1 发布的 FlyDSL #1020 里, 一个 FP8 attention kernel 在 PV 乘法之前把 softmax 概率转成了 e4m3。 key 有几千个时, 大部分概率下溢成 0, 而归一化因子仍然把它们算在内。 V 全为 1 时, 输出应该正好是 1.0, kernel 返回 0.637。 PR 原文说得很直白: 只有尖锐的注意力会暴露这个问题, 随机初始化的模型不会。
Skewed data can also expose a timing bug. In aiter #6127, identical warmup tokens on DeepSeek-V4's hash-routed layers skewed the FlyDSL MegaMoE schedule until a lagging wave read its tile index from an LDS slot that the next stage had reused, and faulted. Random routing hid it. § 07 returns to races.
倾斜的数据还能暴露时序 bug。 在 aiter #6127 里, DeepSeek-V4 的 hash 路由层收到相同的预热 token, FlyDSL MegaMoE 的调度因此严重倾斜, 一个落后的 wave 从一个已被下一阶段复用的 LDS 槽位里读出了自己的 tile 编号, 随即出错。 随机路由会把它藏起来。 § 07 再讲竞争。
Test with captured inputs. Record real prompts, activations, router scores, biases and weights from one model run, and replay them against the reference. Cover both sides of every length the kernel cares about: shorter than one chunk, longer than the top-k size. Add adversarial values on purpose: exact ties, near ties, one-hot routing, all tokens on one expert, distinct per-column biases, NaN and extreme magnitudes.
用抓取的输入做测试。 从一次模型运行里录下真实的 prompt、 激活、 路由分数、 bias 和权重, 拿它们对照参考实现回放。 覆盖 kernel 关心的每个长度的两侧: 比一个 chunk 短, 比 top-k 长。 再刻意加入对抗性的值: 完全相等、 几乎相等、 one-hot 路由、 所有 token 落在同一个 expert、 每列不同的 bias、 NaN 和极端的量级。
§ 05 · Gap ③ · correct relative to what?Contracts
§ 05 · 缺口 ③ · 相对什么才算正确?接口契约
A kernel is not correct in isolation. It is correct relative to a contract: this wave size, this layout, this dtype, these strides, this index width, these rows to skip. A model is a chain of kernels plus a framework, and every link in the chain is a contract between a producer and a consumer.
kernel 不是孤立地正确的。 它相对一份契约正确: 这种波宽、 这种布局、 这种 dtype、 这些 stride、 这种索引宽度、 跳过这些行。 模型是一串 kernel 加一个框架, 链条上的每一环都是生产方和消费方之间的契约。
A unit test hides contracts by construction. The test's helper produces the input in the layout the kernel expects, and the reference reads it the same way, so producer and consumer always agree. In the model, the producer is a quant kernel, a config merge, a checkpoint loader, or a framework that someone else wrote. On a new architecture, the hardware is a producer too.
单测在结构上就把契约藏起来了。 测试的辅助函数按 kernel 期望的布局生成输入, 参考实现也按同样的方式读取, 所以生产方和消费方永远一致。 在模型里, 生产方是量化 kernel、 配置合并、 checkpoint 加载器, 或者别人写的框架。 在新架构上, 硬件本身也是一个生产方。
On a new architecture the first contract is with the hardware. FlyDSL derived the wave size from an “is RDNA” check, so gfx1250, which runs 32-lane waves but is not RDNA, was compiled as wave64. FlyDSL #1024 fixed the rule, and the fix shipped in v0.3.2. A note in SGLang #37751 records that FlyDSL 0.3.1 still had it, and that the same check also chose buffer-descriptor flags. Downstream copies carry their own constant. SGLang's FlyDSL fused-norm kernel hard-coded 64 lanes and stopped at instruction selection on gfx1250. That failure is loud. The DSA top-k kernel in SGLang #41687 also assumed 64-lane waves, and its failure was quiet. Rows no longer than top-k select every element and are correct. Longer rows got wrong or out-of-range indices, which surfaced as faults on an 8K-token prefill and in GSM8K once contexts passed 2048. Without the fix, the kernel's existing test file fails on gfx1250. The fix adds a trap that fires when the compiled wave size differs from the constant the kernel assumes.
在新架构上, 第一份契约是和硬件之间的。 FlyDSL 原来按 「是不是 RDNA」 来推断波宽, 而 gfx1250 跑 32 lane 的 wave, 却不是 RDNA, 于是被编成了 wave64。 FlyDSL #1024 修正了这条规则, 修复随 v0.3.2 发布。 SGLang #37751 里的一条说明记录了 FlyDSL 0.3.1 仍有这个问题, 而且同一个判断还决定了 buffer 描述符的标志位。 下游的副本还带着自己的常量。 SGLang 用 FlyDSL 写的 fused norm kernel 写死了 64 lane, 在 gfx1250 上卡在指令选择阶段。 这种失败是显式的。 SGLang #41687 里的 DSA top-k kernel 同样假设 64 lane, 它的失败却是静默的。 不超过 top-k 的行会选中所有元素, 结果正确。 更长的行拿到错误或越界的索引, 表现为 8K token prefill 时的 fault, 以及上下文超过 2048 之后 GSM8K 上的 fault。 没有这个修复时, 这个 kernel 现有的测试文件在 gfx1250 上就会失败。 修复加了一个 trap: 编译出来的波宽和 kernel 假设的常量不一致时立即触发。
ISA bits are contracts as well. Porting the GLM-5 MonoKernel to gfx1250, FlyDSL #1214 notes that gfx12 cache-policy bits encode scope, so the gfx950 value 17 (sc0 sc1) would mean device scope plus non-temporal on gfx1250. Copied as a number, a coherence setting changes its meaning without an error. Plate IV shows the general shape.
ISA 的位也是契约。 把 GLM-5 MonoKernel 移植到 gfx1250 时, FlyDSL #1214 指出: gfx12 的 cache-policy 位编码的是 scope, 所以 gfx950 上的值 17(sc0 sc1) 到了 gfx1250 上表示 device scope 加 non-temporal。 把它当成一个数字照抄过来, 一致性设置就悄悄换了含义, 不报任何错。 图 IV 画出了一般形态。
In a unit test the producer and the consumer are written by the same person, in the same file, so the contract always holds. In the model, the producer is someone else's code, and on a new architecture the hardware changes the contract under both.
在单测里, 生产方和消费方由同一个人在同一个文件里写成, 所以契约永远成立。 在模型里, 生产方是别人的代码; 到了新架构上, 硬件还会在两者之下改掉契约。
Between kernels, the contract is a layout. In aiter #5465, the FlyDSL quant kernel and the gfx1250 grouped-MoE GEMM each derived the e8m0 scale layout on their own: the GEMM caller re-stated the quant dispatcher's rules instead of being told which layout had been written. They disagreed on the expert-parallel path. MegaMoE sends the quant to the route-ksplit kernel, which writes the interleaved layout, while the caller still asked for row-major. Here the mismatch raised an error; a mismatch that both sides accept would not. The PR's conclusion is a rule worth keeping: one layout, “chosen by the producer and reported to the consumer, not re-derived on both sides”.
kernel 之间的契约是布局。 在 aiter #5465 里, FlyDSL 的量化 kernel 和 gfx1250 的 grouped MoE GEMM 各自推断 e8m0 scale 的布局: GEMM 的调用方重复了一遍量化分派器的规则, 而不是由别人告诉它实际写的是哪种布局。 两者在 expert 并行路径上不一致。 MegaMoE 把量化交给 route-ksplit kernel, 它写交错布局, 调用方却仍然要行主序布局。 这次不一致抛出了错误; 如果两边都接受, 就不会报错。 PR 的结论值得记住: 只保留一种布局, 「由生产方选定并告诉消费方, 而不是两边各自推断」。
Configuration is a contract between whoever writes a table and whoever reads it. aiter merges its tuned grouped-MoE CSV files with pandas, which writes the integers of any column with a blank cell as floats: 16 becomes 16.0. In aiter #6360, the gfx1250 FlyDSL reader parsed those cells with int(), so 878 cells raised, and the key “1” never matched the cell “1.0”, so expert-parallel calls silently got the generic row. Runs that named the two CSV files directly read them one at a time and worked. Only the merged default, the file that production loads, broke.
配置是写表的人和读表的人之间的契约。 aiter 用 pandas 合并调优过的 grouped MoE CSV 文件, 而 pandas 会把含空单元格的列里的整数写成浮点: 16 变成 16.0。 在 aiter #6360 里, gfx1250 的 FlyDSL 读取代码用 int() 解析这些单元格, 于是 878 个单元格报错; 键 「1」 也永远匹配不上单元格 「1.0」, expert 并行调用悄悄拿到了通用行。 直接指定两个 CSV 文件的运行会逐个读取文件, 一切正常。 只有合并后的默认文件, 也就是生产环境加载的那个文件, 出了问题。
The reference must keep the contract too. FlyDSL #1253 found that the test quantizers rounded MXFP8 E8M0 scales to nearest, while the in-kernel quantizers round up. Rounding to nearest gives a scale that is too small when the mantissa of amax / 448 is below 1.5, so the largest values of a block saturate by up to 33%. One test used that quantizer as the accuracy oracle for the kernel's own quantizer. On gfx1250 the two disagreed on 58% to 98.5% of 557,056 blocks.
参考实现也必须遵守契约。 FlyDSL #1253 发现, 测试里的量化器把 MXFP8 的 E8M0 scale 四舍五入到最近值, 而 kernel 里的量化器向上取整。 amax / 448 的尾数小于 1.5 时, 四舍五入得到的 scale 太小, 一个 block 里最大的值会饱和, 误差最多 33%。 有一个测试拿这个量化器当 kernel 自身量化器的精度参考。 在 gfx1250 上, 两者在 557,056 个 block 中的 58% 到 98.5% 上不一致。
Test the seams. Feed each kernel from its real producer: the quant kernel that runs before it, the merged config file, the checkpoint loader. Assert the hardware contract at the kernel entry: the compiled wave size, the matrix shape, the cache bits. Let the producer choose a layout and pass it on. Build the reference with the kernel's own quantization rule.
测接缝。 用真实的生产方喂每个 kernel: 在它之前运行的量化 kernel、 合并后的配置文件、 checkpoint 加载器。 在 kernel 入口断言和硬件的契约: 编译出的波宽、 矩阵形状、 cache 位。 让生产方选定布局并传下去。 用 kernel 自己的量化规则构建参考实现。
§ 06 · Gap ④ · production does not call kernels like a test doesExecution context
§ 06 · 缺口 ④ · 生产环境调用 kernel 的方式和测试不一样执行环境
Inference engines record kernels into CUDA or HIP graphs and replay them. They run kernels on side streams. They keep one process alive for days, call the same kernel with hundreds of shapes, reuse output buffers and fill caches. Each of these changes what the kernel and its host code must get right.
推理引擎会把 kernel 录进 CUDA 或 HIP graph 再回放。 它们在旁路 stream 上运行 kernel。 它们让一个进程活好几天, 用上百种形状调用同一个 kernel, 复用输出 buffer, 把缓存填满。 每一点都会改变 kernel 和它的 host 代码必须做对的事情。
Graph capture freezes host decisions. In aiter #4494, a split-K GEMM's semaphore was zeroed once, when it was allocated during warmup, outside the graph. Replays never restored the zero, and Kimi-K3 at TP 8 hung during warmup as soon as the draft model was graphed. The PR states why no test saw it: “No test crossed split-K with capture.” Capture is also where the first call of a shape often happens. In aiter #4987, the FlyDSL JIT compile of the fallback MoE kernel ran inside SGLang's decode graph capture on gfx1250, so the compiler abort took down the scheduler process.
graph 捕获会把 host 端的决定冻结下来。 在 aiter #4494 里, split-K GEMM 的 semaphore 只在预热时分配的那一刻清零过一次, 而且是在 graph 之外。 回放从不恢复这个零值, Kimi-K3 在 TP 8 下, draft 模型一进 graph 就在预热时挂住。 PR 写明了为什么没有测试看到它: 「没有测试同时覆盖 split-K 和捕获。」 很多形状的第一次调用也恰好发生在捕获期间。 在 aiter #4987 里, gfx1250 上回退 MoE kernel 的 FlyDSL JIT 编译发生在 SGLang 的 decode graph 捕获里, 编译器一退出, 调度进程也跟着退出。
Memory state is context too. A test usually allocates a fresh output, often filled with zeros. Production reuses buffers. In aiter #6344, the gfx1250 RMSNorm never wrote the last N % 8 elements of each row. Through aiter.rms_norm, which allocates its output with torch.empty_like, those elements came back as garbage, up to 1e38 or NaN. A test whose buffer already held plausible values could not see them. The gfx1250 FP8 GEMM test in FlyDSL #1264 now fills the columns past N with a signaling-NaN bit pattern before the launch and checks the bits afterwards, so a store outside the output fails the test, even one that stores a NaN.
内存的状态也是执行环境。 测试通常分配一块新的输出, 往往填满零。 生产环境会复用 buffer。 在 aiter #6344 里, gfx1250 上的 RMSNorm 从不写每一行的最后 N % 8 个元素。 经过 aiter.rms_norm(它用 torch.empty_like 分配输出), 这些元素返回的是垃圾值, 最大到 1e38, 或者是 NaN。 如果测试的 buffer 里原本就是看起来合理的值, 就看不到它们。 FlyDSL #1264 里 gfx1250 的 FP8 GEMM 测试现在会在启动前把 N 之后的列填成 signaling NaN 的位模式, 启动后再检查这些位, 于是任何写到输出之外的 store 都会让测试失败, 即使它写的是 NaN。
Long-lived processes stress caches. A JIT cache is sound only if its key covers everything that changes the generated code. In aiter #6127, the FlyDSL MegaMoE launcher closed over a spec object that FlyDSL's JIT key ignores, so different stage configs could reuse one compiled launcher. In FlyDSL #1088, the disk-cache key did not include the LLVM version, so a stale code object could hide a compiler change in either direction. FlyDSL #1277 measured the other side: running the suite twice in a row on the same commit gave 22 failures on the second pass against 2 on the first, because a warm cache breaks tests that inspect IR. CI starts each job in a fresh container and never sees it.
长时间运行的进程会考验缓存。 JIT 缓存只有在 key 覆盖了所有会改变生成代码的因素时才是可靠的。 在 aiter #6127 里, FlyDSL MegaMoE 的 launcher 闭包里带着一个 FlyDSL JIT key 不看的配置对象, 于是不同阶段的配置可能共用同一个编译好的 launcher。 在 FlyDSL #1088 里, 磁盘缓存的 key 不包含 LLVM 版本, 一个过期的 code object 可以在两个方向上掩盖编译器的变化。 FlyDSL #1277 量出了另一面: 同一个 commit 连续跑两遍测试, 第二遍 22 个失败, 第一遍 2 个, 因为热缓存会弄坏检查 IR 的测试。 CI 每个 job 都在新容器里启动, 永远看不到这一点。
Streams order work only within themselves. In FlyDSL #1220, shipped in v0.4.0, a test filled its inputs on the default stream and launched the kernel on another stream with raw pointers. Under load the kernel sometimes ran first. About once in 100,000 launches the output kept its identity value, and the test failed with assert inf == -17.0. The race was in the test, not the kernel.
stream 只在自己内部保证顺序。 在 v0.4.0 发布的 FlyDSL #1220 里, 一个测试在默认 stream 上填输入, 再用裸指针在另一个 stream 上启动 kernel。 负载高时, kernel 有时会先跑。 大约每 100,000 次启动有一次, 输出保持初始值, 测试以 assert inf == -17.0 失败。 竞争出在测试里, 而不是 kernel 里。
Run tests the way production runs kernels: capture and replay, a non-default stream with the producer on the same stream, several shapes in one process, a warm cache, output buffers pre-filled with a sentinel, and state filled the way a long-running server fills it.
按生产环境运行 kernel 的方式跑测试: 捕获并回放; 使用非默认 stream, 并让生产方在同一个 stream 上; 同一个进程里调用多种形状; 热缓存; 输出 buffer 预先填上哨兵值; 像长时间运行的服务器那样把状态填满。
§ 07 · Gap ⑤ · a test samples a probabilityRare races
§ 07 · 缺口 ⑤ · 测试只是在对概率抽样偶发竞争
A data race on a GPU depends on which wave arrives first. Most of the time the arrival order is benign, and the kernel passes. A missing barrier or wait can be harmless thousands or millions of times in a row. A test that calls the kernel once, or a hundred times, is sampling a probability. It is not proving correctness.
GPU 上的数据竞争取决于哪个 wave 先到。 大多数时候到达顺序是无害的, kernel 也就通过了。 少一个 barrier 或等待, 可能连续成千上万次、 甚至上百万次都没事。 调用一次或一百次的测试, 只是在对一个概率抽样, 而不是在证明正确性。
gfx1250 changes what a barrier in the source guarantees. Its waits are split by counter: LDS operations by s_wait_dscnt, loads by s_wait_loadcnt, tensor copies by s_wait_tensorcnt. A plain barrier does not wait for a wave's own LDS stores to land; a wait on the right counter does. aiter #6344 shows the cost. The opus RMSNorm reduction synchronized with a plain barrier, and in the gfx1250 code object each ds_store_b32 of the reduction was followed by s_barrier_signal with no s_wait_dscnt in between. Another wave could read a partial sum before the store landed. The wrong rows were scaled by almost exactly 1.156 or 1.41, the factors you get when the sum of squares loses a quarter or a half of its partials. The rate depended on the width: 0 of 30 runs at 4096 × 16384, 2 of 30 at 4096 × 32768, and 13 of 20 at 8192 × 65536. The fix in aiter #6348 adds workgroup release and acquire fences; the compiler then emits s_wait_dscnt before the signal, and no call returns a wrong row.
gfx1250 改变了源代码里一个 barrier 所保证的东西。 它的等待按计数器拆开: LDS 操作看 s_wait_dscnt, load 看 s_wait_loadcnt, tensor 拷贝看 s_wait_tensorcnt。 普通的 barrier 不会等一个 wave 自己的 LDS 写入完成, 等对了计数器才会。 aiter #6344 展示了代价。 opus RMSNorm 的归约用一个普通 barrier 同步, 在 gfx1250 的 code object 里, 归约的每条 ds_store_b32 后面紧跟 s_barrier_signal, 中间没有 s_wait_dscnt。 另一个 wave 可能在写入完成之前就读到部分和。 出错的行几乎正好被放大了 1.156 倍或 1.41 倍, 这正是平方和丢掉四分之一或一半的部分和时得到的系数。 出错率取决于宽度: 4096 × 16384 时 30 次里 0 次, 4096 × 32768 时 30 次里 2 次, 8192 × 65536 时 20 次里 13 次。 aiter #6348 的修复加了 workgroup 级别的 release 和 acquire fence, 编译器随之在 signal 之前生成 s_wait_dscnt, 再没有一次调用返回错误的行。
On gfx1250 the compiler also places waits you did not write, and it can place them wrong. In aiter #5418, DeepSeek-R1 MXFP4 on vLLM faulted at warmup on gfx1250. A Triton GEMM's compiled code overwrote the address registers of an in-flight async load, because a dependency wait came one instruction too late, and the load then read from an address built out of scale bytes. It needed one tuned config at one shape, which a refactor PR had added; a git bisect found it. In SGLang #41868, Triton 3.8 on gfx1250 made the pipelined in-place state write-back of the Qwen3.5 GDN chunked prefill nondeterministic for ragged batches. The report's summary is the clearest symptom in this guide: “Answers look fluent but are wrong.”
在 gfx1250 上, 编译器还会替你插入等待, 而且可能插错位置。 在 aiter #5418 里, gfx1250 上 vLLM 跑 DeepSeek-R1 MXFP4 时在预热阶段出错。 一个 Triton GEMM 编译出的代码覆盖了一个还在进行的 async load 的地址寄存器, 因为一条依赖等待晚了一条指令; 这个 load 于是从一个由 scale 字节拼成的地址读数。 它需要一个特定形状上的一个特定调优配置, 这个配置是一个重构 PR 加进来的, 靠 git bisect 才找到。 在 SGLang #41868 里, gfx1250 上的 Triton 3.8 让 Qwen3.5 GDN 分块 prefill 的流水化原地状态回写在不规则 batch 下变得不确定。 报告里的那句话是本文里最清楚的症状: 「回答看起来很流畅, 但是错的。」
Hand-placed waits are as fragile as compiler-placed ones. Both cases below are FlyDSL kernels inside aiter, run on gfx950. In aiter #6170, a MoE stage-1 kernel waited with vmcnt(5), which assumes five loads are issued after the last copy into LDS. The compiler does not guarantee that order, and in one half of the compiled loop only four loads followed it. A few rows came out 2–13% off in a small fraction of repeated runs; on the vLLM image with FlyDSL 0.3.2, up to 53 rows in 21 of 96 runs. In aiter #6368, an 8-wave GEMM let copies into LDS stay in flight at the K-step barrier. The PR is exact about why tests missed it: the same shape “can be clean on one launch and wrong on the next, so a single-run test passes”, and an error of 1e-3 to 6e-3 “reads as quantisation noise”. A downstream copy of the same pipeline hit the corruption first.
手写的等待和编译器插入的一样脆弱。 下面两个案例都是 aiter 里的 FlyDSL kernel, 跑在 gfx950 上。 在 aiter #6170 里, 一个 MoE 第一阶段 kernel 用 vmcnt(5) 等待, 它假设最后一次拷贝进 LDS 之后还会发出五个 load。 编译器不保证这个顺序, 编译出的循环有一半只在它后面发了四个 load。 重复运行时, 有一小部分运行会有几行偏差 2–13%; 在带 FlyDSL 0.3.2 的 vLLM 镜像上, 96 次运行里有 21 次出错, 最多 53 行。 在 aiter #6368 里, 一个 8-wave GEMM 在 K 步的 barrier 处还有拷贝进 LDS 的操作没完成。 PR 准确说明了测试为什么漏掉它: 同一个形状 「这次启动是干净的, 下次就错了, 所以只跑一次的测试会通过」, 而 1e-3 到 6e-3 的误差 「看起来像量化噪声」。 同一条流水线的一个下游副本先撞上了这个数据损坏。
Why does a test miss what a server finds? Because a test samples a probability. If one call fails with probability p, then N independent calls see at least one failure with probability 1 − (1 − p)^N. Plate V draws three measured rates. The gfx1250 RMSNorm race, at about 2 in 30 calls, shows up within a few dozen calls, if the test uses the wide shape. The cross-stream test race in FlyDSL #1220, at about 1 in 100,000, needs about 70,000 calls for even odds. The radix top-k barrier race in aiter #4841, where a candidate fix still produced 7 stuck events in 292,889,600 row-launches, needs about 29 million. A 1,000-call test catches that one with probability 0.002%. A service at an assumed 10,000 calls per second crosses 50% in under an hour, and certainty within a day.
为什么测试会漏掉服务器遇到的问题? 因为测试只是在对一个概率抽样。 如果单次调用出错的概率是 p, 那么 N 次独立调用至少出错一次的概率是 1 − (1 − p)^N。 图 V 画了三个实测的出错率。 gfx1250 的 RMSNorm 竞争大约每 30 次出错 2 次, 只要测试用的是宽形状, 几十次调用之内就会出现。 FlyDSL #1220 里跨 stream 的测试竞争大约是十万分之一, 要约 70,000 次调用才有一半的机会。 aiter #4841 里 radix top-k 的 barrier 竞争, 一个候选修复在 292,889,600 次行级调用里仍然卡死 7 次, 要约 2,900 万次调用。 1,000 次调用的测试抓到它的概率是 0.002%。 一个假设每秒 10,000 次调用的服务, 不到一小时就超过 50%, 一天之内几乎必然出现。
A test samples the curves at the far left; a service lives at the far right. How far right a bug sits depends on its rate, and the rate can depend on the shape: the same RMSNorm race was 0 of 30 at 4096 × 16384. The rate of 10⁴ calls per second is an assumption for illustration, not a measurement.
测试只在曲线的最左端抽样, 服务则活在最右端。 一个 bug 落在多右边, 取决于它的出错率, 而出错率又可能取决于形状: 同一个 RMSNorm 竞争在 4096 × 16384 时是 30 次里 0 次。 每秒 10⁴ 次调用是为了示意而做的假设, 不是测量值。
So the strongest evidence about a race is not an output check. FlyDSL #1237 makes the point with numbers. Async copies into LDS, including TDM loads on gfx1250, are outside LLVM's automatic wait insertion, so the kernel author places the waits and barriers by hand. A prototype LDS synchronization checker for FlyDSL reported 12 of 12 injected one-line synchronization bugs, each with its rule and source line; the output check of one launch missed 8 of the 12. It reported the race that FlyDSL #1235 fixes on every launch, also when that kernel's test passed. Gluon kernels already have Triton's ConSan, also on gfx1250; FlyDSL kernels had no equivalent.
所以关于竞争, 最有力的证据不是输出检查。 FlyDSL #1237 用数字说明了这一点。 拷贝进 LDS 的异步操作, 包括 gfx1250 上的 TDM load, 都在 LLVM 自动插入等待的范围之外, 所以 kernel 作者要手写等待和 barrier。 一个 FlyDSL 的 LDS 同步检查器原型, 对注入的 12 个单行同步 bug 全部报出, 每个都给出规则和源代码行; 单次启动的输出检查漏掉了其中 8 个。 对 FlyDSL #1235 修复的那个竞争, 它在每次启动时都会报告, 即使那个 kernel 的测试是通过的。 Gluon kernel 已经有 Triton 的 ConSan, 在 gfx1250 上也有; FlyDSL kernel 原来没有对应的工具。
Turn probability into evidence. Call the same input many times and require bit-identical output. Sweep the shapes where waves interleave differently, especially the widest rows. Run a synchronization sanitizer: ConSan for Triton and Gluon, an LDS checker for FlyDSL. Compare hand-written wait counts against the compiled ISA. Bound every wait, so a hang fails the test.
把概率变成证据。 用同一个输入调用很多次, 要求输出逐位一致。 扫一遍 wave 交错方式不同的形状, 尤其是最宽的行。 跑同步 sanitizer: Triton 和 Gluon 用 ConSan, FlyDSL 用 LDS 检查器。 对照编译出的 ISA 检查手写的等待计数。 给每个等待设上限, 让挂起变成测试失败。
§ 08 · Gap ⑥ · hangs and stale reads, not wrong mathMulti-GPU
§ 08 · 缺口 ⑥ · 挂起和陈旧读取, 而不是算错多卡
A multi-GPU kernel adds states that do not exist on one GPU: ranks that arrive in different orders, a fast rank that laps a slow one, data that one device writes and another reads. When such a protocol is wrong, the symptom is rarely a wrong number in a unit test. It is a hang, or a collective that completes and returns stale data. Hangs are expensive to debug and easy to mistake for infrastructure trouble. Stale data is worse, because nothing fails at all.
多卡 kernel 带来了单卡上不存在的状态: 以不同顺序到达的 rank、 套圈慢 rank 的快 rank、 一个设备写、 另一个设备读的数据。 这类协议出错时, 症状很少是单测里的一个错误数字, 而是挂起, 或者集合通信正常结束却返回陈旧的数据。 挂起很难调试, 也很容易被误认为是基础设施的问题。 陈旧的数据更糟, 因为什么都不会失败。
aiter #4812 is the gfx1250 case. In the eager path of its custom all-reduce, each rank copies its input into a reusable IPC-registered buffer, and the kernel then sets a ready flag that peers poll. The store was relaxed, and the peers' load was a relaxed load at device scope. A peer could see the flag before the copy was visible system-wide, and read stale input. In the PR's words, “The collective completes normally but returns incorrect results.” On gfx1250 at TP 2, in the NPS2 / DPX partition mode named in the PR's title, 127 of 128 outputs were wrong in eager mode and 126 of 128 under graph replay. With a system-scope release store and acquire load, none were. Plate VI shows the order. Two facts explain why ordinary tests pass. Only the registered-buffer kernel above 4 MiB uses this flag. And a stale read needs back-to-back collectives with changing inputs, so the regression test queues 64 of them before it synchronizes.
aiter #4812 是 gfx1250 上的案例。 在它的 custom all-reduce 的 eager 路径里, 每个 rank 把输入拷进一块可复用的 IPC 注册 buffer, 然后 kernel 设置一个 ready 标志, 由 peer 轮询。 写标志用的是 relaxed store, peer 读标志用的是 device scope 的 relaxed load。 peer 可能在拷贝对整个系统可见之前就看到标志, 于是读到陈旧的输入。 用 PR 原文的话说: 「集合通信正常结束, 但返回的结果是错的。」 在 gfx1250 上 TP 2、 PR 标题里写明的 NPS2 / DPX 分区模式下, eager 模式 128 个输出错 127 个, graph 回放错 126 个。 改成 system scope 的 release store 和 acquire load 之后, 一个都不错。 图 VI 画出了先后顺序。 有两个事实解释了普通测试为什么能通过。 只有 4 MiB 以上的 registered-buffer kernel 用这个标志。 而陈旧读取需要输入不断变化的连续集合通信, 所以回归测试在同步之前先排队 64 次。
The collective finishes and reports nothing. A peer that sees the flag before the copy is visible reads stale input. Only the registered-buffer path above 4 MiB uses this flag, and only back-to-back calls with changing inputs show the stale read.
集合通信正常结束, 什么都不报。 peer 如果在拷贝可见之前就看到了标志, 读到的就是陈旧的输入。 只有 4 MiB 以上的 registered-buffer 路径用这个标志, 也只有输入不断变化的连续调用才会暴露这次陈旧读取。
Message sizes that change between calls are the other trap. In aiter #6446, an all-reduce derived its slot stride from the current message size, so a fast rank could write call k+1 into slots that a slower peer had not yet read for call k. It surfaced in vLLM CI on gfx950, with Kimi-K3 at TP 2, as an RCCL watchdog timeout: 6 of 6 runs hung when one rank was forced to fall behind, and 0 of 114 on a quiet host. A review of the new gfx1250 LL128 all-reduce in aiter #5616 found the same size-derived stride by reading the code, and added one more lesson for the test: “Two sizes always land in the same bank and can miss the bug.” The test needs three sizes per round.
相邻调用的消息大小在变, 是另一个陷阱。 在 aiter #6446 里, 一个 all-reduce 按当前消息大小计算槽位步长, 于是快的 rank 可能把第 k+1 次调用写进慢 rank 还没读完的第 k 次槽位。 它在 vLLM 的 gfx950 CI 上以 RCCL watchdog 超时的形式出现, 当时在跑 TP 2 的 Kimi-K3: 强制让一个 rank 落后时 6 次全部挂起, 在安静的主机上 114 次一次都没有。 aiter #5616 里对新的 gfx1250 LL128 all-reduce 的评审, 通过读代码发现了同样按大小计算的步长, 还给测试补了一条经验: 「两种大小总是落在同一个 bank 里, 可能漏掉这个 bug。」 测试每轮需要三种大小。
Sometimes the protocol has simply never run. The gfx1250 port of the GLM-5 MonoKernel in FlyDSL #1214 says so: the peer-reduction path for TP 2, 4 or 8 “compiles but has never run”, because the test node has one gfx1250 GPU. One unbounded benchmark run of an earlier revision stopped responding, and the host had to be rebooted; the cause is unknown. Since then every mailbox wait is bounded, “so a protocol defect fails the test instead of hanging the device”. In FlyDSL itself, the default test script excludes every multi_gpu test, as a review of FlyDSL #1277 noted. A single-GPU green check says nothing about the protocol.
有时协议根本就没跑过。 FlyDSL #1214 是 GLM-5 MonoKernel 在 gfx1250 上的移植, 它直接写明: TP 2、 4、 8 的 peer 归约路径 「能编译, 但从没运行过」, 因为测试节点只有一张 gfx1250。 早期版本的一次无上限的基准测试停止了响应, 主机只能重启; 原因不明。 从那以后, 每个 mailbox 等待都有上限, 「这样协议缺陷会让测试失败, 而不是让设备挂住」。 在 FlyDSL 本身, 默认的测试脚本排除了所有 multi_gpu 测试, 这一点在 FlyDSL #1277 的评审中被指出。 单卡的绿色结果对协议本身什么也说明不了。
Test protocols, not just math. Run TP 2, TP 4 and EP in each partition mode the fleet uses. Queue many collectives with changing values before one synchronization. Use at least three message sizes per round. Bound every wait and wrap the test in a watchdog, so a hang fails the test instead of the CI runner.
测协议, 而不只是测数学。 在机群用到的每种分区模式下跑 TP 2、 TP 4 和 EP。 在一次同步之前排队很多次数值不断变化的集合通信。 每轮至少用三种消息大小。 给每个等待设上限, 并给测试套上看门狗, 让挂起变成测试失败, 而不是把 CI 机器拖死。
§ 09 · Gap ⑦ · the compiler, the pin, the copyThe software you didn't test
§ 09 · 缺口 ⑦ · 编译器、 锁定的版本、 副本没测到的软件
A kernel is correct only as the code its toolchain emits for the target, in the build that the engine runs. A unit test checks one build, on the machines CI has. On gfx1250 each of these differs from production.
kernel 的正确性, 指的是工具链为目标架构生成的那份代码, 在引擎实际运行的那个构建里是正确的。 单测只检查一个构建, 只在 CI 拥有的机器上检查。 在 gfx1250 上, 这几样都和生产环境不同。
The compiler adds code you never wrote. FlyDSL #1051 bumped the LLVM pin; it built after one API change and received an LGTM. FlyDSL #1088 then records that this pin was reported to abort on gfx1250 for any kernel, with a memory-aperture violation on a global_prefetch_b8 at the top of the kernel. FlyDSL does not emit that instruction; its only prefetch helper has no callers. LLVM prepends it as an entry prologue on targets that need it, gfx1250 among them. Upstream reworked that prologue three times in three weeks, and the pin sat in the middle of the sequence, with the change that broke it and without the two that walked it back. The PR is careful to say this is static analysis, not a reproduction. The bump never merged.
编译器会加你从没写过的代码。 FlyDSL #1051 升级了锁定的 LLVM 版本; 改了一处 API 之后就能编译, 也拿到了 LGTM。 FlyDSL #1088 随后记录: 有人报告这个版本在 gfx1250 上对任何 kernel 都会退出, 错误是 kernel 开头一条 global_prefetch_b8 上的内存 aperture 越界。 FlyDSL 并不生成这条指令, 它唯一的 prefetch 辅助函数没有任何调用方。 是 LLVM 在需要的目标上把它作为入口 prologue 加在 kernel 前面, gfx1250 正是这样的目标。 上游三周内三次重写了这段 prologue, 而这个锁定版本正好落在中间: 带上了出问题的那次修改, 却没带上后面两次回退。 PR 特意说明这是对上游历史的静态分析, 不是复现。 这次升级最终没有合入。
The compiler can also change what your source means. In aiter #6081 and SGLang #41868, Triton 3.8 on gfx1250 lowered masked loads to global_load_async_to_lds, which has no bounds check, and serving Qwen3.5-397B died with memory faults. The mask in the source was correct. The instruction did not honor it.
编译器还可能改变源代码的含义。 在 aiter #6081 和 SGLang #41868 里, gfx1250 上的 Triton 3.8 把带 mask 的 load 变成了 global_load_async_to_lds, 而这条指令没有越界检查, 服务 Qwen3.5-397B 时出现内存错误。 源代码里的 mask 是对的, 指令却不遵守它。
Flags can fail to reach the code. FlyDSL v0.4.0 removed or repaired three of them. Four LLVM tuning knobs never reached codegen, while autotune searched over one of them (FlyDSL #1153); the audit used gfx1250 as its positive control, because one knob does change gfx1250 code. A DAZ flag on four attention kernels never reached the hardware after upstream LLVM stopped reading the attribute, and with the fix “1051 tests pass, with no numerical change in either direction” (FlyDSL #1177): the suite cannot tell the flag on from off. Compile hints reached only a second code object that the runtime discarded, and on wave32 targets that duplicate was always built (FlyDSL #1195). A test of a flag must check that the flag changes the code object.
开关可能根本到不了代码。 FlyDSL v0.4.0 删掉或修好了三处这样的问题。 四个 LLVM 调优开关从未进入代码生成, 而 autotune 一直在其中一个上搜索(FlyDSL #1153); 这次审查用 gfx1250 作阳性对照, 因为其中一个开关确实会改变 gfx1250 的代码。 四个 attention kernel 上的 DAZ 开关, 在上游 LLVM 不再读取那个属性之后就没有到达硬件; 修复后 「1051 个测试通过, 两个方向上都没有数值变化」(FlyDSL #1177): 测试集分辨不出开关开着还是关着。 编译提示只进入了运行时丢弃的第二个 code object, 而在 wave32 目标上这个重复的 object 总会生成(FlyDSL #1195)。 测一个开关, 就必须检查它确实改变了 code object。
Production also runs someone else's copy. FlyDSL v0.3.0 moved buffer_ops out of flydsl.expr (FlyDSL #880). SGLang's ROCm diffusion fused-norm kernel was written largely against FlyDSL internals, 278 of its 902 lines. It caught the ImportError and fell back to the native path: “a silent performance regression, not a crash … Nothing is logged” (SGLang #33142). aiter imported a FlyDSL attention module that only gfx1250 can use inside flash_attn_varlen_func, with no try/except. With FlyDSL 0.3.1 installed, every call raised, on every architecture (aiter #4701). FlyDSL's answers are an API stability contract (FlyDSL #925, v0.3.0), a stable extension module (FlyDSL #1181, v0.4.0) and contract tests for the APIs that vLLM imports, because a change “could break a kernel vLLM runs in production while CI here stayed green” (FlyDSL #1277).
生产环境运行的还可能是别人的副本。 FlyDSL v0.3.0 把 buffer_ops 移出了 flydsl.expr(FlyDSL #880)。 SGLang 用 FlyDSL 写的 ROCm diffusion fused norm kernel 大部分依赖 FlyDSL 的内部接口, 902 行里有 278 行。 它捕获了 ImportError, 退回原生路径: 「静默的性能回退, 不是崩溃…… 没有任何日志」(SGLang #33142)。 aiter 在 flash_attn_varlen_func 里导入一个只有 gfx1250 能用的 FlyDSL attention 模块, 没有 try/except。 装上 FlyDSL 0.3.1 后, 每一次调用都报错, 在所有架构上都是如此(aiter #4701)。 FlyDSL 的应对是: API 稳定性契约(FlyDSL #925, v0.3.0)、 稳定的 extension 模块(FlyDSL #1181, v0.4.0), 以及给 vLLM 导入的 API 加契约测试, 因为一个改动 「可能弄坏 vLLM 在生产环境运行的 kernel, 而这里的 CI 仍然全绿」(FlyDSL #1277)。
Dispatch can follow the build instead of the device. A review of the RMSNorm fix in aiter #6348 found that its guard read the build target from GPU_ARCHS, not the live device. A gfx1250 process built with GPU_ARCHS=gfx942 would still route to the kernel that the PR shows to be wrong. CI could test the fix only with a mocked device, because its runners are gfx942 and gfx950.
分派可能跟着构建走, 而不是跟着设备走。 对 aiter #6348 里 RMSNorm 修复的评审发现, 它的分派条件读的是 GPU_ARCHS 里的构建目标, 而不是实际运行的设备。 一个用 GPU_ARCHS=gfx942 构建、 跑在 gfx1250 上的进程, 仍然会被分派到这个 PR 证明是错误的 kernel。 CI 只能用模拟的设备来测这个修复, 因为它的 runner 是 gfx942 和 gfx950。
And a test can pass without running. aiter #5133 found that CI ran op tests as scripts, so 141 pytest functions never executed, 8 of them in a gfx1250 FlyDSL grouped-GEMM test. “These files exit 0 having executed no test. CI records that as a pass.”
测试也可能没跑就通过。 aiter #5133 发现 CI 以脚本方式运行算子测试, 于是 141 个 pytest 函数从未执行, 其中 8 个在 gfx1250 的 FlyDSL grouped GEMM 测试里。 「这些文件一个测试都没跑就以 0 退出, CI 把它记为通过。」
Test the build that production runs, on the target: the engine's image and its pinned FlyDSL, LLVM and Triton, on gfx1250. Dispatch on the live device. Put the compiler version in every cache key. Make a flag test fail when the code object does not change. Add contract tests for every API a downstream project imports, and make the runner fail when it collects nothing.
在目标架构上测生产环境实际运行的构建: 引擎的镜像, 以及它锁定的 FlyDSL、 LLVM 和 Triton, 跑在 gfx1250 上。 按实际设备分派。 把编译器版本放进每个缓存 key。 code object 没变时, 开关的测试要失败。 给下游项目导入的每个 API 加契约测试, 并让测试框架在一个用例都没收集到时直接失败。
§ 10 · from cheap to completeA validation ladder
§ 10 · 从便宜到完整验证阶梯
The gaps suggest an order. Each rung catches a class of bugs the rung below cannot, and costs more to run. Climb it in order, and the end-to-end run at the top rarely fails. When it does fail, the rung that should have caught the bug tells you which test to add.
这些缺口给出了一个顺序。 每一级都能抓到下一级抓不到的一类 bug, 运行成本也更高。 按顺序往上爬, 顶端的端到端运行就很少出错。 万一出错, 本该抓住这个 bug 的那一级会告诉你该补哪个测试。
Each rung closes one gap and costs more than the rung below. The model at the top exercises all seven dimensions at once, which is why it is the last rung and not the only one.
每一级关闭一个缺口, 成本也比下一级高。 顶端的模型同时覆盖全部七个维度, 所以它是最后一级, 而不是唯一的一级。
- Boundary unit tests (①). Tails in every dimension (K % tile_k, N % tile_n, N % 8), M = 1, outputs just below and above 2³¹ and 2³² bytes counting the last tile, every tile config the launcher accepts, and every production shape with no tuning entry. Fill the memory past the output with a sentinel, so a stray store fails the test.
- Replay real inputs (②). Captured prompts, activations, routing, biases and weights from one model run; lengths on both sides of every chunk and top-k size; adversarial ties, peaks, NaN and extremes.
- Test the seams (③). Real producers feeding real consumers: the quant kernel, the merged config, the checkpoint loader. Assert the compiled wave size and matrix shape at the kernel entry. Build the reference with the kernel's own quantization rule.
- Production execution modes (④). Graph capture and replay, side streams with the producer on the same stream, several shapes in one process, a warm cache, reused output buffers, and pools filled the way a long-running server fills them.
- Repeat for determinism (⑤). The same input many times with bit-identical output, the widest rows, a synchronization sanitizer, wait counts checked against the ISA, and bounded waits.
- Multi-GPU protocols (⑥). TP 2, TP 4 and EP in each partition mode, three message sizes per round, many queued collectives before one synchronization, under a watchdog.
- Downstream and toolchain (⑦). gfx1250 itself, the engine's image and pins, dispatch on the live device, a cache key with the compiler version, flags that must change the code object, API contract tests, and a runner that fails when it collects no tests.
- The model, end to end. A fixed-seed accuracy evaluation such as GSM8K on the target architecture, and a throughput run, first on one GPU and then on several. Read the score, not the exit code: the server in ATOM #1750 ran without an error and scored 0.0.
- 边界单测(①)。 每一维的尾块(K % tile_k、 N % tile_n、 N % 8)、 M = 1、 刚好低于和高于 2³¹、 2³² 字节的输出(把最后一个 tile 算进去)、 launcher 接受的每一种 tile 配置, 以及每一个没有调优表项的生产形状。 把输出之外的内存填上哨兵值, 让越界的 store 直接让测试失败。
- 回放真实输入(②)。 从一次模型运行里抓取的 prompt、 激活、 路由、 bias 和权重; 覆盖每个 chunk 和 top-k 长度两侧的长度; 再加上刻意构造的相等、 尖峰、 NaN 和极端值。
- 测接缝(③)。 用真实的生产方喂真实的消费方: 量化 kernel、 合并后的配置、 checkpoint 加载器。 在 kernel 入口断言编译出的波宽和矩阵形状。 用 kernel 自己的量化规则构建参考实现。
- 生产执行模式(④)。 graph 捕获与回放、 生产方在同一个 stream 上的旁路 stream、 同一进程里的多种形状、 热缓存、 复用的输出 buffer, 以及像长时间运行的服务器那样填满的池。
- 重复跑, 要求确定性(⑤)。 同一个输入跑很多次, 输出逐位一致; 最宽的行; 同步 sanitizer; 对照 ISA 检查等待计数; 有上限的等待。
- 多卡协议(⑥)。 每种分区模式下的 TP 2、 TP 4 和 EP, 每轮三种消息大小, 一次同步之前排队很多次集合通信, 并套上看门狗。
- 下游与工具链(⑦)。 gfx1250 本身、 引擎的镜像和锁定的版本、 按实际设备分派、 带编译器版本的缓存 key、 必须改变 code object 的开关、 API 契约测试, 以及一个没收集到用例就会失败的测试框架。
- 端到端模型。 在目标架构上做固定随机种子的精度评测, 例如 GSM8K, 再加一次吞吐测试, 先单卡, 再多卡。 看分数, 不看退出码: ATOM #1750 里的服务器没有报任何错, 得分是 0.0。
Before I call a kernel change done, I write down which rungs it has climbed. A green unit test is rung one. A speedup table is not a rung at all. If the kernel is not on any engine's call path yet, I say so, because then even a perfect end-to-end run says nothing about it. On gfx1250 I also record the machine, the driver and the pins, because a result on one machine is weaker evidence for the next one than it looks.
在说一个 kernel 改动完成之前, 我会写下它爬到了哪几级。 一个通过的单测是第一级。 一张加速比表格根本不算一级。 如果这个 kernel 还不在任何引擎的调用路径上, 我会明确写出来, 因为那样的话, 就算端到端运行完美, 也说明不了它的任何问题。 在 gfx1250 上, 我还会记下机器、 驱动和锁定的版本, 因为一台机器上的结果, 对下一台机器的说明力比看上去要弱。
§ 11 · every public case in this guideCase index
§ 11 · 本文引用的全部公开案例案例索引
All cases come from public pull requests and issues, read on 2026-10-14. Each row names the gap, what the test missed, and the source. Chips mark cases on gfx1250, cases that involve FlyDSL, and sources that were still open. Rows marked “review” are findings from code review, not observed failures. Use the buttons to filter.
所有案例都来自公开的 PR 和 issue, 阅读时间是 2026-10-14。 每一行列出所属的缺口、 测试漏掉了什么, 以及来源。 标签标出 gfx1250 上的案例、 涉及 FlyDSL 的案例, 以及当时仍未关闭的来源。 标为 「评审」 的行是代码评审中的发现, 不是实际观察到的故障。 可以用按钮筛选。
| Source来源 | Gap缺口 | What the test missed测试漏掉了什么 |
|---|---|---|
| aiter #6402 gfx1250FlyDSLopen | ①④ | The default BF16 GEMM leaves every row past byte 2³¹ unwritten; the FlyDSL kernel's last-tile rows wrap past 2³² onto rows 0–201.默认的 BF16 GEMM 不写第 2³¹ 字节之后的行; FlyDSL kernel 最后一个 tile 的行越过 2³² 回绕, 覆盖第 0–201 行。 |
| ATOM #2533 gfx1250open | ①④ | int32 KV row ids × 576 wrap from row 3,728,270; only a pool filled by prefix caching reaches it; decode answers “!!!!”.int32 的 KV 行号乘以 576, 从第 3,728,270 行起回绕; 只有被 prefix caching 填满的池才会走到; decode 输出 「!!!!」。 |
| FlyDSL #1199, #1230 gfx1250FlyDSL | ① | K // tile_k drops the K tail without an error: 60% off at K = 640; at gpt-oss K = 2880 only 64-wide K tiles were right.K // tile_k 悄悄丢掉 K 的尾块: K = 640 时偏差 60%; gpt-oss 的 K = 2880 下只有 64 宽的 K tile 是对的。 |
| FlyDSL #1264 gfx1250FlyDSLopen | ①⑦ | A tile below the 128-deep FP8 WMMA issues no WMMA and returns an all-zero C; CI skips the test file.比 128 深的 FP8 WMMA 还小的 tile 一条 WMMA 都不发, 返回全零的 C; CI 跳过这个测试文件。 |
| aiter #6344 → #6348 gfx1250open | ①④⑤⑦ | RMSNorm leaves N % 8 tail elements unwritten; wide rows read partial sums before the LDS store lands; review: the first fix dispatched on the build target.RMSNorm 不写 N % 8 的尾部元素; 宽行在 LDS 写入完成前就读部分和; 评审: 第一版修复按构建目标而不是实际设备分派。 |
| aiter #4987 gfx1250FlyDSL | ①④⑦ | An untuned MoE shape takes the heuristic FlyDSL fallback, which LLVM cannot lower; the server aborts during graph capture.未调优的 MoE 形状走启发式的 FlyDSL 回退 kernel, LLVM 无法 lower 它; 服务器在 graph 捕获时退出。 |
| ATOM #1750 gfx1250 | ② | Prompts shorter than one KDA chunk go NaN; the server answers “!” forever: GSM8K 0.0, no error in the log.比一个 KDA chunk 短的 prompt 变成 NaN; 服务器一直输出 「!」: GSM8K 0.0, 日志里没有任何报错。 |
| aiter #5801 gfx1250FlyDSLopen | ② | The TDM grouped GEMM's bias pointer skips the N-tile index; fixing it restored gpt-oss-120b accuracy on gfx1250.TDM grouped GEMM 的 bias 指针漏了 N-tile 编号; 修复后 gfx1250 上 gpt-oss-120b 的精度恢复。 |
| FlyDSL #1204 → #1213 FlyDSL | ②③ | Real GLM checkpoints: close router scores picked the wrong expert; the gfx1250 port in #1214 carries the fix.真实 GLM checkpoint: 路由分数接近时选错 expert; #1214 的 gfx1250 移植带上了这个修复。 |
| FlyDSL #1020 FlyDSL | ② | FP8 softmax underflow returns 0.637 instead of 1.0; only peaked attention shows it.FP8 softmax 下溢, 返回 0.637 而不是 1.0; 只有尖锐的注意力会暴露。 |
| aiter #6127 FlyDSL | ②④⑤ | Identical warmup tokens skew routing and expose an LDS slot-reuse race; the JIT key ignored the stage spec.相同的预热 token 让路由倾斜, 暴露 LDS 槽位复用竞争; JIT key 忽略了阶段配置。 |
| FlyDSL #1024, SGLang #37751 gfx1250FlyDSL | ③⑦ | Wave size from an RDNA check compiled gfx1250 as wave64; a downstream norm kernel hard-coded 64 lanes and failed instruction selection.按 RDNA 判断波宽, 把 gfx1250 编成 wave64; 下游的 norm kernel 写死 64 lane, 指令选择失败。 |
| SGLang #41687 gfx1250 | ③② | Top-k hard-coded 64-lane waves: rows up to top-k were right, longer rows got wrong or out-of-range indices.top-k 写死 64 lane 的 wave: 不超过 top-k 的行是对的, 更长的行拿到错误或越界的索引。 |
| FlyDSL #1214 gfx1250FlyDSLopen | ③⑤⑥ | Cache-policy value 17 changes meaning on gfx1250; the TP > 1 path compiles but has never run; bounded waits turn a hang into a failure.cache-policy 值 17 在 gfx1250 上含义变了; TP > 1 路径能编译但从没跑过; 有上限的等待把挂起变成测试失败。 |
| aiter #5465 gfx1250FlyDSLopen | ③⑥ | Quant and GEMM re-derive the scale layout on their own; the expert-parallel path disagrees.量化和 GEMM 各自推断 scale 布局; expert 并行路径上两者不一致。 |
| aiter #6360 gfx1250FlyDSLopen | ③⑦ | A pandas merge writes integers as floats; 878 cells fail to parse and EP rows never match; per-file runs work.pandas 合并把整数写成浮点; 878 个单元格解析失败, EP 行永远匹配不上; 逐个文件读取时正常。 |
| FlyDSL #1253 gfx1250FlyDSLopen | ③② | Test quantizers rounded E8M0 scales to nearest; the oracle and the kernel disagreed on 58%–98.5% of blocks.测试量化器把 E8M0 scale 四舍五入到最近值; 参考实现和 kernel 在 58%–98.5% 的 block 上不一致。 |
| FlyDSL #1277 FlyDSLopen | ④⑥⑦ | A warm cache gives 22 failures against 2 on a cold one; an API only vLLM uses; multi-GPU tests excluded by default.热缓存下 22 个失败, 冷缓存下 2 个; 只有 vLLM 在用的 API; 多卡测试默认被排除。 |
| FlyDSL #1220 FlyDSL | ④⑤ | The test filled inputs on one stream and launched on another; about 1 in 100,000 launches kept the identity value.测试在一个 stream 上填输入, 在另一个 stream 上启动; 大约每 100,000 次启动有一次保持初始值。 |
| aiter #4494 | ④⑥ | A split-K semaphore zeroed outside the graph; replay spins forever; TP 8 warmup hangs. Merged, then reverted the same day.split-K semaphore 在 graph 之外清零; 回放时一直空转; TP 8 预热挂起。 合入后当天被回滚。 |
| aiter #5418 gfx1250 | ⑤⑦ | A compiler wait one instruction too late clobbers an in-flight async load; DeepSeek-R1 faults at warmup.编译器的等待晚了一条指令, 覆盖了还在进行的 async load 的地址; DeepSeek-R1 预热时出错。 |
| SGLang #41868, aiter #6081 gfx1250open | ⑤⑦ | Triton 3.8 drops the masked-load bounds check; ragged batches corrupt GDN state: answers fluent but wrong.Triton 3.8 丢掉了带 mask 的 load 的越界检查; 不规则 batch 破坏 GDN 状态: 回答流畅但错误。 |
| aiter #6170, #6368 FlyDSLopen | ⑤⑦ | Hand-placed vmcnt waits broken by the compiler's schedule; stale LDS rows in a fraction of repeated runs; a single-run test passes.手写的 vmcnt 等待被编译器的调度打破; 重复运行时有一部分出现陈旧的 LDS 行; 只跑一次的测试会通过。 |
| FlyDSL #1237 gfx1250FlyDSLopen | ⑤ | RFC prototype: one launch's output check missed 8 of 12 injected sync bugs; an LDS checker reported all 12.RFC 原型: 单次启动的输出检查漏掉了 12 个注入同步 bug 中的 8 个; LDS 检查器报出了全部 12 个。 |
| aiter #4841 | ⑤ | A cross-block barrier race: 7 stuck events in 292,889,600 row-launches for a candidate fix.跨 block barrier 竞争: 一个候选修复在 292,889,600 次行级调用里卡死 7 次。 |
| aiter #4812 gfx1250open | ⑥ | A relaxed ready flag lets peers read stale input: 127 of 128 outputs wrong, and the collective completes normally.relaxed 的 ready 标志让 peer 读到陈旧输入: 128 个输出错 127 个, 集合通信照常结束。 |
| aiter #6446, #5616 gfx1250open | ⑥ | Slot stride derived from the message size hangs when sizes change; review: the gfx1250 LL128 kernel uses the same rule.按消息大小计算槽位步长, 大小变化时挂起; 评审: gfx1250 的 LL128 kernel 用同样的规则。 |
| FlyDSL #1088, #1051 gfx1250FlyDSL | ⑦④ | An LLVM pin bump built and was approved; every gfx1250 kernel was reported to fault in a prologue the compiler added.LLVM 版本升级能编译、 也通过了评审; 据报告 gfx1250 上每个 kernel 都在编译器加的 prologue 里出错。 |
| FlyDSL #1153, #1177, #1195 FlyDSL | ⑦ | Tuning knobs, a DAZ flag and compile hints that never reached the shipped code; fixed in v0.4.0.调优开关、 DAZ 开关和编译提示从来没进入实际发布的代码; 在 v0.4.0 修复。 |
| SGLang #33142, aiter #4701 FlyDSL | ⑦ | FlyDSL v0.3 moved buffer_ops: SGLang silently fell back to a slow path; aiter raised on every architecture.FlyDSL v0.3 移走了 buffer_ops: SGLang 悄悄退回慢路径; aiter 在所有架构上报错。 |
| aiter #5133 FlyDSL | ⑦ | 141 pytest functions never executed, 8 of them in a gfx1250 FlyDSL test; CI recorded a pass.141 个 pytest 函数从未执行, 其中 8 个在 gfx1250 的 FlyDSL 测试里; CI 记为通过。 |
| FlyDSL #1228 gfx1250FlyDSL | ⑦ | Same pinned LLVM, two gfx1250 machines: the tests passed on both; one fence cost ten times more on one of them.同样锁定的 LLVM, 两台 gfx1250 机器: 测试都通过了; 同一个 fence 在其中一台上的开销是另一台的十倍。 |
§ 12 · not bad luckEpilogue
§ 12 · 不是运气不好尾声
“It passed the unit test and failed in the model” sounds like bad luck. It is not. A unit test is a sample, and a model is the population. Every public bug in this guide lived in a dimension the test held still: a row past byte 2³¹, a prompt shorter than one chunk, a wave size taken from the wrong family, a config merged as floats, a barrier that did not wait for its stores, a peer that read before the copy landed, a prologue the compiler added. A new architecture moves many of those dimensions at once, and gfx1250 moved them within the same four months. Name the dimension, and the missing test is usually obvious.
「单测过了, 跑模型出问题」 听起来像运气不好, 其实不是。 单测是样本, 模型是总体。 本文里的每一个公开 bug, 都待在测试固定不动的某个维度里: 第 2³¹ 字节之后的一行、 比一个 chunk 短的 prompt、 按错误的架构家族推断的波宽、 被合并成浮点的配置、 不等写入完成的 barrier、 在拷贝完成前就读取的 peer、 编译器加的一段 prologue。 新架构会同时移动很多个维度, 而 gfx1250 在同样的四个月里移动了它们。 说出是哪个维度, 缺的那个测试通常就一目了然。
§ 13 · sourcesReferences
§ 13 · 来源参考资料
Public repositories: ROCm/FlyDSL, a Python DSL for GPU kernels; ROCm/aiter, a kernel library used by inference engines; ROCm/ATOM, an inference engine; and SGLang, a serving framework. Every claim above links to the pull request or issue that states it. FlyDSL release dates come from its release pages: v0.3.0 on 2026-08-01, v0.3.2 on 2026-08-27, v0.3.4.1 on 2026-09-19 and v0.4.0 on 2026-10-14.
公开仓库: ROCm/FlyDSL, 一个写 GPU kernel 的 Python DSL; ROCm/aiter, 一个被推理引擎调用的 kernel 库; ROCm/ATOM, 一个推理引擎; 以及 SGLang, 一个推理服务框架。 上文每一处论断都链接到陈述它的 PR 或 issue。 FlyDSL 的发布日期来自它的 release 页面: v0.3.0 是 2026-08-01, v0.3.2 是 2026-08-27, v0.3.4.1 是 2026-09-19, v0.4.0 是 2026-10-14。
- FlyDSL #632 · the v0.3 roadmap: full gfx1250 support, validated end-to-endv0.3 路线图: 完整支持 gfx1250, 并做端到端验证
- FlyDSL #1228 · two gfx1250 machines, one pinned LLVM两台 gfx1250 机器, 同一个锁定的 LLVM
- aiter #6402, #6397 · BF16 GEMM rows past byte 2³¹ and 2³² on gfx1250gfx1250 上 BF16 GEMM 第 2³¹ 和 2³² 字节之后的行
- ATOM #2533 · int32 KV row ids on a full pool填满的池上的 int32 KV 行号
- FlyDSL #1199, #1230 · K tails in the gfx1250 GEMMsgfx1250 GEMM 的 K 尾块
- FlyDSL #1264 · tiles below the WMMA shape, and a sentinel past N小于 WMMA 形状的 tile, 以及 N 之外的哨兵值
- aiter #6344, #6348 · RMSNorm row tails and the reduction fence on gfx1250gfx1250 上 RMSNorm 的行尾和归约 fence
- aiter #4987 · the heuristic FlyDSL fallback on gfx1250gfx1250 上的启发式 FlyDSL 回退
- ATOM #1750 · KDA prefill NaN on short prompts短 prompt 上的 KDA prefill NaN
- aiter #5801 · the bias index in the TDM grouped GEMMTDM grouped GEMM 的 bias 索引
- FlyDSL #1204, #1213, #1214 · the GLM-5 MonoKernel, its real-checkpoint fixes, and its gfx1250 portGLM-5 MonoKernel、 真实 checkpoint 暴露问题的修复, 以及它的 gfx1250 移植
- FlyDSL #1020 · FP8 attention softmaxFP8 attention 的 softmax
- aiter #6127 · the MegaMoE LDS broadcast race and JIT keyMegaMoE 的 LDS 广播竞争与 JIT key
- FlyDSL #1024, SGLang #37751 · wave size on gfx1250gfx1250 上的波宽
- SGLang #41687 · a wave32 DSA top-kwave32 下的 DSA top-k
- aiter #5465 · one scale layout, chosen by the producer只保留一种由生产方选定的 scale 布局
- aiter #6360 · integers merged as floats被合并成浮点的整数
- FlyDSL #1253 · E8M0 rounding in the test quantizers测试量化器里的 E8M0 取整
- aiter #4494 · a split-K semaphore under graph capturegraph 捕获下的 split-K semaphore
- FlyDSL #1088, #1051 · the LLVM pin and the gfx1250 entry prologue锁定的 LLVM 版本与 gfx1250 的入口 prologue
- FlyDSL #1277 · a warm cache and downstream contract tests热缓存与下游契约测试
- FlyDSL #1220 · a cross-stream race in a test测试里的跨 stream 竞争
- aiter #5418 · a compiler wait one instruction late on gfx1250gfx1250 上晚了一条指令的编译器等待
- SGLang #41868, aiter #6081 · Triton 3.8 async copy on gfx1250gfx1250 上 Triton 3.8 的 async copy
- aiter #6170, #6368 · hand-placed waits in FlyDSL kernelsFlyDSL kernel 里手写的等待
- aiter #4841 · a radix top-k cross-block barrierradix top-k 的跨 block barrier
- FlyDSL #1237, #1235 · an LDS synchronization sanitizer for FlyDSLFlyDSL 的 LDS 同步 sanitizer
- aiter #4812 · all-reduce input publication on gfx1250gfx1250 上 all-reduce 的输入发布
- aiter #6446, #5616 · all-reduce slots across call sizes不同调用大小下的 all-reduce 槽位
- FlyDSL #1153, #1177, #1195 · flags that never reached the code从未到达代码的开关
- FlyDSL #880, #925, #1181, SGLang #33142, aiter #4701 · downstream copies after the v0.3 API changesv0.3 API 变化之后的下游副本
- aiter #5133 · tests that never ran从未运行的测试