Repository navigation
perf(kernels): fused softmax, norms and banded SDPA kernels (wasm sidecar + JVM FFM) - #1426
Merged
michalharakal merged 4 commits intoOct 20, 2026
Conversation
…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.
…on A/B; chain line reports the budget
…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.
This was referenced Oct 20, 2026
aharakal
approved these changes
Oct 20, 2026
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
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 fullscaledDotProductAttentioncontract: grouped-query heads,causal, slidingwindowwithrightContext, per-batchkeyLengths, and the dense additive mask with its trailing-aligned broadcast. Masked keys score-1e30and 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,Fp32AttentionKernelbehindKernelProvider.softmaxFp32()/normFp32()/attentionFp32()(defaultnull);supports("softmax" | "layer_norm" | "rms_norm" | "sdpa", ["Float32"]). Served byNativeKernelProvider(FFM) andWasmKernelProvider.JVM (
DefaultCpuOpsJvm):softmax/logSoftmaxover the last dimension (from 1024 elements) andscaledDotProductAttentiongo 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, soq/k/v = x · Wᵀ → sdpa → · Wₒᵀnever leaves linear memory.FusedNormOps(skainet-lang-core): an optional interface besideTensorOps—layerNorm/rmsNormover the trailing dimensions — whichLayerNormalizationconsults 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.TensorOpsitself is untouched.Bench:
engine-softmax,engine-layer-norm,engine-sdpaon both harnesses (--provider ffmis the fused path on the JVM,panama/scalarthe common paths).A latent loader failure fixed on the way. GCC with
-ffast-mathand glibc'smath-vector.hauto-vectorises a loop overexpf/tanhf/erfinto libmvec calls (_ZGVbN4v_expf…) thatlibskainet_kernels.sois 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.cfrom #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_erfinskainet_simd.h); the library has no libmvec reference left (nm -Dchecked), 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 fromjvm-cpu-publish(--provider ffm= fused native kernels,panama= common Kotlin path with the Vector API elementwise ops and the context's schedule).engine-sdpa(12 heads × 150 × 150, head dim 64, causal) — GFLOP/sengine-softmax([1024, 1024]) — Melem/sengine-layer-norm(LayerNormalizationwith affine,[1024, 768]) — Melem/sAttention 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 andlaya.chainare unchanged against #1369 within noise.Verification
:skainet-backends:skainet-backend-native-cpu:jvmTest— newNativeFusedKernelsTest: softmax / log-softmax (bit-identical to the kernel called directly, rows sum to one, small and non-last-dim tensors keep the common loop),LayerNormalizationwith 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-1e30mask, three mask broadcast shapes, a fully padded row, and the 12 × 150 × 64 shape that threads.KernelSupportMatrixTestgains 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.f32_fused.ccompiled 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).