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.
This commit is contained in:
laoyao0822
2026-06-02 23:35:12 +08:00
parent 7c8fa2f71c
commit 401de0f8ce
5 changed files with 925 additions and 2 deletions

View File

@@ -14,6 +14,13 @@ Examples:
--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 \
@@ -25,6 +32,7 @@ import argparse
import json
import math
import os
import random
import statistics
import sys
import threading
@@ -55,6 +63,36 @@ class BenchResult:
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()]
@@ -244,6 +282,10 @@ class StandaloneHostAllocator:
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(
*,
@@ -544,6 +586,43 @@ def _is_page_contiguous_selection(selected: Optional[torch.Tensor], page_size: i
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)
@@ -614,6 +693,63 @@ def _summarize(
)
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,
@@ -660,6 +796,166 @@ def _bench_host_case(
)
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
@@ -915,7 +1211,20 @@ def _bench_l1_case(
)
def _format_result(result: BenchResult) -> str:
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} "
@@ -965,6 +1274,64 @@ def _run_host(args) -> list[BenchResult]:
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()
@@ -1015,7 +1382,9 @@ def _run_l1(args) -> list[BenchResult]:
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--bench", default="host,l1", help="comma list: host,l1")
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)
@@ -1036,6 +1405,26 @@ def _build_parser() -> argparse.ArgumentParser:
)
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)
@@ -1071,6 +1460,8 @@ def main(argv: Optional[list[str]] = None) -> int:
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))

View File

@@ -5311,3 +5311,154 @@ C123 full-suite update:
- The CPU stub now also gives `sgl_kernel.kvcacheio` a module-level
`__getattr__`, so named imports resolve to inert functions without importing
the native extension.
### C124 — 2026-06-02 L2 host churn benchmark must model fragmented steady-state eviction
Finding:
- The earlier host allocator benchmark measured one cold allocation against a
synthetic `free_slots` layout. That is not enough for production HiCache: L2
host cache can be ~220 GB, runs near full occupancy, and repeatedly frees old
nodes before reserving a new node.
- If prefill nodes and measured allocation requests have the same page size, a
random eviction workload can still produce unrealistically contiguous freed
chunks. This hides the allocator/pathology we care about for layer-page-first
(LPF) transfer planning.
Correction:
- Added `benchmark/hicache/bench_cp_hicache_allocator_overhead.py --bench
host_churn` for steady-state L2 metadata churn.
- The benchmark now records both CPU allocation latency and transfer-descriptor
quality proxies:
- `pf_desc`: page-first descriptor count, one descriptor per selected page.
- `lpf_desc_mean` / `lpf_ratio`: layer-page-first descriptor count after
coalescing consecutive physical page runs.
- `run_p50` / `max_run_mean`: selected physical-page run quality.
- Added `--host-churn-prefill-node-pages` so the filled resident set can use a
different historical node size than the new allocation request. This models
cache built from many small chunks, then later serving larger extensions.
Scope:
- This is CPU metadata-only. It does not allocate real 220 GB KV buffers and it
does not test CUDA kernel bandwidth.
- It is intended to decide whether L2 `HostKVCache.alloc_contiguous_preferred()`
/ future L2 bucket allocator work is on the actual hot path, and whether LPF
layout can get enough physical-page coalescing to help H2D/D2H/RDMA.
C124 validation update:
- Local CPU unit coverage: `test_cp_hicache_allocator_bench.py` passes with the
fragmented prefill-node churn case.
- Remote `g0034` container validation: py_compile passed and the benchmark unit
file passed (`9 passed`).
- Remote 220GB-equivalent production `HostKVCache` churn sample
(`pages=34375`, `occ=0.97`, `request_pages=512`, `evict_pages=512`):
- `prefill_node_pages=1`: FIFO p50 ~11 us but LPF run quality is poor
(`lpf_ratio≈0.663`, `run_p50≈505`). Contiguous-preferred p50 ~700 us and
still cannot find a good run under this fragmentation.
- `prefill_node_pages=64`: FIFO p50 ~9 us and LPF run quality is much better
(`lpf_ratio≈0.012`, `run_p50≈8`). Contiguous-preferred p50 ~499 us.
- Interpretation: current production `alloc_contiguous_preferred()` can add
hundreds of microseconds on fragmented 220GB-equivalent metadata. The
benchmark now makes this measurable separately from ETE noise; it also shows
that node-size history strongly affects LPF transfer coalescing potential.
### C125 — 2026-06-02 Host churn benchmark needs explicit burn-in to avoid cold free-tail bias
Finding:
- A high-occupancy prefill still leaves an initial contiguous free tail. With
small `warmup` and small requests, measured allocations can consume this cold
tail before they ever allocate from evicted fragmented nodes.
- That can overstate LPF run quality and understate the allocation/search cost we
expect after the service has churned for a while.
Correction plan:
- Add a separate `--host-churn-burnin` iteration count. Burn-in iterations run
the same evict+allocate cycle but are not measured and are independent of
benchmark warmup.
- Use burn-in to exhaust the initial free tail before collecting steady-state
latency and run-quality samples.
C125 validation update:
- Local RED/GREEN: `test_host_churn_burnin_exposes_fragmented_evicted_nodes_after_cold_tail`
first failed on missing `burnin`, then passed after adding the burn-in path.
- Local full benchmark unit file: `10 passed` plus py_compile.
- Remote `g0034` container: py_compile passed and
`test_cp_hicache_allocator_bench.py` passed (`10 passed`).
- Remote 220GB-equivalent production `HostKVCache` sample with `burnin=20`,
`occ=0.97`, `evict_pages=512`:
- `request_pages=64`, `prefill_node_pages=1`: FIFO p50 ~11 us but LPF quality
is worst case (`lpf_ratio=1.000`, all single-page runs). The contiguous
search can cost ~2.6 ms p50.
- `request_pages=512`, `prefill_node_pages=1`: FIFO p50 ~10 us, LPF quality
remains worst case (`lpf_ratio=1.000`). Contiguous search p50 ~576 us and
cannot improve run quality because no 512-page run exists.
- `prefill_node_pages=64`: LPF quality is much better (`lpf_ratio≈0.0120.031`)
but contiguous search can still cost ~0.51.5 ms p50.
- Interpretation: this benchmark now exposes the relevant tradeoff: FIFO is cheap
but can create very high descriptor count for LPF/RDMA when historical nodes
are tiny; naive contiguous search can be milliseconds on 220GB-equivalent host
metadata. A future L2 allocator should avoid full free-list scans and preserve
larger free extents/buckets rather than searching linearly per allocation.
### C126 — 2026-06-02 L2 HostKVCache allocator uses lazy page extents instead of full free-slot scans
Finding:
- `HostKVCache.alloc_contiguous_preferred()` previously scanned/materialized the
full token-level `free_slots` tensor to discover contiguous physical page runs.
On 220GB-equivalent host metadata this could cost hundreds of microseconds to
milliseconds per reservation.
- The full scan is the wrong shape for CP HiCache: allocations and releases are
page-shaped, and the fast path only needs page-run metadata plus a token-index
tensor for the chosen pages.
Correction:
- `HostKVCache` now maintains a lazy page-extent index:
- `free_slots` remains a compatibility property and is materialized lazily only
when external code reads it.
- `available_size()` reads an integer token count and does not materialize.
- `free()` converts page-shaped token indices into page runs and merges them
into sorted extents.
- `alloc_contiguous_preferred()` first checks the largest free extent for a
single-run allocation; if no run is large enough, it falls back to a batched
fragmented run allocation without scanning/sorting the whole `free_slots`
tensor.
- The fallback is still page-shaped and returns normal token indices. It avoids
silent corruption: overlapping/double-free page extents raise, and non-page
shaped frees disable the extent index with a warning before falling back to the
legacy tensor path.
C126 validation update:
- Remote RED/GREEN target: the new lazy extent-index unit first failed on missing
`_free_slots_dirty`, then passed after implementation.
- Remote `test_hicache_controller_cp.py`: `67 passed`.
- Remote `test_cp_hicache_allocator_bench.py`: `10 passed`.
- Remote 220GB-equivalent production `HostKVCache` churn sample with `burnin=20`,
`occ=0.97`, `evict_pages=512` after optimization:
- `request_pages=64`, `prefill_node_pages=64`: contiguous p50 ~31 us.
- `request_pages=512`, `prefill_node_pages=64`: contiguous p50 ~86 us.
- `request_pages=512`, `prefill_node_pages=1`: contiguous p50 ~292 us; this
is no longer a full free-list scan, but mostly fragmented selection plus
constructing a 32768-token host-index tensor.
- Compared with the previous C125 sample, the contiguous path drops from roughly
0.52.6 ms p50 to roughly 30292 us p50 on this benchmark shape.
Remaining risk:
- `alloc()` now uses the extent-backed fragmented allocation when the extent
index is active, so exact FIFO order is no longer the internal contract. This
should be acceptable for host KV slots because callers require unique free
page-shaped slots, not FIFO identity, but future code must not depend on
token-level FIFO order.
- Very fragmented tiny historical nodes still produce poor LPF coalescing. That
requires higher-level eviction/allocation policy to preserve larger free runs;
the allocator now avoids the worst CPU scan cost but cannot create physical
contiguity that the free set does not contain.

View File

@@ -1,4 +1,6 @@
import abc
import bisect
import heapq
import logging
import threading
from collections import defaultdict
@@ -314,6 +316,249 @@ class HostKVCache(abc.ABC):
"""
raise NotImplementedError()
@property
def free_slots(self) -> torch.Tensor:
if getattr(self, "_free_slots_dirty", False):
self._free_slots = self._materialize_free_slots_from_extents()
self._free_slots_dirty = False
return self._free_slots
@free_slots.setter
def free_slots(self, value: torch.Tensor) -> None:
self._free_slots = value.cpu().to(dtype=torch.int64).contiguous()
self._rebuild_free_extent_index_from_slots(self._free_slots)
def _reset_free_extent_index(self, *, enabled: bool, token_count: int) -> None:
self._free_extent_index_enabled = bool(enabled)
self._free_token_count = int(token_count)
self._free_extents_by_start: dict[int, int] = {}
self._free_extent_starts: list[int] = []
self._free_extent_heap: list[tuple[int, int]] = []
self._free_slots_dirty = False
def _rebuild_free_extent_index_from_slots(self, free_slots: torch.Tensor) -> None:
token_count = int(free_slots.numel())
self._reset_free_extent_index(enabled=False, token_count=token_count)
page_size = int(self.page_size)
if token_count == 0:
self._free_extent_index_enabled = True
return
if token_count % page_size != 0:
logger.warning(
"[HiCache-L2-allocator] disabling extent index for non-page-shaped free_slots: tokens=%d page_size=%d",
token_count,
page_size,
)
return
page_slots = free_slots.view(-1, page_size)
offsets = torch.arange(page_size, dtype=page_slots.dtype)
if not bool(torch.all(page_slots == (page_slots[:, :1] + offsets)).item()):
logger.warning(
"[HiCache-L2-allocator] disabling extent index for non-contiguous page slots"
)
return
self._free_extent_index_enabled = True
pages = torch.div(page_slots[:, 0], page_size, rounding_mode="floor").tolist()
if not pages:
return
run_start = int(pages[0])
run_len = 1
prev = run_start
for page in pages[1:]:
page = int(page)
if page == prev + 1:
run_len += 1
else:
self._insert_free_extent(run_start, run_len)
run_start = page
run_len = 1
prev = page
self._insert_free_extent(run_start, run_len)
def _materialize_free_slots_from_extents(self) -> torch.Tensor:
page_size = int(self.page_size)
chunks: list[torch.Tensor] = []
offsets = torch.arange(page_size, dtype=torch.int64)
for start in self._free_extent_starts:
length = self._free_extents_by_start.get(start, 0)
if length <= 0:
continue
pages = torch.arange(start, start + length, dtype=torch.int64)
chunks.append((pages[:, None] * page_size + offsets[None, :]).reshape(-1))
if not chunks:
return torch.empty((0,), dtype=torch.int64)
return torch.cat(chunks).contiguous()
def _insert_free_extent(self, start_page: int, page_count: int) -> None:
if page_count <= 0:
return
start_page = int(start_page)
page_count = int(page_count)
end_page = start_page + page_count
pos = bisect.bisect_left(self._free_extent_starts, start_page)
if pos > 0:
prev_start = self._free_extent_starts[pos - 1]
prev_len = self._free_extents_by_start[prev_start]
prev_end = prev_start + prev_len
if prev_end > start_page:
raise RuntimeError(
"[HiCache-L2-allocator] double free or overlapping host extent"
)
if prev_end == start_page:
start_page = prev_start
page_count += prev_len
del self._free_extents_by_start[prev_start]
del self._free_extent_starts[pos - 1]
pos -= 1
if pos < len(self._free_extent_starts):
next_start = self._free_extent_starts[pos]
next_len = self._free_extents_by_start[next_start]
if end_page > next_start:
raise RuntimeError(
"[HiCache-L2-allocator] double free or overlapping host extent"
)
if end_page == next_start:
page_count += next_len
del self._free_extents_by_start[next_start]
del self._free_extent_starts[pos]
self._free_extents_by_start[start_page] = page_count
bisect.insort(self._free_extent_starts, start_page)
heapq.heappush(self._free_extent_heap, (-page_count, start_page))
def _pop_largest_free_extent(self) -> Optional[tuple[int, int]]:
while self._free_extent_heap:
neg_len, start = heapq.heappop(self._free_extent_heap)
length = -int(neg_len)
current = self._free_extents_by_start.get(start)
if current == length:
del self._free_extents_by_start[start]
pos = bisect.bisect_left(self._free_extent_starts, start)
if (
pos < len(self._free_extent_starts)
and self._free_extent_starts[pos] == start
):
del self._free_extent_starts[pos]
return start, length
return None
def _peek_largest_free_extent(self) -> Optional[tuple[int, int]]:
while self._free_extent_heap:
neg_len, start = self._free_extent_heap[0]
length = -int(neg_len)
if self._free_extents_by_start.get(start) == length:
return start, length
heapq.heappop(self._free_extent_heap)
return None
def _page_ids_to_token_indices(self, page_ids: list[int]) -> torch.Tensor:
if not page_ids:
return torch.empty((0,), dtype=torch.int64)
page_size = int(self.page_size)
pages = torch.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 _alloc_from_free_extents(
self, need_size: int, *, require_single_run: bool
) -> Optional[torch.Tensor]:
page_size = int(self.page_size)
need_pages = int(need_size) // page_size
if need_size > self.available_size():
return None
if need_pages == 0:
return torch.empty((0,), dtype=torch.int64)
if require_single_run:
largest = self._peek_largest_free_extent()
if largest is None or largest[1] < need_pages:
return None
start, length = self._pop_largest_free_extent()
selected_pages: list[int] = []
selected_pages.extend(range(start, start + need_pages))
remaining = length - need_pages
if remaining > 0:
self._insert_free_extent(start + need_pages, remaining)
else:
selected_pages = self._alloc_fragmented_from_free_extents(need_pages)
self._free_token_count -= need_size
self._free_slots_dirty = True
return self._page_ids_to_token_indices(selected_pages)
def _alloc_fragmented_from_free_extents(self, need_pages: int) -> list[int]:
remaining_pages = int(need_pages)
selected_pages: list[int] = []
consumed_count = 0
replacement: Optional[tuple[int, int]] = None
for start in self._free_extent_starts:
if remaining_pages <= 0:
break
length = self._free_extents_by_start[start]
take = min(length, remaining_pages)
selected_pages.extend(range(start, start + take))
remaining_pages -= take
if take == length:
consumed_count += 1
continue
replacement = (start + take, length - take)
break
if remaining_pages > 0:
raise RuntimeError(
"[HiCache-L2-allocator] extent index underflow during fragmented allocation"
)
consumed_starts = self._free_extent_starts[:consumed_count]
for start in consumed_starts:
del self._free_extents_by_start[start]
del self._free_extent_starts[:consumed_count]
if replacement is not None:
old_start = self._free_extent_starts[0]
del self._free_extents_by_start[old_start]
self._free_extent_starts[0] = replacement[0]
self._free_extents_by_start[replacement[0]] = replacement[1]
heapq.heappush(self._free_extent_heap, (-replacement[1], replacement[0]))
return selected_pages
def _page_runs_from_token_indices(
self, indices: torch.Tensor
) -> Optional[list[tuple[int, int]]]:
indices = indices.cpu().to(dtype=torch.int64).contiguous()
token_count = int(indices.numel())
page_size = int(self.page_size)
if token_count == 0:
return []
if token_count % page_size != 0:
return None
page_slots = indices.view(-1, page_size)
offsets = torch.arange(page_size, dtype=page_slots.dtype)
if not bool(torch.all(page_slots == (page_slots[:, :1] + offsets)).item()):
return None
pages = torch.div(page_slots[:, 0], page_size, rounding_mode="floor").tolist()
runs: list[tuple[int, int]] = []
run_start = int(pages[0])
run_len = 1
prev = run_start
for page in pages[1:]:
page = int(page)
if page == prev + 1:
run_len += 1
else:
runs.append((run_start, run_len))
run_start = page
run_len = 1
prev = page
runs.append((run_start, run_len))
return runs
@synchronized
def clear(self):
# Initialize memory states and tracking structures.
@@ -323,6 +568,8 @@ class HostKVCache(abc.ABC):
self.free_slots = torch.arange(self.size, dtype=torch.int64)
def available_size(self):
if hasattr(self, "_free_token_count"):
return int(self._free_token_count)
return len(self.free_slots)
@synchronized
@@ -332,6 +579,10 @@ class HostKVCache(abc.ABC):
), "The requested size should be a multiple of the page size."
if need_size > self.available_size():
return None
if getattr(self, "_free_extent_index_enabled", False):
return self._alloc_from_free_extents(
need_size, require_single_run=False
)
select_index = self.free_slots[:need_size]
self.free_slots = self.free_slots[need_size:]
@@ -356,6 +607,15 @@ class HostKVCache(abc.ABC):
return None
if need_size == 0:
return self.alloc(need_size)
if getattr(self, "_free_extent_index_enabled", False):
select_index = self._alloc_from_free_extents(
need_size, require_single_run=True
)
if select_index is not None:
return select_index
return self._alloc_from_free_extents(
need_size, require_single_run=False
)
fifo_prefix = self.free_slots[:need_size]
expected_prefix = fifo_prefix[:1] + torch.arange(
@@ -422,6 +682,20 @@ class HostKVCache(abc.ABC):
@synchronized
def free(self, indices: torch.Tensor) -> int:
if getattr(self, "_free_extent_index_enabled", False):
runs = self._page_runs_from_token_indices(indices)
if runs is not None:
for start, length in runs:
self._insert_free_extent(start, length)
self._free_token_count += int(indices.numel())
self._free_slots_dirty = True
return len(indices)
logger.warning(
"[HiCache-L2-allocator] disabling extent index for non-page-shaped free indices: tokens=%d page_size=%d",
int(indices.numel()),
int(self.page_size),
)
self._free_extent_index_enabled = False
self.free_slots = torch.cat([self.free_slots, indices.cpu()])
return len(indices)

View File

@@ -1,11 +1,14 @@
import torch
from benchmark.hicache.bench_cp_hicache_allocator_overhead import (
HostChurnBenchResult,
StandaloneCPSharedPagedAllocator,
StandaloneHostAllocator,
_bench_host_churn_case,
_host_pages_from_gb,
_make_host_free_slots,
_make_page_compute_owners,
_page_run_lengths_from_token_slots,
_parse_int_list,
)
@@ -49,6 +52,65 @@ def test_host_random_fragmented_has_requested_size():
assert torch.unique(free_slots).numel() == free_slots.numel()
def test_page_run_lengths_from_token_slots_counts_lpf_descriptors():
page_size = 4
selected = torch.tensor(
[
*range(10 * page_size, 13 * page_size),
*range(20 * page_size, 22 * page_size),
*range(25 * page_size, 26 * page_size),
],
dtype=torch.int64,
)
assert _page_run_lengths_from_token_slots(selected, page_size) == [3, 2, 1]
def test_host_churn_case_reports_l2_run_quality():
result = _bench_host_churn_case(
impl="standalone",
method="contiguous",
total_pages=48,
request_pages=4,
page_size=8,
target_occupancy=0.75,
evict_pages=4,
eviction_pattern="random",
repeat=4,
warmup=1,
seed=7,
)
assert isinstance(result, HostChurnBenchResult)
assert result.bench == "host_churn"
assert result.repeat == 4
assert result.page_first_descriptors_per_op == 4
assert 0 < result.lpf_descriptor_ratio_mean <= 1
assert result.run_count_p50 >= 1
assert result.max_run_pages_mean >= 1
def test_host_churn_prefill_node_pages_can_model_fragmented_free_chunks():
result = _bench_host_churn_case(
impl="standalone",
method="fifo",
total_pages=64,
request_pages=8,
page_size=4,
target_occupancy=0.75,
evict_pages=8,
eviction_pattern="random",
prefill_node_pages=1,
repeat=8,
warmup=2,
seed=11,
)
assert result.prefill_node_pages == 1
assert result.run_count_p50 > 1
assert result.lpf_descriptor_ratio_mean > 1 / result.request_pages
def test_standalone_l1_allocator_reports_owner_lane_stats():
allocator = StandaloneCPSharedPagedAllocator(
physical_pages=4,
@@ -79,3 +141,25 @@ def test_standalone_l1_allocator_allocates_owner_matching_pages():
logical_pages = (selected.view(-1, allocator.page_size)[:, 0] // allocator.page_size)
selected_owners = torch.remainder(logical_pages - 1, allocator.cp_size).tolist()
assert selected_owners == owners
def test_host_churn_burnin_exposes_fragmented_evicted_nodes_after_cold_tail():
result = _bench_host_churn_case(
impl="standalone",
method="fifo",
total_pages=32,
request_pages=8,
page_size=4,
target_occupancy=0.75,
evict_pages=8,
eviction_pattern="random",
prefill_node_pages=1,
burnin=1,
repeat=1,
warmup=0,
seed=23,
)
assert result.burnin == 1
assert result.run_count_p50 > 1
assert result.lpf_descriptor_ratio_mean > 1 / result.request_pages

View File

@@ -1016,6 +1016,29 @@ class TestHiCacheControllerCPWrite(CustomTestCase):
self.assertEqual(selected.tolist(), [8, 9, 10, 11, 12, 13, 14, 15])
self.assertEqual(host_pool.free_slots.tolist(), [100, 101, 102, 103])
def test_host_alloc_contiguous_preferred_uses_lazy_extent_index(self):
host_pool = DummyHostKVCacheForAlloc.__new__(DummyHostKVCacheForAlloc)
host_pool.page_size = 4
host_pool.lock = __import__("threading").RLock()
pages = [50, 51, 52, 53, 100, 7, 8]
host_pool.free_slots = torch.tensor(
[page * 4 + offset for page in pages for offset in range(4)],
dtype=torch.int64,
)
selected = host_pool.alloc_contiguous_preferred(16)
self.assertEqual(
selected.tolist(),
[page * 4 + offset for page in [50, 51, 52, 53] for offset in range(4)],
)
self.assertEqual(host_pool.available_size(), 12)
self.assertTrue(host_pool._free_slots_dirty)
self.assertEqual(
host_pool.free_slots.tolist(),
[page * 4 + offset for page in [7, 8, 100] for offset in range(4)],
)
def test_cp_reserve_zero_owned_queues_no_ack_until_submit(self):
host_pool = FakeHostPool(torch.tensor([], dtype=torch.int64))
controller = self.make_controller(host_pool, cp_rank=3)