Link account component dynamically against their libraries
- Langage dominant
- Rust
- Étoiles
- 132
- Forks
- 167
- Merge moyen
- 1 j 23 h
- PR mergées (30 j)
- 110
Description
## Summary
Account component workspaces statically link the very library whose procedures they re-export, even though that library is always loaded into the MAST store at runtime. Two manifests are affected:
- [`crates/miden-standards/asm/components/miden-project.toml`](https://github.com/0xMiden/protocol/blob/d6d1d21289530ef7a85e3cbd0b29a3f3f1d9ed19/crates/miden-standards/asm/components/miden-project.toml#L42) declares `miden-standards = { linkage = "static" }` for all 31 standard account components.
- [`crates/miden-agglayer/asm/components/miden-project.toml`](https://github.com/0xMiden/protocol/blob/d6d1d21289530ef7a85e3cbd0b29a3f3f1d9ed19/crates/miden-agglayer/asm/components/miden-project.toml#L8) declares `miden-agglayer = { linkage = "static" }` for the bridge and faucet components.
Each component therefore inlines the library code it reaches, plus a large fixed blob of duplicated debug information.
Both libraries are already guaranteed to be present at runtime. [`TransactionMastStore::new`](https://github.com/0xMiden/protocol/blob/d6d1d21289530ef7a85e3cbd0b29a3f3f1d9ed19/crates/miden-tx/src/prover/mast_store.rs#L38-L64) loads the standards package and the agglayer package, and the downstream `DataStore` implementations in `miden-node` and `miden-client` build on it. [`CodeBuilder`](https://github.com/0xMiden/protocol/blob/d6d1d21289530ef7a85e3cbd0b29a3f3f1d9ed19/crates/miden-standards/src/code_builder/mod.rs#L272) already links standards dynamically for note and transaction scripts, and the agglayer components already declare `miden-standards` itself [dynamic](https://github.com/0xMiden/protocol/blob/d6d1d21289530ef7a85e3cbd0b29a3f3f1d9ed19/crates/miden-agglayer/asm/components/miden-project.toml#L11) - only their own library is static.
This issue proposes flipping both to dynamic.
## Measurements
Measured for `miden-standards` only. The agglayer components were not re-measured, because the mechanism is identical and the standards numbers are enough to make the case. For reference, their current static-linked sizes are 130,531 bytes for `miden-agglayer-bridge.masp` and 34,760 bytes for `miden-agglayer-faucet.masp`, against a 150,599-byte `miden-agglayer.masp` that every executor already holds. Both component packages are `include_bytes!`-embedded in `crates/miden-agglayer/src/lib.rs` alongside the library package itself.
Built twice from `d6d1d21` in a scratch worktree, changing only `linkage = "static"` to `"dynamic"` in the standards components manifest.
Shipped component packages (all `include_bytes!`-embedded into the crate binary via [`account_component_code!`](https://github.com/0xMiden/protocol/blob/d6d1d21289530ef7a85e3cbd0b29a3f3f1d9ed19/crates/miden-standards/src/account/mod.rs#L78-L91)):
- 31 component `.masp` files, total: 4,560,576 bytes static, 57,849 bytes dynamic (-98.7%)
- `note_creator`: 131,609 -> 741 bytes
- `basic_wallet`: 132,755 -> 1,167 bytes
- `auth_multisig`: 186,863 -> 3,690 bytes
Serialized `AccountCode` for an AuthSingleSig + BasicWallet account:
- static: 12,234 bytes, 233 MAST nodes
- dynamic: 1,086 bytes, 21 MAST nodes
Test suites under dynamic linkage passed.
## Where the size goes
Two distinct costs, worth separating:
1. Inlined MAST. Real but modest, and it is the part that lands on chain. Most standard components are thin wrappers, so the inlined subtrees are small (`basic_wallet` forest 457 bytes, `auth_multisig` 21,191 bytes).
2. Duplicated debug information. This dominates, and it accounts for the whole fixed floor: `MastForestBuilder::new_with_static_libraries` copies the error-message rows of every statically linked library into each dependent. The result is a roughly 131 KB floor per component. `note/note_creator/note_creator.masm` is a single `pub use` line, and its package still contains every P2ID, SWAP, PSWAP, RBAC, faucet and fee-sponsorship error string defined anywhere in standards.
The same function also copies the static library's entire advice map, but that is unavoidable: advice map keys are computed at runtime, so there is no way to determine which keys a given subset of procedures will read. It is also not a factor here, since the standards component packages carry no advice map entries at all.
Cost 2 is an assembler-level problem that dynamic linkage sidesteps rather than solves: it reappears for anything else that links statically. That is closely related to 0xMiden/protocol#2767.
## Effect on procedure roots
Of the 73 procedure roots exported by the 13 Rust-exposed standard components, 70 are byte-identical under both linkage modes. Three change: the auth procedures of `auth_singlesig`, `auth_multisig` and `auth_network_account`. Account code commitments therefore change for accounts built from those three components.
Pre-release this is acceptable, so it is not an obstacle. It is documented here because the root change is surprising, and because the reason it happens is worth recording.
### Why the roots move, and why that is not an assembler bug
It is tempting to expect MAST roots to be linkage-invariant, since an `ExternalNode` carries the digest of the procedure it stands for and can be swapped for that subtree without changing the root. That invariant does hold node-for-node. The caller's root still moves, for a different reason.
In `miden-assembly` 0.29.0, `Assembler::resolve_target` -> `ensure_valid_procedure_mast_root` -> `MastForestBuilder::ensure_external_link_with_source_ref` copies the callee's subtree into the builder when it is found in a statically linked forest, and interns an `ExternalNode` otherwise. Both carry the same digest at that point. The divergence happens afterwards in `MastForestBuilder::merge_contiguous_basic_block_refs`: if the copied callee is itself a single basic block, its operations are absorbed into the caller's surrounding span. An `ExternalNode` is not a basic block, so under dynamic linkage nothing is absorbed. Different node structure, different caller root.
Note that the `PROCEDURE_INLINING_THRESHOLD` of 32 operation batches does not apply here. `merge_basic_block_refs` consults `should_merge(self.is_procedure_root_ref(..), ..)`, and `procedure_root_refs` is only populated for procedures compiled in the current unit. A subtree copied in from a statically linked library is never registered there, so the size check is bypassed and the callee is absorbed regardless of how large it is. A 4000-operation callee (roughly 56 batches) still collapses the caller to a single node.
Minimal reproduction against `miden-assembly` 0.29.0 alone, with callee `bar` = `push.3 add` and caller `foo` = `push.1 exec.dep::bar push.2 add`:
- `bar`: `0xca14605218c17e70e69b0f6cc953c1fff2eae376c75ed567e3d1786b866ad533`
- `foo` static: `0xec42b099b1ec08a2e32379f796947533d1e991d75c94c224d38182b398070a18`, 1 node
- `foo` dynamic: `0x33adc4b56fb30aab64ca27410cc42a9aaa1a53d92c5df71d93f2224e100834ef`, 5 nodes
- `foo` with the callee written out by hand as `push.1 push.3 add push.2 add`: `0xec42b099b1ec08a2e32379f796947533d1e991d75c94c224d38182b398070a18`, 1 node
The static result is identical to the hand-inlined version, which is exactly the documented contract of `Linkage::Static`: link the contents of the dependency "as if they were defined as part of the dependent". The `vendoring` test in `miden-assembly` `src/tests.rs` sets up the same comparison deliberately.
With a callee that is not a single basic block (`dup.0 eq.0 if.true push.3 add else push.4 add end`), there is nothing for the caller's span to absorb and the roots agree:
- `qux` static: `0x90d4985ce792f422439aed4f7f9073f6b24ae57ea0ea75354af4fdba089f626b`, 9 nodes
- `qux` dynamic: `0x90d4985ce792f422439aed4f7f9073f6b24ae57ea0ea75354af4fdba089f626b`, 5 nodes
This is why only 3 of 73 roots move. Pure `pub use` re-exports export the standards procedure's own root either way, and callers of control-flow-bearing standards procedures are unaffected. The three that change call single-basic-block standards procedures that get absorbed under static linkage.
cc'ing @bitwalker just in case MAST roots are intended to be stable independent of linkage, but given the above, I assume not.
## Why there is no trade-off left
Two objections could have argued for keeping static linkage. Neither survives.
Runtime availability: every executor loads both libraries. `TransactionMastStore::new` inserts the standards and agglayer packages, and the downstream `DataStore` implementations in `miden-node` and `miden-client` build on it.
Self-containment: the concern would be that dynamically linked account code holds external references to specific library procedure roots, so an executor that no longer ships those roots could not run the account. That cannot happen. From mainnet onwards the protocol, standards and agglayer libraries are append-only, so a procedure root that exists once is never removed. A dynamically linked reference stays resolvable for the lifetime of the account.
What remains is one-sided: duplicated MAST in on-chain account code, and a much larger blob of duplicated debug information, in exchange for nothing.
## Suggested next steps
1. Flip `miden-standards` and `miden-agglayer` to `linkage = "dynamic"` in the two component manifests, in the same change, so the two component workspaces do not drift apart again.
2. Separately, consider whether the assembler should still copy every statically linked library's error messages into each dependent. Flipping these two manifests sidesteps the problem here, but it remains for anything else that links statically, including `CodeBuilder::link_static_package` and the compiler case in 0xMiden/protocol#2767.
Guide de contribution
Ouvrir le guide de contribution
Évaluation
Cette issue n'a pas encore été évaluée.