feat(pxe): hash-pinned node read cache - #24969
Conversation
…/rpc-optimizations
| private readonly ephemeralArrayService = new EphemeralArrayService(); | ||
| protected readonly transientArrayService: TransientArrayService; | ||
| private readonly aztecNodeReadCache: AztecNodeReadCache; | ||
| readonly #txReceipts = new Map<string, Promise<TxReceipt<{ includeTxEffect: true }>>>(); |
There was a problem hiding this comment.
Like we say below, receipts are not associated to a specific block hash. They can change over time, even if the anchor block hasn't moved. So we don't cache them at the general PXE cache level. But we were caching them here before in the AztecNodeReadCache, so it makes sense to continue to do that
| * Returns the execution statistics collected during the simulator run. | ||
| * @returns The execution statistics. | ||
| */ | ||
| getStats() { |
There was a problem hiding this comment.
I moved this away from the contract simulator, into the node itself. Felt cleaner
| // computationally demanding that it'd be rare for someone to try to do it concurrently regardless. | ||
| return this.#putInJobQueue(async jobId => { | ||
| const totalTimer = new Timer(); | ||
| const recording = this.node.startRecording(); |
There was a problem hiding this comment.
We now have more control about what we record, so it's a great opportunity to discuss how we were calculating benchmarks before, and what we can do now.
What the stats measured before
nodeRPCCalls came from a wrapper owned by the contract-function simulator, so it covered only the reads the simulator itself issued: membership witnesses, leaf indexes, public storage, blocks fetched during execution, and note discovery (log and receipt queries run from execution oracles). Everything else in the same operation read the node directly and was invisible: block sync, ContractSyncService and ContractClassService (all the getContract and class-id traffic), kernel proving, public simulation and tx validation.
What they measure now
One recording on the shared node wrapper spans the whole operation, from before block sync until the operation returns. It covers every read the operation triggers, and only reads the node actually answered: a cache-served read appears nowhere, and a batch served entirely from cache is not a round trip. This is why absolute rpc and round_trips values will jump relative to dashboard history even though actual node traffic went down.
Alternatives, if we prefer a narrower window
- Start the recording after block sync. The metric becomes a function of the tx alone, since sync cost varies with how stale the PXE is. The downside is that
timings.syncstays inside the same stats object while its RPC cost goes unreported, and the sync reads the cache saves disappear from the metric. - Start it at simulation, reproducing the old window. Comparable with dashboard history, but it hides kernel proving, contract sync and validation, which is where much of the duplication lives.
- Keep two windows (sync and the rest) and report both. Full visibility with stable per-tx numbers, at the cost of a second window in the
NodeStatsschema.
BEGIN_COMMIT_OVERRIDE fix(pxe): validate a BoundedVec against its storage array on deserialization (AztecProtocol#25035) chore: add disclaimers on poc contracts (AztecProtocol#24975) chore: begin nr constant cleanup (AztecProtocol#25014) fix(txe): authorize sync_state utility calls in inlined contexts (AztecProtocol#25034) refactor(stdlib): a function's return type is a single optional AbiType (AztecProtocol#25066) feat(pxe): hash-pinned node read cache (AztecProtocol#24969) feat(noir-projects): publish compiled protocol artifacts to npm (AztecProtocol#25075) fix(ci): trim GitHub commit API response in upload_benchmarks to avoid E2BIG on large merge commits (AztecProtocol#25077) END_COMMIT_OVERRIDE
|
❌ Failed to cherry-pick to |
Merge origin/v5-next into the backport staging branch. The staging branch was 104 commits behind v5-next, which is the sole reason the automatic backport of #24969 failed.
(cherry picked from commit 1b34562)
Promotes `v5-next` onto `v5` for the **v5.2.0** release. Frozen at `ee5d2d367e` — the `v5-next` tip at cut time. Both `merge-train/spartan-v5` and `merge-train/fairies-v5` are drained to that same commit, so nothing staged is left behind. ### Testnet validation This branch was cut at `a4db216abf`, which is byte-identical to the `v5.2.0-nightly.20260815` tag — same commit, same tree (`0b22572eace2419ede6bbab173514d6965d4e0d0`). That nightly's CI3 run is green, its artifacts are published to npm and Docker Hub, and it has been **running healthily on testnet since 2026-08-15**: | Component | Image / reported version | |---|---| | validators, prover node, prover broker, prover agents (`testnet` ns) | `aztecprotocol/aztec:5.2.0-nightly.20260815` | | `v5.testnet.rpc.aztec-labs.com` | `nodeVersion=5.2.0-nightly.20260815` | | `canonical.testnet.rpc.aztec-labs.com` | `nodeVersion=5.2.0-nightly.20260815` | Chain advancing normally, no prunes or reorgs, no WARN/ERROR across node pods since rollout. The branch has since been fast-forwarded to `ee5d2d367e` to pick up #25242 (configurable RPC server HTTP timeouts and CORS allowed-headers). That is the only delta from the soaked tree — 8 files, +131/−10, no nightly covers it yet. Its config defaults were checked against Node's built-ins (`keepAliveTimeout` 5000 ms, `headersTimeout` 60000 ms) and match exactly, and the CORS default path resolves to the same `cors()` call as before, so a node that sets none of the new env vars behaves identically. It touches no circuits, protocol contracts, or generated constants. ### Manifest `.release-please-manifest.json` reads `5.2.0` on this branch, which is the released version — correct as-is, no change needed in this PR. `v5-next` moves to `5.3.0` separately in #25240; this branch is frozen and cannot pick that up, so the two can merge in either order. `v5` is a strict ancestor of `v5-next` this cycle, so there was no manifest conflict to pre-resolve. ### Scope 139 commits (86 non-merge, 23 PR-level) spanning 2026-07-14 to 2026-08-17. | Area | Theme | |---|---| | `prover-node` / `prover-client` | Epoch-proving robustness: retry-to-converge with failure declared only at submission-window expiry, ticker-driven expiry, per-checkpoint post-mortem upload, checkpoint-only re-proving, prune-induced fault handling (#24678, #24982, #24983, #24990, #25027) | | `pxe` | Sync performance: hash-pinned node read cache (#24969), anchor-bounded tag log caching (#25074), note/event validation from cached tx data (#25076), constrained tag sync (#24275), sender tagging finalization from log blocks (#25045) | | `p2p` / `validator-client` | Gossip tx validation no longer stalls behind tx-pool finalization (#25148); startup fails when p2p fails to start (#25177); slashing/proposing/health/`sendTx` gated on p2p connectivity (#25185); duplicate time-sensitive proposal validation removed (#25207); `ValidatedProposal` branding (#25222) | | `ethereum` / `aztec-node` | L1 watchers poll `getLogs` instead of `eth_newFilter` (#25176); block stream sync no longer resolves against an earlier pass (#25206) | | `stdlib` / `foundation` | Deserialization bounds hardening (#25026, #25028, #25029, #25109); checkpoint block-shape and block-count validation (#25229); JSON-RPC cookies (#25231) | | `archiver` | Removed-block cleanup and ownership-checked tx-effect deletes (#24765); L2→L1 witness from a single store snapshot (#24754) | | `aztec` | Declarative deployment framework at `@aztec/aztec/deploy` (#24685) | | `slasher` | Own-validator slash-target warnings and metrics (#25058) | | telemetry | JSON-RPC metrics (#25159) | | JSON-RPC server | Configurable HTTP keep-alive / headers timeouts and CORS allowed-headers, defaults preserving current behaviour (#25242) | | toolchain | Noir bumped to `v1.0.0-beta.25` (#24907) | **One breaking change**, inherited from the Noir bump: note types declared directly inside a `contract` module must now be `pub`. Everything else is additive or internal. ### Protocol constants Built from source and compared against `v5` — a cache-free rebuild of every protocol circuit with each ref's own nargo and bb, then regeneration of `vk_tree.ts` and `protocol_contract_data.ts`: - `vkTreeRoot` = `0x2b3b6ea4412b9c8f6457a37f91a2870306f8641e07e16a49b68bda6f8bc02892` — unchanged from v5.1.0 - `protocolContractsHash` = `0x2c075866eafc88a1f6f9addc7e337c6e64e45d1cb7fd7c0d612ebcec72aab2ca` — unchanged from v5.1.0 The Noir beta.24 → beta.25 bump does not reach the circuits: the release build consumes the committed `pinned-build.tar.gz`, which is bit-identical between v5.1.0 and this commit (blob `3bedcb1fd1…`), so the protocol-circuit bytecode is frozen rather than recompiled. The 47 verification keys were recomputed locally from that pinned bytecode with the cache disabled, and all 47 `check_pinned_vk` checks passed. `protocolContractsHash` is likewise backed by the new `pinned-protocol-contracts.tar.gz`, whose three artifacts were byte-compared against the build. Both values were also confirmed inside the published `@aztec/protocol-contracts` and `@aztec/noir-protocol-circuits-types` packages for `5.2.0-nightly.20260815`. That makes v5.2.0 a drop-in upgrade against the current rollup rather than a coordinated one. ✅ **Gate re-run against `ee5d2d367e` (the current head) and passed** — both values reproduced exactly from a build at this commit, with `vkTreeRoot` numerically evaluated rather than inferred. `29556326ce..ee5d2d3` touches no `noir-projects/**`, `l1-contracts/src/**`, `ConstantsGen.sol`, `constants.gen.ts` or `constants.nr`. Scope of the check: it verifies that the pinned circuit bytecode plus locally recomputed VKs agree with the pin — not that a from-source recompile reproduces v5.1.0's bytecode. <details> <summary>Commits added after the original constants check at 2955632</summary> `#25207` `#25222` `#25206` `#25185` `#25176` `#25163` `#25229` `#25159` `#25162` `#25231` `#25224` `#25228` `#25230` `#25242` plus their merge commits. </details> ### Known gap Migration notes carry entries under `## TBD` that arrived with the docs baseline backport (#25017) and describe `next`-line changes not present on the v5 line — protocol contracts removed from `@aztec/noir-contracts.js`, and the `at(wallet)` → `withWallet(wallet)` deprecation. Neither exists on this branch. There is also no `## 5.1.0` heading, and the entries that ship in v5.2.0 sit under `## TBD` rather than a version heading. This ships in the release docs, so it is worth correcting on `v5-next` and backporting to `v5` before the tag, rather than after.
Motivation
Client flows repeat identical node reads: simulation, witness generation and kernel proving fetch the same membership witnesses, leaf indexes and public storage over and over, and separate PXE services re-fetch what a simulator just read. Every duplicate is a serial RPC round trip on the critical path of proving.
Code today
PXE already deduplicates some of this, but through two caches with their own scope and rules:
ContractClassServicekeeps a class-id cache keyed by(address, anchorHash).AztecNodeReadCachethat memoizes witnesses, storage, blocks and receipts for that one execution.Nothing is shared across consumers: kernel proving re-reads what simulation just fetched, and every execution starts cold.
Our change
withCache(node): anAztecNodewrapper every PXE consumer reads through, backed by one shared cache. Only reads pinned to a block hash are cached. Hash-named content is immutable, so a repeat can never change, which makes the cache safe to share across consumers on different anchors. Reads by block number or tag (like"latest") pass through, and concurrent identical reads share one request.BlockSynchronizerwipes the cache when the anchor block advances. Wiping only bounds memory; correctness never depends on it.Important note: profiling stats are recorded differently now, and their absolute values will jump relative to dashboard history. See the discussion in the comments of this PR.
Metrics
Measured at the node: an identical counting wrapper between the benchmarking wallet and the in-process node, applied to both base and this branch, so the numbers are independent of either side's instrumentation. RPC calls count every request the node serves; round trips count how many times the client blocks waiting on the node (concurrent requests are many calls but one round trip), so client-perceived latency scales with round trips.
Change impact in benchmarks
Per RPC
findLeavesIndexesgetPublicStorageAtgetNoteHashMembershipWitnessgetPublicDataWitnessgetNullifierMembershipWitnessgetPrivateLogsByTagsgetContractgetTxReceiptThe savings land on witnesses, leaf indexes and storage.
getContractandgetTxReceiptstay at their floor because the caches this PR replaces already deduplicated them.getPrivateLogsByTagsis tag-addressed and uncacheable by hash today: extending the cache to reference-block-bounded log queries is the natural follow-up.Per scenario
The bridging flow is omitted because it profiles through the cross-chain harness wallet, outside the counter.
transfer_0_recursions+private_fpctransfer_1_recursions+private_fpctransfer_0_recursions+sponsored_fpctransfer_1_recursions+sponsored_fpcamm_add_liquidity_1_recursionsdeploy_account_ecdsar1deploy_account_schnorrdeploy_token_ecdsar1deploy_token_schnorrstorage_proof_7_layersEntire benchmarks
Each suite runs its own wallet and PXE; within a suite, the cache lives across setup, sync and every tx. Counting each suite's whole run, the improvement is larger than per scenario, because later operations reuse reads earlier ones already paid for — the longest-lived suite (transfers, four profiled txs on one wallet) improves the most.
The benchmarks advance the anchor between interactions, wiping the cache at the fastest possible cadence, so sessions doing repeated operations against one anchor reuse strictly more.
Cool finding
Running this branch with the cache disabled entirely measures the value of the caching layer as a whole — the unified cache plus the base caches it replaced — rather than this PR's delta:
amm_add_liquidityalonegetContract