-
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 2 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 |
|---|---|---|
|
|
@@ -78,7 +78,13 @@ public CodeInfo(IPrecompile? precompile) | |
| { | ||
| if (Volatile.Read(ref _streamBuildState) == StreamBuildUnavailable) | ||
| return null; | ||
| if (CodeHash != default && InstructionStreamCache.TryGet(CodeHash, out InstructionStream? cached)) | ||
| // No cache key (e.g. eth_call overrides) — building would only be discarded. | ||
| if (CodeHash == default) | ||
| { | ||
| Volatile.Write(ref _streamBuildState, StreamBuildUnavailable); | ||
| return null; | ||
| } | ||
|
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 — latching
The // 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,
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: skip is now stateless ( |
||
| if (InstructionStreamCache.TryGet(CodeHash, out InstructionStream? cached)) | ||
| return cached; | ||
| if (Interlocked.Increment(ref _streamHits) < StreamInterpreter.BuildThreshold) | ||
| return null; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -407,7 +407,7 @@ public void Dispose() | |
| if (memory is not null) | ||
| { | ||
| _memory = null; | ||
| ReturnClean(memory, (int)Math.Min(Size, (ulong)memory.Length)); | ||
| Return(memory); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -453,50 +453,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 [_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) | ||
|
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. High — the "no caller can observe bytes beyond the zeroed prefix" invariant does not hold for
public TraceMemory GetTrace()
{
ulong size = Size;
ClearForTracing(size);
return new(size, _memory); // ReadOnlyMemory over _memory.Length, not size
}
ReadOnlySpan<byte> span = _memory.Span;
if (start + length > span.Length) { /* pad with zeros */ }
return span.Slice(start, length); // <-- raw tailBefore this PR every rented buffer was fully zeroed ( Failure scenario: Cheapest fix is to clamp what 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 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)
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.
Regression: |
||
| { | ||
| _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 +526,26 @@ 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) | ||
| { | ||
| Array.Clear(memory, (int)_lastZeroedSize, (int)(size - _lastZeroedSize)); | ||
| _lastZeroedSize = size; | ||
| } | ||
|
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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,9 +4,11 @@ | |||||||
| using System; | ||||||||
| using System.Collections.Generic; | ||||||||
| using System.Linq; | ||||||||
| using System.Numerics; | ||||||||
| using System.Reflection; | ||||||||
| using System.Text; | ||||||||
| using System.Text.Json; | ||||||||
| using System.Text.Json.Nodes; | ||||||||
|
Check warning on line 11 in src/Nethermind/Nethermind.Facade/Eth/RpcTransaction/TransactionForRpc.cs
|
||||||||
|
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 —
Suggested change
|
||||||||
| using System.Text.Json.Serialization; | ||||||||
| using Nethermind.Core; | ||||||||
| using Nethermind.Core.Crypto; | ||||||||
|
|
@@ -158,7 +160,8 @@ | |||||||
| TxType = T.TxType, | ||||||||
| Type = txType, | ||||||||
| FromTransactionFunc = T.FromTransaction, | ||||||||
| DiscriminatorProperties = uniqueProperties | ||||||||
| DiscriminatorProperties = uniqueProperties, | ||||||||
| DiscriminatorPropertiesUtf8 = Array.ConvertAll(uniqueProperties, static p => Encoding.UTF8.GetBytes(p.ToLowerInvariant())) | ||||||||
| }; | ||||||||
|
|
||||||||
| int existingTypeInfo = _txTypes.FindIndex(t => t.TxType == typeInfo.TxType); | ||||||||
|
|
@@ -184,12 +187,10 @@ | |||||||
|
|
||||||||
| public override TransactionForRpc? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) | ||||||||
| { | ||||||||
| // Copy the reader so we can do a double parse: | ||||||||
| // The first parse is used to check for fields, while the second parses the entire Transaction | ||||||||
| // Peek property names for the concrete type, then deserialize (no DOM). | ||||||||
| Utf8JsonReader txTypeReader = reader; | ||||||||
| JsonObject untyped = JsonSerializer.Deserialize<JsonObject>(ref txTypeReader, options); | ||||||||
|
|
||||||||
| Type concreteTxType = DeriveTxType(untyped, options, out bool isDefaulted); | ||||||||
| Type concreteTxType = DeriveTxType(ref txTypeReader, options, out bool isDefaulted); | ||||||||
|
|
||||||||
| TransactionForRpc? result = (TransactionForRpc?)JsonSerializer.Deserialize(ref reader, concreteTxType, options); | ||||||||
| if (result is not null) | ||||||||
|
|
@@ -199,29 +200,76 @@ | |||||||
| return result; | ||||||||
| } | ||||||||
|
|
||||||||
| private Type DeriveTxType(JsonObject untyped, JsonSerializerOptions options, out bool isDefaulted) | ||||||||
| private static ReadOnlySpan<byte> TypeFieldUtf8 => "type"u8; | ||||||||
| private static ReadOnlySpan<byte> GasPriceFieldUtf8 => "gasprice"u8; | ||||||||
|
|
||||||||
| private Type DeriveTxType(ref Utf8JsonReader reader, JsonSerializerOptions options, out bool isDefaulted) | ||||||||
| { | ||||||||
| const string gasPriceFieldKey = nameof(LegacyTransactionForRpc.GasPrice); | ||||||||
| const string typeFieldKey = nameof(TransactionForRpc.Type); | ||||||||
| TxType? setType = null; | ||||||||
| bool hasGasPrice = false; | ||||||||
| // Bit i set ⇒ discriminator for _txTypes[i] seen; lowest bit wins (registration order). | ||||||||
| ulong discriminated = 0; | ||||||||
|
|
||||||||
| if (reader.TokenType == JsonTokenType.StartObject) | ||||||||
| { | ||||||||
| while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName) | ||||||||
| { | ||||||||
| if (setType is null && NameEqualsIgnoreCase(ref reader, TypeFieldUtf8)) | ||||||||
| { | ||||||||
| reader.Read(); | ||||||||
| setType = JsonSerializer.Deserialize<TxType?>(ref reader, options); | ||||||||
| continue; | ||||||||
|
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 (perf, free win) — once
Suggested change
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: break the property scan once |
||||||||
| } | ||||||||
|
|
||||||||
| if (!hasGasPrice && NameEqualsIgnoreCase(ref reader, GasPriceFieldUtf8)) | ||||||||
| { | ||||||||
| hasGasPrice = true; | ||||||||
| } | ||||||||
| else | ||||||||
| { | ||||||||
| int count = Math.Min(_txTypes.Count, 64); | ||||||||
| for (int i = 0; i < count; i++) | ||||||||
| { | ||||||||
| foreach (byte[] discriminator in _txTypes[i].DiscriminatorPropertiesUtf8) | ||||||||
| { | ||||||||
| if (NameEqualsIgnoreCase(ref reader, discriminator)) | ||||||||
| { | ||||||||
| discriminated |= 1UL << i; | ||||||||
| break; | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| reader.Read(); | ||||||||
| reader.Skip(); | ||||||||
|
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 — Per the BCL contract, Today all reachable call sites are safe — the JSON-RPC pipeline builds its reader with
Suggested change
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: |
||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| if (untyped.TryGetPropertyValue(typeFieldKey, out JsonNode? node)) | ||||||||
| Type? viaDiscriminator = null; | ||||||||
| if (discriminated != 0) | ||||||||
| { | ||||||||
| TxType? setType = node.Deserialize<TxType?>(options); | ||||||||
| if (setType is not null) | ||||||||
| viaDiscriminator = _txTypes[BitOperations.TrailingZeroCount(discriminated)].Type; | ||||||||
| } | ||||||||
|
|
||||||||
| if (setType is not null) | ||||||||
| { | ||||||||
| isDefaulted = false; | ||||||||
| foreach (TxTypeInfo candidate in _txTypes) | ||||||||
| { | ||||||||
| isDefaulted = false; | ||||||||
| return _txTypes.FirstOrDefault(p => p.TxType == setType)?.Type ?? throw new JsonException("Unknown transaction type"); | ||||||||
| if (candidate.TxType == setType) return candidate.Type; | ||||||||
| } | ||||||||
|
|
||||||||
| throw new JsonException("Unknown transaction type"); | ||||||||
| } | ||||||||
|
|
||||||||
| if (untyped.ContainsKey(gasPriceFieldKey)) | ||||||||
| if (hasGasPrice) | ||||||||
| { | ||||||||
| isDefaulted = true; | ||||||||
| return typeof(LegacyTransactionForRpc); | ||||||||
| } | ||||||||
|
|
||||||||
| // Discriminator field is a strong signal — not a default. | ||||||||
| Type? viaDiscriminator = _txTypes.FirstOrDefault(p => p.DiscriminatorProperties.Any(untyped.ContainsKey))?.Type; | ||||||||
| if (viaDiscriminator is not null) | ||||||||
| { | ||||||||
| isDefaulted = false; | ||||||||
|
|
@@ -232,6 +280,23 @@ | |||||||
| return typeof(EIP1559TransactionForRpc); | ||||||||
| } | ||||||||
|
|
||||||||
| private static bool NameEqualsIgnoreCase(ref Utf8JsonReader reader, ReadOnlySpan<byte> lowerCaseName) | ||||||||
| { | ||||||||
| if (reader.HasValueSequence || reader.ValueIsEscaped) | ||||||||
| { | ||||||||
| return string.Equals(reader.GetString(), Encoding.UTF8.GetString(lowerCaseName), StringComparison.OrdinalIgnoreCase); | ||||||||
|
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 — the escaped-name fallback allocates two strings per comparison, and it is called once per (property × discriminator) pair.
Also worth noting for future maintainers: the
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: hot path stays allocation-free; escaped path tries |
||||||||
| } | ||||||||
|
|
||||||||
| ReadOnlySpan<byte> name = reader.ValueSpan; | ||||||||
| if (name.Length != lowerCaseName.Length) return false; | ||||||||
| for (int i = 0; i < name.Length; i++) | ||||||||
| { | ||||||||
| if ((name[i] | 0x20) != lowerCaseName[i]) return false; | ||||||||
| } | ||||||||
|
|
||||||||
| return true; | ||||||||
| } | ||||||||
|
|
||||||||
| public override void Write(Utf8JsonWriter writer, TransactionForRpc value, JsonSerializerOptions options) => JsonSerializer.Serialize(writer, value, value.GetType(), options); | ||||||||
|
|
||||||||
| public static TransactionForRpc FromTransaction(Transaction tx, in TransactionForRpcContext extraData) => _txTypes.FirstOrDefault(t => t.TxType == tx.Type)?.FromTransactionFunc(tx, extraData) | ||||||||
|
|
@@ -243,6 +308,7 @@ | |||||||
| public Type Type { get; set; } | ||||||||
| public FromTransactionFunc FromTransactionFunc { get; set; } | ||||||||
| public string[] DiscriminatorProperties { get; set; } = []; | ||||||||
| public byte[][] DiscriminatorPropertiesUtf8 { get; set; } = []; | ||||||||
|
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 — After the rewrite, Separately:
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: removed dead |
||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
|
|
||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -469,8 +469,15 @@ private static int CountBufferedNodes(IEnumerable<List<(TreePath Path, TrieNode | |
| public void SetAccount(Address address, Account? account) => | ||
| _changedAccounts[address] = account; | ||
|
|
||
| internal void PromoteAccount(Address address, Account? account) => | ||
| _changedAccounts.TryAdd(address, account); | ||
| internal void PromoteAccount(Address address, Account? account) | ||
| { | ||
| // 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)) | ||
|
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 (perf) — on the
So the optimisation is real, but it's targeted at the prewarmer path only, and it taxes the Not a correctness issue ( |
||
| { | ||
| _changedAccounts.TryAdd(key, account); | ||
| } | ||
| } | ||
|
|
||
| public void SetChangedSlot(Address address, in UInt256 index, byte[] value) | ||
| { | ||
|
|
||
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 test passes unchanged on
master, so it doesn't guard the optimisation it's named after.On
master,GetOrBuildStream()with a defaultCodeHashalso returnsnullon every call: the cache lookup is skipped by theCodeHash != defaultguard, the firstBuildThreshold - 1calls returnnullon the hit counter, and the call that crosses the threshold schedules a background build thatBuildStreamthen refuses to publish (CodeHash != defaultfails) — returningnulltoo. 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
_streamBuildStatetransitioning toStreamBuildUnavailableon the very first call, or assertInstructionStreamCachegained no entry and no work item ran (the siblingInstructionStreamTests.cs:490already uses aSpinUntilonGetOrBuildStreamand could be inverted here).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.
Fixed: test now uses
BuildThreshold = 1, asserts no stream appears for defaultCodeHashwithin a short spin, and a hashed control does build.