Batch CP HiCache backup submits across requests

CP HiCache write reservations must stay per radix node, but the transfer descriptor does not need to be per request. This changes the layer-end hook to group pending write states for the same source and layer, so bs>1 prefill emits one target D2H descriptor and one draft D2H descriptor per layer while preserving per-node metadata, rollback, and ack semantics.\n\nConstraint: CP shared-KV HiCache metadata, host slots, and radix acknowledgements remain per request/node.\nConstraint: TAI direct transfer kernels already accept flattened page descriptors, so no tai-kernel change is required.\nRejected: Merge HiCache reservations or radix nodes | would complicate rollback and split handling.\nRejected: Add collective synchronization for grouped backup | grouping is local descriptor construction and must not add rank-level sync.\nConfidence: high\nScope-risk: moderate\nDirective: Keep target and draft source notifications separate; final ack must wait for both when draft HiCache is attached.\nTested: local py_compile for cache_controller.py and test_hicache_controller_cp.py\nTested: local git diff --check\nTested: remote pytest test/registered/unit/managers/test_hicache_controller_cp.py test/registered/unit/mem_cache/test_cp_hicache_load_back_owner_lanes.py => 85 passed, 3 warnings\nNot-tested: full ETE bs>1 CP HiCache replay with admission gate removed\nNot-tested: Nsight/throughput validation of reduced D2H submit count
This commit is contained in:
laoyao0822
2026-06-03 10:23:30 +08:00
parent 19dcd6c4dc
commit b3913046b6
3 changed files with 655 additions and 98 deletions

View File

@@ -745,3 +745,149 @@ runtime / kernel 都消费 CPSharedKVBatchPlan descriptors
4. **MLA current/partial-current 和 direct store 已有 flatten kernel 基础,但还需用 bs>1 ETE 验证没有 fallback hot path。**
- 特别关注 FP8/BF16 两种 dtype、draft/EAGLE 路径、prefetch 关闭时 full/current reuse 是否仍启用。
---
## 16. 2026-06-03 W6 HiCache load/backup batch 化设计补充
### 当前结论
1. **L2->L1 load-back 的传输层已经接近 batch 化。**
- `HiRadixCache.load_back()` 仍按 request 调 `cache_controller.load_cp()`,但 `HiCacheController.start_loading()` 会对 `load_queue` / `draft_load_queue``CacheOperation.merge_ops()`
- 因此只要 scheduler 允许同一个 prefill batch 内多个 request 都在 `ready_to_load_host_cache()` 前完成 `init_load_back()`,目标 KV 和 draft KV 的 H2D per-layer transfer 会按 layer 合并成一次 `load_to_device_per_layer()` 调用。
- 已有低风险验证点:补一个单测,先连续调用两次 `load_cp()`,再调用一次 `start_loading()`,断言每层只有一次 target load、一次 draft load且 host/device indices 为两个 request 的 concat。
2. **D2H backup 仍没有吃到 bs>1 的收益。**
- `scheduler._prepare_hicache_write_backups_before_forward()` 当前仍然 `for req in batch.reqs: prepare_write_backup_for_req(req)`,这是正确的 per-request reservation/metadata 生命周期。
- 真正的问题在 `HiCacheController.on_layer_end()`:它遍历 `pending_layer_writes`,对每个 reservation 单独调用 `submit_write_cp_layer()`
- `submit_write_cp_layer()` 又对每个 reservation / layer 分别调用 `mem_pool_host.backup_from_device_per_layer()` 和 draft host pool backup。
- 底层 TAI direct PF/LF transfer kernel 接收 flatten indices本身可以处理多个 request 的 concat descriptor当前没有收益是 runtime 没有把多个 reservation 合并为 layer-level descriptor。
3. **scheduler 仍有 CP bs>1 admission gate。**
- `schedule_policy.py` 里仍有 `# TODO support cp with multiple requests`,当 `nsa_prefill_cp_in_seq_split``prefill_context_parallel_enabled` 打开时,`len(self.can_run_list) >= 1` 会拒绝继续加 request。
- 所以 W6 可以先把 load/backup runtime 改成 batch-ready 并用 unit test 锁住;真正 ETE 吃到收益还依赖后续移除/替换 admission gate。
### 推荐实现:保持 radix/metadata per request传输按 layer 聚合
目标不是把 HiCache node 合并,而是只合并底层 transfer descriptor
- reservation、host slot、draft host slot、`CpHiCacheNodeMetadata` 仍每个 request 独立;
- radix tree attach/rollback/ack 仍按 node_id 独立;
- layer-end hook 对同一 source/同一 layer 下所有 pending reservation 做一次 concat transfer
- final ack 可以继续 per node append避免改变 `writing_check()` 的队列语义;
- 不新增 collective不改变 owner-lane 分布语义。
### W6A为现有 load-back batch 行为补测试
文件:`test/registered/unit/managers/test_hicache_controller_cp.py`
新增测试目标:
1. `load_cp(req_a)` + `load_cp(req_b)` + 单次 `start_loading()`
- `host_pool.loads` 每个 layer 只有一次;
- 传入的 host/device indices 是两个 request 的 concat
- `ack_load_queue[0].node_ids == [node_a, node_b]`
- draft host pool 同样是每层一次。
2. zero-owned request 参与 batch 时仍不触发实际 host load但 node_id 保留在 ack 中。
### W6Bbackup layer-level grouping
文件:`python/sglang/srt/managers/cache_controller.py`
计划改造:
1. 抽出 helper`_submit_write_cp_layer_states(states, layer_id, submit_target, submit_draft)`
2. `submit_write_cp_layer(reservation, ...)` 保持公开 API但内部只传单个 state 给 helper保证 bs=1 行为不变。
3. `on_layer_end(layer_id, source)` 不再对每个 reservation 单独 submit而是收集当前 pending states一次调用 helper。
4. helper 内部:
- 计算每个 state 是否需要 target/draft
- 记录一个 layer event并挂到所有参与 states 上保活;
-`write_stream` 上等待每个 state 的 `start_event` 和 group layer event
- 对 target states concat `state.host_indices` / `state.physical_device_indices`,调用一次 target `backup_from_device_per_layer()`
- 对 draft states concat `state.draft_host_indices` / 同一 physical indices调用一次 draft backup
- 标记每个 state 的 target/draft layer completed
- 对完成全部层的 state 逐个 record finish_event、record_stream、append ack。
### W6B 单测要求
文件:`test/registered/unit/managers/test_hicache_controller_cp.py`
1. 两个 target-only reservation 同一 layer-end
- `allocator.device_pool.notify_layer_end_for_backup(0)``host_pool.layer_backups` 只新增一条;
- indices 为两个 reservation concat
- 未完成所有 layer 前 ack 为空;
- 最后一层后两个 node_id 都释放为 ack。
2. target + draft
- target source layer-end 只备 targetdraft source layer-end 只备 draft
- 每个 source 每层只产生一次 backup call
- final ack 等 target/draft 都完成。
3. zero-owned reservation
- 不增加实际 backup call
- 仍按层完成并最终 ack。
4. 直接调用 `submit_write_cp_layer()` 的旧路径仍保持单 reservation 行为。
### W6C后续优化不作为第一步
1. `load_cp()` 的 owner-lane allocation 仍按 request 调用;如果 admission 放开后 CPU overhead 明显,再做 batch owner allocation planner。
2. `CacheOperation.merge_ops()` 仍会 `torch.cat` 二次 concat如果 profile 显示 CPU cat 明显,再引入预分配 descriptor workspace。
3. backup grouping 首版不把 ack 合并成单个 `HiCacheAck`,避免影响 `writing_check()` 与 rollback unattached prepared backup 的语义。
4. 不引入新的 all-reduce/all-gatherW6 只改变 descriptor 聚合粒度。
### W6A 完成状态
已补 characterization tests锁住现有 L2->L1 load-back batch 行为:
- `test_cp_start_loading_batches_multiple_load_cp_requests_with_draft`
- 两个 `load_cp()` request 在一次 `start_loading()` 中合并;
- target 每层一次 `load_to_device_per_layer()`draft 每层一次;
- host/device descriptors 为两个 request 的 concat
- ack 保留两个 node_id。
- `test_cp_start_loading_keeps_zero_owned_load_ack_in_batched_load`
- zero-owned request 不产生实际 host transfer
- 但 zero-owned node_id 仍保留在 batched load ack 中。
远端验证:
```text
PYTHONPATH=python python -m pytest -q \
test/registered/unit/managers/test_hicache_controller_cp.py::TestHiCacheControllerCPLoad::test_cp_start_loading_batches_multiple_load_cp_requests_with_draft \
test/registered/unit/managers/test_hicache_controller_cp.py::TestHiCacheControllerCPLoad::test_cp_start_loading_keeps_zero_owned_load_ack_in_batched_load
=> 2 passed, 3 warnings
```
本地限制:本地 pytest 仍因缺少 `orjson` 无法收集该测试文件;本地只做了 `py_compile`
### W6B 完成状态
已实现 CP HiCache per-layer backup 的 layer-level grouping
- `HiCacheController.on_layer_end()` 不再逐 reservation 调 `submit_write_cp_layer()`;现在对当前 pending states 做一次 grouped submit。
- reservation、radix metadata、host slot、draft host slot、ack node_id 仍保持 per request。
- 每个 layer/source 的底层 transfer descriptor 会按 pending reservation concat
- target source一次 target `backup_from_device_per_layer()`
- draft source一次 draft `backup_from_device_per_layer()`
- zero-owned state 不参与实际 transfer但继续按 layer 完成并最终 ack。
- `submit_write_cp_layer()` 公开 API 保留,内部走单 state grouped helperbs=1 兼容路径不变。
- 没有新增 collective没有修改 owner-lane 语义;没有修改 tai-kernel。TAI direct transfer kernel 已支持 flatten indicesruntime concat 后即可吃到 bs>1 descriptor 收益。
新增测试:
- `test_cp_layer_hook_groups_target_backups_across_pending_reservations`
- `test_cp_layer_hook_groups_target_and_draft_backups_by_source`
- `test_cp_layer_hook_keeps_zero_owned_ack_in_grouped_backup`
远端验证:
```text
PYTHONPATH=python python -m pytest -q \
test/registered/unit/managers/test_hicache_controller_cp.py
=> 75 passed, 3 warnings
```
补充验证:
```text
PYTHONPATH=python python -m pytest -q \
test/registered/unit/mem_cache/test_cp_hicache_load_back_owner_lanes.py
=> 10 passed, 3 warnings
```

View File

@@ -452,18 +452,12 @@ class HiCacheController:
return
if source not in ("target", "draft"):
raise ValueError(f"Unknown CP HiCache layer backup source: {source}")
reservations = [
state.reservation for state in list(self.pending_layer_writes.values())
]
for reservation in reservations:
if reservation.node_id not in self.pending_layer_writes:
continue
self.submit_write_cp_layer(
reservation,
layer_id,
submit_target=(source == "target"),
submit_draft=(source == "draft"),
)
self._submit_write_cp_layer_states(
list(self.pending_layer_writes.values()),
layer_id,
submit_target=(source == "target"),
submit_draft=(source == "draft"),
)
def _start_storage_threads(self):
"""Start storage prefetch/backup threads and their queues.
@@ -1148,6 +1142,193 @@ class HiCacheController:
state.start_event.record()
return state
def _layer_write_state_done(self, state: HiCacheLayerWriteState) -> bool:
target_done = len(state.completed_target_layers) >= self.layer_num
draft_done = (
state.draft_host_indices is None
or len(state.completed_draft_layers)
>= self.draft_mem_pool_device.layer_num
)
return target_done and draft_done
@staticmethod
def _record_tensor_on_stream(tensor: Optional[torch.Tensor], stream) -> None:
if tensor is not None and tensor.is_cuda:
tensor.record_stream(stream)
@staticmethod
def _concat_layer_write_tensors(tensors: List[torch.Tensor]) -> torch.Tensor:
if len(tensors) == 1:
return tensors[0]
return torch.cat(tensors)
def _append_layer_write_ack(self, state: HiCacheLayerWriteState) -> None:
if state.ack_appended:
return
state.ack_appended = True
reservation = state.reservation
self.pending_layer_writes.pop(reservation.node_id, None)
self.ack_write_queue.append(
HiCacheAck(state.start_event, state.finish_event, [reservation.node_id])
)
logger.info(
"[CacheCtrl-write] submit_write_cp_layer final ack: node_id=%d logical_len=%d layers=%d draft=%s",
reservation.node_id,
reservation.metadata.logical_len,
state.total_layers,
reservation.draft_host_indices is not None,
)
def _submit_write_cp_layer_states(
self,
states: List[HiCacheLayerWriteState],
layer_id: int,
*,
submit_target: bool = True,
submit_draft: bool = True,
) -> None:
"""Submit one layer for a group of reserved CP host backups.
Radix/HiCache metadata remains per node. Only the transfer descriptor is
grouped so bs>1 batches issue one target D2H and one draft D2H per layer
instead of one D2H per request per layer.
"""
if not states:
return
unique_states: List[HiCacheLayerWriteState] = []
seen_state_ids = set()
for state in states:
state_id = id(state)
if state_id in seen_state_ids:
continue
seen_state_ids.add(state_id)
unique_states.append(state)
for state in unique_states:
if layer_id < 0 or layer_id >= state.total_layers:
raise ValueError(
f"layer_id={layer_id} is outside CP HiCache backup layer range "
f"[0, {state.total_layers}) for node_id={state.reservation.node_id}"
)
target_states = [
state
for state in unique_states
if submit_target
and layer_id < self.layer_num
and layer_id not in state.completed_target_layers
]
draft_states = [
state
for state in unique_states
if submit_draft
and state.draft_host_indices is not None
and layer_id < self.draft_mem_pool_device.layer_num
and layer_id not in state.completed_draft_layers
]
if not target_states and not draft_states:
return
active_states: List[HiCacheLayerWriteState] = []
seen_active_ids = set()
for state in target_states + draft_states:
state_id = id(state)
if state_id in seen_active_ids:
continue
seen_active_ids.add(state_id)
active_states.append(state)
layer_event = device_module.Event()
layer_event.record()
for state in active_states:
state.layer_events.append(layer_event)
target_transfer_states = [
state
for state in target_states
if state.physical_device_indices is not None
and len(state.physical_device_indices) > 0
]
draft_transfer_states = [
state
for state in draft_states
if state.physical_device_indices is not None
and len(state.physical_device_indices) > 0
]
grouped_tensors: List[torch.Tensor] = []
final_states: List[HiCacheLayerWriteState] = []
with device_module.stream(self.write_stream):
for state in active_states:
state.start_event.wait(self.write_stream)
layer_event.wait(self.write_stream)
if target_transfer_states:
target_host_indices = self._concat_layer_write_tensors(
[state.host_indices for state in target_transfer_states]
)
target_device_indices = self._concat_layer_write_tensors(
[
state.physical_device_indices
for state in target_transfer_states
]
)
self.mem_pool_host.backup_from_device_per_layer(
self.mem_pool_device,
target_host_indices,
target_device_indices,
layer_id,
self.io_backend,
)
grouped_tensors.extend([target_host_indices, target_device_indices])
if draft_transfer_states:
draft_host_indices = self._concat_layer_write_tensors(
[state.draft_host_indices for state in draft_transfer_states]
)
draft_device_indices = self._concat_layer_write_tensors(
[
state.physical_device_indices
for state in draft_transfer_states
]
)
self.draft_mem_pool_host.backup_from_device_per_layer(
self.draft_mem_pool_device,
draft_host_indices,
draft_device_indices,
layer_id,
self.io_backend,
)
grouped_tensors.extend([draft_host_indices, draft_device_indices])
for tensor in grouped_tensors:
self._record_tensor_on_stream(tensor, self.write_stream)
for state in target_states:
state.completed_target_layers.add(layer_id)
for state in draft_states:
state.completed_draft_layers.add(layer_id)
final_states = [
state
for state in active_states
if self._layer_write_state_done(state) and not state.ack_appended
]
for state in final_states:
state.finish_event.record()
self._record_tensor_on_stream(state.host_indices, self.write_stream)
self._record_tensor_on_stream(
state.physical_device_indices, self.write_stream
)
self._record_tensor_on_stream(
state.draft_host_indices, self.write_stream
)
for state in final_states:
self._append_layer_write_ack(state)
def submit_write_cp_layer(
self,
reservation: HiCacheWriteReservation,
@@ -1163,92 +1344,11 @@ class HiCacheController:
"""
state = self._get_or_create_layer_write_state(reservation)
if layer_id < 0 or layer_id >= state.total_layers:
raise ValueError(
f"layer_id={layer_id} is outside CP HiCache backup layer range "
f"[0, {state.total_layers}) for node_id={reservation.node_id}"
)
needs_target = (
submit_target
and layer_id < self.layer_num
and layer_id not in state.completed_target_layers
)
needs_draft = (
submit_draft
and state.draft_host_indices is not None
and layer_id < self.draft_mem_pool_device.layer_num
and layer_id not in state.completed_draft_layers
)
if not needs_target and not needs_draft:
return
layer_event = device_module.Event()
layer_event.record()
state.layer_events.append(layer_event)
if len(reservation.physical_device_indices) > 0:
with device_module.stream(self.write_stream):
state.start_event.wait(self.write_stream)
layer_event.wait(self.write_stream)
if needs_target:
self.mem_pool_host.backup_from_device_per_layer(
self.mem_pool_device,
state.host_indices,
state.physical_device_indices,
layer_id,
self.io_backend,
)
if needs_draft:
self.draft_mem_pool_host.backup_from_device_per_layer(
self.draft_mem_pool_device,
state.draft_host_indices,
state.physical_device_indices,
layer_id,
self.io_backend,
)
if needs_target:
state.completed_target_layers.add(layer_id)
if needs_draft:
state.completed_draft_layers.add(layer_id)
target_done = len(state.completed_target_layers) >= self.layer_num
draft_done = (
state.draft_host_indices is None
or len(state.completed_draft_layers)
>= self.draft_mem_pool_device.layer_num
)
if not target_done or not draft_done:
return
if state.ack_appended:
return
with device_module.stream(self.write_stream):
state.finish_event.record()
if state.host_indices is not None and state.host_indices.is_cuda:
state.host_indices.record_stream(self.write_stream)
if (
state.physical_device_indices is not None
and state.physical_device_indices.is_cuda
):
state.physical_device_indices.record_stream(self.write_stream)
if (
state.draft_host_indices is not None
and state.draft_host_indices.is_cuda
):
state.draft_host_indices.record_stream(self.write_stream)
state.ack_appended = True
self.pending_layer_writes.pop(reservation.node_id, None)
self.ack_write_queue.append(
HiCacheAck(state.start_event, state.finish_event, [reservation.node_id])
)
logger.info(
"[CacheCtrl-write] submit_write_cp_layer final ack: node_id=%d logical_len=%d layers=%d draft=%s",
reservation.node_id,
reservation.metadata.logical_len,
state.total_layers,
reservation.draft_host_indices is not None,
self._submit_write_cp_layer_states(
[state],
layer_id,
submit_target=submit_target,
submit_draft=submit_draft,
)
def submit_write_cp_per_layer(

View File

@@ -1157,6 +1157,177 @@ class TestHiCacheControllerCPWrite(CustomTestCase):
self.assertEqual(controller.ack_write_queue[0].node_ids, [84])
self.assertEqual(host_pool.backups, [])
def test_cp_layer_hook_groups_target_backups_across_pending_reservations(self):
class SequentialHostPool(FakeHostPool):
def __init__(self, alloc_results):
super().__init__(torch.empty((0,), dtype=torch.int64))
self.alloc_results = [result.clone() for result in alloc_results]
def alloc(self, need_size):
self.alloc_calls.append(need_size)
if not self.alloc_results:
return None
return self.alloc_results.pop(0).clone()
host_pool = SequentialHostPool(
[
torch.tensor([100, 101, 102, 103], dtype=torch.int64),
torch.tensor([200, 201, 202, 203], dtype=torch.int64),
]
)
allocator = FakeAllocator()
allocator.device_pool = FakeDevicePool("target", layer_num=2)
controller = self.make_controller(host_pool, allocator=allocator, cp_rank=1)
reservation_a = controller.reserve_write_cp(
torch.arange(4, 20, dtype=torch.int64), node_id=501
)
reservation_b = controller.reserve_write_cp(
torch.arange(20, 36, dtype=torch.int64), node_id=502
)
controller.submit_write_cp_per_layer(reservation_a, catch_up_all_layers=False)
controller.submit_write_cp_per_layer(reservation_b, catch_up_all_layers=False)
allocator.device_pool.notify_layer_end_for_backup(0)
self.assertEqual(len(host_pool.layer_backups), 1)
host_indices, device_indices, layer_id, device_pool = host_pool.layer_backups[0]
self.assertEqual(
host_indices.tolist(), [100, 101, 102, 103, 200, 201, 202, 203]
)
self.assertEqual(device_indices.tolist(), [4, 5, 6, 7, 8, 9, 10, 11])
self.assertEqual(layer_id, 0)
self.assertIs(device_pool, allocator.device_pool)
self.assertEqual(controller.ack_write_queue, [])
allocator.device_pool.notify_layer_end_for_backup(1)
self.assertEqual(len(host_pool.layer_backups), 2)
self.assertEqual([backup[2] for backup in host_pool.layer_backups], [0, 1])
self.assertEqual(len(controller.ack_write_queue), 2)
self.assertEqual(
[ack.node_ids for ack in controller.ack_write_queue], [[501], [502]]
)
def test_cp_layer_hook_groups_target_and_draft_backups_by_source(self):
class SequentialHostPool(FakeHostPool):
def __init__(self, alloc_results):
super().__init__(torch.empty((0,), dtype=torch.int64))
self.alloc_results = [result.clone() for result in alloc_results]
def alloc(self, need_size):
self.alloc_calls.append(need_size)
if not self.alloc_results:
return None
return self.alloc_results.pop(0).clone()
host_pool = SequentialHostPool(
[
torch.tensor([100, 101, 102, 103], dtype=torch.int64),
torch.tensor([200, 201, 202, 203], dtype=torch.int64),
]
)
draft_host_pool = SequentialHostPool(
[
torch.tensor([300, 301, 302, 303], dtype=torch.int64),
torch.tensor([400, 401, 402, 403], dtype=torch.int64),
]
)
allocator = FakeAllocator()
allocator.device_pool = FakeDevicePool("target", layer_num=2)
draft_device_pool = FakeDevicePool("draft", layer_num=2)
controller = self.make_controller(
host_pool,
allocator=allocator,
cp_rank=1,
draft_host_pool=draft_host_pool,
draft_mem_pool_device=draft_device_pool,
)
reservation_a = controller.reserve_write_cp(
torch.arange(4, 20, dtype=torch.int64), node_id=601
)
reservation_b = controller.reserve_write_cp(
torch.arange(20, 36, dtype=torch.int64), node_id=602
)
controller.submit_write_cp_per_layer(reservation_a, catch_up_all_layers=False)
controller.submit_write_cp_per_layer(reservation_b, catch_up_all_layers=False)
allocator.device_pool.notify_layer_end_for_backup(0)
self.assertEqual(len(host_pool.layer_backups), 1)
self.assertEqual(len(draft_host_pool.layer_backups), 0)
self.assertEqual(
host_pool.layer_backups[0][0].tolist(),
[100, 101, 102, 103, 200, 201, 202, 203],
)
self.assertEqual(
host_pool.layer_backups[0][1].tolist(),
[4, 5, 6, 7, 8, 9, 10, 11],
)
draft_device_pool.notify_layer_end_for_backup(0)
self.assertEqual(len(draft_host_pool.layer_backups), 1)
self.assertEqual(
draft_host_pool.layer_backups[0][0].tolist(),
[300, 301, 302, 303, 400, 401, 402, 403],
)
self.assertEqual(
draft_host_pool.layer_backups[0][1].tolist(),
[4, 5, 6, 7, 8, 9, 10, 11],
)
self.assertEqual(controller.ack_write_queue, [])
allocator.device_pool.notify_layer_end_for_backup(1)
self.assertEqual(len(host_pool.layer_backups), 2)
self.assertEqual(controller.ack_write_queue, [])
draft_device_pool.notify_layer_end_for_backup(1)
self.assertEqual(len(draft_host_pool.layer_backups), 2)
self.assertEqual(len(controller.ack_write_queue), 2)
self.assertEqual(
[ack.node_ids for ack in controller.ack_write_queue], [[601], [602]]
)
def test_cp_layer_hook_keeps_zero_owned_ack_in_grouped_backup(self):
class SequentialHostPool(FakeHostPool):
def __init__(self, alloc_results):
super().__init__(torch.empty((0,), dtype=torch.int64))
self.alloc_results = [result.clone() for result in alloc_results]
def alloc(self, need_size):
self.alloc_calls.append(need_size)
if not self.alloc_results:
return None
return self.alloc_results.pop(0).clone()
host_pool = SequentialHostPool(
[torch.tensor([100, 101, 102, 103], dtype=torch.int64)]
)
allocator = FakeAllocator()
allocator.device_pool = FakeDevicePool("target", layer_num=2)
controller = self.make_controller(host_pool, allocator=allocator, cp_rank=1)
zero_owned = controller.reserve_write_cp(
torch.arange(4, 8, dtype=torch.int64), node_id=701
)
owned = controller.reserve_write_cp(
torch.arange(8, 24, dtype=torch.int64), node_id=702
)
controller.submit_write_cp_per_layer(zero_owned, catch_up_all_layers=False)
controller.submit_write_cp_per_layer(owned, catch_up_all_layers=False)
allocator.device_pool.notify_layer_end_for_backup(0)
allocator.device_pool.notify_layer_end_for_backup(1)
self.assertEqual(len(host_pool.layer_backups), 2)
for host_indices, device_indices, _, _ in host_pool.layer_backups:
self.assertEqual(host_indices.tolist(), [100, 101, 102, 103])
self.assertEqual(device_indices.tolist(), [4, 5, 6, 7])
self.assertEqual(len(controller.ack_write_queue), 2)
self.assertEqual(
[ack.node_ids for ack in controller.ack_write_queue], [[701], [702]]
)
def test_cp_layer_hook_waits_for_draft_source_before_final_ack(self):
host_pool = FakeHostPool(torch.tensor([100, 101, 102, 103], dtype=torch.int64))
draft_host_pool = FakeHostPool(
@@ -1227,6 +1398,146 @@ class TestHiCacheControllerCPWrite(CustomTestCase):
class TestHiCacheControllerCPLoad(TestHiCacheControllerCPWrite):
def test_cp_start_loading_batches_multiple_load_cp_requests_with_draft(self):
class SequentialOwnerAllocator(FakeAllocator):
def __init__(self, alloc_results):
super().__init__()
self.alloc_results = [result.clone() for result in alloc_results]
self.device_pool = FakeDevicePool("target", layer_num=2)
def alloc_pages_with_owners(self, page_owners):
owners = list(page_owners)
self.owner_alloc_calls.append(owners)
if not self.alloc_results:
return None
return self.alloc_results.pop(0).clone()
host_pool = FakeHostPool(torch.empty((0,), dtype=torch.int64))
draft_host_pool = FakeHostPool(torch.empty((0,), dtype=torch.int64))
allocator = SequentialOwnerAllocator(
[
torch.arange(64, 80, dtype=torch.int64),
torch.arange(80, 96, dtype=torch.int64),
]
)
draft_device_pool = FakeDevicePool("draft", layer_num=2)
controller = self.make_controller(
host_pool,
allocator=allocator,
cp_rank=1,
draft_host_pool=draft_host_pool,
draft_mem_pool_device=draft_device_pool,
)
node_a = TreeNode()
node_a.host_len = 16
node_a.cp_hicache = CpHiCacheNodeMetadata(
logical_len=16,
owned_positions=torch.tensor([4, 5, 6, 7], dtype=torch.int64),
host_indices=torch.tensor([100, 101, 102, 103], dtype=torch.int64),
draft_host_indices=torch.tensor([300, 301, 302, 303], dtype=torch.int64),
page_owners=torch.tensor([3, 0, 1, 2], dtype=torch.int8),
page_size=4,
)
node_b = TreeNode()
node_b.host_len = 16
node_b.cp_hicache = CpHiCacheNodeMetadata(
logical_len=16,
owned_positions=torch.tensor([4, 5, 6, 7], dtype=torch.int64),
host_indices=torch.tensor([200, 201, 202, 203], dtype=torch.int64),
draft_host_indices=torch.tensor([400, 401, 402, 403], dtype=torch.int64),
page_owners=torch.tensor([3, 0, 1, 2], dtype=torch.int8),
page_size=4,
)
device_indices_a = controller.load_cp([node_a], node_id=201)
device_indices_b = controller.load_cp([node_b], node_id=202)
controller.start_loading()
self.assertEqual(device_indices_a.tolist(), list(range(64, 80)))
self.assertEqual(device_indices_b.tolist(), list(range(80, 96)))
self.assertEqual(
allocator.owner_alloc_calls,
[[3, 0, 1, 2], [3, 0, 1, 2]],
)
self.assertEqual(len(host_pool.loads), 2)
self.assertEqual([load[2] for load in host_pool.loads], [0, 1])
for host_indices, device_indices, _, device_pool in host_pool.loads:
self.assertEqual(
host_indices.tolist(),
[100, 101, 102, 103, 200, 201, 202, 203],
)
self.assertEqual(device_indices.tolist(), [20, 21, 22, 23, 24, 25, 26, 27])
self.assertIs(device_pool, allocator.device_pool)
self.assertEqual(len(draft_host_pool.loads), 2)
self.assertEqual([load[2] for load in draft_host_pool.loads], [0, 1])
for host_indices, device_indices, _, device_pool in draft_host_pool.loads:
self.assertEqual(
host_indices.tolist(),
[300, 301, 302, 303, 400, 401, 402, 403],
)
self.assertEqual(device_indices.tolist(), [20, 21, 22, 23, 24, 25, 26, 27])
self.assertIs(device_pool, draft_device_pool)
self.assertEqual(len(controller.ack_load_queue), 1)
self.assertEqual(controller.ack_load_queue[0].node_ids, [201, 202])
def test_cp_start_loading_keeps_zero_owned_load_ack_in_batched_load(self):
class SequentialOwnerAllocator(FakeAllocator):
def __init__(self, alloc_results):
super().__init__()
self.alloc_results = [result.clone() for result in alloc_results]
self.device_pool = FakeDevicePool("target", layer_num=2)
def alloc_pages_with_owners(self, page_owners):
owners = list(page_owners)
self.owner_alloc_calls.append(owners)
if not self.alloc_results:
return None
return self.alloc_results.pop(0).clone()
host_pool = FakeHostPool(torch.empty((0,), dtype=torch.int64))
allocator = SequentialOwnerAllocator(
[
torch.arange(64, 68, dtype=torch.int64),
torch.arange(80, 96, dtype=torch.int64),
]
)
controller = self.make_controller(host_pool, allocator=allocator, cp_rank=1)
zero_owned_node = TreeNode()
zero_owned_node.host_len = 4
zero_owned_node.cp_hicache = CpHiCacheNodeMetadata(
logical_len=4,
owned_positions=torch.empty((0,), dtype=torch.int64),
host_indices=torch.empty((0,), dtype=torch.int64),
page_owners=torch.tensor([0], dtype=torch.int8),
page_size=4,
)
owned_node = TreeNode()
owned_node.host_len = 16
owned_node.cp_hicache = CpHiCacheNodeMetadata(
logical_len=16,
owned_positions=torch.tensor([4, 5, 6, 7], dtype=torch.int64),
host_indices=torch.tensor([200, 201, 202, 203], dtype=torch.int64),
page_owners=torch.tensor([3, 0, 1, 2], dtype=torch.int8),
page_size=4,
)
zero_visible_indices = controller.load_cp([zero_owned_node], node_id=301)
owned_visible_indices = controller.load_cp([owned_node], node_id=302)
controller.start_loading()
self.assertEqual(zero_visible_indices.tolist(), [64, 65, 66, 67])
self.assertEqual(owned_visible_indices.tolist(), list(range(80, 96)))
self.assertEqual(allocator.owner_alloc_calls, [[0], [3, 0, 1, 2]])
self.assertEqual(len(host_pool.loads), 2)
self.assertEqual([load[2] for load in host_pool.loads], [0, 1])
for host_indices, device_indices, _, _ in host_pool.loads:
self.assertEqual(host_indices.tolist(), [200, 201, 202, 203])
self.assertEqual(device_indices.tolist(), [24, 25, 26, 27])
self.assertEqual(len(controller.ack_load_queue), 1)
self.assertEqual(controller.ack_load_queue[0].node_ids, [301, 302])
def test_cp_load_allocates_full_logical_locs_and_transfers_owned_physical_locs(self):
host_pool = FakeHostPool(torch.tensor([100, 101, 102, 103], dtype=torch.int64))
allocator = FakeAllocator(alloc_result=torch.arange(64, 80, dtype=torch.int64))