5 · Repacking weights for the tensor core (_pack)
Four lines of Python make the GEMV fast. _pack reorders each weight matrix once, in prepare(). After that, every lane can fetch its whole tensor-core operand with a single 16-byte load, and every warp reads one contiguous 512-byte chunk. This chapter explains the trick behind it. The lab then runs the real layout through an emulated warp to prove the answer comes out right.
5.1 The problem with the natural layout
Recall from chapter 2 what one lane must hand to mma.sync.m16n8k16 as its A fragment:
- 8 BF16 weights;
- from two rows (g and g+8);
- at four k positions (2t, 2t+1, 2t+8, 2t+9).
In the natural row-major W[n][k], those 8 values sit in four separate places, two rows apart in memory. A lane would need four small loads, and the warp's loads would be scattered. Both are bad for a kernel whose only job is to stream bytes at full speed.
5.2 The trick: the tensor core doesn't know which k is which
A dot product is a sum: Σₖ W[n][k] · x[k]. The order of the terms doesn't matter. Inside one mma, the hardware multiplies A's k-slot j by B's k-slot j, for j = 0…15, and adds the products. It has no idea which real k a slot stands for.
So we may relabel k any way we like, as long as A (weights) and B (x) use the same relabelling. Here is the relabelling we choose. Inside each 32-wide block of K, lane t's slots stand for the real k values 8t, 8t+1, …, 8t+7:
| mma step | lane (g, t), register | k-slots (hardware view) | real k (inside the 32-wide block) |
|---|---|---|---|
| 0 | A reg 0 and 2, row g; reg 1 and 3, row g+8 | 2t, 2t+1, 2t+8, 2t+9 | 8t+0, 8t+1, 8t+2, 8t+3 |
| 1 | same registers, next fragment | 2t, 2t+1, 2t+8, 2t+9 | 8t+4, 8t+5, 8t+6, 8t+7 |
| 0 | B regs (x of token g) | 2t, 2t+1, 2t+8, 2t+9 | 8t+0 … 8t+3 |
| 1 | B regs (x of token g) | same | 8t+4 … 8t+7 |
The payoff is on both sides:
- x: a lane needs x[token g][8t … 8t+7], which is 8 consecutive BF16 values, or one 16-byte load. It covers both mma steps of the block.
- W: a lane needs rows g and g+8 at those same 8 k values.
_packstores exactly those 16 values next to each other: 2 × 16 bytes, one 16-byte load per step.
| lane | g | t | 1st 16-byte weight load (step 0) | 2nd 16-byte weight load (step 1) | its 16-byte x load (per 8-token block) |
|---|---|---|---|---|---|
| 0 | 0 | 0 | rows 0 & 8, k 0–3 | rows 0 & 8, k 4–7 | x[token 0][0–7] |
| 1 | 0 | 1 | rows 0 & 8, k 8–11 | rows 0 & 8, k 12–15 | x[token 0][8–15] |
| 2 | 0 | 2 | rows 0 & 8, k 16–19 | rows 0 & 8, k 20–23 | x[token 0][16–23] |
| 3 | 0 | 3 | rows 0 & 8, k 24–27 | rows 0 & 8, k 28–31 | x[token 0][24–31] |
| 4 | 1 | 0 | rows 1 & 9, k 0–3 | rows 1 & 9, k 4–7 | x[token 1][0–7] |
| 5 | 1 | 1 | rows 1 & 9, k 8–11 | rows 1 & 9, k 12–15 | x[token 1][8–15] |
| 6 | 1 | 2 | rows 1 & 9, k 16–19 | rows 1 & 9, k 20–23 | x[token 1][16–23] |
| 7 | 1 | 3 | rows 1 & 9, k 24–27 | rows 1 & 9, k 28–31 | x[token 1][24–31] |
| … lanes 8–31 continue the pattern: lane = 4·g + t covers rows g and g+8 and the 8 k values 8t … 8t+7. | |||||
_pack: 1024 bytes, read by one warp as two perfectly contiguous 512-byte requests.5.3 _pack, decoded index by index
640 641 642 643
def _pack(w: torch.Tensor) -> torch.Tensor:
"""[N, K] bf16 -> [RB, KB, step, lane(g,t), (j,h,e)]: each lane's 16B is one m16n8k16 A fragment."""
n, k = w.shape
return w.reshape(n // 16, 2, 8, k // 32, 4, 2, 2, 2).permute(0, 3, 5, 2, 4, 6, 1, 7).contiguous()- 641The output's axes: [row block, K block, step, lane (g, t), 8 values (j, h, e)]. Each lane's 8 values (16 bytes) form one A fragment.
- 642W is [n, k], for example [4096, 8192] for the pooler.
- 643Two operations.
reshapeonly names pieces of the two indices;permutereorders them;.contiguous()writes the reordered copy to memory.
The reshape(n // 16, 2, 8, k // 32, 4, 2, 2, 2) splits the two indices like this:
| axis | name | size | meaning |
|---|---|---|---|
| 0 | RB | n/16 | row block: one CTA's 16 rows |
| 1 | h | 2 | which half of the row block (row g or row g+8) |
| 2 | g | 8 | row inside the half, which is also the lane's group |
| 3 | KB | k/32 | 32-wide K block |
| 4 | t | 4 | which 8-wide quarter of the K block, which is also the lane's t |
| 5 | s | 2 | mma step: the first or second 4 of those 8 |
| 6 | j | 2 | the pair inside the 4 (0: k+0,1; 1: k+2,3) |
| 7 | e | 2 | element inside the pair |
So row = 16·RB + 8·h + g and k = 32·KB + 8·t + 4·s + 2·j + e.
permute(0, 3, 5, 2, 4, 6, 1, 7) reorders the axes to [RB, KB, s, g, t, j, h, e]. Read the result from slowest to fastest:
- For each row block,
- for each K block,
- for each mma step,
- for each of the 32 lanes (lane = 4g + t),
- come 8 values in the order (j, h, e). That order is - (row g, k+0, k+1): register 0; - (row g+8, k+0, k+1): register 1; - (row g, k+2, k+3): register 2; - (row g+8, k+2, k+3): register 3.
That is exactly the A-fragment register order (a0 a1 | a2 a3 | a4 a5 | a6 a7) from the hardware layout.
5.4 How the kernel walks the packed array
Here are the lines of gemv_kernel (chapter 6) that use this layout:
72 73 74 75 76 77 78 79 80 81 82 83 84
const int tid = threadIdx.x, lane = tid & 31, warp = tid >> 5, g = lane >> 2, t = lane & 3;
const int rb = blockIdx.x, sidx = blockIdx.y;
const int KB = K / 32, KBS = KB / S, KBW = KBS / NW;
const int kb0 = sidx * KBS + warp * KBW;
const size_t woff = ((size_t)rb * KB + kb0) * 64 + lane;
const uint4* wp = w + woff;
const uint4* wp2 = DUAL ? (w2 + woff) : nullptr;
const bf16* xr[NB];
#pragma unroll
for (int nb = 0; nb < NB; nb++) {
int tok = min(nb * 8 + g, M - 1);
xr[nb] = x + (size_t)tok * K + kb0 * 32 + t * 8;
}- 72
g = lane >> 2andt = lane & 3: the coordinates the layout was built around. - 74–75K is counted in 32-wide blocks. Each warp starts at its own block
kb0. - 76The offset in 16-byte units. One (row block, K block) occupies 2 steps × 32 lanes = 64 units. Adding
lanemakes neighbouring lanes read neighbouring 16-byte units, so a warp reads 512 contiguous bytes. - 79–84x pointers. Lane (g, t) reads token
nb·8 + gat k offset8t: the same 8 real k values its weights cover. Tokens past m are clamped to the last real token. That avoids reading out of bounds, and those results are never stored.
94 95
a[u][0] = ld_stream(wp + (size_t)(i + u) * 64);
a[u][1] = ld_stream(wp + (size_t)(i + u) * 64 + 32);- 94–95Step 0's fragment is at
+0; step 1's is 32 units (512 bytes) later. Moving to the next K block jumps 64 units.
105 106 107
uint4 xv = __ldg((const uint4*)(xr[nb] + (i + u) * 32));
mma16816(acc[nb], a[u][0], xv.x, xv.y);
mma16816(acc[nb], a[u][1], xv.z, xv.w);- 105One 16-byte load of x: values 8t … 8t+7 of token g.
- 106Step 0 multiplies fragment
a[u][0]by x values 0–3 (xv.x,xv.y). - 107Step 1 multiplies
a[u][1]by x values 4–7 (xv.z,xv.w). Swap these two and the answer is wrong; lab 2's--breakmode shows exactly that.
Key idea. Lay the data out in memory in the order the hardware consumes it, and do that once, in untimed setup. Then every load in the hot loop is a full-width, contiguous, 100 %-useful 16-byte load, and no shuffling happens at run time. The same idea drives the 12-bit format in chapter 7: it keeps this order, just with fewer bytes per weight.
cd ~/projects/okc/muse_bf16_proj/learn/labs
python3 lab2_fragments.pyThe file is already on your Mac at ~/projects/okc/muse_bf16_proj/learn/labs/lab2_fragments.py; you can also view or download it here.Part 2 of the lab first prints what lanes 0–5 load: the (row, k) of each of their 8 values. Then it emulates one CTA of gemv_kernel, lane by lane, on the real packed layout:
- the 16-byte loads;
- two
mmaper K block, using the hardware's register layouts; - the shared-memory reduction over warps;
- the store.
The result is checked against x @ W.T.
Exercises.
- Run
python3 lab2_fragments.py --break. It swaps the two halves of x between the steps, and the error jumps from about 1e-16 to about 1. - In
part2(), changeMto 13:NBbecomes 2 and a second 8-token block appears. ChangeNWto 8 (withK = 512, K/32 = 16 blocks must divide by NW × U). The answer stays right. - Change
Uto 4: 16 blocks ÷ 4 warps = 4 blocks per warp, one trip of 4. Still right. Now think aboutU = 3: 4 blocks per warp don't split into trips of 3. The real kernel would read past its own slice and count blocks twice; the emulation stops with anIndexErrorfor the same reason. That is why every configuration in the tables divides evenly. Lab 0 checks this for you.
Check yourself
Why load x with one 16-byte __ldg per lane, instead of staging it in shared memory?
x is tiny (at most 32 tokens × 16 KB) and is read by every CTA, so it is served from L2 and L1 caches rather than HBM. Each lane needs exactly 8 consecutive values of one token, which is one vector load. Staging through shared memory would add copies and a __syncthreads() for no benefit.
How many bytes does one warp read per K block per row block, and in how many load instructions?
16 rows × 32 k × 2 bytes = 1,024 bytes, read in 2 instructions (a[u][0] and a[u][1]), each 32 lanes × 16 bytes = 512 contiguous bytes.
Does the relabelling change the numerical result compared with the natural order?
It changes the order in which products are added, as any split of K does. FP32 accumulation is not associative, so the last bits can differ, but the error is far inside the 1 % tolerance. With a one-hot probe row only one product is nonzero, so the result is exact either way.