ci-bench: cross-platform CI race scaffold (rickub CI vs GitHub Actions)Unverified
Identical pinned workload on both platforms, one workflow file per platform: .github/workflows/bench.yml (GitHub) and .rickub/workflows/bench.yml (rickub reads .rickub/workflows/ as its sole source when present), same steps in the same order: rust cold build, rust warm build, rust test, docker build, runner probe. Steps emit one JSON record each via scripts/emit_timing.sh; collect.sh + compare.py produce median/p95 tables; Makefile wires bootstrap/run-local/collect/compare. Everything degrades to recorded 'skipped'/'null' when docker or a probe tool is missing. Rust workload pins toolchain 1.85.0 and an exact-pinned dep set (serde/tokio/clap/regex/anyhow + serde_json) with a generated Cargo.lock. Docker workload pins alpine:3.20 by digest. Methodology, clock boundaries, interleaving and variance pitfalls documented in README.md.
0baf736 added
.github/workflows/bench.yml +88 -0 | new file mode 100644 | ||
| @@ -0,0 +1,88 @@ | ||
| 1 | +name: bench | |
| 2 | + | |
| 3 | +# THE GITHUB side of the CI race. Runs on github.com only: rickub reads | |
| 4 | +# .rickub/workflows/ as its sole workflow source when that directory exists | |
| 5 | +# and ignores .github/workflows/ entirely (rickub web/ci_dispatch.go | |
| 6 | +# walkWorkflows), so the two files never double-fire. Keep the STEP ORDER | |
| 7 | +# identical to .rickub/workflows/bench.yml — that is the point of the | |
| 8 | +# benchmark. | |
| 9 | +# | |
| 10 | +# Deliberately NO `concurrency:` block: queue depth is part of what we | |
| 11 | +# measure, and cancel-in-progress would destroy rounds. | |
| 12 | + | |
| 13 | +on: | |
| 14 | + push: | |
| 15 | + branches: [main] | |
| 16 | + workflow_dispatch: | |
| 17 | + | |
| 18 | +permissions: | |
| 19 | + contents: read | |
| 20 | + | |
| 21 | +defaults: | |
| 22 | + run: | |
| 23 | + shell: bash | |
| 24 | + | |
| 25 | +env: | |
| 26 | + BENCH_PLATFORM: github | |
| 27 | + | |
| 28 | +jobs: | |
| 29 | + bench: | |
| 30 | + runs-on: ubuntu-latest | |
| 31 | + timeout-minutes: 30 | |
| 32 | + steps: | |
| 33 | + # Pin comment policy mirrors rickub's own ci.yml: SHA-pinned actions. | |
| 34 | + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| 35 | + | |
| 36 | + - name: mark job-start | |
| 37 | + run: | | |
| 38 | + rid="${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-$(git rev-parse --short HEAD)" | |
| 39 | + echo "BENCH_RUN_ID=$rid" >> "$GITHUB_ENV" | |
| 40 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | |
| 41 | + BENCH_RUN_ID="$rid" bench_mark job-start | |
| 42 | + | |
| 43 | + # COLD build: hosted runners start empty, so this run of cargo fetches | |
| 44 | + # every crate and compiles the full tree. `rm -rf target` is belt and | |
| 45 | + # braces (it should never exist here). No actions/cache on purpose. | |
| 46 | + - name: rust cold build | |
| 47 | + run: | | |
| 48 | + set -euo pipefail | |
| 49 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | |
| 50 | + bench_step rust-cold-build bash -ec \ | |
| 51 | + 'rm -rf workloads/rust-build/target && cargo build --manifest-path workloads/rust-build/Cargo.toml' | |
| 52 | + | |
| 53 | + # WARM build: same job, target/ populated — measures the incremental | |
| 54 | + # no-op rebuild path (link + freshness checks only). | |
| 55 | + - name: rust warm build | |
| 56 | + run: | | |
| 57 | + set -euo pipefail | |
| 58 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | |
| 59 | + bench_step rust-warm-build cargo build --manifest-path workloads/rust-build/Cargo.toml | |
| 60 | + | |
| 61 | + - name: rust test | |
| 62 | + run: | | |
| 63 | + set -euo pipefail | |
| 64 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | |
| 65 | + bench_step rust-test cargo test --manifest-path workloads/rust-build/Cargo.toml | |
| 66 | + | |
| 67 | + # docker-build: skipped (recorded, not failed) when docker is absent. | |
| 68 | + - name: docker build | |
| 69 | + run: | | |
| 70 | + set -euo pipefail | |
| 71 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | |
| 72 | + if command -v docker >/dev/null 2>&1; then | |
| 73 | + bench_step docker-build bash -ec \ | |
| 74 | + 'docker build -t ci-bench:docker workloads/docker-build && docker run --rm ci-bench:docker' | |
| 75 | + else | |
| 76 | + bench_skip docker-build "docker not available on runner" | |
| 77 | + fi | |
| 78 | + | |
| 79 | + - name: probe runner | |
| 80 | + run: bash workloads/probe/probe.sh | |
| 81 | + | |
| 82 | + - name: show results | |
| 83 | + run: cat "${GITHUB_WORKSPACE:-$PWD}/results.jsonl" | |
| 84 | + | |
| 85 | + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| 86 | + with: | |
| 87 | + name: bench-results | |
| 88 | + path: results.jsonl | |
| new file mode 100644 | |||
| @@ -0,0 +1,88 @@ | |||
| 1 | +name: bench | ||
| 2 | + | ||
| 3 | +# THE GITHUB side of the CI race. Runs on github.com only: rickub reads | ||
| 4 | +# .rickub/workflows/ as its sole workflow source when that directory exists | ||
| 5 | +# and ignores .github/workflows/ entirely (rickub web/ci_dispatch.go | ||
| 6 | +# walkWorkflows), so the two files never double-fire. Keep the STEP ORDER | ||
| 7 | +# identical to .rickub/workflows/bench.yml — that is the point of the | ||
| 8 | +# benchmark. | ||
| 9 | +# | ||
| 10 | +# Deliberately NO `concurrency:` block: queue depth is part of what we | ||
| 11 | +# measure, and cancel-in-progress would destroy rounds. | ||
| 12 | + | ||
| 13 | +on: | ||
| 14 | + push: | ||
| 15 | + branches: [main] | ||
| 16 | + workflow_dispatch: | ||
| 17 | + | ||
| 18 | +permissions: | ||
| 19 | + contents: read | ||
| 20 | + | ||
| 21 | +defaults: | ||
| 22 | + run: | ||
| 23 | + shell: bash | ||
| 24 | + | ||
| 25 | +env: | ||
| 26 | + BENCH_PLATFORM: github | ||
| 27 | + | ||
| 28 | +jobs: | ||
| 29 | + bench: | ||
| 30 | + runs-on: ubuntu-latest | ||
| 31 | + timeout-minutes: 30 | ||
| 32 | + steps: | ||
| 33 | + # Pin comment policy mirrors rickub's own ci.yml: SHA-pinned actions. | ||
| 34 | + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | ||
| 35 | + | ||
| 36 | + - name: mark job-start | ||
| 37 | + run: | | ||
| 38 | + rid="${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-$(git rev-parse --short HEAD)" | ||
| 39 | + echo "BENCH_RUN_ID=$rid" >> "$GITHUB_ENV" | ||
| 40 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | ||
| 41 | + BENCH_RUN_ID="$rid" bench_mark job-start | ||
| 42 | + | ||
| 43 | + # COLD build: hosted runners start empty, so this run of cargo fetches | ||
| 44 | + # every crate and compiles the full tree. `rm -rf target` is belt and | ||
| 45 | + # braces (it should never exist here). No actions/cache on purpose. | ||
| 46 | + - name: rust cold build | ||
| 47 | + run: | | ||
| 48 | + set -euo pipefail | ||
| 49 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | ||
| 50 | + bench_step rust-cold-build bash -ec \ | ||
| 51 | + 'rm -rf workloads/rust-build/target && cargo build --manifest-path workloads/rust-build/Cargo.toml' | ||
| 52 | + | ||
| 53 | + # WARM build: same job, target/ populated — measures the incremental | ||
| 54 | + # no-op rebuild path (link + freshness checks only). | ||
| 55 | + - name: rust warm build | ||
| 56 | + run: | | ||
| 57 | + set -euo pipefail | ||
| 58 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | ||
| 59 | + bench_step rust-warm-build cargo build --manifest-path workloads/rust-build/Cargo.toml | ||
| 60 | + | ||
| 61 | + - name: rust test | ||
| 62 | + run: | | ||
| 63 | + set -euo pipefail | ||
| 64 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | ||
| 65 | + bench_step rust-test cargo test --manifest-path workloads/rust-build/Cargo.toml | ||
| 66 | + | ||
| 67 | + # docker-build: skipped (recorded, not failed) when docker is absent. | ||
| 68 | + - name: docker build | ||
| 69 | + run: | | ||
| 70 | + set -euo pipefail | ||
| 71 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | ||
| 72 | + if command -v docker >/dev/null 2>&1; then | ||
| 73 | + bench_step docker-build bash -ec \ | ||
| 74 | + 'docker build -t ci-bench:docker workloads/docker-build && docker run --rm ci-bench:docker' | ||
| 75 | + else | ||
| 76 | + bench_skip docker-build "docker not available on runner" | ||
| 77 | + fi | ||
| 78 | + | ||
| 79 | + - name: probe runner | ||
| 80 | + run: bash workloads/probe/probe.sh | ||
| 81 | + | ||
| 82 | + - name: show results | ||
| 83 | + run: cat "${GITHUB_WORKSPACE:-$PWD}/results.jsonl" | ||
| 84 | + | ||
| 85 | + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | ||
| 86 | + with: | ||
| 87 | + name: bench-results | ||
| 88 | + path: results.jsonl | ||
added
.gitignore +14 -0 | new file mode 100644 | ||
| @@ -0,0 +1,14 @@ | ||
| 1 | +# rust build artifacts | |
| 2 | +workloads/rust-build/target/ | |
| 3 | + | |
| 4 | +# local benchmark run output (collected results/ is COMMITTED-free but kept | |
| 5 | +# out of git too — it is machine-local analysis state) | |
| 6 | +/results.jsonl | |
| 7 | +results/ | |
| 8 | + | |
| 9 | +# probe scratch output (written to TMPDIR, guarded just in case) | |
| 10 | +probe.bin | |
| 11 | +*.probe.bin | |
| 12 | + | |
| 13 | +# macOS noise | |
| 14 | +.DS_Store | |
| new file mode 100644 | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | +# rust build artifacts | ||
| 2 | +workloads/rust-build/target/ | ||
| 3 | + | ||
| 4 | +# local benchmark run output (collected results/ is COMMITTED-free but kept | ||
| 5 | +# out of git too — it is machine-local analysis state) | ||
| 6 | +/results.jsonl | ||
| 7 | +results/ | ||
| 8 | + | ||
| 9 | +# probe scratch output (written to TMPDIR, guarded just in case) | ||
| 10 | +probe.bin | ||
| 11 | +*.probe.bin | ||
| 12 | + | ||
| 13 | +# macOS noise | ||
| 14 | +.DS_Store | ||
added
.rickub/workflows/bench.yml +97 -0 | new file mode 100644 | ||
| @@ -0,0 +1,97 @@ | ||
| 1 | +name: bench | |
| 2 | + | |
| 3 | +# THE RICKUB side of the CI race. Runs only on rickub's own CI: once | |
| 4 | +# .rickub/workflows/ exists, rickub reads it as the SOLE workflow source and | |
| 5 | +# ignores .github/workflows/ entirely (web/ci_dispatch.go walkWorkflows), so | |
| 6 | +# this file and the GitHub copy never double-fire on one platform. Keep the | |
| 7 | +# STEP ORDER identical to .github/workflows/bench.yml — that is the point of | |
| 8 | +# the benchmark. | |
| 9 | +# | |
| 10 | +# runs-on: large (4 vCPU / 8 GiB / 32 GiB scratch, Docker-in-Docker guest | |
| 11 | +# rootfs-docker-glibc.ext4) to roughly match github.com's ubuntu-latest | |
| 12 | +# (4 vCPU / 16 GiB). The vCPU count matches; RAM does not — documented in the | |
| 13 | +# README as a known asymmetry and a user decision (ubuntu-latest on rickub is | |
| 14 | +# 2 vCPU / 4 GiB, cheaper and arguably the fairer "as-consumed" comparison). | |
| 15 | +# | |
| 16 | +# Deliberately NO `concurrency:` block: queue depth is part of what we | |
| 17 | +# measure. | |
| 18 | + | |
| 19 | +on: | |
| 20 | + push: | |
| 21 | + branches: [main] | |
| 22 | + workflow_dispatch: | |
| 23 | + | |
| 24 | +permissions: | |
| 25 | + contents: read | |
| 26 | + | |
| 27 | +defaults: | |
| 28 | + run: | |
| 29 | + shell: bash | |
| 30 | + | |
| 31 | +env: | |
| 32 | + BENCH_PLATFORM: rickub | |
| 33 | + | |
| 34 | +jobs: | |
| 35 | + bench: | |
| 36 | + runs-on: large | |
| 37 | + timeout-minutes: 30 | |
| 38 | + steps: | |
| 39 | + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| 40 | + | |
| 41 | + - name: mark job-start | |
| 42 | + run: | | |
| 43 | + rid="${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-$(git rev-parse --short HEAD)" | |
| 44 | + echo "BENCH_RUN_ID=$rid" >> "$GITHUB_ENV" | |
| 45 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | |
| 46 | + BENCH_RUN_ID="$rid" bench_mark job-start | |
| 47 | + | |
| 48 | + # COLD build: every rickub job boots a fresh Firecracker microVM with a | |
| 49 | + # read-only rootfs and a fresh scratch disk, so this cargo run fetches | |
| 50 | + # every crate and compiles the full tree — exactly the GitHub twin's | |
| 51 | + # cold path. `rm -rf target` is belt and braces. No cache action on | |
| 52 | + # purpose. | |
| 53 | + - name: rust cold build | |
| 54 | + run: | | |
| 55 | + set -euo pipefail | |
| 56 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | |
| 57 | + bench_step rust-cold-build bash -ec \ | |
| 58 | + 'rm -rf workloads/rust-build/target && cargo build --manifest-path workloads/rust-build/Cargo.toml' | |
| 59 | + | |
| 60 | + # WARM build: same job, same VM, target/ populated — the incremental | |
| 61 | + # no-op rebuild path. | |
| 62 | + - name: rust warm build | |
| 63 | + run: | | |
| 64 | + set -euo pipefail | |
| 65 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | |
| 66 | + bench_step rust-warm-build cargo build --manifest-path workloads/rust-build/Cargo.toml | |
| 67 | + | |
| 68 | + - name: rust test | |
| 69 | + run: | | |
| 70 | + set -euo pipefail | |
| 71 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | |
| 72 | + bench_step rust-test cargo test --manifest-path workloads/rust-build/Cargo.toml | |
| 73 | + | |
| 74 | + # The large class runs Docker-in-Docker (dockerd in the guest), so this | |
| 75 | + # exercises the guest's dockerd + registry pull path. Still guarded: a | |
| 76 | + # runner without docker records "skipped" instead of failing the job. | |
| 77 | + - name: docker build | |
| 78 | + run: | | |
| 79 | + set -euo pipefail | |
| 80 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | |
| 81 | + if command -v docker >/dev/null 2>&1; then | |
| 82 | + bench_step docker-build bash -ec \ | |
| 83 | + 'docker build -t ci-bench:docker workloads/docker-build && docker run --rm ci-bench:docker' | |
| 84 | + else | |
| 85 | + bench_skip docker-build "docker not available on runner" | |
| 86 | + fi | |
| 87 | + | |
| 88 | + - name: probe runner | |
| 89 | + run: bash workloads/probe/probe.sh | |
| 90 | + | |
| 91 | + - name: show results | |
| 92 | + run: cat "${GITHUB_WORKSPACE:-$PWD}/results.jsonl" | |
| 93 | + | |
| 94 | + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| 95 | + with: | |
| 96 | + name: bench-results | |
| 97 | + path: results.jsonl | |
| new file mode 100644 | |||
| @@ -0,0 +1,97 @@ | |||
| 1 | +name: bench | ||
| 2 | + | ||
| 3 | +# THE RICKUB side of the CI race. Runs only on rickub's own CI: once | ||
| 4 | +# .rickub/workflows/ exists, rickub reads it as the SOLE workflow source and | ||
| 5 | +# ignores .github/workflows/ entirely (web/ci_dispatch.go walkWorkflows), so | ||
| 6 | +# this file and the GitHub copy never double-fire on one platform. Keep the | ||
| 7 | +# STEP ORDER identical to .github/workflows/bench.yml — that is the point of | ||
| 8 | +# the benchmark. | ||
| 9 | +# | ||
| 10 | +# runs-on: large (4 vCPU / 8 GiB / 32 GiB scratch, Docker-in-Docker guest | ||
| 11 | +# rootfs-docker-glibc.ext4) to roughly match github.com's ubuntu-latest | ||
| 12 | +# (4 vCPU / 16 GiB). The vCPU count matches; RAM does not — documented in the | ||
| 13 | +# README as a known asymmetry and a user decision (ubuntu-latest on rickub is | ||
| 14 | +# 2 vCPU / 4 GiB, cheaper and arguably the fairer "as-consumed" comparison). | ||
| 15 | +# | ||
| 16 | +# Deliberately NO `concurrency:` block: queue depth is part of what we | ||
| 17 | +# measure. | ||
| 18 | + | ||
| 19 | +on: | ||
| 20 | + push: | ||
| 21 | + branches: [main] | ||
| 22 | + workflow_dispatch: | ||
| 23 | + | ||
| 24 | +permissions: | ||
| 25 | + contents: read | ||
| 26 | + | ||
| 27 | +defaults: | ||
| 28 | + run: | ||
| 29 | + shell: bash | ||
| 30 | + | ||
| 31 | +env: | ||
| 32 | + BENCH_PLATFORM: rickub | ||
| 33 | + | ||
| 34 | +jobs: | ||
| 35 | + bench: | ||
| 36 | + runs-on: large | ||
| 37 | + timeout-minutes: 30 | ||
| 38 | + steps: | ||
| 39 | + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | ||
| 40 | + | ||
| 41 | + - name: mark job-start | ||
| 42 | + run: | | ||
| 43 | + rid="${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-$(git rev-parse --short HEAD)" | ||
| 44 | + echo "BENCH_RUN_ID=$rid" >> "$GITHUB_ENV" | ||
| 45 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | ||
| 46 | + BENCH_RUN_ID="$rid" bench_mark job-start | ||
| 47 | + | ||
| 48 | + # COLD build: every rickub job boots a fresh Firecracker microVM with a | ||
| 49 | + # read-only rootfs and a fresh scratch disk, so this cargo run fetches | ||
| 50 | + # every crate and compiles the full tree — exactly the GitHub twin's | ||
| 51 | + # cold path. `rm -rf target` is belt and braces. No cache action on | ||
| 52 | + # purpose. | ||
| 53 | + - name: rust cold build | ||
| 54 | + run: | | ||
| 55 | + set -euo pipefail | ||
| 56 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | ||
| 57 | + bench_step rust-cold-build bash -ec \ | ||
| 58 | + 'rm -rf workloads/rust-build/target && cargo build --manifest-path workloads/rust-build/Cargo.toml' | ||
| 59 | + | ||
| 60 | + # WARM build: same job, same VM, target/ populated — the incremental | ||
| 61 | + # no-op rebuild path. | ||
| 62 | + - name: rust warm build | ||
| 63 | + run: | | ||
| 64 | + set -euo pipefail | ||
| 65 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | ||
| 66 | + bench_step rust-warm-build cargo build --manifest-path workloads/rust-build/Cargo.toml | ||
| 67 | + | ||
| 68 | + - name: rust test | ||
| 69 | + run: | | ||
| 70 | + set -euo pipefail | ||
| 71 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | ||
| 72 | + bench_step rust-test cargo test --manifest-path workloads/rust-build/Cargo.toml | ||
| 73 | + | ||
| 74 | + # The large class runs Docker-in-Docker (dockerd in the guest), so this | ||
| 75 | + # exercises the guest's dockerd + registry pull path. Still guarded: a | ||
| 76 | + # runner without docker records "skipped" instead of failing the job. | ||
| 77 | + - name: docker build | ||
| 78 | + run: | | ||
| 79 | + set -euo pipefail | ||
| 80 | + source "${GITHUB_WORKSPACE:-$PWD}/scripts/emit_timing.sh" | ||
| 81 | + if command -v docker >/dev/null 2>&1; then | ||
| 82 | + bench_step docker-build bash -ec \ | ||
| 83 | + 'docker build -t ci-bench:docker workloads/docker-build && docker run --rm ci-bench:docker' | ||
| 84 | + else | ||
| 85 | + bench_skip docker-build "docker not available on runner" | ||
| 86 | + fi | ||
| 87 | + | ||
| 88 | + - name: probe runner | ||
| 89 | + run: bash workloads/probe/probe.sh | ||
| 90 | + | ||
| 91 | + - name: show results | ||
| 92 | + run: cat "${GITHUB_WORKSPACE:-$PWD}/results.jsonl" | ||
| 93 | + | ||
| 94 | + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | ||
| 95 | + with: | ||
| 96 | + name: bench-results | ||
| 97 | + path: results.jsonl | ||
added
Makefile +56 -0 | new file mode 100644 | ||
| @@ -0,0 +1,56 @@ | ||
| 1 | +# ci-bench — cross-platform CI race benchmark (rickub CI vs GitHub Actions). | |
| 2 | +# bash + python3(stdlib) only; everything degrades gracefully. | |
| 3 | + | |
| 4 | +SHELL := /usr/bin/env bash | |
| 5 | + | |
| 6 | +PLATFORM ?= local | |
| 7 | +RUN_ID ?= local-$(shell date -u +%Y%m%dT%H%M%SZ) | |
| 8 | + | |
| 9 | +.PHONY: bootstrap run-local collect compare clean | |
| 10 | + | |
| 11 | +## bootstrap — make scripts executable and create results/ (safe to re-run) | |
| 12 | +bootstrap: | |
| 13 | + chmod +x scripts/*.sh workloads/probe/probe.sh | |
| 14 | + mkdir -p results | |
| 15 | + | |
| 16 | +## run-local — run the benchmark step sequence on THIS machine (smoke-test / | |
| 17 | +## local baseline). Emits ./results.jsonl, mirroring the CI step order. | |
| 18 | +run-local: bootstrap | |
| 19 | + rm -f results.jsonl | |
| 20 | + set -euo pipefail; \ | |
| 21 | + export BENCH_RUN_ID="$(RUN_ID)"; \ | |
| 22 | + source scripts/emit_timing.sh; \ | |
| 23 | + bench_mark job-start; \ | |
| 24 | + if command -v cargo >/dev/null 2>&1; then \ | |
| 25 | + bench_step rust-cold-build bash -ec \ | |
| 26 | + 'rm -rf workloads/rust-build/target && cargo build --manifest-path workloads/rust-build/Cargo.toml'; \ | |
| 27 | + bench_step rust-warm-build cargo build --manifest-path workloads/rust-build/Cargo.toml; \ | |
| 28 | + bench_step rust-test cargo test --manifest-path workloads/rust-build/Cargo.toml; \ | |
| 29 | + else \ | |
| 30 | + bench_skip rust-cold-build "cargo not found"; \ | |
| 31 | + bench_skip rust-warm-build "cargo not found"; \ | |
| 32 | + bench_skip rust-test "cargo not found"; \ | |
| 33 | + fi; \ | |
| 34 | + if command -v docker >/dev/null 2>&1; then \ | |
| 35 | + bench_step docker-build bash -ec \ | |
| 36 | + 'docker build -t ci-bench:docker workloads/docker-build && docker run --rm ci-bench:docker'; \ | |
| 37 | + else \ | |
| 38 | + bench_skip docker-build "docker not found"; \ | |
| 39 | + fi; \ | |
| 40 | + bash workloads/probe/probe.sh; \ | |
| 41 | + cat results.jsonl | |
| 42 | + | |
| 43 | +## collect — fold a results.jsonl into the results tree: | |
| 44 | +## make collect PLATFORM=github RUN_ID=<id> SRC=/path/to/results.jsonl | |
| 45 | +collect: | |
| 46 | + @test -n "$(SRC)" || { echo "usage: make collect PLATFORM=<p> RUN_ID=<id> SRC=<results.jsonl>"; exit 2; } | |
| 47 | + scripts/collect.sh "$(PLATFORM)" "$(RUN_ID)" "$(SRC)" | |
| 48 | + | |
| 49 | +## compare — median/p95 table from everything collected so far | |
| 50 | +compare: | |
| 51 | + python3 scripts/compare.py | |
| 52 | + | |
| 53 | +## clean — drop local run outputs (not the collected results/ tree) | |
| 54 | +clean: | |
| 55 | + rm -f results.jsonl | |
| 56 | + rm -rf workloads/rust-build/target | |
| new file mode 100644 | |||
| @@ -0,0 +1,56 @@ | |||
| 1 | +# ci-bench — cross-platform CI race benchmark (rickub CI vs GitHub Actions). | ||
| 2 | +# bash + python3(stdlib) only; everything degrades gracefully. | ||
| 3 | + | ||
| 4 | +SHELL := /usr/bin/env bash | ||
| 5 | + | ||
| 6 | +PLATFORM ?= local | ||
| 7 | +RUN_ID ?= local-$(shell date -u +%Y%m%dT%H%M%SZ) | ||
| 8 | + | ||
| 9 | +.PHONY: bootstrap run-local collect compare clean | ||
| 10 | + | ||
| 11 | +## bootstrap — make scripts executable and create results/ (safe to re-run) | ||
| 12 | +bootstrap: | ||
| 13 | + chmod +x scripts/*.sh workloads/probe/probe.sh | ||
| 14 | + mkdir -p results | ||
| 15 | + | ||
| 16 | +## run-local — run the benchmark step sequence on THIS machine (smoke-test / | ||
| 17 | +## local baseline). Emits ./results.jsonl, mirroring the CI step order. | ||
| 18 | +run-local: bootstrap | ||
| 19 | + rm -f results.jsonl | ||
| 20 | + set -euo pipefail; \ | ||
| 21 | + export BENCH_RUN_ID="$(RUN_ID)"; \ | ||
| 22 | + source scripts/emit_timing.sh; \ | ||
| 23 | + bench_mark job-start; \ | ||
| 24 | + if command -v cargo >/dev/null 2>&1; then \ | ||
| 25 | + bench_step rust-cold-build bash -ec \ | ||
| 26 | + 'rm -rf workloads/rust-build/target && cargo build --manifest-path workloads/rust-build/Cargo.toml'; \ | ||
| 27 | + bench_step rust-warm-build cargo build --manifest-path workloads/rust-build/Cargo.toml; \ | ||
| 28 | + bench_step rust-test cargo test --manifest-path workloads/rust-build/Cargo.toml; \ | ||
| 29 | + else \ | ||
| 30 | + bench_skip rust-cold-build "cargo not found"; \ | ||
| 31 | + bench_skip rust-warm-build "cargo not found"; \ | ||
| 32 | + bench_skip rust-test "cargo not found"; \ | ||
| 33 | + fi; \ | ||
| 34 | + if command -v docker >/dev/null 2>&1; then \ | ||
| 35 | + bench_step docker-build bash -ec \ | ||
| 36 | + 'docker build -t ci-bench:docker workloads/docker-build && docker run --rm ci-bench:docker'; \ | ||
| 37 | + else \ | ||
| 38 | + bench_skip docker-build "docker not found"; \ | ||
| 39 | + fi; \ | ||
| 40 | + bash workloads/probe/probe.sh; \ | ||
| 41 | + cat results.jsonl | ||
| 42 | + | ||
| 43 | +## collect — fold a results.jsonl into the results tree: | ||
| 44 | +## make collect PLATFORM=github RUN_ID=<id> SRC=/path/to/results.jsonl | ||
| 45 | +collect: | ||
| 46 | + @test -n "$(SRC)" || { echo "usage: make collect PLATFORM=<p> RUN_ID=<id> SRC=<results.jsonl>"; exit 2; } | ||
| 47 | + scripts/collect.sh "$(PLATFORM)" "$(RUN_ID)" "$(SRC)" | ||
| 48 | + | ||
| 49 | +## compare — median/p95 table from everything collected so far | ||
| 50 | +compare: | ||
| 51 | + python3 scripts/compare.py | ||
| 52 | + | ||
| 53 | +## clean — drop local run outputs (not the collected results/ tree) | ||
| 54 | +clean: | ||
| 55 | + rm -f results.jsonl | ||
| 56 | + rm -rf workloads/rust-build/target | ||
added
README.md +199 -0 | new file mode 100644 | ||
| @@ -0,0 +1,199 @@ | ||
| 1 | +# ci-bench — the CI race | |
| 2 | + | |
| 3 | +An identical, pinned workload pushed to **GitHub Actions** and **rickub CI** | |
| 4 | +(Firecracker microVM fleet), raced to answer: who starts jobs faster, whose | |
| 5 | +steps run faster, and what the runner hardware itself is worth? | |
| 6 | + | |
| 7 | +Everything here is bash + python3 stdlib. No package installs, no external | |
| 8 | +services beyond the two CI platforms themselves. | |
| 9 | + | |
| 10 | +## Layout | |
| 11 | + | |
| 12 | + .github/workflows/bench.yml GitHub side of the race | |
| 13 | + .rickub/workflows/bench.yml rickub side (same steps, same order) | |
| 14 | + workloads/rust-build/ pinned Cargo project (cold/warm/test) | |
| 15 | + workloads/docker-build/ digest-pinned multi-stage Dockerfile | |
| 16 | + workloads/probe/probe.sh runner capability probe (cpu/disk/fs) | |
| 17 | + scripts/emit_timing.sh sourced by steps; one JSON line per step | |
| 18 | + scripts/collect.sh results.jsonl -> results/<platform>/<run>.json | |
| 19 | + scripts/compare.py median/p95 table per step per platform | |
| 20 | + Makefile bootstrap / run-local / collect / compare | |
| 21 | + | |
| 22 | +Why two workflow files and not one: rickub reads `.rickub/workflows/` as its | |
| 23 | +SOLE workflow source when that directory exists and ignores | |
| 24 | +`.github/workflows/` entirely, while GitHub only ever reads `.github/`. One | |
| 25 | +repo therefore carries one file per platform and neither platform sees the | |
| 26 | +other's copy. The two files run the same steps in the same order; the only | |
| 27 | +differences are `BENCH_PLATFORM`, `runs-on:` (see *Known asymmetries*), and | |
| 28 | +comments. | |
| 29 | + | |
| 30 | +## The workload (identical on both platforms) | |
| 31 | + | |
| 32 | +| step | what it does | | |
| 33 | +|-----------------|--------------------------------------------------------------------------| | |
| 34 | +| `job-start` | zero-duration marker emitted by the first step after checkout | | |
| 35 | +| `rust-cold-build` | `rm -rf target && cargo build` — full crate download + compile | | |
| 36 | +| `rust-warm-build` | immediate second `cargo build` — incremental no-op rebuild | | |
| 37 | +| `rust-test` | `cargo test` (5 real tests) over the warm build | | |
| 38 | +| `docker-build` | multi-stage `docker build` (alpine:3.20 pinned by digest) + `docker run` | | |
| 39 | +| `probe-*` | cpu loop, 2 GiB sequential write/read to `$RUNNER_TEMP`, fs type + free | | |
| 40 | + | |
| 41 | +Pins: Rust toolchain 1.85.0 (`workloads/rust-build/rust-toolchain.toml`), the | |
| 42 | +full dependency tree (`workloads/rust-build/Cargo.lock`, generated offline | |
| 43 | +against a real cargo registry cache, versions exact-pinned in `Cargo.toml` | |
| 44 | +with `=`), Docker base `alpine:3.20@sha256:d9e853e87e...` (the digest rickub | |
| 45 | +itself pins for its CI images). The workload is not bit-frozen against | |
| 46 | +`apk`/crates.io mirrors moving — see *Variance pitfalls*. | |
| 47 | + | |
| 48 | +### Cold vs warm — definitions | |
| 49 | + | |
| 50 | +Both platforms give every job a **fresh machine** (GitHub hosted runners; | |
| 51 | +rickub boots a new Firecracker microVM with a read-only rootfs and a fresh | |
| 52 | +scratch disk). Therefore: | |
| 53 | + | |
| 54 | +- **cold** = the FIRST `cargo build` in a job: crates.io fetch + full compile | |
| 55 | + of the transitive tree. Never accelerated by any cache (there is no | |
| 56 | + `actions/cache` and no cargo cache persistence — on purpose). | |
| 57 | +- **warm** = the SECOND `cargo build` in the SAME job: `target/` exists, the | |
| 58 | + no-op incremental path (metadata checks + link of nothing). | |
| 59 | +- Docker build is always cold (no image cache survives the machine). | |
| 60 | +- A "cold docker" vs "warm docker" axis would need an in-job second build of | |
| 61 | + the same Dockerfile; not in scope for v1. | |
| 62 | + | |
| 63 | +## Metrics — exact clock boundaries | |
| 64 | + | |
| 65 | +All records are JSON lines in `results.jsonl`: | |
| 66 | + | |
| 67 | + {"step": "...", "platform": "...", "run_id": "...", | |
| 68 | + "start": "2026-09-11T12:34:56.789Z", "end": "2026-09-11T12:35:40.123Z", | |
| 69 | + "status": "ok|fail|skipped", "duration_ms": 43334, | |
| 70 | + "value": <optional measurement>, "unit": "...", "reason": "..."} | |
| 71 | + | |
| 72 | +`start`/`end` are guest wall clock, UTC, millisecond precision (GNU `date | |
| 73 | +%3N`; python3 fallback on BSD; whole-second last resort — the fallback in use | |
| 74 | +is visible in the timestamps themselves: `.000Z` means coarse). | |
| 75 | + | |
| 76 | +1. **push→job-start** = `start` of the `job-start` record − **push timestamp | |
| 77 | + from the platform API** (not from the guest). | |
| 78 | + - GitHub: `gh api repos/:o/:r/actions/runs/<run_id>` → `created_at` | |
| 79 | + (when the push event was accepted server-side). Alternative, fully | |
| 80 | + server-side cross-check: `run_started_at − created_at` (queue + | |
| 81 | + provisioning, no guest clock involved). | |
| 82 | + - rickub: the run's created/queued timestamp from its CI API for the same | |
| 83 | + commit. | |
| 84 | + - CAVEAT: this metric subtracts a **server-side** timestamp from a | |
| 85 | + **guest-side** timestamp. Guest clock skew shifts both platforms' numbers | |
| 86 | + unpredictably (NTP inside a just-booted microVM can be off by seconds). | |
| 87 | + Treat it as indicative; prefer the server-side-only variant above when | |
| 88 | + the two disagree, and record both when possible. | |
| 89 | +2. **per-step duration** = `end − start` of the step's record, from the same | |
| 90 | + guest clock, so skew cancels. Millisecond precision. | |
| 91 | +3. **end-to-end pipeline time** (two views): | |
| 92 | + - job execution = last record's `end` − `job-start` record's `start`; | |
| 93 | + - run wall clock = last record's `end` − push timestamp (carries the same | |
| 94 | + cross-clock caveat as metric 1). | |
| 95 | +4. **cold vs warm cache** = `rust-cold-build` vs `rust-warm-build` durations | |
| 96 | + as defined above. | |
| 97 | +5. **disk-write throughput** = `probe-write` value (MB/s, 2 GiB `dd | |
| 98 | + bs=1M count=2048` to the job scratch dir, then removed); `probe-read` is | |
| 99 | + the same file read back (warm page cache — still comparable across | |
| 100 | + platforms); `probe-cpu` is shell-loop kops/s; `probe-fs-type`/`probe-fs-free` | |
| 101 | + describe the filesystem the runner gave the job. | |
| 102 | + | |
| 103 | +Anything unavailable degrades to `"value": null` or a `skipped` record; the | |
| 104 | +job itself never fails from the probe, and absent docker skips | |
| 105 | +`docker-build` with a recorded reason. | |
| 106 | + | |
| 107 | +## Running the race | |
| 108 | + | |
| 109 | +### One round | |
| 110 | + | |
| 111 | +1. Push the same commit to both remotes (e.g. an empty commit: | |
| 112 | + `git commit --allow-empty -m "bench round N"`; or use | |
| 113 | + `workflow_dispatch` on both — but record the dispatch time as the "push" | |
| 114 | + timestamp then). | |
| 115 | +2. When both runs finish, download `results.jsonl` from each: | |
| 116 | + - GitHub: `gh run download <run-id> -n bench-results` (or the run page); | |
| 117 | + - rickub: the run's artifacts panel. | |
| 118 | +3. Collect: | |
| 119 | + ``` | |
| 120 | + scripts/collect.sh github <run-id> /path/to/github-results/results.jsonl | |
| 121 | + scripts/collect.sh rickub <run-id> /path/to/rickub-results/results.jsonl | |
| 122 | + ``` | |
| 123 | +4. Record the push timestamps (server-side, per platform API) into a | |
| 124 | + `push-times.json` map `"github/<run-id>": "<iso>"` if you want the | |
| 125 | + push→job-start row. | |
| 126 | + | |
| 127 | +### Interleaving (mandatory) | |
| 128 | + | |
| 129 | +Run rounds **alternating platforms** — G, R, G, R, … — at least **10 rounds | |
| 130 | +per platform**, serialized (never two benchmark runs in flight at once: on | |
| 131 | +rickub your own queued job would inflate the other platform's queue metric). | |
| 132 | +Cloud CI variance across hours and neighbours is well documented; a single | |
| 133 | +run is noise. Alternation decorrelates the two platforms from time-of-day | |
| 134 | +effects (fleet load, mirror warmth, co-tenants). Optionally discard round 1 | |
| 135 | +per platform as image/toolchain warmup for the *operators*, not the runners. | |
| 136 | + | |
| 137 | +### Reporting | |
| 138 | + | |
| 139 | +``` | |
| 140 | +python3 scripts/compare.py [--push-times push-times.json] | |
| 141 | +``` | |
| 142 | + | |
| 143 | +Median (p50) and p95 per step per platform, linear-interpolated percentiles | |
| 144 | +(the numpy method) over `ok` records only; `skipped`/`fail` counts are shown | |
| 145 | +next to each cell so a platform quietly skipping docker is visible. The | |
| 146 | +table prints n per cell — do not compare anything with n < 10. | |
| 147 | + | |
| 148 | +### Local smoke run | |
| 149 | + | |
| 150 | +`make run-local` executes the same step sequence on your machine (macOS | |
| 151 | +works: the emitter falls back to python3 for millisecond timestamps and the | |
| 152 | +probe degrades gracefully). Collect with | |
| 153 | +`make collect PLATFORM=local RUN_ID=<id> SRC=results.jsonl`. | |
| 154 | + | |
| 155 | +## Known variance pitfalls | |
| 156 | + | |
| 157 | +- **Cross-clock subtraction** (push→job-start): server vs guest clock skew; | |
| 158 | + prefer server-side-only `run_started_at − created_at` as cross-check. | |
| 159 | +- **Queue contamination**: rickub queues runs; benchmark rounds must be | |
| 160 | + serialized or you measure your own backlog. Same for GitHub concurrency | |
| 161 | + groups (none set here, on purpose). | |
| 162 | +- **Runner placement**: GitHub assigns runners across regions/hosts; | |
| 163 | + crates.io and the Alpine mirror are at different RTTs from each placement. | |
| 164 | + Only medians over many rounds are meaningful. | |
| 165 | +- **Floating package indexes**: `apk add build-base` in the docker workload | |
| 166 | + floats with the mirror; crate downloads float with crates.io. A changed | |
| 167 | + upstream version shifts a cold build by minutes. If a round's cold build | |
| 168 | + moves >2x the running median, check upstream before believing it. | |
| 169 | +- **Toolchain download**: 1.85.0 is rustup-installed at job start on both | |
| 170 | + platforms (inside the timed cold-build step? No — rustup resolves it when | |
| 171 | + `cargo` first runs, so it IS inside the cold-build timing; identical on | |
| 172 | + both platforms by construction). | |
| 173 | +- **Warm build is nearly zero**: it measures scheduler/link noise; treat p95, | |
| 174 | + not median, as the signal. | |
| 175 | +- **probe-read is page-cache warm** by design (comparable, not absolute). | |
| 176 | +- **vCPU asymmetry**: rickub `large` = 4 vCPU / 8 GiB vs GitHub ubuntu-latest | |
| 177 | + 4 vCPU / 16 GiB. See below. | |
| 178 | + | |
| 179 | +## Known asymmetries (decisions to revisit) | |
| 180 | + | |
| 181 | +1. **Runner class**: the rickub workflow uses `runs-on: large` for vCPU parity | |
| 182 | + with GitHub's ubuntu-latest. `ubuntu-latest` on rickub (2 vCPU / 4 GiB) is | |
| 183 | + the "as-consumed, 1x minutes" alternative. This is a user decision. | |
| 184 | +2. **Where the repo lives**: needs one GitHub repo and one rickub repo (or the | |
| 185 | + same repo mirrored to both forges). Not created by this scaffold — no | |
| 186 | + network was touched. | |
| 187 | +3. **push-times.json**: the push timestamp per run is currently recorded by | |
| 188 | + the operator from each platform's API; automating it needs API tokens on | |
| 189 | + both sides. | |
| 190 | + | |
| 191 | +## Degrade behaviour | |
| 192 | + | |
| 193 | +| missing thing | behaviour | | |
| 194 | +|---------------|------------------------------------------------------| | |
| 195 | +| docker | `docker-build` step records `skipped` with reason | | |
| 196 | +| cargo | (local runs) rust steps record `skipped` | | |
| 197 | +| GNU date | python3 millis fallback, then whole-second `.000Z` | | |
| 198 | +| dd / stat / df| probe records `null` values, job stays green | | |
| 199 | +| python3 | emitter falls back to whole-second timestamps | | |
| new file mode 100644 | |||
| @@ -0,0 +1,199 @@ | |||
| 1 | +# ci-bench — the CI race | ||
| 2 | + | ||
| 3 | +An identical, pinned workload pushed to **GitHub Actions** and **rickub CI** | ||
| 4 | +(Firecracker microVM fleet), raced to answer: who starts jobs faster, whose | ||
| 5 | +steps run faster, and what the runner hardware itself is worth? | ||
| 6 | + | ||
| 7 | +Everything here is bash + python3 stdlib. No package installs, no external | ||
| 8 | +services beyond the two CI platforms themselves. | ||
| 9 | + | ||
| 10 | +## Layout | ||
| 11 | + | ||
| 12 | + .github/workflows/bench.yml GitHub side of the race | ||
| 13 | + .rickub/workflows/bench.yml rickub side (same steps, same order) | ||
| 14 | + workloads/rust-build/ pinned Cargo project (cold/warm/test) | ||
| 15 | + workloads/docker-build/ digest-pinned multi-stage Dockerfile | ||
| 16 | + workloads/probe/probe.sh runner capability probe (cpu/disk/fs) | ||
| 17 | + scripts/emit_timing.sh sourced by steps; one JSON line per step | ||
| 18 | + scripts/collect.sh results.jsonl -> results/<platform>/<run>.json | ||
| 19 | + scripts/compare.py median/p95 table per step per platform | ||
| 20 | + Makefile bootstrap / run-local / collect / compare | ||
| 21 | + | ||
| 22 | +Why two workflow files and not one: rickub reads `.rickub/workflows/` as its | ||
| 23 | +SOLE workflow source when that directory exists and ignores | ||
| 24 | +`.github/workflows/` entirely, while GitHub only ever reads `.github/`. One | ||
| 25 | +repo therefore carries one file per platform and neither platform sees the | ||
| 26 | +other's copy. The two files run the same steps in the same order; the only | ||
| 27 | +differences are `BENCH_PLATFORM`, `runs-on:` (see *Known asymmetries*), and | ||
| 28 | +comments. | ||
| 29 | + | ||
| 30 | +## The workload (identical on both platforms) | ||
| 31 | + | ||
| 32 | +| step | what it does | | ||
| 33 | +|-----------------|--------------------------------------------------------------------------| | ||
| 34 | +| `job-start` | zero-duration marker emitted by the first step after checkout | | ||
| 35 | +| `rust-cold-build` | `rm -rf target && cargo build` — full crate download + compile | | ||
| 36 | +| `rust-warm-build` | immediate second `cargo build` — incremental no-op rebuild | | ||
| 37 | +| `rust-test` | `cargo test` (5 real tests) over the warm build | | ||
| 38 | +| `docker-build` | multi-stage `docker build` (alpine:3.20 pinned by digest) + `docker run` | | ||
| 39 | +| `probe-*` | cpu loop, 2 GiB sequential write/read to `$RUNNER_TEMP`, fs type + free | | ||
| 40 | + | ||
| 41 | +Pins: Rust toolchain 1.85.0 (`workloads/rust-build/rust-toolchain.toml`), the | ||
| 42 | +full dependency tree (`workloads/rust-build/Cargo.lock`, generated offline | ||
| 43 | +against a real cargo registry cache, versions exact-pinned in `Cargo.toml` | ||
| 44 | +with `=`), Docker base `alpine:3.20@sha256:d9e853e87e...` (the digest rickub | ||
| 45 | +itself pins for its CI images). The workload is not bit-frozen against | ||
| 46 | +`apk`/crates.io mirrors moving — see *Variance pitfalls*. | ||
| 47 | + | ||
| 48 | +### Cold vs warm — definitions | ||
| 49 | + | ||
| 50 | +Both platforms give every job a **fresh machine** (GitHub hosted runners; | ||
| 51 | +rickub boots a new Firecracker microVM with a read-only rootfs and a fresh | ||
| 52 | +scratch disk). Therefore: | ||
| 53 | + | ||
| 54 | +- **cold** = the FIRST `cargo build` in a job: crates.io fetch + full compile | ||
| 55 | + of the transitive tree. Never accelerated by any cache (there is no | ||
| 56 | + `actions/cache` and no cargo cache persistence — on purpose). | ||
| 57 | +- **warm** = the SECOND `cargo build` in the SAME job: `target/` exists, the | ||
| 58 | + no-op incremental path (metadata checks + link of nothing). | ||
| 59 | +- Docker build is always cold (no image cache survives the machine). | ||
| 60 | +- A "cold docker" vs "warm docker" axis would need an in-job second build of | ||
| 61 | + the same Dockerfile; not in scope for v1. | ||
| 62 | + | ||
| 63 | +## Metrics — exact clock boundaries | ||
| 64 | + | ||
| 65 | +All records are JSON lines in `results.jsonl`: | ||
| 66 | + | ||
| 67 | + {"step": "...", "platform": "...", "run_id": "...", | ||
| 68 | + "start": "2026-09-11T12:34:56.789Z", "end": "2026-09-11T12:35:40.123Z", | ||
| 69 | + "status": "ok|fail|skipped", "duration_ms": 43334, | ||
| 70 | + "value": <optional measurement>, "unit": "...", "reason": "..."} | ||
| 71 | + | ||
| 72 | +`start`/`end` are guest wall clock, UTC, millisecond precision (GNU `date | ||
| 73 | +%3N`; python3 fallback on BSD; whole-second last resort — the fallback in use | ||
| 74 | +is visible in the timestamps themselves: `.000Z` means coarse). | ||
| 75 | + | ||
| 76 | +1. **push→job-start** = `start` of the `job-start` record − **push timestamp | ||
| 77 | + from the platform API** (not from the guest). | ||
| 78 | + - GitHub: `gh api repos/:o/:r/actions/runs/<run_id>` → `created_at` | ||
| 79 | + (when the push event was accepted server-side). Alternative, fully | ||
| 80 | + server-side cross-check: `run_started_at − created_at` (queue + | ||
| 81 | + provisioning, no guest clock involved). | ||
| 82 | + - rickub: the run's created/queued timestamp from its CI API for the same | ||
| 83 | + commit. | ||
| 84 | + - CAVEAT: this metric subtracts a **server-side** timestamp from a | ||
| 85 | + **guest-side** timestamp. Guest clock skew shifts both platforms' numbers | ||
| 86 | + unpredictably (NTP inside a just-booted microVM can be off by seconds). | ||
| 87 | + Treat it as indicative; prefer the server-side-only variant above when | ||
| 88 | + the two disagree, and record both when possible. | ||
| 89 | +2. **per-step duration** = `end − start` of the step's record, from the same | ||
| 90 | + guest clock, so skew cancels. Millisecond precision. | ||
| 91 | +3. **end-to-end pipeline time** (two views): | ||
| 92 | + - job execution = last record's `end` − `job-start` record's `start`; | ||
| 93 | + - run wall clock = last record's `end` − push timestamp (carries the same | ||
| 94 | + cross-clock caveat as metric 1). | ||
| 95 | +4. **cold vs warm cache** = `rust-cold-build` vs `rust-warm-build` durations | ||
| 96 | + as defined above. | ||
| 97 | +5. **disk-write throughput** = `probe-write` value (MB/s, 2 GiB `dd | ||
| 98 | + bs=1M count=2048` to the job scratch dir, then removed); `probe-read` is | ||
| 99 | + the same file read back (warm page cache — still comparable across | ||
| 100 | + platforms); `probe-cpu` is shell-loop kops/s; `probe-fs-type`/`probe-fs-free` | ||
| 101 | + describe the filesystem the runner gave the job. | ||
| 102 | + | ||
| 103 | +Anything unavailable degrades to `"value": null` or a `skipped` record; the | ||
| 104 | +job itself never fails from the probe, and absent docker skips | ||
| 105 | +`docker-build` with a recorded reason. | ||
| 106 | + | ||
| 107 | +## Running the race | ||
| 108 | + | ||
| 109 | +### One round | ||
| 110 | + | ||
| 111 | +1. Push the same commit to both remotes (e.g. an empty commit: | ||
| 112 | + `git commit --allow-empty -m "bench round N"`; or use | ||
| 113 | + `workflow_dispatch` on both — but record the dispatch time as the "push" | ||
| 114 | + timestamp then). | ||
| 115 | +2. When both runs finish, download `results.jsonl` from each: | ||
| 116 | + - GitHub: `gh run download <run-id> -n bench-results` (or the run page); | ||
| 117 | + - rickub: the run's artifacts panel. | ||
| 118 | +3. Collect: | ||
| 119 | + ``` | ||
| 120 | + scripts/collect.sh github <run-id> /path/to/github-results/results.jsonl | ||
| 121 | + scripts/collect.sh rickub <run-id> /path/to/rickub-results/results.jsonl | ||
| 122 | + ``` | ||
| 123 | +4. Record the push timestamps (server-side, per platform API) into a | ||
| 124 | + `push-times.json` map `"github/<run-id>": "<iso>"` if you want the | ||
| 125 | + push→job-start row. | ||
| 126 | + | ||
| 127 | +### Interleaving (mandatory) | ||
| 128 | + | ||
| 129 | +Run rounds **alternating platforms** — G, R, G, R, … — at least **10 rounds | ||
| 130 | +per platform**, serialized (never two benchmark runs in flight at once: on | ||
| 131 | +rickub your own queued job would inflate the other platform's queue metric). | ||
| 132 | +Cloud CI variance across hours and neighbours is well documented; a single | ||
| 133 | +run is noise. Alternation decorrelates the two platforms from time-of-day | ||
| 134 | +effects (fleet load, mirror warmth, co-tenants). Optionally discard round 1 | ||
| 135 | +per platform as image/toolchain warmup for the *operators*, not the runners. | ||
| 136 | + | ||
| 137 | +### Reporting | ||
| 138 | + | ||
| 139 | +``` | ||
| 140 | +python3 scripts/compare.py [--push-times push-times.json] | ||
| 141 | +``` | ||
| 142 | + | ||
| 143 | +Median (p50) and p95 per step per platform, linear-interpolated percentiles | ||
| 144 | +(the numpy method) over `ok` records only; `skipped`/`fail` counts are shown | ||
| 145 | +next to each cell so a platform quietly skipping docker is visible. The | ||
| 146 | +table prints n per cell — do not compare anything with n < 10. | ||
| 147 | + | ||
| 148 | +### Local smoke run | ||
| 149 | + | ||
| 150 | +`make run-local` executes the same step sequence on your machine (macOS | ||
| 151 | +works: the emitter falls back to python3 for millisecond timestamps and the | ||
| 152 | +probe degrades gracefully). Collect with | ||
| 153 | +`make collect PLATFORM=local RUN_ID=<id> SRC=results.jsonl`. | ||
| 154 | + | ||
| 155 | +## Known variance pitfalls | ||
| 156 | + | ||
| 157 | +- **Cross-clock subtraction** (push→job-start): server vs guest clock skew; | ||
| 158 | + prefer server-side-only `run_started_at − created_at` as cross-check. | ||
| 159 | +- **Queue contamination**: rickub queues runs; benchmark rounds must be | ||
| 160 | + serialized or you measure your own backlog. Same for GitHub concurrency | ||
| 161 | + groups (none set here, on purpose). | ||
| 162 | +- **Runner placement**: GitHub assigns runners across regions/hosts; | ||
| 163 | + crates.io and the Alpine mirror are at different RTTs from each placement. | ||
| 164 | + Only medians over many rounds are meaningful. | ||
| 165 | +- **Floating package indexes**: `apk add build-base` in the docker workload | ||
| 166 | + floats with the mirror; crate downloads float with crates.io. A changed | ||
| 167 | + upstream version shifts a cold build by minutes. If a round's cold build | ||
| 168 | + moves >2x the running median, check upstream before believing it. | ||
| 169 | +- **Toolchain download**: 1.85.0 is rustup-installed at job start on both | ||
| 170 | + platforms (inside the timed cold-build step? No — rustup resolves it when | ||
| 171 | + `cargo` first runs, so it IS inside the cold-build timing; identical on | ||
| 172 | + both platforms by construction). | ||
| 173 | +- **Warm build is nearly zero**: it measures scheduler/link noise; treat p95, | ||
| 174 | + not median, as the signal. | ||
| 175 | +- **probe-read is page-cache warm** by design (comparable, not absolute). | ||
| 176 | +- **vCPU asymmetry**: rickub `large` = 4 vCPU / 8 GiB vs GitHub ubuntu-latest | ||
| 177 | + 4 vCPU / 16 GiB. See below. | ||
| 178 | + | ||
| 179 | +## Known asymmetries (decisions to revisit) | ||
| 180 | + | ||
| 181 | +1. **Runner class**: the rickub workflow uses `runs-on: large` for vCPU parity | ||
| 182 | + with GitHub's ubuntu-latest. `ubuntu-latest` on rickub (2 vCPU / 4 GiB) is | ||
| 183 | + the "as-consumed, 1x minutes" alternative. This is a user decision. | ||
| 184 | +2. **Where the repo lives**: needs one GitHub repo and one rickub repo (or the | ||
| 185 | + same repo mirrored to both forges). Not created by this scaffold — no | ||
| 186 | + network was touched. | ||
| 187 | +3. **push-times.json**: the push timestamp per run is currently recorded by | ||
| 188 | + the operator from each platform's API; automating it needs API tokens on | ||
| 189 | + both sides. | ||
| 190 | + | ||
| 191 | +## Degrade behaviour | ||
| 192 | + | ||
| 193 | +| missing thing | behaviour | | ||
| 194 | +|---------------|------------------------------------------------------| | ||
| 195 | +| docker | `docker-build` step records `skipped` with reason | | ||
| 196 | +| cargo | (local runs) rust steps record `skipped` | | ||
| 197 | +| GNU date | python3 millis fallback, then whole-second `.000Z` | | ||
| 198 | +| dd / stat / df| probe records `null` values, job stays green | | ||
| 199 | +| python3 | emitter falls back to whole-second timestamps | | ||
added
scripts/collect.sh +60 -0 | new file mode 100755 | ||
| @@ -0,0 +1,60 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# collect.sh — gather one run's results.jsonl into the results tree. | |
| 3 | +# | |
| 4 | +# scripts/collect.sh PLATFORM RUN_ID SRC [SRC...] | |
| 5 | +# | |
| 6 | +# PLATFORM github | rickub | local | |
| 7 | +# RUN_ID the run's identifier (bench run_id / artifact run number) | |
| 8 | +# SRC one or more results.jsonl files (e.g. a downloaded artifact, | |
| 9 | +# extracted anywhere; multiple are concatenated in order) | |
| 10 | +# | |
| 11 | +# Output: results/<platform>/<run-id>.json — a JSON array of the run's records, | |
| 12 | +# normalized (blank lines dropped). Refuses to overwrite an existing file: two | |
| 13 | +# collected runs with the same id are almost certainly a copy/paste mistake. | |
| 14 | + | |
| 15 | +set -euo pipefail | |
| 16 | + | |
| 17 | +usage() { | |
| 18 | + sed -n '2,12p' "$0" >&2 | |
| 19 | + exit 2 | |
| 20 | +} | |
| 21 | + | |
| 22 | +[ $# -ge 3 ] || usage | |
| 23 | + | |
| 24 | +platform=$1 | |
| 25 | +run_id=$2 | |
| 26 | +shift 2 | |
| 27 | + | |
| 28 | +out="results/$platform/$run_id.json" | |
| 29 | +if [ -e "$out" ]; then | |
| 30 | + echo "collect: refusing to overwrite $out (delete it first if intentional)" >&2 | |
| 31 | + exit 1 | |
| 32 | +fi | |
| 33 | + | |
| 34 | +mkdir -p "results/$platform" | |
| 35 | + | |
| 36 | +# Concatenate the sources, drop blanks, and re-emit as a JSON array via | |
| 37 | +# python3 (stdlib) so the output is valid JSON even from partial files. | |
| 38 | +python3 - "$out" "$@" <<'PY' | |
| 39 | +import json, sys | |
| 40 | + | |
| 41 | +out_path, sources = sys.argv[1], sys.argv[2:] | |
| 42 | +records = [] | |
| 43 | +for src in sources: | |
| 44 | + with open(src, "r", encoding="utf-8", errors="replace") as fh: | |
| 45 | + for line in fh: | |
| 46 | + line = line.strip() | |
| 47 | + if not line: | |
| 48 | + continue | |
| 49 | + try: | |
| 50 | + records.append(json.loads(line)) | |
| 51 | + except json.JSONDecodeError: | |
| 52 | + # A torn last line (job killed mid-write): keep it out but say so. | |
| 53 | + print(f"collect: skipping unparseable line in {src}: {line[:80]!r}", file=sys.stderr) | |
| 54 | +if not records: | |
| 55 | + sys.exit(f"collect: no records found in {sources}") | |
| 56 | +with open(out_path, "w", encoding="utf-8") as fh: | |
| 57 | + json.dump(records, fh, indent=1) | |
| 58 | + fh.write("\n") | |
| 59 | +print(f"collect: {len(records)} records -> {out_path}") | |
| 60 | +PY | |
| new file mode 100755 | |||
| @@ -0,0 +1,60 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | +# collect.sh — gather one run's results.jsonl into the results tree. | ||
| 3 | +# | ||
| 4 | +# scripts/collect.sh PLATFORM RUN_ID SRC [SRC...] | ||
| 5 | +# | ||
| 6 | +# PLATFORM github | rickub | local | ||
| 7 | +# RUN_ID the run's identifier (bench run_id / artifact run number) | ||
| 8 | +# SRC one or more results.jsonl files (e.g. a downloaded artifact, | ||
| 9 | +# extracted anywhere; multiple are concatenated in order) | ||
| 10 | +# | ||
| 11 | +# Output: results/<platform>/<run-id>.json — a JSON array of the run's records, | ||
| 12 | +# normalized (blank lines dropped). Refuses to overwrite an existing file: two | ||
| 13 | +# collected runs with the same id are almost certainly a copy/paste mistake. | ||
| 14 | + | ||
| 15 | +set -euo pipefail | ||
| 16 | + | ||
| 17 | +usage() { | ||
| 18 | + sed -n '2,12p' "$0" >&2 | ||
| 19 | + exit 2 | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +[ $# -ge 3 ] || usage | ||
| 23 | + | ||
| 24 | +platform=$1 | ||
| 25 | +run_id=$2 | ||
| 26 | +shift 2 | ||
| 27 | + | ||
| 28 | +out="results/$platform/$run_id.json" | ||
| 29 | +if [ -e "$out" ]; then | ||
| 30 | + echo "collect: refusing to overwrite $out (delete it first if intentional)" >&2 | ||
| 31 | + exit 1 | ||
| 32 | +fi | ||
| 33 | + | ||
| 34 | +mkdir -p "results/$platform" | ||
| 35 | + | ||
| 36 | +# Concatenate the sources, drop blanks, and re-emit as a JSON array via | ||
| 37 | +# python3 (stdlib) so the output is valid JSON even from partial files. | ||
| 38 | +python3 - "$out" "$@" <<'PY' | ||
| 39 | +import json, sys | ||
| 40 | + | ||
| 41 | +out_path, sources = sys.argv[1], sys.argv[2:] | ||
| 42 | +records = [] | ||
| 43 | +for src in sources: | ||
| 44 | + with open(src, "r", encoding="utf-8", errors="replace") as fh: | ||
| 45 | + for line in fh: | ||
| 46 | + line = line.strip() | ||
| 47 | + if not line: | ||
| 48 | + continue | ||
| 49 | + try: | ||
| 50 | + records.append(json.loads(line)) | ||
| 51 | + except json.JSONDecodeError: | ||
| 52 | + # A torn last line (job killed mid-write): keep it out but say so. | ||
| 53 | + print(f"collect: skipping unparseable line in {src}: {line[:80]!r}", file=sys.stderr) | ||
| 54 | +if not records: | ||
| 55 | + sys.exit(f"collect: no records found in {sources}") | ||
| 56 | +with open(out_path, "w", encoding="utf-8") as fh: | ||
| 57 | + json.dump(records, fh, indent=1) | ||
| 58 | + fh.write("\n") | ||
| 59 | +print(f"collect: {len(records)} records -> {out_path}") | ||
| 60 | +PY | ||
added
scripts/compare.py +197 -0 | new file mode 100644 | ||
| @@ -0,0 +1,197 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""compare.py — median/p95 table per step per platform, from collected runs. | |
| 3 | + | |
| 4 | +Reads results/<platform>/<run-id>.json files (produced by scripts/collect.sh). | |
| 5 | +python3 stdlib only. | |
| 6 | + | |
| 7 | +Usage: | |
| 8 | + scripts/compare.py [--push-times FILE] | |
| 9 | + | |
| 10 | +Output: | |
| 11 | + - per step x platform: n, median, p95 of duration_ms (status ok only; | |
| 12 | + "fail"/"skipped" counted separately and shown) | |
| 13 | + - for metric-bearing steps (value field: probe-*), median of the value | |
| 14 | + - with --push-times: push -> job-start latency per platform, where push | |
| 15 | + timestamps come from FILE: {"<platform>/<run-id>": "<iso8601>", ...} | |
| 16 | + (see README "push->job-start" for where each timestamp comes from) | |
| 17 | + | |
| 18 | +Percentiles: linear interpolation between order statistics (the method numpy | |
| 19 | +percentile uses), reported at p50 (median) and p95. | |
| 20 | +""" | |
| 21 | + | |
| 22 | +import argparse | |
| 23 | +import json | |
| 24 | +import sys | |
| 25 | +from pathlib import Path | |
| 26 | + | |
| 27 | +RESULTS_DIR = Path(__file__).resolve().parent.parent / "results" | |
| 28 | +JOB_START_STEP = "job-start" | |
| 29 | +DURATION_UNITS = {"duration_ms": "ms"} | |
| 30 | + | |
| 31 | + | |
| 32 | +def percentile(sorted_vals, q): | |
| 33 | + """Linear-interpolation percentile. sorted_vals non-empty, q in [0,100].""" | |
| 34 | + if len(sorted_vals) == 1: | |
| 35 | + return sorted_vals[0] | |
| 36 | + pos = (len(sorted_vals) - 1) * q / 100.0 | |
| 37 | + lo = int(pos) | |
| 38 | + hi = min(lo + 1, len(sorted_vals) - 1) | |
| 39 | + frac = pos - lo | |
| 40 | + return sorted_vals[lo] + (sorted_vals[hi] - sorted_vals[lo]) * frac | |
| 41 | + | |
| 42 | + | |
| 43 | +def fmt_ms(v): | |
| 44 | + return f"{v / 1000.0:8.2f}s" if v >= 1000 else f"{v:8.0f}ms" | |
| 45 | + | |
| 46 | + | |
| 47 | +def fmt_v(v): | |
| 48 | + if v is None: | |
| 49 | + return " null" | |
| 50 | + return f"{v:8.1f}" | |
| 51 | + | |
| 52 | + | |
| 53 | +def load_platforms(): | |
| 54 | + platforms = {} | |
| 55 | + if not RESULTS_DIR.is_dir(): | |
| 56 | + return platforms | |
| 57 | + for pdir in sorted(RESULTS_DIR.iterdir()): | |
| 58 | + if not pdir.is_dir(): | |
| 59 | + continue | |
| 60 | + runs = [] | |
| 61 | + for rfile in sorted(pdir.glob("*.json")): | |
| 62 | + try: | |
| 63 | + with open(rfile, encoding="utf-8") as fh: | |
| 64 | + runs.append(json.load(fh)) | |
| 65 | + except (json.JSONDecodeError, OSError) as exc: | |
| 66 | + print(f"compare: skipping unreadable {rfile}: {exc}", file=sys.stderr) | |
| 67 | + if runs: | |
| 68 | + platforms[pdir.name] = runs | |
| 69 | + return platforms | |
| 70 | + | |
| 71 | + | |
| 72 | +def parse_iso(ts): | |
| 73 | + from datetime import datetime, timezone | |
| 74 | + | |
| 75 | + ts = ts.strip() | |
| 76 | + if ts.endswith("Z"): | |
| 77 | + ts = ts[:-1] + "+00:00" | |
| 78 | + dt = datetime.fromisoformat(ts) | |
| 79 | + if dt.tzinfo is None: | |
| 80 | + dt = dt.replace(tzinfo=timezone.utc) | |
| 81 | + return dt | |
| 82 | + | |
| 83 | + | |
| 84 | +def main(): | |
| 85 | + ap = argparse.ArgumentParser() | |
| 86 | + ap.add_argument("--push-times", metavar="FILE", | |
| 87 | + help="JSON map of '<platform>/<run-id>' -> push ISO timestamp") | |
| 88 | + args = ap.parse_args() | |
| 89 | + | |
| 90 | + platforms = load_platforms() | |
| 91 | + if not platforms: | |
| 92 | + print("compare: no collected results under results/ — run scripts/collect.sh first") | |
| 93 | + return 1 | |
| 94 | + | |
| 95 | + # step -> platform -> list of (record); plus counters | |
| 96 | + steps = {} | |
| 97 | + for platform, runs in platforms.items(): | |
| 98 | + for run in runs: | |
| 99 | + for rec in run: | |
| 100 | + step = rec.get("step", "?") | |
| 101 | + steps.setdefault(step, {}).setdefault(platform, []).append(rec) | |
| 102 | + | |
| 103 | + step_names = sorted(steps) | |
| 104 | + plat_names = sorted(platforms) | |
| 105 | + | |
| 106 | + header = f"{'step':<22}" + "".join(f"{p:>26}" for p in plat_names) | |
| 107 | + sub = f"{'':<22}" + "".join(f"{'n / median / p95':>26}" for p in plat_names) | |
| 108 | + print(header) | |
| 109 | + print(sub) | |
| 110 | + print("-" * len(header)) | |
| 111 | + | |
| 112 | + for step in step_names: | |
| 113 | + row = f"{step:<22}" | |
| 114 | + for plat in plat_names: | |
| 115 | + recs = steps[step].get(plat, []) | |
| 116 | + durs = sorted(r["duration_ms"] for r in recs | |
| 117 | + if r.get("status") == "ok" and isinstance(r.get("duration_ms"), (int, float))) | |
| 118 | + n_skipped = sum(1 for r in recs if r.get("status") == "skipped") | |
| 119 | + n_fail = sum(1 for r in recs if r.get("status") == "fail") | |
| 120 | + cell = f"{len(durs):>3} /" | |
| 121 | + if durs: | |
| 122 | + cell += f" {fmt_ms(percentile(durs, 50))} / {fmt_ms(percentile(durs, 95))}" | |
| 123 | + else: | |
| 124 | + cell += " - / -" | |
| 125 | + if n_skipped: | |
| 126 | + cell += f" (+{n_skipped}skip)" | |
| 127 | + if n_fail: | |
| 128 | + cell += f" (+{n_fail}FAIL)" | |
| 129 | + row += f"{cell:>26}" | |
| 130 | + print(row) | |
| 131 | + | |
| 132 | + # Metric-bearing steps: median of value. | |
| 133 | + metric_steps = [s for s in step_names | |
| 134 | + if any("value" in r for recs in steps[s].values() for r in recs)] | |
| 135 | + if metric_steps: | |
| 136 | + print() | |
| 137 | + print(f"{'metric (median value)':<22}" + "".join(f"{p:>26}" for p in plat_names)) | |
| 138 | + print("-" * len(header)) | |
| 139 | + for step in metric_steps: | |
| 140 | + row = f"{step:<22}" | |
| 141 | + unit = "" | |
| 142 | + for plat in plat_names: | |
| 143 | + recs = [r for r in steps[step].get(plat, []) if "value" in r] | |
| 144 | + nums = sorted(r["value"] for r in recs | |
| 145 | + if isinstance(r.get("value"), (int, float))) | |
| 146 | + strs = [r["value"] for r in recs if isinstance(r.get("value"), str)] | |
| 147 | + if nums: | |
| 148 | + unit = next((r.get("unit", "") for r in recs if r.get("unit")), "") | |
| 149 | + cell = f"{percentile(nums, 50):.1f} {unit}" | |
| 150 | + elif strs: | |
| 151 | + # categorical metric (e.g. fs type): show the most common value | |
| 152 | + cell = max(set(strs), key=strs.count)[:16] | |
| 153 | + else: | |
| 154 | + cell = "null" | |
| 155 | + row += f"{cell:>26}" | |
| 156 | + print(row) | |
| 157 | + | |
| 158 | + # push -> job-start latency. | |
| 159 | + if args.push_times: | |
| 160 | + print() | |
| 161 | + try: | |
| 162 | + with open(args.push_times, encoding="utf-8") as fh: | |
| 163 | + push_times = json.load(fh) | |
| 164 | + except (json.JSONDecodeError, OSError) as exc: | |
| 165 | + print(f"compare: cannot read --push-times: {exc}", file=sys.stderr) | |
| 166 | + return 1 | |
| 167 | + row = f"{'push->job-start':<22}" | |
| 168 | + for plat in plat_names: | |
| 169 | + lat = [] | |
| 170 | + for run in platforms[plat]: | |
| 171 | + run_id = next((r.get("run_id") for r in run if r.get("run_id")), None) | |
| 172 | + key = f"{plat}/{run_id}" | |
| 173 | + if key not in push_times: | |
| 174 | + continue | |
| 175 | + mark = next((r for r in run if r.get("step") == JOB_START_STEP), None) | |
| 176 | + if not mark: | |
| 177 | + continue | |
| 178 | + try: | |
| 179 | + delta = (parse_iso(mark["start"]) - parse_iso(push_times[key])).total_seconds() * 1000 | |
| 180 | + except (ValueError, KeyError): | |
| 181 | + continue | |
| 182 | + if delta >= 0: | |
| 183 | + lat.append(delta) | |
| 184 | + cell = f"{len(lat):>3} /" | |
| 185 | + cell += (f" {fmt_ms(percentile(sorted(lat), 50))} / {fmt_ms(percentile(sorted(lat), 95))}" | |
| 186 | + if lat else " - / -") | |
| 187 | + row += f"{cell:>26}" | |
| 188 | + print(row + " (cross-clock; see README caveats)") | |
| 189 | + | |
| 190 | + print() | |
| 191 | + print("durations = emitted end-start per step; percentiles linear-interpolated;") | |
| 192 | + print("report n>=10 per platform before trusting any comparison (README: variance).") | |
| 193 | + return 0 | |
| 194 | + | |
| 195 | + | |
| 196 | +if __name__ == "__main__": | |
| 197 | + sys.exit(main()) | |
| new file mode 100644 | |||
| @@ -0,0 +1,197 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +"""compare.py — median/p95 table per step per platform, from collected runs. | ||
| 3 | + | ||
| 4 | +Reads results/<platform>/<run-id>.json files (produced by scripts/collect.sh). | ||
| 5 | +python3 stdlib only. | ||
| 6 | + | ||
| 7 | +Usage: | ||
| 8 | + scripts/compare.py [--push-times FILE] | ||
| 9 | + | ||
| 10 | +Output: | ||
| 11 | + - per step x platform: n, median, p95 of duration_ms (status ok only; | ||
| 12 | + "fail"/"skipped" counted separately and shown) | ||
| 13 | + - for metric-bearing steps (value field: probe-*), median of the value | ||
| 14 | + - with --push-times: push -> job-start latency per platform, where push | ||
| 15 | + timestamps come from FILE: {"<platform>/<run-id>": "<iso8601>", ...} | ||
| 16 | + (see README "push->job-start" for where each timestamp comes from) | ||
| 17 | + | ||
| 18 | +Percentiles: linear interpolation between order statistics (the method numpy | ||
| 19 | +percentile uses), reported at p50 (median) and p95. | ||
| 20 | +""" | ||
| 21 | + | ||
| 22 | +import argparse | ||
| 23 | +import json | ||
| 24 | +import sys | ||
| 25 | +from pathlib import Path | ||
| 26 | + | ||
| 27 | +RESULTS_DIR = Path(__file__).resolve().parent.parent / "results" | ||
| 28 | +JOB_START_STEP = "job-start" | ||
| 29 | +DURATION_UNITS = {"duration_ms": "ms"} | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +def percentile(sorted_vals, q): | ||
| 33 | + """Linear-interpolation percentile. sorted_vals non-empty, q in [0,100].""" | ||
| 34 | + if len(sorted_vals) == 1: | ||
| 35 | + return sorted_vals[0] | ||
| 36 | + pos = (len(sorted_vals) - 1) * q / 100.0 | ||
| 37 | + lo = int(pos) | ||
| 38 | + hi = min(lo + 1, len(sorted_vals) - 1) | ||
| 39 | + frac = pos - lo | ||
| 40 | + return sorted_vals[lo] + (sorted_vals[hi] - sorted_vals[lo]) * frac | ||
| 41 | + | ||
| 42 | + | ||
| 43 | +def fmt_ms(v): | ||
| 44 | + return f"{v / 1000.0:8.2f}s" if v >= 1000 else f"{v:8.0f}ms" | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +def fmt_v(v): | ||
| 48 | + if v is None: | ||
| 49 | + return " null" | ||
| 50 | + return f"{v:8.1f}" | ||
| 51 | + | ||
| 52 | + | ||
| 53 | +def load_platforms(): | ||
| 54 | + platforms = {} | ||
| 55 | + if not RESULTS_DIR.is_dir(): | ||
| 56 | + return platforms | ||
| 57 | + for pdir in sorted(RESULTS_DIR.iterdir()): | ||
| 58 | + if not pdir.is_dir(): | ||
| 59 | + continue | ||
| 60 | + runs = [] | ||
| 61 | + for rfile in sorted(pdir.glob("*.json")): | ||
| 62 | + try: | ||
| 63 | + with open(rfile, encoding="utf-8") as fh: | ||
| 64 | + runs.append(json.load(fh)) | ||
| 65 | + except (json.JSONDecodeError, OSError) as exc: | ||
| 66 | + print(f"compare: skipping unreadable {rfile}: {exc}", file=sys.stderr) | ||
| 67 | + if runs: | ||
| 68 | + platforms[pdir.name] = runs | ||
| 69 | + return platforms | ||
| 70 | + | ||
| 71 | + | ||
| 72 | +def parse_iso(ts): | ||
| 73 | + from datetime import datetime, timezone | ||
| 74 | + | ||
| 75 | + ts = ts.strip() | ||
| 76 | + if ts.endswith("Z"): | ||
| 77 | + ts = ts[:-1] + "+00:00" | ||
| 78 | + dt = datetime.fromisoformat(ts) | ||
| 79 | + if dt.tzinfo is None: | ||
| 80 | + dt = dt.replace(tzinfo=timezone.utc) | ||
| 81 | + return dt | ||
| 82 | + | ||
| 83 | + | ||
| 84 | +def main(): | ||
| 85 | + ap = argparse.ArgumentParser() | ||
| 86 | + ap.add_argument("--push-times", metavar="FILE", | ||
| 87 | + help="JSON map of '<platform>/<run-id>' -> push ISO timestamp") | ||
| 88 | + args = ap.parse_args() | ||
| 89 | + | ||
| 90 | + platforms = load_platforms() | ||
| 91 | + if not platforms: | ||
| 92 | + print("compare: no collected results under results/ — run scripts/collect.sh first") | ||
| 93 | + return 1 | ||
| 94 | + | ||
| 95 | + # step -> platform -> list of (record); plus counters | ||
| 96 | + steps = {} | ||
| 97 | + for platform, runs in platforms.items(): | ||
| 98 | + for run in runs: | ||
| 99 | + for rec in run: | ||
| 100 | + step = rec.get("step", "?") | ||
| 101 | + steps.setdefault(step, {}).setdefault(platform, []).append(rec) | ||
| 102 | + | ||
| 103 | + step_names = sorted(steps) | ||
| 104 | + plat_names = sorted(platforms) | ||
| 105 | + | ||
| 106 | + header = f"{'step':<22}" + "".join(f"{p:>26}" for p in plat_names) | ||
| 107 | + sub = f"{'':<22}" + "".join(f"{'n / median / p95':>26}" for p in plat_names) | ||
| 108 | + print(header) | ||
| 109 | + print(sub) | ||
| 110 | + print("-" * len(header)) | ||
| 111 | + | ||
| 112 | + for step in step_names: | ||
| 113 | + row = f"{step:<22}" | ||
| 114 | + for plat in plat_names: | ||
| 115 | + recs = steps[step].get(plat, []) | ||
| 116 | + durs = sorted(r["duration_ms"] for r in recs | ||
| 117 | + if r.get("status") == "ok" and isinstance(r.get("duration_ms"), (int, float))) | ||
| 118 | + n_skipped = sum(1 for r in recs if r.get("status") == "skipped") | ||
| 119 | + n_fail = sum(1 for r in recs if r.get("status") == "fail") | ||
| 120 | + cell = f"{len(durs):>3} /" | ||
| 121 | + if durs: | ||
| 122 | + cell += f" {fmt_ms(percentile(durs, 50))} / {fmt_ms(percentile(durs, 95))}" | ||
| 123 | + else: | ||
| 124 | + cell += " - / -" | ||
| 125 | + if n_skipped: | ||
| 126 | + cell += f" (+{n_skipped}skip)" | ||
| 127 | + if n_fail: | ||
| 128 | + cell += f" (+{n_fail}FAIL)" | ||
| 129 | + row += f"{cell:>26}" | ||
| 130 | + print(row) | ||
| 131 | + | ||
| 132 | + # Metric-bearing steps: median of value. | ||
| 133 | + metric_steps = [s for s in step_names | ||
| 134 | + if any("value" in r for recs in steps[s].values() for r in recs)] | ||
| 135 | + if metric_steps: | ||
| 136 | + print() | ||
| 137 | + print(f"{'metric (median value)':<22}" + "".join(f"{p:>26}" for p in plat_names)) | ||
| 138 | + print("-" * len(header)) | ||
| 139 | + for step in metric_steps: | ||
| 140 | + row = f"{step:<22}" | ||
| 141 | + unit = "" | ||
| 142 | + for plat in plat_names: | ||
| 143 | + recs = [r for r in steps[step].get(plat, []) if "value" in r] | ||
| 144 | + nums = sorted(r["value"] for r in recs | ||
| 145 | + if isinstance(r.get("value"), (int, float))) | ||
| 146 | + strs = [r["value"] for r in recs if isinstance(r.get("value"), str)] | ||
| 147 | + if nums: | ||
| 148 | + unit = next((r.get("unit", "") for r in recs if r.get("unit")), "") | ||
| 149 | + cell = f"{percentile(nums, 50):.1f} {unit}" | ||
| 150 | + elif strs: | ||
| 151 | + # categorical metric (e.g. fs type): show the most common value | ||
| 152 | + cell = max(set(strs), key=strs.count)[:16] | ||
| 153 | + else: | ||
| 154 | + cell = "null" | ||
| 155 | + row += f"{cell:>26}" | ||
| 156 | + print(row) | ||
| 157 | + | ||
| 158 | + # push -> job-start latency. | ||
| 159 | + if args.push_times: | ||
| 160 | + print() | ||
| 161 | + try: | ||
| 162 | + with open(args.push_times, encoding="utf-8") as fh: | ||
| 163 | + push_times = json.load(fh) | ||
| 164 | + except (json.JSONDecodeError, OSError) as exc: | ||
| 165 | + print(f"compare: cannot read --push-times: {exc}", file=sys.stderr) | ||
| 166 | + return 1 | ||
| 167 | + row = f"{'push->job-start':<22}" | ||
| 168 | + for plat in plat_names: | ||
| 169 | + lat = [] | ||
| 170 | + for run in platforms[plat]: | ||
| 171 | + run_id = next((r.get("run_id") for r in run if r.get("run_id")), None) | ||
| 172 | + key = f"{plat}/{run_id}" | ||
| 173 | + if key not in push_times: | ||
| 174 | + continue | ||
| 175 | + mark = next((r for r in run if r.get("step") == JOB_START_STEP), None) | ||
| 176 | + if not mark: | ||
| 177 | + continue | ||
| 178 | + try: | ||
| 179 | + delta = (parse_iso(mark["start"]) - parse_iso(push_times[key])).total_seconds() * 1000 | ||
| 180 | + except (ValueError, KeyError): | ||
| 181 | + continue | ||
| 182 | + if delta >= 0: | ||
| 183 | + lat.append(delta) | ||
| 184 | + cell = f"{len(lat):>3} /" | ||
| 185 | + cell += (f" {fmt_ms(percentile(sorted(lat), 50))} / {fmt_ms(percentile(sorted(lat), 95))}" | ||
| 186 | + if lat else " - / -") | ||
| 187 | + row += f"{cell:>26}" | ||
| 188 | + print(row + " (cross-clock; see README caveats)") | ||
| 189 | + | ||
| 190 | + print() | ||
| 191 | + print("durations = emitted end-start per step; percentiles linear-interpolated;") | ||
| 192 | + print("report n>=10 per platform before trusting any comparison (README: variance).") | ||
| 193 | + return 0 | ||
| 194 | + | ||
| 195 | + | ||
| 196 | +if __name__ == "__main__": | ||
| 197 | + sys.exit(main()) | ||
added
scripts/emit_timing.sh +143 -0 | new file mode 100755 | ||
| @@ -0,0 +1,143 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# emit_timing.sh — sourced by every benchmark step (CI and local). | |
| 3 | +# | |
| 4 | +# Emits ONE JSON line per step to $GITHUB_WORKSPACE/results.jsonl (falling back | |
| 5 | +# to ./results.jsonl). Schema: | |
| 6 | +# {"step": <name>, "platform": <github|rickub|local>, "run_id": <id>, | |
| 7 | +# "start": <iso8601>, "end": <iso8601>, "status": <ok|fail|skipped>, | |
| 8 | +# "duration_ms": <int>, # convenience, = end - start | |
| 9 | +# "value": <json>, "unit": <string>, "reason": <string>} # optional extras | |
| 10 | +# | |
| 11 | +# Public API: | |
| 12 | +# bench_step STEP CMD [ARGS...] run CMD, record ok/fail (compound bodies: | |
| 13 | +# bench_step x bash -c '...; ...') | |
| 14 | +# bench_skip STEP REASON record a skipped step (e.g. no docker) | |
| 15 | +# bench_metric STEP VALUE UNIT record a measurement now (value is raw JSON: | |
| 16 | +# number, "string", or null) | |
| 17 | +# bench_mark STEP zero-duration marker (e.g. job-start) | |
| 18 | +# | |
| 19 | +# Determined at source time (env overrides win): | |
| 20 | +# BENCH_PLATFORM github | rickub | local (default: inferred) | |
| 21 | +# BENCH_RUN_ID stable identifier for the whole run (default: GITHUB_RUN_ID, | |
| 22 | +# else local-<utcstamp>-<pid>) | |
| 23 | +# | |
| 24 | +# This file sets NO shell options on purpose: it is sourced into the caller's | |
| 25 | +# shell and must not change its behaviour. | |
| 26 | + | |
| 27 | +# --- clock helpers ----------------------------------------------------------- | |
| 28 | +# Prefer GNU date's %N (all Linux CI guests); BSD date (macOS) prints a literal | |
| 29 | +# "N" which the regex rejects; then python3; then whole-second granularity. | |
| 30 | +_bench_now_iso() { | |
| 31 | + local t | |
| 32 | + t=$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ 2>/dev/null || true) | |
| 33 | + # A valid timestamp contains no "N"; BSD date prints a literal one for %3N. | |
| 34 | + case $t in | |
| 35 | + *N*) ;; # %3N unsupported (BSD date) — fall through | |
| 36 | + *) printf '%s\n' "$t"; return 0 ;; | |
| 37 | + esac | |
| 38 | + t=$(python3 -c 'import datetime | |
| 39 | +print(datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]+"Z")' 2>/dev/null || true) | |
| 40 | + case $t in | |
| 41 | + 2[0-9][0-9][0-9]-*) printf '%s\n' "$t"; return 0 ;; | |
| 42 | + esac | |
| 43 | + date -u +%Y-%m-%dT%H:%M:%S.000Z | |
| 44 | +} | |
| 45 | + | |
| 46 | +_bench_iso_to_ms() { # ISO (above format) -> epoch ms; python3 when available | |
| 47 | + python3 - "$1" <<'PY' 2>/dev/null || printf '%s\n' "$(_bench_iso_to_ms_shell "$1")" | |
| 48 | +import sys, datetime | |
| 49 | +s = sys.argv[1] | |
| 50 | +print(int(datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=datetime.timezone.utc).timestamp() * 1000)) | |
| 51 | +PY | |
| 52 | +} | |
| 53 | + | |
| 54 | +_bench_iso_to_ms_shell() { # fallback: second granularity (documented coarser) | |
| 55 | + local s=${1%%.*} | |
| 56 | + date -u -j -f '%Y-%m-%dT%H:%M:%S' "$s" +%s 2>/dev/null \ | |
| 57 | + || date -u -d "$s" +%s 2>/dev/null \ | |
| 58 | + || printf '0\n' | |
| 59 | +} | |
| 60 | + | |
| 61 | +# --- identity ---------------------------------------------------------------- | |
| 62 | +_bench_platform() { | |
| 63 | + if [ -n "${BENCH_PLATFORM:-}" ]; then | |
| 64 | + printf '%s\n' "$BENCH_PLATFORM" | |
| 65 | + elif [ -n "${GITHUB_SERVER_URL:-}" ]; then | |
| 66 | + case $GITHUB_SERVER_URL in | |
| 67 | + *github.com*) printf 'github\n' ;; | |
| 68 | + *) printf 'rickub\n' ;; | |
| 69 | + esac | |
| 70 | + else | |
| 71 | + printf 'local\n' | |
| 72 | + fi | |
| 73 | +} | |
| 74 | + | |
| 75 | +_bench_run_id() { | |
| 76 | + if [ -n "${BENCH_RUN_ID:-}" ]; then printf '%s\n' "$BENCH_RUN_ID" | |
| 77 | + elif [ -n "${GITHUB_RUN_ID:-}" ]; then | |
| 78 | + printf '%s-attempt%s\n' "${GITHUB_RUN_ID}" "${GITHUB_RUN_ATTEMPT:-1}" | |
| 79 | + else | |
| 80 | + printf 'local-%s-%s\n' "$(date -u +%Y%m%dT%H%M%SZ)" "$$" | |
| 81 | + fi | |
| 82 | +} | |
| 83 | + | |
| 84 | +# --- record emission --------------------------------------------------------- | |
| 85 | +_bench_results_file() { | |
| 86 | + local dir=${GITHUB_WORKSPACE:-$PWD} | |
| 87 | + printf '%s/results.jsonl\n' "$dir" | |
| 88 | +} | |
| 89 | + | |
| 90 | +_bench_esc() { # minimal JSON string escaping (control chars flattened) | |
| 91 | + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr '\n\r\t' ' ' | |
| 92 | +} | |
| 93 | + | |
| 94 | +# _bench_emit STEP STATUS START END [VALUE UNIT REASON] | |
| 95 | +_bench_emit() { | |
| 96 | + local step=$1 status=$2 start=$3 end=$4 value=${5-} unit=${6-} reason=${7-} | |
| 97 | + local line f dur_ms | |
| 98 | + f=$(_bench_results_file) | |
| 99 | + dur_ms=$(( $(_bench_iso_to_ms "$end") - $(_bench_iso_to_ms "$start") )) | |
| 100 | + [ "$dur_ms" -lt 0 ] && dur_ms=0 | |
| 101 | + line='{"step": "'$(_bench_esc "$step")'"' | |
| 102 | + line+=', "platform": "'$(_bench_esc "$(_bench_platform)")'"' | |
| 103 | + line+=', "run_id": "'$(_bench_esc "$(_bench_run_id)")'"' | |
| 104 | + line+=', "start": "'$(_bench_esc "$start")'"' | |
| 105 | + line+=', "end": "'$(_bench_esc "$end")'"' | |
| 106 | + line+=', "status": "'$(_bench_esc "$status")'"' | |
| 107 | + line+=', "duration_ms": '"$dur_ms" | |
| 108 | + [ -n "$value" ] && line+=', "value": '"$value" | |
| 109 | + [ -n "$unit" ] && line+=', "unit": "'$(_bench_esc "$unit")'"' | |
| 110 | + [ -n "$reason" ] && line+=', "reason": "'$(_bench_esc "$reason")'"' | |
| 111 | + line+='}' | |
| 112 | + printf '%s\n' "$line" >>"$f" | |
| 113 | +} | |
| 114 | + | |
| 115 | +# --- public API -------------------------------------------------------------- | |
| 116 | +bench_step() { # STEP CMD [ARGS...] | |
| 117 | + # Records ok/fail AND propagates the command's exit code, so a failed | |
| 118 | + # workload fails the CI step (not silently recorded as a red data point). | |
| 119 | + local step=$1; shift | |
| 120 | + local start end status rc | |
| 121 | + start=$(_bench_now_iso) | |
| 122 | + rc=0 | |
| 123 | + "$@" || rc=$? | |
| 124 | + [ "$rc" -eq 0 ] && status=ok || status=fail | |
| 125 | + end=$(_bench_now_iso) | |
| 126 | + _bench_emit "$step" "$status" "$start" "$end" | |
| 127 | + return "$rc" | |
| 128 | +} | |
| 129 | + | |
| 130 | +bench_skip() { # STEP REASON | |
| 131 | + local now=$(_bench_now_iso) | |
| 132 | + _bench_emit "$1" skipped "$now" "$now" '' '' "$2" | |
| 133 | +} | |
| 134 | + | |
| 135 | +bench_metric() { # STEP VALUE UNIT (VALUE is raw JSON: 123, "ext4", null) | |
| 136 | + local now=$(_bench_now_iso) | |
| 137 | + _bench_emit "$1" ok "$now" "$now" "$2" "$3" | |
| 138 | +} | |
| 139 | + | |
| 140 | +bench_mark() { # STEP | |
| 141 | + local now=$(_bench_now_iso) | |
| 142 | + _bench_emit "$1" ok "$now" "$now" | |
| 143 | +} | |
| new file mode 100755 | |||
| @@ -0,0 +1,143 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | +# emit_timing.sh — sourced by every benchmark step (CI and local). | ||
| 3 | +# | ||
| 4 | +# Emits ONE JSON line per step to $GITHUB_WORKSPACE/results.jsonl (falling back | ||
| 5 | +# to ./results.jsonl). Schema: | ||
| 6 | +# {"step": <name>, "platform": <github|rickub|local>, "run_id": <id>, | ||
| 7 | +# "start": <iso8601>, "end": <iso8601>, "status": <ok|fail|skipped>, | ||
| 8 | +# "duration_ms": <int>, # convenience, = end - start | ||
| 9 | +# "value": <json>, "unit": <string>, "reason": <string>} # optional extras | ||
| 10 | +# | ||
| 11 | +# Public API: | ||
| 12 | +# bench_step STEP CMD [ARGS...] run CMD, record ok/fail (compound bodies: | ||
| 13 | +# bench_step x bash -c '...; ...') | ||
| 14 | +# bench_skip STEP REASON record a skipped step (e.g. no docker) | ||
| 15 | +# bench_metric STEP VALUE UNIT record a measurement now (value is raw JSON: | ||
| 16 | +# number, "string", or null) | ||
| 17 | +# bench_mark STEP zero-duration marker (e.g. job-start) | ||
| 18 | +# | ||
| 19 | +# Determined at source time (env overrides win): | ||
| 20 | +# BENCH_PLATFORM github | rickub | local (default: inferred) | ||
| 21 | +# BENCH_RUN_ID stable identifier for the whole run (default: GITHUB_RUN_ID, | ||
| 22 | +# else local-<utcstamp>-<pid>) | ||
| 23 | +# | ||
| 24 | +# This file sets NO shell options on purpose: it is sourced into the caller's | ||
| 25 | +# shell and must not change its behaviour. | ||
| 26 | + | ||
| 27 | +# --- clock helpers ----------------------------------------------------------- | ||
| 28 | +# Prefer GNU date's %N (all Linux CI guests); BSD date (macOS) prints a literal | ||
| 29 | +# "N" which the regex rejects; then python3; then whole-second granularity. | ||
| 30 | +_bench_now_iso() { | ||
| 31 | + local t | ||
| 32 | + t=$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ 2>/dev/null || true) | ||
| 33 | + # A valid timestamp contains no "N"; BSD date prints a literal one for %3N. | ||
| 34 | + case $t in | ||
| 35 | + *N*) ;; # %3N unsupported (BSD date) — fall through | ||
| 36 | + *) printf '%s\n' "$t"; return 0 ;; | ||
| 37 | + esac | ||
| 38 | + t=$(python3 -c 'import datetime | ||
| 39 | +print(datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]+"Z")' 2>/dev/null || true) | ||
| 40 | + case $t in | ||
| 41 | + 2[0-9][0-9][0-9]-*) printf '%s\n' "$t"; return 0 ;; | ||
| 42 | + esac | ||
| 43 | + date -u +%Y-%m-%dT%H:%M:%S.000Z | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +_bench_iso_to_ms() { # ISO (above format) -> epoch ms; python3 when available | ||
| 47 | + python3 - "$1" <<'PY' 2>/dev/null || printf '%s\n' "$(_bench_iso_to_ms_shell "$1")" | ||
| 48 | +import sys, datetime | ||
| 49 | +s = sys.argv[1] | ||
| 50 | +print(int(datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=datetime.timezone.utc).timestamp() * 1000)) | ||
| 51 | +PY | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +_bench_iso_to_ms_shell() { # fallback: second granularity (documented coarser) | ||
| 55 | + local s=${1%%.*} | ||
| 56 | + date -u -j -f '%Y-%m-%dT%H:%M:%S' "$s" +%s 2>/dev/null \ | ||
| 57 | + || date -u -d "$s" +%s 2>/dev/null \ | ||
| 58 | + || printf '0\n' | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +# --- identity ---------------------------------------------------------------- | ||
| 62 | +_bench_platform() { | ||
| 63 | + if [ -n "${BENCH_PLATFORM:-}" ]; then | ||
| 64 | + printf '%s\n' "$BENCH_PLATFORM" | ||
| 65 | + elif [ -n "${GITHUB_SERVER_URL:-}" ]; then | ||
| 66 | + case $GITHUB_SERVER_URL in | ||
| 67 | + *github.com*) printf 'github\n' ;; | ||
| 68 | + *) printf 'rickub\n' ;; | ||
| 69 | + esac | ||
| 70 | + else | ||
| 71 | + printf 'local\n' | ||
| 72 | + fi | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +_bench_run_id() { | ||
| 76 | + if [ -n "${BENCH_RUN_ID:-}" ]; then printf '%s\n' "$BENCH_RUN_ID" | ||
| 77 | + elif [ -n "${GITHUB_RUN_ID:-}" ]; then | ||
| 78 | + printf '%s-attempt%s\n' "${GITHUB_RUN_ID}" "${GITHUB_RUN_ATTEMPT:-1}" | ||
| 79 | + else | ||
| 80 | + printf 'local-%s-%s\n' "$(date -u +%Y%m%dT%H%M%SZ)" "$$" | ||
| 81 | + fi | ||
| 82 | +} | ||
| 83 | + | ||
| 84 | +# --- record emission --------------------------------------------------------- | ||
| 85 | +_bench_results_file() { | ||
| 86 | + local dir=${GITHUB_WORKSPACE:-$PWD} | ||
| 87 | + printf '%s/results.jsonl\n' "$dir" | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +_bench_esc() { # minimal JSON string escaping (control chars flattened) | ||
| 91 | + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr '\n\r\t' ' ' | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +# _bench_emit STEP STATUS START END [VALUE UNIT REASON] | ||
| 95 | +_bench_emit() { | ||
| 96 | + local step=$1 status=$2 start=$3 end=$4 value=${5-} unit=${6-} reason=${7-} | ||
| 97 | + local line f dur_ms | ||
| 98 | + f=$(_bench_results_file) | ||
| 99 | + dur_ms=$(( $(_bench_iso_to_ms "$end") - $(_bench_iso_to_ms "$start") )) | ||
| 100 | + [ "$dur_ms" -lt 0 ] && dur_ms=0 | ||
| 101 | + line='{"step": "'$(_bench_esc "$step")'"' | ||
| 102 | + line+=', "platform": "'$(_bench_esc "$(_bench_platform)")'"' | ||
| 103 | + line+=', "run_id": "'$(_bench_esc "$(_bench_run_id)")'"' | ||
| 104 | + line+=', "start": "'$(_bench_esc "$start")'"' | ||
| 105 | + line+=', "end": "'$(_bench_esc "$end")'"' | ||
| 106 | + line+=', "status": "'$(_bench_esc "$status")'"' | ||
| 107 | + line+=', "duration_ms": '"$dur_ms" | ||
| 108 | + [ -n "$value" ] && line+=', "value": '"$value" | ||
| 109 | + [ -n "$unit" ] && line+=', "unit": "'$(_bench_esc "$unit")'"' | ||
| 110 | + [ -n "$reason" ] && line+=', "reason": "'$(_bench_esc "$reason")'"' | ||
| 111 | + line+='}' | ||
| 112 | + printf '%s\n' "$line" >>"$f" | ||
| 113 | +} | ||
| 114 | + | ||
| 115 | +# --- public API -------------------------------------------------------------- | ||
| 116 | +bench_step() { # STEP CMD [ARGS...] | ||
| 117 | + # Records ok/fail AND propagates the command's exit code, so a failed | ||
| 118 | + # workload fails the CI step (not silently recorded as a red data point). | ||
| 119 | + local step=$1; shift | ||
| 120 | + local start end status rc | ||
| 121 | + start=$(_bench_now_iso) | ||
| 122 | + rc=0 | ||
| 123 | + "$@" || rc=$? | ||
| 124 | + [ "$rc" -eq 0 ] && status=ok || status=fail | ||
| 125 | + end=$(_bench_now_iso) | ||
| 126 | + _bench_emit "$step" "$status" "$start" "$end" | ||
| 127 | + return "$rc" | ||
| 128 | +} | ||
| 129 | + | ||
| 130 | +bench_skip() { # STEP REASON | ||
| 131 | + local now=$(_bench_now_iso) | ||
| 132 | + _bench_emit "$1" skipped "$now" "$now" '' '' "$2" | ||
| 133 | +} | ||
| 134 | + | ||
| 135 | +bench_metric() { # STEP VALUE UNIT (VALUE is raw JSON: 123, "ext4", null) | ||
| 136 | + local now=$(_bench_now_iso) | ||
| 137 | + _bench_emit "$1" ok "$now" "$now" "$2" "$3" | ||
| 138 | +} | ||
| 139 | + | ||
| 140 | +bench_mark() { # STEP | ||
| 141 | + local now=$(_bench_now_iso) | ||
| 142 | + _bench_emit "$1" ok "$now" "$now" | ||
| 143 | +} | ||
added
workloads/docker-build/Dockerfile +25 -0 | new file mode 100644 | ||
| @@ -0,0 +1,25 @@ | ||
| 1 | +# docker-build workload — multi-stage build against a digest-pinned base. | |
| 2 | +# | |
| 3 | +# The base is pinned to the exact alpine:3.20 digest rickub itself pins for its | |
| 4 | +# own CI images (images/firecracker/rootfs/Dockerfile in the rickub monorepo), | |
| 5 | +# i.e. a digest known to be stable and public. CAVEAT (documented in README): | |
| 6 | +# `apk add` inside the builder still floats with the Alpine mirror at run time; | |
| 7 | +# a truly bit-frozen build would vendored-package the toolchain, which would | |
| 8 | +# remove the network component this workload deliberately measures. | |
| 9 | + | |
| 10 | +# syntax=docker/dockerfile:1 | |
| 11 | +ARG ALPINE_IMAGE=alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc | |
| 12 | + | |
| 13 | +# --- builder: fetch toolchain, compile -------------------------------------- | |
| 14 | +FROM ${ALPINE_IMAGE} AS builder | |
| 15 | +RUN apk add --no-cache build-base | |
| 16 | +WORKDIR /src | |
| 17 | +COPY main.c . | |
| 18 | +RUN cc -O2 -static -o ci-bench-docker main.c | |
| 19 | + | |
| 20 | +# --- final: tiny image, just the static binary ------------------------------ | |
| 21 | +FROM ${ALPINE_IMAGE} | |
| 22 | +WORKDIR / | |
| 23 | +COPY --from=builder /src/ci-bench-docker /usr/local/bin/ci-bench-docker | |
| 24 | +# Expected output: ci-bench-docker-a56ee6092483 | |
| 25 | +CMD ["/usr/local/bin/ci-bench-docker"] | |
| new file mode 100644 | |||
| @@ -0,0 +1,25 @@ | |||
| 1 | +# docker-build workload — multi-stage build against a digest-pinned base. | ||
| 2 | +# | ||
| 3 | +# The base is pinned to the exact alpine:3.20 digest rickub itself pins for its | ||
| 4 | +# own CI images (images/firecracker/rootfs/Dockerfile in the rickub monorepo), | ||
| 5 | +# i.e. a digest known to be stable and public. CAVEAT (documented in README): | ||
| 6 | +# `apk add` inside the builder still floats with the Alpine mirror at run time; | ||
| 7 | +# a truly bit-frozen build would vendored-package the toolchain, which would | ||
| 8 | +# remove the network component this workload deliberately measures. | ||
| 9 | + | ||
| 10 | +# syntax=docker/dockerfile:1 | ||
| 11 | +ARG ALPINE_IMAGE=alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc | ||
| 12 | + | ||
| 13 | +# --- builder: fetch toolchain, compile -------------------------------------- | ||
| 14 | +FROM ${ALPINE_IMAGE} AS builder | ||
| 15 | +RUN apk add --no-cache build-base | ||
| 16 | +WORKDIR /src | ||
| 17 | +COPY main.c . | ||
| 18 | +RUN cc -O2 -static -o ci-bench-docker main.c | ||
| 19 | + | ||
| 20 | +# --- final: tiny image, just the static binary ------------------------------ | ||
| 21 | +FROM ${ALPINE_IMAGE} | ||
| 22 | +WORKDIR / | ||
| 23 | +COPY --from=builder /src/ci-bench-docker /usr/local/bin/ci-bench-docker | ||
| 24 | +# Expected output: ci-bench-docker-a56ee6092483 | ||
| 25 | +CMD ["/usr/local/bin/ci-bench-docker"] | ||
added
workloads/docker-build/main.c +41 -0 | new file mode 100644 | ||
| @@ -0,0 +1,41 @@ | ||
| 1 | +/* | |
| 2 | + * main.c — the tiny C program the docker-build workload compiles. | |
| 3 | + * | |
| 4 | + * The BUILD is the benchmark (multi-stage, network fetch of build-base, gcc | |
| 5 | + * invocation, final image assembly). The program itself just does a | |
| 6 | + * deterministic FNV-1a pass so the binary is non-trivial and its output is | |
| 7 | + * stable across platforms: ci-bench-docker-a56ee6092483 | |
| 8 | + */ | |
| 9 | + | |
| 10 | +#include <stdio.h> | |
| 11 | +#include <stdint.h> | |
| 12 | +#include <string.h> | |
| 13 | + | |
| 14 | +#define ROUNDS 2000000u | |
| 15 | +#define SEED_LEN 64 | |
| 16 | + | |
| 17 | +static uint64_t fnv1a(uint64_t h, const unsigned char *data, size_t len) { | |
| 18 | + for (size_t i = 0; i < len; i++) { | |
| 19 | + h ^= data[i]; | |
| 20 | + h *= 1099511628211ULL; /* FNV prime */ | |
| 21 | + } | |
| 22 | + return h; | |
| 23 | +} | |
| 24 | + | |
| 25 | +int main(void) { | |
| 26 | + unsigned char seed[SEED_LEN]; | |
| 27 | + for (size_t i = 0; i < SEED_LEN; i++) | |
| 28 | + seed[i] = (unsigned char)(i * 31 + 7); | |
| 29 | + | |
| 30 | + uint64_t h = 1469598103934665603ULL; /* FNV offset basis */ | |
| 31 | + for (uint32_t r = 0; r < ROUNDS; r++) { | |
| 32 | + seed[0] = (unsigned char)(r & 0xff); | |
| 33 | + seed[1] = (unsigned char)((r >> 8) & 0xff); | |
| 34 | + h = fnv1a(h, seed, SEED_LEN); | |
| 35 | + } | |
| 36 | + | |
| 37 | + /* Expected: stable for the fixed seed/rounds above; printed so a runner | |
| 38 | + * log shows the binary actually executed. */ | |
| 39 | + printf("ci-bench-docker-%012llx\n", (unsigned long long)(h & 0xffffffffffffULL)); | |
| 40 | + return 0; | |
| 41 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,41 @@ | |||
| 1 | +/* | ||
| 2 | + * main.c — the tiny C program the docker-build workload compiles. | ||
| 3 | + * | ||
| 4 | + * The BUILD is the benchmark (multi-stage, network fetch of build-base, gcc | ||
| 5 | + * invocation, final image assembly). The program itself just does a | ||
| 6 | + * deterministic FNV-1a pass so the binary is non-trivial and its output is | ||
| 7 | + * stable across platforms: ci-bench-docker-a56ee6092483 | ||
| 8 | + */ | ||
| 9 | + | ||
| 10 | +#include <stdio.h> | ||
| 11 | +#include <stdint.h> | ||
| 12 | +#include <string.h> | ||
| 13 | + | ||
| 14 | +#define ROUNDS 2000000u | ||
| 15 | +#define SEED_LEN 64 | ||
| 16 | + | ||
| 17 | +static uint64_t fnv1a(uint64_t h, const unsigned char *data, size_t len) { | ||
| 18 | + for (size_t i = 0; i < len; i++) { | ||
| 19 | + h ^= data[i]; | ||
| 20 | + h *= 1099511628211ULL; /* FNV prime */ | ||
| 21 | + } | ||
| 22 | + return h; | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +int main(void) { | ||
| 26 | + unsigned char seed[SEED_LEN]; | ||
| 27 | + for (size_t i = 0; i < SEED_LEN; i++) | ||
| 28 | + seed[i] = (unsigned char)(i * 31 + 7); | ||
| 29 | + | ||
| 30 | + uint64_t h = 1469598103934665603ULL; /* FNV offset basis */ | ||
| 31 | + for (uint32_t r = 0; r < ROUNDS; r++) { | ||
| 32 | + seed[0] = (unsigned char)(r & 0xff); | ||
| 33 | + seed[1] = (unsigned char)((r >> 8) & 0xff); | ||
| 34 | + h = fnv1a(h, seed, SEED_LEN); | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + /* Expected: stable for the fixed seed/rounds above; printed so a runner | ||
| 38 | + * log shows the binary actually executed. */ | ||
| 39 | + printf("ci-bench-docker-%012llx\n", (unsigned long long)(h & 0xffffffffffffULL)); | ||
| 40 | + return 0; | ||
| 41 | +} | ||
added
workloads/probe/probe.sh +132 -0 | new file mode 100755 | ||
| @@ -0,0 +1,132 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# probe.sh — deterministic runner-capability probe. Every measurement degrades | |
| 3 | +# to a "null" value (still a recorded line) when a tool is missing; the job | |
| 4 | +# NEVER fails from the probe itself. | |
| 5 | +# | |
| 6 | +# Measurements: | |
| 7 | +# probe-cpu single-core shell-arithmetic loop throughput (kops/s over a | |
| 8 | +# fixed 1,000,000-iteration loop) | |
| 9 | +# probe-write sequential write throughput (dd 2 GiB to the job scratch | |
| 10 | +# dir, then removed) | |
| 11 | +# probe-read sequential read throughput of that same file | |
| 12 | +# probe-fs-type filesystem type of the scratch dir | |
| 13 | +# probe-fs-free free space of the scratch dir (MB) | |
| 14 | +# | |
| 15 | +# Usage: run from the repo root (the scripts/ dir next to workloads/): | |
| 16 | +# bash workloads/probe/probe.sh | |
| 17 | + | |
| 18 | +set -u | |
| 19 | + | |
| 20 | +REPO_ROOT=$(cd "$(dirname "$0")/../.." && pwd) | |
| 21 | +# shellcheck disable=SC1091 | |
| 22 | +. "$REPO_ROOT/scripts/emit_timing.sh" | |
| 23 | + | |
| 24 | +# Scratch dir: the runner's temp when we have one, else mktemp -d. | |
| 25 | +PROBE_DIR=${RUNNER_TEMP:-${TMPDIR:-/tmp}} | |
| 26 | +mkdir -p "$PROBE_DIR" 2>/dev/null || PROBE_DIR=$(mktemp -d) | |
| 27 | +PROBE_BIN=$PROBE_DIR/probe.bin | |
| 28 | + | |
| 29 | +now_s() { # fractional seconds, best available clock | |
| 30 | + local t | |
| 31 | + t=$(date -u +%s.%N 2>/dev/null || true) | |
| 32 | + case $t in | |
| 33 | + *N) t=$(python3 -c 'import time; print(f"{time.time():.6f}")' 2>/dev/null || echo '') ;; | |
| 34 | + esac | |
| 35 | + [ -n "$t" ] || t=$(date +%s).000 | |
| 36 | + printf '%s\n' "$t" | |
| 37 | +} | |
| 38 | + | |
| 39 | +probe_cpu() { | |
| 40 | + if ! command -v python3 >/dev/null 2>&1 && ! command -v bc >/dev/null 2>&1; then | |
| 41 | + bench_metric probe-cpu null kops_per_s # no fractional clock available | |
| 42 | + return 0 | |
| 43 | + fi | |
| 44 | + local iters=1000000 t0 t1 elapsed | |
| 45 | + t0=$(now_s) | |
| 46 | + local i=0 | |
| 47 | + while [ "$i" -lt "$iters" ]; do | |
| 48 | + i=$((i + 1)) | |
| 49 | + done | |
| 50 | + t1=$(now_s) | |
| 51 | + if command -v python3 >/dev/null 2>&1; then | |
| 52 | + elapsed=$(python3 -c "print(f'{$t1 - $t0:.6f}')") | |
| 53 | + else | |
| 54 | + elapsed=$(echo "$t1 - $t0" | bc -l) | |
| 55 | + fi | |
| 56 | + bench_metric probe-cpu "$(python3 -c "print(f'{$iters / $elapsed / 1000:.2f}')" 2>/dev/null \ | |
| 57 | + || echo "$iters $elapsed" | awk '{printf "%.2f", $1 / $2 / 1000}')" kops_per_s | |
| 58 | +} | |
| 59 | + | |
| 60 | +# Parse the "bytes ... copied/transferred in N s" line dd writes to stderr. | |
| 61 | +# GNU: "X bytes (...) copied, 3.123 s, ...". BSD: "X bytes transferred in | |
| 62 | +# 3.123456 secs". Prints "BYTES SECONDS" on stdout, nothing when unmatched. | |
| 63 | +# ERE (-E) because BSD sed has no BRE alternation. | |
| 64 | +parse_dd() { | |
| 65 | + sed -nE 's/^([0-9]+) bytes.*[ ,] ?([0-9.]+) s.*/\1 \2/p' | |
| 66 | +} | |
| 67 | + | |
| 68 | +probe_write() { | |
| 69 | + command -v dd >/dev/null 2>&1 || { bench_metric probe-write null MB_per_s; return 0; } | |
| 70 | + local out | |
| 71 | + out=$(dd if=/dev/zero of="$PROBE_BIN" bs=1M count=2048 2>&1) || true | |
| 72 | + local parsed | |
| 73 | + parsed=$(printf '%s\n' "$out" | parse_dd) | |
| 74 | + if [ -z "$parsed" ]; then | |
| 75 | + bench_metric probe-write null MB_per_s | |
| 76 | + return 0 | |
| 77 | + fi | |
| 78 | + set -- $parsed | |
| 79 | + bench_metric probe-write "$(awk -v b="$1" -v s="$2" 'BEGIN { printf "%.1f", b / s / 1000000 }')" MB_per_s | |
| 80 | +} | |
| 81 | + | |
| 82 | +probe_read() { | |
| 83 | + if [ ! -f "$PROBE_BIN" ]; then | |
| 84 | + bench_metric probe-read null MB_per_s # write probe never produced it | |
| 85 | + return 0 | |
| 86 | + fi | |
| 87 | + command -v dd >/dev/null 2>&1 || { bench_metric probe-read null MB_per_s; return 0; } | |
| 88 | + # Drop the page cache when we can (best effort, usually not permitted); | |
| 89 | + # otherwise this measures warm cache — still comparable across platforms. | |
| 90 | + sync 2>/dev/null || true | |
| 91 | + local out | |
| 92 | + out=$(dd if="$PROBE_BIN" of=/dev/null bs=1M 2>&1) || true | |
| 93 | + local parsed | |
| 94 | + parsed=$(printf '%s\n' "$out" | parse_dd) | |
| 95 | + if [ -z "$parsed" ]; then | |
| 96 | + bench_metric probe-read null MB_per_s | |
| 97 | + return 0 | |
| 98 | + fi | |
| 99 | + set -- $parsed | |
| 100 | + bench_metric probe-read "$(awk -v b="$1" -v s="$2" 'BEGIN { printf "%.1f", b / s / 1000000 }')" MB_per_s | |
| 101 | +} | |
| 102 | + | |
| 103 | +probe_fs() { | |
| 104 | + # Filesystem type: GNU stat -f -c %T, BSD stat -f %T, else null. | |
| 105 | + local fstype=null | |
| 106 | + if stat -f -c %T "$PROBE_DIR" >/dev/null 2>&1; then | |
| 107 | + fstype='"'$(stat -f -c %T "$PROBE_DIR" 2>/dev/null)'"' | |
| 108 | + elif stat -f %T "$PROBE_DIR" >/dev/null 2>&1; then | |
| 109 | + fstype='"'$(stat -f %T "$PROBE_DIR" 2>/dev/null | tr -d '"')'"' | |
| 110 | + fi | |
| 111 | + bench_metric probe-fs-type "$fstype" fstype | |
| 112 | + | |
| 113 | + # Free space in MB via df -k (POSIX), else null. | |
| 114 | + if command -v df >/dev/null 2>&1; then | |
| 115 | + local free_kb | |
| 116 | + free_kb=$(df -Pk "$PROBE_DIR" 2>/dev/null | awk 'NR==2 {print $4}') || free_kb='' | |
| 117 | + case $free_kb in | |
| 118 | + ''|*[!0-9]*) bench_metric probe-fs-free null MB ;; | |
| 119 | + *) bench_metric probe-fs-free "$((free_kb / 1024))" MB ;; | |
| 120 | + esac | |
| 121 | + else | |
| 122 | + bench_metric probe-fs-free null MB | |
| 123 | + fi | |
| 124 | +} | |
| 125 | + | |
| 126 | +probe_cpu | |
| 127 | +probe_write | |
| 128 | +probe_read | |
| 129 | +rm -f "$PROBE_BIN" 2>/dev/null || true | |
| 130 | +probe_fs | |
| 131 | + | |
| 132 | +exit 0 | |
| new file mode 100755 | |||
| @@ -0,0 +1,132 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | +# probe.sh — deterministic runner-capability probe. Every measurement degrades | ||
| 3 | +# to a "null" value (still a recorded line) when a tool is missing; the job | ||
| 4 | +# NEVER fails from the probe itself. | ||
| 5 | +# | ||
| 6 | +# Measurements: | ||
| 7 | +# probe-cpu single-core shell-arithmetic loop throughput (kops/s over a | ||
| 8 | +# fixed 1,000,000-iteration loop) | ||
| 9 | +# probe-write sequential write throughput (dd 2 GiB to the job scratch | ||
| 10 | +# dir, then removed) | ||
| 11 | +# probe-read sequential read throughput of that same file | ||
| 12 | +# probe-fs-type filesystem type of the scratch dir | ||
| 13 | +# probe-fs-free free space of the scratch dir (MB) | ||
| 14 | +# | ||
| 15 | +# Usage: run from the repo root (the scripts/ dir next to workloads/): | ||
| 16 | +# bash workloads/probe/probe.sh | ||
| 17 | + | ||
| 18 | +set -u | ||
| 19 | + | ||
| 20 | +REPO_ROOT=$(cd "$(dirname "$0")/../.." && pwd) | ||
| 21 | +# shellcheck disable=SC1091 | ||
| 22 | +. "$REPO_ROOT/scripts/emit_timing.sh" | ||
| 23 | + | ||
| 24 | +# Scratch dir: the runner's temp when we have one, else mktemp -d. | ||
| 25 | +PROBE_DIR=${RUNNER_TEMP:-${TMPDIR:-/tmp}} | ||
| 26 | +mkdir -p "$PROBE_DIR" 2>/dev/null || PROBE_DIR=$(mktemp -d) | ||
| 27 | +PROBE_BIN=$PROBE_DIR/probe.bin | ||
| 28 | + | ||
| 29 | +now_s() { # fractional seconds, best available clock | ||
| 30 | + local t | ||
| 31 | + t=$(date -u +%s.%N 2>/dev/null || true) | ||
| 32 | + case $t in | ||
| 33 | + *N) t=$(python3 -c 'import time; print(f"{time.time():.6f}")' 2>/dev/null || echo '') ;; | ||
| 34 | + esac | ||
| 35 | + [ -n "$t" ] || t=$(date +%s).000 | ||
| 36 | + printf '%s\n' "$t" | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +probe_cpu() { | ||
| 40 | + if ! command -v python3 >/dev/null 2>&1 && ! command -v bc >/dev/null 2>&1; then | ||
| 41 | + bench_metric probe-cpu null kops_per_s # no fractional clock available | ||
| 42 | + return 0 | ||
| 43 | + fi | ||
| 44 | + local iters=1000000 t0 t1 elapsed | ||
| 45 | + t0=$(now_s) | ||
| 46 | + local i=0 | ||
| 47 | + while [ "$i" -lt "$iters" ]; do | ||
| 48 | + i=$((i + 1)) | ||
| 49 | + done | ||
| 50 | + t1=$(now_s) | ||
| 51 | + if command -v python3 >/dev/null 2>&1; then | ||
| 52 | + elapsed=$(python3 -c "print(f'{$t1 - $t0:.6f}')") | ||
| 53 | + else | ||
| 54 | + elapsed=$(echo "$t1 - $t0" | bc -l) | ||
| 55 | + fi | ||
| 56 | + bench_metric probe-cpu "$(python3 -c "print(f'{$iters / $elapsed / 1000:.2f}')" 2>/dev/null \ | ||
| 57 | + || echo "$iters $elapsed" | awk '{printf "%.2f", $1 / $2 / 1000}')" kops_per_s | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +# Parse the "bytes ... copied/transferred in N s" line dd writes to stderr. | ||
| 61 | +# GNU: "X bytes (...) copied, 3.123 s, ...". BSD: "X bytes transferred in | ||
| 62 | +# 3.123456 secs". Prints "BYTES SECONDS" on stdout, nothing when unmatched. | ||
| 63 | +# ERE (-E) because BSD sed has no BRE alternation. | ||
| 64 | +parse_dd() { | ||
| 65 | + sed -nE 's/^([0-9]+) bytes.*[ ,] ?([0-9.]+) s.*/\1 \2/p' | ||
| 66 | +} | ||
| 67 | + | ||
| 68 | +probe_write() { | ||
| 69 | + command -v dd >/dev/null 2>&1 || { bench_metric probe-write null MB_per_s; return 0; } | ||
| 70 | + local out | ||
| 71 | + out=$(dd if=/dev/zero of="$PROBE_BIN" bs=1M count=2048 2>&1) || true | ||
| 72 | + local parsed | ||
| 73 | + parsed=$(printf '%s\n' "$out" | parse_dd) | ||
| 74 | + if [ -z "$parsed" ]; then | ||
| 75 | + bench_metric probe-write null MB_per_s | ||
| 76 | + return 0 | ||
| 77 | + fi | ||
| 78 | + set -- $parsed | ||
| 79 | + bench_metric probe-write "$(awk -v b="$1" -v s="$2" 'BEGIN { printf "%.1f", b / s / 1000000 }')" MB_per_s | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +probe_read() { | ||
| 83 | + if [ ! -f "$PROBE_BIN" ]; then | ||
| 84 | + bench_metric probe-read null MB_per_s # write probe never produced it | ||
| 85 | + return 0 | ||
| 86 | + fi | ||
| 87 | + command -v dd >/dev/null 2>&1 || { bench_metric probe-read null MB_per_s; return 0; } | ||
| 88 | + # Drop the page cache when we can (best effort, usually not permitted); | ||
| 89 | + # otherwise this measures warm cache — still comparable across platforms. | ||
| 90 | + sync 2>/dev/null || true | ||
| 91 | + local out | ||
| 92 | + out=$(dd if="$PROBE_BIN" of=/dev/null bs=1M 2>&1) || true | ||
| 93 | + local parsed | ||
| 94 | + parsed=$(printf '%s\n' "$out" | parse_dd) | ||
| 95 | + if [ -z "$parsed" ]; then | ||
| 96 | + bench_metric probe-read null MB_per_s | ||
| 97 | + return 0 | ||
| 98 | + fi | ||
| 99 | + set -- $parsed | ||
| 100 | + bench_metric probe-read "$(awk -v b="$1" -v s="$2" 'BEGIN { printf "%.1f", b / s / 1000000 }')" MB_per_s | ||
| 101 | +} | ||
| 102 | + | ||
| 103 | +probe_fs() { | ||
| 104 | + # Filesystem type: GNU stat -f -c %T, BSD stat -f %T, else null. | ||
| 105 | + local fstype=null | ||
| 106 | + if stat -f -c %T "$PROBE_DIR" >/dev/null 2>&1; then | ||
| 107 | + fstype='"'$(stat -f -c %T "$PROBE_DIR" 2>/dev/null)'"' | ||
| 108 | + elif stat -f %T "$PROBE_DIR" >/dev/null 2>&1; then | ||
| 109 | + fstype='"'$(stat -f %T "$PROBE_DIR" 2>/dev/null | tr -d '"')'"' | ||
| 110 | + fi | ||
| 111 | + bench_metric probe-fs-type "$fstype" fstype | ||
| 112 | + | ||
| 113 | + # Free space in MB via df -k (POSIX), else null. | ||
| 114 | + if command -v df >/dev/null 2>&1; then | ||
| 115 | + local free_kb | ||
| 116 | + free_kb=$(df -Pk "$PROBE_DIR" 2>/dev/null | awk 'NR==2 {print $4}') || free_kb='' | ||
| 117 | + case $free_kb in | ||
| 118 | + ''|*[!0-9]*) bench_metric probe-fs-free null MB ;; | ||
| 119 | + *) bench_metric probe-fs-free "$((free_kb / 1024))" MB ;; | ||
| 120 | + esac | ||
| 121 | + else | ||
| 122 | + bench_metric probe-fs-free null MB | ||
| 123 | + fi | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +probe_cpu | ||
| 127 | +probe_write | ||
| 128 | +probe_read | ||
| 129 | +rm -f "$PROBE_BIN" 2>/dev/null || true | ||
| 130 | +probe_fs | ||
| 131 | + | ||
| 132 | +exit 0 | ||
added
workloads/rust-build/Cargo.lock +341 -0 | new file mode 100644 | ||
| @@ -0,0 +1,341 @@ | ||
| 1 | +# This file is automatically @generated by Cargo. | |
| 2 | +# It is not intended for manual editing. | |
| 3 | +version = 4 | |
| 4 | + | |
| 5 | +[[package]] | |
| 6 | +name = "aho-corasick" | |
| 7 | +version = "1.1.5" | |
| 8 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" | |
| 10 | +dependencies = [ | |
| 11 | + "memchr", | |
| 12 | +] | |
| 13 | + | |
| 14 | +[[package]] | |
| 15 | +name = "anstream" | |
| 16 | +version = "1.0.0" | |
| 17 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 18 | +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" | |
| 19 | +dependencies = [ | |
| 20 | + "anstyle", | |
| 21 | + "anstyle-parse", | |
| 22 | + "anstyle-query", | |
| 23 | + "anstyle-wincon", | |
| 24 | + "colorchoice", | |
| 25 | + "is_terminal_polyfill", | |
| 26 | + "utf8parse", | |
| 27 | +] | |
| 28 | + | |
| 29 | +[[package]] | |
| 30 | +name = "anstyle" | |
| 31 | +version = "1.0.14" | |
| 32 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 33 | +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" | |
| 34 | + | |
| 35 | +[[package]] | |
| 36 | +name = "anstyle-parse" | |
| 37 | +version = "1.0.0" | |
| 38 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 39 | +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" | |
| 40 | +dependencies = [ | |
| 41 | + "utf8parse", | |
| 42 | +] | |
| 43 | + | |
| 44 | +[[package]] | |
| 45 | +name = "anstyle-query" | |
| 46 | +version = "1.1.5" | |
| 47 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 48 | +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" | |
| 49 | +dependencies = [ | |
| 50 | + "windows-sys", | |
| 51 | +] | |
| 52 | + | |
| 53 | +[[package]] | |
| 54 | +name = "anstyle-wincon" | |
| 55 | +version = "3.0.11" | |
| 56 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 57 | +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" | |
| 58 | +dependencies = [ | |
| 59 | + "anstyle", | |
| 60 | + "once_cell_polyfill", | |
| 61 | + "windows-sys", | |
| 62 | +] | |
| 63 | + | |
| 64 | +[[package]] | |
| 65 | +name = "anyhow" | |
| 66 | +version = "1.0.103" | |
| 67 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 68 | +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" | |
| 69 | + | |
| 70 | +[[package]] | |
| 71 | +name = "bench-rust-build" | |
| 72 | +version = "0.1.0" | |
| 73 | +dependencies = [ | |
| 74 | + "anyhow", | |
| 75 | + "clap", | |
| 76 | + "regex", | |
| 77 | + "serde", | |
| 78 | + "serde_json", | |
| 79 | + "tokio", | |
| 80 | +] | |
| 81 | + | |
| 82 | +[[package]] | |
| 83 | +name = "bytes" | |
| 84 | +version = "1.12.1" | |
| 85 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 86 | +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" | |
| 87 | + | |
| 88 | +[[package]] | |
| 89 | +name = "clap" | |
| 90 | +version = "4.6.1" | |
| 91 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 92 | +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" | |
| 93 | +dependencies = [ | |
| 94 | + "clap_builder", | |
| 95 | + "clap_derive", | |
| 96 | +] | |
| 97 | + | |
| 98 | +[[package]] | |
| 99 | +name = "clap_builder" | |
| 100 | +version = "4.6.0" | |
| 101 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 102 | +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" | |
| 103 | +dependencies = [ | |
| 104 | + "anstream", | |
| 105 | + "anstyle", | |
| 106 | + "clap_lex", | |
| 107 | + "strsim", | |
| 108 | +] | |
| 109 | + | |
| 110 | +[[package]] | |
| 111 | +name = "clap_derive" | |
| 112 | +version = "4.6.1" | |
| 113 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 114 | +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" | |
| 115 | +dependencies = [ | |
| 116 | + "heck", | |
| 117 | + "proc-macro2", | |
| 118 | + "quote", | |
| 119 | + "syn 2.0.119", | |
| 120 | +] | |
| 121 | + | |
| 122 | +[[package]] | |
| 123 | +name = "clap_lex" | |
| 124 | +version = "1.1.0" | |
| 125 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 126 | +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" | |
| 127 | + | |
| 128 | +[[package]] | |
| 129 | +name = "colorchoice" | |
| 130 | +version = "1.0.5" | |
| 131 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 132 | +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" | |
| 133 | + | |
| 134 | +[[package]] | |
| 135 | +name = "heck" | |
| 136 | +version = "0.5.0" | |
| 137 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 138 | +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | |
| 139 | + | |
| 140 | +[[package]] | |
| 141 | +name = "is_terminal_polyfill" | |
| 142 | +version = "1.70.2" | |
| 143 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 144 | +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" | |
| 145 | + | |
| 146 | +[[package]] | |
| 147 | +name = "itoa" | |
| 148 | +version = "1.0.18" | |
| 149 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 150 | +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | |
| 151 | + | |
| 152 | +[[package]] | |
| 153 | +name = "memchr" | |
| 154 | +version = "2.8.3" | |
| 155 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 156 | +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" | |
| 157 | + | |
| 158 | +[[package]] | |
| 159 | +name = "once_cell_polyfill" | |
| 160 | +version = "1.70.2" | |
| 161 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 162 | +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" | |
| 163 | + | |
| 164 | +[[package]] | |
| 165 | +name = "pin-project-lite" | |
| 166 | +version = "0.2.17" | |
| 167 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 168 | +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | |
| 169 | + | |
| 170 | +[[package]] | |
| 171 | +name = "proc-macro2" | |
| 172 | +version = "1.0.107" | |
| 173 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 174 | +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" | |
| 175 | +dependencies = [ | |
| 176 | + "unicode-ident", | |
| 177 | +] | |
| 178 | + | |
| 179 | +[[package]] | |
| 180 | +name = "quote" | |
| 181 | +version = "1.0.47" | |
| 182 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 183 | +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" | |
| 184 | +dependencies = [ | |
| 185 | + "proc-macro2", | |
| 186 | +] | |
| 187 | + | |
| 188 | +[[package]] | |
| 189 | +name = "regex" | |
| 190 | +version = "1.13.1" | |
| 191 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 192 | +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" | |
| 193 | +dependencies = [ | |
| 194 | + "aho-corasick", | |
| 195 | + "memchr", | |
| 196 | + "regex-automata", | |
| 197 | + "regex-syntax", | |
| 198 | +] | |
| 199 | + | |
| 200 | +[[package]] | |
| 201 | +name = "regex-automata" | |
| 202 | +version = "0.4.18" | |
| 203 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 204 | +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" | |
| 205 | +dependencies = [ | |
| 206 | + "aho-corasick", | |
| 207 | + "memchr", | |
| 208 | + "regex-syntax", | |
| 209 | +] | |
| 210 | + | |
| 211 | +[[package]] | |
| 212 | +name = "regex-syntax" | |
| 213 | +version = "0.8.11" | |
| 214 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 215 | +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" | |
| 216 | + | |
| 217 | +[[package]] | |
| 218 | +name = "serde" | |
| 219 | +version = "1.0.228" | |
| 220 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 221 | +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" | |
| 222 | +dependencies = [ | |
| 223 | + "serde_core", | |
| 224 | + "serde_derive", | |
| 225 | +] | |
| 226 | + | |
| 227 | +[[package]] | |
| 228 | +name = "serde_core" | |
| 229 | +version = "1.0.228" | |
| 230 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 231 | +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" | |
| 232 | +dependencies = [ | |
| 233 | + "serde_derive", | |
| 234 | +] | |
| 235 | + | |
| 236 | +[[package]] | |
| 237 | +name = "serde_derive" | |
| 238 | +version = "1.0.228" | |
| 239 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 240 | +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" | |
| 241 | +dependencies = [ | |
| 242 | + "proc-macro2", | |
| 243 | + "quote", | |
| 244 | + "syn 2.0.119", | |
| 245 | +] | |
| 246 | + | |
| 247 | +[[package]] | |
| 248 | +name = "serde_json" | |
| 249 | +version = "1.0.150" | |
| 250 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 251 | +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" | |
| 252 | +dependencies = [ | |
| 253 | + "itoa", | |
| 254 | + "memchr", | |
| 255 | + "serde", | |
| 256 | + "serde_core", | |
| 257 | + "zmij", | |
| 258 | +] | |
| 259 | + | |
| 260 | +[[package]] | |
| 261 | +name = "strsim" | |
| 262 | +version = "0.11.1" | |
| 263 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 264 | +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" | |
| 265 | + | |
| 266 | +[[package]] | |
| 267 | +name = "syn" | |
| 268 | +version = "2.0.119" | |
| 269 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 270 | +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" | |
| 271 | +dependencies = [ | |
| 272 | + "proc-macro2", | |
| 273 | + "quote", | |
| 274 | + "unicode-ident", | |
| 275 | +] | |
| 276 | + | |
| 277 | +[[package]] | |
| 278 | +name = "syn" | |
| 279 | +version = "3.0.4" | |
| 280 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 281 | +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" | |
| 282 | +dependencies = [ | |
| 283 | + "proc-macro2", | |
| 284 | + "quote", | |
| 285 | + "unicode-ident", | |
| 286 | +] | |
| 287 | + | |
| 288 | +[[package]] | |
| 289 | +name = "tokio" | |
| 290 | +version = "1.53.0" | |
| 291 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 292 | +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" | |
| 293 | +dependencies = [ | |
| 294 | + "bytes", | |
| 295 | + "pin-project-lite", | |
| 296 | + "tokio-macros", | |
| 297 | +] | |
| 298 | + | |
| 299 | +[[package]] | |
| 300 | +name = "tokio-macros" | |
| 301 | +version = "2.7.2" | |
| 302 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 303 | +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" | |
| 304 | +dependencies = [ | |
| 305 | + "proc-macro2", | |
| 306 | + "quote", | |
| 307 | + "syn 3.0.4", | |
| 308 | +] | |
| 309 | + | |
| 310 | +[[package]] | |
| 311 | +name = "unicode-ident" | |
| 312 | +version = "1.0.24" | |
| 313 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 314 | +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | |
| 315 | + | |
| 316 | +[[package]] | |
| 317 | +name = "utf8parse" | |
| 318 | +version = "0.2.2" | |
| 319 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 320 | +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" | |
| 321 | + | |
| 322 | +[[package]] | |
| 323 | +name = "windows-link" | |
| 324 | +version = "0.2.1" | |
| 325 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 326 | +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | |
| 327 | + | |
| 328 | +[[package]] | |
| 329 | +name = "windows-sys" | |
| 330 | +version = "0.61.2" | |
| 331 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 332 | +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" | |
| 333 | +dependencies = [ | |
| 334 | + "windows-link", | |
| 335 | +] | |
| 336 | + | |
| 337 | +[[package]] | |
| 338 | +name = "zmij" | |
| 339 | +version = "1.0.23" | |
| 340 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 341 | +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
| new file mode 100644 | |||
| @@ -0,0 +1,341 @@ | |||
| 1 | +# This file is automatically @generated by Cargo. | ||
| 2 | +# It is not intended for manual editing. | ||
| 3 | +version = 4 | ||
| 4 | + | ||
| 5 | +[[package]] | ||
| 6 | +name = "aho-corasick" | ||
| 7 | +version = "1.1.5" | ||
| 8 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 9 | +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" | ||
| 10 | +dependencies = [ | ||
| 11 | + "memchr", | ||
| 12 | +] | ||
| 13 | + | ||
| 14 | +[[package]] | ||
| 15 | +name = "anstream" | ||
| 16 | +version = "1.0.0" | ||
| 17 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 18 | +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" | ||
| 19 | +dependencies = [ | ||
| 20 | + "anstyle", | ||
| 21 | + "anstyle-parse", | ||
| 22 | + "anstyle-query", | ||
| 23 | + "anstyle-wincon", | ||
| 24 | + "colorchoice", | ||
| 25 | + "is_terminal_polyfill", | ||
| 26 | + "utf8parse", | ||
| 27 | +] | ||
| 28 | + | ||
| 29 | +[[package]] | ||
| 30 | +name = "anstyle" | ||
| 31 | +version = "1.0.14" | ||
| 32 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 33 | +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" | ||
| 34 | + | ||
| 35 | +[[package]] | ||
| 36 | +name = "anstyle-parse" | ||
| 37 | +version = "1.0.0" | ||
| 38 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 39 | +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" | ||
| 40 | +dependencies = [ | ||
| 41 | + "utf8parse", | ||
| 42 | +] | ||
| 43 | + | ||
| 44 | +[[package]] | ||
| 45 | +name = "anstyle-query" | ||
| 46 | +version = "1.1.5" | ||
| 47 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 48 | +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" | ||
| 49 | +dependencies = [ | ||
| 50 | + "windows-sys", | ||
| 51 | +] | ||
| 52 | + | ||
| 53 | +[[package]] | ||
| 54 | +name = "anstyle-wincon" | ||
| 55 | +version = "3.0.11" | ||
| 56 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 57 | +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" | ||
| 58 | +dependencies = [ | ||
| 59 | + "anstyle", | ||
| 60 | + "once_cell_polyfill", | ||
| 61 | + "windows-sys", | ||
| 62 | +] | ||
| 63 | + | ||
| 64 | +[[package]] | ||
| 65 | +name = "anyhow" | ||
| 66 | +version = "1.0.103" | ||
| 67 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 68 | +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" | ||
| 69 | + | ||
| 70 | +[[package]] | ||
| 71 | +name = "bench-rust-build" | ||
| 72 | +version = "0.1.0" | ||
| 73 | +dependencies = [ | ||
| 74 | + "anyhow", | ||
| 75 | + "clap", | ||
| 76 | + "regex", | ||
| 77 | + "serde", | ||
| 78 | + "serde_json", | ||
| 79 | + "tokio", | ||
| 80 | +] | ||
| 81 | + | ||
| 82 | +[[package]] | ||
| 83 | +name = "bytes" | ||
| 84 | +version = "1.12.1" | ||
| 85 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 86 | +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" | ||
| 87 | + | ||
| 88 | +[[package]] | ||
| 89 | +name = "clap" | ||
| 90 | +version = "4.6.1" | ||
| 91 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 92 | +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" | ||
| 93 | +dependencies = [ | ||
| 94 | + "clap_builder", | ||
| 95 | + "clap_derive", | ||
| 96 | +] | ||
| 97 | + | ||
| 98 | +[[package]] | ||
| 99 | +name = "clap_builder" | ||
| 100 | +version = "4.6.0" | ||
| 101 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 102 | +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" | ||
| 103 | +dependencies = [ | ||
| 104 | + "anstream", | ||
| 105 | + "anstyle", | ||
| 106 | + "clap_lex", | ||
| 107 | + "strsim", | ||
| 108 | +] | ||
| 109 | + | ||
| 110 | +[[package]] | ||
| 111 | +name = "clap_derive" | ||
| 112 | +version = "4.6.1" | ||
| 113 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 114 | +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" | ||
| 115 | +dependencies = [ | ||
| 116 | + "heck", | ||
| 117 | + "proc-macro2", | ||
| 118 | + "quote", | ||
| 119 | + "syn 2.0.119", | ||
| 120 | +] | ||
| 121 | + | ||
| 122 | +[[package]] | ||
| 123 | +name = "clap_lex" | ||
| 124 | +version = "1.1.0" | ||
| 125 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 126 | +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" | ||
| 127 | + | ||
| 128 | +[[package]] | ||
| 129 | +name = "colorchoice" | ||
| 130 | +version = "1.0.5" | ||
| 131 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 132 | +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" | ||
| 133 | + | ||
| 134 | +[[package]] | ||
| 135 | +name = "heck" | ||
| 136 | +version = "0.5.0" | ||
| 137 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 138 | +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | ||
| 139 | + | ||
| 140 | +[[package]] | ||
| 141 | +name = "is_terminal_polyfill" | ||
| 142 | +version = "1.70.2" | ||
| 143 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 144 | +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" | ||
| 145 | + | ||
| 146 | +[[package]] | ||
| 147 | +name = "itoa" | ||
| 148 | +version = "1.0.18" | ||
| 149 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 150 | +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | ||
| 151 | + | ||
| 152 | +[[package]] | ||
| 153 | +name = "memchr" | ||
| 154 | +version = "2.8.3" | ||
| 155 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 156 | +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" | ||
| 157 | + | ||
| 158 | +[[package]] | ||
| 159 | +name = "once_cell_polyfill" | ||
| 160 | +version = "1.70.2" | ||
| 161 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 162 | +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" | ||
| 163 | + | ||
| 164 | +[[package]] | ||
| 165 | +name = "pin-project-lite" | ||
| 166 | +version = "0.2.17" | ||
| 167 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 168 | +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | ||
| 169 | + | ||
| 170 | +[[package]] | ||
| 171 | +name = "proc-macro2" | ||
| 172 | +version = "1.0.107" | ||
| 173 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 174 | +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" | ||
| 175 | +dependencies = [ | ||
| 176 | + "unicode-ident", | ||
| 177 | +] | ||
| 178 | + | ||
| 179 | +[[package]] | ||
| 180 | +name = "quote" | ||
| 181 | +version = "1.0.47" | ||
| 182 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 183 | +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" | ||
| 184 | +dependencies = [ | ||
| 185 | + "proc-macro2", | ||
| 186 | +] | ||
| 187 | + | ||
| 188 | +[[package]] | ||
| 189 | +name = "regex" | ||
| 190 | +version = "1.13.1" | ||
| 191 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 192 | +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" | ||
| 193 | +dependencies = [ | ||
| 194 | + "aho-corasick", | ||
| 195 | + "memchr", | ||
| 196 | + "regex-automata", | ||
| 197 | + "regex-syntax", | ||
| 198 | +] | ||
| 199 | + | ||
| 200 | +[[package]] | ||
| 201 | +name = "regex-automata" | ||
| 202 | +version = "0.4.18" | ||
| 203 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 204 | +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" | ||
| 205 | +dependencies = [ | ||
| 206 | + "aho-corasick", | ||
| 207 | + "memchr", | ||
| 208 | + "regex-syntax", | ||
| 209 | +] | ||
| 210 | + | ||
| 211 | +[[package]] | ||
| 212 | +name = "regex-syntax" | ||
| 213 | +version = "0.8.11" | ||
| 214 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 215 | +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" | ||
| 216 | + | ||
| 217 | +[[package]] | ||
| 218 | +name = "serde" | ||
| 219 | +version = "1.0.228" | ||
| 220 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 221 | +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" | ||
| 222 | +dependencies = [ | ||
| 223 | + "serde_core", | ||
| 224 | + "serde_derive", | ||
| 225 | +] | ||
| 226 | + | ||
| 227 | +[[package]] | ||
| 228 | +name = "serde_core" | ||
| 229 | +version = "1.0.228" | ||
| 230 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 231 | +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" | ||
| 232 | +dependencies = [ | ||
| 233 | + "serde_derive", | ||
| 234 | +] | ||
| 235 | + | ||
| 236 | +[[package]] | ||
| 237 | +name = "serde_derive" | ||
| 238 | +version = "1.0.228" | ||
| 239 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 240 | +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" | ||
| 241 | +dependencies = [ | ||
| 242 | + "proc-macro2", | ||
| 243 | + "quote", | ||
| 244 | + "syn 2.0.119", | ||
| 245 | +] | ||
| 246 | + | ||
| 247 | +[[package]] | ||
| 248 | +name = "serde_json" | ||
| 249 | +version = "1.0.150" | ||
| 250 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 251 | +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" | ||
| 252 | +dependencies = [ | ||
| 253 | + "itoa", | ||
| 254 | + "memchr", | ||
| 255 | + "serde", | ||
| 256 | + "serde_core", | ||
| 257 | + "zmij", | ||
| 258 | +] | ||
| 259 | + | ||
| 260 | +[[package]] | ||
| 261 | +name = "strsim" | ||
| 262 | +version = "0.11.1" | ||
| 263 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 264 | +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" | ||
| 265 | + | ||
| 266 | +[[package]] | ||
| 267 | +name = "syn" | ||
| 268 | +version = "2.0.119" | ||
| 269 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 270 | +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" | ||
| 271 | +dependencies = [ | ||
| 272 | + "proc-macro2", | ||
| 273 | + "quote", | ||
| 274 | + "unicode-ident", | ||
| 275 | +] | ||
| 276 | + | ||
| 277 | +[[package]] | ||
| 278 | +name = "syn" | ||
| 279 | +version = "3.0.4" | ||
| 280 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 281 | +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" | ||
| 282 | +dependencies = [ | ||
| 283 | + "proc-macro2", | ||
| 284 | + "quote", | ||
| 285 | + "unicode-ident", | ||
| 286 | +] | ||
| 287 | + | ||
| 288 | +[[package]] | ||
| 289 | +name = "tokio" | ||
| 290 | +version = "1.53.0" | ||
| 291 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 292 | +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" | ||
| 293 | +dependencies = [ | ||
| 294 | + "bytes", | ||
| 295 | + "pin-project-lite", | ||
| 296 | + "tokio-macros", | ||
| 297 | +] | ||
| 298 | + | ||
| 299 | +[[package]] | ||
| 300 | +name = "tokio-macros" | ||
| 301 | +version = "2.7.2" | ||
| 302 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 303 | +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" | ||
| 304 | +dependencies = [ | ||
| 305 | + "proc-macro2", | ||
| 306 | + "quote", | ||
| 307 | + "syn 3.0.4", | ||
| 308 | +] | ||
| 309 | + | ||
| 310 | +[[package]] | ||
| 311 | +name = "unicode-ident" | ||
| 312 | +version = "1.0.24" | ||
| 313 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 314 | +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | ||
| 315 | + | ||
| 316 | +[[package]] | ||
| 317 | +name = "utf8parse" | ||
| 318 | +version = "0.2.2" | ||
| 319 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 320 | +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" | ||
| 321 | + | ||
| 322 | +[[package]] | ||
| 323 | +name = "windows-link" | ||
| 324 | +version = "0.2.1" | ||
| 325 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 326 | +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | ||
| 327 | + | ||
| 328 | +[[package]] | ||
| 329 | +name = "windows-sys" | ||
| 330 | +version = "0.61.2" | ||
| 331 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 332 | +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" | ||
| 333 | +dependencies = [ | ||
| 334 | + "windows-link", | ||
| 335 | +] | ||
| 336 | + | ||
| 337 | +[[package]] | ||
| 338 | +name = "zmij" | ||
| 339 | +version = "1.0.23" | ||
| 340 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 341 | +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | ||
added
workloads/rust-build/Cargo.toml +18 -0 | new file mode 100644 | ||
| @@ -0,0 +1,18 @@ | ||
| 1 | +[package] | |
| 2 | +name = "bench-rust-build" | |
| 3 | +version = "0.1.0" | |
| 4 | +edition = "2021" | |
| 5 | +description = "Cold/warm build + test workload for the ci-bench CI race benchmark." | |
| 6 | + | |
| 7 | +# Versions are EXACT-pinned (=) so both platforms resolve the identical set; the | |
| 8 | +# generated Cargo.lock is committed alongside and is the real pin. | |
| 9 | +[dependencies] | |
| 10 | +anyhow = "=1.0.103" | |
| 11 | +clap = { version = "=4.6.1", features = ["derive"] } | |
| 12 | +regex = "=1.13.1" | |
| 13 | +serde = { version = "=1.0.228", features = ["derive"] } | |
| 14 | +serde_json = "=1.0.150" | |
| 15 | +tokio = { version = "=1.53.0", features = ["rt-multi-thread", "macros", "time", "sync", "io-util"] } | |
| 16 | + | |
| 17 | +[profile.release] | |
| 18 | +debug = 1 | |
| new file mode 100644 | |||
| @@ -0,0 +1,18 @@ | |||
| 1 | +[package] | ||
| 2 | +name = "bench-rust-build" | ||
| 3 | +version = "0.1.0" | ||
| 4 | +edition = "2021" | ||
| 5 | +description = "Cold/warm build + test workload for the ci-bench CI race benchmark." | ||
| 6 | + | ||
| 7 | +# Versions are EXACT-pinned (=) so both platforms resolve the identical set; the | ||
| 8 | +# generated Cargo.lock is committed alongside and is the real pin. | ||
| 9 | +[dependencies] | ||
| 10 | +anyhow = "=1.0.103" | ||
| 11 | +clap = { version = "=4.6.1", features = ["derive"] } | ||
| 12 | +regex = "=1.13.1" | ||
| 13 | +serde = { version = "=1.0.228", features = ["derive"] } | ||
| 14 | +serde_json = "=1.0.150" | ||
| 15 | +tokio = { version = "=1.53.0", features = ["rt-multi-thread", "macros", "time", "sync", "io-util"] } | ||
| 16 | + | ||
| 17 | +[profile.release] | ||
| 18 | +debug = 1 | ||
added
workloads/rust-build/rust-toolchain.toml +5 -0 | new file mode 100644 | ||
| @@ -0,0 +1,5 @@ | ||
| 1 | +# Pin the toolchain: identical rustc on every platform is part of the pinned | |
| 2 | +# workload. 1.85.0 is the minimum for clap_builder 4.6.x (edition 2024). | |
| 3 | +[toolchain] | |
| 4 | +channel = "1.85.0" | |
| 5 | +profile = "minimal" | |
| new file mode 100644 | |||
| @@ -0,0 +1,5 @@ | |||
| 1 | +# Pin the toolchain: identical rustc on every platform is part of the pinned | ||
| 2 | +# workload. 1.85.0 is the minimum for clap_builder 4.6.x (edition 2024). | ||
| 3 | +[toolchain] | ||
| 4 | +channel = "1.85.0" | ||
| 5 | +profile = "minimal" | ||
added
workloads/rust-build/src/lib.rs +95 -0 | new file mode 100644 | ||
| @@ -0,0 +1,95 @@ | ||
| 1 | +//! bench-rust-build: the workload behind the ci-bench "rust build" steps. | |
| 2 | +//! | |
| 3 | +//! It deliberately touches every pinned dependency (serde + serde_json, tokio, | |
| 4 | +//! clap, regex, anyhow) so a cold `cargo build` actually compiles the full | |
| 5 | +//! transitive tree, and `cargo test` does real work on top of the warm build. | |
| 6 | + | |
| 7 | +use anyhow::Result; | |
| 8 | +use clap::Parser; | |
| 9 | +use serde::{Deserialize, Serialize}; | |
| 10 | + | |
| 11 | +/// Tunable workload parameters (parsing exercises clap's derive machinery). | |
| 12 | +#[derive(Debug, Clone, Serialize, Deserialize, Parser)] | |
| 13 | +#[command(name = "bench-rust-build", about = "ci-bench synthetic workload")] | |
| 14 | +pub struct Config { | |
| 15 | + /// Number of synthetic work items per round. | |
| 16 | + #[arg(long, default_value_t = 5000)] | |
| 17 | + pub items: u32, | |
| 18 | + | |
| 19 | + /// Rounds to run. | |
| 20 | + #[arg(long, default_value_t = 4)] | |
| 21 | + pub rounds: u32, | |
| 22 | +} | |
| 23 | + | |
| 24 | +impl Default for Config { | |
| 25 | + fn default() -> Self { | |
| 26 | + Self::parse_from(["bench-rust-build"]) | |
| 27 | + } | |
| 28 | +} | |
| 29 | + | |
| 30 | +/// A synthetic work item; JSON round-tripping exercises serde on a non-trivial | |
| 31 | +/// type (string + vec + float + u64). | |
| 32 | +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | |
| 33 | +pub struct Item { | |
| 34 | + pub id: u64, | |
| 35 | + pub label: String, | |
| 36 | + pub tags: Vec<String>, | |
| 37 | + pub score: f64, | |
| 38 | +} | |
| 39 | + | |
| 40 | +/// The label pattern that marks an item "interesting" (exercises regex). | |
| 41 | +pub const LABEL_PATTERN: &str = r"^(cold|warm)-(cache|boot)-[0-9]{4}$"; | |
| 42 | + | |
| 43 | +/// Matches labels against [`LABEL_PATTERN`]. | |
| 44 | +pub fn interesting(label: &str) -> bool { | |
| 45 | + // Compiled per call on purpose: the regex crate's compile path is part of | |
| 46 | + // the exercised cost, and the workload is small enough that it stays cheap. | |
| 47 | + let re = regex::Regex::new(LABEL_PATTERN).expect("static pattern compiles"); | |
| 48 | + re.is_match(label) | |
| 49 | +} | |
| 50 | + | |
| 51 | +/// Builds one synthetic item. | |
| 52 | +pub fn make_item(round: u32, i: u32) -> Item { | |
| 53 | + let kind = if i % 2 == 0 { "cold-cache" } else { "warm-boot" }; | |
| 54 | + Item { | |
| 55 | + id: (u64::from(round) << 32) | u64::from(i), | |
| 56 | + label: format!("{kind}-{:04}", i % 10_000), | |
| 57 | + tags: vec!["bench".to_string(), "ci-race".to_string()], | |
| 58 | + score: f64::from(i) * 0.5, | |
| 59 | + } | |
| 60 | +} | |
| 61 | + | |
| 62 | +/// One round of synthetic work: build items, JSON round-trip each, select on a | |
| 63 | +/// regex. Returns the number of selected items. | |
| 64 | +pub fn run_round(cfg: &Config, round: u32) -> Result<usize> { | |
| 65 | + let mut selected = 0usize; | |
| 66 | + for i in 0..cfg.items { | |
| 67 | + let item = make_item(round, i); | |
| 68 | + let json = serde_json::to_string(&item)?; | |
| 69 | + let back: Item = serde_json::from_str(&json)?; | |
| 70 | + debug_assert_eq!(back, item); | |
| 71 | + if interesting(&back.label) { | |
| 72 | + selected += 1; | |
| 73 | + } | |
| 74 | + } | |
| 75 | + Ok(selected) | |
| 76 | +} | |
| 77 | + | |
| 78 | +/// Tokio entry point: rounds on a multi-threaded runtime, briefly yielding | |
| 79 | +/// between rounds so the scheduler paths are exercised too. | |
| 80 | +pub async fn drive(cfg: Config) -> Result<usize> { | |
| 81 | + let mut total = 0usize; | |
| 82 | + for r in 0..cfg.rounds { | |
| 83 | + total += run_round(&cfg, r)?; | |
| 84 | + tokio::time::sleep(std::time::Duration::from_millis(1)).await; | |
| 85 | + } | |
| 86 | + Ok(total) | |
| 87 | +} | |
| 88 | + | |
| 89 | +/// Synchronous entry point for the binary. | |
| 90 | +pub fn run(cfg: Config) -> Result<usize> { | |
| 91 | + let rt = tokio::runtime::Builder::new_multi_thread() | |
| 92 | + .worker_threads(2) | |
| 93 | + .build()?; | |
| 94 | + rt.block_on(drive(cfg)) | |
| 95 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,95 @@ | |||
| 1 | +//! bench-rust-build: the workload behind the ci-bench "rust build" steps. | ||
| 2 | +//! | ||
| 3 | +//! It deliberately touches every pinned dependency (serde + serde_json, tokio, | ||
| 4 | +//! clap, regex, anyhow) so a cold `cargo build` actually compiles the full | ||
| 5 | +//! transitive tree, and `cargo test` does real work on top of the warm build. | ||
| 6 | + | ||
| 7 | +use anyhow::Result; | ||
| 8 | +use clap::Parser; | ||
| 9 | +use serde::{Deserialize, Serialize}; | ||
| 10 | + | ||
| 11 | +/// Tunable workload parameters (parsing exercises clap's derive machinery). | ||
| 12 | +#[derive(Debug, Clone, Serialize, Deserialize, Parser)] | ||
| 13 | +#[command(name = "bench-rust-build", about = "ci-bench synthetic workload")] | ||
| 14 | +pub struct Config { | ||
| 15 | + /// Number of synthetic work items per round. | ||
| 16 | + #[arg(long, default_value_t = 5000)] | ||
| 17 | + pub items: u32, | ||
| 18 | + | ||
| 19 | + /// Rounds to run. | ||
| 20 | + #[arg(long, default_value_t = 4)] | ||
| 21 | + pub rounds: u32, | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | +impl Default for Config { | ||
| 25 | + fn default() -> Self { | ||
| 26 | + Self::parse_from(["bench-rust-build"]) | ||
| 27 | + } | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +/// A synthetic work item; JSON round-tripping exercises serde on a non-trivial | ||
| 31 | +/// type (string + vec + float + u64). | ||
| 32 | +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 33 | +pub struct Item { | ||
| 34 | + pub id: u64, | ||
| 35 | + pub label: String, | ||
| 36 | + pub tags: Vec<String>, | ||
| 37 | + pub score: f64, | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +/// The label pattern that marks an item "interesting" (exercises regex). | ||
| 41 | +pub const LABEL_PATTERN: &str = r"^(cold|warm)-(cache|boot)-[0-9]{4}$"; | ||
| 42 | + | ||
| 43 | +/// Matches labels against [`LABEL_PATTERN`]. | ||
| 44 | +pub fn interesting(label: &str) -> bool { | ||
| 45 | + // Compiled per call on purpose: the regex crate's compile path is part of | ||
| 46 | + // the exercised cost, and the workload is small enough that it stays cheap. | ||
| 47 | + let re = regex::Regex::new(LABEL_PATTERN).expect("static pattern compiles"); | ||
| 48 | + re.is_match(label) | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +/// Builds one synthetic item. | ||
| 52 | +pub fn make_item(round: u32, i: u32) -> Item { | ||
| 53 | + let kind = if i % 2 == 0 { "cold-cache" } else { "warm-boot" }; | ||
| 54 | + Item { | ||
| 55 | + id: (u64::from(round) << 32) | u64::from(i), | ||
| 56 | + label: format!("{kind}-{:04}", i % 10_000), | ||
| 57 | + tags: vec!["bench".to_string(), "ci-race".to_string()], | ||
| 58 | + score: f64::from(i) * 0.5, | ||
| 59 | + } | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +/// One round of synthetic work: build items, JSON round-trip each, select on a | ||
| 63 | +/// regex. Returns the number of selected items. | ||
| 64 | +pub fn run_round(cfg: &Config, round: u32) -> Result<usize> { | ||
| 65 | + let mut selected = 0usize; | ||
| 66 | + for i in 0..cfg.items { | ||
| 67 | + let item = make_item(round, i); | ||
| 68 | + let json = serde_json::to_string(&item)?; | ||
| 69 | + let back: Item = serde_json::from_str(&json)?; | ||
| 70 | + debug_assert_eq!(back, item); | ||
| 71 | + if interesting(&back.label) { | ||
| 72 | + selected += 1; | ||
| 73 | + } | ||
| 74 | + } | ||
| 75 | + Ok(selected) | ||
| 76 | +} | ||
| 77 | + | ||
| 78 | +/// Tokio entry point: rounds on a multi-threaded runtime, briefly yielding | ||
| 79 | +/// between rounds so the scheduler paths are exercised too. | ||
| 80 | +pub async fn drive(cfg: Config) -> Result<usize> { | ||
| 81 | + let mut total = 0usize; | ||
| 82 | + for r in 0..cfg.rounds { | ||
| 83 | + total += run_round(&cfg, r)?; | ||
| 84 | + tokio::time::sleep(std::time::Duration::from_millis(1)).await; | ||
| 85 | + } | ||
| 86 | + Ok(total) | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +/// Synchronous entry point for the binary. | ||
| 90 | +pub fn run(cfg: Config) -> Result<usize> { | ||
| 91 | + let rt = tokio::runtime::Builder::new_multi_thread() | ||
| 92 | + .worker_threads(2) | ||
| 93 | + .build()?; | ||
| 94 | + rt.block_on(drive(cfg)) | ||
| 95 | +} | ||
added
workloads/rust-build/src/main.rs +15 -0 | new file mode 100644 | ||
| @@ -0,0 +1,15 @@ | ||
| 1 | +//! Thin CLI wrapper so the workload is runnable as a binary, not just a lib. | |
| 2 | + | |
| 3 | +use clap::Parser; | |
| 4 | + | |
| 5 | +use bench_rust_build::{run, Config}; | |
| 6 | + | |
| 7 | +fn main() -> anyhow::Result<()> { | |
| 8 | + let cfg = Config::parse(); | |
| 9 | + let selected = run(cfg.clone())?; | |
| 10 | + println!( | |
| 11 | + "items/round={} rounds={} selected={}", | |
| 12 | + cfg.items, cfg.rounds, selected | |
| 13 | + ); | |
| 14 | + Ok(()) | |
| 15 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | +//! Thin CLI wrapper so the workload is runnable as a binary, not just a lib. | ||
| 2 | + | ||
| 3 | +use clap::Parser; | ||
| 4 | + | ||
| 5 | +use bench_rust_build::{run, Config}; | ||
| 6 | + | ||
| 7 | +fn main() -> anyhow::Result<()> { | ||
| 8 | + let cfg = Config::parse(); | ||
| 9 | + let selected = run(cfg.clone())?; | ||
| 10 | + println!( | ||
| 11 | + "items/round={} rounds={} selected={}", | ||
| 12 | + cfg.items, cfg.rounds, selected | ||
| 13 | + ); | ||
| 14 | + Ok(()) | ||
| 15 | +} | ||
added
workloads/rust-build/tests/workload.rs +51 -0 | new file mode 100644 | ||
| @@ -0,0 +1,51 @@ | ||
| 1 | +//! Integration tests: real work over the warm build, so `cargo test` is a | |
| 2 | +//! meaningful, separately-timed step (not a no-op). | |
| 3 | + | |
| 4 | +use bench_rust_build::{drive, interesting, make_item, run_round, Config}; | |
| 5 | + | |
| 6 | +fn small() -> Config { | |
| 7 | + Config { | |
| 8 | + items: 200, | |
| 9 | + rounds: 2, | |
| 10 | + } | |
| 11 | +} | |
| 12 | + | |
| 13 | +#[test] | |
| 14 | +fn labels_match_the_documented_pattern() { | |
| 15 | + assert!(interesting("cold-cache-0001")); | |
| 16 | + assert!(interesting("warm-boot-9999")); | |
| 17 | + assert!(!interesting("hot-cache-0001")); | |
| 18 | + assert!(!interesting("cold-cache-1")); | |
| 19 | + assert!(!interesting("")); | |
| 20 | +} | |
| 21 | + | |
| 22 | +#[test] | |
| 23 | +fn items_round_trip_through_json() { | |
| 24 | + let item = make_item(3, 42); | |
| 25 | + let json = serde_json::to_string(&item).unwrap(); | |
| 26 | + let back: bench_rust_build::Item = serde_json::from_str(&json).unwrap(); | |
| 27 | + assert_eq!(item, back); | |
| 28 | +} | |
| 29 | + | |
| 30 | +#[test] | |
| 31 | +fn a_round_selects_exactly_the_cold_items() { | |
| 32 | + // items are alternating cold-cache/warm-boot, all matching the pattern; | |
| 33 | + // "selected" counts matches, so every item is selected. | |
| 34 | + let cfg = small(); | |
| 35 | + let selected = run_round(&cfg, 0).unwrap(); | |
| 36 | + assert_eq!(selected, cfg.items as usize); | |
| 37 | +} | |
| 38 | + | |
| 39 | +#[tokio::test] | |
| 40 | +async fn the_tokio_runtime_drives_all_rounds() { | |
| 41 | + let cfg = small(); | |
| 42 | + let selected = drive(cfg.clone()).await.unwrap(); | |
| 43 | + assert_eq!(selected, cfg.items as usize * cfg.rounds as usize); | |
| 44 | +} | |
| 45 | + | |
| 46 | +#[test] | |
| 47 | +fn config_defaults_are_sane() { | |
| 48 | + let cfg = Config::default(); | |
| 49 | + assert!(cfg.items > 0); | |
| 50 | + assert!(cfg.rounds > 0); | |
| 51 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,51 @@ | |||
| 1 | +//! Integration tests: real work over the warm build, so `cargo test` is a | ||
| 2 | +//! meaningful, separately-timed step (not a no-op). | ||
| 3 | + | ||
| 4 | +use bench_rust_build::{drive, interesting, make_item, run_round, Config}; | ||
| 5 | + | ||
| 6 | +fn small() -> Config { | ||
| 7 | + Config { | ||
| 8 | + items: 200, | ||
| 9 | + rounds: 2, | ||
| 10 | + } | ||
| 11 | +} | ||
| 12 | + | ||
| 13 | +#[test] | ||
| 14 | +fn labels_match_the_documented_pattern() { | ||
| 15 | + assert!(interesting("cold-cache-0001")); | ||
| 16 | + assert!(interesting("warm-boot-9999")); | ||
| 17 | + assert!(!interesting("hot-cache-0001")); | ||
| 18 | + assert!(!interesting("cold-cache-1")); | ||
| 19 | + assert!(!interesting("")); | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +#[test] | ||
| 23 | +fn items_round_trip_through_json() { | ||
| 24 | + let item = make_item(3, 42); | ||
| 25 | + let json = serde_json::to_string(&item).unwrap(); | ||
| 26 | + let back: bench_rust_build::Item = serde_json::from_str(&json).unwrap(); | ||
| 27 | + assert_eq!(item, back); | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +#[test] | ||
| 31 | +fn a_round_selects_exactly_the_cold_items() { | ||
| 32 | + // items are alternating cold-cache/warm-boot, all matching the pattern; | ||
| 33 | + // "selected" counts matches, so every item is selected. | ||
| 34 | + let cfg = small(); | ||
| 35 | + let selected = run_round(&cfg, 0).unwrap(); | ||
| 36 | + assert_eq!(selected, cfg.items as usize); | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +#[tokio::test] | ||
| 40 | +async fn the_tokio_runtime_drives_all_rounds() { | ||
| 41 | + let cfg = small(); | ||
| 42 | + let selected = drive(cfg.clone()).await.unwrap(); | ||
| 43 | + assert_eq!(selected, cfg.items as usize * cfg.rounds as usize); | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +#[test] | ||
| 47 | +fn config_defaults_are_sane() { | ||
| 48 | + let cfg = Config::default(); | ||
| 49 | + assert!(cfg.items > 0); | ||
| 50 | + assert!(cfg.rounds > 0); | ||
| 51 | +} | ||