Guides
Prepared execution
Build a PreparedSync once per operation kind and configuration, reuse it for every request, and know what it shares, what stays private to each request and when to rebuild it.
The fresh calls prove_sync_op and verify_sync_op compile a circuit and compute its commitment again on every call, in addition to the work that depends on the request. prepare moves that setup out of the request path: you build a PreparedSync once per operation kind and configuration, then pass it to the prepared calls for every request of that kind.
Before you begin#
- Build requests as described in Proving state operations.
- Decide the configuration your service runs with: tree depth, leaf-encoding bound and layer strategy. A preparation is valid for exactly one configuration.
What preparation does#
StateSyncProver::prepare(kind) compiles the circuit for one operation kind with the wiring hints, derives the verifier's wiring from those hints, and computes the full circuit commitment. It returns a PreparedSync, or SyncError::Compile when the configuration cannot be compiled.
| Call | Work done on every call |
|---|---|
prove_sync_op | Compile the circuit, generate the witness, compute the commitment, prove |
prove_sync_op_prepared | Generate the witness, prove |
verify_sync_op | Compile the circuit with wiring hints, derive the wiring, compute the commitment, verify |
verify_sync_op_prepared | Check that the request's operation kind matches, verify |
Both proving paths run the same prove body, so a prepared proof is byte for byte the proof that the fresh path produces for the same request, and either verifier accepts it.
Prepare once, reuse for every request#
A service typically prepares all three operation kinds at startup and selects the preparation by each request's own kind. The program below does that, then proves eight requests at depth 24, the default configuration:
use std::process::ExitCode;
use statesync_gkr::compiler::{
AssetId, LayerStrategy, LeafPayload, LeafState, MerklePath, PublicInputs, SmtOpKind,
SmtOperation, SmtParams, SmtWitness,
};
use statesync_gkr::primitives::field::{BaseField, PrimeCharacteristicRing};
use statesync_gkr::primitives::hash::{Digest, HashGadget, Poseidon2Gadget};
use statesync_gkr::{PreparedSync, StateSyncGkrConfig, StateSyncProver, SyncRequest, SyncResult};
/// Long-lived proving state: one prover and one preparation per operation kind.
pub struct PreparedProver {
prover: StateSyncProver,
membership: PreparedSync,
non_membership: PreparedSync,
update: PreparedSync,
}
impl PreparedProver {
/// Compile, commit and derive wiring once per operation kind. Call at startup.
pub fn new(config: StateSyncGkrConfig) -> Result<Self, String> {
let prover = StateSyncProver::new(config);
let membership = prepare_kind(&prover, SmtOpKind::Membership)?;
let non_membership = prepare_kind(&prover, SmtOpKind::NonMembership)?;
let update = prepare_kind(&prover, SmtOpKind::Update)?;
Ok(Self {
prover,
membership,
non_membership,
update,
})
}
/// The preparation for the request's own kind, so kinds can never be mixed.
fn prepared_for(&self, request: &SyncRequest) -> &PreparedSync {
match request.operation.kind() {
SmtOpKind::Membership => &self.membership,
SmtOpKind::NonMembership => &self.non_membership,
SmtOpKind::Update => &self.update,
}
}
/// Prove one request and return the result only if verification accepts it.
pub fn prove(&self, request: &SyncRequest) -> Result<SyncResult, String> {
let prepared = self.prepared_for(request);
let result = self
.prover
.prove_sync_op_prepared(prepared, request)
.map_err(|error| format!("proving failed: {error:?}"))?;
if !self.prover.verify_sync_op_prepared(prepared, request, &result) {
return Err("the proof or its request binding was rejected".to_owned());
}
Ok(result)
}
}
fn prepare_kind(prover: &StateSyncProver, kind: SmtOpKind) -> Result<PreparedSync, String> {
prover
.prepare(kind)
.map_err(|error| format!("preparing {kind:?} failed: {error:?}"))
}
fn field(value: u32) -> BaseField {
BaseField::from_u32(value)
}
/// A synthetic membership request with fixture siblings, as in the Quickstart.
fn membership_request(params: &SmtParams, index: u32) -> Result<SyncRequest, String> {
let hasher = Poseidon2Gadget::new(params.leaf_max_fields as usize);
let key = AssetId(u64::from(index));
let payload = LeafPayload {
sync_state: vec![field(42 + index), field(7)],
identity_digest: [9_u8; 32],
};
let leaf = LeafState::Occupied(payload.clone());
let path = MerklePath {
siblings: (0..params.depth)
.map(|level| Digest([field(level * 13 + 1); 8]))
.collect(),
};
let root = path
.compute_root(&hasher, params, key, &leaf)
.map_err(|error| format!("root construction failed: {error:?}"))?;
let value_digest = hasher
.hash_leaf(&leaf.encode())
.map_err(|error| format!("leaf hashing failed: {error:?}"))?;
let operation = SmtOperation::Membership { key, payload };
let op_kind_tag = PublicInputs::kind_tag(operation.kind());
Ok(SyncRequest {
operation,
witness: SmtWitness { leaf, path },
public_inputs: PublicInputs {
old_root: root,
new_root: root,
op_kind_tag,
asset_id: key,
value_digest,
},
})
}
fn run() -> Result<(), String> {
// Depth 24 and the 31-field leaf-encoding bound are the defaults.
let config = StateSyncGkrConfig {
smt: SmtParams::default(),
layer_strategy: LayerStrategy::A,
..Default::default()
};
let params = config.smt;
let engine = PreparedProver::new(config)?;
for index in 0..8 {
let request = membership_request(¶ms, index)?;
engine.prove(&request)?;
}
println!("prepared-requests=PASS");
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("prepared-requests=FAIL: {error}");
ExitCode::FAILURE
}
}
}The program prints prepared-requests=PASS.
PreparedProver::new pays the setup cost three times, once per kind, and every later request pays only for its witness, its proof and its verification. A verifier service that never proves follows the same pattern: it builds its own preparations from the same configuration and calls verify_sync_op_prepared, or verify_encoded_sync_op for proofs received as bytes (Encoding and transport).
What is shared and what stays per request#
| Item | Held by | Lifetime |
|---|---|---|
| Compiled circuit for one operation kind and configuration | PreparedSync | Process |
| Full circuit commitment | PreparedSync, readable through circuit_commitment() | Process |
| Derived verifier wiring | PreparedSync | Process |
| Public inputs: roots, operation tag, key and value digest | The request | One request |
| Private witness: leaf and sibling path | The request | One request |
| Circuit witness: every wire value | Built inside each proving call, or held in a ProveJob | One request |
| Fiat-Shamir transcript and challenges | Inside each prove or verify call | One call |
| GKR proof | SyncResult | One request |
Sharing stops at setup by design. Each request's transcript starts from its own public inputs, and everything the prover computes after that depends on the request's challenges and witness. Sharing any of that state across requests would change proof bytes, so preparation shares only work that cannot influence them. Faster throughput for many requests comes from running independent jobs in parallel, covered in Batching and parallelism.
Thread safety and lifetime#
PreparedSyncis immutable after construction andSync, so one instance serves every worker thread. Share it by reference inside a thread pool, or throughstd::sync::Arcin a long-lived service.PreparedSyncimplementsClone, but a clone copies the whole compiled circuit. Share one instance instead of cloning it per request.PreparedSyncitself cannot be serialized. Apart from the reviewed material described in Loading reviewed prepared material, build it when the process starts.- A
PreparedSyncdoes not record the configuration it was built with; every prepared call uses the configuration of the prover you call it on. Keep each preparation with the prover that built it, or with a prover configured identically.StateSyncProver::configis a public field, so never change it after preparing.
When to rebuild#
Build a new PreparedSync when any of these change:
- The operation kind. Each kind has its own circuit, so a service needs one preparation per kind it serves.
smt.depth,smt.leaf_max_fieldsorlayer_strategy. Together with the kind, these are the only values thatpreparereads.- The source revision of StateSync-GKR. A preparation lives only in memory, so building it at process start keeps it matched to the code that uses it.
No rebuild is needed when roots, keys, payloads or paths change, because those belong to individual requests. Changing StateSyncGkrConfig::batching does not affect preparation either.
A prepare error is a configuration error, such as a layer strategy other than A, a depth of 0 or a leaf-encoding bound below 10. Retrying returns the same error, so fix the configuration instead.
Measured benefit#
Direct measurements at depth 24 found 3.46–4.45 times lower per-request proving-path latency on the prepared path than on the fresh path: about 3.69 for membership, 3.46 for non-membership and 4.45 for update. Each operation and mode ran in three processes of 1,000 timed requests, and the ratios compare run-matched process summaries. Encoding, transport and acceptance are outside the timer. The measurements used the 1.1.0 release source.
Prepare the circuit once. Pay less for every request.
Depth 24, one request per call on a 192-core bare-metal host, three processes of 1,000 timed requests per operation and mode. Prepared material is reusable only for the same operation kind and configuration. Encoding, transport and acceptance are excluded.
The gap is large because preparation removes the circuit commitment as well as compilation. Direct-call medians for depth-24 membership, taken on two AMD EPYC 9R45 sockets (192 CPUs, SMT off, 384 GiB of RAM) with Rust 1.96.1 and -Ctarget-cpu=native:
| Direct call | Median (ms) |
|---|---|
| Compile with wiring hints | 7.90 |
| Derive verifier wiring | 4.42 |
| Circuit commitment | 105.03 |
| Witness generation | 0.55 |
| Prove on the circuit | 41.54 |
| Verify with derived wiring | 5.87 |
Each value is the median of five per-process request medians, with 300 measured requests per process. Each row times one direct call on its own, so a sum of rows is a cost model that was never measured as a single interval.
The derived wiring that a PreparedSync holds is also the faster of the two wiring evaluators. With the same proof and request, the complete prepared verification call was 10.80–23.68 times faster with derived wiring than with the sparse table wiring, across nine depth and operation profiles. That comparison excludes preparation and changes only the wiring evaluator. Benchmark results gives the full data, and Benchmark methodology defines each interval.
To see the effect on your own hardware, run the development harness, which reports single-path and prepared costs among other sections:
RUSTFLAGS="-Ctarget-cpu=native" cargo run --release --locked --bin measureTreat its output as a development observation. Absolute values from two machines are comparable only when hardware, compiler flags, thread placement, background load and repetition policy are all fixed.
Loading reviewed prepared material#
For one profile, the facade can build a PreparedSync from serialized material instead of compiling. prepare_pinned_d24_a_membership(&bytes) accepts only the reviewed material for depth-24 membership with layer strategy A and the default 31-field leaf-encoding bound. It first requires the prover's configuration to match that profile and returns PreparedMaterialError::ConfigMismatch otherwise. It then checks the exact length and digest of the bytes, decodes them strictly, compares the circuit commitment with the reviewed value and rebuilds the derived wiring locally instead of trusting it from the input. It never falls back to compiling. This path serves the recorded external-proof route; for every other profile, use prepare.
Common mistakes#
| Symptom | Cause | Fix |
|---|---|---|
| A debug build stops on an assertion, or a release build panics or produces rejected proofs | A request of another kind was passed to prove_sync_op_prepared or make_job_prepared; those calls check the kind only in debug builds | Select the preparation by request.operation.kind(), as PreparedProver does |
| Every prepared proof is rejected after a configuration change | The prover's configuration changed after prepare, or the preparation came from a differently configured prover | Rebuild the preparations whenever smt or layer_strategy changes |
| Verification throughput is far below expectations | verify_sync_op runs in a loop and compiles, derives wiring and commits on every call | Use verify_sync_op_prepared |
| Each request spends time and memory copying the circuit | A PreparedSync is cloned for each request | Share one instance by reference or through Arc |
Startup retries prepare in a loop | A configuration error was treated as transient | Fix the configuration; prepare fails the same way every time |
Checklist#
- One
PreparedSyncper operation kind and configuration, built at startup. - The preparation is selected by the request's own kind.
- Workers share each preparation by reference or through
Arc. - Preparations are rebuilt when depth, leaf-encoding bound or layer strategy changes.
- Verifiers use
verify_sync_op_preparedorverify_encoded_sync_opwith preparations built from the same configuration as the prover's. - A request is accepted only after verification returns
true.
Next steps#
- Batching and parallelism: prove many prepared requests on a worker pool.
- Encoding and transport: verify proofs received from another process.
- Configuration: every field of
StateSyncGkrConfig. - Performance tuning: choose operating settings for a service.