From 5e1bd2f48296e856b346e29f9b894eb1c10afa43 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Sun, 25 Oct 2026 07:42:12 -0700 Subject: [PATCH] Guard performance with Duel benchmarks Add Duel, with 23 benchmarks in duel/ that fail CI when a change makes parsing slower: MultiXML.parse on documents from tiny to large, each shape from benchmark.rb, the namespace, symbolize and typecast options, an IO input, every backend other than Ox, and requiring the library. The Perf workflow compares each pull request with main, each push with the commit before it, and the last release on a schedule. RuboCop now checks .duel.rb too. Duel needs Ruby 3.4 or later, so the Gemfile and Rakefile skip it on older Rubies. --- .duel.rb | 11 ++++ .github/workflows/perf.yml | 64 +++++++++++++++++++++++ .gitignore | 1 + .rubocop.yml | 6 +++ Gemfile | 6 +++ README.md | 1 + Rakefile | 7 +++ duel/backend_duel.rb | 22 ++++++++ duel/boot_duel.rb | 3 ++ duel/duel_helper.rb | 104 +++++++++++++++++++++++++++++++++++++ duel/parse_duel.rb | 76 +++++++++++++++++++++++++++ duel/typecast_duel.rb | 13 +++++ multi_xml.gemspec | 2 +- 13 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 .duel.rb create mode 100644 .github/workflows/perf.yml create mode 100644 duel/backend_duel.rb create mode 100644 duel/boot_duel.rb create mode 100644 duel/duel_helper.rb create mode 100644 duel/parse_duel.rb create mode 100644 duel/typecast_duel.rb diff --git a/.duel.rb b/.duel.rb new file mode 100644 index 0000000..d02a2c1 --- /dev/null +++ b/.duel.rb @@ -0,0 +1,11 @@ +# Duel's configuration: https://github.com/sferik/duel-ruby/blob/main/docs/configuration.md +# +# Duel follows conventions, so set only what differs: benchmarks are in +# duel/**/*_duel.rb, each version's lib/ is first on the load path, a +# change is compared when it touches lib/ or ext/, and releases are +# tagged v*. +Duel.configure do |c| + # The library each version's workers require (from the gemspec, unless + # this says otherwise). + # c.library = "multi_xml" +end diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml new file mode 100644 index 0000000..22ffbe0 --- /dev/null +++ b/.github/workflows/perf.yml @@ -0,0 +1,64 @@ +name: Perf + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: "0 4 * * *" + workflow_dispatch: + +permissions: + contents: read + pull-requests: write # to comment on a pull request with the report + +jobs: + duel: + name: Duel + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # the baseline comes from history, tags included + # (And whatever else your tests need set up: packages, services.) + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + # Against the main branch for a pull request, what was there before + # for a push, and the last release on a schedule (slow creep). Duel + # adds its report to the job's summary, and writes it to duel.md for + # the comment. + - name: Compare + env: + DUEL_BASE: ${{ github.event_name == 'schedule' && 'last-release' || github.event_name == 'push' && github.event.before || '' }} + # (Run by hand, it compares anyway: with nothing changed, the code + # with itself, which checks the setup.) + DUEL_FORCE: ${{ github.event_name == 'workflow_dispatch' && '1' || '' }} + run: | + # (A branch's first push, or one that rewrote history, has no + # commit before it here to compare against.) + if [ "$GITHUB_EVENT_NAME" = push ] && ! git cat-file -e "$DUEL_BASE^{commit}" 2>/dev/null; then + echo "::notice::No commit before this push to compare against" + exit 0 + fi + bundle exec duel --markdown "$RUNNER_TEMP/duel.md" + # The report, as a comment on the pull request: one comment, updated + # on every push. (A pull request from a fork gets no comment, as its + # token can't write one: the job's summary has the report.) + - name: Comment on the pull request + if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + [ -f "$RUNNER_TEMP/duel.md" ] || exit 0 + marker="" + { echo "$marker"; cat "$RUNNER_TEMP/duel.md"; } > "$RUNNER_TEMP/comment.md" + ids=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \ + --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | .id") + if [ -n "$ids" ]; then + gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/${ids%%$'\n'*}" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null + else + gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null + fi diff --git a/.gitignore b/.gitignore index 991df23..310dbaf 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ doc/* log/* measurement/* pkg/* +tmp/* diff --git a/.rubocop.yml b/.rubocop.yml index 057479a..c9de647 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,8 +7,14 @@ plugins: - rubocop-minitest - standard-performance +inherit_mode: + merge: + - Include + AllCops: NewCops: enable + Include: + - .duel.rb TargetRubyVersion: 3.2 Layout/ArgumentAlignment: diff --git a/Gemfile b/Gemfile index c28e1cf..1317e14 100644 --- a/Gemfile +++ b/Gemfile @@ -28,4 +28,10 @@ gem "steep", ">= 2", platforms: :ruby gem "yard", ">= 0.9.38" gem "yardstick", ">= 0.9.9" +group :development do + # Duel requires Ruby 3.4 or later + gem "duel" if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4") + gem "stackprof", platforms: :mri +end + gemspec diff --git a/README.md b/README.md index f50ef7b..2b883d2 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![Typecheck](https://github.com/sferik/multi_xml/actions/workflows/typecheck.yml/badge.svg)][typecheck] [![Docs](https://github.com/sferik/multi_xml/actions/workflows/docs.yml/badge.svg)][docs] [![Gem Version](https://badge.fury.io/rb/multi_xml.svg)][gem] +[![Duel](https://raw.githubusercontent.com/sferik/duel-ruby/main/assets/badge.svg)](https://github.com/sferik/duel-ruby) Lots of Ruby libraries parse XML and everyone has their favorite XML parser. Instead of choosing a single XML parser and forcing users of your library to diff --git a/Rakefile b/Rakefile index 50ed4f6..329493d 100644 --- a/Rakefile +++ b/Rakefile @@ -66,3 +66,10 @@ default_tasks = %i[test lint verify_measurements mutant] default_tasks << :steep unless RUBY_PLATFORM == "java" || Gem.win_platform? task default: default_tasks + +begin + require "duel/rake_task" + Duel::RakeTask.new +rescue LoadError + # Duel requires Ruby 3.4 or later +end diff --git a/duel/backend_duel.rb b/duel/backend_duel.rb new file mode 100644 index 0000000..cd13ab8 --- /dev/null +++ b/duel/backend_duel.rb @@ -0,0 +1,22 @@ +# The same 20 records through each of the other backends, picked per +# call with the :parser option. The backend's module is resolved once, +# untimed, so each iteration times only the parse and the hash-building +# that MultiXML's own handler for that backend does. +require_relative "duel_helper" + +{ + "libxml" => :libxml, + "libxml SAX" => :libxml_sax, + "nokogiri" => :nokogiri, + "nokogiri SAX" => :nokogiri_sax, + "oga" => :oga, + "rexml" => :rexml +}.each do |label, name| + duel "backend: #{label}, 20 records" do + once do + @parser = MultiXML.with_parser(name) { MultiXML.parser } + MultiXML.parse(@xml = record_document(20), parser: @parser) + end + MultiXML.parse(@xml, parser: @parser) + end +end diff --git a/duel/boot_duel.rb b/duel/boot_duel.rb new file mode 100644 index 0000000..7ccb8d5 --- /dev/null +++ b/duel/boot_duel.rb @@ -0,0 +1,3 @@ +# How long the library takes to load, with its standard-library +# dependencies (bigdecimal, date, time, and yaml). +duel_process "boot: require multi_xml", ruby: %(require "multi_xml") diff --git a/duel/duel_helper.rb b/duel/duel_helper.rb new file mode 100644 index 0000000..4263a17 --- /dev/null +++ b/duel/duel_helper.rb @@ -0,0 +1,104 @@ +# Shared XML documents for the benchmarks. Every document is built from +# fixed inputs, so each iteration and each version parses the same bytes. +# The shapes follow the payloads in benchmark.rb. + +# Auto-detection picks the first parser library already loaded, so which +# one a worker gets would depend on whether a backend benchmark loaded +# LibXML or Nokogiri before the first parse. Pin Ox, the one detection +# prefers, so every worker of both versions parses with the same backend. +MultiXML.parser = :ox + +def token(prefix, index, width) + base = "#{prefix}_#{index.to_s(36)}_abcdefghijklmnopqrstuvwxyz0123456789" + (base * (width.to_f / base.length).ceil)[0, width] +end + +def timestamp(index) + Kernel.format("2026-04-%02dT12:%02d:56Z", day: (index % 28) + 1, minute: index % 60) +end + +def wrap_root(inner) = "#{inner}" + +def tiny_document = "Erikyes" + +def shallow_document(count) + wrap_root(Array.new(count) { |i| "#{token("node", i, 24)}" }.join) +end + +def deep_document(depth) + open_tags = Array.new(depth) { |i| %(#{token("value", i, 24)}) } + wrap_root(open_tags.join + Array.new(depth) { |i| "" }.join) +end + +def record(index) + status = (index % 3).zero? ? "active" : "pending" + amount = Kernel.format("%.2f", ((index * 17) % 10_000) / 10.0) + %(#{token("title", index, 40)}#{status}) + + "#{timestamp(index)}#{amount}" \ + "alphabeta#{token("tag", index, 12)}" +end + +def record_document(count) = wrap_root(Array.new(count) { |i| record(i) }.join) + +def attribute_document(count) + nodes = Array.new(count) do |i| + attrs = Array.new(8) { |slot| %(a#{slot}="#{token("attr", i + slot, 14)}") }.join(" ") + "#{token("node", i, 18)}" + end + wrap_root(nodes.join) +end + +def mixed_document(count) + sections = Array.new(count) do |i| + %(
#{token("title", i, 28)}) + + "

This #{token("text", i, 18)} text has inline emphasis #{i} and strong #{i}.

" \ + "

#{token("body", i + 1, 46)} link #{i} #{token("tail", i + 2, 20)}

" + end + wrap_root(sections.join) +end + +def feed_document(count) + entries = Array.new(count) do |i| + %(#{token("title", i, 34)}) + + %(#{token("content", i, 58)}) + + %() + end + namespaces = %(xmlns:atom="http://www.w3.org/2005/Atom" xmlns:gd="http://schemas.google.com/g/2005" xmlns:ex="https://example.test/schema") + %(#{entries.join}) +end + +def product(index) + [ + %(#{token("name", index, 30)}), + %(#{Kernel.format("%.2f", ((index * 13) % 100_000) / 100.0)}), + %(#{(index * 7) % 400}), + "hardwarecomponent", + "#{token("category", index, 12)}", + product_dimensions(index), + "#{token("description", index, 120)}" + ].join +end + +def product_dimensions(index) + "#{(index % 100) + 1}#{(index % 80) + 1}#{(index % 50) + 1}" +end + +def catalog_document(count) = wrap_root(Array.new(count) { |i| product(i) }.join) + +# Records whose values carry type attributes, as Rails' to_xml writes +# them, so that typecasting converts every one. +def typed_record(index) + [ + %(#{index}#{token("name", index, 20)}), + %(#{index}.5#{index}.25), + %(#{index.even?}2001-02-#{(index % 28) + 1}), + %(#{timestamp(index)}), + %(readerwriter) + ].join +end + +def typed_document(count) = %(#{Array.new(count) { |i| typed_record(i) }.join}) + +def dashed_document(count) + wrap_root(Array.new(count) { |i| "#{token("first", i, 16)}" }.join) +end diff --git a/duel/parse_duel.rb b/duel/parse_duel.rb new file mode 100644 index 0000000..57a8b5d --- /dev/null +++ b/duel/parse_duel.rb @@ -0,0 +1,76 @@ +# MultiXML.parse with the default parser (Ox, pinned in duel_helper.rb) +# and default options: typecasting on, and namespaces stripped. Each +# document is built and parsed once, untimed, so the parser is detected +# and loaded before anything is measured. +require_relative "duel_helper" + +duel "parse: a tiny document" do + once { MultiXML.parse(@xml = tiny_document) } + MultiXML.parse(@xml) +end + +duel "parse: 10 records" do + once { MultiXML.parse(@xml = record_document(10)) } + MultiXML.parse(@xml) +end + +duel "parse: 120 records" do + once { MultiXML.parse(@xml = record_document(120)) } + MultiXML.parse(@xml) +end + +duel "parse: a large catalog" do + once { MultiXML.parse(@xml = catalog_document(100)) } + MultiXML.parse(@xml) +end + +duel "parse: 450 shallow fields" do + once { MultiXML.parse(@xml = shallow_document(450)) } + MultiXML.parse(@xml) +end + +duel "parse: a tree 180 levels deep" do + once { MultiXML.parse(@xml = deep_document(180)) } + MultiXML.parse(@xml) +end + +duel "parse: attribute-dense nodes" do + once { MultiXML.parse(@xml = attribute_document(100)) } + MultiXML.parse(@xml) +end + +duel "parse: mixed content" do + once { MultiXML.parse(@xml = mixed_document(60)) } + MultiXML.parse(@xml) +end + +duel "parse: a feed, namespaces stripped" do + once { MultiXML.parse(@xml = feed_document(60)) } + MultiXML.parse(@xml) +end + +duel "parse: a feed, namespaces preserved" do + once { MultiXML.parse(@xml = feed_document(60), namespaces: :preserve) } + MultiXML.parse(@xml, namespaces: :preserve) +end + +duel "parse: dashed keys" do + once { MultiXML.parse(@xml = dashed_document(100)) } + MultiXML.parse(@xml) +end + +duel "parse: 120 records, symbolized names" do + once { MultiXML.parse(@xml = record_document(120), symbolize_names: true) } + MultiXML.parse(@xml, symbolize_names: true) +end + +duel "parse: 120 records, no typecasting" do + once { MultiXML.parse(@xml = record_document(120), typecast_xml_value: false) } + MultiXML.parse(@xml, typecast_xml_value: false) +end + +duel "parse: 10 records from an IO" do + once { MultiXML.parse(@io = StringIO.new(record_document(10))) } + @io.rewind + MultiXML.parse(@io) +end diff --git a/duel/typecast_duel.rb b/duel/typecast_duel.rb new file mode 100644 index 0000000..98242ba --- /dev/null +++ b/duel/typecast_duel.rb @@ -0,0 +1,13 @@ +# Typecasting values by their type attributes (integer, float, decimal, +# boolean, date, dateTime, nil, and array), as Rails' to_xml writes them. +require_relative "duel_helper" + +duel "typecast: one typed record" do + once { MultiXML.parse(@xml = typed_document(1)) } + MultiXML.parse(@xml) +end + +duel "typecast: 50 typed records" do + once { MultiXML.parse(@xml = typed_document(50)) } + MultiXML.parse(@xml) +end diff --git a/multi_xml.gemspec b/multi_xml.gemspec index 6dff6a0..af43ae4 100644 --- a/multi_xml.gemspec +++ b/multi_xml.gemspec @@ -26,7 +26,7 @@ Gem::Specification.new do |spec| # The `git ls-files -z` loads the files in the RubyGem that have been added into git. spec.files = Dir.chdir(__dir__) do `git ls-files -z`.split("\x0").reject do |f| - (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor]) + (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ duel/ .duel.rb .git .circleci appveyor]) end end spec.bindir = "exe"