Skip to content

Commit 2e8bc4c

Browse files
zackeesclaude
andauthored
feat(bench): #205 AC#5 enforce <= 50 ms warm threshold in bench-fastled-examples (#228)
AC#5 requires the warm-cache library-selection step to stay within +50 ms of current fbuild on the FastLED examples matrix. The bench-fastled-examples binary now accepts --max-warm-ms <f64>; when set, it exits 1 (after printing the full table) if any example's warm timing exceeds the threshold, listing every breach in both the error message and the JSON report. The bench-205 fastled-examples workflow job is no longer workflow_dispatch- only — it runs on every PR touching the resolver crates, the bench dir, or the workflow itself, and passes --max-warm-ms 50. The 50 ms cap gives ~25x headroom over current ~1-2 ms warm timings on developer hardware (and ~10 ms on CI), absorbing runner noise without false positives. Refs #205 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0240d9e commit 2e8bc4c

3 files changed

Lines changed: 120 additions & 19 deletions

File tree

.github/workflows/bench-205.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ on:
77
- 'crates/fbuild-header-scan/**'
88
- 'crates/fbuild-library-select/**'
99
- 'crates/fbuild-test-support/src/mini_framework.rs'
10+
- 'bench/fastled-examples/**'
1011
- '.github/workflows/bench-205.yml'
1112

1213
env:
@@ -57,8 +58,7 @@ jobs:
5758
retention-days: 14
5859

5960
fastled-examples:
60-
name: Bench (fastled-examples — workflow_dispatch only)
61-
if: github.event_name == 'workflow_dispatch'
61+
name: Bench (fastled-examples — AC#5 ≤ 50 ms warm)
6262
runs-on: ubuntu-latest
6363
timeout-minutes: 30
6464
steps:
@@ -81,12 +81,13 @@ jobs:
8181
# bench/fastled-examples/README.md when retaking baseline.
8282
ref: "3.10.3"
8383

84-
- name: Run bench-fastled-examples
84+
- name: Run bench-fastled-examples (AC#5 enforcement, threshold 50 ms)
8585
env:
8686
FASTLED_DIR: ${{ github.workspace }}/external/fastled
8787
run: |
8888
mkdir -p bench/fastled-examples
8989
soldr cargo run --release -p fbuild-bench-fastled-examples -- \
90+
--max-warm-ms 50 \
9091
--json bench/fastled-examples/report.json | tee bench/fastled-examples/report.md
9192
9293
- name: Upload report

bench/fastled-examples/README.md

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,36 @@ FASTLED_DIR=/path/to/fastled \
3939
FASTLED_DIR=/path/to/fastled \
4040
uv run soldr cargo run --release -p fbuild-bench-fastled-examples \
4141
-- --json bench/fastled-examples/report.json
42+
43+
# Enforce AC#5 (≤ 50 ms warm per example). Exits 1 on breach.
44+
FASTLED_DIR=/path/to/fastled \
45+
uv run soldr cargo run --release -p fbuild-bench-fastled-examples \
46+
-- --max-warm-ms 50
4247
```
4348

4449
If any example fails to measure (missing sketch, KvStore error, warm
4550
miss) the binary exits non-zero rather than skipping the row. CI must
4651
treat a partial matrix as a failure, not a pass.
4752

53+
## AC#5 enforcement
54+
55+
`--max-warm-ms <f64>` is the CI gate for AC#5. When provided, the
56+
binary prints the full results table, then exits 1 if any example's
57+
warm timing exceeds the threshold; the error message lists every
58+
breach. The threshold is also echoed in the report header (`- Warm
59+
threshold: 50.00 ms`) and recorded under the top-level `max_warm_ms`
60+
key in the JSON report, with the breach list under `breaches`.
61+
62+
The `fastled-examples` job in `.github/workflows/bench-205.yml` passes
63+
`--max-warm-ms 50`. The 50 ms value gives roughly 25× headroom over
64+
the current ~1-2 ms warm timings on developer hardware (and ~10 ms on
65+
CI runners), comfortably absorbing runner noise without false
66+
positives. The job runs on every PR whose changes touch
67+
`crates/fbuild-library-select/**`, `crates/fbuild-header-scan/**`,
68+
`bench/fastled-examples/**`, the shared `MiniFramework` fixture, or
69+
the workflow itself — gating those PRs on the resolver staying within
70+
the AC#5 envelope.
71+
4872
## Sample numbers
4973

5074
Captured 2026-05-10 on Windows / Ryzen workstation, FastLED `main`,
@@ -77,16 +101,15 @@ The current set spans:
77101

78102
## CI
79103

80-
The `fastled-examples` job in `.github/workflows/bench-205.yml` is
81-
`workflow_dispatch`-only because it requires a FastLED checkout. CI
82-
checks out FastLED at a pinned release tag (currently `3.10.3`) so
83-
measurements are reproducible, then runs the bench and uploads the JSON
84-
report as an artifact. Bumping the pin is a deliberate baseline event —
85-
update both the workflow `ref:` and the sample-numbers table above in
86-
lockstep.
87-
88-
There is no automatic CI gate on the warm timings yet — first capture a
89-
stable cross-runner baseline, then a follow-up adds the threshold gate.
104+
The `fastled-examples` job in `.github/workflows/bench-205.yml` runs on
105+
every PR that touches the resolver crates, this bench, or the workflow
106+
itself. CI checks out FastLED at a pinned release tag (currently
107+
`3.10.3`) so measurements are reproducible, then runs the bench with
108+
`--max-warm-ms 50` and uploads the JSON report as an artifact. The
109+
threshold is the AC#5 enforcement gate (see "AC#5 enforcement" above);
110+
a breach fails the job and blocks the PR. Bumping the FastLED pin is a
111+
deliberate baseline event — update both the workflow `ref:` and the
112+
sample-numbers table above in lockstep.
90113

91114
## Cross-links
92115

bench/fastled-examples/src/main.rs

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,17 @@
3333
//! ## CLI
3434
//!
3535
//! ```text
36-
//! bench-fastled-examples [--json <path>]
36+
//! bench-fastled-examples [--json <path>] [--max-warm-ms <f64>]
3737
//! ```
3838
//!
3939
//! `--json <path>` writes a structured report alongside the stdout table
4040
//! for diffing in PR comments.
4141
//!
42+
//! `--max-warm-ms <f64>` enforces AC#5: each example whose warm timing
43+
//! exceeds the threshold causes the binary to exit 1 after the table is
44+
//! printed. Wired in CI at 50 ms (`~25x` headroom over current ~1-2 ms
45+
//! warm numbers — absorbs runner noise without false positives).
46+
//!
4247
//! Refs: #205 Phase 7 (AC#5), #218.
4348
4449
use std::path::{Path, PathBuf};
@@ -86,7 +91,8 @@ fn main() {
8691

8792
fn run() -> Result<(), Box<dyn std::error::Error>> {
8893
let args: Vec<String> = std::env::args().collect();
89-
let json_out = parse_json_flag(&args);
94+
let json_out = parse_path_flag(&args, "--json");
95+
let max_warm_ms = parse_f64_flag(&args, "--max-warm-ms")?;
9096

9197
let fastled_dir = resolve_fastled_dir()?;
9298
let fastled_src = fastled_dir.join("src");
@@ -109,6 +115,10 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
109115
println!();
110116
println!("- FastLED: `{}`", fastled_dir.display());
111117
println!("- Framework lib set: {} synthetic libs", libraries.len());
118+
match max_warm_ms {
119+
Some(t) => println!("- Warm threshold: {t:.2} ms"),
120+
None => println!("- Warm threshold: none"),
121+
}
112122
println!();
113123
println!("| example | cold (ms) | warm (ms) | speedup | selected | hit |");
114124
println!("|---|---:|---:|---:|---:|---|");
@@ -138,12 +148,38 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
138148
rows.push(row);
139149
}
140150

151+
// Collect breaches (after the table prints, so the row data is always visible).
152+
let breaches: Vec<(String, f64)> = match max_warm_ms {
153+
Some(t) => rows
154+
.iter()
155+
.filter(|r| r.warm_ms > t)
156+
.map(|r| (r.example.clone(), r.warm_ms))
157+
.collect(),
158+
None => Vec::new(),
159+
};
160+
141161
if let Some(path) = json_out {
142-
write_json_report(&path, &fastled_dir, &rows)?;
162+
write_json_report(&path, &fastled_dir, &rows, max_warm_ms, &breaches)?;
143163
println!();
144164
println!("JSON report written to `{}`", path.display());
145165
}
146166

167+
if let Some(t) = max_warm_ms {
168+
if !breaches.is_empty() {
169+
let summary: Vec<String> = breaches
170+
.iter()
171+
.map(|(name, ms)| format!("{name} ({ms:.2} ms)"))
172+
.collect();
173+
return Err(format!(
174+
"AC#5 warm threshold breached: {} example(s) exceeded {:.2} ms: {}",
175+
breaches.len(),
176+
t,
177+
summary.join(", ")
178+
)
179+
.into());
180+
}
181+
}
182+
147183
Ok(())
148184
}
149185

@@ -212,6 +248,8 @@ fn write_json_report(
212248
path: &Path,
213249
fastled_dir: &Path,
214250
rows: &[Row],
251+
max_warm_ms: Option<f64>,
252+
breaches: &[(String, f64)],
215253
) -> Result<(), Box<dyn std::error::Error>> {
216254
let entries: Vec<_> = rows
217255
.iter()
@@ -225,8 +263,19 @@ fn write_json_report(
225263
})
226264
})
227265
.collect();
266+
let breach_entries: Vec<_> = breaches
267+
.iter()
268+
.map(|(name, ms)| {
269+
serde_json::json!({
270+
"example": name,
271+
"warm_ms": ms,
272+
})
273+
})
274+
.collect();
228275
let body = serde_json::json!({
229276
"fastled_dir": fastled_dir.to_string_lossy(),
277+
"max_warm_ms": max_warm_ms,
278+
"breaches": breach_entries,
230279
"rows": entries,
231280
});
232281
if let Some(parent) = path.parent() {
@@ -236,19 +285,47 @@ fn write_json_report(
236285
Ok(())
237286
}
238287

239-
fn parse_json_flag(args: &[String]) -> Option<PathBuf> {
288+
fn parse_path_flag(args: &[String], flag: &str) -> Option<PathBuf> {
289+
let prefix = format!("{flag}=");
240290
let mut iter = args.iter().skip(1);
241291
while let Some(arg) = iter.next() {
242-
if arg == "--json" {
292+
if arg == flag {
243293
return iter.next().map(PathBuf::from);
244294
}
245-
if let Some(rest) = arg.strip_prefix("--json=") {
295+
if let Some(rest) = arg.strip_prefix(prefix.as_str()) {
246296
return Some(PathBuf::from(rest));
247297
}
248298
}
249299
None
250300
}
251301

302+
fn parse_f64_flag(args: &[String], flag: &str) -> Result<Option<f64>, Box<dyn std::error::Error>> {
303+
let prefix = format!("{flag}=");
304+
let mut iter = args.iter().skip(1);
305+
while let Some(arg) = iter.next() {
306+
let raw = if arg == flag {
307+
match iter.next() {
308+
Some(v) => v.as_str(),
309+
None => return Err(format!("{flag} requires a value").into()),
310+
}
311+
} else if let Some(rest) = arg.strip_prefix(prefix.as_str()) {
312+
rest
313+
} else {
314+
continue;
315+
};
316+
let parsed: f64 = raw
317+
.parse()
318+
.map_err(|e| format!("{flag} expects a number, got {raw:?}: {e}"))?;
319+
if !parsed.is_finite() || parsed < 0.0 {
320+
return Err(
321+
format!("{flag} must be a non-negative finite number, got {parsed}").into(),
322+
);
323+
}
324+
return Ok(Some(parsed));
325+
}
326+
Ok(None)
327+
}
328+
252329
/// Read `FASTLED_DIR` from the environment. No fallback default: the
253330
/// value depends on the host (CI uses a workspace-relative checkout,
254331
/// developers use whatever convention they like) and silently

0 commit comments

Comments
 (0)