Skip to content

[Bugfix][AMD] A GPU-CPU KV transfer fault in OffloadingConnector takes the engine down instead of degrading the cache. - #52838

Open
okorzh-amd wants to merge 5 commits into
vllm-project:mainfrom
okorzh-amd:okorzh/kv-offload-dma-failure-channel
Open

okorzh-amd wants to merge 5 commits into
vllm-project:mainfrom
okorzh-amd:okorzh/kv-offload-dma-failure-channel

Conversation

@okorzh-amd

@okorzh-amd okorzh-amd commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Purpose

swap_blocks_batch raises synchronously inside transfer_asyncSTD_TORCH_CHECK(result == hipSuccess, ...) at csrc/libtorch_stable/cache_kernels.cu:168 on the ROCm path, and the equivalent check at :151 on the CUDA path. A device error latched by earlier work on the transfer stream surfaces the same way at end_event.query().

Every call site is unguarded, so that exception reaches execute_model and kills the worker:

  • V1 (kv_connector_model_runner_mixin.py) calls start_load_kv at :95, one line above the try: at :96.
  • V2 (v1/worker/gpu/kv_connector.py) has no try at all in pre_forward (:61) or post_forward (:77).
  • gpu_model_runner.handle_preemptions, which submits stores and waits on them, is also unguarded.

Meanwhile the failure channel that exists is dead code. offloading/worker.py:359 carries the comment "we currently do not support job failures" above assert transfer_result.success, and the only producer of that result hardcodes success=True (v1/kv_offload/cpu/gpu_worker.py:691). OffloadingConnector also does not override get_block_ids_with_load_errors, so base.py's empty-set default applies and the scheduler's _handle_invalid_blocks recovery is unreachable for this connector — even though the plumbing is already wired at v1/worker/gpu/kv_connector.py:89.

This is the wrong failure mode for this connector in particular. offloading_connector.py:56 returns requires_kv_delivery = False with the comment "a dropped save is just a future cache miss, so opt out of the producer-role default". It declares best-effort delivery and then aborts the engine when a transfer fails.

What this changes

Faults are reported, not raised:

  • transfer_async catches the fault, queues the job as a submit failure, and still returns True. Jobs must always complete: the scheduler's per-job pending count waits for world_size acks, so a job that silently disappears strands the request forever. The stream and events are deliberately not returned to their pools — a latched device error would otherwise leak into an unrelated transfer. The pinned descriptor buffers are plain host memory and are still recycled.
  • get_finished drains submit failures and guards end_event.query() / elapsed_time, emitting TransferResult(success=False).
  • wait() no longer propagates a synchronize failure; the next poll reports it, keeping failure handling in one place.
  • A failed store is dropped with a warning. The chunk is simply not cached — exactly what requires_kv_delivery = False already promises.
  • A failed load is escalated. Its destination GPU blocks hold undefined data while the scheduler has already advanced num_computed_tokens past them, so resuming the request would read garbage KV. The blocks are reported through a newly implemented get_block_ids_with_load_errors() and the scheduler recomputes them. The request is still reported via get_finished(), as the base-class contract requires.

Scope of the catch (second commit). The handlers catch RuntimeError, not Exception. torch.AcceleratorError and torch.cuda.CudaError both derive from RuntimeError, as does the STD_TORCH_CHECK in swap_blocks_batch, so every genuine device fault is still caught — while an AttributeError, TypeError or ValueError keeps propagating instead of being silently downgraded to a degraded cache.

Test Plan

  1. run existing kv_offload in CI

Test Result

8×MI355X (gfx950), ROCm 7.2, torch 2.12.0, with the compiled extensions on sys.path:

origin/main (12f64b39d2)   783 passed, 3 skipped, 0 failed  
this branch                790 passed, 3 skipped, 0 failed  

Essential Elements of an Effective PR Description Checklist
  • The purpose of the PR, such as "Fix some issue (link existing issues this PR will resolve)".
  • The test plan, such as providing test command.
  • The test results, such as pasting the results comparison before and after, or e2e results
  • (Optional) The necessary documentation update, such as updating supported_models.md and examples for a new model.

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

@mergify mergify Bot added rocm Related to AMD ROCm bug Something isn't working kv-connector labels Aug 18, 2026
@github-project-automation github-project-automation Bot moved this to Todo in AMD Aug 18, 2026
Comment thread vllm/distributed/kv_transfer/kv_connector/v1/offloading/worker.py Outdated
@AndreasKaratzas

Copy link
Copy Markdown
Member

/amd-ci run nightly

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite AMD CI #12216 for commit d6a8c30cbc1e.

@orozery

orozery commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Thanks @okorzh-amd !
So far we avoided using get_block_ids_with_load_errors since I believe this API is in the process of being changed/deprecated.

Can you elaborate in what cases will the h2d transfers may fail?

@Etelis Etelis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1 to Or's questions +

Comment thread vllm/v1/kv_offload/cpu/gpu_worker.py Outdated
Comment on lines +689 to +690
self._buffer_pool.append((batch_src, batch_dst, batch_sizes))
self._submit_failures.append(job_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we drain any copies already queued before reporting this job as finished? hipMemcpyBatchAsync can fail partway through a batch, so the scheduler could reuse the KV blocks while earlier copies are still running.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@Etelis @orozery Fixed in 60f8664 please review it one more time

okorzh-amd and others added 3 commits September 8, 2026 12:11
…e engine

A GPU<->CPU transfer fault in OffloadingConnector takes the engine down.
`swap_blocks_batch` raises synchronously inside `transfer_async` via
STD_TORCH_CHECK on a non-success return, and a device error latched by an
earlier transfer surfaces the same way at `end_event.query()`. Every call
site is unguarded: the V1 mixin starts the load one line above its `try`,
and the V2 path (`gpu/kv_connector.py` pre_forward/post_forward) has no
`try` at all. `OffloadingConnectorWorker.get_finished` then asserts
`transfer_result.success`, which is dead code because the only producer
hardcodes `success=True`.

This is the wrong failure mode for this connector specifically:
`requires_kv_delivery` is False with the comment "a dropped save is just a
future cache miss", so it declares best-effort delivery and then aborts the
engine when a transfer fails.

Report faults instead of raising:

- `transfer_async` catches the fault, queues the job as a submit failure
  and still returns True, so the job completes. Jobs must always complete
  or the scheduler's per-job pending count never reaches zero and the
  request is never resumed. The stream and events are deliberately dropped
  rather than pooled, so a latched device error cannot leak into an
  unrelated transfer.
- `get_finished` drains submit failures and guards `end_event.query()` /
  `elapsed_time`, emitting `TransferResult(success=False)`.
- `wait()` no longer propagates a synchronize failure; the next poll
  reports it, keeping failure handling in one place.
- A failed store is dropped with a warning: the chunk is simply not cached.
- A failed load is not droppable. Its destination GPU blocks hold undefined
  data while the scheduler has already advanced num_computed_tokens past
  them, so the blocks are reported through the newly implemented
  `get_block_ids_with_load_errors()` and the scheduler recomputes them. The
  request is still reported via `get_finished()`, per the base contract.

Block-level recovery assumes a single KV cache group
(`_update_requests_with_invalid_blocks` has a standing TODO for the hybrid
memory allocator), so on a hybrid model a failed load raises a clear error
naming that limitation rather than silently serving undefined KV. Store
faults, which dominate offload traffic, are handled on every model.

Test plan
---------
`pytest tests/v1/kv_connector/unit/offloading_connector/ tests/v1/kv_offload/`
before: 76 failed, 655 passed, 23 skipped, 32 errors
after:  76 failed, 661 passed, 23 skipped, 32 errors
The +6 are the new tests; the pre-existing failures and errors are
environmental (no GPU visible to the test container) and identical in both
arms. Mutation check: with the source change reverted and the tests kept,
6 of the 7 new/affected tests fail, so they are not vacuous.

pre-commit (ruff-check, ruff-format, mypy-3.10, mypy-3.12, all other hooks)
passes on the changed files.

Not a duplicate: vllm-project#50984 does this for the Mooncake connector, not this one.
vllm-project#42461 would replace the per-block error API with a per-request one, but its
RFC (vllm-project#35780) was auto-closed as stale on 2026-08-18 and the per-request API
is not on main; if it ever lands, this connector migrates alongside nixl,
mooncake, flexkv and lmcache.

AI-assistance disclosure: developed with Claude (Anthropic).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: okorzh-amd <okorzh-amd@users.noreply.github.com>
…rors

Narrow the three new handlers from `except Exception` to `except RuntimeError`.
torch.AcceleratorError and torch.cuda.CudaError both derive from RuntimeError,
as does the STD_TORCH_CHECK in swap_blocks_batch, so every genuine device fault
is still caught; an AttributeError, TypeError or ValueError now propagates
instead of being silently downgraded to a degraded cache.

Found by running the suite against an environment where
torch.ops._C_cache_ops.swap_blocks_batch was unavailable. The broad catch turned
that AttributeError into a "failed transfer", so the copy silently never
happened and test_canonical_layout.py reported
"reader tp=4 rank=0 bytes diverge from the tp=2 writers' ground truth" -- a
missing op presenting as data corruption. With the narrower catch the
AttributeError surfaces directly.

Adds test_get_finished_propagates_non_device_errors to pin the boundary.

Test:
  pytest v1/kv_offload v1/kv_connector/unit/offloading_connector
  origin/main   783 passed, 3 skipped
  this branch   790 passed, 3 skipped
Run on 8xMI355X (gfx950, ROCm 7.2) with the repo's compiled extensions on
sys.path; the +7 are this PR's new tests.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: okorzh-amd <okorzh-amd@users.noreply.github.com>
Review catch on this PR: a failed store was reported to the scheduler as an
ordinary completion. mark_completed() carried no failure information, so
_process_worker_metadata called manager.complete_store() with the default
success=True, which sets ref_cnt = 0 and marks the blocks evictable
(cpu/manager.py:247-255) -- publishing destination blocks that were never
written. A later lookup on those keys would load whatever the recycled blocks
last held, which may be another request's KV. That is a worse failure than the
engine death this PR set out to fix.

Propagate the verdict instead:

- OffloadingWorkerMetadata gains failed_jobs, mark_completed() takes
  success=True, and aggregate() unions the sets across workers.
- The worker reports its failed transfers with success=False.
- The scheduler records the failure on TransferJobStatus and passes
  success=not failed to complete_store(), taking the existing
  cpu/manager.py:256-262 path that removes and frees the blocks.

The flag is sticky on the job rather than read from the current batch:
pending_count is decremented across steps, so the step that carries the
failure is often not the step that reaches zero. A per-batch lookup would
silently miss exactly the multi-worker case this guards.

complete_load() is deliberately left alone. It has no success parameter and
needs none -- a failed load does not corrupt the CPU source blocks, and the
call must still run to drop their ref count. The GPU-side damage is reported
separately via get_block_ids_with_load_errors().

Test:
  pytest v1/kv_offload v1/kv_connector/unit/offloading_connector
  790 -> 794 passed, 3 skipped, 0 failed  (8xMI355X, gfx950, ROCm 7.2)

New: test_failed_store_is_not_published_as_cache reports the failure in an
earlier step than the one completing the job, so it fails if the verdict is
read per-batch; test_successful_store_is_published keeps the guard from
passing vacuously. Mutation check: forcing success=True fails the first and
leaves the second passing.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: okorzh-amd <okorzh-amd@users.noreply.github.com>
@okorzh-amd
okorzh-amd force-pushed the okorzh/kv-offload-dma-failure-channel branch from d6a8c30 to ff68153 Compare September 8, 2026 17:12
A batched copy can fail partway through, so the submit-failure path reported
a job whose copies were still running.

`swap_blocks_batch` submits the descriptor list in chunks and checks each
chunk separately (ROCm caps a call at 8192 descriptors, so it always
chunks), and `hipMemcpyBatchAsync` is itself a loop of async copies that
records `failIdx` and breaks on the first failure. Copies submitted before
the fault are already on the stream. `transfer_async` caught the exception,
queued the job on `_submit_failures` and returned, and `get_finished()`
reported `success=False` on the next poll with nothing synchronized. Three
consequences, all on the same lines:

- The scheduler is free to reuse the destination GPU blocks while an
  abandoned load is still writing them, and the CPU block a failed store
  targeted is freed back to the allocator while the D2H write may still land
  in it.
- `_transfer_events[job_id]` was only set on the success path, so `wait()`
  silently no-opped for exactly these jobs.
- The job never entered `_transfers`, so the next submission's
  `stream.wait_event(self._transfers[-1].end_event)` chained past the
  orphaned stream.

The pinned descriptor buffers were also returned to the pool at the same
point, while the driver may still be DMA-reading them for the copies it did
enqueue.

Record `end_event` on the failing stream instead, and queue the transfer
with `failed=True`. `get_finished()` reports `success=False` only once
`end_event.query()` is true, which restores the ordering guarantees for free
and removes `_submit_failures` entirely -- that queue also reported failures
ahead of still-running earlier jobs, breaking in-order completion. A failed
transfer's stream, events and descriptor buffers are dropped rather than
pooled, in both the submit and poll failure paths. If `end_event.record()`
itself raises, the in-flight copies cannot be bounded and the error
propagates.

Test plan
---------
8xMI355X (gfx950), ROCm, nightly-e962733e08 image with the compiled
extensions overlaid on the branch:
`pytest tests/v1/kv_offload/ tests/v1/kv_connector/unit/offloading_connector/`
before: 925 passed, 3 skipped
after:  927 passed, 3 skipped
(+3 new tests, -1 replaced.) Mutation check: with the source change reverted
and the tests kept, all 3 new tests fail plus the tightened poll-failure
assertion, so they are not vacuous.

pre-commit (ruff-check, ruff-format, mypy-3.12, all other hooks) passes on
the changed files.

Addresses @Etelis's review comment on gpu_worker.py.

AI-assistance disclosure: developed with Claude (Anthropic).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Oxana Korzh <okorzh@amd.com>
Per AGENTS.md: minimize comments, prefer self-documenting code, assume the
reader is familiar with vLLM. The rationale that mattered is in the commit
messages and the PR description; the docstrings are unchanged.

No functional change. `pytest tests/v1/kv_offload/
tests/v1/kv_connector/unit/offloading_connector/` gives 927 passed, 3 skipped,
identical to the parent commit. ruff-check and ruff-format clean.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Oxana Korzh <okorzh@amd.com>
@okorzh-amd

Copy link
Copy Markdown
Contributor Author

Thanks @okorzh-amd ! So far we avoided using get_block_ids_with_load_errors since I believe this API is in the process of being changed/deprecated.

Can you elaborate in what cases will the h2d transfers may fail?

@orozery On the API I did check before using it. RFC #35780, the proposal to remove per-block error
handling, was closed not_planned by the stale bot on 2026-08-18

#50984 is open right now doing for Mooncake P/D exactly what this PR does. Its consumer side is being actively fixed rather than retired — #54733 is open making the scheduler's _update_requests_with_invalid_blocks hybrid-aware. There is also no
successor on main to target: get_request_ids_with_load_errors does not exist yet.

For when h2d can fail, I don't have a clean repro here. #49276 is an open load-direction report, but its shapes are a segfault and a hang, which this cannot catch. I cite it as evidence the surface is real, not as something this PR fixes.

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

Labels

bug Something isn't working kv-connector rocm Related to AMD ROCm

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

4 participants