Skip to content

[Feature][Multimodal] Support KV-backed continuation through Generate inputs - #60526

Open
HanFa wants to merge 1 commit into
vllm-project:mainfrom
HanFa:codex/mm-kv-handoff-2
Open

HanFa wants to merge 1 commit into
vllm-project:mainfrom
HanFa:codex/mm-kv-handoff-2

Conversation

@HanFa

@HanFa HanFa commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Purpose

Let a decode instance continue a multimodal request from existing decoder KV, without the media and without encoder capacity. Closes #60521.

This revision is the engine contract only, cut down to the scope discussed in the review (@Isotr0py's mm-kv-minimal and the reduced scope offered on Oct 26):

  • Generate API: features.requires_kv on MultiModalFeatures. All items must already be covered by decoder KV; they carry hashes and placeholder positions only, and the validator rejects serialized payloads in kwargs_data / mm_metadata and empty hashes. The field is omitted from the wire format when false. engine_input_from_features turns a Generate request into the engine input (mm_requires_kv=True), and serve_tokens uses it.
  • Engine input / feature spec: MultiModalInput.mm_requires_kv and MultiModalFeatureSpec.requires_kv carry the same flag per request and per item through IPC.
  • Admission (InputProcessor): KV-only inputs are exempt from the encoder-cache capacity check, so a decode worker with --limit-mm-per-prompt '{"image": 0}' admits them. They must carry a nonempty hash and no payload per item, stay within the prompt, and end before the final prompt token, which is always recomputed. Prompt logprobs, pooling, M-RoPE and encoder-decoder models are rejected: M-RoPE derives its positions from the media the input does not carry, so it needs a metadata channel first.
  • Scheduler: Request.mm_required_kv_tokens caches the furthest required position. _try_schedule_encoder_inputs fails the request (FINISHED_ERROR) whenever num_computed_tokens is below it, instead of running the decoder over placeholder ids. The check re-applies after preemption and after failed KV loads, and _update_request_as_session raises the boundary when a streaming session appends a requires_kv feature.
  • Receiver cache / renderers: the receiver cache skips requires_kv features (data=None is a deliberate absence, not a cache reference), and the renderers refuse to render such inputs again, so LLM.generate() cannot silently drop the requirement.

With the prefill-side hashes and placeholder positions, the decode engine computes the same block hashes as prefill, so the local prefix cache and hash-keyed KV consumers see the same blocks on both sides; the alternative of folding image hashes into cache_salt rewrites every decode block hash (data).

Removed from the previous revision: the Python prepare_multimodal_kv_handoff / restore_multimodal_kv_handoff / export_multimodal_kv_handoff helpers, the kv_handoff version/position-state descriptor and the model fingerprint, the vllm/inputs/mm_features package (types and serde stay in token_in_token_out/), the is_embed round-trip on PlaceholderRangeInfo (separate PR), and the long docs section. Callers that embed AsyncLLM build the input with mm_input(...) plus mm_requires_kv=True, or through engine_input_from_features.

Test Plan

CPU-only unit tests on the rebased tree (Linux, Python 3.12, torch 2.14.0, editable install with the precompiled extensions of 866bccf083):

  • tests/multimodal/test_mm_requires_kv.py (new): block hashes equal prefill's and differ across images; the receiver cache skips only requires_kv features; the flag survives engine IPC; admission with a zero encoder budget, and rejection of payloads, empty hashes, out-of-range spans, spans reaching the last token, prompt logprobs, pooling and M-RoPE models; ordinary multimodal inputs still need encoder capacity; LLM.generate() preprocessing and the async renderer reject KV-only inputs.
  • tests/v1/core/test_scheduler.py::test_media_free_*: fail-closed below the boundary, re-check after preemption, async load lifecycle (success / failure / cancel, with and without recompute), sync load failure, local prefix-cache coverage without a connector (block-granularity limit), streaming-session boundary update.
  • tests/entrypoints/scale_out/token_in_token_out/test_mm_serde.py and test_generate_stream.py: the wire format omits requires_kv when false, restores a media-free engine input, rejects payloads and empty hashes; serve_tokens passes the flag through without rendering or media.
  • pre-commit: ruff, ruff-format and the repo's mypy wrapper on the changed files.

Test Result

The branch is a single commit, e8ea00b1cf, on main at 39dbf51839. Results on this tree (Linux, Python 3.12, torch 2.14.0, one RTX 5090, editable install with the precompiled extensions of 866bccf083):

  • tests/multimodal/test_mm_requires_kv.py: 18 passed.
  • tests/v1/core/test_scheduler.py: 299 passed, 3 failed (test_async_scheduling_pp_allows_rescheduling_with_output_placeholders, test_max_loras_counts_running_request_awaiting_output, test_diffusion_read_deferral_keeps_a_longer_pp_wait); the same three fail identically on a clean upstream/main worktree at 39dbf51839 in this environment, so they are unrelated to this change. All test_media_free_* tests and the FSM-abort test pass.
  • tests/entrypoints/scale_out/token_in_token_out/: 141 passed in the unit files, and the 29 server-backed tests in test_serving_tokens.py and test_serving_multimodal_tokens.py (a real vllm serve, including the render → generate round trip) pass on the GPU.
  • tests/entrypoints/scale_out/render and tests/renderers: identical results on this branch and on clean main (24 passed, 38 fixture errors from models unavailable in this environment), so the renderer guard changes nothing there.
  • tests/multimodal/test_cache.py: 34 passed; test_sleep_wake_preserves_mm_cache_consistency fails identically on clean main here.
  • pre-commit on the changed files: ruff-check, ruff-format, the repo's mypy wrapper (the remaining mypy errors in multimodal/inputs.py, scheduler.py and input_processor.py are present on clean main with the same toolchain), typos, markdownlint-cli2, SPDX headers, forbidden and lazy imports, torch.cuda calls, test tethering: all passed.

GPU evidence from earlier revisions of the same contract (LLaVA-1.5 / InternVL3, NIXL P/D): latency and throughput and decode-side prefix caching. The engine behaviour exercised there (admission exemption, scheduler coverage check, block-hash parity) is unchanged in this revision; the removed pieces were orchestrator-side helpers.

Dynamo is one motivating caller: HanFa/dynamo#2, ai-dynamo/dynamo#15343. AI assistance was used (Codex and Claude Code) for the implementation, tests and this description; the commit carries DCO sign-off and attribution trailers.


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.

@mergify

mergify Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

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

@mergify mergify Bot added documentation Improvements or additions to documentation multi-modality Related to multi-modality (#4194) scheduler labels Oct 8, 2026
@HanFa
HanFa marked this pull request as ready for review October 8, 2026 04:00

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

@HanFa
HanFa force-pushed the codex/mm-kv-handoff-2 branch from e895851 to c1b6226 Compare October 8, 2026 04:02
@gty111

gty111 commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Thanks for putting this together — the requires_kv semantics and the scheduler-side coverage enforcement (incl. preemption/load-failure rechecks) look like the genuinely new and valuable part of this PR.

One design concern before this goes further: the descriptor/API layer looks like a third parallel schema next to the existing metadata-only paths, and I think it should be aligned with what vLLM already has instead:

  1. Descriptor format: MultiModalKvHandoff largely duplicates the mm_metadata serialization in token_in_token_out/mm_features.py (hashes, placeholder offsets/lengths). The one real gap there is that PlaceholderRange.is_embed is not preserved on main today — fixing that in mm_features.py would benefit both the EC path and this one, and seems like a prerequisite either way. Could the handoff payload reuse/extend that format (e.g. model fingerprint as a field) rather than introducing a separate struct?

  2. Admission path: instead of a new Python-only export/restore API, could this ride on the existing Generate-protocol admission for metadata-only items? _require_ec_for_metadata_only currently hard-couples metadata-only features to ec_transfer_params; generalizing it to also accept a KV-backed fulfillment claim (e.g. kv_transfer_params or an explicit flag) would give KV continuation the same door the EC path already uses, with the scheduler coverage check you added here as the actual enforcement.

The lifecycle contract itself (bypassing the receiver cache for intentionally-absent media, and failing the request whenever KV coverage is missing — including after preemption) has no counterpart in the EC path and does need new logic; my suggestion is only that the descriptor and admission layers not fork from the existing protocol.

@HanFa
HanFa force-pushed the codex/mm-kv-handoff-2 branch from c1b6226 to ca3214b Compare October 10, 2026 00:25
@HanFa HanFa changed the title [Feature][Multimodal] Add experimental KV continuation inputs [Feature][Multimodal] Support KV-backed continuation through Generate inputs Oct 10, 2026
@mergify mergify Bot added the frontend label Oct 10, 2026
@mergify

mergify Bot commented Oct 10, 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, @HanFa.

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 10, 2026
@HanFa
HanFa force-pushed the codex/mm-kv-handoff-2 branch from ca3214b to f15e70b Compare October 10, 2026 00:30
@Isotr0py

Copy link
Copy Markdown
Member

Hmmm, I think passing --language-model-only should also archieve same targets with Dynamo/SMG's pixel-less decode. Maybe the proper fix for dynamo pixel-less decode KV mismatches (ai-dynamo/dynamo#15343) should lie on dynamo itself similar to SMG (smg-project/smg#2640) 🤔

@mergify

mergify Bot commented Oct 21, 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, @HanFa.

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

@HanFa

HanFa commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

cc @gty111 @DarkLight1337

Thanks @Isotr0py. Two separate points, and I agree with you on the first one.

  1. The KV-collision bug in Dynamo was Dynamo's to fix, and it was fixed in fix(vllm): keep multi_modal_data on non-Qwen decode for image KV identity ai-dynamo/dynamo#15371 by loading and processing the image again on decode. This PR is not a fix for that bug. It is about letting decode skip that media work without reopening the same hazard.

Hmmm, I think passing --language-model-only should also archieve same targets

  1. --language-model-only plus a pixel-less text prompt (what fix(pd): serve multimodal requests on language-model-only vLLM decode workers smg-project/smg#2640 does) gets close, and this PR is meant to run on exactly such workers: validated KV-only inputs are exempt from the encoder-cache capacity check, so a decode worker with --limit-mm-per-prompt '{"image":0}' admits them. The differences are in what the engine can guarantee:

    • Block identity. SMG keeps same-size images apart by folding the image hashes into cache_salt. The salt enters the first block hash and chains through every later block, so decode's block hashes no longer equal prefill's, and the shared text prefix before the image no longer prefix-caches across requests on the decode side. NIXL does not care because it addresses blocks by id, but anything that looks blocks up by hash (shared-storage and offloading-tier connectors, hash-based reuse between instances) cannot match them. With the real mm_hashes and placeholder ranges in features, decode computes the same block hashes as prefill and prefix caching keeps its normal semantics.
    • Fail-closed. A text-only prompt gives the scheduler nothing that stops it from recomputing the image span from placeholder token ids: after a preemption whose blocks are evicted before the request resumes, or a failed load under kv_load_failure_policy=recompute, the request completes with HTTP 200 and an answer conditioned on garbage. That is the same class of silent wrong answer as the Dynamo incident with a different trigger, and a caller-side check cannot see it because it happens after admission. requires_kv makes the scheduler fail the request instead, for the whole request lifetime.
    • One implementation. The Generate API already carries metadata-only features for the EC path ([Frontend] Expose multimodal metadata for disaggregated prefill #54659). Reusing that door means orchestrators do not each re-derive expanded ids, placeholder layout and hash identity, plus the carve-outs SMG needed (M-RoPE, media refs, n>1), and keep them in sync with vLLM's processors.

If the concern is the amount of surface this adds, I am happy to cut it down to the engine contract only: is_embed round-tripping in PlaceholderRangeInfo, and features.requires_kv with the receiver-cache bypass, the admission exemption and the scheduler coverage check. The Python prepare/restore helpers and the model fingerprint would go (text-only P/D does not fingerprint model configs between workers today and relies on the orchestrator pairing them, so this path can too). Would that scope be acceptable, or would you prefer is_embed and requires_kv as two separate PRs?

@HanFa

HanFa commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

Reviewed and signed off by @HanFa

Benchmark update: InternVL3-8B and LLaVA-1.5-7B, same-host H100 P/D, three decode-side forms

Per @DarkLight1337's suggestion to use a model with heavier image processing, and @Isotr0py's question about pixel-less decode, I reran on b837be7476 with InternVL3-8B (dynamic tiling, 13 x 256 image tokens, ordinary positions; Qwen-VL waits for the M-RoPE follow-up) and added a third arm that reproduces the orchestrator-side alternative. Further results will be edited into this comment rather than posted as new ones.

image-1793050819252

Arms. Same prefill engine, decode engine, NIXL transfer, prompt tokens and 128 output tokens; only what the decode side receives differs.

Setup. 2 x H100 80GB in one pod, one P and one D engine, NIXL over UCX cuda_ipc. Official wheel of base 4135590ffe (torch 2.14.0+cu130) with the PR sources overlaid, plus two experiment-only patches: admission gate widened to non-M-RoPE decoder-only models, and the fingerprint fix below. BF16, TP=1, eager, prefix caching on, mm_processor_cache_gb=0 on both engines, decode keeps limit-mm-per-prompt image=1 so all arms share one process. One distinct 4032x3024 JPEG per request (5.9 MB as base64), temperature 0, 3 rounds x 24 requests per cell with arm order rotated; 432 requests per model, no errors. Prompt tokens: InternVL3 3355, LLaVA 605.

Results. p50 over 72 requests per cell. "Decode-attr." is TTFT minus the prefill adapter's own time; at C8 the prefill side's p50 moves by +-100 ms between arms from CPU contention, so that column is the one to compare arms on.

model C arm TTFT p50 / p95 ms decode-attr. p50 ms D input prep ms D request body req/s
InternVL3-8B 1 full 914 / 979 421 330 5.9 MB 0.313
handoff 560 / 612 62 0.7 48 KB 0.351
textonly 559 / 616 61 0 28 KB 0.351
InternVL3-8B 8 full 1229 / 3386 630 442 5.9 MB 1.813
handoff 817 / 2622 128 0.7 48 KB 2.077
textonly 726 / 2757 126 0 28 KB 2.081
LLaVA-1.5-7B 1 full 580 / 595 320 232 5.9 MB 0.314
handoff 331 / 344 66 0.2 5.3 KB 0.339
textonly 330 / 345 67 0 4.9 KB 0.339
LLaVA-1.5-7B 8 full 1403 / 2409 700 348 5.9 MB 1.813
handoff 871 / 1713 164 0.2 5.3 KB 2.117
textonly 1070 / 1620 177 0 4.9 KB 2.094

Handoff vs full media: TTFT p50 -39% / -33% (InternVL3 C1 / C8) and -43% / -38% (LLaVA), p95 -23% to -42%, C8 throughput +15-17%, decode request body 5.9 MB -> 5-48 KB. Decode submit-to-first-token is equal across arms, so the whole difference is the decode-side input preparation.

Parity. LLaVA: 8/8 requests bit-identical across arms for all 128 tokens. InternVL3: of five serial passes over the same images, passes 2-5 are bit-identical to each other; pass 1, the only one where prefill computed the prompt from scratch instead of hitting its prefix cache, differs from every later pass at the same positions. Given the same KV, handoff equals the full-media output bit for bit.

Takeaways.

  1. Image size matters as much as the model: with 12 MP inputs even LLaVA pays ~230 ms per request on the decode side. The earlier runs used ~300 KB images and saw 6-11 ms.
  2. Handoff and pixel-less decode are equal on latency (restore costs 0.7 ms and ~20 KB of descriptor). The case for the engine contract over text-only is what text-only cannot provide: failing the request instead of silently recomputing the image span from placeholder ids after a preemption or load failure; decode block hashes equal to prefill's, whereas per-image cache_salt rewrites every block hash on decode, so the shared text prefix stops prefix-caching across requests and hash-keyed connectors cannot match; and a metadata channel for M-RoPE. Targeted runs for the first two are next.
  3. C8 TTFT includes queueing of eight 3355-token prefills on one prefill engine; read the decode-attributed column there.

Bug found. _kv_handoff_model_fingerprint calls json.dumps(hf_config.to_dict()), and InternVL3's config contains a torch.dtype, so prepare_multimodal_kv_handoff raised TypeError. These runs used default=str; the same fix, with a regression test that computes the fingerprint from a config carrying a torch.dtype, is now in the PR (d37246d260).

Limitations. In-process AsyncLLM adapters rather than the Dynamo or SMG stacks; one P and one D; closed loop; same-host transport (cross-node TCP dilutes the relative gain, URL-fetched images enlarge it); eager; synthetic images; InternVL3 admitted through the experiment patch. Four-image, CPU-pinned-decode and 640x480 LLaVA results are in the update below.

Update (additional configurations, InternVL3-8B, same setup).

Four images per request (4 x 4032x3024, 13348 prompt tokens, limit-mm-per-prompt image=4, 3 rounds x 16 requests per cell):

C arm TTFT p50 / p95 ms D input prep ms D submit->first ms P total p50 ms D request body req/s
1 full 3893 / 4049 1661 153 1961 23.8 MB 0.160
1 handoff 2119 / 2246 2.1 142 1956 189 KB 0.222
1 textonly 2112 / 2217 0 57 1956 110 KB 0.225
8 full 7204 / 13662 2197 204 4719 23.8 MB 0.616
8 handoff 6933 / 12294 2.1 150 6712 189 KB 0.654
8 textonly 6731 / 11805 0 186 6583 110 KB 0.670

At C1 the decode side re-processes four 12 MP images for 1.66 s per request; handoff removes it (TTFT -46%). At C8 the single prefill engine is saturated by eight 13k-token prompts (P total 4.7-6.7 s), so TTFT is prefill-bound and the gain shrinks to -4% with throughput +6%; the closed loop also pushes more load onto prefill in the arms whose decode side is faster, which is why their P total is higher. One observation that is not specific to this PR: decode submit-to-first-token is ~85 ms higher for requests that carry multimodal features at this prompt length (full 153 ms, handoff 142 ms) than for the feature-less text-only request (57 ms); with one image it was equal across arms. Worth profiling separately, since it is the per-request cost of keeping multimodal-aware block hashes on decode.

Decode engine pinned to 8 CPU cores (taskset, one image per request, 3 rounds x 32 requests per cell, C8 and C16):

C arm TTFT p50 / p95 ms D input prep ms D submit->first ms P total p50 ms req/s
8 full 1119 / 3337 405 129 555 1.883
8 handoff 664 / 2591 0.7 111 549 2.200
8 textonly 671 / 2537 0 111 560 2.224
16 full 2216 / 6236 667 135 1535 2.409
16 handoff 1958 / 5474 0.7 121 1764 2.562
16 textonly 1953 / 5462 0 119 1752 2.590

With eight cores the baseline's decode-side preparation grows from 330 ms (C1) to 405 ms (C8) and 667 ms (C16) while handoff stays at 0.7 ms; TTFT p50 -41% at C8 and -12% at C16, throughput +17% / +6%. At C16 the prefill engine is again the bottleneck (P total 1.5-1.8 s), which caps the relative gain in this single-P/single-D topology.

Image-size control, LLaVA-1.5-7B with 640x480 inputs (~170 KB as base64, otherwise the same protocol as the main LLaVA matrix):

C arm TTFT p50 / p95 ms D input prep ms D submit->first ms P total p50 ms req/s
1 full 112.6 / 117.2 7.4 54.2 47.9 0.371
1 handoff 104.6 / 112.5 0.2 52.8 48.7 0.373
1 textonly 104.3 / 107.9 0 53.7 48.2 0.375
8 full 252.9 / 370.7 10.1 120.3 113.9 2.807
8 handoff 248.7 / 344.9 0.1 121.7 118.0 2.797
8 textonly 243.7 / 358.6 0 118.2 112.0 2.801

This reproduces the earlier LLaVA result: with small images the decode-side preparation is 7-10 ms and TTFT improves by 7% (C1) / 2% (C8). The same model with 12 MP inputs pays 230 ms and improves by 43% / 38%. The benefit scales with media size and preprocessing cost, not with the model alone.

AI assistance (Claude Code) was used for the harness, the runs and this write-up; all numbers are from the runs described.

@HanFa
HanFa force-pushed the codex/mm-kv-handoff-2 branch from b837be7 to d37246d Compare October 26, 2026 21:48
@mergify

mergify Bot commented Oct 27, 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, @HanFa.

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 27, 2026
@HanFa
HanFa force-pushed the codex/mm-kv-handoff-2 branch from d37246d to 880acdd Compare October 27, 2026 21:22
@mergify mergify Bot removed the needs-rebase label Oct 27, 2026
@DarkLight1337

Copy link
Copy Markdown
Member

@claude review

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread vllm/renderers/base.py Resolved
Comment thread vllm/v1/engine/input_processor.py Resolved
Comment thread vllm/v1/engine/input_processor.py Resolved
Comment thread vllm/v1/core/sched/scheduler.py Resolved
@HanFa
HanFa force-pushed the codex/mm-kv-handoff-2 branch from 880acdd to 51cb98c Compare October 28, 2026 19:12
@HanFa

HanFa commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Reviewed and signed off by @HanFa

@DarkLight1337 here is the data behind the "cache_salt rewrites every decode block hash" point from the Slack thread (cc @Isotr0py @gty111). Same harness and host as the benchmark above, PR head 51cb98cb89 on base 70cb50dab2, LLaVA-1.5-7B, 2 x H100 in one pod, one prefill and one decode engine, NIXL.

Decode-side prefix caching: handoff vs text-only + cache_salt

Arms. handoff: the PR's descriptor with requires_kv (positional mm identifiers, no media). textonly: expanded token ids + cache_salt over all image hashes, i.e. smg-project/smg#2365 / #2640's decode leg. The full-media arm (decode re-renders the images) was run too; it matched handoff on every caching metric and only adds its preprocessing time, so it is left out of the chart.

Setups (one per x-axis group in the chart):

setup prompt shape shared across requests differs between requests prompt tokens what it shows
shared system prompt [~2100-token system prompt] USER: <image> question the system prompt text the image (a different one per request) 2716 whether blocks that hold only shared text are reused on decode when the image differs; the most common serving shape (fixed instructions, per-request image)
shared ref. image USER: <image R> <image_i> question the reference image R (same bytes in every request) the second image 1186 whether a shared image prefix is reused when a later image differs (comparison tasks)
4 turns, +1 image / turn 4-turn conversation; turn t repeats turn t-1 (prompt + answer) and adds a new image + question within a conversation, turn t's prefix is all of turn t-1 every turn adds an image, so the image set changes each turn 602 → 1225 → 1848 → 2471 whether a conversation keeps reusing its own KV on decode while its image set grows (multi-image chat)
control: 1 image 4-turn conversation; one image in turn 1, text-only follow-ups all of turn t-1 only text 602 → 726 the case where the salt stays constant; both arms should cache identically, which scopes the claim

C1 / C8 = conversations in flight. Every setup: 32 conversations per round, 2 rounds, arms paired on identical images and conversations, prefix caches on both engines reset before each round so the first request is cold in both arms, 640x480 single-color images with a one-word color question, 16 output tokens, temperature 0, every answer checked against the image's color.

What is measured (decode engine's own counters, deltas over a round): local hit rate = prefix_cache_hits / prefix_cache_queries; KV pulled = external_prefix_cache_hits tokens over NIXL, at 512 KiB per token for this model; P→D hash overlap = share of the block hashes prefill published (KV events, BlockStored) that decode also published, which is what anything keyed on block hashes sees.

setup (prompt tokens) C arm D local hit KV pulled / req P→D hash overlap TTFT p50 ms D submit→first p50 ms req/s
shared system prompt (2716) 1 handoff 75.3% 335 MiB 100% 104 53 2.54
textonly 0.0% 1358 MiB 0% 123 71 2.46
8 handoff 65.6% 467 MiB 100% 260 129 13.05
textonly 0.0% 1358 MiB 0% 367 201 11.78
shared ref. image (1186) 1 handoff 47.0% 314 MiB 100% 104 53 2.47
textonly 0.0% 593 MiB 0% 108 57 2.44
4 turns, +1 image / turn (602→2471) 1 handoff 59.4% 312 MiB 100% 105 52 2.54
textonly 0.0% 768 MiB 0% 113 59 2.46
control: 1 image (602→726) 1 handoff 71.8% 94 MiB 100% 81 46 2.70
textonly 71.8% 94 MiB 0% 80 47 2.69

Reading it. The hit rates are the block arithmetic: cache_salt enters the hash only at block 0 and chains forward, so any request whose image set differs from a cached one shares nothing, including the blocks that hold only the shared system prompt or the shared reference image; with the handoff the mm identifiers are positional and those blocks hit (75.3% = 2112 of 2716 tokens per warm request times 31/32 for the cold first one). In the 4-turn chat the fourth turn pulls 315 MiB with the handoff and 1235 MiB with the salt, because the salt changes every time an image is added. The control row scopes the claim: with one image per conversation the salt is constant and caches exactly like the handoff, but its hashes still never equal prefill's (0% overlap).

On one host over NVLink the saved transfer is tens of milliseconds (decode submit-to-first-token 53 vs 71 ms at C1, 129 vs 201 ms at C8 for the 2100-token prefix; TTFT -15% / -29%). Where the bytes cost more it is seconds (bottom-right panel): with UCX forced to tcp,cuda_copy instead of cuda_ipc, TTFT p50 1.1 vs 4.7 s at C1 and throughput 0.64 vs 0.20 req/s (loopback TCP inside one pod, a transport-cost sensitivity rather than a cross-host measurement); with the decode KV budget capped at 1500 blocks (24k tokens) and 16 concurrent 128-token requests, the per-request copies of the system prompt let at most 8 text-only requests be resident, so throughput is 2.94 vs 5.38 req/s and TTFT p50 2.8 s vs 332 ms. No preemptions occurred in that run, so it shows the capacity effect only; the preemption path (recompute from placeholder ids vs. fail-closed) still needs a run with long outputs.

Correctness was identical across arms: all system-prompt, control and pressure/TCP answers correct, and the LLaVA-1.5 multi-image misses (75% on the reference-image setup, 79-92% on the 4-turn chat) agree across arms on 637 of 640 paired requests. Mechanism check without a GPU: building the same requests and hashing them with get_request_block_hasher gives 69/69 equal block hashes between prefill and decode for the handoff and 0/69 for the salted decode, 128/165 reusable blocks across requests for the 2048-token system prompt vs 0.

Limitations: in-process AsyncLLM adapters rather than Dynamo or SMG; one P and one D on one host; LLaVA-1.5 only; eager; synthetic images; the TCP run is a transport-cost sensitivity; the pressure run did not reach preemption. AI assistance (Claude Code) was used for the harness, the runs, the chart and this write-up; all numbers are from the runs described.

@mergify

mergify Bot commented Nov 2, 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, @HanFa.

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 Nov 2, 2026
@Isotr0py

Isotr0py commented Nov 3, 2026

Copy link
Copy Markdown
Member

IIUC, to fix the block corruption issue, we just need to append requires_kv hook + multimodal feature position for identifier (see https://github.com/Isotr0py/vllm/tree/mm-kv-minimal). Furthermore, if we alwanys assumed only --language-model-only for D, we can even avoid the requires_kv hook. 🤔

So I significantly feel most of engine side changes and new implementation are unnecessary here.

…ith features.requires_kv

Let a decode instance continue a multimodal request from decoder KV that
already exists, locally or through a KV connector, without the media and
without encoder capacity. The Generate API input carries the prefill-side
token ids plus item hashes and placeholder positions, and
`features.requires_kv` states that every item must be covered by KV.

Engine contract:

- `MultiModalFeatures.requires_kv` (omitted from the wire format when
  false) rejects serialized payloads and empty hashes;
  `engine_input_from_features` builds the engine input and `serve_tokens`
  uses it. `MultiModalInput.mm_requires_kv` and
  `MultiModalFeatureSpec.requires_kv` carry the flag through IPC.
- Admission exempts KV-only inputs from the encoder-cache capacity check
  and validates them instead: nonempty hash and no payload per item, spans
  within the prompt and ending before the final prompt token (which is
  always recomputed); prompt logprobs, pooling, M-RoPE and encoder-decoder
  models are rejected.
- The scheduler caches `Request.mm_required_kv_tokens` and fails the
  request instead of recomputing a span from placeholder ids whenever
  `num_computed_tokens` is below it, including after preemption and failed
  KV loads; streaming sessions extend the boundary.
- The receiver cache skips `requires_kv` features and the renderers refuse
  to render such inputs again.

Because the decode input carries prefill's hashes and positions, decode
computes the same block hashes as prefill, so prefix caching and
hash-keyed KV stores see the same blocks on both sides.

Compared with the previous revision, the Python export/restore helpers,
the kv_handoff descriptor and model fingerprint, the vllm/inputs/mm_features
package move and the is_embed round-trip are dropped; the latter will be a
separate PR.

Closes vllm-project#60521

Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Fang Han <hanfa@umich.edu>
@HanFa
HanFa force-pushed the codex/mm-kv-handoff-2 branch from 51cb98c to e8ea00b Compare November 3, 2026 21:53
@mergify mergify Bot removed the needs-rebase label Nov 3, 2026

This branch has not been deployed

No deployments
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 frontend multi-modality Related to multi-modality (#4194) scheduler

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Restore multimodal requests from processed state and existing KV

4 participants