Skip to content

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

Draft
jiacao-amd wants to merge 10 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 10 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 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.
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.
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.
…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.
@jiacao-amd
jiacao-amd force-pushed the spconv-bf16-implicit-gemm branch from c2e72f3 to 1ad9e42 Compare July 31, 2026 01:36
jiacao-amd added 6 commits July 31, 2026 18:06
…piler

_compile_fused_tiled_kernel took kv_total (= K**3) and recovered K with
round(kv_total ** (1/3)), which needed an assert to catch a bad inverse. The
caller already has kernel_size and was squaring-then-cubing it away, so pass it
directly -- K**3 is exact and the assert has nothing left to guard. This also
matches _compile_zdelta_kernel, which already took kernel_size.

Behaviour-preserving: LUT/in_rows/out_rows bit-identical on four shapes incl.
K=5, 39/39 tests pass cold.
An earlier commit (b0d838b) claimed scf.IfOp had to stay because
_collect_assigned_vars treats a method-call receiver inside a branch as
branch state, so any GTensor used in the body lands in result_names and
scf_if_dispatch rejects it with "state variable 'lut_' is GTensor, not an
MLIR Value".

The diagnosis was right; the conclusion was not. The receiver only becomes
state if it is a symbol from an ENCLOSING scope -- _collect_assigned_vars
filters through in_active_symbols(). Constructing the GTensor inside the
branch that uses it makes the name local to that branch, so nothing is
collected and the plain `if` lowers normally. Deeper branches each need
their own local view; that is why the first attempt at this still failed
one level down.

All nine scf.IfOp sites convert -- the scatter kernel, both mapping kernels
(4-deep nesting in zdelta: o_ok / binary-search / p_ok / hit), and the GEMM
epilogue. The scf import is now unused and removed.

The epilogue comment about fx.Index / vector.extract is unchanged and still
accurate: this commit only replaces the branch construct there, not the
index arithmetic that carried the 9% cost.

Verified with FLYDSL_RUNTIME_ENABLE_CACHE=0 throughout, since a stale
compile cache is what produced the wrong conclusion the first time:
  - 39/39 tests pass
  - LUT, mask, active_kv_ids and active_count BIT-IDENTICAL to the
    pre-change baseline on four shapes including K=5
  - a real runtime branch survives lowering (one llvm.cond_br in the final
    IR), so the `if` is not being constant-folded away
  - no perf change at the shape that motivated the original epilogue note:
    N=100k C=128 174.5 -> 174.7 us, C=512 1143.4 -> 1145.0 us
    (median-of-7; 0.1%, within run-to-run noise)
The four lru_cache sizes here (4/8/16/32) were the smallest in the whole
kernel library -- 4, 8 and 16 were each the only occurrence of that value
across all 40 compile caches under kernels/. The convention in this very
directory is 64 for the helper kernels and 256 for the main one:

  conv3d_implicit.py      compile_transpose_ncdhw_ndhwc     64
                          compile_conv3d_implicit          256
  conv3d_implicit_fp8.py  all three                         64

This now follows it: the three mapping kernels get 64, the GEMM gets 256.

The old numbers came from counting the key space and adding a small margin,
which is the wrong thing to optimize. A cache entry is one compiled function
object; an eviction is a full recompile, measured here at 709 ms against a
0.05 ms cached call -- roughly 14,000x. The trade is that asymmetric, so
every other kernel in the repo just picks a comfortably large number.

Counting also got one of them wrong. The zdelta key includes n_bits, which
is derived from N at run time:

    n_bits = max(1, int(N - 1).bit_length() + 1)

A PTv3-style stage sweep (N = 338..100k) yields 7 distinct n_bits, times
kernel_size in (3, 5) = 14 keys against maxsize=16. Two spare slots, and
the failure mode is not an error but an intermittent 709 ms stall.

39/39 tests pass with FLYDSL_RUNTIME_ENABLE_CACHE=0.
build_compacted_pairs was the largest single stage of the map path -- 297 us of
the 443 us total at nuScenes scale (N=91k), against 38 us for the GEMM it feeds.
Profiling it statement by statement showed the time was not in data movement:
two host-device syncs cost 79 us (34%) and ~17 torch ops carry ~75 us of pure
dispatch, while a single pass that only reads the lut takes 25 us.

An earlier attempt to fuse this into one kernel failed because it replaced the
prefix sum with atomic counters and serialised 380k pairs onto 27 of them. The
fix is not a better atomic but a smaller scan: with one thread per (kv, tile),
the scan runs on the [KV, num_tiles] counts instead of the KV*num_tiles*block_m
lut slots -- 16x fewer elements -- so an ordinary cumsum suffices and no
device-wide scan inside a kernel is needed.

  _compile_pair_count  count valid rows per (kv, tile)
  cumsum               exclusive scan over the small counts
  _compile_pair_write  each thread writes its rows from its reserved offset
  _compile_tile_kv     emit tile_kv (replaces repeat_interleave, 54 us on its own)

Pairs stay ordered (kv major, tile, row), which is exactly what nonzero()
produced, so this is a bit-identical replacement rather than an equivalent one.
Verified against the old implementation inlined from HEAD, on 11 shapes covering
N=1, N=7, N=16, N=17 (block_m boundaries), k=3 and k=5, batched [N,4] coords,
and nuScenes at N=91090: in_rows, out_rows, tile_kv and n_tiles_c all match
exactly. 39/39 tests pass with FLYDSL_RUNTIME_ENABLE_CACHE=0.

  build_compacted_pairs   297 -> 120 us   (2.5x)
  end-to-end, no key      602 -> 297 us   (2.0x)
  end-to-end, with key     51 ->  51 us   (unchanged -- the stage is cached)

The two kernels now take 22 us combined, close to the 25 us single-read floor;
the remaining ~98 us is the cumsum and allocation glue. Note this only helps the
first layer of an indice_key-sharing stack, or callers that pass no key at all.
Drops the user-facing indice_key argument. The cache is now keyed on
(id(coords), coords._version, block_m, kernel_size), so reuse is automatic and
a different or mutated coordinate set is a miss rather than a hit.

indice_key was copied from spconv, which is the outlier among the libraries
that ship this. torchsparse's Conv3d takes no key at all and keys its kmaps on
(input.stride, kernel_size, stride, dilation); MinkowskiEngine keys on the
coordinate map identity. NVIDIA's deployed libspconv does carry a `rulebook`
string, but only as an ONNX graph attribute copied verbatim from the training
module's indice_key -- the inference API is `forward(stream)` and no caller
ever names a rulebook.

The old key had no coordinate identity in it, and the hit-time guard compared
only N, so two different coordinate sets with equal N silently shared one
rulebook. Measured on box=32 N=400 C=64: max|diff| 0.388 against |gold|max
0.525, with no exception raised. Under the new key the same sequence gives
2.98e-08. Note that spatial_shape is deliberately still absent from the key:
it selects a storage stride and a bounds check, both invariant over occupied
cells, and 60 randomized configs (k=3 and k=5, tight vs loose vs oversized
shapes, including a fully dense 3x3x3 cube where every point is on a face)
produce bit-identical LUTs -- adding it would only force needless rebuilds.

id() is recycled once a tensor is freed, so a weakref.finalize drops both
cache entries first; this is the scheme _WPACK_CACHE already uses for weights.
Verified: after three short-lived coord tensors die, both caches are empty, so
a recycled id cannot hit a stale entry. In-place mutation is caught by
_version. Two views of the same rows get different ids, which costs a rebuild
but is never a wrong hit.

A 4-layer block with the caller passing nothing now costs 464.7 us (116.2
us/layer) against 51.6 us steady-state -- the rulebook is built once. Before
this, the same code without indice_key rebuilt it every layer.

39/39 tests pass with FLYDSL_RUNTIME_ENABLE_CACHE=0.
build_compacted_pairs ran a torch cumsum plus a blocking .item() between its
count and write kernels. That block measured 88-183 us, 45-73% of the whole
compaction, against 17-129 us for the three fx kernels around it.

Replace it with a decoupled scan over the [KV, num_tiles] counts: a per-chunk
LDS scan on the full grid, a KV-way parallel scan of the chunk totals, a serial
pass over the KV row totals (KV is 27 or 125), then a grid-parallel fold into
the slot offsets. n_tiles_c is published to totals[0], so the host reads one
scalar instead of syncing on a reduction.

  compaction   ptv3 N=10k   112.5 -> 67.5 us   1.67x
               nusc N=90k   121.7 -> 68.2 us   1.78x
               nusc N=720k  317.9 -> 172.0 us  1.85x
               N=300k       233.8 -> 140.9 us  1.66x
               N=500k       329.4 -> 207.9 us  1.58x

  end to end   ptv3 N=10k   175 -> 111 us
               nusc N=91k   310 -> 220 us
               nusc N=135k  367 -> 276 us

Compaction is not free, so this moves where it pays for itself: measured
against an uncompacted pair list feeding the same gemm, the break-even shifts
from C~192-256 down to C~128-192. At N=150k, C=128 goes from -47 us to +16 us.

Also drop mask/active_kv_ids/active_count from both map kernels. Nothing
consumed them -- the gemm never took them and the coords entry point unpacked
them into throwaway locals -- but producing them cost a block barrier and a
serialized tid==0 loop over all KV taps. Dense map 33.1 -> 21.8 us at N=10k.

test_compacted_path_matches_indexed toggled FLYDSL_SPCONV_COMPACT to A/B the
compacted and uncompacted paths, but nothing in kernels/ has ever read that
variable, so both halves ran the same path and the test could not fail. Rebuild
it against a pair list derived straight from the lut. Verified it now catches a
compaction bug: dropping one row in the write kernel fails all four cases.
@jiacao-amd
jiacao-amd force-pushed the spconv-bf16-implicit-gemm branch from 2d1d4cd to f9cb4f1 Compare August 1, 2026 00:04
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