Your current environment
🐛 Describe the bug
After #46278 (which fixed the store-side store-vs-compute race, #45704), we still received a small number of user reports of garbled output under load — far fewer than before the fix, but not zero. We reproduced it and root-caused a second, independent race, this time on the load path, that #46278 does not cover.
Symptom. Output starts coherent, then collapses into degenerate repetition + mixed-script "salad" (a decode collapse, not genuine multilingual text), e.g.:
"...single de single de single or de laer singleT single singleyP ..."
Detection used in the repro: a completion containing ≥3 distinct non-Latin Unicode scripts = one "hard garble".
Root cause: cross-stream write-after-read (WAR) on the load path.
In vllm/v1/simple_kv_offload/worker.py::get_finished, stores are ordered after compute via a _store_compute_done event (the #46278 fix). Loads are launched with no wait_event; load completion is gated only on the CPU side (a request stays WAITING_FOR_REMOTE_KVS until the load event query()s true → finished_recving).
That correctly prevents the resuming request from reading its own blocks early, but it does not enforce the reverse ordering: when a GPU block is freed and immediately reallocated as a load destination, the load DMA (on the dedicated low-priority load_stream) can begin overwriting that block while a previous request's attention is still reading it on the compute stream. Nothing makes load_stream wait for that compute.
Normal (non-offload) block reuse is safe because the old reader and the new writer are both on the compute stream and serialize; offload moves the write onto a separate stream with no event bridging the two, so the WAR window opens. The transfer streams are created at lowest priority ("KV I/O yields to compute"), which makes the load lag right into the compute window, so the rate scales with concurrency.
This is consistent with all observations: it occurs only with offload ON; it is independent of MTP; and it is independent of the sampling mechanism (greedy also corrupts, at a lower rate, because coherent outputs are shorter → fewer decode steps → less offload traffic).
Decisive evidence (synchronization bisection). With a diagnostic-only barrier around the copy (no data / no logic change) gating one direction at a time, identical workload (fp8 / 16384 / temp 0.7 / concurrency 64, 500 prompts × 3 rounds):
| mode |
garble rate |
| none (baseline) |
~4.2% |
| sync store only |
2.6% (still garbling) |
| sync load only |
0% |
| sync all |
0% |
Serializing the load path eliminates the garble; serializing the store path does not → the residual defect is on the load side, and #46278's store-side fix is correct.
Proposed fix
Give the load path the barrier symmetric to the store path: in get_finished, record one compute-done event per step (rename _store_compute_done → a shared _compute_done) and have both load and store wait_event on it, so a load cannot overwrite a reused block the compute stream is still reading.
Verification (same image, same workload):
- Garble: baseline 142/3300 = 4.3% → fix 0/3000 = 0% (Fisher exact p ≈ 5e-41).
- Added a self-validating regression test
test_load_orders_after_compute_read (the no-barrier control corrupts a live read; the barrier path is clean), symmetric to the existing store test; all pass; ruff / mypy / pre-commit clean.
- Performance: no measurable cost in a healthy regime (concurrency matched to GPU KV, loads still active on cache hits: within ±1%); only under deliberate ~10× KV oversubscription (offload thrashing) is there a single-digit cost (~2% throughput, +6–7% p99), which is expected since the event is only recorded / awaited when transfers actually happen.
I have the fix + test on a branch and am happy to open a PR if this direction looks right. One design question: the current approach makes the load wait for all compute issued so far this step (conservative, matching the store side) rather than only the compute that read the specific reused blocks — I lean toward the simple symmetric version for clarity, but I'm open to input.
Before submitting a new issue...
Your current environment
main(includes [Bugfix][KVConnector] Fix SimpleCPUOffloadConnector GPU->CPU store race #46278 / fix for [Bug]: SimpleCPUOffloadConnector corrupts restored KV under load → intermittent garbled output (missing GPU→CPU store cross-stream sync) #45704); also reproduced onnightly0.23.1rc1.dev601RedHatAI/gemma-4-31B-it-NVFP4, single GPU (32 GB)VLLM_USE_SIMPLE_KV_OFFLOAD=1, native backend, 100 GB pinned CPU pool, fp8 KV cache--max-model-len 16384 --max-num-seqs 64 --max-num-batched-tokens 8192, eager /-O3🐛 Describe the bug
After #46278 (which fixed the store-side store-vs-compute race, #45704), we still received a small number of user reports of garbled output under load — far fewer than before the fix, but not zero. We reproduced it and root-caused a second, independent race, this time on the load path, that #46278 does not cover.
Symptom. Output starts coherent, then collapses into degenerate repetition + mixed-script "salad" (a decode collapse, not genuine multilingual text), e.g.:
Detection used in the repro: a completion containing ≥3 distinct non-Latin Unicode scripts = one "hard garble".
Root cause: cross-stream write-after-read (WAR) on the load path.
In
vllm/v1/simple_kv_offload/worker.py::get_finished, stores are ordered after compute via a_store_compute_doneevent (the #46278 fix). Loads are launched with nowait_event; load completion is gated only on the CPU side (a request staysWAITING_FOR_REMOTE_KVSuntil the load eventquery()s true →finished_recving).That correctly prevents the resuming request from reading its own blocks early, but it does not enforce the reverse ordering: when a GPU block is freed and immediately reallocated as a load destination, the load DMA (on the dedicated low-priority
load_stream) can begin overwriting that block while a previous request's attention is still reading it on the compute stream. Nothing makesload_streamwait for that compute.Normal (non-offload) block reuse is safe because the old reader and the new writer are both on the compute stream and serialize; offload moves the write onto a separate stream with no event bridging the two, so the WAR window opens. The transfer streams are created at lowest priority ("KV I/O yields to compute"), which makes the load lag right into the compute window, so the rate scales with concurrency.
This is consistent with all observations: it occurs only with offload ON; it is independent of MTP; and it is independent of the sampling mechanism (greedy also corrupts, at a lower rate, because coherent outputs are shorter → fewer decode steps → less offload traffic).
Decisive evidence (synchronization bisection). With a diagnostic-only barrier around the copy (no data / no logic change) gating one direction at a time, identical workload (fp8 / 16384 / temp 0.7 / concurrency 64, 500 prompts × 3 rounds):
Serializing the load path eliminates the garble; serializing the store path does not → the residual defect is on the load side, and #46278's store-side fix is correct.
Proposed fix
Give the load path the barrier symmetric to the store path: in
get_finished, record one compute-done event per step (rename_store_compute_done→ a shared_compute_done) and have both load and storewait_eventon it, so a load cannot overwrite a reused block the compute stream is still reading.Verification (same image, same workload):
test_load_orders_after_compute_read(the no-barrier control corrupts a live read; the barrier path is clean), symmetric to the existing store test; all pass; ruff / mypy / pre-commit clean.I have the fix + test on a branch and am happy to open a PR if this direction looks right. One design question: the current approach makes the load wait for all compute issued so far this step (conservative, matching the store side) rather than only the compute that read the specific reused blocks — I lean toward the simple symmetric version for clarity, but I'm open to input.
Before submitting a new issue...