项目文件夹

文件
2026-07-13 12:24:33 +08:00

232 行
6.8 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
Plot token cache hit rate vs cache capacity (GiB).
Sweeps a logarithmically-spaced range of cache capacities, runs the simulator
at each point, and produces a matplotlib figure showing how token hit rate
scales with available memory.
Usage::
python -m lmcache.tools.cache_simulator.plot_hit_rate \\
-i /path/to/lookup_hashes/ \\
--min-capacity-gib 1 \\
--max-capacity-gib 512 \\
--points 30 \\
-o hit_rate_vs_capacity.png
"""
# Standard
from pathlib import Path
import argparse
import math
import sys
# First Party
from lmcache.tools.cache_simulator.simulator import (
compute_kv_bytes_per_chunk,
load_lookup_events,
simulate,
)
_GIB = 2**30
def capacity_range_bytes(
min_gib: float,
max_gib: float,
num_points: int,
) -> list[int]:
"""
Return *num_points* byte capacities log-spaced between *min_gib* and
*max_gib* GiB.
"""
log_min = math.log10(min_gib * _GIB)
log_max = math.log10(max_gib * _GIB)
step = (log_max - log_min) / max(num_points - 1, 1)
return sorted({round(10 ** (log_min + i * step)) for i in range(num_points)})
def add_sweep_arguments(parser: argparse.ArgumentParser) -> None:
"""Register all ``sweep`` CLI flags onto *parser*.
Called by both the module ``main()`` and by
:class:`~lmcache.cli.commands.tool.ToolCommand` so that flag definitions
live in exactly one place.
Args:
parser: The ``ArgumentParser`` (or sub-parser) to add flags to.
"""
parser.add_argument(
"-i",
"--input",
nargs="+",
required=True,
metavar="PATH",
help="One or more lookup-hash JSONL files or directories",
)
parser.add_argument(
"-n",
"--max-samples",
type=int,
default=None,
metavar="N",
help="Maximum number of events to process (default: all)",
)
parser.add_argument(
"--model",
default=None,
metavar="NAME",
help="Filter events by model_name (exact match)",
)
parser.add_argument(
"--min-capacity-gib",
type=float,
default=0.5,
metavar="GiB",
help="Minimum cache capacity to sweep (default: 0.5 GiB)",
)
parser.add_argument(
"--max-capacity-gib",
type=float,
default=500.0,
metavar="GiB",
help="Maximum cache capacity to sweep (default: 500 GiB)",
)
parser.add_argument(
"--points",
type=int,
default=30,
metavar="N",
help="Number of log-spaced capacity samples (default: 30)",
)
parser.add_argument(
"--linear",
action="store_true",
help="Use a linear x-axis (default: log scale)",
)
parser.add_argument(
"--kv-bytes-per-chunk",
type=int,
default=None,
metavar="BYTES",
help=(
"Bytes consumed by one cached chunk. "
"Auto-computed from the first event's shapes/dtypes if omitted."
),
)
parser.add_argument(
"-o",
"--output",
default="hit_rate_vs_capacity.png",
metavar="FILE",
help="Output image path (default: hit_rate_vs_capacity.png)",
)
def run_sweep(args: argparse.Namespace) -> None:
"""Execute the sweep workflow from a parsed argument namespace.
Loads events, resolves ``kv_bytes_per_chunk``, sweeps across a log-spaced
range of cache capacities, prints a results table, and saves a hit-rate vs
capacity PNG. Called by both the module ``main()`` and by
:class:`~lmcache.cli.commands.tool.ToolCommand`.
Args:
args: Parsed CLI arguments. Must have the attributes registered by
:func:`add_sweep_arguments`.
"""
paths = [Path(p) for p in args.input]
print(f"Loading lookup events from {[str(p) for p in paths]} …")
events = load_lookup_events(paths, model=args.model, max_samples=args.max_samples)
print(f"Loaded {len(events):,} event(s)\n")
if not events:
print("No events to process.")
sys.exit(0)
kv_bpc = args.kv_bytes_per_chunk
if kv_bpc is None:
kv_bpc = compute_kv_bytes_per_chunk(events[0])
if kv_bpc == 0:
print(
"Error: could not determine kv_bytes_per_chunk from the first event "
"(shapes/dtypes are empty). Pass --kv-bytes-per-chunk explicitly.",
file=sys.stderr,
)
sys.exit(1)
print(f"Auto-detected kv_bytes_per_chunk = {kv_bpc:,} bytes")
chunk_size = events[0].get("chunk_size", "?")
model_label = args.model or "all models"
capacities_bytes = capacity_range_bytes(
args.min_capacity_gib, args.max_capacity_gib, args.points
)
hit_rates: list[float] = []
scale_label = "linear" if args.linear else "log"
print(
f"Sweeping {len(capacities_bytes)} capacity values "
f"({args.min_capacity_gib:.2f}{args.max_capacity_gib:.2f} GiB), "
f"chunk_size = {chunk_size} tokens, model = {model_label}\n"
)
print(f"{'Capacity (GiB)':>18} {'Hit rate':>10}")
print("-" * 32)
for cap_bytes in capacities_bytes:
cap_gib = cap_bytes / _GIB
res = simulate(events, cap_bytes, kv_bpc, fast=True)
rate = res["token_hit_rate"]
hit_rates.append(rate)
print(f"{cap_gib:>18.3f} {rate:>9.2%}")
# ── Plot ────────────────────────────────────────────────────────────────
x_values = [c / _GIB for c in capacities_bytes]
# Third Party
import matplotlib.pyplot as plt # noqa: PLC0415 — lazy import to avoid hard dependency
fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(
x_values,
[r * 100 for r in hit_rates],
marker="o",
linewidth=2,
markersize=4,
)
if not args.linear:
ax.set_xscale("log")
ax.set_xlabel("Cache capacity (GiB)", fontsize=12)
ax.set_ylabel("Token hit rate (%)", fontsize=12)
ax.set_title(
f"Token cache hit rate vs capacity\n"
f"(chunk_size = {chunk_size} tokens, {len(events):,} requests, "
f"model = {model_label}, {scale_label} scale)",
fontsize=11,
)
ax.set_ylim(0, 100)
ax.grid(True, which="both", linestyle="--", alpha=0.5)
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f"{y:.0f}%"))
fig.tight_layout()
fig.savefig(args.output, dpi=150)
print(f"\nPlot saved to '{args.output}'")
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Plot token cache hit rate vs cache capacity from lookup-hash JSONL logs"
)
)
add_sweep_arguments(parser)
args = parser.parse_args()
run_sweep(args)
if __name__ == "__main__":
main()