Skip to content

Commit 35e7b02

Browse files
committed
test: track device performance regressions
1 parent 2af1d5a commit 35e7b02

4 files changed

Lines changed: 359 additions & 2 deletions

File tree

.github/workflows/host-validation.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,18 @@ jobs:
6060
done < <(git ls-files '*.sh' | sort)
6161
6262
- name: Validate tracked JSON artifacts
63-
run: python -W error tests/validate_tracked_json.py
63+
env:
64+
PERFORMANCE_BASELINE: >-
65+
${{ github.event_name == 'pull_request' &&
66+
github.event.pull_request.base.sha || github.event.before }}
67+
shell: bash
68+
run: |
69+
args=()
70+
if [[ -n "$PERFORMANCE_BASELINE" &&
71+
"$PERFORMANCE_BASELINE" != "0000000000000000000000000000000000000000" ]]; then
72+
args+=(--performance-baseline "$PERFORMANCE_BASELINE")
73+
fi
74+
python -W error tests/validate_tracked_json.py "${args[@]}"
6475
6576
- name: Validate exact TiGrIS core pins
6677
run: python -W error scripts/check_core_versions.py --manifest-only

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,21 @@ python scripts/results.py results/raw/ -o results/summary.json
4040

4141
Accuracy validation is part of the pipeline: `validate_accuracy.py` compares device outputs against the ORT reference, so a run is rejected if the numbers do not match within tolerance.
4242

43+
When a committed hardware summary changes, CI also compares it with the pull
44+
request base (or previous pushed commit). The comparison runs entirely from the
45+
two versioned snapshots: it reports every latency, all-in working-memory,
46+
model/plan-size, and firmware-size delta, and rejects material regressions or a
47+
formerly successful cell becoming unavailable. It does not time shared CI
48+
runners or pretend that hardware is continuously available. The materiality
49+
limits are 5% for latency; 2% plus 128 bytes for working memory; 1% plus 256
50+
bytes for model/plan artifacts; and 1% plus 2 KiB for firmware. A regression
51+
must exceed both the percentage and absolute limit. Review the same gate locally
52+
with:
53+
54+
```bash
55+
python tests/validate_tracked_json.py --performance-baseline <git-revision>
56+
```
57+
4358
## Common dependencies
4459

4560
- Host: Python 3.10+, `onnx`, `onnxruntime`, `tigris-ml`, plus whatever a specific suite needs

tests/test_provenance.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,18 @@
1616
git_tracked_paths,
1717
validate_provenance,
1818
)
19-
from validate_tracked_json import validate_readme_results
19+
from validate_tracked_json import (
20+
compare_performance_summaries,
21+
validate_readme_results,
22+
)
2023

2124

2225
ROOT = Path(__file__).resolve().parents[1]
2326
PROVENANCE_PATH = ROOT / "cortex-m-deployability/results/provenance.json"
2427
RESULTS_PATH = ROOT / "cortex-m-deployability/scripts/results.py"
2528
README_PATH = ROOT / "cortex-m-deployability/README.md"
2629
SUMMARY_PATH = ROOT / "cortex-m-deployability/results/summary.json"
30+
ESP_SUMMARY_PATH = ROOT / "tflm-esp32s3/results/summary.json"
2731
RESULTS_SPEC = importlib.util.spec_from_file_location(
2832
"cortex_results", RESULTS_PATH)
2933
assert RESULTS_SPEC and RESULTS_SPEC.loader
@@ -138,6 +142,102 @@ def test_mutated_summary_result_is_rejected(self) -> None:
138142
any("| TS | 2.56 ms" in error for error in errors), errors)
139143

140144

145+
class PerformanceRegressionContractTest(unittest.TestCase):
146+
def setUp(self) -> None:
147+
self.cortex = json.loads(SUMMARY_PATH.read_text())
148+
self.esp = json.loads(ESP_SUMMARY_PATH.read_text())
149+
150+
@staticmethod
151+
def cell(document: dict, log_file: str) -> dict:
152+
return next(
153+
config for config in document["configs"]
154+
if config["log_file"] == log_file)
155+
156+
def test_unchanged_snapshots_have_no_deltas(self) -> None:
157+
for suite, summary in (("cortex-m", self.cortex), ("esp32-s3", self.esp)):
158+
observations, errors = compare_performance_summaries(
159+
summary, copy.deepcopy(summary), suite)
160+
self.assertEqual(observations, [])
161+
self.assertEqual(errors, [])
162+
163+
def test_material_cycle_regression_is_rejected(self) -> None:
164+
current = copy.deepcopy(self.cortex)
165+
cell = self.cell(current, "h753_ds_cnn_cmsis_nn.log")
166+
cell["latency_median_cycles"] = int(
167+
cell["latency_median_cycles"] * 1.051)
168+
observations, errors = compare_performance_summaries(
169+
current, self.cortex, "cortex-m")
170+
self.assertTrue(any("median latency" in item for item in observations))
171+
self.assertTrue(any("exceeds +5%" in error for error in errors), errors)
172+
173+
def test_timing_noise_below_limit_is_reported_but_allowed(self) -> None:
174+
current = copy.deepcopy(self.esp)
175+
cell = self.cell(current, "tigris_i8_espnn.log")
176+
cell["latency_mean_ms"] *= 1.049
177+
observations, errors = compare_performance_summaries(
178+
current, self.esp, "esp32-s3")
179+
self.assertTrue(any("mean latency" in item for item in observations))
180+
self.assertEqual(errors, [])
181+
182+
def test_material_working_memory_growth_is_rejected(self) -> None:
183+
current = copy.deepcopy(self.cortex)
184+
cell = self.cell(current, "h753_ad_cmsis_nn.log")
185+
cell["sram_peak_bytes"] += 129
186+
_, errors = compare_performance_summaries(
187+
current, self.cortex, "cortex-m")
188+
self.assertTrue(
189+
any("all-in working memory" in error for error in errors), errors)
190+
191+
def test_small_absolute_memory_growth_is_reported_but_allowed(self) -> None:
192+
current = copy.deepcopy(self.cortex)
193+
cell = self.cell(current, "h753_ad_cmsis_nn.log")
194+
cell["sram_peak_bytes"] += 64
195+
observations, errors = compare_performance_summaries(
196+
current, self.cortex, "cortex-m")
197+
self.assertTrue(
198+
any("all-in working memory" in item for item in observations))
199+
self.assertEqual(errors, [])
200+
201+
def test_exact_firmware_growth_is_rejected(self) -> None:
202+
current = copy.deepcopy(self.cortex)
203+
provenance = current["provenance"]["cells"]
204+
artifact = provenance["h753_ds_cnn_cmsis_nn.log"]["artifacts"][
205+
"firmware"]
206+
artifact["size_bytes"] += 4096
207+
_, errors = compare_performance_summaries(
208+
current, self.cortex, "cortex-m")
209+
self.assertTrue(
210+
any("firmware artifact" in error for error in errors), errors)
211+
212+
def test_success_to_failure_is_rejected(self) -> None:
213+
current = copy.deepcopy(self.esp)
214+
self.cell(current, "tigris_i8_espnn.log")["status"] = "FAILED"
215+
_, errors = compare_performance_summaries(
216+
current, self.esp, "esp32-s3")
217+
self.assertTrue(any("status regressed" in error for error in errors), errors)
218+
219+
def test_removed_baseline_cell_cannot_hide_a_regression(self) -> None:
220+
current = copy.deepcopy(self.cortex)
221+
current["configs"] = [
222+
cell for cell in current["configs"]
223+
if cell["log_file"] != "h753_ds_cnn_cmsis_nn.log"]
224+
_, errors = compare_performance_summaries(
225+
current, self.cortex, "cortex-m")
226+
self.assertTrue(
227+
any("removed baseline cell" in error for error in errors), errors)
228+
229+
def test_removed_baseline_metric_cannot_hide_a_regression(self) -> None:
230+
current = copy.deepcopy(self.esp)
231+
del self.cell(current, "tigris_i8_espnn.log")["latency_mean_ms"]
232+
_, errors = compare_performance_summaries(
233+
current, self.esp, "esp32-s3")
234+
self.assertTrue(
235+
any("removed baseline metric mean latency" in error
236+
for error in errors),
237+
errors,
238+
)
239+
240+
141241
def valid_capture_provenance() -> dict:
142242
revision = "1" * 40
143243
digest = "2" * 64

0 commit comments

Comments
 (0)