Skip to content

[KV Offload] Load small CPU-tier hits synchronously within the step - #61961

Merged
orozery merged 4 commits into
vllm-project:mainfrom
kfirtoledo:offload-sync-small-loads
Oct 26, 2026
Merged

orozery merged 4 commits into
vllm-project:mainfrom
kfirtoledo:offload-sync-small-loads

Conversation

@kfirtoledo

@kfirtoledo kfirtoledo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Overview

On wide-EP MoE prefill (such as GLM-5.3), a step takes 0.3 to 0.5 s. Almost every request has a small CPU-tier hit, and an async load parks it for about 2 steps although the copy takes ~1 ms.

This adds an opt-in sync_load_budget_bytes option (kv_connector_extra_config, default 0, off): small hits load in the same step, up to that many KV bytes per GPU per step. The change stays in the offloading connector, the OffloadingWorker interface, and the CPU offloading worker.

Changes

  • scheduler.py: a hit that fits the step's remaining budget returns load_async=False, so the request is scheduled in this step; the load targets its blocks by position and is marked sync. _current_batch_sync_loads and the byte total reset every step.
  • worker.py: submits sync loads with submit_load(..., expedited=True), waits for them before the forward pass, and does not report them in finished_recving.
  • kv_offload/base.py, cpu/gpu_worker.py: an expedited flag on OffloadingWorker.submit_load; the CPU worker runs expedited loads on their own stream.
  • common.py: a TransferJob.sync flag carries that decision from scheduler to worker.

Claims

  • Small hits load in the same step, up to sync_load_budget_bytes of KV per GPU per step; the rest stay async. The budget exists because the whole batch waits for the copy. A hit that would leave nothing to compute stays async.
  • Sync-load blocks are hashed at allocation, so the load range is found by position. Other requests can match them in the same step, which is safe since the forward pass waits for the copy.
  • Sync loads use their own stream, so they never wait behind async loads.
  • With the option at 0, behavior is unchanged.

Validation

Qwen3-30B-A3B on H100, OffloadingConnector + TieringOffloadingSpec. 12k-token prefix in GPU cache, 2k-token tail only in the CPU tier. Median extra TTFT vs a GPU-hit control:

Setup Without fix With fix (limit 4096)
1 node: DP=2 + EP (this version, 10 reps) +219 ms +1 ms
2 nodes: TP=8, DP=2, EP over 16 GPUs (12 reps) +344 ms +8 ms

These numbers were measured with the earlier per-request token limit (sync_load_max_tokens=4096) and a shared load stream; the per-step byte budget and separate stream are not yet run end to end.

GLM-5.3, one prefill group (4x8 H100, wide EP), request latency with a CPU-tier hit: ~1.9 s without the fix or above the limit, ~0.85 s under it (hits of 2k to 15k tokens).

Correctness: greedy continuation with the tail served from the CPU tier is correct 7/7 (Qwen3 and GLM-5.3).

pytest tests/v1/kv_connector/unit/offloading_connector/   # 644 passed
pytest tests/v1/kv_offload/cpu/test_gpu_worker.py         # 41 passed, 1 skipped

Details

Not a duplicate: no open PR adds sync offload loads (closest: #60574, #58198).


Pull Request Checklist
  • I used vLLM's /pr-checklist skill. (Mandatory for agents, optional for humans).

  • AI assistance was used during the creation of this PR.

  • Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.

  • Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.

  • Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.

  • Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.

@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 commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Documentation preview: https://vllm--61961.org.readthedocs.build/en/61961/

@mergify mergify Bot added documentation Improvements or additions to documentation kv-connector labels Oct 16, 2026
@robertgshaw2-redhat robertgshaw2-redhat added the ready ONLY add when PR is ready to merge/full CI is needed label Oct 18, 2026
@robertgshaw2-redhat

Copy link
Copy Markdown
Collaborator

/ci run

@github-actions

Copy link
Copy Markdown

✅ @kfirtoledo, CI is now available for this PR.

  • /ci run starts upstream CI; /amd-ci run starts AMD CI only.
  • Run commands test your branch with every commit currently on its upstream target branch. If it is behind, the bot merges the target branch into it first (pull before pushing again). Append --allow-stale to a run command to test an outdated branch as is, at your own risk.
  • /ci retry retries failed jobs in the CI build for the current PR head. If the current head has no CI build, it starts a new CI build for the current head containing only jobs that failed in the latest earlier CI build for this PR.
  • /amd-ci retry retries failed jobs in AMD CI for the current PR head. Use /amd-ci run when the current head has no AMD CI build.
  • /ci cancel cancels scheduled or running CI builds for this PR branch; /amd-ci cancel does the same for AMD CI only.

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #95718 for commit df0002fadd46.

@Etelis Etelis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I agree this is a real issue in the specific scenario we’re seeing.
However, for a general-purpose inference engine, I’m concerned that sync_load_max_tokens adds more tuning complexity than value.

Users would need to choose an appropriate threshold for each deployment, and that value may become suboptimal as the workload changes.
For example, many individually small loads could all qualify for synchronous loading while collectively causing a substantial batch-wide delay.

I’d suggest either keeping the existing async behavior and accepting the latency cost, or exposing a simplesync_loads flag, defaulting to async. That would make KV loading synchronous before the forward pass and give users a clear, predictable tradeoff.

Another direction might worth exploring is reducing the async completion round trip from two scheduling steps to one where possible.

I think @orozery needs to decide.

@kfirtoledo

Copy link
Copy Markdown
Contributor Author

Thanks @Etelis. The main idea is to keep long hits async and only load small ones sync. So some cut-off is needed, but it doesn't really need tuning: hits up to ~4096 tokens copy in about 1 ms, which is negligible against a 300+ ms step. Agreed that a per-step budget would bound the total better than a per-request limit, but it still needs tuning.

@kfirtoledo
kfirtoledo force-pushed the offload-sync-small-loads branch from d0144db to 473da74 Compare October 20, 2026 06:45
success = self.worker.submit_load(job_id, entry.src_spec, entry.dst_spec)
assert success
if sync_job_ids:
self.worker.wait(sync_job_ids)

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.

We still submit the sync jobs to the stream containing async jobs.
We should give sync jobs their own handler / stream.

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.

Done. Sync loads now go through submit_sync_load, which CPUOffloadingWorker sends to a separate handler with its own stream. Other workers fall back to submit_load.

# Keep at least one token to compute, or the request can't be scheduled.
req_status.sync_load = (
num_hit_tokens is not None
and 0 < num_hit_tokens <= self.config.sync_load_max_tokens

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.

We should have a per-step budget for sync loading instead of per-request.

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.

Done. One budget per step, reset at the end of the step. Hits that don't fit stay async.

retention_interval=retention_interval,
dcp_world_size=vllm_config.parallel_config.decode_context_parallel_size,
# Hits up to this many tokens load within the step (0 disables).
sync_load_max_tokens=int(spec.extra_config.get("sync_load_max_tokens", 0)),

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.

I think a limit in bytes is easier to set, since it does not depend on the model KV size per token.

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.

Done. It's now sync_load_budget_bytes, per GPU. A hit's size is estimated as hit tokens × KV bytes per token. That's exact for full attention and an overestimate for sliding-window and Mamba groups, so the budget is never exceeded.

is_sync_load=req_status.sync_load,
)

if self._chunks_being_loaded is not None:

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.

sync load keys should not be added to _chunks_being_loaded

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.

Done. Sync-load keys are no longer added.

# time.monotonic() of this request's first deferred offload lookup;
# None once consumed (observed) or while no lookup is pending.
deferred_lookup_start_time: float | None = None
sync_load: bool = False

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.

Let's introduce _current_batch_sync_loads instead.

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.

Done. The per-request flag is replaced by _current_batch_sync_loads, a set reset each step.

@mergify

mergify Bot commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @kfirtoledo.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Oct 25, 2026
@kfirtoledo
kfirtoledo force-pushed the offload-sync-small-loads branch from f034057 to 66ec8bd Compare October 25, 2026 12:07
@mergify mergify Bot removed the needs-rebase label Oct 25, 2026
@mergify

mergify Bot commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @kfirtoledo.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Oct 25, 2026
Comment thread vllm/v1/kv_offload/base.py Outdated
) -> bool:
"""Async offloaded medium -> GPU."""

def submit_sync_load(

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.

How about we introduce expedited: bool = False in submit_load instead? (I used expedited instead of sync to not confuse the implementor to not mistakenly block in the submit function itself).

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.

Done. submit_sync_load is replaced by submit_load(..., expedited=False); the CPU worker sends expedited loads to their own handler and stream. The connector passes expedited=True only for sync loads, so existing workers are unaffected while the option is off.

@kfirtoledo
kfirtoledo force-pushed the offload-sync-small-loads branch from 66ec8bd to 4ee6980 Compare October 25, 2026 17:27
@mergify mergify Bot removed the needs-rebase label Oct 25, 2026
@kfirtoledo
kfirtoledo force-pushed the offload-sync-small-loads branch from 4ee6980 to 8200e0d Compare October 25, 2026 17:35
@kfirtoledo

Copy link
Copy Markdown
Contributor Author

/ci run

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #97165 for commit fec16a805847.

ℹ️ This PR is 3 commits behind upstream main. Merged upstream main into this branch as fec16a805847 before starting CI. Pull the branch before pushing more changes.

An async load parks the request until the load is reported finished, about
two engine steps later. For a small hit the copy takes ~1 ms, so the wait
dominates. With sync_load_max_tokens set in kv_connector_extra_config, hits
up to that size load within the step and the request runs immediately.

Signed-off-by: Kfir Toledo <kfir.toledo@ibm.com>
Address review: replace sync_load_max_tokens with a per-step
sync_load_budget_bytes, track sync loads in _current_batch_sync_loads,
keep their keys out of _chunks_being_loaded, and submit them through a
separate CPU handler so they don't queue behind async loads.

Signed-off-by: Kfir Toledo <kfir.toledo@ibm.com>
Address review: replace OffloadingWorker.submit_sync_load with an
expedited flag on submit_load, so the API does not suggest blocking.

Signed-off-by: Kfir Toledo <kfir.toledo@ibm.com>
@kfirtoledo
kfirtoledo force-pushed the offload-sync-small-loads branch from fec16a8 to e26409f Compare October 25, 2026 21:14
@kfirtoledo

Copy link
Copy Markdown
Contributor Author

/ci retry

@github-actions

Copy link
Copy Markdown

✅ The previous CI build is still running: https://buildkite.com/vllm/ci/builds/97165

@kfirtoledo

Copy link
Copy Markdown
Contributor Author

/ci retry

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #97167 for commit e26409f764e7, running 1 failed step(s) from Buildkite CI #97165.

@orozery

orozery commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

/ci run

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #97227 for commit 663a53c8f3e8.

@orozery
orozery merged commit fe5fe8b into vllm-project:main Oct 26, 2026
96 checks passed
okorzh-amd added a commit to okorzh-amd/vllm that referenced this pull request Oct 26, 2026
A sync load (vllm-project#61961) is waited on before its request's forward pass, and
the request is already running, so failed_recving cannot reschedule it:
the scheduler only acts on it for requests waiting for remote KV. Raise
instead of letting the request return output computed on unloaded KV.
Async loads and stores still survive their faults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Oxana Korzh <okorzh@amd.com>
Micador pushed a commit to Micador/vllm that referenced this pull request Oct 27, 2026
…llm-project#61961)

Signed-off-by: Kfir Toledo <kfir.toledo@ibm.com>
Co-authored-by: Or Ozeri <oro@il.ibm.com>
Signed-off-by: Christopher Mackenzie <christopher.mackenzie.software@gmail.com>
hyunnnchoi added a commit to hyunnnchoi/vllm that referenced this pull request Oct 27, 2026
vllm-project#61961 built the handlers through make_handler and added an expedited
load handler. Calibration still runs once after the store handler, and
both load handlers now take its result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZAzEvT3BdxhAbW9FwkeTR
Signed-off-by: hyunnnchoi <hyunmok.choi@gmail.com>
Micador pushed a commit to Micador/vllm that referenced this pull request Oct 27, 2026
…llm-project#61961)

Signed-off-by: Kfir Toledo <kfir.toledo@ibm.com>
Co-authored-by: Or Ozeri <oro@il.ibm.com>
Signed-off-by: Christopher Mackenzie <christopher.mackenzie.software@gmail.com>
okorzh-amd added a commit to okorzh-amd/vllm that referenced this pull request Oct 28, 2026
A sync load (vllm-project#61961) is waited on before its request's forward pass, and
the request is already running, so failed_recving cannot reschedule it:
the scheduler only acts on it for requests waiting for remote KV. Raise
instead of letting the request return output computed on unloaded KV.
Async loads and stores still survive their faults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Oxana Korzh <okorzh@amd.com>
okorzh-amd added a commit to okorzh-amd/vllm that referenced this pull request Oct 28, 2026
A sync load (vllm-project#61961) is waited on before its request's forward pass, and
the request is already running, so failed_recving cannot reschedule it:
the scheduler only acts on it for requests waiting for remote KV. Raise
instead of letting the request return output computed on unloaded KV.
Async loads and stores still survive their faults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Oxana Korzh <okorzh@amd.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation kv-connector ready ONLY add when PR is ready to merge/full CI is needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants