name: Profile
on:
workflow_dispatch:
inputs:
profile:
description: sf-decomposer fixture profile
type: choice
default: large
options: [small, medium, large, xlarge, manyfiles]
format:
description: Shard format passed to disassemble
type: choice
default: xml
options: [xml, json, json5, yaml]
sf_decomposer_ref:
description: sf-decomposer ref to take the fixture generator from
type: string
default: main
compare_ref:
description: Same-runner A/B against this ref (e.g. main). Empty = profile only.
type: string
default: ''
rounds:
description: A/B rounds (only with compare_ref)
type: string
default: '6'
pull_request:
paths:
- examples/replay_workload.rs
- .github/workflows/profile.yml
permissions:
contents: read
concurrency:
group: profile-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
PROFILE: ${{ inputs.profile || 'large' }}
FORMAT: ${{ inputs.format || 'xml' }}
CARGO_TERM_COLOR: never
RUSTFLAGS: -C force-frame-pointers=yes
CARGO_PROFILE_RELEASE_DEBUG: line-tables-only
jobs:
profile:
if: ${{ !inputs.compare_ref }}
name: Profile (${{ inputs.profile || 'large' }}, ${{ inputs.format || 'xml' }})
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Checkout sf-decomposer (fixture generator)
uses: actions/checkout@v7
with:
repository: mcarvin8/sf-decomposer
ref: ${{ inputs.sf_decomposer_ref || 'main' }}
path: sf-decomposer
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: lts/*
- name: Generate fixtures
run: |
npx --yes tsx scripts/gen-perf-fixtures.ts --profile "$PROFILE" --out "$RUNNER_TEMP/fixtures"
working-directory: sf-decomposer
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v2
with:
toolchain: stable
cache: false
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
- name: Install inferno (flamegraphs)
uses: taiki-e/install-action@v2
with:
tool: inferno
- name: Install perf
run: |
sudo apt-get update -qq
# Package layout differs across runner images: newer Ubuntu ships a
# `linux-perf` package; older ones need the kernel-matched
# linux-tools package. Try both; whichever yields a working binary wins.
sudo apt-get install -y -qq linux-tools-common
sudo apt-get install -y -qq linux-perf \
|| sudo apt-get install -y -qq "linux-tools-$(uname -r)" \
|| sudo apt-get install -y -qq linux-tools-azure || true
# Prefer a versioned binary (the /usr/bin/perf wrapper on older images
# refuses to run when the tools package doesn't match the kernel), and
# keep the first candidate that actually executes.
PERF=""
for candidate in /usr/lib/linux-tools/*/perf /usr/lib/linux-tools-*/perf \
/usr/lib/linux-perf*/perf "$(command -v perf || true)"; do
if [ -x "$candidate" ] && "$candidate" --version >/dev/null 2>&1; then
PERF="$candidate"; break
fi
done
if [ -z "$PERF" ]; then
echo "::error::no working perf binary found"; find / -name perf -type f 2>/dev/null | head; exit 1
fi
echo "PERF=$PERF" >> "$GITHUB_ENV"
"$PERF" --version
sudo sysctl -w kernel.perf_event_paranoid=-1 kernel.kptr_restrict=0
- name: Build replay
run: cargo build --release --example replay_workload
- name: Unprofiled baseline (wall time, max RSS)
run: |
BIN=target/release/examples/replay_workload
OUT="$RUNNER_TEMP/out"; mkdir -p "$OUT"
cp -r "$RUNNER_TEMP/fixtures" "$RUNNER_TEMP/base"
for phase in decompose recompose; do
/usr/bin/time -v "$BIN" "$phase" "$RUNNER_TEMP/base" --format "$FORMAT" \
2> "$OUT/time-$phase.txt"
done
{
echo "## Unprofiled baseline (profile=$PROFILE, format=$FORMAT)"
echo
echo "Compare with sf-decomposer's perf comment rows \`$PROFILE.$FORMAT.decompose\` / \`.recompose\`."
echo
echo '| Phase | Wall | User CPU (s) | Sys CPU (s) | Max RSS (MB) |'
echo '|---|---:|---:|---:|---:|'
for phase in decompose recompose; do
f="$OUT/time-$phase.txt"
wall=$(grep 'Elapsed (wall clock)' "$f" | awk '{print $NF}')
user=$(grep 'User time' "$f" | awk '{print $NF}')
sys=$(grep 'System time' "$f" | awk '{print $NF}')
rss=$(grep 'Maximum resident' "$f" | awk '{printf "%.1f", $NF/1024}')
echo "| $phase | $wall | $user | $sys | $rss |"
done
echo
} > "$OUT/summary-baseline.md"
# Job summaries aren't readable through the API; print to the log too.
cat "$OUT/summary-baseline.md" | tee -a "$GITHUB_STEP_SUMMARY"
- name: CPU profile (perf)
run: |
BIN=target/release/examples/replay_workload
OUT="$RUNNER_TEMP/out"
cp -r "$RUNNER_TEMP/fixtures" "$RUNNER_TEMP/cpu"
for phase in decompose recompose; do
"$PERF" record -F 499 -g --call-graph fp -o "$OUT/perf-$phase.data" -- \
"$BIN" "$phase" "$RUNNER_TEMP/cpu" --format "$FORMAT" 2>/dev/null
"$PERF" report -i "$OUT/perf-$phase.data" --stdio --no-children --sort symbol \
--percent-limit 0.5 2>/dev/null > "$OUT/top-self-$phase.txt"
"$PERF" report -i "$OUT/perf-$phase.data" --stdio --children --sort symbol \
--percent-limit 2 -g none 2>/dev/null > "$OUT/top-inclusive-$phase.txt"
"$PERF" script -i "$OUT/perf-$phase.data" 2>/dev/null \
| inferno-collapse-perf > "$OUT/$phase.folded"
inferno-flamegraph --title "$phase ($PROFILE, $FORMAT)" < "$OUT/$phase.folded" \
> "$OUT/flamegraph-$phase.svg"
done
{
for phase in decompose recompose; do
echo "## $phase: top functions by self time"
echo
echo '```'
grep -E '^\s+[0-9.]+%' "$OUT/top-self-$phase.txt" | head -n 30
echo '```'
echo
echo "## $phase: top functions by inclusive time"
echo
echo '```'
grep -E '^\s+[0-9.]+%' "$OUT/top-inclusive-$phase.txt" | head -n 30
echo '```'
echo
done
} > "$OUT/summary-cpu.md"
cat "$OUT/summary-cpu.md" | tee -a "$GITHUB_STEP_SUMMARY"
- name: Upload CPU profiles
if: always()
uses: actions/upload-artifact@v7
with:
name: profile-cpu-${{ env.PROFILE }}-${{ env.FORMAT }}
path: |
${{ runner.temp }}/out/*.svg
${{ runner.temp }}/out/*.txt
${{ runner.temp }}/out/*.md
${{ runner.temp }}/out/*.folded
retention-days: 30
if-no-files-found: warn
ab:
name: Same-runner A/B (${{ inputs.compare_ref }} vs ${{ github.ref_name }}, ${{ inputs.profile || 'large' }}, ${{ inputs.format || 'xml' }})
if: ${{ inputs.compare_ref }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
ROUNDS: ${{ inputs.rounds || '6' }}
BASE_REF: ${{ inputs.compare_ref }}
HEAD_REF: ${{ github.ref_name }}
steps:
- name: Checkout head (dispatched ref)
uses: actions/checkout@v7
with:
path: head
persist-credentials: false
- name: Checkout base (compare_ref)
uses: actions/checkout@v7
with:
ref: ${{ inputs.compare_ref }}
path: base
persist-credentials: false
- name: Checkout sf-decomposer (fixture generator)
uses: actions/checkout@v7
with:
repository: mcarvin8/sf-decomposer
ref: ${{ inputs.sf_decomposer_ref || 'main' }}
path: sf-decomposer
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: lts/*
- name: Generate fixtures
run: npx --yes tsx scripts/gen-perf-fixtures.ts --profile "$PROFILE" --out "$RUNNER_TEMP/fixtures"
working-directory: sf-decomposer
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v2
with:
toolchain: stable
cache: false
- name: Build both replays
run: |
for side in base head; do
if [ ! -f "$side/examples/replay_workload.rs" ]; then
echo "::error::$side has no examples/replay_workload.rs (compare_ref must include #155)"; exit 1
fi
(cd "$side" && cargo build --release --example replay_workload)
cp "$side/target/release/examples/replay_workload" "$RUNNER_TEMP/replay-$side"
done
- name: Alternating rounds on one runner
run: |
OUT="$RUNNER_TEMP/ab"; mkdir -p "$OUT"
TSV="$OUT/ab.tsv"
printf 'round\tref\tphase\twall_s\tuser_s\tsys_s\tmaxrss_mb\n' > "$TSV"
run_one() { # side round
local side=$1 round=$2 work="$RUNNER_TEMP/work-$1"
rm -rf "$work"; cp -r "$RUNNER_TEMP/fixtures" "$work"
sync; echo 3 | sudo tee /proc/sys/vm/drop_caches >/dev/null
for phase in decompose recompose; do
/usr/bin/time -f '%e\t%U\t%S\t%M' -o "$RUNNER_TEMP/t" \
"$RUNNER_TEMP/replay-$side" "$phase" "$work" --format "$FORMAT" 2>/dev/null
IFS=$'\t' read -r e u s m < "$RUNNER_TEMP/t"
printf '%s\t%s\t%s\t%s\t%s\t%s\t%.1f\n' "$round" "$side" "$phase" "$e" "$u" "$s" "$(echo "$m/1024" | bc -l)" >> "$TSV"
done
}
# One untimed warm-up each, then alternate which side goes first.
run_one base 0; run_one head 0
for r in $(seq 1 "$ROUNDS"); do
if [ $((r % 2)) -eq 1 ]; then run_one base "$r"; run_one head "$r"; else run_one head "$r"; run_one base "$r"; fi
done
cat "$TSV"
echo
python3 - "$TSV" > "$OUT/summary-ab.md" <<'PY'
import csv, os, statistics, sys
rows = [r for r in csv.DictReader(open(sys.argv[1]), delimiter='\t') if r['round'] != '0']
env = os.environ
print(f"## Same-runner A/B: `{env['BASE_REF']}` (base) vs `{env['HEAD_REF']}` (head)\n")
print(f"profile={env['PROFILE']}, format={env['FORMAT']}, {env['ROUNDS']} alternating rounds after a warm-up; medians.\n")
print('| phase | metric | base | head | change | head better in |')
print('|---|---|---:|---:|---:|---:|')
for phase in ('decompose', 'recompose'):
for metric in ('wall_s', 'user_s', 'sys_s', 'maxrss_mb'):
base = {r['round']: float(r[metric]) for r in rows if r['ref'] == 'base' and r['phase'] == phase}
head = {r['round']: float(r[metric]) for r in rows if r['ref'] == 'head' and r['phase'] == phase}
b, h = statistics.median(base.values()), statistics.median(head.values())
wins = sum(1 for k in base if head[k] < base[k])
change = f'{(h / b - 1) * 100:+.1f}%' if b else 'n/a'
print(f'| {phase} | {metric} | {b:.2f} | {h:.2f} | {change} | {wins}/{len(base)} |')
PY
cat "$OUT/summary-ab.md" | tee -a "$GITHUB_STEP_SUMMARY"
- name: Upload A/B results
if: always()
uses: actions/upload-artifact@v7
with:
name: ab-${{ env.PROFILE }}-${{ env.FORMAT }}
path: ${{ runner.temp }}/ab/
retention-days: 30
if-no-files-found: warn