[Bugfix][KV Offload] Fix SWA store reachability during chunked prefill (#54362)

Signed-off-by: Will Hampson <whamp@ggl.slmail.me> Co-authored-by: Will Hampson <whamp@ggl.slmail.me> Co-authored-by: Or Ozeri <oro@il.ibm.com>

Will Hampson committed Sep 6, 2026 at 03:50 UTC 569adb5a9780f9c02d22a6b29826acf711512356
4 files changed +385 -15
tests/v1/core/test_prefix_caching.py
+35
@@ -4389,6 +4389,41 @@ def test_mamba_shared_prefix_reuse_under_zero_retention():
4389 assert last_req_hit(retention=0, pin=True) == 2 * block_size
4390
4391
4392 +@pytest.mark.skip_global_cleanup
4393 +def test_swa_reachable_block_mask_final_partial_segment():
4394 + """Only a final SWA horizon makes its incomplete segment tail reachable."""
4395 + from vllm.v1.core.single_type_kv_cache_manager import SlidingWindowManager
4396 +
4397 + spec = SlidingWindowSpec(
4398 + block_size=4,
4399 + num_kv_heads=1,
4400 + head_size=1,
4401 + dtype=torch.float32,
4402 + sliding_window=16,
4403 + )
4404 + start_block = 243
4405 + end_block = 250
4406 +
4407 + def reachable_blocks(
4408 + final_segment_end_block: int, *, use_eagle: bool = False
4409 + ) -> set[int]:
4410 + mask = SlidingWindowManager.reachable_block_mask(
4411 + start_block=start_block,
4412 + end_block=end_block,
4413 + alignment_tokens=36,
4414 + kv_cache_spec=spec,
4415 + use_eagle=use_eagle,
4416 + final_segment_end_block=final_segment_end_block,
4417 + )
4418 + assert mask is not None
4419 + return {start_block + i for i, reachable in enumerate(mask) if reachable}
4420 +
4421 + assert reachable_blocks(300) == {248, 249}
4422 + assert reachable_blocks(250) == {246, 247, 248, 249}
4423 + assert reachable_blocks(300, use_eagle=True) == {243, 248, 249}
4424 + assert reachable_blocks(250, use_eagle=True) == {243, 248, 249}
4425 +
4426 +
4427 def test_swa_reachable_block_mask_pins_shared_prefix():
4428 """SWA analog of the Mamba pin: the shared-prefix junction must keep the
4429 ``need``-block sliding-window tail ending on that boundary (not a single
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py
+215
@@ -48,6 +48,7 @@ from vllm.v1.core.sched.output import (
48 from vllm.v1.kv_cache_interface import (
49 ChunkedLocalAttentionSpec,
50 FullAttentionSpec,
51 + KVCacheConfig,
52 KVCacheGroupSpec,
53 SlidingWindowSpec,
54 )
@@ -3390,6 +3391,220 @@ class TestEagle:
3391 f"interior hole in stored blocks: {offsets}"
3392 )
3393
3394 + @pytest.mark.skip_global_cleanup
3395 + @pytest.mark.parametrize(
3396 + "abort_after_first_step,async_scheduling,store_horizon_tokens",
3397 + [
3398 + pytest.param(False, False, 1200, id="completed-prompt"),
3399 + pytest.param(True, False, 1000, id="aborted-sync"),
3400 + pytest.param(True, True, 1000, id="aborted-async"),
3401 + ],
3402 + )
3403 + def test_chunked_prefill_uses_final_swa_store_horizon(
3404 + self,
3405 + request_runner,
3406 + abort_after_first_step: bool,
3407 + async_scheduling: bool,
3408 + store_horizon_tokens: int,
3409 + ):
3410 + """SWA stores must use the completed or aborted request horizon."""
3411 + full_attn_block_size = 36
3412 + swa_block_size = 4
3413 + sliding_window = 16
3414 + num_tokens = 1200
3415 +
3416 + kv_cache_groups = [
3417 + KVCacheGroupSpec(
3418 + ["layer0"],
3419 + FullAttentionSpec(
3420 + block_size=full_attn_block_size,
3421 + num_kv_heads=1,
3422 + head_size=1,
3423 + dtype=torch.float32,
3424 + ),
3425 + ),
3426 + KVCacheGroupSpec(
3427 + ["layer1"],
3428 + SlidingWindowSpec(
3429 + block_size=swa_block_size,
3430 + num_kv_heads=1,
3431 + head_size=1,
3432 + dtype=torch.float32,
3433 + sliding_window=sliding_window,
3434 + ),
3435 + ),
3436 + ]
3437 + runner = request_runner(
3438 + block_size=swa_block_size,
3439 + num_gpu_blocks=1000,
3440 + async_scheduling=async_scheduling,
3441 + kv_cache_groups=kv_cache_groups,
3442 + )
3443 +
3444 + runner.new_request(token_ids=[0] * num_tokens)
3445 + runner.manager.prepare_store.side_effect = lambda keys, req_context: (
3446 + generate_store_output(keys)
3447 + )
3448 +
3449 + def abort_after_prefill_step() -> None:
3450 + if runner.scheduler.running:
3451 + runner.scheduler.finish_requests(
3452 + (str(runner.req_id),), RequestStatus.FINISHED_ABORTED
3453 + )
3454 +
3455 + runner._run(
3456 + [1] if abort_after_first_step else [1, 1, EOS_TOKEN_ID],
3457 + complete_transfers=True,
3458 + post_step_fn=(abort_after_prefill_step if abort_after_first_step else None),
3459 + )
3460 +
3461 + stored_swa_chunks = sorted(
3462 + block.request_block_offset
3463 + for transfer in runner.completed_stores
3464 + for block in transfer.gpu_blocks
3465 + if block.group_idx == 1
3466 + )
3467 + store_horizon_chunks = store_horizon_tokens // swa_block_size
3468 + alignment_chunks = full_attn_block_size // swa_block_size
3469 + sliding_window_chunks = sliding_window // swa_block_size
3470 + expected_swa_chunks = []
3471 + for chunk in range(store_horizon_chunks):
3472 + segment_start = chunk - chunk % alignment_chunks
3473 + segment_length = min(alignment_chunks, store_horizon_chunks - segment_start)
3474 + if chunk % alignment_chunks >= segment_length - sliding_window_chunks:
3475 + expected_swa_chunks.append(chunk)
3476 + assert stored_swa_chunks == expected_swa_chunks
3477 +
3478 + @pytest.mark.skip_global_cleanup
3479 + def test_active_decode_does_not_advance_swa_final_horizon(self, request_runner):
3480 + """Active decode chunks are not final partial SWA segments."""
3481 + full_attn_block_size = 36
3482 + swa_block_size = 4
3483 + prompt_tokens = 1200
3484 + kv_cache_groups = [
3485 + KVCacheGroupSpec(
3486 + ["layer0"],
3487 + FullAttentionSpec(
3488 + block_size=full_attn_block_size,
3489 + num_kv_heads=1,
3490 + head_size=1,
3491 + dtype=torch.float32,
3492 + ),
3493 + ),
3494 + KVCacheGroupSpec(
3495 + ["layer1"],
3496 + SlidingWindowSpec(
3497 + block_size=swa_block_size,
3498 + num_kv_heads=1,
3499 + head_size=1,
3500 + dtype=torch.float32,
3501 + sliding_window=16,
3502 + ),
3503 + ),
3504 + ]
3505 + runner = request_runner(
3506 + block_size=swa_block_size,
3507 + num_gpu_blocks=1000,
3508 + async_scheduling=False,
3509 + kv_cache_groups=kv_cache_groups,
3510 + )
3511 + runner.new_request(token_ids=[0] * prompt_tokens)
3512 + runner.manager.prepare_store.side_effect = lambda keys, req_context: (
3513 + generate_store_output(keys)
3514 + )
3515 +
3516 + runner._run([1] * 12, complete_transfers=True)
3517 +
3518 + assert runner.scheduler.running
3519 + stored_swa_chunks = {
3520 + block.request_block_offset
3521 + for transfer in runner.completed_stores
3522 + for block in transfer.gpu_blocks
3523 + if block.group_idx == 1
3524 + }
3525 + first_decode_chunk = prompt_tokens // swa_block_size
3526 + assert first_decode_chunk not in stored_swa_chunks
3527 +
3528 + def test_dcp_abort_reconsiders_final_swa_tail(self):
3529 + """DCP-expanded SWA blocks are not scaled twice during abort."""
3530 + vllm_config = _make_vllm_config(
3531 + extra_config={"offload_prompt_only": False},
3532 + tensor_parallel_size=2,
3533 + decode_context_parallel_size=2,
3534 + )
3535 + vllm_config.speculative_config = None
3536 + vllm_config.kv_events_config = KVEventsConfig(
3537 + enable_kv_cache_events=True, publisher="null"
3538 + )
3539 + kv_cache_config = KVCacheConfig(
3540 + num_blocks=0,
3541 + kv_cache_tensors=[],
3542 + kv_cache_groups=[
3543 + KVCacheGroupSpec(
3544 + ["full"],
3545 + FullAttentionSpec(
3546 + block_size=36,
3547 + num_kv_heads=1,
3548 + head_size=1,
3549 + dtype=torch.float32,
3550 + ),
3551 + ),
3552 + KVCacheGroupSpec(
3553 + ["swa"],
3554 + SlidingWindowSpec(
3555 + block_size=4,
3556 + num_kv_heads=1,
3557 + head_size=1,
3558 + dtype=torch.float32,
3559 + sliding_window=16,
3560 + ),
3561 + ),
3562 + ],
3563 + )
3564 + spec = MockOffloadingSpec(build_offloading_config(vllm_config, kv_cache_config))
3565 + scheduler = OffloadingConnectorScheduler(spec, vllm_config, kv_cache_config)
3566 + scheduler.manager.prepare_store.side_effect = (
3567 + lambda keys, req_context: generate_store_output(keys)
3568 + )
3569 +
3570 + request = MagicMock()
3571 + request.request_id = "req"
3572 + request.kv_transfer_params = None
3573 + request.num_prompt_tokens = 1200
3574 + request.num_tokens = 1200
3575 + request.num_computed_tokens = 0
3576 + request.block_hashes = [BlockHash(f"h{i}".encode()) for i in range(150)]
3577 + request.all_token_ids = list(range(1200))
3578 + request.lora_request = None
3579 + request.shared_prefix_boundary = 0
3580 + request.status = RequestStatus.RUNNING
3581 + request.is_finished.return_value = False
3582 + scheduler.on_new_request(request)
3583 +
3584 + full_block_ids = list(range(1, 15))
3585 + swa_block_ids = list(range(1001, 1126))
3586 + first_output = SchedulerOutput.make_empty()
3587 + first_output.scheduled_new_reqs = [
3588 + SimpleNamespace(req_id="req", block_ids=(full_block_ids, swa_block_ids))
3589 + ]
3590 + first_output.num_scheduled_tokens = {"req": 1000}
3591 + first_output.total_num_scheduled_tokens = 1000
3592 + scheduler.build_connector_meta(first_output)
3593 +
3594 + request.num_computed_tokens = 1000
3595 + request.status = RequestStatus.FINISHED_ABORTED
3596 + request.is_finished.return_value = True
3597 + abort_output = SchedulerOutput.make_empty()
3598 + abort_output.finished_req_ids = {"req"}
3599 + abort_meta = scheduler.build_connector_meta(abort_output)
3600 +
3601 + assert len(abort_meta.store_jobs) == 1
3602 + [store_job] = abort_meta.store_jobs.values()
3603 + assert isinstance(store_job.src_spec, GPULoadStoreSpec)
3604 + assert store_job.src_spec.block_ids.tolist() == [swa_block_ids[123]]
3605 + assert store_job.src_spec.group_sizes == [0, 1]
3606 + assert store_job.src_spec.block_indices == [0, 123]
3607 +
3608 @pytest.mark.parametrize("async_scheduling", [True, False])
3609 def test_full_attn_store_then_load(self, request_runner, async_scheduling: bool):
3610 """Eagle group constrains load: convergence tightens both groups.
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
+117 -15
@@ -1326,6 +1326,46 @@ class OffloadingConnectorScheduler:
1326
1327 return store_jobs
1328
1329 + def _reachable_store_block_mask(
1330 + self,
1331 + group_config: GroupOffloadConfig,
1332 + start_chunk_idx: int,
1333 + end_chunk_idx: int,
1334 + final_segment_end_chunk_idx: int | None,
1335 + reachable_boundaries: tuple[int, ...],
1336 + ) -> list[bool] | None:
1337 + """Build the block mask for a range of candidate offload chunks."""
1338 + blocks_per_chunk = self.config.blocks_per_chunk
1339 + return group_config.manager_cls.reachable_block_mask(
1340 + start_block=start_chunk_idx * blocks_per_chunk,
1341 + end_block=end_chunk_idx * blocks_per_chunk,
1342 + alignment_tokens=self.config.alignment_tokens,
1343 + kv_cache_spec=group_config.kv_cache_spec,
1344 + use_eagle=group_config.is_eagle_group,
1345 + retention_interval=self.config.retention_interval,
1346 + reachable_boundaries=reachable_boundaries,
1347 + dcp_world_size=self.config.dcp_world_size,
1348 + final_segment_end_block=(
1349 + final_segment_end_chunk_idx * blocks_per_chunk
1350 + if final_segment_end_chunk_idx is not None
1351 + else None
1352 + ),
1353 + )
1354 +
1355 + def _final_swa_alignment_blocks(
1356 + self, group_config: GroupOffloadConfig
1357 + ) -> int | None:
1358 + """Return representable final-tail alignment for a non-EAGLE SWA group."""
1359 + alignment_tokens = self.config.alignment_tokens
1360 + if (
1361 + alignment_tokens is None
1362 + or group_config.is_eagle_group
1363 + or not isinstance(group_config.kv_cache_spec, SlidingWindowSpec)
1364 + or alignment_tokens % group_config.tokens_per_block != 0
1365 + ):
1366 + return None
1367 + return alignment_tokens // group_config.tokens_per_block
1368 +
1369 def _build_store_jobs(
1370 self,
1371 scheduler_output: SchedulerOutput,
@@ -1352,10 +1392,14 @@ class OffloadingConnectorScheduler:
1392 num_offloadable_tokens = self._calc_num_offloadable_tokens(
1393 req_status, num_tokens_after_batch
1394 )
1395 + prompt_offloadable_tokens = self._calc_num_offloadable_tokens(
1396 + req_status, req.num_prompt_tokens
1397 + )
1398
1399 # Filter out chunks skipped due to sliding window attention / SSM
1400 # or unreachable by the load path's alignment constraints.
1401 new_offload_keys: list[OffloadKey] = []
1402 + group_store_ranges: list[tuple[int, int]] = []
1403
1404 reachable_boundaries: tuple[int, ...] = ()
1405 if self.config.retention_interval is not None:
@@ -1370,10 +1414,47 @@ class OffloadingConnectorScheduler:
1414 group_config, group_state, num_offloadable_tokens
1415 )
1416
1417 + start_chunk_idx = group_state.next_stored_chunk_idx
1418 + prompt_horizon_chunks = (
1419 + prompt_offloadable_tokens // group_config.tokens_per_chunk
1420 + )
1421 + final_swa_alignment_blocks = self._final_swa_alignment_blocks(
1422 + group_config
1423 + )
1424 + reconsider_final_swa_tail = (
1425 + req.is_finished()
1426 + and num_chunks != prompt_horizon_chunks
1427 + and self.config.retention_interval is None
1428 + and final_swa_alignment_blocks is not None
1429 + )
1430 + if (
1431 + self.config.retention_interval is not None
1432 + or group_config.is_eagle_group
1433 + ):
1434 + store_horizon_chunks = None
1435 + elif req.is_finished():
1436 + store_horizon_chunks = num_chunks
1437 + elif num_chunks <= prompt_horizon_chunks:
1438 + store_horizon_chunks = prompt_horizon_chunks
1439 + else:
1440 + # An active decode frontier is not a final request
1441 + # boundary. Only fixed alignment tails are reachable.
1442 + store_horizon_chunks = None
1443 + if reconsider_final_swa_tail:
1444 + assert final_swa_alignment_blocks is not None
1445 + horizon_blocks = num_chunks * blocks_per_chunk
1446 + partial_segment_start_block = (
1447 + horizon_blocks - horizon_blocks % final_swa_alignment_blocks
1448 + )
1449 + partial_segment_start_chunk = (
1450 + partial_segment_start_block // blocks_per_chunk
1451 + )
1452 + start_chunk_idx = min(start_chunk_idx, partial_segment_start_chunk)
1453 + group_store_ranges.append((start_chunk_idx, num_chunks))
1454 +
1455 if group_config.requires_cow_source:
1456 continue
1457
1376 - start_chunk_idx = group_state.next_stored_chunk_idx
1458 if num_chunks <= start_chunk_idx:
1459 continue
1460 offload_keys = group_state.offload_keys[start_chunk_idx:num_chunks]
@@ -1393,22 +1474,44 @@ class OffloadingConnectorScheduler:
1474 # (SWA/Mamba sparsity + retention interval).
1475 # reachable_block_mask operates in KV-block coordinates,
1476 # so convert chunk indices to block indices.
1396 - block_mask = group_config.manager_cls.reachable_block_mask(
1397 - start_block=start_chunk_idx * blocks_per_chunk,
1398 - end_block=num_chunks * blocks_per_chunk,
1399 - alignment_tokens=self.config.alignment_tokens,
1400 - kv_cache_spec=group_config.kv_cache_spec,
1401 - use_eagle=group_config.is_eagle_group,
1402 - retention_interval=self.config.retention_interval,
1477 + block_mask = self._reachable_store_block_mask(
1478 + group_config=group_config,
1479 + start_chunk_idx=start_chunk_idx,
1480 + end_chunk_idx=num_chunks,
1481 + final_segment_end_chunk_idx=store_horizon_chunks,
1482 reachable_boundaries=reachable_boundaries,
1404 - dcp_world_size=self.config.dcp_world_size,
1483 )
1484
1485 + prompt_horizon_block_mask: list[bool] | None = None
1486 + if reconsider_final_swa_tail:
1487 + prompt_horizon_block_mask = self._reachable_store_block_mask(
1488 + group_config=group_config,
1489 + start_chunk_idx=start_chunk_idx,
1490 + end_chunk_idx=num_chunks,
1491 + final_segment_end_chunk_idx=prompt_horizon_chunks,
1492 + reachable_boundaries=reachable_boundaries,
1493 + )
1494 +
1495 for key_idx, (offload_key, block_id) in enumerate(
1496 zip(offload_keys, offload_block_ids)
1497 ):
1498 if block_id == 0:
1499 continue
1500 + abs_chunk_idx = start_chunk_idx + key_idx
1501 + if (
1502 + reconsider_final_swa_tail
1503 + and abs_chunk_idx < group_state.next_stored_chunk_idx
1504 + and (
1505 + prompt_horizon_block_mask is None
1506 + or any(
1507 + prompt_horizon_block_mask[
1508 + key_idx * blocks_per_chunk + block_idx
1509 + ]
1510 + for block_idx in range(blocks_per_chunk)
1511 + )
1512 + )
1513 + ):
1514 + continue
1515 # A chunk is reachable if any of its constituent
1516 # blocks is reachable.
1517 if block_mask is not None and not any(
@@ -1445,16 +1548,15 @@ class OffloadingConnectorScheduler:
1548 src_block_ids: list[int] = []
1549 fenced_block_ids: list[int] = []
1550 deferred_fence_block_ids: list[int] = []
1448 - for group_config, group_state in zip(
1449 - self.config.kv_group_configs, req_status.group_states
1551 + for group_config, group_state, store_range in zip(
1552 + self.config.kv_group_configs,
1553 + req_status.group_states,
1554 + group_store_ranges,
1555 ):
1556 is_sliding_window = (
1557 group_config.sliding_window_size_in_chunks is not None
1558 )
1454 - num_chunks = req_status.storable_chunks(
1455 - group_config, group_state, num_offloadable_tokens
1456 - )
1457 - start_chunk_idx = group_state.next_stored_chunk_idx
1559 + start_chunk_idx, num_chunks = store_range
1560 block_ids = group_state.block_ids
1561 num_group_blocks = 0
1562 start_gpu_block_idx: int | None = None
vllm/v1/core/single_type_kv_cache_manager.py
+18
@@ -508,6 +508,7 @@ class SingleTypeKVCacheManager(ABC):
508 retention_interval: int | None = None,
509 reachable_boundaries: Sequence[int] = (),
510 dcp_world_size: int = 1,
511 + final_segment_end_block: int | None = None,
512 ) -> list[bool] | None:
513 """Per-block mask for ``cache_full_blocks``. ``None`` means cache
514 every (non-null) block — the default for full attention.
@@ -516,6 +517,10 @@ class SingleTypeKVCacheManager(ABC):
517 blocks that can never serve a hit at any alignment-aligned prefix length.
518 ``reachable_boundaries`` are token positions whose reachable tail must be
519 retained; the base (dense) policy ignores them.
520 + ``final_segment_end_block`` is the exclusive end of the request's final
521 + segment. It may be later than ``end_block`` while prefill is still in
522 + progress. Non-EAGLE sparse managers may keep its reachable tail. The
523 + base policy already keeps every block, so it ignores this value.
524 """
525 return None
526
@@ -1033,6 +1038,7 @@ class SlidingWindowManager(SingleTypeKVCacheManager):
1038 retention_interval: int | None = None,
1039 reachable_boundaries: Sequence[int] = (),
1040 dcp_world_size: int = 1,
1041 + final_segment_end_block: int | None = None,
1042 ) -> list[bool] | None:
1043 assert isinstance(kv_cache_spec, SlidingWindowSpec)
1044 if alignment_tokens is None:
@@ -1079,6 +1085,17 @@ class SlidingWindowManager(SingleTypeKVCacheManager):
1085 if i >= shift and (i - shift) % per_segment >= per_segment - need:
1086 mask[i - start_block] = True
1087
1088 + if final_segment_end_block is not None and not use_eagle:
1089 + final_segment_size = final_segment_end_block % per_segment
1090 + if final_segment_size:
1091 + final_segment_start = final_segment_end_block - final_segment_size
1092 + final_tail_start = max(
1093 + final_segment_start, final_segment_end_block - need
1094 + )
1095 + final_tail_end = min(end_block, final_segment_end_block)
1096 + for i in range(max(start_block, final_tail_start), final_tail_end):
1097 + mask[i - start_block] = True
1098 +
1099 # (2) Reachable-boundary tails: the replay boundary (``num_prompt - 1``,
1100 # capped by ``get_computed_blocks``) and any shared-prefix junction. Both
1101 # land before segments would cover them under sparse retention, so keep
@@ -1513,6 +1530,7 @@ class MambaManager(SingleTypeKVCacheManager):
1530 retention_interval: int | None = None,
1531 reachable_boundaries: Sequence[int] = (),
1532 dcp_world_size: int = 1,
1533 + final_segment_end_block: int | None = None,
1534 ) -> list[bool] | None:
1535 """Sparse Mamba state-snapshot retention.
1536