Repository navigation
bugc: keep inlined invoke and return markers on their path - #384
Merged
Merged
Conversation
Contributor
|
Common subexpression elimination folds an instruction into an earlier one that dominates it. That earlier instruction can run on other paths, too: the dispatch's msg.data read runs before every selector check. An inlined function's invoke on the folded instruction went with it, so every run invoked that function. The folded instruction's invoke and return now move to the next instruction kept in its block (or its terminator), on its own path.
A pass that removes or merges the instruction (or jump) carrying an inlined call's invoke or return now moves the marker onto a neighbour on the same path: invokes onto the next instruction, returns onto the one before, only where the markers still read as invokes, then returns. Dead code elimination, block merging, jump optimization (#382) and read/write merging do this, too, and constant propagation no longer copies a constant's markers onto its uses. Phi copies before a jump do not repeat the jump's markers.
gnidan
force-pushed
the
bugc-invoke-merge
branch
from
October 15, 2026 01:07
2a3dc1e to
fc47ae2
Compare
A marker with no op on its path to carry it is now dropped (and recorded), where before the pass kept code for it: CSE and DCE keep no instruction, block merging merges, read/write merging merges, and jump optimization skips every empty block. A skipped block's markers go on the jump into it or, if nothing else reaches it, the entry (JUMPDEST) of the block it leads to, kept in the new Block.entryActivations. Code generation emits those entry markers, and gives a return on a jump its `returned` debug. CSE keeps its expressions in step with the instructions that markers moved onto. A test pins the code for every example and test program at O0 to O3 to what main generates.
A return the optimizer moved onto a callee's jump or block entry brings its debug once returned. Inlining the callee now prefixes that debug's inline sites, as it does an instruction's.
This was referenced Oct 15, 2026
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.
An inlined call's
invokeandreturnare positional: the invoke rides the body's first op and the return its last, and a debugger replays them as a call stack. Several optimizer passes treated them as ordinary context. When a pass removed or merged what carried one, the marker went where it should not be, or was lost:msg.data, that is the dispatch's own read, before the selector check: every transaction invoked each of those functions and never returned (found in the arcade contract for the Argot post:joinandsetMotdat -O2).g();, whereg's body ends in an unusedmsg.value, lostg's return).Debug information must not change the code, so every pass still does exactly what it did; only the markers move.
Ir.Utils.placeActivationsputs the markers of what is removed onto a neighbour on the same path: invokes onto what runs just after (the body's new first op), returns onto what ran just before (its new last op). An op lists its invokes, then its returns, so the markers split between the two neighbours only where each still reads that way: a return never lands after an invoke it came before. The neighbours are:Ir.Utils.dropInstructions): the instruction kept before it or the block's entry, and the instruction kept after it or the block's jump;A block's entry is its
JUMPDEST. The markers for it are kept in a newBlock.entryActivations, which code generation adds to theJUMPDEST's context (entryDebugis rewritten by local-variable tracking). This is how #382's second program pairs now: its loop's exit block is skipped, and the return rides the call continuation'sJUMPDEST. A call whose whole body was optimized away stays visible as a zero-length call: its invoke and return go together onto a neighbour, where they fit. Markers that no neighbour can take (such a pair included) are dropped, and the pass records them as a transformation. None of the examples or test programs drops one.Also: code generation now gives a return on a jump the caller's variables (
returned), as it does for an instruction. The phi copies before a jump no longer repeat the jump's markers. Constant propagation no longer copies a constant's markers onto its uses (not reproduced, but the same pattern). CSE keeps its dominating expressions in step with the instructions whose debug a move replaced.The bytecode is identical to
mainfor every example, the arcade contract, and the test programs, at O0 to O3. A test pins this: a snapshot of each one's code digest, taken frommain.evmgen/activation-pairing.test.tsruns each case at O0 to O3 and replays the trace's invokes and returns. Every return must close the innermost open call, the run must enter exactly the expected calls, and no call may be open at the end. The cases are the dispatch, a body whose last instruction goes, a body that goes whole (twice in a row), calls in sequence that share a block at O3, nested calls (taken and not taken), a call in a loop, an unused result, merged writes, and #382's two programs. Onmain, 21 of them fail.ir/utils/debug.test.tscovers the placement rules.Closes #382.
Not here:
a()) is still gathered onto the instruction CSE keeps, so the dispatch's read maps to both call sites: bugc: CSE gathers an inlined call site's source range onto an instruction that other paths run #385.{}: bugc: the optimizer's fixpoint loop compares JSON that leaves out every block #386. The cases here need no more than two rounds; I checked the unused-result case against a DCE without the move, and it fails.inlineSitesandoriginfrom the merged instructions (older than this PR): bugc: read/write merging drops inlineSites and origin from the merged instructions #387.callOriginto the instruction that carries an inlined invoke and keeps it withwithPlacement. When the two meet,placeActivationsmust carrycallOriginwith a moved invoke, as it carriesreturnedwith a moved return, and CSE's merge must not drop it.inlining.tskeeps its ownwithoutActivationsDebugrather than the shared one, because bugc: list the caller's locals at the entry of inlined code as at the call #380 changes the lines that call it. That can go after bugc: list the caller's locals at the entry of inlined code as at the call #380 merges.This touches
ir/utils/debug.ts,inlining.tsand the changelog, as #380 does. The code merges cleanly; the changelog entries conflict only by position.