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"