From 453ff40cc5f2b8413acbf87980a909f72b9676c6 Mon Sep 17 00:00:00 2001 From: michal harakal Date: Thu, 2 Jul 2026 22:07:30 +0200 Subject: [PATCH] native-cpu: verify NEON kernels on aarch64, add linuxArm64 test path Add a shared `nativeTest` source set so the NativeKn matmul parity tests run on both linuxX64 and linuxArm64 (same suite, two codegens), and a custom linuxArm64Test Exec task that runs the cross-built test binary under the Kotlin/Native-bundled qemu-aarch64 (overridable via -PskainetQemu / -PskainetAarch64Sysroot / -PskainetAarch64Cc). Extend coverage from q5k/q6k to all NEON kernels: fp32, q4k (dotprod), q5k, q6k, q8_0, plus the provider registry test. All 23 pass on linuxArm64 under QEMU and on the physical SL2610 (Cortex-A55: asimddp + fphp present, i8mm absent, matching -march=armv8.2-a+fp16+dotprod). objdump confirms genuine SIMD (udot/sdot/fmla), not the scalar fallback. Clear the BOARD-VERIFY-PENDING banner in skainet_simd.h -> AARCH64-VERIFIED, and fix a pre-existing config-cache violation in the crossArm64 onlyIf closures. Co-Authored-By: Claude Opus 4.8 --- .../build.gradle.kts | 55 ++++++++++-- .../native/include/skainet_simd.h | 17 +++- .../kernel/NativeKnFp32MatmulParityTest.kt | 82 +++++++++++++++++ .../exec/kernel/NativeKnKernelProviderTest.kt | 0 .../NativeKnQ4KMatmulKernelParityTest.kt | 90 +++++++++++++++++++ .../NativeKnQ5KMatmulKernelParityTest.kt | 6 +- .../NativeKnQ6KMatmulKernelParityTest.kt | 6 +- .../NativeKnQ8_0MatmulKernelParityTest.kt | 70 +++++++++++++++ 8 files changed, 309 insertions(+), 17 deletions(-) create mode 100644 skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnFp32MatmulParityTest.kt rename skainet-backends/skainet-backend-native-cpu/src/{linuxX64Test => nativeTest}/kotlin/sk/ainet/exec/kernel/NativeKnKernelProviderTest.kt (100%) create mode 100644 skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnQ4KMatmulKernelParityTest.kt rename skainet-backends/skainet-backend-native-cpu/src/{linuxX64Test => nativeTest}/kotlin/sk/ainet/exec/kernel/NativeKnQ5KMatmulKernelParityTest.kt (91%) rename skainet-backends/skainet-backend-native-cpu/src/{linuxX64Test => nativeTest}/kotlin/sk/ainet/exec/kernel/NativeKnQ6KMatmulKernelParityTest.kt (90%) create mode 100644 skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnQ8_0MatmulKernelParityTest.kt diff --git a/skainet-backends/skainet-backend-native-cpu/build.gradle.kts b/skainet-backends/skainet-backend-native-cpu/build.gradle.kts index 7c5e95e0..6bd68dd9 100644 --- a/skainet-backends/skainet-backend-native-cpu/build.gradle.kts +++ b/skainet-backends/skainet-backend-native-cpu/build.gradle.kts @@ -59,13 +59,22 @@ kotlin { } val linuxX64Main by getting { dependsOn(nativeMain) } val linuxArm64Main by getting { dependsOn(nativeMain) } - val linuxX64Test by getting { + // 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 + // or on the SL2610 board). Same tests, two codegens — this is how the + // NEON paths get bit-checked without board-only test code. + val nativeTest by creating { + dependsOn(commonTest.get()) dependencies { implementation(libs.kotlin.test) - // ScalarQ5_KMatmulKernel reference for the cinterop parity test. + // ScalarQ*_KMatmulKernel / ScalarQ8_0MatmulKernel / ScalarMatmulKernel + // references for the cinterop parity tests. implementation(project(":skainet-backends:skainet-backend-cpu")) } } + val linuxX64Test by getting { dependsOn(nativeTest) } + val linuxArm64Test by getting { dependsOn(nativeTest) } } } @@ -162,8 +171,9 @@ val packageNativeKernels by tasks.registering(Copy::class) { // host opts in. NativeLibraryLoader resolves native/linux-arm64/ from os.arch // at runtime, so the consuming side needs no change once this .so is bundled. // -// BOARD-VERIFY-PENDING: the NEON code is syntax-validated for aarch64 but has -// not been executed; run the parity tests under QEMU or on the SL2610. +// NEON parity verified under qemu-aarch64 (see skainet_simd.h banner): +// ./gradlew :skainet-backends:skainet-backend-native-cpu:linuxArm64Test -PcrossArm64=true +// runs the shared nativeTest parity suite against the cross-built archive. val crossArm64Enabled: Boolean = (findProperty("crossArm64") as String?)?.toBoolean() == true val aarch64Cc: String = (findProperty("skainetAarch64Cc") as String?) ?: "aarch64-linux-gnu-gcc" val cmakeBuildArm64Path: String = layout.buildDirectory.dir("native/cmake-build-arm64").get().asFile.absolutePath @@ -173,7 +183,10 @@ val toolchainFilePath = "$nativeSourcePath/toolchain-aarch64.cmake" val configureNativeKernelsArm64 by tasks.registering(Exec::class) { group = "build" description = "CMake configure for the aarch64 (NEON) native kernels (cross-compile)." - onlyIf { crossArm64Enabled } + // Local copy so the onlyIf lambda captures a Boolean, not the build script + // (script object references break configuration-cache serialization). + val enabled = crossArm64Enabled + onlyIf { enabled } inputs.file("$nativeSourcePath/CMakeLists.txt") inputs.dir("$nativeSourcePath/src") inputs.dir("$nativeSourcePath/include") @@ -191,7 +204,8 @@ val configureNativeKernelsArm64 by tasks.registering(Exec::class) { val buildNativeKernelsArm64 by tasks.registering(Exec::class) { group = "build" description = "Cross-build the aarch64 (NEON) native kernels shared library." - onlyIf { crossArm64Enabled } + val enabled = crossArm64Enabled + onlyIf { enabled } dependsOn(configureNativeKernelsArm64) inputs.file("$nativeSourcePath/CMakeLists.txt") inputs.dir("$nativeSourcePath/src") @@ -203,7 +217,8 @@ val buildNativeKernelsArm64 by tasks.registering(Exec::class) { val packageNativeKernelsArm64 by tasks.registering(Copy::class) { group = "build" description = "Stage the cross-built aarch64 native kernels into JVM resources." - onlyIf { crossArm64Enabled } + val enabled = crossArm64Enabled + onlyIf { enabled } dependsOn(buildNativeKernelsArm64) from(cmakeBuildArm64Path) { include("libskainet_kernels.so") @@ -229,6 +244,32 @@ if (crossArm64Enabled) { tasks.matching { it.name.startsWith("link") && it.name.endsWith("LinuxArm64") }.configureEach { dependsOn(buildNativeKernelsArm64) } + + // The Kotlin Gradle plugin does not create a run task for non-host K/N test + // binaries (only linkDebugTestLinuxArm64 / linuxArm64TestBinaries), so wire + // one explicitly: run test.kexe under qemu-aarch64 (user-mode emulation). + // Defaults point at the K/N-bundled dependencies (~/.konan/dependencies): + // the same qemu K/N itself uses for linux_x64 -> linux_arm64 test emulation + // (konan.properties emulatorExecutable.linux_x64-linux_arm64) and the glibc + // sysroot the binary was linked against. Override with -PskainetQemu / + // -PskainetAarch64Sysroot (e.g. /usr/bin/qemu-aarch64-static and + // /usr/aarch64-linux-gnu for the distro toolchain). On the SL2610 board + // itself, just push and run test.kexe directly — no qemu involved. + val konanDeps = "${System.getProperty("user.home")}/.konan/dependencies" + val qemuAarch64: String = (findProperty("skainetQemu") as String?) + ?: "$konanDeps/qemu-aarch64-static-5.1.0-linux-2/qemu-aarch64" + val aarch64Sysroot: String = (findProperty("skainetAarch64Sysroot") as String?) + ?: "$konanDeps/aarch64-unknown-linux-gnu-gcc-8.3.0-glibc-2.25-kernel-4.9-2/aarch64-unknown-linux-gnu/sysroot" + val testKexePath: String = + layout.buildDirectory.file("bin/linuxArm64/debugTest/test.kexe").get().asFile.absolutePath + + tasks.register("linuxArm64Test") { + group = "verification" + description = "Run the linuxArm64 K/N tests under qemu-aarch64 (NEON kernel parity vs scalar reference)." + dependsOn("linkDebugTestLinuxArm64") + inputs.file(testKexePath) + commandLine(qemuAarch64, "-L", aarch64Sysroot, testKexePath) + } } // Forward `-Dskainet.runBench=true` from Gradle CLI to the forked test diff --git a/skainet-backends/skainet-backend-native-cpu/native/include/skainet_simd.h b/skainet-backends/skainet-backend-native-cpu/native/include/skainet_simd.h index dd2ec25b..c1c5b72a 100644 --- a/skainet-backends/skainet-backend-native-cpu/native/include/skainet_simd.h +++ b/skainet-backends/skainet-backend-native-cpu/native/include/skainet_simd.h @@ -11,10 +11,19 @@ * with the right -march). `__ARM_FEATURE_DOTPROD` / `__ARM_FEATURE_MATMUL_INT8` * are gated on the build flags (`-march=armv8.2-a+dotprod`, etc.). * - * BOARD-VERIFY-PENDING: the NEON paths in this tree compile to the scalar - * fallback on the x86 build host and have NOT been executed on aarch64. - * They must be built with the cross toolchain and bit-exact-checked under - * QEMU or on the SL2610 before being relied on. + * AARCH64-VERIFIED (2026-07-02): the NEON paths (fp32 vfmaq_f32, q4k + * vdotq_s32 dotprod, q5k, q6k, q8_0) were cross-built with + * `-march=armv8.2-a+fp16+dotprod` (aarch64 gcc 8.3, the K/N-bundled + * toolchain) and parity-checked against the commonMain scalar references: + * - under qemu-aarch64 via + * ./gradlew :skainet-backends:skainet-backend-native-cpu:linuxArm64Test -PcrossArm64=true + * - AND on the physical SL2610 board (Cortex-A55, aarch64): the same + * test.kexe run natively on-device — 23/23 tests green, no SIGILL. + * `/proc/cpuinfo` confirmed asimddp + fphp/asimdhp present and i8mm + * absent, matching the chosen -march (no +i8mm). + * The linked archive was confirmed to contain udot/sdot + fmla, i.e. the + * SIMD paths — not the scalar fallback — executed. bf16 and q4_0 have no + * NEON path (scalar only). */ #if defined(__ARM_NEON) || defined(__ARM_NEON__) diff --git a/skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnFp32MatmulParityTest.kt b/skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnFp32MatmulParityTest.kt new file mode 100644 index 00000000..e7000676 --- /dev/null +++ b/skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnFp32MatmulParityTest.kt @@ -0,0 +1,82 @@ +package sk.ainet.exec.kernel + +import kotlin.math.abs +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import sk.ainet.kernels.cinterop.skainet_fp32_matmul + +/** + * Proves the Kotlin/Native cinterop path for the FP32 SGEMM: the C + * `skainet_fp32_matmul` (called directly via cinterop — there is no + * NativeKn wrapper object for FP32 yet, `NativeKnKernelProvider.matmulFp32()` + * is null) must agree with the commonMain [ScalarMatmulKernel] reference + * within FMA + `-ffast-math` reassociation tolerance. + * + * Runs on linuxX64 (host archive: scalar/auto-vectorized) AND linuxArm64 + * (cross-built archive: NEON `vfmaq_f32` over the n dimension), so the + * aarch64 run bit-checks the `SKAINET_HAVE_NEON` path in fp32_matmul.c. + * Shapes deliberately include n % 4 != 0 to hit both the 4-lane vector + * loop and the scalar tail. + */ +@OptIn(ExperimentalForeignApi::class) +class NativeKnFp32MatmulParityTest { + + private fun cinteropMatmul( + a: FloatArray, b: FloatArray, c: FloatArray, + m: Int, n: Int, k: Int, + ) { + a.usePinned { aPin -> + b.usePinned { bPin -> + c.usePinned { cPin -> + skainet_fp32_matmul( + aPin.addressOf(0), 0, k, + bPin.addressOf(0), 0, n, + cPin.addressOf(0), 0, n, + m, n, k, + ) + } + } + } + } + + private fun assertParity(m: Int, n: Int, k: Int, seed: Int, tol: Float) { + val rng = Random(seed) + val a = FloatArray(m * k) { rng.nextFloat() - 0.5f } + val b = FloatArray(k * n) { rng.nextFloat() - 0.5f } + + val refOut = FloatArray(m * n) + ScalarMatmulKernel.matmul(a, 0, k, b, 0, n, refOut, 0, n, m, n, k) + + val knOut = FloatArray(m * n) + cinteropMatmul(a, b, knOut, m, n, k) + + for (i in 0 until m * n) { + val diff = abs(refOut[i] - knOut[i]) + val rel = diff / (abs(refOut[i]) + 1e-9f) + assertTrue( + diff <= tol || rel < 1e-4f, + "elem $i (row ${i / n}, col ${i % n}) diverged: " + + "scalar=${refOut[i]} cinterop=${knOut[i]} diff=$diff rel=$rel tol=$tol", + ) + } + } + + @Test + fun tail_only_shape() = assertParity(m = 2, n = 3, k = 16, seed = 42, tol = 1e-4f) + + @Test + fun vector_plus_tail_shape() = assertParity(m = 3, n = 7, k = 32, seed = 7, tol = 1e-4f) + + @Test + fun aligned_shape() = assertParity(m = 4, n = 64, k = 128, seed = 123, tol = 1e-3f) + + @Test + fun matvec_row_shape() = assertParity(m = 1, n = 513, k = 256, seed = 321, tol = 1e-3f) + + @Test + fun llm_typical_shape() = assertParity(m = 8, n = 300, k = 1024, seed = 999, tol = 1e-2f) +} diff --git a/skainet-backends/skainet-backend-native-cpu/src/linuxX64Test/kotlin/sk/ainet/exec/kernel/NativeKnKernelProviderTest.kt b/skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnKernelProviderTest.kt similarity index 100% rename from skainet-backends/skainet-backend-native-cpu/src/linuxX64Test/kotlin/sk/ainet/exec/kernel/NativeKnKernelProviderTest.kt rename to skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnKernelProviderTest.kt diff --git a/skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnQ4KMatmulKernelParityTest.kt b/skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnQ4KMatmulKernelParityTest.kt new file mode 100644 index 00000000..4b6e1171 --- /dev/null +++ b/skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnQ4KMatmulKernelParityTest.kt @@ -0,0 +1,90 @@ +package sk.ainet.exec.kernel + +import kotlin.math.sqrt +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Proves the Kotlin/Native cinterop path: [NativeKnQ4KMatmulKernel] (calling the + * C `skainet_q4k_matmul` via cinterop, linked from libskainet_kernels.a) must + * agree with the commonMain [ScalarQ4_KMatmulKernel] exact-float reference. + * + * Runs on linuxX64 (host archive: scalar/auto-vectorized) AND linuxArm64 + * (cross-built archive: NEON + vdotq_s32), so the aarch64 run bit-checks the + * `SKAINET_HAVE_DOTPROD` path in q4k_matmul.c against the scalar reference. + * + * IMPORTANT: the C kernel quantizes the activation to int8 (Q8) for the + * ggml-style dotprod fast path — deliberately lossy, so it is NOT bit-exact vs + * the float reference. Per-row relative error is the wrong gate (a true-zero + * row shows unbounded relative error); the meaningful metric is aggregate + * error energy RMS(error)/RMS(signal), same as the JVM + * `NativeQ4KMatmulKernelParityTest`. + */ +class NativeKnQ4KMatmulKernelParityTest { + + private val blockSize = 256 + private val bytesPerBlock = 144 + + private fun randomQ4KBytes(numBlocks: Int, seed: Int): ByteArray { + val rng = Random(seed) + val bytes = ByteArray(numBlocks * bytesPerBlock) + rng.nextBytes(bytes) + for (block in 0 until numBlocks) { + val base = block * bytesPerBlock + // 0x3C00 == 1.0f16 for d and dMin so dequant stays finite. + bytes[base + 0] = 0x00.toByte() + bytes[base + 1] = 0x3C.toByte() + bytes[base + 2] = 0x00.toByte() + bytes[base + 3] = 0x3C.toByte() + } + return bytes + } + + private fun assertParity(inputDim: Int, outputDim: Int, seed: Int, tol: Float) { + val numBlocks = (inputDim / blockSize) * outputDim + val packed = randomQ4KBytes(numBlocks, seed) + val input = FloatArray(inputDim) { Random(seed + it).nextFloat() - 0.5f } + + val refOut = FloatArray(outputDim) + ScalarQ4_KMatmulKernel.matmul(input, 0, packed, 0, inputDim, outputDim, refOut, 0) + + val knOut = FloatArray(outputDim) + NativeKnQ4KMatmulKernel.matmul(input, 0, packed, 0, inputDim, outputDim, knOut, 0) + + // Aggregate RMS gate (see class kdoc): Q8-activation quantization makes + // per-row float parity meaningless; bound the total error energy instead. + var sqErr = 0.0 + var sqSig = 0.0 + for (o in 0 until outputDim) { + val d = (refOut[o] - knOut[o]).toDouble() + sqErr += d * d + sqSig += refOut[o].toDouble() * refOut[o].toDouble() + } + val rmsErr = sqrt(sqErr / outputDim) + val rmsSig = sqrt(sqSig / outputDim) + val relRms = rmsErr / (rmsSig + 1e-9) + assertTrue( + relRms < AGG_REL_TOL || rmsErr < tol, + "Q8 parity exceeded: relRms=$relRms (rmsErr=$rmsErr rmsSig=$rmsSig) over $outputDim rows, tol=$AGG_REL_TOL", + ) + } + + private companion object { + // Aggregate Q8-activation RMS-relative-error bound (uniform-random worst + // case) — same bar as the JVM NativeQ4KMatmulKernelParityTest. + const val AGG_REL_TOL = 0.03 + } + + @Test + fun single_block_single_row() = assertParity(256, 1, 42, 1e-2f) + + @Test + fun single_block_multi_row() = assertParity(256, 16, 7, 1e-2f) + + @Test + fun multi_block_multi_row() = assertParity(1024, 64, 123, 5e-2f) + + @Test + fun llm_typical_shape() = assertParity(4096, 64, 999, 5e-1f) +} diff --git a/skainet-backends/skainet-backend-native-cpu/src/linuxX64Test/kotlin/sk/ainet/exec/kernel/NativeKnQ5KMatmulKernelParityTest.kt b/skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnQ5KMatmulKernelParityTest.kt similarity index 91% rename from skainet-backends/skainet-backend-native-cpu/src/linuxX64Test/kotlin/sk/ainet/exec/kernel/NativeKnQ5KMatmulKernelParityTest.kt rename to skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnQ5KMatmulKernelParityTest.kt index fceb2b28..ed7d15cd 100644 --- a/skainet-backends/skainet-backend-native-cpu/src/linuxX64Test/kotlin/sk/ainet/exec/kernel/NativeKnQ5KMatmulKernelParityTest.kt +++ b/skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnQ5KMatmulKernelParityTest.kt @@ -11,9 +11,9 @@ import kotlin.test.assertTrue * agree with the commonMain [ScalarQ5_KMatmulKernel] reference within FMA + * `-ffast-math` reassociation tolerance. * - * This is the host (linuxX64) de-risking of the board (linuxArm64) consumption: - * the cinterop mechanism + kernel correctness are verified here; only the NEON - * codegen differs on aarch64 (board-verify-pending). + * Runs on linuxX64 (host archive: scalar/auto-vectorized) AND linuxArm64 + * (cross-built archive: NEON), so the aarch64 run bit-checks the + * `SKAINET_HAVE_NEON` path in q5k_matmul.c against the scalar reference. */ class NativeKnQ5KMatmulKernelParityTest { diff --git a/skainet-backends/skainet-backend-native-cpu/src/linuxX64Test/kotlin/sk/ainet/exec/kernel/NativeKnQ6KMatmulKernelParityTest.kt b/skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnQ6KMatmulKernelParityTest.kt similarity index 90% rename from skainet-backends/skainet-backend-native-cpu/src/linuxX64Test/kotlin/sk/ainet/exec/kernel/NativeKnQ6KMatmulKernelParityTest.kt rename to skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnQ6KMatmulKernelParityTest.kt index 922f5f14..cba129a6 100644 --- a/skainet-backends/skainet-backend-native-cpu/src/linuxX64Test/kotlin/sk/ainet/exec/kernel/NativeKnQ6KMatmulKernelParityTest.kt +++ b/skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnQ6KMatmulKernelParityTest.kt @@ -11,9 +11,9 @@ import kotlin.test.assertTrue * agree with the commonMain [ScalarQ6_KMatmulKernel] reference within FMA + * `-ffast-math` reassociation tolerance. * - * This is the host (linuxX64) de-risking of the board (linuxArm64) consumption: - * the cinterop mechanism + kernel correctness are verified here; only the NEON - * codegen differs on aarch64 (board-verify-pending). Q6_K magnitudes (codes + * Runs on linuxX64 (host archive: scalar/auto-vectorized) AND linuxArm64 + * (cross-built archive: NEON), so the aarch64 run bit-checks the + * `SKAINET_HAVE_NEON` path in q6k_matmul.c. Q6_K magnitudes (codes * [-32, 31] × signed int8 scales) are larger than Q5_K, so absolute tolerances * are a touch looser; the `rel < 1e-4` relative check is the real gate. */ diff --git a/skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnQ8_0MatmulKernelParityTest.kt b/skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnQ8_0MatmulKernelParityTest.kt new file mode 100644 index 00000000..937a1d69 --- /dev/null +++ b/skainet-backends/skainet-backend-native-cpu/src/nativeTest/kotlin/sk/ainet/exec/kernel/NativeKnQ8_0MatmulKernelParityTest.kt @@ -0,0 +1,70 @@ +package sk.ainet.exec.kernel + +import kotlin.math.abs +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Proves the Kotlin/Native cinterop path: [NativeKnQ8_0MatmulKernel] (calling + * the C `skainet_q8_0_matmul` via cinterop, linked from libskainet_kernels.a) + * must agree with the commonMain [ScalarQ8_0MatmulKernel] reference within + * FMA + `-ffast-math` reassociation tolerance. + * + * Runs on linuxX64 (host archive: scalar/auto-vectorized) AND linuxArm64 + * (cross-built archive: NEON + sdot), so the aarch64 run bit-checks the + * `SKAINET_HAVE_NEON` path in q8_0_matmul.c against the scalar reference. + * Q8_0 blocks are 32 elements / 34 bytes (FP16 `d` + 32 signed int8 codes); + * full-range random codes exercise the signed widening/dot paths. + */ +class NativeKnQ8_0MatmulKernelParityTest { + + private val blockSize = 32 + private val bytesPerBlock = 34 + + private fun randomQ8_0Bytes(numBlocks: Int, seed: Int): ByteArray { + val rng = Random(seed) + val bytes = ByteArray(numBlocks * bytesPerBlock) + rng.nextBytes(bytes) + for (block in 0 until numBlocks) { + val base = block * bytesPerBlock + // 0x3C00 == 1.0f16 for the per-block scale so dequant stays finite. + bytes[base + 0] = 0x00.toByte() + bytes[base + 1] = 0x3C.toByte() + } + return bytes + } + + private fun assertParity(inputDim: Int, outputDim: Int, seed: Int, tol: Float) { + val numBlocks = (inputDim / blockSize) * outputDim + val packed = randomQ8_0Bytes(numBlocks, seed) + val input = FloatArray(inputDim) { Random(seed + it).nextFloat() - 0.5f } + + val refOut = FloatArray(outputDim) + ScalarQ8_0MatmulKernel.matmul(input, 0, packed, 0, inputDim, outputDim, refOut, 0) + + val knOut = FloatArray(outputDim) + NativeKnQ8_0MatmulKernel.matmul(input, 0, packed, 0, inputDim, outputDim, knOut, 0) + + for (o in 0 until outputDim) { + val diff = abs(refOut[o] - knOut[o]) + val rel = diff / (abs(refOut[o]) + 1e-9f) + assertTrue( + diff <= tol || rel < 1e-4f, + "row $o diverged: scalar=${refOut[o]} cinterop=${knOut[o]} diff=$diff rel=$rel tol=$tol", + ) + } + } + + @Test + fun single_block_single_row() = assertParity(32, 1, 42, 1e-2f) + + @Test + fun single_block_multi_row() = assertParity(32, 16, 7, 1e-2f) + + @Test + fun multi_block_multi_row() = assertParity(1024, 64, 123, 2e-1f) + + @Test + fun llm_typical_shape() = assertParity(4096, 64, 999, 2e0f) +}