232 lines
6.8 KiB
Python
232 lines
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()
|