qwen3_5_moe: run lm_head on sampled rows only (fixes 32 GB first-prefill OOM) - #342
qwen3_5_moe: run lm_head on sampled rows only (fixes 32 GB first-prefill OOM)#342chrisqianz wants to merge 1 commit into
Conversation
…ill OOM The engine samples one row per request (batch_logits = logits[:batch.size]); the remaining rows of the forward window are overlap context. Projecting the whole window through the vocab GEMM allocated M x vocab bf16 -- a default 8192-token chunk at Qwen3.8's 248k vocab is 3.79 GiB, a guaranteed OOM on 32 GB cards at the first prefill of every session -- and spent FLOPs on rows nobody reads. Slice to batch.size rows before the lm_head GEMM. All three call sites already consume exactly this contract: the eager path slices logits[:batch.size], graph capture assigns into buffer.logits[:bs], and the prefill warmup discards the output. Long prefills additionally get a much cheaper lm_head pass. Report: unsloth/Qwen3.8-27B-NVFP4 on RTX 5090D. (cherry picked from commit 1d28547)
…cle stat) Ports from upstream FreeToken, adapted to the tier: FlashML-org#342 lm_head on sampled rows only (already generalised here via select_lm_head_rows); FlashML-org#339 the varlen GDN/KDA prefill conv takes max_seq_len from the scheduler on the Triton fallback (inert when sgl_kernel is installed, which every install path pins, so no node-4 change); FlashML-org#338 the n-gram PLE row-id hash as one Triton kernel with a bounded memo that is bypassed during CUDA graph capture (consumed by the pinned and cached PLE backends; the disk backend stages from its host hash); FlashML-org#231 the routing-oracle hit rate on the stats line next to the realised hot-pair rate, with the baseline reset on a live cache rebuild so the oracle can never read below realised. FlashML-org#89 (route-density tile selection) is skipped: its ds_fp4 tile table does not match the NVFP4 kernel's, which needs its own sm_89 sweep. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A88MCbnLtwsFSHmqwuJezY
|
Data point from trying the same one-line slice on the sibling hidden = self.model.forward(batch.input_ids, batch)
return self.lm_head.forward(hidden[: batch.size])On (the device assert surfaces at the sampler's first sync; without the slice the same run serves fine). So the "sampled rows are the first |
|
This is not a qwen3_5_moe bug. The same call site exists unfixed in three more families, and the engine slice you are keying on is shared by all of them: Every one projects the whole forward window and hands Concretely, on a deployment that is not 32 GB. We serve Qwen3.8-Flash-Next ( of bf16 logits to read at most 8 rows of, and runs a vocab GEMM 1024x larger than the sampler needs — on the TTFT path, every chunk. We had not hit the OOM your report opens with because the allocator has So the 32 GB OOM is the symptom that made this visible, not the boundary of the problem. Even where it fits, it is paid in TTFT. I have applied your one-liner to Two suggestions for this PR. First, it is worth widening to all four call sites — it is the same line, the comment you wrote applies verbatim, and leaving three behind means the next person rediscovers it on a different card. Second, a test would pin it cheaply. Slicing before a row-wise projection is exactly equal to slicing after, so the property is assertable without a GPU: full_then_slice = head.forward(hidden)[:size]
slice_then_project = head.forward(hidden[:size])
torch.testing.assert_close(slice_then_project, full_then_slice)plus a source guard on the call site so it cannot silently regress in a rebase. I have both in One thing worth stating explicitly in the comment, because it is the part a reviewer has to trust: the rows kept are the first 🤖 Generated with Claude Code |
This reverts fb5f3b4. The waste it targets is real -- engine.py keeps logits[:batch.size] and qwen4_exp projected the whole forward window, 4.07 GiB of bf16 logits at an 8192-token chunk -- but the one-line fix upstream applied to qwen3_5_moe (FlashML-org#342) is NOT correct for this family as written. At TP=2 the server dies in _warmup_prefill with an asynchronous IndexKernel.cu:111: Assertion `-sizes[i] <= index && index < sizes[i] && "index out of bounds"` failed torch.AcceleratorError: CUDA error: device-side assert triggered CUDA-graph capture passes, so batch.size does equal the captured bs; it is the prefill path, where the warmup batch is one request with `length` tokens, that breaks. Every indexing kernel in that path (PLE table gather, KV store, hybrid-SWA location mapping) runs BEFORE lm_head, so the dependency is not one static reading finds -- it needs CUDA_LAUNCH_BLOCKING=1 on the box to name the kernel. Reverting to keep the deploy branch at a state that starts. The change stays on perf/lmhead-rows as an open investigation. Note the test added alongside it passes: it asserts the projection is row-wise-exact and guards the call site, neither of which is the failing property. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
|
Correction to my previous comment, and it matters before you widen this PR. I said I had applied your one-liner to The only delta from a branch that starts and serves is your one line. I have reverted it on our deploy branch. What I can tell you about the shape of it:
I do not yet know whether this is specific to Also worth noting for your own PR: the test I wrote alongside the change passes. It asserts that slicing before a row-wise projection equals slicing after, and guards the call site. Both are true. Neither is the property that breaks. So "row-wise projection commutes with slicing" is not a sufficient argument for this change, and I was wrong to treat it as one — the engine's prefill contract, not the linear algebra, is what needs pinning. The memory and TTFT argument for the change stands and is worth chasing: 4.07 GiB of bf16 logits per 8192-token chunk at a 248k vocab, to read at most 🤖 Generated with Claude Code |
|
I have the kernel now, and the answer inverts my earlier comments. Please do not widen this PR — for any family whose head is Run under
def forward(self, x: torch.Tensor) -> torch.Tensor:
bs = batch.size
if batch.is_prefill:
indices = batch.attn_metadata.get_last_indices(bs)
x = x[indices].contiguous() # line 142
logits = self._logits(x)The head already does the reduction, and it does it before the GEMM. Two consequences. 1. Slicing first breaks it. 2. This is not a qwen4_exp quirk. On current and Which leaves your OOM genuinely unexplained, and worth a second look. Your traceback allocates 3.79 GiB at Sorry for the noise across three comments — the first two were wrong in opposite directions, and this one has the traceback behind it. Reverted on our side; our branch is back to a state that starts and serves. 🤖 Generated with Claude Code |
|
Closing as superseded — @gdevenyi's final comment is correct, and the real root cause was on our side. On The remaining question — quantized lm_heads keeping the reduction — is solved structurally by #418 + #438: heads are always Thanks for the CUDA_LAUNCH_BLOCKING trace that turned a wrong turn into the right answer. |
Symptom
Serving any qwen3_5_moe dense model (vocab 248,320 — e.g. Qwen3.6-27B-NVFP4, unsloth/Qwen3.8-27B-NVFP4) on a 32 GB card dies on the first request of every session:
The worker exits and the supervisor stops the API (
Backend worker is gone and cannot be restarted— the #20 symptom family). A shorter first prompt "works": the fatal allocation scales with the prefill window, so it fires exactly when the first request's chunk fills.Root cause
The eager model forward runs the vocab GEMM over the entire forward window, but the engine only ever consumes one row per request:
engine.py:933—batch_logits = logits[: batch.size](the rest is overlap context, discarded);graph.py:175— CUDA-graph capture already assignsself.buffer.logits[:bs] = model.forward(), i.e. the graph path is built around abs-row output;engine.py:985) discards the output entirely.So for a default 8192-token chunk the eager path transiently allocates 8194 × 248,320 × bf16 = 3.79 GiB — exactly the number in the traceback (8194 =
max_extend_tokens8192 + sampled rows) — and spends ~M × vocab × hiddenFLOPs on rows nobody reads. On a 32 GB card holding ~21 GB of weights + the KV pool, the first prefill of every session deterministically OOMs.Fix
Slice to the sampled rows before the lm_head GEMM:
qwen3_5_moe's forward window already carries the sampled rows first (that ordering is exactly what
logits[:batch.size]assumes), so no gather is needed. This is the same reduction deepseek_v4 already ships —F.linear(h[0, last_indices], self.head) # [B, vocab]— just expressed as a slice for this family's layout.Transient buffer: 3.79 GiB →
batch.size × vocab × 2B(~0.5 MB at concurrency 1); long-prefill lm_head cost collapses from a full-window GEMM to a single-row GEMM.Scope / prior art
Present since the initial release (
3af9d90); surfaced while servingunsloth/Qwen3.8-27B-NVFP4on an RTX 5090D (32 GB) in the #208 discussion, but #208 never touchedforward()— this is a pre-existing engine bug that also threatens Qwen3.6-27B-NVFP4 on the same hardware. Distinct from the other M-proportional prefill OOM reports: #171 (dsv4 pool-derived chunk budget), #172 (dsv4 sliding-window re-prefill), #110 (MoE expert workspace). Several sibling families (llama,qwen3,glm4_moe, … ) still returnself.lm_head.forward(output)over the full window and could adopt the same one-liner; this PR keeps to the family verified end-to-end.Verification
prompt_tokens=9500), server stays healthy; chat conversations run normally.batch.sizerows (slice is a no-op), CUDA graphs capture with the same shapes as before.tests/models/test_qwen3_5_moe_config.py+test_qwen3_5_moe_weight.py: 29 passed.