Mnemonic

Talking to the GPU

How I drove an NVIDIA card from assembly with no CUDA toolkit, ran my first PTX kernels, and measured what the card can really do.

Until stage 4 everything ran on the CPU. The usual way to program an NVIDIA GPU is the CUDA toolkit, with its C++ compiler, headers and import libraries, and none of that was allowed in this project. Two things come with the graphics driver anyway: nvcuda.dll, which exports the CUDA driver API, and a compiler inside it for PTX, NVIDIA’s assembly language for the GPU. Those two turned out to be all I needed.

Loading the driver by hand

Without the toolkit there is no cuda.lib to link against, so gpu_init does the linker’s job itself. It calls LoadLibraryA("nvcuda.dll"), then GetProcAddress once for each function, and stores every address in a table. Several functions are exported under versioned names (cuMemAlloc is really cuMemAlloc_v2), so the list has two columns: the name I call a function by, and the name the DLL exports it under.

%macro FUNCS 0
    F cuInit,                   cuInit
    F cuDriverGetVersion,       cuDriverGetVersion
    F cuDeviceGet,              cuDeviceGet
    F cuDeviceGetCount,         cuDeviceGetCount
    F cuDeviceGetName,          cuDeviceGetName
    F cuDeviceGetAttribute,     cuDeviceGetAttribute
    F cuDeviceTotalMem,         cuDeviceTotalMem_v2
    F cuDevicePrimaryCtxRetain, cuDevicePrimaryCtxRetain
    F cuCtxSetCurrent,          cuCtxSetCurrent
    F cuCtxSynchronize,         cuCtxSynchronize
    F cuMemGetInfo,             cuMemGetInfo_v2
    F cuMemAlloc,               cuMemAlloc_v2

The list is a macro, and the file expands it twice with two different definitions of F. In the read-only data section, F emits db %str(%2), 0, so the export names become one run of zero-terminated strings. In the .bss section, F emits %1 resq 1, an 8-byte slot named after the first column. gpu_init walks the strings with rsi and the slots with rdi, and since both come from the same list, a name can never end up paired with the wrong slot.

One macro for every call

Every driver function returns a CUresult, and 0 means success. Rather than write a check after each call, I call them all through one macro:

; call a driver function, and if it doesn't return CUDA_SUCCESS die with its
; name and the driver's own words for what went wrong
%macro CU 1
    call [%1]
    test eax, eax
    jz %%ok
    mov ecx, eax
    lea rdx, [%%name]
    call cu_fail
    section .rdata
    %%name db %str(%1), 0
    section .text
%%ok:
%endmacro

call [%1] is an indirect call through the slot that gpu_init filled in. On failure the macro passes the error code in ecx and the function’s name in rdx to cu_fail, which asks the driver to describe the error (cuGetErrorName and cuGetErrorString), prints it and exits. The name string is made right where the macro is used: it switches to .rdata, emits the string under a label local to this one expansion (%%name), and switches back to .text. So CU cuMemAlloc is one line in the source, and a failure prints cuda: cuMemAlloc failed: followed by the driver’s own words.

A context, and PTX without a header

gpu_init then takes device 0 and makes its primary context current, with cuDevicePrimaryCtxRetain and cuCtxSetCurrent. The kernels are plain .ptx files, which kernels.asm puts into the executable with NASM’s incbin, each between a start label and an end label. A PTX file normally begins with a .version and a .target line, and mine don’t. gpu_init reads the compute capability with cuDeviceGetAttribute, turns it into a number (12.0 becomes 120), and picks the newest entry in a small table that isn’t newer than the card. On the 5070 Ti that’s PTX ISA 8.7 and sm_120. gpu_module writes those two lines and .address_size 64 in front of the source and hands the result to cuModuleLoadDataEx, which compiles it for this GPU. Anything older than compute capability 8.0, where bf16 tensor cores start, gets an error message instead.

When the compiler rejects a kernel, its error log has the right message but line numbers 4 higher than in my file, because of the header. The driver also caches what it compiles under %APPDATA%, and I keep everything on my D: drive, so gpu_init sets CUDA_CACHE_PATH before it even loads the DLL. The compiler’s “verbose” info log stays empty, so bin\gpuinfo gets each kernel’s registers, shared memory and spills from cuFuncGetAttribute, and its occupancy from cuOccupancyMaxActiveBlocksPerMultiprocessor.

First kernels and first numbers

The first PTX file, basic.ptx, has five small kernels: vadd, a 16-byte copy16, f2bf (f32 to bf16, rounded to nearest even), a busy loop of FMAs and an empty one for timing launches. vadd and f2bf match the CPU bit for bit. Then bin\gpubench measured the card:

Test Result
PCIe 5.0 upload, pinned memory 53.6 GB/s
PCIe 5.0 download, pinned memory 34.6 GB/s
PCIe, pageable memory, either way about 13 GB/s
VRAM, copy16 752 GB/s (84% of 896)
Kernel launches, queued 6.7 µs each
Kernel launch and wait 10.8 µs each

Launches are slow under WDDM, the Windows display driver model. The test that mattered most for later was the overlap: a pinned 256 MB upload took 5.0 ms, a kernel took 4.1 ms, and on two streams the pair took 5.0 ms together, so the kernel was completely hidden behind the copy.

Half of the first gpubench output was inf and 0.00. The Windows calling convention lets any function overwrite xmm0 to xmm5, and my say macro calls WriteFile, so a result kept in xmm0 across a printed label was gone by the time I printed it. Now floats go to the stack first. A related mistake happened twice: the 32 bytes from [rsp] to [rsp+31] belong to whatever function I call next, and a local I kept there got overwritten. One of those times it was a return address, and the program crashed.

The real tensor core peak

sm_120 has no wgmma or tcgen05 (those belong to sm_90a and sm_100a), so the tensor core path is mma.sync.m16n8k16: one warp multiplies a 16×16 bf16 tile by a 16×8 one and adds into 16×8 fp32 accumulators. To find its peak I wrote mma_peak, in which every warp keeps 8 independent mma.sync chains going and touches no memory until the end. Each instruction is 2 × 16 × 8 × 16 = 4,096 flops. It came out at 102.9 TFLOPS, which only makes sense at a clock above the card’s rated 2.45 GHz boost, so I measured the clock too. Thread 0 of block 0 reads %clock64 (SM cycles) and %globaltimer (nanoseconds) before and after its loop, and the ratio is the clock it actually ran at: 2.87 GHz. Dividing by the whole kernel’s time instead would be wrong once there’s more than one wave of blocks, so the occupancy call sizes the grid to exactly one wave. 102.9 TFLOPS at 2.87 GHz on 70 SMs is 512 flops per SM per clock, or 256 bf16 multiply-adds, which is GeForce’s half rate for fp32 accumulation.

Fragments, ldmatrix and padding

Each lane of the warp holds its own pieces of the three tiles in registers, in a fixed layout. Writing the lane number as 4g + t:

  • A (16×16) is 4 registers of 2 bf16 each: rows g and g+8, k pairs 2t and 2t+8.
  • B (16×8) is 2 registers: column g, k pairs 2t and 2t+8.
  • C (16×8) is 4 floats: rows g and g+8, columns 2t and 2t+1.

A linear layer’s weight is stored [N][K], one row per output, and that is exactly the “col” layout B wants, so every fragment register is one aligned 32-bit load. gemm_mma1 does just that, straight from global memory:

kloop:
    setp.ge.u32     %p, %r15, %r3;
    @%p bra         store;
    ld.global.b32   %r16, [%rd5];               // a0: row g,   k 2t..
    ld.global.b32   %r17, [%rd7];               // a1: row g+8, k 2t..
    ld.global.b32   %r18, [%rd5+16];            // a2: row g,   k 2t+8..
    ld.global.b32   %r19, [%rd7+16];            // a3: row g+8, k 2t+8..
    ld.global.b32   %r20, [%rd9];               // b0: n g, k 2t..
    ld.global.b32   %r21, [%rd9+16];            // b1: n g, k 2t+8..
    mma.sync.aligned.m16n8k16.row.col.f32.bf16.bf16.f32
        {%f0, %f1, %f2, %f3}, {%r16, %r17, %r18, %r19}, {%r20, %r21}, {%f0, %f1, %f2, %f3};
    add.u64         %rd5, %rd5, 32;
    add.u64         %rd7, %rd7, 32;
    add.u64         %rd9, %rd9, 32;
    add.u32         %r15, %r15, 16;
    bra             kloop;

gemm_tc is the real one. A block of 256 threads computes a 128×128 tile of C, going through K 32 at a time. Its 8 warps are arranged 2 × 4, so each warp owns 64×32 of C, which is 16 mma tiles and 64 accumulator registers per thread. cp.async copies tiles from global to shared memory 16 bytes at a time without going through registers, in two stages, so the next k tile streams in while the current one is being multiplied. ldmatrix.x4 then loads four 8×8 matrices from shared memory and hands them out already in the fragment layout. For A, lane i supplies the address of row i % 16, column (i / 16) × 8.

Shared memory has 32 banks of 4 bytes, and one ldmatrix reads 8 rows of 16 bytes. With rows of 32 bf16 (64 bytes), rows 0, 2, 4 and 6 all start in the same banks, and their reads conflict. Padding each row to 40 bf16 (80 bytes) puts the 8 rows in 8 different groups of 4 banks, so there are no conflicts, and 80 is still a multiple of 16, so cp.async keeps its 16-byte alignment. gemm_tc uses 96 registers and 40,960 bytes of shared memory with no spills, and shared memory limits it to 2 blocks per SM.

The first matmuls

All three compute C = A W^T with bf16 inputs and an f32 result. The last five rows use the main model’s real shapes, for 16 sequences of 1,024 tokens.

Kernel Shape (M × N × K) TFLOPS
gemm_ref, plain FMA 4096³ 0.7
gemm_mma1, no tiling 4096³ 17.0
gemm_tc 4096³ 87.9
gemm_tc 8192³ 66.6
gemm_tc, qkv 16384 × 1280 × 768 82
gemm_tc, attention out 16384 × 768 × 768 82
gemm_tc, MLP up 16384 × 4096 × 768 87
gemm_tc, MLP down 16384 × 768 × 2048 87
gemm_tc, logits 16384 × 32768 × 768 61.9

87.9 TFLOPS is 85% of the measured peak. The shapes with a large N lose to L2 cache misses (the blocks aren’t swizzled yet) and to writing C as f32. gemm_ref matches the CPU bit for bit, and gemm_mma1 and gemm_tc match gemm_ref to within 1e-7 to 7e-6 relative, with the error growing with K, as it should when fp32 sums are added in a different order. The matmuls of a whole forward pass of the main model ran at 79.7 TFLOPS, which put 5 billion tokens at about 14.5 hours of pure matmul.