-
Notifications
You must be signed in to change notification settings - Fork 720
perf(rpc): cut eth_call overhead in memory, code stream, and tx parse #12716
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
8994d58
fdc7da2
f58ff48
54e9009
a05ef7c
e1c5c1b
67712f4
1f3fc9d
c711e6d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -208,6 +208,33 @@ public void PushNJumpdest_Over10k(int n) | |
| } | ||
| } | ||
|
|
||
| [Test] | ||
| public void GetOrBuildStream_without_code_hash_never_schedules_build() | ||
| { | ||
| int thresholdBefore = StreamInterpreter.BuildThreshold; | ||
| StreamInterpreter.BuildThreshold = 1; | ||
| try | ||
| { | ||
| byte[] code = Enumerable.Repeat((byte)Instruction.JUMPDEST, 32).ToArray(); | ||
| CodeInfo unhashed = new(code); | ||
| for (int i = 0; i < 8; i++) | ||
| Assert.That(unhashed.GetOrBuildStream(), Is.Null); | ||
|
|
||
| Assert.That( | ||
| !System.Threading.SpinWait.SpinUntil(() => unhashed.GetOrBuildStream() is not null, TimeSpan.FromMilliseconds(250)), | ||
| "default CodeHash must not publish a stream"); | ||
|
|
||
| CodeInfo hashed = new(code) { CodeHash = Nethermind.Core.Crypto.ValueKeccak.Compute(code) }; | ||
| Assert.That( | ||
| System.Threading.SpinWait.SpinUntil(() => hashed.GetOrBuildStream() is not null, TimeSpan.FromSeconds(5)), | ||
| "hashed CodeInfo should build a stream"); | ||
| } | ||
| finally | ||
| { | ||
| StreamInterpreter.BuildThreshold = thresholdBefore; | ||
| } | ||
| } | ||
|
Comment on lines
+211
to
+236
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Low — this test passes unchanged on On To actually pin the behaviour, assert the observable difference: that no build was scheduled. E.g. expose/observe
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed: test now uses |
||
|
|
||
| [TestCaseSource(nameof(Codes))] | ||
| public void JumpDestinationAnalyzer_are_equivalent(byte[] codeInput) | ||
| { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -393,11 +393,21 @@ private ulong ComputeMemoryExpansionCost(ulong newSize) | |
| return cost; | ||
| } | ||
|
|
||
| private static readonly TraceMemory EmptyTraceMemory = new(0, default); | ||
|
|
||
| public TraceMemory GetTrace() | ||
| { | ||
| ulong size = Size; | ||
| if (size == 0) | ||
| return EmptyTraceMemory; | ||
|
|
||
| ClearForTracing(size); | ||
| return new(size, _memory); | ||
| // Clamp to Size so TraceMemory.Slice past the EVM high-water cannot see dirty tail bytes. | ||
| if (_memory is null) | ||
| return new(size, default); | ||
|
LukaszRozmej marked this conversation as resolved.
|
||
|
|
||
| int visible = (int)Math.Min(size, (ulong)_memory.Length); | ||
| return new(size, _memory.AsMemory(0, visible)); | ||
| } | ||
|
|
||
| public void Dispose() | ||
|
|
@@ -407,7 +417,7 @@ public void Dispose() | |
| if (memory is not null) | ||
| { | ||
| _memory = null; | ||
| ReturnClean(memory, (int)Math.Min(Size, (ulong)memory.Length)); | ||
| Return(memory); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -453,50 +463,48 @@ private void EnsureRented() | |
|
|
||
| private const int MinRentSize = 1_024; | ||
| private const int MaxCachedArrayLength = 1 << 16; | ||
| private const int CleanCacheSlots = 16; | ||
| private const int CacheSlots = 16; | ||
|
|
||
| [ThreadStatic] private static byte[]?[]? _cleanArrays; | ||
| [ThreadStatic] private static int _cleanArrayCount; | ||
| [ThreadStatic] private static byte[]?[]? _cachedArrays; | ||
| [ThreadStatic] private static int _cachedArrayCount; | ||
|
|
||
| private static byte[] RentClean(int minLength) | ||
| // Cached dirty; RentSlow zero-extends past Size in chunks 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) | ||
| { | ||
| _cleanArrayCount = cleanArrayCount; | ||
| cache[i] = cache[cleanArrayCount]; | ||
| cache[cleanArrayCount] = null; | ||
| _cachedArrayCount = cachedArrayCount; | ||
| cache[i] = cache[cachedArrayCount]; | ||
| cache[cachedArrayCount] = null; | ||
| return candidate; | ||
| } | ||
| } | ||
|
|
||
| if (minLength > MaxCachedArrayLength) | ||
| { | ||
| byte[] pooled = RentLarge(minLength); | ||
| Array.Clear(pooled); | ||
| return pooled; | ||
| return RentLarge(minLength); | ||
| } | ||
|
|
||
| return new byte[BitOperations.RoundUpToPowerOf2((uint)minLength)]; | ||
| } | ||
|
|
||
| private static void ReturnClean(byte[] array, int dirtyLength) | ||
| private static void Return(byte[] array) | ||
| { | ||
| if (array.Length > MaxCachedArrayLength) | ||
| { | ||
| ReturnLarge(array); | ||
| return; | ||
| } | ||
|
|
||
| byte[]?[] cache = _cleanArrays ??= new byte[CleanCacheSlots][]; | ||
| if (_cleanArrayCount < CleanCacheSlots) | ||
| byte[]?[] cache = _cachedArrays ??= new byte[CacheSlots][]; | ||
| if (_cachedArrayCount < CacheSlots) | ||
| { | ||
| Array.Clear(array, 0, dirtyLength); | ||
| cache[_cleanArrayCount++] = array; | ||
| cache[_cachedArrayCount++] = array; | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -528,19 +536,29 @@ private static void ReturnLarge(byte[] array) | |
| [MethodImpl(MethodImplOptions.NoInlining)] | ||
| private void RentSlow() | ||
| { | ||
| if (_memory is null) | ||
| byte[]? memory = _memory; | ||
| if (memory is null) | ||
| { | ||
| _memory = RentClean((int)Math.Max((uint)Size, MinRentSize)); | ||
| _memory = memory = Rent((int)Math.Max((uint)Size, MinRentSize)); | ||
| _lastZeroedSize = 0; | ||
| } | ||
| else if (Size > (ulong)_memory.LongLength) | ||
| else if (Size > (ulong)memory.LongLength) | ||
| { | ||
| byte[] beforeResize = _memory; | ||
| _memory = RentClean(TruncateToInt32(Size)); | ||
| Array.Copy(beforeResize, 0, _memory, 0, beforeResize.Length); | ||
| ReturnClean(beforeResize, beforeResize.Length); | ||
| byte[] grown = Rent(TruncateToInt32(Size)); | ||
| Array.Copy(memory, 0, grown, 0, (int)_lastZeroedSize); | ||
| Return(memory); | ||
| _memory = memory = grown; | ||
| } | ||
|
|
||
| _lastZeroedSize = (ulong)_memory.Length; | ||
| ulong size = Size; | ||
| if (size > _lastZeroedSize) | ||
| { | ||
| // Over-zero to a chunk boundary so sequential MSTORE growth does not take RentSlow per 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; | ||
| } | ||
|
Comment on lines
+553
to
+561
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Medium — this moves
Failure scenario: a contract that ABI-encodes sequentially ( 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 ( 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 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;
}
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in f58ff48 (amortization part).
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. |
||
| } | ||
|
|
||
| // (int)(uint)value rather than (int)value: RyuJIT emits noticeably worse codegen for a | ||
|
|
||
There was a problem hiding this comment.
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 == defaultearly return removed, and it now mutates a process-wide static to do so.I re-traced it against
origin/master'sGetOrBuildStream. WithBuildThreshold = 1and no early return:unhashed.GetOrBuildStream()→CodeHash != defaultis false so the cache lookup is skipped;Interlocked.Increment→ 1;1 < 1is false; the CAS schedules aStreamBuilder; returnsnull.BuildStream()runs,CodeHash != defaultfails, so it takes theelsebranch and never callsInstructionStreamCache.Set.null— either from theStreamBuildUnavailablelatch 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_streamBuildStatestayingStreamBuildIdle, and nothing here reads it. Thehashedcontrol is a nice addition, but it only proves the harness works, not that the skip happened.Second, independent problem:
StreamInterpreter.BuildThresholdispublic static int(VirtualMachine.Stream.cs:26) — process-wide, not per-fixture.Nethermind.Evm.Testhas parallelizable fixtures (CodeInfoRepositoryTests,Eip7708TestswithParallelScope.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". Thehashedhalf also permanently publisheskeccak(32× JUMPDEST)into the globalInstructionStreamCache.Note that the mutation buys nothing even for the current assertions: the original version of this test simply called
GetOrBuildStream()more thanBuildThresholdtimes, 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 ainternal bool StreamBuildScheduledprobe) lets you assertIdleafter N calls, which fails onmasterand passes here.Fix this →