Skip to content

ci(bench): rank families per OS on one machine per OS - #59

Merged
themixednuts merged 12 commits into
mainfrom
ci/per-os-topology
Aug 10, 2026
Merged

ci(bench): rank families per OS on one machine per OS#59
themixednuts merged 12 commits into
mainfrom
ci/per-os-topology

Conversation

@themixednuts

Copy link
Copy Markdown
Owner

The leaderboard puts every family in one table, so a rank only means something if the rows came off one machine. They did not — eight families were measured on eight separate CI VMs, and postgres-all fixed that for three of them only. And the ranking itself ran 01..N across operating systems, so a Windows row could outrank a Linux row under a heading that reads as a library comparison.

CI

postgres-all is replaced by two publish-only jobs that run every family their OS can host, back to back on one VM under one cohort id:

  • linux-all — all eight families. PostgreSQL's service container is pinned with --cpuset-cpus and SpacetimeDB starts last under taskset, so load / client / database are three disjoint sets. Previously the database floated unpinned over the whole VM while the in-process engines were confined to the upper half — a silent handicap for every one of them in the same table.
  • desktop-all — the three in-process families on macOS and Windows.

Cross-OS folding stays impossible and is not attempted: GitHub runs service containers on Linux only, and libsql segfaults the benchmark on the other two, so macOS and Windows can only ever rank in-process engines.

These run alongside the per-family jobs, not instead of them. The per-family jobs lose their if: guards and keep producing the paced latency reading in parallel on every event; slug-suffix: cross keeps the two sets of artifacts and baseline caches apart.

Fitting the 360-minute ceiling

linux-all is a serial sequence and is the one job that can hit GitHub's hard cancellation. At 27 targets it estimated 356 minutes against a 350-minute timeout — it did not fit, and the way it would have announced that is a cancellation at minute 350 with most of the ranking missing.

The saturation ramp now ends at 512 VUs. On a two-core server set every target is already in queueing collapse there, so the 1024 step has never produced a peak — published peaks all sit at 16–64 concurrency, so no existing curve changes shape. That brings the estimate to 334 minutes.

plan now computes that estimate from the specs themselves and fails in five minutes if it exceeds the timeout, so adding a target lengthens the sequence loudly instead of silently.

Dashboard

Operating system becomes the ranking's outer scope, in the URL as ?os=, with pills above the database ones. Rank, bar scale, the "vs drizzle-rs" baseline, the verdict tiles, the harness strip and whether the set measured capacity at all are computed inside the scope. No rows are hidden — every platform is a pill away, and the pill says how many targets it holds.

osScopes carries the provenance a scope can honestly assert: more than one CPU model is proof its rows came off more than one machine and says so; one model is only consistent with a shared host and is worded that way. topology.cpu_pinning now reaches the row (and now includes the database's cpuset), so a Linux scope states its split and a macOS one states that nothing was pinned — Darwin exposes no usable CPU-affinity API — rather than leaving the absence to read as isolation.

Verification

  • cargo test -p bench-runner — 84 passed
  • dashboard bun run check — 0 errors; vitest — 70 passed (8 new, covering osScopes)
  • dashboard bun run build — clean
  • budget check exercised locally: passes at 2 trials (334/350), fails with the actionable error at 3 (474/350)

🤖 Generated with Claude Code

themixednuts and others added 8 commits August 9, 2026 03:51
Salvaged from an interrupted agent run. Adds a db-cpuset input to the
bench-runner composite so an out-of-process database (the PostgreSQL service
container, or SpacetimeDB under taskset) owns cores carved out of the SUT
half, making load / server / database three disjoint sets instead of letting
the database float unpinned while embedded engines stay confined. Hard-fails
when the literal disagrees with nproc. Documents that macOS has no usable
CPU-affinity API, so there is nothing to fall back to there.

Incomplete: runners.yml still has the old per-family job layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A publish run is about to measure each family twice: once in its existing
per-family job on the paced workload, and once in a new per-OS job on the
saturation ramp that produces the cross-family ranking. Both invocations
would otherwise write `bench-out/<family>-<platform>`, upload
`runner-<family>-<platform>`, and save the same baseline cache key, so the
second would clobber the first.

`slug-suffix` is empty by default, so the per-family jobs keep their current
artifact names and cache keys and branch-protection checks are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`postgres-all` collapsed the three PostgreSQL families onto one VM so their
leaderboard rows shared hardware, and skipped the per-family jobs to avoid
artifact collisions. That bought comparability inside PostgreSQL only: SQLite,
libSQL, Turso and SpacetimeDB still landed in the same table from four other
VMs, and a publish run lost the parallel per-family latency readings.

Replace it with `plan.outputs.cross_family`, which the per-OS `*-all` jobs in
the next commit key off. The per-family jobs lose their `if:` guards and now
always run, so every event keeps fast parallel feedback on the paced workload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sequence

The per-OS ranking job runs every family on one VM back to back, so it is the
one job here that can hit GitHub's hard 360-minute cancellation. At 27 linux
targets the sequence estimated 356 minutes against a 350-minute timeout — it
did not fit, and the way it would have announced that is a cancellation at
minute 350 with most of the ranking missing.

Drop the 1024-VU step. On a two-core server set every target is already in
queueing collapse at 512, so the step has never produced a peak: it breaches
the p99 SLO for anything that breached at 512, and its only remaining job is
ruling out `did_not_saturate`, which 512 does. Published peaks all sit at
16-64 concurrency, so no existing curve changes shape. That buys 25s per
trial per target and brings the estimate to 334 minutes.

`plan` now computes that estimate from the specs themselves and fails in five
minutes if it exceeds the timeout, so adding a target lengthens the sequence
loudly instead of silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-OS ranking job carves the SUT half into a client set and a database
set, applied by the caller via --cpuset-cpus on the PostgreSQL service or
taskset on the SpacetimeDB daemon. The runner cannot pin a process it did not
spawn, so it never saw that third set and the manifest described a three-way
split as a two-way one — which is exactly the disclosure the dashboard reads
to say whether a ranking's families were isolated from each other.

BENCH_CPUSET_DB is read for the manifest only; nothing applies it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The leaderboard puts every family in one table, so a rank only means something
if the rows came off one machine. They did not: eight families were measured on
eight separate CI VMs, and `postgres-all` fixed that for three of them only.

Add `linux-all` (all eight families) and `desktop-all` (the in-process engines
on macOS and Windows). Each runs its families back to back on one VM under one
cohort id, so every row in an OS ranking shares hardware. They run on publish
events only, alongside — not instead of — the per-family jobs, which keep
producing the paced latency reading in parallel on every event.

Cross-OS folding stays impossible and is not attempted: service containers are
Linux-only on GitHub-hosted runners, and libsql segfaults the benchmark on the
other two, so macOS and Windows can only ever rank in-process engines. That is
why the ranking is scoped per OS rather than merged.

On linux the PostgreSQL service is pinned to the top core of the SUT half so
load / client / database are three disjoint sets; SpacetimeDB starts last under
taskset on the same core, by which point PostgreSQL is idle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ranking ran 01..N over every row in the set, and the set spans operating
systems: the SQLite family alone is measured on Linux, macOS and Windows. So a
Windows row could outrank a Linux row, under a heading that reads as a library
comparison, when what separated them was a kernel and a CI machine. The field
is not even the same shape across platforms — GitHub runs service containers on
Linux only, so PostgreSQL and SpacetimeDB can never appear on the other two.

Operating system becomes the ranking's outer scope, in the URL as `?os=`, with
pills above the database ones. Rank, bar scale, the "vs drizzle-rs" baseline,
the per-database verdict tiles, the harness strip and whether the set measured
capacity at all are now computed inside the scope, because each is a
within-scope claim. No rows are hidden: every platform is a pill away, and the
pill says how many targets it holds.

`osScopes` also carries the provenance the scope can honestly assert. More than
one CPU model in a scope is proof its rows came off more than one machine and
says so; one model is only consistent with a shared host and is worded that way.
`topology.cpu_pinning` reaches the row now, so a Linux scope states its cpuset
split and a macOS one states that nothing was pinned — Darwin exposes no usable
CPU-affinity API — rather than leaving the absence to be read as isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The audit table still listed `postgres-all` and claimed the per-family
PostgreSQL jobs were skipped on publish runs, both of which stopped being true
when the ranking moved to one job per OS. The CI topology section described
core isolation as a two-way split, which it no longer is on the families whose
engine is out of process.

Also notes BENCH_CPUSET_DB in the manifest schema's description of
`topology.cpu_pinning`, since the field's value now carries a third set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

themixednuts and others added 4 commits August 9, 2026 04:32
`cross_family` required `publish`, and `publish` is gated on `main`. That made
the two jobs no pull request can run also the two jobs that would run for the
first time only after merging — the topology would get debugged in production,
on a five-hour serial sequence.

A `workflow_dispatch` now enables them whether or not it publishes; R2 stays
gated on `publish` independently. Dispatching `benchmark_size=preview` from a
branch runs the whole sequence on the saturation-preview ramp at one trial.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first per-OS run recorded `load=0-1 server=2-2 db=3` on the out-of-process
families. Correct, and the parser takes either form, but this string is what
the dashboard prints as a run's isolation disclosure and `server=2-2` reads
like a typo rather than a deliberate single-core assignment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…onflict

The first per-OS run showed the copy was wrong. A cross-family linux scope has
two splits by design — the in-process engines take the whole system-under-test
half, the out-of-process ones hand a core to the database — and both halves own
the same cores, which is the property that makes the ranking comparable. The
scope detail reported that as "under more than one CPU-isolation setting, so no
single split describes them", which reads as a warning about a correct state.

It now lists what actually ran: "cores split load=0-1 server=2-3 and load=0-1
server=2 db=3 by engine". A scope mixing pinned and unpinned rows still stays
`mixedPinning` and names only the splits it has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`EntityFilter` is `Box<dyn Fn(..)>`, which is already `#[must_use]`, so the
attribute is a no-op that `clippy::double_must_use` rejects. The lint started
firing with the 2026-08-08 nightly, which turned Lint red on main and on every
open branch — main's last green run predates it.

The sibling `system_table_filter` returns `bool` and keeps its `#[must_use]`,
which is where the attribute actually does something.

Unrelated to this branch's subject; included because it blocks its CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@themixednuts
themixednuts merged commit e843906 into main Aug 10, 2026
36 checks passed
@themixednuts
themixednuts deleted the ci/per-os-topology branch August 10, 2026 07:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant