GLaDOS Vol 8 · The Jetson Brain
ch 80 / 99
Chapter 80

Performance Benchmarks

One afternoon, one number, and no way to argue with it

Chapter 79 ended with 19.18 tokens a second and a verdict that the GPU had carried the generation. That was one request, on one afternoon, on a board that had been sitting idle for an hour. Run the same script now and it prints something else. Run it after a long generation has warmed the silicon and it prints something else again. None of those readings is wrong. Not one of them can be compared to anything, including the others.

The box the board came in is no more help. Its 67 TOPS is a real figure for dense batched arithmetic, and it predicts how many camera frames this hardware can classify in a second. It predicts nothing about how long somebody stands in the kitchen waiting for a sentence. Chapter 75 derived the number that does govern that, from memory bandwidth: 102 GB/s divided by 2.0 GB of weights puts a ceiling of 51 tokens a second on this model, against 6.4 on the Pi. A ceiling is arithmetic, not evidence.

So the instrument gets built. Six runs of one prompt, the first thrown away, reduced to a mean and both ends of the spread and written to a file with a date and a board name on it. Then the part that usually goes missing: what comes out is one stage of a turn. Chapter 10 had you write down what each stage of a conversation cost you, and chapter 61 turned those into ceilings with a tail measured behind them. This is where the new board gets held to that record.

◆ Note — the bench for this chapter, and how to read its numbers

The same Orin Nano Super as the last four chapters: JetPack 6.2, NVMe root, no monitor, reached as glados-jetson over SSH, in the 25 W MAXN SUPER power mode, with llama3.2:3b loaded. Every rate, duration and token count below came off that board across two evenings, and the Pi comparison came off the 4B that Volumes 1 through 7 were built on. Yours will differ in all of them. What carries over is the procedure and the arithmetic, which is the whole argument for writing the procedure down.

Six runs, and every one of them written down

▣ Build · stage 1 — one run, described in four numbers
# labs/benchmark.py — copied to the board, run on the python3 JetPack shipped
import json
import urllib.request

OLLAMA_URL = "http://127.0.0.1:11434/api/generate"
MODEL = "llama3.2:3b"
NS_PER_MS = 1_000_000

# Her system prompt, copied out of configs/personality.json, so the request costs
# what one of her turns costs instead of what a demo question costs.
PREAMBLE = (
    "You are GLaDOS, the rogue AI from Aperture Science. You are sardonic, "
    "passive-aggressive and darkly witty. Answer in at most two sentences.\n\n"
    "User: Good morning.\n"
    "GLaDOS: It is morning. One of us is pleased about that.\n\n"
)
PROMPT = PREAMBLE + "User: Is the front door locked?\nGLaDOS:"


def measure(prompt: str, timeout: float = 180.0) -> dict:
    """One non-streamed generation, reduced to what a benchmark run reports."""
    body = json.dumps({"model": MODEL, "prompt": prompt, "stream": False}).encode("utf-8")
    request = urllib.request.Request(
        OLLAMA_URL,
        data=body,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(request, timeout=timeout) as reply:
        r = json.loads(reply.read().decode("utf-8"))
    decode_ms = r["eval_duration"] / NS_PER_MS
    return {
        "tokens": r["eval_count"],
        "prompt_tokens": r["prompt_eval_count"],
        "decode_ms": round(decode_ms, 1),
        "stage_ms": round(r["total_duration"] / NS_PER_MS, 1),
        "decode_tps": round(r["eval_count"] / (decode_ms / 1000), 1),
    }


if __name__ == "__main__":
    print(measure(PROMPT))
$ ssh glados-jetson python3 benchmark.py   # measured on the bench — yours will vary
{'tokens': 31, 'prompt_tokens': 96, 'decode_ms': 1949.0, 'stage_ms': 7205.0, 'decode_tps': 15.9}

Two numbers come out of this call and they answer different questions. decode_tps divides the generated tokens by the generation duration, the pairing chapter 79 established, and it describes the model on this silicon. stage_ms is total_duration, everything the request cost from arrival to reply, and it is what the collector from chapter 49 would have timed if it had wrapped the call. The budget written in chapter 61 is in milliseconds, so a benchmark that reports only tokens a second cannot be checked against it. Notice the gap between the two on this first run: 1,949 milliseconds of generation inside a 7,205 millisecond stage, because the weights were read off the NVMe drive first.

The prompt is her actual preamble with one line of the conversation attached. Chapter 79 showed that prompt length barely moves the decode rate, so a demo question would have produced a similar decode_tps and a much smaller stage_ms. Benchmark the request she will really make.

▣ Build · stage 2 — repeat it, and keep the whole record
# labs/benchmark.py — continued
NUM_RUNS = 6
WARMUP_RUNS = 1


def run_series(n: int = NUM_RUNS) -> list[dict]:
    """Every run that came back, in order. A run that raises prints and is skipped."""
    runs: list[dict] = []
    for i in range(1, n + 1):
        label = "warm-up" if i <= WARMUP_RUNS else "measured"
        try:
            r = measure(PROMPT)
        except Exception as exc:
            print(f"  run {i}/{n}  {label:8}  FAILED: {exc}")
            continue
        runs.append(r)
        print(f"  run {i}/{n}  {label:8}  {r['decode_tps']:5.1f} t/s{r['stage_ms']:9.1f} ms")
    return runs


if __name__ == "__main__":
    run_series()
$ ssh glados-jetson python3 benchmark.py   # measured on the bench — yours will vary
  run 1/6  warm-up    15.9 t/s   7205.0 ms
  run 2/6  measured   18.7 t/s   1836.0 ms
  run 3/6  measured   19.2 t/s   1693.0 ms
  run 4/6  measured   18.5 t/s   1907.0 ms
  run 5/6  measured   18.1 t/s   1720.0 ms
  run 6/6  measured   17.5 t/s   1882.0 ms

Read run 1 against runs 2 through 6 before reading anything else. It is slower in both columns, and the two columns are slow for different reasons. Its stage cost includes the 4.9 seconds of disk read that the later runs skip, because Ollama keeps the model resident for five minutes after a request. Its decode rate is low for a second reason: the GPU clocks were idling when the request arrived and spent the first fraction of a second climbing. That run is not a bad measurement of this board. It is a good measurement of a different situation, the first question after a long silence, and mixing it into a mean answers neither question.

The function keeps every run in full, not the one number the summary will use later. A list of six floats is smaller and it throws away the ability to ask a new question of an old benchmark. Tomorrow you will want to know whether the slow runs were slow in generation or slow in loading, and only the record can say.

The reduction, and the run that does not belong in it

▣ Build · stage 3 — a mean, and the two numbers a mean cannot carry
# labs/benchmark.py — continued
def summarize(values: list[float]) -> dict[str, float]:
    """A mean says where the runs sat; min and max say how far they wandered."""
    return {
        "mean": round(sum(values) / len(values), 1),
        "min": min(values),
        "max": max(values),
    }


if __name__ == "__main__":
    runs = run_series()
    for key in ("decode_tps", "stage_ms"):
        values = [r[key] for r in runs]
        print(f"  {key}")
        print(f"    all six       {summarize(values)}")
        print(f"    warm-up out   {summarize(values[WARMUP_RUNS:])}")
$ ssh glados-jetson python3 benchmark.py   # tail of the same run
  decode_tps
    all six       {'mean': 18.0, 'min': 15.9, 'max': 19.2}
    warm-up out   {'mean': 18.4, 'min': 17.5, 'max': 19.2}
  stage_ms
    all six       {'mean': 2707.2, 'min': 1693.0, 'max': 7205.0}
    warm-up out   {'mean': 1807.6, 'min': 1693.0, 'max': 1907.0}

One discarded run moves the reported rate from 18.0 to 18.4, which sounds like rounding and is not. It moves the stage summary from a mean of 2,707 milliseconds with a worst case of 7,205 to a mean of 1,808 with a worst case of 1,907. The ceiling chapter 61 set for the model stage is 3,000 milliseconds. Keep the warm-up in and this board fails that ceiling on its worst run by more than a factor of two; take it out and the worst run clears it by 1,093 milliseconds. The same six requests, the same silicon, two opposite verdicts, and the only difference is whether a cold-start measurement was allowed to describe a warm system.

The spread is the reason min and max are reported at all. A mean of 18.4 is compatible with five runs clustered inside half a token a second, and equally compatible with a board that started at 19 and was down to 12 by the last run while its case heated up. Those are different boards and you would want to know which one you own. Chapter 61 refused to print a percentile from 14 samples; five samples cannot support one either, so this file reports the ends of the range it actually saw and claims nothing about the ninety-fifth.

▣ Build · stage 4 — a file with a date, a board and a power mode on it
# labs/benchmark.py — continued
import os
import time

BOARD = "jetson-orin-nano-super"
POWER_MODE = "MAXN SUPER 25W"          # read off `nvpmodel -q`, copied in by hand
RESULTS_DIR = "glados/data/benchmarks"


def build_record(runs: list[dict]) -> dict:
    """Everything a future reader needs to decide whether this run is comparable."""
    return {
        "board": BOARD,
        "power_mode": POWER_MODE,
        "model": MODEL,
        "prompt_tokens": runs[0]["prompt_tokens"],
        "runs": NUM_RUNS,
        "warmup_runs": WARMUP_RUNS,
        "decode_tps": summarize([r["decode_tps"] for r in runs][WARMUP_RUNS:]),
        "stage_ms": summarize([r["stage_ms"] for r in runs][WARMUP_RUNS:]),
        "per_run_tps": [r["decode_tps"] for r in runs],
        "started": time.strftime("%Y-%m-%dT%H:%M:%S"),
    }


def save(record: dict) -> str:
    os.makedirs(RESULTS_DIR, exist_ok=True)
    stamp = record["started"].replace(":", "-")
    path = f"{RESULTS_DIR}/{record['board']}-{stamp}.json"
    with open(path, "w") as handle:
        json.dump(record, handle, indent=2)
    return path


if __name__ == "__main__":
    print(f"  wrote {save(build_record(run_series()))}")
$ ssh glados-jetson cat glados/data/benchmarks/jetson-orin-nano-super-2026-08-14T20-31-07.json
{
  "board": "jetson-orin-nano-super",
  "power_mode": "MAXN SUPER 25W",
  "model": "llama3.2:3b",
  "prompt_tokens": 96,
  "runs": 6,
  "warmup_runs": 1,
  "decode_tps": {
    "mean": 18.4,
    "min": 17.5,
    "max": 19.2
  },
  "stage_ms": {
    "mean": 1807.6,
    "min": 1693.0,
    "max": 1907.0
  },
  "per_run_tps": [
    15.9,
    18.7,
    19.2,
    18.5,
    18.1,
    17.5
  ],
  "started": "2026-08-14T20:31:07"
}

One file per run, named for the board and the moment, never overwritten. That choice is what makes a benchmark an archive instead of a reading, and every field in it exists to answer a question a future comparison will raise. Which board. Which power mode, because the same silicon at 7 W is a different machine. How long the prompt was, because a benchmark against a 900-token prompt is not the same experiment. How many runs, and how many were discarded, so nobody has to guess whether the mean includes a cold start. And per_run_tps keeps the raw values, warm-up included, so a later reader who disagrees with the reduction can redo it instead of re-running the board.

The script writes into glados/data/benchmarks beside itself on the Jetson, since her workspace does not land there until chapter 82. Copy the file back to the repository with scp and it joins the record of the build.

◆ Note — benchmark the mode you will actually run in

sudo nvpmodel -q prints the board's current power mode and sudo nvpmodel -m 2 selects MAXN SUPER, the 25 W mode these numbers were taken in; sudo jetson_clocks then pins the clocks at their maximum instead of letting the governor ramp them. Both are legitimate settings and neither is the right one by default. A board in a sealed enclosure on a shelf may have to live at 15 W, and benchmarking at 25 W would then be measuring a machine you do not own. Record the mode in the file and the question never comes up again.

From a rate to a turn

▣ Build · stage 5 — two files, one comparable column
# labs/compare_boards.py — runs on the repository machine, not the board
import glob
import json

RESULTS_DIR = "glados/data/benchmarks"


def load_all(pattern: str = f"{RESULTS_DIR}/*.json") -> list[dict]:
    records: list[dict] = []
    for path in sorted(glob.glob(pattern)):
        with open(path) as handle:
            records.append(json.load(handle))
    return sorted(records, key=lambda r: r["started"])


def compare(records: list[dict], model: str) -> None:
    """Decode rate only: it is the column that survives a change of prompt."""
    same = [r for r in records if r["model"] == model]
    print(model)
    for r in same:
        d = r["decode_tps"]
        print(f"  {r['board']:<24}{r['started']}  mean {d['mean']:5.1f} t/s"
              f"   spread {d['min']:.1f}-{d['max']:.1f}")
    baseline, latest = same[0], same[-1]
    factor = latest["decode_tps"]["mean"] / baseline["decode_tps"]["mean"]
    print(f"  {latest['board']} is {factor:.1f}x the decode rate of {baseline['board']}")


if __name__ == "__main__":
    compare(load_all(), "llama3.2:3b")
$ uv run python -m labs.compare_boards   # both records measured on the bench — yours will vary
llama3.2:3b
  raspberry-pi-4b         2026-07-29T21:14:03  mean   2.7 t/s   spread 2.5-2.8
  jetson-orin-nano-super  2026-08-14T20:31:07  mean  18.4 t/s   spread 17.5-19.2
  jetson-orin-nano-super is 6.8x the decode rate of raspberry-pi-4b

There is the receipt for the money. Chapter 75 predicted the ratio from the bandwidth row alone, 12.8 GB/s against 102, and the measurement lands on 6.8. Only the decode column is compared, on purpose: stage milliseconds depend on how long the prompt was and whether the weights were resident, and two evenings apart those were not identical. The decode rate is a property of the model and the silicon, which is what makes it the honest column to carry across boards.

▣ Build · stage 6 — the same measurement, put back into a turn
# labs/turn_budget.py
from labs.latency_budget import STAGES, LatencyBudget

# p95 per stage from the evening recorded in chapter 61, on the old board.
MEASURED_P95_MS = {"stt": 1042.8, "llm": 4270.5, "tts": 512.4, "arm": 1180.6}


def with_llm(p95: dict[str, float], llm_ms: float) -> dict[str, float]:
    """The same turn with one stage replaced by a newer measurement."""
    return {**p95, "llm": llm_ms}


def turn_ms(p95: dict[str, float]) -> float:
    return sum(p95[stage] for stage in STAGES)


def report(before: dict[str, float], after: dict[str, float]) -> None:
    budget = LatencyBudget()
    print(f"{'stage':<6}{'before ms':>11}{'after ms':>10}{'ceiling':>9}  verdict")
    rows = [(s, before[s], after[s], budget.for_stage(s)) for s in STAGES]
    rows.append(("turn", turn_ms(before), turn_ms(after), budget.total_ms))
    for name, was, now, ceiling in rows:
        verdict = "ok" if now <= ceiling else f"OVER by {now - ceiling:.1f}"
        print(f"{name:<6}{was:>11.1f}{now:>10.1f}{ceiling:>9.0f}  {verdict}")


if __name__ == "__main__":
    after = with_llm(MEASURED_P95_MS, 1907.0)     # the worst of five measured runs
    report(MEASURED_P95_MS, after)
    print(f"model {18.4 / 2.7:.1f}x faster, "
          f"turn {turn_ms(MEASURED_P95_MS) / turn_ms(after):.1f}x faster")
$ uv run python -m labs.turn_budget
stage   before ms  after ms  ceiling  verdict
stt        1042.8    1042.8     1500  ok
llm        4270.5    1907.0     3000  ok
tts         512.4     512.4      800  ok
arm        1180.6    1180.6      600  OVER by 580.6
turn       7006.3    4642.8     5900  ok
model 6.8x faster, turn 1.5x faster

The last line is the chapter. The model got 6.8 times faster and the conversation got 1.5 times faster, because the model was never the whole turn. Substituting the worst of five measured runs for a p95 is deliberately pessimistic and it is the only substitution honest at five samples. The three unchanged rows are unchanged for a plain reason: transcription, speech and the arm still run on the old board, and they move here in chapter 82. Reading the table is a different exercise from reading a tokens-per-second figure. The llm row was 61 percent of the whole turn by itself and is now 41 percent of a shorter one, so the next second you want back is not in the model at all.

The arm row also stays over its ceiling, and it stayed over it through a hardware migration that never touched a servo. Chapter 61 marked that row thin at 14 samples and thin it remains. A GPU cannot fix a pin, which is where chapter 81 starts.

One turn's p95, before and after the model moved to the Jetson Two stacked bars drawn to the same scale. The upper bar is the turn measured in chapter 61 on the Pi: stt 1,042.8 ms, llm 4,270.5 ms, tts 512.4 ms and arm 1,180.6 ms, totalling 7,006.3 ms and running past the 5,900 ms turn budget marker. The lower bar replaces only the llm segment with the Jetson's measured 1,907 ms; the other three segments are identical, and the total of 4,642.8 ms stops short of the budget marker. ONE TURN · ONLY ONE SEGMENT MOVED before · 7,006 ms llm 4,270 after · 4,643 ms llm 1,907 stt llm tts arm turn budget 5,900 ms
Figure 80.1 — Both bars are drawn to one scale. The three untouched segments are 2,735.8 ms of the lower bar, so no future model, however fast, brings the turn below them.

Why this works: a mean is a claim about a population

Every benchmark in this chapter is one idea applied twice. A measurement of a system that varies is a statement about a population of possible runs, and a single run is one member of it. Six runs let you estimate where the population sits and how wide it is, and the width is the part most reports drop. Mean, min and max is the smallest summary that carries both, and it survives being read a year later by somebody who was not there.

The warm-up run is the second half of the same idea, and it is a question about which population you are sampling. A cold board with no weights in memory and idle GPU clocks is a real situation she will meet, at breakfast, after a quiet night. It is a different situation from the eleventh question in a conversation, and one number cannot describe both. Discarding the warm-up is not tidying the data; it is declaring which of the two you are measuring. If the cold case is the one you care about, throw away runs 2 through 6 instead and report the first, which is the honest benchmark of a board that unloads its model after five minutes.

The last stage generalizes past this board entirely. Improving one stage of a pipeline improves the whole pipeline by a factor bounded by that stage's share of the total, and the bound is unforgiving: a stage worth 61 percent of the turn, made instant, still leaves 39 percent standing. That arithmetic is why the table in stage 6 exists, and why a benchmark that reports only the model is a benchmark of the wrong thing. Before you buy hardware for a bottleneck, measure the share of the total that the bottleneck holds.

⚠ Worked failure — a mean of 18.8 from a board that averages 18.4

You start the benchmark over SSH while Ollama on the Jetson is being restarted by a systemd timer you had forgotten about. Three requests are refused mid-flight. The script keeps going, exactly as written, and prints a summary:

$ ssh glados-jetson python3 benchmark.py
  run 1/6  warm-up    15.9 t/s   7205.0 ms
  run 2/6  measured  FAILED: <urlopen error [Errno 104] Connection reset by peer>
  run 3/6  measured  FAILED: <urlopen error [Errno 104] Connection reset by peer>
  run 4/6  measured  FAILED: <urlopen error [Errno 104] Connection reset by peer>
  run 5/6  measured   18.6 t/s   1845.0 ms
  run 6/6  measured   18.9 t/s   1715.0 ms
  mean 18.8 t/s over 6 runs

Two things on that screen are false and one of them is subtle. The easy one first: "over 6 runs" is NUM_RUNS, a constant, printed as though it described the data. Three of those six produced nothing. The mean behind it is an average of two values, and had all six failed, sum(values) / len(values) would have raised on an empty list, which at least announces itself.

The subtle one is why 18.8 is higher than any five-run mean this board has produced. The slice is values[WARMUP_RUNS:], and it drops the first element of the list, on the assumption that the list index and the run number are the same thing. They are, until a run fails. Here the failures fell after the warm-up, so the slice dropped the correct value by luck and the summary is merely thin. Move the failures earlier and the luck reverses: if run 1 had been the one refused, the slice would have discarded a good warm run and kept the cold one, and the reported mean would have been dragged down by the measurement the warm-up rule exists to remove.

Both bugs come from the same confusion, and chapter 49 named the distinction that resolves it. How many runs were attempted, how many failed, how many were discarded as warm-ups and how many were kept are counters: they count events that happened. A mean tokens-a-second is a gauge: a value that describes a state. Printing a constant where a counter belongs is how "6 runs" ends up standing over an average of two. So the run records carry the flag, and the counts are counted:

# labs/benchmark.py — the repair
MIN_KEPT = 3


def run_series(n: int = NUM_RUNS) -> list[dict]:
    runs: list[dict] = []
    for i in range(1, n + 1):
        warmup = i <= WARMUP_RUNS
        try:
            r = measure(PROMPT)
        except Exception as exc:
            print(f"  run {i}/{n}  {'warm-up' if warmup else 'measured':8}  FAILED: {exc}")
            continue
        runs.append({**r, "warmup": warmup})     # the flag, not the position
        print(f"  run {i}/{n}  {'warm-up' if warmup else 'measured':8}  "
              f"{r['decode_tps']:5.1f} t/s{r['stage_ms']:9.1f} ms")
    return runs


def tally(runs: list[dict], attempted: int) -> dict[str, int]:
    """Counters. Each one counts something that happened."""
    kept = [r for r in runs if not r["warmup"]]
    return {
        "runs_attempted": attempted,
        "runs_failed": attempted - len(runs),
        "runs_discarded": len(runs) - len(kept),
        "runs_kept": len(kept),
    }


def measured(runs: list[dict], key: str) -> list[float]:
    """The values that count as data, chosen by flag and never by position."""
    return [r[key] for r in runs if not r["warmup"]]


if __name__ == "__main__":
    runs = run_series()
    counts = tally(runs, NUM_RUNS)
    if counts["runs_kept"] < MIN_KEPT:
        raise RuntimeError(
            f"{counts['runs_kept']} measured runs of {counts['runs_attempted']} attempted "
            f"({counts['runs_failed']} failed); {MIN_KEPT} is the floor for a mean")
    print(f"  wrote {save(build_record(runs, counts))}")
$ ssh glados-jetson python3 benchmark.py
  run 1/6  warm-up    15.9 t/s   7205.0 ms
  run 2/6  measured  FAILED: <urlopen error [Errno 104] Connection reset by peer>
  run 3/6  measured  FAILED: <urlopen error [Errno 104] Connection reset by peer>
  run 4/6  measured  FAILED: <urlopen error [Errno 104] Connection reset by peer>
  run 5/6  measured   18.6 t/s   1845.0 ms
  run 6/6  measured   18.9 t/s   1715.0 ms
Traceback (most recent call last):
  File "benchmark.py", line 118, in <module>
    raise RuntimeError(
RuntimeError: 2 measured runs of 6 attempted (3 failed); 3 is the floor for a mean

MIN_KEPT is the same instinct chapter 61 applied when it printed thin (14 samples) instead of a confident percentile. Three is a low floor and a defensible one: below it, a mean and a spread describe the accident of which requests survived. The counters then replace "runs": NUM_RUNS in the saved record with runs_attempted, runs_failed, runs_discarded and runs_kept, so the file says 5 where five values went into the mean, and a comparison run next spring can tell a clean benchmark from a salvaged one without asking you.

Checkpoint, and a header nothing has touched yet

✓ Checkpoint — what you can now do
  • I can say what the first run of a benchmark measures that the fifth does not, and name both reasons it comes back slow.
  • Given a mean of 18.4 tokens a second, I can name the two further numbers I need before I will believe the board is not throttling.
  • I can explain why the saved record keeps the power mode, the prompt length and the raw per-run values, and what question each one answers later.
  • I can take a measured stage time, put it into a per-stage budget, and say what the whole turn does in response.
  • I know why a 6.8x faster model produced a 1.5x faster conversation, and where the next second has to come from.
  • Given a summary line reading "mean 18.8 t/s over 6 runs", I can name two separate ways it may be lying.
⚡ Exercises — try first, then reveal
Exercise 1 — put a number on the wandering. Add a standard deviation to summarize and use it to tell a steady board from a throttling one when both report a similar mean.

The standard library reduces the whole thing to one import. pstdev treats the runs you have as the entire population, which is what they are:

import statistics


def summarize(values: list[float]) -> dict[str, float]:
    return {
        "mean": round(statistics.mean(values), 1),
        "min": min(values),
        "max": max(values),
        "stdev": round(statistics.pstdev(values), 2),
    }


steady = [18.7, 19.2, 18.5, 18.1, 17.5]        # the five measured runs from stage 2
throttled = [18.6, 18.4, 14.2, 11.9, 12.1]     # the same board in a sealed box

for name, runs in (("steady", steady), ("throttled", throttled)):
    print(f"{name:<10}{summarize(runs)}")
$ uv run python -m labs.spread   # measured on the bench — yours will vary
steady    {'mean': 18.4, 'min': 17.5, 'max': 19.2, 'stdev': 0.57}
throttled {'mean': 15.0, 'min': 11.9, 'max': 18.6, 'stdev': 2.94}

A mean of 15.0 is not obviously alarming. A deviation five times the steady board's, with the low values all at the end of the run, is the fingerprint of a board shedding clocks as it heats. Print the runs in order alongside the deviation: a high spread scattered randomly is noise from other processes, and a high spread that only descends is thermal.

Exercise 2 — measure the machine you will actually own. Run the benchmark once in each of the board's power modes and compare the three saved files.

sudo nvpmodel -m 0, -m 1 and -m 2 select the 15 W, 7 W and 25 W MAXN SUPER modes on this board; confirm each with sudo nvpmodel -q before starting, edit POWER_MODE to match, and let the board sit for a minute after each switch so the clocks settle. Three files land in glados/data/benchmarks, and compare_boards.py already prints them in order because it sorts on the timestamp. Group by power_mode instead of by board and the output answers a question the spec sheet cannot: what the extra ten watts buy in tokens, and whether the enclosure you plan to build can afford them.

Exercise 3 (stretch) — benchmark the model that fits but cannot keep up. Pull llama3.1:8b, benchmark it with the same script, and put its stage time into the turn table.

Chapter 75 worked out that the 8B weights fit this board's pool with 1.2 GB spare, and estimated the rate from arithmetic. Replace the estimate with a measurement: change MODEL, run six, and read both columns.

$ ssh glados-jetson python3 benchmark.py   # measured on the bench — yours will vary
  decode_tps
    warm-up out   {'mean': 7.4, 'min': 7.1, 'max': 7.6}
  stage_ms
    warm-up out   {'mean': 4288.0, 'min': 4102.0, 'max': 4471.0}
$ uv run python -m labs.turn_budget   # after with_llm(MEASURED_P95_MS, 4471.0)
stage   before ms  after ms  ceiling  verdict
stt        1042.8    1042.8     1500  ok
llm        4270.5    4471.0     3000  OVER by 1471.0
tts         512.4     512.4      800  ok
arm        1180.6    1180.6      600  OVER by 580.6
turn       7006.3    7206.8     5900  OVER by 1306.8
model 6.8x faster, turn 1.0x faster

The bigger model loads, answers well, and puts the turn back where the Pi had it. Weights that fit are a capacity question and a turn that finishes in time is a bandwidth question, and this board answers the two differently. Chapter 83 takes the same benchmark to a shelf of models and asks which of them she can afford to think with.

Her brain is measured, dated and on disk, and the record says the board earned its money. What it also says is that the second half of the turn never left the Pi. Before any of that can move, one physical question is still open: the 40-pin header on this board looks identical to the one the wiring loom was built against, and the chip behind it is not the same chip. That header is where the next chapter puts a meter.