Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
In modern deep learning inference, generating a single token doesn't launch one monolithic GPU kernel. During autoregressive decoding, each transformer layer issues a flurry of micro-operations: RMSNorm, RoPE rotary embeddings, QKV projections, attention decode kernels, SwiGLU activations, linear projections, and residual additions. Across 80 layers in a large language model, producing one token requires enqueuing between 300 and 500 individual CUDA operations.
On contemporary accelerators like NVIDIA Hopper or Blackwell, each tiny kernel executes in 2 to 5 microseconds because high-bandwidth memory (HBM) and tensor cores stream through small vectors almost instantly. But the host CPU driver overhead to validate, marshal, and dispatch a single kernel launch takes 3 to 10 microseconds.
When host launch time exceeds device execution time (), the GPU starves. The hardware command processor drains its queue faster than the CPU can push new work, leaving the Streaming Multiprocessors (SMs) sitting completely idle in execution bubbles. Your server might show low GPU throughput, but it's neither memory-bound nor compute-bound. It's host-submission bound.
Why can a GPU appear underutilized even when running an inference service at 100% CPU load?
Answer
When host launch overhead exceeds kernel execution time, the GPU drains its work queue faster than the CPU can push commands. The GPU spends most of its time idling in submission bubbles between micro-kernels.
To master this boundary, we follow a four-operation pipeline from a CPU simulation into a PyTorch CUDA benchmark:
- Copy input values into a working buffer.
- Affine transform: compute .
- Square: compute each value's square .
- Reduce: sum the elements down to a single scalar.
On toy integer inputs [1, 2, 3, 4], the affine step yields [3, 5, 7, 9], the square step yields [9, 25, 49, 81], and the final reduction sums to 164.
The host can enqueue all four operations into the driver queue and return to user code while the GPU is still working on the initial copy. That isn't a bug. CUDA runtimes are deliberately asynchronous. Bugs appear when engineers mistake host enqueue return for kernel completion, when concurrent streams read shared memory without explicit synchronization, or when graph replay reuses stale pointer addresses.
Two clocks describe one execution
Every CUDA program runs against two independent clocks: the host CPU clock and the device GPU clock. When you call an asynchronous kernel or launch a memory transfer, control returns to your CPU thread almost immediately. Each CUDA stream functions as a first-in, first-out (FIFO) queue managed by the GPU hardware command processor.[1]
Under the hood, launching a kernel isn't a direct hardware jump. The CPU runtime writes command packets into a user-space driver push-buffer ring in pinned host memory. It then issues a memory-mapped I/O (MMIO) write across PCIe or NVLink to a hardware doorbell register on the GPU Work Launch Engine (WLE). The GPU's front-end command processor polls this doorbell, pulls packets over DMA, and assigns thread blocks to available SMs.

Because enqueue and execution run independently, standard host wall-clock measurements prove very little about device progress:
| Observation | What it proves | What it doesn't prove |
|---|---|---|
| Host launch call returns | Driver accepted the command into its push buffer | Kernel started, finished, or output buffer is valid |
| Recorded event completes | Preceding work in that stream reached the marker | Work in other streams has finished |
| Stream synchronization returns | All prior commands in that specific stream completed | Other device streams or engines are idle |
| Device synchronization returns | All submitted work across the entire device completed | Benchmark run excluded setup, allocation, or JIT compile |
Output buffer equals 164 | The fixture produced the expected value for this run | Work submission is free of data races across varying timings |
To visualize this asynchronous separation, consider a logical issue-order simulation. Each host submission costs one tick: the CPU submits operations at ticks 0, 1, 2, and 3, and returns to user code at tick 4. Device operations run on their own schedule, waiting for host submission, preceding stream tasks, and cross-stream dependencies. You can download the complete runtime submission simulation script.
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
10def schedule(operations: list[Operation]) -> dict[str, tuple[int, int]]:
11 stream_ready: dict[str, int] = {}
12 times: dict[str, tuple[int, int]] = {}
13 for issue_tick, op in enumerate(operations):
14 if not op.name or not op.stream or op.name in times:
15 raise ValueError("operation names must be unique and streams nonempty")
16 if type(op.duration) is not int or op.duration <= 0:
17 raise ValueError("durations must be positive integer ticks")
18 if any(name not in times for name in op.waits_for):
19 raise ValueError("dependencies must name earlier submitted operations")
20 ready = max((times[name][1] for name in op.waits_for), default=0)
21 start = max(issue_tick, stream_ready.get(op.stream, 0), ready)
22 times[op.name] = (start, start + op.duration)
23 stream_ready[op.stream] = start + op.duration
24 return times
25
26operations = [
27 Operation("copy", "copy", 3),
28 Operation("affine", "compute", 4, ("copy",)),
29 Operation("square", "compute", 2),
30 Operation("reduce", "compute", 1),
31]
32times = schedule(operations)
33print(f"host_return_tick={len(operations)}")
34for op in operations:
35 start, end = times[op.name]
36 print(f"{op.name} stream={op.stream} start={start} end={end}")
37print(f"device_complete_tick={max(end for _, end in times.values())}")
38print(f"result={sum((2*x+1)**2 for x in [1, 2, 3, 4])}")
39unsafe = schedule([operations[0], Operation("affine", "compute", 4),
40 *operations[2:]])
41print(f"without_event_read_before_copy_done={unsafe['affine'][0] < unsafe['copy'][1]}")
42assert times["affine"][0] >= times["copy"][1]
43assert times["reduce"][1] == 101host_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=164
8without_event_read_before_copy_done=TrueThe host returns at tick 4, but the final reduction kernel doesn't finish on the device until tick 10. If an engineer removes the cross-stream dependency between copy and affine, affine launches at tick 1, reading uninitialized buffer memory while the copy engine is still active. Square and reduce require no extra synchronization events because they sit sequentially in the same compute stream.

Stream semantics and synchronization
Commands submitted to a single CUDA stream execute strictly in issue order. Commands in different streams can execute concurrently or in any interleaved order unless an explicit dependency binds them. Having distinct streams grants permission to overlap; it doesn't guarantee that the hardware has the capacity to execute them concurrently.[1]
The legacy default stream trap
Default stream behavior catches engineers off-guard because CUDA provides three distinct stream operational models:
-
The Legacy Default Stream (NULL stream): By default, CUDA operations submitted without an explicit stream target stream 0 (the legacy NULL stream). The legacy default stream is implicitly synchronizing. Any command enqueued into the legacy NULL stream can't begin until all previously submitted commands across all blocking streams on the device have completed. Even more punishing, no subsequent command in any blocking stream can begin until that legacy NULL stream operation finishes. A single unintentional default stream call in a multi-tenant inference server collapses all concurrent worker streams into a serialized bottleneck.
-
Non-Blocking Streams (
cudaStreamNonBlocking): Created viacudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking). Non-blocking streams completely opt out of the legacy default stream's implicit barrier. Operations in non-blocking streams run concurrently regardless of pending work in the legacy default stream. -
Per-Thread Default Streams: When compiled with the
--default-stream per-threadcompiler flag (or when usingcudaStreamPerThread), CUDA replaces the global synchronizing NULL stream with an independent, non-blocking stream unique to each host CPU thread. This eliminates cross-thread synchronization traps in multi-threaded serving frameworks.
Why can an unadorned cudaMemcpy call in one thread stall independent worker streams running in another thread?
Answer
Without non-blocking flags or per-thread default stream compilation, unadorned calls target the legacy NULL stream. That stream acts as a global device barrier, waiting for all active blocking streams to finish and preventing subsequent stream work from starting until it completes.
Cross-stream coordination via events
To coordinate producer and consumer streams without blocking the host CPU, use CUDA events:

The synchronization sequence relies on two runtime calls:
cudaEventRecord(ready, copy_stream)appends an event marker into the copy stream's FIFO queue immediately after the copy command.cudaStreamWaitEvent(compute_stream, ready, 0)enqueues a dependency barrier into the compute stream.
cudaStreamWaitEvent returns immediately on the host CPU (taking under 0.5 microseconds). It doesn't block the host thread. Instead, it places a dependency token into the GPU command processor's hardware queue. The device Work Launch Engine pauses execution on the compute stream until the copy stream signals the event flag, freeing the CPU to prepare subsequent pipeline stages.
Remember to record an event before asking a stream to wait on it. Asking a stream to wait on an event that has never been recorded is undefined behavior and won't block future work. Keep source and destination memory buffers pinned and alive until all consuming kernels finish.[2]
Overlap requirements
Our four-operation demonstration workload is strictly sequential because each kernel consumes the output of its predecessor. However, real-world inference pipelines overlap memory copies for request with compute kernels for request . That overlap requires meeting strict architectural conditions:
| Overlap Type | Hardware and Software Prerequisites | Common Serialization Bottleneck | Diagnostic Clue |
|---|---|---|---|
| Host-to-device copy with kernel | Pinned host memory (cudaHostAlloc), asynchronous copy (cudaMemcpyAsync), non-default streams, separate buffers | Pageable host memory forces synchronous driver staging through internal bounce buffers | Trace shows host thread blocked inside cudaMemcpy |
| Device-to-host copy with kernel | Pinned host destination buffer, non-blocking streams, available copy engine | Host code reads destination pointer before synchronizing, forcing an immediate CPU stall | Trace shows CPU-side wait before GPU kernel completes |
| Kernel with kernel | Independent memory, distinct streams, sufficient SM resources (registers, shared memory, thread blocks) | Kernel exhausts SM registers or shared memory, leaving zero block slots for concurrent execution | SM occupancy reaches 100% on one kernel while other streams wait |
| Request transfer with request compute | Double-buffered memory allocations, per-slot event management | Reusing a single workspace pointer causes write-after-read hazards | Pipelines serialize unless distinct virtual addresses are bound |
CUDA reports device hardware capabilities like asyncEngineCount (indicating separate copy and compute engines). But hardware support alone doesn't guarantee overlap. Host dispatch delays, resource exhaustion, and implicit stream barriers can serialize operations that look concurrent on paper.[1]
Measure enqueue, device work, and completion
Measuring GPU execution requires tracking three non-overlapping spans:
- Host submission time: CPU wall-clock duration around the submission loop, measured without any synchronizations inside the timed region.
- Device elapsed time: The duration between two CUDA events recorded before and after the workload in the execution stream, evaluated only after the stop event completes.
- End-to-end time: Host wall-clock duration from the start of submission through the final scoped device synchronization.

Never add host submission time and device elapsed time together. Host and device clocks run concurrently. CUDA event elapsed time reflects the device wall-clock span between two hardware timestamps, not the sum of individual kernel runtimes. If the host CPU takes 8 microseconds to enqueue the next kernel and the current kernel finishes in 3 microseconds, the 5-microsecond starvation bubble is included directly in the measured event duration.[3]
A high host submission time paired with tiny kernels points to framework dispatch, Python overhead, or driver contention. Long kernel times paired with a host that stays far ahead indicate GPU compute or memory saturation. Nsight Systems exposes CPU runtime calls alongside GPU streams, memory engines, and kernels. Use it to verify whether host dispatch gaps are starving your SMs before attempting low-level kernel optimizations.[4][5]
Isolating steady-state performance
A disciplined benchmark isolates initialization from steady-state execution:
- Initialize the CUDA context and runtime libraries before starting timers.
- Pre-allocate all input, workspace, and output tensors.
- Warm up the execution pipeline to trigger just-in-time (JIT) compilation, autotuning, and driver memory bindings.
- Prime CUDA timing events: PyTorch initializes its underlying CUDA event handles lazily on their first record call, which can add hundreds of microseconds of artifact noise if done inside the timed loop.[6]
- Enforce strict numerical tolerance checks against an FP64 CPU reference before trusting any timing speedup.
| Benchmark Receipt Field | Why It's Mandatory | Rejection Threshold |
|---|---|---|
| GPU model, driver, CUDA runtime, framework version | Driver push-buffer behavior and graph features evolve between releases | Uncontrolled environment variations |
| Tensor shape, strides, and memory addresses | Workload identity and layout continuity | Unnoticed memory reallocations |
| Warmup and measurement iterations | Separates cold initialization from steady-state reuse | Zero warmup or single-shot timing |
| Host, device event, and end-to-end intervals | Separates CPU launch bottlenecks from GPU execution | Omitting host enqueue duration |
| Reference value, tolerance, observed maximum error | Guards against fast but mathematically broken optimizations | Error exceeds numerical tolerance |
| Graph setup time and captured memory addresses | Accounts for instantiation overhead and verifies address contracts | Hidden instantiation costs inside replay loops |
CUDA Graphs eliminate submission overhead
A CUDA Graph represents a static directed acyclic graph (DAG) of execution nodes. Rather than repeatedly paying CPU driver and kernel launch costs on every step, a CUDA Graph lets you capture the topology once, bake it into hardware-ready descriptors, and replay it with minimal CPU intervention.[1]
The CUDA Graph lifecycle follows three distinct phases:

-
Capture (
cudaGraph_t): Wrapping normal stream commands betweencudaStreamBeginCaptureandcudaStreamEndCaptureintercepts kernel dispatches and constructs an in-memory DAG. No work runs on the GPU during this phase. -
Instantiation (
cudaGraphExec_t): CallingcudaGraphInstantiatecompiles the logical DAG into an executable graph. The driver validates node dependencies, assigns hardware resources, binds kernel arguments, and packs the entire pipeline into a pre-built push-buffer command sequence. Instantiation is computationally expensive (often taking 10 to 50 milliseconds), but it runs only once during startup. -
Launch (
cudaGraphLaunch): Replaying the graph requires only a single MMIO doorbell write to the GPU Work Launch Engine. Launching a graph with 200 kernels takes under 2 microseconds of host CPU time, completely eliminating per-kernel launch overhead and preventing execution bubbles between nodes.

Suppose eager execution takes 18 microseconds per iteration (including host launch overhead and kernel execution), while graph replay drops the time to 12 microseconds, but incurs 12 milliseconds of upfront capture and instantiation cost. Each replay saves 6 microseconds:
At 2,000 iterations, both approaches take exactly 36 milliseconds. Replay iteration 2,001 is the first iteration that delivers a net performance improvement:
1from math import floor, isfinite
2
3def first_cheaper_reuse(setup_ms: float, eager_us: float, replay_us: float):
4 if not all(isfinite(x) and x >= 0 for x in (setup_ms, eager_us, replay_us)):
5 raise ValueError("costs must be finite and nonnegative")
6 saving_us = eager_us - replay_us
7 return floor(setup_ms * 1000 / saving_us) + 1 if saving_us > 0 else None
8
9print("first cheaper reuse:", first_cheaper_reuse(12, 18, 12))
10print("no replay saving:", first_cheaper_reuse(12, 12, 12))
11assert 12_000 + 2000 * 12 == 2000 * 18
12assert 12_000 + 2001 * 12 < 2001 * 181first cheaper reuse: 2001
2no replay saving: NoneCapture rules and constraints
Stream capture intercepts stream activity and converts it into graph nodes. It enforces strict runtime constraints:
- Never begin capture on the legacy default stream. Capture must occur on a user-created non-blocking stream or a per-thread default stream.
- If a capture region forks into multiple streams using events, all joined streams must merge back into the origin stream before
cudaStreamEndCaptureis called. - Never call host-device synchronizations (
cudaStreamSynchronize,cudaDeviceSynchronize, orcudaEventSynchronize) during capture. Doing so invalidates the capture and raises a runtime error. - Standard host dynamic memory allocations (
malloc,cudaMalloc) are strictly prohibited during stream capture. Dynamic allocations alter virtual memory mappings that the static graph can't track. To allocate memory inside a graph, use stream-ordered memory allocators (cudaMallocAsyncorcudaGraphAddMemAllocNode) backed by a pre-allocated memory pool.[1]
PyTorch simplifies this workflow through torch.cuda.graph(), which manages side streams and coordinates with PyTorch's internal caching allocator to keep memory pools stable.[2]
Virtual addresses are baked into the graph
When a graph is instantiated, the physical 64-bit virtual memory addresses of all input, intermediate, and output buffers are baked directly into kernel argument tables.
Reassigning a Python variable name doesn't update the graph. If you allocate a new tensor with x = torch.empty(...), its virtual memory address changes. Replaying the graph will either read stale values from the old address or corrupt unrelated memory if that address was reused by another allocator.
To use fresh data with a captured graph, you must allocate static input and output buffers once and hold strong references to them for the graph's lifetime. Write fresh data into place using input_buffer.copy_(new_data) before calling graph.replay(). Similarly, read or clone results from static output buffers before launching the next replay.
Why does reassigning an input variable with a freshly allocated tensor break CUDA Graph replay?
Answer
CUDA Graph instantiation bakes 64-bit virtual memory addresses directly into device launch descriptors. Reallocating a tensor changes its memory address, causing graph replay to process the old memory location.
Graph parameter updates
CUDA supports two mechanisms for updating an executable graph without paying for a full reinstantiation:
- Node Parameter Updates (
cudaGraphExecKernelNodeSetParams): Updates the launch configuration (grid dimensions, block dimensions, or scalar parameters) of a specific node in an existing executable graph. - Whole-Graph Updates (
cudaGraphExecUpdate): Compares a newly captured logicalcudaGraph_tagainst an existingcudaGraphExec_tand updates execution parameters in place.
Updates can adjust kernel arguments, memory pointers, and grid dimensions without rebuilding the underlying command buffer. However, they can't modify graph topology. You can't add nodes, remove nodes, or alter dependency edges. If a workload's execution structure changes, you must instantiate a new graph executable.[1]
Bucketed graph pools in inference runtimes
Production inference engines like vLLM and SGLang can't rely on a single static CUDA Graph. In continuous batching, the number of active requests changes on every decoding step (e.g., batch sizes 1, 3, 7, 12, or 32). Capturing a new graph on the fly is unacceptable because a 20-millisecond instantiation pause would introduce severe latency spikes.[7][8]
Inference engines resolve this tension using bucketed graph pools:

- Pre-Capture Discrete Buckets:
During service initialization, the engine captures and instantiates graphs for a fixed set of batch sizes (typically powers of two or tuned intervals:
[1, 2, 4, 8, 16, 32, 64, 128, 256]). - Padding with Attention Masking: When a batch of 6 requests arrives, the runtime rounds up to the batch-8 graph bucket. The two unused slots are populated with dummy tokens. Attention masks, loss computations, and KV cache updates are configured to ignore these padded slots, ensuring numerical precision remains exact.
- Eager Fallback for Outliers: When an incoming batch exceeds the largest pre-captured bucket (e.g., a massive prompt prefill or an unusual batch size), the engine falls back to standard eager execution. Eager fallback is an essential correctness mechanism that guarantees reliability for arbitrary input shapes.
Persistent kernels and device-side queues
CUDA Graphs reduce host launch overhead to under 2 microseconds. But for ultra-low latency inference, speculative decoding verification, or tree-search decoding, even 2 microseconds of CPU interaction can become a bottleneck.
Persistent kernels eliminate the CPU from the execution loop entirely. Instead of repeatedly launching kernels from the host, the application launches a single, long-running grid that stays resident on the GPU for the lifetime of the process.

A persistent kernel uses NVIDIA Cooperative Groups (cooperative_groups::grid_group grid = cooperative_groups::this_grid();) launched via cudaLaunchCooperativeKernel. The grid is sized to fit within the physical SM capacity of the GPU (typically 1 or 2 blocks per SM).
Each SM worker block executes a persistent while (running) loop:
- Threads poll a task ring buffer located in GPU device memory or host-mapped memory using atomic operations (
atomicAdd) and memory fences (__threadfence_system()). - When a task descriptor arrives, the SM workers execute the math immediately without waiting for an MMIO doorbell write from the CPU.
- Once finished, workers mark the task complete and poll for the next work unit.
Architectural trade-offs of persistent kernels
While persistent kernels achieve sub-microsecond dispatch latencies, they introduce severe operational trade-offs:
| Engineering Dimension | Standard CUDA Graph | Persistent Worker Kernel |
|---|---|---|
| Launch overhead | ~1-2 µs per graph replay | Nanoseconds (direct memory polling) |
| SM resource footprint | Resources released immediately when kernel finishes | Registers and shared memory held indefinitely |
| Multi-tenant fairness | Excellent (GPU scheduler interleaves streams) | Poor (resident blocks monopolize SM execution slots) |
| Watchdog timers | Immune | Subject to OS display driver timeouts (TDR) |
| Failure recovery | Driver resets stream on kernel failure | Stuck worker hangs the entire GPU device |
Because persistent thread blocks never yield their SM slots, other CUDA streams and kernels can't schedule work on those multiprocessors. If a persistent worker depends on a secondary kernel that can't launch due to resource starvation, the GPU enters an unrecoverable deadlock. Persistent kernels are powerful, specialized tools that require rigorous lifecycle management, bounded polling timeouts, and dedicated hardware instances.
Run the GPU runtime lab
Download the complete gpu_runtime_lab.py script. It benchmarks our four-operation pipeline (copy -> affine -> square -> reduce) across three submission modes:
eager: Submits each operation sequentially from the current stream.stream: Submits the same chain to an explicit non-blocking stream.graph: Warms up the pipeline, captures static buffers, and executes via graph replay.
Run the lab on a system with a supported NVIDIA GPU:
1uv run web/src/content/fundamentals/gpu-runtime-execution-lab/assets/gpu_runtime_lab.py \
2 --mode all --elements 4096 --warmup 20 --iterations 1000The script evaluates a 4,096-element floating-point vector initialized with linspace(0, 1, elements). The initial step performs a device-to-device copy, ensuring data transfer mechanics are exercised alongside arithmetic compute.
Before running timed iterations, the benchmark performs an automated preflight correctness check. It fills the input buffer with zeros and ones, validating that the outputs produce exact expected sums (elements and 9 * elements) within numerical tolerances. It then restores the original input, warms up the pipeline, primes CUDA timing events, and evaluates final results against an FP64 CPU reference.
1{
2 "receipt_schema": 2,
3 "environment": {
4 "device": "NVIDIA A100-SXM4-80GB",
5 "compute_capability": "8.0",
6 "torch": "2.6.0+cu124",
7 "cuda_runtime": "12.4"
8 },
9 "workload": {
10 "elements": 4096,
11 "warmup": 20,
12 "iterations": 1000,
13 "chain": ["copy", "affine", "square", "reduce"]
14 },
15 "graph_setup_ms": 14.82,
16 "modes": [
17 {
18 "mode": "eager",
19 "host_submit_us_per_iteration": 19.42,
20 "device_us_per_iteration": 12.15,
21 "end_to_end_us_per_iteration": 20.81,
22 "passed": true
23 },
24 {
25 "mode": "stream",
26 "host_submit_us_per_iteration": 19.18,
27 "device_us_per_iteration": 12.08,
28 "end_to_end_us_per_iteration": 20.64,
29 "passed": true
30 },
31 {
32 "mode": "graph",
33 "host_submit_us_per_iteration": 2.14,
34 "device_us_per_iteration": 8.41,
35 "end_to_end_us_per_iteration": 8.95,
36 "passed": true
37 }
38 ]
39}Notice how the numbers separate the bottlenecks:
- In
eagermode, the host spends ~19.4 microseconds submitting the four operations. Device execution takes ~12.1 microseconds. Host dispatch overhead is the primary bottleneck. - In
graphmode, host submission plunges from 19.4 microseconds down to 2.1 microseconds, reflecting the single doorbell launch. - On top of that, device execution time drops from 12.1 microseconds to 8.4 microseconds. Why? Because eliminating the CPU dispatch bubbles allows the GPU Work Launch Engine to schedule the four kernels back-to-back without the execution gaps present in eager mode.
Diagnostic guide for runtime bottlenecks
| Symptom | Primary Diagnostic Evidence | Root Cause | Engineering Solution |
|---|---|---|---|
| CPU timer reports tiny numbers, but end-to-end request latency is high | Host submission is fast, but cudaStreamSynchronize blocks for milliseconds | Host timer stopped immediately after enqueue without waiting for device completion | Use CUDA timing events or synchronize before stopping CPU timers |
| Consumer kernel reads stale or partially written data | Corrupted output values that change across runs | Missing cross-stream event dependency edge | Insert cudaEventRecord after producer and cudaStreamWaitEvent before consumer |
| Memory transfers don't overlap compute kernels | Profiler shows serialized timeline between copy and compute engines | Host memory is pageable, transfers target default stream, or buffers are shared | Use pinned host memory (cudaHostAlloc) and explicit non-blocking streams |
| Stream capture fails immediately | Runtime error during cudaStreamEndCapture | Legacy NULL stream usage, dynamic memory allocation, or host synchronization during capture | Move dynamic allocations out of capture and use non-blocking streams |
| Graph replay outputs stale or corrupted results | Pointer logging reveals tensor address changed between capture and replay | In-place tensor was reallocated, changing its virtual memory pointer | Pre-allocate static input buffers and use copy_() to inject fresh data |
| Dynamic batch size crashes or corrupts memory | Crash occurs when request count changes | Reused graph executable has incompatible tensor shapes or grid bindings | Implement bucketed graph pools with padded masking and eager fallback |
| CUDA Graph shows zero performance improvement | Host submission and device execution times remain identical to eager mode | Workload consists of a few long-running kernels where submission overhead is negligible () | Focus on kernel-level optimization (operator fusion, tiling) rather than graph replay |
| Persistent worker degrades co-located workload throughput | Co-located inference kernels experience massive latency spikes | Persistent blocks occupy all SM register files and shared memory slots | Limit persistent grid size to reserve SM slots for transient kernels |