[RNE Rewrite] test: add an on-device performance regression harness - #1363
Draft
msluszniak wants to merge 12 commits into
Draft
[RNE Rewrite] test: add an on-device performance regression harness#1363msluszniak wants to merge 12 commits into
msluszniak wants to merge 12 commits into
Conversation
msluszniak
force-pushed
the
@ms/perf-benchmarks
branch
from
August 26, 2026 18:01
3d4f42a to
54f6c9b
Compare
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
force-pushed
the
@ms/perf-benchmarks
branch
from
August 27, 2026 10:44
383f54e to
df61e4d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
model.executetogether with preprocessing and post-processing, which are TypeScript and unaffected by a bump. The pass loads the.pteon its own and sizes its tensors frommodel.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./proc/self/smapsand costs milliseconds, which would otherwise land in the inference numbers.modules/bench-probeis a local Expo module readingtask_vm_info.phys_footprinton iOS and total PSS on Android, both of which count the resident pages of a memory mapped.ptethat the native heap counters miss.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?
Type of change
Tested on
Testing instructions
yarn benchstarts a collector, sets the app'sEXPO_PUBLIC_BENCH_*variables, builds and launches the app, and writesresults/<label>-<platform>-<device>.json. It sets upadb reverseon Android and binds to the host LAN address for an iOS device.--suite fulladds the larger models,--only <id>runs a single case,--no-launchwaits 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
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>andpipeline.medianare 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.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-rewritenow that the feature work has landed there. Two things came out of that:schemanamespace 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 aconstantsobject. Both are tracked now, andapps/benchmarkstypechecks against the current surface again.fulltier. 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 overcmd 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 readingscaling_max_freqback. 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-clocksdefaults toauto, pinning where the device supports it and carrying on where it does not;onfails the run rather than measuring unpinned,offopts 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 autopollsdumpsysuntil 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-maxceiling, 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
autofalls back to a fixed sleep there rather than pretending to measure something.