fix(qwen4): CPU-offload correctness, breakable graphs, mmap PLE table - #37793
Open
HaberstrohSystems wants to merge 1 commit into
Open
HaberstrohSystems wants to merge 1 commit into
HaberstrohSystems wants to merge 1 commit into
Conversation
## Motivation
Qwen3.8-Flash-Next (Qwen4-Exp: 176B total / 6B active, 48 layers =
36 GatedDeltaNet + 12 Qwen sparse attention, 512 experts top-10, a 51 GB
PLE n-gram embedding table) only fits a 24 GB card with `--cpu-offload-gb`.
On the base commit that path does not work: weight loading fails on a
device mismatch in `GemmaRMSNorm`, `functional_call` rejects the tied
`A_log` tensors, the GDN convolution reads a stale `.view()` of a
parameter whose `.data` the offloader has replaced (GDN output exactly
zero, NaN logits), checkpoints saved through transformers carry the layer
type `qwen_sparse_attention`, the fused `in_proj_ba` lands at the wrong bit
width because `packed_modules_mapping` never reaches the quant config, and
the PLE table is allocated in full (51.2 GB as fp8, 51,200,245,760 B;
102.4 GB if materialised as bf16, derived as 2 x 51,200,245,760 B) before
any offload can act. These are the nine findings of `WRITEUP.md` section 3
("Nine findings in SGLang"), with finding 8 (the silent NaN) analysed in
section 4 and finding 9 (26 GB of PCIe traffic per token) in section 5.
The reference machine is one RTX PRO 4000 Blackwell (24 GB, sm_120) with
32 GB host RAM, serving a 2.572 bpw AutoRound checkpoint (model card,
"Quantization").
Measurements and logs: https://github.com/HaberstrohSystems/qwen3.8-flash-next-24gb-sglang
(`docs/CAMPAIGN.md`, an append-only log cited below by its dated entries;
`docs/ELASTIC_MEMORY.md`; `docs/WRITEUP.md`; `docs/DECODE_PERF_PLAN.md`;
`tools/bench_speed.py`) and the model card
https://huggingface.co/HaberstrohSystems/Qwen3.8-Flash-Next-int2-mixed-AutoRound-24GB-SGLang
(cited by section name).
## Modifications
Correctness under `--cpu-offload-gb` (general, not Qwen4-specific):
* `layers/layernorm.py`: `GemmaRMSNorm` re-derives `gemma_weight` on the
device of the parameter (`_weight_loader`) and of the input
(`_gemma_weight_for`); the non-persistent buffer was left on the GPU
while the parameter went to the CPU.
* `utils/offloader.py`: `functional_call(..., tie_weights=False)` in both
hook variants; `_CpuParamOffloader` keeps a hard reference to the pinned
host storage (`cpu_data`). With `SGLANG_MOE_EXPERT_STREAM=1` only expert
tensors (`w13_qweight`, `w2_qweight`, `w13_scales`, `w2_scales`,
`w13_qzeros`, `w2_qzeros`) are offloaded, and the forward hook is not
installed when every offloaded parameter of a module is a streamed expert
parameter (the hook would reparametrize the module with tensors that are
already on the device). The streamer itself is part 2 of this series.
* `layers/radix_linear_attention.py`, `models/qwen3_5.py`: the GDN module
passes its `nn.Conv1d` instead of a `.view()` of its weight;
`RadixLinearAttention.conv_weights` is a property that builds the 2D view
from the current parameter on every access. Tensors and tuples pass
through unchanged, so KDA / ShortConv / Lightning are not affected.
Qwen4-Exp specific:
* `models/qwen4_exp.py`: accept the layer type `qwen_sparse_attention`
(transformers renames `full_attention` on load; loading otherwise fails
with `KeyError: 'qwen_sparse_attention'`). The matching
`layers_block_type` alias in `configs/qwen4_exp.py` is already on
`qwen4-main-squashed` since sgl-project#36772 and is not repeated here;
`Qwen4ExpForConditionalGeneration.__init__` calls
`quant_config.update_packed_modules_mapping(...)` like `deepseek_v2.py`
does for Quark.
* `server_args.py`: `Qwen4ExpForConditionalGeneration` in
`LANGUAGE_MODEL_ONLY_ARCHITECTURES` (the class inherits the
`language_model_only` implementation from `Qwen3VLForConditionalGeneration`;
only the entry was missing). Saves the vision tower (0.84 GiB) and the
multimodal reservation in the KV budget for text-only serving.
* `models/qwen4_exp.py`: `Qwen4ExpMmapEmbedding` serves the PLE table from
a memory-mapped file (`SGLANG_QWEN4_PLE_MMAP=<dir>` with `ple.f8_e4m3.bin`
and `ple.json`; `ple.json` carries `file`, `rows`, `dim`, `dtype` and
`weight_scale`, the file is `rows x dim x itemsize` bytes and is checked).
The `VocabParallelEmbedding` is created on the `meta` device in that mode
so nothing is allocated first. Decode fetches its rows with parallel
`os.pread` (16 threads, GIL released; the numpy fancy-index into the
memmap held the GIL for the whole cold row fetch, measured at 2.56-3.09
ms per token; `DECODE_PERF_PLAN.md`, "2. Parallelise the PLE row
fetch"), bulk gathers go through the memmap. `MADV_RANDOM` on the
mapping and `POSIX_FADV_RANDOM` on the fd stop the page-cache
read-around per 160-byte row (`CAMPAIGN.md` 2026-09-02 14:12);
`POSIX_FADV_DONTNEED` after bulk gathers of >= 512 ids drops the pages
again (random text otherwise churns ~1M page-cache pages per long
prompt; `ELASTIC_MEMORY.md`, "What the reference host taught").
* `models/qwen4_exp.py`: `Qwen4ExpMmapEmbedding.forward` is wrapped with
`eager_on_graph(True)`, so decode runs under
`--cuda-graph-backend-decode breakable` with the PLE lookup as the one
eager break.
* `model_executor/runner_backend/breakable_cuda_graph_backend.py`: the four
output-structure helpers understand the `LogitsProcessorOutput`
dataclass; capture died with "Unsupported BCG output type" for any graph
body that returns it.
* `layers/attention/qsa/qsa_indexer.py`: `_qsa_ensure_rope` hoists the
`positions.max().item()` device sync (once per QSA layer, 12 per token;
`DECODE_PERF_PLAN.md`, "5. Two micro-fixes") and pre-sizes the rotary
cos/sin cache from `context_length`.
* `mem_cache/memory_pool.py`: fp8 write path in both `set_kv_buffer`
variants skips the no-op `div_` by a unit scale and saturates to +-448
before the e4m3 cast (the fp32/bf16 -> e4m3fn cast returns NaN beyond
the range). Consumed by the fp8 read path of part 4.
No new server flags; the two switches are `SGLANG_MOE_EXPERT_STREAM` and
`SGLANG_QWEN4_PLE_MMAP`. No tests are added in this part.
## Accuracy Tests
Exactness oracle: teacher-forced logprobs on a fixed 10k-token prompt,
mean and max |dlogprob| per token against the previous configuration;
noise floor mean 0.002, threshold for host-side changes mean <= 0.01,
max <= 0.5 (model card, "Serving fidelity"; `CAMPAIGN.md` 2026-09-02
11:25).
* Host fixes (offloader hook skip, indexer hoist, PLE pread; together with
the streamer items of part 2): oracle max 0.078 / mean 0.0019, equivalent
(`CAMPAIGN.md` 2026-09-02 09:30).
* Breakable decode graphs + `LogitsProcessorOutput` support: oracle max
0.079 / mean 0.0022, equivalent (`CAMPAIGN.md` 2026-09-02 11:48).
* PLE page-cache fixes involve no numerics; the 248k-token needle test on
the same server retrieved 5/5 codes after the page-drop fix
(`CAMPAIGN.md` 2026-09-02 20:00).
* fp8 KV write path: a 1-token prompt still crashes in the QSA indexer
prefill in `fp8_e4m3` mode; >= 101-token prompts work (`CAMPAIGN.md`
2026-09-02 15:26). Open issue, documented here.
## Speed Tests and Profiling
Streaming bench (`tools/bench_speed.py`, 200 streamed tokens, single
request, ~10k context; model card, "Performance"):
* host fixes: decode 15.4 -> 19.4 tok/s (`CAMPAIGN.md` 2026-09-02 09:30;
includes the streamer items of part 2);
* breakable decode graphs: decode 21.8 -> 40.0 tok/s at prefill 2249
tok/s, graph capture 3.97 s / 0.12 GB (`CAMPAIGN.md` 2026-09-02 11:33
and 11:48);
* fp8 write path: `fp8_e4m3` mode at parity with bf16, decode 55.9-57.2 /
prefill 2303-2339 tok/s (`CAMPAIGN.md` 2026-09-02 15:12);
* PLE read-around: before `MADV_RANDOM` / `POSIX_FADV_RANDOM` long
prompts (~55k tokens) drove host memory pressure past systemd-oomd
(`CAMPAIGN.md` 2026-09-02 14:12); the 248k random-text needle run
survived only after `POSIX_FADV_DONTNEED` (2026-09-02 19:37 and 20:00).
Part 1/5 of the Qwen3.8-Flash-Next 24 GB serving series. The series is
based on 73a2552, the first commit of the branch qwen4-main-squashed
(PR sgl-project#36497); there is no part 0.
HaberstrohSystems
requested review from
BBuf,
Edwardf0t1,
Fridge003,
HaiShaw,
Qiaolin-Yu,
Ying1123,
alphabetc1,
ch-wan,
hanming-lu,
hebiao064,
hnyls2002,
huangtingwei9988,
hzh0425,
ispobock,
merrymercy,
xiezhq-hermann and
yizhang2077
as code owners
September 3, 2026 11:38
5 tasks
This was referenced Sep 3, 2026
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.
Part 1/5 of the Qwen3.8-Flash-Next 24 GB serving series (RFC issue: #37792). Patch:
upstream/series-q4head/0001-fix-qwen4-CPU-offload-correctness-breakable-graphs-m.patchin the companion repository (9 files, +423 / -33).
Motivation
Qwen3.8-Flash-Next (Qwen4-Exp: 176B total / 6B active, 48 layers = 36 GatedDeltaNet + 12 Qwen
sparse attention, 512 experts top-10, a 51 GB PLE n-gram embedding table) only fits a 24 GB card
with
--cpu-offload-gb. On the base commit that path does not work: weight loading fails on adevice mismatch in
GemmaRMSNorm,functional_callrejects the tiedA_logtensors, the GDNconvolution reads a stale
.view()of a parameter whose.datathe offloader has replaced (GDNoutput exactly zero, NaN logits), the fused
in_proj_balands at the wrong bit width becausepacked_modules_mappingnever reaches the quant config, and the PLE table is allocated in full(51.2 GB as fp8, 51,200,245,760 B; 102.4 GB if materialised as bf16, derived as
2 x 51,200,245,760 B) before any offload can act. These are the nine findings of
docs/WRITEUP.mdsection 3 in the companion repository, with finding 8 (the silent NaN) analysedin section 4.
Reference machine: one RTX PRO 4000 Blackwell (24 GB, sm_120) with 32 GB host RAM, serving a
2.572 bpw AutoRound checkpoint (model card, "Quantization"). Sources below: dated entries of
docs/CAMPAIGN.mdin https://github.com/HaberstrohSystems/qwen3.8-flash-next-24gb-sglang("CAMPAIGN "),
docs/DECODE_PERF_PLAN.mdthere, and the model cardhttps://huggingface.co/HaberstrohSystems/Qwen3.8-Flash-Next-int2-mixed-AutoRound-24GB-SGLang
("card,
Modifications
Correctness under
--cpu-offload-gb(general, not Qwen4-specific):layers/layernorm.py:GemmaRMSNormre-derivesgemma_weighton the device of the parameter(
_weight_loader) and of the input (_gemma_weight_for); the non-persistent buffer was left onthe GPU while the parameter went to the CPU.
utils/offloader.py:functional_call(..., tie_weights=False)in both hook variants;_CpuParamOffloaderkeeps a hard reference to the pinned host storage. WithSGLANG_MOE_EXPERT_STREAM=1only expert tensors (w13_qweight,w2_qweight,w13_scales,w2_scales,w13_qzeros,w2_qzeros) are offloaded, and the forward hook is not installedwhen every offloaded parameter of a module is a streamed expert parameter. The streamer itself
is part 2.
layers/radix_linear_attention.py,models/qwen3_5.py: the GDN module passes itsnn.Conv1dinstead of a
.view()of its weight;RadixLinearAttention.conv_weightsis a property thatbuilds the 2D view from the current parameter on every access. Tensors and tuples pass through
unchanged, so KDA / ShortConv / Lightning are not affected.
model_executor/runner_backend/breakable_cuda_graph_backend.py: the four output-structurehelpers understand the
LogitsProcessorOutputdataclass; capture died with "Unsupported BCGoutput type" for any graph body that returns it.
mem_cache/memory_pool.py: the fp8 write path in bothset_kv_buffervariants skips the no-opdiv_by a unit scale and saturates to +-448 before the e4m3 cast (the cast returns NaN beyondthe range). Consumed by the fp8 read path of part 4.
Qwen4-Exp specific:
models/qwen4_exp.py: accept the layer typeqwen_sparse_attention(transformers renamesfull_attentionon load). The matchinglayers_block_typealias inconfigs/qwen4_exp.pyisalready on
qwen4-main-squashedsince fix(qwen4): accept qwen_sparse_attention layer type alias #36772 and is not repeated.Qwen4ExpForConditionalGeneration.__init__callsquant_config.update_packed_modules_mappinglike
deepseek_v2.pydoes for Quark.server_args.py:Qwen4ExpForConditionalGenerationinLANGUAGE_MODEL_ONLY_ARCHITECTURES(the class already inherits the implementation; only the entry was missing). Saves the vision
tower (0.84 GiB) and the multimodal reservation in the KV budget for text-only serving.
models/qwen4_exp.py:Qwen4ExpMmapEmbeddingserves the PLE table from a memory-mapped file(
SGLANG_QWEN4_PLE_MMAP=<dir>withple.f8_e4m3.binandple.json, which carriesfile,rows,dim,dtype,weight_scale; the file sizerows x dim x itemsizeis checked). Theembedding is created on the
metadevice in that mode. Decode fetches rows with parallelos.pread(16 threads, GIL released; the numpy fancy-index into the memmap held the GIL forthe whole cold row fetch, measured at 2.56-3.09 ms per token,
docs/DECODE_PERF_PLAN.md,"2. Parallelise the PLE row fetch"), bulk gathers go through the memmap.
MADV_RANDOM/POSIX_FADV_RANDOMstop the page-cache read-around per 160-byte row (CAMPAIGN 2026-09-0214:12);
POSIX_FADV_DONTNEEDafter bulk gathers of >= 512 ids drops the pages again.forwardis wrapped witheager_on_graph(True), so decode runs under--cuda-graph-backend-decode breakablewith the PLE lookup as the one eager break.layers/attention/qsa/qsa_indexer.py:_qsa_ensure_ropehoists thepositions.max().item()device sync (once per QSA layer, 12 per token;
docs/DECODE_PERF_PLAN.md, "5. Twomicro-fixes") and pre-sizes the rotary cos/sin cache from
context_length. On this branch itreplaces upstream's
_ensure_cos_sin_cache_length(int(positions.max().item()))call in
apply_rope.No new server flags; the two switches are
SGLANG_MOE_EXPERT_STREAMandSGLANG_QWEN4_PLE_MMAP(see the RFC, open question 1). No tests are added in this part.
Accuracy Tests
Exactness oracle: teacher-forced logprobs on a fixed 10k-token prompt, mean and max |dlogprob|
per token against the previous configuration; noise floor mean 0.002, threshold for host-side
changes mean <= 0.01, max <= 0.5 (card, "Serving fidelity"; CAMPAIGN 2026-09-02 11:25).
part 2): oracle max 0.078 / mean 0.0019, equivalent (CAMPAIGN 2026-09-02 09:30).
LogitsProcessorOutputsupport: max 0.079 / mean 0.0022, equivalent(CAMPAIGN 2026-09-02 11:48).
the page-drop fix (CAMPAIGN 2026-09-02 20:00).
fp8_e4m3mode a 1-token prompt still crashes in the QSA indexerprefill; >= 101-token prompts work (CAMPAIGN 2026-09-02 15:26).
Reproduction (server level; the companion repository's tools, run against a server started with
scripts/serve.shthere, which sets the flags and environment of the published configuration):Speed Tests and Profiling
Streaming bench (
tools/bench_speed.py, 200 streamed tokens, single request, ~10k context;card, "Performance"), measured on
73a255206fwith the flat patch:items of part 2);
3.97 s / 0.12 GB (CAMPAIGN 2026-09-02 11:33 and 11:48);
fp8_e4m3mode at parity with bf16, decode 55.9-57.2 / prefill 2,303-2,339tok/s (CAMPAIGN 2026-09-02 15:12);
MADV_RANDOM/POSIX_FADV_RANDOM, ~55k-token prompts drove hostmemory pressure past systemd-oomd (CAMPAIGN 2026-09-02 14:12); the 248k random-text needle run
survived only after
POSIX_FADV_DONTNEED(CAMPAIGN 2026-09-02 19:37 and 20:00).Not re-measured on
78c5024e9d(see the RFC, open question 3).Reproduction:
Checklist
F401,F821,UP037, codespell andpy_compileclean on the commit.)conv_weightsproperty under a simulatedparam.dataswap, a round-trip test of the BCG helpers with aLogitsProcessorOutput, a test ofQwen4ExpMmapEmbeddingon a small generated table.)ple.jsonfile format and the two environment variables.)Suggested reviewers
python/sglang/srt/models,python/sglang/srt/layers/attention: @Fridge003, @ishandhanani,@Qiaolin-Yu (NV and model-specific optimizations); @JustinTong0323 as author of Introduce Qwen 3.8 Flash Next #36497.
python/sglang/srt/model_executor(breakable CUDA graph backend): @merrymercy, @hnyls2002,@cctry (Scheduler).
python/sglang/srt/mem_cache(fp8 write path): @ispobock, @xiezhq-hermann (KV Cache).CI States
Latest PR Test (Base): ❌ Run #33750754162
Latest PR Test (Extra): ❌ Run #33750754117
Latest PR Test (AMD ROCm 10): ❌ Run #33750754243