Proving is the bottleneck.
Verifying is cheap.
So we distribute proving.
Atlas is a research pipeline that turns a single-threaded CPU bottleneck — Groth16 zero-knowledge proof generation for a private balance circuit — into a horizontally scalable system coordinated by Kafka. One worker generates a proof in 182 ms. Eight workers push throughput to 37.4 proofs/s with the scaling curve documented below.
The counter above is driven by the synthesised 8-worker throughput. In live mode it would tick at the rate of proofs landing on the proof-results Kafka topic.
What the circuit actually proves, without revealing anything else.
Atlas's circuit is a private balance proof. Alice proves she has at least the amount she claims to spend, against a previously-published Poseidon commitment to her balance — without revealing the balance or the salt that hides it. This section walks through what is private, what is public, and where proving time goes.
IDLE
// 432 non-linear constraints
template BalanceProof(N) {
// private
signal input balance;
signal input salt;
// public
signal input amount;
signal input commitment;
// (1) binding
Poseidon(2)
.inputs <== [balance, salt];
.out === commitment;
// (2) range-check
Num2Bits(N) balance, amount;
// (3) solvency
GreaterEqThan(N)
.in <== [balance, amount];
.out === 1;
}
- Poseidon(2)213
- Num2Bits × 2130
- GreaterEqThan(64)65
- Wiring / glue24
Why this looks like a streaming system, not an RPC service.
ZK proofs are CPU-bound and uneven. A request/response server stalls under bursts, and a thread-pool inside one process can't cross machines. Atlas treats every proof as a durable message on a partitioned queue, then lets a horizontally scalable pool of worker processes — each running snarkjs in its own worker_thread — drain the queue at the rate that hardware allows.
Trap: snarkjs.groth16.fullProve pegs a CPU core for hundreds of milliseconds. If it ran on the same thread as the kafkajs consumer, missed heartbeats would trigger a partition rebalance mid-proof, and the result would land on a partition no one is consuming.
Fix: the consumer thread receives a job, hands it to a long-lived worker_thread running the prover, and only commits the offset after the proof returns. Heartbeats keep flowing the whole time.
The observer reads the consumer-group's log-end − committed offset every 2 s. That lag, divided by the current drain rate, gives "seconds to clear" — the natural input to an HPA-style controller.
In this 2-day build the autoscaler is manual (docker compose up --scale worker=N). The signal is plumbed; the controller is the next phase.
- → partition key = low | high
- → heavy and light traffic land on disjoint partitions, no head-of-line blocking
- → consumer group proof-workers rebalances across replicas
- → partition key = jobId, preserving per-job ordering
- → gateway taps via its own consumer group (does not steal worker offsets)
- → retained 24h: enough for late /jobs/<id> polls and audit
Submit one proof. Watch the pipeline.
No gateway is reachable, so the demo draws timings from the measured baseline distribution. Honest about it — labelled SIMULATED throughout.
- →01 // SUBMITTED
- →02 // QUEUED
- →03 // PROVING
- 04 // VERIFIED
| TS | EVENT | DETAILS |
|---|---|---|
| — no events yet. Press ▶ RUN PROOF to start. | ||
The shape of the speedup curve is the whole pitch.
Three views of the same workload, swept across 1, 2, 4, and 8 workers: throughput (proofs/sec), end-to-end latency distribution (queue wait plus proving), and the speedup curve vs the ideal linear scaling. Where the speedup line falls below the diagonal is where coordination overhead becomes visible.
Dashed amber line — perfectly linear scaling. Doubling workers doubles throughput. This is the theoretical ceiling for an embarrassingly parallel workload.
Solid signal line — the actual / projected throughput ratio. The gap between solid and dashed is coordination overhead: kafkajs heartbeats, partition rebalances, the gateway's WS fanout.
At N = 8 the system runs at 86% efficiency — still well into the useful regime; the curve begins to flatten only as we approach the physical core count of the host machine.
T(N) = N · T(1) · η(N)
η(N) = 1 − α · (N − 1)
α = 0.02
When the cluster is up, run.js replaces this projection with measured numbers and the chart re-renders. See METHODOLOGY.md for the full derivation.
Producer fires the workload in a short burst; lag rises to ≈ 200 then drains at a slope proportional to N · T(1) · η(N). A real autoscaler would treat lag ÷ drain_rate as "seconds to clear" and pick a target N.
| N | p/s | p99 e2e | eff |
|---|---|---|---|
| 1 | 5.4 | 36210 ms | 100% |
| 2 | 10.7 | 18194 ms | 98% |
| 4 | 20.5 | 9102 ms | 94% |
| 8 | 37.4 | 4556 ms | 86% |
Click any data point in the charts for the exact value. Full per-job records live at pipeline/benchmark/runs/sweep.jsonl when a real cluster sweep has been run.
Where this fits in the actual on-chain stack.
The circuit is a real Groth16 verifier candidate. snarkjs exports a Solidity verifier for it; we don't deploy it, but the file lives in the repo and the gas envelope is small enough to be practical. This section frames what the demo proves, what it doesn't, and how the same pattern shows up at industrial scale in zk-rollups.
∃ (balance, salt).
Poseidon(balance, salt) = C ∧
balance ≥ amount- amount — the floor the prover claims
- commitment — published once, never re-derivable
- That a previous spend did not already exhaust the balance — there is no nullifier in this circuit. Adding one is the difference between a solvency proof and a real payment system.
- That the commitment corresponds to anything on a particular blockchain. That binding is the job of a separate Merkle-membership proof against a state root.
- That the prover and the commitment owner are the same entity. A signature over the proof's public inputs would close that gap.
snarkjs zkey export solidityverifier emits a Solidity contract that checks any proof for this circuit. The proof itself is 256 bytes; verification is a fixed sequence of BN254 pairings.
- PROOF SIZE 256 bytes
- VERIFY GAS ≈ 250,000 (Groth16 pairing precompile)
- PUBLIC INPUTS 2 × 32 bytes on calldata
- SCHEME Groth16 on BN254 (matches Ethereum precompiles)
Numbers are typical for a Groth16 verifier of this shape; exact gas depends on the EVM implementation and any preliminary calldata decoding. Atlas does not deploy this contract — it ships in the repo for reproducibility.
pragma solidity ^0.8.x;
contract Verifier {
function verifyProof(
uint[2] _pA,
uint[2][2] _pB,
uint[2] _pC,
uint[2] _pubSignals
) public view returns (bool);
}Atlas is intentionally the smallest interesting version of what a zk-rollup prover does: take a stream of arbitrary user state transitions, produce a single proof per batch, and let the L1 chain verify cheaply.
- Proving is the cost center. A real rollup prover is a fleet of beefy machines doing exactly this work, often with proof aggregation on top.
- Verifying is constant-time. The L1 doesn't care how many user transactions a proof represents — it pays the same fixed gas to accept it.
- Coordination is the systems problem. Which prover gets which batch, how to handle a slow prover, how to autoscale on lag — those are the same questions Atlas asks in miniature.
This is a building block, not a payment system. It demonstrates a deployable Groth16 verifier, a measurable proving bottleneck, and a horizontally scalable proving pool. Turning it into something real means:
- · nullifiers for spend semantics
- · Merkle membership against an on-chain state root
- · a fee market that prices proving time
- · a public Powers of Tau ceremony in place of the dev-only one
- · an autoscaler controller acting on the lag signal we expose
These are not papered over — they are the explicit future work in the methodology doc.
The credibility layer.
Every absolute number on this page is reproducible. The committed methodology document defines the workload, the hardware, the model used for projections (when the cluster isn't live), and the threats to validity. You can run the same sweep locally and check our numbers.
Bigger machines move every absolute number proportionally. The shape of the speedup curve is what matters — it is the property of the system, not of the silicon underneath it.
- · per-proof latency distribution
- · single-worker throughput
- · circuit constraint count
- · proving + self-verify wall time
- · T(N) for N > 1 from the η(N) formula
- · queue-wait distribution from FIFO assumption
- · observer time series as fill/drain sawtooth
When run.js is executed against a live cluster, every "modelled" item above gets replaced with real measurements and the chart re-renders.
cd pipeline/circuits npm install npm run compile:balance npm run setup:balance cd ../benchmark node baseline.js --count 50 node synthesize.js
cd pipeline docker compose up -d kafka topics-init cd benchmark node run.js --workers 1,2,4,8 --jobs 200 node aggregate.js \ --sweep runs/sweep.jsonl \ --out ../../web/public/data/benchmarks.json
- Thermal throttling on laptop CPUs is real over long sweeps; per-proof time creeps up.
- The first proof in every worker process is slower (wasm + zkey load) — baseline.js discards it; run.js does not, so live runs include that cost in the long tail.
- Worker count is set manually here; partition rebalances on scale-up cost a one-time settle delay.
- The Powers of Tau is a local dev ceremony. Real deployments would use a public, audited one — same numbers, different trust assumptions.
- Real autoscaler controller consuming the lag signal we already plumb.
- Plonk / Halo2 baselines for comparison — different setup costs, different proving-time profile.
- Nullifiers + Merkle membership to turn solvency into a spendable balance.
- k8s HPA instead of
docker compose --scale. - Real on-chain deploy of the exported Verifier.sol with an end-to-end demo against a testnet.
Atlas is open source. The deployed site is the showcase; the actual proving pipeline runs on your own machine via Docker Compose. Clone the repo, run two commands, and the same charts above are produced from your hardware.
git clone https://github.com/patrick-steve/atlas cd atlas/pipeline/circuits && npm install && npm run setup:balance cd ../.. && docker compose -f pipeline/docker-compose.yml up --scale worker=4
Once your local pipeline is up, expose the gateway through a tunnel and tell a Vercel deployment of this site about it:
# expose the gateway publicly cloudflared tunnel --url http://localhost:8080 # in Vercel project settings: NEXT_PUBLIC_GATEWAY_URL=https://<your-tunnel>.trycloudflare.com
The demo panel will probe /health on load and switch from SIMULATED to LIVE automatically. No rebuild required.