Skip to content

[Bug] SimpleCPUOffload: residual output garbling under high concurrency — load path lacks cross-stream sync with compute (WAR), symmetric gap left by #46278 #47282

Description

@Saddss

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...

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions