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

Running Larger Models

Eight gigabytes, and three claimants

The stack moved in the last chapter and almost nothing about it changed: one platform fingerprint, one override table, four values, and the same voice loop running on the Jetson that ran on the Pi. She is still thinking with llama3.2:3b. That model was chosen back in volume 1 for a specific and now expired reason. A Raspberry Pi with 4 GB of memory had 2.6 GB free once the operating system and her own processes were resident, and 2.0 GB of weights was the largest thing that fit inside it.

The reason is gone and the model is still there. Chapter 75 put the Jetson's free pool at 6.1 GB and showed that an eight-billion-parameter model at 4.9 GB clears that with 1.2 GB to spare, so the obvious move is to pull the bigger model and enjoy the better answers. Chapter 80 already measured what the obvious move costs: 7.4 tokens a second against the 3B model's 18.4, on the same board, in the same power mode, on the same afternoon.

Both numbers are true and they point in opposite directions. More parameters is a claim about the quality of her answers. Fewer bytes is a claim about how long the kitchen stays silent while she produces one. Choosing between them by reading model names is guessing, because neither a name nor a parameter count appears anywhere in the arithmetic that governs the outcome.

There is also a third claimant on the pool that the fit table in chapter 75 did not count. A loaded model is not only its weights. It also holds a cache of everything said so far in the conversation, and that cache is sized by the context window, not by the model's parameter count. On a board where the operating system, the speech models and the language model all draw from one pool, an uncounted gigabyte is the difference between a machine that runs for a month and one that gets its language model killed halfway through a sentence.

So the shelf of candidates goes into a file before anything is downloaded. Every candidate is one row carrying its bytes and its tokens per second, and a model becomes eligible only when both numbers clear budgets that were written down before the menu was drawn.

◆ Note — where this chapter's numbers come from

The same Orin Nano Super: JetPack 6.2, NVMe root, 25 W MAXN SUPER, reached over SSH. The pulled column is what ollama list reports on that board after each model was downloaded. The layer and head counts are each model's published architecture; ollama show <model> prints the summary Ollama holds for a model without loading it. The two decode rates labelled measured are the means saved by the benchmark in chapter 80, read back out of the archive it wrote. Your board will report different rates and, if the registry has re-quantised a tag since this was written, slightly different file sizes. Every calculation below runs on your laptop; only the last stage touches the Jetson.

Counting the bytes before downloading them

▣ Build · stage 1 — the candidates, and the bits each weight is stored in
# labs/model_catalog.py — the candidates, written as numbers instead of adjectives
BITS_PER_WEIGHT = {"q4_K_M": 4.6, "q5_K_M": 5.6, "q8_0": 8.5, "f16": 16.0}

MODELS: list[dict] = [
    {"name": "qwen2.5:0.5b", "params_b": 0.5, "quant": "q4_K_M", "disk_gb": 0.4,
     "layers": 24, "kv_heads": 2, "head_dim": 64},
    {"name": "gemma2:2b", "params_b": 2.6, "quant": "q4_K_M", "disk_gb": 1.6,
     "layers": 26, "kv_heads": 4, "head_dim": 256},
    {"name": "llama3.2:3b", "params_b": 3.2, "quant": "q4_K_M", "disk_gb": 2.0,
     "layers": 28, "kv_heads": 8, "head_dim": 128},
    {"name": "llama3.1:8b", "params_b": 8.0, "quant": "q4_K_M", "disk_gb": 4.9,
     "layers": 32, "kv_heads": 8, "head_dim": 128},
    {"name": "qwen2.5:14b", "params_b": 14.8, "quant": "q4_K_M", "disk_gb": 9.0,
     "layers": 48, "kv_heads": 8, "head_dim": 128},
]


def weight_gb(params_b: float, quant: str) -> float:
    """Billions of parameters at so many bits each, in gigabytes of file."""
    return params_b * 1e9 * BITS_PER_WEIGHT[quant] / 8 / 1e9


if __name__ == "__main__":
    print(f"{'model':<14}{'params':>8}{'quant':>9}{'predicted':>11}{'pulled':>9}")
    for m in MODELS:
        print(f"{m['name']:<14}{m['params_b']:>7.1f}B{m['quant']:>9}"
              f"{weight_gb(m['params_b'], m['quant']):>10.1f}G{m['disk_gb']:>8.1f}G")
$ uv run python -m labs.model_catalog
model           params    quant  predicted   pulled
qwen2.5:0.5b      0.5B   q4_K_M       0.3G     0.4G
gemma2:2b         2.6B   q4_K_M       1.5G     1.6G
llama3.2:3b       3.2B   q4_K_M       1.8G     2.0G
llama3.1:8b       8.0B   q4_K_M       4.6G     4.9G
qwen2.5:14b      14.8B   q4_K_M       8.5G     9.0G

That one function is the bridge between the number on the box and the number that matters. A parameter is a stored value, and how many bytes it occupies is a separate decision made when the file was built. Ollama serves 4-bit K-quantised weights by default, and the constant here says 4.6 rather than 4.0 because the K schemes are mixed: most of the network is packed into 4-bit blocks, while the embedding and output tensors, the ones the answer quality is most sensitive to, are kept at higher precision. Averaged over the whole file that lands near 4.6 bits per weight.

Read the two right-hand columns as a check on the arithmetic, not as a table of facts. From 2.6B upwards the prediction sits within about ten percent of the real file and is always the smaller of the two, because a downloaded model also carries a tokeniser, a vocabulary and metadata. On the 0.5B row the prediction is out by a third: the fixed tensors are a large fraction of a tiny model, so a flat rate per weight has nothing left to average over. Trust the pulled figure for anything on disk, and use weight_gb to sketch a model you have not downloaded yet.

▣ Build · stage 2 — the cache nobody budgets for, and a reserve that is not spendable
# labs/model_catalog.py — continued
CONTEXT_TOKENS = 4096      # the window Ollama opens by default on this board
KV_BYTES = 2               # the cache is kept in float16
POOL_GB = 8.0
RESIDENT_GB = 1.9          # the OS and her own stack, measured with the loop idle
RESERVE_GB = 1.0           # held back so a burst never meets the OOM killer
HEADROOM_GB = POOL_GB - RESIDENT_GB
BUDGET_GB = HEADROOM_GB - RESERVE_GB


def kv_cache_gb(model: dict, context_tokens: int = CONTEXT_TOKENS) -> float:
    """One key and one value per head, per layer, per token in the window."""
    per_token = 2 * model["layers"] * model["kv_heads"] * model["head_dim"] * KV_BYTES
    return per_token * context_tokens / 1e9


def footprint_gb(model: dict, context_tokens: int = CONTEXT_TOKENS) -> float:
    return model["disk_gb"] + kv_cache_gb(model, context_tokens)


def fit(model: dict, context_tokens: int = CONTEXT_TOKENS) -> tuple[str, float]:
    """Where this model lands against the budget, and by how much."""
    used = footprint_gb(model, context_tokens)
    if used <= BUDGET_GB:
        return "fits", BUDGET_GB - used
    if used <= HEADROOM_GB:
        return "tight", HEADROOM_GB - used
    return "over", used - HEADROOM_GB


VERDICTS = {"fits": "fits, {:.2f} GB under budget",
            "tight": "tight, {:.2f} GB left above the OS",
            "over": "over by {:.2f} GB"}

if __name__ == "__main__":
    print(f"budget {BUDGET_GB:.1f} GB: an {POOL_GB:.1f} GB pool, "
          f"{RESIDENT_GB:.1f} GB resident, {RESERVE_GB:.1f} GB held back")
    print(f"{'model':<14}{'weights':>9}{'kv@' + str(CONTEXT_TOKENS):>9}{'total':>8}   verdict")
    for m in MODELS:
        verdict, margin = fit(m)
        print(f"{m['name']:<14}{m['disk_gb']:>9.1f}{kv_cache_gb(m):>9.2f}"
              f"{footprint_gb(m):>8.2f}   {VERDICTS[verdict].format(margin)}")
$ uv run python -m labs.model_catalog
budget 5.1 GB: an 8.0 GB pool, 1.9 GB resident, 1.0 GB held back
model           weights  kv@4096   total   verdict
qwen2.5:0.5b        0.4     0.05    0.45   fits, 4.65 GB under budget
gemma2:2b           1.6     0.44    2.04   fits, 3.06 GB under budget
llama3.2:3b         2.0     0.47    2.47   fits, 2.63 GB under budget
llama3.1:8b         4.9     0.54    5.44   tight, 0.66 GB left above the OS
qwen2.5:14b         9.0     0.81    9.81   over by 3.71 GB

The cache calculation is worth walking through once, because every term in it is something you can look up. Attention works by comparing the token being generated against a key and a value for every token before it, and recomputing those from scratch each time would make a long conversation quadratically slow, so they are computed once and kept. Two tensors, per attention head that stores them, per layer, per token in the window, at two bytes each. For llama3.2:3b that is 2 × 28 × 8 × 128 × 2, or 114,688 bytes for every token the window can hold, and a full 4,096-token window therefore costs 0.47 GB on top of the weights.

Notice that the cache column does not track the weights column. gemma2:2b is a fifth smaller on disk than llama3.2:3b and its cache is almost the same size, because it stores 256-wide heads where the Llama model stores 128-wide ones. Nothing on a download page says so.

The reserve is the other correction to chapter 75. That chapter asked whether the weights fit and answered yes with 1.2 GB spare. Count the window, hold a gigabyte back for the transcription and speech models that both spike during a turn, and the 8B model has 0.66 GB standing between it and the kernel's out-of-memory killer, which does not negotiate or warn. The three verdicts exist so that condition has a name instead of being a positive margin nobody looked at twice.

The same 8 GB pool divided by two candidate models Two horizontal bars drawn to the same 8 GB scale. In both, the operating system and her own stack take the first 1.9 GB. With llama3.2:3b the weights take 2.0 GB and the key-value cache 0.47 GB, leaving 3.6 GB free. With llama3.1:8b the weights take 4.9 GB and the cache 0.54 GB, leaving 0.66 GB, so the bar crosses into the one-gigabyte reserve marked near the right-hand end. ONE POOL, TWO CANDIDATES, THE SAME OS UNDERNEATH 0 8 GB llama3.2:3b 2.0 3.63 GB free llama3.1:8b 4.9 1.9 1.0 GB reserve OS and her stack weights key-value cache at a 4,096-token window
Figure 83.1 — Both bars are the same eight gigabytes. The green sliver is the part chapter 75 did not count, and on the lower bar it is what pushes the model across the reserve line.

Two budgets on one table

▣ Build · stage 3 — rates, with the provenance of every one of them printed
# labs/model_rates.py — the other half of the trade: what each row costs in time
import glob
import json

from labs.model_catalog import BUDGET_GB, MODELS, fit, footprint_gb

BANDWIDTH_GB_S = 102.0          # this board's peak, off the spec sheet
EFFICIENCY = 0.36               # measured decode over ceiling, on this board
FITTED_BAND_GB = (2.0, 4.9)     # the two weight sizes that efficiency came from
BOARD = "jetson-orin-nano-super"
RESULTS_DIR = "glados/data/benchmarks"

REPLY_TOKENS = 27               # decode tokens implied by the benchmark's stage mean
STAGE_CEILING_MS = 3000         # the llm slice of the turn budget
PREFILL_ALLOWANCE_MS = 350      # 96 prompt tokens at the measured 287.67 t/s, rounded up
FLOOR_TPS = REPLY_TOKENS / ((STAGE_CEILING_MS - PREFILL_ALLOWANCE_MS) / 1000)


def ceiling_tps(disk_gb: float) -> float:
    """Every weight is read once per token: bandwidth over weight bytes."""
    return BANDWIDTH_GB_S / disk_gb


def measured_tps(pattern: str = f"{RESULTS_DIR}/*.json") -> dict[str, float]:
    """Decode means for this board out of the benchmark archive, keyed by model."""
    rates: dict[str, float] = {}
    for path in sorted(glob.glob(pattern)):
        with open(path) as handle:
            record = json.load(handle)
        if record["board"] == BOARD:
            rates[record["model"]] = record["decode_tps"]["mean"]
    return rates


def rate_of(model: dict, rates: dict[str, float]) -> tuple[float, str]:
    """The best rate available for this model, and where it came from."""
    if model["name"] in rates:
        return rates[model["name"]], "measured"
    low, high = FITTED_BAND_GB
    estimate = ceiling_tps(model["disk_gb"]) * EFFICIENCY
    if low / 2 <= model["disk_gb"] <= high * 2:
        return estimate, "estimate"
    return estimate, "extrapolated"

Three words in the last column of the finished table, and they are not decoration. A measured rate came off this board and is in a file with a date on it. An estimate is the ceiling from chapter 75, bandwidth divided by weight bytes, multiplied by the 0.36 of peak this board actually reached. An extrapolated rate is the same arithmetic applied outside the range it was fitted in, which is a different kind of claim and deserves a different word. The band rule is the ordinary one for a fitted constant: trust it within a factor of two of the data it came from, and stop there.

▣ Build · stage 4 — a floor taken from the turn budget, and the dashboard
# labs/model_rates.py — continued
def speed_of(model: dict, rates: dict[str, float]) -> str:
    """One word for the rate column: a floor comparison, or an admission."""
    tps, source = rate_of(model, rates)
    if source == "extrapolated":
        return "unknown"
    return "ok" if tps >= FLOOR_TPS else "slow"


def recommend(rates: dict[str, float]) -> dict:
    """The most capable row that clears both budgets, capability read as parameters."""
    ok = [m for m in MODELS
          if fit(m)[0] == "fits" and speed_of(m, rates) == "ok"]
    return max(ok, key=lambda m: m["params_b"])


def dashboard() -> None:
    rates = measured_tps()
    print("=" * 70)
    print(f"  Model budget {BUDGET_GB:.1f} GB   floor {FLOOR_TPS:.1f} t/s   "
          f"peak {BANDWIDTH_GB_S:.0f} GB/s")
    print("=" * 70)
    print(f"  {'model':<14}{'total GB':>9}  {'memory':<8}{'ceiling':>8}{'t/s':>7}  "
          f"{'source':<14}speed")
    for m in MODELS:
        tps, source = rate_of(m, rates)
        print(f"  {m['name']:<14}{footprint_gb(m):>9.2f}  {fit(m)[0]:<8}"
              f"{ceiling_tps(m['disk_gb']):>8.1f}{tps:>7.1f}  {source:<14}"
              f"{speed_of(m, rates)}")
    print("-" * 70)
    print(f"  recommended: {recommend(rates)['name']}")
    print("=" * 70)


if __name__ == "__main__":
    dashboard()
$ uv run python -m labs.model_rates   # two rates from the bench archive — yours will vary
======================================================================
  Model budget 5.1 GB   floor 10.2 t/s   peak 102 GB/s
======================================================================
  model          total GB  memory   ceiling    t/s  source        speed
  qwen2.5:0.5b       0.45  fits       255.0   91.8  extrapolated  unknown
  gemma2:2b          2.04  fits        63.8   22.9  estimate      ok
  llama3.2:3b        2.47  fits        51.0   18.4  measured      ok
  llama3.1:8b        5.44  tight       20.8    7.4  measured      slow
  qwen2.5:14b        9.81  over        11.3    4.1  estimate      slow
----------------------------------------------------------------------
  recommended: llama3.2:3b
======================================================================

The floor is the one number here that could have been an opinion and is not. Every term in it was measured somewhere earlier in the book. Chapter 61 gave the language-model stage 3,000 milliseconds of the turn. The benchmark in chapter 80 timed that stage at a mean of 1,807.6 milliseconds while decoding at 18.4 tokens a second, and its 96-token prompt costs 334 milliseconds at the prefill rate this board measured, so the decoding part was 1,473.6 milliseconds and her typical reply is 27 tokens long. Twenty-seven tokens in the 2.65 seconds left after prefill is 10.2 tokens a second. Change the turn budget or the length of her replies and the floor moves on its own.

Two of those columns deserve an argument. The 91.8 in the top row is arithmetic, not a prediction: at that rate a token would take eleven milliseconds, and the fixed costs per token, sampling, the HTTP round trip, Python itself, stop being negligible long before then. The word unknown beside it is the honest output, and it keeps that row out of the recommendation until somebody benchmarks it. And llama3.1:8b is the row this whole chapter exists for: it loads, it answers well, it is the most capable thing on the board, and at 7.4 tokens a second it puts the language stage 1,471 milliseconds over its budget. Capability and latency are separate purchases and this board cannot afford both.

The recommendation falls out of the data with no branching in it: among the rows whose memory verdict is fits and whose speed is ok, take the one with the most parameters. It picks the model she is already running. That result is worth more now than it was in volume 1, because the reason has changed from "it was all that fit" to a floor, a budget and two measurements.

A menu that can refuse

▣ Build · stage 5 — the picker, and every reason it might say no
# labs/pull_model.py — the menu, and the checks standing between it and a download
import subprocess

from labs.model_catalog import MODELS, VERDICTS, fit
from labs.model_rates import FLOOR_TPS, dashboard, measured_tps, rate_of


def check_choice(raw: str, rates: dict[str, float]) -> tuple[int, list[str]]:
    """Validate what the reader typed, returning every reason to refuse it."""
    try:
        number = int(raw)
    except ValueError:
        return 0, [f"{raw!r} is not a number"]
    if not 1 <= number <= len(MODELS):
        return 0, [f"{number} is not on the menu (1 to {len(MODELS)})"]

    model, reasons = MODELS[number - 1], []
    verdict, margin = fit(model)
    if verdict == "over":
        reasons.append(f"{model['name']} is {VERDICTS['over'].format(margin)}")
    tps, source = rate_of(model, rates)
    if source == "extrapolated":
        reasons.append(f"{model['name']} has no rate this board has measured")
    elif tps < FLOOR_TPS:
        reasons.append(f"{model['name']} decodes at {tps:.1f} t/s, under the "
                       f"{FLOOR_TPS:.1f} t/s floor")
    return number, reasons


def main() -> None:
    rates = measured_tps()
    dashboard()
    for index, model in enumerate(MODELS, 1):
        print(f"  {index}. {model['name']}")
    try:
        raw = input("\n  pull which model? (q to quit) ").strip()
    except (EOFError, KeyboardInterrupt):
        print("\n  nothing pulled.")
        return
    if raw.lower() == "q":
        return

    number, reasons = check_choice(raw, rates)
    for reason in reasons:
        print(f"  refused: {reason}")
    if reasons:
        return
    name = MODELS[number - 1]["name"]
    print(f"\n  pulling {name}")
    subprocess.run(["ollama", "pull", name], check=True)


if __name__ == "__main__":
    main()
$ ssh -t glados-jetson uv run python -m labs.pull_model
  1. qwen2.5:0.5b
  2. gemma2:2b
  3. llama3.2:3b
  4. llama3.1:8b
  5. qwen2.5:14b

  pull which model? (q to quit) 5
  refused: qwen2.5:14b is over by 3.71 GB
  refused: qwen2.5:14b decodes at 4.1 t/s, under the 10.2 t/s floor

Both reasons print. That is the same decision the config validator made in chapter 34, for the same reason: a function that stops at the first problem makes the reader fix one thing, run again, and discover the next one, and a function that returns a list tells them everything wrong with the choice in a single pass. An empty list is the only unambiguous permission to download nine gigabytes.

subprocess.run gets the list form, so the model name is handed to the program as one argument and never reaches a shell that could split it on the colon. check=True is the other half: a pull that fails halfway through, and they do, on a board whose Wi-Fi is a USB adapter, raises instead of letting the script print a success line over a partial download.

◆ Note — pulling a different quantisation, and the thing no table measures

A tag suffix picks the quantisation directly: ollama pull llama3.1:8b-instruct-q5_K_M fetches the same model stored at about 5.6 bits per weight instead of 4.6. Everything in this chapter's arithmetic moves with it, in both directions at once, which is the subject of the next section. What none of it measures is whether her answers got better. Quality cannot be put on this table by arithmetic. Keep a short list of the questions you actually ask her, run it against two candidates, and read the replies yourself. That list answers the one question no leaderboard can ask: is this model good at being GLaDOS in your kitchen.

Why this works: one lever on both budgets

Generating one token means running the whole network once, and every weight gets read from memory exactly once to do it. That single fact is why the bytes of a model are two quantities at the same time. They are a capacity cost, because those bytes have to sit somewhere in the pool, and they are a time cost, because those same bytes have to travel from memory to the arithmetic units before a word can appear. Halve the bits per weight and both halve together: the footprint drops, and the ceiling of bandwidth over weight bytes doubles. Quantisation is the only lever on this board that moves both budgets in the direction you want, and parameter count never enters either equation except by way of the bytes it turns into.

The two budgets behave differently once you push on them, though, and the table has two verdict columns for that reason. Capacity is a threshold. A model either loads or it does not, and being 40 megabytes over is exactly as fatal as being four gigabytes over. Speed is continuous: 7.4 tokens a second is not a failure, it is a slower conversation, and whether that is acceptable depends on a budget you wrote down for the whole turn. The key-value cache belongs to the capacity side and grows with how much of the conversation you keep, not with the model, so a decision about her memory is also a decision about which models remain eligible.

None of this is particular to a Jetson. Any machine answering one person is memory-bound during decode, because a single conversation reuses no weight: each one is fetched, multiplied once, and dropped. A datacentre escapes that by batching hundreds of users through one set of loaded weights, and that escape is not available to a box in a kitchen serving one household. So the purchase is always bytes. Free bytes decide what will load and bytes per second decide how fast it answers, and the third quantity, whether the answers are any good, is measured by reading them.

⚠ Worked failure — the menu answered 0 by downloading the largest model on it

Before check_choice existed, the picker did the obvious thing with the number it was handed:

# labs/pull_model.py — the first version, with the menu number trusted
choice = int(input("  pull which model? "))
model = MODELS[choice - 1]                 # trusts the number the reader typed
print(f"  pulling {model['name']}")
subprocess.run(["ollama", "pull", model["name"]], check=True)
$ ssh -t glados-jetson uv run python labs/pull_model.py
  pull which model? 0
  pulling qwen2.5:14b
pulling manifest
pulling 5e0a9b4ad0d3: 100% ▕██████████████████▏ 9.0 GB
verifying sha256 digest
writing manifest
success

Nothing raised. Nine gigabytes came down the USB Wi-Fi adapter, the script reported success, and the model it downloaded is the one row on the table that cannot run on this board at all. The next request confirmed that from the other end:

$ ssh glados-jetson ollama run qwen2.5:14b "Say exactly: facility online"
Error: model requires more system memory (9.8 GiB) than is available (6.1 GiB)

Work backwards from the wrong name. The menu offered 1 to 5, the reader typed 0, and 0 - 1 is -1. Python treats a negative index as counting from the end of the list, so MODELS[-1] is a perfectly legal expression that returns the last candidate. There was no bug in the indexing at all. The bug is that a number outside the menu was converted into an index before anyone asked whether it was on the menu.

Typing 7 instead does raise, and that is the part that makes this expensive, because it teaches the wrong lesson:

$ ssh -t glados-jetson uv run python labs/pull_model.py
  pull which model? 7
Traceback (most recent call last):
  File "/home/glados/labs/pull_model.py", line 5, in <module>
    model = MODELS[choice - 1]                 # trusts the number the reader typed
            ~~~~~~^^^^^^^^^^^^
IndexError: list index out of range

One out-of-range number crashes loudly and the other quietly does the most expensive thing on the menu, from the same missing check. Catching IndexError would have handled 7 and left 0 exactly as broken. The guard has to run on the number the human typed, in the units the menu is printed in, before any arithmetic converts it into something the list will accept:

$ ssh -t glados-jetson uv run python -m labs.pull_model
  pull which model? (q to quit) 0
  refused: 0 is not on the menu (1 to 5)

One more thing that run makes clear. Being on the menu was never sufficient: option 4 is a real row, in range, already downloaded, and still refused, on grounds the range check knows nothing about. Validation of a menu number and validation of a choice are two different jobs, and both of them belong before the download starts.

Checkpoint, and a model sitting on the wrong disk

✓ Checkpoint — what you can now do
  • Given a parameter count and a quantisation tag, I can predict a model's file size to within about ten percent, and say why the prediction fails on very small models.
  • I can compute a key-value cache from a model's layers, key-value heads, head width and context window, and explain why two models of similar size can differ by a factor of two in that cost.
  • I can state the difference between a model that fits, a model that fits only by spending the reserve, and a model that will be killed by the kernel.
  • I can derive a tokens-per-second floor from a turn budget and a reply length recovered from a stage time, instead of picking a number that sounds reasonable.
  • Handed a rate, I can say whether it was measured, fitted or extrapolated, and why 91.8 tokens a second on this board is arithmetic and not a forecast.
  • I know why a menu that rejects 7 with a traceback can still accept 0 and download the wrong model, and where the check that catches both belongs.
⚡ Exercises — try first, then reveal
Exercise 1 — add a sixth candidate. Put qwen2.5:7b into the catalog (7.6B parameters, q4_K_M, 4.7 GB pulled, 28 layers, 4 key-value heads, 128-wide heads) and run both tools without editing a function.
$ uv run python -m labs.model_rates
  model          total GB  memory   ceiling    t/s  source        speed
  qwen2.5:0.5b       0.45  fits       255.0   91.8  extrapolated  unknown
  gemma2:2b          2.04  fits        63.8   22.9  estimate      ok
  llama3.2:3b        2.47  fits        51.0   18.4  measured      ok
  llama3.1:8b        5.44  tight       20.8    7.4  measured      slow
  qwen2.5:7b         4.93  fits        21.7    7.8  estimate      slow
  qwen2.5:14b        9.81  over        11.3    4.1  estimate      slow

One dictionary, no code touched, and the new row lands between the two it belongs between. The interesting column is the cache: this model carries 0.23 GB where llama3.1:8b carries 0.54, because it stores four key-value heads instead of eight while having nearly the same number of weights. That is enough to move it from tight to fits on a board with no room to spare. It is still under the floor, so it changes nothing about the recommendation, and it is a different bargain.

Exercise 2 — price the lever. Take llama3.1:8b at q4_K_M, q5_K_M and q8_0, and print the footprint, the ceiling and the estimated rate for each.

Build each row from weight_gb instead of a pulled size, since the point is to price a download you have not made:

from labs.model_catalog import MODELS, VERDICTS, fit, footprint_gb, weight_gb
from labs.model_rates import EFFICIENCY, ceiling_tps

eight = MODELS[3]
print(f"{'quant':<9}{'weights':>9}{'total':>8}{'ceiling':>9}{'estimate':>10}   verdict")
for quant in ("q4_K_M", "q5_K_M", "q8_0"):
    weights = round(weight_gb(eight["params_b"], quant), 1)
    row = dict(eight, quant=quant, disk_gb=weights)
    verdict, margin = fit(row)
    print(f"{quant:<9}{weights:>9.1f}{footprint_gb(row):>8.2f}{ceiling_tps(weights):>9.1f}"
          f"{ceiling_tps(weights) * EFFICIENCY:>10.1f}   {VERDICTS[verdict].format(margin)}")
$ uv run python -m labs.quant_sweep
quant      weights   total  ceiling  estimate   verdict
q4_K_M         4.6    5.14     22.2       8.0   tight, 0.96 GB left above the OS
q5_K_M         5.6    6.14     18.2       6.6   over by 0.04 GB
q8_0           8.5    9.04     12.0       4.3   over by 2.94 GB

Two things to take from those three lines. The q5 row misses by 40 megabytes, which is the kind of margin that loads fine on a quiet board and gets killed the evening somebody opens a browser on it. And the ceiling column falls almost exactly in proportion to the weights, 22.2 to 18.2 to 12.0, because the ceiling is a division by those same bytes. Every bit you add per weight is paid for twice.

Exercise 3 (stretch) — let her remember more. Raise the context window from 4,096 to 16,384 tokens and report which verdicts change.

Both kv_cache_gb and fit already take the window as an argument, so the sweep needs no new machinery:

$ uv run python -m labs.context_sweep
model           kv@4096  kv@16384   total   verdict
qwen2.5:0.5b       0.05      0.20    0.60   fits, 4.50 GB under budget
gemma2:2b          0.44      1.74    3.34   fits, 1.76 GB under budget
llama3.2:3b        0.47      1.88    3.88   fits, 1.22 GB under budget
llama3.1:8b        0.54      2.15    7.05   over by 0.95 GB
qwen2.5:14b        0.81      3.22   12.22   over by 6.12 GB

The 8B model stops fitting at all, and not one byte of it changed. The cache is linear in the window, so four times the conversation is four times the cache, and on the row with no slack that is decisive. Her history limit and her model size are the same budget viewed from two ends, and setting the window larger than the history she keeps pays for tokens that will never be stored. Try 8,192 and see which rows survive.

The choice is made and it is defensible: the same model, on new grounds, with the rejected candidates and their reasons in a file anyone can rerun. What the picker still does is download several gigabytes into a directory nobody has checked, on a board whose fast NVMe drive and slow boot media are two different devices, while she starts only when somebody logs in over SSH and types a command. The next chapter hands all three of those to the operating system.