Skip to content

[RNE Rewrite] feat: benchmark harness for every registry variant - #1363

Draft
msluszniak wants to merge 24 commits into
rne-rewritefrom
@ms/perf-benchmarks
Draft

[RNE Rewrite] feat: benchmark harness for every registry variant#1363
msluszniak wants to merge 24 commits into
rne-rewritefrom
@ms/perf-benchmarks

Conversation

@msluszniak

@msluszniak msluszniak commented Aug 16, 2026

Copy link
Copy Markdown
Member

Description

Adds apps/benchmarks, a headless Expo app that measures load time, inference latency and peak memory for every published registry variant (163 Android / 234 iOS, generated from models.ts), plus a collector, a summarizer and a comparator.

Notes:

  • Execute time is measured in-band via a new getExecutionProfile() API, so Execute % reflects the shapes and call counts the pipeline actually used.
  • Memory is a separate pass (reading PSS costs milliseconds) and uses process footprint, which counts mmap'd .pte pages.
  • Inputs are synthetic and deterministic, so two devices feed identical bytes.
  • Runs are release builds and gated on device temperature before every measurement.

Protocol for other devices: apps/benchmarks/BENCHMARK_SPEC.md.

Introduces a breaking change?

  • Yes
  • No

Type of change

  • Bug fix (change which fixes an issue)
  • New feature (change which adds functionality)
  • Documentation update (improves or adds clarity to existing documentation)
  • Other (chores, tests, code style improvements etc.)

Tested on

  • iOS
  • Android

Testing instructions

cd apps/benchmarks
yarn bench --platform android --suite quick --label v0.10.0 --max-temp-c 37
yarn bench:summary ~/rne-benchmarks/v0.10.0-android-SM-S948B.jsonl

Screenshots

Related issues

Closes #1078

Checklist

  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have updated the documentation accordingly
  • My changes generate no new warnings

Additional notes

Not yet run on iOS.

@msluszniak msluszniak self-assigned this Aug 16, 2026
@msluszniak msluszniak added test Issue and PR related to tests or testing infrastructure performance Related to all issues and tasks focused on improving performance labels Aug 16, 2026
@msluszniak msluszniak linked an issue Aug 17, 2026 that may be closed by this pull request
@msluszniak
msluszniak force-pushed the @ms/perf-benchmarks branch 2 times, most recently from 383f54e to df61e4d Compare August 27, 2026 10:44
Adds apps/benchmarks, a headless Expo app that runs the task pipelines
against deterministic synthetic inputs and reports load time, inference
latency and peak memory as JSON, plus a driver that collects a run and a
comparator that diffs two runs and fails on regressions.

Built to bracket an ExecuTorch bump: run the suite on 1.3.1, bump, run it
again on the same device, compare.

Three things the design turns on:

- A raw-execute pass isolates ExecuTorch from the pipeline. Task timings
  fold model.execute together with preprocessing and post-processing,
  which are TypeScript and unaffected by a bump. The pass loads the .pte
  on its own and sizes its tensors from model.schema, so it covers every
  method a program exports and needs no per-model wiring.
- Memory is sampled in a pass of its own. Reading total PSS on Android
  walks /proc/self/smaps and costs milliseconds, which would otherwise
  land in the inference numbers.
- Inputs are pure functions of their parameters. Post-processing cost is
  input-dependent, so a harness reading a photo off the device would move
  for reasons unrelated to the change under test.

The comparator refuses to diff runs from different devices, and reports a
metric whose workload changed as INCOMPARABLE rather than as a delta.

Refs #1078
The case list is heterogeneous, so the array can only be typed as
BenchCase<any> — which let a case keep compiling after the pipeline it
drives renamed the method it calls. Naming the pipeline's create as a
separate leading parameter of defineCase makes it its own inference site,
resolved before the case body is checked, so run is checked against the
real instance type. Passing create inside the literal does not work: it
is then inferred alongside run and TInstance collapses to its constraint.

Also stop a failed build from leaving the collector waiting forever. It
holds the port, so the next attempt could not start its own.
The first waveform was a harmonic stack, and on device the FSMN VAD scored
none of it as speech: the case reported zero segments, so the segmentation
path never ran and the comment claiming otherwise was wrong.

Replaced with a glottal pulse train swept through three formant resonators
plus aspiration noise. Still not speech and still fully deterministic, but
close enough in spectral shape that the VAD now opens a segment per burst
(10 over the 10 s waveform) and closes it on the gap.

Also document that execute.<method> and pipeline.median are not comparable
to each other: the raw pass takes dynamic dimensions at the top of their
declared domain, so on all-MiniLM-L6-v2 it runs a 254-token forward while
the pipeline runs a 20-token one.
A full-suite run died partway through and took every result with it. Three
separate causes, all worth fixing:

- The collector only wrote to disk on /end, so a run that stopped at case
  10 of 12 threw away the nine cases it had already measured. Cases are now
  appended to a .partial file as they arrive, removed once the final report
  lands.
- The adb reverse tunnel disappeared mid-run on a wireless connection. Every
  result posted after that was dropped while the run carried on looking
  healthy. The driver now re-establishes the tunnel on a timer, and the app
  retries a failed post rather than giving up on the first refusal.
- The screen turned off, Android froze the app, and the run stopped dead.
  The app now holds a keep-awake lock for the duration.

Also adds baselines/, where a run worth keeping is committed, with a README
covering the naming convention and when to re-record.
Full suite on a Galaxy S26 Ultra (SM-S948B, Android 16), all 12 cases
reporting. This is the reference for the 1.3.1 to 1.4.1 bump.

The run covers the three paths nothing had exercised before: the privacy
filter, Whisper (whose multi-method program gives separate encode and decode
numbers from the raw-execute pass, 112 ms and 10.5 ms) and Supertonic, whose
streaming generator has no synchronous entry point and is timed on the RN
thread instead.

Also adds a NOISY verdict to the comparator. Where a metric's own
interquartile range is wider than the tolerance, it cannot resolve a
regression of the size we care about, and reporting it as "same" overstates
what the run knows. YOLO26's pipeline metric is the case in point: its IQR is
around 38% of its median, from garbage collection during post-processing, and
it moved 57% between two runs of identical code, while its execute.forward
number over the same runs sits inside 1%.
Comparing the recorded 1.3.1 baseline against a second run of the same build
produced six regressions. All six were false. The measured drift between
those runs, by metric family:

  execute.*          7.6% worst case, most inside 4%
  pipeline.median    35%
  load.*             45%
  memory.*           2%

Two causes, both fixed here.

Load was timed exactly once per case, and one sample of a load is not a
measurement: it is mostly filesystem cache state. Loads are now repeated
(three cycles by default, load and dispose) and reported as a median with
its spread, which is why the report schema goes to 2.

And a single tolerance across every metric cannot work. A pipeline figure
carries TypeScript pre- and post-processing and its garbage collection; a
raw execute figure carries ExecuTorch and nothing else, which is what a
version bump changes. They are now separate knobs, set from the numbers
above rather than guessed: execute 10%, pipeline 30%, load 35%.

The raw-execute rows also move to the top of each case block, since they
are the ones to read.
Two full suites run fifteen seconds apart reported sixteen regressions,
including every raw-execute metric, on identical code. The cause was the
phone, not the build: all twelve execute metrics were slower in the second
run, from 9% to 51%, median 22%. The device never got a chance to cool.

That is not something a tolerance can absorb. Widening execute to 50% would
hide it and destroy the only signal that reliably tracks ExecuTorch; the
earlier well-separated pair had every execute metric inside 7.6%, so the 10%
tolerance is right for runs taken cool.

So the harness measures the confounder instead. bench-probe reports thermal
state (PowerManager on Android with battery temperature, ProcessInfo on iOS),
the runner records it per case and at run boundaries, and the comparator
refuses outright to diff two runs where either was throttling. The driver
grows --cooldown for the wait between runs.
The schema contract moved behind a `schema` namespace and its specs gained a
dimension type parameter (#1327), and the task constants are re-exported flat
rather than under a `constants` object. Track both so the harness typechecks
against the current surface.
These landed on rne-rewrite after the harness was written, so the suite had no
numbers for them. All three go in the `full` tier: FastSAM and PP-OCRv6 time
their worklet entry point, while Kokoro streams chunks from the RN thread and
carries the same caveat as Supertonic.

LLM and SDXS text-to-image are still uncovered. Both pull multi-gigabyte
artifacts, which changes what a full run costs, so they are worth a deliberate
decision rather than being folded in here.
The biggest source of noise on a phone is the clock, not the code: a device
boosts early and sags as it heats, which is how two runs of identical code came
out 9% to 51% apart.

Android exposes PowerManager's fixed-performance mode over `cmd power`, and
vendors implement it as a hard frequency cap rather than a hint. On the S26
Ultra it takes every cluster from 3.19/3.40 GHz to about 1.98 GHz. Absolute
numbers drop, which is the trade: the same clock in every run is worth more
than a fast one.

`--pin-clocks` defaults to `auto` (pin where supported), with `on` to require
it and `off` to opt out. The driver reads the frequency back rather than
trusting the call, since not every vendor implements the HAL, and restores
normal clocks on exit, signal and crash alike so a capped device is never left
behind. The run records whether it was pinned and the comparator refuses to
diff a pinned run against an unpinned one.

No iOS equivalent exists; nothing in the public API pins the clock, so runs
there still depend on the thermal gate.
A fixed `--cooldown 420` is wrong in both directions: it burns seven minutes on
a phone that is already cold, and it is not enough after a heavy suite.

`--cooldown auto` polls `dumpsys` until the framework reports no throttling and
the battery temperature has stopped falling. It waits on a plateau rather than
an absolute threshold, because what counts as cool differs per device while "no
longer dropping" does not, and it requires two consecutive settled samples so a
flat reading mid-fall does not end the wait early. A 30s floor lets the heat of
building and installing dissipate; `--cooldown-max` stops a warm room or a
charging phone stalling the run forever. Charging is reported, since it keeps a
device warm.

On an idle S26 Ultra this releases after 30s where the fixed wait took 420s.

Android only: iOS exposes no thermal readout to the host, so `auto` falls back
to a fixed sleep there rather than pretending to measure.
The FastSAM case wedged a run. FastSAM pairs a 0.5 confidence threshold with an
IoU of 0.9, and NMS at 0.9 suppresses almost nothing, so on a textured
synthetic image nearly every candidate survives and each survivor materialises
a full 640x640 mask in JS. The worklet thread stalled with no output.

RF-DETR nano emits a fixed set of queries and runs NMS at 0.55, so its
post-processing is bounded whatever the input looks like. Verified on an
S26 Ultra: 215 ms median, 516 MB peak.

This is the input-dependence the README already warns about, met head on: a
model whose post-processing cost is unbounded in the number of detections does
not belong in a suite fed deliberately adversarial synthetic images.
The suite measured 17 hand-written cases against a registry that publishes
261 variants. Extending it by hand does not scale and does not stay correct:
a variant added to models.ts and not to the suite is a model that silently
never gets benchmarked, which is the failure this harness exists to prevent.

So the case list is derived rather than written. generate-variants.mjs
evaluates models.ts under Node's type stripping and emits every concrete
variant with its backend, precision, platforms and download size; suite.ts
joins each to a per-task driver. 163 variants are runnable on Android and 234
on iOS. Adding a model or a variant now needs no change here at all; adding a
task needs one driver; a task with no driver is reported as skipped rather
than dropped.

Each variant is measured three times, and each measurement starts with the
device at or below 35C. The gate is an absolute ceiling rather than the
plateau rule it replaces: a plateau answers "has it stopped cooling", which is
the right question for two runs on one device and the wrong one for four
devices, since a phone settling at 41C and one settling at 30C both pass it.
The wait lives on the host because Android exposes battery temperature to adb
and not to an app; iOS has no readout at all, so it falls back to thermalState
plus a fixed settle and records that it did, rather than implying 35C.

Repeats are not iterations. Iterations bound the noise inside one measurement;
repeats expose the run-to-run spread that thermal state and clock drift
produce, which on a phone is the larger of the two. The comparator folds
repeats to a median and widens each metric's noise floor by the across-repeat
range, so a thermal artefact stops reading as a regression.

Measurements are appended to a JSONL as they land and --resume skips what it
already holds: a whole-estate run is hours, and a report assembled only at the
end loses all of it to a crash on the last case. Models are deleted after a
variant's last repeat, so peak disk is one model rather than 119 GB, and a
variant over --max-bytes is recorded as skipped with its size instead of
failing halfway through its download.

Also adds an LLM driver (decode pinned to 64 tokens with EOS ignored, since
generation length is a property of the model and not of the runtime), a
summarize script for the table people actually read, and BENCHMARK_SPEC.md as
the protocol other devices run against.
Fixed-performance mode caps a Galaxy S26 Ultra from 3.19/3.40 GHz to about
1.98 GHz. That is the right trade for detecting a regression, where the same
clock in both runs is worth more than a fast one, and the wrong one for
publishing device numbers: every figure would understate the phone by roughly
the ratio of the clocks and describe a state its governor would never choose.

The flag stays, for A/B work against another build. What changes is which way
it points when nobody says. With the clock free the thermal gate is the only
control left over run-to-run drift, which is what the per-repeat gate is for.

Also resolves a contradiction in the spec, which told people to charge the
phone for a long run two sections after telling them charging stalls the gate.
@msluszniak

msluszniak commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Running this on your devices

git submodule update --init --recursive   # phonemis, CMake fails without it
yarn install
cd apps/benchmarks
yarn bench --platform android --suite quick --label v0.10.0 --max-temp-c 37
yarn bench --platform ios    --suite quick --label v0.10.0
yarn bench:summary <out>.jsonl

Send back the .jsonl. It is appended per measurement, so an interrupted run is still useful. --resume skips what is already recorded.

Notes:

  • Release builds only; the harness defaults to release and warns in debug.
  • Don't pin clocks, and don't run in Battery Saver. dumpsys battery unplug silently enables it on Samsung and cost me 3 bad measurements (66 -> 116 ms).
  • 37 C gate, not 35: an S26 Ultra idles at 35.4 C with the screen on, so 35 never opens. If a device of yours idles above 37, tell me and we raise it for all four.
  • Read Execute % (what to optimise) and Model MB (peak minus baseline).

Repeating a whole measurement was meant to expose the run-to-run spread that
thermal state and clock drift produce, on the assumption it was larger than the
spread inside one measurement. Measured on a Galaxy S26 Ultra it is not: 16.8%
within against 12.1% across on EfficientNet int8, 21.7% against 19.6% on fp32.
Twenty back-to-back iterations already show what three cold repeats show, at
roughly a third of the wall clock, because each repeat waits at the gate again.

The spread that remains belongs to the metric rather than to the sampling.
pipeline carries garbage collection in its TypeScript post-processing and sits
at 7-22%; the raw execute figure is ExecuTorch alone and sits near 2.5%. No
repeat count changes that, which is why the summary now leads with where the
time goes rather than with an error bar.

--repeats 3 still does the old thing for a model worth pricing precisely.

Also reports Model MB (peak minus the baseline taken just before the load) and
Execute %, after finding that absolute peak charges a model measured late for
the cases before it: every case shares one process, and its baseline crept from
292 MB to 350 MB over seven measurements.
Two ways a run could quietly measure under settings nobody chose.

expo run:* attaches to a bundler already listening rather than starting one,
and every EXPO_PUBLIC_BENCH_* value is inlined at transform time, so the
settings live in that process. A run asked for one repeat under a new label and
the app announced three repeats under the previous one. The driver now frees the
dev-server port first, and the app echoes the label and repeat count it actually
booted with so a mismatch is a hard stop rather than a footnote: a complete set
of plausible numbers taken under the wrong configuration is worse than a crash.

The device-side gate then ignored the ceiling it was given. It exists for iOS,
where nothing exposes a temperature, but it also catches any blip in the adb
tunnel — and on Android BenchProbe does report one. A lost /gate POST dropped a
measurement onto that path and it started at 36.6C under a 35C gate, with the
reading sitting in its own result. It now holds the ceiling wherever a
temperature is readable, and keeps the blind settle only where one is not.
A ceiling below the device's idle floor never opens. A Galaxy S26 Ultra sits at
35.4C doing nothing with the harness in the foreground, because the screen is
held on for the length of the run or Android freezes the app mid-suite. Under a
35C gate every measurement waited out its full 30 minute timeout and then
measured warm regardless, which would have turned a 52 model tier into a day of
waiting and made the timedOut flag meaningless by setting it on every row. 37C
opens immediately on an idle device and still holds after a model that heated
it. It stays an absolute number, since two phones gated differently are not
comparable, which is the whole reason for a fixed ceiling over a plateau rule.

--resume was also weaker than it read. The app asks the collector which
measurements exist and skips them, over the same adb tunnel that has already
been seen to drop a POST: a lost answer means everything is measured again and
the file quietly grows a second copy of every row. The collector now holds the
keys its output file already contains and refuses one it has, so the guarantee
belongs to the writer rather than to a request surviving.
Stopping the charge was meant to keep the device under a 35C gate, since the
cable adb needs holds it about 1.5C warmer. It worked, and it invalidated the
measurements: dumpsys battery unplug convinces the framework the device is on
battery, Samsung answers by engaging Battery Saver, and it sticks until the cell
reaches 90%. Nothing about it looks like throttling — the CPU's maximum
frequencies read normal — but EfficientNet int8 went from 66 ms to 116 ms with
nothing else changed. A phone in a power-saving mode no user asked for is not
the phone the numbers describe.

With the gate at 37C the trade is unnecessary: charging costs 1.5C and the
ceiling has room for it, so runs stay plugged in and the battery survives a long
suite. --unplug is still there for a device whose idle floor needs it, and now
clears low_power rather than leaving it set.

The run also refuses to start in Battery Saver rather than trusting that nobody
turned it on, because the harness itself turned it on once and the resulting
numbers looked entirely plausible.
@msluszniak

msluszniak commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Android results: SM-S948B

Release build, ET 1.4.1, quick tier, 52 variants. 20 iterations after 3 warmups, gated to 37C before each measurement, clocks free.

Variant Backend Precision Size MB Load ms Inference ms Spread % Execute ms JS ms Execute % Model MB Proc peak MB Retained MB Runs Status
classification/efficientnet-v2-s-xnnpack-int8 xnnpack int8 23 28 52.23 0 47.66 4.57 91 59 238 21 1
classification/efficientnet-v2-s-xnnpack-fp32 xnnpack fp32 86 49 110.03 0 102.36 7.67 93 109 316 1
style-transfer/candy-xnnpack-int8 xnnpack int8 2 36 382.46 0 386.11 0.00 100 909 1106 19 1
style-transfer/candy-xnnpack-fp32 xnnpack fp32 7 49 630.21 0 633.64 0.00 100 1061 1278 18 1
style-transfer/mosaic-xnnpack-int8 xnnpack int8 2 42 375.49 0 379.52 0.00 100 891 1120 1
style-transfer/mosaic-xnnpack-fp32 xnnpack fp32 7 73 869.54 0 843.01 26.53 97 1044 1275 1
style-transfer/rain-princess-xnnpack-int8 xnnpack int8 2 55 484.49 0 486.48 0.00 100 894 1118 1
style-transfer/rain-princess-xnnpack-fp32 xnnpack fp32 7 69 872.04 0 870.31 1.73 100 1054 1283 13 1
style-transfer/udnie-xnnpack-int8 xnnpack int8 2 55 496.70 0 499.54 0.00 100 892 1125 1
style-transfer/udnie-xnnpack-fp32 xnnpack fp32 7 73 838.06 0 820.15 17.91 98 1054 1275 12 1
semantic-segmentation/selfie-segmentation-xnnpack-fp32 xnnpack fp32 0 2 11.71 0 8.15 3.55 70 14 226 7 1
semantic-segmentation/selfie-segmentation-landscape-xnnpack-fp32 xnnpack fp32 0 1 4.29 0 2.71 1.58 63 12 224 10 1
semantic-segmentation/lraspp-mobilenet-v3-large-xnnpack-int8 xnnpack int8 4 32 37.73 0 27.23 10.50 72 166 391 1
semantic-segmentation/lraspp-mobilenet-v3-large-xnnpack-fp32 xnnpack fp32 13 37 49.42 0 39.00 10.42 79 184 416 1
semantic-segmentation/deeplab-v3-resnet50-xnnpack-int8 xnnpack int8 42 86 149.33 0 142.33 7.00 95 243 472 1
semantic-segmentation/deeplab-v3-resnet50-xnnpack-fp32 xnnpack fp32 159 166 532.51 0 525.16 7.35 99 438 672 7 1
semantic-segmentation/deeplab-v3-resnet101-xnnpack-int8 xnnpack int8 62 113 217.19 0 202.46 14.73 93 404 645 1
semantic-segmentation/deeplab-v3-resnet101-xnnpack-fp32 xnnpack fp32 234 235 818.18 0 822.30 0.00 100 583 808 1
semantic-segmentation/deeplab-v3-mobilenet-v3-large-xnnpack-int8 xnnpack int8 11 49 30.69 0 23.76 6.93 77 178 403 1
semantic-segmentation/deeplab-v3-mobilenet-v3-large-xnnpack-fp32 xnnpack fp32 44 51 99.70 0 77.26 22.45 77 204 423 6 1
semantic-segmentation/fcn-resnet50-xnnpack-int8 xnnpack int8 36 80 123.82 0 118.07 5.76 95 218 444 1
semantic-segmentation/fcn-resnet50-xnnpack-fp32 xnnpack fp32 132 133 468.76 0 465.27 3.49 99 414 640 7 1
semantic-segmentation/fcn-resnet101-xnnpack-int8 xnnpack int8 55 116 197.80 0 189.63 8.17 96 312 546 1
semantic-segmentation/fcn-resnet101-xnnpack-fp32 xnnpack fp32 208 197 701.34 0 691.55 9.79 99 479 711 1
keypoint-detection/blazeface-xnnpack-fp32 xnnpack fp32 1 1 4.56 0 4.57 0.00 100 28 243 8 1
keypoint-detection/yolo26-pose-size-384-xnnpack-fp32 xnnpack fp32 12 13 22.70 0 24.15 0.00 100 35 262 1
keypoint-detection/yolo26-pose-size-512-xnnpack-fp32 xnnpack fp32 12 11 37.89 0 37.95 0.00 100 53 281 1
keypoint-detection/yolo26-pose-size-640-xnnpack-fp32 xnnpack fp32 12 12 55.24 0 51.88 3.36 94 75 298 1
keypoint-detection/rfdetr-keypoint-xnnpack-fp32 xnnpack fp32 146 152 974.52 0 969.49 5.04 99 797 1028 1
voice-activity-detection/fsmn-vad-xnnpack-fp32 xnnpack fp32 2 2 6.48 0 6.92 0.00 100 20 251 11 1
text-embeddings/all-minilm-l6-v2-xnnpack-fp32 xnnpack fp32 91 59 4.43 0 4.39 0.03 99 103 337 9 1
text-embeddings/all-minilm-l6-v2-vulkan-fp16 vulkan fp16 46 52 3.85 0 5.66 0.00 100 58 322 1
text-embeddings/all-mpnet-base-v2-xnnpack-fp32 xnnpack fp32 437 345 26.12 0 25.36 0.77 97 467 713 12 1
text-embeddings/all-mpnet-base-v2-vulkan-fp16 vulkan fp16 219 307 17.43 0 18.34 0.00 100 291 541 18 1
text-embeddings/all-mpnet-base-v2-vulkan-int8 vulkan int8 134 181 22.31 0 22.37 0.00 100 194 448 15 1
text-embeddings/multi-qa-minilm-l6-cos-v1-xnnpack-fp32 xnnpack fp32 91 87 8.19 0 8.04 0.15 98 134 397 7 1
text-embeddings/multi-qa-minilm-l6-cos-v1-vulkan-fp16 vulkan fp16 46 72 3.95 0 3.94 0.00 100 87 359 1
text-embeddings/multi-qa-mpnet-base-dot-v1-xnnpack-fp32 xnnpack fp32 437 410 28.83 0 28.23 0.60 98 495 741 12 1
text-embeddings/multi-qa-mpnet-base-dot-v1-vulkan-fp16 vulkan fp16 219 300 18.02 0 18.16 0.00 100 318 570 20 1
text-embeddings/multi-qa-mpnet-base-dot-v1-vulkan-int8 vulkan int8 134 157 22.56 0 22.84 0.00 100 224 479 15 1
text-embeddings/paraphrase-multilingual-minilm-l12-v2-xnnpack-8-da4-w xnnpack 8da4w 414 727 8.52 0 8.70 0.00 100 557 815 86 1
text-embeddings/paraphrase-multilingual-minilm-l12-v2-xnnpack-fp32 xnnpack fp32 487 700 13.08 0 13.22 0.00 100 597 878 70 1
text-embeddings/paraphrase-multilingual-minilm-l12-v2-vulkan-fp16 vulkan fp16 252 863 7.40 0 8.53 0.00 100 386 674 101 1
text-embeddings/distiluse-base-multilingual-cased-v2-xnnpack-8-da4-w xnnpack 8da4w 396 317 8.61 0 9.32 0.00 100 393 681 1
text-embeddings/distiluse-base-multilingual-cased-v2-xnnpack-fp32 xnnpack fp32 544 431 17.63 0 17.66 0.00 100 535 813 10 1
text-embeddings/distiluse-base-multilingual-cased-v2-vulkan-fp16 vulkan fp16 273 419 9.92 0 10.53 0.00 100 270 571 1
text-embeddings/clip-vit-base-patch32-text-xnnpack-fp32 xnnpack fp32 256 264 24.81 0 23.85 0.96 96 237 543 1
text-embeddings/clip-vit-base-patch32-text-vulkan-fp16 vulkan fp16 129 244 12.32 0 12.14 0.19 98 147 434 1
text-embeddings/lfm2-5-embedding-350-m-xnnpack-8-da4-w xnnpack 8da4w 580 825 63.22 0 63.37 0.00 100 642 923 6 1
ocr/paddle-ppocrv6-small-xnnpack xnnpack default 24 130 161.22 0 168.95 0.00 100 344 624 22 1
ocr/paddle-ppocrv6-small-xnnpack-fp32 xnnpack fp32 31 40 210.41 0 198.55 11.85 94 371 674 1
ocr/paddle-ppocrv6-small-vulkan vulkan default 26 32 29.11 0 24.85 4.26 85 256 549 8 1

The suite answers "how fast". This answers "what did the model actually
compute on this device", which is a question a host cannot answer for
#1406: Core ML fp16 draws shifted
masks and boxes on an iPhone's ANE, and a Mac cannot reproduce it because
its own ANE compiler rejects the model and Core ML falls back to CPU/GPU
without saying so. Every host check therefore passed.

The host writes the input as a raw tensor and serves it; the device feeds
those bytes to execute untouched and sends the output tensors back as
base64 of their own buffers. Neither side decodes an image or rounds
through decimal text, so a difference in the output cannot be a
difference in preprocessing or in serialisation - which matters when the
signal being measured is a fraction of a pixel.

iOS needs the ATS exception and a local-network usage string to reach a
collector on the LAN at all; Android already had usesCleartextTraffic.
@msluszniak msluszniak changed the title [RNE Rewrite] test: add an on-device performance regression harness [RNE Rewrite] feat: benchmark harness for every registry variant Sep 4, 2026
Two defects made the execute share unusable, and both are fixed here.

The share was derived from a standalone replay that sized every tensor at
its schema maximum. For a model with a dynamic dimension that is not the
work the pipeline did: a text embedder declaring 510 tokens was replayed
at 510 while the pipeline ran the ~75 of the benchmark text, so execute
exceeded the pipeline and the column was blanked for 21 of 52 variants.
OCR was worse, since the pipeline calls `recognize` once per detected box
and the replay called it once. `getExecutionProfile()` accumulates time
inside `Model::execute` instead, so the figure covers exactly the shapes
and call counts the pipeline used, and the share is always defined. The
API is new public surface because a task pipeline owns its `Model`
privately and callers had no way to ask.

The runs were also debug builds. ExecuTorch is a prebuilt release library
so `execute` was unaffected, but the library's own C++ compiles
unoptimised and JS is served as a dev bundle, inflating everything around
the model by roughly an order of magnitude. That does not merely add
noise, it inverts the conclusion: EfficientNet measured 29% ExecuTorch in
debug and 91% in release. Release is now the default, debug warns, and
the build type is recorded in the report.

Drops the ET 1.3.1 baseline: schema 1, debug, no thermal gate, and no
execution data, so nothing the harness produces can be compared to it.
The spec is the contract other devices follow, and it did not say which
build type to use. A debug run does not merely read slow, it inverts the
verdict: EfficientNet is 29% ExecuTorch in debug and 91% in release.
…pass

Cuts about 4,000 lines from the harness without losing a measurement.

`src/variants.generated.ts` was 3,171 checked-in lines produced by a
script, plus a CI check to catch it going stale. The registry is a plain
nested object once imported and `variants()` only spreads a map, so the
list is a walk, not something that needs generating. Everything in the
generated file was derivable in-process except the download sizes, which
need a network round trip, so those alone stay cached and the generator
shrinks to the script that measures them.

The replay pass goes with it. It loaded each `.pte` separately and sized
tensors at the schema maximum, which is not what the pipeline ran: it is
what made the execute share unusable for 21 of 52 variants and produced
a 510-token forward to divide by a 75-token pipeline. The in-band
profiler measures the real work, so the replay answered no question worth
the code.

The RF-DETR ANE probe moves out to its own branch. It rides on the
collector but it is a device-only correctness investigation for #1406,
not part of a benchmark harness.
Both functions wrap a native JSI call, so the repo's convention requires
the directive or they cannot be serialized onto a worklet runtime. That
is not hypothetical here: the profile is read around a measurement loop
that runs inside one, which is also why the C++ side takes a mutex.

Records the three new exports in the API surface snapshot.
The execution profile was zeroed before the warmups and then divided by
iterations + warmup, so "Execute ms" was a mean over all 23 passes while
"Inference ms" was the median of the 20 timed ones. A warmup pays one-off
costs the timed median never sees, Vulkan shader compilation above all,
so ExecuTorch time came out larger than the pipeline time that contains
it on 23 of 52 variants - MiniLM on Vulkan fp16 by 1.47x, which works out
to roughly 18 ms per warmup pass.

The profile is now zeroed between the warmups and the timed loop, inside
the worklet, so the tally covers exactly the iterations the durations
cover. That works because resetExecutionProfile carries the 'worklet'
directive.

summarize.mjs compared that per-iteration mean against the pipeline
median, mixing two statistics; it now compares mean to mean. It also
clamped the share to 100% and floored JS ms at 0, which is how a
physically impossible reading rendered as a tidy "100% / 0.00 ms" and
went unnoticed. Both clamps are gone: a share over 100% is a measurement
fault and has to be visible.

Verified on the first re-run rows: mosaic-int8 101% -> 99%, and every
row now sits under 100%.
@msluszniak

Copy link
Copy Markdown
Member Author

Pushed a0fb58b: the Execute ms column in the results comment above is wrong, re-running now.

The profile was zeroed before the warmups and divided by iterations + warmup, so Execute ms was a mean over all 23 passes while Inference ms was the median of the 20 timed ones. Warmups carry one-off costs the median never sees (Vulkan shader compilation mostly), so ExecuTorch time came out larger than the pipeline time containing it on 23 of 52 variants, MiniLM Vulkan fp16 by 1.47x.

Fix: zero the profile between the warmups and the timed loop, inside the worklet. summarize.mjs also compared that mean against the pipeline median, and clamped the share to 100% and JS ms to 0, which is how an impossible reading rendered as a clean "100% / 0.00" and went unnoticed. Both clamps removed.

Inference ms and the memory columns are unaffected. Table gets updated when the re-run lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Related to all issues and tasks focused on improving performance test Issue and PR related to tests or testing infrastructure

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add performance regression tests

1 participant