Skip to content

fix(bench): reject implausible wall times; correct polyglot bench.rs setup - #9277

Merged
proggeramlug merged 3 commits into
PerryTS:mainfrom
parv0888:bench/honest-timing-gate
Aug 31, 2026
Merged

fix(bench): reject implausible wall times; correct polyglot bench.rs setup#9277
proggeramlug merged 3 commits into
PerryTS:mainfrom
parv0888:bench/honest-timing-gate

Conversation

@parv0888

@parv0888 parv0888 commented Aug 31, 2026

Copy link
Copy Markdown

Summary

honest_bench can record wall times that are physically impossible, and nothing
downstream notices. The artifact committed at 38ff7ecc contains 150 negative
wall_ms samples out of 300, and REPORT.md is generated from them.

This PR adds two guards so that failure is loud instead of silent, re-runs the
suite on Linux to show the harness working end to end, and fixes three setup
issues in benchmarks/polyglot/bench.rs that RESULTS.md already identifies.

1. The harness publishes timing-free samples as results

From benchmarks/honest_bench/results/results.json at 38ff7ecc:

{"workload": "image_convolution",  "language": "rust", "wall_ms": -0.108458, "exit_code": 0, "output_match": true}
{"workload": "json_pipeline_full", "language": "node", "wall_ms":  0.026375, "exit_code": 0, "output_match": true}

All 300 measured samples are sub-millisecond noise around zero; 150 are negative.
The second row claims Node processed the 108 MB JSON fixture in 26 microseconds.

The runs themselves were fine — exit_code is 0 and the FNV checksums match.
Only the timing is meaningless. report.py filters on exit_code == 0 alone
(scripts/report.py:111), so these samples flow into statistics.median() and
render as confident cells:

| rust  | -0.0 | 0.0 | 48.9 MB | 295.5 KB | 112 | 20/20 |
| perry | -0.0 | 0.1 | 56.1 MB |   4.6 MB |  92 | 20/20 |

The generated ratio lines then contradict the file's own prose — the
json_pipeline_full table reports perry = 1.00× (fastest) while the Bottom
line section above it says Perry is slowest at ~2.7×.

When it started

commit date rust/image_convolution median negative rows
8a7ea9986b 2026-05-14 392.1 ms 0
7beb3a50ca 2026-08-01 392.1 ms 0
6fb58cdead 2026-08-07 429.8 ms 0
88e0812a7d (#7641) 2026-08-08 -0.001 ms 150

#7641 changed no harness code — it regenerated data on a different host (metadata
switches to Apple M1 / 8 cores / 8 GB). So this looks host-specific:
run_bench.sh samples time.monotonic_ns() in two separate python3 processes
and subtracts them, and Python documents that clock's reference point as
undefined. On Linux the pair is boot-relative and valid (I measured 522 ms for a
known 500 ms child); on the host behind #7641 it evidently is not.

I could not reproduce the underlying clock behaviour — I don't have that machine —
so this PR does not change the timing mechanism. It only makes the harness refuse
to emit or report a sample it cannot stand behind.

The fix

  • harness/run_bench.sh — abort with the offending start_ns/end_ns pair
    rather than writing a non-positive sample.
  • scripts/report.py — refuse to build a report from an artifact containing
    non-positive successful samples, and say how many.

Verified both directions:

2. A clean run on Linux x86_64 with the fixed harness

To confirm the harness works once the gate is in place, I ran the full suite on
Linux. Setup reproduces your reference exactly: the generated fixture is
byte-identical (112,695,869 bytes), and all three runtimes match the committed
Bun oracle — checksum=2ba2e053 for convolution, hash=7fc66fa8 and
sha256 a1d5a65c… for the JSON pipeline.

Host: i9-12900HK, Linux 6.17, otherwise idle. Perry 0.5.1220 (latest release),
rustc 1.95.0, Node 22.23.1. No Zig or Bun available, so those columns are absent.
5 warmup + 20 measured, medians. 180/180 runs output-verified; 0 implausible
samples — the new gate never fired.

Workload Perry Rust Node Perry vs Rust
Image convolution (4K, 5×5) 288.3 ms 322.0 ms 1,183.0 ms 0.90× — Perry ahead
JSON pipeline (100 records) 32.4 ms 16.5 ms 60.4 ms 1.97×
JSON pipeline (500k records) 5,746.2 ms 694.5 ms 1,090.4 ms 8.27×

Peak RSS on the same runs:

Workload Perry Rust Node
Image convolution 78.6 MB 49.1 MB 114.0 MB
JSON pipeline (100 records) 38.7 MB 2.1 MB 58.5 MB
JSON pipeline (500k records) 977.2 MB 427.6 MB 564.3 MB

Three things worth recording from this run:

  1. The convolution result reproduces — with an important caveat. Perry does
    beat Rust here on x86_64 too (0.90×), the same direction and margin as your
    arm64 figure. Your image_conv/rust/src/main.rs is an ordinary
    bounds-checked implementation with no handicap. But the result is specific to
    the implementation shape, not to the languages — see below.
  2. The 100-record JSON row is architecture-sensitive. Your table has Perry at
    1.15× Rust; here it is 1.97×, and peak RSS is 38.7 MB against the 3.5 MB in the
    README's memory row. Worth a look before the next regeneration.
  3. The 500k-record row is much worse on x86_64 — 8.27× Rust here versus the
    ~2.7× your REPORT prose records on arm64.

Why the convolution row favours Perry

The kernel clamps both coordinates inside the innermost loop. That makes every
index data-dependent, which blocks a chain of LLVM optimisations: it cannot hoist
the bounds check, use fixed offsets, or unroll the 5×5 cleanly. Measured on the
blur alone:

Rust variant median
upstream (clamp in inner loop) 256.2 ms baseline
+ 3-byte slice per tap 261.9 ms no change — bounds checks are not the cost
clamps removed from interior only 229.9 ms 10%
interior/border split + windowed row 121.9 ms 2.1×

Neither change alone helps much; they unlock together. (Not vectorisation — the
LLVM IR contains no vector ops in either variant.)

Applying that same interior/border split to both implementations, through
this harness, all output-verified against 2ba2e053:

median σ
rust, as shipped 323.5 ms 57.6
perry, as shipped 287.7 ms 85.0
rust + split 155.8 ms 5.0
perry + split 4,421.8 ms 144.3

The identical source change makes Rust 2.08× faster and Perry ~14× slower. So at
equal naive implementation quality Perry is ahead by 1.12×; at equal optimised
quality Rust is ahead by 28×. Both statements are true, and the README currently
reports only the first. A sentence noting that the row reflects the naive kernel
shape would make it much harder to argue with.

I hit a separate silent-miscompilation bug while building the Perry side of that
comparison — an 8-line repro where a store to a module-global Buffer is dropped
entirely. It is unrelated to benchmarking, so I am filing it on its own rather
than burying it here. Verified on 0.5.1220 (latest release); not yet checked
against main.

These are one host and one Perry release; they are offered as a second data point,
not as a replacement for your Apple Silicon numbers.

3. benchmarks/polyglot/bench.rs

Three changes, each already described in benchmarks/polyglot/RESULTS.md:

bench_array_writesuite/03_array_write.ts fills every slot before calling
Date.now(), so its timed loop overwrites resident pages. The Rust version used
vec![0.0; 10_000_000] (calloc, lazily mapped) and timed the loop that
first-touches them, additionally paying ~10M page faults the TS loop had already
paid. Pre-touching before the timer mirrors the TS setup. Measured here:
19 ms → 5 ms, which ties Perry 0.5.1220 on the same machine. RESULTS.md
already notes "the Rust result is -O with bounds-checked indexing;
.iter_mut() would match Perry."

fib — was i32. RESULTS.md states "Perry's type inference refines the TS
number parameter to i64"
, so i64 is the like-for-like peer: 240 ms → 214 ms.
(RESULTS.md currently describes the Rust fib as f64-typed, which hasn't matched
this file for some time — worth a separate look.)

bench_object_create — without a barrier LLVM proves Point never escapes and
deletes the loop, which is why the row reports 0 ms. RESULTS.md says exactly
that: "the compiler proves the struct never escapes and eliminates the whole
loop."
A black_box makes the row measure the allocation it claims to:
0 ms → 1 ms.

These move published numbers, so the polyglot sweep needs a re-run on your
reference hardware. Happy to drop this section if you'd rather take the harness
fix alone — it is a separate commit.

4. README

Two changes, both using your own published numbers rather than mine, so
nothing is substituted across architectures:

  • Pinned the REPORT.md citation to 7beb3a50ca. The README's convolution and
    JSON values match that revision's artifact exactly, so they were correct when
    written; the file at HEAD no longer contains them. Re-pin to main after the
    next good regeneration.
  • Added the 500k-record JSON row to the performance table, using the figures
    already in your REPORT.md prose (Perry 1,649 / Rust 604 / Node 1,010 /
    Bun 647). The table currently shows only the 100-record fixture. Given the
    paragraph directly beneath it — "We publish everything, including the workloads
    where V8's JIT still beats us"
    — this row seemed to belong there.

Testing

  • report.py against both the broken and the last-good artifact (above).
  • Full honest_bench run on Linux, 180 rows, all output-verified (§2).
  • rustc -O -C codegen-units=1 bench.rs builds clean; output format unchanged, so
    run_all.sh's name:elapsed_ms parsing is unaffected.
  • No committed result artifact is modified by this PR.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation

    • Added JSON pipeline performance results for processing 500,000 records.
    • Updated benchmark source references and clarified the reliability of reported timings.
  • Bug Fixes

    • Benchmark runs now reject invalid or non-positive timing samples instead of recording misleading results.
    • Reports now stop with guidance to rerun when timing data is unusable.
  • Tests

    • Improved benchmark accuracy by preventing compiler optimizations and memory initialization effects from distorting measurements.

parv0888 and others added 3 commits August 31, 2026 17:24
honest_bench can record a wall time that is physically impossible and
nothing downstream notices. The artifact at 38ff7ec holds 150 negative
wall_ms samples out of 300, and REPORT.md is generated from them, so every
timing cell in that file currently reads 0.0 ms.

The runs themselves are fine (exit_code 0, checksums match); only the
timing is meaningless. report.py filters on exit_code == 0 alone, so those
samples reach statistics.median() and render as confident results.

Add two guards:

  - run_bench.sh aborts, printing the offending start_ns/end_ns pair,
    rather than writing a non-positive sample.
  - report.py refuses to build a report from an artifact containing
    non-positive successful samples, and says how many.

Verified against the current committed results.json (exits 1) and against
the pre-PerryTS#7641 artifact at 7beb3a5 (regenerates normally).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each of these is already described in benchmarks/polyglot/RESULTS.md.

bench_array_write: suite/03_array_write.ts fills every slot before calling
Date.now(), so its timed loop overwrites resident pages. The Rust version
used vec![0.0; 10_000_000] -- calloc, lazily mapped -- and timed the loop
that first-touches them, paying ~10M page faults the TS loop had already
paid. Pre-touch before starting the timer. 19 ms -> 5 ms locally.

fib: was i32. RESULTS.md states that Perry's inference refines the TS
number parameter to i64, so i64 is the like-for-like peer. 240 ms -> 214 ms.

bench_object_create: without a barrier LLVM proves Point never escapes and
deletes the loop, which is why the row reports 0 ms. black_box makes it
measure the allocation it claims to. 0 ms -> 1 ms.

These move published numbers, so the polyglot sweep needs a re-run on the
project's reference hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The convolution and JSON values in the performance table match the artifact
at 7beb3a5 exactly, so they were correct when written -- but PerryTS#7641
regenerated that file with an invalid clock, and the copy at HEAD no longer
contains them. Pin the citation to the revision that does, and note it
should be re-pinned to main after the next good regeneration.

Also add the 500k-record JSON pipeline row, using the figures already in
REPORT.md's own prose (Perry 1,649 / Rust 604 / Node 1,010 / Bun 647). The
table showed only the 100-record fixture, and the paragraph directly beneath
it says the project publishes the workloads where the JITs win.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 70e6054f-900f-4a16-baa4-c3bf0c5bc4e3

📥 Commits

Reviewing files that changed from the base of the PR and between 6c880be and 659c0b8.

📒 Files selected for processing (4)
  • README.md
  • benchmarks/honest_bench/harness/run_bench.sh
  • benchmarks/honest_bench/scripts/report.py
  • benchmarks/polyglot/bench.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The benchmark harness and report reject non-positive timing samples. Rust workloads add compiler-optimization barriers and pre-touch allocated memory. The README adds a JSON pipeline result and updates benchmark source references.

Changes

Benchmark measurement integrity

Layer / File(s) Summary
Preserve benchmark workload measurements
benchmarks/polyglot/bench.rs
The Rust benchmarks use black_box for recursive Fibonacci input and object construction. The array-write benchmark pre-touches its allocation before timing.
Reject invalid timing samples
benchmarks/honest_bench/harness/run_bench.sh, benchmarks/honest_bench/scripts/report.py
The harness rejects non-positive wall-time deltas and cleans up temporary files. The report stops when successful runs contain non-positive wall times.
Update published benchmark results and sources
README.md
The performance table adds the JSON pipeline workload. The sources footnote pins the systems-language report and separates source groups.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: 🔵 Low · up to 659c0

The PR improves timing validation and adjusts benchmark setup, but the object-allocation benchmark may no longer represent the documented intentionally eliminable workload, which could affect cross-language result comparability. This is a localized, non-blocking concern that maintainers should explicitly confirm.

Suggested reviewers: proggeramlug, thehypnoo

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 3 files. (1 skipped: 1 … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the two main changes: rejecting invalid benchmark wall times and correcting the polyglot benchmark setup.
Description check ✅ Passed The description is detailed and covers the purpose, implementation changes, benchmark validation, and testing results. It does not include the template's explicit Related issue or Checklist sections, …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description is detailed and covers the purpose, implementation changes, benchmark validation, and testing results. It does not include the template's explicit Related issue or Checklist sections, but the required change and test information is substantially complete.

Full details: Docstring Coverage

Explanation

Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 3 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug

Copy link
Copy Markdown
Contributor

Merged.

Validated on a shared branch with #9228, #9257, #9263, #9271, #9272, #9274, #9277, #9279 and #9280 — one build, one validation pass, then split back out and merged individually.

Results across the batch:

  • perry-runtime 2885 passed / 0 failed at RUST_TEST_THREADS=1
  • perry-codegen 30 suites green (the one failure was a doc-test reporting a missing libperry_codegen-*.rlib — an artifact of my own cleanup of stale build dirs, confirmed by a clean re-run at 31/31, not a code defect)
  • all 60 lint gates plus the check_thread_locals / tls_budget checkers
  • a nine-row differential probe byte-identical to node 26.5.1, covering every changed area: RegExp \w/\b/. ASCII and LineTerminator semantics, offset (a[i±1]) and length-bounded array reads, the assert RegExp matcher, and closure identity across a 40k-allocation GC churn
  • seven earlier regression probes re-run at zero diff lines: tagged and scalar array stores, pointer↔scalar transition churn, growth-forwarding receivers, BigInt negation, iterator protocols, field shadowing

One probe (protorepl) moved from 2 to 4 diff lines and I ran it down rather than waving it through: both divergences are accepted trades already on main — the fresh-instance case (#9239) and #9247's deliberate change of a custom-chain miss from Some(undefined) to None, which it made because swallowing the miss left everything Perry synthesizes unreachable (Object(true).valueOf(), plain-function .prototype, iterator helpers). Neither is anything in this batch.

@proggeramlug
proggeramlug merged commit e8c08aa into PerryTS:main Aug 31, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants