[Qwen4-Exp] File-backed PLE table backend for unified-memory devices (GB10 / DGX Spark) - #37068
Conversation
c3bc41c to
3379b3d
Compare
|
Notes for reviewers / CI:
|
3379b3d to
6e4a334
Compare
|
Cross-referencing for reviewers: #36567 (@jzinno, opened three days before this one, same Both PRs edit |
…vices (GB10)
--ple-offload-embedding keeps the PLE n-gram table (47.7 GiB in fp8 for
Qwen3.8-Flash-Next) in CPU pinned memory. On a discrete GPU that frees VRAM;
on unified-memory parts such as the GB10 (DGX Spark) pinned host memory comes
out of the same pool as the weights, so the 126.0 GiB checkpoint still does not
fit in 121.63 GiB.
Add --ple-offload-backend {pinned,file} (default pinned, unchanged) and
--ple-offload-dir. The file backend maps a sparse file (deterministic name,
reused across restarts) and hands its pageable pointer to the existing Triton
gather kernel, which works on devices that report
cudaDevAttrPageableMemoryAccessUsesHostPageTables (checked at load time;
SGLANG_QWEN4_PLE_FILE_SKIP_DEVICE_CHECK=1 to bypass). MADV_RANDOM keeps the
kernel readahead from pulling ~560x the bytes a gather touches, and
prefill-sized gathers hint the page cache with posix_fadvise(WILLNEED) so page
faults are served concurrently (SGLANG_QWEN4_PLE_FILE_PREFETCH=0 to disable).
The gather kernel, prefetch stream and CUDA graphs are untouched: they keep
receiving a host pointer.
A row fault maps in a whole page-cache folio, so with large folios (Linux 6.x)
the mapping's resident set climbs towards the full table -- measured ~45 KB per
generated token on a GB10 -- while a token only reads a few KB of it. On a
unified-memory part that is not a slow leak: the free-memory readings that size
the KV pool come from the same pool. MADV_RANDOM does not prevent it (it bounds
readahead I/O, not the mapping in of folios already in cache) and
posix_fadvise(DONTNEED) does not release them. MADV_DONTNEED over the mapping
does: the page-table entries go, the pages stay in the page cache, and hot rows
come back at minor-fault cost. PleFileRssTrimmer reads the Rss of the table's
VMAs from /proc/self/smaps and, once over SGLANG_QWEN4_PLE_FILE_RSS_BUDGET_GB
(default 8 GiB, 0 disables), drops them in 1 GiB slices -- one madvise over the
whole table holds mmap_lock for ~3.5 s, which would stall every fault in the
process including the gather kernel's. It runs on its own daemon thread:
decode replays a CUDA graph and executes no Python, so a hook in the gather
would never fire in the phase that grows the mapping. Dropping entries under a
running gather is the state this backend already handles, since the file starts
out unfaulted and every cold row is faulted in from inside the kernel.
The allocator, prefetcher and trimmer live in a Triton-free module so they are
unit tested on CPU; a device test (skipped where the attribute is absent)
checks the production gather kernel reading from the file-backed table.
6e4a334 to
0977d22
Compare
| flat_ids = input_ids.reshape(-1).long() | ||
| if flat_ids.numel(): | ||
| if self._file_prefetcher is not None: | ||
| self._file_prefetcher.enqueue(flat_ids) |
There was a problem hiding this comment.
These are global IDs, but the file contains only this rank's shard. Please filter to the local range and subtract the shard start before prefetching.
3a09f08
into
sgl-project:qwen4-main-squashed
… file-backed on NVMe Two single-node DGX Spark cells (NVFP4 (RDXA), low latency with MTP at 8 concurrent requests, high throughput without MTP at 24) plus a DGX-Spark-only PLE Offload chip "On (NVMe file)" that appends --ple-offload-embedding --ple-offload-backend file (#37068, merged into qwen4-main-squashed). Off stays forced for the 2-node cells. Benchmark rows with GSM8K 98.0% / 96.5% and ISL 1024 / OSL 256 speed, and a single-Spark section in the notes including the table-rewrite boot caveat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wording only, no flag or number changes. Drops the em-dash clusters and decorative bold in the two notes accordions and the Docker tab, and fixes two claims that had gone stale or were too strong: - "None of the DGX Spark or RTX PRO 6000 recipes run on the qwen38flashnext image" was not true of the RadixArk cells (the 2x Spark ones were verified on it, the RTX ones first passed on it). The Docker tab and the cell warnings now say what is true: that image predates the loaders the NVIDIA export (#38121) and the file-backed table (#37068) need, so the Spark and RTX rows are generated for dev-qwen38-next-local. - The PLE Offload chip reason for DGX Spark still said "Off is the verified setting until NVMe-backed PLE lands"; it landed, and the single-Spark cells use On (NVMe file). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V5B2WK8ciABmJ8pMBtGcgN
Motivation
--ple-offload-embeddingkeeps the Qwen4-Exp PLE n-gram table (47.7 GiB in fp8 for Qwen3.8-Flash-Next) in CPU pinned memory and lets the Triton gather kernel read rows from the host pointer. On a discrete GPU that frees VRAM. On unified-memory parts — GB10 / DGX Spark today — pinned host memory comes out of the same pool as the model weights, so it frees nothing: theRadixArk/Qwen3.8-Flash-Next-NVFP4checkpoint is 126.0 GiB of weights on a 121.63 GiB box and does not boot.The GB10 reports
cudaDevAttrPageableMemoryAccessUsesHostPageTables = 1: the GPU resolves pageable host addresses through the host page tables, so a kernel can dereference a pointer into a memory-mapped file. Backing the table with a sparse file on NVMe instead of pinned RAM makes the table's residency a page-cache matter rather than a hard reservation, and the model fits with room for a 262k context. This has been serving on one DGX Spark since 2026-08-26 as a monkeypatch (recipe); this PR is the proper backend.Stacked on
qwen4-main-squashedbecause the Qwen4-Exp code is not onmainyet.Modifications
--ple-offload-backend {pinned,file}(defaultpinned, behaviour unchanged) and--ple-offload-dir(default$SGLANG_CACHE_DIR/ple/<model path>, one directory per checkpoint). Thefilebackend is validated against--ple-offload-embedding, and at load time against the device attribute above (SGLANG_QWEN4_PLE_FILE_SKIP_DEVICE_CHECK=1bypasses the check for devices that cannot be queried).sglang/srt/models/qwen4_exp_ple_table.py:allocate_ple_host_table(shape, dtype, backend, table_dir): pinned as before, or a sparse file with a deterministic name (ple_table_<dims>_<dtype>_<bytes>B.bin, reused across restarts; a file of the wrong size is recreated), mapped withtorch.from_file(shared=True)and advisedMADV_RANDOM(the table is pure random access, 16 rows of 160 B per token; without it the kernel readahead pulled ~560x the bytes a gather uses).PleFilePrefetcher: for gathers of ≥ 2048 rows (prefill-sized; decode gathers are 16–64 rows) it computes the distinct 4 KiB pages of the requested rows and issuesposix_fadvise(WILLNEED)on a background thread before the kernel launches, so the page faults are served concurrently instead of one at a time. Skipped during CUDA-graph capture.SGLANG_QWEN4_PLE_FILE_PREFETCH=0disables it.PleFileRssTrimmer: a row fault maps in a whole page-cache folio, so with large folios (Linux 6.x) the mapping's resident set climbs towards the full 47.7 GiB while a token only reads a few KB of it (measured ~45 KB of Rss growth per generated token on a GB10). On a unified-memory part that is not a slow leak, because the free-memory readings that size the KV pool come from the same pool.MADV_RANDOMdoes not prevent it — it bounds readahead I/O, not the mapping in of folios already in cache — andposix_fadvise(DONTNEED)does not release them;MADV_DONTNEEDover the mapping does, dropping the page-table entries while the pages stay in the page cache, so hot rows come back at minor-fault cost. The trimmer reads the Rss of the table's VMAs from/proc/self/smapsand, once overSGLANG_QWEN4_PLE_FILE_RSS_BUDGET_GB(default 8 GiB,0disables), drops them in 1 GiB slices: onemadviseover the whole table holdsmmap_lockfor ~3.5 s, which would stall every fault in the process including the gather kernel's. It runs on its own daemon thread rather than as a hook in the gather, because decode replays a CUDA graph and executes no Python — a hook would never fire in the phase that grows the mapping. Dropping entries under a running gather is the state this backend already handles: the file starts out entirely unfaulted and every cold row is faulted in from inside the kernel through the same host page tables. Absent where the resident set cannot be read and for thepinnedbackend.Qwen4ExpPinnedHostEmbeddingtakesbackend/table_dir, allocates through the module, andgather()calls the prefetcher when present. The gather kernel, the prefetch stream and the CUDA graphs are untouched: they keep receiving a host pointer. The weight loader is unchanged too —copy_into the mapped tensor writes through to the file.Qwen4ExpConfigcarriesple_offload_backend/ple_offload_dir;load_model_utilspropagates them likeple_offload_embedding.rows<start>-<end>), so tensor-parallel shards of the same shape never share a file; the default directory is per checkpoint ($SGLANG_CACHE_DIR/ple/<model path>). Every boot rewrites the whole table through the unchanged weight loader, so a stale file cannot leak old rows.test/registered/unit/models/test_qwen4_exp_ple_table.py(mirrors the module path) — allocator (sparse and exactly sized, writes persist and the file is reused, wrong-sized file replaced, per-rank tag, per-checkpoint default dir, unknown backend rejected, pinned path unchanged), prefetcher (page set covers row start and end, dedup, size floor, advised offsets), the/proc/self/smapsparser (sums every VMA of the table, counts a partially overlapping one, ignores unrelated mappings, reports an unreadablesmapsas unknown), the trimmer against a live mapping (measures its own mapping only, drops its pages once over budget without losing what was written through them, no-op under budget, thread starts and stops), and a device test that runs the production gather kernel over a file-backed table and compares with a torch gather (skipped unless the device reports the attribute).--ple-offload-embedding(previously undocumented),--ple-offload-backendand--ple-offload-dirinserver_arguments.mdx, and the fiveSGLANG_QWEN4_PLE_FILE_*variables inenvironment_variables.mdx.test/registered/kernels/ops/embeddings/test_qwen4_ple_offload.pygains two file-backend cases (bf16 parity withpinnedat dims 7 and 160 including a prefill-sized gather through the page-cache hint, and an fp8 table). Drive-by: that file's source stub lacked the per-tensorweight_scalebuffer the class has required since 73a2552, so it failed at construction on this branch; the stub now carries it.Not included: a chunked
initialize_dummy_weights(the fp16 staging copy of a 47.7 GiB fp8 table OOMs--load-format dummy; separate PR), and a cookbook cell (the Qwen3.8-Flash-Next cookbook page is onmain, not on this branch).One deliberate
tensor.cpu(): the prefetcher syncs once per prefill-sized gather (≥ 2048 rows) to compute the page set on the host; decode-sized gathers and CUDA-graph capture never reach it.Accuracy Tests
test/registered/unit/models/test_qwen4_exp_ple_table.py: 20 passed, andtest/registered/kernels/ops/embeddings/test_qwen4_ple_offload.py: 13 passed (10 existing + 3 file-backend) — 33 with nothing skipped, on a DGX Spark (GB10, sm_121, kernel 6.17, ext4 on NVMe, CUDA 13.0, torch 2.13.0). Pre-commit: all hooks pass.Speed Tests and Profiling
Measured on one DGX Spark (GB10, unified LPDDR5X ~273 GB/s, NVMe), fp8 table, rows of 160 B,
ple_layer_ids=[2]:MADV_RANDOM(2.5 KB useful; the rest is 4 KiB page granularity).lm_head, 262k context served.Nothing changes for
pinned(default): the only new code on that path is the backend dispatch in the allocator.Checklist
--help; the Qwen3.8-Flash-Next cookbook page lives onmain, not on this branch — happy to add a GB10 cell there once the model lands).CI States
Latest PR Test (Base): ❌ Run #33365912972
Latest PR Test (Extra): ❌ Run #33365912862
Latest PR Test (AMD ROCm 7.2): ❌ Run #33365912958