Benchmarks
What vci costs and what it saves, as measured. Every number says where and when it was taken, and links to the notes it comes from.
The chart on the overview is a model. This page has measurements only: production CI jobs of one large Rails suite, the plan step across fifteen releases, synthetic stores on a laptop, and a pilot against Turso's hosted service. Where two readings of the same thing disagree, both are here.
vex's Rails suite in production
ComposalAI/vex is a Rails application with 2,078 test files on 23 October 2026 (2,230 by the 25th), split over two CI jobs per pull request, with a seeded MySQL database and 16 test slots per job. Everything below is read from its GitHub Actions runs: the job logs, with vci's VCI_TIMING lines, and the jobs API for each step's start and end.
23 to 25 October: minutes per job
The first plain row is the median of 769 plain jobs in the week before vci took over, 13 to 19 October, as Measured on vex's Rails tests and CI is the bottleneck report it. The rows with vci are from the same pages, read from the logs of 23 to 25 October. A job where little changed was shorter than the plain job; a job where everything re-ran was longer, because each file runs in its own process so its inputs can be recorded, and about a thousand attestations are then signed, stored and pushed.
The plain figure did not hold up. Read again from the jobs API on 25 October (notes-rails-cost.md), the plain jobs of 16 and 17 October took a median of 272 s (application 261 s, services 284 s), and those of 18 October 304 and 318 s: 4.5 to 5.3 minutes, not 3.5. The 3.5 could not be reproduced. Since then the MySQL service image is pulled on every job, which takes container setup from 20 s to 48 s, plain or not.
25 October, vci 2.7.32: 143 jobs by runner kind
vex's runner pool hands out two kinds of host. The kind with 94,750 MiB of memory runs a test file's process in 4 to 5 s; the kind with 257,583 MiB takes 9 to 12 s, so every median here is per kind.
| Files this job ran | Fast runners: job, vci step | Slow runners: job, vci step |
|---|---|---|
| 60 or fewer | 437 s, 297 s (10 jobs) | 686 s, 348 s (10 jobs) |
| 61 to 900 (a shared file) | 472 s, 261 s (31 jobs) | 1,133 s, 686 s (9 jobs) |
| more than 900 (everything) | 682 s, 534 s (57 jobs) | 1,423 s, 999 s (25 jobs) |
Against the plain 272 to 318 s, every case was longer on that day. The files that still ran after a small change were mostly long persistent_agents tests (90 to 280 s each, run as shards), so even the "little" case spent 186 s in its Ruby batch. A full re-run's 534 s on a fast runner was, in medians: the plan 23 s, the Ruby batch 406 s, checking, signing and storing 13 s, and the push 49 s. 57 of the 143 pushes went through a git merge-tree that failed after 8 to 55 s and then git-meta's own merge of 10 to 67 s; 2.7.34 and 2.7.35 fixed that (below).
26 October: the same files, plain and with vci, on the same runner
A manual workflow ran the application group (1,072 files, 6,463 tests) once per variant per job, with no store, no push and no shards:
| Variant | Runner | Result |
|---|---|---|
plain bin/rails test, eager-loaded, 16 workers, as CI runs it | fast | Finished in 340 to 346 s (four jobs) |
vci run, 16 slots, as shipped | fast | 372 and 384 s (two jobs); a process 3.1 to 3.2 s on average |
| plain, lazily loaded, as vci loads | slow | 638 s |
vci run, 16 slots | slow | 674 and 696 s; a process 6.5 s on average |
On the same runner, vci's batch cost about what the plain run cost. The slowest file, unsharded, ran 284 to 319 s in one process and ended every vci batch, where the plain run spreads its tests over the workers. Loading the application before the slots fork made each process slower on the slow kind (7.9 s against 6.5 s); a compile cache and Process.warmup changed nothing measurable.
26 October: the new job, vci 2.7.35 and 2.7.36
At 00:30 UTC on 26 October vex moved its pull-request Rails jobs to file-backed SQLite, running 1,618 of the 2,232 files, on composal-x64-4x runners with 4 CPUs (vex #2128). At 05:46 UTC it moved them to composal-x64-8x, with 8 CPUs (#2139), the label of the Turso pilot below. So the three cases above cannot be measured again on the old job. These are the 32 pull-request jobs that finished between 04:46 and 17:45 UTC that day, with 4 or 8 slots. Most of those that finished before 06:00 ran on the 4-CPU label, and every later one on the 8-CPU label:
| vci | Files ran | Jobs | Job, median (range) | vci run step | Plan step | Publish step |
|---|---|---|---|---|---|---|
| 2.7.35 | 3 to 25 | 6 | 202 s (96 to 907) | 66 s | 18 s | 22 s |
| 2.7.35 | 276 | 1 | 1,742 s | 1,237 s | 44 s | 67 s |
| 2.7.35 | 816 | 1 | 786 s | 683 s | 19 s | 26 s |
| 2.7.36 | 5 to 24 | 8 | 598 s (451 to 644) | 182 s | 22 s | 24 s |
| 2.7.36 | 195 to 197 | 2 | 635 s (510 to 760) | 218 s | 22 s | 38 s |
| 2.7.36 | 808 to 832 | 14 | 742 s (334 to 1,173) | 406 s | 22 s | 27 s |
Three things outside vci dominate these medians. GitHub's cache restores took about 140 s each from about noon UTC, so the part of a job outside vci's steps was a median of 358 s on the 2.7.36 jobs against 72 s on the 2.7.35 ones. Two pull requests had 13 and 16 files failing for real, and each failing file runs again alone to tell a slot effect from a real failure. And 14 of the 24 jobs on 2.7.36 re-ran everything, because master had not yet been attested in that release's mode and the SQLite lane's attestations were new. For comparison, vex's own cold plain run of that lane took 12 min 28 s and 12 min 42 s per shard on 4 workers.
The push fix held: in those 32 jobs no git merge-tree failed, the seven jobs whose other job had pushed first merged in 0.5 to 2.4 s, and the publish step took a median of 22 to 27 s.
The plan step, release by release
The plan is what every job pays, whatever it runs. These are vex's Rails plans from 23 to 25 October 2026, 266 in all, read from the GitHub Actions logs.
vci plan on vex's Rails jobs.| vci | Plan, median | What changed |
|---|---|---|
| 2.7.18 (23 Oct) | 67 s (35 plans; p90 77 s) | the version measured first |
| 2.7.19 | 32 s pull requests, 21 s master | the store remembers what each blob names and which trees are complete |
| 2.7.22 to 2.7.26 (24 Oct, morning) | master 10.5 s (9 of 20 plans under 10 s; best 6.5 s) | Rails boots beside the fetch; candidates read in one batch; only the newest few read first; store warmed in the background |
| 2.7.27 to 2.7.32 (24 Oct evening on) | 15 s pull requests (29 plans; p90 27 s), 15 s master; 4.0 to 4.4 s on the quietest jobs | completeness walks only new objects; the metadata commit is one fast-import; the plan remembers which attestation passed per unit per tree; no second round after an inputs failure; the tip listing kept as one blob |
Of the 15 seconds, over the last twelve pull-request plans of that period: 7.4 s fetching and merging the attestations master pushed since the job's cache was saved, 2.1 s downloading the attestation files the verdicts need, 1.6 s re-hashing inputs, 1.1 s for a second look at units that did not pass, and 0.7 s reading candidates. The fetch is git-meta's structural cost; a shared store answers a plan in about a dozen requests instead (below).
Two later readings, from notes-rails-cost.md: on 25 October, 2.7.32's plans took a median of 25 s (p90 46 s), 11.7 s of it fetching and merging; on 26 October, on the new SQLite job (4 CPUs until 05:46 UTC, 8 after), the action's plan step took a median of 18 to 22 s, of which vci's own plan total was 4.6 to 4.8 s and the rest installing vci and warming the store.
Checking, signing and storing a run
After its tests, vci run checks each unit, signs its attestation and stores it. 2.8.0 signs in process (ed25519 and ECDSA key files, and through ssh-agent) instead of starting ssh-keygen per unit, and writes a project's units in one transaction instead of a commit per write. Measured on a synthetic module of 1,200 Go packages, on a 14-core Mac busy with other work, medians of three runs, on 25 October 2026 (2.8.0 release notes):
vci run's check, sign and store step, 1,200 units.The units are still checked one per CPU at a time: twice as many threads as cores grew the check's thread-seconds by half and did not shorten the step. On a heavily loaded machine, SQLite wrote a 1,200-unit run's 2,400 records in 4.8 s with a commit each, and in 0.9 s as one transaction.
The attestation stores side by side
Read these as relative numbers. They were measured for 2.8.0 on 24 and 25 October 2026 on a laptop, against a PostgreSQL server and a sqld 0.24.32 on the same machine, so no request crossed a network. During the first table other work kept the laptop at a load average of 35 to 315, and whole plans swung from 14 to 65 s in every store alike, mostly rehashing inputs: only the store's own timing lines compare.
The synthetic world: 1,200 Go packages, each attested at three commits, so 3,600 attestations of about 3 KB and 3,601 input manifests, written by three vci runs; macOS arm64, release build, two repetitions (STORE-BACKENDS.md, section 18, "Measured").
| Step | SQLite file (and libSQL's local file) | libSQL server, remote | libSQL, replica |
|---|---|---|---|
| plan, every unit skips: listings and chosen values | 0.01 s (1,200 listings, 2,400 values) | 0.42 to 0.57 s for 1,200 units and values in 4 requests (3.5 s at the worst load), then 1,200 more values in one request, 0.04 to 0.06 s | the sync, then as SQLite |
| plan, every unit runs (its second round) | 0.01 s | 0.15 to 0.38 s for 3,600 values in 2 requests (1.7 and 3.3 s at the worst load) | as SQLite |
| the replica's sync | everything (7,201 records): 0.6 to 9.3 s, median 3.4 s; nothing new: 0.01 to 0.04 s | ||
vci run, 1,200 units edited: the store's writes | 2,400 records in one transaction, 0.20 to 0.76 s | 1,200 manifests and 1,200 attestations in one request, 0.14 to 0.25 s | that request, then 2,400 records written to the file, 0.10 to 0.11 s |
vci run: check, sign and store | 0.47 to 1.80 s | 0.35 to 1.11 s into an empty namespace; 1.08 to 2.37 s into the filled one | 0.80 to 1.03 s |
PostgreSQL, on a synthetic world of 600 Go units attested at three commits (1,800 attestations of about 3,100 bytes, 1,801 manifests of about 490 bytes), one connection, debug build, measured with a byte-counting proxy and pg_stat_statements (STORE-BACKENDS.md, section 17):
| Plan | Client to server | Server to client | Statements |
|---|---|---|---|
| unchanged: all 600 skip | 510,857 B | 5,380,920 B (5.13 MiB, about 9.0 KB a unit) | 9 |
| every unit edited: all run | 741,399 B | 11,916,040 B (11.36 MiB, about 19.9 KB a unit) | 10 |
| reverted to the oldest: all skip in the second round | 897,879 B | 11,916,148 B | 12 |
These manifests are tiny. On a real repository input manifests, often megabytes, dominate the bytes; none of this was measured on vex.
Request and statement counts, from the stores' tests:
- PostgreSQL: a plan of 1,200 units in 14 statements (the test allows 15), and 0 more for 1,200 manifest reads from 8 threads; a 1,000-unit batch written in 5 statements,
BEGINandCOMMITincluded. - libSQL, release build against a local
sqld: 1,200 units of 18 KB written in 0.64 s and 8 requests, then listed and read on a new handle in 0.10 s and 4 requests; synced into a new replica in 0.41 s and read from it in 11 ms. A plan's status and a prefetch of 300 units take at most 8 requests. - SQLite: 1,200 units of 18 KB listed and read in about 8 ms, release build.
Turso's hosted service from a GitHub runner
On 27 October 2026 a pilot ran vci 2.8.0 against Turso's hosted service in aws-eu-west-1, about 25 ms of TCP round trip from vex's runners. It took three workflow runs (1, 2, 3), each on one composal-x64-8x runner, the label vex's Rails jobs use, with a fresh namespace per run. The project was vex-api's 2,291 Rails units. The git-meta side is vex's production path, from the 38 pull-request jobs of rails-tests.yml that ran between 00:12 and 01:50 UTC that day, mostly on vci 2.7.36.
| Checking, signing and storing about 810 Rails units | Seconds |
|---|---|
| Turso, 819 units into an empty namespace (three runs) | 21.6 to 30.4 |
| Turso, 819 units into a namespace holding 819 (two runs) | 29.5 and 30.1 |
| Turso, 819 units into a namespace holding 1,638, then 2,457 | 41.6, then 39.8 |
| git-meta's local store, in vex's jobs on the same host class (seven parts of 804 to 811 units) | 9.3 to 18, then vci push: 32 to 63, a median of 39 |
Each Turso run wrote its units in four requests of 3 to 12 s: three of 8 MiB of values or more, and a smaller last one.
Turso gains the most in the plan: no cache to restore, nothing to fetch, and only the attestations a plan checks cross the network. Without the cache, on the pilot's own runners, git-meta's fetch and plan took 86 to 94 s. Storing into Turso takes longer than into git-meta's local store, but nothing is pushed after it: on the pilot's host class git-meta's store and push together took 43 to 81 s a job, against Turso's 22 to 42 s. On vex's smaller hosts (94,750 MiB) the same parts took 19 to 29 s on git-meta (3.9 to 6.6 s to store, 15 to 22 s to push); Turso was not measured there. A replica gained nothing on a fresh runner: synced from nothing, its plans took 4.1 to 5.1 s, and its first sync reads the whole namespace, about a millisecond a record, where a remote plan reads only what it checks. It suits a machine that keeps its clone. The pilot wrote 5,733 attestations, seven runs of 819, with none refused.
A vex pull request (#2214) proposes moving its Rails CI to Turso. It is under review.
Three runs and a small namespace. The largest pilot namespace held 3,276 attestations. vex's git-meta holds about 24,000, and its plans check 3,700 to 26,000 candidates; a remote plan's cost grows with the values it reads, so Turso at that size is not measured. Turso's latency varied up to 3x: the same read of 1,638 values took 2.5 to 7.8 s. The git-meta figures are whole CI steps of other jobs, from the jobs API, and the Turso ones vci plan's wall time in the pilot. On the pilot's host class (257,583 MiB of memory) the git-meta median was 51.5 s.
Other measurements
- Measured on this repository: the demo projects, 21 units, with vci 1.5.0. A job with nothing changed costs about two seconds; attesting costs about 1.5 s per test file or package on top of its tests.
- Measured on vex-web: 253
node --testfiles, 17 s plain against 8 s with nothing changed, with vci 1.5.0.