LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 196 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/17
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningTraining Run OperationsReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/31
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingGPU Failure OperationsDistributed Inference Data PlaneServing Benchmark EngineeringA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnInference & Production ScaleGPU Runtime Execution Lab
🚀HardInference Optimization

GPU Runtime Execution Lab

Master asynchronous CUDA submission with streams, events, graph capture, replay, updates, runtime dispatch, and correctness-first benchmark receipts.

25 min read
Learning path
Step 151 of 196 in the full curriculum
Low-Precision GPU ProgrammingAI-Generated Kernel Engineering

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 (Tlaunch>TkernelT_{\text{launch}} > T_{\text{kernel}}Tlaunch​>Tkernel​), 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:

  1. Copy input values into a working buffer.
  2. Affine transform: compute 2x+12x + 12x+1.
  3. Square: compute each value's square (2x+1)2(2x + 1)^2(2x+1)2.
  4. 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]Reference 1CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/

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.

Diagram showing Host CPU Space, GPU Hardware, Host Thread, and marshals args.
Host CPU Space, GPU Hardware, Host Thread, and marshals args.

Because enqueue and execution run independently, standard host wall-clock measurements prove very little about device progress:

ObservationWhat it provesWhat it doesn't prove
Host launch call returnsDriver accepted the command into its push bufferKernel started, finished, or output buffer is valid
Recorded event completesPreceding work in that stream reached the markerWork in other streams has finished
Stream synchronization returnsAll prior commands in that specific stream completedOther device streams or engines are idle
Device synchronization returnsAll submitted work across the entire device completedBenchmark run excluded setup, allocation, or JIT compile
Output buffer equals 164The fixture produced the expected value for this runWork 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.

runtime_submission_sim.py
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] == 10
Output
1host_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=True

The 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.

Timeline showing host submission ending at tick 4 while device work runs through tick 10. Copy stream records event ready at tick 3, unblocking affine kernel on compute stream. Without that event, compute reads partial data.
The copy event establishes the cross-stream dependency edge. Host submission overlaps device execution, while data-dependent operations execute serially on the GPU. Tick markers represent logical order, not hardware nanoseconds.

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]Reference 1CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/

The legacy default stream trap

Default stream behavior catches engineers off-guard because CUDA provides three distinct stream operational models:

  1. 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.

  2. Non-Blocking Streams (cudaStreamNonBlocking): Created via cudaStreamCreateWithFlags(&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.

  3. Per-Thread Default Streams: When compiled with the --default-stream per-thread compiler flag (or when using cudaStreamPerThread), 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:

Diagram showing Copy Stream d2d_copy input, cudaEventRecord(ready), ready event, and cudaStreamWaitEvent(compute, ready).
Copy Stream d2d_copy input, cudaEventRecord(ready), ready event, and cudaStreamWaitEvent(compute, ready).

The synchronization sequence relies on two runtime calls:

  1. cudaEventRecord(ready, copy_stream) appends an event marker into the copy stream's FIFO queue immediately after the copy command.
  2. 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]Reference 2CUDA semanticshttps://docs.pytorch.org/docs/stable/notes/cuda.html

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 n+1n + 1n+1 with compute kernels for request nnn. That overlap requires meeting strict architectural conditions:

Overlap TypeHardware and Software PrerequisitesCommon Serialization BottleneckDiagnostic Clue
Host-to-device copy with kernelPinned host memory (cudaHostAlloc), asynchronous copy (cudaMemcpyAsync), non-default streams, separate buffersPageable host memory forces synchronous driver staging through internal bounce buffersTrace shows host thread blocked inside cudaMemcpy
Device-to-host copy with kernelPinned host destination buffer, non-blocking streams, available copy engineHost code reads destination pointer before synchronizing, forcing an immediate CPU stallTrace shows CPU-side wait before GPU kernel completes
Kernel with kernelIndependent memory, distinct streams, sufficient SM resources (registers, shared memory, thread blocks)Kernel exhausts SM registers or shared memory, leaving zero block slots for concurrent executionSM occupancy reaches 100% on one kernel while other streams wait
Request n+1n + 1n+1 transfer with request nnn computeDouble-buffered memory allocations, per-slot event managementReusing a single workspace pointer causes write-after-read hazardsPipelines 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]Reference 1CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/

Measure enqueue, device work, and completion

Measuring GPU execution requires tracking three non-overlapping spans:

  1. Host submission time: CPU wall-clock duration around the submission loop, measured without any synchronizations inside the timed region.
  2. 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.
  3. End-to-end time: Host wall-clock duration from the start of submission through the final scoped device synchronization.
Diagram showing Unified Execution Timeline, Host Wall Clock: Enqueue Loop (3-10 µs / kernel), Device Stream Clock: Event Start to Event Stop, and End-to-End Clock: Submission Start through Final Stream Synchronize.
Unified Execution Timeline, Host Wall Clock: Enqueue Loop (3-10 µs / kernel), Device Stream Clock: Event Start to Event Stop, and End-to-End Clock: Submission Start through Final Stream Synchronize.

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]Reference 3CUDA Runtime API: Event Managementhttps://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__EVENT.html

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]Reference 4Nsight Systems User Guidehttps://docs.nvidia.com/nsight-systems/UserGuide/[5]Reference 5Nsight Compute Profiling Guidehttps://docs.nvidia.com/nsight-compute/ProfilingGuide/

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]Reference 6torch.cuda.Eventhttps://docs.pytorch.org/docs/stable/generated/torch.cuda.Event.html
  • Enforce strict numerical tolerance checks against an FP64 CPU reference before trusting any timing speedup.
Benchmark Receipt FieldWhy It's MandatoryRejection Threshold
GPU model, driver, CUDA runtime, framework versionDriver push-buffer behavior and graph features evolve between releasesUncontrolled environment variations
Tensor shape, strides, and memory addressesWorkload identity and layout continuityUnnoticed memory reallocations
Warmup and measurement iterationsSeparates cold initialization from steady-state reuseZero warmup or single-shot timing
Host, device event, and end-to-end intervalsSeparates CPU launch bottlenecks from GPU executionOmitting host enqueue duration
Reference value, tolerance, observed maximum errorGuards against fast but mathematically broken optimizationsError exceeds numerical tolerance
Graph setup time and captured memory addressesAccounts for instantiation overhead and verifies address contractsHidden 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]Reference 1CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/

The CUDA Graph lifecycle follows three distinct phases:

Diagram showing 1. Define & Capture, 2. Instantiate, 3. Launch & Replay, and cudaStreamBeginCapture().
1. Define & Capture, 2. Instantiate, 3. Launch & Replay, and cudaStreamBeginCapture().
  1. Capture (cudaGraph_t): Wrapping normal stream commands between cudaStreamBeginCapture and cudaStreamEndCapture intercepts kernel dispatches and constructs an in-memory DAG. No work runs on the GPU during this phase.

  2. Instantiation (cudaGraphExec_t): Calling cudaGraphInstantiate compiles 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.

  3. 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.

Cumulative execution cost comparing eager submission at 18 microseconds per iteration versus graph replay at 12 microseconds per iteration with 12 milliseconds upfront setup. The two lines cross at 2000 iterations (36 ms), after which graph replay becomes strictly faster.
Graph instantiation overhead must amortize across repeated replay iterations. In this worked example, eager execution costs 18 µs while graph replay costs 12 µs after 12 ms of setup. Replay breaks even at iteration 2,000 and delivers net latency savings thereafter.

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:

Break-even Iterations=Setup CostPer-iteration Savings=12,000 μs6 μs=2,000\text{Break-even Iterations} = \frac{\text{Setup Cost}}{\text{Per-iteration Savings}} = \frac{12{,}000\,\mu\text{s}}{6\,\mu\text{s}} = 2{,}000Break-even Iterations=Per-iteration SavingsSetup Cost​=6μs12,000μs​=2,000

At 2,000 iterations, both approaches take exactly 36 milliseconds. Replay iteration 2,001 is the first iteration that delivers a net performance improvement:

graph-amortization.py
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 * 18
Output
1first cheaper reuse: 2001 2no replay saving: None

Capture 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 cudaStreamEndCapture is called.
  • Never call host-device synchronizations (cudaStreamSynchronize, cudaDeviceSynchronize, or cudaEventSynchronize) 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 (cudaMallocAsync or cudaGraphAddMemAllocNode) backed by a pre-allocated memory pool.[1]Reference 1CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/

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]Reference 2CUDA semanticshttps://docs.pytorch.org/docs/stable/notes/cuda.html

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:

  1. Node Parameter Updates (cudaGraphExecKernelNodeSetParams): Updates the launch configuration (grid dimensions, block dimensions, or scalar parameters) of a specific node in an existing executable graph.
  2. Whole-Graph Updates (cudaGraphExecUpdate): Compares a newly captured logical cudaGraph_t against an existing cudaGraphExec_t and 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]Reference 1CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/

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]Reference 7CUDA Graphshttps://docs.vllm.ai/en/latest/design/cuda_graphs/[8]Reference 8SGLang: Efficient Execution of Structured Language Model Programshttps://arxiv.org/abs/2312.07104

Inference engines resolve this tension using bucketed graph pools:

Diagram showing Incoming Decode Batch (Batch Size = 6), Batch Size Router, Pad to 8, and Captured Graph Bucket: 8 (Dummy slots masked out).
Incoming Decode Batch (Batch Size = 6), Batch Size Router, Pad to 8, and Captured Graph Bucket: 8 (Dummy slots masked out).
  1. 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]).
  2. 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.
  3. 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.

Diagram showing Host / Producer, Persistent SM Worker Grid, Host Thread or Worker, and writes task descriptor.
Host / Producer, Persistent SM Worker Grid, Host Thread or Worker, and writes task descriptor.

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:

  1. Threads poll a task ring buffer located in GPU device memory or host-mapped memory using atomic operations (atomicAdd) and memory fences (__threadfence_system()).
  2. When a task descriptor arrives, the SM workers execute the math immediately without waiting for an MMIO doorbell write from the CPU.
  3. 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 DimensionStandard CUDA GraphPersistent Worker Kernel
Launch overhead~1-2 µs per graph replayNanoseconds (direct memory polling)
SM resource footprintResources released immediately when kernel finishesRegisters and shared memory held indefinitely
Multi-tenant fairnessExcellent (GPU scheduler interleaves streams)Poor (resident blocks monopolize SM execution slots)
Watchdog timersImmuneSubject to OS display driver timeouts (TDR)
Failure recoveryDriver resets stream on kernel failureStuck 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:

terminal
1uv run web/src/content/fundamentals/gpu-runtime-execution-lab/assets/gpu_runtime_lab.py \ 2 --mode all --elements 4096 --warmup 20 --iterations 1000

The 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.

receipt.json
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 eager mode, the host spends ~19.4 microseconds submitting the four operations. Device execution takes ~12.1 microseconds. Host dispatch overhead is the primary bottleneck.
  • In graph mode, 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

SymptomPrimary Diagnostic EvidenceRoot CauseEngineering Solution
CPU timer reports tiny numbers, but end-to-end request latency is highHost submission is fast, but cudaStreamSynchronize blocks for millisecondsHost timer stopped immediately after enqueue without waiting for device completionUse CUDA timing events or synchronize before stopping CPU timers
Consumer kernel reads stale or partially written dataCorrupted output values that change across runsMissing cross-stream event dependency edgeInsert cudaEventRecord after producer and cudaStreamWaitEvent before consumer
Memory transfers don't overlap compute kernelsProfiler shows serialized timeline between copy and compute enginesHost memory is pageable, transfers target default stream, or buffers are sharedUse pinned host memory (cudaHostAlloc) and explicit non-blocking streams
Stream capture fails immediatelyRuntime error during cudaStreamEndCaptureLegacy NULL stream usage, dynamic memory allocation, or host synchronization during captureMove dynamic allocations out of capture and use non-blocking streams
Graph replay outputs stale or corrupted resultsPointer logging reveals tensor address changed between capture and replayIn-place tensor was reallocated, changing its virtual memory pointerPre-allocate static input buffers and use copy_() to inject fresh data
Dynamic batch size crashes or corrupts memoryCrash occurs when request count changesReused graph executable has incompatible tensor shapes or grid bindingsImplement bucketed graph pools with padded masking and eager fallback
CUDA Graph shows zero performance improvementHost submission and device execution times remain identical to eager modeWorkload consists of a few long-running kernels where submission overhead is negligible (Tkernel≫TlaunchT_{\text{kernel}} \gg T_{\text{launch}}Tkernel​≫Tlaunch​)Focus on kernel-level optimization (operator fusion, tiling) rather than graph replay
Persistent worker degrades co-located workload throughputCo-located inference kernels experience massive latency spikesPersistent blocks occupy all SM register files and shared memory slotsLimit persistent grid size to reserve SM slots for transient kernels

Quiz

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A CPU timer wrapped around four CUDA kernel launches reports 15 microseconds, while synchronized CUDA events report 120 microseconds. What explains the discrepancy?

Correct answer: The CPU timer measured only the host enqueue duration, while the kernels were still executing asynchronously on the GPU

Asynchronous CUDA calls return control to the host CPU as soon as the commands are placed into the driver push-buffer. Host timers measure submission, while synchronized CUDA events measure device execution.

2.What is the primary architectural cause of execution bubbles between short kernels during eager LLM autoregressive decoding?

Correct answer: The host CPU launch overhead (3-10 µs per kernel) exceeds device kernel duration (<5 µs), starving the GPU command processor

When host launch overhead exceeds kernel execution time (T_launch > T_kernel), the GPU finishes executing each kernel before the CPU can prepare and ring the doorbell for the next one, creating idle starvation bubbles.

3.Why does issuing an operation on the legacy default stream (stream 0) serialize concurrent streams on the device?

Correct answer: The legacy NULL stream implicitly waits for all prior work in all blocking streams to finish, and blocks subsequent work in all blocking streams until it completes

The legacy default stream has implicit synchronization semantics with blocking streams across the entire device, creating a bidirectional execution barrier that eliminates concurrency.

4.How does cudaStreamWaitEvent synchronize two streams without stalling the host CPU thread?

Correct answer: It enqueues a dependency barrier directly into the GPU hardware command queue and returns immediately on the host

cudaStreamWaitEvent is entirely asynchronous with respect to the host. It inserts a hardware dependency token into the consumer stream's push buffer, letting the GPU command processor handle the wait on-chip.

5.Why does assigning a newly allocated tensor (e.g., x = torch.empty(...)) break subsequent CUDA Graph replay?

Correct answer: The graph executable bakes static 64-bit virtual memory addresses into kernel launch descriptors during instantiation

CUDA Graph instantiation fixes kernel argument pointers to the exact virtual memory addresses observed during capture. Reallocating a tensor assigns a new address, causing the graph to process stale memory.

6.How do production inference engines like vLLM and SGLang handle variable decode batch sizes with CUDA Graphs?

Correct answer: They pre-instantiate bucketed graph pools for discrete batch sizes, pad extra slots with masked dummy tokens, and fall back to eager mode for outliers

Inference runtimes pre-capture graphs for discrete bucket sizes (e.g., 1, 2, 4, 8, 16...). Incoming batches are padded to the nearest bucket with masked dummy tokens, while oversized batches fall back to eager execution.

7.What is the primary operational risk of deploying a persistent kernel that keeps thread blocks resident on all SMs?

Correct answer: Resident blocks monopolize SM registers and shared memory, preventing concurrent streams from scheduling work and risking deadlocks

Persistent kernels hold SM execution resources continuously. This prevents other streams or kernels from scheduling blocks on those SMs, which can starve concurrent workloads or cause deadlocks.

8.If graph setup costs 15 ms, eager execution takes 20 µs/iter, and graph replay takes 5 µs/iter, what is the first iteration that achieves net savings?

Correct answer: 1,001

Each replay saves 20 µs - 5 µs = 15 µs. Break-even occurs at 15,000 µs / 15 µs = 1,000 iterations. The first iteration with strictly lower cumulative time is 1,001.

9.Which set of conditions is strictly required to achieve concurrent overlap between a host-to-device memory copy and a GPU compute kernel?

Correct answer: Pinned host memory, asynchronous copy API, separate non-default streams, independent buffers, and hardware copy-engine support

Asynchronous transfer overlap requires page-locked (pinned) host memory to bypass CPU bounce buffers, non-blocking stream separation, independent memory addresses, and hardware copy engine availability.

10.When can cudaGraphExecUpdate successfully update an executable graph without requiring full reinstantiation?

Correct answer: When kernel arguments, memory pointers, or grid dimensions change while the overall graph topology and node types remain identical

Graph updates support parameter and pointer modifications within an existing topology. Structural changes, such as adding nodes, deleting nodes, or altering dependency edges, require full reinstantiation.

10 questions remaining.

Next Step
Continue to AI-Generated Kernel Engineering

Now that you can diagnose host-device submission bottlenecks, manage asynchronous stream dependencies, and amortize launch overhead with CUDA Graphs, you're ready to explore how autonomous agents generate, benchmark, and optimize custom GPU kernels within automated correctness loops.

PreviousLow-Precision GPU Programming
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

CUDA Programming Guide.

NVIDIA · 2026

https://docs.nvidia.com/cuda/cuda-programming-guide/

CUDA semantics

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/stable/notes/cuda.html

CUDA Runtime API: Event Management

NVIDIA · 2026

https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__EVENT.html

Nsight Systems User Guide

NVIDIA · 2026 · Official documentation

https://docs.nvidia.com/nsight-systems/UserGuide/

Nsight Compute Profiling Guide

NVIDIA · 2026 · Official documentation

https://docs.nvidia.com/nsight-compute/ProfilingGuide/

torch.cuda.Event

PyTorch · 2026

https://docs.pytorch.org/docs/stable/generated/torch.cuda.Event.html

CUDA Graphs

vLLM Project · 2026

https://docs.vllm.ai/en/latest/design/cuda_graphs/

SGLang: Efficient Execution of Structured Language Model Programs

Zheng, L., Yin, L., Xie, Z., et al. · 2024 · NeurIPS 2024

https://arxiv.org/abs/2312.07104

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.