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
62 changes: 56 additions & 6 deletions KernelRace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,46 @@ hand-written NEON matmul kernels behind a JNI bridge, with two `.so` tiers (`arm
This example began as the Android-only **AndroidNeonLlmDemo** (still available as a frozen
snapshot at the git tag [`2026_08_arm_android`](../../tree/2026_08_arm_android/AndroidNeonLlmDemo))
and was converted into a proper Kotlin Multiplatform sample: shared engine/view-model logic, a
Compose Multiplatform UI, and platform-specific runtime construction for Android, Desktop and
Wasm. iOS is deferred until the multiplatform structure is proven out.
Compose Multiplatform UI, and platform-specific runtime construction for Android, Desktop, Wasm
and iOS.

On iOS, the same Llama runtime dispatches to SKaiNET's Apple `native-cinterop` packed-quant
kernels (see `shared/src/iosMain/.../Platform.ios.kt`) — the direct engine-level counterpart to
Android's NEON JNI kernels, though without the two-process split-screen race mechanic (Android
only, see below).

> **Note**: this repo's `iosApp/` Xcode project was authored without access to Xcode/macOS, so
> it's structurally complete but unbuilt — the first real compile/run needs a Mac. If Xcode
> reports a project-format issue on first open, that's the thing to fix; the Kotlin side
> (`shared`/`composeApp`'s `iosArm64`/`iosSimulatorArm64` targets) is unaffected by it either way.
> Separately: `kllama`'s Apple native-kernel wiring was fixed in SKaiNET-transformers PR #315
> (stale no-op stubs were leaving packed-quant matmul on the scalar floor on iOS/macOS) — until a
> SK-TR release ships that fix, KernelRace's iOS build runs correctly but without NEON
> acceleration.

## What it demonstrates

- **~5-line integration**: `DecoderGgufWeightLoader` → `OptimizedLLMRuntime` →
`generateUntilStop`, streaming tokens into Compose (see `shared/.../engine/LlmEngine.kt` and
the per-platform `LlamaRuntimeBuilder.*.kt` actuals).
- **How cheap adding iOS actually was**: the entire iOS-specific surface is ~200 lines across
four files (`shared/src/iosMain/`, `composeApp/src/iosMain/`) — and the one that matters,
`LlamaRuntimeBuilder.ios.kt`, is 36 lines and nearly a line-for-line copy of the JVM actual
(same `DecoderGgufWeightLoader` call, `PosixPreadRandomAccessSource` instead of
`JvmRandomAccessSource`). No SKaiNET code changed to make this work — the engine's KMP targets
and Apple `native-cinterop` kernels were already there. That's the actual point of this sample:
proof that SKaiNET apps aren't Android-first with iOS bolted on — iOS is just another
`expect`/`actual` pair.
- **NEON | SCALAR switch** (Android only): two chips re-pin the kernel registry (engine reloads
on the next run) — same APK, same model, full-device A/B with a live tok/s counter.
- **Split-screen race** (Android only): one button launches a second process with the scalar
provider pinned and starts both generations simultaneously.

![Split-screen race: NEON at 44.7 tok/s vs scalar at 9.3 tok/s](docs/screenshots/split_race.png)
- **Cross-platform kernel tiers**: the same Kotlin `LlmEngine` runs on three different kernel
paths — Android's ARM NEON JNI kernels, Desktop's native-optimized file-based load, and Wasm's
in-memory FP32 fallback (browsers have no filesystem, so the model is bundled at build time
instead of downloaded).
- **Cross-platform kernel tiers**: the same Kotlin `LlmEngine` runs on four different kernel
paths — Android's ARM NEON JNI kernels, iOS's Apple `native-cinterop` kernels, Desktop's
native-optimized file-based load, and Wasm's in-memory FP32 fallback (browsers have no
filesystem, so the model is bundled at build time instead of downloaded).
- **Model delivery**: Android downloads the GGUF from the Hugging Face Hub on first run
(SKaiNET's Ktor fetcher, streamed to disk with progress) or uses a bundled asset if present;
Desktop downloads to a local cache dir; Wasm bundles the model into the production build via
Expand Down Expand Up @@ -76,12 +98,37 @@ That's a deliberately large asset for a web page — acceptable for a local dev
maintainer-approved deploy, but worth knowing about before wiring this into CI or a public
samples page.

The production build (as deployed to GitHub Pages) vendors the
[`coi-serviceworker`](https://github.com/gzuidhof/coi-serviceworker) shim — Compose's Wasm/Skiko
canvas needs cross-origin isolation (`SharedArrayBuffer`) for its multi-threaded renderer, and
GitHub Pages can't set the `Cross-Origin-Opener-Policy`/`Cross-Origin-Embedder-Policy` response
headers that provides. `wasmJsBrowserDevelopmentRun`'s own dev server sets them automatically, so
this only matters for the static production build.

### iOS

```sh
open iosApp/iosApp.xcodeproj
```

Run the `iosApp` scheme on a simulator or device from Xcode (⌘R). First build runs a "Compile
Kotlin Framework" script phase (`:composeApp:embedAndSignAppleFrameworkForXcode`) that builds and
embeds `shared`+`composeApp`'s Kotlin/Native framework before Swift compiles — no separate Gradle
step needed. Model delivery mirrors Desktop: downloads to the app's Caches directory on first run,
or bundle `SmolLM2-135M-Instruct-Q8_0.gguf` into the Xcode target for an offline build.

No NEON-vs-scalar race UI here (Android-only mechanic) — but the Llama runtime still dispatches
through SKaiNET's Apple `native-cinterop` kernels rather than falling back to scalar, once the
`kllama` fix referenced above has shipped.

## Requirements

- Android: ARM64 device (minSdk 24, one APK covers armv8.0 through armv9), JDK 21+
- Desktop: JDK 21+ with the JDK Vector API (incubator) enabled — wired automatically by the
Gradle build
- Web: a Chromium-based browser for the wasm GC runtime
- iOS: Xcode 15+, a Mac (this repo's iOS support was authored and structurally verified without
either — see the note above)

## Architecture

Expand All @@ -92,13 +139,16 @@ KernelRace/
│ ├── commonMain/ # LlmEngine, ChatViewModel, ModelResolver (pure, unit-tested)
│ ├── androidMain/ # AndroidModelProvider, NEON-aware LlamaRuntimeBuilder actual
│ ├── jvmMain/ # DesktopModelProvider, file-based LlamaRuntimeBuilder actual
│ ├── iosMain/ # IosModelProvider (Ktor/Darwin), pread-based LlamaRuntimeBuilder actual
│ └── wasmJsMain/ # Bytes-only LlamaRuntimeBuilder actual (no filesystem)
└── composeApp/ # Compose Multiplatform UI + platform entry points
└── src/
├── commonMain/ # App/ChatScreen (skainet-ui themed), kernelControls slot
├── androidMain/ # KernelRaceApp (kernel pinning), race UI, manifest
├── jvmMain/ # Desktop window entry point
├── iosMain/ # MainViewController — entry point called from iosApp/
└── wasmJsMain/ # Browser entry point + bundled model resource
iosApp/ # Xcode project shell embedding composeApp's Kotlin/Native framework
```

The race mechanics (multi-process kernel pinning, `KernelRegistry`, the split-screen button) are
Expand Down
21 changes: 21 additions & 0 deletions KernelRace/THIRD_PARTY_LICENSES/MIT-coi-serviceworker.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2021 Guido Zuidhof

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
6 changes: 6 additions & 0 deletions KernelRace/THIRD_PARTY_LICENSES/NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@ GGUF build: https://huggingface.co/unsloth/SmolLM2-135M-Instruct-GGUF
GGUF file: SmolLM2-135M-Instruct-Q8_0.gguf (~145 MB)

Attribution: HuggingFaceTB. See https://github.com/huggingface/smollm.

This product bundles coi-serviceworker (composeApp/src/wasmJsMain/resources/
coi-serviceworker.js), (c) 2021 Guido Zuidhof and contributors, distributed
under the MIT License (see MIT-coi-serviceworker.txt in this directory).

Source: https://github.com/gzuidhof/coi-serviceworker
7 changes: 7 additions & 0 deletions KernelRace/composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ kotlin {

jvm()

listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}

@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ fun ChatScreen(
Modifier.fillMaxSize().padding(padding)
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
PlatformChips(Modifier.padding(bottom = 6.dp))

kernelControls(state.busy, viewModel) { prompt }

Text(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package sk.ainet.samples.kernelrace.ui

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import sk.ainet.samples.kernelrace.platform.SamplePlatform
import sk.ainet.samples.kernelrace.platform.currentSamplePlatform

/**
* One chip per platform this sample runs on, with whichever one is currently running
* highlighted — a quick visual "this sample is genuinely multiplatform" signal, independent of
* [sk.ainet.samples.kernelrace.platform.kernelTierLabel] (which describes the kernel *inside*
* the current platform, not the platform list itself). Purely informational — selection is
* driven by [currentSamplePlatform], not by taps.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlatformChips(modifier: Modifier = Modifier) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
for (platform in SamplePlatform.entries) {
FilterChip(
selected = platform == currentSamplePlatform,
onClick = {},
label = { Text(platform.label) },
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package sk.ainet.samples.kernelrace

import androidx.compose.runtime.remember
import androidx.compose.ui.window.ComposeUIViewController
import platform.UIKit.UIViewController
import sk.ainet.context.DirectCpuExecutionContext
import sk.ainet.samples.kernelrace.engine.LlmEngine
import sk.ainet.samples.kernelrace.model.IosModelProvider
import sk.ainet.samples.kernelrace.model.ModelData
import sk.ainet.samples.kernelrace.vm.ChatViewModel

/** Entry point called from iosApp/iOSApp.swift — same "resolve model, then load engine" shape
* as the JVM/Android entry points (see main.kt / MainActivity.kt). No kernelControls slot:
* the NEON-vs-scalar race is Android-only (see Platform.ios.kt's supportsKernelRace = false). */
fun MainViewController(): UIViewController = ComposeUIViewController {
val viewModel = remember {
ChatViewModel(loadModel = { onProgress ->
val model = IosModelProvider().resolve(onProgress) as ModelData.FilePath
onProgress("Building runtime…")
LlmEngine.load(DirectCpuExecutionContext(), model)
})
}
App(viewModel = viewModel, skainetVersion = SKAINET_VERSION)
}
7 changes: 7 additions & 0 deletions KernelRace/composeApp/src/wasmJsMain/resources/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kernel Race</title>
<link type="text/css" rel="stylesheet" href="styles.css">
<!-- Compose's Wasm/Skiko canvas needs cross-origin isolation (SharedArrayBuffer) for its
multi-threaded renderer. GitHub Pages can't set the COOP/COEP response headers that
provides, so this shim sets them client-side instead — registers a service worker on
first load (which triggers one reload), then every load after that is isolated.
wasmJsBrowserDevelopmentRun's own dev server sets these headers natively and is
unaffected either way. See THIRD_PARTY_LICENSES/NOTICE. -->
<script src="coi-serviceworker.js"></script>
<script type="application/javascript" src="composeApp.js"></script>
</head>
<body>
Expand Down
146 changes: 146 additions & 0 deletions KernelRace/composeApp/src/wasmJsMain/resources/coi-serviceworker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/*! coi-serviceworker v0.1.7 - Guido Zuidhof and contributors, licensed under MIT */
let coepCredentialless = false;
if (typeof window === 'undefined') {
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));

self.addEventListener("message", (ev) => {
if (!ev.data) {
return;
} else if (ev.data.type === "deregister") {
self.registration
.unregister()
.then(() => {
return self.clients.matchAll();
})
.then(clients => {
clients.forEach((client) => client.navigate(client.url));
});
} else if (ev.data.type === "coepCredentialless") {
coepCredentialless = ev.data.value;
}
});

self.addEventListener("fetch", function (event) {
const r = event.request;
if (r.cache === "only-if-cached" && r.mode !== "same-origin") {
return;
}

const request = (coepCredentialless && r.mode === "no-cors")
? new Request(r, {
credentials: "omit",
})
: r;
event.respondWith(
fetch(request)
.then((response) => {
if (response.status === 0) {
return response;
}

const newHeaders = new Headers(response.headers);
newHeaders.set("Cross-Origin-Embedder-Policy",
coepCredentialless ? "credentialless" : "require-corp"
);
if (!coepCredentialless) {
newHeaders.set("Cross-Origin-Resource-Policy", "cross-origin");
}
newHeaders.set("Cross-Origin-Opener-Policy", "same-origin");

return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
})
.catch((e) => console.error(e))
);
});

} else {
(() => {
const reloadedBySelf = window.sessionStorage.getItem("coiReloadedBySelf");
window.sessionStorage.removeItem("coiReloadedBySelf");
const coepDegrading = (reloadedBySelf == "coepdegrade");

// You can customize the behavior of this script through a global `coi` variable.
const coi = {
shouldRegister: () => !reloadedBySelf,
shouldDeregister: () => false,
coepCredentialless: () => true,
coepDegrade: () => true,
doReload: () => window.location.reload(),
quiet: false,
...window.coi
};

const n = navigator;
const controlling = n.serviceWorker && n.serviceWorker.controller;

// Record the failure if the page is served by serviceWorker.
if (controlling && !window.crossOriginIsolated) {
window.sessionStorage.setItem("coiCoepHasFailed", "true");
}
const coepHasFailed = window.sessionStorage.getItem("coiCoepHasFailed");

if (controlling) {
// Reload only on the first failure.
const reloadToDegrade = coi.coepDegrade() && !(
coepDegrading || window.crossOriginIsolated
);
n.serviceWorker.controller.postMessage({
type: "coepCredentialless",
value: (reloadToDegrade || coepHasFailed && coi.coepDegrade())
? false
: coi.coepCredentialless(),
});
if (reloadToDegrade) {
!coi.quiet && console.log("Reloading page to degrade COEP.");
window.sessionStorage.setItem("coiReloadedBySelf", "coepdegrade");
coi.doReload("coepdegrade");
}

if (coi.shouldDeregister()) {
n.serviceWorker.controller.postMessage({ type: "deregister" });
}
}

// If we're already coi: do nothing. Perhaps it's due to this script doing its job, or COOP/COEP are
// already set from the origin server. Also if the browser has no notion of crossOriginIsolated, just give up here.
if (window.crossOriginIsolated !== false || !coi.shouldRegister()) return;

if (!window.isSecureContext) {
!coi.quiet && console.log("COOP/COEP Service Worker not registered, a secure context is required.");
return;
}

// In some environments (e.g. Firefox private mode) this won't be available
if (!n.serviceWorker) {
!coi.quiet && console.error("COOP/COEP Service Worker not registered, perhaps due to private mode.");
return;
}

n.serviceWorker.register(window.document.currentScript.src).then(
(registration) => {
!coi.quiet && console.log("COOP/COEP Service Worker registered", registration.scope);

registration.addEventListener("updatefound", () => {
!coi.quiet && console.log("Reloading page to make use of updated COOP/COEP Service Worker.");
window.sessionStorage.setItem("coiReloadedBySelf", "updatefound");
coi.doReload();
});

// If the registration is active, but it's not controlling the page
if (registration.active && !n.serviceWorker.controller) {
!coi.quiet && console.log("Reloading page to make use of COOP/COEP Service Worker.");
window.sessionStorage.setItem("coiReloadedBySelf", "notcontrolling");
coi.doReload();
}
},
(err) => {
!coi.quiet && console.error("COOP/COEP Service Worker failed to register:", err);
}
);
})();
}
Loading
Loading