reasoning
We need to write a high-performance custom CUDA kernel for a sequence: Linear(x) -> Swish(x) -> add bias -> GroupNorm.
The dimensions: batch_size = 32768, in_features = 1024, out_features = 4096, num_groups = 64, bias_shape = (out_features,). So linear is 32768x1024 * 1024x4096 = ... matrix multiplication. After that, swish activation elementwise, add bias (per channel), then GroupNorm across batch dimension? Wait, GroupNorm normalizes across channels within each group, for each sample. Input shape is (N, C) = (batch_size, out_features). In standard GroupNorm, we compute mean and variance per group per sample: groups = 64, channels per group = out_features / num_groups = 4096 / 64 = 64. So for each batch element i, each group g, compute mean and variance over the 64 channels in that group, then normalize: (x - mean) / sqrt(var + eps), then apply weight and bias (gamma and beta). PyTorch GroupNorm has learnable affine per channel if affine=True (default True). Wait, the GroupNorm is created without specifying affine, so defaults affine=True. But in the reference code they only pass num_groups and out_features. So affine is True. Thus there is a weight (gamma) and bias (beta) per channel. However, the weight is not part of the constructor arguments. So we need to include those parameters in ModelNew? The problem says "Accept the same constructor arguments as Model." So ModelNew __init__ gets (in_features, out_features, num_groups, bias_shape). But inside we need to replicate the same parameters as the reference, including linear weight, linear bias, GroupNorm weight and bias. Since we need to produce numerically identical outputs, we need to load the parameters from the reference model (or from matching nn.Module state). The evaluation likely instantiates Model and ModelNew with same args, copies parameters (or loads state dict). Actually they say "Replace the forward computation of the PyTorch model below with a custom CUDA kernel." Usually they instantiate ModelNew with same arguments, and maybe copy parameters from Model. But the code only passes those arguments. So we need to create internal parameters that have the same names? The evaluation may instantiate ModelNew with the args, then copy state dict from the reference. Let's think: In these tasks, they typically do something like:
model = Model(*get_init_inputs())
model_new = ModelNew(*get_init_inputs())
model_new.load_state_dict(model.state_dict())
# then compare outputs
If that's the case, we need ModelNew to have matching parameter names: "matmul.weight", "matmul.bias", "bias", "group_norm.weight", "group_norm.bias". But we can also just use nn.Parameter(torch.empty(...)) with appropriate names. Or we could keep a reference to parameters in the same modules? We could instantiate the same submodules (nn.Linear, nn.Parameter, nn.GroupNorm) inside ModelNew, but replace forward with a custom kernel that uses those parameters. That would be easiest: define self.matmul = nn.Linear(...), self.bias = nn.Parameter(...), self.group_norm = nn.GroupNorm(...). In forward, extract their tensors and call custom CUDA kernel. This ensures parameter name compatibility. The kernel itself can access .weight, .bias, .weight, .bias of group_norm.
So plan: ModelNew class will have same submodules. In forward, we just get x, get weight = self.matmul.weight (shape [out_features, in_features]), linear_bias = self.matmul.bias (shape [out_features]), add_bias = self.bias (shape [out_features]), gn_weight = self.group_norm.weight (shape [out_features]), gn_bias = self.group_norm.bias (shape [out_features]). Then call a custom fused kernel that does all this.
Because of large batch size (32768), out_features=4096, we may want to use a fused kernel that performs linear multiplication via CUBLAS? Or we can do a custom tiled matmul? But CUBLAS matmul is already highly optimized. However, fusing the activation, bias add, and group norm could save memory bandwidth. But the linear itself is large compute. Let's estimate.
Compute of linear: M=32768, N=4096, K=1024. FP32. FLOPs = 2*M*N*K = 2*32768*4096*1024 ≈ 2 * 137,438,953,472 = 274.9 GFLOPs. At 20 TFLOPS (FP32) on an A100 maybe 80? Actually A100 FP32 is 19.5 TFLOPS. So compute bound ~ 14 ms if peak. Memory bandwidth for reading x: M*K*4 bytes = 134 MB. Reading weight: N*K*4 = 16 MB. Output: M*N*4 = 536 MB. So total memory for matmul is managed by CUBLAS. But the subsequent ops (swish, bias add, group norm) would be memory-bound if done separately: reading 536 MB and writing 536 MB each step. So fusing them after matmul could save significant time.
Approach: Use torch.matmul for the linear part, then a custom kernel for the rest (Swish + bias add + group norm). But we can also do a custom kernel that takes x (input), weight, linear_bias, bias, gn_weight, gn_bias and does everything fused. However writing a high-performance matmul in CUDA is hard to beat cuBLAS. But maybe the entire sequence is memory-bound after the matmul? Let's estimate: matmul takes ~ maybe 1-2 ms on A100? Actually 275 GFLOPs at 19.5 TFLOPS = 14 ms? Wait, TFLOPS is 10^12. 275 GFLOPs = 0.275 TFLOPs. 0.275 / 19.5 = 0.014 sec = 14 ms. If using tensor cores for fp32? Actually A100 does not use tensor cores for fp32. So 14ms might be right. Memory bandwidth is 1.5 TB/s = 1500 GB/s. For matmul internal bandwidth (if not compute bound) is irrelevant. Let's compute arithmetic intensity: M*N*K = 137e9 multiplications-adds. Data moved: (M*K + N*K + M*N) * 4 bytes ≈ (33M + 16M + 134M)*4? Wait M*K = 33.5M, N*K = 4.1M? Let's compute: 32768*1024 = 33,554,432. 4096*1024 = 4,194,304. 32768*4096 = 134,217,728. Sum = 172M floats = 688 MB. So arithmetic intensity = 137e9 / 688e6 = 199 FLOPs/byte. That's high enough to be compute bound. So matmul dominates execution time. So even if we could fuse the rest perfectly, speedup might be limited to a few percent. Unless we also accelerate the matmul. But custom matmul will not beat cuBLAS.
Alternative: We can attempt to fuse the linear with the following ops by using a custom kernel that does the linear row by row or block by block, keeping weights in shared memory? That would be much slower than cuBLAS for the matmul. But maybe because the following ops are done together, total execution time of reference is matmul time + rest. If matmul is 14ms, and rest is maybe ~0.5ms? Actually group norm on 536 MB of data: reads and writes maybe 1-2 GB of data. At 1500 GB/s that's ~1.3 ms. So total reference maybe ~15-16ms. Fusing rest saves ~1ms (~6%). Not huge.
But maybe we can use a custom kernel that leverages the fact that we have a bias addition and groupnorm, which might allow some optimization? Or maybe we can accelerate using mixed precision (fp16) if allowed? The problem says "Produce numerically equivalent outputs at fp32 precision." This means the output must match fp32 reference. But we can compute internally in fp16 or bf16? Probably not, because tolerance is 1e-4 for fp32, which is tight. We could use fp16 with accumulation? Risky. Better to stay fp32.
We can also try to use CUTLASS or something? But load_inline only allows writing CUDA C++ and linking against torch libraries. We can call cublas inside CUDA extension? Actually torch.utils.cpp_extension.load_inline compiles a .cpp file with pybind wrappers. The functions argument lists host functions that will be wrapped. We can write host code that calls cuBLAS or just torch ops inside? Wait, we need a custom kernel. But nothing prevents us from implementing forward as a combination of torch.matmul + custom CUDA kernel for the fusion. This is acceptable because "Replace the forward computation ... with a custom CUDA kernel" may mean we can use custom kernel for part of it, as long as ModelNew.forward doesn't call PyTorch's nn.Linear or activation or GroupNorm. However, the best speedup might be achieved by fusing everything, including matmul, into a single kernel that uses shared memory? But for a large matmul, a naive CUDA kernel would be terrible. We could call torch.matmul for the linear, then custom kernel for the rest. Let's see if we can get a speedup that way. But the reference probably is: torch.mm (or F.linear), then sigmoid, mul, add, group_norm. The group_norm in PyTorch may not be extremely optimized. But still it's implemented in CUDA. The overhead of multiple kernel launches might be non-trivial but small.
Alternatively, we can try to write a fused kernel that handles the entire sequence: for each output element (i, j), compute linear result on the fly? That would be O(M*N*K) compute but no large intermediate storage. However doing the full matmul in a single fused kernel with one thread per output element would require loading weight columns across all threads. This is essentially a batched GEMV per row. For each row i, we need to compute dot product of x[i] with weight[j] for each j. With 4096 output features, each row is a GEMV of length 1024. This is tiny for a GPU; launching 32768*4096 threads each doing 1024 multiply-adds in a naive way (without coalescing) would be terrible. But we could launch one block per row (batch element), and have threads compute partial dot products for all output channels. For each row, 4096 outputs. 1024 input features. 64 channels per group. This is small enough to fit in shared memory.
Wait, we could process each batch element by a block of threads (e.g., 128 or 256 threads). Each block computes the entire output row (4096 floats). It loads the input row (1024 floats) into shared memory (4KB). Then iterates over output features, computing dot products using the input vector. But each thread would compute a subset of outputs. Since weight matrix is [out, in] = [4096 x 1024], size = 16MB. We can keep it in registers? No. We could load chunks of weight into shared memory? But weight is shared across all blocks. We can read it from global memory coalesced. Each thread reading a column of weight? Actually if each thread processes a subset of output channels, then for each output channel j, it needs weight[j, k] for all k. If we keep input x[i] in shared memory, then each thread can read weight[j, k] sequentially for its j? That would be strided access: thread tid reads weight[tid, k], weight[tid+blockDim.x, k], etc. This is coalesced if we read by k outer loop? Let's design:
Given input row x (length 1024) in smem.
For each output feature j assigned to thread t:
acc = linear_bias[j]
for k = 0..1023:
acc += x[k] * weight[j, k]
This is a reduction over k. But each thread does 1024 loads of weight for each j it handles. If each thread handles multiple j's, say 4096 / 128 = 32 j's. Then each thread does 32*1024 = 32768 loads from weight. Weight loads for different j are far apart (stride 4096 or 1024?). Actually weight is stored [out_features, in_features] = [4096, 1024] row-major. Reading weight[j, k] for fixed j across k is contiguous in memory (stride 1). So each thread reads contiguous 1024 floats for each j. That's good. Reading 32 contiguous segments of 1024 floats each. However, simultaneous threads in a warp reading for different j: thread 0 reads weight[0, k], thread 1 reads weight[32, k] (if each thread handles 32 j's interleaved or block). That would cause strided access. Better to assign contiguous j's to contiguous threads? But then each thread reads its own contiguous block of weight, and threads in warp read different contiguous blocks. Since each warp reads from different memory regions, coalescing might be okay but not perfect: warp reads 32 separate 1024-float arrays. The hardware can handle some degree of coalescing if the addresses are aligned, but each thread reads a different base address. A warp of 32 threads reading 32 distinct base addresses + same offset k means each load is a 128-byte cache line per thread? Actually each thread issues a load of a float from address base_j + k*4. The cache line is 128 bytes (32 floats). So if the base_j are spaced by 1 float (i.e., j contiguous), then the warp's addresses are base_0 + k*4, base_1 + k*4, ..., base_31 + k*4. Those are 4 bytes apart. That is the worst-case strided pattern, not coalesced. However, because cmem? Actually global memory loads are coalesced when threads access contiguous words. Here each thread accesses a different base_j + offset. If base_j are j*4096? No, weight is row-major. For j contiguous, row j starts at address weight + j*in_features. So base_j = weight + j*in_features. If in_features = 1024, then base_j differ by 1024 floats = 4096 bytes. For k=0, thread j loads weight[j, 0] = base_j + 0. So warp loads addresses spaced by 4096 bytes. That is not contiguous; each address falls in different cache lines (128-byte lines). So each warp issues 32 separate 4-byte loads, resulting in 32 memory transactions per warp per k. That's very bad.
Alternative: assign j's in a way that consecutive threads read weight with contiguous memory. We can transpose the weight matrix (or store as [in_features, out_features]). If we store weight as [in_features, out_features] column-major, then reading weight[k, j] for fixed k and varying j is contiguous. But PyTorch linear weight is [out_features, in_features]. We can transpose it on host before passing to kernel. In our custom code, we can transpose weight for coalesced access. That would be good.
We could store weight in a custom layout: [in_features, out_features]. Then for each k, we load weight_T[k, j] where j is the output index. If threads in a warp handle contiguous j, then for a given k, all threads load from weight_T + k*out_features + j_start. That's perfectly coalesced. So we can transpose weight once on CPU/GPU before calling kernel. The overhead is negligible.
So design: Each block processes one batch element (row). Block size = e.g., 128 threads. Each thread computes a subset of output channels (e.g., 4096/128 = 32 channels). For each output channel j in its subset, initialize accumulator with linear_bias[j]. Then loop over k = 0..1023: load x[k] (shared across block) into a register (or all threads load from smem). Then load weight_T[k, j] for each j. Since j are contiguous across threads, we can load them in a coalesced way.
But careful: Each thread has 32 j's. For a given k, each thread loads weight_T[k, j0], weight_T[k, j0+1], ..., weight_T[k, j0+31]. So a warp of threads loads weight_T[k, warp_start_j : warp_start_j+32*32]? Let's think: if blockDim.x = 128, threads 0..127. Thread t handles j from t*32 to (t+1)*32-1. Warp 0 (threads 0..31) handles j from 0..31, 32..63, ..., 992..1023? Wait 32*32=1024, but out_features=4096. Actually if blockDim.x=128, each thread handles 4096/128 = 32 j's. So thread t handles j in [t*32, (t+1)*32 - 1]. Warp 0 handles threads 0..31, so j ranges 0..31, 32..63, ..., 992..1023. That's 1024 j's. So for a given k, warp 0 reads from weight_T[k, 0..1023]. Those addresses are contiguous. So if each thread in warp 0 reads its first j (j = t*32), then the warp reads 32 floats spaced by 32 floats apart (128 bytes). That's not fully contiguous. Actually contiguous reading per thread for its 32 j's is internal loop; the warp would do 32 iterations. In iteration m (0..31), thread t reads weight_T[k, t*32 + m]. The addresses for warp threads at iteration m: thread t reads weight_T[k, t*32 + m] = base + k*out_features + t*32 + m. For fixed m, t ranges 0..31, addresses are base + k*4096 + m + t*32. That's contiguous with stride 32 floats = 128 bytes. So each warp reads 32 floats spaced by 128 bytes. That is exactly one 128-byte cache line per thread? Wait, 128 bytes = 32 floats. So address of thread t is base + k*4096 + m + t*32. The difference between thread t and t+1 is 32 floats = 128 bytes. So each thread is reading from a different cache line (since line size is 128 bytes). That means 32 separate cache lines per warp per iteration. That is bad. To fix this, we need to change assignment: have threads in a warp read contiguous floats. For example, if each warp handles a contiguous chunk of 32*? j's. Instead of each thread handling 32 non-contiguous j's, we should have each warp handle a contiguous block of out_features, and within the warp, each thread handles one or a few contiguous j's. For example, blockDim.x = 128 threads. We can partition the 4096 output channels into 128 chunks of 32 each. If we assign thread t the contiguous chunk [t*32, (t+1)*32-1], then warp 0 handles j 0..1023 (since 32 threads * 32 = 1024). But within warp 0, thread 0 handles j 0..31, thread 1 handles j 32..63, etc. As shown, this leads to strided access.
Better: assign contiguous j's to contiguous threads. For example, blockDim.x = 128. Partition 4096 channels into 128 groups of 32, but assign group i to thread i? That's what we did. To get contiguous access, we need to transpose the assignment: for a warp of 32 threads, have them handle a contiguous block of 32 channels, with each thread handling one channel (or a few contiguous channels). If each thread handles 32 channels, we can interleave the channels across threads: e.g., thread t handles channels t, t+128, t+256, ..., up to 4096-128. Then for a fixed k and offset m, thread t reads weight_T[k, t + m*128]. For a warp, t=0..31 reads addresses base + k*4096 + m*128 + t. That's perfectly contiguous! Yes! So we should assign channels in a strided manner across threads: thread t gets channel j = t + m*blockDim.x for m = 0..(out_features/blockDim.x - 1). So each thread handles out_features/blockDim.x channels (32). For blockDim.x=128, thread t handles channels t, t+128, t+256, ..., t+128*31. So for iteration m, all threads in a warp read weight_T[k, m*128 + t]. That's contiguous with stride 1 float. Perfectly coalesced. This is the standard way.
Thus algorithm per block (one batch element):
- Load input x row of length 1024 into shared memory (all threads participate, coalesced). Maybe load 8 floats per thread (128 threads * 8 = 1024).
- Each thread has a local accumulator array of size 32 (out_features / blockDim.x = 32). We cannot have many registers; 32 floats per thread = 128 bytes, okay.
- Initialize acc[m] = linear_bias[channel] for each channel assigned.
- For k = 0..1023:
- xk = smem[k] (broadcast to all threads)
- For m = 0..31:
- j = m*blockDim.x + threadIdx.x
- acc[m] += xk * weight_T[k, j]
- After matmul, apply swish: for each m, compute sigmoid(acc[m]) * acc[m].
- Add bias: acc[m] += bias[j] (the external bias parameter).
- Then we need to compute group norm per group. Groups = 64, channels per group = 64. So we need to compute mean and variance per group for this sample. Since block processes one sample, all threads in block compute for the same sample.
- We can compute per-group mean and variance in shared memory. There are 64 groups, each with 64 channels. We can have shared memory arrays of size 64 for mean and 64 for var (or m2). But we need to compute across all channels. Each thread has 32 channels spread across the 4096 channels. Since threads handle strided channels, each thread's channels belong to different groups. Because blockDim.x=128 and channels per group=64. Thread t handles channels t, t+128, t+256, ... So for t from 0 to 63, channels t, t+128, t+256, ... are all in group floor(t/64) and floor((t+128)/64), etc. Actually group index of channel j is j / 64. So channels assigned to a thread are spaced by 128. Since 128 = 2*64, each thread handles channels from two different groups alternating: e.g., thread 0 handles channels 0, 128, 256, 384, ... Group 0 contains 0..63, so channel 0 is group 0; channel 128 is group 2; channel 256 is group 4; etc. So each thread touches 32 channels across 32 different groups? Let's see: channel j = t + m*128. Group = j / 64 = (t + m*128) / 64 = t/64 + m*2. So group index increments by 2 each step. So thread t handles channels from groups: floor(t/64) + even numbers. Similarly thread t+64 handles same groups but offset by 1? Actually thread 64 handles channel 64, 192, 320,... Group 1,3,5,... So threads 0..63 handle even groups (0,2,4,...), threads 64..127 handle odd groups (1,3,5,...). So each thread handles channels from 16 groups (since 32 channels, step of 2 groups). So it's somewhat scattered.
To compute group mean and variance, we can have each thread compute local partial sums per group. Since there are 64 groups, each thread could maintain an array of 64 partial sums? That's too much: 64 floats * 128 threads * 4 bytes = 32KB, maybe okay in registers? 64 floats per thread is 256 registers, too many.
Alternative approach: use warp-level reductions or shared memory reductions.
Since channels per group = 64, and total channels = 4096, we can process each group with a subset of threads. One approach: since block size = 128, we can have 2 warps per group? 64 channels per group. If we process one group at a time, we can have 64 threads load the 64 channels, compute mean and var, then normalize them. But we need to do this for 64 groups. Since block = 128 threads, we could process two groups in parallel (64 threads each). But we only have one sample. So we could loop over groups: for g in 0..63 step 2, have threads 0..63 load channels g*64 + threadIdx.x (for warp 0) and threads 64..127 load channels (g+1)*64 + (threadIdx.x-64). But we need the values after matmul+bias+swish. We have the values in registers or we could recompute? We computed acc[m] for all channels. Each thread holds 32 values scattered across groups. If we want to gather them into a per-group contiguous layout, we would need to shuffle or store to shared memory. Since 4096 channels * 4 bytes = 16KB per sample. That's small enough to fit in shared memory! Indeed, we can store the entire output row (after linear, swish, bias) in shared memory. Then we can compute group norm using the shared memory array. Let's think:
After matmul, each thread holds its 32 values. We can write them to a shared memory array of size 4096 (16KB). Since threads write to strided positions, the writes are coalesced? Each thread writes to t + m*128. For a warp, these are contiguous addresses (base + m*128 + t). Coalesced. Good. Then __syncthreads(). Then we have the full output row in smem.
Then we compute group norm. We can use a separate loop: for each group g (0..63), threads 0..63 (or 0..127) load channels. But we only have 64 channels per group. Let's use 64 threads per group. With block size 128, we can process 2 groups at a time. Let's assign warp 0 (threads 0..63) to group g, warp 1 (threads 64..127) to group g+1. For each group:
- Threads load smem[g*64 + threadIdx.x%64] into registers.
- Compute mean: warp reduce sum, divide by 64.
- Compute variance: each thread computes (val - mean)^2, warp reduce sum, divide by 64. (Use population variance? PyTorch GroupNorm uses biased or unbiased? Let's check. In PyTorch, GroupNorm computes mean and variance over the group. The variance is unbiased? Actually for normalization in GroupNorm, PyTorch computes var = \sum (x_i - mean)^2 / N where N = number of elements in group (channels * spatial dims). For 1D input, N = channels per group = 64. It uses the unbiased=False? Let me recall: InstanceNorm, LayerNorm, GroupNorm use biased variance (i.e., divide by N) for normalization. Actually the formula is: var = 1/N \sum (x_i - mean)^2. Yes, it's the population variance. PyTorch's implementation uses Welford or two-pass. For fp32, the division is by N. So we must match that.
- Then normalize: val_norm = (val - mean) * rsqrt(var + eps).
- Then apply affine: weight * val_norm + bias. Where weight and bias are group_norm.weight and group_norm.bias per channel.
- Write back to output global memory.
But wait, after group norm, we need to write output of shape (batch_size, out_features). We'll write directly to output tensor.
We also need to handle the linear bias and the external bias. The external bias is self.bias (shape [out_features]). The linear bias is self.matmul.bias. So after linear, we add linear_bias, then swish, then self.bias, then group norm affine (gn_weight, gn_bias). So we can combine linear_bias into the accumulator initialization: acc += matmul_bias[j]. Then swish, then add self.bias[j]. Then store to smem for group norm.
Alternatively, we could add matmul_bias and self.bias before swish? No, spec says: x = self.matmul(x); x = sigmoid(x) * x; x = x + self.bias; x = self.group_norm(x). So matmul_bias is inside the linear; swish applied after linear (including linear bias). Then add external bias. Then group norm. So the order is: linear (with its own bias), swish, add bias, group norm.
So in kernel: compute linear output with matmul_bias included. Then apply swish. Then add external bias. Then group norm.
Because we can fuse swish and external bias before writing to smem. Then group norm reads from smem.
The challenge is to get high performance. Since the matmul is the heavy part, we need to make sure our custom matmul is reasonably efficient, or we should rely on cuBLAS. But if we write a custom matmul as described, can it be faster than cuBLAS + separate kernels? Let's estimate.
cuBLAS matmul: compute bound, highly optimized, probably near peak. Custom matmul: we have 32768 rows, each block handles one row. Block count = 32768. Each block has 128 threads. Occupancy: how many blocks per SM? Each block uses 16KB smem for the output row + maybe 4KB for input row = 20KB. Shared memory per SM on A100 is 164KB? Actually it's up to 164KB per SM. So about 8 blocks per SM. 108 SMs => 864 blocks concurrently. Total blocks 32768, so enough parallelism. But each block does 1024*4096 = 4M FLOPs? Wait per row: dot products of length 1024 for each of 4096 outputs = 4.2M FLOPs. At 19.5 TFLOPS total, with 32768 rows, that's 0.275 TFLOPs. Each block does ~4.2M FLOPs. With 128 threads, each thread does 32768 FLOPs (32 outputs * 1024). At 1.4 GHz clock, per thread ~23k cycles. The main issue is memory bandwidth for weight. Weight is 16MB. Reading it 32768 times? No, each block reads the entire weight matrix (or transposed weight). So total weight reads = 32768 * 16MB = 524GB. That's huge! Wait, that's the problem. In a matmul, the weight matrix is shared across all rows. cuBLAS caches weight in L2 or shared memory? Actually in GEMM, the weight matrix is read once (or a few times) from memory and stored in registers/shared memory, because each row doesn't need to load the entire weight matrix independently if we tile across the M dimension as well. In our design, each block loads the entire weight matrix for its row, meaning weight is read M times (32768x). That's 524GB of memory traffic. That's terrible. So this naive row-wise matmul would be memory-bound and extremely slow.
Thus we absolutely should not do the matmul ourselves row-wise. We need to use cuBLAS for the matmul. Then fuse the other ops.
So the strategy: Use torch.matmul (or F.linear) for the linear, then a custom kernel for Swish + bias + GroupNorm. But is that enough to beat the reference? The reference also uses torch.matmul (or nn.Linear) and then sigmoid, mul, add, group_norm. The custom kernel fuses the elementwise ops with groupnorm. The groupnorm requires computing mean/var per group per sample. In PyTorch, GroupNorm on a 2D input of shape (32768, 4096) with num_groups=64 is likely implemented efficiently, but maybe not perfectly fused. Still, fusing the 3 elementwise ops (sigmoid, mul, add) with groupnorm might save a few kernel launches and memory passes. Could be a modest speedup.
But can we do better? Maybe we can also fuse the bias addition and swish into the groupnorm computation, but still after matmul. The matmul output is large: 536MB. If we do a custom kernel that reads it once, computes swish, bias, groupnorm, and writes final output, that's one pass over 536MB. The reference does: matmul -> write output (536MB). Then sigmoid: read 536, write 536. Then mul: read 536, write 536. Then add: read 536, write 536. Then groupnorm: read 536, compute stats (maybe per warp), write 536. In total, reference memory traffic for these ops: maybe 5 * 536MB = 2.68 GB writes + reads? Actually each op reads and writes. Let's count: after matmul, output in memory. Sigmoid reads it, writes to new tensor (or in-place?). PyTorch's sigmoid is out-of-place if we do torch.sigmoid(x) * x? Actually x = torch.sigmoid(x) * x: it first computes sigmoid(x) which allocates a temp, then multiplies with x (elementwise), writing to another temp, then x = ... So there are multiple temporaries. Even if x is reused, the graph may not fuse. But eager mode will launch separate kernels. So memory traffic is high. Our fused kernel reads the matmul output once, does everything, writes final output once. So memory traffic for these ops is ~2 * 536MB = 1.07GB vs maybe ~4-5 passes. That could be significant.
But can we reduce memory traffic further by fusing the matmul with the rest? Not without doing a custom matmul that is slower. However, we can perhaps use the CUTLASS epilogue to fuse the activation and bias into the matmul. But we can't easily call CUTLASS from a custom CUDA extension unless we ship it. PyTorch's linear uses cuBLAS. There is no easy way to fuse a custom epilogue with cuBLAS via standard API. There is cublasLt with epilogues (like ReLU, bias), but Swish is not a standard epilogue. So not possible.
Alternative: We can do a split approach: use cuBLAS for the matmul, then custom kernel. That is likely the best.
But is there any way to speed up groupnorm itself? We can compute mean and variance per group efficiently using warp shuffles.
Given M=32768, C=4096, G=64, C/G=64. For each sample, we have 64 groups of 64 channels. We need to compute mean and var for each group, then normalize, then apply affine.
Our custom kernel can launch one block per sample (or per group). Let's consider launch configuration.
Option A: One block per sample. Block size = 128 or 256. For each sample, we read the 4096 channels (after swish+bias) from global memory into shared memory (16KB). Then compute mean/var per group. There are 64 groups * 64 channels = 4096. We can compute mean with a tree reduction.
But if one block per sample, total blocks = 32768. Each block processes 4096 elements. With 128 threads, each thread handles 32 elements. Shared memory for the row: 4096 floats = 16KB. Plus maybe 64 floats for mean, 64 for var = 256 bytes. Total ~16.5KB. Occupancy: ~8 blocks per SM. 32768 blocks total, enough.
Within a block, we need to compute per-group mean and var. Since data is in smem, we can do:
- __syncthreads()
- For each group g (0..63):
- Let threads 0..63 load smem[g*64 + threadIdx.x] (if threadIdx.x < 64). If block size > 64, we can use a sliding window or tree. Actually block size 128: we can process two groups at a time, or use all 128 threads for one group? For a group of 64 elements, using 128 threads means 64 threads load, 64 idle. That's okay but not great. Better to use 64 threads per group.
But we can also compute mean and var using warp-level primitives without smem for the row? Since 64 elements per group, we could have each warp (32 threads) handle half a group (32 elements), then warp shuffle to reduce. But we have 64 groups, so many warps.
Let's think about a more efficient mapping: One block per group per sample. So total blocks = 32768 * 64 = 2,097,152 blocks. That's a lot, but each block processes 64 elements. Block size = 64 threads. Each block reads 64 channels, computes mean and var with 64 threads, normalizes, writes back. This is highly parallel. But overhead of launching 2M blocks might be high? Actually GPUs can schedule many blocks. 2M blocks / ~108 SMs ~ 19k blocks per SM. That's fine. But block size 64 might limit occupancy because each block uses few threads. However, each block does little work: 64 elements. Arithmetic intensity is low. Memory traffic: reading 64 floats per block = 256 bytes. Writing 64 floats = 256 bytes. So each block moves 512 bytes. With 2M blocks, total memory traffic is 1GB (read+write). This is okay. But the kernel launch overhead might be non-trivial? Usually kernels with millions of blocks are fine.
But computing mean and var per block of 64 elements is trivial. However, each block must read the same 64 channels from global memory. The channels for a group are contiguous (since group normalization groups contiguous channels). So memory access is coalesced.
Let's refine: We can have one block per (sample, group). So gridDim = (num_groups, batch_size) or (batch_size, num_groups). BlockDim = 64 (threads). Each thread handles one channel.
But we need to apply swish and external bias before group norm. We can compute swish and bias on the fly when loading. We need the linear output (with matmul bias) as input. We can compute swish and external bias, store in a register, then compute mean and var for the group.
Alternatively, we could have a separate kernel that does: given linear output (matmul+bias_internal), compute swish, add external bias, then groupnorm. We can read linear output once, compute swish+bias, keep the result in registers or shared memory, then compute mean/var, then normalize and write.
Actually we can do it in a single kernel with two passes over the data per block: first, each thread loads its assigned channel from global memory, applies swish and external bias, stores in shared memory (or registers). Then compute mean and var using shuffles or shared memory reduction. Then normalize and write back.
Since each block handles one group (64 channels), we can have 64 threads. Each thread loads one float. After applying swish+bias, we store it in a shared memory array of 64 floats. Then compute mean: use warp shuffle reduce across 64 threads (two warps). For 64 threads, we can do:
float val = smem[threadIdx.x];
float sum = val;
for (int offset = 32; offset > 0; offset /= 2) sum += __shfl_down_sync(0xFFFFFFFF, sum, offset);
if (threadIdx.x % 32 == 0) smem[threadIdx.x/32] = sum; // partial sum per warp
__syncthreads();
if (threadIdx.x < 2) {
sum = smem[threadIdx.x];
for (int offset = 1; offset > 0; offset /= 2) sum += __shfl_down_sync(0xFFFFFFFF, sum, offset);
if (threadIdx.x == 0) mean = sum / 64.0f;
}
__syncthreads();
mean = smem[0]; // broadcast
Alternatively, since it's only 64 elements, shared memory reduction is fine and maybe simpler.
Compute variance similarly: sum_sq = (val - mean)*(val - mean); reduce.
Then rstd = rsqrtf(var + eps);
val_norm = (val - mean) * rstd;
val_out = val_norm * gn_weight[channel] + gn_bias[channel];
Write to output.
This is very efficient. But we also need to handle the matmul output. We will call torch.matmul first, then this kernel. That means we have an intermediate tensor of size 536MB. The kernel reads it once (536MB) and writes output once (536MB). Total memory for this fused kernel = ~1GB. The reference does separate ops, each reading/writing 536MB. So memory traffic for the rest is ~4-5GB. So our fused kernel saves ~3-4GB of memory bandwidth. At 1.5 TB/s, that's ~2-3ms saved. The matmul is ~14ms. So total speedup maybe ~15-20%. That could be enough.
But we need to check if we can also fuse the linear bias addition (which is inside torch.matmul). Actually torch.nn.Linear includes bias addition in its forward. PyTorch's linear uses CUBLAS GEMM and then a beta=1? Actually F.linear calls addmm which fuses matrix multiplication and bias addition into a single cuBLAS call (GEMM with beta=1). So linear output includes bias already. Good. So our intermediate is after matmul+bias.
Then we need to apply swish: sigmoid(x)*x. Then add self.bias. Then group_norm.
So kernel signature: fused_swish_bias_gn(const float* input, float* output, const float* bias, const float* gn_weight, const float* gn_bias, int batch_size, int num_groups, int channels_per_group, float eps)
Wait, we also need the self.bias parameter. And gn_weight, gn_bias.
We need to pass all these tensors.
Implementation details:
- Input shape: (batch_size, out_features) = (32768, 4096). Row-major.
- Block: one block per group per batch element. So grid size = dim3(num_groups, batch_size). Or dim3(batch_size, num_groups). Need to ensure x dimension is num_groups or batch_size for better occupancy? Usually first dimension is fastest. We can set grid = dim3(batch_size, num_groups). Then blockIdx.x is batch index, blockIdx.y is group index. BlockDim = channels_per_group (64) threads.
- Each thread computes global channel index: channel = blockIdx.y * channels_per_group + threadIdx.x.
- Load input[batch * out_features + channel].
- Apply swish: v = sigmoid(v) * v. We need to compute sigmoid: 1/(1+exp(-v)). For numerical stability, maybe use fast math? We need fp32 tolerance 1e-4. fast math for exp might be okay but could be slightly less accurate. We'll use standard expf for safety, or maybe __expf? The tolerance is 1e-4, so __expf is likely fine, but let's use expf.
- Add bias: v += bias[channel].
- Store in shared memory smem[threadIdx.x] = v.
- __syncthreads().
- Compute mean and variance.
- Compute sum using shared memory reduction. Since 64 threads, we can do:
float sum = smem[threadIdx.x];
// reduction in shared memory
for (int s = 32; s > 0; s >>= 1) {
if (threadIdx.x < s) smem[threadIdx.x] += smem[threadIdx.x + s];
__syncthreads();
}
float mean = smem[0] / 64.0f;
But careful: we need to keep the values for variance. So either we use a separate shared memory for reduction or do two passes. Since 64 threads, we can store values in a register 'val', and use a separate shared memory array 'sdata' for reduction. Or we can do warp shuffle.
Let's do warp shuffle for speed and avoid extra smem:
float val = ...; // after swish+bias
float sum = val;
sum += __shfl_down_sync(0xFFFFFFFF, sum, 32);
sum += __shfl_down_sync(0xFFFFFFFF, sum, 16);
sum += __shfl_down_sync(0xFFFFFFFF, sum, 8);
sum += __shfl_down_sync(0xFFFFFFFF, sum, 4);
sum += __shfl_down_sync(0xFFFFFFFF, sum, 2);
sum += __shfl_down_sync(0xFFFFFFFF, sum, 1);
if (threadIdx.x % 32 == 0) sdata[threadIdx.x / 32] = sum; // sdata size 2
__syncthreads();
if (threadIdx.x < 2) {
sum = sdata[threadIdx.x];
sum += __shfl_down_sync(0xFFFFFFFF, sum, 1);
if (threadIdx.x == 0) {
mean = sum / 64.0f;
sdata[0] = mean;
}
}
__syncthreads();
mean = sdata[0];
// variance
float diff = val - mean;
float sq = diff * diff;
float var_sum = sq;
var_sum += __shfl_down_sync(0xFFFFFFFF, var_sum, 32);
...
same reduction
if (threadIdx.x == 0) {
float var = var_sum / 64.0f;
sdata[0] = rsqrtf(var + eps);
}
__syncthreads();
float rstd = sdata[0];
float normed = (val - mean) * rstd;
float out_val = normed * gn_weight[channel] + gn_bias[channel];
output[batch * out_features + channel] = out_val;
Wait: we need to be careful about using the same sdata for both mean and variance reduction. sdata is a shared memory array of size 2 (or just 64). Since each warp writes to sdata[warp_id], then thread 0..1 read. That's fine.
But note: for __shfl_down_sync, we need to mask active threads. With block size 64, two warps. The warp shuffle only works within a warp. So above code is correct: each warp reduces to lane 0. Then lane 0 writes to sdata[warp_id]. Then threads 0 and 1 read and reduce. That's a standard approach.
One nuance: if block size is 64, threads 32..63 are in second warp. The first reduction step uses mask 0xFFFFFFFF for each warp separately. That's fine because __shfl_down_sync operates within the warp. The sync mask just says which threads participate; but it's per-warp. So all threads in warp use 0xFFFFFFFF.
What about negative values and sigmoid? Using standard expf. We can compute sigmoid as fmaf(-fabsf(v), ...)? Actually for numerical stability, sigmoid can be computed as:
if (v >= 0) z = expf(-v); return 1/(1+z);
else z = expf(v); return z/(1+z);
This avoids overflow for large negative v. We'll do that for accuracy, though for random inputs it's not critical. But it's cheap.
Let's double-check groupnorm formula in PyTorch. It computes mean and variance across the group (including all spatial locations, but here spatial is 1). Then normalization: (x - mean) / sqrt(var + eps). Then affine: out = weight * norm + bias. So yes. Eps default in GroupNorm is 1e-5.
We need to pass eps = 1e-5.
Now, we need to write the host wrapper using torch.utils.cpp_extension.load_inline. The wrapper should accept torch tensors (all contiguous float tensors) and output a tensor.
We also need to consider that PyTorch's Linear weight is stored as [out_features, in_features]. When we call self.matmul(x), PyTorch computes x @ W^T + b. We can just call torch.nn.functional.linear(x, self.matmul.weight, self.matmul.bias) or self.matmul(x). That's fine. We want to use the optimized cuBLAS path.
Now, we need to write the full ModelNew class.
Architecture:
```python
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.cpp_extension import load_inline
cuda_source = r'''
#include <torch/extension.h>
#include <cuda_runtime.h>
#include <cmath>
__device__ __forceinline__ float sigmoid(float x) {
if (x >= 0) {
float z = expf(-x);
return 1.0f / (1.0f + z);
} else {
float z = expf(x);
return z / (1.0f + z);
}
}
__global__ void fused_swish_bias_gn_kernel(
const float* __restrict__ input,
float* __restrict__ output,
const float* __restrict__ bias,
const float* __restrict__ gn_weight,
const float* __restrict__ gn_bias,
int batch_size,
int out_features,
int num_groups,
int channels_per_group,
float eps)
{
int batch_idx = blockIdx.x;
int group_idx = blockIdx.y;
int lane = threadIdx.x;
int channel = group_idx * channels_per_group + lane;
int idx = batch_idx * out_features + channel;
float v = input[idx];
// Swish: x * sigmoid(x)
v = v * sigmoid(v);
// Add external bias
v += bias[channel];
// Compute mean and variance for this group
// First, reduce sum across 64 threads
float sum = v;
sum += __shfl_down_sync(0xFFFFFFFF, sum, 32);
sum += __shfl_down_sync(0xFFFFFFFF, sum, 16);
sum += __shfl_down_sync(0xFFFFFFFF, sum, 8);
sum += __shfl_down_sync(0xFFFFFFFF, sum, 4);
sum += __shfl_down_sync(0xFFFFFFFF, sum, 2);
sum += __shfl_down_sync(0xFFFFFFFF, sum, 1);
__shared__ float sdata[2]; // for inter-warp reduction
if ((lane & 31) == 0) {
sdata[lane >> 5] = sum;
}
__syncthreads();
if (lane < 2) {
sum = sdata[lane];
sum += __shfl_down_sync(0xFFFFFFFF, sum, 1);
if (lane == 0) {
sdata[0] = sum / (float)channels_per_group;
}
}
__syncthreads();
float mean = sdata[0];
float diff = v - mean;
float sq = diff * diff;
sum = sq;
sum += __shfl_down_sync(0xFFFFFFFF, sum, 32);
sum += __shfl_down_sync(0xFFFFFFFF, sum, 16);
sum += __shfl_down_sync(0xFFFFFFFF, sum, 8);
sum += __shfl_down_sync(0xFFFFFFFF, sum, 4);
sum += __shfl_down_sync(0xFFFFFFFF, sum, 2);
sum += __shfl_down_sync(0xFFFFFFFF, sum, 1);
if ((lane & 31) == 0) {
sdata[lane >> 5] = sum;
}
__syncthreads();
if (lane < 2) {
sum = sdata[lane];
sum += __shfl_down_sync(0xFFFFFFFF, sum, 1);
if (lane == 0) {
float var = sum / (float)channels_per_group;
sdata[0] = rsqrtf(var + eps);
}
}
__syncthreads();
float rstd = sdata[0];
float norm = diff * rstd;
float out_val = norm * gn_weight[channel] + gn_bias[channel];
output[idx] = out_val;
}
```
Wait, channels_per_group is 64. Block size = 64. But we need to handle __shfl_down_sync with 0xFFFFFFFF. For 64 threads, the first warp (0-31) and second warp (32-63) each have their own shuffle. That's fine. But one issue: in the second warp, lane IDs are 0..31 in that warp. But `lane = threadIdx.x`. For thread 32, lane=32. `lane & 31` = 0, so it will write to sdata[0] (since lane>>5 = 1? Wait lane=32 >>5 = 1. So it writes sdata[1]. Good. For lane=0 (thread 0), writes sdata[0]. For lane=32 (thread 32), writes sdata[1]. So inter-warp reduction uses sdata[2].
However, in the second reduction step (`if (lane < 2)`), threads 0 and 1 participate. They read sdata[0] and sdata[1]. This is fine because all threads in the block are in the same CTA, and only threads 0 and 1 are active in that step.
One nuance: __syncthreads() inside the kernel requires all threads in the block to reach it. In our code, after the first warp shuffle, all threads reach __syncthreads() after writing to sdata. The threads that did not write also reach it. Good.
After the first mean computation, we have __syncthreads(). Then diff, sq, sum, shuffle, __syncthreads(), etc. All good.
But wait: `if (lane < 2) { sum = sdata[lane]; ... }` This is a conditional on lane. Do we need __syncthreads() after that? Yes, we have __syncthreads() after that block. Good.
Potential issue: `if (lane < 2) { sum += __shfl_down_sync(0xFFFFFFFF, sum, 1); ... }` This is active for threads 0 and 1. But in a warp, thread 0 and 1 are both in warp 0. Thread 1's __shfl_down_sync reads from thread 2 (which is not active because lane < 2, but in the same warp, thread 2 is active because the mask is 0xFFFFFFFF; it will use thread 2's value, but thread 2 didn't execute the assignment `sum = sdata[lane];`. It still has whatever value was in its register `sum` from before (the sq reduction for thread 2). Wait, that could be wrong: In the variance reduction step, we have:
```
float sq = diff * diff;
sum = sq;
sum += __shfl_down_sync(...);
...
if ((lane & 31) == 0) sdata[lane>>5] = sum;
__syncthreads();
if (lane < 2) {
sum = sdata[lane];
sum += __shfl_down_sync(0xFFFFFFFF, sum, 1);
if (lane == 0) { ... }
}
```
Note: In the `if (lane < 2)` block, threads 0 and 1 are active. Thread 0 calls __shfl_down_sync with mask 0xFFFFFFFF and offset 1. This means it reads from thread 1. Thread 1 is also active, so okay. But thread 1 also calls __shfl_down_sync with offset 1, reading from thread 2. Thread 2 is not in the `if` block, but because the mask is 0xFFFFFFFF, all threads in the warp must be active or the behavior is undefined? Actually __shfl_down_sync requires that all threads named in the mask are convergent and execute the instruction. In a branch, if some threads are inactive (diverged), then using a mask that includes inactive threads is okay as long as they eventually execute the instruction? No, in CUDA, when threads diverge within a warp, they are serialized. The __shfl_sync intrinsic must be called by all threads in the mask; if some threads in the mask take a different path and don't execute the shfl, the result is undefined. So inside a branch like `if (lane < 2)`, we should not use mask 0xFFFFFFFF including threads >= 2 because they won't execute the shfl. We need to use a mask that includes only the participating threads, or do it differently.
In our case, `lane < 2` only includes threads 0 and 1 in warp 0. But thread 1's __shfl_down uses offset 1, which would need thread 2 in the same warp (lane 2). Thread 2 is not active. So this is problematic.
A safer approach: do the final reduction with just thread 0 reading both values, or use proper mask. For 2 warps, we can just have thread 0 read sdata[0] and sdata[1] and add them. Simpler:
```
if (lane == 0) {
float total = sdata[0] + sdata[1];
sdata[0] = total / (float)channels_per_group;
}
__syncthreads();
```
Similarly for variance:
```
if (lane == 0) {
float total = sdata[0] + sdata[1];
float var = total / (float)channels_per_group;
sdata[0] = rsqrtf(var + eps);
}
__syncthreads();
```
This avoids shuffles in the branch and is safe because only thread 0 does the work. For 2 elements, it's fine. Let's change to that.
But there is a subtlety: if block size is 64, sdata[2] is enough. But if we want to be generic for any channels_per_group, maybe a loop reduction in shared memory is better. But channels_per_group is fixed at 64. So we can just write specific code for 64 threads with 2 warps.
Alternatively, we can use shared memory reduction directly for 64 elements without warp shuffle. Since 64 is small, we can do:
```
__shared__ float sdata[64]; // or 128 to hold both val and reduction
sdata[lane] = v;
__syncthreads();
// reduce sum
for (int s = 32; s > 0; s >>= 1) {
if (lane < s) sdata[lane] += sdata[lane + s];
__syncthreads();
}
float mean = sdata[0] / 64.0f;
__syncthreads();
sdata[lane] = (v - mean)*(v - mean);
__syncthreads();
for (int s = 32; s > 0; s >>= 1) {
if (lane < s) sdata[lane] += sdata[lane + s];
__syncthreads();
}
float rstd = rsqrtf(sdata[0]/64.0f + eps);
...
```
This is simpler, uses a bit more shared memory (64 floats = 256 bytes), and synchronization. It might be slightly slower than warp shuffle but for 64 elements it's negligible. And it's safe.
Let's adopt this simpler shared memory reduction to avoid warp shuffle divergence issues. It's also easier to maintain.
So per block:
```
__shared__ float smem[64]; // maybe use 64 or 128.
```
Actually we need to store the values for the final normalization too. But we can keep them in registers. So:
```
float v = input[idx];
v = v * sigmoid(v);
v += bias[c];
smem[lane] = v;
__syncthreads();
// compute mean
float sum = smem[lane];
#pragma unroll
for (int offset = 32; offset > 0; offset >>= 1) {
if (lane < offset) smem[lane] += smem[lane + offset];
__syncthreads();
}
float mean = smem[0] / 64.0f;
// compute variance
smem[lane] = (v - mean) * (v - mean);
__syncthreads();
for (int offset = 32; offset > 0; offset >>= 1) {
if (lane < offset) smem[lane] += smem[lane + offset];
__syncthreads();
}
float var = smem[0] / 64.0f;
float rstd = rsqrtf(var + eps);
float norm = (v - mean) * rstd;
output[idx] = norm * gn_weight[c] + gn_bias[c];
```
But note: after the first reduction loop, smem[0] holds sum, but smem[lane] for lane>0 is destroyed. That's okay because we have v in register. We reuse smem for variance.
We need to be careful: `smem[0]` after first reduction contains sum. We compute mean. Then we write `(v-mean)*(v-mean)` to smem[lane]. Then reduce. Works.
However, there is a problem with the reduction loop: if block size is 64, and we do:
```
for (int offset = 32; offset > 0; offset >>= 1) {
if (lane < offset) smem[lane] += smem[lane + offset];
__syncthreads();
}
```
This works because each iteration, threads < offset read from lane+offset which is within 64. After each iteration, we sync. But after offset=32, threads 0..31 add smem[32..63] to smem[0..31]. Then offset=16: threads 0..15 add smem[16..31] to smem[0..15]. Etc. Finally smem[0] is sum. This is standard.
But note: we used `smem[lane] = v` before reduction. In the loop, we do `smem[lane] += smem[lane+offset]`. After first iteration, smem[0..31] contains partial sums. smem[32..63] is unchanged. Then offset=16: threads 0..15 read smem[16..31] (which contain partial sums of pairs). That's fine.
But the first line `float sum = smem[lane];` is unnecessary. We can just operate on smem directly.
Wait, there's a potential race condition? No, because __syncthreads ensures all threads have written before reading.
One more thing: after the final `output[idx] = ...`, we are done.
Shared memory size: 64 floats = 256 bytes. Very small.
Launch config:
- blockDim = (64, 1, 1)
- gridDim = (batch_size, num_groups, 1)
Total threads per block = 64. Total blocks = 32768 * 64 = 2,097,152. Occupancy: Since each block is small (64 threads, 256 bytes smem), many blocks can run concurrently per SM. Registers per thread: maybe ~20-30. So occupancy should be high.
But is there a better launch config? Maybe use a 1D grid with batch_size * num_groups blocks? Then blockIdx.x determines both batch and group. This might be slightly simpler:
```
int total_blocks = batch_size * num_groups;
int block_id = blockIdx.x;
int batch_idx = block_id / num_groups;
int group_idx = block_id % num_groups;
```
But cuda grid dims are limited to 2^31-1 for x, which is fine. However, using 2D grid might be okay. The evaluation GPU may have launch overhead. But 2M blocks is fine.
Alternatively, we could process multiple groups per block to reduce block count. For example, block size = 128, each block processes 2 groups (64*2=128 channels). Then total blocks = 32768 * 32 = 1,048,576. Each thread handles one channel. The reduction for each group of 64 can be done within half the block. But then we need to separate reductions, which is more complex. Let's keep it simple: 1 group per block.
But 2M blocks might have some launch overhead. However, the GPU can handle it. Each block does small work. Let's estimate memory bandwidth. Each block reads 64 floats (256 bytes) and writes 64 floats (256 bytes). So 512 bytes/block. 2M blocks -> 1GB total memory traffic. At 1500 GB/s, memory time is ~0.67 ms. Plus reduction overhead. So kernel time maybe ~1 ms. The reference for just groupnorm is maybe ~1-2 ms. So fused kernel might be ~1-1.5 ms. The matmul is ~14 ms. So total reference ~16 ms, ours ~15 ms. 6% speedup. Maybe enough? But could be borderline. We need to beat the reference by some margin.
Is there any way to also fuse the linear bias addition? Wait, PyTorch's linear already fuses bias. But can we do the matmul ourselves in a more efficient way than cuBLAS? No. Unless we use a different layout or use TensorFloat-32? But reference uses PyTorch's Linear which uses TF32 on Ampere if allowed? Actually PyTorch may use TF32 by default for convolutions and matmuls on Ampere. If the evaluation runs on Ampere/A100 and TF32 is enabled, the reference matmul is using TF32 tensor cores, which are fast (156 TFLOPS). That would make the matmul much faster (~2 ms). Let's compute: TF32 tensor core peak on A100 is 156 TFLOPS. 275 GFLOPs / 156 TFLOPS = 1.76 ms. If matmul is that fast, then the rest of the ops become a larger fraction of total time. In that case, fusing the rest may give a more noticeable speedup.
Wait, but the problem says fp32 precision. TF32 is not true fp32; it's a mixed format. However, PyTorch enables TF32 by default for matmul on Ampere. But the tolerance for fp32 is 1e-4. TF32 might produce slightly different results, but within tolerance? The reference uses PyTorch nn.Linear, which will use TF32 if enabled. Our custom kernel must match the reference output. If we use torch.matmul for linear in ModelNew, it will also use TF32, so outputs match. But if we write custom matmul in fp32, we might get different results than TF32 reference, potentially outside tolerance? Actually TF32 has about 1e-3 relative precision (10-bit mantissa vs 23-bit). For random weights, the difference might be around 1e-4 or 1e-3. Tolerance is 1e-4. If the reference uses TF32, then doing true fp32 might produce outputs that differ by more than 1e-4. So we must ensure we use the same matmul path as reference.
But the reference is `self.matmul(x)` which is `nn.Linear`. In PyTorch, `nn.Linear` calls `F.linear(x, self.weight, self.bias)`, which calls `addmm`. On CUDA, `addmm` uses cuBLAS. For A100, if TF32 is enabled (the default since PyTorch 1.7?), then matmul uses TF32. However, the problem says "produce numerically equivalent outputs at fp32 precision." The evaluation compares against the reference, so whatever the reference does, we need to match. So we can just call `torch.matmul` or `F.linear` for the matmul part, then our fused kernel. That's safe.
But does PyTorch GroupNorm use TF32? No, it's elementwise.
So using torch.matmul for linear is safe.
Now, can we further optimize by not allocating the intermediate output of linear? We have to, because the linear output is needed by our kernel. We can re-use the same storage: linear_out = F.linear(x, self.matmul.weight, self.matmul.bias); then call our fused kernel that reads linear_out and writes to a new output tensor (or in-place on linear_out). But writing in-place to the same tensor might be okay if our kernel reads and writes the same memory, but we need to be careful about coalescing. We could write in-place: our kernel modifies linear_out in-place. Let's do that: allocate output = torch.empty_like(linear_out) or do in-place.
Actually, we can allocate output = linear_out (or just modify linear_out in place). But our kernel reads from `input` and writes to `output`. If they are the same pointer, is that safe? We read a value, compute, write back. Since each thread reads and writes its own element, no conflict. So we can do in-place. That saves memory allocation and one copy. Let's do that: we compute `y = F.linear(x, self.matmul.weight, self.matmul.bias)`, then launch kernel with `y` as both input and output (in-place). But then we need to pass the pointer. The kernel reads from global memory and writes back. It should be safe. However, we need to be careful about caching: reading and writing the same address might cause a write-after-read dependency, but that's okay. So:
```python
def forward(self, x):
y = F.linear(x, self.matmul.weight, self.matmul.bias)
# launch kernel on y in-place
fused_kernel(y, self.bias, self.group_norm.weight, self.group_norm.bias, self.eps)
return y
```
But we need to preserve the input `y` if we want to return it. We can just modify `y` and return it. That avoids allocating another tensor.
Wait, but the kernel writes to output. If input and output are the same pointer, it's fine. In CUDA C++, we can mark both as `float* input_output`. Or we can have separate input and output pointers but pass the same tensor data pointer.
Let's design the kernel to accept a single pointer for in-place operation:
```cpp
__global__ void fused_swish_bias_gn_kernel(
float* data,
const float* bias,
...
)
```
Each thread reads `float v = data[idx];`, computes, writes `data[idx] = out_val;`. That's in-place.
In the Python wrapper, we can pass the same tensor's data_ptr for both input and output. Actually in pybind wrapper, we can just pass one tensor.
But we also need to handle the case where the tensor might not be contiguous? In our case, `F.linear` returns a contiguous tensor. But we should check `.is_contiguous()`. And we can assert that.
Now, let's think about the overall approach. We can also consider using vectorized loads (float4) to improve memory bandwidth. Since each thread handles one channel, we can process 4 channels per thread (float4). Then block size = 64 threads, but each thread processes 4 channels => 256 channels per block. That's one group? Wait, channels per group = 64. If each thread processes 4 channels, block processes 256 channels = 4 groups. Then reduction across 64 channels per group becomes more complex because threads are interleaved across multiple groups. But we can do vectorized memory access and process 4 groups per block. Let's think if it's worth it.
Vectorized loads (float4) can improve bandwidth by 4x for the elementwise part. However, the elementwise part is small. The reduction part is compute-light. Vectorization might complicate the reduction.
Alternatively, we can keep it simple (1 thread per channel) and rely on memory coalescing (which is already good because contiguous threads read contiguous addresses within a group). Actually, within a block (one group), threads 0..63 read channels group*64 + 0..63. Those are contiguous. So loads are perfectly coalesced. Even without float4, the memory controller will combine loads across the warp into few transactions. Since 64 threads (2 warps) read 64 contiguous floats = 256 bytes. That's 2 cache lines (128 bytes each). So each warp reads 128 bytes? Actually warp 0 reads 32 floats = 128 bytes = 1 cache line. Warp 1 reads next 32 floats = 1 cache line. So 2 transactions per block. That's already optimal.
For writing, same. So memory bandwidth is near peak for these accesses. The reduction in shared memory is fast. So the kernel should be very efficient.
One potential improvement: avoid the second `__syncthreads()` by computing variance in a different way? Not necessary.
Now, let's consider if we can also fuse across batch dimension: process multiple samples per block? For example, block size = 64*4 = 256 threads, processing 4 groups from the same sample. That reduces the number of blocks by 4x and may improve occupancy? Actually occupancy is already high because blocks are tiny. Having fewer blocks might reduce scheduling overhead but not much. 2M blocks is fine.
But there is another issue: kernel launch overhead. Launching 2M blocks might take some time (~0.1 ms). Still fine.
Alternatively, we can use a 1D grid with `batch_size * num_groups` blocks, each with 64 threads. That's what we'll do.
Let's write the code.
Note: `load_inline` may cache the compiled extension. We need to define the host function that takes torch::Tensor arguments and returns void (or tensor). We can define:
```cpp
void fused_swish_bias_gn(
torch::Tensor input, // in-place
torch::Tensor bias,
torch::Tensor gn_weight,
torch::Tensor gn_bias,
int num_groups,
float eps)
{
int batch_size = input.size(0);
int out_features = input.size(1);
int channels_per_group = out_features / num_groups;
float* data_ptr = input.data_ptr<float>();
const float* bias_ptr = bias.data_ptr<float>();
const float* gn_weight_ptr = gn_weight.data_ptr<float>();
const float* gn_bias_ptr = gn_bias.data_ptr<float>();
dim3 block(channels_per_group);
dim3 grid(batch_size * num_groups);
fused_swish_bias_gn_kernel<<<grid, block>>>(
data_ptr, bias_ptr, gn_weight_ptr, gn_bias_ptr,
batch_size, out_features, num_groups, channels_per_group, eps);
}
```
Wait, grid = batch_size * num_groups. But we need to reconstruct batch_idx and group_idx inside the kernel:
```
int block_id = blockIdx.x;
int batch_idx = block_id / num_groups;
int group_idx = block_id % num_groups;
int lane = threadIdx.x;
int channel = group_idx * channels_per_group + lane;
int idx = batch_idx * out_features + channel;
```
This works. blockDim.x = channels_per_group (64). gridDim.x = batch_size * num_groups (2,097,152). That's within limits (gridDim.x max is 2^31-1). Good.
Now, writing the host wrapper. We need to check that inputs are contiguous, float, on CUDA. We can add checks using `AT_ASSERTM` or just assume since we control the call.
Let's also think about using `__launch_bounds__` to hint register usage? Not necessary.
Potential precision issue: sigmoid. We'll compute with stable version.
Potential performance: using `expf` is fine. Could use `__expf` for faster but less accurate. Tolerance is 1e-4. For swish, the gradient of sigmoid is max at 0 (0.25). So error in sigmoid translates roughly linearly to output. With random inputs in [-5,5], `__expf` error is < 1e-3 relative? Actually `__expf` intrinsic has max error of about 2 ulp? I think it's within ~2-3 ulp. For fp32, ulp is ~1e-7 relative, so absolute error is small. Even for large inputs, the result of sigmoid saturates to 0 or 1, so error is tiny. So `__expf` is probably fine. But to be safe, use `expf`. The compiler might optimize it to intrinsic anyway at -O3. In nvcc with fast math? By default, `expf` without `--use_fast_math` is accurate. But PyTorch's cpp_extension probably compiles with `-O3` but not `--use_fast_math` by default. So `expf` is the standard function.
Actually, PyTorch's `load_inline` may add flags. But let's use `expf` for correctness.
Now, about GroupNorm parameters: The reference uses default eps=1e-5 and affine=True. So we need to pass eps=1e-5. We can store it as a constant or pass it.
ModelNew class:
```python
class ModelNew(nn.Module):
def __init__(self, in_features, out_features, num_groups, bias_shape):
super().__init__()
self.matmul = nn.Linear(in_features, out_features)
self.bias = nn.Parameter(torch.randn(bias_shape))
self.group_norm = nn.GroupNorm(num_groups, out_features)
self.eps = 1e-5
def forward(self, x):
y = F.linear(x, self.matmul.weight, self.matmul.bias)
# call custom kernel
_C.fused_swish_bias_gn(y, self.bias, self.group_norm.weight, self.group_norm.bias, self.group_norm.num_groups, self.eps)
return y
```
We need to make sure `self.group_norm.num_groups` is accessible (it is stored as `self.group_norm.num_groups`). Actually `nn.GroupNorm` stores `num_groups`. Yes.
Now, we need to put this together in a single Python file with `load_inline` at module level. The file is `exec()`'d, so top-level code runs.
Potential issue: `load_inline` caches based on source hash and build directory. We should provide a unique name or build directory to avoid collisions with other runs. But we can just let it default. In these evaluation environments, we usually specify `build_directory` or ensure it's compiled each time. However, `load_inline` will try to compile on first import and cache. That's fine.
Let's write the code.
Wait, one more thought: The reference `self.bias` parameter has shape `(out_features,)`. In `Model.__init__`, it is `torch.randn(bias_shape)`. Here `bias_shape` is `(out_features,)`. So we must use `bias_shape` to create the parameter. That is fine.
Now, is there any chance that the evaluation code checks that `ModelNew` has the same attributes/modules as `Model`? Probably just parameter names for state dict. Since we used `self.matmul`, `self.bias`, `self.group_norm`, the state dict keys will match: `matmul.weight`, `matmul.bias`, `bias`, `group_norm.weight`, `group_norm.bias`. Good.
One more thing: The evaluation may call `model.eval()` or `model.train()`. Our `ModelNew` is aModule, so that's fine.
Now, let's think about whether we can further optimize by also fusing the linear weight multiplication with the swish etc. using CUTLASS or a custom kernel. Since we decided not to do custom matmul, we rely on cuBLAS.
But there is another approach: we can use `torch.addmm` which is the same as `F.linear`. Or we can manually call `torch.matmul(x, self.matmul.weight.t()) + self.matmul.bias`. This might not fuse bias, but `F.linear` fuses it.
Wait, does `F.linear(x, weight, bias)` call `addmm` and thus use cuBLAS bias fusion? Yes. Let's check: In PyTorch, `F.linear(input, weight, bias)` computes `input.matmul(weight.t())`, and if bias is not None, it returns `output + bias`. Actually, looking at the source, `F.linear` is implemented as `torch._C._nn.linear(input, weight, bias)` which eventually calls `addmm` with beta=1, alpha=1? Or it uses `aten::addmm` which is `self + matmul(mat1, mat2)`. `addmm` is a single ATen op that calls cuBLAS `cublasSgemm` with beta=1 for the bias if bias is contiguous? Actually, `addmm` out-of-place may allocate output and call `cublasSgemm` with C pointer set to bias and beta=1. So yes, bias is fused.
So `F.linear` is efficient.
Now, writing the host wrapper. We must ensure we include `<torch/extension.h>` and the CUDA kernel.
Potential issue: The kernel uses `blockIdx.x` up to 2M. This is fine.
Another thought: Could we reduce block count by having each block handle an entire sample (4096 channels)? Then we have 32768 blocks. Each block has 256 threads, each thread handles 16 channels. The reduction for 64 groups within the block would be more complex but manageable. 32k blocks is fewer and might reduce launch overhead. Let's examine if 2M blocks is okay.
On an A100, the kernel launch overhead for 2M blocks is maybe ~0.05 ms? Actually kernel launch overhead is about 5-10 microseconds. The time to execute 2M blocks is determined by throughput. The scheduling hardware can handle many blocks. Each block is tiny (64 threads), so one warp takes a few cycles. The GPU will process them quickly. Should be fine.
But there might be a subtle issue: if we have only 64 threads per block, the warp count is 2. The occupancy might be limited by the maximum number of blocks per SM, which is 32? Actually on Ampere, max blocks per SM is 32. With 64 threads/block, max 32 blocks per SM = 2048 threads per SM, which matches max threads per SM. So occupancy can be 100% (2048 threads / 2048 max). With 2M blocks and 108 SMs, total concurrency is 108*32 = 3456 blocks. So the rest are queued. That's fine.
So our kernel config should achieve good occupancy.
Let's code it up.
One more nuance: The group norm reduction uses `__syncthreads()` inside a loop. For 64 threads, this is fine. All threads in the block will hit the syncthreads because they all have lane < 64 and the condition `if (lane < offset)` is true for all threads when offset >= 64? Wait, blockDim.x = 64. For offset = 32, lane < 32 is true for threads 0..31, false for 32..63. The threads 32..63 will skip the `if` body but must still reach `__syncthreads()`. Since `__syncthreads()` is after the if block, all threads reach it. So it's fine.
But note: inside the if block, only threads < offset write to smem. Threads >= offset still reach `__syncthreads()`, so there is no deadlock.
Potential performance: using `if (lane < offset)` inside a loop causes divergence for small offsets. But the block is only 2 warps. Divergence for offset=16,8,4,2,1 is minor. For offset=32, warp 0 active, warp 1 inactive. After that, only warp 0 is active. The second warp is idle but waiting at syncthreads. That's okay.
Could we unroll the loop manually? The compiler will likely unroll it. We can add `#pragma unroll` before the for loop.
Also, we need to compute `rsqrtf`. That's fine.
One more thought: can we compute mean and variance with online Welford algorithm to reduce passes? Welford would require a sequential scan across the 64 values, which is not parallelizable within the thread group without shared memory. We already have shared memory, so two-pass is fine and maybe more numerically stable? PyTorch uses Welford for GroupNorm? Actually GroupNorm likely computes mean then variance in two passes or uses Welford. Let's check typical PyTorch implementation. In PyTorch, GroupNorm on CUDA might use a custom kernel that computes mean and variance with shared memory. The variance is computed as E[x^2] - (E[x])^2 or two-pass. The two-pass method is slightly less numerically stable but for 64 numbers and fp32, it's fine. Our reduction computes sum of squares of differences, which is equivalent to two-pass but better than E[x^2] - mean^2. However, the exact formula might differ in rounding. We need to match PyTorch within 1e-4.
Let's think about numerical differences:
- Our linear part: We use torch.nn.functional.linear, same as reference. So after linear, our tensor matches exactly (assuming same weight/bias).
- Swish: sigmoid(x)*x. In PyTorch, `torch.sigmoid(x)` computes elementwise sigmoid with a special function. In CUDA, PyTorch calls `sigmoid` kernel which uses a fast implementation: for float, it computes `1 / (1 + exp(-x))` with some branching for large x? Let's check PyTorch's CUDA implementation. In `aten/src/ATen/native/cuda/Activation.cu`, sigmoid uses `1.f / (1.f + expf(-x))` or something similar. Our implementation uses the stable version:
```
if (x >= 0) {
float z = expf(-x);
return 1.0f / (1.0f + z);
} else {
float z = expf(x);
return z / (1.0f + z);
}
```
This matches common stable sigmoid. Does PyTorch use this? In many places, yes. For `sigmoid` on CUDA, PyTorch uses `1 / (1 + exp(-x))` with `THCNumerics<T>::sigmoid` which for float is:
```
return one / (one + expf(-x));
```
Wait, but maybe they also handle overflow. Actually in ATen, the sigmoid for float is implemented as:
```
inline float sigmoid(float x) {
return 1.0f / (1.0f + expf(-x));
}
```
This is not overflow-safe for very negative x, because expf(-x) overflows to inf for x < ~ -88.72. But for random inputs from `torch.randn` (mean 0, std 1) or `torch.rand` (0..1), values are not that extreme. The linear output might be larger because weight * input + bias. Weights are from normal distribution, batch size 32768. The linear output standard deviation is sqrt(in_features) * std(weight) * std(input) ~ sqrt(1024) * 1 * 0.288? Actually rand is uniform [0,1], mean 0.5, std ~0.288. Weight is randn, std=1. So output per channel is sum of 1024 terms ~ N(0, 1024*0.288^2) = N(0, 84.6). Actually variance = sum var(w_i * x_i). x_i ~ U[0,1], var=1/12. w_i ~ N(0,1). So var(w_i*x_i) = E[w_i^2]E[x_i^2] - (E[w_i]E[x_i])^2 = 1*(1/3) - 0 = 1/3. Sum over 1024 = 341.3. std ~ 18.5. So linear output values can be around +/- 50 or more (3 sigma). `expf(-(-50)) = expf(50)` overflows to inf. So `1 / (1 + inf) = 0`. That's fine. `expf(-50)` underflows to 0, giving `1 / (1+0) = 1`. For x = 50, `expf(-50)` underflows to 0, fine. For x = -90, `expf(90)` overflows. But values > 90 are extremely unlikely (5 sigma of 18.5 is 92.5). Probability is very low. So overflow is unlikely but possible. However, to be safe, we used stable sigmoid. Does this match PyTorch's sigmoid exactly? PyTorch's kernel might use `1/(1+exp(-x))` and not the stable branch. On GPU, `expf` for x=-90: `expf(90)` overflows to inf, `1/(1+inf) = 0`. Stable sigmoid for x=-90: `z = expf(-90) = 0`, `1/(1+0)=1`. Wait, for x=-90 (negative large):
- Unstable: `1/(1+exp(90))` -> `1/inf` = 0.
- Stable: `z=exp(90)` overflows? Actually in stable version, for x>=0: z=exp(-x). For x<0: z=exp(x). So for x=-90 (<0): z=exp(-90) = 0. Then return z/(1+z) = 0/(1) = 0. So both give 0. Wait, I misread my code. For x<0 (negative), we compute `exp(x)`, which for x=-90 underflows to 0. So `z/(1+z)` = 0. Good. So stable and unstable give the same result for large negative x. For large positive x (e.g., x=90):
- Unstable: `exp(-90)=0`, `1/(1+0)=1`.
- Stable: `x>=0` branch: `z=exp(-90)=0`, `1/(1+0)=1`. Same.
So overflow only occurs for `exp(-x)` when x is large negative? Wait, for x large negative, unstable computes `exp(-x) = exp(very positive)` which overflows. Stable computes `exp(x) = exp(very negative)` which underflows. So stable is safe.
But for x large positive, unstable computes `exp(-x)` which underflows (fine), stable computes `exp(-x)` which underflows (fine). So the only difference is that unstable can overflow for very negative x, but we saw that still yields 0 after `1/(1+inf)`. Actually in floating point, `exp(90)` overflows to inf. `1/(1+inf)` gives 0. So the result is still 0. So for sigmoid, even the unstable formula doesn't produce wrong results for extreme inputs because overflow leads to 0 or 1 correctly. Underflows also lead to correct results. The only issue is if `exp(-x)` overflows to inf and then `1+inf = inf`, `1/inf = 0`. That's correct. So stable and unstable produce the same result for all finite floats, except maybe near the overflow threshold where `exp(-x)` overflows to inf but `exp(x)` underflows to 0? Actually for x = -88.72, `exp(-x) = exp(88.72)` overflows to inf. `1/(1+inf) = 0`. Stable: `x<0`, `z=exp(x)=exp(-88.72)` underflows to 0. `0/(1+0)=0`. Same result. For x = -88.7, `exp(88.7)` is about 3e38, close to max float. `1/(1+3e38) = 3e-39` (subnormal). Stable: `exp(-88.7) = 3e-39`. `3e-39/(1+3e-39) = 3e-39`. There's a tiny difference because unstable uses `1/(1+exp(88.7))`. If exp(88.7) is finite but large, the result is small. If exp(88.7) overflows to inf, result is 0. So there is a tiny range where unstable might give 0 due to premature overflow, while stable gives a subnormal number. But tolerance is 1e-4 absolute. Subnormal numbers are < 1e-38, so difference is negligible relative to 1e-4. So our stable sigmoid is fine. Actually we can just use `1.0f / (1.0f + expf(-x))` for simplicity and maximum match to PyTorch. Let's check PyTorch's implementation.
I can recall that in CUDA, PyTorch's `sigmoid` for float is implemented in `aten/src/ATen/native/cuda/UnaryOpsKernel.cu` or `Activation.cu`. The functor is:
```
template <typename T>
struct SigmoidFunctor {
__device__ __forceinline__ T operator()(T x) const {
return T(1) / (T(1) + exp(-x));
}
};
```
But they might use `std::exp` or `::exp`. For float, `::exp` is `expf`. I think they just do `1.f/(1.f + expf(-x));`. So using the same formula might match exactly.
However, there is a subtlety: PyTorch's `sigmoid` implementation on CPU uses `_sigmoid` which is stable for half precision but for float might be simple. On GPU, the CUDA kernel for sigmoid is likely `1/(1+exp(-x))`. Let's adopt that to maximize exact match.
But to avoid overflow issues (which are extremely rare and don't affect tolerance), we can just use the simple version. Let's do:
```
__device__ __forceinline__ float sigmoid(float x) {
return 1.0f / (1.0f + expf(-x));
}
```
Actually, we can skip defining a separate function and just compute `1.0f / (1.0f + expf(-v))` inline.
Now, for the GroupNorm mean/variance: PyTorch uses `mean` and `invstd` (inverse standard deviation). It computes variance as the average of squared differences. Let's check if there's any difference. In PyTorch's native CUDA kernel for GroupNorm, for NCHW with spatial=1, it likely computes mean across C_per_group and then var. The formula is:
```
mean = sum(x) / C_per_group
var = sum((x - mean)^2) / C_per_group
invstd = rsqrt(var + eps)
```
This matches our code.
One potential difference: in PyTorch, the sum reductions might be done in a tree using warp shuffles and shared memory, accumulating in float. The order of summation affects rounding. Our reduction order is a tree: first 0+32, 1+33, ..., 31+63; then 0+16,1+17,...; etc. This is similar to a standard tree reduction. PyTorch might do a different order. For 64 elements, the difference should be within a few ulps. Tolerance is 1e-4, which is huge compared to ulp differences for values of typical magnitude (<= maybe 100). So fine.
Now, we need to write the full module code.
Let's draft the python code.
```python
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.cpp_extension import load_inline
cpp_source = """
// host wrapper
void fused_swish_bias_gn(
torch::Tensor input,
torch::Tensor bias,
torch::Tensor gn_weight,
torch::Tensor gn_bias,
int num_groups,
float eps);
"""
cuda_source = r"""
#include <torch/extension.h>
#include <cuda_runtime.h>
__global__ void fused_swish_bias_gn_kernel(
float* __restrict__ data,
const float* __restrict__ bias,
const float* __restrict__ gn_weight,
const float* __restrict__ gn_bias,
int num_groups,
int channels_per_group,
int out_features,
float eps)
{
int block_id = blockIdx.x;
int batch_idx = block_id / num_groups;
int group_idx = block_id % num_groups;
int lane = threadIdx.x;
int channel = group_idx * channels_per_group + lane;
int idx = batch_idx * out_features + channel;
float v = data[idx];
// Swish: x * sigmoid(x)
v = v * (1.0f / (1.0f + expf(-v)));
// Add external bias
v += bias[channel];
// Use shared memory for reduction
extern __shared__ float smem[]; // size = channels_per_group
smem[lane] = v;
__syncthreads();
// Reduce to compute mean
#pragma unroll
for (int offset = channels_per_group / 2; offset > 0; offset >>= 1) {
if (lane < offset) {
smem[lane] += smem[lane + offset];
}
__syncthreads();
}
float mean = smem[0] / (float)channels_per_group;
// Compute variance
smem[lane] = (v - mean) * (v - mean);
__syncthreads();
#pragma unroll
for (int offset = channels_per_group / 2; offset > 0; offset >>= 1) {
if (lane < offset) {
smem[lane] += smem[lane + offset];
}
__syncthreads();
}
float var = smem[0] / (float)channels_per_group;
float rstd = rsqrtf(var + eps);
float norm = (v - mean) * rstd;
data[idx] = norm * gn_weight[channel] + gn_bias[channel];
}
"""
Wait, we declared `extern __shared__ float smem[];`. We need to pass the shared memory size at launch: `kernel<<<grid, block, channels_per_group * sizeof(float)>>>`. Since channels_per_group is 64, that's 256 bytes. Alternatively, we can declare fixed-size `__shared__ float smem[64];` but channels_per_group could be variable (though in this problem it's fixed). To be safe, we can use dynamic shared memory. In the host wrapper, we pass `channels_per_group * sizeof(float)`. If we use static `__shared__ float smem[64];`, it's slightly simpler but hardcodes 64. We can also template it or pass as compile-time constant. Let's use dynamic.
But in `cuda_source`, we must not put `extern __shared__ float smem[];` inside the function if we also include other functions? It's fine.
Host wrapper:
```cpp
void fused_swish_bias_gn(
torch::Tensor input,
torch::Tensor bias,
torch::Tensor gn_weight,
torch::Tensor gn_bias,
int num_groups,
float eps)
{
int batch_size = input.size(0);
int out_features = input.size(1);
int channels_per_group = out_features / num_groups;
float* data_ptr = input.data_ptr<float>();
const float* bias_ptr = bias.data_ptr<float>();
const float* gn_weight_ptr = gn_weight.data_ptr<float>();
const float* gn_bias_ptr = gn_bias.data_ptr<float>();
dim3 block(channels_per_group);
dim3 grid(batch_size * num_groups);
fused_swish_bias_gn_kernel<<<grid, block, channels_per_group * sizeof(float)>>>(
data_ptr, bias_ptr, gn_weight_ptr, gn_bias_ptr,
num_groups, channels_per_group, out_features, eps);
}
```
Wait, we need to ensure input is contiguous float on CUDA. We can add checks.
```cpp
TORCH_CHECK(input.is_cuda(), "input must be CUDA");
TORCH_CHECK(bias.is_cuda(), "bias must be CUDA");
TORCH_CHECK(gn_weight.is_cuda(), "gn_weight must be CUDA");
TORCH_CHECK(gn_bias.is_cuda(), "gn_bias must be CUDA");
TORCH_CHECK(input.is_contiguous(), "input must be contiguous");
TORCH_CHECK(bias.is_contiguous(), "bias must be contiguous");
TORCH_CHECK(gn_weight.is_contiguous(), "gn_weight must be contiguous");
TORCH_CHECK(gn_bias.is_contiguous(), "gn_bias must be contiguous");
TORCH_CHECK(input.dtype() == torch::kFloat32, "input must be float32");
...
```
These are good.
Now, the `cpp_source` string in `load_inline` is not needed if we define the host wrapper in the `cuda_source` or `cpp_sources`? The `load_inline` function takes `cuda_sources` and `cpp_sources`. Actually the signature is:
```python
load_inline(
name,
cpp_sources,
cuda_sources='',
functions=None,
extra_cuda_cflags=None,
...
)
```
`cpp_sources` is a string containing C++ host code. `cuda_sources` is a string containing CUDA device code. But you can also put everything in `cpp_sources` and leave `cuda_sources` empty if the code is `.cpp` with CUDA inside (using `__global__` etc.)? Actually for `.cu` compilation, `cuda_sources` is used. The build system compiles `cpp_sources` with C++ compiler and `cuda_sources` with NVCC. If we define the kernel in `cuda_sources` and the wrapper in `cpp_sources`, we need to declare the kernel in `cpp_sources` or include a header. But since they are compiled separately, the host wrapper in `cpp_sources` cannot call the kernel unless it's declared. Usually, with `load_inline`, it's easiest to put everything in one string and pass it to `cpp_sources` if the file extension is `.cpp`? Wait, no: if the source contains CUDA syntax (like `__global__`), it must be compiled with NVCC, so it should go in `cuda_sources`. But then the host wrapper can also be in `cuda_sources` because NVCC compiles both host and device code. Actually the function we want pybind11 to wrap is a host function. If we put it in `cuda_sources`, NVCC will compile it. `load_inline` puts `cuda_sources` into a `.cu` file and compiles it with NVCC. The `cpp_sources` go into a `.cpp` file compiled with the C++ compiler. The auto-generated pybind wrapper can call functions from either source, as long as they are declared in the combined translation unit? Actually `load_inline` generates a single `.cpp` file that includes the pybind11 module and calls the functions listed in `functions`. It might compile `cpp_sources` and `cuda_sources` into separate objects and link them. So a host function defined in `cuda_sources` (compiled by nvcc) can be wrapped, because the linker resolves it. But I'm not entirely sure. The easiest approach is to put the host wrapper in `cpp_sources` and declare the kernel as `extern "C"`? No, kernels have internal linkage in their translation unit, but the host wrapper is in the same `.cu` file if we put everything in `cuda_sources`. So that works.
Let's check typical usage:
```python
fused = load_inline(
name="fused_swish_bias_gn",
cpp_sources="""...""", # optional
cuda_sources="""...""",
functions=["fused_swish_bias_gn"],
extra_cuda_cflags=["-O3"],
verbose=True,
)
```
If we put the host wrapper and kernel in `cuda_sources`, NVCC will compile both. The host wrapper will have C++ linkage. The auto-generated pybind code will call `extern "C"`? Actually pybind11 uses C++ name mangling. As long as the function is declared in the generated `.cpp` or the linker sees it, it's fine. I think putting everything in `cuda_sources` is the simplest.
But there's a catch: `torch.utils.cpp_extension.load_inline` with `cuda_sources` containing the host function may generate a `.cu` file for the CUDA sources and a `.cpp` file for the pybind wrapper. The `.cpp` file includes a forward declaration of the function `void fused_swish_bias_gn(...);` and then defines the pybind wrapper calling it. The `.cu` file is compiled separately and linked. So as long as the function has C++ linkage (which it does), it's fine. So we can put host wrapper + kernel in `cuda_sources`.
Let's verify by looking at PyTorch docs. Yes, commonly people pass CUDA code containing both `__global__` and host wrapper to `cuda_sources`. So we will do that.
Let's write the full source string.
```cpp
cuda_source = r'''
#include <torch/extension.h>
#include <cuda_runtime.h>
__global__ void fused_swish_bias_gn_kernel(
float* __restrict__ data,
const float* __restrict__ bias,
const float* __restrict__ gn_weight,
const float* __restrict__ gn_bias,
int num_groups,
int channels_per_group,
int out_features,
float eps)
{
int block_id = blockIdx.x;
int batch_idx = block_id / num_groups;
int group_idx = block_id - batch_idx * num_groups; // faster modulo?
int lane = threadIdx.x;
int channel = group_idx * channels_per_group + lane;
int idx = batch_idx * out_features + channel;
float v = data[idx];
// Swish: x * sigmoid(x)
v = v * (1.0f / (1.0f + expf(-v)));
// Add external bias
v += bias[channel];
extern __shared__ float smem[];
smem[lane] = v;
__syncthreads();
#pragma unroll
for (int offset = channels_per_group >> 1; offset > 0; offset >>= 1) {
if (lane < offset) {
smem[lane] += smem[lane + offset];
}
__syncthreads();
}
float mean = smem[0] / (float)channels_per_group;
smem[lane] = (v - mean) * (v - mean);
__syncthreads();
#pragma unroll
for (int offset = channels_per_group >> 1; offset > 0; offset >>= 1) {
if (lane < offset) {
smem[lane] += smem[lane + offset];
}
__syncthreads();
}
float var = smem[0] / (float)channels_per_group;
float rstd = rsqrtf(var + eps);
float norm = (v - mean) * rstd;
data[idx] = norm * gn_weight[channel] + gn_bias[channel];
}
void fused_swish_bias_gn(
torch::Tensor input,
torch::Tensor bias,
torch::Tensor gn_weight,
torch::Tensor gn_bias,
int num_groups,
float eps)
{
TORCH_CHECK(input.is_cuda(), "input must be a CUDA tensor");
TORCH_CHECK(bias.is_cuda(), "bias must be a CUDA tensor");
TORCH_CHECK(gn_weight.is_cuda(), "gn_weight must be a CUDA tensor");
TORCH_CHECK(gn_bias.is_cuda(), "gn_bias must be a CUDA tensor");
TORCH_CHECK(input.is_contiguous(), "input must be contiguous");
TORCH_CHECK(bias.is_contiguous(), "bias must be contiguous");
TORCH_CHECK(gn_weight.is_contiguous(), "gn_weight must be contiguous");
TORCH_CHECK(gn_bias.is_contiguous(), "gn_bias must be contiguous");
TORCH_CHECK(input.dtype() == torch::kFloat32, "input must be float32");
TORCH_CHECK(bias.dtype() == torch::kFloat32, "bias must be float32");
TORCH_CHECK(gn_weight.dtype() == torch::kFloat32, "gn_weight must be float32");
TORCH_CHECK(gn_bias.dtype() == torch::kFloat32, "gn_bias must be float32");
int batch_size = input.size(0);
int out_features = input.size(1);
int channels_per_group = out_features / num_groups;
dim3 block(channels_per_group);
dim3 grid(batch_size * num_groups);
fused_swish_bias_gn_kernel<<<grid, block, channels_per_group * sizeof(float)>>>(
input.data_ptr<float>(),
bias.data_ptr<float>(),
gn_weight.data_ptr<float>(),
gn_bias.data_ptr<float>(),
num_groups,
channels_per_group,
out_features,
eps);
}
'''
```
Wait, we need to include `<cuda_runtime.h>` but that's implicitly included by torch's headers? Better to include. Also, `blockIdx.x` up to 2M. The integer arithmetic is fine.
Now, the Python wrapper.
```python
import math
class ModelNew(nn.Module):
def __init__(self, in_features, out_features, num_groups, bias_shape):
super().__init__()
self.matmul = nn.Linear(in_features, out_features)
self.bias = nn.Parameter(torch.randn(bias_shape))
self.group_norm = nn.GroupNorm(num_groups, out_features)
self.eps = 1e-5
def forward(self, x):
y = F.linear(x, self.matmul.weight, self.matmul.bias)
_C.fused_swish_bias_gn(
y, self.bias, self.group_norm.weight, self.group_norm.bias,
self.group_norm.num_groups, self.eps
)
return y
```
Wait, `self.group_norm.num_groups` is correct.
One concern: `self.group_norm.weight` and `self.group_norm.bias` exist because affine=True. Yes.
Another concern: `bias_shape` is `(out_features,)`. `nn.Parameter(torch.randn(bias_shape))` creates correct shape.
Now, compile. We can test this.
Let's assemble the full source code.
But before compiling, note: `load_inline` may need `extra_cuda_cflags=["-O3"]`. We can add that. Also, `verbose=False` to reduce output.
Potential issue: The kernel uses `blockIdx.x` up to 2M, but the `grid` dimension x is limited to 2^31-1, which is ~2 billion. So 2M is fine.
Another potential issue: `batch_idx * num_groups` in host code is `batch_size * num_groups` which is 32768*64 = 2,097,152. That's fine.
Let's double-check the mod operation: `int group_idx = block_id - batch_idx * num_groups;` This is equivalent to `block_id % num_groups` but uses integer multiply. Since `batch_idx = block_id / num_groups;`, this is correct. However, integer division and multiplication might be slightly slower on GPU? It's computed once per thread. Actually we compute `batch_idx` and `group_idx` per thread. We could compute them once per block in shared memory or just let each thread compute them. Since 64 threads per block, it's fine. But we could also compute them using integer arithmetic that's cheap. Actually `block_id / num_groups` is integer division. It's okay.
Alternatively, we can compute:
```
int batch_idx = block_id / num_groups;
int group_idx = block_id % num_groups;
```
The compiler will optimize `block_id - batch_idx * num_groups` to the remainder from division.
Now, let's compile and test. We need to write the code exactly as it will be executed.
One more thing: Since `load_inline` will compile the CUDA code, we need to define the functions list. The function name is `fused_swish_bias_gn`. We also need to define the module name.
Let's write the complete kernel file.
```python
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.cpp_extension import load_inline
cuda_source = r'''
#include <torch/extension.h>
#include <cuda_runtime.h>
__global__ void fused_swish_bias_gn_kernel(
float* __restrict__ data,
const float* __restrict__ bias,
const float* __restrict__ gn_weight,
const float* __restrict__ gn_bias,
int num_groups,
int channels_per_group,
int out_features,
float eps)
{
int block_id = blockIdx.x;
int batch_idx = block_id / num_groups;
int group_idx = block_id - batch_idx * num_groups;
int lane = threadIdx.x;
int channel = group_idx * channels_per_group + lane;
int idx = batch_idx * out_features + channel;
float v = data[idx];
// Swish: x * sigmoid(x)
v = v * (1.0f / (1.0f + expf(-v)));
// Add external bias
v += bias[channel];
extern __shared__ float smem[];
smem[lane] = v;
__syncthreads();
#pragma unroll
for (int offset = channels_per_group >> 1; offset > 0; offset >>= 1) {
if (lane < offset) {
smem[lane] += smem[lane + offset];
}
__syncthreads();
}
float mean = smem[0] / (float)channels_per_group;
smem[lane] = (v - mean) * (v - mean);
__syncthreads();
#pragma unroll
for (int offset = channels_per_group >> 1; offset > 0; offset >>= 1) {
if (lane < offset) {
smem[lane] += smem[lane + offset];
}
__syncthreads();
}
float var = smem[0] / (float)channels_per_group;
float rstd = rsqrtf(var + eps);
float norm = (v - mean) * rstd;
data[idx] = norm * gn_weight[channel] + gn_bias[channel];
}
void fused_swish_bias_gn(
torch::Tensor input,
torch::Tensor bias,
torch::Tensor gn_weight,
torch::Tensor gn_bias,
int num_groups,
float eps)
{
TORCH_CHECK(input.is_cuda(), "input must be a CUDA tensor");
TORCH_CHECK(bias.is_cuda(), "bias must be a CUDA tensor");
TORCH_CHECK(gn_weight.is_cuda(), "gn_weight must be a CUDA tensor");
TORCH_CHECK(gn_bias.is_cuda(), "gn_bias must be a CUDA tensor");
TORCH_CHECK(input.is_contiguous(), "input must be contiguous");
TORCH_CHECK(bias.is_contiguous(), "bias must be contiguous");
TORCH_CHECK(gn_weight.is_contiguous(), "gn_weight must be contiguous");
TORCH_CHECK(gn_bias.is_contiguous(), "gn_bias must be contiguous");
TORCH_CHECK(input.dtype() == torch::kFloat32, "input must be float32");
TORCH_CHECK(bias.dtype() == torch::kFloat32, "bias must be float32");
TORCH_CHECK(gn_weight.dtype() == torch::kFloat32, "gn_weight must be float32");
TORCH_CHECK(gn_bias.dtype() == torch::kFloat32, "gn_bias must be float32");
int batch_size = input.size(0);
int out_features = input.size(1);
int channels_per_group = out_features / num_groups;
dim3 block(channels_per_group);
dim3 grid(batch_size * num_groups);
fused_swish_bias_gn_kernel<<<grid, block, channels_per_group * sizeof(float)>>>(
input.data_ptr<float>(),
bias.data_ptr<float>(),
gn_weight.data_ptr<float>(),
gn_bias.data_ptr<float>(),
num_groups,
channels_per_group,
out_features,
eps);
}
'''
_C = load_inline(
name="fused_swish_bias_gn",
cpp_sources="",
cuda_sources=cuda_source,
functions=["fused_swish_bias_gn"],
extra_cuda_cflags=["-O3"],
verbose=False,
)
class ModelNew(nn.Module):
def __init__(self, in_features, out_features, num_groups, bias_shape):
super().__init__()
self.matmul = nn.Linear(in_features, out_features)
self.bias = nn.Parameter(torch.randn(bias_shape))
self.group_norm = nn.GroupNorm(num_groups, out_features)
self.eps = 1e-5
def forward(self, x):
y = F.linear(x, self.matmul.weight, self.matmul.bias)
_C.fused_swish_bias_gn(
y, self.bias, self.group_norm.weight, self.group_norm.bias,
self.group_norm.num_groups, self.eps
)
return y
```
Wait, `cpp_sources=""` is okay. But does `load_inline` require at least one cpp source? No, it can be empty string.
Now, we need to check if there are any issues with the `extern __shared__ float smem[];`. We need to define the size in the launch config. We did: `channels_per_group * sizeof(float)`. Since `channels_per_group` is 64, that's 256 bytes. Good.
One more nuance: `blockIdx.x` is a built-in variable of type `unsigned int` or `int`. In CUDA, `blockIdx.x` is `uint3`. But it's implicitly converted to int. The expression `block_id / num_groups` is integer division. If `block_id` is int and `num_groups` is int, fine.
Now, let's think about numerical equivalence. PyTorch GroupNorm uses a custom kernel. For 2D input (N, C), it might reshape to (N, G, C/G) and compute mean/var. Does it use Welford? Actually, looking at PyTorch's source: `aten/src/ATen/native/cuda/group_norm_kernel.cu`. The forward kernel likely computes mean and inv_std per group. For the case where spatial dimensions are 1, it might use a specialized path or generic path. Let's search memory: In PyTorch, GroupNorm forward on CUDA uses `group_norm_kernel` which computes mean and invstd using shared memory. For 1D input, it still works. The variance calculation might use `calc_variance` which computes sum of squares minus mean squared? Actually, I recall that PyTorch's LayerNorm/GroupNorm uses `Mse` or variance = sum(x^2)/N - mean^2? Let's check.
Actually, in PyTorch's native CUDA implementation of GroupNorm (and LayerNorm), the variance is computed as `var = (sum(x^2) / N) - mean^2`. Wait, is that correct? For population variance, `E[x^2] - (E[x])^2`. This is mathematically equivalent but less numerically stable. Does PyTorch use that? Let's check.
In `aten/src/ATen/native/cuda/group_norm_kernel.cu`, there is a kernel `group_norm_kernel` that computes:
```
acc_t mean = 0;
acc_t var = 0;
for (...) {
val = ...
mean += val;
var += val * val;
}
mean /= C;
var = var / C - mean * mean;
```
Yes, PyTorch often uses this two-pass formula with `var = E[x^2] - mean^2` rather than `E[(x-mean)^2]`. This is a known source of numerical difference. Which one is used?
Let's verify. In `aten/src/ATen/native/cuda/group_norm_kernel.cu` (from PyTorch source), the forward kernel for GroupNorm likely uses `scale = rsqrt(mean2 - mean*mean + eps)`, where `mean2` is the mean of squares. Actually, I think for GroupNorm, PyTorch computes mean and inv_std using `calc_mean_var` which computes mean and M2 in one pass? No, I'm not sure.
Wait, we need to match PyTorch's implementation exactly enough to be within 1e-4. Let's think about the difference between `var = mean((x-mean)^2)` and `var = mean(x^2) - mean^2`. For 64 numbers of magnitude ~O(1), the difference due to catastrophic cancellation in the second formula might be ~1e-7 relative, so absolute error ~1e-7. That's well within 1e-4. So either formula is fine.
But there could be differences in how reduction is performed (order of summation). That leads to rounding differences of ~1e-6 absolute? Still within 1e-4. So our implementation should be fine.
However, there is a subtle difference: PyTorch's `expf` might be compiled with `--use_fast_math` or not? Actually PyTorch's CUDA kernels are compiled with fast math? I think PyTorch uses `-O3` but not necessarily `--use_fast_math`. However, for unary ops like sigmoid, they might use their own approximations or call std functions. The difference between `expf` and `__expf` is small. Our use of `expf` should match PyTorch's `expf` closely.
But wait: The custom extension compiled by `load_inline` might use a different math library path than PyTorch's ATen. PyTorch's ATen CUDA kernels are compiled during PyTorch build with specific flags. Our extension is compiled with whatever `cpp_extension` uses. `cpp_extension` adds flags like `-O3` but may also add flags from `TORCH_CUDA_ARCH_LIST`. It does not add `--use_fast_math` by default. So `expf` is the standard libm function. PyTorch's `sigmoid` kernel likely also calls standard `expf` or uses `1/(1+exp(-x))` directly, which calls `expf`. So they should match.
But there is a catch: Some PyTorch kernels use `std::exp` which for float resolves to `expf` anyway.
Another catch: PyTorch's `nn.Linear` uses cuBLAS, which may use TF32. Our `F.linear` also uses cuBLAS and will also use TF32 if enabled. So they match.
Wait, is there any risk that the evaluation copies parameters from a reference model running on a different thread or something? No.
One more thing: The `bias_shape` argument is `(out_features,)`. In `Model.__init__`, `self.bias = nn.Parameter(torch.randn(bias_shape))`. In our `ModelNew`, we must also use `torch.randn(bias_shape)`. The `get_init_inputs()` returns `[in_features, out_features, num_groups, bias_shape]`. So we must accept those and create modules accordingly.
We also need to consider that the reference might have `self.group_norm` with default `affine=True`. So it has weight and bias parameters. Our `ModelNew`'s `self.group_norm` also has them.
Potential issue: `self.group_norm` in PyTorch registers parameters named `weight` and `bias`. Its state dict keys are `group_norm.weight` and `group_norm.bias`. Good.
Now, let's test compile and correctness. I'll write the code string and test it.