← Course index Module 3 of 6
Module 3 · OS fundamentals for AI systems

The CPU side: processes, threads, scheduling

The GPU only runs what a CPU thread tells it to, when that thread gets to run. This module covers how the kernel decides who runs, what it costs to cross into the kernel and back, and why one descheduled Python thread can leave an H100 idle.

≈35 min reading2 interactive diagramsDrill ≈25 minTeaches criterion #4 (DataLoader memory), plus GPU starvation and fork safety

1The user/kernel boundary

What it is. The CPU runs in two privilege levels that matter here. Your code runs in user mode (ring 3 on x86, EL0 on ARM64). The kernel runs in kernel mode (ring 0 / EL1), where it can touch page tables, devices and other processes' memory. User code can't simply call into the kernel. It must use a controlled entry: a system call (deliberate), an exception such as a page fault (accidental), or an interrupt (a device or timer, unrelated to what's running).

Fig 1 · Three ways into the kernel
User mode (ring 3 / EL0) python · libtorch · libcuda (most of the CUDA driver runs here!) · NCCL · your threads can only touch its own mapped pages; no device registers except ones mapped to it (GPU doorbells, RDMA queues) Kernel mode (ring 0 / EL1) scheduler · page-fault handler · VFS + page cache · TCP/IP · nvidia.ko (memory mgmt, interrupts) · drivers one kernel stack per thread; the same page tables (kernel half is mapped in every process) syscall / svc (read, ioctl, futex…) exception (page fault, #DE) interrupt
Why it matters for AI workloads: the CUDA hot path is designed to avoid the kernel. libcuda writes commands into a ring buffer mapped into your process and rings a doorbell register on the GPU, also mapped in, so a kernel launch is usually zero syscalls. RDMA does the same for the NIC (Module 4). Anything that does enter the kernel (allocations, cudaHostAlloc, blocking syncs, file I/O, sockets) costs at least hundreds of ns and might put your thread to sleep.
Where this shows up in your stack Under strace a training step is dominated by futex (threads waiting on each other), ioctl (the CUDA driver talking to /dev/nvidia* for allocations, events and some syncs), poll/read (DataLoader queues) and mmap/munmap (allocators). If you see thousands of ioctls per step, something is allocating or synchronizing in the loop.

2Syscalls and context switches, step by step

How a syscall works. libc puts the syscall number and arguments in registers and executes syscall (x86-64) or svc #0 (ARM64). The CPU switches to kernel mode and jumps to a fixed entry point. The kernel saves user registers on the thread's kernel stack, runs the handler, and returns with sysret / eret. A trivial syscall costs ≈100–300 ns round trip, more with Spectre/Meltdown mitigations. Some calls never enter the kernel: clock_gettime reads a kernel-maintained page (the vDSO) mapped into every process.

How a context switch works. When a thread blocks (waits for data, a lock, I/O) or uses up its time slice, the kernel calls schedule(), which picks the next runnable thread on that CPU. It saves the old thread's registers and stack pointer and loads the new one's. If the new thread belongs to another process, it also switches the page-table root (CR3 / TTBR0), and the TLB entries tagged with the old address space become useless. The direct cost is ≈1–5 µs. The bigger cost is indirect: the new thread starts with cold caches and TLB, and the old thread finds them cold when it returns.

Fig 2 · A blocking read, at the register level interactive
USER MODE
KERNEL MODE
x86-64 Linux names. On ARM64: svc instead of syscall, x8 holds the syscall number, TTBR0_EL1 plays the role of CR3, and the return is eret. Thread T1 is your trainer's main thread and T2 is a DataLoader worker process.
Why it matters for AI workloads: a thread that blocks gives up the CPU, and getting it back needs a wake-up and the scheduler's agreement: microseconds in the best case, a whole time slice (milliseconds) if the CPU is busy with someone else. That's the gap between "the data was ready" and "the thread noticed". It's why latency-critical code (NCCL proxies, RDMA progress, inference schedulers) busy-polls instead of blocking.
Where this shows up in your stack /proc/PID/status has voluntary_ctxt_switches (blocked by choice: waiting on futex or I/O) and nonvoluntary_ctxt_switches (preempted: someone else needed the CPU). A climbing involuntary count on your launch thread means it's sharing a core. pidstat -wt 1 shows both per thread. cudaDeviceScheduleBlockingSync turns GPU waits into voluntary sleeps, trading CPU for wake-up latency.

3Processes and threads

What it is. Linux schedules tasks. A process is a group of tasks that share one address space (page tables), file-descriptor table and signal handlers. A thread is another task in that group. Both are created by clone(): flags decide what's shared. Each thread has its own registers, stack and kernel stack, and is scheduled independently.

Threads (one process)Processes
Memoryshared: same page tablesseparate (COW after fork, or explicit shared memory)
Switch costno CR3 change, TLB stays warmCR3 change, colder TLB/caches
Pythonone GIL: one thread runs Python bytecode at a time (except free-threaded 3.13t/3.14t builds)one interpreter each: true parallelism
Failurea segfault kills all threadsisolated
In your stackmain thread, pin-memory thread, NCCL proxy/watchdog threads, CUDA driver threads, intra-op pools (OpenMP)torchrun ranks, DataLoader workers, inference worker pools
Why it matters for AI workloads: a single PyTorch rank is easily 50–100 threads: OpenMP/MKL intra-op threads (one per core by default), the CUDA driver's helpers, NCCL's proxy and watchdog, and the DataLoader's pin-memory thread. Eight ranks per node, each with a default-sized OpenMP pool, means hundreds of runnable threads competing for the cores the launch threads need.
Where this shows up in your stack torchrun sets OMP_NUM_THREADS=1 per process by default when launching several ranks per node, precisely to avoid that oversubscription. Raise it deliberately (≈ cores per rank), not to the core count. ps -T -p PID or top -H -p PID lists the threads, and their names (pt_main_thread, pt_autograd_0, NCCL threads) tell you who is who.

4Futexes: locks that only sometimes enter the kernel

What it is. A futex ("fast userspace mutex") is a 32-bit integer in shared memory plus a kernel wait queue keyed by its address. Uncontended lock/unlock is a single atomic instruction in user space, with no syscall. Only when a thread must wait does it call futex(FUTEX_WAIT) to sleep. The releasing thread calls futex(FUTEX_WAKE) only if someone is waiting. Every pthread mutex, condition variable and semaphore on Linux, and therefore Python's GIL and queue.Queue, is built on futexes.

Why it matters for AI workloads: in a strace -c of a training step, futex is usually #1 by time. That isn't the futex's cost. It's threads waiting: the main thread waiting for the next batch, the pin-memory thread waiting for a worker, the GIL being handed over. Heavy futex time tells you who's blocked, not what's slow. On your box, futex accounted for 58% of time spent in syscalls, summed across all threads.
Where this shows up in your stack GIL hand-offs between the training loop and Python helper threads (pin-memory, logging, metrics) show up as futex waits and as gaps in the launch stream. Python's switch interval is 5 ms (sys.getswitchinterval()): a thread holding the GIL can delay your launch thread by up to that long. nsys with --trace=osrt shows these as pthread_mutex_lock / pthread_cond_wait ranges.

5fork vs spawn vs forkserver, and why fork after CUDA init breaks

What it is. Python's multiprocessing can start a child three ways:

Start methodHowStartupGotchas
forkfork() the current process: the child is a COW clone of the parent, including all memory and handles, but only the calling threadfastother threads vanish mid-whatever; locks they held stay locked forever; CUDA/NCCL state is copied but meaningless
spawnstart a fresh interpreter, then unpickle the target and its argumentsslow (re-imports torch: seconds)dataset must be picklable and is copied per worker
forkserverat startup, spawn one clean, single-threaded server process; later children are forked from itfast after the firstarguments must be picklable; the server must be started before CUDA init and before threads exist

Why forking after CUDA init is unsafe. A CUDA context is far more than memory: driver threads, open file descriptors to /dev/nvidia*, GPU-side page tables keyed to the parent, pinned host mappings, and locks inside libcuda. fork() copies the parent's memory (COW) but only one thread, and none of the GPU-side state is duplicated. The child sees a context that claims to exist but whose helper threads are gone and whose locks may be held forever. CUDA doesn't support using it, and PyTorch refuses outright: RuntimeError: Cannot re-initialize CUDA in forked subprocess. More generally, POSIX only guarantees async-signal-safe calls in the child of a multithreaded parent until it calls exec. NCCL, OpenMP runtimes and logging libraries all have threads, so the same hazard applies.

Why it matters for AI workloads (completion criterion #4, process half): DataLoader workers default to fork on Linux for Python ≤ 3.13. That's fast, and it's the reason dataset memory is shared copy-on-write, and the reason it slowly un-shares as refcount writes copy pages (Module 2 §7). It works only because workers never touch CUDA. Python 3.14 changed the Linux default to forkserver. That's safer, but your Dataset now has to be picklable and is sent to each worker, so per-worker memory and startup behave differently. Check which interpreter your containers use.
Where this shows up in your stack DataLoader(multiprocessing_context="fork" | "forkserver" | "spawn") pins the choice explicitly. torch.multiprocessing is a wrapper that shares tensors through shared memory (/dev/shm or fd passing; that's the accept4/recvmsg calls in the drill's strace) instead of pickling their bytes. torchrun starts ranks with a fresh exec, which is why ranks can each init CUDA safely.

6The Linux scheduler: EEVDF

What it is. Each CPU has a runqueue of runnable tasks. Since Linux 6.6 the default policy for normal tasks (SCHED_OTHER) is EEVDF (Earliest Eligible Virtual Deadline First), which replaced CFS's picking logic. Your x86 box runs 6.8. Recent DGX OS kernels for the Spark are 6.17 and later, so both use EEVDF.

How it works, in the parts that matter:

  • Fair share by weight. Each task accrues virtual runtime in proportion to its CPU use divided by its weight (from its nice value). Lag is how much CPU a task is owed versus its fair share. Tasks with lag ≥ 0 are eligible.
  • Deadlines. Each task requests a slice (a base of 0.75 ms, scaled up on bigger machines to about 3 ms). Its virtual deadline is eligible time + slice / weight. The scheduler runs the eligible task with the earliest virtual deadline. Short requested slices give better latency, and that's the knob EEVDF adds over CFS (sched_setattr's sched_runtime).
  • Preemption. A running task is preempted at the end of its slice if someone else is eligible with an earlier deadline. A wakeup can preempt sooner. If 33 CPU-hungry tasks share 32 CPUs, some runnable task is always waiting a full slice.
  • Load balancing moves tasks between CPUs' runqueues periodically and on idle. A migrated task lands on cold caches, possibly on an E-core, or on a CPU in another cluster or NUMA node.
  • Other classes: SCHED_FIFO / SCHED_RR (real-time: always beat normal tasks) and SCHED_IDLE. Since 6.12, sched_ext lets a BPF program implement the scheduling policy.
Fig 3 · One CPU's runqueue under EEVDF
virtual time → V (average vruntime): eligible if your vruntime ≤ V launch thread (deadline) DataLoader worker 3 OpenMP thread (rank 5) logger (not eligible: ran too much) ← runs next: eligible + earliest deadline
Bars run from each task's eligible time to its virtual deadline. Simplified: real lag and vruntime bookkeeping is per-entity and weighted.
Why it matters for AI workloads: the scheduler is fair, and your launch thread doesn't want fairness. It wants to run right now, every few µs. On an oversubscribed node (DataLoader workers, OpenMP pools, other ranks, monitoring agents) the launch thread will regularly wait out someone else's slice. That's milliseconds, while a GPU kernel lasts microseconds.
Where this shows up in your stack Fixes, in order of preference: don't oversubscribe (OMP_NUM_THREADS, num_workers), give the launch thread its own cores (affinity, §9), and reduce how often it needs to run (CUDA graphs, §8). Raising its priority (nice -n -5, or chrt for real-time) helps but can starve the helpers it depends on. Use it surgically.

7Why a descheduled launch thread starves the GPU

The mechanism. The CPU enqueues kernels into a stream, and the GPU drains them. The queue is a buffer between the two. If each kernel runs longer than it takes to launch, the queue fills up, and a CPU hiccup is absorbed as long as the queue holds enough work to cover it. If kernels are shorter than the launch cost (small decode steps, small-batch inference, many elementwise ops in eager mode), the queue stays near empty. The GPU then runs at the CPU's pace, and every CPU stall becomes GPU idle time, one for one.

Fig 4 · CPU launch thread vs GPU timeline interactive
A deterministic simulation: launch cost 5 µs per kernel, a 120 µs deschedule where marked, and a 10 µs single graph launch. Your measured 4090 numbers: ≈2.3 µs per eager x.add_ launch vs ≈0.9 µs of GPU time per kernel. Stepping moves the cursor through time.
Why it matters for AI workloads: measured in the drill on your 4090, 2,000 tiny kernels took 1.77 ms of GPU time. Eager launching took 4.7 ms on a quiet machine (63% GPU idle) and 21 ms with CPU noise on every core (92% idle). Pinning the launch thread to a quiet core brought it back to 5.4 ms, and a CUDA graph ran in 1.7 ms in every case. The GPU was never the bottleneck: the CPU thread feeding it was.
Where this shows up in your stack In nsys, look for gaps between kernels on the GPU row that line up with CPU-side gaps in the cudaLaunchKernel row. OS runtime tracing (--trace=osrt) and CPU sampling show the thread blocked or descheduled. LLM decode with small batches is the classic case, which is why vLLM, SGLang and TensorRT-LLM capture decode steps as CUDA graphs. With large training kernels the queue absorbs most hiccups, until a collective's straggler effect turns one rank's hiccup into everyone's wait.

8How CUDA graphs help

What it is. A CUDA graph records a sequence of kernel launches, copies and dependencies once (stream capture), then replays the whole thing with a single launch. The driver pre-validates and pre-builds the GPU work, so per-kernel CPU cost drops to near zero and the GPU walks the graph back to back.

Why it fixes jitter. The CPU's job becomes one launch per step instead of thousands. A deschedule in the middle of the step no longer matters: the whole step is already on the GPU. You still pay jitter between replays, so if steps are tiny, capture several.

Constraints. Graphs replay fixed memory addresses and shapes: inputs must be copied into static buffers, dynamic shapes need one graph per shape bucket, CPU-side logic and synchronizations inside the step aren't allowed, and allocations during capture come from a private memory pool.

Where this shows up in your stack torch.cuda.graph(g) / g.replay() directly; torch.compile(mode="reduce-overhead") captures automatically (CUDA graph trees); torch.cuda.make_graphed_callables for parts of a training step. NCCL collectives can be captured too. For the CPU side that remains, combine graphs with pinning the launch thread.

9CPU affinity, isolcpus, IRQ affinity

CPU affinity restricts which CPUs a thread may run on (sched_setaffinity, taskset -c, numactl --cpunodebind, a cgroup cpuset). It keeps a thread on its GPU's NUMA node and away from noisy neighbours. Affinity only says where a thread may run. Other threads can still be scheduled onto the same CPUs unless you keep them out.

isolcpus= (kernel boot parameter) removes CPUs from the general scheduler: nothing runs there unless explicitly pinned. Usually combined with nohz_full= (stop the periodic timer tick when only one task runs) and rcu_nocbs= (move RCU callbacks off those CPUs). Isolated CPUs give a pinned thread a core that nobody else touches. Many clusters do the same thing more flexibly with cgroup cpusets.

IRQ affinity. Device interrupts run on some CPU, stealing it from whatever was there. /proc/interrupts shows counts per CPU, and /proc/irq/N/smp_affinity_list controls where IRQ N may land. irqbalance moves them around automatically. NVMe and many NIC drivers use managed per-queue IRQs spread over all CPUs. For latency-critical cores, steer NIC/GPU interrupts elsewhere: to a housekeeping core, on the device's own NUMA node.

Fig 5 · A sane per-node layout (one socket shown)
NUMA node 0: cores 0–31 (GPUs 0–3, NIC 0–1 attached) 0–1housekeep 2–5launch threads, ranks 0–3 6–7NCCL proxies 8–29DataLoader workers, OpenMP, pin-memory 30–31NIC/NVMe IRQs • launch + proxy cores: isolcpus or an exclusive cpuset, nohz_full, no IRQs, and their SMT siblings left idle too • memory for each rank: numactl --membind=0 (pinned buffers, page cache for its shards) • the same layout mirrored on node 1 for GPUs 4–7. Check against nvidia-smi topo -m before copying any numbers
Why it matters for AI workloads: the drill shows the SMT trap. Pinning the launch thread to CPU 0 while noise ran on CPU 1, its hyperthread sibling, still cost 1.9×: 8.9 ms vs 4.7 ms. Keeping the whole physical core quiet got it to 5.4 ms. Two hyperthreads share one core's execution units and L1/L2. Isolate cores, not logical CPUs.
Where this shows up in your stack Slurm (--cpu-bind, --hint=nomultithread), Kubernetes' static CPU manager (exclusive cores for Guaranteed pods) and per-rank numactl wrappers are where you implement this. NCCL inherits the affinity it starts with. On the Spark, consider the X925 vs A725 split: the launch thread belongs on an X925 core (the drill's cpu_capacity list shows which CPUs those are).

DDrill: syscalls, the COW "leak", launch jitter

bash drills/m3_cpu.sh syscalls   # strace -f -c of a small training loop
bash drills/m3_cpu.sh cow        # DataLoader workers: list-of-objects vs numpy storage
bash drills/m3_cpu.sh jitter     # quiet / noisy / pinned launch threads, + CUDA graph

# lower-overhead syscall view (root):
sudo perf trace -s python3 drills/m3_train_step.py
# full timeline with OS runtime events:
nsys profile --trace=cuda,nvtx,osrt -o m3 python3 drills/m3_train_step.py

Expected output

x86 + RTX 4090 measured on your box

strace -c (top):
 57.96%  futex   9649 calls
 28.74%  poll     195
  4.61%  ioctl   3115
  1.83%  accept4  128   (fd passing)

cow --storage list (4 workers):
  USS per worker  60 -> 439 MiB
  sum USS         240 -> 1756 MiB
cow --storage numpy:  8 MiB, flat

jitter (2000 tiny kernels, GPU 1.77 ms):
  quiet           4.74 ms  (63% idle)
  noise all CPUs 21.04 ms  (92% idle)
  pin 0, sib noisy 8.92 ms
  pin 0, core quiet 5.35 ms
  CUDA graph      1.67-1.77 ms always

DGX Spark expected, not yet measured

strace: same shape (futex/poll lead);
  ioctl count similar (same driver model)
cow: same mechanism; if the kernel uses
  64 KiB pages, each COW break copies 64 KiB,
  so USS grows in bigger steps (and faster)
jitter: no SMT on Arm cores, so pin to
  an X925 (cpu_capacity 1024);
  A725 cores launch slower
  CUDA graph still ~= GPU time
Spark launch latency and the X925/A725 split's effect on it are predictions. The drill picks CPU 0 to pin, so check that CPU 0 is a big core (cat /sys/devices/system/cpu/cpu0/cpu_capacity) and pass --pin explicitly if not.

?Self-check

DataLoader workers' RSS looks stable, yet free memory falls steadily during the first epoch and the job gets OOM-killed at 6 workers. What's happening and what do you change? criterion #4
Workers are forked, so they share the parent's dataset pages copy-on-write. Every dataset[i] on a Python object increments its refcount (and GC touches headers), which is a write, so the kernel copies that page into the worker. Over an epoch each worker builds a private copy of every page it touched. RSS doesn't show it because those pages were already counted as resident. PSS and USS do (drill: 60 → 439 MiB USS per worker). Fixes: keep the data in numpy/Arrow/tensors or an mmap'd file (no per-item objects), reduce workers, or use per-worker lazy loading.
Why does PyTorch refuse to use CUDA in a forked child, and what are the alternatives?
The parent's CUDA context depends on driver threads (which don't survive fork, only the calling thread does), on GPU-side mappings and file descriptors tied to the parent, and on locks inside libcuda that might be held at fork time. The child gets a memory image of a context it can't use, so CUDA doesn't support it and PyTorch raises "Cannot re-initialize CUDA in forked subprocess". Use spawn or forkserver (started before CUDA init) for processes that need the GPU, or keep forked workers CPU-only, as DataLoader does.
A small-batch decode loop gets 35% GPU utilization. nsys shows gaps between kernels, and the CPU thread shows cudaLaunchKernel calls of ~5 µs with occasional 2 ms holes. Explain, and give three fixes.
Kernels are shorter than their launch cost, so the stream queue is nearly empty and the GPU runs at the CPU's pace. The 2 ms holes are the launch thread being descheduled (or waiting on the GIL), and with no queued work each one becomes GPU idle time. Fixes: (1) CUDA graphs: one launch per step, so jitter inside the step no longer matters; (2) give the launch thread an exclusive physical core on the GPU's NUMA node and remove oversubscription (OMP threads, workers); (3) fewer, larger kernels (fusion, torch.compile), or batch more requests per step.
strace -c shows futex at 60% of syscall time. Is the futex implementation slow?
No. strace -c sums the time threads spend inside syscalls, and a futex wait is a thread sleeping until another thread releases a lock or signals a condition. A high futex share means threads are waiting for each other: the main thread for the next batch, the pin-memory thread for workers, threads for the GIL. Find out who waits on whom (nsys osrt, py-spy, perf trace). Don't optimize the futex.
You pinned the launch thread with taskset -c 0, but jitter is still bad on a busy node. Name two likely reasons.
(1) CPU 1 is CPU 0's SMT sibling and other work still runs there, sharing the core's execution units and caches (the drill measured 8.9 ms vs 5.4 ms). (2) Nothing stops other threads from running on CPU 0: affinity limits where your thread runs, not where others run. Use isolcpus or an exclusive cpuset. Also check IRQs landing on CPU 0 (/proc/interrupts), and whether the Python GIL is held by a helper thread (that isn't a scheduler problem at all).
Your team upgrades the training image from Python 3.12 to 3.14 and DataLoader startup gets slower and per-worker memory rises. Why?
Python 3.14 changed the default start method on Linux from fork to forkserver. Workers are no longer COW clones of the trainer: the Dataset object is pickled and sent to each worker, which rebuilds it in its own memory. That's slower startup and no sharing of the parent's pages. It's safer (no threads or locks inherited). Set multiprocessing_context explicitly, and make the dataset cheap to pickle (paths and mmap'd files, not in-memory lists).
Saved in this browser and shown on the index page.

§Sources