Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 48 additions & 7 deletions skainet-backends/skainet-backend-native-cpu/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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<Exec>("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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Loading