Conversation
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>
Owner
Author
|
Rebased onto Two things upstream did for us in that window:
Post-rebase verification: |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Makes the mHC (hyper-connections) tilelang ops batch-invariant under
VLLM_BATCH_INVARIANT=1.compute_num_splitderives the K-split count fromn_sms // cdiv(num_tokens, 64), so the GEMM reduction tree changes withthe batch — the same defect class as the RMSNorm block-size fix in
upstream vllm-project#48391.
use_small_fmaadditionally switches to a secondimplementation at
num_tokens <= 16. Under the flag the split count ispinned to a batch-independent constant (
min(cap, n_sms//4)rounded downto 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.serialordered loop, so pinning the count pins the tree.Perf (CUDA-graph + events, GB200)
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: bitwisestability 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_postandhc_headregressions, and negativecontrols that actually fail — with checkpoint-realistic magnitudes
(
hc_attn_scale≈[2.08,0.019,0.245]): synthetic*1e-4weights wash the10-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_gemmis DeepGEMM's and this path has no otherimplementation of it, so a
deep_gemmwithoutset_batch_invariantleavesit 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_tilelangandmhc_fused_post_pre_tilelang, one call each —behind a
functools.cacheso the per-layer path pays the probe once. This adds a dependency ondeep_gemm_batch_invariant_enabled()from #24.Notes
compute_num_splitdefect and no deterministic branch; nothing upstreamcovers mHC BI.
🤖 Generated with Claude Code
Update: consolidation round (2026-08-15)
Branch rebuilt on a clean
bi/baseparent with the consolidation passfolded in (shared test helpers, pure Triton key fn,
tl.constexprconstants — 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.py15 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:
are all
skip_if_not_cuda.schedule is used there for correctness; the GB200 numbers are not
extrapolated to unmeasured hardware.
HAS_AITER_MHC and hidden_size % 256 == 0, so this PR must not be read as"ROCm mHC is batch invariant".
and the regressions are kernel-level.
Added after the audit:
mhc_fused_post_pre_tilelangwithnorm_weight) now has an invariance sweep, a negative control, and areference composed from the kernel suite's own post/pre references plus an
fp32 RMSNorm. Its epilogue is not shared with the norm-free entry.
B200 always takes DeepGEMM and nothing else in the file reached it. Of its two
branches only the
< 128one moves a row's bits; the>= 1024block-Mvariant keeps the same
n_thrandtile_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.pyis now listed in the B200Batch Invariance job; it was previously not executed by any job, since that job
names its files individually.