Skip to content

fix(staking): drop the snapshot undo log when forking the staking view - #4967

Open
envestcc wants to merge 2 commits into
masterfrom
perf/viewdata-drop-snapshot-copy
Open

envestcc wants to merge 2 commits into
masterfrom
perf/viewdata-drop-snapshot-copy

Conversation

@envestcc

Copy link
Copy Markdown
Member

Part 2 of 2 for #4964. Independent of #4966 — different files, either can merge alone. This one is the larger win, and also the riskier of the two: unlike #4966 it is not height-gated, so it changes behaviour at every height.

What

viewData.Fork() no longer copies the snapshots slice; the fork starts with it empty.

Second commit is a one-liner: views.Revert set views.snapshotID = id before its cleanup loop for i := id + 1; i <= views.snapshotID; i++, so the loop never ran. Split out so the main diff stays clean — drop it if you would rather it went separately.

Why

viewData.Fork() deep-copied the whole snapshots slice on every fork, allocating a Snapshot struct and a new(big.Int).Set(...) per retained entry. v.snapshots grows one entry per action-snapshot and is cleared only in viewData.Commit, which below Okhotsk runs only when the staking view is dirty — almost never on early mainnet history. So the slice grows roughly with blocks processed and each fork's copy gets more expensive.

With #4966 applied, this is what the profile looks like at height ~750k:

      flat  flat%   cum   cum%
    12.84s 19.00%  21.64s 32.02%  runtime.tryDeferToSpanScan
    11.81s 17.47%  31.40s 46.46%  runtime.scanObjectsSmall
     3.62s  5.36%  17.28s 25.57%  action/protocol/staking.(*viewData).Fork
     2.12s  3.14%   2.55s  3.77%  math/big.(*Int).Set

viewData.Fork breaks down as runtime.newobject 55%, math/big.(*Int).Set 14.8%, runtime.makeslice 5.2% — the copy loop — and essentially all of the remaining profile is GC driven by it.

Why this is safe

Two independent legs.

1. snapshots cannot reach the write queue. The field appears only in viewdata.go. viewData.Snapshot() and Revert() take neither a StateManager nor a context and cannot write state. viewData.Commit reads candCenter / bucketPool / contractsStake and clears snapshots without reading it. The only channel from snapshots to the flusher's ordered write queue is the values Revert restores into candCenter.size, candCenter.change, bucketPool.total.{amount,count} and contractsStake. So if no Revert behaves differently, no write and no write order changes.

2. No snapshot index survives a fork, so no Revert can behave differently. Every index handed to viewData.Revert was produced by a Snapshot() on that same instance after its Fork():

  • protocol.views is the only long-lived holder, and views.Fork() returns NewViews() — snapshotID: 0, empty snapshot map — then repopulates only vm. A forked container cannot yield a pre-fork index.
  • Two call sites hold an index directly, staking.Protocol.Handle and rewarding.slashDelegates. Both snapshot and revert within a single call frame, and forking happens only at working-set construction, so no fork can interleave.
  • statedb.go assigns sdb.protocolViews = ws.views, but that field is only ever Read or Forked, never Snapshot/Revert. workingSet.viewsSnapshots is made fresh per working set.

Post-fork indices now start at 0 instead of len(parent.snapshots), but they are only ever positions in this one slice and Revert truncates with v.snapshots[:snapshot]. Every index shifts by the same constant, so the retained set and the restored values are identical.

The only new failure mode would be a Revert landing on an index the fork no longer has, which returns the deterministic error invalid snapshot index %d. That string appears nowhere in the full test run.

Incidentally the old copy was latently unsafe: fork.snapshots[i].contractsStake was a bare pointer into the parent's unforked wrapper chain, so a revert to a pre-fork index would have installed parent-owned mutable state into the child — and it pinned the parent's whole chain alive.

Effect

Combined with #4966, replaying mainnet from genesis:

build throughput trend extrapolated 0→8M
master 185 → 3.2 blk/s by height 355k linear decay ~325 days
+ #4966 250 → 71 blk/s over 85k blocks still decaying ~60 days
+ this 500–1000 blk/s, still 746 blk/s at height 2.67M flat ~2.7 hours

CPU dropped from 296% to 92% — the GC storm is gone. 0 → 8,000,000 has since been replayed end to end.

Testing

  • go test ./action/protocol/... ./state/factory/... ./systemcontractindex/... ./e2etest/... — all pass on a master base. (e2etest builds and validates real blocks, exercising VerifyDeltaStateDigest.)
  • New TestViewData_Snapshot_RevertAfterFork; two assertions in TestViewData_Fork updated to the new contract.
  • Replay soak with digest verification: 0 → 8,000,000 from genesis, and a post-activation range (32M → 34M, past Okhotsk and past the V1/V2 contract heights, in the heaviest million-block file on the chain) — no digest mismatch. The 32M → 34M soak is still running at the time of writing; I will post the final result here.

Given this one is not height-gated, I would not merge it on the unit tests alone — the post-activation soak is the evidence that matters, and I am happy to run more ranges if you want a specific one covered.

viewData.Fork() deep-copied the whole snapshots slice, allocating a
Snapshot struct and a new(big.Int).Set(...) per retained entry. The slice
grows by one entry per action snapshot (views.Snapshot() fans out to every
registered View, and every EVM snapshot reaches it via workingSet.Snapshot)
and is only cleared by viewData.Commit, which pre-Okhotsk is reached solely
through Protocol.Commit under `if !view.IsDirty()`. Replaying early mainnet
the staking view is almost never dirty, so the log grows roughly without
bound while stateDB.protocolViews is handed from one committed working set
to the next, and each block's fork copies a longer slice than the last --
O(N^2) in blocks replayed.

The copy buys nothing. snapshots is a purely in-memory undo log; neither
Snapshot() nor Revert() takes a StateManager or touches the write queue, so
it can only affect state through the values Revert restores. Every index
handed out by viewData.Snapshot() is consumed by a Revert() on the same
instance: protocol.views, its only long-lived holder, is rebuilt empty by
views.Fork(), and the two callers that hold an index directly (Protocol.Handle
here, slashDelegates in rewarding) snapshot and revert inside one call frame.
No index survives a fork, so the carried entries were unreachable -- and
worse, their contractsStake pointers aliased the parent view's unforked
wrappers, keeping them alive and reachable from the fork.

Indices in the fork now start at 0 instead of len(parent.snapshots); since
they all shift by the same offset and are only ever used as positions in
this slice, truncation in Revert lands on exactly the same entries.

Profiled at height ~750k with the vote-view fix in place, viewData.Fork was
25.6% cumulative CPU (55% of it runtime.newobject) and drove the GC work that
dominated the rest of the profile.

Adds TestViewData_Snapshot_RevertAfterFork: the existing coverage only checked
slice length and never exercised revert after a fork.
views.snapshotID was lowered to id before the cleanup loop, so the loop
bound `i <= views.snapshotID` collapsed to `i <= id` and the loop never ran,
leaking every snapshot entry above id for the life of the views container.

Unobservable beyond memory: an entry at index j > id can only be read by
views.Revert(j), which requires j <= snapshotID, which requires Snapshot()
to have walked back up to j -- and Snapshot() overwrites
views.snapshots[snapshotID] with a fresh map first. The entry at id itself
is still kept, so reverting twice to the same id keeps working.
@envestcc
envestcc requested a review from a team as a code owner August 11, 2026 14:07
@sonarqubecloud

Copy link
Copy Markdown

@envestcc

Copy link
Copy Markdown
Member Author

Post-activation soak result — passed

The 32M → 34M soak referenced in the description has finished.

check result
exit code 0
final height 34,000,000 (target)
Reached stop-at-height logged yes
delta state digest mismatches 0
invalid snapshot index 0
error/fatal/panic lines 6, all p2p noise (error when advertising, error when finding peers, Error when subscribing a broadcast message) — nothing state-related
duration ~11 h, 520k blocks

invalid snapshot index is the one new failure mode this change could introduce (a Revert landing on an index the fork no longer carries). It did not occur.

Coverage: the range is past OkhotskBlockHeight — so workingset.ValidateBlock calls views.Commit() unconditionally on every block, exercising the commit path this change interacts with — and past both the V1 (24,486,464) and V2 (30,934,838) system staking contract heights, so the contract stake view is live rather than idle. It also sits inside chain-00000033.db, the largest million-block file on the chain (15.4 GB), which means a high action count per block and correspondingly frequent snapshot/revert activity — the conditions most likely to surface a snapshot bookkeeping bug.

Alongside this, the from-genesis range 0 → 8,000,000 also completed with this change applied.

One thing worth stating plainly for the record, though it is a pre-existing property and not related to this PR: CheckIndexer retries indefinitely on ErrDeltaStateMismatch below HawaiiBlockHeight (11,267,641), so digest verification below that height only establishes that a computation path reaches the correct digest, not that every one does. All the ranges cited above as mismatch-free are either entirely above Hawaii (this soak) or were separately audited for retry counts.

Happy to run another range if you want specific heights covered — e.g. across Xingu (41,648,761), where contractStakeView.Migrate runs.

@envestcc

Copy link
Copy Markdown
Member Author

Independent end-to-end measurement — the quadratic decay is gone

Ran this while doing v2.5.0-rc2 whole-chain fullsync verification. The existing soak on this PR covers correctness at 32M–34M; this adds the performance payoff at the low heights where the decay actually bites, plus a from-genesis completion.

Before/after at matched heights

Both runs on the same box (24-core, NVMe RAID0), same 0m checkpoint, same corpus, replaying mainnet 0 → 800,000. Rate averaged over each 100k window, taken from the indexer is catching up log timestamps.

height window without this PR with this PR ratio
0 – 100k 120.7 blk/s 1578.6 blk/s 13×
100k – 200k 47.9 1673.6 35×
200k – 300k 24.0 1811.4 75×
300k – 400k 20.5 1740.1 85×
400k – 500k 16.4 1729.8 105×
500k – 600k 14.3 1340.0 94×
600k – 700k 12.3 1197.9 97×
700k – 800k 9.3 1338.8 144×

The ratio is not the point — the shape is. Without the PR the rate decays monotonically, 120 → 9, and a power-law fit (exponent ≈ 0.76) extrapolates 800k → 8M at ~35 days. With it the curve is flat: it ends the run no slower than it started.

On concurrency, since it is the obvious confound: the baseline's early windows ran with more concurrent replay segments on the box than its late ones, so its early numbers are if anything understated. The cleanest matched pair is the 700k–800k row — baseline and experiment both ran with the same two segments active — and that is where the gap is widest.

From-genesis run

0 → 8,000,000, same binary:

check result
exit code 0
final height 8,000,000 (target)
wall clock 1 h 54 m
delta state digest mismatches (error level) 0
fatal 0
dumped state write queue (info-level retries) 12, all below Hawaii (11,267,641); 0 above

For reference the same range on the unpatched binary was still at 785,000 after many hours when I stopped it.

Why the snapshots copy and not something else

CPU profile taken from the degraded state (255k blocks in) on a binary carrying #4966 and this PR's protocol.go commit but not its viewdata.go commit:

      flat  flat%   cum   cum%
     6.63s 15.03% 22.65s 51.36%  staking.(*viewData).Fork

with Fork's own callees:

  runtime.newobject        11.89s  52.49%
  runtime.makeslice         2.03s   8.96%   <- make([]Snapshot, len(v.snapshots))
  math/big.(*Int).Set       1.97s   8.70%   <- new(big.Int).Set(...) per entry
  contractStakeView.Fork    0.01s   0.04%

Fork carries 15% flat — the cost is in its own body, not a callee. CandidateCenter.Clone, the other O(N) thing Fork calls, does not appear in the profile at all at this height. That matches the diff: the slice copy and its per-entry big.Int are the whole cost.

Caveats

  • The measured binary also carried an unrelated local change of mine (copy-on-write for candBase in CandidateCenter.Fork). Attribution to this PR rests on the profile above — CandidateCenter.Clone accounted for zero time at these heights — not on an isolating A/B. If you want the isolation run before merging, say so and I will do it.
  • Baseline = v2.5.0-rc2 + fix(staking): skip vote-view layering before the staking contract exists #4966 + this PR's protocol.go commit. Only the viewdata.go commit differs between the two columns.

One thing worth flagging beyond replay

stateDB.Validate → getFromWorkingSets → newWorkingSet → views.Fork runs once per block on the normal block-processing path, not just during replay. len(v.snapshots) grows over time, so this is a per-block cost that grows with chain age — invisible today only because it fits inside the 5 s block interval. Replay just compresses that budget until the trend becomes measurable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant