Skip to content

perf(kernels): fused softmax, norms and banded SDPA kernels (wasm sidecar + JVM FFM) - #1426

Merged
michalharakal merged 4 commits into
SKaiNET-developers:developfrom
michalharakal:feature/wasm-fused-ops
Oct 20, 2026
Merged

michalharakal merged 4 commits into
SKaiNET-developers:developfrom
michalharakal:feature/wasm-fused-ops

Conversation

@michalharakal

Copy link
Copy Markdown
Contributor

Summary

Fifth step of the JVM ↔ wasm compute parity series (after #1363, #1364, #1368, #1369): the ops that were scalar Kotlin on both tiers — softmax, the normalisations and scaled dot-product attention — become one C kernel each, bound through FFM on the JVM and in the SIMD128 sidecar on wasmJs. "Both directions" parity: the JVM gains native attention and norms it never had, the browser gets the last missing piece of a transformer block resident in the sidecar.

Kernels (f32_fused.c, shared tree, SIMD128 + NEON + portable bodies, row-pool threading):

  • skainet_f32_softmax_rows — softmax / log-softmax per row.
  • skainet_f32_layer_norm, skainet_f32_rms_norm — one pass per row, optional affine.
  • skainet_f32_sdpa — the full scaledDotProductAttention contract: grouped-query heads, causal, sliding window with rightContext, per-batch keyLengths, and the dense additive mask with its trailing-aligned broadcast. Masked keys score -1e30 and are never multiplied, so a fully padded row stays finite and uniform as the reference does. Query rows are the threading unit (12 heads × 150 tokens → 1800 rows across the pool).

SPI (skainet-backend-api): Fp32SoftmaxKernel, Fp32NormKernel, Fp32AttentionKernel behind KernelProvider.softmaxFp32() / normFp32() / attentionFp32() (default null); supports("softmax" | "layer_norm" | "rms_norm" | "sdpa", ["Float32"]). Served by NativeKernelProvider (FFM) and WasmKernelProvider.

JVM (DefaultCpuOpsJvm): softmax/logSoftmax over the last dimension (from 1024 elements) and scaledDotProductAttention go to the native kernels when the FFM provider is loaded; otherwise the common implementation serves unchanged. The common SDPA's validation is factored into protected helpers (requireSdpaOperands, sdpaKeyLengths, sdpaMaskDims) that the overrides share, so the contract cannot drift.

wasm (WasmCpuOps): the same three ops run in the sidecar with resident outputs, so q/k/v = x · Wᵀ → sdpa → · Wₒᵀ never leaves linear memory.

FusedNormOps (skainet-lang-core): an optional interface beside TensorOps — layerNorm / rmsNorm over the trailing dimensions — which LayerNormalization consults for FP32 input before its decomposed chain. Only the sidecar and the JVM FFM op sets implement it; recording and differentiating op sets never see it, so recorded graphs, lowerings and gradients are unchanged. TensorOps itself is untouched.

Bench: engine-softmax, engine-layer-norm, engine-sdpa on both harnesses (--provider ffm is the fused path on the JVM, panama/scalar the common paths).

A latent loader failure fixed on the way. GCC with -ffast-math and glibc's math-vector.h auto-vectorises a loop over expf/tanhf/erf into libmvec calls (_ZGVbN4v_expf …) that libskainet_kernels.so is not linked against; the symbol resolves lazily, so the process is terminated by the loader at the first call (exit 127, no crash log). The JVM never called such a loop before this PR — f32_ops.c from #1369 carried four of these references unnoticed — and the new softmax loop was the first to hit it. The transcendentals in element loops now go through opaque scalar wrappers (skainet_expf / skainet_tanhf / skainet_erf in skainet_simd.h); the library has no libmvec reference left (nm -D checked), which also keeps it loadable on glibc builds without libmvec (aarch64 before 2.38).

Measured

Node, 12-core laptop, medians; wasm numbers from wasm-bench, JVM from jvm-cpu-publish (--provider ffm = fused native kernels, panama = common Kotlin path with the Vector API elementwise ops and the context's schedule).

Scenario JVM panama (common path) JVM ffm (fused) wasm scalar wasm simd128-st wasm simd128-mt
engine-sdpa (12 heads × 150 × 150, head dim 64, causal) — GFLOP/s 10.8 (per-head scalar, all cores via the schedule) 27.9 0.47 13.45 35.24
engine-softmax ([1024, 1024]) — Melem/s 90 208 37.5 104 228
engine-layer-norm (LayerNormalization with affine, [1024, 768]) — Melem/s 5.4 316 2.1 247 351

Attention on the wasm tier goes from 0.47 to 35 GFLOP/s (75×) and on the JVM from 10.8 (the scalar per-head loop spread over all cores) to 27.9 (vectorised dots, four pool threads). A layer norm goes from 2 → 350 Melem/s on wasm and 5 → 316 on the JVM: the decomposed chain allocated ten intermediates per call on both tiers, which is where the time went; the fused pass allocates one output (resident on wasm). The other engine-* scenarios and laya.chain are unchanged against #1369 within noise.

Verification

  • :skainet-backends:skainet-backend-native-cpu:jvmTest — new NativeFusedKernelsTest: softmax / log-softmax (bit-identical to the kernel called directly, rows sum to one, small and non-last-dim tensors keep the common loop), LayerNormalization with and without affine and over two trailing dims against a double-precision reference (bit-identical to the kernel → the hook answered), RMS norm, attention over every mask rule (GQA × shapes × causal × lengths × window/rightContext) against the common kernel and against the dense -1e30 mask, three mask broadcast shapes, a fully padded row, and the 12 × 150 × 64 shape that threads. KernelSupportMatrixTest gains the three fused rows and gates them against the FFM provider.
  • :skainet-backends:skainet-backend-wasm-simd:wasmJsNodeTest — new test: the same ops on the sidecar against the scalar lane, outputs resident, a projection feeding attention without a download, structured rules vs the dense mask bit for bit on the sidecar kernel. Plus the existing eleven.
  • C: f32_fused.c compiled for wasm32 SIMD128 (emcc 4.0.14), the host portable branch and aarch64 NEON (syntax check); the JVM native library builds it.
  • scripts/pr-gate.sh --quick, full gate, apiDump.

Next in the series: the browser IO gaps (WP-D1).

…l on the JVM

Wasm: the sidecar exports skainet_ternary_f32_gemv, skainet_ternary_lmhead_stage1 and
skainet_bitnet_gemv_tq2_0. The portable branch of skainet_ternary_f32.c (MSVC, Emscripten) holds
the vendored decode table as a compile-time constant with a SIMD128 body and threads over output
rows through the shared row pool; bitnet_gemv.c gains a SIMD128 block body and the same row
threading on every tier. WasmTernaryKernelPack (installed by WasmSimd.install) registers the three
native faces under the dispatch keys the JVM FFM and Android JNI ternary packs serve.

JVM: Fp32MatmulWtKernel / KernelProvider.matmulFp32Wt() and the JVM-only Fp32MemSegMatmulWtKernel /
MemSegKernelProvider.matmulFp32WtMemSeg() expose skainet_fp32_matmul_wt; NativeFp32MatmulWtKernel
binds it with a heap and a segment face. DefaultCpuOpsJvm.matmulWeightTransposed multiplies a dense
[out, in] FP32 weight in place over a resident off-heap weight instead of transposing and caching a
copy. WasmKernelProvider exposes the same accessor.

Kernel support matrix: a fourth table, "Kernel packs (view dispatch)". Bench: engine-fp32-linear
on both harnesses. Docs: eager-execution ternary rows and x·Wᵀ row; the wasm ternary gap is closed.
…ion and reduction kernels

PlatformStorage.offHeapAllocator (Kotlin/Wasm) binds MemoryDomain.HOST_OFFHEAP to a backend's
memory. WasmSimd.install() binds it to the SIMD128 sidecar's linear memory: WasmLinearStorage
(Storage.OffHeap: pointer, bulk copies, slices, close frees) and WasmLinearTensorData
(FloatArrayTensorData whose heap mirror is downloaded once on first access), so a ForwardScope
frees sidecar activations at reset() and StorageCapabilities reports off-heap support in a
browser. Ambient outputs are freed through a FinalizationRegistry; WasmSimd.residentBudgetBytes
bounds what stays resident.

WasmCpuOps keeps matmul / matmulWeightTransposed outputs resident and runs add/subtract/multiply/
divide, relu/sigmoid/silu/gelu/erf/tanh and sum/mean in the sidecar on resident operands
(transcendentals on heap operands too); two heap operands keep the common path. toResident()
uploads an input once.

New shared C kernels in f32_ops.c (skainet_f32_binary/unary/reduce_all/reduce_axis; SIMD128 and
NEON bodies, row-pool chunks), compiled into the native library and the sidecar. wasm-bench gains
the laya.chain case and resident-operand variants of the add and sum scenarios.
…sm sidecar and the JVM FFM provider

New shared C kernels in f32_fused.c — skainet_f32_softmax_rows, skainet_f32_layer_norm,
skainet_f32_rms_norm and skainet_f32_sdpa (grouped-query heads, causal, sliding window with
rightContext, per-batch key lengths, dense additive mask with trailing-aligned broadcast) —
with SIMD128 and NEON bodies and row-pool threading over query rows. SPI faces
Fp32SoftmaxKernel / Fp32NormKernel / Fp32AttentionKernel behind KernelProvider.softmaxFp32() /
normFp32() / attentionFp32() (supports("softmax" | "layer_norm" | "rms_norm" | "sdpa",
["Float32"])), served by NativeKernelProvider through FFM and by WasmKernelProvider.

DefaultCpuOpsJvm routes softmax/logSoftmax over the last dimension and scaledDotProductAttention
to the native kernels when the FFM provider is loaded; WasmCpuOps runs them in the sidecar with
resident outputs. The common SDPA's validation moves into protected helpers both overrides share.
FusedNormOps (lang-core) is an optional interface beside TensorOps that LayerNormalization
consults for FP32 input; only the sidecar and the JVM FFM op sets implement it, so recording and
differentiating op sets keep the decomposition.

Also fixes a latent loader failure: GCC with -ffast-math vectorised the expf/tanhf/erf loops of
f32_ops.c into libmvec calls the shared library is not linked against, which terminated the
process at the first JVM call into such a kernel; the transcendentals now go through opaque
scalar wrappers and libskainet_kernels.so has no libmvec reference.

Bench: engine-softmax, engine-layer-norm, engine-sdpa on both harnesses. Kernel matrix: three
fused rows. Docs, CHANGELOG, apiDump.
@michalharakal
michalharakal merged commit f8ef840 into SKaiNET-developers:develop Oct 20, 2026
23 checks passed
@michalharakal
michalharakal deleted the feature/wasm-fused-ops branch October 20, 2026 14:43
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.

2 participants