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())