Skip to content

perf: convert U256 <-> Address/Hash a word at a time, not a byte at a time - #102

Merged
Gabriel-Trintinalia merged 4 commits into
Consensys-Incorporated:mainfrom
Gabriel-Trintinalia:perf/u256-converters
Sep 3, 2026
Merged

perf: convert U256 <-> Address/Hash a word at a time, not a byte at a time#102
Gabriel-Trintinalia merged 4 commits into
Consensys-Incorporated:mainfrom
Gabriel-Trintinalia:perf/u256-converters

Conversation

@Gabriel-Trintinalia

@Gabriel-Trintinalia Gabriel-Trintinalia commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Problem

u256ToAddress / u256ToHash and 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.

u256ToAddress runs 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.zig plus 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, baseline 259467a. Full analysis in PERF_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 test plus 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 to 259467a.

🤖 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.zig with word-at-a-time std.mem.readInt / writeInt paths for addressToU256, u256ToAddress, hashToU256, and u256ToHash. Behavior is unchanged: big-endian layout and truncation above 160 bits in u256ToAddress. 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.zig that 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.

… 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 garyschulte left a comment

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.

LGTM functionally, but lets trim the overly verbose comment in host.zig for the converter functions.

Comment thread src/evm/interpreter/host.zig Outdated
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).

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.

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

Gabriel-Trintinalia and others added 2 commits September 3, 2026 11:31
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
Gabriel-Trintinalia merged commit 99d2546 into Consensys-Incorporated:main Sep 3, 2026
8 checks passed
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.

2 participants