From fa24145b3629fa8647a6d4de4d258fef772a4ada Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 14 Oct 2026 21:48:27 -0400 Subject: [PATCH 1/4] bugc: list the caller's locals at the entry of inlined code as at the call Inlining records where the call was before optimization (callOrigin) on the op that carries the invoke; the entry of the inlined code is placed there, so the caller's locals are listed as the call lists them. withPlacement keeps callOrigin where debugs are combined. --- packages/bugc/CHANGELOG.md | 5 + .../debug/local-variables.soundness.test.ts | 213 ++++++++++++++++++ .../bugc/src/evmgen/debug/local-variables.ts | 14 +- packages/bugc/src/ir/spec/instruction.ts | 6 + packages/bugc/src/ir/utils/debug.ts | 6 +- packages/bugc/src/optimizer/steps/inlining.ts | 28 ++- 6 files changed, 262 insertions(+), 10 deletions(-) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 7bb8f32bdb..21e94e5949 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -92,6 +92,10 @@ support. Changes to the specification itself are tracked in the root 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]). +- At the JUMPDEST where an inlined function's code starts, the caller's + locals are listed as they are at the call, with the same pointers. + Before, a local with a version the optimizer removed was listed with + no pointer at that one step ([#380]). - 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 @@ -571,6 +575,7 @@ First publication. [#377]: https://github.com/ethdebug/format/pull/377 [#378]: https://github.com/ethdebug/format/pull/378 [#379]: https://github.com/ethdebug/format/pull/379 +[#380]: https://github.com/ethdebug/format/pull/380 [#383]: https://github.com/ethdebug/format/pull/383 [#384]: https://github.com/ethdebug/format/pull/384 [#388]: https://github.com/ethdebug/format/pull/388 diff --git a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts index 34b4fd5e07..40b94338b6 100644 --- a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts +++ b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts @@ -14,7 +14,9 @@ import { Program } from "@ethdebug/format"; import { executeProgram } from "#test/evm/behavioral"; import { type Level, + type Local, type LocalsProgram, + type LocalsTrace, type Shape, check, localsOf, @@ -57,6 +59,96 @@ code { r = h; }`; +/** The same, with the call only on one branch */ +const callInBranch = `name CallInBranch; +define { + struct P { a: uint64; b: uint32; }; + function f() -> uint256 { return r + 3; }; +} +storage { [0] r: uint256; [1] ps: mapping; [2] c: uint256; } +create { c = 1; } +code { + let p: P = ps[msg.sender]; + p.a = p.a + 5; + if (c == 1) { r = f(); } + p.b = p.b + 1; + ps[msg.sender] = p; +}`; + +/** The same, with the call where two branches meet */ +const callAfterBranch = `name CallAfterBranch; +define { + struct P { a: uint64; b: uint32; }; + function f() -> uint256 { return r + 3; }; +} +storage { + [0] r: uint256; [1] ps: mapping; [2] c: uint256; + [3] s: uint256; +} +create { c = 1; } +code { + let p: P = ps[msg.sender]; + p.a = p.a + 5; + if (c == 1) { s = 1; } + let h = f(); + p.b = p.b + 1; + ps[msg.sender] = p; + r = h; +}`; + +/** The same, with a constant argument: the callee's first + * instruction folds away once inlined */ +const constantArgument = `name ConstantArgument; +define { + struct P { a: uint64; b: uint32; }; + function f(a: uint256) -> uint256 { return a + 3; }; +} +storage { [0] r: uint256; [1] ps: mapping; } +create {} +code { + let p: P = ps[msg.sender]; + p.a = p.a + 5; + let h = f(4); + if (h == 0) { return; } + p.b = p.b + 1; + ps[msg.sender] = p; + r = h; +}`; + +/** The same, in an inlined function, across a call it inlines */ +const nestedCall = `name NestedCall; +define { + struct P { a: uint64; b: uint32; }; + function g(q: uint256) -> uint256 { let w = q * 3; return w; }; + function f(n: uint256) -> uint256 { + let p: P = ps[msg.sender]; + p.a = p.a + 5; + let t = g(n); + p.b = p.b + 1; + ps[msg.sender] = p; + return t; + }; +} +storage { [0] r: uint256; [1] ps: mapping; } +create { r = 1; } +code { r = f(r); }`; + +/** A local given a constant between two calls */ +const constantBetweenCalls = `name ConstantBetweenCalls; +define { + function f(a: uint256) -> uint256 { return a + 1; }; + function g(b: uint256) -> uint256 { return b * 2; }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 3; } +code { + let x = s + 1; + let h = f(x); + x = 7; + let k = g(h); + r = k + x; +}`; + describe("which locals are listed", () => { it("(a) does not list a local before its declaration", async () => { const source = `name PreDef; @@ -271,6 +363,72 @@ code { ]; }; + /** The steps just before each invoke of `callee` */ + const beforeInvokes = (run: LocalsTrace, callee: string) => + run.steps.flatMap((step, k) => + k > 0 && + activations(run.instructionAt(step)?.context, "invoke").includes(callee) + ? [run.steps[k - 1]] + : [], + ); + + it.each([2, 3] as const)( + "(l) lists the caller's locals located where inlined code starts at O%i", + async (level) => { + // The step before the invoke is the JUMPDEST that starts the + // inlined code. It changes nothing, so `p` is where it was at the + // step before (and holds its value there: see `programs`). At + // O3, block merging can leave no entry between them + const sources = { + // (by the function called) + acrossCall: [acrossCall, "f"], + callInBranch: [callInBranch, "f"], + callAfterBranch: [callAfterBranch, "f"], + constantArgument: [constantArgument, "f"], + nestedCall: [nestedCall, "g"], + }; + for (const [name, [source, callee]] of Object.entries(sources)) { + const run = await traceLocals(source, level); + const steps = beforeInvokes(run, callee); + expect(steps.length, name).toBe(1); + for (const step of steps) { + if (step.opcode !== "JUMPDEST" && level === 3) continue; + expect(step.opcode, name).toBe("JUMPDEST"); + const p = localsOf(run.instructionAt(step)?.context).find( + (v) => v.identifier === "p", + ); + expect(p?.pointer, `${name}, pc ${step.pc}`).toBeDefined(); + } + } + }, + ); + + it.each([2, 3] as const)( + "(m) lists a local as it is just before an inlined call at O%i", + async (level) => { + // `g`'s code starts right after `f`'s returns, but `x = 7` comes + // between the calls: at `g`'s entry, `x` is 7, which has no + // place, so `x` has no pointer (its old place still holds 4); + // `h` is located. At O3, `g`'s code follows `f`'s return in one + // block, with no entry between them + const run = await traceLocals(constantBetweenCalls, level); + const steps = beforeInvokes(run, "g"); + expect(steps.length).toBe(1); + for (const step of steps) { + const context = run.instructionAt(step)?.context; + if (level === 3) { + expect(activations(context, "return")).toContain("f"); + continue; + } + expect(step.opcode).toBe("JUMPDEST"); + const pointer = (name: string) => + localsOf(context).find((v) => v.identifier === name)?.pointer; + expect(pointer("x"), `x at pc ${step.pc}`).toBeUndefined(); + expect(pointer("h"), `h at pc ${step.pc}`).toBeDefined(); + } + }, + ); + it.each([2, 3] as const)( "(h) lists an inlined function's locals only within its body at O%i", async (level) => { @@ -967,6 +1125,61 @@ code { h: { values: [3n] }, }, }, + ...( + [ + ["on one branch", callInBranch, {}], + ["after a branch", callAfterBranch, { h: { values: [3n] } }], + [ + "with a constant argument", + constantArgument, + { h: { values: [7n] }, a: { values: [4n] } }, + ], + [ + "in an inlined function", + nestedCall, + { + n: { values: [1n] }, + t: { values: [3n] }, + q: { values: [1n] }, + w: { values: [3n] }, + }, + ], + ] as Array<[string, string, Record]> + ).map( + ([where, source, others]): LocalsProgram => ({ + name: `a struct local across a call ${where}`, + source, + locals: { + p: { + shape: { + kind: "struct", + fields: { + a: { kind: "scalar", size: 8 }, + b: { kind: "scalar", size: 4 }, + }, + }, + everyLevel: true, + values: [ + { a: 0n, b: 0n }, + { value: { a: 5n, b: 0n }, after: "p.a = p.a + 5" }, + { value: { a: 5n, b: 1n }, after: "p.b = p.b + 1" }, + ], + }, + ...others, + }, + }), + ), + { + name: "a local given a constant between two calls", + source: constantBetweenCalls, + locals: { + x: { values: [4n, { value: 7n, after: "x = 7" }] }, + h: { values: [5n] }, + k: { values: [10n] }, + a: { values: [4n] }, + b: { values: [5n] }, + }, + }, { name: "a void call", source: `name VoidCall; diff --git a/packages/bugc/src/evmgen/debug/local-variables.ts b/packages/bugc/src/evmgen/debug/local-variables.ts index 53a0da2db1..deb18cc358 100644 --- a/packages/bugc/src/evmgen/debug/local-variables.ts +++ b/packages/bugc/src/evmgen/debug/local-variables.ts @@ -796,8 +796,16 @@ function enrichFunction( for (const [blockId, block] of func.blocks) { // The block's entry: just before its first instruction (or its // terminator), with that instruction's scope and inline site. Just - // before an inlined function's invoke, that is the caller's. + // before an inlined function's invoke, that is the caller's, at + // the call: the instruction's own origin is in the callee, where + // the caller's versions are not placed, so the position is the + // call's before optimization (`callOrigin`). A call is a + // terminator, and its result is defined in its continuation, so + // the call's own position is the caller's state as the call + // begins, as for the call itself; `stamp` shifts the position + // back one, so the origin is given one past it. const first = block.instructions[0] ?? block.terminator; + const callOrigin = first.operationDebug?.callOrigin; const result = spilled.get(blockId); const sites = first.operationDebug?.inlineSites ?? []; const invokes = Ir.Utils.activationsOf( @@ -809,6 +817,10 @@ function enrichFunction( ? { ...first.operationDebug, inlineSites: sites.slice(0, -Math.min(invokes, sites.length)), + origin: callOrigin && { + ...callOrigin, + index: callOrigin.index + 1, + }, } : first.operationDebug, blockId, diff --git a/packages/bugc/src/ir/spec/instruction.ts b/packages/bugc/src/ir/spec/instruction.ts index 6d6462876b..0ae29c1db4 100644 --- a/packages/bugc/src/ir/spec/instruction.ts +++ b/packages/bugc/src/ir/spec/instruction.ts @@ -63,6 +63,12 @@ export namespace Instruction { inlineSites?: string[]; /** Where the instruction was before optimization */ origin?: Function.Origin; + /** + * For the op that invokes an inlined function: where the call was + * before optimization. Just before the invoke, the caller's + * variables are as they were there, at the call. + */ + callOrigin?: Function.Origin; /** * Listed locals whose value may be on the stack: code generation * gives each a stack pointer where the stack holds its temp. diff --git a/packages/bugc/src/ir/utils/debug.ts b/packages/bugc/src/ir/utils/debug.ts index 4ab959633a..56c1e7c4aa 100644 --- a/packages/bugc/src/ir/utils/debug.ts +++ b/packages/bugc/src/ir/utils/debug.ts @@ -16,8 +16,9 @@ export function combineDebugContexts( return withPlacement(combineContexts(debugs), debugs[0]); } -/** Keep an instruction's inline sites, origin and (if it returns from - * an inlined function) its debug once returned on a new debug. */ +/** Keep an instruction's inline sites, origin, (if it invokes an + * inlined function) its call's origin and (if it returns from one) its + * debug once returned on a new debug. */ function withPlacement( debug: Ir.Instruction.Debug, from: Ir.Instruction.Debug | undefined, @@ -26,6 +27,7 @@ function withPlacement( ...debug, ...(from?.inlineSites ? { inlineSites: from.inlineSites } : {}), ...(from?.origin ? { origin: from.origin } : {}), + ...(from?.callOrigin ? { callOrigin: from.callOrigin } : {}), ...(from?.returned ? { returned: from.returned } : {}), }; } diff --git a/packages/bugc/src/optimizer/steps/inlining.ts b/packages/bugc/src/optimizer/steps/inlining.ts index 7fa040cbf9..84ef88bddc 100644 --- a/packages/bugc/src/optimizer/steps/inlining.ts +++ b/packages/bugc/src/optimizer/steps/inlining.ts @@ -209,7 +209,17 @@ export class InliningStep extends BaseOptimizationStep { call.operationDebug?.context, "invoke", ); - const callDebug = withoutActivationsDebug(call.operationDebug); + const { callOrigin: pendingOrigin, ...callDebug } = withoutActivationsDebug( + call.operationDebug, + ); + // Where the call of the outermost function invoked was before + // optimization: just before the invoke is the caller's code there + const callOrigin = + pendingInvokes.length > 0 ? pendingOrigin : call.operationDebug?.origin; + const invoked = (debug: Ir.Instruction.Debug): Ir.Instruction.Debug => { + const { callOrigin: _, ...rest } = debug; + return callOrigin ? { ...rest, callOrigin } : rest; + }; const callSiteCode = (callDebug.context as { code?: unknown } | undefined) ?.code; @@ -338,11 +348,13 @@ export class InliningStep extends BaseOptimizationStep { const first = block.instructions[0]; const t = block.terminator; if (first) { - first.operationDebug = withActivationsDebug( - callSiteCode === undefined - ? first.operationDebug - : gatherCallSite(first.operationDebug, callSiteCode), - { invokes }, + first.operationDebug = invoked( + withActivationsDebug( + callSiteCode === undefined + ? first.operationDebug + : gatherCallSite(first.operationDebug, callSiteCode), + { invokes }, + ), ); break; } @@ -354,7 +366,9 @@ export class InliningStep extends BaseOptimizationStep { entryId = t.target; continue; } - t.operationDebug = withActivationsDebug(t.operationDebug, { invokes }); + t.operationDebug = invoked( + withActivationsDebug(t.operationDebug, { invokes }), + ); break; } From ce451c2c6a2ef2032f713dca84e982a9e404da30 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 14 Oct 2026 21:54:37 -0400 Subject: [PATCH 2/4] bugc: carry an invoke's call origin where the optimizer moves it When a pass moves an op's invoke onto a neighbour (dropInstructions, jump optimization, block merging, read/write merging), the call origin of its outermost invoke goes with it, as returned goes with a return. Inlining uses Ir.Utils.withoutActivationsDebug. --- packages/bugc/src/ir/utils/debug.test.ts | 75 ++++++++++++++++++- packages/bugc/src/ir/utils/debug.ts | 47 ++++++++++-- .../bugc/src/optimizer/steps/block-merging.ts | 10 ++- .../bugc/src/optimizer/steps/inlining.test.ts | 29 +++++++ packages/bugc/src/optimizer/steps/inlining.ts | 18 ++--- .../src/optimizer/steps/jump-optimization.ts | 12 ++- .../src/optimizer/steps/read-write-merging.ts | 22 ++++-- 7 files changed, 178 insertions(+), 35 deletions(-) diff --git a/packages/bugc/src/ir/utils/debug.test.ts b/packages/bugc/src/ir/utils/debug.test.ts index 1656a18fae..dd18364982 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 { markersOf, placeActivations } from "./debug.js"; +import { dropInstructions, markersOf, placeActivations } from "./debug.js"; const invoke = (identifier: string) => ({ invoke: { identifier } }); const ret = (identifier: string) => ({ return: { identifier } }); @@ -86,4 +86,77 @@ describe("placeActivations", () => { }); expect(placed.before?.returned).toBe(returned); }); + + const at = (block: string): Ir.Function.Origin => ({ + function: "main", + block, + index: 0, + }); + + it("carries `callOrigin` with a moved invoke", () => { + const placed = placeActivations( + [invoke("f")], + undefined, + { before: plain(), after: { ...plain(), callOrigin: at("other") } }, + at("call"), + ); + expect(placed.after?.callOrigin).toEqual(at("call")); + expect(placed.before?.callOrigin).toBeUndefined(); + }); + + it("keeps the call origin of the outermost invoke", () => { + // Moved after, an invoke comes before the op's own: it is outermost + const after = placeActivations( + [invoke("f")], + undefined, + { after: { ...debug(invoke("g")), callOrigin: at("g") } }, + at("f"), + ); + expect(after.after?.callOrigin).toEqual(at("f")); + // Moved before, it comes after the op's own + const before = placeActivations( + [invoke("f")], + undefined, + { before: { ...debug(invoke("e")), callOrigin: at("e") } }, + at("f"), + ); + expect(before.before?.callOrigin).toEqual(at("e")); + }); +}); + +describe("dropInstructions", () => { + it("carries a dropped instruction's invoke with its `callOrigin`", () => { + const callOrigin: Ir.Function.Origin = { + function: "main", + block: "entry", + index: 3, + }; + const dropped = { + kind: "env", + op: "msg_sender", + dest: "t1", + operationDebug: { ...debug(invoke("f")), callOrigin }, + } as unknown as Ir.Instruction; + const kept = { + kind: "env", + op: "msg_value", + dest: "t2", + operationDebug: plain(), + } as unknown as Ir.Instruction; + const block = { + id: "inl0$entry", + phis: [], + instructions: [dropped, kept], + terminator: { kind: "jump", target: "next", operationDebug: {} }, + predecessors: new Set(), + debug: {}, + } as unknown as Ir.Block; + + const { instructions } = dropInstructions(block, new Set([dropped]), { + isEntry: false, + }); + expect(instructions).toHaveLength(1); + expect(names(instructions[0].operationDebug)).toEqual(["invoke f"]); + expect(instructions[0].operationDebug.callOrigin).toEqual(callOrigin); + }); }); diff --git a/packages/bugc/src/ir/utils/debug.ts b/packages/bugc/src/ir/utils/debug.ts index 56c1e7c4aa..93d8e028dc 100644 --- a/packages/bugc/src/ir/utils/debug.ts +++ b/packages/bugc/src/ir/utils/debug.ts @@ -579,21 +579,43 @@ function inOrder(markers: ActivationMarker[]): boolean { ); } -/** A debug whose markers are these (in order: see `inOrder`) */ +/** + * The call origin of the first of these debugs to carry an invoke: + * that of the outermost invoke among them, as they run in order + */ +export function callOriginOf( + debugs: (Ir.Instruction.Debug | undefined)[], +): Ir.Function.Origin | undefined { + return debugs.find( + (debug) => activationsOf(debug?.context, "invoke").length > 0, + )?.callOrigin; +} + +/** Whether markers include an invoke */ +const hasInvoke = (markers: ActivationMarker[]) => + markers.some((m) => "invoke" in m); + +/** + * A debug whose markers are these (in order: see `inOrder`), with the + * debug once returned of their returns and the call origin of their + * first invoke + */ function withMarkers( debug: Ir.Instruction.Debug, markers: ActivationMarker[], returned: Ir.Instruction.Debug | undefined, + callOrigin: Ir.Function.Origin | 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; + const { context: _, returned: __, callOrigin: ___, ...rest } = debug; return { ...rest, ...(context ? { context } : {}), ...(returned ? { returned } : {}), + ...(callOrigin ? { callOrigin } : {}), }; } @@ -629,6 +651,8 @@ export function placeActivations( before, after, }: { before?: Ir.Instruction.Debug; after?: Ir.Instruction.Debug }, + // The call origin of the markers' first invoke (see `callOriginOf`) + callOrigin?: Ir.Function.Origin, ): Placement { if (markers.length === 0) return { before, after, dropped: [] }; const own = { before: markersOf(before), after: markersOf(after) }; @@ -662,6 +686,9 @@ export function placeActivations( before, [...own.before, ...early], returns(early) ? (returned ?? before.returned) : before.returned, + hasInvoke(own.before) || !hasInvoke(early) + ? before.callOrigin + : callOrigin, ) : before, after: @@ -674,6 +701,7 @@ export function placeActivations( : returns(late) ? returned : after.returned, + hasInvoke(late) ? callOrigin : after.callOrigin, ) : after, dropped: [], @@ -707,6 +735,7 @@ export function dropInstructions( // The markers of the dropped instructions since the last one kept let pending: ActivationMarker[] = []; let pendingReturned: Ir.Instruction.Debug | undefined; + let pendingCallOrigin: Ir.Function.Origin | undefined; const settle = (after: Ir.Instruction.Debug | undefined) => { if (pending.length === 0) return after; @@ -716,13 +745,16 @@ export function dropInstructions( : isEntry ? undefined : (block.entryActivations ?? {}); - const placed = placeActivations(pending, pendingReturned, { - before, - after, - }); + const placed = placeActivations( + pending, + pendingReturned, + { before, after }, + pendingCallOrigin, + ); dropped.push(...placed.dropped); pending = []; pendingReturned = undefined; + pendingCallOrigin = undefined; if (placed.before !== before) { if (last) { const next = { ...last, operationDebug: placed.before! }; @@ -740,6 +772,9 @@ export function dropInstructions( for (const inst of block.instructions) { if (drop.has(inst)) { + if (!hasInvoke(pending)) { + pendingCallOrigin = callOriginOf([inst.operationDebug]); + } pending.push(...markersOf(inst.operationDebug)); if (activationsOf(inst.operationDebug.context, "return").length > 0) { pendingReturned = inst.operationDebug.returned; diff --git a/packages/bugc/src/optimizer/steps/block-merging.ts b/packages/bugc/src/optimizer/steps/block-merging.ts index b4dcd74327..7fed56c4c0 100644 --- a/packages/bugc/src/optimizer/steps/block-merging.ts +++ b/packages/bugc/src/optimizer/steps/block-merging.ts @@ -143,10 +143,12 @@ function moveJumpActivations( ? undefined : (from.entryActivations ?? {}); const after = (first ?? toJump)?.operationDebug; - const placed = Ir.Utils.placeActivations(markers, returned, { - before, - after, - }); + const placed = Ir.Utils.placeActivations( + markers, + returned, + { before, after }, + Ir.Utils.callOriginOf([jump, entry]), + ); if (placed.before !== before) { if (last) { from.instructions[from.instructions.length - 1] = { diff --git a/packages/bugc/src/optimizer/steps/inlining.test.ts b/packages/bugc/src/optimizer/steps/inlining.test.ts index e16b2aef6c..3aea911551 100644 --- a/packages/bugc/src/optimizer/steps/inlining.test.ts +++ b/packages/bugc/src/optimizer/steps/inlining.test.ts @@ -259,3 +259,32 @@ code { r = cap(t); }`, expect(jumps).toContainEqual(["inl0$", "inl0$inner$"]); }); }); + +describe("an inlined function's invoke the optimizer moves", () => { + it("keeps where the call was before optimization", async () => { + // The callee's first instruction reads msg.sender, which the caller + // read already: common subexpression elimination drops it, and the + // invoke moves onto the next instruction + const module = await optimizedIr( + `name MovedInvoke; +define { + function f() -> uint256 { return (msg.sender as uint256) % 7; }; +} +storage { [0] r: uint256; [1] s: mapping; } +create {} +code { r = s[msg.sender]; r = r + f(); }`, + 2, + ); + const carriers = [...module.main.blocks.values()] + .flatMap((block) => [...block.instructions, block.terminator]) + .filter( + (op) => + Ir.Utils.activationsOf(op.operationDebug?.context, "invoke").length > + 0, + ); + expect(carriers).toHaveLength(1); + const [carrier] = carriers; + expect("op" in carrier && carrier.op).not.toBe("msg_sender"); + expect(carrier.operationDebug.callOrigin?.function).toBe("main"); + }); +}); diff --git a/packages/bugc/src/optimizer/steps/inlining.ts b/packages/bugc/src/optimizer/steps/inlining.ts index 84ef88bddc..075a2eb529 100644 --- a/packages/bugc/src/optimizer/steps/inlining.ts +++ b/packages/bugc/src/optimizer/steps/inlining.ts @@ -209,9 +209,8 @@ export class InliningStep extends BaseOptimizationStep { call.operationDebug?.context, "invoke", ); - const { callOrigin: pendingOrigin, ...callDebug } = withoutActivationsDebug( - call.operationDebug, - ); + const { callOrigin: pendingOrigin, ...callDebug } = + Ir.Utils.withoutActivationsDebug(call.operationDebug); // Where the call of the outermost function invoked was before // optimization: just before the invoke is the caller's code there const callOrigin = @@ -238,7 +237,9 @@ export class InliningStep extends BaseOptimizationStep { // returned: the caller's, at the call or past those returns let after = callDebug; if (pendingReturns.length > 0) { - after = withoutActivationsDebug(continuation!.terminator.operationDebug); + after = Ir.Utils.withoutActivationsDebug( + continuation!.terminator.operationDebug, + ); continuation!.terminator.operationDebug = after; } const invokes = [...pendingInvokes, inlineInvoke]; @@ -746,15 +747,6 @@ function withActivationsDebug( return { ...debug, ...(context ? { context } : {}) }; } -/** A debug without activation discriminators */ -function withoutActivationsDebug( - debug: Ir.Instruction.Debug | undefined, -): Ir.Instruction.Debug { - const { context: _, ...rest } = debug ?? {}; - const context = Ir.Utils.withoutActivations(debug?.context); - return { ...rest, ...(context ? { context } : {}) }; -} - /** * Preserve the call site's `code` range on the entry instruction. * The instruction already maps to the callee body, so the two source diff --git a/packages/bugc/src/optimizer/steps/jump-optimization.ts b/packages/bugc/src/optimizer/steps/jump-optimization.ts index 660c5a44bd..51e4553744 100644 --- a/packages/bugc/src/optimizer/steps/jump-optimization.ts +++ b/packages/bugc/src/optimizer/steps/jump-optimization.ts @@ -167,6 +167,7 @@ export class JumpOptimizationStep extends BaseOptimizationStep { for (const { from, skipped } of skipping) { const markers: Ir.Utils.ActivationMarker[] = []; let returned: Ir.Instruction.Debug | undefined; + const carriers: (Ir.Instruction.Debug | undefined)[] = []; const visited = new Set(); let current = skipped; while (jumpTargets.has(current) && !visited.has(current)) { @@ -177,6 +178,7 @@ export class JumpOptimizationStep extends BaseOptimizationStep { block.terminator.operationDebug, ]) { markers.push(...Ir.Utils.markersOf(debug)); + carriers.push(debug); if (Ir.Utils.activationsOf(debug?.context, "return").length > 0) { returned = debug?.returned; } @@ -197,10 +199,12 @@ export class JumpOptimizationStep extends BaseOptimizationStep { this.edgesInto(func, current, reachable) === 1 ? (target.entryActivations ?? {}) : undefined; - const placed = Ir.Utils.placeActivations(markers, returned, { - before: jump?.operationDebug, - after: entry, - }); + const placed = Ir.Utils.placeActivations( + markers, + returned, + { before: jump?.operationDebug, after: entry }, + Ir.Utils.callOriginOf(carriers), + ); if (jump && placed.before !== jump.operationDebug) { source.terminator = { ...jump, operationDebug: placed.before! }; } diff --git a/packages/bugc/src/optimizer/steps/read-write-merging.ts b/packages/bugc/src/optimizer/steps/read-write-merging.ts index 406e323483..b6bfce683c 100644 --- a/packages/bugc/src/optimizer/steps/read-write-merging.ts +++ b/packages/bugc/src/optimizer/steps/read-write-merging.ts @@ -258,6 +258,9 @@ export class ReadWriteMergingStep extends BaseOptimizationStep { const markers = writes.flatMap(({ operationDebug }) => Ir.Utils.markersOf(operationDebug), ); + const callOrigin = Ir.Utils.callOriginOf( + writes.map(({ operationDebug }) => operationDebug), + ); const returned = [...writes] .reverse() .find( @@ -361,18 +364,23 @@ export class ReadWriteMergingStep extends BaseOptimizationStep { const returns = markers.filter((marker) => "return" in marker); const placed = ordered ? { - before: Ir.Utils.placeActivations(invokes, undefined, { - after: first.operationDebug, - }).after, + before: Ir.Utils.placeActivations( + invokes, + undefined, + { after: first.operationDebug }, + callOrigin, + ).after, after: Ir.Utils.placeActivations(returns, returned, { before: mergedWrite.operationDebug, }).before, dropped: [], } - : Ir.Utils.placeActivations(markers, returned, { - before: first.operationDebug, - after: mergedWrite.operationDebug, - }); + : Ir.Utils.placeActivations( + markers, + returned, + { before: first.operationDebug, after: mergedWrite.operationDebug }, + callOrigin, + ); first.operationDebug = placed.before!; mergedWrite.operationDebug = placed.after!; recordDropped(context, this.name, placed.dropped); From 7dbbb41ef8b19ec34ce842bbc09d37f378aaf6c8 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 15 Oct 2026 11:58:11 -0400 Subject: [PATCH 3/4] bugc: give a split invoke no call origin but its own placeActivations takes the first invoke's call origin; where the invokes split between two ops, those after get none. Combining debugs keeps the call origin of the debug whose invoke comes first. Test (m) at O3 with the second call on a branch. --- .../debug/local-variables.soundness.test.ts | 57 ++++++++++++++----- packages/bugc/src/ir/utils/debug.test.ts | 37 +++++++++++- packages/bugc/src/ir/utils/debug.ts | 16 +++++- 3 files changed, 91 insertions(+), 19 deletions(-) diff --git a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts index 40b94338b6..70a42d5bb2 100644 --- a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts +++ b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts @@ -149,6 +149,22 @@ code { r = k + x; }`; +/** The same, with the second call on a branch */ +const constantBeforeBranch = `name ConstantBeforeBranch; +define { + function f(a: uint256) -> uint256 { return a + 1; }; + function g(b: uint256) -> uint256 { return b * 2; }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 3; } +code { + let x = s + 1; + let h = f(x); + x = 7; + if (h > 1) { r = g(h); } + r = r + x; +}`; + describe("which locals are listed", () => { it("(a) does not list a local before its declaration", async () => { const source = `name PreDef; @@ -403,26 +419,27 @@ code { }, ); - it.each([2, 3] as const)( - "(m) lists a local as it is just before an inlined call at O%i", - async (level) => { - // `g`'s code starts right after `f`'s returns, but `x = 7` comes - // between the calls: at `g`'s entry, `x` is 7, which has no - // place, so `x` has no pointer (its old place still holds 4); - // `h` is located. At O3, `g`'s code follows `f`'s return in one - // block, with no entry between them - const run = await traceLocals(constantBetweenCalls, level); + // At O3, `g`'s code follows `f`'s return in one block in + // `constantBetweenCalls`, with no entry between them + it.each([ + [2, "constantBetweenCalls", constantBetweenCalls], + [2, "constantBeforeBranch", constantBeforeBranch], + [3, "constantBeforeBranch", constantBeforeBranch], + ] as const)( + "(m) lists a local as it is just before an inlined call at O%i (%s)", + async (level, _, source) => { + // `x = 7` comes between the calls: at `g`'s entry, `x` is 7, + // which has no place, so `x` has no pointer (its old place still + // holds 4); `h` is located + const run = await traceLocals(source, level); const steps = beforeInvokes(run, "g"); expect(steps.length).toBe(1); for (const step of steps) { - const context = run.instructionAt(step)?.context; - if (level === 3) { - expect(activations(context, "return")).toContain("f"); - continue; - } expect(step.opcode).toBe("JUMPDEST"); const pointer = (name: string) => - localsOf(context).find((v) => v.identifier === name)?.pointer; + localsOf(run.instructionAt(step)?.context).find( + (v) => v.identifier === name, + )?.pointer; expect(pointer("x"), `x at pc ${step.pc}`).toBeUndefined(); expect(pointer("h"), `h at pc ${step.pc}`).toBeDefined(); } @@ -1169,6 +1186,16 @@ code { }, }), ), + { + name: "a local given a constant before a call on a branch", + source: constantBeforeBranch, + locals: { + x: { values: [4n, { value: 7n, after: "x = 7" }] }, + h: { values: [5n] }, + a: { values: [4n] }, + b: { values: [5n] }, + }, + }, { name: "a local given a constant between two calls", source: constantBetweenCalls, diff --git a/packages/bugc/src/ir/utils/debug.test.ts b/packages/bugc/src/ir/utils/debug.test.ts index dd18364982..8466b667e8 100644 --- a/packages/bugc/src/ir/utils/debug.test.ts +++ b/packages/bugc/src/ir/utils/debug.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "vitest"; import type * as Ir from "#ir"; -import { dropInstructions, markersOf, placeActivations } from "./debug.js"; +import { + combineDebugContexts, + dropInstructions, + markersOf, + placeActivations, +} from "./debug.js"; const invoke = (identifier: string) => ({ invoke: { identifier } }); const ret = (identifier: string) => ({ return: { identifier } }); @@ -122,6 +127,20 @@ describe("placeActivations", () => { ); expect(before.before?.callOrigin).toEqual(at("e")); }); + + it("gives no call origin to invokes split from the first", () => { + // `callOrigin` is `f`'s: `g`'s call origin is not known + const placed = placeActivations( + [invoke("f"), ret("f"), invoke("g")], + undefined, + { before: plain(), after: plain() }, + at("f"), + ); + expect(names(placed.before)).toEqual(["invoke f", "return f"]); + expect(placed.before?.callOrigin).toEqual(at("f")); + expect(names(placed.after)).toEqual(["invoke g"]); + expect(placed.after?.callOrigin).toBeUndefined(); + }); }); describe("dropInstructions", () => { @@ -160,3 +179,19 @@ describe("dropInstructions", () => { expect(instructions[0].operationDebug.callOrigin).toEqual(callOrigin); }); }); + +describe("combineDebugContexts", () => { + it("keeps the call origin of the debug that carries the invoke", () => { + const callOrigin: Ir.Function.Origin = { + function: "main", + block: "entry", + index: 2, + }; + const combined = combineDebugContexts(plain(), { + ...debug(invoke("f")), + callOrigin, + }); + expect(names(combined)).toEqual(["invoke f"]); + expect(combined.callOrigin).toEqual(callOrigin); + }); +}); diff --git a/packages/bugc/src/ir/utils/debug.ts b/packages/bugc/src/ir/utils/debug.ts index 93d8e028dc..7b2e0d8a43 100644 --- a/packages/bugc/src/ir/utils/debug.ts +++ b/packages/bugc/src/ir/utils/debug.ts @@ -12,8 +12,12 @@ export function combineDebugContexts( ): Ir.Instruction.Debug { // The first debug is the instruction that stays in place (others // are folded into it), so the result keeps its inline sites and - // origin. - return withPlacement(combineContexts(debugs), debugs[0]); + // origin; its call origin, unless another's invoke is kept first. + const callOrigin = callOriginOf(debugs); + return { + ...withPlacement(combineContexts(debugs), debugs[0]), + ...(callOrigin ? { callOrigin } : {}), + }; } /** Keep an instruction's inline sites, origin, (if it invokes an @@ -701,7 +705,13 @@ export function placeActivations( : returns(late) ? returned : after.returned, - hasInvoke(late) ? callOrigin : after.callOrigin, + // `callOrigin` is the first invoke's: with invokes on both + // sides, the ones after have none known (and get none) + hasInvoke(late) + ? hasInvoke(early) + ? undefined + : callOrigin + : after.callOrigin, ) : after, dropped: [], From 038ae23e49dbad6bbc720c1a446f85c40d17e97e Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 15 Oct 2026 12:20:51 -0400 Subject: [PATCH 4/4] bugc: take the call origin off with the invokes withoutActivationsDebug now drops callOrigin too, so no debug keeps a call origin with no invoke for a combine to pair with another's. --- packages/bugc/src/ir/utils/debug.test.ts | 14 ++++++++++++++ packages/bugc/src/ir/utils/debug.ts | 7 +++++-- packages/bugc/src/optimizer/steps/inlining.ts | 7 ++++--- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/bugc/src/ir/utils/debug.test.ts b/packages/bugc/src/ir/utils/debug.test.ts index 8466b667e8..32e0d513e3 100644 --- a/packages/bugc/src/ir/utils/debug.test.ts +++ b/packages/bugc/src/ir/utils/debug.test.ts @@ -4,6 +4,7 @@ import { combineDebugContexts, dropInstructions, markersOf, + withoutActivationsDebug, placeActivations, } from "./debug.js"; @@ -194,4 +195,17 @@ describe("combineDebugContexts", () => { expect(names(combined)).toEqual(["invoke f"]); expect(combined.callOrigin).toEqual(callOrigin); }); + + it("keeps no call origin from a debug its invokes were taken off", () => { + // The invoke kept has no call origin: none is paired with it + const combined = combineDebugContexts( + withoutActivationsDebug({ + ...debug(invoke("e")), + callOrigin: { function: "main", block: "entry", index: 2 }, + }), + debug(invoke("f")), + ); + expect(names(combined)).toEqual(["invoke f"]); + expect(combined.callOrigin).toBeUndefined(); + }); }); diff --git a/packages/bugc/src/ir/utils/debug.ts b/packages/bugc/src/ir/utils/debug.ts index 7b2e0d8a43..2b334114f0 100644 --- a/packages/bugc/src/ir/utils/debug.ts +++ b/packages/bugc/src/ir/utils/debug.ts @@ -547,11 +547,14 @@ export function withActivations( ) as Format.Program.Context; } -/** A debug without its `invoke` and `return` discriminators */ +/** + * A debug without its `invoke` and `return` discriminators, nor the + * call origin of its invokes + */ export function withoutActivationsDebug( debug: Ir.Instruction.Debug | undefined, ): Ir.Instruction.Debug { - const { context: _, ...rest } = debug ?? {}; + const { context: _, callOrigin: __, ...rest } = debug ?? {}; const context = withoutActivations(debug?.context); return { ...rest, ...(context ? { context } : {}) }; } diff --git a/packages/bugc/src/optimizer/steps/inlining.ts b/packages/bugc/src/optimizer/steps/inlining.ts index 075a2eb529..e87d4b34cf 100644 --- a/packages/bugc/src/optimizer/steps/inlining.ts +++ b/packages/bugc/src/optimizer/steps/inlining.ts @@ -209,12 +209,13 @@ export class InliningStep extends BaseOptimizationStep { call.operationDebug?.context, "invoke", ); - const { callOrigin: pendingOrigin, ...callDebug } = - Ir.Utils.withoutActivationsDebug(call.operationDebug); + const callDebug = Ir.Utils.withoutActivationsDebug(call.operationDebug); // Where the call of the outermost function invoked was before // optimization: just before the invoke is the caller's code there const callOrigin = - pendingInvokes.length > 0 ? pendingOrigin : call.operationDebug?.origin; + pendingInvokes.length > 0 + ? call.operationDebug?.callOrigin + : call.operationDebug?.origin; const invoked = (debug: Ir.Instruction.Debug): Ir.Instruction.Debug => { const { callOrigin: _, ...rest } = debug; return callOrigin ? { ...rest, callOrigin } : rest;