Skip to content

[Fix] Make flash_mla_cudnn SparseMLA backward deterministic under XTUNER_DETERMINISTIC - #2235

Open
ShilohYu wants to merge 7 commits into
InternLM:mainfrom
ShilohYu:fix/glm53-deterministic-sparse-mla-bwd
Open

ShilohYu wants to merge 7 commits into
InternLM:mainfrom
ShilohYu:fix/glm53-deterministic-sparse-mla-bwd

Conversation

@ShilohYu

@ShilohYu ShilohYu commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

CI task glm5-3-flash-sft-25B-vl (added in #2215) fails its grad_norm assertion: two runs at the
same commit (ebf73a232), same image, same data, XTUNER_DETERMINISTIC=true diverge. Tracker-level
evidence (qa-llm-cicd/test_output/{40617516054,39953013246}):

  • Steps 1-2: loss/reduced_llm_loss is bitwise identical (diff = 0.00e+00), but grad_norm
    already differs at step 1 (23.309881 vs 23.308867) → forward and data are deterministic; the
    first backward injects the noise.
  • The noise amplifies chaotically: step 10 grad_norm 13.3 vs 1.3; step 14 loss diff 3.03.

Op-level bisection (GLM-5.3-Flash-25B DSA geometry: NoPE, heads=64, head_dim=512, topk=2048, two
separate processes): forward output and dq are bitwise stable; dkv is not (sha256 differs).
Source: cuDNN's dsa_bwd_sm90 kernel scatters per-CTA dKV contributions into one shared FP32
accumulator with atomic_add_fp32x4 — the cross-CTA completion order differs every run. dq has no
shared accumulation and is deterministic.

This is the same factor known from GLM-5.2 (whose CI config selects the tilelang backend, which
has the same class of issue in another kernel). GLM-5.3's NoPE 512-wide geometry is only supported
by flash_mla_cudnn, so there was no escape hatch. Full analysis, alternatives measured, and
iteration history: see the design doc linked below.

Change (final form: subclassed cuDNN kernel + split-slice ordered reduction)

When XTUNER_DETERMINISTIC=true, the backward takes a deterministic path; the default path is
unchanged. No vendored kernel copy — everything subclasses/reuses the installed cudnn package:

  • xtuner/v1/ops/sparse_mla/_cudnn_dsa_bwd_det_subclass.py (96 lines): subclass of the stock
    CuTe-DSL kernel overriding only scatter_dkv_atomic (+2 lines: offset each CTA's scatter target
    by blockIdx.x % n_splits * slice_size into a widened FP32 accumulator, so CTAs never share
    addresses). FA2's deterministic backward uses the same split-buffer + ordered-reduction pattern.
  • xtuner/v1/ops/sparse_mla/cudnn_dsa_det.py (526 lines): phase-controllable interface +
    multi-launch driver (flash_attn_bwd_sm90_multilaunch): preprocess once, 128-query launches
    whose slices persist, then a fixed-order slice reduction and one postprocess.
  • cudnn_dsa.py (+27/-2): the XTUNER_DETERMINISTIC dispatch lives inside the shared backward
    op _cudnn_dsa_sparse_mla_backward_op, so every backend routing through it gets the
    deterministic path (flash_mla_cudnn and glm5.2's cudnn_dsa); flash_mla_cudnn.py is
    byte-identical to main.
  • tests/ops/test_flash_mla_cudnn_sparse_mla.py: bitwise-repeatability + torch-reference
    equivalence tests.

Iteration history (kept in this branch for the record): v1 einsum reference backward with
deterministic index_add_ (worked, but 1.9s/layer), v2 Triton ordered segment reduction
(540ms/layer), both removed in favor of the fork; the final diff only adds the fork.

Validation

  • Op-level (16K×16K×64h×512d×topk2048, H200): dq bitwise identical to stock, dkv relL2 5.1e-5
    (reduction-order level), bitwise repeatable across processes, 57ms/layer (stock 36ms).
  • CI-scale double run of the glm53_25b_vl CI config (41 steps, XTUNER_DETERMINISTIC=true):
    all training metrics bitwise identical across runs (only wall-clock fields differ);
    median step time 3.5s vs ~2s stock (tgs nearly unchanged), +7GB peak memory for the slices.

CI double-run comparison

Impact / notes

  • Only XTUNER_DETERMINISTIC=true runs change behavior; production default is untouched.
  • CI baseline trackers recorded with the cuDNN backward must be re-recorded once after merge.
  • The subclass mirrors cudnn 1.28.0's scatter_dkv_atomic; a cudnn upgrade that changes that
    method's internals must be handled by guarding/falling back (noted in the file docstring).
  • GLM-5.2's cudnn_dsa / flash_mla (tilelang bwd) backends share the same unordered-atomic dKV
    issue and are intentionally out of scope.

…NER_DETERMINISTIC

CI glm5-3-flash-sft-25B-vl double runs at ebf73a2 (same image, same code)
diverge in grad_norm from step 1 with bitwise-identical loss. Op-level probe
shows the cuDNN sparse-attention backward accumulates dKV with unordered
atomics: forward and dq are bitwise stable across runs, dkv is not. The tiny
dkv noise amplifies chaotically over steps (loss diff reaches 3.03 by step 14
in the VL task).

When XTUNER_DETERMINISTIC=true, route the backward to an ordered FP32 reference
implementation: fixed-order query chunks, gather + einsum recomputation of the
softmax backward, and dkv accumulation via index_add_ (deterministic under
torch.use_deterministic_algorithms). Verified bitwise repeatable across
processes and within 3e-3 relL2 of the cuDNN backward; the default path is
unchanged.
… segment reduction in sparse-MLA backward

PyTorch's deterministic index_add_ pays an internal sort + serial reduction
(~1.5s, ~64GB workspace at GLM-5.3-Flash's DSA shape: total_q=16384,
topk=2048, dim=512). Stage per-chunk dKV contributions in a bf16 buffer, sort
by kv index once per 2048-query flush, and reduce each kv's segment serially
in sorted order with a Triton kernel into an FP32 accumulator. The fixed
kv-major + in-segment order keeps the backward bitwise repeatable while the
reduce itself drops to ~20ms/layer (total backward 1900ms -> 540ms).

-1 padding rows are dropped before the reduction instead of being clamped
into segment 0: they are exact-zero contributions, and clamping them formed
a single ~2.1M-row monster segment that one program had to sum serially
(~436ms on the first flush). Falls back to deterministic index_add_ when
triton is unavailable or head_dim is not a power of two.
… split-slice accumulation

The cuDNN SM90 DSA backward accumulates dKV with cross-CTA unordered atomics.
This adds cudnn_dsa_det.py: a multi-launch driver (128-query launches over a
widened FP32 accumulator) plus a subclass of the stock CuTe-DSL kernel that
overrides only scatter_dkv_atomic to offset each CTA into its own slice; slices
are reduced in fixed order afterwards. Bitwise repeatable across processes at
57ms per DSA-layer backward (stock 36ms), dq bitwise identical to stock, dkv
relL2 5.1e-5. XTUNER_DETERMINISTIC=true prefers this path; the einsum+Triton
reference backward (deterministic_bwd.py) remains as fallback. No vendored
kernel copy: the subclass reuses the installed cudnn package's kernel.
… selector

Removes the deterministic index_add_ fallback path: the reference backward now
always uses the sort + Triton ordered segment reduction and asserts when triton
or a power-of-two head_dim is unavailable. The planned XTUNER_GLM53_DSA_DET_BWD
selector knob is reverted; dispatch stays a simple capability chain:
XTUNER_DETERMINISTIC -> fork (SM90+dim512) -> Triton reference -> assert.
…eterministic path

Removes deterministic_bwd.py (Triton ordered segment reduction) and the einsum
reference backward: XTUNER_DETERMINISTIC=true now always uses the split-slice
cuDNN fork and raises if its runtime is unavailable (SM90 + CuTe-DSL), instead
of falling back to a slower alternative implementation.
…_backward_op

The XTUNER_DETERMINISTIC branch now lives inside the shared backward op
(cudnn_dsa.py) instead of flash_mla_cudnn.py's autograd entry: flash_mla_cudnn.py
returns to main unchanged, and glm5.2's cudnn_dsa backend gets the deterministic
path for free since it routes through the same op.
…c driver

The fork driver always runs sink-free (GLM DSA passes attn_sink=-inf), so the
sink validation, state variables, compile-key entries and return variants are
dead code. No behavior change: tests still pass.
@Denny991

Denny991 commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Review summary

感谢这个实现。整体方向我认为是合理的:复用 cuDNN DSA backward 的主体,只通过一个很窄的子类覆写 scatter_dkv_atomic,再用独立 slice 和固定顺序归约消除跨 CTA 的无序原子加。相比维护一份完整的 vendor kernel fork,这个 seam 的代码量和后续维护成本都更低。

我也认可目标 workload 上的结果:提供的 41 步对比中,两次 deterministic 训练的 loss、grad norm、LR、token、TGS 和 memory 曲线重合。我随后在同一 PR head、同一 CI 配置和 8×H200 上独立复跑,也确认两次 deterministic 的非计时训练指标及显存记录逐项一致,说明方案确实解决了目标确定性问题。

不过,基于当前 head 85c73586bd6942fdcc1f4acd732bd0a55b5df37f,我建议在合并前处理下面两个 P1。第一个关系到最后一次累加顺序是否在所有已接受形状上都有构造保证;第二个关系到长序列 deterministic 路径是否仍然可执行。此外,我的同源码 stock 对照没有复现“TGS 基本不降”和“+7 GB peak memory”,具体数据放在验证部分,建议一起澄清测量口径。


P1: slice ID 只包含 blockIdx.x,没有包含 head-tile 维度

相关位置:

  • xtuner/v1/ops/sparse_mla/_cudnn_dsa_bwd_det_subclass.py:69-71
  • xtuner/v1/ops/sparse_mla/cudnn_dsa_det.py:153-155
  • xtuner/v1/ops/sparse_mla/cudnn_dsa_det.py:388-404

当前 slice offset 是:

split_offset = (cute.arch.block_idx()[0] % self._det_n_splits) * self._det_slice_size

这里假设一次 main launch 的 CTA 可以只通过 blockIdx.x 区分,但 cuDNN 1.28 的 scheduler 在 qhead_tiles > 1 时还会使用 grid.y。例如 128 heads 时,grid 会包含两个 head-tile CTA,而不是 docstring 中描述的 grid=(chunk, 1, 1)。

因此,(blockIdx.x=x, blockIdx.y=0) 和 (blockIdx.x=x, blockIdx.y=1) 会得到相同的 split_offset,同时写入同一个 slice。这样一来,“每个 CTA 拥有私有 slice,所以跨 CTA 累加顺序消失”的证明不再成立。跨多个 launch 复用同一 slice 后,同一地址还可能存在两项以上的 FP32 累加,其先后次序会重新影响 bitwise 结果。

我在 H200 上补了 128 heads、2/3/5 次 main launch、不同 head-tile 梯度量级以及 duplicate indices 的探针,每组重复 30 次,目前都逐位一致。因此这不是一个我已经复现的 bitwise failure;但空闲机器上稳定的调度结果不能替代 slice ownership 的构造证明,后续不同 GPU 占用、cuDNN 版本或 scheduler 变化也不应影响这个不变量。

建议按一次 launch 的完整 CTA grid 计算线性 ID,并同步修改 workspace 的 slice 数。例如,在当前 batch 维固定的情况下,逻辑上应接近:

# Pseudocode; please use the corresponding CuTe grid APIs.
linear_cta = blockIdx.x + blockIdx.y * gridDim.x
split_offset = linear_cta * slice_size
n_slices_per_launch = launch_query_count * qhead_tiles

multi-launch driver 中不建议再用 SM count 对 CTA ID 取模:只要不同 CTA 在同一 accumulation 阶段共享 slice,就会重新引入需要证明的写顺序。独立 single-launch helper 如果继续保留,也应拒绝或正确处理 total_CTAs > n_splits 的配置。

另外,_cudnn_dsa_bwd_det_subclass.py:55-58 中关于 duplicate top-k entries “accumulate in program order on the same thread” 的注释,建议补充线程映射依据,或者改成更保守的说明。当前 duplicate 探针通过是好的信号,但这段注释本身还不足以证明所有 fragment/thread layout 下的写顺序。


P1: 默认 workspace 为 128 × full_KV × head_dim,长序列显存上界不可接受

相关位置:

  • xtuner/v1/ops/sparse_mla/cudnn_dsa_det.py:397-411
  • xtuner/v1/ops/sparse_mla/cudnn_dsa_det.py:465-468

当前主 workspace 大小为:

4 × min(chunk, S_q) × round_up(S_k, 64) × round_up(D, 32) bytes

默认 chunk=128,也就是相对于单份 FP32 dKV accumulator 放大约 128 倍;之后 sum(dim=2) 还会生成一份 reduced accumulator。

我在 S_q=S_k=16384, H=64, D=512, topk=128 上测到:

Arm Peak allocated 相对调用前的峰值增量
stock 4.0938 GiB 1.0626 GiB
deterministic 8.0938 GiB 5.0626 GiB

deterministic 的额外峰值恰好是 4 GiB,与上述 128-slice 公式一致。

按同一公式外推到 S_k=524288, D=576:

  • 主 workspace 为 154,618,822,656 bytes,即 144 GiB;
  • reduced accumulator 还需要额外 1.125 GiB;
  • 如果 S_q 也是 512K,当前实现还会执行 4,096 次 main launch。

提供的 41 步 CI 图显示,目标约 16K-token workload 的 deterministic memory 大约为 95–98 GB,stock 大约为 87–90 GB,说明当前目标形状可以容纳这份开销。但通用 dispatch 接受的长序列形状可能无法运行,因此建议不要只以目标 CI 形状作为显存契约。

建议至少选择一种处理方式:

  1. 实现 memory-bounded 的 slice/chunk 策略,并让 slice 数与实际并发 CTA/grid 一致;
  2. 按 KV tile 做分块累加和归约,避免保存“每 CTA × full KV”的 workspace;
  3. 使用另一种不保留全部 per-CTA full-KV accumulator 的确定性两阶段归约;
  4. 如果暂时不准备支持长序列,明确记录 deterministic 路径的显存契约和支持范围,并对必然超过资源的形状给出清晰错误,而不是静默 OOM。

需要特别注意:不能只把 n_splits 调小、同时保留原来的并发 CTA 数量,否则多个 CTA 会再次共享 slice,与第一个 P1 冲突。workspace 策略和 CTA-to-slice 映射最好一起修改、一起测试。


P2: deterministic 分支缺少对应的 runtime capability check

相关位置:

  • xtuner/v1/ops/sparse_mla/cudnn_dsa.py:112-130
  • xtuner/v1/ops/sparse_mla/cudnn_dsa.py:172-188
  • xtuner/v1/ops/sparse_mla/cudnn_dsa_det.py:10-23
  • xtuner/v1/ops/sparse_mla/cudnn_dsa_det.py:75-76
  • xtuner/v1/ops/sparse_mla/_cudnn_dsa_bwd_det_subclass.py:11-13

当前 availability probe 主要覆盖 stock wrapper 和 SM90+,但 deterministic 分支还依赖 cuDNN private class、CuTe helper、特定的内部调用签名,并且实现本身针对 SM90。子类注释提到了 import/capability fallback,但实际 dispatch 中没有看到对应的 guard。

建议增加 deterministic-specific capability probe,至少覆盖:

  • private imports 和所需方法是否可用;
  • 已验证的 cuDNN API/version;
  • major == 9,而不只是 major >= 9;
  • dispatch 与 test skip 共用同一判断,避免测试环境和实际运行路径不一致。

不支持时应该给出清晰错误,或者走另一个仍然确定性的实现;不建议静默回退到 stock backward,因为调用方设置 XTUNER_DETERMINISTIC=true 后,通常会把“启用成功”理解为确定性契约已经生效。


P2: 建议把已经验证的关键边界合入 CI

当前新增测试使用 64 queries、64 heads,因此只覆盖一次 main launch,也没有触发 qhead_tiles > 1。建议补充:

  1. S_q=64/128/129/257,覆盖 single-launch、完整 chunk 和 partial final chunk;
  2. 64 和 128 heads,后者用于防止只按 blockIdx.x 分配 slice 的回归;
  3. 512/576 两种 head dim,576 目前只有数值回归,缺少跨 launch bitwise 覆盖;
  4. interspersed -1、duplicate valid indices,并同时比较 stock/det 的数值语义;
  5. workspace/slice 映射的小型可执行测试,以及至少一个代表性长上下文内存检查。

此外,tests/conftest.py:9 默认设置了 XTUNER_DETERMINISTIC=true。这意味着既有通用测试默认不再保护 stock backward 分支。建议共享测试显式参数化 deterministic=false/true,避免 deterministic 成为默认后,stock 路径失去原有回归覆盖。


P3: docformatter 检查尚未通过

我在当前 head 上执行的结果是:

ruff check: passed
ruff format --check: passed
py_compile: passed
git diff --check: passed
docformatter --check --wrap-descriptions 119: failed

docformatter 涉及:

  • xtuner/v1/ops/sparse_mla/_cudnn_dsa_bwd_det_subclass.py
  • xtuner/v1/ops/sparse_mla/cudnn_dsa_det.py
  • tests/ops/test_flash_mla_cudnn_sparse_mla.py

按项目 CI 规则格式化后重跑即可。


我这边的验证范围

验证环境为 H200、PyTorch 2.9.1+cu128、cuDNN 1.28.0,代码为上述 PR head。

  • PR 新增 deterministic 测试:2 passed;
  • 既有 576-wide cuDNN 数值回归:1 passed;
  • 额外 9 组 bitwise 探针,每组重复 30 次:全部逐位一致;
  • 覆盖 64/128 heads、single/multi-launch、129/257/513 queries、duplicate/holes 和不同 head-tile 梯度量级;
  • 固定同一份 FlashMLA forward 输出和 LSE,只切换 stock/deterministic backward:unique、duplicate、duplicate+holes 均通过既有数值容差;
  • 16K 微基准中,两组交替 A/B 的算子时间分别为 8.33→11.43 ms、10.72→11.37 ms。这个数字只代表该算子、该形状,不外推为整步性能结论。

41 步完整训练独立复跑

我使用原始 autotest/config/glm53_25b_vl.py、相同模型和 ci_vl 数据,在同一源码快照上依次运行 deterministic A、deterministic B 和 stock A。三组均自然完成 41/41 步,退出码均为 0;除 XTUNER_DETERMINISTIC 外,配置和环境开关保持一致。

确定性结果通过:

  • 8 个 rank × 41 步,共 6,232 个非计时训练指标值,deterministic A/B 全部完全相等,0 项差异;包括 loss、grad norm、LR、token 和负载统计;
  • 两次 deterministic 的 allocated/reserved memory 记录也逐步、逐 rank 完全相等;
  • 这里排除了 time/* 和 TGS 等墙钟/吞吐字段,不把性能数值相同冒称训练语义相同。

性能和显存采用第 11–41 步作为共同稳态窗口。step_time 先对同一步的 8 个 rank 取最大值,再取窗口中位数;全局 TGS 为各 rank 唯一 text token 之和除以同一个 max-rank step-time。显存为第 41 步时各 rank 历史峰值的最大值,单位按源码实际口径记为 GiB:

Arm max-rank step-time median 全局 TGS median peak allocated peak reserved
deterministic A 3.5729 s 33,222.9 94.694 GiB 133.623 GiB
deterministic B 3.7609 s 33,031.4 94.694 GiB 133.623 GiB
stock A 3.2479 s 38,555.0 94.275 GiB 132.469 GiB

在这组同源码、同环境对照中:

  • deterministic A/B 的 step-time 分别比 stock 高 10.0% / 15.8%;
  • 全局 TGS 分别低 13.8% / 14.3%;
  • deterministic 的最大 allocated/reserved 分别高 0.419 / 1.154 GiB,没有复现描述中的 +7 GB peak memory。

目前只有一组 stock,运行间波动也客观存在,所以我不把这些数字直接定性为普遍性能回归;但它们足以说明“TGS 基本不降”不能作为当前实现的通用结论。建议作者核对自己的 stock 基座、源码 commit、环境、统计窗口和 TGS 口径,最好补充同源码开关切换的重复或交错 A/B。这个性能测量差异不改变上面的确定性复现结论,也不改变两个 P1 的代码问题。

总体而言,我认为这个方案值得继续推进。优先把完整 CTA grid 的 slice ownership 和 workspace 策略处理好,再补 capability guard 与对应回归,就能让实现从“目标形状实测稳定”变成“确定性不变量和资源边界都可检查”。

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants