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.
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
# 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.
# 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.
# 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.
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".
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.
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.
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
- 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_countand four duration fields, I can name which counter belongs to which duration and why the other pairings are meaningless. - I can say what
GR3D_FREQin tegrastats reports, and whynvidia-smishows no per-process memory on this board. - I can force a CPU-only generation with
num_gpuand 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.
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.