Skip to content

perf(rpc): cut eth_call overhead in memory, code stream, and tx parse - #12716

Open
kamilchodola wants to merge 9 commits into
masterfrom
perf/eth-call-memory-and-rpc-parse
Open

perf(rpc): cut eth_call overhead in memory, code stream, and tx parse#12716
kamilchodola wants to merge 9 commits into
masterfrom
perf/eth-call-memory-and-rpc-parse

Conversation

@kamilchodola

@kamilchodola kamilchodola commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Changes

  • Zero recycled EVM memory on expansion (RentSlow) instead of bulk-clearing the high-water mark on frame exit; thread-local buffers are cached dirty and zero-extended just before use.
  • Skip instruction-stream builds when CodeInfo.CodeHash is default (eth_call state overrides / uncacheable code) so per-build buffers are not churned and discarded.
  • Derive TransactionForRpc concrete subtype by scanning property names on Utf8JsonReader instead of materializing a JsonObject DOM (large eth_call payloads).
  • Pre-check ContainsKey before PromoteAccount TryAdd so hot-account re-promotions stay off the concurrent dictionary bucket lock.
  • Regression tests: dirty small/large buffer reuse, grow-after-reuse, default-hash CodeInfo stream skip, large-calldata tx type detection.

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
  • Other: Description

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

Notes on testing

  • Extended EvmPooledMemoryTests for dirty cache reuse (1–32 KiB + large paths) and grow-after-reuse prefix preservation.
  • Added CodeInfoTests.GetOrBuildStream_without_code_hash_is_unavailable.
  • Extended TransactionForRpcDeserializationTests with large calldata cases.
  • Local: EvmPooledMemoryTests + CodeInfoTests.GetOrBuildStream* (60) and TransactionForRpcDeserializationTests (59) all green.
  • RPC bench (jsonbench-sweep, ethcallchaos-percategory-validated, 100 RPS / 120s, 3×): paired vs master −2.7% avg, better p95/p99; 497/497 corpus parity.

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

Minor eth_call / JSON-RPC path optimizations: lower latency on dense eth_call workloads via deferred EVM memory zeroing, skipping uncacheable code-stream builds, cheaper tx JSON subtype selection, and reduced flat-state promotion lock contention.

Remarks

Product-only extract from perf/ethcall-497-iter6 (no rpc-bench harness changes). Benchmark red on those runs was reth corpus parity vs master, not these branches.

Zero EVM pooled memory on growth instead of frame exit, skip uncacheable
instruction-stream builds, derive TransactionForRpc subtype from the
Utf8JsonReader instead of a DOM, and keep repeat flat-state account
promotions off the bucket lock. Regression coverage for dirty-buffer
reuse, default-hash CodeInfo, and large-calldata tx type detection.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@claude

claude Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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


Review: perf(rpc): cut eth_call overhead in memory, code stream, and tx parse

  • Read PR diff against origin/master (incl. the newer head fdc7da2, comment-trim only)
  • Audit EvmPooledMemory deferred-zeroing for EVM-visible correctness
  • Audit CodeInfo.GetOrBuildStream skip
  • Audit TransactionForRpc reader-based subtype derivation
  • Audit SnapshotBundle.PromoteAccount
  • Post findings

Verdict

The four optimizations are individually sound in idea, and I traced the EvmPooledMemory invariant carefully — for every EVM-visible read path it holds. But there is one real regression in the tracing path, and one behaviour that needs a benchmark it hasn't had. 1 High, 2 Medium, 5 Low.


High

1. TraceMemory can now return stale bytes past SizeEvmPooledMemory.cs

The <remarks> claim that "no caller can observe bytes beyond the zeroed prefix" has one exception. GetTrace() does ClearForTracing(Size) and then hands the whole array to TraceMemory, whose Slice(start, length) bounds-checks against span.Length (the array length), not Size:

ReadOnlySpan<byte> span = _memory.Span;
if (start + length > span.Length) { /* pad with zeros */ }
return span.Slice(start, length);   // <-- raw dirty tail

Before this PR the whole rented buffer was zeroed, so an out-of-Size slice read back as zeros. Now everything past _lastZeroedSize == Size is the previous frame's memory.

Reachable via debug_traceTransaction / debug_traceCall with a custom JS tracer doing log.memory.slice(0, 512) while log.memory.length() is 32 (Custom/JavaScript/Log.cs:69) → non-deterministic trace output containing unrelated EVM memory. NativePrestateTracer:160 and GethLikeTxDirectStreamingTracer:195 are safe (both clamp to Size). Fix is a one-liner in GetTrace(): clamp the ReadOnlyMemory to min(Size, _memory.Length), which also restores the old zero-padding behaviour.

Medium

2. RentSlow now runs on every memory expansion, not just on reallocationEvmPooledMemory.cs

EnsureRented's Size > _lastZeroedSize clause used to be subsumed by Size > memory.Length; it's now the live one. A contract ABI-encoding sequentially across 32 KiB goes from ~1000 inlined no-op checks + one bulk clear to ~1000 non-inlined calls + 1000 32-byte Array.Clear calls. Total bytes cleared are unchanged, but the per-call overhead isn't. Only an eth_call RPC sweep was reported (−2.7%); this is the hot path for all block processing — please run the reproducible payload benchmark. Chunked zero-extension (round the clear up to 4 KiB, clamped to memory.Length) keeps the win and preserves every invariant.

3. CodeInfo permanently latches StreamBuildUnavailable on a default CodeHashCodeInfo.cs

CodeHash is a public setter assigned after construction by StaticCodeCache.Set. Every current path publishes before first execution, so this is latent — but if any path ever executes a CodeInfo before caching it, that bytecode silently loses the stream interpreter forever. The Volatile.Write saves one comparison on an already-slow path; making the skip stateless (return null) removes the failure mode.

Low

# Finding
4 using System.Text.Json.Nodes; is now unusedcode-lint.yml will fail CI
5 reader.Skip() throws on non-final-block readers; TrySkip() is free. Current call sites are all isFinalBlock: true, so latent
6 Missed free win: break once setType is found — avoids scanning the 100s-of-KB input this PR is optimizing for
7 Escaped-name fallback allocates 2 strings per (property × discriminator); also worth documenting that the | 0x20 fold relies on ASCII-letter discriminators
8 DiscriminatorProperties is now dead code (AGENTS.md: remove it); Math.Min(_txTypes.Count, 64) is a silent cap
9 GetOrBuildStream_without_code_hash_is_unavailable passes on master — doesn't discriminate the change

What I verified as correct

Worth recording, since these were the parts most likely to be wrong:

  • EvmPooledMemory EVM-visible invariant holds. Every write/read path (TrySave*, TryLoad*, *AfterGas, CopyAfterGas, Load32BytesAfterGas) is preceded by EnsureRented, which zero-extends to the full Size — not just to the accessed newLength. I specifically checked MCOPY, the one op where the source region can sit above the destination: UpdateMemoryCost(..., UInt256.Max(b, a), c, ...) raises Size to cover max(src,dst)+len before CopyAfterGas, so the source read is inside the zeroed prefix. Inspect is guarded by largeSize > _memory.Length and calls ClearForTracing.
  • _lastZeroedSize = 0 in the memory is null branch is load-bearing and presentVmState.Dispose does _memory = default (VmState.cs:229), so a pooled VmState re-enters with Size = 0; and the grow branch's Array.Copy(..., (int)_lastZeroedSize) is in bounds in every state I could construct, including the ClearForTracing-clamped and CalculateMemoryCost(rentIfNeeded: false) cases.
  • Large arrays returned dirty to ArrayPool.Shared is not new — the old ReturnClean already ignored dirtyLength for > MaxCachedArrayLength; only the rent-side full Array.Clear was dropped, and RentSlow covers [0, Size).
  • Tx-type derivation is order-equivalent. _txTypes is built in descending TxType order (SetCode, Blob, EIP1559, AccessList, Legacy), so TrailingZeroCount(discriminated) picks the same winner as the old FirstOrDefault. typegasPrice → discriminator precedence is preserved, and case-insensitivity matches the old JsonObject lookup (EthereumJsonSerializer sets PropertyNameCaseInsensitive = true). GasPrice carries no [JsonDiscriminator], so skipping the discriminator loop in the gasPrice branch is equivalent.
  • PromoteAccount is semantically identical and genuinely cheaper. ContainsKey is lock-free, TryAdd still guards the race, and hoisting HashedKey<Address> reuses the cached _hashCode (HashedKey.cs:11) across both lookups.

Merge recommendation

Blocked on #1 (correctness) and #4 (CI lint). #2 needs a block-processing benchmark or an explicit "measured, no regression" note; #3 is a one-line hardening. The Lows are optional but #6 is free performance in exactly the scenario this PR targets.
· branch perf/eth-call-memory-and-rpc-parse

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment on lines 461 to 469
// Cached dirty; RentSlow zero-extends [_lastZeroedSize, Size) on growth.
private static byte[] Rent(int minLength)
{
byte[]?[]? cache = _cleanArrays;
int cleanArrayCount = _cleanArrayCount - 1;
for (int i = cleanArrayCount; i >= 0; i--)
byte[]?[]? cache = _cachedArrays;
int cachedArrayCount = _cachedArrayCount - 1;
for (int i = cachedArrayCount; i >= 0; i--)
{
byte[] candidate = cache![i]!;
if (candidate.Length >= minLength)

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 — the "no caller can observe bytes beyond the zeroed prefix" invariant does not hold for TraceMemory.

GetTrace() (line 396) does ClearForTracing(Size) and then hands the whole array to TraceMemory:

public TraceMemory GetTrace()
{
    ulong size = Size;
    ClearForTracing(size);
    return new(size, _memory);   // ReadOnlyMemory over _memory.Length, not size
}

TraceMemory.Slice(start, length) (Nethermind.Evm/Tracing/TraceMemory.cs:57) bounds-checks against span.Length — the array length — not Size:

ReadOnlySpan<byte> span = _memory.Span;
if (start + length > span.Length) { /* pad with zeros */ }
return span.Slice(start, length);   // <-- raw tail

Before this PR every rented buffer was fully zeroed (RentClean cleared the whole array, and RentSlow set _lastZeroedSize = _memory.Length), so an out-of-Size slice read back as zeros. Now _lastZeroedSize == Size and everything past it is the previous frame's / previous transaction's memory.

Failure scenario: debug_traceTransaction / debug_traceCall with a custom JS tracer whose step does log.memory.slice(0, 512) while log.memory.length() is 32 (Custom/JavaScript/Log.cs:69MemoryTrace.Slice). Old output: 32 real bytes + 480 zero bytes. New output: 32 real bytes + 480 bytes of whatever the thread-local cached buffer (MinRentSize = 1024) held from a prior frame — so trace output becomes non-deterministic and leaks unrelated EVM memory into the RPC response. NativePrestateTracer:160 and GethLikeTxDirectStreamingTracer:195 are safe because they clamp to Size.

Cheapest fix is to clamp what TraceMemory can see, which also restores the old zero-padding path:

public TraceMemory GetTrace()
{
    ulong size = Size;
    ClearForTracing(size);
    return new(size, _memory is null ? default : _memory.AsMemory(0, (int)Math.Min(size, (ulong)_memory.Length)));
}

Please also add a regression test (dirty a large buffer, dispose, then GetTrace().Slice(0, n) past Size and assert zeros).

Fix this →%20is%20zeroed.%20TraceMemory.Slice%20bounds-checks%20against%20the%20array%20length%2C%20so%20a%20JS%20tracer%20calling%20log.memory.slice%20past%20Size%20now%20reads%20stale%20bytes%20instead%20of%20zeros.%20Clamp%20the%20ReadOnlyMemory%20passed%20to%20TraceMemory%20to%20min(Size%2C%20_memory.Length)%20and%20add%20a%20regression%20test%20in%20EvmPooledMemoryTests.&repo=NethermindEth/nethermind)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in f58ff48.

GetTrace() now clamps the ReadOnlyMemory passed to TraceMemory to min(Size, _memory.Length), so Slice past the EVM high-water takes the zero-pad path instead of reading dirty tail bytes.

Regression: GetTrace_slice_past_size_does_not_leak_dirty_bytes — dirties a 1 KiB buffer, reuses it with Size=32, asserts trace.Slice(0, 512) is all zeros.

Comment on lines +543 to +548
ulong size = Size;
if (size > _lastZeroedSize)
{
Array.Clear(memory, (int)_lastZeroedSize, (int)(size - _lastZeroedSize));
_lastZeroedSize = size;
}

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 — this moves RentSlow from "once per buffer reallocation" to "once per memory expansion". That is a plausible block-processing regression, and the reported benchmark does not cover it.

EnsureRented fires when memory is null || Size > memory.Length || Size > _lastZeroedSize. Before this PR _lastZeroedSize was always _memory.Length, so the third clause was subsumed by the second and RentSlow was reached only when the buffer actually had to grow. Now the third clause is the live one, so every memory-expanding opcode takes the NoInlining RentSlow call plus an Array.Clear call.

Failure scenario: a contract that ABI-encodes sequentially (MSTORE walking the high-water mark 32 bytes at a time across 32 KiB) goes from ~1000 inlined no-op checks + one 32 KiB Array.Clear at frame exit, to ~1000 non-inlined RentSlow calls + 1000 32-byte Array.Clear calls. "Total bytes cleared are the same" is true, but small Array.Clear is dominated by call/setup cost, not by the stores — so the per-op cost goes up while only one bulk clear is removed.

The PR reports only an eth_call RPC sweep (−2.7% avg). This is the hot path for all block processing, so please run the reproducible payload benchmark (.github/workflows/run-expb-reproducible-benchmarks.yml, or the gas-benchmark skill) before merging, and post the result.

A cheap way to keep the deferred-clear win while amortising the call overhead is to zero-extend to a chunk boundary rather than exactly to Size:

ulong size = Size;
if (size > _lastZeroedSize)
{
    // Over-zero to a chunk boundary so sequential growth doesn't take RentSlow on every word.
    const ulong ZeroChunk = 4 * 1024;
    ulong target = Math.Min((ulong)memory.Length, (size + (ZeroChunk - 1)) & ~(ZeroChunk - 1));
    Array.Clear(memory, (int)_lastZeroedSize, (int)(target - _lastZeroedSize));
    _lastZeroedSize = target;
}

_lastZeroedSize >= Size is preserved, so every other invariant in the file still holds.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in f58ff48 (amortization part).

RentSlow now zero-extends to a 4 KiB boundary (capped at memory.Length), so sequential word-by-word MSTORE growth stays inside an already-zeroed chunk and does not take the NoInlining path per op. Invariant _lastZeroedSize >= Size still holds.

On the payload-benchmark ask: agreed this is block-processing hot path; will follow up with expb numbers (or gate on CI) before merge. The chunking change is the cheap insurance in the meantime.

Comment on lines +81 to +86
// No cache key (e.g. eth_call overrides) — building would only be discarded.
if (CodeHash == default)
{
Volatile.Write(ref _streamBuildState, StreamBuildUnavailable);
return null;
}

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 — latching StreamBuildUnavailable here permanently poisons the instance, and CodeHash is a public setter assigned after construction.

CodeHash is public ValueHash256 CodeHash { get; set; } and is filled in by StaticCodeCache.Set (StaticCodeCache.cs:21), i.e. after new CodeInfo(code). Today every cacheable path happens to Set before the instance is ever executed (CacheCodeInfoRepository.GetOrCacheCodeInfo, InsertCode, SetDelegation), so this is latent rather than live. But the moment any path executes a CodeInfo once before publishing it to the code cache — or publishes it to a cache implementation that doesn't assign CodeHash — that bytecode loses the stream interpreter for the entire lifetime of the cached instance, silently and with no way to recover. That's a hard-to-diagnose performance cliff on a hot contract.

The Volatile.Write buys essentially nothing: it saves one ValueHash256 comparison on a path that has already taken a Volatile.Read and is about to take a cache lookup. Making the skip stateless removes the failure mode entirely:

// No cache key (e.g. eth_call overrides) — building would only be discarded.
if (CodeHash == default)
    return null;

If you do want to keep the latch, CodeHash should become set-once/immutable so the ordering is enforced rather than assumed.

Fix this →

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: skip is now stateless (if (CodeHash == default) return null;) — no latch of StreamBuildUnavailable, so a late CodeHash assignment can still enable stream builds.

using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;

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 — using System.Text.Json.Nodes; is now unused and will fail the lint job.

JsonObject / JsonNode no longer appear anywhere in this file after DeriveTxType was rewritten. .github/workflows/code-lint.yml flags unused using directives, so CI will go red on this. Please drop the line.

Suggested change
using System.Text.Json.Nodes;

}

reader.Read();
reader.Skip();

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 — Utf8JsonReader.Skip() throws InvalidOperationException on a non-final-block reader; TrySkip() doesn't.

Per the BCL contract, Skip() throws "The reader was given partial data with more data to follow (that is, IsFinalBlock is false)". The previous JsonSerializer.Deserialize<JsonObject>(ref txTypeReader, options) had no such constraint.

Today all reachable call sites are safe — the JSON-RPC pipeline builds its reader with isFinalBlock: true (JsonRpcProcessor.cs:449) and batch items go through JsonDocument.Parse — and I couldn't find any IJsonSerializer.Deserialize<T>(Stream) call whose T transitively contains a TransactionForRpc. So this is a latent trap rather than a live bug, but the converter is a public extension point and the failure would surface as a 500 on eth_call, not a JSON parse error.

TrySkip() costs nothing here and removes the constraint:

Suggested change
reader.Skip();
if (!reader.TrySkip()) break;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: reader.TrySkip() instead of Skip().

{
reader.Read();
setType = JsonSerializer.Deserialize<TxType?>(ref reader, options);
continue;

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 (perf, free win) — once setType is non-null the answer is fully determined, so the scan can stop here.

setType is not null short-circuits ahead of both hasGasPrice and viaDiscriminator, so nothing read after this point can change the result. Continuing means the reader still has to scan to the closing quote of input — which on the simulation-style payloads this PR is optimising for is hundreds of kilobytes, and type is conventionally the first property.

Suggested change
continue;
if (setType is not null) break;
continue;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: break the property scan once setType is non-null.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

EXPB Benchmark Comparison

Run: View workflow run

superblocks

No metrics were produced for nethermind-flat-superblocks-perf-eth-call-memory-and-rpc-parse-delay0s.

realblocks

No metrics were produced for nethermind-flat-realblocks-perf-eth-call-memory-and-rpc-parse-delay0s.

fusaka

No metrics were produced for nethermind-flat-fusaka-perf-eth-call-memory-and-rpc-parse-delay0s.

{
if (reader.HasValueSequence || reader.ValueIsEscaped)
{
return string.Equals(reader.GetString(), Encoding.UTF8.GetString(lowerCaseName), StringComparison.OrdinalIgnoreCase);

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 escaped-name fallback allocates two strings per comparison, and it is called once per (property × discriminator) pair.

Encoding.UTF8.GetString(lowerCaseName) re-materialises a compile-time-constant literal on every call, and reader.GetString() allocates the unescaped name again for each of the ~7 discriminators plus type/gasprice. An RPC caller can trigger this path deliberately by escaping every property name ("type", "input", …), turning one request into O(properties × discriminators) string allocations.

reader.ValueTextEquals(utf8) handles both escaping and HasValueSequence allocation-free, but is ordinal — so the cleanest shape is to try the ordinal UTF-8 compare first and only fall back for genuinely mixed-case escaped names, or to unescape once per property (into a stack buffer) before entering the discriminator loop rather than once per comparison.

Also worth noting for future maintainers: the | 0x20 fold is only sound because every current discriminator name is pure ASCII letters. Since RegisterTransactionType<T> is an extension point (Optimism registers its own tx type), the invariant is worth stating in the doc comment — b | 0x20 == target has a second pre-image for non-letter targets.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: hot path stays allocation-free; escaped path tries ValueTextEquals first, then one GetString with ASCII fold (no per-call Encoding.UTF8.GetString of the literal). Noted ASCII-letters invariant on the fold.

public Type Type { get; set; }
public FromTransactionFunc FromTransactionFunc { get; set; }
public string[] DiscriminatorProperties { get; set; } = [];
public byte[][] DiscriminatorPropertiesUtf8 { 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 — DiscriminatorProperties is now dead; per AGENTS.md ("If a change makes existing code unused, remove it") it should go.

After the rewrite, DiscriminatorProperties is only ever assigned — nothing reads it (I checked the whole repo; the only other hits are the assignment at line 163 and the <see cref> in the doc comment that the follow-up commit removed). Drop the string[] property and build the UTF-8 array straight from uniqueProperties in RegisterTransactionType.

Separately: Math.Min(_txTypes.Count, 64) at line 230 silently stops matching discriminators for any type registered at index ≥ 64. With 5 built-in types plus plugin registrations that's not reachable today, but the truncation is invisible. A Debug.Assert(_txTypes.Count <= 64) in RegisterTransactionType would make the bitmask's capacity an enforced invariant rather than a silent cap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: removed dead DiscriminatorProperties; build UTF-8 discriminators directly. Added Debug.Assert(_txTypes.Count < 64) on registration.

Comment on lines +211 to +223
[Test]
public void GetOrBuildStream_without_code_hash_is_unavailable()
{
byte[] code = Enumerable.Repeat((byte)Instruction.JUMPDEST, StreamInterpreter.BuildThreshold + 8).ToArray();
CodeInfo codeInfo = new(code);

for (int i = 0; i < StreamInterpreter.BuildThreshold + 2; i++)
{
Assert.That(codeInfo.GetOrBuildStream(), Is.Null);
}

Assert.That(codeInfo.GetOrBuildStream(), Is.Null);
}

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 — this test passes unchanged on master, so it doesn't guard the optimisation it's named after.

On master, GetOrBuildStream() with a default CodeHash also returns null on every call: the cache lookup is skipped by the CodeHash != default guard, the first BuildThreshold - 1 calls return null on the hit counter, and the call that crosses the threshold schedules a background build that BuildStream then refuses to publish (CodeHash != default fails) — returning null too. So every assertion here holds both before and after the change.

To actually pin the behaviour, assert the observable difference: that no build was scheduled. E.g. expose/observe _streamBuildState transitioning to StreamBuildUnavailable on the very first call, or assert InstructionStreamCache gained no entry and no work item ran (the sibling InstructionStreamTests.cs:490 already uses a SpinUntil on GetOrBuildStream and could be inverted here).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: test now uses BuildThreshold = 1, asserts no stream appears for default CodeHash within a short spin, and a hashed control does build.

TraceMemory.Slice bounds-checks against the backing array, so hand it
only [0, Size). Amortize deferred clears by zeroing to a 4 KiB boundary
so sequential MSTORE growth does not take RentSlow per word. Regression
for JS-tracer style slice-past-Size on a dirty reused buffer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude please re-review — addressed both findings in f58ff48:

  1. GetTrace leak: clamp backing memory to Size + regression test
  2. RentSlow per expansion: zero-extend to 4 KiB chunk boundary

Ready for another pass.

@claude

claude Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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


Re-review: f58ff48 (GetTrace clamp + 4 KiB chunk zeroing)

  • Read f58ff48 diff and current EvmPooledMemory.cs in full
  • Verify the GetTrace clamp closes the TraceMemory stale-tail hole
  • Verify the 4 KiB chunked zero-extension preserves _lastZeroedSize >= Size
  • Check the new regression test actually discriminates the fix
  • Re-check the still-open findings (Netcore #3Lazynodes #9)
  • Post verdict

Verdict

Both product fixes are correct. The GetTrace clamp closes the leak, and the 4 KiB chunking is sound — I re-derived every _lastZeroedSize invariant against the new over-zeroing and they all hold. The expb run posted above also settles the #2 benchmark ask in your favour.

The regression test for #1 is vacuous — it passes on the unfixed code, so #1 currently has a fix with no guard. Plus the four unaddressed findings from the first pass.

1 Medium (new), 1 Medium + 6 Low carried over.


#1GetTrace stale-tail leak: fixed

if (_memory is null || size == 0) return new(size, default);
int visible = (int)Math.Min(size, (ulong)_memory.Length);
return new(size, _memory.AsMemory(0, visible));

ClearForTracing(size) zeroes exactly [_lastZeroedSize, min(size, _memory.Length)), so the whole visible window is either EVM-written or freshly zeroed. TraceMemory.Slice now bounds-checks against visible, so a JS tracer's log.memory.slice(0, 512) at Size == 32 takes the zero-pad branch instead of reading the previous frame's bytes. I re-walked the other three consumers — ToHexWordList (sizeAvailable > 0 else-branch), ToRawWordBytes (min(Size, _memory.Length)), GetUint (clamped to Size) — all correct under the narrower view, including the Size > _memory.Length case that CalculateMemoryCost(rentIfNeeded: false) can produce.

#2RentSlow per expansion: amortized, and the data is in

target = min(memory.Length, roundUp(Size, 4 KiB)) cuts RentSlow from once per 32-byte MSTORE to once per 4 KiB of growth — a 128× reduction on the sequential-ABI-encoding case I was worried about. Invariant checks I re-did against the over-zeroing (_lastZeroedSize can now exceed Size, which it never could before):

  • target >= _lastZeroedSize in the clear branch: guarded by Size > _lastZeroedSize, and roundUp(Size) >= Size. ✔
  • Array.Copy(memory, 0, grown, 0, (int)_lastZeroedSize) in the grow branch: the grow path requires Size > memory.Length, and _lastZeroedSize <= memory.Length always (both RentSlow and ClearForTracing cap it), so _lastZeroedSize <= memory.Length < Size <= grown.Length. ✔ It also still copies all live data, since _lastZeroedSize >= oldSize.
  • Zeroing [Size, target) can't destroy anything: writes only ever land in [0, Size) within a buffer's lifetime. ✔
  • Worst case is never worse than master: target <= memory.Length, and master cleared the full array on every rent. ✔

And the benchmark ask is satisfied — the expb comparison posted at 12:55 ran on fdc7da2, i.e. the un-chunked version, which is the worst case for this concern: realblocks −0.62% AVG, superblocks −2.13%, fusaka −1.18%, no payload set regressing. Chunking can only improve on that. Consider #2 closed on data, not just on reasoning.


Medium (new)

10. The GetTrace regression test passes on the unfixed codeEvmPooledMemoryTests.cs:161

Vacuous for two independent reasons:

  1. The dirty buffer is never rented. CalculateMemoryCostComputeMemoryExpansionCostUpdateSize(newSize, rentIfNeeded: false) (EvmPooledMemory.cs:391). Size becomes 32 but _memory is still null at GetTrace(). The assertion is satisfied by the new _memory is null early return — and was equally satisfied before the fix, since new(size, _memory) on a null array yields an empty ReadOnlyMemory and Slice zero-pads anyway.
  2. 1 KiB can't hold a dirty byte under the new chunking. dirtySize == MinRentSize == 1024, and RentSlow now zeroes min(1024, 4096) = 1024 — the whole buffer. So even with a real rent there is nothing dirty to observe.

Fix: dirty ≥ 32 KiB and force a real rent (TrySaveWord), then slice 8 KiB past Size. Full suggestion in the inline comment. I could not run dotnet test in this job (not in the allowlist), so please confirm the amended test fails with the clamp reverted.

Carried over, unaddressed by f58ff48

f58ff48 touched only EvmPooledMemory.cs + its test, so these all still stand as originally filed:

# Sev Finding
3 Medium CodeInfo permanently latches StreamBuildUnavailable on a default CodeHashCodeHash is a public setter assigned after construction by StaticCodeCache.Set. Latent today; a one-line return null removes the failure mode
4 Low (CI) using System.Text.Json.Nodes; is unused — still on line 11 of TransactionForRpc.cs. Confirmed this reds CI: .editorconfig:97 sets IDE0005.severity = warning and code-lint.yml fails on grep -qP "warning (IDE|CA)\d+". The Code Lint run hasn't reported yet on this head
5 Low reader.Skip() throws on non-final-block readersTrySkip() is free
6 Low Free win: break once setType is found — avoids scanning the 100s-of-KB input this PR targets
7 Low Escaped-name fallback allocates 2 strings per (property × discriminator)
8 Low DiscriminatorProperties is dead code (AGENTS.md: remove it); Math.Min(_txTypes.Count, 64) is a silent cap
9 Low GetOrBuildStream_without_code_hash_is_unavailable passes on master

Informational

The clamp narrows TraceMemory._memory.Length from the array length to Size, which shifts Slice's MemoryPadLimit (1 MiB) reference point. A tracer slicing more than 1 MiB past Size now throws InvalidOperationException where it previously would have padded, but only in the window _memory.Length - Size, which is bounded by max(MinRentSize, ~Size). Not reachable in any realistic tracer, and arguably the more correct semantics (memory past the high-water is conceptually zero, and the pad limit exists to bound the allocation). No action needed — noting it so it isn't a surprise later.


Merge recommendation

Not blocked on correctness any more. Before merge:

#5#9 are optional; #6 is free performance in exactly the scenario this PR targets.
· branch perf/eth-call-memory-and-rpc-parse

Comment on lines +161 to +176
const int dirtySize = 1024;
EvmPooledMemory dirty = new();
Span<byte> pattern = new byte[dirtySize];
pattern.Fill(0xff);
Assert.That(dirty.TrySave(UInt256.Zero, pattern), Is.True);
dirty.Dispose();

EvmPooledMemory memory = new();
try
{
memory.CalculateMemoryCost(0, 32, out bool outOfGas);
Assert.That(outOfGas, Is.False);

TraceMemory trace = memory.GetTrace();
Assert.That(trace.Size, Is.EqualTo(32UL));
Assert.That(trace.Slice(0, 512).ToArray(), Is.EqualTo(new byte[512]));

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 — this regression test passes on the unfixed code, so it does not guard the GetTrace clamp. Two independent reasons, either one alone is enough to make it vacuous:

1. The dirty buffer is never rented. CalculateMemoryCost(0, 32, …)ComputeMemoryExpansionCostUpdateSize(newSize, rentIfNeeded: false) (EvmPooledMemory.cs:391). So Size becomes 32 but _memory is still null when GetTrace() runs. The assertion is satisfied by the brand-new _memory is null early return — and it was equally satisfied before the fix, because new(size, _memory) with a null array produced an empty ReadOnlyMemory<byte> and Slice took the zero-pad path anyway.

2. Even with a real rent, 1 KiB can't hold a dirty byte. dirtySize == MinRentSize == 1024, and the new 4 KiB chunk rounding in RentSlow zeroes min(memory.Length, roundUp(32, 4096)) = min(1024, 4096) = 1024 — the entire buffer. So no offset in that array is dirty, clamp or not.

To make it discriminate, force an actual rent and use a buffer bigger than the 4 KiB zero chunk:

Suggested change
const int dirtySize = 1024;
EvmPooledMemory dirty = new();
Span<byte> pattern = new byte[dirtySize];
pattern.Fill(0xff);
Assert.That(dirty.TrySave(UInt256.Zero, pattern), Is.True);
dirty.Dispose();
EvmPooledMemory memory = new();
try
{
memory.CalculateMemoryCost(0, 32, out bool outOfGas);
Assert.That(outOfGas, Is.False);
TraceMemory trace = memory.GetTrace();
Assert.That(trace.Size, Is.EqualTo(32UL));
Assert.That(trace.Slice(0, 512).ToArray(), Is.EqualTo(new byte[512]));
// Must exceed the 4 KiB RentSlow zero chunk, otherwise the whole buffer is zeroed anyway.
const int dirtySize = 32 * 1024;
EvmPooledMemory dirty = new();
Span<byte> pattern = new byte[dirtySize];
pattern.Fill(0xff);
Assert.That(dirty.TrySave(UInt256.Zero, pattern), Is.True);
dirty.Dispose();
EvmPooledMemory memory = new();
try
{
// TrySaveWord rents (unlike CalculateMemoryCost), so the dirty buffer is reused with Size = 32.
Assert.That(memory.TrySaveWord(UInt256.Zero, new byte[EvmPooledMemory.WordSize]), Is.True);
TraceMemory trace = memory.GetTrace();
Assert.That(trace.Size, Is.EqualTo((ulong)EvmPooledMemory.WordSize));
Assert.That(trace.Slice(0, 8 * 1024).ToArray(), Is.EqualTo(new byte[8 * 1024]), "trace leaked dirty tail bytes past Size");
}

Rent scans the thread-local cache from the top down and takes the first entry with Length >= minLength, and the dirtied 32 KiB array is the most recently returned — so it is deterministically the one picked, same assumption the sibling Pooled_buffer_is_zeroed_on_reuse already relies on. I verified this by reading; I couldn't run dotnet test in this job (command not in the allowlist), so please confirm the amended test fails with the GetTrace clamp reverted before merging.

Fix this →

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: test now dirties 32 KiB (past the 4 KiB zero chunk), forces a real rent via TrySaveWord, and asserts Slice(0, 8 KiB) is zeros. Confirmed locally with EvmPooledMemoryTests green.

@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude re-review

@claude

claude Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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


Re-review: head a05ef7c (adds 54e9009 + master merge since last pass)


Verdict

Since the last pass only 54e9009 (drop the unused using) and a master merge landed. #4 is fixed. No new defects. I re-derived the EvmPooledMemory invariants against the current file rather than trusting the previous pass, and re-walked the remaining findings from source — one of them (#3) I'm downgrading to Low after confirming there is no live path.

0 Critical, 0 High, 1 Medium, 5 Low. The only thing I'd still hold merge on is #10: correctness fix #1 currently ships without a test that can fail.

# Sev Status
1 High ✅ fixed in f58ff48 (GetTrace clamp)
2 Medium ✅ fixed in f58ff48 + settled by the expb run
4 Low (CI) ✅ fixed in 54e9009
10 Medium ❌ open — the regression test for #1 passes on the unfixed code
3 Low (was Medium) ❌ open — downgraded, see below
5, 6, 7, 8, 9 Low ❌ open

Medium — the only item I'd block on

#10EvmPooledMemoryTests.cs:161 GetTrace_slice_past_size_does_not_leak_dirty_bytes is vacuous

I re-traced it against the current file and both reasons still hold exactly:

  1. CalculateMemoryCost(0, 32, …)ComputeMemoryExpansionCostUpdateSize(newSize, rentIfNeeded: false) (EvmPooledMemory.cs:391). Size becomes 32, _memory stays null. The assertion is satisfied by the new _memory is null || size == 0 early return at line 401 — and was equally satisfied before the clamp, because new(size, (byte[])null) is an empty ReadOnlyMemory<byte> and TraceMemory.Slice zero-pads.
  2. dirtySize == 1024; RentSlow zeroes min(memory.Length, roundUp(32, 4096)) = min(1024, 4096) = 1024 — the whole buffer. Nothing dirty is reachable at any offset.

I hand-simulated the fix I suggested inline (32 KiB dirty + TrySaveWord to force a real rent + Slice(0, 8192)) against the current code and it does discriminate: _lastZeroedSize lands at 4096, [4096, 32768) stays 0xff, and without the clamp Slice(0, 8192) returns those bytes. AGENTS.md ("When fixing a bug, always add a regression test") is the reason this matters — otherwise a future refactor of GetTrace silently reopens the leak. dotnet is not in this job's allowlist, so please confirm the amended test fails with the clamp reverted.

Low — #3 downgraded from Medium

CodeInfo.cs:82 latches StreamBuildUnavailable on a default CodeHash — I went looking for a live victim this time and there isn't one:

  • Only two ICodeCache impls exist (StaticCodeCache, NoopCodeCache). StaticCodeCache.Set assigns CodeHash (StaticCodeCache.cs:21) before publishing, and all three Set call sites (CacheCodeInfoRepository lines 37/60/68) publish before the instance is reachable for execution.
  • The CodeInfos that do execute with a default CodeHashStateOverridesExtensions.cs:117 (SetCodeOverride), OverridableCodeInfoRepository.cs:51 (MovePrecompile), VirtualMachine.warmup.cs:47 — are all per-request instances, so a per-instance latch is discarded with them. That's exactly the case this PR is optimising.
  • The NoopCodeCache (stateless/witness) path never built a stream on master either: BuildStream refuses to publish when CodeHash == default, so behaviour is unchanged.

So this is latent hardening, not a defect: the risk is only that CodeHash's public setter makes "published before first execution" an assumed rather than enforced ordering. The Volatile.Write buys one ValueHash256 compare on an already-slow path; return null without the write removes the failure mode. Your call.

Low — unchanged, all still open

# Finding
5 reader.Skip() at line 244 throws on non-final-block readers; TrySkip() is free. All current call sites are isFinalBlock: true, so latent
6 break once setType is found (line 220 still continue) — free win, and it avoids scanning the 64 KiB input your own new test case at TransactionForRpcDeserializationTests.cs:82 exercises
7 Escaped-name fallback re-materialises both strings per (property × discriminator). I re-checked the | 0x20 fold against every registered discriminator (AccessList, MaxFeePerGas, MaxPriorityFeePerGas, BlobVersionedHashes, AuthorizationList) — all pure ASCII letters, so sound today. It silently breaks for any name containing _ (0x5F | 0x20 == 0x7F), and RegisterTransactionType<T> is a plugin extension point, so the constraint is worth stating in the doc comment
8 DiscriminatorProperties is dead now that DiscriminatorPropertiesUtf8 exists — still assigned at line 162, read nowhere. AGENTS.md: remove it. Also Math.Min(_txTypes.Count, 64) (line 229) is a silent cap; a Debug.Assert in RegisterTransactionType would make the bitmask capacity an enforced invariant
9 GetOrBuildStream_without_code_hash_is_unavailable passes on master — re-traced: on master the CodeHash != default guard skips the cache lookup, the first BuildThreshold - 1 calls return null on the hit counter, and the threshold-crossing call schedules a build that BuildStream then refuses to publish. Every assertion holds both before and after

Re-verified from source this pass

Not taking the previous pass on trust, since the merge commit could have shifted things:

  • _lastZeroedSize can now exceed Size (chunk rounding) — a state that was previously impossible. Every consumer survives it: EnsureRented (Size > _lastZeroedSize) just no-ops more often; ClearForTracing (line 272) is guarded by size > _lastZeroedSize and caps at memory.Length; the grow branch's Array.Copy(memory, 0, grown, 0, (int)_lastZeroedSize) is in bounds because the grow path requires Size > memory.Length and _lastZeroedSize <= memory.Length always.
  • The grow branch still copies all live data. Copying only _lastZeroedSize bytes is safe because every write path goes through EnsureRented, which zero-extends to the full Size — so _lastZeroedSize is always ≥ the written high-water mark.
  • MCOPY — the one op whose source can sit above the destination, and CopyAfterGas only calls PrepareAccessAfterGas(dest + len). Re-checked EvmInstructions.Storage.cs:303: UpdateMemoryCost(ref gas, UInt256.Max(b, a), c, …) raises Size to cover max(src,dst)+len, and EnsureRented zeroes to Size (not to the accessed length), so the source read is inside the zeroed prefix. Same for the TTracingInst.IsActive branch's LoadSpanAfterGas(in b, length).
  • No new span-invalidation window. The new Size > _lastZeroedSize clause reaches RentSlow but never reallocates (reallocation still requires Size > memory.Length), so spans held across a subsequent EnsureRented — e.g. source in the MCOPY tracing branch — stay valid.
  • Return no longer clears, and large buffers now come back dirty from the shared ArrayPool. EVM-visible reads are still covered by RentSlow's [0, Size) zeroing, and the trace path is clamped, so no cross-transaction/cross-thread bytes are observable. Inspect remains guarded (largeSize > _memory.Lengthdefault, then ClearForTracing).
  • Size == 0 rent leaves a fully dirty 1 KiB buffer with _lastZeroedSize == 0; every read at that point is a zero-length span, and the next expansion clears from 0. Safe.
  • PromoteAccount is byte-for-byte equivalent: _changedAccounts is keyed by HashedKey<Address> (line 31), SetAccount relies on the same implicit conversion, ContainsKey is lock-free, TryAdd still guards the race, and hoisting the key reuses the cached hash across both lookups.
  • DeriveTxType is order-equivalent to the old FirstOrDefault chain. _txTypes is built in descending TxType order (RegisterTransactionType, lines 175–183), so TrailingZeroCount picks the same winner. typegasPrice → discriminator precedence preserved; GasPrice carries no [JsonDiscriminator] (only AccessList, MaxFeePerGas, MaxPriorityFeePerGas, BlobVersionedHashes, AuthorizationList do), so skipping the discriminator scan in the gasPrice branch is equivalent. As a bonus it's strictly more robust than the DOM version on a null transaction body, where the old untyped.TryGetPropertyValue would have NRE'd.

Merge recommendation

Not blocked on correctness. Before merge: #10 (amend the test so the GetTrace fix has a guard that can fail). #3 is now optional hardening. #6 is free performance in exactly the scenario this PR targets.

--- · branch perf/eth-call-memory-and-rpc-parse

- CodeInfo: do not latch StreamBuildUnavailable on default CodeHash
- GetTrace regression: force rent + 32 KiB dirty buffer past zero chunk
- Tx parse: TrySkip, early exit on explicit type, drop dead
  DiscriminatorProperties, cheaper escaped-name compare, assert <64 types
- CodeInfo test: assert no stream is scheduled without a hash

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude please re-review — remaining medium/low findings addressed:

Medium

  1. CodeInfo: no latch on default CodeHash (stateless skip)
  2. GetTrace test: real rent + 32 KiB dirty + 8 KiB slice past Size

Low
3. TrySkip
4. Early break on explicit type
5. Escaped-name compare without per-call UTF8.GetString of literal
6. Drop dead DiscriminatorProperties + Debug.Assert < 64 types
7. CodeInfo test asserts no scheduled build without hash

Ready for another pass.

@claude

claude Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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


Re-review: head e1c5c1b


Verdict

Five of the seven items are properly fixed, and #10 is the one that mattered — I hand-traced the amended test and it genuinely fails without the clamp now. Two items are not closed the way the commit message claims, both Low.

0 Critical, 0 High, 0 Medium, 2 Low. Nothing blocks merge.

# Sev Status
1, 2, 4 ✅ fixed earlier (f58ff48, 54e9009)
10 Medium fixed — test is no longer vacuous
3 Low fixed — latch removed
5 Low TrySkip()
6 Low ✅ early break
7 Low ✅ mostly — one alloc per compare remains, acceptable
8 Low ⚠️ dead code gone, but the Math.MinDebug.Assert swap regresses Release behaviour
9 Low ❌ still passes on master; the new form also mutates a process-wide static

#10 — the GetTrace test now discriminates

I traced it byte by byte against the current RentSlow:

  1. dirty.TrySave(0, 32 KiB of 0xff)Rent(32768), _lastZeroedSize = 32768, buffer filled 0xff. Dispose()Return pushes it at _cachedArrayCount++ (32 KiB ≤ MaxCachedArrayLength = 64 KiB, so it is cached).
  2. memory.TrySaveWord(0, …)UpdateSize(32)EnsureRentedRentSlowRent(1024). Rent scans top-down and the dirty array is the most recently pushed entry, so it is deterministically the one returned.
  3. _lastZeroedSize = 0, target = min(32768, roundUp(32, 4096)) = 4096. [0, 4096) zeroed; [4096, 32768) stays 0xff.
  4. GetTrace()ClearForTracing(32) no-ops (32 > 4096 false), visible = 32. Slice(0, 8192)8192 > 32 → zero-pad branch → 8192 zeros. ✔

With the clamp reverted, span.Length is 32768, 8192 <= 32768, and the slice returns [4096, 8192) as 0xffassertion fails. The fix now has a guard that can fail. paddingNeeded is 8160, well under the 1 MiB MemoryPadLimit, so no spurious throw.

Minor robustness note (not worth changing): if the 16-slot thread-local cache were already full when dirty disposes, Return drops the array silently and the test would go vacuous again. Not reachable in practice — these tests hold at most one or two live EvmPooledMemory at a time.

#3 — stateless skip, correct

if (CodeHash == default)
    return null;

No Volatile.Write, so a CodeInfo executed before StaticCodeCache.Set assigns its hash can still build a stream later. The StreamBuildUnavailable latch is now written only by BuildStream, where it means "this bytecode genuinely can't be streamed" — which is the right semantics for a permanent latch.

#5, #6, #7

  • if (!reader.TrySkip()) break; — no more InvalidOperationException on a non-final-block reader.
  • if (setType is not null) break; — I re-confirmed Read() scans a copy (Utf8JsonReader txTypeReader = reader;, line 193) and hands the untouched reader to Deserialize, so abandoning the scan mid-object is safe. setType short-circuits ahead of both hasGasPrice and viaDiscriminator in the resolution block, so the outcome is unchanged — including the Unknown transaction type throw.
  • NameEqualsIgnoreCase — the hot path is unchanged and allocation-free; the escaped path now tries ValueTextEquals (alloc-free, ordinal) before falling back. The unescaped.Length != lowerCaseName.Length UTF-16-vs-UTF-8 length compare is safe because the c > 0x7f check rejects everything non-ASCII anyway. One GetString() per (escaped property × discriminator) remains in the adversarial case, but that's half the old cost and the fold invariant is now documented. Good enough.

Low — the two that aren't closed

#8b — Math.Min(_txTypes.Count, 64)Debug.Assert regresses Release behaviour (TransactionForRpc.cs:176)

Removing the dead DiscriminatorProperties is right. The bound placement is right too — the assert runs pre-insert, so < 64 before ⇒ <= 64 after ⇒ max index 63, and it's correctly only on the add path.

But Debug.Assert is compiled out of Release while the loop now runs to _txTypes.Count unconditionally. A 65th type would do 1UL << 64, which C# masks to a shift of 0 → sets bit 0TrailingZeroCount resolves to _txTypes[0] (SetCodeTransactionForRpc). That's a silent wrong-type deserialization where the old code silently ignored the type. Registration is startup-only, so if (_txTypes.Count >= 64) throw costs nothing and makes it loud.

#9the CodeInfo test still passes on master (CodeInfoTests.cs:212)

Re-traced against origin/master's GetOrBuildStream with BuildThreshold = 1: the first call skips the cache (CodeHash != default is false), increments past the threshold, schedules a StreamBuilder, returns null; BuildStream then refuses to Set and latches Unavailable; every later call returns null. So all three assertions hold on the unfixed code. The difference the name promises — no work item scheduled — is _streamBuildState staying Idle, and nothing reads it.

Separately, StreamInterpreter.BuildThreshold is a public static int (VirtualMachine.Stream.cs:26). Nethermind.Evm.Test has [Parallelizable(ParallelScope.All)] fixtures, and NUnit drains the non-parallel queue on the main thread while parallel workers run — so for the duration of this test other EVM tests see a threshold of 1 and take the stream path after one execution. The hashed half also permanently publishes keccak(32× JUMPDEST) into the global InstructionStreamCache. The mutation doesn't even buy anything: the original loop-past-the-default-threshold form had identical discriminating power without touching global state.


Also re-verified this pass

  • System.Diagnostics added for Debug.Assert; System.Text/System.Linq still genuinely used (Encoding.UTF8.GetBytes at line 163, .Where/.Select at 157). No new unused using.
  • EvmPooledMemory invariants unchanged by e1c5c1b — it only touched the test.
  • Read()'s reader-copy discipline, _txTypes descending-order/TrailingZeroCount equivalence, and SnapshotBundle.PromoteAccount are as previously verified; nothing in this commit disturbs them.

Not run: dotnet is not in this job's allowlist, so I could not compile or execute the tests — everything above is from reading. StreamInterpreter.BuildThreshold is a mutable static field (not const), and EvmPooledMemory.WordSize / CodeInfo.CodeHash are public, so the new test code should compile; please confirm the suite is green locally.

Merge recommendation

Not blocked. Both remaining items are Low and neither affects production behaviour today. #8b is a two-token change worth taking; #9 is a test-hygiene item you can defer.
· branch perf/eth-call-memory-and-rpc-parse

{
// Discriminator bitset in DeriveTxType is ulong — keep registration count within it.
Debug.Assert(_txTypes.Count < 64);

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 — swapping Math.Min(_txTypes.Count, 64) for a Debug.Assert turns a silent cap into a silent mis-selection in Release.

The bound placement itself is right: the assert runs before the insert, so < 64 before ⇒ <= 64 after ⇒ max index 63, which 1UL << 63 handles. And it's correctly only on the add path (the replace path doesn't grow the list).

But Debug.Assert is compiled out of Release, and the loop in DeriveTxType now runs to _txTypes.Count unconditionally. With a 65th registered type, 1UL << 64 masks the shift count to 0 in C# and sets bit 0 — so a discriminator belonging to _txTypes[64] would resolve to _txTypes[0] (currently SetCodeTransactionForRpc, the highest-priority entry, and TrailingZeroCount makes bit 0 win outright). Previously that type was simply never matched and the request fell through to the gasPrice/EIP-1559 default.

Not reachable today (5 built-ins + a couple of plugin types), and RegisterTransactionType is startup-only — which is exactly why a hard guard is free here:

Suggested change
// Discriminator bitset in DeriveTxType is ulong — keep registration count within it.
if (_txTypes.Count >= 64) throw new InvalidOperationException($"At most 64 transaction types can be registered.");

That converts an invisible wrong-type deserialization into a loud startup failure.

}

[Test]
public void GetOrBuildStream_without_code_hash_never_schedules_build()

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 — this still passes with the CodeHash == default early return removed, and it now mutates a process-wide static to do so.

I re-traced it against origin/master's GetOrBuildStream. With BuildThreshold = 1 and no early return:

  1. unhashed.GetOrBuildStream()CodeHash != default is false so the cache lookup is skipped; Interlocked.Increment → 1; 1 < 1 is false; the CAS schedules a StreamBuilder; returns null.
  2. BuildStream() runs, CodeHash != default fails, so it takes the else branch and never calls InstructionStreamCache.Set.
  3. Every later call returns null — either from the StreamBuildUnavailable latch or from the miss.

So SpinUntil(… is not null) is still false and all three assertions hold on the unfixed code. The observable difference the test name promises — that no work item is scheduled — is _streamBuildState staying StreamBuildIdle, and nothing here reads it. The hashed control is a nice addition, but it only proves the harness works, not that the skip happened.

Second, independent problem: StreamInterpreter.BuildThreshold is public static int (VirtualMachine.Stream.cs:26) — process-wide, not per-fixture. Nethermind.Evm.Test has parallelizable fixtures (CodeInfoRepositoryTests, Eip7708Tests with ParallelScope.All, KzgPointEvaluationPrecompileTests, …), and NUnit drains the non-parallel queue on the main thread while parallel workers are still running. So for the duration of this test any concurrently-running EVM test sees a threshold of 1 and starts taking the stream-interpreter path after a single execution — nondeterministic coverage, and a plausible source of "passes locally, flakes in CI". The hashed half also permanently publishes keccak(32× JUMPDEST) into the global InstructionStreamCache.

Note that the mutation buys nothing even for the current assertions: the original version of this test simply called GetOrBuildStream() more than BuildThreshold times, which is threshold-agnostic.

Suggestion: either revert to the loop-past-the-default-threshold form (same discriminating power, no global mutation), or make it actually discriminate — e.g. [InternalsVisibleTo] is already in place for this assembly, so exposing _streamBuildState (or a internal bool StreamBuildScheduled probe) lets you assert Idle after N calls, which fails on master and passes here.

Fix this →

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

EVM Opcode Benchmark Diff

Aggregated runs: base=3, pr=3
Noisy rerun opcodes: REVERT

No significant regressions or improvements detected.

Comment thread src/Nethermind/Nethermind.Evm/CodeAnalysis/CodeInfo.cs Outdated
Comment thread src/Nethermind/Nethermind.Evm/EvmPooledMemory.cs
kamilchodola and others added 2 commits August 6, 2026 17:37
Co-authored-by: Lukasz Rozmej <lukasz.rozmej@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@kamilchodola

Copy link
Copy Markdown
Contributor Author

CI failures on 1f3fc9d3 look like a GitHub Actions outage, not product regressions:

  • Logs show Failed to resolve action download info. Error: Service Unavailable across build/test/spellcheck jobs
  • Code Lint, Code formatting, Build (release/debug, Nethermind) already passed
  • Nethermind.Evm.Test (ubuntu-latest) and multi-OS JsonRpc.Test / State.Flat.Test also passed where runners got past setup

Re-ran all failed workflow runs with gh run rerun --failed.

@kamilchodola
kamilchodola marked this pull request as draft August 7, 2026 06:44
@kamilchodola
kamilchodola marked this pull request as ready for review August 7, 2026 06:44
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 6m 38s —— View job


Re-review: head c711e6d (adds 67712f4, 1f3fc9d, master merge)

  • Diff current head against origin/master
  • Verify the new EmptyTraceMemory change (1f3fc9d)
  • Verify the CodeInfo edit in 67712f4
  • Re-check remaining open findings (#8b, Lazynodes #9)
  • Fresh regression pass over all four touched areas
  • Post verdict

Verdict

Since the last pass only 67712f4 (drop the explanatory comment on the CodeHash == default skip), 1f3fc9d (EmptyTraceMemory), and a master merge landed. Both are behaviour-preserving; no new defects. I re-derived the EvmPooledMemory zeroing invariants against the current file rather than trusting the earlier passes, and hand-traced all five Pooled_buffer_is_zeroed_on_reuse cases through the new Rent/RentSlow.

One new Low in SnapshotBundle that earlier passes missed, plus the two carried Lows.

0 Critical, 0 High, 0 Medium, 3 Low. Nothing blocks merge.

# Sev Status
1, 2, 3, 4, 5, 6, 7, 10 ✅ fixed in f58ff48 / 54e9009 / e1c5c1b
11 Low (new) PromoteAccount pre-check is a guaranteed miss on the Get path
8b Low Debug.Assert(_txTypes.Count < 64) is compiled out of Release
9 Low CodeInfo test passes on master and mutates a process-wide static

1f3fc9dEmptyTraceMemory is equivalent

private static readonly TraceMemory EmptyTraceMemory = new(0, default);

public TraceMemory GetTrace()
{
    ulong size = Size;
    if (size == 0) return EmptyTraceMemory;
    ClearForTracing(size);
    if (_memory is null) return new(size, default);
    int visible = (int)Math.Min(size, (ulong)_memory.Length);
    return new(size, _memory.AsMemory(0, visible));
}
  • The skipped ClearForTracing(0) was already a no-op (0 > _lastZeroedSize is false for all unsigned _lastZeroedSize), so the early return changes nothing.
  • Pre-1f3fc9d the size == 0 path produced new(0, _memory.AsMemory(0, 0)) — an empty ReadOnlyMemory with size 0, identical to EmptyTraceMemory for every consumer (ToHexWordList allocates a zero-length array, ToRawWordBytes short-circuits on Size == 0, Slice(0, n) takes the pad branch either way).
  • Keeping new(size, default) for _memory is null && size > 0 is the right call and matches master's behaviour for the CalculateMemoryCost(rentIfNeeded: false) state — ToHexWordList still emits the zero words rather than an empty array.

Worth noting for the record (your reply to @LukaszRozmej already says this): TraceMemory is a readonly struct, so the old new(...) was never a heap allocation. The static field is a copy from a static base rather than a stack zero-init, so this is neutral, not a win — harmless either way, and GetTrace is off the hot path.

67712f4CodeInfo unchanged in substance

Only the // No cache key … comment was dropped; the stateless if (CodeHash == default) return null; and the absent Volatile.Write are intact. BuildStream remains the only writer of the StreamBuildUnavailable latch, which is the correct semantics for a permanent latch.


Low — new this pass

#11PromoteAccount's ContainsKey pre-check is a guaranteed miss on the Get path (SnapshotBundle.cs:476)

PromoteAccount has two callers with opposite profiles:

  • FlatWorldStateScope.cs:170promote: !isInCurrentSnapshot, where isInCurrentSnapshot came from DoGetAccount's _changedAccounts.TryGetValue(key, …) (line 91) on the same dictionary. So promote is only reached when absence was just proven, and the new probe is dead weight there.
  • PrewarmerScopeProvider.cs:152FlatWorldStateScope.cs:186 — unconditional promote: true. This is where re-promotes of hot accounts skip the bucket lock, so the optimisation does have a real target.

Not a correctness issue (TryAdd still arbitrates the race, and hoisting HashedKey<Address> correctly reuses the cached _hashCode), and the miss costs a lock-free probe on an already-hot cache line. Low.

Low — carried over

#8bDebug.Assert(_txTypes.Count < 64) (line 175) is compiled out of Release while DeriveTxType now loops to _txTypes.Count unconditionally (line 234, no Math.Min). A 65th registered type would do 1UL << 64, which C# masks to a shift of 0 → sets bit 0TrailingZeroCount resolves to _txTypes[0] (SetCodeTransactionForRpc). Silent wrong-type deserialization where the old code silently ignored the type. RegisterTransactionType is startup-only, so if (_txTypes.Count >= 64) throw is free and makes it loud.

#9GetOrBuildStream_without_code_hash_never_schedules_build (CodeInfoTests.cs:211) still passes with the CodeHash == default early return removed (on master the threshold-crossing call schedules a build that BuildStream then refuses to publish, so every assertion holds), and it sets the process-wide StreamInterpreter.BuildThreshold to 1. CodeInfoTests is a plain [TestFixture] — the sibling InstructionStreamTests.cs:207 is explicitly [TestFixture, NonParallelizable], which is the precedent — so for the ~250 ms of the negative SpinUntil any concurrently-running parallelizable fixture (Eip7708Tests, KzgPointEvaluationPrecompileTests, CodeInfoRepositoryTests, …) sees a threshold of 1. The hashed half also permanently publishes keccak(32× JUMPDEST) into the global InstructionStreamCache.


Re-verified from source this pass

Not taken on trust from earlier passes, since the master merge could have shifted things (it brought in only 8274eb9, which touches none of these files):

  • RentSlow chunk-zeroing invariants. target = min(memory.Length, roundUp(Size, 4 KiB)). After the null branch memory.Length >= Size (Rent(max(Size, 1024))), after the grow branch memory.Length >= Size too, and in the no-realloc case Size <= memory.Length by the EnsureRented guard — so target >= Size in all three, preserving _lastZeroedSize >= Size on exit. Array.Clear(memory, (int)_lastZeroedSize, (int)(target - _lastZeroedSize)) is in bounds because _lastZeroedSize <= memory.Length always (both RentSlow and ClearForTracing cap at it).
  • The grow branch's Array.Copy(memory, 0, grown, 0, (int)_lastZeroedSize) copies all live data, since every write path goes through EnsureRented which zero-extends to the full Size — so _lastZeroedSize is always ≥ the written high-water. And it is in bounds: grow requires Size > memory.Length, so _lastZeroedSize <= memory.Length < Size <= grown.Length.
  • Dispose leaves _lastZeroedSize/Size stale, but the memory is null branch of RentSlow resets _lastZeroedSize = 0 before zero-extending, so a recycled VmState can't inherit a bogus zeroed-prefix claim.
  • Large arrays now come back dirty from the pools. ArrayPool can hand back a bucket larger than requested (e.g. 128 KiB for a 100 KiB Size), leaving [Size, memory.Length) holding unrelated process bytes. The two paths that can read above Size are both covered: GetTrace clamps to visible = min(Size, _memory.Length), and Inspect (line 266) calls ClearForTracing(location + length) — which zeroes up to min(largeSize, _memory.Length) — before handing back the slice. Every EVM-visible read (TrySave*, TryLoad*, *AfterGas, CopyAfterGas, Load32BytesAfterGas) is inside [0, Size).
  • MCOPY re-checked (EvmInstructions.Storage.cs:303): UpdateMemoryCost(ref gas, UInt256.Max(b, a), c, …) raises Size to cover max(src,dst)+len before CopyAfterGas, and EnsureRented zeroes to Size (not to the accessed length), so the source read at b sits inside the zeroed prefix even though CopyAfterGas only calls PrepareAccessAfterGas(dest + len). The TTracingInst.IsActive branch's LoadSpanAfterGas(in b, length) is covered by the same reasoning.
  • All five Pooled_buffer_is_zeroed_on_reuse cases hand-traced through Rent/RentSlow: 1024 and 4096 go to the thread-local cache and are fully re-zeroed (target == memory.Length); 32 KiB likewise; 70 KiB crosses MaxCachedArrayLength into SafeArrayPool.Shared (rented dirty, zeroed to roundUp(71680, 4096) == 71680, and only [0, 71680) is asserted); 2 MiB crosses MaxSharedArrayLength into _largeArrayPool. All pass, none regress.
  • GetTrace_slice_past_size_does_not_leak_dirty_bytes still discriminates on the current file: the 32 KiB 0xff array is the most recently pushed cache entry and Rent scans top-down, so Rent(1024) deterministically returns it; _lastZeroedSize lands at 4096, [4096, 32768) stays 0xff, and with the clamp reverted Slice(0, 8192) returns those bytes. paddingNeeded is 8160, well under the 1 MiB MemoryPadLimit.
  • DeriveTxType remains order- and precedence-equivalent. _txTypes is built in descending TxType order (lines 177–186), so TrailingZeroCount(discriminated) picks the same winner as the old FirstOrDefault. typegasPrice → discriminator precedence preserved, including the Unknown transaction type throw. reader.Read() + TrySkip() correctly consumes object/array values; the type branch leaves the reader on the value's last token so the continue re-enters cleanly. Read() still scans a copy (Utf8JsonReader txTypeReader = reader;), so the early break cannot desync the real deserialize.
  • | 0x20 fold re-checked: for an ASCII-letter target the pre-images are exactly {lower, upper}, and every registered discriminator (AccessList, MaxFeePerGas, MaxPriorityFeePerGas, BlobVersionedHashes, AuthorizationList) plus type/gasprice is pure ASCII letters. Non-ASCII bytes can't collide (0x80+ | 0x20 never lands in 0x61–0x7A). The constraint is documented on the method.
  • Usings/lint: System.Diagnostics (Debug.Assert), System.Numerics (BitOperations), System.Text (Encoding.UTF8.GetBytes) are all used; System.Linq is still needed (.Where/.Select at 155–156, FirstOrDefault at 318). System.Text.Json.Nodes is gone. No new unused directive.

Not run: dotnet is not in this job's allowlist, so nothing here was compiled or executed — all of the above is from reading. Your note about the 1f3fc9d CI failures being a GitHub Actions Service Unavailable outage matches the pattern (Code Lint / Code formatting / Build all green, failures clustered on Failed to resolve action download info); worth a glance at the rerun before merging.

Merge recommendation

Not blocked. All Critical/High/Medium findings from the earlier passes are closed. The three remaining items are Low and none affects production behaviour today — #8b is a two-token hardening worth taking, #9 and #11 are deferrable.
· branch perf/eth-call-memory-and-rpc-parse

{
// ContainsKey is lock-free; TryAdd alone would take the bucket lock on every hot re-promote.
HashedKey<Address> key = new(address);
if (!_changedAccounts.ContainsKey(key))

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 (perf) — on the FlatWorldStateScope.Get path this ContainsKey is a guaranteed miss, so it is pure added work there.

PromoteAccount has exactly two live callers, and they have opposite profiles:

  • FlatWorldStateScope.cs:170HintGet(address, account, promote: !isInCurrentSnapshot). isInCurrentSnapshot is set by DoGetAccount (line 91) from _changedAccounts.TryGetValue(key, …) on the same dictionary. So promote is true only when the caller has just proven the key absent, and the new ContainsKey re-lookup will (barring a concurrent insert) always return false — a second hash + probe, then TryAdd takes the bucket lock anyway.
  • PrewarmerScopeProvider.cs:152FlatWorldStateScope.cs:186 (promote: true, unconditional). This is where the win is: repeated hints for the same hot account skip the bucket lock.

So the optimisation is real, but it's targeted at the prewarmer path only, and it taxes the Get path. Since DoGetAccount already computed HashedKey<Address> key and knows the answer, the cheapest shape is to let the caller that knows skip the probe — e.g. an overload that takes the already-hashed key and a knownAbsent flag, or simply keep TryAdd for the Get path and use the pre-check only on the unconditional HintGet.

Not a correctness issue (TryAdd still arbitrates the race), and the miss cost is a lock-free probe on an already-hot line — hence Low. But if the −2.7% was attributed partly to this, it's worth confirming which caller dominates before assuming the win generalises.

Fix this →

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.

5 participants