Skip to content

[BI][DSv4] Make mHC tilelang ops batch invariant - #22

Open
aoshen02 wants to merge 5 commits into
bi/basefrom
bi/mhc
Open

[BI][DSv4] Make mHC tilelang ops batch invariant#22
aoshen02 wants to merge 5 commits into
bi/basefrom
bi/mhc

Conversation

@aoshen02

@aoshen02 aoshen02 commented Aug 15, 2026

Copy link
Copy Markdown
Owner

What

Makes the mHC (hyper-connections) tilelang ops batch-invariant under
VLLM_BATCH_INVARIANT=1.

compute_num_split derives the K-split count from
n_sms // cdiv(num_tokens, 64), so the GEMM reduction tree changes with
the batch — the same defect class as the RMSNorm block-size fix in
upstream vllm-project#48391. use_small_fma additionally switches to a second
implementation at num_tokens <= 16. Under the flag the split count is
pinned to a batch-independent constant (min(cap, n_sms//4) rounded down
to a power of two → 32 on GB200: divides the 256 K-blocks, single wave up
to M=256) and the small-fma fork is disabled. The cross-split merge is
already a T.serial ordered loop, so pinning the count pins the tree.

Perf (CUDA-graph + events, GB200)

n baseline BI (s=32)
≤128 10.27 us 10.26 us (0%)
129–192 12.30 us 10.26–11.05 us (faster)
193–256 12.30 us 12.30 us (0%)

Only n=1 keeps +2 us (the fused kernel splits into post+GEMM — mechanism-
inherent). ncu: grid 192→96 single wave; the earlier +17% was attributed
by probe to the second wave turning real past grid ~150, and eliminated by
the s=32 choice.

Tests

tests/v1/determinism/test_mhc_batch_invariance.py — 10 passed: bitwise
stability across batch boundaries [1,7,8,15,16,17,63,64,65,128,129,192,
193,256] for mhc_pre / mhc_fused_post_pre / first-layer broadcast /
fused-RMSNorm variants, mhc_post and hc_head regressions, and negative
controls that actually fail — with checkpoint-realistic magnitudes
(hc_attn_scale≈[2.08,0.019,0.245]): synthetic *1e-4 weights wash the
10-ULP reordering signal below fp32 resolution and made the first control
pass vacuously.

GSM8K (full 43-layer Flash-Base, 1319 questions, 5-shot, greedy): BI=1
accuracy 0.911 vs BI=0 0.904 (0 invalid both; within noise).

Review follow-up

An external review (codex, gpt-5.6) found the fail-closed discipline had a
hole, and it was verified against the source before acting:
tf32_hc_prenorm_gemm is DeepGEMM's and this path has no other
implementation of it, so a deep_gemm without set_batch_invariant leaves
it free to select its config from the batch. Disabling DeepGEMM MoE, the
loader's answer to such a build, does nothing here. Every entry point that
reaches the kernel now refuses — mhc_pre_tilelang,
mhc_pre_broadcast_tilelang and mhc_fused_post_pre_tilelang, one call each —
behind a functools.cache so the per-layer path pays the probe once. This adds a dependency on
deep_gemm_batch_invariant_enabled() from #24.

Notes

  • Not a duplicate: SGLang's mHC implementation has the same
    compute_num_split defect and no deterministic branch; nothing upstream
    covers mHC BI.
  • AI assistance was used (Claude); every line human-reviewed before merge.

🤖 Generated with Claude Code

Update: consolidation round (2026-08-15)

Branch rebuilt on a clean bi/base parent with the consolidation pass
folded in (shared test helpers, pure Triton key fn, tl.constexpr
constants — plain global ints fail to compile under Triton 3.7 — and
repo-pinned ruff format), adversarially reviewed (codex r10/r10b).
Container suite on GB200: test_mhc_batch_invariance.py 15 passed.

Scope (added 2026-08-16 after audit)

This validates CUDA TileLang mHC batch invariance on SM100 (GB200). The change
itself is correctness-first and platform-independent: it stops the two fast
paths from switching kernels on the token count, which is what made a row's
result move with the batch. That applies wherever TileLang runs, including
ROCm -- limiting it to CUDA would leave ROCm batch-variant under the flag,
which is the worse outcome.

What is not claimed:

  • No numerical or performance evidence on ROCm TileLang/AITER. The new tests
    are all skip_if_not_cuda.
  • No performance claim for SM120 or any other Blackwell part. The generic
    schedule is used there for correctness; the GB200 numbers are not
    extrapolated to unmeasured hardware.
  • ROCm AITER is not covered at all. Production ROCm prefers AITER when
    HAS_AITER_MHC and hidden_size % 256 == 0, so this PR must not be read as
    "ROCm mHC is batch invariant".
  • No real-model end-to-end evaluation. This is a kernel-level correctness fix
    and the regressions are kernel-level.

Added after the audit:

  • The production fused-norm entry (mhc_fused_post_pre_tilelang with
    norm_weight) now has an invariance sweep, a negative control, and a
    reference composed from the kernel suite's own post/pre references plus an
    fp32 RMSNorm. Its epilogue is not shared with the norm-free entry.
  • The non-DeepGEMM prenorm GEMM's own dispatch is now exercised directly, since
    B200 always takes DeepGEMM and nothing else in the file reached it. Of its two
    branches only the < 128 one moves a row's bits; the >= 1024 block-M
    variant keeps the same n_thr and tile_n, reassociates K in the same order,
    and comes out bitwise equal at DSv4's shape. It stays pinned anyway, and the
    negative control says which of the two it can actually prove.

tests/v1/determinism/test_mhc_batch_invariance.py is now listed in the B200
Batch Invariance job; it was previously not executed by any job, since that job
names its files individually.

aoshen02 and others added 3 commits August 18, 2026 15:07
compute_num_split derives the K-split count from n_sms // cdiv(num_tokens,
64), so the reduction tree changes with the batch (same defect class as the
RMSNorm fix in vllm-project#48391); use_small_fma switches to a second implementation
at num_tokens <= 16. Under VLLM_BATCH_INVARIANT=1 the split count is pinned
(min(cap, n_sms//4) rounded down to a power of two = 32 on GB200; divides
the 256 K-blocks, single wave up to M=256) and the small-fma fork is
disabled. Cross-split merge is already a T.serial ordered loop, so pinning
the count pins the reduction tree.

CUDA-graph cost after pinning: parity with baseline for n<=128 and 193-256,
faster at 129-192; only n=1 keeps +2us (fused kernel split into post+GEMM).

Tests (10): bitwise stability across batch boundaries for mhc_pre /
mhc_fused_post_pre / broadcast / fused-RMSNorm variants, mhc_post and
hc_head regressions, negative controls with checkpoint-realistic
magnitudes (synthetic small weights wash out real defects), correctness vs
the torch reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Consolidation pass folded in (codex r10/r10b reviewed): shared test
helpers, pure Triton key fn, tl.constexpr-instantiated constants (plain
global ints fail to compile under Triton 3.7), repo-pinned ruff format.
Container suite 39/39 green; topk equivalence probe 240/240 bitwise.
The new tests were not listed in the B200 Batch Invariance job, which names its
files individually, so they were never executed. Add the file.

Also narrow what the change claims. Disabling the two token-count fast paths is
a correctness change and applies wherever TileLang runs, ROCm included --
restricting it to CUDA would leave ROCm batch-variant under the flag, which is
worse. What was measured is the numerics and the cost of doing so, on SM100
only. Say that at both sites rather than letting a GB200 measurement read as a
claim about every platform the code touches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…em in CI

The suite exercised mhc_pre with a fused norm but mhc_fused_post_pre without
one, and the fused-norm entry is the shape DSv4 runs between attention and
FFN. Its epilogue is not shared with the norm-free one -- it takes a second
pass reducing over hidden_size to form the RMS -- so a split-count change
reaches the output through a path nothing covered. Add the invariance sweep,
a negative control, and a reference composed from the kernel suite's own post
and pre references plus an fp32 RMSNorm.

The non-DeepGEMM prenorm GEMM has two dispatch flips of its own, keyed on the
row count rather than the split count. B200 always takes DeepGEMM, so nothing
in the file reached them; call the helper directly instead. Only the < 128
branch turns out to move a row's bits: the >= 1024 block-M variant keeps the
same n_thr and tile_n, reassociates K in the same order, and comes out bitwise
equal at DSv4's shape. It stays pinned because that equality is a property of
the current tile config rather than a guarantee, and the negative control says
so rather than asserting a difference that does not exist.

Also add vllm/model_executor/kernels/mhc/ to the B200 job's
source_file_dependencies: this PR triggers the job only because it adds a test
file, and a later change confined to the kernels would not have run any of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aoshen02

Copy link
Copy Markdown
Owner Author

Rebased onto fork/bi/base, which now points at upstream aa9903490 — the exact
commit the nightly container image is built from, so the files copied into
site-packages can no longer be half-old. The previous base was 3ee2df303,
218 commits behind, and upstream had touched every file this stack changes.

Two things upstream did for us in that window:

Post-rebase verification: tests/v1/determinism (the six files this stack adds
or touches) → 120 passed, 0 failed on GB200 against the new image.

tf32_hc_prenorm_gemm is DeepGEMM's and the mHC path has no other
implementation of it, so a deep_gemm without set_batch_invariant leaves it
free to select its config from the batch. The loader's answer to that build
-- disable DeepGEMM MoE -- does nothing here, and mHC would keep running
under VLLM_BATCH_INVARIANT with a row's result depending on its neighbours.

Guard every entry that reaches the kernel, including the two that only
consult is_deep_gemm_supported() to choose n_splits. Cached, since this sits
on the per-layer path and the answer cannot change once deep_gemm is loaded.

Depends on the DeepGEMM branch for deep_gemm_batch_invariant_enabled().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mhc_pre_tilelang and mhc_fused_post_pre_tilelang each carried the fail-closed
guard twice -- once at the tf32 import and once at the is_deep_gemm_supported
site inside the same function. One call per entry point is the whole point of
caching it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant