Contents
Transformers: from scratch to production · lesson 8 of 10 · all lessons · run on Kaggle · open in Colab · code on GitHub
In November 2006 Greg Linden, who had built Amazon’s recommendations, gave a talk at Stanford. One slide said “+100 ms → −1% sales @ Amazon” and, next to it, “+500 ms → −20% traffic @ Google” [1]. The two lines have been quoted ever since, mostly without the footnotes: the Google experiment also showed thirty results instead of ten, so it measured a heavier page, not just a slower one. When Google ran a clean experiment three years later, adding 100 to 400 ms to search cost 0.2–0.6% of searches [2]. The effect is smaller than the legend, but it is there.
The other half of the story is what happens when many people ask at once. A request that is fast alone can wait behind others, and users feel the waiting as much as the computing. The slow requests also matter more than their share suggests. Dean and Barroso’s paper on Google’s serving points out that if answering a user takes a hundred servers, and each is slow one time in a hundred, 63% of users wait for a slow one [3]. That’s why this lesson measures the slowest requests, not only the average.

This lesson puts perevod behind an HTTP API, and then measures it the way users will meet it: under load. On the way it makes the model several times faster without changing a single translation, tries a famous speed-up that doesn’t pay off here, and finds out why a queue, not the model, decides most of the latency.
What you’ll do
- Time a real translator and ours, and find where a translation spends its time.
- Bring lesson 5’s KV cache into perevod’s beam search, and check it changes no translation.
- Try int8 quantization, measure that it doesn’t help this model, and see why.
- Build a FastAPI service, load it the way users do, and compare the curves with queueing theory.
- Add dynamic batching, a Gradio page, and release perevod 0.2.0.
What you need: a CPU, as in lesson 7; Kaggle’s free CPU sessions will do. The lesson downloads opus-mt (300 MB) for a comparison and lesson 7’s model (40 MB), and runs in about a quarter of an hour, two thirds of it in load tests that run in real time.
On your own machine you can also open the service in a browser; on Kaggle and Colab the notebook talks to it from Python.
Setup
One small helper package, tfs, draws the course’s plots and token chips and knows where the notebook runs (Kaggle, Colab or your own machine). Everything the lesson teaches is written out in the notebook itself.
import importlib.util
import subprocess
import sys
TFS = "git+https://github.com/xmarva/transformers-from-scratch@main"
if importlib.util.find_spec("tfs") is None:
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", TFS])
from tfs import env
from tfs.viz import table, token_chips, use_course_style
env.quiet() # no progress bars or environment warnings in the outputs
SEED = 42
env.seed_everything(SEED)
COLORS = use_course_style()
WORK_DIR = env.work_dir()
import asyncio
import math
import os
import random
import shutil
import time
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import torch
from huggingface_hub import hf_hub_download
for package in ["sacrebleu", "httpx"]: # Kaggle has them, Colab may not
if importlib.util.find_spec(package) is None:
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", package])
import httpx
import sacrebleu
print("CPU cores Python sees:", os.cpu_count())
print("threads PyTorch uses:", torch.get_num_threads())
CPU cores Python sees: 4
threads PyTorch uses: 2 1. How long a translation takes
Start with a real model on this machine. opus-mt-en-ru, the baseline of the course, is a Marian Transformer with six encoder and six decoder layers, trained on millions of sentence pairs. Here it translates sentences one at a time, as a service would when requests arrive one by one, with the beam search it ships with:
from transformers import MarianMTModel, MarianTokenizer
OPUS = "Helsinki-NLP/opus-mt-en-ru"
opus_tokenizer = MarianTokenizer.from_pretrained(OPUS)
opus = MarianMTModel.from_pretrained(OPUS).eval()
DATA_REVISION = "540be5b93bf77cd0846b02d55f2558a5596887a9"
test_path = hf_hub_download("xmarva/tfs-tatoeba-en-ru", "data/test.parquet",
repo_type="dataset", revision=DATA_REVISION)
test = pd.read_parquet(test_path)
probe = list(test.sample(30, random_state=SEED).en)
@torch.inference_mode()
def opus_translate(sentence):
batch = opus_tokenizer([sentence], return_tensors="pt")
output = opus.generate(**batch, num_beams=4, max_new_tokens=128)
return opus_tokenizer.decode(output[0], skip_special_tokens=True)
def latencies(translate, sentences):
times = []
for sentence in sentences:
start = time.perf_counter()
translate(sentence)
times.append(time.perf_counter() - start)
return np.array(times)
opus_times = latencies(opus_translate, probe)
n_opus = sum(parameter.numel() for parameter in opus.parameters())
print(f"opus-mt: {n_opus / 1e6:.0f}M parameters, "
f"median {np.median(opus_times) * 1000:.0f} ms per sentence")
opus-mt: 77M parameters, median 264 ms per sentence And perevod, the package of lesson 7. Its source lives in the course repository; we take the release we made, by its tag, and only the perevod/ folder of it, so the clone stays small. That folder is where this lesson works:
REPO = WORK_DIR / "course"
shutil.rmtree(REPO, ignore_errors=True)
URL = "https://github.com/xmarva/transformers-from-scratch"
QUIET = ["-c", "advice.detachedHead=false"]
subprocess.run(["git", *QUIET, "clone", "-q", "--depth", "1", "--branch", "perevod-v0.1.0",
"--filter=blob:none", "--sparse", URL, str(REPO)], check=True)
subprocess.run(["git", "sparse-checkout", "set", "perevod"], cwd=REPO, check=True)
os.chdir(REPO)
def write(path, text):
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).write_text(text.lstrip("\n"), encoding="utf-8")
!{sys.executable} -m pip install -q ./perevod
importlib.invalidate_caches()
import perevod
from perevod import Translator
translator = Translator.from_pretrained(device="cpu")
n_ours = sum(parameter.numel() for parameter in translator.model.parameters())
ours_times = latencies(translator, probe)
print(f"perevod {perevod.__version__}: {n_ours / 1e6:.1f}M parameters, "
f"median {np.median(ours_times) * 1000:.0f} ms")
Installing build dependencies ... done
Getting requirements to build wheel ... done
Preparing metadata (pyproject.toml) ... done
Building wheel for perevod (pyproject.toml) ... done
perevod 0.1.0: 9.6M parameters, median 73 ms Ours is eight times smaller and three and a half times faster. Not eight: opus-mt’s generate keeps a KV cache, the trick of lesson 5, and perevod 0.1.0 doesn’t; section 3 adds it.
A sentence in less than a tenth of a second sounds like the end of the story. It isn’t, for two reasons.
The time per sentence depends on its length, and the next section shows how badly. And a service doesn’t translate one sentence: it translates whatever arrives, whenever it arrives, and section 7 shows what that does.
2. Where a translation spends its time
A translation is two very different computations. The encoder reads the English sentence once, all positions in parallel.
The decoder writes the Russian one token at a time, and every token is another pass through the decoder. Time both, on one long sentence, token by token:
from perevod import decoding
model, tokenizer = translator.model, translator.tokenizer
LONG = ("The European Central Bank raised interest rates by a quarter of a percentage "
"point on Thursday, citing persistent inflation in the services sector.")
src = torch.tensor([decoding.source_ids(tokenizer, LONG, model.config.max_len)])
pad_id, bos, eos = decoding.special_ids(tokenizer)
with torch.inference_mode():
start = time.perf_counter()
memory = model.encode(src)
encode_ms = (time.perf_counter() - start) * 1000
written = torch.full((1, 1), bos)
plain_steps = []
for _ in range(100): # past the end of the translation too: only the clock matters here
start = time.perf_counter()
next_id = model.decode(written, memory, src)[:, -1].argmax(dim=-1)
plain_steps.append((time.perf_counter() - start) * 1000)
written = torch.cat([written, next_id[:, None]], dim=1)
print(f"encoder: {encode_ms:.1f} ms for {src.shape[1]} tokens")
print(f"decoder step 1: {plain_steps[0]:.1f} ms, step 100: {plain_steps[99]:.1f} ms")
encoder: 6.6 ms for 39 tokens
decoder step 1: 5.4 ms, step 100: 16.5 ms The encoder reads all the English tokens in about the time of one decoder step, and a translation takes one decoder step per Russian token: nearly all the time goes to the decoder. And its steps get slower as the translation grows.
The decoder is called with everything written so far, so every step recomputes all the earlier positions to get the scores for the next one. Step \(t\) processes \(t\) positions, and a translation of \(T\) tokens processes
\[1 + 2 + \dots + T = \frac{T(T+1)}{2}\]positions in all: twice as long a sentence means four times the positions. The clock shows less than that here. Step 100 handles a hundred times the positions of step 1 and takes only a few times as long, because one short sequence leaves the CPU mostly idle, and the fixed cost of a call dominates.
Beam search runs four hypotheses of every sentence of a batch at once, and there the positions dominate, as the next section measures.
Lesson 5 met this and fixed it for greedy decoding with a KV cache. perevod 0.1.0 ships the uncached code, because lesson 7 moved the simplest decoding into the package. The package’s default, beam search, never had a cache at all.
3. The KV cache, in the package
A refresher from lesson 5, in one sentence: at step \(t\) the new token’s query needs the keys and values of all earlier positions, and those never change once computed, so we keep them per layer and compute only the new token’s. The cross-attention’s keys and values come from the source alone and are computed once per sentence.

Beam search adds one complication lesson 5 didn’t have. After every step, the beams are re-chosen: a hypothesis can produce two survivors, and another none.
Each surviving beam continues the history of its parent, so its row in the cache must be its parent’s row. Before the next step, the cache is reordered by the parents:
![Beam search with a cache, beam size three. After a step, the three best continuations come from rows 0, 0 and 2 of the cache: row 1's hypothesis died and row 0's had two children. The cache is gathered with the index [0, 0, 2] so that each new beam's row holds its parent's keys and values](/assets/posts/tfs/08/beam-reorder-dark.png)
The new decoding.py of perevod. The helpers at the top are lesson 7’s:
%%writefile perevod/src/perevod/decoding.py
# Greedy decoding and beam search of lesson 3, batched as in lesson 4, with lesson 5's KV
# cache: every step feeds the decoder only the newest token. A source longer than the
# model's positions is cut to fit, instead of failing inside the model.
import torch
import torch.nn.functional as F
from .model import apply_rope
def special_ids(tokenizer):
pad_id = tokenizer.token_to_id("<pad>")
return pad_id, tokenizer.token_to_id("<bos>"), tokenizer.token_to_id("<eos>")
def source_ids(tokenizer, sentence, limit):
return tokenizer.encode(sentence).ids[:limit]
def pad(sequences, pad_id, device):
longest = max(len(ids) for ids in sequences)
batch = torch.full((len(sequences), longest), pad_id, device=device)
for row, ids in enumerate(sequences):
batch[row, : len(ids)] = torch.tensor(ids, device=device)
return batch
def batches(sources, batch_size):
# sentences in batches of similar length, so that little of a batch is padding
order = sorted(range(len(sources)), key=lambda i: len(sources[i]))
return [order[start : start + batch_size] for start in range(0, len(order), batch_size)]
def length_penalty(length, alpha=0.6):
# Wu et al. (2016): ((5 + |y|) / 6) ** alpha
return ((5 + length) / 6) ** alpha
Overwriting perevod/src/perevod/decoding.py attend is one attention sublayer for the newest token. It works on the model’s own Attention modules, so the weights are the model’s; only the order of operations is new.
For self-attention it rotates the new query and key with RoPE and appends the key and value to the cache. It needs no causal mask: the only query is the newest token, and everything in the cache is its past.
For cross-attention the cache is computed once from the source, and marked "fixed" so that nothing is appended to it:
%%writefile -a perevod/src/perevod/decoding.py
def attend(attention, x, cache, mask, angles):
# one attention sublayer for the newest token; the cache holds everything before it
q = attention.split_heads(attention.w_q(x))
if angles is not None: # RoPE: self-attention rotates queries and keys by position
q = apply_rope(q, angles)
if "fixed" not in cache: # self-attention: add the new token's key and value
k = attention.split_heads(attention.w_k(x))
v = attention.split_heads(attention.w_v(x))
if angles is not None:
k = apply_rope(k, angles)
if "k" in cache:
k = torch.cat([cache["k"], k], dim=2)
v = torch.cat([cache["v"], v], dim=2)
cache["k"], cache["v"] = k, v
out = F.scaled_dot_product_attention(q, cache["k"], cache["v"], attn_mask=mask)
batch, _, length, _ = out.shape
return attention.w_o(out.transpose(1, 2).reshape(batch, length, -1))
Appending to perevod/src/perevod/decoding.py decode_step runs the decoder of model.py for one token, in the same order: pre-norm, self-attention, cross-attention, feed-forward. It repeats the model’s arithmetic outside the model, which is a debt: if model.py changes, this has to change with it. The golden test of lesson 7 fails if the two drift apart.
%%writefile -a perevod/src/perevod/decoding.py
def new_caches(model, memory):
# per decoder layer: an empty self-attention cache, and the cross-attention's keys and
# values, which depend only on the source and are computed once
caches = []
for layer in model.decoder:
attention = layer.cross_attention
k = attention.split_heads(attention.w_k(memory))
v = attention.split_heads(attention.w_v(memory))
caches.append({"self": {}, "cross": {"fixed": True, "k": k, "v": v}})
return caches
def decode_step(model, token, memory_mask, caches, position):
# the decoder of model.py, in the same order, for one new token; dropout is off anyway
x = model.embed(token)
angles = model.angles[position : position + 1]
for layer, cache in zip(model.decoder, caches, strict=True):
x = x + attend(layer.self_attention, layer.norm1(x), cache["self"], None, angles)
x = x + attend(layer.cross_attention, layer.norm2(x), cache["cross"], memory_mask,
None)
x = x + layer.feed_forward(layer.norm3(x))
return (model.decoder_norm(x) @ model.embedding.weight.T)[:, -1]
Appending to perevod/src/perevod/decoding.py reorder moves the cached keys and values along with their hypotheses, as in the figure above. It handles both kinds of cache, so the source’s keys and values follow their sentence too.
%%writefile -a perevod/src/perevod/decoding.py
def reorder(caches, rows):
# the cached keys and values follow their hypotheses: row r of the new caches is row
# rows[r] of the old ones
for cache in caches:
for part in cache.values():
for key in ("k", "v"):
if key in part:
part[key] = part[key][rows]
Appending to perevod/src/perevod/decoding.py Greedy decoding with the cache is lesson 5’s greedy_cached, batched:
%%writefile -a perevod/src/perevod/decoding.py
@torch.inference_mode()
def greedy(model, tokenizer, sentences, max_tokens=128, batch_size=256):
model.eval()
device = next(model.parameters()).device
pad_id, bos, eos = special_ids(tokenizer)
sources = []
for sentence in sentences:
sources.append(source_ids(tokenizer, sentence, model.config.max_len))
max_tokens = min(max_tokens, model.config.max_len - 1)
translations = [None] * len(sentences)
for indices in batches(sources, batch_size):
src = pad([sources[i] for i in indices], pad_id, device)
memory_mask = (src != pad_id)[:, None, None, :]
caches = new_caches(model, model.encode(src))
token = torch.full((len(indices), 1), bos, device=device)
written = []
done = torch.zeros(len(indices), dtype=torch.bool, device=device)
for position in range(max_tokens):
scores = decode_step(model, token, memory_mask, caches, position)
next_ids = scores.argmax(dim=-1)
next_ids[done] = pad_id # a finished sentence only collects padding
written.append(next_ids)
done |= next_ids == eos
token = next_ids[:, None]
if done.all():
break
written = torch.stack(written, dim=1)
for row, i in enumerate(indices):
ids = []
for token_id in written[row].tolist():
if token_id in (eos, pad_id):
break
ids.append(token_id)
translations[i] = tokenizer.decode(ids)
return translations
Appending to perevod/src/perevod/decoding.py Beam search is lesson 3’s, step for step, with two changes: a hypothesis remembers its row in the cache, and the cache is reordered before each step.
%%writefile -a perevod/src/perevod/decoding.py
@torch.inference_mode()
def beam_search(model, tokenizer, sentences, beam_size=4, alpha=0.6, max_tokens=128,
batch_size=64):
model.eval()
device = next(model.parameters()).device
pad_id, bos, eos = special_ids(tokenizer)
sources = []
for sentence in sentences:
sources.append(source_ids(tokenizer, sentence, model.config.max_len))
max_tokens = min(max_tokens, model.config.max_len - 1)
translations = [None] * len(sentences)
for indices in batches(sources, batch_size):
src = pad([sources[i] for i in indices], pad_id, device)
memory_mask = (src != pad_id)[:, None, None, :]
caches = new_caches(model, model.encode(src))
# per sentence: (tokens so far, total log-probability, its row in the caches)
beams = []
for k in range(len(indices)):
beams.append([([bos], 0.0, k)])
finished = [[] for _ in indices]
active = [True for _ in indices]
Appending to perevod/src/perevod/decoding.py Each step does three things. It collects the cache rows of the live hypotheses of every sentence still being translated, and reorders the caches to them.
On the first step that turns one row per sentence into one row per hypothesis; later, sentences that are done drop out, and their rows with them. Then one decoder step runs for all those hypotheses at once, and each gets its beam_size best next tokens.
Finally, for each sentence, every hypothesis is extended by each of its tokens, the candidates are sorted, those that end with <eos> are set aside as finished, and the best beam_size others survive. A sentence stops when it has beam_size finished hypotheses.
%%writefile -a perevod/src/perevod/decoding.py
for position in range(max_tokens):
rows, owners, last = [], [], []
for k in range(len(indices)):
if active[k]:
for tokens, _, row in beams[k]:
rows.append(row)
owners.append(k)
last.append(tokens[-1])
if not rows:
break
reorder(caches, torch.tensor(rows, device=device))
token = torch.tensor(last, device=device)[:, None]
owner = torch.tensor(owners, device=device)
scores = decode_step(model, token, memory_mask[owner], caches, position)
top = scores.float().log_softmax(dim=-1).topk(beam_size)
values, ids = top.values.tolist(), top.indices.tolist()
row = 0
for k in range(len(indices)):
if not active[k]:
continue
candidates = []
for tokens, total, _ in beams[k]:
for log_p, token_id in zip(values[row], ids[row], strict=True):
candidates.append((tokens + [token_id], total + log_p, row))
row += 1
candidates.sort(key=lambda candidate: candidate[1], reverse=True)
beams[k] = []
for tokens, total, parent in candidates:
if tokens[-1] == eos:
finished[k].append((tokens, total))
else:
beams[k].append((tokens, total, parent))
if len(beams[k]) == beam_size:
break
if len(finished[k]) >= beam_size:
active[k] = False
Appending to perevod/src/perevod/decoding.py At the end each sentence takes its best hypothesis by the length-normalized score, as in lesson 3:
%%writefile -a perevod/src/perevod/decoding.py
for k, i in enumerate(indices):
def normalized(candidate):
tokens, total = candidate[0], candidate[1]
return total / length_penalty(len(tokens) - 1, alpha) # without <bos>
best = max(finished[k] or beams[k], key=normalized)
translations[i] = tokenizer.decode(best[0][1:]) # decode drops <eos>
return translations
Appending to perevod/src/perevod/decoding.py The file now holds the new code, and the installed package still has the old one. That’s useful for a moment: the old module is already imported as decoding, and the new file can be loaded next to it under another name.
def load_module(name, path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
old = decoding # perevod 0.1.0, as installed
new = load_module("perevod.decoding_new", "perevod/src/perevod/decoding.py")
print("old:", old.__file__)
print("new:", new.__file__)
old: /usr/local/lib/python3.13/dist-packages/perevod/decoding.py
new: /kaggle/working/tfs/course/perevod/src/perevod/decoding.py The cache must not change a single translation. On 500 sentences of the test set and 30 of the news test set of lesson 7, with beam search, the old code against the new:
from sacrebleu.utils import get_source_file
tatoeba = test.sample(500, random_state=SEED)
news_file = Path(get_source_file("wmt20", "en-ru"))
news = random.Random(SEED).sample(news_file.read_text(encoding="utf-8").splitlines(), 30)
rows = []
for name, sentences in [("Tatoeba", list(tatoeba.en)), ("WMT20 news", news)]:
start = time.perf_counter()
before = old.beam_search(model, tokenizer, sentences)
middle = time.perf_counter()
after = new.beam_search(model, tokenizer, sentences)
end = time.perf_counter()
assert after == before, name
rows.append({"test set": name, "sentences": len(sentences),
"without the cache, s": middle - start, "with it, s": end - middle,
"faster": (middle - start) / (end - middle)})
speed = pd.DataFrame(rows)
display(table(speed))
| test set | sentences | without the cache, s | with it, s | faster |
|---|---|---|---|---|
| Tatoeba | 500 | 22.56 | 4.96 | 4.54 |
| WMT20 news | 30 | 68.38 | 3.77 | 18.16 |
The same translations, word for word, and faster, by more on the news. That’s the \(T^2\) at work: Tatoeba’s sentences are short, and a few steps of recomputation are cheap; a news sentence runs for dozens of steps, and the recomputation dominates. The step times of section 2 show it directly:
with torch.inference_mode():
memory_mask = (src != pad_id)[:, None, None, :]
caches = new.new_caches(model, model.encode(src))
token = torch.full((1, 1), bos)
cached_steps = []
for position in range(100):
start = time.perf_counter()
scores = new.decode_step(model, token, memory_mask, caches, position)
next_id = scores.argmax(dim=-1)
cached_steps.append((time.perf_counter() - start) * 1000)
token = next_id[:, None]
fig, ax = plt.subplots(figsize=(8, 3.2))
ax.plot(range(1, 101), plain_steps, color=COLORS["dec"], label="recompute everything")
ax.plot(range(1, 101), cached_steps, color=COLORS["enc"], label="with the KV cache")
ax.set_xlabel("decoding step")
ax.set_ylabel("ms per step")
ax.set_ylim(bottom=0)
ax.set_title("One decoding step, on a CPU")
ax.legend(frameon=False)
plt.show()

Without the cache a step gets slower as the translation grows; with it, every step costs about the same.
4. int8 quantization, measured
The next speed-up everyone reaches for is quantization: store weights as 8-bit integers instead of 32-bit floats, and multiply in integers. Each weight matrix gets a scale \(s\) so that \(w \approx s \cdot q\), with \(q\) an integer from −128 to 127. PyTorch’s dynamic quantization does it in one call: the weights of every nn.Linear become int8 once, and at every call the input is measured and scaled the same way, the multiplication runs in integers, and the result is scaled back [4].
from torch.ao.quantization import quantize_dynamic
engines = torch.backends.quantized.supported_engines
if torch.backends.quantized.engine == "none" and "qnnpack" in engines: # ARM, e.g. a Mac
torch.backends.quantized.engine = "qnnpack"
quantized = quantize_dynamic(model, {torch.nn.Linear}, dtype=torch.qint8)
sentences = list(tatoeba.en)
start = time.perf_counter()
fp32 = new.beam_search(model, tokenizer, sentences)
fp32_seconds = time.perf_counter() - start
start = time.perf_counter()
int8 = new.beam_search(quantized, tokenizer, sentences)
int8_seconds = time.perf_counter() - start
changed = sum(a != b for a, b in zip(fp32, int8))
fp32_chrf = sacrebleu.corpus_chrf(fp32, [list(tatoeba.ru)]).score
int8_chrf = sacrebleu.corpus_chrf(int8, [list(tatoeba.ru)]).score
print(f"float32: {fp32_seconds:.1f} s, chrF {fp32_chrf:.1f}")
print(f"int8: {int8_seconds:.1f} s, chrF {int8_chrf:.1f}, {changed} of {len(sentences)} "
"translations changed")
float32: 4.9 s, chrF 61.0
int8: 4.8 s, chrF 59.9, 178 of 500 translations changed About as fast as before, and worse. The speed-up that int8 promises comes from large matrix multiplications, where moving 8-bit numbers instead of 32-bit ones saves memory traffic. Count where the work of one decoding step goes in our model, layer by layer:

The output projection onto the 16,000 tokens of the vocabulary does 59% of the multiply-adds of a step, and it isn’t an nn.Linear: the model reuses the embedding matrix there, so quantization never touches it.
Even if int8 made every nn.Linear free, more than half of the work would stay in float32. What quantization does touch are 256-wide layers, too small for integer arithmetic to win back the cost of measuring and scaling the input at every call.
On a model this size, on this CPU, quantization costs quality and buys nothing, so perevod 0.2.0 doesn’t use it. (PyTorch is moving quantization to its torchao library, and the cell prints a deprecation warning saying so; the arithmetic won’t change with the API.)
5. Batches and threads
A CPU does more work per second on bigger matrices. The fixed cost of every call, and reading the weights from memory, are paid once per batch instead of once per sentence. Here is perevod’s throughput with batches of different sizes, on 256 sentences.
beam_search groups sentences of similar length into a batch, so that none waits long for a much longer neighbour; these numbers are the best case:
sentences = list(test.sample(256, random_state=SEED + 1).en)
rows = []
for batch_size in [1, 2, 4, 8, 16, 32, 64]:
start = time.perf_counter()
new.beam_search(model, tokenizer, sentences, batch_size=batch_size)
seconds = time.perf_counter() - start
rows.append({"batch": batch_size, "sentences per second": len(sentences) / seconds,
"ms per batch": 1000 * seconds / math.ceil(len(sentences) / batch_size)})
by_batch = pd.DataFrame(rows)
display(table(by_batch, digits=1))
| batch | sentences per second | ms per batch |
|---|---|---|
| 1 | 18.4 | 54.2 |
| 2 | 28.3 | 70.7 |
| 4 | 43.0 | 93.0 |
| 8 | 56.3 | 142.2 |
| 16 | 72.2 | 221.6 |
| 32 | 82.6 | 387.4 |
| 64 | 83.4 | 767.2 |
From one sentence to sixteen, the throughput grows four times, from 18 sentences a second to 72, and a batch of sixteen takes four times as long as a single sentence, not sixteen times.
That’s the fact the second half of this lesson is built on: one translation of sixteen sentences costs about as much as four translations of one. Past sixteen the gain flattens.
A batch of 64 gives 83 sentences a second instead of 72 and takes three quarters of a second, which every sentence in it waits for. The server’s batches will stop at sixteen.
One more knob: threads. PyTorch splits a matrix multiplication between CPU threads, and more isn’t always better:
threads_before = torch.get_num_threads()
rows = []
for threads in [1, 2, 4]:
torch.set_num_threads(threads)
start = time.perf_counter()
new.beam_search(model, tokenizer, sentences[:128], batch_size=16)
rows.append({"threads": threads, "seconds": time.perf_counter() - start})
torch.set_num_threads(threads_before)
display(table(pd.DataFrame(rows)))
| threads | seconds |
|---|---|
| 1 | 3.34 |
| 2 | 2.08 |
| 4 | 2.15 |
Kaggle’s free CPU session shows Python four cores, but they are two physical cores running two hardware threads each, and two threads that share a core mostly take turns. PyTorch starts one thread per physical core, two here, and four buy nothing over two. The default is right on this machine and can be wrong on another: a container run with --cpus=2 still shows the process every core of its host, and PyTorch starts a thread per physical core of the host. Check the number on the machine you deploy to, and set it if it’s wrong: torch.set_num_threads(n) in the code, or the OMP_NUM_THREADS environment variable before the process starts [4].
6. A FastAPI service
A command line loads the model for every call, and lesson 7 measured that the loading costs more than translating a sentence. A service loads it once and answers requests over HTTP for as long as it runs.
Two programs make one in Python. A server, here uvicorn, listens on a port, speaks HTTP and hands every request to the app. The app, written with FastAPI, decides what to answer.
(The interface between the two is called ASGI; you’ll meet the name in error messages.) In the app, a handler is the function called for one address.
Ours are a POST to /translate with a JSON body like {"text": "..."}, and a GET to /health that tells whoever asks that the service is up.

FastAPI builds on pydantic, which turns a class with typed fields into a validator: a request body that doesn’t fit is refused, with an explanation, before our code runs.
The model reads at most 256 tokens, about a thousand characters of English, so the API refuses longer texts instead of cutting them silently. A real service would split a long text into sentences and translate them one by one.
%%writefile perevod/src/perevod/server.py
import argparse
import asyncio
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI
from pydantic import BaseModel, Field
from . import __version__
from .translator import MODEL, Translator
class TranslateRequest(BaseModel):
# the model reads at most 256 tokens, about a thousand characters of English
text: str = Field(max_length=1000, examples=["Tom is afraid of the dark."])
class TranslateResponse(BaseModel):
translation: str
Writing perevod/src/perevod/server.py Many requests can be open at once, and the model should see them one batch at a time. Between the two sits a queue, and the queue lives in asyncio, the part of Python that uvicorn runs on. It needs a paragraph of its own.
uvicorn runs the app on an event loop: one thread that juggles all the open requests. A function defined with async def is a coroutine, and at every await (on the network, a queue, a timer) it pauses and lets the loop run something else. That’s how one process holds hundreds of requests without hundreds of threads.
The flip side is that code which computes without awaiting holds the whole loop: a translation of 60 ms would freeze every other request for 60 ms. So the model runs in another thread, through asyncio.to_thread, and the loop waits for it as it waits for the network.
Last, a future is a box for a result that doesn’t exist yet: await answer pauses until someone calls answer.set_result(...), and then returns that value.
Now the batcher. Every request puts its text and a future into the queue and waits for the future. The batcher takes requests out of the queue: the first one, whenever it comes, then whatever else arrives within max_wait seconds (wait_for gives up when the time left runs out), up to max_batch requests. With max_batch=1 it is a plain queue, one request at a time. NVIDIA’s Triton inference server batches with the same two settings [5].
%%writefile -a perevod/src/perevod/server.py
class Batcher:
"""Translates requests that arrive close together as one batch.
A batch closes when it holds max_batch requests or max_wait seconds after its first one.
"""
def __init__(self, translator, max_batch=16, max_wait=0.005):
self.translator = translator
self.max_batch, self.max_wait = max_batch, max_wait
self.queue = asyncio.Queue()
self.batch_sizes = []
async def translate(self, text):
answer = asyncio.get_running_loop().create_future()
await self.queue.put((text, answer))
return await answer # until run() gives the future its result
Appending to perevod/src/perevod/server.py %%writefile -a perevod/src/perevod/server.py
async def collect(self):
batch = [await self.queue.get()] # waits for the first request, however long
deadline = time.monotonic() + self.max_wait
while len(batch) < self.max_batch:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
try:
batch.append(await asyncio.wait_for(self.queue.get(), remaining))
except asyncio.TimeoutError:
break
return batch
Appending to perevod/src/perevod/server.py The batcher translates each batch in a worker thread and gives every request its own translation. If the translation fails, every request of the batch gets the error instead of waiting forever.
The done() checks cover a quieter case: a caller that stopped waiting, say on a timeout, leaves a cancelled future behind. Setting a result on it raises an error, and without the check that error would end run(); every later request would then wait forever, while /health went on answering “ok”.
%%writefile -a perevod/src/perevod/server.py
async def run(self):
while True:
batch = await self.collect()
self.batch_sizes.append(len(batch))
texts = [text for text, _ in batch]
try: # in a thread, so that the event loop keeps serving requests meanwhile
translations = await asyncio.to_thread(self.translator.translate, texts)
except Exception as error: # noqa: BLE001, every request of the batch gets it
for _, answer in batch:
if not answer.done():
answer.set_exception(error)
continue
for (_, answer), translation in zip(batch, translations, strict=True):
if not answer.done(): # done already if its caller stopped waiting
answer.set_result(translation)
Appending to perevod/src/perevod/server.py The app itself. lifespan runs when the server starts and when it stops: it loads the model once and starts the batcher as a background task next to the handlers (create_task), and cancels it at the end.
app.state is where the handlers find the batcher. The demo switch mounts the Gradio page of section 9.
%%writefile -a perevod/src/perevod/server.py
def create_app(model=MODEL, revision=None, max_batch=16, max_wait=0.005, demo=False):
@asynccontextmanager
async def lifespan(app):
# before yield: when the server starts; after it: when it stops
translator = Translator.from_pretrained(model, revision)
app.state.batcher = Batcher(translator, max_batch, max_wait)
worker = asyncio.create_task(app.state.batcher.run()) # runs next to the handlers
yield
worker.cancel()
app = FastAPI(title="perevod", version=__version__, lifespan=lifespan)
@app.post("/translate")
async def translate(request: TranslateRequest) -> TranslateResponse:
translation = await app.state.batcher.translate(request.text)
return TranslateResponse(translation=translation)
@app.get("/health")
async def health():
return {"status": "ok", "version": __version__}
if demo:
import gradio as gr # only the demo extra installs it
from .demo import build_demo
app = gr.mount_gradio_app(app, build_demo(app), path="/")
return app
Appending to perevod/src/perevod/server.py And a command, perevod-server, with the batcher’s settings as options:
%%writefile -a perevod/src/perevod/server.py
def main(argv=None):
parser = argparse.ArgumentParser(prog="perevod-server",
description="Serve perevod over HTTP.")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--max-batch", type=int, default=16, help="1 turns batching off")
parser.add_argument("--max-wait-ms", type=float, default=5.0)
parser.add_argument("--demo", action="store_true", help="serve a Gradio page at /")
args = parser.parse_args(argv)
import uvicorn # only the serve extra installs it; the tests don't need it
app = create_app(max_batch=args.max_batch, max_wait=args.max_wait_ms / 1000,
demo=args.demo)
uvicorn.run(app, host=args.host, port=args.port)
if __name__ == "__main__":
main()
Appending to perevod/src/perevod/server.py The package grows a new optional dependency, the server’s: pip install "perevod[serve]" installs FastAPI and uvicorn, and a plain pip install perevod stays as light as it was. The server gets a command of its own, perevod-server.
The version becomes 0.2.0, and from now on it is written in one place. Lesson 7 wrote it twice, in pyproject.toml and in __init__.py, and its version test compared the command’s output with the second copy, so the two could drift apart at the first release without a test noticing.
Now pyproject.toml declares the version dynamic, and hatchling reads it from __init__.py when it builds the wheel:
write("perevod/pyproject.toml", """
[project]
name = "perevod"
dynamic = ["version"] # read from src/perevod/__init__.py, see [tool.hatch.version]
description = "English-to-Russian translation with a small Transformer built from scratch"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
dependencies = [
"torch>=2.1",
"tokenizers>=0.15",
"huggingface_hub>=0.20",
"safetensors>=0.4",
]
[project.optional-dependencies]
serve = ["fastapi>=0.110", "uvicorn>=0.29"]
demo = ["fastapi>=0.110", "uvicorn>=0.29", "gradio>=4.40"]
test = ["pytest>=8", "ruff>=0.6", "sacrebleu>=2.4", "pandas>=2.0", "pyarrow>=14",
"fastapi>=0.110", "httpx>=0.27"]
[project.scripts]
perevod = "perevod.cli:main"
perevod-server = "perevod.server:main"
[project.urls]
Source = "https://github.com/xmarva/transformers-from-scratch/tree/main/perevod"
Model = "https://huggingface.co/xmarva/perevod-en-ru"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.version]
path = "src/perevod/__init__.py" # the one place the version is written
[tool.pytest.ini_options]
markers = ["slow: downloads the published model and translates real sentences"]
[tool.ruff]
line-length = 92 # what a notebook cell shows: lesson 7 writes these files in its cells
""")
%%writefile perevod/src/perevod/__init__.py
__version__ = "0.2.0"
def __getattr__(name):
# `import perevod` stays light, so that --help and --version answer at once;
# PyTorch is loaded only when someone asks for the translator
if name == "Translator":
from .translator import Translator
return Translator
raise AttributeError(f"module 'perevod' has no attribute {name!r}")
Overwriting perevod/src/perevod/__init__.py From here on we change the package as we go, so it’s installed in editable mode: pip puts a pointer to perevod/src into site-packages instead of a copy, and every process that imports perevod gets the current files.
That’s the right install for working on a package. The CI job that installs the built wheel is what checks the copy users get, and section 10 makes it check more.
!{sys.executable} -m pip install -q -e "./perevod[serve]"
Installing build dependencies ... done
Checking if build backend supports build_editable ... done
Getting requirements to build editable ... done
Installing backend dependencies ... done
Preparing editable metadata (pyproject.toml) ... done
Building editable for perevod (pyproject.toml) ... done
A service runs in its own process. start_server starts one, sends what it prints to a log file, and waits until /health answers; if the process exits first, the error shows the end of its log. Then a sentence, first with batching turned off:
def start_server(port, max_batch, max_wait_ms=5, demo=False):
command = [sys.executable, "-m", "perevod.server", "--port", str(port),
"--max-batch", str(max_batch), "--max-wait-ms", str(max_wait_ms)]
if demo:
command.append("--demo")
log = Path(f"server-{port}.log")
with log.open("w") as output:
process = subprocess.Popen(command, stdout=output, stderr=subprocess.STDOUT)
for _ in range(180):
if process.poll() is not None: # it exited: a port in use, a missing package...
break
try:
if httpx.get(f"http://127.0.0.1:{port}/health").status_code == 200:
return process
except httpx.HTTPError:
pass
time.sleep(1)
process.kill()
raise RuntimeError("the server did not start:\n" + log.read_text()[-2000:])
server = start_server(8001, max_batch=1)
URL = "http://127.0.0.1:8001/translate"
answer = httpx.post(URL, json={"text": "Nobody understands me."})
print(answer.status_code, answer.json())
refused = httpx.post(URL, json={"text": "word " * 2000})
print(refused.status_code, refused.json()["detail"][0]["msg"])
200 {'translation': 'Никто меня не понимает.'}
422 String should have at most 1000 characters A translation, and for the text that is too long a 422, HTTP’s code for “I understood the request, but its content is invalid”, with a readable reason. Our code never saw that request: FastAPI refused it from the declaration of TranslateRequest.
The same declarations describe the API at /docs, an interactive page where anyone can try requests (on your own machine, open http://127.0.0.1:8001/docs in a browser), and at /openapi.json, a machine-readable description from which clients in other languages can be generated:
spec = httpx.get("http://127.0.0.1:8001/openapi.json").json()
for path, operations in spec["paths"].items():
for method, operation in operations.items():
print(method.upper(), path, "-", operation["summary"])
POST /translate - Translate
GET /health - Health 7. Load testing without fooling yourself
Now the question this lesson is about: how fast does the service answer when many people use it? The obvious way to find out is a loop that sends a request, waits for the answer, records the time, and sends the next one, with a few such loops in parallel. Tools built around “virtual users” that wait for their answers, like Locust or JMeter’s usual thread groups, work that way, and Gil Tene named what’s wrong with it: coordinated omission [6].

When the server stalls, a tester that waits for each answer stops sending, so it records one slow request where real users, who don’t coordinate with the server, would have produced dozens.
The fix is an open-loop test: decide in advance when every request is sent, independently of the answers, and measure each latency from the moment the request was supposed to go.
wrk2, which Tene wrote for exactly this, and the arrival-rate executors of k6 work that way. Users arriving independently at an average rate \(\lambda\) are well described by a Poisson process, whose gaps between arrivals are exponentially distributed with mean \(1/\lambda\):
def arrival_times(rate, seconds, seed):
rng = random.Random(seed)
times, moment = [], rng.expovariate(rate)
while moment < seconds:
times.append(moment)
moment += rng.expovariate(rate) # exponential gaps: a Poisson process
return times
async def load_test(url, rate, seconds, sentences, seed=0):
# no connection limit: with httpx's default of 100, requests would queue in the
# client and the test would blame the server; no timeout: a slow answer is a
# measurement, not an error
limits = httpx.Limits(max_connections=None, max_keepalive_connections=None)
async with httpx.AsyncClient(timeout=None, limits=limits) as client:
start = time.perf_counter()
async def one(k, at):
await asyncio.sleep(max(0.0, start + at - time.perf_counter()))
response = await client.post(url, json={"text": sentences[k % len(sentences)]})
response.raise_for_status()
return time.perf_counter() - (start + at) # from when it was due, not sent
tasks = []
for k, at in enumerate(arrival_times(rate, seconds, seed)):
tasks.append(one(k, at))
return np.array(await asyncio.gather(*tasks))
Each request is a coroutine that sleeps until its moment, then sends. If the server is slow, the coroutines don’t wait for each other, just like users.
gather runs them all together and returns their results in order. (A notebook can await at the top level; in a script you’d write asyncio.run(load_test(...)).)
Two shortcuts make this a lesson’s load test rather than a release’s. The load generator shares the server’s two cores, where a real test runs it from another machine. And raise_for_status stops the test at the first error, where a real one counts errors next to latencies: a server that answers “500” quickly has excellent latency.
First, the service time: how long the server takes per request when no other request is waiting. For this one measurement the closed loop is exactly the right tool: send a request, wait for the answer, send the next.
There’s never a queue, so every time it records is a service time. Five hundred sentences of the traffic the load test will send, in the same order:
traffic = list(test.sample(2_000, random_state=SEED + 2).en)
service_times, answers = [], []
with httpx.Client(timeout=None) as client:
for sentence in traffic[:500]:
start = time.perf_counter()
response = client.post(URL, json={"text": sentence})
service_times.append(time.perf_counter() - start)
answers.append(response.json()["translation"])
service_times = np.array(service_times)
service = service_times.mean()
mu = 1 / service
median, p99 = np.percentile(service_times, [50, 99]) * 1000
print(f"service time: mean {service * 1000:.0f} ms, median {median:.0f} ms, "
f"99th percentile {p99:.0f} ms")
print(f"one request at a time, at most {mu:.1f} requests per second")
slowest = service_times.argmax()
print(f"\nthe slowest, {service_times[slowest] * 1000:.0f} ms:")
print(traffic[slowest])
print(answers[slowest])
service time: mean 60 ms, median 50 ms, 99th percentile 261 ms
one request at a time, at most 16.7 requests per second
the slowest, 777 ms:
I have not read it, but someone who has read the book "On the origin of species" by Charles Darwin guarantees that he claims that all living beings — plants, insects, reptiles, birds, dinosaurs, whales, man — descend from a common ancestor, from which they evolved, diversifying according to a law he calls "natural selection".
Руководителями, которые утверждают, что он утверждает, что он утверждает, что он заявляет, что он утверждает, что он говорит, что не умеет читать все, которые предвзяваются в том, что он говорит, что он говорит, что всё равно что предлагал в том, что он говорит, что он говорит, что всё, что подслушивает, что он говорит, что он говорит, что всё, по происхождению, из которых предшествует, что он намеревает, что он не читает в том, что он утверждает, что он говорит, что он Half the requests take about 50 ms, the mean is 60, and one in a hundred takes more than a quarter of a second.
The slowest took 777 ms, fifteen times the median, and its translation shows why: on a long sentence the model fell into a loop, “он утверждает, что он утверждает…”, and wrote until it reached the limit of 128 tokens, each token one more step of the decoder. Here the slowest requests are the model’s failures.
A server can bound them by capping the output at a multiple of the input’s length. perevod doesn’t, because the cap would change translations, and this release promises to change none.
A service that answers one request at a time has a capacity of \(\mu = 1/S\) requests per second, where \(S\) is the mean service time. The load test runs at fractions of it, from a fifth to almost all, thirty seconds each.
It reports two percentiles of the latency. The median, p50, is the latency half of the requests beat. The 90th percentile, p90, is the one nine requests in ten beat: one user in ten waits longer.
Dean and Barroso’s “slow one time in a hundred” is the 99th percentile, but thirty seconds hold a few hundred requests at most, and their 99th percentile would rest on the slowest two or three. A load test before a release runs for many minutes and reports the 99th percentile and beyond.
LOADS = [0.2, 0.4, 0.6, 0.7, 0.8, 0.9, 0.95]
one_at_a_time = {}
for load in LOADS:
rate = load * mu
one_at_a_time[rate] = await load_test(URL, rate, 30, traffic, seed=int(100 * load))
p50, p90 = np.percentile(one_at_a_time[rate], [50, 90]) * 1000
print(f"{rate:5.1f} requests/s, {load:.0%} of capacity: "
f"p50 {p50:6.0f} ms, p90 {p90:6.0f} ms")
3.3 requests/s, 20% of capacity: p50 59 ms, p90 121 ms
6.7 requests/s, 40% of capacity: p50 67 ms, p90 147 ms
10.0 requests/s, 60% of capacity: p50 83 ms, p90 249 ms
11.7 requests/s, 70% of capacity: p50 99 ms, p90 440 ms
13.3 requests/s, 80% of capacity: p50 262 ms, p90 1527 ms
15.0 requests/s, 90% of capacity: p50 192 ms, p90 719 ms
15.8 requests/s, 95% of capacity: p50 366 ms, p90 874 ms At a fifth of its capacity the server answers in about its service time: the median request takes 59 ms. The tail moves first.
At 60% the median has grown by 40% and the 90th percentile has doubled; at 95% the median is seven times the median service time, and one request in ten waits nearly a second, though the model works exactly as fast as before. The time went into the queue.
One point is out of line: the test at 80% load came out worse than the one at 90%, with a median of a quarter of a second and one request in ten waiting a second and a half. Keep it in mind; the next pages explain it.
One check before any theory. This runs on a shared machine, and a cloud CPU can get slower or faster while you measure, when its neighbours start or stop working.
If it did, the service time we measured belongs to another machine than the load tests, and nothing below would add up. The first hundred sentences, one at a time, once more:
again = []
with httpx.Client(timeout=None) as client:
for sentence in traffic[:100]:
start = time.perf_counter()
client.post(URL, json={"text": sentence}).raise_for_status()
again.append(time.perf_counter() - start)
before, after = 1000 * service_times[:100].mean(), 1000 * np.mean(again)
print(f"the same 100 sentences: {before:.0f} ms before the load tests, "
f"{after:.0f} ms after")
the same 100 sentences: 55 ms before the load tests, 54 ms after The same speed before and after: the service time we measured is the one the load tests met. On one of the runs that went into writing this lesson it wasn’t.
The machine took half again as long per sentence while the service time was measured and was fast again by the load tests, and the predictions near capacity came out three to five times too high. Measure the service time again after a load test; it costs a few seconds.
Queueing theory predicts this curve, and the prediction is worth having before you buy servers. Its formulas are simplest for the mean, so from here on the comparison is about mean latency.
The simplest model is the M/M/1 queue (Harchol-Balter, 2013): Poisson arrivals at rate \(\lambda\), one server, exponentially distributed service times with mean \(S = 1/\mu\).
The utilization \(\rho = \lambda / \mu\) is the fraction of time the server is busy; it has to stay below 1, or the queue grows without end. The mean time a request spends in the system, waiting plus being served, is then
\[\mathbb{E}[T] = \frac{1}{\mu - \lambda} = \frac{S}{1 - \rho}.\]The factor \(1/(1 - \rho)\) is the whole story: at \(\rho = 0.5\) a request takes twice its service time on average, at \(0.9\) ten times, at \(0.99\) a hundred. The last 10% of capacity costs more latency than the first 90%.
That’s why services are planned to stay well below full at their busiest hour, often around 60–70% busy, where waiting adds one and a half to two and a half service times and a burst of traffic doesn’t push them over the edge.
Real service times are rarely exponential. Kingman’s approximation keeps the factor and corrects for how spread out the service times are[7]:
\[\mathbb{E}[T] \approx S + \frac{\rho}{1 - \rho} \cdot \frac{c_a^2 + c_s^2}{2} \cdot S,\]where \(c_a\) and \(c_s\) are the coefficients of variation (standard deviation over mean) of the gaps between arrivals and of the service times.
Poisson arrivals have \(c_a = 1\), and then the formula is exact, not approximate: it’s the Pollaczek–Khinchine formula for a single server with any distribution of service times. Exponential service has \(c_s = 1\) too, and then the formula is M/M/1’s. Ours, from the five hundred service times:
c_s = service_times.std() / service_times.mean()
print(f"coefficient of variation of the service time: {c_s:.2f}")
rows = []
for rate, times in one_at_a_time.items():
rho = rate / mu
kingman = service + rho / (1 - rho) * (1 + c_s**2) / 2 * service
rows.append({"load, %": round(100 * rho), "measured mean, ms": 1000 * times.mean(),
"M/M/1, ms": 1000 * service / (1 - rho), "Kingman, ms": 1000 * kingman})
theory = pd.DataFrame(rows)
display(table(theory, digits=0))
coefficient of variation of the service time: 0.91 | load, % | measured mean, ms | M/M/1, ms | Kingman, ms |
|---|---|---|---|
| 20 | 72 | 75 | 74 |
| 40 | 88 | 100 | 96 |
| 60 | 122 | 150 | 142 |
| 70 | 189 | 200 | 188 |
| 80 | 532 | 300 | 279 |
| 90 | 311 | 601 | 552 |
| 95 | 439 | 1,201 | 1,098 |
Both formulas describe a queue in its steady state, after it has run long enough to forget how it started. Our tests start from an empty queue, and near capacity a queue forgets slowly. For M/M/1 the time it takes, the relaxation time, is \(S/(1 - \sqrt{\rho})^2\), which grows like \(1/(1 - \rho)^2\) [8]: about 90 service times at 80% load, 380 at 90% and 1,560 at 95%. A test that starts empty and ends before its queue has settled measures less than the steady state.
And a thirty-second test is one draw of a random process. A burst of arrivals early on leaves a backlog that a server with little spare capacity clears slowly, and the mean of the whole test goes up with it. How much do such tests vary? The queue can tell us itself. A single server that answers in order follows Lindley’s recursion [9]: a request starts when it arrives or when the previous one ends, whichever is later. Run it two ways. First, replay each of our tests exactly: the arrival times it had, and for request \(k\) the service time we measured for sentence \(k\), the sentence it sent. Second, two hundred times on fresh arrivals, with service times drawn at random from the five hundred, which shows how much thirty-second tests at that load vary:
def simulated_mean(arrivals, service_times):
done, spent = 0.0, []
for arrived, service_time in zip(arrivals, service_times, strict=True):
done = max(arrived, done) + service_time # on arrival, or when the last one ends
spent.append(done - arrived)
return np.mean(spent)
def simulate(arrivals):
return simulated_mean(arrivals, rng.choice(service_times, len(arrivals)))
for load in [0.8, 0.9, 0.95]:
print(f"relaxation time at {load:.0%} load: {service / (1 - load**0.5) ** 2:.0f} s")
rng = np.random.default_rng(SEED)
rows = []
for load, (rate, times) in zip(LOADS, one_at_a_time.items(), strict=True):
own = arrival_times(rate, 30, seed=int(100 * load)) # the arrivals this test had
sent = np.arange(len(own)) % len(service_times) # request k sent sentence k
replay = simulated_mean(own, service_times[sent])
others = [simulate(arrival_times(rate, 30, seed=1_000 + k)) for k in range(200)]
low, high = np.percentile(others, [10, 90])
rows.append({"load, %": round(100 * load), "measured mean, ms": 1000 * times.mean(),
"replay, ms": 1000 * replay,
"8 in 10 tests: from": 1000 * low, "to": 1000 * high})
simulated = pd.DataFrame(rows)
display(table(simulated, digits=0))
relaxation time at 80% load: 5 s
relaxation time at 90% load: 23 s
relaxation time at 95% load: 94 s | load, % | measured mean, ms | replay, ms | 8 in 10 tests: from | to |
|---|---|---|---|---|
| 20 | 72 | 67 | 59 | 96 |
| 40 | 88 | 83 | 73 | 128 |
| 60 | 122 | 117 | 98 | 198 |
| 70 | 189 | 182 | 118 | 259 |
| 80 | 532 | 514 | 161 | 427 |
| 90 | 311 | 305 | 221 | 903 |
| 95 | 439 | 460 | 261 | 1,243 |
rhos = np.linspace(0.05, 0.97, 100)
fig, ax = plt.subplots(figsize=(8, 3.8))
measured = [1000 * times.mean() for times in one_at_a_time.values()]
ax.plot(rhos, 1000 * (service + rhos / (1 - rhos) * (1 + c_s**2) / 2 * service),
color=COLORS["att"], label="Kingman's approximation")
ax.plot(rhos, 1000 * service / (1 - rhos), color=COLORS["muted"], linestyle=(0, (3, 3)),
label="M/M/1")
ax.vlines(LOADS, simulated["8 in 10 tests: from"], simulated["to"],
color=COLORS["dec"], alpha=0.3, linewidth=7,
label="8 in 10 simulated 30-second tests")
ax.plot(LOADS, simulated["replay, ms"], "x", color=COLORS["dec"], markersize=8,
label="replay of the test")
ax.plot(LOADS, measured, "o", color=COLORS["dec"], label="measured mean")
ax.set_xlabel("utilization ρ = λ / μ")
ax.set_ylabel("mean latency, ms")
ax.set_ylim(0, 1.15 * max(max(measured), simulated["to"].max()))
ax.set_title("Latency against load, one request at a time")
ax.legend(frameon=False)
plt.show()

Our service times are almost as spread out as an exponential distribution’s, \(c_s = 0.91\), because of the few sentences the model loops on, so Kingman’s curve and M/M/1’s nearly coincide. The measured means follow them up to 70% of capacity. Above that, tests scatter widely, as the bands show, and the 80% point lies outside even its band.
The replays settle it. Given the arrival times each test had and the service time measured for each sentence it sent, a dozen lines of arithmetic with no server in them reproduce every measured mean to within 7%, the 80% test included.
In that test a burst of arrivals, a quarter more than the server could handle, came between the 18th and the 21st second, together with two of the slowest sentences of the traffic, the looping Darwin among them. The queue they built took most of the remaining test to clear.
In the 90% test the same two sentences came just before a quieter stretch, and their queue was gone three seconds later. So this server is a queue and nothing more, and a thirty-second test is one throw of its dice.
Two practical rules follow. Below about 70% of capacity a short test is enough. Near capacity a single test can land almost anywhere: at 95%, eight thirty-second tests in ten fall between a quarter and a little more than the steady-state mean of Kingman’s formula.
That’s why the measured 95% point sits so far under the curve: thirty seconds is a third of its relaxation time of 94 seconds, and a test that starts from an empty queue doesn’t reach the steady state in that time. Near capacity, run long tests, or many, before you believe a number.
8. Dynamic batching
Section 5 showed that one batch of sixteen costs a few times one sentence, not sixteen times. Requests waiting in a queue are already a batch; the batcher of section 6 only takes them together: the first request, whatever else arrives within a few milliseconds, all translated at once.

The cost is a wait of at most max_wait for the first request of a batch when the server is idle; the gain is that under load the server translates many requests at the price of a few.
Section 5 also gives a forecast. Its batches of sixteen ran at about four times the throughput of single sentences.
The server will do worse: it batches whatever arrives, short and long sentences together, and every request in a batch waits for the longest. Two to four times the capacity is a fair guess.
The same server with batches of up to sixteen, at the same loads with the same arrivals, and at loads it couldn’t take before:
server.terminate()
server.wait()
server = start_server(8002, max_batch=16, max_wait_ms=5)
URL = "http://127.0.0.1:8002/translate"
batched = {}
for load in LOADS + [1.5, 2, 2.5, 3]:
rate = load * mu
batched[rate] = await load_test(URL, rate, 30, traffic, seed=int(100 * load))
p50, p90 = np.percentile(batched[rate], [50, 90]) * 1000
print(f"{rate:5.1f} requests/s, {load:.0%} of the capacity one at a time: "
f"p50 {p50:6.0f} ms, p90 {p90:6.0f} ms")
3.3 requests/s, 20% of the capacity one at a time: p50 61 ms, p90 131 ms
6.7 requests/s, 40% of the capacity one at a time: p50 69 ms, p90 138 ms
10.0 requests/s, 60% of the capacity one at a time: p50 77 ms, p90 164 ms
11.7 requests/s, 70% of the capacity one at a time: p50 88 ms, p90 216 ms
13.3 requests/s, 80% of the capacity one at a time: p50 103 ms, p90 310 ms
15.0 requests/s, 90% of the capacity one at a time: p50 104 ms, p90 279 ms
15.8 requests/s, 95% of the capacity one at a time: p50 104 ms, p90 228 ms
25.0 requests/s, 150% of the capacity one at a time: p50 162 ms, p90 439 ms
33.3 requests/s, 200% of the capacity one at a time: p50 265 ms, p90 867 ms
41.6 requests/s, 250% of the capacity one at a time: p50 573 ms, p90 1449 ms
50.0 requests/s, 300% of the capacity one at a time: p50 1392 ms, p90 2948 ms fig, ax = plt.subplots(figsize=(8, 3.8))
for runs, colour, name in [(one_at_a_time, COLORS["dec"], "one at a time"),
(batched, COLORS["enc"], "dynamic batching, up to 16")]:
rates = list(runs)
p50 = [1000 * np.percentile(runs[rate], 50) for rate in rates]
p90 = [1000 * np.percentile(runs[rate], 90) for rate in rates]
ax.plot(rates, p50, "o-", color=colour, label=f"{name}: median")
ax.plot(rates, p90, "o--", color=colour, alpha=0.6, label=f"{name}: 90th percentile")
ax.axvline(mu, color=COLORS["muted"], linewidth=1, linestyle=(0, (3, 3)))
ax.text(mu, ax.get_ylim()[1] * 0.92, " capacity one at a time", color=COLORS["muted"],
fontsize=9)
ax.set_yscale("log")
ax.set_xlabel("requests per second")
ax.set_ylabel("latency, ms (log scale)")
ax.set_title("The same server, without and with dynamic batching")
ax.legend(frameon=False, fontsize=8)
plt.show()

At the loads the server could already take, batching changes little at the bottom and a lot near the top.
At 20% of the old capacity the two servers answer alike, the batching one a couple of milliseconds slower, the price of max_wait. At 95% the median falls from 366 ms to 104 and the 90th percentile from 874 ms to 228.
And the server now takes loads it couldn’t take at all: at one and a half times the old capacity the median is 162 ms, at twice 265 ms. At two and a half times the latency climbs, and at three the queue grows for as long as the test lasts.
The capacity grew about two and a half times, inside the forecast and short of section 5’s fourfold, because a batch waits for its longest sentence.
Two settings remain to choose, and the measurements above say how. max_wait is added latency when the server is idle, and it hardly matters under load, because the queue fills batches while the model works: keep it a small fraction of the service time.
max_batch belongs where section 5’s throughput flattens. Past that point a bigger batch adds little throughput, and every request in it waits for all of it.
One thing this server doesn’t do. Its queue has no limit, so beyond capacity the latency grows for as long as the overload lasts, and the last points of the plot measure the length of the test more than the server. A production service caps its queue and answers 503, “try later”, to the requests it can’t take.
Large language model servers take the same idea further. A request to an LLM generates hundreds of tokens, and waiting for a whole batch to finish would hold short answers behind long ones, so vLLM and its predecessors batch at the level of single decoding steps: a finished sequence leaves the batch after its last token and a new one joins at the next step[10, 11].
Our sentences are short enough that batching whole requests is the right trade.
9. A Gradio page
An API is for programs. For people, a page with a text box helps more. Gradio builds one from a few lines, and it can live inside the same FastAPI app, so the page and the API share one model and one queue:
%%writefile perevod/src/perevod/demo.py
import gradio as gr
EXAMPLES = ["My brother lives in a small town by the sea.",
"Every morning I drink tea and read the news.",
"The cat is sleeping on the windowsill.", "Tom is afraid of the dark."]
ABOUT = """# perevod
English to Russian with a small Transformer built from scratch in the course
[Transformers: from scratch to production][course]. It was trained on short everyday
sentences; its [model card][card] says where it fails.
[course]: https://github.com/xmarva/transformers-from-scratch
[card]: https://huggingface.co/xmarva/perevod-en-ru"""
def build_demo(app):
async def translate(text):
return await app.state.batcher.translate(text) # through the same queue as the API
with gr.Blocks(title="perevod") as demo:
gr.Markdown(ABOUT)
source = gr.Textbox(label="English", lines=3)
target = gr.Textbox(label="Russian", lines=3)
button = gr.Button("Translate", variant="primary")
# no limit: Gradio would otherwise send its visitors one at a time
button.click(translate, source, target, concurrency_limit=None)
gr.Examples(EXAMPLES, source)
return demo
Writing perevod/src/perevod/demo.py The page translates through app.state.batcher, so people on the page and programs calling the API wait in the same queue and fill the same batches.
One argument makes that true: Gradio sends a page’s clicks through a queue of its own, one at a time by default, and concurrency_limit=None lets them all through to ours.
Gradio is a heavy dependency, so it is an extra of its own, demo, and the page appears only with --demo. Here is what it looks like:

server.terminate()
server.wait()
!{sys.executable} -m pip install -q -e "./perevod[demo]"
server = start_server(8003, max_batch=16, demo=True)
page = httpx.get("http://127.0.0.1:8003/")
answer = httpx.post("http://127.0.0.1:8003/translate", json={"text": "Do you dream?"})
server.terminate()
server.wait()
print("the page:", page.status_code, "| the API:", answer.json())
Installing build dependencies ... done
Checking if build backend supports build_editable ... done
Getting requirements to build editable ... done
Installing backend dependencies ... done
Preparing editable metadata (pyproject.toml) ... done
Building editable for perevod (pyproject.toml) ... done
ERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.
dopamine-rl 4.1.2 requires gym<=0.25.2, but you have gym 0.26.2 which is incompatible.
the page: 200 | the API: {'translation': 'Ты видишь сны?'} Both answer. On Kaggle, pip also prints an error about dopamine-rl and gym, two packages of Kaggle’s own environment that disagree with each other; it has nothing to do with ours.
10. Tests and the release of 0.2.0
The server gets tests of its own, on lesson 7’s tiny model. pytest runs the app in-process: FastAPI’s TestClient calls it without a network and runs its lifespan, so the batcher is real.
The last test matters most: requests that end up in one batch must each get their own translation back. It’s lesson 7’s “translations come back in the wrong order” one level up, where request A would get request B’s translation.
TestClient sends one request and waits for its answer, so it can never put two requests in one batch. That test sends all four at once from an asynchronous HTTPX client instead.
ASGITransport lets HTTPX call the app directly, without uvicorn or a network, but it doesn’t run the lifespan, so the test enters it itself, and asyncio.run gives the test an event loop of its own. Then it checks that the batcher really saw one batch of four.
%%writefile perevod/tests/test_server.py
import asyncio
import httpx
import pytest
from fastapi.testclient import TestClient
from perevod.server import create_app
@pytest.fixture
def client(tiny_dir):
app = create_app(str(tiny_dir), max_wait=0.05)
with TestClient(app) as client: # entering it runs the lifespan: model and batcher
yield client
def test_the_health_check(client):
assert client.get("/health").json()["status"] == "ok"
def test_the_api_translates_like_the_package(client, translator, pairs):
english = next(iter(pairs))
response = client.post("/translate", json={"text": english})
assert response.json() == {"translation": translator.translate(english)}
def test_a_text_too_long_is_refused(client):
assert client.post("/translate", json={"text": "tom " * 2000}).status_code == 422
def test_requests_in_one_batch_get_their_own_translations(tiny_dir, pairs):
app = create_app(str(tiny_dir), max_wait=0.2)
async def ask_all():
async with app.router.lifespan_context(app): # the model and the batcher
transport = httpx.ASGITransport(app=app)
base_url = "http://perevod"
async with httpx.AsyncClient(transport=transport, base_url=base_url) as client:
requests = []
for english in pairs:
requests.append(client.post("/translate", json={"text": english}))
return await asyncio.gather(*requests)
responses = asyncio.run(ask_all())
assert app.state.batcher.batch_sizes == [len(pairs)] # one batch for all of them
for response, russian in zip(responses, pairs.values(), strict=True):
assert response.json()["translation"] == russian
Writing perevod/tests/test_server.py Rereading lesson 7 for this release turned up two gaps in its tests, and 0.2.0 closes them.
Lesson 7 planted “dropout left on” to see which tests notice, and the test that the same input gives the same translation was one of them.
Then section 11 made the tiny model learn its four pairs by heart, and the model became too sure of itself for dropout to change a word: that test stopped noticing, as lesson 7’s second grid shows.
A test that compares outputs can lose its power this way when the model changes; the direct check can’t. The file with one more test at the end:
%%writefile perevod/tests/test_translator.py
import pytest
def test_an_empty_input_has_an_empty_translation(translator):
assert translator.translate(["", " ", "tom reads"])[:2] == ["", ""]
def test_a_string_gives_a_string_and_a_list_gives_a_list(translator):
assert isinstance(translator.translate("tom reads"), str)
assert isinstance(translator.translate(["tom reads"]), list)
def test_spaces_and_line_breaks_change_nothing(translator):
expected = translator.translate("tom reads a book")
assert translator.translate("tom reads\na book") == expected
def test_a_long_input_is_cut_with_a_warning(translator):
long_text = " ".join(["tom reads a book ."] * 10) # 50 tokens, the model has 32 places
with pytest.warns(UserWarning, match="cut"):
translation = translator.translate(long_text)
assert isinstance(translation, str)
def test_the_translator_switches_dropout_off(translator):
assert not translator.model.training
Overwriting perevod/tests/test_translator.py The second gap is in CI. The wheel job ran only perevod --version, and thanks to lesson 7’s light import perevod that command loads neither PyTorch nor the model code: a wheel without decoding.py would have passed.
The job now installs the wheel with its test extra and runs the fast tests against it, from a folder outside the source tree. And the quality job now starts the moment someone adds the label model to a pull request, not at the next push:
write(".github/workflows/perevod.yml", """
name: perevod
on:
push:
branches: [main]
paths: ["perevod/**", ".github/workflows/perevod.yml"]
pull_request:
types: [opened, synchronize, reopened, labeled] # labeled: see the quality job
paths: ["perevod/**", ".github/workflows/perevod.yml"]
schedule:
- cron: "0 6 * * 1" # Mondays: the published model against the golden file
defaults:
run:
working-directory: perevod # the package lives in a folder of the course repository
env:
# PyTorch's CPU build, without gigabytes of CUDA libraries
PIP_EXTRA_INDEX_URL: https://download.pytorch.org/whl/cpu
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.10", "3.13"] # the oldest Python the package promises, and the newest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python }}
- run: pip install -e ".[test]"
- run: ruff check .
- run: pytest -q -m "not slow"
wheel:
# tests import the package from its folder; this job checks what users install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- run: pip wheel --no-deps --wheel-dir dist .
- run: pip install "$(ls dist/*.whl)[test]"
- name: The installed command works outside the source tree
working-directory: /tmp
run: perevod --version
- name: The fast tests pass against the installed wheel, outside the source tree
working-directory: /tmp
run: pytest -q -p no:cacheprovider -m "not slow" "$GITHUB_WORKSPACE/perevod/tests"
quality:
# slow, downloads the model: on main, on Mondays, on pull requests labelled "model"
if: >-
github.event_name != 'pull_request' ||
contains(github.event.pull_request.labels.*.name, 'model')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- uses: actions/cache@v6
with:
path: ~/.cache/huggingface
key: huggingface-${{ hashFiles('perevod/src/perevod/translator.py') }}
- run: pip install -e ".[test]"
- run: pytest -q -m slow
""")
!{sys.executable} -m pip install -q -e "./perevod[test,serve]"
!cd perevod && {sys.executable} -m pytest -q -p no:cacheprovider --color=no
Installing build dependencies ... done
Checking if build backend supports build_editable ... done
Getting requirements to build editable ... done
Installing backend dependencies ... done
Preparing editable metadata (pyproject.toml) ... done
Building editable for perevod (pyproject.toml) ... done
.......................... [100%]
26 passed in 12.65s
All of them, the slow ones included. The golden test of lesson 7 is the statement this release rests on: the cached beam search, the new decoding step and the reordered caches produce exactly what 0.1.0 produced on the twelve golden sentences, the chrF test keeps its floor, and section 3 compared 530 more sentences word for word.
That makes 0.2.0 a minor release in lesson 7’s terms: new features (the server, the page, the speed), and nothing that changes a translation or breaks a call that worked in 0.1.0.
The release is a commit and a tag, perevod-v0.2.0, in the course repository. On your own machine it installs and starts like this (on Linux without a GPU, add --extra-index-url https://download.pytorch.org/whl/cpu to the first command, or pip downloads gigabytes of CUDA libraries with PyTorch):
pip install "perevod[demo] @ git+https://github.com/xmarva/transformers-from-scratch@perevod-v0.2.0#subdirectory=perevod"
perevod-server --port 8000 --demo
Then open http://127.0.0.1:8000 for the page and http://127.0.0.1:8000/docs for the API, or ask from a terminal:
curl -X POST http://127.0.0.1:8000/translate -H "Content-Type: application/json" -d '{"text": "Hello!"}'
11. The same for your own model
Nothing in the method depends on perevod. Measure the service time first, one request at a time, back to back, on inputs like the ones your users send, and compute the capacity \(\mu = 1/S\).
Then run open-loop tests at a range of loads, from a fifth of \(\mu\) to almost all of it; arrival_times and load_test above work against any HTTP endpoint.
Near capacity, run each load for many relaxation times of the queue, minutes rather than seconds, leave out the first stretch while the queue fills up, and count errors along with latencies. Read the median and the 90th percentile, and the 99th only from thousands of requests.
Then put the means next to Kingman’s curve. If they follow it, you understand your server and can predict what more traffic will do to it.
If they don’t, something besides the queue is at work: another process taking the CPU, or service times measured on the wrong inputs. Plan for the busiest hour at 60–70% of capacity; if that’s not enough, batch, and measure again.
References
- Linden, G. (2006). Make Data Useful, a talk at Stanford. The slide with Amazon’s 100 ms and Google’s 500 ms. ↩
- Brutlag, J. (2009). Speed Matters for Google Web Search. Google. ↩
- Dean, J., Barroso, L. A. (2013). The Tail at Scale. Communications of the ACM 56(2). ↩
- PyTorch: dynamic quantization, CPU threading. ↩
- NVIDIA Triton: dynamic batcher. ↩
- Tene, G. (2013). Coordinated Omission, on the mechanical-sympathy list; and the talk How NOT to Measure Latency (2015). ↩
- Kingman, J. F. C. (1961). The single server queue in heavy traffic. Proceedings of the Cambridge Philosophical Society 57(4). ↩
- Morse, P. M. (1955). Stochastic Properties of Waiting Lines. Operations Research 3(3). The relaxation time of a queue. ↩
- Lindley, D. V. (1952). The theory of queues with a single server. Proceedings of the Cambridge Philosophical Society 48(2). ↩
- Yu, G.-I. et al. (2022). Orca: A Distributed Serving System for Transformer-Based Generative Models. OSDI. Iteration-level scheduling. ↩
- Kwon, W. et al. (2023). Efficient Memory Management for Large Language Model Serving with PagedAttention. SOSP. vLLM. ↩
- Open-loop load generators: wrk2; k6, open and closed models.
- Harchol-Balter, M. (2013). Performance Modeling and Design of Computer Systems: Queueing Theory in Action. Cambridge University Press. The M/M/1 queue.
- FastAPI, Uvicorn, Gradio, HTTPX.