The Jetson Orin Nano Super
Seven volumes on a board that has run out of room
Volume 7 closed with a 90-second run that put six checks through one pass, the eye, the light ring, the microphones, the speaker, the arm and the power rails, and printed GO. She has a body, the body is calibrated, and the sign-off report is dated and on disk. Every bit of that runs on a Raspberry Pi 4B. Nothing on that board is broken, and this volume moves her off it anyway.
Two numbers from earlier explain why. Chapter 61 measured the tail instead of the average and put the llm stage at a p95 of 4,270 milliseconds against a ceiling of 3,000: one reply in twenty takes more than four seconds while somebody stands in the kitchen waiting for it. Chapter 8 picked a three-billion-parameter model, and the reason was never that three billion is the right size for a house assistant. It was the largest thing that would load beside everything else and still answer in about a second.
The NVIDIA Jetson Orin Nano Super is the usual answer to both, and the usual way of describing it will mislead you. Read as "a Pi with a bigger number on the box" it disappoints on the first evening: there is no Wi-Fi radio, no storage of any kind in the carton, a barrel jack where you expected USB-C, and a different operating system. Those are shopping-list problems and this chapter will list them. The difference that actually earns the migration sits one row further down the table.
So the boards go into code before anything gets ordered. Every board here is one dictionary declaring exactly the rows every other board declares, and the row the migration turns on is memory: how much of one shared pool she gets, and how fast that pool can be read.
One Raspberry Pi 4B with 4 GB, the machine Volumes 1 through 7 were built on, and one Jetson Orin Nano Super 8 GB developer kit with a 500 GB NVMe drive in the M.2 slot, a 19 V supply on the barrel jack, and a USB Wi-Fi adapter, since the board has no radio. Peak bandwidth and core counts in this chapter come from the vendors' own documentation and are the same everywhere. Every tokens-per-second figure, and every gigabyte of memory reported as resident, came off that one bench with that one set of models, and yours will land somewhere else. Nothing here needs the Jetson powered on: the whole chapter runs on the machine you already have.
Two boards, the same rows
# labs/board_specs.py
BOARDS: dict[str, dict[str, str]] = {
"Raspberry Pi 4B": {
"CPU": "4x Cortex-A72 @ 1.8 GHz",
"GPU": "VideoCore VI, no CUDA",
"Memory": "4 GB LPDDR4, one pool",
"Bandwidth": "12.8 GB/s peak",
"Storage": "microSD or USB",
"Power": "5 V over USB-C",
"Wi-Fi": "on the board",
"GPIO": "40-pin header",
},
"Jetson Orin Nano Super": {
"CPU": "6x Cortex-A78AE @ 1.7 GHz",
"GPU": "Ampere, 1024 CUDA cores",
"Memory": "8 GB LPDDR5, one pool",
"Bandwidth": "102 GB/s peak",
"Storage": "M.2 NVMe, none included",
"Power": "9-19 V barrel jack",
"Wi-Fi": "not on the board",
"GPIO": "40-pin header",
},
}
def missing_keys(boards: dict[str, dict[str, str]]) -> dict[str, list[str]]:
"""For each board, the rows some other board declares and this one does not."""
every: set[str] = set()
for spec in boards.values():
every |= set(spec)
return {name: sorted(every - set(spec)) for name, spec in boards.items()}
def shared_keys(boards: dict[str, dict[str, str]]) -> list[str]:
"""The comparable rows, in the first board's order. Raises if any board is short one."""
gaps = {name: gap for name, gap in missing_keys(boards).items() if gap}
if gaps:
raise KeyError(f"boards do not declare the same rows: {gaps}")
return list(next(iter(boards.values())))
if __name__ == "__main__":
print(f"boards: {len(BOARDS)}")
print(f"rows: {len(shared_keys(BOARDS))}")
print(f"gaps: {missing_keys(BOARDS)}")
$ uv run python -m labs.board_specs
boards: 2
rows: 8
gaps: {'Raspberry Pi 4B': [], 'Jetson Orin Nano Super': []}
Two dictionaries side by side are only a comparison while both declare the same rows.
Drop one and the reader of the table sees a board with no entry where the other has a
number, and reads that absence as a fact about the hardware instead of a fact about
your typing. So the check happens once, at the top, and it is loud:
shared_keys either returns the row list or refuses to return anything.
Notice that missing_keys hands back data, not a yes or no. A boolean can
only tell you something is wrong; a dictionary of gaps tells you which board is short
which row, and that difference is what makes the failure at the end of this chapter
solvable in one line.
# labs/board_compare.py
from labs.board_specs import BOARDS, shared_keys
def column_widths(boards: dict[str, dict[str, str]],
keys: list[str]) -> tuple[int, dict[str, int]]:
"""Widths taken from the data, so no cell can outgrow the column it sits in."""
label = max(len(k) for k in keys)
cells = {n: max([len(n)] + [len(s[k]) for k in keys]) for n, s in boards.items()}
return label, cells
def render_table(boards: dict[str, dict[str, str]]) -> list[str]:
keys = shared_keys(boards)
label_w, cell_w = column_widths(boards, keys)
names = list(boards)
lines = ["".ljust(label_w) + "".join(f" {n:<{cell_w[n]}}" for n in names)]
lines.append("-" * label_w + "".join(" " + "-" * cell_w[n] for n in names))
for key in keys:
lines.append(f"{key:<{label_w}}"
+ "".join(f" {boards[n][key]:<{cell_w[n]}}" for n in names))
return [line.rstrip() for line in lines]
if __name__ == "__main__":
for line in render_table(BOARDS):
print(line)
$ uv run python -m labs.board_compare
Raspberry Pi 4B Jetson Orin Nano Super
--------- ----------------------- -------------------------
CPU 4x Cortex-A72 @ 1.8 GHz 6x Cortex-A78AE @ 1.7 GHz
GPU VideoCore VI, no CUDA Ampere, 1024 CUDA cores
Memory 4 GB LPDDR4, one pool 8 GB LPDDR5, one pool
Bandwidth 12.8 GB/s peak 102 GB/s peak
Storage microSD or USB M.2 NVMe, none included
Power 5 V over USB-C 9-19 V barrel jack
Wi-Fi on the board not on the board
GPIO 40-pin header 40-pin header
A format specifier like :<35 sets a minimum width and never a maximum,
so a hard-coded column is a promise the data is free to break: one long cell shoves the
rest of its row past the border and the alignment is gone. Measuring the widest cell
first costs one pass over the same dictionary and makes that impossible. The row list
comes from shared_keys rather than from one board picked by name, so
adding a third column later cannot quietly drop a row that only two of the three
boards declare.
Read the table before moving on. The GPIO row is identical, so the wiring loom built in Volume 7 physically survives the move, though the pin behaviour behind that header gets checked against the new chip in chapter 81. Wi-Fi, Power and Storage are the shopping list: budget for a USB adapter, a 19 V supply, and an NVMe drive, because none of the three is in the box. Everything that matters for how fast she thinks is in the two middle rows.
What the pool has to hold, and how fast it can be read
# labs/board_compare.py — added under the imports
POOL_GB = {"Raspberry Pi 4B": 4.0, "Jetson Orin Nano Super": 8.0}
RESIDENT_GB = {"Raspberry Pi 4B": 1.4, "Jetson Orin Nano Super": 1.9}
WEIGHTS_GB = {"llama3.2:3b": 2.0, "llama3.1:8b": 4.9, "qwen2.5:14b": 9.0}
def headroom_gb(board: str) -> float:
"""What is left of the shared pool once the OS and her own stack are resident."""
return POOL_GB[board] - RESIDENT_GB[board]
def fit_verdict(board: str, model: str) -> str:
slack = headroom_gb(board) - WEIGHTS_GB[model]
if slack >= 0:
return f"fits, {slack:.1f} GB spare"
return f"short by {-slack:.1f} GB"
if __name__ == "__main__":
for board in BOARDS:
print(f"{board}: headroom {headroom_gb(board):.1f} GB of {POOL_GB[board]:.0f} GB")
for model in WEIGHTS_GB:
print(f" {model:<13} {WEIGHTS_GB[model]:>4.1f} GB {fit_verdict(board, model)}")
$ uv run python -m labs.board_compare # resident figures measured on the bench — yours will vary
Raspberry Pi 4B: headroom 2.6 GB of 4 GB
llama3.2:3b 2.0 GB fits, 0.6 GB spare
llama3.1:8b 4.9 GB short by 2.3 GB
qwen2.5:14b 9.0 GB short by 6.4 GB
Jetson Orin Nano Super: headroom 6.1 GB of 8 GB
llama3.2:3b 2.0 GB fits, 4.1 GB spare
llama3.1:8b 4.9 GB fits, 1.2 GB spare
qwen2.5:14b 9.0 GB short by 2.9 GB
The resident figures are what the operating system, Python, faster-whisper and Piper were holding on each board with the voice loop idle. Subtracting them is the whole calculation, and it is a calculation you never have to do on a desktop with a graphics card, because there the model's budget is the card's own memory and the operating system lives somewhere else entirely. On both of these boards there is one pool and every user of it is a competitor. The first line of that output is also the answer to a question left open in Volume 1: the model chosen back in chapter 8 was 2.0 GB because 2.6 GB was all there was.
# labs/board_compare.py — continued
BANDWIDTH_GB_S = {"Raspberry Pi 4B": 12.8, "Jetson Orin Nano Super": 102.0}
MEASURED_TPS = {"Raspberry Pi 4B": 2.7, "Jetson Orin Nano Super": 18.4}
MODEL = "llama3.2:3b"
def peak_gb_s(board: str) -> float:
"""Peak memory bandwidth, the number the ceiling is computed from."""
return BANDWIDTH_GB_S[board]
def ceiling_tps(board: str, model: str) -> float:
"""One token reads every weight once, so bandwidth over weight bytes is the ceiling."""
return peak_gb_s(board) / WEIGHTS_GB[model]
if __name__ == "__main__":
print(f"{MODEL}: {WEIGHTS_GB[MODEL]:.1f} GB of weights read per token")
print(f"{'board':<24}{'GB/s':>7}{'ceiling':>10}{'measured':>10}{'of peak':>9}")
for board in BOARDS:
ceiling, measured = ceiling_tps(board, MODEL), MEASURED_TPS[board]
print(f"{board:<24}{peak_gb_s(board):>7.1f}{ceiling:>10.1f}"
f"{measured:>10.1f}{measured / ceiling * 100:>8.0f}%")
$ uv run python -m labs.board_compare # measured column from the bench — yours will vary
llama3.2:3b: 2.0 GB of weights read per token
board GB/s ceiling measured of peak
Raspberry Pi 4B 12.8 6.4 2.7 42%
Jetson Orin Nano Super 102.0 51.0 18.4 36%
That division is the most useful piece of arithmetic in the volume. Generating one token means running the whole network once, and every weight in the file gets read exactly once to do it, so a 2.0 GB model reads 2.0 GB per token. A board that can move 12.8 GB every second therefore cannot produce more than 6.4 tokens a second no matter how clever the software is, and one that can move 102 GB/s tops out near 51. Real engines land between a third and a half of peak once you count cache misses, the attention cache and the gaps between layers, which is where both measured columns sit. The ratio of the two measured rates is 6.8, and it comes almost entirely from the bandwidth row.
Why this works: one pool, two customers
Inside the Jetson the LPDDR5 chips are wired to a single memory controller, and the Cortex-A78AE cluster and the Ampere GPU are both clients of it. They address the same physical pages. A buffer allocated by Python and handed to a CUDA kernel is the same memory at the same address, seen from two directions, so there is no transfer to schedule and no second allocation to size. That is what "unified" means, and it is why the fit table earlier could ask a single question, will the weights fit beside the operating system, instead of the two questions a desktop forces.
The trade runs in both directions and the second half is the part that catches people. One pool means one budget for capacity: the model, the operating system, the desktop session, the CUDA context, faster-whisper and Piper all draw from the same 8 GB, and nothing protects the model's share the way a graphics card's own memory would. It also means one budget for bandwidth. While the GPU is streaming weights at 102 GB/s the CPU cores are queueing at the same controller for the same cycles, so an audio thread and a generating model are genuinely in each other's way. A discrete card buys isolation with a copy and a hard capacity limit; the Jetson buys capacity and zero-copy by giving the isolation up.
Generalize it past this board and two numbers decide whether any machine can host a local model conversationally: the free bytes in the pool it may live in, and the bytes per second that pool can be read at. Nothing else is close. The 67 TOPS printed on the Jetson's box is a real figure for dense batched arithmetic, the kind of work a vision pipeline does when one loaded weight gets reused across a whole batch of images. Answering one person reuses nothing: every weight is fetched, multiplied once, and dropped. Buy bandwidth and capacity; treat the arithmetic rating as a number for a different problem.
The bench Pi is a 4B, and after a week of looking at the table you rename it in
BOARDS so the record says which revision the measurements came from. Four
side tables are keyed by that same name. You update three of them, run it, and get a
KeyError: 'Raspberry Pi 4B' out of peak_gb_s. The fix takes a
second and feels reasonable, because surely a board might turn up one day with no
published bandwidth figure:
# labs/board_compare.py — the rename, and the repair that followed it
BANDWIDTH_GB_S = {"Raspberry Pi 4": 12.8, "Jetson Orin Nano Super": 102.0}
def peak_gb_s(board: str) -> float:
return BANDWIDTH_GB_S.get(board, 0.0) # "not every board will have a figure"
$ uv run python -m labs.board_compare
llama3.2:3b: 2.0 GB of weights read per token
board GB/s ceiling measured of peak
Traceback (most recent call last):
File ".../labs/board_compare.py", line 60, in <module>
f"{measured:>10.1f}{measured / ceiling * 100:>8.0f}%")
~~~~~~~~~^~~~~~~~~
ZeroDivisionError: float division by zero
Work backwards from the operator. A float division by zero means the divisor is zero,
and the divisor is ceiling, which came from bandwidth divided by weights.
Weights is 2.0, a literal you can read. So the bandwidth was 0.0, and the only place a
zero can enter that expression is the default you just added. Print the two key sets
and the rename is sitting there in plain sight.
Now the uncomfortable part. The exception was luck. It fired because the last column
happens to divide by the ceiling; delete that column and the row prints
0.0 for bandwidth and 0.0 for the ceiling, and the table reads
as a finding about a board that cannot run a language model at all. A default turns a
structural mistake into a plausible number, and plausible numbers do not raise. The
same rule that governs the rows inside BOARDS governs every table keyed by
board name, so state it once and check it at import:
# labs/board_specs.py — added beside shared_keys
def check_tables(boards: dict[str, dict[str, str]],
**tables: dict[str, float]) -> None:
"""Every table keyed by board name must be keyed by exactly these boards."""
names = set(boards)
for label, table in tables.items():
if set(table) != names:
raise KeyError(f"{label} is keyed by {sorted(table)}, not by {sorted(names)}")
# labs/board_compare.py — at module level, above the functions
check_tables(BOARDS, POOL_GB=POOL_GB, RESIDENT_GB=RESIDENT_GB,
BANDWIDTH_GB_S=BANDWIDTH_GB_S, MEASURED_TPS=MEASURED_TPS)
$ uv run python -m labs.board_compare
Traceback (most recent call last):
File ".../labs/board_compare.py", line 12, in <module>
check_tables(BOARDS, POOL_GB=POOL_GB, RESIDENT_GB=RESIDENT_GB,
File ".../labs/board_specs.py", line 54, in check_tables
raise KeyError(f"{label} is keyed by {sorted(table)}, not by {sorted(names)}")
KeyError: "BANDWIDTH_GB_S is keyed by ['Jetson Orin Nano Super', 'Raspberry Pi 4'], not by ['Jetson Orin Nano Super', 'Raspberry Pi 4B']"
The error now names the table, both key sets, and arrives before a single row is
printed. Put peak_gb_s back to indexing with square brackets while you are
there: a key that should exist deserves an exception, and a key that legitimately might
not exist deserves a default you chose on purpose and can defend, which 0.0 GB/s is
not.
Checkpoint, and a board that still has nothing on it
- Given a model's weight file in gigabytes and a board's peak memory bandwidth, I can compute the tokens-per-second ceiling and say why a real engine lands near a third to a half of it.
- I can say what a 4,270 millisecond tail and a 2.0 GB model have in common as reasons to change hardware.
- I can state both halves of the unified-memory trade: what removing the copy buys, and what sharing one pool with the operating system costs.
- I can explain why 67 TOPS predicts vision throughput and not how fast she answers.
- Handed a fit table saying a model fits, I know that is a different question from whether it generates fast enough to hold a conversation.
- I know why
.get(key, 0.0)is the wrong repair for a key that should exist, and what to write in its place.
Exercise 1 — add a third column. Put a Raspberry Pi 5 into
BOARDS (4x Cortex-A76 at 2.4 GHz, VideoCore VII, 8 GB LPDDR4X, 17.1 GB/s
peak, microSD or NVMe, USB-C power, Wi-Fi on the board) and render the table with
three columns.
Leave the GPIO row out of the new entry the first time, on purpose, and watch the invariant do its job:
$ uv run python -m labs.board_compare
KeyError: "boards do not declare the same rows: {'Raspberry Pi 5': ['GPIO']}"
Add "GPIO": "40-pin header" and the table widens on its own, because
nothing in render_table knows how many boards there are:
$ uv run python -m labs.board_compare
Raspberry Pi 4B Jetson Orin Nano Super Raspberry Pi 5
--------- ----------------------- ------------------------- -----------------------
CPU 4x Cortex-A72 @ 1.8 GHz 6x Cortex-A78AE @ 1.7 GHz 4x Cortex-A76 @ 2.4 GHz
GPU VideoCore VI, no CUDA Ampere, 1024 CUDA cores VideoCore VII, no CUDA
Memory 4 GB LPDDR4, one pool 8 GB LPDDR5, one pool 8 GB LPDDR4X, one pool
Bandwidth 12.8 GB/s peak 102 GB/s peak 17.1 GB/s peak
Storage microSD or USB M.2 NVMe, none included microSD or NVMe
Power 5 V over USB-C 9-19 V barrel jack 5 V over USB-C
Wi-Fi on the board not on the board on the board
GPIO 40-pin header 40-pin header 40-pin header
The Pi 5 has the same 8 GB the Jetson has and reads it six times more slowly, so its ceiling on the same model is 8.6 tokens a second against 51. Capacity and bandwidth are separate purchases.
Exercise 2 — find the model that fits and still cannot talk.
For every model in WEIGHTS_GB, print the Jetson's ceiling, an estimate at
the fraction of peak you measured, and a verdict against a floor of 8 tokens per
second.
from labs.board_compare import WEIGHTS_GB, ceiling_tps, fit_verdict, headroom_gb
BOARD = "Jetson Orin Nano Super"
EFFICIENCY = 0.36 # the fraction of peak this board reached on the bench
FLOOR_TPS = 8.0 # below this, a reply stops feeling like a conversation
for model in WEIGHTS_GB:
if WEIGHTS_GB[model] > headroom_gb(BOARD):
print(f"{model:<13} {fit_verdict(BOARD, model)}")
continue
ceiling = ceiling_tps(BOARD, model)
estimate = ceiling * EFFICIENCY
call = "usable" if estimate >= FLOOR_TPS else "too slow to talk to"
print(f"{model:<13} ceiling {ceiling:>5.1f} t/s estimate {estimate:>5.1f} t/s {call}")
$ uv run python -m labs.model_floor
llama3.2:3b ceiling 51.0 t/s estimate 18.4 t/s usable
llama3.1:8b ceiling 20.8 t/s estimate 7.5 t/s too slow to talk to
qwen2.5:14b short by 2.9 GB
The 8B model clears the capacity test with 1.2 GB to spare and lands under the floor anyway, because more than twice the weights means less than half the tokens. Whether 8 is the right floor is a judgement about your own patience, and the estimate is arithmetic on one bench measurement rather than a reading. Chapter 83 replaces both with a real timing run.
Exercise 3 — measure what your own machine gets of its peak. Ask Ollama for one generation on the board you have right now, turn its timing fields into tokens per second, and divide by the ceiling.
import ollama
from labs.board_compare import MODEL, WEIGHTS_GB, ceiling_tps
BOARD = "Raspberry Pi 4B" # whichever row of BOARDS you are sitting in front of
reply = ollama.generate(model=MODEL, prompt="Name three noble gases.")
seconds = reply["eval_duration"] / 1e9 # Ollama reports nanoseconds
measured = reply["eval_count"] / seconds
ceiling = ceiling_tps(BOARD, MODEL)
print(f"{reply['eval_count']} tokens in {seconds:.2f} s")
print(f"{measured:.1f} t/s measured, {ceiling:.1f} t/s ceiling, "
f"{measured / ceiling * 100:.0f}% of peak")
$ uv run python -m labs.own_peak # measured on the bench — yours will vary
61 tokens in 22.14 s
2.8 t/s measured, 6.4 t/s ceiling, 43% of peak
Two fields do the work: eval_count is how many tokens were generated
and eval_duration is how long that took in nanoseconds, excluding the
time spent reading your prompt. A result far under 30 percent of peak usually means
something else was competing for the memory controller while the model ran, which
is a claim you can test by starting a second copy and watching the number fall.
The case is made and written down: a pool twice as large, read eight times faster, for a board that arrives with no disk in it, no radio, and no operating system. That is the next evening's work. Flashing JetPack onto the NVMe drive is a long progress bar that reports success whether or not the CUDA toolkit actually landed, so the chapter after this one builds the script that reads the board's own version files back and prints what is really there beside what was promised.