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
9 changes: 9 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ jobs:
tasks: verifyNpmPins jsTest wasmJsTest wasmWasiTest
- name: native
tasks: linuxX64Test
# golden-parity: the SKEEP-003 packed-encoding gate (#1005). Bit-identical decode /
# scalar-kernel / TurboQuant digests on JVM and Kotlin/Native plus the dispatch parity
# tests, and the binary-compatibility check (apiCheck) that the contributing docs require
# but the per-target test legs do not run. Fast (a few minutes) so it fails early.
- name: golden-parity
tasks: >-
apiCheck
:skainet-backends:skainet-backend-cpu:jvmTest --tests sk.ainet.exec.golden.*
:skainet-backends:skainet-backend-cpu:linuxX64Test --tests sk.ainet.exec.golden.*

permissions:
contents: read
Expand Down
27 changes: 27 additions & 0 deletions docs/modules/ROOT/pages/contributing/build-from-source.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,33 @@ Applying `sk.ainet.multiplatform` to the *root* project is not supported and fai
. Rewrite any `val someSourceSet by getting ++{++ ++}++` to the convention accessors (`jvmMain ++{++ ++}++`, `iosArm64Main ++{++ ++}++`). The `by getting` form resolves eagerly and fails now that targets are created by the plugin.
. Confirm nothing moved: `./gradlew :module:tasks --all` before and after must be identical, and `./gradlew apiCheck` must pass without a new dump.

=== Pre-PR Gate and the Packed-Encoding Golden Parity Tests

CI runs the test legs per target (`jvmTest`, `jsTest wasmJsTest wasmWasiTest`, `linuxX64Test`,
`assemble`) and, since the SKEEP-003 roadmap, a `golden-parity` leg. Before opening a PR, run the
same set locally:

[source,bash]
----
scripts/pr-gate.sh # full gate: jvmTest, apiCheck, JS/Wasm tests, linuxX64Test, assemble, Java API tests
scripts/pr-gate.sh --quick # jvmTest + apiCheck only, for iteration
scripts/pr-gate.sh --golden # packed-encoding golden parity (JVM + linuxX64) + apiCheck only
scripts/pr-gate.sh --bench # full gate + StorageBenchmarks and JMH microbenchmarks
----

The script points `CHROME_BIN` at an installed Chrome/Chromium for the Karma browser tests and
expects a JDK 21+ (`JAVA_HOME`; CI uses 25).

*Golden parity tests* (`skainet-backends/skainet-backend-cpu/src/goldenTest/kotlin/sk/ainet/exec/golden/`)
guard the packed encodings bit-for-bit: for every GGML block format (Q4_0, Q5_0, Q5_1, Q8_0, Q4_K,
Q5_K, Q6_K), ternary (TQ2_0) and TurboQuant they decode seeded bytes, run the scalar reference
kernels and encode/decode seeded vectors, and compare an FNV-64 digest of the raw float bits against
the values recorded in `Goldens.kt`. They are compiled into the JVM and Kotlin/Native test targets
(JS/Wasm compute `Float` in double precision and are covered by the tolerance-based
`PackedMatmulDispatchParityTest` in `commonTest` instead). A mismatch means a decoder or kernel no
longer produces the same bits; if that is the intent of a PR, re-baseline `Goldens.kt` in the same
PR and say so. See SKEEP-003 and the memory-architecture design record under `docs/design/memory/`.

=== Pinning npm Packages

Kotlin/JS and Kotlin/Wasm dependencies are locked in `kotlin-js-store/yarn.lock` and `kotlin-js-store/wasm/yarn.lock`. Both files are *generated*. Editing them by hand does not survive the next lockfile refresh — see https://github.com/SKaiNET-developers/SKaiNET/pull/894[PR #894], where a hand-applied `ws` security bump was reverted by `kotlinWasmUpgradeYarnLock` in the same pull request.
Expand Down
9 changes: 9 additions & 0 deletions scripts/pr-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# scripts/pr-gate.sh # full gate
# scripts/pr-gate.sh --bench # full gate + StorageBenchmarks and JMH microbenchmarks
# scripts/pr-gate.sh --quick # JVM leg + apiCheck only (iterate fast, then run the full gate)
# scripts/pr-gate.sh --golden # packed-encoding golden parity (JVM + linuxX64) + apiCheck only
#
# Set JAVA_HOME to a JDK 25 (CI uses 25; the build requires >= 21).
set -euo pipefail
Expand All @@ -25,6 +26,14 @@ echo "pr-gate: JAVA_HOME=${JAVA_HOME:-<default>} CHROME_BIN=${CHROME_BIN:-<none:

step() { echo; echo "=== pr-gate: $* ==="; }

if [[ "$mode" == "--golden" ]]; then
step "golden parity (bit-identical packed decode / scalar kernels / TurboQuant; dispatch parity) + apiCheck"
"${GRADLE[@]}" apiCheck \
:skainet-backends:skainet-backend-cpu:jvmTest --tests 'sk.ainet.exec.golden.*' \
:skainet-backends:skainet-backend-cpu:linuxX64Test --tests 'sk.ainet.exec.golden.*'
echo; echo "pr-gate: golden parity passed."; exit 0
fi

step "JVM tests"
"${GRADLE[@]}" jvmTest

Expand Down
10 changes: 10 additions & 0 deletions skainet-backends/skainet-backend-cpu/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ kotlin {
macosArm64Main { dependsOn(macosMain.get()) }
linuxX64Main { dependsOn(linuxMain.get()) }
linuxArm64Main { dependsOn(linuxMain.get()) }

// Golden (bit-identical) regression tests for the packed encodings — SKEEP-003 gate.
// Shared by the JVM and Kotlin/Native test targets only: JS/Wasm compute Float in
// double precision and are not expected to be bit-identical (they are covered by the
// tolerance-based parity tests in commonTest).
val goldenTestDir = "src/goldenTest/kotlin"
jvmTest { kotlin.srcDir(goldenTestDir) }
linuxX64Test { kotlin.srcDir(goldenTestDir) }
linuxArm64Test { kotlin.srcDir(goldenTestDir) }
macosArm64Test { kotlin.srcDir(goldenTestDir) }
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package sk.ainet.exec.golden

import sk.ainet.context.DirectCpuExecutionContext
import sk.ainet.lang.tensor.Shape
import sk.ainet.lang.tensor.data.Q4_0BlockTensorData
import sk.ainet.lang.tensor.data.Q4_KBlockTensorData
import sk.ainet.lang.tensor.data.Q5_0BlockTensorData
import sk.ainet.lang.tensor.data.Q5_1BlockTensorData
import sk.ainet.lang.tensor.data.Q5_KBlockTensorData
import sk.ainet.lang.tensor.data.Q6_KBlockTensorData
import sk.ainet.lang.tensor.data.Q8_0BlockTensorData
import sk.ainet.lang.tensor.data.TensorData
import sk.ainet.lang.tensor.storage.PackedBlockStorage
import sk.ainet.lang.types.FP32
import kotlin.math.abs
import kotlin.test.Test
import kotlin.test.assertTrue

/**
* SKEEP-003 golden gate, dispatch half (runs on every target): for all seven GGML packed
* encodings, `ops.matmul(x, ops.transpose(w))` on a canonical row-major packed weight must agree
* with an FP32 reference computed from the decoded weight. Tolerance-based because the JVM may
* pick SIMD / native / Q8-activation kernel tiers (#944), which are not bit-identical to the
* scalar reference; the bit-identical guarantees live in the goldenTest source set.
*/
class PackedMatmulDispatchParityTest {

private enum class Fmt(val blockSize: Int, val bytesPerBlock: Int) {
Q4_0(32, 18), Q5_0(32, 22), Q5_1(32, 24), Q8_0(32, 34), Q4_K(256, 144), Q5_K(256, 176), Q6_K(256, 210)
}

private class Rng(seed: Long) {
private var s: Long = seed
fun nextLong(): Long { var x = s; x = x xor (x ushr 12); x = x xor (x shl 25); x = x xor (x ushr 27); s = x; return x * 0x2545F4914F6CDD1DuL.toLong() }
fun nextByte(): Byte = (nextLong() ushr 56).toByte()
fun nextFloat(): Float = ((nextLong() ushr 40).toInt() and 0xFFFFFF) / 16777216.0f
}

private fun half(v: Float): Int {
val bits = v.toRawBits(); val sign = (bits ushr 16) and 0x8000
val expo = ((bits ushr 23) and 0xFF) - 127 + 15; val mant = bits and 0x7FFFFF
if (expo <= 0) return sign; if (expo >= 31) return sign or 0x7C00
return sign or (expo shl 10) or (mant ushr 13)
}

private fun le16(b: ByteArray, off: Int, h: Int) { b[off] = (h and 0xFF).toByte(); b[off + 1] = ((h ushr 8) and 0xFF).toByte() }

/** Valid random block: random payload, sane FP16 scales (no NaN/Inf). */
private fun block(f: Fmt, rng: Rng): ByteArray {
val b = ByteArray(f.bytesPerBlock) { rng.nextByte() }
val d = rng.nextFloat() * 0.045f + 0.005f; val dMin = rng.nextFloat() * 0.02f + 0.005f; val m = rng.nextFloat() - 0.5f
when (f) {
Fmt.Q4_0, Fmt.Q5_0, Fmt.Q8_0 -> le16(b, 0, half(d))
Fmt.Q5_1 -> { le16(b, 0, half(d)); le16(b, 2, half(m)) }
Fmt.Q4_K, Fmt.Q5_K -> { le16(b, 0, half(d)); le16(b, 2, half(dMin)) }
Fmt.Q6_K -> le16(b, 208, half(d))
}
return b
}

private val ctx = DirectCpuExecutionContext()

@Suppress("UNCHECKED_CAST")
private fun parity(f: Fmt, build: (Shape, ByteArray) -> PackedBlockStorage) {
val outDim = 4; val blocksPerRow = 3; val inDim = blocksPerRow * f.blockSize; val batch = 3
val rng = Rng(0x5EED_0004L + f.ordinal)
// canonical row-major bytes: row o's blocks contiguous
val bytes = ByteArray(outDim * blocksPerRow * f.bytesPerBlock)
for (o in 0 until outDim) for (bI in 0 until blocksPerRow) {
block(f, rng).copyInto(bytes, (o * blocksPerRow + bI) * f.bytesPerBlock)
}
val storage = build(Shape(outDim, inDim), bytes)
val w = ctx.fromData(storage as TensorData<FP32, Float>, FP32::class)
val xf = FloatArray(batch * inDim) { rng.nextFloat() * 2f - 1f }
val x = ctx.fromFloatArray<FP32, Float>(Shape(batch, inDim), FP32::class, xf)

val actual = ctx.ops.matmul(x, ctx.ops.transpose(w)).data.copyToFloatArray()

// FP32 reference from the decoded weight
val wf = FloatArray(outDim * inDim); val tmp = FloatArray(f.blockSize)
for (b in 0 until outDim * blocksPerRow) { storage.dequantizeBlock(b, tmp, 0); tmp.copyInto(wf, b * f.blockSize) }
val expected = FloatArray(batch * outDim)
var maxRef = 0f
for (r in 0 until batch) for (o in 0 until outDim) {
var acc = 0f
for (i in 0 until inDim) acc += xf[r * inDim + i] * wf[o * inDim + i]
expected[r * outDim + o] = acc; maxRef = maxOf(maxRef, abs(acc))
}
val tol = 5e-3f * maxOf(maxRef, 1f) + 1e-4f
for (i in expected.indices) {
assertTrue(
abs(expected[i] - actual[i]) <= tol,
"${f.name}: dispatch output[$i]=${actual[i]} vs reference ${expected[i]} (tol $tol)",
)
}
}

@Test fun q4_0() = parity(Fmt.Q4_0) { s, b -> Q4_0BlockTensorData(s, b) }
@Test fun q5_0() = parity(Fmt.Q5_0) { s, b -> Q5_0BlockTensorData(s, b) }
@Test fun q5_1() = parity(Fmt.Q5_1) { s, b -> Q5_1BlockTensorData(s, b) }
@Test fun q8_0() = parity(Fmt.Q8_0) { s, b -> Q8_0BlockTensorData(s, b) }
@Test fun q4_K() = parity(Fmt.Q4_K) { s, b -> Q4_KBlockTensorData(s, b) }
@Test fun q5_K() = parity(Fmt.Q5_K) { s, b -> Q5_KBlockTensorData(s, b) }
@Test fun q6_K() = parity(Fmt.Q6_K) { s, b -> Q6_KBlockTensorData(s, b) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package sk.ainet.exec.golden

import kotlin.test.fail

/**
* Support for the SKEEP-003 golden parity gate: deterministic inputs for every packed encoding
* and a compact, bit-exact digest of the results.
*
* The digest is an FNV-1a 64-bit hash over the raw IEEE-754 bits of the produced floats (or the
* produced bytes), plus the first four raw float bit patterns for a readable diff. A digest is
* stable for the same bytes on every strict-binary32 target (JVM, Kotlin/Native); it changes the
* moment a decoder or scalar kernel produces a different bit anywhere. To re-baseline after an
* *intended* numeric change, run the tests and copy the "actual" value into [Goldens].
*/
internal object GoldenSupport {

/** xorshift64* — small, allocation-free, identical on every target. */
class Rng(seed: Long) {
private var s: Long = if (seed == 0L) 0x9E3779B97F4A7C15uL.toLong() else seed
fun nextLong(): Long {
var x = s
x = x xor (x ushr 12); x = x xor (x shl 25); x = x xor (x ushr 27)
s = x
return x * 0x2545F4914F6CDD1DuL.toLong()
}
fun nextByte(): Byte = (nextLong() ushr 56).toByte()
/** Uniform in [0, 1) with 24 bits of randomness (exactly representable as Float). */
fun nextFloat(): Float = ((nextLong() ushr 40).toInt() and 0xFFFFFF) / 16777216.0f
/** Uniform in [-1, 1). */
fun nextSigned(): Float = nextFloat() * 2f - 1f
}

/** Round-to-nearest-even FP32 → FP16 bits (same routine as the kernel parity tests). */
fun half(v: Float): Int {
val bits = v.toRawBits()
val sign = (bits ushr 16) and 0x8000
val expo = ((bits ushr 23) and 0xFF) - 127 + 15
val mant = bits and 0x7FFFFF
if (expo <= 0) return sign
if (expo >= 31) return sign or 0x7C00
return sign or (expo shl 10) or (mant ushr 13)
}

fun le16(b: ByteArray, off: Int, h: Int) {
b[off] = (h and 0xFF).toByte(); b[off + 1] = ((h ushr 8) and 0xFF).toByte()
}

/** One packed GGML encoding: block geometry and a seeded block builder. */
enum class Packed(val blockSize: Int, val bytesPerBlock: Int) {
Q4_0(32, 18), Q5_0(32, 22), Q5_1(32, 24), Q8_0(32, 34), Q4_K(256, 144), Q5_K(256, 176), Q6_K(256, 210);

/**
* A random but *valid* block: every byte random, then the FP16 fields overwritten with sane
* scales so no NaN/Inf can enter the arithmetic (NaN payloads are not portable).
*/
fun block(rng: Rng): ByteArray {
val b = ByteArray(bytesPerBlock) { rng.nextByte() }
val d = rng.nextFloat() * 0.045f + 0.005f
val dMin = rng.nextFloat() * 0.02f + 0.005f
val m = rng.nextSigned() * 0.5f
when (this) {
Q4_0, Q5_0, Q8_0 -> le16(b, 0, half(d))
Q5_1 -> { le16(b, 0, half(d)); le16(b, 2, half(m)) }
Q4_K, Q5_K -> { le16(b, 0, half(d)); le16(b, 2, half(dMin)) }
Q6_K -> le16(b, 208, half(d))
}
return b
}
}

/** Seeded weight: [rows] output rows × [blocksPerRow] blocks, returned per (row, block). */
fun weightBlocks(p: Packed, rows: Int, blocksPerRow: Int, seed: Long): Array<Array<ByteArray>> {
val rng = Rng(seed)
return Array(rows) { Array(blocksPerRow) { p.block(rng) } }
}

/** Canonical row-major bytes ([out, in] tensor on disk / in a TensorData). */
fun rowMajor(blocks: Array<Array<ByteArray>>): ByteArray {
val out = ArrayList<Byte>()
for (row in blocks) for (blk in row) for (x in blk) out.add(x)
return out.toByteArray()
}

/** Block-major bytes (the scalar kernels' input layout: `(blockIdx * outputDim + o)`). */
fun blockMajor(blocks: Array<Array<ByteArray>>): ByteArray {
val rows = blocks.size; val perRow = blocks[0].size
val out = ArrayList<Byte>()
for (bI in 0 until perRow) for (o in 0 until rows) for (x in blocks[o][bI]) out.add(x)
return out.toByteArray()
}

fun floats(n: Int, seed: Long, scale: Float = 1f): FloatArray {
val rng = Rng(seed)
return FloatArray(n) { rng.nextSigned() * scale }
}

// --- digests ---

private const val FNV_OFFSET = -3750763034362895579L // 0xcbf29ce484222325
private const val FNV_PRIME = 1099511628211L

private fun fnvByte(h: Long, b: Int): Long = (h xor (b and 0xFF).toLong()) * FNV_PRIME

fun digest(values: FloatArray): String {
var h = FNV_OFFSET
for (v in values) {
val bits = v.toRawBits()
h = fnvByte(h, bits); h = fnvByte(h, bits ushr 8); h = fnvByte(h, bits ushr 16); h = fnvByte(h, bits ushr 24)
}
val head = (0 until minOf(4, values.size)).joinToString(",") { hex32(values[it].toRawBits()) }
return "n=${values.size} fnv=${hex64(h)} head=$head"
}

fun digest(bytes: ByteArray): String {
var h = FNV_OFFSET
for (b in bytes) h = fnvByte(h, b.toInt())
val head = (0 until minOf(8, bytes.size)).joinToString("") { hex8(bytes[it].toInt()) }
return "n=${bytes.size} fnv=${hex64(h)} head=$head"
}

private fun hex8(v: Int): String = (v and 0xFF).toString(16).padStart(2, '0')
private fun hex32(v: Int): String = v.toUInt().toString(16).padStart(8, '0')
private fun hex64(v: Long): String = v.toULong().toString(16).padStart(16, '0')

/** Assert [actual] equals the recorded golden for [name]; the failure message carries the actual digest. */
fun check(name: String, actual: String) {
val expected = Goldens.expected[name]
?: fail("No golden recorded for '$name'. Record it in Goldens.kt:\n \"$name\" to \"$actual\",")
if (expected != actual) {
fail(
"Golden mismatch for '$name' — the packed-encoding output is no longer bit-identical.\n" +
" expected: $expected\n actual: $actual\n" +
"If this change is intended (new kernel semantics), re-baseline Goldens.kt in the same PR with the evidence."
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package sk.ainet.exec.golden

/**
* Recorded golden digests (see [GoldenSupport.digest]). One entry per (test, encoding). Recorded on
* JVM (HotSpot, strict binary32) and verified bit-identical on Kotlin/Native linuxX64.
*
* Re-baselining is a deliberate act: change a value only in the PR that intentionally changes the
* numeric behaviour of a decoder or scalar kernel, and say so in the PR.
*/
internal object Goldens {
val expected: Map<String, String> = mapOf(
"decode/Q4_0" to "n=384 fnv=d6bc5d718cd98a58 head=bdb0dc00,3d979800,00000000,3d4a2000",
"decode/Q4_K" to "n=3072 fnv=fa096e860a45191e head=4049e820,408a0210,409c8910,40c19710",
"decode/Q5_0" to "n=384 fnv=6a72d36758b74185 head=3dc3e000,bdc3e000,bef4d800,be74d800",
"decode/Q5_1" to "n=384 fnv=b33abdb77cf4b87f head=be80e800,bd89d000,3bd50000,3ead5800",
"decode/Q5_K" to "n=3072 fnv=3381529c397a361d head=4125da40,416af240,41980520,41980520",
"decode/Q6_K" to "n=3072 fnv=3c3d7fa00c48faa9 head=c2606550,40ef5b00,41fe50b0,42606550",
"decode/Q8_0" to "n=384 fnv=334c03e32df05c28 head=3f6a1700,bf6ede00,3ee55000,bf786c00",
"decode/TERNARY_tq2_0" to "n=256 fnv=48409a8f0eeaba6e head=bd000000,bd000000,bd000000,bd000000",
"decode/TERNARY_values" to "n=384 fnv=e455e2a9df6acf48 head=00000000,bf500000,bf500000,3f500000",
"scalar-matmul/Q4_0" to "n=12 fnv=0aaee3d77e3619f6 head=3f15be82,3f0f4b4e,3e49fb8e,be220b31",
"scalar-matmul/Q4_K" to "n=12 fnv=1c27f69421ebce94 head=c29e9232,c3116957,c3a2cb4f,c2da54e5",
"scalar-matmul/Q5_0" to "n=12 fnv=9cf4c45d4329bee6 head=3ffafe21,4073853c,c0a97fbe,3e9fe3a0",
"scalar-matmul/Q5_1" to "n=12 fnv=3a57aa4feea433fd head=3fe5eab0,bfe35b0c,40821cc5,40ca2f5a",
"scalar-matmul/Q5_K" to "n=12 fnv=69764ad908eba4c2 head=4317c800,c3350201,4380baa3,4286212d",
"scalar-matmul/Q6_K" to "n=12 fnv=b534bb781b931bb2 head=c41704aa,425d6ccb,4423cdf0,43bffcfd",
"scalar-matmul/Q8_0" to "n=12 fnv=a1ba10e77b91e0ab head=bf462edd,3f92c634,c151b4cb,c0e14735",
"turboquant/polar3/codes" to "n=48 fnv=46f69505e290de9f head=5db9a5a5d84adcb4",
"turboquant/polar4-qjl1/codes" to "n=64 fnv=4e1c32b146ebeda5 head=6a7b76918a83ba43",
"turboquant/polar4/codes" to "n=64 fnv=4e1c32b146ebeda5 head=6a7b76918a83ba43",
"packed/TERNARY_values" to "n=96 fnv=f659cade8a7a5701 head=8192058611602495",
"turboquant/polar3/scales" to "n=4 fnv=91fae3204b7f58d3 head=3fa460a9,3f9ffba5,400b2d5d,3fdc5844",
"turboquant/polar4-qjl1/scales" to "n=4 fnv=296a63c84523f465 head=3f0ce523,3f0920d7,3f6e96e8,3f3cddf1",
"turboquant/polar4/scales" to "n=4 fnv=296a63c84523f465 head=3f0ce523,3f0920d7,3f6e96e8,3f3cddf1",
"turboquant/polar3/decoded" to "n=128 fnv=92a4aba1adab2736 head=3ef321a3,be3f44b9,bf927550,4054d201",
"turboquant/polar4-qjl1/decoded" to "n=128 fnv=c709e3b423fd49ff head=3ef331d0,beddd096,bf501ad5,404d9967",
"turboquant/polar4/decoded" to "n=128 fnv=aad39f2f7c413875 head=3ed15f9f,bebbfe6a,bf71ed00,403cb050",
)
}
Loading
Loading