472 lines
14 KiB
Python
472 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
# Standard
|
|
from collections import OrderedDict
|
|
from typing import List, Optional, Tuple, Union
|
|
import argparse
|
|
import json
|
|
|
|
# Third Party
|
|
from tqdm import tqdm
|
|
from transformers import AutoTokenizer
|
|
import matplotlib.pyplot as plt
|
|
import torch
|
|
|
|
# Constants
|
|
DEFAULT_TOKENIZER = "meta-llama/Llama-3.1-8B"
|
|
DEFAULT_TOKENS_PER_GB = 8200 # Default for Llama-3.1; More details here: https://docs.lmcache.ai/getting_started/kv_cache_calculator.html
|
|
DEFAULT_POOL_SIZES_GB: List[Union[int, float, str]] = [
|
|
1,
|
|
2,
|
|
4,
|
|
8,
|
|
16,
|
|
32,
|
|
50,
|
|
100,
|
|
200,
|
|
500,
|
|
"unlimited",
|
|
]
|
|
|
|
|
|
class LRUTokenPool:
|
|
"""
|
|
Token pool with LRU eviction policy based on token count limit.
|
|
"""
|
|
|
|
def __init__(self, max_tokens: float) -> None:
|
|
self.max_tokens = max_tokens
|
|
self.current_tokens = 0
|
|
self.requests: OrderedDict[int, List[int]] = OrderedDict()
|
|
|
|
def longest_prefix_len(self, tokens: List[int]) -> Tuple[int, int]:
|
|
"""
|
|
Find longest prefix match and update LRU ordering.
|
|
For request i (1-indexed):
|
|
y[i] = y[i-1] + (len(tokens[i]) - max_shared_prefix(tokens[i], any previous))
|
|
"""
|
|
best_len = 0
|
|
best_id = -1
|
|
|
|
for req_id, req_tokens in self.requests.items():
|
|
common_len = 0
|
|
for i in range(min(len(tokens), len(req_tokens))):
|
|
if tokens[i] == req_tokens[i]:
|
|
common_len += 1
|
|
else:
|
|
break
|
|
|
|
if common_len > best_len:
|
|
best_len = common_len
|
|
best_id = req_id
|
|
|
|
# Update LRU ordering
|
|
if best_id != -1:
|
|
self.requests.move_to_end(best_id)
|
|
|
|
return best_len, best_id
|
|
|
|
def longest_common_substring(
|
|
self,
|
|
request_id: int,
|
|
token_tensor: torch.Tensor,
|
|
tokens: List[int],
|
|
*,
|
|
chunk_len: int = 4,
|
|
stride_r: int = 4,
|
|
chunk_batch: int = 512,
|
|
) -> Tuple[int, float]:
|
|
"""
|
|
For token_tensor[request_id], chunk it and check whether each chunk
|
|
appears contiguously in any previous request (token_tensor[:request_id]).
|
|
Returns (total_tokens_matched, elapsed_seconds).
|
|
"""
|
|
assert token_tensor.ndim == 2, "Expected [N, T] tensor"
|
|
N, T = token_tensor.shape
|
|
assert 0 <= request_id < N, "request_id out of range"
|
|
|
|
if request_id == 0 or T < chunk_len:
|
|
return 0, 0
|
|
|
|
r = token_tensor[request_id] # [T]
|
|
r = r[: len(tokens)]
|
|
Xprev = token_tensor[:request_id] # [request_id, T]
|
|
|
|
# Sliding windows for previous rows
|
|
Xw = Xprev.unfold(dimension=1, size=chunk_len, step=1) # [R, W, L]
|
|
# Chunks of r
|
|
r_chunks = r.unfold(dimension=0, size=chunk_len, step=stride_r) # [C, L]
|
|
if r_chunks.numel() == 0:
|
|
return 0, 0
|
|
|
|
total_matched_chunks = 0
|
|
|
|
# Process in mini-batches to control memory
|
|
for b in range(0, r_chunks.size(0), chunk_batch):
|
|
rc = r_chunks[b : b + chunk_batch] # [B, L]
|
|
eq = Xw[:, :, None, :] == rc[None, None, :, :]
|
|
full = eq.all(dim=-1) # [R, W, B]
|
|
# Count how many unique chunks matched (across all previous rows)
|
|
matched_chunk_indices = torch.unique(full.nonzero(as_tuple=True)[2])
|
|
total_matched_chunks += matched_chunk_indices.numel()
|
|
|
|
total_tokens_matched = total_matched_chunks * chunk_len
|
|
|
|
return total_tokens_matched, 0
|
|
|
|
def add_request(
|
|
self,
|
|
request_id: int,
|
|
tokens: List[int],
|
|
token_tensor: Optional[torch.Tensor] = None,
|
|
) -> None:
|
|
"""
|
|
Add a request to the pool, evicting LRU entries if necessary.
|
|
"""
|
|
# Evict until we have space
|
|
while self.current_tokens + len(tokens) > self.max_tokens and self.requests:
|
|
old_id, old_tokens = self.requests.popitem(last=False)
|
|
self.current_tokens -= len(old_tokens)
|
|
|
|
# substring matching case
|
|
if token_tensor is not None:
|
|
token_tensor[old_id, :] = 0
|
|
|
|
# Add new request
|
|
self.requests[request_id] = tokens
|
|
self.current_tokens += len(tokens)
|
|
|
|
|
|
def load_and_tokenize_inputs(
|
|
jsonl_path: str, tokenizer_name: str = DEFAULT_TOKENIZER
|
|
) -> Tuple[List[List[int]], torch.Tensor]:
|
|
"""
|
|
Load and tokenize inputs from a JSONL file.
|
|
|
|
Returns:
|
|
Tuple of (tokenized_sequences_list, tokenized_sequences_tensor)
|
|
- tokenized_sequences_list: List of token lists
|
|
- tokenized_sequences_tensor: Padded 2D tensor (sequences, tokens)
|
|
Sequences are padded with 0s to match the longest sequence.
|
|
"""
|
|
print(f"Loading tokenizer: {tokenizer_name}")
|
|
tokenizer = AutoTokenizer.from_pretrained(tokenizer_name)
|
|
|
|
print(f"Reading and tokenizing inputs from: {jsonl_path}")
|
|
tokenized_sequences = []
|
|
|
|
with open(jsonl_path, "r", encoding="utf-8") as f:
|
|
lines = f.readlines()
|
|
|
|
for line in tqdm(lines, desc="Tokenizing"):
|
|
try:
|
|
data = json.loads(line.strip())
|
|
input_text = data.get("input", "")
|
|
tokens = tokenizer.encode(input_text)
|
|
tokenized_sequences.append(tokens)
|
|
except Exception as e:
|
|
print(f"Warning: Failed to process line: {e}")
|
|
tokenized_sequences.append([])
|
|
|
|
if tokenized_sequences:
|
|
max_length = max(len(seq) for seq in tokenized_sequences)
|
|
num_sequences = len(tokenized_sequences)
|
|
|
|
# Create padded tensor (pad with 0s)
|
|
tokenized_tensor = torch.zeros((num_sequences, max_length), dtype=torch.long)
|
|
for i, seq in enumerate(tokenized_sequences):
|
|
if seq:
|
|
tokenized_tensor[i, : len(seq)] = torch.tensor(seq, dtype=torch.long)
|
|
else:
|
|
tokenized_tensor = torch.tensor([], dtype=torch.long)
|
|
|
|
return tokenized_sequences, tokenized_tensor
|
|
|
|
|
|
def calculate_hit_rate(
|
|
token_sequences: List[List[int]],
|
|
pool_size: Optional[int] = None,
|
|
token_tensor: Optional[torch.Tensor] = None,
|
|
method: str = "prefix",
|
|
) -> float:
|
|
# Use float('inf') for unlimited case to avoid eviction
|
|
max_tokens = float("inf") if pool_size is None else pool_size
|
|
pool = LRUTokenPool(max_tokens)
|
|
|
|
total_tokens = 0
|
|
hit_tokens = 0
|
|
|
|
total_lcs_time_s = 0.0
|
|
lcs_calls = 0
|
|
|
|
for idx, tokens in tqdm(list(enumerate(token_sequences))):
|
|
total_tokens += len(tokens)
|
|
|
|
if method == "prefix":
|
|
if idx > 0:
|
|
common, _ = pool.longest_prefix_len(tokens)
|
|
hit_tokens += common
|
|
pool.add_request(idx, tokens)
|
|
elif method == "substring" and token_tensor is not None:
|
|
if idx > 0:
|
|
common, elapsed = pool.longest_common_substring(
|
|
idx, token_tensor, tokens
|
|
)
|
|
hit_tokens += common
|
|
total_lcs_time_s += elapsed
|
|
lcs_calls += 1
|
|
pool.add_request(idx, tokens, token_tensor)
|
|
else:
|
|
raise ValueError(f"Invalid method: {method}")
|
|
|
|
if method == "substring":
|
|
avg_ms = (total_lcs_time_s / lcs_calls * 1000.0) if lcs_calls > 0 else 0.0
|
|
print(
|
|
f" [Timing] longest_common_substring: total {total_lcs_time_s:.3f}s, "
|
|
f"calls {lcs_calls}, avg {avg_ms:.2f} ms"
|
|
)
|
|
|
|
return hit_tokens / total_tokens if total_tokens > 0 else 0.0
|
|
|
|
|
|
def analyze_hit_rates_across_pool_sizes(
|
|
token_sequences: List[List[int]],
|
|
pool_sizes_gb: List[Union[int, float, str]],
|
|
tokens_per_gb: int,
|
|
token_tensor: Optional[torch.Tensor] = None,
|
|
) -> Tuple[List[float], List[float], List[str]]:
|
|
print("\nAnalyzing hit rates across pool sizes...")
|
|
print("=" * 60)
|
|
|
|
prefix_hit_rates = []
|
|
substring_hit_rates = []
|
|
x_labels = []
|
|
|
|
for size_gb in pool_sizes_gb:
|
|
if size_gb == "unlimited":
|
|
size_tokens = None
|
|
x_labels.append("∞")
|
|
pool_desc = "unlimited"
|
|
token_desc = ""
|
|
else:
|
|
size_tokens = int(size_gb * tokens_per_gb)
|
|
x_labels.append(str(int(size_gb)))
|
|
pool_desc = f"{size_gb}GB"
|
|
token_desc = f" ({size_tokens:,} tokens)"
|
|
|
|
print(f"Testing pool size: {pool_desc}{token_desc}")
|
|
|
|
# For every pool size round, we should start from fresh
|
|
tensor_copy = token_tensor.clone() if token_tensor is not None else None
|
|
|
|
prefix_hit_rate = calculate_hit_rate(
|
|
token_sequences, size_tokens, tensor_copy, method="prefix"
|
|
)
|
|
prefix_hit_rates.append(prefix_hit_rate)
|
|
print(f" Prefix: {prefix_hit_rate:.4f} ({prefix_hit_rate * 100:.2f}%)")
|
|
|
|
substring_hit_rate = calculate_hit_rate(
|
|
token_sequences, size_tokens, tensor_copy, method="substring"
|
|
)
|
|
substring_hit_rates.append(substring_hit_rate)
|
|
print(
|
|
f" Substring: {substring_hit_rate:.4f} ({substring_hit_rate * 100:.2f}%)\n"
|
|
)
|
|
|
|
print("=" * 60)
|
|
return prefix_hit_rates, substring_hit_rates, x_labels
|
|
|
|
|
|
def plot_hit_rates(
|
|
prefix_hit_rates: List[float],
|
|
substring_hit_rates: List[float],
|
|
x_labels: List[str],
|
|
output_path: str,
|
|
) -> None:
|
|
"""
|
|
Generate and save the hit rate vs pool size plot comparing both methods.
|
|
"""
|
|
plt.figure(figsize=(12, 7))
|
|
|
|
# Plot prefix
|
|
plt.plot(
|
|
range(len(prefix_hit_rates)),
|
|
prefix_hit_rates,
|
|
marker="o",
|
|
linewidth=2,
|
|
markersize=8,
|
|
color="#2E86AB",
|
|
label="Prefix Matching",
|
|
)
|
|
|
|
# Plot substring
|
|
plt.plot(
|
|
range(len(substring_hit_rates)),
|
|
substring_hit_rates,
|
|
marker="s",
|
|
linewidth=2,
|
|
markersize=8,
|
|
color="#A23B72",
|
|
label="Substring Matching",
|
|
)
|
|
|
|
plt.xlabel("Pool Size (GB)", fontsize=12, fontweight="bold")
|
|
plt.ylabel("Hit Rate", fontsize=12, fontweight="bold")
|
|
plt.title(
|
|
"Cache Hit Rate vs Pool Size: Prefix vs Substring Matching",
|
|
fontsize=14,
|
|
fontweight="bold",
|
|
)
|
|
plt.xticks(range(len(x_labels)), x_labels, rotation=45)
|
|
plt.grid(True, alpha=0.3, linestyle="--")
|
|
|
|
# Set y-axis limit based on max of both methods
|
|
max_rate = max(max(prefix_hit_rates), max(substring_hit_rates))
|
|
plt.ylim(0, min(1.0, max_rate * 1.1))
|
|
plt.legend(loc="best", fontsize=10)
|
|
|
|
# Annotate prefix matching rates
|
|
for i, (rate, label) in enumerate(zip(prefix_hit_rates, x_labels, strict=False)):
|
|
plt.annotate(
|
|
f"{rate * 100:.1f}%",
|
|
xy=(i, rate),
|
|
xytext=(0, 8),
|
|
textcoords="offset points",
|
|
ha="center",
|
|
fontsize=8,
|
|
color="#2E86AB",
|
|
)
|
|
|
|
# Annotate substring matching rates
|
|
for i, (rate, label) in enumerate(zip(substring_hit_rates, x_labels, strict=False)):
|
|
plt.annotate(
|
|
f"{rate * 100:.1f}%",
|
|
xy=(i, rate),
|
|
xytext=(0, -15),
|
|
textcoords="offset points",
|
|
ha="center",
|
|
fontsize=8,
|
|
color="#A23B72",
|
|
)
|
|
|
|
plt.tight_layout()
|
|
plt.savefig(output_path, dpi=150, bbox_inches="tight")
|
|
print(f"Plot saved to: {output_path}")
|
|
|
|
|
|
def parse_arguments() -> argparse.Namespace:
|
|
"""Parse command-line arguments."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Analyze prefix cache hit rates across different pool sizes",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
%(prog)s -i trace.jsonl
|
|
%(prog)s -i trace.jsonl -o custom_output.png
|
|
%(prog)s -i trace.jsonl --pool-sizes 1 2 4 8 16 unlimited
|
|
""",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-i",
|
|
"--input",
|
|
type=str,
|
|
required=True,
|
|
help="Path to input JSONL file (trace.jsonl)",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-o",
|
|
"--output",
|
|
type=str,
|
|
default="prefix_cache_hit_rate.png",
|
|
help="Path to output plot file (PNG) (default: prefix_cache_hit_rate.png)",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--tokenizer",
|
|
type=str,
|
|
default=DEFAULT_TOKENIZER,
|
|
help=f"HuggingFace tokenizer model name (default: {DEFAULT_TOKENIZER})",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--tokens-per-gb",
|
|
type=int,
|
|
default=DEFAULT_TOKENS_PER_GB,
|
|
help=f"Conversion factor from GB to tokens "
|
|
f"(default: {DEFAULT_TOKENS_PER_GB}). "
|
|
"This should be adjusted when using a different tokenizer.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--pool-sizes",
|
|
nargs="+",
|
|
default=None,
|
|
help='Pool sizes in GB to test (space-separated, can include "unlimited"). '
|
|
f"Default: {' '.join(map(str, DEFAULT_POOL_SIZES_GB))}",
|
|
)
|
|
|
|
return parser.parse_args()
|
|
|
|
|
|
def parse_pool_sizes(
|
|
pool_sizes_input: Optional[List[str]],
|
|
) -> List[Union[int, float, str]]:
|
|
if pool_sizes_input is None:
|
|
return DEFAULT_POOL_SIZES_GB
|
|
|
|
parsed_sizes: List[Union[int, float, str]] = []
|
|
for size in pool_sizes_input:
|
|
if size.lower() == "unlimited":
|
|
parsed_sizes.append("unlimited")
|
|
else:
|
|
try:
|
|
parsed_sizes.append(float(size))
|
|
except ValueError:
|
|
raise ValueError(
|
|
f"Invalid pool size: {size}. Must be a number or 'unlimited'"
|
|
) from None
|
|
|
|
return parsed_sizes
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_arguments()
|
|
|
|
# Parse pool sizes
|
|
pool_sizes_gb = parse_pool_sizes(args.pool_sizes)
|
|
|
|
print("Configuration:")
|
|
print(f" Input: {args.input}")
|
|
print(f" Output: {args.output}")
|
|
print(f" Tokenizer: {args.tokenizer}")
|
|
print(f" Tokens per GB: {args.tokens_per_gb}")
|
|
print(f" Pool sizes: {pool_sizes_gb}\n")
|
|
|
|
# Load and tokenize inputs
|
|
token_sequences, token_tensor = load_and_tokenize_inputs(args.input, args.tokenizer)
|
|
print(f"Loaded {len(token_sequences)} requests")
|
|
print(f"Token tensor shape: {token_tensor.shape} (padded with 0s)")
|
|
print(f"First sequence: {token_tensor[0]}")
|
|
|
|
# Analyze hit rates using both methods
|
|
prefix_hit_rates, substring_hit_rates, x_labels = (
|
|
analyze_hit_rates_across_pool_sizes(
|
|
token_sequences,
|
|
pool_sizes_gb,
|
|
args.tokens_per_gb,
|
|
token_tensor,
|
|
)
|
|
)
|
|
|
|
# Generate comparison plot
|
|
plot_hit_rates(prefix_hit_rates, substring_hit_rates, x_labels, args.output)
|
|
print("\nAnalysis complete!")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|