Chapter 2 · Foundations
Compilation and machine code
2.4

Compilation and machine code

A CUDA source file mixes host and device code, and nvcc, the CUDA compiler driver, splits it: the host code is forwarded to an ordinary C++ compiler, while the device functions go through NVIDIA’s own compilers. The device side compiles in two stages. First the CUDA C++ is compiled to , assembly for a virtual GPU defined only by its capabilities, not by any physical chip. Then an assembler called ptxas compiles the PTX into a cubin, an ELF binary holding the actual machine instructions for one real architecture. The compiled GPU code is embedded back into the host executable, which can carry both cubins and the PTX text itself.

The split exists because NVIDIA does not guarantee binary compatibility across GPU generations: instruction sets and encodings change between major compute capabilities, so a cubin built for one generation will not run on the next. Every nvcc compilation therefore names two architectures, a virtual one (compute_80) stating which features the code needs, and a real one (sm_80) naming the processor to build for. Because the executable can also embed the PTX, the GPU driver can it at runtime for whatever GPU is actually present, including architectures that did not exist when the application shipped.

The two-stage device compile. nvcc compiles the device side of a .cu file to PTX, assembly for a virtual GPU, and ptxas compiles that into the machine instructions (SASS) of one real architecture. Because the executable can also carry the PTX, the driver can JIT compile it at runtime for the GPU actually present.

Everything above the cubin is an intermediate form, which is why kernel authors read the machine code when it matters. PTX is what the compiler promised; the disassembled cubin, called , is what the hardware will execute, and the two can differ because ptxas optimizes, allocates registers, and schedules instructions on the way down. The toolkit ships two disassemblers: cuobjdump accepts both cubins and host executables and can extract the embedded PTX, while nvdisasm accepts only cubins but adds control flow analysis and richer display options. When this book claims a kernel change did something, disassembling is how that claim gets checked: against the instructions the compiler actually emitted, not the ones the source seems to imply.

What PTX looks like is worth seeing once. NVIDIA’s Understanding PTX post compiles the same bounds-checked vecAdd kernel from section 1.1 and prints the result. Most of it reads like assembly for any machine, loads, an add, a store, with the CUDA thread model showing through in special registers: %tid.x, %ntid.x, and %ctaid.x are threadIdx.x, blockDim.x, and blockIdx.x.

vecAdd compiled to PTX, from Understanding PTX (declarations and parameter loads elided)
.visible .entry _Z6vecAddPfS_S_j(
.param .u64 _Z6vecAddPfS_S_j_param_0,
// ...
.param .u32 _Z6vecAddPfS_S_j_param_3
)
{
// ...
mov.u32 %r3, %tid.x;
mov.u32 %r4, %ntid.x;
mov.u32 %r5, %ctaid.x;
mad.lo.s32 %r1, %r5, %r4, %r3;
setp.ge.u32 %p1, %r1, %r2;
@%p1 bra $L__BB0_2;

// ...
ld.global.f32 %f1, [%rd8];
ld.global.f32 %f2, [%rd6];
add.f32 %f3, %f2, %f1;
// ...
st.global.f32 [%rd10], %f3;

$L__BB0_2:
ret;
}

The kernel survives translation recognizably. A single mad.lo.s32, a multiply-add, computes the entire workIndex expression; setp.ge.u32 and the predicated branch @%p1 bra are the bounds check, comparing the index against the length parameter (loaded into %r2 in the elided lines) and jumping past the body when it is out of range; and the load, add, store triple in the middle is the arithmetic itself. Because PTX targets a virtual machine, the post notes, ptxas behaves more like a compiler than a traditional assembler when it lowers this into a cubin, which is where the register allocation and scheduling that separate PTX from SASS happen. The post draws the comparison this whole pipeline rests on: PTX plays the role in CUDA that LLVM IR plays for clang, a portable middle stage that many front ends can target and any supported GPU can be compiled from.

What a kernel ships as#

All of that machinery exists to answer one practical question: what does a program actually ship? Every NVIDIA GPU carries a , and binary compatibility is not guaranteed across major versions. An application compiled for a compute capability 9.0 GPU will not run on a 10.0 GPU, and the reverse fails too, because the instruction set and the instruction encodings differ between major versions. Within a major version compatibility can be guaranteed under certain conditions, and the useful case is inclusion: code compiled for sm_80 runs on the other 8.x parts, such as sm_86 and sm_89.

This is why an nvcc command names two architectures, and why the two choices run in opposite directions. The virtual architecture, given with --gpu-architecture (short form -arch), is a statement about which features the code requires, so it should be chosen as low as possible: a lower virtual architecture leaves a wider set of real GPUs available for the second stage, while demanding features the application never uses only narrows that set. The real architecture, given with --gpu-code (short form -code), names the processor to generate machine code for and should be chosen as high as possible, since that generally produces better code, but that is only possible with knowledge of the GPUs the application will actually meet. -arch takes a single virtual architecture; -code takes a list, and nvcc runs the second stage once per entry and embeds each result.

Omit both and the defaults still make the same two choices on the program’s behalf: plain nvcc x.cu is equivalent to nvcc x.cu --gpu-architecture=compute_75 --gpu-code=sm_75,compute_75, machine code for one architecture with the PTX beside it. Naming only a virtual architecture in --gpu-code skips the second stage entirely and embeds the PTX alone, leaving the driver to assemble it when the application launches. That is the JIT path, and its cost is startup delay, which the driver’s compilation cache, persistent over multiple runs of the application, exists to absorb. The shipping answer is to do both at once:

one fatbinary for two real architectures plus PTX, from the CUDA Compiler Driver NVCC documentation
nvcc x.cu --gpu-architecture=compute_80 --gpu-code=compute_80,sm_86,sm_89

That command generates exact code for two architectures, plus PTX code for use by JIT in case a next-generation GPU is encountered. nvcc organizes device code into , containers that hold multiple translations of the same GPU source, and at runtime the CUDA driver selects the most appropriate translation when the device function is launched. That is the whole compatibility story in one file: machine code wherever the target was known at build time, PTX wherever it was not. Two shorthands cover the common cases: -arch=all-major embeds an image for every supported major version and the earliest supported one, plus PTX for the highest major virtual architecture, and -arch=native detects the GPUs visible on the build machine and generates code for exactly those, with no PTX at all.