Files
sglang/benchmark/hicache/bench_cp_hicache_allocator_overhead.py
laoyao0822 401de0f8ce Reduce CP HiCache L2 allocator scan cost
Host HiCache reservations were paying token-level free-slot scans when trying to preserve page contiguity. The allocator now keeps a lazy page-extent index so availability checks and contiguous-preferred allocations avoid materializing the full 220GB-equivalent free-slot metadata path.

The companion benchmark models steady-state L2 churn near full occupancy, including burn-in and historical node-size effects, so LPF/RDMA descriptor quality can be separated from ETE noise.

Constraint: CP HiCache host allocations are page-shaped, but existing callers may still read free_slots directly.
Rejected: Sort and scan free_slots on each alloc_contiguous_preferred call | measured ms-level CPU overhead on 220GB-equivalent metadata.
Rejected: Remove free_slots compatibility | storage/tests still rely on the public tensor surface.
Confidence: medium
Scope-risk: moderate
Directive: Do not reintroduce per-allocation full free_slots scans on HostKVCache; preserve page-extent metadata or benchmark before changing allocator shape.
Tested: Local py_compile for memory_pool_host.py, allocator benchmark, and related tests.
Tested: Local test_cp_hicache_allocator_bench.py 10 passed.
Tested: Remote g0034 test_hicache_controller_cp.py 67 passed; test_cp_hicache_allocator_bench.py 10 passed.
Tested: Remote 220GB-equivalent host_churn benchmark: contiguous path reduced from ms-level to ~30-292us p50 depending on fragmentation.
Not-tested: Full CUDA ETE run after allocator change.
Not-tested: Production long-run fragmentation behavior under live traffic.
2026-06-02 23:35:45 +08:00

1481 lines
54 KiB
Python

from __future__ import annotations
"""Microbench CP HiCache allocator/control-path overhead.
This benchmark targets metadata-only allocator costs that matter for large
HiCache deployments. It intentionally does not allocate real 220GB host KV
buffers; instead it constructs the same token/page index metadata sizes used by
HostKVCache and CPSharedPagedTokenToKVPoolAllocator.
Examples:
# Host allocator model for 220GB-equivalent metadata.
PYTHONPATH=. python benchmark/hicache/bench_cp_hicache_allocator_overhead.py \
--bench host --host-sizes-gb 220 --request-pages 1,8,64,512 \
--patterns contiguous_fifo,fragmented_prefix_later_run,random_fragmented
# Steady-state L2 host churn model near full HiCache occupancy.
PYTHONPATH=. python benchmark/hicache/bench_cp_hicache_allocator_overhead.py \
--bench host_churn --host-sizes-gb 220 --request-pages 16,64,512 \
--host-churn-occupancies 0.90,0.97,0.99 \
--host-churn-evict-pages 64,512,2048 \
--host-churn-eviction-patterns oldest,random
# L1 allocator path on CUDA, stubbing sgl_kernel import if needed.
PYTHONPATH=python:. python benchmark/hicache/bench_cp_hicache_allocator_overhead.py \
--bench l1 --device cuda --stub-sgl-kernel --physical-pages 8192,32768 \
--request-pages 8,64,512 --l1-impl current,fifo \
--l1-ops stats,free_room_stats,select_only,alloc_pages
"""
import argparse
import json
import math
import os
import random
import statistics
import sys
import threading
import time
import types
from dataclasses import asdict, dataclass
from typing import Callable, Iterable, Optional
import torch
@dataclass(frozen=True)
class BenchResult:
bench: str
impl: str
pattern: str
device: str
total_pages: int
request_pages: int
page_size: int
repeat: int
mean_us: float
p50_us: float
p95_us: float
p99_us: float
min_us: float
max_us: float
contiguous_ratio: float
@dataclass(frozen=True)
class HostChurnBenchResult:
bench: str
impl: str
pattern: str
device: str
total_pages: int
request_pages: int
page_size: int
repeat: int
target_occupancy: float
evict_pages: int
prefill_node_pages: int
burnin: int
mean_us: float
p50_us: float
p95_us: float
p99_us: float
min_us: float
max_us: float
contiguous_ratio: float
page_first_descriptors_per_op: int
lpf_descriptors_mean: float
lpf_descriptor_ratio_mean: float
run_count_p50: float
run_count_p95: float
max_run_pages_mean: float
max_run_pages_p50: float
def _parse_int_list(value: str | Iterable[int]) -> list[int]:
if isinstance(value, str):
return [int(item.strip()) for item in value.split(",") if item.strip()]
return [int(item) for item in value]
def _parse_float_list(value: str | Iterable[float]) -> list[float]:
if isinstance(value, str):
return [float(item.strip()) for item in value.split(",") if item.strip()]
return [float(item) for item in value]
def _host_pages_from_gb(size_gb: float, *, bytes_per_token: int, page_size: int) -> int:
if size_gb <= 0:
raise ValueError(f"size_gb must be positive, got {size_gb}")
if bytes_per_token <= 0:
raise ValueError(f"bytes_per_token must be positive, got {bytes_per_token}")
if page_size <= 0:
raise ValueError(f"page_size must be positive, got {page_size}")
return int(math.floor(size_gb * 1e9 / float(bytes_per_token * page_size)))
def _percentile(samples: list[float], percentile: float) -> float:
if not samples:
return 0.0
ordered = sorted(samples)
if len(ordered) == 1:
return ordered[0]
rank = (len(ordered) - 1) * percentile / 100.0
lo = int(math.floor(rank))
hi = int(math.ceil(rank))
if lo == hi:
return ordered[lo]
return ordered[lo] * (hi - rank) + ordered[hi] * (rank - lo)
def _page_ids_to_token_slots(page_ids: list[int] | torch.Tensor, page_size: int) -> torch.Tensor:
pages = torch.as_tensor(page_ids, dtype=torch.int64)
offsets = torch.arange(page_size, dtype=torch.int64)
return (pages[:, None] * page_size + offsets[None, :]).reshape(-1).contiguous()
def _make_host_page_order(
*, total_pages: int, request_pages: int, pattern: str, seed: int
) -> list[int]:
if total_pages <= 0:
raise ValueError(f"total_pages must be positive, got {total_pages}")
if request_pages <= 0:
raise ValueError(f"request_pages must be positive, got {request_pages}")
if request_pages > total_pages:
raise ValueError(
f"request_pages must be <= total_pages, got {request_pages=} {total_pages=}"
)
if pattern == "contiguous_fifo":
return list(range(total_pages))
if pattern == "random_fragmented":
generator = torch.Generator(device="cpu")
generator.manual_seed(seed)
return torch.randperm(total_pages, generator=generator).tolist()
if pattern == "fragmented_prefix_later_run":
run_start = min(max(1, total_pages // 3), total_pages - request_pages)
run = list(range(run_start, run_start + request_pages))
used = set(run)
# Put non-contiguous pages before the usable run so FIFO is poor but a
# later contiguous run exists. Prefer descending odd/even pages because
# consecutive physical pages are unlikely to appear in the prefix.
scattered: list[int] = []
for page in range(total_pages - 1, -1, -2):
if page not in used:
scattered.append(page)
used.add(page)
if len(scattered) >= request_pages:
break
for page in range(total_pages - 2, -1, -2):
if len(scattered) >= request_pages:
break
if page not in used:
scattered.append(page)
used.add(page)
rest = [page for page in range(total_pages) if page not in used]
return scattered + run + rest
raise ValueError(f"unsupported host pattern: {pattern}")
def _make_host_free_slots(
*, total_pages: int, request_pages: int, page_size: int, pattern: str, seed: int
) -> torch.Tensor:
return _page_ids_to_token_slots(
_make_host_page_order(
total_pages=total_pages,
request_pages=request_pages,
pattern=pattern,
seed=seed,
),
page_size,
)
class StandaloneHostAllocator:
"""Small metadata-only copy of HostKVCache allocation behavior."""
def __init__(self, *, page_size: int, free_slots: torch.Tensor):
self.page_size = int(page_size)
self.free_slots = free_slots
self.lock = threading.RLock()
def available_size(self) -> int:
return int(self.free_slots.numel())
def alloc(self, need_size: int) -> Optional[torch.Tensor]:
assert need_size % self.page_size == 0
if need_size > self.available_size():
return None
select_index = self.free_slots[:need_size]
self.free_slots = self.free_slots[need_size:]
return select_index
def alloc_contiguous_preferred(self, need_size: int) -> Optional[torch.Tensor]:
assert need_size % self.page_size == 0
if need_size > self.available_size():
return None
if need_size == 0:
return self.alloc(need_size)
fifo_prefix = self.free_slots[:need_size]
expected_prefix = fifo_prefix[:1] + torch.arange(
need_size, dtype=fifo_prefix.dtype, device=fifo_prefix.device
)
if torch.equal(fifo_prefix, expected_prefix):
return self.alloc(need_size)
page_size = int(self.page_size)
need_pages = need_size // page_size
page_count = int(self.free_slots.numel()) // page_size
if page_count < need_pages:
return self.alloc(need_size)
page_slots = self.free_slots[: page_count * page_size].view(page_count, page_size)
page_offsets = torch.arange(
page_size, dtype=page_slots.dtype, device=page_slots.device
)
page_is_contiguous = torch.all(
page_slots == (page_slots[:, :1] + page_offsets), dim=1
)
if not bool(page_is_contiguous.any()):
return self.alloc(need_size)
chunk_indices = torch.arange(page_count, dtype=torch.int64)[page_is_contiguous.cpu()]
page_starts = page_slots[:, 0][page_is_contiguous]
sorted_starts, order = torch.sort(page_starts)
sorted_chunks = chunk_indices[order.cpu()]
run_start = -1
if need_pages == 1:
run_start = 0
else:
current_run_start = 0
current_run_len = 1
diffs = sorted_starts[1:] - sorted_starts[:-1]
for offset, diff in enumerate(diffs.tolist(), start=1):
if diff == page_size:
current_run_len += 1
if current_run_len >= need_pages:
run_start = current_run_start
break
else:
current_run_start = offset
current_run_len = 1
if run_start < 0:
return self.alloc(need_size)
selected_chunks = sorted_chunks[run_start : run_start + need_pages]
token_offsets = (
selected_chunks[:, None] * page_size
+ torch.arange(page_size, dtype=torch.int64)[None, :]
).reshape(-1)
select_index = self.free_slots[token_offsets]
keep_mask = torch.ones(self.free_slots.numel(), dtype=torch.bool)
keep_mask[token_offsets] = False
self.free_slots = self.free_slots[keep_mask]
return select_index
def free(self, indices: torch.Tensor) -> int:
self.free_slots = torch.cat([self.free_slots, indices.cpu()])
return int(indices.numel())
def _compute_owner_lane_free_room_deficits(
*,
required: list[int],
available: list[int],
capacities: list[int],
target_ratio: float,
trigger_ratio: float,
) -> list[int]:
deficits: list[int] = []
for req, avail, capacity in zip(required, available, capacities):
target_room = (
int(math.ceil(float(capacity) * float(target_ratio)))
if capacity > 0 and target_ratio > 0
else 0
)
trigger_room = (
int(math.ceil(float(capacity) * float(trigger_ratio)))
if capacity > 0 and trigger_ratio > 0
else 0
)
if int(avail) >= int(req) + trigger_room:
deficits.append(0)
else:
deficits.append(max(0, int(req) + target_room - int(avail)))
return deficits
class StandaloneCPSharedPagedAllocator:
"""Metadata-only copy of the CP shared-KV page owner allocator.
This intentionally mirrors the current Python/Torch control path used by
``CPSharedPagedTokenToKVPoolAllocator`` so CPU-only environments can measure
the allocator shape without importing the full SGLang runtime dependency
stack. The benchmark still uses the production allocator when imports are
available.
"""
def __init__(
self,
*,
physical_pages: int,
page_size: int,
cp_size: int,
device: torch.device,
):
self.physical_size = int(physical_pages) * int(page_size)
self.page_size = int(page_size)
self.cp_size = int(cp_size)
self.device = device
logical_pages = int(physical_pages) * int(cp_size)
self._owner_free_pages = None
self._owner_release_pages = None
self._flat_free_pages_cache = None
self._flat_release_pages_cache = None
self.free_pages = torch.arange(
1, logical_pages + 1, dtype=torch.int64, device=device
)
self.release_pages = torch.empty((0,), dtype=torch.int64, device=device)
self.debug_mode = False
def _empty_pages(self) -> torch.Tensor:
return torch.empty((0,), dtype=torch.int64, device=self.device)
def _split_owner_buckets(
self, pages: Optional[torch.Tensor]
) -> Optional[list[torch.Tensor]]:
if pages is None:
return None
if pages.numel() == 0:
return [torch.empty_like(pages) for _ in range(self.cp_size)]
owner_ids = torch.remainder(pages - 1, self.cp_size)
buckets: list[torch.Tensor] = []
for owner in range(self.cp_size):
owner_pages = pages[owner_ids == owner]
if owner_pages.numel() > 1:
owner_pages, _ = torch.sort(owner_pages)
buckets.append(owner_pages)
return buckets
def _materialize_owner_buckets(
self, buckets: Optional[list[torch.Tensor]]
) -> Optional[torch.Tensor]:
if buckets is None:
return None
non_empty = [bucket for bucket in buckets if bucket.numel() > 0]
if not non_empty:
return self._empty_pages()
return torch.cat(non_empty)
@property
def free_pages(self):
if self._owner_free_pages is not None:
if self._flat_free_pages_cache is None:
self._flat_free_pages_cache = self._materialize_owner_buckets(
self._owner_free_pages
)
return self._flat_free_pages_cache
return self._flat_free_pages_cache
@free_pages.setter
def free_pages(self, pages):
self._flat_free_pages_cache = pages
self._owner_free_pages = self._split_owner_buckets(pages)
@property
def release_pages(self):
if self._owner_release_pages is not None:
if self._flat_release_pages_cache is None:
self._flat_release_pages_cache = self._materialize_owner_buckets(
self._owner_release_pages
)
return self._flat_release_pages_cache
return self._flat_release_pages_cache
@release_pages.setter
def release_pages(self, pages):
self._flat_release_pages_cache = pages
self._owner_release_pages = self._split_owner_buckets(pages)
def _owner_bucket_counts(self, buckets: Optional[list[torch.Tensor]]) -> list[int]:
if buckets is None:
return [0 for _ in range(self.cp_size)]
return [int(bucket.numel()) for bucket in buckets]
def _owner_available_counts(self) -> list[int]:
free_counts = self._owner_bucket_counts(self._owner_free_pages)
release_counts = self._owner_bucket_counts(self._owner_release_pages)
return [
free_count + release_count
for free_count, release_count in zip(free_counts, release_counts)
]
def _consume_owner_bucket_prefix(
self,
*,
release: bool,
counts_by_owner: list[int],
) -> None:
target_attr = "_owner_release_pages" if release else "_owner_free_pages"
cache_attr = "_flat_release_pages_cache" if release else "_flat_free_pages_cache"
buckets = getattr(self, target_attr)
mutated = False
for owner, count in enumerate(counts_by_owner):
if count <= 0:
continue
buckets[owner] = buckets[owner][count:]
mutated = True
if mutated:
setattr(self, target_attr, buckets)
setattr(self, cache_attr, None)
def compute_owner_lane_stats(
self,
page_compute_owners: list[int],
) -> tuple[list[int], list[int], list[int]]:
required = [0 for _ in range(self.cp_size)]
for owner in page_compute_owners:
if owner < 0 or owner >= self.cp_size:
raise ValueError(
f"compute owner must be in [0, {self.cp_size}), got {owner}"
)
required[owner] += 1
available = self._owner_available_counts()
deficits = [
max(0, required_count - available_count)
for required_count, available_count in zip(required, available)
]
return required, available, deficits
def compute_owner_lane_capacity_pages(self) -> list[int]:
capacity_pages = int(self.physical_size // self.page_size)
return [capacity_pages for _ in range(self.cp_size)]
def compute_owner_lane_free_room_stats(
self,
page_compute_owners: list[int],
*,
target_ratio: float,
trigger_ratio: float,
) -> tuple[list[int], list[int], list[int]]:
required, available, _exact_deficits = self.compute_owner_lane_stats(
page_compute_owners
)
deficits = _compute_owner_lane_free_room_deficits(
required=required,
available=available,
capacities=self.compute_owner_lane_capacity_pages(),
target_ratio=target_ratio,
trigger_ratio=trigger_ratio,
)
return required, available, deficits
def _select_owner_free_pages_prefer_contiguous(
self,
owner_pages: torch.Tensor,
required_count: int,
) -> torch.Tensor:
return owner_pages[: min(required_count, int(owner_pages.numel()))]
def _select_compute_owner_pages(
self,
page_compute_owners: list[int],
) -> Optional[tuple[torch.Tensor, list[int], list[int]]]:
if not page_compute_owners:
return (
torch.empty((0,), dtype=torch.int64, device=self.device),
[0 for _ in range(self.cp_size)],
[0 for _ in range(self.cp_size)],
)
required_by_owner = [0 for _ in range(self.cp_size)]
positions_by_owner: list[list[int]] = [[] for _ in range(self.cp_size)]
for position, owner in enumerate(page_compute_owners):
if owner < 0 or owner >= self.cp_size:
raise ValueError(
f"compute owner must be in [0, {self.cp_size}), got {owner}"
)
required_by_owner[owner] += 1
positions_by_owner[owner].append(position)
lane_pages = [None for _ in range(self.cp_size)]
selected_free_counts = [0 for _ in range(self.cp_size)]
selected_release_counts = [0 for _ in range(self.cp_size)]
for owner, required_count in enumerate(required_by_owner):
if required_count == 0:
continue
selected_owner_free_mask = self._select_owner_free_pages_prefer_contiguous(
self._owner_free_pages[owner], required_count
)
selected_owner_pages = selected_owner_free_mask
free_count = int(selected_owner_pages.numel())
remaining_count = required_count - free_count
if remaining_count > 0:
release_bucket = self._owner_release_pages[owner]
if remaining_count > release_bucket.numel():
return None
selected_owner_release_pages = release_bucket[:remaining_count]
selected_owner_pages = torch.cat(
(selected_owner_pages, selected_owner_release_pages)
)
selected_release_counts[owner] = remaining_count
selected_free_counts[owner] = free_count
lane_pages[owner] = selected_owner_pages
selected_pages = torch.empty(
(len(page_compute_owners),), dtype=torch.int64, device=self.device
)
for owner, positions in enumerate(positions_by_owner):
if not positions:
continue
position_tensor = torch.tensor(
positions, dtype=torch.int64, device=self.device
)
selected_pages[position_tensor] = lane_pages[owner]
return (
selected_pages,
selected_free_counts,
selected_release_counts,
)
def alloc_pages_with_owners(
self,
page_compute_owners: list[int],
) -> Optional[torch.Tensor]:
if not page_compute_owners:
return torch.empty((0,), dtype=torch.int64, device=self.device)
selected = self._select_compute_owner_pages(page_compute_owners)
if selected is None:
return None
selected_pages, selected_free_counts, selected_release_counts = selected
page_size = self.page_size
base = selected_pages.to(torch.int64).unsqueeze(1) * page_size
offsets = torch.arange(
page_size, dtype=torch.int64, device=self.device
).unsqueeze(0)
out_indices = (base + offsets).reshape(-1)
self._consume_owner_bucket_prefix(
release=False, counts_by_owner=selected_free_counts
)
self._consume_owner_bucket_prefix(
release=True, counts_by_owner=selected_release_counts
)
return out_indices
def _is_page_contiguous_selection(selected: Optional[torch.Tensor], page_size: int) -> bool:
if selected is None or selected.numel() == 0:
return False
pages = selected.view(-1, page_size)[:, 0] // page_size
if pages.numel() <= 1:
return True
return bool(torch.all(pages[1:] - pages[:-1] == 1).item())
def _page_run_lengths_from_token_slots(
selected: Optional[torch.Tensor], page_size: int
) -> list[int]:
"""Return consecutive physical-page run lengths for one host selection.
This is the layout-independent descriptor proxy used by the L2 benchmark:
current ``page_first_direct`` needs one fixed-layer copy descriptor per page,
while ``layer_page_first`` can collapse each consecutive page run to one
descriptor per KV tensor.
"""
if selected is None or selected.numel() == 0:
return []
if selected.numel() % page_size != 0:
raise ValueError(
f"selected token slots must be page-shaped, got {selected.numel()=} "
f"{page_size=}"
)
pages = (selected.view(-1, page_size)[:, 0] // page_size).tolist()
if not pages:
return []
run_lengths: list[int] = []
current_len = 1
prev_page = int(pages[0])
for page in pages[1:]:
page = int(page)
if page == prev_page + 1:
current_len += 1
else:
run_lengths.append(current_len)
current_len = 1
prev_page = page
run_lengths.append(current_len)
return run_lengths
def _make_host_allocator(impl: str, *, page_size: int, free_slots: torch.Tensor):
if impl == "standalone":
return StandaloneHostAllocator(page_size=page_size, free_slots=free_slots)
if impl == "production":
from sglang.srt.mem_cache.memory_pool_host import HostKVCache
class DummyHostKVCacheForBench(HostKVCache):
def get_size_per_token(self):
return 1
def init_kv_buffer(self):
return None
def load_to_device_per_layer(self, *args, **kwargs) -> None:
pass
def backup_from_device_per_layer(self, *args, **kwargs) -> None:
pass
def backup_from_device_all_layer(self, *args, **kwargs) -> None:
pass
def get_data_page(self, *args, **kwargs) -> torch.Tensor:
return torch.empty((0,), dtype=torch.uint8)
def get_dummy_flat_data_page(self) -> torch.Tensor:
return torch.empty((0,), dtype=torch.uint8)
def set_from_flat_data_page(self, *args, **kwargs) -> None:
pass
allocator = DummyHostKVCacheForBench.__new__(DummyHostKVCacheForBench)
allocator.page_size = int(page_size)
allocator.lock = threading.RLock()
allocator.free_slots = free_slots
return allocator
raise ValueError(f"unsupported host impl: {impl}")
def _summarize(
*,
bench: str,
impl: str,
pattern: str,
device: str,
total_pages: int,
request_pages: int,
page_size: int,
samples_us: list[float],
contiguous_hits: int,
) -> BenchResult:
return BenchResult(
bench=bench,
impl=impl,
pattern=pattern,
device=device,
total_pages=int(total_pages),
request_pages=int(request_pages),
page_size=int(page_size),
repeat=len(samples_us),
mean_us=float(statistics.mean(samples_us)) if samples_us else 0.0,
p50_us=float(_percentile(samples_us, 50)),
p95_us=float(_percentile(samples_us, 95)),
p99_us=float(_percentile(samples_us, 99)),
min_us=float(min(samples_us)) if samples_us else 0.0,
max_us=float(max(samples_us)) if samples_us else 0.0,
contiguous_ratio=float(contiguous_hits / len(samples_us)) if samples_us else 0.0,
)
def _summarize_host_churn(
*,
impl: str,
method: str,
eviction_pattern: str,
total_pages: int,
request_pages: int,
page_size: int,
target_occupancy: float,
evict_pages: int,
prefill_node_pages: int,
burnin: int,
samples_us: list[float],
run_counts: list[int],
max_run_lengths: list[int],
contiguous_hits: int,
) -> HostChurnBenchResult:
repeat = len(samples_us)
lpf_descriptors_mean = (
float(statistics.mean(run_counts)) if run_counts else 0.0
)
return HostChurnBenchResult(
bench="host_churn",
impl=f"{impl}:{method}",
pattern=f"occ={target_occupancy:.2f}:{eviction_pattern}",
device="cpu",
total_pages=int(total_pages),
request_pages=int(request_pages),
page_size=int(page_size),
repeat=repeat,
target_occupancy=float(target_occupancy),
evict_pages=int(evict_pages),
prefill_node_pages=int(prefill_node_pages),
burnin=int(burnin),
mean_us=float(statistics.mean(samples_us)) if samples_us else 0.0,
p50_us=float(_percentile(samples_us, 50)),
p95_us=float(_percentile(samples_us, 95)),
p99_us=float(_percentile(samples_us, 99)),
min_us=float(min(samples_us)) if samples_us else 0.0,
max_us=float(max(samples_us)) if samples_us else 0.0,
contiguous_ratio=float(contiguous_hits / repeat) if repeat else 0.0,
page_first_descriptors_per_op=int(request_pages),
lpf_descriptors_mean=lpf_descriptors_mean,
lpf_descriptor_ratio_mean=(
lpf_descriptors_mean / float(request_pages) if request_pages else 0.0
),
run_count_p50=float(_percentile([float(x) for x in run_counts], 50)),
run_count_p95=float(_percentile([float(x) for x in run_counts], 95)),
max_run_pages_mean=(
float(statistics.mean(max_run_lengths)) if max_run_lengths else 0.0
),
max_run_pages_p50=float(
_percentile([float(x) for x in max_run_lengths], 50)
),
)
def _bench_host_case(
*,
impl: str,
method: str,
total_pages: int,
request_pages: int,
page_size: int,
pattern: str,
repeat: int,
warmup: int,
seed: int,
) -> BenchResult:
base_free_slots = _make_host_free_slots(
total_pages=total_pages,
request_pages=request_pages,
page_size=page_size,
pattern=pattern,
seed=seed,
)
need_size = request_pages * page_size
samples_us: list[float] = []
contiguous_hits = 0
for iteration in range(warmup + repeat):
allocator = _make_host_allocator(
impl, page_size=page_size, free_slots=base_free_slots.clone()
)
fn = allocator.alloc if method == "fifo" else allocator.alloc_contiguous_preferred
start_ns = time.perf_counter_ns()
selected = fn(need_size)
elapsed_us = (time.perf_counter_ns() - start_ns) / 1000.0
if iteration >= warmup:
samples_us.append(elapsed_us)
contiguous_hits += int(_is_page_contiguous_selection(selected, page_size))
return _summarize(
bench="host",
impl=f"{impl}:{method}",
pattern=pattern,
device="cpu",
total_pages=total_pages,
request_pages=request_pages,
page_size=page_size,
samples_us=samples_us,
contiguous_hits=contiguous_hits,
)
def _evict_host_churn_nodes(
*,
allocator,
active_nodes: list[torch.Tensor],
target_pages_to_free: int,
page_size: int,
eviction_pattern: str,
rng: random.Random,
) -> int:
freed_pages = 0
while active_nodes and freed_pages < target_pages_to_free:
if eviction_pattern == "oldest":
node_index = 0
elif eviction_pattern == "youngest":
node_index = len(active_nodes) - 1
elif eviction_pattern == "random":
node_index = rng.randrange(len(active_nodes))
else:
raise ValueError(f"unsupported host churn eviction pattern: {eviction_pattern}")
node = active_nodes.pop(node_index)
allocator.free(node)
freed_pages += int(node.numel()) // page_size
return freed_pages
def _bench_host_churn_case(
*,
impl: str,
method: str,
total_pages: int,
request_pages: int,
page_size: int,
target_occupancy: float,
evict_pages: int,
eviction_pattern: str,
repeat: int,
warmup: int,
seed: int,
prefill_node_pages: Optional[int] = None,
burnin: int = 0,
) -> HostChurnBenchResult:
if not 0 < target_occupancy < 1:
raise ValueError(
f"target_occupancy must be in (0, 1), got {target_occupancy}"
)
if request_pages <= 0:
raise ValueError(f"request_pages must be positive, got {request_pages}")
if evict_pages <= 0:
raise ValueError(f"evict_pages must be positive, got {evict_pages}")
if burnin < 0:
raise ValueError(f"burnin must be non-negative, got {burnin}")
if request_pages > total_pages:
raise ValueError(
f"request_pages must be <= total_pages, got {request_pages=} {total_pages=}"
)
if prefill_node_pages is None:
prefill_node_pages = request_pages
if prefill_node_pages <= 0:
raise ValueError(
f"prefill_node_pages must be positive, got {prefill_node_pages}"
)
if prefill_node_pages > total_pages:
raise ValueError(
"prefill_node_pages must be <= total_pages, got "
f"{prefill_node_pages=} {total_pages=}"
)
base_free_slots = _make_host_free_slots(
total_pages=total_pages,
request_pages=request_pages,
page_size=page_size,
pattern="contiguous_fifo",
seed=seed,
)
allocator = _make_host_allocator(impl, page_size=page_size, free_slots=base_free_slots)
rng = random.Random(seed)
need_size = request_pages * page_size
prefill_need_size = prefill_node_pages * page_size
# Fill with configurable node sizes so the benchmark can model fragmented
# steady-state HiCache: many old small nodes can be evicted to satisfy one
# larger new request, which is the path LPF allocation policy cares about.
target_used_pages = min(
total_pages - request_pages,
int(math.floor(float(total_pages) * float(target_occupancy))),
)
target_used_pages = (target_used_pages // prefill_node_pages) * prefill_node_pages
active_nodes: list[torch.Tensor] = []
used_pages = 0
while used_pages + prefill_node_pages <= target_used_pages:
selected = allocator.alloc(prefill_need_size)
if selected is None:
break
active_nodes.append(selected)
used_pages += prefill_node_pages
samples_us: list[float] = []
run_counts: list[int] = []
max_run_lengths: list[int] = []
contiguous_hits = 0
fn = allocator.alloc if method == "fifo" else allocator.alloc_contiguous_preferred
min_evict_pages = max(evict_pages, request_pages)
first_sample_iteration = int(burnin) + int(warmup)
for iteration in range(first_sample_iteration + repeat):
_evict_host_churn_nodes(
allocator=allocator,
active_nodes=active_nodes,
target_pages_to_free=min_evict_pages,
page_size=page_size,
eviction_pattern=eviction_pattern,
rng=rng,
)
while allocator.available_size() < need_size and active_nodes:
_evict_host_churn_nodes(
allocator=allocator,
active_nodes=active_nodes,
target_pages_to_free=request_pages,
page_size=page_size,
eviction_pattern=eviction_pattern,
rng=rng,
)
start_ns = time.perf_counter_ns()
selected = fn(need_size)
elapsed_us = (time.perf_counter_ns() - start_ns) / 1000.0
if selected is None:
raise RuntimeError(
"host churn allocation failed after eviction: "
f"{total_pages=} {request_pages=} {target_occupancy=} "
f"{evict_pages=} {eviction_pattern=}"
)
active_nodes.append(selected)
if iteration >= first_sample_iteration:
samples_us.append(elapsed_us)
run_lengths = _page_run_lengths_from_token_slots(selected, page_size)
run_count = len(run_lengths)
run_counts.append(run_count)
max_run_lengths.append(max(run_lengths) if run_lengths else 0)
contiguous_hits += int(run_count <= 1)
return _summarize_host_churn(
impl=impl,
method=method,
eviction_pattern=eviction_pattern,
total_pages=total_pages,
request_pages=request_pages,
page_size=page_size,
target_occupancy=target_occupancy,
evict_pages=evict_pages,
prefill_node_pages=prefill_node_pages,
burnin=burnin,
samples_us=samples_us,
run_counts=run_counts,
max_run_lengths=max_run_lengths,
contiguous_hits=contiguous_hits,
)
def _zigzag_owners(num_pages: int, cp_size: int) -> list[int]:
segment_num = cp_size * 2
base = num_pages // segment_num
rem = num_pages % segment_num
owners: list[int] = []
for segment_idx in range(segment_num):
count = base + (1 if segment_idx < rem else 0)
owner = segment_idx if segment_idx < cp_size else segment_num - segment_idx - 1
owners.extend([owner] * count)
return owners[:num_pages]
def _make_page_compute_owners(request_pages: int, cp_size: int, pattern: str) -> list[int]:
if pattern == "round_robin":
return [idx % cp_size for idx in range(request_pages)]
if pattern == "single_owner":
return [0 for _ in range(request_pages)]
if pattern == "zigzag":
return _zigzag_owners(request_pages, cp_size)
raise ValueError(f"unsupported owner pattern: {pattern}")
def _make_l1_free_pages(
*,
physical_pages: int,
cp_size: int,
request_owners: list[int],
pattern: str,
device: torch.device,
seed: int,
) -> torch.Tensor:
logical_pages = physical_pages * cp_size
all_pages = list(range(1, logical_pages + 1))
if pattern == "sequential":
return torch.tensor(all_pages, dtype=torch.int64, device=device)
if pattern == "random":
generator = torch.Generator(device="cpu")
generator.manual_seed(seed)
return torch.randperm(logical_pages, generator=generator, dtype=torch.int64).to(device) + 1
if pattern != "owner_fragmented_later_run":
raise ValueError(f"unsupported L1 pattern: {pattern}")
required_by_owner = [0 for _ in range(cp_size)]
for owner in request_owners:
required_by_owner[owner] += 1
used: set[int] = set()
front: list[int] = []
runs: list[int] = []
max_ordinal = physical_pages - 1
for owner, count in enumerate(required_by_owner):
if count <= 0:
continue
run_start = min(max(4, count * 3), max(0, physical_pages - count))
owner_run = [owner + 1 + cp_size * (run_start + idx) for idx in range(count)]
if owner_run[-1] > logical_pages:
owner_run = []
for page in owner_run:
used.add(page)
runs.extend(owner_run)
# Fragmented prefix for the same owner, avoiding the later run.
ordinal = max_ordinal
added = 0
while ordinal >= 0 and added < count:
page = owner + 1 + cp_size * ordinal
ordinal -= 2
if page > logical_pages or page in used:
continue
front.append(page)
used.add(page)
added += 1
rest = [page for page in all_pages if page not in used]
return torch.tensor(front + runs + rest, dtype=torch.int64, device=device)
def _install_sgl_kernel_stubs() -> None:
if "sgl_kernel" not in sys.modules:
mod = types.ModuleType("sgl_kernel")
mod.__file__ = "sgl_kernel_stub.py"
mod.__path__ = []
def _getattr(name):
if name.startswith("__"):
raise AttributeError(name)
fn = lambda *args, **kwargs: None
setattr(mod, name, fn)
return fn
mod.__getattr__ = _getattr
sys.modules["sgl_kernel"] = mod
for submodule in ("sgl_kernel.kvcacheio", "sgl_kernel.quantization"):
if submodule not in sys.modules:
sub = types.ModuleType(submodule)
sub.__file__ = submodule.replace(".", "_") + "_stub.py"
def _sub_getattr(name, _sub=sub):
if name.startswith("__"):
raise AttributeError(name)
fn = lambda *args, **kwargs: None
setattr(_sub, name, fn)
return fn
sub.__getattr__ = _sub_getattr
sys.modules[submodule] = sub
def _make_l1_allocator(
*,
physical_pages: int,
page_size: int,
cp_size: int,
device: torch.device,
production: bool,
):
if not production:
return StandaloneCPSharedPagedAllocator(
physical_pages=physical_pages,
page_size=page_size,
cp_size=cp_size,
device=device,
)
from sglang.srt.mem_cache.allocator import CPSharedPagedTokenToKVPoolAllocator
return CPSharedPagedTokenToKVPoolAllocator(
logical_size=physical_pages * cp_size * page_size,
physical_size=physical_pages * page_size,
page_size=page_size,
dtype=torch.bfloat16,
device=str(device),
kvcache=None,
need_sort=False,
cp_size=cp_size,
cp_rank=0,
)
def _patch_l1_fifo_selector(allocator) -> None:
def fifo_selector(owner_pages: torch.Tensor, required_count: int) -> torch.Tensor:
return owner_pages[: min(required_count, int(owner_pages.numel()))]
allocator._select_owner_free_pages_prefer_contiguous = fifo_selector
def _is_l1_selection_physically_contiguous(
selected: Optional[torch.Tensor], *, page_size: int, cp_size: int
) -> bool:
if selected is None or selected.numel() == 0:
return False
logical_pages = selected.view(-1, page_size)[:, 0] // page_size
owners = torch.remainder(logical_pages - 1, cp_size)
physical_pages = torch.div(logical_pages - 1, cp_size, rounding_mode="floor") + 1
for owner in torch.unique(owners).tolist():
owner_phys = physical_pages[owners == int(owner)]
if owner_phys.numel() <= 1:
continue
if not bool(torch.all(owner_phys[1:] - owner_phys[:-1] == 1).item()):
return False
return True
def _bench_l1_case(
*,
impl: str,
op: str,
physical_pages: int,
request_pages: int,
page_size: int,
cp_size: int,
device: torch.device,
free_pattern: str,
owner_pattern: str,
repeat: int,
warmup: int,
seed: int,
free_room_ratio: float,
free_room_trigger_ratio: float,
production_allocator: bool,
) -> BenchResult:
request_owners = _make_page_compute_owners(request_pages, cp_size, owner_pattern)
base_free_pages = _make_l1_free_pages(
physical_pages=physical_pages,
cp_size=cp_size,
request_owners=request_owners,
pattern=free_pattern,
device=device,
seed=seed,
)
samples_us: list[float] = []
contiguous_hits = 0
use_cuda = device.type == "cuda"
if use_cuda:
torch.cuda.synchronize(device)
for iteration in range(warmup + repeat):
allocator = _make_l1_allocator(
physical_pages=physical_pages,
page_size=page_size,
cp_size=cp_size,
device=device,
production=production_allocator,
)
allocator.free_pages = base_free_pages.clone()
allocator.release_pages = torch.empty((0,), dtype=torch.int64, device=device)
if impl == "fifo":
_patch_l1_fifo_selector(allocator)
if use_cuda:
torch.cuda.synchronize(device)
start_ns = time.perf_counter_ns()
selected = None
if op == "stats":
allocator.compute_owner_lane_stats(request_owners)
elif op == "free_room_stats":
allocator.compute_owner_lane_free_room_stats(
request_owners,
target_ratio=free_room_ratio,
trigger_ratio=free_room_trigger_ratio,
)
elif op == "select_only":
selected_result = allocator._select_compute_owner_pages(request_owners)
if selected_result is not None:
selected_pages = selected_result[0]
base = selected_pages.to(torch.int64).unsqueeze(1) * page_size
offsets = torch.arange(
page_size, dtype=torch.int64, device=device
).unsqueeze(0)
selected = (base + offsets).reshape(-1)
elif op == "alloc_pages":
selected = allocator.alloc_pages_with_owners(request_owners)
else:
raise ValueError(f"unsupported l1 op: {op}")
if use_cuda:
torch.cuda.synchronize(device)
elapsed_us = (time.perf_counter_ns() - start_ns) / 1000.0
if iteration >= warmup:
samples_us.append(elapsed_us)
contiguous_hits += int(
_is_l1_selection_physically_contiguous(
selected, page_size=page_size, cp_size=cp_size
)
)
return _summarize(
bench="l1",
impl=f"{impl}:{op}",
pattern=f"{free_pattern}:{owner_pattern}",
device=device.type,
total_pages=physical_pages,
request_pages=request_pages,
page_size=page_size,
samples_us=samples_us,
contiguous_hits=contiguous_hits,
)
def _format_result(result: BenchResult | HostChurnBenchResult) -> str:
if isinstance(result, HostChurnBenchResult):
return (
f"{result.bench:10s} impl={result.impl:20s} pattern={result.pattern:34s} "
f"dev={result.device:4s} pages={result.total_pages:7d} req={result.request_pages:5d} "
f"evict={result.evict_pages:5d} prefill_node={result.prefill_node_pages:5d} "
f"burnin={result.burnin:4d} p50={result.p50_us:9.2f}us "
f"p95={result.p95_us:9.2f}us p99={result.p99_us:9.2f}us "
f"mean={result.mean_us:9.2f}us contig={result.contiguous_ratio:.2f} "
f"pf_desc={result.page_first_descriptors_per_op:d} "
f"lpf_desc_mean={result.lpf_descriptors_mean:.2f} "
f"lpf_ratio={result.lpf_descriptor_ratio_mean:.3f} "
f"run_p50={result.run_count_p50:.1f} max_run_mean={result.max_run_pages_mean:.1f}"
)
return (
f"{result.bench:4s} impl={result.impl:20s} pattern={result.pattern:34s} "
f"dev={result.device:4s} pages={result.total_pages:7d} req={result.request_pages:5d} "
f"p50={result.p50_us:9.2f}us p95={result.p95_us:9.2f}us "
f"p99={result.p99_us:9.2f}us mean={result.mean_us:9.2f}us "
f"contig={result.contiguous_ratio:.2f}"
)
def _run_host(args) -> list[BenchResult]:
host_pages = _parse_int_list(args.host_pages) if args.host_pages else []
for size_gb in _parse_float_list(args.host_sizes_gb):
host_pages.append(
_host_pages_from_gb(
size_gb, bytes_per_token=args.bytes_per_token, page_size=args.page_size
)
)
if not host_pages:
host_pages = [8192, 16384, 32768]
host_pages = sorted(set(page for page in host_pages if page > 0))
request_pages_list = _parse_int_list(args.request_pages)
patterns = [item.strip() for item in args.patterns.split(",") if item.strip()]
host_impls = [item.strip() for item in args.host_impl.split(",") if item.strip()]
methods = [item.strip() for item in args.host_methods.split(",") if item.strip()]
results: list[BenchResult] = []
for total_pages in host_pages:
for request_pages in request_pages_list:
if request_pages > total_pages:
continue
for pattern in patterns:
for impl in host_impls:
for method in methods:
results.append(
_bench_host_case(
impl=impl,
method=method,
total_pages=total_pages,
request_pages=request_pages,
page_size=args.page_size,
pattern=pattern,
repeat=args.repeat,
warmup=args.warmup,
seed=args.seed,
)
)
return results
def _run_host_churn(args) -> list[HostChurnBenchResult]:
host_pages = _parse_int_list(args.host_pages) if args.host_pages else []
for size_gb in _parse_float_list(args.host_sizes_gb):
host_pages.append(
_host_pages_from_gb(
size_gb, bytes_per_token=args.bytes_per_token, page_size=args.page_size
)
)
if not host_pages:
host_pages = [8192, 16384, 32768]
host_pages = sorted(set(page for page in host_pages if page > 0))
request_pages_list = _parse_int_list(args.request_pages)
host_impls = [item.strip() for item in args.host_impl.split(",") if item.strip()]
methods = [item.strip() for item in args.host_methods.split(",") if item.strip()]
occupancies = _parse_float_list(args.host_churn_occupancies)
evict_pages_list = _parse_int_list(args.host_churn_evict_pages)
prefill_node_pages_list = (
_parse_int_list(args.host_churn_prefill_node_pages)
if args.host_churn_prefill_node_pages
else [0]
)
eviction_patterns = [
item.strip() for item in args.host_churn_eviction_patterns.split(",") if item.strip()
]
results: list[HostChurnBenchResult] = []
for total_pages in host_pages:
for request_pages in request_pages_list:
if request_pages > total_pages:
continue
for target_occupancy in occupancies:
for evict_pages in evict_pages_list:
for prefill_node_pages in prefill_node_pages_list:
for eviction_pattern in eviction_patterns:
for impl in host_impls:
for method in methods:
results.append(
_bench_host_churn_case(
impl=impl,
method=method,
total_pages=total_pages,
request_pages=request_pages,
page_size=args.page_size,
target_occupancy=target_occupancy,
evict_pages=evict_pages,
eviction_pattern=eviction_pattern,
repeat=args.repeat,
warmup=args.warmup,
seed=args.seed,
prefill_node_pages=(
prefill_node_pages or None
),
burnin=args.host_churn_burnin,
)
)
return results
def _run_l1(args) -> list[BenchResult]:
if args.stub_sgl_kernel:
_install_sgl_kernel_stubs()
if args.device == "cuda" and not torch.cuda.is_available():
raise RuntimeError("--device cuda requested but CUDA is not available")
device = torch.device(args.device)
if device.type == "cuda":
torch.cuda.set_device(args.cuda_device)
device = torch.device(f"cuda:{args.cuda_device}")
physical_pages_list = _parse_int_list(args.physical_pages)
request_pages_list = _parse_int_list(args.request_pages)
free_patterns = [item.strip() for item in args.l1_free_patterns.split(",") if item.strip()]
owner_patterns = [item.strip() for item in args.l1_owner_patterns.split(",") if item.strip()]
impls = [item.strip() for item in args.l1_impl.split(",") if item.strip()]
ops = [item.strip() for item in args.l1_ops.split(",") if item.strip()]
results: list[BenchResult] = []
for physical_pages in physical_pages_list:
for request_pages in request_pages_list:
if request_pages > physical_pages * args.cp_size:
continue
for free_pattern in free_patterns:
for owner_pattern in owner_patterns:
for impl in impls:
for op in ops:
results.append(
_bench_l1_case(
impl=impl,
op=op,
physical_pages=physical_pages,
request_pages=request_pages,
page_size=args.page_size,
cp_size=args.cp_size,
device=device,
free_pattern=free_pattern,
owner_pattern=owner_pattern,
repeat=args.repeat,
warmup=args.warmup,
seed=args.seed,
free_room_ratio=args.l1_free_room_ratio,
free_room_trigger_ratio=args.l1_free_room_trigger_ratio,
production_allocator=args.l1_allocator == "production",
)
)
return results
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--bench", default="host,l1", help="comma list: host,host_churn,l1"
)
parser.add_argument("--page-size", type=int, default=64)
parser.add_argument("--repeat", type=int, default=20)
parser.add_argument("--warmup", type=int, default=5)
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--json-output", default="")
parser.add_argument(
"--host-pages",
default="",
help="comma list of host pages; defaults to 8192,16384,32768 only when no --host-sizes-gb is set",
)
parser.add_argument("--host-sizes-gb", default="")
parser.add_argument("--bytes-per-token", type=int, default=100_000)
parser.add_argument("--request-pages", default="1,8,64,512")
parser.add_argument(
"--patterns",
default="contiguous_fifo,fragmented_prefix_later_run,random_fragmented",
)
parser.add_argument("--host-impl", default="standalone")
parser.add_argument("--host-methods", default="fifo,contiguous")
parser.add_argument("--host-churn-occupancies", default="0.90,0.97,0.99")
parser.add_argument("--host-churn-evict-pages", default="64,512,2048")
parser.add_argument(
"--host-churn-prefill-node-pages",
default="",
help=(
"comma list of node sizes used to prefill steady-state host cache; "
"default uses each request_pages value"
),
)
parser.add_argument(
"--host-churn-burnin",
type=int,
default=0,
help=(
"unmeasured steady-state evict+allocate iterations before warmup; "
"useful for exhausting the cold contiguous free tail"
),
)
parser.add_argument("--host-churn-eviction-patterns", default="oldest,random")
parser.add_argument("--physical-pages", default="8192,32768")
parser.add_argument("--cp-size", type=int, default=8)
parser.add_argument("--device", choices=("cpu", "cuda"), default="cpu")
parser.add_argument("--cuda-device", type=int, default=0)
parser.add_argument("--stub-sgl-kernel", action="store_true")
parser.add_argument("--l1-impl", default="fifo,current")
parser.add_argument("--l1-ops", default="alloc_pages")
parser.add_argument(
"--l1-allocator",
choices=("production", "standalone"),
default="production",
help="Use production allocator imports or the dependency-light metadata copy.",
)
parser.add_argument("--l1-free-room-ratio", type=float, default=0.15)
parser.add_argument("--l1-free-room-trigger-ratio", type=float, default=0.05)
parser.add_argument(
"--l1-free-patterns",
default="sequential,owner_fragmented_later_run,random",
)
parser.add_argument("--l1-owner-patterns", default="round_robin,single_owner,zigzag")
return parser
def main(argv: Optional[list[str]] = None) -> int:
args = _build_parser().parse_args(argv)
if args.repeat <= 0:
raise ValueError("--repeat must be positive")
if args.warmup < 0:
raise ValueError("--warmup must be non-negative")
results: list[BenchResult] = []
benches = {item.strip() for item in args.bench.split(",") if item.strip()}
if "host" in benches:
results.extend(_run_host(args))
if "host_churn" in benches:
results.extend(_run_host_churn(args))
if "l1" in benches:
results.extend(_run_l1(args))
for result in results:
print(_format_result(result), flush=True)
if args.json_output:
output_path = os.path.abspath(args.json_output)
with open(output_path, "w", encoding="utf-8") as fout:
json.dump([asdict(result) for result in results], fout, indent=2)
print(f"wrote_json={output_path}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())