[PD][NixlPush][Bugfix] Fix prefix caching (#48758)

Signed-off-by: NickLucche <nicolo.lucchesi@mistral.ai>

Nicolò Lucchesi committed Aug 7, 2026 at 16:21 UTC d4ecb75ba2f53a1e445cf5ac277ea8a5e78d516b
5 files changed +189 -73
tests/v1/kv_connector/unit/test_nixl_connector_hma.py
+10 -3
@@ -314,7 +314,10 @@ def test_apply_prefix_caching_mamba_hybrid(
314 worker.kv_cache_config = make_kv_cache_config(block_size=16, mamba_enabled=True)
315
316 aligned_local, aligned_remote = worker._apply_prefix_caching(
317 - local_block_ids, remote_block_ids, remote_physical_per_logical
317 + local_block_ids,
318 + remote_block_ids,
319 + local_physical_per_logical,
320 + remote_physical_per_logical,
321 )
322
323 assert aligned_local == expected_local, (
@@ -413,7 +416,10 @@ def test_apply_prefix_caching_ssm_prefix_cache_hit(
416 worker.kv_cache_config = make_kv_cache_config(block_size=16, mamba_enabled=True)
417
418 aligned_local, aligned_remote = worker._apply_prefix_caching(
416 - local_block_ids, remote_block_ids, remote_physical_per_logical
419 + local_block_ids,
420 + remote_block_ids,
421 + local_physical_per_logical,
422 + remote_physical_per_logical,
423 )
424
425 assert aligned_local == expected_local, (
@@ -442,7 +448,7 @@ def test_apply_prefix_caching_ssm_unpairable_slots_rejected():
448
449 with pytest.raises(AssertionError, match="unpairable SSM state slots"):
450 worker._apply_prefix_caching(
445 - [list(range(10)), [4, 5, 6, 7]], [list(range(10)), [8, 9]], 10
451 + [list(range(10)), [4, 5, 6, 7]], [list(range(10)), [8, 9]], 10, 10
452 )
453
454
@@ -527,6 +533,7 @@ def test_mismatched_physical_per_logical_fails_with_prefix_caching(
533 aligned_local, aligned_remote = worker._apply_prefix_caching(
534 local_block_ids,
535 remote_block_ids,
536 + local_physical_per_logical,
537 remote_physical_per_logical,
538 )
539
tests/v1/kv_connector/unit/test_nixl_push_connector.py
+92 -1
@@ -341,6 +341,7 @@ class _StubWriterWorker(NixlPushConnectorWorker):
341 # Single non-hybrid attention group, matching the stub block id lists.
342 w._has_mamba = False
343 w._group_spec_types = (FullAttentionSpec,)
344 + w._engine_ttl = 0.0
345 w._engine_last_active = {}
346
347 # Track _do_start_push_kv invocations.
@@ -569,7 +570,7 @@ def test_do_start_push_kv_drops_request_on_handshake_failure():
570 the failure is logged. Blocks are reclaimed by the lease/watchdog, matching
571 the old blocking behaviour."""
572 w = _StubWriterWorker.fresh()
572 - w._logical_to_kernel_block_ids = lambda x: x
573 + w._logical_to_kernel_block_ids = lambda x, ratio: x
574 xfer_calls: list[dict[str, Any]] = []
575 w._xfer_blocks_for_req = lambda **kw: xfer_calls.append(kw)
576 failures: list[dict[str, Any]] = []
@@ -1157,3 +1158,93 @@ class TestPushWriterMlaReplication:
1158 # All of the request's WRITE handles must be tracked together, so the
1159 # engine thread never sees a partial set and double-frees the request.
1160 assert sorted(w._sending_transfers["p-req"]) == [1000, 1001]
1161 +
1162 +
1163 +class TestPushPrefixCaching:
1164 + """Partial prefix-cache hit on D: D preallocates only its *uncomputed*
1165 + blocks, so on a partial hit it registers fewer blocks than P's full
1166 + sequence. P must WRITE only the *tail* of its sequence into D's slots --
1167 + mirroring pull-mode ``_apply_prefix_caching`` (end-trim), never a front-trim
1168 + which would write P's cached prefix into D's uncomputed suffix slots.
1169 +
1170 + The trim runs inside ``_xfer_blocks``, so these tests drive the real
1171 + ``_xfer_blocks_for_req`` path and stub only the NIXL WRITE. With one region
1172 + and 1:1 ratios ``_compute_desc_ids`` is the identity map, so the descs
1173 + captured from ``make_prepped_xfer`` are exactly the (trimmed) block IDs.
1174 + """
1175 +
1176 + @staticmethod
1177 + def _worker_driving_xfer(engine_id: str = "decode-engine"):
1178 + from types import SimpleNamespace
1179 +
1180 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl.tp_mapping import (
1181 + TPMapping,
1182 + )
1183 +
1184 + w = _StubWriterWorker.fresh()
1185 + w._has_mamba = False
1186 + w.use_mla = False
1187 + w.block_size = 16
1188 + w.num_regions = 1
1189 + w._physical_blocks_per_logical_kv_block = 1
1190 +
1191 + w.transfer_topo = MagicMock()
1192 + w.transfer_topo.get_engine_info.return_value = SimpleNamespace(
1193 + remote_physical_blocks_per_logical=1,
1194 + remote_block_size=16,
1195 + remote_tp_size=1,
1196 + )
1197 + w.transfer_topo.tp_ratio.return_value = 1
1198 + w.transfer_topo.block_size_ratio.return_value = 1
1199 + w.tp_mappings = {
1200 + engine_id: TPMapping(
1201 + source_ranks_per_group=((0,),),
1202 + all_source_ranks=(0,),
1203 + rank_to_attention_slot={0: 0},
1204 + rank_offset_factor=0,
1205 + )
1206 + }
1207 + w.dst_num_blocks = {engine_id: 10_000, w.engine_id: 10_000}
1208 + w.dst_xfer_side_handles = {engine_id: {0: 5000}}
1209 + w.src_xfer_handles_by_block_size = {16: 2000}
1210 +
1211 + # Stub only the NIXL WRITE; kernel expansion, prefix-cache trim, desc
1212 + # computation and count assertions all run for real.
1213 + w.nixl_wrapper = MagicMock()
1214 + w.nixl_wrapper.make_prepped_xfer.return_value = 7
1215 + w._ensure_handshake = lambda *a, **k: None
1216 + w._logical_to_kernel_block_ids = lambda x, ratio: x
1217 + return w, engine_id
1218 +
1219 + @staticmethod
1220 + def _written_block_ids(w) -> tuple[list[int], list[int]]:
1221 + """Return the (local, remote) block IDs handed to the NIXL WRITE."""
1222 + args, _ = w.nixl_wrapper.make_prepped_xfer.call_args
1223 + # ("WRITE", local_handle, local_descs, remote_handle, remote_descs)
1224 + return list(args[2]), list(args[4])
1225 +
1226 + def test_partial_prefix_hit_end_trims_producer_blocks(self):
1227 + """D registered only its 2 uncomputed suffix blocks; P finished the
1228 + full 5-block sequence. P must WRITE its LAST 2 blocks into D's slots."""
1229 + w, _ = self._worker_driving_xfer()
1230 + reg = _registration_data("req-pc", local_block_ids=([500, 501],))
1231 +
1232 + NixlPushConnectorWorker._do_start_push_kv(
1233 + w, "req-pc", ([10, 11, 12, 13, 14],), reg
1234 + )
1235 +
1236 + local, remote = self._written_block_ids(w)
1237 + # End-trim (suffix), NOT front-trim: [13, 14], not [10, 11].
1238 + assert local == [13, 14]
1239 + assert remote == [500, 501]
1240 +
1241 + def test_no_prefix_hit_leaves_blocks_untrimmed(self):
1242 + """Equal counts (no prefix cache hit on D): nothing is trimmed."""
1243 + w, _ = self._worker_driving_xfer()
1244 + reg = _registration_data("req-full", local_block_ids=([500, 501, 502],))
1245 +
1246 + NixlPushConnectorWorker._do_start_push_kv(w, "req-full", ([10, 11, 12],), reg)
1247 +
1248 + local, remote = self._written_block_ids(w)
1249 + assert local == [10, 11, 12]
1250 + assert remote == [500, 501, 502]
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+63 -55
@@ -2397,93 +2397,101 @@ class NixlBaseConnectorWorker:
2397
2398 def _apply_prefix_caching(
2399 self,
2400 - local_block_ids: BlockIds,
2401 - remote_block_ids: BlockIds,
2402 - remote_physical_per_logical: int,
2403 - ) -> tuple[BlockIds, list]:
2404 - """Apply prefix caching by trimming local/remote block ID lists.
2400 + decode_block_ids: BlockIds,
2401 + prefill_block_ids: BlockIds,
2402 + decode_physical_per_logical: int,
2403 + prefill_physical_per_logical: int,
2404 + ) -> tuple[BlockIds, BlockIds]:
2405 + """Trim block ID lists so only the uncomputed suffix is transferred.
2406 +
2407 + Inputs are *kernel* (physical) block IDs, already expanded from logical IDs
2408 + with each side's physical-per-logical ratio. Both pull and push call this after
2409 + that expansion so the trim happens at kernel granularity.
2410 +
2411 + The prefix-cache hit is always on the decode (D) side, so ``decode``
2412 + holds only its uncomputed blocks while prefill (P) holds the full
2413 + sequence. This is mode-independent: pull passes its own (D) blocks as
2414 + ``decode``, push passes the remote D registration as ``decode``.
2415
2406 - For non-Mamba models: end-trim remote to match local count, so that
2416 + For non-Mamba models: end-trim ``prefill`` to match ``decode`` count, so
2417 already-cached prefix blocks are skipped in the transfer.
2418
2409 - For Mamba hybrid (prefix caching not yet supported): front-trim both
2410 - to the minimum count to handle kernel block count discrepancies from
2411 - logical block rounding in heterogeneous TP.
2419 + For Mamba hybrid: SSM groups pair state slots by position and FA
2420 + groups end-trim to the uncomputed suffix when physical-per-logical
2421 + matches.
2422 """
2413 - # Partial prefix cache hit: just read uncomputed blocks.
2423 + # Partial prefix cache hit: just transfer uncomputed blocks.
2424 # Skip mamba groups — their blocks represent full state (conv+ssm),
2425 # not per-token data, so trimming would corrupt the transfer.
2416 - remote_block_ids = list(remote_block_ids)
2426 + prefill_block_ids = list(prefill_block_ids)
2427 if not self._has_mamba:
2418 - for i, remote_group in enumerate(remote_block_ids):
2419 - num_local_blocks = len(local_block_ids[i])
2420 - assert num_local_blocks <= len(remote_group)
2421 - if num_local_blocks < len(remote_group):
2422 - remote_block_ids[i] = remote_group[-num_local_blocks:]
2428 + for i, prefill_group in enumerate(prefill_block_ids):
2429 + num_decode_blocks = len(decode_block_ids[i])
2430 + assert num_decode_blocks <= len(prefill_group)
2431 + if num_decode_blocks < len(prefill_group):
2432 + prefill_block_ids[i] = prefill_group[-num_decode_blocks:]
2433 else:
2424 - # (NOTE: ZhanqiuHu) Mamba hybrid: no prefix caching support so far.HeteroTP
2425 - # can cause different kernel block counts due to logical block rounding.
2434 + # (NOTE: ZhanqiuHu) HeteroTP can cause different kernel block counts
2435 + # due to logical block rounding.
2436 # Example: 640 prompt tokens, kernel_block_size=64
2427 - # remote physical_per_logical=10, local physical_per_logical=6
2428 - # remote logical ids from kv_transfer_params = [0]
2429 - # local logical ids allocated = [0, 1]
2430 - # remote kernel blocks: [0..9] (1*10=10)
2431 - # local kernel blocks: [0..11] (2*6=12)
2437 + # prefill physical_per_logical=10, decode physical_per_logical=6
2438 + # prefill logical ids from kv_transfer_params = [0]
2439 + # decode logical ids allocated = [0, 1]
2440 + # prefill kernel blocks: [0..9] (1*10=10)
2441 + # decode kernel blocks: [0..11] (2*6=12)
2442 # actual data blocks = ceil(640/64) = 10, trim both to 10
2433 - # Vice versa (remote physical_per_logical=6, local=10):
2434 - # remote logical ids = [0, 1], local logical ids = [0]
2435 - # remote kernel blocks: [0..11] (2*6=12)
2436 - # local kernel blocks: [0..9] (1*10=10)
2443 + # Vice versa (prefill physical_per_logical=6, decode=10):
2444 + # prefill logical ids = [0, 1], decode logical ids = [0]
2445 + # prefill kernel blocks: [0..11] (2*6=12)
2446 + # decode kernel blocks: [0..9] (1*10=10)
2447 # actual data blocks = ceil(640/64) = 10, trim both to 10
2438 - local_block_ids = list(local_block_ids)
2439 - for i, remote_group in enumerate(remote_block_ids):
2440 - num_local_blocks = len(local_block_ids[i])
2441 - num_remote_blocks = len(remote_group)
2448 + decode_block_ids = list(decode_block_ids)
2449 + for i, prefill_group in enumerate(prefill_block_ids):
2450 + num_decode_blocks = len(decode_block_ids[i])
2451 + num_prefill_blocks = len(prefill_group)
2452 if _is_ssm_spec(self._group_spec_types[i]):
2443 - if num_local_blocks == num_remote_blocks:
2453 + if num_decode_blocks == num_prefill_blocks:
2454 continue
2455 # Only state-bearing slots reach here, single-state modes
2456 # just one (see get_exchange_clipped_blocks), so differing
2457 # counts mean position-indexed "all"-mode lists. A longer
2448 - # remote list carries earlier positions the local side
2449 - # already has (prefix hit) -> read its tail; a longer local
2458 + # prefill list carries earlier positions the decode side
2459 + # already has (prefix hit) -> take its tail; a longer decode
2460 # list holds the position D recomputes itself, which gets
2451 - # no remote state.
2452 - assert num_local_blocks - num_remote_blocks <= 1, (
2461 + # no prefill state.
2462 + assert num_decode_blocks - num_prefill_blocks <= 1, (
2463 f"Group {i}: unpairable SSM state slots, "
2454 - f"local={num_local_blocks} remote={num_remote_blocks}"
2464 + f"decode={num_decode_blocks} prefill={num_prefill_blocks}"
2465 )
2456 - num_blocks = min(num_local_blocks, num_remote_blocks)
2457 - if num_local_blocks < num_remote_blocks:
2458 - remote_block_ids[i] = remote_group[-num_blocks:]
2466 + num_blocks = min(num_decode_blocks, num_prefill_blocks)
2467 + if num_decode_blocks < num_prefill_blocks:
2468 + prefill_block_ids[i] = prefill_group[-num_blocks:]
2469 else:
2460 - local_block_ids[i] = local_block_ids[i][:num_blocks]
2470 + decode_block_ids[i] = decode_block_ids[i][:num_blocks]
2471 elif (
2462 - self._physical_blocks_per_logical_kv_block
2463 - == remote_physical_per_logical
2464 - and num_local_blocks < num_remote_blocks
2472 + decode_physical_per_logical == prefill_physical_per_logical
2473 + and num_decode_blocks < num_prefill_blocks
2474 ):
2475 # Partial prefix cache hit for FA group.
2467 - remote_block_ids[i] = remote_group[-num_local_blocks:]
2476 + prefill_block_ids[i] = prefill_group[-num_decode_blocks:]
2477 else:
2478 # TODO Handle prefix caching with different block_sizes
2479 # Allocation rounding legitimately leaves up to
2480 # ppl - 1 trailing dead kernel blocks per side (plus one
2472 - # extra local block for the recomputed final token), so
2481 + # extra decode block for the recomputed final token), so
2482 # the counts may differ by up to the sum of the two
2483 # ratios; anything larger indicates mismatched lists.
2484 max_padding = (
2476 - self._physical_blocks_per_logical_kv_block
2477 - + remote_physical_per_logical
2485 + decode_physical_per_logical + prefill_physical_per_logical
2486 )
2479 - assert abs(num_local_blocks - num_remote_blocks) <= max_padding, (
2480 - f"Group {i}: |{num_local_blocks} - "
2481 - f"{num_remote_blocks}| > {max_padding}"
2487 + assert abs(num_decode_blocks - num_prefill_blocks) <= max_padding, (
2488 + f"Group {i}: |{num_decode_blocks} - "
2489 + f"{num_prefill_blocks}| > {max_padding}"
2490 )
2483 - num_blocks = min(num_local_blocks, num_remote_blocks)
2484 - local_block_ids[i] = local_block_ids[i][:num_blocks]
2485 - remote_block_ids[i] = remote_group[:num_blocks]
2486 - return local_block_ids, remote_block_ids
2491 + num_blocks = min(num_decode_blocks, num_prefill_blocks)
2492 + decode_block_ids[i] = decode_block_ids[i][:num_blocks]
2493 + prefill_block_ids[i] = prefill_group[:num_blocks]
2494 + return decode_block_ids, prefill_block_ids
2495
2496 def get_kv_connector_stats(self) -> KVConnectorStats | None:
2497 """
vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py
+4 -2
@@ -289,9 +289,11 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
289 == len(local_block_ids)
290 == len(self.kv_cache_config.kv_cache_groups)
291 )
292 - remote_physical_per_logical = remote_info.remote_physical_blocks_per_logical
292 local_block_ids, remote_block_ids = self._apply_prefix_caching(
294 - local_block_ids, remote_block_ids, remote_physical_per_logical
293 + decode_block_ids=local_block_ids,
294 + prefill_block_ids=remote_block_ids,
295 + decode_physical_per_logical=self._physical_blocks_per_logical_kv_block,
296 + prefill_physical_per_logical=remote_info.remote_physical_blocks_per_logical,
297 )
298
299 # NOTE (nicolo) With homogeneous TP, each TP worker loads KV from
vllm/distributed/kv_transfer/kv_connector/v1/nixl/push_worker.py
+20 -12
@@ -454,10 +454,6 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
454 # how pull-mode transfers touch _engine_last_active in start_load_kv.
455 self._engine_last_active[decode_engine_id] = time.perf_counter()
456
457 - # Both sides stay logical here; ``_xfer_blocks_for_req`` converts each
458 - # to physical with its own physical-blocks-per-logical ratio -- P uses
459 - # ``self._physical_blocks_per_logical_kv_block``, D's is learned during
460 - # the NIXL handshake.
457 logical_local = self._as_grouped_block_ids(local_block_ids)
458 logical_remote = self._as_grouped_block_ids(remote_block_ids)
459 physical_local = self._logical_to_kernel_block_ids(
@@ -629,16 +625,28 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
625 logger.warning("No blocks to push for request %s", request_id)
626 return None
627
632 - # Align per-group block counts for push.
628 + # Prefix caching: D allocated only uncached blocks, so on a partial hit it
629 + # sends fewer than P's. End-trim P's blocks to that same suffix so we WRITE only
630 + # the uncomputed tail into D's slots. Runs on kernel ids, post-expansion.
631 + remote_block_ids, local_block_ids = self._apply_prefix_caching(
632 + decode_block_ids=remote_block_ids,
633 + prefill_block_ids=local_block_ids,
634 + decode_physical_per_logical=remote_info.remote_physical_blocks_per_logical,
635 + prefill_physical_per_logical=self._physical_blocks_per_logical_kv_block,
636 + )
637 +
638 local_block_ids = list(local_block_ids)
639 remote_block_ids = list(remote_block_ids)
635 - for i in range(min(len(local_block_ids), len(remote_block_ids))):
636 - num_local = len(local_block_ids[i])
637 - num_remote = len(remote_block_ids[i])
638 - if num_local > num_remote:
639 - local_block_ids[i] = local_block_ids[i][:num_remote]
640 - elif num_local < num_remote:
641 - remote_block_ids[i] = remote_block_ids[i][:num_local]
640 + assert len(local_block_ids) == len(remote_block_ids), (
641 + f"push group-count mismatch for {request_id}: {len(local_block_ids)} "
642 + f"local vs {len(remote_block_ids)} remote groups"
643 + )
644 + for i in range(len(local_block_ids)):
645 + assert len(local_block_ids[i]) == len(remote_block_ids[i]), (
646 + f"push block-count mismatch for {request_id} group {i}: "
647 + f"{len(local_block_ids[i])} local vs "
648 + f"{len(remote_block_ids[i])} remote blocks"
649 + )
650
651 # Get descs ids.
652 remote_block_descs_ids = self._compute_desc_ids(