Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Your vector-add kernel passed every correctness check yesterday. Suppose the same source now takes 300 milliseconds on its first call, or fails on a newer GPU with "no kernel image is available." Reading the source again won't explain either symptom. The GPU never executes CUDA C++ or Python directly.
The accelerator field guide mapped execution and memory owners across hardware. The parallel primitives lab then made synchronization scope, reduction trees, and prefix dependencies measurable. Keep those habits. Here we follow compiler artifacts between source and hardware, because each boundary owns a different class of bugs.
One addition, several representations
For 1,000 pairs of numbers, add each pair into a separate output slot. At index zero, ; at index one, . In general, write for each valid index .
CUDA C++ expresses the work from one thread's point of view. With 256 threads per block, 1,000 elements require four blocks: 1,024 threads, of which 24 must skip memory access. The same fixture below also checks lengths immediately around a block boundary.
Save this as vector_add.cu. It requires a CUDA toolkit to build and compatible NVIDIA hardware to run. The checks use small, exactly representable FP32 values; exact equality here isn't a general tolerance policy for reductions or matrix multiplication.
1#include <cuda_runtime.h>
2
3#include <cmath>
4#include <cstdlib>
5#include <iostream>
6#include <limits>
7#include <stdexcept>
8#include <vector>
9
10void check(cudaError_t status) {
11 if (status != cudaSuccess) {
12 std::cerr << cudaGetErrorString(status) << "\n";
13 std::exit(1);
14 }
15}
16
17__global__ void add_kernel(
18 const float* x,
19 const float* y,
20 float* out,
21 int n
22) {
23 int i = blockIdx.x * blockDim.x + threadIdx.x;
24 if (i < n) {
25 out[i] = x[i] + y[i];
26 }
27}
28
29void launch_add(const float* x, const float* y, float* out, int n) {
30 if (n < 0) throw std::invalid_argument("negative length");
31 if (n == 0) return; // A zero-block GPU launch is invalid.
32 constexpr int threads = 256;
33 int blocks = (n + threads - 1) / threads;
34 add_kernel<<<blocks, threads>>>(x, y, out, n);
35}
36
37bool matches(const std::vector<float>& actual,
38 const std::vector<float>& expected) {
39 if (actual.size() != expected.size()) return false;
40 for (std::size_t i = 0; i < actual.size(); ++i) {
41 if (!std::isfinite(actual[i]) || actual[i] != expected[i]) return false;
42 }
43 return true;
44}
45
46bool run_case(int n) {
47 std::vector<float> x(n), y(n), expected(n), out(n);
48 for (int i = 0; i < n; ++i) {
49 x[i] = 0.25F * i;
50 y[i] = float(i % 7 - 3);
51 expected[i] = x[i] + y[i];
52 }
53
54 float *d_x, *d_y, *d_out;
55 check(cudaMalloc(&d_x, n * sizeof(float)));
56 check(cudaMalloc(&d_y, n * sizeof(float)));
57 check(cudaMalloc(&d_out, n * sizeof(float)));
58 check(cudaMemcpy(d_x, x.data(), n * sizeof(float), cudaMemcpyHostToDevice));
59 check(cudaMemcpy(d_y, y.data(), n * sizeof(float), cudaMemcpyHostToDevice));
60
61 // All-one FP32 bit patterns are NaNs: skipped writes must fail the check.
62 check(cudaMemset(d_out, 0xff, n * sizeof(float)));
63 launch_add(d_x, d_y, d_out, n);
64 check(cudaGetLastError());
65 check(cudaDeviceSynchronize());
66 check(cudaMemcpy(out.data(), d_out, n * sizeof(float), cudaMemcpyDeviceToHost));
67
68 check(cudaFree(d_x));
69 check(cudaFree(d_y));
70 check(cudaFree(d_out));
71
72 bool correct = matches(out, expected);
73 std::cout << (correct ? "PASS" : "FAIL") << " n=" << n << "\n";
74 return correct;
75}
76
77int main() {
78 // Test the checker itself: NaNs and wrong values must fail.
79 if (matches({std::numeric_limits<float>::quiet_NaN()}, {4.0F}) ||
80 matches({3.0F}, {4.0F}) || matches({}, {4.0F})) return 1;
81 launch_add(nullptr, nullptr, nullptr, 0); // No GPU access.
82 for (int n : {1, 255, 256, 257, 1000}) {
83 if (!run_case(n)) return 1;
84 }
85 return 0;
86}Successful execution prints PASS n=1, PASS n=255, PASS n=256, PASS n=257, and PASS n=1000. These are expected results, not measurements from this review's CPU-only environment. Distinct input values help catch misplaced writes; checking finiteness prevents a NaN from disappearing inside a maximum-error reduction. This fixture doesn't test large-index overflow, arbitrary strides, or aliasing.
At source level, you can inspect the bounds check and the index formula. You can't yet prove which machine instructions were emitted, how many physical registers they require, or whether a deployed binary contains code for the target GPU.
NVIDIA's offline compiler driver, nvcc, coordinates separate host and device compilation. For this SIMT device kernel, the compilation path reaches Parallel Thread Execution (PTX), a versioned virtual instruction set architecture (ISA). PTX acts as a stable, hardware-agnostic intermediate representation. Crucially, PTX operates over an unbounded virtual register space (such as %r0, %r1, %f0, %f1). It doesn't know how many physical registers exist on the target GPU, nor does it concern itself with warp scheduler stalls or instruction issue slots.[1]
The optimizing assembler, ptxas, transforms that virtual PTX into a CUDA binary, called a cubin, for a specific streaming multiprocessor target such as sm_90. A cubin is a standard ELF container holding encoded GPU machine instructions plus symbols, relocations, and resource metadata.[2] In this translation, ptxas isn't a passive 1-to-1 translator:
- It performs physical register allocation, mapping unbounded virtual registers into the SM's finite register file through graph coloring and live-range analysis.
- It performs instruction scheduling, reordering instructions to interleave independent math between memory load issues and their arrival, hiding memory and arithmetic latency.
- It injects hardware control codes, setting per-instruction stall counts, yield flags, and scoreboard dependency barriers that direct hardware warp schedulers.
- It manages register spilling, inserting spill and fill instructions to thread-private
.localmemory if register demand exceeds physical constraints.
Engineers commonly call the human-readable native instruction listing SASS. SASS isn't an executable artifact or a portable source format. It's a disassembly of target-specific machine instructions stored in a cubin.
To see this transformation concretely, compare the virtual PTX emitted for the core vector add against the native SASS disassembled from the resulting sm_90 cubin:
1// Virtual PTX: unbounded virtual registers (%f1, %f2, %f3)
2ld.global.f32 %f1, [%rd3];
3ld.global.f32 %f2, [%rd4];
4add.f32 %f3, %f1, %f2;
5st.global.f32 [%rd5], %f3;1// Native SASS (sm_90): physical hardware registers (R2, R3, R4, R6)
2LDG.E.SYS R2, [R2.64] ;
3LDG.E.SYS R3, [R4.64] ;
4FADD R2, R2, R3 ;
5STG.E.SYS [R6.64], R2 ;Notice the register reuse in SASS: ptxas reused physical register R2 for both the first loaded float and the final addition result, freeing up registers for other concurrent warps.
There can also be an earlier compiler layer. NVVM intermediate representation (IR) is NVIDIA's GPU-oriented subset and extension of LLVM IR. Language frontends can generate NVVM IR, and libNVVM compiles compatible NVVM IR into PTX.[3][4] That makes this conceptual path useful:
Don't turn a conceptual compiler layer into a deployment promise. nvcc publicly documents source-to-PTX and PTX-to-cubin stages; it doesn't promise a stable, human-readable NVVM file for every build. Another frontend may use different IRs before it reaches PTX, and a non-NVIDIA backend won't target PTX at all. CUDA Tile, introduced later, has a separate Tile IR path; the SIMT path here isn't a universal pipeline for every CUDA language.

You find an FFMA instruction in nvdisasm output. Which layer has supplied the strongest evidence that this native operation exists?
Answer
The target cubin. nvdisasm reads encoded machine instructions from a cubin and renders them as SASS. A source expression or PTX instruction can suggest an FFMA, but a later compiler stage may still fuse, split, remove, or replace it.
nvcc builds a package, not one universal binary
A CUDA translation unit may contain CPU host code and GPU device code. nvcc separates those paths, sends the host path to a supported C++ compiler, compiles device code for requested virtual and real architectures, and embeds device images into the host object. The container of one or more device images is a fatbinary, usually shortened to fatbin.[1]
The distinction between compute_90 and sm_90 records intent:
| Target spelling | Artifact meaning | Runtime consequence |
|---|---|---|
compute_90 | PTX using the virtual feature set associated with compute capability 9.0 | Driver may just-in-time compile it for a compatible current or later GPU |
sm_90 | Cubin containing native code assembled for the 9.0 hardware target | Compatible GPU can load it without PTX code generation |
| Both in a fatbin | Native code for known deployment plus PTX fallback | Loader prefers compatible binary and retains a forward-compatibility path |
Ordinary PTX targets such as compute_90 provide a forward path to compatible later NVIDIA targets, not to older GPUs missing required features. The driver must also understand the emitted PTX ISA version. A toolkit upgrade can therefore raise the required driver version even when compute_90 stays unchanged.
Target suffixes matter: architecture-specific PTX such as compute_90a doesn't have the same forward-compatibility guarantee, and family-specific targets such as compute_100f restrict compatibility to their documented family. Cubins are narrower still, with compatibility rules within supported major compute-capability families, not across major versions. Check the exact target, not just the word “PTX.”[1]
The runtime flow branches after packaging:

This branch explains two otherwise confusing observations. A cold process may pay compilation or lazy-loading cost before the first kernel. A package may run on the build machine yet fail on another GPU because neither a compatible cubin nor usable PTX was embedded.
Produce artifacts you can inspect
On a supported CUDA development system, compile the CUDA source for one native Hopper target and keep PTX for future targets. --keep asks nvcc to retain supported intermediate files, while the two --generate-code entries package both sm_90 cubin and compute_90 PTX. Offline compilation and disassembly don't require a GPU, but they do require these NVIDIA tools and a supported host toolchain. Apple Clang alone can't run this lab. Use your deployment targets instead of copying sm_90 onto unrelated hardware.
1mkdir -p build/keep
2
3nvcc -O3 -lineinfo \
4 --generate-code arch=compute_90,code=sm_90 \
5 --generate-code arch=compute_90,code=compute_90 \
6 --keep --keep-dir build/keep \
7 vector_add.cu -o build/vector_addCreate standalone PTX and cubin files as well. Keeping both makes the boundary explicit and gives nvdisasm a cubin it can read directly.
1nvcc -O3 --ptx \
2 --gpu-architecture=compute_90 \
3 vector_add.cu -o build/vector_add.compute_90.ptx
4
5nvcc -O3 -lineinfo --cubin \
6 --generate-code arch=compute_90,code=sm_90 \
7 vector_add.cu -o build/vector_add.sm_90.cubinNow inspect the package before judging performance. cuobjdump accepts a standalone cubin or a host executable, object, library, or external fatbin. nvdisasm accepts standalone cubins and adds richer control-flow and register-liveness views.[2]
1# What did the host executable package?
2cuobjdump --list-elf build/vector_add
3cuobjdump --list-ptx build/vector_add
4
5# Read virtual and native instruction forms.
6cuobjdump --dump-ptx build/vector_add > build/embedded.ptx
7cuobjdump --dump-sass build/vector_add > build/embedded.sass
8
9# Check per-kernel registers, local memory, shared memory, and stack use.
10cuobjdump --dump-resource-usage build/vector_add
11
12# Read one standalone cubin with source lines.
13nvdisasm --print-code \
14 --print-line-info \
15 build/vector_add.sm_90.cubin > build/vector_add.lines.sass
16
17# Generate a separate register-liveness view.
18nvdisasm --print-code \
19 --print-life-ranges \
20 build/vector_add.sm_90.cubin > build/vector_add.liveness.sassRead these outputs as a chain of evidence:
- PTX header: Check
.version,.target, address size, and kernel entry name. A mismatched.targetcan explain a deployment failure before launch. - PTX body: Look for global loads, bounds predication, arithmetic, and the store. This confirms virtual operations, not final scheduling.
- Resource report: Record registers, static shared memory, local memory, and stack per kernel. Local memory can indicate spills, although not every local-memory use is a spill.
- SASS listing: Confirm native loads, arithmetic, stores, predicates, and architecture-specific instructions. Compare against compiler flags and source line info.
- Profiler trace: Measure executed instructions, memory behavior, and time on the target GPU. Static disassembly can't tell you which path dominates wall time.
⚠️ Common mistake: A shorter SASS listing isn't automatically faster. Instruction latency, issue rate, dependency chains, memory transactions, occupancy, and input-dependent control flow still decide runtime behavior.
Register pressure, occupancy cliffs, and spilling
When you inspect cuobjdump --dump-resource-usage or parse compiler logs, the most critical number is the register count per thread. That single metric dictates whether your kernel saturates GPU execution units or falls off a severe occupancy cliff.
Every modern NVIDIA Streaming Multiprocessor (SM), across architectures from Ampere (sm_80) and Ada (sm_89) to Hopper (sm_90) and Blackwell (sm_100), houses a physical register file of 64K (65,536) 32-bit registers. At the same time, an SM can host up to 2,048 active concurrent threads (organized as 64 warps of 32 threads).
To achieve 100% theoretical occupancy, all 2,048 threads must fit within the SM's register budget simultaneously:
When each thread uses 32 registers or fewer, all 64 warps reside on the SM at once. The hardware warp schedulers have maximum freedom to swap out a stalled warp (waiting on global memory or math pipelines) and issue independent instructions from ready warps.
What happens when register demand creeps upward? The SM can't dynamically allocate fractions of registers. Instead, hardware allocates registers in fixed warp granularities, producing steep occupancy cliffs:
| Registers per thread | Active threads per SM | Active warps per SM | Theoretical occupancy | Hardware impact |
|---|---|---|---|---|
| 32 | 2,048 | 64 | 100% | Maximum warp concurrency to hide instruction latency |
| 33 to 40 | 1,536 | 48 | 75% | First occupancy cliff; 16 fewer warps to hide memory stalls |
| 41 to 64 | 1,024 | 32 | 50% | Half SM warp capacity |
| 65 to 128 | 512 | 16 | 25% | Severe latency exposure; pipeline stalls become visible |
| 129 to 255 | 256 | 8 | 12.5% | Minimal latency hiding; memory stalls stall execution units |
| > 255 | 0 | 0 | 0% | Exceeds hardware limit; compilation fails or spills completely |
These transitions aren't gentle slopes. Adding a single local variable or unrolling an inner loop that pushes register count from 32 to 33 immediately drops thread occupancy by 25%.
The spilling disaster: local memory is DRAM
When a complex kernel needs more registers than the hardware allows, or when you force a low register limit via compiler flags, ptxas doesn't abort. It spills excess registers into Local Memory (.local).
Don't let the name mislead you: local memory is not fast on-chip SRAM. Local memory is thread-private memory backed by off-chip DRAM, cached in L1 and L2.
When 1,000 or more active threads on an SM spill variables to .local memory simultaneously, the SM's 128KB to 256KB L1 cache lines thrash relentlessly. Spilled values get evicted down to off-chip DRAM. Every register read and write that turned into a spill now incurs memory controller roundtrips, triggering a catastrophic 10x to 50x latency penalty that ruins kernel throughput.
Parsing ptxas -v reports
To catch register pressure and spills before running a kernel, pass -Xptxas -v (or --ptxas-options=-v) to nvcc:
1nvcc -O3 -Xptxas -v --gpu-architecture=sm_90 vector_add.cu -o build/vector_addThe compiler outputs an exact resource receipt for every compiled kernel:
1ptxas info : Compiling entry function 'add_kernel' for 'sm_90'
2ptxas info : Function properties for 'add_kernel'
3ptxas info : Used 32 registers, 0 bytes smem, 0 bytes cmem[0]
4ptxas info : Spilled 0 bytes to local memory, 0 bytes read backEvery field in this report delivers an operational truth:
Used 32 registers: Physical 32-bit registers allocated per thread. At exactly 32 registers, the kernel hits the 100% occupancy threshold for 2,048 threads per SM.0 bytes smem: Statically allocated shared memory per block.0 bytes cmem[0]: Constant memory bank 0 usage (kernel parameters and pointer addresses passed from the CPU host).Spilled 0 bytes to local memory, 0 bytes read back: Zero is the target. If you seeSpilled 16 bytes to local memory, 16 bytes read back, each thread spilled 4 32-bit registers (16 bytes). Across a full grid of 100,000 threads, those 4 words translate into hundreds of thousands of unnecessary memory bus accesses.
You have two primary compiler knobs to manage this tradeoff:
--maxrregcount=N: Forcesptxasto cap registers per thread at . If the kernel requires more than registers, the compiler spills to.localmemory to preserve target occupancy.__launch_bounds__(maxThreadsPerBlock, minBlocksPerMultiprocessor): A function qualifier placed in CUDA C++ source above__global__. It promises the compiler your launch bounds, allowingptxasto calculate the exact register ceiling for that block size without accidentally triggering an occupancy drop.
Keep an artifact manifest
A benchmark result becomes easier to reproduce when its compiler state travels beside it. This is a template, not a captured build. Replace every placeholder and record at least these fields:
1{
2 "source_revision": "git-sha",
3 "compiler": "nvcc",
4 "toolkit_version": "capture nvcc --version",
5 "driver_version": "capture nvidia-smi",
6 "virtual_target": "compute_90",
7 "native_targets": ["sm_90"],
8 "compile_flags": ["-O3", "-lineinfo"],
9 "embedded_images": ["compute_90 PTX", "sm_90 cubin"],
10 "kernel": "add_kernel",
11 "correctness": "record actual PASS/FAIL for n=1,255,256,257,1000"
12}Hash the source, compile options, target, compiler version, and any compile-time constants into a JIT cache key. Shape-specialized kernel systems also need shape, stride, data type, alignment, and algorithmic mode when those values change generated code. Caching only by function name can silently load a valid cubin for the wrong contract.
For example, suppose two variants have 1,000 elements but strides 1 and 2. They address different memory locations, so a specialization that assumes stride 1 mustn't be reused for stride 2. Similarly, changing the target architecture suffix from sm_90 to sm_90a unlocks architecture-specific Hopper features like Tensor Memory Accelerator (TMA) asynchronous copies and Warpgroup Matrix Multiply-Accumulate (WGMMA) instructions. A cached binary compiled for sm_90 won't use those instructions, while an sm_90a cubin will crash if loaded on a GPU without the architecture-accelerated feature set.
This CPU exercise builds a canonical key from an explicit compilation recipe, including launch geometry and pipeline stages. It doesn't compile a kernel or implement a production artifact store.
1from dataclasses import asdict, dataclass, replace
2import hashlib
3import json
4
5@dataclass(frozen=True)
6class Recipe:
7 source_sha256: str
8 toolchain: str
9 target: str
10 flags: tuple[str, ...]
11 shape: tuple[int, ...]
12 strides: tuple[int, ...]
13 dtype: str
14 block: int
15 num_warps: int = 4
16 num_stages: int = 2
17
18 def key(self):
19 encoded = json.dumps(asdict(self), sort_keys=True, separators=(",", ":"))
20 return hashlib.sha256(encoded.encode()).hexdigest()
21
22base = Recipe(
23 source_sha256=hashlib.sha256(b"vector-add-example-v1").hexdigest(),
24 toolchain="illustrative-compiler-build-A", target="sm_90",
25 flags=("-O3",), shape=(1000,), strides=(1,), dtype="float32", block=256,
26)
27assert base.key() == replace(base).key()
28variants = [
29 replace(base, source_sha256=hashlib.sha256(b"v2").hexdigest()),
30 replace(base, toolchain="illustrative-compiler-build-B"),
31 replace(base, target="sm_100"), replace(base, flags=("-O3", "--use_fast_math")),
32 replace(base, shape=(257,)), replace(base, strides=(2,)),
33 replace(base, dtype="float16"), replace(base, block=128),
34 replace(base, num_warps=8), replace(base, num_stages=3),
35]
36assert len({base.key(), *(variant.key() for variant in variants)}) == 11
37cache = {base.key(): "fixture-artifact-handle"}
38assert cache.get(replace(base).key()) == "fixture-artifact-handle"
39assert all(cache.get(variant.key()) is None for variant in variants)
40print("same recipe: hit; 10 code-generation changes: miss")1same recipe: hit; 10 code-generation changes: missThe marker string stands in for source bytes only in this exercise. A real cryptographic cache key must cover transitive includes, linked device libraries, compiler components, backend options, and any assumed pointer alignment (since 16-byte alignment enables 128-bit vector loads). Preserve flag order because repeated flags can override earlier ones. Runtime tensor values needn't enter the key unless compilation specializes on them. Store the artifact's hash and compatibility metadata separately; a cache hit isn't evidence that an untrusted binary is safe to load.
JIT is part of request latency
Just-in-time (JIT) compilation moves work from build time to runtime. NVIDIA's runtime compiler, NVRTC, accepts CUDA C++ strings and can emit PTX, cubin, CUDA Tile IR, or link-time optimization IR depending on target and options. The CUDA Driver API can load the result, while nvJitLink can combine PTX or link-time IR and produce a linked cubin.[5]
PTX loaded at runtime is compiled by the device driver's JIT compiler. Generated cubins enter a compute cache, and a driver upgrade invalidates that cache so the new JIT compiler can regenerate code.[6]
That runtime behavior creates an operational contract:
| Contract | Evidence to capture | Failure when omitted |
|---|---|---|
| Compilation identity | Source or IR hash, compiler version, flags, target, specialization values | Wrong artifact reused or rollout can't be reproduced |
| Load compatibility | GPU compute capability, driver version, PTX target, cubin targets | Missing image or unsupported PTX at module load |
| Cold-start budget | Compile time, module-load time, cache state, first launch time | First request violates latency objective while steady state looks healthy |
| Correctness | Reference outputs, tolerances, edge shapes, alignment and mask cases | Fast specialization returns wrong values on a boundary case |
| Performance | Warmups, synchronization, target GPU, input shapes, precision, profiler evidence | JIT or lazy-load time is mistaken for kernel execution time |
Multi-process compilation races in distributed clusters
In production distributed training and serving (such as 8 GPUs on an HGX node running PyTorch DistributedDataParallel, DeepSpeed, Megatron-LM, or vLLM), all 8 worker processes execute in parallel.
When all 8 ranks encounter an uncompiled kernel or a new sequence length at the exact same millisecond, they all detect a cache miss simultaneously. If all 8 processes attempt to compile and write to the same disk location (such as ~/.triton/cache/<hash>.so or ~/.nv/ComputeCache) without coordination, severe race conditions occur:
- Corrupted binary loads: Process A is halfway through writing the compiled ELF shared object when Process B checks the directory, finds the file name, and immediately calls
dlopen()orcuModuleLoad(). Process B crashes with an invalid ELF header, truncated file error, or segmentation fault. - Overlapping clobbers: Multiple processes open the same file descriptor for writing simultaneously, corrupting disk blocks and producing a broken cache entry that breaks all future runs until manually purged.
Production GPU engines resolve this race through three complementary strategies:
- Atomic temporary write and replace: Each worker writes its compiled binary to a unique temporary file on the exact same filesystem (for example,
<hash>.tmp.<pid>.<uuid>), flushes it to disk withos.fsync(), and performs an atomic rename viaos.replace(). Under POSIX semantics,renameis guaranteed to be atomic within the same filesystem. Any competing reader sees either the complete existing file or the complete newly replaced file, never a half-written fragment. - Advisory file locking: Before initiating compilation, the worker acquires an exclusive advisory lock using
fcntl.flockor a cross-platform lock file (<hash>.lock). The first process to acquire the lock performs the compile. The remaining 7 processes block. Once the lock releases, the waiting processes wake up, see the populated cache file, and take an immediate cache hit without recompiling. - Rank-0 compilation barrier: In managed distributed setups, rank 0 is assigned compilation responsibility during an explicit initialization phase. All workers wait at a
torch.distributed.barrier(). Once rank 0 finishes populating the on-disk cache, workers 1 through 7 safely load the compiled artifacts in read-only mode.
Use the driver's switches to test both deployment branches. The first command forces embedded PTX through JIT and disables its disk cache for the run. The second disables PTX JIT, so success requires a compatible embedded cubin.[7]
1CUDA_FORCE_PTX_JIT=1 CUDA_CACHE_DISABLE=1 ./build/vector_add
2CUDA_DISABLE_PTX_JIT=1 ./build/vector_addRun each mode in a fresh process. These shell assignments affect only that invocation; unset any conflicting JIT variables you previously exported. These are packaging tests, not kernel benchmarks.
CUDA module loading is lazy by default in current documentation. First use can therefore include module or kernel loading even when a compatible cubin exists. Warm the exact kernel or use eager loading when a latency-sensitive service needs that work outside the request path.[7]
A service packages sm_90 cubin plus compute_90 PTX. Its first request on a future compatible GPU is slow, but later requests are fast. What should you separate in the trace?
Answer
Separate process initialization, module loading, PTX-to-cubin JIT, cache write, first kernel launch, and steady-state execution. A fast warm kernel doesn't disprove expensive compilation or loading on the first request.
Same kernel, different unit of thought
CUDA C++ asks you to describe one thread, then organize threads into blocks. Triton asks you to describe one blocked program instance operating on vectors of offsets. Both versions below compute the same masked vector add.
The Triton kernel forms a block of 256 indices, loads two blocks under a mask, adds them, and stores the result. The compiler maps that blocked program to GPU threads and memory operations. The kernel follows Triton's vector-add tutorial; the wrapper below adds explicit input checks.[8][9]
1import torch
2import triton
3import triton.language as tl
4
5@triton.jit
6def add_kernel(x_ptr, y_ptr, out_ptr, n, BLOCK: tl.constexpr):
7 offsets = tl.program_id(axis=0) * BLOCK + tl.arange(0, BLOCK)
8 mask = offsets < n
9 x = tl.load(x_ptr + offsets, mask=mask, other=0.0)
10 y = tl.load(y_ptr + offsets, mask=mask, other=0.0)
11 tl.store(out_ptr + offsets, x + y, mask=mask)
12
13def add(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
14 if not (x.is_cuda and y.is_cuda and x.device == y.device):
15 raise ValueError("inputs must share a CUDA device")
16 if x.ndim != 1 or y.ndim != 1 or x.shape != y.shape:
17 raise ValueError("inputs must be matching one-dimensional vectors")
18 if x.dtype != torch.float32 or y.dtype != torch.float32:
19 raise ValueError("this fixture supports float32 only")
20 if x.requires_grad or y.requires_grad:
21 raise ValueError("this wrapper has no autograd rule")
22 if not (x.is_contiguous() and y.is_contiguous()):
23 raise ValueError("strided views require a different address calculation")
24 out = torch.empty_like(x)
25 n = x.numel()
26 if n:
27 with torch.cuda.device(x.device):
28 add_kernel[(triton.cdiv(n, 256),)](x, y, out, n, BLOCK=256)
29 return out
30
31if __name__ == "__main__":
32 for n in (0, 1, 255, 256, 257, 1000):
33 i = torch.arange(n, device="cuda", dtype=torch.float32)
34 x, y = 0.25 * i, i.remainder(7) - 3
35 actual = add(x, y)
36 torch.cuda.synchronize(x.device)
37 torch.testing.assert_close(actual, x + y, rtol=0, atol=0, equal_nan=False)
38 print(f"PASS n={n}")
39 try:
40 add(x[::2], y[::2])
41 except ValueError:
42 print("PASS rejected strided views")
43 else:
44 raise AssertionError("strided views were accepted")Run this on a supported Linux/PyTorch/Triton CUDA environment. It should print six shape passes and one strided-view rejection. The GPU code wasn't executed in this review; don't treat the CPU checks below as Triton compilation or memory-safety evidence. Inputs requiring autograd aren't supported by this wrapper: a custom backward rule would be separate work.
The arithmetic stayed fixed. Ownership changed:
| Question | CUDA C++ answer | Triton answer |
|---|---|---|
| What does one source instance own? | One scalar thread | One block of values |
| How is the global index formed? | blockIdx, blockDim, threadIdx | program_id plus arange |
| How are edge elements protected? | Scalar branch or predicate | Vector mask on load and store |
| Who chooses thread mapping? | Programmer chooses block size and per-thread work | Compiler maps blocked program; author supplies block and launch meta-parameters |
| What evidence closes the loop? | Cubin resource report, SASS, profiler, correctness test | Compiler artifacts, generated target code, profiler, correctness test |
Triton removes some thread-level bookkeeping. It doesn't remove the need to reason about coalescing, block size, occupancy, specialization, or numerical behavior. A blocked expression is an optimization hypothesis until generated artifacts and measurements support it.
How Triton compiles: the MLIR lowering pipeline
Triton isn't an interpreter or a simple string-template emitter. It's an optimizing compiler built on the Multi-Level Intermediate Representation (MLIR) framework. It lowers abstract block mathematics into native GPU machine code through a sequence of explicit intermediate dialects:
- Triton IR (
ttdialect): Parsed directly from the Python AST and@triton.jitdecorator. This level captures high-level tile operations (tt.load,tt.store,tt.add,tt.dot). Operations at this stage are hardware-agnostic: they express math over multidimensional tiles without committing to thread counts, warp allocations, or physical memory layouts. - TritonGPU IR (
ttgdialect): This stage contains Triton's core GPU optimization intelligence. The compiler takes the abstract tiles and maps them to physical GPU resources:- Distributed layout assignment: The compiler assigns formal layout encodings (such as
#triton_gpu.blocked,#triton_gpu.shared, and#triton_gpu.dot_op) that dictate how each element in a tile is distributed across warps and individual thread lanes. - Shared memory allocation and XOR swizzling: Matrix tiles loaded into shared memory often suffer from shared memory bank conflicts when threads access columns across regular power-of-two strides. Triton automatically allocates shared memory buffers and applies an XOR address-swizzling pattern to the indices. This scatters memory accesses across all 32 independent banks, completely eliminating bank serialization without manual programmer padding.
- Asynchronous software pipelining: For reduction or GEMM loops, Triton automatically constructs circular multi-stage buffers in shared memory (
num_stages = 3or4). It emits asynchronous global-to-shared copy instructions (cp.asyncon Ampere/Hopper), so that while the SM computes iteration on Tensor Cores, it prefetches iteration from global memory in the background. - Vectorized memory coalescing: When the compiler inspects
offsets = pid * BLOCK + tl.arange(0, BLOCK), it proves that the memory slice is contiguous and aligned. It automatically synthesizes wide 128-bit vector load instructions (LDG.E.128in SASS) that fetch 16 bytes per thread in a single transaction. This guarantees coalesced memory bus saturation without requiring manualfloat4pointer casting in user code.
- Distributed layout assignment: The compiler assigns formal layout encodings (such as
- LLVM IR / NVVM IR: The optimized TritonGPU dialect lowers into LLVM IR annotated with target GPU intrinsics (such as barrier synchronization, special registers, and warp shuffles).
- PTX virtual ISA: The LLVM NVPTX backend compiles the LLVM IR into PTX virtual assembly.
- Native SASS via
ptxas: Finally, the NVIDIA driver invokesptxasto allocate physical registers from the SM's 64K register file, schedule instructions, and assemble the final.cubinbinary.
Check ownership without a GPU
A CPU enumeration can verify the index arithmetic independently of either compiler. It can't detect generated-code bugs, GPU races, or invalid device memory access. Predict the counts before running it: length 257 requires two 256-lane blocks, with 255 lanes masked out.
1def covered_indices(n, block=256, *, include_equal=False):
2 if n < 0 or block <= 0:
3 raise ValueError("length must be nonnegative and block positive")
4 blocks = (n + block - 1) // block
5 active = []
6 for program in range(blocks):
7 for lane in range(block):
8 i = program * block + lane
9 if i < n or (include_equal and i == n):
10 active.append(i)
11 return blocks, active
12
13for n in (0, 1, 255, 256, 257, 1000):
14 blocks, indices = covered_indices(n)
15 assert indices == list(range(n)) # No duplicates, omissions, or excess.
16 print(f"n={n}: blocks={blocks}, active={len(indices)}, masked={blocks * 256 - n}")
17
18# Deliberately change < to <=. It only looks correct on full blocks.
19assert covered_indices(256, include_equal=True)[1] == list(range(256))
20bad = covered_indices(257, include_equal=True)[1]
21assert bad[-1] == 257 and bad != list(range(257))
22print("off-by-one mask: full block passes; n=257 exposes index 257")1n=0: blocks=0, active=0, masked=0
2n=1: blocks=1, active=1, masked=255
3n=255: blocks=1, active=255, masked=1
4n=256: blocks=1, active=256, masked=0
5n=257: blocks=2, active=257, masked=255
6n=1000: blocks=4, active=1000, masked=24
7off-by-one mask: full block passes; n=257 exposes index 257Now change the block size to 128 and repeat. Length 257 needs three blocks, with 127 masked lanes. A passing multiple-of-block test alone misses the off-by-one error; that's why both GPU fixtures include 255 and 257.
Six authoring surfaces, six control contracts
A kernel language isn't a ranking from easy to powerful. Each surface chooses which decisions belong to author, compiler, library, and runtime.
CUDA C++
CUDA C++ uses single instruction, multiple threads (SIMT) semantics. You control thread and block geometry, address calculations, synchronization, memory spaces, and low-level intrinsics. That directness fits irregular algorithms, architecture experiments, and cases where a higher layer hides a critical decision. Its portability surface is NVIDIA GPUs, with source, PTX, and fatbins handling generations inside that ecosystem.[6]
Evidence should reach the cubin. Keep ptxas resource output, disassemble native code, profile target hardware, and compare results against a trusted implementation.
Triton block programs
Triton is a Python-embedded domain-specific language (DSL) whose program instances operate on blocks. Its compiler performs block-level data-flow analysis and can automate coalescing, vectorization, shared-memory management, synchronization, and instruction selection.[8] Current project documentation lists NVIDIA and AMD GPU support, but backend support and generated code still vary by hardware and Triton release.[10]
Triton fits custom fusion and dense or structured kernels where block ownership is natural. Inspect specialized IR and target assembly when available, because one Python function can compile into many shape, type, and meta-parameter variants.
CUTLASS and CuTe
CUTLASS supplies CUDA C++ templates for high-performance linear algebra. CuTe, used inside modern CUTLASS, models hierarchical tensor layouts, tensors, copy atoms, and matrix multiply-accumulate atoms. Its C++ surface keeps detailed control over thread-data mapping and architecture features. CuTe DSL brings the same low-level concepts into a Python JIT stack while preserving explicit memory, thread, and data hierarchy.[11]
Consider this family when matrix multiplication, convolution, attention building blocks, or unusual layouts need explicit layout and hardware-atom control. Whether it exposes a needed capability depends on the specific APIs and releases being compared. Expect a steeper layout-algebra learning curve and a larger specialization space. Use CUTLASS profiler or an equivalent harness, inspect PTX and SASS, and keep compile-time policies in the benchmark manifest.
CUDA Tile
CUDA Tile changes CUDA's unit of thought from one SIMT thread to one block operating collectively on immutable multidimensional tiles. The compiler chooses the number of threads per block and maps tile operations onto registers, shared memory, tensor cores, and other hardware resources. SIMT and tile kernels can coexist in one application.[12]
Tooling snapshot, verified September 2, 2026: NVIDIA's CUDA 13.3 documentation lists CUDA Tile in Python through
cuda.tileand CUDA Tile C++ in the toolkit from 13.3 onward. Tile shapes must be compile-time powers of two in the documented model. Treat earlier toolkits and different language releases as separate environments, not compatible assumptions.[12]
CUDA Tile offers source portability across NVIDIA generations by hiding thread mapping, not cross-vendor portability. Inspect Tile IR or emitted device images where supported, record compiler version, then profile on each architecture you claim to support.
Pallas
Pallas extends JAX with custom kernels for GPU and tensor processing unit (TPU) backends. Kernels use references (Refs) to memory, launch grids, block specifications (BlockSpecs), and backend-specific pipelining or hardware APIs. A kernel is embedded in a larger JAX program through an API such as pl.kernel, pl.pallas_call, or a backend-specific wrapper; their signatures and supported transformations differ.[13]
Pallas is useful when JAX composition or GPU and TPU integration matters. A custom kernel doesn't automatically inherit every JAX transformation; check the chosen API's support and supply derivative rules when needed. Shared concepts don't guarantee one source or tuning configuration performs equally across backends. JAX describes Pallas as under active development, and its quickstart still warns that the API is experimental. Hardware coverage is operation-specific: JAX 0.11 added Ampere matrix instructions and asynchronous copies to Mosaic GPU. The older Pallas Triton backend is deprecated in JAX 0.11 and scheduled for removal.[14] Pin JAX and backend versions, test interpret mode where supported, inspect lowering, and profile each target.
NKI
Neuron Kernel Interface (NKI) targets AWS Trainium, Inferentia2, Trainium2, and Trainium3 NeuronCores. Its high-level nki.language API handles tensor indexing and placement, while lower-level nki.isa exposes hardware operations for computation, data movement, dynamic control flow, and cross-core communication. The documented execution pattern moves inputs from high-bandwidth memory (HBM) into the on-chip state buffer (SBUF), computes on NeuronCore engines, then stores outputs to HBM.[15]
NKI fits workloads committed to AWS Neuron hardware that need custom operations or tighter control than framework compilation supplies. It isn't a CUDA portability layer. Keep Neuron compiler and instance type in the artifact record, use framework-level correctness comparisons, and collect Neuron profiler evidence.
The comparison is easier to use after each model has a concrete meaning:
| Surface | Author's main unit | Explicit control | Portability boundary | Minimum convincing evidence |
|---|---|---|---|---|
| CUDA C++ | Thread, warp, block | Explicit thread-level control | NVIDIA CUDA GPUs | PTX, cubin resources, SASS, target profile |
| Triton | Blocked program | Tile shape, loads, masks, launch meta-parameters | Supported Triton GPU backends | Specialization key, compiler IR or assembly, target profile |
| CUTLASS/CuTe | Hierarchical layout and hardware atom | Explicit layout, copy, pipeline, and MMA control | NVIDIA CUDA GPUs | Policy manifest, profiler, PTX/SASS, numeric check |
| CUDA Tile | Block-level immutable tiles | Tile partition and operations; compiler owns threads | CUDA Tile-capable NVIDIA stack | Tile/compiler artifact, version pin, per-target profile |
| Pallas | Grid program over Refs and blocks | Memory movement, block specs, backend pipeline APIs | JAX GPU and TPU backends, with backend-specific constraints | JAX/backend pin, lowering evidence, per-target profile |
| NKI | Tile over Neuron memory and engines | SBUF placement plus optional low-level ISA control | AWS Inferentia2 and Trainium families | Neuron compiler record, profiler, framework comparison |
A Triton kernel and a CuTe rewrite pass the same correctness fixture. Triton is faster on H100, while CuTe is faster on B200. What must the review artifact contain before either implementation becomes the default?
Answer
Keep both source revisions, compiler and backend versions, specialization keys, compile flags, native targets, correctness tolerances, cold and warm timings, emitted-code evidence, and per-target profiler results. Choose against the deployment GPU mix and latency objective. One architecture's timing or a PTX instruction count can't establish a portable winner.
Diagnose the first failing boundary
Compiler failures become manageable when you ask which representation last satisfied its contract.
| Symptom | Likely boundary | Evidence to collect | Next action |
|---|---|---|---|
| Source compile rejects type, address space, or intrinsic | Source to frontend IR | Full compiler log, minimal source, language and compiler versions | Reduce to smallest rejected construct; check supported language subset |
ptxas rejects target or instruction | PTX to cubin | PTX header, ptxas log, toolkit version, requested sm | Align PTX feature set, toolkit, and target architecture |
| "No kernel image" at load or launch | Fatbin selection | GPU compute capability, cuobjdump --list-elf, --list-ptx | Package compatible cubin or usable PTX fallback |
| Forced PTX JIT fails, normal mode works | Driver JIT path | Embedded PTX, driver version, JIT error log | Fix PTX compatibility or driver floor; don't treat cubin success as forward-compatibility proof |
| First call is slow, warm calls are fast | Compile or lazy-load path | Fresh-process timeline, cache state, module-loading mode | Precompile, warm exact variants, or move eager loading outside request path |
| Register count rises and throughput falls | IR or target-code optimization | Old and new resource reports, SASS, occupancy and stall profile | Find specialization or code change that extended live ranges or caused spills |
| Edge shape returns wrong values | Source mask or specialization contract | Failing shape, strides, alignment, generated variant, reference output | Fix boundary mask and include shape or stride in specialization key |
nvdisasm rejects host executable | Inspection tool input | File type and packaged image list | Use cuobjdump on host file or extract cubin before nvdisasm |
Don't skip directly from source to profiler. A profiler identifies an executed bottleneck, but it can't explain a missing image that never loaded. Disassembly proves emitted code, but it can't prove correct answers. Correctness tests prove values for their fixtures, but they can't establish speed. The evidence layers complement one another.
Choose by the decision you must own
Start with a framework or vendor library if it meets the need. For a custom kernel, choose by the decision you need to express:
- Use a framework or vendor library when an existing operation already meets correctness and performance needs.
- Use Triton when custom block structure, fusion, or masking is central and compiler-managed thread mapping is acceptable.
- Use Pallas when the kernel must compose naturally with JAX or span its GPU and TPU ecosystem.
- Use NKI when NeuronCore memory placement or engines are the target, not an afterthought.
- Use CUDA Tile when block-level tile semantics fit and NVIDIA's compiler should own intra-block threads.
- Use CUTLASS/CuTe when tensor layouts, asynchronous copies, and matrix hardware atoms need explicit control.
- Use CUDA C++ when per-thread behavior, synchronization, special instructions, or unsupported irregularity requires it.
These are alternatives, not a ranked ladder. A prototype may begin in Triton, expose a compiler limitation, and move one hot kernel to CuTe or CUDA C++. Another kernel may move upward after a compiler learns the missing transformation.
If you have a compatible NVIDIA environment, practice the full artifact path before moving on. Build the vector-add executable with an sm_90 cubin and compute_90 PTX, save its manifest and cuobjdump image inventory, then run forced-PTX and cubin-only checks in fresh processes. Capture the resource report, SASS, correctness output, and separate cold and warm timings in one review artifact. Run the Triton version on the same five nonempty shapes. Both implementations should match the same reference exactly, and the strided-view test must reject before launch. For a memory-safety check, run the CUDA executable under compute-sanitizer --tool memcheck ./build/vector_add. Capture failures as well as passes. On CPU-only machines, complete the indexing and cache-identity exercises without claiming a GPU build or benchmark.