reasoning
Let me analyze this problem:
1. We need to implement a matrix multiplication C = A * B where:
- M = 256
- N = 256
- K = 131072 * 4 = 524288
2. This is a GEMM operation with relatively small M and N but very large K.
3. On an H100, cuBLAS/hipBLAS should already be quite optimized, but the standard torch.matmul might not choose the optimal algorithm for this specific problem shape.
Let me think about the characteristics:
- M = 256, N = 256 are relatively small
- K = 524288 is very large
This means we have a tall-and-skinny GEMM. For small MN and large K, the bottleneck is likely memory bandwidth since we need to read A (256 * 524288) elements and B (524288 * 256) elements. Each element is 4 bytes in fp32.
Memory needed:
- A: 256 * 524288 * 4 = 536,870,912 bytes = 512 MB
- B: 524288 * 256 * 4 = 536,870,912 bytes = 512 MB
- C: 256 * 256 * 4 = 262,144 bytes = 256 KB
For standard GEMM, we're bandwidth bound. Fused multiply-add operations will be done while streaming data from memory.
Actually, for H100, cuBLAS should handle this extremely well. But maybe we can do better with a custom kernel that uses persistent thread blocks, good shared memory tiling, and perhaps vectorized loads.
Wait, let me think more carefully. M = N = 256, K = 524288.
For a standard tiled GEMM:
- Each output element requires K multiply-adds
- Total FLOPs = 2 * M * N * K = 2 * 256 * 256 * 524288 ≈ 68.7 GFLOPs
- Total memory read = M*K + N*K ≈ 1 GB
- Arithmetic intensity = 68.7 GFLOPs / 1 GB ≈ 68.7 FLOPs/byte
With H100 memory bandwidth of 3.35 TB/s, the theoretical peak is about 3.35 TB/s * 68.7 FLOPs/byte ≈ 230 TFLOPs. But the H100 peak FP32 TFLOPS is only 67 TFLOPs. So this is actually COMPUTE BOUND, not memory bandwidth bound.
Wait, I made an error. Let me recalculate:
Total elements of A = 256 * 524288 = 134,217,728
Total elements of B = 524288 * 256 = 134,217,728
If we read each element once: 268,435,456 * 4 bytes = 1,073,741,824 bytes ≈ 1 GB
If we compute bound to 67 TFLOPS:
Time = 68.7 GFLOPs / 67 TFLOPs = ~1 ms
Memory bandwidth = 1 GB / 1ms = 1 TB/s
So we need 1 TB/s which is well within the 3.35 TB/s. But actually in practice, for GEMM, data access is not perfectly sequential and caches matter.
Actually, wait. Let me recalculate. For GEMM with small M and N:
- A needs to be read N times (or portions of it) if we compute one row of C at a time
- B needs to be read M times (or portions of it) if we compute one column of C at a time
With a good tiled approach, each element of A is reused across N output elements and each element of B is reused across M output elements.
In a coarsely tiled approach:
- If we load a tile of A of size M x BK and a tile of B of size BK x N
- We can reuse A tile N/BN? Wait, let me be more careful.
For C (MxN) = A (MxK) * B (KxN):
If we split K into tiles of size BK:
For each BK tile:
- Load M x BK from A
- Load BK x N from B
- Compute partial products
Total A read = M * K (each element read once) if we stream through
Total B read = N * K (each element read once) if we stream through
Actually in a tiled approach, each element of A is read once and each element of B is read once (if we have enough accumulators to compute all of C). Wait, that's not right either.
For GEMM, in the most cache-efficient implementation:
- Each element of A (M x K) is loaded once and used to compute N partial products
- Each element of B (K x N) is loaded once and used to compute M partial products
So the total loaded data = M*K + N*K = K*(M+N) = 524288 * 512 ≈ 268M elements ≈ 1 GB
With perfect caching and no temporaries written to memory, this is compute-bound.
Now, for this specific shape on H100, let me think about what kernel would be optimal.
M=N=256 means the output C is only 256*256 = 65536 elements. With 132 SMs on H100, we'd want each thread to compute multiple elements.
For a large K dimension, we want to:
1. Use vectorized loads (float4) for coalesced memory access
2. Keep accumulators in registers
3. Load chunks of K and do fused multiply-add in registers
4. Unroll the K loop aggressively
Actually, since M and N are both small (256), we could even have each thread compute one or more output elements and stream through K.
Let me think about a simple but effective approach:
- Use a 2D thread block of 16x16 = 256 threads
- Have a grid of 16x16 = 256 blocks to cover 256x256 output
- Wait, that's actually overkill. Let me reconsider.
Actually, 256x256 output elements. If each thread computes one element, we need 65536 threads. That's a lot.
Better approach:
- Each block computes a tile of C, say 64x64 elements
- Grid = 4x4 = 16 blocks
- Each block has 256 threads
- Each thread computes (64*64)/256 = 16 elements
But wait, threads in a block can collaborate to load tiles of A and B into shared memory.
For large K, let me think of a different strategy:
- Since M and N are small but K is large, we can use 1D blocks where each block handles one row of C (256 elements), and we have 256 blocks.
- Or each block handles multiple rows.
Actually, since M=N=256, we can have:
- 16x16 thread blocks
- Each block computes one output element? No that's wasteful.
Let me think about warp-level matrix multiply.
Since this is an H100, we might want to use wmma/mma instructions for tensor cores. But given the K dimension is large, even with simple vectorized FMAs we might be close to peak.
Actually, let me consider if cuBLAS already handles this optimally. For small M and N with large K, cuBLAS might not use tensor cores because the MN tile is too small to amortize the tensor core overhead. But actually 256x256 should be large enough.
Hmm, let me think about a custom kernel approach. For this specific problem:
Option 1: Simple streaming kernel
- Each thread computes one or more elements of C
- Stream through K dimension in chunks
- Use float4 for vectorized loads
Option 2: Shared memory tiling
- Each block computes a tile of C
- Load tiles of A and B into shared memory
- This is more classic
For K being very large, the overhead of shared memory might not be worth it since we can just stream through global memory with good vectorized loads.
Actually, let me compute more carefully:
- M * N = 65536 output elements
- Each output element needs K = 524288 FMAs
- Total FMAs = 65536 * 524288 ≈ 34.4 billion FMAs = 68.7 GFLOPs
Wait, 65536 * 524288 = 34,359,738,368. Times 2 (multiply + add) = 68.7 billion operations = 68.7 GFLOPs.
Hmm, that's actually not that much. At 67 TFLOPS (FP32 peak), this would take about 1 ms if we were compute bound. But actually I think modern GEMM implementations on H100 should handle this well.
Let me think about what cuBLAS might do. For M=256, N=256, K=524288, this is a batched-like GEMM where each inner dimension is long but the outer dimensions are small. cuBLAS might use algorithms that are not perfectly tuned for this exact shape.
For a custom kernel, a good approach would be:
1. Divide C into tiles, each block handles a tile
2. Use shared memory for A and B tiles
3. Stream through K
4. Each thread accumulates multiple elements of C
Let me design a kernel:
Grid: (M/TileM, N/TileN) blocks
Block: ThreadsPerBlock threads
Let's say TileM = 64, TileN = 64
Then grid = (4, 4) = 16 blocks
With 256 threads per block, each thread handles (64*64)/256 = 16 elements
For K dimension, we iterate in chunks of BK (e.g., BK=8 or 16 for float4)
Wait, if we use float4, we can load 4 floats at a time.
Actually, let me think about how to write this kernel efficiently:
```cuda
__global__ void matmul_kernel(const float* __restrict__ A, const float* __restrict__ B, float* __restrict__ C, int M, int N, int K) {
// Each block computes a tile of C
int tileM = 64;
int tileN = 64;
int bk = 8;
int blockRow = blockIdx.y;
int blockCol = blockIdx.x;
int rowStart = blockRow * tileM;
int colStart = blockCol * tileN;
// Each thread handles multiple elements
int threadIdxInBlock = threadIdx.y * blockDim.x + threadIdx.x;
int numThreads = blockDim.x * blockDim.y;
float accum[REG_TILE_M][REG_TILE_N] = {0}; // in registers
__shared__ float As[tileM][bk];
__shared__ float Bs[bk][tileN];
for (int k = 0; k < K; k += bk) {
// Load A and B tiles into shared memory
// Each thread loads multiple elements
// Then compute using shared memory
for (int kk = 0; kk < bk; kk++) {
for (int i = 0; i < REG_TILE_M; i++) {
for (int j = 0; j < REG_TILE_N; j++) {
accum[i][j] += As[...][kk] * Bs[kk][...];
}
}
}
}
// Write results
}
```
Wait, I need to think more carefully about the layout and thread mapping.
Actually, let me think about an even simpler approach: since M and N are only 256, what if we just have each warp compute a subset of output elements and stream K from memory?
With 32 threads per warp:
- Warp 0 computes C[0:8, 0:32] (8*32=256 elements)
- Each thread computes 8 elements in the same column, different rows
- Thread t computes C[0:8, t]
For K, each thread:
1. Loads A[rowStart:rowStart+8, k] - wait, that's 8 floats per iteration, not great for vectorization
Alternative:
- Each thread computes 8x1 elements (8 rows, 1 column)
- 32 threads per warp compute 32 columns
- With 8 warps = 256 threads, we can handle 32*8=256 rows and 32 columns
Actually, with M=256, we could have:
- 32 warps per block (1024 threads, but let's use 256 threads = 8 warps)
- Or 1 warp per block and 256 blocks
Let me reconsider. Since M and N are equal, maybe a simple 2D block where each thread computes multiple elements:
Block size: 16x16 = 256 threads
Grid: 16x16 = 256 blocks? No, C is only 256x256.
Grid: (N/16, M/16) = (16, 16) blocks, each computing 16x16 elements.
Wait, 16*16 = 256 blocks, each with 256 threads computing 16*16 = 256 elements. That's 1 element per thread.
Hmm, but then each thread would do K FMAs = 524288 FMAs. At 67 TFLOPS, this is fine. But can we do better with fewer blocks and more work per thread?
Actually, let me think about memory coalescing.
- A is row-major (M x K), so threads in a block reading the same row but different K would NOT be coalesced well if they're accessing different K values.
- Wait, if we tile K, then in the inner loop, different threads in the same block might read different rows of A but the same K slice.
Let me think about a more optimal memory access pattern:
- For the K dimension, we iterate with stride BK
- Threads in a warp should read consecutive memory for coalescing
If block is 16x16:
- Thread (tx, ty) computes C[by*16 + ty, bx*16 + tx]
- In K loop, for a given k:
- Thread (tx, ty) reads A[by*16 + ty, k]
- Thread (tx, ty) reads B[k, bx*16 + tx]
For fixed ty and varying tx, A accesses are the same (A[by*16+ty, k]), so it's broadcast within the warp - good.
For fixed tx and varying ty, B accesses are the same (B[k, bx*16+tx]), also broadcast - good.
But loading into shared memory for each BK slice:
- We can have each thread load a few elements of the A tile and B tile
- Then all threads read from shared memory
This is the standard GEMM approach.
Let me try a specific configuration:
- TileM = 128, TileN = 128
- BK = 8
- Threads per block = 256
Grid = (256/128, 256/128) = (2, 2) = 4 blocks
Each block computes 128*128 = 16384 elements with 256 threads, so each thread computes 64 elements.
We can arrange the 64 elements as 8x8 tile per thread.
Or 4x16, or 2x32, etc.
For the K loop (step BK=8):
1. Load A tile: 128 x 8 = 1024 floats
2. Load B tile: 8 x 128 = 1024 floats
3. Total shared mem per tile = 2048 floats = 8KB
4. Compute: 128*128*8 = 131072 FMAs per block per K iteration
With 524288/8 = 65536 K iterations, total FMAs = 4 * 65536 * 131072 = ... let me check: 4 blocks * 65536 iterations * 131072 FMAs = 34.4 billion FMAs. ✓
Each thread does 131072 / 256 = 512 FMAs per K iteration
Total per thread = 65536 * 512 = 33.5 million FMAs
Hmm, that's a lot of instructions but quite manageable.
Actually wait, I want to be more efficient. Let me try using float4.
With float4, we can load 4 floats at once.
- A tile: 128 x 8 floats = 256 float4s
- B tile: 8 x 128 floats = 256 float4s
- 256 threads, so each thread loads 1 float4 from A and 1 float4 from B
For A: thread t loads A[row, col:col+4] where col is 4 consecutive
For B: thread t loads B[row:row+4, col] ... wait, B is K x N column-major? No, B is K x N row-major.
Actually let me use a layout-aware approach.
Let's say A is M x K (row-major):
- A[i][k] = A[i * K + k]
B is K x N (row-major):
- B[k][j] = B[k * N + j]
To read B efficiently with float4, we want consecutive threads reading consecutive j values.
B[k][j:j+4] = B[k*N + j : k*N + j + 4]
For A, consecutive threads reading the same k but different i would read non-consecutive memory (stride K). So for vectorized A loads, we'd want each thread to read consecutive k values.
Actually, let me reconsider the tiling.
Row-major A (M x K):
- If we tile K with BK=8, and threads cooperatively load A[0:TileM][k:k+BK]
- Thread tid loads A[tid / something][...]
If threads in a warp read consecutive rows of A for the same k, they are NOT coalesced (stride K).
If threads in a warp read the same row but consecutive k, they ARE coalesced.
So for A, we should have consecutive threads load consecutive elements in the K dimension.
For B (K x N):
- Threads reading consecutive elements in N dimension for the same K are coalesced.
So the optimal loading pattern:
- For A: threads load across K dimension (coalesced)
- For B: threads load across N dimension (coalesced)
In a block computing C[TileM_start:TileM_start+TileM][TileN_start:TileN_start+TileN]:
- A needs to be loaded as TileM x BK
- B needs to be loaded as BK x TileN
For A loading (TileM x BK):
- If we map threads to read A in row-major order
- Thread t reads the t-th element in row-major order of the tile
- This means consecutive threads might read consecutive K elements within the same row (good for some, bad when crossing rows)
Actually, let's think about coalescing more carefully.
A row-major MxK:
- Address of A[m][k] = base + m * K + k
- Threads in warp loading A[m][k + tid] are coalesced (consecutive addresses)
- Threads loading A[m + tid][k] are strided by K, not coalesced
B row-major KxN:
- Address of B[k][n] = base + k * N + n
- Threads loading B[k][n + tid] are coalesced
For our tile:
A tile: TileM rows x BK cols
- If we have 256 threads and BK=8, TileM=128, we have 1024 elements
- Each thread loads 4 elements (using float4)
- We can arrange so threads load A[0:4][0:8], A[4:8][0:8], etc.
- Better: have thread (tx, ty) in a 16x16 block load A[ty * TileM/16 : (ty+1) * TileM/16][tx * BK/16 : (tx+1) * BK/16] ... wait this gets complicated.
Actually, let me use a simpler approach. Let me use the CUTLASS-style layout but simpler.
Alternative: vectorized stream kernel with no shared memory.
Since K is very large, maybe we don't even need shared memory. We can just stream A and B through registers.
Grid: (M, 1) blocks or something...
Wait, let me think about a different structure:
- Have 256 thread blocks, one per row of C
- Each block has 256 threads, one per column of C
- Each thread computes one element of C: dot product of row of A and column of B
For this to be efficient:
- Thread j in block i computes C[i][j]
- In the K loop, at step k:
- Thread j loads A[i][k] - all threads in block load the same value! This is broadcast, not good utilization.
- Thread j loads B[k][j] - consecutive j load consecutive memory, coalesced!
Hmm, but A[i][k] being broadcast to all 256 threads in a block means only 1 float is loaded per memory transaction for A. That's terrible bandwidth.
To improve A bandwidth:
- Have each thread compute multiple elements of C in the same row
- Or reorganize blocks
If each thread computes a 1x4 strip of C (same row, 4 consecutive columns):
- Block has 64 threads (organized as 1x64 or 8x8?)
- Thread j computes C[i][j*4:(j+1)*4]
- In K loop:
- Thread j loads A[i][k] - still broadcast to some threads
- Thread j loads B[k][j*4:(j+1)*4] using float4 - coalesced!
Wait, but we still have the A broadcast problem. Unless we have multiple rows per block.
If block handles 32 rows and 8 columns per thread:
- Block = (32, 8) = 256 threads
- Each thread computes one element
- Block computes 32x8 = 256 elements
- Grid = (8, 8) to cover 256x256
In K loop for block (bx, by):
- Rows = by*32:(by+1)*32, Cols = bx*8:(bx+1)*8
- Thread (r, c) computes C[by*32 + r][bx*8 + c]
- Load A[by*32 + r][k] - threads with same r load same value, threads with different r load strided by K (bad for coalescing!)
- Load B[k][bx*8 + c] - threads with same c load same value, different c load consecutive (good!)
Actually for A: if thread (r, c) loads A[row][k], then in a warp we might have threads with different r values. These are strided by K. With K=524288, consecutive warps reading A[row][k] and A[row+1][k] are 2MB apart, terrible.
So we need to fix the A coalescing. To make A accesses coalesced, consecutive threads must read consecutive k values, not consecutive rows.
Let me redesign:
- Block computes a tile of C with dimensions TileM x TileN
- Threads are arranged so that in a warp, consecutive threads have consecutive tx (column index in block), not ty (row index)
Better yet:
- ThreadIdx = ty * TileW + tx in block
- But we map thread index to element positions carefully.
Actually, forget the traditional block structure. Let me use a persistent approach where:
- We launch enough warps to keep SMs busy
- Each warp computes a sub-tile of C
- Within each warp, threads are organized for coalesced access
Hmm, this is getting complex. Let me look at existing high-performance GEMM kernels for small MN, large K.
For small M and N with large K, one very effective strategy is to:
1. Use 1 warp per row of C (or per few rows)
2. Each thread in the warp computes 1 or more elements in that row
3. Stream through K
For 256 rows and maybe 8 warps per block:
- We can have 32 blocks, each with 8 warps
- Each block handles 8 rows
- Each warp handles 1 row
- In a warp, thread j computes C[row][j*8:(j+1)*8] or similar
For A:
- In K loop, for each k, warp needs A[row][k]. Actually if each warp handles 1 row and K is large, we load A[row][k] and broadcast to the warp or keep in a register.
- Wait, if each thread computes 8 consecutive elements in the row, and we iterate k:
- Load A[row][k] into a register (shared by all threads in warp via broadcast or just loaded once by lane 0)
- Each thread loads B[k][j*8:(j+1)*8] via float4 x 2
- Do 8 FMAs
For A broadcast: lane 0 loads A[row][k], other lanes wait. This is terrible! Better to have all lanes participate.
Wait, actually on modern GPUs, L2 cache handles broadcasts quite well. If all 32 threads in a warp request the same address, it only does 1 cache line read and broadcasts to all threads. This is actually very efficient for bandwidth!
So for A:
- All threads in warp need A[row][k] - broadcast, 1 cache line
- Each thread needs B[k][j * vec_elems:(j+1) * vec_elems] - coalesced, 8 floats = 2 float4s
With vec=8 floats per thread:
- Warp handles 32*8 = 256 columns = 1 row
- 256 rows / 32 warps per block = 8 rows per block
- 32 blocks total
Wait, but 32 blocks * 8 warps = 256 warps total. On H100 with 132 SMs, each SM gets ~2 warps, which is underutilized. We want more warps.
Let me try:
- Thread block size: 256 threads = 8 warps
- Grid: 256 blocks (one per row)
- Each block has 8 warps, each warp handles 4 rows? No.
Actually for 256 rows, if we want one block per row:
- 256 blocks, each computing 1 row x 256 cols
- Each block has 8 warps = 256 threads
- Each warp handles 32 columns (256 cols / 8 warps = 32 cols per warp)
- Each thread handles 1 column
In K loop:
- Block loads A[row][k] - all 256 threads need same value. Broadcast is efficient for same-warp, but across 8 warps, it's still cached.
- Thread j loads B[k][j] (where j is its column in the row) - coalesced within each warp!
Wait, but B is K x N. B[k][j] for j in 0..255 with k fixed means addresses base + k*256 + j, which are consecutive. Yes, coalesced!
And A[row][k] for all threads in block is the same address. On H100, this should broadcast efficiently.
Actually, I think the broadcast for A might still be a bottleneck because each block needs 1 float from A per K step, and 256 blocks all reading different rows of A at potentially different offsets. But A is very large (512MB), so cache might not help much across blocks.
Wait, if we process K sequentially, at step k, all blocks need A[0:255][k] (all rows, same k column). These are strided by K=524288 in memory, which is huge. So they won't be in the same cache line and won't benefit from spatial locality.
But within a block, all threads need A[row][k], so 1 float per block. For 256 blocks, that's 256 floats = 1KB at k, and they span 256 * K * 4 bytes ≈ 536MB apart in memory! This is terrible for L2 cache.
To improve A locality:
- Process K in tiles
- Each block processes K tile BK, computing partial sums for its assigned rows
- Multiple blocks working on different rows but same K tile: A accesses for same k but different rows are far apart
Hmm, the problem is A is M x K row-major, and M * sizeof(float) = 1024 bytes per row. But consecutive rows' same column are K floats apart = 2MB apart.
To improve this, we should have each block work on multiple consecutive rows, and within each block, warps work on consecutive rows. This way, when warp 0 needs A[row][k] and warp 1 needs A[row+1][k], these are 2MB apart but they might be at least in consecutive cache lines if the cache line is large enough... no, they're very far apart.
Wait, I have an idea. What if we transpose A first? Or read A in a transposed pattern?
Actually, on H100 with 3.35 TB/s bandwidth, even if A accesses are scattered, maybe we can still get close to peak if we have enough thread blocks to saturate memory.
Let me recalculate:
- 256 blocks, each needs 1 float from A per k step
- K = 524288 steps
- Total A reads = 256 * 524288 floats = 134M floats = 536MB
- Total B reads = 256 * 256 * 524288 floats = 34.4B floats = 137GB
Wait, that can't be right. Let me recalculate B:
Each output element needs K floats from B? No.
For C[row][col] = sum_k A[row][k] * B[k][col]
If we iterate k from 0 to K-1:
- At step k, we need B[k][col] for all col
- For all rows, we need the entire B row k[0:N] = N floats
For 256 rows and 256 cols, processing k sequentially:
- Total A reads: 256 rows * K = 134M floats (each row of A read once)
- Total B reads: K * 256 cols = 134M floats (each col of B read once)
Wait, that's wrong. For B, at each k, we need B[k][0:256] for all 256 columns. But if we process all outputs simultaneously, we read B[k][0:256] and reuse it for all rows.
In a block that handles 1 row:
- At step k, reads A[row][k] and B[k][0:256]
- B[k][0:256] = 256 floats = 1KB, coalesced
For 256 blocks (1 per row):
- A reads: 256 * K floats, scattered across memory
- B reads: 256 * K * 256 floats = way too much! Because each block reads the full B row.
Oh no, that's terrible! Each block reads all of B! 256 blocks * 524288 K * 256 N = 34 billion B reads.
To reduce B reads, blocks must SHARE B data via shared memory or L2 cache. But with 256 blocks, if they all read the same B[k][0:256] at step k, at least there's temporal locality if they execute in sync. But blocks might not be synchronized.
Actually, in CUDA, there's no global synchronization other than kernel launch. So different blocks might execute the same k at different times, causing B to be read multiple times from memory.
To fix this, we should use fewer blocks that each compute more rows, so B is read fewer times.
If we have 1 block computing all 256 rows:
- Block loads B[k][0:256] into shared memory (1KB)
- Then each thread computes its partial products using the shared B and its A values
- A[row][k] for row=0..255 - but these are scattered
Actually, let me reconsider the tile:
TileM = 256 (all rows), TileN = some value
- BK = maybe 8 or 16
Block computes 256 x TileN elements.
For K step:
- Load A[0:256][k:k+BK] into shared memory
- Load B[k:k+BK][0:TileN] into shared memory
- Compute
A[0:256][k:k+BK]: 256 * BK floats. For BK=8, that's 2KB. These are not coalesced well if loaded naively because consecutive elements are K apart.
But we can use shared memory with careful thread-to-element mapping.
Actually, A is M x K, so A[row][k:k+8] are consecutive in memory. If thread ty * 8 + tx loads A[row][k + tx], then threads in the same warp loading the same row but different k are coalesced.
So to load A[0:256][k:k+BK]:
- For each row r in 0..255:
- For each k_offset in 0..BK-1:
- A[r][k + k_offset] is loaded by some thread
With 256 threads and BK=8:
- We can have 32 warps, each handling 8 rows? Or 1 warp per 8 rows?
Wait, 256 threads = 8 warps. Each warp can handle 32 elements.
For A[0:256][k:k+8], we can decompose:
- Rows partitioned among threads: 256 rows / 256 threads = 1 row per thread
- Each thread loads BK=8 consecutive elements from its row using float2 or float4... but 8 floats = 2 float4s
- So each thread loads 2 float4s from A[threadIdx.x][k:k+8]
This is coalesced because threads with consecutive threadIdx.x load consecutive rows, but within each row they load consecutive k elements. Wait:
- Thread 0 loads A[0][k:k+4] and A[0][k+4:k+8]
- Thread 1 loads A[1][k:k+4] and A[1][k+4:k+8]
In a warp (threads 0-31):
- Thread t loads A[t][k:k+4] at addresses base + t*K + k
- These addresses are spaced by K = 524288 * 4 = 2MB apart!
- This is NOT coalesced! Each warp issues 32 separate memory transactions.
This is terrible. To coalesce A loads, consecutive threads must load consecutive memory addresses. That means consecutive threads must access the same row, consecutive k.
Solution: Use fewer rows per memory load, or transpose the loading.
Actually, let me think: if we're loading A tile of 256x8, we can use transpose loading:
- Thread t loads A[t/8][k + t%8]
- But within a warp (t=0..31), we have addresses: A[0][k..7], A[1][k..7], A[2][k..7], A[3][k..7]
- Address of A[r][k+offset] = base + r*K + k + offset
- For threads in warp: base + 0*K + k + 0, base + 0*K + k + 1, ..., base + 0*K + k + 7, base + 1*K + k + 0, ...
- These are NOT consecutive.
Hmm, with K=524288, elements from different rows are very far apart. The ONLY way to get coalesced A loads is to have consecutive threads read elements from the SAME row, consecutive K.
So for A[r][k:k+BK]:
- We want threads 0..31 all reading A[r][k], A[r][k+1], ... but they all need the same row! Unless multiple warps cooperate.
For A tile 256x8, each row slice k:k+8 needs 8 floats. We can have:
- Warp r loads A[r][k:k+8] for r = 0..255? But there are only 8 warps in a block of 256 threads.
- Actually, with 256 threads = 8 warps, each warp handles 32 rows.
- Warp w loads A[w*32:(w+1)*32][k:k+8]
- In warp, lane l loads A[w*32 + l/8][k + l%8]? No, lanes 0..7 would load A[w*32][k:k+8], lanes 8..15 load A[w*32+1][k:k+8], etc.
- For lane l reading A[row][k+offset], address = base + row*K + k + offset
- Lanes 0..7: addresses spaced by 1 (coalesced!) for same row
- Lanes 0 and 8: addresses spaced by K (not coalesced across groups of 8)
Wait! In a warp, memory coalescing groups contiguous lanes reading contiguous addresses. If lanes 0-7 read A[row][k:k+8], that's 8 consecutive floats = 1 transaction. Lanes 8-15 read A[row+1][k:k+8], which is K floats away = 2MB away, that's a separate transaction.
So the warp issues 4 transactions (one per group of 8 lanes, for 4 rows). Each transaction is fully coalesced. That's actually not bad! It means each warp reads 4 * 8 floats = 32 floats per K step.
But the total transactions per K step for A is 256 rows / 8 floats per transaction = 32 transactions. Which corresponds to 32 * 8 = 256 float reads. But with BK=8 and TileM=256, that's exactly right.
Hmm, but each transaction is a 32-byte cache line for 8 floats? Yes, and each accesses 8 consecutive floats in a row.
For the full A tile loading:
- With 256 threads, 32 transactions per K step
- Each transaction reads 8 floats (32 bytes)
- Total A loaded per K step = 256 * 8 = 2048 floats
Wait, I think on H100, the L1 cache line is 128 bytes. A single 128-byte segment can service all 32 lanes if they read 128 bytes of contiguous memory. But here, within each group of 8 lanes, they read 32 bytes contiguous. Then the next group reads 32 bytes that's 2MB away.
Actually, I think on Ampere/Hopper, the L1 cache can handle up to 32 addresses per warp. If all addresses fall within the same 128-byte aligned segment, it's 1 transaction. Here, we have 4 groups each reading 32 bytes from different 128-byte segments (since they're K apart = 2MB apart). So the warp issues 4 memory transactions.
But with 8 warps, each handling 32 rows and reading 4 rows worth of data per... wait, each warp handles 32 rows (since 256 rows / 8 warps = 32 rows per warp).
For warp w handling rows w*32:(w+1)*32:
- We need to load 32 rows * 8 floats = 256 floats
- If grouped as 4 rows per... let me think.
If we use 32 lanes, and each row needs 8 floats:
- We can process 4 rows at a time (32 lanes / 8 floats per row = 4 rows per warp iteration)
- But then we need 32/4 = 8 iterations per warp
- In each iteration, lanes 0-7 read row r+0, k:k+8; lanes 8-15 read row r+1, k:k+8, etc.
- 4 transactions per iteration
- 8 iterations = 32 transactions per warp per K step
With 8 warps = 256 transactions per K step for A. Each transaction is 32 bytes, so 8KB total. That's 256 * 32B = 8KB which matches our A tile size.
Similarly for B: B[k:k+8][0:TileN]. If TileN=256, B tile is 8 x 256 = 2048 floats = 8KB.
To load B[k:k+8][0:256]:
- For row k+offset in B (which is 1 x 256), we can have threads load consecutive elements.
- 256 threads can load 256 consecutive elements per row.
- But we need 8 rows, so maybe each thread loads 8 elements in the same column but different rows? Or same row, different columns.
B[k+row_offset][col_offset] address = B_base + (k+row_offset)*N + col_offset
For consecutive col_offset, addresses are consecutive.
For consecutive row_offset with same col_offset, addresses are spaced by N = 256 floats = 1KB.
To coalesce B loads, consecutive threads should read consecutive col_offset.
If thread t reads B[k + t%8][col_base + t/8], then:
- Thread 0: B[k][col_base]
- Thread 1: B[k+1][col_base]
- ...
- Thread 7: B[k+7][col_base]
- Thread 8: B[k][col_base+1]
In a warp: threads 0-7 have addresses spaced by N=1KB, threads 0 and 8 are spaced by 1. This is bad.
Better: thread t reads B[k + t/32][col_start + t%32]. No wait, if TileN=256, we have 256 cols and 8 rows.
Maybe: each thread reads 1 float from each of the 8 rows, at column = threadIdx (or some mapping).
Actually, for B[k:k+8][0:256], this is 8 rows of 256 floats each. Total 2048 floats.
If we arrange in column-major for loading:
- Thread t can load B[k + 0][t], B[k + 1][t], ..., B[k + 7][t] for t = 0..255
- Address of B[k+i][t] = B_base + (k+i)*N + t
- For threads 0-31 reading B[k+i][0-31]: addresses are B_base + (k+i)*N + 0 to +31, consecutive! Coalesced!
- Then next iteration or instruction: threads read B[k+i+1][0-31], addresses spaced by N = 1KB from previous set.
Hmm, but consecutive threads reading same row, consecutive columns: that's coalesced within each row.
Actually, threads t reading B[k+i][t] for t=0..31:
- Addresses: base+(k+i)*N + 0, base+(k+i)*N + 1, ... base+(k+i)*N + 31
- These are consecutive, 1 transaction!
Next, threads read B[k+i+1][t]:
- Addresses: base+(k+i+1)*N + 0, etc.
- These are 1KB away from the previous set, so separate transaction.
- But still coalesced within the set.
With 256 threads and 8 rows of B, each of 256 cols:
- We can load all 8 rows simultaneously: threads 0..255 read B[k+0][0..255] using all threads? No, that's only 1 row.
- But 256 threads reading 256 consecutive floats = perfectly coalesced, 1-2 transactions!
- Then for next row, another coalesced load.
- Total 8 transactions for B tile.
Wait, if 256 threads read B[k][0:256], that's 256 floats = 1024 bytes = 8 cache lines (128 bytes each). On H100, a warp can read up to 32 bytes per thread, so 256 threads = 8 warps. The block executes in warps, not all 256 simultaneously in terms of memory. But consecutive threads within each warp are reading consecutive addresses, so each warp's load is coalesced.
Let's be more precise. 256 threads = 8 warps.
Warp w reads B[k][w*32:(w+1)*32]. Addresses: base+(k)*N + w*32 to base+(k)*N + (w+1)*32 - 1.
Each warp issues 1 transaction (32 contiguous floats = 128 bytes). Perfect.
8 warps, 1 row: 8 transactions.
8 rows: 8 * 8 = 64 transactions.
For A:
Warp w reads A[w*32:(w+1)*32][k:k+8].
Like before, in groups of 8 lanes per row: 4 groups * 8 iterations? Wait.
Warp w handles 32 rows. Each row needs 8 floats.
32 rows * 8 floats = 256 floats per warp per K step.
With 32 lanes, each lane can load 8 floats.
Lane l loads A[w*32 + l/4][k + (l%4)*2] ? Hmm, that's 2 floats per chunk per lane = 8 floats total? No.
Actually, simpler: each lane loads 8 consecutive floats from its assigned row.
Lane l loads A[w*32 + l][k:k+8]. That's 8 floats per lane.
Address: base + (w*32 + l)*K + k to base + (w*32 + l)*K + k + 7.
In warp: addresses spaced by K = 2MB. Each lane reads 32 bytes contiguous.
The warp issues 32 separate 32-byte transactions. That's terrible!
On H100, the memory subsystem might group some of these, but 2MB apart is far beyond any cache line size. So we get 32 transactions per warp for A.
To reduce A transactions, we should load A in a way that consecutive threads read consecutive memory. That means consecutive threads must access the SAME row, consecutive K.
So instead of each lane handling 1 row, we should have multiple lanes handle the same row:
- 32 lanes can cooperatively load 32*8 = 256 floats from the SAME row
- But we only need 8 floats per row!
Wait, if we have 32 lanes all loading from the same row, and each lane loads 8 floats, we load 256 floats from one row. But we need to load 8 floats from EACH of 32 rows. That's the opposite.
The fundamental problem: A is MxK with K huge. Elements in the same column, adjacent rows are very far apart. To load a "slice" of A (multiple rows, some columns), we can't coalesce across rows.
What if we use vectorized float4 loads and process multiple K iterations per load?
Actually, I realize I should try a completely different approach. What if we compute multiple K values per thread before moving to the next row? Or better yet...
Approach: Transpose A or use a custom layout.
But wait, we can't transpose A because we don't control the input format.
Actually, what if we just let each thread compute its C element and stream K?
- Thread j in warp computes 1 or more elements of C
- For K loop, it loads A[row][k] and B[k][col] and does FMA
With 256 threads per block and 1 block per row:
- Row r: 256 threads, each handling 1 column
- Thread j loads A[r][k] and B[k][j]
- All threads in block load SAME A[r][k] - but this is actually not bad for a warp (broadcast), though across warps it's cached.
- Thread j loads B[k][j] - consecutive j means consecutive B addresses, coalesced!
For A: within a warp, all 32 threads load the same address. This is a broadcast, which is handled efficiently by the L1 cache (1 cache line read per warp).
For B: consecutive threads load consecutive addresses, perfectly coalesced.
Now, with 256 rows and 1 block per row = 256 blocks.
At step k:
- Each block loads A[row][k] - 256 separate addresses, spaced K apart = 2MB apart
- These are going to different cache lines. L2 cache can handle this, but it's 256 separate memory transactions.
Memory bandwidth check for A:
- Total A traffic: 256 rows * K floats * 4 bytes = 536 MB
- Spread across K steps = 524288 steps
- At each step, 256 floats = 1KB from A
- Total A bandwidth = 536 MB over kernel time
For B:
- Each block loads B[k][0:256] = 256 floats = 1KB
- 256 blocks all loading the same B[k][0:256] at step k
Wait! If all 256 blocks load the same B[k][0:256], and they're not synchronized, the L2 cache might have to serve this from memory multiple times if the blocks don't execute in lockstep.
On H100 with 132 SMs and 256 blocks, there are enough blocks to keep all SMs busy. L2 cache capacity is quite large (maybe 50MB). The B rows are 1KB each, and with K=524288, there are 524288 B rows = 512MB. L2 might not cache all B rows.
But the key insight: multiple blocks loading the same B row at the same (or nearby) times will benefit from L2 cache. Since K is processed sequentially, at any given time, many blocks are probably reading B[k][0:256] for the same k or nearby k values.
Actually, if blocks are scheduled dynamically and they all process k=0, then k=1, etc., there could be significant L2 cache reuse for B. Each B row is 1KB, and L2 can hold ~50MB, so ~50K B rows. Since we process k in order, if blocks stay roughly synchronized, B[k] stays in L2 for a while.
But blocks might not stay synchronized because they also need to read A, which is strided and causes cache thrashing.
Hmm. Let me think about fewer blocks.
Alternative: 1 block handling all 256 rows and 256 columns.
- 256 threads in block
- Thread j handles C[0:256][j] or some mapping
Actually, with 256 threads and 256*256 C elements, each thread computes 256 elements.
What if each thread computes a column of C?
- Thread j computes C[0:256][j] = A[0:256][:] * B[:][j]
- For K loop:
- Thread j loads B[k][j] for each k
- Thread j also needs A[0:256][k] for all rows - but it only needs A[r][k] for its current output row r.
Wait, if thread j computes one column of C, it needs to iterate over rows. For each row r:
C[r][j] = sum_k A[r][k] * B[k][j]
So thread j iterates r=0..255, and for each r, iterates k=0..K-1.
But this means thread j loads B[k][j] K*256 times (once per row). That's terrible.
Better: thread j computes a subset of rows for column j.
Or even better: have the block load B[k][0:256] into shared memory, and then all threads compute their outputs using the shared B.
With 1 block, 256 threads, full C = 256x256:
- For each BK chunk of K:
- Load B[k:k+BK][0:256] into shared memory
- Load A[0:256][k:k+BK] into shared memory (if possible) or directly compute
- Each thread computes a subset of C
Wait, with 256 threads, if BK=1:
- Load B[k][0:256] = 256 floats into shared memory
- But which thread loads which? Thread j loads B[k][j]. Coalesced!
- Then for A: thread j loads A[j][k]? No, all threads need all of A.
Actually, each thread could handle multiple rows.
Hmm, let me try this configuration:
- 1 block, 256 threads
- Each thread handles 1 row of C, and computes all 256 columns
- Thread r computes C[r][0:256]
For K loop:
- At step k, all threads need A[r][k] (each thread needs its own row's A value)
- Broadcast within each thread (it's in a register)
- All threads need B[k][0:256] - this is where shared memory helps
Without shared memory:
- Thread r loads A[r][k] into register a
- Thread r loads B[k][c] for each c? No, thread r needs all 256 B values for this k.
- Thread r loads B[k][0], B[k][1], ..., B[k][255] and does 256 FMAs.
But B[k][c] for different c are consecutive in memory. Thread r could load them efficiently using float4, doing 64 float4 loads = 64 memory operations per K step per thread.
With 256 threads each doing 64 loads, and K=524288 steps, total B loads = 256 * 64 * 524288 = 8.6 billion loads. At 32 bytes per float4, that's 274 GB of B reads! Way too much.
So we MUST share B across threads.
With shared memory for B[k][0:256]:
- 256 threads cooperatively load B[k][0:256] into shared mem (256 floats)
- Then each thread reads the shared B values for its computations
- Thread r (handling row r) reads all 256 B values and does 256 FMAs with A[r][k]
But A[r][k] for different r are scattered (2MB apart). If each thread loads its own A[r][k], that's 256 separate memory transactions per K step. Again, A bandwidth is the issue.
Total A reads: still 256 * K = 134M floats = 536MB. But the issue is the number of transactions.
With 256 transactions per K step and K=524288, that's about 134 million transactions. At 1 transaction per ~100 cycles or more, that might be slow.
But if we group A loads:
- What if a warp loads A[r:r+32][k]? As I analyzed before, that's 32 transactions per warp.
- If we have 8 warps, that's 256 transactions per K step. Same as before.
OK so let me just accept that A loads will be scattered and focus on saturating memory bandwidth with enough parallelism.
Actually, for scattered A loads, the key is memory latency hiding. If we have many warps, the GPU can switch between warps while waiting for memory. But we only have 1 block with 8 warps, so SM occupancy is very low.
Let me use more blocks to improve parallelism.
Configuration: 32 blocks, each handling 8 rows of C.
- Block b handles rows b*8:(b+1)*8
- 256 threads per block, so each thread handles 1 element? 8 rows * 256 cols = 2048 elements, 256 threads = 8 elements per thread.
Wait, 8 rows * 256 cols = 2048 output elements.
256 threads * 8 elements/thread = 2048. Good.
Each thread handles 8 elements in the same column, different rows? Or same row, different columns?
If thread t handles elements in a 2x4 tile or something...
Actually, with 256 threads, if we use shared memory for B:
- Block computes 8 rows x 256 cols
- For each K step:
- Load B[k][0:256] into shared memory using 256 threads (coalesced, 8 transactions for the block)
- Load A[b*8:(b+1)*8][k] using 8 threads (1 per row), each row's A value
- Actually, 8 A values scattered 2MB apart. Not great, but only 8 values.
- Each thread computes its assigned elements using shared B and its A value
Wait, but if a thread handles multiple rows, it needs multiple A values. And if threads in a warp handle different rows, they all need different A values.
Actually let me make each thread handle 1 element, but have the block handle 8 rows x 256 columns = 2048 elements, with 256 threads each computing 8 elements? No, 2048/256 = 8 elements per thread.
What if each thread computes all 256 columns for one row? Then we need 8 threads for 8 rows, and the other 248 threads are idle. Bad.
What if each thread computes 1 column for all 8 rows?
- Thread j computes C[b*8:(b+1)*8][j]
- 256 threads, but we only need 256 columns. 256 threads each computing 1 column for 8 rows = 256 * 8 = 2048 elements. Perfect!
- Thread j loads A[b*8 + r][k] for r=0..7?
- And B[k][j] which is shared among all rows for this thread.
Actually, thread j computing C[r0:r0+8][j]:
for k:
a0 = A[r0][k]
a1 = A[r0+1][k]
...
a7 = A[r0+7][k]
b = B[k][j] (shared across all rows for this k and j)
sum0 += a0 * b
sum1 += a1 * b
...
sum7 += a7 * b
But B[k][j] is shared across rows but each column j needs its own B value. With 256 threads each needing B[k][j] for their own j, all 256 B values are needed anyway.
Actually, for B[k][j], if we don't use shared memory, each thread j loads B[k][j]. That's 256 coalesced loads (8 transactions for 8 warps), getting all 256 B values. Then each thread computes 8 FMAs with its 8 A values.
Wait, this means at each K step, each thread loads 8 A values and 1 B value. But we only need 1 B value per thread, and it's already coalesced.
A loads: thread j loads A[r0][k], A[r0+1][k], ..., A[r0+7][k]. These are 8 addresses spaced 2MB apart. A single thread doing 8 scattered loads = 8 transactions. Terrible for that thread.
But multiple threads might help hide latency. Thread j loads A for rows r0..r0+7, and thread j+1 does the same for its assigned rows. Since all threads in the block are loading different rows' A values, all the loads are to different memory regions.
With 256 threads and 8 rows per block, actually threads within the same block are assigned to the SAME 8 rows but DIFFERENT columns. So thread j loads A[r0..r0+7][k], and so does thread j+1! They're loading the same 8 A values! This is redundant!
To avoid redundant A loads, threads in the same block should share A values. That means using shared memory for A too.
So:
- Block handles 8 rows x 256 cols
- Load A[8][1] for current k into shared memory (8 floats)
- Load B[1][256] for current k into shared memory (256 floats)
- Each thread computes 8 FMAs using shared A and shared B
For A shared loading: 8 threads load the 8 A values, other threads idle or help with B.
For B shared loading: 256 threads load B[k][0:256], coalesced.
Hmm, let's refine:
Block: 256 threads
Grid: 32 blocks (for 256 rows)
Each block: 8 rows x 256 cols
In K loop:
- Thread 0..7 load A[block_row*8 + threadIdx.x][k] into s_A[threadIdx.x]
- Addresses: base + (block_row*8 + t)*K + k, spaced 2MB apart
- 8 transactions
- Thread 0..255 load B[k][threadIdx.x] into s_B[threadIdx.x]
- Addresses: base + k*256 + t, consecutive
- 8 transactions (coalesced per warp, 8 warps)
- __syncthreads()
- Each thread j computes:
for r = 0..7:
accum[r] += s_A[r] * s_B[j]
- __syncthreads()
Wait, each thread computes 8 outputs (one for each of the 8 rows, at column j).
Total work per thread per K step: 8 FMAs.
Total FMAs per block per K step: 256 * 8 = 2048.
Total FMAs for all blocks for all K: 32 * 524288 * 2048 = 34.4 billion = correct.
Memory traffic per K step per block:
- A: 8 floats
- B: 256 floats
- Total: 264 floats = 1056 bytes
Per K step across all 32 blocks:
- A: 32 * 8 = 256 floats (all unique, covering 256 rows)
- B: 32 * 256 = 8192 floats (each block loads the same B row!)
Oh! The problem is each block loads the same B row independently. That's 32x redundancy for B!
If blocks are not synchronized, L2 cache might help if multiple blocks read the same B[k][0:256] while it's in cache. But 32 blocks reading the same 1KB is 32KB. L2 can easily cache 1KB. As long as blocks arrive at the same k roughly together, the first block's load brings B[k] into L2, and subsequent blocks hit L2.
But if blocks are desynchronized (some finish k=0 while others are still at k=0 because they're stalled on A loads), L2 might evict the row before all blocks read it.
To improve this, we could use fewer blocks. What if we use 1 block with 256 threads, computing all 256 rows x 256 cols?
With 1 block:
- Only 1 SM is used! That's terrible on a 132-SM GPU.
What if we use TileN < 256 and have blocks for different column ranges?
- Block (bx, by) handles rows by*TileM:(by+1)*TileM, cols bx*TileN:(bx+1)*TileN
- If TileN=32, we have 8 column blocks
- Total blocks = 32 * 8 = 256 blocks
For K step:
- Block (bx, by) loads A[by*8:(by+1)*8][k] (8 floats)
- Block (bx, by) loads B[k][bx*32:(bx+1)*32] (32 floats)
- Each block loads a different slice of B! No redundancy.
- Each block loads a different slice of A (different rows).
This is much better! Total B loaded per K step across all blocks = 8 * 32 = 256 floats = 1KB, exactly the full B row, read exactly once.
Total A loaded per K step across all blocks = 32 * 8 = 256 floats, exactly all rows, read exactly once.
This is optimal in terms of memory traffic!
Now let's check block sizing:
- TileM = 8 rows per block, 32 row blocks to cover 256 rows
- TileN = 32 cols per block, 8 col blocks to cover 256 cols
- Total blocks = 32 * 8 = 256 blocks
- Threads per block: ?
With TileM=8, TileN=32, each block computes 8*32 = 256 elements.
- 256 threads per block means 1 element per thread.
- Or 128 threads = 2 elements per thread.
With 256 blocks and 256 threads per block = 65536 threads total. On 132 SMs, that's ~500 threads per SM, which is good (~15 warps per SM, plenty for latency hiding).
But wait, each block's shared memory:
- s_A: 8 floats
- s_B: 32 floats
- Very small, no problem.
Let me also think about float4. If TileN=32, we can load B[k][0:32] as 8 float4s, which is nice.
Also, if each thread computes 1 element, total registers per thread: 8 accumulators + some temps = very low.
Actually, with TileN=32 and threads computing 1 element each, we only need 32 threads... but we have 256 thread slots. We can either use fewer threads or have each thread compute more elements.
Let me scale up to improve utilization:
- TileM = 32 rows, TileN = 32 cols
- Grid = (8, 8) = 64 blocks
- Each block computes 32*32 = 1024 elements
- 256 threads per block, each computing 4 elements
For K step:
- Block loads A[32][1] = 32 floats (scattered, but only 32 transactions for the block)
- Block loads B[1][32] = 32 floats (coalesced, or via float4)
- Each thread computes 4 FMAs
Wait, but with 64 blocks reading B, and TileN=32, each K step requires 64 * 32 = 2048 B floats. But B row is only 256 floats. So each B element is read 2048/256 = 8 times. That's 8x redundancy.
To have no B redundancy, we need TileN * num_col_blocks = 256, where num_col_blocks = N / TileN = 256 / TileN. So TileN * (256 / TileN) = 256. That means if we arrange it so each column block reads a unique subset of columns, then each B element is read once. But we already have that by construction.
Wait, let me recompute. Grid for columns: N / TileN. If TileN = 32, num_col_blocks = 8.
At K step, each of the 8 column blocks reads B[k][bx*32:(bx+1)*32] = 32 floats.
Total B read per K step = 8 * 32 = 256 floats = full B row.
Perfect!
And for rows: num_row_blocks = M / TileM = 256 / 32 = 8.
At K step, each row block reads A[by*32:(by+1)*32][k] = 32 floats.
Total A read per K step = 8 * 32 = 256 floats = full A column.
Perfect!
So the redundancy is zero for both A and B.
BUT: The issue is that different blocks execute at different rates. If row block 0 finishes K=0 quickly while row block 1 is still on K=0, they both need B[k=0][col_range]. If B[k=0] is in L2, row block 0's read brings it in, and row block 1 benefits. But if they're working on DIFFERENT K values, there's no reuse.
To maximize temporal locality for B, we want all blocks processing the same K to execute concurrently. With 64 blocks and 132 SMs, that's definitely possible. Some SMs will run 2 or more blocks concurrently (time-sliced).
Actually, on H100, an SM can run up to 32 blocks concurrently? No, up to 32 blocks per SM in terms of scheduling, but limited by warps and shared memory. With 256 threads = 8 warps per block, each SM could run maybe 4 blocks concurrently (32 warps max per SM). So with 132 SMs and 64 blocks, each SM runs about 0.5 blocks on average. Wait, no, blocks are distributed across SMs.
Actually, 64 blocks on 132 SMs means most SMs get 0 or 1 block. Some SMs will be idle. That's underutilization!
To better utilize the GPU, we need more blocks. Let's use smaller tiles.
If TileM = 8, TileN = 32:
- num_row_blocks = 256 / 8 = 32
- num_col_blocks = 256 / 32 = 8
- Total blocks = 32 * 8 = 256 blocks
256 blocks on 132 SMs ≈ 2 blocks per SM. Good!
Each block computes 8 * 32 = 256 elements.
Threads per block: let's use 256 threads, each computing 1 element. Or 128 threads, each computing 2 elements.
Wait, 256 threads computing 256 elements means 1 element per thread. That's fine.
But then each thread only does K = 524288 FMAs. With ~500 warps total running across the GPU, that's plenty.
Actually, with 256 blocks * 256 threads = 65536 threads. But a warp has 32 threads, so 2048 warps. With 132 SMs * 32 warps/SM max = 4224 warps capacity. So we're using about half the warp capacity. Not ideal but maybe OK.
Let me try even more blocks:
TileM = 4, TileN = 32:
- num_row_blocks = 64
- num_col_blocks = 8
- Total blocks = 512
512 blocks on 132 SMs ≈ 4 blocks per SM. Great!
Each block computes 4 * 32 = 128 elements.
Threads per block: 128 threads, each computing 1 element.
Total warps = 512 * 4 = 2048 warps. That's exactly half the 4224 warp capacity. Still some room.
What about TileM = 2, TileN = 32:
- num_row_blocks = 128
- num_col_blocks = 8
- Total blocks = 1024
1024 blocks * 64 threads (2 warps) = 2048 warps. Same total warps.
Actually, reducing threads per block might hurt because shared memory synchronization and block overhead become more significant. Let me aim for blocks with 128-256 threads.
TileM = 8, TileN = 64:
- num_row_blocks = 32
- num_col_blocks = 4
- Total blocks = 128
128 blocks * 256 threads = 32768 threads = 1024 warps. That's only 1/4 of capacity. Might not be enough to hide latency.
TileM = 4, TileN = 64:
- num_row_blocks = 64
- num_col_blocks = 4
- Total blocks = 256
256 blocks * 256 threads = 65536 threads = 2048 warps. Better.
But with TileN=64, each block reads 64 B floats per K step. 4 column blocks * 64 = 256 floats. Still perfect.
A per block: 4 floats per K step. 64 row blocks * 4 = 256 floats. Perfect.
Total blocks = 256. Good.
Let me also consider computing multiple K values per load to amortize the shared memory sync cost.
If BK = 8, each block loads:
- A: 4 rows * 8 K values = 32 floats
- B: 8 K values * 64 cols = 512 floats
With 256 threads, loading A is easy (32 floats by 32 threads, others idle or help with B).
Loading B: 512 floats by 256 threads, each loads 2 floats. Coalesced!
Then in the compute phase, each thread does 4 rows * 8 K * 64 cols? No, each thread handles a subset.
Wait, if TileM=4 and TileN=64, total elements per block = 256.
With 256 threads, each thread handles 1 element.
For BK=8, thread computing C[row][col]:
accum = 0
for k_offset in 0..7:
accum += s_A[row][k_offset] * s_B[k_offset][col]
So each thread does 8 FMAs per K-outer-iteration.
Total outer iterations = K / BK = 524288 / 8 = 65536.
Total FMAs per thread = 65536 * 8 = 524288.
Total FMAs per block = 256 * 524288 = 134,217,728.
Total FMAs for all 256 blocks = 256 * 134,217,728 = 34.4 billion. ✓
This seems reasonable. But can we do more FMAs per thread to reduce instruction overhead?
If we use BK=16:
- A: 4*16 = 64 floats
- B: 16*64 = 1024 floats
- Each thread does 16 FMAs per outer iteration
- Outer iterations = 524288 / 16 = 32768
Total shared memory: A tile + B tile = (64 + 1024) * 4 bytes = 4352 bytes. Very small.
But for BK=16, loading B[16][64] = 1024 floats = 256 float4s. With 256 threads, each thread loads 1 float4.
For A[4][16] = 64 floats = 16 float4s. Only 16 threads need to load A.
The computation per thread:
- Thread computes C[row][col] for some (row, col) assignment
- In block, 256 threads handle 4*64=256 elements
- Mapping: threadIdx to (row_in_tile, col_in_tile)
With 4 rows and 64 cols per tile, and 256 threads:
- We could map thread t to (t/64, t%64) = (row, col)
- But there are only 4 rows and 64 cols = 256 elements. Perfect 1-to-1.
For BK=16:
accum = 0
for k in 0..15:
accum += s_A[row][k] * s_B[k][col]
thread computes 16 FMAs.
I think this is a good configuration.
But wait, all threads in a warp might compute different rows, so they read different s_A[row] values but possibly the same s_B[k] values. Since s_A and s_B are in shared memory, this should be fast (no bank conflicts if arranged properly).
For s_A[4][16]:
- Row-major: s_A[row][k]
- Threads in warp reading s_A[row][k] for different row: address = row*16 + k
- If row differs, address differs by 16. With 32-byte shared memory banks (on H100, I think banks are 4 bytes wide but let's check)...
Actually, on modern GPUs:
- Shared memory has 32 banks
- Each bank is 4 bytes
- Consecutive 32-bit words go to consecutive banks
s_A[row][k] is accessed by threads reading different rows.
If thread t reads s_A[t/64][k], then in a warp of 32 threads:
- Threads 0..63 aren't all in the same warp (warp has 32 threads).
In warp 0 (threads 0..31):
- Thread t = t/64 = 0 (since t < 32, t/64 = 0)
- So all threads in warp 0 access s_A[0][k], but different k values depending on their col = t%64.
Wait no, each thread computes 1 element (row, col). In the inner loop over k, all threads read s_A[row][k] for their row, and s_B[k][col] for their col.
For fixed k, threads in warp 0:
- Thread t: row = 0, col = t (for t in 0..31 if warp covers col range)
- All read s_A[0][k] - same address, broadcast! No bank conflict.
- Thread t reads s_B[k][t] - consecutive addresses, consecutive banks (assuming s_B is row-major K x N). No bank conflict.
In warp 1 (threads 32..63):
- Thread t: row = 0, col = t (for t in 32..63)
- All read s_A[0][k] - broadcast again.
- Thread t reads s_B[k][t] - consecutive, no conflict.
Wait, but all threads in the block with row=0 read s_A[0][k] simultaneously. That's 64 threads (columns 0..63 for row 0). Two warps both broadcasting s_A[0][k]. Shared memory handles broadcasts, no conflict.
Similarly for row 1, 2, 3.
So there's no bank conflict for s_A accesses because all threads reading the same row broadcast the same address. And for s_B accesses, threads read consecutive addresses, which map to consecutive banks, no conflict.
Perfect!
Now let me reconsider A loading. With TileM=4 and BK=16, we need to load A[4][16] into shared mem.
A is M x K row-major. A[row_start + r][k_start + c] is at address base + (row_start + r)*K + k_start + c.
With K=524288, consecutive rows are 2MB apart. To load A[r][k_start:k_start+16] for r=0..3:
- We could use 4 warps, each warp loading 1 row via float4.
- Warp 0 (threads 0-31): thread t loads A[0][k_start + t*4 : k_start + t*4 + 4] via float4. But we only have 16 elements per row! So thread 0 loads first 4, thread 1 loads next 4, thread 2 loads next 4, thread 3 loads last 4. Threads 4-31 idle.
- That's 16 floats per row = 4 float4s.
- Warp 0 issues 4 transactions (each float4 is 16 bytes).
- Warp 1 does the same for row 1, etc.
Total A transactions per BK chunk: 4 rows * 4 float4s = 16 transactions = 256 bytes loaded. Correct.
For the 16 transactions, each is coalesced (consecutive threads reading consecutive memory in same row).
Actually, with 4 float4s per row and 32 threads in warp, we only need 4 threads per row. What if we use all 32 threads to load 8 rows' worth? But we only have 4 rows in the tile. We could have 8 threads per row, each loading 2 floats (float2). Or some other split.
But using 4 threads per row with float4 works. The idle threads in the warp don't hurt because warps execute together.
Actually, on modern GPUs, all 32 threads in a warp execute, but the inactive lanes are masked. The memory unit still sees the active lanes' requests. So 4 active lanes means 4 transactions, which is fine.
For B loading: B[k_start:k_start+16][col_start:col_start+64]. If col_start=bx*64.
B is K x N row-major. We want to load 16 rows x 64 cols.
If we arrange so that threads 0..255 load B[k][col_start:col_start+64] for different k and cols:
Actually, we have 16 rows of B and 64 cols, total 1024 floats = 256 float4s. With 256 threads, each thread loads 1 float4.
Mapping:
Thread t maps to B element number t in row-major order.
t = k*64 + c, where k in 0..15, c in 0..63.
Thread t loads B[k_start + k][col_start + c : col_start + c + 4] via float4.
Wait, but t = k*64 + c means c = t%64, k = t/64.
Address = B_base + (k_start + t/64)*N + col_start + t%64
For t=0..31: k=0, c=0..31. Addresses: B_base + k_start*N + col_start + 0, +1, ..., +31. Consecutive! 1 transaction.
But then we need float4, so addresses: +0, +4, +8, ..., +124. Still mostly consecutive but strided by 4.
Wait, float4 means each thread loads 4 consecutive floats: c, c+1, c+2, c+3.
Address: B_base + k_start*N + col_start + c to +c+3.
For thread t: c = (t%64) and we need c to be multiple of 4 for alignment. Let's set c = (t%64)*4... no, that means c ranges 0, 4, 8, ..., 252, but we only have 64 cols. (64/4 = 16).
Hmm, if each thread loads 1 float4, and each float4 covers 4 consecutive columns, then with 64 columns, we need 64/4 = 16 float4s per B row.
With 16 rows of B, total float4s = 16 * 16 = 256 float4s.
With 256 threads, each thread loads exactly 1 float4. Perfect!
Mapping: thread t = k*16 + f4_idx, where k in 0..15, f4_idx in 0..15.
Thread t loads B[k_start + k][col_start + f4_idx*4 : col_start + f4_idx*4 + 3].
Wait, but we also need to make this coalesced. In a warp of 32 threads:
If threads 0..15 load row 0's 16 float4s, and threads 16..31 load row 1's 16 float4s.
For threads 0..15: addresses = B_base + k_start*N + col_start + f4_idx*4.
These are spaced by 16 bytes each.
In a warp: thread 0 loads col 0-3, thread 1 loads col 4-7, etc.
Are these coalesced? The addresses are 16 bytes apart.
On Ampere/Hopper, a warp transaction can cover up to 128 bytes. So 16 consecutive threads each reading 16 bytes (total 256 bytes) might take 2 transactions (each 128 bytes).
For threads 16..31 loading row 1: addresses are B_base + (k_start+1)*N + col_start + f4_idx*4.
These are N*4 = 1024 bytes away from the row 0 addresses. So separate transactions.
Per warp, for B row k loaded by 16 threads: 2 transactions per row.
With 2 rows per warp (16 threads each): 4 transactions per warp.
With 8 warps: 32 transactions total for the block.
Total B loaded: 16 rows * 64 cols * 4 bytes = 4096 bytes.
At 128 bytes per transaction, 32 transactions * 128 bytes = 4096 bytes. Perfect!
And all transactions are well-formed (coalesced within each group).
Great, so the memory access pattern works.
Now let me write the kernel:
```cuda
template<int TILE_M, int TILE_N, int BK>
__global__ void matmul_kernel(const float* __restrict__ A, const float* __restrict__ B, float* __restrict__ C, int M, int N, int K) {
int blockRow = blockIdx.y;
int blockCol = blockIdx.x;
int rowStart = blockRow * TILE_M;
int colStart = blockCol * TILE_N;
__shared__ float sA[TILE_M][BK];
__shared__ float sB[BK][TILE_N];
float accum[TILE_M_PER_THREAD][TILE_N_PER_THREAD] = {0};
for (int k = 0; k < K; k += BK) {
// Load A tile: TILE_M rows x BK cols
// Each thread loads some elements
for (int i = threadIdx.x; i < TILE_M * BK; i += blockDim.x) {
int a_row = i / BK;
int a_col = i % BK;
int globalRow = rowStart + a_row;
int globalK = k + a_col;
if (globalRow < M && globalK < K) {
sA[a_row][a_col] = A[globalRow * K + globalK];
} else {
sA[a_row][a_col] = 0.0f;
}
}
// Load B tile: BK rows x TILE_N cols
for (int i = threadIdx.x; i < BK * TILE_N; i += blockDim.x) {
int b_row = i / TILE_N;
int b_col = i % TILE_N;
int globalK = k + b_row;
int globalCol = colStart + b_col;
if (globalK < K && globalCol < N) {
sB[b_row][b_col] = B[globalK * N + globalCol];
} else {
sB[b_row][b_col] = 0.0f;
}
}
__syncthreads();
// Compute
for (int bk = 0; bk < BK; bk++) {
for each element assigned to thread:
accum += sA[local_row][bk] * sB[bk][local_col];
}
__syncthreads();
}
// Write C
}
```
Wait, this generic approach with each thread loading `i += blockDim.x` elements might not be optimal because the threads in a warp could be loading elements that are not coalesced well.
For A loading with the loop approach:
Thread t loads A elements t, t+blockDim.x, t+2*blockDim.x, etc.
With blockDim.x = 256 and TILE_M=4, BK=16, TILE_M*BK=64.
Only 64 elements total, so thread t loads element t if t < 64, else nothing.
Thread 0: A[rowStart][k]
Thread 1: A[rowStart][k+1] (if BK>1)
...
Thread 15: A[rowStart+0][k+15]
Thread 16: A[rowStart+1][k+0] (since (16 / BK) = 1 if BK=16)
Wait: a_row = i / BK, a_col = i % BK.
Thread 16: a_row = 1, a_col = 0. Address = A[(rowStart+1)*K + k].
Thread 17: a_row = 1, a_col = 1. Address = A[(rowStart+1)*K + k + 1].
In warp 0 (threads 0..31):
Threads 0..15: row 0, cols 0..15. Addresses spaced by 1. Coalesced!
Threads 16..31: row 1, cols 0..15. Addresses spaced by 1 within row 1, but row 1 is K away from row 0.
Actually, in a warp, threads 0..15 and 16..31 might be executing simultaneously. The memory unit sees addresses: A[row0][k..k+15] and A[row1][k..k+15]. These are two separate 64-byte regions (16 floats = 64 bytes each), spaced 2MB apart. So the warp issues 2 transactions.
This is actually quite good! It means 16 threads loading row 0 coalesced into 1-2 transactions, and 16 threads loading row 1 coalesced into another 1-2 transactions. Total 4 transactions for the warp.
With 4 rows * 16 cols = 64 elements, and warp handling 32 elements (2 rows), we need 2 warps to load all A elements. Total A transactions: ~8.
For B loading with the loop approach:
BK * TILE_N = 16 * 64 = 1024 elements.
Threads 0..255 each load 1 element (since 1024/256 = 4, actually each thread loads 4 elements).
Wait: `for (int i = threadIdx.x; i < BK * TILE_N; i += blockDim.x)` with blockDim.x=256 and BK*TILE_N=1024.
Thread t loads elements t, t+256, t+512, t+768.
For thread t:
b_row = t / TILE_N = t / 64
b_col = t % 64
Thread 0: b_row=0, b_col=0. Address: B[k][colStart]
Thread 1: b_row=0, b_col=1. Address: B[k][colStart+1]
...
Thread 63: b_row=0, b_col=63. Address: B[k][colStart+63]
Thread 64: b_row=1, b_col=0. Address: B[k+1][colStart]
In warp 0 (threads 0..31): all loading B[k][colStart:colStart+31]. Coalesced!
In warp 1 (threads 32..63): all loading B[k][colStart+32:colStart+63]. Coalesced!
In warp 2 (threads 64..95): some load B[k][colStart+64..] (but TILE_N=64, so colStart+64 is out of bounds if b_col wraps). Wait, b_col = t % 64, so thread 64 has b_col=0, b_row=1.
Warp 2 (threads 64..95): b_row = 1 for all (64/64=1 to 95/64=1), b_col = 0..31.
Addresses: B[k+1][colStart:colStart+31]. Coalesced!
Warp 3 (threads 96..127): b_row = 1, b_col = 32..63. Coalesced!
And so on. Each warp loads a contiguous slice of B. Perfect coalescing!
So the generic loop approach is actually quite good for both A and B.
Now for the compute part. With 256 threads and 256 elements (4*64), each thread handles 1 element.
Thread t handles element (t / TILE_N, t % TILE_N) = (t / 64, t % 64).
But wait, TILE_M = 4, so t / 64 gives 0, 1, 2, 3 for t in 0..255. And t % 64 gives 0..63.
So thread t computes C[rowStart + t/64][colStart + t%64].
For the compute loop:
for (int bk = 0; bk < BK; bk++) {
accum[0] += sA[t/64][bk] * sB[bk][t%64];
}
Each thread does BK FMAs per outer K iteration.
With BK=16, each thread does 16 FMAs per outer iteration.
The sA access is broadcast within groups of 64 threads (same row).
The sB access is strided by 1 (consecutive threads read consecutive columns), which maps to consecutive shared memory banks. No conflict.
This looks great!
But I'm concerned about 1 FMA per inner loop iteration causing instruction overhead. Let me see if we can unroll.
With BK=16, we can pragma unroll:
```cuda
#pragma unroll
for (int bk = 0; bk < BK; bk++) {
accum += sA[row][bk] * sB[bk][col];
}
```
The compiler should unroll this, generating 16 FMAs with good ILP.
And with outer loop iterations = K/BK = 32768, there are many iterations but the inner loop is fully unrolled.
One concern: 256 threads per block = 8 warps. Since each warp might execute at different speeds, shared memory sync points (__syncthreads) every BK steps might be a bottleneck.
Can we increase BK without increasing shared memory too much?
BK=32:
- sA: 4 * 32 = 128 floats = 512 bytes
- sB: 32 * 64 = 2048 floats = 8192 bytes
- Total: 8704 bytes. Still small.
Compute: 32 FMAs per thread per outer iteration.
Outer iterations: 524288 / 32 = 16384.
Total FMAs per thread: 16384 * 32 = 524288. Same.
But fewer __syncthreads() calls.
Let me try BK=64:
- sA: 4 * 64 = 256 floats = 1024 bytes
- sB: 64 * 64 = 4096 floats = 16384 bytes
- Total: 17408 bytes. Still well within shared memory limits (227KB max per block).
Compute: 64 FMAs per thread per outer iteration.
Outer iterations: 524288 / 64 = 8192.
Total FMAs: 8192 * 64 = 524288 per thread.
With 64 FMAs unrolled, the compiler might struggle if register pressure is high. But each thread only has 1 accumulator. So sA and sB accessed multiple times.
Actually, if BK=64 and we unroll the inner loop 64 times, that's 64 FMA instructions + 64 loads from shared memory. With 1 accumulator, the FMAs are dependent (each accum += ... depends on previous accum). To improve ILP, we could split BK into two or more accumulators.
Wait, with dependent FMAs, the throughput is limited by the FMA latency (about 4 cycles on H100). If we have multiple accumulators, we can interleave independent FMAs.
But each thread computes 1 C element. The FMA is:
accum = accum + a * b
This is a single dependency chain. With BK=64, all 64 FMAs depend on each other.
Latency of FMA is ~4 cycles. Throughput is 1 FMA per cycle per thread (actually per CUDA core, but let's say per thread).
So 64 dependent FMAs take ~64 * 4 = 256 cycles = 64 warps of latency.
But if a warp has 32 threads, and the GPU has many warps, this is hidden. Each SM on H100 can execute multiple warps. 8 warps per block * let's say 4 blocks per SM = 32 warps per SM. With enough warps, the latency is hidden.
But we can improve by having threads compute multiple elements of C, creating independent FMA chains.
For example, if each thread computes 2 C elements:
- accum0 = sA[row0][bk] * sB[bk][col]
- accum1 = sA[row1][bk] * sB[bk][col]
Wait, but then we need sA[row0] and sA[row1], which are different. And sB[bk][col] is the same.
Actually, what if each thread computes 2 elements in the same row but 2 different columns?
- accum0 for col = c
- accum1 for col = c + TILE_N/2 or something
Then:
for bk:
a = sA[row][bk]
b0 = sB[bk][c0]
b1 = sB[bk][c1]
accum0 += a * b0
accum1 += a * b1
Now accum0 and accum1 are independent! Two dependency chains.
With BK=32, we have 32 independent FMA pairs.
This might improve ILP. But we also need more registers for accum0, accum1, b0, b1.
On H100 with 64K registers per SM and 255 max per thread, we have plenty.
Let me think about this more. If each thread computes 2 elements, we need half the threads per block. So with TILE_M=4, TILE_N=64, elements=256, we'd need 128 threads.
But fewer threads per block might reduce occupancy if we can't fit enough blocks per SM. Let's check:
Block size: 128 threads = 4 warps
Shared memory: ~17KB for BK=64
Registers per thread: ~16-20 for 2 accumulators
Total registers per block: 128 * 20 = 2560 registers. Well within 64K.
Can we fit multiple blocks per SM?
- Warps per block: 4
- Max warps per SM: let's say 32 (theoretical max is 64 on H100, but practically limited).
- So we could fit 8 blocks per SM = 32 warps.
- Total blocks: 256 (for TileM=4, TileN=64). At 8 blocks per SM and 132 SMs = 1056 blocks capacity. We only have 256 blocks, so SMs would be 25% utilized.
That's bad. So we need either more blocks or larger blocks.
If we use 256 threads per block and each thread computes 1 element:
- 256 blocks, each with 8 warps
- SMs can hold maybe 4 blocks each = 32 warps per SM
- Total capacity: 132 * 4 = 528 blocks. We need 256. Good, each SM runs ~2 blocks.
If each SM runs 2 blocks concurrently, that's 16 warps per SM. H100 has 32 warp slots per SM? Actually, H100 has 64 warps per SM (I think). Let me check... On Ampere, maximum is 64 warps per SM. On Hopper, it's also quite high.
Actually, I think the constraint is:
- Max 32 blocks per SM
- Max 64 warps per SM
- Max 1024 threads per SM
So with 256 threads per block = 8 warps, we can have at most 8 blocks per SM (64 warps / 8 = 8).
For 256 blocks on 132 SMs, we need ~2 blocks per SM. That's only 16 warps per SM, well below the 64 warp limit.
Can we fit more blocks? The shared memory per block is ~17KB. SM shared memory is 227KB. 227 / 17 ≈ 13 blocks. So shared memory isn't the limit.
With 2 blocks per SM = 16 warps, and each warp doing 64 dependent FMAs, the latency might not be fully hidden. We want more warps per SM.
To increase warps, we could:
1. Use more blocks (smaller tiles)
2. Use larger BK (more compute per block, but same warps)
If TileM=2, TileN=64:
- num_row_blocks = 128
- num_col_blocks = 4
- Total blocks = 512
- 512 / 132 ≈ 4 blocks per SM = 32 warps per SM. Better!
But TileM=2 means A tile is 2 rows x BK cols. A loading becomes even more scattered (only 2 rows but same issue).
If TileM=2, TileN=32:
- num_row_blocks = 128
- num_col_blocks = 8
- Total blocks = 1024
- 1024 / 132 ≈ 8 blocks per SM = 64 warps per SM. That's great!
Shared memory: sA[2][64] + sB[64][32] = 128 + 2048 = 2176 floats = 8704 bytes. Still small.
Each block computes 2*32 = 64 elements. With 256 threads, most threads are idle (only 64 active).
Let's use 64 threads per block instead.
64 threads = 2 warps per block.
1024 blocks * 2 warps = 2048 warps.
132 SMs * 64 warps/SM = 8448 warp capacity.
So we're using 2048/8448 = 24% of warp capacity. That's bad!
Hmm, but we need to balance block count with threads per block.
Actually, the key metric is total active warps. With 1024 blocks of 64 threads (2 warps) = 2048 warps. That's quite a few. But 8448 is the max across all SMs, so we're at 24% utilization.
Maybe 2048 warps is still enough to hide latency? For memory-bound kernels, you typically need 20+ warps per SM to hide latency. With 2048 warps / 132 SMs = 15.5 warps per SM on average. That might be marginal.
Let's try to get more warps:
TileM=4, TileN=32, BK=64:
- num_row_blocks = 64
- num_col_blocks = 8
- Total blocks = 512
- Elements per block = 128
- Use 128 threads per block (4 warps)
- Total warps = 512 * 4 = 2048 warps.
- 2048 / 132 = 15.5 warps/SM. Still marginal.
TileM=4, TileN=64, BK=64:
- num_row_blocks = 64
- num_col_blocks = 4
- Total blocks = 256
- Elements per block = 256
- Use 256 threads per block (8 warps)
- Total warps = 2048.
Same total warps!
Hmm, no matter how we split, the product blocks * warps_per_block = total warps, and total warps = (M * N) / elements_per_thread.
If each thread computes 1 element, total warps = (256*256) / 32 = 2048 warps. Always!
So to increase total warps, we need threads to compute FRACTIONAL elements? No, that doesn't make sense. Or we need more total threads, but M*N is fixed.
Wait, the total number of independent computations is M*N = 65536. At 1 element per thread, we need 65536 threads. Each warp has 32 threads, so 2048 warps. That's fixed.
The question is: can 2048 warps hide the latency of H100? With 132 SMs and 2048 warps, that's ~15.5 warps per SM. The rule of thumb is that you need at least 10-20 warps per SM to hide memory latency. For FMA latency, maybe 5-10.
So 15.5 should be just enough, maybe on the edge.
But H100 supports up to 64 warps per SM, and we want to maximize occupancy. But with M*N=65536, we can have at most 65536 threads. If we use 1024 threads per block, that's 64 blocks. 64 blocks on 132 SMs means almost half the SMs are idle!
To keep all SMs busy, we need at least 132 blocks. With 65536 threads, 132 blocks = 496 threads per block. But block sizes are typically multiples of 32, so maybe 512 threads per block = 128 blocks.
Wait, can we have more than M*N threads? Yes! We can have threads compute 0 elements or do redundant work. But that's wasteful.
Actually, we CAN have more threads by reducing work per thread, but we can't increase total threads beyond... well, we CAN launch more blocks where some threads don't compute anything, but that's not useful.
Alternative idea: have each thread compute a partial sum for multiple output elements, and accumulate in registers.
Wait, what if we use more threads by assigning each output element to MULTIPLE threads that each compute a partial sum over a subset of K, then reduce?
For example, have 2 threads compute C[0][0], one over k=0..K/2-1 and the other over k=K/2..K-1, then sum them.
This would double the total warps to 4096, giving ~31 warps per SM. Much better!
Is this worth it? It adds a reduction step, but that's minimal (written to C, then atomic add or another kernel).
Actually, we can do warp-level or block-level reduction.
But for this to help, we need the bottleneck to be latency, not throughput. With 2x more warps, each doing half the work, throughput stays the same but latency hiding improves.
Actually, if the kernel is compute-bound, doubling warps doesn't help because the FMA units are already saturated. If it's latency-bound, it does help.
Given K is very large and M, N are small, the kernel might be compute-bound. Let me check:
FMAs = 68.7 GFLOPs
H100 FP32 TFLOPS = 67 TFLOPS
Time if compute bound = 68.7 / 67 ≈ 1.025 ms
Memory traffic:
A: 512 MB
B: 512 MB
Total memory: ~1GB (read once each with good tiling)
Bandwidth: 3.35 TB/s
Time if memory bound = 1 / 3.35 ≈ 0.3 ms
So the theoretical compute time is longer, meaning it's compute-bound! (1ms > 0.3ms)
If it's compute-bound, then adding more warps doesn't help unless we're not saturating the FMA units. Are we saturating the FMA units?
H100 has 132 SMs. Each SM has 128 FP32 CUDA cores (I think). Total FP32 cores = 132 * 128 = 16,896.
Peak FP32 TFLOPS = 67 TFLOPS = 67,000 GFLOPS.
At 1.4 GHz clock: 16,896 cores * 1.4 GHz * 2 FMAs per core per cycle (FMA counts as 2 ops) = 47,308 GFLOPS. Hmm, that doesn't match 67 TFLOPS.
Let me recalculate. H100 SXM5: 132 SMs * 128 FP32 units * ~1.98 GHz * 1 FMA/cycle = 33,400 GFLOPS? That doesn't match either.
Actually, H100 has tensor cores which provide much higher throughput. But for pure CUDA cores, FP32 might be lower.
Actually, the spec says 67 TFLOPS for FP32 (non-tensor). For H100, I believe each SM has 128 FP32 units, and with a high clock, you can get ~67 TFLOPS.
Let me use 67 TFLOPS as given.
With 68.7 GFLOPs total work, the theoretical minimum time on pure FMA is 68.7/67,000 = 1.025 ms.
If we have 2048 warps doing the work, can we sustain 67 TFLOPS?
Each warp has 32 threads. If each thread does 1 FMA per 4 cycles (latency), then each warp does 32 FMAs per 4 cycles = 8 FMAs per cycle.
Per SM, with N warps, you can issue N * 8 FMAs per 4 cycles if latency is hidden.
To saturate 128 FP32 units per SM, you need 128 FMAs per cycle.
With 8 FMAs per warp per 4 cycles = 2 FMAs per warp per cycle.
So you need 64 warps per SM to saturate the FMA units via latency hiding.
64 warps per SM * 132 SMs = 8448 warps max.
We have 2048 warps, which is 2048/8448 = 24% of theoretical warp capacity.
At 24% warp capacity, FMA throughput might be ~24% * 67 = 16 TFLOPS.
Then time = 68.7 / 16 = 4.3 ms.
Hmm, that's much slower. But this assumes pure latency-limited execution with no overlap. On H100, there might be more instruction-level parallelism.
Actually, I made an error. When I said each thread does 1 FMA per 4 cycles, that's for a dependent chain. But modern GPUs can issue multiple independent instructions per cycle. If the compiler can find independent instructions within a thread, throughput improves.
With unrolled loops, the compiler might generate:
fma r0, a0, b0, r0
fma r0, a1, b1, r0
...
If there's no ILP (r0 is always the destination), then we truly need multiple warps to hide latency.
But we can introduce ILP by having each thread compute multiple accumulators:
```cuda
float accum0 = 0, accum1 = 0;
#pragma unroll
for (int i = 0; i < BK; i+=2) {
float a0 = sA[row][i];
float a1 = sA[row][i+1];
float b0 = sB[i][col];
float b1 = sB[i+1][col];
accum0 += a0 * b0;
accum1 += a1 * b1;
}
accum0 += accum1;
```
Now accum0 and accum1 are independent. The compiler can interleave the two FMAs.
With BK=64 and unroll by 2, we have 32 independent pairs, giving 2-way ILP.
With 4 accumulators, 4-way ILP, etc.
On H100, you need about 4-way ILP to saturate FMA units from a single warp. But we have multiple warps, so combined ILP + thread-level parallelism might suffice.
Actually, let me reconsider if cuBLAS is already doing this perfectly. cuBLAS likely uses tensor cores for this shape. 256x256x524288 might actually be a good case for tensor cores if properly tiled.
Wait, tensor cores operate on small tiles. For example, m16n8k16 means they compute a 16x8 tile with K=16 inner dimension. For K=524288, you'd need 524288/16 = 32768 tensor core operations per 16x8 tile.
With 256x256 output, that's (256/16) * (256/8) = 16 * 32 = 512 tiles of m16n8.
Total tensor core ops = 512 * 32768 = 16.7 million tensor core ops.
Each tensor core op on H100 might take a few cycles.
Actually, this seems like exactly what tensor cores are good for. And cuBLAS uses tensor cores for almost all GEMM shapes on H100.
If cuBLAS is already using tensor cores, beating it with a custom CUDA kernel is going to be extremely difficult. Unless cuBLAS has some overhead (workspace, algorithm selection) that we can avoid.
But wait, torch.matmul calls cuBLAS. And for FP32 on H100, cuBLAS might actually use FP32 tensor cores (TF32) with automatic type conversion, or it might use CUDA cores.
Let me think about whether TF32 tensor cores can be used. TF32 has 10-bit mantissa vs FP32's 23-bit. The reference uses FP32 A and B, and expects FP32 output. cuBLAS in TF32 mode would give slightly different results. But PyTorch might enable TF32 by default.
Actually, the problem says "Produce numerically equivalent outputs at fp32 precision." This means we need exact FP32 output, not TF32. If cuBLAS uses TF32 under the hood, torch.matmul might still do pure FP32 depending on settings.
Wait, on H100, the default for torch.matmul on FP32 inputs... PyTorch might use TF32 by default with Ampere/Hopper unless disabled. But the reference code does `torch.matmul(A, B)` with A, B as FP32. By default, PyTorch enables TF32 on Ampere+ for matmul.
If TF32 is enabled in the reference, then the output is at TF32 precision, not pure FP32. But the problem says "Produce numerically equivalent outputs at fp32 precision." This might mean the reference is using pure FP32, or that our output must match whatever the reference produces (even if TF32).
The tolerance is atol=rtol=1e-4 for FP32. TF32 precision is about 1e-3 (10 mantissa bits ~ 3 decimal digits). FP32 is about 1e-7. So 1e-4 tolerance could accommodate TF32 or FP32 differences.
I think I should write the kernel for pure FP32 using CUDA cores, and see if it can beat cuBLAS/whatever PyTorch uses.
Actually, let me just write a simple kernel first and test its performance. Then iterate.
Let me write a kernel with the following design:
- Block size: 256 threads (8 warps)
- TileM=4, TileN=64
- BK=64
- Each thread computes 1 element
- Use shared memory for A and B tiles
- Vectorized loads where possible
Actually, let me think about vectorized loads for A and B.
For B: B[k+k_off][colStart + col_local]. With block threads loading B tile of BK x TileN.
If TileN=64 and BK=64, B tile is 64x64 = 4096 floats.
256 threads, each loads 4096/256 = 16 floats = 4 float4s.
Thread t:
- We want to load 4 float4s = 16 floats.
- Map t to a starting position in the BK x TileN tile.
- t = b_row * 64 + b_col, where b_row in 0..63, b_col in 0..63.
- But 256 threads * 16 floats = 4096, so each thread loads 16 consecutive floats? Or 4 float4s spread out?
To coalesce: have thread t load 4 float4s from B[k + t/64][colStart + (t%64)*4] for 4 consecutive rows?
Actually, simpler: thread t in the block handles a chunk.
Let me just use the loop `for (int i = threadIdx.x; i < BK * TILE_N; i += blockDim.x)` without vectorization, and let the compiler handle it. For first attempt, simplicity wins.
Wait, I should use float4 for B because B tile row is 64 floats which is 16 float4s. 256 threads loading 16 float4s per row * 64 rows... no, BK * TILE_N floats, mapped in row-major.
If thread t loads element i=t, then i maps to (i/TILE_N, i%TILE_N). For t=0: (0,0), t=1: (0,1), ..., t=63: (0,63), t=64: (1,0).
In warp 0 (t=0..31): all load B[k+0][colStart+0] to B[k+0][colStart+31]. Coalesced.
Warp 1 (t=32..63): all load B[k+0][colStart+32] to B[k+0][colStart+63]. Coalesced.
Warp 2 (t=64..95): all load B[k+1][colStart+0] to B[k+1][colStart+31]. Coalesced.
This is great coalescing even without vectorization. The hardware will coalesce these into 128-byte transactions. In fact, using float4 might not help much because the coalescing already groups 32 threads' accesses.
For A: thread t loads A[rowStart + t/BK][k + t%BK]. For t < 256 and BK=64, TILE_M=4:
t=0: A[0][k], t=1: A[0][k+1], ..., t=63: A[0][k+63], t=64: A[1][k], etc.
Warp 0: all load A[rowStart][k:k+31]. Coalesced.
Warp 1: all load A[rowStart][k+32:k+63]. Coalesced.
Warp 2: all load A[rowStart+1][k:k+31]. Coalesced.
Also great coalescing. Except that warp 2's load is K floats away from warp 0's. But each warp's own load is coalesced.
So for the first attempt, I'll skip explicit vectorization and just use simple scalar loads with the loop approach.
Let me also think about whether I should try different tile sizes. But let's benchmark first.
Actually, looking back at the problem: K = 131072 * 4 = 524288.
Let me write the kernel code carefully.
```cuda
#include <cuda_runtime.h>
#define TILE_M 4
#define TILE_N 64
#define BK 64
__global__ void matmul_kernel(const float* __restrict__ A, const float* __restrict__ B, float* __restrict__ C, int M, int N, int K) {
int blockRow = blockIdx.y;
int blockCol = blockIdx.x;
int rowStart = blockRow * TILE_M;
int colStart = blockCol * TILE_N;
__shared__ float sA[TILE_M][BK];
__shared__ float sB[BK][TILE_N];
int threadId = threadIdx.x;
int row = threadId / TILE_N;
int col = threadId % TILE_N;
float accum = 0.0f;
for (int k = 0; k < K; k += BK) {
// Load A tile
int aElems = TILE_M * BK;
for (int i = threadId; i < aElems; i += blockDim.x) {
int aRow = i / BK;
int aCol = i % BK;
int globalRow = rowStart + aRow;
int globalK = k + aCol;
sA[aRow][aCol] = (globalRow < M && globalK < K) ? A[globalRow * K + globalK] : 0.0f;
}
// Load B tile
int bElems = BK * TILE_N;
for (int i = threadId; i < bElems; i += blockDim.x) {
int bRow = i / TILE_N;
int bCol = i % TILE_N;
int globalK = k + bRow;
int globalCol = colStart + bCol;
sB[bRow][bCol] = (globalK < K && globalCol < N) ? B[globalK * N + globalCol] : 0.0f;
}
__syncthreads();
// Compute
#pragma unroll
for (int bk = 0; bk < BK; bk++) {
if (row < TILE_M && col < TILE_N) {
accum += sA[row][bk] * sB[bk][col];
}
}
__syncthreads();
}
// Write result
if (row < TILE_M && col < TILE_N) {
int globalRow = rowStart + row;
int globalCol = colStart + col;
if (globalRow < M && globalCol < N) {
C[globalRow * N + globalCol] = accum;
}
}
}
```
Wait, with blockDim.x = 256 and TILE_M=4, TILE_N=64, the mapping `row = threadId / TILE_N` gives:
threadId 0..63: row=0
threadId 64..127: row=1
threadId 128..191: row=2
threadId 192..255: row=3
And col = threadId % 64.
So threads 0..63 compute C[rowStart][colStart:colStart+63].
Threads 64..127 compute C[rowStart+1][colStart:colStart+63], etc.
This means each group of 64 threads computes one full row of the tile.
But wait, with sA[row][bk], threads 0..63 all read sA[0][bk], which is a broadcast in shared memory (no bank conflict).
And threads 0..63 read sB[bk][0..63], which are consecutive shared memory locations, mapping to consecutive banks (no conflict).
Similarly for the other groups. So shared memory is used efficiently.
But with 256 threads and only 4*64=256 active compute threads, every thread computes exactly 1 element. Good.
Now, the issue with row = threadId / TILE_N: if TILE_N doesn't divide blockDim.x nicely... here 64 divides 256 so we get exactly 4 groups of 64. Perfect.
But what about threads with threadId >= TILE_M * TILE_N? TILE_M * TILE_N = 256, and blockDim.x = 256. So all threads are active.
Actually, if we used a smaller block size like 128, then threads 0..63 compute row 0, 64..127 compute row 1, and we'd need another block iteration or more complex mapping for rows 2 and 3.
With 256 threads and 256 elements, it's a perfect fit.
However, I have a concern about the launch configuration.
Grid = ((N + TILE_N - 1) / TILE_N, (M + TILE_M - 1) / TILE_M) = (4, 64) for M=256, N=256.
That's 256 blocks. But blockIdx.y = 64, blockIdx.x = 4.
Actually, in CUDA, gridDim.x is typically the fastest-varying dimension. If we map columns to x and rows to y:
gridDim.x = ceil(N / TILE_N) = ceil(256 / 64) = 4
gridDim.y = ceil(M / TILE_M) = ceil(256 / 4) = 64
So we have 4 column blocks and 64 row blocks = 256 total blocks.
In the kernel, blockCol = blockIdx.x, blockRow = blockIdx.y.
colStart = blockCol * TILE_N = 0, 64, 128, 192
rowStart = blockRow * TILE_M = 0, 4, 8, ..., 252
This gives good spatial locality for C writes: blocks with consecutive blockRow write to consecutive rows. And within a row, consecutive column blocks write to consecutive column ranges.
But for B reads: blocks with the same blockCol but different blockRow read DIFFERENT B slices? No, they all read the same B[k:k+BK][colStart:colStart+TILE_N]. So blocks in the same column read the same B data at each K step. That's good for L2 cache.
For A reads: blocks with the same blockRow but different blockCol read the same A tile? No, different blockCol means different column output, but A reads depend only on rowStart and K. So blocks in the same row block (same blockRow) but different columns all read the same A tile at each K step. But different row blocks read different A tiles.
So 64 row blocks reading 64 different A tiles means at K step k, all 64 A tiles are read. Each A tile is 4*64 = 256 floats = 1KB. A tile for row 0 and A tile for row 4 are 4*K floats apart = 8MB apart. So they're completely separate in memory.
But 64 different A tiles all being read simultaneously might keep the memory system busy with many independent requests. This can actually be good for bandwidth saturation if the memory controller can handle many requests in parallel.
Now for B: 4 column blocks. At K step k, column block 0 reads B[k:k+64][0:64], block 1 reads B[k:k+64][64:128], etc. These are contiguous in memory! So ideally, the memory controller can service them with few transactions if they're in the same memory pages.
But from different blocks on different SMs, they arrive at different times. Still, if roughly synchronized, L2 cache might capture some locality.
Actually, let me reconsider if we can fuse the column blocks into one larger block, reducing B redundancy concerns. If TILE_N=256, then we have only 1 column block. But then each block computes 4*256=1024 elements, and we'd need 256 threads with each computing 4 elements.
With TILE_M=4, TILE_N=256, BK=64:
- sB = 64 * 256 * 4 = 65536 bytes = 64KB. Still within shared memory limit.
- Each block computes 1024 elements. With 256 threads, each thread computes 4 elements.
- We could arrange: thread t computes C[rowStart + t/64][colStart + (t%64)*4 : colStart + (t%64)*4 + 4]? No, that's not good.
Let's say thread t computes 1 element. Then we need 1024 threads, which is too many.
Alternatively, have thread t compute 4 elements in the same row, consecutive columns.
row = t / 64 (for t in 0..255, row in 0..3)
base_col = (t % 64) * 4
So thread t computes C[row][base_col], C[row][base_col+1], C[row][base_col+2], C[row][base_col+3].
With 4 accumulators:
accum0, accum1, accum2, accum3.
For K step:
for bk in 0..63:
a = sA[row][bk]
b0 = sB[bk][base_col]
b1 = sB[bk][base_col+1]
b2 = sB[bk][base_col+2]
b3 = sB[bk][base_col+3]
accum0 += a * b0
accum1 += a * b1
accum2 += a * b2
accum3 += a * b3
This gives 4 independent accumulators! 4-way ILP per thread.
And 64 row blocks, 1 column block. Total blocks = 64.
64 blocks on 132 SMs = 0.5 blocks per SM. That's terrible! Most SMs would be idle.
So we need more blocks. TileN=64 gives 4 column blocks and 64 row blocks = 256 blocks. That's better.
Alternatively, we can have multiple "panels" per block. But that complicates the kernel.
Wait, what if we use TileM=1 and TileN=256?
- Row blocks = 256
- Col blocks = 1
- Total blocks = 256
Same total blocks. But TileM=1, BK=64. sA = 64 floats. sB = 64 * 256 = 16384 floats = 64KB.
Each block computes 256 elements. 256 threads, each computing 1 element.
For compute: thread j computes C[rowStart][colStart + j] = C[row][j].
accum = 0
for bk in 0..63:
accum += sA[0][bk] * sB[bk][j]
Each thread does 64 FMAs with 1 accumulator (dependent chain). Not great.
If we use threads computing multiple elements in different K ranges:
Thread j computes C[row][j] from K chunks. No, we already established that has a reduce step.
Hmm, let me just go with TileM=4, TileN=64, BK=64 for now and see the performance.
Actually, I realized there's another concern. In the kernel I wrote, I have `if (row < TILE_M && col < TILE_N)` in the compute loop. But with our specific values, row is always < 4 and col always < 64 for all threads in a 256-thread block. Wait:
threadId=255: row = 255/64 = 3 (since integer division). col = 255%64 = 63.
So row is 0,1,2,3 and col is 0..63. All threads have row < 4 and col < 64.
So the branch is always taken and can be removed. But for generality, I might keep it.
Actually, I should remove it to avoid unnecessary branching in the inner loop.
Let me write a cleaner version:
```cuda
__global__ void matmul_kernel(const float* __restrict__ A, const float* __restrict__ B, float* __restrict__ C, int M, int N, int K) {
const int blockRow = blockIdx.y;
const int blockCol = blockIdx.x;
const int rowStart = blockRow * TILE_M;
const int colStart = blockCol * TILE_N;
const int tid = threadIdx.x;
__shared__ float sA[TILE_M][BK];
__shared__ float sB[BK][TILE_N];
const int row = tid / TILE_N;
const int col = tid % TILE_N;
float accum = 0.0f;
for (int k = 0; k < K; k += BK) {
// Load A
#pragma unroll
for (int i = tid; i < TILE_M * BK; i += blockDim.x) {
int aRow = i / BK;
int aCol = i % BK;
sA[aRow][aCol] = A[(rowStart + aRow) * K + k + aCol];
}
// Load B
#pragma unroll
for (int i = tid; i < BK * TILE_N; i += blockDim.x) {
int bRow = i / TILE_N;
int bCol = i % TILE_N;
sB[bRow][bCol] = B[(k + bRow) * N + colStart + bCol];
}
__syncthreads();
// Compute
#pragma unroll
for (int bk = 0; bk < BK; bk++) {
accum += sA[row][bk] * sB[bk][col];
}
__syncthreads();
}
C[(rowStart + row) * N + colStart + col] = accum;
}
```
Wait, I should also think about launch bounds. With 256 threads and shared memory ~17KB, the occupancy should be decent. But let me calculate exact shared memory:
sA = 4 * 64 = 256 floats = 1024 bytes
sB = 64 * 64 = 4096 floats = 16384 bytes
Total = 17408 bytes.
At 17408 bytes per block, and 227KB per SM, we can fit 227KB / 17KB ≈ 13 blocks per SM.
With 256 threads = 8 warps per block, max 32 blocks per SM (but limited by warps to 64/8=8 blocks).
So the shared memory limit is not binding; the warp limit is. Max 8 blocks per SM, each with 8 warps = 64 warps (max).
For 256 blocks on 132 SMs, average = 1.94 blocks per SM. That's well below the max of 8.
Each SM has 1.94 * 8 = 15.5 warps.
If we could get 4 blocks per SM, that would be 32 warps, which is better for latency hiding. To get 4 blocks per SM, we need 4 * 132 = 528 blocks. But we only have 256.
Hmm. What if TileM=2, TileN=64?
- Row blocks = 128
- Col blocks = 4
- Total = 512 blocks
- Average = 3.88 blocks per SM = 31 warps per SM. Much better!
But each block computes 2*64 = 128 elements.
With 256 threads, only 128 threads are active (half the threads compute valid elements).
We can use 128 threads per block instead.
Total threads = 512 * 128 = 65536. Same total threads.
Total warps = 512 * 4 = 2048 warps. Same total warps.
Wait, the total number of warps is ALWAYS 2048 if every thread computes 1 element! No matter how we split into blocks. Because M*N = 65536 elements, and each thread computes 1, we need 65536 threads = 2048 warps.
So changing the block configuration doesn't change total warps!
Ah, but what about if some threads compute 0 elements? In my example with TileM=2, TileN=64, and 256 threads per block, threads with row>=2 are idle but still consume a warp slot. That means we have more warps but some are partially idle.
Actually, a warp has 32 threads. If threads 64..255 are idle, then warps 2..7 have all threads idle. CUDA still schedules these warps, but they do nothing. So we still pay the warp count but get no work from them. This is bad.
So threads 128..255 should not exist. Block size should be 128.
With block size 128:
- 512 blocks * 128 threads = 65536 threads.
- Warps = 512 * 4 = 2048 warps. Same total, but all warps are fully utilized.
- Blocks per SM = 512/132 ≈ 3.88.
- Warps per SM = 3.88 * 4 = 15.5.
Same warps per SM! So TileM=2 with 128 threads gives the same compute density as TileM=4 with 256 threads.
In fact, since total warps is always 2048 (for 1 element per thread), and we have 132 SMs, we always get ~15.5 warps per SM.
To increase warps per SM, we need MORE threads. That means each thread must compute less than 1 element, which is impossible. OR, we need some threads to compute partial results (e.g., over a subset of K), then accumulate.
Ah! That's the key insight I missed earlier. If we have threads compute partial sums over different K ranges and add them up, we increase total thread count.
For example:
- Each output element C[m][n] is computed by 2 threads.
- Thread 1 sums k=0..K/2-1.
- Thread 2 sums k=K/2..K-1.
- Final result = thread1_result + thread2_result.
This requires 2x the threads (131072 threads = 4096 warps), giving ~31 warps per SM. Better!
But how to reduce? We can use atomicAdd, or a second reduction kernel, or write to a workspace and sum.
atomicAdd on FP32 to global memory is pretty fast on H100. With 65536 output elements and 2 partial sums each, that's 131072 atomic adds. atomicAdd throughput on H100 is very high (hundreds of billions per second), so this overhead is minimal.
Alternatively, we can have each block reduce its own partial sums. But since partial sums are computed by different blocks, we need global reduction.
We could also write to an expanded C buffer of size M*N*num_partitions and then reduce along the partition dimension. But that requires extra memory.
Let's try the atomicAdd approach.
But wait, if we evenly split K, each thread does half the FMAs. With double the threads, total FMAs stay the same. But execution can be faster if the bottleneck was warp latency hiding.
Actually, for a compute-bound kernel, splitting K doesn't change total FLOPs, and if the FMA units are already saturated, doubling threads doesn't help. But with only 15.5 warps per SM, the FMA units are likely NOT saturated. So adding more warps should help.
Let me compare:
- Without splitting: 2048 warps * (524288 FMAs / warp) / some_rate
- With splitting: 4096 warps * (262144 FMAs / warp) / some_rate
The rate at which a warp executes FMAs is limited by FMA latency (4 cycles) and the instruction issue rate. With multiple warps, the SM switches between them. If there are enough warps to always have a ready FMA instruction, the FMA units are fully utilized.
On H100, each SM can issue FMA instructions for many warps per cycle. The exact number depends on the architecture. For sustained FMA throughput, we need enough warps to cover latency. If 15.5 warps is below the threshold, then 31 warps should be better.
Let me look for similar benchmarks or papers. For Ampere/Hopper, a common rule of thumb is that you need ~16-32 warps per SM to saturate arithmetic throughput, depending on instruction mix. With pure FMA and no memory operations, maybe fewer warps suffice because the compiler can use ILP.
But our kernel has a memory load from shared memory before each FMA. That adds latency.
Actually, let me think about this differently. In the compute loop:
```cuda
#pragma unroll
for (int bk = 0; bk < BK; bk++) {
float a = sA[row][bk];
float b = sB[bk][col];
accum += a * b;
}
```
If BK=64 and unrolled, we have 64 LDs from shared mem, 64 FMAs, and some address arithmetic.
Shared memory latency is ~20-30 cycles. But the FMAs are dependent, so the LD for iteration i can be issued while the FMA from iteration i-1 is executing (if the compiler reorders instructions).
With 64 iterations, the compiler can schedule LDs early and FMAs late to overlap memory latency with computation.
But the chain of dependent FMAs is the main issue. The latency of FMA is ~4 cycles. So a single thread can issue a new FMA every 4 cycles if there's no ILP. That's 0.25 FMAs per cycle per thread.
A warp (32 threads) does 32 * 0.25 = 8 FMAs per cycle.
An SM on H100 can execute 128 FP32 FMAs per cycle (I think). So we need 128/8 = 16 warps per SM to saturate FMA units via thread-level parallelism.
With 15.5 warps per SM, we're close to 16 but slightly below. With 31 warps per SM (via K splitting), we're well above.
So splitting K by 2x might push us past the saturation threshold and improve performance!
But implementing K splitting is more complex. Let me first try the simple version without splitting, and if it's not fast enough, I'll add splitting.
Actually, I just realized: torch.matmul on H100 might be using CUTLASS or cuBLAS with highly optimized kernels including tensor cores. We might not be able to beat it with a simple hand-written kernel. But let's try anyway.
Let me write the kernel and test. I'll start with TileM=4, TileN=64, BK=64, 256 threads per block. I'll also make sure to use `__launch_bounds__` to help with register allocation.
Actually, I have another idea. What if we use a warp-level matrix multiply approach? Have each warp handle a small tile and accumulate in registers, with K streamed from global memory to registers (no shared memory).
For M=256, N=256, K=524288:
- Warp computes 8x4 or 8x8 tile of C
- Stream K: for each k, load A and B elements via vectorized loads, do FMA
But the A coalescing issue remains. Let me think about this.
If warp computes a tile:
- Warp lanes read A elements for their assigned rows
- Warp lanes read B elements for their assigned columns
Example: Warp handles 32 rows x 1 column? Or 8 rows x 4 columns?
If 8 rows x 4 columns:
- Per output element, need K FMAs
- 32 lanes, 32 elements total. Each lane handles 1 element.
- Lane j handles row r=j/4, col c=j%4.
- For K step k: lane j loads A[row][k] and B[k][col].
A loads: lanes 0..3 load A[0][k], lanes 4..7 load A[1][k], etc. Same address per group of 4 lanes. Broadcast within each group. 8 separate transactions (1 per row).
B loads: lanes 0,4,8,... load B[k][0], lanes 1,5,9,... load B[k][1], etc.
For B[k][col] where col is 0,1,2,3:
- Lanes with col=0 load B[k][0], same address (broadcast)
- Lanes with col=1 load B[k][1], same address
- etc.
So B loads: 4 broadcasts. A loads: 8 broadcasts. Total 12 transactions per K step per warp.
With 32 warps = 1024 threads, how many elements?
If each warp handles 32 elements (8x4), then 32 warps handle 1024 elements.
Total warps needed for 65536 elements = 65536/32 * (32 threads/warp) = 65536 threads = 2048 warps... no wait.
Each warp has 32 threads computing 32 elements (1 per thread). So 32 warps compute 1024 elements.
For 65536 elements: 65536/1024 = 64 such groups. If each group is 32 warps, that's 2048 warps total. Same as before.
But if the groups are blocks:
Block size = 32 warps = 1024 threads.
Total blocks = 65536/1024 = 64 blocks.
64 blocks on 132 SMs. 0.48 blocks per SM. That's terrible occupancy.
So warp-level without shared memory doesn't solve the occupancy problem.
Let me try yet another approach: persistent threads with a single block or few blocks but using all SMs... no, that's not how CUDA works.
OK, I think the key constraint is M*N=65536 limits total parallelism to 65536 independent computations. To increase parallelism, we must parallelize over K as well.
K splitting seems like the way to go.
Let me design a kernel with K splitting:
We partition K into P partitions (e.g., P=2).
Each thread block computes a tile of C, but only over K/p start to K/p end.
AtomicAdd to accumulate into C.
Kernel:
- Block computes TileM x TileN elements, over K/P range.
- After computing partial sums, atomicAdd to C.
Grid: (num_col_blocks, num_row_blocks, P) - but CUDA grids are up to 3D. We can use the z dimension for P.
Actually, CUDA supports up to 3D grid. So:
blockIdx.x = col block
blockIdx.y = row block
blockIdx.z = k partition
This is perfect! Total blocks = num_col_blocks * num_row_blocks * P.
With TileM=4, TileN=64, P=2:
num_col = 4, num_row = 64, P = 2
Total blocks = 4 * 64 * 2 = 512.
512 / 132 ≈ 3.88 blocks per SM. With 8 warps each = 31 warps per SM. Much better!
With P=4: total blocks = 1024. 1024/132 ≈ 7.76 blocks per SM = 62 warps per SM. Near maximum!
But each block does K/4 = 131072 FMAs per thread. With BK=64, that's 131072/64 = 2048 outer iterations.
Total FMAs per thread = 2048 * 64 = 131072 (half of without splitting). Wait no, K is split so each thread does K/P FMAs. Total FMAs per thread = 524288/4 = 131072.
But wait, with P=4 and TileM=4, TileN=64, each thread computes 1 element. Total FMAs per thread = 131072.
Total warps = 1024 blocks * 8 warps = 8192 warps.
8192 / 132 SMs = 62.1 warps per SM. Very close to the max of 64.
Will this saturate the FMA units? Likely yes!
The cost: each block does an atomicAdd for each of its 256 output elements. With 1024 blocks, that's 1024 * 256 = 262144 atomicAdds. atomicAdd on global memory has high throughput on H100. Should be fine.
Actually, we can also accumulate to shared memory first, then do a block-level reduction and have 1 thread per output element do the atomicAdd. But that doesn't save much since we already have 1 thread per output.
Wait, each thread computes 1 element and then atomicAdd's its result. That's 1 atomicAdd per thread. With atomicAdd throughput of ~100 billion/s on H100, 262k atomicAdds take ~2.6 microseconds. Negligible compared to the ~1ms kernel time.
But actually, multiple threads from different blocks might atomicAdd to the same C element simultaneously. That's exactly what we want - they sum their partial results.
However, atomicAdd on FP32 might have numerical differences compared to a single serial accumulation. With 4 partial sums added in arbitrary order, the rounding error might differ slightly from the reference. But the tolerance is 1e-4 for FP32, so 4-way reduction should be fine.
Let me write the K-splitting kernel.
Actually, I want to be careful about numerical stability. The reference does K=524288 additions in order. Our kernel does K/4 additions per thread, then 3 atomicAdds. The atomicAdds happen in arbitrary order from different blocks, which can cause slightly different rounding. Is this acceptable?
For FP32, adding 4 partial sums that each have ~K/4 = 131072 terms. The partial sums are all roughly the same magnitude (since A and B are uniform random in [0,1]). The average term is about 0.25, and the sum is about 131072 * 0.25 = 32768. Adding 4 partial sums of ~32768 gives ~131072.
The rounding error of adding 4 numbers of similar magnitude: about 3 ULP of 131072 ≈ 3 * 131072 * 2^-23 ≈ 3 * 0.016 ≈ 0.05. Which is well within 1e-4 * 131072 ≈ 13. So FP32 tolerance should easily accommodate this.
Wait, tolerance is absolute 1e-4. The output values are around 131072 * 0.25 = 32768 (since A, B ~ Uniform[0,1], E[AB] = 0.25). The relative tolerance is 1e-4, so absolute tolerance for large values is ~3.3. The atomicAdd reordering error is at most a few ULP ~ 0.05. So we're well within tolerance.
Now let me think about the kernel details.
Grid: (gridDim.x = N/TILE_N, gridDim.y = M/TILE_M, gridDim.z = P)
Block: (blockDim.x = TILE_M * TILE_N) = 256 threads
Wait, blockDim.x=256, but we need to map threadIdx.x to (row, col) within the tile, and also we need blockIdx.z for K partition.
```cuda
__global__ void matmul_kernel(const float* __restrict__ A, const float* __restrict__ B, float* __restrict__ C, int M, int N, int K, int K_partition) {
int blockRow = blockIdx.y;
int blockCol = blockIdx.x;
int partition = blockIdx.z;
int rowStart = blockRow * TILE_M;
int colStart = blockCol * TILE_N;
int kStart = partition * (K / K_partition);
int kEnd = (partition + 1) * (K / K_partition);
int tid = threadIdx.x;
int row = tid / TILE_N;
int col = tid % TILE_N;
__shared__ float sA[TILE_M][BK];
__shared__ float sB[BK][TILE_N];
float accum = 0.0f;
for (int k = kStart; k < kEnd; k += BK) {
// Load A
for (int i = tid; i < TILE_M * BK; i += blockDim.x) {
int aRow = i / BK;
int aCol = i % BK;
sA[aRow][aCol] = A[(rowStart + aRow) * K + k + aCol];
}
// Load B
for (int i = tid; i < BK * TILE_N; i += blockDim.x) {
int bRow = i / TILE_N;
int bCol = i % TILE_N;
sB[bRow][bCol] = B[(k + bRow) * N + colStart + bCol];
}
__syncthreads();
#pragma unroll
for (int bk = 0; bk < BK; bk++) {
accum += sA[row][bk] * sB[bk][col];
}
__syncthreads();
}
int globalRow = rowStart + row;
int globalCol = colStart + col;
if (globalRow < M && globalCol < N) {
atomicAdd(&C[globalRow * N + globalCol], accum);
}
}
```
But there's a problem: C is initialized to 0 before the kernel, right? The host code should zero C before launching the kernel.
In PyTorch, we can do: `C = torch.zeros(M, N, device=A.device)` and then launch the kernel.
But atomicAdd with FP32 to zeros should be fine.
Wait, what about the first partition adding to 0, second partition adding to partial sum, etc. That's fine.
But with P=4, the last block to write to each C element does an atomicAdd. Since atomicAdd is atomic, the final result is the sum of all 4 partitions.
However, if we use atomicAdd, the order of additions is arbitrary (determined by block execution order). This can cause slightly different rounding than sequential addition. As I analyzed, this should be within tolerance.
One more optimization: we might want to use `__shared__ float sB[TILE_N][BK]` instead of `sB[BK][TILE_N]` to see if the bank access pattern is better. Let me think.
In the compute loop, we do `sB[bk][col]`. If sB is declared as `sB[BK][TILE_N]`, then:
- For fixed bk and varying col: addresses = bk * TILE_N + col. Consecutive col gives consecutive addresses, consecutive banks. No conflict.
If declared as `sB[TILE_N][BK]` and accessed as `sB[col][bk]`:
- Address = col * BK + bk. For fixed bk and varying col, addresses are spaced by BK = 64 floats = 256 bytes.
- Banks are 4 bytes wide. 256 / 4 = 64 bank stride. There are 32 banks on H100. 64 mod 32 = 0. So consecutive threads access the SAME bank! This causes a 32-way bank conflict!
So `sB[BK][TILE_N]` with access `sB[bk][col]` is much better.
For sA[BK][TILE_M] accessed as sA[bk][row]:
- Address = bk * TILE_M + row. For fixed bk and varying row (0,1,2,3), addresses are spaced by 1, 2, 3. These map to banks 0,1,2,3 (or similar). No conflict since only 4 threads access sA simultaneously (one per row group).
Wait, in our setup, all 256 threads access sA and sB simultaneously in the compute loop.
- Threads 0..63: row=0, cols=0..63. They read sA[0][bk] (broadcast) and sB[bk][0..63].
- Broadcast of sA[0][bk] is handled by shared memory (no conflict).
- sB[bk][0..63] by 64 threads: consecutive cols, consecutive banks, no conflict.
So the `sB[BK][TILE_N]` layout is optimal.
One more thing: I should use compile-time constants for TILE_M, TILE_N, BK to allow the compiler to optimize address calculations.
Let me define them as template parameters or #defines.
Actually, I realize that if P=4 and we partition K, then kStart and kEnd may not be aligned to BK. If K/P is not divisible by BK, we need bounds checking.
K = 524288, P = 4, K/P = 131072. Is 131072 divisible by BK=64? Yes, 131072 / 64 = 2048. Good.
More generally, if K is always divisible by P*BK (which it is for our values), then the inner loops are clean with no boundary checks needed.
But for robustness, I'll add boundary checks.
Let me now write the complete Python module with the kernel and test it.
One optimization I want to add: prefetching. We can prefetch the next A and B tiles while computing the current one using double buffering. This requires 2x shared memory but can overlap memory loads with computation.
With BK=64, TILE_M=4, TILE_N=64:
sA = 256 floats, sB = 4096 floats. Total = 4352 floats = 17KB.
Double buffer: 34KB. Still well under 227KB.
But the potential gain is limited because our loop is small (only a few loads and many FMAs). The loads should already be mostly hidden by the unrolled FMAs.
Let me stick with single buffer for simplicity first.
Let me write the code and test.