Skip to content

Add the factored precompile VM ACE registry - #3465

Open
Al-Kindi-0 wants to merge 2 commits into
nextfrom
al/pvm-ace-registry-pr2
Open

Add the factored precompile VM ACE registry#3465
Al-Kindi-0 wants to merge 2 commits into
nextfrom
al/pvm-ace-registry-pr2

Conversation

@Al-Kindi-0

@Al-Kindi-0 Al-Kindi-0 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

The recursive verifier must use the ACE circuit matching the proof's height-sorted AIR order. With ten AIRs there are 10! = 3,628,800 valid orders, so checking in every circuit commitment or rebuilding the complete registry for each proof is impractical.

This PR makes the registry compact to store and cheap to serve while retaining exhaustive checks over every proof order.

How it works

Each ACE circuit is split into a short order-dependent prefix and a larger common section:

leaf(order) = merge(H(constants | shuffle(order)), H(common))

The factory builds the common circuit once, caches its commitment and the sponge state after the constants, and then emits only the shuffle section for each order. When a protocol change requires minting a new registry from scratch, this reduces the offline leaf-and-root construction phase from 169 seconds with per-order assembly to 58 seconds with the factored encoder (2.9x). Packed hashing and the final two-tier layout reduce that phase further to 37.6 seconds on M4 NEON. None of this full-registry work is performed at process startup or while serving a proof.

The complete tree has 2^22 slots. Instead of checking in its 3.6 million active leaves, the repository stores 4,096 authenticated subtree roots. The complete registry is rebuilt only by the maintenance tool; normal proof serving never performs that work. A serving process authenticates the checked-in row and builds the circuit factory once, then reconstructs only the selected 1,024-leaf subtree for each proof.

The regeneration tool has two roles:

  • --check recomputes the complete registry, checks the fast shuffle encoding against full circuit assembly for every order, and compares the result with the checked-in artifacts.
  • --write is the rarer minting path. Before replacing protocol constants, it compares every packed leaf with the scalar commitment obtained from fully assembling that order's circuit.

The Miden VM registry moves to the same factored commitment format. Registry roots and relation digests change, so recursive-proof artifacts created with the previous format are incompatible.

Factoring adds an order-dependent shuffle section. This PR also applies a conservative DAG normalization pass that retains only circuit-shrinking rewrites while preserving evaluation. The net size change at this branch tip is:

Relation EVAL gates Change Stream felts Change
Miden VM 5,176 -> 5,336 +3.1% 5,760 -> 5,920 +2.8%
Precompile VM 9,576 -> 10,640 +11.1% 12,592 -> 13,656 +8.4%

Unlike full-registry minting, this extra work is paid when a recursive verifier loads and evaluates the circuit. If end-to-end profiling shows that cost is material, a follow-up can retain the two-tier registry while investigating unfactored per-order circuits and a different strategy for constructing or serving their authenticated subtrees.

Performance

All figures below use release mode on M4 NEON. The full-registry figures are offline maintenance costs, not per-proof costs:

  • Complete registry construction: 37.6 s with Rayon's default 14-thread pool.
  • Exhaustive CI drift check: 54.85 s median over three runs.
  • Minting new constants: 100.60 s median over three runs; this does not run in CI.
  • First lookup in a fresh concurrent process: 29.2 ms, including factory construction and row authentication.
  • Marginal registry lookup for each subsequent proof: 14.7 ms with concurrent. The default single-threaded std path takes 106.0 ms warm.

Circuit generation and validation dominate these measurements. Building the Merkle subtrees is less than 1% of the full construction cost.

@Al-Kindi-0
Al-Kindi-0 force-pushed the al/pvm-ace-registry-pr2 branch 2 times, most recently from 9cc7966 to 8b8c952 Compare August 6, 2026 06:14
@Al-Kindi-0
Al-Kindi-0 marked this pull request as ready for review August 6, 2026 12:08
Base automatically changed from al/pvm-10-chiplets to next August 7, 2026 11:19
@Al-Kindi-0
Al-Kindi-0 force-pushed the al/pvm-ace-registry-pr2 branch from 8b8c952 to eabf90f Compare August 7, 2026 11:19
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

This PR contains unsigned commits. All commits must be cryptographically signed (GPG or SSH).

Unsigned commits:

  • eabf90f5 feat(ace): Add the factored precompile VM ACE registry

For instructions on setting up commit signing and re-signing existing commits, see:
https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits

- factor multi-AIR ACE circuits into per-order and shared sections
- add reusable order tagging, packed leaf hashing, and path authentication
- mint the 10-chiplet PVM registry over all 10! proof orders
- bind the registry root into the PVM relation digest
- serve one authenticated subtree path per proof
@Al-Kindi-0
Al-Kindi-0 force-pushed the al/pvm-ace-registry-pr2 branch from eabf90f to eb05168 Compare August 7, 2026 11:29
Comment on lines +129 to 131
/// Add a subtraction node with constant folding, add/sub cancellation, and negation
/// normalization.
pub fn sub(&mut self, a: NodeId, b: NodeId) -> NodeId {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One issue with the new DagBuilder::sub handling is that now the unreachable path in reemit_air_root is no longer unreachable I believe (DagBuilder::sub can return a non-Sub node via the new cancel_sub / negated)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's still unreachable, though the argument deserved to be written down and it now lives at the call site. The root's right operand is the freshly interned Mul(q, v) over the quotient inputs, which no node built from the AIR constraints can reference or equal. Every non-Sub return in DagBuilder::sub — the cancel_sub branches and the negation rewrites — requires exactly such a relation to the right operand, and sub has no left-operand rewrites, so none of the new simplifications can fire on this root.

There's also a unit test now pinning the no-left-rewrite half, which is the part a future rewrite could silently erode.

Comment thread verifier/src/recursive/mod.rs Outdated
Comment on lines 403 to 405
let registry_tree = config::ace_circuit_registry_tree();
store.extend(registry_tree.inner_nodes());
extend_ace_registry_store(&mut store, proof_order)?;

let circuit = build_recursive_verifier_ace_circuit(proof_order).map_err(|_| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't we computing the ACE twice? i.e. once in ace_registry_path (within extend_ace_registry_store) then in build_recursive_verifier_ace_circuit?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's even worse with no-std builds as we don't do caching

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, the entry was minted twice and things are fixed now so that call sites now collapse into a single recursive_registry_entry(order) that serves circuit, leaf, and path from one factory.

On std vs no-std: under std the factory and the registry tree sit behind OnceLocks, so everything is computed once per process. Under no-std the entry is still built per call, though now it's one factory serving both outputs instead of two independent computations. We could cache there too via miden-utils-sync's racy statics, but that adds a dependency edge and pins the factory and tree in memory for the process lifetime, which only pays off for a consumer that verifies repeatedly in one process. If such a no-std consumer shows up, wiring the same cache through utils-sync is a small follow-up.

Comment on lines +68 to +85
pub fn pvm_ace_registry_path(tag: u32) -> Option<(Word, MerklePath)> {
if (tag as usize) >= PVM_REGISTRY_LAYOUT.leaf_count() {
return None;
}
let subtree_index = tag as usize / PVM_REGISTRY_LAYOUT.subtree_leaves();
let leaves = leaves_for_subtree(subtree_index);
let subtree = MerkleTree::new(&leaves).expect("subtree has power-of-two leaves");
Some(
path_in_verified_tree(
&PVM_REGISTRY_LAYOUT,
verified_pyramid(),
&subtree,
tag,
MISMATCH_HINT,
)
.expect("bounded tag and verified pyramid must produce a registry path"),
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're recomputing the subtree on every lookup, couldn't we amortize that?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Leaves are now cached in per-subtree OnceLocks.
Caching leaves is better because this is where most of the cost lives and it allows us to get away with less memory pressure.

- build MVM circuits and authentication paths from one shared factory
- cache PVM active-subtree leaves and share the padding allocation
- enforce registry-entry coherence and harden DAG and packed-hash invariants
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.

2 participants