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:
- solc's unoptimized
--ir (the common input);
- the Yul AST after this repository's production verified optimizer; and
- 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
- 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.
- Add stack-aware general helper inlining/specialization: guards,
multi-statement bodies, nested calls, and multi-result helpers; compose it
immediately with propagation and DCE.
- 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.
- Expand the Core rule/dataflow engine: conversions, masks, boolean facts,
bounds facts, branch joins, CSE, and dead assignments.
- Specialize ABI/array copy graphs: target rows 7, 11-15, 17-20, 22, and
25-29.
- 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.
Gas optimization gap report
Date: 2026-07-20
Compiler revision:
262c111345fd87db3570fd3986a38c9094ffcf89Solidity corpus revision:
902f848958ff94c10492f546476bd23fd989fbb1Reference compiler: solc
0.8.35, EVM versionosakaScope and method
This report analyzes the gas suites that compare real Solidity contracts through
the two optimized pipelines:
The primary ranking is by absolute excess execution gas,
ours - solc, across:test/solidity-semantic-gas-baseline.txt;test/solidity-gas-baseline.txt; andtest/uniswap-v4-gas-baseline.txt.All global top-30 rows are in
semanticTests; repeated loop scenarios make theirabsolute gaps much larger than the microbenchmarks. Uniswap and
gasTestsaretherefore 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
gasTestsrows reproduced exactly.For every top-30 and Uniswap fixture I also dumped:
--ir(the common input);--ir-optimized --optimize --via-iroutput.The
defs/callscolumns below count named Yul function definitions and staticcall 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
38,443,813 gas gap (
1.398x). Ours is more expensive on 842 of 846comparable fixtures; four are equal and none is cheaper.
The top three dynamic-array loop fixtures alone account for 29,391,099 gas
(76.5%).
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.
4.16x).Our optimized Yul still has 243 definitions / 350 calls across those six
contracts; solc has 5 / 8.
FullMath,SqrtPriceMath,SwapMath,TickBitmap, andTickMathstill fail thebackend's
DUP16/SWAP16reach 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.
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/callsandsolc defs/callsdescribe the respective optimized Yul.array/array_storage_length_access.solarray/array_storage_push_empty_length_address.solarray/array_storage_push_pop.solstorageLayoutSpecifier/dynamic_array_storage_end.solstorageLayoutSpecifier/mapping_storage_end.solarray/array_storage_index_zeroed_test.solarray/copying/bytes_storage_to_storage.solarray/array_storage_index_boundary_test.solarray/array_storage_index_access.solarray/array_memory_index_access.solevents/event_indexed_string.solarray/byte_array_storage_layout.solarray/pop/byte_array_pop_long_storage_empty_garbage_ref.solstorage/storage_boundary_packed_array.solstorage/storage_packed_array_copy.solviaYul/detect_mul_overflow_signed.solarray/copying/nested_array_of_structs_memory_to_storage.solstorage/storage_boundary_array_copy.solarray/invalid_encoding_for_storage_byte_array.solarray/copying/nested_array_element_storage_to_storage.soloperators/userDefined/all_possible_operators.solarray/copying/nested_array_of_structs_calldata_to_storage.solviaYul/exp_various.solfunctionCall/calling_other_functions.solarray/copying/nested_array_element_storage_to_memory.solviaYul/exp_neg.solabicoder/calldataDecoding/array/calldata_nested_array_reencode_v2.solarray/copying/array_to_mapping.solarray/copying/nested_array_of_structs_storage_to_storage.solviaYul/unary_operations.solPer-fixture diagnosis
sload/sstore, comparisons, and the required panic branches.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):
Those helpers call further helpers. For example,
array_push_zero...calls thearray-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):
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
BitMath.solLPFeeLibrary.soluint24cleanup, flag predicates, validation, custom-revert helpers, and wrapper calls.LiquidityMath.solProtocolFeeLibrary.solSafeCast.solUnsafeMath.soldivRoundingUpandmulDivwrapper/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
FullMath.solmulDivretains long chains of zero-initialized and reassigned temporaries plus wrapping helpers; a needed local becomes deeper than the classic stack instructions can reach.SqrtPriceMath.solFullMath, cast, branch, and rounding helpers preserve too many simultaneous values.SwapMath.solcomputeSwapSteppath combines the SqrtPrice/FullMath graph with many live branch results.TickBitmap.solTickMath.solThis 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
_optimisedfixture pairs produce identical source sections in this harness,so the distinct gaps are:
dispatch_large*dispatch_medium*exp*abiv2*dispatch_small*storage_costs.soldata_storage.solMain missing optimizations
1. General interprocedural inlining and specialization
Current
InlineHelpershandles only a narrow Core shape: one assignment of aflat 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:
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
Propagateintentionally creates literal facts only. Copy facts areproved but disabled because substituting an older variable made
dispatch_*locals deeper and caused
DUP16failures.DeadLitsremoves only dead literalbindings.
Still missing:
An intrinsically scoped Core/ANF pass is the cleanest way to prove the required
well-boundness facts while retaining the strong
Optimizer.Passcontract. Usingthe 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:
element width when sound;
The current simplifier also leaves
forinitializers unchanged because theirscope/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 fiveheavy 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:
iszerolayers in condition position;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
PUSHencodings would mostly improve bytecode/deployment size, not thecall 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
nonliteral DCE, stack-depth-aware copy propagation, and a stack compressor or
spill fallback. Track all five currently rejected fixtures as acceptance
targets.
multi-statement bodies, nested calls, and multi-result helpers; compose it
immediately with propagation and DCE.
(rows 1-3), then storage index/address helpers (rows 4-10), measuring gas per
iteration as well as fixture totals.
bounds facts, branch joins, CSE, and dead assignments.
25-29.
fixed-width location invariants preserved.
Useful acceptance metrics for each optimizer change are:
Reproduction
For Yul inspection, the reference sides are produced with
solc --irandsolc --ir-optimized --optimize --via-ir --evm-version osaka. The repositoryside is the parsed/desugared object after
Optimizer.optimizerPipelineObject, printed withYulParser.printObjC.