Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Four CUDA operations transform [1, 2, 3, 4] into one scalar:
- copy values to a working buffer;
- compute , giving
[3, 5, 7, 9]; - square each value, giving
[9, 25, 49, 81]; - reduce the four values to
164.
The host can enqueue all four operations and return while the GPU is still executing the copy. That isn't a bug. CUDA normally works this way. The bug appears when a benchmark calls host return "latency," when a second stream reads the buffer without a dependency, or when graph replay reuses storage whose address changed.
This lab keeps that same four-operation chain from the first simulation through a real PyTorch CUDA experiment. Stable work makes runtime choices visible. Kernel math doesn't change, so each timing difference has an execution-layer owner.
What two facts must you keep separate before optimizing this chain?
Answer
The host has submitted four operations, and the device has produced 164. A host API return proves only submission unless that API is documented as synchronizing.
Two clocks describe one execution
CUDA launches and asynchronous copies usually place work into a stream, then return control to the host before that work completes. Each stream is an in-order queue, but host progress and device progress are separate.[1]
| Observation | What it proves | Proof limit | Annotation |
|---|---|---|---|
| Host enqueue returned | Runtime accepted or buffered the submission | Kernel finished or output is readable | Submission evidence |
| Event after reduction completed | Earlier work in that event's stream reached the event | Unrelated streams completed | Device progress evidence |
| Stream synchronization returned | All earlier work in that stream completed | Every stream on the device is idle | Scoped completion evidence |
| Device synchronization returned | Previously submitted work on the device completed | Measurement excludes setup or JIT | Broad completion evidence |
Output equals 164 | This fixture's observed result is correct | Runtime ordering is correct for every input and shape | Correctness evidence |
The next program is a logical scheduler, not a GPU performance model. Its ticks have no time unit. They expose the two clocks without requiring CUDA hardware. Download the complete runtime submission simulation.
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class Operation:
5 name: str
6 stream: str
7 duration: int
8 waits_for: tuple[str, ...] = ()
9
10operations = [
11 Operation("copy", "copy", 3),
12 Operation("affine", "compute", 4, ("copy",)),
13 Operation("square", "compute", 2, ("affine",)),
14 Operation("reduce", "compute", 1, ("square",)),
15]
16
17stream_ready: dict[str, int] = {}
18completed: dict[str, int] = {}
19
20print(f"host_return_tick={len(operations)}")
21for operation in operations:
22 dependencies_ready = max(
23 (completed[name] for name in operation.waits_for), default=0
24 )
25 start = max(stream_ready.get(operation.stream, 0), dependencies_ready)
26 end = start + operation.duration
27 stream_ready[operation.stream] = end
28 completed[operation.name] = end
29 print(
30 f"{operation.name} stream={operation.stream} "
31 f"start={start} end={end}"
32 )
33
34values = [1, 2, 3, 4]
35affine = [2 * value + 1 for value in values.copy()]
36result = sum(value * value for value in affine)
37print(f"device_complete_tick={max(completed.values())}")
38print(f"result={result}")
39
40assert len(operations) < max(completed.values())
41assert result == 1641host_return_tick=4
2copy stream=copy start=0 end=3
3affine stream=compute start=3 end=7
4square stream=compute start=7 end=9
5reduce stream=compute start=9 end=10
6device_complete_tick=10
7result=164Host submission ends at logical tick 4. The dependent device chain ends at tick 10. Synchronizing immediately after every operation would force those clocks together, but it would also erase the queueing behavior this lab needs to inspect.
Streams carry order
Commands in one CUDA stream execute in issue order. Commands in different streams may execute concurrently or in either order unless a dependency connects them. "Different streams" therefore means permission to overlap, not a promise that hardware can or will overlap them.[1]
Default-stream behavior needs an explicit contract because CUDA supports two modes:
- The legacy default stream (also called the NULL stream) synchronizes with blocking streams. A NULL-stream operation submitted between otherwise independent operations can prevent their overlap.
- A stream created with nonblocking semantics doesn't synchronize with that legacy stream.
- Per-thread default-stream mode makes each host thread's default stream a regular stream instead of one process-wide synchronization point.
- A library may run on its current stream, a caller-supplied stream, or an internal stream. Confirm its API contract before assuming order.
Relying on accidental default-stream ordering makes code sensitive to build flags, stream creation flags, host thread, and library integration. Record an event after the producer instead. Make the consumer stream wait for that event.

cudaEventRecord(ready, copy_stream) inserts the event after earlier copy-stream work. cudaStreamWaitEvent(compute_stream, ready) prevents later compute-stream work from passing that event. The host doesn't have to wait. CUDA events can also carry timestamps, but dependency-only events can be created with timing disabled when no timestamp is needed.[1]
Overlap has prerequisites
Our four operations are intentionally dependent, so their critical path stays serial. A pipeline can overlap copy for request with compute for request only after it gives the requests separate buffers and correct event edges.
| Intended overlap | Required conditions | Common serialization cause | Annotation |
|---|---|---|---|
| Host-to-device copy with kernel | Pinned (page-locked) host memory, asynchronous copy, distinct streams without implicit synchronization, independent buffers, supported copy engine | Pageable host memory, same-stream ordering, or legacy default-stream synchronization | Necessary, not sufficient |
| Device-to-host copy with kernel | Pinned destination, asynchronous copy, independent buffers, streams without implicit synchronization, compatible copy engine | Host reads destination and synchronizes early | Readiness boundary matters |
| Kernel with kernel | Independent data, distinct streams, device support, enough free registers, shared memory, blocks, and streaming multiprocessor (SM) capacity | Resource saturation or hidden dependency | Streams don't create capacity |
| Request copy with request compute | Double buffering and event ownership per slot | Reusing one address before its prior consumer finishes | Lifetime is part of ordering |
CUDA exposes device properties such as asynchronous engine count and concurrent-kernel support, but a capability bit doesn't prove overlap for a particular trace. Resource pressure, transfer direction, dependencies, and host submission cadence still decide the timeline.[1]
Two operations appear in different streams but remain serialized. Which four owners should you inspect before changing code?
Answer
Inspect dependencies, pinned-memory eligibility, copy-engine or SM capacity, and host submission gaps. Separate stream names permit overlap; they don't prove workload and hardware conditions allow it.
Measure enqueue, device work, and completion
One timing number can't locate launch overhead. Keep three intervals:
- Host submission time: CPU wall time around the enqueue loop, without a synchronization inside it.
- Device elapsed time: CUDA events placed before and after the chain in the execution stream, read only after the stop event completes.
- End-to-end time: CPU wall time from before submission through the final scoped synchronization.
These intervals answer different questions. A large host interval with narrow device work points toward Python, framework, driver, or launch dispatch. Long kernels with a small host interval point toward device execution. Large end-to-end time outside both intervals points toward setup, synchronization, memory movement, or other process work.
Nsight Systems shows CUDA API calls beside GPU streams, copies, and kernels. Use it to separate API time, queue gaps, and kernel time before reaching for a kernel profiler. Nsight Compute then explains a selected kernel's resource use and instruction or memory behavior.[2][3]
Remove cold work from steady state
A valid steady-state comparison records setup separately:
- create the CUDA context and initialize libraries;
- allocate output and workspace buffers;
- trigger module loading, just-in-time (JIT) compilation, autotuning, and lazy library setup;
- run warmup iterations on a side stream when graph capture will follow;
- synchronize once before starting timed work;
- keep shape, dtype, stream policy, power state, and background load documented.
Warmup isn't a fixed magic count. Repeat until the execution path and timing distribution stabilize, then report the chosen count. A cold-start receipt is also useful, but differs from a steady-state replay experiment.
| Receipt field | Why retain it | Reject the comparison when | Annotation |
|---|---|---|---|
| Device, runtime, driver, framework | Runtime paths and supported graph features vary | Environments differ without intent | Reproduction identity |
| Shape, dtype, strides, operation chain | Work and specialization identity | Any field changed unnoticed | Workload identity |
| Warmup and timed iterations | Separates setup and sample policy | One path receives different preparation | Protocol identity |
| Host, device-event, end-to-end intervals | Locates orchestration versus kernel time | Timer scope differs between modes | Timing evidence |
| Reference value, tolerance, observed error | Prevents fast wrong results | Error exceeds declared contract | Correctness gate |
| Graph setup time and captured addresses | Exposes amortization and lifetime | Setup is hidden inside replay timing | Graph evidence |
A CPU timer reports 40 microseconds around ten launches, while a CUDA event reports 120 microseconds through same stream. What did each timer measure?
Answer
CPU timer measured roughly 40 microseconds of host enqueue work. Synchronized CUDA events measured 120 microseconds of device progress through stream. Dividing host number by ten doesn't produce kernel latency.
Graph replay removes repeated submission
A CUDA Graph represents a dependency graph of operations. Runtime cost has distinct phases:
- Define: build nodes explicitly or capture operations issued to streams.
- Instantiate: validate the graph and create an executable snapshot with launch resources prepared.
- Replay: enqueue the executable graph into a stream.
Instantiation isn't replay, and capture isn't a benchmark iteration. Report graph setup outside steady-state replay timing. Graph replay can reduce repeated CPU and driver submission overhead while preserving the same GPU nodes and dependencies.[1]

Capture is an ordering proof
Stream capture records work issued to an origin stream and any joined streams. Raw CUDA permits any origin except the legacy NULL stream, including a per-thread default stream, and rejects patterns whose ordering can't be represented safely.[1]
- Don't begin capture on the legacy default stream.
- If capture forks into another stream, record an event from the captured stream, make the joined stream wait on it, and rejoin the origin before ending capture.
- Don't query or synchronize a captured stream or captured event during capture.
- Avoid unrelated uncaptured CUDA work in the same process while PyTorch capture is active.
- Ordinary CPU work executes during capture but isn't replayed. Raw CUDA can represent an explicit CPU-function graph node.
- CPU-GPU synchronization, unsupported stream APIs, or capture-unsafe library work can make a region unsafe to capture.
PyTorch's torch.cuda.graph context uses a side stream. Raw PyTorch capture requires a nondefault stream. Its safe recipe is warmup, allocate long-lived inputs and outputs, capture only safe CUDA work, then copy new values into the same input addresses before replay.[4]
Addresses are graph inputs too
Raw graph replay uses the same kernel arguments and virtual addresses observed during capture. Holding a Python variable with the same name doesn't preserve its address. Reallocating a tensor, allowing a captured output to be freed, or changing a view's layout can invalidate replay or silently redirect work.
Keep strong references to captured inputs, outputs, parameters, and workspaces for the graph's full lifetime. Copy fresh values into static input buffers. Consume or clone static outputs before another replay overwrites them. When capture includes asynchronous allocation or free nodes, dependency order must still prevent access before allocation and after free.[4][1]
One executable graph (cudaGraphExec_t) can't run concurrently with itself. CUDA orders another launch of the same executable after its previous launch. A runtime that needs overlapping graph executions must use distinct executable instances or another execution strategy, while keeping each instance's static buffers and memory-pool lifetime safe.[1]
Updates have a structural boundary
CUDA offers two update styles:
- update parameters of an individual node in an executable graph;
- compare a new graph definition with an existing executable using whole-graph update.
Parameter updates can change compatible kernel arguments, memcpy parameters, or addresses without rebuilding every launch resource. They don't permit arbitrary topology changes. Whole-graph update requires matching topology and compatible node types and ordering. A changed node count, dependency shape, or incompatible function contract generally needs a new graph executable. Successful updates apply to later launches, not one already running.[1]
Dynamic shapes cross that boundary often. A changed size may alter grid dimensions only, or it may change temporary allocation, operator choice, kernel count, and control flow. Ordinary PyTorch graph captures therefore suit static shapes and control flow. Current PyTorch can represent supported GPU-data-dependent branches through torch.cond(), but arbitrary Python branching and CPU decisions still aren't replayed. Use fixed shape buckets, padding with masked semantics, framework-managed graph trees, or eager fallback when the topology or memory plan changes.[4]
CUDA also supports device-side graph launch for constrained graph node types and launch modes. That is an advanced scheduling mechanism, not a loophole around lifetime rules. Device-launched executables require the appropriate instantiation and upload path, stay on one device, have node-type restrictions, and can't be updated from device code. Don't mix host and device launches of the same executable concurrently.[1]
A request changes from batch 8 to batch 9. What must remain compatible before you update one captured grid dimension?
Answer
Check allocation sizes, attention path, loop and kernel counts, dependencies, and output layout. If topology or memory plan changes, select another captured bucket, instantiate compatible graph, or run eager.
Run the GPU lab
Download gpu_runtime_lab.py. It runs the same copy, affine, square, reduce chain in three modes:
eager: issue each operation from the current stream;stream: issue the same chain on an explicit nondefault stream;graph: warm, capture fixed buffers, then replay the captured graph.
Use an isolated CUDA machine. The script requires a CUDA-enabled PyTorch build compatible with the installed driver. uv creates the script environment; if its default PyTorch wheel lacks support for your CUDA setup, install the official compatible PyTorch build in a project environment and run the same file there.
1uv run web/src/content/fundamentals/gpu-runtime-execution-lab/assets/gpu_runtime_lab.py \
2 --mode all --elements 4096 --warmup 20 --iterations 1000Start with the defaults. Increase iterations only after checking shared-machine policy and total runtime. Don't run a persistent stress loop on a display GPU or shared production accelerator. Long-running kernels can monopolize resources or distort neighboring workloads. Supported devices can use compute preemption to improve sharing, but context switches add overhead.[1]
The script prints measured JSON rather than a promised speedup. It records device and driver identity, workload, graph setup, captured buffer addresses, timing totals, per-iteration averages, reference value, observed value, error, tolerance, and pass status. Results depend on GPU, driver, framework build, clocks, process load, and workload size. Preserve the receipt instead of copying somebody else's numbers.
Each timing total covers the full loop. The per-iteration fields divide that total by iterations. End-to-end timing performs one scoped synchronization after the loop, so its per-iteration value measures amortized steady-state execution. It isn't the latency of a request that synchronizes after every chain.
For a useful comparison:
- save the unedited JSON receipt;
- repeat runs in a fresh process and inspect distribution, not one sample;
- confirm every mode passes the reference check;
- profile one representative run with Nsight Systems;
- explain gaps using trace evidence before changing stream or graph structure.
The explicit-stream mode is not expected to accelerate this dependent chain. Its purpose is to prove stream ownership and timing scope. Graph mode targets repeated submission overhead. If kernels dominate device time, replay may produce little end-to-end change.
Decide whether replay owns the bottleneck
| Observed trace | Likely next move | Why | Annotation |
|---|---|---|---|
| Many short kernels, visible host API gaps, stable shape | Capture and measure graph replay | Dispatch is a material share | Strong graph candidate |
| Few long kernels, host queue stays ahead | Optimize kernel or algorithm | Replay can't shorten kernel work | Weak graph candidate |
| Capture fails at one dynamic or synchronizing region | Keep that region eager; capture safe islands | Partial capture preserves correctness | Piecewise candidate |
| Many shape variants with low reuse | Bucket carefully or stay eager | Setup and graph memory may not amortize | Reuse-limited candidate |
| Same topology, compatible parameters change | Test graph update | Reinstantiation may be avoidable | Update candidate |
| Topology, allocation plan, or control flow changes | Reinstantiate or dispatch elsewhere | Executable structure no longer matches | Update rejection |
| Replay is fast but output drifts | Stop and fix address, ordering, or tolerance contract | Performance receipt is invalid | Correctness failure |
Graph replay doesn't fuse kernels. Our graph still launches copy, affine, square, and reduce nodes. A compiler such as torch.compile, or a custom Triton or CUDA kernel, may combine compatible pointwise work and reduce intermediate memory traffic.[5][6] Fusion and graphs therefore attack different boundaries:
- fusion changes GPU work by reducing nodes or memory traffic;
- graph replay changes how a stable sequence is submitted;
- streams and events change permission and order among operations;
- persistent kernels move scheduling into a long-lived device program.
Measure in that order of ownership. If fusion turns four tiny kernels into one substantial kernel, graph benefit may shrink because fewer host dispatches remain. If a required library operation forms a fusion boundary, piecewise capture can still reduce submission around it.
Inference runtimes dispatch by execution contract
Inference requests vary in batch size, token phase, sequence length, key-value (KV) cache layout, adapter, collective pattern, and attention backend. A production runtime can't safely send every request through one raw graph.
vLLM's current CUDA Graph design names five configurations: NONE, PIECEWISE, FULL, FULL_DECODE_ONLY, and FULL_AND_PIECEWISE. Its dispatcher selects FULL, PIECEWISE, or eager NONE execution from a runtime mode and a batch descriptor that includes token count, request count, uniformity, and Low-Rank Adaptation (LoRA) presence. Full graphs can cost more startup time and memory; piecewise graphs leave incompatible regions eager.[7]
SGLang likewise keeps separate prefill and decode graph policies with captured-size lists and full, breakable, tc_piecewise, or disabled backends. Its runtime code owns static capture buffers, warmup, runner selection, and eager fallbacks.[8] Those structures encode four production rules:
- Dispatch key: shape isn't enough when backend, phase, adapter, dtype, or collective structure changes execution.
- Static memory plan: captured addresses and workspaces must outlive replay.
- Coverage policy: capture hot reusable buckets; don't force rare requests through unsafe padding.
- Fallback: eager execution is part of correctness, not evidence that graphs failed.
Padding also needs a semantic proof. Padded tokens must be masked from attention, reductions, sampling, cache updates, and service accounting. Selecting a captured size only for larger capacity can be fast and wrong.
Persistent work queues move the boundary
A persistent kernel stays resident, reads work descriptors from a device-visible queue, executes tasks, and loops. It can remove repeated host launches and react to device-side work, but it exchanges runtime simplicity for a custom scheduler.
| Design question | Persistent-kernel consequence | Required receipt | Annotation |
|---|---|---|---|
| How does work arrive? | Queue publication needs memory ordering and backpressure | Queue-depth and producer-stall trace | Submission contract |
| How does kernel yield? | Resident blocks can occupy SM resources while idle | Co-residency and utilization trace | Fairness contract |
| How are tasks prioritized? | First-in, first-out (FIFO) order may hurt latency-critical work; custom priority may starve old work | Per-class latency distribution | Scheduling contract |
| How does it stop? | Exit flag and shutdown order must avoid stuck readers | Bounded shutdown test | Lifecycle contract |
| What if one task hangs? | Long-lived execution can block progress indefinitely | Timeout and recovery drill | Failure contract |
| Can other kernels run? | Registers, shared memory, blocks, and priorities limit co-residency | Multi-tenant trace | Capacity contract |
CUDA doesn't guarantee block scheduling order. A persistent design must size resident blocks using occupancy evidence and avoid assuming that a queued producer block will run before a waiting consumer block.[1] Preemption can prevent one long kernel from monopolizing a GPU, but saving and restoring execution state adds overhead. Shared-accelerator policy remains a deployment constraint, not a detail to discover under traffic.[1]
Graph replay is usually the lower-risk first move for a stable host-driven sequence. Persistent kernels fit specialized schedulers whose queue protocol, fairness, cancellation, observability, and recovery justify owning more runtime machinery.
Debug symptom by owner
| Symptom | First evidence | Frequent cause | Fix boundary |
|---|---|---|---|
| CPU timer is tiny but request latency is high | Event timing plus final synchronization | Timer stopped after enqueue | Measurement |
| Consumer reads stale data | Stream and event trace | Missing cross-stream dependency | Ordering |
| Copies don't overlap compute | Pinned-memory status and timeline | Pageable memory, same stream, or no engine capacity | Transfer pipeline |
| Capture fails immediately | Capture error and first unsafe operation | Legacy stream, synchronization, allocation, or unsupported call | Capture region |
| Replay writes old storage | Pointer log and tensor lifetime | Static captured address was replaced or freed | Memory lifetime |
| New shape crashes or corrupts | Dispatch key and graph topology | Reused graph has incompatible shape or layout | Runtime dispatch |
| Replay shows no speedup | Host and device intervals | Kernels dominate or setup isn't amortized | Bottleneck choice |
| Persistent worker hurts neighbors | SM occupancy and per-tenant latency | Resident blocks consume capacity or don't yield fairly | Scheduler policy |
Mastery check
Keep four receipts for one experiment:
- simulation output showing host tick 4, device tick 10, and result
164; - eager, explicit-stream, and graph JSON with environment and all three intervals;
- Nsight Systems trace locating API gaps, stream order, copies, and kernels;
- correctness record with reference, tolerance, maximum error, and captured dispatch key.
Use this rubric:
- Incomplete: reports one CPU duration or one fast replay result without synchronized device timing and correctness.
- Operational: separates enqueue, device, and end-to-end intervals; identifies stream ownership; preserves static captured storage.
- Production-ready: also records setup, dispatch keys, fallback behavior, shape and lifetime contracts, profiler evidence, and shared-GPU risk.
Common pitfalls:
- synchronizing after every launch, then claiming independent streams never overlap;
- timing graph capture or instantiation as if it were replay;
- comparing cold eager execution with warmed graph execution;
- replacing a captured tensor instead of copying into its static buffer;
- padding shape without masking every downstream semantic effect;
- treating graph replay as kernel fusion;
- adopting a persistent queue without fairness, cancellation, or timeout drills.
If you can explain why the stream mode may match eager, why graph mode may reduce only host time, and why every mode must still produce the same scalar within tolerance, you can distinguish runtime optimization from timing theater.