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
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"name": "skainet-contributor-skills",
"source": "./skainet-contributor-skills",
"description": "Skills for contributing TO SKaiNET. Activate when editing files inside the SKaiNET repository: Kotlin idioms, multi-module Gradle + version catalog, KMP source-sets, Java-interop facade convention, in-repo test strategy.",
"version": "0.1.0",
"version": "0.2.0",
"tags": [
"skainet",
"contributor",
Expand All @@ -21,7 +21,7 @@
"name": "skainet-consumer-skills",
"source": "./skainet-consumer-skills",
"description": "Skills for USING SKaiNET in your own app. Activate when adding SKaiNET to a consumer Gradle project, building tensors and networks via the public DSL, loading models, running inference, integrating on Android, or consuming from pure Java.",
"version": "0.1.0",
"version": "0.2.0",
"tags": [
"skainet",
"consumer",
Expand Down
2 changes: 1 addition & 1 deletion skainet-consumer-skills/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "skainet-consumer-skills",
"version": "0.1.0",
"version": "0.2.0",
"description": "Skills for using SKaiNET as a library in your own Kotlin Multiplatform / JVM / Android / Java app. Covers BOM-based dependency setup, the data and neural-network DSLs, model loading (GGUF / ONNX / SafeTensors), running inference, Android integration, and pure-Java consumers calling the sk.ainet.java facade. Contributor-side skills (working inside the SKaiNET repo itself) live in the sibling skainet-contributor-skills plugin.",
"author": {
"name": "SKaiNET-developers",
Expand Down
48 changes: 33 additions & 15 deletions skainet-consumer-skills/skills/skainet-android-integration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,20 @@ Android-specific concerns for SKaiNET consumers: how to load model files from `a
## Hard rules

1. **`minSdk` = 24.** SKaiNET's Android target is built for `minSdk = 24`, `compileSdk = 36`. Lower is unsupported; higher is fine.
2. **Ship ARM64 at minimum; ARM32 only if you must.** The CPU backend has NEON paths for both, but ARM64 is the primary target. Do not ship x86 / x86_64 unless you specifically support emulator-only builds — production devices are ARM. Configure `ndk.abiFilters` accordingly to keep APK size down.
3. **Never call `forward(...)` on the main thread.** Use `viewModelScope.launch { withContext(Dispatchers.Default) { ... } }` from a `ViewModel`, or `lifecycleScope.launch { ... }` from an `Activity`/`Fragment`. The CPU backend can take hundreds of ms to seconds; the main thread has a 16 ms budget.
4. **Open assets with `context.assets.open(name)`. Don't decode the path as a `File` — there is no path inside the APK.** Use a `RandomAccessSource` factory that wraps the `InputStream` if the loader needs random access (some loaders do; SafeTensors and GGUF need random access — read the asset to a cache file in `context.cacheDir` and load from there if random access is required).
5. **Hold one `ExecutionContext` and one `Module` on the `Application` object** (or in a DI scope spanning the app lifetime). They're heavyweight; per-Activity construction is wasteful.
6. **React to `onTrimMemory(level)`.** When `level >= TRIM_MEMORY_BACKGROUND`, drop the `Module` (and any cached tensors); rebuild on next access. Holding multi-GB models across a backgrounded app is a sure path to LMK kills.
7. **Cache the model file once.** If the asset is a multi-GB GGUF that must be unpacked from the APK, copy it to `context.filesDir` (or `context.cacheDir`) on first launch and load from there subsequently. Loading from `assets/` repeatedly extracts on every cold start.
2. **Ship ARM64 at minimum; ARM32 only if you must.** ARM64 is the primary target. Do not ship x86 / x86_64 unless you specifically support emulator-only builds — production devices are ARM. Configure `ndk.abiFilters` accordingly to keep APK size down.
3. **Add `skainet-backend-jni-cpu` for real speed.** ART has no `java.lang.foreign`, so the JVM's FFM kernel provider can never run on Android; without the JNI AAR, inference runs on the scalar reference floor. The AAR ships NDK-built kernel `.so`s (`arm64-v8a` + `x86_64`, 16 KB-page-aligned for Android 15+; two NEON tiers selected at load time from `/proc/cpuinfo`) and a `JniKernelProvider` that the Android ops factory discovers via `ServiceLoader` automatically — no bootstrap call. Consumer R8 rules keeping the ServiceLoader entry are shipped in the AAR.
4. **Never call `forward(...)` on the main thread.** Use `viewModelScope.launch { withContext(Dispatchers.Default) { ... } }` from a `ViewModel`, or `lifecycleScope.launch { ... }` from an `Activity`/`Fragment`. The CPU backend can take hundreds of ms to seconds; the main thread has a 16 ms budget.
5. **Open assets with `context.assets.open(name)`. Don't decode the path as a `File` — there is no path inside the APK.** SafeTensors and GGUF loaders need random access: copy the asset to a real file in `context.cacheDir` / `context.filesDir` once, then open it with `openRandomAccessSource(path)` or `AndroidGguf.loader(path)`.
6. **Prefer mapped weights for GGUF.** The ART heap is hard-capped (256 MB, 512 MB with `largeHeap`) no matter the device RAM. `AndroidGguf.loader(path)` defaults to `WeightForm(residency = WeightResidency.MAPPED)`: weights are served from file-backed pages the OS pages in on demand and evicts under pressure — they never count against the heap cap. Check fit *before* loading with `AndroidGguf.plan(path, ctx)` + `AndroidGguf.deviceMemory(context)`.
7. **Hold one `ExecutionContext` and one `Module` on the `Application` object** (or in a DI scope spanning the app lifetime). They're heavyweight; per-Activity construction is wasteful.
8. **React to `onTrimMemory(level)`.** When `level >= TRIM_MEMORY_BACKGROUND`, drop the `Module` (and any cached tensors / KV cache); rebuild on next access. Mapped weight pages are evictable by the OS on their own, but heap-staged tensors are not.
9. **Cache the model file once.** If the asset is a multi-GB GGUF that must be unpacked from the APK, copy it to `context.filesDir` (or `context.cacheDir`) on first launch and load from there subsequently. Loading from `assets/` repeatedly extracts on every cold start — and mmap needs a real file anyway.

## Workflow

1. Add the SKaiNET BOM + `skainet-lang-core` + `skainet-backend-cpu` (see `skainet-consumer-setup`). For loaders, add `skainet-io-core` + the format-specific artifact.
1. Add the SKaiNET BOM + `skainet-lang-core` + `skainet-backend-cpu` + `skainet-backend-jni-cpu` (NEON kernels; see `skainet-consumer-setup`). For loaders, add `skainet-io-core` + the format-specific artifact.
2. Pick where to host the `ExecutionContext` and `Module` — usually a singleton tied to `Application` lifetime, or a Hilt/Koin app-scope binding.
3. Load the model file (asset, downloaded cache, or remote): see Canonical examples.
3. Load the model file (asset, downloaded cache, or remote): see Canonical examples. For GGUF, run the header-only fit check first (`AndroidGguf.plan` / `fitOn`).
4. Wire the forward pass through a coroutine on `Dispatchers.Default`.
5. Handle `onTrimMemory` to release the `Module` under pressure.
6. Test on a real ARM64 device — emulators don't surface ABI / NEON issues.
Expand Down Expand Up @@ -74,6 +76,9 @@ class SkainetApp : Application() {
**Loading a GGUF from assets via cacheDir (random-access loaders need a real file):**

```kotlin
import sk.ainet.io.gguf.AndroidGguf
import sk.ainet.lang.types.FP32

private suspend fun copyAssetIfNeeded(context: Context, name: String): java.io.File =
withContext(Dispatchers.IO) {
val target = java.io.File(context.cacheDir, name)
Expand All @@ -85,12 +90,18 @@ private suspend fun copyAssetIfNeeded(context: Context, name: String): java.io.F
target
}

private suspend fun loadGGUF(context: Context): GGUFModelReader = withContext(Dispatchers.IO) {
private suspend fun loadGguf(context: Context, ctx: ExecutionContext) = withContext(Dispatchers.IO) {
val file = copyAssetIfNeeded(context, "model-q4.gguf")
GGUFModelReader(/* RandomAccessSource factory wrapping `file` */)
// Mapped residency by default: tensor payloads come from mmap'd pages, not the ART heap.
val loader = AndroidGguf.loader(file.absolutePath)
loader.load(ctx, FP32::class) { name, tensor ->
// bind 'name' -> 'tensor' into your Module's parameters
}
}
```

`AndroidGguf` (in `skainet-io-gguf`, androidMain) also offers the pre-load fit check: `AndroidGguf.plan(path, ctx)` prices the model from the GGUF header alone (a few KB of reads), and `AndroidGguf.deviceMemory(context)` reports RAM + the ART heap cap — combine with `fitOn` to refuse a model *before* the OOM kill.

**Inference in a ViewModel:**

```kotlin
Expand Down Expand Up @@ -120,14 +131,20 @@ class ClassifyViewModel(
**Asset-resident SafeTensors via cache-first pattern (same idea as GGUF):**

```kotlin
import sk.ainet.io.openRandomAccessSource
import sk.ainet.io.weights.WeightMapper
import sk.ainet.io.weights.WeightTensor

suspend fun loadSafeTensors(context: Context, ctx: ExecutionContext, module: Module<FP32, Float>) {
val file = copyAssetIfNeeded(context, "weights.safetensors")
val loader = SafeTensorsParametersLoader(
sourceProvider = { JvmFileRandomAccessSource(file) }
sourceProvider = { openRandomAccessSource(file.absolutePath)!! }
)
val loaded = mutableListOf<WeightTensor<FP32, Float>>()
loader.load(ctx, FP32::class) { name, tensor ->
module.setParameter(name, tensor)
loaded += WeightTensor(name, tensor.shape.dimensions.toList(), tensor)
}
WeightMapper.applyWeights(module, loaded) // name-based binding into the Module tree
}
```

Expand All @@ -151,6 +168,7 @@ dependencies {
implementation(platform(libs.skainet.bom))
implementation(libs.skainet.lang.core)
implementation(libs.skainet.backend.cpu)
implementation(libs.skainet.backend.jni.cpu) // AAR: NEON kernels via JNI; ServiceLoader auto-discovery
implementation(libs.skainet.io.core)
implementation(libs.skainet.io.gguf)
}
Expand Down Expand Up @@ -182,12 +200,12 @@ val ctx = app.executionContext()

```kotlin
// WRONG — assets opened as a File path (path doesn't exist inside the APK)
val reader = GGUFModelReader(JvmFileRandomAccessSource(File("file:///android_asset/model.gguf")))
val loader = AndroidGguf.loader("file:///android_asset/model.gguf")
```
```kotlin
// RIGHT — copy to cacheDir, load from there
// RIGHT — copy to cacheDir, load from there (mapped weights need a real file)
val file = copyAssetIfNeeded(context, "model.gguf")
val reader = GGUFModelReader(/* source factory wrapping `file` */)
val loader = AndroidGguf.loader(file.absolutePath)
```

```kotlin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,30 @@ private suspend fun copyAssetIfNeeded(context: Context, assetName: String): File

Use `cacheDir` if the model can be re-downloaded; `filesDir` if losing it would brick the feature.

Once the file exists on disk, open it by path:

```kotlin
// GGUF — mapped weights by default (page cache, not ART heap):
val loader = AndroidGguf.loader(file.absolutePath) // sk.ainet.io.gguf (androidMain)

// SafeTensors — the cross-platform source factory:
val stLoader = SafeTensorsParametersLoader(
sourceProvider = { openRandomAccessSource(file.absolutePath)!! } // sk.ainet.io
)
```

On Android, `openRandomAccessSource` is backed by `AndroidRandomAccessSource` (positional `FileChannel` reads — thread-safe, API 1+), so streaming loaders work without heap-loading the whole file.

## ONNX is sequential — `assets.open(...).asSource()` works

ONNX is a single-pass parse. The `OnnxLoader.fromModelSource { ... }` lambda can return a `Source` backed directly by an asset stream:

```kotlin
import kotlinx.io.asSource
import kotlinx.io.buffered

val loader = OnnxLoader.fromModelSource {
context.assets.open("model.onnx").asSource()
context.assets.open("model.onnx").asSource().buffered()
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ The dispatcher contract:

`forward(...)` is CPU-bound; never use `Dispatchers.Main` or `Dispatchers.IO` for it.

## Intra-op threading: the `Schedule`, and the JNI kernel pool

Two separate layers (0.54.0+):

- **`ctx.schedule`** (`sk.ainet.context.schedule.Schedule`) says how an op's independent work maps onto tasks. On Android the platform default is `Schedule.Sequential` — one task, the caller's thread. (The JVM's `CoroutineSchedule` lives in `skainet-backend-cpu` *jvmMain* and is not part of the Android compilation.) A schedule never changes results.
- **The native kernels thread themselves.** With `skainet-backend-jni-cpu`, the packed-quant matmul kernels run on their own native worker pool (engaged at `outputDim >= 512`, spin-then-park — the llama.cpp trick), independent of the Kotlin dispatcher you call `forward` from. Results are bit-identical to single-threaded. So one `forward` call on `Dispatchers.Default` already uses multiple cores for the heavy matmuls.

Practical consequence: don't fan one request out over many coroutines to "parallelise" a single forward pass — run one forward per request on `Dispatchers.Default` and let the kernels thread.

## Memory pressure — `onTrimMemory(level)`

Override on `Application`:
Expand Down Expand Up @@ -55,6 +64,8 @@ override fun onTrimMemory(level: Int) {

After dropping, set the holder reference to `null`. The next `model()` call rebuilds — make sure the rebuild path is fast (cached weight file vs. re-download).

Note on mapped GGUF weights (`AndroidGguf.loader`, default `WeightResidency.MAPPED`): tensor payloads live in file-backed page cache, which the OS evicts under pressure on its own — they never count against the ART heap cap. `onTrimMemory` still matters for everything heap-staged: the KV cache, activations, dense/repacked tensors, and your own caches.

## ABI filters

```kotlin
Expand All @@ -70,11 +81,13 @@ What to ship:
- **`arm64-v8a` + `armeabi-v7a`** — adds ~30% to native lib size; covers older / cheaper devices.
- **`x86_64`** — emulator only (Android Studio runs x86 emulators). Don't ship in the production split.

The `skainet-backend-jni-cpu` AAR builds `.so`s for `arm64-v8a` and `x86_64`, 16 KB-page-aligned (required on Android 15+ devices with 16 KB pages). On arm64, two tiers are built from the same sources and selected at load time from `/proc/cpuinfo`: baseline `armv8-a` NEON (runs on every arm64 core) and `armv8.2-a+fp16+dotprod` (faster q4k/q6k paths, gated so Cortex-A53-class cores don't SIGILL). On 32-bit ARM devices the scalar reference kernels run; benchmark on arm64 hardware.

Use App Bundles (`AAB`) — Play Store auto-splits per-device, so you can list multiple ABIs without paying the size cost on every device.

## Locking (multi-coroutine forward)

`DirectCpuExecutionContext` is thread-safe for read-only inference. If two coroutines might enter `forward(...)` simultaneously, that's fine — they share the context, not the model state.
Sharing one `DirectCpuExecutionContext` across coroutines doing inference is the normal pattern; since 0.54.0 the kernel registries are safe for concurrent reads. If two coroutines might enter `forward(...)` simultaneously, that's fine — they share the context, not the model state.

If you're updating the `Module` (loading new weights, swapping), guard with a `Mutex`:

Expand Down
Loading
Loading