[Kernel] Add submanifold sparse 3D convolution (bf16 implicit GEMM, gfx950) - #942
Draft
jiacao-amd wants to merge 4 commits into
Draft
[Kernel] Add submanifold sparse 3D convolution (bf16 implicit GEMM, gfx950)#942jiacao-amd wants to merge 4 commits into
jiacao-amd wants to merge 4 commits into
Conversation
…fx950)
Self-contained spconv-style operator: raw voxel coordinates in, dense output
features out, kernel map built internally. Matches the input convention of
spconv / MinkowskiEngine / TorchSparse so it can drop into their call sites.
Three stages, four GPU kernels:
coords --[map]--> LUT --[compact]--> pair list --[gemm]--> output
MAPPING. Two interchangeable builders, routed by bounding-box volume, because
neither dominates:
dense presence grid O(1) lookup, but O(volume) memory AND fill, so its
cost tracks the grid rather than the point count.
2-4x faster on compact grids; cannot address past
2^31 cells.
Z-Delta Spira, arXiv 2511.20834 5.2. One sort, then K^2
binary searches instead of K^3, using
packed(q+d) == packed(q) + packed(d) so queries never
unpack. O(N) memory, cost flat in grid volume.
MEASURED: 195^3 dense 41 us / zdelta 160 us; 595x595x245 dense 93 / zdelta 180;
2095^2x695 dense CANNOT RUN (3.05e9 cells, 12.2 GB of presence, and the linear
index overflows int32) while zdelta does it in 203 us. That last grid is PTv3
on SemanticKITTI, so the second mapper is a functional requirement outdoors,
not a tuning option. Keys are packed into 32 bits when the padded extent allows
(1.15-1.27x) and int64 otherwise.
COMPACTION. An indexed [tile, kv, row] layout runs all 16 rows of a tile even
when only 2-3 have a neighbour for that tap: MEASURED 5.4-6.2x wasted MACs,
i.e. 16-21% of the multiplies useful, while executed throughput is already
45-53% of peak -- the hardware saturates computing zeros. Relisting only the
valid pairs, grouped by tap so a tile shares one weight slab, makes every MFMA
tile full. Padding each tap's run to a multiple of 16 costs 1.002x.
GEMM. mfma_f32_16x16x32_bf16 with fp32 accumulate, one wave per block, gather
and scatter both fused in: A is read through in_rows[], results are atomically
added through out_rows[], and no per-pair intermediate is materialised.
Four things that shaped the design, each measured rather than assumed:
* No LDS. With one 64-lane wave and a 16-wide tile every operand element is
read by exactly one lane -- reuse is 1.00x in fp32 and again in bf16 after
vectorisation. Staging through LDS is a pure detour that also sets
LDS/block = C_IN*64 B, which collapsed occupancy to 5 blocks/CU at C_IN=512
(isolated by compiling for a padded C_IN: 17x slower at identical work).
* Weights are packed [kv, t, k_outer, u, k_inner]. The obvious K-major
[kv, t, u, ci] gives each lane its 8 consecutive k but puts adjacent lanes
C_IN*2 bytes apart, so a wave reads 16 half-used cache lines: 50%
coalescing. Interleaving k_inner innermost makes it 8 fully-used lines.
* Loads for K_UNROLL k-steps are hoisted above their MFMAs. Issuing each MFMA
right after its load left the wave on L2 latency (24-27 cycles against an
8-cycle issue rate) even though DRAM traffic is 7-16% of peak and L2 hits
91-96%. This is the no-LDS way to get prefetch.
* The centre tap is compacted along with the rest. Leaving it on a separate
indexed kernel looks right -- SubM makes it 100% dense already -- but that
ignores the extra launch and the extra read-modify-write: folding it in
measured 1.40-2.46x faster and removed a whole kernel.
dtype is bf16 because production sparse conv is 16-bit: spconv ships
fp32/fp16/bf16, its own benchmark tables are F16/TF32, and NVIDIA's shipped
CenterPoint logs are float16 and int8 with no fp32 variant. An fp32 GEMM was
built first and measured 1.6-2.9x slower at every shape. Rounding the inputs
costs ~2.5e-3 relative error per layer; across a 22-layer stack with
normalisation and residuals that compounds to ~1.3e-2 (cosine similarity
0.99994, argmax agreement 98.6%). NOT yet validated against a pretrained model
-- that gate should be cleared before relying on it for accuracy-sensitive work.
Sized against MEASURED production shapes, not synthetic ones: nuScenes
CenterPoint over all 6019 val frames is median 91,090 active voxels (p95
114,827, max 135,306), and PTv3 captures give ScanNet ~148k and SemanticKITTI
~120k. Earlier tuning had used a 10k-point toy input, which understates both N
and neighbour density by enough to invert several conclusions.
End to end on those shapes (warm cache, includes mapping):
nuScenes C=128 N=32371 47 us
nuScenes C=256 N=26509 101 us
nuScenes C=512 N=15052 172 us
SemKITTI C=128 N=94517 178 us
Host-side torch is used for allocation, torch.sort (Z-Delta) and the compaction
scan; the GPU kernels are pure FlyDSL. Replacing those host ops with
hand-written kernels was tried and measured 2-8x SLOWER -- torch's
nonzero/cumsum are parallel scans and the replacement serialised on atomics.
Co-Authored-By: Claude <noreply@anthropic.com>
Mechanical cleanup per the kernel-code-cleanup skill, in the two mapping kernels
and the akv-compaction tail they share:
arith.cmpi/andi/addi/muli -> Python operators on typed fx values
arith.constant(n, T.i32) -> fx.Int32(n)
fx.Index(...) -> fx.Int32(...)
vector.extract/from_elements-> fx.Vector(v)[i] / fx.Vector.from_elements([...])
arith.constant_vector -> fx.Vector.filled(...)
gpu.thread_idx.x -> gpu.thread_id("x")
const_expr(<python int>) -> the int itself (builders take plain ints)
fx.Index was not just verbose but wrong-ish here: buffer_load documents its
offset as i32, so every fx.Index(...) forced an implicit index->i32 cast. The
offsets are now i32 throughout.
scf.IfOp is kept explicit, and the reason is structural rather than stylistic.
_collect_assigned_vars (ast_rewriter.py) treats the RECEIVER OF A METHOD CALL
inside a branch as loop/branch state, not just assigned names:
def visit_Call(self, node):
base_name = RegionAnalyzer._get_call_base(node.func)
if base_name is not None and base_name != "self":
add_unique(invoked_args, base_name)
Every branch body here reads or writes a GTensor (lut_.store, pr_.load, ...),
so the GTensor lands in result_names and scf_if_dispatch rejects it with
"state variable 'lut_' is GTensor, not an MLIR Value". Three formulations were
tried under FLYDSL_RUNTIME_ENABLE_CACHE=0 -- assignments inside the branch,
loads fully inlined so the branch assigns nothing, and the innermost branch
whose values are dead afterwards -- and all three fail the same way. This is a
GTensor/rewriter incompatibility, not something callers can write around; fixing
it means changing kernels/common/tensor_shim.py, which four other kernels share.
The conditions are now typed compares with .ir_value() at the boundary.
GTensor / buffer_ops also stay for that same shared-shim reason.
Verified against a golden capture taken before the migration: the LUT, the
compacted in_rows and out_rows are BIT-IDENTICAL on four shapes including K=5.
Float output differs by ~1e-7, which is below the kernel's own run-to-run noise
(~2e-6): buffer_atomic_add reassociates, so re-running unmodified code differs
by the same amount. 39/39 tests pass with FLYDSL_RUNTIME_ENABLE_CACHE=0.
Co-Authored-By: Claude <noreply@anthropic.com>
Attempted the same fx-operator migration there and reverted it. Its keys are KEY_T (i32 or i64 depending on grid extent) and GTensor.load returns a raw value typed by that dtype rather than an fx wrapper, so operator compares do not apply and fx.Int32 offsets take a different buffer_load path -- the result fails to compile. Recorded in-code so the next reader does not retry it. Co-Authored-By: Claude <noreply@anthropic.com>
…face
Same mechanical cleanup as the map kernels, applied to kernel_bf16_cmp:
fx.Index(...) -> fx.Int32(...) (buffer_load wants i32; every
fx.Index was forcing an index->i32 cast)
arith.constant / cmpi -> fx.Int32(n) / typed compares
arith.constant_vector -> fx.Vector.filled(4, 0.0, fx.Float32)
const_expr(<python int>) -> the int (make_layout/vec_load take plain ints)
dropped `lane = tid` alias and the duplicate mfma_row/mfma_col computation
The EPILOGUE is left on fx.Index / vector.extract deliberately, with the reason
in-code. Migrating it is op-count-identical but measured 185 us vs 170 us at
C_OUT=512 (median-of-7, range 184.8-186.4 so well outside noise) -- an
instruction-scheduling effect around the predicated atomics, the same class of
exception PR ROCm#913 documents for mxfp_moe gemm1 and fp8_4wave. Bisected by
reverting prologue/hot-loop/epilogue independently: the hot loop is free, the
epilogue is not.
Verified: LUT, in_rows and out_rows BIT-IDENTICAL to the pre-migration golden on
four shapes incl. K=5; float output within the kernel's own atomic-order noise.
39/39 tests pass cold. Perf median-of-5 vs before: C=128 1.00x, C=256 1.02x,
C=512 1.02x.
Co-Authored-By: Claude <noreply@anthropic.com>
jiacao-amd
force-pushed
the
spconv-bf16-implicit-gemm
branch
from
July 31, 2026 01:36
c2e72f3 to
1ad9e42
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a self-contained submanifold sparse 3D convolution (spconv-style): raw voxel
coordinates in, dense output features out, kernel map built internally. Matches the
input convention of spconv / MinkowskiEngine / TorchSparse so it drops into their call
sites. Four GPU kernels, one public entry point.
Draft — see Open questions at the bottom before merging.
Design, and the measurements behind it
Sized against measured production shapes rather than synthetic ones: nuScenes
CenterPoint over all 6019 val frames is median 91,090 active voxels (p95 114,827, max
135,306), and PTv3 captures give ScanNet ~148k and SemanticKITTI ~120k. Earlier tuning
had used a 10k-point toy input, which understates both N and neighbour density by enough
to invert several conclusions.
Two mappers, both required.
build_lut_autoroutes by bounding-box volume:The dense presence grid is 2-4x faster on compact grids but is O(volume) in memory and
fill; Z-Delta (Spira, arXiv 2511.20834 §5.2) sorts
packed keys once and does K² binary searches instead of K³, flat in grid volume. The
last row is PTv3 on SemanticKITTI, so the second mapper is a functional requirement
outdoors, not a tuning option.
Compaction is the largest single win (2.4-3.3x). An indexed
[tile, kv, row]layoutruns all 16 rows of a tile when only 2-3 have a neighbour for that tap — measured
5.4-6.2x wasted MACs, i.e. 16-21% of multiplies useful, while executed throughput was
already 45-53% of peak. The hardware was saturated computing zeros.
No LDS, deliberately. With one 64-lane wave and a 16-wide tile every operand element
is read by exactly one lane — reuse is 1.00x in fp32 and again in bf16 after
vectorisation. Isolated by compiling for a padded C_IN so useful work is unchanged: LDS
alone made it 17x slower by collapsing occupancy to 5 blocks/CU at C_IN=512. An
LDS-transposed epilogue was also built and measured 8x slower, with padding making no
difference — bounded by the round-trip, not by bank conflicts.
Weight layout is
[kv, t, k_outer, u, k_inner]. The obvious K-major[kv, t, u, ci]gives each lane its 8 consecutive k but puts adjacent lanes C_IN*2 bytesapart, so a wave reads 16 half-used cache lines: 50% coalescing, and that was 84% of the
kernel's requested traffic. Interleaving k_inner innermost makes it 8 fully-used lines —
1.8x, a pure packing change.
dtype is bf16 because production sparse conv is 16-bit: spconv ships fp32/fp16/bf16,
its own benchmark tables are F16/TF32, and NVIDIA's shipped CenterPoint logs are
perf-float16-*.logandperf-int8.logwith no fp32 variant. An fp32 GEMM was builtfirst and measured 1.6-2.9x slower at every shape.
Performance
End to end through the public API on production shapes (warm cache, includes mapping):
A 6-layer stage sharing one
indice_keyruns in 0.596 ms.API style
Commits 3-5 port the kernels to the current
fxsurface per the kernel-code-cleanupskill (
fx.Index→fx.Int32,arith.*→ operators,vector.*→fx.Vector), −34lines net. Verified bit-identical against a golden capture and perf-neutral
(median-of-5: 1.00x / 1.02x / 1.02x).
Three sites are left legacy on purpose, each documented in-code:
scf.IfOpeverywhere._collect_assigned_varstreats the receiver of a methodcall inside a branch as branch state, so any
lut_.store(...)puts aGTensorintoresult_namesandscf_if_dispatchrejects it. Three formulations were tried cold;all fail identically. Fixing this means changing
kernels/common/tensor_shim.py,shared by four other kernels.
KEY_T(i32 or i64 by grid extent) andGTensor.loadreturns a raw value typed by that dtype, so operator compares don'tapply and
fx.Int32offsets take a differentbuffer_loadpath.at C_OUT=512 (median-of-7, range 184.8-186.4) — the same instruction-scheduling class
of exception Port GEMM/MoE/conv kernels to the layout API #913 documents for
mxfp_moegemm1 andfp8_4wave.Also note this kernel does not use
buffer_ops/create_buffer_resourcedirectly, so thelayout-API port in #913 does not apply to it; it goes through the shared
GTensorshim.Test plan
39 tests: generic shapes (K=3 and K=5, non-divisible channel counts, degenerate C=1),
[N,4]batched coords, explicit vs derivedspatial_shape, dense-vs-Z-Delta bitequality, the >2^31-cell grid, 32- vs 64-bit key selection, weight-cache invalidation and
eviction, and full-tap coverage of the compacted pair list.
Open questions before this leaves draft
~2.5e-3; across a 22-layer stack with normalisation and residuals it compounds to
~1.3e-2 (cosine similarity 0.99994, argmax agreement 98.6%) — but that is synthetic
weights, not a real mIoU comparison. This should gate any accuracy-sensitive use.
generated coordinates, not a real SemanticKITTI frame.
torch.sortand the compaction scan; the GPUkernels are pure FlyDSL. Replacing those host ops with hand-written kernels was tried
and measured 2-8x slower (torch's nonzero/cumsum are parallel scans; the
replacement serialised on atomics). Flagging in case the repo prefers a different
dependency posture.
🤖 Generated with Claude Code