Skip to content

Commit 412116a

Browse files
authored
Merge pull request #145 from Raphaeal19/feat/android-benchmark-submission-block
android: add an on-device benchmark that prints the community submiss…
2 parents 8c2d422 + 40f2bf1 commit 412116a

9 files changed

Lines changed: 1045 additions & 1 deletion

File tree

‎android/README.md‎

Lines changed: 224 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,229 @@ adb shell am instrument -w -e class dev.edge0.runtime.app.LlamaRuntimeTest \
111111
Coverage: 8B/35B smoke, 35B↔8B in-process switching, prefix-reuse fidelity,
112112
thinking on/off × system-prompt quadrants (8B gate + 35B off leak probe), and
113113
identity retention across multi-turn rendering. Host-side logic tests:
114-
`./gradlew :app:testDebugUnitTest` (32 tests, no device needed).
114+
`./gradlew :app:testDebugUnitTest` (no device needed).
115+
116+
### 1.7 Benchmark (community submission block)
117+
118+
`BenchTest` measures decode speed, prefill speed and peak memory of a model on a real phone and
119+
prints the result block used by the
120+
[Hardware Benchmarks](https://github.com/Edge0-AI/Edge0/discussions/142) Discussion
121+
(`runtime: android`). Besides the speed and memory numbers, the block records the device model, SoC,
122+
RAM and Android version and the engine commit, so it is ready to paste.
123+
124+
#### What you need
125+
126+
- A phone connected over USB with USB debugging on (`adb devices` lists it as `device`).
127+
- The app and the test APK installed (§1.3) and the model staged on the phone (§1.4).
128+
- A tier that fits the phone: `8b` runs on any 8 GB+ phone, `35b` needs 12 GB+.
129+
130+
#### Quick start
131+
132+
```bash
133+
./gradlew :app:installDebug :app:installDebugAndroidTest
134+
bash tools/model/push_models.sh --8b # or --35b; restage if the app's data was cleared
135+
bash tools/bench/run_bench.sh --8b --reporter @your-handle
136+
```
137+
138+
The last command prints the block on stdout and nothing else, so it can be redirected:
139+
`bash tools/bench/run_bench.sh --8b > block.txt`.
140+
141+
#### The runner script: `tools/bench/run_bench.sh`
142+
143+
```
144+
bash tools/bench/run_bench.sh --8b | --35b [--reporter @handle] [--notes "text"] [--temperature 0.2]
145+
```
146+
147+
| Option | Required | Meaning |
148+
|---|---|---|
149+
| `--8b` / `--35b` | yes | The tier to run. If both are given, the last one wins. |
150+
| `--reporter @handle` | no | Your handle, written to the `reporter` field. The `@` is added if missing. |
151+
| `--notes "text"` | no | Free text appended to the `notes` field (for example `case on, charging`). Quotes and spaces are safe. |
152+
| `--temperature 0.2` | no | Sampling temperature, 0 (greedy) to 2. The default, 0.2, is the app's own default. See "Sampling". |
153+
154+
| Environment | Meaning |
155+
|---|---|
156+
| `ADB_SERIAL` | Which device to use. Required when more than one is attached (`adb devices` shows the serials). |
157+
158+
What it does, in order:
159+
160+
1. Checks that `adb` sees a usable device. If not, it stops with exit code 1 and tells you to check
161+
`adb devices` or set `ADB_SERIAL`.
162+
2. Starts the instrumented test with `adb shell am instrument -w`, passing the tier, reporter and
163+
notes. It prints one line to stderr, then stays silent until the run ends (see "Following
164+
progress").
165+
3. Reads the block out of the instrumentation output and prints it to stdout.
166+
4. If no block was produced, or the test failed, it prints the full instrumentation output to
167+
stderr and exits with code 1. The failure text from the test is in that output.
168+
169+
Exit codes: `0` block printed, `1` no device or no block (including a failed test), `2` bad
170+
arguments.
171+
172+
The script does not check beforehand that the APKs and the model are on the phone. The test
173+
reports it: a missing model fails with `edge0-<tier>.gguf not found under files/models`, and a
174+
missing test APK fails with `INSTRUMENTATION_FAILED` and "Unable to find instrumentation info".
175+
176+
Without the script, the same run is:
177+
178+
```bash
179+
adb shell am instrument -w -e class dev.edge0.runtime.app.BenchTest \
180+
-e tier 8b -e reporter @your-handle \
181+
dev.edge0.runtime.app.test/androidx.test.runner.AndroidJUnitRunner
182+
```
183+
184+
Besides the instrumentation output, the test logs the block under the tag `E0Bench` and saves a
185+
copy to `/sdcard/Android/data/dev.edge0.runtime.app/files/bench-edge0-<tier>.txt` (`adb pull`).
186+
`BenchTest` does nothing unless `-e tier` is given, so other instrumented runs are unaffected.
187+
188+
#### Running through Gradle
189+
190+
The same test can be run as a Gradle connected test. Install the app and stage the model first, then
191+
name the test class and pass the arguments as Gradle properties:
192+
193+
```bash
194+
./gradlew :app:installDebug # the app must exist before the model can be staged (§1.4)
195+
./gradlew :app:connectedDebugAndroidTest \
196+
-Pandroid.testInstrumentationRunnerArguments.class=dev.edge0.runtime.app.BenchTest \
197+
-Pandroid.testInstrumentationRunnerArguments.tier=8b \
198+
-Pandroid.testInstrumentationRunnerArguments.reporter=@your-handle
199+
```
200+
201+
- Always pass `class=`: without it Gradle runs every instrumented test, including
202+
`LlamaRuntimeTest`, which needs both models. Without `tier=`, `BenchTest` skips itself.
203+
- Gradle reinstalls the app in place, so the model you staged is still there for the run. When the run
204+
ends it **uninstalls both APKs, which removes the staged model**, so stage it again for the next run.
205+
- The console only shows pass or fail. The block is recorded in
206+
`app/build/outputs/androidTest-results/connected/debug/<device>/` (`test-result.textproto`, key
207+
`submission_block`) and in the logcat attachment next to it, which prints it with:
208+
209+
```bash
210+
sed -n 's/.*E0Bench *: //p' app/build/outputs/androidTest-results/connected/debug/*/logcat-*BenchTest*.txt
211+
```
212+
213+
- Gradle warns that runner arguments given on the command line are not compatible with its
214+
configuration cache. The arguments still reach the test; the run just does not use the cache.
215+
216+
#### What the benchmark does
217+
218+
- **Prompt:** one fixed prompt of about 3.3k tokens, built from the `BENCH_LONG=1` paragraphs of
219+
`python/examples/bench.py` plus an instruction that asks for a long answer. Thinking is off.
220+
- **Protocol:** one cold warmup run, then 2 measured runs with a 5 s pause between them. Each run
221+
starts from scratch, so every run pays a full prefill. The block reports the mean of the 2
222+
measured runs. The warmup is not averaged, but its numbers and each measured run's are listed in
223+
`notes`.
224+
- **Decode:** exactly 200 new tokens, so every run measures an extended decode, sampled at
225+
temperature 0.2 by default (the app's own default; `--temperature 0` is greedy). See "Sampling".
226+
The engine cannot be told to ignore end-of-sequence, so a run where the model stops earlier fails
227+
instead of reporting a shorter window.
228+
- **Timing:** prefill and decode speeds come from the engine's own timers, as in the app's
229+
metrics line.
230+
- **Peak memory:** the process's peak resident set (`VmHWM`). The benchmark resets that counter
231+
before every run (by writing `5` to `/proc/self/clear_refs`), so each run reports its own peak, and
232+
the block shows the highest of the measured runs. If the phone refuses the reset, the number is the
233+
whole-process maximum and `notes` says so. It includes memory-mapped model pages that are resident
234+
at the time, so it is not the model's size and not comparable to MLX's allocator peak.
235+
- **Comparable runs only:** a run fails instead of reporting if the prompt is outside 2.8k-3.8k
236+
tokens, the prefix cache was reused, or decode did not produce exactly 200 tokens.
237+
238+
Differences from `python/examples/bench.py`: the Android run reports a mean of 2 runs after a cold
239+
warmup (the Python bench reports its cold and warm runs) and has no 10-token untimed decode warmup
240+
inside a run; the model may end its answer early, which fails the run (the Python bench always
241+
decodes 200 tokens); only the temperature is applied when sampling (next section); and peak memory
242+
is the process's resident peak, not MLX's allocator peak.
243+
244+
#### Sampling
245+
246+
| Setting | `python/examples/bench.py` | Android benchmark |
247+
|---|---|---|
248+
| Temperature | `BENCH_TEMP`, default 0.7 | `--temperature`, default 0.2 (the app's default); 0 is greedy |
249+
| Top-k | model config, default 64 | Not applied. With temperature above 0 the engine always uses top-k 40. |
250+
| Top-p | model config, default 0.95 | Not applied. The engine has no top-p. |
251+
| Repetition penalty | model config, default off | Not applied. |
252+
| Seed | `BENCH_SEED`, optional | Fixed inside the engine. |
253+
254+
The app's Settings screen has top-k and top-p sliders, but the llama path forwards only the
255+
temperature to the engine (`LlamaRuntime.kt`), so they have no effect there either. Applying them
256+
needs a change to the engine's decode loop (`llama_chat.c`), which the benchmark does not touch.
257+
258+
#### The block
259+
260+
```
261+
tier: edge0-8b
262+
runtime: android
263+
device: Samsung SM-S911U
264+
soc: Snapdragon 8 Gen 2
265+
ram_gb: 8
266+
os: Android 16 (API 36)
267+
decode_tok_s: 1.14
268+
prefill_tok_s: 10.3
269+
peak_active_gib: 3.40
270+
engine_commit: 7ab4ee7baad2d920464cbacfad4f4b07cf111fd2
271+
reporter: @your-handle
272+
notes: cold run: decode 1.80 tok/s, prefill 10.4 tok/s, peak 3.54 GiB; run 1: decode 1.56 tok/s, prefill 10.0 tok/s, peak 3.33 GiB; run 2: decode 0.72 tok/s, prefill 10.7 tok/s, peak 3.40 GiB; 3301-token prompt, 200 decoded, mean of 2 reps, temperature 0.2
273+
```
274+
275+
The block above is the output of one run of the 8B tier on an 8 GB phone, where the 5 GB model does
276+
not fit in free memory (see below). The screen turned off during run 2, which decoded at less than
277+
half the speed of run 1; the notes list every run so that an outlier like this stays visible.
278+
279+
| Field | Who fills it |
280+
|---|---|
281+
| `tier`, `runtime` | The benchmark. |
282+
| `decode_tok_s`, `prefill_tok_s`, `peak_active_gib` | The benchmark (means of the 2 measured runs; peak is the highest of the two). |
283+
| `engine_commit` | The benchmark: the pinned engine commit from `vendor.llama.pin`. |
284+
| `reporter` | `--reporter`. |
285+
| `notes` | The benchmark adds the cold run and each measured run (decode, prefill and, when the peak could be reset per run, peak), the prompt length, tokens decoded, run count and temperature; `--notes` is appended. |
286+
| `device`, `soc`, `ram_gb`, `os` | The benchmark, from the Android framework. `device` is manufacturer and model. `soc` is the chip: known Qualcomm codes are mapped to marketing names (`SM8550` becomes Snapdragon 8 Gen 2), any other chip is shown as vendor and model code. `ram_gb` is total memory rounded up to the marketed size (a phone that reports 7.4 GiB shows 8). `os` is the Android release and API level. |
287+
288+
#### Following progress
289+
290+
The runner prints nothing until the run finishes. To follow progress, watch the engine log in a
291+
second terminal:
292+
293+
```bash
294+
adb logcat -s E0LLAMA
295+
```
296+
297+
The benchmark sends three requests: the warmup, then runs 1 and 2. Each logs a `REND` line when it
298+
starts, `PRE fill ...` when its prefill is done, and `OK gen=... tps=... prefill_ms=...` when it
299+
finishes, so the third `OK gen=` line means the run is nearly over.
300+
301+
#### Getting representative numbers
302+
303+
The model file is memory-mapped. When it is larger than the free memory on the phone, the weights
304+
are read back from flash again and again, and speed drops sharply (an 8 GB phone running the 5 GB
305+
8B model reaches only a few tokens per second, and the warmup alone can take several minutes). For a
306+
representative number, reboot, close other apps, keep the screen on for the whole run, and note in
307+
`--notes` whether the phone was charging and how much memory was free. The notes list every run, so a
308+
run that was disturbed (for example the screen turning off partway) shows up as an outlier instead of
309+
being hidden in the mean.
310+
311+
#### Troubleshooting
312+
313+
| Message | Meaning and fix |
314+
|---|---|
315+
| `no usable device` | `adb devices` shows nothing usable. Unlock the phone, accept the USB debugging prompt, or set `ADB_SERIAL`. |
316+
| `INSTRUMENTATION_FAILED` / `Unable to find instrumentation info` | The test APK is not installed. Run `./gradlew :app:installDebugAndroidTest`. |
317+
| `edge0-<tier>.gguf not found under files/models` | The model is not staged, or the app's data was cleared. Stage it again (§1.4). |
318+
| `load failed: ...` | The engine could not load the model. The text after it is the engine's reason. |
319+
| `prefix cache reused N tokens` | The run did not pay a full prefill, so the prefill speed would be wrong. Re-run. |
320+
| `prompt is N tokens, expected 2800..3800` | The tokenizer produced a different prompt length than the benchmark was designed for. Report it. |
321+
| `decode stopped after N tokens, need exactly 200` | The model ended its answer early, or the engine hit a decode error (for example a full KV cache). The engine reports such an error as a normal, shorter answer, so the two look the same. Re-run; if it repeats, report it with the tier and the number of tokens. |
322+
323+
#### Code layout
324+
325+
| File | Role |
326+
|---|---|
327+
| `app/src/main/java/dev/edge0/runtime/app/bench/Bench.kt` | Tiers, instrumentation arguments, the fixed prompt, the measurement protocol and its results. |
328+
| `app/src/main/java/dev/edge0/runtime/app/bench/BenchBlock.kt` | Renders the submission block and the per-run notes; holds the device-name, SoC and RAM helpers. |
329+
| `app/src/main/java/dev/edge0/runtime/app/bench/DeviceProbe.kt` | Reads the device model, SoC, RAM and Android version from the Android framework, and resets the peak-memory counter. |
330+
| `app/src/androidTest/java/dev/edge0/runtime/app/BenchTest.kt` | The instrumented test: reads the arguments, loads the model, runs the protocol, prints the block. |
331+
| `app/src/test/java/dev/edge0/runtime/app/bench/` | JVM unit tests for the protocol and the block (no device needed). |
332+
| `tools/bench/run_bench.sh` | The runner script described above. |
333+
334+
The protocol only depends on the app's `Runtime` interface, so it is unit-tested with a scripted
335+
runtime. `app/build.gradle.kts` adds a `BuildConfig.ENGINE_COMMIT` field read from
336+
`../vendor.llama.pin`.
115337

116338
---
117339

@@ -179,6 +401,7 @@ app/ Android app: Compose UI (src/main/java), JNI shell (src
179401
instrumented + unit tests (src/androidTest, src/test)
180402
tools/llama/ build_vendor_libs.sh — engine rebuild from the pinned tree + bands
181403
tools/model/ push_models.sh — md5-gated model staging to devices
404+
tools/bench/ run_bench.sh — on-device benchmark runner (§1.7)
182405
../vendor/llama.cpp/ materialized by the build scripts from vendor.llama.pin (gitignored; never patched in place)
183406
../patches/llama.cpp/ common(6) + android(14) hook-point bands + ledger README
184407
../windows/ desktop companion — hosts the MLX→GGUF converter used in §1.4

‎android/app/build.gradle.kts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ android {
3232
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
3333
buildConfigField("String", "GIT_SHA",
3434
"\"${System.getenv("E0_GIT_SHA") ?: "unknown"}\"")
35+
// engine provenance for the benchmark submission block (repo-root vendor.llama.pin)
36+
val enginePin = providers.fileContents(
37+
rootProject.layout.projectDirectory.dir("..").file("vendor.llama.pin")
38+
).asText.map { it.trim().substringBefore(' ') }.orElse("unknown")
39+
buildConfigField("String", "ENGINE_COMMIT", "\"${enginePin.get()}\"")
3540
}
3641
buildTypes {
3742
debug { isMinifyEnabled = false }
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// BenchTest - on-device benchmark that prints the community submission block.
2+
// Skipped unless `-e tier 8b|35b` is given, so other instrumented runs are unaffected.
3+
// Usage and model staging: android/README.md, "Benchmark".
4+
package dev.edge0.runtime.app
5+
6+
import android.content.Context
7+
import android.os.Bundle
8+
import android.util.Log
9+
import androidx.test.platform.app.InstrumentationRegistry
10+
import dev.edge0.runtime.app.bench.BenchArgs
11+
import dev.edge0.runtime.app.bench.BenchConfig
12+
import dev.edge0.runtime.app.bench.BenchRunner
13+
import dev.edge0.runtime.app.bench.buildPrompt
14+
import dev.edge0.runtime.app.bench.describeRuns
15+
import dev.edge0.runtime.app.bench.findModel
16+
import dev.edge0.runtime.app.bench.formatSubmissionBlock
17+
import dev.edge0.runtime.app.bench.readDeviceInfo
18+
import dev.edge0.runtime.app.bench.resetPeakMemory
19+
import dev.edge0.runtime.app.runtime.LlamaRuntime
20+
import java.io.File
21+
import kotlinx.coroutines.runBlocking
22+
import org.junit.Assert.assertNotNull
23+
import org.junit.Assert.assertTrue
24+
import org.junit.Assume.assumeTrue
25+
import org.junit.Test
26+
27+
class BenchTest {
28+
29+
@Test
30+
fun submissionBlock() = runBlocking {
31+
val instrumentation = InstrumentationRegistry.getInstrumentation()
32+
val raw = InstrumentationRegistry.getArguments()
33+
val rawArgs = raw.keySet().associateWith { raw.getString(it) }
34+
assumeTrue("pass -e ${BenchArgs.KEY_TIER} 8b|35b to run the benchmark",
35+
BenchArgs.KEY_TIER in rawArgs)
36+
val args = BenchArgs.parse(rawArgs)
37+
val context = instrumentation.targetContext
38+
39+
val model = findModel(modelRoots(context), args.tier)
40+
assertNotNull("${args.tier.modelFile} not found under files/models (see README, Benchmark)", model)
41+
42+
val runtime = LlamaRuntime()
43+
runtime.ensureLoaded(model!!.absolutePath)
44+
assertTrue("load failed: ${runtime.state.value}", runtime.activeModelDir != null)
45+
46+
val summary = try {
47+
BenchRunner(runtime, BenchConfig(temperature = args.temperature), resetPeak = ::resetPeakMemory)
48+
.run(buildPrompt())
49+
} finally {
50+
runtime.unloadActive()
51+
}
52+
53+
val shape = summary.reps.first()
54+
val block = formatSubmissionBlock(
55+
tier = args.tier,
56+
device = readDeviceInfo(context),
57+
summary = summary,
58+
engineCommit = BuildConfig.ENGINE_COMMIT,
59+
reporter = args.reporter,
60+
notes = describeRuns(summary) + listOf(
61+
"${shape.promptTokens}-token prompt, ${shape.newTokens} decoded, mean of ${summary.reps.size} reps, " +
62+
"temperature ${args.temperature}",
63+
args.notes,
64+
),
65+
)
66+
publish(context.getExternalFilesDir(null) ?: context.filesDir, args.tier.id, block)
67+
}
68+
69+
private fun modelRoots(context: Context): List<File?> = listOf(
70+
File(context.filesDir, "models"),
71+
context.getDir("models", 0),
72+
File(context.cacheDir, "models"),
73+
context.getExternalFilesDir(null)?.let { File(it, "models") },
74+
)
75+
76+
/** Prints through `am instrument` and logcat, then saves a pullable copy; the copy is best effort. */
77+
private fun publish(outputDir: File, tierId: String, block: String) {
78+
Log.i(TAG, "\n$block")
79+
val instrumentation = InstrumentationRegistry.getInstrumentation()
80+
instrumentation.sendStatus(0, Bundle().apply { putString("submission_block", "\n$block") })
81+
// "stream" is the standard key for a test's console output, which Gradle's connected-test report shows
82+
instrumentation.sendStatus(0, Bundle().apply { putString("stream", "\n$block\n") })
83+
runCatching { File(outputDir, "bench-$tierId.txt").writeText(block + "\n") }
84+
.onFailure { Log.w(TAG, "could not save the block to $outputDir", it) }
85+
}
86+
87+
private companion object { const val TAG = "E0Bench" }
88+
}

0 commit comments

Comments
 (0)