The Software Frontier

The Software Frontier

How CUDA Binaries Actually Work

A byte-level surgical dissection of the cubin and fatbin formats, and the second encoding nobody documents.

Lorenzo Bradanini's avatar
Lorenzo Tettamanti's avatar
Lorenzo Bradanini and Lorenzo Tettamanti
Jul 31, 2026
∙ Paid

Intro

I spent a good part of last year writing an x86-64 assembler by hand, in C, for no more reason than wanting to know what an object file really is. The thing that surprised me was not the instruction encoding, but was the metadata.

A modern object file is essentially a negotiation between the compiler and the loader about few things the machine code itself cannot say properly: where the arguments are, how much stack to reserve, which symbols are entry points, what has to be patched before anything runs.

So when I started reading GPU binaries, the question I kept asking more and more was not “what does this instruction do.” It was “what is this file promising the driver.”

The answer turns out to be… well, a lot, and almost none of it is written down. NVIDIA just documents the container in one sentence and the disassembler in twelve pages. And that’s basically it.

The CUDA Binary Utilities manual says a cubin is “an ELF-formatted file which consists of CUDA executable code sections as well as other sections containing symbols, relocators, debug info, etc.” That is the specification, and the word “etc.” is where the whole platform lives.

I want you to imagine this piece as a medical dissection, if it’s possible to say so. Every single number, hex dump and section listing below, came out of a container on my own machine, and the exact commands are in the appendix so you can disagree with me precisely.

Two things made it possible: first thing is that you do not need a GPU to compile, link or disassemble CUDA binaries: ptxas, fatbinary, nvdisasm and cuobjdump are a bunch of ordinary x86 programs. The second is that the entire toolchain is on PyPI, so getting a byte-exact CUDA 13.3.73 install is actually just one pip install.

Methodology

Everything measured here is from CUDA 13.3.73 (built 9 June 2026) installed from the nvidia-cuda-nvcc, nvidia-cuda-cuobjdump and nvidia-cuda-nvdisasm wheels on x86-64 Linux, plus the cuBLAS 13.6.0.2 wheel for the library dissection.

I do not currently have a GPU, which means every claim here is about what the toolchain emits and not about what silicon does with it. There are a few parts where I’m reading structure out of bytes instead of documentation, dont’t worry: I’ll say so and I give it like a confidence tier at the end.

NVIDIA publishes no specification for the cubin section layout, the .nv.info attribute encoding, the fatbin entry header or the Mercury sections, and all the interpretations are mine, unless specified.


Seven processes to compile one file

I want you to introduce the format and the pipeline now, because the shape of the output is derived from it. Keep in mind that nvcc is not a compiler, it’s just a driver that orchestrates other programs, and it will show you its plan.

$ nvcc -arch=sm_90 -c -o k.o saxpy.cu --dryrun
gcc      -D__CUDA_ARCH_LIST__=900 -E -x c++ -D__CUDACC__ ...        # host preprocess
cudafe++ --c++17 --static-host-stub --device-hidden-visibility ...  # split host/device
gcc      -D__CUDA_ARCH__=900 -E -x c++ -DCUDA_DOUBLE_MATH_FUNCTIONS # device preprocess
cicc     ... saxpy.cudafe1.gpu -o saxpy.ptx                        # C++ front end -> PTX
ptxas    -arch=sm_90 -m64 saxpy.ptx -o saxpy.sm_90.cubin           # PTX -> SASS
fatbinary -64 --cicc-cmdline=... --image3=...  -o saxpy.fatbin.c    # package
gcc      -c -x c++ saxpy.cudafe1.cpp -o k.o                        # host compile

We have two preprocessor passes over the same source with “different macros”, then a source-to-source splitter, a front end that emits PTX, an assembler that (you know) emits machine code, a packager that turns the machine code into a C array, and a host compiler that swallows the result.

The artifacts in the middle are real files you can save with --keep, and each one is a place where the format is decided.

The only thing that matters is where the close part actually is. We alredy know that cicc is the NVVM-based front end and it produces PTX, which is a published virtual instruction set, with all docs that you can read. But for ptxas,which takes PTX and produces the thing this article is about, we don’t know nothing.

Everything that works below PTX is the vendor’s private business, and everything upstream has alredy been made public: Clang, Triton, Julia, Mojo and tinygrad all emit PTX, and NVIDIA itself ships libNVVM, so they can do it.

The interesting feature in the CUDA stack is just one process wide.


The container is an ELF file that lies about being one

Now, let’s compile a kernel to a standalone cubin and we will see that the file recognizes it immediately.

$ nvcc -arch=sm_90 -cubin -o saxpy.sm90.cubin saxpy.cu
$ file saxpy.sm90.cubin
saxpy.sm90.cubin: ELF 64-bit LSB executable, NVIDIA CUDA architecture, version 1,
                  statically linked, not stripped

It is a real ELF64, little endian, and readelf will walk its section table happily. Three fields in the 64-byte header carry the CUDA-specific part.

e_ident:  7f 45 4c 46 02 01 01 41 08 00 00 00 00 00 00 00
                              |  |
                              |  +-- EI_ABIVERSION = 8
                              +----- EI_OSABI      = 0x41
e_type    = 2        (ET_EXEC; ET_REL when compiled with -rdc=true)
e_machine = 190      (0xbe, EM_CUDA)
e_flags   = 0x06005a04

EM_CUDA = 190 is the one part of this that is available to everybody: it sits in LLVM’s BinaryFormat/ELF.h alongside every other machine type, because LLVM needs to read these files.

The OS ABI byte is where things get strange. That same header defines two CUDA values, ELFOSABI_CUDA = 51 and ELFOSABI_CUDA_V2 = 41, both written in decimal. Every cubin my 13.3 toolchain produces carries 65, which is 0x41. 51 is 0x33, so the first constant is the older value written in decimal, and the second…. looks like 0x41 transcribed as if it were decimal.

Obv. I don’t know where is the mistake, but if you are writing a tool that sniffs cubins, sniff for the byte 0x41 and don’t trust either constant. The ABI version byte is 8.

I know that e_flags is where the architecture lives, and the field names are recoverable from the disassembler: EF_CUDA_SM, EF_CUDA_PTX_SM, EF_CUDA_64BIT_ADDRESS and EF_CUDA_ACCELERATORS, alongside an EF_CUDA_SM10 through EF_CUDA_SM121 enumeration that covers fifteen years of hardware in one list.

The layout is easy peasy to recover by sweeping every target the compiler supports.

Left: the e_flags word of nine cubins broken into four bytes, showing the compute capability in byte 1 and a byte 0 that changes at Blackwell. Right: cubin size for each target.

The same four-line kernel, one target per row. Bits 8 to 15 hold the compute capability as a plain integer. Byte 0 flips from 0x04 to 0x02 at the Blackwell boundary, and the file grows by 44% across that same boundary for identical source.

This is actually very good, but at least three things don’t match reality:

First, the virtual architecture is not in e_flags anymore, despite the name in the field. Compile the same kernel three ways, with the PTX architecture set to compute_75, compute_80 and compute_90 but the real target fixed at sm_90, and all three cubins carry e_flags = 0x06005a04.

The virtual architecture actually moved into a note section, where cuobjdump reports it as CUDA Virtual SM: sm_75 and so on. This used to be visible: CUDA 8-era disassembly printed a line reading .headerflags @"EF_CUDA_SM20 EF_CUDA_PTX_SM(EF_CUDA_SM20)", and current nvdisasm prints a plain .target sm_90 instead. If you have old tooling that reads the PTX architecture out of the flags word, you have seen zero for some years.

Second, the architecture-conditional suffixes do not show up here either. sm_90 and sm_90a produce identical e_flags, and so do all three of sm_100, sm_100a and sm_100f. For a kernel that uses no special features the sm_100 and sm_100f cubins are the same program byte for byte, and the 210 bytes that do differ between the two files are all downstream of one string: the note section records -arch sm_100f instead of -arch sm_100, which is one byte longer and shifts everything after it.

That is in line with what NVIDIA says about the feature, which is that code=sm_100 and code=sm_100f are aliases producing the same cubin when no family-specific features are in play. The suffix is recorded, but the recording is somewhere else and is “triggered” only when the kernel actually uses something.

Third, CUDA 13’s supported target list is very short, and that surprised even me. Turing is now the floor. ptxas in 13.3 accepts exactly sm_75, sm_80, sm_86, sm_87, sm_88, sm_89, sm_90, sm_90a, then the three-digit generation: sm_100, sm_103, sm_110, sm_120, sm_121 each with a and f variants, plus the matching lto_* targets.

Maxwell, Pascal and Volta were removed in CUDA 13.0, which NVIDIA’s release notes state directly: offline compilation and library support for those architectures are gone completely, and 12.x is the last line that can target them.

You have to know at least two numbers if you ever have to explain a build matrix. sm_110 is Jetson AGX Thor, renamed from sm_101 when CUDA 13.0 shipped, which means build scripts that hardcoded 101 broke on a rename rather than on a chip. NVIDIA states the rename in the nvCOMPDx release notes rather than anywhere prominent.

And sm_88, compute capability 8.8, is said by the community compatibility references to be the Nintendo Switch 2, which I cannot verify from the toolchain itself: ptxas will happily build for it and tells you nothing about what it is.

If that claim is right, the CUDA binary format has a target for a games console sitting in the same list as your H100.

Share The Software Frontier


Fifteen sections, but only one is code

Below I will show you the full section table of a cubin containing one trivial kernel, a SAXPY with four parameters, compiled for Hopper.

idx name                                   type        off   size  align
  1 .shstrtab                              STRTAB       64    295      1
  2 .strtab                                STRTAB      406    369      1
  3 .symtab                                SYMTAB      776    240      8
  4 .debug_frame                           PROGBITS   1016    104      1
  5 .note.nv.tkinfo                        NOTE       1120    164      4
  6 .note.nv.cuinfo                        NOTE       1284     32      4
  7 .nv.info                               CUDA_INFO  1316     36      4
  8 .nv.compat                             CUDA_COMPAT_INFO
                                                      1352     28      4
  9 .nv.info._Z5saxpyifPKfPf               CUDA_INFO  1380    132      4
 10 .nv.callgraph                          CUDA_CALLGRAPH
                                                      1512     32      4
 11 .rela.debug_frame                      RELA       1544     24      8
 12 .text._Z5saxpyifPKfPf                   PROGBITS   1664    512    128
 13 .nv.shared.reserved.0                   NOBITS     2176      0      1
 14 .nv.constant0._Z5saxpyifPKfPf           PROGBITS   2176    552      4

We have 512 bytes of machine code inside a 3,968 byte file. The italicised sections are the CUDA-specific ones, with section types in the 0x70000000 range that generic ELF tools print as “unknown”.

The code section is said to be “per kernel” and named after the mangled symbol, which is why a file with ten kernels has ten .text.* sections and ten of most other things too. Alignment is 128 bytes, one instruction cache line’s worth of paranoia.

Then, in a random order of how much they surprised me:

.note.nv.tkinfo records how the binary was built

This is a standard ELF note with owner string NVIDIA Corp, and cuobjdump will print it for you.

$ cuobjdump -elf saxpy.sm90.cubin | grep -A5 “CUDA Toolkit Information”
  NVIDIA Corp   140   NVIDIA CUDA Toolkit Information
    Note Version: 2
    Tool Name: ptxas
    Tool Version: Cuda compilation tools, release 13.3, V13.3.73
    Tool Branch: Build cuda_13.3.r13.3/compiler.38244171_0
    Tool Command Line Arguments: -arch sm_90 -m 64

Every cubin has the exact compiler build that produced it and the flags it was invoked with. That's a provenance record, and it’s sitting in every shipped binary on every machine learning platform you have ever installed and used.

If you want to know which toolkit built the kernels inside somebody’s wheel, no need to ask them. There is also a -verbose-tkinfo mode in the assembler that emits, in its own words, “object name and command line arguments which contains all arguments having file format,” meaning paths. I havent seen it on by default, and I’d look before shipping a binary built with it.

.nv.constant0 is the launch frame, and it keeps growing

Kernel parameters don’t come in registers, but in constant bank 0, and the cubin declares a per-kernel section for it. On Hopper we said this section is 552 bytes for a kernel whose parameters occupy 24. The user arguments start at offset 0x210; everything below that is owned by the driver and the ABI.

You can watch the exact boundary move across generations. It is at 0x160 for Turing through Ada, 0x210 for Hopper, and 0x380 for Blackwell and later, with the section growing from 380 to 556 to 924 bytes for the same kernel.

This is the sort of constant that reverse-engineering projects hardcode and then rediscover every two years.

Two bar charts: kernel parameter base offset in constant bank 0 and total .nv.constant0 size, both by architecture, showing steps at Hopper and Blackwell.
The driver’s half of the launch. The implicit state a kernel launch carries has grown 2.4x since Turing while the user-visible argument list stayed the same size. Clusters, tensor memory, distributed shared memory and grid constants all have to be described somewhere, and this is where.Measured. Base offset read from the EIATTR_PARAM_CBANK attribute; 24 bytes of user arguments in every case. The space below the base belongs to the driver and the ABI.

You can see the effect in the disassembly, which is the easiest way I know to make constant bank 0 sort of…. real. Here is the same kernel on Hopper and on Thor.

sm_90                                    sm_110
LDC   R1, c[0x0][0x28]                   LDC   R1, c[0x0][0x37c]
S2R   R0, SR_TID.X                       S2R   R0, SR_TID.X
S2UR  UR4, SR_CTAID.X                    S2UR  UR4, SR_CTAID.X
LDC   R7, c[0x0][RZ]                     LDCU  UR5, c[0x0][0x380]
IMAD  R7, R7, UR4, R0                    LDC   R7, c[0x0][0x360]
ULDC  UR4, c[0x0][0x210]                 IMAD  R7, R7, UR4, R0
ISETP.GE.AND P0, PT, R7, UR4, PT         ISETP.GE.AND P0, PT, R7, UR5, PT
@P0   EXIT                               @P0   EXIT

Left: the first parameter is loaded from 0x210. Right: from 0x380, using LDCU, an instruction that loads a constant straight into a uniform register and that does not exist in the Hopper instruction set reference. Even the stack pointer moved, from 0x28 to 0x37c.

The symbol table has its own vocabulary

Kernels are ordinary global function symbols with two CUDA-specific decorations, but if you look carefully, ther's one symbol in a cubin that has nothing to do with your code.

index  value   size   info  other  shndx  name
  0x3      0      0    0x3      0    0xc  .text._Z5saxpyifPKfPf
  0x4      0    0x4   0x21      0      0  .nv.reservedSmem.offset0
  0x5      0      0   0x20   0xa0    0xd  __nv_reservedSMEM_offset_0_alias
  0x8      0  0x200   0x12   0x10    0xc  _Z5saxpyifPKfPf
  0x9      0      0    0x3      0    0xe  .nv.constant0._Z5saxpyifPKfPf

The kernel symbol carries st_other = 0x10, which nvdisasm prints as STO_CUDA_ENTRY STV_DEFAULT: a visibility byte reused to mark launchable entry points, which is how the driver tells a kernel from a device function without parsing names.

The reserved shared memory symbols are the interesting thing here. Even a kernel that declares “no shared memory” unexpectedly gets a reservation record, and on Blackwell that reservation has a non-zero size: somehw you see 64 bytes of shared memory that belong to the platform, not to you, aliased under a name beginning with __nv_. Anyone computing occupancy from source is computing it from the wrong number.

The same source is not the same program

I decided to write a whole paragraph to the architecture sweep, because it shows how much the cubin is influenced by the specific chip, rather than by your code.

The four-line SAXPY compiles to 24 instruction slots on sm_86 and 48 on sm_87, Orin, and the difference is not optimization.

sm_86 opcode histogram:   8 NOP   2 S2R   2 MOV   2 LDG.E   2 IMAD.WIDE  ...
sm_87 opcode histogram:  15 BMOV.32.CLEAR  12 NOP   2 S2R   2 LDG.E  ...

We see fifteen instructions clearing barrier state at kernel entry, on one embedded part, for a kernel that uses no barriers.

In other words, that piece of code inside the kernel isn’t there because your program desperately needs it. It’s here because NVIDIA is paying, through software, the price of a silicon limitation. And that proves that SASS is dependent not only on the PTX, but also on the hardware, the drivers and the workarounds embedded into the toolchain.

.nv.callgraph exists because device code can call things

Fixed 8-byte entries, and for a leaf kernel it is four rows of negative sentinels:

.nv.callgraph
<0,-1>   <0,-2>   <0,-3>   <0,-4>

The sentinels are the interesting part: a leaf kernel does not get an empty section, but four explicit “no callee of kind N” markers. This suggests that .nv.callgraph is not simply a list of callees, but a structured description of call relationships understood by the runtime.

That metadata matters because features such as indirect calls, dynamic parallelism, and separately compiled device functions require the driver to know the worst-case call depth before launch so it can size the per-thread stack correctly.

Even a kernel that calls nothing still participates in this contract, which is why absence is encoded explicitly rather than by omitting the section entirely.


.nv.info is where the kernel describes itself

This is the section that let you see a loadable cubin, rather than merely readable. The format is a simple tag-length-value stream: one byte of format, one byte of attribute, then either nothing, one byte, two bytes, or a 16-bit length followed by that many bytes.

And….thirty-six bytes of it, at file level, look like this:

04 2f 08 00 | 08 00 00 00  0a 00 00 00      EIATTR_REGCOUNT
04 11 08 00 | 08 00 00 00  00 00 00 00      EIATTR_FRAME_SIZE
04 12 08 00 | 08 00 00 00  00 00 00 00      EIATTR_MIN_STACK_SIZE
 ^  ^  ^
 |  |  +-- value length (EIFMT_SVAL)
 |  +----- attribute code
 +-------- format: 1 = none, 2 = one byte, 3 = two bytes, 4 = length-prefixed

The first value that you read is a symbol table index, the second is the payload. The symbol 8 in each column is the kernel, and it needs 10 registers, no stack frame, no minimum stack. You can check that decode against the tool, because cuobjdump -elf knows the names.

$ cuobjdump -elf saxpy.sm90.cubin
.nv.info
  Attribute: EIATTR_REGCOUNT       Value: function: _Z5saxpyifPKfPf(0x8)  register count: 10
  Attribute: EIATTR_FRAME_SIZE     Value: function: _Z5saxpyifPKfPf(0x8)  frame size: 0x0
  Attribute: EIATTR_MIN_STACK_SIZE Value: function: _Z5saxpyifPKfPf(0x8)  min stack size: 0x0

The per-kernel section is way richer. Let’s see a four-parameter SAXPY:

.nv.info._Z5saxpyifPKfPf
  EIATTR_LANGUAGE            PTX
  EIATTR_CUDA_API_VERSION    0x85                      (133 = CUDA 13.3)
  EIATTR_KPARAM_INFO         Ordinal 0x3  Offset 0x10  Size 0x8  Space CBANK
  EIATTR_KPARAM_INFO         Ordinal 0x2  Offset 0x8   Size 0x8  Space CBANK
  EIATTR_KPARAM_INFO         Ordinal 0x1  Offset 0x4   Size 0x4  Space CBANK
  EIATTR_KPARAM_INFO         Ordinal 0x0  Offset 0x0   Size 0x4  Space CBANK
  EIATTR_SPARSE_MMA_MASK     0x0
  EIATTR_MAXREG_COUNT        0xff
  EIATTR_MERCURY_ISA_VERSION 1.1
  EIATTR_EXIT_INSTR_OFFSETS  0x70  0x120
  EIATTR_CBANK_PARAM_SIZE    0x18                      (24 bytes of parameters)
  EIATTR_PARAM_CBANK         0x9   0x180210            (symbol 9, size 0x18 at 0x210)
  EIATTR_SW_WAR              0x8
  EIATTR_NVSAL_SW_WAR        0x1

Kernel parameters are described entirely through size-and-offset metadata, with entries stored in something called “reverse ordinal order”.

This means the host runtime doesn’t have to understand the original programming language or the function signature. It can construct the kernel’s parameter buffer directly from the metadata, placing each argument at the correct location before launch. The compiled binary therefore exposes a language-independent ABI that the driver and runtime can consume.

EIATTR_EXIT_INSTR_OFFSETS contains the byte offset of every EXIT instruction generated for the kernel. At first glance this is considered to be like a minor detail, but it’s very useful for tooling.

A profiler, tracer, or instrumentation framework can find every kernel return point immediately, without performing a full disassembly or reconstructing the control-flow graph. If a tool have to inject timing code, coverage probes, or custom telemetry before kernel termination, these offsets provide the exact insertion points.

EIATTR_SW_WAR is a different kind of purpose here. It encodes software workarounds for known hardware errata as a bitmask. Modern GPUs sometimes require compiler- or driver-level mitigations to avoid wrong behavior in specific corner cases.

Rather than hard-coding these decisions somewhere else, the compiler do a record of the workarounds that are needed, in the binary metadata. The driver then inspects the flags at load time and enables the correct mitigation paths for the architecture in place.

All these attributes show you that CUDA metadata is not just a bunch of descriptions; it forms actively some parts of the contract between the compiler, runtime, driver, profiling tools, and the GPU itself, carrying the information needed to launch kernels, instrument execution, and safely navigate hardware-specific quirks.

Now a question arises naturally: how big is this vocabulary? The disassembler contains the enum names, so you can just ask it, and you’ll find out.

$ strings -a nvdisasm | grep -o “EIATTR_[A-Z0-9_]*” | sort -u | wc -l
113

Now you see it: 113 attribute kinds. A few of them are a decent map of what the hardware has learned to do since 2010.

  • EIATTR_TCGEN05_1CTA_USED and EIATTR_TCGEN05_2CTA_USED flag use of Blackwell’s fifth-generation tensor cores, and the fact that there are separate flags for the one-CTA and two-CTA forms tells you the driver has to care which.

  • EIATTR_CTA_PER_CLUSTER, EIATTR_MAX_CLUSTER_RANK, EIATTR_EXPLICIT_CLUSTER and EIATTR_BLOCKS_ARE_CLUSTERS are the Hopper cluster model.

  • EIATTR_STACK_CANARY_TRAP_OFFSETS means device code has stack canaries now, and indeed nvcc has a flag for them.

  • EIATTR_COROUTINE_RESUME_ID_OFFSETS is there for something that has not been announced.

  • And a small family of attributes are named after bug numbers: EIATTR_SW1850030_WAR, EIATTR_SW2393858_WAR, EIATTR_SW2861232_WAR, EIATTR_WAR5829587_NEEDED.

There is also an internal ticket for each somewhere, and the workaround is load-bearing enough to have its own slot in the binary format.

The format is not a description of a program. It is a contract between a compiler and a driver that ship on different schedules and are written by the same company.


Twenty-one bits per instruction that the hardware does not check

Starting with Volta, SASS instructions are 128 bits, and a piece of every instruction word is not the instruction at all, because it’s the scheduling metadata that the assembler computes and the hardware obeys without verifying.

You will find plenty of documentation about the structure of the field in the microbenchmarking literature, rather than by the vendor: a paper by Jia and colleagues describes stall counts, a yield flag, separate read and write dependency barriers, a wait mask that is a bitmask because an instruction can wait on several barriers at once, and reuse flags.

The exact bit positions below are the ones I decoded, and I trust them because the result is coherent with the docs: stall count in bits 105 to 108, yield flag at 109, write barrier index at 110 to 112, read barrier index at 113 to 115, a six-bit wait mask at 116 to 121, and four reuse flags at 122 to 125. Bits 126 and 127 come out zero on every instruction of every kernel I looked at, on five architectures.

You can decode it yourself with twenty lines of Python, and I think everyone who works on inference should do it at least once, because the mechanism explains more about the CUDA moat than any benchmark. Here is a real Hopper GEMM prologue with the fields pulled out.

off   stall  y  wr  rd    wait  reuse   instruction
0000      1  1   -   -  000000   0000   LDC R1, c[0x0][0x28]
0010      1  1   0   -  000000   0000   S2R R9, SR_CTAID.Y
0020      1  1   -   -  000000   0000   ULDC UR4, c[0x0][0x228]
0030      1  1   -   -  000000   0000   ULDC.64 UR6, c[0x0][0x208]
0040      1  1   -   -  000000   0000   MOV R0, UR4
0050      1  1   0   -  000000   0000   S2R R13, SR_TID.Y
0060      2  1   -   -  000000   0000   HFMA2.MMA R16, -RZ, RZ, 0, 0
0070      1  1   -   -  000000   0000   ISETP.GE.AND P0, PT, R0, 0x1, PT
0080      1  1   1   -  000000   0000   S2R R17, SR_CTAID.X
0090      1  1   1   -  000000   0000   S2R R7, SR_TID.X
00a0      2  0   -   -  000001   0000   LEA R9, R9, R13, 0x5
00b0      8  0   -   -  000010   0000   LEA R11, R17, R7, 0x5

Two special register reads allocate barrier 0, and the LEA that consumes them waits on barrier 0. Two more allocate barrier 1, and the next LEA waits on barrier 1. The dependency is not discovered at run time, but written into the binary.

That is the whole argument, in twelve instructions. For variable-latency instructions the assembler allocates one of six barriers and makes consumers wait on a bitmask.

For fixed-latency instructions it does not use barriers at all, it just writes a number of cycles into the stall field and the scheduler holds off that long. There is no interlock checking this: if the number is wrong, you do not get a stall, you get wrong answers.

Across a full tiled GEMM kernel the distribution looks like this.

Histogram of encoded stall counts and a bar chart of how often each control field is used across one kernel.
One 32x32 tiled float GEMM for sm_90, 168 instruction slots. Most instructions issue back to back with a one cycle stall. Write barriers appear on 26% of slots, read barriers on none, and the yield flag on 71%. The same decode works unchanged on sm_100, sm_110 and sm_120, which is a small piece of evidence that Blackwell did not change the scheduling contract. Measured: one 32x32 float tiled GEMM compiled for sm_90, 168 instruction slots including padding. Counts are slots, decoded by direct parse of the .text section.

The linker can rewrite the control bits

The relocation namespace makes the point better than I can. Pull the type names out of nvlink and there are 119 of them, and most encode a bit position in the name.

$ strings -a nvlink | grep -o “R_CUDA_[A-Z0-9_]*” | sort -u | wc -l
119

R_CUDA_ABS32_23        patch 32 bits at bit offset 23
R_CUDA_ABS32_HI_32     high half of a 64-bit address, at bit 32
R_CUDA_CONST_FIELD19_20  19-bit constant bank field at bit 20
R_CUDA_ABS55_16_34     55-bit value split across bits 16 and 34
R_CUDA_INSTRUCTION128  a whole 128-bit instruction word
R_CUDA_PCREL_IMM24_23  24-bit program counter relative branch at bit 23
R_CUDA_YIELD_CLEAR_PRED4_87   clear 4 bits at bit 87
R_CUDA_YIELD_OPCODE9_0        rewrite a 9-bit opcode field at bit 0

On a CPU, relocations patch whole bytes because operands are byte-aligned. In this case, the operands are bit fields inside a 128-bit word, so the relocation type has to name the field, and there is a separate type for the same width at each position it can occur.

The last two are the ones that really stopped me. A relocation that clears the yield predicate, and one that rewrites an opcode, mean the device linker’s contract includes editing scheduling and instruction selection after the assembler has finished.

The 21 control bits are not a compiler-internal detail, which is nested at the end of ptxas, because they are a key part of the object format, and a later stage is allowed to change them.

I have argued many times before that this field, not the language and not PTX, is the load-bearing part of the CUDA moat, and taking cubins apart has not changed my mind. It has sharpened one point though.

The reason the assembler is fully close is not that NVIDIA wants to hide optimizations and reverse-engineering, at least not the first point. What we also need to keep in mind is that the ptxas is correctness-critical infrastructure.

An external tool that emits SASS is not competing with a compiler on quality of code, it is taking over responsibility for a hazard-avoidance protocol with no runtime check and no error reporting, which is extremely dangerous.

NVIDIA’s own programming guide is blunt about the consequence: you read that binary compatibility is promised only for binaries created by NVIDIA tools: manual editing or generating binary code is not supported, and compatibility promises are invalidated if binaries are modified in any way.

Read that as a description of the support boundary rather than a threat, and the closure looks less like strategy and more like the only sentence a vendor can write once it has moved the interlock into software.

Share The Software Frontier


Blackwell cubins carry a second copy of every kernel

When I thought about writing this part, I didn’t even know what to expect. It was a complete surprise to me. Let’s do a concrete example: compile the same trivial kernel for Hopper and for Blackwell and count sections: 15 for sm_90, 22 for sm_100. The seven extra sections are not debug info.

idx name                                   type              flags     size
 12 .text._Z5saxpyifPKfPf                  PROGBITS              6      512
 13 .nv.shared.reserved.0                  NOBITS                3       64
 14 .nv.constant0._Z5saxpyifPKfPf          PROGBITS             42      920
 15 .nv.capmerc.text._Z5saxpyifPKfPf       0x70000016     10000000      258
 16 .nv.merc.debug_frame                   PROGBITS       10000000      112
 17 .nv.merc.nv.info                       0x70000083     10000000       36
 18 .nv.merc.nv.info._Z5saxpyifPKfPf       0x70000083     10000040      144
 19 .nv.merc.rela.debug_frame              0x70000082     10000040       24
 20 .nv.merc.nv.shared.reserved.0          0x70000015     10000003        0
 21 .nv.merc.symtab                        0x70000085     10000000      216

You are now staring at a complete parallel object: its own symbol table, its own info sections, its own relocations, its own shared memory reservation, and a “capmerc” text section. Section flag bit 0x10000000 marks the whole family.

The prefix is merc, and the name shows up in three other places. It is in the per-kernel info as EIATTR_MERCURY_ISA_VERSION 1.1. It is in a new section called .nv.compat, which exists on every target but says more on Blackwell, and it is all over the assembler binary.

$ cuobjdump -elf saxpy.sm100a.cubin | sed -n ‘/nv.compat/,/nv.info\._/p’
.nv.compat
  EICOMPAT_ATTR_CUDA_ACCELERATOR_TARGET                 0x1
  EICOMPAT_ATTR_ISA_CLASS                                0x1
  EICOMPAT_ATTR_INST_TCGEN05_MMA                        0x5
  EICOMPAT_ATTR_MERCURY_ISA_MAJOR_MINOR_VERSION_V2       1.1
  EICOMPAT_ATTR_MERCURY_ISA_MAJOR_MINOR_VERSION_V1       1.1
  EICOMPAT_ATTR_INST_TENSORMAP_V1                        0x0
  EICOMPAT_ATTR_CAN_FASTPATH_FINALIZE                   0x9 0x0

There is the missing suffix. EICOMPAT_ATTR_CUDA_ACCELERATOR_TARGET is 1 for sm_100a and 0 for both sm_100 and sm_100f, and the accelerated build declares EICOMPAT_ATTR_INST_TCGEN05_MMA, a capability it was permitted to use even though this kernel does not use it.

To really find out what the block really tracks, you need to build a kernel that needs a family-specific instruction. One line of inline PTX will do: tcgen05.fence::before_thread_sync, part of Blackwell’s fifth-generation tensor core interface.

Compiling it for five targets gives the feature tiers as a straight experiment rather than as a diagram.

$ for a in sm_100 sm_100f sm_100a sm_103f sm_120a; do nvcc -arch=$a -cubin -o t.cubin tc.cu; done

sm_100    Instruction ‘tcgen05.fence’ not supported on .target ‘sm_100’
sm_100f   OK  (5136 bytes)
sm_100a   OK  (5136 bytes)
sm_103f   OK  (5136 bytes)
sm_120a   Instruction ‘tcgen05.fence’ not supported on .target ‘sm_120a’

The baseline target refuses the instruction, but the family target accepts it, and so does the family target for the other member of the same family.

And the fully accelerated target for consumer Blackwell refuses it, because sm_120 is a different family and doesn’t have the hardware, which is the concrete answer to the frequently asked question of why a 5090 is not a small B200.

Now look at what the successful builds wrote into the object.

That is the piece I was missing when I first wrote this section, infact I had to rebuild it. The compat block is not a record of your compiler flags; it is a graded statement about what the machine code inside requires, computed from the code, and the flags only set the ceiling on what the assembler was allowed to reach for.

A resolver reading ISA_CLASS = 1 knows this object is safe anywhere in the generation; reading 2, it knows to check the family. This is the minimum viable metadata for the compatibility promise NVIDIA made in CUDA 12.9, and it is sitting in a 32-byte section nobody documents.

What reads it becomes obvious once you look inside ptxas. The strings are unambiguous.

$ strings -a ptxas | grep -iE “merc|finaliz” | sort -u
[Finalizer] fastpath optimization applied for off-target %u -> %u finalization
Generate Capsule Mercury
Specify the type of target ELF binary kind. Default on sm100+ is capmerc
Self check for capsule mercury (capmerc)
Specify the opportunistic finalization level. 0=default, 1=no opportunistic
  finalization, 2=intra family finalization only, or 3=intra and inter family
  finalization
Turns off the fast-path finalization optimization (allows normal refinaization)
Specify the ‘sm_’ name of the target architecture. If not specified, default
  behavior is on-target finalization
R_MERCURY_NONE  R_MERCURY_G64  R_MERCURY_ABS64  R_MERCURY_ABS32  R_MERCURY_ABS16 ...

Read together with the sections, this describes a two-stage back end. Mercury is an encoding of a kernel that is below PTX, but above final machine code.

A capsule wraps it with the metadata a finalizer needs: its own symbol table, register and barrier counts, shared memory usage, and its own relocation types. “Finalization” is the step that turns a capsule into executable SASS for a specific chip. On-target finalization is the ordinary case.

Off-target finalization is the same capsule being finalized for a different chip than the one it was compiled for, with a fast path when the assembler can prove the existing encoding is already valid. And the levels of “opportunistic finalization” go from none, to within a family, to across families.

That is what the f targets are made of. NVIDIA introduced family-conditional compilation in CUDA 12.9 and actually described it in terms of feature sets: an f binary may use the architecture-specific features that are common to a whole family, and will run on later members of that family.

The public framing is a compatibility promise. The mechanism, as far as I can see it in the binary, is that the cubin ships a re-finalizable representation next to the SASS, and the driver can produce correct machine code for a family member the compiler never saw.

Where I am reading, not knowing

Unfortunately, I havent observed finalization happen. At the moment, don’t own a Blackwell GPU, and none of this machinery has the ptxas command line: passing --binary-kind gets you Unknown option, so the option table I am quoting belongs to an internal or driver-side entry point.

What I am confident about is the presence and shape of the artifacts. An independent reverse-engineering effort on ptxas 13.0 reached the same reading and adds detail I could not verify, including a 328-byte capsule descriptor and a compilation-knob snapshot inside it. Treat the purpose as tier C, so a speculative claim.

There is more of it in the section type table than any single cubin shows. cuobjdump has to be able to print a name for every section type it might meet, so the names are in the binary, and the Mercury family is large.

$ strings -a cuobjdump | grep -oE “CUDA_[A-Z_]{3,}” | sort -u | grep -i merc
CUDA_CAPMERC
CUDA_MERCURY
CUDA_MERCURY_CONSTANT_DRIVER      CUDA_MERCURY_CONSTANT_PARAMS
CUDA_MERCURY_CONSTANT_IMGHDR      CUDA_MERCURY_CONSTANT_PIC
CUDA_MERCURY_CONSTANT_OPT         CUDA_MERCURY_CONSTANT_TOOLS
                                  CUDA_MERCURY_CONSTANT_USER
CUDA_MERCURY_RESOLVED_RELA
CUDA_MERCURY_SASS_MAP

Seven distinct constant bank section types, split by who owns the data: driver, image header, optimizer, parameters, position independent code, tools, user. A resolved-relocation type. And a section type called CUDA_MERCURY_SASS_MAP, which is hard to read as anything other than a correspondence between Mercury entities and SASS.

A format that needs a map to SASS is not an annotation on SASS, because it’s a representation of the program in its own right, and the map exists so that debuggers, profilers and the finalizer can move between the two.

All of which makes the size behaviour the really awkward part, because it argues against the simplest reading of what I just described.

If the capsule were a second copy of the instruction stream, in whatever denser encoding, its size would have to follow the SASS. Longer kernel, longer capsule. That is not what happens. Across seven kernels the SASS spans 384 to 2,048 bytes, a factor of 5.3, while the capsules span only 130 to 350, a factor of 2.7.

Correlations are carried almost entirely by a single long kernel: the coefficient across all seven is 0.76, and dropping that one point takes it to 0.33. Now, if you rank the kernels by capsule size and you get an ordering with no obvious relationship to how much code they contain.

The cleanest way to see it is a controlled pair. Two of these kernels compile to exactly 384 bytes of SASS. One stores a float, the other multiplies and adds in double precision. Same code size, same instruction count, and their capsules are 130 and 194 bytes, a 49% difference. Meanwhile a kernel using sqrtf compiles to 896 bytes of SASS, more than twice the double-precision kernel, and carries a smaller capsule at 184.

I want to be careful here, because when I first wrote this section I claimed the ordering tracked the variety of operations a kernel performs, and then I checked. It does not. Correlating capsule size against the number of distinct opcodes in each kernel gives 0.03, which is nothing at all. I could not find anything that predicts capsule size, and with seven kernels I would not trust a pattern even if I had found one.

What the double-precision pair does suggest is that whatever the capsule enumerates is closer to a set of requirements than to a program, and double precision is a plausible thing to find in such a set. Double-precision throughput is one of the sharpest differences within a Blackwell family: the datacenter parts and the consumer parts are not the same machine in that respect.

A representation whose job is to let a driver produce correct code for a family member the compiler never saw would need to record exactly this sort of thing, and would not need to grow much when you add another hundred instructions of the same kind.

So the sizes push against “a second copy of every instruction” and toward “a description of what this code needs.” They cannot separate the two readings that remain. A compact whole-kernel encoding in a much denser format, and a fixup table over the existing SASS naming only the sites a re-finalizer would have to revisit, would both be small and would both fail to scale with instruction count.

The section type called CUDA_MERCURY_SASS_MAP is the one piece of evidence that leans toward the first, since a table of fixups would not obviously need a map. I cannot settle it from sizes alone, and I doubt anyone can without a driver to watch.

Scatter plot of Mercury capsule size against SASS size for seven kernels, showing a nearly flat relationship.
Seven kernels on sm_100. If the capsule were a compact second encoding of the instruction stream, these points would sit on a line through the origin. They do not. A double-precision kernel with 384 bytes of SASS carries a 194 byte capsule; a sqrtf kernel with 896 bytes of SASS carries 184. Whatever the capsule enumerates, it is closer to a set of properties than to a program.Measured on sm_100. The double-precision kernel has the smallest SASS and nearly the largest capsule; the sqrtf kernel has the largest SASS and one of the smallest.

What it costs is easier to state. On the ten-kernel workload, Mercury sections add 4.2 KB to a 57.8 KB cubin, about 7%, and Blackwell cubins are 2.0x the size of Turing cubins for identical source.

Stacked bar chart of cubin size composition across twelve architectures, showing text, constant bank, Mercury sections, metadata, debug and ELF scaffolding.
Ten kernels, twelve targets, same source file. The SASS roughly doubles from Turing to Blackwell for reasons that have nothing to do with the format, the launch frame more than doubles, and the Mercury sections appear from sm_100 onwards. Nothing here is compressed: this is the cubin as ptxas writes it.Measured with nvcc -cubin. Section sizes read from the ELF section headers; ‘ELF scaffolding’ is the file remainder (string tables, symtab, headers, padding).

There is one more hint about direction of travel, in the disassembler rather than the assembler. nvdisasm in 13.3 has an option called --no-vliw, described as “conventional mode; disassemble paired instructions in normal syntax, instead of VLIW syntax.”

Nothing I compiled for any current target produced paired output. An option to turn off VLIW syntax implies a target where VLIW syntax is the default.

I would not build a thesis on a command-line flag, but if a future architecture issues statically paired instructions, the tooling has already been taught the word.


The fatbin is a filesystem, and the driver is the resolver

A cubin runs on exactly one compute capability. Shipping software means shipping several, plus PTX for the ones that do not exist yet, and the container for that is the fatbin.

It starts with a 16-byte header whose magic is 0xBA55ED50, and it is followed by a run of entries, each with its own header and payload.

$ nvcc -gencode arch=compute_90,code=sm_90 \
       -gencode arch=compute_100,code=[sm_100,compute_100] -fatbin -o saxpy.fatbin saxpy.cu

kind     arch  hdrsz  payload   compressed  uncompressed  flags
CUBIN      90     64     3968            0             0  0x11
CUBIN     100    112     5712            0             0  0x1000011
PTX       100     80      400          399           922  0x8011

The entry header fields are recoverable by differential compilation: change one thing about the build, diff the bytes, name the field.

Reconstructed by construction, not from documentation. Every row was fixed by building the same kernel with one thing changed and diffing the header bytes. Fields I did not manage to move are omitted.

PTX is not the cheap option

The standard advice is to always ship PTX for the newest virtual architecture so future GPUs have something to JIT. The advice generally is right, and the reason people give for it is usually wrong.

PTX is not a compact representation. For the ten-kernel workload the PTX is 37.3 KB against a 35.9 KB Hopper cubin, and on Blackwell it is 48.0 KB against 57.8 KB. It is the same order of magnitude in both directions, and after compression the gap narrows further because PTX is text and compresses beautifully.

What PTX buys is coverage of chips that do not exist yet, at the cost of a JIT compile on first use, per process, unless the driver’s compute cache is warm.

What it does not buy is what most people think: forward compatibility does not extend to the instructions anyone actually cares about, because PTX built for an architecture-conditional target is not forward compatible at all.

If your kernel needs wgmma or tcgen05, it needs sm_90a or sm_100a, and the compatibility story ends there. The f targets exist precisely because that cliff was too steep, and the Mercury capsule is how they made it less steep.

That extended header is the good part. The sm_100 entry above has a 112-byte header instead of 64, and the extra 48 bytes are a length-prefixed copy of the .nv.compat capability block from inside the cubin.

header bytes at offset 0x40:
48 00 00 00  20 00 00 00     -- payload at 0x48, length 0x20
02 09 00 00  02 02 01 00  03 0d 01 01  03 07 01 01
02 03 00 00  04 0b 08 00  09 00 00 00  00 00 00 00
   ^^ the same TLV stream that .nv.compat holds inside the ELF

The capability claim is duplicated into the index so that whatever picks an entry never has to open the ELF. That is a loader optimization, and it is also a small confirmation of what the compat block is for: it is the thing a resolver matches against a device before committing to a payload.

Compression is off by default for the part that matters

CUDA has a --compress-mode flag with values none, speed, balance, size and default. Running all five over the same two-cubin-plus-PTX fatbin gives a result worth knowing.

Horizontal bar chart of fatbin size under four compression modes, with the size mode dramatically smaller.
In default, speed and balance mode, only the PTX gets compressed and both cubins are stored raw. Only -compress-mode=size compresses SASS, and on this input it takes the fatbin from 10,352 bytes to 3,000.Measured. In default, speed and balance mode the two cubins are stored raw and only the PTX is compressed; -compress-mode=size compresses the cubins as well.

The flag values behave like two different algorithms rather than one algorithm at three settings: speed sets flag bit 13 and gets 504 bytes out of 922, while balance and size set bit 15 and get 400.

On the larger ten-kernel workload across twelve targets, size mode takes 537 KB down to 75 KB, an 86% reduction, for no measurable build time cost at this scale.

If you ship CUDA binaries and have not tried it, that is the cheapest win in this article.

How it gets into your executable

The host side is one of the few places NVIDIA actually publishes the format, in fatbinary_section.h in the toolkit include directory.

#define FATBINC_MAGIC   0x466243B1
#define FATBINC_VERSION 1
typedef struct {
  int magic;
  int version;
  const unsigned long long* data;
  void *filename_or_fatbins;
} __fatBinC_Wrapper_t;

#define FATBIN_CONTROL_SECTION_NAME  “.nvFatBinSegment”
#define FATBIN_DATA_SECTION_NAME     “.nv_fatbin”

Compile a .cu file to an object and you get exactly that: a 24-byte wrapper in .nvFatBinSegment holding the magic and a relocation pointing at the fatbin bytes in .nv_fatbin, plus a section called __nv_module_id, plus a constructor.

$ readelf -x .nvFatBinSegment saxpy.o
  0x00000000 b1436246 01000000 00000000 00000000 .CbF............
$ readelf -rW saxpy.o | grep nvFatBinSegment
  R_X86_64_64  .nv_fatbin + 0

$ cat saxpy.cudafe1.stub.c        # generated by nvcc --keep
static void __nv_cudaEntityRegisterCallback(void **__T0) {
  __cudaRegisterEntry(__T0, (void(*)(int,float,const float*,float*))saxpy,
                      _Z5saxpyifPKfPf, (-1)); }
static void __sti____cudaRegisterAll(void) __attribute__((__constructor__));
static void __sti____cudaRegisterAll(void) {
  __cudaRegisterBinary(__nv_cudaEntityRegisterCallback); }

void __device_stub__Z5saxpyifPKfPf(int p0, float p1, const float *p2, float *p3){
  __cudaLaunchPrologue(4);
  __cudaSetupArgSimple(p0, 0UL);  __cudaSetupArgSimple(p1, 4UL);
  __cudaSetupArgSimple(p2, 8UL);  __cudaSetupArgSimple(p3, 16UL);
  __cudaLaunch((char *)saxpy); }

So a CUDA program registers its device code from an ELF constructor, before main. The argument offsets in the stub, 0, 4, 8 and 16, are the same offsets the cubin declared in EIATTR_KPARAM_INFO.

Host and device agree on the layout because both were generated from the same front end, and the binary carries the agreement in two places.

Two variants are worth noting because they trip people up. With -rdc=true, the device code is relocatable, the cubin becomes ET_REL with .rela.text.* sections and an EIATTR_EXTERNS attribute, and it lands in a differently named section, __nv_relfatbin, until the device link step turns it into a normal .nv_fatbin. The cost shows up in the machine code, and it is not subtle:

$ nvcc -arch=sm_90 -rdc=true -c a.cu b.cu && nvcc -arch=sm_90 -dlink -o dl.o a.o b.o
$ cuobjdump -sass dl.o
    Function : _Z5applyPfPKffi
        /*0100*/  CALL.ABS.NOINC 0x0 ;
    Function : _Z5scaleff
        /*0000*/  FFMA R4, R4, R5, 1 ;
        /*0010*/  RET.ABS.NODEC R20 0x0 ;

A one-instruction device function became a real call, with a stack frame, a return address register and a separate .text section, where whole-program compilation would have inlined it into a single FFMA.

That is what -dlto exists to undo: with LTO the fatbin entry is kind 8 and the instruction selection is deferred to link time so the inlining can happen after all translation units are visible.

In my sandbox the LTO device link fails for want of a driver-side link library, so I can report the container shape (1,944 compressed bytes expanding to 2,548) but not the linked output.

What the driver does with all this

The loading path is the reason the format looks the way it does, and it is worth stating plainly because the layering is easy to lose. The constructor calls __cudaRegisterBinary with a pointer to the wrapper.

The runtime hands the fatbin to the driver, which reads the index and selects one payload for the device it is about to use: an exact-architecture cubin if there is one, otherwise a compatible one, otherwise PTX to be compiled on the spot. The selected payload becomes a module.

The module’s .nv.info tells the driver how many registers each kernel needs, how much shared memory to reserve, how big a stack to allocate given the call graph, and where in constant bank 0 to write the arguments. Only then can a launch happen, and a launch is essentially a memcpy of the argument block into the frame the cubin described, followed by a grid dispatch.

Everything in the binary that is not machine code exists to let that sequence happen without the driver consulting anything else. That is why the parameter layout is in the object rather than in a header, why the exit offsets are precomputed, why the capability claim is duplicated into the fatbin index, and why the reserved shared memory has a symbol. A loader that had to infer any of it would be slower and more fragile.

Two changes to this path are worth knowing. Since CUDA 12.0 there is a second module abstraction, the library and kernel API (cuLibraryLoadData, cuKernel), which decouples a loaded artifact from a specific context, and it exists largely because the old model made lazy loading awkward.

And since 12.2 on Linux and 12.3 everywhere, lazy loading is the default: modules load on first use of a symbol from them, and kernels within a module load on first cuModuleGetFunction.

The requirement is a runtime of 11.7 or later, statically linked into whatever built the library, which means an old dependency in your stack can quietly opt you out. NVIDIA’s own documentation puts the requirement plainly: libraries compiled against older runtimes load all modules eagerly.


What the format costs, in bytes and seconds

All of the above has a price, and it is paid by everyone who ships a wheel.

Two panels: fatbin size versus number of targets, and nvcc wall clock versus number of targets.
Ten kernels. Each additional real target is a full independent compilation and a full independent copy. The relationship is linear in both size and time, which is the whole problem: there is no sharing between targets, because there is nothing to share.Measured: CUDA 13.3.73, 10 templated kernels (4 GEMM, 4 elementwise, 2 reductions), single-threaded nvcc, no GPU present.

This is why PyTorch’s release engineering discussions read the way they do. When the CUDA 12.8 build matrix had to add Blackwell, the team explicitly rejected keeping the older architectures because of binary size. The trade is not subtle: adding a target adds a copy of every kernel in the library, and machine learning libraries have a great many kernels.

You can see the endpoint of that logic by taking apart what NVIDIA itself ships. I pulled the cuBLAS 13.6.0.2 wheel from PyPI and parsed the .nv_fatbin sections directly.

Two libraries, 6,860 fatbin entries, 178 MB on disk expanding to 1.8 GB of device code. And every single entry is compressed, which tells you that NVIDIA does not ship its own libraries with the default compression mode.

Left: device code per architecture for the two cuBLAS libraries. Right: section sizes inside libcublasLt.so.13.
Hopper is still the largest single target in cuBLASLt at 37.4 MB of compressed SASS, more than consumer Blackwell and datacenter Blackwell individually. The right panel is the part I did not expect: device code is only 27% of the file.Measured on the cuBLAS 13.6.0.2 wheel from PyPI. Right panel counts only file-resident sections, which sum to 491.4 MB of the 492.7 MB file; .bss and .lbss add another 99 MB of runtime memory but zero bytes on disk. cuBLASLt holds 5,599 fatbin entries (5,309 cubins, 290 PTX), 132.3 MB compressed, expanding to 1,361 MB.

The right-hand panel is worth sitting with. In cuBLASLt, 101 MB is host code, and 78 MB is a section called .cask_resource.

One detail worth noting before anyone adds those numbers up: the library also declares 99 MB of .bss and .lbss, and those are NOBITS sections, which means they cost 99 MB of memory at run time and zero bytes on disk.

Counting only the sections that actually occupy file space gets you 491.4 MB of the 492.7 MB file, and the rest is headers and padding. I cannot tell you what CASK stands for, because NVIDIA has never said, and the name only surfaces publicly in cuDNN and TensorRT diagnostics.

What I can tell you is that the strings in that section include 2,429 mangled C++ names beginning N11cask, so it is a namespace, and that the rest of the section is a catalog. Pull strings out of it and you get entries like this:

cutlass3x_sm103_bstensorop_s256x256x96gemm_block_scaled_ue4m3xe2m1_ue4m3xe2m1_
  f32_bf16_ue4m3xe2m1_256x256x768_0_tnn_align...

A CUTLASS 3.x kernel for sm_103 doing a block-scaled GEMM with FP8 and FP4 operand types at a 256x256x96 tile.

Counting distinct kernel-shaped names across the resource and read-only sections gives 25,401, with the generation prefixes still visible in the histogram: 5,329 mentioning sm90, 5,015 cutlass3x, 1,956 ampere, 808 volta, 274 turing.

That number is the moat, expressed as an artifact count rather than an argument. Not one clever kernel. Twenty-five thousand parameterised ones, plus 78 MB of metadata describing which to pick, plus 101 MB of host code doing the picking. A competitor can match the code generator.

Matching the catalog and the selection heuristics is a different kind of work, and it is the kind that does not benefit much from being smart.

NVIDIA is visibly feeling the weight of it. cuDNN 9.5 shipped a build configuration called GRAPH_JIT_ONLY that keeps the runtime kernel generation engines and drops the precompiled ones specifically to cut binary size, which is the vendor arriving at the same trade its users have been making by hand: generate at run time or ship the archive.

It also explains why lazy loading became the default. If a process eagerly loaded all 5,309 cubins in cuBLASLt it would spend its startup budget on kernels it will never call.

Lazy loading arrived as opt-in in CUDA 11.7, became the Linux default in 12.2 and the universal default in 12.3, and defers module and kernel loading until first use.

In a world where a single library holds thousands of independently loadable objects, that stops being an optimization and becomes a requirement.

Debug information is not free either

For the same ten kernels on sm_90: 35.9 KB release, 75.6 KB with -lineinfo, 205.9 KB with -G. The last one is not just extra sections.

It changes code generation, taking .text from 16.0 KB to 57.5 KB, because full device debug turns off most optimization. -lineinfo is the option you want in production builds: it doubles the cubin and leaves the code alone.

Share


Why any of this matters if you are not writing a compiler

Three reasons, in increasing order of how much I care about them.

The first is operational. Binary size and load time are real costs in inference deployments, container images and cold starts, and they are almost entirely determined by choices in the format: how many targets, PTX or not, which compression mode, lazy or eager. Those are four flags. Most teams have never touched them.

The second is diagnostic. A cubin tells you its register count, its shared memory usage, its stack frame, its parameter layout, the toolkit that built it and the flags it was built with, and you can read all of that out of a shipped wheel without running anything.

If you are trying to work out why somebody else’s kernel spills, or which architectures a vendor actually optimized for versus which they merely support, the binary is a better source than the changelog. The -res-usage flag on cuobjdump is the fastest occupancy audit I know.

Auditing somebody else’s wheel

cuobjdump -lelf libfoo.so          # which architectures, how many cubins
cuobjdump -lptx libfoo.so          # is there PTX for anything newer
cuobjdump -res-usage libfoo.so     # registers, shared memory, stack, per kernel
cuobjdump -elf libfoo.so | grep -A5 “Toolkit Information”   # which ptxas built it
readelf -SW libfoo.so | grep nv_fatbin                      # how much of the file is device code

Five commands, no GPU, no source. The last one is the one that surprises people: if .nv_fatbin is 85% of a library, you are shipping a kernel archive with a thin API on top, and any conversation about image size has to start there.

The third is strategic, and it is why I went looking in the first place.

There is a long line of work reverse-engineering these formats: decuda for the earliest chips, asfermi for Fermi, KeplerAs, Scott Gray’s maxas for Maxwell (the toolchain behind the fast convolution kernels of the deep learning era), turingas, CuAssembler for everything through Ampere, NVBit for dynamic instrumentation, and now work lifting SASS back into typed compiler IR.

Every one of these projects had to rediscover the container before it could touch the code, and every one of them targets an architecture at least two generations behind current silicon. That lag is the moat’s actual shape.

It is not that the format is unknowable, instead it’s that knowing it is a full-time job that resets on a yearly cadence, and the number of people doing it is small enough to name.

Mercury is the part of this that I think changes the picture, and not in the direction I expected. Publishing an interface and keeping the lowering is NVIDIA’s oldest move: PTX is public, ptxas is not.

What the capsule does is insert a second, private layer at exactly the point where a competitor would want to attach, and it does it for a reason customers asked for: one binary that keeps working on the next chip in the family. The compatibility benefit is real.

The side effect is that the interesting representation of a Blackwell kernel is no longer the SASS you can disassemble, and the format that carries it has no public name, no documented encoding, and its own relocation types.


Four things I would change, and one I would not

Having read a few hundred of these files, I have opinions about the format that have nothing to do with the moat.

Publish the metadata, keep the encoding. There is no competitive information in EIATTR_KPARAM_INFO. It is a calling convention. Documenting the .nv.info attribute codes and the fatbin entry header would cost NVIDIA nothing and would stop every profiler, virtualization layer, checkpointer and build tool from rediscovering the same twenty structs. The instruction encoding is a different argument and I understand the refusal. The container is not.

Make size the default compression mode. The current default compresses PTX and leaves the SASS raw, which is the wrong way round: SASS is the bulk and the part that never gets read by a human. NVIDIA already ships its own libraries with everything compressed. Every framework wheel on PyPI is paying for a default its vendor does not use.

Give the toolkit a way to strip PTX. There is nvprune for removing architectures, but the common case in a container build is “I know exactly which GPUs this image runs on, remove the JIT fallback.” Doing that today means unpacking and repacking fatbins with nvFatbin, which is a strange amount of work for what should be a link flag.

Version the format visibly. Between the ABI version byte, the OS ABI byte that changed value, the toolkit note, the API version attribute and the Mercury ISA version, there are five different notions of “which format is this” in one file, and no single field that a tool can check to know whether it will understand what follows. A cubin that a future tool cannot parse should say so in its first sixteen bytes.

What I would not change personally is the decision to make the binary self-describing rather than pushing the information into headers or a side-channel.

It is the reason NVBit can instrument a kernel it has never seen, the reason cuobjdump -res-usage works on a stranger’s wheel, and the reason this article was possible on a machine with no GPU in it. Verbose, redundant, self-describing binaries are a gift to everyone downstream, including the people trying to work out what you shipped.

Most of what is wrong with the CUDA binary format is that it is undocumented. Very little of it is that it is badly designed.

Where I might be wrong

  • The capsule might be much less than I think. If it turns out to be a small fixup table rather than a re-encodable program, then “second copy of every kernel” is the wrong phrase and the right one is “compatibility annotations.” The sublinear size scaling in the capsule plot above is evidence for the smaller reading; the existence of a section type called CUDA_MERCURY_SASS_MAP is evidence for the larger one. I could not settle it.

  • I have no Blackwell hardware. Nothing here was executed. A claim about what a driver does with a capsule is, from my position, a claim about what an artifact appears designed for. Everything about run time behaviour, including JIT cost and the actual effect of lazy loading, is cited rather than measured.

  • The OS ABI byte is a mess and I may be reading the mess wrong. LLVM lists 51 and 41; cubins carry 65. My reading is that the second constant is 0x41 written as decimal, but it is equally possible NVIDIA changed the value again and LLVM has not caught up. I only checked 13.3 output.

  • The fatbin flag bits are inferred from a handful of data points. I set them by construction using the compression modes and the LTO path; other bits in that word certainly mean things I did not exercise. Byte 0 of e_flags, which flips from 4 to 2 at Blackwell, I could not explain at all.

  • My library numbers are one version of one vendor library. cuBLAS is the extreme case for kernel count. Generalizing “27% device code” to other libraries would be wrong.

  • The feature-tier experiment used one instruction. tcgen05.fence behaves as described, and I have no reason to think the mechanism differs for other family-specific instructions, but one probe is one probe.

Predictions

  1. By the end of 2027, at least one open-source project will publish a working parser for the Mercury capsule format, and it will be motivated by GPU virtualization or checkpointing rather than by performance.

  2. NVIDIA will not document the capsule encoding, on the same terms it has never documented SASS encodings, through at least CUDA 15.

  3. By the end of 2027, -compress-mode=size or its equivalent will be the default in at least one major framework’s release build, driven by wheel size limits rather than by anyone reading the flag documentation.

  4. The next architecture after Blackwell will ship a SASS encoding with statically paired instructions, and nvdisasm‘s VLIW syntax will be why we find out.

  5. Family-conditional targets will absorb the architecture-conditional ones in practice: by the end of 2028, the fraction of shipped cubins carrying a suffixes will fall as libraries move to f, because one binary per family is a better deal than one per chip.


Dossier

Tier A, measured directly and reproducible from the appendix.

  • ELF header field values and the e_flags architecture encoding, including the absence of the virtual architecture from that word.

  • Section inventories and sizes for all twelve targets.

  • The .nv.info and .nv.compat attribute contents as decoded by cuobjdump, including the ISA_CLASS transition when a family-specific instruction is used.

  • The rejection of tcgen05.fence on sm_100 and sm_120a and its acceptance on sm_100f, sm_100a and sm_103f.

  • The 113 attribute names and the 119 relocation names. Parameter base offsets and constant bank sizes.

  • Control field bit positions and their per-kernel distribution.

  • Fatbin entry field offsets, compression flag bits and all size and timing measurements.

  • The CALL.ABS.NOINC cost of relocatable device code.

  • The cuBLAS section and entry statistics, the 25,401 kernel names and the 2,429 cask mangled names.

  • The presence, sizes and scaling of the Mercury sections.

Tier B, documented by NVIDIA, in shipped headers, or in third-party source.

  • The fatbin wrapper struct and section names (fatbinary_section.h).

  • The family and architecture-conditional target semantics, including the statement that sm_100 and sm_100f are aliases absent family features (Programming Guide, PTX ISA, the CUDA 12.9 blog post).

  • Lazy loading history and defaults.

  • Dropped architecture support in CUDA 13.0 and the Thor renumbering.

  • EM_CUDA, ELFOSABI_CUDA and ELFOSABI_CUDA_V2 in LLVM.

  • cuDNN’s GRAPH_JIT_ONLY configuration.

  • The structure of the control field, which is documented in the literature rather than by the vendor.

Tier C, inferred from artifacts.

  • Mercury is a re-finalizable intermediate representation and that the capsule exists to serve family-conditional compatibility.

  • That the duplicated compat block in the fatbin entry header is a loader fast path.

  • ptxas‘s hidden finalizer options correspond to a driver-side code path.

  • .cask_resource is a kernel selection catalog.

  • That the ELFOSABI_CUDA_V2 constant is a hexadecimal value written as decimal.

Tier D, speculation, flagged as such in the text.

  • --no-vliw implies a future paired-issue ISA.

  • EIATTR_COROUTINE_RESUME_ID_OFFSETS points at an unannounced feature.

  • sm_88 is the Nintendo Switch 2, which comes from community references and not from anything I could check.


Reproduce this

No GPU required. On x86-64 Linux:

pip install nvidia-cuda-nvcc==13.3.73 nvidia-cuda-cuobjdump==13.3.73 \
            nvidia-cuda-nvdisasm==13.3.73 nvidia-cuda-crt==13.3.73 \
            nvidia-cuda-runtime nvidia-cuda-cccl
export PATH=$(python3 -c “import sysconfig,os;print(os.path.join(sysconfig.get_paths()[’purelib’],’nvidia/cu13/bin’))”):$PATH

cat > saxpy.cu <<’EOF’
#include <cuda_runtime.h>
__global__ void saxpy(int n, float a, const float* x, float* y) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) y[i] = a * x[i] + y[i];
}
EOF

# the container and its metadata
nvcc -arch=sm_90  -cubin -o k90.cubin  saxpy.cu
nvcc -arch=sm_100 -cubin -o k100.cubin saxpy.cu
cuobjdump -elf k90.cubin | less
diff <(cuobjdump -elf k90.cubin | sed -n ‘/^Sections:/,/^$/p’) \
     <(cuobjdump -elf k100.cubin | sed -n ‘/^Sections:/,/^$/p’)

# the capability claim, and the accelerator bit
for a in sm_100 sm_100a sm_100f; do nvcc -arch=$a -cubin -o c.cubin saxpy.cu
  echo “== $a”; cuobjdump -elf c.cubin | grep -A1 EICOMPAT | grep -E “Attr|Value”; done

# the attribute namespace and the finalizer options
strings -a $(command -v nvdisasm) | grep -o “EIATTR_[A-Z0-9_]*” | sort -u
strings -a $(command -v ptxas)    | grep -iE “capmerc|finaliz|R_MERCURY”

# the feature tiers, as an experiment rather than a diagram
cat > tc.cu <<’EOF’
#include <cuda_runtime.h>
__global__ void k(unsigned* o){
  asm volatile(”tcgen05.fence::before_thread_sync;” ::: “memory”);
  o[0]=1;
}
EOF
for a in sm_100 sm_100f sm_100a sm_103f sm_120a; do printf “%-9s “ $a
  nvcc -arch=$a -cubin -o t.cubin tc.cu 2>&1 | head -1; done
for a in sm_100f sm_100a; do nvcc -arch=$a -cubin -o t_$a.cubin tc.cu
  echo “== $a”; cuobjdump -elf t_$a.cubin | grep -A2 EICOMPAT | grep -E “Attribute|Value”; done

# is the virtual architecture in e_flags? (no)
for v in 75 80 90; do nvcc -gencode arch=compute_$v,code=sm_90 -cubin -o v.cubin saxpy.cu
  python3 -c “import struct;print(’compute_$v -> 0x%08x’%struct.unpack_from(’<I’,open(’v.cubin’,’rb’).read(),48)[0])”
  cuobjdump -elf v.cubin | grep “Virtual SM”; done

# the field names nvdisasm knows
strings -a $(command -v nvdisasm) | grep -o “EF_CUDA_[A-Z0-9_]*” | sort -u

# scheduling control bits, 21 bits starting at 105 of each 128-bit word
nvdisasm -c -hex k90.cubin | head -40
python3 - <<’PY’
import struct
d=open(’k90.cubin’,’rb’).read()
o,=struct.unpack_from(’<Q’,d,0x28); es,n,si=struct.unpack_from(’<HHH’,d,0x3a)
sh=[struct.unpack_from(’<IIQQQQIIQQ’,d,o+i*es) for i in range(n)]
st=sh[si][4]; nm=lambda x: d[st+x:d.index(b’\0’,st+x)].decode()
for s in sh:
    if not nm(s[0]).startswith(’.text.’): continue
    b=d[s[4]:s[4]+s[5]]
    for i in range(0,len(b),16):
        lo,hi=struct.unpack_from(’<QQ’,b,i); w=(hi<<64)|lo
        print(f”{i:04x} stall={(w>>105)&0xf} yield={(w>>109)&1} “
              f”wr={(w>>110)&7} rd={(w>>113)&7} wait={(w>>116)&0x3f:06b} “
              f”reuse={(w>>122)&0xf:04b}”)
PY

# the fatbin container and the cost of coverage
nvcc -gencode arch=compute_90,code=sm_90 \
     -gencode arch=compute_100,code=[sm_100,compute_100] -fatbin -o f.fatbin saxpy.cu
for m in none speed balance size default; do
  nvcc -compress-mode=$m -gencode arch=compute_90,code=sm_90 \
       -gencode arch=compute_100,code=[sm_100,compute_100] \
       -fatbin -o f_$m.fatbin saxpy.cu; ls -l f_$m.fatbin; done

# the host side
nvcc -arch=sm_90 -c -o k.o saxpy.cu
readelf -SW k.o | grep -E “nv_fatbin|nvFatBinSegment|module_id”
readelf -x .nvFatBinSegment k.o
nvcc -arch=sm_90 --keep -c -o /dev/null saxpy.cu && cat saxpy.cudafe1.stub.c

The fatbin entry parser used for the library dissection is thirty lines: walk from the 0xBA55ED50 header, then for each entry read the kind at offset 0, header size at 4, padded payload size at 8, compressed size at 0x10, architecture at 0x1c, flags at 0x28, and skip header_size + payload_size to the next one.


User's avatar

Continue reading this post for free, courtesy of Lorenzo Bradanini.

Or purchase a paid subscription.
© 2026 Lorenzo Bradanini · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture