Files
2026-07-13 12:24:33 +08:00

796 lines
28 KiB
Python
Raw Permalink 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
"""
Cache hit-rate simulator driven by LMCache lookup-hash JSONL logs.
The simulator replays ``MP_LOOKUP`` events recorded by
:class:`~lmcache.v1.mp_observability.subscribers.logging.lookup_hash.LookupHashLoggingSubscriber`.
Each event contains the ordered list of *full-chunk* hashes that were looked up
for a single request, together with the sequence length and chunk size.
**Token cache hit rate** (the primary metric) is defined as::
token_hit_rate = total_hit_tokens / total_tokens
where:
* ``total_tokens`` = sum of ``seq_len`` across all requests (includes tail tokens
that do not fill a complete chunk — these are *always* a miss because LMCache
only caches complete chunks).
* ``total_hit_tokens`` = number of tokens covered by a *continuous prefix* of
cache-hit chunks at the start of each request, i.e.
``hit_prefix_chunks × chunk_size``.
Running the simulator prints a text report **and** saves a multi-panel PNG with
seven statistical charts.
Usage (module mode)::
python3 -m lmcache.tools.cache_simulator.simulator \\
-i /path/to/lookup_hashes/ \\
--cache-capacity-gib 64 \\
-o stats.png
"""
# Standard
from collections import defaultdict
from pathlib import Path
from typing import Any
import argparse
import json
import math
import sys
import warnings
# First Party
from lmcache.tools.cache_simulator.lru_cache import LRUCache, LRUCacheFast
# ---------------------------------------------------------------------------
# Dtype → bytes mapping
# ---------------------------------------------------------------------------
_DTYPE_BYTES: dict[str, int] = {
"float32": 4,
"float16": 2,
"bfloat16": 2,
"float8_e4m3fn": 1,
"float8_e5m2": 1,
"int8": 1,
"int32": 4,
"int64": 8,
}
_GIB = 2**30
# ---------------------------------------------------------------------------
# Public helpers
# ---------------------------------------------------------------------------
def compute_kv_bytes_per_chunk(event: dict[str, Any]) -> int:
"""
Compute the number of KV-cache bytes that one chunk occupies.
The value is derived from the ``shapes`` and ``dtypes`` fields of a single
lookup event. Each ``(shape, dtype)`` pair represents one tensor stored
per chunk (e.g. key and value tensors for all layers); their byte sizes are
summed.
Returns 0 if ``shapes`` or ``dtypes`` is empty (caller must handle this).
"""
shapes = event.get("shapes", [])
dtypes = event.get("dtypes", [])
if not shapes or not dtypes:
return 0
total = 0
for shape, dt in zip(shapes, dtypes, strict=False):
elem_bytes = _DTYPE_BYTES.get(dt, 0)
if elem_bytes == 0:
warnings.warn(
f"Unknown dtype '{dt}' — treating as 0 bytes per element.",
UserWarning,
stacklevel=2,
)
total += math.prod(shape) * elem_bytes
return total
def load_lookup_events(
paths: list[Path],
model: str | None = None,
max_samples: int | None = None,
) -> list[dict[str, Any]]:
"""
Load and return lookup events from one or more JSONL files or directories.
Parameters
----------
paths:
Each element may be a ``.jsonl`` file or a directory. Directories are
globbed for ``lookup_hashes_*.jsonl`` files.
model:
If given, only events whose ``model_name`` exactly matches this string
are returned.
max_samples:
If given, truncate the final sorted list to this many events.
Returns
-------
list[dict]
Events sorted by ``timestamp`` ascending.
"""
all_events: list[dict[str, Any]] = []
for p in paths:
files: list[Path]
if p.is_dir():
files = sorted(p.glob("lookup_hashes_*.jsonl"))
if not files:
warnings.warn(
f"Directory '{p}' contains no lookup_hashes_*.jsonl files.",
UserWarning,
stacklevel=2,
)
else:
files = [p]
for f in files:
try:
with open(f, encoding="utf-8") as fh:
for lineno, line in enumerate(fh, start=1):
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
except json.JSONDecodeError as exc:
warnings.warn(
f"{f}:{lineno}: skipping malformed JSON — {exc}",
UserWarning,
stacklevel=2,
)
continue
if model is not None and event.get("model_name") != model:
continue
all_events.append(event)
except OSError as exc:
warnings.warn(
f"Could not open '{f}': {exc}",
UserWarning,
stacklevel=2,
)
all_events.sort(key=lambda e: e.get("timestamp", 0.0))
if max_samples is not None and max_samples > 0:
all_events = all_events[:max_samples]
return all_events
# ---------------------------------------------------------------------------
# Simulation
# ---------------------------------------------------------------------------
def simulate(
events: list[dict[str, Any]],
cache_capacity_bytes: int,
kv_bytes_per_chunk: int,
fast: bool = False,
) -> dict[str, Any]:
"""
Replay lookup events through an LRU cache and compute token hit-rate
statistics.
Parameters
----------
events:
Lookup events as returned by :func:`load_lookup_events`.
cache_capacity_bytes:
Total cache capacity in bytes.
kv_bytes_per_chunk:
Bytes consumed by one cached chunk.
fast:
If ``True``, use :class:`~lmcache.tools.cache_simulator.lru_cache.LRUCacheFast`
and skip per-chunk statistics (faster for capacity sweeps).
Returns
-------
dict
Simulation results (see source for field list).
"""
if kv_bytes_per_chunk <= 0:
raise ValueError(
"kv_bytes_per_chunk must be > 0. "
"Either pass --kv-bytes-per-chunk or ensure the JSONL records "
"contain non-empty 'shapes' and 'dtypes' fields."
)
cache_capacity_chunks = max(1, cache_capacity_bytes // kv_bytes_per_chunk)
cache: LRUCacheFast | LRUCache
if fast:
cache = LRUCacheFast(cache_capacity_chunks)
else:
cache = LRUCache(cache_capacity_chunks)
# ── Aggregates ──────────────────────────────────────────────────────────
total_requests = 0
total_tokens = 0
total_hit_tokens = 0
# ── Per-request (skipped in fast mode) ──────────────────────────────────
per_request_token_hit_rates: list[float] = []
hit_prefix_lengths: list[int] = []
rolling_token_hit_rate: list[float] = []
input_lengths: list[int] = []
# ── Chunk-level (skipped in fast mode) ──────────────────────────────────
chunk_reuse_counts: dict[str, int] = defaultdict(int)
chunk_last_seen: dict[str, int] = {}
global_span_distribution: list[int] = []
cache_position_distribution: list[int] = []
global_chunk_index = 0
for event in events:
hashes: list[str] = event.get("chunk_hashes", [])
seq_len: int = event.get("seq_len", 0)
chunk_sz: int = event.get("chunk_size", 1)
if not hashes and seq_len == 0:
continue
# ── Prefix hit count ────────────────────────────────────────────────
hit_prefix = 0
for h in hashes:
if cache.contains(h):
hit_prefix += 1
else:
break
# ── Token accounting ────────────────────────────────────────────────
# Tail tokens (seq_len - len(hashes)*chunk_sz) are always a miss.
hit_tokens = hit_prefix * chunk_sz
request_tokens = seq_len # includes tail tokens
total_requests += 1
total_tokens += request_tokens
total_hit_tokens += hit_tokens
if not fast:
input_lengths.append(seq_len)
per_request_token_hit_rates.append(
hit_tokens / request_tokens if request_tokens > 0 else 0.0
)
hit_prefix_lengths.append(hit_prefix)
rolling_token_hit_rate.append(
total_hit_tokens / total_tokens if total_tokens > 0 else 0.0
)
# Per-hit-chunk statistics
for i, h in enumerate(hashes[:hit_prefix]):
chunk_reuse_counts[h] += 1
if h in chunk_last_seen:
global_span_distribution.append(
global_chunk_index + i - chunk_last_seen[h]
)
if isinstance(cache, LRUCache):
cache_position_distribution.append(cache.position(h))
# ── Update cache ────────────────────────────────────────────────────
for i, h in enumerate(hashes):
if not fast:
chunk_last_seen[h] = global_chunk_index + i
if i < hit_prefix:
cache.access(h)
else:
cache.insert(h)
if not fast:
global_chunk_index += len(hashes)
token_hit_rate = total_hit_tokens / total_tokens if total_tokens > 0 else 0.0
return {
# ── Aggregates ──────────────────────────────────────────────────────
"total_requests": total_requests,
"total_tokens": total_tokens,
"total_hit_tokens": total_hit_tokens,
"total_miss_tokens": total_tokens - total_hit_tokens,
"token_hit_rate": token_hit_rate,
"eviction_count": cache.eviction_count,
"cache_size_at_end_chunks": len(cache),
"cache_capacity_chunks": cache_capacity_chunks,
"cache_capacity_bytes": cache_capacity_bytes,
"kv_bytes_per_chunk": kv_bytes_per_chunk,
# ── Per-request ─────────────────────────────────────────────────────
"per_request_token_hit_rates": per_request_token_hit_rates,
"hit_prefix_lengths": hit_prefix_lengths,
"input_lengths": input_lengths,
"rolling_token_hit_rate": rolling_token_hit_rate,
# ── Chunk-level ─────────────────────────────────────────────────────
"chunk_reuse_counts": dict(chunk_reuse_counts),
"global_span_distribution": global_span_distribution,
"cache_position_distribution": cache_position_distribution,
}
# ---------------------------------------------------------------------------
# Reporting — text
# ---------------------------------------------------------------------------
def _percentiles(values: list[float], pcts: list[int]) -> dict[str, float]:
if not values:
return {}
s = sorted(values)
n = len(s)
result = {}
for p in pcts:
idx = min(int(p / 100 * n), n - 1)
result[f"p{p}"] = s[idx]
return result
def print_statistics(results: dict[str, Any]) -> None:
sep = "=" * 60
gib = results["cache_capacity_bytes"] / _GIB
print(sep)
print("Aggregate")
print(sep)
print(f" Requests processed : {results['total_requests']:,}")
print(f" Total tokens : {results['total_tokens']:,}")
print(f" Hit tokens : {results['total_hit_tokens']:,}")
print(f" Miss tokens : {results['total_miss_tokens']:,}")
print(f" Token hit rate : {results['token_hit_rate']:.2%}")
print(
f" Cache capacity : {gib:.2f} GiB "
f"({results['cache_capacity_chunks']:,} chunks × "
f"{results['kv_bytes_per_chunk']:,} bytes/chunk)"
)
print(
f" Cache occupancy : {results['cache_size_at_end_chunks']:,} / "
f"{results['cache_capacity_chunks']:,} chunks"
)
rates = results["per_request_token_hit_rates"]
if rates:
zero_hit = sum(1 for r in rates if r == 0.0)
full_hit = sum(1 for r in rates if r == 1.0)
pcts = _percentiles(rates, [25, 50, 75, 90, 99])
print()
print(sep)
print("Stat 1 — Per-request token hit rate distribution")
print(sep)
print(
f" Requests with 0% hit rate : "
f"{zero_hit:,} ({zero_hit / len(rates):.1%})"
)
print(
f" Requests with 100% hit rate : "
f"{full_hit:,} ({full_hit / len(rates):.1%})"
)
print(f" Mean : {sum(rates) / len(rates):.2%}")
for k, v in pcts.items():
print(f" {k:4s} : {v:.2%}")
lengths = results["hit_prefix_lengths"]
if lengths:
pcts_len = _percentiles([float(x) for x in lengths], [25, 50, 75, 90, 99])
print()
print(sep)
print("Stat 2 — Hit prefix length per request (chunks)")
print(sep)
print(f" Mean : {sum(lengths) / len(lengths):.1f}")
for k, v in pcts_len.items():
print(f" {k:4s} : {v:.0f}")
reuse = sorted(results["chunk_reuse_counts"].values())
if reuse:
pcts_reuse = _percentiles([float(x) for x in reuse], [25, 50, 75, 90, 99])
print()
print(sep)
print("Stat 3 — Chunk reuse count distribution")
print(sep)
print(f" Unique chunks hit at least once : {len(reuse):,}")
print(f" Mean reuse count : {sum(reuse) / len(reuse):.1f}")
print(f" Max reuse count : {reuse[-1]:,}")
for k, v in pcts_reuse.items():
print(f" {k:4s} : {v:.0f}")
rolling = results["rolling_token_hit_rate"]
if rolling:
print()
print(sep)
print("Stat 4 — Rolling (cumulative) token hit rate over time")
print(sep)
n = len(rolling)
for frac in (0.1, 0.25, 0.5, 0.75, 1.0):
idx = max(0, min(int(n * frac) - 1, n - 1))
print(f" After request {idx + 1:>6,} : {rolling[idx]:.2%}")
print()
print(sep)
print("Stat 5 — Evictions")
print(sep)
print(f" Total evictions : {results['eviction_count']:,}")
spans = results["global_span_distribution"]
if spans:
pcts_span = _percentiles([float(x) for x in spans], [25, 50, 75, 90, 99])
print()
print(sep)
print("Stat 6 — Global span distribution (chunks between last store and hit)")
print(sep)
print(f" Total hit chunks : {len(spans):,}")
print(f" Mean span : {sum(spans) / len(spans):.1f}")
print(f" Max span : {max(spans):,}")
for k, v in pcts_span.items():
print(f" {k:4s} : {v:.0f}")
positions = results["cache_position_distribution"]
if positions:
pcts_pos = _percentiles([float(x) for x in positions], [25, 50, 75, 90, 99])
print()
print(sep)
print("Stat 7 — Cache position at hit (0 = MRU, max = LRU)")
print(sep)
print(f" Mean position : {sum(positions) / len(positions):.1f}")
print(f" Max position : {max(positions):,}")
for k, v in pcts_pos.items():
print(f" {k:4s} : {v:.0f}")
print(sep)
# ---------------------------------------------------------------------------
# Reporting — charts
# ---------------------------------------------------------------------------
def plot_statistics(
results: dict[str, Any], events: list[dict[str, Any]], output: str
) -> None:
"""
Render and save a 2×4 multi-panel figure with seven statistical charts.
Parameters
----------
results:
Output of :func:`simulate` with ``fast=False``.
events:
The event list used to produce *results* (used for chunk_size label).
output:
Output file path (PNG).
"""
cap_gib = results["cache_capacity_bytes"] / _GIB
chunk_size = events[0].get("chunk_size", "?") if events else "?"
n_req = results["total_requests"]
per_request_hit_rates = [r * 100 for r in results["per_request_token_hit_rates"]]
hit_prefix_lengths = results["hit_prefix_lengths"]
reuse_counts = sorted(results["chunk_reuse_counts"].values())
rolling = [r * 100 for r in results["rolling_token_hit_rate"]]
input_lengths = results["input_lengths"]
global_spans = results["global_span_distribution"]
cache_positions = results["cache_position_distribution"]
# Third Party
import matplotlib.pyplot as plt # noqa: PLC0415 — lazy import to avoid hard dependency
fig, axes = plt.subplots(2, 4, figsize=(22, 10))
fig.suptitle(
f"Cache simulation statistics "
f"(chunk_size={chunk_size} tokens, capacity={cap_gib:.1f} GiB, "
f"{n_req:,} requests, token hit rate={results['token_hit_rate']:.2%})",
fontsize=12,
)
# ------------------------------------------------------------------
# Plot 1 — Per-request token hit rate (non-zero requests only)
# Two small pies: left = requests hit/miss, right = tokens hit/miss
# ------------------------------------------------------------------
ax = axes[0, 0]
nonzero = [r for r in per_request_hit_rates if r > 0]
n_zero = len(per_request_hit_rates) - len(nonzero)
ax.hist(nonzero, bins=50, edgecolor="black", linewidth=0.4)
ax.set_xlabel("Token hit rate (%) — zero-hit requests excluded")
ax.set_ylabel("Number of requests")
ax.set_title("1. Per-request token hit rate")
# Left pie — requests
ax_pie = ax.inset_axes([0.01, 0.52, 0.24, 0.42])
ax_pie.patch.set_alpha(0)
wedges, _, _ = ax_pie.pie(
[len(nonzero), n_zero],
labels=["hit", "miss"],
autopct="%1.0f%%",
startangle=90,
textprops={"fontsize": 5},
colors=["#4C72B0", "#DD8452"],
)
for w in wedges:
w.set_alpha(0.6)
ax_pie.set_title("requests", fontsize=5, pad=2)
ax_pie.text(
0.5,
-0.08,
"Fraction of requests\nwith ≥1 chunk hit",
transform=ax_pie.transAxes,
fontsize=5,
ha="center",
va="top",
color="dimgray",
)
# Right pie — tokens
ax_pie2 = ax.inset_axes([0.27, 0.52, 0.24, 0.42])
ax_pie2.patch.set_alpha(0)
wedges2, _, _ = ax_pie2.pie(
[results["total_hit_tokens"], results["total_miss_tokens"]],
labels=["hit", "miss"],
autopct="%1.0f%%",
startangle=90,
textprops={"fontsize": 5},
colors=["#4C72B0", "#DD8452"],
)
for w in wedges2:
w.set_alpha(0.6)
ax_pie2.set_title("tokens", fontsize=5, pad=2)
ax_pie2.text(
0.5,
-0.08,
"Fraction of tokens\nserved from cache",
transform=ax_pie2.transAxes,
fontsize=5,
ha="center",
va="top",
color="dimgray",
)
# ------------------------------------------------------------------
# Plot 1b — Zoom into 97100% hit rate
# ------------------------------------------------------------------
ax = axes[0, 1]
n_full = sum(1 for r in per_request_hit_rates if r == 100)
high = [r for r in nonzero if r >= 97]
ax.hist(high, bins=20, edgecolor="black", linewidth=0.4)
ax.set_xlim(97, 100)
ax.set_xlabel("Token hit rate (%) — 97100% zoom")
ax.set_ylabel("Number of requests")
ax.set_title("1b. Per-request token hit rate (97100%)")
ax.text(
0.03,
0.95,
f"100% hit: {n_full:,} requests",
transform=ax.transAxes,
fontsize=8,
ha="left",
va="top",
bbox=dict(boxstyle="round,pad=0.3", facecolor="wheat", alpha=0.7),
)
# ------------------------------------------------------------------
# Plot 2 — Hit prefix length per request (clean histogram, no pie)
# ------------------------------------------------------------------
ax = axes[0, 2]
nonzero_prefix = [n for n in hit_prefix_lengths if n > 0]
ax.hist(nonzero_prefix, bins=50, edgecolor="black", linewidth=0.4)
ax.set_xlabel("Hit prefix length (chunks) — zero-hit requests excluded")
ax.set_ylabel("Number of requests")
ax.set_title("2. Hit prefix length per request")
# Plot 3 — Chunk reuse count
# ------------------------------------------------------------------
ax = axes[0, 3]
if reuse_counts:
cap = min(max(reuse_counts), 100)
ax.hist(
[r for r in reuse_counts if r <= cap],
bins=range(1, cap + 2),
edgecolor="black",
linewidth=0.4,
)
if max(reuse_counts) > cap:
n_above = sum(1 for r in reuse_counts if r > cap)
pct_above = n_above / len(reuse_counts) * 100
ax.text(
0.97,
0.95,
f"max={max(reuse_counts):,}\n"
f"{n_above:,} chunks ({pct_above:.1f}%) above cap",
transform=ax.transAxes,
fontsize=8,
ha="right",
va="top",
bbox=dict(boxstyle="round,pad=0.3", facecolor="wheat", alpha=0.7),
)
ax.set_xlabel("Times a chunk was hit (capped at 100)")
ax.set_ylabel("Number of unique chunks")
ax.set_title("3. Chunk reuse count")
# ------------------------------------------------------------------
# Plot 4 — Rolling token hit rate over time
# ------------------------------------------------------------------
ax = axes[1, 0]
ax.plot(range(1, len(rolling) + 1), rolling, linewidth=1.5)
ax.set_xlabel("Request index")
ax.set_ylabel("Cumulative token hit rate (%)")
ax.set_title("4. Rolling token hit rate over time")
ax.set_ylim(0, 100)
ax.grid(True, linestyle="--", alpha=0.5)
# ------------------------------------------------------------------
# Plot 5 — Input length distribution
# ------------------------------------------------------------------
ax = axes[1, 1]
ax.hist(input_lengths, bins=50, edgecolor="black", linewidth=0.4)
ax.set_xlabel("Input length (tokens / seq_len)")
ax.set_ylabel("Number of requests")
ax.set_title("5. Input length per request")
# ------------------------------------------------------------------
# Plot 6 — Global span distribution
# ------------------------------------------------------------------
ax = axes[1, 2]
if global_spans:
ax.hist(global_spans, bins=50, edgecolor="black", linewidth=0.4)
ax.set_xlabel("Global span (chunks between last store and hit)")
ax.set_ylabel("Number of hit chunks")
ax.set_title("6. Global span distribution")
# ------------------------------------------------------------------
# Plot 7 — Cache position at hit time
# ------------------------------------------------------------------
ax = axes[1, 3]
if cache_positions:
ax.hist(cache_positions, bins=50, edgecolor="black", linewidth=0.4)
ax.set_xlabel("Cache position (0 = MRU, max = LRU)")
ax.set_ylabel("Number of hit chunks")
ax.set_title("7. Cache position at hit")
fig.tight_layout()
fig.savefig(output, dpi=150)
print(f"\nStats plot saved to '{output}'")
# ---------------------------------------------------------------------------
# CLI helpers — shared between the module entry point and lmcache tool
# ---------------------------------------------------------------------------
def add_simulate_arguments(parser: argparse.ArgumentParser) -> None:
"""Register all ``simulate`` 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(
"--cache-capacity-gib",
type=float,
required=True,
metavar="GiB",
help="Cache capacity in gibibytes",
)
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(
"--model",
default=None,
metavar="NAME",
help="Filter events by model_name (exact match)",
)
parser.add_argument(
"-o",
"--output",
default="cache_stats.png",
metavar="FILE",
help="Output image path (default: cache_stats.png)",
)
def run_simulate(args: argparse.Namespace) -> None:
"""Execute the simulate workflow from a parsed argument namespace.
Loads events, resolves ``kv_bytes_per_chunk``, runs the simulator, prints
a text report, and saves a statistics 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_simulate_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)")
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")
capacity_bytes = int(args.cache_capacity_gib * _GIB)
print("\nSimulation parameters:")
print(
f" Cache capacity : {args.cache_capacity_gib:.2f} GiB "
f"({capacity_bytes:,} bytes)"
)
print(f" KV bytes/chunk : {kv_bpc:,}")
chunk_sz = events[0].get("chunk_size", "?")
print(f" Chunk size : {chunk_sz} tokens")
if args.model:
print(f" Model filter : {args.model}")
print()
results = simulate(events, capacity_bytes, kv_bpc)
print_statistics(results)
plot_statistics(results, events, args.output)
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def main() -> None:
"""CLI entry point for ``python -m lmcache.tools.cache_simulator.simulator``.
Parses command-line arguments and delegates to :func:`run_simulate`.
"""
parser = argparse.ArgumentParser(
description=(
"Simulate LRU token cache hit rate from lookup-hash JSONL logs. "
"Prints a text report and saves a multi-panel statistics chart."
)
)
add_simulate_arguments(parser)
args = parser.parse_args()
run_simulate(args)
if __name__ == "__main__":
main()