<- Back to all posts
From SMs to warps to tiles: a grid of SMs, one SM enlarged with its shared memory and warpgroups, and the Q, K, and S tiles of an attention block, joined by a gold route line.

GPU Kernel Tips: From Warps to Tile Sizes

2026-10-08 ·

These are things I picked up about GPU kernels while talking with other people, written up as a dialogue. It starts with the GPU execution model: coalescing and bank conflicts, SMs and warps, warpgroups, shared memory. Then it takes FlashAttention as the example and works out how big block_KV should be from a few public hardware numbers. Last come my own FlashFFN and the idea of extending it to MoE. Each topic ends with what you can ask next: the gold one continues the main line, the others dig into a detail. The map on the left shows the whole route.

Let me start with the basics. People keep saying memory access should be coalesced and free of bank conflicts. Are those the same thing?

No, but they ask the same question: when the 32 threads of a warp access memory together, how are their addresses laid out?

Why does it always come down to the warp?

Because a warp's 32 threads are the batch that issues memory requests together. That batch of addresses is what the hardware looks at.

Start with coalescing, then.

Coalescing is about global memory. When 32 threads read 128 consecutive bytes, the reads merge into one transfer. When the addresses are scattered, say each thread one row apart, they split into as many as 32 transfers.

And bank conflicts?

Those are about shared memory. It is split into 32 banks, each 4 bytes wide; the n-th 4-byte word lives in bank n % 32, and each bank can serve one address per cycle. If the 32 threads hit 32 different banks, the read finishes in one pass. If several threads hit different addresses in the same bank, they queue up, up to 32 times slower. If they all read the same address, that is a broadcast, which is fine.

global memory · 合并访问Global memory · coalesced 一个 warp:32 个线程One warp: 32 threads global memoryglobal memory 连续 128 字节 → 一次传输128 contiguous bytes → one transfer global memory · 地址分散Global memory · scattered 一个 warp:32 个线程One warp: 32 threads global memoryglobal memory 地址分散 → 最多 32 次传输Scattered → up to 32 transfers shared memory · 没有冲突Shared memory · no conflict 一个 warp:32 个线程One warp: 32 threads shared memory:32 个 bankshared memory: 32 banks 每个线程一个 bank → 一个周期One bank per thread → one cycle shared memory · bank conflictShared memory · bank conflict 一个 warp:32 个线程One warp: 32 threads shared memory:32 个 bankshared memory: 32 banks 都挤进 bank 0 → 排队 32 次All in bank 0 → 32 turns
All 32 threads of one warp read at once. On top, global memory: contiguous addresses merge into one transfer, scattered ones split into many. Below, shared memory: one bank per thread finishes in one cycle, while threads crowded into one bank have to queue.

So SM, warp, CTA, block, grid: what maps to what?

From the top down. A grid is every block one kernel launch starts. A block, also called a CTA (cooperative thread array), has up to 1024 threads; the two names mean the same thing. A warp is a group of 32 threads cut out of a block. The SM is the hardware, and blocks are what get placed on SMs to run.

Can one block be split across several SMs?

No. A block stays on one SM from start to finish. Think about what a block can do: its threads share one shared memory and can meet at __syncthreads(). Shared memory is on-chip storage inside an SM, and that barrier is implemented inside the SM too. Split the block across two SMs and neither would work.

Then how do all those blocks get handed out?

Whenever an SM has room, the scheduler gives it the next block, and when one finishes, another takes its place. If resources allow, one SM holds several blocks at once.

grid 一次 launch 的所有 blockevery block of one launch B0 B1 B2 B3 B4 B5 B6 B7 B8 B9 B10 B11 block 也叫 CTA,最多 1024 个线程a block is also called a CTA, up to 1024 threads 蓝色:已经分到 SM;灰色:排队等空位blue: placed on an SM; gray: waiting blockblock 调度器scheduler 哪个 SM 有空位,whichever SM has room 就塞一个 blockgets the next block SM 1 B1 B4 SM 2 B2 B5 SM 131 … SM 0 最多驻留 64 个 warpup to 64 resident warps shared memory · 寄存器:这个 SM 上的 block 分着用shared memory · registers: shared by the blocks here block B0 · 8 个 warp × 32 个线程block B0 · 8 warps × 32 threads w0 w1 w2 w3 w4 w5 w6 w7 block B3 也在这个 SM 上also on this SM 空位free slot warp 调度器warp schedulers 0 1 2 3 每个周期,4 个调度器各挑一个就绪的 warp 发指令Each cycle, each of the 4 schedulers issues one ready warp
The block scheduler hands the grid's blocks to SMs, one to whichever SM has room. An SM can hold several blocks at once, and they share its shared memory and registers. Each block is cut into warps of 32 threads, and the SM's four warp schedulers each issue one ready warp per cycle.

And how are warps cut? My block is two-dimensional.

By linear thread index: tx + ty * blockDim.x + …. A (32, 32) block is 32 warps, one per row ty. Grids and blocks are how you split up the work in code; the warp is the unit the hardware actually issues instructions for.

What if two blocks need to exchange data?

Only through global memory. That is why a reduction has each block write one partial sum, and the total takes either a second pass or an atomic.

You said each block writes one partial sum. How does a block add up its numbers in the first place?

With the two things we just talked about: the shared memory everyone in the block shares, and __syncthreads(). Each block adds its 256 numbers pairwise in its own shared memory and syncs after every round:

__global__ void block_sum(const float* x, float* out, int n) {
    __shared__ float s[256];                 // this block's copy, on its SM
    int tid = threadIdx.x;
    int i = blockIdx.x * blockDim.x + tid;
    s[tid] = (i < n) ? x[i] : 0.f;
    __syncthreads();                         // waits for this block only

    for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
        if (tid < stride) s[tid] += s[tid + stride];
        __syncthreads();
    }
    if (tid == 0) out[blockIdx.x] = s[0];    // one partial sum per block
}

Does __syncthreads() only wait for this block's threads?

Yes. It covers this block's 256 threads, that is, 8 warps. Other blocks sit on other SMs, add up their own numbers, and write their partial sums back to global memory.

Optimized reductions switch to shuffles for the last 32 elements. Why?

Once stride drops below 32, if (tid < stride) leaves part of one warp working and the rest idle. That is called warp divergence. By then everything left fits in one warp, so __shfl_down_sync passes values directly between that warp's registers, with no shared memory and no __syncthreads().

You said the warp is the unit that issues instructions. If a warp's 32 threads all run the same instruction, what happens when they take different sides of an if?

The warp runs both sides: first the if, with the other threads masked off and idle, then the else. You pay for both branches. Different loop trip counts count too; the whole warp waits for its slowest thread.

How do I avoid it?

Write short branches as cond ? a : b, which usually compiles to a select that every thread executes. For long ones, sort or group the data so the threads of one warp take the same path. Keep data-dependent branches out of hot loops; if one is unavoidable, compute both sides and select at the end.

Hopper also has warpgroups. Are they spread over different SMs, or inside one SM?

Inside one SM. A warpgroup is 4 consecutive warps in the same block, 128 threads, with the first warp index aligned to a multiple of 4; in code it is usually just threadIdx.x / 128. A block lives on one SM, so a warpgroup does too.

Where exactly inside that SM?

The SM is split into 4 partitions, each with its own warp scheduler and Tensor Core. A block's warps generally go to the 4 partitions round-robin by index, so 4 consecutive warps land one per partition: a warpgroup spans all 4 partitions of the SM.

一个 SM · 4 个分区One SM · 4 partitions 一个 block 的 12 个 warp = 3 个 warpgroupone block's 12 warps = 3 warpgroups WG0 producer WG1 consumer WG2 consumer 分区 0partition 0 warp 调度器warp scheduler w0 w4 w8 Tensor Core 分区 1partition 1 warp 调度器warp scheduler w1 w5 w9 Tensor Core 分区 2partition 2 warp 调度器warp scheduler w2 w6 w10 Tensor Core 分区 3partition 3 warp 调度器warp scheduler w3 w7 w11 Tensor Core
A block's 12 warps are spread over the 4 partitions. Each warpgroup is one row across: one warp from every partition, all inside the same SM.

Why invent warpgroups at all?

Because Hopper's Tensor Core instruction, wgmma, has to be issued by 4 warps together; one warp can't issue it alone. The picture above shows why: the 4 warps sit in 4 partitions, and wgmma has the 4 partitions' Tensor Cores compute one block of the matrix together. Warpgroups exist for wgmma.

How many warpgroups can a block have?

Several, each with its own job. One can be a producer that only moves data, and one or two others consumers that only run wgmma. They share the same shared memory, so they must be in the same block. More on the split later.

Warpgroups exist for wgmma. On Blackwell, with a new Tensor Core instruction, do we still need them?

Blackwell's MMA instruction is tcgen05, and a single thread can issue it. The accumulator also moves out of registers into dedicated Tensor Memory.

So warpgroups matter less?

Much less. You no longer need 4 warps lined up, the accumulator no longer takes registers, and the register pressure we'll get to later is much lighter on Blackwell.

Can an SM only run one warp at a time, with the others waiting in line?

Clearly not. Keep two numbers apart. Resident warps: up to 64 can live on an SM at once on Hopper, each holding its own registers. Issuing warps: the SM has 4 partitions with a scheduler each, so about 4 warps issue an instruction every cycle.

What are the other resident warps doing?

Waiting, for example on a memory request. When one warp stalls, its scheduler picks another ready warp on the next cycle. That is how a GPU hides latency.

Doesn't switching need a context save? Switching CPU threads is expensive.

Nothing is saved. A resident warp keeps its own slice of the register file the whole time, so switching just means issuing from a different warp. The cost shows up elsewhere: the register file has to hold every resident warp at once.

Then how many warps do I need?

Enough that some warp is always ready. Try it:

A simplified model: each warp computes for 3 cycles, then waits 12 cycles on memory. The bottom row shows whether the SM computes in each cycle. Real HBM latency is hundreds of cycles, so real kernels need many more warps.

So higher occupancy is always better?

Occupancy is the fraction of the warp limit that is resident. Its only job is hiding latency; once latency is hidden, more does nothing.

Are there really fast kernels with low occupancy?

Plenty on Hopper. Instead of many warps, they hide latency inside one block with asynchronous copies and asynchronous math. When we get to FlashAttention running one block per SM, this is exactly that case.

wgmma has to be issued by 4 warps together. Does that mean all 4 must reach the same point first, with some sync instruction inside the SM?

All 4 warps do have to reach the same wgmma, with uniform control flow. But you don't insert a __syncthreads(), and how the hardware coordinates the 4 warps isn't documented.

Then how expensive is that sync? Waiting for everyone on every instruction sounds slow.

The key is that wgmma is asynchronous. wgmma.mma_async returns as soon as it is issued; the warps keep going, and the results land in the accumulators later. What actually stops you is the step where you choose to wait for the results.

How do I wait?

Three instructions work together:

  • wgmma.fence: before issuing, makes earlier writes to registers and shared memory visible to wgmma.
  • wgmma.commit_group: bundles the wgmmas issued so far into a group.
  • wgmma.wait_group N: blocks until at most N groups are still in flight.

So the cost depends on when I wait.

Right. You must wait before reading the accumulators and before overwriting shared memory that still holds operands. Spend the time in between on other work, like waiting for the next tile or running softmax, and the cost is hidden. The worst thing you can do is wait right after issuing.

consumer WG Tensor Core fence mma_async ×4 commit other work做别的 wait_group read acc读结果 the warps keep going after issuing发出去之后,warp 继续往下走 running the 4 MMAs在算这 4 条 MMA time →时间 →
Issue, commit, go do something else, and only wait right before the results are needed.

An H100 SM has over 200 KB of shared memory. Why so much? Does it have to do with how the SM is built?

It does. It is each SM's own on-chip SRAM: 256 KB per SM, shared between L1 and shared memory. Up to 228 KB can be shared memory, and one block can use up to 227 KB.

Why give it so much?

To keep the Tensor Cores fed. The faster the Tensor Cores, the earlier data has to be brought in: tiles get bigger, and several buffers are kept so the next tile is already in place while the current one is computed. All of that lives in shared memory.

I once asked for 100 KB in a kernel and it failed outright.

A block gets 48 KB by default, and static __shared__ arrays can't go past that. For more, use dynamic shared memory (extern __shared__) and raise the limit before launch with cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, bytes).

Forget Tensor Cores for a moment. What is the most basic use of shared memory?

Reuse. The classic case is GEMM. In the naive version each thread computes one C[i][j]. How many times is each element of A read from HBM?

Once per column of C… so N times?

N times, and each element of B M times. The tiled version gives each block a TS×TS tile of C. It walks along K, loads the matching tiles of A and B into shared memory, and every thread reads from there. HBM traffic drops to 1/TS.

#define TS 32
__global__ void gemm(const float* A, const float* B, float* C, int M, int N, int K) {
    __shared__ float sA[TS][TS], sB[TS][TS];
    int tx = threadIdx.x, ty = threadIdx.y;
    int row = blockIdx.y * TS + ty, col = blockIdx.x * TS + tx;
    float acc = 0.f;
    for (int t = 0; t < K; t += TS) {
        sA[ty][tx] = (row < M && t + tx < K) ? A[row * K + t + tx] : 0.f;
        sB[ty][tx] = (t + ty < K && col < N) ? B[(t + ty) * N + col] : 0.f;
        __syncthreads();                 // the whole tile is loaded
        for (int k = 0; k < TS; ++k) acc += sA[ty][k] * sB[k][tx];
        __syncthreads();                 // everyone is done before it is overwritten
    }
    if (row < M && col < N) C[row * N + col] = acc;
}

Do I really need both __syncthreads()?

Yes. The first waits until the tile is loaded; without the second, the next iteration overwrites data other threads are still reading. Pad with zeros at the edges too, or any size that isn't a multiple of TS reads out of bounds. Another common mistake is swapping the indices: sA holds this block's rows × the current K slice, and sB holds the current K slice × this block's columns.

Back to coalescing and bank conflicts from the very start: does this code pass both?

Check it yourself. tx is contiguous within a warp, so the loads from A and B hit consecutive addresses: coalesced. In the inner loop, sA[ty][k] is the same address for the whole warp, a broadcast, and sB[k][tx] spans 32 different banks. No conflicts.

How far is this version from cuBLAS?

Very far.

Then what's the point of hand-writing a GEMM?

In production it's hard to beat cuBLAS, true. But every next step carries over to other kernels. Roughly by payoff: have each thread compute several outputs (say 4×4) and reuse values from registers; vectorized float4 loads; double buffering, prefetching the next tile while computing this one; and finally Tensor Cores.

On Hopper, that last step is the wgmma we talked about?

Yes, plus TMA to move data asynchronously. Take double buffering all the way and you get the multi-stage ring buffer FlashAttention uses.

Can I set in code how many blocks go on one SM?

Not directly. Four limits decide it, and the tightest one wins: shared memory, registers (65,536 per SM), warps (64), and blocks (32). Move the sliders:

Each block also reserves 1 KB of shared memory for the system, and registers are allocated per warp in chunks.

So I can only control it indirectly?

Right, by adjusting block size, shared memory use, and register use. To see the actual result, ask the runtime: cudaOccupancyMaxActiveBlocksPerMultiprocessor.

How many do you usually want?

It depends on the kernel. An ordinary kernel wants several, since more warps hide more latency. Something like FlashAttention is the opposite: one block takes most of the shared memory, so only one fits per SM.

That is what I can't get my head around. A FlashAttention block takes almost 200 KB of shared memory, so only one block fits per SM, with just one or two warpgroups computing. Didn't we say latency is hidden by having many warps? Doesn't this drop MFU, or HFU?

First, what is the bottleneck on Hopper?

The Tensor Cores, I suppose.

And each SM has a fixed number of them. If the consumers in this one block issue wgmma back to back, the Tensor Cores are already full; more blocks would only fight over the same units. MFU and HFU both measure whether the Tensor Cores are working. cutlass's Hopper GEMM runs one block per SM and still gets close to peak.

Then when does it drop?

When the Tensor Cores go idle, usually for one of three reasons: waiting for data, the consumer busy with something else, or prologue and epilogue overhead. Compare three designs:

Durations are schematic: a tile load takes 4 units, a GEMM 4, a softmax 3.

How do you stop waiting for data?

Make shared memory a multi-stage ring buffer, say 4 stages, so the producer loads several tiles ahead. That is where most of FA3's 200 KB goes.

And the consumer being busy?

In attention, softmax follows QKᵀ, and the Tensor Cores sit idle while it runs. FA3 has two consumer warpgroups take turns: while one runs softmax, the other runs its GEMM.

Hold on, what exactly is a Tensor Core? My understanding is that it's basically the systolic array in a TPU. Is that right?

Right direction, but not quite the same. They solve the same problem: in a matrix multiply the same number gets multiplied many times, so you build a dedicated multiply-add array where data is read in once and reused inside the array, instead of going back to the registers for every multiplication.

So what's the difference?

Start with scale. A TPU's matrix unit is one big systolic array, for example 128 × 128 multiply-add units, and a chip has only a few of them. A GPU spreads many small units over its SMs: each Hopper SM has 4 Tensor Cores, one per partition, 528 in an H100.

How big is one Tensor Core?

Work back from a number we'll get to later: each SM does 4096 FLOPs per cycle, that is, 2048 multiply-adds. Split over 4 Tensor Cores, that's 512 multiply-adds per Tensor Core per cycle. Much smaller than a TPU's whole array.

Is it systolic inside too, with data passed cell to cell?

NVIDIA hasn't published its internals. It is generally thought to be closer to a set of dot-product units: one instruction comes in and finishes the multiply-adds on a small matrix in one go, rather than streaming data through hundreds of stages. So rather than a scaled-down TPU systolic array, think of it as a fixed-shape small matrix multiplier in each partition.

How does that change the way I write kernels?

They are fed differently. A TPU's array is filled in whole blocks laid out by the compiler, and matrix dimensions are best rounded up to multiples of 128. A GPU's Tensor Cores are fed instruction by instruction by warps through mma / wgmma, and you move the data yourself from HBM into shared memory and on into the Tensor Cores. Working out N ≥ 64 later is working out whether that feeding path is fast enough.

So whether one block can keep these 4 Tensor Cores fed is what decides MFU.

Exactly, and that's the main line again.

When does prologue and epilogue overhead really hurt?

When the main loop is short. Each tile has to set up the pipeline at the start and write results back to HBM at the end; with a long loop along K that is negligible, with a short one it shows. MoE is the classic case: each expert gets few tokens, so its matrices are small, sometimes with fewer tiles than SMs.

What do you do about it?

Write the grouped GEMM as a persistent kernel: launch as many blocks as SMs, keep each block resident, and let it loop, pulling tiles from a scheduler. That saves the repeated launches and pipeline setups.

How does a block know which expert its tile belongs to?

The experts share K and N, but each has its own M. Take a prefix sum over their tile counts; whichever range a global tile index falls into names its expert, found with a binary search. This comes back when we extend FlashFFN to MoE at the end.

How do I tell which of the three kinds of idle my kernel has?

First see how far measured TFLOPS is from peak. Then open Nsight Compute: check tensor pipe utilization and what the warps are stalled on (the stall reasons). Stalling at a barrier while waiting for data looks completely different from stalling on other math.

So how exactly do producers and consumers work together? I'd guess consumer and producer warps are about 3:1?

Why 3:1?

One warp moves data, three warps compute.

Then tell me how three warps issue one wgmma together.

…They can't?

Right, the units are mixed up: the split is counted in warpgroups. A common setup is 1 producer warpgroup plus 2 consumer warpgroups, as in cutlass's Hopper GEMM and mostly in FA3's forward pass too. That's 12 warps and 384 threads, or 4:8 counted in warps.

block · 12 warps · 384 threadsblock · 12 个 warp · 384 个线程 WG0 · producer WG1 · consumer WG2 · consumer w0w1w2w3 1 thread issues every TMA只有 1 个线程在发 TMA w4w5w6w7 w8w9w10w11 warpgroup = 4 consecutive warps = 128 threadswarpgroup = 连续 4 个 warp = 128 个线程 B tile · read by bothB tile · 两个 consumer 共用 rows 0–63 · WG1 rows 64–127 · WG2 output tile: M = 2 × 64 = 128输出 tile:M = 2 × 64 = 128
Each consumer owns 64 rows of the output tile. Both read the same B tile from shared memory.

How do the two consumers split the work?

The wgmma shape is m64 × nN × k16 for half precision: M is fixed at 64, and N goes from 8 to 256. Each consumer owns 64 rows of the output tile, so together M is 128. Both read the same B from shared memory and keep their accumulators in their own registers.

What about the producer?

The producer uses TMA to copy data from HBM into the ring buffer in shared memory. TMA copies are asynchronous, and only one thread in the whole producer warpgroup actually issues them.

Then how do they know when data has arrived, or when a buffer is free?

Through barriers in shared memory (mbarriers). Each stage of the buffer has two. When the producer finishes loading a tile, the stage's "full" barrier completes and the consumers waiting on it go ahead; when the consumers are done with a stage, they arrive on its "empty" barrier, and only then can the producer overwrite it. Each side runs on its own and they only meet at the barriers.

Aren't the other 127 producer threads just sitting on registers?

Which is why cutlass and FA3 both start by rebalancing registers with setmaxnreg: the producer drops to a few dozen per thread, and the consumers take the freed budget and go above two hundred. Consumers keep their accumulators in registers, so that is exactly where the space is needed.

default默认 WG0 · 168WG1 · 168WG2 · 168 setmaxnreg 40WG1 · 232WG2 · 232 numbers are registers per thread数字是每线程寄存器数 register file: 65,536 per SM寄存器堆:每个 SM 65,536 个
Bar widths are to scale. A split cutlass commonly uses on Hopper: 40 for the producer, 232 for each consumer.

Next I want to work out something concrete: how big block_KV should be in FlashAttention. My setup is BLOCK_SEQ = 64, HEAD_DIM = 128, block_KV written as N for now, in bf16.

Lay out the shapes first. Take the first GEMM, S = Q·Kᵀ.

Q is 64 × 128, K is 128 × N, so S comes out 64 × N.

Where does each of the three live?

Q and K are too big for registers, so they sit in shared memory. S is the accumulator, so it lives in registers.

Right. S stays in registers and is never written back to shared memory, so it adds no memory traffic. What about the 128 in the middle?

It isn't multiplied in one go?

No. One wgmma has the shape m64 nN k16, so it takes only 16 along K at a time. The 128 is cut into 8 slices and accumulated into S over 8 iterations. So we only need to do the math for a single wgmma.

How much does one wgmma compute?

You count. A multiply-add is two operations.

2 × 64 × 16 × N = 2048N FLOPs.

Now turn that into time. What we want is how much one SM computes per cycle.

989.4 TFLOPS for the whole GPU, divided by Hopper's 1.83 GHz clock, is what the whole GPU does per cycle; divide by 132 SMs…

…and you get 4096 FLOPs, that is, 2048 multiply-adds per SM per cycle.

So one wgmma takes 2048N ÷ 4096 = 0.5N cycles.

per SM, per cycle (H100 SXM at 1.83 GHz)每个 SM、每个周期(H100 SXM,1.83 GHz) HBM3.35 TB/s 13.9 B balance 295平衡点 295 sharedmemory228 KB 128 B balance 32平衡点 32 Tensor Core 4096 FLOPs / cycle balance point = FLOPs each byte must buy so the Tensor Core never waits平衡点 = 每读 1 字节至少要换来的 FLOPs,Tensor Core 才不用等
Pipe widths are to scale: HBM feeds each SM about 9× less than shared memory does.

Where do 989.4 and 1.83 GHz come from?

989.4 TFLOPS is the dense bf16 peak of an H100 SXM (the sparse figure is twice that), 1830 MHz is its boost clock, and the SXM part has 132 SMs. Multiply back: 4096 × 132 × 1.83e9 ≈ 989.4e12.

Real runs don't hold the boost clock, though, do they?

Under full load it often runs lower. Compute per cycle stays the same, but HBM bandwidth doesn't follow the SM clock, so a slower clock actually gives each cycle a few more HBM bytes. The order of magnitude doesn't change.

Now memory. One wgmma reads 64 × 16 of A and 16 × N of B, 2 bytes each in bf16: (64×16 + 16×N) × 2 = 2048 + 32N bytes.

Where do those bytes come from, and at what bandwidth?

Q and K are in shared memory, and reading them into registers goes through the L1 path, so it's L1 bandwidth. Coalesced, 32 threads read 32 consecutive fp32 values, 128 bytes per cycle.

The number is right; let me tighten the wording. Shared memory and L1 are the same on-chip storage, and this isn't coalescing in the global-memory sense: shared memory has 32 banks, each 4 bytes wide, so without bank conflicts it delivers exactly 128 bytes per cycle.

So the memory time is (2048 + 32N) ÷ 128 = 16 + 0.25N cycles.

Right. Notice the 16: every wgmma re-reads A, a fixed cost.

Is 128 bytes per cycle really the number?

It assumes a warp reads all 32 banks in one cycle. The real bandwidth of the path wgmma reads its operands through isn't published, so the N we're about to get is an order-of-magnitude answer, not an exact one.

You said every wgmma re-reads A. Does A have to come from shared memory?

Not necessarily. wgmma can also take the A operand straight from registers, which takes those 16 cycles off shared memory. FA3 feeds P to its second GEMM (P·V) this way: after softmax, P is already in registers.

For the Tensor Cores never to wait on data, the compute time has to be at least the memory time.

Write it out.

0.5N ≥ 16 + 0.25N, so N ≥ 64.

Right:

compute time ≥ memory time
0.5 N ≥ 16 + 0.25 N
N ≥ 64

So your 64 sits exactly at break-even, with no margin at all. Drag N:

So a bigger N is always better?

Compute over memory is 1.00 at N = 64, 1.33 at 128, 1.60 at 256, and tops out at 2. wgmma pins M at 64, so the fixed 16 cycles get spread thinner as N grows, but never disappear.

Then I'll just take 256?

That is where N's lower bound comes from; its upper bound depends on registers. One level further up there's HBM. Both are follow-ups you can open.

You said N's upper bound depends on registers. What happens at N = 256?

Count the registers first. S is [64, N] in fp32, spread over a warpgroup's 128 threads. How many per thread?

64N / 128 = N/2, so 128 at N = 256.

Plus the output accumulator O, [64, 128] in fp32: another 64 per thread. That's 192 already, against a cap of 255, and P, the softmax statistics, and addresses still need room.

So that's why, back in part one, the consumers grab two hundred-plus registers with setmaxnreg.

Exactly. Shared memory sets N's lower bound; registers set its upper bound.

We still haven't used HBM's 3.35 TB/s. Same method one level up: moving data from HBM into shared memory?

A KV tile is K and V, each [N, 128] in bf16. You count.

Bytes: 2 × N × 128 × 2 = 512N. FLOPs: QKᵀ plus PV, 2 × (2 × 64 × N × 128) = 32768N, so 8N cycles. HBM gives each SM only 3.35 TB/s ÷ 1.83 GHz ÷ 132 ≈ 13.9 bytes per cycle, so 512N ÷ 13.9 ≈ 37N cycles. …N cancels out?

It does. At this level N doesn't matter; M does, the number of Q rows a block owns. Each byte of KV buys M FLOPs. The balance point is 989.4 ÷ 3.35 ≈ 295, so with M = 64 and all KV read from HBM, you're more than 4× short.

But FlashAttention is fast with M = 64 or 128. That doesn't add up.

Because not every byte really comes from HBM. Many Q blocks of the same head read the same KV, so later blocks mostly hit L2. In this model, M = 64 needs an L2 hit rate of about 78%. Here it is at 80%:

Then sparse attention must be in real trouble.

Why?

Each Q block gathers the KV it picked, and different blocks pick different KV, so L2 can't help.

Right. The effective arithmetic intensity falls back toward M, and the full memory-bound gap shows.

dense Q block 0Q block 1Q block 2 KV 0KV 1KV 2KV 3KV 4KV 5 every Q block reads the same KV → L2 hits每个 Q block 读同一批 KV → 命中 L2 sparse Q block 0Q block 1Q block 2 each Q block gathers its own KV → little reuse每个 Q block gather 自己的 KV → 几乎不复用

How do you rescue it?

Make M bigger by sharing the gather among more queries. For example, the query heads of one GQA group can share the same selected KV and be computed together, which multiplies M by the number of heads in the group.

During decode each request has a single query, so M = 1?

Close to it: one token, or the few query heads of a GQA group. Decode attention is memory-bound no matter what.

Then what's left to optimize?

The goal changes from Tensor Core utilization to saturating HBM bandwidth. For example, split the KV along the sequence so more SMs read it in parallel (split-KV).

Tell me about your FlashFFN paper. How did it start?

I wanted multiple heads in the FFN too. Attention has multiple heads, but the FFN has always had a single one: say hidden size 2048 and an intermediate size of 8192, all one big head.

Why not just split it into heads?

Split it into 16 heads of 128, and each head gets its own 8192-wide intermediate layer. The intermediate tensor goes from [L, 8192] to [L, 16, 8192], 16 times, or H times, the memory. And an intermediate that big has to be written to HBM and read back, so it gets slower too.

Two bottlenecks, then: exploding memory and slower compute.

论文 Figure 2:naive 多头 FFN 的显存随 head 数线性增长。
Paper Figure 2: a naive multi-head FFN's memory grows linearly with the number of heads.

Then use a flash kernel and never write the intermediate to HBM. Problem solved?

A flash kernel alone doesn't work either. A plain FFN's hidden size of 2048 is too big: one 64-row input tile already takes 64 × 2048 × 2 bytes = 256 KB, more than an SM's shared memory, and the output accumulator spans all 2048 dimensions, far too much for registers.

And after splitting into heads?

Each head is only 128 wide, the same as attention's head_dim. The input tile is 64 × 128 and the weights are tiled along the intermediate dimension, which maps straight onto FlashAttention: X plays Q, W1 plays K, W2 plays V, and the 8192 intermediate dimension plays the sequence length.

So each makes the other possible: heads make the flash kernel fit, and the flash kernel keeps heads from blowing up memory.

Exactly. The intermediate only ever exists in SRAM and is never written to HBM. In the paper, compared with a SwiGLU FFN, model quality is better, peak memory drops 3 to 5 times, and inference is about 1.05× faster on average, up to 1.08×.

论文 Figure 3:并行子网络与 SRAM 上的分块计算。
Paper Figure 3: parallel sub-networks and tiled computation in SRAM.

Why is the speedup so small?

Because a plain FFN's intermediate is only [L, 8192]: its round trip was never that expensive, and cuBLAS is already close to peak. The more accurate claim is that the flash kernel brings a multi-head FFN's cost back down to a plain FFN's while keeping the benefits of heads.

Do the N and M from the previous part apply to your kernel?

Directly. The tile along the intermediate dimension is N, set by shared memory bandwidth; the number of tokens a block owns is M, and with the weights playing the role of KV, it is set by HBM bandwidth. And since SwiGLU is element-wise, there's no online-softmax rescaling, so it's even simpler than attention.

Can this idea carry over to MoE?

In a regular MoE, each token X is routed to a few experts. The simplest split is to cut X into heads X1, X2, and so on, each with its own set of experts and its own routing.

So each head's experts are isolated.

Right. That just bolts heads onto MoE without getting the real benefit of heads.

The next step is to share experts across heads: X1's hidden state can also be routed to X2's experts. That gets the most out of having multiple heads.

Sounds reasonable for the model. Have you thought about the cost in the kernel?

What cost?

Routing moves from per token to per (token, head), so the rows each expert receives are cut into 1/16 of what they were. Matrices get smaller, M can hardly reach wgmma's 64, the main loop is short, and prologue and epilogue overhead takes a large share: exactly the kind of idle we talked about earlier.

Then it needs a persistent grouped GEMM.

Yes, and the model-side gains have to be weighed against that cost.

Draft

The raw notes this post was written from, unedited.

BLOCK_SEQ = 64
HEAD_DIM = 128
BLOCK_KV =  64
bf16

TFLOPS = 989.4 * 10^12
Hopper freq = 1830 *10^6 hz

TFLOPS / HFreq = 540672. per SM per cyle = per cycle / SM number = 540672 / 132 = 4096

HBM Speed = 3.35 TB/s
L1 per cycle Memory bandwitdh = 128 Bytes

per SM per cycle -> 4096 FLOPS

Matrix Q = [64, 128]    # 128 loop for 8 times # share memory
Matrix K = [128, MMA_N] # share memory

Store Matrix S = [64, N] # register


MMA FLOPS = 2 * 64 * 16 * MMA_N 
COMPUTE_TIME = MMAFLOPS / per SM per cycle = 0.5 N # per SM cycle
Memory Bytes SM->REG: (64*16 + 16 * N) * 2 = 2048 + 32 N
MEMORY_TIME = MemoryBytesSM2REG / L1 per cycle = 16 + 0.25 N # per SM cycle

COMPUTE_TIME >= MEMORY_TIME -> N >= 64



# hidden = 2048
X [L, 128, 16]
W1 [128, 16, 8192]

X * W1 = [L, 16, 8192] # reminder: 原本的FFN的中间tensor: [L,8192]

Flash FFN 
X * W1 消失了->所有的计算全部放在SRAM上 这个X.W1被分tile 并且不落回HBM 显存开销显著降低 # intermediate memory: 0
# intermediate HBM roundtrip: 0 -> inference speed提升

FlashFFN架构:1. 多头FFN 不收到多头带来的显存爆炸问题 并且获得更好的模型性能 2. Flash的算子 降低显存开销的同时提升速度
software hardware codesign



1. naive MoE 
original MoE
X [L,2048] W1 [expert, 2048, 8192] W2 [expert, 8192, 2048]

Flash MoE 
X [L, 16, 128] W1 [expert, 128, 16, 8192]

x -> expert1 expert2 expert3


2. futher提高多头带来的收益
X1 -- expert11 expert12 expert13

X2 -- expert21 expert22 expert23

X1 -> expert11 expert12 expert13 expert21 expert22 expert23