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

CUDA and GPU Verification

Two commands, two true answers

The board answers over SSH now, so the first thing worth asking it is the question the whole volume was for. Ask it twice, in the two obvious ways, and the answers do not match.

$ ssh glados-jetson nvidia-smi --query-gpu=name,driver_version --format=csv,noheader   # measured on the bench — yours will vary
Orin (nvgpu), 540.4.0
$ ssh glados-jetson nvcc --version
Command 'nvcc' not found, but can be installed with:
sudo apt install nvidia-cuda-toolkit

Three statements are now on the table. The GPU is live and the driver will talk about it. The compiler is missing. And Chapter 76's check_cuda_toolkit read /usr/local/cuda/bin/nvcc off this board's disk and reported it present. All three are correct, and the last two only look contradictory because they are answers to different questions: one asked the filesystem whether a file exists, the other asked a shell whether it could find a command to run.

Do not paste that suggestion, either. Ubuntu's command-not-found handler has no idea it is running on a Tegra board, and nvidia-cuda-toolkit is the distribution's own package, built for generic arm64 against a different driver. Installing it on top of JetPack gives you two CUDAs, one of which cannot see the GPU, and whichever one the next program picks up is decided by search order you did not set.

So the standard for this chapter is stricter than the last one: a layer is verified when the process that needs it can reach it, asked from the environment that process will actually run in. A file on disk is evidence about the disk. Whether a program can reach that file depends on which lookup it performs, and CUDA is found by three different lookups that know nothing about each other.

Running something that might not be there

Chapter 76 verified by reading files, and reading a file that is absent gives you None and a printable row. Verifying by running a program is harder to keep calm, because the failure arrives before your code gets a turn. There are two ways to start a subprocess in Python and they fail in completely different places. Hand subprocess.run a string with shell=True and a shell does the looking; when it finds nothing it exits 127, a number you can test. Hand it a list and Python asks the kernel to execute that binary directly; when the binary is not there the kernel says so and Python raises FileNotFoundError, which stops the script at the first missing tool, on exactly the machine where you most need the remaining checks to run.

The usual advice is to take the first option. Take neither. Look the command up yourself, then use the list form, and a missing tool becomes a field on a returned object without putting a shell between you and the thing you are measuring.

▣ Build · stage 1 — one runner, and absence as a value
# labs/verify_cuda.py
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path


@dataclass
class Ran:
    """What one command did, including never having been there to run."""
    argv: list[str]
    found: bool = False
    code: int = -1
    out: str = ""
    err: str = ""


def run(argv: list[str], timeout: float = 15.0) -> Ran:
    """Look the binary up first, then execute it. Absence returns; it never raises."""
    ran = Ran(argv)
    binary = shutil.which(argv[0])
    if binary is None:
        ran.err = f"{argv[0]}: not found on PATH"
        return ran
    ran.found = True
    try:
        done = subprocess.run([binary, *argv[1:]], capture_output=True, text=True, timeout=timeout)
    except subprocess.TimeoutExpired:
        ran.err = f"{argv[0]} printed nothing in {timeout:.0f}s"
        return ran
    ran.code, ran.out, ran.err = done.returncode, done.stdout.strip(), done.stderr.strip()
    return ran


if __name__ == "__main__":
    for argv in (["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"], ["nvcc", "--version"]):
        ran = run(argv)
        print(f"{argv[0]:<11} found={ran.found} code={ran.code} out={ran.out!r} err={ran.err!r}")
$ uv run python -m labs.verify_cuda   # on the Jetson, before any PATH edits
nvidia-smi  found=True code=0 out='Orin (nvgpu)' err=''
nvcc        found=False code=-1 out='' err="nvcc: not found on PATH"

found and code are deliberately separate. A tool that is absent and a tool that ran and failed need different responses from you, and collapsing both into "non-zero" throws away which one happened. The starting code of -1 is a value no process can exit with, so a row still showing it means nothing ever ran. shutil.which does the same search the shell does, over the PATH this Python process inherited, and it takes an absolute path too: given one, it checks that the file exists and is executable and hands it straight back. One function then serves both kinds of lookup. The timeout is there because nvidia-smi on a wedged driver does not fail, it hangs, and a diagnostic that hangs is worse than one that reports trouble.

Four layers, four different lookups

Each layer below gets a row saying what was detected and, when the row fails, the command that repairs it. Chapter 58's run_check and Chapter 76's Check already do the rest, so this file imports them and adds one field.

▣ Build · stage 2 — layer one: the driver, which was never the problem
# labs/verify_cuda.py — continued
from labs.jetpack_verify import Check, run_check


@dataclass
class Layer(Check):
    """A Check that also carries the command that would fix it."""
    fix: str = ""


def check_driver() -> Layer:
    layer = Layer("driver", "a GPU nvidia-smi can talk to")
    ran = run(["nvidia-smi", "--query-gpu=name,memory.total,driver_version", "--format=csv,noheader"])
    if not ran.found:
        layer.detected = "nvidia-smi is not installed"
        layer.fix = "reflash: the driver comes with the L4T image, so its absence is a flash fault"
        return layer
    if ran.code != 0:
        layer.detected = ran.err or f"nvidia-smi exited {ran.code} silently"
        return layer
    name, memory, driver = (f.strip() for f in ran.out.splitlines()[0].split(","))
    layer.detected = f"{name}, driver {driver}, memory field {memory}"
    layer.ok = True
    return layer
$ uv run python -m labs.verify_cuda   # measured on the bench — yours will vary
[  OK  ] driver   Orin (nvgpu), driver 540.4.0, memory field [N/A]

--format=csv,noheader turns a human dashboard into one comma-separated line, so three fields come out of one split with no parsing to maintain. Two of them read oddly on this board and both readings are right. The name is Orin (nvgpu) because the GPU is a block inside the same chip as the CPU and not a card on a bus. The memory total is [N/A] for the same reason: there is no separate pool of video memory to report, since the GPU and the CPU share the board's LPDDR5. A parser written on a desktop and moved here will happily turn [N/A] into a megabyte figure of zero and then decide the GPU is full. Print the field as it came.

▣ Build · stage 3 — layer two: a compiler that runs while the shell denies it exists
# labs/verify_cuda.py — continued
import re

CUDA_ROOT = Path("/usr/local/cuda")


def check_toolkit() -> Layer:
    layer = Layer("toolkit", "nvcc the shell can find")
    on_path = shutil.which("nvcc")
    on_disk = CUDA_ROOT / "bin" / "nvcc"
    if on_path is None and not on_disk.is_file():
        layer.detected = f"no nvcc on PATH and no file at {on_disk}"
        layer.fix = "sudo apt install cuda-toolkit-12-6"
        return layer

    ran = run([on_path or str(on_disk), "--version"])
    release = re.search(r"release (\d+\.\d+), (V\S+)", ran.out)
    if release is None:
        layer.detected = f"nvcc ran, exit {ran.code}, no release line in its output"
        return layer

    layer.detected = f"CUDA {release.group(1)} ({release.group(2)}) at {ran.argv[0]}"
    layer.ok = on_path is not None
    if on_path is None:
        layer.fix = "echo 'export PATH=/usr/local/cuda/bin:$PATH' >> ~/.bashrc"
    return layer
$ uv run python -m labs.verify_cuda   # measured on the bench — your build number will vary
[  OK  ] driver   Orin (nvgpu), driver 540.4.0, memory field [N/A]
[ FAIL ] toolkit  CUDA 12.6 (V12.6.68) at /usr/local/cuda/bin/nvcc
                  fix: echo 'export PATH=/usr/local/cuda/bin:$PATH' >> ~/.bashrc

Read that failing row for what it says, because it is more useful than a plain "not found". The compiler exists, it ran, it identified itself as CUDA 12.6, and the row still fails, since the question this layer asks is whether the shell can find it. The two version numbers in the output belong to different counters: 12.6 is the release that packages are built against, and V12.6.68 is the compiler's own build. Compare the first and print the second, the same split Chapter 76 made for the SDK manifest.

Why is CUDA off the PATH at all? The driver's tools live in /usr/bin, which every shell searches by default. The toolkit installs under /usr/local/cuda, a versioned tree that lets several CUDAs sit side by side, and no installer can add one of them to your PATH without picking a winner for you. So it picks none, and the choice is left in a file you edit.

▣ Build · stage 4 — layer three: what this process can load, and from where
# labs/verify_cuda.py — continued
import ctypes


def mapped_file(fragment: str) -> str:
    """The file behind a library this process has already loaded, per /proc/self/maps."""
    with open("/proc/self/maps") as handle:
        for line in handle:
            path = line.rsplit(" ", 1)[-1].strip()
            if fragment in path:
                return path
    return "loaded, but no mapping names it"


def check_runtime() -> Layer:
    layer = Layer("runtime", "libcudart loadable by this process")
    try:
        ctypes.CDLL("libcudart.so.12")
    except OSError as exc:
        layer.detected = str(exc).strip()
        layer.fix = ("echo /usr/local/cuda/lib64 | sudo tee /etc/ld.so.conf.d/cuda.conf "
                     "&& sudo ldconfig")
        return layer
    layer.detected = mapped_file("libcudart")
    layer.ok = True
    return layer
$ uv run python -m labs.verify_cuda   # before the ldconfig fix
[ FAIL ] runtime  libcudart.so.12: cannot open shared object file: No such file or directory
                  fix: echo /usr/local/cuda/lib64 | sudo tee /etc/ld.so.conf.d/cuda.conf && sudo ldconfig
$ uv run python -m labs.verify_cuda   # after it
[  OK  ] runtime  /usr/local/cuda-12.6/targets/aarch64-linux/lib/libcudart.so.12.6.68

This is the layer neither of the first two can see. When a Python extension module starts, the dynamic loader resolves its library dependencies against a compiled cache built by ldconfig, plus anything in LD_LIBRARY_PATH and any search path baked into the binary. PATH plays no part in it. That is why fixing the compiler lookup does nothing for a library import, and why the repair here is a file under /etc/ld.so.conf.d/ instead of a line in a shell profile: the cache is system-wide, so a service starting at boot with no shell and no profile gets the same answer you do.

ctypes.CDLL performs that resolution on demand and raises OSError with the loader's own message when it fails, which makes it the cheapest honest test available. The successful row then reads the answer back out of /proc/self/maps, the kernel's list of what this process has mapped into memory. Notice the path is not the one added to the config: it is a real file at the end of a chain of symlinks, with the full build number in its name. Where two CUDAs are installed, that line is what tells you which one won.

Four questions about one CUDA, each answered by a different lookup Four rows. Does the file exist is answered by the filesystem at /usr/local/cuda/bin/nvcc. Can the shell run it is answered by the PATH environment variable, inherited per process. Can a process load the library is answered by the ld.so cache built by ldconfig plus LD_LIBRARY_PATH. Does the engine use the GPU is answered by the library itself, which may choose CPU without reporting it. The first two lookups know nothing about each other, and neither knows about the third. ONE INSTALL · FOUR LOOKUPS · FOUR CHANCES TO MISS does the file exist? filesystem · /usr/local/cuda/bin can the shell run it? $PATH · per process, inherited can a process load it? ld.so cache · LD_LIBRARY_PATH does the engine use it? the library's own choice, unannounced
Figure 78.1 — The top row is all Chapter 76 could ask from the disk. Each row below it consults a mechanism the row above never touches, and the bottom one answers in silence.
▣ Build · stage 5 — layer four: asking the engine that will use the GPU
# labs/verify_cuda.py — continued
import sys


def check_engine() -> Layer:
    layer = Layer("engine", "at least one CUDA device visible to ctranslate2")
    try:
        import ctranslate2
    except ImportError as exc:
        layer.detected = f"ctranslate2 will not import: {exc}"
        layer.fix = "uv sync"
        return layer
    count = ctranslate2.get_cuda_device_count()
    layer.detected = f"{count} CUDA device(s), ctranslate2 {ctranslate2.__version__}"
    layer.ok = count > 0
    if count == 0:
        layer.fix = 'never set device="auto" here: with 0 devices it picks CPU and says nothing'
    return layer


LAYERS = [check_driver, check_toolkit, check_runtime, check_engine]


def report(layers: list[Check]) -> bool:
    width = max(len(layer.name) for layer in layers)
    for layer in layers:
        print(f"[{'  OK  ' if layer.ok else ' FAIL '}] {layer.name:<{width}}  {layer.detected}")
        fix = getattr(layer, "fix", "")
        if fix and not layer.ok:
            print(f"{'':<{width + 11}}fix: {fix}")
    usable = sum(1 for layer in layers if layer.ok)
    print(f"{usable}/{len(layers)} layers usable by the process that asked.")
    return usable == len(layers)


if __name__ == "__main__":
    sys.exit(0 if report([run_check(fn) for fn in LAYERS]) else 1)
$ uv run python -m labs.verify_cuda; echo "exit=$?"   # measured on the bench — yours will vary
[  OK  ] driver   Orin (nvgpu), driver 540.4.0, memory field [N/A]
[  OK  ] toolkit  CUDA 12.6 (V12.6.68) at /usr/local/cuda/bin/nvcc
[  OK  ] runtime  /usr/local/cuda-12.6/targets/aarch64-linux/lib/libcudart.so.12.6.68
[  OK  ] engine   1 CUDA device(s), ctranslate2 4.5.0
4/4 layers usable by the process that asked.
exit=0

The last layer is the only one that asks the software you actually run. CTranslate2 is the engine underneath faster-whisper, and get_cuda_device_count reports how many devices this build of it can see, which is a narrower claim than the driver's. A wheel compiled without CUDA support returns 0 on a board with a perfect driver, and so does a build whose cuDNN requirement the loader cannot satisfy. Volume 1 constructed WhisperModel with device="cpu", since a laptop had nothing else to offer. The tempting edit on this board is device="auto", and a 0 here is exactly the case where "auto" quietly resolves to CPU, transcribes correctly, and takes many times longer with no message anywhere. Name the device you demand and let it fail loudly.

run_check converts a raised exception into a plain Check, not a Layer, so the printer reaches for fix with getattr and a default. A check that died has no repair to suggest anyway; it has a traceback in its detected field, and that is the more useful thing to read.

Why this works: the environment belongs to the process, not the board

Every row above asks its question the way the eventual consumer will ask it. That is the whole design, and it holds well past CUDA. A binary is reachable through PATH; a shared library through the loader's cache; a Python module through sys.path; a device node through file permissions and group membership. Four namespaces, four sets of rules, and an installer that satisfies one of them has told you nothing about the other three. "Installed" is a statement about the disk. "Usable" is always a statement about one process, and the process is where the question has to be asked.

⚠ Worked failure — the fix that worked at the keyboard and not over the wire

Apply the toolkit repair the report printed, confirm it, and then run the verifier the way every later chapter will:

$ ssh glados-jetson
glados@glados-jetson:~$ echo 'export PATH=/usr/local/cuda/bin:$PATH' >> ~/.bashrc
glados@glados-jetson:~$ source ~/.bashrc
glados@glados-jetson:~$ nvcc --version | tail -2
Cuda compilation tools, release 12.6, V12.6.68
Build cuda_12.6.r12.6/compiler.34714021_0
glados@glados-jetson:~$ exit
$ ssh glados-jetson 'cd GladOS && uv run python -m labs.verify_cuda'
[  OK  ] driver   Orin (nvgpu), driver 540.4.0, memory field [N/A]
[ FAIL ] toolkit  CUDA 12.6 (V12.6.68) at /usr/local/cuda/bin/nvcc
                  fix: echo 'export PATH=/usr/local/cuda/bin:$PATH' >> ~/.bashrc

The same board, the same account, the same file, and the fix that was verified sixty seconds ago is being suggested again. Ask each session what it actually has:

$ ssh glados-jetson 'echo $PATH'
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games
$ ssh glados-jetson 'head -6 ~/.bashrc'
# ~/.bashrc: executed by bash(1) for non-login shells.

# If not running interactively, don't do anything
case $- in
    *i*) ;;
      *) return;;
esac

There it is, in the fourth line of a file Ubuntu wrote before you ever logged in. ssh host 'command' starts a non-interactive shell, and Ubuntu's stock ~/.bashrc returns immediately when the session is not interactive, so every line below that guard, including yours, never runs. Nothing malfunctioned. The edit landed in a file that two kinds of session read differently, and the session that matters is the one your scripts use.

The repair has two halves. Keep the ~/.bashrc line, because typing nvcc by hand should work. Then stop depending on it: the toolkit check already falls back to the absolute path, so make anything else that needs the compiler do the same, and put library paths in /etc/ld.so.conf.d/ where the loader reads them for every process on the board. A systemd unit gets no shell profile at all, so a service that hunts for CUDA on PATH fails in a third way you would have met later, at boot, with no terminal attached.

Keep one habit from this. When a check disagrees with your memory of fixing something, print the environment the check is running in before you touch the fix. Two minutes of echo $PATH beats an afternoon of editing profiles that were never read.

Checkpoint, and the first thing that will lean on the GPU

✓ Checkpoint — what you can now do
  • Given a board where nvidia-smi works and nvcc does not, I can name the lookup each command depends on and say why both results are honest.
  • I can say what subprocess.run(["nvcc"], ...) does on a machine without nvcc, what the shell=True form does instead, and why this verifier uses neither.
  • I know why [N/A] is the correct memory total for an Orin, and what a desktop-shaped parser does with it.
  • I can explain why an /etc/ld.so.conf.d/ entry fixes a library lookup for a boot-time service while a ~/.bashrc export does not.
  • I can read /proc/self/maps to name the exact file behind a library already loaded in a running process.
  • I can say what device="auto" costs on a board where the engine counts zero CUDA devices, and why the failure never prints.
⚡ Exercises — try first, then reveal
Exercise 1 — find out which cuDNN the loader picks. JetPack installs cuDNN under the Tegra library directory, and a stray pip package can install a second copy. Add a layer that loads it and reports the file that won.

The same two moves as the runtime layer: load it, then look in the map.

def check_cudnn() -> Layer:
    layer = Layer("cudnn", "libcudnn loadable by this process")
    try:
        ctypes.CDLL("libcudnn.so.9")
    except OSError as exc:
        layer.detected = str(exc).strip()
        layer.fix = "sudo apt install libcudnn9-cuda-12 && sudo ldconfig"
        return layer
    layer.detected = mapped_file("libcudnn.so")
    layer.ok = True
    return layer
$ uv run python -m labs.verify_cuda   # bench capture, your path may differ
[  OK  ] cudnn    /usr/lib/aarch64-linux-gnu/libcudnn.so.9.3.0

Install nvidia-cudnn-cu12 from pip into the same environment and run it again: the mapped path moves into the virtual environment's site-packages, because a Python wheel that carries its own copy wins once its directory reaches the loader first. One line of output tells you which cuDNN your transcriptions are really using.

Exercise 2 — make the fallback impossible to take. Write require_gpu(), which returns the device string to hand a model and refuses to return anything when a layer is missing.
def require_gpu() -> str:
    """The device string for a model, or a refusal naming the layer that failed."""
    broken = [layer.name for layer in (run_check(fn) for fn in LAYERS) if not layer.ok]
    if broken:
        raise RuntimeError(f"GPU not usable: {', '.join(broken)} failed. Run labs.verify_cuda.")
    return "cuda"


if __name__ == "__main__":
    from faster_whisper import WhisperModel
    model = WhisperModel("base", device=require_gpu(), compute_type="int8")
    print("loaded on", require_gpu())
$ uv run python -m labs.require_gpu   # with the ldconfig fix reverted
Traceback (most recent call last):
  File "/home/glados/GladOS/labs/require_gpu.py", line 24, in <module>
    model = WhisperModel("base", device=require_gpu(), compute_type="int8")
                                        ^^^^^^^^^^^^^
  File "/home/glados/GladOS/labs/require_gpu.py", line 18, in require_gpu
    raise RuntimeError(f"GPU not usable: {', '.join(broken)} failed. Run labs.verify_cuda.")
RuntimeError: GPU not usable: runtime, engine failed. Run labs.verify_cuda.

A crash at startup naming two layers is a better morning than a week of transcriptions that were quietly four times slower. Reverting the fix on purpose is also the only way to know this refusal works.

Exercise 3 (stretch) — run the same verifier from three environments and compare. An interactive shell, a non-interactive SSH command, and a systemd unit each hand a process a different environment.

Run all three and diff the verdicts:

ssh glados-jetson 'echo $PATH'                                  # non-interactive
ssh glados-jetson 'bash -lc "echo \$PATH"'                       # login shell, reads the profile
ssh glados-jetson 'systemd-run --user --wait --pipe /usr/bin/env' | grep ^PATH
$ ssh glados-jetson 'systemd-run --user --wait --pipe /usr/bin/env' | grep ^PATH   # bench capture
PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games

Three environments, three PATH values, and the shortest belongs to the one that will start her at boot. Then point the last invocation at the verifier itself and confirm all four layers still pass under systemd, since that is the environment the finished machine runs in and the only one that has to be right unattended.

The GPU is now provably reachable from a Python process, and the report says which file each layer resolved to. The next piece of the stack to move onto the board is her mind: Ollama installs with one command here and almost never fails, and that is the setting where a quiet fall back to CPU inference goes unnoticed until a reply arrives ten times slower than the hardware could produce it. Catching that takes a number, measured from her own generation timings.