Skip to main content

Updated Aug 14, 2026

PTX and Inline Assembly

Almost every optimization in this section works through the C++ source and trusts nvcc to generate good machine code from it. Occasionally that trust runs out — an instruction has no intrinsic, or the compiler's chosen code sequence needs to be inspected or overridden directly — and the only way forward is reading or writing below the C++ level. This page covers both: reading the SASS a kernel actually compiles to, and, rarely, writing PTX by hand to reach an instruction the compiler won't emit on its own.

PTX is not the machine code​

The Compilation Model lays out the full pipeline: PTX is a virtual ISA that device code compiles to first, ptxas compiles that PTX into SASS for a specific architecture, and ptxas optimizes aggressively in the process — so the PTX a kernel produces is not what runs on the GPU, and reasoning about performance from PTX alone can be misleading.

Reading SASS​

The same inspection commands The Compilation Model introduces apply here directly:

cuobjdump -sass ./kernel # disassembled SASS
nvdisasm -c kernel.cubin # control-flow annotated

The following is a representative sample of the kind of output those commands produce for a tiled kernel's inner loop — not a captured compile, but illustrative of the instruction classes worth recognizing:

/*0058*/ LDG.E.128 R4, [R8]; // vectorized 128-bit global load
/*0068*/ LDS R12, [R20]; // shared-memory load
/*0070*/ FFMA R16, R4, R12, R16; // fused multiply-add, FP32
/*0078*/ HFMA2 R18, R6, R14, R18; // fused multiply-add, packed FP16 pair
/*0088*/ BAR.SYNC 0x0; // block-wide barrier (__syncthreads)

LDG.E.128 is a coalesced, vectorized global load moving 128 bits (four FP32 elements) in one instruction rather than four separate 32-bit loads — see Memory Access Optimization for why vectorizing loads this way matters. LDS is a shared-memory load. FFMA and HFMA2 are fused multiply-add on FP32 and packed FP16 pairs respectively — seeing FFMA where the source wrote a separate multiply and add confirms contraction happened, per Instruction-Level Optimization. BAR.SYNC is the SASS a __syncthreads() call compiles to.

Inline PTX​

For the rare case where no intrinsic reaches a specific instruction, asm embeds PTX directly in device code:

__device__ float fmaRn(float a, float b, float c) {
float d;
asm volatile("fma.rn.f32 %0, %1, %2, %3;"
: "=f"(d)
: "f"(a), "f"(b), "f"(c));
return d;
}

%0–%3 map positionally onto the operand list that follows the instruction string: outputs first, then inputs, in the order they're listed.

Constraints and clobbers​

Each operand's constraint letter tells the compiler which register class to allocate and how it's used:

ConstraintMeaning
h16-bit register
r32-bit integer register
l64-bit integer register
f32-bit float register
d64-bit float register
= (prefix)write-only operand
+ (prefix)read-write operand

volatile on the asm statement stops the compiler from reordering, moving, or eliding the instruction as dead code — without it, an asm block with no observed output can simply disappear under optimization the same way any other dead code can. A "memory" clobber tells the compiler the instruction has side effects on memory it can't see from the operand list alone — necessary for anything that reads or writes through a pointer the constraint list doesn't capture — and forces the compiler to avoid reordering ordinary loads/stores across it.

cuda::ptx helpers​

Raw asm strings are untyped, unchecked by the compiler beyond the constraint syntax, and easy to get subtly wrong. cuda::ptx, part of libcu++, is the preferred modern route for the instructions it covers: typed, documented wrapper functions over specific PTX instructions — cp.async.bulk, mbarrier operations, and others — that give normal C++ overload resolution and type checking instead of a hand-written asm string. Code reaching for one of the instructions cuda::ptx wraps should use the wrapper rather than write raw asm for it.

Inline PTX blocks optimization across it

An asm block is opaque to the compiler: it cannot be scheduled around, reordered with, or optimized together with surrounding instructions the way ordinary C++ can, and it pins the kernel to whatever instruction availability the target architecture happens to have. It is justified for instructions with no intrinsic and no cuda::ptx wrapper — a specific mbarrier operation, redux.sync, a cache-hint load variant — and almost never justified for ordinary arithmetic, where the compiler's own instruction selection is already good.

When this is justified​

Reach for inline PTX or cuda::ptx only after confirming, via the SASS, that the compiler isn't already emitting the instruction sequence wanted — not from a guess about what the compiler "should" do. Nsight Compute and the disassembly commands above are how that gets confirmed before reaching for either tool.

See also​

  • The Compilation Model — the PTX-to-SASS pipeline this page reads and occasionally bypasses.
  • Instruction-Level Optimization — checking the SASS for contraction, unrolling, and independent accumulators, the same technique this page applies more broadly.
  • Nsight Compute — confirming an instruction-level problem is real before reaching for asm.
  • GPU & Accelerators — the section index and its three learning paths.