Executing a Batch in Parallel
The previous page, Scheduling Into Conflict-Free Batches, did the hard thinking: it took a stream of transactions, read each one’s declared footprint, applied the conflict rule, and layered them into batches where nothing inside a batch conflicts. That is a promise about the shape of the work. This page cashes it in.
We now have a batch — a list of transactions that provably do not fight over any writable account — and a machine with a dozen cores sitting idle. The job is to run them all at once and end up with exactly the state we would have reached running them one at a time. The remarkable part is how little code that takes, and how much of the correctness is carried by the type system rather than by us being careful. Handing live transactions to a thread pool should be terrifying. This page is about why, here, it is boring.
The three-step shape
Section titled “The three-step shape”Every batch runs through the same three moves. Gather, run, commit.
┌─────────────────────────────────────────────────────────────┐ │ batch = [ t0(A,B) t1(C) t3(D,E) ] ← conflict-free │ └─────────────────────────────────────────────────────────────┘
1. GATHER (sequential reads of the DB) clone each tx's declared accounts into a private Vec<Account> work = [ [A,B] [C] [D,E] ] ← disjoint owned copies
2. RUN (parallel: rayon hands each thread a disjoint &mut) thread ─► t0 mutates its [A,B] thread ─► t1 mutates its [C] ← all at the same time thread ─► t3 mutates its [D,E]
3. COMMIT (sequential, on Ok only) for each tx that returned Ok: write its *writable* accounts back A,B ◄─ t0 C ◄─ t1 D,E ◄─ t3 a tx that returned Err: its clone is discarded — nothing writtenIn the book’s companion crate solmini, this is the whole of run_parallel — around a dozen lines.
Read it top to bottom; every line maps to one of the three steps.
pub fn run_parallel(&mut self, txs: &[Transaction]) -> Vec<Result<()>> { let batches = schedule(txs); let mut results: Vec<Result<()>> = (0..txs.len()).map(|_| Ok(())).collect();
for batch in &batches { // 1. GATHER: pull each tx's declared accounts out of the DB as a // private, owned working set (sequential reads). let mut work: Vec<Vec<Account>> = batch.iter().map(|&i| self.working_set(&txs[i])).collect();
let programs = &self.programs; let compute = self.compute_per_tx;
// 2. RUN: rayon hands out a disjoint `&mut Vec<Account>` per element, // and the batch is conflict-free, so no two threads can ever reach // the same writable account. let batch_results: Vec<Result<()>> = batch .par_iter() .zip(work.par_iter_mut()) .map(|(&i, accts)| Self::run_tx(programs, compute, &txs[i], accts)) .collect();
// 3. COMMIT: each writable key in a conflict-free batch is touched by // exactly one tx, so write-back never clobbers. Failures are dropped. for (slot, &i) in batch.iter().enumerate() { if batch_results[slot].is_ok() { self.commit(&txs[i], &work[slot]); } } for (slot, r) in batch_results.into_iter().enumerate() { results[batch[slot]] = r; } } results}Three quiet properties hold this together, and each earns a section: disjoint mutable borrows make the parallel step a compile-time guarantee, clone-then-commit gives free rollback, and conflict-freedom makes the write-back safe. Get those three and the thread pool is an implementation detail.
Step 1 — Gather: a private working set per transaction
Section titled “Step 1 — Gather: a private working set per transaction”Before any thread runs, we copy. For each transaction, working_set walks its declared account list and
pulls a clone of every account out of the shared database:
/// Pull a transaction's declared accounts out of the DB as an owned working/// set (missing accounts default to an empty account). This clone is the/// transaction's private sandbox: it can mutate freely, and only its writable/// accounts are written back — on success.fn working_set(&self, tx: &Transaction) -> Vec<Account> { tx.accounts .iter() .map(|m| self.accounts.get(&m.key).cloned().unwrap_or_default()) .collect()}The key word is cloned. The transaction never touches the live database while it executes. It gets a
sandbox — an owned Vec<Account> it fully controls. This is a deliberate choice with two payoffs that
land later: nothing a running transaction does is visible to anyone until we choose to commit it, and a
transaction that fails leaves the database untouched simply because we drop its sandbox on the floor.
Gathering is done sequentially, one HashMap read at a time, because reading the database is cheap next
to running a program. The expensive work — signature verification, deserialization, the program logic
itself — happens in step 2, where the parallelism pays off.
Step 2 — Run: the data race is a compile error
Section titled “Step 2 — Run: the data race is a compile error”This is the line that would be a minefield in C, and is a non-event in Rust:
let batch_results: Vec<Result<()>> = batch .par_iter() .zip(work.par_iter_mut()) .map(|(&i, accts)| Self::run_tx(programs, compute, &txs[i], accts)) .collect();work.par_iter_mut() is rayon’s parallel mutable iterator. Its guarantee is precise and it is exactly
the guarantee we need: it hands each closure invocation a disjoint &mut Vec<Account> — element 0
to one thread, element 1 to another — and the borrow checker knows those &muts cannot alias. That is
IndexedParallelIterator: a parallel iterator that can address its elements by index and therefore
split them into non-overlapping mutable slices. No two threads can ever hold a mutable reference into the
same working set, because there is no such shared reference to hand out.
So the classic recipe for a data race — two threads writing the same memory with no synchronization — is
not something we defend against at runtime. It is unrepresentable. If you tried to write a version
that shared a working set across threads, it would not compile: the closure would need to capture a
&mut twice, and Send/Sync and the borrow checker would reject it. The race is a type error, caught
before the program runs, not a Heisenbug caught in production at 3 a.m.
Notice what run_tx does not take:
fn run_tx( programs: &HashMap<Pubkey, Box<dyn Program>>, compute_per_tx: u64, tx: &Transaction, accounts: &mut [Account],) -> Result<()> { /* ... */ }It is an associated function — no &self. A worker thread must not borrow the whole Runtime, because
that would mean every thread sharing one &Runtime while another part of the code wants &mut self.
Instead each thread gets only what it truly shares (&programs, which is read-only and Send + Sync)
plus its own private &mut [Account]. The API shape is the safety argument: shared things are shared
by immutable reference, owned things are owned outright, and nothing mutable is shared.
Under the hood — why disjoint ownership beats a shared lock
Section titled “Under the hood — why disjoint ownership beats a shared lock”It is worth naming what we did not do, because the earlier kvlite project in the Rust playbook did
the opposite, and both are correct.
kvlite is a key–value store built for concurrency the mainstream way: a single shared map behind a
lock (an Arc<RwLock<HashMap<..>>> or a sharded set of them). Every worker holds a reference to the
same map. Safety comes from coordination at runtime: before you touch a key you acquire the lock,
you do your work, you release it. Threads take turns. The map is shared; the discipline that keeps it
correct is the lock protocol, enforced dynamically.
Sealevel’s runtime here is the message-passing / ownership model instead. There is no shared mutable map during execution and therefore no lock to acquire. Each transaction owns a disjoint slice of state outright, mutates it alone, and the results are merged back afterward. Safety comes from the structure of the data, enforced statically:
kvlite (shared-lock) Sealevel runtime (disjoint ownership) ──────────────────── ──────────────────────────────────── one shared HashMap N private Vec<Account>, one per tx Arc<RwLock<..>> around it no lock — nothing is shared threads contend for the lock threads never meet correctness = lock protocol correctness = "shared XOR mutable" on (checked at runtime) accounts (checked at compile time) scales until lock contention scales until two txs want one accountNeither is universally better. A lock lets any two operations touch any keys and sorts out safety on the fly — flexible, but the lock itself becomes the bottleneck under contention, which is the whole lesson of Why the EVM Runs Serially. Disjoint ownership needs the footprint known up front so the scheduler can partition state cleanly, but once it has that, threads literally never synchronize on shared data during the hot path. Solana paid for that with the account- declaration rule; this page is where the bill turns into speedup.
Step 3 — Commit: mergeable write-back, and free rollback
Section titled “Step 3 — Commit: mergeable write-back, and free rollback”After the parallel run, every transaction has a finished working set and a Result. Now we fold the
successful ones back into the database:
fn commit(&mut self, tx: &Transaction, working: &[Account]) { for (meta, acct) in tx.accounts.iter().zip(working) { if meta.is_writable { self.accounts.insert(meta.key, acct.clone()); } }}Two properties make this the easy part.
Free atomic rollback. A transaction either committed all of its writable accounts or none of them.
The mutations lived only in the private clone; if the program returned Err, we simply never call
commit, and the clone is dropped when the loop moves on. There is no undo log, no reverting of partial
writes, no compensating transaction — the rollback is the absence of a commit. An overdrawn transfer,
a failed counter increment, a program that panicked its way to an error: all of them leave the database
byte-for-byte as it was. Atomicity is a property of the architecture, not a feature we coded.
Mergeable commits. We commit sequentially, and the commits cannot clobber each other, because of the
one property the scheduler guaranteed: within a conflict-free batch, each writable key is touched by
exactly one transaction. If two transactions in this batch both wrote account A, that would be a
conflict, and the scheduler would have put the later one in a different batch. So when we write t0’s
copy of A back and then t1’s accounts back, there is no account they both wrote — t1 never had A
as writable. The write-backs commute; their order does not matter; nothing overwrites anything. That is
why the commit loop can be a plain sequential for with no coordination at all.
Note the asymmetry: a batch can have two transactions that both read the same account (read–read is
not a conflict), and both of their clones will contain a copy of it — but neither writes it, so commit
skips it in both. Only writable accounts flow back, and each writable key has a single author.
The headline guarantee
Section titled “The headline guarantee”The whole design earns exactly one promise, and it is the promise that makes it trustworthy:
run_parallelproduces byte-for-byte the same state asrun_sequential.
The sequential runner is the obviously-correct baseline — run transactions in submission order, one at a
time, commit each on success. The parallel runner reorders execution across cores. If those two ever
disagreed on the final database, the parallelism would be a lie. They do not, and the reason is the
chain of guarantees this part built: the scheduler keeps conflicting transactions in submission order
across batches (so a later writer still sees an earlier writer’s effect), and within a batch nothing
conflicts (so intra-batch order is irrelevant). Determinism is not a nice-to-have here — a blockchain
where two validators replaying the same block reach different states cannot reach consensus at all. The
companion crate pins this down with an integration test that runs the same transaction set both ways and
asserts the resulting accounts maps are equal.
The architect’s lens
Section titled “The architect’s lens”- Why does it exist? Because scheduling a conflict-free batch is worthless if executing it is unsafe or slow. This step turns the scheduler’s promise into actual multi-core throughput — the payoff for declaring footprints in the first place.
- What problem does it solve? Running many live transactions on a thread pool without data races, without a shared lock, and without giving up deterministic replay — the three things that usually make “just parallelize it” a trap.
- What are the trade-offs? You pay a clone per transaction (memory and copy cost) and two sequential phases (gather, commit) around the parallel one. In return the parallel phase needs zero synchronization, and rollback and atomicity come for free.
- When should I avoid it? When the workload has no width — if almost every transaction touches one hot account, batches collapse to size one and you get sequential execution plus clone overhead. That is the hot-account ceiling, and it is a real limit.
- What breaks if I remove it? Remove the private clones and threads write the shared database directly: you reintroduce data races and lose free rollback. Remove the conflict-free precondition and the commit loop starts clobbering. Each of the three properties is load-bearing.
Check your understanding
Section titled “Check your understanding”- Why does each transaction execute against a clone of its accounts instead of the live database? Name the two properties this buys.
work.par_iter_mut()is what makes step 2 safe. What exactly does it guarantee, and why does that turn a potential data race into a compile-time error?- The commit loop is a plain sequential
forwith no locking, yet two commits never clobber each other. Which earlier guarantee makes that true, and what would go wrong without it? - A transaction returns
Err. Describe precisely what happens to its writes — and explain why this gives atomic rollback with no undo log. - Contrast this runtime’s safety model with
kvlite’s shared-lock model. Where does each check correctness — runtime or compile time — and what does each need from the workload to scale?
Show answers
- The transaction gets a private, owned working set, so (a) its mutations are invisible to everyone until commit — nothing races on the live DB during execution — and (b) a failure is undone for free by simply dropping the clone. Isolation and free rollback.
par_iter_mut(anIndexedParallelIterator) hands each closure a disjoint&mutinto a distinct element — no two threads can hold a mutable reference to the same working set. There is no shared mutable reference to alias, so a data race isn’t defended against at runtime; it is unrepresentable, rejected by the borrow checker before the program runs.- Conflict-freedom: within a batch, each writable key is touched by exactly one transaction (any second
writer would have been pushed to a later batch). So the write-backs never overlap and commute. Without
it, two transactions could both write account
A, and the second commit would silently clobber the first — a lost update. - We never call
commitfor a failed transaction, and its clone is dropped as the loop advances. None of its writes ever reach the database, so the DB is byte-for-byte unchanged. Because all its mutations lived only in the clone, “commit all or commit nothing” is automatic — atomicity without an undo log. kvliteshares oneHashMapbehind anArc<RwLock<..>>and enforces safety at runtime via the lock protocol; it scales until lock contention. This runtime gives each transaction a disjoint owned slice of state and enforces “shared XOR mutable” on accounts at compile time; it needs the footprint declared up front so the scheduler can partition state, and scales until two transactions want the same writable account.