[SimpleCPUOffloadConnector] PCP + DCP support (#39831)

Signed-off-by: Jonathan Chen <chenleejonathan@gmail.com>

Jonathan Chen committed Jun 20, 2026 at 18:01 UTC c88d3d4775c41793543e97f1609d3161a5689905
2 files changed +243 -7
tests/v1/simple_kv_offload/test_scheduler.py
+230
@@ -6,6 +6,7 @@ from __future__ import annotations
6
7 from dataclasses import dataclass
8
9 +import pytest
10 import torch
11
12 from vllm import SamplingParams
@@ -1528,3 +1529,232 @@ def test_reset_pending_loads() -> None:
1529 # All GPU blocks free
1530 num_used = gpu_pool.num_gpu_blocks - gpu_pool.get_num_free_blocks()
1531 assert num_used == 1, f"Expected only null block in use, got {num_used}"
1532 +
1533 +
1534 +def _make_cp_vllm_config(
1535 + dcp_world_size: int = 1,
1536 + pcp_world_size: int = 1,
1537 +) -> VllmConfig:
1538 + """VllmConfig with context-parallel sizes set for scheduler-only tests."""
1539 + cfg = _make_vllm_config()
1540 +
1541 + cfg.parallel_config.decode_context_parallel_size = dcp_world_size
1542 + cfg.parallel_config.prefill_context_parallel_size = pcp_world_size
1543 + return cfg
1544 +
1545 +
1546 +def _make_cp_scheduler(
1547 + *,
1548 + dcp_world_size: int = 1,
1549 + pcp_world_size: int = 1,
1550 + num_cpu_blocks: int = 8,
1551 + num_gpu_blocks: int = 16,
1552 + lazy: bool = False,
1553 +) -> SchedulerFixture:
1554 + """Build a SimpleCPUOffloadScheduler with CP-scaled virtual block size."""
1555 + cp_world_size = dcp_world_size * pcp_world_size
1556 + virtual_block_size = BLOCK_SIZE * cp_world_size
1557 +
1558 + kv_cache_config = _make_kv_cache_config(num_gpu_blocks)
1559 + vllm_config = _make_cp_vllm_config(dcp_world_size, pcp_world_size)
1560 + cpu_capacity_bytes = _BYTES_PER_BLOCK * num_cpu_blocks
1561 +
1562 + sched = SimpleCPUOffloadScheduler(
1563 + vllm_config=vllm_config,
1564 + kv_cache_config=kv_cache_config,
1565 + cpu_capacity_bytes=cpu_capacity_bytes,
1566 + scheduler_block_size=virtual_block_size,
1567 + hash_block_size=virtual_block_size,
1568 + lazy_offload=lazy,
1569 + )
1570 +
1571 + gpu_block_pool = BlockPool(
1572 + num_gpu_blocks=num_gpu_blocks,
1573 + enable_caching=True,
1574 + hash_block_size=virtual_block_size,
1575 + )
1576 + sched.bind_gpu_block_pool(gpu_block_pool)
1577 +
1578 + return SchedulerFixture(
1579 + scheduler=sched,
1580 + gpu_block_pool=gpu_block_pool,
1581 + vllm_config=vllm_config,
1582 + kv_cache_config=kv_cache_config,
1583 + )
1584 +
1585 +
1586 +def _make_cp_request(
1587 + num_blocks: int,
1588 + virtual_block_size: int,
1589 + request_id: str | None = None,
1590 +) -> Request:
1591 + """Create a request whose block hashes are computed at the virtual
1592 + (CP-scaled) block size, matching what the real scheduler does.
1593 + """
1594 + global _req_counter
1595 + _req_counter += 1
1596 + if request_id is None:
1597 + request_id = f"req-cp-{_req_counter}"
1598 +
1599 + num_tokens = num_blocks * virtual_block_size + 1
1600 + start = _req_counter * 10000
1601 + prompt_token_ids = list(range(start, start + num_tokens))
1602 + sampling_params = SamplingParams(max_tokens=1)
1603 +
1604 + return Request(
1605 + request_id=request_id,
1606 + prompt_token_ids=prompt_token_ids,
1607 + sampling_params=sampling_params,
1608 + pooling_params=None,
1609 + mm_features=None,
1610 + block_hasher=get_request_block_hasher(virtual_block_size, sha256),
1611 + )
1612 +
1613 +
1614 +def _allocate_cp_gpu_blocks(
1615 + gpu_block_pool: BlockPool,
1616 + request: Request,
1617 + num_blocks: int,
1618 + virtual_block_size: int,
1619 + group_id: int = 0,
1620 +) -> list:
1621 + """Allocate GPU blocks and cache them using the CP-scaled block size."""
1622 + blocks = gpu_block_pool.get_new_blocks(num_blocks)
1623 + num_full = min(num_blocks, len(request.block_hashes))
1624 + if num_full > 0:
1625 + gpu_block_pool.cache_full_blocks(
1626 + request=request,
1627 + blocks=blocks,
1628 + num_cached_blocks=0,
1629 + num_full_blocks=num_full,
1630 + block_size=virtual_block_size,
1631 + kv_cache_group_id=group_id,
1632 + )
1633 + return blocks
1634 +
1635 +
1636 +# ---------------------------------------------------------------------------
1637 +# Test 15: CP block size scaling is correct
1638 +# ---------------------------------------------------------------------------
1639 +@pytest.mark.parametrize(
1640 + "dcp_world_size, pcp_world_size",
1641 + [
1642 + (2, 1), # DCP only
1643 + (1, 2), # PCP only
1644 + (2, 2), # DCP + PCP
1645 + ],
1646 +)
1647 +def test_cp_block_size_scaling(dcp_world_size: int, pcp_world_size: int) -> None:
1648 + """Verify that the scheduler's block_size and cp_world_size are correctly
1649 + scaled when context parallelism is enabled."""
1650 + fix = _make_cp_scheduler(
1651 + dcp_world_size=dcp_world_size, pcp_world_size=pcp_world_size
1652 + )
1653 + sched = fix.scheduler
1654 +
1655 + expected_cp = dcp_world_size * pcp_world_size
1656 + assert sched.cp_world_size == expected_cp
1657 + assert sched.block_size == BLOCK_SIZE * expected_cp
1658 +
1659 +
1660 +# ---------------------------------------------------------------------------
1661 +# Test 16: CP eager store-and-load roundtrip
1662 +# ---------------------------------------------------------------------------
1663 +@pytest.mark.parametrize(
1664 + "dcp_world_size, pcp_world_size",
1665 + [
1666 + (2, 1),
1667 + (1, 2),
1668 + ],
1669 +)
1670 +def test_cp_eager_store_and_load_roundtrip(
1671 + dcp_world_size: int, pcp_world_size: int
1672 +) -> None:
1673 + """With CP enabled, store blocks to CPU and reload them for a new request
1674 + with matching tokens. Verifies that hash matching and transfer-pair
1675 + construction work with the virtual block size."""
1676 + fix = _make_cp_scheduler(
1677 + dcp_world_size=dcp_world_size,
1678 + pcp_world_size=pcp_world_size,
1679 + num_cpu_blocks=8,
1680 + num_gpu_blocks=16,
1681 + lazy=False,
1682 + )
1683 + sched = fix.scheduler
1684 + cp = dcp_world_size * pcp_world_size
1685 + vbs = BLOCK_SIZE * cp
1686 +
1687 + num_blocks = 2
1688 + req = _make_cp_request(num_blocks, vbs)
1689 +
1690 + # Allocate GPU blocks and register hashes
1691 + gpu_blocks = _allocate_cp_gpu_blocks(fix.gpu_block_pool, req, num_blocks, vbs)
1692 + kv_blocks = KVCacheBlocks(blocks=(gpu_blocks,))
1693 + req.num_computed_tokens = num_blocks * vbs
1694 + sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
1695 +
1696 + block_ids = kv_blocks.get_block_ids()
1697 + sched_out = make_scheduler_output(
1698 + {req.request_id: num_blocks * vbs},
1699 + new_reqs={req.request_id: block_ids},
1700 + )
1701 +
1702 + meta = sched.build_connector_meta(sched_out)
1703 + assert meta.store_event >= 0, "Expected a store event"
1704 + assert len(meta.store_gpu_blocks) == num_blocks
1705 + assert len(meta.store_cpu_blocks) == num_blocks
1706 + simulate_store_completion(sched, meta.store_event)
1707 +
1708 + # New request with same tokens — should get a full CPU cache hit.
1709 + req2 = Request(
1710 + request_id="req-cp-load",
1711 + prompt_token_ids=req.prompt_token_ids,
1712 + sampling_params=req.sampling_params,
1713 + pooling_params=None,
1714 + mm_features=None,
1715 + block_hasher=req._block_hasher,
1716 + )
1717 +
1718 + hit_tokens, is_async = sched.get_num_new_matched_tokens(req2, num_computed_tokens=0)
1719 + assert hit_tokens == num_blocks * vbs
1720 + assert is_async is True
1721 +
1722 + # Allocate fresh GPU blocks for the load.
1723 + gpu_blocks2 = fix.gpu_block_pool.get_new_blocks(num_blocks)
1724 + kv_blocks2 = KVCacheBlocks(blocks=(gpu_blocks2,))
1725 + sched.update_state_after_alloc(req2, kv_blocks2, num_external_tokens=hit_tokens)
1726 +
1727 + sched_out2 = make_scheduler_output(
1728 + {req2.request_id: 1},
1729 + new_reqs={req2.request_id: kv_blocks2.get_block_ids()},
1730 + )
1731 + meta2 = sched.build_connector_meta(sched_out2)
1732 + assert meta2.load_event >= 0, "Expected a load event"
1733 + assert len(meta2.load_gpu_blocks) == num_blocks
1734 + assert len(meta2.load_cpu_blocks) == num_blocks
1735 +
1736 +
1737 +# ---------------------------------------------------------------------------
1738 +# Test 17: CP lazy target blocks are scaled correctly
1739 +# ---------------------------------------------------------------------------
1740 +@pytest.mark.parametrize("cp_world_size", [1, 2, 4])
1741 +def test_cp_lazy_target_blocks_scaling(cp_world_size: int) -> None:
1742 + """_estimate_lazy_target_blocks returns fewer blocks when cp_world_size > 1
1743 + because each virtual block covers more tokens."""
1744 + kv_cache_config = _make_kv_cache_config(num_blocks=16)
1745 + max_batched = 64
1746 +
1747 + target_base = SimpleCPUOffloadScheduler._estimate_lazy_target_blocks(
1748 + kv_cache_config, max_batched, cp_world_size=1
1749 + )
1750 + target_cp = SimpleCPUOffloadScheduler._estimate_lazy_target_blocks(
1751 + kv_cache_config, max_batched, cp_world_size=cp_world_size
1752 + )
1753 +
1754 + if cp_world_size == 1:
1755 + assert target_cp == target_base
1756 + else:
1757 + assert target_cp < target_base, (
1758 + f"cp_world_size={cp_world_size}: target_cp={target_cp} should be "
1759 + f"less than target_base={target_base}"
1760 + )
vllm/v1/simple_kv_offload/manager.py
+13 -7
@@ -82,6 +82,9 @@ class SimpleCPUOffloadScheduler:
82 vllm_config.kv_events_config is not None
83 and vllm_config.kv_events_config.enable_kv_cache_events
84 )
85 + dcp_world_size = vllm_config.parallel_config.decode_context_parallel_size
86 + pcp_world_size = vllm_config.parallel_config.prefill_context_parallel_size
87 + self.cp_world_size = dcp_world_size * pcp_world_size
88 self.block_size = scheduler_block_size
89 self.hash_block_size = hash_block_size
90 assert self.block_size % self.hash_block_size == 0
@@ -113,9 +116,6 @@ class SimpleCPUOffloadScheduler:
116 )
117
118 # TODO (yifan): maybe need to enable kv_cache_events and metrics_collector here.
116 - dcp_world_size = vllm_config.parallel_config.decode_context_parallel_size
117 - pcp_world_size = vllm_config.parallel_config.prefill_context_parallel_size
118 - assert dcp_world_size == 1 and pcp_world_size == 1
119 self.cpu_coordinator: KVCacheCoordinator = get_kv_cache_coordinator(
120 kv_cache_config=self.cpu_kv_cache_config,
121 max_model_len=vllm_config.model_config.max_model_len,
@@ -155,6 +155,7 @@ class SimpleCPUOffloadScheduler:
155 self._target_free = self._estimate_lazy_target_blocks(
156 kv_cache_config,
157 vllm_config.scheduler_config.max_num_batched_tokens,
158 + self.cp_world_size,
159 )
160 else:
161 self._target_free = 0
@@ -215,19 +216,22 @@ class SimpleCPUOffloadScheduler:
216
217 @staticmethod
218 def _estimate_lazy_target_blocks(
218 - kv_cache_config: "KVCacheConfig", max_num_batched_tokens: int
219 + kv_cache_config: "KVCacheConfig",
220 + max_num_batched_tokens: int,
221 + cp_world_size: int = 1,
222 ) -> int:
223 """GPU blocks to keep available (free/offloaded) per step in lazy mode."""
224 WATERMARK_RATIO = 1.0 # Reserve larger space to avoid running out of GPU blocks
225 target = 0
226 for g in kv_cache_config.kv_cache_groups:
227 spec = g.kv_cache_spec
228 + block_size = spec.block_size * cp_world_size
229 if isinstance(spec, MambaSpec):
230 target += 2
231 elif isinstance(spec, SlidingWindowSpec):
228 - target += cdiv(spec.sliding_window, spec.block_size) + 1
232 + target += cdiv(spec.sliding_window, block_size) + 1
233 else:
230 - target += cdiv(max_num_batched_tokens, spec.block_size)
234 + target += cdiv(max_num_batched_tokens, block_size)
235 return int(target * (1 + WATERMARK_RATIO))
236
237 def bind_gpu_block_pool(self, gpu_block_pool: BlockPool) -> None:
@@ -363,7 +367,9 @@ class SimpleCPUOffloadScheduler:
367 continue
368
369 # Number of blocks in the computed range for this group.
366 - g_block_size = kv_cache_groups[g].kv_cache_spec.block_size
370 + g_block_size = (
371 + kv_cache_groups[g].kv_cache_spec.block_size * self.cp_world_size
372 + )
373 n_computed_g = cdiv(total_computed_tokens, g_block_size)
374
375 # Back-trace: ext blocks sit at the tail of the computed range.