Skip to content

bugc: keep inlined invoke and return markers on their path - #384

Merged
gnidan merged 5 commits into
mainfrom
bugc-invoke-merge
Oct 15, 2026
Merged

gnidan merged 5 commits into
mainfrom
bugc-invoke-merge

Conversation

@gnidan

@gnidan gnidan commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

An inlined call's invoke and return are 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:

  • CSE folded the body's first instruction into an equal, dominating one and gathered the invoke onto it. In a dispatch where each branch's function starts by reading 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: join and setMotd at -O2).
  • DCE dropped a dead instruction with its marker (g();, where g's body ends in an unused msg.value, lost g's return).
  • Block merging (-O3) dropped the markers on the jump between the merged blocks.
  • Jump optimization dropped the markers on the jump of an empty block it skipped (bugc: jump optimization drops an inlined function's invoke or return #382).
  • Read/write merging (-O3) put the merged writes' markers on every instruction of the merge.

Debug information must not change the code, so every pass still does exactly what it did; only the markers move. Ir.Utils.placeActivations puts 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:

  • for an instruction CSE or DCE removes (Ir.Utils.dropInstructions): the instruction kept before it or the block's entry, and the instruction kept after it or the block's jump;
  • for the jump block merging removes: the first block's last instruction (or entry), and the second's first instruction (or jump);
  • for a block jump optimization skips: the jump into it, if it is a plain jump, and the entry of the block it leads to, if nothing else reaches that block;
  • for merged writes: the first and the last instruction of the merge.

A block's entry is its JUMPDEST. The markers for it are kept in a new Block.entryActivations, which code generation adds to the JUMPDEST's context (entryDebug is 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's JUMPDEST. 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 main for 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 from main.

evmgen/activation-pairing.test.ts runs 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. On main, 21 of them fail. ir/utils/debug.test.ts covers the placement rules.

Closes #382.

Not here:

This touches ir/utils/debug.ts, inlining.ts and the changelog, as #380 does. The code merges cleanly; the changelog entries conflict only by position.

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-15 01:52 UTC

@gnidan gnidan changed the title bugc: keep an inlined invoke off instructions that CSE shares bugc: keep inlined invoke and return markers on their path Oct 15, 2026
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
gnidan force-pushed the bugc-invoke-merge branch from 2a3dc1e to fc47ae2 Compare October 15, 2026 01:07
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.
@gnidan
gnidan merged commit 82f010f into main Oct 15, 2026
16 checks passed
@gnidan
gnidan deleted the bugc-invoke-merge branch October 15, 2026 01:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bugc: jump optimization drops an inlined function's invoke or return

1 participant