Skip to content

[Bugfix][QSA] Reuse bounded prefill logits workspace - #56500

Open
jacklin78911-collab wants to merge 1 commit into
vllm-project:mainfrom
jacklin78911-collab:codex/qsa-prefill-workspace
Open

jacklin78911-collab wants to merge 1 commit into
vllm-project:mainfrom
jacklin78911-collab:codex/qsa-prefill-workspace

Conversation

@jacklin78911-collab

Copy link
Copy Markdown
Contributor

Purpose

Chunked QSA prefills allocate a progressively larger logits tensor as max_seq_len grows. The caching allocator can retain the previous sizes, so a per-tensor logits budget does not bound the memory retained across prefill steps. Addresses #56457.

Use the existing WorkspaceManager for a bounded logits buffer and disjoint top-k scratch. Reserve capacity in the QSA owner's profiling path, which returns before calling the indexer, and use compact tensor views for each inference chunk. This preserves the logical logits width, chunk sizes, kernel launches and top-k dispatch introduced by #54915. The existing manager supplies separate ubatch/workspace-lane storage; no new global cache or synchronization mechanism is added.

The reservation includes at least one logits row when the configured budget is smaller than a row. Reserving the configured budget during profiling moves that memory cost up front; it can increase live memory for short-only workloads when another operator has not already reserved a larger shared workspace.

Validation

Base: dc07f1638f73814b95776832b85df1cc92850416. Local environment: RTX 4060 Laptop GPU (SM89, 8 GiB), PyTorch 2.11.0+cu130, existing prebuilt native extensions. This is not the repository's current PyTorch 2.13 CI environment.

  • New regression: execute the real QSA owner profiling branch, lock the workspace, then run real prefill scoring/top-k through growing contexts with 0 MiB and 1 MiB budgets. Check storage reuse, separate scratch and reference selections after poisoning old logits. Restoring only the old allocation path makes both cases fail on the reuse assertion; the patch passes both.
  • Full local sweep initially produced 96 passed, 7 failed. Four FP8 prefill reference failures reproduce on the unmodified base with the same difference (maximum reported absolute difference 7.963e-5). Two attention cases request a 12.5 GiB input allocation on this 8 GiB GPU. One FP8 tiled pre-indexer comparison fails in the unchanged pre-indexer code.
  • The final sweep explicitly excluding those seven cases: 96 passed, 7 deselected.
  • Changed-file pre-commit checks, including Ruff and mypy: passed.
  • Small serving check: two-layer Qwen4Exp with real QSA, dummy weights, V1 eager runner, prefix caching off and 512-token prefill chunks. Prompt lengths 257, 2305 and 4097 each generated eight tokens; baseline and patch agreed on all 24 output tokens with the workspace locked. This is an integration smoke test, not a model-quality evaluation.

Commands for the full and final sweeps:

.venv/bin/python -m pytest \
  tests/models/qwen4_exp/test_qsa_reference.py \
  tests/models/qwen4_exp/test_qsa_pre_indexer.py \
  tests/models/qwen4_exp/test_config.py \
  tests/v1/worker/test_workspace.py -q

.venv/bin/python -m pytest \
  tests/models/qwen4_exp/test_qsa_reference.py \
  tests/models/qwen4_exp/test_qsa_pre_indexer.py \
  tests/models/qwen4_exp/test_config.py \
  tests/v1/worker/test_workspace.py -q \
  -k 'not (prefill_selection and dtype1) and not tp1_r2048 and not (tiled and indexer_dtype1)'

Allocation and timing probes

Allocation-only probe: real wrapper and CUDA allocations, with scoring/top-k skipped to isolate allocation behavior; 3,200 query rows, 12 layer calls per step, 16 growing contexts from 3,200 to 51,200 tokens, default 512 MiB budget and native allocator.

Measurement Base Patch
Reserved memory, first → last step 34 → 1,370 MiB 536 → 536 MiB
Additional device allocations after first step 15 0
Peak live tensor memory 171.90 MiB 528.65 MiB

The larger live reservation is intentional. These numbers establish bounded storage reuse in this probe; they are not a GB10 hang reproduction or a universal memory-saving claim.

Separate real-kernel probe: BF16, four query heads, head dimension 128, identical inputs and native top-k. Valid logits and selected score multisets were bit-exact across the two implementations. For the 3,200-token cold-prefill case, selected indices can differ at ties; repeated baseline runs also differ, consistent with the existing top-k determinism work in #55122.

Median GPU times from three alternating rounds, FlashInfer CUPTI, CUDA graphs and cold L2:

Query rows / context tokens Base Patch
128 / 4,096 112.192 µs 112.098 µs
3,200 / 3,200 342.525 µs 342.707 µs
3,200 / 32,768 2,176.069 µs 2,174.980 µs

A separate warmed eager-wrapper wall-time check, including Python/allocation overhead, was approximately unchanged: median differences -0.2%, +0.0%, +1.0% for these shapes. These are local microbenchmarks, not serving-latency or model-throughput claims.

Remaining validation and related work

  • The GB10 report's full Qwen3.8-Flash-Next workload and model-quality evaluation require suitable hardware and remain pending.
  • V2 integration could not start in this WSL environment: its UVA buffer constructor raises RuntimeError: UVA is not available, before reaching QSA.
  • Keep this as a draft pending human line-by-line review and the remaining platform validation.

Duplicate checks covered the issue discussion, open PRs referencing #56457, and QSA workspace/allocation/allocator searches. No open PR implementing this storage-reuse fix was found as of 2026-09-12. #54915 is the existing compact-width optimization being preserved; #55122 concerns top-k determinism; #56240 adds a different attention backend.

AI assistance: OpenAI Codex assisted with investigation, implementation, tests, benchmarks and a second pass through the complete diff and actual call chain. No claim is made that a human has already reviewed every changed line or run these commands.

Reserve logits and top-k scratch during QSA profiling and reuse compact
views across prefill chunks without changing the selection geometry.

Co-authored-by: OpenAI Codex <noreply@openai.com>
Signed-off-by: Liqian Lin <jacklin78911@gmail.com>

Signed-off-by: jacklin78911-collab <jacklin78911@gmail.com>
@mergify mergify Bot added qwen Related to Qwen models bug Something isn't working labels Sep 11, 2026
@jacklin78911-collab
jacklin78911-collab marked this pull request as ready for review September 11, 2026 23:31

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@jschmied

Copy link
Copy Markdown
Contributor

Ran this on GB10 / DGX Spark, sm_121, aarch64, single node — the hardware class #56457 was
reported from. Tested at head 866c7ba3cf, base dev524+g5db652225; the three production hunks
apply with a +10 line offset (a local env-gated block of ours sits above them).

Stock and patched in one run, toggling the source between arms and reading the marker
(get_qsa_prefill_workspace) back out of the file before each start, so each arm is witnessed rather
than assumed. Real weights (RadixArk/Qwen3.8-Flash-Next-NVFP4), --max-model-len 262144,
--max-num-seqs 1, bf16 KV, one start per arm.

arm prompt tokens prefill s tok/s completed
stock 169,990 75.2 2259.6 yes
#56500 169,990 71.0 2395.0 yes

The patch imports and runs on sm_121, and a 170k real-weights prefill completes with no functional
regression.
That is the part I am confident in, and it complements your smoke test rather than
repeating it — yours tops out at 4,097 tokens with dummy weights.

What this does NOT show. I could not reproduce #56457 on a single node at all: 250,010 tokens at
the default 512 MB budget completes here (105.2 s), past the 166,400 the reporter hangs at. So this
cannot confirm the fix — only that it does not break the working path. Confirming it needs the
failing configuration, 2× DGX Spark with TP=2, which I do not have.

On your stated risk — reserving the budget during profiling increasing live memory. At startup:
stock 23.65 GiB / 944,903 KV tokens, patched 22.72 GiB / 907,877. The patched arm is 0.93 GiB lower,
which is the direction you flagged. I would not read anything into it yet: the same unchanged
cell moved 2.91 GiB between restarts here, so a 0.93 GiB gap at n=1 is inside the noise. A 3-start
measurement of stock/patched/64 MB is running now and I will post the ranges when it finishes.

Two caveats a reviewer should weigh. The serving venv is not stock upstream — four deviations at this
base: two env-gated (a deterministic top-k and a MoE finalize, both off in every arm here, the
absence of their log lines asserted as a run precondition) and two active (a GDN kernel edit, and a
PLE-offload connector that does not exist upstream at this ref). The PLE one moves host/device memory
around, so treat the absolute KV figures as not portable to stock vLLM; the A/B is internally
valid because both arms carry every overlay. And single start per arm, so the 5.6 % timing difference
is not a speedup claim — the same unpatched cell spanned 70.8–75.2 s across runs.

Happy to run further cells on this box if a specific configuration would help.

Testing and this write-up were done with AI assistance; every number is from the run logs above and I reviewed them.

k3dani commented Sep 12, 2026

Copy link
Copy Markdown

Note from a single-GB10 (DGX Spark) validation attempt — negative result + a packaging caveat

We tried to validate this PR on a DGX Spark (GB10, sm_121, ARM64, 128 GB unified) and ended up not
being able to apply it to either realistic base for this hardware. Posting both halves in case it
helps.

1. The main hunk targets a file that neither shippable base has.

The chunk-loop rewrite lands in vllm/models/qwen4_exp/nvidia/ops/qsa_indexer.py, which was created
on 2026-09-02 by #54513. Neither of the two bases we can actually run on this box has it:

base model package nvidia/ops/ contents
pinned preview image (0.1.dev20073+g8e685d198) qwen3_8_flash_next qsa.py, qsa_pre_indexer.py, hc.py
vllm/vllm-openai:v0.29.0 (tag commit 98dff2a81d74) qwen4_exp qsa.py, qsa_pre_indexer.py, hc.py
main qwen4_exp qsa_indexer.py, …

Both bases still carry the older shape, where the per-chunk budget is a module constant rather than
envs.VLLM_SPARSE_INDEXER_MAX_LOGITS_MB:

_LOGITS_WORKSPACE_BYTES = 128 * 1024 * 1024   # v0.29.0 ops/qsa.py:14
...
rows_per_chunk = max(1, _LOGITS_WORKSPACE_BYTES // max(columns * 4, 1))
topk_workspace = torch.empty((_TOPK_WORKSPACE_BYTES,), dtype=torch.uint8, device=q.device)

vllm.v1.worker.workspace.current_workspace_manager does import fine on the preview image, so the
receiving infrastructure is there — but qsa_select_paged_prefill / _prefill_logits are not, so
there is nothing to re-target the diff onto without rewriting it.

2. On the 128 MB-capped shape, the symptom does not reproduce.

One fresh server, growing chunked prefills at 8K → 16K → 24K → 32K → 48K → 64K prompt tokens, each
step with a unique filler seed so every step is a genuine cold prefill (vllm:prefix_cache_hits_total
flat at 292800 across all six steps):

target prompt tokens prefill ms tok/s host used before (MB) after (MB)
8 000 8 024 4 622 1 736 107 557 107 494
16 000 16 019 8 776 1 825 107 560 107 539
24 000 24 027 12 876 1 866 107 569 107 565
32 000 32 022 17 062 1 877 107 570 107 568
48 000 48 025 25 491 1 884 107 570 107 569
64 000 64 028 35 271 1 815 107 566 107 141

Flat plateau: the before-step readings sit within 15 MB of each other across the whole sweep, and
the only larger move is a 425 MB drop after the 64K step. No step function per context length, no
preemption, no worker restart, and prefill throughput does not degrade with context length.

Caveat on the measurement: on GB10 the memory is unified, so
nvidia-smi --query-compute-apps=used_memory returns [N/A], and the torch caching allocator's
reserved value is not readable from outside the engine process. The numbers above are a host-level
(free -m) proxy: they show the growing prefills do not force new host/unified allocations, not that
the in-process reserved figure is byte-stable.

So this is a negative result for this shape, not a claim about the main code path the PR
actually fixes. It lines up with the single-node run posted above: neither of us can reproduce
#56457 on one box, so the release-base packaging point is the part that is new here.

Raw output and tooling: https://github.com/k3net/docai-evals/tree/b1f14a36c5bcc02cf2cd65705031e676fa9cb73f/experiments/2026-09-12-qwen38-flash-next-prefix-cache-cross-request-gb10
(the sweep is results/round3-{A,B}-memoria.json, the probes results/round3-{A,B}-szonda-48tok.json)

@jacklin78911-collab

Copy link
Copy Markdown
Contributor Author

Note from a single-GB10 (DGX Spark) validation attempt — negative result + a packaging caveat

We tried to validate this PR on a DGX Spark (GB10, sm_121, ARM64, 128 GB unified) and ended up not being able to apply it to either realistic base for this hardware. Posting both halves in case it helps.

1. The main hunk targets a file that neither shippable base has.

The chunk-loop rewrite lands in vllm/models/qwen4_exp/nvidia/ops/qsa_indexer.py, which was created on 2026-09-02 by #54513. Neither of the two bases we can actually run on this box has it:

base model package nvidia/ops/ contents
pinned preview image (0.1.dev20073+g8e685d198) qwen3_8_flash_next qsa.py, qsa_pre_indexer.py, hc.py
vllm/vllm-openai:v0.29.0 (tag commit 98dff2a81d74) qwen4_exp qsa.py, qsa_pre_indexer.py, hc.py
main qwen4_exp qsa_indexer.py, …
Both bases still carry the older shape, where the per-chunk budget is a module constant rather than envs.VLLM_SPARSE_INDEXER_MAX_LOGITS_MB:

_LOGITS_WORKSPACE_BYTES = 128 * 1024 * 1024   # v0.29.0 ops/qsa.py:14
...
rows_per_chunk = max(1, _LOGITS_WORKSPACE_BYTES // max(columns * 4, 1))
topk_workspace = torch.empty((_TOPK_WORKSPACE_BYTES,), dtype=torch.uint8, device=q.device)

vllm.v1.worker.workspace.current_workspace_manager does import fine on the preview image, so the receiving infrastructure is there — but qsa_select_paged_prefill / _prefill_logits are not, so there is nothing to re-target the diff onto without rewriting it.

2. On the 128 MB-capped shape, the symptom does not reproduce.

One fresh server, growing chunked prefills at 8K → 16K → 24K → 32K → 48K → 64K prompt tokens, each step with a unique filler seed so every step is a genuine cold prefill (vllm:prefix_cache_hits_total flat at 292800 across all six steps):

target prompt tokens prefill ms tok/s host used before (MB) after (MB)
8 000 8 024 4 622 1 736 107 557 107 494
16 000 16 019 8 776 1 825 107 560 107 539
24 000 24 027 12 876 1 866 107 569 107 565
32 000 32 022 17 062 1 877 107 570 107 568
48 000 48 025 25 491 1 884 107 570 107 569
64 000 64 028 35 271 1 815 107 566 107 141
Flat plateau: the before-step readings sit within 15 MB of each other across the whole sweep, and the only larger move is a 425 MB drop after the 64K step. No step function per context length, no preemption, no worker restart, and prefill throughput does not degrade with context length.

Caveat on the measurement: on GB10 the memory is unified, so nvidia-smi --query-compute-apps=used_memory returns [N/A], and the torch caching allocator's reserved value is not readable from outside the engine process. The numbers above are a host-level (free -m) proxy: they show the growing prefills do not force new host/unified allocations, not that the in-process reserved figure is byte-stable.

So this is a negative result for this shape, not a claim about the main code path the PR actually fixes. It lines up with the single-node run posted above: neither of us can reproduce #56457 on one box, so the release-base packaging point is the part that is new here.

Raw output and tooling: https://github.com/k3net/docai-evals/tree/b1f14a36c5bcc02cf2cd65705031e676fa9cb73f/experiments/2026-09-12-qwen38-flash-next-prefix-cache-cross-request-gb10 (the sweep is results/round3-{A,B}-memoria.json, the probes results/round3-{A,B}-szonda-48tok.json)

Thanks for testing both bases and documenting the version mismatch. This PR targets the current main layout; applying it to the preview image or v0.29.0 would require a separate backport and validation. Your results on the older 128 MiB path are useful context.

Additional validation at head 866c7ba3 on H20 with PyTorch 2.13.0+cu130:

  • All 103 QSA, pre-indexer, configuration, and workspace tests passed without exclusions.
  • Two-layer dummy-model checks matched all 24 output tokens between baseline and patch for each of V1 and V2, using BF16, TP=1, eager execution, and chunked prefill.

An allocation-only probe with contexts growing from 3,200 to 256,000 tokens measured:

Configuration Final allocator-reserved memory
Baseline, 512 MiB budget 14,054 MiB
Patched, 512 MiB budget 536 MiB, constant throughout
Baseline, 64 MiB budget 364 MiB

Scoring and top-k kernels were skipped in this probe. The patch retained 528.65 MiB of live allocations versus 14.65 MiB after baseline calls, so the persistent-workspace cost remains relevant.

These measurements support the allocator-reuse mechanism. They do not establish serving performance, model quality, or resolution of the original two-GB10 TP=2 failure, which remains unverified.

@jacklin78911-collab

Copy link
Copy Markdown
Contributor Author

@jschmied Thanks for testing the exact patch on sm_121 with real weights and documenting the overlays and measurement limits. This adds useful coverage beyond the dummy-model checks.

I agree that single-node completion does not confirm resolution of #56457 or establish a speedup. Additional H20 validation now passes all 103 QSA/config/workspace tests, with baseline/patched outputs matching 24/24 tokens for each of V1 and V2 in the small dummy-model checks.

The repeated stock/512 MiB, patched/512 MiB, and stock/64 MiB startup measurements you mentioned would be particularly useful for assessing the persistent workspace’s impact on KV capacity. Please share those results when available.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working qwen Related to Qwen models

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants