Skip to content

Experimental EIP-7979 subroutines for internal calls in the IR pipeline. - #17005

Draft
gcolvin wants to merge 7 commits into
argotorg:developfrom
gcolvin:eip-7979-subroutines
Draft

gcolvin wants to merge 7 commits into
argotorg:developfrom
gcolvin:eip-7979-subroutines

Conversation

@gcolvin

@gcolvin gcolvin commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Experimental EIP-7979 subroutines for internal calls in the IR pipeline

Draft. A demonstration for the EIP-7979 discussion, not a merge candidate.

EIP-7979 (proposed for Hegotá) gives the EVM a return stack and three instructions: CALLSUB, CALLDEST, RETURNSUB. This PR makes the optimized IR code transform use them for internal function calls when --evm-version @future is selected, so that the question "what does it cost a compiler to target this?" has a concrete answer: 159 lines, 21 files.

solc --experimental --evm-version @future --via-ir --optimize --asm Calls.sol

What changes in the generated code

today with @future
call PUSH ret; <args>; PUSH f; JUMP / ret: JUMPDEST <args>; PUSH f; CALLSUB
function entry f: JUMPDEST with the return label on the stack f: CALLDEST, arguments only
function return shuffle return label to top; JUMP RETURNSUB
call to a function that never returns PUSH f; JUMP unchanged: a JUMP may land on a CALLDEST

The return-label slot disappears from every function's stack layout, which also relieves stack-too-deep pressure by one slot per active call.

Results on a small contract

Calls.sol in the description below has a plain internal call, a nested one, and a recursive one. Runtime code under @future: 3 functions as CALLDEST, 7 CALLSUB, 4 RETURNSUB, and no plain JUMP at all; the only remaining control flow is JUMPI.

Executed on the EIP-7979 reference implementation in execution-specs (ethereum/execution-specs#3575) with three inputs, the results match the legacy build and the gas per external call is lower:

compute(a, b) legacy (osaka) @future
(0, 0) 3530 3480
(3, 4) 4034 3915
(10, 5) 4202 4060

The runtime is 31 bytes larger (317 vs 286): the evmasm inliner recognises JUMP-based calls but not CALLSUB, so fewer small helpers are inlined. That is a follow-up in the inliner, not a cost of the instructions.

The output also validates under EIP-8337 (fully static control flow, no underflow), checked with its reference validator (ethereum/execution-specs#3576). Getting there surfaced one general rule for optimizers targeting these EIPs, implemented here: code shared across subroutines must be entered as a subroutine. The block deduplicator had merged the identical overflow-panic tails of checked_add and checked_mul into one JUMPDEST block reached from two subroutines; with subroutines enabled it now marks every block it merges as a CALLDEST.

Changes

  • libevmasm: the three instructions with gas tiers; RETURNSUB terminates control flow, CALLSUB alters it (so the peephole optimizer and CSE treat them correctly); a Tag item can be marked as a subroutine entry — assembled as CALLDEST, printed as tag_N: (calldest), exported and imported as calldest in assembly JSON; CALLDEST costs what JUMPDEST costs; the block deduplicator never merges a CALLDEST block with a JUMPDEST block (a CALLSUB must never be redirected onto a JUMPDEST) and marks merged blocks as entries.
  • liblangutil: EVMVersion::hasSubroutines(), true for @future only; the opcode gate knows the three instructions.
  • libyul: appendSubroutineLabel, appendCallSubTo, appendReturnSub on AbstractAssembly, implemented for EthAssemblyAdapter and NoOutputAssembly; ControlFlowGraphBuilder stops pushing a return-label slot for calls; StackLayoutGenerator stops expecting one at returns; OptimizedEVMCodeTransform emits the new instructions.
  • libevmasm, post-pass: SubroutineEntryMarker runs at the end of optimisation and marks any block reached from more than one subroutine entry as a CALLDEST. On 1,756 semantic-test contracts, with EIP-8337's proposed framing rule, 98.9% of the optimized output validates.

Deliberately out of scope

Legacy (non-IR) code generation; the unoptimized Yul code transform; teaching the evmasm inliner about CALLSUB; documentation. The change is gated behind an experimental EVM version and does not affect any other version's output.

The demo contract

contract Calls {
    function square(uint256 x) internal pure returns (uint256) { return x * x; }
    function sumOfSquares(uint256 a, uint256 b) internal pure returns (uint256) {
        return square(a) + square(b);
    }
    function factorial(uint256 n) internal pure returns (uint256) {
        if (n < 2) return 1;
        return n * factorial(n - 1);
    }
    function compute(uint256 a, uint256 b) external pure returns (uint256) {
        return sumOfSquares(a, b) + factorial(b);
    }
}

CI status

The semantic-test jobs for @future fail because they execute compiled code on evmone, which does not implement EIP-7979: every test whose IR-pipeline code makes an internal call halts at its first CALLSUB ("the test passed without Yul"). The same tests pass when the bytecode is run on the EIP-7979 reference implementation in execution-specs (checked for selfdestruct_post_cancun.sol, the full test_create2_and_terminate scenario). All other jobs pass. An evmone implementation is at ipsilon/evmone#1706; on it, the same output runs correctly at 10–12% less execution gas.

With `--evm-version @future` (experimental), the optimized IR code
transform compiles internal function calls to the call and return
instructions of EIP-7979 instead of synthesizing them from jumps:

- a call is `PUSH <function tag>; CALLSUB`, with no return label pushed;
- a function entry is a CALLDEST label, with no return label on the
  stack;
- a function return is RETURNSUB.

Functions that cannot return keep the plain JUMP into their entry, which
EIP-7979 permits (a JUMP may land on a CALLDEST).

libevmasm: the three instructions with their gas tiers; RETURNSUB
terminates control flow and CALLSUB alters it; a Tag item may be marked
as a subroutine entry, in which case it assembles as CALLDEST, prints as
`tag_N: (calldest)` and round-trips through assembly JSON as `calldest`;
CALLDEST costs what JUMPDEST costs. The block deduplicator never merges a
CALLDEST block with a JUMPDEST block, so a CALLSUB is never redirected
onto a JUMPDEST, and, when subroutines are enabled, it marks every block
it merges as a subroutine entry, since shared code reached from several
subroutines must be entered as a subroutine (EIP-8337).

liblangutil: `EVMVersion::hasSubroutines()`, true for `@future`.

libyul: `appendSubroutineLabel`, `appendCallSubTo` and `appendReturnSub`
on AbstractAssembly, implemented for the evmasm adapter and the
no-output assembly; the CFG builder and stack layout generator stop
introducing return-label slots when subroutines are available.

Out of scope, deliberately: the legacy code generator, the unoptimized
Yul code transform, teaching the evmasm inliner to inline CALLSUB calls,
documentation and tests.
… interpreter.

CALLSUB, CALLDEST and RETURNSUB are low-level control flow like JUMP,
JUMPI and JUMPDEST: the code transform emits them, and they must not be
exposed as Yul builtins under `@future`. The Yul test interpreter's
exhaustive switch treats them as the other low-level control flow.
Compiles the demo contract with --experimental --evm-version @future
--via-ir --optimize --asm and snapshots the assembly: subroutine entries
as calldest, calls as callsub, returns as returnsub, no plain jump.
Compile-only, since the test EVM does not implement the instructions.
With EIP-7979 subroutines enabled, the optimizer's passes can leave code
that is reached by jumps from several subroutines: identical tails
merged across functions, loop tails shared between an external function
and the dispatcher, and the like. Under EIP-8337 every instruction
belongs to one subroutine entry, so such code can only be reached as a
subroutine entry of its own. A CALLDEST costs what a JUMPDEST costs.

SubroutineEntryMarker runs at the end of optimisation when the EVM
version has subroutines: it traverses the assembly items the way the
validator does, assigning each item the entry it is reached from, and
marks every tag reached from more than one entry as a subroutine entry,
iterating to a fixpoint since marking changes the entries downstream.

Measured on every single-source contract in the semantic tests (1,756
runtimes, via-IR, optimizer): with the EIP-8337 reference validator's
framing rule relaxed to a property of entries (proposed to the EIP),
validation rises from 95.7% to 98.9%; the remaining 20 are blocks merged
from a context with a deeper frame, a handful of net-effect conflicts,
and one CALLSUB whose PUSH the optimizer separated with a POP.
CALLSUB is the first EVM instruction that both transfers control and
comes back: it alters control flow and falls through to its return
point, the instruction that follows it. Until now the only instruction
with that shape was JUMPI, and two passes in the assembly-level
optimizer hard-code that set rather than asking SemanticInformation:

- ControlFlowGraph gave every control-flow instruction other than JUMP
  and JUMPI the STOP end type, so a block ending in CALLSUB had no
  successor: its return point was neither kept reachable nor kept
  physically adjacent, and RETURNSUB returned to whatever the optimizer
  had placed after the call. It now has a CALLSUB end type: the block
  falls through to the next one, like JUMPI, but no knowledge is
  carried into the callee or out of the call, since the callee may
  change storage, memory and the stack.

- BlockDeduplicator's block iterator ended a block at any control-flow
  instruction except JUMPI, so two blocks identical up to a CALLSUB but
  with different return points compared equal and were merged, and the
  merged one returned into the wrong continuation. The iterator now
  continues through a CALLSUB as it does through a JUMPI.

The rule for anyone adding EIP-7979 to a compiler: a fall-through
control-flow instruction has to be added everywhere fall-through control
flow is enumerated, not only to the predicates. In evmasm those are the
two places above; the peephole optimizer consults the predicates.

Found by running the semantic test suite against an evmone that
implements EIP-7979 (0.22.0 plus the three instructions, the build and
EVMC ABI the CI uses): 50 tests failed and one crashed. With this
change the full semanticTests suite passes under --evm-version @future
both with and without the optimizer: "No errors detected", twice.
The common-subexpression eliminator regenerates a block's code and
appends the block's final control transfer after it. The regenerated
code has the destination on top of the stack, as it must, but not
always as the last instruction: it can push the tag early and shuffle,
`PUSH tag; SWAP1; POP; CALLSUB`. Valid EVM, but EIP-8337 requires the
PUSH immediately before the JUMP, JUMPI or CALLSUB (constraints 2 and
3), and the code transform emits it that way.

When the EVM version has subroutines and the original chunk ended with
a pushed tag and a control transfer, a regenerated chunk that does not
is discarded and the original kept. A chunk is only replaced when it is
shorter, so what is lost is a rare micro-optimisation.

With this, every one of the 1,756 single-source semantic-test
contracts compiled with --via-ir --optimize --evm-version @future
passes the EIP-8337 reference validator.
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.

1 participant