rickub/ci-benchpublic Fork 0
28dfdd85f941bb437ac07ec08870550bde4a53a3
Commits
Clone
git clone https://git.rickub.com/rickub/ci-bench.git
git clone ssh://git@rickub.com/rickub/ci-bench.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

README.md · 235 lines · 12.1 KBmarkdown Blame HistoryRaw
ci-bench: cross-platform CI race scaffold (rickub CI vs GitHub Actions) 0baf736 Olivier Girardot yesterday1# ci-bench — the CI race
2
3An identical, pinned workload pushed to **GitHub Actions** and **rickub CI**
4(Firecracker microVM fleet), raced to answer: who starts jobs faster, whose
5steps run faster, and what the runner hardware itself is worth?
6
7Everything here is bash + python3 stdlib. No package installs, no external
8services 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
22Why two workflow files and not one: rickub reads `.rickub/workflows/` as its
23SOLE workflow source when that directory exists and ignores
24`.github/workflows/` entirely, while GitHub only ever reads `.github/`. One
25repo therefore carries one file per platform and neither platform sees the
26other's copy. The two files run the same steps in the same order; the only
27differences are `BENCH_PLATFORM`, `runs-on:` (see *Known asymmetries*), and
28comments.
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
41Pins: Rust toolchain 1.85.0 (`workloads/rust-build/rust-toolchain.toml`), the
42full dependency tree (`workloads/rust-build/Cargo.lock`, generated offline
43against a real cargo registry cache, versions exact-pinned in `Cargo.toml`
44with `=`), Docker base `alpine:3.20@sha256:d9e853e87e...` (the digest rickub
45itself 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
50Both platforms give every job a **fresh machine** (GitHub hosted runners;
51rickub boots a new Firecracker microVM with a read-only rootfs and a fresh
52scratch 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
65All 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
74is visible in the timestamps themselves: `.000Z` means coarse).
75
761. **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.
892. **per-step duration** = `end − start` of the step's record, from the same
90 guest clock, so skew cancels. Millisecond precision.
913. **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).
954. **cold vs warm cache** = `rust-cold-build` vs `rust-warm-build` durations
96 as defined above.
975. **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
103Anything unavailable degrades to `"value": null` or a `skipped` record; the
104job 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
1111. 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).
1152. 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.
1183. 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 ```
1234. 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
129Run rounds **alternating platforms** — G, R, G, R, … — at least **10 rounds
130per platform**, serialized (never two benchmark runs in flight at once: on
131rickub your own queued job would inflate the other platform's queue metric).
132Cloud CI variance across hours and neighbours is well documented; a single
133run is noise. Alternation decorrelates the two platforms from time-of-day
134effects (fleet load, mirror warmth, co-tenants). Optionally discard round 1
135per platform as image/toolchain warmup for the *operators*, not the runners.
136
137### Reporting
138
139```
140python3 scripts/compare.py [--push-times push-times.json]
141```
142
143Median (p50) and p95 per step per platform, linear-interpolated percentiles
144(the numpy method) over `ok` records only; `skipped`/`fail` counts are shown
145next to each cell so a platform quietly skipping docker is visible. The
146table 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
151works: the emitter falls back to python3 for millisecond timestamps and the
152probe 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
1811. **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.
1842. **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.
1873. **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 |
bench: document the real-world tier d5ea3aa Olivier Girardot 8h ago200
201## Real-world tier (added round 12+)
202
203Alongside the synthetic micro-workloads, the bench builds four notorious OSS
204projects at pinned tags, cloned/downloaded inside the timed window:
205
206| step | project | pin | toolchain |
207|---|---|---|---|
208| `rust-ripgrep` | BurntSushi/ripgrep | 15.2.0 | cargo |
209| `cpp-sqlite` | SQLite amalgamation | 3.45.1 (sqlite.org 2024 tarball) | gcc/make |
210| `node-typescript` | microsoft/TypeScript | v5.9.3 (5.x line — 7.x is the Go rewrite) | node/npm |
211| `java-guava` | google/guava | v33.7.1 | maven |
212
213ClickHouse is deliberately absent: a full build needs dozens of cores and
214~100 GB of disk, which no shared-runner tier (ours or GitHub's) provides — a
215ClickHouse-scale tier needs a dedicated runner class, not a benchmark step.
216SQLite's amalgamation is the C/C++ stand-in: a real configure+make of one of
217the most deployed codebases on earth, reproducible from a pinned tarball.
bench: cache tier — actions/cache@v4.2.4 restore/save timed natively on both platforms, isolated CARGO_HOME warm arm 28dfdd8 Olivier Girardot 4h ago218
219## Cache tier (round 3+)
220
221A controlled warm-path arm using each platform's NATIVE cache service exactly
222as users consume it: `actions/cache@v4.2.4` restore/save split actions (the
223v4/node20 line — the action runs inside the guest; v6 requires node24 and
224would measure node absence on some runners, and v4 is the protocol generation
225rickub's cache service implements), identical key (`hashFiles` of the pinned
226Cargo.lock) and paths on both platforms.
227
228Design: the cached build uses an ISOLATED `CARGO_HOME` + target dir, so it
229measures the warm path from cache alone — earlier steps' registry downloads
230cannot leak into it. Metrics per run: `cache-restore-rust` (the cache
231SERVICE's speed, with a hit/miss boolean in the value field),
232`rust-cached-build` (warm build), `cache-save-rust` (upload). The cold arm is
233the same workload without cache, already measured every round. First run
234after a lockfile change is a miss and primes the cache; interleaved rounds
235then measure warm hits.