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
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ plugins {
// :llm-runtime:iree-android, which bundles prebuilt JNI .so's via jniLibs, matching
// the engine repo's skainet-backend-jni-cpu precedent.
alias(libs.plugins.androidLibrary) apply false
// com.android.application: :llm-apps:skainet-decode-android, the on-device decode/metrics
// sample (SKaiNET#1244) — first application module in the repo.
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.jetbrainsKotlinJvm) apply false
alias(libs.plugins.vanniktech.mavenPublish) apply false
alias(libs.plugins.kover)
Expand Down
4 changes: 3 additions & 1 deletion docs/modules/ROOT/pages/reference/architecture.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ llm-apps/
skainet-cli/ Unified CLI (auto-detects architecture, routes every GGUF family)
kllama-cli/ Llama-family CLI with agent/tool-calling mode
kbert-cli/ BERT CLI
skainet-decode/ Shared decode entry points
skainet-decode/ GGUF decode + GenerationMetrics CLI (JVM leg, SKaiNET#1129)
skainet-decode-core/ Shared DecodeSession flow behind both decode legs (KMP: jvm + android)
skainet-decode-android/ On-device decode/metrics activity (Android leg, SKaiNET#1244)
kllama-java-sample/ Java interop sample
llm-performance/ Benchmarking module
----
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ skainet-io-safetensors = { module = "sk.ainet.core:skainet-io-safetensors" }
skainet-data-source = { module = "sk.ainet.core:skainet-data-source" }

[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
androidMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
Expand Down
53 changes: 53 additions & 0 deletions llm-apps/skainet-decode-android/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# skainet-decode-android

The Android activity leg of `skainet-decode` (SKaiNET#1244): load a GGUF, decode, and report the
same `GenerationMetrics` block as the JVM CLI — on the platform the 2 GB memory arc actually
targets. The decode flow lives in `:llm-apps:skainet-decode-core` (`DecodeSession`), so the JVM
and Android legs cannot diverge; this module adds only the rows a device can answer:

- **major-fault rate for mapped weights** — the loader maps the GGUF (`MappedRandomAccessSource`),
so cold weight pages fault in from storage; `MemoryProbe` is sampled inside every decode span
and `GenerationMetrics` derives faults/s from the counters.
- **RSS on a constrained device** — sampled per decode step, with a first/last footer.

## Running it

There is no model picker and no download path — the audience is measurement, and the app's own
external-files dir needs no permissions:

```sh
./gradlew :llm-apps:skainet-decode-android:installDebug
adb push smollm2-135m-q8_0.gguf \
/sdcard/Android/data/sk.ainet.apps.decode/files/model.gguf
```

Launch **skainet-decode**, adjust prompt/steps, press **Run**. The pre-flight renders the
device-fit verdict (`AndroidGguf.fits`, header-only — the refusal happens before a byte of
payload is read) and the run ends with `GenerationMetrics.render()` plus the device footer.

The full report mirrors to logcat (`adb logcat -s SkDecode`) and lands beside the model:

```sh
adb pull /sdcard/Android/data/sk.ainet.apps.decode/files/decode-report.md
```

## The numbers this lane owns (SKEEP-002)

The engine repo's off-heap storage docs cite two device measurements that belong to this sample:

- **≤ 40 MB managed heap for SmolLM2-135M Q8_0** — measured as shipped (`largeHeap="true"`;
the heap *cap* is larger, the claim is about heap *use* with mapped, keep-packed weights).
- **a ~600 MB Q4_K model loads on a 256 MB heap** — measured with `largeHeap` **off**: flip the
manifest attribute to `false` for this run. Llama-family 135M models need `largeHeap` on only
because their dense FP32 `token_embd` (~113 MiB) lands on the managed heap (transformers#272).

## Caveats

- **Kernel fallback skews tok/s, not correctness.** Kernels arrive via the self-healing
ServiceLoader SPI in the `skainet-backend-jni-cpu` AAR (0.52.0). If a quant/view combination
has no pack, dispatch falls back to the decoding reference kernel — the run is correct but
slow, and the *kernel/adapter share* rows in the report are how you notice. Packaging merges
`META-INF/services/**`; if a minified build ever strips them, everything silently slows.
- x86_64 emulator runs keep the lane executable but prove nothing about performance.
- `RecordingTraceSink` is single-threaded by design: the whole session runs on one worker
thread; the UI only receives strings.
67 changes: 67 additions & 0 deletions llm-apps/skainet-decode-android/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

// The Android activity leg of skainet-decode (SKaiNET#1244): load a GGUF, decode, and report
// the same GenerationMetrics as the JVM CLI — on the platform the 2 GB memory arc actually
// targets. The rows the JVM leg cannot show (page-fault rate for mapped weights, RSS on a
// constrained device) are the whole point; the decode flow itself lives in
// :llm-apps:skainet-decode-core so the two legs cannot diverge.
plugins {
// AGP 9 ships built-in Kotlin support — applying kotlin("android") is an error since 9.0
// (see :llm-runtime:iree-android, the precedent module). First com.android.application in
// the repo; not published, so no maven-publish and invisible to bom-coverage by design.
alias(libs.plugins.androidApplication)
}

android {
namespace = "sk.ainet.apps.decode"
compileSdk = libs.versions.android.compileSdk.get().toInt()

defaultConfig {
applicationId = "sk.ainet.apps.decode"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.compileSdk.get().toInt()
versionCode = 1
versionName = "0.1"

ndk {
// Real numbers come from arm64 hardware; x86_64 keeps the emulator lane runnable.
// Matches the engine's skainet-backend-jni-cpu AAR ABI set.
abiFilters += listOf("arm64-v8a", "x86_64")
}
}

packaging {
resources {
// The 0.52.0 self-healing kernel dispatch is ServiceLoader-driven
// (SKaiNET#1240): losing META-INF/services entries silently drops every
// matmul to the slow decoding reference kernel. Merge, never exclude.
merges += "META-INF/services/**"
}
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}

kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
optIn.add("sk.ainet.lang.memory.ExperimentalMemoryApi")
}
}
}

dependencies {
implementation(platform(project(":llm-bom")))
implementation(project(":llm-apps:skainet-decode-core"))
// AndroidGguf (device-fit pre-flight, mapped loader) and MappedRandomAccessSource.
implementation(libs.skainet.io.gguf)
implementation(libs.skainet.io.core)
// AndroidTraceSink (Perfetto spans), MemoryProbe, GenerationMetrics.
implementation(libs.skainet.lang.core)
implementation(libs.kotlinx.coroutines)
// The NEON kernel AAR; registers itself via ServiceLoader on ART — no bootstrap code.
// skainet-backend-native-cpu (FFM) must NOT appear here: FFM does not exist on ART.
runtimeOnly(libs.skainet.backend.jniCpu)
}
21 changes: 21 additions & 0 deletions llm-apps/skainet-decode-android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<!-- largeHeap: llama-family 135M models carry a dense FP32 token_embd (~113 MiB) that
overflows the standard ART heap (transformers#272). The SKEEP-002 "256 MB heap"
measurement is taken with largeHeap OFF — see the README for the config split. -->
<application
android:label="skainet-decode"
android:largeHeap="true"
android:allowBackup="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package sk.ainet.apps.decode

import android.app.Activity
import android.graphics.Typeface
import android.os.Bundle
import android.text.method.ScrollingMovementMethod
import android.util.Log
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import sk.ainet.apps.decode.core.DecodeSession
import sk.ainet.io.MappedRandomAccessSource
import sk.ainet.io.gguf.AndroidGguf
import sk.ainet.lang.memory.MemoryProbe
import sk.ainet.lang.memory.sample
import sk.ainet.lang.memory.trace.AndroidTraceSink
import java.io.File
import java.util.concurrent.Executors

/**
* The on-device skainet-decode leg (SKaiNET#1244): pick a pushed GGUF, decode, and render the
* same `GenerationMetrics.render()` block as the JVM CLI — plus the rows only a device can
* answer: RSS and major-fault rate for mapped weights.
*
* Everything measurement-relevant runs on ONE background thread ([worker]):
* `RecordingTraceSink` is not thread-safe and `KernelDispatch.defaultSink` is assigned in the
* `DecodeSession` constructor, so session construction, load, and the whole traced loop stay on
* that thread. The UI only ever receives strings via [Activity.runOnUiThread].
*
* The full report is mirrored to logcat (tag `SkDecode`) and written to
* `getExternalFilesDir(null)/decode-report.md` for `adb pull` — the M2-A5 harness pattern.
*/
class MainActivity : Activity() {

private companion object {
const val TAG = "SkDecode"

/** KV-sizing context for the header-only pre-flight plan; prompt+steps stay well under it. */
const val PREFLIGHT_CTX = 512
}

private val workerExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "skainet-decode") }
private val worker = workerExecutor.asCoroutineDispatcher()
private val scope = CoroutineScope(worker)

private lateinit var pathField: EditText
private lateinit var promptField: EditText
private lateinit var stepsField: EditText
private lateinit var runButton: Button
private lateinit var output: TextView

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val modelsDir = getExternalFilesDir(null)
val defaultModel = File(modelsDir, "model.gguf")
val ggufs = modelsDir?.listFiles { f -> f.name.endsWith(".gguf") }?.map { it.name }.orEmpty()

val pad = (8 * resources.displayMetrics.density).toInt()
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(pad, pad, pad, pad)
}

root.addView(TextView(this).apply {
text = if (ggufs.isEmpty()) {
"No .gguf in ${modelsDir?.absolutePath} — push one:\nadb push model.gguf ${modelsDir?.absolutePath}/model.gguf"
} else {
"Models in ${modelsDir?.absolutePath}:\n" + ggufs.joinToString("\n") { " $it" }
}
typeface = Typeface.MONOSPACE
textSize = 11f
})

pathField = EditText(this).apply {
hint = "model path"
setText(defaultModel.absolutePath)
maxLines = 2
}
promptField = EditText(this).apply {
hint = "prompt"
setText("Once upon a time")
}
stepsField = EditText(this).apply {
hint = "steps"
setText("32")
}
runButton = Button(this).apply {
text = "Run"
setOnClickListener { runDecode() }
}
output = TextView(this).apply {
typeface = Typeface.MONOSPACE
textSize = 11f
movementMethod = ScrollingMovementMethod()
}

root.addView(pathField)
root.addView(promptField)
root.addView(stepsField)
root.addView(runButton)
root.addView(output, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))

setContentView(ScrollView(this).apply { addView(root) })
}

override fun onDestroy() {
super.onDestroy()
scope.cancel()
workerExecutor.shutdownNow()
}

private fun runDecode() {
val path = pathField.text.toString().trim()
val prompt = promptField.text.toString()
val steps = stepsField.text.toString().trim().toIntOrNull() ?: 32
runButton.isEnabled = false
output.text = ""

val report = StringBuilder()
fun line(s: String) {
Log.i(TAG, s)
report.appendLine(s)
runOnUiThread { output.append(s + "\n") }
}

scope.launch {
try {
if (!File(path).canRead()) {
line("cannot read $path — push a model first (see the hint above)")
return@launch
}

// Pre-flight: refuse before a byte of payload is read (M2-F6).
val device = AndroidGguf.deviceMemory(this@MainActivity)
line("device: ram ${device.totalRamBytes.mib()} MiB (avail ${device.availableRamBytes.mib()} MiB), " +
"heap cap ${device.heapMaxBytes.mib()} MiB" + if (device.lowMemory) " [LOW MEMORY]" else "")
val fit = AndroidGguf.fits(this@MainActivity, path, ctx = PREFLIGHT_CTX, weightsMapped = true)
line("fit @ctx=$PREFLIGHT_CTX: " + if (fit.fits) "OK" else "REFUSED (pool: ${fit.blockingPool})")
if (!fit.fits) {
fit.suggestions.forEach { line(" suggestion: $it") }
return@launch
}

val before = MemoryProbe.sample()
line("rss before: $before")

// Session construction assigns KernelDispatch.defaultSink — must happen here,
// on the same single thread that runs the traced loop.
val session = DecodeSession(extraSink = AndroidTraceSink())
val result = session.run(
sourceProvider = { MappedRandomAccessSource.open(path) },
prompt = prompt,
steps = steps,
onModelInfo = { info ->
line("model: ${info.architecture} (${info.family}), ${info.blockCount} blocks, " +
"vocab ${info.vocabSize}, embd ${info.embeddingLength}")
},
onGenerationStart = { line("generating…") },
onToken = { piece -> runOnUiThread { output.append(piece) } },
)
runOnUiThread { output.append("\n") }
Log.i(TAG, "text: ${result.text}")
report.appendLine().appendLine("```").appendLine(result.text).appendLine("```")

line("")
line(result.metrics.render())
if (result.droppedTraceEvents > 0) {
line("WARNING: trace ring dropped ${result.droppedTraceEvents} events — early prefill spans may be undercounted")
}

val after = MemoryProbe.sample()
line("")
line("--- device footer ---")
line("rss after: $after (peak not tracked; last decode-span RSS is in the metrics above)")
line("major faults over run: ${after.majorFaultsSince(before) ?: "—"}")
line("minor faults over run: ${minorDelta(before.minorFaults, after.minorFaults)}")
line("heap: max ${Runtime.getRuntime().maxMemory().mib()} MiB, " +
"used ${(Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()).mib()} MiB")

val reportFile = File(getExternalFilesDir(null), "decode-report.md")
reportFile.writeText(report.toString())
line("report written: ${reportFile.absolutePath}")
} catch (t: Throwable) {
Log.e(TAG, "decode failed", t)
line("FAILED: $t")
} finally {
runOnUiThread { runButton.isEnabled = true }
}
}
}

private fun Long.mib(): Long = this / (1024 * 1024)

private fun minorDelta(before: Long?, after: Long?): String =
if (before != null && after != null) (after - before).toString() else "—"
}
Loading