SKEEP-007: The browser lane โ packed SIMD kernels and quantize-at-load
Status: Implemented (Tiers 0, 1 and 2 in engine 0.61.0; resident activations with sidecar elementwise, activation and reduction kernels, then fused softmax / normalisation / attention kernels on the wasm and JVM tiers, then the browser IO gaps โ Long Blob sources with bulk copies and worker-side blocking reads, sharded SafeTensors from Blobs, PlannerProfile.BROWSER, the browser MemoryProbe โ and convolutions as im2col + GEMM in the sidecar, in the parity series that followed); the SKaiNET-transformers consumer and the WebGPU proposal remain open
Implementation: Tier 0 #1352, Tier 2 quantize-at-load #1353, Tier 1 skainet-backend-wasm-simd #1354 โ engine 0.61.0; the consumer work in SKaiNET-transformers follows, see Rollout Plan.
Audience: SKaiNET maintainers and contributors; SKaiNET-transformers maintainers (Laya and BERT in the browser); SKaiNET-examples maintainers (every web sample runs through this lane)
Created: 2026-10-16
Origin: the SemanticEmojiFilter example ships its 23M-parameter embedding filter to the web but keeps the 322M-parameter Laya verifier on desktop and Android, because a decision in scalar Kotlin/Wasm is estimated at 10โ30 s and the FP32 weights at about 2.5 GB resident. Nothing in the repository measures the wasm lane today.
Summary
Every SKaiNET browser build runs the same path: scalar Kotlin loops compiled by Kotlin/Wasm, one
thread, GC FloatArray/ByteArray tensors. Three defects compound on that path, and a fourth
makes the compute itself slow:
-
A canonical (row-major) block-quantized weight โ every weight loaded from a GGUF file โ reaches
KernelDispatchon a target without a row-major kernel pack and lands on the decoding reference kernel, which the dispatcher itself warns is about a thousand times slower. The scalar packed kernels every target carries are keyed on kernel feed order and were never reached. -
A dense FP32
[out, in]weight is transposed into a cached heap copy before every first use, which doubles the resident weight memory of a dense encoder. -
A 16-bit token table is widened to FP32 at load (for a 256k ร 768 vocabulary, 786 MB instead of 393 MB) although the embedding lookup already knows how to read rows from a
RowDequantSource. -
Kotlin/Wasm exposes neither SIMD types nor shared-memory threads, so no Kotlin kernel can be faster than a scalar loop on one core.
The proposal fixes the three defects in common code (Tier 0, this change), adds a browser kernel
pack built from the engine’s existing C kernel sources as a SIMD128 sidecar module with Emscripten
pthreads (Tier 1), and teaches the SafeTensors loader the EncodingRequest.RequantizeTo form the
planner already prices, with a common-code Q8_0 quantizer (Tier 2). WebGPU is recorded as a later
proposal behind the same kernel-pack seam.
Motivation
Who pays today
| Sample | Browser build today | After this proposal |
|---|---|---|
KernelRace (SmolLM2-135M Q8_0) |
loads the model as dense FP32 ("in-memory FP32 fallback") |
packed Q8_0 served by the SIMD128 pack |
KllamaDemo (Qwen3-0.6B Q4_0) |
dequantizes to FP32 at load, "perceptibly slow" |
packed Q4_0, same kernels |
SemanticEmojiFilter (LEAF 23M, Laya 322M) |
LEAF only; Laya is desktop and Android |
Laya as an opt-in verifier in the browser |
TinyTransformer, MNIST, Sinus, GloVe, TDALab |
scalar dense matmul |
dense FP32 kernel from the same pack |
Where the time goes
One Laya decision at 80โ150 tokens is 10โ19 G multiply-adds, 96 % of them in four dense
matmulWeightTransposed calls per layer (Wqkv, Wi, mlp.Wo, Wo). The scalar [k, n] loop
streams the whole weight once per activation row: about 75 GB of memory traffic per decision at
150 tokens. For comparison, the same kernel class written with wasm SIMD128 intrinsics runs a
Llama-3.2-1B Q8_0 forward at roughly 270 GFLOPS on seven threads (llama.cpp #11453, measured in
wllama), which puts a Laya decision at well under a second.
Why the engine, not the sample
The kernel-pack seam (ViewKernelPack, KernelDispatch, KernelProvider) is how the JVM and
Android got their packed kernels; a browser pack behind the same seam reaches every web build
without application changes. Quantize-at-load is likewise a loader concern: today only GGUF files
can be served packed, so every Hugging Face checkpoint โ SafeTensors, F16 or BF16 โ is dense FP32
on every target.
Goals
-
A canonical packed weight never reaches the decoding reference kernel when a scalar packed kernel exists for its format (Tier 0).
-
A dense FP32
[out, in]weight is multiplied in place on tiers without a vectorised[k, n]kernel; no transpose copy is made or cached there (Tier 0). -
16-bit dense tables are `RowDequantSource`s (Tier 0).
-
The engine ships an in-memory
RandomAccessSourceand a wasm benchmark that prints GFLOPS for the matmul shapes of the shipped models (Tier 0). -
A SIMD128 kernel pack for wasmJs, built from
skainet-backend-native-cpu/native/src, with a multithreaded artifact for cross-origin-isolated pages and a single-threaded fallback (Tier 1). -
SafeTensorsParametersLoaderhonoursWeightForm, includingRequantizeTo(Q8_0); a common-code Q8_0 quantizer;skainet-quantizefor offline conversion (Tier 2). -
No behaviour change on the JVM and Apple tiers: their kernel keys and golden tests are untouched.
Non-Goals
-
WebGPU. It is the right second pack behind the same seam, but it needs its own proposal (WGSL kernels, device selection, Firefox desktop still off by default).
-
A streaming or asynchronous
RandomAccessSource; SKEEP-003 keeps that open item. -
K-quant encoders (Q4_K, Q5_K, Q6_K) at load; Q8_0 and the existing Q4_0 cover the first consumers.
-
Kotlin-side multithreading. Kotlin/Wasm has no shared-memory threads; parallelism lives inside the sidecar.
Proposed Design
Tier 0 โ common code
DefaultCpuOpsBase.matmulWeightTransposed orders the packed-weight paths by preference: an exact
kernel for the canonical view (the JVM FFM, Android JNI and browser packs), then the one-time
cached relayout into kernel feed order that the scalar packed kernels read (prepackedWeights was
declared for this and never used in common code), then the decoding reference kernel through views.
The dense branch computes x ยท Wแต straight from the weight’s rows with a 4 ร 4 tile and one
sequential accumulator per output, bit-identical to the transpose-then-matmul result. The JVM and
Apple ops override preferDirectWeightTransposed() to keep their cached transpose, because their
[k, n] kernels are vectorised.
NarrowFloatDenseTensorData (and so Fp16DenseTensorData, Bf16DenseTensorData) implements
RowDequantSource; ops.gather and the transformers Embedding layer already branch on it.
skainet-io-core gains ByteArrayRandomAccessSource (single array or equal-sized chunks for files
above the 2 GB array limit). JsBlobRandomAccessSource no longer logs to the console on every read;
its byte-by-byte ArrayBuffer conversion stays until Tier 1 (Kotlin/Wasm 2.4 has no typed-array
adapter), where the sidecar’s linear memory becomes the landing buffer for downloads.
skainet-backends/benchmarks/wasm-bench is a Kotlin/Wasm executable that times the matmul shapes
of Laya, LEAF and SmolLM2 and prints [SKAINET_PERF_WASM] lines; it is the baseline every later
tier is measured against.
Tier 1 โ skainet-backend-wasm-simd
A wasmJs-only module. Its native build compiles the existing kernel sources
(skainet-backend-native-cpu/native/src/*.c, shared, not copied) with Emscripten to
skainet_kernels_{mt,st}.{wasm,mjs}; skainet_simd.h gains wasm_simd128 bodies for the
row-major Q8_0/Q4_0/Q4_K/Q5_K/Q6_K dot products (int8 lanes widened to int16, i32x4.dot_i16x8
accumulation โ the technique of llama.cpp #11453, our code) and f32x4 bodies for the dense FP32,
FP16 and BF16 kernels. skainet_row_threads.c learns the Emscripten pool size and degrades to
the calling thread when the page is not cross-origin isolated. The artifacts ship as module
resources and are reproducible from the pinned emsdk in CI.
On the Kotlin side a WasmRowMajorMatmulKernel : ViewKernel registers under the same keys as
FfmRowMajorMatmulKernel (dense contiguous FP32 activation ร Format(FP32, encoding) weight,
BLOCKED_ROW_MAJOR, heap bytes), uploads a weight into sidecar memory once and copies activations
per call; a WasmKernelProvider serves the dense kernels; WasmCpuOps : DefaultCpuOpsBase
overrides matmul, matmulWeightTransposed, gelu and softmax while the sidecar is ready and
falls back to the Tier 0 paths otherwise. Resident activations in sidecar memory (a
Storage.WasmLinear kind) and a banded attention kernel follow once the copy overhead is measured.
Tier 2 โ quantize-at-load
lang-core gains Q8_0Quantizer (ggml-faithful, 32-element blocks, row-major) beside the existing
Q4_0Quantizer, and a Requantizers registry from TensorEncoding to quantizer.
SafeTensorsParametersLoader and the sharded loader take weightForm/weightFormFor; the
materializer gains a RequantizeTo branch producing XBlockTensorData(shape, bytes, ROW_MAJOR) and
honours WeightShapeOrientation and KERNEL_FEED. The GGUF loader’s RequantizeTo gate relaxes
from "BITNET_PLANES only" to "any encoding with a registered quantizer". PlannerProfile gains
quantizeDenseTo so a browser profile can resolve dense sources to RequantizeTo(Q8_0).
skainet-quantize (in skainet-tensor-tools) writes a GGUF with the source tensor names kept, for
downloads that should not carry F16.
Requirements
Functional
-
A canonical Q8_0/Q4_0/Q4_K/Q5_K/Q6_K/Q5_0/Q5_1 weight multiplied through
matmulWeightTransposedon a target without a row-major pack uses the scalar packed kernel after one relayout per weight. -
matmulWeightTransposedon a heap FP32[out, in]weight returns the same bits asmatmul(x, transpose(w))on every tier. -
Fp16DenseTensorData.dequantRow(r)equalsget(r, c)for every column. -
ByteArrayRandomAccessSourceserves any window across chunk boundaries. -
(Tier 1) Kernel parity against the scalar kernels within 1e-5 relative; multithreaded and single-threaded artifacts agree.
-
(Tier 2)
RequantizeTo(Q8_0)through the SafeTensors loader round-trips through the Q8_0 decoder within the format’s quantization error; the in-memory and CLI paths produce identical bytes.
Non-Functional
-
No change to kernel keys, golden tests or published API on the JVM and Apple tiers.
-
Each tier is measured with
wasm-benchbefore and after; the numbers go into this page. -
Acceptance for the whole proposal: a Laya decision under 1 s in the browser on a 4-core laptop with the multithreaded sidecar and at most 2 s single-threaded; peak tab memory under 1 GB with Q8_0 weights; KernelRace web decode at least 5ร today’s tokens per second.
Compatibility and Migration
Tier 0 is additive. The only observable change is numeric order on packed weights on non-JVM
targets, where the scalar feed-order kernel replaces the per-element reference decode; the
tolerance-based parity tests cover it, and the JVM is unaffected because it overrides the path.
Tier 1 is a new module that must be installed explicitly on wasm (no ServiceLoader); without it
every path falls back to Tier 0. Tier 2 widens loader signatures with defaulted parameters and is
marked breaking only for implementors of ParametersLoader.
Rollout Plan
-
PR 0 (this page, Tier 0): dispatch order, direct dense path, narrow-float rows, in-memory source, Blob bulk copy,
wasm-bench. -
PR 1 (Tier 2, engine):
Q8_0Quantizer,Requantizers, SafeTensors and GGUFRequantizeTo,PlannerProfile.quantizeDenseTo, theskainet-quantizetool inskainet-tensor-tools. -
PR 2 (Tier 1): the sidecar build,
skainet-backend-wasm-simd,WasmCpuOps, parity and bench. -
PR 3 (Tier 1 follow-up): resident activations, banded attention, pthread tuning โ scoped by PR 2’s measurements.
-
Engine release; then SKaiNET-transformers: a common-code
LayaModelLoaderover aRandomAccessSource, a packed-awareLayaWeightPreparation.splitRows, a decision-quality gate for the quantized lane; then the SemanticEmojiFilter web verifier.
Measurements
wasm-bench on Node (Kotlin/Wasm production binary, 12-core laptop, one thread), Tier 0 code,
2026-10-16 โ the baseline the following tiers are measured against. m is the token count of one
Laya decision, one LEAF query, or one decode step:
| Case | m ร n ร k | dense FP32 ms | dense GFLOPS | Q8_0 (scalar) ms | Q8_0 GFLOPS |
|---|---|---|---|---|---|
laya.Wqkv |
150 ร 2304 ร 768 |
635 |
0.84 |
1420 |
0.37 |
laya.Wi |
150 ร 2304 ร 768 |
430 |
1.24 |
1223 |
0.43 |
laya.mlpWo |
150 ร 768 ร 1152 |
158 |
1.68 |
599 |
0.44 |
laya.Wo |
150 ร 768 ร 768 |
114 |
1.55 |
330 |
0.54 |
leaf.ffn |
20 ร 1536 ร 384 |
21 |
1.13 |
61 |
0.38 |
leaf.proj |
20 ร 384 ร 384 |
3.0 |
1.96 |
9.6 |
0.61 |
smollm2.gemv |
1 ร 1536 ร 576 |
5.0 |
0.36 |
5.1 |
0.35 |
qwen06b.gemv |
1 ร 3072 ร 1024 |
22 |
0.28 |
9.9 |
0.63 |
Read across one Laya layer (Wqkv + Wi + mlpWo + Wo) this is about 1.34 s dense, so roughly 29 s for the 22-layer encoder at 150 tokens, matching the estimate in [_origin]. The scalar Q8_0 kernels are 2โ3ร slower than dense FP32 at this size: they decode per block in Kotlin, which is the gap the SIMD128 sidecar (Tier 1) closes, and why quantize-at-load (Tier 2) is a memory measure first and a speed measure only together with Tier 1.
Tier 1 on Node (same machine, same shapes, Tier 1 code, 2026-10-16)
The scalar column is the same run’s scalar pass (a quieter machine than the Tier 0 run above);
st is the SIMD128 sidecar single-threaded, mt the pthreads build with four workers.
| Case | scalar dense | st dense | mt dense | scalar Q8_0 | st Q8_0 | mt Q8_0 |
|---|---|---|---|---|---|---|
laya.Wqkv (150ร2304ร768) |
2.5 GF (212 ms) |
7.6 GF (70 ms) |
23.6 GF (22.5 ms) |
0.72 GF (740 ms) |
8.5 GF (63 ms) |
26.7 GF (19.9 ms) |
laya.Wi (150ร2304ร768) |
2.5 |
7.2 |
25.1 |
0.72 |
8.2 |
26.8 |
laya.mlpWo (150ร768ร1152) |
2.5 |
7.9 |
23.3 |
0.71 |
8.5 |
28.1 |
laya.Wo (150ร768ร768) |
2.5 |
7.7 |
23.8 |
0.71 |
7.7 |
27.2 |
leaf.ffn (20ร1536ร384) |
2.7 |
6.6 |
12.6 |
0.72 |
8.3 |
28.3 |
leaf.proj (20ร384ร384) |
2.6 |
6.4 |
6.2 |
0.71 |
8.1 |
8.0 |
smollm2.gemv (1ร1536ร576) |
0.60 |
0.99 |
0.98 |
0.73 |
8.4 |
16.5 |
qwen06b.gemv (1ร3072ร1024) |
0.61 |
0.91 |
0.94 |
0.72 |
8.5 |
27.4 |
Read across one Laya layer on Q8_0: about 56 ms with pthreads, so roughly 1.2 s for the 22-layer encoder at 150 tokens โ against 29 s scalar in the Tier 0 run, within the acceptance band of this proposal before the resident-activation step. The single-token dense rows (gemv, 1 GFLOPS) showed the cost of re-uploading a dense weight per call; with dense weights resident like the packed ones the same rows measure 8.6 GF (st) and 23.6 GF (mt) for smollm2.gemv and 6.6 / 23.0 GF for qwen06b.gemv. Small shapes (leaf.proj) are bound by the per-call copies and the thread hand-off, which is what resident activations address next.
Acceptance Criteria
-
wasm-benchnumbers recorded in Measurements for Tier 0 (done) and Tier 1 (done); Tier 2 adds memory, not speed, and is measured with the Laya consumer. -
kernel-support-matrix.adocshows the browser pack’s formats and the drift test passes. -
The sample measurements in Who pays today are updated from a real browser run.
Risks
-
Copy overhead across the Kotlin/Wasm โ sidecar boundary (about 440 MB of activation copies per Laya decision at 150 tokens if nothing stays resident) โ measured in PR 2 before PR 3 decides.
-
Emscripten in CI and reproducible
.wasmartifacts; pthreads need cross-origin isolation, which GitHub Pages cannot set in headers (the examples site already uses a service-worker shim). -
Quantization changes Laya’s decisions; the consumer gate is decision agreement, not FP32 parity.
-
wasm32 caps linear memory at 4 GB across the Kotlin heap and the sidecar: Q8_0 Laya fits, dense FP32 does not, so Tier 2 is a prerequisite for that model rather than an option.
Open Questions
-
Whether the dense FP32 fast path in
matmul(the[k, n]loop) should also route throughKernelDispatchfirst, so a registered dense kernel wins without an ops override. Deferred to PR 2, where the first such kernel appears. -
Whether
TensorOpsshould grow a fusedlayerNormโ answered without touchingTensorOps: the optionalFusedNormOpsinterface beside it, whichLayerNormalizationconsults for FP32 input and only the sidecar and the JVM FFM op sets implement, so recording and differentiating op sets keep the decomposition.
References
-
docs/modules/ROOT/pages/explanation/kernel-selection.adoc,eager-execution.adoc,reference/kernel-support-matrix.adoc -
SKEEP-003 (unified tensor storage; synchronous
RandomAccessSourceon JS/Wasm), SKEEP-005 (schedules on single-threaded runtimes), issues #288, #973, #1094, #1096, #1109, #1124, #1150, #1275 -
llama.cpp pull request 11453 ("ggml : x2 speed for WASM by optimizing SIMD")