Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@
- **Explicit SIMD128 bodies for every sidecar matmul kernel**: Q4_K, Q5_K, Q6_K, Q5_0 and Q5_1 join Q8_0 and Q4_0 with hand-written `__wasm_simd128__` paths (the K-quant int8 dots on `i32x4.dot_i16x8_s`, the Q5 fifth-bit plane via a byte swizzle); the dense FP32 GEMM gets a 4×8 register-blocked, k-blocked micro-kernel; BF16 and FP16 widen eight weights per step. BF16 and FP16 also gain NEON bodies (none existed on any tier). Results stay bit-identical in accumulation order to the portable bodies.
- **Threaded dense kernels**: `skainet_fp32_matmul`, `skainet_bf16_matmul` and `skainet_fp16_matmul` now split the output over the shared row-thread pool like the packed kernels do (the GEMM over the longer side of `C`, the 16-bit kernels over columns) — the mt sidecar's pthreads in a browser, the native pool on the JVM.
- **`WasmCpuOps.matmul`**: a dense 2-D `A · B` on heap FP32 operands runs the sidecar GEMM; the common `[k, n]` loop no longer serves that shape on wasm (it was the only dense path that never reached the sidecar).
- **Ternary kernels on the wasm tier**: the sidecar now exports `skainet_ternary_f32_gemv`, `skainet_ternary_lmhead_stage1` and `skainet_bitnet_gemv_tq2_0`, and `WasmTernaryKernelPack` (installed by `WasmSimd.install()`) registers them under the same `KernelDispatch` keys the JVM FFM and Android JNI ternary packs serve — exact FP32 × `BitNet-b1.58`, the fused multi-plane `BitNet-planes` lm_head and int8 × `TQ2_0`. A BitNet weight in a browser previously went to the decoding reference kernel. The portable branch of `skainet_ternary_f32.c` (MSVC and Emscripten) now holds the vendored kernel's 256-entry decode table as a compile-time constant with a SIMD128 body and threads over output rows through the shared row pool; `bitnet_gemv` gains a SIMD128 block body and the same row threading on every tier.
- **Direct `x · Wᵀ` on the JVM**: `Fp32MatmulWtKernel` / `KernelProvider.matmulFp32Wt()` (and the JVM-only `Fp32MemSegMatmulWtKernel` / `MemSegKernelProvider.matmulFp32WtMemSeg()`) expose `skainet_fp32_matmul_wt`, which the wasm sidecar has served since 0.61.0 and the JVM never bound. `NativeKernelProvider` carries it; `DefaultCpuOpsJvm.matmulWeightTransposed` multiplies a dense `[out, in]` FP32 weight in place through it — a segment-backed weight where it lies, a heap weight after one resident off-heap copy — instead of transposing the weight once into `[k, n]` and caching the copy. The wasm provider exposes the same accessor (`WasmFp32MatmulWtKernel`), and `KernelProvider.supports("matmul_wt", …)` reports it.
- **Kernel support matrix, fourth table** *Kernel packs (view dispatch)*: the ternary formats and the direct `x · Wᵀ` per platform, which the `matmul` accessors the first table introspects do not cover.
- **`engine-fp32-linear`** in `jvm-cpu-publish` and `wasm-bench`: a `Linear` layer's `x · Wᵀ` (16 tokens, 768 → 3072) through `ctx.ops.matmulWeightTransposed`; on the JVM `--provider panama` measures the cached-transpose path and `--provider ffm` the direct kernel.
- **Resident activations on the wasm tier**: `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`, `WasmLinearTensorData : FloatArrayTensorData`), so `StorageCapabilities.current()` reports off-heap support in a browser and a `ForwardScope` frees sidecar activations at `reset()`. `WasmCpuOps` now keeps the outputs of `matmul`/`matmulWeightTransposed` resident and runs `add`/`subtract`/`multiply`/`divide`, `relu`/`sigmoid`/`silu`/`gelu`/`erf`/`tanh` and `sum`/`mean` in the sidecar on resident operands (transcendentals also on heap ones), reading a tensor back to the Kotlin heap only when something outside the sidecar needs it. Ambient outputs are freed when their tensor is collected (`FinalizationRegistry`); `WasmSimd.residentBudgetBytes` bounds what stays resident and `WasmCpuOps.toResident()` uploads an input once. New shared C kernels `skainet_f32_binary` / `skainet_f32_unary` / `skainet_f32_reduce_all` / `skainet_f32_reduce_axis` (SIMD128 and NEON bodies, row-pool threading) are in every native build as well.
- **`wasm-bench`**: a `laya.chain` case (`x · Wᵀ`, GELU, multiply, bias add, sum, end to end) and `operands=resident` variants of `engine-elementwise-add` and `engine-reductions-sum`.
- **Fused softmax, normalisations and attention on both tiers**: new shared C kernels `skainet_f32_softmax_rows`, `skainet_f32_layer_norm`, `skainet_f32_rms_norm` and `skainet_f32_sdpa` (`f32_fused.c`, SIMD128 and NEON bodies, row-pool threading) — one pass each where the ops layer ran several allocating scalar passes. `skainet_f32_sdpa` carries the full `scaledDotProductAttention` contract: grouped-query heads, `causal`, the sliding window with `rightContext`, per-batch `keyLengths` and the dense additive mask with its trailing-aligned broadcast. New SPI faces `Fp32SoftmaxKernel` / `Fp32NormKernel` / `Fp32AttentionKernel` behind `KernelProvider.softmaxFp32()` / `normFp32()` / `attentionFp32()` (reported by `supports("softmax" | "layer_norm" | "rms_norm" | "sdpa", ["Float32"])`), served by `NativeKernelProvider` (FFM) and `WasmKernelProvider`. On the JVM, `DefaultCpuOpsJvm` now routes `softmax`/`logSoftmax` over the last dimension (from 1024 elements) and `scaledDotProductAttention` to the native kernels when the FFM provider is loaded, replacing the scalar per-head loop; on wasmJs `WasmCpuOps` runs them in the sidecar with the outputs resident.
- **`FusedNormOps`** (`skainet-lang-core`): an optional interface an op set may implement beside `TensorOps` — `layerNorm` / `rmsNorm` over the trailing dimensions — which `LayerNormalization` consults for FP32 input before its decomposed chain. `DefaultCpuOpsJvm` (native kernel loaded) and `WasmCpuOps` implement it; recording and differentiating op sets never see it, so recorded graphs and gradients are unchanged.
- **`engine-softmax`, `engine-layer-norm`, `engine-sdpa`** in `jvm-cpu-publish` and `wasm-bench` (`--provider ffm` measures the fused kernels on the JVM, `panama`/`scalar` the common paths); the kernel support matrix's *Kernel packs* table gains rows for the three fused ops.
- **Native library no longer references libmvec**: GCC with `-ffast-math` vectorised the `expf`/`tanhf`/`erf` loops of `f32_ops.c` (and the new softmax) into glibc libmvec calls the shared library is not linked against — the symbol resolves lazily, so the first JVM call into such a kernel terminated the process (exit 127, no crash log). The transcendentals in element loops now go through opaque scalar wrappers in `skainet_simd.h`; `libskainet_kernels.so` has no `_ZGV*` reference left and stays loadable on glibc builds without libmvec.
- **Browser IO parity** (`skainet-io-core`, Kotlin/Wasm): `JsBlobRandomAccessSource` addresses a `Blob` with `Long` positions (a shard over 2 GB opens and reads), moves bytes into Kotlin in 4 MB bulk stages through linear memory instead of one interop call per byte, and serves blocking reads beyond the preloaded window from the Blob itself inside a Web Worker through `FileReaderSync` (`supportsSyncReads`, `openSync`, `hasFileReaderSync()`); on the main thread such a read throws with the explanation instead of blocking. The Kotlin/JS variant gets the same `Long` addressing and a copy-free `ByteArray` view.
- **Sharded SafeTensors without a file system**: `ShardedSafeTensorsParametersLoader(indexJson, shardSource)` and `StreamingShardedSafeTensorsReader.openFromIndexContent` / `openFromParsedIndex` take the index as text and a (suspending) provider that opens each shard by file name — a `Blob` per shard in a browser, `ByteArrayRandomAccessSource` over fetched bytes — with `withPolicy` for the new form. Every shard is now opened exactly once on both forms, and a failed open closes what was already opened.
- **`PlannerProfile.BROWSER`** (and `Budget.RESERVE_BROWSER`, `PlannerProfile.BROWSER_AVAILABLE_BYTES`): the tab profile — 256 MB reserved, dense weights quantized to `Q8_0` at load, KV auto-quantized past 80 %, nothing mapped. `PlannerProfile.forDevice()` returns it on Kotlin/JS and Kotlin/Wasm; `skainet-plan --profile browser`.
- **`MemoryProbe` in a browser**: on Kotlin/Wasm `rssBytes()` is the JavaScript heap in use (Chromium's `performance.memory`), the module's linear memory and the bytes the installed `OffHeapAllocator` holds (`OffHeapAllocator.heldBytes()`, which the SIMD128 sidecar reports as its resident weights and activations), with `linearMemoryBytes()` / `jsHeapUsedBytes()` / `jsHeapLimitBytes()` / `offHeapBytes()` beside it; Kotlin/JS reports the JavaScript heap. Fault counters stay `null`, as before.
- **IO docs**: `how-to/io-readers.adoc` gains the browser section (Blob sources, worker-side loads, sharded checkpoints from Blobs) and a per-target table naming every remaining difference with its reason (no file system, no `mmap`, no Kotlin threads, 2 GB arrays).
- **Convolutions on the wasm tier**: `conv1d` / `conv2d` / `conv3d` on dense FP32 run as im2col + GEMM in the SIMD128 sidecar (`WasmCpuOps`), with stride, padding, dilation and groups (depthwise included), the weight resident after its first call and the output resident — the JVM's im2col + Vector API path had no counterpart in a browser, where a convolution was the common per-element loop. The new shared C kernel `skainet_f32_conv` (`f32_conv.c`, in every native build) gathers the receptive fields patch-major, threaded over patches through the row pool, and multiplies them against the weight rows with the existing `skainet_fp32_matmul_wt` SIMD128 / NEON dot kernel. `engine-conv2d` joins `jvm-cpu-publish` and `wasm-bench`; the kernel support matrix's *Kernel packs* table gains the row.
- **`wasm-bench` runs the `engine-*` scenarios** of the JVM publish harness (`engine-fp32-gemm`, `engine-kernel-matmul`, `engine-bf16-matmul`, `engine-q8-matmul`, `engine-q4-gemm`, `engine-ternary-f32-gemv`, `engine-elementwise-add`, `engine-reductions-sum/mean`) with identical shapes and metrics on every tier it measures, so the JVM and wasm numbers sit in one table.

## [0.61.0] - 2026-10-16
Expand Down
33 changes: 33 additions & 0 deletions build-logic/convention/src/main/kotlin/GenerateKernelMatrixTask.kt
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,39 @@ abstract class GenerateKernelMatrixTask : DefaultTask() {
appendLine("|===")
appendLine("")
}
if (module.packs.isNotEmpty()) {
appendLine("== Kernel packs (view dispatch)")
appendLine("")
appendLine(
"Kernels that are not reached through the `matmul` accessors above: the ternary formats " +
"are served by `ViewKernelPack`s keyed in `KernelDispatch` (`BitNet-b1.58` with exact " +
"FP32 activations, the fused multi-plane `BitNet-planes` lm_head, `TQ2_0` with int8 " +
"activations), and the dense `x · Wᵀ` of a `Linear` layer by the `matmulFp32Wt` " +
"accessor, which multiplies a row-major `[out, in]` weight in place instead of " +
"transposing and caching a copy. The fused row kernels — softmax / log-softmax, layer " +
"norm / RMS norm and scaled dot-product attention with its structural masks and " +
"grouped-query heads (`softmaxFp32`, `normFp32`, `attentionFp32`) — run those ops in one " +
"pass where the ops layer otherwise runs its scalar loops or the normalisation's " +
"decomposition. The convolutions run as im2col + GEMM: on the JVM inside the ops layer " +
"itself (`JvmVectorKernels`, declared as the `panama-vector` tier), on wasm as the sidecar's " +
"`skainet_f32_conv`. The scalar reference kernels serve every cell that is empty here (`—`), " +
"at reference speed; the common row-dot serves `x · Wᵀ` there.",
)
appendLine("")
appendLine("[cols=\"$colSpec\", options=\"header\"]")
appendLine("|===")
append("| Kernel ")
module.platforms.forEach { append("| $it ") }
appendLine("")
appendLine("")
module.packs.forEach { fmt ->
append("| `${fmt.name}` ")
module.platforms.forEach { p -> append("| ${fmt.byPlatform[p] ?: "—"} ") }
appendLine("")
}
appendLine("|===")
appendLine("")
}
appendLine(
"See also the eager backends & kernels mindmap " +
"(xref:explanation/eager-execution.adoc[]) for the narrative overview and gaps.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ data class KernelSupportModule(
*/
val mapped: List<KernelFormatSupport> = emptyList(),
val resident: List<KernelFormatSupport> = emptyList(),
/**
* Kernel packs (view dispatch): the ternary formats and the direct dense `x · Wᵀ`, served by
* `ViewKernelPack`s / the `matmulFp32Wt` accessor rather than the `matmul` accessors. Absent in
* older JSON.
*/
val packs: List<KernelFormatSupport> = emptyList(),
)

@Serializable
Expand Down
5 changes: 5 additions & 0 deletions docs/modules/ROOT/pages/contributing/benchmarks.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,18 @@ microbench tests under `skainet-backends/skainet-backend-native-cpu`:
|===
| Scenario | What it exercises | Unit | Direction
| engine-fp32-gemm | `ctx.ops.matmul` end-to-end FP32 square matmul | GFLOPS | higher is better
| engine-fp32-linear | `ctx.ops.matmulWeightTransposed` — a `Linear` layer's `x · Wᵀ` with the weight stored `[out, in]` (panama: cached transpose + GEMM; ffm: native direct kernel, no transposed copy) | GFLOPS | higher is better
| engine-q4-gemm | `PanamaVectorQ4KMatmulKernel` F32 × Q4_K matmul at LLM shapes | GOP/s | higher is better
| engine-kernel-matmul | Direct `Fp32MatmulKernel` (scalar vs panama-vector) | GFLOPS | higher is better
| engine-bf16-matmul | `Bf16MatmulKernel` F32 × BF16-packed matmul (scalar vs panama-vector) | GFLOPS | higher is better
| engine-q8-matmul | `Q8_0MatmulKernel` F32 × Q8_0-packed matvec (scalar vs panama-vector) | GOP/s | higher is better
| engine-elementwise-add | `ctx.ops.add` on 1M FP32 elements | M elements/s | higher is better
| engine-reductions-sum | `ctx.ops.sum` on 1M FP32 elements | M elements/s | higher is better
| engine-reductions-mean | `ctx.ops.mean` on 1M FP32 elements | M elements/s | higher is better
| engine-softmax | `ctx.ops.softmax` over the last dimension of `[1024, 1024]` FP32 (ffm: fused native rows; panama/scalar: the common loop) | M elements/s | higher is better
| engine-layer-norm | a `LayerNormalization` layer with affine over `[1024, 768]` FP32 (ffm: one fused native pass; panama/scalar: the decomposed elementwise chain) | M elements/s | higher is better
| engine-sdpa | `ctx.ops.scaledDotProductAttention`, causal, 12 heads × 150 × 150, head dim 64 (ffm: fused native kernel; panama/scalar: the common per-head kernel under the context's schedule) | GFLOPS (`4·h·s²·d`) | higher is better
| engine-conv2d | `ctx.ops.conv2d`, a 3 × 3 same-padding layer, 64 → 64 channels over a 56 × 56 map, with bias (JVM: the im2col + Vector API path whichever provider is registered; wasm: im2col + GEMM in the sidecar) | GFLOPS (`2·c_out·h·w·c_in·k²`) | higher is better
|===

Each scenario is wired up as a Phoronix test profile under
Expand Down
Loading
Loading