Skip to content

[RNE Rewrite] test: add an on-device performance regression harness - #1363

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

[RNE Rewrite] test: add an on-device performance regression harness#1363
msluszniak wants to merge 12 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 runs the task pipelines against deterministic synthetic inputs and reports model 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 the ExecuTorch 1.3.1 to 1.4.1 bump: run the suite on 1.3.1, bump, run it again on the same device, compare.

Three design points worth a look:

  • A raw-execute pass isolates ExecuTorch from the pipeline. A task timing folds 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. Methods whose schema cannot be pinned to concrete shapes are reported as skipped, with the reason.
  • 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. modules/bench-probe is a local Expo module reading task_vm_info.phys_footprint on iOS and total PSS on Android, both of which count the resident pages of a memory mapped .pte that the native heap counters miss.
  • Inputs are pure functions of their parameters, so two runs feed byte identical data. 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. A delta inside the run's own interquartile range is reported but not failed.

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 --label et-1.3.1     # builds, launches, collects
yarn bench --platform android --label et-1.4.1     # after the bump
yarn bench:compare results/et-1.3.1-*.json results/et-1.4.1-*.json

yarn bench starts a collector, sets the app's EXPO_PUBLIC_BENCH_* variables, builds and launches the app, and writes results/<label>-<platform>-<device>.json. It sets up adb reverse on Android and binds to the host LAN address for an iOS device. --suite full adds the larger models, --only <id> runs a single case, --no-launch waits for an app started by hand.

The full quick suite was run on a Galaxy S26 Ultra (SM-S948B, Android 16) with all six cases reporting. A full suite run covering Whisper and Supertonic was in progress at the time of writing.

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. The CoreML classification case is in the suite but unexercised, since CoreML programs fail to encode on the Simulator and need a physical device.

Two things the device runs surfaced that are worth knowing when reading a report:

  • 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, and the raw number comes out several times larger. Each is comparable against itself across runs, which is all the comparator asks. The resolved shapes are recorded per method.
  • Style transfer peaks at roughly 1257 MB against a 372 MB loaded footprint on the S26 Ultra. That is the first measurement rather than a regression against anything, but it may be worth a look independent of the bump.

The suite is triggered by hand rather than run in CI. A host side benchmark on a CI runner would exercise none of the backends a bump actually changes, and its noise floor is wider than most regressions.

Rebased onto rne-rewrite now that the feature work has landed there. Two things came out of that:

  • The harness had drifted off the library's public surface. The model schema contract moved behind a schema namespace and its specs gained a dimension type parameter ([RNE Rewrite] refactor!: add better model schema contract and validation logic #1327), and the task constants are re-exported flat rather than under a constants object. Both are tracked now, and apps/benchmarks typechecks against the current surface again.
  • Three task families that landed after this branch was cut had no coverage, so the suite now benchmarks FastSAM instance segmentation, PP-OCRv6 OCR and Kokoro text-to-speech. All three sit in the full tier. FastSAM and PP-OCRv6 time their worklet entry point; Kokoro streams chunks from the RN thread and carries the same caveat as Supertonic.

That brings the suite to 16 cases, 7 quick and 9 full.

LLM and SDXS text-to-image are still uncovered. Both pull multi-gigabyte artifacts, which materially changes what a full run costs on a device, so they seemed worth a deliberate decision rather than being folded into a rebase.

Added a mechanism to hold the clock still, since the clock rather than the code is the largest source of noise here. Android exposes PowerManager's fixed-performance mode over cmd power, and vendors implement it as a hard frequency cap: on the S26 Ultra it takes every cluster from 3.19/3.40 GHz down to about 1.98 GHz, measured by reading scaling_max_freq back. Absolute numbers come out lower than a boosted run, which is the trade being made deliberately, because the same clock in every run is worth more than a fast one.

--pin-clocks defaults to auto, pinning where the device supports it and carrying on where it does not; on fails the run rather than measuring unpinned, off opts out. The driver verifies by reading the frequency back rather than trusting the call, because the command exists on devices whose HAL ignores it. Normal clocks are restored on exit, Ctrl-C and crash alike, so a run can never leave a phone capped at 2 GHz and quietly poison every later measurement taken on it. The run records whether it was pinned, and the comparator refuses to diff a pinned run against an unpinned one for the same reason it refuses across throttling.

There is no iOS equivalent. Nothing in the public API pins or caps the clock, so runs there still depend on the thermal gate and on cooling between suites.

The cooldown is adaptive too. --cooldown auto polls dumpsys until the framework reports no throttling and the battery temperature has stopped falling, rather than sleeping a fixed guess that is too long on a cold phone and too short after a heavy suite. It waits on a plateau instead of an absolute threshold, since 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 release it early. There is a 30s floor so the heat of building and installing dissipates first, and a --cooldown-max ceiling, 900s by default, so a warm room or a charging phone cannot stall a run indefinitely. Charging is called out when detected because it keeps the device warm. On an idle S26 Ultra it releases after 30s where the fixed wait took 420s.

This is Android-only as well: iOS exposes no thermal readout to the host, so auto falls back to a fixed sleep there rather than pretending to measure something.

@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 from 3d4f42a to 54f6c9b Compare August 26, 2026 18:01
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.
@msluszniak
msluszniak force-pushed the @ms/perf-benchmarks branch from 383f54e to df61e4d Compare August 27, 2026 10:44
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