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.
# 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.
# 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.
# 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.
# 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.
# 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.
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
- Given a board where
nvidia-smiworks andnvccdoes 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 theshell=Trueform 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~/.bashrcexport does not. - I can read
/proc/self/mapsto 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.
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.