Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .duel.rb
Original file line number Diff line number Diff line change
@@ -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
64 changes: 64 additions & 0 deletions .github/workflows/perf.yml
Original file line number Diff line number Diff line change
@@ -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="<!-- duel -->"
{ 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ doc/*
log/*
measurement/*
pkg/*
tmp/*
6 changes: 6 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ plugins:
- rubocop-minitest
- standard-performance

inherit_mode:
merge:
- Include

AllCops:
NewCops: enable
Include:
- .duel.rb
TargetRubyVersion: 3.2

Layout/ArgumentAlignment:
Expand Down
6 changes: 6 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 22 additions & 0 deletions duel/backend_duel.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions duel/boot_duel.rb
Original file line number Diff line number Diff line change
@@ -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")
104 changes: 104 additions & 0 deletions duel/duel_helper.rb
Original file line number Diff line number Diff line change
@@ -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-%<day>02dT12:%<minute>02d:56Z", day: (index % 28) + 1, minute: index % 60)
end

def wrap_root(inner) = "<root>#{inner}</root>"

def tiny_document = "<user><name>Erik</name><active>yes</active></user>"

def shallow_document(count)
wrap_root(Array.new(count) { |i| "<field#{i}>#{token("node", i, 24)}</field#{i}>" }.join)
end

def deep_document(depth)
open_tags = Array.new(depth) { |i| %(<level#{i} depth="#{i}"><value>#{token("value", i, 24)}</value>) }
wrap_root(open_tags.join + Array.new(depth) { |i| "</level#{depth - 1 - i}>" }.join)
end

def record(index)
status = (index % 3).zero? ? "active" : "pending"
amount = Kernel.format("%.2f", ((index * 17) % 10_000) / 10.0)
%(<record id="#{index}"><title>#{token("title", index, 40)}</title><status>#{status}</status>) +
"<created_at>#{timestamp(index)}</created_at><amount>#{amount}</amount>" \
"<tags><tag>alpha</tag><tag>beta</tag><tag>#{token("tag", index, 12)}</tag></tags></record>"
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(" ")
"<node #{attrs}>#{token("node", i, 18)}</node>"
end
wrap_root(nodes.join)
end

def mixed_document(count)
sections = Array.new(count) do |i|
%(<section id="s#{i}"><title>#{token("title", i, 28)}</title>) +
"<p>This #{token("text", i, 18)} text has <em>inline emphasis #{i}</em> and <strong>strong #{i}</strong>.</p>" \
"<p>#{token("body", i + 1, 46)} <a href=\"https://example.test/#{i}\">link #{i}</a> #{token("tail", i + 2, 20)}</p></section>"
end
wrap_root(sections.join)
end

def feed_document(count)
entries = Array.new(count) do |i|
%(<atom:entry gd:id="item-#{i}" ex:version="#{i}"><atom:title>#{token("title", i, 34)}</atom:title>) +
%(<atom:content type="text">#{token("content", i, 58)}</atom:content><gd:rating value="#{(i % 5) + 1}"/>) +
%(<ex:metadata ex:region="us-west-2" ex:trace="trace-#{i}"/></atom:entry>)
end
namespaces = %(xmlns:atom="http://www.w3.org/2005/Atom" xmlns:gd="http://schemas.google.com/g/2005" xmlns:ex="https://example.test/schema")
%(<atom:feed #{namespaces}>#{entries.join}</atom:feed>)
end

def product(index)
[
%(<product sku="sku-#{index}" region="us" updated="#{timestamp(index)}"><name>#{token("name", index, 30)}</name>),
%(<price currency="USD">#{Kernel.format("%.2f", ((index * 13) % 100_000) / 100.0)}</price>),
%(<inventory available="#{index % 2}" warehouse="w#{index % 11}">#{(index * 7) % 400}</inventory>),
"<categories><category>hardware</category><category>component</category>",
"<category>#{token("category", index, 12)}</category></categories>",
product_dimensions(index),
"<description>#{token("description", index, 120)}</description></product>"
].join
end

def product_dimensions(index)
"<dimensions><width>#{(index % 100) + 1}</width><height>#{(index % 80) + 1}</height><depth>#{(index % 50) + 1}</depth></dimensions>"
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)
[
%(<user><id type="integer">#{index}</id><name>#{token("name", index, 20)}</name>),
%(<score type="float">#{index}.5</score><balance type="decimal">#{index}.25</balance>),
%(<admin type="boolean">#{index.even?}</admin><born-on type="date">2001-02-#{(index % 28) + 1}</born-on>),
%(<updated-at type="dateTime">#{timestamp(index)}</updated-at><nickname nil="true"/>),
%(<roles type="array"><role>reader</role><role>writer</role></roles></user>)
].join
end

def typed_document(count) = %(<users type="array">#{Array.new(count) { |i| typed_record(i) }.join}</users>)

def dashed_document(count)
wrap_root(Array.new(count) { |i| "<first-name-#{i}>#{token("first", i, 16)}</first-name-#{i}>" }.join)
end
76 changes: 76 additions & 0 deletions duel/parse_duel.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions duel/typecast_duel.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion multi_xml.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading