Skip to content

perf(native-cpu): AVX2/FMA register-tiled lane for the dense FP32 SGEMM on x86-64 - #1445

Open
michalharakal wants to merge 2 commits into
SKaiNET-developers:developfrom
michalharakal:feat/x86-avx2-fp32-gemm
Open

michalharakal wants to merge 2 commits into
SKaiNET-developers:developfrom
michalharakal:feat/x86-avx2-fp32-gemm

Conversation

@michalharakal

@michalharakal michalharakal commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

skainet_fp32_matmul — the plain row-major SGEMM behind every FP32 A · B the JVM hands to the native lane (a weight stored [in, out], attention scores and values above the small-shape threshold, training) — gets the x86-64 AVX2/FMA lane that #1442 gave x · Wᵀ. Until now the x86-64 library, built for baseline x86-64, ran this kernel as a scalar/SSE2 outer-product loop without FMA that loaded and stored a row of C for every row of A; only the wasm SIMD128 sidecar had a register tile.

Follow-on F1 of the plan behind #1442. C only, no Kotlin API change.

What changes

  • fp32_matmul.c: new skainet_fp32_block_avx2, compiled per function with target("avx2,fma") (empty on MSVC, which emits VEX intrinsics without /arch) and selected once per call by the cached cpuid probe from skainet_cpu_features.h. The SIMD128 micro-kernel grown to the register file:
    • a 6 × 16 tile of C in twelve __m256 accumulators — two B loads and six broadcasts feed twelve FMAs per p, so the loop sits on the two FMA ports (hot loop verified spill-free in the disassembly);
    • the same 256-deep k-blocking, partial sums carried in C;
    • each sixteen-column panel of B is packed once per k-block. In place, a power-of-two n puts every row of the panel in the same L1 set (4 KiB stride, 8 ways): 63 × 1024 × 1024 ran at half the speed of 63 × 1021 × 1023 before packing. Blocks under two row tiles read B in place;
    • rows left under six: 2 × 16 and 1 × 16 tiles; columns left under sixteen: a zero-padded tail panel and a 16-wide C stripe (lane-masked copy in and out), so the tail runs through the same tiles at full speed.
  • Numerics: every C element takes one fused multiply-add per p in ascending p whichever tile holds it, so sliced, threaded and whole calls stay bit-identical to each other (the row pool and any sub-matrix call rely on that). Against the previous x86 numbers an output moves within rounding — mul-then-add became one fused step — the way the NEON and wasm lanes already differ. Machines without AVX2, and every other architecture, run the previous code.
  • skainet-backend-jni-cpu CMake adds skainet_cpu_features.c to its kernel source list: the Android library compiles fp32_matmul.c for the x86_64 emulator ABI too, which now references the probe (first CI run failed to link there; verified with the pinned NDK 28.2 for both ABIs).
  • NativeFp32MatmulKernel KDoc describes the lanes instead of "auto-vectorizes into FMA (vfmadd231ps on x86_64)", which was never true for the baseline build.
  • NativeFp32MatmulKernel copy-back fix (found by the new strided test): the heap face stages C off-heap and copied the whole staged reach back into out, so a sub-block output (offset, or stride wider than n) had everything before the offset and the padding between rows zeroed. Only the m × n block is copied back now. Pre-existing; its own ### Fixed entry.
  • CHANGELOG ### Changed + ### Fixed entries under Unreleased.

Tests

New NativeFp32MatmulX86LaneTest (runs the portable lane on hosts without AVX2):

  • everyTileEdgeMatchesTheDoubleReference: m ∈ {1, 2, 3, 5, 6, 7, 13, 63} × n ∈ {1, 7, 8, 15, 16, 17, 33, 1021} × k ∈ {1, 255, 257, 1023} against a double-accumulated reference (maxAbs · 1e-5 + 1e-6).
  • stridedOperandsAndAnOffsetOutputLeaveThePaddingAlone: non-contiguous A/B/C strides with offsets; padding stays untouched.
  • anOutputDoesNotDependOnTheBlockThatComputesIt: full call vs rows split at 5, columns split at 3, row-at-a-time, including a shape the pool splits across threads and a pack-vs-in-place pair — assertContentEquals.
  • singleCallBench behind -Dskainet.runBench=true, through a direct downcall with all three operands off-heap (the SPI face copies A, B, C per call and would hide the kernel).

Suites: skainet-backend-native-cpu:jvmTest, skainet-backend-cpu:jvmTest + apiCheck — see the checklist below. A standalone C harness with the module's own skainet_row_threads.c swept 1763 shape/stride/offset/split cases (0 failures) before the Gradle round.

Measured

i7-9750H (6c/12t), GCC 13.3, single call, develop → this PR. The C row pool threads over the longer side when it is ≥ 512, so the first eight rows run on four threads and 192 × 192 × 64 on one.

m / n / k develop GFLOP/s PR GFLOP/s
63 / 1024 / 1024 21.4 280.9
183 / 1024 / 1024 26.1 311.7
63 / 2624 / 1024 31.5 288.6
63 / 1024 / 2624 11.5 238.8
1024 / 1024 / 1024 47.8 310.8
512 / 512 / 512 58.8 357.7
512 / 512 / 64 65.8 328.4
192 / 192 / 64 (one thread) 18.1 98.8
1 / 4096 / 4096 (gemv) 9.1 11.3

The gemv row is DRAM-bound (64 MB of B per call) and is included to show the lane does not regress it. The same shapes through the JVM downcall (NativeFp32MatmulX86LaneTest.singleCallBench, JDK 21, operands off-heap):

[gemm-x86] m=63 n=1024 k=1024: 0.531 ms/call 249.0 GFLOP/s
[gemm-x86] m=183 n=1024 k=1024: 1.413 ms/call 271.6 GFLOP/s
[gemm-x86] m=63 n=2624 k=1024: 1.212 ms/call 279.4 GFLOP/s
[gemm-x86] m=63 n=1024 k=2624: 1.502 ms/call 225.4 GFLOP/s
[gemm-x86] m=1024 n=1024 k=1024: 6.905 ms/call 311.0 GFLOP/s
[gemm-x86] m=512 n=512 k=512: 0.852 ms/call 314.9 GFLOP/s
[gemm-x86] m=192 n=192 k=64: 0.045 ms/call 103.8 GFLOP/s
[gemm-x86] m=1 n=4096 k=4096: 3.856 ms/call 8.7 GFLOP/s

Checklist

  • :skainet-backends:skainet-backend-native-cpu:jvmTest green — 181 tests, 0 failures (incl. the new lane test)
  • :skainet-backends:skainet-backend-cpu:jvmTest + apiCheck green — 492 tests, 0 failures
  • CI windows-x86_64 (MSVC) and macos-arm64 legs green — the MSVC branch cannot be exercised locally

…MM on x86-64

skainet_fp32_matmul had register-tiled bodies for wasm SIMD128 only; the
x86-64 library is built for baseline x86-64, so the kernel behind every
FP32 A·B the JVM hands to the native lane ran a scalar/SSE2 outer-product
loop without FMA. Hosts with AVX2+FMA now select, through the cached cpuid
probe from the x·Wᵀ lane, a per-function avx2+fma body: a 6×16 tile of C
in twelve eight-lane FMA accumulators over a 256-deep k-block, each
sixteen-column panel of B packed once (a power-of-two n otherwise maps
every panel row to one L1 set), 2×16 / 1×16 tiles for the rows left over
and a lane-masked stripe for the columns left over. Every output takes one
fused step per p in ascending p whichever tile holds it, so sliced,
threaded and whole calls stay bit-identical to each other.

Measured single call (i7-9750H, develop → this): 63×1024×1024 21 → 281
GFLOP/s, 1024³ 48 → 311, 512³ 59 → 358, 192×192×64 one thread 18 → 99.

Also: NativeFp32MatmulKernel copied the whole staged C reach back into
`out`, zeroing everything before `outOffset` and the stride padding of a
sub-block caller's buffer; only the m×n block is copied back now.

New NativeFp32MatmulX86LaneTest: tile edges vs a double reference,
strided/offset operands with untouched padding, block independence
bit-for-bit, opt-in single-call bench on a direct downcall.
…l library

The Android library builds fp32_matmul.c for the x86_64 emulator ABI as
well, and that file now calls skainet_cpu_has_avx2_fma(); the probe source
was missing from its CMake list, so the x86_64 link failed. Verified with
the pinned NDK for both ABIs.

This branch has not been deployed

No deployments
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