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

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.

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

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

▣ Build · stage 1 — the facts, and the rule that keeps them comparable
# 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.

▣ Build · stage 2 — a table whose columns are measured, not guessed
# 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

▣ Build · stage 3 — headroom, once the operating system has taken its share
# 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.

A discrete graphics card's two memory pools beside the Jetson's single pool On the left, a desktop: the CPU reads system RAM and the GPU reads its own separate VRAM, with model weights copied from one to the other across the PCIe bus, and the model limited to whatever fits in VRAM. On the right, the Jetson: the CPU and the Ampere GPU both read one 8 GB LPDDR5 pool at 102 GB/s, with no copy between them, and the operating system drawing on that same pool. DISCRETE CARD · TWO SEPARATE POOLS UNIFIED MEMORY · ONE POOL, NO COPY CPU GPU system RAM the OS lives here VRAM the model lives here weights copied over PCIe and the model must fit VRAM CPU GPU 8 GB LPDDR5 at 102 GB/s the OS and the model live here together no copy, and one budget for capacity and for bandwidth
Figure 75.1 — The same eight gigabytes appear once on the right and twice on the left. Removing the copy is the gain; removing the wall between the model's memory and the operating system's is the price.
▣ Build · stage 4 — the fastest she could possibly answer
# 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.

⚠ Worked failure — a board renamed in one table out of four

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

✓ Checkpoint — what you can now do
  • 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.
⚡ Exercises — try first, then reveal
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.