feat(bench): price consumption of each note against a network account#3354
feat(bench): price consumption of each note against a network account#3354mmagician wants to merge 9 commits into
Conversation
The committed cycle counts predate recent kernel and standards changes on next. Regenerating moves every scenario, and the two-P2ID-Falcon scenario's padded chiplets bracket crosses from 65536 to 131072 rows; the scenario expectations mirror is updated accordingly (the miden-vm consumer's copy must be refreshed to match). This drift going unnoticed is the motivating example for the cost-table snapshot check added later in this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consolidates the bench-transaction modules into the lib target (the bin previously re-declared and re-compiled them), adds ExecutionBenchmark::all() as the single scenario list, and routes context construction through one build_benchmark_context dispatch used by main. The duplicated bridge role wallet + bridge account setup shared by the CLAIM and B2AGG builders moves into a BridgeFixture helper. No behavior change: the regenerated bench-tx.json is byte-identical before and after this refactor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…and agglayer notes Adds one benchmark scenario per standard note script and execution path, each measuring the canonical network-account transaction as it exists today: an AuthNetworkAccount-authenticated account (with the components the note requires) consumes the note on a chain charging a verification base fee, paying the fee via a TX_FEE note from its own vault. Covered: P2ID; P2IDE claim/reclaim; SWAP public/private payback; PSWAP full/partial fill; MINT on fungible/non-fungible network faucets; BURN; FAUCET_POLICY_ACTION, PAUSE_ACTION, OWNER_ACTION, RBAC_ACTION (one representative selector each); FEE_SPONSORSHIP with-feature/reclaim; and fee-paying variants of CLAIM L1/L2, B2AGG (empty and populated frontier), plus CONFIG_AGG_BRIDGE, DEREGISTER_AGG_FAUCET, UPDATE_GER and REMOVE_GER consumed by the bridge account. The pre-existing zero-fee CLAIM/B2AGG scenarios are unchanged. All fixtures draw randomness exclusively from the chain builder's seeded RNG; two consecutive runs produce a byte-identical bench-tx.json. The MINT scenarios inject an explicit vault asset witness for the minted asset: the executor only pre-fetches witnesses for input-note assets, so minting from a faucet whose vault is non-empty (here: holding the fee asset) otherwise fails during asset-preservation checks. Worth an upstream fix in miden-tx eventually, since any fee-paying network faucet has a non-empty vault. part of #3344 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds checked-in, generated cost tables exposing each standard and agglayer note's consumption cost in VM cycles: the total cycle count of the canonical network-account transaction consuming the note, maximum across its benchmarked execution paths. Standards costs live in miden_standards::note::costs, agglayer costs in miden_agglayer::costs; both modules are plain constants and no_std-clean. The tables are written by the bench-transaction binary via the new update-note-costs argument (make update-note-costs), which runs the full benchmark suite and then renders the per-note maxima, so bench-tx.json and the tables are always regenerated together. Costs are denominated in cycles, not fee units, because the fee (verification_base_fee * (ilog2(cycles) + 1)) depends on a block-header parameter that can change independently of these measurements. Freshness is enforced by snapshot tests running in the regular CI test suite: one rstest case per priced note re-executes its scenarios and compares the measured maximum against the compiled-in constant, failing with a pointer to make update-note-costs on any drift. A companion test guards the scenario-to-note mapping so no consumption scenario silently goes unpriced. part of #3344 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Tighten the costs module docs in miden-standards and apply the suggested simplification in miden-agglayer (which defers to the standards module for the canonical-transaction definition). - Generated table docs are now succinct per-note summaries listing each benchmarked path with its cycle count and marking the maximum, e.g. "Cycles of consuming a P2IDE note: claim 13310, reclaim 13465 (maximum)."; single-path notes say so. Constant values unchanged. - Fix terminology: the consumers are network accounts with the required components (BasicWallet / faucet / management), not "network-authenticated wallets". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The exact-match check turned every cycle-shifting merge into the base branch into a CI failure for all open PRs (as happened when a fee change on next moved every scenario by 0.2-1.5%). The snapshot tests now fail only when a measured cost diverges from its checked-in constant by more than 5% in either direction. Fee-wise the tolerance is safe: the fee is logarithmic in cycles and the pricing safety margin (one verification cycle by default, roughly a 2x cycle headroom) dwarfs the tolerated drift. Meaningful shifts still fail with a pointer to make update-note-costs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the canonical network-account consumption scenario for the NETWORK_ACCOUNT_CONFIG note introduced by #3330: an owner-controlled network account (AuthNetworkAccount, which auto-allowlists the config note root, plus Ownable2Step authority) consumes a config note carrying the representative AddAllowedNoteScript action and pays the fee. Regenerating the tables also refreshes all other constants to the current base (the drift from the recent next merge is reset to zero), and bench-tx.json gains the new scenario. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PhilippGackstatter
left a comment
There was a problem hiding this comment.
Looks good! I think this doesn't cover all cases such as user-defined mint policies for the MintNote, but it's a good start.
| /// Returns the transaction context for a network ownable account consuming an OWNER_ACTION note. | ||
| /// | ||
| /// The account carries the `Ownable2Step` component owned by the owner wallet (mirrors | ||
| /// `create_ownable_account` in the `ownable2step` test suite). The benchmarked action is | ||
| /// `TransferOwnership`, nominating a new owner. | ||
| pub fn tx_consume_owner_action_note_network() -> Result<TransactionContext> { |
There was a problem hiding this comment.
For the notes that support multiple actions, we should run the most expensive operation for estimation. Not necessarily for this PR, but seems important for actually using the resulting values for the fee policy.
| let token_policy_manager = TokenPolicyManager::builder() | ||
| .active_mint_policy(MintPolicy::owner_only()) | ||
| .active_burn_policy(BurnPolicy::allow_all()) | ||
| .active_send_policy(TransferPolicy::allow_all()) | ||
| .active_receive_policy(TransferPolicy::allow_all()) | ||
| .build(); | ||
|
|
||
| let account_builder = AccountBuilder::new(builder.rng_mut().random()) | ||
| .account_type(AccountType::Public) | ||
| .with_asset_callbacks(AssetCallbackFlag::from(token_policy_manager.has_transfer_policy())) | ||
| .with_component(faucet) | ||
| .with_components(AccessControl::Ownable2Step { owner: owner_account_id }) | ||
| .with_components(token_policy_manager) | ||
| .with_component(Pausable::unpaused()) | ||
| .with_component(PausableManager) | ||
| .with_assets([super::fee_funding_asset()?]); | ||
|
|
||
| builder.add_account_from_builder( | ||
| super::network_auth([MintNote::script_root(), BurnNote::script_root()]), | ||
| account_builder, | ||
| AccountState::Exists, | ||
| ) |
There was a problem hiding this comment.
nit: Might be worth deduplicating with add_existing_network_faucet and adding a add_existing_network_faucet_with_assets to the mock chain builder.
| mock_chain | ||
| .build_tx_context(faucet.id(), &[mint_note.id()], &[])? | ||
| .extend_advice_inputs(minted_asset_witness(&faucet, mint_asset.id())) | ||
| .build() |
There was a problem hiding this comment.
Why do we need to add this custom witness here?
| // Sponsorship path: the network account consumes the fee-unaware feature note together | ||
| // with the FEE_SPONSORSHIP note paying for it, collecting the sponsored fee via an | ||
| // allowlisted transaction script. | ||
| let collect_script = collect_fee_tx_script(sponsored_asset)?; | ||
|
|
||
| let sponsor = builder.add_existing_wallet(Auth::basic_ecdsa())?; | ||
| // The feature note is completely fee-unaware; P2ANY stands in for a real network note. | ||
| let feature_note = builder.add_p2any_note(sponsor.id(), NoteType::Public, [])?; | ||
|
|
||
| let network_account = builder.add_existing_wallet_with_assets( | ||
| Auth::NetworkAccount { | ||
| allowed_script_roots: BTreeSet::from([ | ||
| feature_note.script().root(), | ||
| FeeSponsorshipNote::script_root(), | ||
| ]), | ||
| allowed_tx_script_roots: BTreeSet::from([collect_script.root()]), |
There was a problem hiding this comment.
Why do we still need the fee collection tx script now that fee collection was integrated into AuthNetworkAccount?
partylikeits1983
left a comment
There was a problem hiding this comment.
Looks good! Approving granted the minor issues I pointed out are fixed.
This PR forced me to re-understand the number of cycles it takes to consume a B2AGG note which was fun :)
| //! Each constant is the number of VM cycles of the canonical network-account transaction | ||
| //! consuming the note, measured by the `bench-transaction` binary: an account authenticated | ||
| //! with [`AuthNetworkAccount`](crate::account::auth::AuthNetworkAccount) (carrying the | ||
| //! components the note requires) consumes the note on a fee-charging chain, so the measured | ||
| //! cycles include the allowlist checks and TX_FEE note creation. | ||
| //! | ||
| //! The values are denominated in cycles rather than fee units, since the fee | ||
| //! (`verification_base_fee * (ilog2(cycles) + 1)`) depends on a block-header parameter. | ||
| //! | ||
| //! The table is regenerated with `make update-note-costs`; a snapshot test in | ||
| //! `bench-transaction` fails CI when a checked-in value drifts more than 5% from the measured | ||
| //! one (small drift from unrelated changes is tolerated - the pricing safety margin dwarfs | ||
| //! it). |
There was a problem hiding this comment.
I would mention here that the values are an estimate, and that its actually not possible to correctly price consumption of a note that has branches. I'd be very explicit about this in the docs.
For example, even with the P2ID note, it can contain up to 64 assets, and on consumption it will loop through N number of assets. Although the percentage of P2ID and P2IDE notes which will have 64 assets is low, maybe we should price the worst case anyways.
Edit: I actually think its pretty important to price a P2ID & P2IDE note with consumption of 64 assets, otherwise this opens a griefing vector (dependent on the fee policy).
| pub const fn all() -> &'static [ExecutionBenchmark] { | ||
| &[ | ||
| ExecutionBenchmark::ConsumeSingleP2IDFalcon, | ||
| ExecutionBenchmark::ConsumeSingleP2IDEcdsa, | ||
| ExecutionBenchmark::ConsumeTwoP2IDFalcon, |
There was a problem hiding this comment.
If someone adds a new variant but forgets to list it here nothing will fail. A simple guard would be to assert all().len() against the number of ExecutionBenchmark variants, that way forgetting to add a variant becomes a compile error instead of silently not pricing a note.
| write_bench_results_to_json(path, benchmark_results)?; | ||
|
|
||
| if update_costs { | ||
| bench_transaction::note_costs::update_cost_tables().await?; |
There was a problem hiding this comment.
update-note-costs executes every priced scenario twice. Once in the loop above (line 41), then again inside update_cost_tables (note_costs.rs:275 re-runs benched_path_cycles per note). Reusing the measurements from the first pass would halve regeneration time.
| /// Cycles of consuming a CLAIM note: L1 origin 44275, L2 origin 57256 (maximum). | ||
| pub const CLAIM_CONSUMPTION_CYCLES: u64 = 57256; | ||
|
|
||
| /// Cycles of consuming a B2AGG note: empty frontier 141492 (maximum), 2^31-1 leaves 76890. |
There was a problem hiding this comment.
I spent some time double checking this.
Just as a note, the cycle count for B2AGG notes will oscillate dependent on the leaf count. The number of writes equals the number of 0-bits in its count. So essentially the # of cycles of a B2AGG note follows this:
- note #1: count 0 (binary all zeros) → 32 writes → the maximum, never repeated
- note #2: count 1 (1) → 31 writes → slightly cheaper
- note #3: count 2 (10) → 31 writes → same as #2
- note #4: count 3 (11) → 30 writes → cheaper
- note #5: count 4 (100) → 31 writes → more expensive again
Part of #3344
Benchmarks the cost of consuming every note & its execution path in
miden-standardsandmiden-agglayerand checks the results in as generated cost tables.Most of the diff is for benching context setup - which I frankly have skimmed over, as it's not an important part - and we can improve on later. The generated values in terms of cycle counts look correct.
The actual modifications to other crates are pretty small and should be reviewed carefully (mostly the structure of where things live - and potentially it's worth taking a brief look at #3356 which will be the user of those tables)