Skip to content

Commit e2a0605

Browse files
authored
Merge pull request #29 from lpgauth/pgo-build-tooling
Add profile-guided optimisation build pipeline
2 parents 23a0505 + b220198 commit e2a0605

5 files changed

Lines changed: 301 additions & 10 deletions

File tree

.github/workflows/bench.yml

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ jobs:
2626
elixir-version: "1.18"
2727

2828
- uses: dtolnay/rust-toolchain@stable
29+
with:
30+
components: llvm-tools-preview
2931

3032
- name: Cache Mix deps
3133
uses: actions/cache@v4
@@ -48,15 +50,47 @@ jobs:
4850
- name: Fix simdjsone Makefile priv dir bug
4951
run: sed -i 's|$(BASEDIR)/priv|$(PRIV_DIR)|g' deps/simdjsone/c_src/Makefile
5052

51-
- name: Compile
52-
run: mix compile
53+
# PGO pass 1: build torque instrumented and run a representative workload
54+
# to emit profile data. RUSTFLAGS replaces (not appends to) the crate's
55+
# .cargo/config.toml, so target-cpu=native is carried through here.
56+
- name: Build instrumented torque & collect PGO profile
57+
env:
58+
RUSTFLAGS: "-C target-cpu=native -Cprofile-generate=${{ github.workspace }}/pgo-data"
59+
LLVM_PROFILE_FILE: "${{ github.workspace }}/pgo-data/torque-%p-%m.profraw"
60+
run: |
61+
mkdir -p pgo-data
62+
mix compile
63+
mix run bench/pgo_workload.exs
64+
65+
- name: Merge PGO profile data
66+
run: |
67+
shopt -s nullglob
68+
raw=(pgo-data/*.profraw)
69+
if [ ${#raw[@]} -eq 0 ]; then
70+
echo "::error::no .profraw files produced by the PGO workload"
71+
exit 1
72+
fi
73+
host="$(rustc -vV | sed -n 's/^host: //p')"
74+
profdata="$(rustc --print sysroot)/lib/rustlib/${host}/bin/llvm-profdata"
75+
if [ ! -x "$profdata" ]; then
76+
echo "::error::llvm-profdata not found at $profdata (is the llvm-tools-preview component installed?)"
77+
exit 1
78+
fi
79+
"$profdata" merge -o pgo-data/merged.profdata "${raw[@]}"
80+
ls -la pgo-data
5381
54-
- name: Run benchmarks
55-
run: mix run bench/torque_bench.exs
82+
# PGO pass 2: rebuild torque with the collected profile, then benchmark.
83+
# The changed RUSTFLAGS forces cargo to recompile against merged.profdata.
84+
- name: Run benchmarks (PGO build)
5685
env:
86+
RUSTFLAGS: "-C target-cpu=native -Cprofile-use=${{ github.workspace }}/pgo-data/merged.profdata"
5787
BENCH_OUTPUT: json
88+
run: |
89+
mix compile
90+
mix run bench/torque_bench.exs
5891
5992
- name: Store trend data
93+
if: github.ref == 'refs/heads/main'
6094
uses: benchmark-action/github-action-benchmark@v1
6195
with:
6296
name: Torque Benchmarks
@@ -70,6 +104,7 @@ jobs:
70104
fail-on-alert: false
71105

72106
- name: Deploy comparison data and custom page
107+
if: github.ref == 'refs/heads/main'
73108
run: |
74109
git fetch origin gh-pages
75110
git worktree add /tmp/gh-pages -B gh-pages origin/gh-pages

.github/workflows/release.yml

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ on:
44
push:
55
tags:
66
- "v*"
7+
# Manual build-only run for validating the workflow (incl. PGO) without
8+
# cutting a release: create/upload steps are gated on a tag, so a dispatch
9+
# builds every target but publishes nothing.
10+
workflow_dispatch:
711

812
jobs:
913
create_release:
@@ -13,6 +17,7 @@ jobs:
1317
contents: write
1418
steps:
1519
- name: Create release
20+
if: startsWith(github.ref, 'refs/tags/')
1621
env:
1722
GH_TOKEN: ${{ github.token }}
1823
run: gh release create "${{ github.ref_name }}" --repo "${{ github.repository }}" --title "${{ github.ref_name }}" --draft
@@ -29,15 +34,18 @@ jobs:
2934
fail-fast: false
3035
matrix:
3136
nif: ["2.15"]
37+
# pgo: true is set only where the runner can natively run the
38+
# instrumented NIF to collect a profile (runner arch == target arch).
39+
# Cross-compiled targets build plain -O3 (no native runner to profile on).
3240
job:
33-
- { target: aarch64-apple-darwin, os: macos-14 }
41+
- { target: aarch64-apple-darwin, os: macos-14, pgo: true }
3442
- { target: aarch64-unknown-linux-gnu, os: ubuntu-22.04, use-cross: true }
3543
- { target: x86_64-apple-darwin, os: macos-14, rustflags: "-C target-cpu=x86-64" }
3644
- { target: x86_64-apple-darwin, os: macos-14, rustflags: "-C target-cpu=x86-64-v2", variant: v2 }
3745
- { target: x86_64-apple-darwin, os: macos-14, rustflags: "-C target-cpu=x86-64-v3", variant: v3 }
38-
- { target: x86_64-unknown-linux-gnu, os: ubuntu-22.04, rustflags: "-C target-cpu=x86-64" }
39-
- { target: x86_64-unknown-linux-gnu, os: ubuntu-22.04, rustflags: "-C target-cpu=x86-64-v2", variant: v2 }
40-
- { target: x86_64-unknown-linux-gnu, os: ubuntu-22.04, rustflags: "-C target-cpu=x86-64-v3", variant: v3 }
46+
- { target: x86_64-unknown-linux-gnu, os: ubuntu-22.04, rustflags: "-C target-cpu=x86-64", pgo: true }
47+
- { target: x86_64-unknown-linux-gnu, os: ubuntu-22.04, rustflags: "-C target-cpu=x86-64-v2", variant: v2, pgo: true }
48+
- { target: x86_64-unknown-linux-gnu, os: ubuntu-22.04, rustflags: "-C target-cpu=x86-64-v3", variant: v3, pgo: true }
4149

4250
steps:
4351
- name: Checkout source code
@@ -52,6 +60,9 @@ jobs:
5260
uses: dtolnay/rust-toolchain@stable
5361
with:
5462
target: ${{ matrix.job.target }}
63+
# llvm-tools-preview provides the version-matched llvm-profdata used to
64+
# merge the PGO profile (no-op for non-PGO jobs).
65+
components: llvm-tools-preview
5566

5667
- uses: Swatinem/rust-cache@v2
5768
with:
@@ -63,12 +74,57 @@ jobs:
6374
shell: bash
6475
run: cargo install cross --git https://github.com/cross-rs/cross
6576

77+
- name: Set up Elixir for PGO profiling (Linux)
78+
if: matrix.job.pgo && runner.os == 'Linux'
79+
uses: erlef/setup-beam@v1
80+
with:
81+
otp-version: "27"
82+
elixir-version: "1.18"
83+
84+
- name: Set up Elixir for PGO profiling (macOS)
85+
if: matrix.job.pgo && runner.os == 'macOS'
86+
shell: bash
87+
run: brew install elixir
88+
89+
- name: Collect PGO profile
90+
if: matrix.job.pgo
91+
shell: bash
92+
run: |
93+
set -euo pipefail
94+
mix local.hex --force
95+
mix local.rebar --force
96+
export TORQUE_BUILD=true
97+
PGO_DIR="$PWD/pgo"
98+
rm -rf "$PGO_DIR"; mkdir -p "$PGO_DIR"
99+
mix deps.get
100+
# Build an instrumented NIF (generic CPU so it runs on this runner) and
101+
# exercise it through the BEAM to collect a same-arch profile. The
102+
# workload has no external deps, so a bare mix env suffices.
103+
RUSTFLAGS="-Cprofile-generate=$PGO_DIR" mix compile
104+
RUSTFLAGS="-Cprofile-generate=$PGO_DIR" mix run bench/pgo_workload.exs
105+
# Merge with the toolchain's version-matched llvm-profdata.
106+
HOST="$(rustc -vV | sed -n 's/^host: //p')"
107+
PROFDATA="$(rustc --print sysroot)/lib/rustlib/${HOST}/bin/llvm-profdata"
108+
if [ ! -x "$PROFDATA" ]; then
109+
echo "::error::llvm-profdata not found at $PROFDATA (is the llvm-tools-preview component installed?)"
110+
exit 1
111+
fi
112+
"$PROFDATA" merge -o "$PGO_DIR/merged.profdata" "$PGO_DIR"/*.profraw
113+
echo "PGO_FLAGS=-Cprofile-use=$PGO_DIR/merged.profdata" >> "$GITHUB_ENV"
114+
echo "::notice::Collected PGO profile for ${{ matrix.job.target }}${{ matrix.job.variant && format(' ({0})', matrix.job.variant) || '' }}"
115+
66116
- name: Build native library
67117
shell: bash
68118
run: |
69-
if [ -n "${{ matrix.job.rustflags }}" ]; then
70-
export RUSTFLAGS="${{ matrix.job.rustflags }}"
119+
set -euo pipefail
120+
FLAGS="${{ matrix.job.rustflags }}"
121+
if [ -n "${PGO_FLAGS:-}" ]; then
122+
FLAGS="$FLAGS ${PGO_FLAGS}"
123+
echo "Building ${{ matrix.job.target }} with PGO"
124+
else
125+
echo "Building ${{ matrix.job.target }} without PGO"
71126
fi
127+
if [ -n "$FLAGS" ]; then export RUSTFLAGS="$FLAGS"; fi
72128
if [ "${{ matrix.job.use-cross }}" = "true" ]; then
73129
RUSTLER_NIF_VERSION=${{ matrix.nif }} cross build --release --target ${{ matrix.job.target }} --package torque_nif
74130
else

CLAUDE.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,41 @@ MIX_ENV=bench mix run bench/torque_bench.exs # run benchmarks (requires simdjso
2121

2222
`TORQUE_BUILD=true` is required for local development to force compilation from Rust source instead of downloading precompiled binaries. Without it, `RustlerPrecompiled` will try to fetch binaries from GitHub releases.
2323

24+
## Profile-Guided Optimisation (PGO)
25+
26+
```bash
27+
./scripts/pgo-build.sh # instrument -> run workload -> merge -> rebuild optimised
28+
```
29+
30+
Produces an optimised `priv/native/torque_nif.so` (typically 5-15% faster on
31+
JSON-heavy work than plain `-O3`). The script builds an instrumented NIF, runs
32+
`bench/pgo_workload.exs` to collect branch/call-frequency data, merges the raw
33+
`*.profraw` counters with `llvm-profdata`, then rebuilds with `-Cprofile-use`.
34+
Like any `TORQUE_BUILD` build it overwrites `priv/native/torque_nif.so`, so
35+
re-run `mix compile` (without PGO) to get back to a plain build.
36+
37+
Notes:
38+
- rustc is LLVM-based, so PGO uses the same `llvm-profdata merge` step as a
39+
Clang PGO build. The merge tool's LLVM major version **must** match rustc's
40+
(`rustc -vV`); the script auto-detects a matching one (rustup
41+
`llvm-tools-preview`, Homebrew `llvm@<major>`, or PATH) — override with
42+
`LLVM_PROFDATA=...` if detection misses.
43+
- Setting `RUSTFLAGS` replaces `native/torque_nif/.cargo/config.toml`'s
44+
rustflags rather than merging, so the script re-states `-C target-cpu=native`.
45+
Keep `BASE_RUSTFLAGS` in `scripts/pgo-build.sh` in sync with that config.
46+
- Point the script at a different workload with `WORKLOAD=path/to.exs`.
47+
48+
The release workflow (`release.yml`) applies the same profile → rebuild step to
49+
the targets it builds on a native runner (`aarch64-apple-darwin`,
50+
`x86_64-unknown-linux-gnu`): it builds an instrumented NIF, runs
51+
`bench/pgo_workload.exs` through the BEAM to collect a same-arch profile, then
52+
rebuilds with `-Cprofile-use`. The cross-compiled targets (`x86_64-apple-darwin`
53+
built on the arm runner, and `aarch64-unknown-linux-gnu` via `cross`) build
54+
plain `-O3`, because PGO needs to *run* the instrumented binary and there's no
55+
native runner for them. Trigger `release.yml` via `workflow_dispatch` to
56+
build-and-profile every target without publishing (create/upload are gated on a
57+
tag).
58+
2459
## Releasing
2560

2661
```bash

bench/pgo_workload.exs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# PGO training workload for the torque NIF.
2+
#
3+
# Not a benchmark — it exercises every hot path (decode, encode, parse, get)
4+
# over representative small and large payloads so the Profile-Guided
5+
# Optimisation build (scripts/pgo-build.sh, and the release CI) can collect
6+
# realistic branch and call-frequency data. Run via that tooling with an
7+
# instrumented NIF; running it directly does nothing useful.
8+
#
9+
# It deliberately has NO external dependencies (JSON is built as strings rather
10+
# than via an encoder), so it runs in a bare mix environment — important for CI
11+
# where pulling the full bench dep tree would be heavy and fragile.
12+
#
13+
# Both a small (<20 KB, normal scheduler) and a large (>20 KB, dirty CPU
14+
# scheduler) payload are covered, since torque dispatches on input size.
15+
16+
# Unicode is expressed as JSON \u escapes (ASCII source) so the file stays
17+
# parse-clean while still exercising the decoder's unescape and the encoder's
18+
# escape paths.
19+
small_json =
20+
~s({"id":"req-0001","site":{"domain":"example.com","page":"https://example.com/articles/x","cat":["IAB1","IAB2-3"],"publisher":{"id":"pub-12345"}},"device":{"devicetype":2,"ua":"Mozilla/5.0 Macintosh; Intel Mac OS X 10_15_7 Chrome/120.0.0.0","ip":"203.0.113.42","geo":{"country":"US","lat":40.7128,"lon":-74.006,"zip":"10001"},"connectiontype":2},"user":{"id":"u-abcdef","name":"caf\\u00e9 r\\u00e9sum\\u00e9 \\u2728"},"imp":[{"id":"imp-1","banner":{"w":300,"h":250},"bidfloor":0.5},{"id":"imp-2","video":{"mimes":["video/mp4"],"maxduration":30},"bidfloor":2.0}],"regs":{"coppa":0},"ext":null,"test":true})
21+
22+
record = fn i ->
23+
~s({"metadata":{"result_type":"recent","iso_language_code":"en"},"id":#{505_874_924_000_000_000 + i},"id_str":"#{505_874_924_000_000_000 + i}","text":"Sample tweet #{i} lorem ipsum dolor sit amet consectetur adipiscing elit","truncated":false,"in_reply_to_status_id":null,"user":{"id":#{1_000_000 + i},"screen_name":"username_#{i}","location":"San Francisco, CA","url":null,"followers_count":#{rem(i * 1337, 100_000)},"verified":false,"lang":"en","profile_image_url":"http://pbs.twimg.com/profile_images/#{i}/photo.jpeg"},"geo":null,"retweet_count":#{rem(i * 3, 1000)},"favorite_count":#{rem(i * 7, 2000)},"entities":{"hashtags":[{"text":"elixir","indices":[15,22]}],"urls":[],"user_mentions":[{"screen_name":"user_#{i}","id":#{2_000_000 + i}}]},"favorited":false,"lang":"en"})
24+
end
25+
26+
large_json =
27+
~s({"statuses":[) <>
28+
Enum.map_join(1..200, ",", record) <>
29+
~s(],"search_metadata":{"count":200,"completed_in":0.035,"max_id":505874924095815681,"query":"%23elixir"}})
30+
31+
small_term = Torque.decode!(small_json)
32+
large_term = Torque.decode!(large_json)
33+
34+
to_proplist = fn f, v ->
35+
cond do
36+
is_map(v) -> {Enum.map(v, fn {k, val} -> {k, f.(f, val)} end)}
37+
is_list(v) -> Enum.map(v, &f.(f, &1))
38+
true -> v
39+
end
40+
end
41+
42+
small_proplist = to_proplist.(to_proplist, small_term)
43+
large_proplist = to_proplist.(to_proplist, large_term)
44+
45+
fields = ~w(/id /site/domain /site/page /site/publisher/id /site/cat
46+
/device/devicetype /device/ua /device/ip /device/geo/country
47+
/device/geo/lat /device/connectiontype /user/id /imp /regs/coppa)
48+
49+
IO.puts("PGO workload: small=#{byte_size(small_json)}B large=#{byte_size(large_json)}B")
50+
51+
decode = fn ->
52+
Torque.decode!(small_json)
53+
Torque.decode!(large_json)
54+
end
55+
56+
encode = fn ->
57+
Torque.encode!(small_term)
58+
Torque.encode!(large_term)
59+
Torque.encode_to_iodata(small_term)
60+
Torque.encode_to_iodata(large_term)
61+
Torque.encode!(small_proplist)
62+
Torque.encode!(large_proplist)
63+
end
64+
65+
parse_get = fn ->
66+
{:ok, doc} = Torque.parse(small_json)
67+
{:ok, doc_uk} = Torque.parse(small_json, unique_keys: true)
68+
Enum.each(fields, &Torque.get(doc, &1))
69+
Torque.get_many(doc, fields)
70+
Torque.get_many_nil(doc, fields)
71+
Torque.get_many(doc_uk, fields)
72+
end
73+
74+
Enum.each(1..5_000, fn _ -> decode.() end)
75+
Enum.each(1..5_000, fn _ -> encode.() end)
76+
Enum.each(1..10_000, fn _ -> parse_get.() end)
77+
78+
IO.puts("PGO workload complete")

scripts/pgo-build.sh

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Profile-Guided Optimisation build for the torque NIF.
4+
#
5+
# Builds an instrumented NIF, runs a representative JSON workload to collect
6+
# branch/call-frequency data, then rebuilds the NIF using that profile. The
7+
# result is an optimised priv/native/torque_nif.so that mix loads in place of
8+
# the plain build. Typical gain on a JSON-heavy workload is 5-15% over -O3.
9+
#
10+
# ./scripts/pgo-build.sh
11+
#
12+
# rustc is LLVM-based, so PGO uses the same mechanism (and the same
13+
# `llvm-profdata merge` step) as a Clang/LLVM PGO build. The one subtlety:
14+
# the raw *.profraw counters must be merged by an llvm-profdata whose LLVM
15+
# major version matches the one rustc was built against, otherwise the merge
16+
# fails with a profile-format-version error. We locate a matching tool below;
17+
# override with LLVM_PROFDATA=/path/to/llvm-profdata if detection misses.
18+
set -euo pipefail
19+
20+
cd "$(dirname "$0")/.."
21+
22+
PGO_DIR="$PWD/target/pgo"
23+
MERGED="$PGO_DIR/merged.profdata"
24+
WORKLOAD="${WORKLOAD:-bench/pgo_workload.exs}"
25+
26+
# Must mirror native/torque_nif/.cargo/config.toml: setting RUSTFLAGS replaces
27+
# (does not merge with) the config's rustflags, so re-state target-cpu=native
28+
# or the SIMD build regresses.
29+
BASE_RUSTFLAGS="-C target-cpu=native"
30+
31+
# rustc's LLVM major version — the profraw format is keyed to it.
32+
RUSTC_LLVM_MAJOR="$(rustc -vV | sed -n 's/^LLVM version: \([0-9][0-9]*\).*/\1/p')"
33+
34+
profdata_major() { "$1" --version 2>/dev/null | sed -n 's/.*LLVM version \([0-9][0-9]*\).*/\1/p'; }
35+
36+
find_profdata() {
37+
local host sysroot cand
38+
host="$(rustc -vV | sed -n 's/^host: //p')"
39+
sysroot="$(rustc --print sysroot)"
40+
for cand in \
41+
"${LLVM_PROFDATA:-}" \
42+
"$sysroot/lib/rustlib/$host/bin/llvm-profdata" \
43+
"$(brew --prefix "llvm@$RUSTC_LLVM_MAJOR" 2>/dev/null)/bin/llvm-profdata" \
44+
"$(brew --prefix llvm 2>/dev/null)/bin/llvm-profdata" \
45+
"$(command -v llvm-profdata 2>/dev/null || true)"
46+
do
47+
[ -n "$cand" ] && [ -x "$cand" ] || continue
48+
if [ "$(profdata_major "$cand")" = "$RUSTC_LLVM_MAJOR" ]; then
49+
echo "$cand"; return 0
50+
fi
51+
done
52+
return 1
53+
}
54+
55+
if ! PROFDATA="$(find_profdata)"; then
56+
echo "error: no llvm-profdata matching rustc's LLVM $RUSTC_LLVM_MAJOR found." >&2
57+
echo " install a matching LLVM (rustup: 'rustup component add llvm-tools-preview';" >&2
58+
echo " Homebrew: 'brew install llvm@$RUSTC_LLVM_MAJOR') or set LLVM_PROFDATA=..." >&2
59+
exit 1
60+
fi
61+
echo "==> using llvm-profdata: $PROFDATA (LLVM $RUSTC_LLVM_MAJOR)"
62+
63+
export TORQUE_BUILD=true
64+
65+
rm -rf "$PGO_DIR"
66+
mkdir -p "$PGO_DIR"
67+
68+
echo "==> PGO 1/3: build instrumented NIF"
69+
RUSTFLAGS="$BASE_RUSTFLAGS -Cprofile-generate=$PGO_DIR" mix compile --force
70+
71+
echo "==> PGO 2/3: collect profile data ($WORKLOAD)"
72+
RUSTFLAGS="$BASE_RUSTFLAGS -Cprofile-generate=$PGO_DIR" mix run "$WORKLOAD"
73+
74+
if ! ls "$PGO_DIR"/*.profraw >/dev/null 2>&1; then
75+
echo "error: no *.profraw produced — did the workload exercise the NIF?" >&2
76+
exit 1
77+
fi
78+
79+
echo "==> merging profile data into $(basename "$MERGED")"
80+
"$PROFDATA" merge -o "$MERGED" "$PGO_DIR"/*.profraw
81+
82+
echo "==> PGO 3/3: rebuild using profile"
83+
# LLVM stays quiet about functions the workload never reached unless
84+
# -pgo-warn-missing-function is passed, so no suppression flag is needed.
85+
RUSTFLAGS="$BASE_RUSTFLAGS -Cprofile-use=$MERGED" mix compile --force
86+
87+
echo "==> PGO build complete: priv/native/torque_nif.so"

0 commit comments

Comments
 (0)