Skip to content

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

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

gcolvin wants to merge 3 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.

Deliberately out of scope

Legacy (non-IR) code generation; the unoptimized Yul code transform; teaching the evmasm inliner about CALLSUB; documentation; tests. 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.
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