Files
lmcache--lmcache/lmcache/tools/cache_simulator/plot_hit_rate.py
T
2026-07-13 12:24:33 +08:00

232 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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()