1515# specific language governing permissions and limitations
1616# under the License.
1717
18- # Catches performance regressions by running TPC-H SF10 twice -- once for the
19- # candidate commit (`head`) and once for the commit it sits on (`base`) -- then
20- # failing when `head` is slower than the configured limits allow. SF10 rather
21- # than SF1 because SF1 queries finish in milliseconds, where runner noise is a
22- # large fraction of the measurement.
23- #
24- # The two binaries are built by two jobs, on a runner each, and both are then
25- # measured by a third job on one machine: building is embarrassingly parallel,
26- # while comparing timings taken on different machines is meaningless.
27- #
28- # The two sides are measured interleaved -- a full pass of one, then a full
29- # pass of the other, several times, alternating which goes first -- rather than
30- # all of one side and then all of the other. Measuring in two blocks makes any
31- # drift between them look exactly like a code change: a runner that gets slower
32- # halfway through, or a process that happened to get an unlucky heap, shifts one
33- # side only. Interleaving spreads that over both sides and turns it into
34- # round-to-round spread, which `compare.py` can see and discount.
18+ # Catches performance regressions: runs TPC-H SF10 for a candidate commit
19+ # (`head`) and for the commit it sits on (`base`), on one machine, and fails
20+ # when `head` is slower than the limits allow. The sides are measured
21+ # interleaved, a pass each per round, because measuring one after the other
22+ # lets machine drift read as a code change.
3523
3624name : Benchmarks
3725
3826concurrency :
3927 group : ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }}
4028 cancel-in-progress : true
4129
42- # On `main` this runs on every push, so each merge is measured against the
43- # commit it landed on and a regression that no PR run caught is still pinned to
44- # one merge. On a PR it is opt-in, because the builds and the benchmark runs add
45- # up to roughly half an hour of runner time: add the `performance` label, or
46- # start it by hand from the Actions tab.
30+ # Every push to `main`, so an unlabelled regression is still pinned to one
31+ # merge. Opt-in on a PR, at half an hour of runner time: add the `performance`
32+ # label, or start it from the Actions tab.
4733on :
4834 push :
4935 branches :
50- # The default branch upstream. A fork that calls it something else has to
51- # add that name here for its own merges to be measured.
36+ # A fork whose default branch is named differently has to add it here.
5237 - main
5338 paths-ignore :
5439 - " docs/**"
@@ -98,28 +83,21 @@ permissions:
9883 contents : read
9984
10085env :
101- # `release-nonlto` is `release` with `lto = false` and 16 codegen units. Fat
102- # LTO with a single codegen unit roughly doubles the build, and both sides are
103- # built identically, so the ratio the gate looks at still holds. Dispatch with
104- # `release` when a change is expected to interact with cross-crate inlining,
105- # or to get numbers comparable with locally posted `bench.sh` results.
86+ # `release-nonlto` is `release` without fat LTO, which roughly halves the
87+ # build. Dispatch with `release` for cross-crate inlining effects, or for
88+ # numbers comparable with locally posted `bench.sh` results.
10689 CARGO_PROFILE : ${{ inputs.profile || 'release-nonlto' }}
10790 SCALE_FACTOR : ${{ inputs.scale_factor || '10' }}
108- # Even, for two reasons: each side then leads the same number of rounds, and
109- # the median of an even number of ratios averages the two middle rounds
110- # instead of resting on one.
91+ # Even, so each side leads the same number of rounds.
11192 ROUNDS : ${{ inputs.rounds || '6' }}
11293 ITERATIONS : ${{ inputs.iterations || '1' }}
11394 QUERY_REGRESSION : ${{ inputs.query_regression || '1.20' }}
11495 TOTAL_REGRESSION : ${{ inputs.total_regression || '1.05' }}
115- # A 1.20x swing on a query that runs for 20ms is 4ms, which this kind of
116- # runner cannot resolve. Regressions have to cost real time to count.
96+ # A 1.20x swing on a 20ms query is 4ms, below what a shared runner resolves.
11797 MIN_DELTA_MS : ${{ inputs.min_delta_ms || '25' }}
11898 # `benchmark_runner` finds `sql_benchmarks` through the CARGO_MANIFEST_DIR
119- # baked into it at compile time, so a binary built in one job only works in
120- # another if its tree sits at the same absolute path there. Every job below
121- # puts the two trees under this root, which is why it is a fixed path rather
122- # than something derived from the workspace or the runner.
99+ # baked in at compile time, so its tree has to sit at the same absolute path
100+ # in the job that builds it and the job that runs it -- hence a fixed root.
123101 BENCH_ROOT : /tmp/df-bench
124102 # Same cargo network settings as .github/actions/setup-rust-runtime, without
125103 # its RUSTFLAGS: benchmark binaries are built with the defaults.
@@ -141,9 +119,7 @@ jobs:
141119 - uses : runs-on/action@46910bf61b41721b0579f237e186afb35477007a # v2.3.0
142120 - uses : actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
143121 with :
144- # For `pull_request` this is the PR already merged into the base
145- # branch, for `push` the new branch tip; depth 2 is enough to also
146- # reach the first parent in both cases.
122+ # Depth 2 reaches the first parent, which is the base in both cases.
147123 fetch-depth : 2
148124 - name : Resolve base commit
149125 id : base
@@ -152,16 +128,13 @@ jobs:
152128 run : |
153129 set -euo pipefail
154130 if [ "$GITHUB_EVENT_NAME" = "push" ]; then
155- # A merge that just landed on the default branch. Its first parent
156- # is the branch as it was before, whether the merge was squashed
157- # into one commit or kept as a merge commit, so the difference is
158- # attributable to this one merge.
131+ # A merge that just landed: its first parent is the branch as it
132+ # was before, whether the merge was squashed or not.
159133 base_sha=$(git rev-parse "HEAD^1")
160134 echo "push to $GITHUB_REF_NAME, comparing its tip against the parent"
161135 elif [ "$(git rev-list --parents -n 1 HEAD | wc -w)" -ge 3 ]; then
162136 # HEAD is the PR merged into the base branch, so its first parent
163- # is the base branch tip that the merge used -- the commit this PR
164- # would actually land on.
137+ # is the commit this PR would land on.
165138 base_sha=$(git rev-parse "HEAD^1")
166139 else
167140 # Manual run on a branch: compare it as-is against the base tip.
@@ -190,10 +163,8 @@ jobs:
190163 with :
191164 fetch-depth : 2
192165 - name : Free Disk Space (Ubuntu)
193- # Two minutes of deleting Android SDKs, which a release build of the
194- # workspace genuinely needs on `ubuntu-latest`'s 14GB. The RunsOn
195- # runner above asks for `disk=large` and has no such problem, so only
196- # the fallback pays for this.
166+ # A release build needs this on `ubuntu-latest`'s 14GB, but not on the
167+ # RunsOn runner's `disk=large`, where it is two wasted minutes.
197168 if : vars.USE_RUNS_ON != 'true'
198169 uses : jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
199170 - name : Install Rust
@@ -216,15 +187,8 @@ jobs:
216187 git worktree add --detach "$BENCH_ROOT/$SIDE" HEAD
217188 fi
218189 - name : Cache the dependency build
219- # A cold build of this is fourteen minutes, most of it dependencies
220- # that neither side changed. The action drops workspace crates from
221- # what it saves, so a hit rebuilds DataFusion and reuses the rest.
222- #
223- # `workspaces` because the build happens in a worktree outside the
224- # checkout, and one shared key for both sides because they are a commit
225- # or two apart and share a dependency graph. Only a push to `main`
226- # writes the cache: pull request runs would each save a near-identical
227- # copy of it.
190+ # A cold build is fourteen minutes, mostly dependencies neither side
191+ # changed. One shared key covers both sides; only pushes write it.
228192 uses : Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
229193 with :
230194 workspaces : ${{ env.BENCH_ROOT }}/${{ matrix.side }}
@@ -244,7 +208,7 @@ jobs:
244208 path : ${{ runner.temp }}/benchmark_runner
245209 retention-days : 1
246210
247- # Both sides are measured here, on this one machine, back to back .
211+ # Both sides are measured here, interleaved, on this one machine.
248212 benchmark :
249213 name : TPC-H (head vs base)
250214 needs : [resolve, build]
@@ -280,8 +244,7 @@ jobs:
280244 - name : Check both runners see their queries
281245 run : |
282246 # Artifacts do not carry the executable bit, and a binary whose tree
283- # is missing at the path baked into it would discover no benchmark at
284- # all -- fail here rather than three steps later.
247+ # is missing would discover no benchmark at all -- fail here.
285248 for side in base head; do
286249 binary="$RUNNER_TEMP/bin/$side/benchmark_runner"
287250 chmod +x "$binary"
@@ -295,16 +258,9 @@ jobs:
295258 uses : astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
296259 - name : Generate TPC-H data
297260 # Same generator settings as `bench.sh data tpch`, inlined because that
298- # path also downloads the expected answers through `docker run -it`,
299- # which needs a TTY and is only used for result validation.
300- #
301- # tpchgen-cli comes from its PyPI wheel: the same 3.0.0 release as the
302- # crate, but a ~4MB download instead of a build from source, since the
303- # project attaches no binaries to its GitHub releases for a prebuilt
304- # fetch to find. It also means this job needs no Rust toolchain at all.
305- #
306- # `parquet` as a subcommand rather than `--format parquet`: 3.0.0
307- # deprecated the flag form and warns that it goes away in 4.0.0.
261+ # path also pulls the expected answers through `docker run -it`, which
262+ # has no TTY here. From the PyPI wheel, so no Rust toolchain is needed;
263+ # `parquet` is a subcommand because 3.0.0 deprecated `--format`.
308264 run : |
309265 mkdir -p "$DATA_DIR/tpch_sf$SCALE_FACTOR" "$RESULTS_DIR/base" "$RESULTS_DIR/head"
310266 uv tool run --from 'tpchgen-cli==3.0.0' tpchgen-cli parquet \
@@ -315,20 +271,15 @@ jobs:
315271 du -sh "$DATA_DIR/tpch_sf$SCALE_FACTOR"
316272 df -h "$DATA_DIR"
317273 - name : Describe the machine
318- # A comparison is only as good as the machine under it, and this does
319- # not always land on the machine the `runs-on` line above asks for.
320- # GitHub withholds `vars` from workflows triggered by a pull request
321- # from a fork, so `vars.USE_RUNS_ON` reads as empty there however it is
322- # set on the repository, and every job falls back to a shared 4-vCPU
323- # `ubuntu-latest` -- which is where this workflow's own false Q02
324- # regressions were measured. Pushes to `main` and manual dispatches run
325- # in the repository's own context and do get the 16-vCPU runner.
274+ # GitHub withholds `vars` from fork pull requests, so `USE_RUNS_ON`
275+ # reads as empty and they fall back to a 4-vCPU `ubuntu-latest`. Pushes
276+ # to `main` and manual dispatches do get the 16-vCPU runner.
326277 run : |
327278 echo "cpus: $(nproc)"
328279 lscpu | grep -E '^(Model name|Socket|Core|Thread|CPU\(s\)):' || true
329280 free -h
330281 if [ "$(nproc)" -lt 8 ]; then
331- echo "::warning::running on $(nproc) CPUs, not the runner this workflow asks for; expect a high noise floor. Fork pull requests cannot reach the larger runner -- the run on \`main\` after the merge is the authoritative one ."
282+ echo "::warning::running on $(nproc) CPUs, not the runner asked for; expect a high noise floor. Fork pull requests cannot reach the larger one -- the run on \`main\` after the merge is authoritative."
332283 fi
333284 # Each side runs from its own tree and reads the one generated dataset.
334285 - name : Benchmark both sides, interleaved
@@ -344,18 +295,14 @@ jobs:
344295 --path "$DATA_DIR" \
345296 --output "$output"
346297 }
347- # Read the data once first, so no measured round is the only one
348- # paying to pull the parquet files into the page cache. Reading the
349- # files is all a warmup can carry between rounds -- each round is a
350- # fresh process -- and it takes seconds where a discarded pass of the
351- # suite took as long as a round.
298+ # Pull the data into the page cache, which is all a warmup can carry
299+ # between rounds -- each round is a fresh process.
352300 echo "::group::warm the page cache"
353301 find "$DATA_DIR" -type f -exec cat {} + > /dev/null
354302 echo "::endgroup::"
355303 for round in $(seq 1 "$ROUNDS"); do
356- # Alternate which side goes first, so with an even ROUNDS
357- # whatever the first position costs -- or saves -- is paid by each
358- # side the same number of times.
304+ # Alternate the leader, so each side pays the first-position cost
305+ # the same number of times.
359306 if [ $((round % 2)) -eq 1 ]; then order="base head"; else order="head base"; fi
360307 for side in $order; do
361308 echo "::group::round $round: $side"
0 commit comments