Skip to content

perf(rpc): fix eth_call degradation under concurrent load (50→300 rps) - #12870

Open
kamilchodola wants to merge 10 commits into
masterfrom
perf/ethcall-concurrency
Open

perf(rpc): fix eth_call degradation under concurrent load (50→300 rps)#12870
kamilchodola wants to merge 10 commits into
masterfrom
perf/ethcall-concurrency

Conversation

@kamilchodola

@kamilchodola kamilchodola commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Changes

  • Per-thread LIFO caches in front of the global VmState and EVM stack pools — the shared ConcurrentQueues cost a contended CAS per call frame and migrate ~33 KB pinned stack arrays across cores under concurrent RPC load; shared queues remain as overflow.
  • No per-hit Interlocked ticker in AssociativeCache/AssociativeKeyCache (the RMW dirtied the line every lookup reads first); coarse-clock eviction age, write-free steady-state hits; test-before-Or in ClockCacheBase.MarkAccessed.
  • StripedLong (per-core 128 B slots) for hot shared metric counters (previously one shared "other" word for all RPC/prewarm threads, updated per trie-node read / DB get / code lookup); GC.Add/RemoveMemoryPressure only for spans ≥ 16 KB (was: twice per DB read).
  • Thread-local retention for mid-size (64–256 KB) EVM memory buffers — the buffers being zero-extended stay warm on the executing core.
  • Init.InstructionStreamCacheSize config, default 4096 (was a hardcoded 1024 — the largest default-memory change in this PR, see Remarks). Under concurrency the hot code set outgrows the cache's set associativity and frames silently fall back to the streamless interpreter (own-time ×7 at 300 rps in symbolized perf profiles).
  • JsonRpc.EthModuleConcurrentInstances default 1× → 2× core count + JsonRpcOverloadRejections metric — the caps fail fast and offered concurrency = rate × latency, so at 1× a node rejects override-carrying eth_calls whenever avg latency crosses ~cores/rate; the shedding was invisible (warnings deliberately suppressed).

Evidence

Private 497-call mainnet eth_call corpus, 8-core box, ABBA arms vs pinned master. Response parity 497/497 on every arm of every run.

Fully warmed node, idle machine — state fully cached, ~27 ms/call at 300 rps, concurrency ≈ core count (all values ms):

50 rps master 50 rps PR Δ 300 rps master 300 rps PR Δ
median 22.42 22.03 −1.8% 27.58 26.80 −2.8%
avg 22.18 21.01 −5.3% 27.70 29.31 +5.8% ¹
p95 52.95 49.32 −6.8% 60.90 60.42 −0.8%
p99 103.42 83.52 −19.2% 192 357 ¹

¹ one spike cell at n=3; the loaded-regime table below (n=6) shows the systematic tail behavior.

Partially warmed node — fresh start, caches still filling (typical after restart/snapshot sync or under cache churn): ~45 ms/call at 300 rps pushes offered concurrency past the core count, so queueing and stalls appear — the regime where the degradation actually hurts (n=6 cells per side):

50 rps master 50 rps PR Δ 300 rps master 300 rps PR Δ
median 30.03 29.22 −2.7% 46.73 45.07 −3.5%
avg 29.48 26.29 −10.8% 68.94 54.22 −21.3%
p95 73.45 58.17 −20.8% 190.75 161.79 −15.2%
p99 116.73 75.23 −35.6% 912.81 248.46 −72.8%
failed requests 0% 0% 1.45% ² 0.83% −43%

² ~4% at the old 1×-core default cap; both arms above ran with the cap raised.

InstructionStreamCacheSize 1024 → 4096 in isolation (same build, env-only A/B, 300 rps — this is the PR's shipped default; the tables above include it): avg −29.5%, p90 −40.6%, p95 −45.9%, failures 1.76% → 0.35%.

Block processing safety (expb fusaka, same-session A/B, 3000 payloads):

master PR Δ
AVG 31.02 30.55 −1.5%
MEDIAN 27.5 26.8 −2.5%
P99 100.8 106.8 +6.0% (single-run tail noise)
MAX 338.7 326.6 −3.6%

Mechanism (host perf + symbolized JIT profiles): IPC at 300 rps drops 2.65 → 2.22 at flat clocks, growth entirely in managed code; GC, CPU frequency, and native RocksDB ruled out. The remaining warm 50→300 median inflation (~+22%) is queueing at full core occupancy (300 rps × 27 ms ≈ 8.3 concurrent on 8 cores) — addressable only by reducing per-call CPU; out of scope here.

Types of changes

What types of changes does your code introduce?

  • Bugfix (a non-breaking change that fixes an issue)
  • New feature (a non-breaking change that adds functionality)
  • Breaking change (a change that causes existing functionality not to work as expected)
  • Optimization
  • Refactoring
  • Documentation update
  • Build-related changes

Testing

Requires testing

  • Yes

If yes, did you write tests?

  • Yes

Notes on testing

Core.Test 5936 / Evm.Test 5021 / Trie.Test 470 / Db.Test 549 / Config.Test 53 green locally (release). One pre-existing environment-flaky JsonRpc hex-parsing test fails identically with and without these changes. Full-corpus eth_call response parity vs master (497/497) on every benchmark arm; block-processing safety via same-session expb fusaka A/B.

Documentation

Requires documentation update

  • Yes

Config items carry their own docs: Init.InstructionStreamCacheSize (new) and the JsonRpc.EthModuleConcurrentInstances default change.

Requires explanation in Release Notes

  • Yes

RPC nodes serving concurrent eth_call traffic: hard "Too many requests" rejections at the old 1×-core default are gone by default; the instruction-stream cache default grew 1024 → 4096, raising worst-case (not typical) stream retention from 512 MiB to 2 GiB — memory-constrained setups can lower it.

Remarks

Memory accounting for the two default changes: the stream-cache default raises the worst-case retention ceiling from 512 MiB to 2 GiB (size × 512 KiB per-entry cap; typical mainnet streams are far smaller, so realistic retention at 4096 is a few hundred MB), and the concurrency default doubles only lazily-created pool retention. Lower Init.InstructionStreamCacheSize on memory-constrained setups. Follow-up campaign (per-call CPU toward reth parity) tracked separately; a rebased per-VM arena for EVM frame memory (supersedes #12382) is staged on v7/arena-port for it.

Per-thread LIFO caches in front of the global VmState and stack pools,
coarse-clock eviction tickers in the associative caches, and a
test-before-set access mark in ClockCache. Uncontended these ops are
nearly free; at 8 concurrent RPC threads each is a serialized cross-core
cache-line transfer, thousands of times per eth_call.
…re hints

All non-block-processing threads shared one "other" word per counter, so
per-node and per-DB-get metric updates were contended cross-core RMWs under
concurrent RPC load. StripedLong lands increments on per-core 128-byte-spaced
slots. GC.Add/RemoveMemoryPressure now fires only for spans >= 16KB — it
mutated GC-global accounting twice per DB read.
Buffers between 64KB and 256KB round-tripped ArrayPool.Shared on every
frame, migrating across cores under concurrent RPC load — so the zero-
extension stores on frame growth hit cold, coherence-invalidated lines.
Retain them in the per-thread cache under a 2MB/thread budget; allocation
provenance is unchanged.
…erload-rejection metric

Init.InstructionStreamCacheSize replaces the experiment env knob: under
concurrent load the hot code set outgrows the 1024-entry cache and frames
fall back to the streamless interpreter (measured -25..-40% at 300rps
from 4096 on an 8-core box). EthModuleConcurrentInstances now defaults to
2x core count: the caps fail fast and offered concurrency is rate x
latency, so at 1x a node serving override-carrying eth_calls rejects
whenever latency crosses cores/rate. Rejections are counted so the
(deliberately unlogged) shedding is visible to operators.
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 8m 52s —— View job


Review: perf(rpc): fix eth_call degradation under concurrent load

  • Gather context (diff vs origin/master, repo rules)
  • Review EVM pooling changes (VmState, StackPool, EvmPooledMemory)
  • Review caching changes (AssociativeCache, AssociativeKeyCache, ClockCacheBase)
  • Review StripedLong + metrics rewiring
  • Review DbOnTheRocks / GC-pressure changes
  • Review config + RPC concurrency changes
  • Post review

Strong PR overall — the mechanism analysis is unusually well evidenced, the comments explain why rather than what, and the risky invariants (seqlock ticker store, array provenance, cross-instance [ThreadStatic] sharing) are called out explicitly rather than left implicit. I traced each of those and they hold. Details are in inline comments; summary below.

What I verified as correct

  • EvmPooledMemory provenance. No array can reach a pool it wasn't rented from: Rent's fresh-allocation path is capped at exactly 1 << 16 by RoundUpToPowerOf2, so only RentLarge arrays can satisfy array.Length > MaxNewAllocLength on the return branch. Dispose nulls _memory before Return, so there's no double-cache path that could hand one buffer to two frames. The byte accounting (_cachedArrayBytes) is balanced across Rent/Return.
  • StackPool cross-instance [ThreadStatic]. StackLength is a const, so the "identically-shaped arrays" assumption is real. _poolCount bookkeeping stays consistent (thread-cache hits touch neither side).
  • VmState per-thread pool. Dispose clears _memory, _env, _accessTracker, _snapshot before Return(), so the 64 cached frames per thread are small. No thread-affinity invariant to violate — a frame disposed on a different thread just migrates.
  • ClockCacheBase.MarkAccessedflags is a single bit, so (word & flags) != flags is the right test; the documented lost-mark race is genuinely bounded.
  • StripedLongAdd(-1) works (slots go negative, Sum stays exact); 128-byte stride is right; DbOnTheRocks Add/Remove GC-pressure pairing is symmetric since both sides use the same GcPressureSpanThreshold on the same span.

Findings

High

  1. JsonRpcOverloadRejections misses the rejection class this PR is aboutJsonRpcService.cs:92. The counter lives in ReturnErrorResponse, which only sees exceptions escaping ExecuteAsync. ShareableOverridableEnvSource throws ConcurrencyLimitReachedException during invocation, so it's caught by the inner catchHandleInvocationException → the LimitExceeded arm at JsonRpcService.cs:539-542, bypassing the metric. The PR body and the metric's own [Description] both name the override-environment limit as the thing being made visible; as written, the counter can read 0 while override-carrying eth_calls are shed. Fix this →

Medium

  1. Environment.TickCount64 is coarser than the comment claims, degrading eviction in the cache the PR simultaneously enlargesAssociativeCache.cs:168-177, AssociativeKeyCache.cs:100-112. It's ~15.6 ms on Windows (system timer tick) and 1–4 ms on Linux (CLOCK_MONOTONIC_COARSE, one jiffy) — not "millisecond granularity". At 300 rps every way in a hot set shares one tick value, so Pick3RandomEvict's min-selection degenerates to random eviction where it used to be approximate-LRU. This lands in the same PR that raises InstructionStreamCacheSize because that cache conflict-evicts — the two effects can't be separated by the benchmark arms. Removing the Interlocked is right; consider Stopwatch.GetTimestamp() >> 16 instead, or measure cache hit-rate before/after.
  2. InstructionStreamCacheSize is unvalidated and reaches a static field initializerApplyMemoryHint.cs:29. 0 silently disables the cache entirely (AssociativeCache(0)_setCount = 0, every lookup misses) — i.e. it reproduces the exact pathology being fixed. A negative value throws ArgumentOutOfRangeException inside InstructionStreamCache's type initializer, so the node starts fine and dies with TypeInitializationException at the first EVM execution. Also, "before any EVM execution" is an unasserted ordering assumption with a silent failure mode — log the effective value.
  3. ReceiptRegenerationModule.cs:40 doubles a much heavier consumer's default — the shared knob is justified in the existing comment, but the shared default isn't. Regeneration envs cost roughly a block execution each, and no evidence in this PR exercises that path. Needs an explicit acknowledgement or a revert to ProcessorCount for that one call site.
  4. No new tests. The only test change is a mechanical .Value.Sum. This PR adds a new concurrency primitive, changes lock-free cache eviction policy, and rewires three pooling paths — all with invariants that are currently only documented in comments. AGENTS.md requires a regression test for bug fixes (this is tagged "Bugfix"). Suggested minimum: StripedLong concurrent-sum exactness + negative Add; EvmPooledMemory round-trip across the 64 KiB / 256 KiB boundaries and byte-budget enforcement; StackPool rent/return across two instances on one thread; AssociativeCache eviction-order assertion (which would also pin down finding 2).

Low — inline: StripedLong slot 0 not line-isolated on its low side + missing <remarks> on allocation size and ProcessorCount capture; StackPool retention ceiling now peak-bounded rather than hard-capped (pinned/POH, worth stating) and the ??= before the capacity check; MaxThreadCachedBytes = 2 << 20 reads inconsistently vs its 1 << N siblings; the // Provenance: comment sits above the branch that doesn't enforce it. Plus: System.Diagnostics.Stopwatch.GetTimestamp() is fully qualified inline in both cache files — add using System.Diagnostics; per the repo lint conventions.

Verdict

Not blocking on design — the direction is sound and the evidence is good. Finding 1 is a straightforward fix and finding 3 is cheap; findings 2 and 5 are the ones I'd want addressed or explicitly waived before merge, since together they mean an eviction-policy change is shipping unmeasured and untested inside a performance PR whose headline claim is about a different cache dimension.

Not verified: I could not run a build or the test suite in this environment (dotnet build is outside my allowed tools here), so this is a static review only. All findings above are derived from reading the code and its call sites.
· branch perf/ethcall-concurrency

// that callers are being shed at a concurrency cap.
if (errorCode is ErrorCodes.LimitExceeded or ErrorCodes.ModuleTimeout)
{
Metrics.JsonRpcOverloadRejections++;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — this counter misses the rejection class the PR is actually about.

ReturnErrorResponse only sees exceptions that escape ExecuteAsync. That covers BoundedModulePool (LimitExceededException / ModuleRentalTimeoutException thrown from await _rpcModuleProvider.Rent(method), outside the inner try), but not the override-environment cap: ShareableOverridableEnvSource throws ConcurrencyLimitReachedException (Nethermind.State/OverridableEnv/ShareableOverridableEnvSource.cs:69) during method invocation, so it is caught by ExecuteAsync's inner catchHandleInvocationException → the LimitExceeded arm at JsonRpcService.cs:539-542, which returns "Too many requests" without touching the metric.

That is precisely the path the PR description calls out ("an 8-core node serving override-carrying eth_calls rejects with 'Too many requests' … the shedding was invisible"), and the metric's own [Description] claims to cover the "override-environment limit". As written the counter can read 0 while override-carrying eth_calls are being shed.

Simplest fix: move the increment into GetErrorResponse(…) keyed on errorCode, so both paths (and JsonRpcProcessor's batch-limit rejections) are counted from one place.

Also minor: Metrics.JsonRpcOverloadRejections++ is a non-atomic RMW on a static hit concurrently by every RPC thread — it will under-count exactly under the overload it is meant to measure. (It matches JsonRpcErrors++ in the same file, but this counter is specifically for high-concurrency conditions, so Interlocked.Increment would be worth it.)

Comment on lines 168 to 177
// x64/ARM64 hardware. A race with a concurrent Set only affects eviction ranking,
// not key/value correctness — the "losing" ticker value is simply slightly stale.
if (TRefreshTicker.IsActive)
e.Ticker = Interlocked.Increment(ref _ticker);
{
long now = Environment.TickCount64;
if (e.Ticker != now)
e.Ticker = now;
}
value = storedValue;
return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — the replacement clock is coarser than the comment implies, and this degrades eviction in the very cache the PR is trying to fix.

Removing the per-hit Interlocked is clearly right. The concern is the choice of replacement source, not the removal.

Environment.TickCount64 is not "millisecond granularity" in practice:

  • Windows: GetTickCount64, which advances at the system timer tick — typically ~15.6 ms.
  • Linux: clock_gettime(CLOCK_MONOTONIC_COARSE), i.e. one jiffy — 1–4 ms depending on CONFIG_HZ.

Ticker is only ever consumed by Pick3RandomEvictEntryPick3RandomEvict(ta, tb, tc, …), which picks the minimum. Under the load this PR targets (300 rps, hot code set), all 8 ways of a set are touched many times inside a single tick, so all three sampled tickers are equal and 3-random-min degenerates to plain random eviction within the set. Previously Interlocked.Increment gave a strictly increasing, unique value per hit, i.e. a genuine approximate-LRU ordering.

That interacts directly with the other half of this PR: Init.InstructionStreamCacheSize exists because the instruction-stream cache conflict-evicts under concurrency — and this change makes the eviction victim choice within a conflicted set worse at the same time. The benchmark arms can't separate the two, since both landed together.

Suggestions, in rough order of preference:

  1. Keep a monotonic-but-cheap source with sub-tick resolution, e.g. Stopwatch.GetTimestamp() >> 16 (~µs–tens-of-µs buckets on x64, no shared line, no RDTSC serialization concerns beyond the read itself) — you already pay Stopwatch.GetTimestamp() on the eviction path below.
  2. Or keep TickCount64 but add a hit-rate measurement for InstructionStreamCache / the state caches before and after, so the eviction-quality cost is quantified rather than assumed.

Either way, a unit test asserting "under a set-conflicting key stream, the least-recently-read entry is evicted more often than chance" would pin the intended behaviour down. Same comment applies to AssociativeKeyCache.cs:100-112.

public Task Execute(CancellationToken _)
{
// Before any EVM execution: the cache captures this value when its static state initializes.
Evm.MemoryAllowance.InstructionStreamCacheSize = initConfig.InstructionStreamCacheSize;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — unvalidated config reaches a static field initializer, so a typo becomes a runtime TypeInitializationException instead of a startup error.

InstructionStreamCache._cache is private static readonly AssociativeCache<…> _cache = new(MemoryAllowance.InstructionStreamCacheSize); (Nethermind.Evm/CodeAnalysis/InstructionStreamCache.cs:18). With this value now operator-supplied and unchecked:

  • InstructionStreamCacheSize = 0AssociativeCache(0) takes the maxCapacity == 0 path, _entries = [], _setCount = 0. Every Get misses and every Set is a no-op — the instruction-stream cache is silently disabled and every frame runs the streamless interpreter. That's the exact pathology this PR is fixing, delivered by a plausible "turn it off" config value.
  • Negative value → ArgumentOutOfRangeException.ThrowIfGreaterThan((uint)maxCapacity, MaxCapacity) (the cast makes -14294967295) throws inside the type initializer, so the node starts fine and then dies with a TypeInitializationException at the first EVM execution.

Please validate here (or in InitConfig) — throw a clear ConfigurationException/clamp for values outside [1, AssociativeCache.MaxCapacity] — and log the effective value.

Logging the effective value also addresses the second half of the comment above it: "Before any EVM execution" is an ordering assumption with no assertion behind it. If any step, plugin, or DI factory touches InstructionStreamCache before this step runs (the field is beforefieldinit, so the type initializer fires on first access), the setting is silently ignored and there is no way for an operator to tell. An info-level line here plus a way to read back the cache's actual capacity would make a violation observable.

Design nit (AGENTS.md / di-patterns.md): this introduces a mutable public static crossing an assembly boundary (Init writing into Evm) purely to configure one object. Constructing the cache from the config at its composition point would avoid the global and the ordering hazard entirely — worth considering if it isn't too invasive.

Comment on lines 507 to 525
private static void Return(byte[] array)
{
if (array.Length > MaxCachedArrayLength)
// Provenance: arrays <= MaxNewAllocLength are plain allocations, larger ones came from
// RentLarge — an array must never reach a pool it was not rented from.
if (array.Length <= MaxThreadCachedArrayLength
&& _cachedArrayCount < CacheSlots
&& _cachedArrayBytes + array.Length <= MaxThreadCachedBytes)
{
ReturnLarge(array);
byte[]?[] cache = _cachedArrays ??= new byte[CacheSlots][];
cache[_cachedArrayCount++] = array;
_cachedArrayBytes += array.Length;
return;
}

byte[]?[] cache = _cachedArrays ??= new byte[CacheSlots][];
if (_cachedArrayCount < CacheSlots)
if (array.Length > MaxNewAllocLength)
{
cache[_cachedArrayCount++] = array;
ReturnLarge(array);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — the provenance invariant holds, but the comment is attached to the branch that doesn't enforce it, and pool-rented buffers are now withheld from the shared pool.

I traced the invariant and it does hold today:

  • Rent only allocates fresh when minLength <= MaxNewAllocLength, and RoundUpToPowerOf2 caps that at exactly 1 << 16 — so no fresh array ever exceeds MaxNewAllocLength.
  • Therefore the array.Length > MaxNewAllocLength guard on the ReturnLarge branch can only be reached by arrays that came from RentLarge. No fresh allocation is ever pushed into ArrayPool, and no pool array is dropped without being returned unless it is parked in the thread cache.
  • EvmPooledMemory.Dispose nulls _memory before calling Return, so there's no double-cache path that could hand the same buffer to two frames.

Two things to fix anyway:

  1. Comment placement. The // Provenance: … block sits above the thread-cache branch, which does not check provenance at all; the actual enforcement is the array.Length > MaxNewAllocLength test 8 lines lower. Move it (or restate it) so a future reader who widens MaxNewAllocLength past MaxThreadCachedArrayLength sees the constraint at the place that encodes it. The invariant is entirely implicit in the relationship between three constants — a Debug.Assert or a comment on MaxNewAllocLength stating "must stay ≤ the round-up ceiling of the fresh-allocation path" would be cheaper to maintain than prose here.

  2. Buffers in (64 KiB, 256 KiB] rented from SafeArrayPool<byte>.Shared are now parked in a [ThreadStatic] cache and never returned while cached. Up to MaxThreadCachedBytes (2 MiB) per EVM-executing thread is borrowed from ArrayPool<byte>.Shared indefinitely; the pool just allocates replacements for other callers. It is peak-bounded (thread statics are released when the thread dies) so this isn't a leak, but with EthModuleConcurrentInstances also doubling in this PR the two changes compound. Worth stating the expected worst case in the comment, and worth a note that the first-fit scan in Rent will happily hand a cached 256 KiB buffer to a 1 KiB request and re-cache it, so one deep call can pin a large buffer to a thread for a long time.

Style nit: MaxThreadCachedBytes = 2 << 20 is correct (2 MiB) but reads inconsistently next to the 1 << N siblings — 1 << 21 would be less error-prone.

.AddSingleton<IShareableOverridableEnvSource<ReceiptsRegenerationEnv>>(ctx =>
ctx.Resolve<RegeneratingReceiptsEnvSourceFactory>()
.Create(ctx.Resolve<IJsonRpcConfig>().EthModuleConcurrentInstances ?? Environment.ProcessorCount))
.Create(ctx.Resolve<IJsonRpcConfig>().EthModuleConcurrentInstances ?? 2 * Environment.ProcessorCount))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — please confirm doubling receipt-regeneration concurrency is intentional, not just consistency with the eth-module expression.

The comment right above this line says regeneration is "as expensive as executing a block — so it is bounded by the same knob that bounds the eth module itself". Keeping the knob shared is fine; sharing the default is a different tradeoff, because the two consumers have very different per-instance cost. Going from ProcessorCount to 2 * ProcessorCount here doubles the worst-case concurrent block-execution-sized memory for receipt regeneration, and none of the PR's evidence (eth_call corpus, expb block processing) exercises this path.

The PR body justifies the doubling only for eth_call shedding and says "the concurrency default doubles only pool retention that is lazily created under load" — that reasoning is about eth-module instances, not regeneration envs. Either an explicit acknowledgement that the regeneration memory ceiling doubles, or leaving this one at ProcessorCount, would close it out.

Comment on lines +26 to +33
// 16 longs = 128 bytes between live slots.
private const int SlotStride = 16;
private static readonly int s_stripeMask = (int)BitOperations.RoundUpToPowerOf2((uint)Environment.ProcessorCount) - 1;

private readonly long[] _slots = new long[(s_stripeMask + 1) * SlotStride];

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Add(long value)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — a few notes on the new primitive; no correctness problems found.

Add/Sum are correct, Add(-1) works (individual slots go negative, Sum stays right), and the 128-byte stride matches CacheLinePaddedLong. Three small things:

  1. Slot 0 is not line-isolated on its low side. _slots[0] sits at offset 16 in the array object (header + length), so the 64-byte line containing it also contains the array header and whatever object precedes it on the heap. In this PR the StripedLong statics are allocated back to back, so the preceding object is the previous instance's trailing 128 bytes of padding — safe today, but incidental. Skipping index 0 ((idx + 1) * SlotStride) or padding the head would make it structural.

  2. Environment.ProcessorCount is captured in a static readonly at type-init. With container CPU-limit changes or DOTNET_PROCESSOR_COUNT, Thread.GetCurrentProcessorId() can exceed the mask, which is harmless (two cores share a slot) — worth a sentence in <remarks> so nobody "fixes" it later.

  3. Memory. RoundUpToPowerOf2(ProcessorCount) * 128 bytes per instance: 16 KiB each on a 96-core box. With ~15 static counters plus 2 per DbOnTheRocks instance that's a few hundred KiB — fine, but the <remarks> currently only mentions read cost, not allocation size. Worth adding, since the type is now easy to reach for.

There's no unit test for this type. A concurrent-Add-from-N-threads-sums-exactly test plus a negative-Add test would be cheap and would lock in the contract that Sum is exact (not approximate) once writers quiesce — which DbOnTheRocksTests now depends on.

Comment on lines +15 to +34
// Stacks are rented and returned on the executing thread in LIFO order, so a small per-thread
// cache serves nearly every frame with an array that is hot in this core's cache. The shared
// queue costs two atomics per frame and migrates ~33 KB pinned arrays between cores under
// concurrent load; it remains as overflow so deep chains keep pooling and total retention
// stays bounded. [ThreadStatic] is deliberately shared across pool instances: every pool
// deals in identically-shaped arrays.
private const int MaxStacksCachedPerThread = 16;
[ThreadStatic] private static byte[]?[]? _threadStacks;
[ThreadStatic] private static int _threadStackCount;

public partial void ReturnStacks(byte[] dataStack)
{
byte[]?[] threadStacks = _threadStacks ??= new byte[]?[MaxStacksCachedPerThread];
int cached = _threadStackCount;
if (cached < MaxStacksCachedPerThread)
{
threadStacks[cached] = dataStack;
_threadStackCount = cached + 1;
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — cross-instance sharing checks out; two smaller points.

I verified the load-bearing assumption in the comment: StackPool.StackLength is a const ((EvmStack.MaxStackSize + EvmStack.RegisterLength) * 32 = 32,832, plus Vector256<byte>.Count), so every pool instance really does deal in identically-shaped arrays and the [ThreadStatic] sharing is safe. Good that it's called out explicitly.

  1. Total retention is no longer bounded by MaxStacksPooled. The cap was MaxCallDepth * 2 = 2048 arrays ≈ 67 MB. Now it's that plus up to 16 × ~32 KB ≈ 526 KB of pinned (POH) arrays per thread that has executed EVM. It's peak-bounded — thread statics are freed when a thread-pool thread retires — and in practice a shallow eth_call only parks 1–2 stacks, so the realistic figure is much lower. But POH is not compacted, so thread-pool churn under sustained RPC load can fragment it over time. Since this PR also doubles EthModuleConcurrentInstances, it would be good to state the intended ceiling in the comment, and to say why 16 (rather than, say, 4) is the right per-thread depth — the benchmark evidence doesn't distinguish.

  2. ReturnStacks allocates the 16-slot array via ??= before checking cached < MaxStacksCachedPerThread, so a thread whose first return overflows still allocates it. Trivial, but the check could come first.

No test covers the new path. A test that rents/returns across two StackPool instances on one thread and asserts the returned array length is cheap insurance for the const-shape assumption above.

The benchmark evidence in this PR was measured at 4096; shipping a 1024
default would not reproduce it. Worst-case retention is size x 512 KiB,
but typical streams are far smaller; the frugal 1024 static default
remains for hosts that never run node init.
@github-actions

Copy link
Copy Markdown
Contributor

EXPB Benchmark Comparison

Run: View workflow run

superblocks

Scenario: nethermind-flat-superblocks-perf-ethcall-concurrency-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 912.63 929.12 -1.77%
MEDIAN (ms) 877.4 887.2 -1.10%
P90 (ms) 1072.9 1109.1 -3.26%
P95 (ms) 1209.6 1221.9 -1.01%
P99 (ms) 3134.1 3063.7 +2.30%
MIN (ms) 594.9 617.9 -3.72%
MAX (ms) 3134.1 3063.7 +2.30%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 1604.86 1718.78 -6.63%
MEDIAN (ms) 1092.51 1180.59 -7.46%
P90 (ms) 3023.62 3787.31 -20.16%
P95 (ms) 3525.04 4209.34 -16.26%
P99 (ms) 5307.35 4522.44 +17.36%
MIN (ms) 717.87 708.62 +1.31%
MAX (ms) 9024.63 5315.79 +69.77%

realblocks

Scenario: nethermind-flat-realblocks-perf-ethcall-concurrency-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 21.10 21.69 -2.72%
MEDIAN (ms) 18.6 18.8 -1.06%
P90 (ms) 35.3 36.1 -2.22%
P95 (ms) 41.0 43.1 -4.87%
P99 (ms) 69.6 64.1 +8.58%
MIN (ms) 0.3 0.3 +0.00%
MAX (ms) 199.5 196.6 +1.48%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 24.72 26.57 -6.96%
MEDIAN (ms) 21.93 22.24 -1.39%
P90 (ms) 38.89 41.14 -5.47%
P95 (ms) 45.22 48.85 -7.43%
P99 (ms) 73.58 85.33 -13.77%
MIN (ms) 0.79 1.20 -34.17%
MAX (ms) 373.95 431.27 -13.29%

fusaka

Scenario: nethermind-flat-fusaka-perf-ethcall-concurrency-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 30.31 31.27 -3.07%
MEDIAN (ms) 27.9 28.2 -1.06%
P90 (ms) 47.0 48.5 -3.09%
P95 (ms) 56.1 58.2 -3.61%
P99 (ms) 84.2 84.4 -0.24%
MIN (ms) 4.7 4.3 +9.30%
MAX (ms) 337.9 334.5 +1.02%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 37.41 39.15 -4.44%
MEDIAN (ms) 33.12 33.80 -2.01%
P90 (ms) 56.44 58.06 -2.79%
P95 (ms) 63.75 66.85 -4.64%
P99 (ms) 106.16 111.19 -4.52%
MIN (ms) 6.05 6.21 -2.58%
MAX (ms) 585.52 600.51 -2.50%

… validate cache size, add regression tests

- JsonRpcOverloadRejections moves to the GetErrorResponse funnel so the
  override-environment cap (thrown during invocation) is counted too;
  regression test covers both paths.
- Cache eviction age uses Stopwatch.GetTimestamp() — TickCount64's jiffy
  granularity tied hot sets under load, degrading 3-random eviction to
  random; a deterministic recency test pins the semantics.
- Init.InstructionStreamCacheSize validated at startup (0 disabled the
  cache silently; negatives died later as TypeInitializationException)
  and the effective value is logged.
- Receipts regeneration keeps the 1x-core default (heavier envs, no
  measured need for the eth module's new 2x headroom).
- Tests: StripedLong exactness/negatives, StackPool cross-instance and
  distinctness, EvmPooledMemory pooling-boundary recycling, plus low-fix
  cleanups (slot-0 isolation, constants, comment placement).
@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude re-review

All findings from the first review are addressed in 94e5925:

  • High 1: JsonRpcOverloadRejections now increments in the GetErrorResponse funnel (keyed on LimitExceeded/ModuleTimeout), so the override-environment cap thrown during invocation is counted alongside the rental path; Overload_rejections_are_counted_from_both_shedding_paths covers both.
  • Medium 2: eviction age switched to Stopwatch.GetTimestamp() (fine-grained, monotonic, still no shared write); Refreshed_entry_survives_sustained_churn_in_its_set deterministically pins the recency semantics.
  • Medium 3: Init.InstructionStreamCacheSize validated at startup (rejects ≤ 0 with a config-naming error instead of a silent no-cache or a later TypeInitializationException); effective value logged.
  • Medium 4: receipts regeneration reverted to 1× core count with a comment explaining why it stays below the eth module's 2×.
  • Medium 5: tests added — StripedLong concurrent exactness + negatives, StackPool cross-instance flow + distinctness, EvmPooledMemory pooling-boundary recycling (64 KiB/256 KiB ± cases), the cache recency test, and the metric regression test.
  • Lows: using System.Diagnostics in both cache files, 1 << 21, provenance comment moved to the branch it describes, ??= after the capacity check + peak-bounded retention documented in StackPool, StripedLong slot-0 lead pad + <remarks> on allocation size and ProcessorCount capture.

@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 11m 52s —— View job


Re-review: perf(rpc): fix eth_call degradation under concurrent load

  • Fetch base + read 94e5925 against the rest of the PR
  • Verify High 1 — JsonRpcOverloadRejections counts both shedding paths
  • Verify Medium 2 — eviction age + recency test
  • Verify Medium 3 — InstructionStreamCacheSize validation + logging
  • Verify Medium 4 — receipt-regeneration concurrency
  • Verify Medium 5 — new tests
  • Verify Lows
  • Post findings

All six items from the first review are genuinely addressed — I traced each one to the code rather than taking the summary at face value. One new Medium surfaced (a stale config default in the docs), and the fixes left a handful of Lows. Nothing found that affects consensus, correctness of execution, or safety.

Round-1 findings — verification

# Claim Verdict
High 1 metric counts both paths Fixed. ConcurrencyLimitReachedExceptionHandleInvocationExceptionJsonRpcService.cs:536GetErrorResponse, and the rental path arrives via ReturnErrorResponse. Both now increment, atomically. Test present.
Medium 2 fine-grained eviction age Fixed. Ticker is only ever consumed by Pick3RandomEvictEntry's relative min and cleared entries reset to 0, so the unit change carries no absolute-time semantics. Test is deterministic by construction (Pick3Indices returns three distinct ways; the hot entry always holds the set's max ticker), not by luck.
Medium 3 config validated + logged Fixed for <= 0.
Medium 4 regeneration concurrency Fixed — back to ProcessorCount with the rationale in a comment.
Medium 5 tests Fixed. StripedLong (exactness under 2.4 M concurrent mixed adds, negatives, zero), StackPool (cross-instance thread-cache flow + distinctness across 24 rents), EvmPooledMemory (6 cases bracketing 64 KiB / 256 KiB, asserting a deliberately dirtied buffer comes back reading zero — the load-bearing property), the cache recency test, and the metric regression test. Checked the plumbing compiles: InternalsVisibleTo("Nethermind.Evm.Test") exists, StackPool.StackLength is a public const, TestRequestWithPool + NSubstitute.ExceptionExtensions are already in scope, ConcurrencyLimitReachedException is in Nethermind.Core.Exceptions.
Lows all six Fixed. Verified StripedLong's lead pad is coherent end to end: Add writes (idx+1)*16, Sum iterates stripes 0..mask reading (stripe+1)*16, array is (mask+2)*16 — in bounds, no slot skipped, no slot double-counted.

Also re-checked, unchanged and still correct: DangerousReleaseMemory's GC-pressure pairing is symmetric on the same threshold and the same span, and thresholding is behaviourally safe because DbSpanMemoryManager releases deterministically (no finalizer) — the pressure hint never drove the free. ClockCacheBase.MarkAccessed's test-before-Or, the VmState/StackPool/EvmPooledMemory provenance and clearing invariants, and StripedLong's exact-sum contract all hold.

Findings

Medium

  1. EthModuleConcurrentInstances' description still says "Defaults to the number of logical processors"IJsonRpcConfig.cs:159-160. It is now 2× for the eth module and deliberately 1× for receipt regeneration, so the single operator-facing text for the knob is wrong for both consumers, and it is the text that feeds the generated docs. One-line fix; inline. Fix this →

Low (all inline, all cheap)

  1. The metric funnel is wider than the metric's descriptionJsonRpcService.cs:923. Batch-size limit, MaxBatchResponseBodySize, and HTTP 413 also pass through GetErrorResponse with LimitExceeded, so the counter can be nonzero with no concurrency shedding at all, while its [Description] advises raising EthModuleConcurrentInstances. All three concurrency sites pass suppressWarning: true and none of these do, so that flag isolates them exactly — or just reword the description. (Confirmed the ResultWrapper LimitExceeded failures such as MaxLogsPerResponse are not affected: they bypass GetErrorResponse.)
  2. Metric is now a public static field. It exports fine — MetricsController walks GetProperties().Concat(GetFields()) — but the repo's pattern for an interlocked counter is a get-only property over a private field (Nethermind.Db/Metrics.cs: FullPruningCount => _fullPruningCount), which also keeps it inside MetricsTests.ValidateMetricsDescriptions (property-only walk).
  3. InstructionStreamCacheSize upper bound unguarded — above AssociativeCache.MaxCapacity (1u << 27) the node still starts and then dies with TypeInitializationException at the first EVM execution. Plus: the error text says IInitConfig.… where the operator's key is Init.…, and the effective-value log is at Debug, so at default log level the ordering assumption is still unobservable.
  4. AssociativeKeyCache has the same ticker change but no test — the neighbouring test in that base class already drives both caches side by side, so extending is a few lines.
  5. The metric test asserts >= before + 2 on a process-global counter, so it cannot distinguish "both paths counted once" from "one path counted twice by a concurrent test". Capturing the delta after each of the two requests separately makes it a real regression test for the pair.
  6. Unverified question: Metrics.cs in Nethermind.Evm / Db / Trie is common (not .std.cs), so the ZisK guest build now compiles StripedLongThread.GetCurrentProcessorId() is the only use of that API in the repo. The guest is single-threaded so it is semantically harmless; worth a sanity check that the API is supported there, or move the striped counters behind the .std split.

Verdict

The substantive concerns from round 1 are closed, and the fixes are the ones I'd have written — particularly the eviction-age change, which is now both correct in its unit and pinned by a test that is deterministic rather than probabilistic. What remains is text-vs-behaviour: a stale documented default (finding 1) and a metric whose description over-promises (finding 2). I'd fix those two plus the Info log line before merge; the rest can ride.

Not verified: dotnet build and dotnet test are outside my allowed tools in this environment, so this is again a static review. I checked compile-level plumbing for every new test by hand (visibility, InternalsVisibleTo, existing helpers, usings, namespaces) but did not compile or run anything.
· branch perf/ethcall-concurrency

// rejections reach this point along two distinct paths (module rental before invocation,
// and the override-environment cap during invocation), and their warnings are suppressed
// by design — without a counter operators cannot see that callers are being shed.
if (errorCode is ErrorCodes.LimitExceeded or ErrorCodes.ModuleTimeout)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — the funnel fixed the miss but widened the counter past its own description.

Both shedding paths now land here, which is the fix I asked for — ConcurrencyLimitReachedException reaches HandleInvocationException → line 536 → this method, and the rental path arrives via ReturnErrorResponse. Interlocked.Increment is right too. Verified that ResultWrapper-based LimitExceeded failures (e.g. EthRpcModule.cs:1103, MaxLogsPerResponse) do not pass through here — they are JsonRpcResponses returned via WithResponseContext, so they are correctly excluded.

Three sites that are not concurrency caps do pass through, though:

  • JsonRpcProcessor.cs:804 — batch size limit exceeded
  • JsonRpcProcessor.cs:811 (CreateBatchResponseLimitEntry) — MaxBatchResponseBodySize exceeded
  • Runner/JsonRpc/Startup.cs:504 — HTTP 413 payload too large

So a node that only ever gets oversized batches reports JsonRpcOverloadRejections > 0, and the metric's [Description] then tells the operator to raise EthModuleConcurrentInstances, which will not help. Either reword the description to "rejected at a request/concurrency limit", or narrow the predicate — all three concurrency sites pass suppressWarning: true and none of the three above do, so errorCode is … && suppressWarning isolates exactly the shedding paths.

Separately: Metrics.JsonRpcOverloadRejections is now a public static field. That works (MetricsController.RegisterMetrics walks GetProperties().Concat(GetFields())), but the established pattern for an interlocked counter in this repo is a get-only property over a private field — e.g. Nethermind.Db/Metrics.cs: public static long FullPruningCount => _fullPruningCount;. That form also keeps it inside MetricsTests.ValidateMetricsDescriptions, which only enumerates properties.

// Before any EVM execution: the cache captures this value when its static state initializes.
Evm.MemoryAllowance.InstructionStreamCacheSize = initConfig.InstructionStreamCacheSize;
ILogger logger = logManager.GetClassLogger<ApplyMemoryHint>();
if (logger.IsDebug) logger.Debug($"Instruction stream cache size: {initConfig.InstructionStreamCacheSize} entries");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — validation closes the two failure modes I flagged; three small residuals.

The <= 0 guard removes both the silent no-cache and the negative-value TypeInitializationException. Remaining:

  1. Upper bound still unguarded. AssociativeCache's ctor does ArgumentOutOfRangeException.ThrowIfGreaterThan((uint)maxCapacity, MaxCapacity) with MaxCapacity = 1u << 27 (SeqlockHeader.cs:61). A value above that (a fat-fingered 1000000000) still starts the node cleanly and then dies with TypeInitializationException at the first EVM execution — the same failure mode, just at the other end. is <= 0 or > 1 << 27 costs nothing here.
  2. nameof(IInitConfig) renders as IInitConfig.InstructionStreamCacheSize, but the key an operator sets is Init.InstructionStreamCacheSize (or NETHERMIND_INITCONFIG_INSTRUCTIONSTREAMCACHESIZE). Spelling the config key literally makes the message directly actionable.
  3. IsDebug hides the effective value at the default log level. The reason for logging it was that "before any EVM execution" is an unasserted ordering assumption with a silent failure mode — if anything touches InstructionStreamCache before this step, the setting is ignored and nothing says so. At Debug an operator will never see it. One Info line at startup, alongside the other memory-hint output, is what makes the assumption observable.

if (TRefreshTicker.IsActive)
e.Ticker = Interlocked.Increment(ref _ticker);
e.Ticker = Stopwatch.GetTimestamp();
return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved (Medium 2) — with one coverage gap.

Stopwatch.GetTimestamp() is the right source here: I checked that Ticker is consumed only by Pick3RandomEvictEntry's relative min, and that cleared entries reset to 0 (which sorts as oldest), so switching the unit carries no absolute-time semantics. Folding the eviction path's second GetTimestamp() into the single timestamp local is a nice side-effect.

Gap: AssociativeKeyCache got the identical change, but Refreshed_entry_survives_sustained_churn_in_its_set only exercises AssociativeCache. The neighbouring test in the same base class already drives both cache and keyCache side by side, so extending it is a few lines and would cover both implementations of the semantics being pinned.

Two notes on the test itself, neither blocking:

  • It is deterministic by construction rather than by luck — Pick3Indices guarantees three distinct ways, and the hot entry always holds the set's maximum ticker, so the sampled minimum can never be it. Worth knowing the one theoretical hole: Pick3RandomEvict breaks ties toward the earlier index (ta <= tb), so a tie between the hot entry and both other sampled entries could evict it. That needs three iterations inside one clock tick — impossible on Linux (Stopwatch.Frequency == 1e9), and vanishingly unlikely on Windows QPC's 100 ns tick.
  • The per-hit clock read (~15–25 ns via vDSO / QPC) is now on the hit path of the hottest users — StaticCodeCache, InstructionStreamCache, TxPool.HashCache. Those fire per call frame / per tx rather than per opcode, and the expb arms (AVG −1.5% … −3.1%) show no block-processing regression, so this looks paid for; just flagging that it is the new steady-state cost of a hit.

// eth_calls starts rejecting with "Too many requests" the moment average latency
// crosses ~(cores / rate) — a cliff a transient latency wobble is enough to trip.
.RegisterBoundedJsonRpcModule<IEthRpcModule, EthModuleFactory>(jsonRpcConfig.EthModuleConcurrentInstances ?? 2 * Environment.ProcessorCount, jsonRpcConfig.Timeout)
.AddSingleton<IBlockchainBridgeFactory, ISimulateReadOnlyBlocksProcessingEnvFactory, IOverridableEnvFactory, ILifetimeScope>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — the knob's own documentation still states the old default.

IJsonRpcConfig.EthModuleConcurrentInstances ends with "Defaults to the number of logical processors." (IJsonRpcConfig.cs:159-160). After this PR the eth module defaults to twice that, and ReceiptRegenerationModule deliberately stays at 1× — so the one operator-facing description for the knob is now wrong for both consumers. This is the config-item text that feeds the generated docs, and the PR ticks "requires documentation update" on the strength of config items carrying their own docs.

Suggest: "Defaults to twice the number of logical processors." plus a clause noting that the receipt-regeneration env pool bounded by the same knob defaults to 1× — that asymmetry is otherwise only discoverable by reading two DI modules.

…, bound the cache config

- EthModuleConcurrentInstances description states the 2x eth-module /
  1x receipt-regeneration split.
- Overload counter keys on suppressWarning so batch/body-size
  LimitExceeded responses are not counted as concurrency shedding, and
  follows the get-only-property-over-field metric pattern.
- Init.InstructionStreamCacheSize gets an upper sanity bound, an
  operator-facing key name in the error, and an Info-level effective-
  value log.
- Recency test drives AssociativeKeyCache alongside AssociativeCache;
  metric regression test asserts per-path deltas.
@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude re-review

Round-2 findings addressed:

  • Medium 1: EthModuleConcurrentInstances description now states both defaults (2× eth module / 1× receipt regeneration).
  • Low 2: the counter keys on suppressWarning, which exactly isolates the three concurrency-shedding sites — batch-size/body-size LimitExceeded responses no longer count.
  • Low 3: metric follows the get-only-property-over-private-field pattern with an internal Increment… method (matches FullPruningCount), so it stays inside the property-walking metric tests.
  • Low 4: upper sanity bound (1 << 24) on Init.InstructionStreamCacheSize, operator-facing key name in the error text, effective value logged at Info.
  • Low 5: the recency test now drives AssociativeKeyCache side by side.
  • Low 6: the metric regression test asserts a delta after each path separately.
  • Low 7 (ZisK): Thread.GetCurrentProcessorId() is portable BCL with a safe fallback and the guest is single-threaded, so all adds land in one slot and Sum stays exact; keeping the counters common avoids forking three Metrics files. Happy to move them behind the .std split if you disagree.

@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 5m 43s —— View job


Re-review (round 3): perf(rpc): fix eth_call degradation under concurrent load

  • Read 9999172 against the rest of the PR
  • Verify Medium 1 — EthModuleConcurrentInstances description
  • Verify Low 2 — metric scoped via suppressWarning
  • Verify Low 3 — metric property pattern
  • Verify Low 4 — config upper bound / key name / Info log
  • Verify Low 5 / Low 6 — test coverage
  • Assess Low 7 (ZisK) response
  • Post findings

All seven round-2 items check out. I traced each to the code and its call sites rather than reading the summary. One Medium remains, and it is in the PR description rather than the code — but it is the description a release note will be written from.

Round-2 findings — verification

# Claim Verdict
Medium 1 description states both defaults Fixed. Verified against all three consumers: RpcModules.cs:89 (module pool) and RpcModules.cs:93 (override-path env pool) → 2 * ProcessorCount; ReceiptRegenerationModule.cs:42ProcessorCount. Text matches. Wording nit inline.
Low 2 counter keys on suppressWarning Fixed, and it is exact. suppressWarning: true is reachable from exactly two places, both concurrency shedding: JsonRpcService.cs:82-84 (LimitExceededException/ConcurrencyLimitReachedException/ModuleRentalTimeoutExceptionReturnErrorResponse) and JsonRpcService.cs:535 (HandleInvocationException). The three false positives I named — JsonRpcProcessor.cs:804, :811, Startup.cs:504 — all enter via the public GetErrorResponse(int, string, …) overloads at :902/:905, which never set the flag, so they no longer count.
Low 3 metric follows the property pattern Fixed. public static long JsonRpcOverloadRejections => _jsonRpcOverloadRejections; + internal static void IncrementJsonRpcOverloadRejections(). Verified it still exports and still can't break registration: MetricsController.TryCreateMetricUpdater only calls memberInfo.SetValue for IMetricObserver members and DetailedMetricOnFlag properties — a long [CounterMetric] takes the GetValueAccessor<double>() path, which is read-only. And MetricsTests.ValidateMetricsDescriptions walks GetProperties(Static | Public), so it now covers this metric (the [Description] is present).
Low 4 upper bound / key name / log level Fixed as described; see the inline note on how much the 1 << 24 ceiling actually buys.
Low 5 AssociativeKeyCache covered Fixed. Checked the structures are actually equivalent — AssociativeKeyCache is Ways = 8, same (int)hashCode & _setMask set selection, same Pick3RandomEvictEntry, and Get is the ticker-refreshing GetCore<OnFlag> — so the test is deterministic for the key cache by the same construction, not by luck.
Low 6 per-path deltas Fixed. beforeInvocation → assert after path 1 → re-capture beforeRental → assert after path 2. Double-counting one path can no longer masquerade as both being counted.
Low 7 ZisK Accepted, and I can add the missing half of the argument: even if the guest runtime returns -1 from GetCurrentProcessorId(), (-1 & s_stripeMask) + 1 lands on the top slot, so the index stays in bounds and Sum stays exact — the only residual failure is a hard PlatformNotSupportedException. Confirmed the split mechanism is Directory.Build.targets:8 (EnableZkEvm removes **/*.std.cs), so Nethermind.Evm/Metrics.cs and StripedLong are indeed compiled for the guest; Interlocked is already used there, and [ThreadStatic] is aliased to NoopThreadStaticAttribute for that build, which is what makes the new VmState/EvmPooledMemory thread-caches degrade to plain statics safely in a single-threaded guest. Not worth forking three Metrics files.

Also re-verified in this pass, unchanged and correct: StackPool.RentStacks nulls the cache slot on rent (no stale ~33 KB retention), the ??=-after-capacity-check reordering, VmState.Return's per-closed-generic [ThreadStatic] pool (allocating the array before the count check there is unreachable-when-full, so harmless), and ClockCacheBase.MarkAccessed's test-before-Or.

Findings

Medium

  1. The PR body states the wrong default for the knob it introduces. The body says "Init.InstructionStreamCacheSize config (default 1024, unchanged)", and the release-note line reads "Init.InstructionStreamCacheSize=4096 recommended for heavy simulation workloads" — both imply 4096 is opt-in. But InitConfig.cs:46 ships = 4096 and IInitConfig.cs declares DefaultValue = "4096" (commit c6f0aa1, after the body was written). So the shipped default is 4096, which by the body's own accounting (size × 512 KiB worst case) moves worst-case stream retention from 512 MiB to 2 GiB, and makes "both knob defaults stay memory-conservative" the wrong summary of what merges. The change itself is well evidenced (the same-build A/B: avg −29.5%, p95 −45.9%, failures 1.76% → 0.35%) and I am not arguing against it — but it is now the single largest default-memory change in the PR and it is the one thing the body tells reviewers and release notes did not happen. Please update the body's bullet, the "Remarks" retention sentence, and the release-note line.

Low (all inline)

  1. 1 << 24 closes the > MaxCapacity case but is still ~940 MB of eagerly-allocated _entries inside InstructionStreamCache's static initializer, so the TypeInitializationException-at-first-execution mode survives at the top of the accepted range; 1 << 181 << 20 would make the message the real guard. — ApplyMemoryHint.cs:33
  2. Stopwatch.GetTimestamp() (my own round-2 suggestion) is only vDSO-cheap when the host clocksource is TSC; on a VM fallen back to xen/hpet/acpi_pm it is hundreds of ns to µs, now paid per cache hit on StaticCodeCache/InstructionStreamCache/TxPool.HashCache. Recording the tradeoff, plus the fact that the comment this replaced documented the opposite measurement — a clause reconciling the two would stop it flipping back. — AssociativeCache.cs:172
  3. "one-times" reads awkwardly in generated config docs; suggestion inline. — IJsonRpcConfig.cs:159-162
  4. Keying the counter on suppressWarning is exactly right today, but it couples an observability counter to a logging flag: a future suppressWarning: true on a non-concurrency error silently inflates the metric. The comment at the increment site partly covers this; a note on the two switch arms would close it.

Verdict

The code is in good shape — every substantive concern from rounds 1 and 2 is closed, and the fixes are the ones I would have written. The one thing I would not merge without is finding 1: the description and release-note text disagree with the shipped default on a memory-affecting knob. That is a two-minute edit to the PR body, not a code change.

Not verified: dotnet build/dotnet test are outside my allowed tools in this environment (the build command was denied), so this remains a static review. I hand-checked the compile-level plumbing for the changed test and metric code (member visibility, MetricsController's reflection paths, NUnit's Assert.EnterMultipleScope already in use at HistoryPrunerTests.cs:136, the EnableZkEvm compile-item split) but did not compile or execute anything.
· branch perf/ethcall-concurrency

// size exists to avoid) and an out-of-range value would surface later as an opaque
// TypeInitializationException at the first EVM execution, so reject both here.
// The ceiling is a sanity bound well under the cache's structural capacity limit.
const int maxInstructionStreamCacheSize = 1 << 24;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — the bound closes the > MaxCapacity case but not the failure mode behind it.

1 << 24 is well under AssociativeCache.MaxCapacity (1u << 27), so the ArgumentOutOfRangeException-inside-the-type-initializer path is gone. What remains is that 1 << 24 is still far above anything a node can survive:

  • InstructionStreamCache is AssociativeCache<ValueHash256, InstructionStream>, so an entry is header (8) + ValueHash256 (32) + object ref (8) + ticker (8) ≈ 56 B, and _entries is allocated eagerly in the ctor. At 1 << 24 that is ~940 MB plus a int[1 << 21] gate array — allocated inside InstructionStreamCache's static initializer, i.e. still an OutOfMemoryException wrapped in TypeInitializationException at the first EVM execution on most hosts.
  • The config's own doc says worst-case retention is size × 512 KiB. At 1 << 24 that is 8 TiB; even 1 << 18 is already 128 GiB. So the operator-meaningful ceiling is orders of magnitude below the structural one.

A bound in the 1 << 181 << 20 range would make the error message the actual guard rather than a formality. Init. prefix in the message and the Info log are both right now.

Fix this →

Comment on lines +159 to +162
overrides: calls beyond this cap fail with a `LimitExceeded` JSON-RPC error. Defaults
to the number of logical processors.
to twice the number of logical processors for the eth module and override-path pools
(offered concurrency is request rate times call latency, so one-times rejects under
transient latency wobbles); receipt regeneration deliberately stays at one-times.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved (round-2 Medium 1) — with a wording nit.

Verified against the three consumers: RpcModules.cs:89 (module pool) and RpcModules.cs:93 (override-path env pool) both default to 2 * ProcessorCount, ReceiptRegenerationModule.cs:42 stays at ProcessorCount. So "the eth module and override-path pools" is accurate and the asymmetry is now discoverable from the one operator-facing text.

Nit: "one-times" reads awkwardly twice ("so one-times rejects…", "stays at one-times"). Since this string is rendered verbatim into the generated config docs, something like "one per logical processor" / "stays at one per logical processor" would read better.

Suggested change
overrides: calls beyond this cap fail with a `LimitExceeded` JSON-RPC error. Defaults
to the number of logical processors.
to twice the number of logical processors for the eth module and override-path pools
(offered concurrency is request rate times call latency, so one-times rejects under
transient latency wobbles); receipt regeneration deliberately stays at one-times.
to twice the number of logical processors for the eth module and override-path pools
(offered concurrency is request rate times call latency, so one per logical processor
rejects under transient latency wobbles); receipt regeneration deliberately stays at
one per logical processor.

// not key/value correctness — the "losing" ticker value is simply slightly stale.
if (TRefreshTicker.IsActive)
e.Ticker = Interlocked.Increment(ref _ticker);
e.Ticker = Stopwatch.GetTimestamp();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — one residual risk I should have named when I suggested this source.

The change is the one I asked for and the reasoning in the new comment is right. Two things worth recording, since the comment this replaced documented the opposite conclusion ("Interlocked.Increment is faster than Stopwatch.GetTimestamp() (RDTSC) — ~7.6 ns vs ~19 ns single-threaded, and scales better under contention (20% faster at 8 threads)"):

  1. Stopwatch.GetTimestamp() is only cheap when the host's clocksource is TSC. On Linux it is clock_gettime(CLOCK_MONOTONIC); that resolves in the vDSO without a syscall only for tsc-family clocksources. On a VM that has fallen back to xen / hpet / acpi_pm (older Xen-based instances, some nested-virt setups), the same call becomes an MMIO read or a real syscall in the hundreds-of-ns-to-µs range. That would now be paid on every hit of StaticCodeCache / InstructionStreamCache / TxPool.HashCache — per call frame and per tx. Your benchmark box is presumably TSC, so the arms cannot see this.
  2. Because the removed comment recorded a measured decision in the other direction, a future reader has two contradictory claims to reconcile. One clause noting that the earlier number compared the two clocks in isolation, while the cost being removed here is the shared-line RMW next to _epochAndCount, would stop this flipping back.

If (1) is a concern for the node's supported hosts, the cheapest alternative that keeps the old counter's measured single-threaded cost is to keep Interlocked.Increment but move _ticker onto its own cache line (it was adjacent to _epochAndCount, which is the false-sharing cause the comment names) — that addresses the stated mechanism without introducing a clock read on the hit path. Not asking for a change, just recording the tradeoff.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

EVM Opcode Benchmark Diff

Aggregated runs: base=1, pr=1

Improvements (1)

Opcode Base Median (ns) PR Median (ns) Delta Abs Δ (ns) Base CV PR CV Threshold Uncertainty Effective
CREATE2 473.160 433.780 -8.32% 39.380 2.2% 1.7% ±5.0% ±2.7% ±5.0%

… shedding marker

Ceiling drops to 1<<20 so the config error, not a type-initializer
failure, guards the top of the range; the eviction-clock comment records
both sides of the Interlocked-vs-clock measurement so it does not flip
back; the suppressWarning switch arms note their metric coupling; config
prose drops the awkward wording.
@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude re-review

Round-3 items addressed:

  • Medium 1: PR body now states the shipped 4096 default in all three places (Changes bullet, evidence line, release-note + Remarks memory accounting: worst-case ceiling 512 MiB → 2 GiB, typical far smaller, lower on constrained setups).
  • Low 2: ceiling now 1 << 20 — the config error is the real guard across the whole accepted range.
  • Low 3: the eviction-clock comment records both measurements (old Interlocked wins single-threaded and on non-TSC clocksources; the clock wins under concurrent readers — the motivating regime) so it won't flip back.
  • Low 4: config prose reworded.
  • Low 5: both switch arms note that suppressWarning doubles as the shedding marker feeding the metric.

@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 4m 8s —— View job


Re-review (round 4): perf(rpc): fix eth_call degradation under concurrent load

  • Read d1cc85c against the rest of the PR
  • Verify Medium 1 — PR body states the shipped 4096 default
  • Verify Low 2 — cache-size ceiling
  • Verify Low 3 — clocksource tradeoff recorded
  • Verify Low 4 — config prose
  • Verify Low 5 — suppressWarning shedding marker documented
  • Post findings

All five round-3 items check out. Nothing Critical, High, or Medium remains open. Two cosmetic Lows on the new config guard, both inline and both optional.

Round-3 findings — verification

# Claim Verdict
Medium 1 body states the shipped default Fixed in all three places. Changes bullet now reads "default 4096 (was a hardcoded 1024 — the largest default-memory change in this PR)"; the isolation A/B line is labelled "this is the PR's shipped default; the tables above include it"; the release-note line and Remarks both carry the 512 MiB → 2 GiB worst-case accounting with the "typical is far smaller" caveat and the lower-it-on-constrained-setups instruction. Matches the code: InitConfig.cs:46 = 4096, IInitConfig.cs:113 DefaultValue = "4096".
Low 2 ceiling is the real guard Fixed. 1 << 20 → ~56 MB eager entry table (Entry = 8 + 8 + 32 + 8), so the validation message, not an OOM inside InstructionStreamCache's type initializer, is what an operator hits anywhere in the accepted range. Notes inline.
Low 3 clocksource tradeoff recorded Fixed, and it reconciles the two measurements rather than just replacing one. The comment now states that the clock read loses single-threaded (and more on non-TSC hosts) while winning under concurrent readers — "the regime that motivated the change" — plus an explicit "do not flip back to a shared counter for the ns." Applied identically in AssociativeCache.cs and AssociativeKeyCache.cs.
Low 4 config prose Fixed. "one-times" is gone; "a processor-count cap" reads correctly in generated docs, and the receipt-regeneration asymmetry now carries its rationale ("as expensive as block execution") in the operator-facing text, not just in the DI module.
Low 5 shedding-marker coupling noted Fixed at both suppressWarning: true sites (JsonRpcService.cs:83 switch, :534 HandleInvocationException arm). Re-confirmed the coupling is still exact: suppressWarning: true reaches the private GetErrorResponse from only those two paths, both concurrency shedding; ResultWrapper.cs:83 sets Error.SuppressWarning directly and bypasses the funnel, so IsTemporary errors (Taiko, MaxLogsPerResponse) cannot inflate the counter.

Also re-verified in this pass, unchanged and correct: StripedLong's lead-pad arithmetic is coherent end to end (Add writes (idx+1)*16, Sum reads stripes 0..mask at (stripe+1)*16, array is (mask+2)*16 — in bounds, no slot skipped or double-counted) and the <remarks> allocation formula (stripes + 1) * 128 matches; every Metrics.cs converted to StripedLong keeps Interlocked in use so no using went unused (lint-clean) and all four already imported Nethermind.Core.Threading; the metric is still export-safe and still inside MetricsTests.ValidateMetricsDescriptions' property walk; the new test's unqualified Metrics resolves to Nethermind.JsonRpc.Metrics via the enclosing-namespace lookup, ahead of the Nethermind.Trie / Nethermind.Evm compilation-unit usings that also declare a Metrics, so there is no ambiguity there.

Findings

Low (both inline, both on the new config guard, neither blocking)

  1. InvalidDataException where the repo has a dedicated config typeApplyMemoryHint.cs:37. InvalidConfigurationException carries an exit code via IExceptionWithExitCode (Program.cs:89, :252) and is what the newer validation in this assembly uses (InitializeNetwork.cs:127, PruningTrieStateFactory.cs:196, NethermindModule.cs:124); InvalidDataException matches only MemoryHintMan's older style. Behaviourally identical here — same log, same ExitCodes.GeneralError — so purely consistency. Also: no test pins the accepted range.
  2. The [1, 1 << 20] range is enforced but not documentedIInitConfig.cs:113. The ConfigItem description documents units and default per coding-style.md; one clause naming the ceiling would stop an operator discovering it via a node that refuses to start.

Verdict

Ready to merge from my side. Four rounds in, every substantive concern is closed in the code, and the two text-vs-behaviour gaps from round 3 (the shipped 4096 default in the body, the awkward config prose) are now consistent with what ships. The remaining Lows are wording and exception-type consistency; neither changes behaviour.

Not verified: dotnet build/dotnet test are outside my allowed tools in this environment (git fetch was denied too, so I diffed against the PR's merge-base commit 219d999 in the local history rather than origin/master). This remains a static review — I hand-checked compile-level plumbing for the changed code (member visibility, using liveness after the metric rewrites, name resolution for the new test's Metrics reference, MetricsController's reflection paths) but did not compile or execute anything.
· branch perf/ethcall-concurrency

Comment on lines +34 to +38
const int maxInstructionStreamCacheSize = 1 << 20;
if (initConfig.InstructionStreamCacheSize is <= 0 or > maxInstructionStreamCacheSize)
{
throw new InvalidDataException(
$"Init.{nameof(IInitConfig.InstructionStreamCacheSize)} must be between 1 and {maxInstructionStreamCacheSize}, got {initConfig.InstructionStreamCacheSize}.");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved (round-3 Low 2) — with two cosmetic notes.

1 << 20 is the right end of the range I suggested: AssociativeCache<ValueHash256, InstructionStream>'s Entry is Header(8) + Ticker(8) + ValueHash256(32) + ref(8) = 56 B, so the eagerly-allocated table tops out at ~56 MB — the comment's "tens of MB" is accurate, and this validation is now genuinely the guard across the whole accepted range rather than a formality. Init. prefix and the Info log are both right.

Two nits, neither blocking:

  1. InvalidDataException vs InvalidConfigurationException. The repo has a dedicated config-failure type that carries an exit code (Nethermind.Core/Exceptions/InvalidConfigurationException.cs, consumed by Program.cs:89/:252 via IExceptionWithExitCode), and the newer validation in this assembly uses it — InitializeNetwork.cs:127, PruningTrieStateFactory.cs:196, NethermindModule.cs:124. InvalidDataException matches only MemoryHintMan's older style. Behaviourally identical here (both surface as "unhandled error" + ExitCodes.GeneralError), so purely a semantic/consistency point.
  2. No test for the guard. Two Assert.Throws cases on Execute (0 and (1 << 20) + 1) would pin the range that the config docs now imply. Optional given how much test coverage the rest of the PR gained.

Comment on lines +113 to +114
DefaultValue = "4096")]
int InstructionStreamCacheSize { get; set; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — the accepted range is now enforced but not documented.

ApplyMemoryHint.cs:34-38 rejects anything outside [1, 1 << 20] at startup, and this [ConfigItem] description is the only operator-facing text for the key. Units and default are documented (per coding-style.md), but the bound is discoverable only by tripping it — a node that fails to start on a config value is a worse discovery mechanism than one sentence here. Suggest appending "Valid range: 1 to 1048576." to the description.

@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 10m 41s —— View job


Review (round 5, ready_for_review): perf(rpc): fix eth_call degradation under concurrent load

  • Read d3596ad against the rest of the PR
  • Verify round-4 Low 1 — InvalidConfigurationException for the cache-size guard
  • Verify round-4 Low 2 — accepted range documented in the ConfigItem
  • Full re-pass over the diff for anything missed across rounds
  • Look into the EXTCODESIZE opcode-benchmark regression (+653%)
  • Post findings

Both round-4 Lows are fixed. The code itself I now consider clean — five rounds in I have nothing new to raise against it. One Medium is open, and it is not in the diff: CI is reporting a 7.5× EXTCODESIZE regression that nobody has triaged, on the one opcode path this PR happens to touch twice.

Round-4 findings — verification

# Claim Verdict
Low 1 InvalidConfigurationException Fixed. InvalidConfigurationException(string, int) implements IExceptionWithExitCode, so Program.cs maps it to a deliberate exit code rather than a generic unhandled-error path, and it matches the newer validation in this assembly. Checked the using churn compiles clean: System.IO is now genuinely unused in the file, and ExitCodes lives in Nethermind.Config, so the added using is required. One nit inline — ExitCodes reserves 100…199 for config errors and ForbiddenOptionValue = 107 fits this precisely; GeneralError throws away the reason for carrying a code.
Low 2 accepted range documented Fixed. "accepted range 1 to 1048576" matches ApplyMemoryHint.cs:35-41 exactly (1 << 20), so the generated config docs and the startup guard now agree.

Also re-verified in this pass (unchanged, still correct)

  • Startup ordering. ApplyMemoryHint is a root step (BuiltInStepsModule.cs:14) and InitTxTypesAndRlp declares [RunnerStepDependencies(typeof(ApplyMemoryHint))], so the MemoryAllowance.InstructionStreamCacheSize write really does precede EVM work; the Info log makes a violation observable.
  • EvmPooledMemory byte budget. _cachedArrayBytes is balanced across the only two mutators (Rent's hit path decrements, Return's cache path increments) and cannot drift — Rent is the sole way an array leaves the cache. Rent's int cachedArrayCount = _cachedArrayCount - 1 makes the loop a no-op at count 0, so the cache! deref is unreachable when the cache is null. No new array shape reaches ReturnLarge: (1<<16, 1<<18] buffers are diverted away from the shared pools, never towards them.
  • RentSlow's dirty-buffer contract survives the bigger cached sizes. Growth copies only _lastZeroedSize bytes and then zero-extends to target = min(memory.Length, roundUp(Size, 4 KiB)) >= Size, so every readable byte is cleared regardless of which rent source produced the buffer — which is exactly what the new [TestCase] set asserts, and the 1 MiB case genuinely exercises the shared-pool arm (RentLargeSafeArrayPool at exactly MaxSharedArrayLength).
  • StackPoolTests is not order-dependent, which I had to check since it asserts Is.SameAs against a process-wide [ThreadStatic]. It holds even when a prior deep-recursion test left the thread cache at all 16 slots, because Rent decrements before Return increments — so the return always finds a free slot. No flake.
  • Refreshed_entry_survives_sustained_churn_in_its_set stays deterministic under Windows QPC's 100 ns tick: a tie would need Set(filler_{i-1})Get(hot) to span under 100 ns across an Assert.EnterMultipleScope() exit and two assertions.
  • Metric plumbing: all five files touched by StripedLong already import Nethermind.Core.Threading, all still use Interlocked for the _main* words so no using went dead, and MetricsController never writes a long [CounterMetric] (SetValue is reached only for IMetricObserver / DetailedMetricOnFlag members), so the get-only-property form cannot break registration.
  • GC.Add/RemoveMemoryPressure pairing is still symmetric: the only two thresholded sites are DbOnTheRocks.cs:952 / :1054, both keyed on the same constant and the same span, and the other pressure users in the repo (HyperClockCacheWrapper, BloomFilter, PageResidencyTracker) are untouched and independently paired.

Findings

Medium

  1. The EVM opcode benchmark reports EXTCODESIZE 111.7 ns → 841.1 ns (+653%) and it has not been triaged. I don't think it's real, but I don't think it can be dismissed either. The harness already reran flagged-noisy opcodes and aggregated 3 runs per side, and while the base arm is loose (CV 15.5%) the PR arm is tight — CV 3.1% around 841 ns; a tight measurement 7.5× above base is not the usual shape of a noisy cell. And EXTCODESIZE is the opcode most exposed here: its non-peephole path (EvmInstructions.CodeCopy.cs:308) reaches StaticCodeCache.GetAssociativeCache.Get, so one opcode now pays both new per-lookup costs — Stopwatch.GetTimestamp() on the cache hit (AssociativeCache.cs:174) and Thread.GetCurrentProcessorId() + bounds-checked array add in StripedLong (Metrics.cs:64, via CacheCodeInfoRepository.cs:41). Neither should cost ~730 ns on a TSC host — but that's the point: two cheap checks discriminate. Re-run the opcode diff, and if it reproduces, read /sys/devices/system/clocksource/clocksource0/current_clocksource on the benchmark runner. A non-tsc clocksource makes the clock read an MMIO read or syscall per code-cache hit, which would be a real block-processing regression that the (presumably TSC) expb arms cannot see. Full reasoning inline. Fix this →

Low

  1. ExitCodes.GeneralError where ExitCodes.ForbiddenOptionValue (107, in the reserved 100…199 config-error range) is the fitting code — inline, one-word change.
  2. Still no test pinning the [1, 1 << 20] guard. Two Assert.Throws cases on Execute would do it. I called this optional in round 4 and still would; noting only that it's the one new behaviour in the PR without a test.

Verdict

The diff is ready. Rounds 1–4 closed every substantive concern, and d3596ad closes the last two cosmetic ones; I found nothing new in the code this pass, including on a fresh look at the pooling arithmetic and the new tests' order-independence.

What I would not merge past is finding 1 — not because I believe the regression, but because it is currently unexplained on a path this PR touches twice, and the two things that would explain it are a re-run and one cat on the benchmark runner. If it doesn't reproduce, say so and this is a clean merge; if it does, the round-3 fallback (Interlocked on a line-isolated _ticker) is already sketched. Findings 2 and 3 can ride either way.

Not verified: dotnet build / dotnet test are outside my allowed tools in this environment (both dotnet --version and the clocksource read were denied), so this remains a static review. I hand-checked compile-level plumbing for the round-4 delta — InvalidConfigurationException's two-arg ctor, ExitCodes living in Nethermind.Config (so the added using is needed), System.IO now being unused — but compiled and executed nothing, and I could not reproduce the opcode benchmark.
· branch perf/ethcall-concurrency

if (!ExecutionMetricsFlag.IsActive) return;
Interlocked.Increment(ref IsBlockProcessingThread ? ref _mainCodeDbCache.Value : ref _otherCodeDbCache.Value);
if (IsBlockProcessingThread) Interlocked.Increment(ref _mainCodeDbCache.Value);
else _otherCodeDbCache.Increment();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — the opcode-benchmark bot reports EXTCODESIZE at 111.7 ns → 841.1 ns (+653%) and it has not been triaged.

I am not claiming this is real, but I do not think it can be waved off as noise either, and this is a performance PR:

  • It is the only flagged opcode, and the harness already reruns flagged-noisy opcodes and aggregates 3 runs per side (evm-opcode-benchmark-diff.yml:90, :185-237). The base arm is loose (CV 15.5%) but the PR arm is tight — CV 3.1% around 841 ns. A tight measurement 7.5× above base is not the shape of a noisy cell; noise usually widens the arm that moves.
  • EXTCODESIZE is, as far as I can tell, the opcode most exposed to this PR. On the non-peephole path (EvmInstructions.CodeCopy.cs:308) it calls GetCachedCodeInfo, which reaches CacheCodeInfoRepository.GetOrCacheCodeInfoStaticCodeCache.GetAssociativeCache.Get. So a single EXTCODESIZE now pays both newly-added per-lookup costs:
    1. Stopwatch.GetTimestamp() on the cache hit (AssociativeCache.cs:174), replacing Interlocked.Increment — the exact tradeoff the comment there now documents, including the non-TSC-clocksource case;
    2. _otherCodeDbCache.Increment() here, i.e. Thread.GetCurrentProcessorId() + an array load + bounds check, replacing a direct Interlocked.Increment on a padded word (CacheCodeInfoRepository.cs:41; only when ExecutionMetricsFlag.IsActive).

Neither of those should cost ~730 ns on a TSC host, which is why I lean towards a harness artifact — but that is a guess, and the two candidates above are cheap to discriminate:

  • re-run the opcode diff and see whether EXTCODESIZE reproduces;
  • if it does, check cat /sys/devices/system/clocksource/clocksource0/current_clocksource on the benchmark runner. A non-tsc clocksource turns (1) into an MMIO read or a real syscall on every code-cache hit, which would make this a genuine block-processing regression on any host in the same situation — and the expb arms (all TSC, presumably) could not see it.

Either outcome is fine to merge on; an untriaged 7× on a path the PR touches twice is the part I would not merge past. If it reproduces and (1) is the cause, the fallback the round-3 discussion already identified applies: keep Interlocked.Increment but move _ticker off the _epochAndCount line, which addresses the stated false-sharing mechanism without putting a clock read on the hit path.

{
throw new InvalidConfigurationException(
$"Init.{nameof(IInitConfig.InstructionStreamCacheSize)} must be between 1 and {maxInstructionStreamCacheSize}, got {initConfig.InstructionStreamCacheSize}.",
ExitCodes.GeneralError);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved (round-4 Low 1) — one follow-on nit.

InvalidConfigurationException(string, int) is the right type: it implements IExceptionWithExitCode, so Program.cs maps it to a deliberate exit code instead of a generic unhandled-error path, and it matches the newer validation in this assembly (InitializeNetwork.cs:127, PruningTrieStateFactory.cs:196). The dropped using System.IO; is now genuinely unused in this file and the added using Nethermind.Config; is required for ExitCodes — lint-clean either way.

Nit: ExitCodes reserves 100…199 for config errors and already has a name that fits this exactly — ForbiddenOptionValue = 107 (used elsewhere for out-of-range config values). GeneralError works, but a distinct code is the whole reason to carry one.

Suggested change
ExitCodes.GeneralError);
ExitCodes.ForbiddenOptionValue);

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.

1 participant