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

Ollama on Jetson

One line installs it, and nothing has been proven

Ollama arrives on the Jetson the same way it arrived on your laptop in volume 1: one curl into one shell, a couple of minutes of download, a systemd service registered and started. The installer notices it is on JetPack, creates an ollama system user, puts that user in the render and video groups so it may open the GPU device nodes, and finishes. It almost never fails. That is the problem with it.

A language model does not need a GPU to answer. It needs arithmetic, and the six Arm cores on this board will do that arithmetic quite happily, just slowly. If the CUDA libraries are not visible to the ollama user, or the group membership has not taken effect because the service was already running when the installer added it, Ollama loads the model onto the CPU and says nothing. No error. No warning line in the log you would notice. The model answers, and the answer is correct, and it is the same answer the GPU would have produced.

What differs is the clock, and only the clock. On the Pi this same model produced under three tokens a second, which is why volume 7 had a p95 problem in the first place. This board should be four or five times that. A silent fallback puts you back at Pi speed on hardware you bought specifically to leave Pi speed behind, and the way you find out is that she feels sluggish in the kitchen two weeks later.

So the install log is not evidence and neither is a correct answer. Ollama is finished on this board when a script has divided the tokens it generated by the time it spent generating them, and that number stands beside the same number measured with the GPU deliberately switched off. One reading is a curiosity. Two readings, one of them known-bad, is a measurement with a floor under it.

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

The same Orin Nano Super from the last three chapters: JetPack 6.2, NVMe root, no monitor attached, reached as glados-jetson over SSH. The board is in the 25 W MAXN SUPER power mode and the model is llama3.2:3b, the one she has been thinking with since volume 1. Every token count, duration and rate printed below came off that board on one afternoon. Yours will differ in all of them, and by more than a little: token counts change with the prompt, the sampler, and the model's mood. The commands and the arithmetic are what carry over.

Two witnesses, and neither one is a rate

▣ Build · stage 1 — install, pull, and ask Ollama where it put the model
# on the board, over ssh
curl -fsSL https://ollama.com/install.sh | sh
ollama pull llama3.2:3b
ollama run llama3.2:3b "Say exactly: facility online" > /dev/null
ollama ps
$ ollama ps   # measured on the bench — yours will vary
NAME            ID              SIZE      PROCESSOR    UNTIL
llama3.2:3b     a80c4f17acd5    3.5 GB    100% GPU     4 minutes from now

ollama ps lists the models currently held in memory, and the PROCESSOR column is the first thing anyone points at. Read it precisely: it reports how Ollama placed the model when it loaded it. A model too large for the memory Ollama believes the GPU can have gets split, and this column then says something like 47%/53% CPU/GPU, with the layers that did not fit running on the processor. That is real information and it is still the accused describing its own conduct. The UNTIL column matters later: five minutes after the last request, Ollama unloads the model and the next request pays the load time again.

▣ Build · stage 2 — watch the silicon while it works
# terminal one, on the board
sudo tegrastats --interval 1000

# terminal two, on the board
ollama run llama3.2:3b "Explain what a semiconductor is, in three sentences."
$ sudo tegrastats --interval 1000   # measured on the bench — yours will vary
RAM 3894/7620MB SWAP 0/3810MB CPU [6%@1728,4%@1728,3%@1728,5%@1728,2%@1728,3%@1728] GR3D_FREQ 0% cpu@50.5C soc0@49.1C tj@51.2C VDD_IN 4821mW
RAM 6142/7620MB SWAP 0/3810MB CPU [22%@1728,18%@1728,9%@1728,11%@1728,8%@1728,7%@1728] GR3D_FREQ 61% cpu@52.0C soc0@50.4C tj@52.9C VDD_IN 9942mW
RAM 6148/7620MB SWAP 0/3810MB CPU [9%@1728,7%@1728,5%@1728,6%@1728,4%@1728,5%@1728] GR3D_FREQ 99% cpu@53.4C soc0@51.8C tj@54.1C VDD_IN 13508mW
RAM 6147/7620MB SWAP 0/3810MB CPU [8%@1728,6%@1728,6%@1728,5%@1728,4%@1728,4%@1728] GR3D_FREQ 98% cpu@54.1C soc0@52.2C tj@54.6C VDD_IN 13704mW

tegrastats is the Jetson's own instrument, and on this board it answers a question nvidia-smi will not. The GPU here is on the same die as the CPU and reads the same memory pool, so nvidia-smi has no separate video memory to report and usually prints an empty process table even while inference is running. GR3D_FREQ is the field that counts: the percentage of the last interval the graphics engine spent busy. Idle at 0, climbing to 99 as the tokens come out, with RAM up by about 2.3 GB and VDD_IN, total board power, nearly tripled. Something is loading the GPU.

Something. A percentage sampled once a second tells you the engine was busy; it does not tell you how much of the answer it produced, and it cannot be compared against last month's reading or against the Pi. Neither witness gives you a number with tokens in it. That is the next stage.

The number that settles it

Ollama's HTTP API reports what it did. Volume 1 used it for the text, through the ollama package, and ignored everything else in the reply. Everything else in the reply is what this chapter is for. The script goes on the board and runs there, against 127.0.0.1, because measuring the network between your desk and the board is not the measurement anyone wants. It also runs on the system python3 with no packages installed, for the same reason the headless audit did: her uv workspace does not land on this board until chapter 82, and the check has to work before it.

▣ Build · stage 3 — one generation, and every counter it comes back with
# labs/ollama_gpu_check.py — runs on the board, on the python3 JetPack shipped
import json
import time
import urllib.request

OLLAMA_URL = "http://127.0.0.1:11434/api/generate"
MODEL = "llama3.2:3b"
PROMPT = "In two sentences, what is a GPU good at that a CPU is not?"


def generate(prompt: str, num_gpu: int | None = None, timeout: float = 180.0) -> dict:
    """One non-streamed generation. num_gpu=0 forces every layer onto the CPU."""
    body: dict = {"model": MODEL, "prompt": prompt, "stream": False}
    if num_gpu is not None:
        body["options"] = {"num_gpu": num_gpu}
    request = urllib.request.Request(
        OLLAMA_URL,
        data=json.dumps(body).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(request, timeout=timeout) as reply:
        return json.loads(reply.read().decode("utf-8"))


if __name__ == "__main__":
    started = time.monotonic()
    reply = generate(PROMPT)
    wall = time.monotonic() - started
    for key in ("total_duration", "load_duration", "prompt_eval_count",
                "prompt_eval_duration", "eval_count", "eval_duration"):
        print(f"{key:<21}{reply.get(key)}")
    print(f"{'wall clock (s)':<21}{wall:.2f}")
$ ssh glados-jetson python3 ollama_gpu_check.py   # measured on the bench — yours will vary
total_duration       7810722000
load_duration        4912000000
prompt_eval_count    21
prompt_eval_duration 73000000
eval_count           54
eval_duration        2814722000
wall clock (s)       7.83

"stream": False is the reason those counters exist in one place. A streamed reply arrives as one JSON object per token and only the last object, the one with done: true, carries the totals; with streaming off you get a single object with the finished sums in it. The ten-digit numbers are nanoseconds, which is the unit Go's duration type prints and Ollama is written in Go. Three of them describe different work and the fourth contains the other three: 4.9 seconds reading 3.5 GB of weights off the NVMe drive, 0.073 seconds reading the prompt, 2.8 seconds writing the answer, and 7.81 seconds of total_duration around all of it. The wall clock agrees to within the time urllib spent parsing.

▣ Build · stage 4 — turn counters and durations into rates
NS_PER_S = 1_000_000_000


def rate(count: int, duration_ns: int) -> float:
    """Tokens per second, guarding a zero or missing duration."""
    if not count or not duration_ns:
        return 0.0
    return count / (duration_ns / NS_PER_S)


def phases(reply: dict) -> dict[str, float]:
    """The four numbers that describe one generation."""
    return {
        "load (s)": reply.get("load_duration", 0) / NS_PER_S,
        "prefill (t/s)": rate(reply.get("prompt_eval_count", 0),
                              reply.get("prompt_eval_duration", 0)),
        "decode (t/s)": rate(reply.get("eval_count", 0),
                             reply.get("eval_duration", 0)),
        "total (s)": reply.get("total_duration", 0) / NS_PER_S,
    }


if __name__ == "__main__":
    for name, value in phases(generate(PROMPT)).items():
        print(f"{name:<15}{value:>8.2f}")
$ ssh glados-jetson python3 ollama_gpu_check.py   # measured on the bench — yours will vary
load (s)           4.91
prefill (t/s)    287.67
decode (t/s)      19.18
total (s)          7.81

Two rates, and they differ by a factor of fifteen on the same hardware in the same second. Prefill is the model reading your prompt: all 21 tokens are known in advance, so the arithmetic for all of them runs at once, and a GPU eats that for breakfast. Decode is the model writing its answer, one token at a time, each one an input to the next, and no amount of parallel hardware makes token 30 available before token 29 exists. Decode is the rate a person waiting for a sentence actually feels, and it is the only one of the two you should ever quote as "tokens per second".

Which counter divides which duration inside one generation A bar representing total_duration of 7.81 seconds is divided into three parts: load_duration of 4.91 seconds with no token counter attached, a very thin prompt_eval_duration of 0.07 seconds carrying 21 prompt tokens, and eval_duration of 2.81 seconds carrying 54 generated tokens. Only the third pairing yields the decode rate of 19.18 tokens per second. ONE REPLY · FOUR DURATIONS · ONE OF THEM IS THE ANSWER total_duration 7.81 s load_duration 4.91 s eval_duration 2.81 s no token counter 21 prompt tokens 54 generated tokens 54 / 2.81 = 19.18 t/s disk speed, not model speed
Figure 79.1 — The thin bar is prompt_eval_duration at 0.07 seconds, drawn to scale. Divide the generated tokens by the whole bar instead of by the green one and a healthy board reports 6.9 t/s, because most of that bar is a disk read.
▣ Build · stage 5 — make the fallback happen on purpose
if __name__ == "__main__":
    accelerated = phases(generate(PROMPT))["decode (t/s)"]
    forced_cpu = phases(generate(PROMPT, num_gpu=0))["decode (t/s)"]
    print(f"decode, as installed : {accelerated:6.2f} t/s")
    print(f"decode, num_gpu=0    : {forced_cpu:6.2f} t/s")
    print(f"the GPU is worth     : {accelerated / forced_cpu:6.2f}x")
$ ssh glados-jetson python3 ollama_gpu_check.py   # measured on the bench — yours will vary
decode, as installed :  19.18 t/s
decode, num_gpu=0    :   4.58 t/s
the GPU is worth     :   4.19x

num_gpu is the number of model layers Ollama offloads to the GPU, and zero means none of them. This is the whole chapter in one call: the same board, the same model, the same prompt, run once as installed and once with the GPU forbidden. If the two numbers had come back equal, the installed configuration was already running on the CPU and the install log was lying by omission. They came back a factor of four apart, so the GPU is carrying the generation. Expect both calls to be slow the first time you run them, because switching num_gpu evicts the resident model and reloads it onto the other processor.

▣ Build · stage 6 — a floor, a band, and a verdict
CPU_BASELINE_TPS = 4.58    # measured on this board with num_gpu=0
ACCEL_FLOOR_TPS = 12.0     # two and a half times the baseline, rounded down


def verdict(decode_tps: float) -> str:
    """Where one decode rate sits between the CPU number and the GPU number."""
    if decode_tps >= ACCEL_FLOOR_TPS:
        return "GPU carried the generation"
    if decode_tps >= CPU_BASELINE_TPS * 1.5:
        return "partial: some layers are running on the CPU"
    return "CPU only: the GPU contributed nothing"


def main() -> None:
    started = time.monotonic()
    reply = generate(PROMPT)
    wall = time.monotonic() - started
    p = phases(reply)

    print("=" * 58)
    print(f"  Ollama throughput check: {MODEL}")
    print("=" * 58)
    print(f"  Reply      : {reply.get('response', '').strip()[:60]}...")
    print(f"  Load       : {p['load (s)']:6.2f} s   weights read into memory")
    print(f"  Prefill    : {p['prefill (t/s)']:6.2f} t/s over "
          f"{reply.get('prompt_eval_count', 0)} prompt tokens")
    print(f"  Decode     : {p['decode (t/s)']:6.2f} t/s over "
          f"{reply.get('eval_count', 0)} generated tokens")
    print(f"  Wall clock : {wall:6.2f} s")
    print("-" * 58)
    print(f"  {verdict(p['decode (t/s)'])}")
    print(f"  floor {ACCEL_FLOOR_TPS:.1f} t/s, CPU baseline {CPU_BASELINE_TPS:.2f} t/s")
    print("=" * 58)


if __name__ == "__main__":
    main()
$ ssh glados-jetson python3 ollama_gpu_check.py   # measured on the bench — yours will vary
==========================================================
  Ollama throughput check: llama3.2:3b
==========================================================
  Reply      : A GPU runs thousands of small arithmetic operations at the s...
  Load       :   4.91 s   weights read into memory
  Prefill    : 287.67 t/s over 21 prompt tokens
  Decode     :  19.18 t/s over 54 generated tokens
  Wall clock :   7.83 s
----------------------------------------------------------
  GPU carried the generation
  floor 12.0 t/s, CPU baseline 4.58 t/s
==========================================================

The two constants at the top are the only opinions in the file, and both are traceable to something measured rather than picked. CPU_BASELINE_TPS is what stage 5 recorded with the GPU forbidden. The floor sits well above it and well below the 19 this board actually reaches, so a thermally throttled run or a slightly larger model still passes while a fallback cannot. The middle band exists because placement is not a coin flip: a model that half fits runs half on each processor, and a rate of 8 t/s is neither a healthy GPU nor a pure CPU. Write your own numbers into those constants from your own board, and put the date beside them in a comment, because a floor derived from someone else's silicon is a guess wearing a decimal point.

Why this works: a phase that carries its own clock

Nothing here inspects the GPU. The script never opens a device node, never links against CUDA, never asks the driver anything. It measures an outcome, and the outcome happens to be impossible to fake: the arithmetic for 54 tokens either got done in 2.8 seconds or it did not, and no configuration error can make a CPU produce a GPU's rate. That is the general property to take away. When you cannot inspect a mechanism directly, find the quantity it changes by a factor, then measure that quantity twice with the mechanism on and off.

The second general property is the reason Ollama's reply has four durations in it instead of one. A wall clock measures everything that happened, including work that has nothing to do with the thing you are timing: a 4.9 second disk read in this case, which only happens on the first request after an unload. Any component that reports per-phase timers is offering you the chance to divide the right counter by the right duration. Take it. Divide 54 tokens by the whole 7.81 seconds and this board reports 6.9 t/s, a number that would fail its own check while the GPU sat at 99 percent, and the second run would report 18.5 with nothing changed but a model already in memory. The two counters Ollama gives you are attached to two specific durations, and each one is only meaningful divided by its own.

⚠ Worked failure — 0.0 t/s on a GPU that was at 99 percent

The first version of rate() looked like this, and it does not raise anything:

def rate(count: int, duration_ns: int) -> float:
    if not count or not duration_ns:
        return 0.0
    return count / duration_ns          # BUG: nanoseconds, not seconds
$ ssh glados-jetson python3 ollama_gpu_check.py
  Decode     :   0.00 t/s over 54 generated tokens
  Wall clock :   7.83 s
----------------------------------------------------------
  CPU only: the GPU contributed nothing
  floor 12.0 t/s, CPU baseline 4.58 t/s

Two facts on that screen contradict each other. The report claims the model produced essentially no tokens per second, and the wall clock beside it says 54 tokens arrived in under eight seconds, which is at least seven per second by the crudest possible arithmetic. A tegrastats window running at the same time showed GR3D_FREQ 99%. When three instruments disagree, suspect the one doing arithmetic, and print its inputs before its output:

$ ssh glados-jetson python3 -c "print(54 / 2814722000)"
1.9185148745395815e-08

There it is, in the exponent. The division is correct and the units are not: 54 tokens per nanosecond-count, a number eight orders of magnitude below the one wanted, which :6.2f then rounds to 0.00 and hides completely. The formatting is what makes this expensive. Had the report printed 0.0000000192, the exponent would have pointed at the unit immediately. Dividing duration_ns by NS_PER_S first restores 19.18, and the habit that prevents the whole class of bug is to name the unit in the parameter, as duration_ns does, so the conversion is missing in a place you can see.

Checkpoint, and one run is still one run

✓ Checkpoint — what you can now do
  • I can explain why a CPU fallback in Ollama produces no error, no log line, and an answer identical to the accelerated one.
  • Handed a reply with eval_count and four duration fields, I can name which counter belongs to which duration and why the other pairings are meaningless.
  • I can say what GR3D_FREQ in tegrastats reports, and why nvidia-smi shows no per-process memory on this board.
  • I can force a CPU-only generation with num_gpu and state what it costs in load time to switch back.
  • Given a decode rate of 8 t/s on a board whose CPU baseline is 4.6, I can say what probably happened to the model's layers.
  • I know why a rate of 0.00 t/s beside a seven-second wall clock is an arithmetic bug and not a hardware fault.
⚡ Exercises — try first, then reveal
Exercise 1 — prove prefill and decode are different animals. Send the same request with a 20-token prompt and with a 900-token prompt, and report both rates for each.

Build the long prompt by repetition so the token count is roughly predictable, and print the two rates side by side:

SHORT = "In two sentences, what is a GPU good at that a CPU is not?"
LONG = ("Here is some background you should ignore. " * 100) + SHORT

for name, prompt in (("short", SHORT), ("long", LONG)):
    p = phases(generate(prompt))
    print(f"{name:<6} prefill {p['prefill (t/s)']:7.2f} t/s   "
          f"decode {p['decode (t/s)']:6.2f} t/s   total {p['total (s)']:5.2f} s")
$ ssh glados-jetson python3 prompt_length.py   # measured on the bench — yours will vary
short  prefill  287.67 t/s   decode  19.18 t/s   total  2.92 s
long   prefill  341.05 t/s   decode  18.94 t/s   total  5.60 s

A prompt forty times longer costs about two and a half extra seconds and leaves the decode rate almost untouched. Prefill even improves slightly, because a bigger batch of tokens keeps the GPU busier between memory reads. This is why the decode rate is the number that goes in a benchmark: it is a property of the model and the hardware, nearly independent of what you asked.

Exercise 2 — time the first token, not the last. Switch the request to streaming and report how long the reader waits before anything appears, alongside the decode rate from the final object.

With "stream": True the reply is newline-delimited JSON, one object per token, and the final object carries the counters:

def stream_first_token(prompt: str) -> tuple[float, float]:
    """Seconds until the first token, and the decode rate from the last object."""
    body = json.dumps({"model": MODEL, "prompt": prompt, "stream": True}).encode("utf-8")
    request = urllib.request.Request(OLLAMA_URL, data=body,
                                     headers={"Content-Type": "application/json"},
                                     method="POST")
    started = time.monotonic()
    first: float | None = None
    final: dict = {}
    with urllib.request.urlopen(request, timeout=180.0) as stream:
        for line in stream:
            chunk = json.loads(line)
            if first is None and chunk.get("response"):
                first = time.monotonic() - started
            if chunk.get("done"):
                final = chunk
    return first or 0.0, rate(final.get("eval_count", 0), final.get("eval_duration", 0))
$ ssh glados-jetson python3 first_token.py   # measured on the bench — yours will vary
first token after 0.11 s, then 19.05 t/s

Same generation, a different thing measured. The decode rate says how fast the sentence finishes; the first-token time says how long the room is silent after somebody stops talking. Her voice pipeline cares about the second one, because text can go to the speech model in clauses while the rest is still being written.

Exercise 3 (stretch) — stop paying the load time. Measure the cold and warm cost of the same request, then keep the model resident and prove the difference.

Run the check twice in a row and watch load_duration collapse: 4.91 s on the first call, about 0.02 s on the second, because the weights are already in memory. Five minutes later it is 4.91 again. Pin the model down by setting Environment="OLLAMA_KEEP_ALIVE=-1" in a systemd drop-in at /etc/systemd/system/ollama.service.d/keepalive.conf, then sudo systemctl daemon-reload && sudo systemctl restart ollama, and confirm with ollama ps that the UNTIL column reads Forever. The cost is 3.5 GB of the board's 8 GB held permanently, which is a decision to make deliberately: volume 8 also has a speech model and a transcription model wanting room in that same pool.

The board is accelerated and you can prove it with a number instead of a feeling. One number, from one run, on an afternoon when nothing else was competing for the memory pool. Run the script again after a long generation has warmed the board and it prints something slightly different; run it while the servos are moving and it prints something different again. A single reading cannot tell you whether 19.18 is this board's normal behaviour or its best afternoon, and the next chapter turns that reading into a measurement you can defend against a future one.