Architecture
This page follows arc42’s chapter ordering at a coarse grain. It describes SKaiNET as it is today; the deepest sections are the kernel SPI, the eager execution pipeline and the memory model, because that is where most of the architectural weight sits.
1. Introduction and goals
SKaiNET is a Kotlin Multiplatform ML framework whose primary target is on-device / edge inference and training in environments that already have JVM tooling: Android, JVM server, and Kotlin/Native iOS and linuxX64. The framework separates model authoring (a typed Kotlin DSL with compile-time tensor shape checks where possible) from execution (pluggable backends), so the same model can run eagerly during development and be lowered to MLIR StableHLO β IREE for deployment.
Hard non-goals:
-
Become a full numerics library. SKaiNET targets the operators real models use, not the long tail in PyTorch / NumPy.
-
Run untrusted user code. Kernels are trusted code; security is about not corrupting memory, not about sandboxing.
2. Constraints
| Constraint | Why |
|---|---|
Kotlin Multiplatform with |
Same DSL must run on JVM, Android, iOS, macOS, linuxX64, JS, Wasm. |
|
FloatVector / ByteVector are still incubator on JDK 25 (JEP 508). |
Maven Central publication via |
All modules signed; coordinates |
Antora-based docs site under |
Source-controlled, follows DiΓ‘taxis quadrants for user-facing pages. |
arc42 ordering for this page |
Architectural reference, not a tutorial. |
3. Context (system boundaries)
The framework’s outer surface:
-
DSL layer β Kotlin model DSL (
nn { … },tensor { … }) and imperativeTensorOps/ExecutionContextAPI. -
I/O layer β model loaders for GGUF, SafeTensors, ONNX in
skainet-io-*modules. Reading is the common case, butskainet-io-ggufalso writes:GGUFWriter/GgufExportFacadefor arbitrary tensor export, andI2sAotConverterfor the ternary AOT conversion in Ternary networks: getting started. The IREE-facing counterpart (GGUF β.irpa) lives outside this repo, in the public SKaiNET-IREE-tools β a deliberate boundary (Β§9): IREE/StableHLO-target-specific conversion does not belong in core, which stays target-neutral. -
Compile layer β
RecordingExecutionrecords ops to a tape, then lowers to StableHLO / IREE bytecode inskainet-compile-*modules. -
Backend layer β
BackendProviderdispatchesTensorOpscalls to a concrete implementation (CPU, XNNPACK, future GPU). Inside a backend, the kernel SPI picks the SIMD recipe for the host hardware.
4. Solution strategy
SKaiNET runs the same model graph through one of two execution strategies:
-
Eager execution β
DirectCpuExecutionContextcalls op implementations as the user invokes them. Used during development, testing, and on-device inference paths where AOT compilation is impractical (debug builds, dynamic graphs). This is the path the 0.21.0 SIMD work targets. -
Recorded execution β
RecordingExecutionbuilds an op tape, whichHloGeneratorlowers to StableHLO MLIR. IREE compiles the MLIR to a portable bytecode for production deployment.
Both strategies share the same TensorOps surface, so a model
written once runs in either mode without changes. Numerical parity
between modes is part of the test contract.
In both modes the DSL describes computation only: memory residency,
weight encodings, layouts and kernel choice are decided outside the
model definition β per tensor at the load boundary (WeightForm),
priced by the memory plan, and selected by format-keyed dispatch.
The principle, the level responsibilities, and the evidence for the
split are stated in The DSL is
compute.
5. Building block view (static structure)
5.1 Module layout
| Module path | Role |
|---|---|
|
DSL types, tensor abstractions, common ops, |
|
Reference reusable models (Llama, Gemma, Qwen, Whisper) built on the DSL. |
|
Neutral backend SPI β |
|
CPU implementation. Eager-execution |
|
Native (FFM) kernel provider β JVM only, ART has no |
|
Native (JNI) kernel provider for Android β same shared C kernels as |
|
Optional XNNPACK CPU backend (FP32 matmul / conv2d / pooling) on linuxX64 / linuxArm64 / Android. |
|
JMH harness β |
|
Tape recording, StableHLO emission, IREE export. |
|
The GGUF loader/writer: one loader configured by a single |
|
SafeTensors and ONNX loaders, tokenizers, image I/O, the IREE parameter-archive ( |
|
|
|
Publishes benchmark records as JSON (schema-checked in CI), including generation metrics when a scenario runs a decode loop. |
|
External: SKaiNET-IREE-tools
IREE/StableHLO-target-specific model conversion β starting with a standalone
GGUF β |
5.2 Kernel SPI
Introduced in 0.21.0 (PRs #554, #559, #562). The static structure:
Five live providers ship. The exact, machine-generated coverage of every weight format on every KMP target is at Kernel Γ platform support matrix; for how the kernels are implemented see How SIMD Kernels Are Built (FP32), How Quantized SIMD Kernels Are Built (quantized), and Android NEON Kernels via JNI (Android JNI). Packed-quant matmul (Q4_K/Q6_K/Q5_1/Q5_0) also has a commonMain scalar kernel, so it runs on Kotlin/Native, JS and WASM β not only the JVM.
|
Native (FFM) provider β JVM only
|
|
Native (JNI) provider β Android only
|
5.3 View-based kernel dispatch (KernelDispatch)
5.2’s KernelProvider/KernelRegistry picks a SIMD recipe by dtype at
install time. A second, later SPI (SKEEP-003 Β§5, 2026-08, #1189β#1193)
dispatches a single matmul call by the exact shape of its operands β the
one KernelDispatch.matmul(…) flowchart in Β§6 already uses. This
subsection is what that flowchart is built on:
A ViewKernel declares the exact KernelKey it serves instead of an
is-ladder over TensorData subclasses β Β§993/Β§991 were dispatch bugs, not
math bugs, and a declared key is compiler- and test-checkable where an
is-ladder was not. LayoutClass names the two things a packed kernel
actually varies on: BLOCKED_ROW_MAJOR (canonical file order) versus
BLOCKED_INPUT_MAJOR (prepacked feed order) β the "block order" contract in
Β§8. ReferenceMatmulKernel is registered for every key nothing else can
serve; it is correct for any format because it reads through TensorView.get(),
which decodes.
MappedCapableKernel is a marker on the two kernels that serve a
BLOCKED_ROW_MAJOR weight straight from off-heap or mapped storage as well
as from heap bytes β FfmRowMajorMatmulKernel (JVM/FFM,
skainet-backend-native-cpu) and JniRowMajorMatmulKernel (Android/JNI,
skainet-backend-jni-cpu), both introduced in #1189/#1192. Which encodings
they actually cover is derived, not hand-declared:
KernelDispatch.mappedServableEncodings() scans registered
MappedCapableKernel`s for their `BLOCKED_ROW_MAJOR operand (#1193). This
matters because StorageCapabilities.mappedServableEncodings
(skainet-lang-core, Β§5.4) β the thing the memory planner uses to decide
whether a tensor can be served from a file mapping at all β cannot depend on
skainet-backend-api to ask KernelDispatch directly (Β§9: the dependency
runs the other way, to avoid a cycle), so it stays a hand-kept constant,
cross-checked against the derived set by
KernelSupportMatrixTest.generate_and_gate_support_matrix(). A kernel
gaining or losing the marker without updating that constant now fails CI
instead of drifting silently β the same "declare it, don’t rediscover it"
move as the KernelKey itself.
Every internal fallback β an operand’s storage the fast path can’t read, a
strided activation β emits TraceEvent.KernelRun naming the kernel and the
reason before falling back to ReferenceMatmulKernel, per the "every
conversion is an event" principle in Β§8: a silent fallback to the ~1000Γ
slower reference path is exactly the kind of hidden cost that principle
exists to surface.
5.4 Memory model
Four questions that a tensor library often answers with one type are answered by four here.
Storage owns bytes; Format β a (DType, TensorEncoding) pair β says what they mean; Layout
says where element (i, j) lives inside them; TensorView is the combination, and the only thing
a kernel receives. Scope owns lifetime.
Consequences that shape the rest of the system:
-
A quantized weight is
Format(FP32, Q4_K)β a float tensor with a packed encoding, not a byte tensor.get()returns the decoded value, so one reference kernel serves every format. -
Slicing, transposing and striding are
Layoutarithmetic over the sameStorage;materialize()is the only copy point. -
A
ForwardScopeis a pre-sized slab, bump-allocated and reset each step, which is what makes memory over a long generation a flat line rather than a staircase.
Full treatment: The memory model.
Deciding Storage before dispatch: AllocationResolver
Which Storage subtype backs a loaded weight is decided once, at load time,
by AllocationResolver.resolve(…) β not guessed at by the kernel that
later reads it. Its inputs are a PlanTensor (the weight’s requested
WeightForm: encoding Γ byte order Γ shape Γ WeightResidency), the running
PlatformStorage.current() capabilities, and PlannerProfile’s off-heap
threshold (small tensors stay on the managed heap regardless of residency β
off-heap has a fixed per-segment cost not worth paying under it).
`AllocationResolver.servesFromMapping(weight) is the gate that decides
whether a tensor can be served straight from a file mapping with zero bytes
allocated: WeightResidency.MAPPED and the platform supports mapped files
and the file’s bytes are already the target bytes (no encoding/byte-order
conversion pending) and the encoding is in
StorageCapabilities.mappedServableEncodings (Β§5.3). Get any one of those
wrong and the plan silently falls back to heap staging β which is exactly
the failure mode "plan versus reality" in Β§10 exists to catch.
5.5 Ternary / BitNet weights and AOT conversion
Ternary ({-1, 0, +1}) weights are SKaiNET’s smallest packed encoding
(BITNET_B1_58: four codes per byte, one trailing FP32 scale β ~16Γ smaller
than FP32) and, until 0.51.0, its least storage-flexible one: they always
heap-staged, because BitNetB158TensorData held a bare ByteArray rather
than a Storage, and a GGUF’s I2_S bytes usually need repacking before
they’re the BITNET_B1_58 byte order at all (#1198).
Two follow-on decisions landed with the 0.51.0 work, both recorded in Β§9:
-
Off-heap
Storage, not a bareByteArray(#1202) βBitNetB158TensorDatagained aStorage-backed constructor and lazy heap snapshot, and the FFM/JNI ternary kernels (TernaryF32GemvKernel,BitNetGemvKernel,NativeKnTernaryF32Gemv,JniTernaryF32Gemv) were widened to read off-heap/mapped storage directly instead of silently falling back to the ~1000Γ slower decoding reference for any non-heap operand. -
A native kernel for the grouped BitNet.cpp layouts, proposed and explicitly not built (#1205, closed) β
I2sAotConverterconverts aGROUP_128/GROUP_64file toSEQUENTIALonce, ahead of time, reaching the same zero-copy mmap path (#1203) as a native grouped-layout decoder would, for a fraction of the ongoing cost of maintaining a third SIMD decode variant. See Ternary networks: getting started for the user-facing walkthrough and #1205’s closing comment for what a grouped-layout kernel would need if a workload someday can’t tolerate an AOT step at all.
The fallback sidecar cache for repeated GROUP_128/GROUP_64 loads that
don’t go through the AOT converter (#1198’s original proposal) remains
open and unscheduled β see Β§11.
6. Runtime view β eager execution
A single op, from user code to bytes:
The kernel-facing half of the same call, for code that has already moved to views:
Specifics worth calling out:
-
Lazy provider resolution. The kernel properties on the op set are
by lazy; first access triggersKernelServiceLoader.installAll()when the registry is empty, then caches. Apps that pre-register providers viaKernelRegistry.register(…)bypass auto-discovery. Kotlin/Native has noServiceLoader, so it registers manually withinstallNativeKernels(). -
Fall-through everywhere. Each routing decision returns
nullon a miss rather than throwing, so a new tensor type or SPI accessor is purely additive. -
Rank is normalized once. A rank-1 decode step becomes
[1, k]as a view before any kernel sees it, which is why a kernel written for rank 2 never meets a rank-1 tensor. -
Adapters are visible. A gather, a dequantization, a requantization for a ternary kernel or a block relayout allocates in the caller’s scope and emits
TraceEvent.AdapterInsertedwith its byte count. The costs that used to hide inside kernels are events.
A generation loop wraps this in phase spans β prefill, decode(step), sample, and nested
module spans β from which TTFT, tokens per second, the per-module breakdown and the effective
memory bandwidth are derived. See explanation/memory-model.adoc#_observability.
7. Deployment view
-
Maven Central β every module published as
sk.ainet.core:<module>-<target>:<version>. KMP variants land per target (-jvm,-android,-iosarm64,-macosarm64,-linuxx64,-linuxarm64,-js,-wasm-js,*-wasm-wasi). -
Single BOM β
sk.ainet:skainet-bomprovides aplatform()import for downstream Gradle. Note the group issk.ainet, notsk.ainet.core, so downstream BOMs (e.g.sk.ainet.transformers:skainet-transformers-bom) can import it under the standard umbrella group. -
Releases β tags
0.X.Yon the release branch trigger.github/workflows/publish.ymlβ./gradlew publishon macOS-latest with JDK 25.
8. Cross-cutting concepts
-
Numerical parity testing. Every accelerated kernel has a parity test against a scalar reference within a documented tolerance (typically
1e-5 * kor1e-4relative). Examples:PanamaVectorMatmulKernelTest,PanamaVectorQ4KMatmulKernelTest,Q6KMatmulTest. The scalar reference is the contract; SIMD speed is a non-functional improvement that must not break the contract. -
Lazy resource lifetimes.
MemorySegmentTensorDataFactoryusesArena.ofAuto()for per-op outputs so output segments are GC-reclaimable. The earlierArena.ofConfined()builds leaked ~tens of MB per matmul, blowing 32+ GiB of direct memory in inference loops over a 35-layer Gemma 4 forward pass. Fixed in PR #556. -
Kill switches via system properties. The Vector API code path respects
-Dskainet.cpu.vector.enabled=falseso a deployment can opt out of incubator code without a recompile. Same pattern for BLAS (-Dskainet.cpu.blas.enabled=true). -
Scoped memory. Weights and the KV cache live in a
ModelScope; one step’s activations live in a pre-sizedForwardScopeslab that is reset between steps. Allocation and free events carry theTensorIdthey back, so a plan can be compared with what a run actually did. See The memory model. -
Every conversion is an event. A gather, a dequantization, a requantization or a block relayout allocates in the caller’s scope and emits
AdapterInserted. A conversion that does not appear in the trace is a bug β this is what turned the "hidden allocations" class of issue into something a test can assert. -
One block-order contract. Packed weights exist in exactly two block orders, carried on the
Layoutand declared in each kernel’sKernelKey. Mixing them yields plausible wrong numbers rather than a crash, so the contract is normative and published: Packed weight layout. -
Observability is one stream. Phases, kernel runs, adapters, allocations, scope resets, counters and the memory plan go to a
TraceSink; the debugger, the Perfetto/JFR/android.os.Traceexporters, the derived generation metrics and the benchmark JSON are all consumers of it.
9. Architecture decisions
| Decision | Date | Rationale |
|---|---|---|
Kernel SPI parallel to BackendProvider |
2026-04 (PR #554) |
Matmul / SDPA are model-agnostic; isolating them lets bench harnesses time the SIMD loop directly and lets a future native provider register without touching the op layer. |
|
2026-04 (PR #562) |
Backwards compat for existing providers (Scalar) without forcing every implementation to override. Same pattern will be used for Q6KMatmulKernel / Q4KMemSegMatmulKernel sibling SPIs. |
ServiceLoader auto-discovery deferred until 2 providers exist |
2026-04 (PR #559) |
Single-provider auto-discovery would have been ceremony for nothing; once Panama landed alongside Scalar, the trigger condition was met. |
FFM over JNI for the JVM native provider |
roadmap M5 |
JNI’s per-call overhead and global lock are wrong for hot per-token kernels β where FFM is available. This did not generalize to "JNI is rejected everywhere": ART has no FFM at all, so |
Antora docs (DiΓ‘taxis), not GitHub Wiki |
2025 |
Source-controlled, branchable, ranked higher in search than wikis, ships with the repo. |
Storage-first memory model (SKEEP-003) |
2026-08 |
|
Kernels select on a declared |
2026-08 |
Every quantization bug was arriving as a dispatch bug. A kernel now declares the formats, layouts and capabilities it accepts, and the dispatcher inserts a visible adapter when an operand does not match. |
Block order is part of the layout |
2026-08 |
Two block orders for packed weights had been an unwritten contract, contradicting itself in seven places and shipping wrong numbers twice. It is now carried on |
|
2026-08 |
Transposing block-quantized data is not representable β blocks quantize runs along the input dimension. What the engine called a packed transpose was a per-call layout copy that was not its own inverse; the primitive ggml and BLAS have takes the weight as |
One |
2026-08 |
The three flags asked the caller to resolve, per device, what the resolver can decide from file Γ profile Γ kernels; a single resolved value is priceable, traceable, and overridable. |
|
2026-08 |
"Streaming loader" and "mapped weights helper" were separate code paths that could disagree. What the values are and where the bytes live are independent questions. |
Off-heap |
2026-08 |
|
Zero-copy mmap for |
2026-08 |
The NeoGPU converter’s byte order already matches |
|
2026-08 |
Two independently hand-maintained lists of which encodings serve from mapped storage had nothing checking they agreed. |
IREE-facing GGUF β |
2026-08 |
|
A native decode kernel for BitNet.cpp’s grouped I2_S layouts ( |
2026-08 |
|
10. Quality requirements
-
Performance. Panama FP32 matmul β₯1.5Γ scalar (M5 metric β met, ~10Γ at 1024Β² on Apple Silicon NEON). Native Q4_K matmul β₯2.5Γ scalar dequant baseline (M5 metric β deferred to native FFM PR).
-
Numerical equivalence. Every SIMD kernel matches its scalar reference within FP-rounding tolerance (
1e-5 * kfor matmul,1e-4relative for quantized). Pinned by parity tests. -
Multi-target buildability.
./gradlew allTests(the release gate) must pass on every KMP target β JVM, JS, Wasm, macosArm64, iosSimulatorArm64, linuxX64, linuxArm64, Android. -
Flat memory during generation. A decode step allocates nothing in the forward scope after warm-up, and the resident set does not grow with the number of steps. Asserted on every commit against a synthetic decode harness, and measured on an ARMv8.2 Cortex-A55 reference board: zero major page faults across a run, identical RSS after 4 and 48 steps.
-
Plan versus reality. The header-derived memory plan matches what a run actually allocated within 10 %, checked in CI rather than by inspection.
-
Bit-identical decode across refactors. A golden gate pins the decoded bytes of every packed encoding and the output of every scalar kernel, on JVM and Kotlin/Native. Any change to a decoder, a kernel or a byte layout has to move a recorded digest deliberately.
11. Risks and technical debt
-
Vector API still incubator on JDK 25. JEP 508 keeps it that way through 2026; we depend on it heavily. If it breaks API in a future JDK, every
JvmVectorKernels/JvmQuantizedVectorKernelsfile needs adjustment. Mitigation: thin wrappers, parity tests are a canary. -
Whole-file mapping only. A file larger than 2 GB is refused rather than mapped in windows.
-
Ternary GGUFs still repack on every load unless converted ahead of time.
I2sRepacktransparently repacks BitNet.cpp’s grouped I2_S layouts (GROUP_128/GROUP_64) intoBITNET_B1_58order on each load β correct, but a real per-load cost for a repeatedly-loaded model.I2sAotConverter(Β§5.5) removes it for anyone who converts once; the automatic fallback sidecar cache for callers who don’t (#1198’s original proposal, tracked as a standalone follow-up, #1204) remains open and is intentionally low priority β it isn’t blocking, since the AOT path already covers the shipped-app case. -
Whole-model plans only mapped-serve seven GGML formats plus dense FP32.
StorageCapabilities.MAPPED_SERVABLE_DEFAULTβ now cross-checked against kernel registrations (#1193, Β§5.3/Β§9) β still names a fixed set; a new packed encoding needs aMappedCapableKerneland an update to that constant before it can be served from a mapping. The guard test makes the two drift-safe, not automatic. -
Hand-written NEON is a modest win over the compiler. On a Cortex-A55 the hand-written
bitnet_gemvbeats-O3 -ffast-mathC by 1.08β1.24Γ; the large win is being native at all. Worth knowing before investing in more intrinsics on that core. -
Two reverted optimizations on develop history. MemSeg pool (commit 8642b322) and intra-op matmul parallelism (commit 9ed633b6) were both tried and reverted. Re-attempts need a different strategy than what was tried; the revert commits explain why.
12. Glossary (selected)
| Term | Meaning |
|---|---|
FFM |
Foreign Function & Memory API (JEP 442 et seq.). Java 22 stable, 21 preview. Replaces JNI for native interop. |
FMA |
Fused multiply-add ( |
ggml |
The C library underpinning llama.cpp; defines the canonical Q4_K / Q6_K / Q8_0 block layouts SKaiNET uses for GGUF compatibility. |
Block order |
Which of the two orders a packed weight’s blocks are in β |
Format |
|
Layout |
Strides, offset and block geometry: where element |
Prepack |
Converting a packed weight’s blocks into the order its kernels read. A copy, done once at load, emitted as a visible adapter β not a transpose. |
Scope |
Who owns an allocation and when it is freed: |
Storage |
A byte range with an owner and a lifetime β |
TensorView |
|
MemSeg |
Short for |
Panama |
Codename for the JDK Vector API ( |
SPI |
Service provider interface β a public interface with multiple registered implementations, looked up at runtime. SKaiNET uses it for backends and now for kernels. |
|
The view-based kernel dispatch contract (Β§5.3): a |
|
Marker interface (Β§5.3) on a |
|
A tensor’s requested encoding Γ byte order Γ shape Γ residency, resolved once at load by |
AOT conversion |
Converting a model file once, ahead of time, into a byte order or layout its runtime path can serve with zero copies β e.g. |