reasoning
Let me analyze the current kernel and try to optimize it. The current implementation:
1. Each thread handles one atom (point), loops over all features
2. Uses atomicAdd for accumulation into shared voxel grid
Main issues with the current approach:
- Many atomicAdd operations to potentially the same voxels can cause contention
- The for loop over C features isn't coalesced well (features are stored as [B, N, C] so adjacent threads access the same feature dimension but different atoms - actually this is fine for read, but writes use atomicAdd on scattered addresses)
Let me think about better optimizations:
1. **Thread coarsening / block-level aggregation**: Instead of each thread doing atomics to global memory, we can have threads in a block cooperate to aggregate features into shared memory first, then write to global memory. This is especially useful when multiple atoms from the same batch map to the same voxel.
2. **Feature-wise parallelization**: Launch threads where each thread handles one (b, n, c) element. This avoids the loop over C but increases the number of atomic operations by C times.
3. **Warp-level aggregation**: For the case where multiple atoms in a warp map to the same voxel, we could use warp reductions before atomics.
Actually, given the H100 has very fast FP32 atomics, let me think about what would be most efficient.
For the given config:
- B=4, N=256, C=32, G=32
- Total atoms = 1024
- Total output voxels per batch = 32 * 32 * 32 * 32 = ~1M floats
- Average atoms per voxel = 256 / 32768 = 1/128 per batch... wait no, 256 atoms distributed over 32768 voxels, so many voxels are empty. But atoms cluster around the center, so most atoms might map to a small region.
Actually the coords are centered and then quantized. The coords are normally distributed with std=5. With voxel_size=1.0 and G=32, the grid spans [-16, 16] around the center. With std=5, most atoms are within ±15, so they do cluster around the center.
Wait - I mean the shifted coords go from 0 to 32. Most atoms should be near the center of the grid since coords have mean 0 and std=5. So they'd cluster around x,y,z ≈ 16. That means many atoms could map to the same or nearby voxels.
For optimization strategies, let me think about coarsened shared memory approach:
Option A: Process atoms in tiles. For each tile of atoms:
- Load their voxel indices and features into shared memory
- Sort or bucket them by voxel
- Aggregate within shared memory
- Write to global memory
This is complex. Let me try a simpler approach with better memory access patterns.
Actually, looking at the reference more carefully:
- Features: (B, N, feat_dim) = (4, 256, 32)
- Linear layer just does a matmul: (4, 256, 32) @ (32, 32)
The main bottleneck in the reference is likely the `index_put_` in a Python loop over feat_dim, where each iteration does a scatter-add to a 4D tensor. This is very slow in PyTorch.
For our CUDA kernel, the approaches could be:
1. One thread per atom, loop over C (current implementation) - good for reducing atomics
2. One thread per (atom, feature) - more parallelism but more atomics
With B=4, N=256, we only have 1024 threads. For H100 which can run tens of thousands of threads, this is severely underutilized. The loop over C=32 means each thread does 32 iterations, but this isn't enough to hide latency.
Let me try approach 2: one thread per (b, n, c) element. This gives 1024 * 32 = 32768 threads, much better occupancy.
Actually, even better - let me try a block-based approach where:
- Each block handles a subset of atoms
- Threads in a block cooperate via shared memory when they map to the same voxel
But this gets complicated. Let me start with the simpler approach: one thread per (b, n, c).
Wait, actually the issue is that multiple atoms might map to the same voxel, and if we're doing one thread per feature, we get C times more atomics. Given the sparse occupancy, maybe the contention isn't too bad.
Let me also think about whether we need TILED processing. Since the grid is 32x32x32=32768 voxels and we have 256 atoms per batch, on average each batch has ~0.008 atoms per voxel. But since atoms cluster around center, local density could be higher. Even in worst case, maybe 5-10 atoms per voxel in dense regions? With C=32, that's 160-320 atomic adds per voxel, which isn't terrible on H100.
Let me try the thread-per-element approach and see if it's faster.
Another idea: we can use half-precision atomics or vectorized atomics. H100 supports vectorized atomics.
Let me also think about using warp-level aggregation. If multiple threads in a warp write to the same voxel, we can use warp-level shuffle to reduce before atomicAdd.
Actually, let me consider grouping by voxel within a warp. This is complex to implement but could be very effective.
For a simpler optimization: let me batch the atomics by having one thread handle 4 features at a time (or use float4). The features are contiguous in memory, so a thread can read float4.
Let me try approach: one thread per (b, n, c/4) reading 4 floats at a time and using 4 atomics. This gives 8192 threads and better memory bandwidth.
Actually, let me think about this differently. What if I assign one warp (32 threads) to handle one atom, with each thread in the warp handling one feature channel? This way, within the warp, the threads are accessing the same voxel but different output channels. These atomics won't conflict because they're to different addresses.
With 256 atoms per batch and 4 batches, that's 1024 warps. We can launch 1024 warps = 32768 threads, each warp handling one atom.
This is actually perfect! Each warp handles one atom:
- Thread 0 handles feature channel 0
- Thread 1 handles feature channel 1
- ...
- Thread 31 handles feature channel 31
All threads in the warp compute the same voxel index but atomicAdd to different offsets in the output (differing by G*G*G). Since these are to different memory locations, there's no contention within the warp.
This should give great memory coalescing for both reading features and writing outputs.
Let me implement this. I'll use a 1D grid where each thread block has 128 or 256 threads, and each warp (32 threads) handles one atom. With 128 threads per block, that's 4 atoms per block. With 256 threads, that's 8 atoms per block.
Actually, let me just use 32 threads per atom = 1 warp. Block size = 128 = 4 warps = 4 atoms per block.
Total atoms = 1024, so we need 1024/4 = 256 blocks.
Let me write this kernel:
```cuda
__global__ void voxel_pool_kernel_warp(
const float* __restrict__ coords,
const float* __restrict__ features,
const float* __restrict__ center,
float* __restrict__ out,
int B, int N, int C, int G, float voxel_size)
{
int atom_idx = blockIdx.x * (blockDim.x / 32) + threadIdx.x / 32;
int total_atoms = B * N;
if (atom_idx >= total_atoms) return;
int lane = threadIdx.x % 32;
int b = atom_idx / N;
int n = atom_idx % N;
// Compute voxel index (done redundantly by all threads in warp)
int coord_offset = (b * N + n) * 3;
float x = coords[coord_offset];
float y = coords[coord_offset + 1];
float z = coords[coord_offset + 2];
int center_offset = b * 3;
float cx = center[center_offset];
float cy = center[center_offset + 1];
float cz = center[center_offset + 2];
float inv_voxel = 1.0f / voxel_size;
int ix = (int)((x - cx) * inv_voxel + G * 0.5f);
int iy = (int)((y - cy) * inv_voxel + G * 0.5f);
int iz = (int)((z - cz) * inv_voxel + G * 0.5f);
ix = max(0, min(G - 1, ix));
iy = max(0, min(G - 1, iy));
iz = max(0, min(G - 1, iz));
int voxel_offset = ix * G * G + iy * G + iz;
int out_base = b * C * G * G * G + voxel_offset;
int feat_offset = (b * N + n) * C;
if (lane < C) {
float val = features[feat_offset + lane];
atomicAdd(out + out_base + lane * G * G * G, val);
}
}
```
Wait, I can also process C in chunks if C > 32. But here C=32 so one warp is enough.
Actually, is there a risk of bank conflicts? The output for different feature channels are spaced by G*G*G = 32768 floats = 131072 bytes. So adjacent threads write to addresses 131072 bytes apart. These won't be in the same cache line, which might actually hurt cache efficiency but avoids contention.
Actually, let me think about cache lines. On H100, L2 cache line is 128 bytes = 32 floats. So if lane 0 writes to address A, lane 1 writes to A + 131072. These are far apart and won't share cache lines. So no false sharing within a warp, which is good for performance.
Let me also consider another optimization: can I use `__shfl_sync` to avoid redundant loads from global memory for coords? Actually, each warp loads the same coords, but the compiler might optimize this via L1 cache. Let me explicitly use warp shuffles.
Hmm, actually warp shuffles for float loads from global memory is overkill. The coords load should hit L1 cache for threads in the same warp.
Let me try this kernel and see. I also want to try another variant where each thread handles one (b, n, c) element directly, not using a warp-per-atom organization. Let me compare.
For the simpler approach, each thread handles one (b, n, c), giving 32768 threads total. Thread blocks of 256 threads = 128 blocks.
```cuda
__global__ void voxel_pool_kernel_element(
const float* __restrict__ coords,
const float* __restrict__ features,
const float* __restrict__ center,
float* __restrict__ out,
int B, int N, int C, int G, float voxel_size)
{
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total = B * N * C;
if (idx >= total) return;
int c = idx % C;
int atom_idx = idx / C;
int b = atom_idx / N;
int n = atom_idx % N;
// ... compute voxel index ...
// atomicAdd to appropriate output channel
}
```
This has the advantage of no branching (lane < C), all threads do useful work. But different threads in a warp might map to different voxels, reducing the benefit of shared L1 for coords. However, all threads in a warp that come from the same atom will access the same coords and could benefit from L1.
Actually, with 256 threads per block and B*N=1024 atoms, we'd have threads from 4 different atoms in a warp (since C=32, 32 threads in a warp = 1 full atom). So within a warp, all threads belong to the same atom! This is perfect - all threads in a warp compute the same voxel index, then atomicAdd to different channels.
Wait, this is actually the same as my warp-based approach but with different indexing! Let me verify:
- idx = blockIdx.x * 256 + threadIdx.x
- c = idx % 32
- atom_idx = idx / 32
- For threads 0-31 in a warp: atom_idx is the same, c ranges from 0 to 31
- For threads 32-63 in a warp: atom_idx is the next one, c ranges from 0 to 31
So within each warp of 32 threads, all threads handle the same atom! This is exactly the same computation pattern as my explicit warp-based approach, but without the manual warp indexing.
The difference is just in the thread mapping. Let me just use this simpler indexing since it naturally gives warp-level coherence.
Let me implement this:
```cuda
__global__ void voxel_pool_kernel(
const float* __restrict__ coords,
const float* __restrict__ features,
const float* __restrict__ center,
float* __restrict__ out,
int B, int N, int C, int G, float voxel_size)
{
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total = B * N * C;
if (idx >= total) return;
int c = idx % C;
int tmp = idx / C;
int b = tmp / N;
int n = tmp % N;
int coord_offset = (b * N + n) * 3;
float x = coords[coord_offset];
float y = coords[coord_offset + 1];
float z = coords[coord_offset + 2];
int center_offset = b * 3;
float cx = center[center_offset];
float cy = center[center_offset + 1];
float cz = center[center_offset + 2];
float inv_voxel = 1.0f / voxel_size;
int ix = (int)((x - cx) * inv_voxel + G * 0.5f);
int iy = (int)((y - cy) * inv_voxel + G * 0.5f);
int iz = (int)((z - cz) * inv_voxel + G * 0.5f);
ix = max(0, min(G - 1, ix));
iy = max(0, min(G - 1, iy));
iz = max(0, min(G - 1, iz));
int voxel_offset = ix * G * G + iy * G + iz;
int out_idx = ((b * C + c) * G * G * G) + voxel_offset;
int feat_idx = (b * N + n) * C + c;
float val = features[feat_idx];
atomicAdd(out + out_idx, val);
}
```
Wait, is there an issue with integer division `/ C` and `/ N`? On modern GPUs, integer division by constants is optimized to multiplication+shift. Since C=32 and N=256 are powers of 2, this becomes simple bit shifts.
Actually let me recheck: `tmp / N` where N=256 = 2^8. The compiler will optimize this to `tmp >> 8`. And `idx / C` where C=32 = 2^5 becomes `idx >> 5`. But wait, `idx % C` where C=32 = 2^5 becomes `idx & 31`. So all these are just bit operations.
Good, the indexing should be very fast.
Let me think about L2 cache behavior. For the output, each atom maps to one voxel, and all 32 feature channels for that voxel are at addresses:
`out + b*C*G^3 + c*G^3 + voxel_offset` for c=0..31
These are spaced by G^3 = 32768 floats = 128 KB. On H100, the L2 cache is large (50MB+), so all these will fit easily. However, each atomic is to a different 128B cache line (since 128B = 32 floats, and we're spaced by 32768 floats). So each atomic touches a different cache line.
Actually, if I reorder the output to be [B, G, G, G, C] instead of [B, C, G, G, G], then adjacent feature channels for the same voxel would be adjacent in memory. Let me think about whether this helps...
In the reference, the output is `(B, feat_dim, G, G, G)` which is [B, C, G, G, G]. If I keep this layout, then for a single voxel, the 32 features are spaced 128KB apart. If I were to use [B, G, G, G, C], they'd be adjacent.
But the problem says we need to match the reference output format. So I must produce [B, C, G, G, G]. I can't change the layout.
Can I write atomics in a coalesced way? No, because the output layout inherently spaces the channels far apart.
Wait, what if I use shared memory as an intermediate buffer? For each warp, I could accumulate 32 features for the voxel in shared memory (32 floats = 128 bytes), then write them out atomically. But since each warp handles a different atom (and likely a different voxel), I'd need 128 bytes per warp. For 256 threads/block = 8 warps, that's 1024 bytes of shared memory. Not bad!
But actually, within a warp, each thread handles one feature and writes to a different global address. If I instead have them write to adjacent shared memory addresses first, there's no atomic needed in shared memory (since each thread writes to a different address). Then I'd need to flush shared memory to global with atomics. But since each warp handles a different voxel, I don't actually need atomics for the shared memory phase.
Wait, but different warps in the same block might handle atoms that map to the same voxel! This is where shared memory atomics could help. Let me think...
With 256 threads/block = 8 warps = 8 atoms per block. If 2 or more atoms in the same block map to the same voxel, their shared memory accumulation would need to be combined.
Actually let me reconsider. A simpler approach:
1. Each warp has 32 threads, each handling one feature channel of one atom
2. We compute the voxel index
3. We load the feature value
4. We write it to shared memory at `smem[warp_id * 32 + lane]`
5. Then we need some way to combine warps that hit the same voxel
This gets complicated because we'd need to sort or bucket by voxel index within the block.
Actually, for the given problem parameters, the chance of collision is probably not that high. With 256 atoms and 32768 voxels (assuming uniform distribution), P(collision) is very low. But atoms cluster around center, so let me estimate.
If atoms are normally distributed with std=5 and grid size 32 (spanning roughly ±16), then about 68% are within ±5, 95% within ±10. The center region of 10x10x10 = 1000 voxels would contain ~95% of atoms. So about 244 atoms in 1000 voxels ≈ 0.25 atoms/voxel. Still pretty sparse.
If we process 8 atoms per block, the probability that any two hit the same voxel is small. So block-level shared memory optimization might not be worth the complexity.
Let me go with the element-wise kernel. On H100, with 32768 threads doing global atomics, this should be well-utilized.
Actually, one more thought - H100 supports asynchronous copy and TMA. But for this simple kernel, basic atomics should suffice.
Let me also consider another optimization: vectorized loads. A single thread can load 4 floats at once using float4. This would reduce the number of threads needed by 4x but each thread would do 4 atomics. Given H100's strong atomic performance, this might improve memory bandwidth.
But wait, if I use float4 loads, I need the memory to be aligned. The features array has shape [B, N, C], so features[b, n, :] starts at `features + (b*N + n)*C`. If C=32 and the original tensor is aligned, then each row is aligned. Float4 requires 16-byte alignment, and since we're starting at row boundaries, this should be fine.
Let me try a vectorized version where each thread loads 4 floats and does 4 atomics:
```cuda
__global__ void voxel_pool_kernel_vec4(
const float* __restrict__ coords,
const float* __restrict__ features,
const float* __restrict__ center,
float* __restrict__ out,
int B, int N, int C, int G, float voxel_size)
{
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total_elements = B * N * C;
int vec_elements = total_elements / 4;
if (idx >= vec_elements) return;
int c4 = (idx % (C / 4)) * 4;
int tmp = idx / (C / 4);
int b = tmp / N;
int n = tmp % N;
// compute voxel index...
int feat_idx = ((b * N + n) * C + c4) / 4; // in float4 units
float4 val4 = reinterpret_cast<const float4*>(features)[feat_idx];
int out_base = ((b * C + c4) * G * G * G) + voxel_offset;
int stride = G * G * G;
atomicAdd(out + out_base, val4.x);
atomicAdd(out + out_base + stride, val4.y);
atomicAdd(out + out_base + 2 * stride, val4.z);
atomicAdd(out + out_base + 3 * stride, val4.w);
}
```
Hmm, this might be a bit complex to get right with the indexing. Let me keep it simpler and just try the element-wise approach first, then vectorize if needed.
Actually, let me first try the element-wise kernel and see if it's faster. If not, I'll try more optimizations.
Wait, I just realized an issue. In my original kernel, I had:
- Each thread handles one atom, loops over all C features
- Total threads = 1024
In the new element-wise kernel:
- Each thread handles one (atom, feature) pair
- Total threads = 32768
But the problem is that all threads in a warp that handle the same atom will redundantly compute the voxel index and load the same coordinates. This wastes some compute but since it's only a few operations per thread, the overhead is minimal.
Actually, there's a better way: have the first thread in each warp load and broadcast the coords and voxel index using warp shuffles. But the compiler might already optimize the redundant loads via L1 cache.
Let me just implement the element-wise kernel and test it.
One more consideration: occupancy. With 32768 threads, each using few registers, we can have many blocks running concurrently on the SMs. On H100 with 132 SMs (I need to check - actually H100 SXM has 132 SMs, PCIe has 114 SMs), 32768 threads / 132 SMs ≈ 248 threads per SM. With a max of 2048 threads per SM, this is about 12% occupancy. Hmm, that's not great.
If I use 256 threads per block, that's 128 blocks total. On 132 SMs, most SMs get 1 block, some get 0. That's very low occupancy.
What if I use smaller blocks? Like 128 threads per block = 256 blocks. Then on 132 SMs, most get 2 blocks. Still not great, but better.
Actually, for kernels with atomics, high occupancy is important to hide latency. Let me think about how to increase thread count.
Option 1: Process C in tiles. If C=32, each thread processes 4 features, so we get 32768/4 = 8192 elements. With 128 threads/block, that's 64 blocks. That's even worse!
What I actually need is more total work for the GPU. Since B=4, N=256, C=32 is fixed, maybe I should just accept low thread count and focus on efficiency per thread.
Actually wait, I can increase thread count by having each thread process fewer elements but doing so in a way that increases parallelism... but we can't change the data size.
Hmm, on second thought, for this specific problem, B*N*C = 4*256*32 = 32768. That's actually not too bad. With 32768 threads, if we use 128 threads per block, we have 256 blocks. On H100 with up to 32 blocks per SM, a single SM could theoretically run all 256 blocks if registers/shared memory allow. The occupancy is limited by thread count per SM (max 2048), so each SM can run up to 16 blocks of 128 threads. With 256 blocks / 132 SMs ≈ 2 blocks per SM, that gives 256 threads per SM, which is well below the max.
But actually, each block only runs on one SM at a time. When a block finishes, another block can be scheduled. With 256 blocks and 132 SMs, after all SMs finish their first block, there are 124 blocks left, so most finish a second block, and 8 SMs finish a third. The average occupancy during execution might be around 2-3 blocks per SM, giving 256-384 threads per SM. That's about 12-19% of max occupancy.
To improve this, maybe I need to make each thread do more work (increase arithmetic intensity) while keeping the same total thread count. Actually, no - more threads per SM would help hide memory latency.
Wait, but the kernel is atomic-bound, not compute-bound. Atomic operations have high latency and can be pipelined, so having more warps helps. But we can't create more threads than elements.
One approach: tile the voxels in shared memory. For example, each block handles a sub-region of the grid. Load all atoms that map to this sub-region, accumulate in shared memory, then flush to global. This naturally increases parallelism because we process the grid in tiles, not atoms.
But this requires first knowing which atoms belong to which tiles, which either needs a pre-processing pass or atomics to assign atoms to tiles.
Alternative: use a sorting-based approach.
1. Compute voxel index for all atoms
2. Sort atoms by voxel index
3. Each block processes a range of sorted atoms, using shared memory for accumulation
This is similar to what optimized histogram kernels do. But it requires a sort, which has its own overhead.
For a simple voxel pooling kernel, let me first try the element-wise approach and see the timing. If it's already faster than reference, then good. If not, I'll try more advanced optimizations.
Actually, I realize I should check the reference timing first. The reference does:
1. Linear: matrix multiply (4, 256, 32) @ (32, 32) - this is fast on GPU
2. Compute center, shift, clamp - fast
3. Loop over 32 feature dimensions doing index_put_ with accumulate=True - this is SLOW because each index_put_ is a separate kernel launch and does a lot of indexing work
So the reference bottleneck is definitely step 3. Even a naive CUDA kernel that does the same work in a single kernel should be much faster.
Let me implement the element-wise kernel and run timing through submit_kernel, but actually I can't see timing until I submit. Let me first compile and check correctness.
Wait, I can use run_correctness but it only checks correctness, not timing. To see timing, I need to submit. But I only get one submit that ends the session. Hmm.
Actually the instructions say "Use the tools the way you would run an iteration loop by hand: write a kernel, compile it, check correctness, change something, repeat, submit." So I should make my best effort and submit when confident.
Let me try a more optimized version. I'll implement:
1. Element-wise kernel with warp-level coord sharing
2. Maybe shared memory accumulation for same-voxel atoms in the same warp
Actually, let me try another approach: use per-warp shared memory to batch atomics. If multiple threads in a warp map to the same voxel (which happens when multiple atoms in a warp hit the same voxel), we can use warp-level reduction before atomicAdd. But with 32 threads per atom, all threads in a warp map to the same atom and thus the same voxel. So warp-level reduction doesn't help here.
Let me try block-level shared memory. Each block has 256 threads = 8 warps = 8 atoms. I can have each warp write its features to shared memory, then after all warps are done, I can write from shared memory to global. But since each warp handles a potentially different voxel, I can't combine them in shared memory unless multiple warps hit the same voxel.
For the case where multiple warps DO hit the same voxel, I could add a second phase where I check for collisions and combine. But this adds complexity.
Let me try a completely different approach: grid processing instead of atom processing.
For a grid of G=32, I can tile it into sub-grids. For example, 4x4x4 tiles = 64 tiles of 8x8x8 voxels each. Each block handles one tile:
1. Load all atoms in the batch
2. Check which atoms fall into this tile
3. For atoms in the tile, accumulate their features into shared memory (size 8x8x8x32 floats = 16384 floats = 64KB)
4. Flush to global memory
This has great locality but requires loading all atoms for each tile. With 64 tiles per batch and B=4 batches, that's 256 blocks. Each block loads 256 atoms * 3 coords * 4 bytes = 3KB and 256 atoms * 32 features * 4 bytes = 32KB. The coords can be kept in registers or shared memory. But with 3KB + 32KB = 35KB and 64KB output, that's 99KB shared memory per block. H100 has 228KB per SM, so we can run 2 blocks per SM.
But loading all atoms in each block is redundant work - 256 blocks * 256 atoms = 65536 atom loads, but there are only 1024 atoms total! That's 64x redundant reads. Not good.
A better grid-based approach: first sort atoms by voxel index, then each block processes a contiguous chunk.
Let me try yet another idea: use the sorted approach with a pre-pass to count atoms per voxel, then a prefix sum to allocate space, then a scatter to write atom features to contiguous buffers. But this is basically a full sort and is probably overkill.
OK, let me go with the element-wise atomic approach. It's simple and should be much faster than the Python loop. I'll also add some optimizations:
1. Compute center and offset the coords on the CPU? No, center is per-batch.
2. Use vectorized float4 loads
3. Minimize divergence
Actually, let me think about float4 again. If each thread loads 4 features and does 4 atomics:
- Total threads = 32768 / 4 = 8192
- Threads per block = 128
- Blocks = 64
That's even worse occupancy! Unless I also have each thread handle multiple atoms.
What if each thread handles a strip of 4 atoms * 4 features = 16 elements?
- Total threads = 32768 / 16 = 2048
- Threads per block = 128
- Blocks = 16
That's terrible for occupancy.
What if each thread handles 1 atom (all 32 features) but I launch with 1024 threads? That's my original kernel.
The tradeoff is:
- Original: 1024 threads, each does 32 sequential atomics. Good atom coalescing within thread, minimal thread count.
- Element-wise: 32768 threads, each does 1 atomic. Great parallelism but atomics are more scattered.
On H100, which has very fast FP32 atomics, the element-wise approach might be better because it can run more warps concurrently, hiding latency. But with only 32768 threads, occupancy is limited.
Let me check: H100 has 132 SMs, each can run up to 2048 threads. Total capacity = 132 * 2048 = 270,336 threads. We're using 32768, which is 12% of capacity. Not great, but not terrible.
I think the key insight is that regardless of occupancy, a single CUDA kernel doing all the work is much better than 32 separate PyTorch index_put_ operations.
Let me implement a clean element-wise kernel and also try to optimize center computation.
Actually, one more optimization: instead of computing center on CPU (via PyTorch mean), I can compute it in the kernel. And instead of loading center from global memory, each warp can compute it redundantly. But computing mean requires N reductions per batch... that's expensive.
Or even better: don't subtract the mean at all in the kernel. Just center the coords on the CPU, pass centered coords to the kernel.
Wait, the center is `coords.mean(dim=1, keepdim=True)`. In the reference, this is computed and then subtracted. In my kernel, I compute center and pass it. What if I just center the coords before calling the kernel?
Actually, I can use a separate kernel or PyTorch to compute the center and subtract it. This avoids passing center to the kernel and loading it.
Let me modify: on the CPU, compute `shifted = (coords - center) / voxel_size + G/2.0`, get indices, and pass only indices to the kernel. But the reference does center + shift + clamp, and I need to match numerically.
Actually, the center computation, shift, and clamp are very fast in PyTorch. The bottleneck is the index_put_ loop. So I can keep the preprocessing in PyTorch and just replace the index_put_ loop with a CUDA kernel.
But wait, if I do the preprocessing in PyTorch and just do the atomic scatter in CUDA, that's even simpler:
```python
def forward(self, coords, features):
B, N, _ = coords.shape
G = self.grid_size
features = self.linear(features)
center = coords.mean(dim=1, keepdim=True)
shifted = (coords - center) / self.voxel_size + G / 2.0
indices = shifted.long().clamp(0, G - 1)
out = torch.zeros(B, self.feat_dim, G, G, G, device=coords.device, dtype=coords.dtype)
# Call kernel with coords, indices, features, out
...
```
Then in the kernel, I just:
1. Get b, n from thread index
2. Load ix, iy, iz from indices
3. For each feature c, atomicAdd out[b, c, ix, iy, iz] += features[b, n, c]
This avoids center computation and coordinate transformation in the kernel.
But I can also do everything in the kernel to save the center computation and indexing overhead. The PyTorch overhead for center + shift + clamp is small.
Let me just keep it all in the kernel for simplicity. Actually, let me move the center computation out and pass shifted coords to reduce kernel complexity.
Hmm, but if I pass shifted coords (before clamp), the kernel still needs to clamp. If I pass indices, the kernel is simpler but I need to allocate an indices tensor.
Let me try a hybrid: compute indices in PyTorch, then pass coords (for center), indices, and features to the kernel. Wait, if I compute indices in PyTorch, I don't need coords in the kernel.
Actually, let me just do everything in the kernel to minimize data movement. The center computation in PyTorch is fine.
OK here's my plan:
1. Compute features, center, shift, clamp in PyTorch (fast)
2. Pass shifted coords and features to a CUDA kernel
3. Kernel computes the rest
Actually wait, if I pass shifted coords (the float values), each thread does the clamp. If I pass int indices, no clamp needed. But int indices are smaller (3 ints per atom vs 3 floats).
I'll pass the shifted coords as floats and do clamp in the kernel. This avoids an extra PyTorch operation to create an int tensor.
No wait, in the reference:
```python
shifted = (coords - center) / self.voxel_size + G / 2.0
indices = shifted.long().clamp(0, G - 1)
```
If I replicate this exactly, I need to compute center first. Let me just compute center on CPU, then pass center and coords to kernel, and have kernel do the exact same arithmetic.
Actually, a subtle issue: `.long().clamp(0, G-1)` in PyTorch truncates toward zero (same as C's `(int)` cast for positive numbers). After centering and adding G/2, the values should be mostly positive. For very negative values, truncating toward zero gives 0, which is then clamped. In CUDA, `(int)float_val` also truncates toward zero. So they match.
OK let me write the optimized kernel. I'll use the element-wise approach.
Actually, I just realized something important. Let me re-read the reference:
```python
for d in range(self.feat_dim):
feat_d = features[:, :, d]
voxel_grid[:, d].index_put_(
(batch_idx.reshape(-1), ix.reshape(-1), iy.reshape(-1), iz.reshape(-1)),
feat_d.reshape(-1),
accumulate=True,
)
```
The `index_put_` operation does a scatter-add (since `accumulate=True`). In PyTorch, this maps to something like `torch.scatter_add_` internally.
My CUDA kernel replaces all of these with a single kernel launch.
Let me also think about whether I should use `torch.scatter_add_` directly instead of writing a custom kernel. Something like:
```python
# Flatten everything
flat_features = features.reshape(-1, self.feat_dim) # (B*N, C)
flat_indices = torch.stack([batch_idx.reshape(-1), ix.reshape(-1), iy.reshape(-1), iz.reshape(-1)], dim=-1) # (B*N, 4)
# For each feature channel, scatter add
for d in range(self.feat_dim):
voxel_grid[:, d].scatter_add_(0, flat_indices[:, 0], flat_features[:, d]) # But this doesn't work with 4D indices...
```
Actually, `torch.scatter_add_` with multi-dimensional indices is tricky. There isn't a direct PyTorch equivalent for `index_put_` with accumulate on a 4D tensor that works in one go for all channels.
You'd need to convert 4D indices to 1D linear indices:
```python
linear_idx = ((batch_idx * G + ix) * G + iy) * G + iz
out_1d = voxel_grid.reshape(B * C * G * G* G)
out_1d.scatter_add_(0, linear_idx + d * B * G*G*G, flat_features[:, d])
```
But this still needs a loop over d.
Actually, PyTorch has `scatter_add` that can handle a source dimension:
```python
voxel_grid = torch.zeros(B, C, G, G, G)
linear_idx = ... # (B, N)
# scatter_add_ for dim=1 or something
```
Hmm, I don't think there's a clean one-liner. A custom CUDA kernel is definitely the way to go.
OK let me finalize the element-wise kernel. Actually, I want to also try shared memory optimization for when multiple atoms in a block map to the same voxel. This is called " privatization " in histogram algorithms.
Here's the idea:
- Each block has an output buffer in shared memory for a subset of voxels
- But the full output is 32 * 32 * 32 * 32 = 1M floats = 4MB, too big for shared memory
- Instead, tile the output: each block handles only a small subgrid, and only atoms that map to this subgrid are processed
For subgrid size 4x4x4 and C=32: shared memory = 4*4*4*32*4 bytes = 8KB per block. Very manageable!
But again, we need to know which atoms map to which subgrid tiles. One approach:
1. For each batch, divide the grid into tiles (e.g., 8x8x8 tiles per grid for 32x32x32)
2. For each tile, launch a block
3. In the block, each thread iterates over all atoms and checks if they fall in this tile
4. If yes, accumulate in shared memory
5. At the end, write shared memory to global
Step 3 is expensive: each block scans all atoms. With 512 tiles per batch (8*8*8) and 4 batches = 2048 blocks, each scanning 256 atoms. That's 2048*256 = 524,288 atom checks for 1024 atoms. Wasteful, but maybe the cache helps.
Actually, with B=4 and 8x8x8=512 tiles, total blocks = 2048. Each block loads 256 atoms. If we cache coords in shared memory (256*3*4=3KB), that's very small. Then each thread in the block processes a few atoms.
Wait, actually the total atoms per batch is only 256. If we load all 256 atom coords into shared memory (3KB), then each thread can check all of them against the tile. But we'd still need to load features too (32KB), which is larger but still fits in shared memory (35KB total). Then 8KB for output. Total 43KB, well within 228KB limit.
But with 2048 blocks and only 132 SMs, each SM runs ~15 blocks sequentially. Each block does 256 atom checks, so each SM does 3840 checks. This is definitely more work than the atom-centric approach, but with better locality.
For a simpler kernel, let me not do this. The atom-centric kernel should be fast enough.
Let me think about another optimization: use half2 or float2 atomics where possible. H100 supports float2 atomicAdd. With C=32, I can do 16 float2 atomics per thread instead of 32 float atomics. This halves the number of atomic operations.
```cuda
__device__ float2 atomicAddFloat2(float2* address, float2 val) {
atomicAdd(&address->x, val.x);
atomicAdd(&address->y, val.y);
return val;
}
```
But I need the memory layout to support this. The output is [B, C, G, G, G], so channel c starts at `out + b*C*G^3 + c*G^3 + voxel_offset`. For float2, I'd need channels c and c+1 to be adjacent, but they're spaced by G^3 floats apart. So float2 atomics don't help directly with this layout.
What if I use a transposed layout in shared memory and then transpose when writing? That adds complexity.
OK, I'll go with a clean element-wise kernel. Let me also consider whether I should keep the one-thread-per-atom approach but optimize memory access.
Actually, for the one-thread-per-atom approach with a loop over C:
- Each thread loads 3 coords, 3 center values, then does 32 loads from features and 32 atomics to output
- Total threads = 1024
For the element-wise approach:
- Each thread loads 3 coords, 3 center values, 1 feature, does 1 atomic
- Total threads = 32768
The element-wise approach has 32x more threads but each thread does less work. Given that the PyTorch reference is so slow (32 separate kernel launches), either approach should be much faster.
Let me also consider: H100 has very fast L2 cache and atomics. The atomicAdd latency to L2 is about 20-30 cycles on Hopper. With 32768 threads and 132 SMs, each SM has ~248 threads. At 248 threads, can we hide atomic latency?
A warp has 32 threads. With 248 threads per SM, that's ~8 warps per SM. AtomicAdd has ~30 cycle latency. An H100 SM can issue one instruction per cycle per warp. With 8 warps, after issuing one atomic per warp, it takes 8 cycles to issue all warps, then by the time it cycles back to warp 0, 8 cycles have passed, but the atomic takes 30 cycles. So we'd need about 30 warps to fully hide atomic latency (30 warps * 1 cycle = 30 cycles, matching atomic latency).
But in practice, atomics aren't the only operation. Threads also do arithmetic and memory loads. And with 8 warps, some latency hiding happens from other instructions.
If I switch to 1024 threads (one per atom), that's ~8 threads per SM. That's less than 1 warp per SM! Each SM would be severely underutilized. So the element-wise approach is definitely better.
Wait, 1024 threads / 132 SMs = 7.75 threads per SM. That's definitely bad. With 32768 threads, it's 248 threads per SM, which is ~8 warps. Much better.
OK, I'll implement the element-wise kernel.
One more optimization: use `__ldg` for read-only data. Coords, features, and center are read-only. `__ldg` (load through read-only cache/texture cache) can help with bandwidth.
Actually, on modern GPUs (Pascal and later), regular loads are automatically cached in L1, and `__ldg` might not help much. But it doesn't hurt.
Let me also use `const __restrict__` which helps the compiler optimize.
Here's my final kernel design:
```cpp
__global__ void voxel_pool_kernel(
const float* __restrict__ coords,
const float* __restrict__ features,
const float* __restrict__ center,
float* __restrict__ out,
int B, int N, int C, int G, float voxel_size)
{
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total = B * N * C;
if (idx >= total) return;
int c = idx % C;
int atom_idx = idx / C;
int b = atom_idx / N;
int n = atom_idx % N;
int coord_offset = (b * N + n) * 3;
float x = coords[coord_offset];
float y = coords[coord_offset + 1];
float z = coords[coord_offset + 2];
int center_offset = b * 3;
float cx = center[center_offset];
float cy = center[center_offset + 1];
float cz = center[center_offset + 2];
float inv_voxel = 1.0f / voxel_size;
int ix = (int)((x - cx) * inv_voxel + G * 0.5f);
int iy = (int)((y - cy) * inv_voxel + G * 0.5f);
int iz = (int)((z - cz) * inv_voxel + G * 0.5f);
ix = max(0, min(G - 1, ix));
iy = max(0, min(G - 1, iy));
iz = max(0, min(G - 1, iz));
int voxel_offset = ix * G * G + iy * G + iz;
int out_idx = ((b * C + c) * G * G * G) + voxel_offset;
int feat_idx = (b * N + n) * C + c;
atomicAdd(out + out_idx, features[feat_idx]);
}
```
Thread configuration:
- threads = 256
- blocks = (32768 + 255) / 256 = 128
Wait, blocks = ceil(32768 / 256) = 128. That's actually a good number. With 132 SMs, most SMs get 1 block.
Hmm, 128 blocks < 132 SMs, so some SMs will be idle. What if I use 128 threads per block? blocks = 256. Then most SMs get 2 blocks. Better occupancy!
Let me use 128 threads per block. 32768 / 128 = 256 blocks.
Actually, let me check if 256 is a good block count. With 256 blocks and 132 SMs, 124 SMs get 2 blocks, 8 SMs get 1 block. When the 124 SMs finish their first 2 blocks, there are 8 blocks left. So 8 SMs get a 3rd block. Average concurrency is about 2 blocks per SM = 256 threads per SM.
What if I use 64 threads per block? blocks = 512. Each SM gets ~4 blocks = 256 threads per SM. Similar thread count but more blocks.
In general, for H100, block sizes of 128 or 256 are good. Let me stick with 128 threads per block = 256 blocks.
Actually, another concern: atomicAdd contention. With many threads hitting the same output voxel, they serialize on the atomic. But如前所述, each thread handles a different feature channel for the same atom, so they write to different output addresses. And different atoms in different warps/write to different voxels with low collision probability.
Let me compile and see.
Wait, I should also consider using `gridDim.x` with a for-loop to handle more work per thread. If I want each thread to do 4 elements instead of 1, I can have a loop:
```cpp
for (int idx = blockIdx.x * blockDim.x + threadIdx.x; idx < total; idx += gridDim.x * blockDim.x) {
// process element idx
}
```
This increases work per thread and can help if occupancy is too high (not our case). With low occupancy, having more threads is better than more work per thread.
Hmm, but in our case, total elements = 32768. If I use 128 threads and 256 blocks, that's exactly 32768 threads, so no loop needed.
Actually, what if I use fewer blocks? Say 64 blocks of 128 threads = 8192 threads. Then each thread does 4 elements in a loop. This gives fewer blocks but more work per thread. I'm not sure if this is better or worse.
On H100, having more blocks is generally better for load balancing. With 256 blocks of 128 threads, we have 256 blocks, which is close to the 132 SMs * 2 = 264 concurrent blocks (limited by thread count). This is a good balance.
Let me compile the element-wise kernel.
Actually, I just realized I should also try an even simpler approach: just use torch's built-in operations more efficiently. Can I do this without a custom kernel?
```python
def forward(self, coords, features):
B, N, _ = coords.shape
G = self.grid_size
features = self.linear(features)
center = coords.mean(dim=1, keepdim=True)
shifted = (coords - center) / self.voxel_size + G / 2.0
indices = shifted.long().clamp(0, G - 1)
# Flatten
batch_idx = torch.arange(B, device=coords.device).unsqueeze(1).expand(-1, N)
flat_batch = batch_idx.reshape(-1)
flat_coords = indices.reshape(-1, 3)
flat_features = features.reshape(-1, self.feat_dim)
# Compute linear indices
linear_idx = ((flat_batch * G + flat_coords[:, 0]) * G + flat_coords[:, 1]) * G + flat_coords[:, 2] # (B*N,)
# Expand for all feature dims
# linear_idx: (B*N,) -> want (B*N, feat_dim)
# flat_features: (B*N, feat_dim)
voxel_grid = torch.zeros(B, self.feat_dim, G, G, G, device=coords.device)
# Flatten output to (B*feat_dim*G*G*G,)
flat_out = voxel_grid.reshape(-1)
# For feature d, output_offset = d * B*G*G*G + batch*G*G*G + linear_voxel
# = (d * B + batch) * G*G*G + linear_voxel
# scatter_add_ requires index and src to be 1D
for d in range(self.feat_dim):
out_offset = d * B * G * G * G
src = flat_features[:, d]
idx_d = linear_idx + out_offset # This doesn't work because scatter_add uses idx to index src too
```
Actually, `scatter_add_` index means `out[idx[i]] += src[i]`. So I need:
`idx = d * B * G*G*G + flat_batch * G*G*G + linear_voxel`
= `(d * B + flat_batch) * G*G*G + linear_voxel`
```python
for d in range(self.feat_dim):
idx = (d * B + flat_batch) * (G*G*G) + linear_idx
flat_out.scatter_add_(0, idx, flat_features[:, d])
```
This still needs a loop but uses native scatter_add_ instead of index_put_. It might be faster because it's a single operation per iteration instead of the complex indexing in index_put_.
But a custom CUDA kernel with a single launch is still better than 32 launches.
Let me implement the element-wise kernel. I'll also add some optimizations:
1. Use float4 for feature loads when possible
2. Unroll loops where possible
Actually, for this specific case where C=32 is fixed, let me check if I can hardcode C=32 and use template parameters. But C is configurable in the constructor. Let me use a dynamic approach.
One more idea: since the output is huge (1M floats per batch = 4MB), and most voxels are empty, we're doing a lot of atomics to mostly-zero locations. Actually, no - we're doing atomics to arbitrary locations, but many of those locations will never be written to.
The memory footprint of the output is 4 * 32 * 32^3 * 4 bytes = 16.7 MB. This easily fits in GPU memory.
Let me also check if `atomicAdd` to zero-initialized memory has any special fast path. On modern NVIDIA GPUs, atomics to global memory go through L2 cache, so if the cache line is cold, it needs to be fetched first. This adds latency. But since we're writing to scattered locations, cache efficiency is low regardless.
Could I use a dense hash table or sort-based approach instead? As discussed, probably overkill.
OK, let me write the kernel. I'll make a few variants in my head but submit only one.
Actually, wait - I just realized something. In the original kernel that compiled and passed correctness:
- `compile_kernel` compiled successfully
- `run_correctness` passed with 5/5 trials
- Max abs err = 2.38e-07, which is very small (likely from float32 atomic order-of-operations differences)
So the original kernel was already correct! I just need to optimize it for speed.
Let me think about why the original one-thread-per-atom kernel might be slow:
- Only 1024 threads total
- Each thread does 32 loads and 32 atomics sequentially
- Very low occupancy
Let me try the element-wise kernel which has 32x more threads. This should significantly improve performance.
Also, I can vectorize the feature loads in the element-wise kernel. Since features are stored as [B, N, C] with C=32, and each warp handles all C features for one atom, adjacent threads read adjacent memory. So a float4 load makes sense.
Wait, in the element-wise kernel, threads 0-31 in a warp:
- Thread 0: features[b, n, 0]
- Thread 1: features[b, n, 1]
- ...
- Thread 31: features[b, n, 31]
These are adjacent in memory! So I can use warp-level collectives to load 32 contiguous floats. But actually, each thread already loads exactly 1 float, and the warp issues a single 128-byte load transaction for all 32 threads. So memory coalescing is already perfect!
Similarly for atomics:
- Thread 0: atomicAdd(out + offset_0)
- Thread 1: atomicAdd(out + offset_1)
- ...
These output addresses are spaced by G*G*G = 32768 floats = 128KB. They are NOT adjacent. But since each thread hits a different cache line, they don't conflict with each other. However, the warp issues 32 separate memory transactions (one per thread), which is inefficient.
With vectorized atomics, I could reduce the number of transactions. But as discussed, the layout doesn't support this.
Actually, wait. What if I reorder the output in shared memory and then do coalesced writes?
For each warp handling 1 atom:
1. 32 threads load 32 features (coalesced, 1 transaction)
2. 32 threads compute the same voxel index (redundant but cheap)
3. 32 threads write to 32 different global addresses (32 transactions)
If I instead:
1. Load features into shared memory (coalesced)
2. Do a block-level transpose
3. Write chunks of features to contiguous memory
But this changes the output format which we can't do.
Actually, I just had another idea. Instead of each thread doing atomicAdd to global memory, what if I have each thread write to a different output buffer, then do a reduction? For example, atomics in shared memory are much faster. If I can batch atomics by voxel, I can reduce them in shared memory.
But with 32 feature channels being 128KB apart, they don't share cache lines in global memory, so there's no real contention between threads in the same warp. The issue is just the number of transactions.
On H100, the memory subsystem can handle many concurrent transactions. 32 atomics from a warp might not be a bottleneck.
Let me also consider a completely different structure. What if I process feature-wise instead of atom-wise?
For each feature channel c:
- Launch a kernel with B*N threads
- Each thread handles one atom, adds its feature_c to the appropriate voxel
This gives 32 kernels, which is bad due to launch overhead. But what if I parallelize across C in a single kernel by having blockIdx.y = c?
```cpp
__global__ void voxel_pool_kernel(
const float* __restrict__ coords,
const float* __restrict__ features,
const float* __restrict__ center,
float* __restrict__ out,
int B, int N, int C, int G, float voxel_size)
{
int c = blockIdx.y;
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total = B * N;
if (idx >= total) return;
int b = idx / N;
int n = idx % N;
// compute voxel index...
int feat_idx = (b * N + n) * C + c;
int out_idx = ((b * C + c) * G * G * G) + voxel_offset;
atomicAdd(out + out_idx, features[feat_idx]);
}
```
Grid: dim3(blocks, C, 1) where blocks = ceil(B*N/threads)
With B=4, N=256, threads=128: blocks = 8
Total blocks = 8 * 32 = 256 blocks
Threads per block = 128
This is interesting. It separates the feature dimension into the grid structure. Within each block (which handles one feature channel), all threads access the same output channel stride, but different voxels. This might improve cache locality for the output since all threads in a block write to the same C slice.
Actually, for a fixed c, all threads in the block write to `out[b, c, :, :, :]`. The addresses differ only by voxel_offset. If two threads write to the same voxel (different atoms mapping to same voxel), they compete on the atomic. If they write to different voxels, their addresses are nearby (within the same C slice).
Output layout: [B, C, G, G, G]
For fixed b and c, the slice is contiguous: G*G*G = 32768 floats.
If two threads write to different voxels in the same slice, their addresses could be close (if voxels are nearby) or far (if voxels are far apart).
But the key advantage: a block only accesses one C slice at a time. The L2 cache can hold several C slices (each is 128KB, L2 is 50MB+). So caching should be good.
However, total threads = 256 blocks * 128 threads = 32768 threads, same as before. The distribution is just different.
And features access: for a fixed c, threads access features[b, n, c] with varying n. These are strided by C floats. So adjacent threads access memory C floats apart (C=32, so 128 bytes). This is actually very coalesced! A warp of 32 threads accesses 32 * 128 = 4096 bytes spread across the features array, but consecutive threads hit addresses 128 bytes apart. On H100, a memory transaction is 128 bytes, so each thread causes one transaction. But the addresses are well-aligned (since n increases by 1, address = base + n*C*4, and n varies by threadIdx.x, for C=32 this is 128-byte aligned if base is aligned). Wait, if thread 0 accesses n=0 and thread 1 accesses n=1, the addresses differ by 128 bytes, which is exactly one cache line. So the warp's accesses form 32 consecutive cache lines. This is fully coalesced in the sense that each transaction is used fully, but it's 32 transactions per warp instead of 1.
Hmm, that's not great. In the element-wise kernel where all threads in a warp handle the same atom, the feature loads are all from the same 128-byte segment (32 floats = 128 bytes). So the warp does just 1 memory transaction for all 32 threads. That's much better!
So for feature loads, the element-wise kernel (same atom, different features) is better coalesced than the channel-separated kernel (same feature, different atoms).
But for output writes, the element-wise kernel has 32 scattered transactions, while the channel-separated kernel also has scattered transactions (but within a channel slice).
Given that feature loads are once per thread and atomics are once per thread, and both kernels have the same total number of each, but the element-wise kernel has better load coalescing, I think the element-wise kernel is probably better.
Wait, in the channel-separated kernel, features are stored as [B, N, C]. For thread n in a block handling channel c, the access is `features[b*N*C + n*C + c]`. With n varying across threads and c fixed, adjacent threads (n and n+1) access addresses differing by C floats. For C=32, that's 128 bytes. A warp of 32 threads accesses 32 cache lines. The GPU can coalesce this into multiple segments, but it's still more transactions than loading 32 contiguous floats.
In the element-wise kernel, for a warp handling one atom, the accesses are `features[b*N*C + n*C + c]` where c = threadIdx.x % 32. Adjacent threads have c values 0, 1, 2, ..., 31. These are contiguous floats! One 128-byte transaction serves the entire warp. This is optimal.
So definitely, the element-wise kernel has better feature load coalescing.
For output atomics:
- Element-wise: `out[b*C*G^3 + c*G^3 + voxel_offset]` with c varying. Adjacent threads write to addresses G^3 apart = 32768 floats = 128KB. 32 separate cache lines.
- Channel-separated: `out[b*C*G^3 + c*G^3 + voxel_offset]` with c fixed and voxel_offset varying. Addresses depend on which voxels atoms map to. If they're random, it's also scattered.
Both have scattered writes. But in the element-wise kernel, multiple warps (different atoms) might write to the same voxel, causing contention. In the channel-separated kernel, multiple threads in a block might write to the same voxel within the same channel, also causing contention.
Hmm, but actually in the element-wise kernel, within a warp, all threads write to DIFFERENT channels of the SAME voxel. No contention within warp! Across warps, if two warps handle atoms that map to the same voxel, they'll contend only if they write to the same channel at the same time. Since all 32 channels of a voxel might be written simultaneously by both warps, there's contention. But on average, not all warps map to the same voxel.
In the channel-separated kernel, within a warp, threads might write to the SAME channel of DIFFERENT voxels. No contention. But if two threads write to the same voxel (same channel), they contend.
I think the contention patterns are similar. The main difference is feature load coalescing, which favors the element-wise kernel.
Let me also consider the center load. In the element-wise kernel, all 32 threads in a warp load the same center values. These will hit L1 cache after the first thread loads them. In the channel-separated kernel, threads in a warp all load the same center values too (since b is the same for all threads that differ only in n). Wait, no - in the channel-separated kernel, threads in a block have varying b and n, but b only changes every N threads. So within a warp, many threads might have the same b, but not necessarily all.
Actually, with B*N=1024 and block size 128, each block handles 128 atoms. If B=4 and N=256, a block might handle atoms from different batches. Specifically, atom_idx = blockIdx.x * 128 + threadIdx.x. For block 0: atoms 0-127, which are all batch 0 (n=0 to 127). For block 1: atoms 128-255, which are batch 0 (n=128 to 255). For block 2: atoms 256-383, which are batch 1 (n=0 to 127). Etc.
So in the channel-separated kernel, with blocks of 128 threads and C=32 channels:
- For channel c=0, block(0,0) handles batch 0, n=0..127
- All threads in this block have the same b=0
- They all load center[0], center[1], center[2]
- These are the same 3 values for all 128 threads
- L1 cache handles this well
But features: thread n loads features[0*N*C + n*C + c] = features[n*C + c]. With n=0..127, c fixed. Strided access with stride C.
In the element-wise kernel, with blocks of 128 threads:
- Thread 0: atom 0, c=0 (batch 0, n=0, c=0)
- Thread 1: atom 0, c=1 (batch 0, n=0, c=1)
- ...
- Thread 31: atom 0, c=31 (batch 0, n=0, c=31)
- Thread 32: atom 1, c=0 (batch 0, n=1, c=0)
- Thread 33: atom 1, c=1 (batch 0, n=1, c=1)
- ...
Features: threads 0-31 all load features[0..31] = first atom's features, 1 transaction.
Threads 32-63 load features[C..C+31] = second atom's features, 1 transaction.
This is very efficient!
And center: threads 0-31 all need center for batch 0. One thread loads it, others hit L1.
Threads 32-63 also need center for batch 0. Hit L1.
This continues until the batch changes at atom 256. Then center for batch 1 is loaded.
So the element-wise kernel clearly has better memory access patterns.
But there's another consideration: the channel-separated kernel has `blockIdx.y = c`, which means blocks for different c can run concurrently on the same SM if resources allow. With 256 blocks (8 x 32 grid), and block size 128 threads, each SM can hold up to floor(2048/128) = 16 blocks. With 132 SMs, total capacity is 2112 blocks. We only have 256 blocks, so all blocks can run concurrently if there are no other resource constraints.
Oh wait, 256 blocks can all run concurrently? Maximum concurrent blocks per SM is 32 on H100. So each SM can run up to 32 blocks, limited by threads (2048 / 128 = 16 blocks per SM). Total capacity = 132 * 16 = 2112 blocks. We only have 256 blocks, so only about 256 / 132 ≈ 2 SMs worth of blocks would be used? No, the blocks are distributed across all SMs.
Actually, the GPU scheduler distributes blocks to SMs. With 256 blocks, they will be spread across all 132 SMs. Each SM gets about 2 blocks (256/132 ≈ 1.94). Since each SM can hold 16 blocks, there's plenty of room. But the issue is that each block only runs when it's assigned to an SM. Once a block finishes, the SM can take another block from the queue.
Actually, 256 blocks is more than enough to keep all SMs busy. The execution time is determined by the total work divided by throughput.
In the element-wise kernel, with 128 threads per block:
- blocks = 32768 / 128 = 256 blocks
- Same block count!
- Same occupancy!
So both approaches have the same number of blocks and threads. The difference is purely in memory access patterns.
Given that the element-wise kernel has better feature load coalescing, I'll go with that.
Wait, let me double check the element-wise block count:
- total = B * N * C = 4 * 256 * 32 = 32768
- threads = 128
- blocks = ceil(32768 / 128) = 256
Yes, exactly 256 blocks.
For the channel-separated kernel:
- blocks_x = ceil(1024 / 128) = 8
- blocks_y = 32
- total blocks = 8 * 32 = 256
Same! So they have identical launch configuration. The difference is just the indexing.
OK, I'll go with the element-wise kernel. Let me write it up.
One more optimization: I can precompute `G / 2.0f` and `1.0f / voxel_size` on the CPU and pass them as kernel arguments. But the compiler should optimize the constant math anyway.
Actually, I want to try something: instead of computing center in PyTorch and passing it to the kernel, what if I compute everything in the kernel? The center is just `mean(coords, dim=1)`. I can compute this with a warp reduction or block-level shared memory reduction.
But computing the mean in the kernel would require:
1. Each block loads N coords for one batch
2. Block-level reduction for x, y, z
3. Broadcast mean to all threads
4. Continue with voxel computation
This avoids loading center from global memory (but center is only 3 floats per batch, so negligible). It also avoids the PyTorch mean() operation.
Actually, for B=4 batches, the center computation is trivial and already done on GPU by PyTorch. The overhead of PyTorch launching a mean kernel is small. But it's still one extra kernel launch. If I fold it into the main kernel, it's one fewer launch.
Let me compute center in the kernel:
```cpp
// Each warp handles one atom
// Warp 0 in block handles atoms 0, blockDim.x/32, ...
// Actually, let's organize differently.
```
Wait, if my element-wise kernel uses `idx = blockIdx.x * blockDim.x + threadIdx.x`, and I compute center in the kernel, I need a way for the first few warps in a block to compute the center and store it in shared memory.
For batch b, center = mean(coords[b, :, :]). With N=256 atoms, I can use 8 warps (256 threads) to compute the mean in parallel.
But in my element-wise kernel, a block of 128 threads doesn't necessarily contain all atoms from one batch. Block 0 handles atoms 0-31 (batch 0, atoms 0-31, all features). Block 1 handles atoms 32-63 (batch 0, atoms 32-63). Etc.
Actually, for batch b=0, atoms 0-255. In the element-wise indexing:
- idx = b*N*C + n*C + c
- For b=0, n ranges from 0 to 255
- idx ranges from 0 to 255*32 + 31 = 8191
- Block 0 (threads 0-127): idx = 0 to 127
- These are: atom 0 features 0-31, atom 1 features 0-31, atom 2 features 0-31, atom 3 features 0-31
- Block 1 (threads 128-255): idx = 128 to 255
- These are: atom 4 features 0-31, ..., atom 7 features 0-31
- ...
- Block 7 (threads 896-1023): idx = 896 to 1023
- These are: atom 28 features 0-31, ..., atom 31 features 0-31
- Block 8 (threads 1024-1151): idx = 1024 to 1151
- These are: atom 32 features 0-31, ..., atom 35 features 0-31
- ...
- Block 63: last block for batch 0
So blocks are NOT aligned to full atoms in terms of grouping. Each block handles 4 atoms (128 threads / 32 features = 4 atoms per block).
This makes center computation in the kernel harder because we don't have all atoms for a batch in a single block. We'd need inter-block communication via global memory.
So it's easier to compute center in PyTorch and pass it.
Actually, I just realized: I don't even need to pass coords to the kernel. I can do all the preprocessing in PyTorch:
1. Compute center
2. Shift and clamp coords to get indices
3. Pass indices, features, and output buffer to kernel
This simplifies the kernel significantly:
```cpp
__global__ void voxel_pool_kernel(
const int* __restrict__ indices, // (B, N, 3)
const float* __restrict__ features,
float* __restrict__ out,
int B, int N, int C, int G)
{
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total = B * N * C;
if (idx >= total) return;
int c = idx % C;
int atom_idx = idx / C;
int b = atom_idx / N;
int n = atom_idx % N;
int idx_offset = (b * N + n) * 3;
int ix = indices[idx_offset];
int iy = indices[idx_offset + 1];
int iz = indices[idx_offset + 2];
int voxel_offset = ix * G * G + iy * G + iz;
int out_idx = ((b * C + c) * G * G * G) + voxel_offset;
int feat_idx = (b * N + n) * C + c;
atomicAdd(out + out_idx, features[feat_idx]);
}
```
This eliminates center computation and float math in the kernel. But it requires allocating an int32 tensor of size (B, N, 3).
For B=4, N=256, that's 3072 ints = 12KB. Negligible.
Is this faster? The kernel is simpler and has less arithmetic. The PyTorch preprocessing (center, shift, clamp) is fast. I think this is a good approach.
But wait, PyTorch operations also have kernel launch overhead. We're replacing:
- Original: 1 center kernel + 1 shift/clamp kernel + 32 index_put_ kernels
- New: 1 center kernel + 1 shift/clamp kernel + 1 custom kernel
The custom kernel replaces 32 index_put_ kernels, which is the main win.
Actually, I can compute center, shift, and clamp in a single fused PyTorch expression:
```python
indices = ((coords - coords.mean(dim=1, keepdim=True)) / self.voxel_size + self.grid_size / 2.0).long().clamp(0, self.grid_size - 1)
```
PyTorch might fuse some of this, but mean() is a reduction that can't be fused with element-wise ops. So it's still probably 2-3 PyTorch kernel launches.
For maximal performance, I could even fuse the center computation into the custom kernel. But as discussed, the block structure makes this awkward.
Let me go with the approach of passing coords and computing indices in the kernel. This avoids allocating an extra tensor and avoids the PyTorch clamp kernel. The arithmetic in the kernel is minimal.
Actually wait, let me count the exact instructions:
```
int coord_offset = (b * N + n) * 3; // mul, add, mul
float x = coords[coord_offset]; // load
float y = coords[coord_offset + 1]; // load
float z = coords[coord_offset + 2]; // load
int center_offset = b * 3; // mul
float cx = center[center_offset]; // load
float cy = center[center_offset + 1]; // load
float cz = center[center_offset + 2]; // load
float inv_voxel = 1.0f / voxel_size; // division (but it's a constant!)
float sx = (x - cx) * inv_voxel + G * 0.5f; // sub, mul, add
int ix = (int)sx; // f2i
...
ix = max(0, min(G - 1, ix)); // 2 comparisons, selects
...
```
Most of this is compute-bound but very light. The division by voxel_size can be precomputed. The `G * 0.5f` is also constant.
I can pass `half_grid = G / 2.0f` and `inv_voxel = 1.0f / voxel_size` as precomputed constants to avoid some math.
Actually, the compiler will constant-fold `1.0f / voxel_size` if voxel_size is passed as a constexpr or if we use a template parameter. But since it's a runtime argument, the compiler can't fold it. However, the actual division happens once per thread (or the compiler might move it to a register if it recognizes it's constant across the kernel).
Hmm, actually if `voxel_size` is passed as a function argument, it's in a register and the compiler knows it's the same for all threads. So `inv_voxel = 1.0f / voxel_size` can be computed once and reused... well, the compiler should inline it, but since it's in a register, the FMA/FMUL pipeline can handle it efficiently.
Let me just pass both `voxel_size` and the precomputed `inv_voxel_size` and `half_grid` to the kernel.
Wait, I can also compute `center` in the kernel using a warp shuffle approach. Here's how:
For batch b, atoms are at indices b*N to b*N + N-1 in the flat atom array.
In the element-wise kernel, threads are grouped by atom. Threads for atom n in batch b have indices: `idx = (b*N + n)*C + c` for c=0..C-1.
Within a warp, threads handle one atom (if C=32 and warp size=32). So warp w handles atom `(blockIdx.x * blockDim.x + warp_id * 32) / C`. All threads in the warp need the center for batch b.
I can have thread 0 in the warp compute the center by reading `center[b*3]`, `center[b*3+1]`, `center[b*3+2]` and then broadcasting to other threads via shfl. But since center is already precomputed on CPU and the reads are cached, this doesn't save much.
I'll just pass center and compute in the kernel.
OK, let me write the clean element-wise kernel now.
One more thing: I want to try using the `__launch_bounds__` specifier to hint register usage. But I'll skip that for now.
Let me also think about using CUDA's `__half2` for the features, but since the output needs to match fp32 exactly, we need fp32 accumulation.
Final kernel code:
```cpp
__global__ void voxel_pool_kernel(
const float* __restrict__ coords,
const float* __restrict__ features,
const float* __restrict__ center,
float* __restrict__ out,
int B, int N, int C, int G, float inv_voxel, float half_grid)
{
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total = B * N * C;
if (idx >= total) return;
int c = idx & (C - 1); // c = idx % C, assuming C is power of 2
int tmp = idx >> 5; // tmp = idx / 32, if C=32
// But C might not be a power of 2 in general...
```
For generality, let me not assume C is a power of 2. I'll use regular division and modulo, trusting the compiler to optimize constant powers of 2. Actually wait, the constructor takes `feat_dim` which is arbitrary. I shouldn't assume it's 32.
For compilation, let me just use regular `%` and `/`.
But if C is not a power of 2, the indexing doesn't have nice warp alignment properties. For example, with C=30, a warp of 32 threads would handle part of atom n and part of atom n+1. This breaks the "all threads in warp handle same atom" property.
The reference uses `feat_dim = 32` in the test, so maybe I can optimize for that? But the class should work for arbitrary feat_dim.
For arbitrary feat_dim, the element-wise kernel still works, but warp-level coherence is lost. However, the kernel is still correct and efficient.
If I want to optimize for arbitrary C, I could have each thread handle one atom (loop over C). Then total threads = B*N. With B=4, N=256, that's 1024 threads. Low occupancy.
Or I could have each block handle one atom, and threads in the block handle different feature channels. Block size = C threads. For C=30, block size=30. For C=32, block size=32.
```cpp
__global__ void voxel_pool_kernel_atom(
const float* __restrict__ coords,
const float* __restrict__ features,
const float* __restrict__ center,
float* __restrict__ out,
int B, int N, int C, int G, float inv_voxel, float half_grid)
{
int atom_idx = blockIdx.x;
int total_atoms = B * N;
if (atom_idx >= total_atoms) return;
int c = threadIdx.x;
if (c >= C) return;
int b = atom_idx / N;
int n = atom_idx % N;
// compute voxel
// ...
}
```
Blocks = B*N = 1024
Threads per block = C = up to whatever feat_dim is
For C=32: 1024 blocks of 32 threads = 32768 threads
For C=128: 1024 blocks of 128 threads = 131072 threads
This is nice because blocks = atom count, and each block handles one atom. All threads in a block handle the same atom, so they compute the same voxel and write to different channels.
Block size = C. If C is small (e.g., 8), block size is 8 threads. Very small blocks have overhead. If C is large (e.g., 256), block size is 256 threads. Good.
With 1024 blocks and block size up to 256, this could work well. On H100, max threads per block is 1024, so C can be up to 1024.
Occupancy: 1024 blocks. On 132 SMs, each SM gets ~8 blocks. With block size 32, that's 256 threads per SM. With block size 256, that's 2048 threads per SM (max!).
For C=32, we have 1024 blocks of 32 threads. 1024 blocks / 132 SMs ≈ 8 blocks per SM. Each SM runs 8 * 32 = 256 threads. With max 2048, that's 12.5% occupancy. Similar to the element-wise kernel with 256 blocks of 128 threads.
But the element-wise kernel has 256 blocks of 128 threads = same 32768 threads, same occupancy.
For feature loads: in the atom-based kernel, threads in a block load features[b, n, c] with c = threadIdx.x. These are contiguous, so one transaction per warp. Great coalescing.
For output writes: threads in a block write to out[b, c, voxel] with c varying. Same scattered pattern.
For center loads: all threads in the block load the same center values. Cached well.
I think both approaches are similar. Let me go with the element-wise approach since it's simpler and doesn't depend on block size = C.
Actually, for the element-wise approach with arbitrary C, I can use a loop to ensure each thread handles the same atom within a warp:
```cpp
int warps_per_atom = (C + 31) / 32; // how many warps to cover one atom
```
But this gets complicated. Let me just use the atom-based approach (one block per atom) for better structure regardless of C.
Wait, but if C > max_threads_per_block (1024), the atom-based kernel breaks. We can add a loop:
```cpp
for (int c = threadIdx.x; c < C; c += blockDim.x) {
float val = features[feat_offset + c];
atomicAdd(out + out_base + c * G * G * G, val);
}
```
With blockDim.x = min(C, 256) or something. Let me use blockDim.x = 128 (or 256) and loop.
Actually, let me use the following flexible approach:
- Block size = 128 threads (always)
- Grid size = B * N * ((C + 127) / 128)? No, that complicates things.
Let me just go back to the element-wise approach. It's the most straightforward:
- Grid-stride loop over all B*N*C elements
- Each thread handles one element
- Simple, general, efficient
For the specific test with C=32, threads in a warp naturally handle the same atom. For other C values, it still works correctly, just with slightly less optimal coalescing.
Let me finalize the kernel. I'm going to pass all parameters and keep it general.
Actually, I want to try one more thing: using `blockDim.x` as a power of 2 and optimizing the modulo. Power-of-2 block sizes compile to bit shifts for modulo and division when the divisor is a compile-time constant. But `blockDim.x` is not a compile-time constant (it's a special register).
For `idx % C` where C is a runtime integer, the compiler generates integer division instructions. On GPU, integer division is slow (~20-30 cycles). With 32768 threads, that's a lot of division overhead.
If I structure the kernel so that C is a template parameter, the compiler can optimize `idx % 32` to `idx & 31`. But `load_inline` with templates is tricky.
One workaround: in the host wrapper, I can dispatch to different kernels based on C. But C is dynamic.
Actually, a better approach: instead of computing b, n, c from a flattened index, I can use 3D grid:
```cpp
dim3 threads(32, 1, 1); // one warp per block
dim3 blocks(B, N, (C + 31) / 32);
```
Then in the kernel:
```cpp
int b = blockIdx.x;
int n = blockIdx.y;
int c = blockIdx.z * 32 + threadIdx.x;
if (c >= C) return;
```
This avoids all integer division! Let me think about the launch config.
For B=4, N=256, C=32:
- blocks = (4, 256, 1)
- threads = (32, 1, 1)
- Total blocks = 1024
- Total threads = 1024 * 32 = 32768
For C=128:
- blocks = (4, 256, 4)
- threads = (32, 1, 1)
- Total blocks = 4096
- Total threads = 4096 * 32 = 131072
This is very clean! No division needed in the kernel. The only downside is that blocks might be too many (4096 for C=128). But H100 handles thousands of blocks easily.
And within each block, `threadIdx.x` is the feature channel (mod 32). All threads in a block have the same b and n. Perfect! They all compute the same voxel index.
For center loading: all threads in a warp load `center[b*3]`, `center[b*3+1]`, `center[b*3+2]`. Center for batch b. Since b is the same for all threads in the block, these loads are uniform and go through the constant cache.
For feature loading: `features[(b*N + n)*C + c]`. With c = blockIdx.z*32 + threadIdx.x, adjacent threads (threadIdx.x) access contiguous memory. One transaction per warp.
For output atomic: `out[((b*C + c)*G*G*G) + voxel_offset]`. Same scattered pattern, but no contention within a block since each thread writes to a different channel.
For the case where C < 32 and blockIdx.z = 0, some threads might have c >= C. We handle this with `if (c >= C) return;`. With C=32, no divergence since c ranges from 0 to 31.
When C > 32 and we have multiple z-blocks for the same atom, all z-blocks have the same b and n, so they all compute the same voxel index. But they're in different blocks, so they don't share computation. Each block redundantly computes the voxel index. Is this wasteful?
Yes, but the voxel index computation is very cheap (a few arithmetic ops and 3 loads). The main cost is the atomicAdd and the feature load.
Can I optimize by having only the first warp compute the voxel index and storing it in shared memory? All warps in the block have the same b and n (since they're in the same x and y block). Wait, for C > 32, blockIdx.z > 0 means different blocks, not the same block.
Oh, I see. In the 3D grid, block (b, n, z) and block (b, n, z+1) are different blocks. They can run on different SMs. Each block redundantly computes the voxel index.
For C=128, we have 4 blocks per atom. Each block does:
1. Compute voxel index (redundant)
2. Load 32 features each
3. Do 32 atomics each
The redundant voxel index computation is negligible compared to the memory operations.
But there's another issue: for C > 32, blocks (b, n, z) with the same b and n but different z could run concurrently. They all write to the same voxel. But since each block writes to different channels (z*32 to z*32+31), there's no contention.
This is really elegant! Let me use this 3D grid approach.
Launch configuration:
```python
threads = 32 # warp size
blocks_z = (C + 31) // 32
blocks = (B, N, blocks_z)
```
But wait, `B * N * blocks_z` blocks total. For B=4, N=256, blocks_z=1: 1024 blocks. For blocks_z=4: 4096 blocks.
Max blocks per grid dimension:
- CUDA max grid dim x, y, z: typically 2^31-1, 65535, 65535 on older GPUs. On modern GPUs (compute capability >= 7.0), x and y can be up to 2^31-1, z up to 65535.
For B=4, N=256, blocks_z=4: well within limits.
But blocks = (4, 256, 4) = 4096 blocks. Each block has 32 threads. On H100, each SM can run up to 2048 threads, so up to 64 blocks of 32 threads per SM. With 132 SMs, total capacity is 8448 blocks. We only have 4096, so all blocks can theoretically run concurrently if there are no other constraints. But we have 4096 blocks and 132 SMs, so each SM gets about 31 blocks. That's a lot of blocks per SM, which is good for latency hiding.
Actually, the limit is 32 blocks per SM on H100. So each SM can hold max 32 blocks. With 4096 blocks and 132 SMs, we'd need 4096 / 132 = 31 blocks per SM. That's right at the limit!
For C=32 (1024 blocks): 1024 / 132 = 7.8 blocks per SM. Well within limits.
For C=128 (4096 blocks): 4096 / 132 = 31 blocks per SM. At the limit but ok.
For C=256 (8192 blocks): 8192 / 132 = 62 blocks per SM. Exceeds the 32 block limit! Some blocks would queue up.
Is that bad? The block limit is concurrent blocks per SM. If we exceed it, blocks queue up and run sequentially. So for C=256, each SM would run 62 blocks in two waves of 32. That's still fine.
But there's another limit: the grid y dimension is 65535 max. N=256 is fine. z dimension is also 65535, and blocks_z = ceil(256/32) = 8, which is fine.
Hmm actually, I realize that `blocks(B, N, blocks_z)` means we're launching B*N*blocks_z blocks. Since B is typically small (batch size), and N can be large (256, 1024, etc), putting N in the y dimension is fine up to 65535.
OK, let me implement this 3D grid kernel.
Kernel code:
```cpp
__global__ void voxel_pool_kernel(
const float* __restrict__ coords,
const float* __restrict__ features,
const float* __restrict__ center,
float* __restrict__ out,
int B, int N, int C, int G, float inv_voxel, float half_grid)
{
int b = blockIdx.x;
int n = blockIdx.y;
int c = blockIdx.z * 32 + threadIdx.x;
if (b >= B || n >= N || c >= C) return;
int coord_offset = (b * N + n) * 3;
float x = coords[coord_offset];
float y = coords[coord_offset + 1];
float z = coords[coord_offset + 2];
int center_offset = b * 3;
float cx = center[center_offset];
float cy = center[center_offset + 1];
float cz = center[center_offset + 2];
int ix = (int)((x - cx) * inv_voxel + half_grid);
int iy = (int)((y - cy) * inv_voxel + half_grid);
int iz = (int)((z - cz) * inv_voxel + half_grid);
ix = max(0, min(G - 1, ix));
iy = max(0, min(G - 1, iy));
iz = max(0, min(G - 1, iz));
int voxel_offset = ix * G * G + iy * G + iz;
int out_idx = ((b * C + c) * G * G * G) + voxel_offset;
int feat_idx = (b * N + n) * C + c;
atomicAdd(out + out_idx, features[feat_idx]);
}
```
Wait, thread divergence:
- For a block with blockIdx.z=0 and C=32, all threads have c=0..31, so `c >= C` is false for all. No divergence.
- For a block with blockIdx.z=1 and C=48, threads have c=32..63. c >= 48 for threads 16..31. So half the warp diverges. This is a 50% performance loss for those warps.
- To fix this, we should only launch the exact number of blocks needed. So blocks_z = (C + 31) / 32, and the last block might have some threads with c >= C.
If C is not a multiple of 32, the last z-block has unused threads. This is a minor inefficiency.
We can also handle this by having a for-loop in each thread to cover multiple channels:
```cpp
__global__ void voxel_pool_kernel(
const float* __restrict__ coords,
const float* __restrict__ features,
const float* __restrict__ center,
float* __restrict__ out,
int B, int N, int C, int G, float inv_voxel, float half_grid)
{
int b = blockIdx.x;
int n = blockIdx.y;
int c_start = blockIdx.z * blockDim.x + threadIdx.x;
if (b >= B || n >= N) return;
// compute voxel index (shared by all threads in block)
int coord_offset = (b * N + n) * 3;
float x = coords[coord_offset];
float y = coords[coord_offset + 1];
float z = coords[coord_offset + 2];
int center_offset = b * 3;
float cx = center[center_offset];
float cy = center[center_offset + 1];
float cz = center[center_offset + 2];
int ix = (int)((x - cx) * inv_voxel + half_grid);
int iy = (int)((y - cy) * inv_voxel + half_grid);
int iz = (int)((z - cz) * inv_voxel + half_grid);
ix = max(0, min(G - 1, ix));
iy = max(0, min(G - 1, iy));
iz = max(0, min(G - 1, iz));
int voxel_offset = ix * G * G + iy * G + iz;
int feat_base = (b * N + n) * C;
int out_base = b * C * G * G * G + voxel_offset;
for (int c = c_start; c < C; c += blockDim.x * gridDim.z) {
float val = features[feat_base + c];
atomicAdd(out + out_base + c * G * G * G, val);
}
}
```
Here:
- blockDim.x = 32 (warp size)
- gridDim.z = ceil(C / 32) = blocks_z
- c_start = blockIdx.z * 32 + threadIdx.x
- Loop: c = c_start; c < C; c += 32 * blocks_z
If C = 32, blocks_z = 1. Each thread handles c = threadIdx.x. No loop.
If C = 48, blocks_z = 2. Thread 0 in block z=0 handles c=0. Thread 0 in block z=1 handles c=32. Loop condition: c < 48. For z=0, thread 0: c=0, then c += 64 stops. Handles 1 element. For z=1, thread 0: c=32, then c += 64 stops. Handles 1 element.
Thread 16 in block z=0: c=16, handles 1 element. Thread 16 in block z=1: c=48, loop doesn't execute. Handles 0 elements.
Wait, this is still wasteful. Block z=1 has threads 16-31 doing no work.
Alternative: only launch as many blocks in z as needed, and have threads handle exactly one element.
Actually, for our specific test C=32, there's no waste at all. Let me just use the simple approach with `if (c >= C) return;` and accept minor waste for non-multiple-of-32 C.
And for the specific test parameters, C=32, B=4, N=256, G=32. All nice numbers.
Let me finalize the kernel.
Oh, one concern: for the 3D grid with blocks(B, N, blocks_z), total blocks = 1024. Each block has 32 threads. On H100, each block uses very few registers (maybe 16-24). So many blocks can run concurrently. But each SM can run max 32 blocks. With 1024 blocks and 132 SMs, that's 7.8 blocks per SM. Each block runs 32 threads, so 7.8 * 32 = 249 threads per SM. That seems similar to what we had before.
But the advantage is that all threads in a block handle the same atom, so they share the center load and coord loads via L1 cache. And feature loads are coalesced.
One optimization for this 3D grid kernel: since all threads in a block compute the same voxel index, I can have only thread 0 compute it and store in shared memory. Other threads can then read from shared memory. This is called "privatization" or just avoiding redundant computation.
```cpp
__shared__ int smem_voxel[3]; // ix, iy, iz
if (threadIdx.x == 0) {
// compute voxel index
smem_voxel[0] = ix;
smem_voxel[1] = iy;
smem_voxel[2] = iz;
}
__syncthreads();
ix = smem_voxel[0];
iy = smem_voxel[1];
iz = smem_voxel[2];
```
This avoids 31 threads redundantly computing the same thing. However, the computation is very cheap (a few arithmetic ops), and the `__syncthreads()` adds overhead. For tiny blocks (32 threads), the syncthreads might cost more than the redundant computation.
Let me skip this and let all threads compute redundantly. The compiler might even optimize it since the loads are uniform.
Hmm, actually for uniform loads (all threads load the same address), the GPU hardware has a "uniform datapath" on Volta+ that handles this efficiently. The loads are issued once and broadcast to all threads. So even though 32 threads issue the load, the hardware only fetches one cache line and broadcasts it. The redundant arithmetic is also cheap because all threads compute the same values (they use the same registers due to uniform datapath).
Actually, I'm not sure about the uniform datapath for arithmetic. The loads to `coords` are uniform (all threads in a warp load the same address), so those are efficient. The center loads are also uniform. The arithmetic operations are the same for all threads, so they execute in lockstep without divergence.
I think the 3D grid kernel is great. Let me implement it.
Wait, one more thing: `atomicAdd` performance. On H100, `atomicAdd` on global memory goes through L2 cache. If two atomics hit the same cache line, they serialize. In our kernel, threads in the same warp write to addresses spaced by `G*G*G = 32768 floats = 131072 bytes`. H100 L2 cache line is 128 bytes. So each atomic writes to a different cache line (131072 / 128 = 1024 cache lines apart). No two threads in the same warp share a cache line.
Across warps/blocks, if two atoms map to the same voxel, their atomics for the same channel will hit the same cache line and serialize. But different channels are far apart, so no cross-channel contention.
This is actually optimal for avoiding intra-warp contention. The inter-warp contention depends on collision probability, which is low.
OK, let me write the code.
Actually, I just realized: should I pass `blockIdx.z * blockDim.x + threadIdx.x` or just use a 1D thread index? In a block of 32 threads, `threadIdx.x` ranges from 0 to 31. For blocks_z=1, c = threadIdx.x. That's clean.
For blocks_z > 1, blockIdx.z is the z-coordinate of the block in the 3D grid. So `c = blockIdx.z * 32 + threadIdx.x`.
Kernel:
```cpp
__global__ void voxel_pool_kernel(
const float* __restrict__ coords,
const float* __restrict__ features,
const float* __restrict__ center,
float* __restrict__ out,
int B, int N, int C, int G,
float inv_voxel, float half_grid)
{
int b = blockIdx.x;
int n = blockIdx.y;
int c = blockIdx.z * blockDim.x + threadIdx.x;
if (b >= B || n >= N || c >= C) return;
int coord_offset = (b * N + n) * 3;
float x = coords[coord_offset];
float y = coords[coord_offset + 1];
float z = coords[coord_offset + 2];
int center_offset = b * 3;
float cx = center[center_offset];
float cy = center[center_offset + 1];
float cz = center[center_offset + 2];
int ix = (int)((x - cx) * inv_voxel + half_grid);
int iy = (int)((y - cy) * inv_voxel + half_grid);
int iz = (int)((z - cz) * inv_voxel + half_grid);
ix = max(0, min(G - 1, ix));
iy = max(0, min(G - 1, iy));
iz = max(0, min(G - 1, iz));
int voxel_offset = ix * G * G + iy * G + iz;
int out_idx = ((b * C + c) * G * G * G) + voxel_offset;
int feat_idx = (b * N + n) * C + c;
atomicAdd(out + out_idx, features[feat_idx]);
}
```
Launch:
```python
threads = 32
blocks_z = (C + threads - 1) // threads
blocks = (B, N, blocks_z)
```
Wait, but `blocks(B, N, blocks_z)` means block dimensions are x=B, y=N, z=blocks_z. On older GPUs, z dimension is limited to 65535, which is fine. But is there any performance issue with 3D grids? Generally no, the hardware handles them well.
One subtle issue: warp scheduling. A block with 32 threads is exactly one warp. This means:
- No intra-block synchronization needed
- The block runs as a single warp
- If the warp stalls (e.g., waiting for memory), the SM can run other warps from other blocks
This is actually very efficient! Single-warp blocks are lightweight and easy to schedule.
With 1024 single-warp blocks and 132 SMs, each SM runs about 8 warps. That's enough to keep the memory pipeline busy.
OK, I like this design. Let me compile it.
Actually wait, I realize there's still a minor issue. If C is not exactly a multiple of 32, say C=33, then blocks_z=2. Block z=1 has threads 0..31 corresponding to c=32..63. But only c=32 is valid. Threads 1..31 do nothing. That's 31/32 = 97% waste for that block.
For our specific test C=32, this isn't an issue. For general use, we can add a check. But to be safe and efficient, I could have threads handle C in a strided manner:
```cpp
int c_start = blockIdx.z * blockDim.x + threadIdx.x;
for (int c = c_start; c < C; c += gridDim.z * blockDim.x) {
// process feature channel c
}
```
With this loop, if C=33 and blocks_z=2:
- Block z=0: threads handle c = 0..31 (thread 0 handles c=0, thread 1 handles c=1, etc.)
- Next iteration: c += 64. c=64 >= 33, stop.
- Block z=1: threads handle c = 32..63, but only c < 33 matters.
- thread 0: c=32 < 33, process
- thread 1: c=33 >= 33, skip
- ...
- Next iteration: c += 64. All stop.
So block z=1 only has thread 0 doing work. 31 threads idle. Still wasteful.
What if I compute blocks_z differently? Instead of fixed block size 32, I could use block size = C for small C, or block size = 128 for large C.
Or I can just set block size to `min(C, 32)` but that's weird for warp execution.
Actually, the simplest and most efficient approach for arbitrary C:
```cpp
__global__ void voxel_pool_kernel(
const float* __restrict__ coords,
const float* __restrict__ features,
const float* __restrict__ center,
float* __restrict__ out,
int B, int N, int C, int G,
float inv_voxel, float half_grid)
{
int b = blockIdx.x;
int n = blockIdx.y;
int c = threadIdx.x;
if (b >= B || n >= N || c >= C) return;
// compute voxel index once per block
// ... (same as before)
int voxel_offset = ix * G * G + iy * G + iz;
int feat_base = (b * N + n) * C;
int out_base = b * C * G * G * G + voxel_offset;
// Strided loop to cover all C channels
for (int c = threadIdx.x; c < C; c += blockDim.x) {
float val = features[feat_base + c];
atomicAdd(out + out_base + c * G * G * G, val);
}
}
```
Here, block size is fixed (e.g., `blockDim.x = 128` or `blockDim.x = 256`), but each block handles one atom. The grid is just (B, N).
For C=32 and blockDim.x=128, each thread in the block handles at most 1 channel. 96 threads are idle. That's 75% waste!
For blockDim.x=32, each thread handles 1 channel for C=32, or 2 channels for C=64, etc. No waste if C is a multiple of 32.
What if I set `blockDim.x = min(C, 128)` and round up to the nearest multiple of 32?
Actually, CUDA block sizes should be multiples of the warp size (32) for good occupancy. So valid block sizes are 32, 64, 96, 128, etc.
For C=32: block size 32. Perfect.
For C=48: block size 64. 16 threads idle. 25% waste.
For C=64: block size 64. Perfect.
For C=100: block size 128. 28 threads idle. 22% waste.
The waste is acceptable. Or we can use dynamic block sizing based on C.
But for simplicity and since the test uses C=32, let me just use `blockDim.x = 32` and handle C with loops:
```cpp
for (int c = threadIdx.x; c < C; c += blockDim.x) {
// process channel c
}
```
With blockDim.x = 32:
- C=32: each thread handles 1 channel
- C=64: each thread handles 2 channels
- C=48: each thread handles 1 or 2 channels (thread 0: 0, 32; thread 16: 16, 48. thread 31: 31, 63 but 63 >= 48 so only 1 channel)
The loop handles arbitrary C efficiently.
Grid: (B, N, 1)
Block: (32, 1, 1)
For C=32: 1024 blocks, 32 threads each, total 32768 threads. Same as before.
For C=64: 1024 blocks, each thread does 2 iterations. Total 65536 atomic operations. But still 32768 threads.
Wait, with the loop, the total thread count doesn't increase with C. Each thread just does more work. This is good for C > 32 but might not use all parallelism for very large C.
For C=256: each thread handles 8 channels. Total atomic operations = 1024 * 32 * 8 = 262144. Threads = 32768.
For C=256, having 32768 threads might still be plenty. Each thread does 8 loads and 8 atomics. The arithmetic is minimal.
What about occupancy? Still 32768 threads. H100 capacity is ~270k threads. So 12% occupancy.
Hmm. For C=1024: each thread handles 32 channels. 32768 threads total. 32768 loads and 32768 atomics per thread? No, 32 loads and 32 atomics per thread. Still manageable.
Actually, wait. With block size 32, threads in a warp process channels 0, 1, 2, ..., 31 in the first iteration. Then channels 32, 33, ..., 63 in the second iteration, etc.
For feature loads: in iteration 0, threads load features[b, n, 0..31]. Coalesced, 1 transaction.
In iteration 1, threads load features[b, n, 32..63]. Coalesced, 1 transaction.
Great coalescing for all iterations.
For atomics: in iteration 0, threads write to out[b, 0..31, voxel]. Scattered, 32 transactions.
In iteration 1, threads write to out[b, 32..63, voxel]. Also scattered, 32 transactions.
Same pattern for all iterations.
So the loop-based warp-atom kernel has excellent load coalescing and handles arbitrary C.
Let me go with this design!
Grid: (B, N, 1) = (4, 256, 1) = 1024 blocks
Block: (32, 1, 1) = 32 threads per block
Kernel:
```cpp
__global__ void voxel_pool_kernel(
const float* __restrict__ coords,
const float* __restrict__ features,
const float* __restrict__ center,
float* __restrict__ out,
int B, int N, int C, int G,
float inv_voxel, float half_grid)
{
int b = blockIdx.x;
int n = blockIdx.y;
if (b >= B || n >= N) return;
int coord_offset = (b * N + n) * 3;
float x = coords[coord_offset];
float y = coords[coord_offset + 1];
float z = coords[coord_offset + 2];
int center_offset = b * 3;
float cx = center[center_offset];
float cy = center[center_offset + 1];
float cz = center[center_offset + 2];
int ix = (int)((x - cx) * inv_voxel + half_grid);
int iy = (int)((y - cy) * inv_voxel + half_grid);
int iz = (int)((z - cz) * inv_voxel + half_grid);
ix = max(0, min(G - 1, ix));
iy = max(0, min(G - 1, iy));
iz = max(0, min(G - 1, iz));
int voxel_offset = ix * G * G + iy * G + iz;
int feat_base = (b * N + n) * C;
int out_base = b * C * G * G * G + voxel_offset;
for (int c = threadIdx.x; c < C; c += 32) {
float val = features[feat_base + c];
atomicAdd(out + out_base + c * G * G * G, val);
}
}
```
This handles arbitrary C, has perfect load coalescing, no warp divergence for the loop (all threads execute the same number of iterations for C multiple of 32), and good atomic behavior.
For C=32, each thread does exactly 1 iteration.
For the test case C=32, B=4, N=256:
- 1024 blocks
- 32 threads per block
- Total threads: 32768
- Total warps: 1024
- Warps per SM: ~8
Actually, I just realized that using a 1D grid with 1024 blocks would be slightly simpler than a 2D grid. Let me verify:
```cpp
__global__ void voxel_pool_kernel(
const float* __restrict__ coords,
const float* __restrict__ features,
const float* __restrict__ center,
float* __restrict__ out,
int B, int N, int C, int G,
float inv_voxel, float half_grid)
{
int atom_idx = blockIdx.x;
int total_atoms = B * N;
if (atom_idx >= total_atoms) return;
int b = atom_idx / N;
int n = atom_idx % N;
// ...
}
```
Block size = 32.
Blocks = B * N = 1024.
This uses a 1D grid instead of 2D. Simpler and equivalent. I'll use this.
Actually, `atom_idx / N` and `atom_idx % N` where N is a runtime variable are integer divisions. These are expensive on GPU!
In the 2D grid approach:
- `b = blockIdx.x` (no division)
- `n = blockIdx.y` (no division)
This avoids all integer division in the kernel. For B=4, N=256, the 2D grid is much better.
But `blockIdx.x` and `blockIdx.y` access special registers. On the 2D grid with blocks(B, N), blockIdx.x ranges from 0 to B-1 and blockIdx.y ranges from 0 to N-1.
Wait, actually `gridDim.y = N` and `gridDim.x = B`. So `blockIdx.x` is the batch index and `blockIdx.y` is the atom index. That's clean.
But can we launch 1024 blocks as a 2D grid? Yes, `blocks = (B, N) = (4, 256)`. The total number of blocks is 4 * 256 = 1024. CUDA handles this fine.
Let me use the 2D grid to avoid integer division.
For the loop over C, is there divergence when C is not a multiple of 32? The condition `c < C` in the for-loop is checked before entering the body. After the last iteration, all threads in the warp will find `c >= C` and exit together. So no divergence!
Wait, if C=33:
- Thread 0: c=0, 32. Both < 33. 2 iterations.
- Thread 1: c=1, 33. 1st < 33, 2nd >= 33. 1 iteration.
- Thread 31: c=31, 63. 1st < 33, 2nd >= 33. 1 iteration.
So threads 1-31 do 1 iteration, thread 0 does 2 iterations. This creates divergence within the warp! Thread 0 does an extra iteration while threads 1-31 wait. That's bad.
For C=32, all threads do exactly 1 iteration. No divergence.
For C=64, all threads do exactly 2 iterations. No divergence.
If we always round C up to the nearest multiple of 32 (e.g., use `C_rounded = (C + 31) & ~31`), we can avoid divergence. But we'd access features and outputs beyond C, which is out of bounds.
To fix this, we can pad the features and output tensors to multiples of 32. But we can't control the output size.
Alternative: process features in chunks of 32 inside an unrolled loop:
```cpp
int c = threadIdx.x;
if (c < C) {
float val = features[feat_base + c];
atomicAdd(out + out_base + c * G * G * G, val);
}
c += 32;
if (c < C) {
float val = features[feat_base + c];
atomicAdd(out + out_base + c * G * G * G, val);
}
// etc.
```
This has divergence if C is not a multiple of 32. The last `if (c < C)` might have some threads predicated off.
Actually, divergence within a warp is only a problem if threads take different paths. In the if statement, some threads take the true branch and some take the false branch. The warp executes both paths (or one path if all agree). For the last iteration, if most threads are false and only a few are true, the warp still has to execute the true path for those threads, with the others masked off.
For C=33, in the second iteration:
- Thread 0: c=32 < 33, true
- Threads 1-31: c=33..63 >= 33, false
- The warp executes the true path with thread 0 active and threads 1-31 inactive. This is exactly 1/32 utilization for this iteration.
To avoid this, we could pad C internally. But for the test case C=32, there's no issue.
Since the test uses C=32, I'll use the simple loop and accept potential divergence for non-multiple-of-32 C.
Actually, for a robust kernel, I can handle the last partial chunk more carefully:
```cpp
int c = threadIdx.x;
for (; c + 31 < C; c += 32) {
// Full warp iteration, no divergence
float val = features[feat_base + c];
atomicAdd(out + out_base + c * G * G * G, val);
}
if (c < C) {
// Partial iteration, possible divergence
float val = features[feat_base + c];
atomicAdd(out + out_base + c * G * G * G, val);
}
```
For C=32: `c + 31 < 32` means `c < 1`. c starts at threadIdx.x (0 to 31). Only thread 0 enters the loop. That doesn't work!
Wait, `c + 31 < C` is equivalent to `c < C - 31`. For C=32: `c < 1`. So only c=0 enters the loop. Then `if (c < 32)` has all threads enter. This is wrong.
Let me rethink. I want all threads to process full groups of 32 channels together, and then handle any remainder.
For C=32:
- Full groups: floor(32/32) = 1 group.
- Remainder: 0.
- Each thread process 1 channel in the full group.
For C=48:
- Full groups: floor(48/32) = 1 group.
- Remainder: 16.
- Threads 0-15 process channels 32-47 in the remainder. Threads 16-31 do nothing. Divergence.
The problem is that the remainder always causes divergence. Unless we process the remainder using a different method, like having one thread handle multiple channels.
A cleaner approach: pad C to a multiple of 32 using a temporary buffer. But that's complex.
For the specific problem at hand, C=32, so let me just use the simple loop. The reference implementation uses `feat_dim = 32`.
Let me finally write the kernel.
Actually, one more optimization. The reference computes:
```python
indices = shifted.long().clamp(0, G - 1)
ix = indices[:, :, 0]
iy = indices[:, :, 1]
iz = indices[:, :, 2]
```
The `clamp` operation: `clamp(0, G-1)`. In C:
- `ix = max(0, min(G - 1, ix))`
For values that are very large (like shifted coords >> G), `min` will clamp to G-1. For negative values (very unlikely after adding G/2), `max(0, ...)` will clamp to 0.
Actually, `(coords - center) / voxel_size + G/2.0`:
- coords are normally distributed with mean=center (since center=mean(coords)) and std=5.
- So `(coords - center)` has mean 0 and std=5.
- Dividing by voxel_size=1.0 gives mean 0, std=5.
- Adding G/2 = 16 gives mean 16, std=5.
- Most values are in [1, 31] (mean ± 3*std).
- Very few values are outside [0, 31].
So clamping is rarely needed in practice. But we must include it for correctness.
In the C kernel, the clamp is a few instructions. Not a bottleneck.
OK, here's the final kernel:
```cpp
__global__ void voxel_pool_kernel(
const float* __restrict__ coords,
const float* __restrict__ features,
const float* __restrict__ center,
float* __restrict__ out,
int B, int N, int C, int G,
float inv_voxel, float half_grid)
{
int b = blockIdx.x;
int n = blockIdx.y;
if (b >= B || n >= N) return;
int coord_offset = (b * N + n) * 3;
float x = coords[coord_offset];
float y = coords[coord_offset + 1];
float z = coords[coord_offset + 2];
int center_offset = b * 3;
float cx = center[center_offset];
float cy = center[center_offset + 1];
float cz = center[center_offset + 2];
int ix = (int)((x - cx) * inv_voxel + half_grid);
int iy = (int)((y - cy) * inv_voxel + half_grid);
int iz = (int)((z - cz) * inv_voxel + half_grid);
if (ix < 0) ix = 0;
if (ix >= G) ix = G - 1;
if (iy < 0) iy = 0;
if (iy >= G) iy = G - 1;
if (iz < 0) iz = 0;
if (iz >= G) iz = G - 1;
int voxel_offset = ix * G * G + iy * G + iz;
int feat_base = (b * N + n) * C;
int out_base = b * C * G * G * G + voxel_offset;
for (int c = threadIdx.x; c < C; c += 32) {
float val = features[feat_base + c];
atomicAdd(out + out_base + c * G * G * G, val);
}
}
```
With C=32, the loop runs exactly once.
Launch: `dim3(B, N), 32`
Let me compile and test.
Wait, I'll also add `__launch_bounds__(32)` to tell the compiler the block size in case it helps with register allocation.
Actually, `__launch_bounds__(32)` isn't necessary and might not help much.
Let me also consider using `constexpr` or templates for G if it's fixed. But G is configurable.
I think this kernel is good. Let me write the full code and compile.
One thing: should I compute `inv_voxel` and `half_grid` in Python or pass `voxel_size` and `G` to the kernel?
In Python:
```python
inv_voxel = 1.0 / self.voxel_size
half_grid = self.grid_size / 2.0
voxel_pool_cuda.voxel_pool_forward_cuda(
coords, features, center, out,
B, N, self.feat_dim, self.grid_size,
inv_voxel, half_grid
)
```
This avoids division in the kernel.
Let me write the code.