diff --git a/.github/workflows/kernelrace-ci.yml b/.github/workflows/kernelrace-ci.yml index 47ae20d..351775b 100644 --- a/.github/workflows/kernelrace-ci.yml +++ b/.github/workflows/kernelrace-ci.yml @@ -56,8 +56,8 @@ jobs: run: | ./gradlew \ :shared:jvmTest \ - :shared:testDebugUnitTest \ - :composeApp:assembleDebug \ + :shared:testAndroidHostTest \ + :androidApp:assembleDebug \ :shared:compileKotlinWasmJs \ :composeApp:compileKotlinWasmJs \ --console=plain diff --git a/GloVeEmbeddings/README.md b/GloVeEmbeddings/README.md index 21d945a..2085810 100644 --- a/GloVeEmbeddings/README.md +++ b/GloVeEmbeddings/README.md @@ -22,7 +22,7 @@ Kotlin"), plus an offline Compose Multiplatform app that explores word vectors. ./gradlew :glove:runDemo # the article pipeline, end to end (JVM) ./gradlew :app:run # desktop UI (needs a display) ./gradlew :app:wasmJsBrowserDevelopmentRun # web UI in the browser -./gradlew :app:assembleDebug # Android APK (needs an Android SDK) +./gradlew :androidApp:assembleDebug # Android APK (needs an Android SDK) ``` iOS targets are declared and build on a macOS host (`:app` produces a `GloVeApp` framework); diff --git a/GloVeEmbeddings/androidApp/build.gradle.kts b/GloVeEmbeddings/androidApp/build.gradle.kts new file mode 100644 index 0000000..ecf8d4b --- /dev/null +++ b/GloVeEmbeddings/androidApp/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +kotlin { + dependencies { + implementation(projects.app) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.preview) + implementation(libs.androidx.activity.compose) + debugImplementation(compose.uiTooling) + } + + target { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + } +} + +android { + namespace = "sk.ainet.samples.glove.app" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + applicationId = "sk.ainet.samples.glove.app" + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { isMinifyEnabled = false } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} diff --git a/GloVeEmbeddings/app/src/androidMain/AndroidManifest.xml b/GloVeEmbeddings/androidApp/src/main/AndroidManifest.xml similarity index 100% rename from GloVeEmbeddings/app/src/androidMain/AndroidManifest.xml rename to GloVeEmbeddings/androidApp/src/main/AndroidManifest.xml diff --git a/GloVeEmbeddings/app/src/androidMain/kotlin/sk/ainet/samples/glove/app/MainActivity.kt b/GloVeEmbeddings/androidApp/src/main/kotlin/sk/ainet/samples/glove/app/MainActivity.kt similarity index 100% rename from GloVeEmbeddings/app/src/androidMain/kotlin/sk/ainet/samples/glove/app/MainActivity.kt rename to GloVeEmbeddings/androidApp/src/main/kotlin/sk/ainet/samples/glove/app/MainActivity.kt diff --git a/GloVeEmbeddings/app/src/androidMain/res/values/strings.xml b/GloVeEmbeddings/androidApp/src/main/res/values/strings.xml similarity index 100% rename from GloVeEmbeddings/app/src/androidMain/res/values/strings.xml rename to GloVeEmbeddings/androidApp/src/main/res/values/strings.xml diff --git a/GloVeEmbeddings/app/build.gradle.kts b/GloVeEmbeddings/app/build.gradle.kts index 2e0c9ec..f89c98b 100644 --- a/GloVeEmbeddings/app/build.gradle.kts +++ b/GloVeEmbeddings/app/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig plugins { alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.androidApplication) + alias(libs.plugins.androidMultiplatformLibrary) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) } @@ -12,7 +12,18 @@ plugins { kotlin { jvmToolchain(21) - androidTarget() + // Android app entry point (MainActivity, manifest) lives in :androidApp — AGP 9 no longer + // allows 'com.android.application' directly in a KMP module. This module is now a plain KMP + // library on the Android axis; :androidApp depends on it for App(). + android { + namespace = "sk.ainet.samples.glove.app.library" + compileSdk = libs.versions.android.compileSdk.get().toInt() + minSdk = libs.versions.android.minSdk.get().toInt() + + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + } listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget -> iosTarget.binaries.framework { @@ -42,10 +53,6 @@ kotlin { sourceSets { val desktopMain by getting - androidMain.dependencies { - implementation(compose.preview) - implementation(libs.androidx.activity.compose) - } commonMain.dependencies { implementation(compose.runtime) implementation(compose.foundation) @@ -76,35 +83,6 @@ compose.resources { packageOfResClass = "sk.ainet.samples.glove.app.resources" } -android { - namespace = "sk.ainet.samples.glove.app" - compileSdk = libs.versions.android.compileSdk.get().toInt() - - defaultConfig { - applicationId = "sk.ainet.samples.glove.app" - minSdk = libs.versions.android.minSdk.get().toInt() - targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 1 - versionName = "1.0" - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } - buildTypes { - getByName("release") { isMinifyEnabled = false } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } -} - -dependencies { - debugImplementation(compose.uiTooling) -} - compose.desktop { application { mainClass = "sk.ainet.samples.glove.app.MainKt" diff --git a/GloVeEmbeddings/build.gradle.kts b/GloVeEmbeddings/build.gradle.kts index 94a5ab0..cfed337 100644 --- a/GloVeEmbeddings/build.gradle.kts +++ b/GloVeEmbeddings/build.gradle.kts @@ -2,6 +2,7 @@ plugins { // Declared here (apply false) so each plugin is loaded once for the whole build. alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.androidMultiplatformLibrary) apply false alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeCompiler) apply false } diff --git a/GloVeEmbeddings/gradle.properties b/GloVeEmbeddings/gradle.properties index fa2b8fa..c47068a 100644 --- a/GloVeEmbeddings/gradle.properties +++ b/GloVeEmbeddings/gradle.properties @@ -1,7 +1,7 @@ kotlin.code.style=official # Gradle -org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m +org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1536m kotlin.daemon.jvmargs=-Xmx4g # Kotlin Multiplatform diff --git a/GloVeEmbeddings/gradle/libs.versions.toml b/GloVeEmbeddings/gradle/libs.versions.toml index 9ff62d1..fd02b07 100644 --- a/GloVeEmbeddings/gradle/libs.versions.toml +++ b/GloVeEmbeddings/gradle/libs.versions.toml @@ -7,21 +7,22 @@ kotlin = "2.4.10" # sk.ainet.core:* artifact so individual libraries are declared without versions. skainet = "0.40.1" -# Compose Multiplatform + Android. Pinned to 1.10.1 to match the shared -# :skainet-ui design system (included build): a mismatched Compose pulls two -# Skiko versions onto the desktop classpath — newer skiko-awt classes against an +# Compose Multiplatform + Android. Must match the shared :skainet-ui design +# system (included build) exactly: a mismatched Compose pulls two Skiko +# versions onto the desktop classpath — newer skiko-awt classes against an # older native runtime — which crashes with UnsatisfiedLinkError -# (getAdapterMaxTextureSize) at first render on macOS/Metal. -agp = "8.12.3" -composeMultiplatform = "1.10.1" +# (getAdapterMaxTextureSize) at first render on macOS/Metal. AGP must also +# match skainet-ui's: Gradle composite builds reject mixed AGP versions. +agp = "9.3.1" +composeMultiplatform = "1.11.1" androidx-lifecycle = "2.9.6" androidx-activityCompose = "1.12.2" # Provides the Swing Dispatchers.Main for the desktop (JVM) target; must match # the kotlinx-coroutines-core version on the classpath (1.11.0). coroutines = "1.11.0" -android-compileSdk = "36" +android-compileSdk = "37" android-minSdk = "24" -android-targetSdk = "36" +android-targetSdk = "37" [libraries] skainet-bom = { module = "sk.ainet:skainet-bom", version.ref = "skainet" } @@ -36,5 +37,6 @@ kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines- kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } +androidMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/GloVeEmbeddings/kotlin-js-store/yarn.lock b/GloVeEmbeddings/kotlin-js-store/yarn.lock index 11c30c9..b4519a3 100644 --- a/GloVeEmbeddings/kotlin-js-store/yarn.lock +++ b/GloVeEmbeddings/kotlin-js-store/yarn.lock @@ -426,7 +426,16 @@ source-map@^0.6.0: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -444,7 +453,14 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -489,7 +505,16 @@ workerpool@^9.2.0: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-9.3.4.tgz#f6c92395b2141afd78e2a889e80cb338fe9fca41" integrity sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== diff --git a/GloVeEmbeddings/settings.gradle.kts b/GloVeEmbeddings/settings.gradle.kts index c2a850b..621c7ef 100644 --- a/GloVeEmbeddings/settings.gradle.kts +++ b/GloVeEmbeddings/settings.gradle.kts @@ -39,3 +39,4 @@ includeBuild("../skainet-ui") include(":glove") include(":app") +include(":androidApp") diff --git a/KernelRace/README.md b/KernelRace/README.md index 2d3f181..cd92c3f 100644 --- a/KernelRace/README.md +++ b/KernelRace/README.md @@ -66,13 +66,13 @@ only, see below). Real ARM64 hardware shows the point best (an x86 emulator falls back to scalar): ```sh -./gradlew :composeApp:installDebug +./gradlew :androidApp:installDebug ``` Tap **Generate on-device** — the model (~145 MB) downloads on first use. Then flip the **SCALAR** chip and generate again to see the difference, or tap **Race against scalar** for the side-by-side version. To go fully offline, place `SmolLM2-135M-Instruct-Q8_0.gguf` in -`composeApp/src/androidMain/assets/` before building. +`androidApp/src/main/assets/` before building. Reference numbers (SmolLM2-135M Q8_0, SKaiNET 0.39.1): ~6.4× decode-kernel throughput NEON vs scalar on a Pixel 8a. @@ -141,17 +141,23 @@ KernelRace/ │ ├── 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 +├── composeApp/ # Compose Multiplatform UI — a KMP library (Android, jvm, iOS, wasmJs) +│ └── src/ +│ ├── commonMain/ # App/ChatScreen (skainet-ui themed), kernelControls slot +│ ├── jvmMain/ # Desktop window entry point +│ ├── iosMain/ # MainViewController — entry point called from iosApp/ +│ └── wasmJsMain/ # Browser entry point + bundled model resource +├── androidApp/ # Android application entry point — depends on composeApp + shared +│ └── src/main/ # KernelRaceApp (kernel pinning), race UI, manifest iosApp/ # Xcode project shell embedding composeApp's Kotlin/Native framework ``` +AGP 9 no longer allows `com.android.application` inside a Kotlin Multiplatform module, so the +Android entry point is a separate `:androidApp` module that depends on `:composeApp` (for `App()`) +and `:shared`; `:composeApp` itself is now a KMP library on its Android axis +(`com.android.kotlin.multiplatform.library`), same shape on jvm/iOS/wasmJs as before. + The race mechanics (multi-process kernel pinning, `KernelRegistry`, the split-screen button) are -entirely Android-specific and live in `composeApp/androidMain`. Common code only sees a +entirely Android-specific and live in `androidApp/src/main`. Common code only sees a `kernelControls` composable slot and a `GenerativeEngine` interface, which is what makes `shared`'s `ChatViewModel` unit-testable with a fake on every target — see `shared/src/*Test/`. diff --git a/KernelRace/androidApp/build.gradle.kts b/KernelRace/androidApp/build.gradle.kts new file mode 100644 index 0000000..80b1696 --- /dev/null +++ b/KernelRace/androidApp/build.gradle.kts @@ -0,0 +1,73 @@ +import java.util.Properties + +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +kotlin { + dependencies { + implementation(projects.composeApp) + implementation(projects.shared) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(libs.compose.uiTooling) + implementation(libs.androidx.activity.compose) + implementation(libs.kotlinx.coroutines) + // Hand-written ARM NEON kernels — Android JNI only. + implementation(libs.skainet.backend.jni.cpu) + } + + target { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + } +} + +android { + namespace = "sk.ainet.samples.kernelrace" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + applicationId = "sk.ainet.samples.kernelrace" + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + // ARM only — the JNI NEON kernels are the whole point of the race. + ndk { abiFilters += "arm64-v8a" } + + // Optional Hugging Face token for gated repos. Lives in local.properties + // (gitignored) as HF_TOKEN=hf_xxx — never in source, never committed. + // SmolLM2 itself is a public repo, so this is normally left blank. + val hfToken = Properties().apply { + rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use(::load) + }.getProperty("HF_TOKEN") ?: "" + buildConfigField("String", "HF_TOKEN", "\"$hfToken\"") + // Shown next to the logo — always matches the dependency in the catalog. + buildConfigField("String", "SKAINET_VERSION", "\"${libs.versions.skainet.asProvider().get()}\"") + } + + buildFeatures { + compose = true + buildConfig = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} diff --git a/KernelRace/composeApp/src/androidMain/AndroidManifest.xml b/KernelRace/androidApp/src/main/AndroidManifest.xml similarity index 100% rename from KernelRace/composeApp/src/androidMain/AndroidManifest.xml rename to KernelRace/androidApp/src/main/AndroidManifest.xml diff --git a/KernelRace/composeApp/src/androidMain/kotlin/sk/ainet/samples/kernelrace/KernelRaceApp.kt b/KernelRace/androidApp/src/main/kotlin/sk/ainet/samples/kernelrace/KernelRaceApp.kt similarity index 100% rename from KernelRace/composeApp/src/androidMain/kotlin/sk/ainet/samples/kernelrace/KernelRaceApp.kt rename to KernelRace/androidApp/src/main/kotlin/sk/ainet/samples/kernelrace/KernelRaceApp.kt diff --git a/KernelRace/composeApp/src/androidMain/kotlin/sk/ainet/samples/kernelrace/KernelRaceControls.kt b/KernelRace/androidApp/src/main/kotlin/sk/ainet/samples/kernelrace/KernelRaceControls.kt similarity index 100% rename from KernelRace/composeApp/src/androidMain/kotlin/sk/ainet/samples/kernelrace/KernelRaceControls.kt rename to KernelRace/androidApp/src/main/kotlin/sk/ainet/samples/kernelrace/KernelRaceControls.kt diff --git a/KernelRace/composeApp/src/androidMain/kotlin/sk/ainet/samples/kernelrace/MainActivity.kt b/KernelRace/androidApp/src/main/kotlin/sk/ainet/samples/kernelrace/MainActivity.kt similarity index 100% rename from KernelRace/composeApp/src/androidMain/kotlin/sk/ainet/samples/kernelrace/MainActivity.kt rename to KernelRace/androidApp/src/main/kotlin/sk/ainet/samples/kernelrace/MainActivity.kt diff --git a/KernelRace/composeApp/src/androidMain/kotlin/sk/ainet/samples/kernelrace/ScalarActivity.kt b/KernelRace/androidApp/src/main/kotlin/sk/ainet/samples/kernelrace/ScalarActivity.kt similarity index 100% rename from KernelRace/composeApp/src/androidMain/kotlin/sk/ainet/samples/kernelrace/ScalarActivity.kt rename to KernelRace/androidApp/src/main/kotlin/sk/ainet/samples/kernelrace/ScalarActivity.kt diff --git a/KernelRace/build.gradle.kts b/KernelRace/build.gradle.kts index 58a2e4a..7a58a0c 100644 --- a/KernelRace/build.gradle.kts +++ b/KernelRace/build.gradle.kts @@ -1,6 +1,6 @@ plugins { alias(libs.plugins.androidApplication) apply false - alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.androidMultiplatformLibrary) apply false alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeCompiler) apply false diff --git a/KernelRace/composeApp/build.gradle.kts b/KernelRace/composeApp/build.gradle.kts index 098633c..c4f9541 100644 --- a/KernelRace/composeApp/build.gradle.kts +++ b/KernelRace/composeApp/build.gradle.kts @@ -1,11 +1,10 @@ -import java.util.Properties import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.androidApplication) + alias(libs.plugins.androidMultiplatformLibrary) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) } @@ -13,7 +12,14 @@ plugins { kotlin { jvmToolchain(21) - androidTarget { + // Android app entry point (MainActivity, KernelRaceApp, manifest) lives in :androidApp — + // AGP 9 no longer allows 'com.android.application' directly in a KMP module. This module + // is now a plain KMP library on the Android axis; :androidApp depends on it for App(). + android { + namespace = "sk.ainet.samples.kernelrace.library" + compileSdk = libs.versions.android.compileSdk.get().toInt() + minSdk = libs.versions.android.minSdk.get().toInt() + compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } @@ -35,12 +41,6 @@ kotlin { } sourceSets { - androidMain.dependencies { - implementation(libs.compose.uiTooling) - implementation(libs.androidx.activity.compose) - // Hand-written ARM NEON kernels — Android JNI only. - implementation(libs.skainet.backend.jni.cpu) - } commonMain.dependencies { implementation(projects.shared) implementation("sk.ainet.ui:skainet-ui") @@ -67,50 +67,6 @@ kotlin { } } -android { - namespace = "sk.ainet.samples.kernelrace" - compileSdk = libs.versions.android.compileSdk.get().toInt() - - defaultConfig { - applicationId = "sk.ainet.samples.kernelrace" - minSdk = libs.versions.android.minSdk.get().toInt() - targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 1 - versionName = "1.0" - // ARM only — the JNI NEON kernels are the whole point of the race. - ndk { abiFilters += "arm64-v8a" } - - // Optional Hugging Face token for gated repos. Lives in local.properties - // (gitignored) as HF_TOKEN=hf_xxx — never in source, never committed. - // SmolLM2 itself is a public repo, so this is normally left blank. - val hfToken = Properties().apply { - rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use(::load) - }.getProperty("HF_TOKEN") ?: "" - buildConfigField("String", "HF_TOKEN", "\"$hfToken\"") - // Shown next to the logo — always matches the dependency in the catalog. - buildConfigField("String", "SKAINET_VERSION", "\"${libs.versions.skainet.asProvider().get()}\"") - } - - buildFeatures { - compose = true - buildConfig = true - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } - buildTypes { - getByName("release") { - isMinifyEnabled = false - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } -} - // Single source-of-truth for the JVM flags SKaiNET needs: the JDK Vector API (incubator) for // SIMD-accelerated CPU ops on desktop. val skainetSimdJvmArgs = listOf( diff --git a/KernelRace/composeApp/src/wasmJsMain/kotlin/sk/ainet/samples/kernelrace/main.kt b/KernelRace/composeApp/src/wasmJsMain/kotlin/sk/ainet/samples/kernelrace/main.kt index 7d17410..52d3b2a 100644 --- a/KernelRace/composeApp/src/wasmJsMain/kotlin/sk/ainet/samples/kernelrace/main.kt +++ b/KernelRace/composeApp/src/wasmJsMain/kotlin/sk/ainet/samples/kernelrace/main.kt @@ -3,22 +3,105 @@ package sk.ainet.samples.kernelrace import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.window.ComposeViewport -import kernelrace.composeapp.generated.resources.Res +import kotlin.js.ExperimentalWasmJsInterop import kotlinx.browser.document -import sk.ainet.context.DirectCpuExecutionContext -import sk.ainet.samples.kernelrace.engine.LlmEngine -import sk.ainet.samples.kernelrace.model.ModelData +import kotlinx.coroutines.CompletableDeferred +import sk.ainet.samples.kernelrace.engine.GenerativeEngine import sk.ainet.samples.kernelrace.vm.ChatViewModel -@OptIn(ExperimentalComposeUiApi::class) +// The webWorker module's raw (unwebpacked) compileSync output — every file it needs +// (.wasm, import-object.mjs, js-builtins.mjs, custom-formatters.js) sits alongside it, and the +// repo root is already served statically by composeApp's own dev-server webpack config (one of +// its three static dirs resolves to the project root), so this path needs no extra build wiring. +// `{type: "module"}` is required: the emitted .mjs uses top-level `import`. +private const val WORKER_SCRIPT_URL = + "/webWorker/build/compileSync/wasmJs/main/developmentExecutable/kotlin/KernelRace-webWorker.mjs" + +@OptIn(ExperimentalWasmJsInterop::class) +@JsFun("(url) => new Worker(url, { type: 'module' })") +external fun createModuleWorker(url: String): JsAny + +@OptIn(ExperimentalWasmJsInterop::class) +@JsFun("(worker, msg) => worker.postMessage(msg)") +external fun workerPostMessage(worker: JsAny, msg: String) + +@OptIn(ExperimentalWasmJsInterop::class) +@JsFun("(worker, handler) => { worker.onmessage = (e) => handler(e.data); worker.onerror = (e) => console.error('[main] worker onerror:', e.message, e.filename, e.lineno); }") +external fun installWorkerOnMessage(worker: JsAny, handler: (String) -> Unit) + +@OptIn(ExperimentalWasmJsInterop::class) +@JsFun("(msg) => console.log('[main] ' + msg)") +external fun mainLog(msg: String) + +/** + * Bridges the model-loading/generation Worker (see webWorker/.../Main.kt for the protocol and + * why this exists — Dispatchers.Default isn't real threading on wasmJs, so this is the only way + * to keep the page responsive during load/generation) to the [GenerativeEngine] contract + * [sk.ainet.samples.kernelrace.vm.ChatViewModel] expects. Only one call is ever in flight at a + * time (ChatViewModel guards on `busy`), so routing every incoming message to whichever call is + * "current" is safe — no per-call message tagging needed. + */ +@OptIn(ExperimentalWasmJsInterop::class) +private class WorkerGenerativeEngine(private val worker: JsAny) : GenerativeEngine { + var currentOnToken: ((String) -> Unit)? = null + var currentCompletion: CompletableDeferred? = null + + override suspend fun generate(prompt: String, maxTokens: Int, onToken: (String) -> Unit): String { + val completion = CompletableDeferred() + currentOnToken = onToken + currentCompletion = completion + workerPostMessage(worker, "GENERATE:$prompt") + try { + return completion.await() + } finally { + currentOnToken = null + currentCompletion = null + } + } +} + +@OptIn(ExperimentalComposeUiApi::class, ExperimentalWasmJsInterop::class) fun main() { ComposeViewport(document.body!!) { val viewModel = remember { - ChatViewModel(loadModel = { onProgress -> - onProgress("Downloading model bundle…") - val bytes = Res.readBytes("files/SmolLM2-135M-Instruct-Q8_0.gguf") - onProgress("Building runtime…") - LlmEngine.load(DirectCpuExecutionContext(), ModelData.Bytes(bytes)) + mainLog("creating worker from $WORKER_SCRIPT_URL") + val worker = createModuleWorker(WORKER_SCRIPT_URL) + mainLog("worker object created (this does not confirm the script loaded)") + val engine = WorkerGenerativeEngine(worker) + var loadCompletion: CompletableDeferred? = null + var onProgress: ((String) -> Unit)? = null + + installWorkerOnMessage(worker) { raw -> + when { + raw.startsWith("STATUS:") -> onProgress?.invoke(raw.removePrefix("STATUS:")) + raw == "READY" -> loadCompletion?.complete(Unit) + raw.startsWith("TOKEN:") -> engine.currentOnToken?.invoke(raw.removePrefix("TOKEN:")) + raw.startsWith("DONE:") -> engine.currentCompletion?.complete(raw.removePrefix("DONE:")) + raw.startsWith("ERROR:") -> { + val message = raw.removePrefix("ERROR:") + mainLog("ERROR received: $message") + loadCompletion?.completeExceptionally(RuntimeException(message)) + engine.currentCompletion?.completeExceptionally(RuntimeException(message)) + } + else -> mainLog("unrecognized message prefix, ignoring: ${raw.take(30)}") + } + } + + ChatViewModel(loadModel = { progress -> + // The worker fetches the GGUF itself (see webWorker/.../Main.kt) — the main + // thread never touches the model bytes. An earlier version read them here via + // Res.readBytes and shipped a base64 copy through postMessage, which held up to + // 4 copies of the ~145MB model alive across both threads at once and reliably + // crashed the renderer around 4GB; this doesn't. + mainLog("loadModel: start, posting LOAD") + onProgress = progress + val ready = CompletableDeferred() + loadCompletion = ready + workerPostMessage(worker, "LOAD") + mainLog("loadModel: LOAD message posted, awaiting READY") + ready.await() + mainLog("loadModel: ready.await() returned, returning engine") + engine }) } App(viewModel = viewModel, skainetVersion = SKAINET_VERSION) diff --git a/KernelRace/composeApp/src/wasmJsMain/resources/index.html b/KernelRace/composeApp/src/wasmJsMain/resources/index.html index b809585..b373912 100644 --- a/KernelRace/composeApp/src/wasmJsMain/resources/index.html +++ b/KernelRace/composeApp/src/wasmJsMain/resources/index.html @@ -7,6 +7,12 @@ + +