|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Benchmark to demonstrate cache improvements without requiring full LEANN installation. |
| 4 | +Simulates the query embedding computation and caching behavior. |
| 5 | +""" |
| 6 | + |
| 7 | +import hashlib |
| 8 | +import json |
| 9 | +import time |
| 10 | +from typing import Optional |
| 11 | + |
| 12 | +import numpy as np |
| 13 | + |
| 14 | + |
| 15 | +class QueryEmbeddingCache: |
| 16 | + """Hash-based cache for query embeddings to avoid recomputation.""" |
| 17 | + |
| 18 | + def __init__(self, max_size: int = 1000): |
| 19 | + self.cache: dict[str, np.ndarray] = {} |
| 20 | + self.max_size = max_size |
| 21 | + self.hits = 0 |
| 22 | + self.misses = 0 |
| 23 | + |
| 24 | + def _hash_query(self, query: str, query_template: Optional[str] = None) -> str: |
| 25 | + """Create hash key for query.""" |
| 26 | + key_data = { |
| 27 | + "query": query, |
| 28 | + "template": query_template or "", |
| 29 | + } |
| 30 | + key_str = json.dumps(key_data, sort_keys=True) |
| 31 | + return hashlib.sha256(key_str.encode()).hexdigest() |
| 32 | + |
| 33 | + def get(self, query: str, query_template: Optional[str] = None) -> Optional[np.ndarray]: |
| 34 | + """Get cached embedding if exists.""" |
| 35 | + key = self._hash_query(query, query_template) |
| 36 | + result = self.cache.get(key) |
| 37 | + if result is not None: |
| 38 | + self.hits += 1 |
| 39 | + else: |
| 40 | + self.misses += 1 |
| 41 | + return result |
| 42 | + |
| 43 | + def put(self, query: str, embedding: np.ndarray, query_template: Optional[str] = None): |
| 44 | + """Cache embedding.""" |
| 45 | + key = self._hash_query(query, query_template) |
| 46 | + |
| 47 | + # Simple LRU: remove oldest if cache is full |
| 48 | + if len(self.cache) >= self.max_size and key not in self.cache: |
| 49 | + first_key = next(iter(self.cache)) |
| 50 | + del self.cache[first_key] |
| 51 | + |
| 52 | + self.cache[key] = embedding.copy() |
| 53 | + |
| 54 | + |
| 55 | +def simulate_expensive_embedding(query: str, latency_ms: float = 15000) -> np.ndarray: |
| 56 | + """ |
| 57 | + Simulate expensive embedding computation. |
| 58 | + Issue #177 reports 13-19s per query, using 15s as average. |
| 59 | + """ |
| 60 | + # Scale down for faster testing (use 150ms instead of 15000ms) |
| 61 | + scaled_latency = latency_ms / 100 |
| 62 | + time.sleep(scaled_latency / 1000) |
| 63 | + return np.random.rand(384) # Typical embedding dimension |
| 64 | + |
| 65 | + |
| 66 | +def benchmark_without_cache(queries: list[str], latency_ms: float = 15000): |
| 67 | + """Benchmark without caching (current behavior from issue #177).""" |
| 68 | + print("\n" + "=" * 60) |
| 69 | + print("BENCHMARK: WITHOUT CACHE (Current Behavior)") |
| 70 | + print("=" * 60) |
| 71 | + |
| 72 | + total_start = time.time() |
| 73 | + times = [] |
| 74 | + |
| 75 | + for i, query in enumerate(queries, 1): |
| 76 | + start = time.time() |
| 77 | + simulate_expensive_embedding(query, latency_ms) |
| 78 | + elapsed = time.time() - start |
| 79 | + times.append(elapsed) |
| 80 | + print(f" Query {i} ('{query}'): {elapsed * 1000:.1f}ms") |
| 81 | + |
| 82 | + total_time = time.time() - total_start |
| 83 | + avg_time = sum(times) / len(times) |
| 84 | + |
| 85 | + print(f"\n Total time: {total_time:.2f}s") |
| 86 | + print(f" Average per query: {avg_time * 1000:.1f}ms") |
| 87 | + print(f" Estimated real-world (100x scale): {total_time * 100:.1f}s") |
| 88 | + |
| 89 | + return total_time, times |
| 90 | + |
| 91 | + |
| 92 | +def benchmark_with_cache(queries: list[str], latency_ms: float = 15000): |
| 93 | + """Benchmark with caching (optimized behavior).""" |
| 94 | + print("\n" + "=" * 60) |
| 95 | + print("BENCHMARK: WITH CACHE (Optimized Behavior)") |
| 96 | + print("=" * 60) |
| 97 | + |
| 98 | + cache = QueryEmbeddingCache(max_size=1000) |
| 99 | + total_start = time.time() |
| 100 | + times = [] |
| 101 | + |
| 102 | + for i, query in enumerate(queries, 1): |
| 103 | + start = time.time() |
| 104 | + |
| 105 | + # Check cache first |
| 106 | + cached = cache.get(query) |
| 107 | + if cached is not None: |
| 108 | + embedding = cached |
| 109 | + cache_hit = True |
| 110 | + else: |
| 111 | + embedding = simulate_expensive_embedding(query, latency_ms) |
| 112 | + cache.put(query, embedding) |
| 113 | + cache_hit = False |
| 114 | + |
| 115 | + elapsed = time.time() - start |
| 116 | + times.append(elapsed) |
| 117 | + status = "CACHE HIT" if cache_hit else "COMPUTED" |
| 118 | + print(f" Query {i} ('{query}'): {elapsed * 1000:.1f}ms [{status}]") |
| 119 | + |
| 120 | + total_time = time.time() - total_start |
| 121 | + avg_time = sum(times) / len(times) |
| 122 | + |
| 123 | + print(f"\n Total time: {total_time:.2f}s") |
| 124 | + print(f" Average per query: {avg_time * 1000:.1f}ms") |
| 125 | + print(f" Cache hits: {cache.hits}/{len(queries)} ({cache.hits / len(queries) * 100:.1f}%)") |
| 126 | + print(f" Cache misses: {cache.misses}/{len(queries)}") |
| 127 | + print(f" Estimated real-world (100x scale): {total_time * 100:.1f}s") |
| 128 | + |
| 129 | + return total_time, times, cache |
| 130 | + |
| 131 | + |
| 132 | +def main(): |
| 133 | + """Run benchmarks to demonstrate cache improvements.""" |
| 134 | + print("=" * 60) |
| 135 | + print("LEANN QUERY EMBEDDING CACHE BENCHMARK") |
| 136 | + print("=" * 60) |
| 137 | + print("\nSimulating issue #177 scenario:") |
| 138 | + print(" - Each query takes 13-19s (using 15s average)") |
| 139 | + print(" - Scaled down 100x for faster testing (150ms per query)") |
| 140 | + print(" - Testing with repeated queries to show cache benefit") |
| 141 | + print() |
| 142 | + |
| 143 | + # Test queries - includes repetitions to show cache benefit |
| 144 | + queries = [ |
| 145 | + "hello world", |
| 146 | + "search function", |
| 147 | + "Test query", |
| 148 | + "hello world", # Repeat |
| 149 | + "another query", |
| 150 | + "search function", # Repeat |
| 151 | + "hello world", # Repeat again |
| 152 | + "Test query", # Repeat |
| 153 | + "final query", |
| 154 | + "hello world", # Repeat many times |
| 155 | + ] |
| 156 | + |
| 157 | + print(f"Testing with {len(queries)} queries:") |
| 158 | + unique_queries = set(queries) |
| 159 | + print(f" Unique queries: {len(unique_queries)}") |
| 160 | + print(f" Repeated queries: {len(queries) - len(unique_queries)}") |
| 161 | + print() |
| 162 | + |
| 163 | + # Benchmark without cache |
| 164 | + time_without, _times_without = benchmark_without_cache(queries) |
| 165 | + |
| 166 | + # Benchmark with cache |
| 167 | + time_with, times_with, cache = benchmark_with_cache(queries) |
| 168 | + |
| 169 | + # Calculate improvements |
| 170 | + print("\n" + "=" * 60) |
| 171 | + print("RESULTS SUMMARY") |
| 172 | + print("=" * 60) |
| 173 | + print("\nWithout cache:") |
| 174 | + print(f" Total time: {time_without:.2f}s") |
| 175 | + print(f" Est. real-world: {time_without * 100:.1f}s ({time_without * 100 / 60:.1f} minutes)") |
| 176 | + |
| 177 | + print("\nWith cache:") |
| 178 | + print(f" Total time: {time_with:.2f}s") |
| 179 | + print(f" Est. real-world: {time_with * 100:.1f}s ({time_with * 100 / 60:.1f} minutes)") |
| 180 | + print(f" Cache hit rate: {cache.hits}/{len(queries)} ({cache.hits / len(queries) * 100:.1f}%)") |
| 181 | + |
| 182 | + speedup = time_without / time_with |
| 183 | + time_saved = time_without - time_with |
| 184 | + time_saved_real = time_saved * 100 |
| 185 | + |
| 186 | + print("\nImprovement:") |
| 187 | + print(f" Speedup: {speedup:.2f}x faster") |
| 188 | + print(f" Time saved (scaled): {time_saved:.2f}s") |
| 189 | + print( |
| 190 | + f" Time saved (real-world est.): {time_saved_real:.1f}s ({time_saved_real / 60:.1f} minutes)" |
| 191 | + ) |
| 192 | + |
| 193 | + # Per-query analysis |
| 194 | + print("\nPer-query breakdown:") |
| 195 | + cache_hits = [i for i, q in enumerate(queries) if queries[:i].count(q) > 0] |
| 196 | + cache_misses = [i for i in range(len(queries)) if i not in cache_hits] |
| 197 | + |
| 198 | + if cache_hits: |
| 199 | + avg_hit_time = sum(times_with[i] for i in cache_hits) / len(cache_hits) |
| 200 | + print( |
| 201 | + f" Avg cached query: {avg_hit_time * 1000:.3f}ms (est. real: {avg_hit_time * 100 * 1000:.1f}ms)" |
| 202 | + ) |
| 203 | + |
| 204 | + if cache_misses: |
| 205 | + avg_miss_time = sum(times_with[i] for i in cache_misses) / len(cache_misses) |
| 206 | + print( |
| 207 | + f" Avg uncached query: {avg_miss_time * 1000:.1f}ms (est. real: {avg_miss_time * 100:.0f}s)" |
| 208 | + ) |
| 209 | + |
| 210 | + print("\n" + "=" * 60) |
| 211 | + print("CONCLUSION") |
| 212 | + print("=" * 60) |
| 213 | + print( |
| 214 | + f"\nFor issue #177 workload with {cache.hits / len(queries) * 100:.0f}% repeated queries:" |
| 215 | + ) |
| 216 | + print(" - WITHOUT cache: Every query takes ~15s") |
| 217 | + print(" - WITH cache: Repeated queries are near-instant") |
| 218 | + print(f" - Overall speedup: {speedup:.1f}x") |
| 219 | + print("\nThis demonstrates the theoretical improvement from PR #226.") |
| 220 | + print("Real-world performance will vary based on:") |
| 221 | + print(" - Cache hit rate (how many queries are repeated)") |
| 222 | + print(" - ZMQ connection reuse overhead reduction (~10-50ms per query)") |
| 223 | + print(" - Model loading and server startup optimizations") |
| 224 | + |
| 225 | + |
| 226 | +if __name__ == "__main__": |
| 227 | + main() |
0 commit comments