reasoning
This is a 1D convolution optimization problem on an NVIDIA H100. Let me analyze the problem:
- Input shape: (batch_size=64, in_channels=64, length=524280)
- Output channels: 128
- Kernel size: 3
- Stride: 3
- Dilation: 4
Given the parameters:
- Effective kernel size with dilation: 1 + (3-1)*4 = 9
- Output length: floor((524280 - 9) / 3) + 1 = floor(524271 / 3) + 1 = 174757 + 1 = 174757
So this is essentially a 1D convolution with a small kernel (3), large stride (3), and moderate dilation (4).
For the output, we have:
- 64 batches
- 128 output channels
- 174757 output length per channel
Total output elements: 64 * 128 * 174757 ≈ 1.43 billion elements.
This is a memory-bandwidth bound problem. The PyTorch Conv1d uses cuDNN which is highly optimized, but we might be able to beat it by:
1. Using a custom kernel that's better tailored to this specific shape
2. Leveraging shared memory more effectively
3. Reducing kernel launch overhead by fusing operations
Given stride=3, dilation=4, kernel_size=3, this is actually a sparse convolution in terms of input access.
Let me think about the math more carefully:
- For output position j, input positions accessed are: start + j*stride + k*dilation for k=0,1,2
- Where start depends on padding (which is 0 by default for conv1d with these parameters)
Wait, nn.Conv1d default padding is 0. So:
L_out = floor((524280 + 2*0 - dilation*(kernel_size-1) - 1) / stride) + 1
= floor((524280 - 9 - 1) / 3) + 1
= floor(524270 / 3) + 1
= 174756 + 1
= 174757
Let me verify: 174757 * 3 = 524271. But we have length 524280. The first output uses positions 0, 4, 8. The last output uses positions 524268, 524272, 524276.
Actually let me recalculate. With kernel_size=3, dilation=4:
The kernel covers positions: 0, 4, 8 (for the first output element with stride=3)
With stride=3, the next output covers: 3, 7, 11
So for output j, input positions are: 3*j, 3*j+4, 3*j+8.
The maximum j is when 3*j + 8 < 524280, i.e., j < 174757.33, so j_max = 174757.
Total outputs: 174757 + 1 = 174758? Wait, let me check j from 0 to 174757 inclusive = 174758.
Hmm wait:
floor((524280 - 8 - 1) / 3) + 1 = floor(524271 / 3) + 1 = 174757 + 1 = 174758.
Let me verify: for j=174757, the last position accessed is 3*174757 + 8 = 524271 + 8 = 524279. This is within bounds. For j=174758, last position is 3*174758 + 8 = 524274 + 8 = 524282, out of bounds.
So output length is indeed 174758.
Hmm, the comment says 174757, but let's be precise.
For Conv1d:
Lout = floor((Lin + 2*padding - dilation*(kernel_size-1) - 1) / stride + 1)
= floor((524280 + 0 - 4*2 - 1) / 3 + 1)
= floor((524280 - 9) / 3 + 1)
= floor(524271 / 3 + 1)
= floor(174757 + 1)
= 174758
Wait, floor((524280 - 8 - 1)/3 + 1) = floor(524271/3 + 1) = floor(174757 + 1/3 + 1) = 174758.
Or using the other formula: floor((524280 - 9)/3) + 1 = 174757 + 1 = 174758.
Actually: 524271 / 3 = 174757 exactly. So floor(174757 + 1) = 174758. Yes.
I need to be careful about the exact dimensions. Let me just compute with PyTorch first.
For optimization, given the small kernel size (3), this is very amenable to manual unrolling. We can also notice that with stride=3, each output element only reads 3*in_channels input values.
A good approach for a custom kernel:
- Each thread computes one or more output elements
- We can load weights into shared memory or registers
- For such a small kernel and with in_channels=64, out_channels=128, we might want to tile by output channels and batch
Actually, let me think about this differently. The operation is essentially:
for each batch b:
for each output channel c_out:
for each output position j:
out[b, c_out, j] = bias[c_out] + sum over c_in, k: weight[c_out, c_in, k] * in[b, c_in, j*stride + k*dilation]
This is a typical GEMM-like operation if we reshape appropriately.
With in_channels=64, out_channels=128, kernel_size=3, we have 64*128*3 = 24,576 weight parameters.
For H100, we want to maximize memory bandwidth and utilization.
Let me think about tiling strategies:
1. If each thread computes one output element, we need 64*128*174758 threads. That's about 1.43B threads, too many.
2. Better to have each thread compute multiple output elements along the length dimension.
Given that H100 has high memory bandwidth, we want to ensure coalesced memory access and good occupancy.
One approach that's often faster than cuDNN for small kernels:
- Use shared memory to cache input (or use direct global memory with good coalescing)
- Tile over batch and output channels
- Have each warp or block compute a tile of outputs
Actually, for stride=3 and with dilated convolution, direct memory access isn't contiguous for the kernel dimension but is contiguous across channels. Wait, actually:
- Input is shape (batch, in_channels, length) - so channel-major in PyTorch? No, PyTorch is (N, C, L) so contiguous in length dimension.
- For output position j, we access input at positions j*stride, j*stride+dilation, j*stride+2*dilation.
- These are strided accesses along the length dimension.
With stride=3, dilation=4, the three accesses for a given (b, c_in, j) are at:
- offset = b * in_channels * length + c_in * length + j*3
- offset + 4
- offset + 8
Hmm wait, that's not right. Let me reconsider.
Input layout: (batch_size, in_channels, length) = (64, 64, 524280)
The memory layout in PyTorch is row-major for these dimensions.
So element (b, c, l) is at: b * (in_channels * length) + c * length + l.
For a given (b, c_out, j), the output is:
sum over c_in in [0, 63], k in [0, 2]:
weight[c_out, c_in, k] * input[b, c_in, j*stride + k*dilation]
For fixed b, c_out, j, and varying c_in:
- Access to input[b, c_in, j*stride] is at stride `length` in memory (contiguous across output channels? No, across c_in).
Actually for fixed b, j, k, as c_in varies, we access input[b, 0, j*stride+k*dilation], input[b, 1, j*stride+k*dilation], etc.
These are contiguous in memory! (Well, separated by length, but consecutive c_in values are adjacent when we think of them as a vector.)
Wait, no. Input[b, c_in, l] for varying c_in at fixed l: positions are b*in_channels*length + c_in*length + l.
The stride between c_in and c_in+1 is `length`. So not contiguous in the strict sense, but it's a uniform stride.
Actually, I was wrong. For consecutive c_in, the elements are `length` apart. So if length=524280, they're very far apart.
But for fixed c_in and varying l: consecutive l values are contiguous.
For the convolution, for a fixed output position j, the three input accesses are at l = 3j, 3j+4, 3j+8. These are not contiguous.
However, for consecutive j values, the accesses shift by 3. So j and j+1 access 3j, 3j+4, 3j+8 and 3j+3, 3j+7, 3j+11.
One key optimization for strided convolutions is to use a "im2col" style approach or direct convolution with careful tiling.
Given kernel_size=3 and in_channels=64, each output element requires reading 3*64 = 192 input floats and 3*64*128/(128) = 192 weight floats per output channel... hmm let me think differently.
Total memory traffic for a direct implementation:
- Read all input once: 64 * 64 * 524280 * 4 bytes ≈ 8.58 GB
- Read weights: 128 * 64 * 3 * 4 = 98,304 bytes ≈ 0.1 MB
- Write output: 64 * 128 * 174758 * 4 ≈ 5.72 GB
But with stride=3 and kernel_size=3, we can't just read input once because each input element might be reused. Actually, with stride=3 and kernel_size=3, each input element is used by approximately 1 output element on average (since 3 outputs consume 9 input elements with dilation).
Wait, for a given channel c_in, input position l is used by output position j if:
j*stride <= l <= j*stride + (kernel_size-1)*dilation and l ≡ j*stride (mod gcd(stride, dilation))?
Actually, l = j*stride + k*dilation for some k in [0, 2].
So l = 3j + 4k.
For a given l, how many (j, k) pairs satisfy this?
l = 3j + 4k, k ∈ {0,1,2}, j ≥ 0.
For k=0: j = l/3, valid if l ≡ 0 (mod 3)
For k=1: j = (l-4)/3, valid if l ≡ 1 (mod 3)
For k=2: j = (l-8)/3, valid if l ≡ 2 (mod 3)
Since 3 and 4 are coprime... wait, actually for any l, exactly one of l, l-4, l-8 is divisible by 3.
Because l mod 3, (l-4) mod 3 = (l-1) mod 3, (l-8) mod 3 = (l-2) mod 3.
So one of them is always 0 mod 3.
This means each input element is used exactly once per channel! That's fascinating.
Total memory traffic for inputs is exactly the input size. That's optimal.
So the bandwidth is:
- Read input: ~8.58 GB
- Read weights: negligible
- Write output: ~5.72 GB
- Total: ~14.3 GB
At 3.35 TB/s = 3350 GB/s, this should take at minimum 14.3 / 3350 = 0.00427 seconds = 4.27 ms.
But in practice there will be overhead, and memory access patterns matter.
Now, cuDNN is likely quite optimized, but we might be able to do better with a custom kernel because:
1. We know the kernel size is exactly 3, allowing perfect unrolling
2. We know the dilation and stride and can optimize memory access patterns
3. We can choose block sizes and tiling specifically for this workload
Let me design a kernel:
**Approach: Direct convolution with input tiling in shared memory**
Given that each input element is read exactly once per channel, and the access pattern for consecutive j is strided by 3 in the length dimension, we might be able to use shared memory to coalesce reads.
Actually, for coalesced global memory access, it's better to have threads in a warp access consecutive memory locations.
For a thread computing output (b, c_out, j), the memory accesses are:
- input[b, :, 3j], input[b, :, 3j+4], input[b, :, 3j+8]
These are not contiguous for different j values in a warp (they're spaced by 3*in_channels*length elements).
Actually, let me reconsider the memory layout. In PyTorch, tensor of shape (64, 64, 524280) has strides (524280*64, 524280, 1).
So element (b, c, l) is at index b*33505280 + c*524280 + l.
For a fixed b, and threads in a warp computing outputs for consecutive j at the same c_out:
Thread i computes j = j0 + i.
It accesses input at l = 3*(j0+i) = 3*j0 + 3i for k=0.
So threads in the warp access l = 3j0, 3j0+3, 3j0+6, ...
In memory, these are at indices: base + 3j0 + 0, base + 3j0 + 3, base + 3j0 + 6, ...
Stride between consecutive threads is 3 floats = 12 bytes.
For k=1, accesses are at l = 3j0 + 4 + 3i, stride is still 3.
For k=2, accesses are at l = 3j0 + 8 + 3i, stride is still 3.
A stride of 3 floats (12 bytes) is not great for coalescing, but on H100 the L1 cache and memory subsystem can handle some striding. However, each warp only accesses every 3rd element, so we have 1/3 effective bandwidth.
This suggests we should reorganize the computation.
**Better approach: Interleave computation**
Have threads in a warp work on output positions that are spaced apart but access contiguous memory.
Or better yet, have each thread compute a small tile of outputs along b and c_out dimensions, and have the warp work on the same j range but different c_out values.
Actually, wait. For the same j and different c_in, accesses are at:
input[b, 0, l], input[b, 1, l], input[b, 2, l], ... = base + 0*524280 + l, base + 1*524280 + l, base + 2*524280 + l, ...
These are separated by 524280 elements = 2MB. That's very strided, not contiguous.
For the inner loop over c_in (from 0 to 63), we're jumping by 2MB each time. This will not cache well.
Let me reconsider. For a fixed output element (b, c_out, j), we need to sum over c_in and k:
weight[c_out, c_in, k] * input[b, c_in, 3j+4k]
Actually, if we think of this as a matrix multiplication:
- We can view the output for fixed b, j as: out[b, :, j] = W * in_vector
where W is shape (out_channels, in_channels*kernel_size) and in_vector is shape (in_channels*kernel_size).
- For our case: W is (128, 192) and in_vector has 192 elements. Then out[b, c_out, j] is just one entry of W * in_vector.
But for different j, the in_vector is different (with overlapping elements).
Actually, with stride=3, dilation=4, kernel_size=3:
For j and j+1, the input patches are {3j, 3j+4, 3j+8} and {3j+3, 3j+7, 3j+11}.
These don't overlap at all!
So adjacent outputs (in j) use completely disjoint input elements. This means there's no data reuse between adjacent j values, except within the same j across different c_out.
Hmm, but for j and j+1, at the same c_in, we use input[b, c_in, l] and input[b, c_in, l+3]. These are only 3 elements apart, so they might be in the same cache line.
Given the memory access pattern, let me think about what cuDNN does. For dilated convolutions with small kernel, cuDNN likely uses an implicit GEMM approach or direct convolution with vectorized loads.
**A competitive approach:**
Since each output only needs 192 input values (64 channels × 3 kernel positions), and the kernel is small, we can have each block compute a tile of outputs and load the required input into shared memory.
For a tile of (TILE_BATCH, TILE_OUT_C, TILE_J) outputs:
- We need input for batch indices [b_start:b_start+TILE_BATCH] and positions [3*j_start, 3*j_start+3*TILE_J+8] across all in_channels.
- That's TILE_BATCH * in_channels * (3*TILE_J + 8) floats.
If TILE_J = 32, TILE_BATCH = 1: 1 * 64 * 104 = 6656 floats = 26.6 KB. Very small.
If TILE_BATCH = 2: 53 KB. Still fits in L1/shared.
But actually, for each input channel, we need 104 floats. If we load all 64 channels for 1 batch and 32 j positions, that's 64 * 104 = 6656 floats.
For the weights: TILE_OUT_C * in_channels * 3 = 128 * 64 * 3 = 24576 floats if we load all weights. That's 98 KB, which also fits.
But hold on - if we put both input tile and weights in shared memory, we might exceed 227KB. 26.6KB + 98KB = 125KB, which fits.
However, a better approach might be to keep weights in registers (or L1 cache) and only use shared memory for input tiles. Since weights are only 98KB and are reused across all outputs, they'll naturally stay in L2 cache. Actually with 98KB, they might even stay in L1.
Given H100 has 3.35 TB/s memory bandwidth, and total memory traffic is ~14.3GB, a kernel that's memory bandwidth bound should achieve near-theoretical performance if we minimize overhead.
Wait, I should check if cuDNN actually uses im2col or direct conv for this. For small kernels and large strides, direct conv is often better.
Let me try a very direct approach: use CUTLASS-style implicit GEMM or just a simple but well-tuned direct convolution kernel.
**Kernel design:**
I'll write a kernel where:
- Each block processes a tile of (TB, TO, TJ) outputs.
- Threads within the block cooperatively load input into shared memory.
- Each thread computes one or more output elements.
Let's think about occupancy and resource constraints. H100 has:
- 64K registers per SM
- Max 32 thread blocks per SM
- Max 227 KB shared memory per block
- Max 1536 threads per SM? Actually for Hopper it's probably 2048.
For good occupancy, we want multiple blocks per SM. Let's say 256 threads per block. Then we can have up to 6 blocks per SM if register-limited (64K / ~20K per block for 256 threads = ~80 registers per thread).
Actually, let me design for 128 threads per block and aim for ~50+ registers per thread.
**Thread assignment:**
Option 1: Each thread computes one (batch, out_channel, j) output.
- Need 1.43B output elements / 128 threads per block = 11.2M blocks. Too many, launch overhead.
Option 2: Each thread computes multiple j values.
- 128 threads per block, each computing 8 j values = 1024 outputs per block.
- 1.43B / 1024 = 1.4M blocks. Still a lot but manageable.
Option 3: Each thread computes a tile of (out_channels, j).
- Block = (TJ, TO) or similar.
Let me try a 2D grid:
- dim3 block(TJ, TO) where TJ = 16 and TO = 8, total 128 threads.
- Each thread computes all batch elements in its tile? No, let's have blocks also tile batch.
Actually, let's do:
- Thread block: 128 threads arranged as (16, 8) for (TJ, TO_partial)
- Each thread computes 1 output for a specific (j, partial_out_channel)
- Within the block, TO_partial = 8, so 8 partial out channels.
- For batch, we can either loop or have the block handle a single batch and use another grid dimension.
Block layout:
- gridDim.x = ceil(TJ_total / 16)
- gridDim.y = ceil(TO_total / 8) = ceil(128 / 8) = 16
- gridDim.z = batch = 64
Total blocks = ceil(174758 / 16) * 16 * 64.
174758 / 16 = 10922.375, so 10923.
Total blocks = 10923 * 16 * 64 = 11,205,120. That's a lot of blocks but H100 can handle it.
Actually 11M blocks is quite a lot and might have launch overhead issues. Each block only computes 16*8 = 128 outputs. At 1.43B outputs, we'd need 11M blocks of 128 outputs each. The block launch overhead might be significant.
Better to have larger blocks:
- 256 threads, each computing 16 outputs along j = 4096 outputs per block.
- 1.43B / 4096 = 349K blocks. Much better.
Let me try:
- blockDim.x = 256
- Each thread handles (TO=2, TJ=8) = 16 outputs.
- Block computes (TO=2*128/2=128, wait no)
Actually let's think of it as:
- Block = 256 threads
- Each thread computes 1 output channel for 16 j positions, for 1 batch.
- Block handles 16 output channels and 16*16 = 256 j positions = 256 * 16 = 4096 outputs.
Wait, let me be more careful.
- 256 threads in a block.
- We tile as: TO_per_block = 16 output channels, TJ_per_thread = 16 j positions.
- Then threads can be arranged as (16, 16): 16 channels * 16 threads_per_channel = 256 threads.
- Each thread computes 1 channel and 16 j positions.
- Total outputs per block = 16 channels * 256 j positions = 4096 outputs.
Grid:
- x = ceil(174758 / 256) = 683
- y = ceil(128 / 16) = 8
- z = 64
Total blocks = 683 * 8 * 64 = 349,696. Very reasonable!
Now for memory access. For a block computing:
- batch = b
- out_channels = [c0:c0+16]
- j positions = [j0:j0+256]
Each output value at (b, c_out, j) needs input at (b, :, 3j), (b, :, 3j+4), (b, :, 3j+8).
For the block, the input positions needed are:
j in [j0, j0+255], so l in [3j0, 3(j0+255)+8] = [3j0, 3j0+763+8] = [3j0, 3j0+771].
For all 64 input channels, the input slice is:
- 64 channels * 772 floats = 49,408 floats = 197.6 KB.
This is less than 227KB shared memory limit! Actually it's close but when we add weights for 16 output channels * 64 * 3 = 3072 floats = 12.3 KB, total is ~210KB. That might fit but it's tight and could limit occupancy.
If we use 96KB per block, we get limited to ~2 blocks per SM, which might hurt occupancy.
Alternative: don't load input to shared memory, just use global memory with vectorized loads and rely on L1/L2 cache.
Actually, for H100, L2 cache is quite large (50MB+), and L1 is 256KB per SM. With 349K blocks, each block's input footprint is 197KB, so multiple blocks' inputs might overlap in L2.
But L1 is only 256KB per SM, and multiple SMs share L2. If each block needs 197KB of unique input and we have concurrent blocks per SM, L1 might thrash.
Actually wait - for consecutive j0 values (e.g., j0=0 and j0=256), the input ranges are [0, 771] and [768, 1539]. They overlap by only 4 elements! So there's almost no reuse between consecutive blocks along the j dimension.
This means L2 cache won't help much for input reuse across j tiles.
So this is basically perfectly memory-bandwidth bound with almost no reuse. The theoretical minimum time is determined by total memory traffic.
Total memory traffic:
- Input: 64 * 64 * 524280 * 4 = 8,589,934,592 bytes
- Output: 64 * 128 * 174758 * 4 = 5,729,613,824 bytes
- Weights: 98,304 bytes (negligible)
- Total: ~14.32 GB
- At 3.35 TB/s: 4.27 ms
cuDNN probably achieves close to this. To beat it, we need to either:
1. Have less memory traffic (not possible since we need to read all inputs and write all outputs)
2. Utilize memory bandwidth more efficiently
3. Have less kernel launch overhead
4. Maybe cuDNN uses an im2col approach that temporarily allocates more memory, leading to extra traffic?
Actually, wait. Let me think about whether cuDNN uses implicit GEMM or direct convolution. For large input (524K), small kernel (3), and stride=3, cuDNN might use an algorithm that's not perfectly optimal.
Especially with dilation=4, cuDNN's GEMM-based approaches might need to do extra work or have worse memory access patterns than a custom direct kernel.
Let me try writing a direct kernel with vectorized memory access.
**Vectorized direct convolution kernel:**
For each thread, I'll compute a tile of outputs. Let's try a simpler design first:
- 256 threads per block
- Grid: (blocks_x, blocks_y, batch)
- blocks_x = ceil(output_length / block_tile_j)
- blocks_y = ceil(out_channels / block_tile_c)
- Each thread computes (thread_tile_c, thread_tile_j) outputs.
To maximize coalescing, I'll have threads in a warp access the same input positions but different output channels.
Actually, the best memory access pattern for weights is when threads in a warp access the same weight elements (broadcast), and the best pattern for input depends on the layout.
Wait, let's think about this more carefully.
When different threads compute different output channels c_out for the same (b, j):
- They all access the SAME input elements (b, c_in, 3j+4k for all c_in, k).
- But DIFFERENT weight elements (c_out, c_in, k).
Weights are shape (128, 64, 3). Weight for (c_out, c_in, k) is at c_out * 192 + c_in * 3 + k.
For fixed c_in, k and varying c_out, weights are strided by 192.
For fixed c_out and varying c_in, weights are strided by 3.
If threads in a warp compute different c_out for the same j:
- They access weights strided by 192 floats = 768 bytes. That's bad coalescing.
- They access the same input elements. That's good (broadcast, can use L1 cache).
If threads in a warp compute the same c_out for different j:
- They access the same weights. That's good (broadcast).
- They access input strided by 3 floats = 12 bytes. That's not ideal but not terrible.
Actually 12 bytes means each thread accesses a different 12-byte-aligned location. With 32 threads, that's 384 bytes per load, which is 6 cache lines (64 bytes each). Not great.
Actually for vectorized loads on H100, we want threads to access contiguous 16-byte chunks.
If thread i computes j = j0 + i, and loads input at 3j = 3j0 + 3i.
Consecutive threads access positions 3j0, 3j0+3, 3j0+6, ...
In bytes: 12j0, 12j0+12, 12j0+24, ...
A warp of 32 threads accesses: 12j0, 12j0+12, ..., 12j0+372.
Total span: 384 bytes = 6 cache lines. Terrible.
What if threads compute j = j0 + i*8? Then consecutive threads access j0, j0+24, j0+48, ...
Span for warp: 32*24*4 = 3072 bytes. Even worse.
For contiguous access along j, we could have threads compute outputs for j values that map to contiguous memory.
Actually, what if we reorder the j dimension? If thread 0 computes j=0, thread 1 computes j=1, etc., input accesses are at 0, 3, 6, ... which are strided by 12 bytes.
Hmm, I wonder if we can use the fact that for k=0, accesses are contiguous across groups. If we process j in groups where the j values have a specific pattern...
Or maybe we can use float4 or float2 loads. For k=0, thread i accesses position l = 3*(j0+i) = 3j0 + 3i. Unless i increments by 4 and we use some gather, this won't be vectorizable naturally.
**Alternative: Loop over c_in in shared memory**
Since the main memory bandwidth limitation comes from reading inputs across c_in, and inputs for different c_in are far apart (stride = length = 524280), maybe we should reorganize.
What if we transpose our looping? Instead of having each thread compute an output by looping over c_in, we have blocks that load a slice of c_in channels into shared memory in a transposed format?
Actually, let me try a different perspective. Since each output needs 192 input values, and outputs for the same j but different c_out share the same 192 input values, it makes sense to load those 192 inputs once and reuse them across all c_out.
For a block computing outputs for j in [j0, j0+TJ-1]:
- For each j, there are 3*in_channels = 192 input values needed.
- If TJ=16, that's 16*192 = 3072 input values.
- These can be loaded cooperatively and stored in shared memory.
- Then each thread computes its outputs using the shared memory values.
But the inputs for different j are mostly disjoint. So we only get reuse across c_out, not across j.
Let me calculate: For TJ=16 and TO=128 (all output channels), the block computes 16*128 = 2048 outputs.
Memory traffic:
- Read inputs: 16*192 = 3072 floats (for the 16 j positions)
- Read weights: 128*192 = 24576 floats (for all output channels)
- Write outputs: 2048 floats.
Total: ~29696 floats = ~118KB per block.
For 349696 blocks, total traffic = 349696 * 118KB = 41.3 GB.
Wait, that's way more than before! Because we're reading weights for every block. The weights are only 98KB but each block reads 98KB of them, and there are 349K blocks. That's 349K * 98KB = 34.2GB just for weights!
This is because there's no reuse of weights across blocks. Each block handles a different j range, but the weights are the same. However, if weights stay in L2 cache, subsequent blocks might hit cache.
But on H100 with 50MB L2, and each block needing 98KB of weights, we can fit about 512 block's worth of weights in L2. With 349K blocks, that's only 0.15% cache hit rate if we cycle through all j ranges before reusing.
Actually no, we process all j ranges for a given batch and channel block. If we process j sequentially, the weights for a given channel block can stay in L2 for all j blocks. But with 8 channel blocks * 98KB = 784KB, we can easily fit all weights for the current batch in L2!
Wait, if we process all j for a fixed (batch, channel_block) before moving to the next channel_block, then weights only need to be loaded once per channel_block. And 784KB easily fits in L2.
For inputs, each j block needs 16*192 = 3072 floats = 12KB. Total input across all j blocks for one batch: 174758*192 floats = 133MB. This doesn't fit in L2, so inputs are read from HBM.
So total memory traffic:
- Inputs: 64 batches * 133MB = 8.5GB (same as before)
- Weights: 8 channel blocks * 98KB * 64 batches = 50MB (loaded once each, basically negligible)
- Outputs: 5.7GB
- Total: ~14.2GB
Now, if we have each block handle 16 j positions and all 128 output channels (or 16 output channels and handle 128 j positions), we need to balance occupancy.
Let me try a kernel design:
Block dimensions: 256 threads
Grid: (j_blocks, 1, batch) where j_blocks = ceil(174758/16)
Each block:
- Has 256 threads
- Computes 16 j positions × 16 output channels (if we tile output channels too)
- Wait, 16*16 = 256 outputs, but we have 128 output channels. If each block does 16 channels, we'd need 8 blocks per j tile.
Let me do:
- blocks per j tile: 8 (for 8 channel groups of 16)
- Total blocks = ceil(174758/16) * 8 * 64
For each block (computing 16 j × 16 channels for 1 batch):
1. Load weights for 16 output channels: 16*64*3 = 3072 floats.
2. Load inputs for 16 j positions: need l from 3j0 to 3(j0+15)+8 = [3j0, 3j0+53], so 54 positions per c_in.
Total: 64*54 = 3456 floats.
3. Compute 16*16 = 256 dot products, each of length 192.
Shared memory:
- Input: 3456 floats = 13.8KB
- Weights: 3072 floats = 12.3KB
- Total: ~26KB. Very comfortable.
This gives us excellent shared memory usage and we can potentially have many blocks per SM.
For the computation, each output element needs 192 multiplies and 191 adds. So 256 outputs need 256*192 ≈ 49K FMAs.
At 67 TFLOPS, this is trivial. We're definitely memory bandwidth bound.
If we want even better performance, we can increase the tile size.
What if each block computes 32 j × 32 channels = 1024 outputs?
- Input: 32 j × 64 c_in × (3*32+8) ≈ 32*64*104 = 212,480 floats... wait that's wrong.
For 32 j positions, input range is [3j0, 3j0+96+8] = [3j0, 3j0+104], which is 105 positions.
Total input: 64*105 = 6720 floats = 26.9KB.
Weights: 32*64*3 = 6144 floats = 24.6KB.
Total SMEM: ~51.5KB. Still comfortable, maybe 4 blocks per SM = 2048 concurrent threads. Good.
Actually, even better: what about 64 j × 32 channels?
Input: 64 j, l range = [3j0, 3j0+192+8] = [3j0, 3j0+200], 201 positions.
64*201 = 12,864 floats = 51.4KB.
Weights: 32*64*3 = 6144 floats = 24.6KB.
Total: ~76KB. Still good, maybe 2-3 blocks per SM.
What about vectorized loads? For loading inputs, we need to load for each c_in, positions l = 3j0, 3j0+3, 3j0+6, ... for k=0.
Actually, for all k, the positions are:
For j0: {3j0, 3j0+4, 3j0+8}
For j0+1: {3j0+3, 3j0+7, 3j0+11}
...
If j0 is a multiple of some number, we might get contiguous groups.
Let me think about this differently. The set of all positions accessed by 16 consecutive j values:
{3j0, 3j0+3, 3j0+6, 3j0+9, ..., 3j0+45} ∪ {3j0+4, 3j0+7, 3j0+10, ..., 3j0+49} ∪ {3j0+8, 3j0+11, 3j0+14, ..., 3j0+53}
This is a union of 3 arithmetic sequences with difference 3. The union covers all integers from 3j0 to 3j0+53.
Wait! Let me verify:
{3j0, 3j0+3, ..., 3j0+45} = {x : x ≡ 3j0 (mod 3)} = all multiples of 3 in the range
{3j0+4, 3j0+7, ..., 3j0+49} = {x : x ≡ 3j0+1 (mod 3)} in the range?
3j0+4 = 3(j0+1)+1, so x ≡ 1 (mod 3) in range [3j0+1, 3j0+49]? Wait, 3j0+4, 3j0+7, ... = 3j0 + 4 + 3i. Mod 3, these are all 1.
{3j0+8, 3j0+11, ...} = 3j0 + 8 + 3i. Mod 3, these are all 2.
And the ranges:
k=0: from 3j0 to 3j0+45 (45 = 3*15)
k=1: from 3j0+4 to 3j0+49
k=2: from 3j0+8 to 3j0+53
Does this cover all integers from 3j0 to 3j0+53?
- 3j0: covered by k=0
- 3j0+1: is it covered? k=0 gives multiples of 3, k=1 gives ≡1, k=2 gives ≡2.
3j0+1 ≡ 1 (mod 3), so it should be in k=1 if it's in range [3j0+4, 3j0+49].
But 3j0+1 < 3j0+4, so NOT covered.
Actually wait. The first element of k=1 is for j=j0, which is 3j0+4. So positions 3j0+1 and 3j0+2 are not covered!
This makes sense because output elements don't use all input positions.
The covered positions are:
{3j0, 3j0+3, ..., 3j0+45} (multiples of 3)
{3j0+4, 3j0+7, ..., 3j0+49} (≡1 mod 3)
{3j0+8, 3j0+11, ..., 3j0+53} (≡2 mod 3)
Missing positions: 3j0+1, 3j0+2, 3j0+5, 3j0+6? Wait:
3j0+5 ≡ 2, but first element of k=2 is 3j0+8, so 3j0+5 is missing.
3j0+6 ≡ 0, and it's in k=0 (since k=0 covers 3j0, 3j0+3, 3j0+6, ...). Yes!
3j0+9: in k=0. Yes.
Actually let me be more careful. The missing positions from [3j0, 3j0+53] are:
- ≡1 mod 3: 3j0+1 (missing), 3j0+4 (present), 3j0+7 (present), ..., 3j0+49 (present)
Only 3j0+1 is missing from this residue class in the range.
- ≡2 mod 3: 3j0+2 (missing), 3j0+5 (missing), 3j0+8 (present), ..., 3j0+53 (present)
3j0+2 and 3j0+5 are missing.
So out of 54 positions, 3 are missing. 51 positions are actually used.
For the 16 j values, we have 51 input positions per c_in, not 54. Total: 64*51 = 3264 floats = 13KB.
Still, the positions are very interleaved. Loading them into shared memory efficiently requires careful indexing.
Actually, maybe the simplest and most efficient approach is to NOT use shared memory for inputs, but rather to read directly from global memory. The L1/L2 cache on H100 is quite good, and the memory controller handles strided access patterns better than we can with explicit shared memory management for this specific case.
For direct global memory access:
- Thread computing (b, c_out, j) reads:
- input[b, 0, 3j], input[b, 0, 3j+4], input[b, 0, 3j+8]
- input[b, 1, 3j], input[b, 1, 3j+4], input[b, 1, 3j+8]
- ...
- input[b, 63, 3j], input[b, 63, 3j+4], input[b, 63, 3j+8]
- weight[c_out, 0, 0], weight[c_out, 0, 1], weight[c_out, 0, 2]
- ...
For consecutive threads computing the same c_out and j = j0, j0+1, j0+2, ...:
- Input reads are strided by 3 in the L dimension.
- Cache line is 64 bytes = 16 floats.
- If thread i reads input[b, c, 3(j0+i)], cache line starting at 3(j0+i) covers 16 floats.
- Only thread i reads position 3(j0+i) (for each c). So each cache line serves only 1 thread, but the line contains 16 floats, and the next 5 positions (3(j0+i)+1 through 3(j0+i)+15) might be used by other threads for different c or j...
Actually, for warp reading input[b, c=0, 3j0], input[b, c=0, 3(j0+1)], etc.:
Each thread reads one float from a different cache line (since consecutive reads are spaced by 3 floats = 12 bytes, and cache line is 64 bytes, so consecutive threads hit positions 0, 12, 24, 36, 48, 60 in different cache lines, or possibly the same cache line if they wrap around).
Positions accessed by warp for c=0, k=0:
3j0, 3j0+3, 3j0+6, ..., 3j0+93.
In memory offsets: base + 3j0, base + 3j0 + 3, ...
Dividing by 64 bytes per cache line:
If 3j0 is at offset X in a cache line, then:
thread 0: cache line floor((base+3j0)/64) + offset = base+3j0 mod 64
thread 1: base+3j0+3
thread 2: base+3j0+6
...
thread 21: base+3j0+63 (at end of first cache line)
thread 22: base+3j0+66 (start of next cache line)
So first 22 threads might hit 2-3 cache lines (because 3j0 mod 64 determines the starting point), and the remaining 10 threads hit other cache lines. Total cache lines touched: ceil(96/64) = 2 for the first 22, and similar for the rest. For 32 threads, it's about 2-3 cache lines for each c, k combination.
This isn't great, but it's also not terrible. In practice, H100 has very good memory bandwidth and can handle this.
Hmm, actually, I wonder if we can reorder the computation to improve memory access. What if we assign threads so that consecutive threads compute outputs for j values that are spaced apart by 16 or something, so their input accesses fall in the same cache line?
Wait, I had another idea. What if we transpose the problem?
For fixed c_in, the mapping from j to inputs is:
j=0: l=0, 4, 8
j=1: l=3, 7, 11
j=2: l=6, 10, 14
...
For a fixed c_in, if consecutive threads compute j values that differ by 16, the input accesses differ by 48, which is 3 cache lines. That doesn't help.
What if we have each thread process multiple j values sequentially and the warp processes a contiguous block of j?
Actually, I think the real issue is that we can't get perfect coalescing for strided access with stride 3. But maybe we can use vectorized loads.
For example, can we load float4 from input? If j values are chosen so that 4 consecutive j's have inputs that form a contiguous block?
For k=0: j=0,1,2,3 have l=0,3,6,9. Not contiguous.
For k=1: l=4,7,10,13. Not contiguous.
For k=2: l=8,11,14,17. Not contiguous.
However, what if we look at blocks of j=0..15:
For k=0: l=0,3,6,...,45
For k=1: l=4,7,10,...,49
For k=2: l=8,11,14,...,53
Notice that these are exactly the positions 0,3,6,9,12,15,18,21,24,27,30,33,36,39,42,45,4,7,10,13,16,19,22,25,28,31,34,37,40,43,46,49,8,11,14,17,20,23,26,29,32,35,38,41,44,47,50,53.
These 48 values span 0 to 53, but with gaps at 1,2,5, etc. So they don't form contiguous blocks.
Hmm, but maybe we can just load the entire range [3j0, 3j0+53] into shared memory with vectorized float4 loads, ignoring the gaps. Then the loads ARE contiguous.
For a block handling 16 j values, we'd load float4 from 3j0 to 3j0+53 (which is 54 floats, so 13.5 float4, round to 14 float4 = 56 floats).
Wait, 54 floats = 13.5 float4. We can load 14 float4s covering positions 3j0 to 3j0+55, which is 216 bytes.
This is great! We only waste 2 floats (positions 3j0+54 and 3j0+55), but we get perfect coalescing for the loads.
Let me verify this trick works. For j in [j0, j0+15], we need positions:
3j0, 3j0+3, ..., 3j0+45 (k=0)
3j0+4, 3j0+7, ..., 3j0+49 (k=1)
3j0+8, 3j0+11, ..., 3j0+53 (k=2)
These are all in [3j0, 3j0+53].
If we allocate shared memory for positions [3j0, 3j0+55] (14 float4s), we can load it with coalesced vectorized loads.
Then, for output j = j0 + t, the three inputs we need are at:
- k=0: position 3j0 + 3t
- k=1: position 3j0 + 4 + 3t
- k=2: position 3j0 + 8 + 3t
And we can read them from shared memory easily.
This is a great optimization! Let me formalize:
**Kernel design with shared memory and vectorized loads:**
For each block:
- Computes 1 batch, `BLOCK_C_OUT` output channels, `BLOCK_J` consecutive j values.
- Thread block size = 256 (or other power of 2).
- Loads input segment [3j0, 3j0 + 3*BLOCK_J + 8 - 1] = [3j0, 3j0+3*BLOCK_J+7] into shared memory.
Actually check: for j from j0 to j0+BLOCK_J-1, max position is 3(j0+BLOCK_J-1)+8 = 3j0+3*BLOCK_J+5.
So we need [3j0, 3j0+3*BLOCK_J+5], which has 3*BLOCK_J+6 positions.
Wait, earlier I had j values 0..15 needing up to 53 = 3*15+8.
So range is 3*BLOCK_J - 3 + 8 + 1 = 3*BLOCK_J + 6 positions. That's 3*BLOCK_J+6.
For BLOCK_J=16: 3*16+6 = 54 positions. Earlier I calculated 51 actually-used positions.
Hmm, let me recalculate. For j in [0, 15]:
k=0: 0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42, 45 → 16 positions
k=1: 4, 7, 10, 13, 16, 19, 22, 25, 28, 31, 34, 37, 40, 43, 46, 49 → 16 positions
k=2: 8, 11, 14, 17, 20, 23, 26, 29, 32, 35, 38, 41, 44, 47, 50, 53 → 16 positions
Total: 48 positions.
Range: [0, 53], which is 54 positions.
But I counted 48 positions. So 6 positions in [0,53] are unused: 1, 2, 5, 52? Let me check:
0: used (k=0, j=0)
1: not used
2: not used
3: used (k=0, j=1)
4: used (k=1, j=0)
5: not used
6: used (k=0, j=2)
...
52: 52 = 3*16+4, which would be k=1, j=16, but j=16 is outside range.
Actually, is 52 used? 52 mod 3 = 1. For k=1: l = 4 + 3j. 52 = 4 + 3j => j = 16. Outside range [0,15].
For k=2: l = 8 + 3j. 52 = 8 + 3j => j = 44/3, not integer.
For k=0: l = 3j. 52 is not divisible by 3.
So 52 is not used!
53: used (k=2, j=15)
Unused: 1, 2, 5, and then... Let me check 50: 50 = 8 + 3*14, yes used. 51: not used (51 not div by 3, 51-4=47 not div by 3, 51-8=43 not div by 3? 43 not div by 3. So 51 not used).
Wait, let me be more careful. Position l is used if:
l ≡ 0 (mod 3) and 0 ≤ l ≤ 45 → j = l/3, which is in [0,15]. Covered.
l ≡ 1 (mod 3) and l = 4 + 3j for j ∈ [0,15] → l ∈ [4, 49]. So l ≡ 1 and 4 ≤ l ≤ 49.
l ≡ 2 (mod 3) and l = 8 + 3j for j ∈ [0,15] → l ∈ [8, 53]. So l ≡ 2 and 8 ≤ l ≤ 53.
For l ∈ [0, 53]:
- l ≡ 0: l ∈ {0,3,6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51}. All these are in [0,53]. But j = l/3 must be ≤ 15, so l ≤ 45. So l ∈ {0,3,6,9,12,15,18,21,24,27,30,33,36,39,42,45}. l=48,51 not used.
- l ≡ 1: l ∈ {1,4,7,10,13,16,19,22,25,28,31,34,37,40,43,46,49,52}. Need 4 ≤ l ≤ 49. So l ∈ {4,7,10,13,16,19,22,25,28,31,34,37,40,43,46,49}. l=1,52 not used.
- l ≡ 2: l ∈ {2,5,8,11,14,17,20,23,26,29,32,35,38,41,44,47,50,53}. Need 8 ≤ l ≤ 53. So l ∈ {8,11,14,17,20,23,26,29,32,35,38,41,44,47,50,53}. l=2,5 not used.
Total used: 16 + 16 + 16 = 48 positions.
Unused: {1, 2, 48, 51, 52, 5} = {1, 2, 5, 48, 51, 52}. That's 6 positions.
So for BLOCK_J=16, we load 54 floats but only use 48. That's 89% utilization, which is quite good.
If we use float4 loads, 54 floats requires 14 float4 (56 floats), so we load 56 but use 48. 86% utilization. Still good.
For larger BLOCK_J:
- If BLOCK_J=32, range is [3j0, 3j0+101], 102 positions.
- k=0: 32 positions (l = 3j, j ∈ [0,31])
- k=1: 32 positions (l = 4+3j, j ∈ [0,31], l ∈ [4,97])
- k=2: 32 positions (l = 8+3j, j ∈ [0,31], l ∈ [8,101])
- Total used: 96. Range: 102 positions.
- But: l ≡ 0 unused if l > 93 (j > 31? Actually j = l/3 ≤ 31, so l ≤ 93). So l ∈ {96, 99} are unused.
l ≡ 1 unused if l < 4 or l > 97. So l ∈ {1, 100} are unused in [0,101].
l ≡ 2 unused if l < 8 or l > 101. So l ∈ {2, 5} are unused in [0,101].
Wait, is l=101 used? 101 ≡ 2, 101 = 8+3j => j = 31. Yes.
So unused in [0,101]: {1, 2, 5, 96, 99, 100} = 6 positions.
Total used: 102 - 6 = 96.
Interesting! For any BLOCK_J, there are always 6 unused positions in the range. So utilization is (3*BLOCK_J) / (3*BLOCK_J + 6) = BLOCK_J / (BLOCK_J + 2).
For BLOCK_J=16: 16/18 = 88.9%. For BLOCK_J=32: 32/34 = 94.1%. For BLOCK_J=64: 64/66 = 97%.
This is very efficient for large BLOCK_J!
So the strategy is:
1. Use large BLOCK_J (e.g., 64 or 128)
2. Load a contiguous slice of input into shared memory with vectorized float4 loads
3. Compute outputs using the shared memory data
Now let's figure out BLOCK_J and BLOCK_COut.
For BLOCK_J=64:
- Input range: 3*64+6 = 198 positions per channel.
- For 64 channels: 64*198 = 12,672 floats = 50.7KB.
- This fits in shared memory.
For BLOCK_COut=8:
- Weights: 8*64*3 = 1536 floats = 6KB.
- Total SMEM: ~57KB. Very comfortable.
For 256 threads per block:
- Can compute 64*8 = 512 outputs.
- Or if each thread computes 2 outputs, that's 512 outputs with 256 threads. Actually for larger tiles we can do more.
What about BLOCK_J=128?
- Input: 128*3+6 = 390 positions. 64*390 = 24,960 floats = 100KB.
- BLOCK_COut=8, weights=6KB.
- Total SMEM: 106KB. Still very comfortable.
For 256 threads computing 128*8 = 1024 outputs, each thread computes 4 outputs.
Actually, let's do BLOCK_J=64 and BLOCK_COut=16:
- Input SMEM: 64*198*4 = 50.7KB
- Weight SMEM: 16*64*3*4 = 12.3KB
- Total: 63KB. Still good.
Outputs per block: 64*16 = 1024.
With 256 threads, each thread computes 4 outputs.
This gives us:
- Grid x: ceil(174758 / 64) = 2731
- Grid y: ceil(128 / 16) = 8
- Grid z: 64
- Blocks: 2731*8*64 = 1,398,272
- Threads: 256 per block
- Total threads launched: 358M (reasonable)
Actually, with this many blocks and only 174758/64 ≈ 2731 j tiles, we process all j tiles. Grid z = 64 batches.
Hmm, but 2731 * 64 = 174,784, and output length is 174,758, so the last tile will have 174758 - 64*2730 = 174758 - 174720 = 38 valid elements. We need to handle padding in the kernel.
This kernel design should work well. Let me refine it.
**Kernel Algorithm:**
For each block (bj, bc, bb):
- j_start = bj * BLOCK_J
- c_start = bc * BLOCK_C_OUT
- b = bb
1. Compute j_end = min(L_out, j_start + BLOCK_J)
Compute actual number of j values: J = j_end - j_start
2. Load input into shared memory:
For all c_in in [0, in_channels) and l in [3*j_start, 3*j_start + 3*J + 5]:
shared_input[c_in, l - 3*j_start] = input[b, c_in, l]
But we only need to load up to the input length. And we need a strategy to parallelize this across threads.
If BLOCK_J=64 and in_channels=64, the input slice has 64*198 = 12,672 elements.
With 256 threads, each thread loads about 49.5 elements.
We can have each thread load consecutive float4s.
For thread_idx in [0, 255]:
offset = thread_idx * 4 (loading 4 floats at a time as a float4)
While offset < total_input_elements:
Compute (c_in, l_local) from offset
l_global = l_local + 3*j_start
If l_global < L_in:
Load input[b, c_in, l_global]
offset += blockDim.x * 4
Wait, but l_global will exceed L_in for some positions. The contiguous range [3j_start, 3j_start + 3J + 5] might extend beyond input length for the last j tile.
For j_start = 174720, J = 38:
max l needed = 3*174720 + 3*38 + 5 = 524160 + 114 + 5 = 524279.
And input length is 524280, so 524279 < 524280, OK!
Wait, let me double check. For j = 174757:
l = 3*174757 + 8 = 524271 + 8 = 524279.
And input length is 524280 (indices 0..524279), so l=524279 is valid.
For j_start = 174720, J = 174758 - 174720 = 38.
Max l = 3*(174720+37) + 8 = 3*174757 + 8 = 524279. Good.
But for j_start = 174784 (if we had that), it would be out of bounds. Since we have 2731 blocks for 64-tile: 2730 full tiles + 1 partial.
2730 * 64 = 174720. So block 2730 has j_start = 174720, J = 38.
What about the end of the loaded range for this block?
Range = [3*174720, 3*174720 + 3*38 + 5] = [524160, 524160 + 114 + 5] = [524160, 524279].
Input length is 524280, so positions 524160 to 524279 are all valid. Good!
So for this specific problem, we don't need to check bounds for the input loads IF we allocate exactly the right range. But we always need bounds for safety.
3. Load weights into shared memory:
weights: (out_channels, in_channels, 3)
For this block: weights[c_start : c_start + BLOCK_C_OUT, :, :]
Total: BLOCK_C_OUT * in_channels * 3 = 16 * 64 * 3 = 3072 floats.
Load with 256 threads, each loads ~12 floats. We can use float4.
4. Synchronize threads.
5. Compute outputs:
Each thread computes output elements for specific (c_out, j) pairs.
We can assign threads using a 2D or 1D mapping.
Thread mapping:
thread_idx = threadIdx.x.
Each thread computes a slice of outputs.
Total outputs: J * BLOCK_C_OUT, where J ≤ BLOCK_J (64).
If J = 64 and BLOCK_C_OUT = 16, total = 1024 outputs.
With 256 threads, each thread computes 4 outputs.
Mapping:
For thread i (0 ≤ i < 256):
out_idx = i
While out_idx < J * BLOCK_C_OUT:
c_local = out_idx / J
j_local = out_idx % J
c_out = c_start + c_local
j = j_start + j_local
// Compute output[b, c_out, j]
sum = bias[c_out] if applicable else 0
for c_in in [0, 63]:
for k in [0, 2]:
l_local = 3*j_local + 4*k
val = shared_input[c_in * INPUT_SLICE_SIZE + l_local]
w = shared_weight[c_local * 64 * 3 + c_in * 3 + k]
sum += val * w
output[b, c_out, j] = sum
out_idx += 256
For J < BLOCK_J (last tile), some threads do less work, but that's OK.
This kernel should have:
- Good global memory coalescing for input loads (vectorized float4)
- Good global memory coalescing for weight loads (also vectorized)
- Very fast shared memory access for computation
- Minimal register pressure (small loops)
One concern: the inner loop over c_in and k. For each output, we do 64*3 = 192 iterations. With 4 outputs per thread, that's 768 inner loop iterations. The compiler should unroll the k loop.
For faster execution, we could unroll the k=3 loop manually.
Also, we should consider using float4 for shared memory loads too. But shared memory performance on H100 is very good, so element-wise access is fine.
Let me think about whether we should use shared memory for weights. Weights are only 98KB total, which fits easily in L2 cache. If we don't use shared memory for weights, we save SMEM and can potentially have more blocks per SM. But L2 access is still 10-20x slower than shared memory.
For H100:
- HBM bandwidth: 3.35 TB/s
- L2 bandwidth: ~10-20 TB/s
- Shared memory bandwidth: ~10-20 TB/s per SM
Given that each block computes 1024 outputs but needs 192*1024 = 196,608 multiply-adds. At this computation rate, the bottleneck is memory.
If we read weights from L2 instead of SMEM:
- Each output needs 192 weights.
- For 1024 outputs: 196,608 weight reads from L2.
- Weight size per block: 3072 floats = 12KB.
- If weights are in L2 (they should be, since only 98KB total), L2 bandwidth is ~15 TB/s, so 12KB / 15TB/s = 0.8 nanoseconds. Negligible.
- But each thread needs to read 192 weights for each of 4 outputs = 768 weight reads per thread.
- If all threads read the same weights simultaneously (broadcast), L2 handles it well.
So maybe we don't even need shared memory for weights! We can keep weights in registers or just read them from L2.
Similarly, inputs are read once by the block and stored in SMEM.
- Input per block: 12,672 floats = 50.7KB.
- At HBM bandwidth: 50.7KB / 3.35TB/s = 15 microseconds? No, that's wrong. 50.7KB / 3.35TB/s = 50.7e3 / 3.35e12 = 15.1e-9 seconds = 15 nanoseconds.
Wait, that's per block. But for 1.4M blocks, total is 50.7KB * 1.4M = 71GB just for inputs... but blocks are reading disjoint inputs, so total HBM traffic is 64 * 64 * 524280 * 4 = 8.6GB.
Actually I need to be more careful. The 15ns is how long it takes to load 50KB at peak bandwidth. But there are many blocks and they run in parallel.
The key metric is throughput: how much memory traffic vs arithmetic.
For each block:
- HBM reads: ~50KB inputs + ~0.1 KB weights (if cached) = ~50KB
- HBM writes: ~4KB outputs
- Compute: 1024 * 192 = 196K FMAs
Compute intensity: 196K FMA / 54KB = 3.6 FMAs/byte.
H100 can do 67 TFLOPS with 3.35 TB/s = 20 FLOPs/byte peak.
So we're about 5.5x below compute-bound. Very memory-bound.
For memory-bound kernels, the most important thing is to saturate memory bandwidth. This requires:
1. Enough concurrent memory transactions
2. Good coalescing
3. Enough threads/warps to hide latency
With 256 threads per block and ~1.4M blocks, we should have plenty of concurrency.
If we keep weights in L2 (no SMEM), we save shared memory and might get more blocks per SM. But the L2 bandwidth is still very high.
Let me think about this: if we DON'T use shared memory for weights, we can have smaller SMEM footprint (only 50KB for inputs), allowing maybe 4 blocks per SM = 1024 threads. But L2 bandwidth per SM is also limited.
If we DO use shared memory for weights, SMEM is 63KB, allowing maybe 3 blocks per SM = 768 threads. But we get SMEM bandwidth for weights instead of L2.
I think using shared memory for both is fine on H100 given the large SMEM capacity.
Actually, another optimization: instead of shared memory for weights, we can load weights into registers. Each thread handles 1 or more output channels. If a thread handles 1 output channel, it needs 192 weights. With 4 outputs (4 channels), it needs 768 weights, which is too many for registers.
If a thread handles 4 outputs for the SAME channel, it still only needs 192 weights. So we should assign threads to compute multiple j values for a single c_out, rather than multiple c_out values.
Thread mapping optimized for register usage:
- Each thread computes TJ = 4 (or 8) consecutive j values for ONE output channel.
- Block handles BLOCK_C_OUT = 16 channels and BLOCK_J = 64 j values.
- Threads: 16 channels * (64/4) = 16 * 16 = 256 threads. Good!
- Each thread needs 192 weights in registers. That's 192 floats = 768 bytes = 192 registers. Manageable.
Actually wait, can the compiler optimize this? If we have a loop:
for c_in in [0, 63]:
for k in [0, 2]:
w = weights[c_out, c_in, k]
for t in [0, 3]: // 4 j values
val = shared_input[c_in, 3*t + 4*k]
sum[t] += w * val
This way, each w is loaded once and used for 4 j values. This reduces weight reads by 4x!
And if we do this, weights can stay in registers (or be loaded once per c_in,k). For 64 channels * 3 k values = 192 weights, loaded sequentially. That's efficient.
Let me redo the mapping:
- Block: 16 channels (c_start to c_start+15), 64 j values (j_start to j_start+63), 1 batch.
- Threads: 256.
- Thread (c_local, t): c_local in [0,15], t in [0,15], where t handles 4 consecutive j values.
Total: 16 * 16 = 256 threads.
- Thread computes 4 outputs: j = j_start + t*4 + offset, offset in [0,3].
Algorithm per thread:
float sums[4] = {bias if applicable};
for c_in = 0 to 63:
for k = 0 to 2:
float w = weights[c_start + c_local, c_in, k];
for offset = 0 to 3:
int j_local = t*4 + offset;
int l_local = 3*j_local + 4*k;
float val = shared_input[c_in * slice_size + l_local];
sums[offset] += w * val;
for offset = 0 to 3:
int j = j_start + t*4 + offset;
if (j < L_out):
output[b, c_start + c_local, j] = sums[offset];
This is excellent!
- Weight read: 192 per thread, but each weight is used 4 times.
- Input read: 192*4 = 768 per thread.
- Total operations: 768 FMAs per thread.
- For 256 threads: 196,608 FMAs per block.
And register usage:
- sums[4] = 4 registers
- w = 1 register
- val = 1 register
- Loop indices = a few registers
- weights might be loaded on the fly, so 1 register.
- But the compiler will unroll offset loop, so it might use more.
Actually for the inner offset loop:
sums[0] += w * val0;
sums[1] += w * val1;
sums[2] += w * val2;
sums[3] += w * val3;
If unrolled, val0-val3 are separate, but they can be loaded sequentially and reused immediately. The compiler should optimize this.
Register estimate: ~20-30 registers per thread. Very comfortable.
Now, shared memory for inputs:
slice_size = 3*64 + 6 = 198 (or 200 for alignment).
shared_input: [64][200] floats.
64 * 200 * 4 = 51.2 KB.
With 51.2 KB SMEM per block, we can have up to 4 blocks per SM (on H100, 228KB / 51KB = 4.4). So 4 blocks per SM = 1024 threads per SM. With 132 SMs, total concurrent threads = 1024 * 132 = 135K threads.
Actually, this might not be enough to saturate the GPU. Wait, H100 has 132 SMs? Let me check. H100 SXM has 132 SMs? No, I think H100 has 132 SMs? Let me assume ~100+ SMs.
Each SM can run up to 2048 threads concurrently (on modern GPUs).
If we have 4 blocks per SM = 1024 threads, that's 50% occupancy. Not ideal for latency hiding.
To increase occupancy, we could:
1. Reduce SMEM usage per block (already at minimum for this design)
2. Increase block size (currently 256, could go to 512 or 1024)
3. Reduce shared memory usage by not using it (store inputs in L2 cache directly)
If we don't use shared memory and just read inputs from L2/HBM directly, each block has no SMEM usage, so we can have up to 32 blocks per SM (limited by thread block count). With 256 threads, that's 8192 threads per SM, but max is 2048, so 8 blocks per SM = 2048 threads.
Actually, let me look at the memory access pattern without shared memory:
Thread (c_local, t) computes 4 j values.
For c_in loop, k loop, offset loop:
l_global = 3*(j_start + t*4 + offset) + 4*k
Read input[b, c_in, l_global]
For a warp with c_local varying and t fixed:
Threads read different c_in values? No, c_in is in the outer loop, so at a given time all threads read the same c_in.
For fixed c_in, k, offset, threads read:
input[b, c_in, l_global] where l_global depends on c_local and t.
Wait, within a warp, threads have different c_local (0..15) but all have their own t values (0..15).
Actually, for a fixed k and offset, thread (c_local, t) reads:
input[b, c_in, 3*(j_start + t*4 + offset) + 4*k]
For threads with the same t but different c_local:
They read the SAME l_global but different c_in.
input[b, 0, l], input[b, 1, l], input[b, 2, l], ...
These are strided by length = 524280 floats = 2MB.
Terrible coalescing! Each warp would read 32 values from completely different memory pages.
But different threads in a warp have different c_local. If c_local varies 0..15 and there are 256 threads, we could have 16 threads per c_local value with different t values.
For threads with the same c_local but different t:
They read: input[b, c_local, 3*(j_start + t0*4 + offset) + 4*k]
and input[b, c_local, 3*(j_start + t1*4 + offset) + 4*k]
If t0 and t1 are consecutive, l_global differs by 12. Same coalescing issue as before.
So without shared memory, the memory access for inputs is terrible unless we rearrange threads.
**Alternative: Make threads in a warp have the same c_local and consecutive t.**
In this case, thread i (within warp) handles c_local = warp_id_x, t = lane_id.
Then for fixed c_in, k, offset:
Thread lane reads input[b, c_local_fixed, 3*(j_start + lane*4 + offset) + 4*k]
Consecutive lanes read l values spaced by 12 bytes. Same issue as before.
What if we make consecutive threads handle the SAME output but different c_in contributions? Then each thread would need to do a partial sum and we'd need a reduction. That adds complexity.
Actually, for the no-SMEM approach, we can transpose the loop:
Instead of:
for c_in:
for k:
w = weight[c_out, c_in, k]
for j_local:
val = input[b, c_in, ...]
sum += w * val
We could do:
for j_local:
for k:
for c_in:
w = weight[c_out, c_in, k]
val = input[b, c_in, ...]
sum[j_local] += w * val
Then for fixed j_local, k, c_in loop:
Thread with c_local = c_out reads input[b, 0, l], input[b, 1, l], ...
Still terrible.
What if consecutive threads in a warp handle the same c_out and consecutive j, but we access input[b, c_in, l_global] for varying c_in?
For fixed j_local, k, and c_in varying:
Thread lane i reads input[b, i, l_global] where l_global is the same for all threads.
In memory: base + 0*524280 + l, base + 1*524280 + l, base + 2*524280 + l, ...
Stride: 2MB = 524280 floats.
This is terrible coalescing. But maybe L2 can handle it because we read 32 cache lines? Actually, each thread reads a different cache line, so the total bandwidth is 32 cache lines * 64 bytes = 2KB per warp. H100 can handle 32 concurrent cache line requests per warp, so it might still get decent throughput.
Actually, for modern GPUs, non-coalesced access is not as catastrophic as it used to be. The L1/L2 cache and memory subsystem can handle multiple concurrent requests, especially when they go to different cache lines. But the throughput will be lower than coalesced access.
For coalesced access, a warp can read a contiguous 128-byte segment in one transaction.
For strided access with 2MB stride, it's 32 separate transactions.
Bandwidth drops by a factor of up to 32x.
But maybe we can use shared memory to transpose the access.
OK let me stick with the shared memory approach. It's cleaner and gives guaranteed good performance.
For shared memory input loading, we have:
Total elements to load: 64 * (3*J + 6) where J ≤ 64.
Max: 64 * 198 = 12,672 elements.
With 256 threads, 49.5 elements per thread.
For perfect coalescing, we want threads to load contiguous elements.
Input layout in HBM: [batch, c_in, l] with c_in and l varying.
For a block, we need input[b, 0:c_in, l_start:l_end].
The elements are laid out as:
offset = b * (64 * 524280) + c_in * 524280 + l
For consecutive l: contiguous memory.
For consecutive c_in: separated by 524280.
To coalesce, have threads load consecutive l for the same c_in, or use a transposed pattern.
Loading strategy:
For thread_idx in [0, 255]:
base = thread_idx * VEC_SIZE (e.g., VEC_SIZE = 4 floats)
total = 64 * slice_size
for pos = base; pos < total; pos += blockDim.x * VEC_SIZE:
c_in = pos / slice_size
l_local = pos % slice_size
l_global = 3*j_start + l_local
if l_global < L_in:
load input[b, c_in, l_global]
For consecutive threads (e.g., thread 0, 1, 2, 3 for base = 0, 4, 8, 12):
They read:
thread 0: input[b, 0, l_start+0], [b, 0, l_start+1], [b, 0, l_start+2], [b, 0, l_start+3]
thread 1: input[b, 0, l_start+4], [b, 0, l_start+5], [b, 0, l_start+6], [b, 0, l_start+7]
...
These are contiguous! Perfect coalescing for float4 loads.
Great! This loading pattern works.
One issue: slice_size might not be a multiple of 4. For J=64, slice_size = 198. 198/4 = 49.5.
So the last float4 in each c_in row spans c_in and c_in+1 boundaries.
But we can pad slice_size to a multiple of 4, e.g., 200.
Then 64*200 = 12,800 elements.
thread 0: pos 0..3 = c_in=0, l_local=0..3
thread 1: pos 4..7 = c_in=0, l_local=4..7
...
thread 49: pos 196..199 = c_in=0, l_local=196..199
thread 50: pos 200..203 = c_in=1, l_local=0..3
With 256 threads and 12800 elements (3200 float4s), each thread loads 3200/256 = 12.5 float4s. Actually 3200 is divisible by 256? 3200 / 256 = 12.5. Not exactly.
3200 = 256 * 12 + 128. So some threads load 13, some load 12.
That's fine.
Actually, let me just use a while loop:
int tid = threadIdx.x;
int total_floats = in_channels * slice_size;
int total_vecs = (total_floats + 3) / 4;
for (int idx = tid; idx < total_vecs; idx += blockDim.x) {
int pos = idx * 4;
int c_in = pos / slice_size;
int l_local = pos % slice_size;
float4 val;
val.x = (l_local + 0 < actual_slice_size && pos + 0 < total_floats) ?
input[... + l_local + 0] : 0;
// Similar for y, z, w
shared_mem[idx] = val; // But this is complicated if we store as float array.
}
Actually, maybe simpler: just have each thread load individual floats, and rely on the hardware to batch memory requests. For 12,800 floats with 256 threads, each thread loads 50 floats. The compiler will likely use vectorized loads where possible.
Or we can explicitly use float4 and handle the padding.
Let me simplify: store shared memory as a linear float array of size [64][200].
Then for thread i:
for (int pos = i; pos < 64 * 200; pos += 256) {
int c_in = pos / 200;
int l_local = pos % 200;
int l_global = 3 * j_start + l_local;
if (c_in < 64 && l_local < slice_size && l_global < L_in) {
smem[pos] = input[b, c_in, l_global];
} else {
smem[pos] = 0;
}
}
Here, pos maps to consecutive memory in the global input (for fixed c_in and varying l_local).
Because input is contiguous in l, and consecutive threads have consecutive l_local (mod 200), the memory access is fairly coalesced.
Wait, there's a discontinuity when l_local wraps from 199 to 0 and c_in increments.
Thread reading pos=199: l_local=199, c_in=0
Thread reading pos=200: l_local=0, c_in=1
These are 524280 - 199 = 524081 floats apart in global memory!
That breaks coalescing for threads near the boundary.
A better assignment: have threads read by c_in, then l_local. Or interleave c_in and l_local.
Actually, since l_local ranges up to 198, and we pad to 200, we can have each warp read a contiguous chunk of memory, but the chunks for different c_in are far apart.
Hmm, we can load by c_in sequentially:
for c_in = 0 to 63:
for pos = threadIdx.x; pos < slice_size; pos += 256:
l_global = 3*j_start + pos
if (l_global < L_in):
smem[c_in * slice_size + pos] = input[b, c_in, l_global]
For a fixed c_in, all threads access input[b, c_in, l_global] with varying l_global. These are CONTIGUOUS!
For consecutive c_in, threads switch to a different c_in, but the same l_global pattern.
This gives perfect coalescing within each c_in iteration. There are 64 iterations, each loading up to 198 floats with 256 threads. Since 198 < 256, most threads are idle in each iteration, but the active threads have perfect coalescing.
Wait, only 198 threads are active per c_in, out of 256. That's 198/256 = 77% utilization for loading. Not great.
Alternative: use fewer threads for loading, or have each thread load float4.
Or, interleave c_in across warps.
For warp w (out of 8 warps in a 256-thread block):
c_in_start = w * 8 // 8 c_in per warp
For c_in = c_in_start to c_in_start + 7:
For pos = lane_id * 4; pos < slice_size; pos += 32 * 4:
Load float4 from input[b, c_in, 3*j_start + pos]
With slice_size = 198, each c_in needs ceil(198/4) = 50 float4s.
8 c_in per warp need 400 float4s.
32 lanes, each loads 12.5 float4s (rounding).
Each lane loads float4s for the same c_in at different positions.
For lane i and lane i+1:
Lane i loads pos = 4i, 4i+128, ...
Lane i+1 loads pos = 4(i+1), 4(i+1)+128, ...
These are contiguous! Perfect coalescing.
So this is a good loading strategy.
Let me refine:
- 8 warps, each handles 8 c_in values.
- For each c_in, load 50 float4s (covering 200 floats, but only 198 are used).
- Warp lanes 0..31 load float4s at positions 0, 128, 256 for lane 0.
Actually: lane i loads at 4i + 128k for k=0,1,...
slice_size_padded = 200.
200/4 = 50 float4s per c_in.
8 c_in = 400 float4s.
Each lane loads 400/32 = 12.5, so 13 for some, 12 for others.
This is getting complicated to write. Let me just write a simple kernel that uses float4 for loading and see how it performs.
Actually, I realize that even with simple element-wise loading, the L1/L2 cache on H100 is good enough that we might not need perfect coalescing for the loading phase. The total data per block is only ~50KB, and with 1.4M blocks, the loading is interleaved with computation.
Let me write a simple kernel first and then optimize if needed.
Wait, I should also consider whether a custom kernel is actually needed here. The reference is `nn.Conv1d` with the cuDNN backend. cuDNN is highly optimized. For this specific configuration (kernel=3, stride=3, dilation=4), cuDNN might use an implicit GEMM or Winograd approach.
However, with stride=3 and dilation=4, im2col would create a matrix of size (out_channels, in_channels*kernel_size) = (128, 192) multiplied by a matrix of size (in_channels*kernel_size, batch*output_length) = (192, 64*174758).
The second matrix has 192 * 11.2M = 2.15B elements. For im2col, constructing this matrix involves strided memory copies. The total im2col data might be larger than the input itself, increasing memory traffic.
Direct convolution avoids this extra memory traffic. So a well-written custom direct kernel could outperform cuDNN's GEMM-based approach.
Also, cuDNN might not have a perfectly optimized direct kernel for this exact configuration, especially with dilation. It might fall back to a generic implementation.
Let me write the kernel and test.
**Implementation details:**
I'll define a CUDA kernel with these parameters:
- BLOCK_J = 64
- BLOCK_C_OUT = 16
- Threads per block = 256
- SMEM input padded to 200 per channel
Actually, I realize that I could further optimize by using constant memory for weights. Since weights are only 98KB and are read by all blocks, constant memory could be very effective. But constant memory is limited to 64KB on most GPUs. H100 might have more, but it's risky.
Alternatively, weights can be loaded into registers as I described, which works well.
Let me write the kernel in C++/CUDA and embed it in Python using `torch.utils.cpp_extension.load_inline`.
Actually, I need to be very careful about a few things:
1. Bias handling: the reference adds bias if `bias=True`.
2. Numerical precision: use float (fp32) throughout.
3. Correctness with partial tiles.
For the partial tile case: j_start + J_actual where J_actual < BLOCK_J.
- We need to make sure the input loading and output writing handle this correctly.
- For input loading: only load up to max_l = 3*(j_start + J_actual - 1) + 8 = 3*j_start + 3*J_actual + 5.
- Actually, let me define: actual_l_count = 3*(J_actual - 1) + 9? Wait.
For J_actual values of j, the first output is j_start and the last is j_start + J_actual - 1.
Max l = 3*(j_start + J_actual - 1) + 8 = 3*j_start + 3*J_actual - 3 + 8 = 3*j_start + 3*J_actual + 5.
But wait, for output j_start, we need l = 3*j_start, 3*j_start+4, 3*j_start+8.
For output j_start + J_actual - 1, we need l = 3*(j_start+J_actual-1), 3*(j_start+J_actual-1)+4, 3*(j_start+J_actual-1)+8.
Total range: [3*j_start, 3*(j_start+J_actual-1)+8] = [3*j_start, 3*j_start + 3*J_actual + 5].
Number of positions in range: 3*J_actual + 6.
For J_actual = 64: 3*64 + 6 = 198. Correct.
For J_actual = 1: 3*1 + 6 = 9 positions.
Actually for J=1, j=j_start, l in {3j_start, 3j_start+4, 3j_start+8}. That's 3 positions.
But with our formula 3*1+6=9. Why 9?
Let me recheck. For J values starting at j_start, the positions are:
k=0: 3j_start, 3j_start+3, ..., 3j_start+3(J-1)
k=1: 3j_start+4, 3j_start+7, ..., 3j_start+3(J-1)+4
k=2: 3j_start+8, 3j_start+11, ..., 3j_start+3(J-1)+8
Range: [3j_start, 3j_start+3(J-1)+8] = [3j_start, 3j_start+3J+5].
Number of integers: 3J+6.
For J=1: range is [3j_start, 3j_start+8], 9 integers.
Actually-used: {3j_start, 3j_start+4, 3j_start+8}. 3 integers.
Unused: 6 integers.
For J=38: range is [3j_start, 3j_start+119], 120 integers.
Used: 3*38 = 114 integers.
Unused: 6 integers.
So yes, 3J+6 positions in the range, 3J used, 6 unused.
For shared memory, I'll allocate slice_size = BLOCK_J * 3 + 6 = 198.
And pad to a multiple of 4 for float4: 200.
For actual_j (number of j values in this block), I'll load up to actual_slice = actual_j * 3 + 6 positions.
Actually, let me think about whether we need to zero-pad the unused positions in shared memory. We don't read them during computation, so we don't need to zero them. But we DO need to zero the positions beyond actual_slice to avoid out-of-bounds accesses during computation.
Wait, during computation, each thread computes j_local from 0 to actual_j-1. And l_local = 3*j_local + 4*k, where k ∈ {0,1,2}.
For j_local = actual_j-1: l_local_max = 3*(actual_j-1) + 8 = 3*actual_j + 5.
So l_local ranges [0, 3*actual_j+5].
And actual_slice = 3*actual_j + 6 positions (indices 0 to 3*actual_j+5).
So all computed l_local values are within actual_slice. No out-of-bounds if actual_slice >= 3*actual_j+5.
For actual_j >= 1, 3*actual_j+6 > 3*actual_j+5. So we're fine.
But wait, what if we use a fixed slice_size of 200 for shared memory?
For actual_j = 1, actual_slice = 9, so we load 9 positions and the rest (up to 200) can be left uninitialized. As long as j_local only goes up to actual_j-1=0, l_local only goes up to 8, so we never touch positions 9..199. Good!
For actual_j = 64, actual_slice = 198, so we load 198 positions. j_local goes up to 63, l_local up to 197. We never touch positions 198..199. Good!
So we can safely allocate 200 positions per c_in and only load the needed ones.
Let me now write the CUDA code.
Wait, I also need to handle the case where input length is not what we expect. But the kernel will get L_in, L_out, etc. as parameters.
Actually, I realize I should just write the kernel and test. If there's an issue, I'll fix it.
Let me also think about whether I should use `__restrict__` for the pointers, and whether I should use float4 for loading.
For float4 loading of input:
```cuda
float4* input_vec = reinterpret_cast<float4*>(input + input_offset);
```
But since slice_size might not be divisible by 4, and the input layout doesn't have padding between c_in, we can't simply load float4 from `input[b, c_in, l_start]` because the next float4 might cross into `input[b, c_in+1, ...]`. Actually, since `l_start` can be any value, and each c_in channel has length `L_in`, loading a float4 from `input[b, c_in, l]` will read `l, l+1, l+2, l+3`. If `l+3 < L_in`, this is fine. For our slice, l ranges up to `l_start + slice_size - 1`, and `l_start + slice_size - 1 + 3` might exceed L_in. For the last c_in, it might exceed the tensor bounds.
So float4 loading from global memory requires careful bounds checking. We can do it for the main body and handle the remainder with scalar loads.
Actually, for simplicity and correctness, let me just use scalar loads for now. PyTorch's default memory allocator ensures alignment for the first element of each channel, but not necessarily for arbitrary l_start. Scalar loads might be slightly slower but much safer.
If performance is not good enough, I can optimize the loading later.
Now let me draft the Python and CUDA code.
```python
import torch
import torch.nn as nn
from torch.utils.cpp_extension import load_inline
# CUDA source
cuda_source = """
#include <torch/extension.h>
#include <cuda_runtime.h>
#define BLOCK_J 64
#define BLOCK_C_OUT 16
#define THREADS_PER_BLOCK 256
#define SMEM_SLICE (BLOCK_J * 3 + 6) // 198
#define SMEM_SLICE_PAD 200 // padded to multiple of 4
__global__ void conv1d_kernel(
const float* __restrict__ input,
const float* __restrict__ weight,
const float* __restrict__ bias,
float* __restrict__ output,
int batch_size,
int in_channels,
int out_channels,
int L_in,
int L_out,
int kernel_size,
int stride,
int dilation) {
int b = blockIdx.z;
int c_block = blockIdx.y;
int j_block = blockIdx.x;
int c_start = c_block * BLOCK_C_OUT;
int j_start = j_block * BLOCK_J;
if (b >= batch_size || c_start >= out_channels) return;
int actual_j = min(BLOCK_J, L_out - j_start);
if (actual_j <= 0) return;
// Shared memory
__shared__ float s_input[BLOCK_C_OUT > 0 ? in_channels * SMEM_SLICE_PAD : 1]; // trick to avoid zero-size array
__shared__ float s_weight[BLOCK_C_OUT * in_channels * 3];
int tid = threadIdx.x;
int slice_size = actual_j * 3 + 6;
// Load input into shared memory
// Each c_in has slice_size elements in smem
int total_input = in_channels * slice_size;
for (int idx = tid; idx < total_input; idx += THREADS_PER_BLOCK) {
int c_in = idx / slice_size;
int l_local = idx % slice_size;
int l_global = j_start * stride + l_local; // j_start * 3 + l_local
if (c_in < in_channels && l_global < L_in) {
s_input[c_in * SMEM_SLICE_PAD + l_local] = input[
b * in_channels * L_in + c_in * L_in + l_global
];
} else {
s_input[c_in * SMEM_SLICE_PAD + l_local] = 0.0f;
}
}
// Load weights for this block's output channels
int total_weight = min(BLOCK_C_OUT, out_channels - c_start) * in_channels * 3;
for (int idx = tid; idx < total_weight; idx += THREADS_PER_BLOCK) {
int c_local = idx / (in_channels * 3);
int rem = idx % (in_channels * 3);
int c_in = rem / 3;
int k = rem % 3;
s_weight[idx] = weight[
(c_start + c_local) * in_channels * 3 + c_in * 3 + k
];
}
__syncthreads();
// Compute outputs
// Each thread computes 4 consecutive j values for 1 output channel
// Mapping: threadIdx.x -> (c_local, t) where t handles j_local = t*4 to t*4+3
// Total: BLOCK_C_OUT * (BLOCK_J / 4) = 16 * 16 = 256 threads
int c_local = tid / (BLOCK_J / 4);
int t = tid % (BLOCK_J / 4);
if (c_local < (out_channels - c_start)) {
float sums[4] = {0.0f, 0.0f, 0.0f, 0.0f};
int j_base = t * 4;
for (int c_in = 0; c_in < in_channels; ++c_in) {
for (int k = 0; k < 3; ++k) {
float w = s_weight[c_local * in_channels * 3 + c_in * 3 + k];
#pragma unroll
for (int offset = 0; offset < 4; ++offset) {
int j_local = j_base + offset;
if (j_local < actual_j) {
int l_local = j_local * stride + k * dilation; // 3*j_local + 4*k
float val = s_input[c_in * SMEM_SLICE_PAD + l_local];
sums[offset] += w * val;
}
}
}
}
if (bias != nullptr) {
float bval = bias[c_start + c_local];
#pragma unroll
for (int offset = 0; offset < 4; ++offset) {
sums[offset] += bval;
}
}
#pragma unroll
for (int offset = 0; offset < 4; ++offset) {
int j_local = j_base + offset;
if (j_local < actual_j) {
int j = j_start + j_local;
output[
b * out_channels * L_out +
(c_start + c_local) * L_out +
j
] = sums[offset];
}
}
}
}
torch::Tensor conv1d_custom(
torch::Tensor input,
torch::Tensor weight,
c10::optional<torch::Tensor> bias,
int stride,
int dilation) {
int batch_size = input.size(0);
int in_channels = input.size(1);
int L_in = input.size(2);
int out_channels = weight.size(0);
int kernel_size = weight.size(2);
// Calculate output length
int L_out = (L_in - dilation * (kernel_size - 1) - 1) / stride + 1;
auto output = torch::empty({batch_size, out_channels, L_out}, input.options());
dim3 grid(
(L_out + BLOCK_J - 1) / BLOCK_J,
(out_channels + BLOCK_C_OUT - 1) / BLOCK_C_OUT,
batch_size
);
dim3 block(THREADS_PER_BLOCK);
const float* bias_ptr = bias.has_value() ? bias.value().data_ptr<float>() : nullptr;
conv1d_kernel<<<grid, block>>>(
input.data_ptr<float>(),
weight.data_ptr<float>(),
bias_ptr,
output.data_ptr<float>(),
batch_size,
in_channels,
out_channels,
L_in,
L_out,
kernel_size,
stride,
dilation
);
return output;
}
"""
# But wait, I'm not sure about the shared memory array trick.
# Let me use proper shared memory allocation.
functions = ["conv1d_custom"]
```
Wait, there are some issues:
1. The shared memory array `s_input` with dynamic-ish sizing won't compile as is. I need to either use static max size or `extern __shared__`. Since we know max is 64*200 = 12,800 floats, we can just declare it as `__shared__ float s_input[64*200];`. But if in_channels varies, we need flexibility. Actually, for this problem, in_channels is a constructor parameter and could vary. But the test uses in_channels=64. Let me make it work for any in_channels.
For general kernel, I should use `extern __shared__ float s_mem[];` and partition it manually. But for simplicity, since the kernel parameters are known at compile time for a given instantiation... actually no, they're runtime parameters.
Let me use `extern __shared__ float s_mem[];`:
```cuda
extern __shared__ float s_mem[];
float* s_input = s_mem;
float* s_weight = s_mem + in_channels * SMEM_SLICE_PAD;
```
But then I need to pass the shared memory size to the kernel launch.
```cuda
size_t smem_size = (in_channels * SMEM_SLICE_PAD + BLOCK_C_OUT * in_channels * 3) * sizeof(float);
conv1d_kernel<<<grid, block, smem_size>>>(...);
```
This is cleaner.
Also, I need to be careful about `BLOCK_C_OUT * in_channels * 3`. If out_channels - c_start < BLOCK_C_OUT, we still allocate the full block size, which is fine.
Let me also double-check the output length formula:
For Conv1d with padding=0:
L_out = floor((L_in + 2*padding - dilation*(kernel_size-1) - 1) / stride) + 1
= floor((L_in - dilation*(kernel_size-1) - 1) / stride) + 1
= (L_in - dilation*(kernel_size-1) - 1) / stride + 1 (integer division)
For L_in=524280, kernel_size=3, dilation=4, stride=3:
L_out = (524280 - 8 - 1) / 3 + 1 = 524271 / 3 + 1 = 174757 + 1 = 174758.
Actually, in C++ integer division: 524271 / 3 = 174757 (exact). Then + 1 = 174758. Correct.
One more thing: I should ensure the input and output tensors are contiguous. Or I can handle strides in the kernel. For simplicity, I'll assume contiguous, and in the Python wrapper I can use `.contiguous()` if needed.
Actually, let me not assume. I'll use tensor strides.
Wait, for this problem, the reference implementation `nn.Conv1d` does its own thing, and we just need to match numerically. The test inputs are contiguous by default. Let me ensure my kernel works with contiguous tensors.
Actually, to be safe, I should handle strides. Let me pass the tensor strides to the kernel.
Hmm, but that complicates the code. Since `torch.rand()` creates contiguous tensors by default, and the reference tests with `torch.rand()`, I'll assume contiguous for now.
Let me also make sure I handle the bfloat16/fp16 correctly. The problem says "at fp32 precision", so I'll cast everything to float.
Now let me think about the grid and block configuration more carefully.
Grid:
- x: (L_out + BLOCK_J - 1) / BLOCK_J
- y: (out_channels + BLOCK_C_OUT - 1) / BLOCK_C_OUT
- z: batch_size
For L_out=174758, BLOCK_J=64: grid.x = (174758 + 63) / 64 = 174821 / 64 = 2731.578... = 2732?
Wait: 174758 / 64 = 2730.59375. So ceil = 2731.
Wait let me recalculate:
174758 = 2730 * 64 + 38.
So (174758 + 63) / 64 = 174821 / 64 = 2731.578125... Hmm, integer division: 174821 / 64 = 2731 (since 2731*64 = 174784, 2732*64 = 174848).
Actually wait: (N + B - 1) / B is the standard ceiling formula.
(174758 + 63) / 64 = 174821 / 64 = 2731 (integer division).
2731 * 64 = 174784 > 174758. Correct.
And 2730 * 64 = 174720. The last block has j_start = 174720, actual_j = 38.
Grid.y = (128 + 15) / 16 = 143 / 16 = 8. Correct.
Grid.z = 64.
Total blocks = 2731 * 8 * 64 = 1,398,272.
Now, for thread mapping, I had:
c_local = tid / (BLOCK_J / 4) = tid / 16
t = tid % 16
For tid = 0..255:
c_local ranges 0..15, t ranges 0..15. Total 16*16 = 256 combinations. Good.
But what if out_channels - c_start < BLOCK_C_OUT? Say only 8 output channels in the last block (but 128 is divisible by 16, so this won't happen for out_channels=128). For generality, we handle it with the `if (c_local < (out_channels - c_start))` check.
Now a potential issue: `s_weight` indexing.
`s_weight[idx]` where `idx` goes up to `total_weight - 1`.
`total_weight = min(BLOCK_C_OUT, out_channels - c_start) * in_channels * 3`.
But `s_weight` is allocated as part of shared memory with size `BLOCK_C_OUT * in_channels * 3`.
And in the computation, we access `s_weight[c_local * in_channels * 3 + c_in * 3 + k]`.
Here `c_local` ranges from 0 to `min(BLOCK_C_OUT, out_channels - c_start) - 1`.
And `c_in` ranges 0 to `in_channels - 1`, `k` ranges 0 to 2.
So the max index is `(min(BLOCK_C_OUT, out_channels - c_start) - 1) * in_channels * 3 + (in_channels - 1) * 3 + 2`.
If `out_channels - c_start >= BLOCK_C_OUT`, then max index is `(BLOCK_C_OUT - 1) * in_channels * 3 + in_channels * 3 - 1 = BLOCK_C_OUT * in_channels * 3 - 1`. Exactly the allocation size. Good.
But wait, the weight loading loop only loads `total_weight` elements, not the full BLOCK_C_OUT * in_channels * 3. For blocks where `out_channels - c_start < BLOCK_C_OUT`, the remaining s_weight entries are uninitialized! But we only access them if `c_local < (out_channels - c_start)`, so we don't touch the uninitialized ones. Good.
Actually, I should also think about whether this design fully occupies the GPU.
Each block:
- SMEM: (in_channels * SMEM_SLICE_PAD + BLOCK_C_OUT * in_channels * 3) * 4 bytes
For in_channels=64: (64*200 + 16*64*3) * 4 = (12800 + 3072) * 4 = 63,488 bytes ≈ 62KB.
- Threads: 256
- Computation per block: ~196K FMAs
With 62KB SMEM, max blocks per SM = 3 (228KB / 62KB = 3.68). So 3 blocks per SM = 768 threads.
H100 occupancy: 768 / 2048 = 37.5%.
Is this enough? Maybe. The kernel is memory bandwidth bound, so we need enough warps to hide memory latency. 768 threads = 24 warps per SM. Each warp does ~768 FMAs and 768 shared memory reads. The shared memory latency is low (~20 cycles), so 24 warps should be enough.
But can we improve occupancy? If we double threads to 512, we'd need to either:
1. Double outputs per block (BLOCK_J=128 or BLOCK_C_OUT=32)
- BLOCK_J=128: SMEM = (64*400 + 16*64*3)*4 = (25600+3072)*4 = 114,688 bytes.
Max blocks: 1 (limited by 227KB, but maybe 2). Occupancy: 512/2048 = 25%. Worse.
- BLOCK_C_OUT=32: SMEM = (64*200 + 32*64*3)*4 = (12800+6144)*4 = 75,776 bytes.
Max blocks: 3. Occupancy: 3*512/2048 = 75%. Better!
But wait, the shared memory for inputs doesn't change when we increase BLOCK_C_OUT.
Actually, inputs are shared across all c_out, so increasing BLOCK_C_OUT uses more weight SMEM but same input SMEM.
And each thread computes fewer j values or more c_out values.
Let me try BLOCK_C_OUT=32 with 512 threads.
Thread mapping: c_local in [0,31], t in [0,15] for 512 threads.
Wait, 32*16 = 512 threads, but we need each thread to compute multiple outputs.
If BLOCK_J=64, and BLOCK_C_OUT=32, total outputs = 64*32 = 2048.
With 512 threads, each thread computes 4 outputs. Same as before.
The difference is that we load 32*64*3 = 6144 weights instead of 3072.
SMEM: 64*200*4 + 6144*4 = 51,200 + 24,576 = 75,776 bytes.
Blocks per SM: 3. Total threads: 3*512 = 1536.
Occupancy: 1536/2048 = 75%. Much better!
And we compute 2048 outputs per block instead of 1024, so fewer blocks total.
Grid.y = ceil(128/32) = 4.
Grid.x = ceil(174758/64) = 2731.
Grid.z = 64.
Total blocks = 2731 * 4 * 64 = 699,136. Half as many.
Would this be faster? It depends on how many SMs we have. For H100 with ~132 SMs, each running 3 blocks, that's 396 blocks concurrently. With 699K total blocks, that's ~1766 waves. Each wave processes 396 blocks.
The overhead for fewer blocks might help, but the main thing is that higher occupancy means better latency hiding.
However, higher SMEM usage might mean we can't fit as many blocks. 75KB is still fine for 3 blocks.
Actually, wait. H100 shared memory is 228KB per SM. But is "per block" limit still 227KB? Yes, max per block is 227KB. So 75KB fits.
For 75KB SMEM, max blocks per SM is 3 (limited by SMEM), since 4*75KB = 300KB > 228KB.
Actually, 228/75 = 3.04. So exactly 3 blocks per SM.
3 blocks * 512 threads = 1536 threads per SM.
Max threads per SM on H100 is 2048 (I believe, same as Ampere).
So 1536/2048 = 75% occupancy.
But I heard Hopper might support more. Let me check: H100 has 2048 threads per SM (same as A100).
Actually, I should verify this. I know Ampere has 2048. Hopper might also be 2048.
Hmm, some sources say H100 has up to 2048 threads per SM.
With 1536 threads, that's 48 warps per SM. Each warp has 32 threads, so 48 warps.
H100 supports up to 64 warps per SM (2048/32).
So 48/64 = 75% warp occupancy. Good.
Now, the other consideration: register pressure. With each thread computing 4 outputs (sums[4]), plus loop variables, plus a few temporaries, we might use ~32-64 registers.
512 threads * 64 registers = 32,768 registers per block.
3 blocks per SM = 98,304 registers.
H100 has 65,536 registers per SM. So we'd be limited to 2 blocks per SM if we use 64 registers!
Let me be more precise. If each thread uses R registers:
3 blocks * 512 threads * R <= 65536
R <= 65536 / 1536 = 42.7 registers per thread.
That's tight! If the compiler uses 48 registers, we only get 2 blocks per SM.
2 blocks * 512 = 1024 threads = 50% occupancy.
For the original design (256 threads, ~30 registers):
3 blocks * 256 * 30 = 23,040. Well under 65K.
We could even have 6 blocks if SMEM allowed, but SMEM limits us to 3.
3 blocks * 256 = 768 threads = 37.5% occupancy.
Comparing:
- Design A: 256 threads, 62KB SMEM, 768 concurrent threads (37.5% occupancy), ~30 registers
- Design B: 512 threads, 76KB SMEM, 1536 concurrent threads IF register-limited to 42 registers (75% occupancy)
Design B sounds better IF register usage stays under 43. But with the extra loop unrolling for BLOCK_C_OUT=32 and sums[4], the compiler might use more registers.
Actually, let me think about what a compiler would do.
For the compute kernel:
```cuda
float sums[4] = {0};
for (int c_in = 0; c_in < 64; c_in++) {
for (int k = 0; k < 3; k++) {
float w = s_weight[...];
#pragma unroll
for (int offset = 0; offset < 4; offset++) {
float val = s_input[...];
sums[offset] += w * val;
}
}
}
```
If `#pragma unroll` is applied to the offset loop, the compiler generates:
```cuda
float w = s_weight[...];
float val0 = s_input[...];
float val1 = s_input[...];
float val2 = s_input[...];
float val3 = s_input[...];
sums[0] += w * val0;
sums[1] += w * val1;
sums[2] += w * val2;
sums[3] += w * val3;
```
The vals can be reused immediately and their registers freed.
So we need: sums[4] + w + 1 temporary for val = 6 registers.
Plus index calculations and loop variables = maybe 10-15 more.
Total: ~20-25 registers. Well under 43.
If the compiler doesn't unroll (or only partially unrolls), it might keep the offset loop, which adds a couple more registers.
I think 25-30 registers is realistic.
Why not try both and see which is faster? I'll start with 256 threads (simpler, less register pressure) and test. Then I can try 512.
Actually, there's another design:
- BLOCK_J = 128, BLOCK_C_OUT = 8, 256 threads.
SMEM: 64*(128*3+6+2)*4 + 8*64*3*4 = 64*392*4 + 1536*4 = 100,352 + 6,144 = 106,496 bytes.
Too big, max 2 blocks per SM.
- BLOCK_J = 32, BLOCK_C_OUT = 32, 256 threads.
SMEM: 64*104*4 + 32*64*3*4 = 26,624 + 24,576 = 51,200 bytes.
4 blocks per SM = 1024 threads = 50% occupancy.
Seems middle ground.
Let me just go with Design A first and test. If it's too slow, I'll try others.
Actually, I want to think about one more thing: `s_weight` access pattern.
In the computation, threads with the same c_local but different t access the SAME weights.
For a warp with c_local varying (16 values) and t fixed:
Threads access s_weight[c_local * in_channels * 3 + c_in * 3 + k] for c_local in [0,15].
These are separated by in_channels * 3 = 192 floats.
Shared memory bank size on H100 is 4 bytes, and there are 32 banks.
Stride of 192 floats = 192 * 4 = 768 bytes.
768 / 4 = 192 bank slots.
192 mod 32 = 0.
So consecutive c_local values access the same bank! No wait:
Thread 0: offset 0
Thread 1: offset 192
Thread 2: offset 384
...
In bytes: 0, 768, 1536, ...
Banks: 0/4=0, 768/4=192 mod 32 = 0.
ALL threads in the warp access bank 0! That's a massive bank conflict!
Oh no! I need to fix this.
To avoid bank conflicts, threads in a warp should access addresses that map to different banks.
Bank = (address_in_bytes / 4) % 32.
For s_weight, if threads access c_local * 192 * 4 + ..., then:
Thread c_local: bank = (c_local * 192) % 32 = 0 for all c_local because 192 = 6*32.
So all 16 threads accessing different c_local values hit the SAME bank! This serializes the access into 16 separate transactions.
How can we fix this? We can pad s_weight or reorder it.
Option 1: Pad each channel's weights to a non-multiple-of-32 size.
For example, pad to 193 floats per (c_in, c_local) block? No, the weights are [c_out][c_in][k].
Actually, if we store weights as [c_in][k][c_out] instead of [c_out][c_in][k]:
For fixed c_in, k, and varying c_local: addresses are c_local * 4 bytes apart.
Bank = c_local % 32. No conflicts!
So we should transpose the weight storage in shared memory.
Weight layout options:
A. [c_out_local][c_in][k] - current, has bank conflicts
B. [c_in][k][c_out_local] - no bank conflicts for fixed c_in, k, varying c_out
C. [k][c_in][c_out_local] - similar to B
Option B is great! For the computation:
```cuda
for c_in:
for k:
// All threads in warp read different c_local at the same c_in, k
w = s_weight[c_in * 3 * BLOCK_C_OUT + k * BLOCK_C_OUT + c_local];
```
This accesses consecutive memory (stride = 4 bytes), so consecutive threads hit different banks. No bank conflict!
But wait, when loading weights from global memory, we need to handle the transposition.
Global memory layout: weight[c_out, c_in, k] = (c_out * in_channels * 3 + c_in * 3 + k).
Shared memory layout (transposed): s_weight[c_in * 3 * BLOCK_C_OUT + k * BLOCK_C_OUT + c_local].
Loading code:
```cuda
for idx = tid; idx < total_weight; idx += THREADS_PER_BLOCK:
c_local = idx / (in_channels * 3);
rem = idx % (in_channels * 3);
c_in = rem / 3;
k = rem % 3;
float val = weight[(c_start + c_local) * in_channels * 3 + c_in * 3 + k];
s_weight[c_in * 3 * BLOCK_C_OUT + k * BLOCK_C_OUT + c_local] = val;
```
This transposes the weights during loading. The load from global memory has the same pattern as before (strided by out_channels), but since it's done once per block and there are few writes, the bank conflicts in storing to SMEM are less critical.
Actually, storing to s_weight during loading:
Thread 0 writes to s_weight[0], thread 1 to s_weight[1], etc. (if we restructure the loop).
Wait, with the transposed layout, consecutive threads writing different global memory values to consecutive shared memory addresses... Let me think.
With the loop:
```cuda
for (int idx = tid; idx < total_weight; idx += THREADS_PER_BLOCK) {
int c_local = idx / (in_channels * 3);
int rem = idx % (in_channels * 3);
int c_in = rem / 3;
int k = rem % 3;
s_weight[c_in * 3 * BLOCK_C_OUT + k * BLOCK_C_OUT + c_local] = weight[...];
}
```
For consecutive threads with idx = 0, 1, 2:
- Thread 0: c_local=0, c_in=0, k=0. Writes to s_weight[0].
- Thread 1: c_local=0, c_in=0, k=1. Writes to s_weight[BLOCK_C_OUT].
- Thread 2: c_local=0, c_in=0, k=2. Writes to s_weight[2*BLOCK_C_OUT].
These are spaced by BLOCK_C_OUT floats, which is 16 * 4 = 64 bytes.
Banks: 0, 64/4=16, 128/4=32=0.
Thread 0 and thread 2 hit the same bank! That's a 2-way bank conflict for loading weights, assuming 32 threads.
Actually, let's think about warp 0 (threads 0..31):
idx = 0..31.
c_local = idx / 192 = 0 for all.
rem = idx.
c_in = rem / 3 = 0..10 (for idx=0..32, but idx only goes to 31).
For idx=30: c_in=10, k=0.
For idx=31: c_in=10, k=1.
Writes:
idx=0: c_in=0, k=0 → s_weight[0] = bank 0
idx=1: c_in=0, k=1 → s_weight[16] = bank 4
idx=2: c_in=0, k=2 → s_weight[32] = bank 0
idx=3: c_in=1, k=0 → s_weight[48] = bank 16
idx=4: c_in=1, k=1 → s_weight[64] = bank 0
idx=5: c_in=1, k=2 → s_weight[80] = bank 16
...
Actually let me compute banks more carefully:
s_weight offset = c_in * 3 * BLOCK_C_OUT + k * BLOCK_C_OUT + c_local
= c_in * 48 + k * 16 + c_local
For c_local = 0:
offset = c_in * 48 + k * 16
Bank = (offset * 4 / 4) % 32 = offset % 32.
c_in=0: k=0 → offset=0, bank=0
k=1 → offset=16, bank=16
k=2 → offset=32, bank=0
c_in=1: k=0 → offset=48, bank=16
k=1 → offset=64, bank=0
k=2 → offset=80, bank=16
c_in=2: k=0 → offset=96, bank=0
...
So for threads in a warp loading weights, many hit bank 0 and bank 16 alternately.
That's a 2-way conflict, which is acceptable.
For reading weights in the compute phase:
```cuda
w = s_weight[c_in * 3 * BLOCK_C_OUT + k * BLOCK_C_OUT + c_local];
```
For a fixed c_in, k, and varying c_local: offset = base + c_local.
Bank = (base + c_local) % 32.
Since c_local ranges 0..15 in a warp, and base depends on c_in and k:
If base % 32 = 0, then banks = 0..15, no conflicts!
If base % 32 = 1, banks = 1..16, no conflicts!
Always no conflicts because c_local values are consecutive!
Wait, but what if there are more than 16 c_local values and the warp handles more? In our case, BLOCK_C_OUT = 16, so a warp handles 16 different c_local values across different t? No, actually threads in a warp have varying tid, and c_local = tid / 16, t = tid % 16.
For warp (threads 0..31):
tid=0: c_local=0, t=0
tid=1: c_local=0, t=1
...
tid=15: c_local=0, t=15
tid=16: c_local=1, t=0
tid=17: c_local=1, t=1
...
tid=31: c_local=1, t=15
So a warp has 16 threads with c_local=0 and 16 threads with c_local=1.
These correspond to 2 different output channels.
When all threads read weights for fixed c_in, k:
Thread (c_local=0): offset = base + 0
Thread (c_local=1): offset = base + 1
...
Thread (c_local=15) is not in this warp, it's in another warp.
In a warp, half the threads read offset = base + 0 and half read offset = base + 1.
Wait, no: tid 0..15 all have c_local=0. They ALL read the SAME address: base + 0.
tid 16..31 all have c_local=1. They ALL read base + 1.
But in a warp, multiple threads reading the SAME address is a BROADCAST, not a bank conflict! Shared memory can broadcast a single value to multiple threads in a cycle.
Similarly, reading two different addresses (base+0 and base+1) in the same warp: these are in different banks, so no conflict. The hardware can serve up to 32 different banks per cycle, so two different addresses is trivial.
So the weight read is perfectly fine! No bank conflicts.
What about input reads from shared memory?
```cuda
val = s_input[c_in * SMEM_SLICE_PAD + l_local];
```
For a warp with threads having different t values but same c_local:
tid=0: c_local=0, t=0, j_base=0, offset=0, j_local=0, l_local=0
tid=1: c_local=0, t=1, j_base=4, offset=0, j_local=4, l_local=12
Wait, no. Let me recalculate.
For the offset loop, j_local = j_base + offset.
For tid=0 (c_local=0, t=0): computes j_local = 0..3.
For tid=1 (c_local=0, t=1): computes j_local = 4..7.
At a fixed c_in, k, offset=0:
tid=0: l_local = 3*0 + 4*k = 4k
tid=1: l_local = 3*4 + 4*k = 12 + 4k
tid=2: l_local = 3*8 + 4*k = 24 + 4k
...
tid=15: l_local = 3*60 + 4k = 180 + 4k
In a warp (32 threads), we might have:
tid 0..15: c_local=0, t=0..15 → l_local = 0, 12, 24, ..., 180 (for k=0)
tid 16..31: c_local=1, t=0..15 → l_local = 0, 12, 24, ..., 180 (for k=0)
So within a warp, threads read l_local values: 0, 12, 24, ..., 180 (from 16 threads with c_local=0) and 0, 12, 24, ..., 180 (from 16 threads with c_local=1).
Shared memory addresses:
For c_local=0, c_in=0: base = 0.
Address = 0 + l_local.
Banks: l_local % 32 = 0, 12, 24, 4, 16, 28, 8, 20, 0, 12, 24, 4, 16, 28, 8, 20, ...
Wait, let me list them for k=0:
tid=0: l_local=0, address=0, bank=0
tid=1: l_local=12, address=12, bank=12
tid=2: l_local=24, address=24, bank=24
tid=3: l_local=36, address=36, bank=4
tid=4: l_local=48, address=48, bank=16
tid=5: l_local=60, address=60, bank=28
tid=6: l_local=72, address=72, bank=8
tid=7: l_local=84, address=84, bank=20
tid=8: l_local=96, address=96, bank=0
tid=9: l_local=108, address=108, bank=12
tid=10: l_local=120, address=120, bank=24
tid=11: l_local=132, address=132, bank=4
tid=12: l_local=144, address=144, bank=16
tid=13: l_local=156, address=156, bank=28
tid=14: l_local=168, address=168, bank=8
tid=15: l_local=180, address=180, bank=20
tid 16..31 are reading from c_in=0 as well (they also start at base=0 for c_in=0, but they have c_local=1).
tid=16: l_local=0, address=0, bank=0
tid=17: l_local=12, address=12, bank=12
...
So banks hit: 0, 12, 24, 4, 16, 28, 8, 20 (from first 8 threads), then same again for next 8, then same again for 16..31.
Banks accessed: {0, 4, 8, 12, 16, 20, 24, 28}. Only 8 unique banks.
And bank 0 is hit by 4 threads (tid 0, 8, 16, 24).
Bank 12 is hit by 4 threads (tid 1, 9, 17, 25).
So we have 4-way bank conflicts! Oh no.
The issue is that for 32 threads, 16 of them read one set of l_local values (for c_local=0) and 16 read the SAME set of l_local values (for c_local=1). And within each group of 16, the l_local values repeat every 8 threads.
Actually wait, l_local values for t=0..15:
0, 12, 24, 36, 48, 60, 72, 84, 96, 108, 120, 132, 144, 156, 168, 180.
Banks (mod 32): 0, 12, 24, 4, 16, 28, 8, 20, 0, 12, 24, 4, 16, 28, 8, 20.
Yes, exactly 8 unique banks, each accessed by 2 threads in the same c_local group, and 4 threads total across both c_local groups.
So for 32 threads, we have a 4-way bank conflict for input reads. Not ideal.
How can we fix this?
Option A: Change the thread mapping.
Instead of having c_local vary across warps, have c_local be the same within a warp.
For example: warp 0 handles c_local=0, all 32 threads have c_local=0 but different t.
Then for input reading, all threads read different l_local values.
Banks: 0, 12, 24, 4, 16, 28, 8, 20, 0, 12, ... (16 values) for 32 threads with t=0..15 each appearing twice? No.
If all threads in a warp have the same c_local but different t:
tid 0..15: c_local=0, t=0..15
Wait, a warp has 32 threads. So we'd have t=0..15 appearing twice (for 2 different c_local values or just duplicated).
If we have BLOCK_C_OUT=16 and 16 warps, each warp handles one c_local across all t values.
But t only goes 0..15 (for BLOCK_J=64, computing 4 j values each = 64 j values).
So a warp with c_local fixed and t=0..15 has 16 threads. But a warp needs 32 threads.
We could have each thread compute 8 j values instead of 4, making t range 0..7.
Then 32 threads per warp all with the same c_local and t=0..7 handling 8 j values each = 64 j values.
Let me explore this mapping:
- BLOCK_J = 64, BLOCK_C_OUT = 16
- c_local = warp_id (0..15), t = lane_id (0..31)
- Each thread handles 2 j values (offset = 0, 1)
- j_base = t * 2, so threads have j_base = 0, 2, 4, ..., 62.
- Total j values covered: 32 threads * 2 = 64. Good!
- Total warps: 16 (for 16 c_local values).
- Threads per block: 16 * 32 = 512.
For the compute phase:
For fixed c_in, k, threads in warp read:
l_local = 3*(t*2 + offset) + 4*k for t=0..31, offset∈{0,1}.
For offset=0: l_local = 6t + 4k.
t=0: 4k, bank=4k
t=1: 6+4k, bank=6+4k
t=2: 12+4k, bank=12+4k
t=3: 18+4k, bank=18+4k
t=4: 24+4k, bank=24+4k
t=5: 30+4k, bank=30+4k
t=6: 36+4k, bank=4+4k
t=7: 42+4k, bank=10+4k
...
Banks for k=0: 0, 6, 12, 18, 24, 30, 4, 10, 16, 22, 28, 2, 8, 14, 20, 26, 0, 6, 12, 18, 24, 30, 4, 10, 16, 22, 28, 2, 8, 14, 20, 26.
All 32 threads hit distinct banks for offset=0!
For offset=1: l_local = 6t + 4k + 3, banks = (6t + 4k + 3) % 32.
Are these all distinct? For k=0: 3, 9, 15, 21, 27, 1, 7, 13, 19, 25, 31, 5, 11, 17, 23, 29, then repeats for lane 16..31.
Also all distinct!
So with this mapping, both offsets give no bank conflicts for input reads!
Let me extend this to k=1 and k=2:
For k=1, offset=0: l_local = 6t + 4, banks = (6t + 4) % 32.
t=0: 4, t=1: 10, t=2: 16, ..., all distinct within 0..15, then repeats for 16..31.
Wait, t=0 and t=16 both have lane in warp: bank for t=0 is 4, bank for t=16 is (96+4)%32 = 4. Conflict!
Because a warp has 32 lanes, but t only goes 0..15 and we need two t values per lane? No, with 512 threads and 16 warps, each warp has 32 threads, and t = lane_id (0..31).
Then j_base = t * 2 for t=0..31, so j_base = 0, 2, 4, ..., 62.
That covers 64 j values (each thread handles 2).
Wait, but j_base = t*2 where t=0..31 gives j_base=0,2,4,...,62. That's 32 values.
Each thread computes 2 j values: j = j_base + 0 and j_base + 1.
So j_local = 0,1,2,3,4,5,...,63. All 64 values covered. Good!
Then l_local = 3*j_local + 4*k = 3*(2t + offset) + 4k = 6t + 3*offset + 4k.
For offset=0: l_local = 6t + 4k.
For t in [0,31]: values are 4k, 6+4k, 12+4k, ..., 186+4k.
t=0 and t=16: 4k and 96+4k. 96 % 32 = 0, so same bank as 4k. Conflict!
So even with this mapping, t=0 and t=16 have the same bank for the same offset and k.
To avoid this, we need t to range such that 6t mod 32 are all distinct for t in a warp.
Since gcd(6, 32) = 2, the values 6t mod 32 take on only 16 distinct values as t ranges over 32 values.
In fact: t and t+16 give the same bank.
So any mapping where t ranges 0..31 and l_local depends linearly on t with even coefficient mod 32 will have 2-way conflicts.
What can we do?
Option 1: Map threads so that half warp (t=0..15) handles offset=0 and other half handles offset=1, but all with distinct t.
No, that doesn't help because t=0 and t=16 would be in different offsets but still same bank.
Option 2: Make l_local depend on thread ID with an odd coefficient mod 32.
For example, if j_base = t instead of 2t, then l_local = 3t (for offset=0, k=0).
3t mod 32 are all distinct because gcd(3,32)=1.
But j_base = t means each thread handles 1 j value, and we need 64 threads per c_local for 64 j values.
With 32 threads per warp, each warp handles 32 j values, and we need 2 warps per c_local.
Total warps = 16 c_local * 2 = 32 warps = 1024 threads per block.
Let me explore this:
- BLOCK_J = 64, BLOCK_C_OUT = 16
- c_local = blockwarp / 2 (0..15)
- subwarp = blockwarp % 2 (0 or 1)
- lane = threadIdx.x % 32 (0..31)
- t = subwarp * 32 + lane (0..63)
- j_local = t (0..63)
- Each thread computes exactly 1 output.
Then for fixed c_in, k, threads read:
l_local = 3*j_local + 4k = 3t + 4k.
Banks = (3t + 4k) % 32.
For t=0..63, 3t mod 32 takes all values in {0,1,...,31} exactly twice (since 64/32 = 2).
For a warp (32 threads with t in some range):
If warp handles t=0..31: banks = 3t+4k mod 32, all distinct (since 3 and 32 are coprime).
If warp handles t=32..63: banks = 3t+4k = 3(t-32) + 96+4k = 3(t-32) + 4k mod 32. Also all distinct.
But within a warp of 32 threads, if t ranges over 32 consecutive values, all banks are distinct!
No bank conflicts!
Wait, but there's a catch: in a warp, ALL threads have the same c_local. So the shared memory base address is the same for all threads.
And l_local = 3t + 4k where t=0..31 or 32..63.
For t in 0..31: banks = (3t + 4k) mod 32. Since 3 is invertible mod 32, these are all distinct.
For t in 32..63: banks = (3t + 4k) mod 32 = (3(t-32) + 96 + 4k) mod 32 = (3(t-32) + 4k) mod 32 (since 96 mod 32 = 0).
So t and t+32 give the SAME bank. But they're in different warps, so no conflict within a warp.
This is perfect!
For this mapping:
- BLOCK_C_OUT = 16, BLOCK_J = 64
- Threads per block = 16 * 2 * 32 = 1024 threads.
- Each thread computes 1 output.
- SMEM input: 64 * 200 * 4 = 51,200 bytes.
- SMEM weights: 16 * 64 * 3 * 4 = 12,288 bytes.
- Total SMEM: 63,488 bytes (same as before).
- Blocks per SM: 3. Total threads per SM: 3 * 1024 = 3072.
Wait, 3072 > 2048 (max threads per SM)! So we're limited to 2 blocks per SM = 2048 threads.
Occupancy: 2048/2048 = 100%!
But wait, 2 blocks * 1024 threads = 2048 threads. Register limit:
65,536 registers / 2048 threads = 32 registers per thread.
That's tight! With 32 registers per thread, can the compiler fit everything?
Actually, let me check if H100 supports more than 2048 threads per SM. I actually don't know for sure. A100 supports 2048. H100 might also be 2048. Some newer architectures have 1536 or 2048.
If it's 2048, then 2 blocks * 1024 threads = exactly 2048, giving 100% occupancy IF register limit allows.
But 32 registers per thread is very tight for a kernel with multiple array accesses and loops.
Actually, the compiler might need to spill registers to local memory, which goes to L1 cache and is slow.
Alternative: Use 512 threads per block.
Mapping:
- c_local = warp / 2 (0..7 for 8 warps), or similar.
Wait, if we want 16 output channels, we need 16 c_local values. With 512 threads, we could have:
- c_local = warp_id (0..15), with 32 threads per warp.
- But each warp only has 32 threads, and BLOCK_J=64. So each warp can handle 32 j values, and we need 2 warps per c_local to cover 64 j values.
Let's try:
- BLOCK_C_OUT = 8 (instead of 16)
- BLOCK_J = 64
- c_local = warp_id (0..7)
- subwarp = threadIdx.x % 64 / 32? Let me think.
Actually: t = threadIdx.x (0..511)
c_local = t / 64 = 0..7
j_local = t % 64 = 0..63
Total threads: 8 * 64 = 512 threads.
But within a warp: threads 0..31 have c_local=0, j_local=0..31.
Threads 32..63 have c_local=0, j_local=32..63.
Threads 64..95 have c_local=1, j_local=0..31.
A warp is threads 0..31: all c_local=0, j_local=0..31.
Shared memory reads: l_local = 3*j_local + 4k, with j_local=0..31.
Banks = (3j_local + 4k) % 32. Since j_local ranges 0..31 and gcd(3,32)=1, all 32 values are distinct.
PERFECT!
For the next warp (threads 32..63): c_local=0, j_local=32..63.
Banks = (3j_local + 4k) % 32 = (3(j_local-32) + 96 + 4k) % 32 = (3(j_local-32) + 4k) % 32.
Since j_local-32 ranges 0..31, same distinct banks.
So no bank conflicts within any warp!
And we have 8 warps per block. Total threads: 512.
SMEM input: 64 * 200 * 4 = 51,200 bytes.
SMEM weights: 8 * 64 * 3 * 4 = 6,144 bytes.
Total: 57,344 bytes.
Blocks per SM: 3 (228KB / 57KB = 3.98).
Threads per SM: 3 * 512 = 1536.
Occupancy: 1536/2048 = 75% (assuming 2048 max threads). Or if max is 1536, then 100%.
And register usage: 65,536 / 1536 = 42.7 registers per thread. Much more comfortable!
Actually, let me verify H100 max threads per SM. Looking at specs:
- A100: 2048 threads/SM
- H100: I believe it's also 2048 threads/SM, but let me not assume.
Actually, I'll assume 2048 for safety. 75% occupancy is excellent.
And with 512 threads and 8 warps, we get perfect bank utilization for shared memory.
This mapping is:
- blockDim.x = 512
- Grid.y = ceil(128 / 8) = 16
- Grid.x = ceil(174758 / 64) = 2731
- Grid.z = 64
Total blocks = 2731 * 16 * 64 = 2,796,544.
Wait, with only 8 output channels per block, we need twice as many blocks in y. That's fine.
Now let me verify the complete design:
**Parameters:**
- BLOCK_C_OUT = 8
- BLOCK_J = 64
- THREADS_PER_BLOCK = BLOCK_C_OUT * BLOCK_J = 512
**Grid:**
- x = ceil(L_out / BLOCK_J) = ceil(174758 / 64)
- y = ceil(out_channels / BLOCK_C_OUT)
- z = batch_size
**Thread mapping:**
tid = threadIdx.x (0..511)
c_local = tid / BLOCK_J = tid / 64 (0..7)
j_local = tid % BLOCK_J (0..63)
**Shared memory:**
- s_input: in_channels * SMEM_SLICE_PAD floats
- s_weight: BLOCK_C_OUT * in_channels * 3 floats
- Total: dynamically allocated.
**Loading:**
- Input: for tid in [0, 511], load by iterating over all input elements.
- Weights: load and transpose to [c_in][k][c_local] layout.
**Computation per thread:**
```cuda
float sum = (bias != nullptr) ? bias[c_start + c_local] : 0.0f;
for (int c_in = 0; c_in < in_channels; c_in++) {
#pragma unroll
for (int k = 0; k < 3; k++) {
float w = s_weight[c_in * 3 * BLOCK_C_OUT + k * BLOCK_C_OUT + c_local];
float val = s_input[c_in * SMEM_SLICE_PAD + j_local * 3 + k * dilation];
// Wait, dilation is fixed to 4, stride is fixed to 3.
// Actually l_local = j_local * stride + k * dilation = 3*j_local + 4*k.
sum += w * val;
}
}
output[...] = sum;
```
Wait, ifstride and dilation are runtime parameters, we can't unroll using literal 3 and 4 unless we specialize the kernel or compute with the runtime values.
For this problem, stride and dilation are passed to the constructor. We can specialize the kernel for the given values, or just use the runtime values in the loop.
With `#pragma unroll` on the k loop, and runtime stride/dilation, the compiler will still unroll the loop since k ranges 0..2 (kernel_size=3).
The shared memory index: `j_local * stride + k * dilation`.
If stride=3 and dilation=4, this is `3*j_local + 4*k`.
Bank analysis: for j_local = 0..31 in a warp, banks = (3*j_local + 4k) % 32.
Since j_local differs by 1 between consecutive lanes and gcd(3,32)=1, consecutive lanes hit different banks.
For j_local = 32..63 in another warp, banks repeat but it's a different warp.
So within a warp, 32 unique banks. No conflicts!
For `s_weight` access: `c_in * 3 * BLOCK_C_OUT + k * BLOCK_C_OUT + c_local`.
For fixed c_in, k, and varying c_local: addresses differ by 1 float = 4 bytes = 1 bank.
32 consecutive c_local values (if they were in a warp) would hit 32 different banks.
But in our mapping, c_local = tid / 64. For threads in a warp (32 consecutive tids), c_local is constant (0 for warp 0, etc.). So all threads in warp read the SAME weight address. This is a broadcast, not a conflict.
After the weight read, each thread stores it in a register. Then for the val read, each thread reads a different input address with no bank conflict. Excellent!
And FMA instruction can use the register w and register val, and accumulate into sum.
This design is near-optimal for shared memory usage!
One more optimization: I can ensure the compiler generates FMA instructions by writing `sum += w * val` rather than `sum = sum + w * val`. But the compiler usually optimizes this anyway.
Let me now write the actual CUDA kernel code.
Actually, there's one more subtlety. The problem says kernel_size can be arbitrary (it's a constructor argument). But in the test, it's 3. However, to be fully correct, the kernel should handle arbitrary kernel_size.
If kernel_size is arbitrary, my unrolling strategies and SMEM calculations need to be dynamic.
But for maximum performance, I should probably specialize for kernel_size=3 and common values, while still being correct for general cases. Actually, given the constraint of producing numerically equivalent output, I need to handle the general case. But if I can make the kernel fast for the test case, that's what matters most.
Wait, but the correctness test might use different kernel sizes. The `get_init_inputs()` provides the specific values, but `ModelNew` should work for any valid inputs.
I should write a kernel that works for general kernel_size, stride, and dilation, but is optimized for the common cases. Since kernel_size=3 is small, I can unroll the k loop with `#pragma unroll` and the compiler will do it if kernel_size is known or if the loop bound is small.
Actually, I'll pass kernel_size to the kernel and use a loop with `#pragma unroll` if kernel_size <= 4.
In CUDA, dynamic loop bounds can't be unrolled. I can use template specialization or just a switch statement.
For simplicity, let me write the kernel with a loop over kernel_size and trust that for kernel_size=3, the compiler will optimize it well. Alternatively, I can write conditional unrolling:
```cpp
if (kernel_size == 3) {
// manually unrolled or specialized
} else {
// general loop
}
```
Actually, the most robust approach is to use template kernel_size, but since we're compiling at module-load time, we can instantiate multiple versions.
But for simplicity and to get something working, let me just write a general loop and see if it's fast enough.
Actually, now that I think about it more, maybe I should verify whether cuDNN's direct convolution is already extremely fast for this case. Let me just implement the kernel and see.
Let me write the code:
```python
import torch
import torch.nn as nn
from torch.utils.cpp_extension import load_inline
cuda_source = r"""
#include <torch/extension.h>
#include <cuda_runtime.h>
#define BLOCK_J 64
#define BLOCK_C_OUT 8
#define THREADS_PER_BLOCK (BLOCK_C_OUT * BLOCK_J) // 512
#define SMEM_SLICE_PAD 200
__global__ void conv1d_direct_kernel(
const float* __restrict__ input,
const float* __restrict__ weight,
const float* __restrict__ bias,
float* __restrict__ output,
int batch_size,
int in_channels,
int out_channels,
int kernel_size,
int stride,
int dilation,
int L_in,
int L_out) {
int b = blockIdx.z;
int c_block = blockIdx.y;
int j_block = blockIdx.x;
int c_start = c_block * BLOCK_C_OUT;
int j_start = j_block * BLOCK_J;
if (b >= batch_size || c_start >= out_channels || j_start >= L_out) return;
int actual_j = min(BLOCK_J, L_out - j_start);
int actual_c_out = min(BLOCK_C_OUT, out_channels - c_start);
extern __shared__ float s_mem[];
float* s_input = s_mem;
float* s_weight = s_mem + in_channels * SMEM_SLICE_PAD;
int tid = threadIdx.x;
int slice_size = actual_j > 0 ? (actual_j - 1) * stride + (kernel_size - 1) * dilation + 1 : 0;
// Load input into shared memory
int total_input = in_channels * slice_size;
for (int idx = tid; idx < total_input; idx += THREADS_PER_BLOCK) {
int c_in = idx / slice_size;
int l_local = idx % slice_size;
int l_global = j_start * stride + l_local;
if (c_in < in_channels && l_global < L_in) {
s_input[c_in * SMEM_SLICE_PAD + l_local] = input[
((size_t)b * in_channels + c_in) * L_in + l_global
];
}
}
// Load and transpose weights into shared memory
// Layout: [c_in][k][c_local]
int total_weight = actual_c_out * in_channels * kernel_size;
for (int idx = tid; idx < total_weight; idx += THREADS_PER_BLOCK) {
int c_local = idx / (in_channels * kernel_size);
int rem = idx % (in_channels * kernel_size);
int c_in = rem / kernel_size;
int k = rem % kernel_size;
float w = weight[
((size_t)(c_start + c_local) * in_channels + c_in) * kernel_size + k
];
s_weight[(c_in * kernel_size + k) * BLOCK_C_OUT + c_local] = w;
}
__syncthreads();
// Compute
int c_local = tid / BLOCK_J;
int j_local = tid % BLOCK_J;
if (c_local < actual_c_out && j_local < actual_j) {
float sum = (bias != nullptr) ? bias[c_start + c_local] : 0.0f;
for (int c_in = 0; c_in < in_channels; ++c_in) {
int w_base = (c_in * kernel_size) * BLOCK_C_OUT;
int i_base = c_in * SMEM_SLICE_PAD;
#pragma unroll
for (int k = 0; k < kernel_size; ++k) {
float w = s_weight[w_base + k * BLOCK_C_OUT + c_local];
int l_local = j_local * stride + k * dilation;
float val = s_input[i_base + l_local];
sum += w * val;
}
}
output[
((size_t)b * out_channels + (c_start + c_local)) * L_out + (j_start + j_local)
] = sum;
}
}
torch::Tensor conv1d_custom(
torch::Tensor input,
torch::Tensor weight,
c10::optional<torch::Tensor> bias,
int stride,
int dilation) {
int batch_size = input.size(0);
int in_channels = input.size(1);
int L_in = input.size(2);
int out_channels = weight.size(0);
int kernel_size = weight.size(2);
int L_out = (L_in - dilation * (kernel_size - 1) - 1) / stride + 1;
if (L_out < 0) L_out = 0;
auto output = torch::empty({batch_size, out_channels, L_out}, input.options());
if (L_out == 0) return output;
dim3 grid(
(L_out + BLOCK_J - 1) / BLOCK_J,
(out_channels + BLOCK_C_OUT - 1) / BLOCK_C_OUT,
batch_size
);
dim3 block(THREADS_PER_BLOCK);
size_t smem_size = (in_channels * SMEM_SLICE_PAD + BLOCK_C_OUT * in_channels * kernel_size) * sizeof(float);
const float* bias_ptr = bias.has_value() ? bias.value().data_ptr<float>() : nullptr;
conv1d_direct_kernel<<<grid, block, smem_size>>>(
input.data_ptr<float>(),
weight.data_ptr<float>(),
bias_ptr,
output.data_ptr<float>(),
batch_size,
in_channels,
out_channels,
kernel_size,
stride,
dilation,
L_in,
L_out
);
return output;
}
"""
```
Wait, I need to verify the slice_size formula.
For actual_j values of j, starting from j_start, the output positions are j_start, j_start+1, ..., j_start+actual_j-1.
For each j, the kernel accesses input at stride*j + k*dilation for k in [0, kernel_size-1].
Minimum l_global = stride * j_start + 0 * dilation = stride * j_start.
Maximum l_global = stride * (j_start + actual_j - 1) + (kernel_size - 1) * dilation.
The number of positions in [min_l, max_l] is: max_l - min_l + 1
= stride*(j_start + actual_j - 1) + (kernel_size - 1)*dilation - stride*j_start + 1
= stride*(actual_j - 1) + (kernel_size - 1)*dilation + 1.
For actual_j=1: (kernel_size-1)*dilation + 1. Correct (covers exactly the kernel footprint).
For actual_j=64, stride=3, kernel_size=3, dilation=4: 3*63 + 8 + 1 = 189 + 9 = 198. Correct.
But the formula `actual_j > 0 ? (actual_j - 1) * stride + (kernel_size - 1) * dilation + 1 : 0` is exactly this.
Now, about the shared memory `s_input` size: I use `in_channels * SMEM_SLICE_PAD`, with `SMEM_SLICE_PAD = 200`.
For actual_j up to 64 with stride=3, kernel_size=3, dilation=4: slice_size up to 198. 198 < 200. Good.
But if stride is larger, say stride=10, kernel_size=5, dilation=2, actual_j=64:
slice_size = 9*64 + 4*2 + 1 = 576 + 9 = 585. This is > 200.
SMEM_SLICE_PAD needs to be large enough for the maximum possible slice_size, or we need to compute it dynamically.
For the general case, we should set SMEM_SLICE_PAD dynamically, or allocate exactly `in_channels * slice_size` and compute indices carefully.
But since shared memory is allocated per block and `slice_size` varies per block (for the last partial block), we need the maximum possible slice_size.
Maximum slice_size for a kernel:
= (BLOCK_J - 1) * stride + (kernel_size - 1) * dilation + 1.
If these are all runtime parameters, we can't easily define a compile-time constant for SMEM_SLICE_PAD.
But we can still use shared memory with dynamic sizing:
`float* s_input = s_mem;`
`float* s_weight = s_mem + in_channels * slice_size_max;`
Where `slice_size_max` = (BLOCK_J - 1) * stride + (kernel_size - 1) * dilation + 1.
In CUDA, for `extern __shared__`, we just need `smem_size` to be large enough.
For the kernel launch: `smem_size = (in_channels * slice_size_max + BLOCK_C_OUT * in_channels * kernel_size) * sizeof(float);`
And in the kernel, threads write to `s_input[c_in * slice_size_max + l_local]` instead of `c_in * SMEM_SLICE_PAD + l_local`.
This way we don't need SMEM_SLICE_PAD at all; we use slice_size_max.
Wait, but slice_size_max depends on runtime parameters. And different blocks might have different actual slice_size (only the last partial block has actual_j < BLOCK_J). But for simplicity, we can allocate the max and only use what's needed.
Let me revise:
```cpp
size_t slice_size_max = (BLOCK_J - 1) * stride + (kernel_size - 1) * dilation + 1;
size_t smem_size = (in_channels * slice_size_max + BLOCK_C_OUT * in_channels * kernel_size) * sizeof(float);
```
In kernel:
```cpp
int slice_size = actual_j > 0 ? (actual_j - 1) * stride + (kernel_size - 1) * dilation + 1 : 0;
// For safety, cap at slice_size_max
```
And accesses: `s_input[c_in * slice_size_max + l_local]`.
This avoids the padding constant.
But wait, `slice_size_max` could be very large if stride, kernel_size, or dilation are large. We need to make sure it fits in shared memory.
For the test parameters: (64-1)*3 + (3-1)*4 + 1 = 189 + 8 + 1 = 198. Very small.
But for general parameters like kernel_size=101, stride=1, dilation=1: slice_size_max = 63 + 100 + 1 = 164. Still small.
For kernel_size=101, stride=10, dilation=10: slice_size_max = 630 + 1000 + 1 = 1631.
for in_channels=64: 64*1631*4 = 417,536 bytes. Plus weights. That might exceed 227KB.
For safety, we can fall back to no shared memory if slice_size_max * in_channels * 4 > some threshold.
But that's overkill for this problem.
Actually, I just realized: the template parameters are constructor arguments. The reference implementation passes `kernel_size=3`, `stride=3`, `dilation=4`. My kernel must accept these but can be optimized for small values.
To keep it robust, I can:
1. If the shared memory requirement is too large, use a no-SMEM fallback kernel.
2. Or just don't use SMEM and have each thread load directly from global memory.
But no-SMEM has terrible coalescing as I analyzed earlier.
Let me think of a hybrid approach: for small slice_size, use SMEM. For large, use global memory directly.
Actually for correctness, I should first get the simple version working and see if it's faster than cuDNN.
Let me rewrite with dynamic slice_size_max.
Also, one concern: since I need to divide the code into `cuda_sources` and `cpp_sources`, and `load_inline` auto-generates the pybind11 module, I need to be careful.
`load_inline` signature:
```python
load_inline(
name="...",
cpp_sources=[...], # C++ host code
cuda_sources=[...], # CUDA device code
functions=[...],
extra_cuda_cflags=[...],
extra_cflags=[...],
)
```
The CUDA sources can contain the kernel definition, and the C++ sources can contain the host wrapper function that calls the kernel.
Or, I can put everything in `cuda_sources` since it's `.cu` compilation. But `cpp_sources` is compiled with the host compiler, and `cuda_sources` with nvcc.
Actually, `load_inline` handles this. Let me just put the kernel in `cuda_sources` and the host wrapper in `cpp_sources` (or vice versa).
Actually, a simpler approach: just put everything in `cpp_sources` with `__global__` and `__device__` functions. nvcc can compile `.cpp` files that contain CUDA code if I add the right flags. Actually no, `cpp_sources` are compiled with the C++ host compiler, not nvcc. For CUDA code, I should use `cuda_sources`.
Wait, looking at `torch.utils.cpp_extension.load_inline` documentation:
- `cuda_sources`: list of CUDA sources (compiled with nvcc)
- `cpp_sources`: list of C++ sources (compiled with C++ compiler)
I should put the kernel in `cuda_sources` and the host binding in `cpp_sources`.
But actually, I can put everything in `cuda_sources` (both device and host code) and leave `cpp_sources` empty. nvcc can compile host-side code too.
Let me do that.
Actually, there can be issues with putting host C++ code in `cuda_sources` due to pybind11. Let me just use `cpp_sources` for the pybind11 wrapper and keep `cuda_sources` for the CUDA kernel.
Wait, no. `load_inline` has the `functions` parameter which lists the names of functions to expose. I need to define:
1. The kernel in `cuda_sources`
2. A host function in `cpp_sources` or `cuda_sources` that calls the kernel and is declared `extern "C"` or similar, and is exposed to Python.
Actually, `load_inline` auto-generates pybind11 bindings for functions listed in `functions`. These functions should be callable from C++ and exposed. I can put the host function definition in `cpp_sources` and the device code in `cuda_sources`. The host function can call the kernel if it has a forward declaration of the kernel, or I can put everything in `cuda_sources`.
Let me look for a working pattern:
```python
cuda_sources = ["""
__global__ void my_kernel(...) { ... }
"""]
cpp_sources = ["""
torch::Tensor my_function(torch::Tensor x) {
my_kernel<<<grid, block>>>(...);
return x;
}
"""]
load_inline(
name="my_module",
cuda_sources=cuda_sources,
cpp_sources=cpp_sources,
functions=["my_function"],
)
```
This should work, but the C++ compiler compiling `cpp_sources` needs to know about `my_kernel`. I can add a forward declaration in `cpp_sources`:
```cpp
void my_kernel(...); // forward declaration for the kernel
```
Or, I can put both in `cuda_sources` and leave `cpp_sources` empty. nvcc can compile both host and device code.
Let me try putting everything in `cuda_sources`. But then I need to make sure the host function is properly declared for the auto-generated pybind11.
Actually, let me look at how the evaluator expects this.
The typical pattern in these tasks is:
```python
cuda_source = r"""
#include <torch/extension.h>
__global__ void kernel(...) { ... }
torch::Tensor function(...) {
kernel<<<...>>>(...);
return output;
}
"""
module = load_inline(
name="conv1d_cuda",
cuda_sources=[cuda_source],
functions=["conv1d_custom"],
extra_cuda_cflags=["-O3", "--use_fast_math"],
verbose=False,
)
```
And the auto-generated pybind11 wraps `conv1d_custom`.
I think putting everything in `cuda_sources` should work. nvcc is used for compilation.
Wait, but there's also `cpp_sources`. Maybe I can leave it empty and put everything in `cuda_sources`.
Actually, looking at PyTorch docs, `load_inline` compiles:
- `cpp_sources` with the C++ compiler
- `cuda_sources` with nvcc
Both are linked together.
So if I put the host wrapper in `cpp_sources`, I need a forward declaration of the kernel. Or I can put the host wrapper in `cuda_sources` too.
I'll put the kernel in `cuda_sources` and the host wrapper (which uses the kernel) also in `cuda_sources`. The host wrapper just orchestrates the kernel launch.
One issue: `torch::Tensor` and related APIs need the right headers. Including `<torch/extension.h>` should handle both host and device compilation.
Let me also consider using `AT_DISPATCH_FLOATING_TYPES` to make it work for both float and half. But the problem asks for fp32 precision, so I'll stick with float.
However, the input might come as float32 but the model weights are also float32. The reference uses `nn.Conv1d` which operates in the dtype of the weights.
For the test: `x = torch.rand(...)` gives float32 by default. `nn.Conv1d` creates float32 weights. So everything is float32. Good.
Let me now finalize the code and try compiling.
Actually, before that, let me think about whether to use `const float*` or just `float*` for pointers. `__restrict__` is good for optimization.
Another optimization: ensure the input/output tensors are contiguous. The wrapper can call `.contiguous()` if needed. But `torch.rand()` creates contiguous tensors.
One more thing: bias. The reference adds bias if `bias=True`. My kernel supports it.
Let me also consider using `cudaStream_t stream = at::cuda::getCurrentCUDAStream();` and passing it to the kernel launch for async execution.
Actually, the default stream is fine for correctness. Using the current stream is better practice.
Let me write the complete code.
Wait, I want to double check: do I need to manually copy weights from the nn.Module to pass them to the kernel? Yes. In `ModelNew.__init__`, I'll create `self.weight` and `self.bias` parameters (or buffers), and in `forward`, I'll call the custom kernel with these.
Actually, I should shape the weights correctly. nn.Conv1d stores weights as (out_channels, in_channels, kernel_size).
Now, I just realized: the reference uses `self.conv1d(x)`. For my custom kernel, I need to match this exactly. But cuDNN might use a different algorithm or precision for the accumulation. To be numerically equivalent within fp32 tolerance, my kernel's accumulation order might differ from cuDNN, causing small differences.
To minimize numerical differences, I should use the same accumulation order as a straightforward implementation, or better yet, compare the output and see if it's within tolerance.
FP32 tolerance is 1e-4 relative and absolute. For random inputs in [0,1] and random weights, the output values are sums of ~192 products. Each product is in [0,1], sum is in [0,192]. The rounding error of FMA in FP32 is about 1e-7 relative, so absolute error per FMA is about 1e-7 * 1 = 1e-7. For 192 FMAs, if errors are random, total error is about sqrt(192) * 1e-7 ≈ 1.4e-6. If errors accumulate systematically, it could be 192 * 1e-7 = 1.9e-5.
1e-4 tolerance should be easily met.
But if cuDNN uses FP16/TF32/tensor cores internally, the error might be larger. For FP32 conv on modern GPUs, cuDNN typically uses pure FP32 or TF32 depending on settings. By default for FP32 input, it might use tensor cores with TF32 (which has 1e-3 precision), but PyTorch usually sets cuDNN to use accurate FP32.
Actually, I'm not sure. Let me just write the kernel and check correctness.
One more optimization: I can try to use `__ldg` for read-only global memory, but the compiler usually infers this.
Let me also consider using `float2` or `float4` for the output write. But writing a single float per thread is fine since writes are coalesced (consecutive threads write consecutive output elements).
For our thread mapping:
tid=0: c_local=0, j_local=0 → output position (b, c_start+0, j_start+0)
tid=1: c_local=0, j_local=1 → output position (b, c_start+0, j_start+1)
...
tid=31: c_local=0, j_local=31 → output position (b, c_start+0, j_start+31)
tid=32: c_local=0, j_local=32 → output position (b, c_start+0, j_start+32) -- next warp
For consecutive threads in a warp, output positions have consecutive j_local. They are adjacent in memory! Perfect coalescing.
Excellent. This design has:
- Coalesced global memory reads for input (during shared memory loading)
- Coalesced global memory reads for weights
- No shared memory bank conflicts for input or weight reads
- Broadcast for weight reads (threads in warp read same weight)
- Coalesced global memory writes for output
I think this kernel will be very fast.
Let me write it now.