Skip to content

Commit 8ddce2a

Browse files
committed
Complete quantized LLM, depth and ColBERT device benchmarks
1 parent 5c521a5 commit 8ddce2a

17 files changed

Lines changed: 2430 additions & 516 deletions

‎apps/benchmarks/BENCHMARK_SPEC.md‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ Per variant, per repeat:
3939
| `memory.disposed` | Footprint after `dispose` — a leak is a case that never returns to baseline |
4040

4141
`memory.*` is process footprint, not native heap: `phys_footprint` on iOS (what
42-
jetsam measures an app against) and total PSS on Android. Both count the
43-
resident pages of a memory-mapped `.pte`, which the heap counters miss entirely
44-
and which is most of a model's cost.
42+
jetsam measures an app against) and total PSS on Android. Raw results record the
43+
process baseline separately, so process memory can be distinguished from a
44+
model's incremental footprint.
4545

4646
Memory is sampled in a separate pass from the timings. Reading total PSS on
4747
Android walks `/proc/self/smaps` and costs milliseconds; polling that during a
@@ -148,14 +148,15 @@ would move for reasons unrelated to the device.
148148
`src/inputs.ts` is the definition; `INPUT_SPEC_VERSION` is recorded in every
149149
report and the comparator refuses to diff across a change to it.
150150

151-
| Task | Input |
152-
| -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
153-
| Vision (classification, detection, segmentation, style transfer, OCR, keypoints, image embeddings) | A fixed 512x512 or 640x640 scene: a vertical gradient, three solid ellipses, and a seeded dither |
154-
| Text embeddings, privacy filter | Fixed prose, with the PII text seeded with the entity types a detector should find |
155-
| VAD, speech-to-text | 10 s of a voice-shaped waveform: a 130 Hz pulse train through three formant resonators, 0.7 s bursts alternating with 0.3 s of near-silence |
156-
| Text-to-speech | Fixed text; the voice is the alphabetically first the config publishes, so the choice is a property of the voice set rather than of declaration order |
157-
| Text-to-image | Fixed prompt, fixed seed |
158-
| LLM | Fixed short prompt, **64 tokens decoded with EOS ignored and temperature 0** |
151+
| Task | Input |
152+
| -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
153+
| Vision (classification, depth estimation, detection, segmentation, style transfer, OCR, keypoints, image embeddings) | A fixed 512x512 or 640x640 scene: a vertical gradient, three solid ellipses, and a seeded dither |
154+
| ColBERT embeddings | The fixed text-embedding paragraph, encoded as a document; query encoding and MaxSim scoring are not timed |
155+
| Text embeddings, privacy filter | Fixed prose, with the PII text seeded with the entity types a detector should find |
156+
| VAD, speech-to-text | 10 s of a voice-shaped waveform: a 130 Hz pulse train through three formant resonators, 0.7 s bursts alternating with 0.3 s of near-silence |
157+
| Text-to-speech | Fixed text; the voice is the alphabetically first the config publishes, so the choice is a property of the voice set rather than of declaration order |
158+
| Text-to-image | Fixed prompt, fixed seed |
159+
| LLM | Fixed short prompt, **64 tokens decoded with EOS ignored and temperature 0** |
159160

160161
Two caveats worth knowing before reading the output:
161162

@@ -166,7 +167,7 @@ Two caveats worth knowing before reading the output:
166167
- **LLMs.** Decode is pinned to a fixed token count because generation length is
167168
otherwise a property of the model and its quantisation, and a wall-clock
168169
figure over a varying token count compares nothing. Divide `pipeline.median`
169-
by 64 for milliseconds per token; `detail.timeToFirstTokenMs` and
170+
by the recorded generated-token count (`units`) for milliseconds per token; `detail.timeToFirstTokenMs` and
170171
`detail.prefillMs` are reported separately. Multimodal models are driven
171172
text-only.
172173

‎apps/benchmarks/README.md‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,8 @@ post-processing in it.
173173

174174
## Publishing to the docs
175175

176-
The docs benchmarks page is built from raw runs stored beside it, so each docs
177-
version keeps the runs its numbers came from:
176+
The docs benchmarks page reads `measured.json`; raw runs are only needed to
177+
regenerate it. Temporarily collect the runs under the page directory:
178178

179179
```bash
180180
mkdir -p ../../docs/docs/04-benchmarks/runs/s26ultra
@@ -183,12 +183,15 @@ yarn bench:docs # regenerates docs/docs/04-benchmarks/measured.json
183183
```
184184

185185
The directory name is the device id the docs list it under (`DEVICES` in
186-
`docs/src/data/benchmarks.ts`). A device with runs replaces its rows in
187-
`imported.json`, which holds published numbers that have no raw runs behind
188-
them. To check a build against a release, compare against its archived runs:
186+
`docs/src/data/benchmarks.ts`). Generation replaces `measured.json`, so include
187+
all runs whose measurements it should retain. Archive the raw runs outside the
188+
repository and remove the temporary `runs/` directory before committing; the
189+
docs build only needs the generated JSON. Measured variants replace matching
190+
rows in `imported.json`, preserving other published results. To check a build
191+
against a release, compare against its archived runs:
189192

190193
```bash
191-
yarn bench:compare ../../docs/versioned_docs/version-0.11.0/04-benchmarks/runs/s26ultra/<run>.jsonl results/<new>.jsonl
194+
yarn bench:compare /path/to/archived/<run>.jsonl results/<new>.jsonl
192195
```
193196

194197
## What gets measured
@@ -322,6 +325,6 @@ a **model or variant** means nothing here beyond `yarn bench:sizes`.
322325

323326
`modules/bench-probe` is a local Expo module reading the process footprint:
324327
`task_vm_info.phys_footprint` on iOS (what jetsam measures an app against) and
325-
total PSS on Android. Both count the resident pages of a memory-mapped `.pte`,
326-
which the native-heap counters miss entirely — and that is most of a model's
327-
footprint.
328+
total PSS on Android. These are process-footprint counters, rather than
329+
native-heap totals. The raw results also record the baseline before loading and
330+
the footprint after disposal.

‎apps/benchmarks/app.json‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,7 @@
2929
"foregroundImage": "./assets/icons/adaptive-icon.png",
3030
"backgroundColor": "#ffffff"
3131
},
32-
"package": "com.anonymous.benchmarks",
33-
"usesCleartextTraffic": true
32+
"package": "com.anonymous.benchmarks"
3433
},
3534
"web": {
3635
"favicon": "./assets/icons/favicon.png"
@@ -40,13 +39,15 @@
4039
"expo-build-properties",
4140
{
4241
"android": {
43-
"minSdkVersion": 26
42+
"minSdkVersion": 26,
43+
"usesCleartextTraffic": true
4444
},
4545
"ios": {
4646
"deploymentTarget": "17.0"
4747
}
4848
}
49-
]
49+
],
50+
"./plugins/withBenchmarkBundleInputs"
5051
]
5152
}
5253
}

‎apps/benchmarks/package.json‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,27 @@
2424
"bench:compare": "node scripts/compare.mjs",
2525
"bench:summary": "node scripts/summarize.mjs",
2626
"bench:sizes": "node scripts/fetch-variant-sizes.mjs",
27-
"bench:docs": "node scripts/docs-data.mjs"
27+
"bench:docs": "node scripts/docs-data.mjs",
28+
"test": "node --test scripts/*.test.mjs"
2829
},
2930
"dependencies": {
30-
"expo": "~56.0.9",
31-
"expo-build-properties": "~56.0.17",
32-
"expo-keep-awake": "~56.0.3",
31+
"expo": "~57.0.28",
32+
"expo-build-properties": "~57.0.22",
33+
"expo-keep-awake": "~57.0.2",
3334
"react": "19.2.3",
34-
"react-native": "0.85.3",
35+
"react-native": "0.86.3",
3536
"react-native-blob-util": "^0.25.0",
3637
"react-native-executorch": "workspace:*",
3738
"react-native-safe-area-context": "~5.7.0",
38-
"react-native-worklets": "0.10.3"
39+
"react-native-worklets": "0.13.0"
3940
},
4041
"devDependencies": {
41-
"@babel/core": "^7.29.0",
42+
"@babel/core": "^7.29.7",
4243
"@react-native/metro-config": "^0.86.0",
4344
"@types/react": "~19.2.0",
44-
"babel-preset-expo": "~56.0.14",
45-
"react-refresh": "^0.14.0"
45+
"babel-preset-expo": "~57.0.15",
46+
"react-refresh": "^0.14.0",
47+
"typescript": "~5.9.2"
4648
},
4749
"private": true
4850
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const { withAppBuildGradle } = require('expo/config-plugins');
2+
3+
// React Native's bundle task tracks source files, but Expo's inlined benchmark
4+
// settings come from the environment. Give Gradle those inputs as well so a
5+
// release APK cannot reuse another device's collector or validation settings.
6+
module.exports = (config) =>
7+
withAppBuildGradle(config, (modConfig) => {
8+
const marker = '// Benchmark environment inputs';
9+
if (!modConfig.modResults.contents.includes(marker)) {
10+
modConfig.modResults.contents += `
11+
${marker}
12+
tasks.withType(com.facebook.react.tasks.BundleHermesCTask).configureEach {
13+
inputs.property("benchmarkEnvironment", providers.environmentVariablesPrefixedBy("EXPO_PUBLIC_BENCH_"))
14+
}
15+
`;
16+
}
17+
return modConfig;
18+
});

‎apps/benchmarks/scripts/docs-data.mjs‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@
22
/**
33
* Builds the docs benchmark data from raw runs.
44
*
5-
* The raw runs are the source of truth and live beside the docs page, one
5+
* To generate data, collect raw runs beside the docs page, one
66
* directory per device: `runs/<deviceId>/*.jsonl`, where `<deviceId>` is the
7-
* id the docs list the device under. A docs version cut copies them along with
8-
* the page, so every release keeps the runs its numbers came from, and
9-
* `bench:compare` can diff a new run against any of them.
7+
* id the docs list the device under. Only the generated JSON is needed by the
8+
* docs build; archive raw runs outside the repository after generation.
109
*
1110
* This writes `measured.json` beside them. The page merges it with
1211
* `imported.json`, the published rows that have no raw runs behind them: a
13-
* device with runs here replaces its imported rows entirely.
12+
* measured variant replaces its matching imported row.
1413
*
1514
* Usage:
1615
* yarn bench:docs # ../../docs/docs/04-benchmarks
@@ -67,8 +66,19 @@ function toRow(deviceId, entries) {
6766
};
6867

6968
if (task === 'llm') {
70-
// `units` is the runner's generated-token count over a pinned-length
71-
// generation: 63 for 64 new tokens, the first coming from prefill.
69+
if (
70+
entries.some(
71+
(entry) =>
72+
!Number.isFinite(entry.units) ||
73+
entry.units <= 0 ||
74+
!Number.isFinite(entry.detail?.timeToFirstTokenMs) ||
75+
entry.detail.timeToFirstTokenMs < 0
76+
)
77+
) {
78+
throw new Error(`LLM measurement ${first.id} is missing valid token or TTFT stats`);
79+
}
80+
// Use the runner's actual generated-token count for the fixed-length
81+
// generation; older runtime exports counted the first token separately.
7282
row.tokensPerSecond = round(first.units / (medianMs / 1000), 1);
7383
row.ttftMs = round(pick((entry) => entry.detail?.timeToFirstTokenMs));
7484
} else {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import assert from 'node:assert/strict';
2+
import { spawnSync } from 'node:child_process';
3+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4+
import { tmpdir } from 'node:os';
5+
import { join } from 'node:path';
6+
import { test } from 'node:test';
7+
import { fileURLToPath } from 'node:url';
8+
import { runInNewContext } from 'node:vm';
9+
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript';
10+
11+
test('partial device runs replace matching variants and retain other published results', () => {
12+
const exports = {};
13+
const source = readFileSync(
14+
new URL('../../../docs/src/data/benchmarks.ts', import.meta.url),
15+
'utf8'
16+
);
17+
const code = transpileModule(source, {
18+
compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2022 },
19+
}).outputText;
20+
runInNewContext(code, { exports });
21+
const old = {
22+
device: 's20plus',
23+
task: 'llm',
24+
model: 'test',
25+
backend: 'xnnpack',
26+
precision: '8da4w',
27+
medianMs: 10,
28+
};
29+
const fresh = { ...old, medianMs: 5 };
30+
const otherDevice = { ...old, device: 's26ultra' };
31+
const otherTask = { ...old, task: 'classification' };
32+
const otherBackend = { ...old, backend: 'vulkan' };
33+
const otherPrecision = { ...old, precision: '8da8w' };
34+
const rows = exports.mergeBenchmarkRows(
35+
[fresh],
36+
[old, otherDevice, otherTask, otherBackend, otherPrecision]
37+
);
38+
assert.equal(rows.length, 5);
39+
assert.equal(rows[0], fresh);
40+
assert.equal(rows[1], otherDevice);
41+
assert.equal(rows[2], otherTask);
42+
assert.equal(rows[3], otherBackend);
43+
assert.equal(rows[4], otherPrecision);
44+
});
45+
46+
test('docs generation publishes valid LLM throughput and rejects missing token stats', () => {
47+
const page = mkdtempSync(join(tmpdir(), 'rne-docs-test-'));
48+
try {
49+
const dir = join(page, 'runs', 'iphone17');
50+
mkdirSync(dir, { recursive: true });
51+
const file = join(dir, 'test.jsonl');
52+
const header = { type: 'run', label: 'test', device: { model: 'test' } };
53+
const entry = {
54+
id: 'llm/test-xnnpack-8-da4-w',
55+
status: 'ok',
56+
backend: 'xnnpack',
57+
precision: '8da4w',
58+
pipeline: { mean: 100, median: 100 },
59+
units: 64,
60+
detail: { timeToFirstTokenMs: 5 },
61+
taskLoadMs: 10,
62+
bytes: 1024 ** 2,
63+
memory: { peakMb: 20 },
64+
};
65+
const convert = () =>
66+
spawnSync(
67+
process.execPath,
68+
[fileURLToPath(new URL('./docs-data.mjs', import.meta.url)), page],
69+
{ encoding: 'utf8' }
70+
);
71+
const write = (measurement) =>
72+
writeFileSync(file, `${JSON.stringify(header)}\n${JSON.stringify(measurement)}\n`);
73+
write(entry);
74+
const valid = convert();
75+
assert.equal(valid.status, 0, valid.stderr);
76+
const [row] = JSON.parse(readFileSync(join(page, 'measured.json'), 'utf8'));
77+
assert.equal(row.tokensPerSecond, 640);
78+
assert.equal(row.ttftMs, 5);
79+
write({ ...entry, units: undefined });
80+
const invalid = convert();
81+
assert.equal(invalid.status, 2);
82+
assert.match(invalid.stderr, /missing valid token or TTFT stats/);
83+
} finally {
84+
rmSync(page, { recursive: true, force: true });
85+
}
86+
});

‎apps/benchmarks/scripts/fetch-variant-sizes.mjs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ function loadRegistry() {
5151
)
5252
.replace("} from './extensions/speech/tasks/whisperSpeechToText';", "} from './_stub.ts';")
5353
.replace("} from './constants';", "} from './constants.ts';")
54+
.replace(
55+
"import { registerDeprecation } from './deprecation';",
56+
"import { registerDeprecation } from './_stub.ts';"
57+
)
5458
// Type-only imports name files that are not copied into the scratch dir.
5559
// Node strips the annotations but still resolves the specifier, so they
5660
// have to go.
@@ -70,6 +74,7 @@ function loadRegistry() {
7074
"export const getRegisteredBackends = () => ['XnnpackBackend', 'CoreMLBackend', 'MLXBackend', 'VulkanBackend'];",
7175
"export const WHISPER_LANGUAGES = ['en'];",
7276
'export const WhisperSttModel = undefined;',
77+
'export const registerDeprecation = () => {};',
7378
'',
7479
].join('\n')
7580
);
@@ -119,12 +124,19 @@ async function measure(urls, cached) {
119124
for (let url = queue.pop(); url !== undefined; url = queue.pop()) {
120125
try {
121126
const response = await fetch(url, { method: 'HEAD', redirect: 'follow' });
127+
if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
122128
// Hugging Face serves LFS objects through a redirect and reports the
123129
// real object size in this header; Content-Length on the redirect hop
124130
// describes the pointer, not the file.
125131
const linked = response.headers.get('x-linked-size');
126132
const length = response.headers.get('content-length');
127133
sizes[url] = Number(linked ?? length ?? 0) || 0;
134+
// Small JSON resources can be served without Content-Length.
135+
if (sizes[url] === 0 && new URL(url).pathname.endsWith('.json')) {
136+
const body = await fetch(url);
137+
if (!body.ok) throw new Error(`HTTP ${body.status} for ${url}`);
138+
sizes[url] = (await body.arrayBuffer()).byteLength;
139+
}
128140
} catch {
129141
sizes[url] = 0;
130142
}

0 commit comments

Comments
 (0)