Coverage for orchestrant/benchmark/speed_summary.py: 100%
56 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-18 00:48 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-18 00:48 +0000
1"""One speed summary per run: the headline figures every printer shows.
3On 2026-09-24 one report, the GenieX upgrade run's `v070-npu-speed.json`, read
4three ways. The speed runner's table printed `Tokens/sec 18.3 avg`, `Overall
5... 25.4 tok/s` and `Decode only 19.7 tok/s avg`; `report table` printed
6`T/s: 18.3`; the viewer charted that 18.3 as "Tokens per Second (overall)",
7the name the runner gave 25.4. Each printer averaged its own way: "Overall"
8divided prompt AND completion tokens by the wall time (1763 tokens, 427 of
9them the prompt), and every other figure was a mean of per-request rates, so
10an 8-token reply weighed as much as a 256-token one.
12Every figure is now computed here, once. `print_table`, `report summary`,
13`report table` and the viewer -- through the `speed` block `report manifest`
14writes -- all read the dict `summarise` returns. Rates are ratios of sums, the
15tokens over the seconds they took pooled across requests, as the CPU-rail
16joules per token already were (hostload.summary_lines) and for the same
17reason: a short request's timing noise must not outweigh a long request's
18measurement. Times are means.
20Which rows count: every request that completed, cut at `max_tokens` or not,
21thinking or not -- its tokens were produced and timed either way. Only an
22errored request leaves the figures, and it is counted apart. Time to an
23ANSWER is another matter, and answers.py owns it: a cut reply has none.
24"""
26from __future__ import annotations
29def completed(rows):
30 """The requests that returned a reply: every row without an `error` key.
32 The key, not its value: the runner writes `str(e)`, which is "" for an
33 exception raised without a message, and such a row still has no tokens or
34 timings to count.
35 """
36 return [r for r in rows if "error" not in r]
39def _number(value):
40 return isinstance(value, (int, float)) and not isinstance(value, bool)
43def _seconds(row, n, rate_key, seconds_key):
44 """The seconds behind one row's rate: recorded, or read back from the rate."""
45 if seconds_key and _number(row.get(seconds_key)):
46 return row[seconds_key]
47 rate = row.get(rate_key)
48 return n / rate if _number(rate) and rate > 0 else None
51def _pooled_rate(rows, amount, rate_key, seconds_key=None):
52 """Sum of amounts over the sum of the seconds they took, or None.
54 Each row's seconds are its `seconds_key` field where it has one, else read
55 back from its own rate (amount / rate): the runner computed the rate from
56 unrounded times, while `latency_s` and `ttft_s` are stored rounded to 10 ms
57 and 1 ms -- nearly 2 % of the 0.3 s decode window of an 8-token reply.
58 """
59 pairs = [(amount(r), r) for r in rows]
60 pairs = [(n, _seconds(r, n, rate_key, seconds_key)) for n, r in pairs if n > 0]
61 pairs = [(n, s) for n, s in pairs if s is not None and s > 0]
62 if not pairs:
63 return None
64 return sum(n for n, _ in pairs) / sum(s for _, s in pairs)
67def decode_tok_s(rows):
68 """Tokens decoded after the first, over the seconds spent decoding them.
70 Answers "how fast does this lane generate once it has started?" for the
71 run as a whole: the sum of `completion_tokens - 1` over the sum of the
72 decode windows (`decode_s`, or read back from `decode_tok_per_sec` in a
73 report older than it) of the streamed rows of more than one token. A row
74 whose window was too short for a rate of its own still counts, as it did
75 when it carried one (answers.decode_fields). The first token belongs to
76 the prefill, as in the row fields.
78 Pooled, not a mean of per-request rates: the GenieX v0.6.1 -> v0.7.0 NPU
79 loss read -13.4 % as that mean, dragged by the 8- and 12-token replies
80 whose rates moved -6.5 % and -8.6 %; pooled it reads -14.8 %, and the
81 per-prompt median `bench_compare` prints is -14.7 %. On the 2048-token
82 `v070r2-cpu-speed-answer` the mean said 19.2 tok/s for a run that decoded
83 9928 tokens at 13.9: five replies of 144-815 tokens at 20-27 tok/s
84 counted as much as the four of 1666-2048 at 11.8-13.4.
85 """
86 return _pooled_rate(
87 rows,
88 lambda r: (r.get("completion_tokens") or 0) - 1,
89 "decode_tok_per_sec",
90 "decode_s",
91 )
94def prefill_tok_s(rows):
95 """Prompt tokens over the time to the first token, pooled across requests.
97 The sum of `prompt_tokens` over the sum of TTFTs, over the rows with a
98 `prefill_tok_per_sec`. A TTFT carries the request's fixed overhead too, so
99 on the speed runner's 11-158-token prompts this reads far below what the
100 lane prefills at agent sizes (about 300 tok/s on the NPU lane, where the
101 contract's cold 1.8k-token prefill measured about 1000): compare it between
102 runs of one prompt set, not with a prefill benchmark.
103 """
104 return _pooled_rate(
105 rows, lambda r: r.get("prompt_tokens") or 0, "prefill_tok_per_sec"
106 )
109def overall_tok_s(rows):
110 """Completion tokens over the whole wall time: what a caller received.
112 Answers "sending these prompts one after another, how many tokens per
113 second came back?": the sum of `completion_tokens` over the sum of
114 `latency_s`, prefill, thinking and decode all inside the time. Prompt
115 tokens are not output. The runner's old "Overall" line counted them and
116 read 25.4 tok/s for a lane that generated 19.3, and a CPU lane's 31-32
117 from that line was once published as its decode rate (the page's
118 correction: ~30). A request that returned no tokens still cost its wall
119 time, so it stays in the denominator.
120 """
121 timed = [
122 r
123 for r in rows
124 if _number(r.get("completion_tokens")) and _number(r.get("latency_s"))
125 ]
126 seconds = sum(r["latency_s"] for r in timed)
127 if seconds <= 0:
128 return None
129 return sum(r["completion_tokens"] for r in timed) / seconds
132def ttft_s(rows):
133 """Mean seconds to the first token of any kind, thinking included.
135 A time, not a rate, so a plain mean: what one request waits on average
136 before anything arrives. Over the rows that measured one; None, not 0,
137 when none did -- a non-streamed run has no first-token moment, and 0.00 s
138 would claim an instant one.
139 """
140 times = [r["ttft_s"] for r in rows if _number(r.get("ttft_s"))]
141 return sum(times) / len(times) if times else None
144def _spread(rows, key):
145 """[lowest, highest] of one row field, or None: the per-request range."""
146 values = [r[key] for r in rows if _number(r.get(key))]
147 return [min(values), max(values)] if values else None
150def summarise(rows):
151 """The run's headline figures, for every printer to read.
153 `rows` is the report's `results` as the runner wrote them, errors
154 included. `per_request` keeps the range of each row field behind a
155 headline, so a printer can show the spread without averaging it again.
156 """
157 ok = completed(rows)
158 return {
159 "requests": len(ok),
160 "errored": len(rows) - len(ok),
161 # Counted from stream chunks where the server sent no usage.
162 "estimated": sum(1 for r in ok if r.get("tokens_estimated")),
163 "completion_tokens": sum(r.get("completion_tokens") or 0 for r in ok),
164 "prompt_tokens": sum(r.get("prompt_tokens") or 0 for r in ok),
165 "wall_s": sum(r["latency_s"] for r in ok if _number(r.get("latency_s"))),
166 "overall_tok_s": overall_tok_s(ok),
167 "decode_tok_s": decode_tok_s(ok),
168 "prefill_tok_s": prefill_tok_s(ok),
169 "ttft_s": ttft_s(ok),
170 "per_request": {
171 key: _spread(ok, key)
172 for key in ("tokens_per_sec", "decode_tok_per_sec", "ttft_s")
173 },
174 }
177def _range(spread, spec):
178 return f" (per request {spread[0]:{spec}}-{spread[1]:{spec}})" if spread else ""
181def summary_lines(s):
182 """The speed runner's token, rate and TTFT lines, from one summary."""
183 lines = []
184 per = s["per_request"]
185 if s["overall_tok_s"] is not None:
186 ct, n = s["completion_tokens"], s["requests"]
187 lines.append(f" Completion tok: {ct} total / {ct / n:.1f} avg per req")
188 lines.append(
189 f" Overall: {s['overall_tok_s']:.1f} tok/s = {ct} tokens / "
190 f"{s['wall_s']:.1f}s, prefill and thinking included"
191 + _range(per["tokens_per_sec"], ".1f")
192 )
193 if s["ttft_s"] is None:
194 lines.append(" TTFT: not measured — re-run with --stream")
195 else:
196 low, high = per["ttft_s"]
197 lines.append(
198 f" TTFT: {low:.2f}s / {s['ttft_s']:.2f}s avg / {high:.2f}s max"
199 )
200 if s["decode_tok_s"] is not None:
201 lines.append(
202 f" Decode: {s['decode_tok_s']:.1f} tok/s after each first token"
203 + _range(per["decode_tok_per_sec"], ".1f")
204 )
205 if s["prefill_tok_s"] is not None:
206 lines.append(
207 f" Prefill: {s['prefill_tok_s']:.0f} tok/s prompt tokens / TTFT, "
208 "request overhead included"
209 )
210 if s["estimated"]:
211 lines.append(
212 f" Estimated: {s['estimated']} of {s['requests']} token counts "
213 "are stream chunks: the server sent no usage"
214 )
215 return lines