AGP 9 migration + KernelRace web fixes (crash, streaming, tokenizer) - #35
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
AGP 9.3.1 migration, required across the whole monorepo since Gradle composite builds can't mix AGP versions:
com.android.application/com.android.librarydirectly withandroidTarget()split into a dedicatedandroidAppentry-point module +com.android.kotlin.multiplatform.libraryfor 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:
GGUFTokenizer.fromSource, a different BPE tokenizer implementation thanTokenizerFactory.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).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).CADisableMinimumFrameDurationOnPhoneInfo.plist key Compose Multiplatform'sPlistSanityCheckrequires at launch (crashed without it).Test plan
./gradlew buildgreen on KernelRace (Android/JVM/wasmJs/iOS) and on each migrated sibling repo, individuallyskainet-uibuilds standalone