From ee708f2e29092c861149572926b7eed00c0e5ba7 Mon Sep 17 00:00:00 2001 From: Michal Harakal Date: Tue, 25 Aug 2026 20:58:27 +0200 Subject: [PATCH] feat(plan): MOBILE_2GB is strict, as its own documentation always claimed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profile has said since it was written that it treats dequantization as "the defect it is". The flag said otherwise, so nothing enforced it. On a 2 GB board a missing kernel is not a slow path to take quietly: it is a weight arriving several times its size on the device least able to hold it, and the honest moment to say so is before the load rather than at the OOM. strict drives two things, and both change. WeightFormResolver refuses to resolve a weight nothing on the target can feed, and checkDequant turns a dispatcher-inserted widening past the 5 % threshold from a warning into an error. Consistent, because it is the same defect whether it is paid once at load or on every step. Three tests were asserting the old policy: - PlannerProfileTest used MOBILE_2GB as its *lenient* example. DESKTOP is the honest one now — it has the memory to absorb a widening and is told, while a 2 GB board does not and fails. strict() stays covered for turning a lenient profile into a failing one in CI. - The #1118 acceptance test had mobile as the side that dequantizes, which is no longer legal. Swapped: a desktop build without a Q8_0 kernel pays once at load, a phone with the kernel keeps it packed and mapped. The same contrast, and a more plausible story. - The pricing test opts out of strict explicitly to price a widening — which is exactly the decision that number exists to inform. skainet-plan needed it too: `--profile mobile --kernels dense` would have surfaced an IllegalStateException as a stack trace. A strict profile refusing is an answer to what the planner was asked, so it prints the message and exits 1. copy(strict = false) is documented on the profile: "would rather load slowly than not at all" is a legitimate position, it just should not be the silent default. Gate: scripts/pr-gate.sh — all legs passed. Co-Authored-By: Claude Fable 5 --- .../kotlin/sk/ainet/apps/plan/SkainetPlan.kt | 15 ++++++--- .../ainet/io/gguf/WeightFormAcceptanceTest.kt | 33 +++++++++++-------- .../ainet/lang/memory/plan/PlannerProfile.kt | 10 +++++- .../lang/memory/plan/PlannerProfileTest.kt | 15 ++++++--- 4 files changed, 50 insertions(+), 23 deletions(-) diff --git a/skainet-apps/skainet-plan/src/main/kotlin/sk/ainet/apps/plan/SkainetPlan.kt b/skainet-apps/skainet-plan/src/main/kotlin/sk/ainet/apps/plan/SkainetPlan.kt index 766286361..5dae76877 100644 --- a/skainet-apps/skainet-plan/src/main/kotlin/sk/ainet/apps/plan/SkainetPlan.kt +++ b/skainet-apps/skainet-plan/src/main/kotlin/sk/ainet/apps/plan/SkainetPlan.kt @@ -70,10 +70,17 @@ public fun main(args: Array) { // for rather than detected. `all` keeps the plan exactly as it was before #1116. val input = when { profile == null -> stored - else -> stored.resolveWeightForms( - profile, - if (kernels == "dense") KernelCapabilities.DENSE_ONLY else KernelCapabilities.EVERYTHING, - ) + else -> try { + stored.resolveWeightForms( + profile, + if (kernels == "dense") KernelCapabilities.DENSE_ONLY else KernelCapabilities.EVERYTHING, + ) + } catch (e: IllegalStateException) { + // A strict profile refuses a weight nothing on the target can feed. That is an + // answer to the question the planner was asked, not a crash, so it reads as one. + System.err.println("skainet plan: ${e.message}") + exitProcess(1) + } } val available = budget?.let { parseBytes(it) } ?: Runtime.getRuntime().maxMemory() if (profile != null) { diff --git a/skainet-io/skainet-io-gguf/src/jvmTest/kotlin/sk/ainet/io/gguf/WeightFormAcceptanceTest.kt b/skainet-io/skainet-io-gguf/src/jvmTest/kotlin/sk/ainet/io/gguf/WeightFormAcceptanceTest.kt index e6c839a1f..32080838a 100644 --- a/skainet-io/skainet-io-gguf/src/jvmTest/kotlin/sk/ainet/io/gguf/WeightFormAcceptanceTest.kt +++ b/skainet-io/skainet-io-gguf/src/jvmTest/kotlin/sk/ainet/io/gguf/WeightFormAcceptanceTest.kt @@ -103,20 +103,19 @@ class WeightFormAcceptanceTest { fun `one model and one snippet of code run correctly on a desktop and on a 2 GB board`() { val f = modelFile() try { - // A workstation with the packed kernels SKaiNET ships. - val (desktopForm, desktopWeight) = loadFor(f, PlannerProfile.DESKTOP, KernelCapabilities.EVERYTHING) - // A 2 GB board: weights mapped, and — for the sake of the contrast — a build whose - // kernels cannot feed Q8_0, so the resolver must dequantize rather than dequantize - // per forward pass. - val (mobileForm, mobileWeight) = loadFor(f, PlannerProfile.MOBILE_2GB, KernelCapabilities.DENSE_ONLY) + // A desktop build without a Q8_0 kernel: it has the memory to absorb a widening, so + // the resolver dequantizes once at load rather than on every forward pass. + val (desktopForm, desktopWeight) = loadFor(f, PlannerProfile.DESKTOP, KernelCapabilities.DENSE_ONLY) + // A 2 GB board with the packed kernels SKaiNET ships: weights mapped, encoding kept. + val (mobileForm, mobileWeight) = loadFor(f, PlannerProfile.MOBILE_2GB, KernelCapabilities.EVERYTHING) // 1. The two devices resolved to different forms — asserted, not assumed. assertNotEquals(desktopForm, mobileForm, "if both devices got the same form this test proves nothing") - assertEquals(EncodingRequest.KeepAsStored, desktopForm.encoding, "the desktop can feed Q8_0, so it keeps it") assertEquals( - EncodingRequest.DequantizeTo(FP32), mobileForm.encoding, - "this board cannot feed Q8_0, so it pays once at load rather than every forward pass", + EncodingRequest.DequantizeTo(FP32), desktopForm.encoding, + "no kernel for Q8_0 here, and a desktop can afford to pay once at load", ) + assertEquals(EncodingRequest.KeepAsStored, mobileForm.encoding, "the board can feed Q8_0, so it keeps it") assertEquals(WeightResidency.HEAP, desktopForm.residency) assertEquals(WeightResidency.MAPPED, mobileForm.residency, "a 2 GB board maps its weights") @@ -150,7 +149,10 @@ class WeightFormAcceptanceTest { } val kept = stored.resolveWeightForms(PlannerProfile.MOBILE_2GB, KernelCapabilities.EVERYTHING) - val dequantized = stored.resolveWeightForms(PlannerProfile.MOBILE_2GB, KernelCapabilities.DENSE_ONLY) + // Strict is the point of MOBILE_2GB, so pricing a widening on it means opting out of + // the refusal first — which is exactly the decision the number is meant to inform. + val lenientMobile = PlannerProfile.MOBILE_2GB.copy(strict = false) + val dequantized = stored.resolveWeightForms(lenientMobile, KernelCapabilities.DENSE_ONLY) val keptPlan = MemoryPlans.plan(kept) val dequantizedPlan = MemoryPlans.plan(dequantized) @@ -174,12 +176,17 @@ class WeightFormAcceptanceTest { fun `a strict board is told about a missing kernel instead of quietly paying for it`() { // MOBILE_2GB's own documentation calls dispatcher-inserted dequantization "the defect it // is". With strict set, the resolver refuses rather than resolving to a 4x load. - val strict = PlannerProfile.MOBILE_2GB.copy(strict = true) val failure = kotlin.runCatching { - WeightFormResolver.resolve(TensorEncoding.Q8_0, strict, KernelCapabilities.DENSE_ONLY) + WeightFormResolver.resolve(TensorEncoding.Q8_0, PlannerProfile.MOBILE_2GB, KernelCapabilities.DENSE_ONLY) }.exceptionOrNull() - assertTrue(failure is IllegalStateException, "expected a refusal, got $failure") + assertTrue(failure is IllegalStateException, "MOBILE_2GB is strict by default, so this must refuse: $failure") assertTrue(failure.message!!.contains("Q8_0"), failure.message!!) + + // A build that would rather load slowly than not at all opts out explicitly. + val lenient = WeightFormResolver.resolve( + TensorEncoding.Q8_0, PlannerProfile.MOBILE_2GB.copy(strict = false), KernelCapabilities.DENSE_ONLY, + ) + assertEquals(EncodingRequest.DequantizeTo(FP32), lenient.encoding) } } diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/plan/PlannerProfile.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/plan/PlannerProfile.kt index fff38fbdc..e77d26be0 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/plan/PlannerProfile.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/plan/PlannerProfile.kt @@ -106,7 +106,10 @@ public data class PlannerProfile( /** * A 2 GB-class phone: 700 MB reserved for the OS and the app, weights mapped, KV * automatically quantized once the plan passes 80 % of the budget, and dequantization - * treated as the defect it is. The default on Android. + * treated as the defect it is — [strict], so a missing kernel fails rather than quietly + * costing several times the weight's size. The default on Android. + * + * Use `copy(strict = false)` for a build that would rather load slowly than not at all. */ public val MOBILE_2GB: PlannerProfile = PlannerProfile( name = "mobile-2gb", @@ -115,6 +118,11 @@ public data class PlannerProfile( kvMode = KvCacheMode.BF16, kvAutoQuantizeAbove = 0.80, weightsMapped = true, + // This profile has always *said* dequantization is "the defect it is"; the flag said + // otherwise. On a 2 GB board a missing kernel is not a slow path to take quietly — it + // is a weight arriving several times its size on the device least able to hold it, and + // the honest moment to say so is before the load rather than at the OOM. + strict = true, ) /** A desktop or server JVM: the same reserve, no automatic KV quantization, heap staging. */ diff --git a/skainet-lang/skainet-lang-core/src/commonTest/kotlin/sk/ainet/lang/memory/plan/PlannerProfileTest.kt b/skainet-lang/skainet-lang-core/src/commonTest/kotlin/sk/ainet/lang/memory/plan/PlannerProfileTest.kt index 09d358864..fa0a58f4f 100644 --- a/skainet-lang/skainet-lang-core/src/commonTest/kotlin/sk/ainet/lang/memory/plan/PlannerProfileTest.kt +++ b/skainet-lang/skainet-lang-core/src/commonTest/kotlin/sk/ainet/lang/memory/plan/PlannerProfileTest.kt @@ -66,7 +66,7 @@ class PlannerProfileTest { assertEquals(0.80, m.kvAutoQuantizeAbove, "KV auto-quantizes over 80 % of the budget") assertEquals(0.05, m.dequantWarnFraction, "dispatcher dequant warns over 5 %") assertTrue(m.weightsMapped, "on a phone the weights are mapped") - assertFalse(m.strict) + assertTrue(m.strict, "and a missing kernel fails rather than costing several times the weight") val d = PlannerProfile.DESKTOP assertEquals(1.0, d.kvAutoQuantizeAbove, "a desktop never silently re-quantizes the cache") @@ -155,17 +155,22 @@ class PlannerProfileTest { @Test fun dequantizationOverTheLimitWarnsAndFailsUnderStrict() { - val lenient = PlannerProfile.MOBILE_2GB.checkDequant(0.31) + // A desktop has the memory to absorb a widening, so it is told and carries on. + val lenient = PlannerProfile.DESKTOP.checkDequant(0.31) assertEquals(DequantSeverity.WARN, lenient.severity) assertTrue(lenient.message.contains("31.0%"), lenient.message) assertTrue(lenient.message.contains("kernel for the on-disk format is missing"), lenient.message) lenient.requireAcceptable() // a warning does not stop a desktop run - val strict = PlannerProfile.MOBILE_2GB.strict().checkDequant(0.31) - assertEquals(DequantSeverity.ERROR, strict.severity) - assertTrue(strict.profile.name.contains("strict")) + // A 2 GB board does not, so the same share is an error there without asking for strict. + val strict = PlannerProfile.MOBILE_2GB.checkDequant(0.31) + assertEquals(DequantSeverity.ERROR, strict.severity, "MOBILE_2GB is strict by default") val failure = assertFailsWith { strict.requireAcceptable() } assertTrue(failure.message!!.contains("over the 5.0%"), failure.message!!) + + // strict() stays available for turning a lenient profile into a failing one in CI. + assertTrue(PlannerProfile.DESKTOP.strict().checkDequant(0.31).profile.name.contains("strict")) + assertEquals(DequantSeverity.ERROR, PlannerProfile.DESKTOP.strict().checkDequant(0.31).severity) } // --- picking a profile ---------------------------------------------------------------------