Warmup Guide — Linux Internals, GPU Drivers & Containers
Zero-to-expert primer for Phase 04. Assumes you can use a Linux shell and read C. No kernel programming background assumed — that's what we build. By the end the stack between
cudaLaunchKerneland silicon is glass, not fog.
Table of Contents
- Chapter 1: Kernel Space, User Space, and What a Driver Is
- Chapter 2: Character Devices — /dev, major/minor, file_operations
- Chapter 3: ioctl — The GPU's Actual Control Path
- Chapter 4: The Real NVIDIA Stack — KMD, UMD, Ring Buffers, Doorbells
- Chapter 5: DMA, IOMMU, and Pinned Memory From the Kernel's Side
- Chapter 6: Version Skew — The Driver/CUDA Compatibility Matrix
- Chapter 7: Namespaces — What a Container Actually Is
- Chapter 8: cgroups v2 — Resources, and the GPU-Shaped Hole
- Chapter 9: How a GPU Enters a Container
- Chapter 10: The Debugging Toolkit — strace, dmesg, /proc, /sys
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Kernel Space, User Space, and What a Driver Is
Zero background: the CPU runs in (at least) two privilege modes. Kernel mode can touch hardware, page tables, interrupts; user mode cannot — any attempt traps. Your process lives in user mode; the only doorway into kernel mode is a syscall (or an interrupt/fault). That hard boundary is why "the process crashed" doesn't take the machine down, and why everything hardware-related — including every GPU operation — ultimately funnels through the kernel at least once.
What a driver is, demystified: a driver is not a process or a daemon. It's
a bundle of callbacks registered with the kernel ("when someone open()s
this device, call my open; when they ioctl(), call my ioctl") plus
interrupt handlers ("when the hardware raises IRQ N, call this"). A loadable
kernel module (.ko) is just a relocatable object the kernel links into itself
at runtime — insmod is closer to dlopen than to exec. Lab 01 makes this
concrete: your fakegpu.ko is ~300 lines of callbacks and one struct file_operations.
The cost model that explains GPU driver design: a syscall costs ~100–300 ns plus cache pollution; an interrupt costs more. Drivers for high-rate devices (GPUs, NICs, NVMe) are therefore architected to get the kernel out of the hot path: set up shared memory once, then submit work from user space without syscalls (Ch. 4). Keep this in mind — it's the single design force behind everything in this phase.
Misconception: "the GPU driver runs on the GPU." The driver runs on the CPU, in the kernel; the GPU runs firmware (increasingly — GSP, Ch. 4) and your kernels. The driver's job is bookkeeping, memory management, scheduling, and shoveling command buffers.
Chapter 2: Character Devices — /dev, major/minor, file_operations
Unix's grand unification: devices are files. open("/dev/nvidia0") returns
an fd; read/write/ioctl/mmap on that fd dispatch into driver callbacks.
The plumbing, end to end:
- A character device ("char dev") is the streams-of-bytes/commands flavor (vs block devices, which are the page-cached storage flavor). GPUs are char devices.
- Each device has a major number (selects the driver) and minor number
(selects the instance —
/dev/nvidia0vs/dev/nvidia1).ls -l /dev/nvidia*shows them (major 195 for NVIDIA, historically). /devnodes are created by udev at hotplug (ormknodby hand, ordevtmpfs). A node is just a (type, major, minor) triple in a filesystem — this fact is load-bearing for containers (Ch. 9): injecting a GPU into a container is largely creating/allowing this node inside it.- In the driver, you register a
struct file_operations:
static const struct file_operations fakegpu_fops = {
.owner = THIS_MODULE,
.open = fakegpu_open, // allocate per-fd state — "a context"
.release = fakegpu_release, // free it
.unlocked_ioctl = fakegpu_ioctl, // the command path
.mmap = fakegpu_mmap, // shared-memory path (extension)
};
The idea to carry away: per-open-file state. Each open() gets its own
context structure (Lab 01 hangs a buffer registry and job queue off it). This is
precisely what a CUDA context is at the driver level — and why two processes
on one GPU don't see each other's allocations (and why a poisoned context dies
alone, Phase 02 Ch. 4).
Chapter 3: ioctl — The GPU's Actual Control Path
read/write suit byte streams. Devices need commands: "allocate 2 GB,"
"map this buffer," "submit this command stream." The escape hatch is
ioctl(fd, CMD, arg) — a generic "do device-specific thing," where CMD
encodes direction/size/type and arg points to a command struct copied across
the boundary.
The ABI discipline (Lab 01's core lesson):
struct fakegpu_alloc { __u64 size; __u32 handle; /* out */ };
#define FAKEGPU_IOC_ALLOC _IOWR('F', 1, struct fakegpu_alloc)
// kernel side — NEVER dereference user pointers:
if (copy_from_user(&req, (void __user *)arg, sizeof(req))) return -EFAULT;
...validate everything...
if (copy_to_user((void __user *)arg, &req, sizeof(req))) return -EFAULT;
copy_from_user/copy_to_user are the checked crossings; validation is
mandatory because user space is adversarial (a security boundary you'll
revisit in Phase 11). Handles — small integers the driver maps to kernel-side
objects — instead of raw pointers, for the same reason fds exist.
Proof that this is real: strace any CUDA program:
openat(AT_FDCWD, "/dev/nvidiactl", O_RDWR) = 3
ioctl(3, _IOC(_IOC_READ|_IOC_WRITE, 0x46, 0x2a, 0x20), 0x7ffd...) = 0
openat(AT_FDCWD, "/dev/nvidia0", O_RDWR) = 4
ioctl(4, ...) = 0 // dozens at init
mmap(NULL, 0x200000, ..., fd=4, ...) = 0x7f... // ring buffers!
Initialization is a flurry of ioctls; then it goes quiet — because the steady state uses the mmap'd path (next chapter). Lab 01 step 3 has you place these two traces side by side.
Chapter 4: The Real NVIDIA Stack — KMD, UMD, Ring Buffers, Doorbells
The division of labor:
- KMD (kernel-mode driver) —
nvidia.ko(+nvidia-uvm.kofor unified memory,nvidia-modeset.kofor display): privileged setup. Memory management (GPU page tables!), context creation, channel/ring allocation, interrupt handling, recovery (Xid errors — Phase 10), power. - UMD (user-mode driver) —
libcuda.so(the driver API from Phase 02 Ch. 1 lives here): everything per-launch. Builds command buffers in memory shared with the GPU, manages your virtual address space, JIT-compiles PTX (Phase 03 Ch. 7!), implements the CUDA API surface.
The hot path — why launches take ~5 µs, not ~5 ms:
- At context setup, the KMD maps a ring buffer (command queue) and a
doorbell register into the process's address space (those
mmaps in the strace). - To launch a kernel,
libcuda.sowrites the command into the ring (a memory write!) and writes the doorbell (another memory write, which the hardware sees) — zero syscalls. - The GPU's front end fetches commands, executes, and writes a fence
(completion marker) back to shared memory; the UMD polls or sleeps on an
interrupt for it. (
cudaEventSynchronize= wait-on-fence.)
This user-space-submission pattern is universal in high-performance I/O — NVMe
queues, io_uring, DPDK NICs, every modern GPU (AMD's is in the open
amdgpu driver — same shapes, readable source). Your Lab 01 extension (mmap'd
completion ring) implements the fence half.
Two modern wrinkles worth knowing: GSP firmware — recent NVIDIA drivers offload much KMD logic to a RISC-V management processor on the GPU, shrinking kernel-side code (and enabling the open-gpu-kernel-modules release: the kernel module is now MIT/GPL dual-licensed and on GitHub — readable!); the proprietary crown jewels moved to firmware + UMD. And nouveau — the reverse-engineered FOSS driver — historically lacked the firmware interfaces for reclocking, hence its performance gap; GSP is slowly changing that equation.
Chapter 5: DMA, IOMMU, and Pinned Memory From the Kernel's Side
Phase 02 Ch. 5 told you pinned memory makes transfers ~2× faster and truly async. Now the mechanism:
- DMA: the GPU's copy engines read/write host RAM directly, without the CPU touching the data. To do that safely, the physical pages must (a) stay resident — not be swapped or migrated — and (b) be known to the device.
- Pinning (
cudaMallocHost→get_user_pages/page-locking in the kernel) guarantees (a). For pageable memory the driver must instead copy through an internal pre-pinned bounce buffer — there's your 2× and your fake-async. - IOMMU provides (b)-with-safety: it's a page table for devices,
translating device-visible addresses and blocking DMA outside allowed ranges.
Cloud passthrough (vfio), confidential computing, and "why does
iommu=offmake it faster but scarier" all live here. (Security relevance returns in Phase 11: DMA from a malicious device is a classic attack class.)
Chapter 6: Version Skew — The Driver/CUDA Compatibility Matrix
The unglamorous chapter that pages people at 2 a.m.:
- One kernel module serves all processes; each container/app brings its own
CUDA toolkit/runtime. The contract: driver ≥ what the toolkit needs.
nvidia-smi's "CUDA Version" = the max the driver supports, not what's installed. - Failure signatures to recognize on sight:
CUDA driver version is insufficient for CUDA runtime version(driver too old);forward compatibility was attempted on non supported HW(compat package misuse); a container that worked on node A failing on node B (driver skew across the fleet); kernel upgrade → module won't load → DKMS didn't rebuild (or secure-boot signing failed — Phase 11 connection). - Fleet upgrade choreography (the head-of-engineering deliverable): inventory driver/toolkit pairs → canary nodes → drain GPU workloads (Phase 06 scheduler integration) → upgrade KMD (reboot or careful unload) → health-check (Phase 10's checks) → progressive rollout. The forward-compatibility packages (newer libcuda on older driver branch, datacenter GPUs only) buy flexibility; pinning exact driver versions in node images buys reproducibility. Pick a policy and write it down — Lab 02's explainer and Phase 10's release engineering both cite this chapter.
Chapter 7: Namespaces — What a Container Actually Is
There is no container object in the kernel. "Container" = a process started with unshared namespaces + resource limits (Ch. 8) + (usually) reduced privileges. Namespaces virtualize visibility:
| Namespace | Isolates | Demo |
|---|---|---|
| mount | filesystem tree | different / via pivot_root |
| PID | process IDs | your shell is PID 1; can't see host |
| net | interfaces, ports | empty ip a until you add a veth |
| UTS | host/domain name | hostname container1 |
| IPC | SysV/POSIX queues | |
| user | UID mappings | root inside = UID 100000 outside (rootless) |
| cgroup | cgroup tree view |
One syscall family creates them: clone(CLONE_NEWPID | CLONE_NEWNS | ...) /
unshare(2). Lab 02's runtime is ~200 lines because that's genuinely all it
takes: clone with flags, set hostname, pivot_root into a rootfs, mount fresh
/proc, drop into a shell. Docker/Podman/containerd add image management,
networking, and an API — the isolation itself is these kernel primitives.
Why this matters for a GPU platform: every scheduling, isolation, and security promise you make to customers (Phase 06, 11) bottoms out in which namespaces/cgroups/devices a workload got. You can't reason about "can tenant A see tenant B's model weights" without this chapter.
Chapter 8: cgroups v2 — Resources, and the GPU-Shaped Hole
Namespaces control what a process sees; cgroups control what it uses.
v2 is a unified tree mounted at /sys/fs/cgroup; directories are groups; writes
to control files set limits:
mkdir /sys/fs/cgroup/demo
echo 100M > /sys/fs/cgroup/demo/memory.max
echo "50000 100000" > /sys/fs/cgroup/demo/cpu.max # 0.5 CPU
echo $$ > /sys/fs/cgroup/demo/cgroup.procs # join
Exceed memory.max → the OOM killer fires inside the group (Lab 02 step 2
demonstrates). The devices controller (an eBPF program in v2) allow-lists
device-node access — the enforcement half of GPU injection (Ch. 9).
The GPU-shaped hole — say this sentence in interviews: there is no GPU
cgroup controller. No gpu.max, no kernel-enforced fraction of SMs or HBM.
Consequences:
- "GPU limits" in k8s are allocation (whole devices via the device plugin), not enforcement.
- Actual sharing requires GPU-side mechanisms — MPS (cooperative, weak isolation), MIG (hardware partition, strong), time-slicing (none) — Phase 06's entire subject matter, motivated by this one missing kernel feature.
- Vendors building "fractional GPU" products (and this JD's company likely among them) are filling exactly this hole with userspace interception or scheduler tricks — knowing why the hole exists (GPU contexts are opaque to the kernel; the KMD, not the scheduler, owns GPU time) is what lets you evaluate those products honestly.
Chapter 9: How a GPU Enters a Container
Now compose Chapters 2, 7, 8 — the full anatomy, which Lab 02 step 3 replicates manually:
A container needs three things to use a GPU:
- The device nodes:
/dev/nvidia0,/dev/nvidiactl,/dev/nvidia-uvmvisible in its mount namespace (bind-mount or mknod), and allowed by the devices cgroup/eBPF program. - The user-mode driver libraries:
libcuda.somatching the host kernel module (Ch. 6!). The container image cannot ship these — it would skew against the host KMD. So they're injected from the host at start. - No special kernel work — the KMD is shared; namespaces don't apply to it.
nvidia-container-toolkit is precisely this: an OCI hook (now CDI spec) that,
at container creation, mounts the device nodes + host driver libs +
nvidia-smi et al. into the container and writes the cgroup allow rule.
CUDA_VISIBLE_DEVICES (or NVIDIA_VISIBLE_DEVICES at the hook level) then
filters which minors get this treatment — note carefully: visibility, not
security — a process that can mknod or escape the allow-list can reach other
GPUs; the cgroup rule is the actual enforcement, and per-tenant isolation worthy
of the name means dedicated devices or MIG (Phase 06) plus the Phase 11 story.
Debugging ladder for "container can't see GPU" (memorize; it's a weekly
occurrence somewhere in any fleet): node has driver? (nvidia-smi on host) →
toolkit hook configured? (runtime config) → nodes present in container?
(ls /dev/nvidia*) → libs injected & matching? (ldconfig -p | grep cuda,
compare versions) → cgroup allows? → only then blame the app.
Chapter 10: The Debugging Toolkit — strace, dmesg, /proc, /sys
The five tools that solve most layer-4 mysteries:
strace -f -e trace=openat,ioctl,mmap— what is the process actually asking the kernel? (CUDA init failures become obvious: which open/ioctl returned what errno.)dmesg -T— the kernel's side of the story: module load errors, Xid errors (the GPU's error codes: Xid 79 = fell off the bus, Xid 48 = ECC DBE… Phase 10 builds alerting on these), OOM kills, IOMMU faults./proc/PID/—maps(see the mmap'd rings!),fd/(which devices are open),status,cgroup./sys— device topology (/sys/bus/pci/devices/...), cgroup tree, module params (/sys/module/nvidia/).lsof /dev/nvidia*— who's holding the GPU (the "why won't the driver unload" question).
Lab 01 step 3 and Lab 02's debugging exercises are drills on exactly these.
Lab Walkthrough Guidance
Order: Lab 01 → Lab 02 (Lab 02 injects Lab 01's device into a container — the punchline depends on both).
Lab 01 (fakegpu driver):
- Read
fakegpu.ctop-down against Ch. 2–3 before building; predict whatdmesgwill print at insmod. - Build/load (any Linux VM; headers via
apt install linux-headers-$(uname -r)). Run./client; all asserts PASS. strace ./client→ annotate against the chapter-3 CUDA trace. Two columns, same shape — that's the lab's thesis made visible.- Extensions in order: quotas (validation discipline) → timer-based async completion (interrupt/fence flavor) → mmap'd completion ring (the real pattern; hardest).
- Safety: it's a VM, snapshot first; a buggy module can panic the kernel — which is itself the lesson about why KMDs are small and UMDs are big.
Lab 02 (mini container runtime):
- Run first, read after:
sudo ./mininer run /bin/sh, poke around (ps,hostname,ls /), exit, then readmininer.cagainst Ch. 7. - cgroup demo: apply
memory.max, run the includedhog, watch the group OOM while the host stays healthy. - The synthesis step: inject
/dev/fakegpuinto the container (bind-mount + devices allow) and run Lab 01's client inside. When that passes, write the explainer — you have personally performed what nvidia-container-toolkit automates.
Success Criteria
- You can define "driver" as callbacks + interrupts, and narrate open→ioctl→copy_from_user→validate→copy_to_user (Ch. 1–3)
- You can explain why steady-state GPU submission uses zero syscalls (rings + doorbells + fences) and what the KMD vs UMD each own (Ch. 4)
- You can diagnose the four version-skew signatures and sketch the fleet upgrade choreography (Ch. 6)
- You can build a container from syscalls and state precisely what namespaces, cgroups, and capabilities each contribute (Ch. 7–8)
- You can recite the GPU-into-container anatomy and its debugging ladder, and you've performed it by hand (Ch. 9)
- "There is no GPU cgroup controller" — and you can derive Phase 06 from it
Interview Q&A
Q1: What happens at the OS level when a CUDA app starts and launches a kernel?
A: Init: libcuda.so opens /dev/nvidiactl and /dev/nvidia0, issues a series
of ioctls to create a context — the KMD allocates GPU page tables, a command
ring, and maps the ring plus a doorbell into the process (visible as mmaps in
strace). It also JIT-compiles any PTX lacking a matching SASS (Phase 03). Launch:
the UMD writes the launch command into the mapped ring and taps the doorbell —
two memory writes, no syscall — the GPU front end fetches, executes, and writes a
fence; synchronization waits on that fence (poll or interrupt). That's why
launches cost microseconds and why strace goes quiet after init.
Q2: Why doesn't CUDA_VISIBLE_DEVICES provide isolation?
A: It's an environment variable read by the CUDA library in the untrusted
process — it filters enumeration, nothing more; the process can unset it, and
the device nodes are what they are. Enforcement lives lower: the devices
cgroup/eBPF allow-list controls which nodes can be opened (what
nvidia-container-toolkit configures), and even that only gates access to a
whole device — within a shared GPU there's no kernel-enforced resource split
because no GPU cgroup controller exists. Real isolation: dedicated GPU per
tenant, or MIG hardware partitions, plus process-level (not context-level)
multi-tenancy. The env var is UX; the cgroup is policy; MIG is enforcement.
Q3: A container sees /dev/nvidia0 but cudaInit fails. Debug.
A: Ladder: (1) host sanity — nvidia-smi on the node (driver loaded? GPU
healthy? check dmesg for Xids). (2) Library injection — ldconfig -p | grep libcuda inside the container; missing → toolkit hook not configured for this
runtime; present → compare its version to the host KMD (nvidia-smi driver
version) for skew, the classic "image ships its own libcuda" bug.
(3) Permissions — devices cgroup allow rule, node major/minor match, non-root
user in the right group. (4) The other nodes — nvidia-uvm missing breaks
managed memory only, nvidiactl missing breaks everything. (5) strace the
init and read which exact open/ioctl fails with which errno — it almost always
names the culprit directly.
Q4: Why do GPU drivers split into a kernel module and libcuda.so, and why is the kernel part getting smaller? A: The split follows the privilege/performance line: things requiring kernel mode (page tables, interrupts, PCI config, cross-process arbitration) sit in the KMD; everything per-launch sits in user space for speed (ring submission) and shipability (a library updates with the toolkit; a kernel module update risks the host). The KMD is shrinking because GSP firmware on the GPU's own RISC-V core absorbed initialization and management logic — less GPL-adjacent kernel code (enabling NVIDIA's open kernel modules), faster context ops, and the proprietary logic now lives in firmware+UMD where it's both protected and updatable. Same pattern as NVMe/io_uring/DPDK: kernel for setup and safety, user space for the data path, firmware for vendor secrets.
Q5: Design the ioctl ABI for a simple accelerator. What discipline applies?
A: Fixed-size, explicitly-padded structs (the ABI is forever — version field or
reserved fields for growth); _IOWR macros encoding direction and size;
copy_from_user, then validate every field against per-context state
(handles not pointers, bounds on sizes, quota checks) because user space is
adversarial; return handles, never kernel addresses; per-fd context so
isolation falls out of the file model; errno discipline (-EINVAL vs -ENOMEM vs
-EPERM mean different things to the caller's retry logic). And keep the hot
path out of ioctl entirely — submission belongs in a mapped ring once setup
is done (Lab 01's extension, and every real GPU's design).
Q6 (leadership): Plan a driver upgrade across a 10,000-GPU inference fleet.
A: Inventory first: current driver/toolkit pairs per node pool, and every
container image's CUDA runtime requirement — build the compatibility matrix
before touching anything (driver must satisfy the max toolkit; forward-compat
packages where datacenter GPUs allow). Canary: one node pool, full Phase 10
health suite (Xid monitoring, perf regression benchmarks — driver upgrades have
shipped perf regressions). Choreography per node: cordon → drain via the
scheduler (Phase 06; respect long-running jobs with checkpointing policy) →
upgrade KMD + container toolkit together → reboot (cleanest; hot-unload is
fragile with GPUs) → health-check → uncordon. Progressive rollout with
automatic halt on Xid-rate or perf anomaly. Rollback path tested before
rollout, image-pinned node pools so a rollback is a re-image, not an
archaeology dig. And the boring decisive control: driver versions pinned in
infrastructure-as-code, never apt upgrade'd by hand.
References
- Linux Device Drivers, 3rd ed. (Corbet, Rubini, Kroah-Hartman) — free online; Ch. 1–3, 6 map directly to Lab 01 — https://lwn.net/Kernel/LDD3/
- The kernel's own docs: The Linux Kernel documentation — driver-api, cgroup-v2 — https://docs.kernel.org
- NVIDIA open GPU kernel modules (read a real KMD!) — https://github.com/NVIDIA/open-gpu-kernel-modules
- NVIDIA Container Toolkit architecture + CDI spec — https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/
- NVIDIA CUDA Compatibility (the skew matrix) — https://docs.nvidia.com/deploy/cuda-compatibility/
- NVIDIA Xid errors reference — https://docs.nvidia.com/deploy/xid-errors/
man 2 clone,man 7 namespaces,man 7 cgroups— genuinely excellent- Liz Rice, "Containers from Scratch" (the famous live demo) — https://www.youtube.com/watch?v=8fi7uSYlOdc
- freedesktop.org DRM/amdgpu docs (the open-source GPU driver world) — https://docs.kernel.org/gpu/
- Cross-track: Phase 06 WARMUP (what fills the cgroup hole), security track (kernel/container hardening at depth)