reasoning
The simple fused kernel is correct. Now, I need to also fuse the conv part to gain performance. The reference has:
1. Conv2d: `nn.Conv2d(in_channels=64, out_channels=64, kernel_size=3)` applied to input of shape `(64, 64, 256, 256)`. This is a standard GEMM-based convolution. cuDNN will be very fast for this.
2. Scale by per-channel multiplier: `(out_channels, 1, 1)` - broadcast multiply
3. LeakyReLU
4. GELU
The current speedup just fuses steps 2-4. To get a better speedup, I can try to do the conv + post-processing in one fused kernel by writing a custom implicit GEMM or direct conv.
However, looking at the sizes:
- Batch=64, InC=64, OutC=64, H=256, W=256, K=3
The problem is that cuDNN is highly optimized for convolutions. To beat it, I'd need to fuse the entire conv + post-processing into one kernel. This is a common optimization.
For a 3x3 conv with in_channels=64 and out_channels=64, I can use a direct convolution kernel where each thread or warp computes output pixels, loads input patches, and then applies the fused post-processing.
Since cuDNN convolution is memory-bound (reading input, writing output), and then we're reading the output back again for post-processing, fusing conv + post-processing can reduce memory bandwidth by writing the final result directly without an intermediate tensor.
Let me write an implicit GEMM or simple direct convolution kernel that fuses the entire pipeline.
The dimensions:
- N = 64
- C_in = 64
- C_out = 64
- H = W = 256
- KH = KW = 3
Output size: H_out = W_out = 256 (assuming padding=0? Wait, PyTorch default padding for Conv2d is 0. So H_out = 256 - 3 + 1 = 254? No wait, let me check...)
Actually, let me re-read the reference. `nn.Conv2d(in_channels, out_channels, kernel_size)` with default padding=0. So output is (batch_size=64, out_channels=64, height=254, width=254). Let me verify:
Formula for Conv2d output size without padding, dilation=1, stride=1:
H_out = H_in - kernel_size + 1 = 256 - 3 + 1 = 254
W_out = 256 - 3 + 1 = 254
And multiplier_shape is (out_channels, 1, 1).
So the total number of elements after conv is 64 * 64 * 254 * 254 = about 263M elements.
Let me write a fused conv + post-processing kernel. Since this is a 3x3 conv with 64 input and 64 output channels, it's feasible to do a direct convolution.
Given the output size, I can tile the output space.
Let me think about how to write an efficient kernel:
Option 1: Use a simple direct conv kernel where each thread computes one output pixel.
- For output (n, c_out, h, w), thread loads the 3x3xC_in input window and computes the dot product with the 3x3xC_in weights.
- C_in=64, K=3, so each output pixel needs 64*3*3 = 576 multiply-adds.
- Total threads: 64*64*254*254 ≈ 263M threads. That's way too many to launch individually.
We need to tile this. On H100, we can have blocks of say 256 threads.
Each thread can compute a tile of output. For example, a thread computes one (c_out) for all (h, w) in a tile, or one thread computes one output pixel for multiple c_out.
Better approach: use a similar pattern to CUTLASS/Implicit GEMM.
Since C_in=64, C_out=64, K=3, we can actually load data into shared memory efficiently.
For a block of threads computing a tile:
- Let each thread block compute a tile of output: say Bm=output channels per block, Bn=spatial positions per block.
Actually for 1x1 and 3x3 convs, direct conv in shared memory is very effective.
Let me try:
- Each thread block handles a subset of output channels (e.g., 8) and a subset of spatial locations (e.g., 16*16=256).
- So blockDim = (8, 16, 16)? That's 2048 threads, too many. Or blockDim = 256 threads.
Maybe:
- Each block computes a tile of 8 output channels x 32 spatial positions. Total 256 threads.
- Each thread computes one (c_out, spatial_position) output.
Spatial position = h * W_out + w.
For each thread:
- Determine c_out = blockIdx.y * 8 + threadIdx.x % 8
- Determine spatial_pos = blockIdx.x * 32 + threadIdx.x / 8
- Convert spatial_pos to h, w.
The thread then loops over C_in=64 channels, and for each c_in, loads the 3x3 input patch and does the dot product with weights[c_out, c_in, :, :].
To avoid redundant loads, the block should load the input into shared memory.
For a block handling 32 spatial output positions (arranged as say 2x16 or 4x8 or whatever), the input region is larger by 2 in each dimension (because K=3). So if we compute a 4x8 block of outputs, input is 6x10. But this depends on which spatial positions the 32 threads cover.
Actually, if the 32 spatial positions are contiguous, say positions [base, base+31], then for a 1D output they're in a range. But in 2D, contiguity is tricky.
Let me arrange it differently. Let's have each thread block handle a 2D tile of outputs:
- Tile size: Th x Tw in spatial, Tc in channels. Let's say Th=4, Tw=4, Tc=4. Total: 4*4*4=64 output pixels. With 256 threads, each thread handles 1 output pixel? No...
Actually with 256 threads, each thread can handle one output pixel. Then the block computes 256 output pixels. We can arrange them as:
- 8 output channels (Tc=8)
- 8 spatial positions (but 8 is small for spatial)
Better: Tc = 8 output channels, and each thread handles one spatial position among 32 positions. So total output pixels = 8 * 32 = 256.
- The 32 spatial positions form a tile, e.g., 4x8 or 2x16.
But loading input into shared memory for a 4x8 output tile means loading input in (4+2)x(8+2) = 6x10 area, for each input channel. With C_in=64 channels, we'd need to load 64 * 60 floats = 3840 floats per block. That's about 15KB, which is very manageable in shared memory.
However, each thread only uses a small part of the loaded input. The weights are 64 * 64 * 9 = 36864 floats. That's 144KB, which doesn't fit in shared memory. But we can load weights for only 8 output channels at a time (8 * 64 * 9 = 4608 floats = 18KB).
Wait, registers per thread would need to hold 9 floats for the dot product accumulation across c_in if we do it channel by channel. Or we can accumulate in a register and load weights on the fly from global memory since they are coalesced.
Actually for a direct 3x3 conv, we can do it without shared memory for weights - just read weights directly from global memory. But caching in shared memory helps.
For H100, L2 cache should handle weights well since each weight is reused many times.
Let me try a simpler pragmatic approach first:
A direct conv kernel where each thread computes one output pixel (N, C_out, H_out, W_out).
- Total outputs: 64*64*254*254 ≈ 263M elements.
- With 256 threads per block, we need ~1M blocks. That's a lot.
Each thread would:
1. Load 3*3*64 = 576 input values and 3*3*64 = 576 weight values.
2. Compute 576 FMAs.
3. Apply post-processing: multiply by multiplier, LeakyReLU, GELU.
4. Write output.
Total FMAs: 263M * 576 ≈ 151G FMAs = 302 GFLOPs.
With H100 FP32 at 67 TFLOPS, this takes ~4.5ms compute bound, but conv is memory bound. In practice, cuDNN conv achieves very high utilization.
The problem with many small blocks on H100 is overhead. But ~1M blocks of 256 threads each might still be OK. Let me try this simple approach first and see if it's fast enough.
Actually, maybe I should use a 2D grid for better distribution.
Grid: (blocks_along_nchw) in 1D is fine.
But let me think about optimizing:
For each thread computing one output pixel at (n, c_out, h, w):
```cpp
float acc = 0.0f;
for (int c_in = 0; c_in < C_in; c_in++) {
for (int kh = 0; kh < 3; kh++) {
for (int kw = 0; kw < 3; kw++) {
float in = input[n, c_in, h + kh, w + kw];
float w = weight[c_out, c_in, kh, kw];
acc += in * w;
}
}
}
acc = acc * multiplier[c_out];
// LeakyReLU + GELU
output[n, c_out, h, w] = ...
```
This is straightforward but each thread reads 576 input values. With 263M outputs, total input reads: 263M * 576 ≈ 151B reads. But each input pixel is shared across outputs. Without shared memory, each input pixel is read from global memory multiple times. Specifically, each input pixel contributes to 3*3*C_out = 9*64 = 576 output pixels (roughly, at boundaries it's fewer). So the total global memory reads are indeed much higher than optimal.
With cuDNN, the convolution is highly optimized using shared memory tiling. It minimizes redundant reads.
I need to use shared memory to get close to cuDNN performance. Let me try an implicit GEMM approach or a well-tiled direct conv.
For a 3x3 conv with 64->64 channels, maybe I can use CUTLASS or something... but I have to write CUDA C++ directly.
Let me try a different strategy: instead of writing a full conv kernel from scratch, maybe I can just fuse the post-processing more aggressively and rely on cuDNN for conv.
Wait, the problem is that my current kernel does conv (cuDNN) -> elementwise_fuse. The elementwise_fuse should be fast. But the reference is: conv -> multiply -> leaky_relu -> gelu. cuDNN might already fuse some of this, or at least the elementwise ops are bandwidth-bound and fast.
Actually let me reconsider: the reference model uses `nn.Conv2d`, then `x * self.multiplier`, then `self.leaky_relu(x)`, then `F.gelu(x)`.
PyTorch eager mode would typically run these as separate kernels. The custom kernel advantage is:
1. Fusing the 3 elementwise ops into one kernel (saves 2 memory round-trips).
2. Then there's still the conv kernel.
Can we also fuse conv + elementwise? That's the holy grail. But to beat cuDNN conv by enough to make up for not fusing the elementwise is very hard.
Actually, the problem says I need to be faster than the reference. The reference is pure PyTorch eager. In eager mode:
1. Conv2d: cuDNN convolution kernel (fast)
2. Multiply: elementwise kernel
3. LeakyReLU: elementwise kernel
4. GELU: elementwise kernel
So total: 1 conv kernel + 3 elementwise kernels.
My solution: 1 conv kernel + 1 fused elementwise kernel.
This should definitely be faster because I saved 2 memory round-trips over the conv output (which is ~263M elements * 4 bytes = ~1GB of memory traffic saved).
But maybe the reference with cuDNN is already doing some fusion? In torch.compile or certain configurations, some ops might fuse. But since it's plain PyTorch eager mode, they won't fuse automatically.
Actually, let me think more carefully. The output of conv is 64*64*254*254 = 263,476,224 elements. At fp32, that's about 1.05 GB.
Without fusion:
- Conv writes 1.05 GB
- Multiply reads 1.05 GB, writes 1.05 GB
- LeakyReLU reads 1.05 GB, writes 1.05 GB
- GELU reads 1.05 GB, writes 1.05 GB
Total elementwise memory: 6 passes * 1.05 GB = 6.3 GB of memory traffic.
At 3.35 TB/s, that's about 1.9ms of memory-bound time for elementwise + conv time.
With fusion:
- Conv writes 1.05 GB
- Fused kernel reads 1.05 GB, writes 1.05 GB
Total elementwise memory: 2 passes * 1.05 GB = 2.1 GB.
So we save roughly 4 passes * 1.05 GB = 4.2 GB of memory bandwidth. At 3.35 TB/s that's about 1.25ms saved.
My simple kernel already achieves this. Let me submit and see how it performs? Or I should try to improve it further.
To approach SOL better, I should write a fully fused conv+post kernel. Let me attempt that.
For implicit GEMM / direct conv, let me try an approach similar to what I described, using shared memory for a tile of input.
Actually, let me try using shared memory with a very simple configuration first and see if I can get it to compile and be correct.
For a block size of 256 threads:
- Let's say we compute a tile of 4x4 output channels... no, let's do 1 output channel and 32x32 spatial? No.
Actually, for simplicity and ensuring correctness, let me try a warp-level approach or just a simple shared memory direct conv.
Wait, there's another option: since the conv is 3x3 and in/out channels are 64, I can transpose the problem to use a GEMM via im2col. But doing im2col on the fly in shared memory is basically implicit GEMM.
Let me implement a simple implicit GEMM kernel.
Output dimensions: M = N * H_out * W_out (number of output pixels per channel), but we process all C_out channels at once.
Actually a simpler view:
- Total output elements: N * C_out * H_out * W_out.
- For each output element (n, c_out, h, w), it's dot(input[n, :, h:h+3, w:w+3], weight[c_out, :, :, :]).
I can tile by output channels and spatial positions.
Let me use:
- BlockDim: 256 threads.
- Each block computes a tile of outputs:
- T_c = 4 output channels
- T_hw = 64 spatial positions (these are just mapped linearly)
- Total outputs per block = 4 * 64 = 256.
Thread assignment:
- threadIdx.x determines output element within block: threadIdx.x in [0, 255]
- c_out = blockIdx.y * 4 + threadIdx.x % 4
- hw = blockIdx.x * 64 + threadIdx.x / 4
- Convert hw to (h, w) using W_out.
But 64 spatial positions in the same block might map to spread-out memory locations if not contiguous.
Better: let the block compute a 2D spatial tile of 8x8 outputs and some number of channels.
T_hw = 8x8 = 64, T_c = 4, total = 256 outputs.
- threadIdx.x in [0, 255]
- c_out = blockIdx.z * 4 + threadIdx.x / 64
- local_hw = threadIdx.x % 64
- local_h = local_hw / 8
- local_w = local_hw % 8
- h = blockIdx.y * 8 + local_h
- w = blockIdx.x * 8 + local_w
Then the block grid is (W_out/8, H_out/8, C_out/4) for each N? We need to include N too.
Actually we can have:
- blockIdx.x: tile in W dimension
- blockIdx.y: tile in H dimension
- blockIdx.z: combines N and C_out tiles
e.g., blockIdx.z = n * (C_out/4) + c_tile
But W_out=254, H_out=254 which aren't divisible by 8. We'll need to handle boundaries.
For inputs, to compute an 8x8 output tile, we need a 10x10 input tile (since K=3). With C_in=64, storing this in shared memory:
- Input shared: 64 * 10 * 10 * 4 bytes = 25,600 bytes = 25 KB per block. That's fine!
- For each output channel tile of 4, weights are 4 * 64 * 3 * 3 * 4 bytes = 9,216 bytes = 9 KB. Also fine.
- Total shared memory: ~35 KB per block. Each SM has 228 KB, so we can have multiple blocks per SM.
This is a very promising approach! Let me write this kernel.
Kernel design:
1. Each block computes a tile of outputs: Tn=1 (one batch), Tc=4 (output channels), Th=8, Tw=8.
2. Load input tile (C_in x (Th+2) x (Tw+2)) into shared memory.
3. Load weight tile (Tc x C_in x 3 x 3) into shared memory.
4. Each thread computes one output element by iterating over C_in and doing the 3x3 dot product using shared memory data.
5. Apply post-processing and write to output.
Wait, actually I should think about whether to load all C_in into shared memory at once, or iterate over C_in chunks. C_in=64, so loading all at once is fine.
Let me write the kernel in detail:
```cpp
#define TC 4
#define TH 8
#define TW 8
#define C_IN 64
__shared__ float s_input[C_IN * (TH+2) * (TW+2)]; // 64 * 100 = 6400 floats
__shared__ float s_weight[TC * C_IN * 3 * 3]; // 4 * 64 * 9 = 2304 floats
```
Wait, 6400 + 2304 = 8704 floats = 34.8 KB. That's very reasonable.
But loading all input for a tile:
For an 8x8 output tile at position (h_base, w_base), input spans (h_base:h_base+10, w_base:w_base+10) across all 64 input channels.
How to load input into shared memory efficiently?
Total elements: 64 * 100 = 6400. With 256 threads, each thread loads 25 elements. That's manageable.
But the memory access pattern for loading input might not be coalesced. Let me think:
For a given spatial position (h_in, w_in), the 64 channels are contiguous in memory (NCHW format). So each thread should load a vector of channels at one spatial position.
Actually, input layout is (N, C, H, W), so channels are the innermost dimension? No, in NCHW, C is the second dimension. The memory layout is: for a given n, c, h, w are arranged with w being the fastest changing, then h, then c, then n.
So for fixed n, c, h, the w dimension is contiguous.
For fixed n, c, the h*w plane is contiguous with w varying fastest.
To load the 10x10 spatial region for all 64 channels:
- For spatial position (h_in, w_in), the 64 channels at that position are not contiguous in memory because c varies with stride H*W.
Actually in NCHW:
offset = ((n * C + c) * H + h) * W + w
So for fixed (n, h, w), channels c are separated by H*W = 256*256 = 65536 elements. That's a huge stride.
Wait, that means loading all channels for one spatial position results in strided memory access. Not great.
Alternative: we can have threads load along the channel dimension. For coalesced access, consecutive threads should access consecutive memory locations. That means consecutive threads should vary c or w.
If thread i loads:
- n = block_n
- c = i % C_IN
- h = some_h
- w = some_w
Then memory locations are offset by H*W between threads, which is not coalesced.
Better: consecutive threads should vary w.
- thread i: (c = i / W_tile, w = i % W_tile) for fixed h. But channels are still separated by H*W.
Actually for coalesced access in NCHW, consecutive memory addresses correspond to consecutive w for fixed n, c, h.
So if I want coalesced loads, I should have consecutive threads load consecutive w's for the same c and h.
To load 6400 input elements, I could iterate: for each channel c and each h_in, the 10 w's are contiguous. So a warp of 32 threads can load 32 consecutive w's... but we only have 10 w's.
For each (c, h_in), the w_in values [0, 9] are contiguous. So 10 threads can load them coalesced.
With 256 threads, we can process 25 such (c, h_in) slices in parallel.
Actually, let me reconsider. Maybe I should process channels in chunks and not load all C_in at once. But C_in=64 is small.
Wait, on second thought, maybe NCHW isn't the optimal layout for an efficient conv kernel. But PyTorch uses NCHW by default. I could actually transposed to NHWC or channels-last, but that would require extra memory ops.
Actually, since the conv weights are loaded as part of PyTorch's nn.Conv2d, they're in NCHW (actually NCHW for weights: [C_out, C_in, KH, KW]).
Let me think about a simpler but still effective approach.
For weights, the layout is [C_out, C_in, KH, KW]. For fixed c_out, c_in, the 3x3 weights are contiguous (9 elements).
For input, maybe I should just write the kernel with the understanding that it won't perfectly coalesce but try to make it reasonable.
Actually, an important realization: we can increase tiling in the C_out dimension and decrease spatial tiling to reduce shared memory input size.
Or, another approach: use a "fragment-based" approach where each thread computes one output and loads input directly from global memory. With proper use of __ldg (read-only cache), the L2 cache on H100 should help significantly, and this avoids complex shared memory management.
For a 254x254 output with 64 channels, each input pixel is reused 3*3*64 = 576 times. With L2 cache of 50MB on H100, can we expect good caching?
Input size: 64 * 64 * 256 * 256 * 4 bytes = 1 GB. That's larger than L2 cache.
Output size: 64 * 64 * 254 * 254 * 4 bytes = ~1 GB.
L2 cache is 50 MB. So we can only cache a small fraction.
Without shared memory, each thread reads 64*9 = 576 floats. Total reads = 263M * 576 * 4 bytes = 606 GB. That's way too much.
With shared memory tiling the spatial region, we reduce redundant reads. For an 8x8 output tile, the 10x10 input tile is read once and shared among 64 outputs (actually among 4*64 = 256 outputs). Wait no, the input tile is read once per block and shared among all threads in the block.
Each block computes 256 outputs.
Input tile size: 64 * 10 * 10 = 6400 floats.
Each output requires 64*9 = 576 input accesses.
Total input accesses if all from global mem: 256 * 576 = 147,456.
With shared memory: 6,400 reads from global memory.
Reduction factor: ~23x. That's huge!
So shared memory is critical.
Now the question is: can we write the shared memory loader efficiently given NCHW layout?
Actually for loading input tile into shared memory:
For a given spatial position (h_in, w_in), all 64 channels are at memory offsets: base + c*H*W + h_in*W + w_in. These are separated by H*W = 65536 floats = 256KB. This strided access is terrible for coalescing.
To fix this, we need to transpose the layout in shared memory. In shared memory, we can store as (h_in, w_in, c) so that channels are contiguous for a given spatial position.
And for loading from global memory, consecutive threads can load consecutive channels. Since channels are strided by H*W in global memory, this is still strided. But each thread is loading one float, and consecutive threads load offsets that are H*W apart. With H*W = 65536, and float size = 4 bytes, the stride is 256KB. On H100, a warp would access 32 addresses 256KB apart. This might use L2 cache efficiently but it's not coalesced into a single memory transaction.
Maybe we can use vector loads? No, because the stride is too large.
Alternative layout in shared memory: instead of storing by (h, w, c), what if each warp loads one channel at a time? That is, 256 threads load 256 channels... but we only have 64 channels. So 64 threads (2 warps) load one spatial position across all channels, and the rest are idle.
Actually, we can load channels in a coarsened way:
- 64 threads load channels 0..63 for spatial position (h_in, w_in).
- Thread c loads channel c. The loads are at stride H*W, not coalesced.
- Then next 64 threads load spatial position (h_in, w_in+1), etc.
This still isn't coalesced.
But wait - maybe we can reorganize which threads load what. In a warp, threads 0-31 would load channels 0-31 for position (h_in, w_in). These are at offsets base + 0*65536 + offset, base + 1*65536 + offset, ..., base + 31*65536 + offset. The memory controller on H100 can handle strided access through the L2 cache, though it's not as efficient as coalesced access.
Actually, since H*W*4 = 256KB, which is exactly the L2 cache line size or larger, strided access by 256KB means each thread hits a different cache line. A warp would load 32 cache lines. That's very inefficient.
Hmm. For NCHW, maybe we should tile differently.
What if we make C_in the innermost tile dimension and Th, Tw the outer dimensions? So each block computes 1 spatial position (or few) for many channels?
For example:
- Block computes 1 spatial output position (h, w) for 256 output channels.
- Thread i computes output channel i.
- To compute this, each thread needs 64 input channels * 9 weights.
- Input for spatial position (h, w) across all C_in=64 channels: these are strided by H*W.
This is the same strided access problem.
OK, the real question is: should we convert input to NHWC or channels_last? PyTorch supports channels_last memory format. If we convert the input to channels_last (NHWC), then channels are contiguous for each spatial position, and our shared memory loading becomes perfectly coalesced!
But wait, PyTorch's nn.Conv2d by default operates on NCHW. If I convert the input to NHWC in the forward pass, I'd need to either:
1. Have the conv weights in a compatible format.
2. Use a custom conv that expects NHWC.
For the reference, PyTorch's Conv2d on NCHW will call cuDNN. If I convert input to channels_last, PyTorch might still handle it, but there could be extra transpose overhead.
Actually, maybe I can write a full custom kernel that uses NHWC internally. But the input comes in as NCHW from `get_inputs()`. I'd need to transpose it, which costs memory bandwidth.
Alternatively, I can write the conv kernel to work on NCHW but optimize the loading differently.
Let me reconsider whether shared memory is worth the complexity for NCHW. Maybe I can just write a simple direct conv and see if the reference is beatable with just the elementwise fusion.
Actually, let me think about the reference performance more carefully.
PyTorch Conv2d on 64x64x256x256 with 64 output channels, kernel=3:
- cuDNN is highly optimized. This is a standard configuration.
- FLOPs: N * C_out * H_out * W_out * C_in * K * K = 64 * 64 * 254 * 254 * 64 * 9 ≈ 151 GFLOPs.
- Memory: Read input (64*64*256*256*4 = 1GB), read weights (64*64*9*4 = 0.15MB), write output (~1GB).
- cuDNN likely achieves close to peak memory bandwidth or compute.
The elementwise ops each touch ~1GB. With fusion, we save ~2GB of memory traffic.
The total reference time might be conv_time + 3 * elementwise_time.
Conv time: ~1-2ms depending on whether it's memory or compute bound.
Elementwise time: each one reads and writes 1GB. At 3.35TB/s, each takes ~0.6ms. Total elementwise = 1.8ms.
So reference total = ~2.8-3.8ms.
My fused version = conv_time + 1 * 0.6ms = ~1.6-2.6ms.
Speedup = ~1.5x.
That's decent, but maybe I can do better by fusing conv + elementwise.
For the fused conv, even with imperfect coalescing, the reduction in memory writes (no intermediate conv output) helps.
A fused conv+elementwise kernel writes only the final output (1GB) instead of conv output (1GB) + final output (1GB), saving 1GB of write bandwidth. It also doesn't need to read the conv output, saving another 1GB.
So fused conv saves ~2GB compared to separate conv + fused elementwise.
Total fused conv memory: reads input (1GB) + reads weights (negligible) + writes output (1GB) = 2GB.
Reference memory: reads input (1GB) + writes conv output (1GB) + reads conv output (1GB) + writes mult out (1GB) + reads mult out (1GB) + writes leaky out (1GB) + reads leaky out (1GB) + writes gelu out (1GB) = 8GB.
So the fused kernel could be up to 4x faster memory-wise. But compute needs to be good too.
Let me try writing the fully fused kernel with shared memory, imperfect coalescing or not. Maybe the overall reduction in memory traffic compensates.
Actually, I just realized: PyTorch's Conv2d default layout is NCHW but the actual cuDNN implementation might use NHWC internally or some other format. cuDNN can handle both.
For my custom kernel, let me try a direct approach with shared memory but using a clever loading scheme.
Since NCHW has channels separated by H*W, and H*W = 65536 floats = 256KB, which is a multiple of cache line sizes, maybe I can use the fact that consecutive channels for the same spatial position fall on different cache lines but can be prefetched.
Actually, on modern GPUs, the L2 cache has many banks and can handle multiple concurrent accesses. The issue is more with warp-level coalescing into fewer transactions. But with L2, strided access might not be as catastrophic as it used to be.
Let me just write the kernel and see.
For the shared memory input tile, I'll have each thread load a portion of the input. Since 256 threads and 6400 elements to load, each thread loads ~25 elements.
Loading pattern:
```cpp
for (int idx = threadIdx.x; idx < C_IN * IN_H * IN_W; idx += blockDim.x) {
int c = idx / (IN_H * IN_W);
int rem = idx % (IN_H * IN_W);
int h_in = rem / IN_W;
int w_in = rem % IN_W;
int global_h = tile_h_start + h_in;
int global_w = tile_w_start + w_in;
if (global_h < H && global_w < W) {
s_input[idx] = input[((n * C_IN + c) * H + global_h) * W + global_w];
} else {
s_input[idx] = 0.0f;
}
}
```
Wait, if I store s_input as [c, h_in, w_in], then idx layout matches the global memory offset pattern: for fixed n, offset = c*H*W + h*W + w. So s_input[idx] where idx = c*(TH+2)*(TW+2) + h_in*(TW+2) + w_in.
And consecutive threads (threadIdx.x and threadIdx.x+1) load consecutive idx: so they load consecutive (c, h_in, w_in) in the lexicographic order. That means thread 0 loads (c=0, h=0, w=0), thread 1 loads (c=0, h=0, w=1), etc.
But in global memory, (c=0, h=0, w=0) and (c=0, h=0, w=1) are consecutive! So if W_tile <= W and the threads load consecutive w's for the same c and h, it's coalesced!
Wait, I made a mistake earlier. Let me recalculate:
In NCHW, for fixed n and c, w varies fastest. So consecutive w's for same n, c, h ARE contiguous in memory.
My s_input indexing is: c * IN_H * IN_W + h_in * IN_W + w_in.
Consecutive threads load consecutive idx, which means they load consecutive (c, h, w) in row-major order.
- Threads 0-9 load (c=0, h=0, w=0..9) - these are all contiguous in global memory! Coalesced!
- Thread 10 loads (c=0, h=1, w=0)
- Threads 10-19 load (c=0, h=1, w=0..9) - also contiguous!
So as long as W_tile = 10 <= 32 (warp size), and the threads are within the same warp, the first 10 threads in a warp will access 10 contiguous floats. On H100, this coalesces perfectly!
And since blockDim.x = 256, threads 0-9 in warp 0 access 10 contiguous floats.
Threads 10-19 in warp 0 access another 10 contiguous floats (but offset by W from the previous group).
Wait, threads 10-19 have w=0..9 for h=1. The address for thread 10 is: base + 0*H*W + 1*W + 0 = base + W.
Thread 11: base + W + 1, etc.
So these are also contiguous! They're just offset by W from where threads 0-9 were reading.
In a warp, threads 0-31 might load:
- 0-9: h=0, w=0-9
- 10-19: h=1, w=0-9
- 20-29: h=2, w=0-9
- 30-31: h=3, w=0-1
Are addresses contiguous across h boundaries?
Thread 9: base + 0*H*W + 0*W + 9 = base + 9
Thread 10: base + 0*H*W + 1*W + 0 = base + W = base + 256
These are NOT contiguous! There's a gap of 256 - 10 = 246 floats between thread 9 and thread 10.
On H100, memory transactions are typically done in 32-byte or 128-byte chunks. A warp might issue multiple transactions. Threads 0-9 access bytes [0, 36) relative to base, which might be in one or two 32-byte transactions. Threads 10-19 access [256*4, 256*4+36) = [1024, 1060), which is far away. So warp 0 issues separate transactions for each group.
Wait, but threads 0-9 access contiguous 10 floats. Threads 10-19 access another contiguous block. The GPU memory controller will merge these into at least 2 L2 cache requests (possibly more depending on alignment).
With 256 threads, we have many such groups. The total memory bandwidth utilization depends on how many cache lines are fetched. Since each group of up to 10 contiguous floats fits in at most 2 cache lines (128 bytes = 32 floats), we're doing reasonably well.
Actually, in this case, consecutive threads loading non-contiguous data cause the warp to issue multiple transactions, but each transaction is still efficient because the subset of threads accessing it are contiguous.
So despite not being perfectly coalesced across the entire warp, the access pattern is a bunch of short contiguous segments, which is reasonably efficient.
H100 also supports independent thread scheduling, but memory coalescing still happens at warp level.
I think this loading pattern is acceptable.
Now for weights:
Weight layout: [C_out, C_in, KH, KW]. For fixed c_out, c_in, the 3x3 kernel is contiguous.
In our kernel, consecutive threads compute different output channels (c_out = block_c_start + thread_c). They all use the same set of input channels but different weights.
If we store weight in shared memory as [c_out_local, c_in, kh, kw], and each thread loads a portion, then consecutive threads loading consecutive idx would load consecutive c_out for the same (c_in, kh, kw). In global memory, these are separated by C_in*KH*KW = 64*9 = 576 floats = 2304 bytes. That's a stride of 2304 bytes.
For a warp of 32 threads loading 32 different c_out's for the same (c_in, kh, kw):
Offsets: base + 0*576, base + 1*576, ..., base + 31*576.
Stride = 2304 bytes.
These are all in different cache lines (128-byte cache lines). A warp would issue 32 memory transactions. This is very inefficient.
To fix weight loading, we should have consecutive threads load weights for the SAME c_out but different (c_in, kh, kw). Since within a c_out, weights for different c_in are contiguous in blocks of 9 floats.
Actually for fixed c_out, as c_in increases, the 3x3 weights are stored at offset c_out*C_in*9 + c_in*9 + kh*3 + kw.
Consecutive c_in values have offsets differing by 9 floats = 36 bytes.
So if threads load:
thread i: c_out = 0, c_in = i / 9, kh = (i%9)/3, kw = i%3
Then consecutive threads load weights for c_out=0, with c_in varying. These are contiguous! (well, 9 floats apart, which is close).
Wait, consecutive c_in values have weights separated by 9 floats = 36 bytes. For i=0 (c_in=0, kh=0, kw=0) and i=1 (c_in=0, kh=0, kw=1), the offset differs by 1 float = 4 bytes - contiguous!
So if threads load elements within the same c_out sequentially, it's coalesced.
In our block, we have TC=4 output channels. Total weights = 4 * 64 * 9 = 2304 floats.
Each thread can load ~9 weights. If thread i loads weights for positions i, i+256, i+512, etc., then thread 0 loads position 0, thread 1 loads position 1, etc. These are contiguous in the weight array (since weight is stored as [c_out, c_in, kh, kw] in row-major order).
Wait! For TC=4, the weight subset we load is contiguous in memory IF we store the block's weights contiguously and in the same order as global memory.
In fact, if we just map:
s_weight[idx] = weight[block_c_start * C_IN * 9 + idx]
where idx ranges from 0 to TC * C_IN * 9 - 1.
Then thread i loads weight[block_c_start * C_IN * 9 + i], which is contiguous! Perfectly coalesced.
And for computation, each thread needs weights for ONE specific c_out. So after loading, thread with c_out_local can access its weights from s_weight[c_out_local * C_IN * 9 + c_in * 9 + kh * 3 + kw].
This works great!
So the plan is:
1. All 256 threads cooperate to load TC * C_IN * KH * KW weights into shared memory. Each thread loads ~9 weights. Since the weights for the block are contiguous in global memory, this is coalesced.
2. All 256 threads cooperate to load the input tile into shared memory. Since we store it in [c, h, w] order matching the global memory layout for the H and W dimensions, each group of up to min(W_tile, warp_size) threads accesses contiguous memory. This is reasonably efficient.
3. Each thread computes its assigned output using the shared memory.
For step 2, actually, let me verify:
Input global memory: [N, C, H, W].
For a block computing outputs at spatial position (tile_h, tile_w), the input spans [tile_h, tile_h+TH+2) in H and [tile_w, tile_w+TW+2) in W, across all C_IN channels.
If we store s_input as [C_IN, IN_H, IN_W] in row-major, then:
- s_input[c, h, w] corresponds to global input[n, c, tile_h + h, tile_w + w].
- The offset in global memory is: n*C*H*W + c*H*W + (tile_h+h)*W + (tile_w+w) = (n*C*H*W + tile_h*W + tile_w) + c*H*W + h*W + w.
- The offset in shared memory is: c*IN_H*IN_W + h*IN_W + w.
Consecutive threads loading consecutive shared memory indices:
idx = c*IN_H*IN_W + h*IN_W + w
idx+1 = c*IN_H*IN_W + h*IN_W + w+1 (if w+1 < IN_W)
These correspond to consecutive w values, which map to consecutive global memory addresses! So YES, consecutive threads loading consecutive idx values ARE loading contiguous global memory, as long as they stay within the same (c, h) and w doesn't overflow to the next h.
When w overflows (w = IN_W-1, next is w= 0 but h increases by 1), global memory address jumps from ... + h*W + (W-1) to ... + (h+1)*W + 0, which is a jump of 1 element. So it's STILL contiguous!
Wait: global offset for (c, h, w=IN_W-1) = base + c*H*W + h*W + (IN_W-1)
Global offset for (c, h+1, w=0) = base + c*H*W + (h+1)*W + 0 = base + c*H*W + h*W + W.
The difference is W - (IN_W - 1). If IN_W = 10 and W = 254 (full width), then the jump is 254 - 9 = 245 elements!
Oh right, because in global memory, after w=9 in row h, the next element is w=0 in row h+1, which is 254 elements later, not 1 element later. So there's a gap!
This means that when w wraps to the next row, there's a discontinuity in memory. But threads within each row access contiguous memory. At row boundaries, the warp might issue multiple transactions.
Since IN_W = 10 and warp size = 32, a single warp covers multiple rows. Specifically, threads 0-9 access row 0, threads 10-19 access row 1, threads 20-29 access row 2, threads 30-31 access row 3 (first 2 elements). Each of these groups is contiguous internally but separated from other groups.
A warp will issue ~4 transactions (one per group). On H100, this is still reasonably efficient.
And since each row group has 10 threads accessing 40 contiguous bytes, it fits in one or two 32-byte transactions.
So the loading is not perfect but quite good.
Let me write this kernel now.
Actually, I need to be careful with the exact output dimensions:
- H_in = 256, W_in = 256
- K = 3, stride = 1, padding = 0 (default in Conv2d)
- H_out = 256 - 3 + 1 = 254
- W_out = 256 - 3 + 1 = 254
So the output size is indeed 254x254.
Let me now code the full fused kernel.
Parameters:
- TC = 4 (output channels per block)
- TH = 8 (output height per block)
- TW = 8 (output width per block)
- IN_H = TH + 2 = 10
- IN_W = TW + 2 = 10
Grid dimensions:
- gridDim.x = (W_out + TW - 1) / TW
- gridDim.y = (H_out + TH - 1) / TH
- gridDim.z = N * ((C_out + TC - 1) / TC)
In the kernel:
- n = blockIdx.z / n_c_tiles
- c_tile = blockIdx.z % n_c_tiles
- c_out_base = c_tile * TC
Wait, since C_out = 64 and TC = 4, n_c_tiles = 16.
blockIdx.z ranges from 0 to N*16 - 1 = 1024 - 1 = 1023.
Thread assignment:
- thread linear id: tid = threadIdx.x (0 to 255)
- n_outputs_per_block = TC * TH * TW = 4 * 8 * 8 = 256. This matches blockDim.x!
Each thread computes exactly one output:
- out_idx = tid (0 to 255)
- c_out_local = out_idx / (TH * TW)
- hw_local = out_idx % (TH * TW)
- h_local = hw_local / TW
- w_local = hw_local % TW
- c_out = c_out_base + c_out_local
- h_out = blockIdx.y * TH + h_local
- w_out = blockIdx.x * TW + w_local
Wait, but some outputs might be out of bounds if H_out or W_out aren't multiples of TH/TW, or if c_out >= C_out.
We need boundary checks.
Actually, let me set the grid to exact tiles to avoid some boundary issues, but since 254 isn't divisible by 8, I'll need checks anyway.
Actually, I need to carefully handle so threads that would compute out-of-bounds outputs do useful work (like loading data) but don't write.
For simplicity, let me have the grid compute ceil(H_out/TH) * ceil(W_out/TW) tiles, but since C_out=64 is divisible by TC=4, no boundary issues there.
Wait, 254 / 8 = 31.75, so we need 32 tiles in each dimension. 32 * 8 = 256, but output is only 254, so tiles at the edge will have 6 valid outputs.
Let me write the kernel.
```cpp
template <int TC, int TH, int TW>
__global__ void fused_conv_gelu_kernel(
const float* __restrict__ input,
const float* __restrict__ weight,
const float* __restrict__ multiplier,
float* __restrict__ output,
int N, int C_in, int H_in, int W_in, int C_out, int H_out, int W_out
) {
constexpr int K = 3;
constexpr int IN_H = TH + K - 1;
constexpr int IN_W = TW + K - 1;
__shared__ float s_input[C_in * IN_H * IN_W];
__shared__ float s_weight[TC * C_in * K * K];
int n = blockIdx.z / (C_out / TC);
int c_tile = blockIdx.z % (C_out / TC);
int c_out_base = c_tile * TC;
int tile_h = blockIdx.y * TH;
int tile_w = blockIdx.x * TW;
// Load weights (coalesced)
int total_weights = TC * C_in * K * K;
for (int idx = threadIdx.x; idx < total_weights; idx += blockDim.x) {
s_weight[idx] = weight[c_out_base * C_in * K * K + idx];
}
// Load input tile
int total_input = C_in * IN_H * IN_W;
for (int idx = threadIdx.x; idx < total_input; idx += blockDim.x) {
int c = idx / (IN_H * IN_W);
int rem = idx % (IN_H * IN_W);
int h = rem / IN_W;
int w = rem % IN_W;
int h_in = tile_h + h;
int w_in = tile_w + w;
// Only load if within input bounds
if (h_in < H_in && w_in < W_in) {
int in_idx = ((n * C_in + c) * H_in + h_in) * W_in + w_in;
s_input[idx] = input[in_idx];
} else {
s_input[idx] = 0.0f;
}
}
__syncthreads();
// Each thread computes one output
int tid = threadIdx.x;
if (tid < TC * TH * TW) {
int c_out_local = tid / (TH * TW);
int hw = tid % (TH * TW);
int h_local = hw / TW;
int w_local = hw % TW;
int h_out = tile_h + h_local;
int w_out = tile_w + w_local;
int c_out = c_out_base + c_out_local;
if (h_out < H_out && w_out < W_out && c_out < C_out) {
float acc = 0.0f;
#pragma unroll
for (int c_in = 0; c_in < C_in; c_in++) {
#pragma unroll
for (int kh = 0; kh < K; kh++) {
#pragma unroll
for (int kw = 0; kw < K; kw++) {
float in_val = s_input[c_in * IN_H * IN_W + (h_local + kh) * IN_W + (w_local + kw)];
float w_val = s_weight[c_out_local * C_in * K * K + c_in * K * K + kh * K + kw];
acc += in_val * w_val;
}
}
}
// Apply post-processing
acc = acc * multiplier[c_out];
acc = fmaxf(acc * 0.01f, acc); // LeakyReLU
acc = 0.5f * acc * (1.0f + erff(acc * 0.7071067811865475f)); // GELU
int out_idx = ((n * C_out + c_out) * H_out + h_out) * W_out + w_out;
output[out_idx] = acc;
}
}
}
```
Wait, there's an issue: `if (tid < TC * TH * TW)` means only 256 out of 256 threads compute. But what if some of those 256 compute out-of-bounds outputs? I handle that with the inner if. Good.
But there's another issue: what about threads with tid >= TC * TH * TW? There are none since TC*TH*TW = 4*8*8 = 256 = blockDim.x.
However, I need to handle the case where blockDim.x > TC*TH*TW. But in my launch configuration, I'll set blockDim.x = 256.
For output writing, if the tile extends beyond H_out or W_out, the boundary threads validly don't write. Good.
One more thing: the for loops for weight and input loading have `int` which is fine.
Actually, I need to double-check the weight loading indexing. Weights in PyTorch Conv2d are [C_out, C_in, K, K]. In memory, this is row-major with K (kw) being the fastest, then kh, then c_in, then c_out.
If c_out_base is the starting output channel, then weight[c_out_base + i] for i in [0, TC) corresponds to the i-th output channel in our tile. And the data for c_out_base + i starts at offset (c_out_base + i) * C_in * K * K.
So s_weight should be loaded as:
s_weight[idx] = weight[c_out_base * C_in * K * K + idx]
for idx in [0, TC*C_in*K*K).
This is correct because the TC output channels' weights are contiguous in memory.
Now, during computation, the weight access pattern in s_weight is:
s_weight[c_out_local * C_in * K * K + c_in * K * K + kh * K + kw]
This corresponds to the correct layout.
One optimization: since each thread accesses c_in sequentially and kh, kw sequentially, and different threads access different c_out_local, there might be bank conflicts for the weight access. Since s_weight is accessed with stride C_in*K*K between different c_out_local, and we have TC=4, this means threads computing different c_out read from different regions of shared memory, so no bank conflicts for weights.
For input access, different threads within the same warp might access the same input values (since they compute different outputs in the same tile). Since the outputs in a tile share input values, multiple threads will read the same s_input elements. This is fine because shared memory supports broadcasting.
Actually, threads access s_input with the pattern: c_in * IN_H * IN_W + (h_local + kh) * IN_W + (w_local + kw).
For a fixed c_in and kh, kw, different threads with different h_local, w_local access different positions. These positions might map to the same or different shared memory banks depending on the address.
Bank conflicts in shared memory occur when multiple threads access different addresses that map to the same bank. The bank index is (address / 4) % num_banks (typically 32).
For s_input, addresses are: base + (h_local + kh) * IN_W * 4 + (w_local + kw) * 4.
= base + ((h_local + kh) * IN_W + (w_local + kw)) * 4.
Bank = (((h_local + kh) * IN_W + (w_local + kw)) / 4) % 32
= ((h_local + kh) * IN_W / 4 + (w_local + kw) / 4) % 32
IN_W = 10 for TH=TW=8.
(h_local + kh) ranges from 0 to 9, w_local + kw ranges from 0 to 9.
The bank calculation is a bit complex, but with IN_W=10, there's no guarantee of being conflict-free. However, bank conflicts are not catastrophic - they just serialize access.
If I want to be more careful, I could increase IN_W to a multiple of 4 and add padding. But let me just try it and see.
Wait, actually a better approach: since H100 has 32 shared memory banks, and IN_W=10 is not a multiple of anything nice, maybe I should add padding to IN_W.
Let me change IN_W to 12 (next multiple of 4 after 10, since we need 10 for K=3 and TW=8). Actually, we only need IN_W >= TW + 2 = 10. We can pad to 12 to avoid bank conflicts.
Or I can set IN_W to a multiple of 4 in the shared memory array. For a 10-wide logical tile, the physical shared memory can be 12-wide.
```cpp
constexpr int IN_W_PAD = ((TW + K - 1 + 3) / 4) * 4;
__shared__ float s_input[C_in * IN_H * IN_W_PAD];
```
And when loading:
```cpp
s_input[c * IN_H * IN_W_PAD + h * IN_W_PAD + w] = ...
```
When reading:
```cpp
in_val = s_input[c_in * IN_H * IN_W_PAD + (h_local + kh) * IN_W_PAD + (w_local + kw)];
```
With IN_W_PAD = 12, the stride between rows is 12 floats = 48 bytes. Bank = 48/4 % 32 = 12. Then consecutive h values map to banks offset by 12. After 32/gcd(32,12) = 8 rows, we cycle. This is reasonably distributed.
Actually to check if there's conflict: threads with constant (c_in, kh, kw) but varying (h_local, w_local):
Each thread accesses offset = c_in * IN_H * IN_W_PAD + (h_local + kh) * IN_W_PAD + (w_local + kw).
For a warp, threads have different (h_local, w_local). Let's see if any two map to the same bank.
The bank is determined by: ((h_local + kh) * IN_W_PAD + (w_local + kw)) / 4 % 32.
For a group of threads computing in the same tile, with IN_W_PAD=12:
offset / 4 = (h_local + kh) * 3 + (w_local + kw) / 4.
Since w_local + kw are consecutive integers for threads computing adjacent w outputs, and they differ by 1, (w_local + kw) / 4 is the same for 4 consecutive threads. So some bank conflicts might still occur.
This is getting complex. Let me not overthink and just run it.
Actually for NCHW conv, PyTorch's cuDNN is extremely optimized using tensor cores or GEMM. My direct conv might not beat it. But if I fuse the post-processing, maybe the overall time is better even if conv is slightly slower.
Let me also consider another approach: use `torch.nn.functional.conv2d` for the conv, and fuse only the elementwise ops. This is the simplest and might be enough.
Actually, let me first check the performance of my simple fused elementwise kernel (which passed correctness). If the speedup is already good, I can submit that. But if it's not enough, I'll use the fused conv kernel.
Let me try to run both approaches and see which is better. I'll start with the full fused conv kernel since it theoretically saves more memory.
Let me write this kernel with TC=4, TH=8, TW=8. I need to make sure about the dimensions.
Wait, actually I realize that if I use `load_inline`, I need to make sure the CUDA code compiles correctly. Let me try a simpler first version without the full template complexity.
Actually, since C_in, C_out, H_in, W_in are fixed for this problem (batch_size is fixed too), I can hardcode them or pass them as parameters.
Let me write a clean version with hardcoded values to simplify:
C_in = 64, C_out = 64, H_in = 256, W_in = 256, H_out = 254, W_out = 254, K = 3, N = 64.
But using hardcoded values makes the kernel less general. The specification says ModelNew must accept the same constructor arguments. But it doesn't say the kernel has to work for arbitrary sizes. However, if the test framework submits different sizes, it might fail.
Actually looking at the reference, the batch_size and other dimensions are defined at module level. The tests probably use those exact sizes because `get_inputs()` and `get_init_inputs()` are fixed. But for safety, I should make the kernel work for arbitrary sizes that match the reference.
I'll pass the dimensions as parameters.
Let me write the kernel and compile/test it.
Actually, I'll write a fused conv + post-processing kernel where I use PyTorch's conv2d via the `conv` module parameter, then just fuse elementwise. Let's see if the fully fused conv is necessary.
Wait, I just realized that in the constructor, the conv layer is initialized with `nn.Conv2d(in_channels, out_channels, kernel_size)`. This creates weight parameters. In my custom forward, I need to access these weights.
For the fully fused approach, I need to call `self.conv.weight` directly and pass it to the CUDA kernel. That's fine.
Let me write a version that keeps the conv layer but does the conv + post-processing in CUDA, using a tiled direct conv.
Actually, you know what, let me first try a much simpler approach. Let me check if the simple elementwise fusion is enough for the speedup requirement. Since I don't have a timing tool that tells me speedup before submit, let me just write the best kernel I can and submit.
For the fused conv approach, let me think about whether a 3x3 conv with 64 in/out channels and 254x254 spatial output can be efficiently done with a direct kernel on H100.
FLOPs = 64 * 64 * 254 * 254 * 64 * 9 / 1e9 ≈ 151 GFLOPs.
Memory traffic with fused kernel: input ~1GB, output ~1GB, weights negligible. Total ~2GB.
Arithmetic intensity = 151 GFLOPs / 2 GB = 75.5 FLOPs/byte.
Peak FP32 = 67 TFLOPS, peak memory = 3.35 TB/s.
For memory-bound kernel, we'd get 3.35 TB/s * 75.5 FLOPs/byte = 253 TFLOPs/s. But we're limited to 67 TFLOPs/s.
So the kernel is COMPUTE BOUND!
Wait, 151 GFLOPs / 67 TFLOPs/s = 2.25ms just for the math. Plus memory access overhead. So the theoretical minimum is around 2.25-3ms.
For the reference (cuDNN conv + 3 elementwise kernels):
- cuDNN conv might use tensor cores (if in TF32 mode) which gives 989 TFLOPS, so 151 GFLOPs / 989 TFLOPs/s = 0.15ms for the conv! But that's only if it uses tensor cores and all FLOPs are efficient.
- Actual cuDNN time for a 3x3 conv of this size is probably around 0.5-1ms.
- Elementwise: 3 kernels * 1GB * 2 (read+write) / 3.35TB/s = each reads+writes 2GB, so 3 kernels = 6GB of traffic = ~1.8ms.
Total reference = ~2.3-2.8ms.
My fused elementwise = conv ~0.5-1ms + 1 fused elementwise (2GB traffic = 0.6ms) = ~1.1-1.6ms.
Speedup = ~1.4-1.8x.
My fully fused conv = ~2.5-3ms (since it's compute bound and my kernel won't be as optimized as cuDNN).
Wait, actually the cuDNN conv might be faster than my direct kernel. But the reference overhead of 3 separate elementwise kernels might dominate.
If cuDNN is 0.5ms and each elementwise is 0.3ms, reference = 0.5 + 3*0.3 = 1.4ms.
My fused elementwise = 0.5 + 0.3 = 0.8ms. Speedup = 1.75x.
My fully fused might be ~1.5-2ms (if my direct conv is slower than cuDNN). So fused elementwise approach might actually be better!
Unless I can write a conv kernel that's almost as fast as cuDNN.
For cuDNN to run this fast, it likely uses implicit GEMM via CUTLASS or similar, with highly optimized shared memory usage and warp-specialized kernels. My hand-written kernel might achieve maybe 50-70% of cuDNN performance, which for a 0.5ms conv might be 0.7-1ms. Then fused = 0.7ms, which is still better than reference but not necessarily better than separate conv + fused elementwise.
Wait, if separate conv (cuDNN) + fused elementwise = 0.5 + 0.3 = 0.8ms, and my fully fused = 0.7ms, the fully fused is only slightly better. And if my direct conv is worse, say 1.5ms, then fully fused = 1.5ms which is WORSE than the separate approach.
So maybe the right strategy is: keep cuDNN for conv, fuse only the elementwise ops.
This is safer and almost certainly gets a good speedup.
But wait, the problem statement says "Replace the forward computation ... with a custom CUDA kernel." This suggests replacing the entire forward pass. But it might be acceptable to just fuse the elementwise ops. However, to maximize speedup, fusing more is better if done correctly.
Actually, let me look at this differently. The reference model does 4 operations. I need to be "faster than the reference." Even a 1.5x speedup should be sufficient if the evaluation is lenient.
Let me try to write a good full-fusion kernel and see if I can get it to compile and run correctly. Then I'll compare performance via submit.
Actually, I realize I can write both approaches and choose the better one. But I can only submit once per final evaluation. Let me write the full fused kernel first and see.
Let me write a more careful version.
Considerations:
1. The weights need to be contiguous in the expected layout. `self.conv.weight` from `nn.Conv2d` is `[C_out, C_in, K, K]`.
2. Input is `[N, C_in, H_in, W_in]` (NCHW).
3. Output should be `[N, C_out, H_out, W_out]`.
I'll write a direct conv kernel. The key is to make it efficient enough.
An alternative: instead of writing a full custom conv, I can use `torch.nn.functional.conv2d` for the conv part and then write a custom elementwise kernel. This is the "partial fusion" approach.
Actually, looking at the instruction again, it says "Replace the forward computation... with a custom CUDA kernel." It doesn't say ALL of it must be in one kernel. I think using cuDNN for conv and a custom kernel for the rest is fine.
Let me try a different approach to the custom kernel: instead of thread-per-element, use vectorized loads/stores for the elementwise fused kernel.
Since the output is large (263M elements), a thread-per-element approach with 1024 threads per block and grid = total / 1024 should saturate the GPU. But we can optimize further by:
1. Using float4 vectorized loads/stores.
2. Unrolling loops.
3. Using the `erf` approximation instead of `erff` (the reference uses `torch.nn.functional.gelu` which might use the exact formula or an approximation).
Actually, PyTorch's `F.gelu` uses a fast approximation by default, or the exact formula? Let me check: PyTorch GELU computes `0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3)))` as the fast approximation. Or does it compute the exact form with erf?
In the default `torch.nn.functional.gelu(x, approximate='none')`, it uses the exact erf-based formula. Since no `approximate` argument is passed in the reference, it uses exact GELU.
Wait, in PyTorch, `F.gelu` default is `approximate='none'` which uses `0.5 * x * (1 + erf(x / sqrt(2)))`. So my kernel with `erff` should match exactly.
However, `erff` in CUDA might be slightly different from PyTorch's `erf` on CPU? Since PyTorch on GPU uses the same `erf` from libdevice/cudart I suppose, it should match if both run on GPU. Let me check: the reference `Model` computes on GPU (since inputs are likely GPU tensors), so PyTorch uses CUDA's `erf`. My kernel should match.
For the elementwise kernel, let me try using float4 vectorization. Since 263M elements is divisible by 4 (263,476,224 / 4 = 65,869,056), we could process 4 elements per thread.
But the multiplier is per-channel. Each float4 would likely span multiple channels or spatial positions, depending on memory layout.
In NCHW, the innermost dimension is W. With W_out=254, which is not divisible by 4 (254/4 = 63.5). So a float4 would cross row boundaries if we try to load from the W dimension.
Alternatively, we could just write a float4 kernel that works along the flattened tensor. Since the tensor is contiguous, we can treat it as a 1D array and load float4 from it. But float4 requires 16-byte alignment. The total tensor might be aligned, but individual elements at offsets not divisible by 4 won't be.
Actually, the base pointer of a contiguous tensor is typically aligned to at least 256 bytes. And since we have 263,476,224 elements, we can process the first N-3 elements in groups of 4, and the last few individually.
But the issue is: if we load a float4 from offset i, it loads elements i, i+1, i+2, i+3. The channel index for element j is (j / (H_out * W_out)) % C_out. So for a float4 starting at offset i, if i / (H * W) is not an integer multiple, the 4 elements might span different channels, meaning the multiplier values are different for each.
That's fine! We can extract the channel index for each element in the float4 and apply the appropriate multiplier.
```cpp
float4 val4 = reinterpret_cast<const float4*>(input)[idx];
// val4.x, val4.y, val4.z, val4.w need different multipliers potentially
```
But computing the channel index for each requires some math.
Actually, since H_out * W_out = 254 * 254 = 64516, which is not divisible by 4, any float4 starting at an offset not aligned to H*W*C boundaries might span channels. This makes vectorization tricky.
Let me just stick with scalar access for simplicity. The elementwise kernel is memory bandwidth bound, and H100 has very high bandwidth. Scalar access with 256 threads/block and enough blocks should saturate it.
Actually, let me try a different approach: instead of treating the tensor as 1D, I can use 2D or 3D grids for better L2 cache behavior.
For the elementwise ops, I can write:
```cpp
__global__ void fused_kernel(float* out, const float* in, const float* mult, int C, int HW) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int n = idx / (C * HW);
int rem = idx % (C * HW);
int c = rem / HW;
int hw = rem % HW;
// ...
}
```
But the division/modulo is expensive. I can precompute or use a simpler approach.
For a 1D grid with 256 threads/block:
```cpp
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total = N * C * H * W;
if (idx < total) {
int tmp = idx;
int w = tmp % W; tmp /= W;
int h = tmp % H; tmp /= H;
int c = tmp % C; tmp /= C;
int n = tmp;
float val = in[idx] * mult[c];
val = fmaxf(val * 0.01f, val);
val = 0.5f * val * (1.0f + erff(val * 0.7071067811865475f));
out[idx] = val;
}
```
The integer divisions are expensive but maybe not too bad for H100. The kernel is memory-bound anyway.
Alternatively, I can rearrange to avoid divisions:
```cpp
int c = idx / HW; // still division
```
Or, since N=64, C=64, HW=64516, I can notice that idx / HW is easy... no, 64516 is not a power of 2.
Let me try the simple fused kernel approach first. If I need more speed, I'll optimize.
Actually, for the elementwise kernel, I can also precompute N*C*H*W and just pass it, then use sequential index.
Let me compile a clean version and then run correctness. I'll use the simple elementwise fusion for now. If timing shows it's enough, great. If not, I'll try the fully fused conv.
Wait, but I need timing information to know. The tools don't tell me timing until I submit. I could run the simple one and submit to see, but submitting ends the session.
Given the time, I'll first write the best simple fused elementwise kernel, then also write a fully fused conv kernel, and see which compiles and seems better.
Actually, I know from experience that for PyTorch eager mode, fusing just the elementwise ops after a well-optimized cuDNN conv is typically a solid approach and often sufficient for meaningful speedup, especially when the elementwise ops are non-trivial like GELU.
Let me write a clean elementwise fusion kernel with some optimizations:
1. Use `__ldg` for read-only data to use the texture/L1 cache.
2. Process a large number of elements - maybe make the grid larger and block size 512 or 1024.
3. Avoid unnecessary syncthreads.
Then for the submit, I can try the fully fused conv if I think it's needed.
Let me also think about writing the fully fused conv kernel more carefully, with the realization that cuDNN is very fast. I might also try a hybrid: use CUTLASS-like GEMM for conv via torch's matmul after im2col, but that requires creating the im2col matrix which is expensive.
Actually, here's a clever approach: using torch.matmul for the conv, then fusing the elementwise.
A Conv2d with kernel=3 can be viewed as:
1. Extract sliding windows: output is [N, H_out, W_out, C_in, K, K]
2. Reshape to [N*H_out*W_out, C_in*K*K]
3. Reshape weights to [C_out, C_in*K*K]
4. matmul: [N*H_out*W_out, C_in*K*K] x [C_in*K*K, C_out]^T
5. Reshape to [N, H_out, W_out, C_out] and permute.
But extracting sliding windows (im2col) requires either a custom kernel or `torch.nn.functional.unfold`. `unfold` is itself a strided view operation in PyTorch - it's very efficient, just a view! Wait, is `unfold` a view? Let me think.
`F.unfold` extracts sliding local blocks. For a 3x3 kernel, it would create a tensor of shape [N, C_in*K*K, H_out*W_out]. This involves copying data, not just a view.
Actually, the unfold operation in PyTorch is implemented efficiently using strided tensor operations. It creates a new tensor but uses clever striding.
Then the matmul on H100 with tensor cores would be extremely fast.
But this still requires:
1. Unfold: some memory traffic
2. Matmul: compute, very fast with tensor cores
3. Reshape + permute: view operations, cheap
4. Fused elementwise: my kernel
This might be faster than cuDNN for the conv part because tensor cores are so fast for the matmul, and unfold is efficient.
Wait, but cuDNN likely uses a very similar implicit GEMM approach with tensor cores. So cuDNN is probably already doing this optimally.
Let me just write the simple fused elementwise kernel and submit. If that works, I'll be happy. But I want to also try the full fused conv to see if it's better.
Let me code the full fused conv kernel first. If I can make it work correctly, I'll test compile and correctness, then decide.
Wait, I should think about whether I can vectorize the elementwise kernel by processing along the channel dimension. Since multiplier is per-channel, we could use float4 loads along W if W were divisible by 4. But W=254, not divisible by 4.
Alternatively, we can process 4 consecutive elements in W for a given (n, c, h). Since W=254, we can process 63 groups of 4 = 252 elements, and 2 leftover. But then the float4 loads might not be aligned unless the start of each row is aligned.
The start of row h for channel c is at offset: ((n * C + c) * H + h) * W.
This is divisible by W=254. 254 * 4 = 1016 bytes. Is 1016 divisible by 16? 1016 / 16 = 63.5. No, it's not. So rows are NOT 16-byte aligned!
This means float4 loads from the start of a row are not aligned. We could still do them (on modern GPUs, unaligned vector loads are supported but might be less efficient).
Actually, let me check: do we even need float4? For a memory bandwidth bound kernel on H100, scalar 32-bit loads should be able to saturate memory bandwidth if we have enough warps. H100 memory bandwidth is 3.35 TB/s = 3350 GB/s. Each warp does 32 loads * 4 bytes = 128 bytes per instruction. To saturate 3350 GB/s, we need about 3350e9 / 128 = 26 billion warp-instructions per second. At 1.5 GHz clock, that's about 17 warps per SM. With 132 SMs (assuming H100 SXM5), that's easily achievable.
So scalar loads should be fine. The main issue is instruction overhead and thread occupancy.
Let me write a clean elementwise fused kernel and see if it passes. Since it already passed before, I'll optimize it slightly with `__ldg` and a larger grid.
Actually, I just realized something. In my previous elementwise kernel, I computed `c = (idx / (H * W)) % C`. But I can avoid some math:
```cpp
int hw = idx % HW;
int c = (idx / HW) % C;
```
Or, since N*C*HW is the total, and N=64, C=64, HW=64516:
Actually, let me think of it as:
```cpp
int nc = idx / HW;
int c = nc % C;
```
This does one division and one modulo. Division is still expensive.
Alternative using precomputed reciprocals or magic numbers wouldn't help in a memory-bound kernel since the compiler might already optimize it.
Actually, since HW = 64516, which is not a nice number, we can't easily avoid division.
But here's an idea: we could launch a 3D grid that maps directly to (n, c, hw) and avoid the division.
```cpp
__global__ void kernel(...) {
int n = blockIdx.z;
int c = blockIdx.y;
int hw = blockIdx.x * blockDim.x + threadIdx.x;
if (hw >= HW) return;
int idx = (n * C + c) * HW + hw;
float val = in[idx] * mult[c];
...
out[idx] = val;
}
```
Grid: (HW/256, C, N) or similar.
HW = 64516. With blockDim.x = 256:
gridDim.x = (64516 + 255) / 256 = 253.
gridDim.y = 64.
gridDim.z = 64.
Total blocks: 253 * 64 * 64 = 1,036,288 blocks. That's a lot of blocks, but H100 can handle it.
Each thread computes one hw position. Threads within a block access consecutive hw values, which means consecutive memory addresses (since w varies fastest)! This is perfectly coalesced!
And we don't need any integer division at all! This is much better.
Let me write this version.
```cpp
__global__ void fused_post_conv_kernel(
const float* __restrict__ input,
const float* __restrict__ multiplier,
float* __restrict__ output,
int N, int C, int HW) {
int n = blockIdx.z;
int c = blockIdx.y;
int hw = blockIdx.x * blockDim.x + threadIdx.x;
if (hw >= HW) return;
int idx = ((n * C) + c) * HW + hw;
float val = input[idx] * multiplier[c];
val = fmaxf(val * 0.01f, val);
val = 0.5f * val * (1.0f + erff(val * 0.7071067811865475f));
output[idx] = val;
}
```
Wait, `((n * C) + c) * HW + hw` involves one multiply and one add. And since n, c are the same for all threads in a block, the base offset `(n*C + c)*HW` is the same. Threads only differ in `hw`, so they access consecutive memory addresses. Perfect coalescing.
Let me also use `__ldg` for the input read since it's read-only:
```cpp
float val = __ldg(&input[idx]) * multiplier[c];
```
This uses the read-only cache. Since all threads in a warp access consecutive addresses, this should be very efficient.
For multiplier, it's broadcast within a block (c is the same for all threads in a block), so it's cached in L1 and broadcast.
Let's also use a launch bound to maximize occupancy:
```cpp
__launch_bounds__(256, 4) // or whatever
```
But actually for a memory-bound kernel, occupancy isn't as critical as long as we have enough warps to hide latency. With 253*64*64 = 1,036,288 blocks of 256 threads each, we have plenty of work.
Wait, 254 * 254 = 64516, not HW. I said HW earlier. But in the kernel, `hw` going from 0 to H*W-1 = 64515. And each `hw` corresponds to a linearized (h, w) pair. In memory, for fixed n and c, element at (h, w) and (h, w+1) are consecutive. So yes, consecutive `hw` values are consecutive in memory.
This is excellent.
Now, can I also fuse the conv into this? Well, for that I'd need the conv to output directly without an intermediate buffer. But doing conv then this kernel in sequence requires writing conv output and reading it. If I want fully fused, I need to write the conv kernel.
Let me think once more about whether the elementwise fusion is enough.
Reference: conv + mult + leaky_relu + gelu.
My optimized elementwise fusion: conv + fused_elementwise.
Memory saved: conv writes 1.05GB, fused_elementwise reads 1.05GB and writes 1.05GB = 2.1GB total for these two kernels.
Reference: conv writes 1.05GB, mult reads+writes 2.1GB, leaky_relu reads+writes 2.1GB, gelu reads+writes 2.1GB = 7.35GB total.
So my approach saves ~5.25GB of memory traffic.
The conv in both cases is the same (cuDNN), so the time difference is entirely from the elementwise ops.
Reference elementwise: 3 kernels * 2.1GB each = 6.3GB traffic / 3.35TB/s = ~1.88ms.
My elementwise: 1 kernel * 2.1GB = 2.1GB traffic / 3.35TB/s = ~0.63ms.
Difference: ~1.25ms saved.
Is this enough to beat the reference? Yes, almost certainly.
But let me also try the fully fused conv approach just in case the evaluation is competitive and others submit fully fused kernels.
Actually, a fully fused kernel would have:
- Read input: 1GB
- Write output: 1GB
- Compute: 151 GFLOPs
If my direct conv achieves 50% of cuDNN performance but saves the entire conv output bandwidth, let's say cuDNN takes 0.5ms and my direct conv takes 1.0ms. Then:
Reference: 0.5ms + 1.88ms = 2.38ms.
My direct fused: 1.0ms (since conv and elementwise are fused).
Speedup = 2.38x.
My separate approach: 0.5ms + 0.63ms = 1.13ms.
Speedup = 2.1x.
Both are good. The separate approach is safer because cuDNN is very optimized.
But what if cuDNN conv takes 1.0ms and elementwise takes 0.6ms each?
Reference: 1.0 + 3*0.6 = 2.8ms.
My separate: 1.0 + 0.6 = 1.6ms. Speedup = 1.75x.
And if my direct conv takes 2.0ms (worse than cuDNN), then fully fused = 2.0ms, which is only 1.4x speedup. The separate approach wins.
I think the separate approach is safer and easier to get right. I'll go with it but write a highly optimized elementwise kernel.
Wait, I want to try one more optimization: reducing the number of blocks to a more reasonable amount. 1 million blocks is fine for modern GPUs, but maybe slightly fewer larger blocks would have less scheduling overhead.
What if I use blockDim.x = 1024?
gridDim.x = (64516 + 1023) / 1024 = 64.
Total blocks = 64 * 64 * 64 = 262,144.
Each block has 1024 threads.
This is also fine.
With 1024 threads, each block processes 1024 hw values. Since HW=64516, grid=64 blocks in x. The last block has some threads doing no work (64*1024 = 65536, but we only need 64516, so 65536 - 64516 = 1020 threads do nothing in the last block).
Wait, actually 63 blocks * 1024 = 64512, so the 64th block has only 4 active threads! That's poor utilization.
With blockDim.x = 256:
gridDim.x = (64516 + 255) / 256 = 253.
253 * 256 = 64768.
Last block has 64768 - 64516 = 252 unused threads. That's much better.
With blockDim.x = 512:
gridDim.x = (64516 + 511) / 512 = 127.
127 * 512 = 65024.
Last block has 65024 - 64516 = 508 unused threads. That's one full warp (32 threads) doing work and 508/512 wasted. Still decent.
With blockDim.x = 128:
gridDim.x = 505.
505 * 128 = 64640.
Last block has 64640 - 64516 = 124 unused. Very good.
I'll go with 256 threads per block.
But I can also use a different strategy: tile so that a block processes multiple channels.
Currently, each block processes one channel (c) and multiple hw positions. With 253 blocks per channel and 64 channels per N, and 64 N, total blocks = 253 * 64 * 64 = 1,036,288.
We could have each block process multiple c values. For example, blockDim.x = 256 processes 256 hw values, and we could also loop over multiple c values per block. But that doesn't change the grid size, it just changes the work per thread.
Actually, we could use blockIdx.y for c and have fewer y blocks with each processing multiple c values, but that doesn't really help.
With 1M blocks, the GPU has plenty of work to keep all SMs busy. H100 has 132 SMs, so each SM gets ~7800 blocks if distributed evenly. That's plenty for latency hiding.
One more optimization: I can process multiple hw values per thread, so there are fewer blocks and less block launch overhead.
For example, each thread processes 4 hw values with stride gridDim.x * blockDim.x:
```cpp
int tid = blockIdx.x * blockDim.x + threadIdx.x;
int total_hw = HW;
int stride = gridDim.x * blockDim.x; // this requires knowing gridDim.x at compile time or using a fixed launch config
```
But launch configuration is fixed in my code. Let me use:
```cpp
for (int hw = blockIdx.x * blockDim.x + threadIdx.x; hw < HW; hw += gridDim.x * blockDim.x) {
...
}
```
If I launch 128 blocks of 256 threads = 32768 threads. Each thread processes 2 hw values (64516 / 32768 ≈ 1.97). Total blocks = 128 * 64 * 64 = 524,288.
Or 64 blocks of 256 threads = 16384 threads. Each thread processes ~4 hw values. Total blocks = 64 * 64 * 64 = 262,144.
With fewer blocks, there's less kernel launch overhead and better occupancy per block. Let me try 128 blocks in x dimension.
Wait, gridDim.x = 128 means 128 * 256 = 32768 threads cover HW. But HW = 64516, so we need a stride loop.
Actually, a simpler idea: set gridDim.x to ceil(HW / blockDim.x) = 253, but this might not divide evenly. With the 1D loop, each thread handles exactly one hw in most blocks, except block 252 which has threads covering hw 64512 to 64515.
But with gridDim.x = 253 and blockDim.x = 256, there are 253 * 256 = 64768 threads but HW = 64516, so 252 threads (in the last block) are idle. That's very good utilization.
I'll stick with this.
Now, should I add any `__restrict__` qualifiers? Yes.
Should I use `__ldg`? Yes, for the input.
Should I make the kernel `__forceinline__` or template? Not needed.
Actually, I want to try one more thing: can I use `float` math that matches PyTorch exactly?
PyTorch's LeakyReLU with negative_slope=0.01 computes `max(0, x) + 0.01 * min(0, x)`, which is equivalent to `fmaxf(x, x * 0.01f)`. For negative x, x * 0.01f < x, so fmaxf returns x. Wait no, for negative x (say -2), x = -2, x * 0.01 = -0.02, so fmaxf(-2, -0.02) = -0.02. That's correct LeakyReLU.
My `fmaxf(x, x * 0.01f)` is correct.
For GELU, PyTorch exact formula:
```python
0.5 * x * (1 + torch.erf(x / math.sqrt(2)))
```
My CUDA:
```cpp
0.5f * x * (1.0f + erff(x * 0.7071067811865475f))
```
This should match exactly since `erff` is single-precision erf, same as PyTorch CUDA uses.
One subtlety: PyTorch's `nn.Conv2d` default padding is 0. So H_out = H_in - K + 1 = 254. My elementwise kernel doesn't need to know this since it operates on whatever the conv outputs.
Wait, I need to know H and W of the output to compute the linear index. In my current elementwise kernel, I flatten everything into HW. But I need to know what H*W is. In the kernel, I can compute it as total_elements / (N * C).
Actually, the kernel is called after conv, and the output has the same shape. I can compute HW from the tensor shape in Python and pass it.
Let me also consider using `torch.empty_like` for the output in Python.
Here's my plan for the elementwise fused kernel:
```cpp
#include <torch/extension.h>
#include <cuda_runtime.h>
__global__ void fused_elementwise_kernel(
const float* __restrict__ input,
const float* __restrict__ multiplier,
float* __restrict__ output,
int N, int C, int HW) {
int n = blockIdx.z;
int c = blockIdx.y;
int hw = blockIdx.x * blockDim.x + threadIdx.x;
if (hw >= HW) return;
int idx = ((n * C) + c) * HW + hw;
float val = __ldg(&input[idx]);
val = val * multiplier[c];
val = fmaxf(val * 0.01f, val);
val = 0.5f * val * (1.0f + erff(val * 0.7071067811865475f));
output[idx] = val;
}
torch::Tensor fused_elementwise(torch::Tensor input, torch::Tensor multiplier) {
int N = input.size(0);
int C = input.size(1);
int H = input.size(2);
int W = input.size(3);
int HW = H * W;
auto output = torch::empty_like(input);
const int threads = 256;
dim3 blocks((HW + threads - 1) / threads, C, N);
fused_elementwise_kernel<<<blocks, threads>>>(
input.data_ptr<float>(), multiplier.data_ptr<float>(),
output.data_ptr<float>(), N, C, HW);
return output;
}
```
This looks solid. Let me compile and test it.
Actually, one potential issue: `torch::empty_like(input)` creates an output tensor of the same shape and dtype. The conv output might be contiguous. If the conv output is not contiguous for some reason, `empty_like` would match that and my kernel indexing might be wrong. But by default `nn.Conv2d` outputs a contiguous tensor when the input is contiguous, so this should be fine.
Let me submit this and see.
Wait, I want to also try precomputing the multiplier access by loading it into a register once per block. But since it's the same for all threads in a block (same c), the compiler will likely keep it in a register anyway.
Another optimization: use `__launch_bounds__(256, 4)` to hint the compiler that there are at least 4 blocks per SM. But this is mainly useful for register pressure reduction. For a simple kernel like this, registers per thread should be low, so occupancy should already be high.
Let me compile and test this version.