Skip to content
Open
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
43 changes: 43 additions & 0 deletions .duel.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

# 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/ (or the benchmarks, or
# this file), 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 = "pg"

# Builds each version's C extension, so that each is measured with its
# own build.
c.build = "bundle exec rake compile"

# A PostgreSQL server of each worker's own, and the queries, rows and
# server time that each benchmark took of it.
c.use :postgresql

# When the workflow comments on a pull request with the report: :always,
# :results (passed or failed), :failures (failed, or couldn't compare),
# or :never.
# c.comment = :failures

# Which runs a regression fails: :every_run, or :pull_requests, for a
# project where people push to master directly (on GitHub Actions, another
# run warns and passes, and a pushed commit gets the comment instead, from
# a workflow job written only where this is set: see docs/ci.md).
# c.fail_on = :every_run

# A label that accepts a regression in a pull request, for a change
# that's slower on purpose (nil for none).
c.accept_label = "duel: accept"

# What the workflow compares a push with: :before (the commit before it),
# or :last_passed (the last commit Duel passed, which a branch,
# duel/passed/master, marks), so small slowdowns add up until they fail.
# (A workflow job moves the branch, written only where this is set: see
# docs/ci.md.)
# c.push_base = :before
end
80 changes: 80 additions & 0 deletions .github/workflows/perf-comment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
name: Perf comment

# Duel's report, as a new comment on the pull request, when Duel says to:
# always (post), or only when its last comment's status differs from the
# run's (change), so the latest comment is never stale. It never edits or
# deletes a comment. It runs once the Perf workflow has, with this
# repository's own workflow and a token that can write, so a pull request
# from a fork gets a comment too. It checks nothing out and runs none of
# the pull request's code, and of what the Duel job kept (where that code
# ran), it trusts only what that job could have said: the pull request's
# number, if it names the pull request the run compared, and the comment
# and the status, if they're ones Duel says. A fork's report, which the
# fork's code wrote, is shown as text.
#
# (The workflow must be on the default branch before it runs at all.)
on:
workflow_run:
workflows: ["Perf"]
types: [completed]

permissions: {}

jobs:
comment:
name: Comment
if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion != 'cancelled' }}
runs-on: ubuntu-latest
permissions:
actions: read # to get the report from the Perf run
pull-requests: write # to comment on the pull request with the report
steps:
- name: Get the report
id: report
continue-on-error: true # there's none when the Duel job failed before comparing
uses: actions/download-artifact@v8
with:
name: duel-report
path: ${{ runner.temp }}/report
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Comment on the pull request
if: steps.report.outcome == 'success'
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
said() { grep -xE "$1=($2)" "$RUNNER_TEMP/report/comment.env" | tail -n 1 | cut -d= -f2; }
PR=$(said pr '[0-9]+')
COMMENT=$(said comment 'post|change')
STATUS=$(said status 'passed|failed|neutral')
[ -n "$PR" ] && [ -n "$COMMENT" ] && [ -n "$STATUS" ] || exit 0
# (The pull request's head commit, and the repository it's from.)
head=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq '"\(.head.sha) \(.head.repo.full_name)"')
[ "${head%% *}" = "$HEAD_SHA" ] || exit 0
# The report, as Markdown for a branch of this repository (whose
# author can push here anyway), but for a fork's, as text: its run
# ran the fork's code, which could have written anything in it. In
# a code block, fenced with more backticks than any run of them in
# the report, it can't mention anyone, link anywhere or show an
# image. It's cut short where a comment couldn't hold it, and loses
# any NUL bytes, which GitHub wouldn't take, and grep reads it as
# text whatever's in it, so it finds every run of backticks.
report() {
if [ "${head#* }" = "$GITHUB_REPOSITORY" ]; then cat "$RUNNER_TEMP/report/duel.md" && return; fi
text="$RUNNER_TEMP/report.txt"
LC_ALL=C tr -d '\000' < "$RUNNER_TEMP/report/duel.md" | head -c 60000 > "$text"
fence=$(LC_ALL=C grep -ao '``*' "$text" | awk '{ if (length > n) n = length } END { n = (n < 3) ? 3 : n + 1; while (n-- > 0) printf "`" }')
printf 'From a fork, so shown as text:\n\n%s\n' "$fence"
cat "$text"
printf '\n%s\n' "$fence"
}
marker="<!-- duel"
# The first line of the last comment Duel posted here: its marker,
# with its status (none in Duel 0.1.0's, which counts as another).
last=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \
--jq ".[] | select(.user.login == \"github-actions[bot]\") | .body | split(\"\n\")[0] | rtrimstr(\"\r\") | select(startswith(\"$marker status=\") or . == \"$marker -->\")" | tail -n 1)
if [ "$COMMENT" = post ] || { [ -n "$last" ] && [ "$last" != "$marker status=$STATUS -->" ]; }; then
{ echo "$marker status=$STATUS -->"; report; } > "$RUNNER_TEMP/comment.md"
gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@"$RUNNER_TEMP/comment.md" > /dev/null
fi
114 changes: 114 additions & 0 deletions .github/workflows/perf.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
name: Perf

on:
pull_request:
merge_group: # (a merge queue's)
push:
branches: [master]
schedule:
- cron: "0 4 * * *"
workflow_dispatch:

# A pull request's newer push cancels its run, if that's still going, as
# only its newest commit's result counts. Every other run (a push's, say)
# runs to the end, so that each push is compared.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

# The Duel job runs the branch's code (its benchmarks too), so it only
# reads. Commenting on a commit is the Comment on the commit job's, which
# runs none of it, and commenting on a pull request is the Perf comment
# workflow's.
permissions:
contents: read
pull-requests: read # for the pull request's labels (see c.accept_label)

jobs:
duel:
name: Duel
runs-on: ubuntu-latest
# Whether to comment (post, change or none), the run's status (passed,
# failed or neutral), and whether to mark a push passed (true, or
# nothing), from Duel.
outputs:
comment: ${{ steps.compare.outputs.comment }}
status: ${{ steps.compare.outputs.status }}
passed: ${{ steps.compare.outputs.passed }}
env:
BUNDLE_WITH: duel # the Gemfile's optional group, with Duel and its PostgreSQL plugin
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # the baseline comes from history, tags included
persist-credentials: false
# The labels of the pull request (or for a push, of the one that
# merged it), for c.accept_label in .duel.rb, as a JSON array. They're
# read as the job runs, and adding the label (or taking it off) runs
# the job again: see the Perf comment workflow.
- name: Find the pull request's labels
id: labels
if: github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'push'
continue-on-error: true # (without them, nothing's accepted)
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
QUEUED: ${{ github.event.merge_group.head_ref }}
run: |
# (In a merge queue, the last pull request queued, which the queue's
# branch names: gh-readonly-queue/master/pr-123-<sha>.)
if [ -n "$QUEUED" ]; then
PR=${QUEUED##*/pr-}
PR=${PR%%-*}
fi
if [ -n "$PR" ]; then
labels=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/labels" --jq '[.[].name]')
else
labels=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq '[.[0].labels[]?.name]')
fi
echo "labels=$labels" >> "$GITHUB_OUTPUT"
# PostgreSQL, for the server each Duel worker starts (its programs in
# /usr/lib/postgresql/*/bin, where duel-postgresql looks, with
# pg_stat_statements), and libpq's headers, to build each version's
# C extension (c.build in .duel.rb).
- run: sudo apt-get install -y postgresql libpq-dev
- uses: ruby/setup-ruby@v1
with:
ruby-version: "4.0" # the newest Ruby, which those who care most about speed run
bundler-cache: true
# Against the branch it targets for a pull request, the queue's base
# for a merge queue, what was there before for a push (or as
# c.push_base in .duel.rb says, the last commit Duel passed), and the
# last release on a schedule (slow creep). Duel adds its report to
# the job's summary, writes it to duel.md for the comment, and sets
# the step's outputs to say whether to comment, as c.comment in
# .duel.rb says, whatever the outcome.
- name: Compare
id: compare
env:
DUEL_BASE: ${{ github.event_name == 'schedule' && 'last-release' || github.event_name == 'push' && github.event.before || github.event_name == 'merge_group' && github.event.merge_group.base_sha || '' }}
# (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' || '' }}
DUEL_LABELS: ${{ steps.labels.outputs.labels }}
run: bundle exec duel --markdown "$RUNNER_TEMP/duel/duel.md"
# For a pull request, what Duel said, for the Perf comment workflow,
# which can't see this job's outputs.
- name: Say whether to comment
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
env:
PR: ${{ github.event.pull_request.number }}
COMMENT: ${{ steps.compare.outputs.comment }}
STATUS: ${{ steps.compare.outputs.status }}
run: |
mkdir -p "$RUNNER_TEMP/duel"
printf 'pr=%s\ncomment=%s\nstatus=%s\n' "$PR" "$COMMENT" "$STATUS" > "$RUNNER_TEMP/duel/comment.env"
# The report, for the comments.
- name: Keep the report
if: ${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'push') }}
uses: actions/upload-artifact@v7
with:
name: duel-report
path: ${{ runner.temp }}/duel/
if-no-files-found: ignore # none when Duel didn't get to compare
retention-days: 1
16 changes: 16 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ group :development do
gem "mini_portile2", "~> 2.1"
end

# Duel, for the performance regression guards in duel/: install it with
# `bundle config set --local with duel`, and run them with `rake duel`.
# Its PostgreSQL plugin needs a released pg, which would load its own
# pg_ext in the specs and in Duel's workers, so the group is optional,
# and the released pg is the source gem, whose lib/ has no pg_ext of the
# Ruby's version (lib/3.4/pg_ext) for pg.rb to load ahead of this tree's.
group :duel, optional: true do
# Duel requires Ruby 3.4 or later
if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4")
gem "duel", "~> 0.1.7"
gem "duel-postgresql", "~> 0.1.5"
gem "pg", force_ruby_platform: true
end
gem "stackprof", platforms: :mri
end

group :test do
gem "bundler", ">= 1.16", "< 5.0"
gem "rake-compiler", "~> 1.0"
Expand Down
7 changes: 7 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,10 @@ task :translate do
sh "po4a po4a.cfg"
end
end

begin
require "duel/rake_task"
Duel::RakeTask.new
rescue LoadError
# Duel requires Ruby 3.4 or later, and the Gemfile's optional duel group
end
105 changes: 105 additions & 0 deletions duel/coder_duel.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# frozen_string_literal: true

require_relative "duel_helper"

# Encoders and decoders on their own, 100 fixed values a call, as a type
# map runs them on a column: the text format, which results come in by
# default, and the binary one.

duel "text decoder: Integer" do
once { @decoder = PG::TextDecoder::Integer.new }
TEXT_VALUES[:integers].each { |value| @decoder.decode(value) }
end

duel "text decoder: Float" do
once { @decoder = PG::TextDecoder::Float.new }
TEXT_VALUES[:floats].each { |value| @decoder.decode(value) }
end

duel "text decoder: TimestampWithoutTimeZone" do
once { @decoder = PG::TextDecoder::TimestampWithoutTimeZone.new }
TEXT_VALUES[:timestamps].each { |value| @decoder.decode(value) }
end

duel "text decoder: TimestampWithTimeZone" do
once { @decoder = PG::TextDecoder::TimestampWithTimeZone.new }
TEXT_VALUES[:timestamptzs].each { |value| @decoder.decode(value) }
end

duel "text decoder: Date" do
once { @decoder = PG::TextDecoder::Date.new }
TEXT_VALUES[:dates].each { |value| @decoder.decode(value) }
end

duel "text decoder: an Array of 100 integers" do
once do
@decoder = PG::TextDecoder::Array.new(elements_type: PG::TextDecoder::Integer.new)
@array = "{#{TEXT_VALUES[:integers].join(',')}}"
end
@decoder.decode(@array)
end

duel "text decoder: an Array of quoted strings, nested" do
once do
@decoder = PG::TextDecoder::Array.new(elements_type: PG::TextDecoder::String.new)
row = Array.new(10) { |i| %("name \\"#{i}\\", with, commas") }.join(",")
@array = "{#{Array.new(10) { "{#{row}}" }.join(',')}}"
end
@decoder.decode(@array)
end

duel "text decoder: Bytea of 4 KB" do
once do
@decoder = PG::TextDecoder::Bytea.new
@hex = "\\x#{(0...4096).map { |i| format('%02x', i % 256) }.join}"
end
@decoder.decode(@hex)
end

duel "text encoder: Integer" do
once do
@encoder = PG::TextEncoder::Integer.new
@integers = TEXT_VALUES[:integers].map(&:to_i)
end
@integers.each { |value| @encoder.encode(value) }
end

duel "text encoder: TimestampWithTimeZone" do
once { @encoder = PG::TextEncoder::TimestampWithTimeZone.new }
TIMES.each { |value| @encoder.encode(value) }
end

duel "text encoder: an Array of strings that need quoting" do
once do
@encoder = PG::TextEncoder::Array.new(elements_type: PG::TextEncoder::String.new)
@array = Array.new(10) { |i| Array.new(10) { |j| %(name "#{i}-#{j}", with {braces}) } }
end
@encoder.encode(@array)
end

duel "binary decoder: Integer, Float and TimestampUtc" do
once do
@integer = PG::BinaryDecoder::Integer.new
@float = PG::BinaryDecoder::Float.new
@timestamp = PG::BinaryDecoder::TimestampUtc.new
@int8s = TEXT_VALUES[:integers].map { |value| [value.to_i].pack("q>") }
@float8s = TEXT_VALUES[:floats].map { |value| [value.to_f].pack("G") }
@times = TIMES.map { |time| PG::BinaryEncoder::TimestampUtc.new.encode(time) }
end
@int8s.each { |value| @integer.decode(value) }
@float8s.each { |value| @float.decode(value) }
@times.each { |value| @timestamp.decode(value) }
end

duel "binary encoder: Int8, Float8 and TimestampUtc" do
once do
@int8 = PG::BinaryEncoder::Int8.new
@float8 = PG::BinaryEncoder::Float8.new
@timestamp = PG::BinaryEncoder::TimestampUtc.new
@integers = TEXT_VALUES[:integers].map(&:to_i)
@floats = TEXT_VALUES[:floats].map(&:to_f)
end
@integers.each { |value| @int8.encode(value) }
@floats.each { |value| @float8.encode(value) }
TIMES.each { |value| @timestamp.encode(value) }
end
Loading
Loading