Skip to content

optimizer: gas-gap analysis for top 30 semantic tests and Uniswap v4 #65

Description

@leonardoalt

Gas optimization gap report

Date: 2026-07-20

Compiler revision: 262c111345fd87db3570fd3986a38c9094ffcf89

Solidity corpus revision: 902f848958ff94c10492f546476bd23fd989fbb1

Reference compiler: solc 0.8.35, EVM version osaka

Scope and method

This report analyzes the gas suites that compare real Solidity contracts through
the two optimized pipelines:

ours: Solidity --solc --ir--> unoptimized Yul
      --verified optimizerPipelineObject--> optimized Yul
      --verified backend--> EVM

solc: Solidity --optimize --via-ir--> optimized Yul --> EVM

The primary ranking is by absolute excess execution gas, ours - solc, across:

  • test/solidity-semantic-gas-baseline.txt;
  • test/solidity-gas-baseline.txt; and
  • test/uniswap-v4-gas-baseline.txt.

All global top-30 rows are in semanticTests; repeated loop scenarios make their
absolute gaps much larger than the microbenchmarks. Uniswap and gasTests are
therefore analyzed separately rather than omitted.

The checked-in numbers were remeasured with a freshly rebuilt
checkSolidityGas. Every selected semantic row, every compiling Uniswap row,
and all 12 gasTests rows reproduced exactly.

For every top-30 and Uniswap fixture I also dumped:

  1. solc's unoptimized --ir (the common input);
  2. the Yul AST after this repository's production verified optimizer; and
  3. solc's --ir-optimized --optimize --via-ir output.

The defs/calls columns below count named Yul function definitions and static
call sites to those defined functions. They are structural indicators, not
dynamic call counts. They are useful here because retained Yul calls become the
compiler's explicit return-address/jump calling convention.

Executive summary

  • The semantic suite totals 135,139,424 gas ours vs 96,695,611 solc, a
    38,443,813 gas gap (1.398x). Ours is more expensive on 842 of 846
    comparable fixtures; four are equal and none is cheaper.
  • The top 30 account for 36,046,560 gas, or 93.8% of the semantic gap.
    The top three dynamic-array loop fixtures alone account for 29,391,099 gas
    (76.5%).
  • On the top 30, the common unoptimized IR contains 3,482 function definitions
    and 7,213 static calls. Our pipeline reduces this to 3,358 definitions / 5,799
    calls
    ; solc reduces it to 227 / 676. The dominant difference is therefore
    not missed local constant folding. It is missing interprocedural
    inlining/specialization followed by propagation and dead-code removal.
  • Compiling Uniswap fixtures total 30,658 gas ours vs 7,365 solc (4.16x).
    Our optimized Yul still has 243 definitions / 350 calls across those six
    contracts; solc has 5 / 8.
  • The other five Uniswap fixtures do not produce gas rows. FullMath,
    SqrtPriceMath, SwapMath, TickBitmap, and TickMath still fail the
    backend's DUP16/SWAP16 reach check. Our optimized Yul retains 481 functions,
    899 call sites, and many live temporaries across them. This is both an
    optimization problem and a stack-allocation problem.
  • The immediate high-value work is a scoped dataflow pass (safe copy/constant
    propagation plus general DCE), stack-pressure-aware helper inlining, loop
    optimization, and stack compression/spilling. Backend peepholes are secondary
    for these execution-gas measurements.

The 30 largest absolute gaps

ours defs/calls and solc defs/calls describe the respective optimized Yul.

# Fixture Ours solc Excess Ratio Ours defs/calls solc defs/calls
1 array/array_storage_length_access.sol 13,066,985 1,306,640 11,760,345 10.00x 32/29 0/0
2 array/array_storage_push_empty_length_address.sol 13,395,747 2,139,347 11,256,400 6.26x 45/45 1/2
3 array/array_storage_push_pop.sol 20,402,922 14,028,568 6,374,354 1.45x 43/41 1/2
4 storageLayoutSpecifier/dynamic_array_storage_end.sol 26,392,544 25,142,559 1,249,985 1.05x 53/61 1/3
5 storageLayoutSpecifier/mapping_storage_end.sol 25,638,980 24,503,311 1,135,669 1.05x 36/38 0/0
6 array/array_storage_index_zeroed_test.sol 8,480,879 7,424,367 1,056,512 1.14x 52/73 8/18
7 array/copying/bytes_storage_to_storage.sol 1,592,768 835,859 756,909 1.91x 76/113 6/17
8 array/array_storage_index_boundary_test.sol 938,373 212,135 726,238 4.42x 43/45 1/3
9 array/array_storage_index_access.sol 7,360,907 6,816,025 544,882 1.08x 50/53 1/4
10 array/array_memory_index_access.sol 371,681 109,723 261,958 3.39x 55/72 3/8
11 events/event_indexed_string.sol 485,797 311,724 174,073 1.56x 74/87 2/5
12 array/byte_array_storage_layout.sol 185,254 131,729 53,525 1.41x 57/87 3/8
13 array/pop/byte_array_pop_long_storage_empty_garbage_ref.sol 173,105 120,974 52,131 1.43x 48/59 11/14
14 storage/storage_boundary_packed_array.sol 133,140 81,069 52,071 1.64x 83/102 1/2
15 storage/storage_packed_array_copy.sol 115,428 64,188 51,240 1.80x 135/158 1/2
16 viaYul/detect_mul_overflow_signed.sol 67,599 20,469 47,130 3.30x 40/58 0/0
17 array/copying/nested_array_of_structs_memory_to_storage.sol 594,133 547,431 46,702 1.09x 281/543 16/56
18 storage/storage_boundary_array_copy.sol 229,616 185,548 44,068 1.24x 145/179 3/8
19 array/invalid_encoding_for_storage_byte_array.sol 659,012 616,496 42,516 1.07x 148/265 10/48
20 array/copying/nested_array_element_storage_to_storage.sol 452,750 411,659 41,091 1.10x 308/651 43/140
21 operators/userDefined/all_possible_operators.sol 39,567 426 39,141 92.88x 69/345 1/3
22 array/copying/nested_array_of_structs_calldata_to_storage.sol 576,864 538,435 38,429 1.07x 281/498 16/56
23 viaYul/exp_various.sol 46,145 13,388 32,757 3.45x 32/44 2/2
24 functionCall/calling_other_functions.sol 38,740 6,030 32,710 6.42x 37/46 2/4
25 array/copying/nested_array_element_storage_to_memory.sol 125,751 94,161 31,590 1.34x 433/792 36/115
26 viaYul/exp_neg.sol 80,962 51,077 29,885 1.59x 26/28 1/1
27 abicoder/calldataDecoding/array/calldata_nested_array_reencode_v2.sol 56,106 27,162 28,944 2.07x 132/186 6/24
28 array/copying/array_to_mapping.sol 212,773 184,091 28,682 1.16x 239/453 16/39
29 array/copying/nested_array_of_structs_storage_to_storage.sol 308,801 280,371 28,430 1.10x 202/425 26/68
30 viaYul/unary_operations.sol 49,420 21,227 28,193 2.33x 103/223 9/24

Per-fixture diagnosis

Rows Dominant difference in optimized Yul
1-3 Dynamic-array push/pop loops. Ours executes generic length, bounds, index, zeroing, readback, cleanup, and panic helpers on every iteration. solc inlines and specializes these to direct sload/sstore, comparisons, and the required panic branches.
4-6, 9 Thousand-iteration or multi-loop storage tests. The remaining percentage is smaller because storage dominates, but repeated checked-increment, conversion, array/mapping address, and cleanup calls still add 0.5-1.25M gas per fixture. Missing LICM and induction-variable simplification amplify the cost.
7, 12-15 Dynamic bytes and packed storage. Ours retains generic short/long byte-array branches, masks, extract/update helpers, and copy loops. solc specializes element width/layout and removes redundant cleanup/readback paths.
8, 10 Boundary/index microbenchmarks. Generic allocation, index-access, cleanup, and panic helpers dominate what solc reduces to direct bounds checks and address arithmetic.
11 Indexed dynamic event data. Ours retains ABI/memory-array helper layers around copying, hashing, and logging; solc joins the encoding/copy path and keeps only a few helpers.
16, 23, 26, 30 Checked arithmetic, exponentiation, and unary operations. Ours retains cleanup/conversion and checked-operation functions around each call. solc inlines the checks and propagates widths/values through them.
17-20, 22, 25, 27-29 Nested array/struct ABI and storage copies. These have the largest Yul helper graphs: decoders, allocators, indexers, element converters, copy loops, and encoders. solc specializes by concrete type and joins loops/expressions; ours preserves most generic layers.
21 Interprocedural constant case. The Solidity body is a sequence of assertions over constants and user-defined operator wrappers. solc resolves nearly everything at compile time; ours still executes 345 static helper call sites spanning constants, cleanup, conversions, wrappers, and assertions. This is the clearest evidence for general inlining + propagation + DCE.
24 Collatz loop calling evenStep/oddStep. solc inlines/specializes the small arithmetic functions. Ours retains the general Yul function-call protocol on every loop iteration.

What the optimized Yul actually looks like

The largest fixture is representative. Our optimized loop is still expressed
through generic Solidity-generated helpers (abridged):

for { } 1 { } {
  let expr := array_length_t_array$_t_uint256_$dyn_storage(0)
  if iszero(lt(expr, var_len)) { break }
  let slot, offset := array_push_zero_t_array$_t_uint256_$dyn_storage_ptr(0)
  let ignored := read_from_storage_split_dynamic_t_uint256(slot, offset)
}

Those helpers call further helpers. For example, array_push_zero... calls the
array-length and index-access functions and a panic function before returning two
values through the compiler's stack calling convention.

solc's optimized Yul has no function definitions in this contract and emits the
operations directly in the loop (abridged):

for { } 1 { } {
  let oldLen := sload(0)
  if iszero(lt(oldLen, value)) { break }
  if iszero(lt(oldLen, 0x10000000000000000)) {
    mstore(0, shl(224, 0x4e487b71))
    mstore(4, 0x41)
    revert(0, 0x24)
  }
  let newLen := add(oldLen, 1)
  sstore(0, newLen)
  if iszero(lt(oldLen, newLen)) {
    mstore(0, shl(224, 0x4e487b71))
    mstore(4, 0x32)
    revert(0, 0x24)
  }
  mstore(0, 0)
}

The same pattern appears in the arithmetic cases: our optimizer can inline a
single-assignment flat pure helper, but not a helper containing checks, branches,
multiple assignments, nested calls, or multi-value results. solc inlines those
helpers, specializes them with argument facts, folds the exposed expressions,
and deletes the now-unused scaffolding.

Uniswap v4

Compiling fixtures

Fixture Calls replayed Ours solc Excess Ratio Ours defs/calls solc defs/calls Main retained work
BitMath.sol 6 6,186 1,836 4,350 3.37x 27/32 0/0 Wrapper calls, result zeroing, ABI helpers, and the MSB/LSB function boundary; solc places the assembly directly in each selector arm.
LPFeeLibrary.sol 5 6,420 1,294 5,126 4.96x 55/89 4/5 uint24 cleanup, flag predicates, validation, custom-revert helpers, and wrapper calls.
LiquidityMath.sol 3 3,165 831 2,334 3.81x 22/23 0/0 Signed/unsigned conversions, checked add/sub, cleanup, ABI, and wrapper layers.
ProtocolFeeLibrary.sol 4 4,478 1,091 3,387 4.10x 48/69 1/3 Width cleanup, constant wrappers, checked arithmetic, and nested library calls.
SafeCast.sol 6 6,509 1,545 4,964 4.21x 68/105 0/0 Conversion/cleanup chains and custom-revert wrappers remain as calls; solc reduces each selector to a direct mask/signextend/check sequence.
UnsafeMath.sol 4 3,900 768 3,132 5.08x 23/32 0/0 divRoundingUp and mulDiv wrapper/ABI/helper call protocol instead of direct arithmetic.

The Uniswap total is 30,658 vs 7,365, an excess of 23,293 gas. These are
small pure-library calls, so there is little expensive state work to hide call
overhead; missing interprocedural optimization is exposed directly.

Rejected fixtures: the more important Uniswap result

Fixture Our optimized defs/calls solc optimized defs/calls Why it still fails
FullMath.sol 34/71 3/7 mulDiv retains long chains of zero-initialized and reassigned temporaries plus wrapping helpers; a needed local becomes deeper than the classic stack instructions can reach.
SqrtPriceMath.sol 79/193 19/62 Nested FullMath, cast, branch, and rounding helpers preserve too many simultaneous values.
SwapMath.sol 80/208 27/78 The large multi-result computeSwapStep path combines the SqrtPrice/FullMath graph with many live branch results.
TickBitmap.sol 111/169 1/2 Signed-width conversion/cleanup and bit-math helper chains leave many stack locals despite solc reducing the runtime almost completely.
TickMath.sol 177/258 4/22 Long sequences of shifts, masks, constants, and signed conversions retain hundreds of temporary bindings and calls.

This is not solved by inlining alone: indiscriminate inlining can increase live
stack pressure. The necessary unit is inlining/specialization plus propagation,
DCE, and stack-aware scheduling
, with spilling or a stack-compression fallback
when the result still exceeds depth 16.

Other Solidity gasTests

The 12 rows reproduce at 396,019 ours vs 336,827 solc (1.176x, +59,192).
The _optimised fixture pairs produce identical source sections in this harness,
so the distinct gaps are:

Fixture(s) Excess per row Ratio Optimized-Yul difference
dispatch_large* 15,925 1.21x Ours 107 defs/287 calls vs solc 20/37; dispatch and ABI helper specialization dominate.
dispatch_medium* 6,037 1.15x Ours 75/127 vs solc 2/13.
exp* 2,800 4.61x Ours 39/46 vs solc 0/0; checked exponent and cleanup helpers dominate.
abiv2* 2,224 1.32x Ours 157/302 vs solc 18/53; nested ABI decoding/encoding and copy helpers dominate.
dispatch_small* 1,679 1.06x Ours 50/57 vs solc 1/2.
storage_costs.sol 1,469 1.05x Repeated generic storage/cleanup helpers around intrinsically expensive storage operations.
data_storage.sol 393 2.95x A tiny path where helper and ABI overhead is most of the execution.

Main missing optimizations

1. General interprocedural inlining and specialization

Current InlineHelpers handles only a narrow Core shape: one assignment of a
flat pure expression, with flat value arguments. The object path is further
restricted to variable-only classification so that layout resolution cannot
change whether a call is inlined.

Missing cases visible throughout the top 30 and Uniswap are:

  • multi-statement helpers;
  • helpers containing a guard or panic/revert branch;
  • nested helper calls;
  • multi-result helpers;
  • helpers called with literals on the object path;
  • single-use functions and wrapper functions; and
  • specialization by known widths, masks, storage layout, or constant arguments.

Inlining should be driven by a live-stack/size budget, not only AST size. It
should process the lexical call graph by SCC, decline recursive edges, and rerun
propagation and pruning on the specialized body.

2. Full scoped propagation and dead-code elimination

Production Propagate intentionally creates literal facts only. Copy facts are
proved but disabled because substituting an older variable made dispatch_*
locals deeper and caused DUP16 failures. DeadLits removes only dead literal
bindings.

Still missing:

  • depth-aware copy propagation;
  • propagation through assignments, function arguments/results, and branch joins;
  • dead pure lets with nonliteral right-hand sides;
  • dead assignments and overwritten values;
  • dead function definitions;
  • branch pruning after interprocedural propagation; and
  • removal of redundant zero-initialization/return-slot scaffolding.

An intrinsically scoped Core/ANF pass is the cleanest way to prove the required
well-boundness facts while retaining the strong Optimizer.Pass contract. Using
the weaker observational pass contract for arbitrary DCE would move the audited
specification boundary and therefore requires human approval; it is not needed
if the scoped pass can prove exact block equivalence.

3. Loop optimization

Rows 1-10 dominate absolute gas because a modest body overhead repeats hundreds
or thousands of times. Needed transformations include:

  • loop-invariant code motion;
  • induction-variable recognition and checked-increment simplification;
  • caching/reusing array length, base slot, keccak-derived data area, masks, and
    element width when sound;
  • eliminating unused push-result readbacks;
  • specializing generic array/bytes helpers into the loop body; and
  • loop fusion/joining for decode-copy-encode and storage-copy paths.

The current simplifier also leaves for initializers unchanged because their
scope/hoisting congruence has not yet been proved. That is a smaller but concrete
coverage hole.

4. Stack compression or spilling

The backend correctly rejects reads/stores deeper than DUP16/SWAP16. The five
heavy Uniswap fixtures show that source optimization alone is not yet a reliable
way to stay below the limit.

A stack-allocation pass should shorten live ranges, rematerialize cheap literals
and expressions, reorder independent computations, and spill only as a fallback.
This should be designed together with inlining: the profitability metric must
include maximum live depth. It is higher priority than adding more local rewrite
rules because it unlocks currently unsupported production programs.

5. Richer expression simplification and CSE

The current rule engine folds flat all-literal pure operations and a small set of
neutral identities. The dumps still contain:

  • nested cleanup/conversion chains;
  • repeated masks, shifts, sign extensions, and boolean normalization;
  • repeated bounds/address expressions;
  • redundant iszero layers in condition position;
  • duplicate pure calculations; and
  • constant assertions only exposed after helper inlining.

Add proved rules behind the existing Core rule interface, then add pure-expression
CSE with explicit invalidation for memory/storage reads.

6. ABI, array-copy, and memory specialization

The nested-copy rows have hundreds of decoder, allocator, accessor, converter,
copy, and encoder call sites. A typed Core representation can specialize these by
concrete element width and location, join adjacent loops, reuse allocation facts,
and remove encode/decode round trips whose intermediate value does not escape.

7. Backend work is secondary for this benchmark

The verified function convention has real overhead: code-address pushes, return
slots, jumps, rotations, and dynamic return jumps. Eliminating Yul calls removes
that overhead at its source. Later Asm-level jump threading or stack peepholes may
help, but they cannot explain the 4-10x loop and Uniswap gaps.

Shorter PUSH encodings would mostly improve bytecode/deployment size, not the
call execution gas summed by this harness, and would disrupt the repository's
fixed-width location proofs. It is not the appropriate first response to these
results.

Recommended implementation order

  1. Unlock the Uniswap compile frontier: scoped use/liveness analysis,
    nonliteral DCE, stack-depth-aware copy propagation, and a stack compressor or
    spill fallback. Track all five currently rejected fixtures as acceptance
    targets.
  2. Add stack-aware general helper inlining/specialization: guards,
    multi-statement bodies, nested calls, and multi-result helpers; compose it
    immediately with propagation and DCE.
  3. Optimize the dominant loops: start with the dynamic-array push/pop family
    (rows 1-3), then storage index/address helpers (rows 4-10), measuring gas per
    iteration as well as fixture totals.
  4. Expand the Core rule/dataflow engine: conversions, masks, boolean facts,
    bounds facts, branch joins, CSE, and dead assignments.
  5. Specialize ABI/array copy graphs: target rows 7, 11-15, 17-20, 22, and
    25-29.
  6. Only then pursue backend peepholes, with Phase A/Phase B simulation and
    fixed-width location invariants preserved.

Useful acceptance metrics for each optimizer change are:

  • total and per-fixture execution gas;
  • maximum live stack depth and number of formerly rejected fixtures;
  • reachable named-call sites after optimization (not just total AST size);
  • optimized bytecode size and deployment gas as separate metrics; and
  • zero regressions in all existing behavioral differentials and gas ceilings.

Reproduction

lake build checkSolidityGas

.lake/build/bin/checkSolidityGas \
  test/uniswap-v4 test/uniswap-v4-gas-baseline.txt \
  "$(svm which 0.8.35)" 0.8.35 \
  --known=test/uniswap-v4-known-compile-failures.txt

.lake/build/bin/checkSolidityGas \
  /tmp/solidity/test/libsolidity/gasTests \
  test/solidity-gas-baseline.txt \
  "$(svm which 0.8.35)" 0.8.35

For Yul inspection, the reference sides are produced with solc --ir and
solc --ir-optimized --optimize --via-ir --evm-version osaka. The repository
side is the parsed/desugared object after
Optimizer.optimizerPipelineObject, printed with YulParser.printObjC.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions