reasoning
Let me analyze the reference model:
1. `conv = nn.Conv3d(in_channels=8, out_channels=16, kernel_size=(3,3,3))`
- Input shape: [128, 8, 16, 64, 64]
- Output shape: [128, 16, 14, 62, 62] (since 16-3+1=14, 64-3+1=62)
2. `x = x / 2.0`
- Element-wise division
3. `max_pool = nn.MaxPool3d((2,2,2))`
- Output shape: [128, 16, 7, 31, 31] (ceil_mode=False, so floor division)
4. `global_avg_pool = nn.AdaptiveAvgPool3d((1,1,1))`
- Output shape: [128, 16, 1, 1, 1]
5. `x = x + bias` where bias shape = (16, 1, 1, 1)
- Broadcasting
6. `torch.sum(x, dim=1)`
- Sum over channels, output: [128]
Wait, let me recalculate shapes carefully:
- Input: [128, 8, 16, 64, 64]
- Conv3d with kernel_size=(3,3,3), no padding, stride=1, dilation=1
- D_out = 16 - 3 + 1 = 14
- H_out = 64 - 3 + 1 = 62
- W_out = 64 - 3 + 1 = 62
- Output: [128, 16, 14, 62, 62]
- MaxPool3d(2,2,2) with default stride=2, ceil_mode=False
- D_out = floor((14 - 2)/2) + 1 = 6 + 1 = 7
- H_out = floor((62 - 2)/2) + 1 = 30 + 1 = 31
- W_out = floor((62 - 2)/2) + 1 = 30 + 1 = 31
- Output: [128, 16, 7, 31, 31]
- AdaptiveAvgPool3d((1,1,1))
- Output: [128, 16, 1, 1, 1]
- Add bias of shape (16, 1, 1, 1)
- Output: [128, 16, 1, 1, 1]
- Sum along dim=1
- Output: [128, 1, 1, 1]
So the whole thing essentially computes a scalar per batch element.
The key insight: after adaptive avg pool, we have [128, 16, 1, 1, 1]. Then sum over dim=1 gives [128].
We need to numerically match the PyTorch reference. Let me think about how to fuse this.
The operations are:
1. Conv3d
2. Divide by 2
3. MaxPool3d(kernel=2, stride=2)
4. AdaptiveAvgPool3d((1,1,1)) which is just average over the remaining spatial dims
5. Add bias
6. Sum over channels
Since adaptive avg pool over (1,1,1) just takes the mean of all elements in each channel, and then we sum over channels... Actually, we could potentially fuse some things.
Wait, let's trace through:
- After max pool: shape [128, 16, 7, 31, 31]
- Global avg pool: computes mean over 7*31*31 = 6727 elements per channel
- After bias and sum: for each batch element b, output = sum_c (mean_{d,h,w} maxpool_val[b,c,d,h,w] / 6727 + bias[c])
= sum_c mean_{d,h,w} maxpool_val[b,c,d,h,w] / 6727 + sum_c bias[c]
Wait no, adaptive avg pool computes average = sum / count, where count = 7*31*31.
Then x = avg / divisor after conv? Let me re-read...
Actually order is:
1. conv
2. x = x / divisor (divisor = 2.0)
3. max_pool
4. global_avg_pool (which is avg)
5. x = x + bias
6. sum(x, dim=sum_dim)
So:
output[b] = sum_c (avg_{d,h,w}( maxpool( conv(x)[b,c] / 2 ) ) + bias[c])
= sum_c (avg_{d,h,w}( maxpool( conv(x)[b,c] / 2 ) ) ) + sum_c bias[c]
Since sum_c bias[c] is constant, we just need the conv+maxpool+avg to be correct.
Now, the question is: can we fuse all of this into one kernel?
For Conv3d with in_channels=8, out_channels=16, kernel=3x3x3:
- This is a small conv. Output before pool is [128, 16, 14, 62, 62].
- We could use implicit gemm or direct convolution.
Actually, looking at it more carefully, maxpool comes after conv, then global avg pool. Since global avg pool is just reducing everything to 1x1x1, and then we sum over channels... The final output is just [128].
Could we precompute the effect of maxpool + avgpool + channel_sum + bias as a single reduction on the conv output?
After conv, for each output position and channel, we divide by 2, then apply 2x2x2 maxpool with stride 2, then average all remaining spatial positions, then add bias and sum channels.
Actually, maxpool + adaptive avg pool can be combined. After maxpool on [14, 62, 62] with 2x2x2 kernel and stride 2, we get [7, 31, 31]. Then adaptive avg pool to [1,1,1] is just the mean of all 7*31*31 values.
So for each channel c and batch b:
contrib[b, c] = (1 / (7*31*31)) * sum_{d=0..6, h=0..30, w=0..30} max_{di,dh,dw in {0,1}} conv_out[b,c,2d+di,2h+dh,2w+dw] / 2
Plus bias[c], then sum over c.
The conv output has shape [14, 62, 62]. Let me verify:
- 14/2 = 7 (since floor((14-2)/2)+1 = 7) - wait, 14 with kernel=2, stride=2: positions are 0, 2, 4, 6, 8, 10, 12. That's 7 positions. Yes.
- 62/2 = 31: positions 0, 2, ..., 60. That's 31 positions. Yes.
So 7*31*31 = 6727.
Then output[b] = sum_c ( contrib[b, c] + bias[c] )
= sum_c contrib[b, c] + sum_c bias[c]
So we just need to compute for each c and b:
sum_{d,h,w} max_{di,dh,dw} conv_out[b,c,2d+di,2h+dh,2w+dw]
divided by (2 * 6727), then add sum(bias).
Now, the conv itself: for each output position (od, oh, ow) and output channel c:
conv_out[b,c,od,oh,ow] = sum_{ic=0..7} sum_{kd=0..2} sum_{kh=0..2} sum_{kw=0..2}
weight[c, ic, kd, kh, kw] * input[b, ic, od+kd, oh+kh, ow+kw]
So overall:
output[b] = (1/(2*6727)) * sum_c sum_{d=0..6} sum_{h=0..30} sum_{w=0..30}
max_{di,dh,dw in {0,1}} sum_{ic,kd,kh,kw} weight[c,ic,kd,kh,kw] * input[b,ic,2d+di+kd,2h+dh+kh,2w+dw+kw]
+ sum_c bias[c]
This looks quite complex to fuse entirely. Maybe a better approach is to:
1. Use cuDNN for conv (PyTorch will do this) - but we want to be faster than PyTorch reference.
2. Fuse the remaining operations.
Actually, PyTorch probably already uses cuDNN for conv, and might even fuse some ops. But maxpool + global avg pool + bias + sum are separate ops. Maybe we can write a custom kernel that does maxpool + global_avg_pool + bias_add + sum in one go on the conv output.
But conv3d with these shapes might be the bottleneck. Let me estimate FLOPs:
Conv3d FLOPs:
batch=128, out_ch=16, in_ch=8, kernel=27, output_spatial=14*62*62=53816
FLOPs = 128 * 16 * 8 * 27 * 53816 / 1e9 ≈ 2.37 GFLOPs
MaxPool3d: negligible
GlobalAvgPool: 128 * 16 * 7 * 31 * 31 ≈ 13.7M operations
So conv definitely dominates in compute. But PyTorch's conv is already using cuDNN which is highly optimized. The speedup might come from fusing the post-processing.
Wait - in PyTorch, `nn.Conv3d` followed by `/` and `MaxPool3d` etc are separate kernels. Maybe cuDNN conv + bias + relu is fused, but `/ 2.0` and maxpool won't be.
Actually, can we fuse conv + division + maxpool? This would reduce memory bandwidth significantly. The conv output is [128, 16, 14, 62, 62] = 128 * 16 * 14 * 62 * 62 * 4 bytes ≈ 347 MB. Writing and reading this dominates.
If we fuse conv + division + maxpool, we save writing ~347 MB and reading ~347 MB.
For the conv itself, we can implement a direct 3D convolution kernel since the sizes are small and fixed. With in_channels=8 and out_channels=16, we can load weights into shared memory and have each thread block compute one output position or a tile.
Let me think about the kernel strategy:
**Strategy: Fuse Conv3d + Div + MaxPool3d + GlobalAvgPool3d + BiasAdd + Sum**
Since the final output is just [128] (one float per batch), we can potentially have a reduction-based approach.
For each batch element b:
- We need to compute sum over c, d, h, w of max_{di,dh,dw} conv_out[b,c,2d+di,2h+dh,2w+dw]/2 + bias[c], averaged over 6727 and summed.
Actually: global_avg = sum / 6727. Then + bias. Then sum over c.
output[b] = sum_c [ (sum_{dhw} maxpool_val) / 6727 + bias[c] ]
= sum_c (sum_{dhw} maxpool_val) / 6727 + sum_c bias[c]
So if we compute S[b] = sum_{c,d,h,w} maxpool_val[b,c,d,h,w], then:
output[b] = S[b] / (2 * 6727) + sum_c bias[c]
So we just need to compute the sum of all maxpool values across all channels!
This is a massive simplification. Instead of computing the full conv output and then maxpooling, we can directly accumulate into a scalar per batch.
For each batch b, we want:
S[b] = sum_{c=0..15} sum_{d=0..6} sum_{h=0..30} sum_{w=0..30}
max_{di,dh,dw in {0,1}} conv_out[b,c,2d+di,2h+dh,2w+dw]
And conv_out[b,c,od,oh,ow] = sum_{ic=0..7} sum_{kd=0..2} sum_{kh=0..2} sum_{kw=0..2}
weight[c,ic,kd,kh,kw] * input[b,ic,od+kd,oh+kh,ow+kw]
So:
S[b] = sum_{c,d,h,w} max_{di,dh,dw} sum_{ic,kd,kh,kw} weight[c,ic,kd,kh,kw] * input[b,ic,2d+di+kd,2h+dh+kh,2w+dw+kw]
The max is over a 2x2x2 block. The inner sum is the conv dot product.
This is tricky to fuse entirely because the max is outside the convolution sum (since maxpool operates on conv output). But we CAN implement it as:
For each output position (od, oh, ow) of the conv:
1. Compute conv_out[b, c, od, oh, ow]
2. If this position belongs to a maxpool window, compare it and keep max
3. Accumulate the max values
Actually, a simpler and probably more efficient approach: just fuse conv + div + maxpool into one kernel, producing [128, 16, 7, 31, 31]. Then do a separate reduction kernel for global_avg_pool + bias_add + sum.
Producing [128, 16, 7, 31, 31] = 128 * 16 * 7 * 31 * 31 = 13,707,008 floats = ~54.8 MB. That's much smaller than the 347 MB intermediate.
Then reduction of 54.8 MB to 128 floats is tiny.
The main question is: can our fused conv+maxpool beat cuDNN conv + PyTorch maxpool?
For conv: [128, 8, 16, 64, 64] -> [128, 16, 14, 62, 62]
The conv is relatively small: 2.37 GFLOPs. At 67 TFLOPS, that's 0.035 ms in theory. But memory bandwidth for reading input: 128 * 8 * 16 * 64 * 64 * 4 / 1e9 = 268 MB. At 3.35 TB/s that's 0.08 ms. If not fused, writing output is another 347 MB = 0.1 ms. Total maybe 0.2 ms or more.
If we fuse conv + maxpool, we read input once (268 MB) and write maxpool output (55 MB). That's much better.
Actually, we can precompute the input positions needed for each maxpool output. For each maxpool out (d,h,w), we need conv out at 8 positions: (2d,2h,2w), (2d+1,2h,2w), etc.
But wait - each conv output position depends on a 3x3x3 region of input. So each maxpool output depends on input in an approximately 4x4x4 region (since we cover positions 2d to 2d+2 in each dimension, but with overlap).
Let me think about a thread block design:
For a given batch element b and output channel c:
- We need to compute maxpool over [7, 31, 31]
- Each maxpool output is max over 2x2x2 conv outputs
- Each conv output is a dot product of input [8, 3, 3, 3] with weights
We can tile the spatial dimensions. For example, process a tile of 4x8x8 maxpool outputs = 256 positions. Each position needs 8 conv outputs, and each conv output needs 8*27=216 multiply-adds.
Wait, this is 256 * 8 * 216 = 442K FMAs per tile. For 128 batches and 16 channels, we have 128*16=2048 tiles of this size (actually 7*31*31 / 256 ≈ 26 tiles per (b,c), total 2048*26 ≈ 53K thread blocks if one per tile).
Actually let's look at it differently. Total conv FMAs: ~2.37B. If each thread block does 442K FMAs, we need ~5,370 thread blocks. The H100 has many SMs, so this should be enough.
But memory reuse is key. For a tile of maxpool outputs:
- In input D dimension: maxpool outputs at d positions need conv at [2d, 2d+1] and possibly 2d+2 since conv covers 3 elements. So for d tile of size D_tile, we need input depth coordinate range of roughly 2*D_tile + 2. For D_tile=4, that's 10.
- In H dimension: for H_tile=8, input height range is 2*8 + 2 = 18.
- In W dimension: for W_tile=8, input width range is 18.
- Total input elements for the tile: 8 channels * 10 * 18 * 18 = 25,920 floats = 103 KB.
This fits in shared memory! Actually 228 KB per SM, so two of these tiles could fit, or we can make tiles slightly larger.
Let's verify: if we process a tile of Td * Th * Tw maxpool outputs:
- Conv outputs needed: 2*Td * 2*Th * 2*Tw (since maxpool covers 2x2x2 conv outputs, but they're contiguous in the conv output)
Wait no - for maxpool at (d,h,w), we look at conv outputs at (2d,2h,2w), (2d+1,2h,2w), ... etc. But these are the exact conv output positions.
Actually, I think a cleaner approach: compute conv outputs for a tile directly, store in shared memory, then maxpool and write out.
For a tile of conv outputs: say 8x16x16 = 2048 positions.
- Input needed: (8+2) x (16+2) x (16+2) = 10*18*18 = 3240 per channel. For 8 channels: 25,920.
- In shared memory: 25,920 floats ≈ 104 KB.
- Conv output in shared memory: 2048 floats per channel * 16 channels if interleaved... Actually we can compute one output channel at a time, keeping weights in registers/shared.
Maybe better: have each thread block compute a subset of (b, c) and spatial tile.
Let me try a different approach. Since in_channels=8, out_channels=16, kernel=3x3x3:
- Total weights: 16 * 8 * 27 = 3456 floats = 13.8 KB. Fits in shared memory or registers.
Actually, since weights are small, we can keep them in shared memory. Each output channel's weights are 8*27 = 216 floats = 864 bytes. All 16 channels: 13.8 KB.
Input is larger. For a spatial tile, if each thread in a block computes one output point:
- Block size: say 256 threads.
- Each thread computes one conv output for one (b, c, d, h, w).
- To share input, threads should be computing nearby outputs.
Let's say block computes a 4x8x8 tile of conv outputs (256 positions) for one (b, c) pair. Wait, that's only 1 output channel per block. With 128 batches * 16 channels = 2048 blocks, this might not saturate the GPU. But actually 2048 blocks of 256 threads is 524K threads. H100 can run a lot, but maybe more parallelism is better.
Actually let's do: each block computes a 4x8x8 tile of conv outputs for ALL 16 output channels and ONE batch element. That's 16 * 256 = 4096 outputs per block. With 128 batch elements, that's 128 blocks. That might be too few.
How about: each block computes for 1-2 batch elements, a subset of output channels, and a spatial tile.
Wait, let me reconsider using implicit GEMM or even just calling cuDNN. Actually, PyTorch's nn.Conv3d uses cuDNN which is extremely well optimized. For a 2.37 GFLOP conv, cuDNN is probably very close to optimal. The main overhead comes from:
1. Writing conv output (347 MB)
2. Element-wise division (347 MB read + write)
3. MaxPool3d (read 347 MB, write 55 MB)
4. Global avg pool (read 55 MB)
5. Bias + sum (tiny)
So fusing div + maxpool into conv saves writing 347 MB and reading 347 MB (div writes then maxpool reads). But writing conv output and reading it in maxpool is the main issue.
Can we fuse conv + maxpool without doing a full custom conv? We could potentially use CUTLASS or hooks into PyTorch, but we need a custom kernel.
Actually, another idea: we DON'T need the full conv output. Since global avg pool reduces everything to 1x1x1, and then we sum channels, we just need the sum over all maxpool outputs as I derived above!
S[b] = sum_{c=0..15} sum_{d=0..6} sum_{h=0..30} sum_{w=0..30} max_{di,dh,dw} conv_out[b,c,2d+di,2h+dh,2w+dw]
output[b] = S[b] / (2 * 6727) + B_total
where B_total = sum_c bias[c]
So we never need individual conv outputs! For each batch element b, we want a single scalar S[b].
This means each thread/warp/block can work on a subset of the spatial positions and channels, accumulate their local sum, and then reduce.
S[b] is a sum over all channels and spatial positions of the maxpool values. This is perfect for parallel reduction!
For a given batch element b and output channel c:
- We have a grid of 7*31*31 = 6727 maxpool positions
- Each maxpool position is the max of 2*2*2 = 8 conv outputs
- Each conv output is a dot product of 8*3*3*3 = 216 weights with input values
Total work per (b,c): 6727 * 8 * 216 = 11.6M FMAs
For 128*16 = 2048 (b,c) pairs: 23.8B FMAs total. Wait, that's 10x more than before!
Why? Because earlier I said conv FLOPs are 2.37 GFLOPs. Let me recalculate:
Conv FLOPs = batch * out_ch * in_ch * kD * kH * kW * outD * outH * outW
= 128 * 16 * 8 * 27 * 14 * 62 * 62
= 128 * 16 * 8 * 27 * 53,816
= 128 * 16 * 8 * 1,453,032
= 128 * 16 * 11,624,256
= 128 * 185,988,096
= 23,806,476,288 ≈ 23.8 GFLOPs
Ah I miscalculated before! 23.8 GFLOPs. At 67 TFLOPS that's 0.35 ms in theory.
If we compute S[b] directly by iterating over maxpool positions and computing 8 conv outputs per position, that's:
- For each maxpool position, we need 8 conv outputs.
- But many conv outputs are reused across adjacent maxpool positions! For example, conv_out at (0,0,0) is used in maxpool at (0,0,0), but conv_out at (1,0,0) is also used there and at maxpool (0,0,0) with different offsets... Actually:
- maxpool(0,0,0) uses conv at (0,0,0), (0,0,1), (0,1,0), (0,1,1), (1,0,0), (1,0,1), (1,1,0), (1,1,1)
- maxpool(0,0,1) uses conv at (0,0,2), (0,0,3), (0,1,2), (0,1,3), (1,0,2), (1,0,3), (1,1,2), (1,1,3)
So conv outputs are NOT reused across maxpool windows (since maxpool has stride=2 and kernel=2). Great!
So we could compute conv outputs "on demand" for the maxpool windows without reusing conv outputs across windows. But within a maxpool window, we compute 8 conv outputs and take their max.
However, input IS reused across conv outputs. For conv outputs at (0,0,0) and (0,0,1), they share most input values (shifted by 1 in W dimension).
So the question is: what's the most efficient way to compute this?
Option A: Fused conv+maxpool kernel that writes [128, 16, 7, 31, 31]. Then separate reduction.
Option B: Directly accumulate S[b] without writing intermediates.
For Option B, we'd compute maxpool values and immediately add them to a running sum. But to do this efficiently, we still need to compute conv outputs. Since maxpool windows don't overlap, we can assign one thread or warp to one or more maxpool windows.
Let's think about Option A more carefully. Writing [128, 16, 7, 31, 31] = ~55 MB. This is not too bad. Then a reduction kernel over 55MB to [128] is trivial.
The question is whether our fused conv+maxpool can beat cuDNN conv + PyTorch maxpool.
cuDNN conv is very fast. The conv output is 347 MB. A maxpool kernel might read 347 MB and write 55 MB. Total memory for conv+maxpool:
- cuDNN conv: read input (268 MB) + write output (347 MB) + compute
- PyTorch / 2.0: read 347, write 347
- PyTorch maxpool: read 347, write 55
Total memory moved: 268 + 347*3 + 55 = 1263 MB. At 3.35 TB/s that's 0.38 ms just in memory.
If we fuse conv + div + maxpool: read input 268 MB, write maxpool output 55 MB. Total 323 MB. At 3.35 TB/s that's 0.096 ms in memory + compute time.
So there's definitely potential for a 2-3x speedup just from fusion if we can achieve decent compute efficiency.
Now, the conv parameters are small and fixed:
- in_channels=8
- out_channels=16
- kernel_size=3x3x3
- Input size: 16x64x64
- Output size before pool: 14x62x62
Given the small filter size, a direct convolution with explicit loops might work well, especially if we tile carefully.
Let me design the kernel:
**Kernel 1: Fused Conv3d + Scale + MaxPool3d**
For each output position in the maxpool output: (b, c, d, h, w)
- Determine the 2x2x2 window in conv output: conv positions [2d, 2d+1] x [2h, 2h+1] x [2w, 2w+1]
- For each of these 8 positions, compute conv, divide by 2, and track max
- Write max to output[b, c, d, h, w]
Each thread can compute one maxpool output. Or a warp can compute multiple for better coalescing.
To optimize, we should tile the computation to reuse input data via shared memory.
Tile dimensions: Let's say we process a spatial tile of TD x TH x TW maxpool outputs. For example, 2x4x4 = 32 positions. We launch 32 threads.
Actually, we need enough parallelism. Total maxpool outputs: 128*16*7*31*31 ≈ 13.7M. With 1024 threads per block, we'd need about 13K blocks. That's decent.
But to reuse input in shared memory, it's better to have threads in a block compute spatially adjacent outputs.
Let's say each block computes:
- 1 batch element
- Some number of output channels (maybe 1 or 2 or 4)
- A spatial tile of TD x TH x TW
For TD=1, TH=8, TW=8 = 64. Or TD=2, TH=8, TW=4 = 64.
For 1 batch, 4 channels, 64 positions = 256 threads/block. Good.
Input needed for conv outputs at [2d..2d+TD+1] x [2h..2h+TH+1] x [2w..2w+TW+1] but considering kernel size 3:
Actually, conv output at position (od, oh, ow) needs input at [od..od+2] x [oh..oh+2] x [ow..ow+2].
For maxpool over [2d, 2d+1], conv positions are 2d, 2d+1.
But we also need conv position 2d+2 for the next maxpool? No, maxpool windows don't overlap.
For maxpool at (d,h,w), conv positions are exactly (2d, 2h, 2w) to (2d+1, 2h+1, 2w+1).
So for a tile of maxpool outputs from d=[d0, d0+TD-1], h=[h0, h0+TH-1], w=[w0, w0+TW-1]:
The conv positions needed are d=[2d0, 2(d0+TD-1)+1] = [2d0, 2d0+2TD-1]
And input positions for these conv outputs are d=[2d0, 2d0+2TD-1+2] = [2d0, 2d0+2TD+1]
So for TD=2, input depth range = 4+1 = 5
For TH=8, input height range = 16+1 = 17
For TW=8, input width range = 17
Total input per channel: 5 * 17 * 17 = 1445
For 8 channels: 11,560 floats = 46 KB. Fits easily in shared memory.
For 4 output channels processed together, we don't need more input, just more weights (4 * 8 * 27 = 864 floats = 3.4 KB). Total shared memory per block: ~50 KB. Very comfortable.
So a block of 256 threads (64 spatial * 4 channels) for 1 batch element seems great.
Actually, let's use TD=1, TH=4, TW=4 = 16 spatial positions per channel. And 16 channels = 256 threads.
Input spatial range: depth=1*2+1=3, height=4*2+1=9, width=9. Total: 3*9*9 = 243 per channel * 8 = 1944 floats = 7.8 KB.
Weights: 16*8*27 = 3456 floats = 13.8 KB.
Total: ~22 KB. This is very conservative.
But 16 spatial * 16 channels = 256 threads. Number of blocks = 128 batches * ceil(7/1) * ceil(31/4) * ceil(31/4) = 128 * 7 * 8 * 8 = 57,344 blocks. That's plenty of parallelism.
Let's try with larger tiles: TD=1, TH=8, TW=8 = 64 spatial positions. With 4 channels = 256 threads.
Input: depth=3, height=17, width=17. Per channel: 867. For 8 channels: 6936 floats = 27.7 KB.
Weights for 4 channels: 4*8*27 = 864 floats. Total shared: ~29 KB. Still very comfortable.
Blocks: 128 * 7 * ceil(31/8) * ceil(31/8) * ceil(16/4) = 128 * 7 * 4 * 4 * 4 = 57,344 blocks. Same count because 16*4*4 = 256, 64*4 = 256. Actually spatial count is 7*4*4 = 112 per batch, times 4 channel groups = 448 per batch, times 128 = 57,344 blocks.
Hmm wait, each block does 64 positions * 4 channels = 256 outputs. Total outputs = 13.7M. 13.7M / 256 = 53,500 blocks. Yes about that.
Let me try TD=7 (all depth), TH=4, TW=4 = 112 spatial positions. Then per batch we'd have ceil(31/4)*ceil(31/4) = 64 blocks for spatial, times ceil(16/4)=4 for channels = 256 blocks per batch * 128 = 32,768 blocks.
With 112 spatial * 4 channels = 448 threads per block.
Input depth range: 2*7+1 = 15. Height=9, width=9. Per channel: 15*9*9 = 1215. 8 channels = 9720 floats = 38.9 KB. Plus weights ~14KB. Total ~53KB. Still well under limits.
This is getting complex. Let me start with a simpler kernel and iterate.
Actually, for a first version, let me write a kernel where each thread handles one maxpool output position and one output channel. We can improve from there.
**Simple approach:**
- Grid: (batch, out_channel, spatial)
- spatial = d * 31 * 31 + h * 31 + w
- Block: 256 threads handling 256 spatial locations across some channels
- Or just 1 thread per maxpool output
Given we have 13.7M maxpool outputs to produce, and each requires max of 8 conv outputs, and each conv output is 216 FMAs, total FMAs = 23.8G as calculated.
Actually, I realize there's an even simpler insight. Since the final output after adaptive avg pool and sum is just [128], maybe we can compute everything as matrix operations or use reduction. But let's stick with the fused kernel approach.
Wait, I want to reconsider whether we should call cuDNN for conv and just fuse the rest. For small convs, a custom kernel might be competitive, but cuDNN is extremely well-tuned. However, cuDNN conv writes [128, 16, 14, 62, 62] which is large.
Let me try to write a kernel that fuses conv + div + maxpool. If that works well, great. If not, I'll try calling conv via PyTorch and fusing the remaining ops.
Actually, another option: we can fuse maxpool + adaptive_avg_pool + bias + sum into one kernel. The input to this is the conv output / 2.0. But if we can't fuse the division and maxpool into conv, then:
- PyTorch conv (fast)
- Element-wise division (in-place or fused with something)
- Maxpool
- Our fused kernel for adaptive_avg_pool + bias + sum
Actually, could we use torch.compile or something? No, we need a custom CUDA kernel.
Let me look at this differently. What if we do:
1. Use PyTorch's conv3d (cuDNN)
2. Perform a custom fused kernel that does: x = x / divisor, max_pool, global_avg_pool, +bias, sum
For the custom kernel, input is [128, 16, 14, 62, 62], output is [128].
Each thread block could process one batch element and one or more channels, iterating over the spatial dimensions, computing maxpool values on the fly, accumulating averages.
For batch b and channel c:
- For each maxpool window (d=0..6, h=0..30, w=0..30):
- Read 8 values from conv_out[b, c, 2d:2d+2, 2h:2h+2, 2w:2w+2]
- Max of these 8
- Accumulate to sum
- After all windows: avg = sum / 6727
- result = avg + bias[c]
- Sum over channels -> output[b]
If we have each thread block handle one batch element and 16 channels:
- Total reads: 128 batch * 16 channels * 7 * 31 * 31 * 8 floats = 13.7M * 8 = 110M floats = 440 MB
- That's a lot of reads, but less than the 347 MB write + 347 MB read of separate maxpool + global avg.
Wait, 440 MB reading from [128, 16, 14, 62, 62]. The full tensor is 128*16*14*62*62 = 109M floats = 436 MB. So we'd be reading the entire tensor once. That's actually very efficient!
Then we don't even need to write the maxpool output. We just:
1. Conv (PyTorch/cuDNN): reads input, writes conv output
2. Fused kernel: reads conv output, computes maxpool+avg+bias+sum, writes [128]
Total memory: read input 268MB + write conv 436MB + read conv 436MB = 1140 MB.
Compared to original:
1. conv: read 268 + write 436
2. /2.0: read 436 + write 436
3. maxpool: read 436 + write 55
4. global avg: read 55 + effectively tiny write
5. bias+sum: tiny
Total original: 268 + 436 + 436 + 436 + 436 + 55 + 55 ≈ 2062 MB. But PyTorch might fuse some things.
So just doing conv in PyTorch + a fused reduction kernel saves about half the memory bandwidth. This could give a decent speedup and is MUCH simpler to implement correctly.
Wait, but we can also fuse /2.0 into the conv using PyTorch's conv bias? No, nn.Conv3d doesn't support scaling. But we could pass pre-divided weights? No, the weights are learned in the reference. We'd need to copy and divide them.
Actually we CAN scale the conv weights and bias by 1/divisor when we set up the module! But the issue is the evaluator will check our ModelNew produces the same output as Model. If we copy the conv weights to our module and use them, we could divide them by 2 during __init__. Then our custom kernel doesn't need to divide.
Wait - but then the maxpool operates on conv_out / 2. If we embed the division into conv weights and bias, then conv(x) is already divided. But nn.Conv3d also has a bias that gets added. If we divide weights by 2 but leave bias, then conv(x) = (W/2)*x + b. But reference is (W*x + b)/2 = W*x/2 + b/2. So we'd also need to divide bias by 2.
So in __init__, we could copy the conv weights and bias, divide them by 2, and use those in our own conv. Then the PyTorch conv output is already scaled!
Actually we can just use `nn.Conv3d` with the scaled weights, or even better, use `F.conv3d`. But if we use PyTorch's conv3d, it will be just as fast as the reference's conv3d.
So approach:
1. In __init__, recreate conv3d or store pre-divided weights and bias.
2. Forward: apply conv3d (cuDNN), then custom fused kernel for maxpool + adaptive_avg_pool + bias_add + sum.
Actually wait, the bias in the reference model is a separate parameter `self.bias` with shape (16, 1, 1, 1). The conv has its own bias (enabled by default in nn.Conv3d)!
Oh! I missed that! nn.Conv3d has bias=True by default. So `self.conv` includes a bias term. The reference does:
x = conv(x) # includes conv bias
x = x / divisor
x = max_pool(x)
...
x = x + self.bias # adds the separate bias parameter
So there are TWO bias terms: one in conv, one added after global avg pool.
If we want to absorb /2 into conv, we need to divide both conv weight AND conv bias by 2.
Let me verify by checking if the reference conv has bias. Yes, nn.Conv3d default is bias=True.
So our approach:
1. Keep PyTorch conv3d but with weights divided by 2 and bias divided by 2.
2. Custom kernel that reads conv output [128,16,14,62,62] and directly computes: maxpool + mean + add_bias + sum_channels.
The custom kernel input is [B, C, D, H, W] = [128, 16, 14, 62, 62].
For each batch and each channel:
- Iterate d=0..6, h=0..30, w=0..30
- For each (d,h,w), compute max over [2d:2d+2, 2h:2h+2, 2w:2w+2]
- Accumulate sum
- mean = sum / 6727
- After channel loop: total = sum over channels of (mean + bias[c])
- Output[batch] = total
We can compute this very efficiently with coalesced memory access.
Memory access pattern for reading [B, C, D, H, W]:
- We want threads in a warp to read adjacent W values for coalescing.
- But maxpool reads 2x2x2 blocks with stride 2 in each dimension.
If thread i reads position (b, c, d, h, w_i) where w_i is sequential, then we read W values sequentially. But maxpool needs pairs (w, w+1).
Actually, let's have each thread compute one (b, c, d, h) and iterate over w:
For fixed b, c, d, h:
- For w in 0..30:
- Read 8 values: (2d, 2h, 2w), (2d, 2h, 2w+1), (2d, 2h+1, 2w), (2d, 2h+1, 2w+1), (2d+1, 2h, 2w), etc.
- The W dimension varies fastest. So (anything, anything, 2w) and (anything, anything, 2w+1) are adjacent in memory!
If blockDim.x = 32 (warp), and each warp handles different (b, c, d, h) but same w offset? No, to coalesce, we should have threads in a warp reading adjacent memory.
Option: Thread idx determines w, and we iterate over b,c,d,h. But then each thread only processes a few w values and we lose parallelism.
Better option: A block has (B_per_block, C_per_block, D_per_block, H_per_block) = say (1, 2, 1, 16) = 32 threads. Each thread handles all W positions (31 values) for its (b,c,d,h).
In this case, thread t is at some (b, c, d, h). When all threads read at W=2w, they read memory offsets that differ in H and C dimensions:
- Thread 0: (b, c0, d, h0, 2w)
- Thread 1: (b, c0, d, h1, 2w)
- Thread 2: (b, c1, d, h0, 2w)
etc.
If h varies fastest in thread index, and since W varies fastest in memory layout (at least for NCHW or NCDHW), then threads reading different H values are strided by W. Actually in NCDHW:
offset = ((b * C + c) * D + d) * H + h) * W + w
If two threads differ by 1 in h, their memory offsets differ by W = 62. That's not adjacent.
For coalesced access, we want threads to differ in w. So if thread t handles a unique w position, they'd read adjacent w values. But each thread needs to read 2 adjacent w values for the maxpool.
Idea: Use 32 threads where thread t handles (b, c, d, h, t) for w=t... but w only goes to 30, and each thread needs 2 w values.
Actually, let's have each warp handle a 2x2x2 tile or something.
The simplest that might work:
- Each block handles 1 batch element, all 16 channels, and all spatial positions.
- 256 threads.
- Each thread handles a subset of the 6727 maxpool windows.
For example, 6727 windows / 256 threads ≈ 26 windows per thread. Each window requires max over 8 values.
To coalesce, we could have the 256 threads cover a 2D slice. For instance, 16 threads in W × 16 "groups" for other dims.
Actually, let's think about this. If we map 256 threads as:
- threadIdx.x in [0, 31]: index in a flattened spatial range within a channel
- thread group: determines channel
For a given channel c, we have 7*31*31 = 6727 windows. 6727 / 32 = 210.2 windows per 32-thread warp, or we could have 32 warps per block and each warp handles 1 channel with 6727/32 windows each = 210 windows per thread.
Actually 6727 is prime? Let me check: 6727 = 7 * 31 * 31 = 7 * 961. Not divisible by 32.
Alternative: 16 channels, each handled by 16 threads (1 warp). 6727 / 16 = 420.4 windows per thread.
Or 8 threads per channel: 6727 / 8 = 840.9.
For memory access within a warp of 32 threads: if 32 threads are all processing different channels or different spatial positions, coalescing depends on the specific mapping.
What if we use a simpler approach with a grid where each block handles one (b, c, d) and threads in the block handle h*w?
- For each (b, c, d): 31*31 = 961 windows.
- Block size = 256 or 512 or 1024.
- 961 windows / 256 threads ≈ 3.8 windows per thread. Not efficient.
- Or 1 block per (b, c, d), 256 threads, each handles 4 windows (over-assign some threads).
Actually, this is getting complicated for the reduction kernel. Let's step back.
**Alternative: Custom conv + maxpool + reduction kernel**
A single kernel that:
1. Reads input
2. Computes conv + maxpool
3. Writes temporary maxpool output to shared memory
4. Reduces to a scalar per (b, c)
5. Stores in global memory [B, C]
6. Then a tiny kernel does bias + sum over C
Or even better: the first kernel writes [B, C] of maxpool averages. Then second kernel adds bias and sums.
For maxpool output [B, C, 7, 31, 31], computing averages per (b,c) = [B, C] is just a reduction over 6727 elements. We can do this in the same kernel by having each block do atomicAdd to a [B] array or write [B,C] and do a second kernel.
If we use the two-kernel approach:
- Kernel 1: Fused conv + maxpool (no division needed if we bake it into conv weights). Writes [B, C, Dp, Hp, Wp].
- Kernel 2: Reduce [B, C, Dp, Hp, Wp] to [B] with bias and sum.
Kernel 1 is the hard part. Kernel 2 is easy.
For Kernel 1 with direct conv:
Actually, I just realized: if we use PyTorch's conv and ONLY write a custom kernel for the reduction, would that be good enough? Let's think.
PyTorch conv: ~23.8 GFLOPs, very optimized.
Custom reduction: 436 MB read, very small compute.
The reference also uses PyTorch conv. The overhead after conv is:
- Division: 436 MB read + 436 MB write
- Maxpool: 436 MB read + 55 MB write
- Global avg pool: 55 MB read + tiny write
- Bias + sum: tiny
If our custom kernel just does: read conv output [128,16,14,62,62], compute maxpool + avg + bias + sum internally, write [128].
Then we save: division write (436MB), maxpool write (55MB), and perhaps some overhead.
Memory: read 436MB. At 3.35 TB/s: 0.13 ms.
Plus conv time: maybe 0.2-0.3 ms.
Total with our approach: 0.3-0.4 ms.
Reference: conv 0.2-0.3 ms + /2 0.13 ms + maxpool 0.13 ms + avgpool tiny = ~0.5-0.6 ms.
Speedup: 1.3-1.5x. Might be enough.
But can we do better?
Actually, maybe PyTorch already fuses some of these ops in its eager mode? Probably not conv+maxpool. But maybe the element-wise division and maxpool are not too bad.
Let me try the simple approach first: Use PyTorch conv, custom kernel for the rest.
Actually, can we do EVEN better by just not using PyTorch conv at all, and instead using pure PyTorch tensor operations that map to one kernel? No, we need a custom CUDA kernel.
Let me write the simplest custom kernel that reads conv output and produces final result.
Wait, what if we DON'T even use PyTorch conv, and instead write a single fused conv-to-scalar kernel? This would be the ultimate in performance, but quite complex.
Given the complexity, let me try the hybrid approach first. I'll write a kernel that takes the conv output and does maxpool + global_avg + bias + sum. If that's not fast enough, I'll try fusing the conv.
Let me look at the evaluation criteria again. We need to be "faster than the PyTorch reference." A 1.5x speedup should be sufficient. But to be safe, maybe 2x+ is better.
For the hybrid kernel, I can keep the conv in PyTorch. The custom kernel signature:
- Input: [B, C, D, H, W] where D=14, H=62, W=62
- Bias: [C, 1, 1, 1]
- Output: [B]
- Parameters: pool_size=(2,2,2), divisor=2.0, sum_dim=1
In the kernel, we already absorbed divisor into conv weights, so the input to kernel is conv_out / 2.
For each block:
- One block per batch element (128 blocks)
- Threads in block: 256 or 512
- Each thread iterates over some subset of (c, d, h, w) maxpool windows
- Accumulates: channel_sum[c] = sum of maxpool values for channel c
- Need to handle 16 channels * 6727 windows = 107,632 maxpool evaluations per batch
Wait, each maxpool evaluation is max over 8 floats.
For 128 blocks:
- Block b handles batch element b
- 256 threads, each thread handles 16 channels * (6727/256) ≈ 16 * 26.3 = 420 windows
- Each window: read 8 values, find max, add to local accumulator for that channel
Actually, instead of each thread handling multiple channels which requires 16 local accumulators, we could have each thread handle one channel and some windows. 256 threads, 16 channels: 16 threads per channel. Each thread handles 6727/16 ≈ 420 windows.
Total threads: 128 blocks * 256 threads = 32,768 threads. That's plenty.
Memory access within a warp: if 16 threads in a warp process the same channel and different windows, their memory accesses are into the same tensor but different spatial offsets. Are these coalesced?
For channel c, window (d, h, w), the base offset is ((b*C + c)*D + 2d)*H + 2h)*W + 2w.
If thread i handles window (d_i, h_i, w_i), and these are different for each thread, then offsets vary by:
Δoffset = 2*Δd * H * W + 2*Δh * W + 2*Δw
For coalesced access, Δoffset should be 1 (or small and contiguous) for threads in a warp.
If 16 threads handle windows with the same (d, h) and consecutive w:
- Thread i: w = w0 + i
- Δoffset between thread i and i+1 is 2.
- This is great for coalescing!
So for a group of 16 threads (one warp doing 2 warps? no, 16 threads is half a warp), all processing channel c and windows (d, h, w0..w0+15) -- but w max is 30, so we can cover 16 consecutive windows in W for fixed d and h.
Wait, w only goes 0..30. If 16 threads cover w=0..15 for fixed d,h, and the other 16 cover w=16..30. That works!
But then for each such window, we need to read 8 values: (2d, 2h, 2w) through (2d+1, 2h+1, 2w+1).
The first reads for window w=0: positions at W indices 0 and 1. For w=1: W indices 2 and 3.
If thread 0 reads at W=0 and thread 1 reads at W=2, their offsets differ by 2. Not perfectly coalesced but still decent (each thread reads 2 adjacent values, and consecutive threads read consecutive pairs).
Actually, let's formalize. In NCDHW contiguous:
offset(b, c, d, h, w) = b*C*D*H*W + c*D*H*W + d*H*W + h*W + w
= b*C*D*H*W + c*D*H*W + d*H*W + h*W + w
For thread i in a warp processing window (d, h, w0+i):
The first value it reads is offset(d, h, 2(w0+i)) = base + 2(w0+i)
The consecutive threads have offsets: base+2w0, base+2(w0+1), base+2(w0+2), ...
These differ by 2. Since each thread reads 32-bit floats, and offsets differ by 2 * 4 = 8 bytes, adjacent threads access memory with stride 8 bytes. On H100, memory transactions are at least 32 bytes. So a warp of 32 threads accessing stride-8 will require 2 transactions (since 32 threads * 8 bytes apart covers 256 bytes, but with 32-byte lines...). Actually for 32 threads with stride 8: thread 0 hits line 0, thread 4 hits line 1, etc. It might take 8 transactions of 32 bytes each. This is not great but not terrible.
For the second value in each window (W+1): threads access base+2w0+1, base+2(w0+1)+1, etc. Also stride 2.
For the other 6 values (different d or h): accesses are strided by 2*H*W or 2*W. Very far apart. These will have poor coalescing.
To optimize, maybe we should load a tile of the input into shared memory, and then do the maxpool from shared memory. Let's explore this.
If we process windows with w in [w0, w0+W_tile-1] for fixed (b,c,d,h):
- We need to read conv values at W = [2w0, 2(w0+W_tile-1)+1] = [2w0, 2w0+2*W_tile-1]
- And possibly d' in {2d, 2d+1}, h' in {2h, 2h+1}
- So total values: 2 * 2 * (2*W_tile) = 8*W_tile floats.
For W_tile = 32: 256 floats. Very small.
But threads also need to access these values. If we load them into shared memory, then the maxpool comparisons are from fast shared memory.
But the real question is whether the kernel is memory bound. Reading 436 MB should take ~0.13ms. If conv also takes ~0.3ms, total is ~0.43ms. Reference might be ~0.5-0.6ms. Is a 1.3-1.5x speedup enough?
Maybe. Let me also consider TENSOR CORES. Could we use them for the conv? Not easily in a custom kernel without a lot of work.
You know what, let me just go ahead and write a fused kernel for the whole thing (conv + maxpool + reduction) using a relatively simple but reasonably optimized approach. If that works, great. If not, I'll fall back.
Actually, wait. Let me think about whether I can write a custom conv that simply computes each conv output point and immediately checks if it should participate in maxpool.
For the fused conv+maxpool kernel:
- Output: [B, C, 7, 31, 31]
- We can have each thread compute one maxpool output.
For maxpool output at (b, c, d, h, w):
- We need conv at 8 positions: (2d,2h,2w), (2d,2h,2w+1), ..., (2d+1,2h+1,2w+1)
- For each of these, we compute the 3x3x3 conv directly from input.
- We could potentially reuse input loads across the 8 conv computations.
Let's say we fix b, c, d, h, w. We need input regions around (2d..2d+3, 2h..2h+3, 2w..2w+3). That's a 4x4x4 region = 64 input values per input channel. For 8 input channels: 512 floats.
512 floats for 8 conv outputs. Each conv output uses 27*8 = 216 multiplications.
8 conv outputs = 1728 multiplications.
Input size = 512 floats.
Compute intensity = 1728 FLOPs / (512 * 4 bytes read + 1 * 4 bytes written per maxpool pos) ≈ 1728 / 2048 ≈ 0.84 FLOP/byte.
GPU has 67 TFLOPS and 3.35 TB/s = 20 FLOPs/byte. So we're very memory bound!
This means even a perfectly fused conv+maxpool would be memory bound, spending most time reading input.
Reading input once for each maxpool output: 512 floats per output. Total input reads: 13.7M outputs * 512 floats = 7B floats = 28 GB. That's a LOT. That's way more than reading the full input tensor which is only 268 MB. We're reading input 100x more than needed because we don't reuse input across maxpool outputs!
To properly fuse conv+maxpool, we MUST reuse input across maxpool outputs via shared memory or cache. Otherwise it will be much slower.
So the simple fused kernel without tiling is out. We need shared memory.
For a tile of Td x Th x Tw maxpool outputs:
- Input depth needed: 2d to 2(d+Td-1)+1+2 = 2d + 2Td + 1. Actually with kernel=3, if conv outputs range from [start_d, end_d], input ranges from [start_d, end_d+2]. Conv outputs for maxpool at d in [d0, d0+Td-1] with positions {2d, 2d+1} range from 2d0 to 2(d0+Td-1)+1 = 2d0+2Td-1. Input needed: 2d0 to 2d0+2Td-1+2 = 2d0+2Td+1. So span = 2Td+2.
- Input height needed: 2Th+2
- Input width needed: 2Tw+2
- Total input floats: 8 channels * (2Td+2) * (2Th+2) * (2Tw+2)
For Td=2, Th=4, Tw=4: 8 * 6 * 10 * 10 = 4800 floats.
For Td=1, Th=8, Tw=8: 8 * 4 * 18 * 18 = 10,368 floats.
For Td=1, Th=16, Tw=16: 8 * 4 * 34 * 34 = 36,928 floats.
Each tile processes Td*Th*Tw maxpool outputs. Each maxpool output requires reading 8 values and computing their max, where each value is a conv result.
But if we store the conv results for the tile in shared memory or registers, we can do the maxpool cheaply.
The conv results for the tile: we need conv outputs at 2Td * 2Th * 2Tw positions = 8 * Td * Th * Tw positions.
For Td=1, Th=8, Tw=8: 128 conv outputs. At 16 channels, that's 2048 floats.
A block can compute these 128 conv outputs (for all channels) and store in shared memory, then do maxpool.
Wait, but if we compute conv outputs for ALL 16 channels in one block, and the block has say 256 threads, then each thread computes 128*16/256 = 8 conv outputs. Plus weights in shared memory. This is very feasible!
Let's design this carefully:
**Block configuration:**
- Block size: 256 threads
- Each block processes: 1 batch element, 16 output channels, spatial tile of Td x Th x Tw maxpool outputs
- Td=1, Th=4, Tw=4: 16 spatial * 16 channels = 256... wait, that's 256 "work units" per block. Not enough parallel work for 256 threads if each handles one unit, but okay.
- Td=1, Th=8, Tw=4: 32 spatial * 16 channels = 512. Each thread handles 2 units.
Actually, let's say each block processes 1 batch element, 4 output channels, and a tile of Td=1, Th=8, Tw=8 = 64 maxpool outputs. Total outputs = 4 * 64 = 256. Perfect for 256 threads (1 thread per (c, spatial)).
Or better: 1 batch, 8 output channels, Th=4, Tw=4 = 16 spatial. Total = 128 outputs. With 256 threads, each thread handles 0.5 outputs... no, each thread can handle one conv computation within a maxpool window.
Wait, a thread needs to compute the max of 8 conv outputs. If each thread handles one maxpool output, it's 1 max over 8 values per thread.
For 256 threads: 256 maxpool outputs = 8 channels * 32 spatial = 8 channels * (1d * 4h * 8w) for example.
Let me define: BlockDim = 256.
Grid covers (batch, channel_block, d_tile, h_tile, w_tile).
Each block:
- Process batch element b
- Process channels [c_start, c_start+C_block-1] where C_block = 4
- Process spatial tile [d0, d0+Td-1] x [h0, h0+Th-1] x [w0, w0+Tw-1] where Td=1, Th=4, Tw=4 = 16 spatial
Total maxpool outputs per block: 4 * 16 = 64.
Threads: 256.
So each maxpool output gets 4 threads to help compute it? Or each thread handles 64/256 = 0.25 maxpool outputs...
Actually, we can have 64 threads where each thread handles 1 maxpool output (1 channel, 1 spatial). The other 192 threads help load input into shared memory.
Better: 256 threads, 64 maxpool outputs. Each thread computes 1 conv output position (not maxpool). The 8 conv outputs for a maxpool window are computed by 8 threads, then they communicate via shared memory or warp shuffle to find max.
This is complex. Let me use a simpler strategy: each thread computes one maxpool output entirely (all 8 conv positions).
Block processes 64 maxpool outputs. So we need 64 active threads, but let's use 256 threads with each computing one maxpool output, processing 64 outputs at a time in 4 rounds... no.
Or block processes 256 maxpool outputs: 4 channels * 64 spatial (Td=1, Th=8, Tw=8). Then 256 threads, 1 per maxpool output. Good!
Input region: 1 batch, 8 in_channels, depth=[2d0, 2d0+3] (span 4), height=[2h0, 2h0+17] (span 18), width=[2w0, 2w0+17] (span 18).
Input size: 8 * 4 * 18 * 18 = 10,368 floats = 41 KB.
This fits in shared memory.
Conv intermediate storage: we don't need to store all conv outputs. Each thread computes its 8 conv outputs directly in registers and tracks the max. It just needs to read input from shared memory.
So the kernel:
1. Load input tile into shared memory (cooperatively by all threads)
2. Load weights for the 4 output channels into shared memory (or registers)
3. Each thread computes its 8 conv outputs for its assigned maxpool window, using input from shared memory
4. Track max, divide by 2 (or not, depending on strategy)
5. Write max to output[b, c, d, h, w]
Wait, all 256 threads need the same input tile but different weights. If only 4 channels out of 16, the weights for those 4 channels are 4*8*27=864 floats.
Loading input: 10,368 floats. With 256 threads, each loads ~40 floats. Good.
Loading weights: 864 floats. Each thread loads ~3 weights. Good.
Then each thread computes 8 * 216 = 1728 FMAs. At 2.37 GFLOPs total conv and 16 channels, if we process 4 channels per block: total conv FMAs for this input tile is 256 outputs * 8 * 216 = 442K FMAs? Wait no.
Actually, the total conv FMAs is fixed at 23.8 GFLOPs. With this tiling, each block does substantial work.
Total blocks:
- Batch: 128
- Channels: 16 / 4 = 4 groups
- Depth: 7 / 1 = 7
- Height: 31 / 8 ≈ 4 (ceiling)
- Width: 31 / 8 ≈ 4
Total: 128 * 4 * 7 * 4 * 4 = 57,344 blocks.
Each block: 256 threads * 1728 FMAs = 442K FMAs.
Total FMAs: 57,344 * 442K = 25.3 GFLOPs. Slightly above the theoretical 23.8G due to boundary cases or my math. Close enough.
Each block reads 41 KB input + 3.4 KB weights = 44.4 KB. Doesn't write intermediate conv output. Writes 4*64 = 256 floats = 1 KB of maxpool output.
Total memory traffic:
- Read input: 57,344 * 41 KB = 2.35 GB. But the actual input tensor is only 268 MB! We read each input element many times because input tiles overlap.
- For input at position (d,h,w), how many tiles include it?
- It appears in tiles where d0 ranges such that 2d0 <= d <= 2d0+3, so d0 can be floor((d-3)/2) to floor(d/2). For most interior points, 2 tiles in depth.
- Similarly for h: tiles where 2h0 <= h <= 2h0+17, so h0 from floor((h-17)/2) to floor(h/2). For h=32, that's about 9 tiles.
- Same for w.
- So total overlap: ~2 * 9 * 9 = 162x! That's terrible!
This is the problem with naive tiling for direct convolution: we read input many more times than once. We need much larger tiles to amortize the overlap, or we need to stream through the input.
To reduce overlap, we need larger spatial tiles. But with 3D conv, the shared memory grows as O(tile_size^3). If we make Th=16, Tw=16:
- Input height span: 2*16+2 = 34, width: 34
- For Td=1: input size = 8 * 4 * 34 * 34 = 36,928 floats = 147 KB.
- Plus weights 3.4 KB. Total ~150 KB. Still under 228 KB.
- Maxpool outputs: 4 * 1 * 16 * 16 = 1024. Threads: 256, each handles 4 maxpool outputs = 4 * 8 * 216 = 6912 FMAs. Nice.
Number of spatial tiles: 7 * ceil(31/16) * ceil(31/16) = 7 * 2 * 2 = 28.
Total blocks: 128 * 4 * 28 = 14,336 blocks.
Input overlap still exists but less. For interior h, number of tiles covering it: for h=32 with tile height 16 and step 16 (since maxpool output h tile of 16 means conv h span 32+2=34, step 32): tile h0 values are 0, 16. An input at h=16 is in both tiles (since tile 0 covers h=0..33, tile 1 covers h=16..49?). Wait no.
Actually if maxpool Th=16 (output h from 0 to 15), conv outputs are at h=0..31 (for the maxpool windows). Input needed is h=0..33 (span 34). Next tile starts at h0=16, covers maxpool 16..31, conv 32..63, input 32..65. Overlap in input: h=32,33. Just 2 rows! That's great!
Wait, let me recalculate. Maxpool output height indices: 0..30.
If tile 1 covers maxpool h = 0..15, then conv outputs at h = 0..31 (since each maxpool window covers 2h and 2h+1). With kernel=3, input needed is h = 0..33.
If tile 2 covers maxpool h = 16..30, conv outputs at h = 32..60. Input needed: 32..62.
Overlap: input h = 32, 33. Just 2 rows out of 34. About 6% overlap. That's excellent.
The overlap comes from the kernel size (3) causing a shift of 2 in conv output but needing input at conv_output+2.
So with Th=16, Tw=16, Td=1:
- Input per tile: 8 * 4 * 34 * 34 = 36,928 floats. But due to 6% overlap, effective reads are more like 39K.
- Total blocks: 128 * 4 * 7 * 2 * 2 = 14,336 blocks.
- Total input reads: 14,336 * ~39K floats = 557M floats = 2.23 GB. Still 8x more than the 268 MB input. But much better than before.
Hmm, 8x is still a lot. For Td=7 (all depth), input depth span: 2*7+2=16. Total input per tile: 8 * 16 * 34 * 34 = 147,712 floats = 591 KB!
Plus weights for 4 channels. Total ~595 KB. That's over the 228 KB shared memory limit!
So Td can't be that big. Max shared per block is 227 KB.
For Td=2: depth span = 6. Input: 8 * 6 * 34 * 34 = 55,296 floats = 221 KB. Just under the limit!
Blocks: 128 * 8 * 4 * 2 * 2 = 16,384 (since 16 channels / 2 per block = 8 groups).
Wait, for 2 channels per block: weights = 2*8*27 = 432 floats.
Input per block: 221 KB.
Total shared: ~222 KB. Under 227 KB!
With Td=2, Th=16, Tw=16:
- Spatial tiles: ceil(7/2)=4 in depth, ceil(31/16)=2 in H, ceil(31/16)=2 in W = 16 spatial tiles.
- Channel groups: 16 / 2 = 8.
- Total blocks: 128 * 8 * 16 = 16,384 blocks.
- Each block processes 2 * 2 * 16 * 16 = 1024 maxpool outputs.
- Each thread: 1024 outputs / 256 threads = 4 outputs per thread.
- Each output: 8 conv positions * 216 FMAs = 1728 FMAs. Per thread: 6912 FMAs.
- Block work: 256 * 6912 = 1.77M FMAs. Total: 16,384 * 1.77M = 29 GFLOPs. A bit high but okay.
This is getting really complex to code. Let me think if there's a simpler path.
**New idea: Use CUTLASS or implicit GEMM?**
No, not easily available in load_inline without complex setup.
**Another idea: Use pure reduction approach**
What if we reformulate the problem?
conv(x)[b,c,od,oh,ow] = sum_{ic,kd,kh,kw} W[c,ic,kd,kh,kw] * x[b,ic,od+kd,oh+kh,ow+kw]
After conv, we have maxpool over 2x2x2, then global average (sum / N), then sum over channels.
S[b] = sum_c avg_{d,h,w} max_{di,dh,dw} conv[b,c,2d+di,2h+dh,2w+dw]
What if we change the order? Since everything is linear EXCEPT maxpool, and maxpool is the only nonlinearity, we can't push it through.
But wait: what if we precompute, for each batch and spatial position, the sum over channels of conv output, and THEN maxpool and average? No, maxpool is per-channel: for each channel independently, we maxpool. Then average each channel. Then sum channels. So channel sum and maxpool don't commute.
**Yet another idea: Write a simple custom kernel and see if it's faster**
Even a naive kernel with decent occupancy might win if the memory savings are enough.
Let me write a kernel where:
1. A block handles one (b, c) or a few
2. Threads iterate over maxpool windows
3. Each thread computes max over 2x2x2 conv outputs
4. Keep running sum
5. At the end, output sum
For memory access, if we have 32 threads in a warp, and they process the same channel but consecutive W windows for the same (d,h), then when they read the 8 values per window:
- The reads at (d0, h0, w) for w varying across the warp are strided by 2 in W. As discussed, this is okay-ish.
- The reads at (d0, h0+1, w) are strided by 2W.
- The reads at (d0+1, h0, w) are strided by 2H*W.
To improve, what if threads process ADJACENT conv outputs, not maxpool windows? Like thread 0 handles conv (0,0,0), thread 1 handles (0,0,1), etc. Then after computing conv, they communicate to do maxpool.
This is complicated.
**Simplest working kernel: Custom reduction over PyTorch conv output**
Let me write a kernel that assumes PyTorch has already done conv. The kernel reads conv output and computes maxpool+avg+bias+sum.
Even with somewhat uncoalesced access, reading 436 MB takes ~0.13 ms theoretically. In practice maybe 0.2-0.3 ms. With conv at 0.2-0.3 ms, total is 0.5 ms. Reference maybe 0.7 ms. 1.4x speedup.
Then I can try to improve by launching enough blocks, using warp-level primitives, etc.
Actually, let me check: can I use `torch.mean` and other PyTorch ops in a clever way? No, same as reference.
Wait, what about using Tensor Cores through MMA? For conv with 8 in channels and 16 out channels, it's tiny. Tensor cores need larger tiles.
**Let me try a different angle: im2col + GEMM?**
PyTorch does this under the hood via cuDNN. But if I do it manually, I get no benefit.
**What about just writing a straight CUDA kernel for the entire forward pass with explicit loops?**
Since the dimensions are small (in_ch=8, out_ch=16, k=3), a direct 3D convolution with each thread reading input directly from global memory and computing dot products with weights in registers might not be terrible, because weights can be cached well.
Actually, let me think about register usage. For each output channel, we need 8*27=216 weights. For 16 output channels, 3456 weights. That's too many for registers. But if a thread only handles 1 output channel, it only needs 216 weights. That's very reasonable.
So: 1 thread = 1 output channel, 1 maxpool window, 1 batch.
- Thread loads 216 weights into registers (or shared memory). Actually 216 floats is a lot for registers (but 216 registers is under 255 limit).
- For its maxpool window, it computes 8 conv outputs, each requiring 216 FMAs.
- Total: 1728 FMAs.
- It reads input: 8 * 4 * 4 * 4 = 512 floats (for the 4x4x4 region around the window).
But all threads in a block need different input regions (for different windows). So shared memory doesn't help unless threads are grouped by adjacent windows.
What if a block has 32 threads, each handling one channel and one window, and the windows form a 4x4x2... no.
Let's think about what a warp of 32 threads could share. If they all handle the same batch, same channel, and consecutive windows in W for fixed d and h:
- Thread i: window (d, h, w0+i)
- Input regions overlap heavily! The region for window w is [2w, 2w+3] in W. For w and w+1, regions are [2w, 2w+3] and [2w+2, 2w+5], overlapping by 2.
- Similarly for different h: region for h is [2h, 2h+3]. h and h+1 overlap by 2.
- For different d: same.
So a 2x2x2 block of threads (8 threads) handling adjacent windows shares a lot of input. A 4x4x4 block (64 threads) shares even more.
If we use shared memory for a block of 64 threads handling windows (d..d+3, h..h+3, w..w+3):
- Conv outputs span: [2d, 2d+7] x [2h, 2h+7] x [2w, 2w+7]
- Input span: [2d, 2d+9] x [2h, 2h+9] x [2w, 2w+9] = 10x10x10 = 1000 per channel * 8 = 8000 floats = 32 KB.
- 64 threads, each handles one window. Total conv outputs: 64 * 8 = 512. Each thread computes 8 conv outputs.
But the block also needs to handle multiple channels to get enough parallelism. If block handles 4 channels, 64 threads per channel: 256 threads total.
Wait, 64 threads per channel is 4 warps. We can have 256 threads = 64 threads * 4 channels.
For shared memory, input is shared across all channels! So input is still 32 KB. Weights: 4 channels * 216 = 864 floats = 3.4 KB. Total ~36 KB. Very comfortable.
Each block:
- 256 threads
- 4 channels, 64 windows per channel
- 4 * 64 = 256 maxpool outputs.
- Each thread: 8 conv evaluations = 1728 FMAs.
- Block compute: 256 * 1728 = 442K FMAs.
- Block writes: 256 floats.
Wait, 64 windows per channel. How are they arranged? Say 4x4x4 = 64. Then next block shifts by 4 in each dimension.
Tile size for windows: 4d x 4h x 4w.
Input span: (2*4+2) x (2*4+2) x (2*4+2) = 10x10x10. Good.
But maxpool output is 7x31x31. For depth 7: ceil(7/4) = 2. For height 31: ceil(31/4) = 8. For width 31: ceil(31/4) = 8.
Total spatial tiles: 2 * 8 * 8 = 128.
Channel groups: 16 / 4 = 4.
Batch: 128.
Total blocks: 128 * 4 * 128 = 65,536 blocks. That's plenty!
Wait, 65,536 blocks * 256 threads = 16.7M threads. H100 has 132 SMs, each can run up to 2048 threads = 270K threads total concurrently. But blocks are queued. 65K blocks is fine for latency hiding.
Shared memory: 36 KB per block. With 132 SMs and 228 KB each, each SM can run at most floor(228/36) = 6 blocks concurrently. But registers limit: 64K per SM, 255 per thread max. If each thread uses ~80 registers, 256 threads use 20K registers. So 3 blocks per SM. But 228/36 = 6, so registers are the limit.
Actually, 3 blocks * 256 threads = 768 threads per SM. Occupancy: 768 / 2048 = 37.5%. Not great, but not terrible. With enough blocks, we can hide latency.
Can we increase block size to improve occupancy? Say 512 threads = 2 blocks per SM (register limited if 80 regs/thread = 40K, 2 blocks = 80K > 64K... actually 255 max, but if we use fewer we might fit more).
Actually register limit per SM is 64K. If each thread uses 64 registers, then 256 threads use 16K. 4 blocks use 64K. So 4 blocks per SM! Occupancy = 4 * 256 / 2048 = 50%. Better.
If we use 512 threads with 64 registers: 32K per block. 2 blocks = 64K. Occupancy = 2*512/2048 = 50%. Same.
So 256 threads with 64 registers each: occupancy 50%. With 65K blocks, should be fine.
Now, the key challenge is efficiently loading data into shared memory and then computing conv.
For each block:
- b: batch index
- cg: channel group (0..3), handling channels [4*cg, 4*cg+3]
- tile_d: depth tile index (0, 1)
- tile_h: height tile index (0..7)
- tile_w: width tile index (0..7)
Block processes windows:
- d in [4*tile_d, min(4*tile_d+4, 7))
- h in [4*tile_h, min(4*tile_h+4, 31))
- w in [4*tile_w, min(4*tile_w+4, 31))
Number of actual windows: up to 4*4*4 = 64 per channel. But near boundaries, fewer. For depth: first tile 4, second tile 3 (since 7-4=3). For height/width: most tiles 4, last tile might be 3 or fewer? 31 = 4*7 + 3, so last tile has 3. Wait, 4*7=28, 31-28=3. Yes.
So boundary handling is needed.
Threads in block: 256 = 4 channels * 64 windows.
We need to map 256 threads to the actual windows.
Instead of mapping rigidly, we can have 256 threads all participate in:
1. Loading input into shared memory (cooperatively)
2. Loading weights into shared memory (cooperatively)
3. Then each thread processes its assigned windows.
For input loading, we need to load input for the region:
- d_in: [2*d_min, 2*d_max+2+1] actually [2*d0, 2*(d0+TD-1)+1+2] = [2d0, 2d0+2TD+1]
For TD up to 4: span = 2*4+1 = 9. Wait, 2d0 to 2d0+2*4-1+2 = 2d0+9. Span 10.
Similarly H span 10, W span 10.
Total input positions: 10 * 10 * 10 = 1000 per in_channel. 8 in_channels = 8000.
Each thread loads 8000/256 = 31.25 floats. That's fine.
For weights: 4 out_channels * 8 in_channels * 27 = 864 floats. 864/256 = 3.4 floats per thread.
Then computation:
Each of 256 threads handles some windows. If there are exactly 64 windows per channel * 4 channels = 256 windows, then 1 thread per window.
For depth tile 1 (d=4..6): 3 windows per channel. 12 windows total per channel... wait no.
Per channel: d in [4,6], h in [4*tile_h, 4*tile_h+3], w in [4*tile_w, 4*tile_w+3].
For full tiles: 3 * 4 * 4 = 48 windows per channel. Total 48 * 4 = 192 windows.
With 256 threads, some threads are idle or we remap.
Hmm, this boundary stuff is annoying. But manageable.
Let me step back and think whether this is worth implementing for the problem. Given the complexity, maybe the hybrid approach (PyTorch conv + custom reduction) is much easier and might already win.
Let me try the hybrid first. I'll code it up quickly.
**Hybrid approach kernel:**
Input: conv_out [128, 16, 14, 62, 62] (already divided by 2)
Output: out [128]
For each batch b, output_scalar[b] = sum_c ( mean_{d,h,w}( max_{di,dh,dw}( conv_out[b, c, 2d+di, 2h+dh, 2w+dw] ) ) + bias[c] )
Simplify: For each (b, c), compute M[b,c] = sum_{d=0..6} sum_{h=0..30} sum_{w=0..30} max_{di,dh,dw} conv_out[..., 2d+di, ...]
Then out[b] = sum_c ( M[b,c] / 6727 + bias[c] )
We can compute M as a 2D tensor [B, C] first, then reduce.
Kernel 1: Fused maxpool + sum over spatial for each (b,c)
- Grid: B * C = 2048 blocks
- Block: 256 threads
- Each block sums over 6727 elements (max over 8, then add to sum).
- Block output: M[b,c]
This is a reduction kernel. 2048 blocks. Each block does 6727 * 8 ≈ 53K reads and comparisons.
Actually, this is quite efficient because:
1. There are 2048 blocks, which is > number of SMs.
2. Each block does substantial work (53K operations).
3. Memory access is to 436 MB but each block only reads its slice: each (b,c) reads 14*62*62 = 53,816 floats = 215 KB.
Wait not all of it is read, but most of it.
Actually let me think about this: each block reads 7*31*31*8 = 53,816 floats = 215 KB.
2048 blocks * 215 KB = 440 MB. That's basically reading the full conv output once. Perfect!
Memory bandwidth: 440 MB / 3.35 TB/s = 0.13 ms. Plus compare/add overhead: maybe 0.15-0.2 ms total for this kernel.
Plus conv: 0.2-0.3 ms.
Total: 0.35-0.5 ms.
Reference:
- conv: 0.2-0.3 ms
- div: 436MB / 3.35TB/s ≈ 0.13 ms
- maxpool: 436MB read + 55MB write ≈ 0.15 ms
- global_avg: 55MB read ≈ 0.016 ms
- bias+sum: negligible
Total ref: ~0.6-0.65 ms.
Speedup: ~1.5x.
This should be enough! And much simpler to implement.
Wait, but I should also check if PyTorch might already fuse div+maxpool or other ops. In eager mode, probably not. But even if it does some fusion, our approach of reading conv output just once should still win.
Let me also think about how to launch this kernel efficiently.
For kernel 1 (maxpool+spatial_reduce):
```cuda
__global__ void fused_maxpool_reduce(const float* conv_out, float* M, int B, int C, int D, int H, int W, int pD, int pH, int pW) {
// blockIdx.x corresponds to (b, c)
int bc = blockIdx.x;
int b = bc / C;
int c = bc % C;
float sum = 0.0f;
// Each thread handles a subset of maxpool windows
int total_windows = pD * pH * pW; // 7*31*31
for (int idx = threadIdx.x; idx < total_windows; idx += blockDim.x) {
int w = idx % pW;
int h = (idx / pW) % pH;
int d = idx / (pW * pH);
float max_val = -1e30f;
#pragma unroll
for (int di = 0; di < 2; di++) {
for (int dh = 0; dh < 2; dh++) {
for (int dw = 0; dw < 2; dw++) {
int od = 2*d + di;
int oh = 2*h + dh;
int ow = 2*w + dw;
int offset = ((b * C + c) * D + od) * H + oh) * W + ow;
float val = conv_out[offset];
max_val = fmaxf(max_val, val);
}
}
}
sum += max_val;
}
// Warp reduce
__shared__ float shared[32];
// ...
// Block reduce to shared[0]
if (threadIdx.x == 0) {
M[b * C + c] = sum;
}
}
```
Wait, memory access pattern. Threads in a warp with consecutive `threadIdx.x` will process consecutive `idx` values. `idx` maps to consecutive w values (since w = idx % pW). So 32 threads will process w=0,1,2,...,31 for some fixed d,h... but wait, pW=31, so after 31 it wraps.
For idx=0: d=0,h=0,w=0
idx=1: d=0,h=0,w=1
...
idx=30: d=0,h=0,w=30
idx=31: d=0,h=1,w=0
So threads in a warp typically process adjacent w values for the same (d,h), OR adjacent h values when w wraps.
For adjacent w values:
- Thread i reads conv_out at (b,c,2d,2h,2(w+i)) and (b,c,2d,2h,2(w+i)+1), etc.
- Offsets differ by 2 in W. Stride-2 access.
- As I discussed, this means threads in a warp access memory with stride 8 bytes (2 * sizeof(float)).
- On H100, a warp of 32 threads accessing stride-8 results in 8 memory transactions of 32 bytes each (for the first set of reads).
- For 8 sets of reads (the 2x2x2 maxpool), each warp does 8 transactions.
- Each transaction is 32 bytes = 8 floats. Each thread reads 1 float per transaction.
- 32 threads * 8 reads = 256 reads. With 8 transactions of 8 floats = 64 floats read per transaction set.
- Wait, no. Each warp transaction reads 32 bytes = 8 contiguous floats.
- If offsets are stride-8-bytes, then thread 0 hits address A, thread 1 hits A+8, thread 2 hits A+16, thread 3 hits A+24. Threads 0-3 are all in the same 32-byte line! Actually no: thread 0 at A, thread 1 at A+8 bytes = A+2 floats. If A is 32-byte aligned, then A to A+24 is in the same 32-byte line (8 floats). Thread 0-3 are in line 0. Thread 4-7 in line 1 (A+32 to A+56). Thread 8-11 in line 2. Thread 12-15 in line 3. Thread 16-19 in line 4. Thread 20-23 in line 5. Thread 24-27 in line 6. Thread 28-31 in line 7.
- So 32 threads cause 8 transactions. That's actually quite good! 8 transactions for 32 reads = 50% efficiency (since each transaction reads 8 floats but only 4 are used by the warp? No wait, thread 0 reads one float, thread 1 reads the next float in the same line... actually if thread i reads address A + 8*i bytes, and each cache line is 32 bytes:
- Line at A: contains addresses A, A+4, A+8, ..., A+28 (8 floats). Threads 0,1,2,3 read A, A+8, A+16, A+24. All in the same 32-byte line! So 1 transaction serves 4 threads.
- Line at A+32: threads 4,5,6,7. Another transaction.
- Total: 8 transactions for 32 reads. Each transaction is 32 bytes. Total bandwidth: 256 bytes for 32 floats (128 bytes) of actual data. 50% efficiency.
This is not bad! And the same pattern applies to all 8 reads in the maxpool (the 2x2x2 block), except for reads with different d or h which have larger strides.
For reads at (2d, 2h+1, 2w): offset increases by W = 62. Stride = 62 * 4 = 248 bytes.
For a warp reading consecutive w: addresses are A, A+8, A+16, ... for the base, plus 248 bytes.
248 mod 32 = 24 bytes. The addresses are not aligned the same way. Still, 32 threads across 8 cache lines, but each line might have 1-4 useful reads. Similar ~50% efficiency or worse.
Actually, the memory controller on H100 is very sophisticated. For contiguous-ish access, it should be okay.
A bigger issue: thread divergence? No, all threads execute the same maxpool loops.
I think this kernel will perform adequately. Let's code it.
Then kernel 2:
```cuda
__global__ void reduce_channels(const float* M, const float* bias, float* out, int B, int C, float scale, float bias_sum) {
int b = blockIdx.x * blockDim.x + threadIdx.x;
if (b >= B) return;
float sum = 0.0f;
for (int c = 0; c < C; c++) {
sum += M[b * C + c] * scale + bias[c];
}
out[b] = sum;
}
```
Wait, M[b,c] is sum of maxpool values. We need mean = M / 6727. Then + bias. Then sum over c.
sum_c (M[b,c] / 6727 + bias[c]) = (1/6727) * sum_c M[b,c] + sum_c bias[c].
So we can compute sum_M = sum_c M[b,c] and bias_sum = sum_c bias[c].
output[b] = sum_M / 6727 + bias_sum.
This saves a division per channel.
So kernel 2 just reduces M[b, :] to out[b].
Alternatively, we can compute sum_M in kernel 1 using atomic adds or a second reduction.
Actually, we can have kernel 1 write M[b,c]. Then kernel 2 is a simple row reduction.
Or we can try to do everything in kernel 1 by having each block (for each b,c) atomicAdd to out[b]. But then 2048 blocks all contending on 128 output locations. Not great.
Better: have a separate kernel for row reduction.
Even better: use shared memory in kernel 1 to reduce channels. But kernel 1 has one block per (b,c), so threads in a block only have one c. Can't reduce across c in same kernel easily without cross-block communication.
Two kernels is fine.
Let me write this up. But wait - we need to bake the divisor into the conv.
Approach:
- In `__init__`:
- Create a Conv3d with same params
- Copy weights from reference conv, divide by divisor
- Copy bias from reference conv, divide by divisor
- Store the separate bias parameter too
- In forward:
- Apply conv (which now includes /divisor implicitly)
- Call custom kernel for maxpool+reduce
- The custom kernel doesn't need to divide by divisor since conv output is already scaled.
Wait, the conv bias is also divided by divisor. Reference conv computes: W*x + b_conv. Then /divisor. So (W*x + b_conv)/2 = (W/2)*x + b_conv/2. If we use conv with W/2 and b_conv/2, the output is already scaled. Perfect.
Then after maxpool+avg, we add self.bias (the separate parameter).
Let me write the code.
Actually, I realize I should also consider whether to absorb the divisor into the custom kernel instead. That avoids modifying the conv weights. But modifying weights in init is fine since we only need correctness during inference.
Let me write the full ModelNew class.
Wait, I need to be careful about the conv padding. nn.Conv3d default padding=0, stride=1, dilation=1. The reference uses defaults. So our conv should use same.
Let me also consider whether PyTorch's reference conv is on CUDA. Yes, inputs will be on CUDA.
One more optimization: instead of two kernels, can I do one kernel where each block handles all channels for one batch element?
Yes! A single kernel:
- Grid: B = 128 blocks
- Block: say 256 threads
- Each block processes all 16 channels and all 6727 windows for batch b.
Threads can be organized as:
- 16 groups of 16 threads, each group handles one channel.
- Within a group, each thread handles ~6727/16 = 420 windows.
- Each thread computes sum of max values for its windows, storing in a local accumulator.
- Then within the channel group, reduce to get sum for that channel.
- Then across channel groups, accumulate sum over channels.
- Apply scale and bias.
- Write output[b].
This eliminates intermediate M tensor and second kernel launch!
Let me detail this:
```cuda
__global__ void full_reduce(const float* conv_out, const float* bias, float* out, int B, int C, int D, int H, int W, int pD, int pH, int pW, float scale, float bias_sum) {
int b = blockIdx.x;
// 16 channels, 16 threads per channel = 256 threads
int c = threadIdx.x / 16; // 0..15
int tid_in_c = threadIdx.x % 16; // 0..15
int total_windows = pD * pH * pW;
float local_sum = 0.0f;
for (int idx = tid_in_c; idx < total_windows; idx += 16) {
// compute max over 2x2x2
int w = idx % pW;
int h = (idx / pW) % pH;
int d = idx / (pW * pH);
float max_val = -1e30f;
#pragma unroll
for (int di = 0; di < 2; di++) {
int od = 2*d + di;
int base_d = ((b * C + c) * D + od) * H;
for (int dh = 0; dh < 2; dh++) {
int oh = 2*h + dh;
int base_h = (base_d + oh) * W;
for (int dw = 0; dw < 2; dw++) {
int ow = 2*w + dw;
float val = conv_out[base_h + ow];
max_val = fmaxf(max_val, val);
}
}
}
local_sum += max_val;
}
// Reduce within channel group (16 threads)
__shared__ float channel_sums[16];
// warp shuffle within group
#pragma unroll
for (int offset = 8; offset > 0; offset /= 2) {
local_sum += __shfl_down_sync(0xFFFFFFFF, local_sum, offset);
}
if (tid_in_c == 0) {
channel_sums[c] = local_sum;
}
__syncthreads();
// One thread (or all in first warp) sums over channels
if (threadIdx.x < 16) {
float total = channel_sums[threadIdx.x] * scale + bias[threadIdx.x];
// Now need to sum across 16 channels
// We can use warp shuffle again
#pragma unroll
for (int offset = 8; offset > 0; offset /= 2) {
total += __shfl_down_sync(0xFFFF, total, offset);
}
if (threadIdx.x == 0) {
out[b] = total + bias_sum; // wait, bias is already added per channel
}
}
}
```
Wait, bias[c] needs to be added per channel AFTER scaling, then sum. So:
sum_c (local_sum_c * scale + bias[c])
= scale * sum_c local_sum_c + sum_c bias[c]
So we can either:
1. Add bias[c] to each channel sum, then reduce OR
2. Reduce channel sums first, multiply by scale, then add bias_sum
Option 2 is better:
- Reduce local_sum across channel group to get channel_sum[c]
- Sum channel_sum[c] across c to total_sum
- out[b] = total_sum * scale + bias_sum
No need to read bias array in the kernel at all! Just compute bias_sum on CPU and pass as constant.
This is great! Single kernel, no bias array needed.
Actually wait, I can compute `scale = 1.0f / (divisor * pD * pH * pW)`. Since we pre-divided conv weights by divisor, we just need `scale = 1.0f / (pD * pH * pW) = 1.0f / 6727.0f`.
And bias_sum is just `bias.sum().item()` computed in Python and passed to kernel. No need to pass the full bias tensor!
So the CUDA kernel signature is just:
`fused_reduce(const float* conv_out, float* out, int B, int C, int D, int H, int W, int pD, int pH, int pW, float scale, float bias_sum)`
This is beautiful and simple!
Let me refine the kernel:
```cuda
__global__ void fused_reduce(const float* __restrict__ conv_out, float* __restrict__ out,
int B, int C, int D, int H, int W,
int pD, int pH, int pW, float scale, float bias_sum) {
int b = blockIdx.x;
int c = threadIdx.x >> 4; // threadIdx.x / 16, range 0..15
int tid = threadIdx.x & 15; // threadIdx.x % 16, range 0..15
int total_windows = pD * pH * pW;
float local_sum = 0.0f;
// Base offset for channel c
int base_bc = ((b * C + c) * D) * H * W;
for (int idx = tid; idx < total_windows; idx += 16) {
int w = idx % pW;
int tmp = idx / pW;
int h = tmp % pH;
int d = tmp / pH;
float max_val = -FLT_MAX;
int od0 = 2 * d;
int oh0 = 2 * h;
int ow0 = 2 * w;
#pragma unroll
for (int di = 0; di < 2; di++) {
int od = od0 + di;
int base_d = base_bc + od * H * W;
#pragma unroll
for (int dh = 0; dh < 2; dh++) {
int oh = oh0 + dh;
int base_h = base_d + oh * W;
#pragma unroll
for (int dw = 0; dw < 2; dw++) {
int ow = ow0 + dw;
float val = conv_out[base_h + ow];
max_val = fmaxf(max_val, val);
}
}
}
local_sum += max_val;
}
// Reduce within channel using warp shuffle
#pragma unroll
for (int offset = 8; offset > 0; offset >>= 1) {
local_sum += __shfl_down_sync(0xFFFFFFFF, local_sum, offset);
}
__shared__ float channel_sums[16];
if (tid == 0) {
channel_sums[c] = local_sum;
}
__syncthreads();
// Sum over channels
if (threadIdx.x < 16) {
float val = channel_sums[threadIdx.x];
// Warp shuffle across first 16 threads
#pragma unroll
for (int offset = 8; offset > 0; offset >>= 1) {
val += __shfl_down_sync(0xFFFF, val, offset);
}
if (threadIdx.x == 0) {
out[b] = val * scale + bias_sum;
}
}
}
```
Wait, there's an issue. The warp shuffle `__shfl_down_sync(0xFFFFFFFF, local_sum, offset)` works within a warp of 32 threads. But our channel groups are 16 threads (threads 0..15 for c=0, 16..31 for c=1, etc.).
Within a warp of 32, we have threads for c=0 and c=1. If we do shuffle with mask 0xFFFFFFFF across the whole warp, threads from c=0 will get values from c=1! That's wrong.
We need to shuffle within each 16-thread group. But a warp is 32 threads. Two groups per warp.
Options:
1. Use 32 threads per channel. Then c = threadIdx.x / 32, but we only have 256/32 = 8 channels. We'd need 2 blocks per batch or more threads.
2. Use __shfl_down_sync with a mask that is specific to the lane group. But lanes 0..15 and 16..31 are in the same warp. We can't easily separate them with shfl_down, except by ensuring inactive lanes contribute 0 and using correct mask.
Actually, `__shfl_down_sync` with lane mask and using the `c` lane group... wait. For lane i (0..15, group c=0) and lane i+16 (group c=1):
`__shfl_down_sync(0xFFFFFFFF, local_sum, 8)` for lane 0 reads from lane 8 (both c=0). Good.
For lane 8 reads from lane 16 (c=1!). Bad.
So we need a different reduction strategy.
Option A: Use shared memory for reduction within channel group.
Each thread writes local_sum to shared[threadIdx.x]. Then:
```cuda
if (tid < 8) shared[threadIdx.x] += shared[threadIdx.x + 8];
__syncthreads();
if (tid < 4) shared[threadIdx.x] += shared[threadIdx.x + 4];
...
```
Actually simpler: we only need 16 values per channel (one per tid). We can store all 256 local_sums in shared memory and then reduce.
```cuda
__shared__ float shared[256];
shared[threadIdx.x] = local_sum;
__syncthreads();
// Reduce within channel: c*16 + tid
// Need sum over tid=0..15 for each c
if (tid < 8) shared[c*16 + tid] += shared[c*16 + tid + 8];
__syncthreads();
if (tid < 4) shared[c*16 + tid] += shared[c*16 + tid + 4];
__syncthreads();
if (tid < 2) shared[c*16 + tid] += shared[c*16 + tid + 2];
__syncthreads();
if (tid < 1) shared[c*16 + tid] += shared[c*16 + tid + 1];
__syncthreads();
```
This is simple and correct, though it has 4 syncs.
Alternatively, since lanes 0-15 and 16-31 are in the same warp and can communicate without sync via warp shuffle if done carefully.
For group c within warp w:
- Lanes: w*32 + c*16 + tid, where tid=0..15.
Actually for c=0,1 in warp 0: lanes 0..15 (c=0), lanes 16..31 (c=1).
For c=2,3 in warp 1: lanes 64..79, 80..95.
To reduce within each 16-lane group using shuffle, we can treat the 16-lane group as a virtual warp. But CUDA shuffle operates on 32-lane warps. However, we can use the `__shfl_down_sync` with a mask that includes all 32 lanes in the warp, but we just need to make sure we only read from the correct group.
One trick: shift the lane ID conceptually. If we want lanes 0..15 to only communicate with each other, `__shfl_down_sync` with delta=8 for lane 0 reads lane 8 (same group). For lane 8, reads lane 16 (different group!).
So the issue is that a 16-lane group is split across the warp boundary at lane 16.
Can we reorganize so each 32-thread warp handles one channel? Yes!
- Block: 32 threads per channel * 8 channels = 256 threads.
- But we only have 16 output channels. So 8 channels in first block, 8 in second? No, per batch.
- If we have 256 threads and each channel needs 32 threads: we can handle 8 channels per block.
- But we need all 16 channels! So either 2 blocks per batch (128*2=256 blocks total), or 512 threads per block.
If 2 blocks per batch, each handling 8 channels:
- Block 2*b + 0: channels 0..7
- Block 2*b + 1: channels 8..15
Each block writes intermediate to a global array of size [B, 2], then a tiny second kernel sums them.
But we can also do it with one block of 512 threads: 32 threads * 16 channels = 512 threads.
- BlockDim = 512.
- Grid = B = 128 blocks.
- 128 blocks, 512 threads each = 65K threads. Fine.
With 512 threads, occupancy: 512 threads * 64 registers = 32K registers. 2 blocks per SM = 1024 threads / 2048 max = 50% occupancy. Or at 80 regs, 40K per block, 1 block per SM. Still okay.
But can we keep 256 threads and just use shared memory reduction? Yes, and it might be simpler.
Let me try a variant with 256 threads, 16 groups of 16, using shared memory. Then for the cross-channel sum, also use shared memory.
Here's the plan:
- 256 threads per block, 1 block per batch
- c = threadIdx.x / 16, tid = threadIdx.x % 16
- Each thread computes local_sum for its windows in channel c
- `__shared__ float smem[16][16];` or `smem[256]`
- `smem[threadIdx.x] = local_sum;`
- `__syncthreads();`
- Reduce within channel:
```
if (tid < 8) smem[c*16 + tid] += smem[c*16 + tid + 8];
__syncthreads();
if (tid < 4) smem[c*16 + tid] += smem[c*16 + tid + 4];
__syncthreads();
if (tid < 2) smem[c*16 + tid] += smem[c*16 + tid + 2];
__syncthreads();
if (tid < 1) smem[c*16 + tid] += smem[c*16 + tid + 1];
__syncthreads();
```
- Now `smem[c*16]` has the channel sum.
- `__syncthreads();`
- Now if threadIdx.x < 16, read `smem[threadIdx.x*16]` into `val`.
- Then another reduction:
```
if (threadIdx.x < 8) smem[threadIdx.x] = val + smem[threadIdx.x + 8];
__syncthreads();
...
```
- Or just have threadIdx.x < 16 do a shuffle or loop.
Actually, we can do the cross-channel reduction more simply:
```cuda
if (threadIdx.x < 16) {
float val = smem[threadIdx.x * 16];
smem[threadIdx.x] = val;
}
__syncthreads();
if (threadIdx.x < 8) smem[threadIdx.x] += smem[threadIdx.x + 8];
__syncthreads();
if (threadIdx.x < 4) smem[threadIdx.x] += smem[threadIdx.x + 4];
__syncthreads();
if (threadIdx.x < 2) smem[threadIdx.x] += smem[threadIdx.x + 2];
__syncthreads();
if (threadIdx.x < 1) {
out[b] = smem[0] * scale + bias_sum;
}
```
This should work. Lots of __syncthreads but with 128 blocks and plenty of compute per block, should be fine.
Alternatively, we can do:
```cuda
if (tid == 0) smem[c] = smem[c*16]; // after first reduction
__syncthreads();
if (c == 0) { // all 16 threads with c=0
float val = smem[tid];
#pragma unroll
for (int offset = 8; offset > 0; offset >>= 1) {
val += __shfl_down_sync(0xFFFF, val, offset);
}
if (tid == 0) out[b] = val * scale + bias_sum;
}
```
This uses warp shuffle for the channel sum, and only 16 threads (c=0) participate. But `smem[tid]` for tid=0..15 gives the 16 channel sums. Since all 16 threads are in the same warp (lanes 0..15), `__shfl_down_sync` works perfectly!
Wait, threads with c=0 have threadIdx.x = 0..15. They ARE lanes 0..15 of warp 0. Perfect! `__shfl_down_sync(0xFFFF, val, offset)` works for them.
So the full kernel:
```cuda
__global__ void fused_reduce(const float* __restrict__ conv_out, float* __restrict__ out,
int B, int C, int D, int H, int W,
int pD, int pH, int pW, float scale, float bias_sum) {
int b = blockIdx.x;
int c = threadIdx.x >> 4; // 0..15
int tid = threadIdx.x & 15; // 0..15
int total_windows = pD * pH * pW;
float local_sum = 0.0f;
int base_bc = ((b * C + c) * D) * H * W;
for (int idx = tid; idx < total_windows; idx += 16) {
int w = idx % pW;
int tmp = idx / pW;
int h = tmp % pH;
int d = tmp / pH;
float max_val = -1e30f; // or -FLT_MAX
int od0 = 2 * d;
int oh0 = 2 * h;
int ow0 = 2 * w;
#pragma unroll
for (int di = 0; di < 2; di++) {
int base_d = base_bc + (od0 + di) * H * W;
#pragma unroll
for (int dh = 0; dh < 2; dh++) {
int base_h = base_d + (oh0 + dh) * W;
#pragma unroll
for (int dw = 0; dw < 2; dw++) {
float val = conv_out[base_h + ow0 + dw];
max_val = fmaxf(max_val, val);
}
}
}
local_sum += max_val;
}
__shared__ float smem[16];
// Reduce within channel using shared memory
__shared__ float temp[256];
temp[threadIdx.x] = local_sum;
__syncthreads();
if (tid < 8) temp[c*16 + tid] += temp[c*16 + tid + 8];
__syncthreads();
if (tid < 4) temp[c*16 + tid] += temp[c*16 + tid + 4];
__syncthreads();
if (tid < 2) temp[c*16 + tid] += temp[c*16 + tid + 2];
__syncthreads();
if (tid < 1) smem[c] = temp[c*16] + temp[c*16 + 1];
__syncthreads();
// Now threads c=0, tid=0..15 have channel sums in smem[tid]
if (c == 0) {
float val = smem[tid];
#pragma unroll
for (int offset = 8; offset > 0; offset >>= 1) {
val += __shfl_down_sync(0xFFFF, val, offset);
}
if (tid == 0) {
out[b] = val * scale + bias_sum;
}
}
}
```
Wait, there's a cleaner way. Since threads 0..15 are in the same warp, and we want to reduce 16 values (one per thread), we can have each thread write its local_sum to shared, then thread 0..15 read the 16 channel sums into registers and shuffle.
But actually, we can't easily have lane i read channel sum for channel i because the channel sums are at smem[0..15] and we need to broadcast them.
What I have above is: after reduction, smem[c] has channel sum. Then for c==0 (threads 0..15), val = smem[tid] gets channel tid's sum. Then shuffle reduces across tid. This works perfectly!
Let me verify:
- Thread 0 (c=0, tid=0): val = smem[0] = channel 0 sum
- Thread 1 (c=0, tid=1): val = smem[1] = channel 1 sum
- ...
- Thread 15 (c=0, tid=15): val = smem[15] = channel 15 sum
- Then __shfl_down_sync reduces these 16 values.
Yes, correct.
For the reduction within channel, we use `temp[256]` shared memory. But we only need 16 values per channel. We can reduce within a warp without shared memory if we handle the 16-thread group carefully. But using shared memory is simpler and correct.
Actually, since threads 0..15 form a warp, we could do:
```cuda
if (c == 0) {
// But each thread in c=0 group needs the local_sum from the corresponding tid in c=0
// No, all threads in group c=0 are already computing channel 0!
}
```
Wait, c=0 group computes channel 0. c=1 computes channel 1. Each group of 16 threads computes ONE channel. After reduction, we want one value per channel.
So the first reduction is: 16 threads in group c reduce their local_sum to one value for channel c.
Since group c has threads at indices c*16 + 0 to c*16 + 15. For c=0: threads 0..15 (warp 0, lanes 0..15). For c=1: threads 16..31 (warp 0, lanes 16..31).
For group c=0 (lanes 0..15): if we could do shuffle within just lanes 0..15, we'd need `__shfl_down_sync(0x0000FFFF, local_sum, offset)`? No, the mask includes which threads are active in the shuffle, but all 32 threads of the warp must execute the instruction. If `c==0` condition wraps it, then threads 16..31 are inactive and the shuffle may not work correctly with divergence.
Using shared memory is safer.
Can we optimize the shared memory reduction? Instead of multiple __syncthreads(), we can do:
```cuda
__shared__ float temp[256];
temp[threadIdx.x] = local_sum;
__syncthreads();
if (tid < 8) temp[c*16 + tid] += temp[c*16 + tid + 8];
__syncthreads();
if (tid < 4) temp[c*16 + tid] += temp[c*16 + tid + 4];
__syncthreads();
if (tid < 4) { // reuse threads
float sum = temp[c*16 + tid];
sum += __shfl_down_sync(0xFFFFFFFF, sum, 2); // but c=0 and c=1 are in same warp...
}
```
Let me just accept the multiple __syncthreads(). With 128 blocks and each doing many memory reads, a few syncs are negligible.
Actually, looking at this more carefully: for c=0 group, 256 threads, first 16 threads handle channel 0. To reduce 16 values to 1, we can use a simple loop:
```cuda
__shared__ float channel_sums[16];
if (tid == 0) {
float s = 0;
for (int i = 0; i < 16; i++) s += temp[c*16 + i];
channel_sums[c] = s;
}
__syncthreads();
```
Only 1 thread per channel does the sum, but it reads 16 floats from shared memory (fast). 16 additions. For 16 channels, 16 threads each do 16 adds = 256 adds total. This is negligible compared to the 53K maxpool evaluations each thread is already doing.
And it's simpler with fewer syncs!
```cuda
__shared__ float temp[256];
temp[threadIdx.x] = local_sum;
__syncthreads();
__shared__ float channel_sums[16];
if (tid == 0) {
float s = 0;
#pragma unroll
for (int i = 0; i < 16; i++) {
s += temp[c*16 + i];
}
channel_sums[c] = s;
}
__syncthreads();
if (c == 0) {
float val = channel_sums[tid];
#pragma unroll
for (int offset = 8; offset > 0; offset >>= 1) {
val += __shfl_down_sync(0xFFFF, val, offset);
}
if (tid == 0) {
out[b] = val * scale + bias_sum;
}
}
```
Wait, `tid` goes 0..15, and c goes 0..15. For threads with c=0 and tid=0..15, `__shfl_down_sync(0xFFFF, val, offset)` operates on lanes 0..15 of warp 0. `0xFFFF` mask = 16 bits, which means threads 0..15 are active. This is correct because threads 0..15 are exactly the threads in warp 0 with c=0 (and warp 0 lanes 0..15).
But what about threads 16..31 (warp 0, lanes 16..31, c=1)? They hit `if (c == 0)` which is false. In CUDA, when threads in a warp diverge on `if`, the threads not in the `if` are inactive. `__shfl_down_sync` requires all threads in the warp to be active or the result is undefined for inactive lanes. But we pass mask `0xFFFF` which only includes lanes 0..15. Lanes 16..31 are excluded by the mask. However, they must still execute the instruction. Since they're in the else branch, do they execute it?
In practice, threads must not be diverged when calling warp shuffle. The safest approach is to have all threads in the warp participate. Since lanes 16..31 (c=1) are in the same warp as lanes 0..15 (c=0), and we have divergence at `if (c == 0)`, the shuffle is in a divergent branch.
To handle this correctly, we should either:
1. Have the shuffle outside any `if`, with all threads in the warp calling it, OR
2. Compute channel sums for all c in shared memory, then have one warp read all 16 sums and reduce.
Option 2:
```cuda
__shared__ float channel_sums[16];
// after per-channel reduction
if (threadIdx.x < 16) {
float val = channel_sums[threadIdx.x];
#pragma unroll
for (int offset = 8; offset > 0; offset >>= 1) {
val += __shfl_down_sync(0xFFFF, val, offset);
}
if (threadIdx.x == 0) {
out[b] = val * scale + bias_sum;
}
}
```
Now threadIdx.x < 16 might include threads from warp 0 (idx 0..15) and warp 1 (idx 16..31? no, threadIdx.x < 16). Only threads 0..15, which are all in warp 0. Since all 16 threads in warp 0 execute this code, and all threads in warp 0 execute this same code (no divergence within the warp!), this is fine.
Wait, but what about warp 0 lanes 16..31 (threadIdx.x 16..31)? They execute `if (threadIdx.x < 16)` which is false. So they skip the block. And threads 0..15 are in the block. Is there divergence within warp 0? YES! Lanes 0..15 are active, lanes 16..31 are inactive. The shuffle is inside this divergent block. That means lanes 16..31 are inactive for the shuffle. Is that okay?
The CUDA docs say: "All non-exited threads named in mask must execute the same intrinsic with the same mask, or the result is undefined." The mask is 0xFFFF = lanes 0..15. Lanes 16..31 are not named in the mask, so they don't need to execute the intrinsic? Or do they?
Actually, the rule is a bit nuanced. For warp shuffle, all threads in the warp must execute the instruction, but only threads in the mask participate. If there's control divergence, inactive threads still execute the instruction but with undefined data or behavior.
To be 100% safe, I should have the block executed by a full warp, or avoid shuffle within divergent regions.
Simplest safe approach: reduce over channels using shared memory too.
```cuda
__shared__ float channel_sums[16];
// ... fill channel_sums
__syncthreads();
if (threadIdx.x == 0) {
float total = 0;
for (int i = 0; i < C; i++) {
total += channel_sums[i];
}
out[b] = total * scale + bias_sum;
}
```
Only 1 thread does 16 adds. 16 adds is nothing. This is the simplest and safest.
Let me use this.
Final kernel:
```cuda
__global__ void fused_reduce(const float* __restrict__ conv_out, float* __restrict__ out,
int B, int C, int D, int H, int W,
int pD, int pH, int pW, float scale, float bias_sum) {
int b = blockIdx.x;
int c = threadIdx.x >> 4;
int tid = threadIdx.x & 15;
int total_windows = pD * pH * pW;
float local_sum = 0.0f;
int base_bc = ((b * C + c) * D) * H * W;
for (int idx = tid; idx < total_windows; idx += 16) {
int w = idx % pW;
int tmp = idx / pW;
int h = tmp % pH;
int d = tmp / pH;
float max_val = -1e30f;
int od0 = 2 * d;
int oh0 = 2 * h;
int ow0 = 2 * w;
#pragma unroll
for (int di = 0; di < 2; di++) {
int base_d = base_bc + (od0 + di) * H * W;
#pragma unroll
for (int dh = 0; dh < 2; dh++) {
int base_h = base_d + (oh0 + dh) * W;
#pragma unroll
for (int dw = 0; dw < 2; dw++) {
float val = conv_out[base_h + ow0 + dw];
max_val = fmaxf(max_val, val);
}
}
}
local_sum += max_val;
}
__shared__ float temp[256];
temp[threadIdx.x] = local_sum;
__syncthreads();
__shared__ float channel_sums[16];
if (tid == 0) {
float s = 0;
#pragma unroll
for (int i = 0; i < 16; i++) {
s += temp[c * 16 + i];
}
channel_sums[c] = s;
}
__syncthreads();
if (threadIdx.x == 0) {
float total = 0;
for (int i = 0; i < C; i++) {
total += channel_sums[i];
}
out[b] = total * scale + bias_sum;
}
}
```
Wait, I need to double-check: `c` ranges 0..15, so `c*16` ranges 0..240. temp has 256 entries. Good.
But what if C < 16? In our case C=16. Good.
What about the for loop `for (int i = 0; i < 16; i++)`? C is 16, so `i < C` or `i < 16` works.
Actually, should we use `tid == 0` or have all threads reduce? With `tid == 0`, only 16 threads do the reduction (one per channel). Each does 16 reads+adds from shared memory. 256 FLOPs total, negligible.
One optimization: since we're doing `if (tid == 0)`, and `c` determines which channel, all threads with `tid == 0` reduce their respective channels. There are 16 such threads (c=0..15, tid=0). They all write to different `channel_sums[c]`. No bank conflicts since each writes to a different index.
Actually, `channel_sums` is small (16 floats), so it might have some bank conflict issues but it's only 16 writes and 16 reads, so who cares.
Now, about occupancy: blockDim=256. We have `__shared__ float temp[256];` and `__shared__ float channel_sums[16];`. Total shared = 272 * 4 = 1088 bytes. Essentially nothing.
Register usage: each thread needs a few registers for loops and `local_sum`. Probably under 32 registers. So occupancy is limited by block size (256 threads). 256 threads / 2048 per SM = 12.5% lower bound, but multiple blocks: 2048 / 256 = 8 blocks per SM if register-limited.
With 128 blocks total, and 132 SMs, we have 128/132 ≈ 1 block per SM initially, but that's because we only have 128 blocks! To saturate the GPU, we'd need more blocks. 128 blocks * 256 threads = 32,768 threads. H100 has 132 SMs * 2048 = 270K thread capacity. So we can only fill ~12% of the GPU with thread blocks at any instant.
This might be a bottleneck! With only 128 blocks, even if each block has low occupancy, we might be limited by the block count. Actually, each SM can run up to 32 blocks. With 128 blocks and 132 SMs, we have ~1 block per SM. But if each block uses few resources, an SM can run multiple blocks. Let's say an SM can run 8 blocks of 256 threads. Then 128 blocks would use 16 SMs worth of capacity, leaving 116 SMs idle! No, that's not right.
Actually, the scheduler distributes blocks to SMs. With 128 blocks and 132 SMs, at most 128 SMs get 1 block each, and 4 SMs are idle. Each SM would have 1 block * 256 threads = 256 threads active out of 2048 = 12.5% occupancy per SM.
This is bad! We only have 12.5% theoretical occupancy because we have so few blocks! The GPU is severely underutilized.
How to fix this? We need more blocks. Options:
1. Multiple blocks per batch: have 2, 4, or 8 blocks per batch, each handling a subset of channels.
2. More parallelism: have blocks handle sub-batches or sub-regions of channels.
If we have 2 blocks per batch, each handling 8 channels: 256 blocks total.
If 4 blocks per batch, each handling 4 channels: 512 blocks.
If 8 blocks per batch, each handling 2 channels: 1024 blocks.
If 16 blocks per batch, each handling 1 channel: 2048 blocks.
2048 blocks would give good distribution: 2048 / 132 ≈ 15 blocks per SM. Each SM can handle up to 32 blocks. So ~15 blocks * 256 threads = 3840 threads / 2048 max. Some SMs would have 15 blocks but thread-limited to 2048 threads, so ~8 blocks running per SM. Still good.
Let's try 1 block per 1 channel per 1 batch: 2048 blocks of some threads each.
Block handles batch b, channel c. All 6727 windows.
Number of threads: say 128. Each thread handles ~53 windows.
With 2048 blocks * 128 threads = 262K threads. Better.
Or 256 threads per block: 2048 blocks * 256 threads = 524K threads. Occupancy much better.
Let's redesign for `B * C` blocks:
```cuda
__global__ void spatial_reduce(const float* __restrict__ conv_out, float* __restrict__ M,
int B, int C, int D, int H, int W,
int pD, int pH, int pW) {
int bc = blockIdx.x;
int b = bc / C;
int c = bc % C;
int tid = threadIdx.x;
int total_windows = pD * pH * pW;
float local_sum = 0.0f;
int base_bc = ((b * C + c) * D) * H * W;
for (int idx = tid; idx < total_windows; idx += blockDim.x) {
// maxpool over 2x2x2
...
local_sum += max_val;
}
// Block reduction
__shared__ float smem[256]; // max block size 256
smem[tid] = local_sum;
__syncthreads();
// Tree reduction
for (int stride = blockDim.x >> 1; stride > 0; stride >>= 1) {
if (tid < stride) {
smem[tid] += smem[tid + stride];
}
__syncthreads();
}
if (tid == 0) {
M[bc] = smem[0];
}
}
```
Grid: B*C = 2048 blocks. Block: 128 or 256 threads.
Each block outputs one float M[b,c].
Then a second tiny kernel sums over C for each b:
```cuda
__global__ void final_reduce(const float* __restrict__ M, float* __restrict__ out,
int B, int C, float scale, float bias_sum) {
int b = blockIdx.x * blockDim.x + threadIdx.x;
if (b >= B) return;
float total = 0;
int base = b * C;
for (int c = 0; c < C; c++) {
total += M[base + c];
}
out[b] = total * scale + bias_sum;
}
```
This is launched with B=128 threads.
First kernel: 2048 blocks. Second: 1 block of 128 threads.
Total thread count: 2048 blocks * 128 threads = 262K threads or 524K with 256 threads.
This is better utilization!
Let me analyze memory access for the first kernel with 2048 blocks:
- Each block handles one (b,c). Threads in block access the same channel, different windows.
- Consecutive threads access consecutive windows → mostly consecutive W values → decent coalescing.
- But there are 2048 blocks each reading a slice of 215 KB. Total reads 440 MB. Fine.
For the second kernel, reading 2048 floats is negligible.
Now, can we do even better? We could try to fuse the two kernels via atomicAdd or by having the first kernel write to global and immediately launching the second. But two kernel launches have some overhead. Still, for 128 outputs, the second kernel is trivial.
Actually, what if in the first kernel, after computing M[b,c], we atomically add M[b,c] * scale to out[b]? Then no second kernel needed!
```cuda
if (tid == 0) {
atomicAdd(&out[b], smem[0] * scale);
}
```
Wait, but `out[b]` needs `sum_c M[b,c] * scale + bias_sum`. If each block atomically adds `smem[0] * scale` to `out[b]`, then after all C blocks for batch b finish, `out[b] = sum_c M[b,c] * scale`. Then we just need to add `bias_sum` to each `out[b]`. We can do this with a simple kernel or even on CPU.
Or we can initialize `out[b] = bias_sum` before launching the kernel, and then atomicAdd the scaled sums.
But atomics from 2048 blocks to 128 locations might have some contention. Each location has 16 writers (one per channel). 16 atomicAdds per output. Not too bad.
Actually, even better: have each block atomically add to `out[b]`. The systematic approach:
1. `out = torch.full((B,), bias_sum, device=x.device, dtype=x.dtype)`
2. Launch kernel, each block does `atomicAdd(&out[b], smem[0] * scale)`
This eliminates the second kernel entirely!
But atomicAdd on float is not the fastest, and with 16 blocks contending on the same address... But 16 is small. It might be okay.
To avoid atomic contention completely, we can have a two-stage reduction which we were already doing. Given the tiny size of the second kernel, the launch overhead is negligible compared to the first kernel.
I'll stick with the two-kernel approach. It's cleaner and avoids atomics.
Wait, can we avoid the second kernel by having each batch's C blocks cooperate? Not without complex cross-block synchronization.
Two kernels is fine.
Now, what about the `__syncthreads()` in the reduction? With blockDim=256 and 2048 blocks, that's fine.
Actually, I want to optimize the block reduction. The tree reduction with `for (int stride = blockDim.x/2; stride > 0; stride /= 2)` requires `log2(blockDim.x)` iterations. For 256 threads, that's 8 iterations with 8 `__syncthreads()`. That's fine.
But we can do a warp shuffle reduction for the first reduction within each warp, then a final shared memory reduction across warps.
```cuda
// Warp-level reduction
for (int offset = 16; offset > 0; offset >>= 1) {
local_sum += __shfl_down_sync(0xFFFFFFFF, local_sum, offset);
}
// Write warp sum to shared
if (tid % 32 == 0) smem[tid / 32] = local_sum;
__syncthreads();
// Final reduction across warps (max 8 warps)
if (tid < 8) {
float val = smem[tid];
for (int offset = 4; offset > 0; offset >>= 1) {
val += __shfl_down_sync(0xFF, val, offset);
}
if (tid == 0) M[bc] = val;
}
```
This has fewer syncs and is faster. But for simplicity, I'll start with the tree reduction.
Actually, let me consider the launch configuration. Should blockDim be 128 or 256?
- With 128 threads per block, each thread handles 6727/128 ≈ 53 windows.
- With 256 threads, 26 windows each.
- 256 threads means fewer iterations in the for-loop, but more threads competing for shared memory.
Both seem reasonable. 256 gives more parallelism. Let's use 256.
But we only have 2048 blocks of 256 threads. Can we make blockDim smaller to get more blocks? No, block count is fixed at B*C = 2048 based on our parallelism strategy. We can only change threads per block.
What if we use fewer threads per block, say 64, and have each block handle fewer windows per thread (105 windows)?
- 2048 blocks * 64 threads = 131K threads. Worse than 256*2048.
So 256 threads is best. 524K threads total. GPU capacity: 132 SMs * 2048 = 270K threads. So we have 2x more thread slots than can run simultaneously, which is great for latency hiding.
Wait, actually the scheduler can't run more threads than capacity. It will just queue the blocks. Having 2x blocks helps ensure the GPU is fully utilized, but the occupancy per active block matters.
With 2048 blocks and 132 SMs, about 15-16 blocks per SM. At 256 threads each, that's 3840-4096 threads per SM, but max is 2048. So each SM will run at most 2048/256 = 8 blocks concurrently. This is good! 8 blocks * 256 threads = 2048 threads = 100% thread occupancy for this kernel!
Of course, we need to verify register and shared memory limits don't reduce this. With ~32 registers per thread: 256*32 = 8K registers. 64K per SM / 8K = 8 blocks. Perfect.
Shared memory: negligible. So occupancy should be close to 100%.
Great, this kernel design should be efficient.
Now let me write the full Python file with load_inline.
I also need to make sure to handle `bias_shape` properly. The reference bias has shape (out_channels, 1, 1, 1). But since after global avg pool, x has shape [B, C, 1, 1, 1], adding bias of shape [C, 1, 1, 1] broadcasts correctly. The sum over dim=1 then gives [B, 1, 1, 1] or [B].
Our bias_sum = bias.sum(). This is a scalar.
In the reference, convolution output shape is [B, C, D, H, W]. After division, same. After maxpool: [B, C, pD, pH, pW]. After adaptive avg pool: [B, C, 1, 1, 1]. After adding bias: still [B, C, 1, 1, 1]. Sum over dim=1: [B, 1, 1, 1].
So the output is [B, 1, 1, 1], not [B]. The reference returns a 4D tensor. We should match this shape!
Let me check: `torch.sum(x, dim=1)` where x is [128, 16, 1, 1, 1] gives [128, 1, 1, 1]. Yes, it's 4D.
But in my kernel I was writing a 1D output of shape [B]. I need to write shape [B, 1, 1, 1]. Or I can write [B] and reshape in Python.
Actually, I can just allocate `out = torch.empty(batch_size, 1, 1, 1, device=x.device)` and write `out[b] = total * scale + bias_sum`. But the kernel writes to a float array.
Or I can output [B] and then do `out.view(batch_size, 1, 1, 1)`.
Actually, looking at the kernel, `out` is just a 1D float array. In PyTorch, I can allocate `out = x.new_empty(batch_size)` and then after the kernel, `return out.view(batch_size, 1, 1, 1)`.
Wait, the reference does `return x` where x after sum is [B, 1, 1, 1]. To be exactly matching, I should return [B, 1, 1, 1].
So: `out = x.new_empty(batch_size, 1, 1, 1)` and write `out[b, 0, 0, 0] = ...` or equivalently, the base pointer and offset `b`.
Actually, NCHW (or NCDHW) contiguous: `out[b, 0, 0, 0]` is at `out_ptr + b`. So the kernel can just write to `out_ptr + b`.
So I can allocate `out = x.new_empty(batch_size, 1, 1, 1)` and pass `out.data_ptr()` to the kernel.
Let me draft the code:
```python
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.cpp_extension import load_inline
cuda_source = r'''
__global__ void spatial_reduce(const float* __restrict__ conv_out, float* __restrict__ M,
int B, int C, int D, int H, int W,
int pD, int pH, int pW) {
int bc = blockIdx.x;
int b = bc / C;
int c = bc % C;
int tid = threadIdx.x;
int total_windows = pD * pH * pW;
float local_sum = 0.0f;
int base_bc = (((b * C) + c) * D) * H * W;
for (int idx = tid; idx < total_windows; idx += blockDim.x) {
int w = idx % pW;
int tmp = idx / pW;
int h = tmp % pH;
int d = tmp / pH;
float max_val = -1e30f;
int od0 = 2 * d;
int oh0 = 2 * h;
int ow0 = 2 * w;
#pragma unroll
for (int di = 0; di < 2; di++) {
int base_d = base_bc + (od0 + di) * H * W;
#pragma unroll
for (int dh = 0; dh < 2; dh++) {
int base_h = base_d + (oh0 + dh) * W;
#pragma unroll
for (int dw = 0; dw < 2; dw++) {
float val = conv_out[base_h + ow0 + dw];
max_val = fmaxf(max_val, val);
}
}
}
local_sum += max_val;
}
__shared__ float smem[256];
smem[tid] = local_sum;
__syncthreads();
for (int stride = blockDim.x >> 1; stride > 0; stride >>= 1) {
if (tid < stride) {
smem[tid] += smem[tid + stride];
}
__syncthreads();
}
if (tid == 0) {
M[bc] = smem[0];
}
}
__global__ void channel_sum(const float* __restrict__ M, float* __restrict__ out,
int B, int C, float scale, float bias_sum) {
int b = blockIdx.x * blockDim.x + threadIdx.x;
if (b >= B) return;
float total = 0.0f;
int base = b * C;
#pragma unroll
for (int c = 0; c < C; c++) {
total += M[base + c];
}
out[b] = total * scale + bias_sum;
}
'''
functions = ['spatial_reduce', 'channel_sum']
# compile
fused_ops = load_inline(
name='fused_ops',
cpp_sources='', # no cpp wrapper needed
cuda_sources=cuda_source,
functions=functions,
extra_cuda_cflags=['-O3', '--use_fast_math']
)
```
Wait, `load_inline` with `cpp_sources=''` might not work well. Let me check. Usually we provide both. The docs say:
> `cpp_sources` (str or list) – C++ source code(s) containing the host glue code.
We need at least some C++ declarations? Actually, `load_inline` auto-generates pybind11 bindings from `functions`. So if we have `functions=['spatial_reduce', 'channel_sum']`, it will create wrappers that take torch tensors.
Let me make sure the C++ signatures are correct for the auto-generated wrappers. The auto-wrapper expects functions with specific signatures that accept `at::Tensor` parameters. But we're writing raw CUDA kernels with `float*` pointers!
Hmm, `load_inline` auto-generates pybind11 wrappers, but what signature does it expect? It typically expects functions that take `at::Tensor` (or maybe `const at::Tensor&`).
Let me check PyTorch docs for `load_inline`:
> functions: a list of function names for which the bindings are to be generated. The bindings are generated for all functions that are listed in this argument.
Actually, looking at examples, if we provide C++ functions that take `at::Tensor`, the auto-generated bindings call them with the tensors. But if our functions are CUDA `__global__` functions, we need host wrappers to launch them.
So I need to provide host-side C++ functions that call the kernels!
Wait, the instructions say:
> Do not write your own PYBIND11_MODULE(...) block in cpp_sources. load_inline auto-generates one from the functions=[...] argument; including your own causes a duplicate-symbol redefinition error at compile time. List the host-side wrapper function names in functions=[...] instead.
So I need to write host-side wrapper functions in `cpp_sources`, and list them in `functions`.
The host wrappers would be:
```cpp
#include <torch/extension.h>
#include <cuda_runtime.h>
void spatial_reduce_cuda(const torch::Tensor& conv_out, torch::Tensor& M, int B, int C, int D, int H, int W, int pD, int pH, int pW) {
float* conv_ptr = conv_out.data_ptr<float>();
float* M_ptr = M.data_ptr<float>();
int threads = 256;
int blocks = B * C;
spatial_reduce<<<blocks, threads>>>(conv_ptr, M_ptr, B, C, D, H, W, pD, pH, pW);
}
void channel_sum_cuda(const torch::Tensor& M, torch::Tensor& out, int B, int C, float scale, float bias_sum) {
float* M_ptr = M.data_ptr<float>();
float* out_ptr = out.data_ptr<float>();
int threads = 128;
int blocks = (B + threads - 1) / threads;
channel_sum<<<blocks, threads>>>(M_ptr, out_ptr, B, C, scale, bias_sum);
}
```
And `functions=['spatial_reduce_cuda', 'channel_sum_cuda']`.
The CUDA kernels should be in `cuda_sources` and the C++ wrappers in `cpp_sources`.
Let me put this together.
Wait, I also need to make sure the conv in PyTorch is set up correctly. In `__init__`, I need to create a `nn.Conv3d` or use `F.conv3d` with stored weights.
Actually, since we want to scale the conv weights by 1/2 in __init__, we can do:
```python
class ModelNew(nn.Module):
def __init__(self, in_channels, out_channels, kernel_size, divisor, pool_size, bias_shape, sum_dim):
super().__init__()
self.conv = nn.Conv3d(in_channels, out_channels, kernel_size)
# We will set weights later by copying from reference
self.divisor = divisor
self.bias = nn.Parameter(torch.empty(bias_shape))
self.sum_dim = sum_dim
self.pool_size = pool_size
# Precompute scale and bias_sum for kernel
self.register_buffer('scale', torch.tensor(1.0 / (7 * 31 * 31)))
def forward(self, x):
# x = conv(x) where conv weights are already scaled
x = self.conv(x)
# Now call custom kernels
B, C, D, H, W = x.shape
pD = D // self.pool_size[0] # = 7
pH = H // self.pool_size[1] # = 31
pW = W // self.pool_size[2] # = 31
M = x.new_empty(B * C)
out = x.new_empty(B, 1, 1, 1)
fused_ops.spatial_reduce_cuda(x, M, B, C, D, H, W, pD, pH, pW)
bias_sum = self.bias.sum().item()
fused_ops.channel_sum_cuda(M, out, B, C, 1.0 / (pD * pH * pW), bias_sum)
return out
```
Wait, `self.bias` is a parameter of shape (16, 1, 1, 1). Its sum is a scalar.
But we need to make sure `self.conv` matches the reference conv but with scaled weights. Since the evaluation will instantiate ModelNew and call it, we need to either:
1. Copy weights from a reference Model in __init__, or
2. Make ModelNew self-contained so it learns the same weights if trained.
The problem says "Replace the forward computation of the PyTorch model below with a custom CUDA kernel." And "Your ModelNew class must: 1. Accept the same constructor arguments as Model."
The evaluator will do something like:
```python
model = ModelNew(*get_init_inputs())
model.load_state_dict(reference_model.state_dict())
```
Wait, actually the evaluator probably instantiates ModelNew with `get_init_inputs()` and expects it to match the reference when given the same weights. So we need ModelNew to have the same parameter structure as Model, so that state_dict loading works.
The reference Model has:
- `conv.weight` [16, 8, 3, 3, 3]
- `conv.bias` [16]
- `bias` [16, 1, 1, 1]
If ModelNew also has:
- `conv.weight`
- `conv.bias`
- `bias`
Then state_dict loading should work. But we want to scale conv.weight and conv.bias by 1/divisor during forward or in __init__ after loading.
Since state_dict is loaded AFTER __init__, we should NOT scale in __init__. We should scale in forward, OR we can create a buffer that stores the scaled version and update it after state_dict load. But we can't guarantee state_dict will be loaded.
Actually, looking at the evaluation setup more carefully: they probably instantiate both models, copy state dict, and then compare outputs. So if we have `self.conv` with the same names, weights get copied. Then in forward, we can either:
1. Compute `x = self.conv(x) / self.divisor` (but this is what reference does, and the division is a separate kernel that we want to avoid)
2. Compute `x = F.conv3d(x, self.conv.weight / self.divisor, self.conv.bias / self.divisor, ...)` using the pre-divided parameters directly in the conv operation.
Option 2 is great! We don't need to modify the stored weights. We just use `F.conv3d` with scaled weights and bias. Since the weights are in CUDA memory, `self.conv.weight / self.divisor` creates a temporary tensor. This requires a 436MB read+write to divide the weights. But weights are small: 16*8*27 = 3456 floats = 13.8 KB. Dividing them is basically free!
Wait, `self.conv.weight` is [16, 8, 3, 3, 3] = 3456 floats. `self.conv.bias` is 16 floats. Dividing these is negligible.
Then `F.conv3d(x, scaled_weight, scaled_bias, ...)` will call cuDNN, which is just as fast as `self.conv(x)` but the output is already divided. Perfect!
So in forward:
```python
scaled_weight = self.conv.weight / self.divisor
scaled_bias = self.conv.bias / self.divisor
x = F.conv3d(x, scaled_weight, scaled_bias, stride=self.conv.stride, padding=self.conv.padding, dilation=self.conv.dilation, groups=self.conv.groups)
```
This is clean and correct.
Actually, we could even precompute `scaled_weight` and `scaled_bias` as buffers if we want. But since it's so small, computing on the fly is fine.
Wait, but `self.conv.weight / self.divisor` creates a new tensor every forward pass. For 13.8 KB, this is absolutely fine.
One concern: will `F.conv3d` with explicitly provided weights be as fast as `self.conv(x)`? Yes, `self.conv(x)` calls `F.conv3d` internally with the module's stored weights. Same cuDNN path.
Great. Now let me think about the kernel parameters. Since D, H, W after conv are always 14, 62, 62 and pD, pH, pW are 7, 31, 31, we could hardcode some values. But it's better to keep them general.
However, `pD = D // pool_size[0]` might not always be exact. For D=14 and pool_size=2, `14 // 2 = 7`. In PyTorch MaxPool3d with default ceil_mode=False, `floor((D - pool_size)/stride) + 1 = floor((14-2)/2)+1 = 7`. Yes.
Similarly for 62: `floor((62-2)/2)+1 = 31`.
These are exact because 14 and 62 are even.
Now let me draft the complete code.
```python
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.cpp_extension import load_inline
cpp_source = r'''
#include <torch/extension.h>
#include <cuda_runtime.h>
__global__ void spatial_reduce_kernel(const float* __restrict__ conv_out, float* __restrict__ M,
int B, int C, int D, int H, int W,
int pD, int pH, int pW) {
int bc = blockIdx.x;
int b = bc / C;
int c = bc % C;
int tid = threadIdx.x;
int total_windows = pD * pH * pW;
float local_sum = 0.0f;
int base_bc = (((b * C) + c) * D) * H * W;
for (int idx = tid; idx < total_windows; idx += blockDim.x) {
int w = idx % pW;
int tmp = idx / pW;
int h = tmp % pH;
int d = tmp / pH;
float max_val = -1e30f;
int od0 = 2 * d;
int oh0 = 2 * h;
int ow0 = 2 * w;
#pragma unroll
for (int di = 0; di < 2; di++) {
int base_d = base_bc + (od0 + di) * H * W;
#pragma unroll
for (int dh = 0; dh < 2; dh++) {
int base_h = base_d + (oh0 + dh) * W;
#pragma unroll
for (int dw = 0; dw < 2; dw++) {
float val = conv_out[base_h + ow0 + dw];
max_val = fmaxf(max_val, val);
}
}
}
local_sum += max_val;
}
__shared__ float smem[256];
smem[tid] = local_sum;
__syncthreads();
for (int stride = blockDim.x >> 1; stride > 0; stride >>= 1) {
if (tid < stride) {
smem[tid] += smem[tid + stride];
}
__syncthreads();
}
if (tid == 0) {
M[bc] = smem[0];
}
}
__global__ void channel_sum_kernel(const float* __restrict__ M, float* __restrict__ out,
int B, int C, float scale, float bias_sum) {
int b = blockIdx.x * blockDim.x + threadIdx.x;
if (b >= B) return;
float total = 0.0f;
int base = b * C;
#pragma unroll 16
for (int c = 0; c < C; c++) {
total += M[base + c];
}
out[b] = total * scale + bias_sum;
}
void spatial_reduce_cuda(const torch::Tensor& conv_out, torch::Tensor& M, int B, int C, int D, int H, int W, int pD, int pH, int pW) {
int threads = 256;
int blocks = B * C;
spatial_reduce_kernel<<<blocks, threads>>>(
conv_out.data_ptr<float>(), M.data_ptr<float>(),
B, C, D, H, W, pD, pH, pW);
}
void channel_sum_cuda(const torch::Tensor& M, torch::Tensor& out, int B, int C, float scale, float bias_sum) {
int threads = 128;
int blocks = (B + threads - 1) / threads;
channel_sum_kernel<<<blocks, threads>>>(
M.data_ptr<float>(), out.data_ptr<float>(),
B, C, scale, bias_sum);
}
'''
fused_ops = load_inline(
name='fused_ops',
cpp_sources=cpp_source,
cuda_sources='',
functions=['spatial_reduce_cuda', 'channel_sum_cuda'],
extra_cuda_cflags=['-O3', '--use_fast_math'],
verbose=False
)
class ModelNew(nn.Module):
def __init__(self, in_channels, out_channels, kernel_size, divisor, pool_size, bias_shape, sum_dim):
super(ModelNew, self).__init__()
self.conv = nn.Conv3d(in_channels, out_channels, kernel_size)
self.divisor = divisor
self.max_pool = nn.MaxPool3d(pool_size)
self.global_avg_pool = nn.AdaptiveAvgPool3d((1, 1, 1))
self.bias = nn.Parameter(torch.randn(bias_shape))
self.sum_dim = sum_dim
self.pool_size = pool_size
def forward(self, x):
# Apply conv with pre-scaled weights to incorporate division
weight = self.conv.weight / self.divisor
bias = self.conv.bias / self.divisor
x = F.conv3d(x, weight, bias, stride=self.conv.stride, padding=self.conv.padding,
dilation=self.conv.dilation, groups=self.conv.groups)
B, C, D, H, W = x.shape
pD = D // self.pool_size[0]
pH = H // self.pool_size[1]
pW = W // self.pool_size[2]
M = x.new_empty(B, C)
out = x.new_empty(B, 1, 1, 1)
fused_ops.spatial_reduce_cuda(x, M, B, C, D, H, W, pD, pH, pW)
scale = 1.0 / float(pD * pH * pW)
bias_sum = float(self.bias.sum().item())
fused_ops.channel_sum_cuda(M, out, B, C, scale, bias_sum)
return out
```
Wait, `x.new_empty(B, C)` creates a 2D tensor, but `M` in the kernel is accessed as `M[bc]` where bc = b*C + c. So the tensor needs to be contiguous and 1D or 2D NCHW... Actually if M is [B, C] and contiguous, `M[bc]` is wrong because 2D indexing is `M[b, c]`. We should pass a 1D tensor or reshape.
Let me fix: `M = x.new_empty(B * C)` or `M = x.new_empty(B, C).contiguous().view(-1)`. Using `new_empty(B * C)` is fine.
Actually, the kernel just does `M[bc] = smem[0]`. If M is 1D of size B*C, this works.
And `out = x.new_empty(B, 1, 1, 1)` creates a 4D tensor. The kernel writes `out[b]`. But if `out` is 4D, `out[b]` is at offset `b * 1 * 1 * 1 = b`. So contiguous 4D with trailing dims 1 is fine. Or we can pass `out.view(-1)`.
Actually, to be safe, we should pass the reshaped 1D view to the kernel:
`fused_ops.channel_sum_cuda(M, out.view(-1), B, C, scale, bias_sum)`
Wait, PyTorch tensors might not allow data_ptr() on views in the same way... Actually, a view shares the same storage, so data_ptr() is the base pointer and the kernel accesses contiguous memory. For `out.view(-1)`, it's truly contiguous since the original is [B,1,1,1]. So offset b is correct.
Actually, even without view, `out.data_ptr<float>() + b` is correct for `out[b, 0, 0, 0]` since stride after first dim is 1.
I'll pass `out` directly and have the kernel write to `out.data_ptr<float>() + b`.
Hmm, but the auto-generated wrapper takes `torch::Tensor& out`, so it passes the tensor object. The wrapper we wrote accesses `out.data_ptr<float>()`. The kernel writes to `out_ptr[b]`. This works for any contiguous tensor where the first dimension has stride 1. Since `out.new_empty(B, 1, 1, 1)` is contiguous and NCDHW (or NCHW for 5D), the stride is [C*D*H*W, D*H*W, H*W, W, 1]. For [B,1,1,1], strides are [1,1,1,1]. Writing to index b directly gives the right element.
Wait, is [B,1,1,1] contiguous? Yes. Strides: [1, 1, 1, 1]. Element at [b,0,0,0] is at offset b*1 + 0*1 + 0*1 + 0*1 = b. So `out_ptr[b]` is correct.
For M = [B*C], strides [1]. `M[bc]` is at offset bc. Correct.
So we can pass the tensors directly.
Let me verify: in `channel_sum_cuda`, we have `out.data_ptr<float>()`. The kernel writes `out[b]`. If out is 1D size B, fine. If out is 4D [B,1,1,1], `out[b]` is also correct if we treat the pointer as flat. But the CUDA kernel receives a `float*` and does `out[b]`. This is pointer arithmetic. So `out_ptr + b` points to `out[b,0,0,0]`. Correct.
Actually, wait. In C/C++, `out[b]` is pointer arithmetic: `*(out_ptr + b)`. Yes, that's exactly what we want.
Great.
One more thing: the kernel reads from `conv_out` which is the output of `F.conv3d`. This tensor is 5D: [B, C, D, H, W]. The kernel computes offsets as `base_bc + ...` treating it as flat. Since it's contiguous, this is correct.
Let me compile and test this.
Actually, I need to make sure `cpp_sources` is passed correctly. In `load_inline`, if I put the CUDA kernels in `cpp_sources` along with the C++ wrapper code, it should compile because `load_inline` with only `cpp_sources` will treat it as C++ code and compile it with nvcc if it contains CUDA syntax?
Actually, `load_inline` has both `cpp_sources` and `cuda_sources`. The CUDA kernels should go in `cuda_sources`, and the host C++ wrappers in `cpp_sources`. But the host wrappers need to call the kernels, so they need to see the kernel declarations.
Usually, the way to handle this is:
- Put everything (kernels + host wrappers) in `cpp_sources`, and leave `cuda_sources` empty.
- Or put kernels in `cuda_sources` and declare them as `extern "C"` or forward-declare in `cpp_sources`.
But `cpp_sources` might be compiled with the C++ compiler, not nvcc. Hmm.
Looking at PyTorch docs, `load_inline` compiles `cpp_sources` with C++ compiler and `cuda_sources` with nvcc. So if I put CUDA kernels in `cpp_sources`, it will fail. I should put them in `cuda_sources`.
For the host wrappers to call the kernels, I need forward declarations of the kernels in `cpp_sources`, or put everything in `cuda_sources` and nothing in `cpp_sources` if `cuda_sources` can include host code.
Actually, I think `cuda_sources` files are compiled with nvcc, which handles both device and host code. But `load_inline` expects `cpp_sources` to define the functions listed in `functions`. Let me check how others do this.
A common pattern:
```python
load_inline(
name='...',
cpp_sources='''
#include <torch/extension.h>
void my_func(torch::Tensor x) {
// launch kernel
kernel<<<...>>>(...);
}
''',
cuda_sources='''
__global__ void kernel(...) { ... }
''',
functions=['my_func']
)
```
In this pattern, the C++ compiler compiles `cpp_sources`. It sees `kernel<<<...>>>()` but doesn't need the kernel definition at compile time? Actually, yes, CUDA host code can call kernels without seeing their definition if it's compiled by nvcc. But `cpp_sources` is compiled by the host C++ compiler. Usually `cpp_sources` might be compiled with nvcc too if extensions are set up that way, or it might need to use `TORCH_EXTENSION_NAME` macros.
Wait, I think `load_inline` might actually compile both with nvcc when CUDA is available. Let me not worry too much and just put everything in `cpp_sources`. PyTorch's `load_inline` should handle CUDA code in `cpp_sources` if the torch extension build system uses nvcc.
Actually, to be safe, I can put the CUDA kernels in a `.cu` content via `cuda_sources`, and put the host wrappers in `cpp_sources` with `extern "C"` declarations or just use `cuda_runtime_api.h`.
Let me check: if I declare `void spatial_reduce_kernel(...)` in `cpp_sources` without `__global__`, the compiler should accept it as an external function. Then at link time, it's resolved from `cuda_sources`.
```python
cpp_sources = '''
#include <torch/extension.h>
#include <cuda_runtime.h>
// Forward declaration
__global__ void spatial_reduce_kernel(...);
__global__ void channel_sum_kernel(...);
void spatial_reduce_cuda(...) { ... }
void channel_sum_cuda(...) { ... }
'''
cuda_sources = '''
__global__ void spatial_reduce_kernel(...) { ... }
__global__ void channel_sum_kernel(...) { ... }
'''
```
But can we forward-declare `__global__` functions in standard C++? The host compiler might not understand `__global__`. We might need to use `extern "C"` or just omit `__global__` in the forward declaration? Actually, nvcc provides `__global__` as a keyword, but the host compiler might not have it.
We can use a macro like:
```cpp
#ifdef __CUDACC__
#define GLOBAL __global__
#else
#define GLOBAL
#endif
```
Or simpler: just `void spatial_reduce_kernel(...);` without `__global__`. Since C++ name mangling is the same, and `__global__` only affects the calling convention... actually, `__global__` functions are just regular functions from the host compiler's perspective after nvcc preprocesses them. The declaration `void kernel(...);` should work if we compile the `cpp_sources` with nvcc.
I think `load_inline` might compile both sources together or with nvcc. To avoid issues, I'll put everything in `cpp_sources` and leave `cuda_sources` empty. Many PyTorch inline extension examples do this for simple cases.
Actually, looking at PyTorch docs: "If your source code contains CUDA code, you should put it in `cuda_sources`." But for the functions list, they need to be in C++ code callable from Python.
Let me try a simple approach: put everything in `cpp_sources`. If the compiler is nvcc (which it should be for CUDA extensions), `__global__` will be understood.
But actually, in `cpp_sources`, if it contains `__global__`, PyTorch's build system should route it through nvcc. Let me just test this.
I'll write the code and compile it.
One more optimization: in the `channel_sum` kernel, C=16. We should unroll by 16 or just leave the loop with `#pragma unroll`. The compiler will unroll small loops.
Also, `channel_sum_kernel` takes `float scale`. `scale = 1.0 / (pD * pH * pW) = 1.0 / 6727`.
Let me write the complete Python module and compile it.