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.

ON THIS PAGE

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:

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:

lanegt1st 16-byte weight load (step 0)2nd 16-byte weight load (step 1)its 16-byte x load (per 8-token block)
000rows 0 & 8, k 0–3rows 0 & 8, k 4–7x[token 0][0–7]
101rows 0 & 8, k 8–11rows 0 & 8, k 12–15x[token 0][8–15]
202rows 0 & 8, k 16–19rows 0 & 8, k 20–23x[token 0][16–23]
303rows 0 & 8, k 24–27rows 0 & 8, k 28–31x[token 0][24–31]
410rows 1 & 9, k 0–3rows 1 & 9, k 4–7x[token 1][0–7]
511rows 1 & 9, k 8–11rows 1 & 9, k 12–15x[token 1][8–15]
612rows 1 & 9, k 16–19rows 1 & 9, k 20–23x[token 1][16–23]
713rows 1 & 9, k 24–27rows 1 & 9, k 28–31x[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.
One 16-row × 32-k block of W after _pack: 1024 bytes, read by one warp as two perfectly contiguous 512-byte requests.

5.3 _pack, decoded index by index

submission.py, lines 640–643
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()

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:

  1. For each row block,
  2. for each K block,
  3. for each mma step,
  4. for each of the 32 lanes (lane = 4g + t),
  5. 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:

submission.py, lines 72–84
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;
  }
submission.py, lines 94–95
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);
submission.py, lines 105–107
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);

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.

Try it · lab2_fragments.py — Lab 2: tensor-core fragments and the weight repack (_pack), emulated in numpy.
cd ~/projects/okc/muse_bf16_proj/learn/labs
python3 lab2_fragments.py
The 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 result is checked against x @ W.T.

Exercises.

  1. 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.
  2. In part2(), change M to 13: NB becomes 2 and a second 8-token block appears. Change NW to 8 (with K = 512, K/32 = 16 blocks must divide by NW × U). The answer stays right.
  3. Change U to 4: 16 blocks ÷ 4 warps = 4 blocks per warp, one trip of 4. Still right. Now think about U = 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 an IndexError for 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.