8 · The compressed GEMV, line by line (gemvz_kernel)

gemvz_kernel (lines 275–472) is the workhorse of the submission. It runs 12 of the 30 scored shapes and produces most of our speedup. Structurally it is gemv_kernel from chapter 6 with three changes:

ON THIS PAGE
  1. It reads the 12-bit format: 48 bytes per lane per 64-wide K group instead of 64.
  2. It decodes those bytes into tensor-core fragments with decz.
  3. One extra warp applies the exact corrections for the rare out-of-window weights while the other warps stream.

This chapter focuses on what is new. For the parts copied from chapter 6, it points back.

8.1 Signature and shared state

submission.py, lines 275–289
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
// One CTA = one 16-row block x a K-range (blockIdx.y of S). NW streaming warps split the range; warp NW applies
// the exception corrections for the CTA's rows and K-range. PIPE as in gemv_cuda: 0/3 plain (3: minBlocks 1),
// 1/2 software-pipelined (2: minBlocks 2).
template <int NW, int UG, int NB, int PIPE>
__global__ void __launch_bounds__((NW + 1) * 32, (PIPE == 2 ? 2 : PIPE == 3 ? 1 : 0))
gemvz_kernel(const bf16* __restrict__ x, const uint4* __restrict__ w, const int2* __restrict__ esc, int capr,
             bf16* __restrict__ out, float4* __restrict__ ws, int* __restrict__ sem, int M, int N, int K, int S,
             uint32_t TL, uint32_t TH, int dbg) {
  static_assert(NW >= NB, "need at least NB warps for the final reduction");
  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 KG = K / 64, KGS = KG / S, KGW = KGS / NW;
  __shared__ float4 red[NW][NB * 32];
  __shared__ float ecorr[16][NB * 8 + 1];
  __shared__ int s_last;

8.2 The exception warp

submission.py, lines 291–314
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
  if (warp == NW) {
    // exception corrections: lane -> (row r, tokens p, p+2, ...); fixed order, so deterministic
    const int r = lane & 15, p = lane >> 4;
    const int klo = sidx * KGS * 64, khi = klo + KGS * 64;
    float ea[NB * 4];
#pragma unroll
    for (int q = 0; q < NB * 4; q++) ea[q] = 0.f;
    if (!(dbg & 1)) {
      const int2* ep = esc + (size_t)(rb * 16 + r) * capr;
#pragma unroll 4
      for (int j = 0; j < capr; j++) {
        const int2 e = __ldg(ep + j);
        if (e.y != 0 && e.x >= klo && e.x < khi) {
          const float d = __int_as_float(e.y);
#pragma unroll
          for (int q = 0; q < NB * 4; q++) {
            const int tok = p + 2 * q;
            if (tok < M) ea[q] += d * __bfloat162float(x[(size_t)tok * K + e.x]);
          }
        }
      }
    }
#pragma unroll
    for (int q = 0; q < NB * 4; q++) ecorr[r][p + 2 * q] = ea[q];

Each (row, token) belongs to exactly one lane and is summed in a fixed order, so the result is deterministic. The warp runs at the same time as the streaming warps and has far less to do, so it finishes long before them. Its cost is roughly one warp's share of issue slots and registers.

8.3 The streaming warps: set-up

submission.py, lines 315–326
315
316
317
318
319
320
321
322
323
324
325
326
  } else {
    const int g0 = sidx * KGS + warp * KGW;
    const uint4* wp = w + ((size_t)rb * KG + g0) * 96 + lane;
    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 + g0 * 64 + t * 8;
    }
    float acc[NB][4];
#pragma unroll
    for (int nb = 0; nb < NB; nb++) acc[nb][0] = acc[nb][1] = acc[nb][2] = acc[nb][3] = 0.f;

8.4 Main loop, version 1 (PIPE 0 and 3)

submission.py, lines 328–358
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
    if constexpr (PIPE == 0 || PIPE == 3) {
      for (int i = 0; i < KGW; i += UG) {
        uint4 A0[UG], A1[UG], B[UG];
#pragma unroll
        for (int u = 0; u < UG; u++) {
          const uint4* p = wp + (size_t)(i + u) * 96;
          A0[u] = ldz_stream(p);
          A1[u] = ldz_stream(p + 32);
          B[u] = ldz_stream(p + 64);
        }
#pragma unroll
        for (int u = 0; u < UG; u++) {
          uint4 xa[NB], xb[NB];
#pragma unroll
          for (int nb = 0; nb < NB; nb++) {
            xa[nb] = __ldg((const uint4*)(xr[nb] + (i + u) * 64));
            xb[nb] = __ldg((const uint4*)(xr[nb] + (i + u) * 64 + 32));
          }
          const uint4 f0 = decz(A0[u].x, A0[u].y, B[u].x, TL, TH);
          const uint4 f1 = decz(A0[u].z, A0[u].w, B[u].y, TL, TH);
          const uint4 f2 = decz(A1[u].x, A1[u].y, B[u].z, TL, TH);
          const uint4 f3 = decz(A1[u].z, A1[u].w, B[u].w, TL, TH);
#pragma unroll
          for (int nb = 0; nb < NB; nb++) {
            mmaz(acc[nb], f0, xa[nb].x, xa[nb].y);
            mmaz(acc[nb], f1, xa[nb].z, xa[nb].w);
            mmaz(acc[nb], f2, xb[nb].x, xb[nb].y);
            mmaz(acc[nb], f3, xb[nb].z, xb[nb].w);
          }
        }
      }

8.5 Main loop, version 2: software pipelining (PIPE 1 and 2)

submission.py, lines 359–404
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
    } else {
      uint4 A0[UG], A1[UG], B[UG];
#pragma unroll
      for (int u = 0; u < UG; u++) {
        const uint4* p = wp + (size_t)u * 96;
        A0[u] = ldz_stream(p);
        A1[u] = ldz_stream(p + 32);
        B[u] = ldz_stream(p + 64);
      }
      for (int i = 0; i < KGW; i += UG) {
        uint4 xa[UG][NB], xb[UG][NB];
#pragma unroll
        for (int u = 0; u < UG; u++)
#pragma unroll
          for (int nb = 0; nb < NB; nb++) {
            xa[u][nb] = __ldg((const uint4*)(xr[nb] + (i + u) * 64));
            xb[u][nb] = __ldg((const uint4*)(xr[nb] + (i + u) * 64 + 32));
          }
        uint4 nA0[UG], nA1[UG], nB[UG];
        if (i + UG < KGW) {
#pragma unroll
          for (int u = 0; u < UG; u++) {
            const uint4* p = wp + (size_t)(i + UG + u) * 96;
            nA0[u] = ldz_stream(p);
            nA1[u] = ldz_stream(p + 32);
            nB[u] = ldz_stream(p + 64);
          }
        }
#pragma unroll
        for (int u = 0; u < UG; u++) {
          const uint4 f0 = decz(A0[u].x, A0[u].y, B[u].x, TL, TH);
          const uint4 f1 = decz(A0[u].z, A0[u].w, B[u].y, TL, TH);
          const uint4 f2 = decz(A1[u].x, A1[u].y, B[u].z, TL, TH);
          const uint4 f3 = decz(A1[u].z, A1[u].w, B[u].w, TL, TH);
#pragma unroll
          for (int nb = 0; nb < NB; nb++) {
            mmaz(acc[nb], f0, xa[u][nb].x, xa[u][nb].y);
            mmaz(acc[nb], f1, xa[u][nb].z, xa[u][nb].w);
            mmaz(acc[nb], f2, xb[u][nb].x, xb[u][nb].y);
            mmaz(acc[nb], f3, xb[u][nb].z, xb[u][nb].w);
          }
        }
#pragma unroll
        for (int u = 0; u < UG; u++) { A0[u] = nA0[u]; A1[u] = nA1[u]; B[u] = nB[u]; }
      }
    }

This is the same idea as chapter 6.5. Here it matters even more, because decoding adds integer work between a load and its use. The tuner picked PIPE 2 for the biggest shapes at m = 1 and 8 (qkv_gate and pooler: 16 warps, 2 CTAs per SM), PIPE 1 for qkv_gate at m = 16, and PIPE 3 or 0 for the others.

8.6 Combining everything

submission.py, lines 405–422
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
#pragma unroll
    for (int nb = 0; nb < NB; nb++) red[warp][nb * 32 + lane] = make_float4(acc[nb][0], acc[nb][1], acc[nb][2], acc[nb][3]);
  }
  __syncthreads();
  float4 r = make_float4(0.f, 0.f, 0.f, 0.f);
  const bool active = tid < NB * 32;
  const int nbo = tid >> 5;
  if (active) {
#pragma unroll
    for (int wi = 0; wi < NW; wi++) {
      float4 v = red[wi][tid];
      r.x += v.x; r.y += v.y; r.z += v.z; r.w += v.w;
    }
    r.x += ecorr[g][nbo * 8 + 2 * t];
    r.y += ecorr[g][nbo * 8 + 2 * t + 1];
    r.z += ecorr[g + 8][nbo * 8 + 2 * t];
    r.w += ecorr[g + 8][nbo * 8 + 2 * t + 1];
  }
submission.py, lines 423–449
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
  if (S > 1) {
    if (active) ws[(size_t)(rb * S + sidx) * (NB * 32) + tid] = r;
    __threadfence();
    __syncthreads();
    if (tid == 0) s_last = (atomicAdd(&sem[rb], 1) == S - 1);
    __syncthreads();
    if (!s_last) return;
    __threadfence();
    if (active) {
      r = make_float4(0.f, 0.f, 0.f, 0.f);
      for (int s = 0; s < S; s++) {
        float4 v = __ldcg(ws + (size_t)(rb * S + s) * (NB * 32) + tid);
        r.x += v.x; r.y += v.y; r.z += v.z; r.w += v.w;
      }
    }
    if (tid == 0) sem[rb] = 0;
  }
  if (active) {
    float rr[4] = {r.x, r.y, r.z, r.w};
#pragma unroll
    for (int q = 0; q < 4; q++) {
      int tok = nbo * 8 + 2 * t + (q & 1);
      int row = rb * 16 + g + ((q >> 1) << 3);
      if (tok < M) out[(size_t)tok * N + row] = __float2bfloat16_rn(rr[q]);
    }
  }
}

8.7 Launch and instantiation list

submission.py, lines 451–472
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
template <int NW, int UG, int NB, int PIPE>
static void launchz(torch::Tensor x, torch::Tensor w, torch::Tensor esc, int capr, torch::Tensor out, torch::Tensor ws,
                    torch::Tensor sem, int N, int K, int S, uint32_t TL, uint32_t TH, int dbg) {
  dim3 grid(N / 16, S);
  gemvz_kernel<NW, UG, NB, PIPE><<<grid, (NW + 1) * 32, 0, c10::cuda::getCurrentCUDAStream()>>>(
      (const bf16*)x.data_ptr(), (const uint4*)w.data_ptr(), (const int2*)esc.data_ptr(), capr,
      (bf16*)out.data_ptr(), (float4*)ws.data_ptr(), (int*)sem.data_ptr(), (int)x.size(0), N, K, S, TL, TH, dbg);
}
#define Z3(NW_, U_, NB_, P_) if (NW == NW_ && UG == U_ && NB == NB_ && P == P_) { launchz<NW_, U_, NB_, P_>(x, w, esc, (int)capr, out, ws, sem, (int)N, (int)K, (int)S, (uint32_t)TL, (uint32_t)TH, (int)dbg); return; }
void gemvz(torch::Tensor x, torch::Tensor w, torch::Tensor esc, int64_t capr, torch::Tensor out, torch::Tensor ws,
           torch::Tensor sem, int64_t N, int64_t K, int64_t NW, int64_t S, int64_t UG, int64_t NB, int64_t P,
           int64_t TL, int64_t TH, int64_t dbg) {
  Z3(4, 1, 2, 1)
  Z3(4, 1, 4, 3)
  Z3(4, 2, 1, 3)
  Z3(4, 2, 2, 3)
  Z3(8, 1, 4, 3)
  Z3(8, 2, 2, 0)
  Z3(8, 2, 2, 3)
  Z3(16, 1, 1, 2)
  TORCH_CHECK(false, "unsupported gemvz config");
}

Key idea. Keep the hot loop identical for every weight and move rare cases off to the side. A first compression attempt in round 1 applied the corrections after the main loop, which added a serial tail, and it gained only 1–3 %. Round 5's design gives them their own warp running in parallel, and keeps the main loop branch-free. That is what turned the same 25 % byte saving into 13–20 % faster shapes.

Try it · lab3_compress.py — Lab 3: the lossless 12-bit weight format (pack_z + decz), emulated bit for bit in numpy.
cd ~/projects/okc/muse_bf16_proj/learn/labs
python3 lab3_compress.py
The file is already on your Mac at ~/projects/okc/muse_bf16_proj/learn/labs/lab3_compress.py; you can also view or download it here.

Part 3 of the lab is this kernel in miniature. It multiplies the decoded weights by x, which is what the streaming warps compute, and lets an emulated exception warp add its corrections. Then it compares against the original weights. It also runs a judge-style probe row on an exception column: decoded alone the answer is visibly wrong, and with the correction it is exact.

Exercise. Chapter 10's experiment A removes two ZTABLE entries, sending pooler at m = 1 and 8 back to the plain GEMV. Using the harness numbers in chapter 3.3 (17.12 vs 13.63 µs), predict the new score before you run it.

Check yourself

About how much memory does the exception table add for the pooler?

About 4,700 corrections spread over 4,096 rows, padded to capr slots per row (single digits in our samples). At 8 bytes per slot, that is a few hundred KB at most, against about 50 MB of packed weights. The table is read once per call by the exception warps, which costs almost nothing.

Why does the exception warp filter by klo ≤ k < khi?

With split-K (S > 1), every CTA of a row block sees the same exception list, but each CTA covers only its own slice of K. Without the filter, every correction would be added S times.

The final reduction uses threads tid < NB·32. Can the exception warp be one of them?

No. Those threads are the first NB warps. Because NW ≥ NB (the static_assert on line 283), the first NB warps are always streaming warps. The exception warp has the highest index, NW, so its only contribution is what it wrote into ecorr.