perf: convert U256 <-> Address/Hash a word at a time, not a byte at a time - #102
Merged
Gabriel-Trintinalia merged 4 commits intoSep 3, 2026
Conversation
… time All four converters in host.zig looped over the bytes, shifting a full u256 on every iteration — around seven machine ops per byte, so ~140 for a 20-byte address and ~250 for a 32-byte hash. They are not incidental helpers. u256ToAddress runs on every BALANCE, EXTCODE*, CALL, CALLCODE, DELEGATECALL, STATICCALL and SELFDESTRUCT, and hashToU256 is essentially the whole cost of PREVRANDAO (9,468 cost-per-gas against a median of 1,962, for a 2-gas opcode). Read and write a machine word at a time instead: one byteswap per word rather than per byte. This ISA has no rev8, so the byteswap is itself shift/or work, but over eight bytes instead of one. Measured alone on top of main@259467a, over for_amsterdam_at_0010M (2,458 units, ReleaseFast ELF under ziskemu): total trace cells 22.9308e12 -> 22.9008e12, **-0.13%**; steps -0.34%. The win is concentrated and small: instruction/block_context -8.63% (PREVRANDAO -25.2%, BLOBHASH -22.4%) and instruction/tx_context -5.39%. Against that, precompile/sha256 +1.20%, instruction/log +1.22%, precompile/identity +1.17%. Two honest caveats, since this is the marginal one of the series. The reasoning above is an instruction-count argument, and instruction count is not what proving costs. Step counts fall further than trace cells here (-0.34% vs -0.13%), and on the pure address-push opcodes they move in *opposite* directions: COINBASE, ORIGIN and CALLER each execute ~9.5% fewer instructions and cost ~2.3% *more* cells, because the replacement instructions are dearer per instruction. ADDRESS is the control at -0.01%. Where there is more to save the trade is clearly favourable — PREVRANDAO converts 32 bytes, so its MAIN component drops 79.9G -> 54.5G against a 3.8G rise in OPCODES. If this area is revisited, the thing to test is a formulation that avoids the byteswap entirely even at the cost of more instructions. An earlier revision of this message credited instruction/call_context -3.80% and instruction/account_query -3.74% to this change. Both were wrong — they came from a stacked measurement that also contained the div/mod fast paths, and both belong to those. Isolated, this change moves neither suite measurably. Note this is also not the SELFBALANCE/PREVRANDAO caching that PERF_FINDINGS_AMSTERDAM proposed. That document calls the executing contract's balance "constant within the frame" and it is not: a sub-call can re-enter the same address with value and change its balance while the outer frame is suspended, so a frame-lifetime cache would serve a stale value. Doing it safely needs invalidation against a journal-side mutation counter and is left alone. Nothing here caches anything, so there is no behaviour-adjacent surface at all. Round-trip alone would not catch a byte order that both directions got wrong identically, so the tests pin the big-endian layout explicitly, along with u256ToAddress discarding bits above 160 as the truncation it replaces did. Gate: zig build test clean; blockchain-tests 97324 passed / 0 failed / 48 skipped; zkevm 23994/23994 — all identical to main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
garyschulte
approved these changes
Sep 2, 2026
garyschulte
left a comment
Contributor
There was a problem hiding this comment.
LGTM functionally, but lets trim the overly verbose comment in host.zig for the converter functions.
Comment on lines
+1070
to
+1076
| // These four converters ran a byte at a time, each iteration shifting a full | ||
| // u256 — around seven machine ops per byte, so 140 for an address and 250 for a | ||
| // hash. They are not incidental: `u256ToAddress` runs on every BALANCE, | ||
| // EXTCODE*, CALL, CALLCODE, DELEGATECALL, STATICCALL and SELFDESTRUCT, which is | ||
| // the hot path of the call-heavy workloads. Converting a machine word at a time | ||
| // costs one byteswap per word instead (this ISA has no `rev8`, so the byteswap | ||
| // is itself shift/or, but over eight bytes rather than one). |
Contributor
There was a problem hiding this comment.
I think we can omit the history of what this function used to do and just focus on the explanation for word-at-a-time conversions
Per review: drop the history of the byte-at-a-time implementation and the op-count arithmetic, keep the reason the code reads the way it does. Move the note to the section header, since it covers all four converters rather than addressToU256 alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gabriel-Trintinalia
merged commit Sep 3, 2026
99d2546
into
Consensys-Incorporated:main
8 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
u256ToAddress/u256ToHashand their inverses (host.zig) ran a byte at a time, shifting a full u256 per byte — roughly 140 machine ops per address and ~250 per hash.u256ToAddressruns on every BALANCE, EXTCODE*, CALL and CALLCODE, so the cost is paid throughout any call-heavy workload.Change
Convert a word at a time instead. Touches
src/evm/interpreter/host.zigplus its tests.Effect
block_context −8.6%, call_context −3.8% on the 0010M tier.
Measurement context
Numbers are from
for_amsterdam_at_0010M(2,458 units), ReleaseFast ELF under ziskemu at--jobs 8, baseline259467a. Full analysis inPERF_FINDINGS_AMSTERDAM.md.This is one of 5 granular PRs split out of a stack that measured −10.92% cumulatively (22.93e12 → 20.43e12 trace cells). That headline figure belongs to the stack, not to this PR alone.
Gated on
zig build testplus the full blockchain-test and zkevm suites, which sat at 97324 passed / 0 failed / 48 skipped and 23994/23994 at every commit in the stack — identical to259467a.🤖 Generated with Claude Code
Note
Low Risk
Localized performance refactor with explicit layout tests; no API or EVM semantics change beyond preserving existing truncation rules.
Overview
Replaces byte-at-a-time U256 ↔ Address/Hash conversion in
host.zigwith word-at-a-timestd.mem.readInt/writeIntpaths foraddressToU256,u256ToAddress,hashToU256, andu256ToHash. Behavior is unchanged: big-endian layout and truncation above 160 bits inu256ToAddress. A short comment documents why this matters on opcodes that turn stack words into addresses (BALANCE, EXTCODE*, CALL family, SELFDESTRUCT).Adds focused unit tests in
host_ops_tests.zigthat pin exact byte order (not only round-trips), high-bit discard for addresses, and hash round-trips at edge values.Reviewed by Cursor Bugbot for commit 394da07. Bugbot is set up for automated code reviews on this repo. Configure here.