perf(rust): a decode stage breakdown, bound like §1 - #89
Merged
Merged
Conversation
…ecode_stages example The decoder's render_at_size now carries stage! marks covering it end to end (header, selection, ac_dequant, window_filter, cos_tables, gamma_lut, render), through the same recorder the encoder uses. The macro is made crate-visible from encode.rs, and expands to nothing without the feature. bench_decode_stages encodes a gradient at a tier, then times its natural or capped decode stage by stage. Before timing anything it decodes all 19 shared decode vectors through the instrumented build and requires the spec's bytes, so the shipped-bytes claim is checked against bytes this build did not produce, not only against its own warm-up. Also moves analyze()'s doc comment back onto analyze(); it had been attached to the stage_timing module.
record_stages.py gains a --decode mode writing perf-decode-stages.json (schema chromahash-perf-decode-stages/1), one cell per invocation keyed WxH-tTIER-natural or WxH-tTIER-capCWxCH. A decode cell carries its render raster, hash length and the number of spec vectors the instrumented build reproduced, and is refused outright when any of those or its totals are missing. The dirty probe now excludes both recorder outputs rather than only the one being written, so §1 and §1.1 can be recorded back to back before either is committed; any other change still records dirty: true.
Recorded with benchmark:decode-stages from a clean tree: tier 1 natural (2000 iterations), tier 4 natural (20) and tier 4 capped 32x32 (200), each after reproducing all 19 shared decode vectors.
biome format rewraps a short JSON array onto one line, so a [w, h] cap made the committed baseline either fail format:check or differ from what the recorder writes. An object is laid out the same by both.
verify:benchmark reads perf-decode-stages.json through parseDecodeStages, which refuses a missing, unparseable, wrong-schema or empty file the way parseStages does, and exits 1 when §1.1 is bound but the file is unusable. checkDecodeStagesProvenance holds every cell to one clean commit, a recorded spec-vector check, a key that matches its fields, and shares equal to their own ns over whole_decode. checkStageRowCoverage fails a table that omits a recorded stage or names one nothing recorded, and a missing §1.1 table fails rather than being listed as skipped. Ten prose figures in §1.1, §12.1 item 4 and §12.3 are bound as claims. checkProseClaims now fails a claim whose cell or stage the baseline does not hold; it used to skip it silently, for §1's claims as well.
…from it §1.1 is the decode equivalent of §1: shares of a tier-1 natural, tier-4 natural and tier-4 capped 32x32 decode, bound to perf-decode-stages.json. It finds the gamma LUT rebuilt on every call at two thirds of a default-tier decode, the render loop at 99% of a tier-4 one, and the candidate sort at over a third of a capped tier-4 one. §12.1 item 4 is restated in two parts sized by that table, §12.3 no longer says decode is sized by reading decode.rs, §10 item 4 points at the LUT, and §0's procedure records §1.1 alongside §1.
Re-recorded with the cap written as an object, from a clean tree at the commit that carries §1.1, and §1.1 quotes that revision. The capped tier-4 render share rounds to 55% in this run, against 56% in the last.
checkTable passes over a cell whose text is not a number, so a §1.1 cell written as n/a would have been a silent pass. Count what it checked for §1.1 and fail unless it is every row times every bound column.
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.
Summary
spec/PERFORMANCE.md§1 breaks encode down by stage, but decode had no breakdown, so §12.1 item 4 was sized by readingdecode.rs. This PR measures decode stage by stage, records the result in a committed baseline, binds a new §1.1 table to it throughverify:benchmark, and restates item 4 from the measurement.What the measurement found. Shares of one decode of a 100×100 gradient (see §1.1):
gamma_lutrenderselectionbuild_gamma_lut. It rebuilds a 4096-entry table (oneportable_powper entry for sRGB/P3) on every call, to render 1024 pixels. The table depends only on the output gamut, so building it once would give byte-identical output. This PR measures that lever; it does not implement it (§12.1 item 4(a)).Changes, by path:
rust/src/decode.rs:stage!marks coverrender_at_sizefrom start to end (header,selection,ac_dequant,window_filter,cos_tables,gamma_lut,render). Withoutbench-internalsthey compile to nothing. The render loop gets one mark on purpose, and its doc comment says why.rust/src/encode.rs:pub(crate) use stage;lets the decoder use the encoder's macro and recorder. The recorder's doc says the two stay separable because neither path calls the other. Also movesanalyze()'s doc comment back ontoanalyze(); it had ended up on thestage_timingmodule.rust/src/lib.rs: thestage_timingre-export's doc now covers decode as well.rust/Cargo.toml: adds thebench_decode_stagesexample withrequired-features = ["bench-internals", "spec-vectors"], and updates thebench-internalsfeature comment.rust/examples/bench_decode_stages.rs(new): before timing anything, it decodes all 19 shared decode vectors (natural and capped) through the instrumented build and requires the spec's exact bytes. This checks against bytes the build did not produce itself, which is stronger than a repeatability check. It then times the natural or capped decode of a gradient at a given tier, stage by stage, and printsmeta.*lines (raster, hash length, vectors checked).mise-tasks/benchmark/decode-stages(new):mise run benchmark:decode-stages SRC_W SRC_H TIER ITERS [CAP]. It runs the example and records one cell.tools/benchmark/record_stages.py: adds a--decodemode that writesperf-decode-stages.json(schemachromahash-perf-decode-stages/1, keysWxH-tTIER-natural|capCWxCH). A decode cell is refused if it is missing its totals, raster, hash length or a vector check of at least 1. The dirty probe now excludes both recorder outputs, so §1 and §1.1 can be recorded back to back. Any other change still recordsdirty: true.tools/benchmark/test_record_stages.py: six new tests cover the decode mode:tools/comparison/baselines/perf-decode-stages.json(new): the three §1.1 cells, recorded at8f5f4d1from a clean tree. The file is exactly the recorder's output.tools/comparison/src/verify-benchmark-core.ts:DECODE_STAGES_BASELINE,parseDecodeStages(shares the refusal logic withparseStages),checkDecodeStagesProvenanceandcheckStageRowCoverage.checkDecodeStagesProvenancerequires one clean commit,vectorsChecked ≥ 1, a key that matches the cell's fields,iters ≥ 1,whole_decode ≥ stage_sum, and shares equal to their own ns overwhole_decode. A non-number share also fails.checkStageRowCoveragefails a table that omits a recorded stage or names one that was never recorded.checkProseClaimsnow fails a claim whose cell or stage is missing. It used to skip it silently, which also affected §1's claims.tools/comparison/src/verify-benchmark.ts: binds §1.1:MissingCellinstead of counting as unbound.n/acell cannot pass silently.tools/comparison/src/metric-selftest.ts: four new blocks (B14–B17) cover the functions above.spec/PERFORMANCE.md:benchmark:decode-stages..mise.toml: thetest:benchmarkdescription and comments now mention §1.1 and the decode recorder.Validation
All at
2ca3423unless noted, on this host:cargo fmt --check(rust/): passcargo clippy --all-targets --features full -- -D warnings: passcargo clippy --all-targets --features full,bench-internals -- -D warnings: pass. CI does not run this.cargo test --features full: pass (144 + 14 + 5 + 2)cargo build --no-default-features/cargo test --no-default-features: passcargo +1.85 build --all-targets: pass (MSRV).cargo +1.85 build --all-targets --features bench-internalsalso passed at500e78e; rust/ has not changed since../tools/ci/check-versions.sh: pass.python3 spec/validate.py: passuvx ruff format --check tools/benchmark,uvx ruff check tools/benchmark: passpython3 -m unittest discover -s tools/benchmark -p 'test_*.py': pass (13 tests)pnpm --prefix tools/comparison run format:check/lint/build: passnode tools/comparison/dist/metric-selftest.js: passnode tools/comparison/dist/verify-claims.js: pass.verify-experiments.js --strict: pass.verify-sweep-labels.js: pass.corpus-licenses.js --check: passnode tools/comparison/dist/metric-reference-test.js: pass.determinism-check.js: pass.rd-gate.js: PASS (0.00% drift)node tools/comparison/dist/verify-benchmark.js: exit 1, pre-existing. The six disagreements are the §2 tier-3/4 encode and §3 128/1024TBDplaceholders that §0 lists and master also carries.ci-comparison.ymlruns this step withcontinue-on-error. Every §1.1 value, and all 10 of its prose figures, checked clean.verify-benchmark.js --section 1.1exits 0.n/acell and an extraidctrow temporarily in §1.1, the gate reported the missing cell, the count shortfall (27 expected, 23 checked) and the unrecorded row. The edit was then reverted.convco check origin/master..HEAD: no errors.Replicate runs (not committed)
On the same loaded host (load average 9–19), I re-ran
bench_decode_stagesfive times per column without recording. Ranges of the share per stage:gamma_lut62.3–65.6%,render29.0–32.0%. One earlier 200-iteration run gave 55% / 38%. In absolute termsgamma_lutheld at 213–219 µs whilerenderranged 95–158 µs.render98.8–99.0%.selection37.8–38.3%,render54.9–55.5%.§1.1 is published at whole-percent precision and says in place that the t1 split between
gamma_lutandrenderis its least stable figure. The finding that the LUT outweighs the render loop at tier 1 held in every run.Coverage gaps
bench-internals, so neither the decodestage!marks norbench_decode_stagesis compiled or run there. They ran locally: clippy, MSRV, and the three recorded runs.mise-tasks/benchmark/decode-stageshas no automated test. The recorder it calls does.verify-benchmark.ts's process-level exits have no test: the missing-baselineexit 1, the §1.1 cell-count check, the missing-table and missing-column failures. The core functions they call are covered by B14–B17. The count, row and cell failures were exercised once by hand (above).Risks and rollout
bench-internals, and the golden decode tests, the spec vectors andrd-gateall pass.checkProseClaimsis stricter for §1 as well. All 13 §1 claims still resolve and pass.Issue
Closes #80
Decisions taken
Taken: one
rendermark around the wholeO(w·h·K)loop. §1.1 and item 4(b) say its split between the inverse DCT and the colour conversion is not measured.Rejected: a timer per pixel, or splitting the loop in two. Either would time a decoder the crate does not ship: the timer overhead is comparable to per-pixel work at tier 1, and a second loop changes memory traffic.
Reverses: add
stage!marks inside the loop inrust/src/decode.rs, then add the rows to §1.1 and to the recorded cells.stage!from.Taken:
pub(crate) use stage;inencode.rs, reusing the one thread-local recorder. This matches the manifest's "stage!/stage_timing visibility" and is the smallest change.Rejected: moving
stage_timinginto its own module, a larger move with no behavioural gain.Reverses: move the module and macro to
rust/src/stage_timing.rsand update the twousesites andlib.rs.Taken: before timing, decode all 19 shared decode vectors and require the spec's bytes (
spec-vectorsis a required feature). The artifact records the count and the gate requires at least 1.Rejected: comparing each timed decode only with its own warm-up, as
bench_stages.rsdoes. That shows repeatability, not agreement with the shipped decoder. The timed decode is still also checked for repeatability.Reverses: drop
check_spec_vectorsandvectorsChecked, and thespec-vectorsrequirement.Taken: a 100×100 gradient (§2's fixture) at tier 1 natural, tier 4 natural and tier 4 capped 32×32. These are the default, the costliest (§2), and §2's mitigation. Iterations are 2000, 20 and 200, so each column times a comparable interval.
Rejected: §1's 512×512 sources, because decode cost depends on tier and raster, not source size. Also rejected: tiers 2 and 3, to keep three columns like §1.
Reverses: record more cells with
benchmark:decode-stagesand add entries toDECODE_STAGE_COLUMNS.Taken: whole percent, shares only.
Rejected: §1's 0.1% precision, because replicate runs on this loaded host moved the tier-1 shares by several points. Also rejected: a
totalrow in ms, because perf: a decode stage breakdown, bound like §1 #80 reserves absolute figures for a quiet host.Reverses: re-record on a quiet host, add decimals or a total row, and bind the total to
ns.whole_decode.Taken: a
--decodemode inrecord_stages.py, which is the one file in the manifest. The dirty probe excludes both recorder outputs.Rejected: excluding only the file being written. Then §1 and §1.1 could not be recorded back to back as §0's procedure does, and a measurement artifact is not a source input anyway.
Reverses: narrow
RECORDER_OUTPUTSto the output being written.Taken:
{"width", "height"}ornull, sobiome formatleaves the recorder's output byte-identical.Rejected: a
[w, h]pair, which biome rewraps, so the committed file would failformat:checkor differ from the recorder's output.Reverses: change the
capline inrecord_stages.pyanddecodeCellKey, and addbaselines/**to biome's ignore list.Taken:
### 1.1under §1.Rejected: a new top-level section, which would renumber §2–§12, and those numbers are cross-referenced throughout.
Reverses: move the heading and change the binding's
section.Taken: fold it into §12.1 item 4 as part (a).
Rejected: a new numbered row, which would renumber items 8 and 9, and "Finalize API surface for v1 #8" is referenced in §12.3.
Reverses: split item 4(a) into its own row and renumber.
Taken: throw
MissingCell, so every §1.1 cell is required.Rejected: return
null, whichcheckTablecounts as a deliberately unbound pass. That is how §1 behaves, and it is the defect class this series has been removing.Reverses: return
nullin the §1.1 resolver.checkProseClaimstreats a claim whose cell or stage is missing.Taken: it fails, for §1's claims too.
Rejected: skipping silently, which meant a claim bound to the wrong cell was never checked.
Reverses: restore the two
continues incheckProseClaims.rust/tree equals HEAD's.Taken: no. §1.1 matches §1, which checks one clean commit across columns but not tree equality to HEAD.
Rejected: recording
git rev-parse HEAD:rustand comparing. Everyrust/change on master would then failverify:benchmarkuntil both stage tables were re-recorded, a policy §1 has not adopted.Reverses: record
rustTreeinrecord_stages.pyand compare it incheckOneCleanCommit.Taken:
rust/Cargo.toml(the example needs an[[example]]entry),tools/comparison/src/metric-selftest.ts(the only harness that testsverify-benchmark-core.ts), and PERFORMANCE.md §0 and §10. §0's procedure would otherwise leave §1.1 unrecorded, and §10 item 4 would otherwise contradict §1.1.Rejected: leaving them. The example would not build, the new gate functions would be untested, and the document would contradict itself.
Reverses: revert those hunks.