#!/usr/bin/env python3 """ FlashLib IVF (GPU) vs FAISS IVF (CPU) head-to-head for LEANN. This is the apples-to-apples *approximate* comparison: both backends are IVF-Flat (inverted file) indexes that coarse-quantize the corpus into ``nlist`` cells and, at search time, scan only the ``nprobe`` nearest cells. At a fixed ``(nlist, nprobe)`` the two probe (almost) the same candidate set, so recall is comparable - the only difference is GPU vs CPU kernels. (Contrast with ``flashlib_vs_hnsw_speed_comparison.py``, which compares *exact* GPU k-NN against exact CPU flat search.) Backends compared, per corpus size, at a shared ``nlist`` and across an ``nprobe`` sweep: - ``flashlib_ivf (GPU)`` -> the LEANN ``flashlib_ivf`` backend (``packages/leann-backend-flashlib-ivf``): FlashLib ``flash_ivf_flat`` on CUDA tensors. - ``ivf (CPU)`` -> the LEANN ``ivf`` backend (``packages/leann-backend-ivf``): FAISS ``IndexIVFFlat`` on CPU. Both are driven through the LEANN backend registry (the real builders/searchers), so this measures what each backend actually does. Distance metric is cosine (vectors are L2-normalized; FlashLib IVF ranks by squared-L2, FAISS by inner product - equivalent on normalized vectors). Metrics per (size, nprobe): single-query latency (median ms), batched throughput (queries/s), recall@k vs exact ground truth (for BOTH backends), and the GPU/CPU speedup. Build time and index size are reported once per (size, nlist). Data is a mixture-of-Gaussians (clustered + L2-normalized) to mimic the local structure of real embeddings, so IVF coarse quantization behaves realistically. Requirements: a CUDA GPU, ``flashlib``, ``torch``+CUDA, ``faiss-cpu``, ``leann-core``, ``leann-backend-ivf`` and ``leann-backend-flashlib-ivf``. Examples: # laptop-like CPU budget (8 threads) for the FAISS baseline python benchmarks/flashlib_ivf_vs_faiss_ivf.py --sizes 100000 1000000 --cpu-threads 8 # single 1M run, custom nlist and nprobe sweep python benchmarks/flashlib_ivf_vs_faiss_ivf.py --sizes 1000000 \ --nlist 4096 --nprobe-sweep 1 8 32 128 Note: importing ``leann`` pulls in ``leann_backend_hnsw`` (LEANN's API imports it at module load). From a source checkout whose compiled HNSW backend is not installed (e.g. glibc < 2.35), put the pure-Python package on the path first: PYTHONPATH=packages/leann-backend-hnsw python benchmarks/flashlib_ivf_vs_faiss_ivf.py """ # ruff: noqa: E402 (BLAS env vars must be set before importing numpy / faiss) import os import sys def _argv_value(flag: str, default: str) -> str: """Read ``--flag value`` from argv before argparse, so we can pin BLAS thread counts BEFORE numpy/faiss import (their thread pools are fixed at import time).""" if flag in sys.argv: i = sys.argv.index(flag) if i + 1 < len(sys.argv): return sys.argv[i + 1] return default # FAISS CPU search latency is governed by the BLAS thread pool, which is read at # import time - so pin it here, before importing numpy/faiss, to the requested CPU # budget. ``--cpu-threads 0`` means "all cores" (capped at 32: 192-thread OpenBLAS # both crashes with "too many memory regions" and yields no benefit here). _cpu_threads = int(_argv_value("--cpu-threads", "0")) _blas = str(_cpu_threads) if _cpu_threads > 0 else str(min(os.cpu_count() or 1, 32)) for _v in ("OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "NUMEXPR_NUM_THREADS", "OMP_NUM_THREADS"): os.environ[_v] = _blas import argparse import gc import json import math import tempfile import time from pathlib import Path from typing import Any import numpy as np def _fail(msg: str) -> None: print(f"\n[ERROR] {msg}") sys.exit(1) def _normalize(x: np.ndarray) -> np.ndarray: norms = np.linalg.norm(x, axis=1, keepdims=True) norms[norms == 0] = 1.0 return np.ascontiguousarray(x / norms) def make_clustered_data(n_db: int, n_query: int, dim: int, seed: int, cluster_std: float): """Mixture-of-Gaussians, L2-normalized: a stand-in for real embeddings that have local cluster structure (so IVF coarse quantization is representative).""" rng = np.random.default_rng(seed) n_clusters = max(16, min(n_db // 100, 8192)) centers = rng.standard_normal((n_clusters, dim), dtype=np.float32) centers /= np.linalg.norm(centers, axis=1, keepdims=True) assign = rng.integers(0, n_clusters, size=n_db) db = centers[assign] + cluster_std * rng.standard_normal((n_db, dim)).astype(np.float32) q_assign = rng.integers(0, n_clusters, size=n_query) queries = centers[q_assign] + cluster_std * rng.standard_normal((n_query, dim)).astype( np.float32 ) return _normalize(db.astype(np.float32)), _normalize(queries.astype(np.float32)) def exact_ground_truth(db: np.ndarray, queries: np.ndarray, top_k: int): """Exact top-k by cosine (== inner product on normalized vectors), on GPU, chunked over queries to bound memory.""" import torch db_t = torch.from_numpy(db).cuda() q_t = torch.from_numpy(queries).cuda() out = np.empty((queries.shape[0], top_k), dtype=np.int64) step = 256 for i in range(0, q_t.shape[0], step): scores = q_t[i : i + step] @ db_t.T out[i : i + step] = scores.topk(top_k, dim=1, largest=True).indices.cpu().numpy() del db_t, q_t torch.cuda.empty_cache() return out def recall_at_k(found: np.ndarray, truth: np.ndarray) -> float: k = truth.shape[1] return float(np.mean([len(set(found[i]) & set(truth[i])) / k for i in range(truth.shape[0])])) def best_time(fn, n_repeat: int) -> float: best = float("inf") for _ in range(n_repeat): t = time.perf_counter() fn() best = min(best, time.perf_counter() - t) return best def measure(search_fn, queries: np.ndarray, n_single: int, n_repeat: int): """Single-query latency (median ms over n_single individual queries) and batched throughput (q/s, best of n_repeat for all queries at once).""" for _ in range(3): # warmup (FlashLib JIT-compiles per batch shape) search_fn(queries[:1]) search_fn(queries) per_query = [] for i in range(min(n_single, queries.shape[0])): t = time.perf_counter() search_fn(queries[i : i + 1]) per_query.append((time.perf_counter() - t) * 1000.0) single_ms = float(np.median(per_query)) batch_time = best_time(lambda: search_fn(queries), n_repeat) return single_ms, queries.shape[0] / batch_time def auto_nlist(n_db: int) -> int: """A sane default nlist ~ 4*sqrt(N), clamped, rounded to a power of two.""" target = 4 * math.sqrt(max(n_db, 1)) p = 2 ** round(math.log2(max(target, 256))) return int(max(256, min(p, 16384))) def _write_meta(index_path: str, backend_name: str, dim: int) -> None: Path(f"{index_path}.meta.json").write_text( json.dumps( { "version": "1.0", "backend_name": backend_name, "embedding_model": "synthetic", "dimensions": dim, "backend_kwargs": {"distance_metric": "cosine"}, "embedding_mode": "sentence-transformers", "passage_sources": [], } ) ) def _index_size_mb(index_path: str) -> float: stem = Path(index_path).stem parent = Path(index_path).parent total = 0 for p in parent.glob(f"{stem}.*"): if p.name.endswith(".meta.json"): continue total += p.stat().st_size return total / (1024 * 1024) def build_backend(name: str, db, ids, index_path: str, nlist: int, nprobe: int) -> dict[str, Any]: from leann.registry import BACKEND_REGISTRY kwargs = { "dimensions": db.shape[1], "distance_metric": "cosine", "nlist": nlist, "nprobe": nprobe, } t = time.perf_counter() BACKEND_REGISTRY[name].builder(**kwargs).build(db, ids, index_path, **kwargs) build_time = time.perf_counter() - t _write_meta(index_path, name, db.shape[1]) return {"build_time": build_time, "index_size_mb": _index_size_mb(index_path)} def make_searcher(name: str, index_path: str): from leann.registry import BACKEND_REGISTRY return BACKEND_REGISTRY[name].searcher(index_path, enable_warmup=False, use_daemon=False) def run_search(searcher, queries, top_k, truth, nprobe, n_single, n_repeat) -> dict[str, Any]: def search(x): return searcher.search(x, top_k=top_k, nprobe=nprobe, recompute_embeddings=False) single_ms, qps = measure(search, queries, n_single, n_repeat) out = search(queries) found = np.array( [[int(x) if x.lstrip("-").isdigit() else -1 for x in row] for row in out["labels"]], dtype=np.int64, ) recall = recall_at_k(found, truth) return {"single_ms": single_ms, "throughput_qps": qps, "recall": recall} def run_size(n_db: int, args) -> dict[str, Any]: import faiss import torch nlist = args.nlist if args.nlist > 0 else auto_nlist(n_db) print(f"\n{'=' * 80}") print( f"Corpus: {n_db:,} vectors x {args.dim} dims | cosine | nlist={nlist} | " f"{args.queries} queries | top_k={args.top_k}" ) print(f"{'=' * 80}") db, queries = make_clustered_data(n_db, args.queries, args.dim, args.seed, args.cluster_std) ids = [str(i) for i in range(n_db)] print("Computing exact ground truth (GPU)...") truth = exact_ground_truth(db, queries, args.top_k) nprobe_sweep = [p for p in args.nprobe_sweep if p <= nlist] result: dict[str, Any] = { "n_db": n_db, "nlist": nlist, "nprobe_sweep": nprobe_sweep, "rows": [], } with tempfile.TemporaryDirectory() as tmp: # ---- Build both backends once (build cost is a one-time offline step). ---- faiss.omp_set_num_threads(min(os.cpu_count() or 1, 64)) # build uses many cores gpu_path = str(Path(tmp) / "flashlib_ivf.leann") cpu_path = str(Path(tmp) / "ivf.leann") print("Building flashlib_ivf (GPU)...") gb = build_backend("flashlib_ivf", db, ids, gpu_path, nlist, max(nprobe_sweep)) print(f" build {gb['build_time']:.2f}s | index {gb['index_size_mb']:.1f} MB") print("Building ivf (FAISS, CPU)...") cb = build_backend("ivf", db, ids, cpu_path, nlist, max(nprobe_sweep)) print(f" build {cb['build_time']:.2f}s | index {cb['index_size_mb']:.1f} MB") result["flashlib_ivf_build"] = gb result["ivf_build"] = cb # ---- Sweep nprobe; reuse a single searcher per backend across the sweep. ---- gpu_searcher = make_searcher("flashlib_ivf", gpu_path) cpu_searcher = make_searcher("ivf", cpu_path) faiss.omp_set_num_threads(args.n_threads) # constrain SEARCH to CPU budget for nprobe in nprobe_sweep: g = run_search( gpu_searcher, queries, args.top_k, truth, nprobe, args.single_queries, args.repeat ) c = run_search( cpu_searcher, queries, args.top_k, truth, nprobe, args.single_queries, args.repeat ) row = {"nprobe": nprobe, "gpu": g, "cpu": c} result["rows"].append(row) lat = c["single_ms"] / g["single_ms"] if g["single_ms"] else float("nan") tpt = g["throughput_qps"] / c["throughput_qps"] if c["throughput_qps"] else float("nan") print( f" nprobe={nprobe:<4} | " f"GPU {g['single_ms']:7.3f}ms {g['throughput_qps']:>10,.0f}q/s r{g['recall']:.3f} | " f"CPU {c['single_ms']:7.3f}ms {c['throughput_qps']:>10,.0f}q/s r{c['recall']:.3f} | " f"speedup {lat:5.1f}x lat {tpt:6.1f}x tpt" ) del gpu_searcher, cpu_searcher gc.collect() torch.cuda.empty_cache() return result def print_summary(rows: list[dict[str, Any]], args) -> None: print(f"\n\n{'#' * 84}") print("# SUMMARY: flashlib_ivf (GPU) vs ivf (FAISS, CPU) - matched nlist, nprobe sweep") print(f"# CPU baseline used {args.n_threads} thread(s); metric=cosine; top_k={args.top_k}") print(f"{'#' * 84}") rcol = f"R@{args.top_k}" hdr = ( f"{'nprobe':>6} | {'GPU ms':>8} {'GPU q/s':>11} {rcol:>6} | " f"{'CPU ms':>8} {'CPU q/s':>11} {rcol:>6} | {'lat x':>6} {'tpt x':>6}" ) for r in rows: gb, cb = r["flashlib_ivf_build"], r["ivf_build"] print(f"\n{'-' * len(hdr)}") print( f"Corpus {r['n_db']:,} x {args.dim}d | nlist={r['nlist']} | " f"build: GPU {gb['build_time']:.1f}s ({gb['index_size_mb']:.0f}MB) vs " f"CPU {cb['build_time']:.1f}s ({cb['index_size_mb']:.0f}MB)" ) print("-" * len(hdr)) print(hdr) print("-" * len(hdr)) for row in r["rows"]: g, c = row["gpu"], row["cpu"] lat = c["single_ms"] / g["single_ms"] if g["single_ms"] else float("nan") tpt = g["throughput_qps"] / c["throughput_qps"] if c["throughput_qps"] else float("nan") print( f"{row['nprobe']:>6} | {g['single_ms']:>8.3f} {g['throughput_qps']:>11,.0f} " f"{g['recall']:>6.3f} | {c['single_ms']:>8.3f} {c['throughput_qps']:>11,.0f} " f"{c['recall']:>6.3f} | {lat:>6.1f} {tpt:>6.1f}" ) def main() -> None: p = argparse.ArgumentParser( description="FlashLib IVF (GPU) vs FAISS IVF (CPU) comparison for LEANN.", formatter_class=argparse.RawDescriptionHelpFormatter, ) p.add_argument("--sizes", type=int, nargs="+", default=[100_000, 1_000_000]) p.add_argument("--dim", type=int, default=768) p.add_argument("--queries", type=int, default=1000) p.add_argument("--top-k", type=int, default=10) p.add_argument("--nlist", type=int, default=0, help="IVF partitions (0 = auto ~4*sqrt(N))") p.add_argument("--nprobe-sweep", type=int, nargs="+", default=[1, 4, 8, 16, 32, 64]) p.add_argument( "--cpu-threads", type=int, default=0, help="FAISS CPU search threads (0 = all cores, capped at 32).", ) p.add_argument("--cluster-std", type=float, default=0.1, help="Cluster spread (lower=tighter)") p.add_argument("--single-queries", type=int, default=200) p.add_argument("--repeat", type=int, default=5) p.add_argument("--seed", type=int, default=42) args = p.parse_args() try: import torch except ImportError: _fail("PyTorch is required (with CUDA).") if not torch.cuda.is_available(): _fail("FlashLib IVF is GPU-only, but no CUDA GPU is available.") try: import faiss import flashlib except ImportError as e: _fail(f"Missing dependency: {e}. Need 'flashlib' and 'faiss-cpu'.") from leann.registry import BACKEND_REGISTRY, autodiscover_backends autodiscover_backends() for need in ("ivf", "flashlib_ivf"): if need not in BACKEND_REGISTRY: _fail( f"Backend '{need}' not registered. Install it: " f"pip install -e packages/leann-backend-{need.replace('_', '-')}" ) all_cores = os.cpu_count() or 1 args.n_threads = args.cpu_threads if args.cpu_threads > 0 else min(all_cores, 32) print("FlashLib IVF (GPU) vs FAISS IVF (CPU) comparison for LEANN") print(f"GPU: {torch.cuda.get_device_name(0)} | CPU cores available: {all_cores}") print( f"flashlib {flashlib.__version__} | faiss {faiss.__version__} | torch {torch.__version__}" ) print( f"Config: dim={args.dim}, queries={args.queries}, top_k={args.top_k}, " f"FAISS search threads={args.n_threads} (build uses up to 64), " f"nprobe_sweep={args.nprobe_sweep}" ) rows = [run_size(n, args) for n in args.sizes] print_summary(rows, args) print("\nDone.") if __name__ == "__main__": try: main() except KeyboardInterrupt: print("\nInterrupted.") sys.exit(130) finally: sys.stdout.flush() sys.stderr.flush() os._exit(0)