Skip to content

AGP 9 migration + KernelRace web fixes (crash, streaming, tokenizer) - #35

Merged
michalharakal merged 12 commits into
developfrom
feature/agp9-migration
Aug 21, 2026
Merged

michalharakal merged 12 commits into
developfrom
feature/agp9-migration

Conversation

@michalharakal

Copy link
Copy Markdown
Contributor

Summary

AGP 9.3.1 migration, required across the whole monorepo since Gradle composite builds can't mix AGP versions:

  • KernelRace, skainet-ui, GloVeEmbeddings, KllamaDemo, MNISTDemo, SinusApproximator, TinyTransformer all migrated: any module combining com.android.application/com.android.library directly with androidTarget() split into a dedicated androidApp entry-point module + com.android.kotlin.multiplatform.library for the KMP side (AGP 9 no longer allows the old combined shape). Verified with real builds on every one, not just config review.

KernelRace web (wasmJs) build — was hanging/crashing, now works correctly:

  • Fixed missing COOP/COEP headers (both local dev server and the production static build) so the wasm/Skiko multi-threaded runtime actually gets cross-origin isolation.
  • Moved model loading + token generation into a genuine Web Worker — the old code ran SKaiNET's generation loop (a synchronous, non-suspending function) directly on the main thread, freezing the page for the full duration of every load and generate call. Tokens now stream in live and the UI stays responsive.
  • Fixed a real OOM crash: the worker was receiving the model as a base64 string relayed from the main thread, holding up to 4 copies of the ~145MB GGUF alive across both threads at once. The worker now fetches it directly.
  • Fixed a tokenizer bug that was the actual cause of "hallucinated" answers: wasm was using GGUFTokenizer.fromSource, a different BPE tokenizer implementation than TokenizerFactory.fromGgufSource (what Android/JVM actually use in production). The two disagree on token count for identical input, corrupting the ChatML special-token structure the model needs to know it's being asked a question. Output is now verified byte-for-byte identical to the real JVM/Android generation path for the same prompt (see companion issue in SKaiNET-transformers).
  • Switched to QuantPolicy.NATIVE_OPTIMIZED (packed Q8_0, matching Android/JVM) instead of full FP32 dequantization — less memory, and ~5x faster generation (2.1 → 10 tok/s).
  • iOS: added the CADisableMinimumFrameDurationOnPhone Info.plist key Compose Multiplatform's PlistSanityCheck requires at launch (crashed without it).
  • Minor: MIT license attribution in the web footer instead of a stale copyright notice.

Test plan

  • Full ./gradlew build green on KernelRace (Android/JVM/wasmJs/iOS) and on each migrated sibling repo, individually
  • skainet-ui builds standalone
  • wasm web build manually verified in-browser: loads without crashing, UI responsive during load, tokens stream token-by-token, generated answers match JVM/Android output for the same prompt
  • Sibling repos not yet re-verified after their individual AGP 9 migrations beyond the initial build pass — worth a spot-check before merge if any are actively used

AGP 9 no longer allows applying com.android.application (or
com.android.library) alongside the Kotlin Multiplatform plugin in the
same module, and Gradle composite builds enforce a single AGP version
across the whole build graph.

- KernelRace/composeApp: split the Android app entry point (Activities,
  Application subclass, manifest) out into a new androidApp module.
  composeApp itself now targets Android via the new
  com.android.kotlin.multiplatform.library plugin instead of
  com.android.application.
- KernelRace/shared: migrated from com.android.library to the same
  com.android.kotlin.multiplatform.library plugin.
- skainet-ui: same plugin migration, plus bumped composeMultiplatform to
  1.11.1 to stay in lockstep with KernelRace (avoids a Skiko
  double-version classpath crash on desktop).
- Bumped agp to 9.3.1, compileSdk/targetSdk to 37, androidx-lifecycle to
  2.11.0 across both modules.
- Updated README.md and kernelrace-ci.yml to reference the new
  androidApp module's Gradle tasks instead of composeApp's.

Verified with real builds: jvmTest, testAndroidHostTest,
androidApp:assembleDebug, wasmJs compile, and all 4 iOS framework
variants (debug/release x arm64/simulator) all green.
…ert broken quant policy

- iosApp/Info.plist: add CADisableMinimumFrameDurationOnPhone, required
  by Compose Multiplatform's PlistSanityCheck at launch (was missing,
  crashed on first real device/simulator run).
- index.html + new webpack.config.d/coi-headers.js: the wasm build's
  portal page never actually achieved cross-origin isolation (COOP/COEP)
  because only app.html loaded the coi-serviceworker shim while the
  top-level index.html (which the isolation status is scoped to) did
  not, and the iframe was missing allow="cross-origin-isolated". Also
  make the local dev server send the headers natively instead of relying
  solely on the shim.
- gradle.properties: raise daemon heap/metaspace (512m was too tight for
  this multi-target build and produced misleading transient errors).
- shared/LlamaRuntimeBuilder.kt: reverted the wasmJs fallback from
  QuantPolicy.NATIVE_OPTIMIZED back to DEQUANTIZE_TO_FP32 — confirmed
  with a standalone Node/Kotlin-Wasm harness that NATIVE_OPTIMIZED
  currently throws inside SKaiNET's DecoderGgufWeightLoader for this
  model's tied embedding tensor (shape-mismatch bug in the sequential
  GGUF loader, not a config issue on our side).
Same treatment as KernelRace: AGP 9 no longer allows com.android.application
directly in a Kotlin Multiplatform module, and compileSdk must be 37+ to
consume skainet-ui at its now-current AGP 9 pin.

- New androidApp module: the real Android entry point (MainActivity,
  manifest, strings.xml), depends on :app for the shared App() composable.
- app: now a KMP library (com.android.kotlin.multiplatform.library) on its
  Android axis; unchanged on iOS/desktop/wasmJs.
- Bumped agp to 9.3.1, composeMultiplatform to 1.11.1 (lockstep with
  skainet-ui, avoids a Skiko double-version desktop crash), compileSdk/
  targetSdk to 37.
- Updated README's Android run command to the new module.

Verified with a real full build: glove, app (desktop/wasmJs/iOS), and
androidApp (debug + release) all green.
Same treatment as KernelRace/GloVeEmbeddings. composeApp mixed the real
Android entry point (MainActivity, SetupAndroidFilePicker, manifest,
launcher icons) with expect/actual platform logic
(QwenRuntimeBuilder.android.kt) in one module — split the former into a
new androidApp module; the latter stays in composeApp since it's business
logic, not entry-point code.

- New androidApp module, depends on composeApp + shared.
- composeApp and shared: now com.android.kotlin.multiplatform.library on
  their Android axis.
- Bumped agp to 9.3.1, composeMultiplatform to 1.11.1 (lockstep with
  skainet-ui), compileSdk/targetSdk to 37.
- Updated README's Android run command to the new module.

Verified with a real full build: androidApp (debug + release), composeApp
(jvm/js/wasmJs compile/iOS), and shared all green. Pre-existing, unrelated
issues left untouched and excluded from verification: wasmJsBrowserTest
finds no tests (test config, not this migration), wasmJs production
webpack bundling fails on an unresolved @js-joda/core npm module (kotlinx-
datetime's transitive JS dependency, missing from this repo's yarn lock
policy — KllamaDemo's root build.gradle.kts has no YarnPlugin
auto-replace config, unlike GloVeEmbeddings'), and kotlinStoreYarnLock
fails for the same lock-drift reason. None of these are Android/AGP
related; Kotlin/Wasm compilation itself succeeds cleanly both times.
Same treatment as the other samples, plus two MNISTDemo-specific fixes
this migration surfaced:

- New androidApp module: both MainActivity variants (the live one at
  com.kkon.kmp.ai.mnist.demo, referenced by the manifest, and an unused
  dead one at sk.ai.net.samples.kmp.mnist.demo — left as-is, not mine to
  prune here), manifest, launcher icons, and the mnist.json asset.
- composeApp and shared: now com.android.kotlin.multiplatform.library on
  their Android axis.
- composeApp: added compose.resources { publicResClass = true } — the
  generated Res accessor defaults to internal visibility, which broke
  once MainActivity moved out to a separate module.
- shared: dropped two unused dependencies (skainet-data-api,
  skainet-data-simple — grep confirms neither sk.ainet.data.* symbol is
  referenced anywhere in this repo). Necessary, not just cleanup: their
  0.40.1 Android artifacts both declare the namespace sk.ainet.core.api,
  which AGP 9's manifest merger now rejects as a duplicate (an upstream
  SKaiNET packaging collision, not fixable from this repo).
- Bumped this repo's own Gradle wrapper 9.4.1 -> 9.5.1 (AGP 9.3.1
  requires 9.5.0+; skainet-ui's includeBuild runs under the root build's
  Gradle version, so the mismatch surfaced immediately).
- Bumped agp to 9.3.1, composeMultiplatform to 1.11.1, compileSdk/
  targetSdk to 37.

Verified with a real full build (after clearing a stale incremental-
compilation cache that briefly produced an unrelated Kotlin/Wasm
internal compiler error): androidApp (debug + release), composeApp
(desktop/wasmJs), and shared all green.
Same treatment as the other samples. composeApp mixed the real Android
entry point (MainActivity, manifest, launcher icons) with expect/actual
platform logic (Platform.android.kt's `actual val isWasmPlatform`) in
one module — split the former into a new androidApp module; the latter
stays in composeApp since it's platform logic, not entry-point code.

- New androidApp module, depends on composeApp + shared.
- composeApp and shared: now com.android.kotlin.multiplatform.library on
  their Android axis.
- shared: dropped two unused dependencies (skainet-data-api,
  skainet-data-simple — confirmed unreferenced anywhere in this repo,
  same as the identical fix in MNISTDemo). Necessary: their 0.40.1
  Android artifacts both declare the same namespace, which AGP 9's
  manifest merger rejects as a duplicate.
- Bumped agp to 9.3.1, composeMultiplatform to 1.11.1 (lockstep with
  skainet-ui), compileSdk/targetSdk to 37.

Verified with a real full build: androidApp (debug + release), composeApp
(desktop/js/wasmJs/iOS), and shared all green on the first attempt after
the above fixes.
Same treatment as SinusApproximator (identical starting shape): split
composeApp's Android entry point (MainActivity, manifest, launcher
icons) into a new androidApp module; Platform.android.kt's expect/actual
(`actual val isWasmPlatform`) stays in composeApp since it's platform
logic, not entry-point code.

- New androidApp module, depends on composeApp + shared.
- composeApp and shared: now com.android.kotlin.multiplatform.library on
  their Android axis.
- Bumped agp to 9.3.1, composeMultiplatform to 1.11.1 (lockstep with
  skainet-ui), compileSdk/targetSdk to 37.
- Updated README's Android run command to the new module.

Verified with a real full build: androidApp (debug + release), composeApp
(desktop/js/wasmJs/iOS), and shared all green on the first attempt.
…ity.nowarn

Dead workaround for the old AGP/KMP compatibility warning, no longer
relevant now that skainet-ui builds on com.android.kotlin.multiplatform.library.
Dispatchers.Default on wasmJs isn't real threading (same single JS
thread as Main), and SKaiNET's generateUntilStop is a plain
non-suspend loop with no yield points — so calling either from the
main thread froze the page for the full duration of model load and
every generate() call, with no token-by-token streaming visible.

New :webWorker module runs LlmEngine.load()/generate() inside a
dedicated Web Worker instead — a genuinely separate JS/Wasm execution
context — and streams status/tokens back to the main thread over a
small string postMessage protocol (MODEL/GENERATE in, STATUS/TOKEN/
DONE/ERROR/READY out). composeApp's main.kt bridges that protocol to
the existing GenerativeEngine contract, so ChatViewModel needed no
changes at all — it already assumed streaming, the wasm main thread
just couldn't deliver it.

Model bytes still come from Res.readBytes on the main thread (handles
resource-path resolution correctly in both dev and prod already) and
get base64-encoded over to the worker, rather than having the worker
fetch the GGUF independently — simpler, and the encode/postMessage
copy is bounded (well under a second) unlike the multi-second compute
this actually fixes.

Worker script is served from webWorker's raw (unwebpacked) compileSync
output — every dependency file sits alongside it, and the repo root is
already in composeApp's dev-server static paths, so no extra build
wiring was needed for local dev. NOT verified in an actual browser
(no browser access in this environment) — build is green and every
file the worker needs is reachable over HTTP with correct headers,
but the production wasmJsBrowserDistribution path (webpack-bundled
rather than raw compileSync output) is unverified and may need a
different worker URL/wiring; needs a real browser check before
trusting this end to end.
The worker previously received the GGUF as a base64 string relayed
from the main thread (which read it via Res.readBytes and encoded
it before posting), leaving up to 4 copies of the ~145MB model alive
across both threads at once — main-thread bytes, main-thread base64
string, the worker's structured-clone copy, and the worker's decoded
bytes. That reliably crashed the renderer around 4GB, a regression
the original single-threaded path never had.

The worker now fetches the GGUF directly (same static URL the main
thread used to resolve via Res.readBytes), so only one thread ever
holds the model in memory. Confirmed working: no crash, responsive
UI during load, and tokens stream in live during generation.
Footer said "© 2026 SKaiNET. All rights reserved." — the repo is
actually MIT licensed (see LICENSE), attributed to SKaiNET-developers.
wasm was using GGUFTokenizer.fromSource, a completely separate BPE
tokenizer implementation from TokenizerFactory.fromGgufSource — the
one DesktopModelProvider/AndroidModelProvider actually use in
production. Confirmed via a standalone Node/Kotlin-Wasm harness: the
two disagree on token count for the identical prompt string (25 vs
16 tokens), which was corrupting the ChatML special-token structure
the model relies on to tell prompt from response. Model output was
fluent (real vocabulary, valid tokens) but consistently off-topic —
it had lost the "answer this question" structural signal but not its
underlying language ability.

Switched to TokenizerFactory.fromGgufSource, backed by the same
InMemoryRandomAccessSource already used for model loading. Verified
in the harness to produce byte-for-byte identical output to the real
JVM path for the same prompt. Also noticeably faster in the browser
(~10 tok/s vs ~2.1 tok/s) — packed Q8_0 needs far less memory
bandwidth per matmul than the dense FP32 the old fallback used.
@michalharakal
michalharakal merged commit e210e51 into develop Aug 21, 2026
1 check passed
@michalharakal
michalharakal deleted the feature/agp9-migration branch August 21, 2026 20:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant