reasoning
Let me analyze the problem:
1. We have a 4D tensor-matrix multiplication: `C[b, i, j, k] = sum_l A[b, i, j, l] * B[l, k]`
2. Input shapes: A=(b=8, i=256, j=512, l=256), B=(256, 768)
3. Output shape: C=(8, 256, 512, 768)
4. This is essentially a batched matrix multiplication where we multiply matrices of shape (i*j, l) with (l, k)
Actually, let's think about this more carefully.
- For each batch b and each i, we have a matrix of shape (j, l)
- We multiply by B of shape (l, k)
- So we get output (b, i, j, k)
We can reshape A to (b*i, j, l) and then do bmm with B expanded to (b*i, l, k), giving (b*i, j, k) = (b, i, j, k).
Or even simpler: A.view(b*i*j, l) @ B → view as (b, i, j, k)
Since the einsum is `bijl,lk->bijk`, we can compute this efficiently with a simple matmul:
- Flatten A to (B*I*J, L)
- Matrix multiply with B (L, K) → (B*I*J, K)
- Reshape to (B, I, J, K)
This should be very efficient in cuBLAS. But the task asks for a custom CUDA kernel. However, we could also write a custom kernel that does this more efficiently for the specific sizes.
Let me think about writing a custom CUDA kernel. The operation is essentially:
For each (b, i, j, k), C[b, i, j, k] = sum_l A[b, i, j, l] * B[l, k]
This is a large GEMM-like operation. With the given dimensions:
- b=8, i=256, j=512, l=256, k=768
- Total output elements: 8 * 256 * 512 * 768 = 805,306,368
- Reduction dimension l=256
If we write a custom kernel, we need to handle tile-based matrix multiplication.
Actually, thinking about what would be fastest: cuBLAS should already be highly optimized for this. But we need a custom CUDA kernel. Let me think about how to maximize performance.
The operation can be viewed as:
A' = A.reshape(8*256*512, 256) = (1,048,576, 256)
B = (256, 768)
C' = A' @ B = (1,048,576, 768)
This is a GEMM with M=1,048,576, N=768, K=256. This is quite large and should be compute-bound (not memory bound for the output, but the inputs need to be read).
M * N * K = 1,048,576 * 768 * 256 ≈ 206 billion ops
At 67 TFLOPS, this would take ~3ms at peak. Memory bandwidth is 3.35 TB/s.
For a custom kernel, I should write a tiled GEMM kernel using shared memory. Let's think about the tiling strategy.
We can think of this as:
- M = b * i * j = 1,048,576
- N = k = 768
- K = l = 256
For tiling:
- Each thread block could compute a tile of size BM x BN
- Each thread computes TM x TN elements
- We load tiles from A and B into shared memory
Given K=256 is relatively small, we can potentially load the entire K dimension for a tile of A into shared memory, or we can process it in chunks.
Actually, since K=256, and we're using fp32, a tile of A with BM=128 and BK=256 would use 128 * 256 * 4 = 131,072 bytes = 128 KB of shared memory. That's reasonable.
For B, BN=128 and BK=256 would use another 128 KB. Total 256 KB which exceeds the 227 KB per block limit on H100.
Let's try:
- BM = 128, BN = 128, BK = 32
- Shared memory for A: 128 * 32 * 4 = 16,384 bytes
- Shared memory for B: 32 * 128 * 4 = 16,384 bytes
- Total: 32 KB - very reasonable
With TM=8, TN=8, each thread block has (128/8) * (128/8) = 16 * 16 = 256 threads.
Actually, for better occupancy, we might want more threads per block. Let's try:
- BM = 128, BN = 256, BK = 32
- A smem: 128 * 32 * 4 = 16 KB
- B smem: 32 * 256 * 4 = 32 KB
- Total: 48 KB - still fine
Threads: TM=8, TN=8 → 16 * 32 = 512 threads per block.
Or BM=64, BN=256, BK=32:
- A smem: 64*32*4 = 8 KB
- B smem: 32*256*4 = 32 KB
- Total: 40 KB
- Threads: (64/8)*(256/8) = 8*32 = 256 threads
With M=1,048,576 and N=768:
- For BM=64, BN=256: blocks in M = 16384, blocks in N = 3
- Total blocks = 49152
- H100 has many SMs, so this should be fine.
Wait, but 49152 blocks with each taking some time... Let me think about this differently.
Actually, since the einsum can be flattened to a single large GEMM with M=1,048,576, N=768, K=256, we should be able to achieve good performance with a tiled approach.
An alternative is to use CUTLASS or just write a simple kernel that calls cuBLAS from C++, but the instructions say to write a custom CUDA kernel. Actually wait - we CAN call cuBLAS from CUDA. But the auto-generated pybind11 wrapper might not link against cuBLAS easily unless we specify it. Let me check if that's possible.
Actually `torch.utils.cpp_extension.load_inline` might not easily link against cuBLAS by default. Let me think about writing a pure CUDA kernel.
For a custom GEMM-like kernel on H100, I should write a kernel that:
1. Uses vectorized loads (float4)
2. Uses shared memory for tiling
3. Unrolls the inner loop
4. Maybe uses double buffering or Hopper-specific features
Actually, wait. H100 has Tensor Cores. But writing a custom kernel that uses Tensor Cores via PTX is quite complex. A good shared-memory tiled GEMM with vectorized loads should already be quite competitive for this problem size.
Let me think about another approach. We can also use 1D indexing and just call `torch.mm` or `torch.matmul` in PyTorch. But the problem asks for a custom CUDA kernel.
Let me design a kernel:
```cuda
__global__ void einsum_kernel(const float* __restrict__ A, const float* __restrict__ B, float* __restrict__ C,
int b, int i, int j, int l, int k) {
// Flatten b, i, j into M dimension
int total_M = b * i * j;
// Compute tile coordinates
int block_m = blockIdx.x;
int block_n = blockIdx.y;
int m_start = block_m * BM;
int n_start = block_n * BN;
// Each thread computes TM x TN elements
int thread_m = threadIdx.x / (BN / TN);
int thread_n = threadIdx.x % (BN / TN);
float accum[TM][TN] = {0};
for (int tk = 0; tk < l; tk += BK) {
// Load A tile into shared memory
// Load B tile into shared memory
__syncthreads();
// Compute partial dot products
for (int kk = 0; kk < BK; ++kk) {
// ... compute using shared memory
}
__syncthreads();
}
// Write back to C
}
```
Let me think about more specific parameters:
BM = 64, BN = 64, BK = 16, TM = 4, TN = 4
- Threads per block = (64/4) * (64/4) = 256
- A smem = 64 * 16 * 4 = 4KB
- B smem = 16 * 64 * 4 = 4KB
This is conservative. We can do better.
BM = 128, BN = 128, BK = 8, TM = 8, TN = 8
- Threads per block = 16 * 16 = 256
- A smem = 128 * 8 * 4 = 4KB
- B smem = 8 * 128 * 4 = 4KB
For this to work well, we'd need many iterations over K. K=256, BK=8, so 32 iterations. That's a lot of shared memory loads.
BM = 64, BN = 64, BK = 64, TM = 8, TN = 8
- Threads per block = 8 * 8 = 64
- A smem = 64 * 64 * 4 = 16KB
- B smem = 64 * 64 * 4 = 16KB
- Total: 32KB - good
With K=256, BK=64, we have 4 iterations.
Actually, for H100, we might want to maximize bandwidth utilization. Let me think about vectorized loads.
If we load float4 (4 floats at a time), then:
- For A: each row has l=256 floats = 64 float4s
- For B: each column can be loaded with float4 if k is aligned
With k=768, 768/4 = 192, so it's divisible by 4. Good.
With l=256, 256/4 = 64, also divisible by 4.
So we can write a kernel with float4 loads.
Let me think of a sophisticated kernel design. I'll use 2D thread blocks.
Actually, one important insight: since this is just a matrix multiplication after flattening the first 3 dimensions, and given the sizes, using a 1D block might actually be simpler and effective if we use a straightforward approach.
But a tiled GEMM is the standard approach. Let me write a kernel with these parameters:
- Block size: 256 threads
- Tile sizes: BM=64, BN=64, BK=32 (or similar)
- Vectorized loads: float4
- Each thread computes TM=4, TN=4 elements (or TM=8, TN=2, etc.)
Wait, 64*64 tile with 256 threads means each thread handles (64*64)/256 = 16 elements. We can arrange this as 4x4 per thread.
Actually, let me think about compute intensity. Each block computes a 64x64 output tile = 4096 elements.
To compute this, it loads:
- A tile: 64 * 32 * 4 bytes = 8KB per K-iteration
- B tile: 32 * 64 * 4 bytes = 8KB per K-iteration
- For K=256 and BK=32: 8 iterations
- Total A reads: 64KB, Total B reads: 64KB
- Total memory read per block: 128KB
- Compute: 64 * 64 * 256 = 1,048,576 FMAs = ~2M FLOPs
- Arithmetic intensity: 2M FLOPs / 128KB = ~16 FLOPs/byte
- To be compute bound at 67 TFLOPS / 3.35 TB/s ≈ 20 FLOPs/byte
So we need AI > 20 to be compute bound. With 16, we're slightly memory bound. We can improve by increasing tile sizes.
BM=128, BN=128, BK=32:
- Compute: 128*128*256 = 4,194,304 FMAs
- Memory: 2 * 128 * 32 * 4 * (256/32) = 2 * 128 * 32 * 4 * 8 = 262,144 bytes = 256KB
- AI = 8,388,608 / 262,144 = 32 FLOPs/byte - compute bound!
But shared memory might be too much: 128*32*4 + 32*128*4 = 16KB + 16KB = 32KB. That's fine.
Threads: with TM=8, TN=8: (128/8)*(128/8) = 16*16 = 256 threads.
This looks good. Let's refine.
Actually wait - if K=256, and BK=32, then we have 256/32 = 8 iterations.
In each iteration:
- Load A[128][32] into smem
- Load B[32][128] into smem
- Compute 128*128 dot products using these tiles
For vectorization:
- A: 128 rows, each row has 32 floats = 8 float4. So we can have 32 threads each load 4 float4s to fill A_smem? No wait, we have 256 threads.
Actually, let me think about how to map threads to loads:
- A_smem has 128 * 32 = 4096 floats
- With 256 threads, each thread loads 4096/256 = 16 floats = 4 float4
- We can arrange it so threads load sequentially
- B_smem has 32 * 128 = 4096 floats
- Each thread also loads 16 floats = 4 float4 from B
For computation:
- Each thread computes TM=8, TN=8 = 64 outputs
- Total threads = 256
- Total outputs per block = 256 * 64 = 16,384
- Tile size = 128 * 128 = 16,384. ✓
So thread layout for computation:
- thread_m = threadIdx.x / 16, thread_n = threadIdx.x % 16
- Wait, if TM=8 and TN=8, and tile is 128x128, then:
- Threads needed in M: 128/8 = 16
- Threads needed in N: 128/8 = 16
- So we can use threadIdx.x as: tm = threadIdx.x / 16, tn = threadIdx.x % 16
For loading:
- We'd want to load A_smem[128][32] and B_smem[32][128]
- Maybe use the same thread layout but load different data
Actually, let me design this more carefully.
```cuda
template<int BM, int BN, int BK, int TM, int TN>
__global__ void gemm_kernel(const float* A, const float* B, float* C, int M, int N, int K) {
__shared__ float As[BM][BK]; // Actually should be [BM][BK] but for coalescing, maybe [BK][BM]?
__shared__ float Bs[BK][BN];
int block_m = blockIdx.x;
int block_n = blockIdx.y;
int thread_m = threadIdx.x / (BN / TN);
int thread_n = threadIdx.x % (BN / TN);
float accum[TM][TN] = {0};
const float* A_ptr = A + block_m * BM * K;
const float* B_ptr = B + block_n * BN;
float* C_ptr = C + block_m * BM * N + block_n * BN;
for (int bk = 0; bk < K; bk += BK) {
// Load A tile: each thread loads some elements
// A has shape [M, K], so A[row, col] = A_ptr[row * K + col]
#pragma unroll
for (int i = 0; i < (BM * BK) / blockDim.x; ++i) {
int idx = threadIdx.x + i * blockDim.x;
int row = idx / BK;
int col = idx % BK;
As[row][col] = A_ptr[row * K + col];
}
// Load B tile: B has shape [K, N]
#pragma unroll
for (int i = 0; i < (BK * BN) / blockDim.x; ++i) {
int idx = threadIdx.x + i * blockDim.x;
int row = idx / BN;
int col = idx % BN;
Bs[row][col] = B_ptr[row * N + col];
}
__syncthreads();
// Compute
#pragma unroll
for (int k = 0; k < BK; ++k) {
float a_reg[TM];
float b_reg[TN];
#pragma unroll
for (int m = 0; m < TM; ++m) {
a_reg[m] = As[thread_m * TM + m][k];
}
#pragma unroll
for (int n = 0; n < TN; ++n) {
b_reg[n] = Bs[k][thread_n * TN + n];
}
#pragma unroll
for (int m = 0; m < TM; ++m) {
#pragma unroll
for (int n = 0; n < TN; ++n) {
accum[m][n] += a_reg[m] * b_reg[n];
}
}
}
__syncthreads();
A_ptr += BK;
B_ptr += BK * N;
}
// Write back
#pragma unroll
for (int m = 0; m < TM; ++m) {
#pragma unroll
for (int n = 0; n < TN; ++n) {
C_ptr[(thread_m * TM + m) * N + (thread_n * TN + n)] = accum[m][n];
}
}
}
```
I need to be careful about shared memory bank conflicts. To avoid them:
- For As, I should use As[BK][BM] and access as As[k][m] when k iterates. Actually, for loading A (M, K), accessing As[k][m] would give the same bank for all threads if they access same k but different m... Actually no, if threads are accessing A_smem[k][m] with different m, they access different banks.
Wait, let me think about bank conflicts. Each bank is 4 bytes. Accessing As[row][col]:
- If threads access different col values, they might hit same bank depending on layout.
- To avoid bank conflicts when reading As for computation: we want threads in a warp to access different banks.
- If As[BM][BK] with BM=128, BK=32, then As[m][k] - if threads access same k but different m, consecutive m values map to consecutive banks (since banks cycle every 32 floats = 128 bytes). With m differing by 1, they access banks m % 32. So consecutive threads with consecutive m access different banks - no conflict!
- But wait, if a warp has 32 threads, and they access m = 0, 1, 2, ..., 31 all with same k, they access banks 0, 1, 2, ..., 31. That's perfect - no bank conflicts.
For Bs[BK][BN]: threads access Bs[k][n] with same k but different n. If n differs by 1, they access consecutive banks. Also perfect.
But when we load into shared memory, we need coalesced global memory access.
For loading A_ptr[row * K + col]:
- If threads access with consecutive idx, they access A_ptr with consecutive indices. With row = idx / BK and col = idx % BK, consecutive threads access A_ptr with consecutive memory addresses. Good - coalesced.
For B_ptr[row * N + col]:
- Similarly, consecutive threads access consecutive indices in B_ptr. Good.
One issue: I used As[BM][BK] which for BM=128, BK=32 = 128*32*4 = 16KB shared memory. Bs[BK][BN] = 32*128*4 = 16KB. Total 32KB. That should be fine.
Wait, actually in CUDA shared memory layout, As[128][32] means the second index varies fastest. So As[m][k] where m is the row and k is the column of the tile. But A is stored in row-major with K as the fast dimension, so A[m][k] = A[m*K + k]. When we load A into As, we want As[m][k] = A[m*K + k], which matches.
For the computation loop:
```cuda
for (int k = 0; k < BK; ++k) {
// All threads load a_reg from As
// Threads have thread_m varying
// They access As[thread_m * TM + m][k]
// For m=0 and different thread_m values: thread_m=0 → As[0][k], thread_m=1 → As[8][k], etc.
// These are 8 rows apart. With bank width 4 bytes, As[0][k] is in bank k, As[8][k] is in bank (8*32 + k)%32 = k%32. Wait, that would be bank conflicts!
```
Hmm, let me think more carefully. As is As[128][32].
- As[0][k] is at address (0 * 32 + k) * 4 = k * 4 bytes → bank k % 32
- As[8][k] is at address (8 * 32 + k) * 4 = (256 + k) * 4. 256*4 = 1024 bytes. 1024 / 4 = 256 elements. 256 / 32 = 8. So As[8][k] is in bank (8 * 32 + k) % 32 = k % 32.
So if threads in a warp access As[0][k], As[8][k], As[16][k], etc. (for TM=8 and 32 threads in a warp, thread_m ranges from 0 to 31/16 = 1 or 2... wait.
With 256 threads, BN=128, TN=8, so BN/TN = 16.
blockDim.x = 256.
thread_m = threadIdx.x / 16, so thread_m ranges 0 to 15.
thread_n = threadIdx.x % 16, ranges 0 to 15.
For a warp of 32 threads, threadIdx.x goes from 0 to 31 (or 32 to 63, etc.).
In warp 0: thread_m = 0 or 1 (since 32/16 = 2). Specifically:
- threads 0-15: thread_m=0, thread_n=0..15
- threads 16-31: thread_m=1, thread_n=0..15
So when loading a_reg:
- threads 0-15: As[0..7][k] (for m=0..7)
- threads 16-31: As[8..15][k] (for m=0..7)
For a fixed k, all threads in a warp access either As[0..7][k] or As[8..15][k].
Wait, but each thread loads TM=8 values. The question is: do all threads in a warp access the same k index? Yes, in the loop `for (int k = 0; k < BK; ++k)`, all threads iterate the same k.
For k=0, and warp 0:
- Thread 0 accesses As[0][0], As[1][0], ..., As[7][0] sequentially
- Thread 1 accesses As[0][0], As[1][0], ..., As[7][0] sequentially - these are same addresses as thread 0! That's terrible - all threads in the same thread_m group access the same addresses!
Oh wait, I see the issue. With my indexing:
```cuda
for (int m = 0; m < TM; ++m) {
a_reg[m] = As[thread_m * TM + m][k];
}
```
For threads 0-15, thread_m = 0, so they all access As[0..7][k]. That's 16 threads all reading the exact same 8 values! That's redundant.
But actually, is it? Thread 0 computes outputs (0..7, 0..7). Thread 1 computes outputs (0..7, 8..15). So they DO need the same A values but different B values.
For the A reads: yes, all threads with the same thread_m need the same A values. In a warp, threads 0-15 have thread_m=0, and threads 16-31 have thread_m=1. So there are 2 groups of 16 threads each, with threads in each group reading the same A values. This is actually fine for L1/shared memory because it's a broadcast - multiple threads reading the same address is called broadcast and it's very fast (only 1 bank conflict, if any, but actually broadcast means all threads get the data in one cycle).
For B reads:
```cuda
for (int n = 0; n < TN; ++n) {
b_reg[n] = Bs[k][thread_n * TN + n];
}
```
Threads 0-15 have thread_n = 0..15. So:
- Thread 0: Bs[k][0..7]
- Thread 1: Bs[k][8..15]
- ...
- Thread 15: Bs[k][112..119]
These are consecutive in N, so consecutive threads read consecutive memory locations. With Bs[k] being 128 floats, and 32 banks, Bs[k][0] is bank 0, Bs[k][1] is bank 1, etc. But threads 0-15 are reading 8 floats each. Thread 0 reads banks 0-7, thread 1 reads banks 8-15, thread 2 reads banks 16-23, thread 3 reads banks 24-31, thread 4 reads banks 0-7, etc.
In a single warp with 32 threads, if all read 8 values sequentially, there will be bank conflicts. But because the `b_reg` is in registers and the compiler can optimize, the reads might happen in parallel or sequentially. This is where vector loads (float4) would help.
Actually for Bs, we could restructure as Bs[BN][BK] or use a different layout. But bank conflicts aren't catastrophic - they serialize accesses within a warp, but with only 2-way or 4-way conflicts, it's still manageable.
Wait, I think there's a better approach. What if we use a thread layout where threads in a warp have consecutive thread_m (instead of thread_n)? Let me reconsider.
Actually, a standard practice in GEMM kernels is:
- Load A and B into shared memory with the same data layout as global memory
- In the compute phase, each warp computes a sub-tile
- Each thread in a warp loads one value and broadcasts to other threads, or uses warp shuffle
But for simplicity, my approach should work. The bank conflict analysis:
For Bs[k][n] reads: if thread_n ranges from 0 to 15 within a warp, and TN=8, then thread n reads Bs[k][n*8], Bs[k][n*8+1], ..., Bs[k][n*8+7].
In a warp, for k fixed:
- thread 0 (thread_n=0): Bs[k][0..7] → banks 0..7
- thread 1 (thread_n=1): Bs[k][8..15] → banks 8..15
- thread 2 (thread_n=2): Bs[k][16..23] → banks 16..23
- thread 3 (thread_n=3): Bs[k][24..31] → banks 24..31
- thread 4 (thread_n=4): Bs[k][32..39] → banks 0..7 (32%32=0)
So threads 0, 4, 8, 12 all access banks 0..7 simultaneously. That's a 4-way bank conflict for each bank. Similarly for other groups.
4-way conflict means 4 cycles instead of 1. Not terrible but we can do better.
Alternative: use float4 for B loads. If we load Bs[k][n*8 + 0..3] as float4 and Bs[k][n*8 + 4..7] as another float4, then each thread does 2 float4 loads. A float4 load from shared memory is implemented as 4 separate 32-bit loads, so same issue.
Actually, for bank conflict-free shared memory access, a trick is to pad the shared memory arrays. E.g., As[BM][BK + pad] where pad=1 eliminates some conflicts.
But let me think... what if we transpose As in shared memory? As[BK][BM]?
Actually, for reading a_reg, if As is stored as [BK][BM], then As[k][thread_m * TM + m]. For fixed k, different thread_m access different columns. Consecutive columns = consecutive banks. So threads in a warp with consecutive thread_m (if they exist) would access consecutive banks. But in our layout, thread_m doesn't vary within a warp for the first 16 threads. So it's still broadcast-like.
Wait, let me reconsider the thread layout. With 256 threads and BN/TN=16, the layout is 16 threads in N by 16 in M? No:
thread_m = threadIdx.x / 16, range 0..15
thread_n = threadIdx.x % 16, range 0..15
So warp 0 (threads 0-31): thread_m = 0 or 1, thread_n = 0..15.
For a standard GEMM, it's actually better to have threads in a warp vary in the same dimension as the reduction to enable better data reuse. But our reduction is in k, which is handled by the outer `for (k = 0; k < BK; ++k)` loop.
I think for a first attempt, this kernel should work reasonably well. Let me write it and test.
But actually, before going into complex kernel tuning, let me check if there's an even simpler approach that might be competitive. Could we just call torch.matmul from Python but wrap it in a custom class? The task says "custom CUDA kernel"...
Let me re-read the prompt: "Write a custom CUDA kernel that replaces a PyTorch reference... Use `torch.utils.cpp_extension.load_inline` to compile and bind CUDA source at module load time, then call the compiled extension from `ModelNew.forward`."
So yes, we need a custom CUDA kernel. We can't just call `torch.matmul`.
Let me write a solid GEMM kernel. I'll also try to use const __restrict__ pointers for better compiler optimization.
Actually, for maximum performance on H100, I should consider:
1. Using cp.async (H100 has async copy)
2. Using Tensor Cores (very complex to do manually)
3. Using larger tiles to increase compute intensity
Given the problem size and that we need to beat PyTorch's einsum (which will resolve to a highly optimized cuBLAS GEMM), writing a custom CUDA kernel that beats cuBLAS is extremely challenging. But maybe the einsum decomposition in PyTorch isn't taking the optimal path, and our direct approach with careful tuning can win.
Actually, let me think about what PyTorch does with einsum("bijl,lk->bijk", A, B). It will contract over l. The optimal path might be:
1. Reshape A to (b*i*j, l)
2. Call GEMM with (b*i*j, l) @ (l, k)
cuBLAS GEMM for M=1,048,576, N=768, K=256 on H100 should be extremely fast - likely close to peak performance.
Can a hand-written kernel beat cuBLAS? It's very unlikely for a standard GEMM of this size unless we're doing something clever that cuBLAS doesn't.
But wait - the problem asks for a CUDA kernel. Maybe the evaluation is comparing against PyTorch's einsum, which might not take the optimal path (einsum overhead, suboptimal contraction path, etc.). If PyTorch's einsum is slower than optimal cuBLAS, we just need to be close to optimal cuBLAS performance.
Let me write a kernel and see how it performs. I should start with a good tiled kernel, then iterate.
Here's my plan:
1. Write a tiled GEMM kernel with parameters tuned for M=1M, N=768, K=256
2. BM=128, BN=128, BK=32, TM=8, TN=8, thread count=256
3. Use float4 for global memory loads where possible
4. Proper shared memory padding
Actually, for this specific problem, since K=256 is relatively small, we could even consider processing an entire K slice per thread block. For example, with BK=256:
- A_smem = 128 * 256 * 4 = 128 KB
- B_smem = 256 * 128 * 4 = 128 KB
- Total = 256 KB > 227 KB limit
What about BK=128:
- A_smem = 128 * 128 * 4 = 64 KB
- B_smem = 128 * 128 * 4 = 64 KB
- Total = 128 KB - fine!
Then K=256 requires 2 iterations. This would reduce the number of global memory loads for A (since each A element is loaded only once per block - actually still the same total, just fewer iterations with more work per iteration).
Let me try BK=128, BM=64, BN=64:
- A_smem = 64 * 128 * 4 = 32 KB
- B_smem = 128 * 64 * 4 = 32 KB
- Total: 64 KB - fine
- Threads: TM=8, TN=8 → 8 * 8 = 64 threads per block. That's a bit low.
- With TM=4, TN=4 → 16 * 16 = 256 threads per block. Good.
- Compute per block: 64*64*256 = 1M FMAs
- Memory: 2 * 64 * 128 * 4 * 2 = 131,072 bytes = 128KB
- AI = 2M / 128K = 16 FLOPs/byte - slightly memory bound
Let's try BK=128, BM=128, BN=128:
- A_smem = 128 * 128 * 4 = 64 KB
- B_smem = 128 * 128 * 4 = 64 KB
- Total: 128 KB - fine
- Threads: TM=8, TN=8 → 16 * 16 = 256 threads
- Compute = 128*128*256 = 4.2M FMAs
- Memory = 2 * 128 * 128 * 4 * 2 = 256KB
- AI = 8.4M / 256K = 32.8 FLOPs/byte - compute bound!
This is great! 2 iterations over K, large tiles. Let me design this kernel.
BM=128, BN=128, BK=128, TM=8, TN=8, threads=256.
M = 1,048,576, N = 768, K = 256.
Blocks in M = 1,048,576 / 128 = 8192
Blocks in N = 768 / 128 = 6
Total blocks = 49,152
With 132 SMs on H100, that's 372 blocks per SM on average. Plenty of parallelism.
For the output writing, each thread writes 64 floats back to global memory.
Total writes per block = 128 * 128 * 4 = 64KB.
I'll implement this with:
1. Cooperative loading using all threads
2. Unrolled compute loop
3. Proper shared memory padding
Actually, shared memory padding is crucial for bank conflict-free access. For As[128][128+1], each row is padded so that As[row][col] and As[row+1][col] are in different bank patterns. But honestly, with modern GPUs, bank conflicts aren't as big a deal as they used to be, and we can start without padding and add it if needed.
Wait, H100 has 32-byte banks, not 4-byte banks. Let me check... Actually, from Ampere onwards, shared memory has 32-byte banks. So with float access, 8 consecutive floats are in the same bank. This changes things significantly!
With 32-byte banks:
- Bank 0: bytes 0-31 (floats 0-7)
- Bank 1: bytes 32-63 (floats 8-15)
- etc.
For As[128][128], As[row][col] is at byte offset (row * 128 + col) * 4.
Bank = ((row * 128 + col) * 4 / 32) % 32 = ((row * 128 + col) / 8) % 32.
For thread_m=0 reading As[0..7][k]:
As[m][k] bank = ((m * 128 + k) / 8) % 32 = (m * 16 + k/8) % 32 approximately.
For m=0..7, banks differ by 16. So banks are 0, 16, 0, 16, 0, 16, 0, 16... wait.
m=0: (0 + k/8) % 32
m=1: (16 + k/8) % 32
m=2: (32 + k/8) % 32 = (k/8) % 32
m=3: (48 + k/8) % 32 = (16 + k/8) % 32
m=4: (64 + k/8) % 32 = (k/8) % 32
So for m=0,2,4,6: same bank. For m=1,3,5,7: same bank. That's 4-way conflicts for 32-byte banks!
But actually, if we access sequentially within a thread, the accesses are serialized anyway within that thread, so bank conflicts only matter for simultaneous accesses by different threads in a warp.
In our case, all threads in a warp with the same thread_m are reading the same As values. That's broadcast, not bank conflict.
For Bs[k][n]:
Bs[k][thread_n * TN + n] with thread_n varying.
For n=0: Bs[k][thread_n * 8] bank = ((k * 128 + thread_n * 8) / 8) % 32 = (k * 16 + thread_n) % 32.
So for thread_n = 0..31 in a warp (but wait, our warp only has thread_n = 0..15, and there are 2 thread_m values). For threads 0-15: thread_n=0..15, all reading Bs[k][thread_n * 8].
These access banks: k*16 + 0, k*16 + 1, ..., k*16 + 15. These are all different banks! No bank conflicts!
For threads 16-31: thread_n=0..15 again (since thread_m=1), same banks. But they're in the same warp, so all 32 threads access the same 16 banks but different from each other. Wait, threads 0 and 16 both have thread_n=0, so they both access bank k*16. That's a 2-way conflict.
Hmm, actually in a warp, threads 0-15 and 16-31 each have their own thread_n values. Thread 0: thread_n=0, thread 1: thread_n=1, ..., thread 15: thread_n=15, thread 16: thread_n=0, thread 17: thread_n=1, etc.
So for Bs[k][thread_n * 8], threads 0 and 16 both access bank k*16, threads 1 and 17 both access bank k*16+1, etc. That's a 2-way bank conflict for each bank. Not bad at all.
For a_reg reads: As[thread_m * TM + m][k]. Warp 0 has thread_m=0 for threads 0-15, thread_m=1 for threads 16-31.
Threads 0-15 all read As[0..7][k]. This is broadcast - same addresses by many threads. CUDA handles this well - no bank conflicts for broadcast.
Threads 16-31 all read As[8..15][k]. Same thing - broadcast within this half.
So overall bank conflicts are minimal! This is great.
Alright, let me write the kernel. I'll also try to use float4 for global memory loads to maximize bandwidth.
For loading A_smem[128][128]:
Total elements = 16384. Threads = 256. Each thread loads 64 floats = 16 float4.
For B_smem[128][128]:
Same, 16 float4 per thread.
For C writes:
Each thread writes 8x8 = 64 floats. Can be written as 16 float4.
Let me write the code:
```cuda
#include <torch/extension.h>
#include <cuda_runtime.h>
#define BM 128
#define BN 128
#define BK 128
#define TM 8
#define TN 8
__global__ void einsum_gemm_kernel(const float* __restrict__ A, const float* __restrict__ B, float* __restrict__ C,
int M, int N, int K) {
__shared__ alignas(16) float As[BM][BK];
__shared__ alignas(16) float Bs[BK][BN];
int block_m = blockIdx.x;
int block_n = blockIdx.y;
int thread_m = threadIdx.x / (BN / TN);
int thread_n = threadIdx.x % (BN / TN);
float accum[TM][TN] = {0.0f};
const float* A_tile = A + block_m * BM * K;
const float* B_tile = B + block_n * BN;
float* C_tile = C + block_m * BM * N + block_n * BN;
// Each thread loads elements into shared memory
int num_loads_A = (BM * BK) / blockDim.x; // 16384 / 256 = 64
int num_loads_B = (BK * BN) / blockDim.x; // 16384 / 256 = 64
for (int bk = 0; bk < K; bk += BK) {
// Load A tile
#pragma unroll
for (int i = 0; i < num_loads_A / 4; ++i) {
int idx = threadIdx.x * (num_loads_A / 4) + i;
// Actually, let's do float4 loads properly
int row = (threadIdx.x * 4 + i * blockDim.x * 4) / BK; // This is getting complicated
}
}
}
```
The indexing for float4 loads is tricky. Let me simplify.
For A_tile, we want to load BM * BK = 16384 floats. With 256 threads, each loads 64 floats. Using float4, each thread does 16 loads.
The simplest approach: each thread handles a contiguous chunk.
Thread t loads floats t*64 to t*64 + 63.
```cuda
for (int i = 0; i < 16; ++i) {
int idx = threadIdx.x * 64 + i * 4;
int row = idx / BK;
int col = idx % BK;
float4 val = reinterpret_cast<const float4*>(A_tile + row * K + col)[0];
As[row][col] = val.x;
As[row][col+1] = val.y;
As[row][col+2] = val.z;
As[row][col+3] = val.w;
}
```
For this to work, row * K + col must be aligned to 16 bytes. Since col = idx % BK, and if BK=128, then idx ranges from 0 to 16383 in increments of 64 per thread. For thread 0, idx=0, col=0, so A_tile[0] is aligned if A is aligned (it should be since torch tensors are aligned).
For thread 1, idx=64, row=0, col=64. A_tile[64] is also 256-byte aligned (64 floats * 4 bytes = 256 bytes), so it's definitely 16-byte aligned.
For B_tile: B_tile + row * N + col. N here is the total N dimension. row = idx / BN, col = idx % BN.
For thread 0: row=0, col=0. B_tile[0] aligned.
For thread 1: idx=64, row=0, col=64. B_tile[64] is 256-byte aligned. Good.
Wait, but for B, the row spans K, and col spans N. In the actual layout:
B is shape (K, N). B_tile points to B + block_n * BN.
B[k][n] is at address B_tile + k * N + n.
For our tile iteration, bk=0, so B_tile is at the start.
When we load B_smem, we're conceptually loading B[bk: bk+BK][block_n * BN: block_n * BN + BN].
For the first iteration (bk=0): A_tile = A + 0, B_tile = B + 0.
For the second iteration (bk=128): A_tile = A + 128, B_tile = B + 128 * N.
In the kernel, after each iteration, we do:
```cuda
A_tile += BK;
B_tile += BK * N;
```
So the B_tile pointer moves by BK * N = 128 * 768 = 98304 floats. The alignment of B_tile + row * N + col needs to be 16-byte for float4 loads.
At bk=128: B_tile = B + 128*768. 128*768*4 = 393,216 bytes. 393,216 / 16 = 24,576. So it's 16-byte aligned. Good.
For row=0, col=0: address = B_tile + 0 * N + 0 = B + 128*768. 16-byte aligned.
For row=0, col=64: address = B_tile + 0 * N + 64 = B + 128*768 + 64. 64*4 = 256 bytes. 256/16 = 16. Still aligned.
So float4 loads should work.
Let me write the full kernel now. Actually, I realize I can make this simpler by not trying to be too clever with float4. Let me first write a version with float loads and make sure it works, then optimize with float4.
Here's a clean version:
```cuda
#include <torch/extension.h>
#include <cuda_runtime.h>
#define BM 128
#define BN 128
#define BK 128
#define TM 8
#define TN 8
__global__ void einsum_gemm_kernel(const float* __restrict__ A, const float* __restrict__ B,
float* __restrict__ C, int M, int N, int K) {
__shared__ float As[BM][BK];
__shared__ float Bs[BK][BN];
int block_m = blockIdx.x;
int block_n = blockIdx.y;
int thread_m = threadIdx.x / (BN / TN);
int thread_n = threadIdx.x % (BN / TN);
float accum[TM][TN];
#pragma unroll
for (int m = 0; m < TM; ++m) {
#pragma unroll
for (int n = 0; n < TN; ++n) {
accum[m][n] = 0.0f;
}
}
const float* A_ptr = A + block_m * BM * K;
const float* B_ptr = B + block_n * BN;
int num_elements_A = BM * BK;
int num_elements_B = BK * BN;
for (int bk = 0; bk < K; bk += BK) {
// Load A tile into shared memory
#pragma unroll
for (int i = 0; i < num_elements_A / blockDim.x; ++i) {
int idx = threadIdx.x + i * blockDim.x;
int row = idx / BK;
int col = idx % BK;
As[row][col] = A_ptr[row * K + col];
}
// Load B tile into shared memory
#pragma unroll
for (int i = 0; i < num_elements_B / blockDim.x; ++i) {
int idx = threadIdx.x + i * blockDim.x;
int row = idx / BN;
int col = idx % BN;
Bs[row][col] = B_ptr[row * N + col];
}
__syncthreads();
// Compute
#pragma unroll
for (int k = 0; k < BK; ++k) {
float a_vals[TM];
float b_vals[TN];
#pragma unroll
for (int m = 0; m < TM; ++m) {
a_vals[m] = As[thread_m * TM + m][k];
}
#pragma unroll
for (int n = 0; n < TN; ++n) {
b_vals[n] = Bs[k][thread_n * TN + n];
}
#pragma unroll
for (int m = 0; m < TM; ++m) {
#pragma unroll
for (int n = 0; n < TN; ++n) {
accum[m][n] += a_vals[m] * b_vals[n];
}
}
}
__syncthreads();
A_ptr += BK;
B_ptr += BK * N;
}
// Write back
float* C_ptr = C + block_m * BM * N + block_n * BN;
#pragma unroll
for (int m = 0; m < TM; ++m) {
#pragma unroll
for (int n = 0; n < TN; ++n) {
C_ptr[(thread_m * TM + m) * N + (thread_n * TN + n)] = accum[m][n];
}
}
}
torch::Tensor einsum_forward(torch::Tensor A, torch::Tensor B) {
int b = A.size(0);
int i = A.size(1);
int j = A.size(2);
int l = A.size(3);
int k = B.size(1);
int M = b * i * j;
int N = k;
int K = l;
auto C = torch::empty({b, i, j, k}, A.options());
int blocks_m = (M + BM - 1) / BM;
int blocks_n = (N + BN - 1) / BN;
dim3 grid(blocks_m, blocks_n);
dim3 block(BM / TM * BN / TN); // 16 * 16 = 256
einsum_gemm_kernel<<<grid, block>>>(
A.data_ptr<float>(),
B.data_ptr<float>(),
C.data_ptr<float>(),
M, N, K
);
return C;
}
```
Wait, M = b * i * j = 8 * 256 * 512 = 1,048,576. BM = 128.
blocks_m = 1,048,576 / 128 = 8192.
But I need to handle cases where M is not divisible by BM or N not divisible by BN. For this specific problem, N=768 and BN=128, so 768/128 = 6 exactly. M=1,048,576 / 128 = 8192 exactly. So no boundary issues.
But for safety, I should add boundary checks. Actually, the problem says these are the fixed dimensions, but for correctness with any input, we should add checks.
Let me add boundary guards, but keep them minimal since they'll be branch-predicted away for the main case.
```cuda
// In the load loops:
if (row < BM && col < BK && block_m * BM + row < M) {
As[row][col] = A_ptr[row * K + col];
}
```
Actually, adding boundary checks complicates things, especially for coalescing. Let me write a version without boundary checks first, assuming the dimensions are exact multiples. Then if there's an issue, I'll add them.
Wait, but what if K=256 and BK=128, then K/BK = 2 exactly. No boundary issues in K either.
So for the given problem sizes, everything divides evenly. I'll write a kernel without boundary checks and note that it assumes alignment, then add checks if needed for generality.
Actually, I should be careful. The evaluation might test with different sizes. Let me add soft boundary checks.
For A loads: row is always < BM, and col is always < BK (since idx < BM*BK and row = idx/BK). But we need to check if we're loading valid data: block_m * BM + row < M and bk + col < K.
Similarly for B: bk + row < K and block_n * BN + col < N.
For C writes: block_m * BM + thread_m * TM + m < M and block_n * BN + thread_n * TN + n < N.
Let me write a more general kernel:
```cuda
__global__ void einsum_gemm_kernel(const float* __restrict__ A, const float* __restrict__ B,
float* __restrict__ C, int M, int N, int K) {
__shared__ float As[BM][BK];
__shared__ float Bs[BK][BN];
int block_m = blockIdx.x;
int block_n = blockIdx.y;
int thread_m = threadIdx.x / (BN / TN);
int thread_n = threadIdx.x % (BN / TN);
float accum[TM][TN];
#pragma unroll
for (int m = 0; m < TM; ++m)
for (int n = 0; n < TN; ++n)
accum[m][n] = 0.0f;
int m_offset = block_m * BM;
int n_offset = block_n * BN;
for (int bk = 0; bk < K; bk += BK) {
// Load A tile
#pragma unroll
for (int i = 0; i < (BM * BK) / blockDim.x; ++i) {
int idx = threadIdx.x + i * blockDim.x;
int row = idx / BK;
int col = idx % BK;
int global_m = m_offset + row;
int global_k = bk + col;
if (global_m < M && global_k < K) {
As[row][col] = A[global_m * K + global_k];
} else {
As[row][col] = 0.0f;
}
}
// Load B tile
#pragma unroll
for (int i = 0; i < (BK * BN) / blockDim.x; ++i) {
int idx = threadIdx.x + i * blockDim.x;
int row = idx / BN;
int col = idx % BN;
int global_k = bk + row;
int global_n = n_offset + col;
if (global_k < K && global_n < N) {
Bs[row][col] = B[global_k * N + global_n];
} else {
Bs[row][col] = 0.0f;
}
}
__syncthreads();
// Compute
#pragma unroll
for (int k = 0; k < BK; ++k) {
float a_vals[TM];
float b_vals[TN];
#pragma unroll
for (int m = 0; m < TM; ++m) a_vals[m] = As[thread_m * TM + m][k];
#pragma unroll
for (int n = 0; n < TN; ++n) b_vals[n] = Bs[k][thread_n * TN + n];
#pragma unroll
for (int m = 0; m < TM; ++m)
#pragma unroll
for (int n = 0; n < TN; ++n)
accum[m][n] += a_vals[m] * b_vals[n];
}
__syncthreads();
}
// Write back
#pragma unroll
for (int m = 0; m < TM; ++m) {
int global_m = m_offset + thread_m * TM + m;
if (global_m < M) {
#pragma unroll
for (int n = 0; n < TN; ++n) {
int global_n = n_offset + thread_n * TN + n;
if (global_n < N) {
C[global_m * N + global_n] = accum[m][n];
}
}
}
}
}
```
This should work for arbitrary dimensions. Now let me put this into a full Python module.
One thing I want to check: can I use `__shared__` arrays with dynamic sizing? No, they must be compile-time constants. I'll use `#define` constants.
Also, I should consider using `__launch_bounds__` to help the compiler optimize register allocation. For 256 threads per block, we can use `__launch_bounds__(256)`.
Actually, let me also consider if using float4 would speed things up. The main performance limiter for memory-bound parts is memory bandwidth, and float4 should help maximize it. Let me try a float4 version.
Actually, let me first get a basic version working, then we can iterate on performance. The compile and run tools will tell us how we're doing.
Here's my first draft of the full Python file:
```python
import torch
import torch.nn as nn
from torch.utils.cpp_extension import load_inline
cuda_source = """
#include <torch/extension.h>
#include <cuda_runtime.h>
#define BM 128
#define BN 128
#define BK 128
#define TM 8
#define TN 8
__global__ void einsum_gemm_kernel(const float* __restrict__ A, const float* __restrict__ B,
float* __restrict__ C, int M, int N, int K) {
__shared__ float As[BM][BK];
__shared__ float Bs[BK][BN];
int block_m = blockIdx.x;
int block_n = blockIdx.y;
int thread_m = threadIdx.x / (BN / TN);
int thread_n = threadIdx.x % (BN / TN);
float accum[TM][TN];
#pragma unroll
for (int m = 0; m < TM; ++m)
for (int n = 0; n < TN; ++n)
accum[m][n] = 0.0f;
int m_offset = block_m * BM;
int n_offset = block_n * BN;
for (int bk = 0; bk < K; bk += BK) {
#pragma unroll
for (int i = 0; i < (BM * BK) / blockDim.x; ++i) {
int idx = threadIdx.x + i * blockDim.x;
int row = idx / BK;
int col = idx % BK;
int global_m = m_offset + row;
int global_k = bk + col;
As[row][col] = (global_m < M && global_k < K) ? A[global_m * K + global_k] : 0.0f;
}
#pragma unroll
for (int i = 0; i < (BK * BN) / blockDim.x; ++i) {
int idx = threadIdx.x + i * blockDim.x;
int row = idx / BN;
int col = idx % BN;
int global_k = bk + row;
int global_n = n_offset + col;
Bs[row][col] = (global_k < K && global_n < N) ? B[global_k * N + global_n] : 0.0f;
}
__syncthreads();
#pragma unroll
for (int k = 0; k < BK; ++k) {
float a_vals[TM];
float b_vals[TN];
#pragma unroll
for (int m = 0; m < TM; ++m) a_vals[m] = As[thread_m * TM + m][k];
#pragma unroll
for (int n = 0; n < TN; ++n) b_vals[n] = Bs[k][thread_n * TN + n];
#pragma unroll
for (int m = 0; m < TM; ++m)
#pragma unroll
for (int n = 0; n < TN; ++n)
accum[m][n] += a_vals[m] * b_vals[n];
}
__syncthreads();
}
#pragma unroll
for (int m = 0; m < TM; ++m) {
int global_m = m_offset + thread_m * TM + m;
if (global_m < M) {
#pragma unroll
for (int n = 0; n < TN; ++n) {
int global_n = n_offset + thread_n * TN + n;
if (global_n < N) {
C[global_m * N + global_n] = accum[m][n];
}
}
}
}
}
torch::Tensor einsum_forward(torch::Tensor A, torch::Tensor B) {
int b = A.size(0);
int i = A.size(1);
int j = A.size(2);
int l = A.size(3);
int k = B.size(1);
int M = b * i * j;
int N = k;
int K = l;
auto C = torch::zeros({b, i, j, k}, A.options());
int blocks_m = (M + BM - 1) / BM;
int blocks_n = (N + BN - 1) / BN;
dim3 grid(blocks_m, blocks_n);
dim3 block(BM / TM * BN / TN);
einsum_gemm_kernel<<<grid, block>>>(
A.data_ptr<float>(),
B.data_ptr<float>(),
C.data_ptr<float>(),
M, N, K
);
return C;
}
"""
cpp_sources = """
torch::Tensor einsum_forward(torch::Tensor A, torch::Tensor B);
"""
module = load_inline(
name="einsum_cuda",
cpp_sources=cpp_sources,
cuda_sources=cuda_source,
functions=["einsum_forward"],
extra_cuda_cflags=["-O3", "--use_fast_math"],
extra_cflags=["-O3"],
verbose=False,
)
class ModelNew(nn.Module):
def __init__(self):
super(ModelNew, self).__init__()
def forward(self, A, B):
return module.einsum_forward(A, B)
```
Wait, I should check if there are issues with the shared memory layout. CUDA shared memory with 2D arrays: `As[BM][BK]` means row-major with BK elements per row. For `As[thread_m * TM + m][k]`, different m values access different rows. Since each row is BK=128 floats = 512 bytes, and 32-byte banks (assuming H100), each row spans 512/32 = 16 banks. So As[0][k] and As[1][k] are 512 bytes apart, which is 16 banks apart. So As[0][k] and As[8][k] are 128 banks apart, which wraps around 4 times (128/32=4), so they're in the same bank.
This means for `a_vals[m] = As[thread_m * TM + m][k]`:
For thread_m=0: accesses As[0][k], As[1][k], ..., As[7][k]. These are banks (0+k/8)%32, (16+k/8)%32, (0+k/8)%32, etc. depending on exact k. Actually let me recalculate:
As[row][col] address = (row * 128 + col) * 4 bytes.
Bank = ((row * 128 + col) * 4 / 32) % 32 = ((row * 128 + col) / 8) % 32.
For col=k, row from 0 to 7:
row=0: (0 + k/8) % 32
row=1: (16 + k/8) % 32
row=2: (32 + k/8) % 32 = (k/8) % 32
row=3: (48 + k/8) % 32 = (16 + k/8) % 32
row=4: (64 + k/8) % 32 = (k/8) % 32
So for row=0,2,4,6: all same bank! That's a 4-way bank conflict if accessed in the same cycle.
But wait, these accesses are serialized within a single thread (each thread loads TM=8 values sequentially). Since a single thread can't have bank conflicts with itself (it accesses sequentially), there's no issue. Bank conflicts only occur when different threads in the same warp access the same bank.
For a_reg: all threads with the same thread_m access the same As rows. In a warp, 16 threads have thread_m=0, 16 threads have thread_m=1. Each of these groups accesses the same addresses. This is broadcast, not conflict.
For b_vals: Bs[k][thread_n * TN + n]. Bs[k][col] address = (k * 128 + col) * 4.
Bank = ((k * 128 + col) / 8) % 32.
For col = thread_n * 8 + n:
Thread 0 (thread_n=0): col = 0..7 → banks (k*16 + 0) % 32 to (k*16 + 0) % 32 (since 7/8=0)
Wait, col/8 for col=0..7 is all 0. So bank = (k*16) % 32 for all n in 0..7!
That's ALL THE SAME BANK for different n values within a single thread!
But again, these are sequential accesses within a thread, so no conflict.
For different threads in a warp:
Thread 0: thread_n=0, bank = k*16 % 32
Thread 1: thread_n=1, bank = (k*16 + 1) % 32
...
Thread 15: thread_n=15, bank = (k*16 + 15) % 32
These are all different banks. No conflicts.
Thread 16: thread_n=0, same bank as thread 0. 2-way conflict.
So overall, at most 2-way bank conflicts. That's very good.
OK, I'm confident this kernel will work. Let me add one more optimization: padding shared memory slightly to eliminate even the 2-way conflict.
```cuda
__shared__ float As[BM][BK + 1]; // Pad by 1
__shared__ float Bs[BK][BN + 1]; // Pad by 1
```
With BK+1=129, As[row][col] bank = ((row * 129 + col) / 8) % 32.
This breaks the regular pattern that caused conflicts.
But extra shared memory is fine as long as we stay under the limit. Let me check:
As: 128 * 129 * 4 = 66,048 bytes
Bs: 128 * 129 * 4 = 66,048 bytes
Total: 132,096 bytes = 129 KB. Under 227 KB limit.
Actually, for Bs, the conflict was in Bs[k][thread_n * TN + n] where different threads with same thread_n but different thread_m hit same bank. With BN+1 padding: Bs[k][col] bank = ((k * 129 + col) / 8) % 32.
For thread 0: col=0, bank = k*129/8 % 32
For thread 16: col=0, bank = k*129/8 % 32. Same conflict!
Hmm, padding doesn't help because threads 0 and 16 access the exact same column. This is inherent in our layout - they compute different output tiles in M but use the same B values. This is fine, it's just broadcast-like.
Actually wait, since thread 0 and 16 are in the same warp, and they both read Bs[k][0], this is a broadcast. Modern GPUs (Ampere+) handle broadcasts very efficiently. In fact, Ampere actually has dedicated broadcast capability where all threads in a warp can read the same address in one cycle.
So we don't need to worry about this. Let me not pad.
Let me write and compile the kernel.