Skip to content

[KV Connector] Canonical CPU layout for parallelism-agnostic KV offload - #48414

Merged
orozery merged 12 commits into
vllm-project:mainfrom
Etelis:kv-offload-fragment-layout
Aug 10, 2026
Merged

[KV Connector] Canonical CPU layout for parallelism-agnostic KV offload#48414
orozery merged 12 commits into
vllm-project:mainfrom
Etelis:kv-offload-fragment-layout

Conversation

@Etelis

@Etelis Etelis commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Stacked on #48408. Stores offloaded KV in the canonical (parallelism-free) layout
described by the refs' page mappings: each worker scatters its page fragments to
their canonical positions in a CPU area shared by the whole worker group.

legacy row:     [worker0: T0|T1][worker1: T0|T1]...   per-worker slots
canonical row:  [T0 canonical page][T1 canonical page]  full heads + tokens, one copy

MLA latent and replicated GQA heads are stored once instead of once per rank
(empty store runs on non-writers). Copy expansions are precomputed per ref at
init; per-block placement is one vectorized base + offsets sum, with no
parallelism inputs anywhere in the transfer path.

Configuration

kv_connector_extra_config: {"canonical_layout": true} on TieringOffloadingSpec
(requires the shared mmap). Requesting it on an uncertifiable config fails at
startup instead of silently downgrading. The persisted format identity
(v1-nhd / v1-hnd) joins the FileMapper namespace so canonical, legacy, and
cross-family bytes can never resolve to the same files; the parallel_agnostic
gate now also excludes replicated GQA heads, per-token-head scales, and CP.

Test plan

pytest tests/v1/kv_offload/cpu/test_canonical_layout.py tests/v1/kv_offload/test_file_mapper.py
(no GPU): expansion offsets, empty store runs, per-tensor areas, namespace
separation, parallel_agnostic gates. Cross-topology byte semantics are covered by
the schema tests in #48408.

E2E on 4x H100 (eviction-forced CPU reloads, block-aligned prompts, greedy
token comparison against a GPU-cached baseline, lookup hits verified in logs):

case model tokens exact ranks certified
tp1 legacy Qwen2.5-0.5B yes -
tp1 canonical Qwen2.5-0.5B yes 1
tp2 canonical Qwen2.5-0.5B yes 2
tp4 + dcp2 canonical Qwen2.5-1.5B yes 4

Note for reviewers: reset_prefix_cache also resets the offload manager, so
reload tests must evict via cache pressure instead.

Signed-off-by: Itay Etelis <itay.etelis@ibm.com>
@mergify

mergify Bot commented Jul 17, 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, @Etelis.

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 Jul 17, 2026
All parallelism reasoning moves to vllm/v1/kv_offload/sharding.py, covering
TP/DCP/PCP, packed and split KV layouts, with single-writer election.

Signed-off-by: Itay Etelis <itay.etelis@ibm.com>
CPU pages follow the refs' canonical mappings behind an opt-in flag;
the persisted format id joins the file-tier namespace.

Signed-off-by: Itay Etelis <itay.etelis@ibm.com>
@Etelis
Etelis force-pushed the kv-offload-fragment-layout branch from 93d8816 to f5966e8 Compare July 18, 2026 19:47
@Etelis Etelis changed the title [KV Connector] Fragment-major canonical CPU layout for KV offload [KV Connector] Canonical CPU layout for parallelism-agnostic KV offload Jul 18, 2026
# Conflicts:
#	tests/v1/kv_offload/test_file_mapper.py
#	vllm/v1/kv_offload/cpu/gpu_worker.py
#	vllm/v1/kv_offload/file_mapper.py

Signed-off-by: Itay Etelis <itay.etelis@ibm.com>

@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 Jul 26, 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, @Etelis.

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

@Change72

Copy link
Copy Markdown
Contributor

Hi @Etelis — while preparing the gate relaxation @orozery suggested in #48906 (extending replicated_layout to UniformTypeKVCacheSpecs / all-MLA multi-group), my duplicate check landed back on this PR — the canonical layout covers a superset of that goal, so I don't want to open a competing path if this is landing.

Two questions:

  • Are you planning to rebase and continue this soon?
  • If it's paused for a while: any objection to a small interim relaxation of the existing coarse replicated_layout gate (whole-row single copy, no new layout machinery) for the V3.2/multi-group shapes? It would be strictly subsumed by the canonical layout once this lands — @orozery, your call whether the interim step is worth it or we should just wait.

@Etelis

Etelis commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Hi @Etelis — while preparing the gate relaxation @orozery suggested in #48906 (extending replicated_layout to UniformTypeKVCacheSpecs / all-MLA multi-group), my duplicate check landed back on this PR — the canonical layout covers a superset of that goal, so I don't want to open a competing path if this is landing.

Two questions:

  • Are you planning to rebase and continue this soon?
  • If it's paused for a while: any objection to a small interim relaxation of the existing coarse replicated_layout gate (whole-row single copy, no new layout machinery) for the V3.2/multi-group shapes? It would be strictly subsumed by the canonical layout once this lands — @orozery, your call whether the interim step is worth it or we should just wait.

I will continue tomorrow :) rebase and continue

Comment thread vllm/v1/kv_offload/tiering/spec.py Outdated
kv_bytes_per_block=self.kv_bytes_per_chunk,
cpu_page_size=self.cpu_page_size_per_worker,
)
canonical_layout = bool(self.extra_config.get("canonical_layout", False))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we parse this at build_offloading_config?

canonical_layout=canonical_layout,
)

def _validate_canonical_refs(self, kv_caches: CanonicalKVCaches) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

same, can we move this when parsing OffloadingConfig?

Comment on lines +161 to +165
"""Allocate a strided int8 view shared by all workers for one
canonical tensor (canonical layout). Canonical views are carved from
the start of each block row; workers write disjoint bytes within them
as described by their mappings. Must be called once per canonical
tensor, instead of create_next_worker_view."""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we add a diagram like in create_next_worker_view?

create_next_worker_view

              |<----- cpu_page_size ----->|<----- cpu_page_size ----->|
              |       worker 0 slot       |       worker 1 slot       |
              |                           |                           |
block 0:      | [  tensor0  |  tensor1  ] | [  tensor0  |  tensor1  ] |
block 1:      | [  tensor0  |  tensor1  ] | [  tensor0  |  tensor1  ] |
block 2:      | [  tensor0  |  tensor1  ] | [  tensor0  |  tensor1  ] |
              ^                           ^
              worker0 offset=0            worker1 offset=cpu_page_size

create_next_canonical_view:

              |<------- canonical area ------->|<--- unused padding --->|
              |  all workers share this area   |                        |
              |                                |                        |
block 0:      | [ canonical_t0 | canonical_t1 ]|        (waste)         |
block 1:      | [ canonical_t0 | canonical_t1 ]|        (waste)         |
block 2:      | [ canonical_t0 | canonical_t1 ]|        (waste)         |
              ^                ^
              _canonical_offset=0  then advances by each tensor's size

Comment on lines 142 to +151
is_parallelism_agnostic = (
not vllm_config.use_v2_model_runner
and single_group_spec is not None
and isinstance(single_group_spec, FullAttentionSpec)
and not isinstance(single_group_spec, MLAAttentionSpec)
and single_group_spec.num_kv_heads * parallel_config.tensor_parallel_size
== vllm_config.model_config.get_total_num_kv_heads()
and not single_group_spec.kv_quant_mode.is_per_token_head
and parallel_config.decode_context_parallel_size == 1
and parallel_config.prefill_context_parallel_size == 1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It seems like we are just adding conditions.
With this PR we should have more cases where is_parallelism_agnostic is True, no?

Comment thread vllm/v1/kv_offload/cpu/gpu_worker.py Outdated
Comment on lines +539 to +544
fill_group_ops = (
self._fill_legacy_ops
if self._canonical_copy_plans is None
else self._fill_canonical_ops
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's bind _fill_group_ops at init instead of branching on self._canonical_copy_plans is None in every transfer_async call.

Comment thread vllm/v1/kv_offload/cpu/gpu_worker.py Outdated
block_bases_dst[:, None] + plan.frag_offsets_dst[None, :]
).ravel()
all_sizes[op_idx:end_idx] = np.tile(plan.frag_sizes, num_active_blocks)
num_bytes += num_active_blocks * int(plan.frag_sizes.sum())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We can avoid plan.frag_sizes.sum() by introducing total_bytes=int(frag_sizes_arr.sum()) to CopyPlan.

Comment thread vllm/v1/kv_offload/cpu/gpu_worker.py Outdated
all_dst[op_idx:end_idx] = (
block_bases_dst[:, None] + plan.frag_offsets_dst[None, :]
).ravel()
all_sizes[op_idx:end_idx] = np.tile(plan.frag_sizes, num_active_blocks)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Claude suggests this avoids allocation:

Suggested change
all_sizes[op_idx:end_idx] = np.tile(plan.frag_sizes, num_active_blocks)
all_sizes[op_idx:end_idx].reshape(num_active_blocks, plan.num_frags)[:] = (
plan.frag_sizes
)

It claims:
This works because all_sizes[op_idx:end_idx] is a contiguous slice of a 1D array, so .reshape() returns a view (no copy).
The [:] = broadcasts frag_sizes (shape (num_frags,)) across rows.

But need to verify.

Comment thread vllm/v1/kv_offload/cpu/gpu_worker.py Outdated
Comment on lines +417 to +418
block_bases_src = np.empty(group_size, dtype=np.uint64)
block_bases_dst = np.empty(group_size, dtype=np.uint64)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Claude's suggestion (have not verified):

Currently allocates two np.empty(group_size, uint64) arrays per (group, ref) per transfer. Pre-allocate once at init, sized to the max possible group.

In init, after computing blocks_per_chunk etc.:

+        max_group_size = max(t.tensor.shape[0] for t in kv_caches.tensors)
+        self._scratch_bases_src = np.empty(max_group_size, dtype=np.uint64)
+        self._scratch_bases_dst = np.empty(max_group_size, dtype=np.uint64)

Then in _fill_canonical_ops:

-            block_bases_src = np.empty(group_size, dtype=np.uint64)
-            block_bases_dst = np.empty(group_size, dtype=np.uint64)
+            block_bases_src = self._scratch_bases_src[:group_size]
+            block_bases_dst = self._scratch_bases_dst[:group_size]

Note: after _filter_writer_blocks applies a boolean mask, the result is a new array anyway (fancy indexing copies). So the scratch is only reused for the compute_sub_block_ptrs output — the filtered result is still a fresh allocation. This is fine; the savings are on the common path (num_writers == 1) where no filtering happens and the scratch is used directly for the broadcast.

Comment thread vllm/v1/kv_offload/cpu/gpu_worker.py Outdated
Comment on lines +446 to +451
all_src[op_idx:end_idx] = (
block_bases_src[:, None] + plan.frag_offsets_src[None, :]
).ravel()
all_dst[op_idx:end_idx] = (
block_bases_dst[:, None] + plan.frag_offsets_dst[None, :]
).ravel()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Claude suggestion (have not verified, and if it's big we can do as follow-up):

Avoid 2D broadcast intermediate in _fill_canonical_ops

(block_bases_src[:, None] + frag_offsets_src[None, :]).ravel() allocates an (N, M) temporary array every transfer just to immediately flatten it into the output.
Replace with np.add(..., out=...) writing directly into a reshaped view of the destination buffer — zero allocation.

The blocker is a dtype mismatch: the output buffer is backed by a torch.int64 tensor (kernel requirement) but the operands are np.uint64 (natural for addresses). Clean fix: at init, create a uint64 numpy view over the same memory:

self._np_src = self._all_src.numpy().view(np.uint64)

This is zero-copy — int64 and uint64 are bit-equivalent for pointer values.
All numpy arithmetic then works in uint64 natively, and the CUDA kernel sees the same bytes as int64. No per-call casts.

@@ -0,0 +1,171 @@
# SPDX-License-Identifier: Apache-2.0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Claude:

Missing test: cross-topology roundtrip for canonical layout

The canonical layout's core value proposition — KV written with one parallelism config can be read with a different one — has no integration test.
The existing tests in test_canonical_layout.py verify the building blocks (_build_copy_plan, _canonical_page_ids, _canonical_block_sizes) and a single-topology GPU roundtrip, but nobody tests that a TP=2 writer's data is actually usable by a TP=4 reader.

Suggested test (in test_canonical_layout.py):

  1. Create a shared SharedOffloadRegion (simulates the canonical mmap file)
  2. N writer workers (tp_size=writer_tp), each scatters its KV fragment into canonical positions (GPU→CPU)
  3. M reader workers (tp_size=reader_tp), each gathers its fragment from the same canonical region (CPU→GPU)
  4. Assert: reassembled reader data == original writer data (same logical KV, different physical sharding)

Parametrize over (writer_tp, reader_tp) pairs like (2, 4), (4, 2), (2, 1).

This is the one test that proves the feature works end-to-end, not just that the pieces are individually correct.

…, direct-layout naming

Signed-off-by: Itay Etelis <itay.etelis@ibm.com>
Signed-off-by: Itay Etelis <itay.etelis@ibm.com>
@mergify mergify Bot removed the needs-rebase label Aug 9, 2026
@orozery orozery added the ready ONLY add when PR is ready to merge/full CI is needed label Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

@Etelis, CI is now available for this PR.

  • /ci run starts a CI build.
  • /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.

Canonical certification is stride-verified per layer at registration and
fails closed, so the static gate needs only the derivation preconditions;
this admits the v2 model runner and replicated GQA heads.

Signed-off-by: Itay Etelis <itay.etelis@ibm.com>
@orozery

orozery commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

/ci run

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

✅ Triggered Buildkite CI #83056 for commit 1af655d592c9.

@orozery

orozery commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

@Etelis

Etelis commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

The Triton load kernel dereferences CPU pointers on the GPU, which is only
legal on pinned memory; production pins via CPUOffloadingWorker.

Signed-off-by: Itay Etelis <itay.etelis@ibm.com>
@orozery

orozery commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

/ci run

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

✅ Triggered Buildkite CI #83063 for commit 73d2619a4fac.

@orozery

orozery commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

/ci retry

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

✅ No failed, timed-out, or expired jobs need retrying: https://buildkite.com/vllm/ci/builds/83063

@Etelis

Etelis commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

/ci retry

@github-actions

Copy link
Copy Markdown

✅ Queued 1 failed job(s) for retry in Buildkite CI #83063.

@Etelis

Etelis commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

@orozery green.

@orozery orozery left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM!
Thanks @Etelis !

@orozery
orozery merged commit 81840a1 into vllm-project:main Aug 10, 2026
86 checks passed
zyp2014 pushed a commit to zyp2014/vllm that referenced this pull request Aug 21, 2026
…ad (vllm-project#48414)

Signed-off-by: Itay Etelis <itay.etelis@ibm.com>
Co-authored-by: Itay Etelis <itay.etelis@ibm.com>
LCAIZJ pushed a commit to LCAIZJ/vllm that referenced this pull request Aug 24, 2026
Map replicated MLA host KV into one pinned region for eligible single-node MP tensor-parallel workers. Each rank retains its own DMA submission so its CUDA stream continues to order host writes before subsequent reads.

This is a HiSparse integration of the shared offload substrate from vllm-project#48414, not a duplicate of that general layout work.

Tests: 141 CPU and multiprocess tests passed; 32 CUDA offload tests passed. No model evaluation was required because storage bytes and attention results are unchanged.

AI assistance was used for implementation and review.

Co-authored-by: Codex <codex@openai.com>

Signed-off-by: Matthew Bonanni <mbonanni@redhat.com>
MatthewBonanni added a commit to LCAIZJ/vllm that referenced this pull request Aug 24, 2026
Map replicated MLA host KV into one pinned region for eligible single-node MP tensor-parallel workers. Each rank retains its own DMA submission so its CUDA stream continues to order host writes before subsequent reads.

This is a HiSparse integration of the shared offload substrate from vllm-project#48414, not a duplicate of that general layout work.

Tests: 141 CPU and multiprocess tests passed; 32 CUDA offload tests passed. No model evaluation was required because storage bytes and attention results are unchanged.

AI assistance was used for implementation and review.

Co-authored-by: Codex <codex@openai.com>

Signed-off-by: Matthew Bonanni <mbonanni@redhat.com>
MatthewBonanni added a commit to LCAIZJ/vllm that referenced this pull request Aug 25, 2026
Map replicated MLA host KV into one pinned region for eligible single-node MP tensor-parallel workers. Each rank retains its own DMA submission so its CUDA stream continues to order host writes before subsequent reads.

This is a HiSparse integration of the shared offload substrate from vllm-project#48414, not a duplicate of that general layout work.

Tests: 141 CPU and multiprocess tests passed; 32 CUDA offload tests passed. No model evaluation was required because storage bytes and attention results are unchanged.

AI assistance was used for implementation and review.

Co-authored-by: Codex <codex@openai.com>

Signed-off-by: Matthew Bonanni <mbonanni@redhat.com>
MatthewBonanni added a commit to LCAIZJ/vllm that referenced this pull request Aug 26, 2026
Map replicated MLA host KV into one pinned region for eligible single-node MP tensor-parallel workers. Each rank retains its own DMA submission so its CUDA stream continues to order host writes before subsequent reads.

This is a HiSparse integration of the shared offload substrate from vllm-project#48414, not a duplicate of that general layout work.

Tests: 141 CPU and multiprocess tests passed; 32 CUDA offload tests passed. No model evaluation was required because storage bytes and attention results are unchanged.

AI assistance was used for implementation and review.

Co-authored-by: Codex <codex@openai.com>

Signed-off-by: Matthew Bonanni <mbonanni@redhat.com>
MatthewBonanni added a commit to LCAIZJ/vllm that referenced this pull request Aug 26, 2026
Map replicated MLA host KV into one pinned region for eligible single-node MP tensor-parallel workers. Each rank retains its own DMA submission so its CUDA stream continues to order host writes before subsequent reads.

This is a HiSparse integration of the shared offload substrate from vllm-project#48414, not a duplicate of that general layout work.

Tests: 141 CPU and multiprocess tests passed; 32 CUDA offload tests passed. No model evaluation was required because storage bytes and attention results are unchanged.

AI assistance was used for implementation and review.

Co-authored-by: Codex <codex@openai.com>

Signed-off-by: Matthew Bonanni <mbonanni@redhat.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kv-connector ready ONLY add when PR is ready to merge/full CI is needed v1

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants