Where a request's time goes, and what was cut¶
The profile¶
Milliseconds per request, median of 10 runs:
| stage | ticket, 1 question | ticket, 3 | ticket, 10 | email, 3 |
|---|---|---|---|---|
| routing (language detection) | 0.21 | 0.21 | 0.20 | 0.97 |
| tokenize the state | 0.34 | 0.35 | 0.34 | 1.18 |
| build rows (questions and options) | 0.41 | 1.02 | 2.59 | 0.97 |
| encoder forward | 260 | 505 | 1,390 | 2,239 |
| decision head forward | 18.5 | 27.1 | 80.2 | 167 |
| everything else | 0.63 | 0.93 | 1.40 | 1.13 |
| latency | 311 | 503 | 1,542 | 2,237 |
The ticket is 97 characters and the email 1,125. The encoder is 89-95% of every request measured. Every question row carries the whole state, so time follows the number of questions times the length of the state. The full table, with batches and Spanish, is in Where a request's time goes.
Speed-ups made, with identical output¶
| change | before | after |
|---|---|---|
| language detection, 2,000-character English text | 723 µs | 221 µs |
| language detection, JSON ticket | 1,610 µs | 946 µs |
| question heads tokenized once per tokenizer: 3 questions | 0.71 ms | 0.13 ms |
| question heads: 10 questions | 2.07 ms | 0.21 ms |
| question heads: batch of 32 tickets | 22.6 ms | 4.0 ms |
| routing a state seen before, 2,000 characters | 246 µs | 10 µs |
a DecisionCache hit, 140 / 2,000 / 20,000 characters |
121 / 314 / 614 µs | 73 / 76 / 167 µs |
Language detection output was compared against the previous code: 267,449 comparisons over 38,247 inputs, none different. These cut milliseconds from a request that takes hundreds on CPU. They matter more on an accelerator, where the encoder is short and the milliseconds around it are a visible share.
Runtime options tried¶
| option | ticket, 3 questions | email, 3 questions | answers |
|---|---|---|---|
| int8 dynamic quantization of the encoder | 1.80x faster | 1.64x faster | shifted |
torch.inference_mode() |
1.02x | 1.01x | identical |
| 2 threads instead of 4 | 1.48x slower | 1.65x slower | identical |
| bf16 autocast without native BF16 | 3.10x slower | 3.47x slower | shifted |
Use every physical core, and use bf16 only on hardware with native support.
The CPU fast path¶
Over 13 English requests (1 to 10 questions, tickets to 3,000-token emails), interleaved over 5 repeats:
| backend | ticket, 3 | email, 3 | geometric mean | same decision as fp32 |
|---|---|---|---|---|
| int8 PyTorch (encoder linear layers) | 1.85x | 1.68x | 1.89x | 44 of 56 |
| ONNX fp32, export before the fix | 1.04x | 0.79x | 1.01x | 56 of 56 |
| ONNX fp32, NaN guards stripped | 1.11x | 0.90x | 1.06x | 56 of 56 |
| ONNX int8 | 1.96x | 1.76x | 2.07x | 28 of 56 |
- ONNX fp32 loses on long inputs because the export unfuses attention into a full
[heads, length, length] tensor per layer. It also added a NaN check after each softmax that
cannot fire.
scripts/export_onnx.pynow strips those checks, which took the email from 0.79x to 0.90x. Fusing the attention is the next step. - int8 changes answers. Every changed answer had an fp32 margin of at most 0.0006, which is a coin flip under random weights. Trained checkpoints will change far fewer; measure yours. The int8 ONNX file is 579 MB, against 1.68 GB for fp32.
- The cache pairs with int8. Fp32, int8 and ONNX of one checkpoint share one set of decisions (see the cache report), so a repeated request gets its first answer whichever backend computed it.
Trimming the state¶
Since time follows state length, trimming is the largest lever on a new request.
laya.email.clean_email_body drops quoted history, signatures and disclaimers, and caps the rest
at 3,000 characters:
| state | characters before | after |
|---|---|---|
| the benchmark email | 1,125 | 1,064 |
| a reply quoting it | 1,263 | 27 |
| a two-deep thread | 2,498 | 27 |
The quoted history is gone too, so keep it when the questions depend on it.
Encoding the state once (research spike)¶
k questions put the state through the encoder k times. Encoding the state once and each question separately, then letting the two head layers cross-attend, would cost this much of today's forward pass (speedup, median of 3):
| state tokens | English, 1 question | 3 | 10 | multilingual, 3 | 10 |
|---|---|---|---|---|---|
| 100 | 0.60x | 1.45x | 2.32x | 1.19x | 2.06x |
| 300 | 0.83x | 2.07x | 4.18x | 1.61x | 3.46x |
| 476 / 800 | 0.86x | 2.19x | 4.29x | 2.09x | 3.70x |
It pays from about three questions on a hundred-token state, and loses on one question. It is compute only: the answers mean nothing until a model is trained this way, most likely by distilling from today's.
Reproduce¶
python research/scripts/bench_stages.py --device cpu # or mps, cuda
python research/scripts/bench_question_heads.py
python scripts/export_onnx.py --model ID --output english.onnx --int8
python research/scripts/bench_cpu_fast_path.py --onnx fp32=english.onnx --onnx int8=english.int8.onnx
python research/scripts/spike_encode_once.py