Skip to content

Commit 0546741

Browse files
authored
Merge pull request #147 from dergoegge/bench-change
ci: cleanup benchmarking pipeline
2 parents 34d7f78 + 53aa2c3 commit 0546741

3 files changed

Lines changed: 204 additions & 133 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: Benchmark comment
2+
3+
# Posts the benchmark report as a PR comment.
4+
#
5+
# Why this is a separate workflow: the Benchmark workflow runs untrusted fork-PR code, so it
6+
# is read-only and holds no write token. This workflow triggers on workflow_run (after that
7+
# Benchmark run completes) and therefore runs in the trusted BASE-repo context with a write
8+
# token - but it executes no PR code. It only downloads the artifacts the Benchmark run left
9+
# behind (the rendered report + the PR number) and posts the comment. This is GitHub's
10+
# recommended secure pattern for commenting on fork PRs ("pwn request" mitigation).
11+
#
12+
# Caveats:
13+
# - This file must exist on the default branch (master) for the workflow_run trigger to fire.
14+
# - Fork PRs carry an empty workflow_run.pull_requests, so the PR number is read from the
15+
# `pr-number` artifact rather than from the event payload.
16+
17+
on:
18+
workflow_run:
19+
workflows: ["Benchmark"]
20+
types: [completed]
21+
22+
permissions:
23+
contents: read
24+
actions: read # to download artifacts from the triggering run
25+
pull-requests: write # to post the comment
26+
27+
jobs:
28+
comment:
29+
# Only comment for runs that actually came from a pull_request (skip workflow_dispatch).
30+
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
31+
runs-on: ubuntu-latest
32+
steps:
33+
- name: Download benchmark report
34+
uses: actions/download-artifact@v4
35+
with:
36+
name: benchmark-report
37+
path: report
38+
run-id: ${{ github.event.workflow_run.id }}
39+
github-token: ${{ secrets.GITHUB_TOKEN }}
40+
- name: Download PR number
41+
uses: actions/download-artifact@v4
42+
with:
43+
name: pr-number
44+
path: pr
45+
run-id: ${{ github.event.workflow_run.id }}
46+
github-token: ${{ secrets.GITHUB_TOKEN }}
47+
- name: Comment on PR
48+
uses: actions/github-script@v7
49+
with:
50+
script: |
51+
const fs = require('fs');
52+
const issue_number = parseInt(fs.readFileSync('pr/pr-number.txt', 'utf8').trim(), 10);
53+
const body = fs.readFileSync('report/evaluation_report.md', 'utf8');
54+
await github.rest.issues.createComment({
55+
...context.repo,
56+
issue_number,
57+
body,
58+
});

.github/workflows/benchmark.yml

Lines changed: 72 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@ name: Benchmark
44
# duration in parallel, then aggregate and statistically compare the campaigns.
55
#
66
# Build stages (so campaigns never recompile - 2 builds total instead of 2*N):
7-
# - build-base: the version-independent base image (Bitcoin Core + AFL++ + toolchain).
8-
# - build-fuzzer: per version, bakes that ref's compiled fuzzer + Nyx share dir on top
9-
# of the base (Dockerfile.libafl.bench), cached per version.
7+
# - build-base: builds the version-independent base image (Bitcoin Core + AFL++ +
8+
# toolchain) and pushes it to the runs-on ephemeral ECR registry, tagged by the hash
9+
# of its build inputs so it is reused across runs AND branches (ECR is org-shared,
10+
# unlike branch-scoped gha cache - which is why the base used to rebuild every PR).
11+
# - build-fuzzer: per version, FROM the base, bakes that ref's compiled fuzzer + Nyx
12+
# share dir (Dockerfile.libafl.bench) and pushes a per-version image to ECR.
13+
# - campaign: just `docker pull`s its per-version image - no build, no recompile.
1014
# Bitcoin Core (the target) is identical across both arms, so the comparison isolates the
1115
# fuzzer change. See ci/benchmark-evaluation.py for the metrics.
1216
#
13-
# Runner specs are inlined in each job's `runs-on:` label (runs-on.com syntax); there is
14-
# no .github/runs-on.yml. Campaign runners are pinned to a single exact instance type so
15-
# every campaign runs on identical hardware (see the campaign job).
17+
# Requires the runs-on stack's Ephemeral Registry feature (the `ecr-cache` extra auto-logs
18+
# the runner into ECR before each job). Runner specs are inlined in each job's `runs-on:`
19+
# label (runs-on.com syntax); there is no .github/runs-on.yml. Campaign runners are pinned
20+
# to a single exact instance type so every campaign runs on identical hardware.
1621
#
1722
# Triggers:
1823
# - workflow_dispatch: compare two arbitrary refs (full control over N / duration).
@@ -25,8 +30,8 @@ on:
2530
baseline_ref:
2631
description: "Baseline fuzzamoto git ref"
2732
default: "master"
28-
treatment_ref:
29-
description: "Treatment fuzzamoto git ref"
33+
experiment_ref:
34+
description: "Experiment fuzzamoto git ref"
3035
required: true
3136
runs:
3237
description: "Campaigns per version (N)"
@@ -39,8 +44,10 @@ on:
3944
types: [labeled]
4045

4146
permissions:
42-
contents: read
43-
pull-requests: write # for the PR comment
47+
contents: read # read-only: this workflow runs untrusted fork PR code, so it must not
48+
# hold a write token. The PR comment is posted by the separate benchmark-comment.yml,
49+
# which triggers on workflow_run in the trusted base-repo context (GitHub's recommended
50+
# pattern for commenting on fork PRs).
4451

4552
jobs:
4653
setup:
@@ -53,7 +60,7 @@ jobs:
5360
- extras=s3-cache
5461
outputs:
5562
baseline: ${{ steps.refs.outputs.baseline }}
56-
treatment: ${{ steps.refs.outputs.treatment }}
63+
experiment: ${{ steps.refs.outputs.experiment }}
5764
duration: ${{ steps.refs.outputs.duration }}
5865
runs_json: ${{ steps.refs.outputs.runs_json }} # e.g. "[1,2,...,10]"
5966
spot: ${{ steps.refs.outputs.spot }} # true for smoke tests, false for real benchmarks
@@ -65,7 +72,7 @@ jobs:
6572
SPOT=false
6673
if [ "${{ github.event_name }}" = "pull_request" ]; then
6774
echo "baseline=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT"
68-
echo "treatment=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT"
75+
echo "experiment=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT"
6976
if [ "${{ github.event.label.name }}" = "needs benchmark smoke" ]; then
7077
N=2
7178
echo "duration=300" >> "$GITHUB_OUTPUT"
@@ -76,7 +83,7 @@ jobs:
7683
fi
7784
else
7885
echo "baseline=${{ inputs.baseline_ref }}" >> "$GITHUB_OUTPUT"
79-
echo "treatment=${{ inputs.treatment_ref }}" >> "$GITHUB_OUTPUT"
86+
echo "experiment=${{ inputs.experiment_ref }}" >> "$GITHUB_OUTPUT"
8087
N=${{ inputs.runs }}
8188
echo "duration=${{ inputs.duration_secs }}" >> "$GITHUB_OUTPUT"
8289
fi
@@ -85,71 +92,70 @@ jobs:
8592
8693
build-base:
8794
needs: setup
88-
# Fat instance to build the version-independent base image (Bitcoin Core + AFL++ + deps).
95+
# Build the version-independent base image (Bitcoin Core + AFL++ + deps) and push it to
96+
# ECR, tagged by the hash of its build inputs so it is reused across runs and branches.
97+
# The registry build cache keeps re-runs fast when those inputs are unchanged.
8998
runs-on:
9099
- runs-on=${{ github.run_id }}
91100
- cpu=16+32
92101
- family=c7i+m7i+c7a
93102
- image=ubuntu22-full-x64
94103
- volume=120gb:gp3
95-
- extras=s3-cache # S3-backed docker layer cache (no 10GB cap)
104+
- extras=ecr-cache # auto-login to the ephemeral ECR registry
96105
- spot=true
106+
outputs:
107+
base_image: ${{ steps.meta.outputs.base_image }}
97108
steps:
98109
- uses: actions/checkout@v4 # Bitcoin Core build is version-independent
110+
- id: meta
111+
# Content-addressed tag: rebuilt only when the base build inputs actually change.
112+
run: echo "base_image=${{ env.RUNS_ON_ECR_CACHE }}:base-${{ hashFiles('Dockerfile.libafl', 'target-patches/**', 'ci/**') }}" >> "$GITHUB_OUTPUT"
99113
- uses: docker/setup-buildx-action@v3
100-
- name: Build & cache the base image
114+
- name: Build & push the base image
101115
uses: docker/build-push-action@v5
102116
with:
103117
context: .
104118
file: ./Dockerfile.libafl
105-
push: false
106-
tags: fuzzamoto-libafl:bench
119+
push: true
120+
tags: ${{ steps.meta.outputs.base_image }}
107121
platforms: linux/amd64
108-
cache-from: type=gha,scope=base
109-
cache-to: type=gha,scope=base,mode=max
122+
cache-from: type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:base-buildcache
123+
cache-to: type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:base-buildcache,mode=max
110124

111125
build-fuzzer:
112126
needs: [setup, build-base]
113-
# Bake each fuzzer version (compiled binary + Nyx share dir) once, cached per version,
114-
# so the campaign jobs reuse it instead of recompiling. Only compiles the fuzzamoto
115-
# crates (the heavy base is already built), so 8 CPUs is plenty and spot is fine - a
116-
# reclaim mid-bake just retries and resumes from the layer cache.
127+
# Bake each fuzzer version (compiled binary + Nyx share dir) FROM the base image and push
128+
# a per-version image to ECR, so campaigns just pull it. Only compiles the fuzzamoto
129+
# crates (the heavy base is prebuilt), so 8 CPUs is plenty and spot is fine - a reclaim
130+
# mid-bake just retries and resumes from the registry build cache.
117131
runs-on:
118132
- runs-on=${{ github.run_id }}
119133
- cpu=8
120134
- family=c7i+m7i+c7a
121135
- image=ubuntu22-full-x64
122136
- volume=120gb:gp3
123-
- extras=s3-cache
137+
- extras=ecr-cache
124138
- spot=true
125139
- retry=when-interrupted
126140
strategy:
127141
matrix:
128-
version: [baseline, treatment]
142+
version: [baseline, experiment]
129143
steps:
130144
- name: Checkout fuzzer version to bake
131145
uses: actions/checkout@v4
132146
with:
133-
ref: ${{ matrix.version == 'baseline' && needs.setup.outputs.baseline || needs.setup.outputs.treatment }}
147+
ref: ${{ matrix.version == 'baseline' && needs.setup.outputs.baseline || needs.setup.outputs.experiment }}
134148
- uses: docker/setup-buildx-action@v3
135-
- name: Materialize base image from cache
136-
uses: docker/build-push-action@v5
137-
with:
138-
context: .
139-
file: ./Dockerfile.libafl
140-
load: true
141-
tags: fuzzamoto-libafl:bench
142-
cache-from: type=gha,scope=base
143-
- name: Build & cache the per-version fuzzer image
149+
- name: Build & push the per-version fuzzer image
144150
uses: docker/build-push-action@v5
145151
with:
146152
context: .
147153
file: ./Dockerfile.libafl.bench
148-
build-args: BASE_IMAGE=fuzzamoto-libafl:bench
149-
push: false
150-
tags: fuzzamoto-libafl:bench-${{ matrix.version }}
151-
cache-from: type=gha,scope=bench-${{ matrix.version }}
152-
cache-to: type=gha,scope=bench-${{ matrix.version }},mode=max
154+
build-args: BASE_IMAGE=${{ needs.build-base.outputs.base_image }}
155+
push: true
156+
tags: ${{ env.RUNS_ON_ECR_CACHE }}:bench-${{ github.run_id }}-${{ matrix.version }}
157+
cache-from: type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:bench-${{ matrix.version }}-buildcache
158+
cache-to: type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:bench-${{ matrix.version }}-buildcache,mode=max
153159

154160
campaign:
155161
needs: [setup, build-fuzzer]
@@ -164,37 +170,18 @@ jobs:
164170
- image=ubuntu22-full-x64
165171
- nested-virt
166172
- volume=80gb:gp3
167-
- extras=s3-cache
173+
- extras=ecr-cache
168174
- spot=${{ needs.setup.outputs.spot }}
169-
timeout-minutes: 90 # 60m campaign + image-restore headroom
175+
timeout-minutes: 90 # 60m campaign + image-pull headroom
170176
strategy:
171177
fail-fast: false # a dead campaign must not cancel the others
172178
matrix:
173-
version: [baseline, treatment]
179+
version: [baseline, experiment]
174180
run: ${{ fromJSON(needs.setup.outputs.runs_json) }}
175181
steps:
176-
- name: Checkout fuzzer version under test
177-
uses: actions/checkout@v4
178-
with:
179-
ref: ${{ matrix.version == 'baseline' && needs.setup.outputs.baseline || needs.setup.outputs.treatment }}
180-
- uses: docker/setup-buildx-action@v3
181-
- name: Materialize base image from cache
182-
uses: docker/build-push-action@v5
183-
with:
184-
context: .
185-
file: ./Dockerfile.libafl
186-
load: true
187-
tags: fuzzamoto-libafl:bench
188-
cache-from: type=gha,scope=base
189-
- name: Restore prebuilt fuzzer image from cache
190-
uses: docker/build-push-action@v5
191-
with:
192-
context: .
193-
file: ./Dockerfile.libafl.bench
194-
build-args: BASE_IMAGE=fuzzamoto-libafl:bench
195-
load: true
196-
tags: fuzzamoto-libafl:bench-${{ matrix.version }}
197-
cache-from: type=gha,scope=bench-${{ matrix.version }} # cache hit incl. compile
182+
- name: Pull prebuilt fuzzer image
183+
# $RUNS_ON_ECR_CACHE is exported into the step env by the runs-on pre-job hook.
184+
run: docker pull "$RUNS_ON_ECR_CACHE:bench-${{ github.run_id }}-${{ matrix.version }}"
198185
- name: Enable KVM vmware backdoor (required by Nyx)
199186
run: |
200187
sudo modprobe -r kvm_intel 2>/dev/null || true
@@ -212,7 +199,8 @@ jobs:
212199
docker run --privileged \
213200
-e BENCH_DURATION=${{ needs.setup.outputs.duration }} \
214201
-v /tmp/out:/tmp/out \
215-
fuzzamoto-libafl:bench-${{ matrix.version }} just -f /ci/libafl.justfile bench_run
202+
"$RUNS_ON_ECR_CACHE:bench-${{ github.run_id }}-${{ matrix.version }}" \
203+
just -f /ci/libafl.justfile bench_run
216204
- name: Collect results
217205
run: |
218206
mkdir -p artifact
@@ -240,23 +228,22 @@ jobs:
240228
- cpu=2+4
241229
- family=m7i+c7i
242230
- image=ubuntu22-full-x64
243-
- extras=s3-cache
244231
steps:
245232
- uses: actions/checkout@v4
246233
- uses: actions/download-artifact@v4
247234
with:
248235
pattern: bench-*
249236
path: dl
250-
- name: Arrange into baseline/treatment layout
237+
- name: Arrange into baseline/experiment layout
251238
run: |
252239
shopt -s nullglob
253240
for d in dl/bench-baseline-*; do
254241
n=${d##*-}; mkdir -p results/baseline/ir/$n
255242
cp "$d"/bench-cpu_*.csv results/baseline/ir/$n/ 2>/dev/null || true
256243
done
257-
for d in dl/bench-treatment-*; do
258-
n=${d##*-}; mkdir -p results/treatment/ir/$n
259-
cp "$d"/bench-cpu_*.csv results/treatment/ir/$n/ 2>/dev/null || true
244+
for d in dl/bench-experiment-*; do
245+
n=${d##*-}; mkdir -p results/experiment/ir/$n
246+
cp "$d"/bench-cpu_*.csv results/experiment/ir/$n/ 2>/dev/null || true
260247
done
261248
- name: Install evaluation dependencies
262249
run: pip install numpy pandas matplotlib seaborn scipy statsmodels tabulate
@@ -266,7 +253,7 @@ jobs:
266253
{
267254
echo "## fuzzamoto-libafl benchmark"
268255
echo
269-
echo "Baseline: \`${{ needs.setup.outputs.baseline }}\` • Treatment: \`${{ needs.setup.outputs.treatment }}\`"
256+
echo "Baseline: \`${{ needs.setup.outputs.baseline }}\` • Experiment: \`${{ needs.setup.outputs.experiment }}\`"
270257
echo
271258
cat report/evaluation_report.md
272259
} >> "$GITHUB_STEP_SUMMARY"
@@ -275,15 +262,17 @@ jobs:
275262
name: benchmark-report
276263
path: report
277264
retention-days: 90
278-
- name: Comment on PR
265+
- name: Save PR number for the comment workflow
266+
# Fork PRs carry an empty workflow_run.pull_requests, so the trusted comment
267+
# workflow can't recover the PR number from the event - we stash it here and it
268+
# reads it back from this artifact.
269+
if: github.event_name == 'pull_request'
270+
run: |
271+
mkdir -p pr
272+
echo "${{ github.event.pull_request.number }}" > pr/pr-number.txt
273+
- uses: actions/upload-artifact@v4
279274
if: github.event_name == 'pull_request'
280-
uses: actions/github-script@v7
281275
with:
282-
script: |
283-
const fs = require('fs');
284-
const body = fs.readFileSync('report/evaluation_report.md', 'utf8');
285-
await github.rest.issues.createComment({
286-
...context.repo,
287-
issue_number: context.issue.number,
288-
body,
289-
});
276+
name: pr-number
277+
path: pr
278+
retention-days: 14

0 commit comments

Comments
 (0)