Kernel SPI and the selection algorithm
A matmul in SKaiNET does not pick its implementation from an is-ladder over Kotlin classes.
It is selected, from a declared descriptor of the operands, out of a registry a backend
populated. This page explains the two registries, the SPI a backend implements, the exact
selection algorithm, and how to tell which kernel actually ran.
Its companion pages: Eager execution maps the backends and platform coverage; Packed weight layout explains the block orders this page matches on; the support matrix is generated from real registrations.
Two registries, two jobs
There are two, and confusing them is the most common source of "why is this slow".
KernelRegistry |
KernelDispatch |
|
|---|---|---|
Selects on |
dtype + provider priority |
a |
SPI |
|
|
Answers |
"who provides the best FP32 GEMM here?" |
"which kernel takes this activation and this weight, in these layouts?" |
Used by |
the legacy fast paths in |
|
Priority model |
scalar 0 ยท Panama 50 ยท native FFM/JNI 100 โ highest available wins |
exact key match; later registration wins for the same key |
KernelRegistry answers a question about capability; KernelDispatch answers a question about
applicability. A provider can be the best available and still be unable to take a particular
weight โ off-heap, block-order-mismatched, strided โ which is exactly what the key encodes.
The descriptor: KernelKey
KernelKey.matmul(a, b) builds ("matmul", [OperandKey.of(a), OperandKey.of(b)], HOST, capabilities).
Each OperandKey carries the operand’s Format (dtype + encoding, e.g. dense FP32, Q4_K) and its
LayoutClass, derived from the view’s layout:
CONTIGUOUS-
dense, unit-stride.
STRIDED-
dense, non-unit stride โ what a transposed weight view looks like.
BLOCKED_ROW_MAJOR-
quantization blocks laid out along rows โ the canonical GGUF order, as loaded.
BLOCKED_INPUT_MAJOR-
blocks laid out input-major โ what the packed SIMD kernels read.
|
Key equality is exact. There is no subsumption and no fuzzy match: a kernel registered for
|
The SPI a backend implements
Two interfaces, in skainet-backend-api:
KernelProvider-
a compute backend (scalar, Panama Vector, native FFM, JNI NEON, Accelerate). Exposes
matmulFp32(), the packed-quant entry points,isAvailable()and a priority. ViewKernelPack-
an installable set of
ViewKernels forKernelDispatch. One method,install(), which must be idempotent and must register nothing rather than throw when its platform support is absent (a missing native library, no vector unit).
Registration and discovery
| Platform | KernelProvider |
ViewKernelPack |
|---|---|---|
JVM |
|
|
Android |
|
|
Kotlin/Native, wasm, JS |
manual โ e.g. |
manual โ call the pack’s |
A backend module declares its service the usual way, e.g. skainet-backend-native-cpu ships
META-INF/services/sk.ainet.backend.api.kernel.ViewKernelPack naming
FfmRowMajorKernelPackFactory. ServiceLoader needs a public no-arg constructor, which a Kotlin
object does not expose, so each pack ships a thin factory class delegating to the singleton โ
the same shape NativeKernelProviderFactory has always used for providers.
Bootstrap: the dispatcher heals itself
KernelDispatch.matmul calls ensureInstalled() first. When the table is empty and nothing has
been registered, it performs a one-time bootstrap:
-
discover
KernelProviders, ifKernelRegistryis empty; -
KernelPacks.install(), which registers the reference kernel plus the best available provider’s dense-FP32 view kernels (contiguous and strided) and itsBLOCKED_INPUT_MAJORpacked kernels; -
install every discovered
ViewKernelPackโ on JVM that is the FFM row-major pack, i.e. theBLOCKED_ROW_MAJORkernels that serve mapped GGUF weights zero-copy.
|
Step 1 must come first. |
Explicit registration still wins: a consumer that registers kernels before the first dispatch
suppresses auto-install entirely, so a curated set is never silently widened. clearForTesting()
re-arms the bootstrap.
The selection algorithm
Two properties are worth stating plainly:
-
Rank is normalised once, as views. A rank-1 decode step never reaches a kernel written for rank 2 โ that class of
ClassCastExceptiondisappears by construction. -
Adapters are visible and caller-scoped. When an operand must be converted, the allocation happens in the caller’s
Scope(aForwardscope inside a generation loop) and is emitted asTraceEvent.AdapterInserted, rather than hidden inside a kernel.
prepackWeights is off by default on purpose: the relayout is O(bytes), so doing it inside a
decode step copies the whole weight per token. Prepack once at load instead
(TensorView.prepack), which then hits the exact key and copies nothing.
Why a kernel declines
Reaching a kernel is not the same as it accepting the work. A kernel that cannot serve an operand
falls back and traces reference-fallback from <name>: <reason>. The common reasons:
-
FfmRowMajorMatmulKernelโ activation must be heap-backedFloatArrayand contiguous; the output must be a heapFloatArray; the weight must be buffer-backed or a heapByteArray. -
PackedViewMatmulKernelโ every operand must beStorage.Heap; off-heap and mapped storage are not served by this tier. -
Fp32ViewMatmulKernelโ the output must be a heapFloatArray, and the weight’s row stride must agree with its declared layout. -
Block alignment โ a quantized tensor whose last dimension is not a multiple of the block size (256 for K-quants, 32 for
Q4_0/Q8_0) is rejected outright.
Only FfmRowMajorMatmulKernel and JniRowMajorMatmulKernel implement MappedCapableKernel, i.e.
only they read a weight straight out of mapped or direct-buffer storage. That is why
KernelDispatch.mappedServableEncodings() is derived from live registrations rather than a
hand-kept list.
Diagnosing a selection
The failure mode this design has to defend against is silence: the reference kernel is correct for every format, so a miss produces right answers slowly rather than an error.
KernelDispatch.kernels()-
what is actually registered, most recent first. On a healthy JVM bootstrap this is 17 entries: 7
ffm-rowmajor-, 7native-ffm-packed, 2native-ffm-fp32(contiguous and strided keys), andreference. KernelDispatch.mappedServableEncodings()-
which encodings can be served zero-copy from a mapping right now.
- The one-time warning
-
the first time the reference kernel serves a blocked weight, the dispatcher prints what it could not match and how to install a pack. It fires once per process.
KernelDispatch.defaultSink-
set a real
TraceSinkto see every kernel run and adapter. Production call sites (DefaultCpuOps) do not thread a sink through, so this global is how you observe them. DispatchMode.useRegistry()-
-Dskainet.dispatch.registry=falseforces the legacy generic fallback, which is useful for bisecting a suspected dispatch problem.
Adding a backend
-
Implement
KernelProvider; add a no-arg factory class; list it inMETA-INF/services/sk.ainet.backend.api.kernel.KernelProvider. -
If the backend has kernels that read a specific layout (packed, mapped, prepacked), implement
ViewKernelPack, add its factory toMETA-INF/services/sk.ainet.backend.api.kernel.ViewKernelPack, and register oneViewKernelper(encoding, layout)you actually serve. Register nothing for the rest โ the reference kernel keeps those correct. -
Make
install()a no-op when the platform cannot support it, so discovery on a machine without your native library costs a lookup and changes nothing. -
On Kotlin/Native, wasm and JS, document the manual install call โ there is no discovery there.
-
Regenerate the support matrix; it is gated against drift, so a new tier that forgets this fails the build.