#!/usr/bin/env python3 """Measure embedding throughput per model - resolves the registry's `expected_time_per_1k_texts_cpu: benchmark_required` fields with real numbers. Run this ON the target host: throughput is dominated by per-core CPU speed, so a laptop figure can overstate a shared cloud vCPU by 2-4x. On a Space, open a terminal in the container (or add a one-off startup command) and run: python scripts/bench_models.py --threads 2 Pinning --threads to the host's vCPU count matters: torch otherwise grabs every core it can see and reports a number the app will never reproduce, since jobs run one at a time on a single worker thread (see app/jobs.py). The row shapes below bracket the realistic CCR corpus range - short social posts, survey open-ends, and long documents that fill the model's token window. Multiply s/1k by (rows / 1000) to get expected job wall-clock, then sanity-check it against how long a job may run before a host restart can kill it. """ from __future__ import annotations import argparse import os import time # Thread caps must be set before torch/transformers import to take effect. parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--threads", type=int, default=2, help="CPU threads (match host vCPUs)") parser.add_argument("--batch", type=int, default=64, help="must match ccr.py's encode batch") parser.add_argument("--models", default="", help="comma-separated registry ids (default: all)") parser.add_argument("--quick", action="store_true", help="fewer texts per shape") args = parser.parse_args() for var in ("OMP_NUM_THREADS", "MKL_NUM_THREADS", "OPENBLAS_NUM_THREADS"): os.environ[var] = str(args.threads) os.environ["TOKENIZERS_PARALLELISM"] = "false" import torch # noqa: E402 from sentence_transformers import SentenceTransformer # noqa: E402 torch.set_num_threads(args.threads) import sys # noqa: E402 from pathlib import Path # noqa: E402 sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "backend")) from app import registry # noqa: E402 _WORDS = ( "participants reported that the collaborative environment meaningfully shaped " "their sense of belonging within the wider research group over the study period" ).split() def synth(n_words: int) -> str: return " ".join((_WORDS * 40)[:n_words]) SHAPES = [ ("short (~15w, social post)", 15), ("medium (~60w, survey open-end)", 60), ("long (~250w, document)", 250), ] def bench(model: SentenceTransformer, n_words: int, n: int) -> float: """Return seconds per 1,000 texts.""" texts = [synth(n_words)] * n model.encode(texts[: args.batch], batch_size=args.batch, show_progress_bar=False) # warm start = time.perf_counter() model.encode( texts, batch_size=args.batch, show_progress_bar=False, normalize_embeddings=True ) return (time.perf_counter() - start) / n * 1000 def main() -> None: wanted = [m.strip() for m in args.models.split(",") if m.strip()] models = [m for m in registry.list_models() if not wanted or m.id in wanted] if not models: raise SystemExit(f"no registry models matched {wanted!r}") print(f"threads={args.threads} batch={args.batch} torch={torch.__version__}") print("(load times exclude first-run model download)\n") for cfg in models: try: t0 = time.perf_counter() model = SentenceTransformer(cfg.provider_model_id) model.eval() load = time.perf_counter() - t0 except Exception as exc: # a heavy model may not fit; keep going print(f"{cfg.id}: SKIPPED ({type(exc).__name__}: {exc})\n") continue print(f"{cfg.id} (max_seq={model.max_seq_length}, load {load:.1f}s)") for label, n_words in SHAPES: # Fast models need enough batches that warm-up noise averages out; # at n=100 (< 2 full batches) short-text figures swing ~3x. n = 250 if args.quick else (600 if n_words < 250 else 300) per_1k = bench(model, n_words, n) est_20k = per_1k * 20 # seconds at the common CCR_MAX_ROWS=20000 print( f" {label:32s} {per_1k:8.1f} s/1k " f"(20k rows ~ {est_20k / 60:5.1f} min)" ) print() del model if __name__ == "__main__": main()