Skip to content

Commit 33a576c

Browse files
Merge pull request #573 from SKaiNET-developers/feature/native-q4k-memseg
feat(native-cpu): zero-copy Q4_K MemSeg kernel + SPI sibling (PR 3 of 5)
2 parents c928f71 + 25af043 commit 33a576c

7 files changed

Lines changed: 461 additions & 32 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package sk.ainet.backend.api.kernel
2+
3+
/**
4+
* JVM-only sibling of [KernelProvider] for kernels whose interface
5+
* surface depends on `java.lang.foreign.MemorySegment`. Kept separate
6+
* because [KernelProvider] lives in `commonMain` — adding
7+
* `MemorySegment` accessors there would break Kotlin/Native, JS, and
8+
* Wasm targets.
9+
*
10+
* Providers that ship MemSeg-input kernels declare both interfaces:
11+
*
12+
* ```kotlin
13+
* public object MyProvider : KernelProvider, MemSegKernelProvider { ... }
14+
* ```
15+
*
16+
* Lookup pattern at the call site:
17+
*
18+
* ```kotlin
19+
* val kernel = (KernelRegistry.bestAvailable() as? MemSegKernelProvider)
20+
* ?.matmulQ4KMemSeg()
21+
* ?: fallbackHeapPath()
22+
* ```
23+
*
24+
* No automatic registry lookup helper for now — the smart-cast is
25+
* sufficient and avoids a second registry. If a third MemSeg surface
26+
* lands (FP32 matmul-MemSeg, Q6_K matmul-MemSeg, ...) it joins this
27+
* interface as another `null`-defaulting accessor.
28+
*/
29+
public interface MemSegKernelProvider {
30+
/**
31+
* F32 × Q4_K matmul-MemSeg kernel exposed by this provider, or
32+
* `null` if this provider does not specialize the MemSeg path.
33+
* Default returns `null` so providers that pre-date the MemSeg SPI
34+
* keep compiling.
35+
*/
36+
public fun matmulQ4KMemSeg(): Q4KMemSegMatmulKernel? = null
37+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package sk.ainet.backend.api.kernel
2+
3+
import java.lang.foreign.MemorySegment
4+
5+
/**
6+
* F32 input × Q4_K-packed weights matrix-vector multiply where the
7+
* **weight tensor is supplied as a `java.lang.foreign.MemorySegment`**
8+
* rather than a heap [ByteArray]. JVM-only sibling of [Q4KMatmulKernel].
9+
*
10+
* Use this kernel when the Q4_K weight bytes already live in an
11+
* off-heap segment — typically because they were `mmap`'d from a
12+
* `.gguf` / `.safetensors` file, or because they were materialized
13+
* into an `Arena.ofShared` segment at load time. Letting a backend
14+
* read those bytes directly avoids the staging copy that
15+
* [Q4KMatmulKernel.matmul] does on every call (heap `ByteArray` →
16+
* temporary off-heap segment → native).
17+
*
18+
* The block layout, scale-pair packing, and lazy-`dmin` math are
19+
* identical to [Q4KMatmulKernel] (canonical ggml super-block, 256
20+
* elements, 144 bytes/block; see that kernel's kdoc for the byte
21+
* map). Implementations MUST NOT mutate `input` or `weight`, MUST
22+
* fully write `outputDim` floats starting at `output[outputOffset]`,
23+
* and MAY assume no aliasing between the inputs and the output.
24+
*
25+
* Lifetime contract: the caller owns the [weight] segment's [Arena].
26+
* The kernel must not retain pointers past the [matmul] call return —
27+
* no asynchronous reads, no caching of dereferenced addresses across
28+
* calls. Callers in turn must keep the segment's arena alive for the
29+
* duration of the call.
30+
*/
31+
public interface Q4KMemSegMatmulKernel {
32+
/**
33+
* @param input FP32 input vector (single row), heap array.
34+
* @param inputOffset element offset into [input] where the row starts.
35+
* @param weight off-heap `MemorySegment` holding the packed Q4_K
36+
* weights for the full `outputDim × inputDim` tensor in canonical
37+
* block-major layout `(blockIdx * outputDim + o) * 144` bytes.
38+
* @param weightByteOffset byte offset into [weight] where block
39+
* `(0, 0)` starts.
40+
* @param inputDim contraction dimension; must be a multiple of 256.
41+
* @param outputDim number of output cells.
42+
* @param output FP32 output vector, heap array.
43+
* @param outputOffset element offset into [output] where the row
44+
* starts.
45+
*/
46+
public fun matmul(
47+
input: FloatArray, inputOffset: Int,
48+
weight: MemorySegment, weightByteOffset: Long,
49+
inputDim: Int, outputDim: Int,
50+
output: FloatArray, outputOffset: Int,
51+
)
52+
}

skainet-backends/skainet-backend-native-cpu/src/jvmMain/kotlin/sk/ainet/exec/kernel/NativeKernelProvider.kt

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,34 @@ package sk.ainet.exec.kernel
22

33
import sk.ainet.backend.api.kernel.Fp32MatmulKernel
44
import sk.ainet.backend.api.kernel.KernelProvider
5+
import sk.ainet.backend.api.kernel.MemSegKernelProvider
56
import sk.ainet.backend.api.kernel.Q4KMatmulKernel
7+
import sk.ainet.backend.api.kernel.Q4KMemSegMatmulKernel
68

79
/**
8-
* Native (FFM) [KernelProvider]. Sits at priority `100`, above
9-
* [PanamaVectorKernelProvider] (`50`) and the scalar reference (`0`).
10+
* Native (FFM) [KernelProvider] / [MemSegKernelProvider]. Sits at
11+
* priority `100`, above [PanamaVectorKernelProvider] (`50`) and the
12+
* scalar reference (`0`).
1013
*
1114
* Availability is gated on [NativeQ4KMatmulKernel.isAvailable] — the
12-
* bundled `libskainet_kernels` shared library has to load AND the
13-
* `skainet_q4k_matmul` symbol has to resolve via FFM. When either
14-
* fails (missing arch, sandbox, JDK without FFM, kill-switch),
15+
* bundled `libskainet_kernels` shared library has to load AND
16+
* `skainet_q4k_matmul` has to resolve via FFM. When either fails
17+
* (missing arch, sandbox, JDK without FFM, kill-switch),
1518
* `KernelRegistry.bestAvailable()` cleanly cascades to
1619
* [PanamaVectorKernelProvider] at priority 50.
1720
*
18-
* PR 2 of the staged rollout: real Q4_K matmul wired into the SPI.
19-
* `matmulFp32` follows in a later PR alongside a native FP32 kernel.
21+
* The MemSeg surface ([matmulQ4KMemSeg]) is the JVM-only zero-copy
22+
* path for mmap'd Q4_K weights — sized for inference loops that
23+
* project against pre-loaded `MemorySegment`-backed tensors. Heap
24+
* callers stick with [matmulQ4K]; both wrap the same C symbol so
25+
* outputs are bit-for-bit identical.
26+
*
27+
* Staged rollout cursor (see `native-ffm-plan` asciidoc):
28+
* - PR 2: real Q4_K matmul wired into the heap SPI.
29+
* - PR 3 (this commit): MemSeg-input zero-copy sibling.
30+
* - Later: native `matmulFp32`, `matmulQ6K`, `matmulQ8_0`.
2031
*/
21-
public object NativeKernelProvider : KernelProvider {
32+
public object NativeKernelProvider : KernelProvider, MemSegKernelProvider {
2233
override val name: String = "native-ffm"
2334
override val priority: Int = 100
2435

@@ -28,4 +39,7 @@ public object NativeKernelProvider : KernelProvider {
2839

2940
override fun matmulQ4K(): Q4KMatmulKernel? =
3041
if (NativeQ4KMatmulKernel.isAvailable()) NativeQ4KMatmulKernel else null
42+
43+
override fun matmulQ4KMemSeg(): Q4KMemSegMatmulKernel? =
44+
if (NativeQ4KMemSegMatmulKernel.isAvailable()) NativeQ4KMemSegMatmulKernel else null
3145
}
Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,33 @@
11
package sk.ainet.exec.kernel
22

33
import sk.ainet.backend.api.kernel.KernelProvider
4+
import sk.ainet.backend.api.kernel.MemSegKernelProvider
45

56
/**
67
* `ServiceLoader`-friendly wrapper around [NativeKernelProvider]. The
78
* platform `ServiceLoader` machinery requires a public no-arg
89
* constructor, which a Kotlin `object` does not expose; this factory
9-
* delegates every [KernelProvider] member back to the singleton.
10+
* delegates every [KernelProvider] / [MemSegKernelProvider] member
11+
* back to the singleton.
12+
*
13+
* Implementing both interfaces here matters for the MemSeg lookup
14+
* pattern at the call site:
15+
*
16+
* ```kotlin
17+
* val provider = KernelRegistry.bestAvailable() // KernelProvider
18+
* val memSeg = (provider as? MemSegKernelProvider) // smart-cast
19+
* ?.matmulQ4KMemSeg()
20+
* ```
21+
*
22+
* Without the second `by`, the factory instance the registry hands out
23+
* wouldn't satisfy the smart-cast even though the underlying singleton
24+
* implements both interfaces.
1025
*
1126
* Listed in
1227
* `META-INF/services/sk.ainet.backend.api.kernel.KernelProvider` so
1328
* `KernelServiceLoader.installAll()` discovers the provider on JVM
1429
* startup.
1530
*/
16-
public class NativeKernelProviderFactory : KernelProvider by NativeKernelProvider
31+
public class NativeKernelProviderFactory :
32+
KernelProvider by NativeKernelProvider,
33+
MemSegKernelProvider by NativeKernelProvider
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package sk.ainet.exec.kernel
2+
3+
import java.lang.foreign.Arena
4+
import java.lang.foreign.FunctionDescriptor
5+
import java.lang.foreign.Linker
6+
import java.lang.foreign.MemorySegment
7+
import java.lang.foreign.ValueLayout
8+
import java.lang.invoke.MethodHandle
9+
import sk.ainet.backend.api.kernel.Q4KMemSegMatmulKernel
10+
11+
/**
12+
* Zero-copy native [Q4KMemSegMatmulKernel] implementation.
13+
*
14+
* Reuses the same `skainet_q4k_matmul` C symbol as
15+
* [NativeQ4KMatmulKernel] — the C side just sees `const uint8_t*` and
16+
* doesn't care whether the Kotlin caller backed those bytes by a
17+
* staged copy of a `ByteArray` or by an mmap'd off-heap segment. The
18+
* win on this path is that the weight bytes (which dominate the
19+
* payload — typical LLM Q4_K tensor: tens to hundreds of MB per layer)
20+
* never round-trip through the heap.
21+
*
22+
* Per-call cost vs [NativeQ4KMatmulKernel]:
23+
* - skips `MemorySegment.copy(weight, ...)` of `inputDim/256 * outputDim
24+
* * 144` bytes (e.g. 9 MB at 4096² shape).
25+
* - still copies `inputDim * 4` bytes for the input vector and
26+
* `outputDim * 4` bytes for the output — the input/output are
27+
* typically heap arrays produced/consumed by the surrounding
28+
* forward pass.
29+
*
30+
* PR 3 of the staged native-FFM rollout — see the `native-ffm-plan`
31+
* asciidoc.
32+
*/
33+
internal object NativeQ4KMemSegMatmulKernel : Q4KMemSegMatmulKernel {
34+
35+
private const val BLOCK_SIZE = 256
36+
37+
fun isAvailable(): Boolean = handle != null
38+
39+
override fun matmul(
40+
input: FloatArray, inputOffset: Int,
41+
weight: MemorySegment, weightByteOffset: Long,
42+
inputDim: Int, outputDim: Int,
43+
output: FloatArray, outputOffset: Int,
44+
) {
45+
require(inputDim % BLOCK_SIZE == 0) {
46+
"NativeQ4KMemSegMatmulKernel: inputDim must be a multiple of $BLOCK_SIZE; got $inputDim"
47+
}
48+
require(weightByteOffset >= 0) {
49+
"NativeQ4KMemSegMatmulKernel: weightByteOffset must be non-negative; got $weightByteOffset"
50+
}
51+
require(weightByteOffset <= Int.MAX_VALUE) {
52+
"NativeQ4KMemSegMatmulKernel: weightByteOffset $weightByteOffset exceeds Int range — " +
53+
"the C kernel takes int32_t today; slice the segment first or wait for the int64_t overload"
54+
}
55+
if (outputDim == 0 || inputDim == 0) return
56+
val mh = handle
57+
?: error("NativeQ4KMemSegMatmulKernel.matmul invoked while native library unavailable")
58+
59+
// The C kernel reads weight from offset 0..weightBytesUsed, so
60+
// require that the caller's segment is large enough. This catches
61+
// scope/aliasing bugs early; without it, an undersized segment
62+
// would crash the JVM with SIGSEGV from native code.
63+
val weightBytesUsed = ((inputDim / BLOCK_SIZE).toLong() * outputDim) * 144L
64+
require(weightByteOffset + weightBytesUsed <= weight.byteSize()) {
65+
"NativeQ4KMemSegMatmulKernel: weight segment too small — needs " +
66+
"$weightBytesUsed bytes from offset $weightByteOffset, " +
67+
"segment is ${weight.byteSize()} bytes"
68+
}
69+
70+
Arena.ofConfined().use { arena ->
71+
val inSeg = arena.allocate(
72+
inputDim.toLong() * java.lang.Float.BYTES,
73+
ValueLayout.JAVA_FLOAT.byteAlignment(),
74+
)
75+
val outSeg = arena.allocate(
76+
outputDim.toLong() * java.lang.Float.BYTES,
77+
ValueLayout.JAVA_FLOAT.byteAlignment(),
78+
)
79+
MemorySegment.copy(input, inputOffset, inSeg, ValueLayout.JAVA_FLOAT, 0L, inputDim)
80+
81+
mh.invoke(
82+
inSeg, 0,
83+
weight, weightByteOffset.toInt(),
84+
inputDim, outputDim,
85+
outSeg, 0,
86+
)
87+
88+
MemorySegment.copy(outSeg, ValueLayout.JAVA_FLOAT, 0L, output, outputOffset, outputDim)
89+
}
90+
}
91+
92+
private val handle: MethodHandle? by lazy {
93+
val lookup = NativeLibraryLoader.lookup() ?: return@lazy null
94+
val symbol = lookup.find("skainet_q4k_matmul").orElse(null) ?: return@lazy null
95+
val descriptor = FunctionDescriptor.ofVoid(
96+
ValueLayout.ADDRESS, // input
97+
ValueLayout.JAVA_INT, // input_offset
98+
ValueLayout.ADDRESS, // weight (passed straight through from caller)
99+
ValueLayout.JAVA_INT, // weight_byte_offset
100+
ValueLayout.JAVA_INT, // input_dim
101+
ValueLayout.JAVA_INT, // output_dim
102+
ValueLayout.ADDRESS, // output
103+
ValueLayout.JAVA_INT, // output_offset
104+
)
105+
runCatching { Linker.nativeLinker().downcallHandle(symbol, descriptor) }.getOrNull()
106+
}
107+
}

0 commit comments

Comments
 (0)