Conversation
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.
This was referenced Sep 12, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 @futureis selected, so that the question "what does it cost a compiler to target this?" has a concrete answer: 159 lines, 21 files.What changes in the generated code
@futurePUSH ret; <args>; PUSH f; JUMP/ret: JUMPDEST<args>; PUSH f; CALLSUBf: JUMPDESTwith the return label on the stackf: CALLDEST, arguments onlyJUMPRETURNSUBPUSH f; JUMPJUMPmay land on aCALLDESTThe 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.solin the description below has a plain internal call, a nested one, and a recursive one. Runtime code under@future: 3 functions asCALLDEST, 7CALLSUB, 4RETURNSUB, and no plainJUMPat all; the only remaining control flow isJUMPI.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)@futureThe runtime is 31 bytes larger (317 vs 286): the evmasm inliner recognises
JUMP-based calls but notCALLSUB, 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_addandchecked_mulinto oneJUMPDESTblock reached from two subroutines; with subroutines enabled it now marks every block it merges as aCALLDEST.Changes
RETURNSUBterminates control flow,CALLSUBalters it (so the peephole optimizer and CSE treat them correctly); aTagitem can be marked as a subroutine entry — assembled asCALLDEST, printed astag_N: (calldest), exported and imported ascalldestin assembly JSON;CALLDESTcosts whatJUMPDESTcosts; the block deduplicator never merges aCALLDESTblock with aJUMPDESTblock (aCALLSUBmust never be redirected onto aJUMPDEST) and marks merged blocks as entries.EVMVersion::hasSubroutines(), true for@futureonly; the opcode gate knows the three instructions.appendSubroutineLabel,appendCallSubTo,appendReturnSubonAbstractAssembly, implemented forEthAssemblyAdapterandNoOutputAssembly;ControlFlowGraphBuilderstops pushing a return-label slot for calls;StackLayoutGeneratorstops expecting one at returns;OptimizedEVMCodeTransformemits the new instructions.SubroutineEntryMarkerruns at the end of optimisation and marks any block reached from more than one subroutine entry as aCALLDEST. 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
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.