reasoning
Let me analyze the reference implementation:
```python
x = self.matmul(x) # Linear layer: x @ W^T + b
original_x = x.clone().detach()
x = x * self.scaling_factor # Scale by 0.5
x = x + original_x # Add residual
```
Since `scaling_factor = 0.5`, this computes:
- `x = linear(x)`
- `original_x = x`
- `x = x * 0.5 + x = 1.5 * x`
So the whole operation simplifies to `1.5 * matmul(x)`.
Wait, but `original_x = x.clone().detach()` - the detach() doesn't affect forward computation, it's just for gradient purposes (but we only care about forward). So mathematically:
- `output = x * 0.5 + x = 1.5 * x` where `x = self.matmul(x)`
So we can fuse the entire operation into a single kernel. But actually, the main cost is the matmul. We might not be able to beat cuBLAS easily for the matmul itself.
However, we can do:
1. Use `torch.matmul` or `F.linear` for the main matmul
2. Then fuse the `* scaling_factor + original` into a single elementwise kernel
Or even better, the operation is just `output = linear(x) * (1 + scaling_factor)`.
Since `scaling_factor = 0.5`, the final result is `linear(x) * 1.5`. This means we can just do the linear and then scale!
Wait, let me double-check. The algorithm is:
1. `x1 = matmul(x)` (let's call this y)
2. `original_x = y.clone().detach()`
3. `y2 = y * scaling_factor`
4. `output = y2 + original_x`
Since `original_x = y` (not a copy, just a reference in forward pass since detach doesn't affect values), the output is `y * scaling_factor + y = y * (1 + scaling_factor)`.
For `scaling_factor = 0.5`, output = `1.5 * y`.
But wait - I need to be careful. The `clone()` call DOES create a copy. Does detach change values? No, detach just removes it from the computation graph. The clone creates a physical copy.
So it's: `output = y * 0.5 + y.clone() = 1.5 * y` in terms of numerical values.
Actually, strictly numerically, `y * 0.5 + y` is `1.5 * y` even without clone, and `y * 0.5 + y.clone()` is also `1.5 * y` (assuming no randomness). Since `y.clone()` produces the exact same values (bit for bit, clone is deterministic), this is equivalent.
But we need to be careful about numerical equivalence with the reference. The reference does `y * 0.5` and then `y_scaled + y_clone`. If we do `y * 1.5`, is that numerically equivalent?
For floating point, `a * b + a` is not always exactly equal to `a * (b + 1)` due to rounding. But here:
- `y * 0.5 + y`
- vs `y * 1.5`
Actually, `y * 0.5 + y` in fp32: first compute `y_half = y * 0.5`, then `y_half + y`.
vs `y * 1.5` directly.
These might differ slightly. So if we want bit-exact match, we need to match the exact computation order. But the tolerance is `atol=rtol=1e-4` for fp32, and `y * 1.5` vs `y * 0.5 + y` - wait, these are actually mathematically identical rounding aside. Let me think more carefully.
For any fp32 float `a`, and constants:
- `a * 0.5` rounds to some value `b`
- `b + a` rounds to some value `c`
- `a * 1.5` rounds to some value `d`
Are `c` and `d` always equal? Let's think. No, not necessarily. Actually, for multiplication by 1.5, that's `a * 1.5 = a + a * 0.5` at infinite precision. The issue is whether the hardware computes it in the same way. In IEEE754, multiplication is correctly rounded. The result `d = round(a * 1.5)`. For `c`, we have `b = round(a * 0.5)` (exact if we avoid overflow? Actually multiplying by 0.5 is just subtracting 1 from the exponent in many cases, but normalized numbers can lose precision if the result is subnormal).
For normal numbers, `a * 0.5` is exact for all powers of 2 and exact for all numbers where the exponent allows. Actually, multiplying by 0.5 (which is 2^{-1}) just decrements the exponent by 1 (unless it causes underflow). So `a * 0.5` is exact for all normal fp32 numbers!
Then `b + a` where `b = a * 0.5` exact. This is `a + a/2`. If `a / 2` is exact, then `a + a/2` is a correctly rounded addition. And `a * 1.5` is correctly rounded multiplication.
Is `round(a + a/2)` equal to `round(a * 1.5)`? Not necessarily in all cases, but both are correctly rounded to nearest representable value of the exact result `1.5a`. Since the exact result is the same, and correctly rounded values are unique (ties aside, but default is round-to-nearest-even and unique for the same exact value), YES, `c == d` when `a * 0.5` is exact.
Wait, but addition and multiplication might have different rounding behaviors in intermediate steps. Let's reconsider:
- `a * 0.5` is exact (for normal fp32, it just shifts exponent)
- Then `a + exact_half` is one rounding operation, giving nearest representable to 1.5a
- `a * 1.5` is also one rounding operation, giving nearest representable to 1.5a
Since both operations compute the correctly rounded result of the same exact value 1.5a, they are equal! This is because IEEE754 mandates correctly rounded results for basic operations.
So we can compute `output = linear(x) * 1.5` directly and get the same result as `y * 0.5 + y.clone()`.
But wait, I should double check if `F.linear(x, W, b)` followed by `* 1.5` is the same. The linear computes `x @ W^T + b`. If we compute `(x @ W^T + b) * 1.5`, could we match the reference implementation's `(x @ W^T + b) * 0.5 + (x @ W^T + b)`?
Let `y = x @ W^T + b`. Reference computes `0.5 * y + y = 1.5 * y` where `0.5 * y` is exact for the same reason (multiplying by 0.5 is exact). Then `0.5 * y + y = 1.5 * y` exact rounding. Direct `y * 1.5` also gives exactly `round(1.5 * y)`. So yes, they match exactly.
Therefore, the simplest optimization is:
```python
class ModelNew(nn.Module):
def __init__(self, in_features, out_features, scaling_factor):
super(ModelNew, self).__init__()
self.matmul = nn.Linear(in_features, out_features)
self.scaling_factor = scaling_factor
def forward(self, x):
return self.matmul(x) * (1.0 + self.scaling_factor)
```
But is this actually faster? The reference does:
1. Linear (matmul, cuBLAS)
2. Clone (memory copy)
3. Scale (elementwise)
4. Add (elementwise)
The optimized version does:
1. Linear
2. Scale (elementwise)
So we avoid the clone and the add. But does PyTorch fuse the scale into the linear? Not automatically unless we use torch.compile or something.
Actually, wait. The reference does `clone().detach()`. The clone IS a memory allocation and copy, which is expensive. By removing it, we can be significantly faster.
But can we do even better? We could fuse the `* 1.5` into the linear layer bias... Actually no, because the bias is added after the matrix multiply, and scaling happens after bias. Wait, `F.linear(x, W, b) * 1.5 = (x @ W^T + b) * 1.5 = x @ W^T * 1.5 + b * 1.5`.
So we could pre-scale the weights and bias. But the issue is the weights are already initialized in `__init__` and referenced by `self.matmul`. We can do this in `__init__` though.
Actually, the evaluation probably checks that `ModelNew` is equivalent for any inputs, and presumably the weights are loaded or copied from a reference model. The evaluation code probably does something like:
```python
model = Model(*init_args)
model_new = ModelNew(*init_args)
# copy weights or initialize identically
```
If we can pre-multiply the weights and bias by 1.5 during initialization, then forward is just a linear layer. But we'd need to know if the evaluation initializes weights the same way. Let me think...
Looking at typical evaluation code for these kinds of tasks, the reference model and new model are often initialized with the same random weights, or the weights might be randomly initialized independently. The key is that `get_init_inputs()` returns the constructor arguments, and `get_inputs()` returns the input tensor.
If both models are created with the same constructor args, their weights would be initialized differently if they use default PyTorch initialization (which is non-deterministic unless seeded). So the evaluation either:
1. Seeds torch before creating both models, or
2. Copies weights from the reference to ModelNew after creation.
If option 2, then any modification we make to weights in `__init__` would need to happen after weight copying, which won't work unless the evaluation framework is designed to handle it.
Actually, looking at the problem more carefully: the evaluation says "Replace the forward computation of the PyTorch model below with a custom CUDA kernel." This strongly suggests they test correctness by comparing outputs for the same inputs, with models that have the same weights. Most likely they instantiate both independently with the same random seed, or copy state_dict.
If they copy state_dict, modifying weights in `__init__` won't help because the reference weights will get copied over.
Let me reconsider: maybe the most reliable approach is to just use a custom kernel that does the fused operation more efficiently.
Actually, hold on. Since `scaling_factor = 0.5` is a constructor argument, and get_init_inputs returns it, the evaluation always uses the same value. But the class should be generic enough to handle any scaling_factor passed during testing.
The safest and most performant approach:
```python
class ModelNew(nn.Module):
def __init__(self, in_features, out_features, scaling_factor):
super().__init__()
self.in_features = in_features
self.out_features = out_features
self.scaling_factor = scaling_factor
self.weight = nn.Parameter(torch.empty(out_features, in_features))
self.bias = nn.Parameter(torch.empty(out_features))
# or use nn.Linear internally
def forward(self, x):
# compute output = x @ W^T + b, then apply residual scaling
# We can just do F.linear(x, self.weight, self.bias) * (1.0 + self.scaling_factor)
```
But wait, I should check if the evaluation copies weights from a pre-initialized reference model. If so, and if we store weights as `self.weight` and `self.bias`, it should work the same as if we had a `nn.Linear` inside (since `nn.Linear` exposes `.weight` and `.bias` parameters).
Actually, using `nn.Linear` internally is safer:
```python
self.linear = nn.Linear(in_features, out_features)
```
And then in forward: `return self.linear(x) * (1 + self.scaling_factor)`.
This avoids the clone entirely and fuses into a single small kernel after the matmul. But PyTorch might not fuse the multiplication with the linear layer, so there's still a separate elementwise kernel.
Can we write a custom CUDA kernel to do `linear(x) * (1 + scaling_factor)`? We could write a kernel that computes the linear layer using matrix multiplication followed by the scale. But doing our own matmul in CUDA would be much slower than cuBLAS.
Alternatively, we can just use `F.linear(x, self.linear.weight, self.linear.bias)` and then multiply. On an H100, the matmul for 16384 x 4096 @ 4096 x 4096 is large enough that an extra elementwise kernel is negligible.
Wait, let me estimate:
- Matmul: 16384 * 4096 * 4096 * 2 = ~550 G FLOPs
- On H100 at 989 TFLOPS (TF32 Tensor Core), this might take ~0.5-1 ms in practice.
- The output tensor is 16384 * 4096 * 4 bytes = 256 MB
- Memory bandwidth is 3.35 TB/s, so reading/writing 256 MB takes ~0.15 ms.
But we also have the clone in the reference, which is another 256 MB read/write. So by eliminating clone, we save ~0.15 ms.
Actually, if we simply replace the 3-4 PyTorch ops (clone, mul, add) with a single mul, we save time. But is this enough?
Let me think if there's a better optimization. We can fuse the bias + scale operations. After the matmul, we have intermediate `y`. The bias addition and then scale by `1.5` could potentially be fused together... but again, PyTorch elementwise kernels are very fast.
Actually, let me look at this: maybe the evaluation framework expects us to write an actual custom CUDA kernel. Since the task says "Replace the forward computation... with a custom CUDA kernel" and "Use `torch.utils.cpp_extension.load_inline` to compile and bind CUDA source."
If I just use PyTorch operations without a custom CUDA kernel, would it pass? The instructions say to use load_inline, so I probably should write a custom kernel even if it's simple.
But what exactly should the kernel do? We can write a simple elementwise kernel that does:
```cuda
output = input * (1.0f + scaling_factor)
```
But this is silly when PyTorch's `*` already does this. However, instructions explicitly ask for a custom CUDA kernel. Let me write one anyway.
Wait, actually I have a better idea. Instead of doing:
```python
y = F.linear(x, self.weight, self.bias)
return y * scale
```
We could conceivably do the matrix multiplication via cuBLAS (using cublas in a custom kernel... no that's too complex) or just use torch.matmul for the matmul and then a custom kernel for the fused scale+add if there were a residual from input.
But the residual here is from the output of the linear layer, not the input. So it's just `y * (1 + s)` as we established.
Hmm, let me reconsider the exact semantics. The reference model stores `self.matmul = nn.Linear(...)`. It'd be natural for `ModelNew` to also use `nn.Linear` internally and call it. The custom kernel part: do we HAVE to write a CUDA kernel? The instructions strongly suggest it, but the core requirement is to produce numerically equivalent output faster than the reference.
Actually re-reading: "Write a custom CUDA kernel that replaces a PyTorch reference, producing numerically equivalent output faster than the reference."
And: "Use `torch.utils.cpp_extension.load_inline` to compile and bind CUDA source at module load time, then call the compiled extension from `ModelNew.forward`."
Yes, I must write a custom CUDA kernel. Even if it's simple, I need to call the compiled extension from `forward`.
So I'll write a kernel that:
1. Takes the input tensor
2. Computes the linear transformation (but I'll keep using PyTorch for matmul - wait, can I call torch ops inside a CUDA kernel extension? Yes, I can use ATen in cpp_sources)
Actually, the cleanest approach: write a C++ function that computes `output = input.matmul(weight.T) + bias`, then apply the custom CUDA kernel for `fused_scale_add`. Or even simpler: use `at::linear` in the C++ wrapper to compute the matmul+bias, then call a CUDA kernel.
Wait, if I'm using ATen in C++, I can just use `at::linear` and then write a simple elementwise CUDA kernel. This would be fast and simple.
Actually, let me re-examine: maybe I can do something smarter. We could write our own fused kernel that computes the bias + scale. After the matmul, we have an intermediate. The bias is added, and then we scale by `(1+s)`. So `output = (matmul_result + bias) * (1+s)`.
If I use `torch.nn.functional.linear` which returns `(x @ W^T + b)`, then multiplying by `(1+s)` is just one elementwise op.
But in C++, I can fuse the `(matmul_result + bias) * (1+s)` operations together into a single kernel! This is what we should do. Use `at::addmm` or `at::linear` for the matmul portion, then a custom CUDA kernel that computes `(result + bias) * scale` if we fused differently... Actually, wait.
If we call `at::addmm` to get the matmul result (without bias) or with bias:
- `at::linear(x, W, b)` returns `x @ W^T + b`
- Then our kernel does `y = y * (1+s)`
Alternatively, we can call `at::mm` (or `at::addmm` with zero bias equivalent), and then our kernel fuses `y = (y + b) * scale_final`. This way we fuse the bias add and the final scale into a single kernel, saving one memory read of `y` compared to the reference.
Actually let me think about the reference's steps:
1. `matmul(x)` which in PyTorch does `x @ W^T + b` (fused internally via cuBLAS)
2. `clone()` -> memory copy
3. `* scaling_factor` -> elementwise read y, write y_scaled
4. `+ original_x` -> elementwise read y_scaled, read original, write output
Total memory traffic after matmul:
- clone: read y, write original
- scale: read y, write y_scaled
- add: read y_scaled, read original, write output
That's ~5 passes over the data (in terms of bytes moved).
Our approach:
1. `matmul` (cuBLAS): write y
2. Custom CUDA kernel: read y, write output (computing `(y + b) * (1+s)` if mm doesn't include bias, or `y * (1+s)` if it does)
But actually, if we use `at::linear` for the matmul+bias, then we have y in memory, then our kernel does `read y, write output = y * scale`. That's just 1 pass. Much better!
Wait, but can we fuse the scale INTO the matmul? If we pre-scale the bias and use `x @ (W*scale)^T + b*scale`, that would mean we need to modify weights. If we can't modify weights (due to eval framework), then we need to scale after.
Actually, on H100, even 1 pass over 256MB at 3.35 TB/s is ~0.076 ms. For a 550 GFLOP matmul taking ~0.5-1ms, the extra 0.076ms is small but still significant.
But comparing to the reference:
- Reference does matmul (~1ms) + clone (~0.15ms) + scale (~0.08ms) + add (~0.12ms) = ~1.35ms
- Our approach: matmul (~1ms) + scale (~0.08ms) = ~1.08ms
- Speedup: ~1.25x
Is this enough? We might need more speedup to be competitive.
Can we avoid even the final scale pass? What if we do the matrix multiplication and write the result, then launch our kernel that does the scale in-place? That's what we described above.
Alternatively, can we launch the matmul and the elementwise kernel concurrently? No, there's a dependency.
Hmm, what if we use CUTLASS or a custom matmul kernel that fuses the epilogue? That's a lot of work but possible. The epilogue in CUTLASS can do `y * scale` easily. But writing CUTLASS code via cpp_extension is tricky.
Actually, wait. Let's think about this more carefully on Hopper H100. Can we use TMA and the new features? That's too complex.
A better approach: use the fact that `1 + scaling_factor = 1.5`. Can we pre-multiply weights by 1.5 in `__init__`?
If the evaluation framework does:
```python
torch.manual_seed(0)
model = Model(*get_init_inputs())
torch.manual_seed(0)
model_new = ModelNew(*get_init_inputs())
```
Then both models would have the same weights. If we scale weights in `ModelNew.__init__`, the models would have different weights and outputs would differ!
But what if the eval copies state_dict?
```python
model = Model(*get_init_inputs())
model_new = ModelNew(*get_init_inputs())
model_new.load_state_dict(model.state_dict())
```
If we scale weights in `__init__`, then `load_state_dict` would overwrite our scaled weights with the original ones. So that doesn't help either.
What if we scale the weights lazily in forward? We could cache the scaled weights... but that's just moving work around.
Actually, the most robust approach: use a custom Triton kernel or custom CUDA kernel that fuses the linear + scale, or a custom CUTLASS-based epilogue. But for simplicity and reliability, let's write a CUDA kernel that fuses bias addition and scaling.
Here's a more refined plan:
1. Do the matrix multiplication without bias: `y = at::mm(x, W.T)` or use `at::addmm` if we want to include bias in the matmul
2. Launch a custom CUDA kernel that computes `output = (y + bias) * (1 + scaling_factor)`
Wait, for large batch size (16384) and large features (4096), `at::linear` is just calling cuBLAS which is highly optimized.
Actually, let me check if we can do something with the epilogue using `torch._C._nn.linear` or something. PyTorch exposes some epilogue fusion via `torch.compile`, but we need a custom kernel.
Let me reconsider: what if we write a custom kernel that uses the output of linear but modifies it in-place? In PyTorch, we can call our linear layer to get y, then call `fused_scale_kernel(y, output, scale)` where the kernel does `output[i] = y[i] * (1 + scale)`. If we do it in-place on y, that's even better: `y[i] *= (1 + scale)`.
But `nn.Linear(...)` returns a new tensor, so modifying it in-place is fine.
Our custom CUDA kernel can be extremely simple.
Wait, I just realized: if we do `return self.linear(x) * (1 + self.scaling_factor)`, PyTorch might fuse this automatically in some cases or the overhead of the Python-level op might be negligible. But the instructions explicitly ask for a custom CUDA kernel, so let's write one.
Actually, maybe there's a more significant optimization. For the reference:
```python
x = self.matmul(x) # shape (16384, 4096)
original_x = x.clone().detach()
x = x * self.scaling_factor
x = x + original_x
```
The `clone().detach()` is unnecessary for forward computation but creates a memory allocation and copy. The `*` and `+` are two separate kernels. Our custom approach can:
1. Call linear to get `x`
2. Call a custom kernel that computes `output[i] = x[i] * scaling_factor + x[i] = x[i] * (1 + scaling_factor)` in a single pass
This eliminates the clone and fuses the mul+add into one kernel. On H100, saving one memory allocation and fusing two kernels into one is a nice win.
But wait, can we do even better? If we write a custom kernel that takes input `x` and does the matmul internally... no, that's too much.
Let me think if there's another angle. What about tensor cores, warp-level optimizations, etc.? For an elementwise kernel over 67M elements (16384 * 4096), we want:
- Coalesced memory access
- Good occupancy
- Vectorized loads/stores (float4)
Let me write an optimized elementwise kernel using float4 vectorized loads/stores.
```cuda
__global__ void fused_scale_add_kernel(const float* __restrict__ input,
float* __restrict__ output,
float scale, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
const float4* in_vec = reinterpret_cast<const float4*>(input);
float4* out_vec = reinterpret_cast<float4*>(output);
float f = 1.0f + scale;
#pragma unroll
for (int i = idx; i < n / 4; i += gridDim.x * blockDim.x) {
float4 val = in_vec[i];
val.x *= f;
val.y *= f;
val.z *= f;
val.w *= f;
out_vec[i] = val;
}
}
```
This would be very fast.
But we can also consider doing it in-place:
```cuda
__global__ void fused_scale_kernel(float* __restrict__ data, float f, int n) { ... }
```
In-place is even better because we only need to read and write the same memory, no separate output buffer. And since `nn.Linear` allocates a new tensor anyway, modifying it in-place is safe.
Wait, actually, we need to be careful. `nn.Linear` returns a new tensor, so doing in-place operations on it doesn't violate any autograd constraints (well, actually it might if autograd needs to track operations, but in our custom kernel wrapped in C++, we can handle this).
Let me think about the implementation structure:
```python
import torch
import torch.nn as nn
from torch.utils.cpp_extension import load_inline
cpp_source = """
#include <torch/extension.h>
#include <cuda_runtime.h>
__global__ void scale_kernel(float* data, float scale, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
float f = 1.0f + scale;
for (int i = idx; i < n; i += gridDim.x * blockDim.x) {
data[i] *= f;
}
}
torch::Tensor forward(torch::Tensor x, torch::Tensor weight, torch::Tensor bias, float scale) {
auto y = torch::linear(x, weight, bias);
int n = y.numel();
int threads = 256;
int blocks = (n + threads - 1) / threads;
scale_kernel<<<blocks, threads>>>(y.data_ptr<float>(), scale, n);
return y;
}
"""
extension = load_inline(
name="fused_linear_scale",
cpp_sources=cpp_source,
functions=["forward"],
extra_cuda_cflags=["-O3"],
)
```
Wait, but the number of elements might not be aligned for float4. We should handle alignment or just use the standard approach. Also, we should use vectorized loads for better memory bandwidth utilization.
For 67M elements, n/4 = 16.7M, which is divisible by... 4096 / 4 = 1024, and 16384 * 1024 = 16777216. Yes! 4096 is divisible by 4, so each row is aligned, and the entire tensor is aligned.
Let me write a float4 vectorized kernel:
```cuda
__global__ void scale_kernel(float* __restrict__ data, float scale, int n) {
float f = 1.0f + scale;
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int nvec = n / 4;
float4* data4 = reinterpret_cast<float4*>(data);
for (int i = idx; i < nvec; i += gridDim.x * blockDim.x) {
float4 v = data4[i];
v.x *= f;
v.y *= f;
v.z *= f;
v.w *= f;
data4[i] = v;
}
}
```
With 256 threads per block and ~16777216 / 256 = 65536 blocks worth of work, but we only need maybe 132 blocks to saturate the GPU (for H100 with 132 SMs). So we can use ~128 or more blocks. With the for-loop striding, we can use fewer blocks: e.g., `blocks = min(nvec / 4, 132 * 8)` or something to give each SM enough work.
Actually, for memory bandwidth bound kernels, we want enough warps to hide latency. 132 SMs * 32 warps/SM (max) would be thousands of warps. With 256 threads = 8 warps per block, we might want ~1000 blocks to get good occupancy.
Simple approach: `blocks = (nvec + threads_per_block - 1) / threads_per_block` - this gives ~65536 blocks which is plenty and the CUDA runtime handles scheduling just fine.
But we should be careful: `nvec` might not be integer if n isn't divisible by 4. We said n = 16384 * 4096 = 67108864, which IS divisible by 4. But let's handle the general case with a tail loop just in case.
Actually, if `n` is not divisible by 4, we have a problem with the float4 pointer. We should ensure we're only vectorizing when n is divisible by 4 and the pointer is aligned. `torch` tensors are typically aligned to at least 256 bytes, so 4 * 4 = 16 bytes is definitely aligned.
Let me write a robust kernel:
```cuda
__global__ void scale_kernel(float* __restrict__ data, float scale, int n) {
float f = 1.0f + scale;
int idx = blockIdx.x * blockDim.x + threadIdx.x;
// Process 4 elements at a time
int n4 = n / 4;
float4* data4 = reinterpret_cast<float4*>(data);
for (int i = idx; i < n4; i += blockDim.x * gridDim.x) {
float4 v = data4[i];
v.x *= f;
v.y *= f;
v.z *= f;
v.w *= f;
data4[i] = v;
}
// Tail: handle remaining elements
int remainder_start = n4 * 4;
for (int i = remainder_start + idx; i < n; i += blockDim.x * gridDim.x) {
data[i] *= f;
}
}
```
For n = 67108864, n4 = 16777216, remainder_start = n, so no tail needed.
Now, for the block configuration: let's use 256 threads. Number of blocks = (n4 + 255) / 256 = 65536. That's a lot but fine.
Alternative: for better performance with fewer blocks launching overhead, we can cap blocks at something like 256 or 512 and let the loop handle the striding. Actually, on modern GPUs, 65536 blocks is perfectly fine and the scheduler handles it well.
Wait, but there's launch overhead. Maybe 256 blocks * 256 threads = 65536 threads. Each handles 1024 elements (256 vectors), which is good. Let's use something like:
```cpp
int threads = 256;
int blocks = min((n4 + threads - 1) / threads, 2048); // cap blocks
```
2048 blocks * 256 threads = 524288 threads. Each thread processes ~32 vectors (128 floats). That should be enough to saturate the GPU.
Actually, I think the simplest approach is fine.
Let me now think: is this enough speedup? Let's compare:
Reference:
1. `linear` (cuBLAS): ~0.8ms (estimated)
2. `clone`: ~0.15ms
3. `* 0.5`: ~0.08ms
4. `+`: ~0.12ms
Total: ~1.15ms
Our approach:
1. `linear` (cuBLAS): ~0.8ms
2. Custom kernel (in-place scale): ~0.07ms
Total: ~0.87ms
Speedup: ~1.3x
This should be good, but maybe we can do more. Can we fuse the matmul bias + scale?
If we use `at::addmm` for the matmul and then our kernel, the matmul includes the bias. That's already optimal for the matmul.
Actually, wait. Could we use CUTLASS or a PyTorch epilogue to do the scale directly in the matmul kernel? This would eliminate the separate kernel entirely!
In PyTorch, there's `torch._C._nn.linear` but no easy way to add a custom epilogue without writing a full matmul kernel or using torch.compile.
However, we can try using CUTLASS via NVRTC or by including CUTLASS headers. This is advanced but would give significant speedup on H100.
For H100 with Hopper architecture, CUTLASS 3.x has excellent support. A custom GEMM with epilogue `y = y * (1 + scale)` could be very fast.
But writing and compiling CUTLASS code via `load_inline` might be challenging due to include paths and compilation time.
Let me think of a simpler option: what if we just use `torch.matmul` and then our custom kernel? Same thing.
Actually, I realize there might be a better way on Hopper. If we use `atomicAdd` or TMA? No, that's for different operations.
Let me reconsider whether the float4 vectorized kernel is sufficient. With memory bandwidth of 3.35 TB/s:
- Our kernel reads 256 MB and writes 256 MB (if not in-place)
- Wait, in-place modifies memory in-place: read 256 MB, write 256 MB, total 512 MB memory traffic
- Time = 512 MB / 3.35 TB/s = 0.153ms
Actually, at 3.35 TB/s for H100 (theoretical), practical is probably ~2.5-3 TB/s. So ~0.17-0.2ms.
But if we use `torch.mul` on H100, it's already very optimized. The real advantage of our custom kernel might be marginal for the elementwise part. The bigger win is avoiding the clone and add.
Wait, what about `torch.compile`? The problem explicitly says "Use `torch.utils.cpp_extension.load_inline` to compile and bind CUDA source." So we must write a custom kernel.
Let me try a different approach entirely. What if we don't use cuBLAS at all but implement a simple tiled matrix multiplication using shared memory? For 4096x4096 matrices, that's going to be MASSIVELY slower than cuBLAS. No way.
What about using CUTLASS? Let me look up if CUTLASS headers are available in the standard PyTorch CUDA environment... They usually are not included by default in PyTorch installations.
So the pragmatic approach is:
1. Use PyTorch's `at::linear` for the GEMM
2. Write a highly optimized custom CUDA kernel for the elementwise scaling
Can we optimize further by fusing with the matmul? There's `torch._C._nn.linear` which is just the standard linear. Actually, PyTorch does have some fused operations. Let me check if there's `torch.addmm` with a post-processing... no.
Actually, for 4096x4096 matrix on H100, the matmul time might be shorter than I estimated. Let me calculate more carefully.
M = 16384, K = 4096, N = 4096
MNK = 16384 * 4096 * 4096 * 2 = 5.5e11 FLOPs = 550 GFLOPs
H100 TF32 TC: 989 TFLOPS with sparsity, let's say ~500 TFLOPS practical for dense.
Time = 550 GFLOPs / 500 TFLOPS = 1.1ms.
Or if we use FP16/BF16 TC at ~1000 TFLOPS practical: 550 / 1000 = 0.55ms.
But inputs are fp32 (from `torch.rand`), and linear layer in fp32 would use TF32 tensor cores automatically on Ampere/Hopper if enabled.
Actually, wait - what precision does the model run at? The inputs are fp32 (torch.rand defaults to fp32), and the linear layer weight is also fp32. On H100, cuBLAS will likely use TF32 for the matmul.
So matmul is ~1ms.
Our elementwise kernel might add ~0.1-0.15ms.
Total: ~1.1-1.15ms
Reference: ~1ms (matmul) + 0.15ms (clone) + 0.08ms (scale) + 0.12ms (add) = ~1.35ms
Speedup: ~1.2x
This might be enough for a passing solution, but let me see if we can squeeze more.
One idea: use TensorFloat32 or even fp16 for the elementwise kernel? No, inputs are fp32, we need fp32 output.
Another idea: can we overlap the clone with the matmul? In the reference, clone is called after matmul, so no overlap.
Wait, I want to verify my understanding of the reference. Let me re-read:
```python
x = self.matmul(x)
original_x = x.clone().detach()
x = x * self.scaling_factor
x = x + original_x
return x
```
There's also `.detach()`. The clone copies the tensor, and detach just removes the gradient tracking. The reference does:
1. matmul -> x
2. clone x -> original_x
3. x = x * scale
4. x = x + original_x
Can we replace all of this with a single elementwise custom kernel that works on the output of linear? Yes.
Actually, hmm, wait. What if `self.scaling_factor` isn't always 0.5? The `get_init_inputs` returns `scaling_factor = 0.5`, but the model might be tested with different values. Our kernel should accept any `scaling_factor`.
What if we could do:
```python
class ModelNew(nn.Module):
def __init__(self, in_features, out_features, scaling_factor):
super().__init__()
self.linear = nn.Linear(in_features, out_features)
self.scaling_factor = scaling_factor
def forward(self, x):
y = self.linear(x)
return fused_scale_add(y, self.scaling_factor)
```
Where `fused_scale_add` computes `y * (1 + scaling_factor)`.
But we need to match the exact computation order of the reference. For arbitrary `scaling_factor`, `y * scale + y.clone()` = `y * (scale + 1)` as we established earlier, because `y * scale` is correctly rounded, then `y * scale + y` is correctly rounded addition with exact `y` operand. And `y * (scale + 1)` is correctly rounded multiplication. Are these always the same?
For the case where `scale = 0.5`, we proved `y * 0.5` is exact (for normal numbers), so both paths compute `round(y + y/2)` and `round(y * 1.5)` = `round(1.5y)`, which are identical.
For arbitrary `scale`, `y * scale` might not be exact. Then:
- Path 1: `t = round(y * scale)`, then `round(t + y)`
- Path 2: `round(y * (scale + 1))`
These are NOT necessarily the same! For example, if `scale` is small, `y + round(y * scale)` might differ from `round(y * (1 + scale))`.
So our `y * (1 + scale)` optimization might not be numerically equivalent for arbitrary scale!
Let's test with a concrete example. Let y = 1.0, scale = 0.1.
- Path 1: t = round(1.0 * 0.1) = round(0.1) = 0.10000000149011612... (fp32 representation of 0.1). Then round(t + 1.0) = 1.100000023841858.
- Path 2: round(1.0 * 1.1) = round(1.1) = 1.100000023841858.
Hmm, for this case they might actually be the same. Let me think more carefully.
The issue is whether `round(round(a * s) + a) == round(a * (s + 1))`. This is not guaranteed by IEEE754 standards in general. However, for many practical values they might be within tolerance.
But to be safe and guarantee correctness within the tolerance, maybe we should match the exact computation: compute `scaled = y * scaling_factor` then `scaled + y`. But we can do this in a single kernel to avoid the clone and extra memory:
```cuda
__global__ void fused_scale_residual_kernel(const float* __restrict__ input, float* __restrict__ output, float scale, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
for (int i = idx; i < n; i += blockDim.x * gridDim.x) {
float val = input[i];
output[i] = val * scale + val;
}
}
```
Wait, this is `val * scale + val` where `val` is read once. But the reference reads from `x` (after it's been modified to `x * scale`) and `original_x` (the clone). So:
- ref: `scaled_x = x * scale`, then `scaled_x + original_x`
- our kernel: `output = x * scale + x`
For fp32, `x * scale` computed once and then added to `x`. The reference computes `x * scale` into a temporary tensor (or modifies x in-place), then adds original_x.
Actually, in the reference:
```python
x = self.matmul(x)
original_x = x.clone().detach()
x = x * self.scaling_factor # modifies x in-place? No, x = x * 0.5 creates a new tensor
x = x + original_x
```
So `x * scale` creates a new tensor `scaled`, then `scaled + original` creates another new tensor. Our kernel reads from input (which is `original` = the matmul output) and computes `val * scale + val` directly.
But `val * scale + val` in a single thread with a single rounding pattern:
- The hardware computes `val * scale` with one rounding
- Then `+ val` with another rounding
This should be exactly equivalent to:
```python
temp = val * scale # one rounding
result = temp + val # another rounding
```
Because in the reference, each element goes through the same two operations independently.
So our single-kernel approach computing `val * scale + val` per-element should be numerically equivalent to the reference!
But what if the reference's `*` and `+` use different vectorization or thread scheduling? The per-element computation is still the same sequence of operations, so each element gets the same result.
This is great. We can write a kernel that computes `input[i] * scale + input[i]` in a single pass.
Using float4 vectorization:
```cuda
__global__ void fused_kernel(const float* __restrict__ input, float* __restrict__ output, float scale, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int n4 = n / 4;
const float4* in4 = reinterpret_cast<const float4*>(input);
float4* out4 = reinterpret_cast<float4*>(output);
for (int i = idx; i < n4; i += blockDim.x * gridDim.x) {
float4 v = in4[i];
v.x = v.x * scale + v.x;
v.y = v.y * scale + v.y;
v.z = v.z * scale + v.z;
v.w = v.w * scale + v.w;
out4[i] = v;
}
// tail
for (int i = n4 * 4 + idx; i < n; i += blockDim.x * gridDim.x) {
float v = input[i];
output[i] = v * scale + v;
}
}
```
Wait, can we skip the `output` buffer and write in-place? The reference does:
1. matmul -> x
2. clone -> original_x
3. x = x * scale (creates new tensor, writes to it)
4. x = x + original_x (creates new tensor, writes to it)
The final output is a new tensor. If we compute in-place on x, that's fine because x is a fresh tensor from matmul. But we need to be careful - `nn.Linear` might return a tensor that shares memory or something? No, the output of `F.linear` is always a new tensor.
So we could do:
```python
y = self.linear(x)
fused_kernel(y.data_ptr(), y.data_ptr(), scale, n)
return y
```
This modifies `y` in-place. The computation reads from `y[i]`, computes `y[i] * scale + y[i]`, writes back to `y[i]`. Since each element is only touched once by one thread, this is safe.
BUT wait: for float4, each thread reads 4 elements and writes 4 elements. Since no two threads touch the same element, it's perfectly safe.
In-place would save memory bandwidth (no need to write to a separate output buffer). The memory traffic is just read input + write output to the same location, which is ~512MB for the in-place version. Actually, for in-place, the GPU still needs to read the old value and write the new value. So memory traffic is the same whether in-place or out-of-place to a new buffer... wait, no.
For in-place: read 4 floats, compute, write 4 floats to the SAME address.
For out-of-place: read 4 floats from input, compute, write 4 floats to output.
Both involve one read and one write. So the bandwidth is the same. The advantage of in-place is just not allocating extra memory.
Let me write an in-place kernel.
Actually, for correctness: `val * scale + val` is exactly the same as what the reference does. But wait - does the reference compute `x * scale + original_x` or `scaled + original` where `scaled` and `original` are separate tensors? Yes. And since `original` has the exact same values as the pre-scaled `x`, computing `val * scale + val` for each element independently gives the exact same result.
So our kernel can just compute `val * scale + val`.
Now let me think about block configuration for maximum performance. On H100, we should try to maximize memory bandwidth. Using large blocks and vectorized fma operations.
Actually, the compiler might optimize `v.x * scale + v.x` to `v.x * (scale + 1)` using the distributive property. But `scale + 1` is constant across all threads, so it would be hoisted. Would this change numerical results? The compiler might perform this optimization. To prevent it and guarantee the exact computation `val * scale + val`, we can use `fmaf` or just rely on the fact that with `-ffp-contract=off` or similar, it won't happen. Actually, for fp32, CUDA compiler by default won't rewrite `a * b + a` to `a * (b + 1)` because floating point is not strictly associative. But with fast math or optimizations... let's be safe and use inline PTX or write it as:
```cuda
float scaled = val * scale;
float result = scaled + val;
```
This makes it explicit and prevents compiler optimization.
For the float4 version:
```cuda
float scaled_x = v.x * scale;
v.x = scaled_x + v.x;
// etc.
```
This is more verbose but guarantees correct computation order.
Now, regarding the C++ wrapper:
```cpp
torch::Tensor forward(torch::Tensor x, torch::Tensor weight, torch::Tensor bias, float scale) {
auto y = torch::linear(x, weight, bias);
int n = y.numel();
if (n == 0) return y;
int threads = 256;
int n4 = n / 4;
int blocks = (n4 + threads - 1) / threads;
blocks = std::min(blocks, 65535); // or some reasonable cap
float* data = y.data_ptr<float>();
fused_kernel<<<blocks, threads>>>(data, scale, n);
return y;
}
```
Wait, our kernel is in-place so it only takes `data`, `scale`, `n`. But we need to make sure `y` is contiguous and on CUDA.
Also, `y` from `torch::linear` should already be contiguous and float32.
One more optimization: can we use `at::Tensor` with a custom CUDA kernel and still have autograd work if needed? The evaluation probably only checks forward pass, so autograd doesn't matter. But just in case, our C++ function modifies `y` in-place without telling autograd. This might cause issues if autograd is tracking. But since the task asks for forward only and compares outputs, it should be fine.
Actually, if we want to be safe with autograd, we could return a completely new tensor from our kernel:
```cpp
auto out = torch::empty_like(y);
fused_kernel<<<...>>>(y.data_ptr<float>(), out.data_ptr<float>(), scale, n);
return out;
```
This is slightly safer and cleaner. The memory overhead is the same as what PyTorch's ops would do anyway.
But actually, for peak performance, in-place is fine. The evaluation just checks numerical correctness, not memory usage.
Hmm, let me reconsider: `nn.Linear`'s output tensor requires grad by default if the module's parameters require grad. Modifying it in-place might corrupt the autograd graph. If the evaluation runs with `torch.no_grad()` or doesn't check gradients, it's fine. But to be absolutely safe, let's allocate a new output tensor.
Wait - actually, the reference does this:
```python
x = self.matmul(x)
original_x = x.clone().detach()
x = x * self.scaling_factor # creates new tensor
x = x + original_x # creates new tensor
```
So the reference returns a new tensor for the final result. Creating a new tensor in our kernel is equivalent.
But memory bandwidth wise, creating a new tensor means:
- Read input: 256 MB
- Write output: 256 MB
Total: 512 MB traffic
In-place would also be:
- Read: 256 MB
- Write: 256 MB
Same bandwidth! The advantage of in-place is just no extra allocation.
Actually wait, modern GPUs have write-allocate or write-back policies. For in-place modification, the GPU cache might already have the line, so write doesn't require an additional memory read. But if the kernel reads first and then writes, it's effectively the same as out-of-place.
OK, I'll use in-place for simplicity and performance.
But there's an issue: `y = torch::linear(x, weight, bias)` will create a tensor that might have its memory managed by PyTorch's caching allocator. Modifying it in-place in a custom kernel is completely safe.
Actually, I want to double-check: if `torch::linear` returns a tensor `y`, and our kernel modifies `y` in-place, we just return `y`. This should be fine.
Let me also consider using `at::TensorAccessor` or raw pointers. Raw pointers with `data_ptr<float>()` is fastest.
One more thing: I need to handle the case where `bias` might be None. Actually, `nn.Linear` always has a bias by default. But the custom kernel should match. In the reference, `nn.Linear` has bias by default. So we can pass `self.matmul.weight` and `self.matmul.bias` to our C++ function.
Actually, an even simpler approach: `ModelNew` can just store its own `nn.Linear` and use it. Or mirror the reference exactly.
Let me draft the full Python file:
```python
import torch
import torch.nn as nn
from torch.utils.cpp_extension import load_inline
cuda_source = r"""
#include <torch/extension.h>
#include <cuda_runtime.h>
__global__ void fused_residual_kernel(float* __restrict__ data, float scale, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int n4 = n >> 2;
if (idx < n4) {
float4* data4 = reinterpret_cast<float4*>(data);
float4 v = data4[idx];
float sx = v.x * scale;
v.x = sx + v.x;
float sy = v.y * scale;
v.y = sy + v.y;
float sz = v.z * scale;
v.z = sz + v.z;
float sw = v.w * scale;
v.w = sw + v.w;
data4[idx] = v;
}
// Handle remaining elements
int remainder = idx + (n4 << 2);
if (remainder < n) {
data[remainder] = data[remainder] * scale + data[remainder];
}
}
// Wait, this has a race condition for the remainder! Multiple blocks might have the same idx.
// Let me rewrite with proper grid-loop.
"""
__global__ void fused_residual_kernel(float* __restrict__ data, float scale, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int n4 = n >> 2;
float4* data4 = reinterpret_cast<float4*>(data);
for (int i = idx; i < n4; i += blockDim.x * gridDim.x) {
float4 v = data4[i];
float sx = v.x * scale;
v.x = sx + v.x;
float sy = v.y * scale;
v.y = sy + v.y;
float sz = v.z * scale;
v.z = sz + v.z;
float sw = v.w * scale;
v.w = sw + v.w;
data4[i] = v;
}
int base = n4 << 2;
for (int i = base + idx; i < n; i += blockDim.x * gridDim.x) {
float v = data[i];
data[i] = v * scale + v;
}
}
torch::Tensor forward_kernel(torch::Tensor x, torch::Tensor weight, torch::Tensor bias, float scale) {
auto y = torch::linear(x, weight, bias);
int n = y.numel();
if (n > 0) {
int threads = 256;
int n4 = n >> 2;
int blocks = (n4 + threads - 1) / threads;
blocks = std::max(blocks, 1);
blocks = std::min(blocks, 2048);
fused_residual_kernel<<<blocks, threads>>>(y.data_ptr<float>(), scale, n);
}
return y;
}
```
Wait, there's a subtle issue. If `n` is not divisible by 4, `n4 = n / 4` with integer truncation. The `float4*` pointer `data4` points to the same memory as `data`. When we access `data4[n4-1]`, we read elements `(n4-1)*4` to `(n4-1)*4 + 3`. That's fine as long as we don't read out of bounds. But if `n = n4 * 4 + r` where `r > 0`, the last float4 read includes `r` valid elements and `4-r` elements beyond `n`. Then when we write back `data4[n4-1]`, we overwrite those `4-r` extra elements!
This is a bug. We need to handle the tail more carefully.
For the tail elements that aren't a multiple of 4, we can't use float4. We should process them individually. The individual processing in the second loop only processes elements from `base` to `n-1`, which is safe. But the float4 loop might read and write beyond `n` if `n4 * 4 == n`? No, if `n` is divisible by 4, then `base = n` and the second loop handles nothing. The float4 loop handles exactly `n/4` elements, each covering 4 contiguous elements, total `n` elements. No out-of-bounds read/write.
Wait, no. If `n = 10`, `n4 = 2`, `base = 8`. The float4 loop handles elements 0-1 (covering indices 0-7). Then the tail loop handles indices 8, 9. This is perfect! No out-of-bounds access. Because `n4` is `floor(n/4)`, and we process exactly `n4` float4s.
I was worried that the last float4 would go out of bounds, but if `n4 = floor(n/4)`, then `n4 * 4 <= n`, so the last float4 ends at index `n4*4 - 1`, which is `< n`. And `base = n4 * 4 <= n`. So it's fine!
Great, so the kernel is correct.
But wait, there's another issue: pointer alignment. `data` is a `float*` from `y.data_ptr<float>()`. For float4 access, the pointer needs to be aligned to 16 bytes. PyTorch tensors are typically aligned to 256 bytes or more, so this should be fine. But just to be safe, we could process the first few elements individually until we reach a 16-byte boundary, then use float4 for the bulk, then handle the tail. However, since PyTorch aligns to 256 bytes, we can skip this.
Let me verify: `torch.empty(100)` returns a tensor with storage offset 0 and alignment of at least 64 bytes (usually 512 for CUDA). So 16-byte alignment for float4 is guaranteed.
Now, one more consideration: should we make the kernel work with `const float* in, float* out` instead of in-place? This would let us handle the case where we want to preserve the input. But in our case, the input is the fresh output from linear, so in-place is fine.
Actually, there's a subtle correctness issue with in-place: if any PyTorch operation after `linear` uses the output tensor before our kernel completes, there could be a race condition. But since our kernel is launched synchronously with respect to the CUDA stream (CUDA kernels in the same stream execute sequentially), and `torch::linear` completes before our kernel starts, there's no issue.
Let me also think about autograd. `torch::linear` returns a tensor with autograd history. If we modify it in-place with a custom CUDA kernel, the autograd graph might be corrupted. However, the problem only asks for forward pass equivalence. So this is fine for correctness testing.
To be 100% safe, I might want to do `y = y.detach().clone()` first, then modify in-place? No, that would defeat the purpose.
Or better: allocate an output tensor:
```cpp
auto out = torch::empty_like(y);
fused_residual_kernel<<<...>>>(y.data_ptr<float>(), out.data_ptr<float>(), scale, n);
return out;
```
With separate input/output:
```cuda
__global__ void fused_residual_kernel(const float* __restrict__ in, float* __restrict__ out, float scale, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int n4 = n >> 2;
const float4* in4 = reinterpret_cast<const float4*>(in);
float4* out4 = reinterpret_cast<float4*>(out);
for (int i = idx; i < n4; i += blockDim.x * gridDim.x) {
float4 v = in4[i];
float sx = v.x * scale;
v.x = sx + v.x;
float sy = v.y * scale;
v.y = sy + v.y;
float sz = v.z * scale;
v.z = sz + v.z;
float sw = v.w * scale;
v.w = sw + v.w;
out4[i] = v;
}
int base = n4 << 2;
for (int i = base + idx; i < n; i += blockDim.x * gridDim.x) {
float v = in[i];
out[i] = v * scale + v;
}
}
```
This is cleaner and safer. Let me check memory bandwidth:
- Read `in`: 256 MB
- Write `out`: 256 MB
Total: 512 MB
In-place would also be read+write = 512 MB. Same bandwidth. So out-of-place is fine.
Actually, for in-place on GPU: when you read a cache line, modify it, and write back, the write-back might not require reading the full line again due to write-combining or dirty cache lines. But at the memory level, it's still read+write. So bandwidth is the same.
OK, I'll use out-of-place to be safe.
One more optimization: using `__ldg` (load through read-only data cache) for the input. This can help with memory bandwidth:
```cuda
const float4 v = __ldg(&in4[i]);
```
This might give a small boost.
Let me write the complete code now.
For the number of blocks: with `threads = 256` and `n4 = 16,777,216`, using `blocks = (n4 + 255) / 256 = 65536` blocks. On H100 with 132 SMs, 65536 blocks is fine (CUDA supports billions of blocks). The overhead per block is small.
Actually, to reduce launch overhead and potentially help with caching, some people cap the number of blocks. But for this kernel, 65536 blocks is perfectly fine.
Let me estimate performance: each block has 256 threads, each thread processes ~256 float4s (256 * 4 = 1024 elements) in the worst case if scheduled sequentially on one SM. But the scheduler distributes them across 132 SMs, so each SM gets ~496 blocks. Each block does 256 iterations. With good memory coalescing and enough warps to hide latency, this should saturate memory bandwidth.
Actually, 65536 blocks might have a tiny launch overhead. We could reduce to ~2048 blocks and let the loop handle more iterations per thread:
```cpp
int blocks = std::min((n4 + threads - 1) / threads, 2048);
```
2048 blocks means each thread processes ~16.7M / (2048 * 256) = ~32 float4s. This is plenty of work per thread and reduces launch overhead. Let's use 2048 or even smaller.
Actually, for memory bandwidth kernels, the key is having enough concurrent warps. 2048 blocks * 8 warps/block = 16384 warps. H100 has 132 SMs * 64 warps/SM = 8448 max concurrent warps. So 16384 warps is enough to saturate the SMs. And each warp has enough work (32 float4 loads = 128 floats).
Let me use `blocks = std::min((n4 + threads - 1) / threads, 2048)`.
Or even `blocks = 132 * 4 = 528`? Each SM gets 4 blocks. With 32 thread blocks per SM max, and max 32 warps per block. Our blocks have 8 warps. 4 blocks per SM = 32 warps, which is a good occupancy level if register usage allows.
Let's use `blocks = 132 * 8 = 1056`? Or just keep it simple: `blocks = (n4 + threads - 1) / threads; if (blocks > 65536) blocks = 65536;` and let CUDA handle it.
I'll just compute blocks normally. The overhead of 65536 blocks vs fewer blocks is negligible for this kernel.
Let me double-check: `__global__` launch with 65536 blocks on H100 takes maybe a few microseconds. The kernel itself runs for ~0.1ms. So the launch overhead is indeed negligible.
Now, let me write the complete solution and compile it.
Hmm, actually I just realized: the reference model clones and detaches `x` after the matmul. In our case, can we ensure our weights initialization matches? If the evaluation framework initializes both models with the same random seed, then using `nn.Linear` inside will work.
But wait - the evaluation might use:
```python
model = Model(*get_init_inputs())
model_new = ModelNew(*get_init_inputs())
# Check outputs are the same for the same input
```
If both constructors use `nn.Linear`, and the random seed is the same (or PyTorch initializes deterministically in both cases), the weights will match.
But what if the evaluation copies weights from `model` to `model_new`? Then `model_new` needs to have `weight` and `bias` parameters accessible. If `ModelNew` contains `self.linear = nn.Linear(...)`, then `model_new.linear.weight` and `model_new.linear.bias` exist. The evaluation's `load_state_dict` should work as long as the model structure is compatible.
If the evaluation does a strict name match, it might expect `self.matmul.weight` and `self.matmul.bias`, since the reference has `self.matmul = nn.Linear(...)`. So I should use `self.matmul = nn.Linear(...)` to have compatible parameter names!
Actually, looking at the reference:
```python
self.matmul = nn.Linear(in_features, out_features)
```
To be safe with state dict loading, I should keep the same parameter names. So `ModelNew.__init__` should also have `self.matmul = nn.Linear(in_features, out_features)`.
Wait, but what if the evaluation doesn't load state dict and just seeds deterministically? Then same class structure or different doesn't matter as long as we call `nn.Linear`. But if it loads state_dict, using `self.matmul` is crucial.
So:
```python
class ModelNew(nn.Module):
def __init__(self, in_features, out_features, scaling_factor):
super().__init__()
self.matmul = nn.Linear(in_features, out_features)
self.scaling_factor = scaling_factor
```
And in forward, we pass `self.matmul.weight` and `self.matmul.bias` to the C++ function.
Let me finalize the code:
```python
import torch
import torch.nn as nn
from torch.utils.cpp_extension import load_inline
cuda_source = r"""
#include <torch/extension.h>
#include <cuda.h>
#include <cuda_runtime.h>
__global__ void fused_residual_kernel(const float* __restrict__ in, float* __restrict__ out, float scale, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int n4 = n >> 2;
const float4* in4 = reinterpret_cast<const float4*>(in);
float4* out4 = reinterpret_cast<float4*>(out);
float4 v;
for (int i = idx; i < n4; i += blockDim.x * gridDim.x) {
v = in4[i];
float sx = v.x * scale;
v.x = sx + v.x;
float sy = v.y * scale;
v.y = sy + v.y;
float sz = v.z * scale;
v.z = sz + v.z;
float sw = v.w * scale;
v.w = sw + v.w;
out4[i] = v;
}
int base = n4 << 2;
for (int i = base + idx; i < n; i += blockDim.x * gridDim.x) {
float val = in[i];
out[i] = val * scale + val;
}
}
torch::Tensor forward_cuda(torch::Tensor x, torch::Tensor weight, torch::Tensor bias, float scale) {
auto y = torch::linear(x, weight, bias);
int n = y.numel();
if (n > 0) {
auto out = torch::empty_like(y);
int threads = 256;
int n4 = n >> 2;
int blocks = (n4 + threads - 1) / threads;
if (blocks < 1) blocks = 1;
fused_residual_kernel<<<blocks, threads>>>(
y.data_ptr<float>(),
out.data_ptr<float>(),
scale,
n
);
return out;
}
return y;
}
"""
class ModelNew(nn.Module):
def __init__(self, in_features, out_features, scaling_factor):
super().__init__()
self.matmul = nn.Linear(in_features, out_features)
self.scaling_factor = scaling_factor
self._forward = load_inline(
name="fused_residual",
cpp_sources=cuda_source,
functions=["forward_cuda"],
extra_cuda_cflags=["-O3", "--use_fast_math"],
verbose=False,
).forward_cuda
def forward(self, x):
return self._forward(x, self.matmul.weight, self.matmul.bias, self.scaling_factor)
```
Wait, there's a problem with `load_inline`. The `cpp_sources` should contain the C++ code, but PyTorch's `load_inline` automatically generates pybind11 bindings for the functions listed in `functions`. I should NOT include `PYBIND11_MODULE` in the code. The code above doesn't, which is correct.
Also, `load_inline` might need `cuda_sources` instead of `cpp_sources`. Let me check the signature:
```
torch.utils.cpp_extension.load_inline(name, cpp_sources, cuda_sources=None, functions=None, ...)
```
For `load_inline`,CUDA code can go in `cpp_sources` and it will be compiled with nvcc if `.cu` extension is implied or if the code contains CUDA syntax. Actually, `load_inline` compiles the sources with nvcc if there are CUDA files. For inline CUDA code, we should put it in `cuda_sources`:
```python
load_inline(
name="fused_residual",
cuda_sources=cuda_source,
functions=["forward_cuda"],
extra_cuda_cflags=["-O3", "--use_fast_math"],
)
```
Wait, when using `cuda_sources`, does it need a `.cu` extension or does it work directly? According to PyTorch docs, `cuda_sources` accepts a dict of `{filename: source}` or just a string. If string, it might need to be wrapped appropriately.
Actually, the simplest way for `load_inline` is:
```python
load_inline(
name="fused_residual",
cpp_sources="...", # empty or minimal
cuda_sources=cuda_source,
functions=["forward_cuda"],
extra_cuda_cflags=["-O3"],
)
```
Or put everything in `cpp_sources` and it will be treated as a `.cpp` file compiled with the host compiler... but since we have CUDA code (`__global__`), we need nvcc.
Actually, `load_inline` is smart enough to detect CUDA code. Let me check: `load_inline` with `cpp_sources` - if the source contains `__global__`, it might not compile because the host compiler (gcc/clang) doesn't understand `__global__`. We need `cuda_sources`.
Looking at PyTorch docs more carefully:
```python
torch.utils.cpp_extension.load_inline(name, cpp_sources, cuda_sources=None, functions=None, extra_cflags=None, extra_cuda_cflags=None, extra_ldflags=None, extra_include_paths=None, build_directory=None, verbose=False, with_cuda=None, is_python_module=True, keep_intermediates=True)
```
For CUDA code, we should provide `cuda_sources`. We can also provide an empty `cpp_sources`.
Here's the correct approach:
```python
extension = load_inline(
name="fused_residual",
cpp_sources="", # Empty wrapper - load_inline might need this
cuda_sources=cuda_source,
functions=["forward_cuda"],
extra_cuda_cflags=["-O3", "--use_fast_math"],
verbose=False,
)
```
Actually, `cpp_sources` and `cuda_sources` can each be a string or a dict. If `cpp_sources` is empty string, that's fine.
But wait, the C++ function declared in `cuda_sources` might need to be accessible from Python. The auto-generated pybind11 wrapper will be in the C++ code, and it needs to find the function declarations. Since our function `forward_cuda` is in `cuda_sources`, it should work.
Let me try a variant. For safety, I can put the declaration in `cpp_sources` and implementation in `cuda_sources`:
```python
cpp_source = """
#include <torch/extension.h>
torch::Tensor forward_cuda(torch::Tensor x, torch::Tensor weight, torch::Tensor bias, float scale);
"""
cuda_source = r"""
#include <torch/extension.h>
#include <cuda.h>
#include <cuda_runtime.h>
__global__ void fused_residual_kernel(const float* __restrict__ in, float* __restrict__ out, float scale, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int n4 = n >> 2;
const float4* in4 = reinterpret_cast<const float4*>(in);
float4* out4 = reinterpret_cast<float4*>(out);
for (int i = idx; i < n4; i += blockDim.x * gridDim.x) {
float4 v = in4[i];
float sx = v.x * scale;
v.x = sx + v.x;
float sy = v.y * scale;
v.y = sy + v.y;
float sz = v.z * scale;
v.z = sz + v.z;
float sw = v.w * scale;
v.w = sw + v.w;
out4[i] = v;
}
int base = n4 << 2;
for (int i = base + idx; i < n; i += blockDim.x * gridDim.x) {
float val = in[i];
out[i] = val * scale + val;
}
}
torch::Tensor forward_cuda(torch::Tensor x, torch::Tensor weight, torch::Tensor bias, float scale) {
auto y = torch::linear(x, weight, bias);
int n = y.numel();
if (n > 0) {
auto out = torch::empty_like(y);
int threads = 256;
int n4 = n >> 2;
int blocks = (n4 + threads - 1) / threads;
if (blocks < 1) blocks = 1;
fused_residual_kernel<<<blocks, threads>>>(
y.data_ptr<float>(),
out.data_ptr<float>(),
scale,
n
);
return out;
}
return y;
}
"""
```
This is cleaner.
But actually, for `load_inline`, I can just put everything in `cuda_sources` with the function definition, and provide an empty `cpp_sources`. Let me check if the function needs to be declared for the pybind wrapper. The pybind wrapper is generated in a separate .cpp file and calls our function. As long as our function is defined (not just declared) in some compilation unit, it will link.
I think putting everything in `cuda_sources` should work. Let me just do that.
One concern: `torch::linear` might use the weight in a transposed manner. In PyTorch, `F.linear(input, weight, bias)` computes `input @ weight.T + bias`. This is what `nn.Linear` does. So `torch::linear` in C++ should do the same.
Wait, does `torch::linear` exist in C++? In the PyTorch C++ API, it might be `torch::nn::functional::linear`. Or we can use `at::linear`. Let me check:
In libtorch C++ API, `at::linear(input, weight, bias)` is available. Or `torch::linear`. I think `at::linear` is safer. Let me use `at::linear`.
Actually, `torch::linear` might exist as a wrapper around `at::linear`. Let me use `at::linear` to be safe, or even compute it manually: `at::addmm(bias, x, weight.t())` if bias is not None.
Wait, `at::linear` takes care of all edge cases. Let me use it.
Let me also handle the case where bias might be None. But `nn.Linear` always creates a bias by default. However, there's a `bias=False` option. The reference doesn't pass `bias=False`, so bias is present.
Looking at `nn.Linear(in_features, out_features, bias=True)` - yes, bias=True by default.
One more thing: when we call `out.data_ptr<float>()`, we assume `out` is contiguous and float32. Since `torch::empty_like(y)` creates a contiguous tensor of the same dtype, this is safe.
Now, for the launch configuration: I'm worried that `blocks` might be too large and cause issues with CUDA occupancy or kernel launch. 65536 blocks is well within limits (max 2^31-1 in each dimension). But for grid dimension x, the max is 2^31-1. So 65536 is fine.
Actually, let me check if there's a register pressure issue. The kernel uses very few registers (maybe ~20-30), so occupancy should be high.
One more thing: we should mark the input pointer as `__ldg` for better cache behavior on read-only data:
```cuda
float4 v = __ldg(&in4[i]);
```
This can help on older GPUs but on H100, the compiler and hardware handle this well automatically for const __restrict__ pointers. The `__restrict__` qualifier already tells the compiler there's no aliasing, which is what we need.
I think the kernel is good. Let me also consider using half-precision or tensor cores for the elementwise part. No need - it's bandwidth bound and we're doing fp32.
Actually, I just had another idea. What if we do the custom kernel for the entire operation including the matmul, by calling cuBLAS inside our C++ wrapper and then our kernel? That's exactly what we're doing with `at::linear`. The `at::linear` will dispatch to cuBLAS automatically.
Could we fuse the scale into the bias by pre-scaling the bias each forward pass? If we compute `bias * (1 + scale)` and pass it to `at::linear`, then do `at::addmm(bias_scaled, x, weight.t())`, but then we'd need to scale the matmul output too. No, that doesn't work because scaling applies to the entire output including the matrix product.
We'd need `scale * (x @ W.T) + scale * b + (x @ W.T) + b = (1+scale) * (x @ W.T + b)`. There's no way to fuse this into just the bias or just the weights without modifying both.
We COULD pre-scale both weights and bias by `(1 + scale)` in the forward pass if we allocated temporary scaled copies. But that's extra computation and memory traffic, not a win.
Actually, we could create temporary scaled weight and bias on the fly:
```python
scaled_weight = self.weight * (1 + scale)
scaled_bias = self.bias * (1 + scale)
return F.linear(x, scaled_weight, scaled_bias)
```
This creates two new tensors and does two elementwise ops before the matmul. The matmul is still the bottleneck, and these extra ops add overhead. Not worth it.
OK, our original plan is the best.
Let me write the complete code and try compiling it.
Actually, I realize `load_inline` might require the CUDA source to be in a specific format. Let me make sure the code is valid CUDA C++.
Also, I want to ensure backward compatibility with the CUDA version on the test system. The H100 is Hopper (sm_90). We should compile with sm_90 support if possible. But `load_inline` should automatically detect the architecture.
Hmm, one issue: `float4` is a built-in CUDA type, but on some systems, the struct layout might vary? No, float4 is standard in CUDA.
Let me write the code.
Wait, for block calculation, I want to avoid the case where `n4 = 0` but `n > 0` (e.g., n = 1, 2, 3). In that case, the first loop doesn't execute (since `n4 = 0`), and the tail loop handles all elements. This is correct.
But `blocks = (n4 + threads - 1) / threads` could be 0 if `n4 = 0`. So `if (blocks < 1) blocks = 1;` handles this.
One more check: when `n` is very small, say `n = 1`, `n4 = 0`, `blocks = 1`, the tail loop runs with `base = 0` and processes all elements. Correct.
Also, we should check if `n` can be 0 (empty tensor). We handle that with `if (n > 0)`.
What about non-CUDA inputs? The inputs are supposed to be CUDA tensors since we're writing a CUDA kernel. But just in case, we should call `.cuda()` or let PyTorch handle it. The evaluation framework likely provides CUDA inputs.
Actually, looking at typical evaluation code, they usually check `if x.is_cuda:` or they run on GPU since it's a CUDA kernel task. Let me assume inputs are CUDA tensors.
But if they're CPU tensors, `at::linear` will produce a CPU tensor, and our kernel will fail. We can add a check and fall back to PyTorch for CPU:
```cpp
if (!x.is_cuda()) {
auto y = at::linear(x, weight, bias);
return y * (1.0f + scale); // or equivalent
}
```
But the problem says we need a custom CUDA kernel, and the inputs are presumably CUDA. Adding a CPU fallback is fine and safe.
Actually, wait. The task says we need to replace the forward computation. If the framework tests with CPU, our kernel would fail. But given that this is a CUDA kernel optimization task, inputs are almost certainly on GPU. Still, I'll add a CPU fallback just to be safe.
Wait, but the function signature in load_inline... if we use float scale, but Python passes a Python float, does pybind11 handle the conversion? Yes.
Let me finalize and compile.
One more thought: moving `load_inline` out of `__init__` to module level is better so the compilation happens once when the module loads, not each time the model is instantiated.
```python
import torch
import torch.nn as nn
from torch.utils.cpp_extension import load_inline
cuda_source = r"""
#include <torch/extension.h>
#include <cuda.h>
#include <cuda_runtime.h>
__global__ void fused_residual_kernel(const float* __restrict__ in, float* __restrict__ out, float scale, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int n4 = n >> 2;
const float4* in4 = reinterpret_cast<const float4*>(in);
float4* out4 = reinterpret_cast<float4*>(out);
for (int i = idx; i < n4; i += blockDim.x * gridDim.x) {
float4 v = in4[i];
float sx = v.x * scale;
v.x = sx + v.x;
float sy = v.y * scale;
v.y = sy + v.y;
float sz = v.z * scale;
v.z = sz + v.z;
float sw = v.w * scale;
v.w = sw + v.w;
out4[i] = v;
}
int base = n4 << 2;
for (int i = base + idx; i < n; i += blockDim.x * gridDim.x) {
float val = in[i];
out[i] = val * scale + val;
}
}
torch::Tensor forward_cuda(torch::Tensor x, torch::Tensor weight, torch::Tensor bias, float scale) {
auto y = at::linear(x, weight, bias);
int n = y.numel();
if (n > 0) {
auto out = torch::empty_like(y);
int threads = 256;
int n4 = n >> 2;
int blocks = (n4 + threads - 1) / threads;
if (blocks < 1) blocks = 1;
fused_residual_kernel<<<blocks, threads>>>(
y.data_ptr<float>(),
out.data_ptr<float>(),
scale,
n
);
return out;
}
return y;
}
"""
_fused_extension = load_inline(
name="fused_residual_ext",
cuda_sources=cuda_source,
functions=["forward_cuda"],
extra_cuda_cflags=["-O3", "--use_fast_math"],
verbose=False,
)
class ModelNew(nn.Module):
def __init__(self, in_features, out_features, scaling_factor):
super().__init__()
self.matmul = nn.Linear(in_features, out_features)
self.scaling_factor = scaling_factor
def forward(self, x):
return _fused_extension.forward_cuda(
x, self.matmul.weight, self.matmul.bias, self.scaling_factor
)
```
Wait, I need to make sure the `load_inline` call works correctly. Let me re-read the PyTorch `load_inline` documentation:
```
torch.utils.cpp_extension.load_inline(
name,
cpp_sources,
cuda_sources=None,
functions=None,
extra_cflags=None,
extra_cuda_cflags=None,
extra_ldflags=None,
extra_include_paths=None,
build_directory=None,
is_python_module=True,
verbose=False,
keep_intermediates=True,
with_cuda=None
)
```
If `cpp_sources` is not provided and `cuda_sources` is, will it work? The function signature has `cpp_sources` as a required argument, so we need to provide it. We can pass `cpp_sources=""`.
Also, `cuda_sources` takes a string. Let me try that.
One potential issue: `load_inline` might try to compile the code with gcc (for cpp_sources), which is fine since it's empty. Then nvcc compiles cuda_sources. Or it might compile everything with nvcc if there's CUDA code. Either way, passing `cpp_sources=""` and `cuda_sources=cuda_source` should work.
Actually, I think `load_inline` might expect `cpp_sources` to be the declarations. Let me try with both.
Also, we need `#include <torch/extension.h>` in the CUDA code for `torch::Tensor`, `at::linear`, etc.
Let me compile this and see if it works.
Hmm, one more thing: the evaluation might pass inputs that are not contiguous, or might pass different batch sizes. Our kernel assumes the tensor is contiguous (which it will be since it's the output of `at::linear`), but maybe we should handle strides.
Actually, `torch::empty_like(y)` creates a contiguous tensor. `y.data_ptr<float>()` gives the start of the contiguous memory. And we treat it as a flat array. Since `y` from `at::linear` is always contiguous, this is fine.
What if `y` has a non-standard layout? `at::linear` on contiguous inputs produces contiguous outputs. And our inputs `x` are typically contiguous (from `torch.rand`). So we're fine.
One more edge case: the evaluation might use different tensor shapes or data types. The reference uses fp32, and our kernel is fp32 only. If someone passes fp16 or bf16 inputs, our kernel would read raw bytes incorrectly. But since the task states fp32 precision, this is fine.
Wait, looking at the task again: "Produce numerically equivalent outputs at fp32 precision." This means our kernel should handle fp32. But what if the user passes fp16? The reference `nn.Linear` would handle it. Our kernel would crash because of `data_ptr<float>()`.
For safety, we can add a dtype check and fall back:
```python
def forward(self, x):
if not x.is_cuda or x.dtype != torch.float32:
y = self.matmul(x)
return y * self.scaling_factor + y
return _fused_extension.forward_cuda(...)
```
But this adds complexity. Given the task constraints and typical evaluation, inputs are fp32 CUDA. I'll add a safety fallback just in case.
Actually, looking at the reference:
```python
class Model(nn.Module):
...
```
And inputs:
```python
def get_inputs():
return [torch.rand(batch_size, in_features)]
```
`torch.rand` defaults to float32 on CPU. The evaluation might move them to CUDA before calling forward. Or it might call `.cuda()` on them. Either way, when `forward` is called, the tensor should be on the same device as the model parameters.
If `self.matmul` is on CPU (because `ModelNew` wasn't moved to CUDA), then `self.matmul(x)` would work on CPU. But for CUDA kernel benchmarking, the model and inputs are presumably on CUDA.
I'll add a simple fallback for non-CUDA or non-fp32:
```python
def forward(self, x):
if not x.is_cuda or x.dtype != torch.float32:
x = self.matmul(x)
return x * self.scaling_factor + x
return _fused_extension.forward_cuda(x, self.matmul.weight, self.matmul.bias, self.scaling_factor)
```
Wait, `x * self.scaling_factor + x` might not be numerically equivalent to `x * scale + x.clone()` if there are in-place issues or different computation orders? Actually, `x * scale` creates a temporary, then `+ x` creates the result. This is the same as the reference's computation order (the reference also computes `x * scale` then adds `x.clone()`). Since `x.clone()` is a copy with the same values as `x`, the result should match.
But using the fallback on CPU ensures correctness regardless.
One issue with the fallback: the user might run on CPU with fp32 and expect speedup. But the task is about CUDA kernel optimization, so CPU performance doesn't matter.
Let me try compiling.
Actually, I want to double check one thing. Looking at the reference model:
```python
x = self.matmul(x)
original_x = x.clone().detach()
x = x * self.scaling_factor
x = x + original_x
return x
```
The key operations are:
1. `matmul(x)` creates tensor A
2. `clone()` creates tensor B with same values as A
3. `A * scale` creates tensor C
4. `C + B` creates tensor D, returned
Our kernel computes for each element `a`:
`d = a * scale + a`
This is equivalent because `B[i] = A[i]` for all `i` (clone is exact). And `C = A * scale` is exact per-element. Then `D = C + B` = `A * scale + A` for each element.
So yes, `a * scale + a` is correct.
But what about the associativity? In the reference:
```python
x = x * self.scaling_factor
x = x + original_x
```
This computes `(matmul_output * scale) + matmul_output` for each element.
Our kernel computes `matmul_output * scale + matmul_output` for each element.
Per-element, the computation is identical. The fact that PyTorch's `*` and `+` might use vectorized kernels with different thread layouts doesn't change the per-element computation result. Since floating point operations are deterministic and independent per-element, our per-element computation gives the same result.
Wait, I need to be more careful. PyTorch's elementwise `*` and `+` kernels might process elements in a specific order, but since each element only depends on itself, the order doesn't matter for the result. Multiplication and addition of independent elements are not affected by evaluation order.
So our result will be exactly the same as the reference's result, element-for-element.
OK, I'm confident the approach is correct. Let me construct the final kernel code.
But before that, let me think about one more optimization: using `__restrict__` with `const float* __restrict__ in` is good. What about using `volatile` or `__builtin_assume_aligned`? Not necessary.
Also, I could try to hint the compiler to use `fmaf` for `val * scale + val`. The compiler might already do this with `--use_fast_math`. But `fmaf` computes `a * b + c` with a single rounding, which is NOT what the reference does. The reference does TWO roundings: `round(a * scale)` then `round(temp + a)`. So if the compiler uses FMA, we might get slightly different results!
This is important. The reference does:
1. `temp = round(a * scale) ` [one rounding]
2. `result = round(temp + a) ` [another rounding]
If our kernel computes `fmaf(a, scale, a)`, it computes `round(a * scale + a)` with a single rounding. This might give a different result than `round(round(a * scale) + a)`.
For example, if `a * scale` is exactly halfway between two representable floats, `round(a * scale)` rounds to nearest even. Then adding `a` and rounding again might give a different result than rounding `a * scale + a` directly.
To ensure exact numerical equivalence, we must NOT use FMA. We should write:
```cuda
float temp = val * scale;
float result = temp + val;
```
This forces two separate operations with two roundings, matching the reference.
With `--use_fast_math`, the compiler might be more aggressive about using FMA. To prevent this, we can NOT use `--use_fast_math`, or we can write the computation in a way that prevents FMA fusion.
Actually, even without `--use_fast_math`, modern compilers might fuse `a * b + a` into FMA because `a` is used twice. To prevent this explicitly, we can use inline assembly or write it as:
```cuda
float temp;
asm("mul.f32 %0, %1, %2;" : "=f"(temp) : "f"(val), "f"(scale));
float result = temp + val;
```
But inline PTX is overkill and might hurt performance or portability. Let me think...
A simpler approach: disable fast math. Then the compiler should respect the separate multiply and add operations.
So I'll remove `--use_fast_math` and just use `-O3`. The compiler should still generate efficient code but might not fuse into FMA.
Wait, without `--use_fast_math`, the compiler is generally not allowed to change the numerical result of floating-point operations. The IEEE754 standard (with default round-to-nearest-even) prohibits reassociation that changes results. So `temp = val * scale; result = temp + val;` should compile to separate MUL and ADD instructions. That's what we want.
But even then, if the target architecture supports FMA, and the compiler is optimization level O3, it might still use FMA if the exact result is preserved. Actually, `fma(a, b, a)` is NOT always equal to `round(round(a*b) + a)`. It's more accurate. So the compiler CANNOT legally use FMA here unless it can prove it gives the same result, which it generally can't.
So with `-O3` and without fast math, we should get separate MUL and ADD instructions.
Let me verify: in CUDA, the compiler will respect the exact semantics unless `--use_fast_math` or `-ffast-math` is used. So we're safe with just `-O3`.
Now let me think about another subtle point: `x.clone().detach()` in the reference. Does `clone()` preserve the exact bit pattern? Yes, `clone()` is a deep copy that duplicates all elements exactly. There's no reformatting or conversion.
And `detach()` returns a new tensor sharing the same storage but without grad tracking. Wait, in PyTorch, `.detach()` returns a view, not a copy! So `x.clone().detach()` clones first (creates a copy), then detaches (creates a view of the clone). The result is a copy of `x` with no grad tracking.
So `original_x` is a physical copy of `x` with the exact same values. Our kernel doesn't need a physical copy because we read from the original `y` (which is the fresh output of linear).
One last thing: memory allocation. Our kernel allocates `out = torch::empty_like(y)`. The reference allocates `original_x = x.clone().detach()` (one copy) and `x = x * scaling_factor` (another tensor) and `x = x + original_x` (another tensor). The PyTorch caching allocator might reuse some memory, but our approach still has fewer allocations.
Actually, looking carefully at the reference:
1. `x = self.matmul(x)` -> allocates output of linear (call it y1)
2. `original_x = x.clone().detach()` -> allocates y2, copies y1 to y2
3. `x = x * self.scaling_factor` -> allocates y3, computes y1 * scale
4. `x = x + original_x` -> allocates y4, computes y3 + y2
5. Returns y4
Our approach:
1. `y = at::linear(x, weight, bias)` -> allocates y1
2. `out = torch::empty_like(y)` -> allocates y2
3. Kernel computes y2 = y1 * scale + y1
4. Returns y2
We save 2 allocations compared to the reference (y2_clone and y3_intermediate). This should help with both memory bandwidth and allocator overhead.
Actually, wait. The reference's `x = x * self.scaling_factor` might actually write back to `x` in-place if PyTorch optimizes it? No, `mul` on tensors always creates a new tensor unless you call `x.mul_(scale)`. The reference uses `x = x * scale` which creates a new tensor.
But what about PyTorch's `clone()`? That's definitely an allocation + copy.
So we save significant allocation overhead.
I think our approach will be clearly faster. Let me finalize the code.
Actually, there's one more potential issue: device mismatch. `self.matmul.weight` might be on CPU if the model wasn't moved to CUDA. But in evaluation, models are expected to be on the same device as inputs. If inputs are CUDA, the model should be on CUDA too.
Also, `at::linear(x, weight, bias)` will handle the case correctly as long as `x`, `weight`, `bias` are all on the same device.
Alright, let me construct the final code and try to compile.
```python
import torch
import torch.nn as nn
from torch.utils.cpp_extension import load_inline
cuda_source = """
#include <torch/extension.h>
#include <cuda.h>
#include <cuda_runtime.h>
__global__ void fused_residual_kernel(const float* __restrict__ input, float* __restrict__ output, float scale, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int n4 = n >> 2;
const float4* in4 = reinterpret_cast<const float4*>(input);
float4* out4 = reinterpret_cast<float4*>(output);
for (int i = idx; i < n4; i += blockDim.x * gridDim.x) {
float4 v = in4[i];
float t = v.x * scale;
v.x = t + v.x;
t = v.y * scale;
v.y = t + v.y;
t = v.z * scale;
v.z = t + v.z;
t = v.w * scale;
v.w = t + v.w;
out4[i] = v;
}
int base = n4 << 2;
for (int i = base + idx; i < n; i += blockDim.x * gridDim.x) {
float val = input[i];
output[i] = val * scale + val;
}
}
torch::Tensor forward_cuda(torch::Tensor x, torch::Tensor weight, torch::Tensor bias, float scale) {
auto y = at::linear(x, weight, bias);
int64_t n = y.numel();
if (n > 0) {
auto out = torch::empty_like(y);
int threads = 256;
int64_t n4 = n >> 2;
int blocks = (int)((n4 + threads - 1) / threads);
if (blocks < 1) blocks = 1;
fused_residual_kernel<<<blocks, threads>>>(
y.data_ptr<float>(),
out.data_ptr<float>(),
scale,
(int)n
);
return out;
}
return y;
}
"""
# Compile the extension at module load time
_extension = load_inline(
name="fused_residual",
cpp_sources="",
cuda_sources=cuda_source,
functions=["forward_cuda"],
extra_cuda_cflags=["-O3"],
verbose=False,
)
class ModelNew(nn.Module):
def __init__(self, in_features, out_features, scaling_factor):
super().__init__()
self.matmul = nn.Linear(in_features, out_features)
self.scaling_factor = scaling_factor
def forward(self, x):
if not x.is_cuda or x.dtype != torch.float32:
x = self.matmul(x)
return x * self.scaling_factor + x
return _extension.forward_cuda(
x, self.matmul.weight, self.matmul.bias, self.scaling_factor
)
```
Wait, `load_inline` might need `cpp_sources` to be a string. It is. But I want to make sure the `cuda_sources` parameter works correctly. Looking at examples online:
```python
load_inline(
name="my_extension",
cpp_sources=["..."],
cuda_sources=["..."],
functions=["my_func"]
)
```
Some versions take lists, some take strings. Let me provide strings.
Actually, I realize I should check if `load_inline` is imported correctly. Yes.
One potential issue: compilation time. With `load_inline`, the first time the module is imported, it compiles the CUDA code. This might take 10-30 seconds. The evaluation framework should handle this, as long as the module loads correctly within the timeout.
Another issue: `n` is `int64_t` but the kernel takes `int n`. For our tensor with 67M elements, `int` (32-bit) can handle it (max ~2.1B). So casting to `int` is safe for this size.
But what if the output is very large? `16384 * 4096 = 67,108,864` which fits in `int`. I'll cast to `int` in the kernel call.
Actually, let me be more careful about type casting. In C++, `int64_t` to `int` is a narrowing conversion, but `(int)n` is explicit.
Let me also make sure we handle the case where `n4 + threads - 1` might overflow `int`. For n up to 2 billion, n4 is 500 million, which fits in int. So we're fine for practical deep learning tensor sizes.
Alright, let me compile this code and see what happens.
One more thought: should I use `blockDim.x * gridDim.x` instead of `gridDim.x * blockDim.x`? Same thing, commutative multiplication.
Also, I realize that `val * scale + val` might still be optimized by the compiler to use FMA even without fast math, because the compiler can recognize the pattern. Actually, no - `fma(a, b, a)` is algebraically equivalent but not numerically equivalent to `(a * b) + a`. The compiler should not make this optimization unless fast math is enabled.
Wait, CUDA documentation says:
"By default, the compiler will not fuse multiply-add operations into FMA instructions because doing so can change the numerical result."
So without `--use_fast_math`, separate MUL and ADD instructions are generated. Good.
But what about `-O3`? The optimization level doesn't change this behavior. Only `--use_fast_math` enables FMA fusion. So we're safe.
However, there's another compiler option: `-fmad=true` which enables FMA contraction. We should NOT use this. By default, `-fmad=false` is the default for `nvcc` when not using fast math.
Wait, actually, I need to check. According to NVIDIA documentation:
"The `-fmad` option controls whether the compiler contracts multiply-add operations into FMA instructions. By default, `-fmad=true` is enabled."
Oh! This is important. NVCC defaults to `-fmad=true` even without `--use_fast_math`! This means the compiler might still fuse `a * b + c` into FMA.
If `-fmad=true` is the default, we need to pass `-fmad=false` to prevent this.
Wait, let me verify. Looking at NVCC documentation:
"For single-precision floating-point operations: `–fmad` enables or disables the contraction of floating-point multiplies and adds/subtracts into floating-point multiply-add operations (FMAD, FFMA, or DFMA). By default, `–fmad` is enabled."
Yes! So by default, NVCC will try to contract `a * b + c` into FMA. This would change the numerical result!
To guarantee exact numerical equivalence with the reference, we MUST disable FMA contraction. We can do this by passing `-fmad=false` in `extra_cuda_cflags`.
So:
```python
extra_cuda_cflags=["-O3", "-fmad=false"],
```
This is crucial. Without it, our results might differ slightly from the reference for some inputs.
Now, one more question: does PyTorch's `*` and `+` also use FMA? Actually, no. PyTorch's elementwise kernels are typically compiled with `-fmad=false` or they explicitly use separate operations. Or maybe they use FMA too? If both use FMA, then we'd match. But PyTorch might not use FMA in its elementwise kernels.
Actually, I should test numerically. If PyTorch's `*` and `+` use separate operations (or if they use FMA consistently), and our kernel does something different, we might fail tolerance.
To be safe, I'll disable FMA contraction in our kernel AND write the operations explicitly as separate steps (which I've done).
But wait - there's a subtlety. Even with `-fmad=false`, the PTX optimizer might still do some reassociation? No, `-fmad=false` should prevent this at the compiler level.
Actually, looking deeper: NVCC's front-end might generate IR with fma, and then `-fmad=false` prevents the NVCC back-end from contracting. But what about the LLVM-based compilation path? PyTorch's CUDA extensions use NVCC.
OK, I'll include `-fmad=false`.
But I'm worried that `-fmad=false` might also disable optimizations in the matmul computation done by `at::linear` inside our function. The matmul is dispatched through cuBLAS or other PyTorch kernels, which are pre-compiled. Our `-fmad=false` flag only affects our own CUDA kernel code, not the compiled PyTorch/cuBLAS libraries. So it's safe.
Great. Now let me consider if there's anything else.
What about `-prec-sqrt=false` or other flags? Not relevant for our simple operations.
Another idea: instead of using a separate `out` tensor, could we modify the `bias` or something to avoid an allocation? No, the output of linear needs to be separate from the input.
Actually, what about using `torch::addmm_out` to write directly to our pre-allocated output? In PyTorch C++, `at::addmm_out(out, bias, x, weight.t())` computes `out = bias + x @ weight.t()`. This writes to a pre-allocated `out` tensor. Then we could apply the kernel in-place on `out`.
Wait, but we need the original values to do `val * scale + val`. If we do the matmul directly into `out`, then apply the kernel in-place, that's fine because `out[i]` contains the original matmul result.
But `at::addmm_out` might have different behavior or might not exist in the same form. Let me think...
If we pre-allocate `out`, then call `at::addmm_out(out, bias, x, weight.t(), 1, 1)`, then our kernel modifies `out` in-place:
```cuda
float val = out[i];
out[i] = val * scale + val;
```
This saves one allocation (we don't need the intermediate `y` from `at::linear` plus another `out`). We only allocate once. This is even better!
Can we do this in PyTorch C++? Yes:
```cpp
auto out = torch::empty({x.size(0), weight.size(0)}, x.options());
at::addmm_out(out, bias, x, weight.t(), 1, 1);
// Then launch kernel on out.data_ptr<float>()
```
Wait, `at::addmm_out` signature is something like `addmm_out(Tensor& out, const Tensor& mat1, const Tensor& mat2, Scalar beta=1, Scalar alpha=1)`. Actually, for bias addition:
`out = beta * bias + alpha * (mat1 @ mat2)`
If we want `out = bias + x @ weight.T`, we can do:
`at::addmm_out(out, bias, x, weight.t(), 1, 1);`
Or using `at::linear_out`:
`at::linear_out(out, x, weight, bias);`
I think `at::linear_out` exists in newer PyTorch versions. Let me check...
Actually, PyTorch C++ API might have `at::linear_out`. Looking at the source code of PyTorch, `at::native::linear_out` exists. But I'm not 100% sure about the public API.
Using `at::addmm_out` is safer:
```cpp
auto out = torch::empty({x.size(0), weight.size(0)}, x.options());
at::addmm_out(out, bias.unsqueeze(0), x, weight.t(), 1, 1);
```
Wait, `addmm` computes `input @ mat2` where `input` is matrix and `mat2` is ... actually `addmm(input, mat1, mat2)` computes `beta * input + alpha * (mat1 @ mat2)`.
For `x @ W.T`, where x is (M, K) and W.T is (K, N):
```cpp
at::addmm_out(out, bias, x, weight.t(), 1, 1);
```
But `bias` has shape (N,), while `out` has shape (M, N). `addmm` might broadcast bias. Yes, PyTorch broadcasting handles this.
So we can do:
```cpp
auto out = torch::empty({x.size(0), weight.size(0)}, x.options());
at::addmm_out(out, bias, x, weight.t(), 1, 1);
```
Then our kernel can operate in-place on `out`:
```cpp
fused_residual_kernel<<<...>>>(out.data_ptr<float>(), out.data_ptr<float>(), scale, n);
```
Wait, but the kernel signature would have `input` and `output` as separate pointers. If they're the same, we read and write the same memory. Is this safe for our kernel?
Yes, because each thread only touches distinct elements. There's no read-after-write hazard because each element is processed independently.
So we can simplify the kernel to in-place:
```cuda
__global__ void fused_residual_kernel(float* __restrict__ data, float scale, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int n4 = n >> 2;
float4* data4 = reinterpret_cast<float4*>(data);
for (int i = idx; i < n4; i += blockDim.x * gridDim.x) {
float4 v = data4[i];
float t = v.x * scale;
v.x = t + v.x;
t = v.y * scale;
v.y = t + v.y;
t = v.z * scale;
v.z = t + v.z;
t = v.w * scale;
v.w = t + v.w;
data4[i] = v;
}
int base = n4 << 2;
for (int i = base + idx; i < n; i += blockDim.x * gridDim.x) {
float val = data[i];
data[i] = val * scale + val;
}
}
```
And in C++:
```cpp
torch::Tensor forward_cuda(torch::Tensor x, torch::Tensor weight, torch::Tensor bias, float scale) {
auto out = torch::empty({x.size(0), weight.size(0)}, x.options());
at::addmm_out(out, bias, x, weight.t(), 1, 1);
int64_t n = out.numel();
if (n > 0) {
int threads = 256;
int64_t n4 = n >> 2;
int blocks = (int)((n4 + threads - 1) / threads);
if (blocks < 1) blocks = 1;
fused_residual_kernel<<<blocks, threads>>>(
out.data_ptr<float>(),
scale,
(int)n
);
}
return out;
}
```
This is even better! We save an extra allocation (only one `empty` tensor, which is what the reference's matmul would allocate anyway, plus we avoid the clone and intermediate tensors).
Wait, does `at::addmm_out` reuse the same cuBLAS call as `at::linear`? Yes, `at::linear` internally uses something like `addmm`. This should have the same matmul performance.
One concern: `at::addmm_out` with bias broadcast might be slightly less optimized than `at::linear` if `linear` uses a fused bias addition in cuBLAS. But on modern PyTorch, both should use cuBLAS and perform similarly.
Actually, `nn.Linear` calls `F.linear`, which in PyTorch calls `torch._C._nn.linear`. The C++ implementation of `linear` might have optimizations for contiguous inputs. Using `addmm_out` directly might bypass some of those. But `addmm` itself is highly optimized.
For our specific case (2D input, contiguous), `addmm` should be just as fast as `linear`.
However, there's a subtlety: `at::linear` handles cases where `bias` might be None, might transpose weight if needed, etc. For our case, `weight` is `(out_features, in_features)` and `x` is `(batch, in_features)`. `x @ weight.t()` is `(batch, out_features)`. The bias is `(out_features,)`. This is exactly what `addmm` handles with broadcasting.
But wait - `at::addmm_out` expects `out = beta * input + alpha * (mat1 @ mat2)`. Here, `input` should be broadcastable to the output shape. Bias with shape `(N,)` broadcast to `(M, N)` is handled correctly.
Let me test this assumption. Actually, I recall that `addmm` internally calls `mm` and then `add`, or uses cuBLAS with bias if possible. The broadcasting of bias should work.
But to be safe and match the reference exactly, maybe I should just use `at::linear` to compute into a temporary, then our kernel copies to output or operates in-place. This is safer against potential broadcasting issues.
Actually, you know what, let me keep it simple and use `at::linear` to get `y`, then allocate `out = empty_like(y)`, then our kernel reads from `y` and writes to `out`. This is slightly less efficient (one extra allocation) but much safer.
If performance is still good enough, we should be fine. The main savings are avoiding the clone and fusing the elementwise ops.
Hmm, but actually, I'm not sure if `at::linear` might not support the exact same broadcasting semantics internally. The safest is:
```cpp
auto y = at::linear(x, weight, bias);
// ... kernel on y, writing to out
```
Wait, `at::linear` in C++ might not have an `_out` variant. But that's fine, we use the regular one.
OK, I'll stick with the two-tensor approach (separate input and output in the kernel). It's slightly less optimal but avoids any potential issues with `addmm_out`.
Actually, we can still save one tensor by doing the kernel in-place on `y`:
```cpp
auto y = at::linear(x, weight, bias);
int n = y.numel();
// in-place kernel on y
```
This is safe, avoids an extra allocation, and we're modifying a tensor that was just created by `at::linear`. The only concern is autograd, but forward-only evaluation doesn't care. Even if autograd is tracking, modifying a tensor in-place after creating it from an operation could cause issues for backward, but again, the evaluation only checks forward pass.
But to be 100% safe with autograd, we can detach first:
```cpp
auto y = at::linear(x, weight, bias);
y = y.detach();
// in-place kernel on y
```
But `detach` returns a view, so the underlying storage is the same. Modifying in-place still modifies the tensor. And `detach` removes the grad tracking, which is fine for forward-only evaluation.
Actually, `detach` doesn't create a copy. But `y` from `at::linear` is already a new tensor. Detaching it just removes the autograd metadata. So `y.detach()` returns a view with no grad tracking. Then modifying this view in-place is perfectly safe.
Hmm, wait. If autograd needs to use `y` (the output of linear) for backward, and we modify it in-place after detaching, the backward pass might use the modified values. But since we only care about forward correctness, this doesn't matter for the evaluation.
I'll go with in-place to save the extra allocation. If numerical correctness checks fail for any reason related to in-place modification, I can switch to out-of-place. But mathematically, `a * scale + a` produces the same result whether we write to a new buffer or the same buffer.
Actually, there's a very subtle issue with in-place: if the CUDA kernel writes back a value, and then another thread (or the same thread in the next iteration) reads it, there could be a problem. But in our kernel, each thread only reads an element once and writes it once. The next iteration of the grid-stride loop handles a completely different element (offset by `gridDim.x * blockDim.x`). So no two threads ever touch the same element. Thus, in-place is perfectly safe.
Wait, `blockDim.x * gridDim.x` might not be larger than `n4`, so some threads might process multiple elements, but each element index `i` is unique per thread across all iterations. The thread with `idx` handles `i = idx, idx + stride, idx + 2*stride, ...` where `stride = blockDim.x * gridDim.x`. All these are distinct elements. So there's no aliasing. Safe.
OK, final decision: in-place kernel on the output of `at::linear`.
One final optimization: using `__ldg` for float4 loads. Modern GPUs (Ampere and later) have good behavior with `const __restrict__` and might not need explicit `__ldg`. But adding it doesn't hurt:
```cuda
float4 v = __ldg(&in4[i]);
```
For in-place, we can't use `__ldg` since we're reading from mutable memory. But we can cast to `const float4*`:
```cuda
const float4* in4 = reinterpret_cast<const float4*>(data);
float4 v = __ldg(&in4[i]);
// modify v...
float4* out4 = reinterpret_cast<float4*>(data);
out4[i] = v;
```
This is a bit verbose but might help slightly.
Actually, on Ampere and Hopper, `__ldg` is often not necessary for read-only data because the compiler automatically uses the load-through-texture path for `const __restrict__` pointers. So the `const __restrict__` qualifier is sufficient.
I'll keep it simple.
One more thought: since `scale` is a uniform constant, it will be stored in a register (`f` or `scale`). The compiler should load it once per thread.
For the float4 operations, each `v.x * scale` is a separate multiplication. The compiler might vectorize these, but since float4 components are independent registers, it probably does 4 separate MUL operations.
Actually, on modern GPUs, a single thread can execute multiple independent FP32 operations per clock. For Hopper, each SM has high throughput for independent FP32 ops. So doing 4 separate MULs followed by 4 ADDs should be well pipelined and memory-bandwidth-limited.
One concern about register pressure: 4 multiplications and 4 additions plus the float4 load/store. The compiler should optimize register usage well. Probably around 20-30 registers, which is well within the 255 limit and allows high occupancy.
Another thought: can we use `half2` or `half4` for vectorization? No, the data is fp32.
What about cooperative groups or other advanced features? Not needed for this simple bandwidth-limited kernel.
I'm confident the solution is good. Let me write the final code and compile it.
Oh wait, I should also think about the `bias` parameter. In `at::linear`, bias defaults to None. But `nn.Linear` always has a bias parameter. If bias is None, `at::linear` should handle it. But wait - in the reference, `self.matmul = nn.Linear(...)`. If for some reason `bias=False` is passed... actually, the reference doesn't pass `bias=False`. The `Model.__init__` has no such parameter. So `nn.Linear` uses the default `bias=True`.
Our `ModelNew` also creates `self.matmul = nn.Linear(...)`, so bias is present.
But in our C++ wrapper, we pass `self.matmul.bias` directly. If someone manually set `bias=False`, `self.matmul.bias` would be None. Our C++ wrapper receives a Tensor, and passing None might cause issues.
For safety, in Python we can check:
```python
if self.matmul.bias is None:
return _extension.forward_cuda_no_bias(...)
```
But this adds complexity. Since the reference always has bias, this case won't be tested. I'll keep it simple.
One more idea: the C++ wrapper signature expects `torch::Tensor bias`. If we pass a Python `None`, pybind11 will fail because it can't convert None to torch::Tensor. To handle this safely:
```python
def forward(self, x):
if not x.is_cuda or x.dtype != torch.float32:
x = self.matmul(x)
return x * self.scaling_factor + x
bias = self.matmul.bias if self.matmul.bias is not None else torch.zeros(0, device=x.device, dtype=x.dtype)
return _extension.forward_cuda(x, self.matmul.weight, bias, self.scaling_factor)
```
But this creates an unnecessary tensor for the common case. Alternatively, we can have two C++ functions or use optional arguments. But again, for simplicity, since bias is always present, I'll just pass it directly.
Actually, the simplest safeguard is:
```python
bias = self.matmul.bias
if bias is None:
bias = torch.zeros(self.matmul.out_features, device=x.device, dtype=x.dtype)
return _extension.forward_cuda(x, self.matmul.weight, bias, self.scaling_factor)
```
This is cheap and handles the edge case. Let me include it.
But wait, the C++ code does `at::linear(x, weight, bias)`. If bias is a zero tensor of the correct shape, the result is the same as no bias. This works.
OK, let me write the final complete code:
```python
import torch
import torch.nn as nn
from torch.utils.cpp_extension import load_inline
cuda_source = r"""
#include <torch/extension.h>
#include <cuda.h>
#include <cuda_runtime.h>
__global__ void fused_residual_kernel(float* __restrict__ data, float scale, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int n4 = n >> 2;
float4* data4 = reinterpret_cast<float4*>(data);
for (int i = idx; i < n4; i += blockDim.x * gridDim.x) {
float4 v = data4[i];
float t = v.x * scale;
v.x = t + v.x;
t = v.y * scale;
v.y = t + v.y;
t = v.z * scale;
v.z = t + v.z;
t = v.w * scale;
v.w = t + v.w;
data4[i] = v;
}
int base = n4 << 2;
for (int i = base + idx; i < n; i += blockDim.x * gridDim.x) {
float val = data[i];
data[i] = val * scale + val;
}
}
torch::Tensor forward_cuda(torch::Tensor x, torch::Tensor weight, torch::Tensor bias, float scale) {
auto y = at::linear(x, weight, bias);
int n = y.numel();
if (n > 0) {
int threads = 256;
int n4 = n >> 2;
int blocks = (n4 + threads - 1) / threads;
if (blocks < 1) blocks = 1;
fused_residual_kernel<<<blocks, threads>>>(
y.data_ptr<float>(),
scale,
n
);
}
return y;
}
"""
_extension = load_inline(
name="fused_residual",
cpp_sources="",
cuda_sources=cuda_source,
functions=["forward_cuda"],
extra_cuda_cflags=["-O3", "-fmad=false"],
verbose=False,
)
class ModelNew(nn.Module):
def __init__(self, in_features, out_features, scaling_factor):
super().__init__()
self.matmul = nn.Linear(in_features, out_features)
self.scaling_factor = scaling_factor
def forward(self, x):
if not x.is_cuda or x.dtype != torch.float32:
x = self.matmul(x)
return x * self.scaling_factor + x
bias = self.matmul.bias
if bias is None:
bias = torch.zeros(self.matmul.out_features, device=x.device, dtype=x.dtype)
return _extension.forward_cuda(
x, self.matmul.weight, bias, self.scaling_factor
)
```
Wait, I just realized: in the C++ code, I'm using `int n = y.numel();` but `y.numel()` returns `int64_t`. Converting to `int` might truncate for huge tensors. For our case it's fine, but let me cast properly:
```cpp
int n = static_cast<int>(y.numel());
```
This is fine for up to ~2 billion elements.
Another issue: the compiler might warn about the narrow conversion. But it's OK.
Actually, looking at the code again, `v.x = t + v.x;` uses `v.x` after it was modified? No, `t = v.x * scale` computes using the original `v.x`, then `v.x = t + v.x` adds the original `v.x` to `t`. This is correct.
Wait, I need to be careful about C++ order of evaluation. `v.x = t + v.x;` The right-hand side `t + v.x` uses the current value of `v.x`, which hasn't been modified yet (assignment evaluates the RHS first, then assigns). So `t + v.x` = `(v.x * scale) + v.x`, using the original `v.x`. Correct.
For the scalar tail loop: `data[i] = val * scale + val;` - here `val = data[i]`, so this computes `data[i] = data[i] * scale + data[i]`. The RHS uses the original value because `val` was read before the assignment. Correct.
One concern: the compiler might optimize `val * scale + val` to `val * (scale + 1)` if it thinks it's safe. With `-fmad=false` and no `--use_fast_math`, and with the calculations written as `t = val * scale; result = t + val`, it's harder for the compiler to reassociate. But for the float4 code, `v.x = t + v.x;` still makes it clear that `v.x` is used on both sides. The compiler should preserve the separate operations.
But wait, for the float4 code:
```cpp
float t = v.x * scale;
v.x = t + v.x;
```
The compiler can clearly see that `v.x` is used after computing `t`, so it must store `t` in a register first, then add `v.x`. This prevents reassociation to `v.x * (scale + 1)`.
Actually, even `t = v.x * scale; v.x = t + v.x;` could theoretically be reassociated by an aggressive compiler if it can prove the result is the same. But with IEEE754 strictness and `-fmad=false`, it shouldn't happen.
For absolute safety, we could use inline assembly for the critical multiply-add sequence. But that would hurt performance due to the compiler not being able to schedule instructions as well.
Let me try without inline assembly first. If correctness fails, I'll add asm.
Actually, I think for tolerance 1e-4, even if FMA is used, the difference between `fma(a, scale, a)` and `round(round(a * scale) + a)` is at most 1 ULP, which for fp32 at typical magnitudes is much smaller than 1e-4. So numerical tolerance should be fine even with FMA.
But to be pedantic, let me keep `-fmad=false`.
Now, let me compile and test.