Repository navigation
fix(qtip): gate the trellis grouped GEMM on m-tile occupancy, not token count - #167
Conversation
…en count The grouped GEMM does replace the gather — `qtip_gather_gemv_warp_kernel` vanishes and `qtip_lut_grouped_gemm_kernel` takes 62.37% at B=32 — but aggregate throughput COLLAPSED: 34.19 tok/s at B=8 -> 5.99 at B=32. Widening the batch 4x made serving 5.7x worse. GPU busy 90.8%, memory controller 3.5%, 48.2% unallocated warps: saturated doing nothing. The kernel ran 7,888 us/call, 133x above its own bandwidth bound (nsys, 97a65d6, exclusive H200). The kernel is fine. It was switched on ~2 orders of magnitude too early. The gate was `n_tokens > DECODE_REGIME_MAX_TOKENS` with DECODE_REGIME_MAX_TOKENS = 8, so it flipped at n=9. The grouped kernel stages each woken expert's packed bytes once per m-tile of GROUPED_TILE_M = 16 pairs, so its entire advantage over the per-pair GEMV is the average tile fill: pairs per woken expert = n*k / (E * (1 - (1 - k/E)^n)) fill = that / GROUPED_TILE_M At V4's top-6-of-256, n=32 wakes ~136 distinct experts for 192 pairs => 1.41 pairs per expert => the 16-row tile is 8.8% full. Fill reaches 75% at n~512 and 100% at n~683. The in-tree A/B agrees: 1.00x at N=128 and N=512, 2.41x at N=1024. So gate on the property the kernel actually needs — `grouped_gemm_tiles_amortize`, pairs per woken expert >= GROUPED_TILE_M — rather than a token count, which rots the moment top_k, num_experts or GROUPED_TILE_M moves. Uniform routing is the conservative assumption: a skewed real router wakes fewer experts and fills tiles more, so the gate errs toward switching late. Applied to both rungs. The bitshift rung carried the identical defect behind a comment arguing the cap was correct there because its over-boundary path is the amortizing kernel — true, but only once the tiles are full. That rung is NOT measured here. Also: `--v4-ragged-decode`'s doc claimed equivalence to `ARC_V4_XS_PER_SEQ=1` and the equivalence was false. `v4_ragged_decode` defaults to false, so every build path called `with_v4_ragged_decode(false)`, which latched the OnceLock before the first read could consult the environment — instrumentation showed `batch_can_be_ragged=false xs_per_seq_enabled=false` with the variable set. Not asking for the feature is not the same as asking for it off, so only `true` latches now and the documented fallback works. Tests pin the fill curve the derivation is written from, that the gate refuses every decode width from 9 to 512 at V4's routing and accepts 683+, that the same token count flips the other way under different routing (the property a constant cannot have), and that the grouped and fused-GEMV boundaries leave no token range where dispatch falls through to the dequantize-materialize path — they are set by two independent constants that both happen to be 16. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SpVNMpb13HkUXqSqbN1o9H
Re-ran B=8 and B=32 on the same box and instrument with the gate fixed (89ec931, binary 85aca0165f58). The fix does what it says — the new dispatch log reports the grouped GEMM engaging at 1,572 tokens x top-6 of 256 experts, m-tiles 230% full, and `qtip_gather_gemv_warp_kernel` reappears at B=32 for the 32-token decode steps (534 calls) beside 618 prefill grouped calls, a split that did not exist before. But it does not restore batch scaling, and the premise does not survive: * At B=32 the grouped kernel was never on a 32-token decode step. It was on 1,572-token prefill chunks at 230% fill, the right side of its own boundary. * The B=32 leg ran ~4-6 decode steps in an 18 s window; sequences were still being admitted when it ended (1 -> 21 in 48 s, 2-4 tokens each). The B=8 leg was flat steady state from t~14 s. The "34.19 at B=8 vs 5.99 at B=32" comparison put a steady-state decode window beside a prefill-admission window. * Matched-depth last-10s: B=8 36.80 -> 37.60 tok/s (unchanged, as it must be — the gate does not move at n <= 8); B=32 9.90 -> 6.40, both mid-ramp. * Tile occupancy does not explain the kernel's distance from its bound either. With decode routed away its prefill-only mean ROSE to 15,600 us/call at 230% fill, still ~60x above bound, DRAM read 1.9%, 48.4% unallocated warps. The gate fix stands on its own terms: n_tokens > 8 is not the property the kernel needs and rots when top_k / num_experts / GROUPED_TILE_M move. The B=32 number is V4's known prefill cost reaching a harness whose steady-state rule gates on decode-cohort width alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SpVNMpb13HkUXqSqbN1o9H
Code Metrics Report━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Language Files Lines Code Comments Blanks ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ C Header 5 305 210 52 43 CSS 2 1181 1036 34 111 CUDA 73 25129 18043 4319 2767 Dockerfile 1 39 22 8 9 JavaScript 16 3546 2676 482 388 Jinja2 7 694 656 5 33 JSON 74 4600 4597 0 3 Makefile 1 6 5 0 1 Metal Shading Lan| 33 12224 9431 1142 1651 PowerShell 1 300 227 30 43 Python 145 15139 12482 811 1846 Shell 37 8630 5732 2323 575 Plain Text 4 3801 0 2479 1322 TOML 33 1498 1294 54 150 YAML 3 25 23 2 0 ───────────────────────────────────────────────────────────────────────────────── HTML 4 2687 2604 43 40 |- CSS 2 543 479 37 27 |- JavaScript 1 1233 1215 12 6 (Total) 4463 4298 92 73 ───────────────────────────────────────────────────────────────────────────────── Jupyter Notebooks 4 122 83 23 16 |- Markdown 1 60 30 22 8 |- Python 1 122 113 1 8 (Total) 304 226 46 32 ───────────────────────────────────────────────────────────────────────────────── Markdown 203 44777 0 34735 10042 |- BASH 72 1654 1202 331 121 |- C 3 17 17 0 0 |- CUDA 2 84 56 16 12 |- JSON 18 708 708 0 0 |- PowerShell 1 1 1 0 0 |- Python 23 1008 787 113 108 |- Rust 66 2051 1716 77 258 |- TOML 6 207 164 0 43 |- YAML 5 41 36 5 0 (Total) 50548 4687 35277 10584 ───────────────────────────────────────────────────────────────────────────────── Rust 671 324626 280130 15983 28513 |- Markdown 489 27661 471 23865 3325 (Total) 352287 280601 39848 31838 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Total 1317 484719 346246 87004 51469 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ |
|
Post-merge note, recorded because this PR is not inert and the merge summary should not imply it was. What changes for a user who sets no flags. The gate at
So the framing 'decode and prefill are unchanged' is true only at the two endpoints. Anyone batching above 8 gets a different MoE kernel with no flag set. Why I am not reverting it: both paths compute the same thing — this is a dispatch heuristic, not a math change — so the exposure is performance, not correctness. And the new gate is the more principled one (actual m-tile occupancy beats a token-count proxy). Action for the next session with a box: A/B the 9-682 band specifically — B=16/32/64 decode and 128/256/512-token prefill chunks — before treating this gate as settled. Related: #99 raises |
…192) #167 is the only one of the seven PRs merged in the 2026-08-20 queue sweep that changes behaviour for a flagless user, and it landed on a green-CI gate that cannot see performance. Recording it in the register so it stays a visible open question rather than a merged assumption. The gate moved the 9-682 token band from grouped GEMM to gather-GEMV -- batched decode at B>=9 and every prefill chunk under 683 tokens, which is where chunked prefill lives. The path it now prefers carries 3.15x redundant reads, so it may be slower exactly where it fires. Names the A/B that settles it (B=16/32/64, chunks 128/256/512) and the coupling with the open #99, whose GROUPED_TILE_M -> 64 on SM90+ moves the threshold ~4x and which must read grouped_tile_m_for_cc or the gate and kernel will disagree on tile size. Also records the caveat covering the whole sweep: every number attached to the seven merged PRs was measured on that branch's own base, not on current master, and none may be quoted until re-measured. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The regression
The trellis grouped GEMM does replace the gather —
qtip_gather_gemv_warp_kerneldisappears entirely andqtip_lut_grouped_gemm_kerneltakes 62.4% of GPU kernel time at B=32. But aggregate throughput collapsed:gather_gemv_warp38.5%lut_grouped_gemm62.4%Widening the batch 4× made serving 5.7× worse. Memory controller 3.5%, SM issue 22.5%, 48.2% unallocated warps — saturated doing nothing. The kernel ran 7,888 µs/call, 133× above its own bandwidth bound (nsys, exclusive H200,
97a65d643).The cause is a threshold, not a kernel defect
The gate was
n_tokens > DECODE_REGIME_MAX_TOKENSwithDECODE_REGIME_MAX_TOKENS = 8, so it flipped at n = 9.The grouped GEMM sorts pairs by expert and walks
sum_e ceil(count_e / GROUPED_TILE_M)m-tiles, staging each woken expert's packed bytes once per m-tile. Its whole advantage over the per-pair GEMV is therefore the average tile fill:At V4's top-6-of-256:
The in-tree A/B brackets exactly that curve: 1.00× at N=128 and N=512, 2.41× at N=1024.
The kernel is fine. It was switched on roughly two orders of magnitude too early.
The fix
Gate on the property the kernel actually needs —
grouped_gemm_tiles_amortize: pairs per woken expert ≥GROUPED_TILE_M, i.e. an average m-tile is full.Chosen over raising the constant to ~683 because a hardcoded number rots the moment
top_k,num_expertsorGROUPED_TILE_Mmoves — and the last constant that was not re-derived is this regression. The predicate needs no device data: it is a closed form in three host-side scalars, so it is still safe inside a CUDA-graph capture.Uniform routing is the conservative assumption: a skewed real router wakes fewer distinct experts and so fills tiles more than this predicts, so the gate errs toward switching late, never early.
DECODE_REGIME_MAX_TOKENSsurvives as the RUN-161 capture-safety floor, andARC_QTIP_ONDEVICE_MOE_MAX_TOKENSstill pins the GEMV arm outright so a harness can A/B the two kernels.Applied to both rungs
The bitshift (
qtip2b) rung carried the identical defect atbitshift.rs:1841, behind a comment arguing the cap of 8 was correct there because its over-boundary path is the amortizing kernel. True — but only once the tiles are full; at 9 tokens they are not. Same predicate, same reasons. The bitshift rung is not measured in this PR — the checkpoint under test isqtip2.Also:
--v4-ragged-decodedocumented a false equivalencemistralrs-cli/src/args/mod.rsclaimed the flag was "equivalent toARC_V4_XS_PER_SEQ=1, which remains supported". It was not.v4_ragged_decodedefaults tofalse, so every build path calledwith_v4_ragged_decode(false)→request_xs_per_sequence(false), which latched theOnceLockbefore the first read could ever consult the environment. Instrumentation on the box showedbatch_can_be_ragged=false xs_per_seq_enabled=falsein a run with the variable set — theD32leg above.Not asking for a feature is not the same as asking for it to be off, so only
truelatches now and the documented fallback works. A caller that genuinely wants to force it off against the environment can still callmistralrs_core::request_xs_per_sequence(false)directly.Tests
cargo test -p mistralrs-quant --lib qtip::gather_policy— 9 passed.tile_fill_matches_the_v4_curve_in_the_module_docspins the fill table the derivation is written from, so the docs cannot go stale silently.grouped_gate_refuses_decode_widths_and_accepts_full_tilesasserts the regression directly: no selection at n = 9…512, selection at 683+ — and that the same token count flips the other way under different routing (32 tokens × top-6 of 8 experts), which is the property a constant cannot have.grouped_min_tokens_is_the_exact_flip_pointchecks the reported boundary against the predicate at n and n−1 across 30 routing shapes.grouped_boundary_is_the_fused_gemv_boundarypins that the grouped and fused-GEMV boundaries leave no token range where dispatch falls through to the dequantize-materialize path. They are set by two independent constants (DEQUANT_TRAFFIC_RATIO,GROUPED_TILE_M) that both happen to be 16; if either moves, this fails loudly instead of opening that window silently.Runs now also log which MoE kernel was selected and at what tile fill (
log_grouped_gemm_engaged_once), so a results artifact can be read back to the dispatch that produced it.Re-measurement on this branch — and a retraction of the premise
Same box, same instrument, same harness rule,
89ec93140, binary85aca0165f58…. LegsF8/F32against the priorR8/R32, both with--v4-ragged-decode.The fix does what it says. The new
log_grouped_gemm_engaged_onceline settles it directly:and the profile shows the dispatch split that was not there before —
qtip_gather_gemv_warp_kernelreappears at B=32 for the 32-token decode steps (534 calls) while the grouped GEMM keeps the 1,572-token prefill chunks (618 calls):gather_gemvlut_grouped_gemmBut it does not restore batch scaling, and the premise it was filed against does not survive contact with the data.
The B=32 leg never decodes. Aggregate tok/s in 2 s bins over each whole leg:
At 18 s of nsys, B=32 executed roughly 4–6 decode steps total (534 GEMV calls ÷ ~120 MoE calls per forward). The rest was prefill. So the "34.19 tok/s at B=8 → 5.99 at B=32" comparison placed a steady-state decode window beside a prefill-admission window. It is not a batch-scaling measurement, and the 5.7× is not a decode regression.
Matched-depth check, last 10 s of each token stream:
And tile occupancy does not explain the kernel's inefficiency either. The grouped GEMM's mean call cost rose to 15,600 µs once the cheap decode calls were routed away from it and only prefill remained — at 230% tile fill, still ~60× above its bandwidth bound, with DRAM read at 1.9% and 48.4% unallocated warps. The 7,888 µs/call figure in the original report was an average of expensive 1,572-token prefill calls and cheap 32-token decode calls; separating them makes the prefill call look worse, not better. Whatever is costing 60–130× on this kernel is not m-tile emptiness.
What this PR is, then
A correct gate, landed on its own merits:
n_tokens > 8is indefensible as a boundary for a kernel whose amortization begins at 683 tokens for this routing, it is not the property the kernel needs, and it silently rots whentop_k/num_experts/GROUPED_TILE_Mmove. B=8 is unchanged, and B=32 decode steps now reach the GEMV instead of a 9%-full tile.It is not the fix for the B=32 throughput number. That number is V4's known prefill disease (
memory/mission/BUDGET_V4_PREFILL.md: 11.8 ms per prompt token, ~99.5% GPU-busy) reached through a harness whosewaitsteadyrule declares steady state as soon as the decode cohort is wide, which at B=32 happens while ~30 sequences are still queued for prefill. Two follow-ups worth filing separately: (1) the grouped GEMM is 60×+ off its bandwidth bound at full occupancy; (2)waitsteady.pyneeds a per-sequence token-depth floor, not just a cohort-width gate, or every B≥16 leg will keep reporting prefill as decode.