reasoning
We need to write a custom CUDA kernel that replaces the forward computation of the model above. The model:
- nodes: (B, N, D)
- pair_feats: (B, N, N, P)
- edge_mask: (B, N, N) float {0,1}
- hi = nodes.unsqueeze(2).expand(-1,-1,N,-1) => (B,N,N,D)
- hj = nodes.unsqueeze(1).expand(-1,N,-1,-1) => (B,N,N,D)
- cat [hi, hj, pair_feats] => (B,N,N, 2D + P)
- fuse = nn.Sequential(Linear(2D+P, hidden), SiLU(), Linear(hidden, D))
- fused = fuse(cat) => (B,N,N,D)
- fused = fused.masked_fill(edge_mask.unsqueeze(-1) < 0.5, neg) where neg = minimum float value
- pooled, _ = fused.max(dim=2) => (B,N,D) max over j (the middle dim)
- out = nodes + LayerNorm(pooled) => (B,N,D)
We need to produce a custom CUDA kernel. The parameters dim=96, pair_dim=16, hidden=192, but we should write generic kernel that handles these sizes, maybe using compile-time constants where possible.
We need to keep the weights of the MLP and the LayerNorm parameters inside the ModelNew as nn.Parameter or similar, and load them into the kernel. Since the fused MLP and SiLU and LayerNorm need weights and biases.
Plan: In ModelNew.__init__, we will allocate the same parameters as reference: two Linear layers and one LayerNorm. But we need to implement the fused kernel that does everything in one go per output element. However, the max reduction over j requires computing fused(i,j) for all j.
Given sizes: B=4, N=24, D=96, P=16, H=192. Not huge. But to be faster than PyTorch reference, we need to fuse the MLP + mask + max reduction + residual + layer norm into a single kernel, avoiding intermediate global memory.
We can use a CUDA kernel that computes for each batch b and node i (and maybe each output dimension d), the maximum over j of the fused MLP output. Since D=96, H=192, we could assign each block to (b, i) and each thread to a chunk of d and j. But max over j requires reduction.
Alternative: compute for each (b, i, j) the fused MLP output and store in shared memory / registers, then reduce over j. But N=24 is small. We could process all j within a warp or thread block.
Given D=96, we can have each thread compute one dimension d (or multiple). Max over j is independent per d. So for each (b,i,d), compute max_j MLP_{d}( concat( node_i, node_j, pair_feat_{ij} ) ). This is embarrassingly parallel across b,i,d. Total output elements = B * N * D = 4*24*96 = 9216. If we launch one thread per output element, each thread loops over j from 0 to N-1 (24 iterations) and computes the MLP value for each j, tracks max.
Each thread does: for j in 0..N-1:
- read node_i[d] (D loads)
- read node_j[0..D-1] (D loads)
- read pair_feat[0..P-1] (P loads)
- compute hidden: for h in 0..H-1: sum = w1[h, d_local?] Actually first linear: input size = 2D+P = 2*96+16 = 208. Output size = H = 192.
compute z1[h] = sum_{k=0}^{207} in[k] * W1[k,h] + b1[h].
- apply SiLU: z1[h] * sigmoid(z1[h])
- compute z2[d] = sum_{h=0}^{191} z1[h] * W2[h,d] + b2[d].
- mask: if edge_mask < 0.5, set to -inf.
- track max.
That is 192 + 192 = 384 hidden units. If each thread does this for each j, 24 * 384 = ~9216 multiply-adds per thread. 9216 threads * 9216 ops = ~85M ops. On GPU, 85M ops is tiny. So compute is not bound; memory bound is important. Fusing into a single kernel avoids storing intermediate (B,N,N,D) tensor, which is 4*24*24*96 = 221k floats ~ 0.9 MB. Not huge either. But still, a fused kernel can be faster.
However, each thread computing the full MLP for each j means reading weights W1 and W2 many times. We can load weights into shared memory or rely on L2 cache. Since H=192, D=96, P=16, W1 is (208, 192) floats = 159,744 floats ~ 640KB. W2 is (192, 96) = 73,728 floats ~ 295KB. Total < 1MB. They can fit in L2 or shared memory. But each thread reading all weights may cause register pressure (need to store intermediate z1 for 192 values). With 192 floats per thread, that's 192*4=768 bytes. For 1024 threads per SM, that's 768KB, okay. But register usage may be high. Maybe use shared memory for W1 and W2, and each thread computes a subset of h for the first linear, then reduce? Or compute output d directly by having hidden pre-activation computed on the fly. Since we need max over j, we need the final scalar per d. For each d, we can compute:
For each j, compute vector x = concat(node_i, node_j, pair_ij). Then compute MLP(x) for fixed d. That requires computing the hidden pre-activations (z1[h]) and applying SiLU, then dot with W2[:,d]. So we need all z1[h] for h=0..191. Could recompute them per d, but that's wasteful. Better: compute the entire output vector for a given j (size D=96) and compare to running max per d. That requires storing 96 floats as running max, plus 192 floats as hidden activations. Total ~288 floats ~ 1152 bytes registers. Acceptable.
Alternatively, we can have a block of threads process (b,i) and have threads cooperate on j. For example, blockDim = (D, 1, 1) = 96 threads. Each thread computes a single d. For each j, thread d reads input, computes z1[h] for all h (192) using shared weights, applies SiLU, then dot with W2[h,d] to get output. This is similar to the per-thread approach but with 96 threads per block. Each thread still does 208*192 + 192 dot products = a lot. But 96 threads * 24 j = 2304 thread-j iterations. Actually each block handles one (b,i). There are B*N = 96 such blocks. 96 blocks total. That's very few blocks. GPU has many SMs, but 96 blocks may be okay if each block does enough work. But occupancy might be low because each block needs many registers.
Alternative: have each block process a batch of (b,i) pairs. E.g., blockDim = 256 threads. Each thread is assigned a (d) dimension and also loops over j? Hard.
Since N=24, perhaps process all j in parallel within a warp. For each (b,i,d), we have 24 lanes compute the MLP for each j, then do a warp-level max reduction. That would be efficient: each warp handles one (b,i,d). There are B*N*D = 9216 warps? Actually each warp is 32 threads. We could assign 24 threads of a warp to j=0..23, and the remaining 8 threads idle. For a given (b,i,d), we load the weights into shared memory or registers, broadcast node_i and the target d weights. Each lane j loads node_j and pair_feat_{ij}, computes the MLP output for that j, then warp-level max over the 24 active lanes. But each lane needs to compute the MLP for a single j. The MLP computation per lane is independent. Then we reduce. That seems very nice.
Specifically, for each (b,i,d), we have a warp of 32 threads. Threads 0..23 handle j=0..23. They load node_j (D floats) and pair_feat (P floats). They also need node_i (D floats) - can be broadcast from lane 0 or loaded by each lane (same address, cached). They compute the MLP to produce a scalar out_d. Then we do a warp-level max reduction (using __shfl_sync or warp reductions). The result is the max over j for that (b,i,d). Then we can apply layer norm and residual.
How many warps do we need? B*N*D = 9216. At 32 threads per warp, that's 9216 warps. Each warp does 24 * (208*192 + 192*96?) Actually each lane computes one MLP output for one j. That's 208*192 + 192*96? No, the MLP output is one scalar (for a specific d). Wait, if each lane computes for a specific d, then the second linear is dot product over h for that d only. So per lane, compute z1[h] for all h (192) using W1 and x, then for the specific d, compute sum_h z1[h] * W2[h,d]. That's 208*192 multiply-adds for z1, plus 192 multiply-adds for z2, total ~40k multiply-adds per lane. Since each warp has 24 lanes, total ~960k multiply-adds per warp. 9216 warps => ~8.9 billion ops. Wait, earlier I computed 85M ops. Let's recalc.
The full MLP for all d and one j: input (208) -> hidden (192) -> output (96). That's 208*192 + 192*96 = 39,936 + 18,432 = 58,368 multiply-adds. For each (b,i,j). Number of (b,i,j) = B*N*N = 4*24*24 = 2304. Total ops = 2304 * 58,368 ≈ 134.5 million multiply-adds. That's the total work if doing full vector output. But if each warp computes for a single d, then per (b,i,j,d) we do 208*192 + 192 = 40,128 multiply-adds. Number of (b,i,j,d) = 2304*96 = 221,184. Total ops = 221,184 * 40,128? Wait, no. Actually if we compute per d, the hidden layer z1 is computed for all h regardless of d. So the work per (b,i,j,d) is 208*192 + 192 = 40,128. That seems to multiply the total ops by D=96 compared to computing all d at once? Let's think.
In the full vector case, for each (b,i,j), we compute z1 (208*192 ops) once, then compute all 96 outputs (192*96 ops). Total per (b,i,j) = 208*192 + 192*96 = 58,368.
In the per-d case, for each (b,i,j,d), we compute z1 (208*192 ops) and then one output (192 ops). But z1 does not depend on d, so we recompute it 96 times for the same (b,i,j). That is wasteful. So the per-d assignment is not good unless threads share the hidden computation. But in a warp, each lane computes for a different j, not a different d. Actually, in the warp-level reduction proposal, each lane handles a different j. So for a fixed d, all lanes compute for different j. The z1 computation is per lane (per j), and there is no reuse across lanes because j differs. But there is also no reuse across d: if we have one warp per (b,i,d), then for each d we recompute z1 for all j. Since there are D=96 warps per (b,i), we recompute z1 96 times. That multiplies the compute by a factor of D. Total ops would be 96 * 2304 * (208*192 + 192) ≈ 96 * 2304 * 40,128 ≈ 8.9 billion ops. That is much more than the reference's 134 million ops. So that approach is bad.
We need to avoid recomputing the hidden layer per d. Instead, each thread / warp should compute the full output vector (all D) for a given (b,i,j) or group of j's. Then max-reduce across j.
Better approach: assign each thread block (or warp) to a (b,i). Within the block, each thread computes a subset of d dimensions. For each j, the block computes the MLP output for all d. Since the MLP hidden layer is shared across all d, each thread can compute the hidden z1 and then apply the second linear for its assigned d's. But if each thread handles multiple d's, it still needs to compute z1 once per j. Good. Let's formalize.
Block configuration: Let's use blockDim.x = D (96) threads per block, each handling one d. BlockDim.y = 1. But D=96 is small; we could use blockDim.x = 32 or 64 and have each thread handle multiple d. Let's consider blockDim.x = 32, each thread handles 3 d's (since 96/32=3). Or blockDim.x = 64, each handles 1 or 2 d's. Let's try to maximize occupancy. But since the problem is memory/compute bound with small sizes, maybe a simple approach works.
Let's think about block-level shared memory. W1: (2D + P) * H = 208 * 192 floats ≈ 160kB. W2: H * D = 192 * 96 ≈ 73kB. Total ≈ 233kB. Shared memory per block limit is 48KB or 96KB depending on arch. The GPU is likely A100 or similar. But 233kB is too large for shared memory. We cannot load all weights into shared memory. We can load them into registers or rely on L1/L2 cache. Since the weights are read repeatedly (for each j and each thread), caching is beneficial. We can also tile the computation: compute hidden in chunks, load weights for that chunk into shared memory.
But maybe we don't need to load all weights. We can stream through W1 and W2 from global memory. Since the weights are small (total < 1MB) and read many times, they will be cached in L2. The key is to have coalesced reads.
Alternative: we can pre-transpose W1 to be (H, 2D+P) for better memory access when computing z1? The natural way: for each hidden h, compute dot product of x (length 208) with W1[:,h]. If W1 is stored as (H, 208), then each thread computing one h can read W1[h,:] contiguously. But threads computing different h's will read different rows. If we have blockDim.x = H (192), then each thread handles one h, reads its row of W1. That is coalesced if W1 is row-major? For float, adjacent threads reading adjacent rows: thread 0 reads offset 0, thread 1 reads offset 208, etc. Stride is 208 floats = 832 bytes. That's not coalesced across a warp (32 threads reading with stride 832 bytes = 26,624 bytes apart). That is bad. Better store W1 as (208, H), column-major for hidden? But we can also store as (H, 208) and have each warp read a tile.
Alternatively, we can compute the output via matrix multiplications using cuBLAS or torch.matmul, but the overhead of launching multiple small gemms might be high. However, we can use torch.matmul for the two linears, but we need to avoid materializing the (B,N,N,2D+P) concatenated tensor? Actually we can use custom indexing: hi, hj, pair_feats can be combined via einsum? But the reference already uses standard ops. To beat it, we need to fuse.
Given the small sizes (B=4, N=24), a highly fused kernel is likely best. We can write a kernel where each block processes one (b,i) and has threads cooperating on j and d.
Let's design the kernel:
Grid: (B, N) blocks. Each block computes pooled[b,i,:] (max over j of MLP output).
BlockDim: Maybe (D, J_TILE) where J_TILE is a number of j's processed in parallel. But max over j requires horizontal reduction. Alternatively, blockDim = (D, 1) and each thread loops over j sequentially.
If blockDim.x = D = 96 threads. Then for each j, the block computes the MLP for all d. To compute the MLP, the block can compute hidden z1 in shared memory: each thread computes one or more h? No, D=96, H=192. If we assign 96 threads, each thread could compute 2 h's. Or we can compute z1 using all threads and store in shared memory: size H = 192 floats. Each thread computes 2 h's (since 96 threads). That is manageable. Then each thread (thread d) computes the second linear dot product over h for its d. Let's see:
For a fixed (b,i) block:
- Load node_i (D floats) into shared memory.
- Load W1? Can't load all. But we can compute z1[h] by having each thread accumulate over a subset of input features. Since input x = concat(node_i, node_j, pair_ij). For a given j, x is known. We need z1[h] = sum_k W1[k,h] * x[k] + b1[h].
If we have 96 threads, each thread can compute one d of the final output, but to compute that it needs z1[h] for all h. We can compute z1 in shared memory collaboratively:
Option A: For each j, each of the 96 threads loops over h=0..191 and computes z1[h]? No, each thread computing 192 h's means 96*192 = 18,432 parallel ops per j. That's okay. But then each thread also needs to read W1 and x. If each thread computes all h's, it's redundant but they all compute the same z1? No, we can have thread 0 compute all h's and store in shared memory, then all threads read it. But thread 0 computing all h's means 208*192 multiply-adds. That's a lot for one thread. Better to parallelize over h.
Option B: Use H threads (192) to compute z1. But our block size is limited. We could have block size = 192 threads (e.g., 192 or 256). Then 192 threads compute z1[h] each. Each thread h reads x (208 floats) and W1[:,h] (208 floats) to compute dot product. Doing 192 dot products of length 208. That's 192*208 = 39,936 multiply-adds per j. If each thread does one dot product, that's perfect. Then after z1 is in shared memory (192 floats), we need to compute output d = sum_h W2[h,d] * SiLU(z1[h]) + b2[d]. We have 192 threads. We could assign D=96 threads to compute the output; the other 96 threads are idle for this step. Or we can have all 192 threads compute the output by assigning each output d to a thread, but D=96 < 192. So 96 threads compute output, 96 idle. That's okay.
So block configuration: blockDim.x = max(H, D) = 192. Or maybe 256 for better warp alignment. Let's use 192 threads.
Grid: (B, N)
Processing per block:
1. Load node_i (D floats) into shared memory `s_node_i[D]`.
2. For each j from 0 to N-1:
a. Load node_j (D floats) into shared memory `s_node_j[D]`.
b. Load pair_feat_{ij} (P floats) into shared memory `s_pair[P]`.
c. Load edge_mask_{ij} (float) into a register or shared memory.
d. Compute z1[h] for each thread h (0..191):
- x is concatenation of s_node_i, s_node_j, s_pair. Total length L = 2D+P = 208.
- Each thread h computes dot product of x with W1[:,h] plus b1[h].
- W1 is stored in global memory as shape (H, L) or (L, H). To get good coalescing, we want threads in a warp reading adjacent memory. If W1 is stored as (H, L) (row-major), thread h reads W1[h, k] for k. Adjacent threads h and h+1 read W1[h, k] and W1[h+1, k]. That is strided by L floats = 832 bytes. Not coalesced. If W1 is stored as (L, H) (column-major), thread h reads W1[k, h] at offset k*H + h. For a fixed k, adjacent threads read adjacent floats: offset h. That's perfectly coalesced! So store W1 transposed: shape (L, H). Then thread h reads W1[k, h] for k=0..L-1. That's great. Similarly, W2 should be stored as (H, D) or (D, H)? For computing output, we need sum_h W2[h,d] * act[h]. If we store W2 as (D, H) (i.e., row d contains W2[:,d]?), then thread d reads W2[d, h] with stride H = 192. Not coalesced if threads are d. If we store W2 as (H, D), then thread d reads W2[h, d] with offset h*D + d. For a fixed h, adjacent d's read adjacent floats. So we should parallelize the output over d. Let's say we have a second phase with D=96 threads active. To coalesce reading W2, store as (H, D). Then threads d=0..95 read W2[h, d] at offset h*D + d. That's coalesced.
So weight layout:
- W1: shape (L, H). We'll pass it as a tensor of that shape from PyTorch (which is row-major). In PyTorch, a Linear layer weight is (out_features, in_features). For `nn.Linear(208, 192)`, weight is (192, 208). To get (208, 192), we can pass `weight.t().contiguous()`.
- W2: shape (192, 96). PyTorch weight is (96, 192). Transpose to (192, 96).
- b1: (192,)
- b2: (96,)
- ln_weight: (96,)
- ln_bias: (96,)
But we need to read these weights efficiently. Each thread will read one column of W1 (length L=208). Since W1 is (L, H), column h is contiguous. So thread h reads 208 floats sequentially. That's good memory bandwidth per thread. For W2, we may need to read by rows or columns. Since in output phase we have D threads reading W2 for each h, we can store W2 as (H, D) and have threads read across the row dimension? Wait, if W2 is (H, D), then for a given h, the elements W2[h, d] are contiguous in d. So D threads reading d=0..D-1 for fixed h is coalesced. We can iterate h from 0..H-1, load W2[h,d] into a register, load act[h] (broadcast), multiply-accumulate.
Alternatively, we can store W2 transposed as (D, H) and have each thread d read W2[d, :] sequentially, and broadcast act[h]. But then reads are sequential per thread but not coalesced across threads. Coalescing across threads is more important. So store W2 as (H, D).
Now, blockDim.x = max(H, D) = 192. But for phase 2 we only need 96 threads. Could have blockDim.x = 192 and use threadIdx.x < 192 for phase 1, and threadIdx.x < 96 for phase 2. That leaves threads 96..191 idle in phase 2. That's okay. Or we can have blockDim.x = 96 and have each thread compute 2 h's in phase 1. Let's explore both.
BlockDim = 192:
- Phase 1 (compute hidden): 192 threads. Each thread reads W1[:,h] (208 floats) and x (208 floats) from shared memory. Compute dot product. That's 208 reads from global (W1) and 208 reads from shared (x) per thread. 192 threads per block. For j=24, total W1 reads per block = 192 * 208 * 24 = 958k floats. Since W1 is 40k floats, each element is read ~24 times (once per j). That's good for cache. But each thread reads a different column, so total memory bandwidth per j is 192*208*4 bytes ≈ 160KB from W1. Times 24 j = 3.8MB. Very small. So bandwidth is not an issue; compute is.
Wait, but 192 threads per block * (B*N) blocks = 192 * 96 = 18,432 threads total. That's not many. But each thread does 208 multiply-adds for dot product. The total FLOPs is 208*192*24*96 ≈ 92M (for first linear) + 192*96*24*96 ≈ 42M (for second linear) = 134M. That's exactly the same as reference. So compute is small. The main win is fusing and avoiding writing intermediate global memory. Since the compute is small, kernel launch overhead and memory latency matter. To hide latency, we need enough warps. With 18,432 threads, there are 18,432/32 = 576 warps. That's plenty to occupy the GPU (A100 has 108 SMs, up to 64 warps per SM). So occupancy is fine.
But note: each block has 192 threads = 6 warps. 96 blocks => 576 warps. Good.
However, the block processing per j loads node_j and pair_feat. Using shared memory for node_j and pair_feat: each block loads D floats (node_j) and P floats (pair_feat) per j. That's small. node_i is loaded once. Alternatively, threads can read directly from global memory since N is small.
Potential issue: blockDim=192 means we need to handle the case where H > D but D might be different. The kernel should be generic for any dim, pair_dim, hidden. But the problem says we have fixed sizes? Actually ModelNew accepts same constructor arguments. But for speed we might compile-time specialize? With `load_inline`, we can include the sizes as template parameters if we want, but it's tricky because the constructor arguments are dynamic. However, the reference test uses specific sizes, but the evaluation may test with different sizes (though likely same). To be safe, we should write a generic kernel that handles any dim, pair_dim, hidden. But using blockDim=hidden may be too large if hidden is big. We can set blockDim at runtime based on hidden. Or we can fix blockDim to a constant (e.g., 256) and have each thread handle multiple elements.
The safest generic kernel: blockDim.x = 256 (or 128). Each thread computes a chunk of hidden units. For example, total hidden = H. If H <= 256, we can have blockDim = nearest power of two >= H, or just blockDim = 256 and use only first H threads. Similarly for D.
But if H and D vary, we need to handle reductions across threads. E.g., computing the second linear: each thread has partial sum for its assigned h's, but we need sum over all h. That's a reduction.
Alternatively, we can write the kernel to handle arbitrary sizes by having each thread compute the full output for a subset of j's, not splitting across h or d. For example, assign each thread a (b,i,d) and have it loop over j. That's the simplest generic kernel. Since the total number of output elements is B*N*D, we can launch a grid with one thread per output element (or per block of elements). Each thread loops over j=0..N-1 and computes the MLP for that (b,i,j,d). But as we noted, this recomputes the hidden layer for each d, leading to D times more compute. However, if we write the kernel in a way that each thread computes the full output vector (all d) for each j, then we have B*N*D_total. Let's see.
Option: One thread per (b,i). That would be B*N = 96 threads. Each thread loops over j and for each j computes the full D-vector output, updates a running max per d. At the end, writes D floats. This has minimal parallelism (96 threads) but each thread does a lot of work. Not enough to saturate GPU. Could use one block per (b,i) with many threads.
Option: One block per (b,i), blockDim = D (or multiple of 32). Each thread handles one d. As discussed, each thread would need to compute hidden z1 for each j, which is wasteful. But we can cooperate: the block computes z1 collaboratively for each j, stores in shared memory, then each thread computes its d using W2. That is good.
So the collaborative block approach is best.
Let's design a generic kernel for arbitrary D, P, H, but we can assume D, H are not huge (like <= 256). The evaluation may vary sizes, but typical sizes are similar. However, we cannot assume H <= 256? Well, we can set block size to be a compile-time constant like 256. For H up to 256, we can handle it. For larger H, we could loop. But given the problem statement, dim=96, hidden=192. So H=192 < 256. We could also check staticly and use a generic loop to be safe.
Let's design the kernel with blockDim.x = 256 (or 128). We'll use a compile-time constant `BLOCK_SIZE` = 256. Each block handles one (b,i). We pad hidden and dim to BLOCK_SIZE? Or we use actual sizes.
Within a block:
- Shared memory:
- `s_x[2*MAX_DIM + MAX_PAIR_DIM]`: we need to know max sizes. Since dim and pair_dim are dynamic, we can allocate based on passed parameters? In CUDA, dynamic shared memory size can be set at launch. But with `load_inline`, we can pass the size at kernel launch. Or we can use static shared memory with a max size, e.g., 512 floats. Since 2*96+16 = 208 < 512. But to be generic, dynamic shared memory is better. Actually with `load_inline`, we can call the kernel with a third argument for dynamic shared memory size. `kernel<<<grid, block, shmem_size>>>`. Yes.
- `s_z1[H]`: shared memory for hidden pre-activations. Size H floats.
- `s_node_i[D]`: maybe store node_i in shared memory.
- `s_max[D]`: running max per d. We can keep it in registers if each thread handles one d. But if block size > D, we can store in shared memory.
Let's think about block size. Suppose we set block size to H (hidden). But if hidden > 256, we'd exceed typical thread block. But here hidden=192. To be safe, we can set block size to the smallest power of 2 >= max(H, D) and <= 256. For H=192, D=96, block size = 256. But using block size = 256 when only H=192 threads are active for phase 1 and D=96 for phase 2, there will be idle threads. That's okay. The warp occupancy is 256/32 = 8 warps per block. 96 blocks => 768 warps. Good.
Alternatively, block size = 128 would be enough for H=192? No, 128 < 192. So we'd need to loop. Block size = 256 is simplest.
But if H is larger than 256, our kernel would fail. We can add a loop: each thread processes multiple h's. However, for the shared memory `s_z1`, if H > 256, it's bigger. That's fine. Each thread loops over h = tid; h < H; h += blockDim.x. But then to compute the second linear, each thread may compute a partial sum for its assigned d's, accumulating over its assigned h's. But we need the full vector z1 (post-activation) to be visible to all threads for the second linear. If each thread only computed a subset of h's, they'd store into shared memory, then for second linear we'd need to read the whole s_z1 anyway. That's okay. With blockDim=256, each thread writes a few entries to s_z1. Then __syncthreads(). Then each thread computing an output d reads all s_z1 entries. Wait, reading all s_z1 entries per thread per d is H reads. With D=96 and blockDim=256, only 96 threads are active for output. Each reads H=192 floats. That's 96*192 = 18,432 reads per j. Combined with W2 reads. That's okay.
But if we want to be generic, a simple approach: blockDim = 256, each block handles one (b,i). The kernel loops over j. For each j:
1. Build x = concat(node_i, node_j, pair_ij) in shared memory. Since x is needed by all threads. x size = L = 2*dim + pair_dim.
2. Compute partial z1 contributions: each thread h_idx loops over h = tid + blockIdx.x? Actually tid is threadIdx.x. For h = tid; h < hidden; h += blockDim.x:
- compute dot(x, W1[:,h]) + b1[h]. Store in s_z1[h].
But to compute dot product, each thread needs x. x is in shared memory. W1 is in global memory. If W1 is stored as (L, H), then thread h reads W1[k, h] for k=0..L-1. Since h may be > blockDim.x, the sequential h's in a loop have stride blockDim.x. For coalescing, we want adjacent threads reading adjacent h. In the first iteration, tid=0 reads h=0, tid=1 reads h=1, etc. That's coalesced if W1 is (L, H). Good.
3. __syncthreads(). Now s_z1 contains all z1.
4. Apply SiLU in place: each thread loops over h = tid; h < hidden; h += blockDim.x: s_z1[h] = s_z1[h] * sigmoid(s_z1[h]).
5. __syncthreads().
6. Compute output for each d. We can have each thread compute one or more d's. For d = tid; d < dim; d += blockDim.x:
- val = b2[d]
- for h = 0..hidden-1: val += s_z1[h] * W2[h, d]
- apply mask: if edge_mask < 0.5, val = -inf
- update running_max[d] = max(running_max[d], val)
Since each thread handles a set of d's, we can store running_max in registers or shared memory. Since each thread handles a limited number of d's (dim/blockDim.x rounded up), using registers is fine. But we need to ensure we can store an array of floats per thread with size ceil(dim/blockDim.x). If dim=96, blockDim=256, each thread handles at most 1 d. So just one register per thread. Good.
7. After processing all j, we have running_max[d] for each d assigned to the thread.
8. Then apply layer norm: for each d assigned, compute mean and variance? Wait, LayerNorm is over the last dimension (dim). We need to compute mean and variance across d for each (b,i). That's a reduction over d within the block. Since each thread holds some d's, we can compute partial sum and partial sum of squares, then reduce across the block to get mean and inv_std, then each thread normalizes its d's: (max[d] - mean) * inv_std * gamma[d] + beta[d].
9. Finally, add residual: out = nodes[b,i,d] + normalized.
This is a complete fused kernel.
Let's check the arithmetic intensity. For each (b,i,j), we do:
- First linear: L*H multiply-adds. With L=208, H=192 => ~40k.
- SiLU: H elementwise ops.
- Second linear: H*D multiply-adds. With H=192, D=96 => ~18k.
Total ~58k ops per (b,i,j). For B*N*N = 2304 pairs, total ops ~134M. Memory reads:
- nodes_i: D per (b,i) (negligible)
- nodes_j: D per (b,i,j) -> 2304*96 = 221k floats.
- pair_feats: P per (b,i,j) -> 2304*16 = 36.9k floats.
- edge_mask: 1 per (b,i,j) -> 2304 floats.
- W1: L*H = 40k floats. Read N times per (b,i) -> 24*40k = 960k floats per block. Total across all blocks: 96*960k = 92M reads. But W1 is cached, so actual DRAM traffic is much lower (read once into L2, then reused). Similarly W2: H*D = 18.4k floats. Read 24 times per block => 442k per block, total 42M. But cached.
- Read node_i again for residual: D per (b,i).
- Gamma/beta for LN: D per (b,i).
- W2 read is per j per d: actually each d reads all H*D W2 per j? Wait, step 6 reads W2[h,d] for each d and each h. That's H*D reads per j. For block size 256 with 96 active threads, each thread does H reads for its d. So total W2 reads per j = H*D = 18,432 floats. Times 24 j = 442k floats per block. 96 blocks => 42.5M floats. Since W2 is small (18.4k floats), it will be cached in L1/L2 after the first j. Subsequent reads will be L1 hits if they fit. 18.4k floats * 4 bytes = 73.6 KB. That fits in L1 (128KB). So after first j, W2 is in L1. Good.
- Similarly W1 is 40k floats * 4 = 160KB. That might fit in L1 or L2. L1 is 128KB, so maybe L2. Still fast.
- x is built in shared memory per j: L=208 floats. All threads read it. So shared memory bandwidth is high but very fast.
Thus the kernel should be compute-bound or at least have very good data reuse.
Potential bottleneck: step 2 where each thread computes dot product of x (shared) and W1 column (global). Each thread reads 208 floats from global memory (column). Since adjacent threads read adjacent columns, memory is coalesced. Then they read x from shared memory (broadcast, no bank conflicts if using broadcast). So this is efficient.
Step 6: each active thread reads W2 row by row? Let's see: for each h, we need W2[h,d] for all active d. If d is threadIdx.x (or some mapping), and W2 is (H, D), then for a fixed h, threads d read offset h*D + d. That's coalesced! Great. So we can loop over h from 0 to H-1: load `w = W2[h*D + d]; act = s_z1[h]; val += w * act;`. For each h, the load from W2 is coalesced across threads. The load from s_z1 is broadcast (all threads read same h). That is also efficient.
If D < blockDim.x, only first D threads are active. The rest are idle. That's fine.
Now, reduction for LayerNorm:
After max over j, each thread has its `max_val` for its d. We need to compute mean and variance across d. We can use shared memory for partial sums. For example:
- Each active thread computes its max_val.
- __syncthreads().
- Reduce sum and sum_sq over d. Since D <= blockDim.x, we can do a parallel reduction in shared memory. For simplicity, we can have a small shared array of size blockDim.x. Each active thread writes its max_val to `s_data[tid]`. Then a tree reduction. Then compute mean and inv_std. Then each active thread normalizes.
But note: blockDim.x may be 256. D may be e.g., 96. So we only need to reduce over the first D threads. Or we can use warp shuffle reduction. Since D <= blockDim.x and D is small (<=256), a shared memory reduction is simple.
Alternatively, we can skip the reduction and have each thread compute the mean by reading all d from shared memory. That's O(D) per thread = 96 reads, negligible.
Let's outline the kernel with dynamic block size = nearest power of 2 >= max(H, D) but capped at maybe 256 or 512. For safety, we can choose block size = 256 always, and loop over h and d accordingly. That works for H and D up to 256. If they can be larger, we could use 512 or 1024. But typical sizes in these benchmarks are small. To be robust, we can make block size a template parameter or just set it to 256 and add loops for h and d.
Actually, if H = 384, block size 256 means each thread handles 2 h's in phase 1. That's fine. In phase 2, each thread handles maybe 2 d's. The coalescing for phase 2: if each thread handles multiple d's, we can process them sequentially. That still works. So block size = 256 is generic enough for any H and D. We just need to allocate enough shared memory.
But there is a subtlety: if D > blockDim.x, each thread handles multiple d's, and we need to store running max for each of them. We can store them in a small local array. Since D is typically small (e.g., 96), we can just unroll or use a small loop. If D is large (e.g., 512), each thread would handle 2 d's, still okay.
One issue: if H is very large (e.g., 4096), each thread's dot product in phase 1 becomes long, and shared memory s_z1 becomes large (4096 floats = 16KB). Still okay. But compute per thread increases.
Given the benchmark parameters, using block size = 256 and loops is safe.
Wait, but if H is larger than 256, the first phase: each thread loops over h = tid; h < H; h += blockDim.x. For each h, it reads a column of W1 (length L). The columns are strided by blockDim.x. If W1 is (L, H), thread tid reads column tid, then tid+256, etc. In the first iteration, threads 0..255 read columns 0..255: perfectly coalesced. In the second iteration, threads read columns 256..511: perfectly coalesced. Good.
Second phase: each thread handles d = tid; d < D; d += blockDim.x. For each d, it loops over h=0..H-1, reading W2[h*D + d] and s_z1[h]. The W2 reads are coalesced across threads for each h (since d is the thread index). Then we loop over h. For each h, the read from W2 is coalesced. Good.
Now, we also need to handle the case where D > blockDim.x. If D > blockDim.x, each thread handles multiple d's sequentially. For each of its d's, it loops over h. The W2 reads for a given h across threads are for different d's: if thread 0 handles d=0 and d=256 (e.g.), thread 1 handles d=1 and d=257. For h=0, the reads are offset 0,1,2,... and also 256,257,... But if each thread processes its first d before moving to the next, then for h=0, the reads across the warp are contiguous (0..255). So coalesced. For the second d, reads are 256..511, also coalesced. Good.
Shared memory layout:
- `s_x` size = L = 2*dim + pair_dim. But to be generic, we allocate dynamic shared memory. We'll need to calculate size per block.
- `s_node_i`: D floats
- `s_node_j`: D floats
- `s_pair`: P floats
- `s_z1`: H floats
- `s_red`: 2*blockDim.x floats for reduction (sum and sum_sq)
Total = (2*D + P + H + 2*blockDim) * sizeof(float).
But we can also avoid separate s_node_i/j/pair and just build `s_x` directly.
- `s_x[L]`
- `s_z1[H]`
- `s_red[2*blockDim]` or just `blockDim` for sum and another for sum_sq. Actually for layer norm, we need sum and sum of squares. We can compute variance via `E[x^2] - (E[x])^2`. We need sum and sum of squares.
So `s_sum[blockDim]`, `s_sum_sq[blockDim]`. That's 2*blockDim floats.
But dynamic shared memory in CUDA can be declared as `extern __shared__ float s_mem[];` and we partition it. With `load_inline`, we can write standard CUDA.
Alternatively, we can store node_i, node_j, pair in registers: each thread that builds x can load from global memory. But only one thread needs to build x. Actually to avoid bank conflicts, we can have a few threads load the data into s_x. Since L is small, even one thread loading is fine. But to be safe, we can have all threads load partial data: `for (int idx = tid; idx < L; idx += blockDim.x) s_x[idx] = ...;`. Since L is small, only a few threads participate. That's fine.
Now, writing the kernel:
```cuda
template <typename T>
__forceinline__ __device__ T silu(T x) {
return x / (T(1) + exp(-x));
}
```
But the reference uses `nn.SiLU()` which is `x * sigmoid(x)`. SiLU(x) = x * sigmoid(x). So we need to match exactly. Note that `nn.SiLU` in PyTorch uses `x * torch.sigmoid(x)` (there's also `nn.functional.silu`). The CUDA implementation should be `x * (1.0f / (1.0f + expf(-x)))`.
We need to pass weights as `const float*`. The kernel signature:
```cuda
__global__ void fused_scene_graph_forward(
const float* nodes,
const float* pair_feats,
const float* edge_mask,
const float* w1, // shape (L, H)
const float* b1, // shape (H,)
const float* w2, // shape (H, D)
const float* b2, // shape (D,)
const float* ln_w, // shape (D,)
const float* ln_b, // shape (D,)
float* out,
int B, int N, int D, int P, int H)
```
Wait, need to pass dimensions. The kernel uses these. But we can also template them. Since we pass them at runtime, generic kernel is fine.
One concern: the weights in PyTorch are stored as `float` (fp32). The inputs are fp32. The output should be fp32.
Now, constructing the weights in ModelNew.__init__:
- We need to mimic `nn.Linear` initialization. But we can just allocate `nn.Parameter` and initialize with small random values, or copy from a reference model. However, in the evaluation, they will instantiate ModelNew with `get_init_inputs()` and then test with random inputs. They will compare outputs with the reference Model. The reference Model will have its own randomly initialized weights. But if ModelNew has differently initialized weights, outputs won't match. Therefore, we need to initialize exactly as PyTorch does? Actually, the evaluation likely loads a state dict or copies parameters? The problem says: "Replace the forward computation of the PyTorch model below with a custom CUDA kernel. Your ModelNew class must accept the same constructor arguments as Model." It doesn't explicitly say they will copy weights. But usually in these benchmarks, they instantiate both Model and ModelNew, copy parameters from one to the other (or use the same initialization), and then compare outputs. We need to ensure parameter layout matches.
Standard approach: In ModelNew.__init__, we create `nn.Parameter` tensors for the weights and biases, initialized the same way as PyTorch defaults (Kaiming uniform for Linear, ones/zeros for LayerNorm). But if the evaluation does not copy weights, we need to have identical initialization. PyTorch's `nn.Linear` uses `init.kaiming_uniform_` for weight and `init.uniform_` for bias with bounds `1/sqrt(fan_in)`. Actually `nn.Linear` default is `reset_parameters` which uses `init.kaiming_uniform_(self.weight, a=math.sqrt(5))` and `self.bias` uniform between `-bound` and `bound` where `bound = 1 / sqrt(fan_in)`. LayerNorm default: weight ones, bias zeros.
But there's no guarantee the evaluation uses default initialization; they might assign random values after construction. However, looking at the reference code, `Model` is a standard `nn.Module` and `ModelNew` must be interchangeable. The evaluation script likely does something like:
```python
model = Model(*get_init_inputs())
model_new = ModelNew(*get_init_inputs())
model_new.load_state_dict(model.state_dict())
# or copy parameters
```
But we cannot be sure. The safest is to keep the parameters as standard `nn.Linear` and `nn.LayerNorm` inside ModelNew, and in forward, extract their `.weight` and `.bias` and pass to the kernel. That way, if they copy state dicts, the weight shapes and names match exactly.
So ModelNew.__init__ should create:
```python
self.fuse = nn.Sequential(
nn.Linear(2 * dim + pair_dim, hidden),
nn.SiLU(),
nn.Linear(hidden, dim),
)
self.out_norm = nn.LayerNorm(dim)
```
And forward will pass `self.fuse[0].weight`, `self.fuse[0].bias`, self.fuse[2].weight, etc.
But we need transposed weights for the kernel. In forward, we can call `.t().contiguous()` on the fly. However, `.contiguous()` may allocate memory and copy. That's okay for a one-time overhead? Actually if the weights are static, we can precompute transposed copies and store them as buffers. But if the evaluation copies state dicts after __init__, our precomputed copies might be stale. So better to compute them in forward each time. Since the overhead of transposing a 192x208 matrix is negligible compared to the kernel runtime, or we can just pass the original weight and handle indexing in the kernel.
Wait, we can avoid transposing by choosing the appropriate indexing in the kernel. If W1 in PyTorch is shape (H, L), i.e., `weight[out, in]`, then in the kernel, if each thread computes one hidden h, it needs to read `W1[h, k]` for k=0..L-1. That is contiguous (row-major). So we don't need to transpose! Let's check: In PyTorch, `nn.Linear(in_features, out_features).weight` shape is (out_features, in_features). So W1 shape is (H, L). Thread h reads `W1[h * L + k]`. That's contiguous. Perfect! No transpose needed.
Similarly, W2 shape is (D, H). Wait, `nn.Linear(hidden, dim)` weight is (dim, hidden) = (D, H). In the kernel, for second linear, we want to compute for each d: `sum_h W2[d, h] * act[h]`. If we loop over h, thread d would read `W2[d * H + h]`. That's contiguous for each thread but strided across threads. If adjacent threads d and d+1 read `W2[d*H + h]` and `W2[(d+1)*H + h]`, the offset difference is H floats. That's strided by H*4 bytes. Not coalesced. However, if we store W2 as (H, D), thread d reads `W2[h*D + d]`, which is coalesced. But PyTorch stores it as (D, H). So we may want to transpose W2 for the kernel. Or we can transpose the loop: have each thread compute a partial sum over a subset of h, and then reduce across threads? Let's think.
If we keep W2 as (D, H), and have block size = H, each thread h computes `act[h] * W2[d, h]` for a fixed d? That doesn't make sense. Let's consider alternative assignments.
Actually, for second linear, we can parallelize over h: each thread h computes `act[h]` and then multiplies by `W2[d, h]`. But W2 is per d. We need contributions to each d. We could have each thread compute a partial output vector for a subset of d's: thread tid loops over d and reads W2[d, tid] if tid < H? Wait, W2 is (D, H). If we assign thread h to hidden unit h, it can compute `contrib = act[h] * W2[d, h]` for all d. That means thread h reads row h of W2? But W2 is (D, H), column h corresponds to hidden h. The elements for a fixed h are at offsets `d * H + h` for d=0..D-1. That's strided by H, not contiguous. But thread h can read W2[d, h] for all d, which is strided. However, threads within a warp reading the same column offset but different rows: thread h reads offset `0*H + h`, `1*H + h`, etc. Adjacent threads h and h+1 read offsets `d*H + h` and `d*H + h+1`. That's contiguous! Wait, for a fixed d, W2[d, :] is contiguous. But if each thread handles a different h, and they all read the same d, then for a given d, thread h reads `W2[d*H + h]`. Adjacent threads read adjacent floats. That's coalesced! Great.
So if we parallelize over h for the second linear, we can have each thread h compute `partial_d[d] = act[h] * W2[d, h]` for all d. But storing partial_d for all d per thread requires D registers per thread. If D=96 and H=192, each thread handles one h, and it would need to write 96 floats to shared memory or accumulate in some output registers. But there are 192 threads. If each writes 96 floats, that's 192*96 = 18,432 floats to write to shared memory per j. That's a lot of shmem bandwidth. Alternatively, each thread h writes its contribution to a shared memory output accumulator `s_out[d]`. So each thread h reads W2[d, h] for all d and adds `act[h] * W2[d, h]` to `s_out[d]`. That requires atomic adds or sequential writes with synchronization. Since multiple threads write to the same `s_out[d]`, we need atomicAdd or reduction. Not ideal.
Alternatively, we can transpose W2 to (H, D) when passing to the kernel. Then the second linear read is coalesced per h across d. But computing partial sums is still an issue.
Wait, another idea: In the second linear, we can have each thread compute the dot product for one d, but instead of reading W2[d, h] sequentially (which is contiguous per thread but strided across threads), we can read W2[d, h] in a blocked way. Actually, the memory access pattern for thread d reading W2[d, h] for h sequentially is: for h=0, thread d reads offset d*H + 0. Thread d+1 reads offset (d+1)*H + 0. The stride between threads is H floats. That's not coalesced. But if we transpose W2 to (H, D), then thread d reads offset h*D + d for each h. For a fixed h, threads read contiguous d. That's coalesced across threads. So transposing W2 is beneficial for coalescing in the "one thread per d" approach.
So we have a trade-off:
- Approach A: one thread per h for first linear (natural, coalesced). For second linear, one thread per d (needs W2 transposed for coalescing).
- Approach B: one thread per h for second linear (coalesced with original W2), but accumulation across h requires reduction.
Approach A seems better because we can just transpose W2 once on the CPU side and pass it to the kernel. The cost of transposing is negligible (once at launch). We can even store a transposed copy as a buffer and update it when weights change. But for simplicity, in `forward`, we can call `self.fuse[2].weight.t().contiguous()` each time. That creates a temporary tensor each call. Overhead is small (18k floats), but could be a couple of microseconds. Alternatively, we can pass the original W2 and handle the strided reads; since the tensor is small and cached, the lack of coalescing might not matter much given the small size.
Let's evaluate speed. The second linear is 192*96 = 18k multiply-adds per j. Each thread reads 192 floats from W2. If W2 is not transposed, each thread reads its column (strided by H). The reads will be cached in L1, but the cache line is 128 bytes = 32 floats. For thread d and d+1, the offsets differ by H=192 floats = 768 bytes, which is multiple cache lines. So each read by a warp will cause 32 cache lines to be loaded (one per thread), but only one float used per line. That's terrible bandwidth utilization (1/32). However, since W2 is small (73KB), after the first j, the entire W2 might be in L1/L2. Subsequent j's may still suffer from L1 latency if not fully resident. L2 can hold it. But each warp's read of 32 floats across strided offsets will load 32 cache lines (4KB) from L2. That's 4KB per warp per h iteration. With 96 blocks and D=96 threads active, there are 3 warps per block (96/32=3). For h=192, total L2 traffic per j = 3 warps * 192 iterations * 4KB = 2.3MB per j? Wait, let's compute carefully.
Actually, for each h, the 96 active threads are spread across 3 warps (32 threads each). In warp 0, threads 0..31 read W2[d, h] for d=0..31. Offsets: d*H + h. Since H=192, offsets are h, H+h, 2H+h, ..., 31H+h. That's 32 cache lines (assuming each cache line covers 32 floats (128 bytes), and offsets are 192 apart). So each h iteration loads 32 cache lines per warp. 3 warps => 96 cache lines. 192 h iterations => 18,432 cache lines. Each cache line is 128 bytes. Total = 2.36 MB per j. For N=24 j, that's 56.6 MB per block. 96 blocks => 5.4 GB of L2 traffic just for W2. That's large relative to the 73KB W2 size. So it's wasteful. If W2 is transposed to (H, D), then for each h, threads d read offset h*D + d. For warp 0, d=0..31, offsets h*D + 0 .. h*D + 31. That's one cache line (32 floats contiguous). So per h iteration, 3 warps load 3 cache lines = 384 bytes. 192 h iterations => 73.7 KB per j. That's exactly the size of W2. Times 24 j => 1.77 MB per block. Times 96 => 170 MB. That's much better.
So transposing W2 is important. Similarly for W1, if we parallelize over h and W1 is (H, L), thread h reads row h which is contiguous. That's already coalesced across warps? Wait, adjacent threads h and h+1 read rows h and h+1. Offsets are h*L and (h+1)*L. Stride is L floats = 832 bytes. In a warp of 32 threads, threads 0..31 read offsets 0, L, 2L, ..., 31L. That's 32 cache lines. So W1 in (H,L) layout also has the same strided access problem across threads! Oh no! Wait, I made a mistake earlier.
Let's re-evaluate. PyTorch stores Linear weight as (out_features, in_features). So W1 is (H, L). Thread h reads W1[h, k] for k=0..L-1. For a fixed k, threads h read W1[h, k] which are at offset h*L + k. For k=0, offsets are 0, L, 2L, ..., 31L. That's strided by L floats. Not coalesced. For k=1, offsets are 1, L+1, 2L+1, etc. Still strided. So reading row-wise with one thread per row is not coalesced.
But reading column-wise (transposed) W1^T is (L, H). Thread h reads column h: offsets k*H + h for k=0..L-1. For a fixed k, the accesses are offset k*H + h, which are contiguous across h. That's coalesced! So we need W1 transposed as well.
Wait, but earlier I thought (L, H) is coalesced for thread h reading column h. Let's verify: Suppose W1_T is (L, H), stored row-major. Element (k, h) is at index k*H + h. Threads 0..31 read (k,0)..(k,31). Indices: k*H+0, k*H+1, ..., k*H+31. These are contiguous. Coalesced. Great. So for phase 1, we need W1 stored as (L, H). For phase 2, we need W2 stored as (H, D).
So both W1 and W2 need to be transposed for coalesced access in our thread assignment (phase 1: one thread per h reading a column; phase 2: one thread per d reading a row or column). Actually for phase 2 with W2 as (H, D), thread d reads W2[h, d] for each h. For fixed h, offsets h*D + d. Contiguous across d. Coalesced. Great.
Thus we should transpose both weight matrices before passing to the kernel. We can do this in `forward` each call using `.t().contiguous()`. Or we can store transposed versions as persistent tensors and update them when weights change. Since weights are static during forward, but could be updated by optimizer, we can just transpose in forward. The overhead is small: W1 is (H,L) = 192*208 = 40k floats. Transposing 40k floats is negligible.
Alternatively, we can store the weights in the kernel's expected layout directly in the Module. For example, instead of using `nn.Linear`, we can store `self.w1 = nn.Parameter(torch.empty(L, H))` and initialize it manually. But that breaks compatibility with `state_dict` copying if the reference uses `fuse.0.weight`. However, if the evaluation copies parameters by name, it might fail. If we use `nn.Linear`, the state dict keys are `fuse.0.weight`, `fuse.0.bias`, `fuse.2.weight`, `fuse.2.bias`, `out_norm.weight`, `out_norm.bias`. So we should keep those.
In forward, we can do:
```python
w1_T = self.fuse[0].weight.t().contiguous() # (L, H)
w2_T = self.fuse[2].weight.t().contiguous() # (H, D)
```
But `self.fuse[0].weight.t()` returns a view, and `.contiguous()` copies. That's fine.
Now, the kernel signature should accept these transposed weights.
Let's think about block size again. If we transpose W1 to (L, H), blockDim = H (or a multiple) for phase 1. If H > 256, blockDim could be 256 and each thread loops over multiple h's. The coalescing is maintained within each iteration because threads 0..255 read columns 0..255. Good.
Similarly for W2 (H, D), blockDim = D or a multiple. If D > blockDim, we loop.
So a generic block size of 256 works.
Wait, what if H=192 and D=96. BlockDim=256. Phase 1: only first 192 threads active for h<192. Phase 2: only first 96 threads active for d<96. That's fine.
Now, let's design the kernel step by step.
### Kernel code
```cuda
#include <cuda.h>
#include <cuda_runtime.h>
#include <math.h>
__global__ void fused_sg_kernel(
const float* __restrict__ nodes,
const float* __restrict__ pair_feats,
const float* __restrict__ edge_mask,
const float* __restrict__ w1_T, // (L, H)
const float* __restrict__ b1,
const float* __restrict__ w2_T, // (H, D)
const float* __restrict__ b2,
const float* __restrict__ ln_w,
const float* __restrict__ ln_b,
float* __restrict__ out,
int B, int N, int D, int P, int H)
{
const int tid = threadIdx.x;
const int b = blockIdx.x;
const int i = blockIdx.y;
const int L = 2 * D + P;
const int block_size = blockDim.x;
// Dynamic shared memory
extern __shared__ float s_mem[];
// Partition shared memory
float* s_x = s_mem; // size L
float* s_z1 = s_x + L; // size H
float* s_sum = s_z1 + H; // size block_size
float* s_sum_sq = s_sum + block_size; // size block_size
// Load node_i into s_x[0:D]
for (int idx = tid; idx < D; idx += block_size) {
s_x[idx] = nodes[(b * N + i) * D + idx];
}
// node_j part will be loaded per j into s_x[D:2D]
// pair part into s_x[2D:L]
__syncthreads();
// Each thread will handle a subset of output dimensions
// Since D <= 256 typically, each thread handles at most 1 or a few d's.
// We'll store running max in registers. Use a static array? D is dynamic.
// We can store in shared memory s_max[D] instead.
// Let's add s_max after s_sum_sq.
// But dynamic shared memory size is fixed at launch. We need to calculate it.
// We can compute s_max offset.
// Actually it's easier to use a small local array if we limit max D per thread.
// Since blockDim=256, we can allocate a local array of size (D + block_size - 1) / block_size.
// But C++ doesn't allow VLA. We can use a fixed max size, e.g., 4.
// Alternatively, store running max in shared memory s_max[D].
// Let's adjust shared memory layout:
// s_x[L], s_z1[H], s_max[D], s_sum[block_size], s_sum_sq[block_size]
// We need to pass total shared mem size accordingly.
}
```
Dynamic shared memory partition is a bit messy but manageable. Let's define a struct or just offsets.
```cuda
float* s_x = s_mem;
float* s_z1 = s_x + L;
float* s_max = s_z1 + H;
float* s_sum = s_max + D;
float* s_sum_sq = s_sum + block_size;
```
Total shared mem = (L + H + D + 2*block_size) * sizeof(float).
With D=96, H=192, L=208, block_size=256: total = 208+192+96+512 = 1008 floats = 4032 bytes. Very small. Good.
Initialize `s_max[d] = -INFINITY`. We can have threads do it:
```cuda
for (int idx = tid; idx < D; idx += block_size) {
s_max[idx] = -INFINITY;
}
__syncthreads();
```
Main loop over j:
```cuda
for (int j = 0; j < N; ++j) {
// Load node_j and pair_feat into s_x
for (int idx = tid; idx < D; idx += block_size) {
s_x[D + idx] = nodes[(b * N + j) * D + idx];
}
for (int idx = tid; idx < P; idx += block_size) {
s_x[2 * D + idx] = pair_feats[((b * N + i) * N + j) * P + idx];
}
__syncthreads();
// Compute edge mask
float mask_val = edge_mask[((b * N + i) * N + j)];
// Phase 1: compute z1 for all h
for (int h = tid; h < H; h += block_size) {
float dot = b1[h];
// dot product over L
// Unroll? L is unknown but small (<= ~512). We can just loop.
#pragma unroll 4
for (int k = 0; k < L; ++k) {
dot += w1_T[k * H + h] * s_x[k];
}
// SiLU
float sig = 1.0f / (1.0f + expf(-dot));
s_z1[h] = dot * sig;
}
__syncthreads();
// Phase 2: compute output for each d and update running max
if (mask_val > 0.5f) {
for (int d = tid; d < D; d += block_size) {
float val = b2[d];
#pragma unroll 4
for (int h = 0; h < H; ++h) {
val += w2_T[h * D + d] * s_z1[h];
}
// update max
if (val > s_max[d]) {
s_max[d] = val;
}
}
}
__syncthreads();
}
```
Wait: in the elementwise max update, if multiple threads write to `s_max[d]`, there is no conflict because each d is owned by exactly one thread (since d = tid + k*block_size, and block_size > D? Actually if D <= block_size, each d is unique to one thread. If D > block_size, a given d might be handled by only one thread across iterations. So no conflict. Good. However, `s_max` is shared memory and threads read and write their own entries. But `__syncthreads()` before and after ensures no race? Actually during the max update, each thread reads and writes different entries, so no race. But after the loop, we need a sync before next j because threads from current j's phase 2 might still be writing s_max while threads from next j's phase 1 are reading s_x/s_z1? But s_max is separate memory. Phase 1 writes s_z1. Phase 2 reads s_z1 and writes s_max. So between j iterations, we need `__syncthreads()` after phase 2 to ensure all threads have finished writing s_max before we overwrite s_z1 in next phase 1. The code above has `__syncthreads()` at the end of the j loop. Good.
But note: `mask_val` is read by all threads. If `mask_val <= 0.5f`, we skip the computation entirely for that j. That saves compute for masked edges. In reference, all edges are 1 (since get_inputs uses ones), but evaluation may vary. It's fine.
One optimization: if mask is zero, we still loaded node_j and pair_feat and did __syncthreads. We could check mask before loading, but we need to load for threads that compute phase 1 anyway. Since we skip phase 1 and 2, we can load and then skip compute. That's fine.
But there's a subtle issue: `mask_val` is a float. In the reference, `edge_mask` is float {0,1}. So reading it is fine.
Now after j loop, we have `s_max[d]` = max over j.
Next: LayerNorm. The reference uses `self.out_norm = nn.LayerNorm(dim)`. Default `elementwise_affine=True`. So `out_norm(pooled)` does:
- mean = mean(pooled, dim=-1, keepdim=True)
- var = mean((pooled - mean)^2, dim=-1, keepdim=True)
- std = sqrt(var + eps)
- eps = 1e-5 by default in PyTorch LayerNorm.
- normalized = (pooled - mean) / std
- out = normalized * gamma + beta
Wait, PyTorch's default eps for LayerNorm is 1e-5. Let's confirm: `torch.nn.LayerNorm` default `eps=1e-5`. Yes.
We need to compute mean and variance over d for each (b,i). Since the block has all threads, we can compute sum and sum of squares of s_max[d].
```cuda
// Compute mean and variance
float local_sum = 0.0f;
float local_sum_sq = 0.0f;
for (int idx = tid; idx < D; idx += block_size) {
float v = s_max[idx];
local_sum += v;
local_sum_sq += v * v;
}
s_sum[tid] = local_sum;
s_sum_sq[tid] = local_sum_sq;
__syncthreads();
// Reduce s_sum and s_sum_sq
// Since block_size is power of two (256), we can reduce.
// But block_size may not be a power of two? We can set it to a power of two, e.g., 256.
// Let's assume block_size is a power of two.
for (int stride = block_size / 2; stride > 0; stride >>= 1) {
if (tid < stride) {
s_sum[tid] += s_sum[tid + stride];
s_sum_sq[tid] += s_sum_sq[tid + stride];
}
__syncthreads();
}
float mean = s_sum[0] / D;
float mean_sq = s_sum_sq[0] / D;
float var = mean_sq - mean * mean;
float inv_std = rsqrtf(var + 1e-5f);
// Now compute output
for (int idx = tid; idx < D; idx += block_size) {
float v = s_max[idx];
float norm = (v - mean) * inv_std;
float out_val = norm * ln_w[idx] + ln_b[idx];
out_val += s_x[idx]; // residual: nodes[b,i,idx]
out[(b * N + i) * D + idx] = out_val;
}
```
Wait, `s_x` currently contains the last j's data (node_j and pair_feat). We need `node_i` for the residual. We loaded `node_i` into `s_x[0:D]` at the beginning and never overwrote it (we only wrote `s_x[D:2D]` and `s_x[2D:L]`). So `s_x[idx]` for idx < D is still node_i. Great.
But we need to be careful: after the first `__syncthreads()` following node_i load, other threads read it. Then in the j loop, we load node_j into `s_x[D:D*2]`. We need to ensure `node_i` is not overwritten. So `s_x` layout is fine: indices 0..D-1 = node_i, D..2D-1 = node_j, 2D..L-1 = pair. So residual `nodes[b,i,idx]` is `s_x[idx]`.
One more thing: the LayerNorm computation uses `ln_w` (weight) and `ln_b` (bias). In PyTorch, `ln.weight` is shape (D,), `ln.bias` is shape (D,). We pass them as pointers.
Now, about the grid and block dimensions. We need to decide blockDim.x. It should be a power of two for reduction. Let's use 256. But what if H or D > 256? Then the loops `for (h = tid; h < H; h += block_size)` handle it. The block size can be fixed at 256. The reduction for mean uses `block_size`. If D > 256, the reduction still works because we sum partial sums from all threads. The `for (stride = block_size/2; stride>0; stride>>=1)` reduction assumes all threads wrote to `s_sum[tid]` and `s_sum_sq[tid]`. Since we computed `local_sum` for all indices assigned to the thread, and stored in `s_sum[tid]`, it's fine. Even if D is large, the partial sum per thread is correct. Then `s_sum[0]` is total sum.
But note: `s_sum` and `s_sum_sq` arrays are of size `block_size`. If `block_size = 256`, that's fine.
However, if D is very large, the `s_max` shared memory size becomes D floats, which could be large. But typical D is small. For safety, we might want to allocate s_max dynamically based on D. Since we pass dynamic shared memory size at launch, we can compute it. We need to do that in Python side.
In `forward`, we can compute `shmem = (L + H + D + 2*block_size) * 4` bytes.
One more nuance: we skip phase 1 and 2 when `mask_val <= 0.5f`, but we still need to `__syncthreads()`. The code must ensure all threads reach `__syncthreads()`. In the j loop, `__syncthreads()` is outside the if/else. In my pseudocode, I had `if (mask_val > 0.5f) { compute and update max } __syncthreads();`. That's fine because all threads reach the sync.
Wait, `mask_val` is the same for all threads in the block (since b, i, j are fixed for the block). Actually, is edge_mask shape (B,N,N)? Yes. For a given (b,i,j), mask is a scalar. So all threads have same mask_val. So the if branch is uniform. That's good for divergence.
But we need to compute `mask_val` inside the j loop.
Potential issue: `nodes` and `pair_feats` and `edge_mask` are accessed with indices. Let's compute pointer offsets carefully.
Nodes: shape (B, N, D). Element (b, n, d) at `nodes[(b * N + n) * D + d]`.
Pair_feats: shape (B, N, N, P). Element (b, i, j, p) at `pair_feats[((b * N + i) * N + j) * P + p]`.
Edge_mask: shape (B, N, N). Element (b, i, j) at `edge_mask[((b * N + i) * N + j)]`.
Out: shape (B, N, D). `out[(b * N + i) * D + d]`.
Now, about the fused MLP: `nn.Linear` uses `y = x @ W^T + b`. In PyTorch, if weight is (out, in), then `x` (1, in) multiplied by weight^T (in, out) gives (1, out). Mathematically, `y[d] = sum_k x[k] * W[d, k] + b[d]`.
Our kernel uses:
- w1_T is shape (L, H). `w1_T[k * H + h]` corresponds to W1^T[k, h] = W1[h, k]. So dot product: `sum_k s_x[k] * w1_T[k*H + h]` = `sum_k s_x[k] * W1[h, k]`. Good. Then + b1[h]. Good.
- w2_T is shape (H, D). `w2_T[h * D + d]` corresponds to W2^T[h, d] = W2[d, h]. So dot product: `sum_h s_z1[h] * w2_T[h*D + d]` = `sum_h s_z1[h] * W2[d, h]`. Then + b2[d]. Good.
So the math matches.
Now we need to handle the `neg = torch.finfo(fused.dtype).min`. The reference sets masked entries to the minimum float value, then does max. If we skip computation when mask is 0, the running max is unaffected, which is equivalent to initializing max to -inf and masking. In our kernel, we initialize `s_max[d]` to `-INFINITY` and only update when mask > 0.5. That's correct. However, what if all j are masked for a given (b,i)? Then s_max[d] remains -inf. LayerNorm on -inf would be NaN. But presumably edge_mask is such that each node has at least one valid edge. In the reference, if all are masked, `fused.max(dim=2)` would return -inf, and then adding nodes + LN(-inf) would be weird. But it's an edge case we don't need to worry about.
One more thing: the reference uses `self.fuse` which is a `nn.Sequential`. The weights are `self.fuse[0].weight`, `self.fuse[0].bias`, `self.fuse[2].weight`, `self.fuse[2].bias`. LayerNorm: `self.out_norm.weight`, `self.out_norm.bias`.
Now, we need to write the Python code that defines `ModelNew`, with `load_inline`.
### Python code structure
```python
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.cpp_extension import load_inline
# CUDA source code string
cuda_source = r"""
#include <cuda.h>
#include <cuda_runtime.h>
#include <math.h>
__global__ void fused_scene_graph_kernel(
const float* __restrict__ nodes,
const float* __restrict__ pair_feats,
const float* __restrict__ edge_mask,
const float* __restrict__ w1_T,
const float* __restrict__ b1,
const float* __restrict__ w2_T,
const float* __restrict__ b2,
const float* __restrict__ ln_w,
const float* __restrict__ ln_b,
float* __restrict__ out,
int B, int N, int D, int P, int H)
{
// ...
}
"""
cpp_source = r"""
torch::Tensor fused_scene_graph_forward(
torch::Tensor nodes,
torch::Tensor pair_feats,
torch::Tensor edge_mask,
torch::Tensor w1_T,
torch::Tensor b1,
torch::Tensor w2_T,
torch::Tensor b2,
torch::Tensor ln_w,
torch::Tensor ln_b)
{
int B = nodes.size(0);
int N = nodes.size(1);
int D = nodes.size(2);
int P = pair_feats.size(3);
int H = w1_T.size(1);
auto out = torch::empty_like(nodes);
const int block_size = 256;
dim3 grid(B, N);
dim3 block(block_size);
int L = 2 * D + P;
size_t shmem_size = (L + H + D + 2 * block_size) * sizeof(float);
fused_scene_graph_kernel<<<grid, block, shmem_size>>>(
nodes.data_ptr<float>(),
pair_feats.data_ptr<float>(),
edge_mask.data_ptr<float>(),
w1_T.data_ptr<float>(),
b1.data_ptr<float>(),
w2_T.data_ptr<float>(),
b2.data_ptr<float>(),
ln_w.data_ptr<float>(),
ln_b.data_ptr<float>(),
out.data_ptr<float>(),
B, N, D, P, H);
return out;
}
"""
# Load inline
_fused = load_inline(
name="fused_scene_graph",
cpp_sources=cpp_source,
cuda_sources=cuda_source,
functions=["fused_scene_graph_forward"],
extra_cuda_cflags=["-O3", "--use_fast_math"],
extra_cflags=["-O3"],
)
class ModelNew(nn.Module):
def __init__(self, dim: int, pair_dim: int, hidden: int):
super().__init__()
self.dim = dim
self.pair_dim = pair_dim
self.hidden = hidden
# Use standard layers for parameter compatibility
self.fuse = nn.Sequential(
nn.Linear(2 * dim + pair_dim, hidden),
nn.SiLU(),
nn.Linear(hidden, dim),
)
self.out_norm = nn.LayerNorm(dim)
def forward(self, nodes, pair_feats, edge_mask):
# Extract weights, transpose for kernel-friendly layout
w1_T = self.fuse[0].weight.t().contiguous() # (L, H)
b1 = self.fuse[0].bias.contiguous()
w2_T = self.fuse[2].weight.t().contiguous() # (H, D)
b2 = self.fuse[2].bias.contiguous()
ln_w = self.out_norm.weight.contiguous()
ln_b = self.out_norm.bias.contiguous()
return _fused.fused_scene_graph_forward(
nodes, pair_feats, edge_mask,
w1_T, b1, w2_T, b2, ln_w, ln_b
)
```
But wait: `b1.contiguous()` on a 1D tensor is trivial. We can just pass them.
One issue: `load_inline` may require `at::cuda::CUDAStreamGuard` or something? Usually not; `load_inline` generates pybind wrappers. But we need to ensure the tensors are on CUDA. In the evaluation, inputs are likely on CPU? The reference code doesn't specify device, but benchmarks often run on GPU. The default `ModelNew` delegates to `Model` which works on CPU or GPU. Our kernel requires CUDA. The evaluation probably calls `.cuda()` on inputs. We should call `.contiguous()` and ensure they are on GPU. Or we can just pass them; if they are on CPU, the kernel will fail. But the evaluation expects GPU.
We should also check that the output is `nodes + self.out_norm(pooled)`. In our kernel, we add residual at the end. That's correct.
Now, let's refine the CUDA kernel to be robust and fast.
### Kernel details
We need to be careful about `__syncthreads()` inside the j loop and divergence.
Let's write the kernel clearly.
```cuda
__global__ void fused_scene_graph_kernel(
const float* __restrict__ nodes,
const float* __restrict__ pair_feats,
const float* __restrict__ edge_mask,
const float* __restrict__ w1_T,
const float* __restrict__ b1,
const float* __restrict__ w2_T,
const float* __restrict__ b2,
const float* __restrict__ ln_w,
const float* __restrict__ ln_b,
float* __restrict__ out,
int B, int N, int D, int P, int H)
{
const int tid = threadIdx.x;
const int b = blockIdx.x;
const int i = blockIdx.y;
const int L = 2 * D + P;
const int block_size = blockDim.x;
extern __shared__ float s_mem[];
float* s_x = s_mem; // [L]
float* s_z1 = s_x + L; // [H]
float* s_max = s_z1 + H; // [D]
float* s_sum = s_max + D; // [block_size]
float* s_sum_sq = s_sum + block_size; // [block_size]
// Load node_i
const float* node_i_ptr = nodes + (b * N + i) * D;
for (int idx = tid; idx < D; idx += block_size) {
s_x[idx] = node_i_ptr[idx];
}
__syncthreads();
// Initialize max to -inf
for (int idx = tid; idx < D; idx += block_size) {
s_max[idx] = -CUDART_INF_F; // or -INFINITY
}
__syncthreads();
// Loop over j
for (int j = 0; j < N; ++j) {
// Load node_j and pair_feats
const float* node_j_ptr = nodes + (b * N + j) * D;
const float* pair_ptr = pair_feats + ((b * N + i) * N + j) * P;
for (int idx = tid; idx < D; idx += block_size) {
s_x[D + idx] = node_j_ptr[idx];
}
for (int idx = tid; idx < P; idx += block_size) {
s_x[2 * D + idx] = pair_ptr[idx];
}
__syncthreads();
float mask_val = edge_mask[((b * N + i) * N + j)];
if (mask_val > 0.5f) {
// Phase 1: compute hidden z1
for (int h = tid; h < H; h += block_size) {
float acc = b1[h];
// dot product
// We can loop with pragma unroll, but L is variable.
// For typical sizes, just loop.
for (int k = 0; k < L; ++k) {
acc += w1_T[k * H + h] * s_x[k];
}
float sig = 1.0f / (1.0f + expf(-acc));
s_z1[h] = acc * sig;
}
__syncthreads();
// Phase 2: compute output and update max
for (int d = tid; d < D; d += block_size) {
float val = b2[d];
for (int h = 0; h < H; ++h) {
val += w2_T[h * D + d] * s_z1[h];
}
// atomicMax on float is not directly available without atomic CAS.
// But each d is mapped to a unique thread (if D <= block_size).
// If D > block_size, a given d is still unique across threads because d = tid + m*block_size.
// So we can write directly:
if (val > s_max[d]) {
s_max[d] = val;
}
}
}
__syncthreads();
}
// LayerNorm on s_max
float local_sum = 0.0f;
float local_sum_sq = 0.0f;
for (int idx = tid; idx < D; idx += block_size) {
float v = s_max[idx];
local_sum += v;
local_sum_sq += v * v;
}
s_sum[tid] = local_sum;
s_sum_sq[tid] = local_sum_sq;
__syncthreads();
// tree reduction
for (int stride = block_size / 2; stride > 0; stride >>= 1) {
if (tid < stride) {
s_sum[tid] += s_sum[tid + stride];
s_sum_sq[tid] += s_sum_sq[tid + stride];
}
__syncthreads();
}
float mean = s_sum[0] / D;
float var = s_sum_sq[0] / D - mean * mean;
float inv_std = rsqrtf(var + 1e-5f);
for (int idx = tid; idx < D; idx += block_size) {
float v = s_max[idx];
float norm = (v - mean) * inv_std;
float o = norm * ln_w[idx] + ln_b[idx];
o += s_x[idx]; // residual
out[(b * N + i) * D + idx] = o;
}
}
```
Wait, there is a bug: when `mask_val <= 0.5f`, we skip `__syncthreads()` inside the if block, but we still have the `__syncthreads()` at the end of the j loop. However, if `mask_val > 0.5f`, the threads go through two `__syncthreads()` (one after phase 1, one at the end). If `mask_val <= 0.5f`, the threads skip the first `__syncthreads()` (inside the if block) but hit the one at the end. This causes divergence! Since `mask_val` is uniform across the block (same for all threads), the branch is taken uniformly by all threads. In CUDA, uniform branches don't cause divergence? Actually, if all threads in a warp take the same branch, there is no divergence. Since mask_val is the same scalar for all threads, all threads in the block take the same path. So it's safe. But we must ensure `__syncthreads()` is not inside a branch that some warps take and others don't. Since the condition is uniform across the entire block (mask_val depends only on block indices, not thread indices), it's uniform. Good.
But wait: `if (mask_val > 0.5f)` is not dependent on `tid`. The compiler may not know that, but at runtime it's uniform. CUDA handles uniform branches fine: all warps either take it or not. However, if `mask_val` is loaded by a single thread and broadcast? All threads load it, so it's in registers, same value. It's uniform.
Thus `__syncthreads()` inside the if is okay because either all threads reach it or none.
But to be extra safe, we can separate the phases. Actually, we can compute phase 1 and 2 unconditionally, but set the output to -inf if mask is 0, and then update max. That avoids the if structure but adds compute. Since compute is small, it might be simpler and safer. Let's consider:
```cuda
// Phase 1: compute hidden z1
for (int h = tid; h < H; h += block_size) {
float acc = b1[h];
for (int k = 0; k < L; ++k) {
acc += w1_T[k * H + h] * s_x[k];
}
float sig = 1.0f / (1.0f + expf(-acc));
s_z1[h] = acc * sig;
}
__syncthreads();
// Phase 2
for (int d = tid; d < D; d += block_size) {
float val = b2[d];
for (int h = 0; h < H; ++h) {
val += w2_T[h * D + d] * s_z1[h];
}
if (mask_val <= 0.5f) {
val = -CUDART_INF_F;
}
if (val > s_max[d]) {
s_max[d] = val;
}
}
__syncthreads();
```
This is simpler, always runs the same code path, and the compiler can optimize the mask branch. Since mask is uniform, the branch is cheap. This avoids any divergence issues. The extra compute for masked nodes is negligible unless many are masked. In worst case, still same compute.
However, the reference uses `-torch.finfo(fused.dtype).min`, not `-inf`. Wait, `torch.finfo(fused.dtype).min` is the most negative finite float, which is about -3.4e38 for fp32. `max` with `-inf` also works because `-inf < anything finite`. `max` over a set containing `-inf` returns the max of the finite elements. So `-inf` is acceptable and numerically equivalent as long as there is at least one valid element. If all are masked, the max would be `-inf` in both cases (reference would return min float, which is also very negative). Wait, reference: `fused.masked_fill(edge_mask.unsqueeze(-1) < 0.5, neg)` where `neg = torch.finfo(fused.dtype).min`. That's the smallest finite negative number, like -3.4028235e+38. Then `max` returns that value if all are masked. If we use `-INFINITY`, `max` would return `-inf`. `-inf` vs `-3.4e38` are different. Will that affect correctness? Possibly if all edges are masked for a node. Does the test include such cases? The `get_inputs` uses all ones. But evaluation may have random masks. To be safe, we should use the same sentinel value: `neg = -CUDART_MAX_NORMAL_F`? In CUDA, `FLT_MIN` is the smallest positive normalized float. The most negative finite float is `-FLT_MAX`. In float32, `FLT_MAX` is 3.4028235e+38. So we can use `-FLT_MAX`. PyTorch's `torch.finfo(torch.float32).min` is indeed `-3.4028235e+38`, which is `-FLT_MAX`. So we should use `-FLT_MAX` or `-(float)INFINITY`? `INFINITY` is not the same. So better to use `-CUDART_INF_F`? No, that's `-inf`. Let's use `-3.402823466e+38f` or we can compute it as `-INFINITY` but that might differ in all-masked cases.
Actually, we can just avoid the sentinel and conditionally update max only when mask is valid. In that case, if all are masked, `s_max` remains `-FLT_MAX` (since we initialized it to that). That matches the reference exactly. So we should initialize `s_max` to `-FLT_MAX`.
In the branchless version, if mask is invalid, we set `val = -FLT_MAX` and update max. That also yields `-FLT_MAX` if all invalid. Good.
So use `-FLT_MAX` for masked values.
But wait, the reference sets `fused = fused.masked_fill(..., neg)` where `neg = torch.finfo(fused.dtype).min`. Then `max` over that. The max of all `-FLT_MAX` is `-FLT_MAX`. So initializing to `-FLT_MAX` and not updating yields same. If some are valid, the max will be the maximum of valid values because `-FLT_MAX < any typical value`. Good.
So in code, define:
```cuda
const float NEG_INF = -3.402823466e+38f; // approx -FLT_MAX
```
Or include `<float.h>` and use `-FLT_MAX`. In CUDA, `FLT_MAX` is available from `<float.h>`. We can include it.
Now, the branchless version with `val = mask_val > 0.5f ? val : NEG_INF;` is uniform and safe.
But we need to be careful: `mask_val` is a float. In the reference, `edge_mask` is float {0,1}. So comparing to 0.5 is fine.
Now, let's write the kernel using the branchless approach. This also means we always compute the MLP even for masked edges. That's fine.
Potential performance improvement: The inner dot product loops can be unrolled if L is known at compile time. But L is dynamic (2*D+P). However, we can pass D, P, H as template parameters? Since the evaluation uses fixed sizes, we could specialize. But to be generic, we can use `pragma unroll` with a small number, like `#pragma unroll 8`, but that might not help much since L is ~200. The compiler may unroll automatically. Since each thread does a loop of L=208, unrolling could help. But we can add `#pragma unroll 4` to give a hint.
Wait, each thread looping over L=208 means 208 iterations. Unrolling by 4 gives 52 iterations. That might be okay. But if block_size=256 and H=192, only 192 threads are active in phase 1. 192 threads * 208 iterations = ~40k iterations of the inner loop body per j. That is small. Overhead of the loop is negligible compared to the memory loads. Memory loads from `w1_T` are the bottleneck. Since each thread reads a column of W1 sequentially (k loop), memory access is coalesced across the warp for each k. Wait, is it coalesced?
Let's examine W1_T access in phase 1:
`w1_T[k * H + h]`. For a fixed k, threads with h varying read addresses `k*H + h`. If H=192, and a warp has 32 threads with h=0..31 (first iteration), addresses are `k*H + 0`, `k*H + 1`, ..., `k*H + 31`. These are contiguous floats. Perfectly coalesced. They fit in 1 or 2 cache lines (128 bytes = 32 floats). So 1 cache line per warp per k. Since there are 6 warps (192/32 = 6), 6 cache lines per k. Total W1_T memory traffic per j = 6 warps * L cache lines = 6 * 208 * 128 bytes = 159,744 bytes = 156 KB. That's very close to the size of W1_T (160KB). So the entire W1_T is loaded once per j (or close to it). Good. Then it's cached for next j.
Similarly for Phase 2: `w2_T[h * D + d]`. For fixed h, threads d=0..31 read addresses `h*D + 0` .. `h*D + 31`. Contiguous. Coalesced. 3 warps (96/32=3). Total traffic per j = 3 warps * H cache lines = 3 * 192 * 128 bytes = 73,728 bytes = 72 KB. That's exactly the size of W2_T (73KB). So loaded once per j. Good.
This is excellent.
Now, for s_z1 reads in phase 2: all threads read `s_z1[h]` for each h. That's broadcast. Shared memory broadcast has no bank conflicts. Great.
Thus the kernel should be very efficient.
Now, let's consider the possibility that blockDim.x = 256 but H=192 and D=96. In phase 1, 192 threads active. 64 threads idle. In phase 2, 96 threads active. 160 threads idle. That's fine.
What if D=32 and H=192? Then phase 2 has 1 warp active. 7 warps idle. Still okay.
Given that the problem sizes are small, the overhead of idle threads is minor.
One more optimization: We can precompute `node_i` pointer and residual addition. Already done.
Let's refine the kernel code for correctness.
### Edge cases
- `B` and `N` could be large, but grid size is (B, N). Max grid dim is large.
- `D`, `P`, `H` could be large, but shared memory scales with them. We need to ensure shared memory doesn't exceed device limit. For D=96, H=192, P=16, block_size=256: 1008 floats = 4KB. Very small.
- If D > 1024, `s_max` would be >4KB, plus s_z1 >4KB, etc. But typical sizes are small. We could add an assert or handle generically.
- Block size is 256. If D or H > 256, loops handle it. The reduction uses block_size.
### Writing the kernel with block_size as template parameter?
We can make block_size a template to allow the compiler to unroll the reduction. But we can also just use 256 and loop. The reduction loop is `for (int stride = block_size/2; stride > 0; stride >>= 1)`. Since `block_size` is a variable (though constant because we pass 256), the compiler might not fully unroll it. We can make `block_size` a template parameter or a compile-time constant. In the kernel, `blockDim.x` is runtime. But we know it's 256. We can use `constexpr int block_size = 256;` inside the kernel and always launch with 256 threads. That would allow unrolling. But if we launch with a different block size, it would mismatch. Better to just hardcode 256 in the kernel and launch with 256.
Alternatively, we can pass `block_size` as a template parameter. But `load_inline` with templated kernels is tricky because the C++ wrapper needs to instantiate it. Simpler to just hardcode 256.
Let's set:
```cuda
#define BLOCK_SIZE 256
__global__ void ... {
const int tid = threadIdx.x;
// ...
const int block_size = BLOCK_SIZE;
```
But we also need to handle the case where `threadIdx.x >= block_size`? We launch exactly BLOCK_SIZE threads. So `threadIdx.x` is always < 256.
Wait, we used loops `for (int h = tid; h < H; h += block_size)`. If H < block_size, this loop runs once for some threads and zero times for others. That's fine.
For the reduction, we can safely unroll if we know `block_size` is 256.
```cuda
// reduction
#pragma unroll
for (int stride = 128; stride > 0; stride >>= 1) {
if (tid < stride) {
s_sum[tid] += s_sum[tid + stride];
s_sum_sq[tid] += s_sum_sq[tid + stride];
}
__syncthreads();
}
```
This is more efficient because it's fully unrolled. But it assumes block_size is exactly 256. If we change block_size, we need to adjust. We can add a static_assert or just write it with a loop and hope the compiler unrolls. But for small 256, explicit unroll is easy.
Actually, we can write:
```cuda
#pragma unroll
for (int stride = block_size / 2; stride > 0; stride >>= 1) {
```
But `block_size` is a variable (though constexpr). The compiler may unroll. To be safe, I'll define `constexpr int BLOCK = 256;` and use explicit values.
Now, we need to check if we need `#include <float.h>` for FLT_MAX.
Let's write the CUDA source.
Also, note: in phase 1 and phase 2, we have loops inside loops. The `#pragma unroll` on the inner loops (L and H) might help.
```cuda
#pragma unroll 8
for (int k = 0; k < L; ++k) { ... }
```
But `L` is dynamic. The compiler might not unroll. We can add `#pragma unroll 4` to hint partial unrolling.
Similarly for the H loop in phase 2:
```cuda
#pragma unroll 8
for (int h = 0; h < H; ++h) { ... }
```
Let's add these pragmas.
One more optimization: In phase 2, each thread accumulates `val`. We can preload `b2[d]` and the address for `w2_T` and `s_z1` into registers.
Actually, `w2_T` access pattern is: for each h, read `w2_T[h*D + d]`. We can compute `w2_T += D` each iteration. That's good. The compiler will likely optimize it.
Let's also consider using `float4` loads for `s_x` and weights. Since L=208, D=96, P=16, they are multiples of 4 (208/4=52, 96/4=24, 16/4=4). We could vectorize loads. But the loops are simple; the compiler may not automatically vectorize strided accesses (w1_T columns). For w1_T, `k*H + h` with step H is not contiguous. So float4 for w1_T is not natural. For s_x, we load sequentially. But s_x is in shared memory; vector loads aren't needed.
Given the small size and high occupancy, the kernel is likely fast enough.
### Implementation of Module
We need to be careful with `load_inline`. The C++ wrapper should accept `torch::Tensor` and do CUDA dispatch. Since we are compiling with `load_inline`, the pybind wrapper will handle GIL and tensor conversion.
The C++ function `fused_scene_graph_forward` will be exposed to Python.
One thing: `w1_T` and `w2_T` must be contiguous. We call `.t().contiguous()` in Python. The C++ wrapper can use `.data_ptr<float>()` safely.
Also, we should ensure inputs are on CUDA and contiguous. We can call `.contiguous()` on nodes, pair_feats, edge_mask.
```python
nodes = nodes.contiguous()
pair_feats = pair_feats.contiguous()
edge_mask = edge_mask.contiguous()
```
The output `out = torch.empty_like(nodes)` will be on the same device.
Now, let's think about potential numerical issues.
- SiLU: PyTorch SiLU is `x * sigmoid(x)`, where `sigmoid(x) = 1 / (1 + exp(-x))`. For large positive x, `exp(-x)` underflows to 0, sigmoid=1, SiLU=x. For large negative x, `exp(-x)` overflows? Actually `exp(-(-100)) = exp(100)` overflows to inf, so `1 + inf = inf`, `1/inf = 0`, SiLU = x * 0 = 0 (with sign? x is negative, 0 is +0, result is -0). In CUDA, `expf(100)` gives inf. `1.0f / (1.0f + expf(-x))` for x=-100: `expf(100)=inf`, `1+inf=inf`, `1/inf=0`. Then `x * 0 = -0`. That's fine. `sigmoid(100)` gives 1. `silu(100) = 100`.
- But there might be slight differences in `expf` vs PyTorch's implementation (which may use a more precise approximation or special handling for large values). For typical random values, difference is within tolerance.
- LayerNorm: we compute `var = E[x^2] - mean^2`. This can be slightly negative due to rounding, but we take `rsqrt(max(var, 0) + eps)`. In our code, we compute `var = mean_sq - mean * mean`. If var is slightly negative, adding `1e-5` will make it positive, `rsqrt` is fine. But PyTorch computes variance in a numerically stable way: `mean((x - mean)^2)`. Our two-pass approach (mean then E[x^2] - mean^2) is mathematically equivalent but may have slightly different rounding. Since `x` values are max-pooled MLP outputs, they can be large? Actually after SiLU and linear, values could be moderate. With fp32, subtracting large numbers could lose precision. But the tolerance is 1e-4, so our method should be fine. However, to be safe, we could compute variance in a Welford-like way or just in two passes: first compute mean, then compute `sum((x-mean)^2)`. But that requires another loop.
We can do a two-pass approach using shared memory:
1. Compute mean (reduction).
2. Broadcast mean.
3. Each thread computes `sum_sq_diff` = sum of `(x - mean)^2`.
4. Reduce `sum_sq_diff`.
5. Compute inv_std = 1 / sqrt(sum_sq_diff / D + eps).
This is more numerically stable and matches PyTorch's implementation more closely (PyTorch does a two-pass online algorithm). Let's do this.
```cuda
// LayerNorm
float local_sum = 0.0f;
for (int idx = tid; idx < D; idx += block_size) {
local_sum += s_max[idx];
}
s_sum[tid] = local_sum;
__syncthreads();
// reduce to get mean
#pragma unroll
for (int stride = block_size / 2; stride > 0; stride >>= 1) {
if (tid < stride) {
s_sum[tid] += s_sum[tid + stride];
}
__syncthreads();
}
float mean = s_sum[0] / D;
// compute variance
float local_var = 0.0f;
for (int idx = tid; idx < D; idx += block_size) {
float diff = s_max[idx] - mean;
local_var += diff * diff;
}
s_sum[tid] = local_var;
__syncthreads();
// reduce variance
#pragma unroll
for (int stride = block_size / 2; stride > 0; stride >>= 1) {
if (tid < stride) {
s_sum[tid] += s_sum[tid + stride];
}
__syncthreads();
}
float var = s_sum[0] / D;
float inv_std = rsqrtf(var + 1e-5f);
for (int idx = tid; idx < D; idx += block_size) {
float v = s_max[idx];
float norm = (v - mean) * inv_std;
float o = norm * ln_w[idx] + ln_b[idx];
o += s_x[idx];
out[(b * N + i) * D + idx] = o;
}
```
This requires only `s_sum` (one array) instead of `s_sum` and `s_sum_sq`. We can adjust shared memory layout.
Layout: `s_x[L]`, `s_z1[H]`, `s_max[D]`, `s_sum[BLOCK_SIZE]`. Total = L + H + D + BLOCK_SIZE floats. With BLOCK=256: 208+192+96+256 = 752 floats = 3008 bytes.
This is better and more numerically stable.
Now, we need to make sure that `s_sum` is big enough. Yes, BLOCK_SIZE.
One more thing: in the reference, `self.out_norm = nn.LayerNorm(dim)`. The default `elementwise_affine=True`. The LayerNorm formula in PyTorch is:
```
y = (x - mean) / sqrt(var + eps) * weight + bias
```
We match that.
Now, potential issue: `rsqrtf` computes `1/sqrt(x)`. PyTorch's `rsqrt` may have slightly different precision. But within tolerance.
Also, we need to ensure that the kernel handles `B` and `N` correctly. `blockIdx.x` goes up to B-1, `blockIdx.y` up to N-1. That's fine.
Now, let's consider the kernel launch configuration. We said `dim3 grid(B, N); dim3 block(BLOCK_SIZE);`. But what if B*N is very large, e.g., > 65535 in one dimension? Grid is 2D, so max is 65535 for x and y on older arch. On newer arch (Volta+), grid x,y,z up to 2^31-1. The GPU is likely A100 (Ampere), which supports large grids. So (B, N) is fine.
But if B is large and N is large, we could also have `grid(B*N)` and compute b=i/N, i=i%N. That avoids 2D grid limits. But 2D is simpler.
Actually, CUDA gridDim.y max is 65535 on pre-Volta, but on Volta+ it's 65535? Let's check: On compute capability 7.0+, the maximum grid dimension in x is 2^31-1, in y and z is 65535. On Ampere (8.0), y max is 65535. Reference says: "Maximum y- or z-dimension of a grid of thread blocks" is 65535 for all current compute capabilities. So if N > 65535, we cannot use `blockIdx.y`. N is number of objects, typically small (24). So fine.
If B > 65535? B is batch size, typically small (4). So fine. 2D grid is safe.
Now, let's consider the `load_inline` specifics.
We need to provide `cpp_sources` and `cuda_sources`. The C++ wrapper uses ATen.
```
#include <torch/extension.h>
```
`torch::empty_like(nodes)` returns a tensor. We need to ensure `nodes` is a CUDA float tensor.
In the C++ wrapper, we could add checks:
```
TORCH_CHECK(nodes.is_cuda(), "nodes must be CUDA");
TORCH_CHECK(nodes.is_contiguous(), "nodes must be contiguous");
...
```
But not strictly necessary.
Also, we need to include the kernel call. Since `load_inline` auto-generates pybind, we don't write `PYBIND11_MODULE`. We just write a function `torch::Tensor fused_scene_graph_forward(...)`. And list it in `functions=[...]`.
Potential issue: The kernel name must be declared before the C++ wrapper function, or we can put them in `cuda_sources` which are compiled together. The C++ code sees the CUDA kernel declaration if we include it in `cuda_sources`. Actually, `load_inline` concatenates `cpp_sources` and `cuda_sources` into one file? No, they are separate compilation units. `cuda_sources` is compiled by nvcc, `cpp_sources` by the host compiler. But we can declare the kernel as `extern "C"` or just have the C++ wrapper call it; since they are in the same compilation unit (the extension module), the CUDA compiler handles the linkage? Actually, `load_inline` puts `cpp_sources` in the `.cpp` file and `cuda_sources` in a `.cu` file? Or it combines them? The documentation says: `cpp_sources` is a string containing C++ source code, and `cuda_sources` is a string containing CUDA source code. They are compiled into a single extension. The CUDA source is compiled with nvcc, the C++ with the host compiler, and linked together. So the C++ code needs a declaration of the kernel. We can put the kernel in `cuda_sources` and add a declaration in `cpp_sources`. Or we can put everything in `cuda_sources` and have an empty `cpp_sources`? The wrapper function also needs to be compiled with nvcc if it calls the kernel (since it uses `<<<>>>`). Actually, the wrapper function is host code that launches the kernel. It can be in the `.cpp` or `.cu`. But if it's in `.cpp`, the `.cpp` compiler (gcc/clang) may not understand `<<<>>>` syntax. So the wrapper must be in `cuda_sources`. But we need to expose a function callable from Python (C++ function). `load_inline` takes `functions` from the combined sources? The docs: `functions` is a list of function names to bind. Usually, the C++ wrapper is in `cpp_sources`. But if the wrapper uses CUDA launch syntax, it must be in a `.cu` file. However, `load_inline` compiles `cpp_sources` and `cuda_sources` together, and the `functions` can be defined in either. The pybind module is auto-generated. So we can put the C++ wrapper in `cuda_sources`? Actually, we can put everything in `cpp_sources` if we don't use CUDA-specific syntax? But `<<<>>>` is not valid C++. So we must put the launcher in `cuda_sources`.
Wait, `load_inline` doc: https://pytorch.org/docs/stable/cpp_extension.html
`cpp_sources` and `cuda_sources` are concatenated? Let's check typical usage:
```python
fused = load_inline(
name="fused",
cpp_sources="torch::Tensor foo(torch::Tensor x) { ... }",
cuda_sources="""__global__ void kernel(...) { ... }""",
functions=["foo"],
)
```
In this case, `foo` is defined in `cpp_sources`. But `foo` can call the kernel declared in `cuda_sources` if there's a forward declaration in `cpp_sources`. Actually, the kernel is compiled by nvcc into device code. The host compiler compiles `cpp_sources`. For `foo` to call the kernel, it needs at least a host-side declaration. Usually, you put the kernel declaration (with `__global__`) in a header included by the C++ code, or in the `cuda_sources` file which is also parsed by nvcc? The `cuda_sources` file is compiled by nvcc, which generates host object code for the launcher stub. The C++ compiler sees only `cpp_sources`. So if `foo` is in `cpp_sources`, it cannot call `kernel<<<>>>` unless the C++ compiler sees a declaration.
To avoid this issue, the simplest is to write a single host function in `cuda_sources` that includes both the kernel and the launcher. But `load_inline` might not bind functions defined in `cuda_sources`? Actually, it should; it just compiles all source files and then binds the listed functions. The functions must be visible to the linker. If we define `foo` in `cuda_sources` with `extern "C"` or just regular C++ linkage, nvcc will compile it and the linker will see it. Then we list `foo` in `functions=[]`. This is common.
Let's check typical examples. In many KernelBench solutions, they put everything (kernel + wrapper) in `cuda_sources` and leave `cpp_sources` empty, or put utility code in `cpp_sources`. The `functions` refers to the wrapper function names.
Example from KernelBench:
```python
custom_op = load_inline(
name="...",
cuda_sources=cuda_source,
functions=["custom_forward"],
extra_cuda_cflags=["-O3"],
)
```
Where `cuda_source` contains both `__global__ void kernel(...)` and `torch::Tensor custom_forward(...) { kernel<<<...>>>(...); return out; }`.
So we should put both the kernel and the launcher in `cuda_sources`. `cpp_sources` can be empty or omitted. But we need to include `<torch/extension.h>`.
So:
```python
cuda_source = r"""
#include <torch/extension.h>
#include <cuda.h>
#include <cuda_runtime.h>
#include <float.h>
#include <math.h>
#define BLOCK_SIZE 256
__global__ void fused_scene_graph_kernel(...) { ... }
torch::Tensor fused_scene_graph_forward(...) {
...
fused_scene_graph_kernel<<<grid, block, shmem_size>>>(...);
return out;
}
"""
```
And `functions=["fused_scene_graph_forward"]`.
Now, I need to make sure the kernel compiles. Let's write it carefully.
One more thing: `CUDART_INF_F` is defined in `<cuda_runtime_api.h>`, but for `FLT_MAX` we need `<float.h>`. We'll define `NEG_SENTINEL = -FLT_MAX`.
Alternatively, we can just use `-(3.402823466e+38f)` to avoid including `<float.h>`. But `<float.h>` is standard.
Let's write the kernel.
### Complete CUDA code
```cuda
#include <torch/extension.h>
#include <cuda.h>
#include <cuda_runtime.h>
#include <float.h>
#include <math.h>
#define BLOCK_SIZE 256
__global__ void fused_scene_graph_kernel(
const float* __restrict__ nodes,
const float* __restrict__ pair_feats,
const float* __restrict__ edge_mask,
const float* __restrict__ w1_T, // shape (L, H)
const float* __restrict__ b1, // shape (H,)
const float* __restrict__ w2_T, // shape (H, D)
const float* __restrict__ b2, // shape (D,)
const float* __restrict__ ln_w, // shape (D,)
const float* __restrict__ ln_b, // shape (D,)
float* __restrict__ out,
int B, int N, int D, int P, int H)
{
const int tid = threadIdx.x;
const int b = blockIdx.x;
const int i = blockIdx.y;
const int L = 2 * D + P;
extern __shared__ float s_mem[];
float* s_x = s_mem; // [L]
float* s_z1 = s_x + L; // [H]
float* s_max = s_z1 + H; // [D]
float* s_red = s_max + D; // [BLOCK_SIZE]
// Load node_i into first D elements of s_x
const float* node_i_ptr = nodes + (b * N + i) * D;
for (int idx = tid; idx < D; idx += BLOCK_SIZE) {
s_x[idx] = node_i_ptr[idx];
}
__syncthreads();
// Initialize running max
for (int idx = tid; idx < D; idx += BLOCK_SIZE) {
s_max[idx] = -FLT_MAX;
}
__syncthreads();
// Iterate over all neighbors j
for (int j = 0; j < N; ++j) {
// Load node_j and pair_feats into shared memory
const float* node_j_ptr = nodes + (b * N + j) * D;
const float* pair_ptr = pair_feats + ((b * N + i) * N + j) * P;
for (int idx = tid; idx < D; idx += BLOCK_SIZE) {
s_x[D + idx] = node_j_ptr[idx];
}
for (int idx = tid; idx < P; idx += BLOCK_SIZE) {
s_x[2 * D + idx] = pair_ptr[idx];
}
__syncthreads();
float mask_val = edge_mask[((b * N + i) * N + j)];
// Phase 1: compute hidden pre-activations
for (int h = tid; h < H; h += BLOCK_SIZE) {
float acc = b1[h];
// Dot product over input features
#pragma unroll 4
for (int k = 0; k < L; ++k) {
acc += w1_T[k * H + h] * s_x[k];
}
float sig = 1.0f / (1.0f + expf(-acc));
s_z1[h] = acc * sig;
}
__syncthreads();
// Phase 2: compute output and update running max
for (int d = tid; d < D; d += BLOCK_SIZE) {
float val = b2[d];
#pragma unroll 4
for (int h = 0; h < H; ++h) {
val += w2_T[h * D + d] * s_z1[h];
}
if (mask_val <= 0.5f) {
val = -FLT_MAX;
}
if (val > s_max[d]) {
s_max[d] = val;
}
}
__syncthreads();
}
// LayerNorm over the D dimension
float local_sum = 0.0f;
for (int idx = tid; idx < D; idx += BLOCK_SIZE) {
local_sum += s_max[idx];
}
s_red[tid] = local_sum;
__syncthreads();
// Tree reduction for mean
#pragma unroll
for (int stride = BLOCK_SIZE / 2; stride > 0; stride >>= 1) {
if (tid < stride) {
s_red[tid] += s_red[tid + stride];
}
__syncthreads();
}
float mean = s_red[0] / D;
// Variance
float local_var = 0.0f;
for (int idx = tid; idx < D; idx += BLOCK_SIZE) {
float diff = s_max[idx] - mean;
local_var += diff * diff;
}
s_red[tid] = local_var;
__syncthreads();
#pragma unroll
for (int stride = BLOCK_SIZE / 2; stride > 0; stride >>= 1) {
if (tid < stride) {
s_red[tid] += s_red[tid + stride];
}
__syncthreads();
}
float var = s_red[0] / D;
float inv_std = rsqrtf(var + 1e-5f);
// Final Output
for (int idx = tid; idx < D; idx += BLOCK_SIZE) {
float v = s_max[idx];
float norm = (v - mean) * inv_std;
float o = norm * ln_w[idx] + ln_b[idx];
o += s_x[idx]; // residual
out[(b * N + i) * D + idx] = o;
}
}
torch::Tensor fused_scene_graph_forward(
torch::Tensor nodes,
torch::Tensor pair_feats,
torch::Tensor edge_mask,
torch::Tensor w1_T,
torch::Tensor b1,
torch::Tensor w2_T,
torch::Tensor b2,
torch::Tensor ln_w,
torch::Tensor ln_b)
{
int B = nodes.size(0);
int N = nodes.size(1);
int D = nodes.size(2);
int P = pair_feats.size(3);
int H = w1_T.size(1);
auto out = torch::empty_like(nodes);
dim3 grid(B, N);
dim3 block(BLOCK_SIZE);
int L = 2 * D + P;
size_t shmem_size = (L + H + D + BLOCK_SIZE) * sizeof(float);
fused_scene_graph_kernel<<<grid, block, shmem_size>>>(
nodes.data_ptr<float>(),
pair_feats.data_ptr<float>(),
edge_mask.data_ptr<float>(),
w1_T.data_ptr<float>(),
b1.data_ptr<float>(),
w2_T.data_ptr<float>(),
b2.data_ptr<float>(),
ln_w.data_ptr<float>(),
ln_b.data_ptr<float>(),
out.data_ptr<float>(),
B, N, D, P, H);
return out;
}
```
Wait, there is a subtle issue with the reduction: we have `s_red` of size BLOCK_SIZE. But if `D < BLOCK_SIZE`, threads with `tid >= D` will write zero to local_sum/local_var and participate in reduction. That's fine. Their `s_max[idx]` might read uninitialized? No, in the loops over `idx < D`, threads with `tid >= D` have `idx = tid >= D`, so they don't enter the loop. `local_sum` is 0.0f. So reduction is fine.
But there is a potential race if `D` is not a multiple of something? No.
One more nuance: `mask_val` is a scalar loaded by each thread from global memory. Since the address is the same for all threads, it's cached. Good.
Now, we need to handle the case where `pair_feats` might not have `pair_feats.size(3)`? In forward, shape is (B,N,N,P). So `size(3)` works.
Now, the `ModelNew` class:
```python
class ModelNew(nn.Module):
def __init__(self, dim: int, pair_dim: int, hidden: int):
super().__init__()
self.dim = dim
self.pair_dim = pair_dim
self.hidden = hidden
self.fuse = nn.Sequential(
nn.Linear(2 * dim + pair_dim, hidden),
nn.SiLU(),
nn.Linear(hidden, dim),
)
self.out_norm = nn.LayerNorm(dim)
def forward(self, nodes, pair_feats, edge_mask):
# Make contiguous
nodes = nodes.contiguous()
pair_feats = pair_feats.contiguous()
edge_mask = edge_mask.contiguous()
# Prepare transposed weights for coalesced access
w1_T = self.fuse[0].weight.t().contiguous()
b1 = self.fuse[0].bias.contiguous()
w2_T = self.fuse[2].weight.t().contiguous()
b2 = self.fuse[2].bias.contiguous()
ln_w = self.out_norm.weight.contiguous()
ln_b = self.out_norm.bias.contiguous()
return _fused.fused_scene_graph_forward(
nodes, pair_feats, edge_mask,
w1_T, b1, w2_T, b2, ln_w, ln_b
)
```
But wait: the reference model's `forward` does not modify inputs. Our `contiguous()` may create copies if inputs are not contiguous. That's okay.
Also, `w1_T = self.fuse[0].weight.t().contiguous()` will allocate a new tensor each forward pass. For small weights, that's fine. But to avoid overhead, we could cache them. However, since `load_inline` compilation might take time, the forward will be called multiple times. But the overhead of transposing 40k floats is tiny.
But there is a problem: if the evaluation copies state dicts after `__init__`, then we must ensure our weight layout inside the `nn.Linear` modules is standard. We are using standard `nn.Linear`, so it's fine. The transposition is done on the fly in `forward`.
One more concern: the reference uses `nn.SiLU()`. Does `nn.SiLU()` have inplace option? No. It computes elementwise. So our kernel matches.
Potential performance optimization: Pre-transpose weights and store as buffers. We can do it in `forward` the first time or in `__init__`. But we need to handle parameter updates (e.g., training). If the kernel is used only for inference (which is typical for "forward"), weights are static. But to be safe, we can transpose on the fly. The overhead is tiny compared to the kernel.
Alternatively, we can store the original weights in `nn.Parameter` with shape (L, H) and (H, D) directly, and manually implement the initialization to match `nn.Linear`. But that breaks `state_dict` compatibility.
So on-the-fly transpose is best.
Wait, but `self.fuse[0].weight` is a Parameter of shape (H, L). Calling `.t()` returns a view of shape (L, H). Calling `.contiguous()` copies it to a new tensor with shape (L, H). That new tensor is not a Parameter, just a Tensor. That's fine.
One more thing: `torch.utils.cpp_extension.load_inline` may cache the compiled extension. The first call might be slow. Subsequent calls load from cache.
Now, we should compile and test.
But before that, let's consider if there are any numerical differences.
1. The MLP:
- `nn.Linear` uses fused addmm in PyTorch, which might use different rounding order than our manual dot product. However, for fp32, the dot product order is similar (accumulate in float32). PyTorch may use a different algorithm (e.g., splitting), but for small sizes, it's likely just a loop. So results should match within 1e-4.
2. SiLU: PyTorch SiLU might be implemented with `x * sigmoid(x)` where `sigmoid` uses a fast approximation or the exact formula. CUDA `expf` is accurate. For large negative inputs, `expf` might overflow to inf, giving 0. For very negative inputs, PyTorch's sigmoid may underflow to 0 as well. Slight differences might exist for extreme values. But with random init, values are moderate.
3. Max pool: our max over j should match `max(dim=2)`.
4. LayerNorm:
- PyTorch LayerNorm uses Welford's algorithm or a two-pass method with `mean` then `sum((x - mean)^2)`. Our two-pass method matches. The epsilon is 1e-5. PyTorch's `LayerNorm` default eps is 1e-5. Good.
- We compute variance as `sum((x-mean)^2)/D`. PyTorch computes it as `sum((x-mean)^2)/D`. Yes.
- Then `rsqrt(var + eps)`. Yes.
Potential issue: Our `mean` and `var` are computed in float32. PyTorch also uses float32.
One more potential issue: If there are NaNs or Infs, results differ. But random init shouldn't produce that.
Now, we need to check if any thread divergence or synchronization issue exists.
In the j loop:
1. Load s_x (node_j, pair). All threads participate (some load, some don't). __syncthreads.
2. Phase 1: compute s_z1. Threads with `tid < H` participate. Others idle. __syncthreads.
3. Phase 2: compute s_max. Threads with `tid < D` participate. Others idle. __syncthreads.
Since the idle threads don't reach __syncthreads? Wait, `if (tid < H)` would cause divergence: some threads in a warp may be active, some not. But the `__syncthreads()` inside the if would be a problem because not all threads in the block reach it. However, in our code, `__syncthreads()` is **outside** the `for (int h = tid; h < H; h += BLOCK_SIZE)` loop. The loop body is executed only if `tid < H` (since h starts at tid). If `tid >= H`, the loop doesn't execute. But `__syncthreads()` after the loop is reached by all threads because it's outside any conditional block. So all threads reach it. That's safe.
Same for Phase 2: `__syncthreads()` is after the `for (int d = tid; d < D; d += BLOCK_SIZE)` loop, which is unconditional in terms of thread execution path (the loop just doesn't iterate for some threads). All threads reach the `__syncthreads()`. Good.
But wait: the `if (mask_val <= 0.5f) val = -FLT_MAX;` is inside the loop. Since `mask_val` is the same for all threads, this branch is uniform across the warp. No divergence.
So synchronization is fine.
One small thing: `mask_val` is loaded after `__syncthreads()` inside the j loop. Then phase 1 uses `mask_val`? No, phase 1 is unconditional. Then phase 2 uses mask_val. That's fine.
Potential performance issue: For each j, we recompute the entire MLP even if `mask_val <= 0.5`. But masked edges are few? In evaluation, edge_mask may be random. If many are masked, we waste compute. But we can skip phase 1 and 2 if `mask_val <= 0.5`. However, as discussed, __syncthreads inside an if that is uniform is fine. Let's do that to save compute for masked edges. But to keep code simple and avoid any possible issues, we can leave it unconditional. The compute is small anyway.
Wait, if we skip phase 1+2 when masked, all threads still need to reach the __syncthreads at the end of the j loop. Since the condition is uniform across the block, it's okay.
Let's do unconditional for simplicity; the compiler might still optimize it because mask is uniform and loaded into a register. Actually, the loop over k is the main compute; the compiler likely won't skip it based on a uniform value unless we explicitly branch. Let's branch explicitly for performance, since evaluation may have many masked edges? Probably not, but it's a small code change.
```cuda
if (mask_val > 0.5f) {
for (int h = tid; h < H; h += BLOCK_SIZE) {
...
}
__syncthreads();
for (int d = tid; d < D; d += BLOCK_SIZE) {
...
if (val > s_max[d]) s_max[d] = val;
}
}
__syncthreads();
```
Is the `__syncthreads()` inside the `if` safe? Since the condition is uniform for the block, all threads in the block either take the branch or not. So all threads reach that `__syncthreads()`. Then all threads reach the final `__syncthreads()`. So it's safe. But to avoid any subtle issues with the compiler not recognizing uniform branch, we can write it without inner `__syncthreads()`:
```cuda
if (mask_val > 0.5f) {
for (int h = tid; h < H; h += BLOCK_SIZE) {
...
}
for (int d = tid; d < D; d += BLOCK_SIZE) {
float val = b2[d];
// note: need s_z1 ready, so we need a sync between phase1 and phase2
}
}
```
But we still need a sync between phase 1 and phase 2 because threads writing s_z1 (phase1) and threads reading s_z1 (phase2) are the same threads but may interleave? Actually, within the same `if`, after phase 1 loop, we need a sync before phase 2 loop to ensure all s_z1 writes are visible. Since it's within the `if` and all threads are active, `__syncthreads()` is safe.
But there is a subtle issue: if `mask_val > 0.5f` is uniform across the block, but some threads in a warp may be inactive because `tid >= H`? No, the `if` is not around the loop `for (int h = ...)`. The if is around the entire phase block. So all threads (including those with tid >= H) enter the if. Then they all reach the `__syncthreads()` inside. That's safe.
Let's keep it simple and unconditional. 24 iterations is small. The overhead of branch vs unconditional is negligible.
Wait, actually we can't make it unconditional if we want to match the sentinel exactly: for masked edges we need to set val to -FLT_MAX. But if we compute the MLP anyway, it's fine.
Now, let's compile and test.
I realize that `extern __shared__ float s_mem[];` must be declared with the maximum alignment. Partitioning with `float*` is fine because floats are 4-byte aligned.
Now, about the `load_inline` call: we need to handle the case where the extension is already loaded. `load_inline` caches by name. We should give a unique name. "fused_scene_graph" is fine.
Let's write the full Python file.
But before finalizing, let's consider using `torch.compile` or `Triton`. The instruction says to use `torch.utils.cpp_extension.load_inline`. So we must use CUDA C++.
One more optimization: The `for (int k = 0; k < L; ++k)` loop reads `s_x[k]` which is shared memory. All threads read the same `s_x[k]` for each k? Wait, each thread reads `s_x[k]` for varying k in its own loop. Actually, each thread iterates over all k. So there is no broadcast; each thread reads the entire `s_x` array sequentially. But `s_x` is in shared memory, so it's fast.
Similarly, w1_T reads are strided across threads (coalesced across k). Actually, for a fixed k, threads read `w1_T[k*H + h]`. That's coalesced. Then k increments. So memory access pattern is: for k=0, read 256 contiguous floats (or H if H<256). For k=1, read next 256 contiguous floats. This is perfect. The cache line is 128 bytes = 32 floats. So for a warp of 32 threads, each k reads 1 cache line. For 6 warps, 6 cache lines per k. L=208, so total 1248 cache lines. 1248 * 128 = 159,744 bytes. Good.
Wait, but a warp has 32 threads. The active threads in phase 1 are 192. That's 6 warps. For each k, warp 0 reads h=0..31, warp 1 reads h=32..63, etc. Offsets: k*H + 0, ..., k*H + 31 (contiguous). So each warp reads one cache line. 6 warps -> 6 cache lines. 208 k -> 1248 cache lines. That's 159KB per j. Since W1_T is 160KB, it's essentially read once per j. Great.
Similarly, phase 2: active threads D=96, that's 3 warps. For each h, warp 0 reads d=0..31 (1 cache line), warp 1 reads d=32..63 (1 cache line), warp 2 reads d=64..95 (1 cache line). 3 cache lines per h. H=192 -> 576 cache lines. 576*128 = 73,728 bytes. W2_T is 73KB. Read once per j. Great.
Thus the kernel is very efficient in terms of memory reuse.
Now, one more thing: `pair_feats` indexing is `((b * N + i) * N + j) * P + idx`. We should ensure the pair_feats layout is (B, N, N, P). Yes.
Node_j indexing: `(b * N + j) * D + idx`. Good.
Node_i indexing: `(b * N + i) * D + idx`. Good.
Edge_mask: `((b * N + i) * N + j)`. Good.
Now, let's write the full code and test compile.