The Evolution of Tensor Core Data Layouts#
Overview
On Ampere,
mma.syncreads A, B, and C from registers distributed across the threads in a warp, and D remains in registers.On Hopper,
wgmma.mma_asynccan read inputs directly from shared memory through matrix descriptors. The accumulator, however, remains distributed across per-thread registers.On Blackwell,
tcgen05.mmamoves the accumulator into TMEM. The scale factors used by block-scaled MMA also live in TMEM.
Mathematically, all three generations perform the same matrix multiply-accumulate:
A and B are the multiplicands, C is the incoming accumulator, and D is the result. The equation says nothing about where these matrices live or in what order the Tensor Core reads their elements.
This chapter fills in that missing physical picture. Tensor Core instructions interpret registers, shared memory addresses, and TMEM coordinates according to fixed hardware rules. Put one element in the wrong place, and the instruction will treat it as a different matrix element and produce the wrong result.
We will follow the data path through Ampere, Hopper, and Blackwell, asking the same three questions each time: Where does the MMA read its inputs? Where does it keep the accumulator? How does the kernel describe the layout expected by the instruction? We will use the notation introduced in Data Layout to write the concrete mappings.
Two Memory-Access Requirements to Keep in Mind#
Before looking at Tensor Core instructions, recall two other requirements that already constrain a GPU layout. Global memory addresses issued by a warp should combine into a small number of contiguous, aligned memory transactions. Shared memory addresses should spread across banks to avoid bank conflicts.
A practical Tensor Core layout must satisfy all three constraints: global memory accesses should coalesce, shared memory accesses should avoid conflicts, and every matrix element should occupy the position required by the MMA instruction. The first two were covered earlier. This chapter focuses on the third.
Ampere: Operands and Accumulators in Registers#
Start with Ampere. Its main Tensor Core interface is the mma.sync.aligned.m16n8k* family. mma.sync takes A, B, and C from registers and writes D back to registers.
A warp executes mma.sync collectively. PTX specifies how the A, B, and C/D tiles are distributed across the registers of its 32 threads. Each thread holds only part of a tile; that thread-local portion is its register fragment. Taken together, the 32 fragments represent the complete matrix tile.
Before issuing the MMA, an Ampere kernel will usually stage A and B in shared memory and then use ldmatrix to place the elements in the correct thread registers:
SMEM --ldmatrix--> registers
registers --mma.sync--> registers
registers --ordinary store--> SMEM or GMEM
At the moment mma.sync executes, only the register contents matter. The path above is the common high-performance route, but ordinary loads and register operations can prepare the same fragments.
A Concrete Fragment Mapping#
Consider mma.sync.aligned.m16n8k16. Let A be row-major and B be column-major, with fp16 or bf16 inputs and fp32 accumulation. The warp computes:
A thread’s lane ID determines which elements of A, B, C, and D it holds. We will begin with the C/D accumulator, where the pattern is easiest to see.
The PTX mapping repeats in groups of four lanes: lanes 0-3 cover rows 0 and 8, lanes 4-7 cover rows 1 and 9, and the remaining groups continue in the same way. For lane ID \(l\), define:
Here, \(g\) is the four-lane group index, and \(t\) is the lane’s position within that group.
Group \(g\) covers rows \(g\) and \(g+8\) of the output tile. Lane \(t\) within the group covers columns \(2t\) and \(2t+1\) in both rows. Each lane therefore holds four fp32 accumulator values at:
For example, lane 5 has:
so it holds the following four C/D elements:
The A fragment uses the same \(g\) and \(t\), but its coordinates are \((m,k)\). Each lane holds eight fp16 or bf16 elements, with two adjacent elements packed into each 32-bit register:
At each of the two M coordinates, \(m=g\) and \(m=g+8\), the lane owns one adjacent pair in the lower half of K and another in the upper half.
The B fragment uses \((k,n)\) coordinates. Each lane holds four fp16 or bf16 elements, again with two elements packed into each 32-bit register:
For B, \(g\) determines the \(n\) coordinate, while \(t\) and the register number together determine \(k\).
We can now express the same mapping with the layout notation from the previous chapter. The first eight rows of C/D form an 8x8 local pattern:
S[(8, 4, 2) : (4@laneid, 1@laneid, 1@reg)]
The three coordinates are:
(row, column_pair, element_in_pair)
Adjacent columns form one column_pair, and element_in_pair selects one of the two columns. A matrix coordinate (row, col) therefore becomes:
(row, col // 2, col % 2)
The first two coordinates determine the lane ID, and the last determines the fragment slot within that lane:
lane_id = row * 4 + col // 2
slot = col % 2
Return to the two elements (1,2) and (1,3) held by lane 5. Their atom coordinates are (1,1,0) and (1,1,1). Both map to lane 1*4+1=5; the final coordinate selects slot 0 or slot 1.
The complete C/D fragment contains two of these local patterns along M. The 8x8 atom is a convenient way to describe the layout; the hardware still executes one full mma.m16n8k16 instruction.
Blackwell: Moving Accumulators into TMEM#
Accumulators in TMEM#
Blackwell keeps Hopper’s descriptor-based input path. tcgen05.mma uses descriptors to find A and B in shared memory and read them in the required layout. Some modes also allow A to come from TMEM.
The major change is where the accumulator lives. Hopper keeps C/D in the registers of the participating threads; Blackwell places it in Tensor Memory, or TMEM. When the epilogue needs the result, tcgen05.ld loads the data into registers:
A/B in SMEM --tcgen05.mma--> C/D in TMEM
C/D in TMEM --tcgen05.ld--> register fragment --store--> GMEM
tcgen05.mma executes asynchronously. After issuing the instruction, the kernel must wait for the MMA to complete before the epilogue reads the result with tcgen05.ld. The load is asynchronous as well, so the epilogue must execute tcgen05.wait::ld before using the destination registers. The write and read layouts have to match: whatever TMEM lane and column coordinates receive an element from tcgen05.mma must provide that element to the corresponding thread register during tcgen05.ld.
Scale Factors for Block-Scaled MMA#
The Broadcasting Scale Factors Across Warps in TMEM section in the previous chapter used M=128 and SFK=4 to derive the TMEM packing and .warpx4 replication of scale factors. We now pick up that data path where it left off: how do those values enter TMEM, and how does the MMA select the bytes needed for one operation?
Block-scaled MMA uses two scale-factor matrices:
SFA(M, SFK)
SFB(N, SFK)
SFA[m,sfk] scales row m of A for K-scale block sfk. SFB[n,sfk] does the same for column n of B. We keep the logical index order introduced in the previous chapter; PTX tables write the same B-side matrix as SFB[sfk,n], with shape SFB_M x N.
TMA usually loads A and B into shared memory, where the MMA reads them directly. Scale factors need to reach TMEM, while TMA stops at shared memory, so they take one additional step through tcgen05.cp:
A, B: GMEM --TMA--> SMEM --tcgen05.mma--> Tensor Core
SFA, SFB: GMEM --TMA--> SMEM --tcgen05.cp--> TMEM --tcgen05.mma--> Tensor Core
How tcgen05.cp Writes TMEM#
Recall the complete layout derived in the previous chapter:
S[(4, 32, 4) : (4@TCol, 1@TLane, 1@TCol)]
+ R[4 : 32@TLane]
S[...] maps (Mgroup, lane, sfk) to byte positions in TMEM. In a typed TIRx layout, @TCol strides are measured in buffer elements. A scale factor is 8 bits here, so the logical TCol position is 4*Mgroup+sfk; every four consecutive positions pack into one 32-bit hardware TCol cell. Equivalently, hardware_TCol=(4*Mgroup+sfk)//4 and byte_in_word=(4*Mgroup+sfk)%4.
R[...] adds four replicas along TLane. The .32x128b.warpx4 form of tcgen05.cp creates this layout: it writes one 32-lane window, then broadcasts the same data into the other three warp windows.
Word-Level Replication for scale_vec#
Once the values are in TMEM, one more layout rule applies inside each 32-bit TMEM word. scale_vec determines whether each row of SFA, or each column of SFB, contains one, two, or four logical scales. Shorter scale vectors repeat within the four-byte word:
scale_vec::1X: [SF0, SF0, SF0, SF0]
scale_vec::2X: [SF0, SF1, SF0, SF1]
scale_vec::4X: [SF0, SF1, SF2, SF3]
SFA_ID or SFB_ID tells the MMA which copy to read. With 1X, the byte offset may be 0, 1, 2, or 3. With 2X, offset 0 selects the low half-word and offset 2 selects the high half-word. With 4X, all four bytes are used, so the ID must be 0.
This repetition within a word is separate from R[4 : 32@TLane]: the former repeats bytes inside a 32-bit word, while the latter replicates data across four TMEM lane windows. A scale’s mathematical reuse across every K element in its block is a third, separate idea.
Per-Thread Register Fragments Across Generations#
Across all three generations, matrix data passes through register fragments distributed across threads. The exact fragment shape and mapping depend on the instruction.
On Ampere, ldmatrix loads shared-memory data and builds the register fragment consumed by mma.sync.
On Hopper, wgmma writes its accumulator into register fragments for the epilogue.
On Blackwell, the accumulator stays in TMEM during the compute phase. Before the epilogue begins, tcgen05.ld loads the result into register fragments.
The role of the register fragment therefore changes across generations. Ampere and Hopper use it to hold the accumulator during computation. Blackwell uses it mainly at the boundary between TMEM and the epilogue. The m8n8-style atom shown earlier is one common example; tcgen05.ld and tcgen05.st also provide several other data-movement shapes.
Comparing the Three Data Paths#
Architecture |
Main MMA instruction |
Main source of A/B |
Accumulator location |
How the shared-memory layout is expressed |
|---|---|---|---|---|
Ampere |
|
Registers |
Registers |
The kernel usually computes addresses and swizzles explicitly |
Hopper |
|
A may come from registers or SMEM; B comes from SMEM |
Registers |
A matrix descriptor records strides and the swizzle mode |
Blackwell |
|
Primarily SMEM; some modes take A from TMEM |
TMEM |
Descriptors cover SMEM inputs; TMEM layouts describe accumulators and scale factors |
Side by side, the progression is clear. Ampere arranges inputs as per-lane register fragments. Hopper lets the Tensor Core consume shared memory directly through descriptors. Blackwell then moves the accumulator and scale factors into TMEM.
When implementing a kernel, trace the data flow one step at a time: the layout written by one instruction must be the layout read by the next. If any step interprets the physical arrangement differently, the Tensor Core may consume the wrong elements. Even when the result remains correct, a mismatched access pattern can still waste performance.