Conversation
… instead of baking it into the graph qwen4_exp_ple_pinned_gather took the PLE table address as a Python int, so Inductor wrote the literal address into the compiled graph and the AOT artifact. A cold boot works; the first warm boot with the compile cache on runs the artifact in a new process, where the pinned host buffer lives at a different address, and the stage holding the PLE table dies in profile_run with an illegal memory access (Qwen3.8-Flash-Next, TP2 PP2). The op now takes the table's layer name and a host/device selector, resolves the module through the forward context and reads the pointer at run time, the way qwen4_exp_compute_ple_ngram_ids already resolves its layer. The table registers itself under its prefix. Only the string and the bool reach the graph. Tests construct the table under a VllmConfig context and expose it to the op through the forward context, mirroring the existing ngram-id test. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Peuqui <peuqui@github.com>
…GPU, disk The Qwen4Exp PLE table (47.7 GiB FP8 for Flash-Next) could live in device memory, in pinned host memory beside it, or completely in the PLE offload worker. This adds a cascade over four tiers per tensor-parallel rank, filled fastest first: the compute card up to its measured budget, then a pinned host share, then a store card served by the offload worker, then the mapped checkpoint on disk. Rows beyond the resident tiers arrive from the worker and are merged by rank-local id before the tensor-parallel reduce, dequantized by the same kernel as the resident gathers, so greedy outputs stay identical. Configuration (all off by default, the existing paths are unchanged): - VLLM_QWEN4EXP_PLE_STORE_DEVICE: visible index of the store card; starts the cascade. - VLLM_QWEN4EXP_PLE_STORE_GIB: store budget in total, required with the device. - VLLM_QWEN4EXP_PLE_DISK=1: allow the remainder to be read from the checkpoint; also starts the cascade without a store card. - VLLM_QWEN4EXP_PLE_HOST_GIB keeps its meaning as a fixed per-rank share and is now checked once in create_engine_config against available host memory. Worker: one output buffer serves every rank, because the ranks' store and disk segments are disjoint in the global id space. The store segments are copied from the mapped shards to the store card at registration; the disk tier reuses the whole-table disk lane's mmap reader (_gather_mapped_rows). Pipeline parallelism: only ranks with a PleOffloadLayer build the connector, and the worker drops an inherited VLLM_PP_LAYER_PARTITION, which made get_pp_indices() refuse its single-stage world. Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Peuqui <peuqui@github.com>
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.
Purpose
Depends on #622 (code base). Running it with
nvidia/Qwen3.8-Flash-Next-NVFP4also needs #640, and with MTP under pipeline parallelism #639; see
Dependencies below.
Qwen4Exp's PLE table is large: 47.7 GiB of FP8 rows for Qwen3.8-Flash-Next,
23.8 GiB per tensor-parallel rank at TP2. Today a pre-Ampere deployment has two
places for it. The pinned-host split keeps as much as fits in device memory and
pins the rest in host memory, every rank its own share. The PLE offload worker
holds the whole table in host memory, or maps it from disk with
VLLM_PLE_DISK_OFFLOAD. A host with little RAM loses either way: on our rig(30 GiB RAM) the split pins 12 GiB and leaves about 2.5 GiB available after
startup, a table held in the worker needs more memory than the host has, and
the disk lane reads every row from the checkpoint.
This PR adds an overflow cascade. Each rank fills four tiers, fastest first:
computation as the existing auto placement: utilization budget minus
allocated memory, the KV cache for
max_model_lenand a reserve),served by the PLE offload worker,
What it gives a deployment:
about 2.5 GiB to 11.8 GiB (2 GiB host share per rank) or 16.0 GiB (no host
share), with the rest of the table on a spare V100.
probes in every configuration, including the one that reads rows from disk.
prefill is unchanged.
(
VLLM_QWEN4EXP_PLE_DISK=1alone), so a box without a spare GPU can godevice, host, disk; end to end we measured it only together with a store
card. Rows are never dropped: a table that does not fit the configured tiers
fails at startup with a message that names the knobs.
only for what the device cannot hold.
Nothing changes without the new variables: the pinned-host split, the
whole-table worker, the disk lane and the hybrid lane keep their behaviour.
Configuration
VLLM_QWEN4EXP_PLE_STORE_DEVICEVLLM_QWEN4EXP_PLE_STORE_GIBVLLM_QWEN4EXP_PLE_DISK=1VLLM_QWEN4EXP_PLE_HOST_GIBExample, TP2 with a spare fifth card:
The cascade cannot be combined with
VLLM_SM70_QWEN38_HYBRID_PLEorVLLM_PLE_DISK_OFFLOAD;VllmConfigrefuses that, and a model without PLElayers, before any worker starts.
How it works
common/ple.py):plan_ple_placementreturns a four-tierPLEPlacement. With the cascade, device rows come first up to the measuredbudget, then host, store and disk. Without it the host share comes first and
the device holds the rest, as before.
nvidia/ple_layer.py):Qwen4ExpNGramEmbeddingkeeps itsresident tables under the offload contract (
offload_keeps_local_tables(),a new hook on
PleOffloadLayer). It gathers device and host rows as today,waits for the worker with
ple_offload_waitinside the CUDA graph, andmerges the worker's rows by rank-local id before the tensor-parallel
all-reduce. The worker sends raw FP8 bytes, dequantized with the same op and
scale as the resident gathers, which is why outputs stay identical.
PLERemotePlacement(vocabulary range,resident rows, store rows) in
PleOffloadRegistration.remote_placements.space, so one output buffer serves every rank and each rank takes only its
own slots. Store segments are copied from the mapped shards to the store card
once at registration, as raw bytes and without an anonymous host copy; a step
does
index_selecton that card. The disk tier reuses the disk lane's mmapreader, now factored out as
_gather_mapped_rowsand used by both.VLLM_QWEN4EXP_PLE_HOST_GIBis checked once inEngineArgs.create_engine_config, share times TP size against availablememory minus
VLLM_QWEN4EXP_PLE_HOST_RESERVE_GIB. It used to be checked ineach rank while the sibling ranks were already pinning, so a sibling's share
could count against a rank's own. The budget and reserve helpers moved to
common/ple.pyso that config and ranks use the same code. A derived hostbudget (no
VLLM_QWEN4EXP_PLE_HOST_GIB) is still capped per rank as before.PleOffloadLayerbuild a connector (later stages used to fail in_setup_layers), and the worker drops an inheritedVLLM_PP_LAYER_PARTITION,which made
get_pp_indices()refuse its single-stage world while the metamodel was built.
docs/design/qwen4exp_ple_tier_cascade.md(new): tiers, configuration,placement, per-step flow, validation and limits.
tests/utils.py:set_lazy_env.monkeypatch.setattr(envs, ...)leaves areal module attribute behind that hides
envs.__getattr__from later tests;the new tests set the variables through the environment instead.
Dependencies
branch carries [Bugfix][Qwen4Exp] Resolve the PLE table pointer inside the gather op instead of baking it into the graph #622's commit as its first commit. Please review the second
commit; the first one disappears on rebase once [Bugfix][Qwen4Exp] Resolve the PLE table pointer inside the gather op instead of baking it into the graph #622 is merged.
nvidia/Qwen3.8-Flash-Next-NVFP4. Without it thatcheckpoint cannot take the pinned-host path on pre-Ampere cards, which the
cascade builds on:
NotImplementedError: Qwen4Exp pinned-host PLE requires FP8 checkpoint storage.Checkpoints that set
ple_embedding_dtypedo not need it.is the configuration measured below. The cascade itself does not depend on it.
Test Plan
All on this branch, compiled extensions from our fork build, Python 3.12,
torch 2.10.0+cu128,
CUDA_VISIBLE_DEVICES= one free Tesla V100 plus a secondvisible card (some tests set DP=2 and query device 1),
HF_HUB_OFFLINE=1:Counter-check for the partition fix:
vllm/v1/ple_offload/worker.pywithoutthe
VLLM_PP_LAYER_PARTITIONline, new test kept.End to end, on our fork, whose PLE files equal this branch except for #640's
hunk: 2x Quadro RTX 8000 (first pipeline stage, TP2, holds the table), 2x Tesla
V100 (second stage, TP2), 1x Tesla V100 as store card, all PCIe Gen3 x4 (the
store card behind a USB4 tunnel), 30 GiB host RAM, checkpoint on an NVMe SSD in
a USB enclosure.
nvidia/Qwen3.8-Flash-Next-NVFP4, MTP k=4, TP2 x PP2, asyncscheduling,
--max-model-len 262144,--gpu-memory-utilization 0.95. Probe:three prompts plus the first one again, greedy,
ignore_eos, 260 tokens each,SHA-256 of the text against a reference taken on the pre-change path.
Available memory is
MemAvailableafter startup.Test Result
Unit tests, this branch against its parent (main 02c87ab + #622):
tests/models/qwen4_exp/test_ple.pytests/v1/worker/test_ple_offload_worker.pytests/compile/test_sm70_decode_graph.pytests/v1/worker/test_release_cleanup.pytests/v1/executor/test_executor.pytests/models/qwen4_exp/test_weight_loading.pytests/compile/passes/test_functionalization.pyThe failures are the same on the parent:
test_qsa_e4m3_loader_requires_all_24_scales(DID NOT RAISE) and the bfloat16 cases of
test_fix_functionalization, which aV100 cannot compile ("BF16 is not supported"). pre-commit: all hooks passed;
mypy-3.10: passed. Counter-check: the new partition test fails, the other 30 pass.
End to end:
HOST_GIB=6, no cascadeHOST_GIB=2, storeHOST_GIB=0, storeHOST_GIB=2, store 1 GiB, diskHOST_GIB=2), -2.3 % (HOST_GIB=0),-2.3 % (with disk); against the control boot -2.0, -1.8 and -1.7 %. The
first disk-tier probe right after a boot ran 3.9 % below the reference, a
second one 2.3 %.
shard; the rest goes to the outer tiers.
synthetic word lists, which makes the absolute numbers higher than with real
text), first request after boot excluded: 1,092 to 1,100 tok/s before, 1,096
to 1,101 tok/s with the store tier, 1,096 to 1,100 tok/s with the disk tier.
in 25.8 s.
drop_cachesbefore the boot, three chatrequests): the worker read the 1.0 GiB of store rows as 261,830 single-page
major faults and 10 to 56 MiB of disk-tier rows per request, so the rows did
come from the disk. The store load took 25.8 s, as in warm boots (25.1 to
25.8 s), and decode forward steps per second (tok/s divided by the MTP
acceptance length) were within 0.5 % of the same three requests in an
earlier session whose page cache had not been dropped.
footprint that [Bug][SM70] PLE offload worker allocates GPU memory outside --gpu-memory-utilization budget → CUDA OOM under load #530 describes for the whole-table worker. Our runs did not
run out of device memory; the measured device budget keeps its reserve free
(default 8 % of the card, at most 4 GiB), but it does not account for the
worker's memory, and this PR does not change [Bug][SM70] PLE offload worker allocates GPU memory outside --gpu-memory-utilization budget → CUDA OOM under load #530.
Not a duplicate
gh pr list --state open --searchfor "PLE offload", "PLE host","per-layer embedding", "ple_embedding", "PLE disk", "PLE_HOST_GIB",
"pinned host PLE" and "offload worker" returns only our own #622 and #640 (both
dependencies above) and unrelated work (#576, #598, #637). No open PR references
#530 or #479 in its body. Related issues: #479 (ours, Qwen4Exp under pipeline
parallelism; the two PP fixes above continue it) and #530 (see the last point
of the results).
AI assistance (Claude) was used to design, implement and measure this change
and to write this description. I reviewed every changed line and ran the tests
and the end-to-end measurements on the hardware named above.
Essential Elements of an Effective PR Description Checklist
supported_models.mdandexamplesfor a new model.🤖 Generated with Claude Code