Repository navigation
Conversation
PleOffloadLayer.forward asked offload_keeps_local_tables() on every call whether to gather the layer's own rows and merge the offload worker's rows into them. For Qwen4ExpNGramEmbedding that answer is ple_cascade_configured() or pinned_decode_active(), and both read kernel_config from the current vLLM config. The GPU worker makes that config current only around construction, device and KV-cache initialization and weight loading; profiling, torch.compile tracing, warmup, CUDA graph capture and execute_model run without it. Every forward therefore answered "no cascade" and returned the offload worker's raw buffer, in which the worker writes only the disk-tier slots. The resident slots kept zeros from the dummy runs or rows of earlier chunks. The cascade reached main in 1CatAI#806 together with 41faaa6, which moved ple_cascade_configured() from an environment variable to the kernel config, so the merge has not run at runtime on main. The guarded constructor already decides once, while the config is current, whether the layer builds its resident tables. It now records that decision and the forward uses it, as Qwen4ExpNGramEmbedding already does for _cascade and _pinned_decode. The regression test flips the keeps-local-tables answer after construction and checks that the resident slot is not taken from the worker's unwritten buffer slot. Co-authored-by: Claude Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJxVXkjSHhfq2D71DoYSK7 Signed-off-by: Peuqui <43776522+Peuqui@users.noreply.github.com>
This was referenced Oct 19, 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.
Purpose
With the PLE disk cascade, which is on by default (KernelConfig.ple_disk_cascade) and admitted for FP16 Qwen4Exp models with FP8 or packed GGUF PLE storage on local multiprocessing workers, the n-gram embedding is computed without its resident rows. PleOffloadLayer.forward asks offload_keeps_local_tables() on every call whether to gather the layer's own rows and merge the offload worker's rows into them. For Qwen4ExpNGramEmbedding that answer is ple_cascade_configured() or pinned_decode_active(), and both read kernel_config from get_current_vllm_config_or_none(). The GPU worker makes the config current only around worker construction, init_device, initialize_from_config and load_model. Profiling, torch.compile tracing, warmup, CUDA graph capture and execute_model run without it (vllm/models/qwen4_exp/nvidia/model.py:1164 notes the same for capture), so every forward, eager or compiled, answers "no" and returns the offload worker's raw output buffer. The worker writes only the disk-tier slots of that buffer (_remote_lookup); every slot whose row is resident keeps what the buffer held: zeros from the dummy and capture runs, or rows left over from earlier chunks and requests.
The cascade reached main in #806 together with 41faaa6, which moved ple_cascade_configured() from VLLM_QWEN4EXP_PLE_DISK to the kernel config, so on main the merge has not run at runtime since the cascade exists. How much is affected depends on the placement and the prompt. On our PP4 entry (131,045,319 of 320,001,536 rows in device memory) it was 10,592 of the 25,856 n-gram slots in the first 1,616-token chunk of a German prompt (41 %). On TP2xPP2 (125,439,175 of 160,000,768 rows per rank in device memory) most rows are resident. From the source only, not run: when a placement leaves no rows on disk, the worker zeroes the whole buffer, so the n-gram embedding is all zeros. Because the stale rows depend on earlier requests, the same prompt got different log-probabilities on every pass within one boot.
The guarded constructor already decides once, while the config is current, whether the layer builds its resident tables. This PR records that decision in the instance and lets the forward use it, the same way Qwen4ExpNGramEmbedding already keeps _cascade and _pinned_decode from its constructor, and the way #1004 and #857 moved policy reads to layer construction. Not changed: the packed GGUF pinned path, whose forward returns through the _pinned_decode branch before this check; the hybrid policy, which the cascade admission excludes and for which the recorded value equals the old runtime answer; and Qwen4ExpNGramEmbedding.load_weights, which repeats the guard but runs inside load_model's config.
The new test builds a layer while its keeps-local-tables answer is true, then flips the answer as the missing runtime config does. Its forward_impl takes resident ids itself and the remaining ids from a worker buffer in which only the worker's slots are written; on main the forward returns that buffer and the resident slot comes back as 0.
Not a duplicate: gh pr list --repo 1CatAI/1Cat-vLLM --state all --search with "offload_keeps_local_tables", "ple_disk_cascade_active", "resident PLE rows", "PLE cascade forward", "wait_offloaded_output", "Flash-Next nondeterministic" and "PLE zeros", and the same searches over issues, find no PR or issue that reports or fixes this. Related but different: #806 (merged, brought the cascade and 41faaa6), #856 (merged, cascade storage lookup under checkpoint layer names), #818 (merged, whole-table placeholders without cascade state), #1004, #857. The open PRs touching the same two files (#828, #1020, #1023, #1094, #1167, #1214, #1217) do not change this check, and this PR adds no merge conflict to any of them (git merge-tree against each PR head; #1020 and #1023 conflict with main already).
AI assistance was used for this change. I reviewed every line and ran the tests and measurements below.
Test Plan
Row check against the checkpoint: Qwen3.8-Flash-Next-180B-A4B NVFP4 with MTP, PP4 over two Tesla V100 and two Quadro RTX 8000, disk cascade, --enforce-eager. A forward hook saved the n-gram embedding output and the worker's n-gram ids for a 3,708-token German prompt (prefill chunks 1,616 + 1,616 + 476 tokens); each slot was compared with the row read directly from the checkpoint's FP8 table (dequantized with the table scale for the fixed path, which returns the dequantized embedding).
Teacher-forced log-probabilities: the same prompt with prompt_logprobs (3,707 scored positions), two passes in one boot, same setup.
Serving A/B on our fork (main 6f3557c plus our commits): fork commit 6bbcd40 (this change) against cf50e1a (its parent), PP4 and TP2xPP2, torch.compile with CUDA graphs (FULL_AND_PIECEWISE, capture sizes 1, 2, 4, 5, 8), MTP with 4 speculative tokens. Boots fix cold, base, fix, base, fix; the cold boot is not counted. The base arm runs the production tree, the fix arm the same entries with PYTHONPATH on the fork tree and its own compile cache; the native build is the same in both arms (this PR changes Python only). Per boot: greedy answers to three prompts, four ~29k-token prompts, a decode benchmark (prose, code, edit), eight quality prompts at temperature 0, and prompts of 184,134 and 249,992 tokens.
Path coverage
Acceleration and benchmark contract (required for performance changes)
Not a performance change. Python only; no new switches, fields or environment variables. On main this PR puts the merge path into the compiled forward and the CUDA graph captures for the first time; on our fork it ran that way in the serving A/B below.
Test Result
🤖 Generated with Claude Code
https://claude.ai/code/session_01YaG8VjXn5eN3Aq7eZrA3dp