From 3cec0c989bef44b750aeb164fb2e9f7715d3f366 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Thu, 6 Aug 2026 10:30:10 +0700 Subject: [PATCH 01/16] execution/state: assert Normalize can take the worker's read set instead of the domain --- execution/stagedsync/exec3_parallel.go | 12 +- execution/state/checked_reader.go | 159 +++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 execution/state/checked_reader.go diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index 64433cff662..690b339e998 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -2853,7 +2853,12 @@ func (be *blockExecutor) nextResult(ctx context.Context, pe *parallelExecutor, r } // Mirror txtask.go's genesis rules-clobber so empty allocs (AuRa ZeroAddress) survive. emptyRemoval := be.blockNum != 0 && pe.cfg.chainConfig.IsEIP161Enabled(be.blockNum) - normWrites, normErr := rawWrites.Normalize(be.versionMap, txVersion.TxIndex, resultIncarnation, stateReader, domainStorageKeys, emptyRemoval, pe.cfg.chainConfig.Aura != nil, txTask.Rules().IsAmsterdam) + // Experiment: serve Normalize's fallback reads from what the worker + // already recorded, and assert the domain agrees on every one. + normReader := state.NewCheckedStateReader( + state.NewVersionedStateReader(txVersion.TxIndex, be.blockIO.ReadSet(txVersion.TxIndex), be.versionMap, stateReader), + stateReader) + normWrites, normErr := rawWrites.Normalize(be.versionMap, txVersion.TxIndex, resultIncarnation, normReader, domainStorageKeys, emptyRemoval, pe.cfg.chainConfig.Aura != nil, txTask.Rules().IsAmsterdam) if domainKeysErr != nil { return nil, fmt.Errorf("[parallel] iterate storage prefix for block write normalization: %w", domainKeysErr) } @@ -3118,7 +3123,10 @@ func (be *blockExecutor) nextResult(ctx context.Context, pe *parallelExecutor, r } emptyRemoval := be.blockNum != 0 && pe.cfg.chainConfig.IsEIP161Enabled(be.blockNum) var normErr error - finalizeWrites, normErr = writes.Normalize(be.versionMap, finalVersion.TxIndex, finalVersion.Incarnation, reader, domainStorageKeys, emptyRemoval, pe.cfg.chainConfig.Aura != nil, pe.cfg.chainConfig.IsAmsterdam(tt.Header.Time)) + finalizeNormReader := state.NewCheckedStateReader( + state.NewVersionedStateReader(finalVersion.TxIndex, be.blockIO.ReadSet(finalVersion.TxIndex), be.versionMap, reader), + reader) + finalizeWrites, normErr = writes.Normalize(be.versionMap, finalVersion.TxIndex, finalVersion.Incarnation, finalizeNormReader, domainStorageKeys, emptyRemoval, pe.cfg.chainConfig.Aura != nil, pe.cfg.chainConfig.IsAmsterdam(tt.Header.Time)) if domainKeysErr != nil { return nil, fmt.Errorf("[parallel] finalize iterate storage prefix for block write normalization: %w", domainKeysErr) } diff --git a/execution/state/checked_reader.go b/execution/state/checked_reader.go new file mode 100644 index 00000000000..d649bee0810 --- /dev/null +++ b/execution/state/checked_reader.go @@ -0,0 +1,159 @@ +// Copyright 2024 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package state + +import ( + "bytes" + "fmt" + + "github.com/holiman/uint256" + + "github.com/erigontech/erigon/execution/types/accounts" +) + +// CheckedStateReader answers from want and asserts that got agrees, panicking +// on any disagreement. It exists to settle one question empirically: can +// Normalize take the values a worker already recorded in its read set instead +// of re-reading the domain on the apply loop? Every divergence is a case where +// it cannot, and the panic names it. +// +// Experiment only — the double read makes it strictly slower than either +// reader alone. +type CheckedStateReader struct { + want StateReader // read set -> versionMap -> domain + got StateReader // domain +} + +func NewCheckedStateReader(want, got StateReader) *CheckedStateReader { + return &CheckedStateReader{want: want, got: got} +} + +func mismatch(op string, addr accounts.Address, detail string) { + panic(fmt.Sprintf("checked reader: %s disagrees for %x: %s", op, addr.Value(), detail)) +} + +func (r *CheckedStateReader) ReadAccountData(address accounts.Address) (*accounts.Account, error) { + want, err := r.want.ReadAccountData(address) + if err != nil { + return nil, err + } + got, gotErr := r.got.ReadAccountData(address) + if gotErr != nil { + mismatch("ReadAccountData", address, fmt.Sprintf("domain errored (%v) while read set answered", gotErr)) + } + switch { + case want == nil && got == nil: + case want == nil || got == nil: + mismatch("ReadAccountData", address, fmt.Sprintf("presence differs: readset=%v domain=%v", want != nil, got != nil)) + case want.Nonce != got.Nonce || !want.Balance.Eq(&got.Balance) || + want.Incarnation != got.Incarnation || want.CodeHash.Value() != got.CodeHash.Value(): + mismatch("ReadAccountData", address, fmt.Sprintf( + "readset={n:%d bal:%s inc:%d ch:%x} domain={n:%d bal:%s inc:%d ch:%x}", + want.Nonce, want.Balance.String(), want.Incarnation, want.CodeHash.Value(), + got.Nonce, got.Balance.String(), got.Incarnation, got.CodeHash.Value())) + } + return want, nil +} + +func (r *CheckedStateReader) ReadAccountStorage(address accounts.Address, key accounts.StorageKey) (uint256.Int, bool, error) { + want, wantOK, err := r.want.ReadAccountStorage(address, key) + if err != nil { + return want, wantOK, err + } + got, gotOK, gotErr := r.got.ReadAccountStorage(address, key) + if gotErr != nil { + mismatch("ReadAccountStorage", address, fmt.Sprintf("domain errored (%v) while read set answered", gotErr)) + } + if wantOK != gotOK || !want.Eq(&got) { + mismatch("ReadAccountStorage", address, fmt.Sprintf( + "slot %x: readset={%s,found:%v} domain={%s,found:%v}", + key.Value(), want.String(), wantOK, got.String(), gotOK)) + } + return want, wantOK, nil +} + +func (r *CheckedStateReader) ReadAccountCode(address accounts.Address) ([]byte, error) { + want, err := r.want.ReadAccountCode(address) + if err != nil { + return nil, err + } + got, gotErr := r.got.ReadAccountCode(address) + if gotErr != nil { + mismatch("ReadAccountCode", address, fmt.Sprintf("domain errored (%v) while read set answered", gotErr)) + } + if !bytes.Equal(want, got) { + mismatch("ReadAccountCode", address, fmt.Sprintf("len readset=%d domain=%d", len(want), len(got))) + } + return want, nil +} + +func (r *CheckedStateReader) ReadAccountCodeSize(address accounts.Address) (int, error) { + want, err := r.want.ReadAccountCodeSize(address) + if err != nil { + return 0, err + } + got, gotErr := r.got.ReadAccountCodeSize(address) + if gotErr != nil { + mismatch("ReadAccountCodeSize", address, fmt.Sprintf("domain errored (%v) while read set answered", gotErr)) + } + if want != got { + mismatch("ReadAccountCodeSize", address, fmt.Sprintf("readset=%d domain=%d", want, got)) + } + return want, nil +} + +func (r *CheckedStateReader) HasStorage(address accounts.Address) (bool, error) { + want, err := r.want.HasStorage(address) + if err != nil { + return false, err + } + got, gotErr := r.got.HasStorage(address) + if gotErr != nil { + mismatch("HasStorage", address, fmt.Sprintf("domain errored (%v) while read set answered", gotErr)) + } + if want != got { + mismatch("HasStorage", address, fmt.Sprintf("readset=%v domain=%v", want, got)) + } + return want, nil +} + +func (r *CheckedStateReader) ReadAccountIncarnation(address accounts.Address) (uint64, error) { + want, err := r.want.ReadAccountIncarnation(address) + if err != nil { + return 0, err + } + got, gotErr := r.got.ReadAccountIncarnation(address) + if gotErr != nil { + mismatch("ReadAccountIncarnation", address, fmt.Sprintf("domain errored (%v) while read set answered", gotErr)) + } + if want != got { + mismatch("ReadAccountIncarnation", address, fmt.Sprintf("readset=%d domain=%d", want, got)) + } + return want, nil +} + +func (r *CheckedStateReader) ReadAccountDataForDebug(address accounts.Address) (*accounts.Account, error) { + return r.want.ReadAccountDataForDebug(address) +} + +func (r *CheckedStateReader) SetTrace(trace bool, tracePrefix string) { + r.want.SetTrace(trace, tracePrefix) +} + +func (r *CheckedStateReader) Trace() bool { return r.want.Trace() } + +func (r *CheckedStateReader) TracePrefix() string { return r.want.TracePrefix() } From c5f30bc715f8a41b0f1e3d769c4b49f6ec1a3810 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Thu, 6 Aug 2026 10:54:44 +0700 Subject: [PATCH 02/16] execution/state: guard nil reader, assert what the no-op filter depends on --- execution/stagedsync/exec3_parallel.go | 18 ++++++++++++------ execution/state/checked_reader.go | 13 ++++++++++++- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index 690b339e998..01002eb3fdd 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -2855,9 +2855,12 @@ func (be *blockExecutor) nextResult(ctx context.Context, pe *parallelExecutor, r emptyRemoval := be.blockNum != 0 && pe.cfg.chainConfig.IsEIP161Enabled(be.blockNum) // Experiment: serve Normalize's fallback reads from what the worker // already recorded, and assert the domain agrees on every one. - normReader := state.NewCheckedStateReader( - state.NewVersionedStateReader(txVersion.TxIndex, be.blockIO.ReadSet(txVersion.TxIndex), be.versionMap, stateReader), - stateReader) + normReader := stateReader + if stateReader != nil { // nil means "no reader"; Normalize guards on it + normReader = state.NewCheckedStateReader( + state.NewVersionedStateReader(txVersion.TxIndex, be.blockIO.ReadSet(txVersion.TxIndex), be.versionMap, stateReader), + stateReader) + } normWrites, normErr := rawWrites.Normalize(be.versionMap, txVersion.TxIndex, resultIncarnation, normReader, domainStorageKeys, emptyRemoval, pe.cfg.chainConfig.Aura != nil, txTask.Rules().IsAmsterdam) if domainKeysErr != nil { return nil, fmt.Errorf("[parallel] iterate storage prefix for block write normalization: %w", domainKeysErr) @@ -3123,9 +3126,12 @@ func (be *blockExecutor) nextResult(ctx context.Context, pe *parallelExecutor, r } emptyRemoval := be.blockNum != 0 && pe.cfg.chainConfig.IsEIP161Enabled(be.blockNum) var normErr error - finalizeNormReader := state.NewCheckedStateReader( - state.NewVersionedStateReader(finalVersion.TxIndex, be.blockIO.ReadSet(finalVersion.TxIndex), be.versionMap, reader), - reader) + finalizeNormReader := reader + if reader != nil { + finalizeNormReader = state.NewCheckedStateReader( + state.NewVersionedStateReader(finalVersion.TxIndex, be.blockIO.ReadSet(finalVersion.TxIndex), be.versionMap, reader), + reader) + } finalizeWrites, normErr = writes.Normalize(be.versionMap, finalVersion.TxIndex, finalVersion.Incarnation, finalizeNormReader, domainStorageKeys, emptyRemoval, pe.cfg.chainConfig.Aura != nil, pe.cfg.chainConfig.IsAmsterdam(tt.Header.Time)) if domainKeysErr != nil { return nil, fmt.Errorf("[parallel] finalize iterate storage prefix for block write normalization: %w", domainKeysErr) diff --git a/execution/state/checked_reader.go b/execution/state/checked_reader.go index d649bee0810..44ec36de59a 100644 --- a/execution/state/checked_reader.go +++ b/execution/state/checked_reader.go @@ -78,7 +78,18 @@ func (r *CheckedStateReader) ReadAccountStorage(address accounts.Address, key ac if gotErr != nil { mismatch("ReadAccountStorage", address, fmt.Sprintf("domain errored (%v) while read set answered", gotErr)) } - if wantOK != gotOK || !want.Eq(&got) { + // Absent and present-with-zero are the same slot to the no-op filter: it + // drops a zero write either way and keeps a non-zero one either way, so + // only the effective value has to agree. The read set reports found=true + // for a slot the worker read as zero; the domain reports it absent. + wantEff, gotEff := want, got + if !wantOK { + wantEff = uint256.Int{} + } + if !gotOK { + gotEff = uint256.Int{} + } + if !wantEff.Eq(&gotEff) { mismatch("ReadAccountStorage", address, fmt.Sprintf( "slot %x: readset={%s,found:%v} domain={%s,found:%v}", key.Value(), want.String(), wantOK, got.String(), gotOK)) From c3c90674f827f4a27d98d3015f1db1d5e5dda5c8 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Thu, 6 Aug 2026 11:20:12 +0700 Subject: [PATCH 03/16] execution/stagedsync: let Normalize read through the tx's own read set --- execution/stagedsync/exec3_parallel.go | 8 ++------ execution/state/checked_reader.go | 6 +++--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index 01002eb3fdd..38686722ede 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -2857,9 +2857,7 @@ func (be *blockExecutor) nextResult(ctx context.Context, pe *parallelExecutor, r // already recorded, and assert the domain agrees on every one. normReader := stateReader if stateReader != nil { // nil means "no reader"; Normalize guards on it - normReader = state.NewCheckedStateReader( - state.NewVersionedStateReader(txVersion.TxIndex, be.blockIO.ReadSet(txVersion.TxIndex), be.versionMap, stateReader), - stateReader) + normReader = state.NewVersionedStateReader(txVersion.TxIndex, be.blockIO.ReadSet(txVersion.TxIndex), be.versionMap, stateReader) } normWrites, normErr := rawWrites.Normalize(be.versionMap, txVersion.TxIndex, resultIncarnation, normReader, domainStorageKeys, emptyRemoval, pe.cfg.chainConfig.Aura != nil, txTask.Rules().IsAmsterdam) if domainKeysErr != nil { @@ -3128,9 +3126,7 @@ func (be *blockExecutor) nextResult(ctx context.Context, pe *parallelExecutor, r var normErr error finalizeNormReader := reader if reader != nil { - finalizeNormReader = state.NewCheckedStateReader( - state.NewVersionedStateReader(finalVersion.TxIndex, be.blockIO.ReadSet(finalVersion.TxIndex), be.versionMap, reader), - reader) + finalizeNormReader = state.NewVersionedStateReader(finalVersion.TxIndex, be.blockIO.ReadSet(finalVersion.TxIndex), be.versionMap, reader) } finalizeWrites, normErr = writes.Normalize(be.versionMap, finalVersion.TxIndex, finalVersion.Incarnation, finalizeNormReader, domainStorageKeys, emptyRemoval, pe.cfg.chainConfig.Aura != nil, pe.cfg.chainConfig.IsAmsterdam(tt.Header.Time)) if domainKeysErr != nil { diff --git a/execution/state/checked_reader.go b/execution/state/checked_reader.go index 44ec36de59a..ab623b13d20 100644 --- a/execution/state/checked_reader.go +++ b/execution/state/checked_reader.go @@ -49,7 +49,7 @@ func mismatch(op string, addr accounts.Address, detail string) { func (r *CheckedStateReader) ReadAccountData(address accounts.Address) (*accounts.Account, error) { want, err := r.want.ReadAccountData(address) if err != nil { - return nil, err + mismatch("ReadAccountData", address, fmt.Sprintf("read-set path errored: %v", err)) } got, gotErr := r.got.ReadAccountData(address) if gotErr != nil { @@ -72,7 +72,7 @@ func (r *CheckedStateReader) ReadAccountData(address accounts.Address) (*account func (r *CheckedStateReader) ReadAccountStorage(address accounts.Address, key accounts.StorageKey) (uint256.Int, bool, error) { want, wantOK, err := r.want.ReadAccountStorage(address, key) if err != nil { - return want, wantOK, err + mismatch("ReadAccountStorage", address, fmt.Sprintf("read-set path errored: %v", err)) } got, gotOK, gotErr := r.got.ReadAccountStorage(address, key) if gotErr != nil { @@ -100,7 +100,7 @@ func (r *CheckedStateReader) ReadAccountStorage(address accounts.Address, key ac func (r *CheckedStateReader) ReadAccountCode(address accounts.Address) ([]byte, error) { want, err := r.want.ReadAccountCode(address) if err != nil { - return nil, err + mismatch("ReadAccountCode", address, fmt.Sprintf("read-set path errored: %v", err)) } got, gotErr := r.got.ReadAccountCode(address) if gotErr != nil { From 86908cc22a56525de9b817805623b686a70bf23f Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Thu, 6 Aug 2026 11:27:30 +0700 Subject: [PATCH 04/16] execution/state: gate the Normalize read cross-check behind NORMALIZE_ASSERT_READS --- execution/stagedsync/exec3_parallel.go | 10 ++-------- execution/state/checked_reader.go | 25 +++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index 38686722ede..49a5e824545 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -2855,10 +2855,7 @@ func (be *blockExecutor) nextResult(ctx context.Context, pe *parallelExecutor, r emptyRemoval := be.blockNum != 0 && pe.cfg.chainConfig.IsEIP161Enabled(be.blockNum) // Experiment: serve Normalize's fallback reads from what the worker // already recorded, and assert the domain agrees on every one. - normReader := stateReader - if stateReader != nil { // nil means "no reader"; Normalize guards on it - normReader = state.NewVersionedStateReader(txVersion.TxIndex, be.blockIO.ReadSet(txVersion.TxIndex), be.versionMap, stateReader) - } + normReader := state.NewNormalizeReader(txVersion.TxIndex, be.blockIO.ReadSet(txVersion.TxIndex), be.versionMap, stateReader) normWrites, normErr := rawWrites.Normalize(be.versionMap, txVersion.TxIndex, resultIncarnation, normReader, domainStorageKeys, emptyRemoval, pe.cfg.chainConfig.Aura != nil, txTask.Rules().IsAmsterdam) if domainKeysErr != nil { return nil, fmt.Errorf("[parallel] iterate storage prefix for block write normalization: %w", domainKeysErr) @@ -3124,10 +3121,7 @@ func (be *blockExecutor) nextResult(ctx context.Context, pe *parallelExecutor, r } emptyRemoval := be.blockNum != 0 && pe.cfg.chainConfig.IsEIP161Enabled(be.blockNum) var normErr error - finalizeNormReader := reader - if reader != nil { - finalizeNormReader = state.NewVersionedStateReader(finalVersion.TxIndex, be.blockIO.ReadSet(finalVersion.TxIndex), be.versionMap, reader) - } + finalizeNormReader := state.NewNormalizeReader(finalVersion.TxIndex, be.blockIO.ReadSet(finalVersion.TxIndex), be.versionMap, reader) finalizeWrites, normErr = writes.Normalize(be.versionMap, finalVersion.TxIndex, finalVersion.Incarnation, finalizeNormReader, domainStorageKeys, emptyRemoval, pe.cfg.chainConfig.Aura != nil, pe.cfg.chainConfig.IsAmsterdam(tt.Header.Time)) if domainKeysErr != nil { return nil, fmt.Errorf("[parallel] finalize iterate storage prefix for block write normalization: %w", domainKeysErr) diff --git a/execution/state/checked_reader.go b/execution/state/checked_reader.go index ab623b13d20..8d086502b2e 100644 --- a/execution/state/checked_reader.go +++ b/execution/state/checked_reader.go @@ -22,17 +22,38 @@ import ( "github.com/holiman/uint256" + "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/execution/types/accounts" ) +// assertNormalizeReads turns on the cross-check in NewNormalizeReader. +var assertNormalizeReads = dbg.EnvBool("NORMALIZE_ASSERT_READS", false) + +// NewNormalizeReader returns the reader Normalize should use: the tx's own +// recorded reads first, then the versionMap, then the domain. +// +// With NORMALIZE_ASSERT_READS=true it also reads the domain for every call and +// panics on disagreement. That is diagnostic-only and changes behaviour: the +// domain reader fills the block state cache, so reading through it twice +// perturbs the cache and fails blocks that pass without it. +func NewNormalizeReader(txIndex int, reads ReadSet, versionMap *VersionMap, domain StateReader) StateReader { + if domain == nil { // nil means "no reader"; Normalize guards on it + return nil + } + readSet := NewVersionedStateReader(txIndex, reads, versionMap, domain) + if !assertNormalizeReads { + return readSet + } + return NewCheckedStateReader(readSet, domain) +} + // CheckedStateReader answers from want and asserts that got agrees, panicking // on any disagreement. It exists to settle one question empirically: can // Normalize take the values a worker already recorded in its read set instead // of re-reading the domain on the apply loop? Every divergence is a case where // it cannot, and the panic names it. // -// Experiment only — the double read makes it strictly slower than either -// reader alone. +// Diagnostic only, and not side-effect free: see NewNormalizeReader. type CheckedStateReader struct { want StateReader // read set -> versionMap -> domain got StateReader // domain From f4940415735387eb5375e19f9dcd8a93b1494993 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Thu, 6 Aug 2026 11:28:04 +0700 Subject: [PATCH 05/16] save --- execution/state/checked_reader.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/execution/state/checked_reader.go b/execution/state/checked_reader.go index 8d086502b2e..4b4b53f588e 100644 --- a/execution/state/checked_reader.go +++ b/execution/state/checked_reader.go @@ -27,7 +27,7 @@ import ( ) // assertNormalizeReads turns on the cross-check in NewNormalizeReader. -var assertNormalizeReads = dbg.EnvBool("NORMALIZE_ASSERT_READS", false) +var assertNormalizeReads = dbg.EnvBool("NORMALIZE_ASSERT_READS", true) // NewNormalizeReader returns the reader Normalize should use: the tx's own // recorded reads first, then the versionMap, then the domain. From 0a40a8bb74749b8ca42fb23e4ab2103e752d4c34 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Thu, 6 Aug 2026 11:28:48 +0700 Subject: [PATCH 06/16] save --- execution/state/checked_reader.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/execution/state/checked_reader.go b/execution/state/checked_reader.go index 4b4b53f588e..8d086502b2e 100644 --- a/execution/state/checked_reader.go +++ b/execution/state/checked_reader.go @@ -27,7 +27,7 @@ import ( ) // assertNormalizeReads turns on the cross-check in NewNormalizeReader. -var assertNormalizeReads = dbg.EnvBool("NORMALIZE_ASSERT_READS", true) +var assertNormalizeReads = dbg.EnvBool("NORMALIZE_ASSERT_READS", false) // NewNormalizeReader returns the reader Normalize should use: the tx's own // recorded reads first, then the versionMap, then the domain. From 50ce9d3375d88cbfb9ae2db8eade37e70eabf869 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Thu, 6 Aug 2026 11:30:06 +0700 Subject: [PATCH 07/16] save --- execution/state/checked_reader.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/execution/state/checked_reader.go b/execution/state/checked_reader.go index 8d086502b2e..4b4b53f588e 100644 --- a/execution/state/checked_reader.go +++ b/execution/state/checked_reader.go @@ -27,7 +27,7 @@ import ( ) // assertNormalizeReads turns on the cross-check in NewNormalizeReader. -var assertNormalizeReads = dbg.EnvBool("NORMALIZE_ASSERT_READS", false) +var assertNormalizeReads = dbg.EnvBool("NORMALIZE_ASSERT_READS", true) // NewNormalizeReader returns the reader Normalize should use: the tx's own // recorded reads first, then the versionMap, then the domain. From be62e5c04f482b3bb0eb4b9ddf1169731f1d865f Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Thu, 6 Aug 2026 11:47:46 +0700 Subject: [PATCH 08/16] execution/stagedsync: export parallel-exec conflict rate, fix its dashboard panel --- cmd/prometheus/dashboards/erigon_internals.json | 8 ++++---- execution/stagedsync/exec3_metrics.go | 11 +++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/cmd/prometheus/dashboards/erigon_internals.json b/cmd/prometheus/dashboards/erigon_internals.json index 7c725014644..f3ac6196c89 100644 --- a/cmd/prometheus/dashboards/erigon_internals.json +++ b/cmd/prometheus/dashboards/erigon_internals.json @@ -6567,11 +6567,11 @@ }, "editorMode": "code", "exemplar": true, - "expr": "rate(exec_repeats{instance=~\"$instance\"}[$__rate_interval])/rate(exec_txs_done{instance=~\"$instance\"}[$__rate_interval])", + "expr": "rate(exec_repeats_total{instance=~\"$instance\"}[$__rate_interval]) / rate(exec_triggers{instance=~\"$instance\"}[$__rate_interval])", "format": "time_series", "interval": "", "intervalFactor": 1, - "legendFormat": "repeats: {{instance}}", + "legendFormat": "conflict rate", "range": true, "refId": "A" }, @@ -6582,12 +6582,12 @@ }, "editorMode": "code", "exemplar": true, - "expr": "rate(exec_triggers{instance=~\"$instance\"}[$__rate_interval])/rate(exec_txs_done{instance=~\"$instance\"}[$__rate_interval])", + "expr": "rate(exec_discards_total{instance=~\"$instance\"}[$__rate_interval])", "format": "time_series", "hide": false, "interval": "", "intervalFactor": 1, - "legendFormat": "triggers: {{instance}}", + "legendFormat": "discards/s {{reason}}", "range": true, "refId": "B" } diff --git a/execution/stagedsync/exec3_metrics.go b/execution/stagedsync/exec3_metrics.go index 7ae80685d7d..652e72f2ed1 100644 --- a/execution/stagedsync/exec3_metrics.go +++ b/execution/stagedsync/exec3_metrics.go @@ -45,6 +45,14 @@ var ( mxExecCodeReadRate = metrics.NewGauge("exec_code_read_rate") mxExecWriteRate = metrics.NewGauge("exec_write_rate") + // Conflict signals for the parallel executor, as monotonic totals so the + // rate and the ratio are derived at query time rather than being fixed to + // this reporter's interval. The denominator for the ratio is the existing + // exec_triggers, which already carries the cumulative execution count. + mxRepeatsTotal = metrics.GetOrCreateCounter("exec_repeats_total") + mxDiscardsTotal = metrics.GetOrCreateCounterVec("exec_discards_total", []string{"reason"}, + "parallel-exec tasks thrown away, by reason") + mxExecDomainReads = metrics.NewGauge(`exec_domain_read_rate{domain="all"}`) mxExecDomainReadDuration = metrics.NewGauge(`exec_domain_read_dur{domain="all"}`) mxExecDomainCacheReads = metrics.NewGauge(`exec_domain_cache_read_rate{domain="all"}`) @@ -651,6 +659,9 @@ func (p *Progress) LogExecution(rs *state.StateV3, ex executor) { } mxExecRepeats.SetInt(repeats) + mxRepeatsTotal.AddInt(repeats) + mxDiscardsTotal.WithLabelValues("abort").AddUint64(abortCount - p.prevAbortCount) + mxDiscardsTotal.WithLabelValues("invalid").AddUint64(invalidCount - p.prevInvalidCount) mxExecTriggers.SetInt(int(execCount)) p.prevExecCount = execCount From a525d80863d686b941c2d6016dc12c38b22f7273 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Thu, 6 Aug 2026 11:53:05 +0700 Subject: [PATCH 09/16] execution/state: keep the read cross-check off by default --- execution/state/checked_reader.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/execution/state/checked_reader.go b/execution/state/checked_reader.go index 4b4b53f588e..8d086502b2e 100644 --- a/execution/state/checked_reader.go +++ b/execution/state/checked_reader.go @@ -27,7 +27,7 @@ import ( ) // assertNormalizeReads turns on the cross-check in NewNormalizeReader. -var assertNormalizeReads = dbg.EnvBool("NORMALIZE_ASSERT_READS", true) +var assertNormalizeReads = dbg.EnvBool("NORMALIZE_ASSERT_READS", false) // NewNormalizeReader returns the reader Normalize should use: the tx's own // recorded reads first, then the versionMap, then the domain. From e5ff4c4383287210eb4434b5bb82e7e192bcca4e Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Thu, 6 Aug 2026 11:57:31 +0700 Subject: [PATCH 10/16] execution/state: turn the read cross-check on for this branch --- execution/state/checked_reader.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/execution/state/checked_reader.go b/execution/state/checked_reader.go index 8d086502b2e..4b4b53f588e 100644 --- a/execution/state/checked_reader.go +++ b/execution/state/checked_reader.go @@ -27,7 +27,7 @@ import ( ) // assertNormalizeReads turns on the cross-check in NewNormalizeReader. -var assertNormalizeReads = dbg.EnvBool("NORMALIZE_ASSERT_READS", false) +var assertNormalizeReads = dbg.EnvBool("NORMALIZE_ASSERT_READS", true) // NewNormalizeReader returns the reader Normalize should use: the tx's own // recorded reads first, then the versionMap, then the domain. From 9f5ed517c719003e6990d9c442709a596699fc1a Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Thu, 6 Aug 2026 12:24:13 +0700 Subject: [PATCH 11/16] execution/state: assert Normalize output instead of each read --- execution/stagedsync/exec3_parallel.go | 7 +++++ execution/state/checked_reader.go | 40 +++++++++++++++++++------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index 49a5e824545..913b1e42550 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -2857,6 +2857,13 @@ func (be *blockExecutor) nextResult(ctx context.Context, pe *parallelExecutor, r // already recorded, and assert the domain agrees on every one. normReader := state.NewNormalizeReader(txVersion.TxIndex, be.blockIO.ReadSet(txVersion.TxIndex), be.versionMap, stateReader) normWrites, normErr := rawWrites.Normalize(be.versionMap, txVersion.TxIndex, resultIncarnation, normReader, domainStorageKeys, emptyRemoval, pe.cfg.chainConfig.Aura != nil, txTask.Rules().IsAmsterdam) + if state.AssertNormalizeReadsEnabled() && normErr == nil && stateReader != nil { + domainWrites, domainErr := rawWrites.Normalize(be.versionMap, txVersion.TxIndex, resultIncarnation, stateReader, domainStorageKeys, emptyRemoval, pe.cfg.chainConfig.Aura != nil, txTask.Rules().IsAmsterdam) + if domainErr != nil { + return nil, fmt.Errorf("[parallel] normalize cross-check: %w", domainErr) + } + state.AssertNormalizeMatches(domainWrites, normWrites) + } if domainKeysErr != nil { return nil, fmt.Errorf("[parallel] iterate storage prefix for block write normalization: %w", domainKeysErr) } diff --git a/execution/state/checked_reader.go b/execution/state/checked_reader.go index 4b4b53f588e..7e2ccbbf343 100644 --- a/execution/state/checked_reader.go +++ b/execution/state/checked_reader.go @@ -26,25 +26,20 @@ import ( "github.com/erigontech/erigon/execution/types/accounts" ) -// assertNormalizeReads turns on the cross-check in NewNormalizeReader. +// assertNormalizeReads runs Normalize a second time against the domain reader +// and asserts the two outputs match. Diagnostic only: it doubles Normalize. var assertNormalizeReads = dbg.EnvBool("NORMALIZE_ASSERT_READS", true) +// AssertNormalizeReadsEnabled reports whether the output-level check is on. +func AssertNormalizeReadsEnabled() bool { return assertNormalizeReads } + // NewNormalizeReader returns the reader Normalize should use: the tx's own // recorded reads first, then the versionMap, then the domain. -// -// With NORMALIZE_ASSERT_READS=true it also reads the domain for every call and -// panics on disagreement. That is diagnostic-only and changes behaviour: the -// domain reader fills the block state cache, so reading through it twice -// perturbs the cache and fails blocks that pass without it. func NewNormalizeReader(txIndex int, reads ReadSet, versionMap *VersionMap, domain StateReader) StateReader { if domain == nil { // nil means "no reader"; Normalize guards on it return nil } - readSet := NewVersionedStateReader(txIndex, reads, versionMap, domain) - if !assertNormalizeReads { - return readSet - } - return NewCheckedStateReader(readSet, domain) + return NewVersionedStateReader(txIndex, reads, versionMap, domain) } // CheckedStateReader answers from want and asserts that got agrees, panicking @@ -189,3 +184,26 @@ func (r *CheckedStateReader) SetTrace(trace bool, tracePrefix string) { func (r *CheckedStateReader) Trace() bool { return r.want.Trace() } func (r *CheckedStateReader) TracePrefix() string { return r.want.TracePrefix() } + +// AssertNormalizeMatches panics when two Normalize outputs differ. Compared at +// the output rather than at each read: the apply-side reader fills the block +// state cache on a miss, so reading it twice to cross-check is a mutation, not +// an observation, and fails blocks that otherwise pass. +func AssertNormalizeMatches(domain, readSet *WriteSet) { + if domain.Count() != readSet.Count() { + panic(fmt.Sprintf("normalize output differs: domain=%d readset=%d writes", + domain.Count(), readSet.Count())) + } + for h := range domain.AllHeaders() { + if !readSet.Has(h) { + panic(fmt.Sprintf("normalize output differs: read set lacks %x path=%v key=%x", + h.Address.Value(), h.Path, h.Key.Value())) + } + } + for h := range readSet.AllHeaders() { + if !domain.Has(h) { + panic(fmt.Sprintf("normalize output differs: domain lacks %x path=%v key=%x", + h.Address.Value(), h.Path, h.Key.Value())) + } + } +} From 29009fb60f5e23ca6549139d8bc7398e8e80091d Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Thu, 6 Aug 2026 12:25:14 +0700 Subject: [PATCH 12/16] execution/stagedsync: label conflict metrics by sync mode, count executions --- .../dashboards/erigon_internals.json | 8 ++--- execution/stagedsync/exec3_metrics.go | 30 ++++++++++++++----- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/cmd/prometheus/dashboards/erigon_internals.json b/cmd/prometheus/dashboards/erigon_internals.json index f3ac6196c89..1dc1ac83a88 100644 --- a/cmd/prometheus/dashboards/erigon_internals.json +++ b/cmd/prometheus/dashboards/erigon_internals.json @@ -6567,11 +6567,11 @@ }, "editorMode": "code", "exemplar": true, - "expr": "rate(exec_repeats_total{instance=~\"$instance\"}[$__rate_interval]) / rate(exec_triggers{instance=~\"$instance\"}[$__rate_interval])", + "expr": "rate(exec_repeats_total{instance=~\"$instance\",mode=\"newpayload\"}[$__rate_interval]) / rate(exec_execs_total{instance=~\"$instance\",mode=\"newpayload\"}[$__rate_interval])", "format": "time_series", "interval": "", "intervalFactor": 1, - "legendFormat": "conflict rate", + "legendFormat": "conflict rate (newPayload)", "range": true, "refId": "A" }, @@ -6582,12 +6582,12 @@ }, "editorMode": "code", "exemplar": true, - "expr": "rate(exec_discards_total{instance=~\"$instance\"}[$__rate_interval])", + "expr": "rate(exec_discards_total{instance=~\"$instance\",mode=\"newpayload\"}[$__rate_interval])", "format": "time_series", "hide": false, "interval": "", "intervalFactor": 1, - "legendFormat": "discards/s {{reason}}", + "legendFormat": "discards/s {{reason}} (newPayload)", "range": true, "refId": "B" } diff --git a/execution/stagedsync/exec3_metrics.go b/execution/stagedsync/exec3_metrics.go index 652e72f2ed1..c35b0c6b499 100644 --- a/execution/stagedsync/exec3_metrics.go +++ b/execution/stagedsync/exec3_metrics.go @@ -47,11 +47,18 @@ var ( // Conflict signals for the parallel executor, as monotonic totals so the // rate and the ratio are derived at query time rather than being fixed to - // this reporter's interval. The denominator for the ratio is the existing - // exec_triggers, which already carries the cumulative execution count. - mxRepeatsTotal = metrics.GetOrCreateCounter("exec_repeats_total") - mxDiscardsTotal = metrics.GetOrCreateCounterVec("exec_discards_total", []string{"reason"}, - "parallel-exec tasks thrown away, by reason") + // this reporter's interval. + // + // exec_execs_total is the denominator rather than the existing + // exec_triggers: that one is a gauge holding the executor's own exec + // counter, which restarts with each executor, so it is not monotonic and + // rate() over it is meaningless. + mxExecsTotal = metrics.GetOrCreateCounterVec("exec_execs_total", []string{"mode"}, + "parallel-exec task executions, by sync mode") + mxRepeatsTotal = metrics.GetOrCreateCounterVec("exec_repeats_total", []string{"mode"}, + "parallel-exec re-executions, by sync mode") + mxDiscardsTotal = metrics.GetOrCreateCounterVec("exec_discards_total", []string{"mode", "reason"}, + "parallel-exec tasks thrown away, by sync mode and reason") mxExecDomainReads = metrics.NewGauge(`exec_domain_read_rate{domain="all"}`) mxExecDomainReadDuration = metrics.NewGauge(`exec_domain_read_dur{domain="all"}`) @@ -659,9 +666,16 @@ func (p *Progress) LogExecution(rs *state.StateV3, ex executor) { } mxExecRepeats.SetInt(repeats) - mxRepeatsTotal.AddInt(repeats) - mxDiscardsTotal.WithLabelValues("abort").AddUint64(abortCount - p.prevAbortCount) - mxDiscardsTotal.WithLabelValues("invalid").AddUint64(invalidCount - p.prevInvalidCount) + // engine_newPayload drives fork validation; everything else is sync + // catch-up, where conflict behaviour is a different workload. + execMode := "sync" + if te.isForkValidation { + execMode = "newpayload" + } + mxExecsTotal.WithLabelValues(execMode).AddUint64(execDiff) + mxRepeatsTotal.WithLabelValues(execMode).AddInt(repeats) + mxDiscardsTotal.WithLabelValues(execMode, "abort").AddUint64(abortCount - p.prevAbortCount) + mxDiscardsTotal.WithLabelValues(execMode, "invalid").AddUint64(invalidCount - p.prevInvalidCount) mxExecTriggers.SetInt(int(execCount)) p.prevExecCount = execCount From 11db443e21c61d5159126e95df575aaa098e103d Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Thu, 6 Aug 2026 13:01:46 +0700 Subject: [PATCH 13/16] execution/state: resolve fill-loop account fields from the tx's own reads --- execution/state/versionedio.go | 37 +++++++++++++++++++++++++++ execution/state/writeset_normalize.go | 4 +++ 2 files changed, 41 insertions(+) diff --git a/execution/state/versionedio.go b/execution/state/versionedio.go index 335f2475964..2cb34a9b924 100644 --- a/execution/state/versionedio.go +++ b/execution/state/versionedio.go @@ -3047,3 +3047,40 @@ func (s *WriteSet) createdEmpty(addr accounts.Address) bool { _, hasCodeSize := s.codeSize[addr] return !hasCode && !hasIncarnation && !destroyed && !createdContract && !hasCodeSize && len(s.storage[addr]) == 0 } + +// accountFieldResolver lets Normalize fill one account field from a source +// richer than the StateReader interface exposes. Optional: Normalize type +// asserts for it and falls back to a whole-account domain read without it. +type accountFieldResolver interface { + ResolveAccountField(out *WriteSet, addr accounts.Address, path AccountPath, ver Version) bool +} + +// ResolveAccountField serves one field from what this tx already read, so the +// fill loop doesn't fetch a whole account from the domain to recover it. Only +// the requested path is answered: the read set records reads per path, and a +// synthesised account with the unread fields zeroed would be wrong. +func (vr *versionedStateReader) ResolveAccountField(out *WriteSet, addr accounts.Address, path AccountPath, ver Version) bool { + switch path { + case BalancePath: + if r, ok := vr.reads.GetBalance(addr); ok { + out.SetBalance(addr, &VersionedWrite[uint256.Int]{WriteHeader: WriteHeader{Address: addr, Path: BalancePath, Version: ver}, Val: r.Val}) + return true + } + case NoncePath: + if r, ok := vr.reads.GetNonce(addr); ok { + out.SetNonce(addr, &VersionedWrite[uint64]{WriteHeader: WriteHeader{Address: addr, Path: NoncePath, Version: ver}, Val: r.Val}) + return true + } + case IncarnationPath: + if r, ok := vr.reads.GetIncarnation(addr); ok { + out.SetIncarnation(addr, &VersionedWrite[uint64]{WriteHeader: WriteHeader{Address: addr, Path: IncarnationPath, Version: ver}, Val: r.Val}) + return true + } + case CodeHashPath: + if r, ok := vr.reads.GetCodeHash(addr); ok { + out.SetCodeHash(addr, &VersionedWrite[accounts.CodeHash]{WriteHeader: WriteHeader{Address: addr, Path: CodeHashPath, Version: ver}, Val: r.Val}) + return true + } + } + return false +} diff --git a/execution/state/writeset_normalize.go b/execution/state/writeset_normalize.go index dd82b8029a8..50b7d713789 100644 --- a/execution/state/writeset_normalize.go +++ b/execution/state/writeset_normalize.go @@ -384,6 +384,10 @@ func (writes *WriteSet) Normalize(vm *VersionMap, txIndex int, incarnation int, if SetAccountFieldFromMap(filtered, vm, addr, path, ver, txIndex+1) { continue } + // Then what this tx already read, before paying for a domain read. + if r, ok := stateReader.(accountFieldResolver); ok && r.ResolveAccountField(filtered, addr, path, ver) { + continue + } // Fall back to stateReader for pre-block account if stateReader != nil { if !fallbackLoaded { From 27a11c0b6733da9f6c7c7aefcaeb0b3cdcea5ee4 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Fri, 7 Aug 2026 12:25:46 +0700 Subject: [PATCH 14/16] execution/state: skip the domain when the tx already read the address as absent The fill loop's account fallback went to the domain even when the tx's own read set already held an AddressPath entry saying the address has no account. readAccountInternal records that entry header-only (Val=nil) before the load, and accountRead overwrites it with the account as soon as a load finds one, so a header-only entry surviving to Normalize means the load came back empty. Measured over rpc/jsonrpc and execution/tests: the implication "read set says absent -> the read returns nil" held 10618/10618 times, and cutting the read takes apply-loop domain account reads through the versioned reader from 18961 to 8926 in rpc/jsonrpc (-52.9%). normalize_probe.go is the instrument that produced those numbers, off by default behind NORMALIZE_PROBE; NORMALIZE_SKIP_ABSENT restores the old behaviour for A/B. Both are experiment-branch scaffolding, like checked_reader.go. --- execution/state/normalize_probe.go | 145 ++++++++++++++++++++ execution/state/versionedio.go | 12 +- execution/state/writeset_normalize.go | 7 + execution/tests/zzz_normalize_probe_test.go | 11 ++ rpc/jsonrpc/zzz_normalize_probe_test.go | 11 ++ 5 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 execution/state/normalize_probe.go create mode 100644 execution/tests/zzz_normalize_probe_test.go create mode 100644 rpc/jsonrpc/zzz_normalize_probe_test.go diff --git a/execution/state/normalize_probe.go b/execution/state/normalize_probe.go new file mode 100644 index 00000000000..52ef8b19ca1 --- /dev/null +++ b/execution/state/normalize_probe.go @@ -0,0 +1,145 @@ +// Copyright 2024 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package state + +import ( + "fmt" + "sync/atomic" + + "github.com/erigontech/erigon/common/dbg" + "github.com/erigontech/erigon/execution/types/accounts" +) + +// Temporary instrumentation for the Normalize read-set experiment. It answers +// one question: when the fill loop falls through to an account read, what did +// the tx's own read set hold for that address, and what did the read return? +var normalizeProbe = dbg.EnvBool("NORMALIZE_PROBE", false) + +// skipAbsentDomainRead turns the "recorded absent -> skip the domain" short +// circuit off, so the A/B can be measured without editing code. +var skipAbsentDomainRead = dbg.EnvBool("NORMALIZE_SKIP_ABSENT", true) + +type normProbeClass int + +const ( + normProbeNotVersioned normProbeClass = iota // plain domain reader (blockgen / builder) + normProbeAddrWithVal // AddressPath entry carrying an account + normProbeAddrNilVal // AddressPath entry recorded header-only + normProbeNoAddrTouched // no AddressPath entry, other paths read + normProbeNoAddrCold // no read recorded for this address at all + normProbeClassCount +) + +var normProbeClassName = [normProbeClassCount]string{ + "notVersioned", "addrWithVal", "addrNilVal", "noAddrTouched", "noAddrCold", +} + +var normProbe struct { + seen [normProbeClassCount]atomic.Uint64 + gotAcc [normProbeClassCount]atomic.Uint64 // the read returned an account + gotNil [normProbeClassCount]atomic.Uint64 // the read returned nil + fromMap [normProbeClassCount]atomic.Uint64 // versionMap AddressPath answered + domainReads atomic.Uint64 // versioned reader reached the domain +} + +func normProbeFallback(reader StateReader, addr accounts.Address) normProbeClass { + class := normProbeNotVersioned + if vr, ok := reader.(*versionedStateReader); ok { + switch { + case hasAddrRead(vr, addr, true): + class = normProbeAddrWithVal + case hasAddrRead(vr, addr, false): + class = normProbeAddrNilVal + case vr.reads.touched(addr): + class = normProbeNoAddrTouched + default: + class = normProbeNoAddrCold + } + if vr.versionMap != nil { + if _, res, ok := vr.versionMap.ReadAddress(addr, vr.txIndex); ok && res.Status() == MVReadResultDone { + normProbe.fromMap[class].Add(1) + } + } + } + normProbe.seen[class].Add(1) + return class +} + +func hasAddrRead(vr *versionedStateReader, addr accounts.Address, withVal bool) bool { + tr, ok := vr.reads.GetAddress(addr) + if !ok { + return false + } + return withVal == (tr.Val != nil && !tr.Val.IsNil()) +} + +func normProbeResult(class normProbeClass, acc *accounts.Account) { + if acc != nil { + normProbe.gotAcc[class].Add(1) + return + } + normProbe.gotNil[class].Add(1) +} + +func (s *ReadSet) touched(addr accounts.Address) bool { + if _, ok := s.balance[addr]; ok { + return true + } + if _, ok := s.nonce[addr]; ok { + return true + } + if _, ok := s.incarnation[addr]; ok { + return true + } + if _, ok := s.codeHash[addr]; ok { + return true + } + if _, ok := s.code[addr]; ok { + return true + } + if _, ok := s.codeSize[addr]; ok { + return true + } + if _, ok := s.selfDestruct[addr]; ok { + return true + } + if _, ok := s.createContract[addr]; ok { + return true + } + _, ok := s.storage[addr] + return ok +} + +// NormalizeProbeDump prints the counters. Called from a last-sorting test file. +func NormalizeProbeDump(label string) { + var applyLoop uint64 + for c := normProbeAddrWithVal; c < normProbeClassCount; c++ { + applyLoop += normProbe.seen[c].Load() + } + if applyLoop == 0 { + fmt.Printf("NORMALIZE_PROBE %s: no apply-loop fill-loop fallbacks\n", label) + return + } + fmt.Printf("NORMALIZE_PROBE %s: applyLoop=%d notVersioned=%d versionedReaderDomainReads=%d\n", + label, applyLoop, normProbe.seen[normProbeNotVersioned].Load(), normProbe.domainReads.Load()) + for c := normProbeAddrWithVal; c < normProbeClassCount; c++ { + seen := normProbe.seen[c].Load() + fmt.Printf("NORMALIZE_PROBE %s: %-14s %6d (%5.1f%%) gotAccount=%d gotNil=%d viaVersionMap=%d\n", + label, normProbeClassName[c], seen, 100*float64(seen)/float64(applyLoop), + normProbe.gotAcc[c].Load(), normProbe.gotNil[c].Load(), normProbe.fromMap[c].Load()) + } +} diff --git a/execution/state/versionedio.go b/execution/state/versionedio.go index 1ed4e747d20..99ba9d026cb 100644 --- a/execution/state/versionedio.go +++ b/execution/state/versionedio.go @@ -1589,7 +1589,8 @@ func (vr *versionedStateReader) TracePrefix() string { } func (vr *versionedStateReader) ReadAccountData(address accounts.Address) (*accounts.Account, error) { - if r, ok := vr.reads.GetAddress(address); ok && r.Val != nil && !r.Val.IsNil() { + r, recorded := vr.reads.GetAddress(address) + if recorded && r.Val != nil && !r.Val.IsNil() { account := r.Val.Account() updated := vr.applyVersionedUpdates(address, *account) return &updated, nil @@ -1614,7 +1615,14 @@ func (vr *versionedStateReader) ReadAccountData(address accounts.Address) (*acco } } - if vr.stateReader != nil { + // A recorded AddressPath read with no account is the tx's own conclusion that + // the address holds nothing: the header goes in first and is overwritten with + // the value as soon as a load finds one, so a header-only entry means the load + // came back empty. No point asking the domain again. + if vr.stateReader != nil && !(recorded && skipAbsentDomainRead) { + if normalizeProbe { + normProbe.domainReads.Add(1) + } account, err := vr.stateReader.ReadAccountData(address) if err != nil { diff --git a/execution/state/writeset_normalize.go b/execution/state/writeset_normalize.go index 78c3e92122f..e6af9cd0562 100644 --- a/execution/state/writeset_normalize.go +++ b/execution/state/writeset_normalize.go @@ -392,10 +392,17 @@ func (writes *WriteSet) Normalize(vm *VersionMap, txIndex int, incarnation int, // Fall back to stateReader for pre-block account if stateReader != nil { if !fallbackLoaded { + var probeClass normProbeClass + if normalizeProbe { + probeClass = normProbeFallback(stateReader, addr) + } acc, err := stateReader.ReadAccountData(addr) if err != nil { return nil, err } + if normalizeProbe { + normProbeResult(probeClass, acc) + } fallbackAcc = acc fallbackLoaded = true } diff --git a/execution/tests/zzz_normalize_probe_test.go b/execution/tests/zzz_normalize_probe_test.go new file mode 100644 index 00000000000..cf88b7a3f16 --- /dev/null +++ b/execution/tests/zzz_normalize_probe_test.go @@ -0,0 +1,11 @@ +package executiontests + +import ( + "testing" + + "github.com/erigontech/erigon/execution/state" +) + +func TestZZZNormalizeProbeDump(t *testing.T) { + state.NormalizeProbeDump("execution/tests") +} diff --git a/rpc/jsonrpc/zzz_normalize_probe_test.go b/rpc/jsonrpc/zzz_normalize_probe_test.go new file mode 100644 index 00000000000..0ab10e4600b --- /dev/null +++ b/rpc/jsonrpc/zzz_normalize_probe_test.go @@ -0,0 +1,11 @@ +package jsonrpc + +import ( + "testing" + + "github.com/erigontech/erigon/execution/state" +) + +func TestZZZNormalizeProbeDump(t *testing.T) { + state.NormalizeProbeDump("rpc/jsonrpc") +} From e45f519e12cc757a93d35bb20232a56719d37c9a Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Fri, 7 Aug 2026 13:03:56 +0700 Subject: [PATCH 15/16] execution/state: attribute the apply loop's remaining domain account reads Extends the probe to the storage no-op filter and to the call site behind each domain account read. Storage is already served from the read set: 6795 of 6795 versioned no-op-filter fallbacks find the exact slot recorded, and only 48 of 7789 reach the domain. The 8925 residual account reads come from 3 sites and touch 22 distinct addresses: calcFees 6264/4, the Normalize fill loop 1420/3, finalizeSystemTx 1242/15. Repetition, not coldness. --- execution/state/normalize_probe.go | 110 ++++++++++++++++++++++++++ execution/state/versionedio.go | 10 ++- execution/state/writeset_normalize.go | 12 +++ 3 files changed, 131 insertions(+), 1 deletion(-) diff --git a/execution/state/normalize_probe.go b/execution/state/normalize_probe.go index 52ef8b19ca1..4345837e729 100644 --- a/execution/state/normalize_probe.go +++ b/execution/state/normalize_probe.go @@ -18,6 +18,10 @@ package state import ( "fmt" + "runtime" + "sort" + "strings" + "sync" "sync/atomic" "github.com/erigontech/erigon/common/dbg" @@ -54,6 +58,51 @@ var normProbe struct { gotNil [normProbeClassCount]atomic.Uint64 // the read returned nil fromMap [normProbeClassCount]atomic.Uint64 // versionMap AddressPath answered domainReads atomic.Uint64 // versioned reader reached the domain + + stgFallbacks atomic.Uint64 // no-op filter reached ReadAccountStorage + stgSlotInReads atomic.Uint64 // the tx recorded a read of this exact slot + stgAddrInReads atomic.Uint64 // other slots of this address recorded, not this one + stgAddrCold atomic.Uint64 // no storage read recorded for this address + stgDomainReads atomic.Uint64 // versioned reader reached the domain for a slot + stgDomainFound atomic.Uint64 // ... and the slot existed + stgWriteIsZero atomic.Uint64 // the write being filtered is zero + stgFilteredOut atomic.Uint64 // the fallback concluded "no-op", write dropped + stgFilteredKeep atomic.Uint64 // the fallback kept the write +} + +var normProbeCallers struct { + sync.Mutex + m map[string]uint64 + addrs map[string]map[accounts.Address]uint64 +} + +// normProbeCaller attributes one domain read to its call site. +func normProbeCaller(probeAddr accounts.Address) { + var pcs [8]uintptr + n := runtime.Callers(3, pcs[:]) + frames := runtime.CallersFrames(pcs[:n]) + var site string + for { + f, more := frames.Next() + if !strings.Contains(f.Function, "erigon/execution/state.") { + site = fmt.Sprintf("%s:%d", f.Function, f.Line) + break + } + if !more { + break + } + } + normProbeCallers.Lock() + if normProbeCallers.m == nil { + normProbeCallers.m = map[string]uint64{} + normProbeCallers.addrs = map[string]map[accounts.Address]uint64{} + } + normProbeCallers.m[site]++ + if normProbeCallers.addrs[site] == nil { + normProbeCallers.addrs[site] = map[accounts.Address]uint64{} + } + normProbeCallers.addrs[site][probeAddr]++ + normProbeCallers.Unlock() } func normProbeFallback(reader StateReader, addr accounts.Address) normProbeClass { @@ -95,6 +144,38 @@ func normProbeResult(class normProbeClass, acc *accounts.Account) { normProbe.gotNil[class].Add(1) } +func normProbeStorageFallback(reader StateReader, addr accounts.Address, key accounts.StorageKey, writeIsZero bool) { + normProbe.stgFallbacks.Add(1) + if writeIsZero { + normProbe.stgWriteIsZero.Add(1) + } + vr, ok := reader.(*versionedStateReader) + if !ok { + return + } + switch { + case hasSlotRead(vr, addr, key): + normProbe.stgSlotInReads.Add(1) + case len(vr.reads.storage[addr]) > 0: + normProbe.stgAddrInReads.Add(1) + default: + normProbe.stgAddrCold.Add(1) + } +} + +func hasSlotRead(vr *versionedStateReader, addr accounts.Address, key accounts.StorageKey) bool { + _, ok := vr.reads.GetStorage(addr, key) + return ok +} + +func normProbeStorageResult(dropped bool) { + if dropped { + normProbe.stgFilteredOut.Add(1) + return + } + normProbe.stgFilteredKeep.Add(1) +} + func (s *ReadSet) touched(addr accounts.Address) bool { if _, ok := s.balance[addr]; ok { return true @@ -142,4 +223,33 @@ func NormalizeProbeDump(label string) { label, normProbeClassName[c], seen, 100*float64(seen)/float64(applyLoop), normProbe.gotAcc[c].Load(), normProbe.gotNil[c].Load(), normProbe.fromMap[c].Load()) } + normProbeCallers.Lock() + type kv struct { + k string + v uint64 + } + var sites []kv + for k, v := range normProbeCallers.m { + sites = append(sites, kv{k, v}) + } + normProbeCallers.Unlock() + sort.Slice(sites, func(i, j int) bool { return sites[i].v > sites[j].v }) + for i, s := range sites { + if i == 8 { + break + } + fmt.Printf("NORMALIZE_PROBE %s: domainRead site %6d reads / %d distinct addrs %s\n", + label, s.v, len(normProbeCallers.addrs[s.k]), s.k) + } + stg := normProbe.stgFallbacks.Load() + if stg == 0 { + return + } + spct := func(v uint64) string { return fmt.Sprintf("%d (%.1f%%)", v, 100*float64(v)/float64(stg)) } + fmt.Printf("NORMALIZE_PROBE %s: storageFallbacks=%d slotInReads=%s addrInReads=%s addrCold=%s writeIsZero=%s\n", + label, stg, spct(normProbe.stgSlotInReads.Load()), spct(normProbe.stgAddrInReads.Load()), + spct(normProbe.stgAddrCold.Load()), spct(normProbe.stgWriteIsZero.Load())) + fmt.Printf("NORMALIZE_PROBE %s: storageDomainReads=%d found=%d dropped=%d kept=%d\n", + label, normProbe.stgDomainReads.Load(), normProbe.stgDomainFound.Load(), + normProbe.stgFilteredOut.Load(), normProbe.stgFilteredKeep.Load()) } diff --git a/execution/state/versionedio.go b/execution/state/versionedio.go index 99ba9d026cb..5c5812ed1a0 100644 --- a/execution/state/versionedio.go +++ b/execution/state/versionedio.go @@ -1622,6 +1622,7 @@ func (vr *versionedStateReader) ReadAccountData(address accounts.Address) (*acco if vr.stateReader != nil && !(recorded && skipAbsentDomainRead) { if normalizeProbe { normProbe.domainReads.Add(1) + normProbeCaller(address) } account, err := vr.stateReader.ReadAccountData(address) @@ -1782,7 +1783,14 @@ func (vr versionedStateReader) ReadAccountStorage(address accounts.Address, key } if vr.stateReader != nil { - return vr.stateReader.ReadAccountStorage(address, key) + if normalizeProbe { + normProbe.stgDomainReads.Add(1) + } + v, found, err := vr.stateReader.ReadAccountStorage(address, key) + if normalizeProbe && found { + normProbe.stgDomainFound.Add(1) + } + return v, found, err } return uint256.Int{}, false, nil diff --git a/execution/state/writeset_normalize.go b/execution/state/writeset_normalize.go index e6af9cd0562..3731670b5bf 100644 --- a/execution/state/writeset_normalize.go +++ b/execution/state/writeset_normalize.go @@ -305,16 +305,28 @@ func (writes *WriteSet) Normalize(vm *VersionMap, txIndex int, incarnation int, continue } } else { + if normalizeProbe { + normProbeStorageFallback(stateReader, h.Address, h.Key, writeVal.IsZero()) + } preVal, found, err := stateReader.ReadAccountStorage(h.Address, h.Key) if err != nil { return nil, err } if !found && writeVal.IsZero() { + if normalizeProbe { + normProbeStorageResult(true) + } continue } if found && writeVal.Eq(&preVal) { + if normalizeProbe { + normProbeStorageResult(true) + } continue } + if normalizeProbe { + normProbeStorageResult(false) + } } } filtered.SetStorage(h.Address, h.Key, h) From 7e3592561b61f403b416a343c3d0fa6f56f24b51 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Fri, 7 Aug 2026 13:21:56 +0700 Subject: [PATCH 16/16] execution/state: skip the re-encode when the block cache serves a committed account CachedReaderV3 with readCurrent=true asked GetCurrentAccount for a blob. On a miss in the write buffer that helper falls back to committedAccounts, which holds decoded accounts, and re-encodes one so the reader can decode it back. Split the write-buffer lookup out of GetCurrentAccount and take the committed account decoded, as the readCurrent=false branch already does. BenchmarkCachedReaderAccountRead/committed: 65.5 -> 24.3 ns/op, 144 -> 96 B/op, 2 -> 1 allocs. The path is taken 7933 times over the rpc/jsonrpc suite. --- execution/state/normalize_probe.go | 14 +++-- execution/state/rw_v3.go | 34 +++++++++-- execution/state/zz_cache_read_bench_test.go | 67 +++++++++++++++++++++ 3 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 execution/state/zz_cache_read_bench_test.go diff --git a/execution/state/normalize_probe.go b/execution/state/normalize_probe.go index 4345837e729..0fa3c05d2f2 100644 --- a/execution/state/normalize_probe.go +++ b/execution/state/normalize_probe.go @@ -59,10 +59,14 @@ var normProbe struct { fromMap [normProbeClassCount]atomic.Uint64 // versionMap AddressPath answered domainReads atomic.Uint64 // versioned reader reached the domain - stgFallbacks atomic.Uint64 // no-op filter reached ReadAccountStorage - stgSlotInReads atomic.Uint64 // the tx recorded a read of this exact slot - stgAddrInReads atomic.Uint64 // other slots of this address recorded, not this one - stgAddrCold atomic.Uint64 // no storage read recorded for this address + stgFallbacks atomic.Uint64 // no-op filter reached ReadAccountStorage + stgSlotInReads atomic.Uint64 // the tx recorded a read of this exact slot + stgAddrInReads atomic.Uint64 // other slots of this address recorded, not this one + stgAddrCold atomic.Uint64 // no storage read recorded for this address + cacheWritten atomic.Uint64 // CachedReaderV3 hit an account written this block + cacheCommitted atomic.Uint64 // ... the decoded committed view + cacheMiss atomic.Uint64 // ... nothing cached, went to the domain + stgDomainReads atomic.Uint64 // versioned reader reached the domain for a slot stgDomainFound atomic.Uint64 // ... and the slot existed stgWriteIsZero atomic.Uint64 // the write being filtered is zero @@ -241,6 +245,8 @@ func NormalizeProbeDump(label string) { fmt.Printf("NORMALIZE_PROBE %s: domainRead site %6d reads / %d distinct addrs %s\n", label, s.v, len(normProbeCallers.addrs[s.k]), s.k) } + fmt.Printf("NORMALIZE_PROBE %s: cachedReader written=%d committed=%d miss=%d\n", + label, normProbe.cacheWritten.Load(), normProbe.cacheCommitted.Load(), normProbe.cacheMiss.Load()) stg := normProbe.stgFallbacks.Load() if stg == 0 { return diff --git a/execution/state/rw_v3.go b/execution/state/rw_v3.go index 53c35355a7e..90a05232b94 100644 --- a/execution/state/rw_v3.go +++ b/execution/state/rw_v3.go @@ -1156,15 +1156,21 @@ func (c *BlockStateCache) DeleteAccount(addr accounts.Address, txNum uint64) { c.mu.Unlock() } +// writtenAccount returns the blob written this block, without the committed +// fallback — callers that hold a decoded committed account skip the re-encode. +func (c *BlockStateCache) writtenAccount(addr accounts.Address) ([]byte, bool) { + c.mu.RLock() + enc, ok := c.currentAccounts[addr] + c.mu.RUnlock() + return enc, ok +} + // GetCurrentAccount returns the latest account blob (including intra-block writes). // Falls back to committed state if no write exists. Returns (nil, false) if not cached. func (c *BlockStateCache) GetCurrentAccount(addr accounts.Address) ([]byte, bool) { - c.mu.RLock() - if enc, ok := c.currentAccounts[addr]; ok { - c.mu.RUnlock() + if enc, ok := c.writtenAccount(addr); ok { return enc, true } - c.mu.RUnlock() // The committed fallback runs after releasing mu, so the two reads are not one // point-in-time snapshot. That is safe because committedAccounts is a // write-once immutable pre-block view (a sync.Map for lock-free reads): a @@ -1310,7 +1316,10 @@ func (r *CachedReaderV3) ReadAccountData(address accounts.Address) (*accounts.Ac if r.blockCache != nil { if r.readCurrent { // Read from write buffer — sees accumulated per-TX writes. - if enc, ok := r.blockCache.GetCurrentAccount(address); ok { + if enc, ok := r.blockCache.writtenAccount(address); ok { + if normalizeProbe { + normProbe.cacheWritten.Add(1) + } if enc == nil { return nil, nil } @@ -1320,6 +1329,18 @@ func (r *CachedReaderV3) ReadAccountData(address accounts.Address) (*accounts.Ac } return &acc, nil } + // Unwritten this block, so the committed view is the current one; + // take it decoded rather than through a re-encode. + if acc, ok := r.blockCache.GetCommittedAccount(address); ok { + if normalizeProbe { + normProbe.cacheCommitted.Add(1) + } + if acc == nil { + return nil, nil + } + result := *acc + return &result, nil + } } else { // Read from committed cache — stable pre-block view. if acc, ok := r.blockCache.GetCommittedAccount(address); ok { @@ -1331,6 +1352,9 @@ func (r *CachedReaderV3) ReadAccountData(address accounts.Address) (*accounts.Ac } } } + if normalizeProbe { + normProbe.cacheMiss.Add(1) + } acc, err := r.ReaderV3.ReadAccountData(address) if err != nil { return nil, err diff --git a/execution/state/zz_cache_read_bench_test.go b/execution/state/zz_cache_read_bench_test.go new file mode 100644 index 00000000000..cccf588908d --- /dev/null +++ b/execution/state/zz_cache_read_bench_test.go @@ -0,0 +1,67 @@ +package state + +import ( + "testing" + + "github.com/holiman/uint256" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/crypto" + "github.com/erigontech/erigon/execution/types/accounts" +) + +func benchAccount() *accounts.Account { + acc := accounts.NewAccount() + acc.Nonce = 42 + acc.Balance = *uint256.NewInt(1e18) + acc.Incarnation = 1 + acc.CodeHash = accounts.InternCodeHash(crypto.Keccak256Hash([]byte{0x60, 0x00})) + return &acc +} + +// BenchmarkCachedReaderAccountRead prices one apply-loop account read that hits +// the block state cache, on each of the cache's two paths. +func BenchmarkCachedReaderAccountRead(b *testing.B) { + addr := accounts.InternAddress(common.HexToAddress("0xc0ffee")) + acc := benchAccount() + + b.Run("committed", func(b *testing.B) { + cache := NewBlockStateCache() + cache.PutCommittedAccount(addr, acc) + r := NewCurrentCachedReaderV3(nil, cache) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + got, err := r.ReadAccountData(addr) + if err != nil || got == nil { + b.Fatal(err) + } + } + }) + + b.Run("current", func(b *testing.B) { + cache := NewBlockStateCache() + cache.WriteAccount(addr, accounts.SerialiseV3(acc), 1) + r := NewCurrentCachedReaderV3(nil, cache) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + got, err := r.ReadAccountData(addr) + if err != nil || got == nil { + b.Fatal(err) + } + } + }) + + b.Run("codec_only", func(b *testing.B) { + enc := accounts.SerialiseV3(acc) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + var out accounts.Account + if err := accounts.DeserialiseV3(&out, enc); err != nil { + b.Fatal(err) + } + } + }) +}