Repository navigation
perf: add a render benchmark suite and run comparison - #622
Merged
Merged
Conversation
rake benchmarks only enforces a few throughput floors, so a render-path change can get slower and still pass CI. rake perf varies one factor at a time from a baseline shape (input kind, size, width, nesting, nil values, defaults, value types, field kinds, conditions, views, transformers, extensions, render options, output path, lifecycle) and adds composite profiles, so a change's effect shows per axis rather than as one number. Before timing, each scenario is rendered once and dropped if it raised or issued SQL; the output's digest is kept so a later run can tell whether a change altered what is rendered. Timing interleaves batches, records wall and thread CPU time, and reports the IQR of the batch means as the run's own noise. Allocations are counted with GC disabled. BASELINE=<json> (or latest) compares with an earlier run: a Changed table of rows beyond noise, with a different allocation count, or with different output, then the full tables collapsed. rake perf:compare does the same for two saved runs. The report goes to a markdown file next to the JSON under benchmarks/results/ (gitignored); the terminal shows a progress bar, the paths, and the comparison. docs/benchmarking.md explains what is measured and how far to trust a difference; testing.md has the commands; making-changes.md names the rake perf evidence a render-path PR carries. rainbow and ruby-progressbar are added to the Gemfile for the terminal output; both were already in the bundle as rubocop dependencies. Co-authored-by: cursor/claude-fable-5.1 <noreply@anthropic.com> Signed-off-by: Jacob Sheehy <jacobjsheehy@gmail.com>
writing-docs.md asks for classes, methods, gems, and options as code, and the other docs already write ActiveRecord, Hash, Symbol, and Oj that way. Scenario labels quoted from the tool's output stay as printed. Co-authored-by: cursor/claude-fable-5.1 <noreply@anthropic.com> Signed-off-by: Jacob Sheehy <jacobjsheehy@gmail.com>
Jake read benchmarking.md and the rake perf changes to testing.md and making-changes.md on 2026-10-29. Co-authored-by: cursor/claude-fable-5.1 <noreply@anthropic.com> Signed-off-by: Jacob Sheehy <jacobjsheehy@gmail.com>
The sweeps varied one factor from a baseline of Symbol-keyed Hashes, which take their own extractor path. A render-path change that skips Hashes (the compiled views in #613) then showed only overhead on most rows. The baseline is now plain objects with attr_readers; Hash input stays as an input_kind row and in the Hash export profile. The output digest sorted Hash keys, so a change that only reordered fields compared as the same output. Field order is visible to users (sort_fields_by), so the digest now follows rendered order. Co-authored-by: cursor/claude-fable-5.1 <noreply@anthropic.com> Signed-off-by: Jacob Sheehy <jacobjsheehy@gmail.com>
The noise-floor result and the #608 example were measured when the sweeps used Hash input. Both are re-run on the plain-object baseline. The load caveat is dropped from the example, since the comparison output already warns when load differs between runs. Co-authored-by: claude-code/claude-opus-5-5 <noreply@anthropic.com> Signed-off-by: Jacob Sheehy <jacobjsheehy@gmail.com>
CodeQL's rb/incomplete-sanitization flagged the chained String#gsub calls as not escaping backslashes. They did, but a single character class that escapes backslashes and pipes together reads more plainly and is the form the rule recognizes. Co-authored-by: claude-code/claude-opus-5-5 <noreply@anthropic.com> Signed-off-by: Jacob Sheehy <jacobjsheehy@gmail.com>
oj 3.17.8 dropped its runtime dependency on ostruct, and ostruct is not a default gem as of Ruby 4.0, so a fresh bundle on Ruby 4.0 can't load the specs that require it. Co-authored-by: claude-code/claude-opus-5-5 <noreply@anthropic.com> Signed-off-by: Jacob Sheehy <jacobjsheehy@gmail.com>
lessthanjacob
marked this pull request as ready for review
November 4, 2026 16:48
jhollinger
approved these changes
Nov 4, 2026
Contributor
Author
|
@jhollinger That’s the idea! I’ll likely wrap this up in a skill as a follow up here. |
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
Written by the author.
Changes
Added
rake perftimes rendering across 20 one-factor sweeps and six composite profiles (107 timed scenarios), varying one factor at a time from a baseline of plain objects. Each scenario is rendered once before timing. If that render raises or runs a database query, the scenario is left out and listed with the reason; otherwise its output is fingerprinted so a later run can tell whether a change altered it. Entry pointspec/benchmarks/perf/run.rb.BASELINE=<file|latest>andrake perf:comparereport each scenario's change in wall time, CPU time, and allocations per rendered object, mark time changes inside either run's IQR as~, and lead with aChangedtable.docs/benchmarking.mdexplains what is measured and how far to trust a difference.Changed
AGENTS.md,docs/testing.md, anddocs/making-changes.mdask a render-path change forrake perfbefore-and-after numbers in place of tworake benchmarksfigures.rainbowandruby-progressbarare declared in theGemfilefor the progress bar and colored summary; both were already in the bundle as rubocop dependencies.Fixed
ostructis declared in theGemfile.oj3.17.8 (2026-11-03) dropped its runtime dependency on it, and it isn't a default gem as of Ruby 4.0, so a fresh bundle couldn't load the specs thatrequire 'ostruct'.🎯 Review Focus
Compare::Row#beyond_noise?inspec/benchmarks/perf/compare.rb~L38A time change counts as real when it exceeds the larger of the two runs' IQR/median, with no fixed floor, so a scenario with very steady timings gets a small cutoff. On two runs of identical code, 7 of 104 rows were flagged, the largest at 7.5%. Should a fixed floor (say 5%) sit under the IQR, given it would also hide a real 4% regression?
PerfBench::Fixtures.widgetsinspec/benchmarks/perf/fixtures.rbEvery sweep varies from plain objects with
attr_readers;Hashinput appears only in theinput_kindrow and the Hash export profile. Is that enough coverage for a change confined toHashExtractor, or should the core sweeps (size,width,depth) also run onHashinput?Context
Behavior: new
rake perfandrake perf:comparetasks; run locally, not by CIComparing 29069ec's
:if/:unlessmemoization fix (reverted as the baseline) with the fixed code, at da2f380:Why This Approach: a separate local task beside
rake benchmarks; paired A/B runs left for a follow-uprake benchmarks: it stays the automated floor in CI. Its tests are pass/fail floors (an iterations-per-second minimum or a linearity fit), which say whether a render got slower but not where;rake perfreports per-scenario changes.docs/benchmarking.md).Hashes, andHashinput takes its own extractor path (HashExtractor) that a render-path change may not touch.Testing: CI green on de85267 (Ruby 3.1–4.0); full runs on Ruby 4.0.2, smoke run on 3.1.4
teston Ruby 3.1, 3.2, 3.3, and 4.0, CodeQL, and DCO pass. The first attempt failed on Ruby 4.0 inActiveRecordBigOTest#bench_render_active_record(linearity 0.9839 against 0.99) with all 410 specs passing; re-running the failed jobs passed. That timing test is unchanged here, andmainsaw the same kind of failure at 22e824b (BigOTest#bench_render_basic, 0.9706 against 0.98).bundle exec rakeon de85267 withoj3.17.8 andostruct0.6.3: 410 examples, 0 failures; RuboCop, 34 files, no offenses. Without theostructline, the same bundle fails to loadspec/integrations/base_spec.rbandspec/units/extensions_spec.rb, astest (ubuntu-latest, 4.0)did on ff39fcc.PerfBench::Markdown.escapegives the same output before and after ff39fcc ona|b,a\b,a\|b, andplain; the rewrite answers CodeQLrb/incomplete-sanitization, which didn't recognize the chainedgsubas escaping backslashes.rake perfruns of identical code at da2f380 (Ruby 4.0.2): 7 of 104 rows flagged, none beyond 7.5%; 0 allocation or output changes.QUICK=1 rake perfon Ruby 3.1.4, the oldest in the CI matrix, at 7fb674d: 102 scenarios, exit 0. The one skipped scenario,output / JSON (MultiJson), is skipped on Ruby 4.0.2 too, becausemulti_jsonisn't in the bundle.spec/benchmarks/perf/matchesrake benchmarks'spec/benchmarks/**/*_test.rbor the rspec*_spec.rbpattern (git ls-files), so CI runs the same tests as onmain; the 4 existing_test.rbfiles still match.sort_fields_by = :name_asctwice gives one digest, and under:definitiona different one. From the authoring session on 2026-10-29.Checklist
current as of de85267
🤖 Generated with Claude Code