From d4304986a8d70f3da545a02836119a0be4c386b0 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 14 Oct 2026 20:21:25 -0400 Subject: [PATCH 1/5] bugc: keep an eliminated instruction's invoke and return on its path 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. --- .../src/evmgen/activation-pairing.test.ts | 97 +++++++++++++++++++ packages/bugc/src/ir/utils/debug.ts | 38 ++++++++ .../steps/common-subexpression-elimination.ts | 34 ++++++- 3 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 packages/bugc/src/evmgen/activation-pairing.test.ts diff --git a/packages/bugc/src/evmgen/activation-pairing.test.ts b/packages/bugc/src/evmgen/activation-pairing.test.ts new file mode 100644 index 0000000000..44049acf7c --- /dev/null +++ b/packages/bugc/src/evmgen/activation-pairing.test.ts @@ -0,0 +1,97 @@ +/** + * Every call a run enters, it leaves: replaying a trace's invoke and + * return contexts, each `return` closes the innermost open `invoke` + * of the same function, and no call is open when the run ends. + * + * An invoke on an instruction that runs off the call's path (one the + * optimizer shares between paths, or hoists above the branch) opens + * a call that never returns. + */ +import { describe, it, expect } from "vitest"; +import { Program } from "@ethdebug/format"; +import type * as Format from "@ethdebug/format"; +import { traceLocals, type Level } from "#test/evm/locals"; + +const { Context } = Program; + +/** A context's invoke and return identifiers, reaching into gathers */ +function activations(context: Format.Program.Context | undefined): { + invokes: { identifier: string; real: boolean }[]; + returns: string[]; +} { + const invokes: { identifier: string; real: boolean }[] = []; + const returns: string[] = []; + const visit = (c: Format.Program.Context) => { + if (Context.isInvoke(c)) { + invokes.push({ + identifier: c.invoke.identifier ?? "", + real: "target" in c.invoke, + }); + } + if (Context.isReturn(c)) returns.push(c.return.identifier ?? ""); + if (Context.isGather(c)) c.gather.forEach(visit); + }; + if (context) visit(context); + return { invokes, returns }; +} + +/** The calls a run enters, and those still open at its end, failing + * on a return that does not close the innermost open call */ +async function runCalls(source: string, level: Level, calldata = "") { + const { steps, instructionAt } = await traceLocals(source, level, calldata); + const frames: { name: string; step: number; real: boolean }[] = []; + const calls: string[] = []; + steps.forEach((step, i) => { + const { invokes, returns } = activations(instructionAt(step)?.context); + for (const { identifier, real } of invokes) { + // A real call's JUMP and its callee's entry JUMPDEST both + // carry its invoke, on consecutive steps: count it once + const top = frames.at(-1); + if (real && top?.real && top.name === identifier && top.step === i - 1) { + top.step = i; + continue; + } + frames.push({ name: identifier, step: i, real }); + calls.push(identifier); + } + for (const identifier of returns) { + expect(frames.pop()?.name, `return at pc ${step.pc}`).toBe(identifier); + } + }); + return { calls, open: frames.map(({ name }) => name) }; +} + +const selector = (n: number) => n.toString(16).padStart(8, "0"); + +// Two functions run by a dispatch, each on its own path. Both start +// by reading the call's data, as the dispatch does before it branches. +const dispatch = `name Dispatch; +define { + function a() { r = msg.data[4:36] as bytes32 as uint256; }; + function b() { s = msg.data[4:36] as bytes32 as uint256 + 1; }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { r = 0; } +code { + if (msg.data[0:4] as bytes4 == 0x00000001) { a(); return; } + if (msg.data[0:4] as bytes4 == 0x00000002) { b(); return; } +}`; + +const levels: Level[] = [0, 1, 2, 3]; + +describe("every invoke is matched by its return", () => { + for (const level of levels) { + for (const [call, called] of [ + [1, ["a"]], + [2, ["b"]], + [3, []], + ] as const) { + it(`dispatch, selector ${call}, at O${level}`, async () => { + const data = selector(call) + "0".repeat(63) + "7"; + const { calls, open } = await runCalls(dispatch, level, data); + expect(calls).toEqual(called); + expect(open).toEqual([]); + }); + } + } +}); diff --git a/packages/bugc/src/ir/utils/debug.ts b/packages/bugc/src/ir/utils/debug.ts index aaa0114363..cc94b981e3 100644 --- a/packages/bugc/src/ir/utils/debug.ts +++ b/packages/bugc/src/ir/utils/debug.ts @@ -540,3 +540,41 @@ export function withActivations( : { ...rest, gather: markers } ) as Format.Program.Context; } + +/** A debug without its `invoke` and `return` discriminators */ +export function withoutActivationsDebug( + debug: Ir.Instruction.Debug | undefined, +): Ir.Instruction.Debug { + const { context: _, ...rest } = debug ?? {}; + const context = withoutActivations(debug?.context); + return { ...rest, ...(context ? { context } : {}) }; +} + +/** + * The debug of the instruction that runs next on a removed + * instruction's path (`onto`), with the removed one's `invoke` and + * `return` discriminators moved onto it. An invoke or a return marks + * a point on one path: it must not go with a removed instruction, or + * onto one that runs elsewhere. Its calls open before `onto`'s own and + * close before them; if it returns, `onto` lists the caller's + * variables once returned (unless `onto` returns, too). + */ +export function withActivationsOf( + removed: Ir.Instruction.Debug | undefined, + onto: Ir.Instruction.Debug | undefined, +): Ir.Instruction.Debug { + const invokes = activationsOf(removed?.context, "invoke"); + const returns = activationsOf(removed?.context, "return"); + if (invokes.length === 0 && returns.length === 0) return onto ?? {}; + const own = onto?.context; + const context = withActivations(withoutActivations(own), { + invokes: [...invokes, ...activationsOf(own, "invoke")], + returns: [...returns, ...activationsOf(own, "return")], + }); + const returned = onto?.returned ?? removed?.returned; + return { + ...onto, + ...(context ? { context } : {}), + ...(returns.length > 0 && returned ? { returned } : {}), + }; +} diff --git a/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts b/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts index 84c0f27878..f6e8acbe5a 100644 --- a/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts +++ b/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts @@ -42,6 +42,19 @@ export class CommonSubexpressionEliminationStep extends BaseOptimizationStep { // (cleared on side effects) const expressions = new Map(); const newInstructions: Ir.Instruction[] = []; + // The debug of eliminated instructions whose invoke or return + // waits for the next instruction kept on their path + let removed: Ir.Instruction.Debug | undefined; + const keep = (inst: Ir.Instruction) => { + if (removed) { + inst.operationDebug = Ir.Utils.withActivationsOf( + removed, + inst.operationDebug, + ); + removed = undefined; + } + newInstructions.push(inst); + }; for (const inst of block.instructions) { // Apply any replacements to this instruction @@ -87,9 +100,16 @@ export class CommonSubexpressionEliminationStep extends BaseOptimizationStep { "dest" in processedInst && "dest" in existingInst ) { - // This is a duplicate - combine debug contexts + // This is a duplicate - combine debug contexts. The + // existing instruction may run on other paths, too (it + // dominates this one): an invoke or return of this one + // stays on this path, with the next instruction kept existingInst.operationDebug = Ir.Utils.combineDebugContexts( existingInst.operationDebug, + Ir.Utils.withoutActivationsDebug(processedInst.operationDebug), + ); + removed = Ir.Utils.withActivationsOf( + removed, processedInst.operationDebug, ); @@ -115,19 +135,25 @@ export class CommonSubexpressionEliminationStep extends BaseOptimizationStep { }); } } - newInstructions.push(processedInst); + keep(processedInst); } } else if (this.hasSideEffects(processedInst)) { // Instructions with side effects invalidate our expression // tracking expressions.clear(); - newInstructions.push(processedInst); + keep(processedInst); } else { - newInstructions.push(processedInst); + keep(processedInst); } } block.instructions = newInstructions; + if (removed) { + block.terminator.operationDebug = Ir.Utils.withActivationsOf( + removed, + block.terminator.operationDebug, + ); + } } // Now apply replacements to phi nodes and terminators in a second From 30746a6a599bfb63873a809796f5fcc28aa1599e Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 14 Oct 2026 20:21:47 -0400 Subject: [PATCH 2/5] bugc: add a changelog entry for the inlined invoke CSE kept --- packages/bugc/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index c52fc18c42..aa78be928b 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -59,6 +59,11 @@ support. Changes to the specification itself are tracked in the root - `push` of bytes in calldata onto an `array` in storage now stores the bytes. Before, it stored the calldata reference's word (offset and length) ([#379]). +- At optimization level 2 and up, an inlined function's `invoke` stays + on its own path. Common subexpression elimination folded the inlined + body's first instruction into an equal one before the branch (as a + read of `msg.data` in a dispatch), with its invoke, so every run + invoked the function and never returned ([#384]). - A local defined before a call keeps its pointer after the call. The dominator tree that local-variable tracking uses left out a call's edge to its continuation, so every local defined before a call @@ -539,3 +544,4 @@ First publication. [#378]: https://github.com/ethdebug/format/pull/378 [#379]: https://github.com/ethdebug/format/pull/379 [#383]: https://github.com/ethdebug/format/pull/383 +[#384]: https://github.com/ethdebug/format/pull/384 From fc47ae273484e779fe0adb9202cb21c62df25658 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 14 Oct 2026 20:53:53 -0400 Subject: [PATCH 3/5] bugc: move a removed carrier's invoke and return onto its path, in order 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. --- packages/bugc/CHANGELOG.md | 17 +- .../src/evmgen/activation-pairing.test.ts | 150 ++++++++++++++++- packages/bugc/src/evmgen/generation/block.ts | 4 +- packages/bugc/src/ir/utils/debug.test.ts | 78 +++++++++ packages/bugc/src/ir/utils/debug.ts | 154 +++++++++++++++--- .../bugc/src/optimizer/steps/block-merging.ts | 41 +++++ .../steps/common-subexpression-elimination.ts | 37 +---- .../optimizer/steps/constant-propagation.ts | 7 +- .../optimizer/steps/dead-code-elimination.ts | 15 +- .../src/optimizer/steps/jump-optimization.ts | 53 +++++- .../src/optimizer/steps/read-write-merging.ts | 40 ++++- 11 files changed, 527 insertions(+), 69 deletions(-) create mode 100644 packages/bugc/src/ir/utils/debug.test.ts diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index aa78be928b..eb0c56a78d 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -59,11 +59,18 @@ support. Changes to the specification itself are tracked in the root - `push` of bytes in calldata onto an `array` in storage now stores the bytes. Before, it stored the calldata reference's word (offset and length) ([#379]). -- At optimization level 2 and up, an inlined function's `invoke` stays - on its own path. Common subexpression elimination folded the inlined - body's first instruction into an equal one before the branch (as a - read of `msg.data` in a dispatch), with its invoke, so every run - invoked the function and never returned ([#384]). +- At optimization level 2 and up, every inlined call's `invoke` and + `return` stay on that call's path, in order. A pass that removes or + merges the instruction (or jump) that carries one now moves it onto + a neighbour on the same path: the invoke onto the next instruction, + the return onto the one before. Before, common subexpression + elimination put an invoke on an equal instruction that also runs on + other paths (as a read of `msg.data` before a dispatch's branches), + so every run invoked the function and never returned. Dead code + elimination, block merging and jump optimization lost the marker + with what they removed, and merged writes repeated it. Jump + optimization keeps an empty block whose jump carries a marker it + cannot move ([#384]). - A local defined before a call keeps its pointer after the call. The dominator tree that local-variable tracking uses left out a call's edge to its continuation, so every local defined before a call diff --git a/packages/bugc/src/evmgen/activation-pairing.test.ts b/packages/bugc/src/evmgen/activation-pairing.test.ts index 44049acf7c..dfada6937c 100644 --- a/packages/bugc/src/evmgen/activation-pairing.test.ts +++ b/packages/bugc/src/evmgen/activation-pairing.test.ts @@ -3,9 +3,11 @@ * return contexts, each `return` closes the innermost open `invoke` * of the same function, and no call is open when the run ends. * - * An invoke on an instruction that runs off the call's path (one the - * optimizer shares between paths, or hoists above the branch) opens - * a call that never returns. + * An optimizer pass that removes or merges the instruction (or the + * jump) that carries an invoke or a return must leave the marker on + * that call's path, in order: on an instruction that also runs off the + * path (one shared with another path), it opens a call that never + * returns; lost with the instruction, it leaves the other unmatched. */ import { describe, it, expect } from "vitest"; import { Program } from "@ethdebug/format"; @@ -64,7 +66,8 @@ async function runCalls(source: string, level: Level, calldata = "") { const selector = (n: number) => n.toString(16).padStart(8, "0"); // Two functions run by a dispatch, each on its own path. Both start -// by reading the call's data, as the dispatch does before it branches. +// by reading the call's data, as the dispatch does before it branches: +// that read goes, and with it the instruction that carried the invoke. const dispatch = `name Dispatch; define { function a() { r = msg.data[4:36] as bytes32 as uint256; }; @@ -94,4 +97,143 @@ describe("every invoke is matched by its return", () => { }); } } + + // Each program, and the calls its run enters + const programs: { name: string; source: string; calls: string[] }[] = [ + { + // The body's last instruction goes (the caller read msg.sender + // already): its return moves back onto the body's write + name: "a body whose last instruction goes", + source: `name MovedReturn; +define { + function f(n: uint256) -> address { r = n; return msg.sender; }; + function g(n: uint256) { s = n + r; }; +} +storage { [0] r: uint256; [1] s: uint256; [2] w: address; } +create { r = 0; } +code { let a = msg.sender; w = f(3); g(4); w = a; }`, + calls: ["f", "g"], + }, + { + // The body's one instruction goes, and its block is left empty + name: "a body that goes whole", + source: `name Emptied; +define { function who() -> address { return msg.sender; }; } +storage { [0] w: address; [1] v: address; [2] u: address; } +create { w = msg.sender; } +code { let a = msg.sender; w = who(); v = who(); u = a; }`, + calls: ["who", "who"], + }, + { + // At O3, f's body (one instruction, which goes) and g's share a + // block: f's call must not end after g's begins + name: "calls in sequence", + source: `name Sequence; +define { + function f(n: uint256) -> uint256 { return n + 1; }; + function g(m: uint256) { s = m; }; +} +storage { [0] r: uint256; [1] s: uint256; [2] u: uint256; [3] a: uint256; } +create { a = 4; } +code { let b = a; if (b > 0) { let x = b + 1; g(f(b)); u = x; } }`, + calls: ["f", "g"], + }, + { + name: "nested calls", + source: `name Nested; +define { + function inner() -> uint256 { + return msg.data[4:36] as bytes32 as uint256; + }; + function outer() -> uint256 { return inner() + 1; }; +} +storage { [0] r: uint256; } +create { r = 0; } +code { if (msg.data[0:4] as bytes4 == 0x00000001) { r = outer(); } }`, + calls: ["outer", "inner"], + }, + { + name: "a call in a loop", + source: `name Loop; +define { function who() -> address { r = r + 1; return msg.sender; }; } +storage { [0] r: uint256; [1] w: address; } +create { r = 0; } +code { + let a = msg.sender; + for (let i = 0; i < 3; i = i + 1) { w = who(); } + w = a; +}`, + calls: ["who", "who", "who"], + }, + { + // The body's last instruction is dead (its result is unused) + name: "a call whose result is unused", + source: `name Unused; +define { function g() -> uint256 { r = r + 1; return msg.value; }; } +storage { [0] r: uint256; } +create { r = 0; } +code { g(); }`, + calls: ["g"], + }, + { + // At O3, the body's two writes merge into one + name: "a body whose writes merge", + source: `name Merged; +define { + struct P { a: uint64; b: uint64; }; + function set() { p.a = 1; p.b = 2; }; +} +storage { [0] p: P; [1] r: uint256; } +create { r = 0; } +code { set(); r = 9; }`, + calls: ["set"], + }, + { + // Issue #382: the body's entry block is empty, and it jumps to a + // loop header + name: "a body that starts with a loop", + source: `name LoopFirst; +define { + function f(n: uint256) -> uint256 { + for (let i = n; i > 5; i = i - 1) { + r = r + 1; + } + return n + 1; + }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 9; } +code { let v = s; r = f(v) + v; }`, + calls: ["f"], + }, + { + // Issue #382: the loop's exit block is empty + name: "a body that ends with a loop", + source: `name LoopLast; +define { + function f(n: uint256) -> uint256 { + let acc = 0; + for (let i = 0; i < n; i = i + 1) { + acc = acc + i; + } + return acc; + }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 3; } +code { r = f(s); }`, + calls: ["f"], + }, + ]; + + for (const level of levels) { + for (const { name, source, calls: called } of programs) { + it(`${name}, at O${level}`, async () => { + const data = selector(1) + "0".repeat(63) + "7"; + const { calls, open } = await runCalls(source, level, data); + expect(calls).toEqual(called); + expect(open).toEqual([]); + }); + } + } }); diff --git a/packages/bugc/src/evmgen/generation/block.ts b/packages/bugc/src/evmgen/generation/block.ts index 24ef450170..b6c507d237 100644 --- a/packages/bugc/src/evmgen/generation/block.ts +++ b/packages/bugc/src/evmgen/generation/block.ts @@ -211,11 +211,13 @@ export function generate( if (func && block.terminator.kind === "jump") { const target = func.blocks.get(block.terminator.target); if (target && target.phis.length > 0) { + // The copies run before the jump: an invoke or return the + // jump carries rides the jump alone result = result.then( generatePhis( target.phis, block.id, - block.terminator.operationDebug, + Ir.Utils.withoutActivationsDebug(block.terminator.operationDebug), ), ); } diff --git a/packages/bugc/src/ir/utils/debug.test.ts b/packages/bugc/src/ir/utils/debug.test.ts new file mode 100644 index 0000000000..997bff5425 --- /dev/null +++ b/packages/bugc/src/ir/utils/debug.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import type * as Ir from "#ir"; +import { activationsOf, moveActivations } from "./debug.js"; + +const invoke = (identifier: string) => ({ invoke: { identifier } }); +const ret = (identifier: string) => ({ return: { identifier } }); +const debug = (...markers: object[]): Ir.Instruction.Debug => ({ + context: (markers.length === 1 + ? markers[0] + : { gather: markers }) as Ir.Instruction.Debug["context"], +}); +const markers = (d: Ir.Instruction.Debug | undefined) => [ + ...activationsOf(d?.context, "invoke").map( + (v) => `invoke ${(v as { identifier: string }).identifier}`, + ), + ...activationsOf(d?.context, "return").map( + (v) => `return ${(v as { identifier: string }).identifier}`, + ), +]; + +describe("moveActivations", () => { + it("moves an invoke onto the instruction after", () => { + const moved = moveActivations(debug(invoke("f")), { + before: { context: { code: {} } as never }, + after: debug(invoke("g")), + }); + expect(markers(moved?.before)).toEqual([]); + expect(markers(moved?.after)).toEqual(["invoke f", "invoke g"]); + }); + + it("moves a return onto the instruction before", () => { + const moved = moveActivations(debug(ret("f")), { + before: debug(invoke("f")), + after: debug(invoke("g")), + }); + expect(markers(moved?.before)).toEqual(["invoke f", "return f"]); + expect(markers(moved?.after)).toEqual(["invoke g"]); + }); + + it("never puts a return after an invoke it came before", () => { + // Only the instruction after is there, and it opens a call: the + // return cannot go on it + expect( + moveActivations(debug(ret("f")), { after: debug(invoke("g")) }), + ).toBeUndefined(); + // An invoke cannot follow a return on the instruction before + expect( + moveActivations(debug(invoke("g")), { before: debug(ret("f")) }), + ).toBeUndefined(); + }); + + it("keeps a call wholly on the removed instruction whole", () => { + const moved = moveActivations(debug(invoke("f"), ret("f")), { + before: debug(ret("e")), + after: { context: { code: {} } as never }, + }); + expect(markers(moved?.before)).toEqual(["return e"]); + expect(markers(moved?.after)).toEqual(["invoke f", "return f"]); + }); + + it("drops a call wholly on the removed instruction if it must", () => { + const moved = moveActivations(debug(invoke("f"), ret("f")), { + before: debug(ret("e")), + after: debug(invoke("g")), + }); + expect(markers(moved?.before)).toEqual(["return e"]); + expect(markers(moved?.after)).toEqual(["invoke g"]); + }); + + it("carries `returned` with a moved return", () => { + const returned = { context: { code: {} } as never }; + const moved = moveActivations( + { ...debug(ret("f")), returned }, + { before: debug(invoke("f")) }, + ); + expect(moved?.before?.returned).toBe(returned); + }); +}); diff --git a/packages/bugc/src/ir/utils/debug.ts b/packages/bugc/src/ir/utils/debug.ts index cc94b981e3..d2569da8da 100644 --- a/packages/bugc/src/ir/utils/debug.ts +++ b/packages/bugc/src/ir/utils/debug.ts @@ -551,30 +551,142 @@ export function withoutActivationsDebug( } /** - * The debug of the instruction that runs next on a removed - * instruction's path (`onto`), with the removed one's `invoke` and - * `return` discriminators moved onto it. An invoke or a return marks - * a point on one path: it must not go with a removed instruction, or - * onto one that runs elsewhere. Its calls open before `onto`'s own and - * close before them; if it returns, `onto` lists the caller's - * variables once returned (unless `onto` returns, too). + * Where a removed instruction's (or jump's) `invoke` and `return` + * discriminators go: onto a neighbour on its own path, the one that + * ran just `before` it or the one that runs just `after` it. + * + * An invoke or a return marks a point on one path: it must not go + * with the removed instruction, or onto one that runs elsewhere. A + * debug lists its invokes, then its returns, so the markers move only + * where that order holds: before's markers, then the removed one's, or + * the removed one's, then after's. Returns go before (the last + * instruction kept in the body ends it) and invokes after (the first + * one starts it), where they can. + * + * If neither neighbour can take them, the calls that open and close + * on the removed instruction (their bodies optimized away) go with + * it; otherwise, undefined: the instruction stays. */ -export function withActivationsOf( +export function moveActivations( removed: Ir.Instruction.Debug | undefined, - onto: Ir.Instruction.Debug | undefined, -): Ir.Instruction.Debug { + { + before, + after, + }: { before?: Ir.Instruction.Debug; after?: Ir.Instruction.Debug }, +): { before?: Ir.Instruction.Debug; after?: Ir.Instruction.Debug } | undefined { const invokes = activationsOf(removed?.context, "invoke"); const returns = activationsOf(removed?.context, "return"); - if (invokes.length === 0 && returns.length === 0) return onto ?? {}; - const own = onto?.context; - const context = withActivations(withoutActivations(own), { - invokes: [...invokes, ...activationsOf(own, "invoke")], - returns: [...returns, ...activationsOf(own, "return")], - }); - const returned = onto?.returned ?? removed?.returned; - return { - ...onto, - ...(context ? { context } : {}), - ...(returns.length > 0 && returned ? { returned } : {}), + if (invokes.length === 0 && returns.length === 0) return { before, after }; + + const onto = ( + debug: Ir.Instruction.Debug, + { first }: { first: boolean }, + ): Ir.Instruction.Debug | undefined => { + const own = { + invokes: activationsOf(debug.context, "invoke"), + returns: activationsOf(debug.context, "return"), + }; + // The markers in order must read as invokes, then returns + const [earlier, later] = first + ? [{ invokes, returns }, own] + : [own, { invokes, returns }]; + if (earlier.returns.length > 0 && later.invokes.length > 0) { + return undefined; + } + const context = withActivations(withoutActivations(debug.context), { + invokes: [...earlier.invokes, ...later.invokes], + returns: [...earlier.returns, ...later.returns], + }); + // The instruction that returns last lists the caller's variables + const returned = + (later.returns.length > 0 ? (first ? debug : removed) : undefined) + ?.returned ?? + (earlier.returns.length > 0 ? (first ? removed : debug) : undefined) + ?.returned; + const { context: _, returned: __, ...rest } = debug; + return { + ...rest, + ...(context ? { context } : {}), + ...(returned ? { returned } : {}), + }; }; + + const toBefore = before && onto(before, { first: false }); + const toAfter = after && onto(after, { first: true }); + const preferBefore = returns.length > invokes.length; + if (toBefore && (preferBefore || !toAfter)) + return { before: toBefore, after }; + if (toAfter) return { before, after: toAfter }; + // A call wholly on the removed instruction (its body is gone) goes + // with it + if (invokes.length === returns.length) return { before, after }; + return undefined; +} + +/** + * A block's instructions but those in `drop`, with each dropped one's + * `invoke` and `return` moved onto a neighbour on its path (see + * `moveActivations`): an instruction kept in the block, or the block's + * jump. A dropped instruction whose markers no neighbour can take is + * kept. The kept instructions and the jump get new debugs. + */ +export function dropInstructions( + block: Ir.Block, + drop: Set, +): Ir.Instruction[] { + const kept: Ir.Instruction[] = []; + // A dropped instruction whose markers wait for the next one kept + let pending: Ir.Instruction | undefined; + const settle = (after: Ir.Instruction.Debug | undefined) => { + if (!pending) return after; + const last = kept.at(-1); + const moved = moveActivations(pending.operationDebug, { + before: last?.operationDebug, + after, + }); + if (!moved) { + kept.push(pending); + pending = undefined; + return after; + } + if (last && moved.before !== last.operationDebug) { + kept[kept.length - 1] = { ...last, operationDebug: moved.before! }; + } + pending = undefined; + return moved.after; + }; + + for (const inst of block.instructions) { + if (!drop.has(inst)) { + const debug = settle(inst.operationDebug); + kept.push( + debug === inst.operationDebug + ? inst + : ({ ...inst, operationDebug: debug } as Ir.Instruction), + ); + continue; + } + if (!carriesActivations(inst.operationDebug)) continue; + settle(undefined); + pending = inst; + } + + const { terminator } = block; + const jump = + terminator.kind === "jump" && !terminator.tailCall ? terminator : undefined; + const debug = settle(jump?.operationDebug); + if (jump && debug !== jump.operationDebug) { + block.terminator = { ...jump, operationDebug: debug ?? {} }; + } + return kept; +} + +/** Whether a debug has an `invoke` or a `return` discriminator */ +export function carriesActivations( + debug: Ir.Instruction.Debug | undefined, +): boolean { + return ( + activationsOf(debug?.context, "invoke").length > 0 || + activationsOf(debug?.context, "return").length > 0 + ); } diff --git a/packages/bugc/src/optimizer/steps/block-merging.ts b/packages/bugc/src/optimizer/steps/block-merging.ts index 9eb7ae1674..122e3187af 100644 --- a/packages/bugc/src/optimizer/steps/block-merging.ts +++ b/packages/bugc/src/optimizer/steps/block-merging.ts @@ -33,6 +33,12 @@ export class BlockMergingStep extends BaseOptimizationStep { if (!sourceBlock || !targetBlock) continue; + // The jump between them goes: its invoke or return moves onto + // the instruction before it or the one after it (or the jump + // the merged block ends with). The blocks stay apart if + // neither can take them. + if (!moveJumpActivations(targetBlock, sourceBlock)) continue; + // Merge phi nodes from source to target if (sourceBlock.phis && targetBlock.phis) { targetBlock.phis.push(...sourceBlock.phis); @@ -98,6 +104,41 @@ export class BlockMergingStep extends BaseOptimizationStep { } } +/** + * Move the `invoke` or `return` of the jump from `from` to `to` onto a + * neighbour (see `Ir.Utils.moveActivations`). False if none takes them. + */ +function moveJumpActivations(from: Ir.Block, to: Ir.Block): boolean { + const debug = from.terminator.operationDebug; + if (!Ir.Utils.carriesActivations(debug)) return true; + const last = from.instructions.at(-1); + const first = to.instructions[0]; + const jump = + !first && to.terminator.kind === "jump" && !to.terminator.tailCall + ? to.terminator + : undefined; + const moved = Ir.Utils.moveActivations(debug, { + before: last?.operationDebug, + after: (first ?? jump)?.operationDebug, + }); + if (!moved) return false; + if (last && moved.before) { + from.instructions[from.instructions.length - 1] = { + ...last, + operationDebug: moved.before, + } as Ir.Instruction; + } + if (first && moved.after) { + to.instructions[0] = { + ...first, + operationDebug: moved.after, + } as Ir.Instruction; + } else if (jump && moved.after) { + to.terminator = { ...jump, operationDebug: moved.after }; + } + return true; +} + function renameKey(map: Map, from: string, to: string): void { if (!map.has(from)) return; map.set(to, map.get(from)!); diff --git a/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts b/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts index f6e8acbe5a..b8915a1692 100644 --- a/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts +++ b/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts @@ -42,19 +42,7 @@ export class CommonSubexpressionEliminationStep extends BaseOptimizationStep { // (cleared on side effects) const expressions = new Map(); const newInstructions: Ir.Instruction[] = []; - // The debug of eliminated instructions whose invoke or return - // waits for the next instruction kept on their path - let removed: Ir.Instruction.Debug | undefined; - const keep = (inst: Ir.Instruction) => { - if (removed) { - inst.operationDebug = Ir.Utils.withActivationsOf( - removed, - inst.operationDebug, - ); - removed = undefined; - } - newInstructions.push(inst); - }; + const eliminated = new Set(); for (const inst of block.instructions) { // Apply any replacements to this instruction @@ -102,16 +90,12 @@ export class CommonSubexpressionEliminationStep extends BaseOptimizationStep { ) { // This is a duplicate - combine debug contexts. The // existing instruction may run on other paths, too (it - // dominates this one): an invoke or return of this one - // stays on this path, with the next instruction kept + // dominates this one), so this one's invoke or return + // stays on this path (see below) existingInst.operationDebug = Ir.Utils.combineDebugContexts( existingInst.operationDebug, Ir.Utils.withoutActivationsDebug(processedInst.operationDebug), ); - removed = Ir.Utils.withActivationsOf( - removed, - processedInst.operationDebug, - ); // Map this temp to the existing one globalReplacements.set(processedInst.dest, existingInst.dest); @@ -124,6 +108,8 @@ export class CommonSubexpressionEliminationStep extends BaseOptimizationStep { reason: `Eliminated duplicate computation: ${exprKey}`, }); // Don't emit this instruction + eliminated.add(processedInst); + newInstructions.push(processedInst); } else { // First time seeing this expression if ("dest" in processedInst && exprKey) { @@ -135,25 +121,20 @@ export class CommonSubexpressionEliminationStep extends BaseOptimizationStep { }); } } - keep(processedInst); + newInstructions.push(processedInst); } } else if (this.hasSideEffects(processedInst)) { // Instructions with side effects invalidate our expression // tracking expressions.clear(); - keep(processedInst); + newInstructions.push(processedInst); } else { - keep(processedInst); + newInstructions.push(processedInst); } } block.instructions = newInstructions; - if (removed) { - block.terminator.operationDebug = Ir.Utils.withActivationsOf( - removed, - block.terminator.operationDebug, - ); - } + block.instructions = Ir.Utils.dropInstructions(block, eliminated); } // Now apply replacements to phi nodes and terminators in a second diff --git a/packages/bugc/src/optimizer/steps/constant-propagation.ts b/packages/bugc/src/optimizer/steps/constant-propagation.ts index 86bfa6577f..64a5287a28 100644 --- a/packages/bugc/src/optimizer/steps/constant-propagation.ts +++ b/packages/bugc/src/optimizer/steps/constant-propagation.ts @@ -31,8 +31,13 @@ export class ConstantPropagationStep extends BaseOptimizationStep { inst.type.kind !== "ref" ) { constants.set(inst.dest, inst.value); + // Its uses may run elsewhere: its invoke or return stays + // with it if (inst.operationDebug) { - constantDebug.set(inst.dest, inst.operationDebug); + constantDebug.set( + inst.dest, + Ir.Utils.withoutActivationsDebug(inst.operationDebug), + ); } } else { // Try to propagate constants into instruction operands diff --git a/packages/bugc/src/optimizer/steps/dead-code-elimination.ts b/packages/bugc/src/optimizer/steps/dead-code-elimination.ts index 5c8865b5f0..147d4f86a9 100644 --- a/packages/bugc/src/optimizer/steps/dead-code-elimination.ts +++ b/packages/bugc/src/optimizer/steps/dead-code-elimination.ts @@ -65,12 +65,12 @@ export class DeadCodeEliminationStep extends BaseOptimizationStep { } // Remove dead instructions - const newInstructions: Ir.Instruction[] = []; + const dead = new Set(); for (const inst of block.instructions) { - if (this.hasSideEffects(inst)) { - newInstructions.push(inst); // Keep instructions with side effects - } else if ( + // Instructions with side effects are kept + if ( + !this.hasSideEffects(inst) && "dest" in inst && inst.dest && !usedValues.has(inst.dest) @@ -83,12 +83,13 @@ export class DeadCodeEliminationStep extends BaseOptimizationStep { result: [], reason: `Removed unused instruction: ${inst.kind} -> ${inst.dest}`, }); - } else { - newInstructions.push(inst); + dead.add(inst); } } - block.instructions = newInstructions; + // An invoke or return of a dead instruction moves onto a + // neighbour on its path + block.instructions = Ir.Utils.dropInstructions(block, dead); } }); diff --git a/packages/bugc/src/optimizer/steps/jump-optimization.ts b/packages/bugc/src/optimizer/steps/jump-optimization.ts index 22a100a026..662caea24a 100644 --- a/packages/bugc/src/optimizer/steps/jump-optimization.ts +++ b/packages/bugc/src/optimizer/steps/jump-optimization.ts @@ -19,7 +19,8 @@ export class JumpOptimizationStep extends BaseOptimizationStep { if ( block.instructions.length === 0 && block.phis.length === 0 && - block.terminator.kind === "jump" + block.terminator.kind === "jump" && + this.passActivations(func, blockId) ) { jumpTargets.set(blockId, block.terminator.target); } @@ -139,6 +140,56 @@ export class JumpOptimizationStep extends BaseOptimizationStep { return optimized; } + /** + * Whether a jump-only block may be skipped with respect to the + * `invoke` or `return` its jump carries (an inlined body's, with no + * instruction to carry it): only if each block that reaches it does + * so by a plain jump, which runs only on that path, and takes the + * markers. The block is kept otherwise. A jump-only block takes + * none, as it may be skipped too: this one stays, for now. + */ + private passActivations(func: Ir.Function, blockId: string): boolean { + const block = func.blocks.get(blockId)!; + const debug = block.terminator.operationDebug; + if (!Ir.Utils.carriesActivations(debug)) return true; + if (blockId === func.entry) return false; + + const jumps: Ir.Block[] = []; + for (const [id, from] of func.blocks) { + const t = from.terminator; + const reaches = + (t.kind === "jump" && t.target === blockId) || + (t.kind === "branch" && + (t.trueTarget === blockId || t.falseTarget === blockId)) || + (t.kind === "call" && t.continuation === blockId); + if (!reaches) continue; + if (t.kind !== "jump" || t.tailCall) return false; + if ( + id !== func.entry && + from.instructions.length === 0 && + from.phis.length === 0 + ) { + return false; + } + jumps.push(from); + } + const moved = jumps.map( + (from) => + Ir.Utils.moveActivations(debug, { + before: from.terminator.operationDebug, + })?.before, + ); + if (jumps.length === 0 || moved.some((m) => !m)) return false; + jumps.forEach((from, i) => { + from.terminator = { ...from.terminator, operationDebug: moved[i]! }; + }); + block.terminator = { + ...block.terminator, + operationDebug: Ir.Utils.withoutActivationsDebug(debug), + }; + return true; + } + private resolveJumpChain( target: string, jumpTargets: Map, diff --git a/packages/bugc/src/optimizer/steps/read-write-merging.ts b/packages/bugc/src/optimizer/steps/read-write-merging.ts index 5bd8eff5df..87652e8a60 100644 --- a/packages/bugc/src/optimizer/steps/read-write-merging.ts +++ b/packages/bugc/src/optimizer/steps/read-write-merging.ts @@ -253,6 +253,27 @@ export class ReadWriteMergingStep extends BaseOptimizationStep { return writes; } + // The writes' invokes and returns go on the first and the last + // instruction of the merge, as on one instruction: they must read + // as invokes, then returns + const markers = writes.map(({ operationDebug }) => ({ + invokes: Ir.Utils.activationsOf(operationDebug?.context, "invoke"), + returns: Ir.Utils.activationsOf(operationDebug?.context, "return"), + returned: operationDebug?.returned, + })); + const firstReturn = markers.findIndex(({ returns }) => returns.length); + if ( + firstReturn >= 0 && + markers.slice(firstReturn + 1).some(({ invokes }) => invokes.length) + ) { + return writes; + } + const invokes = markers.flatMap(({ invokes }) => invokes); + const returns = markers.flatMap(({ returns }) => returns); + const returned = markers + .filter(({ returns }) => returns.length) + .at(-1)?.returned; + // Generate instructions to combine the values const instructions: Ir.Instruction[] = []; let tempCounter = this.nextTempCounter; @@ -286,7 +307,7 @@ export class ReadWriteMergingStep extends BaseOptimizationStep { // Track debug contexts as we process writes combinedDebug = Ir.Utils.combineDebugContexts( combinedDebug, - info.write.operationDebug, + Ir.Utils.withoutActivationsDebug(info.write.operationDebug), ); // Mask the value to its length, so that a sign-extended @@ -335,6 +356,23 @@ export class ReadWriteMergingStep extends BaseOptimizationStep { instructions.push(mergedWrite); + const first = instructions[0]; + first.operationDebug = { + ...first.operationDebug, + context: Ir.Utils.withActivations(first.operationDebug.context, { + invokes, + }), + }; + if (returns.length > 0) { + mergedWrite.operationDebug = { + ...mergedWrite.operationDebug, + context: Ir.Utils.withActivations(mergedWrite.operationDebug.context, { + returns, + }), + ...(returned ? { returned } : {}), + }; + } + // Update the temp counter for next time this.nextTempCounter = tempCounter; From 9421b8d17378c7d2d6376cd0dc19f8de31f23c1d Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 14 Oct 2026 21:31:01 -0400 Subject: [PATCH 4/5] bugc: never change the code for invoke and return markers 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. --- packages/bugc/CHANGELOG.md | 21 +- .../activation-pairing.test.ts.snap | 194 ++++++++++++ .../src/evmgen/activation-pairing.test.ts | 267 ++++++++++------- packages/bugc/src/evmgen/generation/block.ts | 64 +++- packages/bugc/src/ir/spec/block.ts | 6 + packages/bugc/src/ir/utils/debug.test.ts | 93 +++--- packages/bugc/src/ir/utils/debug.ts | 282 +++++++++++------- packages/bugc/src/optimizer/optimizer.ts | 22 ++ .../bugc/src/optimizer/steps/block-merging.ts | 87 ++++-- .../steps/common-subexpression-elimination.ts | 14 +- .../optimizer/steps/dead-code-elimination.ts | 11 +- packages/bugc/src/optimizer/steps/inlining.ts | 3 + .../src/optimizer/steps/jump-optimization.ts | 134 ++++++--- .../src/optimizer/steps/read-write-merging.ts | 74 ++--- 14 files changed, 896 insertions(+), 376 deletions(-) create mode 100644 packages/bugc/src/evmgen/__snapshots__/activation-pairing.test.ts.snap diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index eb0c56a78d..1709991b94 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -61,16 +61,17 @@ support. Changes to the specification itself are tracked in the root (offset and length) ([#379]). - At optimization level 2 and up, every inlined call's `invoke` and `return` stay on that call's path, in order. A pass that removes or - merges the instruction (or jump) that carries one now moves it onto - a neighbour on the same path: the invoke onto the next instruction, - the return onto the one before. Before, common subexpression - elimination put an invoke on an equal instruction that also runs on - other paths (as a read of `msg.data` before a dispatch's branches), - so every run invoked the function and never returned. Dead code - elimination, block merging and jump optimization lost the marker - with what they removed, and merged writes repeated it. Jump - optimization keeps an empty block whose jump carries a marker it - cannot move ([#384]). + merges what carries one (an instruction, a jump, an empty block) now + moves it onto a neighbour on the same path: an invoke onto the next + op, a return onto the one before, or a block's entry (its + `JUMPDEST`). Before, common subexpression elimination put an invoke + on an equal instruction that also runs on other paths (as a read of + `msg.data` before a dispatch's branches), so every run invoked the + function and never returned. Dead code elimination, block merging + and jump optimization lost the marker with what they removed, and + merged writes repeated it. A marker with no op on its path to carry + it is dropped: debug information does not change the code + ([#384]). - A local defined before a call keeps its pointer after the call. The dominator tree that local-variable tracking uses left out a call's edge to its continuation, so every local defined before a call diff --git a/packages/bugc/src/evmgen/__snapshots__/activation-pairing.test.ts.snap b/packages/bugc/src/evmgen/__snapshots__/activation-pairing.test.ts.snap new file mode 100644 index 0000000000..20a6969d6e --- /dev/null +++ b/packages/bugc/src/evmgen/__snapshots__/activation-pairing.test.ts.snap @@ -0,0 +1,194 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`the code does not depend on the markers > is the same for every example and program here 1`] = ` +{ + "a body that ends with a loop at O0": "6c524fd930d643d452d0dd582b44617cb8f41a36cefb940f16a246cec7669128", + "a body that ends with a loop at O1": "1e09ac19cc3e6f14e810c4f1a6e1458a03faf8f7c82e625eed14e51bdbf76941", + "a body that ends with a loop at O2": "ccda0a22a60134d9ac21b1d92528078fb9ad3975c6df6dc98d63e3d6dd8e53dd", + "a body that ends with a loop at O3": "b8e78c71d0fb4bf983474843793a06b0b3f823c685b291670fbf53a67c7b3b14", + "a body that goes whole at O0": "a4ed654661bf3793d1cada2cc75f181de49d84a624c7efb722dda971d3b07b9d", + "a body that goes whole at O1": "a4ed654661bf3793d1cada2cc75f181de49d84a624c7efb722dda971d3b07b9d", + "a body that goes whole at O2": "48c756f419a73c14813e657d3180eaf5e9dc765386218b6fd4bd5ac34d81ca9b", + "a body that goes whole at O3": "3f25654ae893c2e2990c86820d59e64642330a1f1b609ec98c1d0d6dd6ba0113", + "a body that starts with a loop at O0": "65fc62eeeb2000e42497e0b2f5180ae3e4de1ede3ebcfd2ad08a63c28cd2ab70", + "a body that starts with a loop at O1": "892b5a5331928f1e940d51e0acaedd0870e6a3c32c69a6a4b8e3aa1b186d8038", + "a body that starts with a loop at O2": "b312aa078f8ad6a78e2aab6f5acbd1bc850aecced786005c03715a44376c4e2f", + "a body that starts with a loop at O3": "93d81fdf0922105d6d8c1be1fa81d33e5e28a258a56b915e4def0c7a61e93da9", + "a body whose last instruction goes at O0": "50bfd57b2a5b8ae52ca973ca38f65de27a6a96fff088f3d970bedf2f46d0a989", + "a body whose last instruction goes at O1": "141a44c31088a82f5070db3f8187f9905249f76e690ed2cdb1e56b032c3c9db8", + "a body whose last instruction goes at O2": "fe93627f9fd6618d8f35f99f71f858d64e2b2058781da28d21c72032ee70fd4e", + "a body whose last instruction goes at O3": "f2aa3ff010f4eef6096f1b5578a7b89218154373136c644d66d728e7b176099a", + "a body whose writes merge at O0": "3cf5feecb4553a488cbfdf46c48a5b7130ab462a98f9425cab97721034332867", + "a body whose writes merge at O1": "bcd40171e13a8e5c6cbb425a5c54062cff8856fe708e5faec030165d1acbbc31", + "a body whose writes merge at O2": "6350e3f046e62d03446a6eac1512b37a85e88c8d4c4beffb019990c34934d07b", + "a body whose writes merge at O3": "1a23f0330f60e53882ad33d511da34a56325cc66a9098ff920075dfdd6bf81b6", + "a call in a loop at O0": "79460752360ff783f5c912973317620e8178d6c559e690274c61cd3940c87075", + "a call in a loop at O1": "23b3a1a91a8a1443be424e57a4d61241802a56988282037ed4eecc78784b68b0", + "a call in a loop at O2": "996bbf9a784aa4e03b0eea685d2c6fdae632036d7c6ef2f9b9f59fe3f8fedc1d", + "a call in a loop at O3": "dc13b3866593fd43d26bd4246c12e75a5878ea88cd3df4796cfc4d3ba64cd10e", + "a call whose result is unused at O0": "3968e24e3a35f3c140521dd78948b2e0b91479974906aaba96f67572046af491", + "a call whose result is unused at O1": "70f3818386f8763c8cf343feb2da3017aae588f47c42b8761f1e5b88576010af", + "a call whose result is unused at O2": "83d18d2aa8787d944285b1cd923134619d895f05949c1b4739c66d688865f855", + "a call whose result is unused at O3": "fd00715b3649bfb556f7fa9a5ea881612fa3b795bc745f16a89bb48bf1282e6b", + "advanced/memory-to-storage.bug at O0": "1c461952928bfc3a8081173c587c60181527aa3b5c8625f792961dcdbebfa36e", + "advanced/memory-to-storage.bug at O1": "0e8bfd484873eec80cfbbabbfca46ea338e6576d033fd6d4dcb6cdd44bbfe3d0", + "advanced/memory-to-storage.bug at O2": "0e8bfd484873eec80cfbbabbfca46ea338e6576d033fd6d4dcb6cdd44bbfe3d0", + "advanced/memory-to-storage.bug at O3": "0e8bfd484873eec80cfbbabbfca46ea338e6576d033fd6d4dcb6cdd44bbfe3d0", + "advanced/nested-arrays.bug at O0": "ce16d77d34f5253da834a5345331aa1d72b6d17a240f18be268cba87683d82ef", + "advanced/nested-arrays.bug at O1": "4c6879dd86b2e8c902c7fdd37eb7726374fa8ad63693dc75353d763b0c624b92", + "advanced/nested-arrays.bug at O2": "ade6afd443138a1e077aaa405419099d82587af37e58863b900ae7e9c4a835fb", + "advanced/nested-arrays.bug at O3": "ade6afd443138a1e077aaa405419099d82587af37e58863b900ae7e9c4a835fb", + "advanced/nested-mappings.bug at O0": "174af1f70704d7d0b25400f8376052418459ccc5ad8c162fd1978cc828c993ad", + "advanced/nested-mappings.bug at O1": "4bc88a81abf0e77e8a90b9f0d10d8dc8ecd42fe59cdb0db8b4c0e0fa33e2e60f", + "advanced/nested-mappings.bug at O2": "4bc88a81abf0e77e8a90b9f0d10d8dc8ecd42fe59cdb0db8b4c0e0fa33e2e60f", + "advanced/nested-mappings.bug at O3": "4bc88a81abf0e77e8a90b9f0d10d8dc8ecd42fe59cdb0db8b4c0e0fa33e2e60f", + "advanced/nested-structs.bug at O0": "b24e11ecaf28273d7b01dba9d82f4efd213702f1f919c5ecd3785a634939fcda", + "advanced/nested-structs.bug at O1": "ae8772307e46e33f94b8e36017ef8a4c23743fbb26085693a495239d25da3008", + "advanced/nested-structs.bug at O2": "d141d683602322f4195283df977a6c5618e72e995bfca7d8b285ae3a0f779bb4", + "advanced/nested-structs.bug at O3": "d141d683602322f4195283df977a6c5618e72e995bfca7d8b285ae3a0f779bb4", + "advanced/token-registry.bug at O0": "430a947f02160f905a04efdeea0b5d69fb7aa6be6b2dc54734be26ec0511988a", + "advanced/token-registry.bug at O1": "2de1cfe1d642a35a409d46df00f9ebb08ce0c43736020a5aa06cae30983b72e6", + "advanced/token-registry.bug at O2": "does not compile", + "advanced/token-registry.bug at O3": "does not compile", + "advanced/voting-system.bug at O0": "e49f6349c9c8fc5d3e14cd737a860891139518b0e0ea73d3c01f314a60e80a30", + "advanced/voting-system.bug at O1": "32f9c89a0890e1591b4e1aa29ec2b52d83a13c6ed06c4fc3391245002c505e38", + "advanced/voting-system.bug at O2": "3479679f427cb43fcc7288d34666f865d94c20a05f216f719f41a218624823ec", + "advanced/voting-system.bug at O3": "321c425fee5b039ff49bc31a650ab5c2d2acffd855513099ffccb24bcbff1cda", + "basic/array-length.bug at O0": "8f5fcef15f56ccce6890bbbd782560f4b202dbb5393307a92b0b32afa4556fa4", + "basic/array-length.bug at O1": "0e015ca03167413e55faf483f55d73f595c0221f77cd56c8a71c1125d0ccad9c", + "basic/array-length.bug at O2": "7920f08259a422bae4d7b2a9f9f67770ea25fcaf4bb70b337de02dcfd8a74dc1", + "basic/array-length.bug at O3": "7920f08259a422bae4d7b2a9f9f67770ea25fcaf4bb70b337de02dcfd8a74dc1", + "basic/array-values.bug at O0": "3317c7cdf7d2602a5aed38a02d88a3872071353d33fd8d06bf2f1b713abd32cf", + "basic/array-values.bug at O1": "224fbfaca6d0aa3a070b75a3a44473de55a0aa42a68e93579a25e5d413067ff5", + "basic/array-values.bug at O2": "8aae59a6b06eeb15020a112eb50374954718e6295b04cbb23bfbc18d0f9c3ffa", + "basic/array-values.bug at O3": "8aae59a6b06eeb15020a112eb50374954718e6295b04cbb23bfbc18d0f9c3ffa", + "basic/conditionals.bug at O0": "36d7853ed4b7a57acede507b9dc7f0faf6de6b15e6bdcb6ccc5fbadd323b26e0", + "basic/conditionals.bug at O1": "a3a00df0d716d96819916e8e63c55e82e4c422f100d4e4a0ce3b72aa338b4de3", + "basic/conditionals.bug at O2": "e4cb78229154dfc960046bd05c4c7d8bee81467c196156039c085d36f3a2acbe", + "basic/conditionals.bug at O3": "e4cb78229154dfc960046bd05c4c7d8bee81467c196156039c085d36f3a2acbe", + "basic/constructor-init.bug at O0": "bd42798b5485d789b0224ca42b4ff31b75a646881c1cfd76f0c1cbc64ee6a3a1", + "basic/constructor-init.bug at O1": "a9e14f70e7def8d6d5f95cafdc1e0c0f2ce2fe73e0fa4784c0b2c930bba9ff5f", + "basic/constructor-init.bug at O2": "a9e14f70e7def8d6d5f95cafdc1e0c0f2ce2fe73e0fa4784c0b2c930bba9ff5f", + "basic/constructor-init.bug at O3": "a9e14f70e7def8d6d5f95cafdc1e0c0f2ce2fe73e0fa4784c0b2c930bba9ff5f", + "basic/functions.bug at O0": "2b51b40fb3f9724554f139f931578d206e0815299bc68b8e6215a16dd28b95e8", + "basic/functions.bug at O1": "2b51b40fb3f9724554f139f931578d206e0815299bc68b8e6215a16dd28b95e8", + "basic/functions.bug at O2": "7b5d526c0b4d16c7f83ff9816524d235fa6e724d66d008a55af35c7e9f8240ca", + "basic/functions.bug at O3": "52e719967eff5d1160dad6bd19d33427d67d98ea1bb30efdcbb6dd160271255e", + "basic/minimal.bug at O0": "9c87a5d76a40ee23c01d10341b35d08d39bd9bef71df5f688dbf738f50b3111f", + "basic/minimal.bug at O1": "7fac02075da002e516ffe2700720450b10d3964764e79409374ed02f4df2a3a7", + "basic/minimal.bug at O2": "7fac02075da002e516ffe2700720450b10d3964764e79409374ed02f4df2a3a7", + "basic/minimal.bug at O3": "7fac02075da002e516ffe2700720450b10d3964764e79409374ed02f4df2a3a7", + "basic/variables.bug at O0": "does not compile", + "basic/variables.bug at O1": "does not compile", + "basic/variables.bug at O2": "does not compile", + "basic/variables.bug at O3": "does not compile", + "calls in sequence at O0": "2a2cd5bfc34f95aa17517bbc4862cdee63b5e568b896ba232cb7b003c2469255", + "calls in sequence at O1": "fc7fb90e0765e5347da8c470befe2664d6522d8d9c0de0010ff43785f6e77365", + "calls in sequence at O2": "05321b02791be0288522e6b7b8b8e45ae683d21d8e11051f47ae6903b3024c54", + "calls in sequence at O3": "b5af313fd2aa2f47d693cd9d927becce9dbf9cefc8d0c26651929db9fdd426b8", + "dispatch at O0": "2e526428199bfcfd12a21c7a1ddee81c3498442e40822c6a8b146d745198c008", + "dispatch at O1": "2f4b97d5bf871f8724ae80aecb2c4bdbfef02ec00def3b128a1d3626e29cae17", + "dispatch at O2": "96bb9a57b8760410ae7114b06cfa6bf2485b03d504447ca408f6398d74c99b87", + "dispatch at O3": "9cb4cfde869c923c8337bfa00901699493b5c4a33960794ecda30d6cb6ac921c", + "intermediate/arrays.bug at O0": "5a68146b7c7bf93288b187b39b413d9d09813a039f791671923fc1c4ecf9c4cf", + "intermediate/arrays.bug at O1": "b6a6dbce13c123c9a84bcb077d92ff2ad4ee3aaeb779c3cad2cdc265905cc47f", + "intermediate/arrays.bug at O2": "689caf7eff80dba0e92ece55580842c15e50a8e53e5af94f1be7b031429b600f", + "intermediate/arrays.bug at O3": "c499b60551b4655e4baee39d8fa71615501e1894a079f539a1c4f89769e2a45c", + "intermediate/bytes.bug at O0": "ef515bf6b34a9dc09b357c69054fd075c878cc531bdd1aaac299bc28732ff6b0", + "intermediate/bytes.bug at O1": "9d1352928f1cf27b83eedf97b63dd2f9005c10526f25e3e67d644228e00e6c30", + "intermediate/bytes.bug at O2": "9d1352928f1cf27b83eedf97b63dd2f9005c10526f25e3e67d644228e00e6c30", + "intermediate/bytes.bug at O3": "9d1352928f1cf27b83eedf97b63dd2f9005c10526f25e3e67d644228e00e6c30", + "intermediate/calldata.bug at O0": "ee3dac77c70ff4e78ece119f6e29b5dffe974646f835b325f6b4213556c10897", + "intermediate/calldata.bug at O1": "a971db577532adbc53f77ca1aa00f331e9650393a8d479a74506ca425b37509e", + "intermediate/calldata.bug at O2": "ea5ef4359e73cf0cf95b9db996b6600f5adda630c74d831a2a189b689dd7b529", + "intermediate/calldata.bug at O3": "ea5ef4359e73cf0cf95b9db996b6600f5adda630c74d831a2a189b689dd7b529", + "intermediate/internal-functions.bug at O0": "03a14e6870bf8ade51be3fb92703b7e1baa4de253e67f15bc33bd5cb3bf69192", + "intermediate/internal-functions.bug at O1": "03a14e6870bf8ade51be3fb92703b7e1baa4de253e67f15bc33bd5cb3bf69192", + "intermediate/internal-functions.bug at O2": "fcce9f8eab1796ac1a5df9065dd1fcb83c2e973ddbf0c2e152115668312aa5d0", + "intermediate/internal-functions.bug at O3": "30312a58f1fa5c9137549b4f575b4122fab3d74f860aae5123e662b63aaac7b3", + "intermediate/literals.bug at O0": "does not compile", + "intermediate/literals.bug at O1": "does not compile", + "intermediate/literals.bug at O2": "does not compile", + "intermediate/literals.bug at O3": "does not compile", + "intermediate/mappings.bug at O0": "208d92ccae758ab0af5549cec75608830c1e0022995a841e3c66c98a5a32bca3", + "intermediate/mappings.bug at O1": "bdc048d5d02236b9e92893897c158570c536c1ad3b8f0bb0727217b6cae1b10b", + "intermediate/mappings.bug at O2": "0d62a5767d6f632d2458a10bda6abc33f3a70a7a96a7ddf6f246514245777a69", + "intermediate/mappings.bug at O3": "0d62a5767d6f632d2458a10bda6abc33f3a70a7a96a7ddf6f246514245777a69", + "intermediate/memory-arrays.bug at O0": "d1de94dd3fde15a4411886f718220fb2e837fb96548dfcaec8b6745e49efdb87", + "intermediate/memory-arrays.bug at O1": "87f8488b12d5b6adb2159f2acc21ec7b6abaa882e7267a362cf0bd601fcfd60c", + "intermediate/memory-arrays.bug at O2": "09e6bc75ee8e9933ef4a96dc42a456f2c6ccbd8107a2cba40b1a55cf45838e83", + "intermediate/memory-arrays.bug at O3": "09e6bc75ee8e9933ef4a96dc42a456f2c6ccbd8107a2cba40b1a55cf45838e83", + "intermediate/memory-slices.bug at O0": "fd801bb38edc3cf7d27719f912e5c9adeb51f311d2fe9a97c18409d55734658d", + "intermediate/memory-slices.bug at O1": "83dece801364a4122081a0735a74f0e37b312b2ee82946a94b51b9e7093a274b", + "intermediate/memory-slices.bug at O2": "83dece801364a4122081a0735a74f0e37b312b2ee82946a94b51b9e7093a274b", + "intermediate/memory-slices.bug at O3": "83dece801364a4122081a0735a74f0e37b312b2ee82946a94b51b9e7093a274b", + "intermediate/memory-structs.bug at O0": "does not compile", + "intermediate/memory-structs.bug at O1": "does not compile", + "intermediate/memory-structs.bug at O2": "does not compile", + "intermediate/memory-structs.bug at O3": "does not compile", + "intermediate/owner-check.bug at O0": "e5f5b44a9cebb0ecdea025d904bd9059ebf3a5c0b2812636e07aa0b2f8919c8c", + "intermediate/owner-check.bug at O1": "ac4a8bbef41a3b020e8a40b25ddcacfdc63f67524a137861e7a384b02813fce4", + "intermediate/owner-check.bug at O2": "ac4a8bbef41a3b020e8a40b25ddcacfdc63f67524a137861e7a384b02813fce4", + "intermediate/owner-check.bug at O3": "ac4a8bbef41a3b020e8a40b25ddcacfdc63f67524a137861e7a384b02813fce4", + "intermediate/owner-counter.bug at O0": "9de1b2c04a3693ef1cb989a5e52a86e7ab4e9c94633cb4da8a4ccc67dd5c521d", + "intermediate/owner-counter.bug at O1": "3e1bc7d219220d983858e6a8b46e80c450d6f634fcdec21865c7d648e20e9c1d", + "intermediate/owner-counter.bug at O2": "3e1bc7d219220d983858e6a8b46e80c450d6f634fcdec21865c7d648e20e9c1d", + "intermediate/owner-counter.bug at O3": "7c9024d58310457605274f9baeccf6dd89acdc152c9ab9a35ab8881532f6b6f8", + "intermediate/packed-struct.bug at O0": "does not compile", + "intermediate/packed-struct.bug at O1": "does not compile", + "intermediate/packed-struct.bug at O2": "does not compile", + "intermediate/packed-struct.bug at O3": "does not compile", + "intermediate/phi-nodes.bug at O0": "4feaef5750fe3b2a337a107283589c0f719f82c7761455cb660a77cd53f58d32", + "intermediate/phi-nodes.bug at O1": "6d4c6ea207ad3a5c967c8ca80d9a7a594c17ab9fd582856f0f977e766d316b58", + "intermediate/phi-nodes.bug at O2": "6d4c6ea207ad3a5c967c8ca80d9a7a594c17ab9fd582856f0f977e766d316b58", + "intermediate/phi-nodes.bug at O3": "6d4c6ea207ad3a5c967c8ca80d9a7a594c17ab9fd582856f0f977e766d316b58", + "intermediate/scopes.bug at O0": "6f7fab968d0fdf458d2536c83abbb3e36a2291d4d3c32b6c4874831a7d28e9bc", + "intermediate/scopes.bug at O1": "9e9e0d1d5e3b39e7ddb512bb5de2ca8d3d6d32fd12a211a68ed0c27ac273b0dc", + "intermediate/scopes.bug at O2": "990ea851a89f3cf194506deb42a9f0640fda9b4da8f6984219622b2f6f7538e3", + "intermediate/scopes.bug at O3": "990ea851a89f3cf194506deb42a9f0640fda9b4da8f6984219622b2f6f7538e3", + "intermediate/slices.bug at O0": "5b4bddf07b235d1891fbb0d19e0a9cbca8ab4d2083423243da0ca296291011a6", + "intermediate/slices.bug at O1": "2023cdd09cc12969678598f68ffba3188aa8ef9ef9157332f90f1e1f987147e0", + "intermediate/slices.bug at O2": "2023cdd09cc12969678598f68ffba3188aa8ef9ef9157332f90f1e1f987147e0", + "intermediate/slices.bug at O3": "2023cdd09cc12969678598f68ffba3188aa8ef9ef9157332f90f1e1f987147e0", + "intermediate/storage-arrays.bug at O0": "24469940f8a0724f0257d1f8479e713ea7b0792f5af41d9acfd3cd2156dea117", + "intermediate/storage-arrays.bug at O1": "93842f4e97f43a0b1713f695007ccfcd76b7d4ab4f730fd35180efdd9456cca8", + "intermediate/storage-arrays.bug at O2": "e3178986ce39eafcffe31b85de4ca286afde9dfaf84a51502d0035b90d49a2f8", + "intermediate/storage-arrays.bug at O3": "e3178986ce39eafcffe31b85de4ca286afde9dfaf84a51502d0035b90d49a2f8", + "intermediate/storage-bytes-slice.bug at O0": "3535e92551d04c8faf45bfcf433db9f3b66cf3342279d0be01e7632182e45f9d", + "intermediate/storage-bytes-slice.bug at O1": "fa92063883b4de1e8419317c9caae55d8af9b9e1d13c17639aa87c31ab45c14c", + "intermediate/storage-bytes-slice.bug at O2": "fa92063883b4de1e8419317c9caae55d8af9b9e1d13c17639aa87c31ab45c14c", + "intermediate/storage-bytes-slice.bug at O3": "fa92063883b4de1e8419317c9caae55d8af9b9e1d13c17639aa87c31ab45c14c", + "intermediate/strings.bug at O0": "fe630fe24a89a7fe39afb7f1ccb475bc43c56404319cdf601d0a679211448465", + "intermediate/strings.bug at O1": "f710905b506c068ae1ae3b5acd7257e207e48266172e0fc9b59dc25cc1abdeec", + "intermediate/strings.bug at O2": "02c3c2321ef8041786d0c865c47f432e54964ed5f03e1395c1e6c9ba5760fad1", + "intermediate/strings.bug at O3": "c6133901e24b6a332a018811adb809354bcc468bf51c897d22b419350162cf70", + "nested calls at O0": "0c66cc768da113a946c83eda0bf3922964509400493e4833781830916142965d", + "nested calls at O1": "f2a624efaa4580303805eda1d001a6e56e266032a6497545fa26500f629ee42f", + "nested calls at O2": "e41f30fd36980bea5453232a71105f1f31b759d89485cba6c62a75f75398f34f", + "nested calls at O3": "1e6d59df26dd3f7cb8cf731919359bd2f5a1a1e82e6501b92de328b5aff0e27b", + "nested calls, not called at O0": "0c66cc768da113a946c83eda0bf3922964509400493e4833781830916142965d", + "nested calls, not called at O1": "f2a624efaa4580303805eda1d001a6e56e266032a6497545fa26500f629ee42f", + "nested calls, not called at O2": "e41f30fd36980bea5453232a71105f1f31b759d89485cba6c62a75f75398f34f", + "nested calls, not called at O3": "1e6d59df26dd3f7cb8cf731919359bd2f5a1a1e82e6501b92de328b5aff0e27b", + "optimizations/constant-folding.bug at O0": "75fa4ea24c71a0f4a2537f8c6caf98aa4198808955159930e9865ba77494f2f0", + "optimizations/constant-folding.bug at O1": "8ee32107586fc7c84863cc3b7ba36e2c23f589c699984451dbc40afd318d3ae9", + "optimizations/constant-folding.bug at O2": "8ee32107586fc7c84863cc3b7ba36e2c23f589c699984451dbc40afd318d3ae9", + "optimizations/constant-folding.bug at O3": "8ee32107586fc7c84863cc3b7ba36e2c23f589c699984451dbc40afd318d3ae9", + "optimizations/cse-simple.bug at O0": "8c95d45f17067c84dc07b42f48c1257af8d92e80f0e94d7a92b352985bc9773b", + "optimizations/cse-simple.bug at O1": "be543722fe6c8e1ea1d9bb4e0e09314560542e795eb3d7fdbd131fca72fd2e2b", + "optimizations/cse-simple.bug at O2": "be543722fe6c8e1ea1d9bb4e0e09314560542e795eb3d7fdbd131fca72fd2e2b", + "optimizations/cse-simple.bug at O3": "be543722fe6c8e1ea1d9bb4e0e09314560542e795eb3d7fdbd131fca72fd2e2b", + "optimizations/cse.bug at O0": "b021b3e98dc7a8fb7647b1df4e7987bbf16a808a5316fd9b85f719b72421f34f", + "optimizations/cse.bug at O1": "df8f5661fe7448e8f38495cdac67c25e2631e947768d4adac457b2633c897954", + "optimizations/cse.bug at O2": "f40077d848a4f5b62c6ab4a171700565689667ec54d97642e053a7df7edc3282", + "optimizations/cse.bug at O3": "f40077d848a4f5b62c6ab4a171700565689667ec54d97642e053a7df7edc3282", + "wip/returndata.bug at O0": "does not compile", + "wip/returndata.bug at O1": "does not compile", + "wip/returndata.bug at O2": "does not compile", + "wip/returndata.bug at O3": "does not compile", + "wip/transient-storage.bug at O0": "does not compile", + "wip/transient-storage.bug at O1": "does not compile", + "wip/transient-storage.bug at O2": "does not compile", + "wip/transient-storage.bug at O3": "does not compile", +} +`; diff --git a/packages/bugc/src/evmgen/activation-pairing.test.ts b/packages/bugc/src/evmgen/activation-pairing.test.ts index dfada6937c..47c2aedff0 100644 --- a/packages/bugc/src/evmgen/activation-pairing.test.ts +++ b/packages/bugc/src/evmgen/activation-pairing.test.ts @@ -12,7 +12,10 @@ import { describe, it, expect } from "vitest"; import { Program } from "@ethdebug/format"; import type * as Format from "@ethdebug/format"; +import { createHash } from "node:crypto"; +import { compile } from "#compiler"; import { traceLocals, type Level } from "#test/evm/locals"; +import { exampleSources } from "../examples/index.js"; const { Context } = Program; @@ -82,154 +85,183 @@ code { const levels: Level[] = [0, 1, 2, 3]; -describe("every invoke is matched by its return", () => { - for (const level of levels) { - for (const [call, called] of [ - [1, ["a"]], - [2, ["b"]], - [3, []], - ] as const) { - it(`dispatch, selector ${call}, at O${level}`, async () => { - const data = selector(call) + "0".repeat(63) + "7"; - const { calls, open } = await runCalls(dispatch, level, data); - expect(calls).toEqual(called); - expect(open).toEqual([]); - }); - } - } - - // Each program, and the calls its run enters - const programs: { name: string; source: string; calls: string[] }[] = [ - { - // The body's last instruction goes (the caller read msg.sender - // already): its return moves back onto the body's write - name: "a body whose last instruction goes", - source: `name MovedReturn; +// Each program, and the calls its run enters +const programs: { + name: string; + source: string; + calls: string[]; + /** The call's selector (1 by default) */ + selector?: number; +}[] = [ + { + // The body's last instruction goes (the caller read msg.sender + // already): its return moves back onto the body's write + name: "a body whose last instruction goes", + source: `name MovedReturn; define { - function f(n: uint256) -> address { r = n; return msg.sender; }; - function g(n: uint256) { s = n + r; }; +function f(n: uint256) -> address { r = n; return msg.sender; }; +function g(n: uint256) { s = n + r; }; } storage { [0] r: uint256; [1] s: uint256; [2] w: address; } create { r = 0; } code { let a = msg.sender; w = f(3); g(4); w = a; }`, - calls: ["f", "g"], - }, - { - // The body's one instruction goes, and its block is left empty - name: "a body that goes whole", - source: `name Emptied; + calls: ["f", "g"], + }, + { + // The body's one instruction goes, and its block is left empty + name: "a body that goes whole", + source: `name Emptied; define { function who() -> address { return msg.sender; }; } storage { [0] w: address; [1] v: address; [2] u: address; } create { w = msg.sender; } code { let a = msg.sender; w = who(); v = who(); u = a; }`, - calls: ["who", "who"], - }, - { - // At O3, f's body (one instruction, which goes) and g's share a - // block: f's call must not end after g's begins - name: "calls in sequence", - source: `name Sequence; + calls: ["who", "who"], + }, + { + // At O3, f's body (one instruction, which goes) and g's share a + // block: f's call must not end after g's begins + name: "calls in sequence", + source: `name Sequence; define { - function f(n: uint256) -> uint256 { return n + 1; }; - function g(m: uint256) { s = m; }; +function f(n: uint256) -> uint256 { return n + 1; }; +function g(m: uint256) { s = m; }; } storage { [0] r: uint256; [1] s: uint256; [2] u: uint256; [3] a: uint256; } create { a = 4; } code { let b = a; if (b > 0) { let x = b + 1; g(f(b)); u = x; } }`, - calls: ["f", "g"], - }, - { - name: "nested calls", - source: `name Nested; + calls: ["f", "g"], + }, + { + name: "nested calls", + source: `name Nested; define { - function inner() -> uint256 { - return msg.data[4:36] as bytes32 as uint256; - }; - function outer() -> uint256 { return inner() + 1; }; +function inner() -> uint256 { + return msg.data[4:36] as bytes32 as uint256; +}; +function outer() -> uint256 { return inner() + 1; }; +} +storage { [0] r: uint256; } +create { r = 0; } +code { if (msg.data[0:4] as bytes4 == 0x00000001) { r = outer(); } }`, + calls: ["outer", "inner"], + }, + { + // Both bodies start with a read of msg.data, which folds into the + // dispatch's own, before the branch: a run that does not take the + // branch enters neither + name: "nested calls, not called", + source: `name Nested; +define { +function inner() -> uint256 { + return msg.data[4:36] as bytes32 as uint256; +}; +function outer() -> uint256 { return inner() + 1; }; } storage { [0] r: uint256; } create { r = 0; } code { if (msg.data[0:4] as bytes4 == 0x00000001) { r = outer(); } }`, - calls: ["outer", "inner"], - }, - { - name: "a call in a loop", - source: `name Loop; + calls: [], + selector: 2, + }, + { + name: "a call in a loop", + source: `name Loop; define { function who() -> address { r = r + 1; return msg.sender; }; } storage { [0] r: uint256; [1] w: address; } create { r = 0; } code { - let a = msg.sender; - for (let i = 0; i < 3; i = i + 1) { w = who(); } - w = a; +let a = msg.sender; +for (let i = 0; i < 3; i = i + 1) { w = who(); } +w = a; }`, - calls: ["who", "who", "who"], - }, - { - // The body's last instruction is dead (its result is unused) - name: "a call whose result is unused", - source: `name Unused; + calls: ["who", "who", "who"], + }, + { + // The body's last instruction is dead (its result is unused) + name: "a call whose result is unused", + source: `name Unused; define { function g() -> uint256 { r = r + 1; return msg.value; }; } storage { [0] r: uint256; } create { r = 0; } code { g(); }`, - calls: ["g"], - }, - { - // At O3, the body's two writes merge into one - name: "a body whose writes merge", - source: `name Merged; + calls: ["g"], + }, + { + // At O3, the body's two writes merge into one + name: "a body whose writes merge", + source: `name Merged; define { - struct P { a: uint64; b: uint64; }; - function set() { p.a = 1; p.b = 2; }; +struct P { a: uint64; b: uint64; }; +function set() { p.a = 1; p.b = 2; }; } storage { [0] p: P; [1] r: uint256; } create { r = 0; } code { set(); r = 9; }`, - calls: ["set"], - }, - { - // Issue #382: the body's entry block is empty, and it jumps to a - // loop header - name: "a body that starts with a loop", - source: `name LoopFirst; + calls: ["set"], + }, + { + // Issue #382: the body's entry block is empty, and it jumps to a + // loop header + name: "a body that starts with a loop", + source: `name LoopFirst; define { - function f(n: uint256) -> uint256 { - for (let i = n; i > 5; i = i - 1) { - r = r + 1; - } - return n + 1; - }; +function f(n: uint256) -> uint256 { + for (let i = n; i > 5; i = i - 1) { + r = r + 1; + } + return n + 1; +}; } storage { [0] r: uint256; [1] s: uint256; } create { s = 9; } code { let v = s; r = f(v) + v; }`, - calls: ["f"], - }, - { - // Issue #382: the loop's exit block is empty - name: "a body that ends with a loop", - source: `name LoopLast; + calls: ["f"], + }, + { + // Issue #382: the loop's exit block is empty + name: "a body that ends with a loop", + source: `name LoopLast; define { - function f(n: uint256) -> uint256 { - let acc = 0; - for (let i = 0; i < n; i = i + 1) { - acc = acc + i; - } - return acc; - }; +function f(n: uint256) -> uint256 { + let acc = 0; + for (let i = 0; i < n; i = i + 1) { + acc = acc + i; + } + return acc; +}; } storage { [0] r: uint256; [1] s: uint256; } create { s = 3; } code { r = f(s); }`, - calls: ["f"], - }, - ]; + calls: ["f"], + }, +]; +describe("every invoke is matched by its return", () => { for (const level of levels) { - for (const { name, source, calls: called } of programs) { + for (const [call, called] of [ + [1, ["a"]], + [2, ["b"]], + [3, []], + ] as const) { + it(`dispatch, selector ${call}, at O${level}`, async () => { + const data = selector(call) + "0".repeat(63) + "7"; + const { calls, open } = await runCalls(dispatch, level, data); + expect(calls).toEqual(called); + expect(open).toEqual([]); + }); + } + } + + for (const level of levels) { + for (const { + name, + source, + calls: called, + selector: call = 1, + } of programs) { it(`${name}, at O${level}`, async () => { - const data = selector(1) + "0".repeat(63) + "7"; + const data = selector(call) + "0".repeat(63) + "7"; const { calls, open } = await runCalls(source, level, data); expect(calls).toEqual(called); expect(open).toEqual([]); @@ -237,3 +269,34 @@ code { r = f(s); }`, } } }); + +// Debug information never changes the code: these digests are of the +// code `main` generated before invoke and return markers were moved +// (or dropped) rather than kept by keeping code. A change to the code +// generation that is meant changes them, too (`vitest -u`). +describe("the code does not depend on the markers", () => { + it("is the same for every example and program here", async () => { + const sources = { + ...exampleSources, + ...Object.fromEntries(programs.map(({ name, source }) => [name, source])), + dispatch, + }; + const digests: Record = {}; + for (const [name, source] of Object.entries(sources)) { + for (const level of levels) { + const result = await compile({ + to: "bytecode", + source, + optimizer: { level }, + }); + digests[`${name} at O${level}`] = result.success + ? createHash("sha256") + .update(result.value.bytecode.runtime) + .update(result.value.bytecode.create ?? new Uint8Array()) + .digest("hex") + : "does not compile"; + } + } + expect(digests).toMatchSnapshot(); + }); +}); diff --git a/packages/bugc/src/evmgen/generation/block.ts b/packages/bugc/src/evmgen/generation/block.ts index b6c507d237..93832d3296 100644 --- a/packages/bugc/src/evmgen/generation/block.ts +++ b/packages/bugc/src/evmgen/generation/block.ts @@ -92,8 +92,14 @@ export function generate( } } - // A block of an inlined body is entered inside that body - const entryDebug = withEntryInline(block); + // A block of an inlined body is entered inside that body. Its + // entry also carries the invokes and returns the optimizer put + // there (of a jump or instructions it removed). + const entryMarkers = block.entryActivations; + const entryDebug = withEntryActivations( + withEntryInline(block), + entryMarkers, + ); if (callSiteCode) { const entry = entryDebug; @@ -108,6 +114,19 @@ export function generate( } else { result = result.then(JUMPDEST({ debug: entryDebug })); } + const entryReturned = entryMarkers?.returned; + if ( + entryReturned && + carriesActivation(entryMarkers?.context, "return") + ) { + result = result.then((s) => ({ + ...s, + instructions: [ + ...s.instructions.slice(0, -1), + withReturned(s.instructions.at(-1)!, entryReturned, s.stack), + ], + })); + } // Annotate TOS with dest variable if this is a continuation with return value. // Also spill to memory if allocated, so the value survives stack cleanup @@ -254,17 +273,27 @@ export function generate( (carriesActivation(termCtx, "invoke") || carriesActivation(termCtx, "return")) ) { + const returned = carriesActivation(termCtx, "return") + ? term.operationDebug.returned + : undefined; result = result.peek((state, builder) => { const start = state.instructions.length; - return builder.then(terminate).then((s) => ({ - ...s, - instructions: bracketActivation( - s.instructions, - start, - termCtx, - s.instructions.length - (term.kind === "call" ? 1 : 0), - ), - })); + return builder.then(terminate).then((s) => { + const end = s.instructions.length - (term.kind === "call" ? 1 : 0); + const instructions = [ + ...bracketActivation(s.instructions, start, termCtx, end), + ]; + // The op that returns from an inlined function lists the + // caller's variables + if (returned && end > start) { + instructions[end - 1] = withReturned( + instructions[end - 1], + returned, + s.stack, + ); + } + return { ...s, instructions }; + }); }); } else { result = result.then(terminate); @@ -275,6 +304,19 @@ export function generate( .done(); } +/** A block's entry debug, with the markers the optimizer put there */ +function withEntryActivations( + debug: Ir.Block.Debug | undefined, + markers: Ir.Block.Debug | undefined, +): Ir.Block.Debug | undefined { + if (!markers) return debug; + const context = Ir.Utils.withActivations(debug?.context, { + invokes: Ir.Utils.activationsOf(markers.context, "invoke"), + returns: Ir.Utils.activationsOf(markers.context, "return"), + }); + return { ...debug, ...(context ? { context } : {}) }; +} + /** * A block's entry debug, with an `inline` transform for each inlined * body the block's entry is in: each one its first operation is in, diff --git a/packages/bugc/src/ir/spec/block.ts b/packages/bugc/src/ir/spec/block.ts index c4a40bd0c8..441a55bbe0 100644 --- a/packages/bugc/src/ir/spec/block.ts +++ b/packages/bugc/src/ir/spec/block.ts @@ -22,6 +22,12 @@ export interface Block { debug: Block.Debug; /** Debug for the block's entry (its JUMPDEST), set by code generation */ entryDebug?: Block.Debug; + /** + * Invoke and return markers for the block's entry (its JUMPDEST): + * those of a jump or an instruction the optimizer removed, with no + * op closer on their path to carry them + */ + entryActivations?: Block.Debug; } export namespace Block { diff --git a/packages/bugc/src/ir/utils/debug.test.ts b/packages/bugc/src/ir/utils/debug.test.ts index 997bff5425..1656a18fae 100644 --- a/packages/bugc/src/ir/utils/debug.test.ts +++ b/packages/bugc/src/ir/utils/debug.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import type * as Ir from "#ir"; -import { activationsOf, moveActivations } from "./debug.js"; +import { markersOf, placeActivations } from "./debug.js"; const invoke = (identifier: string) => ({ invoke: { identifier } }); const ret = (identifier: string) => ({ return: { identifier } }); @@ -9,70 +9,81 @@ const debug = (...markers: object[]): Ir.Instruction.Debug => ({ ? markers[0] : { gather: markers }) as Ir.Instruction.Debug["context"], }); -const markers = (d: Ir.Instruction.Debug | undefined) => [ - ...activationsOf(d?.context, "invoke").map( - (v) => `invoke ${(v as { identifier: string }).identifier}`, - ), - ...activationsOf(d?.context, "return").map( - (v) => `return ${(v as { identifier: string }).identifier}`, - ), -]; +const plain = (): Ir.Instruction.Debug => ({ + context: { code: {} } as never, +}); +const names = (d: Ir.Instruction.Debug | undefined) => + markersOf(d).map((m) => + "invoke" in m + ? `invoke ${(m.invoke as { identifier: string }).identifier}` + : `return ${(m.return as { identifier: string }).identifier}`, + ); -describe("moveActivations", () => { - it("moves an invoke onto the instruction after", () => { - const moved = moveActivations(debug(invoke("f")), { - before: { context: { code: {} } as never }, +describe("placeActivations", () => { + it("puts an invoke on the op after", () => { + const placed = placeActivations([invoke("f")], undefined, { + before: plain(), after: debug(invoke("g")), }); - expect(markers(moved?.before)).toEqual([]); - expect(markers(moved?.after)).toEqual(["invoke f", "invoke g"]); + expect(names(placed.before)).toEqual([]); + expect(names(placed.after)).toEqual(["invoke f", "invoke g"]); }); - it("moves a return onto the instruction before", () => { - const moved = moveActivations(debug(ret("f")), { + it("puts a return on the op before", () => { + const placed = placeActivations([ret("f")], undefined, { before: debug(invoke("f")), after: debug(invoke("g")), }); - expect(markers(moved?.before)).toEqual(["invoke f", "return f"]); - expect(markers(moved?.after)).toEqual(["invoke g"]); + expect(names(placed.before)).toEqual(["invoke f", "return f"]); + expect(names(placed.after)).toEqual(["invoke g"]); }); it("never puts a return after an invoke it came before", () => { - // Only the instruction after is there, and it opens a call: the - // return cannot go on it + // The return cannot go on an op that opens a call expect( - moveActivations(debug(ret("f")), { after: debug(invoke("g")) }), - ).toBeUndefined(); - // An invoke cannot follow a return on the instruction before + placeActivations([ret("f")], undefined, { after: debug(invoke("g")) }) + .dropped, + ).toEqual([ret("f")]); + // An invoke cannot follow a return on the op before expect( - moveActivations(debug(invoke("g")), { before: debug(ret("f")) }), - ).toBeUndefined(); + placeActivations([invoke("g")], undefined, { before: debug(ret("f")) }) + .dropped, + ).toEqual([invoke("g")]); + }); + + it("splits a return, then an invoke, between the two", () => { + const placed = placeActivations([ret("e"), invoke("f")], undefined, { + before: plain(), + after: plain(), + }); + expect(names(placed.before)).toEqual(["return e"]); + expect(names(placed.after)).toEqual(["invoke f"]); }); - it("keeps a call wholly on the removed instruction whole", () => { - const moved = moveActivations(debug(invoke("f"), ret("f")), { + it("keeps a call wholly on what is removed whole", () => { + const placed = placeActivations([invoke("f"), ret("f")], undefined, { before: debug(ret("e")), - after: { context: { code: {} } as never }, + after: plain(), }); - expect(markers(moved?.before)).toEqual(["return e"]); - expect(markers(moved?.after)).toEqual(["invoke f", "return f"]); + expect(names(placed.before)).toEqual(["return e"]); + expect(names(placed.after)).toEqual(["invoke f", "return f"]); }); - it("drops a call wholly on the removed instruction if it must", () => { - const moved = moveActivations(debug(invoke("f"), ret("f")), { + it("drops what no neighbour can take, and lists it", () => { + const placed = placeActivations([invoke("f"), ret("f")], undefined, { before: debug(ret("e")), after: debug(invoke("g")), }); - expect(markers(moved?.before)).toEqual(["return e"]); - expect(markers(moved?.after)).toEqual(["invoke g"]); + expect(names(placed.before)).toEqual(["return e"]); + expect(names(placed.after)).toEqual(["invoke g"]); + expect(placed.dropped).toEqual([invoke("f"), ret("f")]); }); it("carries `returned` with a moved return", () => { - const returned = { context: { code: {} } as never }; - const moved = moveActivations( - { ...debug(ret("f")), returned }, - { before: debug(invoke("f")) }, - ); - expect(moved?.before?.returned).toBe(returned); + const returned = plain(); + const placed = placeActivations([ret("f")], returned, { + before: debug(invoke("f")), + }); + expect(placed.before?.returned).toBe(returned); }); }); diff --git a/packages/bugc/src/ir/utils/debug.ts b/packages/bugc/src/ir/utils/debug.ts index d2569da8da..4ab959633a 100644 --- a/packages/bugc/src/ir/utils/debug.ts +++ b/packages/bugc/src/ir/utils/debug.ts @@ -551,124 +551,207 @@ export function withoutActivationsDebug( } /** - * Where a removed instruction's (or jump's) `invoke` and `return` - * discriminators go: onto a neighbour on its own path, the one that - * ran just `before` it or the one that runs just `after` it. + * An `invoke` or a `return`, as one of the markers a debug carries + * (see `withActivations`) + */ +export type ActivationMarker = { invoke: unknown } | { return: unknown }; + +/** A debug's markers, in the order they happen: invokes, then returns */ +export function markersOf( + debug: Ir.Instruction.Debug | undefined, +): ActivationMarker[] { + return [ + ...activationsOf(debug?.context, "invoke").map((invoke) => ({ invoke })), + ...activationsOf(debug?.context, "return").map((value) => ({ + return: value, + })), + ]; +} + +/** Whether markers can ride one op: no invoke after a return */ +function inOrder(markers: ActivationMarker[]): boolean { + const firstReturn = markers.findIndex((marker) => "return" in marker); + return ( + firstReturn < 0 || + markers.slice(firstReturn).every((marker) => "return" in marker) + ); +} + +/** A debug whose markers are these (in order: see `inOrder`) */ +function withMarkers( + debug: Ir.Instruction.Debug, + markers: ActivationMarker[], + returned: Ir.Instruction.Debug | undefined, +): Ir.Instruction.Debug { + const context = withActivations(withoutActivations(debug.context), { + invokes: markers.flatMap((m) => ("invoke" in m ? [m.invoke] : [])), + returns: markers.flatMap((m) => ("return" in m ? [m.return] : [])), + }); + const { context: _, returned: __, ...rest } = debug; + return { + ...rest, + ...(context ? { context } : {}), + ...(returned ? { returned } : {}), + }; +} + +/** Where `placeActivations` put the markers */ +export interface Placement { + before?: Ir.Instruction.Debug; + after?: Ir.Instruction.Debug; + /** The markers that no neighbour could take */ + dropped: ActivationMarker[]; +} + +/** + * Put the `invoke` and `return` markers of something removed (an + * instruction, a jump, a block's entry) onto a neighbour on its own + * path: what ran just `before` it, or what runs just `after` it. * * An invoke or a return marks a point on one path: it must not go - * with the removed instruction, or onto one that runs elsewhere. A - * debug lists its invokes, then its returns, so the markers move only - * where that order holds: before's markers, then the removed one's, or - * the removed one's, then after's. Returns go before (the last - * instruction kept in the body ends it) and invokes after (the first - * one starts it), where they can. + * with what is removed, or onto an op that runs on other paths. An op + * lists its invokes, then its returns, so the markers split between + * the two neighbours where each still reads that way: before's own, + * then the first part; the rest, then after's own. Of those splits, + * the one that moves the fewest invokes earlier and returns later: + * the first op kept in a body starts it, and the last one ends it. * - * If neither neighbour can take them, the calls that open and close - * on the removed instruction (their bodies optimized away) go with - * it; otherwise, undefined: the instruction stays. + * If no split fits, the markers are dropped (and listed as such): the + * code stays as the optimizer makes it. Debug information never + * changes the code. */ -export function moveActivations( - removed: Ir.Instruction.Debug | undefined, +export function placeActivations( + markers: ActivationMarker[], + returned: Ir.Instruction.Debug | undefined, { before, after, }: { before?: Ir.Instruction.Debug; after?: Ir.Instruction.Debug }, -): { before?: Ir.Instruction.Debug; after?: Ir.Instruction.Debug } | undefined { - const invokes = activationsOf(removed?.context, "invoke"); - const returns = activationsOf(removed?.context, "return"); - if (invokes.length === 0 && returns.length === 0) return { before, after }; - - const onto = ( - debug: Ir.Instruction.Debug, - { first }: { first: boolean }, - ): Ir.Instruction.Debug | undefined => { - const own = { - invokes: activationsOf(debug.context, "invoke"), - returns: activationsOf(debug.context, "return"), - }; - // The markers in order must read as invokes, then returns - const [earlier, later] = first - ? [{ invokes, returns }, own] - : [own, { invokes, returns }]; - if (earlier.returns.length > 0 && later.invokes.length > 0) { - return undefined; +): Placement { + if (markers.length === 0) return { before, after, dropped: [] }; + const own = { before: markersOf(before), after: markersOf(after) }; + let best: { at: number; cost: number } | undefined; + for (let at = 0; at <= markers.length; at++) { + const early = markers.slice(0, at); + const late = markers.slice(at); + if ((early.length > 0 && !before) || (late.length > 0 && !after)) { + continue; } - const context = withActivations(withoutActivations(debug.context), { - invokes: [...earlier.invokes, ...later.invokes], - returns: [...earlier.returns, ...later.returns], - }); - // The instruction that returns last lists the caller's variables - const returned = - (later.returns.length > 0 ? (first ? debug : removed) : undefined) - ?.returned ?? - (earlier.returns.length > 0 ? (first ? removed : debug) : undefined) - ?.returned; - const { context: _, returned: __, ...rest } = debug; - return { - ...rest, - ...(context ? { context } : {}), - ...(returned ? { returned } : {}), - }; + if ( + !inOrder([...own.before, ...early]) || + !inOrder([...late, ...own.after]) + ) { + continue; + } + const cost = + early.filter((m) => "invoke" in m).length + + late.filter((m) => "return" in m).length; + if (!best || cost < best.cost) best = { at, cost }; + } + if (!best) return { before, after, dropped: markers }; + + const early = markers.slice(0, best.at); + const late = markers.slice(best.at); + const returns = (ms: ActivationMarker[]) => ms.some((m) => "return" in m); + return { + before: + before && early.length > 0 + ? withMarkers( + before, + [...own.before, ...early], + returns(early) ? (returned ?? before.returned) : before.returned, + ) + : before, + after: + after && late.length > 0 + ? withMarkers( + after, + [...late, ...own.after], + returns(own.after) + ? after.returned + : returns(late) + ? returned + : after.returned, + ) + : after, + dropped: [], }; +} - const toBefore = before && onto(before, { first: false }); - const toAfter = after && onto(after, { first: true }); - const preferBefore = returns.length > invokes.length; - if (toBefore && (preferBefore || !toAfter)) - return { before: toBefore, after }; - if (toAfter) return { before, after: toAfter }; - // A call wholly on the removed instruction (its body is gone) goes - // with it - if (invokes.length === returns.length) return { before, after }; - return undefined; +/** What `dropInstructions` did */ +export interface Dropped { + instructions: Ir.Instruction[]; + /** Each kept instruction given a new debug, by the one it replaces */ + replaced: Map; + /** The markers that no neighbour could take */ + dropped: ActivationMarker[]; } /** - * A block's instructions but those in `drop`, with each dropped one's - * `invoke` and `return` moved onto a neighbour on its path (see - * `moveActivations`): an instruction kept in the block, or the block's - * jump. A dropped instruction whose markers no neighbour can take is - * kept. The kept instructions and the jump get new debugs. + * A block's instructions but those in `drop`, with their `invoke` and + * `return` markers put on a neighbour on their path (see + * `placeActivations`): the instruction kept before them, or the + * block's entry (its JUMPDEST; not the function's entry block); the + * instruction kept after them, or the block's jump. */ export function dropInstructions( block: Ir.Block, drop: Set, -): Ir.Instruction[] { - const kept: Ir.Instruction[] = []; - // A dropped instruction whose markers wait for the next one kept - let pending: Ir.Instruction | undefined; + { isEntry }: { isEntry: boolean }, +): Dropped { + const instructions: Ir.Instruction[] = []; + const replaced = new Map(); + const dropped: ActivationMarker[] = []; + // The markers of the dropped instructions since the last one kept + let pending: ActivationMarker[] = []; + let pendingReturned: Ir.Instruction.Debug | undefined; + const settle = (after: Ir.Instruction.Debug | undefined) => { - if (!pending) return after; - const last = kept.at(-1); - const moved = moveActivations(pending.operationDebug, { - before: last?.operationDebug, + if (pending.length === 0) return after; + const last = instructions.at(-1); + const before = last + ? last.operationDebug + : isEntry + ? undefined + : (block.entryActivations ?? {}); + const placed = placeActivations(pending, pendingReturned, { + before, after, }); - if (!moved) { - kept.push(pending); - pending = undefined; - return after; - } - if (last && moved.before !== last.operationDebug) { - kept[kept.length - 1] = { ...last, operationDebug: moved.before! }; + dropped.push(...placed.dropped); + pending = []; + pendingReturned = undefined; + if (placed.before !== before) { + if (last) { + const next = { ...last, operationDebug: placed.before! }; + replaced.set(replacedFrom(last), next as Ir.Instruction); + instructions[instructions.length - 1] = next as Ir.Instruction; + } else { + block.entryActivations = placed.before; + } } - pending = undefined; - return moved.after; + return placed.after; }; + // The original of an instruction already replaced here + const replacedFrom = (inst: Ir.Instruction) => + [...replaced].find(([, to]) => to === inst)?.[0] ?? inst; for (const inst of block.instructions) { - if (!drop.has(inst)) { - const debug = settle(inst.operationDebug); - kept.push( - debug === inst.operationDebug - ? inst - : ({ ...inst, operationDebug: debug } as Ir.Instruction), - ); + if (drop.has(inst)) { + pending.push(...markersOf(inst.operationDebug)); + if (activationsOf(inst.operationDebug.context, "return").length > 0) { + pendingReturned = inst.operationDebug.returned; + } continue; } - if (!carriesActivations(inst.operationDebug)) continue; - settle(undefined); - pending = inst; + const debug = settle(inst.operationDebug); + if (debug === inst.operationDebug) { + instructions.push(inst); + } else { + const next = { ...inst, operationDebug: debug! } as Ir.Instruction; + replaced.set(inst, next); + instructions.push(next); + } } const { terminator } = block; @@ -676,17 +759,16 @@ export function dropInstructions( terminator.kind === "jump" && !terminator.tailCall ? terminator : undefined; const debug = settle(jump?.operationDebug); if (jump && debug !== jump.operationDebug) { - block.terminator = { ...jump, operationDebug: debug ?? {} }; + block.terminator = { ...jump, operationDebug: debug! }; } - return kept; + return { instructions, replaced, dropped }; } -/** Whether a debug has an `invoke` or a `return` discriminator */ -export function carriesActivations( - debug: Ir.Instruction.Debug | undefined, -): boolean { +/** A context listing markers, as a transformation records them */ +export function markersContext( + markers: ActivationMarker[], +): Format.Program.Context { return ( - activationsOf(debug?.context, "invoke").length > 0 || - activationsOf(debug?.context, "return").length > 0 - ); + markers.length === 1 ? markers[0] : { gather: markers } + ) as Format.Program.Context; } diff --git a/packages/bugc/src/optimizer/optimizer.ts b/packages/bugc/src/optimizer/optimizer.ts index ff52786161..95b19ebd6a 100644 --- a/packages/bugc/src/optimizer/optimizer.ts +++ b/packages/bugc/src/optimizer/optimizer.ts @@ -225,6 +225,9 @@ export abstract class BaseOptimizationStep implements OptimizationStep { terminator: { ...block.terminator }, predecessors: new Set(block.predecessors), debug: block.debug, + ...(block.entryActivations + ? { entryActivations: block.entryActivations } + : {}), }); } @@ -282,3 +285,22 @@ export abstract class BaseOptimizationStep implements OptimizationStep { return result; } } + +/** + * Record the invoke and return markers a pass dropped: no op on their + * path could carry them, and debug information never changes the code + */ +export function recordDropped( + context: OptimizationContext, + pass: string, + dropped: Ir.Utils.ActivationMarker[], +): void { + if (dropped.length === 0) return; + context.trackTransformation({ + type: "delete", + pass, + original: [Ir.Utils.markersContext(dropped)], + result: [], + reason: "Dropped invoke/return markers with no op on their path", + }); +} diff --git a/packages/bugc/src/optimizer/steps/block-merging.ts b/packages/bugc/src/optimizer/steps/block-merging.ts index 122e3187af..b4dcd74327 100644 --- a/packages/bugc/src/optimizer/steps/block-merging.ts +++ b/packages/bugc/src/optimizer/steps/block-merging.ts @@ -1,6 +1,7 @@ import * as Ir from "#ir"; import { BaseOptimizationStep, + recordDropped, type OptimizationContext, } from "../optimizer.js"; @@ -33,11 +34,15 @@ export class BlockMergingStep extends BaseOptimizationStep { if (!sourceBlock || !targetBlock) continue; - // The jump between them goes: its invoke or return moves onto - // the instruction before it or the one after it (or the jump - // the merged block ends with). The blocks stay apart if - // neither can take them. - if (!moveJumpActivations(targetBlock, sourceBlock)) continue; + // The jump between them goes, and the merged block's entry: + // their invokes and returns move onto a neighbour on the path + recordDropped( + context, + this.name, + moveJumpActivations(targetBlock, sourceBlock, { + isEntry: mergeInto === func.entry, + }), + ); // Merge phi nodes from source to target if (sourceBlock.phis && targetBlock.phis) { @@ -105,38 +110,64 @@ export class BlockMergingStep extends BaseOptimizationStep { } /** - * Move the `invoke` or `return` of the jump from `from` to `to` onto a - * neighbour (see `Ir.Utils.moveActivations`). False if none takes them. + * Put the invokes and returns of the jump from `from` to `to`, and of + * `to`'s entry, onto a neighbour on their path (see + * `Ir.Utils.placeActivations`): `from`'s last instruction (or its + * entry), or `to`'s first instruction (or its jump). The markers no + * neighbour can take. */ -function moveJumpActivations(from: Ir.Block, to: Ir.Block): boolean { - const debug = from.terminator.operationDebug; - if (!Ir.Utils.carriesActivations(debug)) return true; +function moveJumpActivations( + from: Ir.Block, + to: Ir.Block, + { isEntry }: { isEntry: boolean }, +): Ir.Utils.ActivationMarker[] { + const jump = from.terminator.operationDebug; + const entry = to.entryActivations; + delete to.entryActivations; + const markers = [...Ir.Utils.markersOf(jump), ...Ir.Utils.markersOf(entry)]; + if (markers.length === 0) return []; + const returned = + Ir.Utils.activationsOf(entry?.context, "return").length > 0 + ? entry?.returned + : jump.returned; + const last = from.instructions.at(-1); const first = to.instructions[0]; - const jump = + const toJump = !first && to.terminator.kind === "jump" && !to.terminator.tailCall ? to.terminator : undefined; - const moved = Ir.Utils.moveActivations(debug, { - before: last?.operationDebug, - after: (first ?? jump)?.operationDebug, + const before = last + ? last.operationDebug + : isEntry + ? undefined + : (from.entryActivations ?? {}); + const after = (first ?? toJump)?.operationDebug; + const placed = Ir.Utils.placeActivations(markers, returned, { + before, + after, }); - if (!moved) return false; - if (last && moved.before) { - from.instructions[from.instructions.length - 1] = { - ...last, - operationDebug: moved.before, - } as Ir.Instruction; + if (placed.before !== before) { + if (last) { + from.instructions[from.instructions.length - 1] = { + ...last, + operationDebug: placed.before!, + } as Ir.Instruction; + } else { + from.entryActivations = placed.before; + } } - if (first && moved.after) { - to.instructions[0] = { - ...first, - operationDebug: moved.after, - } as Ir.Instruction; - } else if (jump && moved.after) { - to.terminator = { ...jump, operationDebug: moved.after }; + if (placed.after !== after) { + if (first) { + to.instructions[0] = { + ...first, + operationDebug: placed.after!, + } as Ir.Instruction; + } else if (toJump) { + to.terminator = { ...toJump, operationDebug: placed.after! }; + } } - return true; + return placed.dropped; } function renameKey(map: Map, from: string, to: string): void { diff --git a/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts b/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts index b8915a1692..77174ff3e0 100644 --- a/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts +++ b/packages/bugc/src/optimizer/steps/common-subexpression-elimination.ts @@ -2,6 +2,7 @@ import * as Ir from "#ir"; import { BaseOptimizationStep, + recordDropped, type OptimizationContext, } from "../optimizer.js"; @@ -134,7 +135,18 @@ export class CommonSubexpressionEliminationStep extends BaseOptimizationStep { } block.instructions = newInstructions; - block.instructions = Ir.Utils.dropInstructions(block, eliminated); + const { instructions, replaced, dropped } = Ir.Utils.dropInstructions( + block, + eliminated, + { isEntry: blockId === func.entry }, + ); + block.instructions = instructions; + // Instructions kept here may be an expression's, for later blocks + for (const entry of pureExpressions.values()) { + entry.instruction = + replaced.get(entry.instruction) ?? entry.instruction; + } + recordDropped(context, this.name, dropped); } // Now apply replacements to phi nodes and terminators in a second diff --git a/packages/bugc/src/optimizer/steps/dead-code-elimination.ts b/packages/bugc/src/optimizer/steps/dead-code-elimination.ts index 147d4f86a9..210fd4b198 100644 --- a/packages/bugc/src/optimizer/steps/dead-code-elimination.ts +++ b/packages/bugc/src/optimizer/steps/dead-code-elimination.ts @@ -1,6 +1,7 @@ import * as Ir from "#ir"; import { BaseOptimizationStep, + recordDropped, type OptimizationContext, } from "../optimizer.js"; @@ -45,7 +46,7 @@ export class DeadCodeEliminationStep extends BaseOptimizationStep { } // Remove dead instructions - for (const block of func.blocks.values()) { + for (const [blockId, block] of func.blocks) { // Remove dead phi nodes if (block.phis) { const newPhis = block.phis.filter((phi) => { @@ -89,7 +90,13 @@ export class DeadCodeEliminationStep extends BaseOptimizationStep { // An invoke or return of a dead instruction moves onto a // neighbour on its path - block.instructions = Ir.Utils.dropInstructions(block, dead); + const { instructions, dropped } = Ir.Utils.dropInstructions( + block, + dead, + { isEntry: blockId === func.entry }, + ); + block.instructions = instructions; + recordDropped(context, this.name, dropped); } }); diff --git a/packages/bugc/src/optimizer/steps/inlining.ts b/packages/bugc/src/optimizer/steps/inlining.ts index b273aba2af..d73c15640c 100644 --- a/packages/bugc/src/optimizer/steps/inlining.ts +++ b/packages/bugc/src/optimizer/steps/inlining.ts @@ -288,6 +288,9 @@ export class InliningStep extends BaseOptimizationStep { terminator, predecessors: new Set(), debug: origBlock.debug, + ...(origBlock.entryActivations + ? { entryActivations: origBlock.entryActivations } + : {}), }); } diff --git a/packages/bugc/src/optimizer/steps/jump-optimization.ts b/packages/bugc/src/optimizer/steps/jump-optimization.ts index 662caea24a..660c5a44bd 100644 --- a/packages/bugc/src/optimizer/steps/jump-optimization.ts +++ b/packages/bugc/src/optimizer/steps/jump-optimization.ts @@ -1,6 +1,7 @@ import * as Ir from "#ir"; import { BaseOptimizationStep, + recordDropped, type OptimizationContext, } from "../optimizer.js"; @@ -19,8 +20,7 @@ export class JumpOptimizationStep extends BaseOptimizationStep { if ( block.instructions.length === 0 && block.phis.length === 0 && - block.terminator.kind === "jump" && - this.passActivations(func, blockId) + block.terminator.kind === "jump" ) { jumpTargets.set(blockId, block.terminator.target); } @@ -28,6 +28,8 @@ export class JumpOptimizationStep extends BaseOptimizationStep { // Track jump redirections for phi updates const redirections = new Map>(); + // Each redirected edge: from the block, to the first block skipped + const skipping: { from: string; skipped: string }[] = []; // Update all references to skip intermediate jumps for (const [blockId, block] of func.blocks) { @@ -52,6 +54,7 @@ export class JumpOptimizationStep extends BaseOptimizationStep { reason: `Optimized jump chain: ${originalTarget} -> ${finalTarget}`, }); block.terminator.target = finalTarget; + skipping.push({ from: blockId, skipped: originalTarget }); } } else if (block.terminator.kind === "branch") { // A branch must not skip ahead into a block that has phis: @@ -85,6 +88,7 @@ export class JumpOptimizationStep extends BaseOptimizationStep { reason: `Optimized true branch jump chain: ${originalTrue} -> ${trueFinal}`, }); block.terminator.trueTarget = trueFinal; + skipping.push({ from: blockId, skipped: originalTrue }); } if (falseFinal !== originalFalse) { // Track this redirection @@ -101,6 +105,7 @@ export class JumpOptimizationStep extends BaseOptimizationStep { reason: `Optimized false branch jump chain: ${originalFalse} -> ${falseFinal}`, }); block.terminator.falseTarget = falseFinal; + skipping.push({ from: blockId, skipped: originalFalse }); } } } @@ -108,6 +113,11 @@ export class JumpOptimizationStep extends BaseOptimizationStep { // Update phi nodes for redirected jumps. A skipped block goes // away only if nothing reaches it any more. const reachable = this.findReachableBlocks(func); + recordDropped( + context, + this.name, + this.placeSkippedActivations(func, skipping, jumpTargets, reachable), + ); this.updatePhisForRedirections( func, redirections, @@ -141,53 +151,85 @@ export class JumpOptimizationStep extends BaseOptimizationStep { } /** - * Whether a jump-only block may be skipped with respect to the - * `invoke` or `return` its jump carries (an inlined body's, with no - * instruction to carry it): only if each block that reaches it does - * so by a plain jump, which runs only on that path, and takes the - * markers. The block is kept otherwise. A jump-only block takes - * none, as it may be skipped too: this one stays, for now. + * Put the invokes and returns of the blocks each redirected edge + * skips (their entries and jumps: an inlined body's, with no + * instruction to carry them) onto that edge: the jump it starts from, + * or the entry of the block it reaches, if nothing else reaches that + * block. The markers neither can take. */ - private passActivations(func: Ir.Function, blockId: string): boolean { - const block = func.blocks.get(blockId)!; - const debug = block.terminator.operationDebug; - if (!Ir.Utils.carriesActivations(debug)) return true; - if (blockId === func.entry) return false; - - const jumps: Ir.Block[] = []; - for (const [id, from] of func.blocks) { - const t = from.terminator; - const reaches = - (t.kind === "jump" && t.target === blockId) || - (t.kind === "branch" && - (t.trueTarget === blockId || t.falseTarget === blockId)) || - (t.kind === "call" && t.continuation === blockId); - if (!reaches) continue; - if (t.kind !== "jump" || t.tailCall) return false; - if ( - id !== func.entry && - from.instructions.length === 0 && - from.phis.length === 0 - ) { - return false; + private placeSkippedActivations( + func: Ir.Function, + skipping: { from: string; skipped: string }[], + jumpTargets: Map, + reachable: Set, + ): Ir.Utils.ActivationMarker[] { + const dropped: Ir.Utils.ActivationMarker[] = []; + for (const { from, skipped } of skipping) { + const markers: Ir.Utils.ActivationMarker[] = []; + let returned: Ir.Instruction.Debug | undefined; + const visited = new Set(); + let current = skipped; + while (jumpTargets.has(current) && !visited.has(current)) { + visited.add(current); + const block = func.blocks.get(current)!; + for (const debug of [ + block.entryActivations, + block.terminator.operationDebug, + ]) { + markers.push(...Ir.Utils.markersOf(debug)); + if (Ir.Utils.activationsOf(debug?.context, "return").length > 0) { + returned = debug?.returned; + } + } + current = jumpTargets.get(current)!; + } + if (markers.length === 0) continue; + + const source = func.blocks.get(from)!; + const jump = + source.terminator.kind === "jump" && !source.terminator.tailCall + ? source.terminator + : undefined; + const target = func.blocks.get(current); + const entry = + target && + current !== func.entry && + this.edgesInto(func, current, reachable) === 1 + ? (target.entryActivations ?? {}) + : undefined; + const placed = Ir.Utils.placeActivations(markers, returned, { + before: jump?.operationDebug, + after: entry, + }); + if (jump && placed.before !== jump.operationDebug) { + source.terminator = { ...jump, operationDebug: placed.before! }; + } + if (target && entry && placed.after !== entry) { + target.entryActivations = placed.after; } - jumps.push(from); + dropped.push(...placed.dropped); } - const moved = jumps.map( - (from) => - Ir.Utils.moveActivations(debug, { - before: from.terminator.operationDebug, - })?.before, - ); - if (jumps.length === 0 || moved.some((m) => !m)) return false; - jumps.forEach((from, i) => { - from.terminator = { ...from.terminator, operationDebug: moved[i]! }; - }); - block.terminator = { - ...block.terminator, - operationDebug: Ir.Utils.withoutActivationsDebug(debug), - }; - return true; + return dropped; + } + + /** How many edges from reachable blocks reach a block */ + private edgesInto( + func: Ir.Function, + blockId: string, + reachable: Set, + ): number { + let count = 0; + for (const id of reachable) { + const t = func.blocks.get(id)?.terminator; + if (!t) continue; + if (t.kind === "jump" && t.target === blockId) count++; + if (t.kind === "branch") { + if (t.trueTarget === blockId) count++; + if (t.falseTarget === blockId) count++; + } + if (t.kind === "call" && t.continuation === blockId) count++; + } + return count; } private resolveJumpChain( diff --git a/packages/bugc/src/optimizer/steps/read-write-merging.ts b/packages/bugc/src/optimizer/steps/read-write-merging.ts index 87652e8a60..406e323483 100644 --- a/packages/bugc/src/optimizer/steps/read-write-merging.ts +++ b/packages/bugc/src/optimizer/steps/read-write-merging.ts @@ -1,6 +1,7 @@ import * as Ir from "#ir"; import { BaseOptimizationStep, + recordDropped, type OptimizationContext, } from "../optimizer.js"; @@ -253,26 +254,16 @@ export class ReadWriteMergingStep extends BaseOptimizationStep { return writes; } - // The writes' invokes and returns go on the first and the last - // instruction of the merge, as on one instruction: they must read - // as invokes, then returns - const markers = writes.map(({ operationDebug }) => ({ - invokes: Ir.Utils.activationsOf(operationDebug?.context, "invoke"), - returns: Ir.Utils.activationsOf(operationDebug?.context, "return"), - returned: operationDebug?.returned, - })); - const firstReturn = markers.findIndex(({ returns }) => returns.length); - if ( - firstReturn >= 0 && - markers.slice(firstReturn + 1).some(({ invokes }) => invokes.length) - ) { - return writes; - } - const invokes = markers.flatMap(({ invokes }) => invokes); - const returns = markers.flatMap(({ returns }) => returns); - const returned = markers - .filter(({ returns }) => returns.length) - .at(-1)?.returned; + // The writes' invokes and returns, in order + const markers = writes.flatMap(({ operationDebug }) => + Ir.Utils.markersOf(operationDebug), + ); + const returned = [...writes] + .reverse() + .find( + ({ operationDebug }) => + Ir.Utils.activationsOf(operationDebug?.context, "return").length > 0, + )?.operationDebug.returned; // Generate instructions to combine the values const instructions: Ir.Instruction[] = []; @@ -356,22 +347,35 @@ export class ReadWriteMergingStep extends BaseOptimizationStep { instructions.push(mergedWrite); + // The merge runs as the writes did, so their invokes go on its + // first instruction and their returns on its last, as for one + // instruction. If a return comes before an invoke, they split + // between the two (see `Ir.Utils.placeActivations`). const first = instructions[0]; - first.operationDebug = { - ...first.operationDebug, - context: Ir.Utils.withActivations(first.operationDebug.context, { - invokes, - }), - }; - if (returns.length > 0) { - mergedWrite.operationDebug = { - ...mergedWrite.operationDebug, - context: Ir.Utils.withActivations(mergedWrite.operationDebug.context, { - returns, - }), - ...(returned ? { returned } : {}), - }; - } + const ordered = markers.every( + (marker, i) => + !("invoke" in marker) || + !markers.slice(0, i).some((earlier) => "return" in earlier), + ); + const invokes = markers.filter((marker) => "invoke" in marker); + const returns = markers.filter((marker) => "return" in marker); + const placed = ordered + ? { + before: Ir.Utils.placeActivations(invokes, undefined, { + after: first.operationDebug, + }).after, + after: Ir.Utils.placeActivations(returns, returned, { + before: mergedWrite.operationDebug, + }).before, + dropped: [], + } + : Ir.Utils.placeActivations(markers, returned, { + before: first.operationDebug, + after: mergedWrite.operationDebug, + }); + first.operationDebug = placed.before!; + mergedWrite.operationDebug = placed.after!; + recordDropped(context, this.name, placed.dropped); // Update the temp counter for next time this.nextTempCounter = tempCounter; From f7ead0131ea48a2dc7220b0500adaafb1da452df Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 14 Oct 2026 21:39:17 -0400 Subject: [PATCH 5/5] bugc: inline the returned debug of markers moved onto jumps and entries 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. --- .../bugc/src/optimizer/steps/inlining.test.ts | 45 +++++++++++++++++++ packages/bugc/src/optimizer/steps/inlining.ts | 21 ++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/bugc/src/optimizer/steps/inlining.test.ts b/packages/bugc/src/optimizer/steps/inlining.test.ts index 36a4d5919e..e16b2aef6c 100644 --- a/packages/bugc/src/optimizer/steps/inlining.test.ts +++ b/packages/bugc/src/optimizer/steps/inlining.test.ts @@ -13,6 +13,8 @@ import { describe, it, expect } from "vitest"; import { compile } from "#compiler"; import * as Ir from "#ir"; import { executeProgram } from "#test/evm/behavioral"; +import { OptimizationContextImpl } from "../optimizer.js"; +import { InliningStep } from "./inlining.js"; import type * as Format from "@ethdebug/format"; import { Program } from "@ethdebug/format"; @@ -214,3 +216,46 @@ code { r = count(0, 5); }`; }); }); }); + +describe("markers the optimizer moved onto a callee's jumps and entries", () => { + it("inline their debug once returned, as an instruction's", async () => { + const module = await optimizedIr( + `name Moved; +define { + function cap(x: uint256) -> uint256 { + let b = x * 2; + if (b > 100) { b = 100; } + return b; + }; +} +storage { [0] r: uint256; [1] t: uint256; } +create { t = 70; } +code { r = cap(t); }`, + 0, + ); + const callee = module.functions.get("cap")!; + const [id, block] = [...callee.blocks].find(([id]) => id !== callee.entry)!; + const returned = { inlineSites: ["inner$"] }; + block.entryActivations = { returned }; + const jump = [...callee.blocks.values()].find( + (b) => b.terminator.kind === "jump", + )!; + jump.terminator.operationDebug = { + ...jump.terminator.operationDebug, + returned, + }; + + const inlined = new InliningStep().run( + module, + new OptimizationContextImpl(), + ); + const blocks = [...inlined.main.blocks.values()]; + const entry = blocks.find((b) => b.id.endsWith(`$${id}`))!.entryActivations; + expect(entry?.returned?.inlineSites).toEqual(["inl0$", "inl0$inner$"]); + const jumps = blocks + .filter((b) => b.id.startsWith("inl0$")) + .map((b) => b.terminator.operationDebug.returned?.inlineSites) + .filter((sites) => sites); + expect(jumps).toContainEqual(["inl0$", "inl0$inner$"]); + }); +}); diff --git a/packages/bugc/src/optimizer/steps/inlining.ts b/packages/bugc/src/optimizer/steps/inlining.ts index d73c15640c..7fa040cbf9 100644 --- a/packages/bugc/src/optimizer/steps/inlining.ts +++ b/packages/bugc/src/optimizer/steps/inlining.ts @@ -153,6 +153,19 @@ export class InliningStep extends BaseOptimizationStep { const inlineSites = (debug: Ir.Instruction.Debug | undefined) => inlineSitesFor(prefix, debug); + // A return the optimizer moved onto a jump or a block's entry + // brings its debug once returned: inlined here, too + const withInlinedReturned = (debug: Ir.Instruction.Debug) => + debug.returned + ? { + ...debug, + returned: { + ...debug.returned, + inlineSites: inlineSites(debug.returned), + }, + } + : debug; + const remapValue = (v: Ir.Value): Ir.Value => { if (v.kind !== "temp") return v; const sub = paramSubst.get(v.id); @@ -274,7 +287,9 @@ export class InliningStep extends BaseOptimizationStep { // between its blocks) is part of the body, too terminator = remapTerminator(t, remapValue, blockRename); terminator.operationDebug = { - ...Ir.Utils.addTransform(terminator.operationDebug, "inline"), + ...withInlinedReturned( + Ir.Utils.addTransform(terminator.operationDebug, "inline"), + ), inlineSites: inlineSites(t.operationDebug), ...(t.operationDebug?.origin ? { origin: t.operationDebug.origin } @@ -289,7 +304,9 @@ export class InliningStep extends BaseOptimizationStep { predecessors: new Set(), debug: origBlock.debug, ...(origBlock.entryActivations - ? { entryActivations: origBlock.entryActivations } + ? { + entryActivations: withInlinedReturned(origBlock.entryActivations), + } : {}), }); }