Skip to content

Add Duel performance regression guards - #1132

Closed
sferik wants to merge 1 commit into
ruby:masterfrom
sferik:add-duel
Closed

sferik wants to merge 1 commit into
ruby:masterfrom
sferik:add-duel

Conversation

@sferik

@sferik sferik commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

This adds Duel, a gem I wrote to catch performance regressions in CI. On every pull request it runs the benchmarks in duel/ against master and the branch side by side, in alternating worker processes, and fails the build if the branch is slower or allocates more. It judges CPU time and instruction counts with confidence intervals, and counts allocations exactly. When something regresses, it profiles both versions with StackProf and says where the extra time went.

There are 36 benchmarks, and I tried to follow what benchmark/parser.rb, benchmark/encoder.rb and the performance PRs of the last year or so already care about. Parsing covers twitter.json (plain, with symbolize_names and with freeze), citm_catalog.json, canada.json and activitypub.json from benchmark/data, plus small payloads where the per-call overhead dominates: a small hash, a nested document, and a 1 KB API response through JSON.parse, with symbolize_names, and through JSON::Coder. Then the paths on their own: 1,000 integers, 1,000 short decimals, strings with escapes, long ASCII strings for the SIMD scan, and a stream of documents fed to ResumableParser in 4 KB chunks. Generating covers the same documents, twitter.json pretty and not, 10,000 points from canada.json for float formatting, and the 1 KB response through JSON.generate, pretty_generate, Hash#to_json, JSON::Coder and JSON.dump into an IO. It also covers integers, decimals, strings that need escaping, long strings that are ASCII but for one character or entirely multibyte, script_safe, ascii_only, and objects that go through their own #to_json or #to_s, as in the encoder benchmark's "many #to_json calls" (with a fixed Time and Date instead of Time.now). The fixtures are read from benchmark/data, so nothing is duplicated.

The scripts in benchmark/ are good at comparing json with other libraries, but someone has to run them and read the numbers, and nothing runs them in CI. Duel compares each pull request with master instead, and fails it when json itself gets slower, even by a percent or two of instructions.

The tricky part was json being json. Duel's worker process requires json for its own protocol before it loads the version it measures, and under the bundle that require finds the candidate's lib/ through the gemspec. So the baseline's workers were loading the candidate's parser.bundle and generator.bundle first, and then the baseline's on top of them. I checked this with lsof in each worker, and both sets of extensions were mapped in every baseline worker. I fixed that in Duel 0.1.4, whose workers load nothing a version could be before the version itself, and which now fails a run where a worker loads an extension twice. So .duel.rb needs no workaround. It sets c.build to bundle exec rake compile, so each version is measured with its own build. The workflow also runs rake compile once before Duel starts, because the duel command loads json itself (to read the pull request's labels and write its report), and here the bundle finds this checkout's. That json never runs any of the benchmarks, but its extensions have to exist.

The Duel job only reads. The comment on a pull request comes from a second workflow, perf-comment.yml, which runs none of the pull request's code and shows a report from a fork as plain text. If a change is slower on purpose, a duel: accept label lets it through, so that label would need to exist. Nothing else in the repository changes besides the Gemfile, where Duel (on Ruby 3.4 and later, pinned to ~> 0.1.5) and StackProf, for its profiles, are in the development group.

To check that it actually catches things, I planted three regressions, one at a time. An extra rb_str_dup in build_string in parser.c failed 12 of the 16 parsing benchmarks, on allocations every time and on instructions and CPU time for most (1.04x to 1.34x). The four that passed were the ones that never reach that line: freeze interns its strings, the integer and decimal arrays have no strings, and escaped strings take the unescape path. Switching the generator's escape search from the NEON scan back to the scalar loop failed 15 of the 20 generating benchmarks, all on instructions and all but one on CPU time, from 1.02x the instructions for the small array up to 5.3x for the 4 KB strings. Its profiles pointed at State.generate. A Ruby-level change, an extra options.transform_keys(&:to_sym) in JSON.parse, failed exactly the 14 benchmarks that go through JSON.parse (one more allocation each). The profile named Hash#transform_keys, and the JSON::Coder and ResumableParser benchmarks, which skip that method, passed. Comparing master with itself passed three times in a row.

A full run takes about 7 seconds of measuring on my laptop, plus compiling both versions, so I'd expect the job to spend most of its time on setup.

@sferik
sferik marked this pull request as draft November 7, 2026 13:57
Duel (https://github.com/sferik/duel-ruby) runs the benchmarks in duel/
on the branch and on master side by side, and fails a pull request that
is slower or allocates more. The 36 benchmarks parse and generate the
documents in benchmark/data, small API payloads, numbers and strings on
their own, with symbolize_names, freeze, pretty, script_safe,
ascii_only, JSON::Coder, JSON.dump into an IO and ResumableParser.

Each version compiles its own extensions (c.build). The workflow also
compiles this checkout's once before Duel starts, as the duel command
requires json itself.

Checked by comparing master with itself three times, and with planted
regressions: an extra rb_str_dup in the parser failed 12 benchmarks,
the generator's scalar escape search instead of NEON failed 15, and an
extra Hash#transform_keys in JSON.parse failed 14.
@sferik
sferik marked this pull request as ready for review November 9, 2026 00:05
@byroot

byroot commented Nov 9, 2026

Copy link
Copy Markdown
Member

Thank your for the PR but I'm not interested in such system.

@byroot byroot closed this Nov 9, 2026
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