Skip to content

Eval bug: Cross-request KV-cache contamination on integrated GPUs: missing sync in process_ubatch races zero-copy graph inputs against in-flight compute #28056

Description

@allenzz-dev

Summary

On integrated GPUs (tested: AMD gfx1151 / Strix Halo APU, ROCm/HIP backend), llama-server can leak one request's conversation content into a later, unrelated request's responses. The KV cache gets corrupted during chunked prefill: K/V rows are scattered to wrong cells, and stale K/V from a previous request remains resident under cells the new request's attention mask marks visible. We hit this in production as verbatim fragments of a private chat appearing in another client's document-QA outputs.

Verified at v0.3.0 (build 10622, commit c1d0e7a), HIP build, -ngl 99 -b 2048 -ub 2048 -fa on -np 1.

Root cause (three links)

  1. Zero-copy graph inputs on iGPUs: ggml_backend_cuda_device_supports_buft returns true for pinned-host bufts when integrated is set, so GPU kernels read graph-input tensors (k_idxs, v_idxs, kq_mask, inp_tokens, inp_pos, …) directly from host memory. On discrete GPUs these are split inputs with a protective synchronize+copy inside the sched — which is why this never reproduces on dGPUs.

  2. Synchronization gated on pipeline_parallel: in llama_context::process_ubatch, the sync before set_inputs on the graph-reuse path only runs if (cparams.pipeline_parallel) — always false on a single GPU. The graph-rebuild path (ggml_backend_sched_reset + ggml_backend_sched_alloc_graph, which remap the compute buffers) has no synchronization at all.

  3. Fire-and-forget prefill: graph_compute ends with ggml_backend_sched_graph_compute_async, and prefill chunks produce no outputs, so llama_decode returns without any sync and the server immediately submits the next chunk. Measured on this hardware: six 2048-token chunks submitted within 60–70 ms while each takes 0.8–1.8 s to execute (~6-deep async pipeline).

Result: ubatch N+1's set_inputs overwrites the very host buffers ubatch N's in-flight kernels are still reading. ggml_set_rows then scatters K/V rows to wrong cells; the intended cells keep stale data from a previous request, and the new request's mask marks them visible → cross-request contamination.

Reproducer / evidence

Model: Qwen3.6-35B-A3B (hybrid GDN) Q6_K_XL, llama-server --ctx-size 262144 -b 2048 -ub 2048 -fa on -np 1 --cache-reuse 1, one client at a time, deterministic sampling (temperature 0, top_k 1) for the probe requests.

Scenario: 2-turn chat about a distinctive topic X → idle → send a ~21k-token synthetic document + 4 questions. The answers quote topic X verbatim (4/4 runs, up to 5 mentions of X's key term per answer).

Instrumentation findings (per-layer FNV-1a hash of individual K-cache rows, logged at every seq_rm):

  • A probe cell inside the document's chunk-2 target range holds a non-boot value planted during the chat phase — which never legitimately writes anywhere near that cell — and that value never changes while the document's 10 chunks are prefilled, despite host-side k_idxs provably containing the correct contiguous indices for those chunks. So the same race has two faces: earlier writes scatter into wrong cells, and later writes fail to land on their intended cells.
  • A control cell beyond the reach of both requests keeps its boot-time value throughout.
  • Host-zeroing the attention K/V buffers between the two requests eliminates the contamination; zeroing the recurrent (GDN) state does not — the carrier is stale attention K/V, not the recurrent state.
  • GGML_CUDA_DISABLE_FUSION=1 reduces but does not eliminate it (slower enqueue → shallower pipeline — a timing effect, not a cause).

Fix (verified)

Make the synchronize in process_ubatch unconditional, hoisted above the can_reuse branch so it also covers the rebuild path:

    // the previous graph_compute_async may still be running on the GPU. synchronize before
    // mutating any graph state: set_inputs overwrites input tensors the in-flight compute
    // may still be reading, and sched_reset/alloc_graph remap the compute buffers under it.
    // not limited to pipeline parallelism: on integrated GPUs graph inputs are read
    // zero-copy from pinned host memory.
    ggml_backend_sched_synchronize(sched.get());

    if (!graph_reuse_disable && res->can_reuse(gparams)) {
        n_reused++;
    } else {
        ...

After the fix: 0/12 contaminated outputs across 3 runs of the reproducer (was 4/4 heavy per run), and the K-cell hash trajectory becomes healthy (chat era untouched, document data lands in its cells and stays there through all 4 questions).

Performance cost is zero on this hardware — the async pipeline only queued deeper, the GPU was already saturated:

metric before after
21k-token prefill 1230.8 t/s 1231.8 t/s
2k-token prefill 957–970 t/s 957–966 t/s
decode 48.5 t/s 48.4 t/s

Notes

Happy to submit this as a PR.

Activity

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

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