diff --git a/.github/workflows/native-cpu-multiarch.yml b/.github/workflows/native-cpu-multiarch.yml index d4c2a73fc..8935b6952 100644 --- a/.github/workflows/native-cpu-multiarch.yml +++ b/.github/workflows/native-cpu-multiarch.yml @@ -94,6 +94,19 @@ jobs: :skainet-backends:skainet-backend-native-cpu:jvmTest \ :skainet-backends:skainet-backend-native-cpu:jvmJar + # Apple K/N lane (#959, iOS kernel track of #920): builds the simulator + + # macOS static archives via the macOS-host heuristic and runs the shared + # nativeTest parity suites natively (macosArm64Test, dispatch fast arm on + # the M-series runner) and in the iOS simulator (iosSimulatorArm64Test). + - name: K/N Apple simulator + host tests (macOS only) + if: matrix.arch_label == 'macos-arm64' + env: + GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 + run: | + ./gradlew --no-daemon --stacktrace \ + :skainet-backends:skainet-backend-native-cpu:macosArm64Test \ + :skainet-backends:skainet-backend-native-cpu:iosSimulatorArm64Test + - name: Build + test native module (Windows) if: runner.os == 'Windows' shell: pwsh diff --git a/CHANGELOG.md b/CHANGELOG.md index d545c1760..c3465b02d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,21 @@ rebuilt per access. The README now points LLM users to SKaiNET-transformers. baseline+dispatch — runtime-equivalent on every Apple Silicon Mac. iOS builds are static-only (`SKAINET_STATIC_ONLY`, auto-on for `CMAKE_SYSTEM_NAME=iOS`). +### Added + +- **`skainet-backend-native-cpu` publishes Apple Kotlin/Native targets** — `iosArm64`, + `iosSimulatorArm64`, and `macosArm64` klibs with the Mach-O kernel static archive embedded + via cinterop, exactly like the Linux pair ([#959](https://github.com/SKaiNET-developers/SKaiNET/issues/959), + iOS kernel track of [#920](https://github.com/SKaiNET-developers/SKaiNET/issues/920)). + Archives are built by new Apple CMake lanes on a macOS host (platform SDKs, no `-march` — + the #958 runtime FEAT_DotProd dispatch serves A12 through M-series from one device archive) + or injected in CI via `-PskainetKernelsIosArm64Dir` / `-PskainetKernelsIosSimulatorArm64Dir` / + `-PskainetKernelsMacosArm64Dir`. The shared nativeTest parity suites now also run as + `macosArm64Test` (native) and `iosSimulatorArm64Test` (simulator) on the macos-14 PR lane. + On non-macOS hosts the Apple task family is disabled (ubuntu CI unaffected). Registration + on K/N remains manual via `installNativeKernels()` — Apple consumers call it once at startup, + same as Linux. + ## [0.39.0] - 2026-08-10 Headline: **on-device AI on Android becomes real.** A JNI NEON kernel backend diff --git a/skainet-backends/skainet-backend-native-cpu/build.gradle.kts b/skainet-backends/skainet-backend-native-cpu/build.gradle.kts index b9e9c5dfd..48231a072 100644 --- a/skainet-backends/skainet-backend-native-cpu/build.gradle.kts +++ b/skainet-backends/skainet-backend-native-cpu/build.gradle.kts @@ -37,6 +37,28 @@ val x64ArchiveDir: String? = injectedX64Dir val arm64ArchiveDir: String? = injectedArm64Dir ?: if (crossArm64ForEmbed) layout.buildDirectory.dir("native/cmake-build-arm64").get().asFile.absolutePath else null +// Apple (K/N) archives — iOS kernel track of #920 (#959). Mach-O static +// archives built with the platform SDKs, so they exist only on a macOS host +// (Xcode required); elsewhere the whole Apple task family is disabled below +// and CI injects prebuilt archives via the -PskainetKernels*Dir properties +// (publish.yml macos leg), mirroring the Linux pair above. The device archive +// is compiled at baseline arm64 with runtime FEAT_DotProd dispatch (#958), so +// ONE archive serves A12 (no dotprod) through A18/M-series. +val isMacosHostForEmbed: Boolean = + System.getProperty("os.name").lowercase().let { it.contains("mac") || it.contains("darwin") } +val cmakeBuildIosArm64Path: String = + layout.buildDirectory.dir("native/cmake-build-ios-arm64").get().asFile.absolutePath +val cmakeBuildIosSimArm64Path: String = + layout.buildDirectory.dir("native/cmake-build-ios-sim-arm64").get().asFile.absolutePath +val cmakeBuildMacosArm64Path: String = + layout.buildDirectory.dir("native/cmake-build-macos-arm64").get().asFile.absolutePath +val injectedIosArm64Dir: String? = findProperty("skainetKernelsIosArm64Dir") as String? +val injectedIosSimArm64Dir: String? = findProperty("skainetKernelsIosSimulatorArm64Dir") as String? +val injectedMacosArm64Dir: String? = findProperty("skainetKernelsMacosArm64Dir") as String? +val iosArm64ArchiveDir: String? = injectedIosArm64Dir ?: cmakeBuildIosArm64Path.takeIf { isMacosHostForEmbed } +val iosSimArm64ArchiveDir: String? = injectedIosSimArm64Dir ?: cmakeBuildIosSimArm64Path.takeIf { isMacosHostForEmbed } +val macosArm64ArchiveDir: String? = injectedMacosArm64Dir ?: cmakeBuildMacosArm64Path.takeIf { isMacosHostForEmbed } + kotlin { explicitApi() jvm() @@ -63,6 +85,11 @@ kotlin { } linuxX64 { wireSkainetKernels(x64ArchiveDir, staticArchivePath) } linuxArm64 { wireSkainetKernels(arm64ArchiveDir, staticArchiveArm64Path) } + // Apple targets (#959): the same cinterop/embedding seam, fed by the Apple + // CMake lanes below (macOS host) or CI-injected archives. + iosArm64 { wireSkainetKernels(iosArm64ArchiveDir, "$cmakeBuildIosArm64Path/libskainet_kernels.a") } + iosSimulatorArm64 { wireSkainetKernels(iosSimArm64ArchiveDir, "$cmakeBuildIosSimArm64Path/libskainet_kernels.a") } + macosArm64 { wireSkainetKernels(macosArm64ArchiveDir, "$cmakeBuildMacosArm64Path/libskainet_kernels.a") } sourceSets { val jvmMain by getting { @@ -92,6 +119,9 @@ kotlin { } val linuxX64Main by getting { dependsOn(nativeMain) } val linuxArm64Main by getting { dependsOn(nativeMain) } + val iosArm64Main by getting { dependsOn(nativeMain) } + val iosSimulatorArm64Main by getting { dependsOn(nativeMain) } + val macosArm64Main by getting { dependsOn(nativeMain) } // Shared K/N parity tests (cinterop kernel vs commonMain scalar reference), // run on BOTH linuxX64 (host, scalar/auto-vec archive) and linuxArm64 // (cross-built NEON archive, executed under the K/N-bundled qemu-aarch64 @@ -108,6 +138,11 @@ kotlin { } val linuxX64Test by getting { dependsOn(nativeTest) } val linuxArm64Test by getting { dependsOn(nativeTest) } + // Same parity suites on the Apple targets: iosSimulatorArm64Test / + // macosArm64Test execute them on a macOS host (simulator / natively). + val iosArm64Test by getting { dependsOn(nativeTest) } + val iosSimulatorArm64Test by getting { dependsOn(nativeTest) } + val macosArm64Test by getting { dependsOn(nativeTest) } } } @@ -363,6 +398,117 @@ if (crossArm64Enabled) { } } +// --- Apple archives (#959, iOS kernel track of #920) ------------------------ +// +// Three Mach-O static archives for the Apple K/N targets, built with the +// platform SDKs on a macOS host (CMake >= 3.14 has first-class iOS support — +// no toolchain file needed; contrast toolchain-aarch64.cmake, which exists +// for the Linux cross triple). No -march is passed: Apple builds stay at the +// SDK-default baseline and rely on the #958 runtime FEAT_DotProd dispatch. +// Deployment targets match the K/N binary minimums (iOS 12.0 / simulator +// 14.0 / macOS 11.0) so the K/N link never warns about newer object files. +fun registerAppleKernelTasks( + suffix: String, + buildDir: String, + extraCmakeArgs: List, +): TaskProvider { + // Local copy: the onlyIf lambda must capture a Boolean, not the script. + val macHost = isMacosHostForEmbed + val configure = tasks.register("configureNativeKernels$suffix") { + group = "build" + description = "CMake configure for the $suffix (Apple) native kernels." + onlyIf { macHost } + inputs.file("$nativeSourcePath/CMakeLists.txt") + inputs.dir("$nativeSourcePath/src") + inputs.dir("$nativeSourcePath/include") + outputs.dir(buildDir) + commandLine = listOf( + "cmake", "-S", nativeSourcePath, "-B", buildDir, + "-DCMAKE_BUILD_TYPE=Release", + ) + extraCmakeArgs + } + return tasks.register("buildNativeKernels$suffix") { + group = "build" + description = "Build the $suffix (Apple) native kernels static archive." + onlyIf { macHost } + dependsOn(configure) + inputs.file("$nativeSourcePath/CMakeLists.txt") + inputs.dir("$nativeSourcePath/src") + inputs.dir("$nativeSourcePath/include") + outputs.dir(buildDir) + commandLine = listOf("cmake", "--build", buildDir, "--config", "Release") + } +} + +val buildNativeKernelsIosArm64 = registerAppleKernelTasks( + "IosArm64", cmakeBuildIosArm64Path, + listOf( + "-DCMAKE_SYSTEM_NAME=iOS", "-DCMAKE_OSX_SYSROOT=iphoneos", + "-DCMAKE_OSX_ARCHITECTURES=arm64", "-DCMAKE_OSX_DEPLOYMENT_TARGET=12.0", + "-DCMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY", + ), +) +val buildNativeKernelsIosSimulatorArm64 = registerAppleKernelTasks( + "IosSimulatorArm64", cmakeBuildIosSimArm64Path, + listOf( + "-DCMAKE_SYSTEM_NAME=iOS", "-DCMAKE_OSX_SYSROOT=iphonesimulator", + "-DCMAKE_OSX_ARCHITECTURES=arm64", "-DCMAKE_OSX_DEPLOYMENT_TARGET=14.0", + "-DCMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY", + ), +) +val buildNativeKernelsMacosArm64 = registerAppleKernelTasks( + "MacosArm64", cmakeBuildMacosArm64Path, + listOf( + "-DCMAKE_OSX_ARCHITECTURES=arm64", "-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0", + // The macOS lane exists for the K/N klib; the FFM dylib comes from the + // regular host build (packageNativeKernels) on the macos CI leg. + "-DSKAINET_STATIC_ONLY=ON", + ), +) + +// Embedding (#941 semantics): the archive must exist at CINTEROP time (it is +// copied into the klib) and at this project's own test-binary link time; +// bindings-only builds warn loudly instead of publishing broken klibs silently. +fun wireAppleCinterop( + suffix: String, + archiveDir: String?, + injected: String?, + buildTask: TaskProvider, + propName: String, +) { + if (archiveDir != null && injected == null) { + tasks.matching { it.name == "cinteropSkainetKernels$suffix" }.configureEach { dependsOn(buildTask) } + tasks.matching { it.name.startsWith("link") && it.name.endsWith(suffix) }.configureEach { dependsOn(buildTask) } + } + if (archiveDir == null) { + tasks.matching { it.name == "cinteropSkainetKernels$suffix" }.configureEach { + doFirst { + logger.warn( + "skainet-backend-native-cpu: ${suffix.replaceFirstChar { it.lowercase() }} klib is built " + + "WITHOUT the embedded libskainet_kernels.a (no Apple archive available on this " + + "host). Downstream consumers of this klib cannot link. Build on macOS or pass " + + "-P$propName= (#959)." + ) + } + } + } +} +wireAppleCinterop("IosArm64", iosArm64ArchiveDir, injectedIosArm64Dir, buildNativeKernelsIosArm64, "skainetKernelsIosArm64Dir") +wireAppleCinterop("IosSimulatorArm64", iosSimArm64ArchiveDir, injectedIosSimArm64Dir, buildNativeKernelsIosSimulatorArm64, "skainetKernelsIosSimulatorArm64Dir") +wireAppleCinterop("MacosArm64", macosArm64ArchiveDir, injectedMacosArm64Dir, buildNativeKernelsMacosArm64, "skainetKernelsMacosArm64Dir") + +// The Apple task family needs macOS (Xcode SDKs), and klib cross-compilation +// of cinterop-bearing modules is not supported off-macOS anyway. Disable the +// whole family elsewhere so ubuntu CI stays deterministic regardless of KGP +// defaults; publishing runs on macOS (publish.yml). Mirrors the non-Linux +// gate above for the Linux K/N tests. +if (!isMacosHostForEmbed) { + tasks.matching { t -> + listOf("IosArm64", "IosSimulatorArm64", "MacosArm64").any { t.name.endsWith(it) } || + t.name in setOf("iosArm64Test", "iosSimulatorArm64Test", "macosArm64Test") + }.configureEach { enabled = false } +} + // Forward `-Dskainet.runBench=true` from Gradle CLI to the forked test // JVM so Q4KMatmulMicrobenchTest activates. Skipped silently otherwise. val runBenchProperty = providers.systemProperty("skainet.runBench")