[r3.6] execution/stagedsync: skip the fee credit when the recorded set already carries it - #23131
[r3.6] execution/stagedsync: skip the fee credit when the recorded set already carries it#23131AskAlexSharov wants to merge 5 commits into
Conversation
…dy carries it The apply loop re-credits every tx once per validation round, and a tx is revalidated whenever an earlier tx's write set moves under it. calcFees rebuilt an identical write set each round and the caller merged it back in, so a credit that never changed cost a WriteSet, four VersionedWrites, an Account, an O(tx writes) MergeInto and a ReleaseMaps every time. calcFees now takes the set an earlier fee merge produced for the tx and returns nothing when that set already carries the exact credit. The comparison covers the balance value, version and reason plus the AddressPath account, so a moved base balance or a half-recorded credit still re-credits. Comparing against feeMergeTemp rather than TxOut is what makes this safe: calcFees reads TxOut as the pre-credit balance, so folding into it re-adds the tip every round. BenchmarkCalcFees, 200000x n=6: redundant_recredit 313ns -> 92ns, 10 -> 2 allocs/op, 928B -> 192B/op. The first credit of a tx is unchanged.
|
Same change on |
There was a problem hiding this comment.
Pull request overview
This PR optimizes Erigon’s parallel execution staged sync fee-crediting by avoiding redundant rebuild/merge of the same fee-credit write set across validation rounds when the already-recorded write set is known to carry the exact credit.
Changes:
- Extend
execResult.calcFeesto accept an optional “already-credited recorded set” and short-circuit when the required coinbase/burnt credits are already present with the expected version/reason and requiredAddressPathsibling. - Refactor fee-credit write emission into a shared
feeEntryhelper (coinbase + burnt) and adjustfeeAddressAccountto return a value type. - Add targeted unit tests + a benchmark to validate/measure redundant re-credit skipping and
creditedWritesbehavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
execution/stagedsync/exec3_parallel.go |
Adds creditedWrites plumbing and short-circuit logic in calcFees, plus refactors fee-credit write construction into feeEntry. |
execution/stagedsync/exec3_finalize_test.go |
Updates existing finalize tests for the new calcFees(..., credited) signature. |
execution/stagedsync/exec3_fee_credit_test.go |
Adds new tests/benchmark covering redundant re-credit skipping, re-credit triggers, and creditedWrites pointer-identity gating. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Maintain CollectorWrites before the skip so the skipping and emitting paths leave the same state behind for the round. feeEntry.emit is now the only gate on whether an entry is written, the emptied-coinbase predicate is named once instead of spelled out twice, and the methods take a pointer so the entry is not copied per call. The benchmark never returned the emitted set, so the pools it checks maps out of stayed cold and the emit arm was measured against an allocation the apply loop does not pay. Releasing the maps each iteration puts the baseline at 202ns/544B/6 allocs, so the redundant round saves 54%, not 69%. Tests: the changed-balance case now mutates only the balance, the London case uses londonTransferScenario instead of rebuilding an inconsistent one, and a new test drives the re-execution invalidation through VersionedIO rather than hand-placed feeMergeTemp entries.
There was a problem hiding this comment.
Non-blocking follow-ups:
- Make fee-credit provenance explicit on re-execution: clear feeMergeTemp[tx] when RecordWrites installs a new TxOut, or document pointer identity as a VersionedIO contract.
- Put CollectorWrites updates and unused feeEntry/account construction behind the confirmed no-op check where possible; the EIP-161 delete path still allocates on skipped rounds.
- Add coverage through nextResult, including re-execution and EIP-161 deletion, and directly pin recordedIn/writeTo agreement instead of relying only on the hand-mirrored feeCreditRound fold.
recordWorkerWrites replaces the two RecordWrites calls in the result path so that installing a new worker output also clears feeMergeTemp[tx]. The skip no longer relies on the recorded-set pointer changing underneath it to notice a re-execution; provenance is dropped where it is lost. Move the CollectorWrites updates back below the no-op check and build each feeEntry only when it is emitted. An unchanged credit leaves CollectorWrites holding the identical values an earlier round put there, so maintaining it on a skipped round is pure cost — and for an EIP-161 emptied coinbase SetAccountBalanceOrDelete allocates a VersionedWrite every call. Cover the two paths that had none: the EIP-161 delete, and recordedIn against writeTo directly, so the pair cannot drift into a permanent skip or a permanent re-credit without a test failing. BenchmarkCalcFees, 200000x n=6: redundant_recredit 94ns -> 87ns against a 206ns first credit.
|
All three done. Provenance on re-execution. Work behind the no-op check. Both Coverage. Two new tests. The full
|
yperbasis
left a comment
There was a problem hiding this comment.
No correctness bug found; the requests below are lifecycle, hardening, and test items, by severity.
Medium
- exec3_parallel.go:2480-2482:
recordWorkerWritesdisplaces the credited merged set fromblockIO, then deletes its last owner fromfeeMergeTempwithoutqueueMapRelease, so each re-execution of an already-credited tx sends the merged set's pooled maps to GC.recordFeeMergereleases in the identical supersede case. Fix:if temp := be.feeMergeTemp[tx]; temp != nil { be.queueMapRelease(temp) }before the delete. This is safe: the temp is never anexecResult's TxOut (the merge site is gated on!tipWrites.IsEmpty()), and the caller finishes iteratingprevWritesbefore the call. - exec3_parallel.go:2035-2037: the skip is sound only because of three unstated guards — the fee merge runs only for
Err == nilresults, everyresults[tx]replacement bumps the incarnation (so a stale credit failsrecordedIn's Version pin), andCollectorWriteshas no consumer for regular txs on this branch. None of the three is asserted or documented; relaxing any one re-opens the silent-divergence class the PR body describes for the Absorb attempt. Consider keeping the credited-set provenance onexecResultinstead of the block-levelfeeMergeTemp, so replacingresults[tx]invalidates the credit by construction — or at least assert the guards. - exec3_fee_credit_test.go:251:
TestCreditedWritesAfterReExecutionsimulates re-execution with bareblockIO.RecordWrites, bypassingrecordWorkerWrites— removing thedelete(be.feeMergeTemp, tx)leaves the package suite green (mutation-tested). Add a white-box test:recordFeeMergethenrecordWorkerWrites, then assertfeeMergeTemp[tx]is gone andcreditedWrites(tx, oldMerged)returns nil. It would also pin the release requested above.
Low
- exec3_parallel.go:2770-2779: the anchor invariant (
feeMergeTemp[tx] == blockIO.WriteSet(txIndex)iff the set carries the credit) holds only whileRecordWrites+recordFeeMergestay adjacent and receive the same pointer pair, andcreditedWritesre-implements the same identity predicate.recordFeeMergehas one production caller: give ittxVersionand let it do theRecordWritesitself, symmetric withrecordWorkerWrites. - exec3_parallel.go:2039-2048: the
CollectorWritesguards re-checkcoinbaseEntry.emit/burntEntry.emitalthoughemitCoinbase/emitBurntgated the entries' construction just above, and the guard bodies use only the raw locals.if emitCoinbase/if emitBurntkeeps the predicate spelled once (theemitfield stays load-bearing forrecordedIn/writeTo). - exec3_fee_credit_test.go:53-63:
runreturnstipafterr.recorded.MergeInto(tip)has folded the recorded set into it, so the documented "credit calcFees produced" is really the merged union. Benign while the scenario's TxOut has no coinbase/burnt entries; a sender==coinbase variant would silently build fixtures from a worker write. Merge a clone, or fix the doc comment. - exec3_fee_credit_test.go:30:
newFeeCreditRoundis the package's 5th near-copy of the scenario→fixture wiring (cf.runFinalizeTx); a shared helper ontestFinalizeScenariowould keep the two test families' fixtures identical.
Nit
- No main twin: main has no
CollectorWritesand a 4-argcalcFees, so a straight cherry-pick won't apply. Worth a note in the PR body on the forward-port plan. - PR body: "before either feeEntry is built" — both entries are built before the skip check (
recordedInneedse.acc). The allocation claim still holds (they are stack values); suggest "before any VersionedWrite or CollectorWrites entry is built".
…arnation recordWorkerWrites dropped the fee-merge product from feeMergeTemp without reclaiming it, so every re-execution of an already-credited tx sent the merged set's pooled maps to GC. feeMergeTemp now carries the version each credit was computed for, so a stale credit cannot be handed to calcFees even if it outlived the recorded set it lived in — the CollectorWrites entries an earlier round wrote belong to that same incarnation. recordFeeMerge does the MergeInto and the RecordWrites itself, which keeps the merge product and the tx's recorded write set from drifting apart and makes the "a temp is never an execResult's TxOut" ownership rule local to the one function that populates the map. The fee-credit tests move into exec3_finalize_test.go and exec3_fee_merge_temp_test.go, next to the fixtures and the blockExecutor white-box tests they belong with.
|
All eight items addressed on current head. Package suite green; Medium
Low
Nit — forward-port note and the "before any Still draft pending the n5 soak. |
…re accounts by Equals A zero feeEntry read as "already recorded", so a construction site that forgot emit would drop the credit with no error and no failing test. A nil *feeEntry is the absent entry now, and the deleted arm no longer carries the acc and reason it never writes. recordedIn compared the AddressPath value by struct equality, which also folds in Root and PrevIncarnation. Equals is what feeAddressAccount actually fills, so the skip cannot stop firing on a field neither side sets. The deleted arm keys on version and value alone, and a worker's own SELFDESTRUCT carries this same version, so it reads as the credit. That is harmless — the entry would have written that identical delete — but the comment claimed a fence that is not there, and the test built an unrealistic zero-version write to assert it. Both now state what the arm does.
The apply loop re-credits every tx once per validation round — 4-5 at chaintip, ~30 in catch-up. The credit only moves when an earlier tx's writes moved under it, so most rounds rebuilt an identical write set and merged it back in: a
WriteSet, fourVersionedWrites, anAccount, an O(tx writes)MergeIntoand aReleaseMapseach time.calcFeesnow takes the set an earlier fee merge produced for the tx and returns nothing when that set already carries the exact credit — balance value, version and reason, plus theAddressPathaccount.Why compare against
feeMergeTempand notTxOut.calcFeesreadsTxOutas the pre-credit balance, so folding into it re-adds the tip every round. That is whatWriteSet.Absorbdid onalex/fee_merge_in_place_37: 715 Wrong-trie-root errors on n5 within a minute, unit suites green throughout.feeMergeTemp[txIndex]is a separate containercalcFeesnever reads, and it stops matching the recorded write set the moment a tx re-executes.Each entry also carries the version it credited, so a credit cannot outlive the incarnation it was computed for — which is the same incarnation whose
CollectorWritesthe skipped round leaves in place.recordFeeMergeowns the merge (MergeInto+RecordWrites+ the temp), so the merge product and the tx's recorded write set cannot drift apart, and both it andrecordWorkerWritesreclaim the set they displace.feeEntryalso folds together the coinbase and burnt emission, which was the same code written twice.A nil
*feeEntryis the address a fee adjustment does not touch, so a zero value cannot read as "already recorded".recordedIncompares theAddressPathaccount byEqualsrather than struct equality, which would also fold inRootandPrevIncarnation— fieldsfeeAddressAccountnever fills.The deleted arm keys on version and value alone, so a worker's own
SELFDESTRUCTat this version reads as the credit. That is harmless, since the entry writes that identical delete, but the comment previously claimed a version fence that is not there.TestFeeEntryDeletedMatchesAnyWriterAtThisVersionpins what the arm actually accepts.BenchmarkCalcFees, 200000x n=6:Both arms release the emitted set's maps, as
recordFeeMergedoes, so the pools are warm. The skipped round returns before theCollectorWritesupdate and before anyVersionedWriteorCollectorWritesentry is built, so an EIP-161 emptied coinbase no longer allocates on it.Forward port. main has no
CollectorWritesand a 4-argcalcFees, so this does not cherry-pick. #23132 is the hand-written twin, carrying the samecreditedparameter andfeeEntrysplit minus theCollectorWritesblock and the skip's ordering ahead of it. Nothing else differs between the two.Draft until an n5 soak with
grep -ci "Wrong trie root". TheAbsorbattempt above is why green unit suites are not sufficient evidence here.