reasoning
Let me analyze the problem:
1. We need to replace `Conv2d` + `HardSwish` + `ReLU` with a fused CUDA kernel.
2. Input shape: (128, 8, 128, 128)
3. Output channels: 64, kernel_size: 3, so output shape: (128, 64, 126, 126) with default stride=1, padding=0
4. This is a convolution followed by elementwise activations.
The key optimization is to fuse the convolution, HardSwish, and ReLU into a single kernel. We can use CUDA to implement a direct convolution with fused activations.
For a 3x3 conv with 8 input channels and 64 output channels, on 128x128 feature maps:
- Output size: 128 x 64 x 126 x 126 ≈ 128M elements
- Weights: 64 x 8 x 3 x 3 = 4608 weights
This is small enough that we can implement a custom direct convolution kernel. We want to launch one thread per output element (or use tiles).
For maximum performance, we should:
1. Use shared memory for input tiling
2. Keep weights in registers or shared memory
3. Fuse HardSwish and ReLU
HardSwish formula: `x * clamp(x + 3, 0, 6) / 6`
ReLU: `max(0, x)`
Since ReLU comes after HardSwish, and HardSwish(x) ≥ 0 when x ≥ -3? Let me check:
- For x ≥ 0, HardSwish(x) = x * min(max(x+3, 0), 6) / 6 ≥ 0
- For -3 ≤ x < 0, HardSwish(x) = x * (x+3) / 6. This is ≤ 0 for x in [-3, 0].
- For x < -3, HardSwish(x) = 0
So ReLU(HardSwish(x)) = max(0, HardSwish(x)) = HardSwish(ReLU(x+3)? No wait...)
Actually ReLU(HardSwish(x)) = max(0, x * relu6(x+3) / 6)
For x ≥ 0: HardSwish(x) = x * min(x+3, 6) / 6 ≥ 0, so ReLU doesn't change anything for x ≥ 0.
For x in [-3, 0]: HardSwish(x) = x*(x+3)/6 ≤ 0, so ReLU makes it 0.
For x < -3: HardSwish(x) = 0, so ReLU makes it 0.
Wait, is that right? Let's check x = -1.5: HardSwish(-1.5) = -1.5 * 1.5 / 6 = -0.375. ReLU gives 0.
x = -2: HardSwish(-2) = -2 * 1 / 6 = -0.333. ReLU gives 0.
x = -1: HardSwish(-1) = -1 * 2 / 6 = -0.333. ReLU gives 0.
So actually for x < 0, HardSwish(x) ≤ 0, meaning ReLU(HardSwish(x)) = max(0, HardSwish(x)) = HardSwish(max(0, x))? Not exactly.
Actually for x ≥ 0: HardSwish(x) = x * min(x+3, 6)/6. This is always ≥ 0.
For -3 ≤ x < 0: x*(x+3)/6. Since x < 0 and (x+3) > 0, this is negative.
For x < -3: 0.
So ReLU(HardSwish(x)) = 0 for x < 0, and HardSwish(x) for x ≥ 0.
For x ≥ 0: HardSwish(x) = x * min(x+3, 6)/6 = x * min(max(x+3, 0), 6)/6, but since x ≥ 0, x+3 ≥ 3, so min(x+3, 6) = x+3 if x ≤ 3, else 6.
So for 0 ≤ x ≤ 3: ReLU(HardSwish(x)) = x*(x+3)/6
For x > 3: ReLU(HardSwish(x)) = x*6/6 = x
Wait that's interesting! For x > 3, HardSwish(x) = x (since relu6(x+3) = 6 for x ≥ 3).
For 0 ≤ x ≤ 3: x*(x+3)/6.
For x < 0: 0.
But actually ReLU(HardSwish(x)) = max(0, x*(x+3)/6) for x in [0,3], and x for x > 3, and 0 otherwise.
Hmm, but it's easier and more numerically stable to just compute HardSwish then ReLU separately in the kernel. The compiler can optimize it.
Let me write a fused conv kernel. A simple approach:
- Each thread computes one output element
- Or better: each thread computes multiple output elements to improve occupancy and reduce overhead
- Use shared memory for input tiles
Actually, given the sizes, let me think about what cuDNN would do. PyTorch's nn.Conv2d uses cuDNN, which is highly optimized. To beat it, we need to:
1. Fuse the activations, saving memory bandwidth on reading/writing activations
2. For small convs (3x3 with 8→64), cuDNN might not use Tensor Cores since channels are small. We can optimize by specifically choosing thread block sizes that fit our problem.
For 8 input channels and 64 output channels with 3x3 kernel:
- MACs per output: 8 * 3 * 3 = 72 multiplications, 71 additions
- Total output elements: 128 * 64 * 126 * 126 ≈ 128.9M
- Total FLOPs: 128.9M * 72 * 2 ≈ 18.5 GFLOPs
Compute intensity: 18.5 GFLOPs / (read weights + read input + write output)
- Weights: 4608 floats = 18.4 KB (negligible, cached)
- Input: 128 * 8 * 128 * 128 = 16.7M floats = 67 MB
- Output: 128 * 64 * 126 * 126 = 128.9M floats = 516 MB
If we fuse activations, we read input once and write output once.
Memory bandwidth: 67 + 516 = 583 MB
At 3.35 TB/s, this is 0.17 ms.
Compute: 18.5 GFLOPs at 67 TFLOPS = 0.28 ms.
So compute-bound? Actually for fp32 at 67 TFLOPS, 18.5 GFLOPs is about 0.28 ms. The memory is 0.17 ms. Since compute > memory, we're slightly compute-bound, but close.
Actually, Tensor Cores on H100 for FP32 (not TF32) is still 67 TFLOPS. If we could use TF32, it'd be much faster. But we need fp32 precision outputs.
Wait, let me re-read. We need "numerically equivalent outputs at fp32 precision." So we can compute in fp32 using CUDA cores.
Given cuDNN's excellent performance, beating it requires:
1. The fusion of HardSwish+ReLU saves memory bandwidth on intermediate feature maps
2. cuDNN might write the conv output to global memory, then read it back for the activations
3. Our fused kernel avoids these intermediate memory transactions
For the conv itself, cuDNN might use Winograd, implicit gemm, etc. Our custom kernel might not beat cuDNN's conv algorithm, but the fusion could make up for it.
Actually, with 8 input channels and 64 output channels, the conv is quite lightweight in terms of compute per pixel. Let's think about using shared memory effectively.
One approach: each thread block processes a tile of output spatial locations and a subset of output channels.
Let me design a kernel:
- `BLOCK_SIZE = 16` for spatial tile
- Each block computes a 16x16 tile of output spatial locations
- Each block also processes a subset of output channels (e.g., 16 or 32)
- Threads: 16*16 = 256 threads per block, or we could use fewer threads and have each thread compute multiple outputs
Wait, to maximize occupancy and ILP, let's think about:
- BlockDim: 256 threads (16x16 spatial)
- Each thread computes 1 output pixel for 1-2 output channels
- Actually better: have each block compute a tile of output channels and spatial positions
Let me try a different approach. Since 64 output channels is small, and input channels is only 8, we can do:
- Load input tile into shared memory (includes halo for 3x3 kernel)
- Have each thread compute multiple output channels for one spatial position
- Or each thread computes one output channel for one spatial position
Actually, let's think about memory reuse. For a 3x3 conv:
- To compute a 16x16 output tile, we need a 18x18 input tile (with padding=0, so we need to check bounds)
- Input tile size: 18*18*8 floats = 2592 floats = ~10 KB. Fits in shared memory easily.
- Weights: 64*8*3*3 floats. If each block computes all output channels for its spatial tile, each thread would need 64*72 = 4608 MACs. Too many for one thread.
Alternative:
- Block computes 16x16 spatial x some output channels
- For example, block computes 16x16 spatial x 16 output channels
- Threads = 256 (16*16), each thread computes 1 output channel? No, then we need 64/16 = 4 blocks per spatial tile, which is fine.
- Or 16x16 spatial x 8 output channels, threads = 128...
Let me think more carefully:
- Output shape: 126x126 spatial, 64 channels, 128 batch
- Total output: 128 * 64 * 126 * 126
For a 3x3 conv, each output pixel requires 8*3*3 = 72 input pixels.
We can process batch, spatial height, spatial width, and output channels.
Let me try: each thread computes one output element (one pixel, one output channel).
- Grid: (128, 64, 126*126)
- 128 * 64 * 15876 = ~130M threads
- Each thread needs to load 72 input elements from global memory. That's bad.
Better: use shared memory for input tile.
- Block: 16x16 spatial threads, with 16 output channels per block
- Each block computes a 16x16 output tile for 16 output channels
- Need 18x18x8 input elements in shared memory
- We load input cooperatively
- Each thread does 16 * 9 * 8 = 1152 MACs? No, each thread computes one output element for one of the 16 output channels.
Actually, let's have:
- Thread block: 16x16 threads for spatial (256 threads)
- Each thread computes 1 output channel for 1 spatial location
- Block computes 16 output channels for a 16x16 spatial tile
- Total output per block: 16 * 16 * 16 = 4096 values
Loading input:
- Need 18x18x8 elements in shared memory
- 2592 elements, each thread loads ~10 elements
- Then each thread computes for 1 output channel: 72 MACs for 1 spatial position
- Wait, each thread only computes 1 output value? Then 72 MACs per thread. With 256 threads, 18432 MACs per block. That's not great ILP.
Alternative: each thread computes 2 or 4 output channels for 1 spatial position?
- Block computes 16x16 spatial x say 16 output channels
- 256 threads, so each thread could compute 1 output channel for 1 spatial position
- But we have 16 output channels, so we'd need 16 blocks per spatial tile to cover all 64 channels. Actually 4 blocks (64/16 = 4).
Actually, with 64 output channels, maybe have the block compute all 64 output channels for a spatial tile?
- 16x16 spatial tile x 64 channels = 16384 output values
- 256 threads, so each thread computes 64 output values (all channels for 1 spatial position)
- Each thread: for 1 spatial position, compute all 64 output channels. This requires 72 input MACs per output channel = 4608 MACs per thread.
- At 4608 MACs per thread, with fused multiply-add, that's a decent amount of work. We can unroll the inner loop over input channels and kernel size.
For weights, if each thread computes all output channels, each thread needs all weights: 64*8*3*3 = 4608 floats = 18KB. That won't fit in registers well.
Alternative: keep weights in shared memory? 18KB per thread block, but each thread needs all 18KB. That's possible but register pressure would be high (18KB/thread * 256 threads = 4.5MB... no, weights are shared, each thread has 64 accumulators and loads each weight once, using it for all spatial positions).
Actually let me reconsider. If the block computes a 16x16 tile for ALL 64 output channels:
- Input shared memory: 18x18x8 = 2592 floats ≈ 10KB
- Output: 16x16x64 = 16384 floats ≈ 64KB (stored in registers + maybe written directly)
- Weights: 64*8*3*3 = 4608 floats ≈ 18KB. Could keep in shared memory too, but 10+18 = 28KB, still well under shared memory limit (228KB).
Wait, for a thread block with 256 threads:
- Each thread computes for ALL 64 output channels but only a subset of spatial positions?
- If each thread computes 1 spatial position for all 64 channels: 64 accumulators per thread. At 4 bytes each, 256 bytes. 256 threads * 256 bytes = 64KB of register space. That's acceptable.
- Each thread performs 64 * 72 = 4608 multiply-adds. This is good ILP.
- We load input into shared memory cooperatively.
- We keep weights in constant memory or loaded into shared memory.
But 64KB shared memory for input + 18KB for weights. Or weights in global memory with good caching (all threads read same weights in order).
Since all threads in a warp compute different spatial positions but the SAME set of output channels, they will all read the SAME weights at the same time. This is perfect for the L1 cache (or if weights are in shared memory).
Actually, an even better layout:
- Block: 8x8 threads for spatial (64 threads)
- Block computes 16x16 spatial tile (each thread computes 4 spatial positions? e.g., 2x2)
- Or 16x8 spatial threads (128 threads), each computes 1 spatial position, block computes 16x16 spatial x 64 channels
Let me think about register usage. If each thread computes 64 output values:
- 64 float accumulators = 64 registers for accumulators
- Plus some index registers. Total maybe ~70-80 registers.
- At 80 registers/thread, a block of 256 threads needs 20480 registers. Per SM limit is 64K, so 3 blocks per SM. Occupancy = 3/32 = 9%. That's bad.
We need higher occupancy! H100 needs high occupancy to hide latency.
So we should reduce registers per thread. Options:
1. Each thread computes fewer output channels
2. Each thread computes fewer spatial positions
Let's try: block computes 16x16 spatial x 8 output channels
- 256 threads (16x16), each computes 1 spatial position for 1 output channel? But block only computes 8 output channels, so we'd need 8 blocks to cover all 64 channels. That's fine.
- Each thread computes 1 output value, requiring 72 MACs.
- Registers per thread: ~20-30. Occupancy: high.
- But 72 MACs per thread is low ILP. Need to have each thread compute more.
Alternative: block computes 16x16 spatial x 32 output channels
- With 256 threads, maybe arranged as 16x16 spatial, and each thread computes 32/256? That doesn't divide.
Wait, let me try: 16x8 thread block = 128 threads
- Computes 16x16 spatial x 32 output channels
- 128 threads, 16*16*32 = 8192 output values, so 64 values per thread
- Could have each thread compute all 32 channels for 2 spatial positions (e.g., a column of 2)
- Each thread: 32 * 72 * 2 = 4608 MACs. Register: 32*2 = 64 accumulators. Still high.
Alternative: each thread computes 4 output channels for 1 spatial position
- Block: 16x16 threads = 256 threads
- Computes 16x16 spatial x 16 output channels? Wait, 256 threads * 4 channels = 1024 channel-assignments, but we only need 16*16*16 = 4096 output values. So if each thread computes 4 channels for 4 spatial positions...
Let me try:
- Block: 16x16 = 256 threads
- Computes 16x16 spatial x 16 output channels = 4096 output values
- Each thread computes 16 output channels for 1 spatial position? 16 accumulators per thread
- Wait, 256 threads, 4096 outputs, that's 16 outputs per thread
- If each thread computes 16 output channels for 1 spatial position: 16 accumulators, 72*16 = 1152 MACs per thread
- Registers: ~20-25. Good occupancy.
But wait, 256 threads each computing 16 output channels for 1 spatial pos:
- Spatial positions covered: exactly 256 = 16x16. Yes!
- Each block computes one 16x16 spatial tile for 16 output channels.
- To cover 64 output channels: 4 such blocks per spatial tile.
- Total blocks: 128 batch * (126/16 ~ 8) * (126/16 ~ 8) * 4 channel groups ≈ 128 * 8 * 8 * 4 = 32768 blocks. That's a lot!
Actually with 128 batches, each 126x126 spatial, 64 channels:
- Tiles: for height 126 with tile 16: ceil(126/16) = 8. Same for width.
- Channel groups: 64/16 = 4.
- Total blocks: 128 * 8 * 8 * 4 = 32768 blocks.
- With H100 having many SMs, this should be plenty.
Each thread:
- 16 accumulators (floats)
- Loads input from shared memory
- Iterates over input channels (8) and kernel (3x3)
- For each weight set (16 output channels worth), does 16 MACs with one input value
- Total: 8*3*3 = 72 iterations, each doing 16 MACs = 1152 FMAs
- Register usage: maybe 32-40 registers. Good.
Let's refine:
- Shared memory for input: 18x18x8 floats. Actually since padding=0, for most tiles the full 18x18 is valid. For edge tiles, we need bound checks.
- But wait, input is 128x128, kernel 3x3, stride 1, no padding. Output is 126x126.
- So for output position (oh, ow), input needed is (oh:oh+3, ow:ow+3).
- A 16x16 output tile needs input from (oh_start:oh_start+18, ow_start:ow_start+18).
- At edges: oh_start can be 0 to 110 (in steps of 16). Max oh_start+18 = 128. So for tile starting at oh_start=112, we need up to 130, but input height is 128. So some edge tiles need bounds checking.
- Wait, 126 / 16 = 7.875, so tiles at 0, 16, 32, 48, 64, 80, 96, 112.
- 112 + 18 = 130 > 128. So yes, edge handling needed for the last tile in each dimension.
Actually ceil(126/16) = 8. Tiles: [0:16], [16:32], [32:48], [48:64], [64:80], [80:96], [96:112], [112:128].
- Tile 0: needs input [0:18] - valid since input is 128.
- Tile 7 (start=112): needs input [112:130]. But input height is 128, so elements 112-127 are valid, 128-129 are out of bounds.
- Output positions 112-125 are valid (14 positions). Wait, output is 126, so positions 0-125.
- Tile 7 computes outputs 112-125 (14 values), but we set tile size to 16. Some threads compute out-of-bounds outputs.
We need to handle out-of-bounds for both spatial dimensions.
For loading shared memory:
- We load an 18x18 tile (or maybe 16x16 with extra on edges? No, for 3x3 we need 18x18 for a 16x16 output).
- For edge cases, we can either:
a) Use zero padding in shared memory for out-of-bounds loads
b) Check bounds in the main computation loop
Let me use approach (a): when loading input into shared memory, if the load would be out of bounds, store 0. Then the computation is the same for all tiles.
Wait, but for the last tile, output is only 14x14 (positions 112-125 in each dimension). So some threads would compute outputs that don't exist. We can just have those threads return early or not write to output.
- Actually, simpler: have the block load a 18x18 tile (with zero fill for out of bounds). Then in the compute phase, threads beyond the output bounds just don't write.
Let me design the kernel more carefully:
```
// block dimensions: 16x16 threads
// block computes: 16x16 spatial output x 16 output channels
// grid: (ceil(oh/16), ceil(ow/16), batch * (out_ch/16))
__global__ void fused_conv_hardswish_relu(
const float* __restrict__ input,
const float* __restrict__ weight,
const float* __restrict__ bias,
float* __restrict__ output,
int batch, int in_h, int in_w, int in_c,
int out_h, int out_w, int out_c,
int kh, int kw
) {
const int BLOCK_H = 16;
const int BLOCK_W = 16;
const int BLOCK_OC = 16;
// Block coordinates
int block_oc = blockIdx.z % (out_c / BLOCK_OC); // output channel group
int block_b = blockIdx.z / (out_c / BLOCK_OC); // batch index
int block_oh = blockIdx.y * BLOCK_H;
int block_ow = blockIdx.x * BLOCK_W;
// Thread coordinates within block
int tid = threadIdx.y * BLOCK_W + threadIdx.x;
int local_oh = threadIdx.y;
int local_ow = threadIdx.x;
// Output position
int oh = block_oh + local_oh;
int ow = block_ow + local_ow;
int oc_start = block_oc * BLOCK_OC;
// Shared memory for input tile
__shared__ float smem[BLOCK_H + 2][BLOCK_W + 2][8]; // 18x18x8
// Load input into shared memory with zero padding
// We need to load 18x18x8 elements with 256 threads
// Each thread loads 18*18*8 / 256 = 10.125 elements, so some load 10, some 11
// Or we can distribute linearly
int smem_size = (BLOCK_H + 2) * (BLOCK_W + 2) * in_c; // 18*18*8 = 2592
for (int idx = tid; idx < smem_size; idx += BLOCK_H * BLOCK_W) {
int c = idx % in_c;
int w = (idx / in_c) % (BLOCK_W + 2);
int h = idx / (in_c * (BLOCK_W + 2));
int in_h_idx = block_oh + h;
int in_w_idx = block_ow + w;
float val = 0.0f;
if (in_h_idx < in_h && in_w_idx < in_w) {
val = input[((block_b * in_h + in_h_idx) * in_w + in_w_idx) * in_c + c];
}
smem[h][w][c] = val;
}
__syncthreads();
// Load weights for this output channel group into registers or use directly from global
// Since weights are small and all threads use the same weights, we could use L1 cache
// Let's load weight[oc_start:oc_start+BLOCK_OC][in_c][kh][kw]
float acc[BLOCK_OC]; // 16 accumulators
#pragma unroll
for (int i = 0; i < BLOCK_OC; i++) acc[i] = 0.0f;
// Only compute if output position is valid
bool valid = (oh < out_h && ow < out_w);
if (valid) {
for (int ic = 0; ic < in_c; ic++) {
for (int fh = 0; fh < kh; fh++) {
for (int fw = 0; fw < kw; fw++) {
float in_val = smem[local_oh + fh][local_ow + fw][ic];
#pragma unroll
for (int oc = 0; oc < BLOCK_OC; oc++) {
int oc_idx = oc_start + oc;
float w = weight[((oc_idx * in_c + ic) * kh + fh) * kw + fw];
acc[oc] += in_val * w;
}
}
}
}
}
// Apply HardSwish and ReLU, then write back
if (valid) {
#pragma unroll
for (int oc = 0; oc < BLOCK_OC; oc++) {
float x = acc[oc];
// HardSwish + ReLU
// ReLU(HardSwish(x)) = max(0, x * clamp(x + 3, 0, 6) / 6)
float relu6 = fminf(fmaxf(x + 3.0f, 0.0f), 6.0f);
float hs = x * relu6 / 6.0f;
float out_val = fmaxf(hs, 0.0f);
int oc_idx = oc_start + oc;
output[(((block_b * out_h + oh) * out_w + ow) * out_c + oc_idx)] = out_val;
}
}
}
```
Wait, there's an issue with weight indexing. If we load weights in the inner loop from global memory for every thread, that's a lot of redundant loads. But since all threads in a warp (and block) read the SAME weights at the SAME time, they should hit L1 cache and be very fast (broadcast). However, with 72 iterations * 16 channels = 1152 weight loads per thread, all hitting L1, that's still a lot of instruction overhead.
Actually, we could use shared memory for weights too:
- Load all weights for BLOCK_OC output channels into shared memory
- 16 * 8 * 3 * 3 = 1152 floats = 4.6 KB
- We have 10KB for input, so 15KB total. Very reasonable.
Or, even better: load weights into shared memory cooperatively.
Actually, I realize that with 256 threads and smem_size = 2592, loading in a loop like I showed is fine.
Let me reconsider weight access. If we don't use shared memory for weights:
- 1152 weight loads per thread, but all threads load the same values
- These coalesce perfectly (broadcast in L1), but still issue 1152 load instructions per thread
- That's a lot of instructions.
If we use shared memory for weights:
- One-time load of 1152 floats by 256 threads (each loads ~4-5 weights)
- Then the compute loop reads from smem instead of global
- This saves instruction issue bandwidth.
But shared memory is fast but not free. Still, for weights that are reused many times, shared memory is good.
Actually, for a 3x3 conv with 8 input channels and block size 16x16:
- Each weight value is used by 256 threads (all spatial positions in the block)
- wait no: weight[oc][ic][fh][fw] is multiplied with smem[local_oh+fh][local_ow+fw][ic] for all local_oh, local_ow.
- So each weight is used 256 times (once per thread).
- Loading from global once and storing in shared is definitely worth it.
Let me update the design to use shared memory for weights too.
Shared memory layout:
- smem_input: (BLOCK_H+2) * (BLOCK_W+2) * in_c floats
- smem_weight: BLOCK_OC * in_c * kh * kw floats
Total: ~10KB + ~4.6KB = ~14.6KB. Very comfortable.
Loading weights:
```
int weight_size = BLOCK_OC * in_c * kh * kw;
for (int idx = tid; idx < weight_size; idx += BLOCK_H * BLOCK_W) {
int fw = idx % kw;
int fh = (idx / kw) % kh;
int ic = (idx / (kw * kh)) % in_c;
int oc = idx / (kw * kh * in_c);
int oc_idx = oc_start + oc;
smem_weight[((oc * in_c + ic) * kh + fh) * kw + fw] =
weight[((oc_idx * in_c + ic) * kh + fh) * kw + fw];
}
```
Wait, there's a bias too! The PyTorch Conv2d has a bias. I need to include that.
Actually, looking at the reference: `nn.Conv2d(in_channels, out_channels, kernel_size)` - by default bias=True in PyTorch. So yes, there's a bias.
Let me fix:
```
for (int oc = 0; oc < BLOCK_OC; oc++) {
int oc_idx = oc_start + oc;
acc[oc] = bias[oc_idx];
}
```
Now let me think about performance. The major bottleneck might be:
1. For small output channels (64), we need enough blocks to saturate the GPU.
- 32768 blocks as calculated above. H100 has ~132 SMs (actually H100 has 132 or 144 SMs? Let me check - H100 SXM5 has 132 SMs).
- 32768 / 132 ≈ 248 blocks per SM. With high occupancy, this is plenty.
2. Memory bandwidth: We read input once (67MB), write output once (516MB), and read weights (18KB, negligible). Total ~583MB. If we achieve close to memory bandwidth, write throughput is ~3.35TB/s, so theoretical min = 583MB / 3.35TB/s = 0.17ms. But we also need compute: 18.5 GFLOPs. If we get good compute utilization, maybe ~0.2-0.3ms.
Actually, cuDNN conv2d alone might be around 0.1-0.2ms for this size. Then HardSwish + ReLU would be another memory pass over the output (~516MB read + 516MB write = 1GB). At 3.35TB/s that's 0.3ms just for the activations. Total PyTorch: ~0.4-0.5ms.
Our fused kernel: read input (67MB) + compute + write output (516MB) = 0.17ms memory + compute. If compute is ~0.2ms, total ~0.25-0.3ms. This could be faster than PyTorch's 0.4-0.5ms.
But wait, when activation is fused with conv, the input to activation is already in registers/shared memory, not in global memory. So the actual PyTorch overhead is: cuDNN conv writes output to global memory (516MB write), then reads it back (516MB read), applies HardSwish (compute), writes (516MB write), then reads (516MB read), applies ReLU (compute), writes (516MB). So total memory for activation part: 2GB. Plus conv's input read (67MB) and output write (516MB). PyTorch total memory: ~2.6GB. Our kernel: ~583MB. Huge difference.
But cuDNN itself might be faster for the conv than our custom kernel. We need our custom conv to not be too much slower than cuDNN. The memory savings from fusion should more than compensate.
Let me also try a larger BLOCK_OC. With 64 output channels, if we use BLOCK_OC=64:
- Each block computes 16x16 spatial x 64 channels = 16384 outputs
- 256 threads, each computes 64 outputs for 1 spatial position
- 64 accumulators per thread, too many registers.
But what if each thread computes only 1 or a few output channels, but we have fewer threads?
- 16x8 = 128 threads, each computes 64 output channels for 2 spatial positions? Still too many accumulators.
What about: 8x8 threads, each computes 64 output channels for 4 spatial positions? 256 accumulators - way too many.
So per-thread accumulators limit BLOCK_OC. We could try BLOCK_OC=32 with 16x8 threads = 128 threads:
Each thread computes 32 output channels for 2 spatial positions (e.g., along height). That's 64 accumulators. Still a lot.
With BLOCK_OC=16 and 16x16 threads, each thread has 16 accumulators. That's good.
Actually, what if we make the block compute 8x8 spatial x 64 channels?
- 64 threads, each computes 64 channels for 1 spatial position = 64 accumulators. Still many.
Or: 16x16 threads, each computes 4 channels for 1 spatial position, block computes 16 channels total. Then 4 accumulators per thread. Total registers low. But then we need 4 channel blocks per spatial tile. Fine.
Let me compute: 16 accumulators * 256 threads * 4 bytes = 16KB of accumulators in registers. Plus index registers, temp variables. Maybe 25-30 registers per thread. That's quite good.
Wait, what if we increase block spatial size to 32x32?
- BLOCK_H = BLOCK_W = 32
- Block computes 32x32 spatial x 16 channels = 16384 outputs
- 1024 threads (32x32), each computes 1 output = 16 accumulators... wait no, each thread computes 1 spatial position for some channels.
- If we have 1024 threads and 16 output channels, each thread could compute... no, we need each thread to compute a subset.
- With 32x32 = 1024 threads and 16 output channels: if each thread computes all 16 channels for its spatial position, that's 16 accumulators. Fine.
- Shared memory for input: 34x34x8 = 9248 floats = ~37KB. Still under 48KB (which is the default per-block shared memory).
- Actually default shared memory per block is 48KB or 164KB depending on config. On H100 it's 164KB per block max, but default CUDA config often limits to 48KB or 100KB.
Let me stick with 16x16 for now since it's simpler and less shared memory.
Actually, let me think about another optimization: implicit unrolling and vectorization.
With in_c=8, we can process 2 or 4 channels at a time using float2 or float4. But with 16x16 spatial and 16 output channels, loading input channels as float4 when in_c=8 means loading 2 float4s per spatial position.
Another thought: use `__restrict__` and `const` on pointers. Already doing that.
Let me also consider: should we use `float` for everything? Yes, fp32 precision required.
One more thing: thread block size. 16x16 = 256 is good.
Grid size: batch * channel_groups * spatial_tiles.
- batch=128
- channel_groups = ceil(64/16) = 4
- spatial_tiles_h = ceil(126/16) = 8
- spatial_tiles_w = ceil(126/16) = 8
Total: 128 * 4 * 8 * 8 = 32768 blocks.
Each block does 16x16x16 = 4096 MACs. Wait, 16x16 spatial positions, each computing 16 output channels, each requiring 72 MACs. Total per block = 256 * 16 * 72 = 294,912 MACs = ~590K FLOPs.
Total across all blocks: 32768 * 590K ≈ 19.3 GFLOPs. Correct.
At 19.3 GFLOPs in 0.3ms: 19.3 / 0.0003 = 64 GFLOPS. That's only 0.1% of peak (67 TFLOPS). Very low compute utilization. This is definitely memory bandwidth dominated.
Wait, 19.3 GFLOPs is tiny. Compute will be essentially free compared to memory. Our kernel's performance is entirely determined by memory bandwidth utilization.
Memory per block:
- Read input: ~10KB (but with overlap, total unique input read is 67MB)
- Read weights: ~4.6KB per block. 32768 blocks * 4.6KB = 150MB. But weights are the same for all blocks in a channel group! Wait, different channel groups read different weights. 4 channel groups * 16 channels * 8 * 3 * 3 floats = 4 * 1152 * 4 = 18.4KB total weights. Each weight is read by 128 * 8 * 8 = 8192 blocks (all batch and spatial tiles for that channel group). So total weight bytes moved from global to L2/L1: 18.4KB * 8192 ≈ 150MB. But that's if every block misses cache. In practice, weights stay in L2/L1 and are broadcast, so actual HBM traffic for weights is ~18.4KB.
Real HBM traffic:
- Input: 128 * 8 * 128 * 128 * 4 = 67,108,864 bytes ≈ 64 MB
- Output: 128 * 64 * 126 * 126 * 4 = 516,096,768 bytes ≈ 492 MB
- Weights + bias: ~20 KB (negligible)
Total: ~556 MB.
At 3.35 TB/s bandwidth: 556MB / 3.35TB/s = 0.166 ms = 166 microseconds.
If our kernel takes 166 microseconds, that's 100% memory bandwidth utilization. But realistically, we'll get maybe 70-80%, so ~200-240 microseconds.
CuDNN conv2d: Let's estimate. For 18.5 GFLOPs, if cuDNN is memory bandwidth bound too (without fusion), it still has to write output. Plus the activation overhead.
Actually, cuDNN conv for this size might use Winograd or some algorithm that has lower arithmetic complexity or better memory access patterns. But even if cuDNN conv is 150 microseconds, then adding activations (read 516MB + write 516MB = 1032MB at 3.35TB/s = 308 microseconds for naive implementation, but PyTorch might fuse some ops)...
Actually PyTorch probably won't fuse HardSwish+ReLU with Conv2d automatically. It might fuse HardSwish+ReLU if they're adjacent elementwise ops. Let me check:
- nn.Conv2d outputs tensor `x`
- F.hardswish(x) reads x and writes new tensor
- F.relu reads that and writes another
So there are at least 3 tensors involved in memory for the activations alone.
If PyTorch uses one kernel for HardSwish and one for ReLU:
- HardSwish: read 516MB, write 516MB
- ReLU: read 516MB, write 516MB
- Plus conv output write and input read.
Memory traffic in PyTorch:
- Conv input read: 64MB
- Conv output write: 492MB
- HardSwish read: 492MB
- HardSwish write: 492MB
- ReLU read: 492MB
- ReLU write: 492MB
Total: ~2.5 GB.
At 3.35 TB/s: 2.5GB / 3.35TB/s = 746 microseconds.
Even if some of this is cached or fused by PyTorch's JIT, it's still a lot more memory than our kernel's theoretical 556MB.
So our kernel can be up to ~746/166 = 4.5x faster in theory, but only if our conv implementation is nearly as efficient as cuDNN at using memory bandwidth.
One big concern: will cuDNN's conv alone (which writes output) take much less time than our custom conv? If cuDNN conv is, say, 100 microseconds (it has very good memory access patterns), then our custom conv at 200 microseconds is 2x slower on the conv part. But we save on activations. Total PyTorch might be 100 + 100 + 100 = 300 microseconds (if PyTorch fuses HardSwish+ReLU into one kernel, reading conv output and writing final result). Then our 200 microseconds is only 1.5x faster.
Actually, PyTorch's `nn.Hardswish` and `nn.ReLU` are not automatically fused by default in eager mode. But `torch.nn.functional.hardswish` followed by `torch.relu` might be fused by nvFuser if enabled. However, the problem likely benchmarks eager mode.
Let me think about how to make our conv as fast as cuDNN. The key is maximizing L1 cache hit rate and memory coalescing.
For the input loading:
- Our block loads a 18x18x8 tile. With 256 threads, each thread loads ~10 elements.
- Each element is at a different spatial position but same channel (or different channel).
- The access pattern: thread tid loads channels c = tid % 8, position (h, w) = ...
- If we linearize as `idx = tid`, `c = idx % 8`, `w = (idx/8)%18`, `h = idx/(8*18)`, then threads in a warp will have consecutive `idx` and thus varying `c` and `w`.
- Actually, adjacent threads might access strided memory.
Let me redesign the loading to maximize coalescing:
- Load channel by channel, or row by row.
- Better: have each thread load one or more spatial positions for ALL channels (or a group of channels).
- Since channels=8 is small, have each thread load a contiguous chunk of memory.
- Memory layout: NHWC would be better for loading spatial positions across all channels. But PyTorch uses NCHW by default.
Wait! PyTorch default is NCHW. Our input is (N, C, H, W).
- Element at (n, c, h, w) is at offset ((n * C + c) * H + h) * W + w.
- So adjacent `w` values are contiguous.
- To load a row (fixed h, c, varying w): consecutive threads should load consecutive w.
For our input loading, we need:
- smem[h][w][c] = input[n][c][block_oh + h][block_ow + w]
If we map thread tid to (h, w, c):
- We want threads with consecutive tid to access consecutive memory addresses.
- Consecutive memory means varying w, then c, then h.
- `offset = ((n * C + c) * H + block_oh + h) * W + (block_ow + w)`
- So if adjacent threads vary `w` fastest, then `c`, then `h`, we get coalescing.
For 256 threads:
- We need to load 18*18*8 = 2592 elements.
- Each thread loads `ceil(2592/256) = 11` elements (some load 10).
If thread `tid` loads elements `tid`, `tid+256`, `tid+512`, ..., in a linear mapping where element `e` maps to (c=e%8, w=(e/8)%18, h=e/(8*18)):
- Thread 0: w=0, c=0, h=0
- Thread 1: w=1, c=0, h=0
- ...
- Thread 7: w=7, c=0, h=0
- Thread 8: w=8, c=0, h=0 (not contiguous with 7 since w=7 and w=8 are contiguous)
Actually this IS contiguous in memory! Because `w` varies fastest, and adjacent `w` are contiguous.
Let me verify:
- Element (h=0, w=0, c=0): offset = ((n*8+0)*128+block_oh)*128 + block_ow+0
- Element (h=0, w=1, c=0): offset = previous + 1
- ...
- Element (h=0, w=7, c=0): offset = previous + 1
- Element (h=0, w=8, c=0): wait, W=128, so yes, w=8 is offset+8 from w=0.
Yes! If we map linear index `e` to (c=e%8, w=(e/8)%18, h=e/144), then threads with consecutive tid access consecutive memory locations. This is perfectly coalesced.
Let me verify:
- e=0: c=0, w=0, h=0
- e=1: c=1, w=0, h=0 → offset diff: since c changes, offset changes by H*W = 128*128 = 16384. Oh no!
Wait, NCHW layout: ((n*C+c)*H+h)*W+w
- e=0: c=0, w=0, h=0: offset = 0
- e=1: c=1, w=0, h=0: offset = 1*H*W = 16384
These are NOT contiguous!
To get contiguous access, we need to vary `w` fastest but keep `c` fixed for a group of threads:
- Thread 0: c=0, w=0
- Thread 1: c=0, w=1
- ...
- Thread 127: c=0, w=127
But we only need w in [0, 17].
Actually, for contiguous access in NCHW, consecutive threads should vary `w`, with fixed `c` and `h`.
- So a warp of 32 threads processing c=0, h=0, w=0..31 would be perfectly coalesced.
- Then next warp: c=0, h=0, w=32..63, etc.
- Then c=0, h=1, w=0..31, etc.
For loading our 18x18 tile for a specific c:
- We have 128 values (18*18 = 324, wait no, 18*18=324 values per channel).
- To load c=0, h=0, w=0..17: 18 threads, perfectly coalesced.
- c=0, h=1, w=0..17: next 18 threads.
With 256 threads, we can load all of c=0 (324 values) with 324/256 ~ 2 iterations for that channel, then c=1, etc.
Actually, for maximum coalescing:
- Have each thread load a (w, h) position for one or a few channels.
- Map tid to w first: thread `t` loads `w = t % 16`, `h = (t / 16) % 18`, `c = t / (16*18)`... no, we want fixed c and h with varying w.
Better approach: for each channel c and row h, use a warp or group of threads to load w=0..17. Since 18 is not a multiple of 32, some threads in the warp will be idle or load from next row. We can have 18 threads per row, but warps are 32 threads.
Simple approach that still gives decent coalescing:
- Each thread handles one spatial position for all 8 channels.
- tid=0: (h=0,w=0), load c=0..7
- tid=1: (h=0,w=1), load c=0..7
- ...
These accesses in NCHW are separated by H*W for different c, so they're strided. Not great.
But if we transpose in shared memory: store as smem[c][h][w] (channel-major, so adjacent channels are adjacent in smem), then threads with consecutive tid load consecutive w for a fixed c, which is contiguous in global memory. But they store into strided locations in smem.
Actually, for NCHW, the most coalesced load is consecutive threads loading consecutive elements in the W dimension for fixed N, C, H.
Here's a better linearization for the 2592 elements:
- Process by channel: for c=0, there are 324 elements (18x18)
- For c=0: elements are at offset base + h*W + w, where base = ((n*C+c)*H+block_oh)*W + block_ow
- To load row h=0, w=0..17: 18 contiguous elements. We can use 18 threads.
- With 256 threads, in iteration i, each thread handles element i*256 + tid.
If we linearize the smem as row-major: smem[h][w][c] or smem_flat[h*18*8 + w*8 + c]
Then loading element `e` from input for:
- e in [0, 323]: c = e % 8? No, if we want coalescing, we should load all w for fixed c first.
- Actually, we can just load in the natural order of the elements in global memory.
Wait, what if we change our shared memory layout to CHW to match global memory? i.e., `smem[c][h][w]`.
Then when loading c=0, h=0, w=tid..tid+block_size-1, threads load contiguous memory.
But when accessing in the compute loop, we access by (h, w, c). If smem is [c][h][w], threads with different c but same h,w would bank conflict.
Shared memory bank size on H100 is 4 bytes. `smem[c][h][w]` with float: threads accessing same (h,w) but different c would access different banks (since c varies fastest in a 3D array if declared as `float smem[8][18][18]`). Wait, in C/C++, `smem[c][h][w]` -> memory layout is c varies slowest, h in middle, w varies fastest.
- Thread computing (h1, w1, c1) accesses offset = c1*324 + h1*18 + w1
- Thread computing (h1, w1, c2) accesses offset = c2*324 + h1*18 + w1
- Bank = (offset / 4) % 32 (bank size = 4 bytes = sizeof(float), so bank = offset % 32).
- diff = (c2-c1)*324 % 32 = (c2-c1)*(324%32) = (c2-c1)*4
- So c2=1,c1=0: bank diff = 4. No conflict for simultaneous access of different c for same (h,w).
Actually all threads in a warp computing the same (h,w) but different c might have bank conflicts if the bank diff isn't large enough. Let's check: a warp has 32 threads. If they access c=0..31, but we only have 8 channels. So at most 8 threads access different c for the same (h,w) simultaneously. With c diff causing bank diff of 4*c, and 8 channels: c=0..7, banks = base + {0, 4, 8, 12, 16, 20, 24, 28}. All different banks! No conflict.
What about threads accessing different (h,w) with the same c?
- (h1, w1, c) vs (h2, w2, c): offset diff = (h1-h2)*18 + (w1-w2)
- Bank diff = ((h1-h2)*18 + (w1-w2)) % 32
- This could conflict if diff is a multiple of 32. But since w is within [0,17], it's unlikely for nearby threads.
If threads in a warp compute adjacent spatial positions with the same c:
- Thread at local_oh=0, local_ow=0..31: but BLOCK_W=16, so only 16 threads in a row.
- A warp of 32 threads might cover 2 rows of 16.
- Thread (0,0) and (0,1): diff = 1. No conflict.
- Thread (0,0) and (1,0): diff = 18. No conflict.
- Thread (0,0) and (1,14): diff = 18+14 = 32. Bank conflict!
Hmm, (0,0) and (1,14) might be in the same warp. If BLOCK_W=16, a warp of 32 threads covers 2 consecutive rows. Thread 0: (0,0), thread 1: (0,1), ..., thread 15: (0,15), thread 16: (1,0), ..., thread 31: (1,15).
Thread 0 accesses smem[c][0][0], thread 14 accesses smem[c][0][14], thread 16 accesses smem[c][1][0].
Thread 0 and thread 16: diff = 18. Banks 0 and 18. No conflict.
Are there any two threads in the warp with diff multiple of 32?
Thread (0, w1) and (1, w2): diff = 18 + (w2 - w1). Range: 18-15=3 to 18+15=33. So diff=32 when w2 - w1 = 14, e.g., w1=0, w2=14. Threads: (0,0) is thread 0. (1,14) is thread 16+14=30. Both in warp. Bank conflict!
To fix this, we can add padding to the w dimension: `smem[8][18][18+2]` = `smem[8][18][20]`.
Then offset = c*360 + h*20 + w.
Thread (0,0): offset = 0. Thread (1,14): offset = 20 + 14 = 34. Bank diff = 34 % 32 = 2. No conflict.
Or use `smem[8][18][20]`. That's 8*18*20 = 2880 floats. Slightly larger but OK.
For loading weights into shared memory:
- We need `smem_weight[16][8][3][3]`.
- All threads need to read weights for the same (ic, fh, fw) but different oc.
- If we store as `smem_weight[oc][ic][fh][fw]`, threads in a warp accessing different oc but same (ic,fh,fw) access: offset = oc*72 + base.
- Bank diff between oc=0 and oc=1: 72 % 32 = 8. Between 16 threads (oc=0..15): banks are base + {0, 8, 16, 24, 0, 8, 16, 24, ...}. Wait, oc=0: 0, oc=1: 8, oc=2: 16, oc=3: 24, oc=4: 32 ≡ 0. Conflict between oc=0 and oc=4!
With BLOCK_OC=16, and 16 threads in a warp accessing different oc for the same (ic,fh,fw), we have a 4-way bank conflict (banks repeat every 4 oc positions since 72%32=8, and 32/8=4).
To fix, we can pad: `smem_weight[16][8][3][3+1]` or just transpose to `smem_weight2[16][72+8]`? Actually simpler just use another dimension. Or don't use shared memory for weights at all and rely on L1 cache broadcast!
Actually, since all threads in a warp read the same (ic,fh,fw) weights for different oc simultaneously, and the weight array is small (18KB total, fits in L1), reads will hit L1 cache. In L1, each warp can broadcast a single value to all threads. But if different threads in a warp read different addresses, they need separate L1 accesses.
With BLOCK_OC=16 and 16 threads reading different oc for same (ic,fh,fw):
- These are 16 different addresses, spaced by 72 floats (288 bytes).
- L1 cache line is 128 bytes. So each read spans 16 * 4 = 64 bytes per thread... wait, each thread reads one float = 4 bytes. But at 16 different addresses spaced by 288 bytes, they touch 16 cache lines. Each L1 access can handle some number of cache lines per warp, but this is not a broadcast.
Actually, on H100:
- L1 cache can handle one 128B sector per thread per clock. With 32 threads, if they all hit the same sector, it's a broadcast. If they hit different sectors, it takes multiple clocks.
- Our 16 threads access 16 sectors (sector size = 32 bytes? Or 128 bytes?). Let's assume 128B cache line. Each weight is 4B, so 32 weights fit in one cache line. Spacing is 72 weights = 288 bytes = 2.25 cache lines. So each thread access is in a different cache line. This is 16 separate cache line requests, which might take 16 cycles (or more if limited ports).
But with 8 input channels * 3 * 3 = 72 iterations, and 16 cache line requests per iteration, that's 1152 cache line requests per warp. Multiply by 8 warps per block... that's a lot. But weights total only 18KB (288 cache lines of 64B? Actually 18KB / 128B = 144 cache lines). Once these 144 cache lines are in L1, subsequent accesses hit.
Actually wait, each block reads only its BLOCK_OC=16 output channels' weights. That's 16 * 8 * 3 * 3 * 4 = 4608 bytes = 36 cache lines (if 128B). Different blocks read different channel groups. There are 4 channel groups. So total unique weight cache lines = 4 * 36 = 144 cache lines. L1 cache is at least 128KB, so all weights fit easily.
Once a block's weights are in L1, subsequent reads hit. The first read might take some time, but after that it's fast. And since we process the loop sequentially, each (ic,fh,fw) combo is read 16 times (once per oc in the unrolled pragma).
Actually, in our kernel, all 256 threads read the same 16 weights for each (ic,fh,fw). If weights are in L1, L1 can broadcast the same cache line to all threads that need data in it. But our 16 weights for 16 oc values span multiple cache lines. Let's check: weight for oc=0, ic=0, fh=0, fw=0 is at address W0. Weight for oc=1 is at W0 + 72*4 = W0 + 288. These are in different 128B cache lines. So each warp needs 16 cache lines for the 16 oc values. That's not a broadcast.
But within the warp, all 32 threads need these same 16 values! (Because all threads compute different spatial positions but the same 16 output channels). Wait, my kernel design has each thread compute only 1 output channel. So different threads in a warp compute DIFFERENT output channels? Or the SAME?
If we have 16 output channels per block and 256 threads, we could assign:
- Each thread computes all 16 output channels for 1 spatial position.
- Then all threads in a warp computing different spatial positions but same 16 output channels.
- All 32 threads in a warp will read weight[oc=0..15][ic][fh][fw] for ALL 16 channels.
Wait no: if each thread computes all 16 channels, then thread 0 and thread 1 both read weight[0][ic][fh][fw], weight[1][ic][fh][fw], etc. They read the SAME addresses. L1 broadcast!
Let me redesign:
- Block: 16x16 threads, each computes ALL BLOCK_OC output channels for its spatial position.
- With BLOCK_OC=16, each thread has 16 accumulators and reads the same 16 weights as every other thread.
- This is perfect! L1 handles broadcasts beautifully.
But earlier I was worried about registers. With 16 accumulators, it's fine.
So the compute loop:
```
for ic in [0..7]:
for fh in [0..2]:
for fw in [0..2]:
float in_val = smem[local_oh+fh][local_ow+fw][ic];
#pragma unroll
for oc in [0..15]:
acc[oc] += in_val * weight[oc_start + oc][ic][fh][fw];
```
All threads in the block load the SAME weight values. Since weights are tiny and fit in L1, and all accesses are identical across threads, they coalesce into broadcasts. This is optimal.
Registers per thread: 16 accumulators + some temporaries + loop indices. Maybe 30-40 registers. Very good occupancy.
Let me recalculate: 16 accumulators * 4 bytes = 64 bytes. At 40 registers per thread, a block of 256 threads uses 10240 registers. An SM has 65536 registers. So 6 blocks per SM. Max blocks per SM is 32, so register limit gives 6 blocks. Occupancy = 6/32 = 18.75% concurrent warps. Actually 6 blocks * 256 threads = 1536 threads / 2048 per SM = 75% occupancy in terms of thread slots. That's quite good.
Wait, H100 has 2048 threads per SM. 1536/2048 = 75%. Good.
So let me finalize the design:
- BLOCK_H = 16, BLOCK_W = 16, BLOCK_OC = 16
- Block computes 16x16 spatial x 16 output channels
- Each thread computes all 16 output channels for its 1 spatial position
- Grid: (ceil(ow/16), ceil(oh/16), batch * (out_c/16))
- Shared memory: smem_input[18][20][8] (padded in W dimension to avoid bank conflicts), smem_weight[16][8][3][3] (could be in shared or just global - let's use shared for weight too to guarantee performance)
Actually, I don't think we even need shared memory for weights. Since all threads read the exact same addresses in sequence, L1 handles it perfectly. But loading weights into shared memory ensures we don't have any L1 thrashing from other warps. Let's keep weights in shared memory for consistency and to reduce L1 pressure.
Wait, smem for weights: 16 * 8 * 3 * 3 = 1152 floats. Loading this: 256 threads, each loads ~4-5 weight values. Very fast.
Shared memory total: input ~18*20*8 = 2880 floats (~11.5KB) + weights 1152 floats (~4.6KB) + maybe bias 16 floats = ~16KB. Under 48KB, no issues.
Let me write the kernel:
```cuda
#define BLOCK_H 16
#define BLOCK_W 16
#define BLOCK_OC 16
__global__ void fused_conv_hardswish_relu_kernel(
const float* __restrict__ input,
const float* __restrict__ weight,
const float* __restrict__ bias,
float* __restrict__ output,
int N, int C, int H, int W, int K, int OH, int OW
) {
// Block coordinates
int block_ow = blockIdx.x * BLOCK_W;
int block_oh = blockIdx.y * BLOCK_H;
int block_b = blockIdx.z / (K / BLOCK_OC);
int block_oc = blockIdx.z % (K / BLOCK_OC);
int local_ow = threadIdx.x;
int local_oh = threadIdx.y;
int tid = local_oh * BLOCK_W + local_ow;
int ow = block_ow + local_ow;
int oh = block_oh + local_oh;
int oc_start = block_oc * BLOCK_OC;
// Shared memory
__shared__ float smem_in[BLOCK_H + 2][BLOCK_W + 2 + 2][8]; // 18x20x8 with padding in W
__shared__ float smem_w[BLOCK_OC][8][3][3];
__shared__ float smem_b[BLOCK_OC];
// Load input into shared memory with zero padding
int in_h_start = block_oh;
int in_w_start = block_ow;
int smemHW = (BLOCK_H + 2) * (BLOCK_W + 2 + 2);
int smem_elems = smemHW * C; // 18*20*8 = 2880
for (int idx = tid; idx < smem_elems; idx += BLOCK_H * BLOCK_W) {
int c = idx % C;
int tmp = idx / C;
int w = tmp % (BLOCK_W + 2 + 2); // but we only need up to BLOCK_W+2=18
int h = tmp / (BLOCK_W + 2 + 2);
// Only use w < BLOCK_W+2=18, ignore padding column
if (w < BLOCK_W + 2) {
int in_h = in_h_start + h;
int in_w = in_w_start + w;
float val = 0.0f;
if (in_h < H && in_w < W) {
val = input[((block_b * C + c) * H + in_h) * W + in_w];
}
smem_in[h][w][c] = val;
}
}
// Load weights
int weight_elems = BLOCK_OC * C * 3 * 3;
for (int idx = tid; idx < weight_elems; idx += BLOCK_H * BLOCK_W) {
int fw = idx % 3;
int fh = (idx / 3) % 3;
int c = (idx / 9) % C;
int oc = idx / (9 * C);
int global_oc = oc_start + oc;
smem_w[oc][c][fh][fw] = weight[((global_oc * C + c) * 3 + fh) * 3 + fw];
}
// Load bias
if (tid < BLOCK_OC) {
smem_b[tid] = bias[oc_start + tid];
}
__syncthreads();
// Compute
float acc[BLOCK_OC];
#pragma unroll
for (int i = 0; i < BLOCK_OC; i++) {
acc[i] = 0.0f;
}
bool valid = (oh < OH && ow < OW);
if (valid) {
#pragma unroll
for (int c = 0; c < C; c++) {
#pragma unroll
for (int fh = 0; fh < 3; fh++) {
#pragma unroll
for (int fw = 0; fw < 3; fw++) {
float in_val = smem_in[local_oh + fh][local_ow + fw][c];
#pragma unroll
for (int oc = 0; oc < BLOCK_OC; oc++) {
acc[oc] += in_val * smem_w[oc][c][fh][fw];
}
}
}
}
}
// Add bias, apply HardSwish + ReLU, write output
if (valid) {
#pragma unroll
for (int oc = 0; oc < BLOCK_OC; oc++) {
float x = acc[oc] + smem_b[oc];
float relu6 = fminf(fmaxf(x + 3.0f, 0.0f), 6.0f);
float hs = x * relu6 / 6.0f;
float out_val = fmaxf(hs, 0.0f);
int out_idx = (((block_b * K + oc_start + oc) * OH + oh) * OW + ow);
output[out_idx] = out_val;
}
}
}
```
Wait, there are issues:
1. `smem_in` declaration: `[18][20][8]` means h varies slowest? Actually in C, `smem_in[h][w][c]` means c varies fastest (contiguous), then w, then h. So offset = h*20*8 + w*8 + c.
- Thread accessing same (h,w) different c: offset diff = c_diff. No bank conflict (each float is 4 bytes, banks are 4 bytes, so c and c+1 are adjacent banks, but bank = (offset/4) % 32 = offset % 32 when using float. c=0 and c=1 are banks 0 and 1. Good.
- Thread (h,w,c) and (h+1,w-14,c): we added padding specifically for this. With w padded to 20, diff = 20*8 = 160 bytes = 40 floats. Bank diff = 40 % 32 = 8. No conflict between (h,w) and (h+1,w).
2. Weight indexing: `smem_w[oc][c][fh][fw]` means fw varies fastest. Offset = oc*72 + c*9 + fh*3 + fw.
- Threads in a warp accessing same (c,fh,fw) but different oc: offset diff = oc_diff * 72. Bank diff = (oc_diff * 72) % 32 = (oc_diff * 8) % 32. For oc=0..15: banks are {0, 8, 16, 24, 0, 8, 16, 24, ...}.
- With 16 threads accessing oc=0..15 simultaneously: 4-way bank conflict! Every 4 threads map to the same bank.
This is a problem for weights. But we can fix by padding:
- `smem_w[BLOCK_OC][8][3][4]` instead of `[3][3]`. Offset = oc*96 + c*12 + fh*4 + fw. Bank diff = (oc_diff * 96) % 32 = (oc_diff * 0) % 32 = 0!
Wait, 96 % 32 = 0. That means ALL oc values map to the same bank for fixed (c,fh,fw). That's a total conflict.
We want bank_diff to be odd or at least coprime to 32 for consecutive accesses.
If we use `smem_w[BLOCK_OC][8][3][3]` with explicit padding: `smem_w[BLOCK_OC][8][3][4]` -> offset = oc*96 + c*12 + fh*4 + fw. 96 % 32 = 0. Bad.
What about `smem_w[BLOCK_OC][8][4][3]`? offset = oc*96 + c*12 + fh*3 + fw. 96 % 32 = 0. Still bad.
What about `smem_w[BLOCK_OC][9][3][3]`? offset = oc*81 + c*9 + fh*3 + fw. 81 % 32 = 17. Bank diff for oc=1 is 17. For oc=2 is 34%32=2. The pattern {0, 17, 2, 19, 4, 21, 6, 23, 8, 25, 10, 27, 12, 29, 14, 31}. For 16 threads, all different banks! Great.
But C=8, we don't need 9. We can just declare `smem_w[BLOCK_OC][8][3][3]` and pad in the c dimension conceptually by using `smem_w[BLOCK_OC][9][3][3]` but only using 8 channels.
Offset = oc*81 + c*9 + fh*3 + fw. 81 floats * 4 bytes = 324 bytes.
Total size for [16][9][3][3] = 1296 floats = 5.2KB.
Bank for (oc,c,fh,fw) is (oc*81 + c*9 + fh*3 + fw) % 32.
For same (c,fh,fw) and varying oc, banks differ by multiples of 81. Since gcd(81,32) = 1 (32 is 2^5, 81 is 3^4), 81 and 32 are coprime. So as oc varies, banks cycle through all 32 banks before repeating. For 16 threads with oc=0..15, all 16 will be in different banks. Perfect!
Actually, I'm overcomplicating this. We can also just use `__shared__ float smem_w[BLOCK_OC * 8 * 3 * 3];` and manually compute offsets to avoid bank conflicts. But the multidimensional array with padded second dimension works fine.
Similarly for input, instead of [18][20][8], we could use [18][19][8]:
Offset = h*19*8 + w*8 + c = h*152 + w*8 + c.
For (h,w) and (h+1,w-14): diff = 152 - 14*8 = 152 - 112 = 40. Bank diff = 40 % 32 = 8. No conflict.
Is 152%32 = 24. For row diff: bank changes by 24. Is there any w1, w2 such that 24 + (w2-w1)*8 is multiple of 32?
24 + 8k ≡ 0 mod 32 => 8k ≡ 8 mod 32 => k ≡ 1 mod 4. Yes! w2-w1 = 1, 5, 9, 13, 17.
For w2-w1=1: e.g., (h=0,w=0) and (h=1,w=1). Are they in same warp? With BLOCK_W=16:
Thread at (0,0) is tid 0. Thread at (1,1) is tid 16+1=17. Both in warp 0 (threads 0-31). Bank conflict!
So 19 is not enough. Let me check 20:
152+8k ≡ 0 mod 32 for k=... 20*8=160, 160%32=0. Bank diff between rows is 0! Complete conflict between (h,w) and (h+1,w).
Wait, offset = h*20*8 + w*8 + c = h*160 + w*8 + c. diff between (h,w) and (h+1,w) = 160. 160%32=0. Bank conflict between same column in adjacent rows! That's terrible.
I made an error. Let me recalculate for [18][20][8]:
Offset(index) = h*20*8 + w*8 + c (in floats)
Bank = offset % 32.
Thread at (h=0,w=0,c=0): bank 0.
Thread at (h=1,w=0,c=0): bank (160) % 32 = 0. SAME BANK!
Oh no! So using width=20 means every thread in column w accesses the same bank for different rows. Since a warp covers 2 rows of 16, threads 0 and 16 both access bank 0. Conflict!
We need to choose padding such that row_stride % 32 is not 0 and preferably coprime or has large gcd.
If row_stride = width * 8 (since 8 channels). We want (width * 8) % 32 != 0.
width=18: 144 % 32 = 16. Not 0, but:
Thread (0,0): bank 0. Thread (1,14): bank (144 + 14*8) % 32 = (144 + 112) % 32 = 256 % 32 = 0. Conflict between (0,0) [tid0] and (1,14) [tid30]. Yes I identified this earlier.
width=19: 152 % 32 = 24.
Thread (0,0): bank 0. Thread (1,1): bank (152 + 8) % 32 = 160 % 32 = 0. Conflict between tid0 and tid17. Bad.
width=21: 168 % 32 = 8.
Thread (0,0): bank 0. Thread (1,3): bank (168 + 24) % 32 = 192 % 32 = 0. Conflict between tid0 and tid19. Bad.
width=17: 136 % 32 = 8.
Thread (0,0): bank 0. Thread (1,3): bank (136 + 24) % 32 = 160 % 32 = 0. Conflict.
Hmm. We need width*8 + w_diff*8 not ≡ 0 mod 32 for any valid w_diff.
Since w_diff ranges from -15 to +15 (comparing threads in same warp, which spans at most 2 rows of 16):
- If h_diff = 1, we need (width*8 + w_diff*8) % 32 != 0 for all w_diff in [-15, 15].
- This means 8*(width + w_diff) % 32 != 0.
- Since 8*k % 32 = 0 iff k is even, we need width + w_diff to be ODD for ALL w_diff in [-15,15].
But width + w_diff must be odd for all w_diff from -15 to 15. Since this range includes both even and odd values, it's IMPOSSIBLE for width + w_diff to always be odd. For example, if width is even, width+0 is even. If width is odd, width+1 is even.
So with BLOCK_W=16 and 2 rows in a warp, there will ALWAYS be some pair of threads in the warp that have a bank conflict when accessing the same channel in adjacent rows, for any padding!
Wait, this assumes threads in a warp cover exactly 2 rows. With BLOCK_W=16, yes, threads 0-15 are row 0, threads 16-31 are row 1.
How to fix? Make BLOCK_W not 16? If BLOCK_W=32, a warp covers 1 row. Then no adjacent row in same warp. But our output width is 126, so BLOCK_W=32 means ceil(126/32)=4 tiles in width, and input tile is 34x34x8 = 9248 floats = 37KB shared memory. Still OK. Let's check if 34x34 causes issues.
Actually, with BLOCK_W=32:
- A warp covers 32 consecutive threads in x. But block is 32x?
- If BLOCK_H=8 and BLOCK_W=32: 256 threads = 8x32. A warp = 32 threads = one row. No adjacent-row conflict.
- Output tiles: 8x32 spatial per block. 128 batch * ceil(126/8)=16 * ceil(126/32)=4 * 4 channel groups = 32768 blocks. Same as before.
- Shared memory: 10x34x8 = 2720 floats ≈ 11KB per block.
- Wait, BLOCK_H=8, so input tile is (8+2)x(32+2) = 10x34.
- Each thread computes all 16 output channels for 1 spatial position. 16 accumulators per thread. Fine.
Register for accumulators: 16 per thread. Loop variables: a few. Total ~30 registers.
With 256 threads/block: 30*256 = 7680 registers per block. 65536/7680 = 8.5 blocks per SM. Occupancy = 8/32 = 25%. But thread slots: 8*256 = 2048 = full. So 100% occupancy in terms of threads? Actually max threads per SM is 2048. So 8 blocks * 256 = 2048 = max. That's excellent!
Blocks per SM is limited to 32, so 8 is fine. 100% thread occupancy. This is great.
Let me check bank conflicts with BLOCK_H=8, BLOCK_W=32, and shared memory layout [10][34][8]:
- Thread (h,w) and (h+1,w): a warp covers one row, so no adjacent row in same warp.
- But what about thread (h,w) and another thread in the same row? They access different w, same h,c. Offset diff = w_diff*8. For w_diff=4, diff=32, bank conflict (32%32=0).
- Threads in a warp have w=0..31. w_diff can be 4, so threads 0 and 4 access banks 0 and 0. CONFLICT!
For fixed h and c, thread w and thread w+4 access same bank since 4*8=32.
With 32 threads in a warp, this causes 8-way bank conflicts! (because every 4th thread accesses same bank: w=0,4,8,12,16,20,24,28 all access bank 0).
This is because we have 8 channels, and bank size is 4 bytes. Adjacent w are 8 floats apart = 32 bytes = 8 banks apart. So w and w+4 are 32 bytes apart = same bank.
Solutions:
1. Pad channels dimension to 9 or some value such that w_stride * 9 is not multiple of 32?
- If channels=9, w_stride = 9 floats = 36 bytes. w_diff=4 gives 144 bytes = 36 banks. 36 % 32 = 4. No conflict between w=0 and w=4.
- Check w_diff values: for w=0..31 and channels=9, bank = (h*width*9 + w*9 + c) % 32.
- For fixed h,c: bank(w) = (w*9) % 32. Since 9 and 32 are coprime, as w goes 0..31, banks are all different! Perfect! No bank conflicts within a row.
2. Use CHW layout for shared memory but with channel padding to make W_stride coprime to 32.
Actually with C=8, w_stride=8, which shares factors with 32 (both divisible by 8). We need gcd(w_stride, 32) = 1. So w_stride needs to be odd in units of floats. That means we need channel padding so that the fast dimension size is odd.
Since we only use 8 channels but can pad to 9, let me use `smem_in[BLOCK_H+2][BLOCK_W+2][9]` and set the 9th channel to dummy values that we never read.
Actually, we read smem_in[h][w][c] where c goes 0..7. The extra padding in c=8 is not read.
Size: 10*34*9 = 3060 floats ≈ 12.2KB.
Wait, what if instead I just don't use 3D shared memory and instead do manual indexing with a coprime stride?
Let me use `__shared__ float smem_in[10 * 34 * 9];` and compute `idx = h * 34 * 9 + w * 9 + c`. This gives the same coprime property.
For weights, if stored in shared memory: `smem_w[16 * 9 * 3 * 3]` with `idx = oc * 81 + c * 9 + fh * 3 + fw`. Since 81 and 32 are coprime, varying oc gives distinct banks for fixed (c,fh,fw).
Actually, for weights we only need to avoid bank conflicts when threads access different oc for same (c,fh,fw). With 81 floats per oc group: bank = (base + oc*81) % 32. Since 81 and 32 coprime, all 32 banks used before repeating. For 16 threads: all 16 accesses go to different banks. No conflict.
And for input: bank = (h*306 + w*9 + c) % 32, where 306 = 34*9.
Wait, for same warp threads, they have same h but different w.
bank(w) = (w*9 + const) % 32. Since 9 and 32 coprime, 32 consecutive w give 32 distinct banks. For w=0..31, all distinct. No conflict!
Also, for different h: bank(h) = (h*306 + const) % 32. 306 % 32 = 18.
For same w,c in adjacent h: diff = 18. Not 0.
For h_diff=2: diff=36%32=4. Not 0.
For general: need h*306 % 32 = 0. 306%32=18. gcd(18,32)=2. So h_diff*18 ≡ 0 mod 32 when h_diff is multiple of 16. For h in [0,9], no two values differ by 16. So no inter-row conflicts either.
This is great. Let me use channel padding to 9.
Now let me reconsider block size. With BLOCK_W=32, BLOCK_H=8:
- Output spatial per block: 8x32 = 256 positions.
- With BLOCK_OC=16: 256 * 16 = 4096 output values.
- 256 threads, each computes 16 outputs. Good.
- Shared memory: input 10*34*9 = 3060 floats, weights 16*9*3*3 = 1296 floats, bias 16 floats. Total ~4368 floats = 17.5KB.
- Blocks: batch=128, oh_tiles=ceil(126/8)=16, ow_tiles=ceil(126/32)=4, oc_groups=ceil(64/16)=4. Total = 128*16*4*4 = 32768. Same as before.
With this config, each block processes 8x32 spatial. Threads per block = 256. Each thread has:
16 accumulators = 16 registers. Plus shared memory loads, plus loop vars. Maybe 25-30 registers total.
30 * 256 = 7680 registers per block. Can fit 8 blocks per SM (8*256 = 2048 threads = max per SM). Excellent occupancy.
One issue: BLOCK_W=32 means for input loading, we have 34 columns but 256 threads. Let me check loading efficiency.
- smem_in elements actually loaded: 10 * 34 * 8 = 2720 (since c=8 is dummy, we don't need to load it, but we allocate 9).
- Each of the 2720 actual elements needs loading.
- 256 threads, so ceil(2720/256) = 11 loads per thread. Good.
The loading loop:
```
for (int idx = tid; idx < 10*34*9; idx += 256) {
int c = idx % 9;
if (c < 8) {
int w = (idx / 9) % 34;
int h = idx / (9 * 34);
// load
...
smem_in[idx] = val; // access via flat index
}
}
```
For coalescing in global memory:
- c varies fastest, then w, then h. But we skip c=8.
- For global memory NCHW, varying w is contiguous.
- Here idx increments by 256. Since 256 = 28 * 9 + 4 (wait, 256/9 = 28 remainder 4). So idx jumps by 256, wrapping through c, w, h in a complex way.
- Consecutive threads (tid and tid+1) access idx and idx+1.
- idx maps to (c=idx%9, w=(idx/9)%34, h=idx/(9*34)).
- For small idx: idx=0 -> (0,0,0), idx=1 -> (1,0,0). These are NOT contiguous in global memory (NCHW), since varying c changes offset by H*W.
This is bad for global memory coalescing! Consecutive threads should load consecutive global memory addresses for best bandwidth.
To fix: instead of mapping tid linearly to smem, we should have consecutive threads load consecutive w values for fixed c and h.
For our 10x34 tile with 8 channels, we have 2720 actual elements.
Natural NCHW layout for a tile: for each c in 0..7, for each h in 0..9, w in 0..33: contiguous.
Total per (c,h): 34 elements.
With 256 threads, we can load in this order:
- Thread 0: c=0, h=0, w=0..10? No, each thread should load a few contiguous elements, or we can have a phase where all threads load a row.
Let me use 3 nested loops but iterate in the coalescing-friendly order:
For each c in [0..7]:
For each h in [0..9]:
For w = tid to 33 step 256:
load input[n][c][block_oh+h][block_ow+w]
store to smem at position corresponding to (h, w, c)
But 33 < 256, so in this scheme, for a fixed c,h, only 34 threads participate. The rest are idle.
However, we can have multiple (c,h) combinations active simultaneously by interleaving.
Actually, simpler: each thread loads a slice of w from a fixed (c,h), and we process all (c,h) combinations with the loop structure:
```
for (int idx = tid; idx < 2720; idx += 256) {
// Map idx to (c, h, w) in row-major order: c varies slowest, h middle, w fastest
int c = idx / (10 * 34);
int tmp = idx % (10 * 34);
int h = tmp / 34;
int w = tmp % 34;
// load from global: offset = ((n*C + c)*H + block_oh+h)*W + block_ow+w
// consecutive idx -> consecutive w -> consecutive memory!
}
```
Wait, this gives c as the outermost dimension, h middle, w innermost. For idx and idx+1: if they're in the same (c,h), then w differs by 1, which is contiguous in global memory.
If idx is at the end of a w-row (w=33) and idx+1 would start next h, then idx+1 has h increased by 1, w=0. These are NOT contiguous in global memory (since next row is W away).
But for idx and idx+256: they have the same w but differ in c/h. The memory access stride depends on how far apart they are.
Actually, we have 2720 elements, and a block has 256 threads. The first warp (32 threads) loads idx=0..31: all w=0..31, h=0, c=0. These are contiguous! Perfect coalescing.
Next warp loads idx=32..63: w=32..33 (2 elements), then w=0..29, h=1, c=0. Wait:
idx=32: c=0, h=0, w=32 (since c=0, idx in [0, 339], h=0 for [0,33], h=1 for [34,67], etc. Actually idx=32: 32/34=0 w=32. idx=33: w=33. idx=34: h=1, w=0.
So warp 1 (threads 32-63): elements (c=0,h=0,w=32), (c=0,h=0,w=33), then (c=0,h=1,w=0..29). These are not all contiguous!
For contiguous loads, we want each warp to access a contiguous chunk. With 2720 elements not being a multiple of 32, it's messy around boundaries. But overall, this is still mostly coalesced.
Alternatively, we can load row by row:
```
int total_rows = 8 * 10 = 80 rows of 34 elements each.
for (int row = tid / 32; row < 80; row += 8) { // 8 warps, each handles a row
int c = row / 10;
int h = row % 10;
int lane = tid % 32;
if (lane < 34) { // 34 elements per row
int w = lane;
// load element
// ...
}
}
```
But this means each warp only loads 2 elements (34/32 ≈ 2 for some warps), and threads 2-31 in many iterations are wasted. Not efficient.
Let me just go with the linear indexing: `idx` to `(c, h, w)` with w fastest. The imperfect coalescing at row boundaries is minor compared to the overall benefit.
Actually, an even simpler approach: since all elements loaded for one (c,h) are contiguous (34 elements = 136 bytes, just over one cache line of 128 bytes), having a warp straddle two (c,h) combinations is fine. 136 bytes is about 1-2 cache lines. A warp load of 128 consecutive bytes is one cache line request. If the warp needs 136 bytes but starts at an aligned address, it's still 2 cache line requests.
The issue is when threads in a warp access scattered addresses. But with our mapping, a warp gets either:
1. All w for fixed c,h (32 out of 34 values): contiguous, perfectly coalesced.
2. The remaining 2 values + 30 values from next row: the 2 values are at end of one row, 30 at start of next. These are not contiguous (separated by H*W - 2 = 16382 floats = 64KB apart). That's bad.
But wait, with 34 elements per row and 32-thread warps:
- Warp 0: w=0..31 (contiguous)
- Warp 1: w=32..33, then w=0..29 of next row. The first 2 values are 2 floats apart (w=32,33), and w=0 of next row is 2 floats after w=33 in the same row? No! Next row starts at offset + W = +128 from start of current row. Current row w=32 is at offset +32, w=33 at +33. Next row w=0 is at offset +128. So warp 1 accesses offsets {32, 33, 128, 129, ..., 157}. These are NOT contiguous. This causes 32 separate cache line requests! Terrible.
So linear mapping by w-fastest is bad for warps that straddle row boundaries.
Better mapping: let's tile the loading. We have 80 rows (8 channels * 10 h values) of 34 elements each.
80 rows * 34 = 2720 elements.
Instead, we can process 34-element rows as the atomic unit. Each row requires 34 threads. We have 256 threads = 7 full rows + 18 threads for the 8th row.
Actually 256/34 = 7.5. So in iteration 0:
- Threads 0-33 load row 0 (c=0,h=0, w=0..33)
- Threads 34-67 load row 1 (c=0,h=1)
- ...
- Threads 238-271 would load row 7, but we only have up to thread 255. So threads 238-255 load row 7 (c=0,h=7), w=0..17.
Then iteration 1: add 256 / 34 = 7 remainder 18, so thread 0-33 load row 7 starting at w=18? This gets complicated.
Simpler: just map thread to a specific (h,w,c) and let the hardware handle the uncoalesced access? With 256 threads and 2720 loads, the efficiency might still be OK since L1 cache can help.
Actually, let me think differently. For loading shared memory, we could use 1D blocks of 256 threads and have each thread load contiguous elements from global memory. Then transpose in shared memory.
Wait, what if we load the input as NCHW but into shared memory with a special layout that makes the compute phase bank-conflict-free?
For compute phase, we access `smem[h+fh][w+fw][c]` for all threads in parallel. For fixed c and varying h,w, we want no bank conflicts among threads in a warp.
As we established, with C_pad=9, w_stride=9 floats, and threads in a warp having w=0..31 (for BLOCK_W=32), banks are (h*306 + w*9 + c) % 32. For fixed h,c, bank(w) = (w*9 + const) % 32. Since 9 and 32 coprime, all distinct. No conflict.
But for loading, we want consecutive threads to access consecutive global memory for coalescing.
If thread tid loads `smem[h][w][c]` where `c = tid % 8?` No, global memory is NCHW: consecutive in W dimension.
Here's an idea: have each thread load contiguous global memory and write to strided shared memory. The loading is coalesced, writing to shared memory might have conflicts, but shared memory writes are cheaper to handle conflicts than global memory reads.
Actually, what if we change the compute loop to use a different access pattern? If we load input with thread `tid` loading elements `tid, tid+256, tid+512, ...` from global memory, where these are consecutive in W for fixed C,H, then writes to smem are strided, but since it's a one-time write, bank conflicts during the write are acceptable.
Then in the compute phase, we read from smem. With our padded layout, reads have no bank conflicts.
So for loading:
```
for (int idx = tid; idx < 2720; idx += 256) {
// idx corresponds to the linear index in NCHW for the tile portion
// But the tile is not contiguous in global memory due to row boundaries.
}
```
Hmm, the input tile spans multiple rows, and rows in NCHW are separated by W elements. So even within one row, elements are contiguous, but across rows there's a gap.
What if each thread loads an entire row (all 34 elements)? 80 rows, 256 threads. Each thread loads ~0.3 rows. Not great.
What if we have threads load one element each, and we accept some non-coalescing? The total to load is 2720 floats = 10.9 KB per block. Even at 50% coalescing efficiency, it takes very little time. And it's done once per block.
Given that compute is 1152 FMAs per thread (dominant), the loading overhead is small. I'll use simple linear indexing for the smem-to-global mapping and not worry too much about perfect coalescing for the loading phase.
Wait, actually I can improve on the loading. For NCHW, if we have each thread handle one element with index based on (h, w) in the tile:
- Threads with the same (c, h) but consecutive w access contiguous memory.
- If consecutive tids sometimes have consecutive w, we get coalescing.
What if we swap thread organization? Instead of threadIdx.x mapping to w, let threadIdx.x be a flat linear index, and we remap it:
```
int total_warp_loads = (2720 + 31) / 32; // 85 warp-loads of 32 elements each... no, 2720 / 32 = 85 exactly.
int warp_id = tid / 32;
int lane = tid % 32;
int base_idx = warp_id * 32 + lane;
```
Then base_idx maps to (c, h, w) with w fastest. For a full warp, base_idx = k*32 to k*32+31. If these all fall in one (c,h) row (which has 34 elements), then the warp accesses 32 contiguous elements. Perfect!
If the warp straddles two rows: only when base_idx is in the last 2 elements of a row and the next 30 of the next row. This happens for warp_id such that base_idx starts in {2, 4, 6, ...} elements before a row boundary. Specifically, if base_idx % 34 is in [0, 1], then the warp spans two rows (since 32 elements starting at offset 2 in a 34-element row will hit elements 2..33, which is 32 elements entirely in one row). Wait:
- Start at offset 0: elements 0..31 (all in one row)
- Start at offset 1: elements 1..32 (all in one row, row has 0..33)
- Start at offset 2: elements 2..33 (all in one row)
- Start at offset 3: elements 3..33 (31 elements in one row), then element 0 of next row (not contiguous)
So for base_idx % 34 in [0, 2], the 32-element segment fits in one row. That's 3 out of 34 starting positions = ~9%. 91% of warp-loads are perfectly coalesced within one row. 9% span two rows and are scattered.
But actually, `base_idx = warp_id * 32 + lane`. As warp_id increases, base_idx goes 0..31, 32..63, 64..95, etc.
- Warp 0: 0..31 → row 0, elements 0..31 (coalesced)
- Warp 1: 32..63 → row 0, elements 32..33 (2 elements), then row 1, elements 0..29 (30 elements). Scattered.
Wait, row 0 is elements 0..33 (34 elements). Warp 1 starts at 32. Elements 32,33 are in row 0. Elements 34,35,... are in row 1 (since 34 = 1*34). So warp 1 accesses elements from row 0 (w=32,33) and row 1 (w=0..29). Not contiguous.
Warp 2: 64..95 → row 1 (w=30..33, 4 elements) + row 2 (w=0..27, 28 elements). Scattered.
Warp 3: 96..127 → row 2 (w=28..33, 6 elements) + row 3 (w=0..25). Scattered.
This happens for many warps!
Hmm, 2720 / 32 = 85 warps exactly. Since 34 and 32 are close, many warps straddle boundaries.
- Warp k: elements [32k, 32k+31]. Row boundary at every multiple of 34.
- 32k mod 34: for k=0: 0, k=1: 32, k=2: 30, k=3: 28, ... k=16: 0, etc.
- When 32k mod 34 is in [0, 2], the warp is contained in one row.
- 32k ≡ m mod 34. 32k = 34q + m.
- This happens when 32k mod 34 <= 2. Since 32 ≡ -2 mod 34, 32k ≡ -2k mod 34.
- We need -2k mod 34 in {0,1,2}, i.e., 2k mod 34 in {0, 32, 33}.
- 2k mod 34 = 0 when k is multiple of 17.
- 2k mod 34 = 32 when 2k ≡ 32 mod 34 => k ≡ 16 mod 17.
- 2k mod 34 = 33: impossible since LHS is even.
- So k ≡ 0 or 16 mod 17. Out of k=0..84, there are 5 multiples of 17 (0,17,34,51,68) and 5 values ≡ 16 mod 17 (16,33,50,67,84). Total 10 warps out of 85 are perfectly coalesced. That's only 12%.
This is not good. We'd rather have each warp load from a single row.
Let me redesign the loading: process row by row.
Each row has 34 elements. A warp can load 32 elements.
For each row, we need 2 warp-loads (32 + 2 elements).
Implementation:
```
int rows_per_block = C * (BLOCK_H + 2); // 8 * 10 = 80 rows
for (int row = tid / 32; row < rows_per_block; row += 8) { // 8 warps
int c = row / (BLOCK_H + 2);
int h = row % (BLOCK_H + 2);
int lane = tid % 32;
int w = lane;
if (w < BLOCK_W + 2) { // 34
// load element (c, h, w)
int global_h = block_oh + h;
int global_w = block_ow + w;
float val = 0.0f;
if (global_h < H && global_w < W) {
val = input[((block_b * C + c) * H + global_h) * W + global_w];
}
// Store to smem
smem_in[h * 34 * 9 + w * 9 + c] = val;
}
}
```
Wait, this only loads w=0..31 for each row. We need w=32,33 too.
For the second warp on the same row: w = 32 + lane, but lane < 32, so w=32..63. We only need up to 33. So condition `w < 34`.
Actually we can handle this by having the warps take turns more carefully. With 8 warps, row 0 is handled by warp 0, row 1 by warp 1, ..., row 7 by warp 7, row 8 by warp 0, etc.
For each row, the warp loads ALL elements of that row, but a warp only has 32 lanes and a row has 34 elements. So we need a second pass or some lanes load 2 elements.
A cleaner way: have each thread load a contiguous chunk. With 256 threads and 80 rows of 34 elements:
80 * 34 = 2720 elements. 2720 / 256 = 10.625 elements per thread.
Most threads load 11 elements, some load 10.
We can have each thread load `for (int i = 0; i < 11; i++) { idx = tid * 11 + i; if (idx < 2720) load(idx); }`
The accesses of consecutive threads are 11 elements apart. For global memory NCHW:
- Thread 0: idx=0..10 → (c=0,h=0,w=0..10)
- Thread 1: idx=11..21 → (c=0,h=0,w=11..21)
These are contiguous! Thread 0 accesses w=0..10, thread 1 accesses w=11..21. The memory accesses are consecutive across threads.
Wait, thread 0's last element is w=10. Thread 1's first element is w=11. These are contiguous!
Actually, if each thread loads `idx = tid * 11 + i` for i=0..10:
- Thread 0: global indices 0, 1, 2, ..., 10
- Thread 1: global indices 11, 12, ..., 21
- Thread 2: 22, 23, ..., 32
But thread 0, element 0 is at global_w=0. Thread 1, element 0 is at global_w=11. Are these contiguous? No, thread 0 accesses 11 elements starting at 0. Thread 1 accesses 11 elements starting at 11. The elements are contiguous across threads.
Actually in terms of memory access: if thread 0 reads address A, A+1, A+2, ..., A+10, and thread 1 reads A+11, A+12, ..., A+21, etc., then the memory access pattern per instruction is: all threads read their i-th element simultaneously.
- i=0: thread 0 reads A, thread 1 reads A+11, thread 2 reads A+22, etc. These are 11 apart. NOT coalesced.
- i=1: thread 0 reads A+1, thread 1 reads A+12, etc.
This is a strided access with stride 11. L1 cache can handle some striding, but it's not ideal.
But what if we use `idx = i * 256 + tid`? That's back to the linear mapping where consecutive threads read consecutive memory... until row boundaries.
Let me just accept that loading 10.9KB with imperfect coalescing is fine. The time saved by a simpler kernel might outweigh optimizing the loading. Or we can just have each thread load one element at a time with `idx = i * blockDim.x + threadIdx.x`, which gives mostly coalesced access except at row boundaries, and iterate through w first. Since 34 is close to 32, the number of straddling warps is high but the total volume is small.
Actually, an even simpler loading scheme:
For each c in 0..7, we need to load 10*34 elements. Use 256 threads per c. Since 340 elements per channel and 256 threads, each thread loads 1 or 2 elements. For the 340 elements, warps access consecutive memory. 340/32 = 10.625 warps per channel. Some warps straddle row boundaries (since 340 is not a multiple of 32). But this is manageable.
Wait, what if we launch 256 threads and have them load a full 34x10 tile for each channel? For each channel c:
```
int base_offset = ((block_b * C + c) * H + block_oh) * W + block_ow;
for (int idx = tid; idx < 10 * 34; idx += 256) {
int h = idx / 34;
int w = idx % 34;
float val = (block_oh + h < H && block_ow + w < W) ? input[base_offset + h * W + w] : 0.0f;
smem_in[h * 34 * 9 + w * 9 + c] = val;
}
```
For channel c=0: idx=0..339. Consecutive threads read consecutive w for fixed h. Coalesced within rows. The h*W stride for global memory means threads with idx across row boundaries read non-contiguous memory, but only the warps that straddle row boundaries are affected.
With 340 elements and 32-thread warps: 10 full warps (320 elements) + 1 partial warp (20 elements).
Warp 0: idx=0..31 → h=0, w=0..31. Contiguous.
Warp 1: idx=32..63 → h=0, w=32..33 (w=32,33) then h=1, w=0..29. NOT contiguous (gap of W-2 = 126 elements between w=33 and next row w=0).
Warp 2: idx=64..95 → h=1, w=30..33 then h=2, w=0..27. Not contiguous.
...
This happens for every row transition. With 10 rows, and 32-element warps, many warps straddle boundaries.
But wait! We can choose the thread block dimensions differently to avoid this. What if BLOCK_W=34? Then for each row (c fixed), we have 34 elements. And our BLOCK_W for spatial could also be 34. But output is 126, so block width 34 means ceil(126/34)=4. Input width for block starting at 34k is 34, 34, 34, 24 (for last block: 102-126, width=24). Tile sizes vary. This adds complexity.
What if we use BLOCK_H=4, BLOCK_W=34? Then 136 threads per block. Not a multiple of 32 (warp size). Hmm.
Actually, none of the block sizes will perfectly tile both the computation and the loading for arbitrary H,W. And since the loading is only 10.9KB, I'll just go with the simple loop. On H100 with L1 cache, even imperfect coalescing for 10.9KB is very fast.
Let me check if I should just skip shared memory for input and load directly from global. For global loads, if all threads in a warp read the same 3x3 neighborhood but different centers, there's some overlap but no reuse across threads. L1 cache handles it somewhat, but without explicit shared memory, each thread does 72 global loads, and 256 threads * 72 loads = 18432 loads per block. With shared memory, each block does only 2720 global loads. Shared memory gives 6.8x reduction in global memory traffic. Definitely worth it.
Another optimization: can we use `__ldg` (load through read-only cache)? For input and weights, yes, since we don't write to them. But explicit shared memory is still better.
Let me go ahead and write the kernel. I'll accept imperfect loading coalescing for simplicity, as the total loading volume is small.
Actually wait, there's a much simpler way to load coalesced:
Use 2D thread blocks where x dimension covers width and y dimension covers height for loading.
For input loading, each thread in the block can be responsible for a specific (h, w) position. Since we have 256 threads and 340 positions per channel (10*34), and 8 channels, for a 2D block of 16x16:
- We have 256 threads. For loading channel c, we need 340 loads. We can use the 16x16 threads to cover a 16x16 portion, and have some threads handle multiple elements.
- But the memory layout is NCHW, so threads with consecutive x (varying w) access contiguous memory. If x covers w, and y covers h, then a row of threads with fixed y and varying x access contiguous memory (for fixed c). This is coalesced!
For BLOCK_W=32, BLOCK_H=8: 256 threads = 8x32. For loading input:
- For each channel c, we have 10x34 elements to load.
- We have 8x32 threads. If we map thread (ty, tx) to (h=ty, w=tx) for the first 8 rows and first 32 columns: we load rows 0..7, cols 0..31.
- We still need: row 8,9, and cols 32,33 for all rows.
- We can have additional passes: for the remaining 2 rows, reuse the threads. For cols 32,33, use some threads.
I think I was overthinking this. Let me take a step back. If we use a flat loop with `idx = tid; idx < load_size; idx += blockDim.x`, consecutive `idx` values with w varying fastest give mostly coalesced access. The fact that some warps cross row boundaries is a minor issue for 10.9KB.
Let me just optimize the actual compute loop and ensure the main bottleneck (register pressure, instruction throughput) is handled well.
Also, I should verify that PyTorch Conv2d with bias=True is the default. Yes, it is.
One more thing: the problem says outputs must match fp32 precision. If I use `fminf`, `fmaxf`, these should match PyTorch's HardSwish and ReLU exactly? PyTorch uses:
- HardSwish: x * torch.clamp(x + 3, 0, 6) / 6
- ReLU: torch.clamp(x, 0, inf)
For fp32, `fminf(fmaxf(x + 3.0f, 0.0f), 6.0f)` might not exactly match `torch.clamp` but should be very close. The reference uses default PyTorch kernels which likely compile to similar operations.
Actually, let me check: PyTorch's hardswish formula:
`hardswish(x) = x * hardtanh(x+3, 0, 6) / 6`
Where `hardtanh` is `max(min(x+3, 6), 0)`. In CUDA, this would be `fminf(fmaxf(x + 3, 0), 6)`. Same as what I'm using.
Then ReLU is `max(x, 0).`
Let me also think about whether I should compute `ReLU(HardSwish(x))` more efficiently. We established that HardSwish(x) ≤ 0 for x ≤ 0, and ReLU just zeros out negatives. So:
`ReLU(HardSwish(x)) = HardSwish(max(x, 0))?` No, because for x > 3, HardSwish(x) = x (if x > 3, x+3 > 6, so relu6(x+3)=6, HardSwish=x*6/6=x). Wait, for 0 ≤ x ≤ 3, HardSwish(x) = x*(x+3)/6. If we compute HardSwish(ReLU(x)) = HardSwish(max(x, 0)) = HardSwish(x) for x ≥ 0, and HardSwish(0) = 0 for x < 0. This is exactly ReLU(HardSwish(x))!
So `ReLU(HardSwish(x)) = HardSwish(ReLU(x))`? Let me verify:
For x < 0: RHS = HardSwish(0) = 0 * relu6(3)/6 = 0. LHS = ReLU(HardSwish(x)) = ReLU(negative or 0) = 0. Match.
For x ≥ 0: RHS = HardSwish(x) = x * relu6(x+3)/6. LHS = ReLU(HardSwish(x)). For x ≥ 0, is HardSwish(x) always ≥ 0?
- If 0 ≤ x ≤ 3: x*(x+3)/6 ≥ 0. Yes.
- If x > 3: x * 6 / 6 = x ≥ 0. Yes.
So LHS = HardSwish(x). Match.
Therefore `ReLU(HardSwish(x)) = HardSwish(ReLU(x))`.
But does this help? Not really, since we just compute HardSwish and ReLU in sequence. The only potential optimization is that if we compute `y = ReLU(x)` first, then `HardSwish(y)`, we might save some operations for x < 0. But:
- For x < 0: ReLU(x) = 0, HardSwish(0) = 0. We can early out.
- For x ≥ 0: HardSwish(x) as before.
But this requires branching, which is worse than just computing both. So we'll keep it simple: compute HardSwish then ReLU. Or just compute the fused op directly.
Actually, `y = x * clamp(x + 3, 0, 6) / 6; z = max(y, 0);`
This requires:
1. x + 3
2. max(x+3, 0)
3. min(x+3, 6)
4. multiply x * result
5. divide by 6
6. max with 0
That's about 6 operations per element. Since we have 16 elements per thread, 96 operations. With 1152 FMAs, the activation overhead is 8%. Small.
Could we simplify? Since we know `max(y, 0) = HardSwish(max(x, 0))` doesn't actually simplify the computation:
`y = max(x, 0); result = y * clamp(y + 3, 0, 6) / 6;`
For x < 0: y=0, result=0.
For x ≥ 0: y=x, result = x * clamp(x+3, 0, 6) / 6.
This is the same number of ops.
Let me just write the kernel as designed and see how it performs.
Wait, one more optimization: can I use `__launch_bounds__(256, 8)` to help the compiler? H100's occupancy is limited by threads (2048 max), so 256 threads * 8 blocks = 2048. `__launch_bounds__(256, 8)` tells the compiler to use at most enough registers to allow 8 blocks per SM.
Actually, let me check: 65536 registers / (256 * 8) = 32 registers per thread. If we can fit in 32 registers, we get 8 blocks. If we use 33 registers, we get 7 blocks. So staying under 32 registers is valuable.
With 16 accumulators and maybe 10 temp registers, we might be at 26. Adding __launch_bounds__(256, 8) would tell the compiler to try hard to stay at 32. Let me add it.
Here's the complete kernel:
I need to be careful about indexing. PyTorch conv2d with kernel_size=3, stride=1, padding=0 gives output dimensions = input - kernel_size + 1 = 128 - 3 + 1 = 126. Correct.
Let me also handle potential integer division and boundary cases correctly.
One concern: when block_oh + BLOCK_H >= OH. With OH=126 and BLOCK_H=8, tiles at 0, 8, 16, ..., 120.
block_oh=120: valid output rows 120..125 (6 rows). Threads with local_oh >= 6 should not compute or write.
Similarly for block_ow=96 (with BLOCK_W=32): valid cols 96..126 (but OW=126, so cols 96..125 = 30 cols). With block_ow=96 and BLOCK_W=32, ow=96..127. But OW=126, so ow >= 126 is out of bounds.
My `valid` check: `bool valid = (oh < OH && ow < OW);` handles this correctly.
For shared memory loading: if block_oh + h >= H or block_ow + w >= W, we load 0.0f. With H=W=128, block_oh ranges up to 120, h up to 9 (BLOCK_H+2=10). 120+9=129 >= 128, so some loads are out of bounds for the last row tile. We handle this with `if (in_h < H && in_w < W)`.
Now let me think about the exact code structure.
```cuda
#include <torch/extension.h>
#include <cuda_runtime.h>
#include <cuda_fp16.h>
#define BLOCK_H 8
#define BLOCK_W 32
#define BLOCK_OC 16
#define WARP_SIZE 32
__global__ void __launch_bounds__(BLOCK_H * BLOCK_W, 8)
fused_conv_hardswish_relu_kernel(
const float* __restrict__ input,
const float* __restrict__ weight,
const float* __restrict__ bias,
float* __restrict__ output,
int N, int C, int H, int W, int K, int OH, int OW
) {
int block_ow = blockIdx.x * BLOCK_W;
int block_oh = blockIdx.y * BLOCK_H;
int block_b = blockIdx.z / (K / BLOCK_OC);
int block_oc = blockIdx.z % (K / BLOCK_OC);
int local_ow = threadIdx.x;
int local_oh = threadIdx.y;
int tid = local_oh * BLOCK_W + local_ow;
int ow = block_ow + local_ow;
int oh = block_oh + local_oh;
int oc_start = block_oc * BLOCK_OC;
__shared__ float smem_in[(BLOCK_H + 2) * (BLOCK_W + 2 + 2) * 9]; // 10*34*9, only use 8 channels
__shared__ float smem_w[BLOCK_OC * 9 * 3 * 3]; // 16*9*3*3
__shared__ float smem_b[BLOCK_OC];
// Load input
int in_h_start = block_oh;
int in_w_start = block_ow;
int load_elems = (BLOCK_H + 2) * (BLOCK_W + 2) * C; // 10*34*8 = 2720
// Using a layout where c is fastest in smem to avoid bank conflicts
// smem_in[h * 34 * 9 + w * 9 + c]
for (int idx = tid; idx < load_elems; idx += BLOCK_H * BLOCK_W) {
int c = idx % C;
int tmp = idx / C;
int w = tmp % (BLOCK_W + 2);
int h = tmp / (BLOCK_W + 2);
int in_h = in_h_start + h;
int in_w = in_w_start + w;
float val = 0.0f;
if (in_h < H && in_w < W) {
val = input[((block_b * C + c) * H + in_h) * W + in_w];
}
smem_in[h * (BLOCK_W + 2 + 2) * 9 + w * 9 + c] = val;
}
// Load weights
int weight_elems = BLOCK_OC * C * 3 * 3;
for (int idx = tid; idx < weight_elems; idx += BLOCK_H * BLOCK_W) {
int fw = idx % 3;
int fh = (idx / 3) % 3;
int c = (idx / 9) % C;
int oc = idx / (9 * C);
int global_oc = oc_start + oc;
float wval = weight[((global_oc * C + c) * 3 + fh) * 3 + fw];
// smem_w layout: oc * 81 + c * 9 + fh * 3 + fw
smem_w[oc * 81 + c * 9 + fh * 3 + fw] = wval;
}
// Load bias
if (tid < BLOCK_OC) {
smem_b[tid] = bias[oc_start + tid];
}
__syncthreads();
// Compute
float acc[BLOCK_OC];
#pragma unroll
for (int i = 0; i < BLOCK_OC; i++) {
acc[i] = 0.0f;
}
bool valid = (oh < OH && ow < OW);
if (valid) {
#pragma unroll
for (int c = 0; c < C; c++) {
#pragma unroll
for (int fh = 0; fh < 3; fh++) {
#pragma unroll
for (int fw = 0; fw < 3; fw++) {
int smem_h = local_oh + fh;
int smem_w = local_ow + fw;
float in_val = smem_in[smem_h * 34 * 9 + smem_w * 9 + c];
#pragma unroll
for (int oc = 0; oc < BLOCK_OC; oc++) {
acc[oc] += in_val * smem_w[oc * 81 + c * 9 + fh * 3 + fw];
}
}
}
}
}
// Write output
if (valid) {
#pragma unroll
for (int oc = 0; oc < BLOCK_OC; oc++) {
float x = acc[oc] + smem_b[oc];
float relu6 = fminf(fmaxf(x + 3.0f, 0.0f), 6.0f);
float hs = x * relu6 / 6.0f;
float out_val = fmaxf(hs, 0.0f);
output[(((block_b * K + oc_start + oc) * OH + oh) * OW + ow)] = out_val;
}
}
}
```
Wait, I need to double check my shared memory indexing macros or calculations. Let me use constants to avoid confusion.
```cuda
#define IN_PAD_W (BLOCK_W + 2 + 2) // 36? No, I wanted 34. Let me recalculate.
```
BLOCK_W = 32. We need BLOCK_W + 2 = 34 for shared memory width. I declared `(BLOCK_W + 2 + 2)` = 36 in the smem size but only use width=34. That's fine, just a bit wasteful.
Let me define:
```cuda
#define SMEM_W (BLOCK_W + 2) // 34
#define SMEM_C_PAD 9
#define SMEM_OC_STRIDE 81 // 9*9, but we want c_pad * 3 * 3 + padding... Let me recalculate.
```
For weights: `smem_w[oc][c][fh][fw]` with c padded to 9.
Offset = oc * (9 * 3 * 3) + c * (3 * 3) + fh * 3 + fw = oc * 81 + c * 9 + fh * 3 + fw.
`SMEM_W_STRIDE = 81`.
For input: `smem_in[h][w][c]` with w padded to SMEM_W=34 and c padded to 9.
Offset = h * (34 * 9) + w * 9 + c = h * 306 + w * 9 + c.
`SMEM_IN_STRIDE = 306`.
Wait, I had `(BLOCK_W + 2 + 2) * 9` earlier, which is 36 * 9 = 324. But I only need 34 * 9 = 306. Let me fix:
```cuda
#define SMEM_W_PADDED (BLOCK_W + 2) // Actually this is the width we need. But we declared 36.
__shared__ float smem_in[(BLOCK_H + 2) * (BLOCK_W + 2) * 9]; // 10 * 34 * 9
```
Wait, earlier I was worried about bank conflicts and said we need a coprime w_stride. With w varying and c padded to 9, w_stride = 9, which is coprime to 32. Good. But we don't need to pad w itself. The w dimension is the actual width we need (34). The h_stride is 34 * 9 = 306. 306 % 32 = 18. For same (w,c), h and h+1 differ by 18 in bank. Since different h can conflict if 18*h_diff ≡ 0 mod 32, which happens when h_diff is multiple of 16. For h in [0, 9], no conflict. Good.
So we can define:
```cuda
#define SMEM_W (BLOCK_W + 2) // 34
#define SMEM_C_PAD 9
#define SMEM_IN_H_STRIDE (SMEM_W * SMEM_C_PAD) // 306
```
The shared memory array size should be `(BLOCK_H + 2) * SMEM_IN_H_STRIDE` = 10 * 306 = 3060 floats.
But in C/C++, for indexing I can just use a flat array.
Let me also make sure the loading loop accesses the correct global memory for each smem position.
For `idx` in `[0, load_elems)` where `load_elems = (BLOCK_H + 2) * SMEM_W * C`:
The mapping should be consistent with how the compute phase reads.
`idx` → (h, w, c) where `c = idx % C`, `w = (idx / C) % SMEM_W`, `h = idx / (C * SMEM_W)`.
Smem position: `h * SMEM_IN_H_STRIDE + w * SMEM_C_PAD + c`.
In global memory:
`input[((block_b * C + c) * H + block_oh + h) * W + block_ow + w]`
This loads row-major for each channel. Wait, `c` varies fastest in the loop but it's the outer dimension in NCHW. In global memory:
For fixed c, varying w is contiguous.
In our smem layout: varying c is contiguous (since c is fastest).
So when loading idx=0 to idx=min(SMEM_W, blockDim.x)-1, we have c=0..something, not varying w fastest. This means consecutive threads have varying c, so they access non-contiguous memory.
I need to fix this! For coalesced global loads, consecutive threads should access consecutive memory, which means varying w fastest for fixed c.
Let me remap the loading:
For global memory coalescing, we should iterate in the order that matches memory layout:
```
for (int c = 0; c < C; c++) {
for (int idx = tid; idx < (BLOCK_H + 2) * SMEM_W; idx += BLOCK_H * BLOCK_W) {
int w = idx % SMEM_W;
int h = idx / SMEM_W;
int in_h = block_oh + h;
int in_w = block_ow + w;
float val = (in_h < H && in_w < W) ?
input[((block_b * C + c) * H + in_h) * W + in_w] : 0.0f;
smem_in[h * SMEM_IN_H_STRIDE + w * SMEM_C_PAD + c] = val;
}
}
```
For each channel c, consecutive threads (varying tid) access consecutive w for fixed h. This is perfectly coalesced when threads are in the same row. The only issue is at row boundaries (w wraps to next h), but with the loop over idx, consecutive tids have consecutive idx, and idx maps linearly to (h*SMEM_W + w), where w varies fastest. So consecutive idx have consecutive w for fixed h, and when w wraps, idx jumps to next h, which is not contiguous in global memory.
Wait, this is the same issue as before. For SMEM_W=34 and blockDim=256:
- idx=0..33: h=0,w=0..33. Thread 0 gets w=0, thread 1 gets w=1, etc. Coalesced.
- idx=34..67: h=1,w=0..33. Thread 34 gets h=1,w=0. Not contiguous with thread 33 (h=0,w=33).
- But thread 34 is in a different warp (warp 1). Warp 0 handles idx=0..31 (h=0,w=0..31), warp 1 handles idx=32..63 (h=0,w=32..33 and h=1,w=0..29).
With the new loop structure where we go channel by channel, for each channel, idx runs 0..339 (10*34=340). Warp 0: idx=0..31 (all h=0,w=0..31). Coalesced. Warp 1: idx=32..63 (h=0,w=32,33 + h=1,w=0..29). Not coalesced.
Same problem. But the key insight is that most warps (those entirely within a row, or entirely in consecutive positions) get coalesced access. Only warps straddling boundaries are bad. And the total data is small.
For now, I'll use the channel-outermost loop as shown. It's cleaner.
Actually, a bigger issue: global memory access pattern for compute phase.
Each thread in the warp accesses `smem_in[local_oh + fh][local_ow + fw][c]`. For fixed local_oh, fh, fw, c, and varying local_ow (0..31), the threads access consecutive w. In smem, these are at offset `base + local_ow * SMEM_C_PAD + c`. With SMEM_C_PAD=9, consecutive threads access positions 9 apart in smem. Banks are (base/4 + local_ow*9) % 32. Since 9 is coprime to 32, all 32 threads access different banks. No conflict. Perfect.
Another optimization: since local_ow ranges 0..31 and SMEM_W=34, for fw=0..2, local_ow+fw ranges 0..33. All valid. Good.
Let me write the complete Python file now.
One more thought: can I use vectorized loads for weights or input? In the compute loop, we access one float at a time for input and one float at a time for each weight. With 16 output channels, we could potentially load 16 weights as a float4 vector (4 at a time). But the memory layout `smem_w[oc * 81 + c * 9 + fh * 3 + fw]` means weights for consecutive oc are spaced by 81 floats, not contiguous. So we can't vectorize across oc.
For input, `smem_in[h * 306 + w * 9 + c]` with varying c: c=0..7, these are contiguous! We could load them as float2 or float4. But the compute loop iterates c from 0..7, so we'd need to unroll and vectorize the inner loop. This is possible but complex. Let's keep it simple for now.
Wait, I realize that with BLOCK_H=8 and BLOCK_W=32, the block processes 8 rows of 32 columns. But our output H is 126, so we need ceil(126/8)=16 tiles in H. 16*128=2048 blocks in H and W per batch and channel group. Same as before.
Let me write the actual Python file with the kernel.
I should also check: what if I set BLOCK_H=7? No, 8 is fine.
Let me also consider: should we use a smaller block to get more blocks? No, 32768 is plenty.
Let me verify my grid dimensions:
```python
grid = (
(OW + BLOCK_W - 1) // BLOCK_W, # x: width tiles
(OH + BLOCK_H - 1) // BLOCK_H, # y: height tiles
batch_size * (out_channels // BLOCK_OC) # z: batch * channel groups
)
```
For our case: OW=126, OH=126, BLOCK_W=32, BLOCK_H=8, BLOCK_OC=16.
Grid x = ceil(126/32) = 4.
Grid y = ceil(126/8) = 16.
Grid z = 128 * (64/16) = 128 * 4 = 512.
Total blocks = 4 * 16 * 512 = 32768. Correct.
Wait, I realize: `blockIdx.z` goes up to 511. In the kernel, `block_b = blockIdx.z / 4` and `block_oc = blockIdx.z % 4`.
For blockIdx.z in [0, 511]:
- block_b in [0, 127]
- block_oc in {0, 1, 2, 3}
This is correct.
OK, let me write the code and compile it.