Skip to content

perf(prewarm): execute warm transactions with real fee and nonce semantics - #12413

Merged
svlachakis merged 9 commits into
masterfrom
perf/warmup-fidelity
Jul 13, 2026
Merged

perf(prewarm): execute warm transactions with real fee and nonce semantics#12413
svlachakis merged 9 commits into
masterfrom
perf/warmup-fidelity

Conversation

@svlachakis

@svlachakis svlachakis commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Changes

ExecutionOptions.Warmup | SkipValidation routed warm transactions to SystemTransactionProcessor, whose BuyGas/PayFees/IncrementNonce are no-ops. Consequence: same-sender warm sequences executed with undebited balances and unbumped nonces, so deploy chains computed wrong CREATE addresses and warmed the wrong storage — a systematic prewarmer-coverage hole on exactly the heavy blocks.

  • Routing: warmup now takes the regular processor, which under SkipValidation already debits gas and value and bumps the nonce (skipping only the fail checks), and whose existing Warmup guards keep the shared transaction object unmutated. Only opts == SkipValidation (and true system txs) still take the system processor; ITransactionProcessor.Warmup is the single caller of the warmup combination.
  • Best-effort charging: a warm sender funded earlier in the block by another sender's transaction has no parent-state balance, and per-sender warm groups cannot see that funding. BuyGas/PayValue under Warmup charge what exists instead of failing, so such senders keep warming their execution path — no scenario warms less than before this PR.
  • Contract tests updated: Warmup_does_not_modify_sender_nonce / Warmup_does_not_deduct_sender_balance asserted the no-op semantics this PR removes; they now assert the real-semantics warm scope (bumped nonce, debited balance), alongside new tests for the exact debit, the unmutated shared transaction, and the underfunded-sender path.

All effects stay inside the prewarmer's throwaway scope; consensus execution is untouched.

Testing

  • TransactionProcessorWarmupTests: exact value+gas debit, nonce bump, shared transaction unmutated, underfunded sender still executes.
  • Updated warmup contract tests in TransactionProcessorTests.
  • Full solution 121 projects / 0 errors; Nethermind.Evm.Test 4887/4887; Nethermind.Blockchain.Test 1552/1552; Nethermind.Consensus.Test 110/110; dotnet format clean.

Types of changes

  • Optimization

…ntics

Warm transactions routed to the system processor, whose BuyGas, PayFees
and IncrementNonce are no-ops: same-sender warm sequences ran with
undebited balances and unbumped nonces, so deploy chains computed wrong
CREATE addresses and warmed the wrong storage. The regular processor
under SkipValidation already debits, bumps and skips only the fail
checks, and its Warmup guards keep the shared transaction unmutated —
route warmup there and keep the system processor for system
transactions.
@claude

claude Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 4m 37s —— View job


Review: perf(prewarm): execute warm transactions with real fee and nonce semantics

  • Read the diff (3 files) and surrounding context
  • Trace the routing-condition change in ExecuteCore
  • Verify consensus-safety (throwaway scope, shared-tx immutability)
  • Assess behavioral deltas vs. the old system-processor path
  • Review the new test

Verdict

The change is correct, minimal, and consensus-safe. The routing predicate goes from (opts & ~Warmup) == SkipValidation to opts == SkipValidation, which flips exactly one input — Warmup | SkipValidation — from the no-op SystemTransactionProcessor onto the real execution path. Every other combination (SkipValidation, SkipValidationAndCommit, CommitAndRestore, true system txs) routes identically to before. Warmup is only ever set via the single ITransactionProcessor.Warmup extension, so the blast radius is precisely the prewarmer.

Why it's safe:

  • commit stays false (line 218 — SkipValidation is set), so nothing is committed; all effects live in the prewarmer's throwaway IReadOnlyTxProcessingScope.
  • The pre-existing Warmup guards at TransactionProcessor.cs:577 / :583 keep tx.BlockGasUsed / tx.SpentGas unwritten, so the shared Transaction objects the main thread also touches are not mutated. Reads in the warm path are of immutable tx fields only — no new data race.
  • UpdateMetrics is gated to Commit/None (line 876), so warm runs don't pollute gas gauges. IncrementCreates/IncrementOpCodes/IncrementEmptyCalls were already hit under the old system-processor path (it runs the same EVM), so no metrics regression.

Why it's a real fix: BuildExecutionEnvironment derives the CREATE address from WorldState.GetNonce(sender) (line 1221). The old path left the nonce unbumped (SystemTransactionProcessor.IncrementNonce is a no-op), so same-sender deploy chains computed identical CREATE addresses for every tx and warmed the wrong storage slots. Routing through the real IncrementNonce/BuyGas/PayValue (which under SkipValidation bump/debit but skip the fail checks) fixes this and makes the warmed state match what real execution will touch.

Findings

Low — regression test guards the routing, not the described scenario. TransactionProcessorWarmupTests covers a single simple-transfer tx (debit + nonce bump + shared-tx untouched). This does catch a revert of the routing condition (nonce would stay 0), so it's an effective guard. But the motivating bug — a same-sender deploy chain computing wrong CREATE addresses — isn't directly reproduced. A second test that warms two same-sender txs and asserts the successor observes the bumped nonce (and, ideally, that a CREATE lands at the nonce-N address) would document the actual fix. Not blocking.

Low / informational — mempool-prewarm coverage for underpriced/underfunded txs. On the real path, BuyGas now runs its ShouldValidateGas branch for nonzero-fee txs and can return early (MaxFeePerGasBelowBaseFee, insufficient funds) instead of the old unconditional no-op, so such txs no longer warm their EVM storage. This is intended fidelity (those txs wouldn't execute in a valid block), but worth noting for speculative mempool prewarming where a pending tx may be transiently underpriced. No action needed.

No Critical, High, or Medium issues. Consensus execution is untouched.
· branch perf/warmup-fidelity

@svlachakis
svlachakis marked this pull request as draft July 13, 2026 00:47
…rm senders best-effort

A warm sender funded earlier in the block by another sender's
transaction has no parent-state balance; per-sender warm groups cannot
see that funding, so BuyGas/PayValue charge what exists instead of
failing and losing the sender's warming. The old contract tests
asserted the no-op semantics this PR removes; they now assert the
real-semantics warm scope.
@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

EXPB Benchmark Comparison

Run: View workflow run

superblocks

Scenario: nethermind-flat-superblocks-perf-warmup-fidelity-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 981.65 987.28 -0.57%
MEDIAN (ms) 914.9 925.7 -1.17%
P90 (ms) 1221.9 1226.4 -0.37%
P95 (ms) 1493.8 1578.4 -5.36%
P99 (ms) 3223.5 3147.5 +2.41%
MIN (ms) 651.4 660.5 -1.38%
MAX (ms) 3223.5 3147.5 +2.41%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 1181.95 1182.90 -0.08%
MEDIAN (ms) 1063.99 1067.91 -0.37%
P90 (ms) 1409.30 1403.59 +0.41%
P95 (ms) 1705.98 1729.48 -1.36%
P99 (ms) 3551.61 3499.42 +1.49%
MIN (ms) 752.19 754.42 -0.30%
MAX (ms) 5509.05 6069.19 -9.23%

realblocks

Scenario: nethermind-flat-realblocks-perf-warmup-fidelity-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 25.58 25.67 -0.35%
MEDIAN (ms) 21.4 21.4 +0.00%
P90 (ms) 43.8 43.6 +0.46%
P95 (ms) 53.0 51.4 +3.11%
P99 (ms) 96.9 102.7 -5.65%
MIN (ms) 0.3 0.3 +0.00%
MAX (ms) 183.0 184.3 -0.71%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 29.44 29.43 +0.03%
MEDIAN (ms) 25.38 25.23 +0.59%
P90 (ms) 48.05 47.36 +1.46%
P95 (ms) 59.06 55.69 +6.05%
P99 (ms) 99.33 104.69 -5.12%
MIN (ms) 0.89 1.86 -52.15%
MAX (ms) 186.38 187.30 -0.49%

fusaka

Scenario: nethermind-flat-fusaka-perf-warmup-fidelity-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 33.49 33.44 +0.15%
MEDIAN (ms) 29.5 29.1 +1.37%
P90 (ms) 50.8 51.7 -1.74%
P95 (ms) 61.6 62.1 -0.81%
P99 (ms) 121.9 121.9 +0.00%
MIN (ms) 4.9 4.9 +0.00%
MAX (ms) 520.8 515.6 +1.01%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 38.97 38.91 +0.15%
MEDIAN (ms) 34.70 34.59 +0.32%
P90 (ms) 60.09 60.26 -0.28%
P95 (ms) 69.02 69.51 -0.70%
P99 (ms) 126.55 126.22 +0.26%
MIN (ms) 5.77 6.59 -12.44%
MAX (ms) 523.65 518.62 +0.97%

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

EVM Opcode Benchmark Diff

Aggregated runs: base=3, pr=3
Noisy rerun opcodes: MULMOD, SELFDESTRUCT

No significant regressions or improvements detected.

# Conflicts:
#	src/Nethermind/Nethermind.Evm/TransactionProcessing/TransactionProcessor.cs
@svlachakis
svlachakis marked this pull request as ready for review July 13, 2026 15:32
@claude

claude Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 2m 31s —— View job


Review: perf(prewarm): execute warm transactions with real fee and nonce semantics

  • Read the diff (4 files) and surrounding context
  • Trace routing change + best-effort charging in BuyGas/PayValue
  • Verify consensus-safety and thread-safety of the warm path
  • Assess edge cases (blob txs, nonce, PayFees, refunds)
  • Post review

Verdict

The change is correct, minimal, and consensus-safe. I concur with the earlier review and @benaadams's approval, and add a few edge-case observations below. Nothing blocks merge.

Routing (TransactionProcessor.cs:167): the predicate tightens from (opts & ~Warmup) == SkipValidation to opts == SkipValidation, which moves only the Warmup | SkipValidation combination off SystemTransactionProcessor onto the real path. Warmup is set exclusively by the single ITransactionProcessor.Warmup extension, so the blast radius is precisely the prewarmer. Every other combination (SkipValidation, SkipValidationAndCommit, CommitAndRestore, true system txs) routes as before.

Consensus-safe: commit/restore are both false for Warmup | SkipValidation, so execution lands in the ResetTransient() branch (:590) — nothing is committed; all effects stay in the prewarmer's throwaway IReadOnlyTxProcessingScope. The pre-existing Warmup guards at :555/:561 keep tx.BlockGasUsed/tx.SpentGas unwritten, so the shared Transaction objects are not mutated (the new test asserts this via the BlockGasUsed gas-limit fallback). Warm reads touch only immutable tx fields — no new data race.

Real fix confirmed: BuildExecutionEnvironment derives the CREATE recipient from WorldState.GetNonce(sender) (:1209). The old system-processor path left the nonce unbumped, so same-sender deploy chains collided on one CREATE address. Routing through the real IncrementNonce/BuyGas/PayValue (which under SkipValidation bump/debit but skip the fail checks) fixes this. The new Warmup_ForASameSenderDeployChain_DeploysAtConsecutiveCreateAddresses test now directly reproduces the motivating bug — a good addition addressing the prior review's suggestion.

Findings

Low — the "no scenario warms less than before" claim has narrow exceptions. On the real path BuyGas runs ShouldValidateGas for nonzero-fee txs and can now return early — MaxFeePerGasBelowBaseFee (:1078), the 256-bit overflow guards, or the unconditional blob-fee checks (:1133) — so those txs stop warming their EVM path, whereas the old no-op system processor warmed unconditionally. In every such case the tx is invalid and would not execute in a valid block, so the lost warming was wasted work; the claim holds for txs that actually execute. Worth a one-line caveat in the PR description rather than a code change. (Already noted informationally in the prior review.)

Low — best-effort branch can leave the warm-scope sender balance above reality. When BuyGas takes the underfunded warm branch (:1151) it charges Min(senderReservedGasPayment, balance) but still returns the full senderReservedGasPayment via the out-param, and execution proceeds with the full gas limit. RefundPayRefund (:1720/:1791) then credits (GasLimit − spentGas) × gasPrice back to the sender — so a zero-balance underfunded sender can finish the warm run with a positive balance it never had. This is confined to the throwaway scope (consensus re-executes from the real parent state) and only occurs for the inherently-unwarmable cross-sender in-block funding case, so there is no correctness impact — noting it only because the balance the successor warms against is then slightly fictional. No action needed.

No Critical, High, or Medium issues. Consensus execution is untouched; the warm path stays entirely within the prewarmer's throwaway scope.
· branch perf/warmup-fidelity

Comment thread src/Nethermind/Nethermind.Evm/TransactionProcessing/ITransactionProcessor.cs Outdated
Comment thread src/Nethermind/Nethermind.Evm/TransactionProcessing/TransactionProcessor.cs Outdated
@svlachakis
svlachakis merged commit bb2baa9 into master Jul 13, 2026
744 of 746 checks passed
@svlachakis
svlachakis deleted the perf/warmup-fidelity branch July 13, 2026 17:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants