Skip to content

feat(wasm): resident activations in the sidecar; elementwise, activation and reduction kernels - #1369

Merged
michalharakal merged 3 commits into
SKaiNET-developers:developfrom
michalharakal:feature/wasm-resident-activations
Oct 20, 2026
Merged

michalharakal merged 3 commits into
SKaiNET-developers:developfrom
michalharakal:feature/wasm-resident-activations

Conversation

@michalharakal

@michalharakal michalharakal commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fourth step of the JVM ↔ wasm compute parity series (after #1363, #1364, #1368): activations stay resident in the SIMD128 sidecar between kernels, and the ops between two matmuls — elementwise arithmetic, the activations, sum/mean — run there too. This is the "resident activations" step SKEEP-007 left open.

Why. Every matmul on the wasm tier already ran in the sidecar, but its output was copied back to the Kotlin heap, and everything until the next matmul (bias add, GELU, gated multiply, residual add, the reductions of a norm) ran as scalar Kotlin on one thread. Kotlin/Wasm has no SIMD and no shared-memory threads, and copying an activation into the sidecar per op costs about what the scalar loop does — so the only way to use the sidecar for those ops is to not copy at all.

Storage. PlatformStorage.offHeapAllocator (Kotlin/Wasm, skainet-lang-core) is a hook that binds MemoryDomain.HOST_OFFHEAP to a backend's memory, the same pattern as the ops override; lang-core cannot depend on the wasm module. WasmSimd.install() binds it to the sidecar's linear memory: WasmLinearStorage : Storage.OffHeap (pointer, bulk copyInto/copyFrom, slices, close() frees) and WasmLinearTensorData : FloatArrayTensorData (a resident tensor; its heap mirror is downloaded once, on the first access to buffer, an element, copyToFloatArray() or view, so every common fast path keeps working on it unchanged). StorageCapabilities.current() reports off-heap support in a browser while installed; a ForwardScope or ModelScope asking for HOST_OFFHEAP gets sidecar memory that its reset()/close() frees. Ambient outputs are registered with a FinalizationRegistry and freed when their tensor is collected; WasmSimd.residentBudgetBytes (768 MB by default) bounds what can be pinned — above it the kernels still run in the sidecar and the result is read back to the heap.

Ops. WasmCpuOps keeps the outputs of matmul / matmulWeightTransposed resident, and runs add/subtract/multiply/divide (same shape, scalar and last-dim bias broadcasts), relu/sigmoid/silu/gelu (both forms)/erf/tanh and sum/mean (all dims or one) in the sidecar when an operand is resident; the transcendental activations go there for heap operands too (the loop costs more than the upload). Two heap operands of an elementwise op stay on the common lane by design, so a notebook-style a + b is unchanged. WasmCpuOps.toResident() uploads an input once for callers that want the chain to start resident.

Kernels. New shared C file f32_ops.c: skainet_f32_binary (op code, per-operand index mode), skainet_f32_unary (op code), skainet_f32_reduce_all, skainet_f32_reduce_axis, with SIMD128 and NEON bodies for the arithmetic, relu and the reductions, scalar libm for the transcendentals (same formulas as the Kotlin reference ops), and row-pool threading over output chunks. Compiled into every native build as well (the JVM keeps its Vector API paths; a binding is a few lines when wanted).

Bench. wasm-bench gains laya.chain (x · Wᵀ, GELU, multiply, bias add, sum, end to end at Laya's MLP shape) and operands=resident variants of engine-elementwise-add and engine-reductions-sum; the heap-operand lines stay as the like-for-like comparison with the JVM.

Measured (Node, 12-core laptop; medians, residency on unless noted)

Case scalar lane simd128-st simd128-mt
laya.chain (150×768 · (2304×768)ᵀ, GELU, multiply, bias add, sum — end to end) 225.5 ms 71.3 ms 20.3 ms
laya.chain with SKAINET_WASM_RESIDENT=0 (sidecar kernels, every output read back) 223.7 ms 75.9 ms 24.9 ms
engine-elementwise-add, 1M, resident operands — 540 Melem/s 1441 Melem/s
engine-elementwise-add, 1M, heap operands (unchanged path) 334 Melem/s 214 312
engine-reductions-sum, 1M, resident operand — 10.2 Gelem/s 13.4 Gelem/s
engine-reductions-sum, 1M, heap operand (unchanged path) 1042 Melem/s 1018 1042

The chain is the number this step is about: the matmul alone is about 62 / 17 ms on the two sidecar tiers, so the four ops after it went from roughly 160 ms of scalar Kotlin to a few milliseconds. The A/B row shows the split: most of that is the kernels running in the sidecar at all; keeping the intermediates resident takes the rest. The other engine-* scenarios are unchanged against #1368 within noise (the packed and ternary mt figures were verified back to back with and without residency).

Verification

  • :skainet-backends:skainet-backend-wasm-simd:wasmJsNodeTest — new: an activation chain (matmul → gelu → multiply → bias add → relu/sigmoid/silu/tanh/erf/gelu-exact, subtract/divide with scalar and bias broadcasts, sum/mean over all and one axis) stays resident and matches the scalar lane; routing (heap operands common, transcendentals and matmul outputs resident, toResident, element/view/common-op reads through the mirror); scoped outputs freed at ForwardScope.reset() with a StorageClosedException afterwards, the budget fallback, and the storage round trip (zeroed allocation, copyFrom/copyInto, slice, close, StorageCapabilities while installed/uninstalled). Plus the existing eight tests.
  • C: f32_ops.c compiled for wasm32 SIMD128 (emcc 4.0.14), the host portable branch and aarch64 NEON (syntax check); the JVM native library builds it (quick gate).
  • scripts/pr-gate.sh --quick, full gate (lang-core and backend wasmJs/js/wasi test legs included).

Next in the series: fused softmax, normalisations and banded SDPA kernels on both tiers (wasm + JVM FFM), then the browser IO gaps.

…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.
@michalharakal
michalharakal merged commit b49cbf0 into SKaiNET-developers:develop Oct 20, 2026
23 checks passed
@michalharakal
michalharakal deleted the feature/wasm-resident-activations branch October 20, 2026 14:44
michalharakal added a commit that referenced this pull request Oct 21, 2026
CHANGELOG [0.62.0] - 2026-10-21 with the parity headline over #1363, #1364,
#1368, #1369, #1426, #1427, #1428 and #1430, plus entries for the operator
coverage matrix (#1370), the BitNet decode allocation and threading fixes
(#1359, #1360, #1361, #1362), the ClassificationSupport extraction (#1429)
and the toolchain bumps (Kotlin 2.4.21, kotest 6.2.6, Gradle 9.8.1).
VERSION_NAME, CITATION, Antora version, README bom coordinate, What's New
and contributors, Android tutorial coordinates; regenerated operator and
kernel-support matrices; SKEEP-007 rollout step 3 marked shipped.
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