fix: warmup transactions bypass SystemTransactionProcessor due to Warmup flag#10628
Conversation
…mup flag PR #10267 added a `Warmup = 8` flag to ExecutionOptions, changing warmup opts from `SkipValidation` (4) to `Warmup | SkipValidation` (12). This broke the equality check `opts == ExecutionOptions.SkipValidation` in ExecuteCore, which gates routing to the lightweight SystemTransactionProcessor. As a result, warmup transactions fell through to the full Execute() path — performing BuyGas, IncrementNonce, PayFees, PayRefund and other state-mutating operations unnecessarily. The same pattern existed in SystemTransactionProcessor.Execute where opts equality checks determined whether to add OriginalValidate flag. Fix: mask out the Warmup flag before comparing ExecutionOptions in both ExecuteCore and SystemTransactionProcessor.Execute.
EXPB Benchmark ComparisonRun: View workflow run Scenario:
|
…mup flag Cherry-pick of fix/warmup-system-tx-routing (#10628) adapted for perf/flat. Mask out the Warmup flag before comparing ExecutionOptions in ExecuteCore (both OnFlag and OffFlag branches) and SystemTransactionProcessor.Execute.
| { | ||
| if (Logger.IsTrace) Logger.Trace($"Executing tx {tx.Hash}"); | ||
| if (tx.IsSystem() || opts == ExecutionOptions.SkipValidation) | ||
| if (tx.IsSystem() || (opts & ~ExecutionOptions.Warmup) == ExecutionOptions.SkipValidation) |
There was a problem hiding this comment.
| if (tx.IsSystem() || (opts & ~ExecutionOptions.Warmup) == ExecutionOptions.SkipValidation) | |
| if (tx.IsSystem() || (opts & ExecutionOptions.SkipValidation) == ExecutionOptions.SkipValidation) |
There was a problem hiding this comment.
according to claude:
CommitAndRestore = Commit | Restore | SkipValidation = 7. With HasFlag(SkipValidation): (7 & 4) == 4 → TRUE → routes CallAndRestore to SystemTransactionProcessor.
which is not a desired outcome. same with
SkipValidationAndCommit = 5. (5 & 4) == 4 → TRUE → routes Trace() to SystemTransactionProcessor. Also a behavioral change.
…p in base Execute Instead of routing warmup through SystemTransactionProcessor (which wraps the spec in SystemTransactionReleaseSpec with 119 virtual dispatch properties), handle warmup directly in the base Execute method by checking the Warmup flag and skipping state-mutating operations. This avoids the ReleaseSpecDecorator allocation overhead while achieving the same semantic: warmup executes the EVM for cache warming without modifying gas, nonce, or balance state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use HasFlag-style check instead of exact equality for OriginalValidate guard, making it resilient to future ExecutionOptions flag combinations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Benchmarks Against
|
| Method | Master | Fix | Change |
|---|---|---|---|
| Warmup_SimpleTx | 3,239 ns | 1,009 ns | -69% faster |
| Warmup_AccessListTx | 3,435 ns | 1,154 ns | -66% faster |
| Warmup_Eip1559Tx | 3,128 ns | 1,043 ns | -67% faster |
| Warmup_ContractCall | 3,265 ns | 994 ns | -70% faster |
| CallAndRestore_SimpleTx | 3,186 ns | 3,000 ns | -6% (noise) |
| CallAndRestore_ContractCall | 3,390 ns | 3,166 ns | -7% (noise) |
Allocations
| Method | Master | Fix | Change |
|---|---|---|---|
| Warmup_SimpleTx | 4.95 KB | 1.73 KB | -65% |
| Warmup_AccessListTx | 5.04 KB | 1.81 KB | -64% |
| Warmup_Eip1559Tx | 4.95 KB | 1.74 KB | -65% |
| Warmup_ContractCall | 4.59 KB | 1.48 KB | -68% |
Detailed Fix Stats
| Method | Mean | Error | StdDev | Min | Max | Median | P90 | P95 |
|---|---|---|---|---|---|---|---|---|
| Warmup_SimpleTx | 1,009 ns | 66.6 ns | 99.7 ns | 892 ns | 1,204 ns | 965 ns | 1,152 ns | 1,185 ns |
| Warmup_AccessListTx | 1,154 ns | 31.1 ns | 44.6 ns | 1,070 ns | 1,251 ns | 1,158 ns | 1,208 ns | 1,226 ns |
| Warmup_Eip1559Tx | 1,043 ns | 90.6 ns | 127.0 ns | 925 ns | 1,296 ns | 972 ns | 1,241 ns | 1,256 ns |
| Warmup_ContractCall | 994 ns | 73.3 ns | 105.2 ns | 884 ns | 1,231 ns | 989 ns | 1,132 ns | 1,196 ns |
BlockProcessingBenchmark Comparison:
|
| Method | Mean | StdDev | Median | P90 | P95 | Allocated |
|---|---|---|---|---|---|---|
| EmptyBlock | 187.3 us | 22.91 us | 182.0 us | 216.0 us | 223.3 us | 13.47 KB |
| SingleTransfer | 305.2 us | 31.94 us | 309.5 us | 347.2 us | 353.8 us | 25.02 KB |
| Transfers_50 | 1,528.1 us | 109.35 us | 1,524.1 us | 1,653.6 us | 1,703.1 us | 2603.68 KB |
| Transfers_200 | 3,881.6 us | 472.07 us | 3,638.4 us | 4,661.9 us | 4,758.4 us | 3005.4 KB |
| Eip1559_200 | 3,418.1 us | 504.81 us | 3,495.2 us | 4,355.0 us | 4,454.8 us | 2995.27 KB |
| AccessList_50 | 874.6 us | 60.21 us | 899.4 us | 943.1 us | 951.3 us | 1651.36 KB |
| ContractDeploy_10 | 512.2 us | 42.26 us | 499.7 us | 575.9 us | 603.0 us | 1549.18 KB |
| ContractCall_200 | 1,542.7 us | 86.28 us | 1,533.5 us | 1,645.8 us | 1,678.0 us | 2563.79 KB |
| MixedBlock | 976.9 us | 49.93 us | 971.6 us | 1,057.6 us | 1,060.9 us | 2921.45 KB |
Warmup PR (fix/warmup-system-tx-routing)
| Method | Mean | StdDev | Median | P90 | P95 | Allocated |
|---|---|---|---|---|---|---|
| EmptyBlock | 191.8 us | 32.56 us | 180.6 us | 225.2 us | 248.0 us | 13.94 KB |
| SingleTransfer | 270.6 us | 23.92 us | 267.8 us | 304.9 us | 312.1 us | 26.2 KB |
| Transfers_50 | 1,156.6 us | 243.89 us | 1,345.9 us | 1,402.6 us | 1,437.8 us | 2610.78 KB |
| Transfers_200 | 2,771.5 us | 203.71 us | 2,699.4 us | 3,012.1 us | 3,035.6 us | 2927.77 KB |
| Eip1559_200 | 2,585.7 us | 190.18 us | 2,571.5 us | 2,806.8 us | 2,813.8 us | 2927.09 KB |
| AccessList_50 | 694.9 us | 100.70 us | 708.3 us | 811.0 us | 815.1 us | 2114.2 KB |
| ContractDeploy_10 | 393.3 us | 75.19 us | 392.2 us | 470.3 us | 502.0 us | 1058.13 KB |
| ContractCall_200 | 922.5 us | 42.51 us | 907.9 us | 981.5 us | 1,000.6 us | 1773.63 KB |
| MixedBlock | 641.6 us | 36.79 us | 639.5 us | 683.2 us | 700.5 us | 2234.95 KB |
Delta (Warmup PR vs master)
| Method | Master Mean | Warmup Mean | Delta | Change | Alloc Delta |
|---|---|---|---|---|---|
| EmptyBlock | 187.3 us | 191.8 us | +4.5 us | +2.4% | +0.47 KB |
| SingleTransfer | 305.2 us | 270.6 us | -34.6 us | -11.3% | +1.18 KB |
| Transfers_50 | 1,528.1 us | 1,156.6 us | -371.5 us | -24.3% | +7.1 KB |
| Transfers_200 | 3,881.6 us | 2,771.5 us | -1,110.1 us | -28.6% | -77.6 KB |
| Eip1559_200 | 3,418.1 us | 2,585.7 us | -832.4 us | -24.4% | -68.2 KB |
| AccessList_50 | 874.6 us | 694.9 us | -179.7 us | -20.5% | +462.8 KB |
| ContractDeploy_10 | 512.2 us | 393.3 us | -118.9 us | -23.2% | -491.1 KB |
| ContractCall_200 | 1,542.7 us | 922.5 us | -620.2 us | -40.2% | -790.2 KB |
| MixedBlock | 976.9 us | 641.6 us | -335.3 us | -34.3% | -686.5 KB |
Key Takeaways
- EmptyBlock / SingleTransfer: No meaningful change (< 3 txs, pre-warming does not trigger) — within noise.
- Blocks with 3+ transactions show 20–40% improvement in mean processing time on the warmup PR.
- The fix skips
BuyGas,IncrementNonce,PayFees,PayValue, andPayRefundduring the pre-warmer’sWarmup()calls, making the concurrent warmup pass substantially cheaper.
- The fix skips
- ContractCall_200 shows the largest improvement (-40.2%).
- Contract calls execute the EVM during warmup, so skipping gas/nonce/fee accounting has a larger relative impact.
- Allocations drop significantly in most scenarios (up to -790 KB for
ContractCall_200), because the warmup path no longer runs the full gas accounting and fee payment code paths.
|
Redoing tests - seems like new commits have made some regression which is weird |
|
Do not merge - new findings are made and working on proper adjustment |
…actionProcessor Reverts d9ca918 which moved warmup handling into the base Execute method with scattered HasFlag(Warmup) checks. BlockProcessingBenchmark showed 30-50% regressions on transfer-heavy scenarios (Transfers_200 +42%, Eip1559_200 +49%) because the checks polluted the hot path and the prewarmer took the heavier base Execute path instead of the lighter SystemTransactionProcessor overrides. SystemTransactionProcessor already handles all state-mutating skips (BuyGas, IncrementNonce, PayFees, PayValue, PayRefund) via sealed virtual overrides, keeping the base Execute path clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…mup flag (#10628) * fix: warmup transactions bypass SystemTransactionProcessor due to Warmup flag PR #10267 added a `Warmup = 8` flag to ExecutionOptions, changing warmup opts from `SkipValidation` (4) to `Warmup | SkipValidation` (12). This broke the equality check `opts == ExecutionOptions.SkipValidation` in ExecuteCore, which gates routing to the lightweight SystemTransactionProcessor. As a result, warmup transactions fell through to the full Execute() path — performing BuyGas, IncrementNonce, PayFees, PayRefund and other state-mutating operations unnecessarily. The same pattern existed in SystemTransactionProcessor.Execute where opts equality checks determined whether to add OriginalValidate flag. Fix: mask out the Warmup flag before comparing ExecutionOptions in both ExecuteCore and SystemTransactionProcessor.Execute. * perf: skip BuyGas/IncrementNonce/PayFees/PayValue/PayRefund for warmup in base Execute Instead of routing warmup through SystemTransactionProcessor (which wraps the spec in SystemTransactionReleaseSpec with 119 virtual dispatch properties), handle warmup directly in the base Execute method by checking the Warmup flag and skipping state-mutating operations. This avoids the ReleaseSpecDecorator allocation overhead while achieving the same semantic: warmup executes the EVM for cache warming without modifying gas, nonce, or balance state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use bit-flag check for SkipValidation in SystemTransactionProcessor Use HasFlag-style check instead of exact equality for OriginalValidate guard, making it resilient to future ExecutionOptions flag combinations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * revert: remove warmup checks from base Execute, route via SystemTransactionProcessor Reverts d9ca918 which moved warmup handling into the base Execute method with scattered HasFlag(Warmup) checks. BlockProcessingBenchmark showed 30-50% regressions on transfer-heavy scenarios (Transfers_200 +42%, Eip1559_200 +49%) because the checks polluted the hot path and the prewarmer took the heavier base Execute path instead of the lighter SystemTransactionProcessor overrides. SystemTransactionProcessor already handles all state-mutating skips (BuyGas, IncrementNonce, PayFees, PayValue, PayRefund) via sealed virtual overrides, keeping the base Execute path clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: smartprogrammer93 <smartprogrammer@windowslive.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Ahmad Bitar <33181301+smartprogrammer93@users.noreply.github.com>
…mup flag (#10628) * fix: warmup transactions bypass SystemTransactionProcessor due to Warmup flag PR #10267 added a `Warmup = 8` flag to ExecutionOptions, changing warmup opts from `SkipValidation` (4) to `Warmup | SkipValidation` (12). This broke the equality check `opts == ExecutionOptions.SkipValidation` in ExecuteCore, which gates routing to the lightweight SystemTransactionProcessor. As a result, warmup transactions fell through to the full Execute() path — performing BuyGas, IncrementNonce, PayFees, PayRefund and other state-mutating operations unnecessarily. The same pattern existed in SystemTransactionProcessor.Execute where opts equality checks determined whether to add OriginalValidate flag. Fix: mask out the Warmup flag before comparing ExecutionOptions in both ExecuteCore and SystemTransactionProcessor.Execute. * perf: skip BuyGas/IncrementNonce/PayFees/PayValue/PayRefund for warmup in base Execute Instead of routing warmup through SystemTransactionProcessor (which wraps the spec in SystemTransactionReleaseSpec with 119 virtual dispatch properties), handle warmup directly in the base Execute method by checking the Warmup flag and skipping state-mutating operations. This avoids the ReleaseSpecDecorator allocation overhead while achieving the same semantic: warmup executes the EVM for cache warming without modifying gas, nonce, or balance state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use bit-flag check for SkipValidation in SystemTransactionProcessor Use HasFlag-style check instead of exact equality for OriginalValidate guard, making it resilient to future ExecutionOptions flag combinations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * revert: remove warmup checks from base Execute, route via SystemTransactionProcessor Reverts d9ca918 which moved warmup handling into the base Execute method with scattered HasFlag(Warmup) checks. BlockProcessingBenchmark showed 30-50% regressions on transfer-heavy scenarios (Transfers_200 +42%, Eip1559_200 +49%) because the checks polluted the hot path and the prewarmer took the heavier base Execute path instead of the lighter SystemTransactionProcessor overrides. SystemTransactionProcessor already handles all state-mutating skips (BuyGas, IncrementNonce, PayFees, PayValue, PayRefund) via sealed virtual overrides, keeping the base Execute path clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: smartprogrammer93 <smartprogrammer@windowslive.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Ahmad Bitar <33181301+smartprogrammer93@users.noreply.github.com>
|
@claude This PR brings significant performance imporvement but it also increased the "Get" RocksDb method OwnTime per dottrace by 25k ms - making it not super optimal solution - any ideas how to make it even better? |
|
Claude finished @kamilchodola's task in 5m 24s —— View job Analysis: Why RocksDB "Get" OwnTime Increased — and How to Reduce It Further
Root Cause of the Extra RocksDB ReadsThe increase in RocksDB "Get" OwnTime is expected and is actually a sign the fix is working correctly. Here's why: Before the fix (broken path through full The After the fix (correct path through
The 25k ms extra cost in warmup-thread RocksDB reads is paying for useful work that saves much more than 25k ms in the main thread. The EXPB numbers confirm this — the overall latencies are similar/better even with more warmup-side reads. Ideas for Further Reduction1. Batch-load statically-known addresses before EVM execution (highest impact) In // In WarmupSingleTransaction, before Warmup() call:
worldState.WarmUp(tx.To); // recipient
worldState.WarmUp(header.Beneficiary); // coinbase is accessed by PayFees on main threadThe coinbase in particular is accessed by every transaction on the main thread (in 2. Use RocksDB MultiGet for access lists
3. Thundering herd on When multiple warmup threads process transactions that all call the same popular contract (e.g., USDC, a DEX), they can all get a cache miss on the same storage slots simultaneously and all go to RocksDB for the same key. The A simple "in-flight" set (or first-writer-wins pattern) on PreBlockCaches would let the first thread load the slot while others skip the duplicate DB read. 4. Skip EVM for pure ETH transfers For transactions where 5. Speculative slot pre-loading from recent block history For high-frequency contracts (DEXs, stablecoins), the storage slots accessed are largely stable across blocks. A sliding-window bloom filter tracking which SummaryThe 25k ms RocksDB increase is the correct trade-off — the warmup is doing more useful work than before. The highest-leverage quick win is #1 (batch pre-load recipient + coinbase before EVM) and #2 (MultiGet for access lists), both of which are small, focused changes to |

Changes
Fixes a performance regression introduced by #10267 where the pre-warmer executes transactions through the full heavy
Execute()path instead of the lightweightSystemTransactionProcessorpath.Root Cause
PR #10267 added a
Warmup = 8flag toExecutionOptionsand changed the warmup call from:to:
The routing logic in
ExecuteCoreused an equality check:This broke the routing: warmup transactions no longer matched and fell through to the full
Execute()path, which performs:The
SystemTransactionProcessoroverrides all of these as no-ops, making itsignificantlycheaper for warmup/system transactions.The same pattern existed in
SystemTransactionProcessor.Executewhereopts != ExecutionOptions.SkipValidationwas used to decide whether to add theOriginalValidateflag. With the Warmup bit set, this comparison also broke, causing incorrect flag propagation.Fix
Mask out the
Warmupflag before comparingExecutionOptions:TransactionProcessor.cs(ExecuteCore):SystemTransactionProcessor.cs(Execute):Impact
During block processing, the pre-warmer runs transactions ahead of the main thread to warm state caches. Without this fix, every warmup transaction performs unnecessary state mutations (gas accounting, nonce increment, fee distribution) that:
SystemTransactionProcessorfor lightweight executionOn benchmarks running 10k+ sequential blocks, this compounds into a measurable performance regression.
Type of change
Testing
Added 3 regression tests to
TransactionProcessorTests:Warmup_does_not_update_SpentGas— verifies warmup doesn't write totx.SpentGasWarmup_does_not_modify_sender_nonce— verifies warmup doesn't increment sender nonceWarmup_does_not_deduct_sender_balance— verifies warmup doesn't deduct gas/value from senderAll 6 test runs pass (3 tests × 2 EIP-155 fixture variants).