reasoning
The task is to write a custom CUDA kernel for a scatter-add (index_add_) operation. Let me analyze the reference implementation:
- `num_nodes = 768`
- `num_edges = 16384`
- `feat_dim = 64`
- `dst_idx` is int32, shape (num_edges,)
- `edge_feat` is float32, shape (num_edges, feat_dim)
- Output: `out[num_nodes, feat_dim]` where `out[dst_idx[i]] += edge_feat[i]`
This is a scatter-add operation. The default PyTorch `index_add_` should be reasonably efficient, but we can likely do better with a custom kernel.
For optimization on H100:
1. We can use shared memory for atomic adds if there are many edges mapping to the same node
2. We can tile the feature dimension and process multiple features per thread
3. We can potentially sort edges by destination to improve coalescing
For a graph with 768 nodes and 16384 edges, that's about 21 edges per node on average (16384/768 ≈ 21.3). With feature_dim = 64, this is a relatively small graph but with medium feature dimension.
Strategies:
1. **Warp-level reduction**: Since there are only 768 nodes, we could process edges in tiles and use shared memory atomics. But with 64 features, the memory bandwidth is the key.
2. **Tiling by feature dimension**: Each thread/warp/threadblock processes a subset of features for a subset of edges.
3. **Sort edges by dst_idx then use segmented reduction**: This avoids atomics entirely! With only ~21 edges per node on average, a segmented reduction approach could be very efficient.
However, the simplest and often fastest approach for this scale:
- Launch one thread per output element (node, feature), and have each thread iterate over edges looking for matching dst_idx. This is O(num_nodes * feat_dim * num_edges) which is 768 * 64 * 16384 = 805M operations - too expensive.
Better approaches:
1. **AtomicAdd in global memory**: Each edge thread processes a feature block and atomically adds to output. Good for moderate contention (21 edges per node on average).
2. **Sort + scan-based segmented reduction**:
- Sort edges by dst_idx
- Find segment boundaries
- For each node, reduce its edges' features
3. **Shared memory accumulation + atomic to global**:
- Thread block processes a subset of edges
- Uses shared memory for the destination nodes that fall within the block
- Since there are only 768 nodes, we might even fit the partial accumulators in shared memory! 768 * 64 * 4 bytes = 196KB. That's close to the 227KB limit but might work. Actually, we need some overhead, so maybe use half the SM or process in two passes.
Wait, 768 * 64 = 49152 floats = 196KB. We can't quite fit that in shared memory per block (max 227KB but practically needs some padding and other shared memory). We could do 768 * 32 = 98KB, processing 32 features at a time.
Or better: divide feature dimension among thread blocks. Each block handles all edges but only a subset of features (e.g., 16 features). Then we need 768 * 16 * 4 = 49KB of shared memory. Multiple blocks can run concurrently on different feature ranges.
Actually, let's think about memory bandwidth:
- Reading edge_feat: 16384 * 64 * 4 = 4MB
- Writing output: 768 * 64 * 4 = 196KB
- Reading dst_idx: 16384 * 4 = 64KB
Total memory traffic is dominated by reading edge_feat. The H100 has 3.35 TB/s bandwidth, so theoretical minimum is ~1.2 microseconds. Realistically with atomics and index_add, the reference might be 5-20 microseconds.
Actually, PyTorch's `index_add_` is quite optimized. Let me think about how to beat it.
PyTorch likely implements this as:
- Each thread handles one or more features of one edge, doing atomicAdd to the output.
The problem with atomicAdd is bank conflicts and serialization when multiple edges target the same node. With ~21 edges per node, if the edges are random, there will be contention.
One optimization: sort edges by dst_idx to improve coalescing. But sorting itself takes time.
Another idea: use warp-level primitives to do a "warp-staged" accumulation.
- Group edges that target the same node within a warp
- Use shuffle to reduce within the warp
- Then do one atomicAdd per warp per node
But finding matches within a warp is tricky without sorting.
A simpler approach that might beat PyTorch:
- Process edges in tiles (e.g., 256 edges)
- Load dst_idx for the tile
- For each unique dst in the tile, accumulate features using warp primitives
- Write atomically to global memory
Or:
- Use a custom kernel where each thread block handles a feature slice and a subset of edges
- Use shared memory for the output slice (768 * slice_size floats)
- Zero shared memory, accumulate edges into shared memory
- Write shared memory to global output
Let me calculate: if we process 16 features per block, we need 768 * 16 * 4 = 49KB shared memory. We can have 4 blocks per SM (228KB / 49KB ≈ 4). With 132 SMs on H100 (actually need to check: H100 SXM has 132 SMs), and each block processes all 16384 edges but only 16 features.
Wait, that would mean each block has ~1.6 million operations (16384 edges * 16 features). If it takes 1024 threads, each thread does ~256 edge-feature pairs. That's reasonable.
For 64 features total, we'd launch 4 such blocks (64 / 16 = 4). Actually, 4 blocks total is not enough to saturate the GPU. We need many blocks.
So: feature slice size = 16, we need 4 slices to cover 64 features. That's only 4 blocks total. We need many more blocks.
Alternative:
- Each block handles a subset of edges (e.g., 256 or 512 edges) and all 64 features
- Each thread handles one feature dimension for all edges in the block
Block size: 256 threads, each thread handles 1 feature. Threads within a warp handle 32 features. Wait, 64 features needs 64 threads (or 128 or 256).
Actually: if we have 64 features, we could have 256 threads = 4 warps. Each warp handles 16 features. Total shared memory needed: only a small staging buffer, not the full output.
Wait, but we need to avoid atomics. If each block processes a subset of edges, it still needs to write to the global output with atomics because different blocks might process edges targeting the same node.
What if:
- Launch a block per destination node? No, only 768 nodes, and each block needs to process all edges to find matches - that's too much work per block.
Better:
- Phase 1: Sort edges by dst_idx (or use a histogram/counting approach)
- Phase 2: Use the sorted order to do segmented reduction
Let's think about sort + segmented reduction on GPU. With 16384 edges, a radix sort is very fast. Then we can do a parallel segmented reduction.
Actually, there's an even simpler approach: use shared memory for the output. Since we have 768 nodes and 64 features, we can process in multiple passes over the edges:
Pass 1: Process edges with feature range [0, 15], accumulate in shared memory (768 * 16 floats = 49KB)
Pass 2: Process edges with feature range [16, 31], etc.
But we need multiple thread blocks to saturate the GPU. If each block processes all edges but different feature slices, with 4 slices we only get 4 blocks.
What if each block processes a subset of edges AND a subset of features?
- Divide edges into 64 blocks (16384 / 256 = 64 blocks)
- Divide features into 4 slices (16 each)
- Total blocks: 64 * 4 = 256 blocks
- Each block: 256 threads, processes 256 edges and 16 features
Each block needs shared memory for the sub-output: 768 * 16 * 4 = 49KB.
With 228KB per SM, we can fit ~4 blocks per SM concurrently.
With 132 SMs, total concurrent blocks = 528.
We have 256 blocks, so some SMs get 2 blocks. That's not enough to fully saturate but it's decent.
Wait, 256 blocks / 132 SMs ≈ 2 blocks per SM. With 4 blocks concurrent per SM, occupancy is 50% for those SMs. Hmm.
Let's instead make smaller edge tiles. 16384 edges / 128 edges per block = 128 edge tiles.
128 tiles * 4 feature slices = 512 blocks. That's better: ~4 blocks per SM.
Or: 64 edges per block -> 256 edge tiles * 4 = 1024 blocks. ~8 blocks per SM. Great!
But wait, for 64 edges and 16 features, each block has 64 * 16 = 1024 float operations. With 256 threads, each thread does 4 loads and 4 atomic adds (to shared memory). That's very little work.
Maybe we should increase feature slice size. What if feature slice = 64, so no feature tiling?
- 256 edge tiles of 64 edges each
- 256 blocks total
- Each block needs 768 * 64 * 4 = 196KB shared memory
- 196KB < 227KB, so it fits, but only 1 block per SM
- 256 blocks / 132 SMs ≈ 2 blocks per SM sequentially. Occupancy is low.
Actually, let's use a two-level approach:
1. Each block has a small shared memory buffer for its tiles
2. Within the block, for each edge, we accumulate to shared memory indexed by dst
3. At the end, write shared memory to global memory with atomics (or directly if we ensure no overlap)
Wait, if each block processes a distinct set of edges and we accumulate in shared memory per-block, we still need to merge the partial results. There are two ways:
- Use atomicAdd from shared memory to global memory at the end
- Or use cooperative groups / second kernel to merge
AtomicAdd to global memory from shared memory at the end: each block does up to 768 atomicAdds per feature. With 256 blocks, that's 196K atomicAdds. With 64 features, 12.5M atomicAdds total. That's manageable if they're spread out.
Actually, a better approach: shared memory is indexed by `(local_dst)`, where we only need space for nodes that appear in the current edge tile. We can use a hash table or just a dense array of 768 nodes.
For 768 nodes * 64 features * 4 bytes = 196KB per block. This is too much for multiple concurrent blocks.
What about: process feature slices sequentially within the block?
- Shared memory: 768 * 4 floats = 12KB (for 4 features at a time, 768 nodes)
- Each block processes all its edges in passes of 4 features
- Total shared memory: 12KB, allowing many concurrent blocks
Let's work this out:
- Edge tile size: 256 edges
- Feature pass size: 4 features (768 * 4 * 4 = 12KB shared memory)
- Number of edge tiles: 16384 / 256 = 64
- Block config: 256 threads
- For each feature pass (64 / 4 = 16 passes):
- Load 4 features for the 256 edges
- Accumulate to shared memory (indexed by dst_idx)
- Atomic add from shared to global for the 4 features
Each thread handles 1 edge. For feature pass, we can have each thread load its edge's 4 features and do 4 atomicAdds to shared memory. But then the shared memory atomic is still needed within the block.
Wait, since the edge tile is 256 edges and there are 768 nodes, many threads may have the same dst. So shared memory atomics will have contention.
Alternative within a block:
- Have each warp process edges and sort/dedup within the warp
- Or use registers: each thread stores its dst and features, then do warp-aggregated atomics
Actually, there's a well-known optimization called "warp-aggregated atomics". In this approach:
1. Threads in a warp that target the same destination node elect a leader
2. The leader accumulates values from all matching threads using shfl
3. The leader performs a single atomicAdd
For this, we need to group threads by destination within a warp. With 32 threads in a warp and random dst_idx among 768 nodes, the probability of collision is low (32/768 ≈ 4%). So warp aggregation may not help much.
What if we sort edges by dst_idx before processing?
With sorted edges, each warp is likely to have the same or nearby dst_idx, and consecutive warps will also be coherent. Then:
- Within a warp, threads with the same dst perform warp reduction
- A single atomic per dst per warp
- With 21 edges per node on average, each node spans less than one warp. So most warps will have all different dst_idx anyway unless many edges target the same node.
Let me reconsider the memory bandwidth. The theoretical optimum:
- Read edge_feat: 4MB
- Read dst_idx: 64KB
- Write output: 196KB
Total: ~4.26MB
At 3.35 TB/s = 3.35e12 bytes/s
Time = 4.26e6 / 3.35e12 = 1.27 microseconds
Any realistic kernel needs ~5-10 microseconds. PyTorch's index_add_ probably takes 10-30 microseconds.
To maximize bandwidth utilization:
- Coalesced loads of edge_feat (easy if threads consecutive along edge dimension)
- Coalesced stores to output
The main issue with index_add_ is atomic contention and non-coalesced stores.
Another idea: transposed output accumulation
- Process edges in groups where each group targets a different node
- But this requires sorting
Actually, let me think about a simpler kernel that might already beat PyTorch:
```cuda
__global__ void scatter_add_kernel(
const int* dst_idx,
const float* edge_feat,
float* out,
int num_edges,
int num_nodes,
int feat_dim
) {
int edge = blockIdx.x * blockDim.x + threadIdx.x;
int feat = blockIdx.y * blockDim.y + threadIdx.y;
if (edge < num_edges && feat < feat_dim) {
int dst = dst_idx[edge];
float val = edge_feat[edge * feat_dim + feat];
atomicAdd(&out[dst * feat_dim + feat], val);
}
}
```
Grid: (num_edges/32, feat_dim/32) = (512, 2) blocks of (32, 32) threads.
Wait, 16384 edges / 32 = 512. 64 features / 32 = 2. Total blocks = 1024.
Each thread does 1 atomicAdd.
But edge_feat access: thread (x,y) reads edge x, feature y. If x is the fast dimension, we have edge 0,1,2,... in the same warp along x. But edge_feat is stored as [edge, feat]. So threads in a warp read consecutive edges for the same feature, meaning they access edge_feat[0*64+feat], edge_feat[1*64+feat], ... which is strided by 64*4=256 bytes. That's not coalesced! It would be 256 byte stride.
Wait, if edge_feat is row-major [num_edges, feat_dim], then edge i's features are at i*feat_dim + j.
If threadIdx.x varies along edge and threadIdx.y along feat:
- Thread (0,0): address 0*64+0 = 0
- Thread (1,0): address 1*64+0 = 64
- Thread (2,0): address 2*64+0 = 128
Each thread in the warp reads 256 bytes apart (since 32 threads * 64 floats * 4 bytes would be 8192 bytes, but the stride between consecutive threads is 64*4 = 256 bytes). Actually in a warp, threads 0-31 read addresses 0, 64, 128, ..., 2016. These are 256 bytes apart. 256 bytes / 128 bytes per L2 cache line = 2 cache lines apart. Not fully coalesced but maybe okay on H100? Actually, each load would be 4 bytes with 256-byte stride, causing 32 separate transactions per warp - terrible.
So we should make the fast axis along feat_dim instead:
- Let blockIdx.x be feat tiles, blockIdx.y be edge tiles
- threadIdx.x along feat (up to 32), threadIdx.y along edge (up to 32)
Then:
- Warp has threadIdx.x = 0..31, threadIdx.y = const
- Reads edge_feat[edge*64 + feat0], [edge*64 + feat1], ... which is consecutive! Fully coalesced.
But then the atomicAdd target: all threads in the warp have different features but the same edge (if threadIdx.y is const and corresponds to edge). Actually, with threadIdx.y constant in warp, each thread in warp handles a different feature of the same edge. So they all have the same dst but atomic to different output locations (different features of the same node). These are consecutive in memory! 4 bytes apart. So atomics would have 32 separate atomic operations targeting consecutive addresses. On modern GPUs, atomics to consecutive addresses have some optimizations but still need serialization or at least address resolution.
Actually, if all threads in the warp target the same node but different features, they can write without atomics if we know each edge is only handled by one thread. But multiple edges target the same node across different warps.
Wait, the original operation requires reduction. Each edge contributes to its dst. If each edge is processed by exactly one set of threads, and each thread handles exactly one (edge, feat) pair, then we just need atomic adds to the output. But if the same dst is targeted by multiple edges, their contributions must be summed.
Back to coalescing: we definitely want threadIdx.x along feat.
```cuda
dim3 block(32, 32);
dim3 grid((feat_dim + 31) / 32, (num_edges + 31) / 32);
```
Wait, CUDA grid dimensions: if we want x-axis to be feat and y-axis to be edge, we'd do:
- blockDim.x = 32 (feat), blockDim.y = 32 (edge)
- gridDim.x = ceil(64/32) = 2, gridDim.y = ceil(16384/32) = 512
- Inside kernel: feat = blockIdx.x * 32 + threadIdx.x, edge = blockIdx.y * 32 + threadIdx.y
But usually it's better to have more blocks in x for occupancy. Let's swap:
- blockDim.x = 32 (edge), blockDim.y = 8 (feat) or something. Actually for coalescing, we want the x-axis to access consecutive memory.
In CUDA, threads in a warp have consecutive threadIdx.x with threadIdx.y = 0. So if we want a warp to access consecutive features, threadIdx.x should be the feature index.
Let's do:
- blockDim.x = 64 (feat dimension, or 32 if we tile)
- blockDim.y = 16 (edge dimension)
But block size max is 1024. 64 * 16 = 1024. Good.
Actually, with feat_dim = 64, we can just have blockDim.x = 64. Then the warp naturally reads consecutive features for the same edge. Wait, warp is 32 threads. So threads 0-31 in a warp read feat 0-31 for a given edge. Threads 32-63 read feat 32-63. That's two warps per edge.
But if blockDim.x = 64 and blockDim.y = 16, then a warp covers 32 consecutive x-values. So the mapping:
threadIdx.x in [0, 63] -> feat
threadIdx.y in [0, 15] -> edge_offset within block
Warp 0: threadIdx.x in [0, 31], threadIdx.y = 0 -> feat 0..31, edge 0
Warp 1: threadIdx.x in [32, 63], threadIdx.y = 0 -> feat 32..63, edge 0
Warp 2: threadIdx.x in [0, 31], threadIdx.y = 1 -> feat 0..31, edge 1
So warp 0 reads edge 0, features 0-31. These are consecutive in memory for edge 0. Coalesced!
But the problem: we have 16 edges per block. Total warps = (64 * 16) / 32 = 32 warps.
Grid: we need to cover 16384 edges. 16384 / 16 = 1024 blocks in y.
So grid is (1, 1024) or (1024, 1). 1024 blocks total.
1024 blocks on H100 with 132 SMs is ~8 blocks per SM. That's decent.
But each thread does an atomicAdd to `out[dst * 64 + feat]`. With 16 edges per block and each edge potentially different dst, and 64 features per edge, we get 16 * 64 = 1024 atomicAdds per block. Most of which may target different nodes or different features of the same node.
Actually, for a given edge, 64 threads (2 warps) write to 64 consecutive output locations for the same node. These are fully coalesced. But they're atomics, so even though coalesced, they still need to ensure atomicity. Modern GPUs can handle atomicAdd to consecutive float addresses efficiently using vectorized atomic instructions or at least coalescing them to some extent.
Wait, we can't simply write without atomics because multiple blocks might process edges targeting the same node. But what if we sort edges and assign each node to specific blocks? No, that complicates things.
Let me think about another approach: because num_nodes (768) is relatively small, we can actually replicate the computation.
If we detect that some nodes have very few edges and some have many, we might process them differently.
Let's calculate: uniform random dst_idx over 768 nodes with 16384 edges. The distribution is roughly Binomial(16384, 1/768) with mean 21.3 and variance ~21.3. So most nodes have 15-30 edges.
With ~21 edges per node, contention at any output location is from ~21 different edges. Since these 21 edges could be processed by different blocks/warps, we need atomics.
One key optimization: use `__shared__` memory per block for edge accumulation. But the shared memory needs to be per-block and sized for the block's edges. If a block processes edges that target only a subset of nodes, or better, if we sort edges by node and each block processes a range of edges that might have duplicates...
Actually, the best approach for this kind of problem when num_nodes is small enough is:
1. Zero the output
2. Process edges in a straightforward atomic kernel but optimize the memory access patterns
Wait, I'm overcomplicating this. For small graphs like this, PyTorch's index_add_ uses a thrust::scatter approach or a custom CUDA kernel. Let me consider what it might do.
PyTorch's `index_add_` source code likely:
- If the index is sorted and no duplicates, it does a direct scatter
- Otherwise, it uses atomics or a sort-based approach
Since dst_idx is random and has duplicates, it probably uses a kernel with atomicAdd.
To beat it, we can:
1. Improve coalescing by transposing the launch configuration
2. Maybe use vectorized loads (float4) for edge_feat
3. Or use a sort-based approach to avoid atomics entirely
Let me think about sort-based:
1. Sort dst_idx, get permutation
2. Apply permutation to edge_feat
3. Find segment boundaries (unique nodes and their start/end positions)
4. For each node, reduce its segment of edge features
Step 1: Sorting 16384 int32 values on GPU. This is extremely fast (sub-microsecond with CUB/thrust).
Step 2: Gather edge_feat according to permutation. 16384 * 64 floats = 4MB. Fast.
Step 3: Find unique + boundaries. With 768 unique out of 16384, we can do a reduction or use CUB's unique_count.
Step 4: Segmented reduction on 16384 * 64 elements. CUB has very efficient segmented reduction.
But implementing all this with CUB in inline CUDA... it's possible but might be complex.
Alternatively, we can:
1. Create an intermediate array `sorted_edge_feat` of shape [num_edges, feat_dim]
2. Sort `dst_idx` together with `edge_feat` by key = dst_idx
3. Then for each node, find where its edges are in the sorted array
4. Sum them up
Actually, a much simpler approach: we can convert to a dense accumulation using a custom kernel that doesn't require full sorting.
Since num_nodes = 768 is small, what if we launch one thread per (node, feature) and have each thread iterate through ALL edges looking for matches?
```cuda
__global__ void scatter_add_naive(
const int* dst_idx,
const float* edge_feat,
float* out,
int num_edges,
int feat_dim
) {
int node = blockIdx.x;
int feat = threadIdx.x;
if (node >= num_nodes || feat >= feat_dim) return;
float sum = 0.0f;
for (int e = 0; e < num_edges; e++) {
if (dst_idx[e] == node) {
sum += edge_feat[e * feat_dim + feat];
}
}
out[node * feat_dim + feat] = sum;
}
```
Launch: 768 blocks, 64 threads each. Total threads = 49152.
But each thread reads ALL edges (16384)!
Memory reads per thread: 16384 edges * 4 bytes (dst_idx) + 16384 * 4 bytes (edge_feat, only when match) + branching overhead.
Actually, 16384 int comparisons per thread is a lot. 768 * 64 * 16384 = 805M comparisons. At ~2GHz with some IPC, this might take ~1-2ms. Way too slow.
What about tiling: each thread handles a tile of edges?
Actually, there's a classic optimization: "shared memory histogram" but for floats.
I think the most promising approach is:
1. Sort edges by dst_idx using a bitonic sort or CUB sort
2. For each segment (i.e., edges targeting the same node), sum them up with parallel reduction
But implementing CUB sort with `load_inline` is tricky because we need to compile with CUB headers... Actually CUB is included in CUDA toolkit and is accessible. We can use `cub::DeviceRadixSort::SortPairs` and `cub::DeviceSegmentedReduce::Sum` if we can allocate temporary memory.
Wait, `load_inline` allows us to write CUDA C++ code. CUB should be available.
Let me think about the steps more concretely:
```cpp
// Step 1: Sort dst_idx and get permutation
cub::DeviceRadixSort::SortPairs(d_temp_storage, temp_storage_bytes,
dst_idx_in, dst_idx_sorted,
indices_in, indices_sorted, num_edges);
// Step 2: Gather edge_feat
// We need to permute edge_feat according to indices_sorted
// Can do this with a simple kernel
// Step 3: Find segment boundaries
// Count unique dst_idx values and their positions
// Could use cub::DeviceRunLengthEncode or just count segments on the fly
// Step 4: Segmented reduction
// We have sorted_edge_feat of shape [num_edges, feat_dim]
// For each feature dimension, do a segmented sum over nodes
```
Hmm, the segmented reduction over 64 features is essentially 64 independent segmented reductions. CUB's segmented reduce takes offsets array: `d_offsets` of length `num_segments + 1`.
To get offsets, we can do:
- `cub::DeviceRunLengthEncode::Encode` on sorted_dst_idx to get unique_keys and counts
- Then prefix sum on counts to get offsets
This is getting complex but might be extremely fast.
Actually, we might not even need full segmented reduction. Since each node has only ~21 edges on average, and we have sorted the edges, we can simply have each thread handle one node and sum its ~21 edges by iterating over them.
Wait, that's brilliant!
1. Sort dst_idx and corresponding edge_feat by dst_idx
2. Compute unique nodes and the start/end indices of their edges (using `cub::DeviceRunLengthEncode` for unique + run lengths, then prefix sum)
3. Launch a kernel with one block per node (or multiple nodes per block, or one thread per (node, feat))
4. Each thread/thread block sums the edge features for its node
Step 3 details:
- If we have 768 nodes and 64 features
- Launch 768 blocks × 64 threads = 49152 threads
- Or 768 blocks with 256 threads each, where each thread handles multiple features: 64/256 = not integer.
- Better: 768 blocks, each with 64 threads (1 thread per feature), or 128 threads (each handling 1 feature, half the threads idle? No, 64 features so 64 threads).
Wait, each block handles one node. Block has 64 threads, each thread handles one feature. For feature f, thread f iterates over the edges for this node (e.g., 21 edges) and sums `sorted_edge_feat[edge_idx, f]`.
But this has poor coalescing: thread 0 reads feature 0 for edge 0, thread 1 reads feature 1 for edge 0. These are 4 bytes apart, but consecutive threads read consecutive features for the SAME edge. If edge_feat is stored as [edge, feat], then consecutive features for the same edge ARE consecutive in memory! So warp reads 128 bytes (32 floats) in a single transaction. Coalesced!
Wait, for 64 threads, warp 0 reads feat 0-31 for edge 0, warp 1 reads feat 32-63 for edge 0. Both are coalesced.
Then they move to edge 1: feat 0-31 for edge 1. Still coalesced.
This is excellent! And there are no atomics because each node is processed by exactly one block. The edges for each node are contiguous because we sorted.
How many memory transactions per node?
- If node has 21 edges, each edge requires 64 floats = 256 bytes.
- Total reads per node: 21 * 256 = 5376 bytes.
- Memory reads for all nodes: 16384 edges * 64 features * 4 bytes = 4MB. Perfect.
- Plus sorting overhead and temporary arrays.
But each thread only does about 20 FLOPs (21 additions) and 21 loads. Memory latency might dominate if not enough warps.
With 768 blocks × 2 warps (64 threads) = 1536 warps total. On H100 with 132 SMs × 64 warps per SM = 8448 warps capacity. So we're at ~18% occupancy in terms of warps. But each warp does very little work - only about 21 iterations. A warp handles 32 features (or 64 features with 2 warps) for one edge at a time. Wait, with 64 threads per block:
- Warp 0 handles features 0-31
- Warp 1 handles features 32-63
For each edge, both warps are active. So per block, for each edge, 2 warps execute. A block processes ~21 edges, so 42 warp-iterations per block.
Because each warp does so few iterations, the kernel is heavily latency-bound. We need many concurrent warps to hide memory latency.
768 blocks / 132 SMs ≈ 6 blocks per SM. Each SM gets 6 blocks, each with 2 warps = 12 warps per SM. H100 can hold 64 warps per SM. So occupancy is 12/64 = 18.75%. That's somewhat low but might be okay since each warp does 21 memory loads with an add.
Actually, memory throughput might be limited. With 6 blocks per SM and 2 warps per block, total active warps per SM = 12. H100 needs ~20+ warps to saturate memory bandwidth. But we have plenty of blocks that could run if we increase block size.
Alternative block design:
- Block size = 256 threads
- Each block processes 1 node
- 256 threads for 64 features? We can assign 4 threads per feature.
- 4 threads per feature, each thread processes a quarter of the edges.
- At the end, warp shuffle or shared memory reduce within the 4 threads.
This increases warps per block to 8. With 768 blocks / 132 SMs ≈ 6 blocks per SM, that's 48 warps per SM. Much better for latency hiding!
And the reduction at the end is cheap (4 values with shuffle).
Let's work this out:
- Block size: 256 threads
- Threads organized as: 64 groups of 4 threads each. Group g handles feature g.
- Within group, thread t handles edges with offset t, stepping by 4.
- Wait, but the edges for a node are in a contiguous range. If we assign 4 threads per feature:
- Thread 0 of feature f: edges start, start+4, start+8, ...
- Thread 1 of feature f: edges start+1, start+5, ...
- These are coalesced? Thread 0 reads [edge_start, f], thread 1 reads [edge_start+1, f], etc.
- For a fixed f, thread t reads [edge_start + t, f], [edge_start + t + 4, f], etc.
- In the first iteration: threads 0,1,2,3 read [start,f], [start+1,f], [start+2,f], [start+3,f]. But these are 64*4 = 256 bytes apart! Not coalesced at all.
Actually, coalescing requires consecutive threads to access consecutive memory. Let's reconsider.
Memory layout after sorting: [num_edges, feat_dim]. If consecutive threads access the same feature for consecutive edges, they access `sorted_edge_feat[edge*feat_dim + feat]`, and since `edge` differs by 1, addresses differ by `feat_dim * sizeof(float) = 256` bytes.
On modern GPUs, the memory transaction size is 32 bytes minimum. 256 bytes apart means 8 cache lines apart. This is not coalesced - each thread will generate a separate memory transaction.
If instead consecutive threads access consecutive features for the same edge:
Thread f reads `sorted_edge_feat[edge*feat_dim + f]`. With f=0..31, addresses are consecutive. This IS coalesced into a single 128-byte transaction.
So our original plan was right: 1 thread per feature, each thread iterates over all edges for the node.
But then only 64 threads per block. What if we process multiple nodes per block?
Block processes 4 nodes:
- 64 threads per node, but we can interleave them.
- Actually, 256 threads, each warp handles features for one node.
- 256 threads = 4 nodes * 64 features.
- Warp 2i and 2i+1 handle node i (64 features = 2 warps).
This gives 8 warps per block.
Number of blocks = 768 / 4 = 192 blocks.
192 blocks / 132 SMs ≈ 1.5 blocks per SM. That's terrible!
What if we process 1 node per block but use 256 threads creatively?
- 64 threads actually do the loads (1 thread per feature)
- The other 192 threads... do what? If there are enough edges, they could also participate.
Actually, for a node with ~21 edges, the total work per node is 21*64 = 1344 float ops. With 256 threads, many would be idle. But even with 64 threads, each thread only does 21 ops.
What if each thread handles multiple features?
- 256 threads per block, each thread handles 1 feature but 4 threads share the same feature and partition the edges?
No, that ruins coalescing.
Actually, we can use a grid-stride loop model:
- Block handles 1 node
- BlockDim.x = 256
- Each thread loads a subset of edges' features in strided fashion, then reduce
- But coalescing is poor if strided along edge dimension.
Wait, maybe coalescing along the edge dimension is not so bad if we use vector loads and cache well. Or maybe we should transpose the sorted edge features!
If we transpose `sorted_edge_feat` to [feat_dim, num_edges], then consecutive threads reading consecutive edges for the same feature would be coalesced! Transposing 4MB is very fast.
But it adds an extra step.
Actually, let's just use 128 threads per block (for 64 features, 2 threads per feature alternating edges):
- Thread (f, 0): edges start, start+2, start+4, ...
- Thread (f, 1): edges start+1, start+3, ...
- First iteration: threads f=0..63, sub=0 read [edge_start, f]. Threads f=0..63, sub=1 read [edge_start+1, f].
- In a warp: thread 0 (f=0,sub=0) reads [start, 0], thread 1 (f=1,sub=0) reads [start, 1], ..., thread 31 (f=31,sub=0) reads [start, 31]. This is coalesced!
- Next warp: thread 32 (f=32,sub=0) reads [start, 32], ..., thread 63 (f=63,sub=0) reads [start, 63]. Coalesced!
- Then warp 0 of sub=1 reads [start+1, 0]..[start+1, 31]. Coalesced!
So with 128 threads per block:
- 64 features * 2 sub-threads = 128 threads
- 4 warps per block
- Block handles 1 node
- 768 blocks
- Total warps = 768 * 4 = 3072
- Concurrent warps per SM = 3072 / 132 ≈ 23 warps
This is getting into the "good enough" territory for memory latency hiding. 23 warps per SM can probably hide a decent amount of latency.
But wait, each warp only iterates ~10 times (21 edges / 2 sub-threads = ~10.5 iterations). So actually only ~10 loads per thread. That's not many instructions to hide latency across.
What if block handles 2 nodes?
- 128 threads for node 0, 128 threads for node 1
- But now we have 256 threads per block = 8 warps
- 384 blocks total
- Warps per SM = 384 * 8 / 132 ≈ 23 warps per SM still
Same concurrent warps, fewer blocks. Not clearly better.
What if block handles 8 nodes with 32 threads each (1 warp per node, handling 32 features)?
- But we need 64 features per node, so 2 warps per node.
- 8 nodes * 2 warps = 16 warps per block
- 768 / 8 = 96 blocks
- Warps per SM = 96 * 16 / 132 ≈ 11.6 warps per SM
Worse.
Perhaps the best is to let each block handle multiple nodes with a moderate number of threads. Or even better, make the kernel a grid-stride loop where each thread processes multiple nodes.
Actually, instead of 1 block per node, what about:
- Total threads = 768 * 64 = 49152
- Use 512 blocks of 96 threads each... no, that's messy.
Let me reconsider the whole approach. Maybe sorting is overkill for this small scale and a simple shared memory atomic kernel is best.
Shared memory approach:
- Since num_nodes = 768, we can have a 2D array in shared memory per block.
- If each block processes an edge tile of 512 edges and feature slice of 16:
- Shared memory: 768 nodes * 16 features * 4 bytes = 49KB
- Block size: 256 threads
- Each thread processes 2 edges * (some features)
- Wait, 512 edges * 16 features = 8192 values. With 256 threads, each thread handles 32 values.
Actually, let's match the workload:
- 256 threads
- Each thread handles 2 edges, and for each edge, handles some features.
- Better: each warp handles 32 edges, each thread in warp handles 1 edge and 16 features (but sequentially or with vectorization?)
Alternatively:
- 256 threads
- tile_edges = 256 (handled by threadIdx.y conceptually, but let's use linear indexing)
- tile_feat = 16 (handled by threadIdx.x = 16, but 256/16 = 16)
Let's organize as:
- blockDim.x = 16 (feature slice)
- blockDim.y = 16 (edge slice)
- Total = 256 threads
- Grid: (64/16=4, 16384/256=64) = (4, 64) blocks = 256 blocks
Wait, that's only 256 blocks. Need more.
- blockDim.x = 16 (features)
- blockDim.y = 16 (edges within tile)
- tile_edges along grid y: 16384 / 16 = 1024 tiles
- tile_feat along grid x: 64 / 16 = 4 tiles
- Grid = (4, 1024) = 4096 blocks
- Each block: 256 threads, 49KB shared memory
4096 blocks / 132 SMs ≈ 31 blocks per SM. But shared memory limits us to 4 blocks per SM (228KB / 49KB ≈ 4). So 4 * 132 = 528 blocks concurrently. We have 4096 blocks, so each SM processes about 31 blocks sequentially. This is great!
Let's verify:
- Shared memory per block: 768 * 16 * 4 = 49152 bytes = 48KB. Actually 768 * 16 * 4 = 49152. Plus maybe a small workspace, call it 50KB.
- 228KB / 50KB = 4.56, so 4 blocks per SM concurrently.
- Total concurrent blocks = 4 * 132 = 528.
- Total blocks = 4096. Each block has 256 threads = 8 warps.
- Total concurrent warps = 528 * 8 = 4224.
- Max warps on H100 = 132 SMs * 64 warps/SM = 8448.
- Occupancy = 4224 / 8448 = 50%. Very good!
Now let's detail the kernel:
```cpp
__global__ void scatter_add_shared(
const int* dst_idx,
const float* edge_feat,
float* out,
int num_edges,
int num_nodes,
int feat_dim
) {
const int feat_tile = blockIdx.x * blockDim.x + threadIdx.x; // 0..63
const int edge_tile_base = blockIdx.y * blockDim.y; // 0, 16, 32, ...
const int edge_offset = threadIdx.y;
const int edge = edge_tile_base + edge_offset;
extern __shared__ float smem[]; // size: num_nodes * blockDim.x
// smem[node * blockDim.x + threadIdx.x]
// Initialize shared memory for nodes touched by this block?
// Or zero all num_nodes * blockDim.x?
// Zero shared memory - not all nodes might be touched, but easier to zero all
// We have 768 * 16 = 12288 floats. With 256 threads, each thread zeros 48 floats.
// This is 48 stores per thread - very fast.
for (int i = threadIdx.y * blockDim.x + threadIdx.x; i < num_nodes * blockDim.x; i += blockDim.x * blockDim.y) {
smem[i] = 0.0f;
}
__syncthreads();
// Accumulate edges into shared memory
if (edge < num_edges && feat_tile < feat_dim) {
int dst = dst_idx[edge];
float val = edge_feat[edge * feat_dim + feat_tile];
atomicAdd(&smem[dst * blockDim.x + threadIdx.x], val);
}
__syncthreads();
// Write shared memory to global output
// Each thread handles some nodes
for (int i = threadIdx.y * blockDim.x + threadIdx.x; i < num_nodes * blockDim.x; i += blockDim.x * blockDim.y) {
int node = i / blockDim.x;
int feat = blockIdx.x * blockDim.x + (i % blockDim.x);
if (feat < feat_dim) {
float val = smem[i];
if (val != 0.0f) { // optional optimization
atomicAdd(&out[node * feat_dim + feat], val);
}
}
}
}
```
Wait, the shared memory indexing: `smem[dst * blockDim.x + threadIdx.x]` assumes `blockDim.x` is the tile size (16). But `threadIdx.x` ranges from 0 to 15. So for dst=0, we use smem[0..15], for dst=1, smem[16..31], etc. The feature is determined by `blockIdx.x * 16 + threadIdx.x`. So yes, for each node, the shared memory holds the partial sum for that specific feature.
At the end, we atomically add from shared memory to global. Since different blocks may process edges for the same (node, feat) pair, we still need atomicAdd to global memory!
Actually, the block processes a feature slice (16 features) and an edge tile (256 edges? Wait, I said edge_tile size = blockDim.y = 16. Let me recalculate).
Wait in my kernel above, I set blockDim.y = 16. So each block handles 16 edges and 16 features.
Grid: (4, 1024) where x is feature tiles (4 tiles of 16) and y is edge tiles (1024 tiles of 16).
Total blocks = 4096.
But each block only processes 16 edges * 16 features = 256 work items. With 256 threads, each thread does 1 load and 1 atomicAdd (to shared). That's very light. The overhead of zeroing shared memory (12288 floats / 256 threads = 48 stores per thread) and writing back (48 loads + atomics) is comparable to the actual work.
And there's still atomicAdd to global at the end. Though now the smem->global atomic is coalesced (consecutive threads write to consecutive output addresses for different nodes), and since each block handles only a few edges, many smem entries will be zero, but the zero check helps a bit.
Is this faster than PyTorch? PyTorch likely does a very similar thing with a larger tile size. PyTorch might use 256 or 512 edges per thread block with all features in a strided pattern.
Actually, let me reconsider: can we avoid the final atomicAdd to global? Only if each (node, feat) pair is handled by exactly one block. That would require each block to have exclusive ownership of its output elements.
For that, we need to partition the edges such that all edges targeting node N, feature F go to the same block. But with random dst_idx, this means each block handles all edges - not parallelizable.
Alternatively, we can have a second phase that merges partial results. But the merge itself takes time.
Actually, I just realized: for this specific problem, we don't need to zero all shared memory! We only need shared memory for the nodes that actually appear in the edge tile. With 16 random edges out of 768 nodes, we expect about 15-16 unique nodes. So instead of a dense 768*16 shared memory, we could use a hash table or a sparse buffer. But implementing a hash table in CUDA is complicated.
Or: we can have each thread use private registers for accumulation! Here's a clever idea:
- Each warp handles one node (or a group of edges with the same node)
- But without sorting, we don't know which edges go together.
Hmm.
Let me step back and think about what PyTorch actually does and what's the simplest way to beat it.
PyTorch `index_add_` for CUDA:
Looking at the PyTorch source (from memory), it launches a kernel that is essentially:
```cpp
template <typename scalar_t>
__global__ void indexAddKernel(
scalar_t* out, int64_t* indices, scalar_t* source,
int64_t numIndex, int64_t sliceSize, int64_t stride) {
int64_t index = blockIdx.x * blockDim.x + threadIdx.x;
int64_t sourceOffset = index * sliceSize;
if (index < numIndex) {
int64_t dstOffset = indices[index] * sliceSize;
for (int64_t i = threadIdx.y; i < sliceSize; i += blockDim.y) {
atomicAdd(&out[dstOffset + i], source[sourceOffset + i]);
}
}
}
```
This launches with:
- blockDim.x = 32 or 64
- blockDim.y = 32 or 64 or 128
- gridDim.x = ceil(numIndex / blockDim.x)
Wait, this is different. In PyTorch's approach:
- Each block (or thread group) handles one or more edges
- For each edge, all features are processed
- The y-dimension handles different features for the same edge
Actually, the typical PyTorch index_add kernel:
- Grid: 1D or 2D
- Each thread handles a subset of features for a subset of edges
- It maps edge to output and uses atomicAdd
For a 2D configuration with blockDim = (32, 32):
- blockIdx.x corresponds to a group of edges
- threadIdx.x corresponds to a specific edge within the group
- threadIdx.y corresponds to a feature
- Or vice versa
Let me look at a plausible PyTorch implementation. For index_add with dim=0:
- The source is [numIndex, sliceSize] = [16384, 64]
- Output is [numOutput, sliceSize] = [768, 64]
- Kernel: block processes a tile of edges and all features
A common pattern:
```cpp
dim3 grid(ceil_div(numIndex, 32), 1);
dim3 block(32, 32);
```
where:
- blockIdx.x selects the edge tile
- threadIdx.x selects the edge within the tile
- threadIdx.y selects the feature
Wait, but then how do features get covered? With blockDim.y = 32, only 32 out of 64 features are covered. Maybe they launch 2 blocks in y? Or use a grid-stride loop.
Actually, for sliceSize = 64, they might use blockDim.y = 64. But blockDim.y max is 1024/blockDim.x. If blockDim.x = 16, blockDim.y = 64 => 1024 threads.
Or: blockDim.x = 128, blockDim.y = 8. Then x handles 128 edges, y handles 8 features with a grid-stride loop.
To beat PyTorch, I think the key is:
1. More coalesced memory access
2. Fewer atomic operations or better contention handling
What if we pre-sort the dst_idx but do a simple segmented sum without CUB? We can sort using a bitonic network (which is very fast for 16384 elements), then do a segmented reduction.
Wait, 16384 is exactly 2^14. Bitonic sort works great for power-of-2 sizes! We can implement an in-place bitonic sort for 16384 elements directly in the kernel. But we also need to permute the edge features, which complicates things.
Alternatively, we can use a counting-based approach:
- Since num_nodes = 768, we can count edges per node using shared memory atomics
- Then compute prefix sums to get offsets
- Then scatter edges into a buffer sorted by node
- Then sum each node's edges
This is essentially a radix sort / histogram scatter. But it might take multiple kernel launches.
What about using `torch.sort` in Python to sort the indices, then a much simpler custom kernel?
We can sort in Python:
```python
sorted_dst, perm = torch.sort(dst_idx.long())
sorted_edge_feat = edge_feat[perm]
```
Then in the custom kernel, we just need to sum segments. But we need to know segment boundaries. We can find the boundaries of each node in the sorted array.
To find boundaries: we can do something like:
- Create a `counts` array of size num_nodes
- A kernel to count unique runs
- Or use `torch.unique_consecutive(sorted_dst, return_counts=True)` in Python
Wait, we can use PyTorch operations in the forward pass before calling the custom kernel! The requirement is only that `ModelNew.forward` produces numerically equivalent output. We can use PyTorch operations to pre-process, then call our custom kernel.
The overhead of a few extra PyTorch ops might be small if they enable a much faster main kernel.
For example:
1. Sort dst_idx and edge_feat in Python with `torch.sort`
2. Find unique nodes and their counts with `torch.unique_consecutive(sorted_dst, return_counts=True)`
3. Compute prefix sum of counts to get offsets
4. Call a custom kernel that does segmented reduction on the sorted_edge_feat
Step 1: `torch.sort` on 16384 int32 elements. On H100, this is maybe 5-20 microseconds.
Step 2: `torch.unique_consecutive` on 16384 sorted elements. Also very fast.
Step 3: `torch.cumsum` on 768 elements. Negligible.
Step 4: Custom kernel for segmented reduction.
For the segmented reduction kernel:
- We have `sorted_edge_feat` of shape [16384, 64]
- `offsets` of shape [num_unique_nodes + 1] where offsets[i+1] - offsets[i] is the count
- `unique_nodes` of shape [num_unique_nodes] - the node indices
Wait, actually we don't need unique_nodes if we're writing directly to output. For output, we need to know which output row each segment belongs to. We can use `unique_nodes` for that.
```cpp
__global__ void segmented_reduce_kernel(
const float* sorted_edge_feat,
const int64_t* offsets,
const int64_t* unique_nodes,
float* out,
int64_t num_segments,
int64_t feat_dim
) {
int64_t segment = blockIdx.x;
int64_t feat = threadIdx.x;
if (segment >= num_segments || feat >= feat_dim) return;
int64_t start = offsets[segment];
int64_t end = offsets[segment + 1];
int64_t dst_node = unique_nodes[segment];
float sum = 0.0f;
for (int64_t e = start; e < end; e++) {
sum += sorted_edge_feat[e * feat_dim + feat];
}
out[dst_node * feat_dim + feat] = sum;
}
```
Launch: num_segments blocks (768), each with 64 threads. Or to get more warps, we could use 256 threads where each thread handles a subset of features and segments.
With 768 blocks * 64 threads = 49152 threads. But each block has only 2 warps. 768 blocks / 132 SMs = ~6 blocks per SM. 12 warps per SM. That's not great for latency hiding.
If we change to 128 threads per block handling 2 segments:
- Block i handles segments 2i and 2i+1
- 384 blocks
- Each block has 4 warps
- Warps per SM = 384 * 4 / 132 ≈ 11.6. Still not great.
What if we process features with grid-stride loops and have more threads per segment?
Actually, here's a better idea for the segmented reduction:
- Use a 2D grid: (num_segments, feat_tiles)
- BlockDim = 256
- Each block handles one segment and one tile of features
- With 64 features, 1 tile of 64 means 768 blocks total
- But we can have 768 * 2 = 1536 blocks if we use feature tiles of 32
Wait, I keep coming back to the same occupancy issue. Let me compute theoretical performance.
The segmented reduce kernel memory bandwidth:
- Read sorted_edge_feat: 4MB
- Write output: 196KB
Total: ~4.2MB
At 3.35 TB/s, we need at least ~1.3 microseconds for memory alone.
If each thread does sequential reads for its segment (21 reads), and we have 768 * 64 = 49152 threads, each reading ~21 floats = 84 bytes. Memory requests are somewhat random in the sense that consecutive threads might read from different segments.
Wait, in the 1-block-per-segment model:
- Block 0: threads 0..63 read edges start0..end0, feature f
- Block 1: threads 0..63 read edges start1..end1, feature f
- These blocks will run concurrently on different SMs.
- Within an SM, block 0,2,4,... might run. Each block accesses a contiguous chunk of edges.
Because the chunks are contiguous in sorted_edge_feat, and blocks access different chunks, the memory access pattern is actually quite cache-friendly. The L2 cache can hold several blocks' data.
Let's estimate: 768 blocks, each handling ~21 edges * 64 features * 4 bytes = ~5.4KB of reads. This is very small. Many blocks' data fits in L2.
Actually, the total working set is 4MB. H100 L2 is 50MB. So the entire sorted_edge_feat fits in L2! This means once data is in L2, all accesses are fast.
If we process with 768 blocks of 64 threads, each block doing ~21 loads per thread across 2 warps, each warp issues loads that are coalesced (since consecutive threads read consecutive features for the same edge). The 21 loads are sequential along the edge dimension for a fixed feature.
Wait, in the original layout where we DO NOT transpose:
`sorted_edge_feat[e * feat_dim + feat]` for fixed feat and sequential e.
This is strided by `feat_dim * 4 = 256` bytes between edges. Not coalesced in the traditional sense, but the GPU can still issue wide loads. On modern GPUs, a warp issuing 32 loads 256 bytes apart will be coalesced into 32 cache line requests of 32 bytes each (or 8 requests of 128 bytes each, depending on cache line size). H100 has 128-byte L2 cache lines. So 256-byte stride means 2 cache lines apart. A warp requesting 32 addresses spaced 256 bytes apart would generate 32 transactions if each is in a different cache line... Actually, addresses are 0, 256, 512, ..., 7936. With 128-byte lines, lines are [0,127], [128,255], [256,383], etc. So 0, 256, 512 are each at the start of a cache line. 32 such addresses require 32 cache lines. If the cache can handle multiple misses concurrently, and since the addresses are increasing, the memory controller can merge some requests. But still, this is 32 transactions for 128 bytes of data each = 4096 bytes transferred for 128 bytes useful. Terrible bandwidth utilization!
Oh no! This is a critical insight. If `sorted_edge_feat` is in [edges, features] layout and each thread reads the same feature for consecutive edges, the memory access is extremely strided. We need to transpose or change the access pattern.
To fix this, we can either:
1. Transpose sorted_edge_feat to [features, edges] layout
2. Have threads read consecutive features for the SAME edge (coalesced), then loop over edges
Option 2 was our original design: block handles 1 segment (node), threads 0..63 handle features 0..63 for the same edge.
- Thread f reads `sorted_edge_feat[e * 64 + f]`. These are consecutive for f=0..31 (1 warp), so coalesced into 128 bytes.
- Then next edge e+1. Still coalesced.
- Total warps per block = 2 (for 64 features).
The memory transaction count per edge per block = 2 (128-byte loads). Total per edge = 256 bytes transferred, all useful. Perfect bandwidth!
But we only have 2 warps per block with 768 blocks. 6 blocks per SM means 12 warps per SM. With H100's high memory bandwidth and L2 cache, 12 warps might or might not be enough. However, since each thread only does ~21 iterations, the kernel is very short. The overhead of launching 768 blocks is probably the bottleneck.
Wait, what if we combine segments? Each block handles multiple nodes. For example, block handles 8 nodes:
- Total edges per block = 8 * 21 = 168 edges
- Threads: 64 threads per node would require 512 threads. Let's say 512 threads.
- Total warps per block = 16
- Total blocks = 768 / 8 = 96
- 96 blocks is not enough. 96 / 132 SMs = 0.7 blocks per SM. Terrible.
What if we process nodes with a grid-stride loop in a 1D grid of many blocks?
```cpp
__global__ void segmented_reduce_kernel(...) {
int segment = blockIdx.x * blockDim.x + threadIdx.x;
if (segment >= num_segments) return;
float sum = 0;
for (int f = 0; f < feat_dim; f++) {
for (int e = offsets[segment]; e < offsets[segment+1]; e++) {
sum += sorted_edge_feat[e * feat_dim + f];
}
out[unique_nodes[segment] * feat_dim + f] = sum;
}
}
```
No, this processes one node per thread, which would require 768 threads. Even worse.
Let me think differently. What if we don't sort, but instead process output-centric with a gather?
Output-centric means each thread computes one output element (node, feature).
```cpp
__global__ void gather_reduce(
const int* dst_idx,
const float* edge_feat,
float* out,
int num_edges,
int feat_dim
) {
int node = blockIdx.x;
int feat = threadIdx.x;
float sum = 0;
for (int e = threadIdx.y; e < num_edges; e += blockDim.y) {
if (dst_idx[e] == node) {
sum += edge_feat[e * feat_dim + feat];
}
}
// reduce within block
// ...
}
```
This is bad because it reads all edges.
What if we use a CSR-like representation? We can build a CSR in Python:
1. Count edges per node
2. Compute offsets
3. Scatter edges into buckets
But step 3 requires a custom kernel or `torch.scatter`.
Alternatively, we can use PyTorch's built-in `torch.bincount` or `scatter_add` itself to build a sparse representation. That seems circular.
Back to sorting. Sorting in Python:
```python
sorted_dst, perm = torch.sort(dst_idx)
sorted_feat = edge_feat[perm]
```
Then we just need to sum segments. To sum segments efficiently, we can actually use `torch.scatter_add` or a simple custom kernel.
What if we reduce the sorted features using a custom kernel where we tile both nodes and features, and use shared memory for inter-thread communication?
Let me reconsider the memory layout of sorted_edge_feat. If we transpose it to [feat_dim, num_edges], then consecutive threads reading consecutive edges for the same feature ARE coalesced. We can transpose with a simple kernel. Transposing 4MB is very fast.
But actually, do we need to transpose? What if the custom kernel reads float4 vectors?
Each edge has 64 floats. A thread can read a float4 (4 floats) at a time. If 16 threads read 16 float4s = 64 floats for the same edge, that's 4 consecutive float4s per thread? No, for coalescing, we'd want:
- Thread 0 reads floats 0-3
- Thread 1 reads floats 4-7
- ...
- Thread 15 reads floats 60-63
Each float4 is 16 bytes. Thread 0 address = edge*256 + 0, thread 1 = edge*256 + 16, etc.
In a warp, 16 threads (half warp) read addresses 0, 16, 32, ..., 240. These are perfectly consecutive 16-byte chunks! The entire 256-byte edge row is read in 2 transactions (128-byte lines) or 4 transactions (32-byte minimum). This is very efficient.
So:
- BlockDim.x = 16 (each handles a float4 = 4 features)
- BlockDim.y = something (different edges within a segment)
- Or better, blockDim = 256 = 16 * 16. 16 threads handle the 64 features via float4 (16 * 4 = 64), and 16 threads handle different edges.
Wait, that's the key! Process a NODE's edges with a 2D block:
- x-dim = 16 threads, each reads a float4 covering 4 features
- y-dim = 16 threads, each handles a different edge (or subset of edges)
For a node with ~21 edges:
- 16 threads in y handle edges 0..15 for this node
- The remaining 5 edges would need another iteration or another block
But a block per node with 16*16=256 threads where only ~21*16=336 threads are active... wait, each thread does some work. Let me think.
Actually, if we have 16 threads in x (float4 features) and 16 threads in y (edges), then the block processes 16 edges * 64 features = 1024 float values. With 256 threads, each thread processes 4 float values (1 float4 for 1 edge).
If a node has 21 edges, we'd need 2 blocks (16 edges + 5 edges). Or, more elegantly, have each y-thread process multiple edges in a grid-stride loop:
- y-range = ceil_div(num_edges_for_node, 16)
- block handles 16 edges at a time
But the number of edges varies per node. With a block per node, we can't easily launch variable-sized grids.
What if we use a fixed block size and each thread processes a feature-vector for one edge, accumulating in shared memory? The shared memory is [16 edges] * [16 float4s]? No, we still need to reduce.
Let's go back to basics. A very simple optimization over PyTorch might be:
1. Use float4 loads to read 4 features at a time
2. Unroll the feature loop
3. Ensure the grid has enough parallelism
Here's a kernel that might work without sorting:
```cpp
__global__ void scatter_add_opt(
const int* dst_idx,
const float* edge_feat,
float* out,
int num_edges,
int feat_dim
) {
int edge = blockIdx.x * blockDim.x + threadIdx.x;
int lane = threadIdx.x % 32;
if (edge >= num_edges) return;
int dst = dst_idx[edge];
float4* edge_feat4 = (float4*)edge_feat;
float4* out4 = (float4*)out;
int feat4_dim = feat_dim / 4; // 16
// Each thread loads 16 float4s for its edge
for (int f4 = lane; f4 < feat4_dim; f4 += 32) {
float4 val = edge_feat4[edge * feat4_dim + f4];
// Atomic add for float4... doesn't exist as a single instruction
// We have to do 4 separate atomicAdds
atomicAdd(&out4[dst * feat4_dim + f4].x, val.x);
atomicAdd(&out4[dst * feat4_dim + f4].y, val.y);
atomicAdd(&out4[dst * feat4_dim + f4].z, val.z);
atomicAdd(&out4[dst * feat4_dim + f4].w, val.w);
}
}
```
Wait, this uses lane for strided access within a warp. But `blockDim.x` might be 128 or 256. If blockDim.x = 128, then threads 0..31 are warp 0, 32..63 are warp 1, etc.
- Thread 0: f4 = 0, 32 (out of 16) -> only f4=0
- Thread 1: f4 = 1
- ...
- Thread 15: f4 = 15
- Threads 16..31: no work
- So only 16 threads per warp are active. Not efficient.
Alternative:
```cpp
int f4 = threadIdx.x; // 0..127
if (f4 < feat4_dim) { // 16
// load and atomic
}
```
With blockDim.x = 32 (1 warp):
- Warp 0: threads 0..15 handle f4=0..15, threads 16..31 idle
- That's 50% utilization
With blockDim.x = 16:
- Each block is exactly 1 warp
- Handles 1 edge
- 16384 edges = 16384 blocks
- 16384 blocks on H100, 132 SMs -> ~124 blocks per SM
- But each block only processes 1 edge
- Memory reads per block: 64 floats = 256 bytes
- This is tiny. The overhead of launching 16384 blocks might dominate.
Hmm.
What about blockDim.x = 32, blockDim.y = 4 = 128 threads per block?
- Each block handles 4 edges
- Each edge is handled by 32 threads (1 warp), but only 16 threads do work (feat4_dim = 16)
- Grid: 16384 / 4 = 4096 blocks
- Each SM gets ~31 blocks
Still, half the threads are idle in each edge's warp.
What if we use blockDim.x = 16 and have each block process multiple edges sequentially?
```cpp
__global__ void scatter_add_opt(...) {
int edge_start = blockIdx.x * EDGES_PER_BLOCK;
int f4 = threadIdx.x; // 0..15
for (int e = 0; e < EDGES_PER_BLOCK; e++) {
int edge = edge_start + e;
if (edge >= num_edges) break;
int dst = dst_idx[edge];
float4 val = ((float4*)edge_feat)[edge * 16 + f4];
float4* out_ptr = &((float4*)out)[dst * 16 + f4];
atomicAdd(&out_ptr->x, val.x);
// ...
}
}
```
Block config: blockDim.x = 16. BlockDim.y = 1 or maybe 4 for 4 edges concurrently? No, let's keep it simple. 16 threads per block. Each block handles, say, 32 edges sequentially.
Wait, if each block has 16 threads and handles 32 edges:
- 16 threads load float4 for edge 0 (coalesced)
- Then edge 1 (coalesced)
- ...
- 16384 / 32 = 512 blocks
- 512 blocks / 132 SMs = ~4 blocks per SM
- 4 warps per SM
- Very low occupancy
We want MORE threads, not fewer.
How about blockDim = 256, and we organize the block as:
- 16 warps
- Warp w handles edge = block_start + w
- Each warp has 32 threads, but only threads 0..15 are active (handling 16 float4s)
- Block handles 16 edges
- 16384 / 16 = 1024 blocks
- 1024 blocks / 132 = ~8 blocks per SM
- 8 blocks * 16 warps = 128 warps per SM. But only half the threads in each warp are active, so effective warps = 64 per SM.
64 warps per SM is actually very good! H100 can hold 64 warps per SM, so we'd have 100% occupancy.
Let me verify:
- Block size = 256 (8 warps of 32)
- Each warp handles 1 edge
- 16 warps would handle 16 edges, but we only have 8 warps. So block handles 8 edges.
- 16384 / 8 = 2048 blocks
- 2048 / 132 = ~15.5 blocks per SM
- 15.5 blocks * 8 warps = 124 warps per SM... wait, H100 has max 64 warps per SM. So the scheduler would limit some blocks. Actually 64 warps / 8 warps per block = 8 blocks per SM max.
- 15.5 blocks per SM means some blocks run after others finish. Effective concurrent warps = 64 (maxed out).
- But 15.5 blocks per SM is decent.
Within a warp:
- Threads 0..15: load float4 for features 0..15. Addresses are edge*64*4 + thread*16 = edge*256 + thread*16. These are consecutive 16-byte chunks → coalesced!
- Threads 16..31: idle. Could we use them?
What if we halve the block size?
- Block = 128 threads = 4 warps
- Each warp handles 1 edge → 4 edges per block
- 16384 / 4 = 4096 blocks
- 4096 / 132 = ~31 blocks per SM
- Max concurrent = 64 warps / 4 warps = 16 blocks per SM. So we'd have 16 blocks concurrently, 31 total means ~2 waves.
- 16 blocks * 4 warps = 64 warps per SM. Maxed out.
- Each warp: threads 0..15 active. Could we use threads 16..31 for another edge?
Actually, within a 32-thread warp, if threads 0..15 handle edge e and threads 16..31 handle edge e+1:
- Thread 0 reads [e, f0], thread 1 reads [e, f1], ..., thread 16 reads [e+1, f0], etc.
- Are these coalesced? Thread 0: e*256 + 0, Thread 1: e*256 + 16, ..., Thread 15: e*256 + 240.
- Thread 16: (e+1)*256 + 0, Thread 17: (e+1)*256 + 16, etc.
- In a warp, addresses are: 0, 16, 32, ..., 240, 256, 272, ..., 496.
- These span e*256 to (e+1)*256 + 240 = e*256 + 496. This is 512 bytes with some addresses in the second half.
- With 128-byte cache lines: lines at e*256 (+0, +128), (e+1)*256 (+0, +128). 4 cache lines.
- Transaction count: 4 (128-byte each) = 512 bytes transferred for 32 float4s = 512 bytes. Perfect! 100% bandwidth utilization.
Wow! This is a great configuration:
- Warp size = 32 threads
- Each warp handles 2 edges (16 threads per edge, via float4)
- Block size = 128 threads = 4 warps
- Block handles 8 edges
- 2048 blocks total
- Coalescing is nearly perfect
Let me map this precisely:
```cpp
int warp_id = threadIdx.x / 32; // 0..3
int lane = threadIdx.x % 32; // 0..31
int edge_in_block = warp_id * 2 + (lane / 16); // 0..7
int f4_in_edge = lane % 16; // 0..15
int edge = blockIdx.x * 8 + edge_in_block;
if (edge >= num_edges) return;
int dst = dst_idx[edge];
float4 val = ((float4*)edge_feat)[edge * 16 + f4_in_edge];
float4* out_ptr = &((float4*)out)[dst * 16 + f4_in_edge];
atomicAdd(&out_ptr->x, val.x);
atomicAdd(&out_ptr->y, val.y);
atomicAdd(&out_ptr->z, val.z);
atomicAdd(&out_ptr->w, val.w);
```
Wait, will `atomicAdd` to `out_ptr->x` work? CUDA's atomicAdd supports float*. But we're passing `&out_ptr->x` where `out_ptr` is `float4*`. `out_ptr->x` is a float, so `&out_ptr->x` is `float*`. Yes, atomicAdd(float*, float) works.
But are the atomics within a warp to the same destination node coalesced?
If edge_in_block = 0 and edge_in_block = 1 both target the same node, and they do atomicAdd for the same f4, then threads 0..15 and 16..31 both target the same address. This causes bank conflicts in L2 and serialization in memory. But with random dst_idx, the probability that two specific edges in the same warp target the same node is 1/768 ≈ 0.13%. Very unlikely.
Actually, for 2 edges in a warp, they target the same node with probability 1/768. So this is negligible.
What about atomics for the same (node, f4) across different warps/blocks? Two edges in the same block targeting the same node would have their atomicAdds serialized in the L2. With ~21 edges per node and 16384 edges in 2048 blocks (8 edges each), a node appears in roughly 21/8 ≈ 2.6 blocks on average. So there are ~2-3 blocks that process edges for the same node. Their atomics to global memory will be serialized for each feature. But since they arrive at different times, it's not too bad. Plus, modern GPUs have L2 atomics that batch operations.
And importantly, PyTorch's kernel has the same contention! Our kernel should be better due to:
1. Coalesced loads via float4
2. Better coalescing of atomics (16 threads in a warp write to adjacent memory for the same node)
3. Higher occupancy (64 warps per SM)
Wait, let's compute occupancy more carefully.
- Block size = 128 threads
- Registers per thread: let's say 24 (rough estimate)
- Total registers per block = 128 * 24 = 3072
- Max registers per SM = 64K = 65536 (actually H100 register file is 64K 32-bit registers)
- Blocks per SM limited by registers = 65536 / 3072 = 21 blocks
- But we also have shared memory = 0 (we're not using shared memory here)
- Max warps per SM = 64 = 2048 threads
- Blocks per SM limited by threads = 2048 / 128 = 16 blocks
- We calculated ~15.5 blocks per SM total. So each SM can run all its blocks concurrently!
Wait, 2048 blocks total / 132 SMs = 15.5 blocks per SM. And max concurrent blocks per SM = 16. So each SM runs ~15-16 blocks simultaneously, each with 4 warps = 64 warps. That's exactly 100% occupancy!
But actually, not all SMs get the same number. Some get 15, some get 16. The ones with 15 have 60 warps, ones with 16 have 64. Average ~62 warps. Still excellent.
This kernel seems very promising. Let's think about implementation details.
We need to handle the case where `edge_feat` might not be aligned for float4 access. With PyTorch tensors of shape [16384, 64], the rows are aligned to at least 4 bytes, and 64*4=256 bytes per row. So `edge_feat[edge * 64]` should be 4-byte aligned, but is it 16-byte aligned (required for float4)?
Actually, for float4 loads via `*((float4*)(ptr))`, the pointer should be 16-byte aligned. CUDA might enforce stricter alignment for the first dimension of tensors. For a float tensor with inner dimension 64, each row starts at offset `edge * 64 * 4 = edge * 256` bytes from the start. If the tensor base pointer is 256-byte aligned (which is common for PyTorch), then every row is 256-byte aligned, so definitely 16-byte aligned.
But wait, can we guarantee 16-byte alignment? PyTorch usually allocates with at least 4-byte alignment. For rows of 256 bytes, if the base is 4-byte aligned, row 0 is 4-byte aligned, row 1 is 260-byte aligned (not divisible by 16). So `edge_feat[1 * 64]` is only guaranteed 4-byte aligned if base is 4-byte aligned!
But if the base is at least 16-byte aligned, then row 1 starts at 256 bytes which is divisible by 16. In practice, PyTorch allocates memory aligned to at least 512 bytes for large allocations. So float4 should be safe.
Actually, to be safe, we can use `__ldg` with unaligned float4? No, `float4` loads require 16-byte alignment. We should check alignment or handle unaligned cases.
For safety, we could process the first few floats individually to align to 16 bytes, but that's overkill. PyTorch's default alignment for CUDA tensors is 256 bytes for the first dimension... wait, is the tensor itself 256-byte aligned or just the rows?
PyTorch allocates unified memory with `cudaMalloc`, which typically returns 256-byte aligned pointers. So the base is 256-byte aligned. Row 1 at 256 bytes. Row 2 at 512 bytes. All rows are 256-byte aligned. So float4 is safe.
But what if `edge_feat` is not contiguous? The reference implementation implies it could be any tensor, but we can check if it's contiguous and fall back if not, or copy. Actually, for simplicity, let's assume it's contiguous. We can add a `.contiguous()` call in the Python wrapper if needed.
Wait, the inputs won't necessarily be contiguous. But `edge_feat[edge, feat]` access in the original PyTorch code assumes it can index. Our custom kernel will get a pointer to the data. We should probably call `.contiguous()` on the input before passing to the kernel, to ensure row-major layout.
Now, one more thing: we need to zero the output before accumulating. The original code does `torch.zeros(...)`. We can do this in Python or in the kernel. Let's do it in Python for simplicity.
Actually, we need to handle the case where `dst_idx` is int32 (as in the inputs). So dst_idx elements are 4 bytes.
Let me write out the kernel:
```cpp
#include <torch/extension.h>
#include <cuda_runtime.h>
__global__ void scatter_add_float4_kernel(
const int32_t* __restrict__ dst_idx,
const float* __restrict__ edge_feat,
float* __restrict__ out,
int num_edges,
int num_nodes,
int feat_dim
) {
const int edges_per_block = 8;
const int f4_per_edge = feat_dim / 4; // 16
int warp_id = threadIdx.x >> 5; // / 32
int lane = threadIdx.x & 31; // % 32
int edge_in_block = (warp_id << 1) + (lane >> 4); // warp_id * 2 + lane / 16
int f4_idx = lane & 15; // lane % 16
int edge = blockIdx.x * edges_per_block + edge_in_block;
if (edge >= num_edges) return;
int dst = dst_idx[edge];
const float4* edge_feat4 = reinterpret_cast<const float4*>(edge_feat);
float4* out4 = reinterpret_cast<float4*>(out);
float4 val = edge_feat4[edge * f4_per_edge + f4_idx];
float4* out_ptr = &out4[dst * f4_per_edge + f4_idx];
atomicAdd(&out_ptr->x, val.x);
atomicAdd(&out_ptr->y, val.y);
atomicAdd(&out_ptr->z, val.z);
atomicAdd(&out_ptr->w, val.w);
}
```
Wait, `atomicAdd` for float is available, but I'm not sure if it works with `float4*` members directly. The type of `out_ptr->x` is `float`, and `&out_ptr->x` is `float*`. `atomicAdd(float*, float)` is a built-in CUDA function. So yes, it should work.
However, there's a subtlety: `atomicAdd` to `&out_ptr->x` might not be aligned, but since `out_ptr` is a `float4*` and it's 16-byte aligned, `&out_ptr->x` is also 16-byte aligned. So `atomicAdd` on a 16-byte aligned `float*` should be fine. Actually, `atomicAdd` only requires 4-byte alignment for float.
But there's another issue: `out_ptr` is a `float4*`. Accessing `out_ptr->x` when out_ptr is in global memory... wait, `out_ptr` is a local variable (a pointer). Dereferencing it with `->` gives us the x component. But the compiler might generate ugly code. Let me rewrite:
```cpp
float* out_base = out + dst * feat_dim + f4_idx * 4;
atomicAdd(out_base + 0, val.x);
atomicAdd(out_base + 1, val.y);
atomicAdd(out_base + 2, val.z);
atomicAdd(out_base + 3, val.w);
```
This is cleaner and avoids any float4 pointer arithmetic issues. Let me check: `out_base` is `float*`. `out` is `float*`. `dst * feat_dim + f4_idx * 4` gives an integer offset. Yes.
But wait, `f4_idx` ranges from 0 to 15. `f4_idx * 4` gives 0, 4, 8, ..., 60. These are the starting feature indices for each float4. So `out_base + 0` is feature `f4_idx * 4`, `out_base + 1` is `f4_idx * 4 + 1`, etc.
And `edge * f4_per_edge + f4_idx` for edge_feat4: `edge * 16 + f4_idx`. This is correct since each edge has 16 float4s.
Actually, does `reinterpret_cast<const float4*>(edge_feat)` work if edge_feat is float* and the data is not float4-aligned? We assume alignment.
What if `feat_dim` is not divisible by 4? In this problem it is (64), but to be safe we should handle the tail.
Let me also consider if we should handle the output zeroing in the kernel. The reference does `torch.zeros`. We can just create zeros in Python and pass to the kernel.
Is there any issue with `atomicAdd` throughput? For float, on H100, atomicAdd throughput is good but not infinite. However, with random destinations and 16 atomics per edge (4 float components × something?), wait.
Each edge triggers:
- 4 atomicAdds (for x, y, z, w)
But the 4 atomicAdds are to consecutive addresses: `dst*64 + k`, `dst*64 + k+1`, `dst*64 + k+2`, `dst*64 + k+3`.
On modern GPUs, consecutive atomics might be coalesced to some extent. The L2 cache can handle them. But they still need to be serialized if to the same address, but here they're to 4 different addresses.
Actually, 4 atomicAdds per edge for 16384 edges = 65536 atomicAdd operations. If 2-3 edges target the same node on average across blocks, many of these atomics collide at the L2 level. Each atomic takes maybe ~20-50 cycles. But with 64 warps per SM, there are 2048 threads running. Many atomics are in flight simultaneously.
Let me compute: 16384 edges * 4 atomic components * 2.6 blocks per node average ≈ 170K atomic transactions? Actually no, each edge does exactly 4 atomics. Total atomic operations = 16384 * 16 = 262,144 (since each edge is processed by 16 threads, each doing 1 atomicAdd on a float component). Wait, no!
In my kernel:
- Each edge is assigned to a warp (16 threads handle 2 edges per warp)
- For edge e, `f4_idx` ranges from 0 to 15. Each of the 16 threads does 1 float4 load and then 4 atomicAdds on individual floats.
- So per edge: 16 threads × 4 atomicAdds = 64 atomicAdds per edge.
- Total: 16384 * 64 = 1,048,576 atomicAdds.
Oh! That's a lot of atomics. But most of them are to different addresses (different features of potentially different nodes). Specifically:
- Threads handling edge e: they target node `dst = dst_idx[e]`
- Thread f4_idx targets features `f4_idx*4` to `f4_idx*4+3` of node `dst`
- 16 threads in a warp target 16 different `f4_idx`, so 16 different float4 positions
- Each thread does 4 atomics to 4 consecutive floats
- The 16 threads' addresses are: `dst*64 + {0..3}`, `dst*64 + {4..7}`, ..., `dst*64 + {60..63}`
- These are 16 different 16-byte chunks covering the entire 256-byte row for node `dst`.
- So the 16 threads in the first half-warp (edge e) all target the same node but different parts of it.
- The 16 threads in the second half-warp (edge e+1) target the second edge's node.
In a warp, we have 2 edges. Threads 0-15 target node A, threads 16-31 target node B.
For node A, the 16 threads write to 64 different float addresses. Since each thread writes 4 addresses, and there are 16 threads, that's 64 atomic operations to 64 consecutive addresses for node A.
Wait, thread 0 writes to [0,1,2,3], thread 1 to [4,5,6,7], ..., thread 15 to [60,61,62,63]. These are all distinct addresses within the same 256-byte cache line (128-byte lines, so 2 cache lines).
Can modern GPUs batch atomics to the same cache line? Yes! On Ampere and Hopper, atomics to addresses in the same L2 cache sector can be coalesced to some degree. For 64 atomicAdds across 2 cache lines, the L2 might handle this with ~2 transactions (or a few more). This is much better than 64 separate transactions.
Actually, looking at CUDA documentation, `atomicAdd` to float has native support and multiple atomics to the same cache line are coalesced. The exact behavior depends on the architecture, but Ampere/Hopper definitely optimize this.
So the atomic load isn't as bad as 1M independent transactions.
Compare to PyTorch's kernel: PyTorch likely has each thread do atomicAdd for one feature at a time for one edge. With a standard block layout, the memory access pattern for atomics might be worse.
I think this kernel is competitive. Let's also consider an alternative: instead of float4 and 4 atomicAdds, can we do a proper float4 atomicAdd? There's no built-in `atomicAdd(float4*, float4)`, but we could use inline PTX or `atomicAdd_block` in shared memory, then write back. But let's first try the float4 + 4 atomicAdds approach.
One more optimization: use `__ldg` for the load. `__ldg` uses the read-only data cache and can help with bandwidth.
```cpp
float4 val = __ldg(&edge_feat4[edge * f4_per_edge + f4_idx]);
```
This is good because edge_feat is read-only in this kernel.
Now let me think about edge cases:
- `edge_feat` must be contiguous or we need to handle strides. For simplicity, I'll call `.contiguous()` in the Python wrapper.
- `dst_idx` must be int32 as in the problem. I'll cast it.
- Output should be zeroed before the kernel.
- `feat_dim` might not be divisible by 4. I'll add a tail loop.
Wait, the problem says `feat_dim = 64`, which is divisible by 4. But we should support any `feat_dim` for generality.
What about grid size? We need to handle `num_edges` that aren't a multiple of 8.
Grid: `((num_edges + 7) / 8, 1)`.
Block: `128` threads.
`edges_per_block = 8`.
Wait, I assumed `warp_id` from 0 to 3 (4 warps), giving 8 edges. But what if block size is 128, then warp_id goes 0..3. Yes.
Let me double check the mapping for block=128:
- threadIdx.x = 0..127
- warp_id = threadIdx.x / 32 = 0..3
- lane = threadIdx.x % 32 = 0..31
- edge_in_block = warp_id * 2 + lane / 16
Lane / 16 for lane 0..15 gives 0, for lane 16..31 gives 1.
Warp 0: edge_in_block = 0*2 + 0 = 0 (lane 0..15) and 0*2 + 1 = 1 (lane 16..31)
Warp 1: edge_in_block = 1*2 + 0 = 2 and 1*2 + 1 = 3
Warp 2: edge_in_block = 4 and 5
Warp 3: edge_in_block = 6 and 7
Total = 8 edges per block. Good.
f4_idx = lane % 16:
For lane 0..15: f4_idx = lane
For lane 16..31: f4_idx = lane - 16
Good.
One issue: the grid should be large enough. We want `num_edges >= 1`. With `num_edges = 16384`, grid = 2048 blocks. Good.
Let me think about whether 2048 blocks is enough to keep the GPU busy. As computed, 15-16 blocks per SM means ~2 waves of execution (16 concurrent, ~16 more waiting). Actually, if 16 blocks fit concurrently and there are ~16 per SM, they all run at once! Wait, max concurrent blocks per SM is 32. Register limit gives 16. So 16 blocks run concurrently, and there are ~15-16 total blocks per SM. So basically all blocks for an SM run concurrently. As some finish, new ones from the same SM or grid replace them. Since all blocks for an SM are launched together, they all run simultaneously until the last few finish.
This is ideal!
Wait, actually CUDA scheduling is more dynamic. The grid of 2048 blocks is distributed to all SMs. SMs with more blocks will have some blocks queued. But since blocks are independent, as soon as one finishes, another is scheduled. So we should maintain high throughput.
2064 blocks for 132 SMs: 2048/132 = 15.515. Since each SM can hold 16 blocks, most SMs get 15 or 16 blocks. Those with 15 blocks will have 15*4=60 warps instead of 64. Some SMs might get 16 blocks, but only 15 were assigned... actually the grid is 2048. If 76 SMs get 16 blocks (1216 blocks) and 56 SMs get 15 blocks (840 blocks), total = 2056, which is close. Actually 132*15 = 1980, remaining 68 blocks distributed to 68 SMs, so 68 SMs get 16 blocks and 64 SMs get 15 blocks. The 64 SMs with 15 blocks run 60 warps concurrently. The 68 SMs with 16 blocks run 64 warps. Average is very high.
This kernel should work well.
However, there's a potential issue: `atomicAdd` with float has L2 cache coalescing but still might be slower than a reduction-based approach. Let me also consider a kernel that uses shared memory within a block to reduce atomics.
For example, what if a block processes a group of edges that might target the same node, accumulates in shared memory, and then writes to global memory?
With 8 edges per block, and 768 nodes, the probability that 2 edges in a block target the same node is roughly 8/768 ≈ 1%. Very low. So shared memory accumulation within a block would reduce global atomics by only ~1%. Not worth it.
Also, the overhead of shared memory (zeroing, syncing, writing back) is probably more than the benefit.
So the direct atomicAdd approach is best for this problem.
But wait, I want to also consider if we can reduce the number of atomics even further. What if we use float2 atomicAdd (if available)? CUDA has `atomicAdd(float2*, float2)` for compute capability 6.0+. Let me check. Actually, `atomicAdd` for float2 might not exist. But we can use `atomicAdd` on float.
What about using half-precision? No, the problem says fp32.
Another thought: can we pack 4 floats and do a CAS loop for float4 atomic add? That's complex and probably slower than 4 separate `atomicAdd` instructions on H100, which has hardware support for float atomicAdd.
Actually, on H100 (compute capability 9.0), `atomicAdd` for float is very fast and supports coalescing.
Okay, I'm going to write this kernel and see.
Wait, I want to also try an even simpler version to compare: just process each edge with all 64 features in a contiguous manner with as much coalescing as possible. For example:
```cpp
__global__ void scatter_add_simple(
const int* dst_idx,
const float* edge_feat,
float* out,
int num_edges,
int feat_dim
) {
int edge = blockIdx.x * blockDim.y + threadIdx.y;
int feat = threadIdx.x;
if (edge < num_edges && feat < feat_dim) {
int dst = dst_idx[edge];
atomicAdd(&out[dst * feat_dim + feat], edge_feat[edge * feat_dim + feat]);
}
}
```
With `blockDim = (64, 16)`, `gridDim = (ceil(16384/16), 1)` = (1024, 1).
Total threads per block = 1024.
Grid = 1024 blocks.
In a warp (32 threads in x, 1 in y):
- threadIdx.x = 0..31, threadIdx.y = const
- They read `edge_feat[edge * 64 + 0..31]`. These are CONSECUTIVE floats! Fully coalesced.
- They write to `out[dst * 64 + 0..31]`. These are also consecutive.
- But they're ALL atomicAdds! 32 atomicAdds per warp to 32 consecutive addresses for the same node.
Wait, this is actually really good:
- Loads: 1 coalesced 128-byte load per warp per edge
- Atomics: 32 atomicAdds to consecutive addresses. With cache-line coalescing, maybe 1-2 L2 transactions!
And we have 1024 blocks, each with 16 warps. 1024 * 16 = 16384 warps total.
16384 warps / 132 SMs = 124 warps per SM. Max is 64. So we have almost 2 waves.
Occupancy: 64 warps maxed out.
Wait, block size = 64 * 16 = 1024 threads. That's the max. Each block has 16 warps. 1024 blocks total. Warps per block = 16. Max blocks per SM = 2048/1024 = 2 blocks! Only 2 blocks per SM concurrently. 2 * 16 = 32 warps per SM. That's only 50% occupancy!
Ah, that's the issue. 1024 threads per block limits us to 2 blocks per SM (assuming enough registers). 32 warps out of 64 = 50% occupancy. Still decent, but we can do better.
With block size = 64 * 8 = 512 threads:
- 8 warps per block
- Max blocks per SM = 4
- Warps per SM = 32
- Grid: ceil(16384/8) = 2048 blocks
- 2048 / 132 = 15.5 blocks per SM
- 4 concurrent blocks per SM = 32 warps
- Still 50% occupancy
With block size = 64 * 4 = 256 threads:
- 4 warps per block
- Max blocks per SM = 8
- Grid: ceil(16384/4) = 4096 blocks
- 4096 / 132 = 31 blocks per SM
- 8 concurrent blocks = 32 warps
- Still 50% occupancy? Wait, max blocks per SM is 32, but limited by thread count: 2048/256 = 8 blocks. So 8 blocks * 4 warps = 32 warps. Still 50%.
Wait, why is it always 32 warps? Because if the block has `T` threads and we use `num_edges / edges_per_block` blocks, the concurrent warps per SM is capped by `min(total_blocks/SMs, max_blocks_per_SM) * warps_per_block`.
For 256 threads = 8 warps per block. Max blocks per SM = 2048/256 = 8. With enough total blocks, we'd have 8 * 8 = 64 warps. Oh wait, 8 blocks * 8 warps per block = 64 warps!
Wait, I computed warps per block wrong. 256 threads = 8 warps, not 4.
- Block = 256 threads = 8 warps
- Max blocks per SM = 2048/256 = 8 blocks
- 8 * 8 = 64 warps = 100% occupancy!
So with block size 256 (64 features × 4 edges), we can get 100% occupancy.
Let me recalculate:
- blockDim.x = 64 (features)
- blockDim.y = 4 (edges)
- Block size = 256 threads = 8 warps
- Edges per block = 4
- Number of blocks = ceil(16384/4) = 4096
- 4096 / 132 = 31 blocks per SM
- Max concurrent blocks per SM = 8 (due to 256 threads each)
- So 8 blocks run concurrently = 64 warps = 100% occupancy
- 31 total blocks means ~4 waves of execution (31 / 8 = 3.875)
This is great!
Now compare with my float4 approach:
- Block size = 128 threads = 4 warps
- Max concurrent blocks per SM = 2048/128 = 16
- Grid = 2048 blocks
- 2048 / 132 = 15.5 blocks per SM
- 15.5 blocks means ~1 wave (all run concurrently)
- Concurrent warps = min(15.5, 16) * 4 ≈ 62 warps ≈ 97% occupancy
- Effectively 1 wave
Both approaches can achieve near 100% occupancy. The difference:
1. Simple approach: 64 threads in x (features), 4 in y (edges). Block = 256. 4096 blocks. No float4.
2. Float4 approach: 128 threads, 8 edges, 2048 blocks. Uses float4.
In the simple approach:
- Warp 0: threads (0..63, 0) - wait, warp spans x dimension. In a block of (64, 4), warp 0 is threadIdx.x=0..31, threadIdx.y=0. Warp 1 is threadIdx.x=32..63, threadIdx.y=0. So 2 warps handle the 64 features for edge 0.
- Thread (0,0) reads edge_feat[edge*64 + 0]. Thread (1,0) reads [edge*64 + 1]. These are CONSECUTIVE in memory. Coalesced!
- Thread (0,0) does atomicAdd to out[dst*64 + 0]. Thread (1,0) to out[dst*64 + 1]. Consecutive addresses.
- This is as coalesced as the float4 approach, but uses 2x as many threads per edge.
Actually, the simple approach with 256 threads might be MORE efficient because:
1. It doesn't need float4, so no alignment concerns
2. It uses more threads per edge (64 threads per edge), meaning the memory controller sees wider requests for atomics
3. The instructions are simpler (1 atomicAdd per thread vs 4 atomicAdds per thread)
Wait, in the simple approach each thread does 1 atomicAdd. In float4 approach, each thread does 4 atomicAdds. With the same total threads handling the same workload:
- Simple: 4096 blocks * 256 threads = 1,048,576 threads. Each does 1 load, 1 atomic. Total atomic ops = 1,048,576.
- Float4: 2048 blocks * 128 threads = 262,144 threads. Each does 1 float4 load (4 floats), 4 atomics. Total atomic ops = 1,048,576.
Same number of atomic operations! But in the simple approach, the atomics are spread across MORE warps. Specifically, simple has twice as many warps (2M warp-iterations? No, same number of warps executing concurrently).
Actually, let me count warps:
- Simple: 4096 blocks * 8 warps = 32,768 warps
- Float4: 2048 blocks * 4 warps = 8,192 warps
Total warp-instructions:
- Simple: 32,768 warps * (1 edge / 4 edges_per_block * 64 features / 64 threads_per_warp)
Wait, this is getting confusing. Let's think about it differently.
For 16384 edges and 64 features, total work items = 1,048,576 (edge-feature pairs).
Simple approach with 256 threads/block and 4 edges/block:
- Each block processes 4 edges * 64 features = 256 work items.
- 256 threads each do 1 work item. Total warp-operations = 256 work items / 32 threads_per_warp = 8 warp-cycles per block.
- 4096 blocks. Total warp-cycles = 32,768.
Float4 approach with 128 threads/block and 8 edges/block:
- Each block processes 8 edges * 64 features = 512 work items.
- But each thread processes 1 float4 = 4 features for 1 edge. So 128 threads handle 128 * 4 = 512 work items. Total warp-cycles = 512 / 32 = 16 warp-cycles per block.
- 2048 blocks. Total warp-cycles = 32,768.
Same total warp-cycles! Now the question is which has better latency hiding and throughput.
In the simple approach, a warp handles 64 threads, but warp size is 32. So for edge 0, features 0..63:
- Warp 0: threads (0..31, 0) handle features 0..31 for edge 0
- Warp 1: threads (32..63, 0) handle features 32..63 for edge 0
- Next, warp 2 and 3 handle edge 1
- etc.
In the float4 approach, for edge 0:
- Half-warp (threads 0..15) handles features 0..63 for edge 0 via float4
- Other half-warp handles edge 1
Wait, in the simple approach, warp 0 issues 32 loads and 32 atomics for features 0..31 of edge 0.
In the float4 approach, threads 0..15 each issue 1 float4 load and 4 atomics for features 0..63 of edge 0.
The float4 load is a single instruction for 4 floats. The atomics are 4 separate instructions.
So the simple approach has:
- Warp 0: 32 loads + 32 atomics
Float4 approach (for same edge 0 via half-warp):
- Half-warp: 16 float4 loads + 64 atomics
Float4 uses more instructions due to splitting the float4 into 4 atomicAdds. But the loads are more efficient (1 instruction transfers 4 floats).
Actually, on Ampere/Hopper, vector loads have the same throughput as scalar loads. A float4 load takes 1 instruction but may take 4x the time or be dispatched as 4 loads. The main benefit is coalescing and fewer address calculations.
For this scale, I think the simple approach is actually better because:
1. No alignment assumptions
2. Simpler code
3. Simpler memory access pattern for atomics (no float4 splitting)
But wait, can we make the simple approach better by reducing contention? If consecutive edges target the same node, their warps will issue atomics to overlapping addresses. With 4 edges per block:
- Edges 0,1,2,3 in a block might target different nodes
- Warps for edge 0 and edge 1 will be consecutive in time. If they target the same node, their atomics will serialize at L2.
This is the same for both approaches.
However, there's another consideration: register usage. The float4 approach has fewer instructions and might use fewer registers. The simple approach might use fewer registers per thread, but it has more threads per block.
Actually for H100, register pressure is not the bottleneck here. Let me think about what else could matter.
Instruction-level parallelism: In the float4 approach, the 4 atomicAdds are independent and can be pipelined. In the simple approach, 1 load + 1 atomic per thread. The float4 might have better ILP.
But there's another factor: the float4 approach has 128 threads, so 4 warps per block. The simple approach has 8 warps per block. With max 64 warps per SM, the simple approach can have 8 blocks per SM (64/8=8), while the float4 approach can have 16 blocks per SM (64/4=16). More blocks per SM means better ability to hide latency if blocks stall. But each block in the simple approach does fewer warp-cycles (8 vs 16). Actually, the simple approach blocks do 8 warp-cycles of work but have 8 warps, while float4 blocks do 16 warp-cycles with 4 warps.
Wait, does the whole block need to finish before new blocks are scheduled? No, warps are the scheduling units. As long as there are enough warps across all running blocks, latency is hidden.
Let me compute: max concurrent warps = 64 per SM.
Simple: 8 blocks per SM * 8 warps = 64 warps. So an SM is fully occupied with 8 blocks.
Float4: 16 blocks per SM * 4 warps = 64 warps. SM fully occupied with 16 blocks.
In the simple case, SM runs 8 blocks out of ~31 assigned. So after the first 8 finish, the next 8 start. Warps from different blocks can run interleaved.
In the float4 case, SM runs 16 blocks out of ~15.5 assigned. So all blocks for that SM run concurrently!
This means the float4 approach has less scheduling overhead because each SM only needs to launch its blocks once, and they all run together. The simple approach has ~4 rounds of block execution per SM.
Is this significant? Maybe not, but the float4 approach might have slightly less launch overhead.
You know what, let me just try both and see which compiles and is faster. I'll write the simple version first as a baseline, then the float4 version. Actually, I'll go with the simple 2D block version because it's simpler to implement correctly.
Wait, but there's another optimization for the simple approach: process more edges per thread using a grid-stride loop within the block. For example, if blockDim.y = 4, we process 4 edges. But what if we process 8 edges with blockDim.y = 8 and blockDim.x = 32? Then we'd do:
- blockDim = (32, 8) = 256 threads
- Each warp: threadIdx.x = 0..31, threadIdx.y = const
- Warp 0: features 0..31 for edge 0
- Warp 1: features 0..31 for edge 1
- ...
- Warp 7: features 0..31 for edge 7
- Wait, this only covers 32 features, not 64.
- So we'd need a loop over feature tiles:
```cpp
for (int f_tile = 0; f_tile < feat_dim; f_tile += 32) {
int feat = f_tile + threadIdx.x;
// load and atomic
}
```
This adds a loop. But it means the block handles 8 edges and we have 4096/8 = 512 blocks? No, 16384/8 = 2048 blocks.
Wait, 2048 blocks of 256 threads. 2048/132 ≈ 15.5 blocks per SM. Max blocks per SM = 8 (due to 256 threads). So 2 waves. 8 blocks * 8 warps = 64 warps. 100% occupancy.
And inside the block, we loop over 2 feature tiles (f_tile = 0, 32).
Each warp handles 1 edge and 1 feature tile (32 features).
Total warps per block: 8 edges = 8 warps (for tile 0) + 8 warps (for tile 1) = 16 warp executions.
But since warps from tile 0 and tile 1 are the same physical warps, the SM executes them sequentially.
So each physical warp does 2 iterations. Total physical warps = 8, each with 2 iterations.
Actually, this is getting overly complex and might not be faster.
Let me go with the simplest 2D approach:
- blockDim.x = feat_dim (64)
- blockDim.y = 16
- Total threads = 1024
- Grid.x = ceil(num_edges / 16)
- Each warp handles 32 features for 1 edge
Wait, blockDim.x = 64, blockDim.y = 4 = 256 threads:
- Grid.x = ceil(16384 / 4) = 4096
- Warp 0: features 0..31 for edge 0
- Warp 1: features 32..63 for edge 0
- Warp 2: features 0..31 for edge 1
- Warp 3: features 32..63 for edge 1
- etc.
Each block has 8 warps. 4096 blocks. 4096*8 = 32768 warps total.
32768 / 132 = 248 warps per SM. But max concurrent warps per SM = 64.
Blocks per SM = 248 / 8 = 31 blocks. Max concurrent blocks = 2048/256 = 8.
So 8 blocks * 8 warps = 64 concurrent warps. Then 3-4 more waves.
Total execution time = 4 waves * time_per_wave.
In one wave, each SM executes 64 warps. Each warp does 1 load of 32 floats (coalesced 128 bytes) + 32 atomics. Then the next wave starts.
Time per warp is dominated by memory. Load: ~10-20 ns for L2 hit or ~50-100 ns for L2 miss. But with random edges, output atomics might miss L2 if the output row was just accessed by another SM.
For this scale (768 nodes * 64 features * 4 bytes = 196KB output), the entire output fits in L2 cache (50MB). So all atomics should hit L2. L2 latency is maybe 20-30 cycles (~5-8 ns at 3GHz). A warp can hide maybe 4 independent loads, but atomics have back-to-back dependency.
Actually, the bottleneck for atomics hitting L2 is likely the L2 atomic throughput. On A100, L2 atomic throughput is around ~30-50 billion per second for float. On H100, it's higher. Let's estimate.
16384 edges * 64 features = 1,048,576 atomicAdds.
If L2 can do 100B float atomics/sec, that's 10.5 microseconds. Add load time ~4MB / 3TB/s = 1.3 microseconds. Total maybe 12 microseconds.
But warps are independent. With 64 warps per SM and 132 SMs... wait, total concurrent warps = 64 * 132 = 8448. If each warp does 32 atomics, total atomics in flight = 8448 * 32 = 270K atomics. Hmm.
Actually, let's look at it from throughput. Total atomic operations = 1,048,576. If we could do them at 3TB/s equivalent... but atomics aren't limited by bandwidth alone, they're limited by L2 atomic units.
A100 has 40 L2 cache slices (I think). Each can handle some number of atomics per cycle. H100 has more. A common number is ~100B atomics/sec total.
1,048,576 / 100e9 = 10.5 microseconds. This seems like a reasonable estimate.
If PyTorch's kernel has worse coalescing and does more L2 transactions per atomic, it might take 20-30 microseconds.
But wait, we can potentially reduce the number of atomic operations by using warp-level reduction! If edges in the same warp target the same node, we could reduce first.
In our 2D kernel, a warp handles features for a single edge. So all threads in the warp target the same node but different features. There's no redundancy to eliminate within a warp. The only redundancy is if multiple edges target the same node, and those edges might be in different warps or different blocks.
To eliminate this, we need to group edges by target node. With random assignments, we'd need to deliberately sort edges within each block. But that's complex.
Actually, what if we sort edges by target node BEFORE launching the kernel, and then launch a kernel where each warp reduces multiple edges for the same node?
If edges are sorted by dst:
- Edges 0..k0 target node n0
- Edges k0+1..k1 target node n1
- etc.
Then we could have each warp handle one feature and reduce multiple edges for that feature:
- Thread 0 handles feature 0, iterating over edges for node n0
- Thread 1 handles feature 1, iterating over edges for node n0
- ...
- Thread 31 handles feature 31, iterating over edges for node n0
- After the first 32 features, we can launch another warp or loop.
This is the segmented reduction approach again. But now with sorted edges and:
- Block handles one node
- Threads in block handle features
- Thread loops over edges for the node (or we can assign edges to different threads and shuffle-reduce)
With a node having ~21 edges:
- Block of 64 threads handles 64 features
- Each thread handles 1 feature and loops over all 21 edges
- Total loads per thread: 21 * 4 bytes = 84 bytes
- Total blocks: 768
- Warps per block: 2
- Total warps: 1536
- 1536 / 132 = 11.6 warps per SM
11.6 warps per SM is very low. H100 needs 20-40+ warps to hide L2 latency. So this kernel would be heavily latency-bound. Each thread does 21 sequential loads (no ILP), and there aren't enough warps to overlap.
What if each block handles multiple nodes? Say 4 nodes per block:
- 256 threads
- Node 0: threads 0..63
- Node 1: threads 64..127
- Node 2: threads 128..191
- Node 3: threads 192..255
- Each thread loops over edges for its assigned node
- 192 blocks total
- 192 / 132 = 1.45 blocks per SM
- 1.45 * 4 warps_per_node * 4 nodes = 23.2 warps per SM
- Still not great.
What if we process nodes with fewer threads per node but more nodes per block?
- 8 nodes per block, 32 threads per node (1 warp per node, 32 features at a time)
- Block = 256 threads
- For node i: warp i handles features 0..31 for all edges of node i
- Another warp handles features 32..63
- Each node needs 2 warps
- 8 nodes need 16 warps per block
- But a block can only have 8 warps max (256 threads / 32). So we can only fit 4 nodes * 2 warps = 8 warps.
- 768 / 4 = 192 blocks
- 192 blocks / 132 = 1.45 blocks per SM
- 1.45 * 8 warps = 11.6 warps per SM
Still low.
What if we process 1 node per block but with many more threads by assigning multiple threads per feature?
- 256 threads = 4 threads per feature * 64 features
- For feature f, 4 threads partition the 21 edges: thread 0 handles edges 0,4,8,..., thread 1 handles 1,5,9,..., etc.
- After loading, the 4 threads reduce their partial sums using shuffle
- Then write the result to output (NO atomics!)
- 768 blocks
- 768 * 4 warps = 3072 warps
- 3072 / 132 = 23.3 warps per SM
- Better, but coalescing is poor (threads reading strided edges)
To fix coalescing, we can organize the threads so that consecutive threads read consecutive floats for the same edge:
- Warp 0: 32 threads, 4 nodes * 8 features? No.
- If we want 4 threads per feature for 64 features, that's 256 threads.
- But for coalescing, consecutive threads should read consecutive memory.
- For edge e, feature f: address = e * feat_dim + f.
- If thread 0 reads feature 0 of edge 0, thread 1 reads feature 1 of edge 0... wait, that's one thread per feature. We want 4 threads per feature. They'd all read the same address, which is redundant.
Multiple threads per feature means threads read the same address or strided addresses. Not coalesced. So this approach doesn't work well.
I think the direct atomic approach is fundamentally better for this problem because:
1. No preprocessing needed
2. Much higher parallelism (thousands of blocks vs hundreds)
3. Better latency hiding
4. No sequential dependency (each thread is independent)
Let me reconsider the float4 simple 2D block. I'll go with a configuration that maximizes occupancy and simplicity:
```cpp
__global__ void scatter_add_kernel(
const int* dst_idx,
const float* edge_feat,
float* out,
int num_edges,
int feat_dim
) {
int edge = blockIdx.x * blockDim.y + threadIdx.y;
int feat = threadIdx.x;
if (edge < num_edges && feat < feat_dim) {
int dst = dst_idx[edge];
float val = edge_feat[edge * feat_dim + feat];
atomicAdd(&out[dst * feat_dim + feat], val);
}
}
```
Block config: (64, 4) = 256 threads.
Grid: ((num_edges + 3) / 4, 1).
Access pattern:
- Warp: threadIdx.x=0..31, threadIdx.y=const.
- Reads: edge_feat[edge*64 + 0..31], consecutive → coalesced 128-byte load.
- Atomics: out[dst*64 + 0..31], consecutive → coalesced/L2-batched atomic.
This is extremely clean. I suspect it might already beat PyTorch.
But can we do even better by increasing the y-dimension to 8 while keeping x=32?
- Block: (32, 8) = 256
- Grid: (num_edges / 8)
- Warp: threadIdx.x=0..31, threadIdx.y=const
- Each warp handles 32 features for 1 edge.
- But feat_dim=64, so we'd need a loop over 2 feature tiles per warp.
- This means each warp has 2 iterations. The 2 iterations use the same warp sequentially.
- Total blocks = 2048. 2048/132 = 15.5 blocks per SM.
- Concurrent blocks = 2048/256 = 8 per SM. 8 * 8 warps = 64 warps. 100% occupancy.
- But each warp does 2 iterations, so total work per block = 16 warp-executions.
- This might be fine.
The advantage of (32, 8) over (64, 4):
- In (64, 4), each block handles 4 edges. 4096 blocks total.
- In (32, 8), each block handles 8 edges. 2048 blocks total.
- (32, 8) has fewer blocks but the same thread count. Possibly less kernel launch overhead.
Actually, with CUDA, having at least a few thousand blocks is fine. 4096 blocks is not too many.
Let me also think about whether (64, 4) has bank conflicts. In a warp with threadIdx.x = 0..31 and threadIdx.y = const, all threads in the warp have the same threadIdx.y. So they're all accessing the same edge. The `dst_idx[edge]` read is the same for all threads in the warp, which is automatically broadcast from L1 (if cached). The `edge_feat` reads are consecutive. The `atomicAdd` targets are consecutive. No bank conflicts.
For (32, 8), in a warp with threadIdx.x = 0..31 and threadIdx.y = const, same thing.
I'll go with (64, 4) because it handles the full feature dimension in one go without a loop. Simpler and fewer instructions.
One more optimization: can we vectorize the loads and stores? For block (64, 4), the warp loads 32 consecutive floats. That's a natural 128-byte load. The compiler might automatically generate vectorized loads if the pointer is aligned. Since the row is 256 bytes and we start at feature 0 or 32, both are 128-byte aligned. So the compiler should generate 128-bit loads.
Wait, threadIdx.x=0..31 reads features 0..31 for one edge. That's 128 bytes starting from edge*feat_dim + 0. If edge*feat_dim*4 is 128-byte aligned, and edge*feat_dim is a multiple of 32 (which it is, since feat_dim=64), then the starting address is 256-byte aligned for any edge. So features 0..31 start at a 256-byte aligned address, and features 32..63 start at 128-byte aligned address. Both are good for 128-bit or 256-bit loads.
The compiler might generate LDG.128 instructions automatically. If not, we can use float4 or float2 explicitly. But explicit vectorization requires careful handling of alignment and might not be necessary if the compiler is smart.
Actually, I realize that for `atomicAdd` with a warp writing to consecutive addresses, modern CUDA might have a special optimization. But I don't know the exact details.
Let me also consider another optimization: using a temporary local buffer before atomicAdd. If a block processes multiple edges, and some of those edges might target the same node, we could accumulate in registers. But with 4 edges and 768 nodes, the collision probability is tiny (about 4/768 = 0.5%). Not worth it.
Actually, wait. What if we process 16 edges per block and use shared memory to accumulate for those edges? We don't need space for all 768 nodes, just the unique nodes touched by the 16 edges. We could use a hash table in shared memory. But implementing a hash table for 768 possible keys is overkill for a 0.5% collision rate.
Let me now think about whether PyTorch's `index_add_` kernel might already use this exact approach. Looking at PyTorch's CUDA implementation for `index_add_`:
For `Tensor.index_add_(dim, index, source)`, PyTorch dispatches to a kernel. For small `sliceSize`, it might use one thread per element. For larger `sliceSize`, it might tile.
Actually, I recall that PyTorch's `index_add_` for CUDA 2D+tensors uses a kernel like:
```cpp
template <typename scalar_t, typename IndexType>
__global__ void indexAddSmallIndex(...) {
// Each block handles an index, each thread handles a slice element
}
template <typename scalar_t, typename IndexType>
__global__ void indexAddLargeIndex(...) {
// Each block handles a slice element, grid-stride over indices
}
```
The exact kernel depends on the sizes. For [16384, 64] with dim=0, it might launch a kernel where:
- Each thread handles 1 feature
- Threads are grouped to handle multiple indices
I'm not sure of the exact configuration, but our custom kernel can be explicitly tuned for these sizes.
One issue with our kernel: `dst_idx` is int32. In PyTorch's kernel, indices are often int64. Loading int32 might be slightly faster. But more importantly, PyTorch might need to do bounds checking and handle various edge cases.
Our kernel can be very lean.
Let me also try another idea: instead of doing atomicAdd for every thread, what if we group consecutive edges with the same destination? Since dst_idx is random, explicit grouping is hard. But what if the edges are generated in batches where locality exists? The problem says `torch.randint`, so it's fully random.
Actually, `torch.randint(0, num_nodes, (num_edges,), dtype=torch.int32)` generates random integers. But for 768 nodes and 16384 edges, by the birthday paradox, many edges will share destinations. However, when iterating over edges in order, consecutive edges almost always have different destinations (probability 767/768 ≈ 99.87%).
So no locality to exploit.
I think I'll go with the 2D block approach as my primary kernel. But let me also think about one more thing: can we use a different grid layout? For example, having multiple blocks per edge to process features in parallel?
If we divide the 64 features among multiple blocks:
- Block A handles features 0..31 for ALL edges
- Block B handles features 32..63 for ALL edges
- This doubles the block count to 8192 blocks
- But each block still does atomicAdds to the same output locations, just different features
- For block A with features 0..31:
- blockDim = (32, 4) = 128 threads? Or (32, 8) = 256 threads.
- Actually, if we only handle 32 features, we could have blockDim.x = 32, blockDim.y = 8 = 256 threads.
- Grid = 2 * ceil(16384 / 8) = 4096 blocks.
- Each block handles 8 edges * 32 features = 256 work items.
- 4096 blocks. 4096/132 = 31 blocks per SM. Max concurrent = 8 blocks. 8*8=64 warps.
- This is fine, but it has a loop over 2 feature tiles (implicitly via 2 blocks per edge group).
This is similar to our previous (32, 8) with a loop.
Actually, what if we use a 3D grid? CUDA supports 3D grids. We could have:
- gridDim.z = 2 (feature halves)
- gridDim.y = num_edge_tiles
- gridDim.x = 1
But this doesn't really help; it's the same as a loop.
One more idea: use computational graph optimization. We can preprocess dst_idx in Python to find unique values and a permutation, but using PyTorch operations. Then call a custom kernel that does the segmented reduction. The segmented reduction kernel might be slower than direct atomics due to occupancy, but the preprocessing might enable us to avoid atomics.
Actually, let me think about the total time. If sorting + segmented reduction takes 15 microseconds but atomics take 10 microseconds, direct atomics win. So I need to estimate both.
For direct atomics:
- Loads: ~1.5 us (4MB at 3TB/s, but coalesced)
- Atomics: ~10 us (depending on contention)
- Total: ~12 us
For sort + segmented reduce:
- Sort 16384 int32: ~3-5 us
- Gather 16384 * 64 floats: ~1.5 us
- Find unique + boundaries: ~1-2 us
- Segmented reduce: ~5 us (sequential reads, but low occupancy)
- Total: ~12-14 us
So direct atomics might be comparable or faster, and it's much simpler.
I'll stick with direct atomics. Let me write the kernel and test it.
Actually, I want to also try a variant where we process edges in groups and use `__shared__` memory for a small output tile. This might help if multiple edges in a tile go to the same node. With random dst_idx and 8 edges per tile, there's a small chance of collision. But with 768 nodes, the probability that at least 2 edges in a group of 8 hit the same node is:
1 - (768 * 767 * ... * 761) / (768^8) ≈ 1 - exp(-8*7/2/768) ≈ 1 - exp(-0.036) ≈ 3.6%.
So about 3.6% of tiles have at least one collision. If there's 1 collision, that's 1 redundant atomic out of 8. Negligible benefit from shared memory.
So direct atomics is indeed the way to go.
Alright, let me write the kernel. I'll create a file with `ModelNew` that imports torch, loads the CUDA extension, and calls it.
```python
import torch
import torch.nn as nn
from torch.utils.cpp_extension import load_inline
cuda_source = '''
#include <torch/extension.h>
#include <cuda_runtime.h>
__global__ void scatter_add_kernel(
const int32_t* __restrict__ dst_idx,
const float* __restrict__ edge_feat,
float* __restrict__ out,
int num_edges,
int feat_dim
) {
int edge = blockIdx.x * blockDim.y + threadIdx.y;
int feat = threadIdx.x;
if (edge < num_edges && feat < feat_dim) {
int dst = dst_idx[edge];
float val = edge_feat[edge * feat_dim + feat];
atomicAdd(&out[dst * feat_dim + feat], val);
}
}
torch::Tensor scatter_add_forward(
torch::Tensor dst_idx,
torch::Tensor edge_feat,
int num_nodes
) {
int num_edges = edge_feat.size(0);
int feat_dim = edge_feat.size(1);
auto out = torch::zeros({num_nodes, feat_dim}, edge_feat.options());
dim3 block(feat_dim, 4);
dim3 grid((num_edges + block.y - 1) / block.y);
scatter_add_kernel<<<grid, block>>>(
dst_idx.data_ptr<int32_t>(),
edge_feat.data_ptr<float>(),
out.data_ptr<float>(),
num_edges,
feat_dim
);
return out;
}
'''
cpp_source = '''
torch::Tensor scatter_add_forward(torch::Tensor dst_idx, torch::Tensor edge_feat, int num_nodes);
'''
module = load_inline(
name='scatter_add_cuda',
cpp_sources=cpp_source,
cuda_sources=cuda_source,
functions=['scatter_add_forward'],
extra_cuda_cflags=['-O3'],
)
class ModelNew(nn.Module):
def __init__(self, num_nodes):
super().__init__()
self.num_nodes = num_nodes
def forward(self, dst_idx, edge_feat):
return module.scatter_add_forward(dst_idx, edge_feat, self.num_nodes)
```
Wait, I should make sure the tensor is contiguous. I'll add `.contiguous()` for safety.
Also, I should handle the case where `dst_idx` is not int32. The input spec says `dst_idx = torch.randint(..., dtype=torch.int32)`, so it should be int32. But in Model.forward, it does `dst_idx.long()`. Our kernel expects int32. We can either convert or use int64.
Actually, the get_inputs function returns `dst_idx = torch.randint(..., dtype=torch.int32)`. But in Model.forward, it does `out.index_add_(0, dst_idx.long(), edge_feat)`. So the reference converts to long. Our ModelNew.forward receives whatever get_inputs returns, which is int32.
Wait, does the evaluator call `ModelNew.forward` with the exact outputs of `get_inputs()`? Yes, I believe so. So dst_idx will be int32.
But to be safe, I'll handle any integer type. Or just cast to int32.
Actually, to avoid copies, I can check the dtype and either cast or use templates. For simplicity, I'll just cast to int32 in Python: `dst_idx = dst_idx.to(torch.int32)`.
But wait, the input is already int32, so this is a no-op. Good.
One more thing: I should ensure that edge_feat is contiguous: `edge_feat = edge_feat.contiguous()`.
Now, I need to consider if there are bounds checking issues. The dst_idx values are from 0 to num_nodes-1. Our kernel trusts this. The reference does too (index_add_ doesn't do bounds checking in release mode).
Let me also think about whether my block configuration is optimal. `block(feat_dim, 4)` with feat_dim=64 gives (64, 4) = 256 threads.
Actually, I want to try a slightly different configuration: `block(32, 8)` and loop over feature tiles. This might have better register usage because threadIdx.x is only 32 instead of 64. Let me think...
With block(32, 8):
- grid = (num_edges / 8)
- Each warp: threadIdx.x=0..31, threadIdx.y=const
- Warp handles 32 features for 1 edge
- We need 2 iterations to cover 64 features
```cpp
for (int f_base = 0; f_base < feat_dim; f_base += 32) {
int feat = f_base + threadIdx.x;
if (edge < num_edges && feat < feat_dim) {
int dst = dst_idx[edge];
float val = edge_feat[edge * feat_dim + feat];
atomicAdd(&out[dst * feat_dim + feat], val);
}
}
```
Wait, `threadIdx.x` is 0..31, `f_base` is 0 then 32. So `feat` covers 0..31 and 32..63. Good.
But now the compiler can't fully unroll if feat_dim is constant? Actually, we can unroll manually:
```cpp
#pragma unroll
for (int i = 0; i < 2; i++) {
int feat = i * 32 + threadIdx.x;
// ...
}
```
However, with 2 iterations, does the warp diverge? No, all threads in the warp execute the same iteration. `threadIdx.x` is uniform within the warp, but `i` is also uniform.
Wait, `threadIdx.x` varies across threads in the warp. `i` is uniform. So all threads in a warp execute the same `i`, and within the warp, each thread has a different `feat`. No divergence.
One potential issue: the atomicAdd to `out[dst * feat_dim + feat]` - if multiple warps (for different `i` or different `edge`) target the same node, there's still contention. But that's the same.
The benefit of (32, 8) is:
- blockDim.x = 32 means warp divergence is aligned to warp boundaries. With blockDim.x = 64, the first warp handles features 0..31 and the second warp handles 32..63. This is also fine.
- Actually both are the same in terms of warp behavior. block(64, 4) has 2 warps per edge. block(32, 8) with a loop of 2 also has 2 warps per edge per iteration... wait, block(32, 8) has 8 warps, each handling 1 edge and 32 features. Over 2 iterations, it handles 8 edges * 64 features. Same work as block(64, 4) which handles 4 edges * 64 features with 4 warps per block (since 64*4/32 = 8 warps). Wait, block(64, 4) has 8 warps too! 64*4 = 256 threads = 8 warps. Each edge requires 2 warps (for features 0..31 and 32..63). 4 edges * 2 warps = 8 warps. So block(64, 4) handles 4 edges with all 8 warps active at once. block(32, 8) with a loop of 2 also uses all 8 warps, but over 2 sequential iterations. So block(64, 4) has better instruction-level parallelism within the block!
This means block(64, 4) should be better because all 8 warps run concurrently (or at least are available for scheduling), whereas block(32, 8) with a loop forces the two iterations to run sequentially for each warp. While the SM can interleave warps from the two iterations, in practice, the second iteration's warps might have to wait for the first iteration's atomics to complete if there's a dependency. But actually, there is no true dependency - the two iterations target different features of the same edge, and atomicAdd to different addresses is independent. The SM scheduler can issue them back-to-back.
However, block(64, 4) is simpler and has no loop overhead. I'll test it first.
Actually, there's a potential issue with block(64, 4): blockDim.x = 64 means that if feat_dim were larger, we'd need even more threads in x. But for this problem, feat_dim = 64, so 64 threads in x is fine.
Let me also consider block(128, 2) = 256 threads. That's 2 edges * 128 features? No, warp 0 handles features 0..31 for edge 0, warp 1 handles 32..63 for edge 0, warp 2 handles 0..31 for edge 1, warp 3 handles 32..63 for edge 1. So 4 warps for 2 edges. Total 8 warps for 4 edges? Wait, 128 * 2 = 256 threads = 8 warps. If blockDim.x = 128, then warp 0 handles features 0..31, warp 1: 32..63, warp 2: 64..95, warp 3: 96..127. But we only have 64 features. So warp 2 and 3 would be mostly idle. Not good.
So block(64, 4) is indeed the natural choice for feat_dim=64.
Wait, what about block(64, 8)? That would be 512 threads. 512 threads = 16 warps. Max blocks per SM = 2048/512 = 4. 4 * 16 = 64 warps. Still 100% occupancy! And grid = ceil(16384/8) = 2048 blocks. 2048/132 = 15.5 blocks per SM. 15.5 > 4. So ~4 waves. Each block handles 8 edges * 64 features = 512 work items.
Is block(64, 8) better than block(64, 4)?
- Twice as many edges per block
- Same 100% occupancy
- Fewer blocks (2048 vs 4096)
- More work per block
Since there's no shared memory or synchronization, more work per block is generally good (amortizes block launch overhead). But it also means that if one edge is at the boundary (e.g., edge 16383 in a block of 8), the block still processes all 8 edges but some threads are idle for edge 16384. However, with 16384 exactly divisible by 8, there are no boundary issues in the last block. 16384 / 8 = 2048 exactly. 2048 * 8 = 16384. Perfect!
But block(64, 4): 16384 / 4 = 4096 exactly. Also perfect.
Between the two, block(64, 8) means fewer blocks, which means less kernel launch overhead, but on a modern GPU, 4096 blocks is not a lot. Both should be fine. The key difference is that block(64, 8) allows 4 blocks concurrent per SM, while block(64, 4) allows 8. More concurrent blocks is generally better for latency hiding when individual warps stall. So block(64, 4) might have a slight advantage.
Actually, we want to maximize concurrent blocks to hide latency. With 8 blocks per SM (for 64×4) vs 4 blocks per SM (for 64×8), both achieve 64 warps. But 8 blocks gives more opportunities for the warp scheduler to pick a ready warp when one stalls. So block(64, 4) might be slightly better.
What about block(64, 2) = 128 threads? Then max blocks per SM = 16. Grid = 8192 blocks. 8192/132 = 62 blocks per SM. 16 concurrent blocks = 64 warps. Many waves. This might also be good.
Wait, 8192 blocks means 8192 * 2 edges = 16384 edges. 16 concurrent blocks per SM * 128 threads = 2048 threads. 62 total blocks / 16 concurrent = ~4 waves.
Actually, more waves is fine. The warp scheduler handles it.
I think block(64, 4) with 4096 blocks is a sweet spot. Let me stick with it.
One more optimization: we can make the kernel a template on the block dimensions or use `__launch_bounds__` to help the compiler. But probably not necessary.
Another idea: process edges in sorted order within each block to improve L2 cache locality for output. But that requires sorting, which defeats the purpose.
Wait, what if we compile with `-maxrregcount=32` or similar? With 256 threads and not many registers per thread, we probably use <64 registers anyway. I'll let the compiler decide.
Let me now consider one more idea before settling: can we use a persistent kernel style where a block processes multiple groups of edges?
```cpp
__global__ void scatter_add_persistent(...) {
int edge_start = blockIdx.x * blockDim.y;
int feat = threadIdx.x;
for (int e = edge_start + threadIdx.y; e < num_edges; e += gridDim.x * blockDim.y) {
int dst = dst_idx[e];
atomicAdd(&out[dst * feat_dim + feat], edge_feat[e * feat_dim + feat]);
}
}
```
This is a grid-stride loop. The block processes every `gridDim.x * blockDim.y`-th edge. This is useful when the number of blocks is limited by the problem size. But we can already launch enough blocks (4096). Grid-stride loops are most useful when you want to launch fewer blocks (e.g., to tune occupancy).
With 4096 blocks and a grid-stride loop, each block would process... wait, if gridDim.x = 4096 and blockDim.y = 4, then gridDim.x * blockDim.y = 16384 = num_edges. So the loop runs exactly once. It's the same.
So no benefit.
I think I'm overthinking this. Let me write the kernel and compile it. I'll also consider adding vectorized float4 loads for potentially better performance.
Here's a version with explicit float4:
```cpp
__global__ void scatter_add_kernel(
const int32_t* __restrict__ dst_idx,
const float* __restrict__ edge_feat,
float* __restrict__ out,
int num_edges,
int feat_dim
) {
int edge = blockIdx.x * blockDim.y + threadIdx.y;
int lane = threadIdx.x;
if (edge >= num_edges) return;
const float4* edge_feat4 = reinterpret_cast<const float4*>(edge_feat);
float4* out4 = reinterpret_cast<float4*>(out);
int dst = dst_idx[edge];
int f4_base = lane; // 0..15
if (f4_base < feat_dim / 4) {
float4 val = edge_feat4[edge * (feat_dim / 4) + f4_base];
float* out_ptr = out + dst * feat_dim + f4_base * 4;
atomicAdd(out_ptr + 0, val.x);
atomicAdd(out_ptr + 1, val.y);
atomicAdd(out_ptr + 2, val.z);
atomicAdd(out_ptr + 3, val.w);
}
}
```
Block: (16, 16) = 256 threads. 16 warps per block. Each warp handles 2 edges (lane 0..15 for edge 0, lane 16..31 for edge 1). Grid: ceil(16384/16) = 1024 blocks. 1024/132 = 7.8 blocks per SM. 7.8 * 16 warps = 124.8 warps... wait, 1024 blocks * 16 warps = 16384 warps total. No, per block, 16 warps. 1024 blocks total. Max concurrent blocks per SM = 2048/256 = 8. 8 * 16 = 128 warps... but max warps per SM is 64! So only 4 blocks * 16 warps = 64 warps can run concurrently. 4 blocks per SM. 1024 / 4 / 132 = 1.94 waves. Wait, 1024 blocks / 132 SMs = 7.76 blocks per SM. Max concurrent = 4 blocks. So ~2 waves per SM.
In this kernel, each thread does 1 float4 load and 4 atomicAdd. The total atomic operations is the same (1,048,576), but each warp only has 16 active threads. The other 16 threads in the warp are masked off. So we have 50% thread utilization within each warp. However, warps still execute with 50% active threads. On Ampere/Hopper, this still issues the full warp, but only half the threads do work. This means 50% of the SIMD lanes are wasted.
On the other hand, the simple (64, 4) kernel has 100% thread utilization: every thread does 1 load and 1 atomic.
So the simple (64, 4) kernel is more efficient in terms of SIMD utilization. The float4 kernel has fewer instructions (1 load + 4 atomics vs potentially more if the compiler doesn't vectorize), but wastes 50% of the threads.
Given that the simple (64, 4) kernel can achieve 100% occupancy with 100% thread utilization and no alignment assumptions, I think it's the best choice.
But wait, in the simple (64, 4) kernel, a warp actually has 32 threads active (threadIdx.x=0..31, threadIdx.y=const). All 32 threads do work. So 100% warp utilization.
Actually, let me verify: in block(64, 4), threads 0..63 in x, 0..3 in y. Total = 256 threads = 8 warps.
Warp 0: threadIdx.x=0..31, threadIdx.y=0
Warp 1: threadIdx.x=32..63, threadIdx.y=0
Warp 2: threadIdx.x=0..31, threadIdx.y=1
Warp 3: threadIdx.x=32..63, threadIdx.y=1
...
All threads in all warps are active (assuming edge < num_edges and feat < feat_dim). Yes, 100% utilization.
This is excellent. I'll use this configuration.
One more thing: I need to ensure the grid and block dimensions don't exceed CUDA limits.
- blockDim.x = 64 < 1024
- blockDim.y = 4 < 1024
- blockDim.z = 1
- blockDim.x * blockDim.y = 256 < 1024
- gridDim.x = (16384 + 3) / 4 = 4096. CUDA grid x max is ~2^31-1. Fine.
Now let me think about a subtle issue: shared memory bank conflicts. We aren't using shared memory. Good.
Another issue: the kernel might be compiled with too high register usage, limiting occupancy. I'll add `__launch_bounds__(256)` to help the compiler.
```cpp
__launch_bounds__(256)
__global__ void scatter_add_kernel(...)
```
This tells the compiler that the block size is at most 256, so it can optimize register allocation.
Actually, __launch_bounds__(maxThreadsPerBlock, minBlocksPerMultiprocessor). If I want to guarantee at least 8 blocks per SM, I can use `__launch_bounds__(256, 8)`. This limits registers to 64K/8/256 = 32 registers per thread. That should be plenty for this kernel (maybe 10-16 registers). Let me try this.
With 32 registers per thread, the compiler might spill, but with so few variables, it's unlikely. Let's see.
Wait, `__launch_bounds__(256, 8)` means the kernel must support 8 blocks per SM with 256 threads each. Total threads = 2048, which is the max. So register limit = 65536 / (8 * 256) = 32 registers.
If the kernel needs more than 32 registers, the compiler will spill to local memory. Local memory might hurt performance a bit but could still be okay. However, for such a simple kernel, I doubt it needs more than 20 registers.
Let me estimate registers:
- dst_idx pointer: 1
- edge_feat pointer: 1
- out pointer: 1
- num_edges: 1
- feat_dim: 1
- edge: 1
- feat: 1
- dst: 1
- val: 1
- Some temporaries: maybe 3-4
Total: ~12-16 registers. So 32 is plenty.
Let me try with `__launch_bounds__(256, 8)` to ensure the compiler keeps register count low.
Actually, you know what, let me just write the kernel without launch bounds first, compile, and see.
Let me write the full kernel file now.
One more consideration: the reference code creates `out = torch.zeros(...)` and calls `out.index_add_(0, dst_idx.long(), edge_feat)`. Our code should create the output similarly. The `edge_feat.dtype` is float32. `edge_feat.device` is whatever the input is on.
In `ModelNew.forward`, I should handle the dtype and device properly.
Also, importantly: the input `dst_idx` to `index_add_` in PyTorch is a LongTensor (int64), but in get_inputs it's int32. The reference does `.long()` to convert it. Our kernel expects int32, which is what get_inputs returns. But we need to be careful: the evaluator might call `ModelNew.forward` with the actual inputs, which are int32. So we can use them directly.
However, if someone passes an int64 dst_idx to ModelNew (even though get_inputs returns int32), we should handle it. But the evaluator likely uses get_inputs directly. I'll handle both int32 and int64.
Actually, looking at the reference more carefully:
```python
out.index_add_(0, dst_idx.long(), edge_feat)
```
The `dst_idx.long()` converts to int64. But get_inputs already creates int32. Why does the reference convert to long? Because PyTorch's index_add_ expects LongTensor for indices.
In our custom kernel, we can accept int32 directly, which avoids the conversion. This is already a small win.
Let me write the code now. I'll also make sure to handle different index types with a template.
```cpp
template <typename index_t>
__launch_bounds__(256, 8)
__global__ void scatter_add_kernel(
const index_t* __restrict__ dst_idx,
const float* __restrict__ edge_feat,
float* __restrict__ out,
int num_edges,
int feat_dim
) {
int edge = blockIdx.x * blockDim.y + threadIdx.y;
int feat = threadIdx.x;
if (edge < num_edges && feat < feat_dim) {
int dst = dst_idx[edge];
float val = edge_feat[edge * feat_dim + feat];
atomicAdd(&out[dst * feat_dim + feat], val);
}
}
```
Then dispatch based on dst_idx dtype.
Actually, for dispatch, we can use ATen dispatch macros or simple if-else.
```cpp
torch::Tensor scatter_add_forward(torch::Tensor dst_idx, torch::Tensor edge_feat, int num_nodes) {
dst_idx = dst_idx.contiguous();
edge_feat = edge_feat.contiguous();
int num_edges = edge_feat.size(0);
int feat_dim = edge_feat.size(1);
auto out = torch::zeros({num_nodes, feat_dim}, edge_feat.options());
dim3 block(feat_dim, 4);
dim3 grid((num_edges + block.y - 1) / block.y);
AT_DISPATCH_INDEX_TYPES(dst_idx.scalar_type(), "scatter_add", [&] {
scatter_add_kernel<<<grid, block>>>(
dst_idx.data_ptr<scalar_t>(),
edge_feat.data_ptr<float>(),
out.data_ptr<float>(),
num_edges,
feat_dim
);
});
return out;
}
```
Wait, AT_DISPATCH_INDEX_TYPES only handles kInt, kLong. Our dst_idx is int32, which maps to kInt. But the scalar type name is `at::kInt` or `torch::kInt32`? In PyTorch, `torch.int32` is `at::kInt`.
But `AT_DISPATCH_INDEX_TYPES` might not include `kInt`... actually, looking at PyTorch source, `AT_DISPATCH_INDEX_TYPES` handles `at::ScalarType::Int` and `at::ScalarType::Long`.
Wait, I should check. `AT_DISPATCH_INDEX_TYPES` is typically used for index tensors. It handles `at::kInt` and `at::kLong`. Yes.
But in `load_inline`, we might not have access to all these macros easily? Actually we do, since we include `<torch/extension.h>`.
Actually, a simpler approach: just check the dtype and cast if needed.
```python
if dst_idx.dtype != torch.int32:
dst_idx = dst_idx.to(torch.int32)
```
This avoids C++ template dispatch. But it adds a copy kernel. However, for int32 inputs, it's a no-op. For int64 inputs, it's a small overhead.
The original get_inputs returns int32, so we're fine. I'll do the simple Python cast.
Now let me think about edge cases:
- What if feat_dim is not a multiple of warp size? Our kernel works fine as long as feat_dim <= 1024 (since blockDim.x = feat_dim, and blockDim.x max is 1024). For feat_dim > 1024, we'd need to tile features.
- What if num_edges = 0? Grid would be 0, which is okay (kernel doesn't launch).
- What if num_nodes is small? Still fine.
Let me write the complete code and compile.