Skip to content

feat(pxe): hash-pinned node read cache - #24969

Merged
nchamo merged 6 commits into
merge-train/fairiesfrom
nchamo/rpc-optimizations
Jul 31, 2026
Merged

feat(pxe): hash-pinned node read cache#24969
nchamo merged 6 commits into
merge-train/fairiesfrom
nchamo/rpc-optimizations

Conversation

@nchamo

@nchamo nchamo commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

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:

  • ContractClassService keeps a class-id cache keyed by (address, anchorHash).
  • Each utility execution gets an AztecNodeReadCache that 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): an AztecNode wrapper 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.
  • Simulation, kernel proving, contract sync and utility execution now share each other's reads. The class-id cache is deleted, and the per-execution cache is reduced to receipts: the one thing it held that is not immutable (pending → mined → finalized), so it cannot join the shared cache.
  • The BlockSynchronizer wipes 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

RPC call Node calls
findLeavesIndexes 52 → 35 (−32.7%)
getPublicStorageAt 11 → 7 (−36.4%)
getNoteHashMembershipWitness 26 → 22 (−15.4%)
getPublicDataWitness 44 → 39 (−11.4%)
getNullifierMembershipWitness 19 → 17 (−10.5%)
getPrivateLogsByTags 108 → 108
getContract 40 → 40
getTxReceipt 22 → 22
everything else 60 → 60
Total 382 → 350 (−8.4%)

The savings land on witnesses, leaf indexes and storage. getContract and getTxReceipt stay at their floor because the caches this PR replaces already deduplicated them. getPrivateLogsByTags is 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.

Flow RPC calls Round trips
transfer_0_recursions+private_fpc 66 → 56 (−15.2%) 41 → 36 (−12.2%)
transfer_1_recursions+private_fpc 66 → 56 (−15.2%) 38 → 32 (−15.8%)
transfer_0_recursions+sponsored_fpc 36 → 35 (−2.8%) 27 → 26 (−3.7%)
transfer_1_recursions+sponsored_fpc 39 → 37 (−5.1%) 24 → 22 (−8.3%)
amm_add_liquidity_1_recursions 69 → 63 (−8.7%) 41 → 37 (−9.8%)
deploy_account_ecdsar1 21 → 21 18 → 18
deploy_account_schnorr 17 → 14 (−17.6%) 11 → 11
deploy_token_ecdsar1 21 → 21 18 → 18
deploy_token_schnorr 22 → 22 16 → 16
storage_proof_7_layers 25 → 25 19 → 19
Total 382 → 350 (−8.4%) 253 → 235 (−7.1%)

Entire 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.

Suite RPC calls Round trips
transfers 267 → 220 (−17.6%) 181 → 152 (−16.0%)
amm 73 → 67 (−8.2%) 45 → 41 (−8.9%)
account_deployments 41 → 38 (−7.3%) 32 → 32
deployments 46 → 46 37 → 37
storage_proof 28 → 28 22 → 22
Total 458 → 402 (−12.2%) 320 → 287 (−10.3%)

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:

Scope RPC calls Round trips
Whole package, cache off → on 777 → 356 (−54.2%) 622 → 222 (−64.3%)
amm_add_liquidity alone 131 → 62 (−52.7%) 103 → 36 (−65.0%)
of which getContract 70 → 7 (−90.0%)

@nchamo nchamo self-assigned this Jul 24, 2026
@nchamo nchamo added ci-draft Run CI on draft PRs. ci-no-fail-fast Sets NO_FAIL_FAST in the CI so the run is not aborted on the first failure labels Jul 24, 2026
@nchamo
nchamo marked this pull request as ready for review July 24, 2026 17:21
@nchamo
nchamo marked this pull request as draft July 24, 2026 17:23
@nchamo nchamo changed the title feat(pxe): anchor-scoped node read cache feat(pxe): hash-pinned node read cache Jul 30, 2026
private readonly ephemeralArrayService = new EphemeralArrayService();
protected readonly transientArrayService: TransientArrayService;
private readonly aztecNodeReadCache: AztecNodeReadCache;
readonly #txReceipts = new Map<string, Promise<TxReceipt<{ includeTxEffect: true }>>>();

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.

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() {

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.

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();

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.

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.sync stays 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 NodeStats schema.

@nchamo
nchamo requested review from Thunkar and nventuro July 30, 2026 22:47
@nchamo
nchamo marked this pull request as ready for review July 30, 2026 22:47

@Thunkar Thunkar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Love it!

@nchamo nchamo changed the title feat(pxe): hash-pinned node read cache refactor(pxe): hash-pinned node read cache Jul 31, 2026
@nchamo nchamo changed the title refactor(pxe): hash-pinned node read cache feath(pxe): hash-pinned node read cache Jul 31, 2026
@nchamo nchamo changed the title feath(pxe): hash-pinned node read cache feat(pxe): hash-pinned node read cache Jul 31, 2026
Comment thread yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts Outdated
Comment thread yarn-project/pxe/src/node/caching_aztec_node.ts Outdated
Comment thread yarn-project/pxe/src/node/caching_aztec_node.ts Outdated
Comment thread yarn-project/pxe/src/node/caching_aztec_node.ts Outdated
Comment thread yarn-project/pxe/src/node/caching_aztec_node.ts Outdated
Comment thread yarn-project/pxe/src/node/caching_aztec_node.ts Outdated
@nchamo
nchamo enabled auto-merge (squash) July 31, 2026 23:34
@nchamo
nchamo merged commit 1b34562 into merge-train/fairies Jul 31, 2026
12 checks passed
@nchamo
nchamo deleted the nchamo/rpc-optimizations branch July 31, 2026 23:58
rangozd pushed a commit to rangozd/aztec-packages that referenced this pull request Aug 5, 2026
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
@AztecBot

AztecBot commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

❌ Failed to cherry-pick to v5-next due to conflicts. (🤖) View backport run.

AztecBot added a commit that referenced this pull request Aug 5, 2026
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.
AztecBot pushed a commit that referenced this pull request Aug 5, 2026
aminsammara added a commit that referenced this pull request Aug 17, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-to-v5-next ci-draft Run CI on draft PRs. ci-no-fail-fast Sets NO_FAIL_FAST in the CI so the run is not aborted on the first failure

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants