File size: 10,898 Bytes
11ecc5b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | #!/usr/bin/env python3
"""
REQUIREMENT 4 -- "Submit P50 / P70 / P100 latency numbers for your pipeline,
measured across a reasonable number of test queries — not a single best-case run."
P50 / P70 / P100 exactly as the brief names them. P100 is the max, so the worst
case is visible rather than averaged away.
WHAT COUNTS TOWARD THE 200 ms
-----------------------------
The brief scopes the target as "chunking + vector DB retrieval + everything
through to final output". Speech-to-text is a mandated third-party API
(requirement 1: Sarvam or ElevenLabs), so its round trip is reported SEPARATELY
rather than folded into a number we do not control. Both are printed. Nothing is
hidden inside a single figure.
THE STAGE THAT ACTUALLY DECIDES CPU-VS-GPU
------------------------------------------
`retrieve` is not just the dot product. It is a full bge-m3 forward pass to embed
the query, and THEN a dot product over the index. On a GPU the forward pass is a
few ms; on a laptop CPU it can be 50-150 ms, which is most of the budget. The
dot product is trivial either way. So this script times query embedding on its
own -- that single number tells you whether a laptop can serve inside 200 ms
before you spend a day moving.
python scripts/bench_latency.py --n 300 --langs hi,bn,kn
python scripts/bench_latency.py --embed-only # the CPU feasibility check
"""
from __future__ import annotations
import argparse
import json
import statistics as st
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.schema_utils import default_root # noqa: E402
QUERIES = {
"hi": ["कैंटालूप को पकने में कितना समय लगता है", "एक चील कितनी तेजी से उड़ती है",
"कॉर्पोरेशन क्या है", "स्टबहब का टोल फ्री नंबर क्या है",
"मुंबई किस राज्य की राजधानी है"],
"bn": ["তামিল ভাষা কোথায় বলা হয়", "একটি ঈগল কত দ্রুত উড়তে পারে",
"কর্পোরেশন কী", "ক্যান্টালুপ পাকতে কত সময় লাগে"],
"kn": ["ಕಾರ್ಪೊರೇಶನ್ ಎಂದರೇನು", "ಹದ್ದು ಎಷ್ಟು ವೇಗವಾಗಿ ಹಾರುತ್ತದೆ",
"ಕ್ಯಾಂಟಲೋಪ್ ಹಣ್ಣಾಗಲು ಎಷ್ಟು ಸಮಯ ಬೇಕು"],
"ta": ["தமிழ் மொழி எங்கு பேசப்படுகிறது", "ஒரு கழுகு எவ்வளவு வேகமாக பறக்கும்"],
}
def pct(v: list[float], q: float) -> float:
"""P100 must be the true max, so index by ceil rather than interpolating."""
if not v:
return 0.0
s = sorted(v)
if q >= 1.0:
return s[-1]
return s[min(len(s) - 1, int(q * len(s)))]
def table(name: str, v: list[float], budget: float | None = None) -> dict:
d = {"stage": name, "n": len(v), "mean": round(st.mean(v), 2),
"p50": round(pct(v, .50), 2), "p70": round(pct(v, .70), 2),
"p100": round(pct(v, 1.0), 2)}
if budget:
d["within_budget_pct"] = round(100 * sum(1 for x in v if x <= budget) / len(v), 1)
return d
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--root", type=Path, default=None)
ap.add_argument("--langs", default="hi,bn,kn")
ap.add_argument("--n", type=int, default=300, help="total requests")
ap.add_argument("--budget-ms", type=float, default=200.0)
ap.add_argument("--embed-only", action="store_true",
help="time query embedding alone — the CPU feasibility check")
ap.add_argument("--warmup", type=int, default=5)
args = ap.parse_args()
root = args.root.expanduser().resolve() if args.root else default_root()
langs = [l for l in args.langs.split(",") if l in QUERIES]
if not langs:
raise SystemExit(f"no canned queries for {args.langs}; have {sorted(QUERIES)}")
import torch
dev = "cuda" if torch.cuda.is_available() else "cpu"
print(f"==> device: {dev}")
if dev == "cpu":
import os
print(f" threads: {torch.get_num_threads()} cpus: {os.cpu_count()}")
# ---------------------------------------------------------- embed only
from src.evaluate_retrieval import Embedder
man_p = root / "index" / "manifest.json"
man = json.loads(man_p.read_text()) if man_p.exists() else {}
model = man.get("model")
if model is None:
hits = list((root / "hf_cache" / "hub").glob("models--BAAI--bge-m3/snapshots/*"))
model = str(hits[0]) if hits else "BAAI/bge-m3"
emb = Embedder(model, dev, 1, man.get("max_len", 192))
flat = [(lg, q) for lg in langs for q in QUERIES[lg]]
for _ in range(args.warmup): # first pass allocates
emb.encode([flat[0][1]])
e_ms = []
for i in range(args.n):
_lg, q = flat[i % len(flat)]
t0 = time.perf_counter()
emb.encode([q])
e_ms.append((time.perf_counter() - t0) * 1000)
print(f"\n{'='*66}\nQUERY EMBEDDING — bge-m3 forward pass, batch of 1\n{'='*66}")
t = table("embed_query", e_ms, args.budget_ms)
print(f" n={t['n']} mean {t['mean']} ms P50 {t['p50']} P70 {t['p70']} "
f"P100 {t['p100']}")
print(f"\n budget headroom AFTER embedding, per percentile:")
for name in ("p50", "p70", "p100"):
head_p = args.budget_ms - t[name]
flag = " <-- OVER BUDGET" if head_p < 0 else ""
print(f" {name.upper():5s}{t[name]:9.1f} ms leaves {head_p:8.1f} ms{flag}")
# Judge on P100, not P70. The brief asks for P100 specifically, so the tail
# is reported whether or not we like it -- and a percentile that already
# exceeds the budget before retrieval even starts is a fail, not a pass.
# (`within_budget_pct` compares EMBEDDING alone against the WHOLE budget,
# which is the wrong denominator; it is printed for reference only.)
head = args.budget_ms - t["p100"]
print(f"\n reference only: {t['within_budget_pct']}% of embeddings fit in the "
f"full {args.budget_ms:.0f} ms\n (wrong denominator — embedding is one stage, "
f"not the whole pipeline)")
if head < 0:
print(f"\n >> FAILS AT P100. Embedding alone is {-head:.0f} ms over budget before")
print(" retrieval, the reader or TTS run. Do NOT plan to serve from this")
print(" machine unquantised. Run scripts/optimize_cpu.py — int8 dynamic")
print(" quantisation typically gives 2-3x on CPU transformers, which would")
print(" bring P100 inside. Verify retrieval agreement before trusting it.")
elif head < 20:
print(" >> TOO TIGHT. Query embedding alone eats the budget on this machine.")
print(" Options: keep serving on the GPU pod; or cut max_len (192 -> 128);")
print(" or use a smaller embedder for QUERIES only, keeping bge-m3 for the")
print(" index — asymmetric encoders are a real technique, but the pair must")
print(" then be re-evaluated, not assumed compatible.")
elif head < 80:
print(" >> WORKABLE but leaves little headroom. Measure the full path below.")
else:
print(" >> COMFORTABLE. This machine can serve inside the budget.")
if args.embed_only:
out = root / "results" / "latency_embed.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps({"device": dev, "model": model, **t}, indent=2))
print(f"\n==> wrote {out}")
return 0
# ---------------------------------------------------------- full pipeline
if not man_p.exists():
raise SystemExit(f"\nno index at {root/'index'} — run src/index_build.py, "
f"or use --embed-only for the feasibility check alone")
from src.guardrails import Guardrails
from src.harness import AskRequest, Harness
from src.reader import LexicalSpanReader
from src.serve import Index
from src.voice import VoiceStack
idx = Index(root, langs, None, 0)
h = Harness(idx, LexicalSpanReader(prior_weight=1.0, answer_mode="sentence"),
VoiceStack(notes=["bench: TTS off"]), asr=None,
guards=Guardrails(), budget_ms=args.budget_ms)
stages: dict = {}
pipe, answered = [], 0
for i in range(args.n):
lg, q = flat[i % len(flat)]
if lg not in idx.langs:
continue
r = h.run(AskRequest(query=q, lang=lg, want_audio=False))
answered += int(r.answered)
pipe.append(r.timing["pipeline_ms"])
for k, v in r.timing.items():
if k.endswith("_ms") and k not in ("total_ms", "pipeline_ms", "budget_ms"):
stages.setdefault(k, []).append(v)
print(f"\n{'='*66}\nFULL PIPELINE — P50 / P70 / P100 (requirement 4)\n{'='*66}")
print(f" {'stage':18s}{'n':>6}{'mean':>9}{'P50':>9}{'P70':>9}{'P100':>9}")
print(" " + "-" * 60)
rows = []
for k in sorted(stages, key=lambda k: -st.mean(stages[k])):
t = table(k, stages[k])
rows.append(t)
print(f" {k:18s}{t['n']:>6}{t['mean']:>9.2f}{t['p50']:>9.2f}"
f"{t['p70']:>9.2f}{t['p100']:>9.2f}")
tot = table("PIPELINE", pipe, args.budget_ms)
rows.append(tot)
print(" " + "-" * 60)
print(f" {'PIPELINE':18s}{tot['n']:>6}{tot['mean']:>9.2f}{tot['p50']:>9.2f}"
f"{tot['p70']:>9.2f}{tot['p100']:>9.2f}")
print(f"\n budget {args.budget_ms:.0f} ms -> {tot['within_budget_pct']}% of requests inside")
print(f" answered {answered}/{len(pipe)} "
f"({100*answered/max(1,len(pipe)):.0f}% — the rest were guardrail abstentions)")
print("\n NOTE: speech-to-text is a third-party API (requirement 1) and is NOT")
print(" included above. Report it as its own line, measured against your")
print(" provider, so the number you control is not confused with the one you")
print(" do not.")
out = root / "results" / "latency.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps({
"device": dev, "model": model, "budget_ms": args.budget_ms,
"languages": langs, "n_requests": len(pipe),
"percentiles": "P50/P70/P100 as specified in the brief",
"embed_query": table("embed_query", e_ms),
"stages": rows,
"asr_note": "Sarvam/ElevenLabs round trip excluded — third-party, timed separately",
}, indent=2))
print(f"\n==> wrote {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
|