6 · The GEMV kernel, line by line (gemv_kernel)

This chapter reads lines 41–244: the plain BF16 tensor-core GEMV, its launch helper, and its Python entry point. The kernel runs the router at m ≤ 32 and the unpooler slice at m ≤ 8. It is also the skeleton of the compressed kernel in chapter 8, so understanding it here pays off twice.

ON THIS PAGE

The plan in one picture.

6.1 Includes and two inline-assembly helpers

submission.py, lines 42–48
42
43
44
45
46
47
48
#include <cuda_bf16.h>
#include <cuda_runtime.h>
#include <c10/cuda/CUDAStream.h>
#include <torch/extension.h>
#include <stdint.h>

typedef __nv_bfloat16 bf16;
submission.py, lines 50–55 · ld_stream: the streaming 16-byte load
50
51
52
53
54
55
__device__ __forceinline__ uint4 ld_stream(const uint4* p) {
  uint4 r;
  asm volatile("ld.global.nc.L1::no_allocate.v4.u32 {%0,%1,%2,%3}, [%4];"
               : "=r"(r.x), "=r"(r.y), "=r"(r.z), "=r"(r.w) : "l"(p));
  return r;
}

Why not simply __ldg(p)? That also uses the read-only path, but may keep the line in L1. Weight bytes are used exactly once, so we tell the hardware not to cache them. L1 then stays free for x, which is reused.

submission.py, lines 56–60 · mma16816: one tensor-core instruction
56
57
58
59
60
__device__ __forceinline__ void mma16816(float (&d)[4], uint4 a, uint32_t b0, uint32_t b1) {
  asm volatile("mma.sync.aligned.m16n8k16.row.col.f32.bf16.bf16.f32 {%0,%1,%2,%3}, {%4,%5,%6,%7}, {%8,%9}, {%0,%1,%2,%3};"
               : "+f"(d[0]), "+f"(d[1]), "+f"(d[2]), "+f"(d[3])
               : "r"(a.x), "r"(a.y), "r"(a.z), "r"(a.w), "r"(b0), "r"(b1));
}

6.2 The kernel's signature

submission.py, lines 63–70
63
64
65
66
67
68
69
70
// One CTA = one 16-row block x a K-range; its NW warps split that range, reduced through smem.
// S > 1 additionally splits K across CTAs (semaphore-fused reduction by the last CTA).
// PIPE: 0/3 = load a batch, then compute it (3 also sets minBlocks=1 for the register allocator); 1/2 = software-pipelined (next batch's loads issued before this batch is
// computed); 2 also caps registers so two CTAs fit per SM.
template <int NW, int U, int NB, bool DUAL, int PIPE>
__global__ void __launch_bounds__(NW * 32, (PIPE == 2 ? 2 : PIPE == 3 ? 1 : 0))
gemv_kernel(const bf16* __restrict__ x, const uint4* __restrict__ w, const uint4* __restrict__ w2,
            void* __restrict__ out, float4* __restrict__ ws, int* __restrict__ sem, int M, int N, int K, int S) {

6.3 Who does what

submission.py, lines 71–78
71
72
73
74
75
76
77
78
  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 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;
submission.py, lines 79–87
79
80
81
82
83
84
85
86
87
  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;
  }
  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;

6.4 The main loop, version 1: load a batch, then compute it (PIPE 0 and 3)

submission.py, lines 89–114
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
  if constexpr (PIPE == 0 || PIPE == 3) {
    for (int i = 0; i < KBW; i += U) {
      uint4 a[U][2], b[DUAL ? U : 1][2];
#pragma unroll
      for (int u = 0; u < U; u++) {
        a[u][0] = ld_stream(wp + (size_t)(i + u) * 64);
        a[u][1] = ld_stream(wp + (size_t)(i + u) * 64 + 32);
        if (DUAL) {
          b[u][0] = ld_stream(wp2 + (size_t)(i + u) * 64);
          b[u][1] = ld_stream(wp2 + (size_t)(i + u) * 64 + 32);
        }
      }
#pragma unroll
      for (int u = 0; u < U; u++) {
#pragma unroll
        for (int nb = 0; nb < NB; nb++) {
          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);
          if (DUAL) {
            mma16816(acc[nb], b[u][0], xv.x, xv.y);
            mma16816(acc[nb], b[u][1], xv.z, xv.w);
          }
        }
      }
    }

The first mma waits until its weights arrive (about a microsecond). By then the other loads of the batch have arrived too, so the rest of the batch runs without waiting. The weakness is visible too: the next batch's loads only start after this batch's math is done, which leaves a bubble every trip.

6.5 The main loop, version 2: software pipelining (PIPE 1 and 2)

submission.py, lines 115–162
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
  } else {
    uint4 a[U][2], b[DUAL ? U : 1][2];
#pragma unroll
    for (int u = 0; u < U; u++) {
      a[u][0] = ld_stream(wp + (size_t)u * 64);
      a[u][1] = ld_stream(wp + (size_t)u * 64 + 32);
      if (DUAL) {
        b[u][0] = ld_stream(wp2 + (size_t)u * 64);
        b[u][1] = ld_stream(wp2 + (size_t)u * 64 + 32);
      }
    }
    for (int i = 0; i < KBW; i += U) {
      uint4 xv[U][NB];
#pragma unroll
      for (int u = 0; u < U; u++)
#pragma unroll
        for (int nb = 0; nb < NB; nb++) xv[u][nb] = __ldg((const uint4*)(xr[nb] + (i + u) * 32));
      uint4 na[U][2], nbb[DUAL ? U : 1][2];
      if (i + U < KBW) {
#pragma unroll
        for (int u = 0; u < U; u++) {
          na[u][0] = ld_stream(wp + (size_t)(i + U + u) * 64);
          na[u][1] = ld_stream(wp + (size_t)(i + U + u) * 64 + 32);
          if (DUAL) {
            nbb[u][0] = ld_stream(wp2 + (size_t)(i + U + u) * 64);
            nbb[u][1] = ld_stream(wp2 + (size_t)(i + U + u) * 64 + 32);
          }
        }
      }
#pragma unroll
      for (int u = 0; u < U; u++) {
#pragma unroll
        for (int nb = 0; nb < NB; nb++) {
          mma16816(acc[nb], a[u][0], xv[u][nb].x, xv[u][nb].y);
          mma16816(acc[nb], a[u][1], xv[u][nb].z, xv[u][nb].w);
          if (DUAL) {
            mma16816(acc[nb], b[u][0], xv[u][nb].x, xv[u][nb].y);
            mma16816(acc[nb], b[u][1], xv[u][nb].z, xv[u][nb].w);
          }
        }
      }
#pragma unroll
      for (int u = 0; u < U; u++) {
        a[u][0] = na[u][0]; a[u][1] = na[u][1];
        if (DUAL) { b[u][0] = nbb[u][0]; b[u][1] = nbb[u][1]; }
      }
    }
  }

Now, while the tensor cores chew on batch i, the loads of batch i+1 are already travelling. In round 3 we put timestamps inside the kernel and found that about 2 µs of qkv_gate's gap to a pure memory-read kernel was the bubble that version 1 leaves. PIPE 2 combines this loop with "2 CTAs per SM".

Which version wins depends on the shape. The tuner tried all four for every shape, and the tables record the winner.

6.6 Adding up the warps

submission.py, lines 164–177
164
165
166
167
168
169
170
171
172
173
174
175
176
177
  __shared__ float4 red[NW][NB * 32];
  __shared__ int s_last;
#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;
  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;
    }
  }

6.7 Adding up CTAs: split-K in the same launch

submission.py, lines 178–194
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
  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;
  }

Pitfall. Drop either fence and the code still passes most tests, then fails once in a while: the last CTA can see the counter move before it sees another CTA's partial. Drop the reset on line 193 and the second call never finds a "last" CTA, so the output is never written. Lab 5 simulates both protocols and lets you remove the reset.

6.8 Storing the result

submission.py, lines 195–208
195
196
197
198
199
200
201
202
203
204
205
206
207
208
  if (active) {
    const int nb = tid >> 5;
    float rr[4] = {r.x, r.y, r.z, r.w};
#pragma unroll
    for (int q = 0; q < 4; q++) {
      int tok = nb * 8 + 2 * t + (q & 1);
      int row = rb * 16 + g + ((q >> 1) << 3);
      if (tok < M) {
        if (DUAL) ((float*)out)[(size_t)tok * N + row] = rr[q];
        else ((bf16*)out)[(size_t)tok * N + row] = __float2bfloat16_rn(rr[q]);
      }
    }
  }
}

6.9 Launching from C++, and the instantiation list

submission.py, lines 210–217 · launch(): runs on the CPU
210
211
212
213
214
215
216
217
template <int NW, int U, int NB, bool DUAL, int PIPE>
static void launch(torch::Tensor x, torch::Tensor w, torch::Tensor w2, torch::Tensor out, torch::Tensor ws,
                   torch::Tensor sem, int N, int K, int S) {
  dim3 grid(N / 16, S);
  gemv_kernel<NW, U, NB, DUAL, PIPE><<<grid, NW * 32, 0, c10::cuda::getCurrentCUDAStream()>>>(
      (const bf16*)x.data_ptr(), (const uint4*)w.data_ptr(), DUAL ? (const uint4*)w2.data_ptr() : nullptr,
      out.data_ptr(), (float4*)ws.data_ptr(), (int*)sem.data_ptr(), (int)x.size(0), N, K, S);
}
submission.py, lines 220–244 · gemv(): what Python calls
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
#define C3(NW_, U_, NB_, D_, P_) if (NW == NW_ && U == U_ && NB == NB_ && dual == D_ && P == P_) { launch<NW_, U_, NB_, D_, P_>(x, w, w2, out, ws, sem, N, K, S); return; }
void gemv(torch::Tensor x, torch::Tensor w, torch::Tensor w2, torch::Tensor out, torch::Tensor ws, torch::Tensor sem,
          int64_t N, int64_t K, int64_t NW, int64_t S, int64_t U, int64_t NB, bool dual, int64_t P) {
  C3(4, 2, 1, false, 0)
  C3(4, 2, 1, false, 3)
  C3(4, 2, 1, true, 2)
  C3(4, 2, 2, false, 0)
  C3(4, 2, 4, false, 0)
  C3(4, 4, 1, false, 2)
  C3(8, 2, 1, false, 3)
  C3(8, 2, 1, true, 0)
  C3(8, 2, 2, false, 0)
  C3(8, 2, 2, true, 0)
  C3(8, 2, 2, true, 3)
  C3(8, 2, 4, true, 0)
  C3(8, 4, 1, false, 2)
  C3(16, 2, 1, false, 2)
  C3(16, 2, 1, false, 3)
  C3(16, 2, 4, true, 3)
  C3(16, 4, 1, true, 3)
  C3(16, 8, 1, false, 3)
  C3(16, 8, 2, false, 0)
  C3(16, 8, 4, false, 0)
  TORCH_CHECK(false, "unsupported gemv config");
}
Try it · lab5_splitk.py — Lab 5: split-K with a "last CTA reduces" semaphore, and the atomic variant, simulated.
cd ~/projects/okc/muse_bf16_proj/learn/labs
python3 lab5_splitk.py
The file is already on your Mac at ~/projects/okc/muse_bf16_proj/learn/labs/lab5_splitk.py; you can also view or download it here.

Exercises.

  1. In the lab, set RESET = False and watch replay 1 lose its output.
  2. Router at m = 1 uses CTABLE[('router', 1)] = (16, 4, 4, 3). With lab 0, work out its grid (64 CTAs). How many bytes does each lane have in flight right after the load phase? (4 blocks × 4 loads × 16 B = 256 B.)
  3. Chapter 10's experiment B sets S = 1 for that shape on the real H200: 16 CTAs instead of 64. Predict the effect before you run it.

Check yourself

Why does the router at m = 1 split K four ways (S = 4) when that adds a reduction step?

256 rows give only 16 row blocks, so 16 CTAs for 132 SMs. Most of the GPU would sit idle, and each busy SM can only pull data in at a limited rate. S = 4 gives 64 CTAs. The reduction costs a little, but four times as many SMs streaming weights wins easily. The tuner found S = 4 best for m = 1, and S = 16 for m = 8 and 16.

Why does the last CTA re-read its own partial from ws instead of using the copy in its registers?

The loop treats all S partials the same way and adds them in a fixed order. That keeps the code simple and the result bit-for-bit reproducible, whichever CTA comes last. The extra 16 bytes per thread are negligible.

What exactly makes the router "fused"?

Lines 108–111 and 150–153: the lo fragments go through the same mma with the same x registers into the same accumulators as the hi fragments. One kernel reads both matrices once and writes one FP32 output. SGLang instead runs two GEMMs (or an FP32 GEMM at ≤ 8 tokens) plus an add.