Skip to content

[Kernel] Add submanifold sparse 3D convolution (bf16 implicit GEMM, gfx950) - #942

Draft
jiacao-amd wants to merge 4 commits into
ROCm:mainfrom
jiacao-amd:spconv-bf16-implicit-gemm
Draft

[Kernel] Add submanifold sparse 3D convolution (bf16 implicit GEMM, gfx950)#942
jiacao-amd wants to merge 4 commits into
ROCm:mainfrom
jiacao-amd:spconv-bf16-implicit-gemm

Conversation

@jiacao-amd

Copy link
Copy Markdown
Contributor

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.

coords --[map]--> LUT --[compact]--> pair list --[gemm]--> output

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_auto routes by bounding-box volume:

grid dense Z-Delta
195³ 41 µs 160 µs
595×595×245 93 µs 180 µs
2095²×695 cannot run (3.05e9 cells, 12.2 GB, int32 index overflow) 203 µs

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] layout
runs 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 bytes
apart, 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-*.log and perf-int8.log with no fp32 variant. An fp32 GEMM was built
first 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):

case N C µs
nuScenes 32371 128 47
nuScenes 26509 256 101
nuScenes 15052 512 172
SemKITTI 94517 128 178

A 6-layer stage sharing one indice_key runs in 0.596 ms.

API style

Commits 3-5 port the kernels to the current fx surface per the kernel-code-cleanup
skill (fx.Indexfx.Int32, arith.* → operators, vector.*fx.Vector), −34
lines 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.IfOp everywhere. _collect_assigned_vars treats the receiver of a method
    call inside a branch as branch state, so any lut_.store(...) puts a GTensor into
    result_names and scf_if_dispatch rejects it. Three formulations were tried cold;
    all fail identically. Fixing this means changing kernels/common/tensor_shim.py,
    shared by four other kernels.
  • The Z-Delta kernel. Its keys are KEY_T (i32 or i64 by grid extent) and
    GTensor.load returns a raw value typed by that dtype, so operator compares don't
    apply and fx.Int32 offsets take a different buffer_load path.
  • The GEMM epilogue. Migrating it is op-count-identical but measured 185 µs vs 170 µs
    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_moe gemm1 and fp8_4wave.

Also note this kernel does not use buffer_ops/create_buffer_resource directly, so the
layout-API port in #913 does not apply to it; it goes through the shared GTensor shim.

Test plan

FLYDSL_RUNTIME_ENABLE_CACHE=0 python3 -m pytest tests/kernels/test_sparse_conv3d_implicit.py -v

39 tests: generic shapes (K=3 and K=5, non-divisible channel counts, degenerate C=1),
[N,4] batched coords, explicit vs derived spatial_shape, dense-vs-Z-Delta bit
equality, 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

  1. bf16 accuracy is not validated against a pretrained model. Per-layer rounding is
    ~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.
  2. BIG grids are only exercised synthetically. The 3.05e9-cell path is tested with
    generated coordinates, not a real SemanticKITTI frame.
  3. The host side uses torch for allocation, torch.sort 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; the
    replacement serialised on atomics). Flagging in case the repo prefers a different
    dependency posture.

🤖 Generated with Claude Code

jiacao-amd and others added 4 commits July 31, 2026 01:35
…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
jiacao-amd force-pushed the spconv-bf16-implicit-gemm branch from c2e72f3 to 1ad9e42 Compare July 31, 2026 01:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant