Stabilize CP HiCache residency under L1/L2 pressure

CP shared KV now keeps explicit L1 and host free-room targets so pressure is handled by planned eviction instead of repeated capacity-edge retries. The host allocator gains contiguous-preferred page reservation, L1 owner-lane allocation prefers contiguous physical pages, and CP HiCache metadata preserves pending backup safety for page-granular radix updates. Mooncake transfer stats and allocator microbenchmarks are included to make the remaining transfer bottlenecks measurable rather than inferred.

Constraint: CP shared KV uses decode CP size 1 with all prefill CP ranks participating in transfer, so L1/L2 cache residency must remain page-granular and avoid extra collectives.\nConstraint: Production HiCache can be hundreds of GB, so allocator metadata overhead must be visible before enabling aggressive contiguous allocation broadly.\nRejected: Evict only the exact deficit | this keeps the cache at the cliff and causes repeated evict/allocate pressure.\nRejected: Rely on allocator scans alone for contiguity | remote microbenchmarks show fragmented 220GB-equivalent host metadata can make contiguous-preferred scans multi-ms.\nConfidence: medium\nScope-risk: moderate\nDirective: Do not increase L1/L2 free-room defaults or add new CP collectives without ETE evidence and transfer/allocator measurements.\nTested: python -m py_compile on touched runtime/test/benchmark files.\nTested: PYTHONPATH=. python -m pytest -q test/registered/unit/benchmark/test_cp_hicache_allocator_bench.py => 4 passed, 1 warning.\nTested: Remote g0034 log /mnt/beegfs/cjy/log/sglang_cp_hicache_20260601_233723.log shows active prefill process with L1/L2 free-room args, 702 HTTP 200 chat completions, 6272 prefill batches, and no fatal scheduler traceback in latest scan.\nTested: User-reported L1/L2 cache ETE validation passed on remote run.\nNot-tested: Full local pytest suite; local environment is missing several runtime dependencies.\nNot-tested: CUDA allocator microbenchmark during active production prefill process.\nNot-tested: Mooncake straggler fix; stats show transfer tail latency remains a separate bottleneck.
This commit is contained in:
laoyao0822
2026-06-02 07:58:02 +08:00
parent 8be4a3a8b5
commit ce3a20d11b
17 changed files with 2268 additions and 16 deletions
@@ -0,0 +1,742 @@
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
# Production 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
"""
import argparse
import json
import math
import os
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
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 _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 _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 _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 _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):
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_mask: torch.Tensor, required_count: int) -> torch.Tensor:
return owner_mask & (torch.cumsum(owner_mask.to(torch.int64), dim=0) <= required_count)
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,
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,
) -> 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,
)
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 = allocator.alloc_pages_with_owners(request_owners)
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=impl,
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) -> str:
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_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()]
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:
results.append(
_bench_l1_case(
impl=impl,
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,
)
)
return results
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("--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("--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-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 "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())