Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Your vector-add kernel passed every correctness check yesterday. Today the same source 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 kernel performance lab then treated memory traffic, launch geometry, occupancy, and numerical checks as evidence. Keep those habits. Here we follow the compiler artifacts between source and hardware, because each boundary owns a different class of bugs.
One addition, several representations
Start with one operation: for every valid index , write . CUDA C++ expresses the work from one thread's point of view. The launch supplies many threads.
1#include <cuda_runtime.h>
2
3#include <algorithm>
4#include <cmath>
5#include <cstdlib>
6#include <iostream>
7#include <vector>
8
9void check(cudaError_t status) {
10 if (status != cudaSuccess) {
11 std::cerr << cudaGetErrorString(status) << "\n";
12 std::exit(1);
13 }
14}
15
16__global__ void add_kernel(
17 const float* x,
18 const float* y,
19 float* out,
20 int n
21) {
22 int i = blockIdx.x * blockDim.x + threadIdx.x;
23 if (i < n) {
24 out[i] = x[i] + y[i];
25 }
26}
27
28void launch_add(const float* x, const float* y, float* out, int n) {
29 constexpr int threads = 256;
30 int blocks = (n + threads - 1) / threads;
31 add_kernel<<<blocks, threads>>>(x, y, out, n);
32}
33
34int main() {
35 constexpr int n = 1000;
36 std::vector<float> x(n, 1.25F);
37 std::vector<float> y(n, 2.75F);
38 std::vector<float> out(n);
39
40 float *d_x, *d_y, *d_out;
41 check(cudaMalloc(&d_x, n * sizeof(float)));
42 check(cudaMalloc(&d_y, n * sizeof(float)));
43 check(cudaMalloc(&d_out, n * sizeof(float)));
44 check(cudaMemcpy(d_x, x.data(), n * sizeof(float), cudaMemcpyHostToDevice));
45 check(cudaMemcpy(d_y, y.data(), n * sizeof(float), cudaMemcpyHostToDevice));
46
47 launch_add(d_x, d_y, d_out, n);
48 check(cudaGetLastError());
49 check(cudaDeviceSynchronize());
50 check(cudaMemcpy(out.data(), d_out, n * sizeof(float), cudaMemcpyDeviceToHost));
51
52 check(cudaFree(d_x));
53 check(cudaFree(d_y));
54 check(cudaFree(d_out));
55
56 float max_abs_error = 0.0F;
57 for (float value : out) {
58 max_abs_error = std::max(max_abs_error, std::abs(value - 4.0F));
59 }
60
61 bool correct = max_abs_error == 0.0F;
62 std::cout << (correct ? "PASS" : "FAIL")
63 << " max_abs_error=" << max_abs_error << "\n";
64 return correct ? 0 : 1;
65}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 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 device code, the documented path reaches Parallel Thread Execution (PTX), a versioned virtual instruction set architecture (ISA). ptxas then assembles PTX into a CUDA binary, called a cubin, for a particular streaming multiprocessor target such as sm_90. A cubin is an ELF file containing encoded GPU instructions plus symbols, relocations, and resource metadata.[1][2]
Engineers commonly call the human-readable native instruction listing SASS. SASS isn't another portable source format in this workflow. It's a disassembly of target-specific instructions stored in a cubin.
There can 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:
But 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.

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 |
PTX offers forward compatibility, not backward compatibility. PTX generated for compute_90 may be JIT-compiled for a later target, but it can't run on an older GPU that lacks the required feature set. Cubin compatibility is narrower: NVIDIA documents compatibility within some major compute-capability families, and not across major versions.[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
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.
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.
Keep an artifact manifest
A benchmark result becomes much easier to reproduce when its compiler state travels beside it. 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": "max_abs_error=0 for deterministic FP32 fixture"
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.
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 |
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. Reset the environment afterward. 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.[8]
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)
10 y = tl.load(y_ptr + offsets, mask=mask)
11 tl.store(out_ptr + offsets, x + y, mask=mask)
12
13def add(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
14 out = torch.empty_like(x)
15 n = x.numel()
16 grid = (triton.cdiv(n, 256),)
17 add_kernel[grid](x, y, out, n, BLOCK=256)
18 return outThe 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.
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.[9]
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.[10]
Choose this family when matrix multiplication, convolution, attention building blocks, or unusual layouts need more architectural control than Triton exposes. 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.[11]
Tooling snapshot, verified August 29, 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.[11]
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. Current APIs embed a kernel in a larger JAX program through pl.kernel or a backend-specific kernel wrapper.[12]
Pallas is useful when JAX composition, differentiation, or GPU and TPU integration matters. 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. Mosaic GPU supports Hopper and newer GPUs; the older Triton backend is deprecated in JAX 0.11 and scheduled for removal.[12] 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.[13]
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 | Highest 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 | Very high 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 at the highest abstraction that exposes the decision blocking your kernel:
- 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.
- Drop to CUDA C++ when per-thread behavior, synchronization, special instructions, or unsupported irregularity requires it.
That order isn't permanent. 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.
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. Rebuild the Triton version for at least two shapes and compare the same evidence so a language change keeps the fixture and measurement contract intact.