reasoning
We need to write a custom CUDA kernel to replace PyTorch model forward. Let's analyze the computation.
Model forward:
- hidden shape: (batch_size, seq_len, model_dim)
- w_down shape: (model_dim, rank)
- latent = hidden.reshape(B*S, M) @ w_down -> shape (B*S, rank)
- k = latent @ w_up_k -> shape (B*S, kv_heads * head_dim)
- v = latent @ w_up_v -> shape (B*S, kv_heads * head_dim)
- k = k.view(B, S, kv_heads, head_dim)
- v = v.view(B, S, kv_heads, head_dim)
- return torch.stack((k,v), dim=2) -> shape (B, S, 2, kv_heads, head_dim)
So overall it's essentially two matrix multiplications: (B*S, M) @ (M, rank) @ (rank, H) for k and v. This can be fused into a single kernel that computes for each (b,s) index:
latent = hidden[b,s,:] @ w_down (size rank)
k[b,s,:] = latent @ w_up_k (size kv_heads*head_dim)
v[b,s,:] = latent @ w_up_v (size kv_heads*head_dim)
And then writes them into output tensor of shape (B, S, 2, kv_heads, head_dim).
Since model_dim=256, rank=48, kv_heads=4, head_dim=32, total output per sample = 2*4*32=256. Hidden per sample = 256. So it's small dimensions. B*S = 4*96 = 384. So fairly small workload, but we can still try to write an efficient fused kernel.
The naive PyTorch does two gemms sequentially with intermediate latent of size 384*48. The custom kernel can fuse them, reducing memory traffic.
We need to write a CUDA kernel using load_inline. The kernel should support arbitrary shapes? The constructor accepts same as Model (no args). We can get shapes from tensors in forward.
Goal: run faster than PyTorch reference. Since workload is small, fusing and using shared memory could help. But we must be careful with occupancy.
Let's think about the kernel design.
Option 1: Use a simple CUDA kernel where each thread handles one element of the output. Compute the latent contribution on the fly. For each output element k[b,s,h,d] = sum_{r} (sum_{m} hidden[b,s,m] * w_down[m,r]) * w_up_k[r,h,d]. This is a double sum. Since M=256, rank=48, doing per-element would mean each thread computes M*rank multiplications = ~12288 FMAs. With 384*256 output elements = 98304 threads, total FLOPs ~1.2B, which is okay but maybe heavy per thread. But each thread would need to read all of hidden row and w_down columns and w_up row. That's a lot of redundant reads: each hidden row read by all output threads in that (b,s). That's 256 * 256 * 384 = 25M reads of hidden, vs original 384*256 reads. Not good.
Option 2: Each block handles one (b,s) sample. Threads within block cooperatively compute latent and then k/v. Since rank=48 is small, we can compute latent in shared memory: load hidden row into shared mem (or registers), compute latent vector of size 48, store in shared mem. Then compute k and v from latent and w_up_k/v. For k/v size = 128 each. So per block work: M*rank + rank*H*2 = 256*48 + 48*128*2 = 12288 + 12288 = 24576 FMAs. With 384 blocks, total FLOPs ~9.4M? Wait: original PyTorch FLOPs: hidden@w_down: B*S*M*rank = 384*256*48 = 4,718,592. latent@w_up_k: 384*48*128 = 2,359,296. Same for v: 2,359,296. Total = 9,437,184. So ~9.4M FMAs. That's tiny. So memory bandwidth bound mostly.
Fusing reduces memory traffic: read hidden once, write output once. In PyTorch, we read hidden (384*256), write latent (384*48), read latent (384*48), write k (384*128), read latent again (384*48), write v (384*128). Total memory moved: hidden read ~393K floats, latent write+read ~2*18K, k write ~49K, v write ~49K. Total ~0.5M floats = 2MB. That's tiny; memory bandwidth is huge. The overhead of kernel launches and Python/ATen might dominate.
But the custom kernel is called via torch extension from Python, which also has overhead. However, PyTorch does two matmuls, each may launch a cuBLAS kernel (or CUTLASS). Two kernel launches vs one custom kernel. Plus PyTorch may allocate intermediate tensor (latent). So fused kernel should be faster.
Also we can use `torch.mm` for combined? We could do something like `torch.matmul(hidden.view(-1, model_dim), torch.cat([w_down @ w_up_k, w_down @ w_up_v], dim=1))` but that precomputes a (model_dim, 2*kv_heads*head_dim) matrix. Let's see: w_down (256,48) @ w_up_k (48,128) = (256,128). Same for v. Concatenate to (256,256). Then hidden (384,256) @ combined (256,256) = (384,256). Then reshape and stack. This is one larger matmul: 384*256*256 = 25,165,824 FMAs, about 2.7x more compute but only one cuBLAS kernel and no intermediate allocation. Since M and rank are small, cuBLAS is highly optimized, but the extra compute might make it slower? Let's compute: original total FLOPs ~9.4M. Precombined FLOPs ~25M. So 2.7x more compute. But memory reads: read hidden (384*256), read weight (256*256), write output (384*256). That's larger weight read (256*256 vs 256*48 + 48*128*2). Weight size is 65K floats vs original total weight reads: w_down 12K + w_up_k 6K + w_up_v 6K = 24K. So precombined reads more weight memory but still tiny. However, a single cuBLAS gemm of size 384x256x256 might be very fast, possibly faster than two smaller gemms of 384x256x48 and 384x48x128. Because small inner dimensions might underutilize GPU.
Let's think about typical GEMM performance. For M=384, N=48, K=256: this is a skinny matrix multiply. cuBLAS might not be great. For M=384, N=128, K=48: also small. For M=384, N=256, K=256: still small M but larger N and K. On a GPU, a single gemm of size 384x256x256 could be okay but still very small. However, cuBLAS kernel launch overhead is larger than execution time for such small matrices. So fewer kernel launches is better.
But the prompt says "replace the forward computation with a custom CUDA kernel". We could precompute the combined weight in the forward pass on CPU (or even in __init__ if weights were parameters, but they are inputs). Actually w_down, w_up_k, w_up_v are inputs to forward, not parameters. So we can't precompute them in __init__. But we can fuse on the fly.
Alternatively, we could precompute inside the CUDA kernel: each block computes the effective weight for one output head? But that's just doing GEMM manually.
Given the small sizes, a fused custom kernel that does the two-stage multiply per sample is likely best. We need to minimize overhead.
We can also consider using shared memory. Each block handles one (b,s). Since B*S = 384, we need 384 blocks. On an A100 (108 SMs) or similar, that's enough to saturate SMs. Each block can have e.g., 128 threads. We need to be careful about memory coalescing when writing output.
Let's design the kernel.
Forward formulas:
For each sample i = b*S + s:
hidden[i] is vector of length M.
latent[j] = sum_{m=0}^{M-1} hidden[i][m] * w_down[m][j] for j=0..R-1.
k[i][h] = sum_{j=0}^{R-1} latent[j] * w_up_k[j][h] for h=0..H-1.
v[i][h] = sum_{j=0}^{R-1} latent[j] * w_up_v[j][h] for h=0..H-1.
Where H = kv_heads * head_dim.
Output o[i][0][h] = k[i][h], o[i][1][h] = v[i][h].
Reshape h -> (kv_heads, head_dim).
We can treat k and v as two groups. So output shape per sample: (2, H). We can write output as [B, S, 2, kv_heads, head_dim].
Kernel:
- Use one block per sample (i = blockIdx.x). Total blocks = B*S.
- In each block, threads cooperate to compute latent[0..R-1] in shared memory.
- Then threads compute output elements.
Because R=48 is small, and H=128 is small. A block of 128 threads can compute H elements for k and v. Each thread can compute one or more output elements.
Let's think about how to assign threads.
Option A: blockDim.x = 128 threads. Each thread computes 1 element of k and 1 element of v? That would be 2 elements per thread. Total elements per sample = 2*128 = 256. With 128 threads, each thread handles 2 elements. But the mapping to latent is the same for both k and v: each output element h requires sum over j of latent[j] * w_up[j][h]. So a thread could compute k_h and v_h for the same h. That means each thread does 2 dot products of length R with the same latent vector. This is efficient because latent can be kept in registers or shared memory.
Option B: blockDim.x = 256 threads, each handles one element. But 256 threads might be okay, but then we have only 384 blocks, total threads = 384*256 = 98304. Occupancy might be fine. However, we need to compute latent first. We can compute latent using a parallel reduction.
Given R=48, maybe we can compute latent with just a few threads and keep it in shared memory, then let all threads read it.
Let's detail Option A.
Block size = 128 threads.
Shared memory:
- latent_shared[48] (float)
- Optionally hidden_shared[256] to load hidden row.
But we can also load hidden row directly from global memory into registers per thread for the reduction. Since hidden row is contiguous (M floats), and each block reads the same hidden row, caching in L2 might help, but explicit shared memory is better.
Compute latent:
Each thread t (0..127) can sum over m = t, t+128, ...? But M=256, so each thread handles 2 elements. But we need to accumulate over M to produce R outputs. This is a matrix-vector multiplication: w_down^T (M x R) times hidden (M). So we can have threads compute partial sums for each j.
Better: use shared memory for latent. Each thread computes a partial sum for a subset of j? Or each thread computes a partial sum for a subset of m, and then we reduce.
Actually, computing latent = hidden @ w_down:
hidden is length M, w_down is M x R. So latent_j = sum_m hidden_m * w_down_{m,j}.
With 128 threads, we can have each thread compute a chunk of columns j. For example, thread t computes latent_j for j = t, t+128? But R=48 < 128. So many threads idle. Alternatively, each thread handles one j, but then only 48 threads active. That's okay but less parallelism.
But M=256 is also small. Maybe just have thread 0 compute the entire latent vector? 48*256 = 12K multiplies. At ~20 TFLOPS, that's 0.6 microseconds. But memory bandwidth: reading hidden (256 floats) and w_down (256*48 floats) from global memory. w_down is 12K floats = 48KB. Shared among all blocks? Each block reads its own w_down. That's 384*48KB = 18MB of w_down reads. That's a lot. But w_down is broadcast to all blocks? Actually each block reads the same w_down matrix. It will be cached in L2. So it might be okay.
Wait, but if each block reads w_down (48KB) from global memory, and there are 384 blocks, that's 18MB read from L2. L2 bandwidth is huge, but still. Similarly, hidden is 256 floats = 1KB per block, total 384KB. So total memory reads: hidden ~384KB, w_down ~18MB (but cached), w_up_k ~6KB*384 = 2.3MB, w_up_v ~2.3MB. Total ~25MB. At 1-2 TB/s, that's ~0.01-0.02 ms. So memory bound but still very fast.
However, to make it faster, we might want to keep w_down and w_up in shared memory or registers. But w_down is 48KB, w_up_k is 6KB, w_up_v is 6KB. Total ~60KB per block. Shared memory per SM is typically 164KB or more. So we could load w_down, w_up_k, w_up_v into shared memory per block. But there are many blocks per SM (e.g., 4), so 60KB * 4 = 240KB, which might exceed shared memory. But we could just load them into shared memory and use 32KB per block, which is fine. Actually 60KB > 48KB default. Some GPUs have 48KB max shared memory per block unless configured. So we might not fit all.
Alternative: don't load w_down into shared memory; load hidden into shared memory, compute latent with w_down from global memory (cached). Or use registers: each thread holds a portion of w_down? Not easy.
Since sizes are small, a simple kernel where each thread computes its own output elements directly by reading hidden and weights from global memory might be sufficient. Let's evaluate: each thread computes 2 output elements (one k, one v). For each output element, it needs to compute the double dot product:
out = sum_{j=0}^{R-1} (sum_{m=0}^{M-1} hidden_m * w_down_{m,j}) * w_up_{j,h}
If each thread expands this, it needs to read hidden (M), w_down (M*R), w_up_k (R) and w_up_v (R). For 128 threads per block, each block reads hidden once (coalesced), w_down M*R (coalesced? w_down is stored M x R, each thread reads column j for all m, so threads access different columns; memory access to w_down is coalesced if threads read consecutive j? Let's think: w_down[m][j] is stored linearly as m*R + j. If thread h reads w_down[m][h] for a fixed m, then threads read consecutive memory addresses for h=0..127 (but R=48). So for w_up, thread h reads w_up[h] which is coalesced. For w_down, if each thread loops over m and reads w_down[m*R + j], where j is the output channel assigned to the thread, then for a given m, threads read addresses m*R + 0, m*R + 1, ..., m*R + 127 (but only 48 valid). This is coalesced. Good. So each thread can compute its output independently with coalesced reads.
The double loop per thread: for m in 0..M-1: hidden_m * w_down[m*R + j] accumulate into temp[j]? Wait, each thread handles only 1 or 2 j's. Actually each output element corresponds to a specific h (which maps to j index in w_up). But w_up is R x H. So for a given h, we need to sum over j: latent_j * w_up_k[j*H + h]. And latent_j = sum_m hidden_m * w_down[m*R + j].
So for thread assigned to h, we can compute:
sum_m hidden_m * w_down[m*R + j] for each j? No, we need all j to compute the sum over j. So each thread would need to compute the entire latent vector (size R) to compute one output element. That's bad. Unless we precompute latent in shared memory.
So we need to precompute latent in shared memory, or distribute the work.
Given that R=48, each thread computing the entire latent vector is 48*256 = 12K multiplies per thread. With 128 threads per block, that's 1.5M multiplies per block, but each thread does 12K, which is okay but redundant: the same latent computed 128 times per block. But each block would read hidden 128 times, w_down 128 times. That's a lot of redundant reads. However, hidden and w_down might be cached. Still, it's wasteful.
Better: compute latent once per block and store in shared memory.
Let's design the kernel with shared memory for latent and hidden.
Block size: 128 threads.
Shared memory layout:
- hidden_shared[256] (float)
- latent_shared[48] (float)
Total shared mem = (256+48)*4 = 1216 bytes. Very small.
Procedure:
1. Load hidden row into hidden_shared. Since M=256 and blockDim=128, each thread loads 2 floats. Coalesced.
2. __syncthreads().
3. Compute latent_shared[j] for j=0..R-1.
- Since R=48 < 128, we can have 48 threads each compute one j. Thread j (0..47) computes:
sum = 0;
for m = 0; m < M; ++m) sum += hidden_shared[m] * w_down[m*R + j];
latent_shared[j] = sum;
- Threads 48..127 do nothing.
- __syncthreads().
4. Compute output k and v.
- We need to write 2*H = 256 floats per block. With 128 threads, each thread writes 2 floats: one k_h and one v_h.
- Thread t (0..127) computes:
h = t;
sum_k = 0; sum_v = 0;
for j = 0; j < R; ++j) {
float l = latent_shared[j];
sum_k += l * w_up_k[j*H + h];
sum_v += l * w_up_v[j*H + h];
}
write to output.
- Wait, H=128, so t from 0..127 maps directly to h. Perfect.
5. Write output.
- Output shape: (B, S, 2, kv_heads, head_dim). We need to write to proper indices.
- Let sample_idx = blockIdx.x = b*S + s.
- k address: output[sample_idx, 0, h] = sum_k.
- v address: output[sample_idx, 1, h] = sum_v.
- Since h=0..127. But note that output is (B, S, 2, kv_heads, head_dim). So stride = ?. We can compute pointer as:
base = output + sample_idx * 2 * kv_heads * head_dim + 0 * kv_heads * head_dim + h for k.
Actually we can view output as [B, S, 2, H] where H=kv_heads*head_dim. So offset = ((sample_idx * 2 + 0) * H + h) for k.
and ((sample_idx * 2 + 1) * H + h) for v.
- This is coalesced if threads write consecutive h. Yes, thread t writes h=t, so k writes are coalesced, and v writes are coalesced (if we write v after k or interleaved? We can write them separately; both are coalesced).
This seems like a solid design.
Potential issues:
- Step 3 uses only 48 threads out of 128. That's okay; the rest idle. We could also compute latent with all threads by having each thread compute partial sums over m and then reduce. But M=256, blockDim=128. Each thread could handle 2 m's: thread t computes partial sum for m=t and m=t+128. But then we need to reduce across threads for each j. That would require a reduction over 128 threads for each j, which is expensive. Since R=48 and M=256, having 48 threads each do a dot product of length 256 is fine. Each thread does 256 FMAs. With 48 active threads, that's 48*256 = 12K FMAs per block. At peak, that's negligible. But the warps will be partially active: threads 0..47 are in warps 0 and 1 (32 threads per warp). So warp 0 has 32 active threads, warp 1 has 16 active threads, warps 2 and 3 are fully inactive. This means half the threads are idle. Occupancy might be slightly lower, but each block uses very few registers and shared memory, so many blocks can run concurrently.
We can improve by having 128 threads compute latent in parallel for multiple j's? Actually we can let each thread compute latent_j for j = t, where t goes 0..127. But R=48, so we need to wrap: e.g., thread t computes j = t % R? But then each j is computed by multiple threads with different subsets of m, and we need atomic adds or a reduction. Not worth it.
Alternative: have blockDim.x = 64. Then 48 active threads, 16 idle. Two warps: warp 0 fully active (32), warp 1 partially active (16). Still okay. But then step 4: 64 threads, each handles 2 k elements and 2 v elements (since H=128). Or each thread handles 2 output positions. That might be fine. But block size 128 is standard.
What about using vectorized loads for hidden? M=256, blockDim=128, each thread loads 2 floats. We can load float2.
Also, w_down is M x R, stored in column-major? In PyTorch, tensors are row-major (last dimension contiguous). w_down shape (M, R) with M=256, R=48. So w_down[m, r] is contiguous along r. That means w_down[m*R + r]. In step 3, each thread j reads w_down[m*R + j] for all m. For fixed m, consecutive j read consecutive memory: that's coalesced. Good.
In step 4, w_up_k shape (R, H). w_up_k[j, h] is contiguous along h. So w_up_k[j*H + h]. For fixed j, consecutive h read consecutive memory. Thread h reads w_up_k[j*H + h]. So for a fixed j, all threads read consecutive addresses. That's coalesced. Great.
So memory access patterns are good.
Let's consider the launch configuration:
blocks = batch_size * seq_len = 384.
threads = 128.
Shared memory: hidden_shared[256] + latent_shared[48] = 1216 bytes. Plus maybe padding. Very small.
Register usage: each thread holds a float sum_k, sum_v, plus loop variables. Probably < 32 registers. So occupancy will be high.
One more thing: the output shape is (B, S, 2, kv_heads, head_dim). We can create an output tensor of shape (B, S, 2, kv_heads, head_dim) using torch.empty. Or we can view as (B*S, 2, H) for the kernel.
In the kernel, we can treat output as a flat pointer of size B*S*2*H.
Let's write the CUDA kernel code.
We need a host wrapper function that takes the tensors and returns the output.
The wrapper can be something like:
void mla_fused_forward(
const torch::Tensor& hidden,
const torch::Tensor& w_down,
const torch::Tensor& w_up_k,
const torch::Tensor& w_up_v,
torch::Tensor& output) {
int B = hidden.size(0);
int S = hidden.size(1);
int M = hidden.size(2);
int R = w_down.size(1);
int H = w_up_k.size(1);
int num_samples = B * S;
const float* hidden_ptr = hidden.data_ptr<float>();
const float* w_down_ptr = w_down.data_ptr<float>();
const float* w_up_k_ptr = w_up_k.data_ptr<float>();
const float* w_up_v_ptr = w_up_v.data_ptr<float>();
float* out_ptr = output.data_ptr<float>();
mla_fused_kernel<<<num_samples, 128>>>(
hidden_ptr, w_down_ptr, w_up_k_ptr, w_up_v_ptr, out_ptr,
B, S, M, R, H);
}
Wait, we need to include proper includes and use ATen.
We need to write cpp_sources containing the kernel and the wrapper.
Let's draft the kernel:
__global__ void mla_fused_kernel(
const float* __restrict__ hidden,
const float* __restrict__ w_down,
const float* __restrict__ w_up_k,
const float* __restrict__ w_up_v,
float* __restrict__ output,
int B, int S, int M, int R, int H) {
// blockIdx.x corresponds to sample index = b*S + s
int sample_idx = blockIdx.x;
int total_samples = B * S;
if (sample_idx >= total_samples) return;
int b = sample_idx / S;
int s = sample_idx % S;
// hidden offset: (b, s, :) -> sample_idx * M
const float* h_ptr = hidden + sample_idx * M;
// Shared memory
extern __shared__ float sbuf[];
float* h_shared = sbuf; // size M
float* latent_shared = sbuf + M; // size R
// Load hidden into shared memory
for (int i = threadIdx.x; i < M; i += blockDim.x) {
h_shared[i] = h_ptr[i];
}
__syncthreads();
// Compute latent = hidden @ w_down
// Only first R threads participate
if (threadIdx.x < R) {
int j = threadIdx.x;
float sum = 0.0f;
// Unroll? M=256. We can partially unroll.
for (int m = 0; m < M; ++m) {
sum += h_shared[m] * w_down[m * R + j];
}
latent_shared[j] = sum;
}
__syncthreads();
// Compute k and v for this sample
int h = threadIdx.x;
if (h < H) {
float sum_k = 0.0f;
float sum_v = 0.0f;
for (int j = 0; j < R; ++j) {
float l = latent_shared[j];
sum_k += l * w_up_k[j * H + h];
sum_v += l * w_up_v[j * H + h];
}
int base = sample_idx * 2 * H;
output[base + h] = sum_k; // k at slot 0
output[base + H + h] = sum_v; // v at slot 1
}
}
Wait, the output shape is (B, S, 2, kv_heads, head_dim). If we allocate output with that shape, the strides are not necessarily sample_idx * 2 * H. Because the last three dimensions are contiguous? In PyTorch, if we allocate torch.empty(B, S, 2, kv_heads, head_dim), the memory layout is row-major, so offset = ((b * S + s) * 2 + slot) * kv_heads * head_dim + rest. Since kv_heads*head_dim = H, and the inner dims are contiguous, yes, the offset formula is:
offset = ((sample_idx * 2) + slot) * H + h.
Because dimension order: B, S, 2, kv_heads, head_dim. Contiguous means stride of S is 2*H, stride of 2 is H, stride of kv_heads is head_dim.
So sample_idx * (2*H) + slot * H + h. So base = sample_idx * 2 * H. That works.
But we should compute the strides properly to be safe. However, since we pass a freshly allocated contiguous tensor from Python, we can rely on it. But better to compute using strides: output.stride(0) etc. Or just allocate a flat tensor and reshape.
Simpler: In Python forward, allocate output = torch.empty(batch_size, seq_len, 2, kv_heads, head_dim, dtype=torch.float32, device=hidden.device). Then pass output.data_ptr() to kernel. Since it's contiguous, the formula holds.
Alternatively, we can compute int out_stride_B = output.stride(0); int out_stride_S = output.stride(1); etc. But contiguous allocation is simpler.
Wait, batch_size and seq_len may vary per call. We need to get them from tensor shapes.
In the kernel, we can compute sample_idx = blockIdx.x. total_samples = B*S. The hidden stride along batch and seq: hidden is (B, S, M). hidden_stride_B = hidden.stride(0); hidden_stride_S = hidden.stride(1). But if contiguous, hidden[sample_idx * M] works. However, to be robust, we should compute pointer = hidden + b * hidden_stride(0) + s * hidden_stride(1). But since we know it's contiguous in the test, we can simplify. Still, better to compute using strides.
Let's compute in kernel:
int b = sample_idx / S;
int s = sample_idx % S;
const float* h_ptr = hidden + b * hidden_stride_B + s * hidden_stride_S;
where hidden_stride_B and hidden_stride_S are passed.
But passing many scalars is fine.
However, note that output is (B, S, 2, kv_heads, head_dim). Its stride for B is S*2*H, for S is 2*H. So offset = b * out_stride_B + s * out_stride_S + slot * H + h.
But if we allocate contiguously, we can compute base = sample_idx * 2 * H.
Let's just do robust strides.
We need to get strides in C++.
In wrapper:
auto hidden_stride = hidden.stride(0); // hidden is (B,S,M)
But hidden.stride(0) is a long. We can pass as int64_t.
Actually, using .stride() returns int64_t. We can cast to int.
But for simplicity, we can assume contiguous. Since inputs come from torch.randn, they are contiguous. But if user does slicing, maybe not. The reference does reshape, which assumes contiguous? Actually reshape can handle non-contiguous in some cases but returns view. But the test likely uses contiguous. Let's be safe and pass strides.
Actually, we can just use `hidden.data_ptr<float>()` and compute offset = sample_idx * M because `hidden.reshape(B*S, M)` in reference implies that hidden is viewed as contiguous in the first two dims. But if hidden is non-contiguous, reshape may fail or copy. However, hidden is created by torch.randn, so contiguous. So we can assume hidden is contiguous with stride (S*M, M, 1). Similarly for w_down and w_up.
So in kernel, we can just use sample_idx * M for hidden. For w_down, we can use `m * R + j`. For w_up_k, `j * H + h`. For output, `sample_idx * 2 * H + slot * H + h`.
This is fine.
One optimization: use `__restrict__` and `const`. The compiler can optimize.
Another: unroll loops partially. M=256, R=48, H=128. We can add `#pragma unroll` but only for loops with compile-time constants. We can pass them as template parameters? But shapes are dynamic. However, we can add `#pragma unroll 4` or something. But not necessary for correctness.
Could we use half-precision? No, reference is fp32 and we need fp32 output.
Could we transpose w_down to be (R, M) to improve memory coalescing in step 3? In step 3, thread j reads w_down[m*R + j] for varying m. This jumps by R each time. If R=48, that's a stride of 48 floats = 192 bytes. That's not terrible; it hits the same cache line for successive m? Actually, if we read w_down[0*R + j], w_down[1*R + j], w_down[2*R + j], etc., these are separated by 192 bytes. A cache line is 128 bytes. So each access might hit a new cache line, but they are contiguous in memory for a given j across m? No, w_down is stored row-major: row m has elements (m*R) to ((m+1)*R - 1). So w_down[m*R + j] for fixed j and varying m are separated by R elements. So memory access is strided by R. This is not coalesced when multiple threads read the same m but different j (which is coalesced). But within a thread, reading across m is strided. However, the thread is reading sequentially across rows. Since R=48 floats = 192 bytes, each row is 192 bytes. So reading row 0 j, row 1 j, etc., is strided by 192 bytes. That's bigger than a cache line (128 bytes). So each read might be a separate cache line. But 256 rows * 192 bytes = 48KB per w_down read per thread. That's the entire w_down matrix. It will be loaded into L1/L2. With 48 threads, total reads = 48 * 48KB = 2.3MB per block? Actually each thread reads its own column, 256*4=1024 bytes, but strided across the matrix. The entire matrix is 48KB. Since threads read different columns, the combined accesses of all 48 threads cover the entire matrix. The GPU will coalesce and cache. So w_down will be read once per block (via L1). Good.
Similarly, in step 4, each thread h reads w_up_k[j*H + h] for j=0..R-1. For fixed h, varying j, this is strided by H=128 floats = 512 bytes. So reading down a column of w_up_k. w_up_k is 48*128 = 6144 floats = 24KB. Strided by 512 bytes means each access is many cache lines apart. But again, across threads, the accesses for a given j and varying h are coalesced (reading a row of w_up_k). In step 4, for each j, all 128 threads read w_up_k[j*H + h] simultaneously. That means the entire row j is read in a coalesced manner. So the matrix w_up_k is read row by row, which is very efficient. Same for w_up_v.
So memory access is good.
Now, what about step 3: for each m, thread j reads w_down[m*R + j]. For a fixed m, threads read all elements of row m. Again, coalesced! Great. So step 3 reads w_down row by row. Perfect.
Thus the kernel has very good memory patterns.
One more optimization: we can eliminate `__syncthreads()` after loading hidden if we don't need it? We need hidden_shared for all threads before computing latent. But only threads 0..R-1 need it. However, to compute latent, threads 0..R-1 read h_shared[m] for all m. Since threads 0..127 loaded it, and we have `__syncthreads()`, it's safe.
Potential issue: `__syncthreads()` inside conditional code (if threadIdx.x < R) can deadlock if not all threads reach it. In our code, `__syncthreads()` is placed outside the conditional. So all threads call it.
Specifically:
- load hidden: all threads participate (loop).
- __syncthreads()
- compute latent: only threads < R participate.
- __syncthreads() // all threads must reach. Since it's outside if, all threads reach.
- compute output: threads < H participate.
- No further sync needed.
That's safe.
Now, one more thing: `extern __shared__ float sbuf[];` requires passing shared memory size at launch: `mla_fused_kernel<<<num_samples, 128, (M+R)*sizeof(float)>>>(...)`. We need to pass M and R as ints. M is dynamic (model_dim), but in this test it's 256. However, to support arbitrary sizes, we should allocate shared memory dynamically.
In the wrapper, we compute shared_mem_bytes = (model_dim + rank) * sizeof(float).
But what if model_dim + rank exceeds 48KB? The test uses 256+48=304, which is tiny.
Let's write the wrapper:
torch::Tensor mla_fused_forward(
const torch::Tensor& hidden,
const torch::Tensor& w_down,
const torch::Tensor& w_up_k,
const torch::Tensor& w_up_v) {
int B = hidden.size(0);
int S = hidden.size(1);
int M = hidden.size(2);
int R = w_down.size(1);
int H = w_up_k.size(1);
auto output = torch::empty({B, S, 2, H}, hidden.options()); // shape (B,S,2,H)
// Actually we need (B,S,2,kv_heads,head_dim). But H = kv_heads*head_dim.
// In Python we can reshape.
// Or allocate directly: {B,S,2,w_up_k.size(1)/kv_heads?} We don't know kv_heads in C++ unless passed.
// Simpler: allocate flat and reshape in Python.
// But we know w_up_k.size(1) = H.
// The output shape is (B,S,2,kv_heads,head_dim). The kernel just writes (B,S,2,H).
// So we can allocate {B,S,2,H} and return. In Python, we view it as (B,S,2,kv_heads,head_dim).
// Actually we can also just allocate the correct shape and the kernel writes with the contiguous assumption.
int num_samples = B * S;
int threads = 128;
int shared_mem = (M + R) * sizeof(float);
mla_fused_kernel<<<num_samples, threads, shared_mem>>>(
hidden.data_ptr<float>(),
w_down.data_ptr<float>(),
w_up_k.data_ptr<float>(),
w_up_v.data_ptr<float>(),
output.data_ptr<float>(),
B, S, M, R, H
);
return output;
}
But we need to handle the output shape properly. The reference returns a tensor of shape (B, S, 2, kv_heads, head_dim). We can allocate `torch::empty({B, S, 2, kv_heads, head_dim}, ...)` but we don't know kv_heads or head_dim in C++ unless we compute them. However, we can compute kv_heads = H / head_dim? We don't have head_dim. But we can just allocate {B, S, 2, H} and in Python reshape it to (B, S, 2, kv_heads, head_dim). Wait, torch.stack with new dimension inserted at dim=2 on two tensors of shape (B,S,kv_heads,head_dim) yields shape (B,S,2,kv_heads,head_dim). If we allocate {B,S,2,H}, where H=kv_heads*head_dim, that's contiguous and has the same total size. We can then `output.view(B, S, 2, kv_heads, head_dim)` in Python.
That is fine.
However, we need to make sure that the output strides for the base offset formula `sample_idx * 2 * H + slot * H + h` are correct. For a tensor of shape (B,S,2,H), strides are (S*2*H, 2*H, H, 1). So base = b*S*2*H + s*2*H = (b*S + s)*2*H. Yes. So the formula is correct.
But if we allocate (B,S,2,kv_heads,head_dim) in C++, we need to know kv_heads and head_dim. We can pass them as arguments. But it's easier to allocate flat in C++ or allocate correct shape in Python and pass to kernel. Let's just allocate in Python and pass output to kernel. Or allocate in C++ as the correct shape if we compute.
Actually, in the Python ModelNew.forward, we have all the dimensions. We can compute:
batch_size, seq_len, model_dim = hidden.shape
rank = w_down.shape[1]
H = w_up_k.shape[1]
kv_heads = 4
head_dim = H // kv_heads
output = torch.empty(batch_size, seq_len, 2, kv_heads, head_dim, dtype=hidden.dtype, device=hidden.device)
Then call the extension function with hidden, w_down, w_up_k, w_up_v, output.
Or we can have the C++ function take also kv_heads and head_dim, allocate inside, and return. Either works.
Simpler: write a wrapper that returns the output of shape (B,S,2,H) and in Python view it. But then we need to know H = w_up_k.shape[1]. That's fine. But if we allocate (B,S,2,H) and then view as (B,S,2,kv_heads,head_dim), does the view maintain the same underlying memory layout? Yes, if H = kv_heads*head_dim. The reference stack yields exactly that contiguous memory layout. So viewing is safe.
Alternatively, we can allocate correct shape and still use the same offset because contiguous. Let's just allocate correct shape in Python and have the kernel fill it. This avoids any shape mismatch issues.
So the wrapper can be:
void mla_fused_forward(
const torch::Tensor& hidden,
const torch::Tensor& w_down,
const torch::Tensor& w_up_k,
const torch::Tensor& w_up_v,
torch::Tensor& output) {
...
}
This is easier; we don't need to return.
Then in Python:
out = torch.empty(B, S, 2, kv_heads, head_dim, dtype=hidden.dtype, device=hidden.device)
mla_cuda.mla_fused_forward(hidden, w_down, w_up_k, w_up_v, out)
return out
Let's write the CUDA code.
We need to be careful about `const float* __restrict__` pointers.
Kernel:
__global__ void mla_fused_kernel(
const float* __restrict__ hidden,
const float* __restrict__ w_down,
const float* __restrict__ w_up_k,
const float* __restrict__ w_up_v,
float* __restrict__ output,
int B,
int S,
int M,
int R,
int H)
{
int sample_idx = blockIdx.x;
int num_samples = B * S;
if (sample_idx >= num_samples) return;
const float* h_ptr = hidden + sample_idx * M;
extern __shared__ float sbuf[];
float* h_shared = sbuf;
float* latent_shared = sbuf + M;
// Cooperative load of hidden row
for (int idx = threadIdx.x; idx < M; idx += blockDim.x) {
h_shared[idx] = h_ptr[idx];
}
__syncthreads();
// Compute latent (length R)
if (threadIdx.x < R) {
int j = threadIdx.x;
float acc = 0.0f;
#pragma unroll 8
for (int m = 0; m < M; ++m) {
acc += h_shared[m] * w_down[m * R + j];
}
latent_shared[j] = acc;
}
__syncthreads();
// Compute k and v
int h = threadIdx.x;
if (h < H) {
float acc_k = 0.0f;
float acc_v = 0.0f;
#pragma unroll 8
for (int j = 0; j < R; ++j) {
float l = latent_shared[j];
acc_k += l * w_up_k[j * H + h];
acc_v += l * w_up_v[j * H + h];
}
int base = sample_idx * 2 * H;
output[base + h] = acc_k;
output[base + H + h] = acc_v;
}
}
Wait, `#pragma unroll` with dynamic bound? The compiler will ignore it if the bound is not constant. It may still unroll partially with a count hint, but it's not guaranteed. It's fine.
Now, wrapper:
void mla_fused_forward(
const torch::Tensor& hidden,
const torch::Tensor& w_down,
const torch::Tensor& w_up_k,
const torch::Tensor& w_up_v,
torch::Tensor& output) {
int B = hidden.size(0);
int S = hidden.size(1);
int M = hidden.size(2);
int R = w_down.size(1);
int H = w_up_k.size(1);
int num_samples = B * S;
int threads = 128;
int shared_mem_size = (M + R) * sizeof(float);
mla_fused_kernel<<<num_samples, threads, shared_mem_size>>>(
hidden.data_ptr<float>(),
w_down.data_ptr<float>(),
w_up_k.data_ptr<float>(),
w_up_v.data_ptr<float>(),
output.data_ptr<float>(),
B, S, M, R, H
);
}
Make sure to include `<torch/extension.h>` and `<cuda_runtime.h>`.
Need to define the function with appropriate name for pybind.
In `load_inline`, we specify `functions=['mla_fused_forward']`.
The C++ source should not include `PYBIND11_MODULE`. `load_inline` generates it.
Let's write the full Python module.
We need to load the extension at module load time.
```python
import torch
from torch.utils.cpp_extension import load_inline
cpp_source = r"""
#include <torch/extension.h>
#include <cuda_runtime.h>
__global__ void mla_fused_kernel(
const float* __restrict__ hidden,
const float* __restrict__ w_down,
const float* __restrict__ w_up_k,
const float* __restrict__ w_up_v,
float* __restrict__ output,
int B,
int S,
int M,
int R,
int H)
{
int sample_idx = blockIdx.x;
int num_samples = B * S;
if (sample_idx >= num_samples) return;
const float* h_ptr = hidden + sample_idx * M;
extern __shared__ float sbuf[];
float* h_shared = sbuf;
float* latent_shared = sbuf + M;
for (int idx = threadIdx.x; idx < M; idx += blockDim.x) {
h_shared[idx] = h_ptr[idx];
}
__syncthreads();
if (threadIdx.x < R) {
int j = threadIdx.x;
float acc = 0.0f;
for (int m = 0; m < M; ++m) {
acc += h_shared[m] * w_down[m * R + j];
}
latent_shared[j] = acc;
}
__syncthreads();
int h = threadIdx.x;
if (h < H) {
float acc_k = 0.0f;
float acc_v = 0.0f;
for (int j = 0; j < R; ++j) {
float l = latent_shared[j];
acc_k += l * w_up_k[j * H + h];
acc_v += l * w_up_v[j * H + h];
}
int base = sample_idx * 2 * H;
output[base + h] = acc_k;
output[base + H + h] = acc_v;
}
}
void mla_fused_forward(
const torch::Tensor& hidden,
const torch::Tensor& w_down,
const torch::Tensor& w_up_k,
const torch::Tensor& w_up_v,
torch::Tensor& output) {
int B = hidden.size(0);
int S = hidden.size(1);
int M = hidden.size(2);
int R = w_down.size(1);
int H = w_up_k.size(1);
int num_samples = B * S;
const int threads = 128;
const int shared_mem = (M + R) * sizeof(float);
mla_fused_kernel<<<num_samples, threads, shared_mem>>>(
hidden.data_ptr<float>(),
w_down.data_ptr<float>(),
w_up_k.data_ptr<float>(),
w_up_v.data_ptr<float>(),
output.data_ptr<float>(),
B, S, M, R, H
);
}
"""
mla_cuda = load_inline(
name='mla_cuda',
cpp_sources=cpp_source,
functions=['mla_fused_forward'],
extra_cuda_cflags=['-O3'],
verbose=False,
)
class ModelNew(torch.nn.Module):
def __init__(self):
super().__init__()
def forward(self, hidden, w_down, w_up_k, w_up_v):
batch_size, seq_len, model_dim = hidden.shape
rank = w_down.shape[1]
kv_heads = 4
head_dim = w_up_k.shape[1] // kv_heads
output = torch.empty(batch_size, seq_len, 2, kv_heads, head_dim,
dtype=hidden.dtype, device=hidden.device)
mla_cuda.mla_fused_forward(hidden, w_down, w_up_k, w_up_v, output)
return output
```
Wait, we need to be careful: The kernel assumes contiguous tensors. The inputs are from torch.randn and contiguous. The output is torch.empty, contiguous.
But what if hidden is not contiguous in the first two dimensions? The offset `sample_idx * M` assumes that the first two dimensions are collapsed contiguously. For a contiguous tensor of shape (B,S,M), `sample_idx * M` is correct because stride of S is M and stride of B is S*M. For a non-contiguous tensor where e.g., hidden is a view with weird strides, this could be wrong. However, the test uses standard inputs.
To be robust, we can calculate the pointer using the actual strides:
```cpp
int64_t hidden_stride_b = hidden.stride(0);
int64_t hidden_stride_s = hidden.stride(1);
const float* h_ptr = hidden + b * hidden_stride_b + s * hidden_stride_s;
```
And similarly for output. But output stride for sample is `output.stride(0)*b + output.stride(1)*s`. Since output is (B,S,2,kv_heads,head_dim), if contiguous, output.stride(1) = 2*kv_heads*head_dim. So base = b*S*2*H + s*2*H = sample_idx*2*H. That's correct. But let's generalize.
Actually, given the shape of the output, we can compute:
```cpp
int64_t out_stride_b = output.stride(0);
int64_t out_stride_s = output.stride(1);
int64_t out_stride_slot = output.stride(2);
float* out_ptr = output + b * out_stride_b + s * out_stride_s;
out_ptr[slot * out_stride_slot + h] = ...
```
But the output's inner dimensions (kv_heads, head_dim) are contiguous, so out_stride_slot = kv_heads*head_dim = H.
We can pass all needed info.
But the simplest is to rely on contiguous. Let's add a `.contiguous()` call in Python if needed? That would add a memcpy overhead for non-contiguous inputs but guarantee correctness. However, the kernel itself is already small.
We can also compute in kernel:
```cpp
int b = sample_idx / S;
int s = sample_idx % S;
int64_t hidden_stride_b = hidden.stride(0); // but we don't have tensor metadata in kernel.
```
In kernel, we only have raw pointers and scalars. So we need to pass strides as arguments. That's easy.
Let's add arguments for hidden_stride_b, hidden_stride_s, output_stride_b, output_stride_s, output_stride_slot. But we can also just do it in the wrapper pointer arithmetic.
If we want to be robust in wrapper:
```cpp
void mla_fused_forward(...) {
...
int64_t hidden_stride_b = hidden.stride(0);
int64_t hidden_stride_s = hidden.stride(1);
int64_t output_stride_b = output.stride(0);
int64_t output_stride_s = output.stride(1);
int64_t output_stride_slot = output.stride(2);
mla_fused_kernel<<<...>>>(
hidden.data_ptr<float>(),
w_down.data_ptr<float>(),
w_up_k.data_ptr<float>(),
w_up_v.data_ptr<float>(),
output.data_ptr<float>(),
B, S, M, R, H,
hidden_stride_b, hidden_stride_s,
output_stride_b, output_stride_s, output_stride_slot
);
}
```
Then in kernel:
```cpp
int b = sample_idx / S;
int s = sample_idx % S;
const float* h_ptr = hidden + b * hidden_stride_b + s * hidden_stride_s;
...
float* out_ptr = output + b * output_stride_b + s * output_stride_s;
out_ptr[0 * output_stride_slot + h] = acc_k;
out_ptr[1 * output_stride_slot + h] = acc_v;
```
This is more robust. Since the kernel is tiny, adding a few int64_t arguments is fine.
But we need to be careful with 64-bit multiplies in the kernel. They are fine.
However, the shared memory and compute are still the same.
Let's implement this robust version. It will handle any stride as long as the last dimension is contiguous (since we index h directly). Actually we also need w_down and w_up to be contiguous along their last dimension. The reference does matrix multiply, which expects standard strides. We can assume w_down.contiguous() and w_up.contiguous(). But to be safe, we can call .contiguous() in Python for the weights. Hidden is already contiguous.
Let's simplify: In Python forward, we can call:
```python
w_down = w_down.contiguous()
w_up_k = w_up_k.contiguous()
w_up_v = w_up_v.contiguous()
```
Then in kernel we assume standard row-major indexing for weights.
For hidden and output, we can pass the pointer with sample offset using strides.
Actually, even if hidden is contiguous, we can just use `hidden.data_ptr()` and offset by `sample_idx * M`. But to be robust, let's pass hidden_stride.
But note: `hidden.stride(0)` and `hidden.stride(1)` are int64_t. We can pass them as `int` if they fit. For small shapes, they do. But let's pass as `long long` or `int64_t`.
Let's write the kernel with these extra args.
Also, we should consider using `float4` or vectorized loads for hidden_shared to speed up loading. M=256, blockDim=128. Each thread loads 2 floats. We can load `float2` to halve the number of memory instructions.
```cpp
for (int idx = threadIdx.x; idx < M/2; idx += blockDim.x) {
float2 val = reinterpret_cast<const float2*>(h_ptr)[idx];
h_shared[idx*2] = val.x;
h_shared[idx*2+1] = val.y;
}
```
But M may not be divisible by 2. Here M=256, divisible by 2. But to be general, we can keep simple loop.
Let's try vectorized load for hidden. If M is arbitrary, we can do:
```cpp
int idx = threadIdx.x;
if (idx < M) {
h_shared[idx] = h_ptr[idx];
}
if (idx + blockDim.x < M) {
h_shared[idx + blockDim.x] = h_ptr[idx + blockDim.x];
}
```
Since M=256 and blockDim=128, each thread handles exactly 2 elements. This is simpler and avoids loops.
Similarly, for the output, each thread handles exactly one h (0..127). H=128, blockDim=128, each thread writes exactly one k and one v. Perfect.
For latent computation: threadIdx.x < R (48). The first 48 threads compute one latent each. That's fine.
So we can write:
```cpp
int tid = threadIdx.x;
// load hidden
if (tid < M) {
h_shared[tid] = h_ptr[tid];
}
if (tid + blockDim.x < M) {
h_shared[tid + blockDim.x] = h_ptr[tid + blockDim.x];
}
__syncthreads();
```
Since blockDim.x=128 and M=256, each thread loads 2.
For output:
```cpp
if (tid < H) {
// compute...
out_ptr[0 * out_stride_slot + tid] = acc_k;
out_ptr[1 * out_stride_slot + tid] = acc_v;
}
```
This is clean.
But what if M or H are not exactly multiples of blockDim? The kernel should handle arbitrary sizes. We can keep the loop for hidden load and output write.
```cpp
for (int idx = tid; idx < M; idx += blockDim.x) {
h_shared[idx] = h_ptr[idx];
}
```
This is general and still efficient for M <= 256.
For output:
```cpp
for (int h = tid; h < H; h += blockDim.x) {
// compute...
out_ptr[0 * out_stride_slot + h] = acc_k;
out_ptr[1 * out_stride_slot + h] = acc_v;
}
```
This is general. For H=128 and blockDim=128, each thread does one iteration.
Let's keep the loop for generality. The compiler will likely unroll it when blockDim is known (but blockDim is not a compile-time constant; however, if we pass as template, but we don't). It's fine.
Now, about using `blockDim.x` in the loop: it's a runtime constant per kernel launch (but uniform). The compiler can optimize.
One more improvement: Instead of having only 48 active threads for the latent computation, we could compute the latent using a warp matrix approach or use tensor cores? Overkill.
Another optimization: Since w_down is small (256x48), we could load it into shared memory. But that adds 48KB of shared memory. With blockDim=128 and M+R shared memory = 1.2KB, adding 48KB is possible but might limit occupancy. For example, if each block uses ~50KB shared memory, and each SM has ~164KB shared memory, then at most 3 blocks per SM. With 384 blocks, we need ~128 SMs to run all at once. If the GPU has 108 SMs (A100), that's okay. Actually 384 blocks over 108 SMs means ~3.5 blocks per SM. So 3 blocks per SM fits exactly. So occupancy might be okay. But if the GPU has fewer SMs, some blocks wait. However, with such small work, occupancy is not the main bottleneck.
But loading w_down into shared memory might speed up the latent computation because threads won't need to go to global memory; instead they read from shared memory. Since w_down is read by all blocks, it will be in L2, but L2 latency is still higher than shared memory. However, the latency of reading w_down can be hidden by warps. Since only 48 threads are active, and each reads 256 elements, the memory latency might be an issue. But we have 128 threads per block, 48 active. If we load w_down into shared memory using all threads, then compute latent from shared memory. Let's see:
w_down size = M * R = 256 * 48 = 12288 floats = 48KB. Shared memory per block: 48KB. Plus hidden_shared (1KB) and latent_shared (192 bytes) = ~49KB. That's within 48KB? Wait, 48KB is 49152 bytes. 12288*4 = 49152 bytes exactly. Then plus hidden 256*4=1024 bytes -> total 50176 bytes > 49152 bytes. So we cannot fit all in 48KB shared memory without exceeding the limit. But we can use 96KB shared memory if the GPU supports it (requires `cudaFuncSetAttribute` with `cudaFuncAttributeMaxDynamicSharedMemorySize`). Many modern GPUs allow up to 96KB or 164KB per block. But adding that complexity may not be necessary.
Alternatively, we can load w_down into shared memory in chunks. But that's complex.
Given the small size, the current design should be sufficient to beat the reference.
Let's test with compile_kernel.
Actually, we need to write the full Python file. Let's do it.
Wait, there's another optimization: Use `__ldg` for read-only global memory to utilize texture cache. Since weights are read-only and not modified, using `__ldg` might help. But modern GPUs automatically use L1/L2 for global loads; `__ldg` uses read-only cache. It could help.
In step 3: `w_down[m * R + j]` can be loaded with `__ldg(&w_down[m * R + j])`.
In step 4: `w_up_k[j * H + h]` with `__ldg`.
We can add `__ldg` to those loads.
But `__ldg` requires a pointer to the data, and the memory must be read-only for the lifetime of the kernel. That's true.
Let's add `__ldg`.
```cpp
acc += h_shared[m] * __ldg(&w_down[m * R + j]);
```
and
```cpp
acc_k += l * __ldg(&w_up_k[j * H + h]);
acc_v += l * __ldg(&w_up_v[j * H + h]);
```
This may give a slight boost.
Also for loading hidden: `h_shared[idx] = __ldg(&h_ptr[idx]);`. But hidden is not necessarily read-only across the kernel? It is. So we can use `__ldg`. But `__ldg` is for global memory. h_ptr points to global memory. So yes.
But careful: `__ldg` may not compile for all architectures? It works on compute capability >= 3.5. It should be fine.
Let's include it.
Now, what about half-precision? No, float32.
One more thing: the kernel may be memory-bandwidth bound. Could we use `torch.matmul` with fused approach? Let's think about the precombined weight approach: compute `w_comb = torch.matmul(w_down, torch.cat([w_up_k, w_up_v], dim=1))`. That is a (M, 2*H) matrix multiplication: (256,48) @ (48,256) = (256,256). That requires reading w_down (256*48), w_up (48*256*2), and writing w_comb (256*256). Then `output = hidden.view(B*S, M) @ w_comb`. That's (384,256) @ (256,256). This is a single cuBLAS gemm. For such small matrices, cuBLAS overhead might dominate. Actually, the precomputation of w_comb is small: 256*48*256*2 = 6.3M FLOPs, which is also fast. But the latency of two kernel launches (precompute + gemm) might still be okay.
However, the prompt explicitly says to replace with a custom CUDA kernel. A custom kernel that fuses everything should have lower overhead than two cuBLAS calls.
But we should consider if the custom kernel will actually be faster than a single cuBLAS gemm of moderate size. The cuBLAS gemm (384,256,256) is highly optimized: it may use tensor cores, shared memory blocking, etc. A naive custom kernel might be slower. However, the custom kernel avoids writing intermediate outputs and has very good memory coalescing. Let's estimate.
For the precombined cuBLAS approach:
- Precompute w_comb: 256x48 @ 48x256. cuBLAS for this small matrix may be fast but still a kernel launch.
- Then hidden (384x256) @ w_comb (256x256). M=384, N=256, K=256. That is tiny for cuBLAS; it may take ~0.02 ms? But reference uses two gemms: (384,256) @ (256,48) and (384,48) @ (48,128). The first is M=384,N=48,K=256. The second is M=384,N=128,K=48. Both are very skinny. cuBLAS skinny gemms are not great. The precombined approach makes one less-skinny gemm (N=256). It might be faster.
But can we beat a single cuBLAS gemm with a custom kernel? Possibly, because the total FLOPs are lower (9.4M vs 25M), even if our kernel is less optimized. Memory traffic is also lower: precombined reads w_down and w_up once but writes and reads w_comb (256KB). Our custom kernel avoids that. So custom kernel should have lower memory traffic.
But the custom kernel does many small GEMMs (one per sample). cuBLAS does one big GEMM with tiling. For M=384, cuBLAS may not tile well. But still, it's a single kernel.
Let's approximate timings: on an A100, a 384x256x256 fp32 gemm might take ~0.01-0.02 ms (10-20 microseconds). The reference might take ~0.03 ms. Our custom kernel might take ~0.01 ms. It's hard to know without testing.
We can only test with the tools. Let's write the kernel and see.
But we have limited tool calls. Let's write the kernel, compile, then correctness, then submit if it's okay.
We might need to iterate to optimize.
One potential issue: the kernel uses `int num_samples = B * S;` and launch `<<<num_samples, 128>>>`. If B*S is large, that's fine. Here it's 384.
But what if B*S > max grid dim? Grid dim x max is 2^31-1, so fine.
Another issue: `output` shape is (B,S,2,kv_heads,head_dim). In kernel, we compute `out_ptr = output + b * output_stride_b + s * output_stride_s`. Then write `out_ptr[h]` and `out_ptr[output_stride_slot + h]`. That is robust.
Let's write the code with these robust strides.
Also, we should ensure that the tensors are on CUDA. The inputs may be on CPU? The reference likely runs on CPU? Actually `get_inputs()` creates CPU tensors (default). But in the evaluation, they probably move to CUDA? The model forward in evaluation will call with whatever device. Since we are writing a custom CUDA kernel, we need to handle inputs on CUDA. Typically in these tasks, inputs are moved to the default CUDA device before calling forward. So we can assume CUDA tensors.
But to be safe, we can add `.cuda()`? No, we shouldn't modify device. The evaluator likely puts them on CUDA.
Let's write the code.
Also, we need to consider if the output needs to be contiguous for the kernel. We allocate it empty on the same device. It's contiguous.
Let's construct the full Python source.
Potential compile issues: `load_inline` might require `extra_cuda_cflags` to include `--use_fast_math` or `-O3`. We can include `-O3` and `-lineinfo`.
Also, we need to include `<torch/extension.h>` which includes ATen.
One more thing: In step 3, each thread does `for (int m = 0; m < M; ++m)`. M is passed as an argument. The compiler might not unroll. If M is small (256), unrolling could help. But dynamic loops don't unroll. We can templatize M, R, H? But they vary. However, in this problem they are fixed. But the kernel should accept dynamic shapes. We can use a switch or template if we want to optimize for known sizes, but it's overkill.
Actually, because the kernel is small, loop overhead is negligible.
But we could add `#pragma unroll 4` to help. But with dynamic bound, the compiler will unroll the loop in chunks of 4. Let's add `#pragma unroll 8` for the inner loops.
Wait, `#pragma unroll` requires a constant or no argument. With a dynamic bound, it might be ignored or partially applied depending on compiler. In CUDA, `#pragma unroll` on a loop with non-constant bound is allowed? I think it unrolls by a factor if possible? Actually, nvcc only supports `#pragma unroll` when the trip count is computable at compile time, or when the pragma has no argument? Let's check: `#pragma unroll` can be used with a constant integer to specify unroll factor. If the trip count is not known at compile time, the compiler may still partially unroll? I'm not entirely sure. To be safe, we can omit it. The compiler will do its own optimization.
Another potential optimization: For the latent computation, since M=256, each thread does 256 iterations. We can use `#pragma unroll 4` if we know M is a multiple of 4. But we don't know.
Let's just leave it as is.
We could also try to use float16/bfloat16 to speed up, but reference is fp32 and we need fp32 output. Using tensor cores with fp32? Ampere has TF32 tensor cores, but using them from a custom kernel is complex (needs WMMA). Not worth it for this size.
I think the fused kernel is a good approach.
Let's write the code and compile.
One more thing: In Python, we need to import torch. The class is ModelNew(torch.nn.Module). The forward signature is same.
Let's draft the code more precisely.
```python
import torch
from torch.utils.cpp_extension import load_inline
CUDA_SOURCE = r"""
#include <torch/extension.h>
#include <cuda_runtime.h>
__global__ void mla_fused_kernel(
const float* __restrict__ hidden,
const float* __restrict__ w_down,
const float* __restrict__ w_up_k,
const float* __restrict__ w_up_v,
float* __restrict__ output,
int B,
int S,
int M,
int R,
int H,
int64_t hidden_stride_b,
int64_t hidden_stride_s,
int64_t out_stride_b,
int64_t out_stride_s,
int64_t out_stride_slot)
{
int sample_idx = blockIdx.x;
int num_samples = B * S;
if (sample_idx >= num_samples) return;
int b = sample_idx / S;
int s = sample_idx % S;
const float* h_ptr = hidden + b * hidden_stride_b + s * hidden_stride_s;
extern __shared__ float sbuf[];
float* h_shared = sbuf;
float* latent_shared = sbuf + M;
int tid = threadIdx.x;
for (int idx = tid; idx < M; idx += blockDim.x) {
h_shared[idx] = h_ptr[idx];
}
__syncthreads();
if (tid < R) {
int j = tid;
float acc = 0.0f;
for (int m = 0; m < M; ++m) {
acc += h_shared[m] * w_down[m * R + j];
}
latent_shared[j] = acc;
}
__syncthreads();
for (int h = tid; h < H; h += blockDim.x) {
float acc_k = 0.0f;
float acc_v = 0.0f;
for (int j = 0; j < R; ++j) {
float l = latent_shared[j];
acc_k += l * w_up_k[j * H + h];
acc_v += l * w_up_v[j * H + h];
}
float* out_ptr = output + b * out_stride_b + s * out_stride_s;
out_ptr[0 * out_stride_slot + h] = acc_k;
out_ptr[1 * out_stride_slot + h] = acc_v;
}
}
void mla_fused_forward(
const torch::Tensor& hidden,
const torch::Tensor& w_down,
const torch::Tensor& w_up_k,
const torch::Tensor& w_up_v,
torch::Tensor& output)
{
int B = hidden.size(0);
int S = hidden.size(1);
int M = hidden.size(2);
int R = w_down.size(1);
int H = w_up_k.size(1);
int num_samples = B * S;
const int threads = 128;
const int shared_mem = (M + R) * sizeof(float);
mla_fused_kernel<<<num_samples, threads, shared_mem>>>(
hidden.data_ptr<float>(),
w_down.data_ptr<float>(),
w_up_k.data_ptr<float>(),
w_up_v.data_ptr<float>(),
output.data_ptr<float>(),
B, S, M, R, H,
hidden.stride(0), hidden.stride(1),
output.stride(0), output.stride(1), output.stride(2)
);
}
"""
mla_cuda = load_inline(
name='mla_fused_cuda',
cpp_sources=CUDA_SOURCE,
functions=['mla_fused_forward'],
extra_cuda_cflags=['-O3', '--use_fast_math'],
verbose=False,
)
class ModelNew(torch.nn.Module):
def __init__(self):
super().__init__()
def forward(self, hidden, w_down, w_up_k, w_up_v):
batch_size, seq_len, model_dim = hidden.shape
rank = w_down.shape[1]
kv_heads = 4
head_dim = w_up_k.shape[1] // kv_heads
output = torch.empty(batch_size, seq_len, 2, kv_heads, head_dim,
dtype=hidden.dtype, device=hidden.device)
mla_cuda.mla_fused_forward(hidden, w_down, w_up_k, w_up_v, output)
return output
```
Wait, `load_inline` with `cpp_sources` requires a list of strings or a single string? I think `cpp_sources` can be a string or list. Actually the signature is `cpp_sources`, `cuda_sources`. For `load_inline`, you can provide `cpp_sources` and `cuda_sources` separately? Or `cpp_sources` can contain CUDA code? Let me recall.
`torch.utils.cpp_extension.load_inline` signature:
```python
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)
```
If we provide `cpp_sources` that contains `__global__` kernel, it should be compiled as CUDA because the file extension is .cpp but nvcc handles it. Actually, `load_inline` generates a `.cpp` or `.cu` file? I think if `cuda_sources` is provided, it writes them to a `.cu` file and compiles with nvcc. If `cpp_sources` contains CUDA syntax, we should likely put it in `cuda_sources` instead.
Wait, the documentation says: `cpp_sources` are C++ sources, `cuda_sources` are CUDA sources. Usually you put the host wrapper in `cpp_sources` and the kernel in `cuda_sources`. But you can also put everything in `cpp_sources` if you don't pass `cuda_sources` and use `extra_cuda_cflags`? I'm not sure.
Actually, `load_inline` internally uses `CppExtension` or `CUDAExtension` depending on whether `cuda_sources` is provided. If `cuda_sources` is not None, it uses `CUDAExtension` which compiles `.cu` files with nvcc. If `cpp_sources` contains CUDA code, it might be compiled by the C++ compiler and fail.
So to be safe, we should put the CUDA kernel in `cuda_sources` and the C++ wrapper in `cpp_sources`? Or just put everything in `cuda_sources` and leave `cpp_sources` empty. Since the wrapper uses `torch/extension.h`, it can be in a `.cu` file; nvcc can compile C++ code with torch extensions.
Actually, `load_inline` expects the pybind wrapper to be generated from `functions`. The `cpp_sources` (and `cuda_sources`) are included in the compilation. If we provide the kernel + wrapper in `cpp_sources`, and no `cuda_sources`, it might be treated as a C++ extension and compiled with `g++`, which will error on `__global__`. So we need to put it in `cuda_sources`.
Better: use `cuda_sources=CUDA_SOURCE` and `cpp_sources=''` or omit.
Wait, I think we can just pass a single string to `cuda_sources` and it will be compiled as CUDA. The `functions` list will cause auto-generation of pybind11 bindings for the host functions.
Let's check typical usage:
```python
source = """
#include <torch/extension.h>
...
void my_func(...) { ... }
"""
module = load_inline('my_module', cpp_sources='', cuda_sources=source, functions=['my_func'])
```
Or maybe:
```python
module = load_inline('my_module', cpp_sources=source, functions=['my_func'])
```
Actually, I recall that if you pass `cpp_sources` containing CUDA code, and set `extra_cuda_cflags`, it works because `load_inline` uses `CppExtension` by default, but if the source contains `.cu` extensions? No.
Let's be safe: Provide the source as `cuda_sources`. We can leave `cpp_sources` as `''` or maybe a minimal include.
But I've seen examples where everything is passed to `cpp_sources` and it works because `load_inline` infers CUDA if `extra_cuda_cflags` is set? No, not necessarily.
Let's look at PyTorch source: `torch.utils.cpp_extension.load_inline` will create a `setup.py` with `CppExtension` if `cuda_sources` is None, else `CUDAExtension`. `CUDAExtension` compiles `.cu` files using nvcc. So we need `cuda_sources=source`.
Thus:
```python
mla_cuda = load_inline(
name='mla_fused_cuda',
cpp_sources='',
cuda_sources=CUDA_SOURCE,
functions=['mla_fused_forward'],
extra_cuda_cflags=['-O3', '--use_fast_math'],
verbose=False,
)
```
Wait, does `CppExtension` compile `.cpp` with g++ and `.cu` with nvcc? Yes. And the wrapper function is in `.cu`, so it's compiled by nvcc. That's fine. The pybind bindings are generated in a `.cpp` file and compiled by g++. Linking is done.
So we should do that.
But I'm not 100% sure if `cpp_sources=''` works. It might be okay.
Alternatively, we can use `load_inline` with `cpp_sources` only if we wrap the kernel in `extern "C"`? No, CUDA code in `.cpp` will fail.
Let's search memory: I've used `load_inline` before like this:
```python
source = """
#include <torch/extension.h>
...
"""
module = load_inline(name='foo', cpp_sources=source, functions=['foo'])
```
And in the source, I had `__global__` and it compiled. Actually, maybe `load_inline` passes the source to nvcc if it sees CUDA tokens? Or maybe it just writes it to a `.cpp` and nvcc is invoked because of `extra_cuda_cflags`? I think `load_inline` writes a file with extension `.cpp` and then compiles using the CUDA C++ compiler if available. Wait, PyTorch's `CppExtension` compiles `.cpp` files with the C++ compiler, but if the source file is named `.cu`? No.
Actually, `load_inline` writes a temporary file. Let's check the source code mentally: In `load_inline`, it does:
```python
if cuda_sources is None:
# CppExtension
with open(os.path.build_dir, name, '_wrap.cpp') as f: ...
else:
# CUDAExtension
...
```
No, I'm not sure.
To be absolutely safe, I can separate the kernel into `cuda_sources` and put host wrapper there too. `CUDAExtension` allows `.cu` files to contain host code. That's fine.
So I'll pass `cpp_sources=''` or just omit, and `cuda_sources=CUDA_SOURCE`.
But in many examples online, they pass everything to `cpp_sources` and it works. However, those examples might not have `__global__`. Wait, some do:
```python
code = """
#include <torch/extension.h>
__global__ void kernel(...) {}
void foo(torch::Tensor x) { kernel<<<1,1>>>(...); }
"""
mod = load_inline('test', cpp_sources=code, functions=['foo'])
```
Does this compile? I think yes, because `load_inline` with `cpp_sources` might compile with nvcc if the build environment is set up? Actually, if you use `load_inline`, it internally calls `load` which creates a `CUDAExtension` if there are CUDA sources. But `cpp_sources` are considered C++ sources, and the generated binding code is C++. If the C++ compiler (g++) sees `__global__`, it errors. However, `nvcc` can compile `.cpp` files as CUDA C++ if the file extension is `.cu`. But `load_inline` might write the `cpp_sources` to a `.cpp` file.
I think the safe route is to put the source in `cuda_sources`. Let's do that.
On the other hand, I've successfully compiled kernels with `load_inline` using a single string passed as `cpp_sources` and containing CUDA code. Actually, in the PyTorch tutorial "Custom C++ and CUDA Extensions", they use `cpp_sources` for the C++ wrapper and `cuda_sources` for the `.cu` file with kernel. The `lltm_cuda` example:
```python
from torch.utils.cpp_extension import load
lltm_cuda = load('lltm_cuda', ['lltm_cuda.cpp', 'lltm_cuda_kernel.cu'])
```
For `load_inline`, the equivalent would be:
```python
module = load_inline(
name='lltm_cuda',
cpp_sources=cpp_wrapper,
cuda_sources=cuda_kernel,
functions=['lltm_forward', 'lltm_backward']
)
```
So yes, let's use `cuda_sources` for the CUDA code and maybe provide an empty `cpp_sources` or the wrapper in `cpp_sources`? But the wrapper calls the kernel, which is declared in the same translation unit. If we split them into two strings, we need to declare the kernel in the C++ source or include a header. That's messy.
Simpler: Put everything in `cuda_sources`. A `.cu` file can contain host C++ code and device code. That's perfectly fine. The only issue is that pybind11 bindings are auto-generated in a `.cpp` file and need to call `mla_fused_forward`. Since `mla_fused_forward` is defined in the `.cu` file, it will be compiled by nvcc and linked with the binding `.cpp` file. That should work.
So:
```python
mla_cuda = load_inline(
name='mla_fused_cuda',
cpp_sources='',
cuda_sources=CUDA_SOURCE,
functions=['mla_fused_forward'],
extra_cuda_cflags=['-O3', '--use_fast_math'],
verbose=False,
)
```
Actually, looking at the `load_inline` signature: `cpp_sources` is a required positional argument? Actually:
```python
def 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):
```
`cpp_sources` is required. We can pass an empty string or a comment. I think passing empty string `''` is okay.
Alternatively, we can pass the CUDA source to `cpp_sources` and see if it works. Many people do that. But to avoid compilation errors, I'll pass the source to `cuda_sources` and put a minimal include in `cpp_sources`? Actually, if `cpp_sources` is empty, the generated wrapper might still compile.
Let's check if there is any issue with `cpp_sources=''`. I think the generated `setup.py` will have an empty file? Actually, `CUDAExtension` takes `sources`, which includes the `.cu` and the generated `.cpp`. The `.cpp` is generated by pybind. So `cpp_sources` is additional. It's fine if it's empty.
I'll use `cpp_sources=''` and `cuda_sources=CUDA_SOURCE`.
Wait, but in the generated code for `functions`, it creates a `python_module.cpp` that includes the user sources? No, it includes the user sources as separate files. The `python_module.cpp` just contains the pybind wrappers and includes `torch/extension.h`. It calls the functions declared in user sources. So the user sources must be compiled and linked. If `cpp_sources` is empty, only the `.cu` file and the wrapper are compiled. That's fine.
Alternatively, we can put the source in `cpp_sources` and set `extra_cuda_cflags` and maybe `is_python_module`? I'm overthinking. I'll just pass to `cuda_sources`.
One more thing: `--use_fast_math` might cause slight numerical differences. The tolerance is 1e-4 for fp32.`--use_fast_math` enables less accurate divisions, sqrt, etc. Our kernel only does multiplication and addition. So `--use_fast_math` doesn't affect FMA accuracy. It's fine.
But to be safe, we can omit `--use_fast_math` and just use `-O3`.
Actually, FMA can be fused by compiler with `-O3` anyway. `--use_fast_math` might change the order of operations? For multiplication and addition, not really. But it might replace some operations. Since we only have `*` and `+`, it shouldn't matter.
Let's just use `-O3`.
Now, let's compile.
But before that, double-check the logic.
In kernel:
`int tid = threadIdx.x;`
Loop for hidden load: `for (int idx = tid; idx < M; idx += blockDim.x)`. Works.
Latent compute: `if (tid < R)`. Works.
Output compute: `for (int h = tid; h < H; h += blockDim.x)`. Works.
Output pointer: `float* out_ptr = output + b * out_stride_b + s * out_stride_s;`. Then `out_ptr[0 * out_stride_slot + h]` and `out_ptr[1 * out_stride_slot + h]`. Since `out_stride_slot` is the stride of the dimension of size 2, this is correct.
Wait, output shape is (B,S,2,kv_heads,head_dim). Stride of dim 2 (the slot dim) is `kv_heads*head_dim` = H. Yes. So `out_ptr[slot * H + h]` is correct.
But we should verify `out_stride_slot` is indeed H. For contiguous tensor of shape (B,S,2,H), strides: [S*2*H, 2*H, H, 1]. So `output.stride(2) == H`. Yes.
But what about the output shape (B,S,2,kv_heads,head_dim)? The strides are [S*2*kv_heads*head_dim, 2*kv_heads*head_dim, kv_heads*head_dim, head_dim, 1]. So `output.stride(2)` is `kv_heads*head_dim` = H. Good.
So pointer arithmetic is correct.
Similarly for hidden: `hidden_stride_b` and `hidden_stride_s`. For contiguous hidden of shape (B,S,M), strides are [S*M, M, 1]. So `hidden + b * S*M + s * M` = hidden + (b*S + s)*M. Good.
So the kernel works for contiguous and also for some non-contiguous cases (like if hidden has padding between batches but not within rows? Actually if hidden_stride_s != M, then the pointer arithmetic is correct per row. Good.)
Now, one more nuance: the kernel uses `int64_t` for strides. But pointer arithmetic with `int64_t` may cause the pointer offset to be in elements or bytes? We use `hidden + b * hidden_stride_b`. Since `hidden` is `const float*`, pointer arithmetic is in `sizeof(float)` units. If `hidden_stride_b` is the number of elements to skip between batches, then `hidden + b * hidden_stride_b` is correct. In PyTorch, `tensor.stride(0)` returns the number of elements to skip. So yes, correct.
Same for output: `output + b * out_stride_b + s * out_stride_s`. Then `out_ptr[slot * out_stride_slot + h]`. Since `out_stride_slot` is the number of elements between slot 0 and slot 1, that's correct.
Everything looks good.
Let's compile.
We should also consider the case where `hidden.dtype` is not float32. The problem says produce numerically equivalent outputs at fp32 precision. The reference uses float32. The inputs are torch.randn without dtype specified? Actually `get_inputs()` creates `torch.randn(..., dtype=torch.float32)`. So inputs are float32. Our kernel assumes float32. Good.
We should check that the output is also float32. `torch.empty(..., dtype=hidden.dtype, device=hidden.device)` ensures that.
Let's write the final kernel code and compile.