From b23bd5a02ce944a8d9098d23861cd9b7da9a3ea6 Mon Sep 17 00:00:00 2001 From: Michal Harakal Date: Wed, 29 Apr 2026 20:26:18 +0200 Subject: [PATCH] feat(native-cpu): scaffold FFM kernel provider module (PR 1 of 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 1 of the staged native (FFM) kernel provider rollout described in docs/.../explanation/perf/native-ffm-plan.adoc. Lands the module and the Gradle ↔ CMake ↔ JAR-resources ↔ FFM downcall pipeline end-to-end, with a trivial C smoke kernel proving the loader works on real hardware. No production matmul ships yet — that's PR 2. New module: skainet-backends/skainet-backend-native-cpu - native/CMakeLists.txt + native/src/skainet_smoke.c + native/include — a single-source shared lib (libskainet_kernels.{so,dylib,dll}) that exposes one extern "C" symbol skainet_smoke_double, computing output[i] = 2.0f * input[i]. Visibility is hidden by default with an explicit SKAINET_API export macro so the surface stays minimal. - build.gradle.kts wires three Exec/Copy tasks: configureNativeKernels → cmake -S native -B build/native/cmake-build buildNativeKernels → cmake --build (Release) packageNativeKernels → Copy lib into build/native/resources/native/-/ Hooked into jvmProcessResources, so the JAR ships with the host-arch lib at native/-/libskainet_kernels.. Configuration cache is preserved (paths captured as Strings up front; no script- capturing doFirst{} blocks). The xnnpack template referenced by the asciidoc PRD does not exist in-tree; this rolls a minimal Exec-based equivalent. - NativeKernelProvider (priority=100) deliberately reports isAvailable() = false. Both matmulFp32() and matmulQ4K() return null. That keeps KernelRegistry.bestAvailable() cleanly cascading to the Panama priority-50 provider on every shape until PR 2 ships a real Q4_K kernel. NativeKernelProviderFactory delegates via `KernelProvider by NativeKernelProvider` for ServiceLoader, registered in META-INF/services/sk.ainet.backend.api.kernel.KernelProvider. - NativeLibraryLoader extracts the bundled lib from JAR resources to a process-scoped temp dir, calls System.load, and exposes a process-lifetime SymbolLookup backed by Arena.ofShared. All failure modes (missing resource, unsupported platform, load failure) return cleanly — no exceptions escape; the cascade falls through. - NativeFfmSmoke is the internal end-to-end FFM downcall test surface: Linker.nativeLinker().downcallHandle on FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, JAVA_INT), with arena- allocated input/output segments and MemorySegment.copy bulk transfer. Same shape as the existing JvmBlas.kt downcall, sized for the smoke kernel. Toolchain: stays on JDK 21 with --enable-preview (FFM is preview in 21, finalized in 22). Test/JavaExec tasks add --enable-preview and --enable-native-access=ALL-UNNAMED. settings.gradle.kts: include(":skainet-backends:skainet-backend-native-cpu"). Verification (linux-x86_64, JDK 21.0.10, cmake 3.28.3): - :skainet-backends:skainet-backend-native-cpu:jvmJar — 15 KB lib lands at native/linux-x86_64/libskainet_kernels.so inside the JAR - :skainet-backends:skainet-backend-native-cpu:jvmTest — 3/3 pass (FFM downcall doubles inputs end-to-end, provider stays unavailable, factory delegates correctly) - :skainet-backends:skainet-backend-cpu:jvmTest — 218/218 pass, 0 failures, 0 skipped: priority-100 stub does not affect cascade Out of scope (per asciidoc staging): - Real Q4_K NEON / AVX2 kernels + parity vs PanamaVectorQ4KMatmulKernel (PR 2) - Q4KMemSegMatmulKernel SPI sibling (PR 3) - Cross-arch CI matrix (PR 4) - FP32 / Q6_K / Q8_0 native kernels (PR 5) - Maven Central native classifier publishing (separate plan) - vanniktech.mavenPublish, binary-compatibility-validator, sk.ainet.dokka plugins on the new module — fold in when publishing matters Co-Authored-By: Claude Opus 4.7 (1M context) --- settings.gradle.kts | 1 + .../build.gradle.kts | 115 ++++++++++++++++++ .../native/CMakeLists.txt | 30 +++++ .../native/include/skainet_kernels.h | 32 +++++ .../native/src/skainet_smoke.c | 7 ++ .../sk/ainet/exec/kernel/NativeFfmSmoke.kt | 58 +++++++++ .../ainet/exec/kernel/NativeKernelProvider.kt | 33 +++++ .../kernel/NativeKernelProviderFactory.kt | 16 +++ .../ainet/exec/kernel/NativeLibraryLoader.kt | 107 ++++++++++++++++ ...sk.ainet.backend.api.kernel.KernelProvider | 1 + .../exec/kernel/NativeFfmPipelineTest.kt | 54 ++++++++ 11 files changed, 454 insertions(+) create mode 100644 skainet-backends/skainet-backend-native-cpu/build.gradle.kts create mode 100644 skainet-backends/skainet-backend-native-cpu/native/CMakeLists.txt create mode 100644 skainet-backends/skainet-backend-native-cpu/native/include/skainet_kernels.h create mode 100644 skainet-backends/skainet-backend-native-cpu/native/src/skainet_smoke.c create mode 100644 skainet-backends/skainet-backend-native-cpu/src/jvmMain/kotlin/sk/ainet/exec/kernel/NativeFfmSmoke.kt create mode 100644 skainet-backends/skainet-backend-native-cpu/src/jvmMain/kotlin/sk/ainet/exec/kernel/NativeKernelProvider.kt create mode 100644 skainet-backends/skainet-backend-native-cpu/src/jvmMain/kotlin/sk/ainet/exec/kernel/NativeKernelProviderFactory.kt create mode 100644 skainet-backends/skainet-backend-native-cpu/src/jvmMain/kotlin/sk/ainet/exec/kernel/NativeLibraryLoader.kt create mode 100644 skainet-backends/skainet-backend-native-cpu/src/jvmMain/resources/META-INF/services/sk.ainet.backend.api.kernel.KernelProvider create mode 100644 skainet-backends/skainet-backend-native-cpu/src/jvmTest/kotlin/sk/ainet/exec/kernel/NativeFfmPipelineTest.kt diff --git a/settings.gradle.kts b/settings.gradle.kts index c25aed61e..2d63875ef 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,6 +39,7 @@ include("skainet-compile:skainet-compile-c") // ====== BACKENDS include("skainet-backends:skainet-backend-api") include("skainet-backends:skainet-backend-cpu") +include("skainet-backends:skainet-backend-native-cpu") // ====== BENCHMARKS include("skainet-backends:benchmarks:jvm-cpu-jmh") diff --git a/skainet-backends/skainet-backend-native-cpu/build.gradle.kts b/skainet-backends/skainet-backend-native-cpu/build.gradle.kts new file mode 100644 index 000000000..91c5d367d --- /dev/null +++ b/skainet-backends/skainet-backend-native-cpu/build.gradle.kts @@ -0,0 +1,115 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) +} + +kotlin { + explicitApi() + jvm() + + sourceSets { + val jvmMain by getting { + dependencies { + implementation(project(":skainet-backends:skainet-backend-api")) + } + } + val jvmTest by getting { + dependencies { + implementation(libs.kotlin.test) + } + } + } +} + +// --- Native (CMake) wiring ------------------------------------------------- +// +// PR 1 builds for the host arch only. Cross-arch CI matrix is deferred per +// the native-ffm-plan asciidoc. The artifact lands at +// build/native/resources/native/-/libskainet_kernels.{so|dylib|dll} +// and is bundled into the JAR via an extra resources srcDir on jvmMain. + +val nativeOsArch: String = run { + val os = System.getProperty("os.name").lowercase() + val osTag = when { + os.contains("linux") -> "linux" + os.contains("mac") || os.contains("darwin") -> "macos" + os.contains("windows") -> "windows" + else -> error("Unsupported OS for skainet-backend-native-cpu: $os") + } + val archRaw = System.getProperty("os.arch").lowercase() + val archTag = when (archRaw) { + "x86_64", "amd64" -> "x86_64" + "aarch64", "arm64" -> "arm64" + else -> error("Unsupported arch for skainet-backend-native-cpu: $archRaw") + } + "$osTag-$archTag" +} + +// Capture as plain Strings up front so configuration-cache serialization +// doesn't have to walk back to the build script for Provider resolution. +val nativeSourcePath: String = layout.projectDirectory.dir("native").asFile.absolutePath +val cmakeBuildPath: String = layout.buildDirectory.dir("native/cmake-build").get().asFile.absolutePath +val nativeResourcesRoot = layout.buildDirectory.dir("native/resources") +val nativeResourceTargetDir = nativeResourcesRoot.map { it.dir("native/$nativeOsArch") } + +val configureNativeKernels by tasks.registering(Exec::class) { + group = "build" + description = "Run CMake configure step for the native kernels library." + inputs.file("$nativeSourcePath/CMakeLists.txt") + inputs.dir("$nativeSourcePath/src") + inputs.dir("$nativeSourcePath/include") + outputs.dir(cmakeBuildPath) + // CMake auto-creates the -B directory; no doFirst mkdirs needed. + commandLine = listOf( + "cmake", + "-S", nativeSourcePath, + "-B", cmakeBuildPath, + "-DCMAKE_BUILD_TYPE=Release", + ) +} + +val buildNativeKernels by tasks.registering(Exec::class) { + group = "build" + description = "Build the native kernels shared library via CMake." + dependsOn(configureNativeKernels) + inputs.file("$nativeSourcePath/CMakeLists.txt") + inputs.dir("$nativeSourcePath/src") + inputs.dir("$nativeSourcePath/include") + outputs.dir(cmakeBuildPath) + commandLine = listOf( + "cmake", + "--build", cmakeBuildPath, + "--config", "Release", + ) +} + +val packageNativeKernels by tasks.registering(Copy::class) { + group = "build" + description = "Stage the built native kernels library into JVM resources." + dependsOn(buildNativeKernels) + from(cmakeBuildPath) { + include( + "libskainet_kernels.so", + "libskainet_kernels.dylib", + "skainet_kernels.dll", + "Release/skainet_kernels.dll", + ) + eachFile { path = name } + } + into(nativeResourceTargetDir) +} + +kotlin.sourceSets.named("jvmMain") { + resources.srcDir(nativeResourcesRoot) +} + +tasks.named("jvmProcessResources") { + dependsOn(packageNativeKernels) +} + +tasks.withType().configureEach { + jvmArgs("--enable-preview", "--enable-native-access=ALL-UNNAMED") +} + +tasks.withType().configureEach { + jvmArgs("--enable-preview", "--enable-native-access=ALL-UNNAMED") +} diff --git a/skainet-backends/skainet-backend-native-cpu/native/CMakeLists.txt b/skainet-backends/skainet-backend-native-cpu/native/CMakeLists.txt new file mode 100644 index 000000000..dcde76d74 --- /dev/null +++ b/skainet-backends/skainet-backend-native-cpu/native/CMakeLists.txt @@ -0,0 +1,30 @@ +cmake_minimum_required(VERSION 3.20) +project(skainet_kernels C) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() + +add_library(skainet_kernels SHARED + src/skainet_smoke.c +) + +target_include_directories(skainet_kernels PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +# Strip the "lib" prefix on Windows so the artifact name is consistent +# with the resource-bundle path skainet_kernels.{dll,so,dylib}. +if(WIN32) + set_target_properties(skainet_kernels PROPERTIES PREFIX "") +endif() + +# Hide non-exported symbols on ELF / Mach-O for a smaller surface area. +if(CMAKE_C_COMPILER_ID MATCHES "Clang|GNU") + target_compile_options(skainet_kernels PRIVATE -fvisibility=hidden -Wall -Wextra) + set_target_properties(skainet_kernels PROPERTIES C_VISIBILITY_PRESET hidden) +endif() diff --git a/skainet-backends/skainet-backend-native-cpu/native/include/skainet_kernels.h b/skainet-backends/skainet-backend-native-cpu/native/include/skainet_kernels.h new file mode 100644 index 000000000..5db0e6cae --- /dev/null +++ b/skainet-backends/skainet-backend-native-cpu/native/include/skainet_kernels.h @@ -0,0 +1,32 @@ +#ifndef SKAINET_KERNELS_H +#define SKAINET_KERNELS_H + +#include + +#if defined(_WIN32) || defined(__CYGWIN__) +# define SKAINET_API __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +# define SKAINET_API __attribute__((visibility("default"))) +#else +# define SKAINET_API +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Trivial smoke kernel proving the FFM downcall pipeline end-to-end. + * + * for (int i = 0; i < length; ++i) output[i] = 2.0f * input[i]; + * + * The Kotlin caller owns the memory backing `input` and `output`; the + * kernel must not retain pointers past return. + */ +SKAINET_API void skainet_smoke_double(const float* input, float* output, int32_t length); + +#ifdef __cplusplus +} +#endif + +#endif /* SKAINET_KERNELS_H */ diff --git a/skainet-backends/skainet-backend-native-cpu/native/src/skainet_smoke.c b/skainet-backends/skainet-backend-native-cpu/native/src/skainet_smoke.c new file mode 100644 index 000000000..01db030ae --- /dev/null +++ b/skainet-backends/skainet-backend-native-cpu/native/src/skainet_smoke.c @@ -0,0 +1,7 @@ +#include "skainet_kernels.h" + +void skainet_smoke_double(const float* input, float* output, int32_t length) { + for (int32_t i = 0; i < length; ++i) { + output[i] = 2.0f * input[i]; + } +} diff --git a/skainet-backends/skainet-backend-native-cpu/src/jvmMain/kotlin/sk/ainet/exec/kernel/NativeFfmSmoke.kt b/skainet-backends/skainet-backend-native-cpu/src/jvmMain/kotlin/sk/ainet/exec/kernel/NativeFfmSmoke.kt new file mode 100644 index 000000000..350df88d6 --- /dev/null +++ b/skainet-backends/skainet-backend-native-cpu/src/jvmMain/kotlin/sk/ainet/exec/kernel/NativeFfmSmoke.kt @@ -0,0 +1,58 @@ +package sk.ainet.exec.kernel + +import java.lang.foreign.Arena +import java.lang.foreign.FunctionDescriptor +import java.lang.foreign.Linker +import java.lang.foreign.MemorySegment +import java.lang.foreign.ValueLayout +import java.lang.invoke.MethodHandle + +/** + * End-to-end smoke test of the FFM downcall pipeline used by the + * native kernel provider. Calls the bundled native function + * + * void skainet_smoke_double(const float* input, float* output, int32_t length); + * + * which writes `output[i] = 2.0f * input[i]`. This object exists only + * to validate the loader → Linker → MethodHandle path on real hardware + * before any production kernel ships in PR 2. + * + * Not part of the public SPI: `internal` visibility, exposed to tests + * via the same package. + */ +internal object NativeFfmSmoke { + + fun isAvailable(): Boolean = handle != null + + /** + * Run the bundled smoke kernel on [input] and return a fresh + * `FloatArray` with `output[i] = 2.0f * input[i]`. Returns `null` + * when the native lib failed to load (callers fall back to a + * pure-Kotlin reference). + */ + fun double(input: FloatArray): FloatArray? { + val mh = handle ?: return null + val output = FloatArray(input.size) + val byteSize = input.size.toLong() * java.lang.Float.BYTES + val byteAlign = ValueLayout.JAVA_FLOAT.byteAlignment() + Arena.ofConfined().use { arena -> + val inSeg = arena.allocate(byteSize, byteAlign) + val outSeg = arena.allocate(byteSize, byteAlign) + MemorySegment.copy(input, 0, inSeg, ValueLayout.JAVA_FLOAT, 0L, input.size) + mh.invoke(inSeg, outSeg, input.size) + MemorySegment.copy(outSeg, ValueLayout.JAVA_FLOAT, 0L, output, 0, output.size) + } + return output + } + + private val handle: MethodHandle? by lazy { + val lookup = NativeLibraryLoader.lookup() ?: return@lazy null + val symbol = lookup.find("skainet_smoke_double").orElse(null) ?: return@lazy null + val descriptor = FunctionDescriptor.ofVoid( + ValueLayout.ADDRESS, + ValueLayout.ADDRESS, + ValueLayout.JAVA_INT, + ) + runCatching { Linker.nativeLinker().downcallHandle(symbol, descriptor) }.getOrNull() + } +} diff --git a/skainet-backends/skainet-backend-native-cpu/src/jvmMain/kotlin/sk/ainet/exec/kernel/NativeKernelProvider.kt b/skainet-backends/skainet-backend-native-cpu/src/jvmMain/kotlin/sk/ainet/exec/kernel/NativeKernelProvider.kt new file mode 100644 index 000000000..827a95d74 --- /dev/null +++ b/skainet-backends/skainet-backend-native-cpu/src/jvmMain/kotlin/sk/ainet/exec/kernel/NativeKernelProvider.kt @@ -0,0 +1,33 @@ +package sk.ainet.exec.kernel + +import sk.ainet.backend.api.kernel.Fp32MatmulKernel +import sk.ainet.backend.api.kernel.KernelProvider +import sk.ainet.backend.api.kernel.Q4KMatmulKernel + +/** + * Native (FFM) [KernelProvider]. Sits at priority `100`, above + * [PanamaVectorKernelProvider] (`50`) and the scalar reference (`0`). + * + * PR 1 of the staged native-FFM rollout (see the `native-ffm-plan` + * asciidoc) only ships the module scaffolding: the Gradle ↔ CMake + * pipeline that produces a host-arch shared library, its bundling into + * JAR resources, and an end-to-end FFM smoke downcall test. No real + * matmul kernel is wired into the public SPI yet. + * + * Until [NativeQ4KMatmulKernel] (or its `MemSegment`-input sibling) + * lands in PR 2, this provider deliberately reports `isAvailable() = + * false` and returns `null` from every kernel accessor. That keeps + * `KernelRegistry.bestAvailable()` cleanly cascading down to the + * Panama priority-50 provider on every shape we measure today, so + * adding the new module to the classpath produces no behavior change. + */ +public object NativeKernelProvider : KernelProvider { + override val name: String = "native-ffm" + override val priority: Int = 100 + + override fun isAvailable(): Boolean = false + + override fun matmulFp32(): Fp32MatmulKernel? = null + + override fun matmulQ4K(): Q4KMatmulKernel? = null +} diff --git a/skainet-backends/skainet-backend-native-cpu/src/jvmMain/kotlin/sk/ainet/exec/kernel/NativeKernelProviderFactory.kt b/skainet-backends/skainet-backend-native-cpu/src/jvmMain/kotlin/sk/ainet/exec/kernel/NativeKernelProviderFactory.kt new file mode 100644 index 000000000..b9a5480ab --- /dev/null +++ b/skainet-backends/skainet-backend-native-cpu/src/jvmMain/kotlin/sk/ainet/exec/kernel/NativeKernelProviderFactory.kt @@ -0,0 +1,16 @@ +package sk.ainet.exec.kernel + +import sk.ainet.backend.api.kernel.KernelProvider + +/** + * `ServiceLoader`-friendly wrapper around [NativeKernelProvider]. The + * platform `ServiceLoader` machinery requires a public no-arg + * constructor, which a Kotlin `object` does not expose; this factory + * delegates every [KernelProvider] member back to the singleton. + * + * Listed in + * `META-INF/services/sk.ainet.backend.api.kernel.KernelProvider` so + * `KernelServiceLoader.installAll()` discovers the provider on JVM + * startup. + */ +public class NativeKernelProviderFactory : KernelProvider by NativeKernelProvider diff --git a/skainet-backends/skainet-backend-native-cpu/src/jvmMain/kotlin/sk/ainet/exec/kernel/NativeLibraryLoader.kt b/skainet-backends/skainet-backend-native-cpu/src/jvmMain/kotlin/sk/ainet/exec/kernel/NativeLibraryLoader.kt new file mode 100644 index 000000000..88531fd49 --- /dev/null +++ b/skainet-backends/skainet-backend-native-cpu/src/jvmMain/kotlin/sk/ainet/exec/kernel/NativeLibraryLoader.kt @@ -0,0 +1,107 @@ +package sk.ainet.exec.kernel + +import java.lang.foreign.Arena +import java.lang.foreign.SymbolLookup +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption + +/** + * Locates the bundled `libskainet_kernels` shared library shipped in + * this module's JAR resources, extracts it to a process-scoped temp + * directory, calls `System.load` on the resulting absolute path, and + * exposes a process-lifetime [SymbolLookup] for FFM downcalls. + * + * Resource layout: `native/-/` where `` is + * - `libskainet_kernels.so` on Linux + * - `libskainet_kernels.dylib` on macOS + * - `skainet_kernels.dll` on Windows + * + * `-` is the same tag the Gradle build uses when staging the + * artifact (`linux-x86_64`, `linux-arm64`, `macos-arm64`, ...). PR 1 + * only ships one variant per build host; cross-arch shipping comes in + * a later PR. + * + * Failure modes are non-fatal: missing resource, unsupported platform, + * or `System.load` failure → [tryInit] returns `false` and `lookup` + * stays `null`. Callers (notably [NativeKernelProvider.isAvailable]) + * cascade to a lower-priority provider. + */ +internal object NativeLibraryLoader { + + @Volatile + private var initialized: Boolean = false + + @Volatile + private var loadedLookup: SymbolLookup? = null + + /** + * The shared [Arena] that owns the [SymbolLookup]. Lifetime is the + * JVM process; deliberately not closed because the lib stays + * loaded until JVM exit. + */ + private val arena: Arena = Arena.ofShared() + + /** `true` when the lib has been resolved and `System.load`-ed. */ + fun isLoaded(): Boolean = tryInit() + + /** Symbol lookup for the loaded lib, or `null` if loading failed. */ + fun lookup(): SymbolLookup? { + tryInit() + return loadedLookup + } + + @Synchronized + private fun tryInit(): Boolean { + if (initialized) return loadedLookup != null + initialized = true + + val resourcePath = resolveResourcePath() ?: return false + val loader = NativeLibraryLoader::class.java.classLoader + ?: ClassLoader.getSystemClassLoader() + + val stream = loader.getResourceAsStream(resourcePath) ?: return false + val tmpDir: Path = Files.createTempDirectory("skainet-native-") + tmpDir.toFile().deleteOnExit() + val libFile = tmpDir.resolve(resourcePath.substringAfterLast('/')) + stream.use { Files.copy(it, libFile, StandardCopyOption.REPLACE_EXISTING) } + libFile.toFile().deleteOnExit() + + return runCatching { + System.load(libFile.toAbsolutePath().toString()) + loadedLookup = SymbolLookup.libraryLookup(libFile, arena) + true + }.getOrElse { false } + } + + private fun resolveResourcePath(): String? { + val osTag = osTag() ?: return null + val archTag = archTag() ?: return null + val libName = when (osTag) { + "linux" -> "libskainet_kernels.so" + "macos" -> "libskainet_kernels.dylib" + "windows" -> "skainet_kernels.dll" + else -> return null + } + return "native/$osTag-$archTag/$libName" + } + + private fun osTag(): String? { + val os = System.getProperty("os.name")?.lowercase() ?: return null + return when { + os.contains("linux") -> "linux" + os.contains("mac") || os.contains("darwin") -> "macos" + os.contains("windows") -> "windows" + else -> null + } + } + + private fun archTag(): String? { + val arch = System.getProperty("os.arch")?.lowercase() ?: return null + return when (arch) { + "x86_64", "amd64" -> "x86_64" + "aarch64", "arm64" -> "arm64" + else -> null + } + } +} diff --git a/skainet-backends/skainet-backend-native-cpu/src/jvmMain/resources/META-INF/services/sk.ainet.backend.api.kernel.KernelProvider b/skainet-backends/skainet-backend-native-cpu/src/jvmMain/resources/META-INF/services/sk.ainet.backend.api.kernel.KernelProvider new file mode 100644 index 000000000..8767b2f9d --- /dev/null +++ b/skainet-backends/skainet-backend-native-cpu/src/jvmMain/resources/META-INF/services/sk.ainet.backend.api.kernel.KernelProvider @@ -0,0 +1 @@ +sk.ainet.exec.kernel.NativeKernelProviderFactory diff --git a/skainet-backends/skainet-backend-native-cpu/src/jvmTest/kotlin/sk/ainet/exec/kernel/NativeFfmPipelineTest.kt b/skainet-backends/skainet-backend-native-cpu/src/jvmTest/kotlin/sk/ainet/exec/kernel/NativeFfmPipelineTest.kt new file mode 100644 index 000000000..2d210bfb6 --- /dev/null +++ b/skainet-backends/skainet-backend-native-cpu/src/jvmTest/kotlin/sk/ainet/exec/kernel/NativeFfmPipelineTest.kt @@ -0,0 +1,54 @@ +package sk.ainet.exec.kernel + +import sk.ainet.backend.api.kernel.KernelRegistry +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class NativeFfmPipelineTest { + + @AfterTest + fun resetRegistry() { + KernelRegistry.clearForTesting() + } + + @Test + fun `smoke kernel doubles its inputs end-to-end via FFM`() { + assertTrue( + NativeLibraryLoader.isLoaded(), + "Bundled libskainet_kernels resource missing for the host platform — " + + "did the CMake build run before jvmProcessResources?", + ) + assertTrue(NativeFfmSmoke.isAvailable(), "skainet_smoke_double symbol not resolved") + + val input = floatArrayOf(0f, 1f, -2.5f, 3.14159f, 1e6f) + val output = NativeFfmSmoke.double(input) + assertNotNull(output) + for (i in input.indices) { + assertEquals(2.0f * input[i], output[i], 0f, "index $i") + } + } + + @Test + fun `provider stays unavailable in PR 1 so registry falls through`() { + assertEquals("native-ffm", NativeKernelProvider.name) + assertEquals(100, NativeKernelProvider.priority) + assertFalse( + NativeKernelProvider.isAvailable(), + "PR 1 deliberately keeps isAvailable() = false until a real kernel ships in PR 2", + ) + assertEquals(null, NativeKernelProvider.matmulFp32()) + assertEquals(null, NativeKernelProvider.matmulQ4K()) + } + + @Test + fun `factory delegates to the singleton`() { + val factory = NativeKernelProviderFactory() + assertEquals(NativeKernelProvider.name, factory.name) + assertEquals(NativeKernelProvider.priority, factory.priority) + assertEquals(NativeKernelProvider.isAvailable(), factory.isAvailable()) + } +}