name: Bench
on:
schedule:
- cron: "37 3 * * *"
timezone: "Europe/Zurich"
workflow_dispatch:
inputs:
scenario:
description: "Which scenario(s) to run"
type: choice
options: [all, hk-comparison, surrealdb, lance, opendal, cuda-oxide, cuda-oxide-patched, eza, llvm, hk, hk-nopred, hk-pull, hk-pull-perf, hk-pull-nopred, hk-mbx, hk-pull-mbx, opendal-mbx, lance-mbx, surrealdb-mbx, firefox-sccache]
default: all
extra_args:
description: "Extra recipe arguments (hk/eza: sample count; other scenarios: --skip-clone, --retry)"
type: string
default: ""
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.inputs.scenario || 'all' }}
cancel-in-progress: false
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
plan:
if: (github.repository == 'kunobi-ninja/kache' || vars.ENABLE_BENCHMARKS == 'true')
name: Plan scenarios
runs-on: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
outputs:
matrix: ${{ steps.pick.outputs.matrix }}
has_linux: ${{ steps.pick.outputs.has_linux }}
steps:
- id: pick
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
SCENARIO: ${{ github.event_name == 'schedule' && 'all' || github.event.inputs.scenario }}
run: |
# Each arm carries: name (display + artifact label), cmd (the `just`
# recipe + profile to run), dir (scratch subdir under tmp/bench to
# upload), timeout (benchmark-step minutes), job_timeout (adds time
# for setup and the final telemetry upload), min_free_gb (precheck
# floor — fail the arm if free disk is below this before we start,
# since disk-full mid-build is the worst failure mode; emptyDir is
# node-backed, so the precheck also catches a too-small node).
#
# SurrealDB's `surreal` server binary at default features — a large
# pure-Rust workspace whose default features also drag in native C/C++
# deps (rocksdb, grpcio, quickjs) that build outside kache. Comparable
# native-prerequisite set (already installed below).
surrealdb='{"name":"surrealdb","cmd":"bench surrealdb","dir":"bench-surrealdb","timeout":180,"job_timeout":225,"min_free_gb":80,"runner":"kunobi-runners-large"}'
# Lance's `lance` crate at default features — a large pure-Rust
# workspace (Arrow, DataFusion, object-store backends, prost codegen).
# Needs protoc + openssl headers (already installed below). Lighter
# than surrealdb: no rocksdb/grpcio/quickjs native compile.
lance='{"name":"lance","cmd":"bench lance","dir":"bench-lance","timeout":180,"job_timeout":225,"min_free_gb":60,"runner":"kunobi-runners-large"}'
# OpenDAL core (apache/opendal `core/` workspace) with the portable
# service-feature set — mid-size pure-Rust, lighter than surrealdb.
# aws-lc-sys (rustls) compiles outside kache; cmake + a C compiler
# are already in the apt step below.
opendal='{"name":"opendal","cmd":"bench opendal","dir":"bench-opendal","timeout":120,"job_timeout":165,"min_free_gb":40,"runner":"kunobi-runners"}'
# NVlabs/cuda-oxide's examples-compile job: its rustc codegen backend,
# the shared host dependency tree, and 200+ example workspaces built
# into one target dir. Needs the CUDA toolkit headers and LLVM 22,
# installed only for this arm below.
cuda_oxide='{"name":"cuda-oxide","cmd":"bench cuda-oxide --warm-same-tree","dir":"bench-cuda-oxide","timeout":120,"job_timeout":180,"min_free_gb":40,"runner":"kunobi-runners"}'
# The same job with a cuda-oxide patch that declares every backend
# output, so kache can trust and cache the backend compiles.
cuda_oxide_patched='{"name":"cuda-oxide-patched","cmd":"bench cuda-oxide-patched --warm-same-tree","dir":"bench-cuda-oxide-patched","timeout":120,"job_timeout":180,"min_free_gb":40,"runner":"kunobi-runners"}'
# Full cold Firefox build is the long pole — hours. `bench firefox`
# resolves to the exact `bench-firefox` scenario: `--profile firefox`
# is a name SUBSTRING match that also catches `bench-firefox-windows`,
# but discovery prefers the exact-name hit (#458), so no `os:` tag is
# needed to pin this host-native arm. (The Windows variant runs in
# bench-firefox-windows.yml, on the self-hosted Windows runner.)
firefox='{"name":"firefox","cmd":"bench firefox","dir":"bench-firefox","timeout":360,"job_timeout":405,"min_free_gb":120,"runner":"kunobi-runners-large"}'
# Same Firefox shape through sccache for a side-by-side comparison.
firefox_sccache='{"name":"firefox-sccache","cmd":"bench-sccache firefox","dir":"bench-firefox-sccache","timeout":360,"job_timeout":405,"min_free_gb":120,"runner":"kunobi-runners-large"}'
# Almost-pure C/C++ CMake build (X86-only Release) — lighter than Firefox.
llvm='{"name":"llvm","cmd":"bench llvm","dir":"bench-llvm","timeout":240,"job_timeout":285,"min_free_gb":100,"runner":"kunobi-runners-large"}'
# Daily-pull (TEMPORAL axis, #477): one clone, build ref A then rebuild
# ref B in the SAME worktree — two full sequential Firefox builds, so a
# longer timeout than `firefox`. Only ONE worktree (no cross-clone
# clone-b), so disk is comparable, not double; keep the 120 GB floor.
# `bench firefox-pull` resolves to the exact `bench-firefox-pull`
# scenario (substring also catches `bench-firefox-pull-windows`, but
# discovery prefers the exact-name hit). The Windows variant runs in
# bench-firefox-windows.yml, on the self-hosted Windows runner.
firefox_pull='{"name":"firefox-pull","cmd":"bench firefox-pull","dir":"bench-firefox-pull","timeout":480,"job_timeout":525,"min_free_gb":120,"runner":"kunobi-runners-large"}'
# hk (jdx/hk): a mid-size Rust CLI whose build scripts compile ~750
# C/asm objects through cc-rs (aws-lc-sys, vendored libgit2), wired
# the way `kache init` does it so both compiler families are measured.
# `--warm-same-tree` adds the same-path warm phase between cold and the
# cross-clone warm. Minutes, not hours; a C compiler is all it needs.
hk='{"name":"hk","cmd":"bench-short hk","dir":"bench-hk","timeout":120,"job_timeout":150,"min_free_gb":40,"runner":"kunobi-runners"}'
# hk with input predictions off: the control for the arm above, so
# the same nightly shows what predictions are worth (#1000).
hk_nopred='{"name":"hk-nopred","cmd":"bench hk-nopred --warm-same-tree","dir":"bench-hk-nopred","timeout":60,"job_timeout":90,"min_free_gb":40,"runner":"kunobi-runners"}'
# Same subject on the temporal axis: cold at the parent commit, then
# rebuild the child in the same worktree with the store warm.
hk_pull='{"name":"hk-pull","cmd":"bench hk-pull","dir":"bench-hk-pull","timeout":60,"job_timeout":90,"min_free_gb":40,"runner":"kunobi-runners"}'
# The same hk shape through mbx, for a side-by-side comparison
# (as firefox-sccache is for firefox). The workflow installs the
# latest mbx release; the run records the version it measured.
hk_mbx='{"name":"hk-mbx","cmd":"bench-mbx hk --warm-same-tree","dir":"bench-hk-mbx","timeout":60,"job_timeout":90,"min_free_gb":40,"runner":"kunobi-runners"}'
# Timing arms; hk-pull keeps exhaustive prediction verification.
hk_pull_perf='{"name":"hk-pull-perf","cmd":"bench hk-pull-perf","dir":"bench-hk-pull-perf","timeout":60,"job_timeout":90,"min_free_gb":40,"runner":"kunobi-runners"}'
hk_pull_nopred='{"name":"hk-pull-nopred","cmd":"bench hk-pull-nopred","dir":"bench-hk-pull-nopred","timeout":60,"job_timeout":90,"min_free_gb":40,"runner":"kunobi-runners"}'
hk_pull_mbx='{"name":"hk-pull-mbx","cmd":"bench-mbx hk-pull","dir":"bench-hk-pull-mbx","timeout":60,"job_timeout":90,"min_free_gb":40,"runner":"kunobi-runners"}'
# eza: the smallest subject and the only one built through a fat-LTO
# release profile, so the final unit is one whole-program codegen
# instead of many small ones. Its bundled libgit2 and zlib put the
# majority of its cacheable units on the C side (199 of 319 in a local
# run). The cheapest arm in the sweep: minutes, and under a gigabyte
# of build output.
eza='{"name":"eza","cmd":"bench-short eza","dir":"bench-eza","timeout":60,"job_timeout":90,"min_free_gb":40,"runner":"kunobi-runners"}'
# Two more subjects on the same side-by-side axis. mbx fronts Cargo,
# so the arms it can serve at all are exactly the cargo-driven ones:
# firefox (mach/gn/ninja) and llvm (cmake) are out of reach, which is
# why firefox's comparison arm is sccache instead. Both sit on the
# general pool — the reserved runner takes one job at a time and is
# already carrying the multi-hour arms.
opendal_mbx='{"name":"opendal-mbx","cmd":"bench-mbx opendal","dir":"bench-opendal-mbx","timeout":120,"job_timeout":165,"min_free_gb":40,"runner":"kunobi-runners"}'
lance_mbx='{"name":"lance-mbx","cmd":"bench-mbx lance","dir":"bench-lance-mbx","timeout":180,"job_timeout":225,"min_free_gb":60,"runner":"kunobi-runners"}'
surrealdb_mbx='{"name":"surrealdb-mbx","cmd":"bench-mbx surrealdb","dir":"bench-surrealdb-mbx","timeout":180,"job_timeout":225,"min_free_gb":80,"runner":"kunobi-runners-large"}'
case "$SCENARIO" in
surrealdb) inc="[$surrealdb]" ;;
lance) inc="[$lance]" ;;
opendal) inc="[$opendal]" ;;
cuda-oxide) inc="[$cuda_oxide]" ;;
cuda-oxide-patched) inc="[$cuda_oxide_patched]" ;;
llvm) inc="[$llvm]" ;;
hk-comparison) inc="[$hk,$hk_nopred,$hk_pull_perf,$hk_pull_nopred,$hk_pull_mbx]" ;;
hk) inc="[$hk]" ;;
hk-nopred) inc="[$hk_nopred]" ;;
hk-pull) inc="[$hk_pull]" ;;
hk-mbx) inc="[$hk_mbx]" ;;
hk-pull-perf) inc="[$hk_pull_perf]" ;;
hk-pull-nopred) inc="[$hk_pull_nopred]" ;;
hk-pull-mbx) inc="[$hk_pull_mbx]" ;;
eza) inc="[$eza]" ;;
opendal-mbx) inc="[$opendal_mbx]" ;;
lance-mbx) inc="[$lance_mbx]" ;;
surrealdb-mbx) inc="[$surrealdb_mbx]" ;;
# Not in the nightly: sccache is pinned in mise.toml, so this arm
# measures the same binary every night while only kache moves, and
# kache against kache is what the per-PR perf gate reports.
# `bench-sccache.yml` gives it its own run — weekly, and on a pin
# bump — so the nightly never waits for it and its results reach
# the collector on their own schedule.
firefox-sccache) inc="[$firefox_sccache]" ;;
# Windows-only scenarios are served by bench-firefox-windows.yml,
# so the Linux matrix is EMPTY for them (an empty include
# skips the `bench` job cleanly). Without this they'd fall to `*)`
# and wastefully run the whole Linux `all` matrix alongside.
*) inc="[$surrealdb,$lance,$opendal,$cuda_oxide,$cuda_oxide_patched,$llvm,$hk,$hk_nopred,$hk_pull,$hk_pull_perf,$hk_pull_nopred,$hk_pull_mbx,$eza,$opendal_mbx,$lance_mbx,$surrealdb_mbx]" ;;
esac
if [ "$EVENT_NAME" = "schedule" ]; then
inc="$(jq -c --argjson extra "[$firefox,$firefox_pull]" '. + $extra' <<<"$inc")"
fi
echo "matrix={\"include\":$inc}" >> "$GITHUB_OUTPUT"
if [ "$inc" = "[]" ]; then
echo "has_linux=false" >> "$GITHUB_OUTPUT"
else
echo "has_linux=true" >> "$GITHUB_OUTPUT"
fi
bench:
name: Bench (${{ matrix.name }})
needs: plan
if: needs.plan.outputs.has_linux == 'true'
runs-on: >-
${{ fromJSON(
matrix.runner == 'kunobi-runners-large' &&
(vars.BENCH_RUNNER_LINUX_LARGE ||
(github.repository == 'kunobi-ninja/kache' && '"kunobi-runners-large"') || 'null') ||
vars.BENCH_RUNNER_LINUX ||
(github.repository == 'kunobi-ninja/kache' && '"kunobi-runners"')
) }}
timeout-minutes: ${{ matrix.job_timeout }}
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 timeout-minutes: 10
- name: Set up Node for Kartero
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 with:
node-version: 24
- name: Seed failure telemetry
run: |
mkdir -p tmp/bench
npx --yes @kunobi/kartero@0.3.0 gauge \
--name kache.bench.verdict.ok \
--value 0 \
--unit 1 \
--attribute kache.bench.project=${{ matrix.dir }} \
--attribute kache.bench.cache_tool=${{ endsWith(matrix.name, '-sccache') && 'sccache' || endsWith(matrix.name, '-mbx') && 'mbx' || 'kache' }} \
--output tmp/bench/${{ matrix.dir }}
env:
NPM_CONFIG_CACHE: ${{ runner.temp }}/kartero-npm-cache
- name: Install build prerequisites (apt)
run: |
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential pkg-config \
protobuf-compiler clang libclang-dev cmake ninja-build \
libssl-dev \
git curl wget ca-certificates file unzip bzip2 xz-utils \
python3 python3-dev python3-pip
- name: Install CUDA toolkit and LLVM 22 (cuda-oxide only)
if: startsWith(matrix.name, 'cuda-oxide')
timeout-minutes: 15
run: |
set -euo pipefail
source /etc/os-release
export DEBIAN_FRONTEND=noninteractive
distro="ubuntu${VERSION_ID//./}"
wget -q -O "$RUNNER_TEMP/cuda-keyring.deb" \
"https://developer.download.nvidia.com/compute/cuda/repos/${distro}/x86_64/cuda-keyring_1.1-1_all.deb"
sudo dpkg -i "$RUNNER_TEMP/cuda-keyring.deb"
sudo install -d -m 0755 /etc/apt/keyrings
wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key \
| sudo tee /etc/apt/keyrings/apt-llvm-org.asc >/dev/null
echo "deb [signed-by=/etc/apt/keyrings/apt-llvm-org.asc] https://apt.llvm.org/${VERSION_CODENAME}/ llvm-toolchain-${VERSION_CODENAME}-22 main" \
| sudo tee /etc/apt/sources.list.d/llvm-22.list
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
cuda-nvcc-13-1 cuda-cudart-dev-13-1 libcurand-dev-13-1 libnvjitlink-dev-13-1 \
llvm-22
test -f /usr/local/cuda-13.1/include/cuda.h
llc-22 --version | head -2
{
echo "CUDA_TOOLKIT_PATH=/usr/local/cuda-13.1"
echo "CUDA_OXIDE_LLC=/usr/bin/llc-22"
} >> "$GITHUB_ENV"
- name: Install tools via mise
id: mise
continue-on-error: true
uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d with:
version: 2026.6.9
install_args: --force rust github:casey/just${{ (endsWith(matrix.name, '-sccache') || matrix.name == 'hk' || matrix.name == 'eza') && ' github:mozilla/sccache' || '' }}
cache: false
reshim: false
- name: Install tools via mise (retry)
if: steps.mise.outcome == 'failure'
uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d with:
version: 2026.6.9
install_args: --force rust github:casey/just${{ (endsWith(matrix.name, '-sccache') || matrix.name == 'hk' || matrix.name == 'eza') && ' github:mozilla/sccache' || '' }}
cache: false
reshim: false
- name: Install mbx (comparison arms only)
if: endsWith(matrix.name, '-mbx') || matrix.name == 'hk' || matrix.name == 'eza'
timeout-minutes: 10
run: |
bash scripts/install-bench-mbx.sh
- name: Authenticate git for third-party clones
env:
GH_JOB_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
header="$(printf 'x-access-token:%s' "$GH_JOB_TOKEN" | base64 -w0)"
git config --global --replace-all \
"http.https://github.com/.extraheader" "AUTHORIZATION: basic $header"
- name: Free-disk precheck
run: |
avail_gb=$(df -BG --output=avail . | awk 'NR==2 {gsub(/G/,"",$1); print $1}')
echo "Free disk: ${avail_gb} GB (need >= ${{ matrix.min_free_gb }} GB)"
if [ "${avail_gb:-0}" -lt "${{ matrix.min_free_gb }}" ]; then
echo "::error::Insufficient free disk (${avail_gb} GB < ${{ matrix.min_free_gb }} GB) for the ${{ matrix.name }} bench"
exit 1
fi
- name: Run bench (${{ matrix.name }})
timeout-minutes: ${{ matrix.timeout }}
run: |
rustc --version && cargo --version && just --version
# EXTRA_ARGS is passed via env (not interpolated into the command line)
# so a dispatch input can't inject shell. Unquoted on purpose: the
# bench flags (e.g. `--skip-clone --retry`) must word-split. matrix.cmd
# is the full recipe + profile, e.g. "bench firefox" or
# "bench-sccache firefox".
# shellcheck disable=SC2086
just ${{ matrix.cmd }} $EXTRA_ARGS
env:
EXTRA_ARGS: ${{ github.event.inputs.extra_args }}
RUSTC_WRAPPER: ""
RUSTUP_TOOLCHAIN: ""
- name: Upload bench reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: bench-${{ matrix.name }}
path: |
tmp/bench/${{ matrix.dir }}/*.json
tmp/bench/${{ matrix.dir }}/*.md
tmp/bench/${{ matrix.dir }}/report-*.json
tmp/bench/${{ matrix.dir }}/report-*.md
tmp/bench/${{ matrix.dir }}/trace-*.json
tmp/bench/${{ matrix.dir }}/key-diff.*
tmp/bench/${{ matrix.dir }}/*.log
tmp/bench/${{ matrix.dir }}/*.txt
tmp/bench/${{ matrix.dir }}/logs/
tmp/bench/${{ matrix.dir }}/contention/
tmp/bench/${{ matrix.dir }}/contention.log
retention-days: 30
if-no-files-found: warn
- name: Validate telemetry
if: always()
timeout-minutes: 5
run: npx --yes @kunobi/kartero@0.3.0 validate --input tmp/bench/${{ matrix.dir }}
env:
NPM_CONFIG_CACHE: ${{ runner.temp }}/kartero-npm-cache
- name: Upload telemetry
if: always()
timeout-minutes: 10
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: telemetry-otlp-v1-${{ matrix.name }}
path: |
tmp/bench/${{ matrix.dir }}/metrics.otlp.json
tmp/bench/${{ matrix.dir }}/schema_version
tmp/bench/${{ matrix.dir }}/${{ matrix.dir }}.json
retention-days: 14
if-no-files-found: error
- name: Upload cache telemetry (cold)
if: always()
timeout-minutes: 10
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: telemetry-otlp-v1-cache-${{ matrix.name }}-cold
path: tmp/bench/${{ matrix.dir }}/cache-otlp-cold/
retention-days: 14
if-no-files-found: warn
- name: Upload cache telemetry (warm)
if: always()
timeout-minutes: 10
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: telemetry-otlp-v1-cache-${{ matrix.name }}-warm
path: tmp/bench/${{ matrix.dir }}/cache-otlp-warm/
retention-days: 14
if-no-files-found: warn
- name: Upload cache telemetry (warm-same-tree)
if: always() && (contains(matrix.cmd, '--warm-same-tree') || matrix.name == 'hk' || matrix.name == 'eza') && !endsWith(matrix.name, '-mbx')
timeout-minutes: 10
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: telemetry-otlp-v1-cache-${{ matrix.name }}-warm-same-tree
path: tmp/bench/${{ matrix.dir }}/cache-otlp-warm-same-tree/
retention-days: 14
if-no-files-found: warn