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 |
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.