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
7 changes: 6 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ jobs:
matrix:
include:
- name: jvm
tasks: jvmTest
# Plain-JVM modules (the skainet-plan CLI, the engine benchmark publisher) have a
# `test` task rather than `jvmTest`, so the repo-wide jvmTest never reaches them.
tasks: >-
jvmTest
:skainet-apps:skainet-plan:test
:skainet-backends:benchmarks:jvm-cpu-publish:test
# verifyNpmPins guards the npm-* pins in gradle/libs.versions.toml against
# lockfile drift; it belongs on the leg that already has the JS toolchain.
- name: js-wasm
Expand Down
5 changes: 5 additions & 0 deletions scripts/pr-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ step "assemble"
step "Java consumer API tests"
"${GRADLE[@]}" :skainet-test:skainet-test-java:test

# Plain-JVM modules (CLI tools, the benchmark publisher) have `test`, not `jvmTest`, so the
# repo-wide jvmTest leg never reaches them — their tests would otherwise only ever run locally.
step "JVM tool tests (skainet-plan, engine benchmark publisher)"
"${GRADLE[@]}" :skainet-apps:skainet-plan:test :skainet-backends:benchmarks:jvm-cpu-publish:test

# The Android compilations have host-side (JVM) unit tests — the mmap weight path (#921) and the
# Android loading facade (#1038). They compile against androidMain, so they are the only thing that
# proves that code builds and runs on the Android variant; nothing else in the gate touches it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,24 @@ import sk.ainet.lang.memory.plan.KvCacheMode
import sk.ainet.lang.memory.plan.MemoryPlan
import sk.ainet.lang.memory.plan.MemoryPlans
import sk.ainet.lang.memory.plan.PlanInput
import sk.ainet.lang.memory.plan.PlannerProfile
import sk.ainet.lang.memory.plan.ProfiledPlan
import java.io.File
import kotlin.system.exitProcess

/**
* `skainet plan <model.gguf> [--ctx N] [--budget 1.3G] [--kv bf16|turboquant] [--list <glob>]`
* `skainet plan <model.gguf> [--ctx N] [--budget 1.3G] [--kv bf16|turboquant] [--profile P] [--list <glob>]`
*
* Milestone M0 of SKEEP-003 ("know before you load"): prints the memory plan of a GGUF model at a
* context length — weights (resident), KV cache, forward slab, heap headroom — against a budget,
* with concrete suggestions when it does not fit, and lists tensors by `TensorId`. Reads the GGUF
* header only; no tensor bytes are touched.
*
* `--profile` (M2-F6, #1039) plans under a device profile instead of raw defaults: `mobile` is the
* 2 GB phone (700 MB reserved, weights mapped, KV quantized automatically once the plan passes 80 %
* of the budget), `desktop` keeps today's behaviour, `native` reserves the smaller Kotlin/Native
* amount. The profile and every decision it made are printed above the table, so a plan can be read
* back later without guessing which rules produced it.
*/
public fun main(args: Array<String>) {
val parser = ArgParser("skainet-plan")
Expand All @@ -34,25 +42,46 @@ public fun main(args: Array<String>) {
val prefill by parser.option(ArgType.Int, fullName = "prefill-chunk", description = "Prefill chunk size for the forward slab").default(PlanInput.DEFAULT_PREFILL_CHUNK)
val list by parser.option(ArgType.String, fullName = "list", description = "List tensors whose TensorId matches this glob, e.g. 'model.layers[3].*'")
val noBudget by parser.option(ArgType.Boolean, fullName = "no-budget", description = "Print the plan without a fit check").default(false)
val profileName by parser.option(
ArgType.Choice(listOf("none", "mobile", "desktop", "native"), { it }),
fullName = "profile",
description = "Device profile whose rules the plan follows (M2-F6): mobile = 2 GB phone, desktop, native",
).default("none")
parser.parse(args)

val file = File(model)
if (!file.isFile) { System.err.println("skainet plan: file not found: $model"); exitProcess(2) }

val kvMode = if (kv == "turboquant") KvCacheMode.TURBOQUANT_4 else KvCacheMode.BF16
val plan = JvmRandomAccessSource.open(file).use { src ->
val profile = profileFor(profileName)
val profiled: ProfiledPlan = JvmRandomAccessSource.open(file).use { src ->
val reader = StreamingGGUFReader.open(src)
val input = reader.planInput(ctx = ctx, prefillChunk = prefill, kvMode = kvMode)
val b = when {
noBudget -> null
budget != null -> Budget.of(parseBytes(budget!!))
else -> Budget.available(Runtime.getRuntime().maxMemory())
val available = budget?.let { parseBytes(it) } ?: Runtime.getRuntime().maxMemory()
if (profile != null) {
// A profile owns the reserve, so --budget names what the *device* has, not what the
// plan may use; without a profile the flag keeps its original meaning.
profile.plan(input, available)
} else {
val b = when {
noBudget -> null
budget != null -> Budget.of(available)
else -> Budget.available(available)
}
ProfiledPlan(PlannerProfile("none", reserveBytes = 0), MemoryPlans.plan(input, b), emptyList())
}
MemoryPlans.plan(input, b)
}
print(plan.render())
list?.let { glob -> print(renderList(plan, glob)) }
exitProcess(if (plan.fits == false) 1 else 0)
print(if (profile != null) profiled.render() else profiled.plan.render())
list?.let { glob -> print(renderList(profiled.plan, glob)) }
exitProcess(if (profiled.plan.fits == false) 1 else 0)
}

/** The profile behind a `--profile` value; `null` for "none" (the plan's own defaults). */
internal fun profileFor(name: String): PlannerProfile? = when (name) {
"mobile" -> PlannerProfile.MOBILE_2GB
"desktop" -> PlannerProfile.DESKTOP
"native" -> PlannerProfile.NATIVE
else -> null
}

/** `1.3G`, `900M`, `64K`, `123456` → bytes (decimal suffixes are binary multiples, as the plan prints them). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import sk.ainet.lang.memory.plan.MemoryPlans
import sk.ainet.lang.memory.plan.ModelGeometry
import sk.ainet.lang.memory.plan.PlanInput
import sk.ainet.lang.memory.plan.PlanTensor
import sk.ainet.lang.memory.plan.PlannerProfile
import sk.ainet.lang.tensor.TensorId
import sk.ainet.lang.tensor.storage.TensorEncoding
import sk.ainet.lang.types.FP32
Expand Down Expand Up @@ -50,4 +51,30 @@ class SkainetPlanTest {
assertTrue(out.contains("← blk.3.attn_q.weight"), out)
assertTrue(!out.contains("layers[4]"), out)
}

@Test
fun profileFlagSelectsTheDeviceRules() {
assertEquals(PlannerProfile.MOBILE_2GB, profileFor("mobile"))
assertEquals(PlannerProfile.DESKTOP, profileFor("desktop"))
assertEquals(PlannerProfile.NATIVE, profileFor("native"))
assertEquals(null, profileFor("none"), "no profile means the plan's own defaults, as before")
}

@Test
fun theProfiledPlanPrintsItsRulesAboveTheTable() {
// #1039: a plan printed months later has to say which rules produced it.
val f = Format(FP32, TensorEncoding.Q4_K)
val w = PlanTensor("blk.0.attn_q.weight", null, f, 600L * (1 shl 20) / 144 * 256, 600L shl 20)
val g = ModelGeometry(16, 32, 8, 64, 64, 2048, 5632, 32_000)
val input = PlanInput("m", "llama", listOf(w), g, ctx = 8192)

val mobile = PlannerProfile.MOBILE_2GB.plan(input, availableBytes = 2048L shl 20).render()
assertTrue(mobile.startsWith("profile mobile-2gb"), mobile)
assertTrue(mobile.contains("prefill 256"), mobile)
assertTrue(mobile.contains("note: KV cache switched"), "a tight mobile plan quantizes the cache:\n$mobile")

val desktop = PlannerProfile.DESKTOP.plan(input, availableBytes = 2048L shl 20).render()
assertTrue(desktop.startsWith("profile desktop"), desktop)
assertTrue(!desktop.contains("switched"), "the desktop profile keeps today's behaviour:\n$desktop")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import sk.ainet.io.model.QuantPolicy
import sk.ainet.io.model.StagingPolicy
import sk.ainet.lang.memory.ExperimentalMemoryApi
import sk.ainet.lang.memory.plan.DeviceMemory
import sk.ainet.lang.memory.plan.PlannerProfile
import sk.ainet.lang.tensor.Tensor
import sk.ainet.lang.tensor.data.FloatArrayTensorData
import sk.ainet.lang.tensor.data.MmapFloatTensorData
Expand All @@ -17,6 +18,7 @@ import java.nio.ByteOrder
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue

Expand Down Expand Up @@ -123,6 +125,31 @@ class AndroidGgufLoadingHostTest {
}
}

@Test
fun `the android plan follows the mobile profile by default`() {
val f = model()
try {
val phone = DeviceMemory(
totalRamBytes = 2048 * mb, availableRamBytes = 1600 * mb,
heapMaxBytes = 512 * mb, heapUsedBytes = 40 * mb, lowMemoryThresholdBytes = 180 * mb,
)
val profiled = AndroidGguf.profiledPlan(f.absolutePath, ctx = 512, device = phone)
assertEquals(PlannerProfile.MOBILE_2GB, profiled.profile, "Android plans as a 2 GB phone (#1039)")
assertEquals(256, profiled.plan.input.prefillChunk)
assertTrue(profiled.render().contains("profile mobile-2gb"), profiled.render())
profiled.requireFits(phone)

// and it refuses, before allocating anything, when the device cannot take it
val tiny = phone.copy(availableRamBytes = 780 * mb, heapMaxBytes = 8 * mb, heapUsedBytes = 7 * mb)
val refusal = assertFailsWith<IllegalStateException> {
AndroidGguf.profiledPlan(f.absolutePath, ctx = 8192, device = tiny).requireFits(tiny)
}
assertTrue(refusal.message!!.contains("mobile-2gb"), refusal.message!!)
} finally {
f.delete()
}
}

@Test
fun `an unmapped load is charged for its weights, a mapped one is not`() {
val f = model()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import sk.ainet.lang.memory.plan.DeviceFit
import sk.ainet.lang.memory.plan.DeviceMemory
import sk.ainet.lang.memory.plan.MemoryPlan
import sk.ainet.lang.memory.plan.MemoryPlans
import sk.ainet.lang.memory.plan.PlannerProfile
import sk.ainet.lang.memory.plan.ProfiledPlan
import sk.ainet.lang.memory.plan.fitOn

/**
Expand Down Expand Up @@ -94,4 +96,29 @@ public object AndroidGguf {
/** [fits] against an explicit [DeviceMemory] — the form a test or a simulation uses. */
public fun fits(device: DeviceMemory, filePath: String, ctx: Int, weightsMapped: Boolean = true): DeviceFit =
plan(filePath, ctx).fitOn(device, weightsMapped)

/**
* The plan under a device profile (#1039): [PlannerProfile.MOBILE_2GB] by default on Android —
* 700 MB reserved, weights mapped, the KV cache quantized automatically once the plan passes
* 80 % of the budget — with every decision it made recorded in the result.
*
* `profiledPlan(...).requireFits(device)` is the refusal that happens *before* a byte is
* allocated (M2-F6).
*/
public fun profiledPlan(
filePath: String,
ctx: Int,
device: DeviceMemory,
profile: PlannerProfile = PlannerProfile.MOBILE_2GB,
): ProfiledPlan = openSource(filePath).use { source ->
profile.plan(StreamingGGUFReader.open(source).planInput(ctx), device.availableRamBytes)
}

/** [profiledPlan] reading this device's memory itself. */
public fun profiledPlan(
context: Context,
filePath: String,
ctx: Int,
profile: PlannerProfile = PlannerProfile.MOBILE_2GB,
): ProfiledPlan = profiledPlan(filePath, ctx, deviceMemory(context), profile)
}
89 changes: 89 additions & 0 deletions skainet-lang/skainet-lang-core/api/jvm/skainet-lang-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,33 @@ public final class sk/ainet/lang/memory/plan/Budget$Companion {
public final fun of (J)Lsk/ainet/lang/memory/plan/Budget;
}

public final class sk/ainet/lang/memory/plan/DequantSeverity : java/lang/Enum {
public static final field ERROR Lsk/ainet/lang/memory/plan/DequantSeverity;
public static final field OK Lsk/ainet/lang/memory/plan/DequantSeverity;
public static final field WARN Lsk/ainet/lang/memory/plan/DequantSeverity;
public static fun getEntries ()Lkotlin/enums/EnumEntries;
public static fun valueOf (Ljava/lang/String;)Lsk/ainet/lang/memory/plan/DequantSeverity;
public static fun values ()[Lsk/ainet/lang/memory/plan/DequantSeverity;
}

public final class sk/ainet/lang/memory/plan/DequantVerdict {
public fun <init> (Lsk/ainet/lang/memory/plan/PlannerProfile;DLsk/ainet/lang/memory/plan/DequantSeverity;Ljava/lang/String;)V
public final fun component1 ()Lsk/ainet/lang/memory/plan/PlannerProfile;
public final fun component2 ()D
public final fun component3 ()Lsk/ainet/lang/memory/plan/DequantSeverity;
public final fun component4 ()Ljava/lang/String;
public final fun copy (Lsk/ainet/lang/memory/plan/PlannerProfile;DLsk/ainet/lang/memory/plan/DequantSeverity;Ljava/lang/String;)Lsk/ainet/lang/memory/plan/DequantVerdict;
public static synthetic fun copy$default (Lsk/ainet/lang/memory/plan/DequantVerdict;Lsk/ainet/lang/memory/plan/PlannerProfile;DLsk/ainet/lang/memory/plan/DequantSeverity;Ljava/lang/String;ILjava/lang/Object;)Lsk/ainet/lang/memory/plan/DequantVerdict;
public fun equals (Ljava/lang/Object;)Z
public final fun getMessage ()Ljava/lang/String;
public final fun getProfile ()Lsk/ainet/lang/memory/plan/PlannerProfile;
public final fun getSeverity ()Lsk/ainet/lang/memory/plan/DequantSeverity;
public final fun getShare ()D
public fun hashCode ()I
public final fun requireAcceptable ()V
public fun toString ()Ljava/lang/String;
}

public final class sk/ainet/lang/memory/plan/DeviceFit {
public fun <init> (Lsk/ainet/lang/memory/plan/MemoryPlan;Lsk/ainet/lang/memory/plan/DeviceMemory;ZLsk/ainet/lang/memory/plan/PoolFit;Lsk/ainet/lang/memory/plan/PoolFit;Ljava/util/List;)V
public final fun component1 ()Lsk/ainet/lang/memory/plan/MemoryPlan;
Expand Down Expand Up @@ -1547,6 +1574,49 @@ public final class sk/ainet/lang/memory/plan/PlanVsActualLine {
public final fun withinTolerance (D)Z
}

public final class sk/ainet/lang/memory/plan/PlannerProfile {
public static final field Companion Lsk/ainet/lang/memory/plan/PlannerProfile$Companion;
public static final field MOBILE_RAM_CEILING J
public static final field OFF_HEAP_THRESHOLD J
public fun <init> (Ljava/lang/String;JILsk/ainet/lang/memory/plan/KvCacheMode;DJDZZ)V
public synthetic fun <init> (Ljava/lang/String;JILsk/ainet/lang/memory/plan/KvCacheMode;DJDZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun budget (J)Lsk/ainet/lang/memory/plan/Budget;
public final fun checkDequant (Ljava/lang/Double;)Lsk/ainet/lang/memory/plan/DequantVerdict;
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()J
public final fun component3 ()I
public final fun component4 ()Lsk/ainet/lang/memory/plan/KvCacheMode;
public final fun component5 ()D
public final fun component6 ()J
public final fun component7 ()D
public final fun component8 ()Z
public final fun component9 ()Z
public final fun copy (Ljava/lang/String;JILsk/ainet/lang/memory/plan/KvCacheMode;DJDZZ)Lsk/ainet/lang/memory/plan/PlannerProfile;
public static synthetic fun copy$default (Lsk/ainet/lang/memory/plan/PlannerProfile;Ljava/lang/String;JILsk/ainet/lang/memory/plan/KvCacheMode;DJDZZILjava/lang/Object;)Lsk/ainet/lang/memory/plan/PlannerProfile;
public final fun domainFor (J)Lsk/ainet/lang/tensor/storage/MemoryDomain;
public fun equals (Ljava/lang/Object;)Z
public final fun getDequantWarnFraction ()D
public final fun getKvAutoQuantizeAbove ()D
public final fun getKvMode ()Lsk/ainet/lang/memory/plan/KvCacheMode;
public final fun getName ()Ljava/lang/String;
public final fun getOffHeapThresholdBytes ()J
public final fun getPrefillChunk ()I
public final fun getReserveBytes ()J
public final fun getStrict ()Z
public final fun getWeightsMapped ()Z
public fun hashCode ()I
public final fun plan (Lsk/ainet/lang/memory/plan/PlanInput;J)Lsk/ainet/lang/memory/plan/ProfiledPlan;
public final fun strict ()Lsk/ainet/lang/memory/plan/PlannerProfile;
public fun toString ()Ljava/lang/String;
}

public final class sk/ainet/lang/memory/plan/PlannerProfile$Companion {
public final fun forDevice (Lsk/ainet/lang/memory/plan/DeviceMemory;)Lsk/ainet/lang/memory/plan/PlannerProfile;
public final fun getDESKTOP ()Lsk/ainet/lang/memory/plan/PlannerProfile;
public final fun getMOBILE_2GB ()Lsk/ainet/lang/memory/plan/PlannerProfile;
public final fun getNATIVE ()Lsk/ainet/lang/memory/plan/PlannerProfile;
}

public final class sk/ainet/lang/memory/plan/PoolFit {
public fun <init> (Ljava/lang/String;JJ)V
public final fun component1 ()Ljava/lang/String;
Expand All @@ -1564,6 +1634,25 @@ public final class sk/ainet/lang/memory/plan/PoolFit {
public fun toString ()Ljava/lang/String;
}

public final class sk/ainet/lang/memory/plan/ProfiledPlan {
public fun <init> (Lsk/ainet/lang/memory/plan/PlannerProfile;Lsk/ainet/lang/memory/plan/MemoryPlan;Ljava/util/List;)V
public final fun component1 ()Lsk/ainet/lang/memory/plan/PlannerProfile;
public final fun component2 ()Lsk/ainet/lang/memory/plan/MemoryPlan;
public final fun component3 ()Ljava/util/List;
public final fun copy (Lsk/ainet/lang/memory/plan/PlannerProfile;Lsk/ainet/lang/memory/plan/MemoryPlan;Ljava/util/List;)Lsk/ainet/lang/memory/plan/ProfiledPlan;
public static synthetic fun copy$default (Lsk/ainet/lang/memory/plan/ProfiledPlan;Lsk/ainet/lang/memory/plan/PlannerProfile;Lsk/ainet/lang/memory/plan/MemoryPlan;Ljava/util/List;ILjava/lang/Object;)Lsk/ainet/lang/memory/plan/ProfiledPlan;
public fun equals (Ljava/lang/Object;)Z
public final fun getFits ()Ljava/lang/Boolean;
public final fun getNotes ()Ljava/util/List;
public final fun getPlan ()Lsk/ainet/lang/memory/plan/MemoryPlan;
public final fun getProfile ()Lsk/ainet/lang/memory/plan/PlannerProfile;
public fun hashCode ()I
public final fun render ()Ljava/lang/String;
public final fun requireFits (Lsk/ainet/lang/memory/plan/DeviceMemory;)V
public static synthetic fun requireFits$default (Lsk/ainet/lang/memory/plan/ProfiledPlan;Lsk/ainet/lang/memory/plan/DeviceMemory;ILjava/lang/Object;)V
public fun toString ()Ljava/lang/String;
}

public final class sk/ainet/lang/memory/plan/Suggestion {
public fun <init> (Ljava/lang/String;J)V
public final fun component1 ()Ljava/lang/String;
Expand Down
Loading
Loading