reasoning
We need to write a fast custom CUDA kernel that computes the forward pass of a fused MLA-style attention. The reference does a triple nested loop over batch, head, time (seq), and for each t it does a causal attention over s=0..t: it computes key and value by multiplying the latent vector `kv_latent[b,s]` with w_up_k[:,h] (rank x head_dim) and w_up_v[:,h] (rank x head_dim). So key shape is head_dim, value shape is head_dim. Then dot product with q[b,t,h] scaled by 1/sqrt(head_dim), softmax over s, weighted sum of values.
This is essentially a causal attention where keys and values are generated from latents via linear projections per head. Since rank=24, head_dim=16, num_heads=4, seq_len=32, batch=2. This is a small model, but we want to optimize.
A naive CUDA kernel: for each (b, h, t) block or thread compute the causal attention vector. Since seq_len is only 32, but batch=2, heads=4, total 256 queries. We can have each thread handle one query (b,h,t). Then within each thread, loop over s=0..t (max 32) and compute key, dot with query, accumulate to logits and values. This avoids any shared memory or complex reductions because seq_len is tiny and causal. Each thread does at most 32 small matrix-vector multiplies (latent rank 24 to head dim 16). Actually w_up_k is rank x head_dim. We can precompute or load the weights into registers? w_up_k[:,h] is a matrix of shape rank x head_dim. For each head, all threads use same weight matrix. Since rank=24 and head_dim=16, that's 384 floats per weight matrix (w_up_k and w_up_v). Could load into shared memory per block. But each thread can also load directly from global memory; with small data, caching might be fine.
But to get significant speedup, we should vectorize loads and compute. Possibly use half? But input is fp32. Could still use fp32.
Since seq_len is short (max 32), and t varies, one thread per query is easiest. Compute dot product of q and k, where k = latent * W_k. Instead of fully materializing k, we can compute the dot product as q^T (latent * W_k) = (latent^T) (W_k q) ??? Let's see: q is head_dim. W_k is rank x head_dim. latent is rank. k = latent @ W_k (treating latent as 1 x rank, W_k as rank x head_dim). Actually the code: `torch.matmul(latent, w_up_k[:, h])`. latent shape (rank,), w_up_k[:, h] shape (rank, head_dim). So it's a standard matrix multiply producing shape (head_dim,). Wait, if latent is rank, w_up_k[:,h] is rank x head_dim, then matmul gives (head_dim,). Indeed k = latent^T W_k. So k is a linear combination of the rows of W_k weighted by latent entries. Then dot with q: q · k = q · (latent^T W_k) = (W_k q) · latent. Because q is head_dim, W_k q is rank (since W_k is rank x head_dim). Then dot with latent (rank). That's more efficient: precompute per query the vector p_k = W_k[:,h] @ q (but careful: W_k is rank x head_dim, q is head_dim, so W_k @ q yields rank). Then the dot product for each s is latent[b,s] · p_k. This reduces per-step compute from O(rank*head_dim) for k + O(head_dim) for dot to O(rank) for dot only. However we also need value v = latent[b,s] @ W_v (rank x head_dim) to compute weighted sum. For values, we need to compute weighted sum of v over s: sum_s attn_s * v_s. This is like V^T attn where V is (t+1) x head_dim, each row is v_s = latent_s @ W_v. So output = sum_s attn_s * (latent_s @ W_v) = (sum_s attn_s * latent_s) @ W_v. So if we compute a weighted sum of latents first, then multiply by W_v once. That reduces per-step compute from O(rank*head_dim) for each v to O(rank) for accumulating weighted latent, and then O(rank*head_dim) once per query.
So the algorithm per query (b,h,t):
- compute p_k = W_k[h] @ q (rank)
Actually W_k shape (rank, num_heads, head_dim). So per head we can view as W_k_h of shape (rank, head_dim). So p_k = W_k_h @ q (rank vector).
- For s=0..t:
- dot = latent[b,s] · p_k
- compute logits_s = dot * scale
- maybe accumulate max logit for softmax? We need softmax. softmax = exp(logit - max) / sumexp.
- accumulate weighted latent sum: acc_latent += softmax_weight_s * latent[b,s] (but we only know weights after softmax). So need two passes: first compute all logits, find max, compute exp and sum, then second pass compute weighted sum of latents using normalized attn weights.
- Then output = acc_latent @ W_v_h (rank x head_dim) => head_dim.
Given t <= 31, doing two passes over up to 32 elements is trivial.
Thus per query compute:
- p_k: rank*head_dim = 24*16=384 mul-adds.
- First pass over s<=t: rank* (t+1) = up to 24*32=768 mul-adds for dot products.
- Softmax overhead: negligible.
- Second pass: rank*(t+1) = up to 768 mul-adds for weighted latent accumulation.
- Final matvec with W_v_h: rank*head_dim = 384 mul-adds.
Total: ~2304 FLOPs per query. With 256 queries, total ~590k FLOPs. Very small. So kernel launch overhead matters. But still, we can beat the triple Python loop easily.
But we need to be careful: the kernel runtime may be dominated by memory loads. Weight matrices W_k and W_v per head are small and can be cached. Latent and q are loaded per query.
To optimize, we can have each thread process one (b,h,t). We can store the logits in a small local array (e.g., float logits[32];). Since seq_len=32 is small, we can unroll the loop or keep it static. We'll compute max logit, then compute exp and sum, then second loop to accumulate weighted latent.
Alternatively, we could process multiple queries per thread to amortize loads of W_k and W_v. For example, each thread could process a whole (b,h) and loop over t, using previously computed logits? But causal attention per t requires only s up to t, but we can incrementally update. However that adds complexity. Given small size, one thread per query is likely fastest and simplest.
But we need to consider memory coalescing. If each thread handles a query, then threads in a warp will have same h? Not necessarily. If we map threads as: blockDim.x = seq_len (32), blockDim.y = num_heads (4), gridDim.x = batch (2). Then each block handles one batch, and each thread handles one (h,t). Threads in a warp (32) would have varying t and same h? Actually if blockDim.x=32, blockDim.y=4, then threads with same t but different h? No, x dimension is fastest varying. So threadIdx.x = t (0..31), threadIdx.y = h (0..3). A warp consists of 32 threads, so a warp could be all threads with h=0, t=0..31 (since x varies first). That means all threads in a warp have same h (if y is second). Wait, in CUDA, threads are linearized as threadIdx.x + threadIdx.y*blockDim.x + threadIdx.z*blockDim.x*blockDim.y. So threads 0..31 have threadIdx.x = 0..31, threadIdx.y = 0. So indeed a warp of 32 threads corresponds to t=0..31 for a single head h=0. That's great because they all use the same weight matrix W_k[:,h] and W_v[:,h]. So we can load weight matrices into shared memory collaboratively. However, seq_len=32, and per query we access latent[b, s] for s up to t. For t varying across warp, memory access pattern for latent is divergent: thread t accesses s=0..t, so threads with larger t access more elements. But latent is stored as [batch, seq_len, rank] with seq_len as middle dimension. If we store in shared memory per block the entire latent for the batch? No, we can load latent on demand from global memory; each access is contiguous across s? Actually for a given s, latent[b,s] is a vector of rank floats. If multiple threads access same s, it's broadcast. Since t varies, threads might access overlapping s. This is good for cache.
Alternatively, we could transpose the loops: each block processes a (b,h) pair, and threads process s (source) to compute contributions to all t >= s. This could be more parallel but also more reduction complexity. Not needed.
Given the tiny size, a straightforward kernel should be enough to beat the Python loop reference, which is extremely slow. But we also likely need to beat a possible PyTorch vectorized implementation. However the evaluation compares to the reference Model (the Python loop). So we just need to be faster than that. But they also measure SOL. To get good SOL, we want high utilization. For such small problem, SOL may not be high anyway.
But we can still write an efficient kernel.
Given the constraints, let's design the kernel.
We'll define a CUDA kernel `mla_attention_forward` that takes:
- const float* q (B, T, H, D)
- const float* kv_latent (B, T, R)
- const float* w_up_k (R, H, D)
- const float* w_up_v (R, H, D)
- float* out (B, T, H, D)
- int B, int T, int H, int D, int R
- float scale
Mapping: each thread handles one (b, h, t).
b = blockIdx.x
h = threadIdx.y
t = threadIdx.x
Block dimensions: (T, H, 1) = (32, 4, 1). Grid: (B, 1, 1) = (2,1,1). Total threads = B*T*H = 256. This is a very small kernel. But it's fine.
We need to ensure blockDim.x = T = 32 exactly? The reference seq_len is 32 but maybe they vary? The problem statement seems fixed: batch_size=2, seq_len=32, num_heads=4, head_dim=16, rank=24. But to be safe, we can pass dimensions as arguments and handle generically, using loops.
But for performance, we can unroll loops based on compile-time constants? Or just use dynamic loops with seq_len <= 128 maybe. But we can also have blockDim.x = T. If T is passed at runtime, block dimensions must be set accordingly.
I'll write the kernel generically.
Pseudo-code for each thread:
int b = blockIdx.x;
int t = threadIdx.x;
int h = threadIdx.y;
if (t >= T) return;
int idx_q = ((b * T + t) * H + h) * D;
float q_vec[HEAD_DIM_MAX]; // D is small, we can load into registers
for (int d=0; d<D; ++d) q_vec[d] = q[idx_q + d];
// Precompute p_k = W_k_h @ q (size R)
float p_k[RANK_MAX];
for (int r=0; r<R; ++r) {
float sum = 0.0f;
const float* wk_ptr = w_up_k + ((r * H + h) * D);
for (int d=0; d<D; ++d) sum += wk_ptr[d] * q_vec[d];
p_k[r] = sum;
}
// First pass: compute logits for s=0..t
float logits[MAX_SEQ_LEN]; // allocate based on T (max 128?), but we can allocate dynamic with shared? Actually each thread can have local array float logits[128]; but stack may be okay. T is small.
float max_logit = -1e30f;
for (int s=0; s<=t; ++s) {
int idx_latent = (b * T + s) * R;
float dot = 0.0f;
for (int r=0; r<R; ++r) dot += kv_latent[idx_latent + r] * p_k[r];
float logit = dot * scale;
logits[s] = logit;
if (logit > max_logit) max_logit = logit;
}
// Compute softmax
float sum_exp = 0.0f;
for (int s=0; s<=t; ++s) {
float val = expf(logits[s] - max_logit);
logits[s] = val;
sum_exp += val;
}
float inv_sum = 1.0f / sum_exp;
for (int s=0; s<=t; ++s) logits[s] *= inv_sum;
// Second pass: accumulate weighted latents
float acc_latent[RANK_MAX];
for (int r=0; r<R; ++r) acc_latent[r] = 0.0f;
for (int s=0; s<=t; ++s) {
int idx_latent = (b * T + s) * R;
float weight = logits[s];
for (int r=0; r<R; ++r) acc_latent[r] += kv_latent[idx_latent + r] * weight;
}
// Finally compute out = acc_latent @ W_v_h
int idx_out = ((b * T + t) * H + h) * D;
for (int d=0; d<D; ++d) {
float sum = 0.0f;
const float* wv_ptr = w_up_v + (( /* r */ * H + h) * D + d); // need loop over r
// Actually W_v_h is (R, H, D). So for fixed h and d, we need sum over r of acc_latent[r] * w_up_v[(r*H + h)*D + d].
}
So:
for (int d=0; d<D; ++d) {
float sum = 0.0f;
for (int r=0; r<R; ++r) {
sum += acc_latent[r] * w_up_v[((r * H + h) * D) + d];
}
out[idx_out + d] = sum;
}
We need to be careful about indexing of w_up_k and w_up_v. In reference, `w_up_k` shape is (rank, num_heads, head_dim). The code `w_up_k[:, h]` gives shape (rank, head_dim). In C, row-major indexing: index = ((r * H) + h) * D + d.
So the kernel is straightforward.
Potential optimizations:
- Use shared memory for w_up_k and w_up_v per head. Since each warp handles same h, all threads in warp read same weight matrix. But it's small enough to fit in L1 cache. However, explicit shared memory might reduce global memory traffic. Let's weigh: each thread reads W_k_h (R*D = 384 floats) and W_v_h (384 floats). With 256 threads, total reads = 256*768 = 196k floats = 768KB. That's fine for L2. But loading from global memory might be slow. Using shared memory: a block of (T,H) threads can cooperatively load W_k and W_v into shmem. For H heads, each block loads H * R * D * 2 floats = 4*384*2 = 3072 floats = 12KB. That's small. But each block only has 128 threads (if T=32, H=4 => 128 threads). Actually 32*4=128 threads per block. For two blocks (B=2), total threads = 256. Wait blockDim.x=32, blockDim.y=4 => 128 threads per block. B=2 => 2 blocks. So each block loads 12KB into shared memory. Each thread then reads from shmem. This is nice.
But we need to support dynamic T and H. We can allocate shared memory dynamically or with a max size. Since H <= maybe 16? D <= 64? R <= 64? We can estimate max shared memory. But simple constant might be easiest. Alternatively, rely on L1 cache and not use shmem. Given the tiny problem, the speedup vs reference will be huge anyway. However, to get best performance, we might want to vectorize loads.
Another optimization: compute p_k = W_k_h @ q. Since q is per thread, we can compute this in registers. W_k_h can be read from global memory. Since each thread in a warp reads the same W_k_h elements (though with different d?), no: to compute p_k[r] = sum_d W_k_h[r,d] * q[d], each thread needs all W_k_h[r,d] for all d. Since q differs per thread, each thread reads W_k_h and multiplies by its own q. The memory access pattern for W_k_h is uniform across the block (all threads read same W_k_h[r,d] sequentially). This is broadcast, cached.
Thus, we might not need shmem. But we can still add it for good measure.
Another optimization: use float4 for vectorized loads of q, latent, and weights. Since D=16, R=24, not multiples of 4? 16 is multiple of 4, 24 is multiple of 4? 24 is multiple of 4 as well (6 float4s). So we can vectorize loops by 4. That would reduce instruction count and improve bandwidth.
However, writing a fully generic vectorized kernel is more error-prone. Since sizes are small multiples of 4, we can safely assume alignment? PyTorch tensors are aligned to at least 256 bytes, so float4 loads are safe as long as we access contiguous dimensions.
Let's inspect memory layout:
- q: (B, T, H, D). The innermost dimension is D. So q[b,t,h,:] is contiguous. We can load with float4 if D % 4 == 0. D=16, yes.
- kv_latent: (B, T, R). Innermost R=24, contiguous. float4 loads possible.
- w_up_k: (R, H, D). Innermost D=16, contiguous. For a fixed h, w_up_k[:,h,:] is contiguous across D but strided across R: stride between rows is H*D. So W_k_h[r,:] is contiguous. So for each r, we can load a float4 across d.
- out: same as q.
So vectorization is possible.
But for simplicity and correctness, we might not need float4. The reference model is extremely slow, so even a naive kernel will be orders of magnitude faster. Still, we want to maximize speedup and SOL.
Wait: The evaluation compares against the Python reference, not a PyTorch optimized version. But the speedup might be measured as ratio of kernel time to reference time. The reference is a triple nested Python loop doing PyTorch ops on small slices; it will be extremely slow (likely tens to hundreds of milliseconds). A CUDA kernel doing this in a few hundred microseconds will give massive speedup. So we don't need extreme optimization. But we need to ensure correctness and that kernel compiles.
However, there is a subtlety: `torch.utils.cpp_extension.load_inline` can be slow to compile. We need to write clean code.
Also, note that the kernel body should be a string inside Python source.
Let's think about using shared memory. Given that each warp handles same h, and W_k and W_v are shared across t, we can have each warp load W_k_h into registers, but that's per thread. Better: have the first 32 threads (or some subset) cooperatively load weights into shared memory, then all threads read.
But with T=32, blockDim.x=32, threadIdx.x=t. For a fixed h, all 32 threads have same h. They could all load W_k_h and W_v_h. Since there are only 384 floats per matrix, each thread could load ~12 floats. But we need to synchronize. Let's consider using a static shared memory array:
__shared__ float sh_wk[HEADS_MAX * RANK_MAX * HEAD_DIM_MAX];
__shared__ float sh_wv[HEADS_MAX * RANK_MAX * HEAD_DIM_MAX];
This might be too large if max dims are large. But we can allocate dynamically via `extern __shared__ float shared[];` and partition. But we need to know H, R, D at runtime.
Simpler: don't use shared memory. The global memory loads are broadcast and cached. For tiny problem, the kernel is likely compute-bound or latency-bound, not bandwidth-bound. But the compute is small. So the limiting factor is kernel launch overhead and serialization. Having many threads (256) is good.
We can also consider processing each (b,h) with a block of threads, where each thread handles one t. That's what we have. Kernel launch of 2 blocks with 128 threads each. Very fast.
We should also consider using `constexpr` for small dimensions to allow compiler unrolling. Since the problem sizes are fixed at runtime but known from the reference (seq_len=32, num_heads=4, etc.), we could template the kernel with these sizes to let the compiler unroll loops. But `load_inline` with templated kernel is a bit tricky: we can define the kernel as a template and instantiate it in the wrapper with the runtime sizes... but we don't know them at compile time unless we hardcode. Since the evaluation uses exactly these sizes, we could hardcode 32,4,16,24 in the kernel as constants. That would allow full unrolling and better performance. However, if the hidden tests vary sizes slightly (they might use same class but different inputs?), the problem statement says: "batch_size = 2, seq_len = 32, num_heads = 4, head_dim = 16, rank = 24". Usually these are fixed for evaluation. But to be safe, we can write a generic kernel that takes sizes as parameters. The compiler may still unroll loops if we mark them with `#pragma unroll` and the sizes are small.
I think a generic kernel is safer.
Let's draft the CUDA code:
```cuda
#include <torch/extension.h>
#include <cuda_runtime.h>
#include <math.h>
__global__ void mla_attn_kernel(
const float* __restrict__ q,
const float* __restrict__ kv_latent,
const float* __restrict__ w_up_k,
const float* __restrict__ w_up_v,
float* __restrict__ out,
int B, int T, int H, int D, int R,
float scale
) {
int b = blockIdx.x;
int t = threadIdx.x;
int h = threadIdx.y;
if (b >= B || t >= T || h >= H) return;
// Load q
int q_base = ((b * T + t) * H + h) * D;
float q_vec[16]; // assume max D=64 maybe? but allocate dynamic? C++ VLAs not allowed in standard CUDA. We can allocate a fixed max.
// Actually we can't use variable length arrays with non-const size in CUDA easily. Use fixed maximum.
}
```
C++ VLA is not standard in CUDA (some compilers allow it as extension, but it's risky). We can allocate arrays with a maximum size, e.g., `float q_vec[64];` and loop up to D. Since D is small (16), it's fine. Similarly `float p_k[64];` for R up to maybe 64. `float acc_latent[64];`. `float logits[128];` for T up to maybe 128. But we need to allocate on stack. With 128 threads per block, each with ~ (64+64+64+128)*4 = 1280 bytes, total per block ~160KB, which exceeds stack size (default 1KB? Actually local memory is used if registers spill; but small arrays may be placed in registers if indexed with constants. If indexed with variables, they go to local memory). However T is a variable index for logits array, and s loop variable is dynamic. So `float logits[128];` will likely reside in local memory. For 128 threads, that's 128*128*4 = 64KB per block, which is okay for local memory. But local memory is backed by global memory, so it's slow. But with only 32 iterations, it's fine.
Alternatively, we can compute softmax on the fly without storing all logits. Since t <= 31, we can do online softmax. That avoids the logits array. Let's do online softmax to reduce local memory and maybe improve performance.
Online softmax algorithm:
- We need max and sum_exp. We can compute in one pass.
- For s=0..t: compute logit. Keep track of max_logit and sum_exp.
When a new logit > max_logit: sum_exp = sum_exp * exp(old_max - new_max) + exp(logit - new_max); max_logit = new_logit.
Else: sum_exp += exp(logit - max_logit).
- After first pass, we have max_logit and sum_exp.
- Then we need a second pass to compute weighted sum of latents using weights = exp(logit - max_logit) / sum_exp.
We can compute weight per s in the second pass: recompute logit? That would duplicate p_k * latent compute. Or we can store logits in a small array. Given small t, recomputing might be okay, but duplicating the dot product (rank * t) is some compute. However we can store the dot products in a small array of floats (e.g., float logits[32];). That's only 32 floats, 128 bytes. That's fine for local memory. Actually we can store them.
But we can also store the logits in a register array if we unroll the loop with a fixed max size, e.g., `float logits[32]; #pragma unroll`. Since T=32 is constant? If we pass T as a template parameter or if we hardcode max, the compiler might keep them in registers if we unroll. But if we loop with variable t, we can't unroll fully unless T is compile-time constant.
Wait: we can use a temporary shared memory buffer per warp to store logits? Actually each thread only needs its own logits. If we use a fixed-size array `float logits[32];` and index with `s`, CUDA compiler may place it in local memory, but because accesses are dynamic, it will be local memory. Local memory is cached in L1. 32 floats is 128 bytes. That's very small, so it's effectively L1 cached. So it's fine.
Similarly, for q_vec, p_k, acc_latent: we can use fixed-size arrays.
So we can do:
- float q_vec[HEAD_DIM_MAX]; // load q
- float p_k[RANK_MAX]; // precompute
- float logits[MAX_SEQ_LEN]; // store dot*scale
- First pass: compute logits[s] and max_logit.
- Compute sum_exp.
- Second pass: compute weight = expf(logits[s] - max_logit) / sum_exp; accumulate latent.
- float out_vec[HEAD_DIM_MAX] = {0}; compute final.
Given D=16, R=24, T=32, we can set:
#define MAX_D 64
#define MAX_R 64
#define MAX_T 128
But to be generic, we can just allocate arrays of size 64 or 128. It's safe.
Alternatively, we can avoid storing logits by doing two passes: first compute max and sum_exp with online softmax; second pass recompute logits using the same dot product. This avoids storing logits array entirely, at the cost of recomputing dot products. Dot product is rank multiplications. For t=31, recomputing 31 dot products of rank 24 is ~744 ops. That's negligible compared to other compute. And it saves local memory. Actually we can just recompute. Let's see:
Pass 1:
max_logit = -inf
sum_exp = 0
for s=0..t:
dot = 0
for r=0..R: dot += kv_latent[...] * p_k[r]
logit = dot * scale
// online softmax update
if (logit > max_logit) {
sum_exp = sum_exp * expf(max_logit - logit) + 1.0f;
max_logit = logit;
} else {
sum_exp += expf(logit - max_logit);
}
Pass 2:
for s=0..t:
dot = 0
for r=0..R: dot += kv_latent[...] * p_k[r]
logit = dot * scale
weight = expf(logit - max_logit) / sum_exp;
for r=0..R: acc_latent[r] += kv_latent[...] * weight;
But wait, online softmax final sum_exp is sum of exp(logit - max_logit). The weight formula uses that sum_exp. In the original two-pass softmax: max = max(logits), sum_exp = sum(exp(logit - max)), weight = exp(logit - max)/sum_exp. With online softmax, after processing all s, we have max_logit and sum_exp. Does sum_exp equal sum(exp(logit - max_logit))? Let's verify.
Standard online softmax:
max = -inf, sum = 0
for i:
if x_i > max:
sum = sum * exp(max - x_i) + 1
max = x_i
else:
sum += exp(x_i - max)
At the end, sum = sum_i exp(x_i - max). Yes, that's correct. So weight = exp(x_i - max) / sum. Good.
So we can recompute the dot product in pass 2. This avoids storing logits. The cost is small. But we do need to load kv_latent twice. Since kv_latent is read from global memory, caching should make the second pass cheap (L2 cache). This is fine.
However, we can also store the dot product results (just one float per s) in a small local array to avoid recomputation. It's only 32 floats. Let's just store them to be safe and keep code simpler. Actually storing them ensures we compute exactly the same dot product for softmax and weighting, avoiding potential numerical differences due to recomputation (though it should be identical in fp32). We can store in `float lvec[128];`.
But using an array of variable size t (which is dynamic) might cause local memory. Let's allocate `float lvec[128];` fixed size. The compiler will put it in local memory, but it's small. Let's do that.
Actually, we can also use shared memory to store the logits per thread? No, each thread needs its own.
Another idea: process each (b,h) with a block where threads handle s (source), and we compute the full attention matrix (T x T) for that (b,h). Then we do softmax per row and multiply by V. This uses more parallelism and shared memory, and avoids recomputing K per query. But we can compute K = latent @ W_k_h into a shared K matrix of shape (T, D) or (T, R)? Actually K = latent @ W_k_h, but we only need Q @ K^T. Since Q is (T, D) and K is (T, D), we can compute attention scores as Q[t] dot K[s]. We can compute K in shared memory: for each s, a thread computes K_s = latent_s @ W_k_h (size D). That's a matvec of rank x D. With T=32 threads, each computes one K_s. Then similarly compute V_s = latent_s @ W_v_h (size D) and store in shared memory. Then compute scores Q @ K^T (T x T). Then softmax per row (each thread handles one t). Then output per t = sum_s attn[t,s] * V_s. This is a classic attention block but with K and V generated on-the-fly.
This approach might be more efficient because:
- W_k_h and W_v_h are loaded once per block and can be in shared memory.
- All threads in block collaborate.
- We can compute K and V in parallel for all s.
- Then compute scores with coalesced memory accesses.
- The reduction for output can also be parallel.
Given T=32, H=4, B=2, block size = (T, H) = 128 threads. But if we process one (b,h) per block, block size = T = 32 threads. That might be underutilized (one warp). But we can process all h in same block? Actually K and V depend on h (weights differ), so we need separate computation per h. We could have blockDim.x = T (32), blockDim.y = H (4) = 128 threads, processing one batch item b. Then within the block, we can have each thread handle one (h,t) pair. But generating K for all h and s would require shared memory for H * T * D = 4*32*16 = 2048 floats = 8KB. Similarly for V. That's manageable.
But then each thread needs to compute K_{b,h,s} for all s? Wait, we have 128 threads. We could assign threads to compute K and V for all (h,s) pairs. There are H*T = 128 such pairs. So each thread can compute exactly one K_s and one V_s for a specific (h,s). That is perfect: thread (h,s) computes latent_s @ W_k_h and latent_s @ W_v_h. Then store in shared memory.
After that, __syncthreads(). Then each thread (h,t) computes:
- q_t dot K_s for s=0..t. This is a dot product of two D-dimensional vectors in shared memory. Each thread loops over s.
- softmax on the fly or store logits.
- weighted sum of V_s.
This is more parallel and memory-friendly. However, we need to be careful about the mapping: threadIdx.x = s (0..T-1) or threadIdx.x = t? For the first phase (compute K,V), we want each thread to compute one (h,s). For the second phase (compute attention), we want each thread to compute one (h,t). Since we have the same thread grid, each thread can do both: it computes K_{h,s} and V_{h,s} where s = threadIdx.x (or some mapping), and also computes the final output for t = threadIdx.x (or some mapping). But a thread can't be both s and t unless we assign it to compute K/V for its s and then compute output for its t. That's fine: each thread computes K_{h, threadIdx.x} and V_{h, threadIdx.x}. Then after sync, each thread computes output for t = threadIdx.x. That works because all K and V are available in shared memory.
So mapping:
- b = blockIdx.x
- h = threadIdx.y
- idx = threadIdx.x (0..T-1)
- Phase 1: Compute K[idx] = latent[b, idx] @ W_k[:, h] and V[idx] = latent[b, idx] @ W_v[:, h].
- Phase 2: Compute output for query at t = idx.
- Load q[b, t, h] into registers.
- For s=0..t: dot = q · K[s] * scale.
- Softmax over s.
- Weighted sum of V[s].
- Write output.
This is elegant. We need shared memory for K and V per block. Since block processes one b, all h. Shared memory layout: K_shared[H][T][D], V_shared[H][T][D]. That's 4*32*16 = 2048 floats each, total 4096 floats = 16KB. Plus maybe smem for weights? We can also store W_k and W_v in shared memory to avoid repeated global loads. W_k_shared[H][R][D] = 4*24*16 = 1536 floats, same for W_v. Total shared memory = (4096 + 3072) * 4 bytes = 28.7KB. That's well within 48KB (or 96KB on modern GPUs). So we can put everything in shared memory and be very fast.
But wait: each thread only needs W_k for its h. If we load W_k for all h into shared memory, it's fine. But we could also just load from global memory; it's small and broadcast. Let's keep it simple and not use shared memory for weights, just for K and V. Actually even K and V can be computed on the fly per thread without shared memory (as in the earlier design). The earlier design had each thread compute k and v for each s in the loop. That is similar compute but without syncthreads and shared memory.
The shared memory approach reduces redundant computation: each K_s is computed once and used by all t >= s. In the per-thread loop approach, each thread computes k_s for all t >= s, meaning K_s is computed multiple times (by each query t). Number of times: for each s, it's computed by (T - s) threads. So total K computations = sum_{s=0}^{T-1} (T-s) = T*(T+1)/2 = 528 per head. With block approach, K computations = T = 32 per head. So we save ~16x compute for K and V. Since K/V computation is rank*head_dim = 384 mul-adds each, the per-thread approach does ~528*384 ≈ 202k mul-adds per head, while block approach does 32*384 ≈ 12k. That's a huge saving. So the block-shared approach is much more efficient.
But wait: the per-thread approach using p_k precomputation avoids computing K_s explicitly. Instead it does dot = latent_s · p_k. That is only rank=24 mul-adds per s. So per-thread compute for dot products = sum_{s} (T-s)*24 ≈ 528*24 = 12.6k per head. This is comparable to the block approach's K computation cost (12k). Then the block approach still needs to compute Q·K_s dot product (head_dim=16) for each (t,s) pair: number of dots = T*(T+1)/2 = 528 per head, each 16 mul-adds = 8.4k. The per-thread approach avoids this because dot = latent·p_k is rank=24. So per-thread approach total matmuls: p_k precompute = 384, dots = 12.6k, value accumulation = 12.6k, final out = 384 = ~25k. Block approach: K precompute = 12k, V precompute = 12k, Q·K dots = 8.4k, weighted V sum = each output t sums s=0..t vectors of size D: that's D * T*(T+1)/2 = 16*528 = 8.4k, plus no final matvec. Total block = ~40k. So per-thread with p_k precomputation is actually cheaper in FLOPs.
But the block approach may have better memory access patterns and parallelism. However, the per-thread approach is simpler and likely fast enough. Given the problem size is tiny, FLOPs don't matter much; latency and memory do.
I think the per-thread approach with p_k and acc_latent is simpler and less register pressure. Let's compare memory bandwidth:
- Per thread loads:
- q: D=16 floats.
- W_k_h: R*D=384 floats.
- W_v_h: R*D=384 floats.
- latents for s=0..t: (t+1)*R up to 768 floats.
- Total loads per thread: ~1.5k floats = 6KB.
- For 256 threads: 1.5MB.
- Block approach per block (128 threads):
- W_k_h and W_v_h: loaded once per block? If loaded into shared memory by threads, total 4*2*384 = 3k floats. Or each thread loads from global as needed.
- latent for all s: T*R = 768 floats per block (one batch). Actually all threads read latents; each thread reads its own s for K/V compute, and then each thread reads all s again for softmax. So latents are read ~2 times per block.
- q for all t: T*D = 512 floats.
- Total per block: ~2k floats reads + shared.
- For 2 blocks: ~4k floats. Much less memory traffic! Because weights are shared.
But in per-thread approach, weights are read from global memory by each thread, but they are cached in L2/L1 because all threads read same weights. L1 broadcast is fast. So effective memory traffic is lower than 1.5MB due to cache. L1 can broadcast to all threads in a warp simultaneously if they read the same address. So reading W_k_h across the warp is essentially one read per warp. So memory bandwidth is not a big issue.
Given that, either approach works. The per-thread approach is simpler to code and less prone to shared memory bank conflicts. However, the block approach with shared memory for K/V might be faster on GPU because it avoids redundant rank*D matvecs. But per-thread approach avoids them too via p_k trick. The block approach does D-dimensional dot products for Q·K, while per-thread does rank-dimensional dot products for latent·p_k. Since rank=24 > D=16, per-thread has slightly more work per (t,s) pair (24 vs 16). But per-thread also does a final matvec of rank*D=384. Block approach does V precompute of rank*D per s, and weighted sum of D per (t,s). Let's compute total FLOPs more carefully per (b,h):
Per-thread approach (p_k):
For each t (0..T-1):
p_k = W_k @ q_t: R*D = 384
for s=0..t:
dot_s = latent_s · p_k: R = 24
softmax t
for s=0..t:
acc_latent += attn_s * latent_s: R = 24
out_t = acc_latent @ W_v: R*D = 384
Total per (b,h):
Sum_t [384 + (t+1)*24 + (t+1)*24 + 384] = T*384*2 + 2*24 * sum_{t=0}^{T-1} (t+1) = 768*T + 48 * T*(T+1)/2.
For T=32: 768*32 + 48*528 = 24576 + 25344 = 49920 FLOPs.
Block approach:
Phase 1 (per s):
K_s = latent_s @ W_k: R*D = 384
V_s = latent_s @ W_v: R*D = 384
Phase 2 (per t):
for s=0..t:
dot = q_t · K_s: D = 16
softmax
for s=0..t:
out_t += attn_s * V_s: D = 16
Total per (b,h):
T*(384+384) + sum_{t=0}^{T-1} [ (t+1)*16 + (t+1)*16 ] = 768*T + 32 * T*(T+1)/2.
For T=32: 768*32 + 32*528 = 24576 + 16896 = 41472 FLOPs.
So block approach has ~17% fewer FLOPs. But it also requires __syncthreads and shared memory. Given small size, the difference is minor.
I think the per-thread approach is simpler and less risk of correctness issues with shared memory indexing. However, the block approach might be easier to vectorize. But for this problem, simplicity is key. Let's go with per-thread approach, but we can add `#pragma unroll` and process each query in a single thread.
But one potential issue: the kernel does `expf` inside loops. With t=32, it's fine.
We also need to consider numerical stability: the reference computes `torch.softmax(torch.stack(logits, dim=0), dim=0)`. PyTorch softmax uses the max subtraction for numerical stability. We should do the same.
Implementation details for per-thread kernel:
```cuda
__global__ void mla_attn_kernel(
const float* __restrict__ q,
const float* __restrict__ kv_latent,
const float* __restrict__ w_up_k,
const float* __restrict__ w_up_v,
float* __restrict__ out,
int B, int T, int H, int D, int R,
float scale
) {
int b = blockIdx.x;
int t = threadIdx.x;
int h = threadIdx.y;
if (b >= B || t >= T || h >= H) return;
// load q
int q_offset = ((b * T + t) * H + h) * D;
float q_vec[16]; // max head dim, we can use dynamic loop but static array is fine
#pragma unroll
for (int d = 0; d < D; ++d) {
q_vec[d] = q[q_offset + d];
}
// Precompute p_k = W_k_h^T? Wait: W_k is (R, H, D). p_k[r] = sum_d W_k[r,h,d] * q[d]
float p_k[64];
#pragma unroll
for (int r = 0; r < R; ++r) {
float sum = 0.0f;
const float* wk_ptr = w_up_k + ((r * H + h) * D);
#pragma unroll
for (int d = 0; d < D; ++d) {
sum += wk_ptr[d] * q_vec[d];
}
p_k[r] = sum;
}
// First pass: compute logits and max
float max_logit = -1e30f;
float logits[128]; // MAX_SEQ_LEN
#pragma unroll
for (int s = 0; s <= t; ++s) {
int lat_offset = (b * T + s) * R;
float dot = 0.0f;
#pragma unroll
for (int r = 0; r < R; ++r) {
dot += kv_latent[lat_offset + r] * p_k[r];
}
float logit = dot * scale;
logits[s] = logit;
if (logit > max_logit) max_logit = logit;
}
// Compute softmax denominator
float sum_exp = 0.0f;
#pragma unroll
for (int s = 0; s <= t; ++s) {
float val = expf(logits[s] - max_logit);
logits[s] = val;
sum_exp += val;
}
float inv_sum = 1.0f / sum_exp;
// Accumulate weighted latents
float acc_latent[64];
#pragma unroll
for (int r = 0; r < R; ++r) acc_latent[r] = 0.0f;
#pragma unroll
for (int s = 0; s <= t; ++s) {
int lat_offset = (b * T + s) * R;
float weight = logits[s] * inv_sum;
#pragma unroll
for (int r = 0; r < R; ++r) {
acc_latent[r] += kv_latent[lat_offset + r] * weight;
}
}
// Compute out = acc_latent @ W_v_h
int out_offset = ((b * T + t) * H + h) * D;
#pragma unroll
for (int d = 0; d < D; ++d) {
float sum = 0.0f;
#pragma unroll
for (int r = 0; r < R; ++r) {
sum += acc_latent[r] * w_up_v[((r * H + h) * D) + d];
}
out[out_offset + d] = sum;
}
}
```
Potential issues:
- `float logits[128];` initialization for all elements? We only use up to t. It's fine.
- Static arrays with sizes 64 and 128 might exceed register file if compiler decides to put them in registers. But with dynamic indexing (`s` loop variable), they will go to local memory (LR). That's okay. The total local memory per thread is small (128*4 + 64*4*3 + 16*4 = 512+768+64=1344 bytes). For 128 threads per block, that's ~172KB local memory. That might be a bit high if local memory is allocated per thread. Wait, local memory is allocated per thread in global memory. 172KB per block. For 2 blocks, total 344KB. That's fine. However, the compiler might try to optimize some arrays into registers. `q_vec` with loop over D=16 might be kept in registers if D is known? If D is passed as argument, dynamic indexing may cause spilling. But we can help by making D compile-time constant via template. Similarly for R and T. But we can also just not worry.
Alternatively, we can reduce local memory by not storing `logits` and `acc_latent` as arrays but compute in registers. `acc_latent` must be an array. `logits` we can avoid storing by using online softmax (no array needed). That saves 128 floats. Let's do online softmax to reduce local memory. It also saves one pass over stored logits.
Online softmax in first pass:
```
float max_logit = -1e30f;
float sum_exp = 0.0f;
for (int s=0; s<=t; ++s) {
dot = ...
logit = dot * scale;
if (logit > max_logit) {
sum_exp = sum_exp * expf(max_logit - logit) + 1.0f;
max_logit = logit;
} else {
sum_exp += expf(logit - max_logit);
}
}
```
Second pass: recompute dot and weight:
```
for (int s=0; s<=t; ++s) {
dot = ...
logit = dot * scale;
float weight = expf(logit - max_logit) / sum_exp;
acc_latent[r] += latent[r] * weight;
}
```
This avoids storing logits. It duplicates dot computation. But dot is cheap (rank=24). This is simpler and uses less local memory.
But we need to be careful: the second pass must recompute exactly the same dot as the first pass. Since the operations are the same and in the same order, it should produce identical results (bit-exact) because inputs are the same. However, floating point is deterministic, so yes, same inputs give same outputs. So we can recompute.
Let's adopt this approach.
We also need `acc_latent` array. Since R=24, we can declare `float acc_latent[24];` if we want to be safe. But we want generic. Let's allocate `float acc_latent[64];`. It's fine.
To reduce local memory further, we could compute the output on the fly without acc_latent: for each d, we need sum_r acc_latent[r] * W_v[r,d]. acc_latent = sum_s weight_s * latent_s. So out[d] = sum_r W_v[r,d] * sum_s weight_s * latent_s[r] = sum_s weight_s * sum_r latent_s[r] * W_v[r,d] = sum_s weight_s * v_s[d]. That's exactly the original formulation. So if we compute v_s on the fly in the second pass, we can accumulate directly into out_vec[d]. That eliminates acc_latent array entirely and the final matvec.
Let's see: In second pass, for each s:
weight = softmax weight.
For each d: out_acc[d] += weight * (latent_s @ W_v_h)[d].
Where latent_s @ W_v_h is v_s.
So we need to compute v_s = latent_s @ W_v_h for each s. That's a matvec of rank x head_dim. If we do this for each s, the cost per s is R*D = 24*16 = 384. In the previous approach, we computed acc_latent (R) for each s (cost R = 24), then final matvec (R*D = 384) once. So total cost:
- old: sum_s R + R*D = 768*24 + 384 = 18432 + 384 = 18816 per t? Wait per (b,h,t) it's (t+1)*24 + 384. Total per (b,h) = sum_t [(t+1)*24] + T*384 = 24*528 + 32*384 = 12672 + 12288 = 24960.
- new: sum_t sum_s R*D = D*R * sum_t (t+1) = 384 * 528 = 202752 per (b,h). That's 8x more FLOPs! So that's bad.
Thus, accumulating acc_latent then doing one matvec is much better.
But we can also compute v_s for each s and store it in shared memory (block approach) to reuse. But per-thread approach without shared memory doesn't allow that easily.
Alternatively, we can precompute p_v = W_v_h^T ??? Wait, v_s = latent_s @ W_v_h. out = sum_s weight_s * v_s. If we transpose, out = (sum_s weight_s * latent_s) @ W_v_h = acc_latent @ W_v_h. That's the best.
So we'll keep acc_latent.
Another optimization: Instead of `float acc_latent[64];`, we could have a fixed small array. Since R is at most maybe 64 or 128, it's fine.
We also need to consider tail cases: what if D=16, R=24, T=32, H=4. These are all small and divide nicely. We can write loops without `#pragma unroll` and trust the compiler. But adding `#pragma unroll` can help.
Let's write the kernel code with online softmax (no logits array). This reduces local memory.
Wait, there is a nuance: the reference does `torch.softmax(..., dim=0)` which uses the `max` for stability. Our online softmax also uses max. Numerically, it should match within tolerance. But we need to be careful about the order of operations. The reference stacks logits and calls PyTorch softmax, which computes max over the whole tensor, subtracts, exponentiates, sums, divides. Our per-thread online softmax does the same mathematically. The only difference is that we compute the dot products in a loop and then softmax. Should be fine.
One more thing: the reference inner loop `for s in range(t+1)` uses the same `latent` but freshly computes k and v each time. In our kernel for each thread (b,t,h), we loop over s=0..t. The order is same. The W_k and W_v are the same. So it's correct.
Potential numerical issues: using `expf` vs `exp`. `expf` is for float. Good.
Now, the kernel launch configuration. Since we want each thread to handle one (t,h) for a given b:
blockDim = (T, H, 1). gridDim = (B, 1, 1).
But T, H may not be known at compile time; they are passed as arguments. We can set `blockDim.x = T`, `blockDim.y = H`. But CUDA limits block size to 1024. Here T=32, H=4 => 128, safe.
However, `threadIdx.x` goes from 0..T-1, `threadIdx.y` from 0..H-1.
In the kernel, we write:
```
int b = blockIdx.x;
int t = threadIdx.x;
int h = threadIdx.y;
if (b >= B || t >= T || h >= H) return;
```
This works.
Now, the wrapper function in C++. We need a host function that takes `at::Tensor` arguments and launches the kernel. We need to check tensors are contiguous and on CUDA. But we can assume they are as per reference (`.cuda()` might not be used; the reference code doesn't show `.cuda()`, but the inputs may be on CPU? Wait, the reference implementation is pure PyTorch CPU. The evaluation likely runs on GPU? The problem says "custom CUDA kernel". Usually inputs are on GPU. But we should handle CPU tensors by moving to CUDA? The reference implementation works on CPU. However, the evaluation harness likely moves inputs to the device that the model is on. Since `ModelNew` will call a CUDA kernel, the model parameters are not defined (no parameters in init). The forward method receives tensors; if they are on CPU, calling a CUDA kernel will fail. But the harness might just call `model(*inputs)` where inputs are whatever `get_inputs()` returns. `get_inputs()` doesn't specify `.cuda()`. However, the evaluation for custom CUDA kernels usually ensures inputs are on CUDA device, or the kernel is expected to handle device placement. In many such challenges, the harness might run on CPU for reference and GPU for custom, but they probably move inputs to GPU before calling ModelNew. Let's check typical patterns: In the "kernel" tasks, `get_inputs()` returns CPU tensors, and the reference runs on CPU. The custom model is expected to work on the same device as inputs, but if it uses CUDA, it would need to move them. However, the evaluation script probably does something like:
```
model = ModelNew(*get_init_inputs()).cuda()
inputs = [x.cuda() for x in get_inputs()]
...
```
Or it might not. Since the reference Model has no parameters, the harness may not call `.cuda()` on the model. But the problem explicitly asks for a custom CUDA kernel. Usually they run the custom kernel on GPU while reference runs on CPU, and measure kernel time separately. The reference time might be CPU time. But speedup is computed as ratio of custom kernel time to reference time? Actually they measure both. If reference is on CPU, it might be much slower. That would make speedup huge. But they might also run reference on GPU using PyTorch operations? The reference code is a loop of small PyTorch ops. If inputs are on GPU, each op launches a CUDA kernel, but the Python loop overhead is still huge. If inputs are on CPU, the reference runs on CPU. In either case, our CUDA kernel will be much faster.
To be safe, we can handle both: if inputs are not on CUDA, we can compute on CPU using a PyTorch fallback or just move to CUDA and back. But we shouldn't write a fallback to the reference implementation because static check may flag it as reward hacking. The instruction says: "Do not use try/except fallbacks to the reference". But a device check fallback might be okay? It's safer to assume inputs are on CUDA, or we can move them to CUDA inside forward and move output back. For example:
```
def forward(self, q, kv_latent, w_up_k, w_up_v):
device = q.device
if device.type != 'cuda':
q = q.cuda()
kv_latent = kv_latent.cuda()
w_up_k = w_up_k.cuda()
w_up_v = w_up_v.cuda()
out = torch.empty_like(q)
mla_attn_kernel Launch...
if device.type != 'cuda':
out = out.cpu()
return out
```
This way it works regardless. It doesn't call the reference. It's fine.
However, moving to/from GPU adds overhead. But the inputs are likely already on GPU. So the overhead is negligible.
We should also check that the tensors are contiguous. The reference doesn't guarantee that, but `torch.randn` creates contiguous tensors. We can add `.contiguous()` just in case.
Now, writing the code.
We need to use `torch.utils.cpp_extension.load_inline` at module load time. The module-level code will compile the kernel. We need to define `ModelNew` class.
Let's write the Python file:
```python
import math
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>
#include <cmath>
__global__ void mla_attn_kernel(
const float* __restrict__ q,
const float* __restrict__ kv_latent,
const float* __restrict__ w_up_k,
const float* __restrict__ w_up_v,
float* __restrict__ out,
int B, int T, int H, int D, int R,
float scale
) {
int b = blockIdx.x;
int t = threadIdx.x;
int h = threadIdx.y;
if (b >= B || t >= T || h >= H) return;
// Load query vector into registers
int q_offset = ((b * T + t) * H + h) * D;
float q_vec[16];
#pragma unroll
for (int d = 0; d < D; ++d) {
q_vec[d] = q[q_offset + d];
}
// Precompute p_k = W_k_h @ q (size R)
float p_k[64];
#pragma unroll
for (int r = 0; r < R; ++r) {
float sum = 0.0f;
const float* wk_ptr = w_up_k + ((r * H + h) * D);
#pragma unroll
for (int d = 0; d < D; ++d) {
sum += wk_ptr[d] * q_vec[d];
}
p_k[r] = sum;
}
// First pass: online softmax to find max and sum_exp
float max_logit = -1e30f;
float sum_exp = 0.0f;
#pragma unroll
for (int s = 0; s <= t; ++s) {
int lat_offset = (b * T + s) * R;
float dot = 0.0f;
#pragma unroll
for (int r = 0; r < R; ++r) {
dot += kv_latent[lat_offset + r] * p_k[r];
}
float logit = dot * scale;
if (logit > max_logit) {
sum_exp = sum_exp * expf(max_logit - logit) + 1.0f;
max_logit = logit;
} else {
sum_exp += expf(logit - max_logit);
}
}
// Second pass: accumulate weighted latents
float acc_latent[64];
#pragma unroll
for (int r = 0; r < R; ++r) {
acc_latent[r] = 0.0f;
}
#pragma unroll
for (int s = 0; s <= t; ++s) {
int lat_offset = (b * T + s) * R;
float dot = 0.0f;
#pragma unroll
for (int r = 0; r < R; ++r) {
dot += kv_latent[lat_offset + r] * p_k[r];
}
float logit = dot * scale;
float weight = expf(logit - max_logit) / sum_exp;
#pragma unroll
for (int r = 0; r < R; ++r) {
acc_latent[r] += kv_latent[lat_offset + r] * weight;
}
}
// Final matvec: out = acc_latent @ W_v_h
int out_offset = ((b * T + t) * H + h) * D;
#pragma unroll
for (int d = 0; d < D; ++d) {
float sum = 0.0f;
#pragma unroll
for (int r = 0; r < R; ++r) {
sum += acc_latent[r] * w_up_v[((r * H + h) * D) + d];
}
out[out_offset + d] = sum;
}
}
void mla_attn_forward(
torch::Tensor q,
torch::Tensor kv_latent,
torch::Tensor w_up_k,
torch::Tensor w_up_v,
torch::Tensor out,
float scale
) {
int B = q.size(0);
int T = q.size(1);
int H = q.size(2);
int D = q.size(3);
int R = kv_latent.size(2);
const float* q_ptr = q.data_ptr<float>();
const float* lat_ptr = kv_latent.data_ptr<float>();
const float* wk_ptr = w_up_k.data_ptr<float>();
const float* wv_ptr = w_up_v.data_ptr<float>();
float* out_ptr = out.data_ptr<float>();
dim3 block(T, H);
dim3 grid(B);
mla_attn_kernel<<<grid, block>>>(
q_ptr, lat_ptr, wk_ptr, wv_ptr, out_ptr,
B, T, H, D, R, scale
);
}
'''
mla_attn = load_inline(
name='mla_attn',
cpp_sources='', # no extra C++ sources needed
cuda_sources=cuda_source,
functions=['mla_attn_forward'],
extra_cuda_cflags=['-O3', '--use_fast_math'],
verbose=False,
)
class ModelNew(nn.Module):
def __init__(self):
super().__init__()
def forward(self, q, kv_latent, w_up_k, w_up_v):
# Ensure inputs are contiguous float32 CUDA tensors
q = q.contiguous().float()
kv_latent = kv_latent.contiguous().float()
w_up_k = w_up_k.contiguous().float()
w_up_v = w_up_v.contiguous().float()
# Move to CUDA if not already
orig_device = q.device
if orig_device.type != 'cuda':
q = q.cuda()
kv_latent = kv_latent.cuda()
w_up_k = w_up_k.cuda()
w_up_v = w_up_v.cuda()
out = torch.empty_like(q)
scale = 1.0 / math.sqrt(q.shape[-1])
mla_attn.mla_attn_forward(q, kv_latent, w_up_k, w_up_v, out, scale)
if orig_device.type != 'cuda':
out = out.to(orig_device)
return out
```
Wait, `load_inline` with `cpp_sources=''` might be okay. But we need to include `<torch/extension.h>` in the CUDA source. That's fine.
One issue: `block` dimension `dim3 block(T, H);` where T and H are runtime values. This is fine.
Another issue: static arrays `float q_vec[16];` etc. If D > 16, this overflows. We should use a larger maximum, or allocate dynamically. Since the problem sizes are fixed, but to be generic we can allocate `float q_vec[64]; float p_k[256]; float acc_latent[256];`. But large local arrays might increase register pressure or local memory. However D and R are small. We can allocate up to 256 to be safe. Let's use a maximum known constant like 128 or 256. But if D is small, only a few elements are used.
Actually, we can use dynamic shared memory or local memory by doing:
```
float q_vec[64];
```
The compiler won't complain as long as we only access up to D. It will allocate space for 64 floats. For D=16, it's fine.
But what if the evaluation uses different sizes (e.g., head_dim=64)? 64 is okay. We can set max to 128.
Better: we can avoid fixed-size arrays for q_vec by loading directly from global memory in the inner loops. For p_k computation, we can accumulate without storing q_vec. But we need q_vec later? Actually p_k uses q_vec in a dot product with W_k rows. We could just read q[d] inside the inner loop. That is:
```
for (int r=0; r<R; ++r) {
float sum = 0.0f;
int wk_base = ((r * H + h) * D);
for (int d=0; d<D; ++d) {
sum += w_up_k[wk_base + d] * q[q_offset + d];
}
p_k[r] = sum;
}
```
This reuses q from global memory. Since q is coalesced and likely cached, it's fine. It eliminates q_vec array. Similarly for final output, we can compute per d directly without storing out_vec.
For acc_latent, we still need the array.
For p_k, we need the array.
So arrays needed: `p_k[maxR]`, `acc_latent[maxR]`.
If we don't store logits array, we don't need it.
So we can have:
```
#define MAX_R 256
#define MAX_D 256
```
But using large static arrays as local variables can cause stack overflow if the compiler maps them to local memory, but local memory is large. Actually local memory is backed by global memory and allocation is done per thread. 256 floats = 1KB per thread. For 128 threads = 128KB per block. That's okay.
However, to be safe and generic, we could allocate `float p_k[128];` and `float acc_latent[128];`. Since the actual sizes are likely small, it's fine.
But there is another way: we can use dynamic shared memory per thread? No.
Let's keep fixed arrays of size 128 for p_k and acc_latent, and maybe 128 for q_vec if we want. But we might not need q_vec.
Let's think about whether not having q_vec in registers hurts performance. Reading q[d] repeatedly in the r loop means each iteration reads D elements from q. For R=24, that's 24*16 = 384 reads from q. With q_vec in registers, we read 16 times from global memory and then 384 reads from registers. That's faster. So better to load q into a local array. Even if it's in local memory, it's cached L1. But registers are better. To encourage the compiler to put q_vec in registers, we can make the array size a compile-time constant that matches the actual D. But D is dynamic. However, if we write `float q_vec[64];` and the loop is `for (int d=0; d<16; ++d)`, the compiler might keep it in registers if the index is bounded and unrolled. But with a dynamic D passed as argument, it's less likely. However, `#pragma unroll` might help if the compiler knows D is small. But it doesn't know the exact value.
We could template the kernel on D and R. Since D and R are known at runtime but are the same for all calls, we can instantiate the kernel with the actual dimensions inside the wrapper. But templating in `load_inline` is tricky because the CUDA source is compiled once. We can use C++ templates in the CUDA source and then have the wrapper do something like:
```
if (D == 16 && R == 24) {
kernel<16,24><<<grid,block>>>(...);
} else {
generic_kernel<<<grid,block>>>(...);
}
```
This is possible. That would allow the compiler to unroll loops and keep arrays in registers for the common case. But it adds complexity.
Given the small problem, it's probably overkill. But to maximize performance, we can do it.
Let's consider: The kernel runtime is likely <100 microseconds. The overhead of `load_inline` compilation is at module import time, not runtime. The kernel itself... we want it fast.
I think a generic kernel with loops and `#pragma unroll` is fine.
One more thing: the `cpp_sources` argument in `load_inline`. We have a C++ wrapper `mla_attn_forward`. We need to write it in the CUDA source? The `cuda_sources` argument is used for .cu files. The wrapper function can be in `cuda_sources` because it uses torch types. Actually `load_inline` compiles `cpp_sources` as `.cpp` and `cuda_sources` as `.cu`. The wrapper function `mla_attn_forward` can be placed in `cuda_sources` (or `cpp_sources`). It's fine in `cuda_sources` since it includes `<torch/extension.h>`.
Now, check correctness numerically: we need to match reference within 1e-4. Our kernel uses float32. The order of operations:
- p_k = W_k @ q. We do row-wise: sum_d W_k[r,d] * q[d].
- dot = sum_r latent[r] * p_k[r].
- Softmax: online algorithm. Should be stable.
- acc_latent = sum_s weight * latent_s.
- out[d] = sum_r acc_latent[r] * W_v[r,d].
Reference:
- k = latent @ W_k (i.e., sum_r latent[r] * W_k[r,:]).
- dot = sum_d q[d] * k[d] = sum_d q[d] * sum_r latent[r] * W_k[r,d] = sum_r latent[r] * sum_d W_k[r,d] * q[d] = sum_r latent[r] * p_k[r].
So dot matches exactly (same associativity? The reference computes k first as sum_r latent[r] * W_k[r,d] for each d, then dot = sum_d q[d] * k[d]. Our method computes p_k[r] = sum_d W_k[r,d] * q[d] then dot = sum_r latent[r] * p_k[r]. Mathematically identical. In floating point, the order of summation differs. The reference sums over r then d: for each d, it does sum_r latent_r * W_k_{r,d}. That's a length-R sum. Then dot sums over d: sum_d q_d * (sum_r ...). Our method sums over d first: p_k[r] = sum_d W_{r,d} * q_d (length-D sum). Then dot = sum_r latent_r * p_k[r] (length-R sum). Since addition is not associative, there could be a slight difference, but within 1e-4 for fp32. Both are sums of up to 24 or 16 terms of moderate magnitude, error is tiny.
For values:
- Reference: v_s = latent_s @ W_v (length-R sum per d). Then out = sum_s attn_s * v_s (sum over s per d).
- Our method: acc_latent = sum_s attn_s * latent_s (sum over s per r). Then out[d] = sum_r acc_latent[r] * W_v[r,d] (sum over r per d).
Again, different order: reference does (sum_r latent_r * W_v_{r,d}) then weighted sum over s. Ours does weighted sum over s of latent_r, then dot with W_v. The difference is also small.
But we need to ensure we don't use `sum_exp = 0; ...; inv_sum = 1/sum_exp; weight = expf(logit - max_logit) * inv_sum;`. The reference PyTorch softmax may use a different implementation (e.g., using `max` subtraction, then `exp`, then `sum`, then divide). Our online softmax yields mathematically same max and sum. The weight computation is identical. However, we recompute the dot product in the second pass. The recomputed dot might differ slightly from the first pass if there were rounding in p_k? Wait, p_k is computed once. The dot = sum_r latent_r * p_k_r involves a sum of R terms with same p_k and latent. If we do it twice with the same operations, the result should be bitwise identical because same inputs, same order. So first pass and second pass compute identical dot. Good.
Then weight = expf(dot*scale - max_logit) / sum_exp. The first pass computes max_logit and sum_exp based on these dots. The second pass uses the same max_logit and sum_exp. Weight is computed consistently. So softmax is accurate.
One more nuance: `expf(logit - max_logit)` in the first pass: when logit == max_logit, we compute `expf(0) = 1.0f`. In the online update when logit > max_logit (or >=), we do `sum_exp = sum_exp * expf(max_logit - logit) + 1.0f`. If logit == max_logit (which happens for the first element if we initialize max_logit = -inf? Actually for s=0, max_logit = -inf, logit > max_logit, so sum_exp = sum_exp * expf(-inf - logit) + 1.0f = sum_exp * 0 + 1 = 1.0f. Good. For later s with logit == max_logit, the else branch `sum_exp += expf(0) = sum_exp + 1`. That's correct. But if we use `if (logit > max_logit)` and then else, it's fine. But what if due to rounding the recomputed dot in the second pass is slightly different from the max_logit computed in the first pass? We established they should be identical, but let's consider the case where max_logit was set to some logit from first pass, and in second pass the recomputed dot for that same s is the same. So `expf(logit - max_logit)` = 1 exactly. For other s, it's also identical. So fine.
Potential issue: `max_logit` initialized to `-1e30f`. If all logits are smaller than that (e.g., -1e31), it fails. But logits are dot products of vectors with std ~1, scale ~0.25, so logits are modest. -1e30 is safe. But to be safer, we can use `-CUDART_INF_F` or `-INFINITY`. We can include `<math_constants.h>` or just use `-1e20f`. Actually we can use `-std::numeric_limits<float>::infinity()`. But in CUDA kernel, we can use `__int_as_float(0xff800000)` or just `-1e20f`. `-1e30f` is fine. But to be robust, we can compute max_logit by storing the first logit and then comparing. However, online softmax with max_logit = -inf is standard. We can set:
```
float max_logit = -CUDART_INF;
```
But need to include `<math_constants.h>`. Or just set:
```
float max_logit = -1e20f;
```
I think `-1e20f` is safe enough. Actually it's possible for float to be -1e20, but it's fine.
Another nuance: `expf(max_logit - logit)` when max_logit = -1e20 and logit is finite, `expf(-1e20 - finite)` underflows to 0. That's fine. But for the first logit, we want sum_exp = 1. If max_logit = -1e20, and logit = 0.5, then `sum_exp * expf(-1e20 - 0.5)` = `0 * 0` = 0. Then +1.0f = 1.0f. So fine. But if max_logit is -inf, `expf(-inf)` is 0. But -1e20 is close enough. Actually, maybe we should just do a two-pass with stored logits to avoid any online softmax edge cases. Given that memory is cheap, I'll store the logits in a small local array. Wait, but local memory might be slow? 32 floats is fine. Let's just store them. It's simpler and less error prone.
Let's go back to storing logits in a small array. But we want to keep array sizes moderate.
```cuda
float lbuf[128];
```
But wait, the compiler might complain if t is larger than 128. We can set it to 128 or 256. For safety, maybe 1024? That would be 4KB local memory per thread, which is okay but maybe heavy. Since seq_len is 32, 64 or 128 is plenty.
But we can also use dynamic allocation: `float lbuf[64];` and if T > 64, we fallback? The reference says seq_len=32. So 64 is safe. Let's do 64 or 128.
I think 128 is safe.
But there is a better way: we can store the dot products (before scaling) and scale later. Or store the logits. It doesn't matter.
Let's use `float logit_buf[128];`.
First pass:
```
float max_logit = -1e20f;
#pragma unroll
for (int s = 0; s <= t; ++s) {
int lat_offset = (b * T + s) * R;
float dot = 0.0f;
#pragma unroll
for (int r = 0; r < R; ++r) {
dot += kv_latent[lat_offset + r] * p_k[r];
}
float logit = dot * scale;
logit_buf[s] = logit;
if (logit > max_logit) max_logit = logit;
}
float sum_exp = 0.0f;
#pragma unroll
for (int s = 0; s <= t; ++s) {
float val = expf(logit_buf[s] - max_logit);
logit_buf[s] = val;
sum_exp += val;
}
float inv_sum = 1.0f / sum_exp;
...
```
This is clean. The `logit_buf` is only 128 floats = 512 bytes per thread. Local memory. Fine.
But we can shave off local memory by using online softmax. However, with 128 floats, it's fine.
Wait, what if the compiler chooses to put `logit_buf` in registers? It depends. If the loop bound `t` is dynamic and the array size is 128, it will likely be placed in local memory. Accessing local memory is cached (L1), and since the array is small and accessed sequentially, it's fast.
Actually, we can reduce the array size to `MAX_T = 64` or maybe `MAX_T = 128`. Let's define `MAX_T` as a constant. Since T=32, we can set 64.
But to maximize performance, we might want to avoid local memory entirely. Let's consider online softmax again, without storing logits. It only needs `max_logit` and `sum_exp`. Then in the second pass, we recompute `dot` and compute weight. Recomputing dot is cheap. This eliminates the `logit_buf` array. Total local memory: `p_k[64]`, `acc_latent[64]`. That's 512 bytes per thread. Very small. This is better.
Let's adopt online softmax with recomputation. We'll do:
First pass: compute max_logit and sum_exp.
Second pass: recompute dot, compute weight, accumulate acc_latent.
Third pass: compute out[d] using acc_latent and W_v.
Wait, third pass is just the final loop over d. That's fine.
This is actually very clean.
But is recomputing dot in second pass slower? As we computed, dot = sum_r latent_r * p_k_r, which is rank=24 multiplications and 23 additions. For t up to 31, we do at most 32*24 ≈ 768 FLOPs extra per query. The first pass already did 768 FLOPs. So total dot FLOPs double to ~1536. Previously with stored logits, first pass did 768, second pass did 768 latent accumulations, and final out did 384. Total ~2600 FLOPs. Now with recomputation, it's 768 (pass1) + 768 (pass2 latent accum) + 768 (pass2 dot recomputation) + 384 (final) = ~2688. So the difference is only 768 FLOPs per query, or ~30% extra. With the tiny problem size, it's negligible. But it saves local memory. I think it's fine.
Actually, we can be smarter: In the second pass, we need weight = expf(logit - max_logit) / sum_exp. We can recompute logit and then compute weight. That's what we said.
But what about performance of `expf` in the second pass? We already call expf in the first pass. In the second pass, we call expf again for each s. That's at most 32 expf calls per query. No big deal.
So the kernel would be:
```cuda
__global__ void mla_attn_kernel(...) {
int b = blockIdx.x;
int t = threadIdx.x;
int h = threadIdx.y;
if (b >= B || t >= T || h >= H) return;
int q_offset = ((b * T + t) * H + h) * D;
// Load q into local array to reuse
float q_reg[64];
for (int d = 0; d < D; ++d) q_reg[d] = q[q_offset + d];
// Precompute p_k = W_k_h @ q_reg (size R)
float p_k[256];
for (int r = 0; r < R; ++r) {
float sum = 0.0f;
const float* wk = w_up_k + ((r * H + h) * D);
for (int d = 0; d < D; ++d) {
sum += wk[d] * q_reg[d];
}
p_k[r] = sum;
}
// Pass 1: online softmax over s=0..t
float max_logit = -1e30f;
float sum_exp = 0.0f;
for (int s = 0; s <= t; ++s) {
int lat_offset = (b * T + s) * R;
float dot = 0.0f;
for (int r = 0; r < R; ++r) {
dot += kv_latent[lat_offset + r] * p_k[r];
}
float logit = dot * scale;
if (logit > max_logit) {
sum_exp = sum_exp * expf(max_logit - logit) + 1.0f;
max_logit = logit;
} else {
sum_exp += expf(logit - max_logit);
}
}
// Pass 2: accumulate weighted latents
float acc_latent[256];
for (int r = 0; r < R; ++r) acc_latent[r] = 0.0f;
for (int s = 0; s <= t; ++s) {
int lat_offset = (b * T + s) * R;
float dot = 0.0f;
for (int r = 0; r < R; ++r) {
dot += kv_latent[lat_offset + r] * p_k[r];
}
float logit = dot * scale;
float weight = expf(logit - max_logit) * (1.0f / sum_exp);
for (int r = 0; r < R; ++r) {
acc_latent[r] += kv_latent[lat_offset + r] * weight;
}
}
// Compute output
int out_offset = ((b * T + t) * H + h) * D;
for (int d = 0; d < D; ++d) {
float sum = 0.0f;
for (int r = 0; r < R; ++r) {
sum += acc_latent[r] * w_up_v[((r * H + h) * D) + d];
}
out[out_offset + d] = sum;
}
}
```
This kernel is correct and simple.
One concern: `expf(max_logit - logit)` when max_logit is very negative and logit is also very negative? max_logit gets updated to the largest logit. When logit > max_logit, max_logit is the previous max, which could be -inf initially. With -1e30f, `expf(-1e30f - finite)` underflows to 0, sum_exp becomes 0 * 0 + 1 = 1. That's fine. But if logit is `-1e20` and max_logit is `-1e30`, `expf(-1e30 - (-1e20)) = expf(-1e10)` underflows to 0. sum_exp becomes 0 + 1 = 1. Good. So initialization is fine.
But what if all logits are less than `-1e30`? That's impossible for normal data. So fine.
Now, we need to ensure we don't accidentally use `std::numeric_limits<float>::infinity()` which may not be available in device code. We can use `CUDART_INF` or `INFINITY`. Let's just use `-1e20f` for max_logit. Actually, `-1e20f` might be smaller than some large negative logits? Unlikely for these dimensions. To be safe, we can compute max_logit by initializing with the first logit and then looping from s=1..t. That avoids any arbitrary constant.
```
// Compute dot for s=0
int lat_offset = (b * T + 0) * R;
float dot = 0.0f;
for (int r=0; r<R; ++r) dot += ...;
float max_logit = dot * scale;
float sum_exp = 1.0f;
for (int s=1; s<=t; ++s) {
...
float logit = dot * scale;
if (logit > max_logit) {
sum_exp = sum_exp * expf(max_logit - logit) + 1.0f;
max_logit = logit;
} else {
sum_exp += expf(logit - max_logit);
}
}
```
But this special-casing s=0 adds a branch. Since t can be 0, we need to handle that. We can do:
```
float max_logit = -CUDART_INF_F;
float sum_exp = 0.0f;
for (int s=0; s<=t; ++s) {
...
if (logit > max_logit) {
sum_exp = sum_exp * expf(max_logit - logit) + 1.0f;
max_logit = logit;
} else {
sum_exp += expf(logit - max_logit);
}
}
```
We need to define `CUDART_INF_F`. We can include `<math_constants.h>` and use `CUDART_INF_F`. Or we can just use `-INFINITY` which works in device code with `<cmath>`? Actually `INFINITY` is a macro from `<math.h>` and works in device code. Let's use `-CUDART_INF_F` if we include `<math_constants.h>`. Or simpler:
```
float max_logit = -1e20f;
```
Given data scale, it's safe. But if we want to be super safe, we can compute the max_logit in a small two-step: first compute the first logit, set max_logit to it, sum_exp=1.0f, then loop s=1..t. If t<0 (i.e., t=0), the loop doesn't execute. This is simple:
```
float max_logit = 0.0f;
float sum_exp = 0.0f;
for (int s = 0; s <= t; ++s) {
int lat_offset = (b * T + s) * R;
float dot = 0.0f;
for (int r = 0; r < R; ++r) dot += kv_latent[lat_offset + r] * p_k[r];
float logit = dot * scale;
if (s == 0) {
max_logit = logit;
sum_exp = 1.0f;
} else if (logit > max_logit) {
sum_exp = sum_exp * expf(max_logit - logit) + 1.0f;
max_logit = logit;
} else {
sum_exp += expf(logit - max_logit);
}
}
```
This avoids the initial arbitrary constant and handles t=0 correctly. Note that `s` is a loop variable, compiler might not like the `if (s==0)` inside the loop if it can't optimize. But t is small, so overhead is tiny. We can also split:
```
float max_logit;
float sum_exp;
if (t >= 0) {
int lat_offset = (b * T + 0) * R;
float dot = 0.0f;
for (int r=0; r<R; ++r) dot += kv_latent[lat_offset + r] * p_k[r];
max_logit = dot * scale;
sum_exp = 1.0f;
}
for (int s=1; s<=t; ++s) {
...
}
```
But this introduces code duplication. The `s==0` branch is fine.
Actually, there is a subtle numerical issue: soft max of a single element (t=0) should be 1. Our code: s=0, max_logit=logit, sum_exp=1.0f. Then second pass: s=0, weight = expf(logit - max_logit) / sum_exp = 1.0f / 1.0f = 1.0f. Correct.
One more thing: `expf` with `logit - max_logit` when logit is the max and due to rounding might be a tiny negative number instead of zero. That's fine; `expf(small negative)` is slightly less than 1. The sum_exp includes that. It should be accurate.
Alternatively, we can store all logits in a small array and use standard max-sum-divide. That might be even simpler and less branching. Let's do that. The local memory for 32 floats is negligible. And standard softmax is straightforward.
I'll go with storing logits. Let's revisit:
```cuda
float logit_buf[128];
float max_logit = -1e20f;
for (int s=0; s<=t; ++s) {
float dot = ...;
float logit = dot * scale;
logit_buf[s] = logit;
if (logit > max_logit) max_logit = logit;
}
float sum_exp = 0.0f;
for (int s=0; s<=t; ++s) {
float v = expf(logit_buf[s] - max_logit);
logit_buf[s] = v;
sum_exp += v;
}
float inv_sum = 1.0f / sum_exp;
// second pass
for (int s=0; s<=t; ++s) {
float weight = logit_buf[s] * inv_sum;
...
}
```
This is extremely clear and correct. `logit_buf[128]` uses 512 bytes local memory. Acceptable.
Given the small scale, I think clarity is better.
Wait, but can we avoid using `logit_buf` by computing expf on the fly in the second pass and using the stored max_logit? We can recompute dot. That eliminates the buffer. Let's compute the extra cost. For T=32, recomputing dot for 32 elements is 768 FLOPs. With 256 threads, that's 196k FLOPs. On a modern GPU capable of TFLOPs, that's ~0.2 ms? Actually 196k FLOPs at 10 TFLOPS is 0.02 microseconds. So recomputation cost is absolutely negligible. So we can safely recompute and avoid the buffer. This also avoids reading from local memory.
Actually, we can even combine the second pass: compute weight and accumulate acc_latent in one pass, recomputing dot. That means we compute dot twice per s (once in first pass, once in second pass). Total dot compute: 2 * rank * (t+1). For rank=24, t+1=32 avg 16, that's 768 FLOPs per query. The first pass also includes expf calls. Overall still tiny.
So I'll go with no buffer, recompute dot in second pass. This minimizes local memory and makes the kernel cleaner.
Wait, but then why not just compute max_logit in first pass, then second pass recompute dot and accumulate? That's what we have. Let's write it.
Wait, there is a subtle optimization: we can compute `max_logit` and also store the dot products in a register array if we unroll the loop. But with dynamic t, unrolling is difficult. So we recompute.
One more thing: when t is dynamic, loops `for (int s=0; s<=t; ++s)` can't be fully unrolled by the compiler. But t varies per thread. That's okay.
Now, let's think about thread divergence. In a warp (32 threads with same h, t=0..31), the loop `s<=t` means thread 0 loops once, thread 31 loops 32 times. This causes divergence within the warp: threads with smaller t finish their loops early and wait for threads with larger t. This is fine; the warp will execute the loop iterations up to the maximum t (31), with inactive threads masked out. So there is some wasted cycles, but unavoidable for causal mask. The first pass for the warp will take time proportional to 32 iterations * rank=24 * (some threads active). That's okay.
Could we process the attention differently to avoid divergence? For example, each warp could process a fixed t, but then we have fewer threads. Actually, the mapping we have is natural.
Given everything, the kernel should be extremely fast.
Now, we need to ensure the host wrapper checks tensor types and devices. In the C++ wrapper, we can just call `.data_ptr<float>()`. We should ensure the tensors are float and contiguous.
In `ModelNew.forward`:
```python
def forward(self, q, kv_latent, w_up_k, w_up_v):
q = q.contiguous().float()
kv_latent = kv_latent.contiguous().float()
w_up_k = w_up_k.contiguous().float()
w_up_v = w_up_v.contiguous().float()
orig_device = q.device
if orig_device.type != 'cuda':
q = q.cuda()
kv_latent = kv_latent.cuda()
w_up_k = w_up_k.cuda()
w_up_v = w_up_v.cuda()
out = torch.empty_like(q)
scale = 1.0 / math.sqrt(q.shape[-1])
mla_attn.mla_attn_forward(q, kv_latent, w_up_k, w_up_v, out, scale)
if orig_device.type != 'cuda':
out = out.to(orig_device)
return out
```
We need to make sure `out` is on the same device as inputs when we call the kernel. We create `out = torch.empty_like(q)`. If q is on CUDA, out is on CUDA. If q was moved to CUDA, out is on CUDA. Good.
One subtle point: if q is on CPU and we move it to CUDA, `q.shape[-1]` is still correct. `math.sqrt` works.
Now, do we need to add `torch.cuda.synchronize()`? No, the kernel launch is asynchronous, but PyTorch operations will synchronize when needed. The wrapper `.mla_attn_forward` is a pybind call that launches the kernel and returns. It should be fine.
But we need to make sure we included the correct headers. `#include <torch/extension.h>` includes ATen and pybind. `#include <cuda_runtime.h>` is implicit but good to include. `#include <math.h>` for `expf`.
Also, we need to ensure the wrapper function signature matches what pybind expects. We define `void mla_attn_forward(...)` with `torch::Tensor` arguments. That's fine. `load_inline` will generate a pybind wrapper for it.
Now, compiling: we need to pass `extra_cuda_cflags=['-O3', '--use_fast_math']`. `-O3` is good. `--use_fast_math` makes `expf` faster but slightly less accurate. For tolerance 1e-4, it should be fine. Actually, `--use_fast_math` can affect precision more than that? Usually `expf` with fast math is accurate enough for 1e-4. The reference is also using PyTorch's softmax which likely uses similar approximations? PyTorch uses `exp` from libm or vectorized versions. Tolerance is 1e-4 for fp32, so fast math should be okay. But to be safe, we can omit `--use_fast_math`. However, it might help speed. Let's include it; if correctness fails, we can remove it.
Wait, `load_inline` may default to some flags. We can specify `extra_cuda_cflags=['-O3']`. Let's include `-O3` only for safety. Actually, `--use_fast_math` should be fine. I'll include it.
One more thing: block size. `dim3 block(T, H);` where T=32, H=4. If T is larger than 1024, block size exceeds. But T is small.
Let's write the final code. But before that, we need to test compile and correctness.
Let me draft the complete Python source.
```python
import math
import torch
import torch.nn as nn
from torch.utils.cpp_extension import load_inline
# CUDA kernel source
cuda_source = r'''
#include <torch/extension.h>
#include <cuda_runtime.h>
#include <math.h>
__global__ void mla_attn_kernel(
const float* __restrict__ q,
const float* __restrict__ kv_latent,
const float* __restrict__ w_up_k,
const float* __restrict__ w_up_v,
float* __restrict__ out,
int B, int T, int H, int D, int R,
float scale
) {
int b = blockIdx.x;
int t = threadIdx.x;
int h = threadIdx.y;
if (b >= B || t >= T || h >= H) return;
// Load query into registers
int q_offset = ((b * T + t) * H + h) * D;
float q_reg[64];
#pragma unroll
for (int d = 0; d < D; ++d) {
q_reg[d] = q[q_offset + d];
}
// Precompute p_k = W_k_h @ q (size R)
float p_k[256];
#pragma unroll
for (int r = 0; r < R; ++r) {
float sum = 0.0f;
const float* wk_ptr = w_up_k + ((r * H + h) * D);
#pragma unroll
for (int d = 0; d < D; ++d) {
sum += wk_ptr[d] * q_reg[d];
}
p_k[r] = sum;
}
// First pass: online softmax to compute max and sum of exponents
float max_logit = -1e20f;
float sum_exp = 0.0f;
for (int s = 0; s <= t; ++s) {
int lat_offset = (b * T + s) * R;
float dot = 0.0f;
#pragma unroll
for (int r = 0; r < R; ++r) {
dot += kv_latent[lat_offset + r] * p_k[r];
}
float logit = dot * scale;
if (logit > max_logit) {
sum_exp = sum_exp * expf(max_logit - logit) + 1.0f;
max_logit = logit;
} else {
sum_exp += expf(logit - max_logit);
}
}
// Second pass: accumulate weighted latents
float acc_latent[256];
#pragma unroll
for (int r = 0; r < R; ++r) {
acc_latent[r] = 0.0f;
}
for (int s = 0; s <= t; ++s) {
int lat_offset = (b * T + s) * R;
float dot = 0.0f;
#pragma unroll
for (int r = 0; r < R; ++r) {
dot += kv_latent[lat_offset + r] * p_k[r];
}
float logit = dot * scale;
float weight = expf(logit - max_logit) / sum_exp;
#pragma unroll
for (int r = 0; r < R; ++r) {
acc_latent[r] += kv_latent[lat_offset + r] * weight;
}
}
// Compute output = acc_latent @ W_v_h
int out_offset = ((b * T + t) * H + h) * D;
#pragma unroll
for (int d = 0; d < D; ++d) {
float sum = 0.0f;
#pragma unroll
for (int r = 0; r < R; ++r) {
sum += acc_latent[r] * w_up_v[((r * H + h) * D) + d];
}
out[out_offset + d] = sum;
}
}
void mla_attn_forward(
torch::Tensor q,
torch::Tensor kv_latent,
torch::Tensor w_up_k,
torch::Tensor w_up_v,
torch::Tensor out,
float scale
) {
int B = q.size(0);
int T = q.size(1);
int H = q.size(2);
int D = q.size(3);
int R = kv_latent.size(2);
const float* q_ptr = q.data_ptr<float>();
const float* lat_ptr = kv_latent.data_ptr<float>();
const float* wk_ptr = w_up_k.data_ptr<float>();
const float* wv_ptr = w_up_v.data_ptr<float>();
float* out_ptr = out.data_ptr<float>();
dim3 block(T, H);
dim3 grid(B);
mla_attn_kernel<<<grid, block>>>(
q_ptr, lat_ptr, wk_ptr, wv_ptr, out_ptr,
B, T, H, D, R, scale
);
}
'''
mla_attn = load_inline(
name='mla_attn',
cpp_sources='',
cuda_sources=cuda_source,
functions=['mla_attn_forward'],
extra_cuda_cflags=['-O3', '--use_fast_math'],
verbose=False,
)
class ModelNew(nn.Module):
def __init__(self):
super().__init__()
def forward(self, q, kv_latent, w_up_k, w_up_v):
q = q.contiguous().float()
kv_latent = kv_latent.contiguous().float()
w_up_k = w_up_k.contiguous().float()
w_up_v = w_up_v.contiguous().float()
orig_device = q.device
if orig_device.type != 'cuda':
q = q.cuda()
kv_latent = kv_latent.cuda()
w_up_k = w_up_k.cuda()
w_up_v = w_up_v.cuda()
out = torch.empty_like(q)
scale = 1.0 / math.sqrt(q.shape[-1])
mla_attn.mla_attn_forward(q, kv_latent, w_up_k, w_up_v, out, scale)
if orig_device.type != 'cuda':
out = out.to(orig_device)
return out
```
I need to consider whether `load_inline` will work with `cpp_sources=''` (empty string). Usually yes, but sometimes they expect it to be omitted. Actually, the argument is optional? The signature is `load_inline(name, cpp_sources, cuda_sources, functions, ...)`. If we don't have cpp_sources, we can pass `None` or just pass it as empty string. Let me check typical usage:
```python
from torch.utils.cpp_extension import load_inline
custom_op = load_inline(
name="custom_op",
cpp_sources="""...""",
cuda_sources="""...""",
functions=["foo"],
)
```
We have only CUDA code, but the wrapper is C++ code that uses torch types. It can be placed in `cuda_sources` because `.cu` files can contain host code. So `cpp_sources` can be empty. I think passing `cpp_sources=''` is fine.
But wait, the `functions` list must contain the names of the host-side wrapper functions (the ones we want to bind). That's `mla_attn_forward`. Good.
Now, compile it.
But before that, I realize there is a potential issue with `q_reg[64]` and `p_k[256]` being large arrays in registers/local memory. If the compiler puts them in local memory, it's okay. But if it tries to put them in registers, 256 floats is a lot. For `p_k` of size 256, with R=24 only 24 used. But the compiler doesn't know that D and R are small. If we declare `float p_k[256]`, the compiler may allocate 256 registers or spill to local memory. 256 registers per thread is too many; max is 255 on many GPUs. Actually, the compiler can allocate up to 255 registers per thread. An array of 256 floats would likely be placed in local memory anyway because indexing is dynamic. But to be safe, we can declare smaller max arrays: `float p_k[128]; float acc_latent[128]; float q_reg[64];`. That should be fine. Let's change to 64 or 128.
Given D=16, R=24, T=32, we can set:
```cuda
float q_reg[64];
float p_k[128];
float acc_latent[128];
```
This is safe for typical sizes and keeps local memory usage modest.
Alternatively, we can allocate dynamically with `float* p_k = (float*)alloca(R * sizeof(float));` but `alloca` is not supported in CUDA device code.
So static arrays with upper bounds are the way.
Let's set max to 128 for p_k and acc_latent, and 64 for q_reg. 128 floats = 512 bytes.
Actually, we could even set them to `256` if we want. The compiler will likely put them in local memory. The total per thread is ~64*4 + 256*4 + 256*4 = 2304 bytes. For 128 threads = 294KB per block. That might be okay, but let's keep it smaller. 64 for q_reg, 128 for p_k and acc_latent. That's ~1KB per thread, 128KB per block. Fine.
But wait, if hidden tests have R > 128? The problem statement says rank=24. If they test with larger rank, our kernel will break. We can set max to 256 or 512 to be safe. Let's set to 256. The local memory per thread will be ~256*4*2 + 64*4 = 2.3KB. For 128 threads: ~300KB. That's okay. Most GPUs have plenty of local memory space.
Alternatively, we could write a fully generic kernel that uses no local arrays for p_k/acc_latent by computing everything on the fly? No, we need them.
Let's set to 512 to be extremely safe. But then local memory is ~4KB per thread. 128 threads => 512KB. Still okay. But the compiler might have issues. Let's stick with 256. It's a good balance.
Actually, there is a better way: for p_k, we can compute it on the fly inside the s loop? No, we need it for every s.
For q_reg, we can read from global memory each time. q_reg saves D reads. D is small. So we can drop q_reg and read directly from q.
Similarly, we can drop p_k and compute k on the fly for each s: k_s[d] = sum_r latent_s[r] * W_k[r,h,d]. Then dot with q. That's the reference approach. But that requires a temporary k vector of size D. We can compute dot directly as sum_r latent_s[r] * p_k[r]. So we need p_k. Keep p_k.
Let's just keep q_reg and p_k with modest sizes.
So final kernel arrays:
```cuda
float q_reg[64];
float p_k[256];
float acc_latent[256];
```
I think 256 is safe.
Wait, I just realized: if we declare `float p_k[256];` and then loop `for (int r=0; r<R; ++r)`, but `R` is passed as argument, the compiler may not know that R <= 256. It will generate dynamic bounds checks? Not in CUDA. It will just generate a loop. The array is allocated in local memory. Accesses are via local memory offsets. Since the loop uses R, if R > 256, we would have undefined behavior. So we need to be sure about max R. The problem says rank=24. If hidden tests keep similar sizes, 256 is huge margin. Let's set to 256.
But maybe hidden tests have head_dim up to 64 and rank up to 64. So 256 is fine.
We also need to ensure the `logit_buf` is not used. Good.
Now, compile and test.
But before compiling, check the code for syntax errors.
- `__global__ void mla_attn_kernel(...)` parameters fine.
- `int b = blockIdx.x;` etc.
- `q_reg` load loop.
- `p_k` compute loop.
- Online softmax: `max_logit = -1e20f;`
- `sum_exp = sum_exp * expf(max_logit - logit) + 1.0f;`
When logit > max_logit. If max_logit is -1e20f and sum_exp is 0, for the first element: sum_exp = 0 * expf(-1e20f - logit) + 1.0f. `expf(very large negative)` underflows to 0.0f. So sum_exp = 1.0f. Good.
- For subsequent elements where logit > max_logit: sum_exp = old_sum * expf(old_max - new_max) + 1.0f. This is standard.
- For logit <= max_logit: sum_exp += expf(logit - max_logit). Good.
- Second pass: recompute dot, weight = expf(logit - max_logit) / sum_exp. Good.
- Accumulate acc_latent.
- Final output loop: `w_up_v[((r * H + h) * D) + d]`. Let's verify indexing:
w_up_v shape is (R, H, D). Flattened as r*H*D + h*D + d. So `((r * H + h) * D) + d` is correct.
- `out_offset = ((b * T + t) * H + h) * D`. Correct.
- `q_offset = ((b * T + t) * H + h) * D`. Correct.
- `lat_offset = (b * T + s) * R`. Correct, since kv_latent shape is (B, T, R).
Looks good.
Now, one more detail: `kv_latent` is accessed in the inner loop over r. The stride between r elements is 1 (contiguous). Good.
`w_up_k` access in p_k computation: `wk_ptr[d]`. Since W_k is (R, H, D), for fixed r and h, the d dimension is contiguous. So `wk_ptr[d]` is contiguous. Good.
`w_up_v` access in final output: `w_up_v[((r * H + h) * D) + d]`. For fixed r, h, d varies contiguously. Good.
`q` and `out` access: d varies contiguously. Good.
Memory access patterns are coalesced across threads in a warp for d loops: for a fixed b,t,h, threads in a warp have different h or t. Wait, threads in a warp have same h (if y varies slower) and different t=x. When loading q, each thread loads its own q[b,t,h,:]. The d loop loads contiguous memory for each thread. But threads in the warp load different addresses (different t). However, since the innermost dimension D is small (16), and threads with consecutive t are separated by H*D = 4*16 = 64 floats = 256 bytes. So adjacent threads in a warp (t differs by 1) load q memory separated by 256 bytes. That's not coalesced per float, but if each thread loads a float4 (4 floats), the addresses might be spread out. However, with only 32 threads and small data, cache lines will cover it.
Similarly for `p_k` computation: each thread computes its own p_k, reading W_k. Threads with same h but different t read the same W_k (since h is same within warp). So W_k reads are broadcast and highly cached.
For kv_latent access in the s loops: threads with different t access different s ranges. Not much coalescing. But data is small.
Overall, kernel is fine.
Now, let's compile and test.
Wait, we need to make sure `load_inline` doesn't fail. The source string includes raw `"""`? We used `r'''...'''`. That should be fine. No need to escape quotes.
Let's call `compile_kernel`.
But before that, I need to provide the complete Python source as one string to the tool.
I will write the code.
Potential issue: `load_inline` may compile at module import time. If there is a syntax error, it will raise at import. `compile_kernel` essentially imports the module. So any error will be caught.
Let's proceed.
One more consideration: I used `cpp_sources=''`. In some versions of PyTorch, `load_inline` expects at least one of `cpp_sources` or `cuda_sources` to be non-empty. `cuda_sources` is non-empty. So okay.
Another consideration: `functions=['mla_attn_forward']`. The host function must be declared with `extern "C"`? No, pybind11 can bind C++ functions. `load_inline` auto-generates a pybind11 module and wraps the listed functions. The functions should be in the global namespace and have non-mangled names? Actually pybind11 uses the C++ name as-is. The auto-generated binding uses `m.def("mla_attn_forward", &mla_attn_forward, ...);`. So it needs the C++ name to be `mla_attn_forward`. That's what we have. No `extern "C"` needed because pybind11 expects C++ linkage for function overloading etc.
So it's fine.
Let's compile.