reasoning
We need to write a custom CUDA kernel for the forward pass of the Invariant Point Attention (IPA) block. The model is the reference implementation.
First, let's understand the operation thoroughly.
Given:
- node_repr: (B, N, node_dim)
- pair_repr: (B, N, N, pair_dim)
- translations: (B, N, 3)
- rotations: (B, N, 3, 3)
Inside forward:
1. Layer norm on node_repr.
2. Linear projections: q, k, v -> (B, N, h*d) then reshape to (B, N, h, d)
3. Point linear projections: q_pts_local, k_pts_local, v_pts_local -> (B, N, h*Qp*3), etc.
4. Apply frame (global transform) to points.
- For each i: global = local * R_i + t_i
- Wait, the einsum in _apply_frame: "bnpc,bncd->bnpd". Let's check.
pts: (B, N, P, 3). rotations: (B, N, 3, 3).
einsum: b n p c , b n c d -> b n p d
This means: for each b,n: global_pts[b,n,p,d] = sum_c pts[b,n,p,c] * rotations[b,n,c,d].
So it's multiplying the row vector (1x3) of pts by the rotation matrix (3x3). Standard: pt * R.
Then + translations.unsqueeze(2). So yes, global = local * R + t.
5. Reshape points to include heads.
6. attn_scalar = einsum("bihd,bjhd->bhij", q, k) / sqrt(d)
- q: (B, N_i, h, d), k: (B, N_j, h, d)
- output: (B, h, N_i, N_j)
7. Point attention distance.
- q_expand = q_pts.unsqueeze(2) -> (B, N, 1, h, Qp, 3)
- k_expand = k_pts.unsqueeze(1) -> (B, 1, N, h, Qp, 3)
- diff = q_expand - k_expand -> (B, N, N, h, Qp, 3)
- squared_diff = diff^2 -> (B, N, N, h, Qp, 3)
- sum over last two dims (Qp and 3). So pt_dist: sum_{p=1..Qp} sum_{c=1..3} (q_pts[b,i,h,p,c] - k_pts[b,j,h,p,c])^2
- This is (B, N, N, h).
- Note: the heads dimension is 'h' inside. Permute to (B, h, N, N).
8. w = softplus(head_weights). shape (h,). Then w.view(1,1,1,h).
- pt_attn = -(w * pt_dist) / 2.0. shape (B, N, N, h) -> permute to (B, h, N, N).
- Wait, reference does pt_attn = -(w.view(1,1,1,h) * pt_dist) / 2.0. Then permute(0,3,1,2).
9. pair_bias = pair_bias(pair_repr). -> (B, N, N, h). Then permute(0,3,1,2) -> (B, h, N, N).
10. attn = attn_scalar + pt_attn + pair_bias. (B, h, N, N)
attn = softmax(attn, dim=-1).
11. out_scalar = einsum("bhij,bjhd->bihd", attn, v). reshape to (B, N, h*d).
12. out_pts = einsum("bhij,bjhpc->bihpc", attn, v_pts).
- attn: (B, h, N_i, N_j)
- v_pts: (B, N_j, h, Vp, 3)
- output: (B, N_i, h, Vp, 3)
Then reshape (B, N, h*Vp, 3).
Apply inverse frame: centered = out_pts - t.unsqueeze(2). Then local = centered * R^T (since einsum is "bnpc,bndc->bnpd").
Let's verify _apply_inv_frame:
centered = pts - translations.unsqueeze(2)
local_pts = einsum("bnpc,bndc->bnpd", centered, rotations)
This is: local[b,n,p,d] = sum_c centered[b,n,p,c] * rotations[b,n,d,c].
Since rotations[b,n] is orthogonal (3x3), rotations[b,n,d,c] is the transpose of rotations[b,n,c,d].
So local = centered * R^T. Correct.
out_pts_flat = out_pts_local.reshape(B, N, h*Vp*3).
13. Concatenate out_scalar and out_pts_flat along last dim. out_proj.
The goal is to write a CUDA kernel that does this fused.
Since the model is small-ish (batch=2, seq_len=32, node_dim=64, etc.), we might get decent speedup by fusing, but we need to be careful about generality: the kernel should work for arbitrary dimensions, but in the test, B=2, N=32.
However, the evaluation might test on different sizes? Usually the benchmark uses these sizes, but sometimes hidden sizes vary. The kernel must be functionally correct for the described dimensions (node_dim, pair_dim, etc.) and arbitrary B, N. Let's make it fairly general, but we can specialize the loops in shared memory or registers.
Key challenges:
- Many small matrix multiplications (linear layers). We can keep using PyTorch matmuls for the linear projections? The user said: "Replace the forward computation ... with a custom CUDA kernel." Does that mean the entire forward must be in one kernel? Usually we can do some parts in CUDA and some in PyTorch, but for maximum speedup, we should fuse as much as possible. However, writing a fully fused kernel that includes all linear layers, layer norm, einsums, etc., is very complex. But given the small problem size, launching many small PyTorch ops might be overhead. Fusing the attention part (including frame transforms, attention logits, softmax, aggregation, inverse frame) into a single kernel might give good speedup.
But we can also choose to keep linear layers in PyTorch (since they are efficient cuBLAS calls) and fuse the "IPA body" into a custom kernel. Let's analyze where time is spent.
With B=2, N=32, node_dim=64, h=4, d=16, Qp=4, Vp=8, pair_dim=32.
- q_proj: (2,32,64) x (64, 256) -> small, fast.
- q_pts: (2,32,64) x (64, 4*4*3=192) -> small.
- Similar for k, v.
- pair_bias: (2,32,32,32) x (32,4) -> (2,32,32,4). Very small.
- Frame transforms: einsum on (2,32, P, 3). P up to h*Qp=16 for q/k or h*Vp=32 for v. This is also small but many ops.
The heavy part is likely the attention:
- attn_scalar: einsum over d=16: (2,4,32,32). That's 2*4*32*32*16 = 131k mult-adds.
- pt_dist: diff, square, sum over 4 points * 3 coords = 12 terms. For each b,h,i,j. That's 2*4*32*32*12 = 98k ops.
- Softmax over j=32.
- Aggregation: out_scalar: 2*4*32*32*16 = 131k.
- out_pts aggregation: 2*4*32*32*8*3 = 196k.
Overall, this is tiny. A fully fused kernel might not be much faster than PyTorch if PyTorch overhead dominates? Actually for such small tensors, PyTorch kernel launch overhead for ~15 ops might be non-negligible. But the real bottleneck might be memory bandwidth.
However, we need to produce a speedup faster than reference. Let's think of a strategy.
One approach: write a single custom CUDA kernel that takes `x` (after layer norm), `pair_repr`, `rotations`, `translations`, and all weight matrices, and computes everything inside. But this is massive: we need to handle all projections inside the kernel. That means we need to pass many weight tensors.
Alternative: keep PyTorch for linear projections, but fuse everything from "apply frame" to "inverse frame + concat" into one kernel. That is, the kernel inputs are:
- q, k, v: (B, N, h, d)
- q_pts_local, k_pts_local, v_pts_local: (B, N, h*Qp, 3) etc.
- pair_bias: (B, N, N, h)
- rotations, translations
Outputs:
- out: (B, N, h*d + h*Vp*3)
Then call out_proj in PyTorch.
But even this fused kernel would need to:
1. Transform q_pts_local, k_pts_local, v_pts_local to global frame.
2. Compute attn_scalar.
3. Compute pt_dist.
4. Add pair_bias, compute softmax.
5. Aggregate scalar and point values.
6. Inverse transform point values.
Given N=32, B=2, h=4, we could have a thread block handle one (b, h) pair and compute the full N x N attention matrix and aggregation. N=32 is small, so each block could process one head for one batch element. Total blocks = B * h = 8. That's very few blocks for a GPU. We'd want more parallelism.
Alternatively, each block could handle one (b, i) pair, where i is the query index. Then each block computes attention over j for all heads. Or each warp could handle one head.
Let's design a kernel.
Kernel inputs:
- q (B, N, h, d) -> float*
- k (B, N, h, d)
- v (B, N, h, d)
- q_pts_local (B, N, h*Qp, 3)
- k_pts_local (B, N, h*Qp, 3)
- v_pts_local (B, N, h*Vp, 3)
- pair_bias (B, N, N, h)
- rotations (B, N, 3, 3)
- translations (B, N, 3)
We can compute on the fly:
For each b, i, h:
- Load q_i into registers.
- Load q_pts_local_i, apply frame to get q_pts_global_i.
- For each j from 0..N-1:
- Load k_j, compute dot product.
- Load k_pts_local_j, apply frame.
- Compute pt_dist between q_pts_i and k_pts_j: sum_{p=1..Qp} ||q_pts_i[p] - k_pts_j[p]||^2
- Wait: q_pts and k_pts have shape (h, Qp, 3), but the einsum computes diff across Qp points? Let's re-read.
q_pts: (B, N_i, h, Qp, 3)
k_pts: (B, N_j, h, Qp, 3)
diff: (B, N_i, N_j, h, Qp, 3)
sum over -1 (3) and -2 (Qp). So it sums across the Qp dimension as well? Wait.
q_pts has Qp points, k_pts has Qp points. The operation is elementwise subtraction across Qp. So for each point index p, we subtract the p-th query point from the p-th key point? Yes, because there's no separate dimension for key points; they share the Qp dimension. So it's `q_pts[b,i,h,p,c] - k_pts[b,j,h,p,c]`. Then sum over p and c.
So it's sum_{p=1}^{Qp} || q_{i,p} - k_{j,p} ||^2.
- Load pair_bias[b,i,j,h].
- Compute attn_logit.
- Store in a local array or shared memory for softmax.
- After looping j, compute softmax over j for this head.
- Then loop j again to aggregate v_j and v_pts_j.
- load attn_weight.
- accumulate scalar: out_scalar += attn * v_j
- accumulate points: out_pts += attn * v_pts_j (for each Vp)
- After aggregation, apply inverse frame to out_pts.
- Write out_scalar and out_pts_flat.
Given N=32, we can store the attention logits and weights for one (b, h, i) in shared memory or registers. 32 floats is tiny.
If we have one block per (b, i), and inside the block each thread handles one head h. B=2, N=32 -> 64 blocks. h=4. If each block has 4 threads? That's too small. Better to have each warp handle one head. Or each block handle all heads for one (b, i). Let's say blockDim.x = 4 * 32 = 128 threads (4 heads * 32 warpsize? No). Let's think about occupancy.
N is small. If blockDim.x = h = 4, that's too small. We can parallelize across h and some dimensions of d or Vp. But the attention softmax over j couples all j.
Alternatively, have each block handle one (b, h). Then threads within block parallelize over i or j. B*h = 8 blocks. Still too few. We need more blocks for GPU utilization.
Better: one block per (b, i). Then there are B*N = 64 blocks. In each block, we have threads that handle all h and maybe some point dimensions. 64 blocks is okay but still small. On an A100, we have 108 SMs. 64 blocks means some SMs are idle. But each block can have many threads to fill warps.
If each block handles all h and all dimensions for that query i. Let's say blockDim.x = h * warpSize = 4*32 = 128? But the work is small. Many threads will be idle or doing redundant loads.
Another idea: block per (b, h, tile_i). But N is only 32, so tile_i could be 1. Could tile_j to reduce memory loads? Not really necessary.
Given the tiny N, the kernel is memory latency bound. We need to coalesce loads and avoid overhead.
One approach: use a single thread per (b, h, i). Since N=32, B=2, h=4, there are 256 threads total. We could launch a 1D grid of 256 threads, or a 2D grid (B*N, h). This is extremely lightweight. 256 threads is far from enough to saturate GPU, but the whole op is tiny. The reference implementation also uses small tensors. If PyTorch launches many kernels, each with many threads, the overhead of many launches might be larger than one custom kernel with 256 threads.
But wait, PyTorch's einsum for attn_scalar might use a GEMM with good parallelism. For size (8, 32) x (32, 32) i.e., B*h=8 heads, N=32, d=16. That's a batched GEMM of 8 matrices of 32x16 and 32x16 -> 32x32. cuBLAS handles this very efficiently with many threads. So PyTorch is actually highly parallel inside each op. Our custom kernel with 256 threads might actually be slower unless we also launch more threads.
Could we fuse into a kernel that uses many threads?
- For computing attention, we could have each warp handle one (b, h, i) but use warp-level primitives to compute across j, or have each thread handle one j.
- If each block handles one (b, h, i) and has 32 threads, each thread computes the logit for one j. Then we do a warp-level softmax (using __shfl_sync). Then each thread accumulates its v_j.
- This gives 64 blocks * 32 threads = 2048 threads. Better.
Let's design it like this:
- Grid: (B, N, h) -> total blocks = B*N*h = 2*32*4 = 256 blocks.
- Block: 32 threads (one warp) for j dimension. Actually we need 32 threads for j=0..31.
- In each block (b, i, h):
- Thread tx handles j = tx.
- Load q[b, i, h, :], v[b, j, h, :], etc.
- Compute logit_j:
- scalar_dot = dot(q_i, k_j) / sqrt(d)
- pt_dist = sum_{p=0}^{Qp-1} sum_{c=0}^{2} (q_pts_i[p,c] - k_pts_j[p,c])^2
- pair_bias = pair_bias[b, i, j, h]
- logit_j = scalar_dot + pair_bias - (w[h] * pt_dist) / 2.0
- Store logit_j in registers or shared mem.
- Warp-level softmax: find max logit, subtract, exp, sum. Then divide.
- Then compute contributions:
- scalar: out_scalar_i += attn_j * v_j
- point: out_pts_i += attn_j * v_pts_j (for each of Vp points, 3 coords)
- After loop over j, we have accumulated out_scalar_i (d floats) and out_pts_i (Vp*3 floats).
- Then apply inverse frame to out_pts_i.
- Write out_scalar_i to output[b, i, h*d ...] and out_pts_i_flat to output[b, i, h*Vp*3 ...].
Wait, but each block writes to the same output row i for head h. Since each block handles one head, there is no write conflict. Good.
Shared memory needed:
- For softmax, we need the logits for all j. With 32 threads, each thread holds one logit in a register: `float logit = ...`. We can do warp-level max and sum using shfl.
- For accumulation, each thread can accumulate its own partial? No, each thread handles one j and can do the multiply-add into a local register? Actually the aggregation is sum over j of attn_j * v_j. We can have each thread multiply its local attn_j by v_j and then do a warp-level sum across threads for each dimension of v_j.
- `float val = attn_j * v_j[d_idx];` then `__shfl_down_sync` sum across j for each d_idx.
- Since d=16 and Vp*3=24, we need to sum 16 + 24 = 40 values per warp. Each of the 32 threads can help, but the data is distributed.
- Alternatively, after computing attn, do a second loop over j where thread 0 loads v_0 and adds? But each thread has its own j, they can just do `atomicAdd` to shared memory accumulators, or write to shared memory and then reduce.
- Better: Use shared memory for accumulation. size (`d + Vp*3`) floats per head. d=16, Vp=3*8=24, total 40 floats. For 32 threads, we can just accumulate into shared memory with atomicAdd (fast for 32 threads) OR each thread writes to a shared array and then do a parallel reduction.
- Actually, with 32 threads, atomicAdd to 40 floats is trivial and conflict-free if each thread accesses different addresses? No, all threads will atomicAdd to the same 40 addresses, causing serialization. Not good.
- Better: each thread computes `attn_j * v_j` for its j and stores in registers. Then we need to sum across j. We can do a tree reduction in shared memory.
- For each dimension of v (size 16), we can do:
- Load v_j[d] into local reg, multiply by attn_j.
- Write to shared_mem[threadIdx.x] = val.
- __syncwarp(); then reduce shared_mem[0..31] to sum.
- Then thread 0 writes to output.
- We need to do this for each of the 16 dimensions of scalar and 24 dimensions of points. 40 iterations of warp reduction. Each warp reduction of 32 floats is ~5 steps of shfl. Total ~200 shfls. That's fine.
But we can be smarter: each thread computes a partial vector of size (d + Vp*3) = 40 in registers, but we only need to reduce them across the warp. Since there are 32 threads, the total register usage would be high if each thread holds 40 floats: 40 * 32 = 1280 floats per warp = 5KB. That's okay if each block has one warp. We can store the partial accumulation in registers or shared memory.
Let's do shared memory accumulation with `float shmem[32];` for each dimension? Or better, a shared memory block of size `40 * 32`? That would be 1280 floats = 5KB per block. With 256 blocks, total 1.25MB, which is fine. But we can do it simpler.
Actually, let's not overcomplicate. Since N is small, we can have each thread loop over j from 0 to N-1 and compute everything sequentially for a given (b, i, h). This means each block has multiple threads handling different (b, i, h) or maybe multiple threads cooperating on one (b, i, h) but looping j. If we assign one thread per (b, i, h), total threads = B*N*h = 256. That's 8 warps. Not enough to hide latency.
But if we assign multiple threads per (b, i, h), say 32 threads per (b,i,h) that each handle one j, we get 256*32 = 8192 threads. That's much better.
Let's define the kernel:
```cuda
__global__ void ipa_forward(
const float* q, const float* k, const float* v,
const float* q_pts_local, const float* k_pts_local, const float* v_pts_local,
const float* pair_bias,
const float* rotations, const float* translations,
const float* head_weights,
float* out_scalar, float* out_pts_local_flat,
int B, int N, int h, int d, int Qp, int Vp, int pair_dim) {
```
But we want to output the concatenated result. Or two separate buffers then concat in PyTorch? Or we can just output a buffer of size (B, N, h*d + h*Vp*3). That's easiest.
Wait, the `out_pts_local_flat` after inverse frame is (B, N, h*Vp*3). And out_scalar is (B, N, h*d). We can compute both and write to contiguous memory.
Given the projection weights for `out_proj` are in PyTorch, maybe we should just output the concatenated tensor `out` of shape (B, N, out_features) and then apply `out_proj` in PyTorch. Or we can apply `out_proj` inside the kernel too for extra speed? That would require passing the weight matrix (out_features x node_dim) or transposed. But out_proj is a linear layer. Applying it inside the kernel might be nice: each (b,i) would compute a vector-matrix product. But we already have many threads.
Let's keep it simple: kernel computes everything up to the concatenated pre-projection output, then PyTorch does the final linear.
Kernel signature:
Inputs:
- q: (B, N, h, d)
- k: (B, N, h, d)
- v: (B, N, h, d)
- q_pts: (B, N, h, Qp, 3) [Wait, should we pass local or global? Better to pass local and transform inside kernel.]
Actually, we can avoid passing local and global separately. Pass q_pts_local, rotations, translations. Same for k and v. But inside the kernel, each thread needs k_j and q_i. If we transform q_i and k_j on the fly:
- To transform one point set (Qp points) to global: for each p, c: global[p,c] = sum_c2 local[p,c2] * R[c2, c] + t[c]. Or wait, original einsum: bn p c, bn c d -> bn p d. So `global[p,d] = sum_c local[p,c] * R[c,d]`. That's `local @ R`.
- In code: for p in 0..Qp-1, c in 0..2:
float sum = 0;
for c2=0..2: sum += local[p,c2] * R[c2,c];
global[p,c] = sum + t[c];
Qp max is 4, small.
So on-the-fly transform is cheap.
Inside the kernel per (b, i, h):
- Load q_local = q[b, i, h, :] into registers (d floats).
- Load q_pts_local for this b,i,h into registers: Qp*3 floats.
- Compute q_pts_global = q_pts_local @ R_i + t_i.
- For each j (0..N-1):
- Load k_local = k[b, j, h, :].
- Compute dot product.
- Load k_pts_local for b,j,h, compute k_pts_global.
- Compute pt_dist.
- Load pair_bias.
- Compute attn_logit_j.
- Store.
Softmax over j=32.
- Then compute out_scalar_i = sum_j attn_j * v_j.
- Compute out_pts_global_i = sum_j attn_j * v_pts_global_j.
- Apply inverse frame to out_pts_global_i to get local.
- Write to output.
If one thread handles one (b,i,h), that's 256 threads. Each loops over j=32. That's fine. But occupancy is low. We could have each warp (32 threads) handle one (b,i,h) and each thread one j. That is better parallelism and we might want to use warp-level reduction.
I think for performance with such small N, having many threads cooperate is better. Let's do:
- blockDim.x = 32 (threads per block, one per j).
- gridDim.x = B * N * h (one block per query-head).
So total threads = B * N * h * 32 = 8192.
Each block is one warp? Actually 32 threads is one warp. Perfect.
But then we have 256 blocks, each with one warp. 256 warps total. On A100, that's maybe 1/4 of a wave. Still might be okay. We can increase blockDim.x to 64 or 128, but then handling j would need to be modulo-based. But 32 is natural for N=32.
Wait, N is variable (seq_len). The problem description says `seq_len = 32`, but the kernel should be general. If N can be arbitrary, using blockDim.x = N might be okay if N is small, but if N is large, we might want to tile. However, in AlphaFold IPA, N is the number of residues, typically up to a few hundred (e.g., 384). But the benchmark uses 32. The evaluation might use different sizes? We should write a generic kernel but optimize for N up to maybe 384 or 512.
If N is larger, using one thread per j with blockDim=N might launch many threads but if N=384, blockDim=384, which is okay. But softmax over 384 with 384 threads is good.
However, the problem says "Replace the forward computation ... with a custom CUDA kernel". It might be evaluated with the exact sizes shown, or with a range of sizes. Usually these benchmarks test with the given sizes, but sometimes with others. Let's assume we need correctness for arbitrary B, N, and the parameters.
But to maximize speed, we can write a kernel that is generic.
Let's analyze the memory layout.
Input tensors from PyTorch are contiguous? Not necessarily, but we can assume standard row-major.
- q shape (B, N, h, d). stride (N*h*d, h*d, d, 1).
- q_pts_local shape (B, N, h*Qp, 3). stride (N*h*Qp*3, h*Qp*3, 3, 1) if contiguous.
- pair_bias shape (B, N, N, h). stride (N*N*h, N*h, h, 1).
- rotations shape (B, N, 3, 3). stride (N*9, 9, 3, 1).
If we write the kernel assuming contiguous layout, it might fail if not contiguous. But PyTorch `.view()` preserves contiguity if possible. In the reference, after reshape, the q, k, v are views of (B, N, h*d) which is contiguous. q_pts_local is view of (B, N, h*Qp*3) which is contiguous. So we can pass `.contiguous()` if needed, but the tensors are likely contiguous. However, to be safe, we can pass strides to the kernel and use general indexing. But that adds overhead.
Given the complexity, maybe we can write the kernel assuming contiguous layout and call `.contiguous()` on inputs if needed. But `.contiguous()` might cause a copy. Let's instead write general indexing with strides to be safe.
But indexing with strides in CUDA is extra integer muls, but for small sizes it's fine.
Alternatively, we can write the kernel to accept standard strides for the first dimensions and contiguous inner dimensions. For example, the innermost dimensions of q are (d) and contiguous. But actually q is (B, N, h, d). We can compute index = ((b * N + n) * h + h_idx) * d + d_idx. This assumes the view is contiguous in the last three dims combined, which is true if the original tensor is contiguous. Since q_proj produces a contiguous (B, N, h*d) and view doesn't change strides, the strides are (N*h*d, h*d, d, 1). So indexing as `((b * N + n) * h + h) * d + d_idx` works.
Similarly:
- q_pts_local: (B, N, h*Qp, 3) -> `((b * N + n) * (h*Qp) + p) * 3 + c`.
- pair_bias: (B, N, N, h) -> `((b * N + i) * N + j) * h + h_idx`. Wait, shape is (B, N, N, h). After linear, it's (B, N, N, h) from permute? Actually `pair_bias(pair_repr)` yields (B, N, N, h) because `pair_repr` is (B,N,N,pair_dim) and linear maps pair_dim->h. Then `.permute(0,3,1,2)` yields (B,h,N,N). In our kernel, we can either pass the permuted version or pass the original and index accordingly. It might be easier to compute `pair_bias` in PyTorch and then maybe permute to (B,N,N,h) or (B,h,N,N). Let's think.
If we compute `pair_bias` in PyTorch: `pb = self.pair_bias(pair_repr) # (B,N,N,h)`, then the kernel can index `pb[b,i,j,h]`.
Alternatively, the kernel can index `pb[b,h,i,j]` if we pass the permuted version.
Since the kernel is custom, we can pass `pair_bias` in any layout. Let's compute it in PyTorch and pass it as a contiguous tensor of shape (B, h, N, N) or (B, N, N, h). Permute is cheap (view) if contiguous? Actually `.permute(0,3,1,2)` makes it non-contiguous in dimension 3. But we can make it contiguous with `.contiguous()`. That might cause a copy of size B*N*N*h = 2*32*32*4 = 8k floats = 32KB. Negligible. So we can do `.permute(0,3,1,2).contiguous()` if we need. But better to avoid extra copy.
Alternatively, inside the kernel we can compute index for permuted layout: `((b * h + h_idx) * N + i) * N + j`. If we pass the tensor after permute (non-contiguous), this formula is invalid because strides are not standard. But if we call `.contiguous()`, it is valid.
Let's just call `.contiguous()` on the small tensors before passing to kernel. The overhead is tiny.
Similarly, for rotations and translations, they are contiguous.
So the plan:
1. In ModelNew.forward, compute the linear projections and layer norm in PyTorch.
- `x = self.layer_norm(node_repr)`
- `q = self.q_proj(x).view(B, N, h, d)`
- `k = self.k_proj(x).view(B, N, h, d)`
- `v = self.v_proj(x).view(B, N, h, d)`
- `q_pts_local = self.q_pts(x).view(B, N, h*Qp, 3)`
- `k_pts_local = self.k_pts(x).view(B, N, h*Qp, 3)`
- `v_pts_local = self.v_pts(x).view(B, N, h*Vp, 3)`
- `pair_bias = self.pair_bias(pair_repr).permute(0,3,1,2).contiguous() # (B, h, N, N)`
Wait, if we permute to (B,h,N,N) and make contiguous, then inside the kernel we can index it as `((b * h + h_idx) * N + i) * N + j`.
2. Pass all these, plus rotations, translations, head_weights to the CUDA kernel.
3. The kernel computes the concatenated output of shape (B, N, h*d + h*Vp*3).
4. Then apply `self.out_proj` in PyTorch.
Now, let's write the kernel.
We'll have a kernel `ipa_forward_kernel`.
Parameters:
- `const float* q` (B, N, h, d)
- `const float* k`
- `const float* v`
- `const float* q_pts_local` (B, N, h*Qp, 3)
- `const float* k_pts_local`
- `const float* v_pts_local`
- `const float* pair_bias` (B, h, N, N)
- `const float* rotations` (B, N, 3, 3)
- `const float* translations` (B, N, 3)
- `const float* head_weights` (h,)
- `float* out` (B, N, h*d + h*Vp*3)
- ints: B, N, h, d, Qp, Vp
Grid: dim3 blocks(B, N, h); // 3D grid
Threads: dim3 threads(N); // N threads per block, one for each key j
Wait, if N=384, 384 threads per block is okay (<1024). If N is larger, maybe >1024? Unlikely for residue sequences (max ~2000). But if N > 1024, we can't launch that many threads per block. We could do blockDim = 128 or 256 and have each thread handle multiple j. But for now, we can set blockDim to something like 128 and use a loop: for (int j = threadIdx.x; j < N; j += blockDim.x). But for small N, blockDim=N is fine.
Actually, gridDim.z might be limited. Let's check common limits: gridDim.z <= 64 on some GPUs? On A100, max grid size z is 64? Actually for A100, max grid dimensions are 2^31-1 for x, 65535 for y, 65535 for z. Wait, no, CUDA limits: x <= 2^31-1, y <= 65535, z <= 65535. So z = h = 4 is fine.
But we can also flatten the grid: grid size = B * N * h, block size = block_size (e.g., 128), and compute b, i, h from blockIdx.x. That might be more flexible. Let's flatten. `int total_blocks = B * N * h;` `blockIdx.x` ranges over them. Each block corresponds to one (b, i, h_idx).
Inside block:
- threads = some constant, e.g., `constexpr int block_size = 128;`
- Loop `for (int j = threadIdx.x; j < N; j += block_size)`.
- Each thread handles a subset of j.
- We need to compute softmax over all j. With block_size > N, some threads idle.
But for N=32, block_size=32 is ideal. Let's choose block_size = 32 (one warp) or 64. Since N is variable, using a fixed block size and loop is better. However, warp-level primitives require a full warp. If block_size is 128 and N=32, only first 32 threads are active. That's okay. We can use `__syncwarp()` on the mask of active threads? Or just shared memory reduction.
Actually, using block-level reduction with shared memory is robust and works for any block size and N.
Let's do block size = 128 (or maybe 256). Flattened grid.
Design per block (b, i, h_idx):
1. Load rotations[b,i] (3x3) and translations[b,i] (3) into shared memory (or registers for thread 0). Actually, all threads need R_i and t_i for transforming q_pts and for inverse transform of output. Load into shared mem (12 floats). Similarly, load q_i (d floats), q_pts_local_i (h*Qp*3 floats? Wait, q_pts_local is (B, N, h*Qp, 3). For a specific (b,i), all heads' points are stored. But our block is for a specific head `h_idx`. So we need only q_pts_local for head `h_idx`? Let's check shape.
`q_pts_local` after view: (B, N, h * Qp, 3). So the points for head `h_idx` are at indices `h_idx*Qp` to `(h_idx+1)*Qp - 1`.
So we need Qp * 3 = 12 floats. Good.
Similarly for k_pts and v_pts, but those are per j and loaded on the fly.
2. For each thread, loop over j with stride block_size.
- Compute scalar logit:
- Load k_j (d floats). Since each thread loads its own j's k_j into registers (or local mem). d=16, fits in registers.
- Compute dot(q_i, k_j). Use loop.
- Compute point distance:
- Load k_pts_j for this head (Qp*3 floats). Compute global transform: `k_pts_global = k_pts_local_j @ R_j + t_j`.
Wait, we need R_j and t_j. We can load them on the fly or pre-load? Pre-loading all R_j and t_j for all j is too much. Load on the fly per j. Since each j is processed by a thread, each thread loads R_j (9 floats) and t_j (3 floats) and k_pts_local_j (12 floats) from global memory. That's fine. Then compute k_pts_global.
- We also need q_pts_global_i. Compute once per block: `q_pts_global_i = q_pts_local_i @ R_i + t_i`. Qp*3 floats.
- Compute pt_dist = sum_{p=0}^{Qp-1} sum_{c=0}^{2} (q_pts_global_i[p,c] - k_pts_global[p,c])^2.
- Load pair_bias[b, h_idx, i, j]. Since pair_bias is (B, h, N, N), it's `((b*h + h_idx)*N + i)*N + j`.
- Compute `logit = scalar_dot/sqrt(d) + pair_bias - (w_h * pt_dist) / 2.0`.
- Save `logit` and `exp_logit` later.
We need to do softmax:
- First pass: compute max logit across all j.
- Each thread tracks local max over its j's.
- Then block-level reduction to find global max. Store in shared mem.
- Second pass: compute exp(logit - max) for each j. Each thread computes sum of exps over its j's.
- Then block-level reduction to find global sum.
- Third pass: compute attn = exp(logit - max) / sum.
- Then compute contribution to outputs.
3. After softmax, aggregation:
- out_scalar_i = sum_j attn_j * v_j.
- Each thread loops over its j's, computes `attn_j * v_j[d]` and accumulates in a local array `acc_scalar[d]` (size 16).
- Then block-level reduction: for each d, sum `acc_scalar[d]` across threads.
- out_pts_global_i = sum_j attn_j * v_pts_global_j.
- `v_pts_global_j = v_pts_local_j @ R_j + t_j` for head h_idx. Qp? No, Vp points. So Vp*3 floats per j.
- Each thread loops j, transforms v_pts_local_j to global on the fly, scales by attn_j, accumulates into `acc_pts[Vp][3]` in registers.
- Then block-level reduce `acc_pts` across threads.
4. After reduction, thread 0 has out_scalar_i (d floats) and out_pts_global_i (Vp*3 floats).
- Apply inverse frame to out_pts_global_i: `centered = out_pts_global_i - t_i.unsqueeze(0)`, then `local = centered @ R_i^T`.
- Write out_scalar_i to `out[b, i, h_idx*d ... ]`.
- Write out_pts_local_i to `out[b, i, h*d + h_idx*Vp*3 ...]`.
Wait, the output layout should match the concatenation: first h*d scalar values, then h*Vp*3 point values, per (b,i).
The reference does:
`out_scalar.reshape(B, N, h*d)` -> first part.
`out_pts_flat.reshape(B, N, h*Vp*3)` -> second part.
Then `torch.cat([out_scalar, out_pts_flat], dim=-1)`.
So `out[b, i, 0 : h*d]` are the scalar head outputs.
`out[b, i, h*d : h*d + h*Vp*3]` are the point outputs.
Inside the block for head `h_idx`, the scalar output goes to `out[b, i, h_idx*d + d_idx]` and point output goes to `out[b, i, h*d + h_idx*Vp*3 + pt_idx*3 + c]`.
So the kernel writes directly to the concatenated buffer.
Shared memory usage:
- q_i: d floats (max ~64 or more, but here 16).
- q_pts_global_i: Qp*3 floats (12).
- R_i: 9 floats. t_i: 3 floats.
- For reduction: we need a shared memory array for reduction of scalars. We can use two reductions:
- For softmax max and sum: one float per thread (block_size floats).
- For output accumulation: we can do a tree reduction. But it's easier: each thread writes its local accumulation into shared memory and then we tree-reduce.
- Shared mem size = block_size * sizeof(float) for reduction, plus maybe a second shared mem for output accumulation.
- Actually, for output accumulation, we can have each thread store `acc_scalar[0..d-1]` and `acc_pts[0..Vp*3-1]` in local registers. Then to reduce across block, we can have shared memory of size `d + Vp*3` and use atomicAdd? Not great.
- Alternative: In the j-loop, after computing attn_j, instead of accumulating into registers for later reduction, each thread can write `attn_j * v_j[d]` to a shared memory accumulation buffer `out_scalar_shm[d]` using atomicAdd. With block_size up to 128 and d+Vp*3 ~ 40, atomicAdd conflicts are high but maybe manageable? On shared memory, atomicAdd on floats is relatively fast but serializes within a warp. With block_size=128, 128 threads accessing same address -> 128 cycles. For 40 addresses, 40*128 cycles = 5120 cycles. Plus the j-loop has other work. N=32, each thread handles ~1 j. So for each j, we do atomicAdd for d+Vp*3 values. That's 32*40 = 1280 atomicAdds. In shared mem, maybe okay. But for larger N, it's worse.
Another approach: After computing softmax, have each thread compute its weighted contribution, store them in registers, then do a warp-level reduction using shfl. Since d+Vp*3 can be up to maybe 64+8*3*... let's see typical. But we can handle d and Vp*3 by having each thread hold the partial sum and then we do reduction across the warp/block using shared memory in a tree fashion. However, because each thread has a vector, the reduction is a matrix reduction.
We can use a simple approach: __syncthreads(); for (int offset = block_size/2; offset > 0; offset /= 2) { ... } but each thread's data is a vector. We can't store the whole vector in shared memory easily unless we serialize.
Wait, we can store per-thread accumulation in shared memory: `shm[threadIdx.x * vec_size + idx]`. Then do a reduction where for each element idx, we do a reduction across threads. `vec_size = d + Vp*3`. For block_size=128, total shared memory = 128 * 40 * 4 = 20KB. Plus other shared mem, total < 48KB. That's fine!
Then thread 0 reads the final sum and writes output.
Let's do that.
Specifically:
- Allocate shared memory dynamically or fixed: `__shared__ float shm[block_size * (d + Vp*3)];` -> but d and Vp*3 are not compile-time constants. We can allocate `__shared__ float shm[];` and size it in launch.
- Dynamic shared memory size = `block_size * output_vec_size * sizeof(float) + block_size * sizeof(float)` (for softmax reduction).
- Alternatively, use two arrays in dynamic shared mem.
Shared memory layout:
- `float* acc_shm = shared_mem;` size = block_size * out_vec_size.
- `float* softmax_shm = shared_mem + block_size * out_vec_size;` size = block_size.
But out_vec_size depends on head-specific dims: d + Vp*3. It's constant per head? Yes, d and Vp are fixed. So we can compute it.
However, if we want to avoid dynamic sizing complexities, we can set a max. e.g., max d = node_dim / num_heads. node_dim could be 64, num_heads=4 -> d=16. But if node_dim is larger, d could be 64 or 128. Vp maybe up to 8 or 12. out_vec_size probably < 256. block_size * 256 * 4 = 128KB, which might exceed 48KB shared mem. So we should limit block_size based on out_vec_size.
Wait, we don't know the max dims. The constructor arguments are generic. To be safe, we should pass `out_vec_size` to the kernel and allocate dynamic shared memory.
CUDA launch: `kernel<<<grid, block, shared_mem_size>>>`.
`shared_mem_size = block_size * out_vec_size * sizeof(float) + block_size * sizeof(float)`.
But `out_vec_size = d + Vp * 3`. `d = node_dim // num_heads`.
Let's set `block_size = 128` or maybe `min(N, 128)`. If N < block_size, some threads are idle. That's okay.
Actually, to reduce wastage, we can set block_size to a power of 2 >= N, e.g. `int block_size = 32; while (block_size < N) block_size *= 2; block_size = min(block_size, 512);`. But we need to decide at runtime in Python. We can pass block_size as a template or compute and launch accordingly.
For simplicity, let's use a fixed block size of 128 threads. It's a good occupancy sweet spot.
Each block corresponds to (b, i, h_idx).
`int total = B * N * h;`
`int idx = blockIdx.x;`
`int b = idx / (N * h);`
`int rem = idx % (N * h);`
`int i = rem / h;`
`int h_idx = rem % h;`
Block size = 128.
Threads in block: `int tid = threadIdx.x;`.
Shared memory:
- `extern __shared__ float sbuf[];`
- `float* s_acc = sbuf;` // block_size * out_vec_size
- `float* s_softmax = sbuf + block_size * out_vec_size;` // block_size
Initialize `s_acc[tid * out_vec_size + k] = 0` for all k (loop). This may be slow. Alternatively, we can initialize only the needed parts.
But wait, each thread only needs to accumulate `out_vec_size` floats. If block_size is 128 and out_vec_size is 40, initializing 5120 floats is fine.
Loading q_i and q_pts_i:
- `if (tid == 0)`: load into a shared memory area `s_q` or just directly into global register? Thread 0 loads and broadcasts? No, we need all threads to use q_i. We can load into shared memory so all threads can read.
- `float* s_q = s_softmax + block_size;` Additional shared mem for q_i (d), q_pts (Qp*3), R_i (9), t_i (3). `extra_shared = d + Qp*3 + 9 + 3`.
We can also just have each thread load q_i from global memory redundantly. d=16. That's 128 * 16 loads from same address. With L1 cache, it's fast. So we can skip shared mem for q_i and just load into register per thread.
Similarly, for R_i and t_i, each thread can load them from global memory. 12 floats per thread -> 128*12 loads. With cache, it's okay. But we can put them in shared mem to reduce global mem pressure. Since 12 floats is small, thread 0 loads and __syncthreads().
OK, let's keep it simple: each thread loads q_i, R_i, t_i itself from global memory using coalesced pattern or scalar loads.
Wait, all threads in block need same q_i. If they all load from same address simultaneously, it's broadcast from L1. Fine.
Same for R_i, t_i.
So we can just load in registers per thread:
```cuda
float q_reg[d];
for (int di = 0; di < d; ++di) q_reg[di] = q[((b * N + i) * h + h_idx) * d + di];
```
Similarly for rotations and translations.
But `d` is not compile-time constant, so we can't use `float q_reg[d];` in C++ if d is a runtime variable (unless we use dynamic alloca or local array with variable size, which some compilers support but it's stack/array in local memory, slow). We need to handle this.
If d is small (like up to 64), we can unroll with a pragma or just loop and keep the current value in register? The compiler might not keep them all in registers if we loop. But we can declare `float q_reg[64];` with MAX_SIZE. But we don't know max size.
Better approach: Don't keep q in registers; fetch on the fly in the dot product loop. Since d is small, we loop over d. For dot product `dot(q_i, k_j)`, we can compute:
```cuda
float dot = 0;
for (int di=0; di<d; ++di) {
float qv = q[((b*N+i)*h+h_idx)*d + di];
float kv = k[((b*N+j)*h+h_idx)*d + di];
dot += qv * kv;
}
```
This loads q_i[d] repeatedly for each j. But N is small. For N=32, loading q_i 32 times. d=16. Total q loads = 512. That's fine. And we don't need register arrays.
Similarly, for v_j, we load on the fly.
For q_pts_i, we can load once and store in a small shared memory or registers. Qp*3 = 12. We can put in shared memory: `__shared__ float q_pts_global[12];` thread 0 loads and computes global transform.
For k_pts_j and v_pts_j, we load on the fly and transform.
Given the small dimensions, on-the-fly loads and computations are fine and avoid large register pressure.
But for the output accumulation (out_scalar and out_pts), we need to keep the accumulated values. We can allocate shared memory for accumulation, where each thread writes partial sums, then reduce.
Actually, we can use the `s_acc` approach:
- Each thread loops over its j's. For each j:
- compute attn_j
- for di=0..d-1: `acc_scalar[di] += attn_j * v_j[di]`
- for pt=0..Vp-1, c=0..2: `acc_pts[pt][c] += attn_j * v_pts_global_j[pt][c]`
- After the j-loop, each thread writes its `acc_scalar` and `acc_pts` into `s_acc[tid * out_vec_size + offset]`.
- Then __syncthreads().
- Perform parallel reduction on `s_acc` for each element of out_vec_size.
- After reduction, thread 0 reads the final sums, applies inverse frame to points, and writes to global output.
The issue again is `acc_scalar` and `acc_pts` as local arrays of variable size. We can use shared memory directly as the accumulation buffer, and have each thread update its own slice:
```cuda
float* my_acc = &s_acc[tid * out_vec_size];
// initialize my_acc to 0
for (int k = tid; k < out_vec_size; k += block_size) ... no, we need per-thread.
```
Actually, we can initialize the entire `s_acc` to 0 at start:
`for (int k = tid; k < block_size * out_vec_size; k += block_size) s_acc[k] = 0.0f;`
Then in the j-loop, each thread computes its j's contributions and adds to `my_acc[di]`.
`my_acc[di] += attn_j * v_j[di];`
This is faster than storing in registers because `my_acc` is in shared memory, which is fast but has bank conflicts if not careful. With block_size=128 and out_vec_size=40, `my_acc` for thread tid starts at `tid*40`. The stride between threads is 40 floats = 160 bytes. Shared memory banks are 4 bytes wide, so 160/4 = 40 banks apart. On A100, there are 32 banks. 40 mod 32 = 8. So there will be some bank conflicts when threads access the same element index `di` but offset by 8 banks. However, each thread accesses its own slice, so there are no conflicts within a warp? Wait, bank conflicts occur when multiple threads in a warp access different addresses that map to the same bank. `address = (tid*40 + di)*4`. Bank = (address / 4) % 32 = (tid*40 + di) % 32.
For a fixed `di`, thread 0 accesses bank di, thread 1 accesses bank (40+di)%32 = 8+di, thread 2 accesses 16+di, thread 3: 24+di, thread 4: 32+di = di. So threads 0 and 4 access same bank! That's a 4-way conflict. Not terrible.
To avoid conflicts, we can transpose the layout: `s_acc[di * block_size + tid]`. Then each thread accesses `s_acc[di * block_size + tid]`. For a fixed di, all threads in a warp access consecutive addresses, which are strided by 4 bytes (1 float), meaning they access banks 0,1,2,3,...? Actually, consecutive 4-byte accesses by consecutive threads are bank-conflict-free because each thread hits a different bank ( bank = (base + tid) % 32 ). Yes! This is perfect.
So we should store the accumulation transpose: for each output element `k` (0..out_vec_size-1), the partial sum for thread `tid` is at `s_acc[k * block_size + tid]`.
Let's define:
- `s_acc` size = out_vec_size * block_size.
- Initialize: for each k assigned to thread: `s_acc[k * block_size + tid] = 0.0f;` (loop over k, but each thread handles all k? We can do: for (int k=0; k<out_vec_size; ++k) s_acc[k*block_size + tid] = 0.0f;`)
Since out_vec_size is small (40), each thread does 40 writes to shared memory. Total 128*40 = 5120 writes. Fine.
In the j-loop:
- For each j handled by tid:
- Compute attn_j.
- For di=0..d-1:
float vval = v[ ... ];
s_acc[di * block_size + tid] += attn_j * vval;
- For pt=0..Vp-1, c=0..2:
float vpt = v_pts_global[pt*3 + c];
s_acc[(d + pt*3 + c) * block_size + tid] += attn_j * vpt;
After all j's processed, __syncthreads().
Then reduce across threads for each output element k:
```
for (int k = tid; k < out_vec_size; k += block_size) {
float sum = 0;
for (int t = 0; t < block_size; ++t) {
sum += s_acc[k * block_size + t];
}
s_acc[k * block_size + 0] = sum; // or store in a separate buffer
}
```
But the inner loop over t is serial and block_size is 128. That's 128 * out_vec_size operations = ~5k additions per block. That's fine. But we want to parallelize the reduction.
Standard tree reduction:
For (int stride = block_size/2; stride > 0; stride /= 2) {
__syncthreads();
for (int k = tid; k < out_vec_size; k += block_size) {
if (tid < stride) {
s_acc[k*block_size + tid] += s_acc[k*block_size + tid + stride];
}
}
}
This uses `block_size` threads to reduce `out_vec_size` columns concurrently. With block_size=128 and out_vec_size=40, the first few strides have all 40 columns being reduced by 64 threads each? Wait, `tid` goes 0..127. For stride=64, only threads 0..63 participate. For each k assigned by the loop, they add. That's fine.
After log2(128)=7 steps, the sum is in `s_acc[k*block_size + 0]`.
Then thread 0 reads the sums.
This is good.
Softmax reduction:
Similarly, we need max and sum over j. We can use a small shared memory `s_softmax[block_size]`.
First pass: each thread loops j and computes local max.
```
float local_max = -1e30f;
for (int j = tid; j < N; j += block_size) {
float logit = compute(...);
s_softmax[tid] = logit; // maybe store logits?
local_max = fmaxf(local_max, logit);
}
s_softmax[tid] = local_max;
__syncthreads();
// block-level max reduction
for (int stride = block_size/2; stride > 0; stride /= 2) {
if (tid < stride) s_softmax[tid] = fmaxf(s_softmax[tid], s_softmax[tid+stride]);
__syncthreads();
}
float global_max = s_softmax[0];
```
Second pass: each thread computes local sum of exp(logit - global_max).
```
float local_sum = 0;
for (int j = tid; j < N; j += block_size) {
float logit = ...; // recompute? We can store all logits in shared mem if N*block_size is small? Better to recompute to save shared mem.
// Actually recomputing is cheap.
float exp_val = expf(logit - global_max);
// we need to save exp_val or compute attn on the fly?
// We can compute exp_val and add to local sum. But we also need exp_val for the aggregation pass.
// Option 1: Store all exp_vals in shared mem or registers? N max maybe 512, block_size 128. Each thread handles up to ceil(N/block_size) j's. We can store in registers per j?
// For block_size=128 and N=32, each thread handles 1 j. We can store exp_val in a register.
// For larger N, each thread handles multiple j's. We can store in an array? Not if N is large.
// Better: do a second loop that recomputes the logit and then computes attn. Recompute is cheap.
local_sum += expf(logit - global_max);
}
s_softmax[tid] = local_sum;
__syncthreads();
for (int stride = block_size/2; stride > 0; stride /= 2) { ... sum ... }
float global_sum = s_softmax[0];
```
Third pass: aggregation loop. Recompute logit, compute attn = exp(logit - global_max) / global_sum. Then accumulate v_j and v_pts_j into `s_acc`.
So we need to compute logit 3 times per j. Is that okay? For each j, computing logit requires:
- dot(q_i, k_j): d multiplies and adds.
- pt_dist: load k_pts_local_j, transform (12 mult-adds), diff/square/sum over Qp*3.
- pair_bias: 1 load.
Total ~ d + 2*Qp*3 operations. For d=16, Qp=4, that's ~16+24 = 40 FLOPs.
Computing 3 times = 120 FLOPs per j. N=32 -> 3840 FLOPs per block. Negligible.
Recomputing is simpler than storing large arrays.
We can optimize by storing logits and other intermediate values in shared memory for the first pass if N <= block_size. But for generality, recompute is fine.
However, there is a subtlety: the pair_bias and pt_dist and dot product depend on `j`, but for the aggregation, we also need `v_j` and `v_pts_j`. Those are also per j. So recomputing logit in the third loop is fine.
Let's structure the kernel:
```
__global__ void ipa_forward(
const float* q,
const float* k,
const float* v,
const float* q_pts_local,
const float* k_pts_local,
const float* v_pts_local,
const float* pair_bias,
const float* rotations,
const float* translations,
const float* head_weights,
float* out,
int B, int N, int h, int d, int Qp, int Vp) {
```
Shared memory:
```
extern __shared__ float sbuf[];
float* s_acc = sbuf; // out_vec_size * block_size
float* s_softmax = sbuf + out_vec_sizeOutputs * block_size; // block_size
```
Wait, we need `out_vec_size = d + Vp * 3`.
But in the kernel, `blockDim.x` is fixed. Let's pass it or use `blockDim.x`.
`int tid = threadIdx.x;`
`int block_size = blockDim.x;`
Initialize `s_acc`:
`for (int k = tid; k < out_vec_size * block_size; k += block_size) s_acc[k] = 0.0f;`
But using `k += block_size` coalesces writes across threads? Since `s_acc` is transposed (k*block_size + tid), addressing `s_acc[0*block_size + tid]`, `s_acc[1*block_size + tid]`, etc. So we can just do:
```
for (int k = 0; k < out_vec_size; ++k) {
s_acc[k * block_size + tid] = 0.0f;
}
```
This is fine since out_vec_size is small.
Compute q/scalar and q_pts_local for this (b,i,h_idx):
- Compute `q_base = ((b * N + i) * h + h_idx) * d;`
- For dot product, we just loop inside j loop.
Compute q_pts_global_i:
- `int qpts_base = ((b * N + i) * (h * Qp) + h_idx * Qp) * 3;`
- Load `q_pts_local[qpts_base + p*3 + c]`.
- Load R_i: `rot_base = ((b * N + i) * 3 + c2) * 3 + c`? Wait rotations is (B,N,3,3). Index: `((b * N + i) * 3 + c2) * 3 + c` gives `rot[b,i,c2,c]`. But we need `R[c2,c]`.
- Load t_i: `trans_base = (b * N + i) * 3 + c`.
- Compute global: for p=0..Qp-1, c=0..2: `float val = t_i[c]; for c2=0..2: val += q_pts_local[...] * R_i[c2,c];`
- Store `q_pts_global[p*3 + c] = val;` in registers? Again, variable size. Qp*3 <= maybe 4*3=12 or 8*3=24. We can use `float q_pts_global[24];` if we set a max. But to be generic, we can compute it on the fly inside the j-loop? But we need it for each j. Better to compute once per block.
Since Qp*3 is small, we can compute and keep in a small local array, but C++ doesn't allow VLAs on stack. However, we can use a `float* shm_qpts = s_softmax + block_size;` to store q_pts_global (Qp*3 floats) and maybe also R_i and t_i.
Wait, s_softmax is only block_size floats. After that, we have some unused shared mem if we allocated more. We can allocate dynamic shared mem for everything:
Dynamic shared mem size:
- `out_vec_size * block_size * sizeof(float)` (s_acc)
- `block_size * sizeof(float)` (s_softmax for max/sum reduction)
- `Qp * 3 * sizeof(float)` (q_pts_global)
- `9 * sizeof(float)` (R_i)
- `3 * sizeof(float)` (t_i)
Total = `(out_vec_size * block_size + block_size + Qp*3 + 12) * sizeof(float)`.
We can pass this from host.
Inside kernel:
```cuda
extern __shared__ float sbuf[];
float* s_acc = sbuf;
float* s_softmax = sbuf + out_vec_size * block_size;
float* s_qpts = s_softmax + block_size;
float* s_rot = s_qpts + Qp * 3;
float* s_trans = s_rot + 9;
```
Thread 0 loads R_i, t_i, q_pts_local_i and computes q_pts_global_i, storing in shared mem. Then __syncthreads().
Now the j-loop. Each thread handles `j = tid, tid+block_size, ...` if N > block_size. But for typical N <= 384 and block_size=128, each thread handles at most 3-4 elements. It's fine.
Inside j-loop, we need to compute:
1. Load k_j and dot product:
```cuda
float dot = 0.0f;
int k_base = ((b * N + j) * h + h_idx) * d;
int q_base = ((b * N + i) * h + h_idx) * d;
for (int di = 0; di < d; ++di) {
float qv = q[q_base + di];
float kv = k[k_base + di];
dot += qv * kv;
}
float logit = dot * rsqrtf((float)d);
```
2. Load k_pts_local_j, transform to global:
```cuda
int kpts_base = ((b * N + j) * (h * Qp) + h_idx * Qp) * 3;
int rot_j_base = ((b * N + j) * 9); // 3x3
int trans_j_base = (b * N + j) * 3;
float pt_dist = 0.0f;
for (int p = 0; p < Qp; ++p) {
for (int c = 0; c < 3; ++c) {
float kpt = translations[trans_j_base + c];
for (int c2 = 0; c2 < 3; ++c2) {
kpt += k_pts_local[kpts_base + p*3 + c2] * rotations[rot_j_base + c2*3 + c];
}
float diff = s_qpts[p*3 + c] - kpt;
pt_dist += diff * diff;
}
}
float w = head_weights[h_idx];
logit += -(w * pt_dist) * 0.5f;
```
3. Pair bias:
```cuda
int pb_idx = ((b * h + h_idx) * N + i) * N + j;
logit += pair_bias[pb_idx];
```
Note: `pair_bias` shape (B, h, N, N). If we pass it as contiguous in that shape. `self.pair_bias(pair_repr)` yields (B,N,N,h). We can permute and make contiguous: `pair_bias = self.pair_bias(pair_repr).permute(0,3,1,2).contiguous()`.
4. Aggregation of local max for softmax:
Keep `local_max` across j's handled by this thread.
Then after j-loop, write local_max to `s_softmax[tid]`.
Block-level max reduction:
```
for (int stride = block_size/2; stride > 0; stride >>= 1) {
__syncthreads();
if (tid < stride) {
s_softmax[tid] = fmaxf(s_softmax[tid], s_softmax[tid + stride]);
}
}
__syncthreads();
float global_max = s_softmax[0];
```
Second pass: local sum of exps.
Reset local_sum to 0.
Loop j again (same as before) recomputing logit.
```
float local_sum = 0.0f;
for (int j = tid; j < N; j += block_size) {
// recompute logit_j ... same code as above
local_sum += expf(logit - global_max);
}
s_softmax[tid] = local_sum;
__syncthreads();
// reduce sum
for (int stride = block_size/2; stride > 0; stride >>= 1) {
__syncthreads();
if (tid < stride) {
s_softmax[tid] += s_softmax[tid + stride];
}
}
__syncthreads();
float global_sum = s_softmax[0];
```
Third pass: aggregate outputs using attn weights.
Loop j again.
```
for (int j = tid; j < N; j += block_size) {
// recompute logit_j
float logit = ...;
float attn = expf(logit - global_max) / global_sum;
// scalar
int v_base = ((b * N + j) * h + h_idx) * d;
for (int di = 0; di < d; ++di) {
float vv = v[v_base + di];
s_acc[di * block_size + tid] += attn * vv;
}
// points
int vpts_base = ((b * N + j) * (h * Vp) + h_idx * Vp) * 3;
for (int p = 0; p < Vp; ++p) {
for (int c = 0; c < 3; ++c) {
float vpt = translations[trans_j_base + c];
for (int c2 = 0; c2 < 3; ++c2) {
vpt += v_pts_local[vpts_base + p*3 + c2] * rotations[rot_j_base + c2*3 + c];
}
int acc_idx = d + p*3 + c;
s_acc[acc_idx * block_size + tid] += attn * vpt;
}
}
}
```
After j-loop, __syncthreads().
Reduce s_acc across threads for each output element:
```
for (int stride = block_size/2; stride > 0; stride >>= 1) {
__syncthreads();
if (tid < stride) {
for (int k = 0; k < out_vec_size; ++k) {
s_acc[k * block_size + tid] += s_acc[k * block_size + tid + stride];
}
}
}
__syncthreads();
```
Then thread 0 handles writing to output.
Apply inverse frame to point part of the sum.
The point part sum is `out_pts_global` for head h_idx.
We have `s_acc[d + ...]` containing global point coordinates.
For each p in 0..Vp-1, c in 0..2:
`float global_pt = s_acc[(d + p*3 + c) * block_size + 0];`
`float centered = global_pt - s_trans[c];`
`float local_pt = 0;`
`for c2=0..2: local_pt += centered * s_rot[c2*3 + c];` Wait, the inverse is `centered * R^T`. `R^T[c2,c] = R[c,c2]`.
In _apply_inv_frame: `einsum("bnpc,bndc->bnpd", centered, rotations)`. This means `local[p,d] = sum_c centered[p,c] * rotations[b,n,d,c]`.
For our R_i loaded as `rotations[rot_i_base + c2*3 + c]` = `R[c2,c]`.
Then `local[p,d] = sum_c centered[p,c] * R[d,c]`.
So we need R[d,c]. Since we stored R[c2,c], we can index as `s_rot[d*3 + c]`.
So code:
```
float local_val = 0.0f;
for (int c_src = 0; c_src < 3; ++c_src) {
float centered = global_pt_val[c_src] - s_trans[c_src];
local_val += centered * s_rot[d*3 + c_src];
}
```
Wait, but the loop is over `c_src`. For each target coordinate `d`:
```
float local = 0.0f;
for (int c2 = 0; c2 < 3; ++c2) {
float centered = global_pt[c2] - s_trans[c2];
local += centered * s_rot[d*3 + c2];
}
```
Note: `s_rot` should contain `R_i`. The rotation matrix is orthogonal but we don't need to verify. `R[d,c2]` is `s_rot[d*3 + c2]`.
Check _apply_frame: `global = local @ R`. That is `global[p,d] = sum_c local[p,c] * R[c,d]`. In code, `R[c,d]` is `s_rot[c*3 + d]`.
In our loading code, `rotations[...]` is (B,N,3,3). We stored `s_rot[c2*3 + c]` = `R[c2,c]`.
So `global[p,c] = sum_c2 local[p,c2] * R[c2,c]` -> `s_rot[c2*3 + c]`. Yes.
Inverse: `local[p,d] = sum_c centered[p,c] * R[d,c]` -> `s_rot[d*3 + c]`. Yes.
So after reduction, thread 0:
```
int out_base = (b * N + i) * (h * d + h * Vp * 3);
for (int di = 0; di < d; ++di) {
out[out_base + h_idx * d + di] = s_acc[di * block_size + 0];
}
for (int p = 0; p < Vp; ++p) {
float global_pt[3];
for (int c = 0; c < 3; ++c) global_pt[c] = s_acc[(d + p*3 + c) * block_size + 0];
for (int d_out = 0; d_out < 3; ++d_out) {
float local = 0.0f;
for (int c = 0; c < 3; ++c) {
float centered = global_pt[c] - s_trans[c];
local += centered * s_rot[d_out*3 + c];
}
out[out_base + h * d + h_idx * Vp * 3 + p*3 + d_out] = local;
}
}
```
Wait, the output layout:
`out[b, i, 0:h*d]` are scalar heads.
Then `out[b, i, h*d : h*d + h*Vp*3]` are point heads.
The block for head `h_idx` writes:
- scalar: `out[b, i, h_idx*d + di]`
- points: `out[b, i, h*d + h_idx*Vp*3 + p*3 + c]`
Yes.
Now, we need to pass `head_weights` as a tensor. In the reference, `self.head_weights` is `nn.Parameter(torch.zeros(num_heads))`. We need to pass its data pointer.
Shared memory dynamic size:
`sizeof(float) * (out_vec_size * block_size + block_size + Qp*3 + 9 + 3)`
Wait, we also might need to store the local values of `logit` or not. We recompute.
What about the `s_qpts` area? Thread 0 computes q_pts_global and stores there.
Thread 0 loads `R_i` into `s_rot`, `t_i` into `s_trans`.
The amount of shared memory depends on block_size and Qp, etc.
We need to be careful with grid/block dimensions.
We will launch:
```
int block_size = 128; // or maybe 64. Let's choose 128 for good occupancy.
int total_blocks = B * N * h;
int out_vec_size = d + Vp * 3;
int shared_mem_size = sizeof(float) * (out_vec_size * block_size + block_size + Qp * 3 + 12);
ipa_forward_kernel<<<total_blocks, block_size, shared_mem_size>>>(...);
```
But if `N` is small (e.g., 32), a block_size of 128 means only first 32 threads are active in the j-loops. That's okay. The remaining threads will still participate in reduction? In the reduction `for stride = 64..`, threads with tid >= 32 won't have valid data in s_softmax or s_acc. We initialized s_acc for all threads to 0. `s_softmax[tid]` for tid >= 32 won't be set in first pass (local_max). We should initialize `s_softmax[tid] = -1e30f` for all threads to avoid max reduction issues.
Similarly for sum reduction, initialize `s_softmax[tid] = 0.0f` before second pass.
Actually, we can initialize `s_softmax[tid] = -1e30f` at the start for all threads, then threads that have no j (tid >= N) will not update it, but it will stay -inf, which won't affect max reduction if there is at least one active thread. In fmaxf(-inf, x) = x.
For sum, initialize to 0.
So at the top of the kernel, we can do:
```
s_softmax[tid] = -1e30f;
```
But we need to make sure that if N=0 (not possible), it's okay.
Better yet, let's have a branch: `if (j < N)` inside the loop. So threads with no j just skip.
Let's refine the code.
One more detail: `math.sqrt(d)` in reference. We'll compute `rsqrtf((float)d)` and multiply, or `1.0f / sqrtf((float)d)`.
Performance considerations:
- The kernel does a lot of global memory loads: q_i is loaded by each thread in each j-loop. That's `block_size * N` times. For block_size=128 and N=32, 4096 loads of q_i. But it's from L1 cache. Should be fast.
- Similarly, k_pts_local_j, rotations_j, translations_j are loaded per j per thread. Each j is loaded by one thread (assuming N < block_size) or a few. If N < block_size, each j is loaded by exactly one thread. Rotations j: 9 floats, translations j: 3 floats. k_pts_local_j: 12 floats. Total ~24 floats per j. 32 j's = 768 floats loaded per block. With 128 threads, total floats loaded from global mem per block: 768. Actually each thread loads its own j, so 24 floats per thread.
- v_j, v_pts_local_j are loaded in third pass. Similar.
- So global memory traffic is not huge.
However, we are loading `pair_bias` per j, which is contiguous.
Potential issue: when block_size > N, many threads are idle. The reduction loops still run for all block_size. The overhead is minor.
What about block_size < N? Then N / block_size = ceil(N / block_size) iterations per thread. For N=384 and block_size=128, 3 iterations. Good.
Should we choose block_size as `min(128, max(32, N))`? We can compute in Python: `block_size = 32; while (block_size < N and block_size < 128) block_size *= 2;`. This ensures block_size is a power of 2 and >= min(N, 128). Actually, we want block_size to be a power of 2 for reduction. So `block_size = 1; while (block_size < min(N, 128)) block_size *= 2; block_size = max(block_size, 32);` to ensure at least one warp. Or simpler, just use 128 always. It works. I'll use 128.
Wait, if N=1024 and block_size=128, each thread handles 8 j's. Total loads increase but still manageable.
Register pressure: The kernel has loops with `float logit` etc. It should be okay.
Now, the host side wrapper.
We need to define a C++ function that takes torch tensors and calls the kernel. `load_inline` will generate the pybind wrapper.
We need to handle device, dtype.
All inputs are float (fp32). We'll assert that.
Let's draft the C++ wrapper:
```cpp
#include <torch/extension.h>
#include <cuda.h>
#include <cuda_runtime.h>
__global__ void ipa_forward_kernel(...) { ... }
torch::Tensor ipa_forward(
torch::Tensor q,
torch::Tensor k,
torch::Tensor v,
torch::Tensor q_pts_local,
torch::Tensor k_pts_local,
torch::Tensor v_pts_local,
torch::Tensor pair_bias,
torch::Tensor rotations,
torch::Tensor translations,
torch::Tensor head_weights) {
int B = q.size(0);
int N = q.size(1);
int h = q.size(2);
int d = q.size(3);
int Qp = k_pts_local.size(2) / h; // shape (B, N, h*Qp, 3)
int Vp = v_pts_local.size(2) / h;
int out_features = h * d + h * Vp * 3;
auto out = torch::zeros({B, N, out_features}, q.options());
int block_size = 128;
int total_blocks = B * N * h;
int out_vec_size = d + Vp * 3;
int shared_mem = sizeof(float) * (out_vec_size * block_size + block_size + Qp * 3 + 12);
ipa_forward_kernel<<<total_blocks, block_size, shared_mem>>>(
q.data_ptr<float>(),
k.data_ptr<float>(),
v.data_ptr<float>(),
q_pts_local.data_ptr<float>(),
k_pts_local.data_ptr<float>(),
v_pts_local.data_ptr<float>(),
pair_bias.data_ptr<float>(),
rotations.data_ptr<float>(),
translations.data_ptr<float>(),
head_weights.data_ptr<float>(),
out.data_ptr<float>(),
B, N, h, d, Qp, Vp
);
return out;
}
```
Make sure to check and ensure inputs are contiguous and on CUDA.
What about `pair_bias` layout? In wrapper, we can receive `pair_bias` as (B, h, N, N) contiguous. We can ensure that in Python.
Now, let's think about handling the outer linear layers, layer norm, etc.
In `ModelNew.forward`:
```python
def forward(self, node_repr, pair_repr, translations, rotations):
B, N, D = node_repr.shape
h = self.num_heads
d = self.head_dim
Qp = self.num_query_points
Vp = self.num_value_points
x = self.layer_norm(node_repr)
q = self.q_proj(x).view(B, N, h, d)
k = self.k_proj(x).view(B, N, h, d)
v = self.v_proj(x).view(B, N, h, d)
q_pts_local = self.q_pts(x).view(B, N, h * Qp, 3)
k_pts_local = self.k_pts(x).view(B, N, h * Qp, 3)
v_pts_local = self.v_pts(x).view(B, N, h * Vp, 3)
# pair_bias: (B,N,N,h) -> (B,h,N,N)
pair_bias = self.pair_bias(pair_repr).permute(0, 3, 1, 2).contiguous()
# Ensure all are contiguous float32 on cuda
# (they should be already)
out = _C.ipa_forward(
q, k, v,
q_pts_local, k_pts_local, v_pts_local,
pair_bias, rotations, translations,
self.head_weights
)
return self.out_proj(out)
```
Wait, `self.head_weights` is a parameter. We need to pass it as a tensor. `F.softplus(head_weights)` is applied in kernel. We should pass `self.head_weights` directly, and the kernel computes `w = softplus(weight)`. We can compute `w = F.softplus(self.head_weights)` in Python and pass the result. But since it's tiny (h=4), passing the raw parameter is fine. In kernel, do `float w = logf(1.0f + expf(head_weights[h_idx]));`? Or `float w = head_weights[h_idx] > 0 ? head_weights[h_idx] + log1pf(expf(-head_weights[h_idx])) : log1pf(expf(head_weights[h_idx]));` That's the standard softplus.
Actually, PyTorch's softplus default beta=1, threshold=20. For simplicity, we can compute `logf(1.0f + expf(x))`. For large x, this is unstable but x is usually small initialized to 0. We can do `float ew = expf(x); w = log1pf(ew);`.
Or compute it in Python: `w = F.softplus(self.head_weights).contiguous().view(-1)` and pass that. That's easiest and avoids approx mismatch? Wait, `softplus` is smooth. The CUDA kernel using `log1pf(expf(x))` should match PyTorch's implementation for fp32 within tolerance? PyTorch likely uses a numerically stable version. For small x, it should be fine. But to guarantee correctness, we can compute `w = F.softplus(self.head_weights)` in Python and pass the tensor.
However, `self.head_weights` is `torch.zeros(num_heads)`. `softplus(0) = log(2) = 0.6931`. If we compute `log(1+exp(0)) = log(2)`, same.
Potential mismatch: PyTorch's softplus has a `threshold` parameter: for `x > threshold`, it returns `x`. For small values (like 0), it's fine. We'll compute in Python and pass it.
Also, `rotations` in the reference is passed as `torch.eye(3)`, but in general it's a tensor. We need to pass it as is.
Wait, there's a nuance: the reference applies frame to q_pts_local, k_pts_local, v_pts_local using the SAME `rotations` and `translations` for all points. So we can do that.
One more correctness nuance: in the reference, `self._apply_frame` does `torch.einsum("bnpc,bncd->bnpd", pts, rotations) + translations.unsqueeze(2)`. Note the einsum: `bnpc,bncd->bnpd`. That's `pts[b,n,p,c] * rotations[b,n,c,d]`. So `global = local @ rotations`. Yes.
And `_apply_inv_frame` does `torch.einsum("bnpc,bndc->bnpd", centered, rotations)`. That's `centered[b,n,p,c] * rotations[b,n,d,c]`. That is `local = centered @ rotations^T`. Yes.
Now, double-checking the point attention distance.
Reference:
```python
q_expand = q_pts.unsqueeze(2) # (B, N, 1, h, Qp, 3)
k_expand = k_pts.unsqueeze(1) # (B, 1, N, h, Qp, 3)
pt_dist = ((q_expand - k_expand) ** 2).sum(dim=(-1, -2)) # (B, N, N, h)
```
q_pts shape: (B, N_i, h, Qp, 3)
k_pts shape: (B, N_j, h, Qp, 3)
The unsqueeze makes them (B, N_i, 1, h, Qp, 3) and (B, 1, N_j, h, Qp, 3).
Subtract -> (B, N_i, N_j, h, Qp, 3).
Sum over dim -1 (the 3 coordinate) and dim -2 (the Qp dimension). So it's sum over Qp and 3.
Yes, `pt_dist[b,i,j,h] = sum_{p=0}^{Qp-1} sum_{c=0}^{2} (q_pts[b,i,h,p,c] - k_pts[b,j,h,p,c])^2`.
Then `w = F.softplus(self.head_weights)`. shape (h,).
`pt_attn = -(w.view(1,1,1,h) * pt_dist) / 2.0`
Shape (B, N, N, h).
Then permute to (B, h, N, N).
Then `pair_bias = self.pair_bias(pair_repr).permute(0,3,1,2)`.
pair_repr: (B, N, N, pair_dim). Linear to h -> (B, N, N, h). Then permute to (B, h, N, N).
Then `attn = attn_scalar + pt_attn + pair_bias`.
All are (B, h, N, N). Softmax over dim -1 (j).
Then `out_scalar = torch.einsum("bhij,bjhd->bihd", attn, v)`. v: (B, N, h, d). Output (B, N, h, d).
Then `out_pts = torch.einsum("bhij,bjhpc->bihpc", attn, v_pts)`. v_pts: (B, N, h, Vp, 3). Output (B, N, h, Vp, 3).
Then reshape and inverse frame.
All matches.
Potential numerical issues:
- The kernel recompute logit three times. Since rounding might differ slightly from PyTorch's computation which does it once and stores. But as long as the result is within atol=1e-4 for fp32, it should be fine. Actually, recomputing the same operations in the same order should give bitwise-identical results assuming deterministic math. But wait, in PyTorch, the einsum for scalar attention might use a different algorithm (e.g., GEMM) which sums in a different order. Our kernel does a simple loop `for di in 0..d-1: dot += q* k`. This sums in a different order than cuBLAS's tiled algorithm. The result may differ slightly, but should be within 1e-4 for fp32. Let's hope so.
For the point distance, PyTorch does `(q - k)**2.sum(dim=...)`. That's also a loop but might use vectorized operations. Our kernel does explicit loops. Differences should be small.
But there is a potential problem with softmax: PyTorch's softmax is numerically stable. We implement stable softmax (subtract max). That should match.
One more thing: `pair_bias` shape.
In reference: `pair_bias = self.pair_bias(pair_repr).permute(0, 3, 1, 2)`.
`pair_repr` is (B, N, N, pair_dim). Linear weight is (pair_dim, h). So output is (B, N, N, h). Permute -> (B, h, N, N).
In our Python code, we will compute this and pass it to the kernel. To make indexing easy, we can permute and then make contiguous. So inside kernel it's (B, h, N, N) and index `((b * h + h_idx) * N + i) * N + j`.
Alternatively, we can leave it as (B, N, N, h) and index `((b * N + i) * N + j) * h + h_idx`. That avoids the permute + contiguous. Let's do that! It saves a copy.
`pair_bias` in kernel: `int pb_base = ((b * N + i) * N + j) * h + h_idx;`
So in Python:
```python
pair_bias = self.pair_bias(pair_repr) # (B, N, N, h)
# no need to permute
```
But wait, in the reference, `pair_bias` is added to `attn` which is (B, h, N, N). PyTorch broadcasting will handle (B, N, N, h) vs (B, h, N, N) automatically because of aligned dimensions? Actually (B, h, N, N) + (B, N, N, h) would broadcast if we use unsqueeze or similar? Let's check:
- attn_scalar: einsum("bihd,bjhd->bhij") -> (B, h, N, N)
- pt_attn: permute to (B, h, N, N)
- pair_bias: after permute (0,3,1,2) -> (B, h, N, N).
So the reference explicitly permutes pair_bias to (B, h, N, N) before addition.
If we don't permute, inside the kernel we can just read from (B, N, N, h). That's fine. We just need to make sure the value at `[b, i, j, h]` is the same as the permuted `[b, h, i, j]`. Since the linear layer computes `pair_repr[b,i,j,:] @ W` which gives a vector of size h, stored at `[b,i,j,:]`. So `pair_bias[b,i,j,h]` is correct.
So we can skip the permute in Python.
Similarly, `head_weights`: we can compute `w = F.softplus(self.head_weights)` in Python and pass as a contiguous 1D tensor. Or pass raw and compute in kernel. Let's pass raw and compute stable softplus in kernel to avoid extra tensor. But we need exact match.
PyTorch's softplus formula:
```
beta = 1
threshold = 20
if x * beta > threshold: return x
else: return log(1 + exp(beta * x)) / beta
```
With beta=1.
So:
```
float hw = head_weights[h_idx];
float w = hw > 20.0f ? hw : log1pf(expf(hw));
```
Wait, for negative values, `expf(hw)` is safe. `hw = 0 -> log1pf(1) = log(2)`. Good.
For large positive, `hw > 20`, return hw. This is important for stability.
But `head_weights` is initialized to 0. For typical training, it might stay small. So it doesn't matter much. But let's implement it.
Wait, `log1pf(expf(hw))` for hw = 20 is `log1pf(4.85e8) ≈ log(4.85e8) ≈ 20.0`. Actually it's very close. So using `hw > 20.0f ? hw : log1pf(expf(hw));` matches PyTorch.
Actually, PyTorch's condition is `input * beta > threshold`. So for `hw > 20`, use `hw`.
Let's implement that in kernel.
Wait, but we also need `sqrt(d)` for scalar attn.
Reference: `attn_scalar = torch.einsum(...) / math.sqrt(d)`.
So divide by sqrt(d). Use `rsqrtf((float)d)`.
Now, let's write the complete kernel code carefully.
Also, the grid/block calculation must be done in the host wrapper since we need to call the kernel.
One issue: `block_size` must be a power of 2 for the reduction loop `for (int stride = block_size/2; stride > 0; stride >>= 1)`. If we fix block_size to 128, it's fine. But if we want to adapt to N, we can compute block_size as a power of 2.
But using a fixed block_size is simpler. I will use a fixed block_size of 128 or 256. Let's use 128. It should work for any N. For N < 128, some threads idle. For N > 128, threads loop. Reduction over 128 threads is correct because all threads set their `s_softmax[tid]` to either a valid value or -inf/0. But if `tid >= N` and `N < block_size`, those threads set `s_softmax[tid] = -inf` for max and `0` for sum. That is correct.
However, for `s_acc`, we initialize all entries to 0. Threads with no j will keep them at 0, which is correct for sum.
One issue: if `N` is very small (e.g., 1), then only thread 0 has a j. The other threads have logit = -inf? For max reduction, the max of valid logit and -inf is valid logit. For sum, the sum of valid exp and 0 is valid. So fine.
What about `j` index? `for (int j = tid; j < N; j += block_size)`. Good.
Now, let's think about shared memory bank conflicts for `s_acc`.
Layout: `s_acc[k * block_size + tid]`.
When we do reduction:
```
for (int stride = block_size/2; stride > 0; stride >>= 1) {
__syncthreads();
if (tid < stride) {
for (int k = 0; k < out_vec_size; ++k) {
s_acc[k * block_size + tid] += s_acc[k * block_size + tid + stride];
}
}
}
```
For a fixed `k`, threads `tid` and `tid + stride` access addresses `k*block_size + tid` and `k*block_size + tid + stride`. These are offset by `stride`. If `stride` is a multiple of 32, they might be in same bank? Bank = (address / 4) % 32.
Address1 = (k*block_size + tid) * 4. Bank1 = (k*block_size + tid) % 32.
Address2 = (k*block_size + tid + stride) * 4. Bank2 = (k*block_size + tid + stride) % 32.
If stride = 64, Bank2 = (Bank1 + 64) % 32 = Bank1 % 32. So they are in the SAME bank! That's a 2-way bank conflict.
But wait, the reduction reads two values: `s_acc[...tid...]` and `s_acc[...tid+stride...]`. Since they are in the same bank for each thread, the hardware can service them? Actually, each thread accesses two addresses. The shared memory has 32 banks, and each bank can service one request per cycle per warp. If all threads in a warp access two addresses that map to the same bank, that causes serialization. But here it's one read and one read from different addresses in the same bank. A single thread can access two different addresses from the same bank, but they will be serialized. However, if all threads in the warp access the same bank for both reads, it's a 2-way conflict. Not great but not terrible for small out_vec_size.
Wait, actually for reduction, each warp processes 32 threads (tid=0..31). For stride=64, `tid+stride` ranges from 64..95. Another warp handles those. But wait, the condition is `tid < stride`. So threads 0..63 active. Warps 0 and 1 are active.
For warp 0 (tid 0..31), it accesses:
- addr = k*block_size + tid (banks offset by tid)
- addr2 = k*block_size + tid + 64 (banks offset by tid+64, which mod 32 is same as tid).
So all threads in warp 0 read from bank (tid % 32) for the first address, and also from the same bank for the second address. Since it's two reads per thread, and both are in the same bank for each thread, but different addresses, the bank can handle one per cycle, so it's 2-way conflict per thread. Actually, the bank sees two requests for different addresses. It serializes them. For a warp, all threads request two reads from the same bank (different addresses per thread but same bank per thread). Since banks are per-thread addresses, each bank gets two requests (one from addr, one from addr2). It takes 2 cycles. Not a big deal for small arrays.
For stride=32, addr2 is offset by 32: bank = (tid+32)%32 = tid. Again same bank.
For stride=16, offset by 16: bank = (tid+16)%32 = tid+16 (different bank). No conflict! So the later steps are conflict-free. Only the large stride steps have conflicts.
Alternatively, we can do a warp-level reduction using shfl to avoid shared memory bank conflicts. But with 128 threads, we need multiple warps. Could reduce within warps first, then across warps.
For simplicity, the shared memory reduction is acceptable.
Another option: `s_acc` layout `s_acc[tid * out_vec_size + k]`. Then reduction would be:
```
for stride...
s_acc[tid * out_vec_size + k] += s_acc[(tid+stride) * out_vec_size + k];
```
Bank = (tid*out_vec_size + k) % 32. If out_vec_size=40, then consecutive threads access banks (tid*40+k)%32. As before, threads 0 and 4 share a bank. So writing to `s_acc[tid*out_vec_size + k]` for all threads has 4-way conflicts. The other layout (`k*block_size + tid`) is much better for writes but worse for reduction reads.
Actually, for the third pass (aggregation), each thread writes to its own slice:
```
s_acc[k*block_size + tid]
```
This is coalesced: consecutive threads access consecutive banks -> no conflicts. Great.
For reduction, we read `s_acc[k*block_size + tid]` and `s_acc[k*block_size + tid + stride]`. The two reads are offset by `stride`. If `stride` is a multiple of 32, they map to the same bank. But as discussed, only the first few strides (64, 32) are multiples of 32. For `stride=16`, offset by 16 -> different bank.
Overall, it's fine.
Let's write the code.
One more thing: we need to include `<math.h>` for `sqrtf`, `rsqrtf`, `expf`, `log1pf`, `fmaxf`.
Wait, `log1pf` is from `<cmath>` and available in CUDA.
Now, let's draft the complete Python code.
We need to use `torch.utils.cpp_extension.load_inline`.
```python
import torch
import torch.nn as nn
import torch.nn.functional as F
import math
from torch.utils.cpp_extension import load_inline
cpp_source = r'''
#include <torch/extension.h>
#include <cuda.h>
#include <cuda_runtime.h>
#include <math.h>
__global__ void ipa_forward_kernel(
const float* __restrict__ q,
const float* __restrict__ k,
const float* __restrict__ v,
const float* __restrict__ q_pts_local,
const float* __restrict__ k_pts_local,
const float* __restrict__ v_pts_local,
const float* __restrict__ pair_bias,
const float* __restrict__ rotations,
const float* __restrict__ translations,
const float* __restrict__ head_weights,
float* __restrict__ out,
int B, int N, int h, int d, int Qp, int Vp) {
int block_size = blockDim.x;
int tid = threadIdx.x;
int block_id = blockIdx.x;
int h_idx = block_id % h;
int tmp = block_id / h;
int i = tmp % N;
int b = tmp / N;
int out_vec_size = d + Vp * 3;
extern __shared__ float sbuf[];
float* s_acc = sbuf; // out_vec_size * block_size
float* s_softmax = sbuf + out_vec_size * block_size;
float* s_qpts = s_softmax + block_size;
float* s_rot = s_qpts + Qp * 3;
float* s_trans = s_rot + 9;
// initialize accumulation buffer
for (int k = 0; k < out_vec_size; ++k) {
s_acc[k * block_size + tid] = 0.0f;
}
__syncthreads();
// Load rotations and translations for i
if (tid == 0) {
int rot_base_i = ((b * N + i) * 3) * 3; // Actually rotations is (B,N,3,3)
// index: ((b*N+i)*3 + row)*3 + col
for (int row = 0; row < 3; ++row) {
for (int col = 0; col < 3; ++col) {
s_rot[row * 3 + col] = rotations[((b * N + i) * 3 + row) * 3 + col];
}
}
for (int c = 0; c < 3; ++c) {
s_trans[c] = translations[(b * N + i) * 3 + c];
}
}
__syncthreads();
// Compute q_pts_global for this head and store in shared mem
if (tid == 0) {
int qpts_base = ((b * N + i) * (h * Qp) + h_idx * Qp) * 3;
for (int p = 0; p < Qp; ++p) {
for (int c = 0; c < 3; ++c) {
float val = s_trans[c];
int base = qpts_base + p * 3;
for (int c2 = 0; c2 < 3; ++c2) {
val += q_pts_local[base + c2] * s_rot[c2 * 3 + c];
}
s_qpts[p * 3 + c] = val;
}
}
}
__syncthreads();
// head weight softplus
float hw = head_weights[h_idx];
float w = hw > 20.0f ? hw : log1pf(expf(hw));
// Precompute q base index
int q_base = ((b * N + i) * h + h_idx) * d;
// First pass: compute local max
float local_max = -1e30f;
for (int j = tid; j < N; j += block_size) {
// scalar dot product
float dot = 0.0f;
int k_base = ((b * N + j) * h + h_idx) * d;
for (int di = 0; di < d; ++di) {
dot += q[q_base + di] * k[k_base + di];
}
float logit = dot * rsqrtf((float)d);
// point distance
int kpts_base = ((b * N + j) * (h * Qp) + h_idx * Qp) * 3;
int trans_j_base = (b * N + j) * 3;
float pt_dist = 0.0f;
for (int p = 0; p < Qp; ++p) {
int base = kpts_base + p * 3;
for (int c = 0; c < 3; ++c) {
float kpt = translations[trans_j_base + c];
for (int c2 = 0; c2 < 3; ++c2) {
kpt += k_pts_local[base + c2] * rotations[((b * N + j) * 3 + c2) * 3 + c];
}
float diff = s_qpts[p * 3 + c] - kpt;
pt_dist += diff * diff;
}
}
logit += -(w * pt_dist) * 0.5f;
// pair bias
int pb_idx = ((b * N + i) * N + j) * h + h_idx;
logit += pair_bias[pb_idx];
local_max = fmaxf(local_max, logit);
}
s_softmax[tid] = local_max;
__syncthreads();
for (int stride = block_size / 2; stride > 0; stride >>= 1) {
if (tid < stride) {
s_softmax[tid] = fmaxf(s_softmax[tid], s_softmax[tid + stride]);
}
__syncthreads();
}
float global_max = s_softmax[0];
// Second pass: compute local sum of exp
float local_sum = 0.0f;
for (int j = tid; j < N; j += block_size) {
// recompute logit
float dot = 0.0f;
int k_base = ((b * N + j) * h + h_idx) * d;
for (int di = 0; di < d; ++di) {
dot += q[q_base + di] * k[k_base + di];
}
float logit = dot * rsqrtf((float)d);
int kpts_base = ((b * N + j) * (h * Qp) + h_idx * Qp) * 3;
int trans_j_base = (b * N + j) * 3;
float pt_dist = 0.0f;
for (int p = 0; p < Qp; ++p) {
int base = kpts_base + p * 3;
for (int c = 0; c < 3; ++c) {
float kpt = translations[trans_j_base + c];
for (int c2 = 0; c2 < 3; ++c2) {
kpt += k_pts_local[base + c2] * rotations[((b * N + j) * 3 + c2) * 3 + c];
}
float diff = s_qpts[p * 3 + c] - kpt;
pt_dist += diff * diff;
}
}
logit += -(w * pt_dist) * 0.5f;
int pb_idx = ((b * N + i) * N + j) * h + h_idx;
logit += pair_bias[pb_idx];
local_sum += expf(logit - global_max);
}
s_softmax[tid] = local_sum;
__syncthreads();
for (int stride = block_size / 2; stride > 0; stride >>= 1) {
if (tid < stride) {
s_softmax[tid] += s_softmax[tid + stride];
}
__syncthreads();
}
float global_sum = s_softmax[0];
// Third pass: aggregate
for (int j = tid; j < N; j += block_size) {
float dot = 0.0f;
int k_base = ((b * N + j) * h + h_idx) * d;
for (int di = 0; di < d; ++di) {
dot += q[q_base + di] * k[k_base + di];
}
float logit = dot * rsqrtf((float)d);
int kpts_base = ((b * N + j) * (h * Qp) + h_idx * Qp) * 3;
int trans_j_base = (b * N + j) * 3;
float pt_dist = 0.0f;
for (int p = 0; p < Qp; ++p) {
int base = kpts_base + p * 3;
for (int c = 0; c < 3; ++c) {
float kpt = translations[trans_j_base + c];
for (int c2 = 0; c2 < 3; ++c2) {
kpt += k_pts_local[base + c2] * rotations[((b * N + j) * 3 + c2) * 3 + c];
}
float diff = s_qpts[p * 3 + c] - kpt;
pt_dist += diff * diff;
}
}
logit += -(w * pt_dist) * 0.5f;
int pb_idx = ((b * N + i) * N + j) * h + h_idx;
logit += pair_bias[pb_idx];
float attn = expf(logit - global_max) / global_sum;
// aggregate scalar
int v_base = ((b * N + j) * h + h_idx) * d;
for (int di = 0; di < d; ++di) {
float vv = v[v_base + di];
s_acc[di * block_size + tid] += attn * vv;
}
// aggregate points
int vpts_base = ((b * N + j) * (h * Vp) + h_idx * Vp) * 3;
for (int p = 0; p < Vp; ++p) {
int base = vpts_base + p * 3;
for (int c = 0; c < 3; ++c) {
float vpt = translations[trans_j_base + c];
for (int c2 = 0; c2 < 3; ++c2) {
vpt += v_pts_local[base + c2] * rotations[((b * N + j) * 3 + c2) * 3 + c];
}
int idx_acc = d + p * 3 + c;
s_acc[idx_acc * block_size + tid] += attn * vpt;
}
}
}
__syncthreads();
// Reduce s_acc across block
for (int stride = block_size / 2; stride > 0; stride >>= 1) {
if (tid < stride) {
for (int k = 0; k < out_vec_size; ++k) {
s_acc[k * block_size + tid] += s_acc[k * block_size + tid + stride];
}
}
__syncthreads();
}
// Write output
if (tid == 0) {
int out_base = (b * N + i) * (h * d + h * Vp * 3);
for (int di = 0; di < d; ++di) {
out[out_base + h_idx * d + di] = s_acc[di * block_size + 0];
}
for (int p = 0; p < Vp; ++p) {
float global_pt[3];
for (int c = 0; c < 3; ++c) {
global_pt[c] = s_acc[(d + p * 3 + c) * block_size + 0];
}
for (int d_out = 0; d_out < 3; ++d_out) {
float local_val = 0.0f;
for (int c = 0; c < 3; ++c) {
float centered = global_pt[c] - s_trans[c];
local_val += centered * s_rot[d_out * 3 + c];
}
out[out_base + h * d + h_idx * Vp * 3 + p * 3 + d_out] = local_val;
}
}
}
}
torch::Tensor ipa_forward_cuda(
torch::Tensor q,
torch::Tensor k,
torch::Tensor v,
torch::Tensor q_pts_local,
torch::Tensor k_pts_local,
torch::Tensor v_pts_local,
torch::Tensor pair_bias,
torch::Tensor rotations,
torch::Tensor translations,
torch::Tensor head_weights) {
int B = q.size(0);
int N = q.size(1);
int h = q.size(2);
int d = q.size(3);
int Qp = k_pts_local.size(2) / h;
int Vp = v_pts_local.size(2) / h;
int out_features = h * d + h * Vp * 3;
auto out = torch::zeros({B, N, out_features}, q.options());
int block_size = 128;
int total_blocks = B * N * h;
int out_vec_size = d + Vp * 3;
size_t shared_mem_size = sizeof(float) * (out_vec_size * block_size + block_size + Qp * 3 + 12);
ipa_forward_kernel<<<total_blocks, block_size, shared_mem_size>>>(
q.data_ptr<float>(),
k.data_ptr<float>(),
v.data_ptr<float>(),
q_pts_local.data_ptr<float>(),
k_pts_local.data_ptr<float>(),
v_pts_local.data_ptr<float>(),
pair_bias.data_ptr<float>(),
rotations.data_ptr<float>(),
translations.data_ptr<float>(),
head_weights.data_ptr<float>(),
out.data_ptr<float>(),
B, N, h, d, Qp, Vp
);
return out;
}
'''
module = load_inline(
name='ipa_cuda',
cpp_sources=cpp_source,
functions=['ipa_forward_cuda'],
extra_cuda_cflags=['-O3'],
verbose=False,
)
class ModelNew(nn.Module):
def __init__(self, node_dim, pair_dim, num_heads, num_query_points, num_value_points):
super().__init__()
self.num_heads = num_heads
self.head_dim = node_dim // num_heads
self.num_query_points = num_query_points
self.num_value_points = num_value_points
assert node_dim % num_heads == 0
d = self.head_dim
self.q_proj = nn.Linear(node_dim, num_heads * d, bias=False)
self.k_proj = nn.Linear(node_dim, num_heads * d, bias=False)
self.v_proj = nn.Linear(node_dim, num_heads * d, bias=False)
self.q_pts = nn.Linear(node_dim, num_heads * num_query_points * 3, bias=False)
self.k_pts = nn.Linear(node_dim, num_heads * num_query_points * 3, bias=False)
self.v_pts = nn.Linear(node_dim, num_heads * num_value_points * 3, bias=False)
self.pair_bias = nn.Linear(pair_dim, num_heads, bias=False)
self.head_weights = nn.Parameter(torch.zeros(num_heads))
out_features = num_heads * d + num_heads * num_value_points * 3
self.out_proj = nn.Linear(out_features, node_dim)
self.layer_norm = nn.LayerNorm(node_dim)
def forward(self, node_repr, pair_repr, translations, rotations):
B, N, D = node_repr.shape
h = self.num_heads
d = self.head_dim
Qp = self.num_query_points
Vp = self.num_value_points
x = self.layer_norm(node_repr)
q = self.q_proj(x).view(B, N, h, d)
k = self.k_proj(x).view(B, N, h, d)
v = self.v_proj(x).view(B, N, h, d)
q_pts_local = self.q_pts(x).view(B, N, h * Qp, 3)
k_pts_local = self.k_pts(x).view(B, N, h * Qp, 3)
v_pts_local = self.v_pts(x).view(B, N, h * Vp, 3)
pair_bias = self.pair_bias(pair_repr) # (B, N, N, h)
out = module.ipa_forward_cuda(
q.contiguous(), k.contiguous(), v.contiguous(),
q_pts_local.contiguous(), k_pts_local.contiguous(), v_pts_local.contiguous(),
pair_bias.contiguous(),
rotations.contiguous(),
translations.contiguous(),
self.head_weights.contiguous(),
)
return self.out_proj(out)
```
Wait, I used `r''' ... '''` for the C++ string to avoid escaping issues. That's fine.
But I need to double-check the rotation indexing in the kernel.
`rotations` is passed as a contiguous tensor of shape (B, N, 3, 3). The stride is (N*9, 9, 3, 1).
So element `[b, n, row, col]` is at `((b * N + n) * 3 + row) * 3 + col`. Which matches `((b * N + j) * 3 + c2) * 3 + c`.
Good.
For translations: shape (B, N, 3). Index `[(b*N + j)*3 + c]`. Good.
For q_pts_local: shape (B, N, h*Qp, 3). The head-specific base is `((b*N + i) * (h*Qp) + h_idx*Qp) * 3`. Good.
For v_pts_local: shape (B, N, h*Vp, 3). Base `((b*N + j) * (h*Vp) + h_idx*Vp) * 3`. Good.
For pair_bias: shape (B, N, N, h). Index `((b*N + i)*N + j)*h + h_idx`. In the reference, unpermuted. Good.
One thing: in the kernel, the line `int rot_base_i = ((b * N + i) * 3) * 3;` is just a comment. The actual loading uses `rotations[((b * N + i) * 3 + row) * 3 + col]`. Good.
Now, check `s_qpts` usage. We compute `s_qpts` for query points. In j-loop, we read `s_qpts[p*3+c]`. Good.
In third pass, for v_pts_global transform:
We compute `vpt = translations[trans_j_base + c] + sum_c2 v_pts_local[...] * rotations[...]`. That's correct.
After reduction, we apply inverse frame to the accumulated out_pts. The accumulated out_pts is in global frame. We compute local by `centered = global_pt[c] - s_trans[c]`, then `local_val += centered * s_rot[d_out*3 + c]`. Since `s_rot[row*3+col]` stores `R[row,col]`, then `s_rot[d_out*3 + c]` is `R[d_out, c]`. This matches `_apply_inv_frame`: `local[p,d] = sum_c centered[p,c] * rotations[b,n,d,c]`. Yes.
What about the output `out` pointer? It's `float* __restrict__ out`.
`out_base = (b * N + i) * (h*d + h*Vp*3)`.
`out[out_base + h_idx*d + di]` -> scalar output for head h_idx.
`out[out_base + h*d + h_idx*Vp*3 + p*3 + d_out]` -> point output for head h_idx.
This matches concatenation order.
One more nuance: `pair_bias` is not permuted, but in reference it is permuted and then added. Does broadcasting behavior matter? No, because addition is elementwise. As long as we read the correct value for each `(b, h, i, j)`, it's fine. In reference, after permute, `pair_bias[b, h, i, j]` corresponds to original `pair_bias[b, i, j, h]`. So indexing `((b*N+i)*N + j)*h + h_idx` is correct.
Now, compile and check.
Before calling tools, let's think if there are any issues.
Potential issue: `log1pf` requires `#include <math.h>`. In CUDA, `log1pf` is available. `expf` too.
Potential issue: `rsqrtf((float)d)`. Good.
Potential issue: `block_size / 2` with block_size=128. For loop `for (int stride = block_size / 2; stride > 0; stride >>= 1)`. This is fine.
Potential issue: shared memory size calculation. `size_t shared_mem_size = sizeof(float) * (out_vec_size * block_size + block_size + Qp * 3 + 12);`
The `12` accounts for s_rot (9) + s_trans (3). Good.
Total dynamic shared memory per block. A100 has 164KB shared memory per SM. 48KB is the default limit per block. Let's calculate worst-case shared mem.
- out_vec_size: d + Vp*3. If node_dim=64, h=4, d=16. Vp=8, so Vp*3=24. out_vec_size=40.
- block_size=128.
- s_acc: 40*128*4 = 20480 bytes = 20KB.
- s_softmax: 128*4 = 512 bytes.
- s_qpts: Qp*3*4 = max maybe 8*3*4=96 bytes.
- s_rot: 9*4=36.
- s_trans: 3*4=12.
Total ~20.6KB. Well below 48KB.
If dims are larger, e.g., node_dim=256, h=8, d=32. Vp=12, Vp*3=36. out_vec_size=68.
s_acc: 68*128*4 = 34816 bytes = 34KB.
Total ~35KB. Still okay.
If node_dim=512, h=16, d=32, Vp=12: out_vec_size=68. Same. h doesn't affect out_vec_size per block.
So shared mem is safe.
What about register pressure? The kernel has many nested loops. The compiler should unroll small loops.
Performance: the kernel recomputes k_pts_global and v_pts_global inside each pass. For v_pts_global in third pass, it's necessary. For k_pts_global in first/second pass, it's necessary for distance. The global memory loads for k_pts_local, rotations, translations are repeated. But with small N, it's fine.
One optimization: we can precompute and store `k_pts_global` for all j in shared memory to avoid recomputation in second and third passes. But Qp*3*N floats = 12*32*4 = 1.5KB per block. Actually, we have block per head, so it's per (b,i,h). Wait, k_pts_global depends on j, so for each j. Precomputing for all j would need N * Qp * 3 floats per block = 32*12*4=1.5KB. But then we also need to do it for v_pts_global (N*Vp*3*4=3KB). Not a big saving.
But recomputing means we also recompute pair_bias (load global mem) and dot product (load global mem). The main bottleneck is memory loads, not computation. Reusing loaded values would be better. For block_size=128 and N=32, each j is handled by exactly one thread. Each thread loads k_j, v_j, k_pts_j, v_pts_j, rotation_j, translation_j once per pass. That's 3 times. We could do:
- First pass: compute logit, store in shared mem logit[j] (N floats).
- Second pass: read shared mem logit, compute softmax.
- Third pass: compute attn from stored logit and aggregate.
This would reduce recomputation of logit from 3 to 1, but still require computing k_pts transform for pt_dist in first pass. If we store pt_dist or the full logit, we save computation.
But shared mem for logits: N * block_size? No, we need one logit per j. Since each thread handles one j, it can store its computed logit in a register or shared memory. Since each thread handles only one j (if N <= block_size), it can store its logit in a register. But we need warp-level/block-level max reduction. We can pass the logit directly to the reduction without storing. Then after global_max and global_sum are known, we loop again? We need the logit for aggregation.
If N is small (<= block_size), each thread handles one j. We can do:
- Each thread computes logit_j and stores in a register `float logit_j;`.
- Block-level max reduction: we need to pass logit_j to reduction. We can write to shared mem, reduce, get global_max. Similarly for sum.
But we can also do warp-level shfl reduction if block_size is warp-size or multiple. But block_size=128=4 warps. We'd need inter-warp reduction shared mem anyway.
- After global_sum is known, each thread can compute attn_j = exp(logit_j - global_max) / global_sum and then do aggregation. This means we only compute logit once! Great.
Let's adjust the design for the case where each thread handles one or few j's. We can store logit and other needed things in registers or a small shared memory if the number of j's per thread > 1 but small. However, if N is large, each thread handles many j's and we can't store all logits in registers. But we can recompute for large N.
To keep it simple and generic, maybe we stick with 3 passes. The compiler might not inline everything perfectly, but for N=32 it's okay.
Alternatively, we can do a hybrid: if N <= block_size, store logits in shared memory `float s_logits[block_size];`. Since block_size is fixed at compile time? We can allocate `float* s_logits = s_softmax + block_size;` but that adds to dynamic shared mem. Actually, we can reuse `s_softmax` area if we're careful. Let's not overcomplicate. 3 passes is acceptable.
Wait, I can optimize: combine first and second pass into one? Not easily because we need global_max before sum.
Let's compile with the existing code.
Also, I need to make sure that my Python code imports `math` since I don't need it? Actually no, but good to have.
One more thing: `load_inline` might need `extra_cuda_cflags=['-O3', '--use_fast_math']`? `-O3` is fine. `--use_fast_math` could cause precision issues (e.g., `expf`). We'll avoid `--use_fast_math` to ensure correctness.
Let's write the Python code string properly.
I will define `cuda_src` containing both C++ wrapper and kernel. Since `load_inline`'s `cpp_sources` can include CUDA code? Yes, `cpp_sources` can contain `__global__` functions; the extension loader compiles them with nvcc.
Make sure to include the correct headers.
One more check: `torch::Tensor` methods. `q.options()` includes device and dtype. Good.
Let's create the code.