diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 3fc4c7d36d..baa50493ee 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -11,6 +11,16 @@ support. Changes to the specification itself are tracked in the root - The `%` operator, with the precedence of `*` and `/`. It compiles to the EVM's unsigned `MOD`, so `x % 0` is `0`, as `x / 0` is ([#321]). +- Each instruction has a `variables` context that lists the local + variables in scope (the innermost declaration of each name, which hides + a storage variable of that name), with a type, and a pointer to the + exact bytes that hold the value, in memory or on the stack, where the + value is there. For a dynamic array, `string` or `bytes` local, the + pointer reads the local's word and goes on to the length and the + elements or data it refers to. At optimization levels 1 to 3, a local + whose current value the optimizer folded to a constant or removed is + listed without a pointer, and an inlined function's locals are listed + in its inlined code ([#328]). ### Changed @@ -108,6 +118,9 @@ support. Changes to the specification itself are tracked in the root - At optimization level 3, two writes in a row to the same field of a packed struct now leave the second value. Before, read/write merging combined the two values with `or` ([#337]). +- A local initialized with a string literal (`let s = "hello";`) now + holds the string. Before, it held an empty allocation, so `s.length` + was `0` ([#328]). ## 0.1.0-preview.1 — 2026-10-03 @@ -149,6 +162,7 @@ First publication. [#320]: https://github.com/ethdebug/format/pull/320 [#321]: https://github.com/ethdebug/format/pull/321 [#327]: https://github.com/ethdebug/format/pull/327 +[#328]: https://github.com/ethdebug/format/pull/328 [#330]: https://github.com/ethdebug/format/pull/330 [#332]: https://github.com/ethdebug/format/pull/332 [#333]: https://github.com/ethdebug/format/pull/333 diff --git a/packages/bugc/src/evmgen/debug/local-variables.bytecode.test.ts b/packages/bugc/src/evmgen/debug/local-variables.bytecode.test.ts new file mode 100644 index 0000000000..7d983a5241 --- /dev/null +++ b/packages/bugc/src/evmgen/debug/local-variables.bytecode.test.ts @@ -0,0 +1,82 @@ +/** + * Debug info must not change the generated code: compiling every + * example at every level gives the same bytecode with local-variable + * tracking (recording origins, enriching instructions) as without it. + */ +import { it, expect, vi } from "vitest"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { bytesToHex } from "ethereum-cryptography/utils"; + +const examples = join(__dirname, "../../../examples"); + +function sources(dir = examples): Array<[string, string]> { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return sources(path); + return entry.name.endsWith(".bug") + ? [[path.slice(examples.length + 1), readFileSync(path, "utf8")]] + : []; + }) as Array<[string, string]>; +} + +type Compile = (typeof import("#compiler"))["compile"]; + +async function bytecode(compile: Compile): Promise> { + const out: Record = {}; + for (const [name, source] of sources()) { + for (const level of [0, 1, 2, 3] as const) { + let result: string; + try { + const compiled = await compile({ + to: "bytecode", + source, + optimizer: { level }, + }); + result = compiled.success + ? bytesToHex(compiled.value.bytecode.runtime) + + "/" + + bytesToHex(compiled.value.bytecode.create ?? new Uint8Array()) + : "fails"; + } catch { + result = "throws"; + } + out[`${name} O${level}`] = result; + } + } + return out; +} + +it("local-variable tracking does not change any bytecode", async () => { + const tracked = await bytecode((await import("#compiler")).compile); + + vi.resetModules(); + vi.doMock( + "../../../dist/src/evmgen/debug/local-variables.js", + async (load) => ({ + ...(await load()), + enrich: (module: unknown) => module, + }), + ); + vi.doMock("../../../dist/src/optimizer/origins.js", () => ({ + recordOrigins: () => {}, + })); + const { compile } = await import("#compiler"); + + // Without tracking, no instruction lists a local + const probe = await compile({ + to: "bytecode", + source: `name P; storage { [0] r: uint256; } create {} +code { let x = 7; r = x; }`, + optimizer: { level: 0 }, + }); + if (!probe.success) throw new Error("compile failed"); + const listed = probe.value.bytecode.runtimeProgram.instructions.some((i) => + ( + i.context as { variables?: Array<{ identifier: string }> } + )?.variables?.some((v) => v.identifier === "x"), + ); + expect(listed).toBe(false); + + expect(await bytecode(compile)).toEqual(tracked); +}, 120_000); diff --git a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts new file mode 100644 index 0000000000..6e4a1e23fe --- /dev/null +++ b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts @@ -0,0 +1,812 @@ +/** + * Soundness of the per-instruction local-variable snapshot. + * + * The structural tests check which locals are listed where: never + * before their declaration, once per name, within their scope, and at + * calls. The program tests run small programs at every optimization + * level and, at every step that lists a local with a pointer, read it + * against the machine state after the step: it must hold the value the + * program gives that local there (see `check`). + */ +import { describe, it, expect } from "vitest"; + +import { Program } from "@ethdebug/format"; +import { executeProgram } from "#test/evm/behavioral"; +import { + type Level, + type LocalsProgram, + type Shape, + check, + localsOf, + textBytes, + traceLocals, +} from "#test/evm/locals"; + +const levels: Level[] = [0, 1, 2, 3]; + +/** The names listed at each instruction of a program */ +async function listed(source: string, level: Level = 0) { + const { program } = await traceLocals(source, level); + return program.instructions.map((instruction) => ({ + instruction, + names: localsOf(instruction.context).map((v) => v.identifier as string), + })); +} + +/** The offset of an instruction's `code`, if it has one */ +function codeOffset(context: unknown): number | undefined { + return (context as { code?: { range?: { offset: number } } } | undefined) + ?.code?.range?.offset; +} + +describe("which locals are listed", () => { + it("(a) does not list a local before its declaration", async () => { + const source = `name PreDef; +define { + function f(n: uint256) -> uint256 { + let y = n * 2; + let x = y + 1; + if (n > 0) { return x; } + else { return x + y; } + }; +} +storage { [0] r: uint256; } +create {} +code { r = f(3); }`; + const declared = source.indexOf("let x"); + const rows = await listed(source); + for (const { instruction, names } of rows) { + const offset = codeOffset(instruction.context); + if (offset !== undefined && offset < declared) { + expect(names, `x listed at offset ${offset}`).not.toContain("x"); + } + } + expect(rows.some(({ names }) => names.includes("x"))).toBe(true); + }); + + it("(b) lists every local in scope, with a type", async () => { + // `s` is used only within one block, so it stays on the stack; + // it is listed all the same. + const source = `name Complete; +define { + function h(a: uint256, b: uint256) -> uint256 { + let s = a + b; + return s; + }; +} +storage { [0] r: uint256; } +create {} +code { r = h(3, 4); }`; + const { program } = await traceLocals(source); + const entries = program.instructions.flatMap((i) => localsOf(i.context)); + for (const name of ["a", "b", "s"]) { + const named = entries.filter((v) => v.identifier === name); + expect(named.length, name).toBeGreaterThan(0); + expect(named.some((v) => v.pointer)).toBe(true); + } + for (const entry of entries) expect(entry.type).toBeDefined(); + }); + + it("(c) lists each name at most once per instruction", async () => { + const source = `name Shadow; +define { + function sh(n: uint256) -> uint256 { + let x = 111; + if (n > 0) { + let x = 222; + return x; + } + return x; + }; +} +storage { [0] r: uint256; [1] x: uint256; } +create {} +code { r = sh(0); }`; + const { getStorage } = await executeProgram(source, { calldata: "" }); + expect(await getStorage(0n)).toBe(111n); + for (const { names } of await listed(source)) { + expect(new Set(names).size, `[${names}]`).toBe(names.length); + } + }); + + it("(d) keeps locals listed at a call", async () => { + const source = `name AtCall; +define { + function add(x: uint256, y: uint256) -> uint256 { return x + y; }; +} +storage { [0] r: uint256; } +create {} +code { + let a = 3; + let b = 4; + let c = 5; + r = add(a, b); + r = r + c; +}`; + const rows = await listed(source); + const jump = rows.find( + ({ instruction }) => + instruction.operation?.mnemonic === "JUMP" && + instruction.context !== undefined && + Program.Context.isInvoke(instruction.context), + ); + expect(jump, "invoke JUMP for add").toBeDefined(); + for (const name of ["a", "b", "c"]) expect(jump!.names).toContain(name); + }); + + it("(e) lists a for loop's variable only within the loop", async () => { + const source = `name LoopScope; +storage { [0] r: uint256; } +create {} +code { + let acc = 0; + for (let i = 0; i < 3; i = i + 1) { + acc = acc + i; + } + r = acc; +}`; + const after = source.indexOf(" r = acc;"); + const rows = await listed(source); + for (const { instruction, names } of rows) { + const offset = codeOffset(instruction.context); + if (offset !== undefined && offset >= after) { + expect(names).not.toContain("i"); + expect(names).toContain("acc"); + } + } + expect(rows.some(({ names }) => names.includes("i"))).toBe(true); + }); + + it("(f) gives a stack-resident local a stack pointer", async () => { + const { program } = await traceLocals(programs[0].source); + const pointers = program.instructions.flatMap((i) => + localsOf(i.context) + .filter((v) => v.identifier === "p" && v.pointer) + .map((v) => (v.pointer as { location?: string }).location), + ); + expect(pointers.length).toBeGreaterThan(0); + expect(new Set(pointers)).toEqual(new Set(["stack"])); + }); + + it.each([2, 3] as const)( + "(g) lists an inlined function's locals in its inlined code at O%i", + async (level) => { + const source = `name Inlined; +define { + function add(a: uint256, b: uint256) -> uint256 { + let sum = a + b; + return sum * 2; + }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 5; } +code { r = add(s, 2); }`; + const { program } = await traceLocals(source, level); + const transforms = (context: unknown): unknown[] => { + const { transform, gather } = (context ?? {}) as { + transform?: unknown[]; + gather?: unknown[]; + }; + return [...(transform ?? []), ...(gather ?? []).flatMap(transforms)]; + }; + const inlined = program.instructions.filter((i) => + localsOf(i.context).some((v) => v.identifier === "sum" && v.pointer), + ); + expect(inlined.length).toBeGreaterThan(0); + for (const i of inlined) { + expect(transforms(i.context)).toContain("inline"); + } + }, + ); +}); + +const words: Shape = { kind: "array", element: { kind: "scalar", size: 32 } }; + +/** A scalar program for each sub-word kind, `ad` passed as `arg` */ +const kinds = ( + [ + ["address", "0x00000000000000000000000000000000000000aA", 20, 0xaan], + ["bool", "true", 1, 1n], + ["uint8", "200", 1, 200n], + ["uint32", "70000", 4, 70000n], + // bugc cannot yet produce a nonzero intN, so read one from storage + ["int8", "s", 1, 0n], + ["bytes4", "0xABCDEF12", 4, 0xabcdef12n], + ["uint256", "51966", 32, 51966n], + ] as const +).map( + ([type, arg, size, value]): LocalsProgram => ({ + name: `sub-word ${type}`, + source: `name Subword; +define { + function g(ad: ${type}, n: uint256) -> uint256 { + if (n > 0) { return n; } + let z = ad; + return 0; + }; +} +storage { [0] r: uint256; [1] s: int8; } +create {} +code { r = g(${arg}, 0); }`, + locals: { + ad: { values: [value], size }, + z: { values: [value], size }, + n: { values: [0n] }, + }, + }), +); + +/** + * Programs and the values their locals take, in execution order. A + * value with `after` may be read only once that statement has run. + */ +const programs: LocalsProgram[] = [ + { + name: "straight line", + source: `name Straight; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 5; } +code { + let n = 4; + let p = n + s; + r = p * n; +}`, + locals: { n: { values: [4n] }, p: { values: [9n] } }, + }, + { + name: "store timing", + source: `name StoreTiming; +define { + function f(a: uint256) -> uint256 { + let n = a + 3; + if (a > 0) { return n; } + return n + 1; + }; +} +storage { [0] r: uint256; } +create {} +code { r = f(4); }`, + locals: { a: { values: [4n] }, n: { values: [7n] } }, + }, + { + name: "one-armed if", + source: `name OneArmed; +storage { [0] r: uint256; [1] flag: uint256; } +create { flag = 1; } +code { + let x = 7; + if (flag > 0) { x = 9; } + r = x; +}`, + locals: { x: { values: [7n, { value: 9n, after: "x = 9" }] } }, + }, + { + name: "if-else", + source: `name IfElse; +storage { [0] r: uint256; [1] flag: uint256; } +create { flag = 0; } +code { + let x = 7; + let y = 1; + if (flag > 0) { y = 2; } + else { y = 3; } + x = x * y; + r = x + y; +}`, + locals: { + x: { values: [7n, { value: 21n, after: "x = x * y" }] }, + y: { values: [1n, { value: 3n, after: "y = 3" }] }, + }, + }, + { + name: "if-else reassigning in both arms", + source: `name IfElseBoth; +storage { [0] r: uint256; [1] flag: uint256; } +create { flag = 0; } +code { + let x = 7; + let y = 1; + if (flag > 0) { y = 2; x = x + y; } + else { y = 3; x = x * y; } + r = x + y; +}`, + locals: { + x: { values: [7n, { value: 21n, after: "x = x * y" }] }, + y: { values: [1n, { value: 3n, after: "y = 3" }] }, + }, + }, + { + name: "reassigned in a function", + source: `name Reassign; +define { + function g(n: uint256) -> uint256 { + let x = 111; + let keep = n; + if (n > 0) { x = 222; } + else { x = 333; } + return x + keep; + }; +} +storage { [0] r: uint256; } +create {} +code { r = g(7); }`, + locals: { + n: { values: [7n] }, + keep: { values: [7n] }, + x: { values: [111n, { value: 222n, after: "x = 222" }] }, + }, + }, + { + name: "loop", + source: `name Loop; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 4; } +code { + let acc = 0; + for (let i = 0; i < s; i = i + 1) { + acc = acc + i; + } + r = acc; +}`, + locals: { + acc: { + values: [ + 0n, + ...[0n, 1n, 3n, 6n].map((value) => ({ + value, + after: "acc = acc + i", + })), + ], + }, + i: { + values: [ + 0n, + ...[1n, 2n, 3n, 4n].map((value) => ({ value, after: "i = i + 1" })), + ], + }, + }, + }, + { + name: "a let in a loop shadows a local", + source: `name LoopShadow; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 2; } +code { + let x = 1; + for (let i = 0; i < s; i = i + 1) { + let x = 50 + i; + r = r + x; + } + r = r + x; +}`, + locals: { + "x@x = 1": { values: [1n] }, + "x@x = 50": { + values: [50n, 51n].map((value) => ({ value, after: "x = 50 + i" })), + }, + i: { + values: [ + 0n, + ...[1n, 2n].map((value) => ({ value, after: "i = i + 1" })), + ], + }, + }, + }, + { + name: "a folded version, then a located one", + source: `name Folded; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 9; } +code { + let x = 7; + r = x; + x = s; + r = r + x; +}`, + // At O1 and up, x = 7 is folded into its use; x = s still has a + // value to point to + locals: { + x: { values: [7n, { value: 9n, after: "x = s" }], everyLevel: true }, + }, + }, + { + name: "a loop that swaps two locals", + source: `name Swap; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 3; } +code { + let a = 1; + let b = 2; + for (let i = 0; i < s; i = i + 1) { + let t = a; + a = b; + b = t; + r = r + t; + } + r = a * 10 + b; +}`, + locals: { + a: { values: [1n, 2n, 1n, 2n] }, + b: { values: [2n, 1n, 2n, 1n] }, + t: { values: [1n, 2n, 1n] }, + i: { + values: [ + 0n, + ...[1n, 2n, 3n].map((value) => ({ value, after: "i = i + 1" })), + ], + }, + }, + }, + { + name: "two lets of one name in one scope", + source: `name Redeclare; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 4; } +code { + let x = s + 1; + r = x; + let x = s + 2; + r = r + x; +}`, + locals: { + "x@x = s + 1": { values: [5n] }, + "x@x = s + 2": { values: [6n] }, + }, + }, + { + name: "a local aliased after the statement that computes it", + source: `name Early; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 3; } +code { let y = s; let x = y * 2; y = x; r = y; }`, + locals: { + y: { values: [3n, { value: 6n, after: "r = y" }] }, + x: { values: [6n] }, + }, + }, + { + name: "an inlined function's local aliased after its statement", + source: `name EarlyInline; +define { + function f(y: uint256) -> uint256 { + let x = y * 2; + y = x; + return y + 1; + }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 3; } +code { let y = s; r = f(y); r = r + y; }`, + locals: { + "y@y: uint256": { values: [3n, { value: 6n, after: "return y + 1" }] }, + "y@y = s": { values: [3n] }, + x: { values: [6n] }, + }, + }, + { + name: "locals that alias each other", + source: `name Cp; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 3; } +code { let a = s; let b = a; let c = b; r = c + a; }`, + locals: { + a: { values: [3n] }, + b: { values: [3n] }, + c: { values: [3n] }, + }, + }, + { + name: "a local hides a storage variable", + source: `name Coll; +storage { [0] r: uint256; [1] x: uint256; } +create { x = 5; } +code { let x = 7; r = x; r = r + 1; }`, + locals: { x: { values: [7n] } }, + }, + { + name: "a local beside a storage struct", + source: `name StructStor; +define { struct P { a: uint128; b: uint128; }; } +storage { [0] r: uint256; [1] p: P; } +create { p.b = 3; } +code { let a = 7; r = a + p.b; r = r + a; }`, + locals: { a: { values: [7n] } }, + }, + { + name: "a parameter hides a storage variable", + source: `name ParamStor; +define { + function setOwner(owner: uint256) -> uint256 { + let o = owner; + if (o > 100) { return 0; } + return o + owner; + }; +} +storage { [0] r: uint256; [1] owner: uint256; } +create { owner = 9; } +code { r = setOwner(4); }`, + locals: { owner: { values: [4n] }, o: { values: [4n] } }, + }, + { + name: "a parameter named frame", + source: `name FrameParam; +define { + function f(frame: uint256) -> uint256 { + if (frame > 100) { return 0; } + return frame + 1; + }; +} +storage { [0] r: uint256; } +create {} +code { r = f(4); }`, + locals: { frame: { values: [4n] } }, + }, + { + name: "a callee's locals until its frame is torn down", + source: `name Teardown; +define { + function weight(x: uint256, w: uint256) -> uint256 { + return x * w; + }; +} +storage { [0] r: uint256; } +create {} +code { r = weight(21, 2); }`, + locals: { x: { values: [21n] }, w: { values: [2n] } }, + }, + { + name: "calls", + source: `name Calls; +define { + function add(a: uint256, b: uint256) -> uint256 { + let sum = a + b; + return sum; + }; + function twice(c: uint256) -> uint256 { + let d = c + c; + if (c > 100) { return 0; } + return d; + }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 5; } +code { + let m = s; + let k = add(m, 2); + r = twice(k) + m; +}`, + locals: { + a: { values: [5n] }, + b: { values: [2n] }, + sum: { values: [7n] }, + c: { values: [7n] }, + d: { values: [14n] }, + m: { values: [5n] }, + k: { values: [7n] }, + }, + }, + { + name: "one-armed if in a function", + source: `name PickFn; +define { + function pick(c: uint256) -> uint256 { + let x = 1; + if (c > 2) { x = c; } + return x; + }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 5; } +code { r = pick(s); }`, + locals: { + c: { values: [5n] }, + // `x = c` emits no code, so it cannot mark when 5 is due + x: { values: [1n, 5n] }, + }, + }, + { + name: "two inlinable calls", + source: `name TwoCalls; +define { + function inc(v: uint256) -> uint256 { + let w = v + 1; + return w; + }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 5; } +code { + let p = inc(s); + let q = inc(p + 10); + r = p + q; +}`, + locals: { + v: { values: [5n, 16n] }, + w: { values: [6n, 17n] }, + p: { values: [6n] }, + q: { values: [17n] }, + }, + }, + { + name: "loop calling a function", + source: `name LoopCall; +define { + function sq(u: uint256) -> uint256 { + return u * u; + }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 3; } +code { + let total = 0; + for (let j = 1; j <= s; j = j + 1) { + total = total + sq(j); + } + r = total; +}`, + locals: { + total: { values: [0n, 1n, 5n, 14n] }, + j: { values: [1n, 2n, 3n, 4n] }, + u: { values: [1n, 2n, 3n] }, + }, + }, + { + name: "tail recursion", + source: `name TailSum; +define { + function sumTo(n: uint256, acc: uint256) -> uint256 { + if (n == 0) { return acc; } + return sumTo(n - 1, acc + n); + }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 3; } +code { r = sumTo(s, 0); }`, + locals: { + n: { values: [3n, 2n, 1n, 0n] }, + acc: { values: [0n, 3n, 5n, 6n] }, + }, + // A recursive call revisits a caller's values, so recursion fits + // this check only once tail-call optimization makes it a loop + levels: [2, 3], + }, + ...kinds, + { + name: "two sub-word locals in one frame", + source: `name Subwords; +define { + function f(ad: address, flag: bool, m: uint256) -> uint256 { + if (m > 99) { return 1; } + let z = ad; + let g = flag; + return 0; + }; +} +storage { [0] r: uint256; } +create {} +code { r = f(0x00000000000000000000000000000000000000aA, true, 0); }`, + locals: { + ad: { values: [0xaan], size: 20 }, + z: { values: [0xaan], size: 20 }, + flag: { values: [1n], size: 1 }, + g: { values: [1n], size: 1 }, + m: { values: [0n] }, + }, + }, + { + name: "array", + source: `name Arr; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 2; } +code { + let items: array = [42, 12, 99]; + if (s > 1) { r = items[s]; } + r = r + items.length; +}`, + locals: { items: { shape: words, values: [[42n, 12n, 99n]] } }, + }, + { + name: "nested array", + source: `name Nested; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 1; } +code { + let m: array> = [[1, 2], [3]]; + if (s > 0) { r = m[s][0]; } + r = r + m.length; +}`, + locals: { + m: { + shape: { kind: "array", element: words }, + values: [[[1n, 2n], [3n]]], + }, + }, + }, + { + name: "reassigned array", + source: `name ReassignArr; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 1; } +code { + let items: array = [5]; + if (s > 0) { items = [7, 8]; } + r = items.length; +}`, + locals: { + items: { + shape: words, + values: [[5n], { value: [7n, 8n], after: "items = [7, 8]" }], + }, + }, + }, + { + name: "strings", + source: `name Strings; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 1; } +code { + let short = "hello"; + let long = "hello world, this is longer than thirty-two bytes"; + if (s > 0) { r = short.length; } + r = r + long.length; +}`, + locals: { + short: { shape: { kind: "bytes" }, values: [textBytes("hello")] }, + long: { + shape: { kind: "bytes" }, + values: [ + textBytes("hello world, this is longer than thirty-two bytes"), + ], + }, + }, + }, + { + name: "bytes slice", + source: `name Slice; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 1; } +code { + let b = msg.data[0:4]; + if (s > 0) { r = b.length; } + r = r + b.length; +}`, + locals: { b: { shape: { kind: "bytes" }, values: ["0x00000000"] } }, + }, + { + name: "a uint8 cast", + source: `name U8; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 300; } +code { + let n = s; + let m = n as uint8; + if (s > 0) { r = m; } +}`, + locals: { n: { values: [300n] }, m: { values: [44n], size: 1 } }, + }, + { + // Narrowing casts truncate since #334, so `b` is `h`'s first four + // bytes, right-aligned + name: "a bytes4 cast", + source: `name B4; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 1; } +code { + let h: bytes32 = 0x1122334400000000000000000000000000000000000000000000000000000000; + let b = h as bytes4; + if (b == 0x11223344) { r = 1; } + r = r + 2; +}`, + locals: { + h: { values: [0x11223344n << 224n] }, + b: { values: [0x11223344n], size: 4 }, + }, + }, +]; + +describe.each(levels)("locals hold the program's values at O%i", (level) => { + it.each(programs.filter((p) => !p.levels || p.levels.includes(level)))( + "$name", + (program) => check(program, level), + ); +}); diff --git a/packages/bugc/src/evmgen/debug/local-variables.test.ts b/packages/bugc/src/evmgen/debug/local-variables.test.ts new file mode 100644 index 0000000000..8cad2b0ec9 --- /dev/null +++ b/packages/bugc/src/evmgen/debug/local-variables.test.ts @@ -0,0 +1,187 @@ +/** + * Emission tests for memory-homed local-variable debug info (O0). + * + * Verifies that parameters and cross-block `let` bindings that the + * memory planner spills to frame-relative memory get a `variables` + * context with a frame-relative pointer, and that behavior is + * unaffected. + */ +import { describe, it, expect } from "vitest"; + +import { compile } from "#compiler"; +import { executeProgram } from "#test/evm/behavioral"; +import type * as Format from "@ethdebug/format"; + +async function compileProgram( + source: string, + level: 0 | 1 | 2 | 3, +): Promise { + const result = await compile({ + to: "bytecode", + source, + optimizer: { level }, + }); + if (!result.success) { + const errors = result.messages.error ?? []; + throw new Error( + "compile failed:\n" + + errors + .map((e: { message?: string }) => e.message ?? String(e)) + .join("\n"), + ); + } + return result.value.bytecode.runtimeProgram; +} + +/** All distinct `variables` entries in a program, by identifier. */ +function localEntries( + program: Format.Program, +): Map> { + const byId = new Map>(); + for (const instr of program.instructions) { + const ctx = instr.context as Record | undefined; + if (!ctx || !Array.isArray(ctx.variables)) continue; + for (const v of ctx.variables as Array>) { + if (typeof v.identifier !== "string") continue; + const existing = byId.get(v.identifier); + // Prefer the located record: a variable now appears type-only + // (in scope, no value) as well as with a pointer where located. + if (!existing || (!existing.pointer && v.pointer)) { + byId.set(v.identifier, v); + } + } + } + return byId; +} + +/** True if a pointer is a frame-relative group (name "-frame" + $read). */ +function isFrameRelative(pointer: unknown): boolean { + if (!pointer || typeof pointer !== "object") return false; + const group = (pointer as { group?: unknown[] }).group; + if (!Array.isArray(group) || group.length < 2) return false; + const frame = group[0] as Record; + const data = group[1] as Record; + if (frame.name !== "-frame" || frame.location !== "memory") return false; + const offset = data.offset as Record | undefined; + return !!offset && Array.isArray(offset.$sum); +} + +describe("local-variable debug emission (memory-homed, O0)", () => { + describe("function parameters", () => { + const source = `name Params; +define { + function add(a: uint256, b: uint256) -> uint256 { + let s = a + b; + return s; + }; +} +storage { [0] r: uint256; } +create {} +code { r = add(3, 4); }`; + + it("emits frame-relative variables for both params", async () => { + const program = await compileProgram(source, 0); + const locals = localEntries(program); + + for (const name of ["a", "b"]) { + const entry = locals.get(name); + expect(entry, `variables entry for ${name}`).toBeDefined(); + expect(entry!.identifier).toBe(name); + expect(isFrameRelative(entry!.pointer)).toBe(true); + expect(entry!.type).toEqual({ kind: "uint", bits: 256 }); + } + }); + + it("keeps runtime behavior correct", async () => { + const res = await executeProgram(source, { + calldata: "", + optimizationLevel: 0, + }); + expect(res.callSuccess).toBe(true); + expect(await res.getStorage(0n)).toBe(7n); + }); + }); + + describe("cross-block let binding", () => { + // `x` is defined before the branch and used in both arms, so it + // crosses block boundaries and is spilled to frame memory. + const source = `name CrossBlock; +define { + function f(n: uint256) -> uint256 { + let x = n + 1; + if (n > 0) { return x; } + else { return x + n; } + }; +} +storage { [0] r: uint256; } +create {} +code { r = f(5); }`; + + it("emits a frame-relative variable for the spilled let", async () => { + const program = await compileProgram(source, 0); + const locals = localEntries(program); + const x = locals.get("x"); + expect(x, "variables entry for x").toBeDefined(); + expect(isFrameRelative(x!.pointer)).toBe(true); + }); + + it("keeps runtime behavior correct", async () => { + const res = await executeProgram(source, { + calldata: "", + optimizationLevel: 0, + }); + expect(res.callSuccess).toBe(true); + expect(await res.getStorage(0n)).toBe(6n); + }); + }); + + describe("call inside a loop", () => { + // A call in a `for` body to a function whose return expression has + // two operations: each iteration must add weight(i, 7) = 7i + 1, + // so r = 1 + 8 + 15. + const source = `name LoopCall; +define { + function weight(x: uint256, w: uint256) -> uint256 { + return x * w + 1; + }; +} +storage { [0] r: uint256; } +create {} +code { + let acc = 0; + for (let i = 0; i < 3; i = i + 1) { + acc = acc + weight(i, 7); + } + r = acc; +}`; + + // At level 3 this call reverts: a separate optimizer bug. + for (const level of [0, 1, 2] as const) { + it(`keeps runtime behavior correct (level ${level})`, async () => { + const res = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + expect(res.callSuccess).toBe(true); + expect(await res.getStorage(0n)).toBe(24n); + }); + } + }); + + describe("contract without functions", () => { + // No user functions, no frames — the pass is a no-op and must + // not disturb compilation. + const source = `name NoFns; +storage { [0] r: uint256; } +create { r = 0; } +code { r = r + 1; }`; + + it("compiles and runs", async () => { + const res = await executeProgram(source, { + calldata: "", + optimizationLevel: 0, + }); + expect(res.callSuccess).toBe(true); + }); + }); +}); diff --git a/packages/bugc/src/evmgen/debug/local-variables.ts b/packages/bugc/src/evmgen/debug/local-variables.ts new file mode 100644 index 0000000000..7712dec07d --- /dev/null +++ b/packages/bugc/src/evmgen/debug/local-variables.ts @@ -0,0 +1,719 @@ +/** + * Local-variable debug info: a per-instruction snapshot of the locals + * in scope, on two independent axes. + * + * - LIST (which variables appear) = LEXICAL SCOPE. Each declaration + * (a parameter, or a `let`) is in scope over its source interval: a + * `let` from its declaration to the end of its declaring scope, a + * parameter throughout its function. Where several declarations of + * one name are in scope, the innermost one is listed, and it hides + * a storage variable of the same name. Stamped on every + * instruction and terminator, and on each block's entry, except the + * return sequence's frame teardown (`withoutFrameLocals`). Inlined + * code lists only the inlined callee's locals (see + * `Instruction.Debug.inlineSites`). + * - POINTER (value vs type-only) = AVAILABILITY. A pointer is given + * only from the listed declaration's own versions: the current one + * (the deepest whose definition dominates the instruction), where + * its value is in memory (its home) or, during code generation, on + * the stack (`withStackLocals`). Contexts are postconditions, so an + * instruction that defines a memory-homed version lists it only on + * the MSTORE that homes it. + * + * Optimization can fold a version to a constant or remove it. The + * current version is then decided where the instruction was before + * optimization (its `origin`), among all versions: where that is a + * removed version, the local is listed without a pointer, never with + * a stale value. The format has no form yet for "the value is this + * expression" (ethdebug/format#291), so a folded value cannot be + * stated either. + * + * A reference-typed local (memory array, string, dynamic bytes) is + * held as an address word; its pointer names that word, then the data + * it refers to (`dataPointers`). + * + * The memory pointer encodes bugc's frame convention: the frame base + * lives in memory at `FRAME_POINTER` (0x80); a frame-homed local is at + * `mem[ mem[FRAME_POINTER] + delta ]`, expressed as a `group` naming + * the frame region `-frame` (no BUG identifier starts with `-`) and a + * data region whose offset reads it (`$read`) and adds the static + * delta (`$sum`). Functions without a frame (main/create) home locals + * at a static memory offset. + */ +import type * as Format from "@ethdebug/format"; + +import * as Ir from "#ir"; +import { Type as BugType } from "#types"; +import { Memory } from "#evmgen/analysis"; +import { convertToEthDebugType } from "../../irgen/debug/types.js"; +import { fromBugType } from "../../irgen/type.js"; + +type VariableEntry = Format.Program.Context.Variables["variables"][number]; + +/** The frame pointer's region; not a BUG identifier, so no local's */ +const FRAME = "-frame"; +type StackLocal = NonNullable[number]; + +type DefSite = Ir.Utils.DefSite; + +/** + * A value is homed by a full-word `MSTORE` of its stack word, which + * writes the word big-endian. bugc keeps every scalar right-aligned on + * the stack (`bytesN` included), so a scalar of `size` bytes is the + * word's low-order `size` bytes: `32 - size` bytes into the word, + * `size` bytes long. The same holds for a stack slot (segment offsets + * count from the word's most significant byte). + */ +function memoryPointer( + identifier: string, + size: number, + allocation: Memory.Allocation, + frameSize: number | undefined, +): Format.Pointer { + const offset = Number(allocation.offset) + 32 - size; + + if (frameSize === undefined) { + // No call frame (main/create): static memory offset. + return { name: identifier, location: "memory", offset, length: size }; + } + + // Frame-relative: mem[ mem[FRAME_POINTER] + delta ]. + return { + group: [ + { + name: FRAME, + location: "memory", + offset: Memory.regions.FRAME_POINTER, + length: 32, + }, + { + name: identifier, + location: "memory", + offset: { $sum: [{ $read: FRAME }, offset] }, + length: size, + }, + ], + }; +} + +function stackPointer( + identifier: string, + slot: number, + size: number, +): Format.Pointer { + return { + name: identifier, + location: "stack", + slot, + ...(size < 32 ? { offset: 32 - size, length: size } : {}), + }; +} + +/** + * Pointers to the data a reference at `base` refers to, by bugc's + * memory layout, or undefined if the layout is not described here. + * + * - `string` and dynamic `bytes`: a length word at `base`, then that + * many bytes of data. + * - dynamic array: a length word at `base`, then one word per element + * (`base + 32 + 32 * i`). A scalar element is right-aligned in its + * word; a reference element's word holds the address of its data, + * described the same way. + * + * Regions are named after the local: `x-length`, `x-data`, + * `x-element`, and for a nested reference `x-element-length` and so + * on. Fixed-size arrays and structs are left out: bugc cannot build + * one in memory yet (no struct literal; array literals are dynamic). + */ +function dataPointers( + base: Format.Pointer.Expression, + type: Ir.Type.Origin, + name: string, + depth: number = 0, +): Format.Pointer[] | undefined { + if (type === "synthetic") return undefined; + const length: Format.Pointer = { + name: `${name}-length`, + location: "memory", + offset: base, + length: 32, + }; + + if ( + BugType.isElementary(type) && + (BugType.Elementary.isString(type) || + (BugType.Elementary.isBytes(type) && type.size === undefined)) + ) { + return [ + length, + { + name: `${name}-data`, + location: "memory", + offset: { $sum: [base, 32] }, + length: { $read: `${name}-length` }, + }, + ]; + } + + if (!BugType.isArray(type) || type.size !== undefined) return undefined; + + const index = `i${depth}`; + const element = `${name}-element`; + const wordOffset = (within: number): Format.Pointer.Expression => ({ + $sum: [base, 32 + within, { $product: [index, 32] }], + }); + const elementType = fromBugType(type.element); + let is: Format.Pointer; + if (Ir.Type.isScalar(elementType)) { + const size = elementType.size; + is = { + name: element, + location: "memory", + offset: wordOffset(32 - size), + length: size, + }; + } else { + const nested = dataPointers( + { $read: element }, + type.element, + element, + depth + 1, + ); + if (!nested) return undefined; + is = { + group: [ + { + name: element, + location: "memory", + offset: wordOffset(0), + length: 32, + }, + ...nested, + ], + }; + } + + return [ + length, + { list: { count: { $read: `${name}-length` }, each: index, is } }, + ]; +} + +/** A pointer to a local's word plus, for a reference, its data. */ +function withData( + word: Format.Pointer, + data: Format.Pointer[] | undefined, +): Format.Pointer { + if (!data) return word; + return "group" in word + ? { group: [...word.group, ...data] } + : { group: [word, ...data] }; +} + +/** + * One declaration of a local: its versions, and the source interval + * over which it is in scope. + */ +interface Declaration { + name: string; + /** The inline site whose code lists it (undefined: the function's) */ + site: string | undefined; + versions: Ir.Function.SsaVariable[]; + /** Where the declaration is, for the entry's `declaration` */ + loc: Ir.Function.SsaVariable["loc"]; + start: number; + end: number; +} + +/** Group a function's versions by declaration. */ +function declarations(func: Ir.Function): Declaration[] { + const groups = new Map(); + for (const ssa of func.ssaVariables!.values()) { + const key = [ssa.inlineSite ?? "", ssa.name, ssa.declaredIn ?? ""].join( + "/", + ); + const list = groups.get(key); + if (list) list.push(ssa); + else groups.set(key, [ssa]); + } + return [...groups.values()].map((versions) => { + const { name, inlineSite, declaredIn, scopeEnd } = versions[0]; + const loc = versions.find((v) => v.loc)?.loc; + // A parameter is in scope throughout its function; a `let` from + // its declaration to the end of the scope that declares it. + const [scopeStart] = (declaredIn ?? "").split(":").map(Number); + const param = scopeEnd === undefined; + return { + name, + site: inlineSite, + versions, + loc, + start: param + ? -Infinity + : loc + ? Number(loc.offset) + : (scopeStart ?? -Infinity), + end: param ? Infinity : scopeEnd, + }; + }); +} + +/** Identifier + type + declaration of a local. */ +function buildEntry( + declaration: Declaration, + type: Ir.Type, + sourceId: string, +): VariableEntry { + const entry: VariableEntry = { identifier: declaration.name }; + + const converted = convertToEthDebugType(type); + if (converted) entry.type = converted; + + if (declaration.loc) { + entry.declaration = { + source: { id: sourceId }, + range: declaration.loc, + }; + } + + return entry; +} + +/** + * Merge variable entries into an instruction's debug context as a + * flat `variables` sibling, alongside any existing `variables` (the + * storage variables from irgen) and preserving all other keys. A local + * in scope hides a storage variable of the same name. + */ +function withVariables( + debug: Ir.Instruction.Debug | undefined, + entries: VariableEntry[], + stackLocals: StackLocal[], +): Ir.Instruction.Debug { + const { stored: _, stackLocals: __, ...rest } = debug ?? {}; + if (entries.length === 0) return rest; + + const context = (rest.context ?? {}) as Record; + const locals = new Set(entries.map((e) => e.identifier)); + const storage = ( + Array.isArray(context.variables) + ? (context.variables as VariableEntry[]) + : [] + ).filter((v) => !locals.has(v.identifier)); + + return { + ...rest, + context: { + ...context, + variables: [...storage, ...entries], + } as Format.Program.Context, + ...(stackLocals.length > 0 ? { stackLocals } : {}), + }; +} + +/** + * Give each listed local whose temp is on the stack a stack pointer, + * for an instruction just emitted with the stack as it is after it + * (`stack[0]` is the top). Called as code generation emits each + * instruction. + */ +export function withStackLocals( + instruction: I, + stack: ReadonlyArray<{ irValue?: string }>, +): I { + const debug = instruction.debug; + if (!debug?.stackLocals) return instruction; + + const context = debug.context as Record; + const variables = (context.variables as VariableEntry[]).map((entry) => { + const local = debug.stackLocals!.find( + (l) => l.identifier === entry.identifier, + ); + if (!local || entry.pointer) return entry; + const slot = stack.findIndex((item) => item.irValue === local.temp); + if (slot < 0) return entry; + return { + ...entry, + pointer: withData( + stackPointer(local.identifier, slot, local.size), + local.data, + ), + }; + }); + return { + ...instruction, + debug: { + ...debug, + context: { ...context, variables } as Format.Program.Context, + }, + }; +} + +/** + * Drop a function's locals from a debug context, for the instructions + * that tear its frame down. Once the caller's frame pointer is + * restored, a frame-relative pointer names the caller's words, and the + * function's locals are out of scope. A local is an entry with a + * frame-relative pointer or none (type-only, or on the stack); other + * entries (storage) stay. + */ +export function withoutFrameLocals( + debug: Ir.Instruction.Debug, +): Ir.Instruction.Debug { + const context = debug.context as Record | undefined; + if (!context || !Array.isArray(context.variables)) return debug; + + const isLocal = (entry: VariableEntry) => + !entry.pointer || + ("group" in entry.pointer && + (entry.pointer.group[0] as { name?: string }).name === FRAME); + const variables = (context.variables as VariableEntry[]).filter( + (entry) => !isLocal(entry), + ); + + const rest = Object.fromEntries( + Object.entries(context).filter(([key]) => key !== "variables"), + ); + const { stackLocals: _, stored: __, ...others } = debug; + return { + ...others, + context: (variables.length > 0 + ? { ...rest, variables } + : rest) as Format.Program.Context, + }; +} + +/** The source offsets of an instruction/terminator: of its `code` + * context, or of each `code` in a `gather` (all of which apply). */ +function codeOffsets(context: unknown): number[] { + if (!context || typeof context !== "object") return []; + const { code, gather } = context as { + code?: { range?: { offset?: unknown } }; + gather?: unknown[]; + }; + const offset = code?.range?.offset; + return [ + ...(offset == null ? [] : [Number(offset)]), + ...(Array.isArray(gather) ? gather.flatMap(codeOffsets) : []), + ]; +} + +/** + * Among versions with a def site, the current one at (block, index): + * the deepest whose def dominates it. Undefined if none does yet. + */ +function currentVersion( + versions: Array<{ version: V; def: DefSite }>, + block: string, + index: number, + idom: Record, +): V | undefined { + let best: { version: V; def: DefSite } | undefined; + for (const candidate of versions) { + const { def } = candidate; + if (!Ir.Utils.dominates(def, block, index, idom)) continue; + const later = + !best || + (def.block === best.def.block + ? def.index > best.def.index + : Ir.Utils.dominatesBlock(best.def.block, def.block, idom)); + if (later) best = candidate; + } + return best?.version; +} + +/** What the snapshot needs to know about one function. */ +interface FunctionInfo { + declarations: Declaration[]; + /** The temp holding each version's value */ + tempOf: Map; + defs: Map; + idom: Record; + origins: Ir.Function["origins"]; + allocations: Record; + frameSize: number | undefined; + sourceId: string; +} + +/** A position, after optimization and (if known) before it */ +interface Position { + block: string; + index: number; + origin?: Ir.Function.Origin; +} + +/** + * The version of a declaration whose value is current at `at`, if its + * value is located there; undefined if not. + * + * With the position before optimization known, the current version is + * decided there, among all versions: if it is one the optimizer + * removed, there is no value to point to. Without it, a declaration + * with any removed or aliasing version gets no pointer. + */ +function locatedVersion( + info: FunctionInfo, + declaration: Declaration, + at: Position, +): { ssa: Ir.Function.SsaVariable; temp: string } | undefined { + const defined = (ssa: Ir.Function.SsaVariable) => + info.defs.get(info.tempOf.get(ssa)!); + const versions = declaration.versions.filter((v) => !v.placeholder); + + let current: Ir.Function.SsaVariable | undefined; + const { origin } = at; + const idom = origin && info.origins?.get(origin.function); + if ( + origin && + idom && + versions.every((v) => v.origin?.function === origin.function) + ) { + current = currentVersion( + versions.map((version) => ({ version, def: version.origin! })), + origin.block, + origin.index, + idom, + ); + } else { + // Without the position before optimization, a removed version, or + // one defined apart from its temp, cannot be placed + if (versions.some((v) => !defined(v) || v.defined)) return undefined; + current = currentVersion( + versions.map((version) => ({ version, def: defined(version)! })), + at.block, + at.index, + info.idom, + ); + } + + const def = current && defined(current); + if (!def || !Ir.Utils.dominates(def, at.block, at.index, info.idom)) { + return undefined; + } + return { ssa: current!, temp: info.tempOf.get(current!)! }; +} + +/** + * The snapshot at `at` with source `offsets`, for code inlined through + * `site` (undefined: the function's own code). LIST axis: for each + * name, the innermost declaration in scope (name + type). POINTER + * axis: its current version, if located, gets a memory pointer if + * memory-homed, else is left for the stack. + */ +function snapshotAt( + info: FunctionInfo, + at: Position, + offsets: number[], + site: string | undefined, + overwritten: Set, +): { entries: VariableEntry[]; stackLocals: StackLocal[] } { + const inScope = (d: Declaration) => + offsets.length === 0 || + offsets.some((offset) => offset >= d.start && offset < d.end); + + const listed = new Map(); + for (const declaration of info.declarations) { + if (declaration.site !== site || !inScope(declaration)) continue; + const other = listed.get(declaration.name); + if (!other || declaration.start > other.start) { + listed.set(declaration.name, declaration); + } + } + + const entries: VariableEntry[] = []; + const stackLocals: StackLocal[] = []; + for (const declaration of listed.values()) { + const found = locatedVersion(info, declaration, at); + const located = found && !overwritten.has(found.temp) ? found : undefined; + const type = (located?.ssa ?? declaration.versions[0]).type; + const entry = buildEntry(declaration, type, info.sourceId); + entries.push(entry); + if (!located) continue; + + // A scalar's word holds its value; a reference's word holds the + // address of its data. + const { name } = declaration; + let size = 32; + let data: Format.Pointer[] | undefined; + if (Ir.Type.isScalar(type)) { + size = type.size; + } else { + if (type.kind !== "ref" || type.location !== "memory") continue; + data = dataPointers({ $read: name }, type.origin, name); + if (!data) continue; + } + const allocation = info.allocations[located.temp]; + if (allocation) { + entry.pointer = withData( + memoryPointer(name, size, allocation, info.frameSize), + data, + ); + } else { + stackLocals.push({ + identifier: name, + temp: located.temp, + size, + ...(data ? { data } : {}), + }); + } + } + return { entries, stackLocals }; +} + +/** + * Stamp every instruction and terminator of a function, and each + * block's entry, with its variable snapshot. + */ +function enrichFunction( + func: Ir.Function, + module: Ir.Module, + memory: Memory.Function.Info, + sourceId: string, +): void { + if (!func.ssaVariables || func.ssaVariables.size === 0) return; + + const tempOf = new Map(); + for (const [key, ssa] of func.ssaVariables) { + tempOf.set(ssa, ssa.temp ?? key); + } + + const info: FunctionInfo = { + declarations: declarations(func), + tempOf, + defs: Ir.Utils.defSites(func), + idom: new Ir.Analysis.Statistics.Analyzer().analyze({ + ...module, + main: func, + }).dominatorTree, + origins: func.origins, + allocations: memory.allocations, + frameSize: memory.frameSize, + sourceId, + }; + const homed = new Set( + [...tempOf.values()].filter((t) => info.allocations[t] !== undefined), + ); + + // Carry the last known source offsets forward (function-wide) so + // synthesized ops without a `code` context inherit a nearby scope. + let lastOffsets: number[] = []; + const stamp = ( + debug: Ir.Instruction.Debug | undefined, + block: string, + index: number, + // Shift the position back (-1: just before this instruction) + shift: number = 0, + // Temps whose homes the instruction's code overwrites + overwritten: Set = new Set(), + ): Ir.Instruction.Debug => { + const own = codeOffsets(debug?.context); + const offsets = own.length > 0 ? own : lastOffsets; + lastOffsets = offsets; + const origin = debug?.origin && { + ...debug.origin, + index: debug.origin.index + shift, + }; + const { entries, stackLocals } = snapshotAt( + info, + { block, index: index + shift, origin }, + offsets, + debug?.inlineSites?.at(-1), + overwritten, + ); + return withVariables(debug, entries, stackLocals); + }; + + // A call's memory-homed result is on the stack at its continuation's + // entry, and in its home only once spilled. + const spilled = new Map(); + for (const block of func.blocks.values()) { + const term = block.terminator; + if (term.kind === "call" && term.dest && homed.has(term.dest)) { + spilled.set(term.continuation, term.dest); + } + } + + 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. + const first = block.instructions[0] ?? block.terminator; + const result = spilled.get(blockId); + const entry = stamp( + first.operationDebug, + blockId, + 0, + -1, + new Set(result ? [result] : []), + ); + const { variables } = (entry.context ?? {}) as { variables?: unknown }; + block.entryDebug = variables + ? { + context: { variables } as Format.Program.Context, + ...(entry.stackLocals ? { stackLocals: entry.stackLocals } : {}), + } + : {}; + + block.instructions.forEach((inst, i) => { + const debug = inst.operationDebug; + // An instruction that defines a memory-homed local computes its + // value before the MSTORE that homes it. Contexts are + // postconditions, so the instruction's ops carry the snapshot + // from before the def, and only the store carries the snapshot + // that locates the new value. + if (!("dest" in inst) || !homed.has(inst.dest as string)) { + inst.operationDebug = stamp(debug, blockId, i); + return; + } + const stored = stamp(debug, blockId, i); + inst.operationDebug = { ...stamp(debug, blockId, i, -1), stored }; + }); + // The terminator's position is after all instructions in the block. + const term = block.terminator; + if (!term) continue; + const debug = term.operationDebug; + // A jump into a block with phis first copies the phis' values into + // their homes; a local whose current version is one of those phis + // would read the next value early, so it has no pointer here. + const phis = term.kind === "jump" ? func.blocks.get(term.target)?.phis : []; + term.operationDebug = stamp( + debug, + blockId, + block.instructions.length, + 0, + new Set((phis ?? []).map((phi) => phi.dest)), + ); + // A call's memory-homed result is spilled when its continuation + // begins; that store carries the continuation's snapshot. + if (term.kind === "call" && term.dest && homed.has(term.dest)) { + const { origin: __, ...unplaced } = debug ?? {}; + term.operationDebug.stored = stamp(unplaced, term.continuation, -1); + } + } +} + +/** + * Enrich a module's instructions with per-instruction local-variable + * snapshots. Mutates in place; safe before generation. + */ +export function enrich( + module: Ir.Module, + memory: Memory.Module.Info, +): Ir.Module { + const sourceId = module.sourceId; + + const targets: { func: Ir.Function; mem?: Memory.Function.Info }[] = [ + { func: module.main, mem: memory.main }, + ...(module.create ? [{ func: module.create, mem: memory.create }] : []), + ...[...module.functions].map(([name, func]) => ({ + func, + mem: memory.functions[name], + })), + ]; + + for (const { func, mem } of targets) { + if (!mem) continue; + enrichFunction(func, module, mem, sourceId); + } + + return module; +} diff --git a/packages/bugc/src/evmgen/generation/block.ts b/packages/bugc/src/evmgen/generation/block.ts index 370e33eb38..a6782f1bfe 100644 --- a/packages/bugc/src/evmgen/generation/block.ts +++ b/packages/bugc/src/evmgen/generation/block.ts @@ -21,6 +21,7 @@ import { generateCallTerminator, } from "./control-flow/index.js"; import { annotateTop } from "./values/identify.js"; +import { withStackLocals } from "../debug/local-variables.js"; /** * Generate code for a basic block @@ -114,15 +115,18 @@ export function generate( }, }, }; + const entry = block.entryDebug; const continuationDebug = { + ...entry, context: { + ...entry?.context, ...returnCtx, ...(callSiteCode ? { code: callSiteCode } : {}), } as Format.Program.Context, }; result = result.then(JUMPDEST({ debug: continuationDebug })); } else { - result = result.then(JUMPDEST()); + result = result.then(JUMPDEST({ debug: block.entryDebug })); } // Annotate TOS with dest variable if this is a continuation with return value. @@ -156,11 +160,16 @@ export function generate( s.memory.frameSize !== undefined, spillDebug, ), - { - mnemonic: "MSTORE" as const, - opcode: 0x52, - debug: spillDebug, - }, + // The stack after the store is the stack before + // the DUP1. + withStackLocals( + { + mnemonic: "MSTORE" as const, + opcode: 0x52, + debug: spillDebug?.stored ?? spillDebug, + }, + s.stack, + ), ], }; }); @@ -214,7 +223,13 @@ export function generate( if (func && block.terminator.kind === "jump") { const target = func.blocks.get(block.terminator.target); if (target && target.phis.length > 0) { - result = result.then(generatePhis(target.phis, block.id)); + result = result.then( + generatePhis( + target.phis, + block.id, + block.terminator.operationDebug, + ), + ); } } else if (func && block.terminator.kind === "branch") { for (const targetId of [ @@ -257,12 +272,13 @@ export function generate( function generatePhis( phis: Ir.Block.Phi[], predecessor: string, + debug: Ir.Block.Debug, ): Transition { // The stack grows by one per load and shrinks by one per store, // which the types cannot follow across a list const steps = [ - ...phis.map((phi) => loadPhiSource(phi, predecessor)), - ...phis.map((phi) => storePhiDest(phi)).reverse(), + ...phis.map((phi) => loadPhiSource(phi, predecessor, debug)), + ...phis.map((phi) => storePhiDest(phi, debug)).reverse(), ] as unknown as Transition[]; return (state) => steps.reduce((current, step) => step(current), state); } @@ -270,6 +286,7 @@ function generatePhis( function loadPhiSource( phi: Ir.Block.Phi, predecessor: string, + debug: Ir.Block.Debug, ): Transition { const source = phi.sources.get(predecessor); if (!source) { @@ -279,11 +296,12 @@ function loadPhiSource( ); } - return loadValue(source); + return loadValue(source, { debug }); } function storePhiDest( phi: Ir.Block.Phi, + debug: Ir.Block.Debug, ): Transition { const { PUSHn, ADD, MLOAD, MSTORE } = operations; @@ -298,19 +316,21 @@ function storePhiDest( } if (state.memory.frameSize !== undefined) { return builder - .then(PUSHn(BigInt(Memory.regions.FRAME_POINTER)), { as: "offset" }) - .then(MLOAD(), { as: "b" }) - .then(PUSHn(BigInt(allocation.offset)), { + .then(PUSHn(BigInt(Memory.regions.FRAME_POINTER), { debug }), { + as: "offset", + }) + .then(MLOAD({ debug }), { as: "b" }) + .then(PUSHn(BigInt(allocation.offset), { debug }), { as: "a", }) - .then(ADD(), { as: "offset" }) - .then(MSTORE()); + .then(ADD({ debug }), { as: "offset" }) + .then(MSTORE({ debug })); } return builder - .then(PUSHn(BigInt(allocation.offset)), { + .then(PUSHn(BigInt(allocation.offset), { debug }), { as: "offset", }) - .then(MSTORE()); + .then(MSTORE({ debug })); }) .done(); } diff --git a/packages/bugc/src/evmgen/generation/bracket-activation.ts b/packages/bugc/src/evmgen/generation/bracket-activation.ts index 653c2ffc87..88a2886a78 100644 --- a/packages/bugc/src/evmgen/generation/bracket-activation.ts +++ b/packages/bugc/src/evmgen/generation/bracket-activation.ts @@ -97,6 +97,18 @@ export function stripActivation(ctx: Ctx | undefined): Ctx | undefined { const kids = ctx.gather .map(stripActivation) .filter((c): c is Ctx => c !== undefined); + // Keys beside the gather (e.g. local `variables`) still apply. + const { + gather: _, + invoke: __, + return: ___, + ...siblings + } = ctx as Record; + if (Object.keys(siblings).length > 0) { + return ( + kids.length > 0 ? { ...siblings, gather: kids } : siblings + ) as Ctx; + } if (kids.length === 0) return undefined; if (kids.length === 1) return kids[0]; return { gather: kids } as Ctx; @@ -118,7 +130,7 @@ function attachActivation( const marker = { [key]: value } as Ctx; if (!ctx || typeof ctx !== "object") return marker; if (isPick(ctx)) return { pick: [...ctx.pick, marker] } as Ctx; - if (isGather(ctx)) return { gather: [...ctx.gather, marker] } as Ctx; + if (isGather(ctx)) return { ...ctx, gather: [...ctx.gather, marker] } as Ctx; return { ...(ctx as Record), [key]: value } as Ctx; } diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index 54b30422a4..2ebef36ad3 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -9,6 +9,11 @@ import { Memory } from "#evmgen/analysis"; import { type Transition, operations, pipe } from "#evmgen/operations"; import { valueId, loadValue } from "../values/index.js"; +import { + withStackLocals, + withoutFrameLocals, +} from "../../debug/local-variables.js"; +import { stripActivation } from "../bracket-activation.js"; /** * Extract the `code` source-range context from an instruction or @@ -24,6 +29,28 @@ function codeContext( return undefined; } +/** + * Extract the in-scope `variables` list from an instruction/terminator + * debug, if present, so a call's caller JUMP still lists the locals + * that are in scope at the call (they must not vanish at the call). + */ +function variablesContext( + debug: { context?: Format.Program.Context } | undefined, +): Format.Program.Context.Variables["variables"] | undefined { + const ctx = debug?.context as Record | undefined; + if (ctx && Array.isArray(ctx.variables) && ctx.variables.length > 0) { + return ctx.variables as Format.Program.Context.Variables["variables"]; + } + return undefined; +} + +/** A terminator's debug without invoke/return discriminators */ +function withoutActivation(debug: Ir.Block.Debug): Ir.Block.Debug { + const context = stripActivation(debug.context); + const { context: _, ...rest } = debug; + return context ? { ...rest, context } : rest; +} + /** * Generate code for a block terminator */ @@ -88,9 +115,11 @@ export function generateTerminator( // invoke discriminators. Depth stays constant: one pops, // one pushes, on the same instruction. The function's // terminal RETURN pops the final iteration's frame normally. + // Activation markers already ride the block's instructions. + const debug = withoutActivation(term.operationDebug); const jumpDebug = term.tailCall ? buildTailCallJumpOptions(term.tailCall).debug - : undefined; + : debug; // Imperative, like generateCallTerminator/generateReturnEpilogue: // drop any leftover block-local scratch so the target block is @@ -98,13 +127,14 @@ export function generateTerminator( return ((state: State): State => { let s = state as State; while (s.stack.length > 0) { + const stack = s.stack.slice(1); s = { ...s, instructions: [ ...s.instructions, - { mnemonic: "POP", opcode: 0x50 }, + withStackLocals({ mnemonic: "POP", opcode: 0x50, debug }, stack), ], - stack: s.stack.slice(1), + stack, brands: s.brands.slice(1), }; } @@ -114,12 +144,14 @@ export function generateTerminator( ...s, instructions: [ ...s.instructions, - { mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0] }, - { - mnemonic: "JUMP", - opcode: 0x56, - ...(jumpDebug ? { debug: jumpDebug } : {}), - }, + withStackLocals( + { mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0], debug }, + [{}], + ), + withStackLocals( + { mnemonic: "JUMP", opcode: 0x56, debug: jumpDebug }, + [], + ), ], patches: [...s.patches, { index: patchIndex, target: term.target }], stack: [], @@ -133,31 +165,49 @@ export function generateTerminator( // beneath it (SWAP1/POP) so both successors are entered with the // canonical empty stack — the JUMPI and the fall-through JUMP // consume the condition and the pushed counters. + const debug = withoutActivation(term.operationDebug); return ((state: State): State => { - let s: State = loadValue(term.condition)(state as State); + let s: State = loadValue(term.condition, { debug })( + state as State, + ); while (s.stack.length > 1) { + const [top, below, ...rest] = s.stack; s = { ...s, instructions: [ ...s.instructions, - { mnemonic: "SWAP1", opcode: 0x90 }, - { mnemonic: "POP", opcode: 0x50 }, + withStackLocals({ mnemonic: "SWAP1", opcode: 0x90, debug }, [ + below, + top, + ...rest, + ]), + withStackLocals({ mnemonic: "POP", opcode: 0x50, debug }, [ + top, + ...rest, + ]), ], - stack: [s.stack[0], ...s.stack.slice(2)], + stack: [top, ...rest], brands: [s.brands[0], ...s.brands.slice(2)], }; } const trueIndex = s.instructions.length; const falseIndex = trueIndex + 2; + const condition = s.stack; return { ...s, instructions: [ ...s.instructions, - { mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0] }, - { mnemonic: "JUMPI", opcode: 0x57 }, - { mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0] }, - { mnemonic: "JUMP", opcode: 0x56 }, + withStackLocals( + { mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0], debug }, + [{}, ...condition], + ), + withStackLocals({ mnemonic: "JUMPI", opcode: 0x57, debug }, []), + withStackLocals( + { mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0], debug }, + [{}], + ), + withStackLocals({ mnemonic: "JUMP", opcode: 0x56, debug }, []), ], patches: [ ...s.patches, @@ -288,10 +338,12 @@ export function generateCallTerminator( // flat alongside the invoke, so the caller JUMP maps back to the // call expression. invoke and code are disjoint keys. const callSiteCode = codeContext(debug); + const inScopeVars = variablesContext(debug); const invokeContext = { context: { ...invoke, ...(callSiteCode ? { code: callSiteCode } : {}), + ...(inScopeVars ? { variables: inScopeVars } : {}), } as Format.Program.Context, }; @@ -402,6 +454,9 @@ function generateReturnEpilogue( const FP = Memory.regions.FRAME_POINTER; const FMP = Memory.regions.FREE_MEMORY_POINTER; const pcOff = Memory.frameHeader.SAVED_RETURN_PC; + // Restoring the caller's frame pointer ends this frame: from that + // MSTORE on, the function's locals are no longer listed. + const teardown = withoutFrameLocals(debug); s = { ...s, @@ -429,18 +484,18 @@ function generateReturnEpilogue( // mem[FRAME_POINTER] = old_fp { mnemonic: "DUP1", opcode: 0x80, debug }, ...pushImm(FP, debug), - { mnemonic: "MSTORE", opcode: 0x52, debug }, + { mnemonic: "MSTORE", opcode: 0x52, debug: teardown }, // Stack: [old_fp, fp, return_pc, ...] // mem[FREE_MEMORY_POINTER] = fp (deallocate) - { mnemonic: "POP", opcode: 0x50, debug }, + { mnemonic: "POP", opcode: 0x50, debug: teardown }, // Stack: [fp, return_pc, ...] - ...pushImm(FMP, debug), - { mnemonic: "MSTORE", opcode: 0x52, debug }, + ...pushImm(FMP, teardown), + { mnemonic: "MSTORE", opcode: 0x52, debug: teardown }, // Stack: [return_pc, ...] // JUMP - { mnemonic: "JUMP", opcode: 0x56, debug }, + { mnemonic: "JUMP", opcode: 0x56, debug: teardown }, ], }; diff --git a/packages/bugc/src/evmgen/generation/values/identify.ts b/packages/bugc/src/evmgen/generation/values/identify.ts index 2b2d641daa..0de0875177 100644 --- a/packages/bugc/src/evmgen/generation/values/identify.ts +++ b/packages/bugc/src/evmgen/generation/values/identify.ts @@ -1,6 +1,7 @@ import type * as Ir from "#ir"; import type { Stack } from "#evm"; import type { State } from "#evmgen/state"; +import { withStackLocals } from "../../debug/local-variables.js"; /** * Get the ID for a value @@ -33,8 +34,14 @@ export const annotateTop = irValue, }; + // The value on top came from the last instruction, so a local it + // holds is on the stack from that instruction on. + const last = state.instructions.at(-1); return { ...state, stack: newStack, + instructions: last + ? [...state.instructions.slice(0, -1), withStackLocals(last, newStack)] + : state.instructions, }; }; diff --git a/packages/bugc/src/evmgen/generation/values/store.ts b/packages/bugc/src/evmgen/generation/values/store.ts index 66751733f1..227220a9bb 100644 --- a/packages/bugc/src/evmgen/generation/values/store.ts +++ b/packages/bugc/src/evmgen/generation/values/store.ts @@ -1,5 +1,6 @@ import * as Evm from "#evm"; import type { Stack } from "#evm"; +import type * as Ir from "#ir"; import { type Transition, operations, pipe } from "#evmgen/operations"; import { Memory } from "#evmgen/analysis"; @@ -19,6 +20,11 @@ export const storeValueIfNeeded = ( ): Transition => { const { PUSHn, ADD, DUP2, SWAP1, MLOAD, MSTORE } = operations; + // The store itself carries the context that holds once the value is + // in memory (contexts are postconditions); the ops before it do not. + const { stored } = (options?.debug ?? {}) as Ir.Instruction.Debug; + const storeOptions = stored ? { debug: stored } : options; + return ( pipe() // First annotate the top value with the destination ID @@ -46,7 +52,7 @@ export const storeValueIfNeeded = ( .then(ADD(options), { as: "offset" }) .then(DUP2(options)) .then(SWAP1(options)) - .then(MSTORE(options)); + .then(MSTORE(storeOptions)); } return builder .then(PUSHn(BigInt(allocation.offset), options), { @@ -54,7 +60,7 @@ export const storeValueIfNeeded = ( }) .then(DUP2(options)) .then(SWAP1(options)) - .then(MSTORE(options)); + .then(MSTORE(storeOptions)); }) .done() ); diff --git a/packages/bugc/src/evmgen/pass.ts b/packages/bugc/src/evmgen/pass.ts index d741f5fca0..fba894757d 100644 --- a/packages/bugc/src/evmgen/pass.ts +++ b/packages/bugc/src/evmgen/pass.ts @@ -9,6 +9,7 @@ import { Error as EvmgenError, ErrorCode } from "#evmgen/errors"; import { buildProgram } from "#evmgen/program-builder"; import { Layout, Liveness, Memory } from "#evmgen/analysis"; +import { enrich as enrichLocalVariables } from "./debug/local-variables.js"; import { splitPhiBranchEdges } from "./split-edges.js"; @@ -61,6 +62,12 @@ const pass: Pass<{ ); } + // Attach local-variable debug info to the IR before generation, + // so it propagates onto the emitted instructions (stack pointers + // are resolved as they are emitted). Debug info only: it does not + // change the code. + enrichLocalVariables(ir, memoryResult.value); + // Analyze block layout const blockResult = Layout.Module.perform(ir); if (!blockResult.success) { diff --git a/packages/bugc/src/evmgen/split-edges.ts b/packages/bugc/src/evmgen/split-edges.ts index 03ac4697d4..0675e95513 100644 --- a/packages/bugc/src/evmgen/split-edges.ts +++ b/packages/bugc/src/evmgen/split-edges.ts @@ -1,4 +1,4 @@ -import type * as Ir from "#ir"; +import * as Ir from "#ir"; /** * Codegen stores a phi's source value just before the predecessor @@ -73,5 +73,7 @@ function splitFunction(func: Ir.Function): Ir.Function { }); } - return { ...func, blocks }; + const result = { ...func, blocks }; + Ir.Utils.copyDebugInfo(func, result); + return result; } diff --git a/packages/bugc/src/evmgen/state.ts b/packages/bugc/src/evmgen/state.ts index f4473ea8b6..938f24d4a8 100644 --- a/packages/bugc/src/evmgen/state.ts +++ b/packages/bugc/src/evmgen/state.ts @@ -5,6 +5,7 @@ import { type _ } from "#evm"; import * as Analysis from "#evmgen/analysis"; import type { Error } from "#evmgen/errors"; +import { withStackLocals } from "./debug/local-variables.js"; // Debug context type for EVM instructions export type DebugContext = { @@ -91,9 +92,14 @@ const unsafe: Evm.Unsafe.StateControls = { nextId: state.nextId + 1, }, }), + // `state` is the state after the instruction, so its stack gives + // the stack pointers of the locals the instruction lists. emit: (state, instruction) => ({ ...state, - instructions: [...state.instructions, instruction], + instructions: [ + ...state.instructions, + withStackLocals(instruction, state.stack), + ], }), }; diff --git a/packages/bugc/src/ir/spec/block.ts b/packages/bugc/src/ir/spec/block.ts index ce64cd62b7..c4a40bd0c8 100644 --- a/packages/bugc/src/ir/spec/block.ts +++ b/packages/bugc/src/ir/spec/block.ts @@ -1,4 +1,3 @@ -import type * as Format from "@ethdebug/format"; import type * as Ast from "#ast"; import { Value } from "./value.js"; @@ -21,15 +20,15 @@ export interface Block { predecessors: Set; /** Debug information (e.g., for if/while blocks) */ debug: Block.Debug; + /** Debug for the block's entry (its JUMPDEST), set by code generation */ + entryDebug?: Block.Debug; } export namespace Block { /** * Debug information for blocks, terminators, and phi nodes */ - export interface Debug { - context?: Format.Program.Context; - } + export type Debug = Instruction.Debug; /** * Metadata for a jump that originated as a tail call. diff --git a/packages/bugc/src/ir/spec/function.ts b/packages/bugc/src/ir/spec/function.ts index 93d72d8c8e..75cd40a112 100644 --- a/packages/bugc/src/ir/spec/function.ts +++ b/packages/bugc/src/ir/spec/function.ts @@ -21,9 +21,26 @@ export interface Function { loc?: Ast.SourceLocation; /** Source ID for debug info (inherited from module) */ sourceId?: string; + /** + * Dominator trees from before optimization, by function name (this + * function's, and those of functions inlined into it). Instruction + * and version `origin`s refer to these. + */ + origins?: Map>; } export namespace Function { + /** + * A position in a function as it was before optimization: a block + * and an instruction index (`-1`: block entry, i.e. a parameter or + * phi; the instruction count: the terminator). + */ + export interface Origin { + function: string; + block: string; + index: number; + } + /** * Function parameter in SSA form */ @@ -52,5 +69,47 @@ export namespace Function { version: number; /** Source location of declaration */ loc?: Ast.SourceLocation; + /** + * Source offset at which the variable's enclosing lexical scope + * ends. Together with `loc`, gives the lexical extent over which + * the variable is in scope: `[loc.offset, scopeEnd)`. Used to + * list a variable (name + type) across its scope, independently + * of whether its value is currently located. + */ + scopeEnd?: number; + /** + * The source range (`offset:end`) of the scope that declares the + * variable; undefined for a function-level name (a parameter). + * With `name` (and `inlineSite`), it tells declarations apart. + */ + declaredIn?: string; + /** + * Where the version is defined, if not where its temp is: a + * version that aliases a temp computed earlier is defined at the + * assignment, between instructions (a fractional index). + */ + defined?: { block: string; index: number }; + /** + * Where the version was defined before optimization (see + * `Function.origins`), if it was defined. + */ + origin?: Function.Origin; + /** + * The temp that holds this variable's value, where it is not the + * temp this entry is keyed by (e.g. an inlined parameter, whose + * value is the call's argument). + */ + temp?: string; + /** + * A version whose temp the function never defines (a declaration's + * own version, replaced by its initializer's temp). It carries the + * declaration's location and scope only. + */ + placeholder?: boolean; + /** + * For a variable of an inlined callee, the inline site it belongs + * to (see `Instruction.Debug.inlineSites`). + */ + inlineSite?: string; } } diff --git a/packages/bugc/src/ir/spec/instruction.ts b/packages/bugc/src/ir/spec/instruction.ts index 2720c10c18..1d072c020c 100644 --- a/packages/bugc/src/ir/spec/instruction.ts +++ b/packages/bugc/src/ir/spec/instruction.ts @@ -1,6 +1,7 @@ import * as Format from "@ethdebug/format"; import type { Type } from "./type.js"; +import type { Function } from "./function.js"; import { Value } from "./value.js"; export type Instruction = @@ -39,6 +40,30 @@ export namespace Instruction { export interface Debug { context?: Format.Program.Context; + /** + * Debug for the MSTORE that homes this instruction's result in + * memory, where it differs from this one: from that store on, a + * local whose value the result is can be listed with a pointer. + */ + stored?: Debug; + /** + * Inline sites this instruction was inlined through, outermost + * first. Only the innermost site's locals are listed here. + */ + inlineSites?: string[]; + /** Where the instruction was before optimization */ + origin?: Function.Origin; + /** + * Listed locals whose value may be on the stack: code generation + * gives each a stack pointer where the stack holds its temp. + */ + stackLocals?: Array<{ + identifier: string; + temp: string; + size: number; + /** For a reference, the pointers to the data it refers to */ + data?: Format.Pointer[]; + }>; } // Location types for unified read/write diff --git a/packages/bugc/src/ir/utils/debug-info.ts b/packages/bugc/src/ir/utils/debug-info.ts new file mode 100644 index 0000000000..237a8225b6 --- /dev/null +++ b/packages/bugc/src/ir/utils/debug-info.ts @@ -0,0 +1,25 @@ +import type * as Ir from "#ir"; + +/** + * Set a function's debug info (variable metadata, origins) where the + * optimizer's fixpoint check, which compares modules as JSON, does not + * see it: debug info must not change the generated code. + */ +export function setDebugInfo( + func: Ir.Function, + key: K, + value: Ir.Function[K], +): void { + Object.defineProperty(func, key, { + value, + enumerable: false, + writable: true, + configurable: true, + }); +} + +/** Carry a function's debug info over to a copy of it */ +export function copyDebugInfo(from: Ir.Function, to: Ir.Function): void { + if (from.ssaVariables) setDebugInfo(to, "ssaVariables", from.ssaVariables); + if (from.origins) setDebugInfo(to, "origins", from.origins); +} diff --git a/packages/bugc/src/ir/utils/debug.ts b/packages/bugc/src/ir/utils/debug.ts index e34120f672..7a0e0ed0e0 100644 --- a/packages/bugc/src/ir/utils/debug.ts +++ b/packages/bugc/src/ir/utils/debug.ts @@ -9,6 +9,27 @@ import type * as Format from "@ethdebug/format"; */ export function combineDebugContexts( ...debugs: (Ir.Instruction.Debug | Ir.Block.Debug | undefined)[] +): 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]); +} + +/** Keep an instruction's inline sites and origin on a new debug. */ +function withPlacement( + debug: Ir.Instruction.Debug, + from: Ir.Instruction.Debug | undefined, +): Ir.Instruction.Debug { + return { + ...debug, + ...(from?.inlineSites ? { inlineSites: from.inlineSites } : {}), + ...(from?.origin ? { origin: from.origin } : {}), + }; +} + +function combineContexts( + debugs: (Ir.Instruction.Debug | Ir.Block.Debug | undefined)[], ): Ir.Instruction.Debug { // Filter out undefined and empty debug objects const contexts = debugs @@ -396,9 +417,8 @@ export function addTransform( const transform = [...prior, ...ids]; - if (!existing) { - return { context: { transform } }; - } - - return { context: { ...existing, transform } }; + return withPlacement( + { context: { ...(existing ?? {}), transform } as Format.Program.Context }, + debug, + ); } diff --git a/packages/bugc/src/ir/utils/def-sites.ts b/packages/bugc/src/ir/utils/def-sites.ts new file mode 100644 index 0000000000..41910f597b --- /dev/null +++ b/packages/bugc/src/ir/utils/def-sites.ts @@ -0,0 +1,63 @@ +import type * as Ir from "#ir"; + +/** + * Where a temp is defined: a block plus an instruction index (`-1`: + * block entry, i.e. a parameter or a phi, before all instructions). + */ +export interface DefSite { + block: string; + index: number; +} + +/** + * The def site of every temp a function defines: parameters and phis + * at block entry, instruction results at their index, and call + * results at their continuation's entry (the result is on the stack + * when the continuation begins). + */ +export function defSites(func: Ir.Function): Map { + const defs = new Map(); + for (const p of func.parameters) { + defs.set(p.tempId, { block: func.entry, index: -1 }); + } + for (const [blockId, block] of func.blocks) { + for (const phi of block.phis ?? []) { + defs.set(phi.dest, { block: blockId, index: -1 }); + } + block.instructions.forEach((inst, i) => { + if ("dest" in inst && typeof inst.dest === "string") { + defs.set(inst.dest, { block: blockId, index: i }); + } + }); + const term = block.terminator; + if (term.kind === "call" && term.dest) { + defs.set(term.dest, { block: term.continuation, index: -1 }); + } + } + return defs; +} + +/** Whether block `a` dominates (or is) block `b`. */ +export function dominatesBlock( + a: string, + b: string, + idom: Record, +): boolean { + let current: string | null = b; + while (current !== null) { + if (current === a) return true; + current = idom[current] ?? null; + } + return false; +} + +/** Whether def site `d` dominates (or is) position (block, index). */ +export function dominates( + d: DefSite, + block: string, + index: number, + idom: Record, +): boolean { + if (d.block === block) return d.index <= index; + return dominatesBlock(d.block, block, idom); +} diff --git a/packages/bugc/src/ir/utils/index.ts b/packages/bugc/src/ir/utils/index.ts index 2f538a5122..da8e7954bf 100644 --- a/packages/bugc/src/ir/utils/index.ts +++ b/packages/bugc/src/ir/utils/index.ts @@ -1,2 +1,4 @@ export * from "./debug.js"; export * from "./cast.js"; +export * from "./def-sites.js"; +export * from "./debug-info.js"; diff --git a/packages/bugc/src/irgen/generate/function.ts b/packages/bugc/src/irgen/generate/function.ts index 59e1527687..01d7512d08 100644 --- a/packages/bugc/src/irgen/generate/function.ts +++ b/packages/bugc/src/irgen/generate/function.ts @@ -12,6 +12,7 @@ export function* buildFunction( parameters: { name: string; type: Ir.Type; + loc?: Ast.SourceLocation; }[], body: Ast.Block, ): Process { @@ -43,8 +44,19 @@ export function* buildFunction( const blocks = computePredecessors(blocksBeforeCompute); const params = yield* Process.Functions.currentParameters(); - // Collect SSA variable metadata - const ssaVariables = yield* Process.Functions.collectSsaMetadata(); + // Collect SSA variable metadata. A declaration can make a version + // whose temp is never defined; mark it, so debug info can tell it + // from a version the optimizer removed. + const defined = definedTemps(blocks, params); + const ssaVariables = new Map( + [...(yield* Process.Functions.collectSsaMetadata())].map( + ([key, ssa]) => + [ + key, + defined.has(ssa.temp ?? key) ? ssa : { ...ssa, placeholder: true }, + ] as const, + ), + ); const module_ = yield* Process.Modules.current(); @@ -61,6 +73,26 @@ export function* buildFunction( return function_; } +/** Temps a function defines: parameters, phis, results, call results */ +function definedTemps( + blocks: Map, + params: Ir.Function.Parameter[], +): Set { + const defined = new Set(params.map((p) => p.tempId)); + for (const block of blocks.values()) { + for (const phi of block.phis ?? []) defined.add(phi.dest); + for (const inst of block.instructions) { + if ("dest" in inst && typeof inst.dest === "string") { + defined.add(inst.dest); + } + } + if (block.terminator.kind === "call" && block.terminator.dest) { + defined.add(block.terminator.dest); + } + } + return defined; +} + /** * Compute predecessors for all blocks based on their terminators */ diff --git a/packages/bugc/src/irgen/generate/module.ts b/packages/bugc/src/irgen/generate/module.ts index dd3fd0ea39..caa8bb4471 100644 --- a/packages/bugc/src/irgen/generate/module.ts +++ b/packages/bugc/src/irgen/generate/module.ts @@ -66,6 +66,7 @@ export function* buildModule( const parameters = funcDecl.parameters.map((param, index) => ({ name: param.name, type: fromBugType(funcType.parameters[index]), + loc: param.loc ?? undefined, })); const func = yield* withErrorHandling( diff --git a/packages/bugc/src/irgen/generate/process.ts b/packages/bugc/src/irgen/generate/process.ts index fb9dee4c36..eda3fb507e 100644 --- a/packages/bugc/src/irgen/generate/process.ts +++ b/packages/bugc/src/irgen/generate/process.ts @@ -243,6 +243,37 @@ export namespace Process { * Variable and scope management */ export namespace Variables { + /** A scope's declarations, with `name` declared at `loc` */ + const declaredHere = ( + scope: State.Scope, + name: string, + loc: Ast.SourceLocation | undefined, + ): State.Scope["declared"] => + loc + ? new Map([ + ...(scope.declared ?? []), + [ + name, + { + offset: Number(loc.offset), + range: scope.declaringIn ?? scope.range, + }, + ], + ]) + : scope.declared; + + /** + * Make the current scope's next declarations in scope over `range` + * (a `for` loop's, for its init), or over the scope's own range + * again (undefined). + */ + export function* declaringIn( + range: Ast.SourceLocation | undefined, + ): Process { + const scope = yield* lift(State.Scopes.current)(); + yield* lift(State.Scopes.setCurrent)({ ...scope, declaringIn: range }); + } + /** * Declare a new SSA variable in the current scope */ @@ -267,6 +298,7 @@ export namespace Process { ...scope, ssaVars: new Map([...scope.ssaVars, [name, ssaVar]]), usedNames: new Map([...scope.usedNames, [name, version + 1]]), + declared: declaredHere(scope, name, loc), }; // Update scopes @@ -277,7 +309,7 @@ export namespace Process { (s) => s.stack.length - 1, ); const scopeId = `scope_${scopeIndex}_${name}`; - yield* addSsaMetadata(tempId, name, scopeId, type, version, loc); + yield* addSsaMetadata(tempId, name, scopeId, type, version, { loc }); return ssaVar; } @@ -290,6 +322,7 @@ export namespace Process { type: Ir.Type, tempId: string, loc?: Ast.SourceLocation, + options: { placeholder?: boolean } = {}, ): Process { const scope = yield* lift(State.Scopes.current)(); @@ -306,6 +339,7 @@ export namespace Process { ...scope, ssaVars: new Map([...scope.ssaVars, [name, ssaVar]]), usedNames: new Map([...scope.usedNames, [name, version + 1]]), + declared: declaredHere(scope, name, loc), }; // Update scopes @@ -316,7 +350,11 @@ export namespace Process { (s) => s.stack.length - 1, ); const scopeId = `scope_${scopeIndex}_${name}`; - yield* addSsaMetadata(tempId, name, scopeId, type, version, loc); + yield* addSsaMetadata(tempId, name, scopeId, type, version, { + loc, + alias: true, + placeholder: options.placeholder, + }); return ssaVar; } @@ -388,18 +426,68 @@ export namespace Process { scopeId: string, type: Ir.Type, version: number, - loc?: Ast.SourceLocation, + options: { + loc?: Ast.SourceLocation; + /** The temp holds a value computed earlier (perhaps another + * variable's): the version is defined here, not at the temp */ + alias?: boolean; + /** The version only marks the declaration; it holds no value */ + placeholder?: boolean; + } = {}, ): Process { + const { loc, alias, placeholder } = options; const state: State = yield { type: "peek" }; const currentMetadata = state.function.ssaMetadata || new Map(); + // The variable's declaring scope: the innermost scope that holds + // the name (every version of a declaration, phis included, names + // the scope that declared it). Its lexical scope ends where that + // scope's source range ends; a function-level name (a parameter) + // has no range. + const stack = state.scopes.stack; + let declaring = stack.length - 1; + while (declaring >= 0 && !stack[declaring].ssaVars.has(name)) { + declaring--; + } + const declaration = stack[declaring]?.declared?.get(name); + const scopeRange = declaration + ? declaration.range + : stack[declaring]?.range; + const scopeEnd = scopeRange + ? Number(scopeRange.offset) + Number(scopeRange.length) + : undefined; + // Two declarations of a name in one scope differ by where each + // is declared + const declaredIn = scopeRange + ? `${Number(scopeRange.offset)}:${scopeEnd}:${declaration?.offset ?? ""}` + : undefined; + + // A version that aliases a temp is defined just after the + // instructions emitted so far in the current block: from the + // next instruction on, never on an instruction of an earlier + // statement (contexts are postconditions). Each version has its + // own entry, so an aliased temp's key gets a suffix. + const defined = alias + ? { + block: state.block.id, + index: state.block.instructions.length - 0.5, + } + : undefined; + let key = tempId; + for (let n = 1; currentMetadata.has(key); n++) key = `${tempId}~${n}`; + const newMetadata = new Map(currentMetadata); - newMetadata.set(tempId, { + newMetadata.set(key, { name, scopeId, type, version, loc, + scopeEnd, + ...(declaredIn ? { declaredIn } : {}), + ...(key !== tempId ? { temp: tempId } : {}), + ...(defined ? { defined } : {}), + ...(placeholder ? { placeholder } : {}), }); // Update function with new metadata @@ -543,7 +631,10 @@ export namespace Process { }; /** - * Update an SSA variable to point to an existing temp without creating a new one + * Update an SSA variable to point to an existing temp without + * creating a new one. The new version aliases the temp: debug info + * records where the version is defined, here, apart from the temp's + * own definition. */ export function* updateSsaToExistingTemp( name: string, @@ -585,7 +676,9 @@ export namespace Process { // Track SSA metadata for the existing temp const scopeId = `scope_${scopeIndex === -1 ? 0 : scopeIndex}_${name}`; - yield* addSsaMetadata(existingTempId, name, scopeId, type, version); + yield* addSsaMetadata(existingTempId, name, scopeId, type, version, { + alias: true, + }); return; } @@ -617,7 +710,9 @@ export namespace Process { // Track SSA metadata for the existing temp const scopeId = `scope_${scopeIndex}_${name}`; - yield* addSsaMetadata(existingTempId, name, scopeId, type, newVersion); + yield* addSsaMetadata(existingTempId, name, scopeId, type, newVersion, { + alias: true, + }); } /** @@ -880,7 +975,7 @@ export namespace Process { */ export function* initialize( name: string, - parameters: { name: string; type: Ir.Type }[], + parameters: { name: string; type: Ir.Type; loc?: Ast.SourceLocation }[], ): Process { // Convert parameters to SSA form const ssaParams: Ir.Function.Parameter[] = []; @@ -910,6 +1005,7 @@ export namespace Process { scopeId: "param", type: param.type, version: 0, + ...(param.loc ? { loc: param.loc } : {}), }); } diff --git a/packages/bugc/src/irgen/generate/state.ts b/packages/bugc/src/irgen/generate/state.ts index efe70be2eb..4278e8cc34 100644 --- a/packages/bugc/src/irgen/generate/state.ts +++ b/packages/bugc/src/irgen/generate/state.ts @@ -169,6 +169,15 @@ export namespace State { export interface Scope { readonly ssaVars: Map; // SSA variable tracking readonly usedNames: Map; // For handling shadowing + readonly range?: Ast.SourceLocation; // Lexical extent of this scope + /** Where each name is declared, and the range it is in scope over */ + readonly declared?: Map< + string, + { offset: number; range?: Ast.SourceLocation } + >; + /** The range declarations made now are in scope over, if not this + * scope's (a `for` loop's init) */ + readonly declaringIn?: Ast.SourceLocation; } /** @@ -191,9 +200,12 @@ export namespace State { (read) => (state) => read(state.scopes), ); - export const push = () => + export const push = (range?: Ast.SourceLocation) => update((scopes) => ({ - stack: [...scopes.stack, { ssaVars: new Map(), usedNames: new Map() }], + stack: [ + ...scopes.stack, + { ssaVars: new Map(), usedNames: new Map(), range }, + ], })); export const pop = () => diff --git a/packages/bugc/src/irgen/generate/statements/block.ts b/packages/bugc/src/irgen/generate/statements/block.ts index 762b72a522..710b25f531 100644 --- a/packages/bugc/src/irgen/generate/statements/block.ts +++ b/packages/bugc/src/irgen/generate/statements/block.ts @@ -8,7 +8,7 @@ export const makeBuildBlock = ( buildStatement: (stmt: Ast.Statement) => Process, ) => function* buildBlock(block: Ast.Block): Process { - yield* Process.Variables.enterScope(); + yield* Process.Variables.enterScope(block.loc ?? undefined); for (const item of block.items) { if (Ast.isStatement(item)) { diff --git a/packages/bugc/src/irgen/generate/statements/control-flow.ts b/packages/bugc/src/irgen/generate/statements/control-flow.ts index f89fdb6ccd..aa3c78397f 100644 --- a/packages/bugc/src/irgen/generate/statements/control-flow.ts +++ b/packages/bugc/src/irgen/generate/statements/control-flow.ts @@ -131,9 +131,13 @@ const makeBuildLoop = ( }): Process { const buildBlock = makeBuildBlock(buildStatement); - // Execute init statement if present (for loops) + // Execute init statement if present (for loops). Its variables + // are in scope for the loop only (for debug info), as the + // typechecker scopes them. if (config.init) { + yield* Process.Variables.declaringIn(config.node?.loc ?? undefined); yield* buildStatement(config.init); + yield* Process.Variables.declaringIn(undefined); } // Create blocks diff --git a/packages/bugc/src/irgen/generate/statements/declare.ts b/packages/bugc/src/irgen/generate/statements/declare.ts index fc011901a6..9242c1e075 100644 --- a/packages/bugc/src/irgen/generate/statements/declare.ts +++ b/packages/bugc/src/irgen/generate/statements/declare.ts @@ -98,10 +98,20 @@ function* buildVariableDeclaration( } as Ir.Instruction); // Declare the SSA variable and directly use the allocTemp as its value + // With an initializer other than a hex literal, the local's value + // is the initializer's (below), not this allocation. + const builtLater = + !!decl.initializer && + !( + Ast.Expression.isLiteral(decl.initializer) && + Ast.Expression.Literal.isHex(decl.initializer) + ); yield* Process.Variables.declareWithExistingTemp( decl.name, irType, allocTemp, + decl.loc ?? undefined, + { placeholder: builtLater }, ); // If there's an initializer, store the value in memory @@ -112,7 +122,7 @@ function* buildVariableDeclaration( // For reference types, we need to handle initialization // Check the initializer type to determine how to store it - if (Ast.Expression.isLiteral(decl.initializer)) { + if (!builtLater) { const hexLiteral = decl.initializer as Ast.Expression.Literal; if (Ast.Expression.Literal.isHex(hexLiteral)) { const hexValue = hexLiteral.value.startsWith("0x") @@ -151,7 +161,7 @@ function* buildVariableDeclaration( } as Ir.Instruction.Write); } } else { - // For slice expressions and other bytes operations, + // For string literals, slice expressions and other bytes operations, // the value is already a reference to memory // We need to copy the slice result to the new allocation // This is a simplified version - a full implementation would need to @@ -183,7 +193,11 @@ function* buildVariableDeclaration( const value = yield* buildExpression(decl.initializer, { kind: "rvalue", }); - const ssaVar = yield* Process.Variables.declare(decl.name, irType); + const ssaVar = yield* Process.Variables.declare( + decl.name, + irType, + decl.loc ?? undefined, + ); // Generate assignment to the new SSA temp if (value.kind === "temp") { @@ -207,7 +221,11 @@ function* buildVariableDeclaration( } } else { // No initializer - declare with default value - const ssaVar = yield* Process.Variables.declare(decl.name, irType); + const ssaVar = yield* Process.Variables.declare( + decl.name, + irType, + decl.loc ?? undefined, + ); yield* Process.Instructions.emit({ kind: "const", value: 0n, diff --git a/packages/bugc/src/optimizer/optimizer.ts b/packages/bugc/src/optimizer/optimizer.ts index 418e9cd2f6..ff52786161 100644 --- a/packages/bugc/src/optimizer/optimizer.ts +++ b/packages/bugc/src/optimizer/optimizer.ts @@ -228,7 +228,7 @@ export abstract class BaseOptimizationStep implements OptimizationStep { }); } - return { + const cloned: Ir.Function = { name: func.name, parameters: [...func.parameters], entry: func.entry, @@ -240,6 +240,11 @@ export abstract class BaseOptimizationStep implements OptimizationStep { ...(func.loc ? { loc: func.loc } : {}), ...(func.sourceId !== undefined ? { sourceId: func.sourceId } : {}), }; + if (func.ssaVariables) { + Ir.Utils.setDebugInfo(cloned, "ssaVariables", new Map(func.ssaVariables)); + } + if (func.origins) Ir.Utils.setDebugInfo(cloned, "origins", func.origins); + return cloned; } protected replaceInstruction( diff --git a/packages/bugc/src/optimizer/origins.ts b/packages/bugc/src/optimizer/origins.ts new file mode 100644 index 0000000000..2a556dd07d --- /dev/null +++ b/packages/bugc/src/optimizer/origins.ts @@ -0,0 +1,48 @@ +import * as Ir from "#ir"; + +/** + * Record where each instruction, terminator and variable version is + * before optimization, with each function's dominator tree. Debug + * info uses these to tell which version of a local is current at an + * optimized instruction, including versions the optimizer removed. + */ +export function recordOrigins(module: Ir.Module): void { + const functions = [ + module.main, + ...(module.create ? [module.create] : []), + ...module.functions.values(), + ]; + for (const func of functions) { + const at = (block: string, index: number): Ir.Function.Origin => ({ + function: func.name, + block, + index, + }); + // Out of the optimizer's sight, like the rest of the debug info + // (see setDebugInfo) + const idom = new Ir.Analysis.Statistics.Analyzer().analyze({ + ...module, + main: func, + }).dominatorTree; + Ir.Utils.setDebugInfo(func, "origins", new Map([[func.name, idom]])); + for (const [id, block] of func.blocks) { + block.instructions.forEach((inst, i) => { + inst.operationDebug = { ...inst.operationDebug, origin: at(id, i) }; + }); + block.terminator.operationDebug = { + ...block.terminator.operationDebug, + origin: at(id, block.instructions.length), + }; + } + const defs = Ir.Utils.defSites(func); + for (const [key, ssa] of func.ssaVariables ?? []) { + const def = ssa.defined ?? defs.get(ssa.temp ?? key); + if (def && !ssa.placeholder) { + func.ssaVariables!.set(key, { + ...ssa, + origin: at(def.block, def.index), + }); + } + } + } +} diff --git a/packages/bugc/src/optimizer/simple-optimizer.ts b/packages/bugc/src/optimizer/simple-optimizer.ts index 7c64642d70..de1f94ba4c 100644 --- a/packages/bugc/src/optimizer/simple-optimizer.ts +++ b/packages/bugc/src/optimizer/simple-optimizer.ts @@ -4,6 +4,7 @@ import * as Ir from "#ir"; import { OptimizationPipeline, type OptimizationStep } from "./optimizer.js"; +import { recordOrigins } from "./origins.js"; import { ConstantFoldingStep, DeadCodeEliminationStep, @@ -21,6 +22,7 @@ import { * Apply all optimizations based on the specified level */ export function optimizeIr(module: Ir.Module, level: number): Ir.Module { + recordOrigins(module); if (level === 0) return module; const steps = createOptimizationPipeline(level); diff --git a/packages/bugc/src/optimizer/steps/constant-propagation.ts b/packages/bugc/src/optimizer/steps/constant-propagation.ts index 1fb5f86008..261cea22fa 100644 --- a/packages/bugc/src/optimizer/steps/constant-propagation.ts +++ b/packages/bugc/src/optimizer/steps/constant-propagation.ts @@ -23,7 +23,13 @@ export class ConstantPropagationStep extends BaseOptimizationStep { let newInst = inst; // Track constant assignments and their debug info - if (inst.kind === "const" && "dest" in inst) { + // A string constant (a reference) is data that code + // generation puts in memory, not a value an operand can hold. + if ( + inst.kind === "const" && + "dest" in inst && + inst.type.kind !== "ref" + ) { constants.set(inst.dest, inst.value); if (inst.operationDebug) { constantDebug.set(inst.dest, inst.operationDebug); diff --git a/packages/bugc/src/optimizer/steps/inlining.ts b/packages/bugc/src/optimizer/steps/inlining.ts index c7ed4c423f..42fb77801f 100644 --- a/packages/bugc/src/optimizer/steps/inlining.ts +++ b/packages/bugc/src/optimizer/steps/inlining.ts @@ -150,6 +150,9 @@ export class InliningStep extends BaseOptimizationStep { if (!idRename.has(id)) idRename.set(id, prefix + id); } + const inlineSites = (debug: Ir.Instruction.Debug | undefined) => + inlineSitesFor(prefix, debug); + const remapValue = (v: Ir.Value): Ir.Value => { if (v.kind !== "temp") return v; const sub = paramSubst.get(v.id); @@ -196,6 +199,7 @@ export class InliningStep extends BaseOptimizationStep { const instructions: Ir.Instruction[] = origBlock.instructions.map( (inst, idx) => { const cloned = remapInstruction(inst, remapValue, idRename); + const sites = inlineSites(inst.operationDebug); // Mark every inlined instruction for membership. cloned.operationDebug = Ir.Utils.addTransform( cloned.operationDebug, @@ -217,6 +221,7 @@ export class InliningStep extends BaseOptimizationStep { ); } } + cloned.operationDebug.inlineSites = sites; return cloned; }, ); @@ -254,6 +259,13 @@ export class InliningStep extends BaseOptimizationStep { } else { terminator = remapTerminator(t, remapValue, blockRename); } + terminator.operationDebug = { + ...terminator.operationDebug, + inlineSites: inlineSites(t.operationDebug), + ...(t.operationDebug?.origin + ? { origin: t.operationDebug.origin } + : {}), + }; caller.blocks.set(newId, { id: newId, @@ -265,6 +277,25 @@ export class InliningStep extends BaseOptimizationStep { }); } + // --- carry the callee's locals into the caller --- + // Each keeps the temp that now holds its value (a parameter's is + // the call's argument; none if that is a constant) and belongs to + // this inline site. + const variables = new Map(caller.ssaVariables); + for (const [key, ssa] of callee.ssaVariables ?? []) { + const temp = ssa.temp ?? key; + const arg = paramSubst.get(temp); + variables.set(prefix + key, { + ...ssa, + temp: arg + ? arg.kind === "temp" + ? arg.id + : undefined + : (idRename.get(temp) ?? temp), + inlineSite: prefix + (ssa.inlineSite ?? ""), + }); + } + // --- wire the single return value into the caller --- // Current eligibility guarantees exactly one return. Substitute the // call's dest temp with the (remapped) returned value across the @@ -272,9 +303,26 @@ export class InliningStep extends BaseOptimizationStep { if (call.dest) { const returns = collectReturns(callee, blockRename, remapValue); if (returns.length === 1) { - substituteTemp(caller, call.dest, returns[0].value); + const value = returns[0].value; + substituteTemp(caller, call.dest, value); + // A local the call's result was assigned to now has the + // returned value's temp (none if that is a constant). + const local = variables.get(call.dest); + if (local) { + variables.set(call.dest, { + ...local, + temp: value.kind === "temp" ? value.id : undefined, + }); + } } } + if (variables.size > 0) + Ir.Utils.setDebugInfo(caller, "ssaVariables", variables); + Ir.Utils.setDebugInfo( + caller, + "origins", + new Map([...(callee.origins ?? []), ...(caller.origins ?? [])]), + ); // --- rewire the calling block: call -> jump into inlined entry --- callBlock.terminator = { @@ -290,6 +338,18 @@ export class InliningStep extends BaseOptimizationStep { // ---- helpers ---- +/** + * The inline sites of an instruction once inlined through `prefix`: + * this site, then the sites it was already inlined through, renamed + * as its callee's locals are. + */ +function inlineSitesFor( + prefix: string, + debug: Ir.Instruction.Debug | undefined, +): string[] { + return [prefix, ...(debug?.inlineSites ?? []).map((s) => prefix + s)]; +} + function collectReturns( callee: Ir.Function, blockRename: Map, @@ -564,6 +624,7 @@ function mergeDiscriminator( if (existing && "gather" in existing && Array.isArray(existing.gather)) { // Add as a new gather child rather than a sibling of gather. return { + ...debug, context: { ...existing, gather: [...(existing.gather as unknown[]), { [key]: value }], @@ -571,6 +632,7 @@ function mergeDiscriminator( }; } return { + ...debug, context: { ...(existing ?? {}), [key]: value, @@ -596,6 +658,7 @@ function gatherCallSite( if ("gather" in existing && Array.isArray(existing.gather)) { // Already a gather — add the call site as another child. return { + ...debug, context: { gather: [callSite, ...(existing.gather as unknown[])], } as Format.Program.Context, @@ -604,11 +667,13 @@ function gatherCallSite( if ("code" in existing) { // Colliding `code` keys — gather both. return { + ...debug, context: { gather: [callSite, existing] } as Format.Program.Context, }; } // No existing `code` — compose flat. return { + ...debug, context: { ...existing, code: callSiteCode } as Format.Program.Context, }; } diff --git a/packages/bugc/test/evm/locals.ts b/packages/bugc/test/evm/locals.ts new file mode 100644 index 0000000000..4eac4997fc --- /dev/null +++ b/packages/bugc/test/evm/locals.ts @@ -0,0 +1,400 @@ +/** + * Support for tests of local-variable debug info: compile, run with a + * full trace, and read each listed local's pointer with + * @ethdebug/pointers `dereference` against the machine state after + * each step (contexts are postconditions). + */ +import { expect } from "vitest"; +import { compile } from "#compiler"; +import { Executor, createMachineState, type TraceStep } from "@ethdebug/evm"; +import { dereference } from "@ethdebug/pointers"; +import { bytesToHex } from "ethereum-cryptography/utils"; +import type * as Format from "@ethdebug/format"; + +export type Level = 0 | 1 | 2 | 3; + +export interface LocalsTrace { + program: Format.Program; + executor: Executor; + steps: TraceStep[]; + /** The program's instruction at a step's pc */ + instructionAt(step: TraceStep): Format.Program.Instruction | undefined; +} + +/** Compile (at O0 by default) and run once, keeping every step. */ +export async function traceLocals( + source: string, + level: Level = 0, +): Promise { + const result = await compile({ + to: "bytecode", + source, + optimizer: { level }, + }); + if (!result.success) throw new Error("compile failed"); + const bytecode = result.value.bytecode; + const executor = new Executor(); + await executor.deploy( + bytesToHex( + bytecode.create && bytecode.create.length > 0 + ? bytecode.create + : bytecode.runtime, + ), + ); + const steps: TraceStep[] = []; + await executor.execute({ data: "" }, (step) => steps.push(step)); + + const program = bytecode.runtimeProgram; + const byOffset = new Map( + program.instructions.map((i) => [Number(i.offset), i]), + ); + return { + program, + executor, + steps, + instructionAt: (step) => byOffset.get(step.pc), + }; +} + +/** The `variables` entries of a context. */ +export function localsOf(context: unknown): Array> { + if (!context || typeof context !== "object") return []; + const variables = (context as { variables?: unknown }).variables; + return Array.isArray(variables) + ? (variables as Array>) + : []; +} + +/** The layout of a reference-typed local, for reading its pointer */ +export type Shape = + | { kind: "bytes" } + | { kind: "array"; element: Shape | { kind: "scalar"; size: number } }; + +/** A reference's value as its regions read it: hex for bytes and + * strings, an array of bigints (or nested arrays) for arrays. */ +export type RefValue = string | bigint | RefValue[]; + +/** A value a local takes, and (optionally) the source text of the + * statement that gives it: the value may be read only once that + * statement has run (as many times as the value is listed after it). */ +export type Value = RefValue | { value: RefValue; after: string }; + +export interface Local { + /** The values the local takes, in execution order */ + values: Value[]; + /** A scalar's size in bytes (default 32) */ + size?: number; + /** A reference's layout */ + shape?: Shape; + /** Must be located at every level, not just at O0 */ + everyLevel?: boolean; +} + +export interface LocalsProgram { + name: string; + source: string; + /** + * By local: its name, or `name@text` for one of several + * declarations of a name, where `text` is source text within that + * declaration. + */ + locals: Record; + /** The levels to check at, if not all */ + levels?: Level[]; +} + +/** + * Run a program at a level and check every listed local: at each step + * that lists a local with a pointer, the state after the step must + * hold one of the local's values, read through the pointer region by + * region against memory: not earlier than the last value read or the + * latest one whose statement has finished, and not one whose statement + * has not run yet. A listed local the program does not expect fails. + * At O0 (or every level, if so marked), every local must be located at + * some step. + */ +export async function check( + { source, locals }: LocalsProgram, + level: Level, +): Promise { + const run = await traceLocals(source, level); + const { steps, executor } = run; + + // The declaration each key names: an offset in the source + const keys = Object.keys(locals).map((key) => { + const [name, text] = key.split("@"); + const at = text === undefined ? undefined : source.indexOf(text); + if (at !== undefined && at < 0) throw new Error(`no "${text}" in source`); + return { key, name, at }; + }); + const keyOf = (entry: Record): string | undefined => { + const range = ( + entry.declaration as { range?: { offset: number; length: number } } + )?.range; + return keys.find( + ({ name, at }) => + name === entry.identifier && + (at === undefined || + (range !== undefined && + at >= range.offset && + at < range.offset + range.length)), + )?.key; + }; + + // How often the statement giving each value has run so far + const statements = [ + ...new Set( + Object.values(locals).flatMap((local) => + local.values.flatMap((v) => + typeof v === "object" && v !== null && "after" in v ? [v.after] : [], + ), + ), + ), + ].map((text) => { + const offset = source.indexOf(text); + if (offset < 0) throw new Error(`no "${text}" in source`); + return { text, offset, end: offset + text.length }; + }); + // A statement runs while an instruction within it executes, and has + // finished a run when execution leaves it + const runs = new Map(); + const finished = new Map(); + let inside = new Set(); + + /** The values of a local, with the ordinal of each among the values + * its statement gives */ + const valuesOf = (local: Local) => { + const seen = new Map(); + return local.values.map((v) => { + const { value, after } = + typeof v === "object" && v !== null && "after" in v + ? v + : { value: v, after: undefined }; + const run = + after === undefined + ? 0 + : (seen.set(after, (seen.get(after) ?? 0) + 1), seen.get(after)!); + return { value, after, run }; + }); + }; + + const at: Record = {}; + for (let k = 0; k + 1 < steps.length; k++) { + const context = run.instructionAt(steps[k])?.context; + const ranges = codeRanges(context); + const now = new Set( + statements + .filter((s) => ranges.some(([o, e]) => o >= s.offset && e <= s.end)) + .map((s) => s.text), + ); + for (const text of now) { + if (!inside.has(text)) runs.set(text, (runs.get(text) ?? 0) + 1); + } + for (const text of inside) { + if (!now.has(text)) finished.set(text, (finished.get(text) ?? 0) + 1); + } + inside = now; + + for (const entry of localsOf(context)) { + const pointer = entry.pointer as Record | undefined; + // A storage variable (a local's pointer is never into storage) + if (!pointer || JSON.stringify(pointer).includes('"storage"')) continue; + const name = entry.identifier as string; + const key = keyOf(entry); + const where = `after ${steps[k].opcode} at pc ${steps[k].pc}`; + expect(key, `unexpected local ${name} ${where}`).toBeDefined(); + const local = locals[key!]; + + const read = local.shape + ? await readReference( + pointer, + name, + local.shape, + executor, + steps[k + 1], + ) + : await readScalar(pointer, name, executor, steps[k + 1]); + + // Never an earlier value than the last one read, nor than the + // latest one whose statement has finished (that would be stale); + // never a value whose statement has not run (that would be early) + const values = valuesOf(local); + const latest = values.reduce( + (last, { after, run }, i) => + after !== undefined && (finished.get(after) ?? 0) >= run ? i : last, + 0, + ); + const from = Math.max(at[key!] ?? 0, latest); + const index = values.findIndex(({ value, after, run }, i) => { + if (i < from) return false; + if (after !== undefined && (runs.get(after) ?? 0) < run) return false; + const expected = + local.shape || typeof value !== "bigint" + ? stringify(value) + : hexBytes(value, local.size ?? 32); + return expected === stringify(read); + }); + expect( + index, + `${key} reads ${stringify(read)} ${where}; expected ` + + values + .slice(from) + .map(({ value }) => stringify(value)) + .join(", "), + ).toBeGreaterThanOrEqual(0); + at[key!] = index; + } + } + + const required = Object.keys(locals).filter( + (key) => level === 0 || locals[key].everyLevel, + ); + expect( + required.filter((key) => !(key in at)), + `locals not located at O${level}`, + ).toEqual([]); +} + +/** The source ranges of a context's `code`, through `gather` */ +function codeRanges(context: unknown): Array<[number, number]> { + if (!context || typeof context !== "object") return []; + const { code, gather } = context as { + code?: { range?: { offset: number; length: number } }; + gather?: unknown[]; + }; + const range = code?.range; + return [ + ...(range ? [[range.offset, range.offset + range.length]] : []), + ...(Array.isArray(gather) ? gather.flatMap(codeRanges) : []), + ] as Array<[number, number]>; +} + +/** A value as the hex of `size` big-endian bytes. */ +export function hexBytes(value: bigint, size = 32): string { + return "0x" + value.toString(16).padStart(size * 2, "0"); +} + +/** Text as the hex of its UTF-8 bytes */ +export function textBytes(value: string): string { + return "0x" + Buffer.from(value).toString("hex"); +} + +function stringify(value: unknown): string { + return typeof value === "string" + ? value + : JSON.stringify(value, (_, v) => + typeof v === "bigint" ? hexBytes(v).replace(/^0x0*/, "0x") : v, + ); +} + +/** + * Dereference a pointer at a step and read its regions in order, + * checking each against the step's memory. + */ +async function regionsAt( + pointer: unknown, + executor: Executor, + step: TraceStep, +) { + const state = createMachineState(executor, { traceStep: step }); + const cursor = await dereference(pointer as Format.Pointer, { state }); + const view = await cursor.view(state); + const memory = step.memory ?? new Uint8Array(); + const regions = [...view.regions]; + let next = 0; + return { + /** Move to the region named `name` */ + seek(name: string) { + next = regions.findIndex((r) => r.name === name); + if (next < 0) throw new Error(`no region ${name}`); + }, + /** Read the next region, which must be named `name` (and, in + * memory, at `offset` and `size` bytes long, if given) */ + async read(name: string, offset?: bigint, size?: bigint) { + const region = regions[next++]; + if (!region || region.name !== name) { + throw new Error(`expected region ${name}, got ${region?.name}`); + } + const data = await view.read(region); + if (region.location === "memory") { + const start = region.offset!.asUint(); + const length = region.length!.asUint(); + if (offset !== undefined && start !== offset) { + throw new Error(`${name} at ${start}, expected ${offset}`); + } + if (size !== undefined && length !== size) { + throw new Error(`${name} is ${length} bytes, expected ${size}`); + } + const actual = new Uint8Array(Number(length)); + actual.set(memory.slice(Number(start), Number(start + length))); + if (data.toHex() !== "0x" + Buffer.from(actual).toString("hex")) { + throw new Error(`${name} does not read memory at ${start}`); + } + } + return data; + }, + }; +} + +/** A scalar local's bytes (as hex) at a step */ +async function readScalar( + pointer: unknown, + name: string, + executor: Executor, + step: TraceStep, +): Promise { + const regions = await regionsAt(pointer, executor, step); + regions.seek(name); + return (await regions.read(name)).toHex(); +} + +/** + * A reference-typed local's value at a step, by its layout: the + * local's word holds the base address, the length word is at the base, + * each element is in its word (a scalar right-aligned), and the data + * follows the length word, as long as the length says. + */ +async function readReference( + pointer: unknown, + name: string, + shape: Shape, + executor: Executor, + step: TraceStep, +): Promise { + const regions = await regionsAt(pointer, executor, step); + regions.seek(name); + + const decode = async ( + shape: Shape, + prefix: string, + base: bigint, + ): Promise => { + const length = (await regions.read(`${prefix}-length`, base, 32n)).asUint(); + if (shape.kind === "bytes") { + return (await regions.read(`${prefix}-data`, base + 32n, length)).toHex(); + } + const element = shape.element; + const values: RefValue[] = []; + for (let i = 0n; i < length; i++) { + const word = base + 32n + 32n * i; + if (element.kind === "scalar") { + const size = BigInt(element.size); + const value = await regions.read( + `${prefix}-element`, + word + 32n - size, + size, + ); + values.push(value.asUint()); + } else { + const address = ( + await regions.read(`${prefix}-element`, word, 32n) + ).asUint(); + values.push(await decode(element, `${prefix}-element`, address)); + } + } + return values; + }; + + const base = (await regions.read(name)).asUint(); + return decode(shape, name, base); +} diff --git a/packages/bugc/test/examples/examples.test.ts b/packages/bugc/test/examples/examples.test.ts index e6b344005a..f82b0d3554 100644 --- a/packages/bugc/test/examples/examples.test.ts +++ b/packages/bugc/test/examples/examples.test.ts @@ -206,42 +206,48 @@ describe("Example Files", async () => { const itFn = skip ? it.skip : it; for (const level of [0, 1, 2, 3] as const) { - itFn(`${relativePath} at O${level}`, async () => { - const compiler = buildSequence(bytecodeSequence); - const result = await compiler.run({ - source, - optimizer: { level }, - }); - if (!result.success) { - expect.fail(`Compilation failed at O${level}`); - } + itFn( + `${relativePath} at O${level}`, + async () => { + const compiler = buildSequence(bytecodeSequence); + const result = await compiler.run({ + source, + optimizer: { level }, + }); + if (!result.success) { + expect.fail(`Compilation failed at O${level}`); + } - const { runtimeProgram, createProgram } = result.value.bytecode; - const storageNames = (result.value.ast.storage ?? []).map( - ({ name }) => name, - ); - for (const program of [runtimeProgram, createProgram]) { - if (!program) continue; - // validate the program as it is written out, as JSON - const output = await validate( - "schema:ethdebug/format/program", - JSON.parse(JSON.stringify(program)), - BASIC, + const { runtimeProgram, createProgram } = result.value.bytecode; + const storageNames = (result.value.ast.storage ?? []).map( + ({ name }) => name, ); - const invalid = output.valid - ? [] - : (output.errors ?? []).map((e) => e.instanceLocation); - expect(invalid).toEqual([]); - - // every storage variable is in the program-level context - const { context } = program; - const identifiers = - context && Format.Program.Context.isVariables(context) - ? context.variables.map(({ identifier }) => identifier) - : []; - expect(identifiers).toEqual(storageNames); - } - }); + for (const program of [runtimeProgram, createProgram]) { + if (!program) continue; + // validate the program as it is written out, as JSON + const output = await validate( + "schema:ethdebug/format/program", + JSON.parse(JSON.stringify(program)), + BASIC, + ); + const invalid = output.valid + ? [] + : (output.errors ?? []).map((e) => e.instanceLocation); + expect(invalid).toEqual([]); + + // every storage variable is in the program-level context + const { context } = program; + const identifiers = + context && Format.Program.Context.isVariables(context) + ? context.variables.map(({ identifier }) => identifier) + : []; + expect(identifiers).toEqual(storageNames); + } + // A program lists its locals at every instruction, so a long + // one takes a while to validate + }, + 10_000, + ); } } }); diff --git a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx index 26e6a315c9..ad7e569a85 100644 --- a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx +++ b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx @@ -313,6 +313,13 @@ export function collectVariablesWithLocations( } ``` +BUG also lists the local variables (parameters and `let`s) in scope at each +instruction, each with a pointer to its value where the value is located: a +memory word in the function's frame, or a stack slot. A local hides a storage +variable of the same name while it is in scope. Under optimization, a local +whose value the optimizer folded to a constant or removed is listed without a +pointer. See `packages/bugc/src/evmgen/debug/local-variables.ts`. + ## Testing strategy BUG tests debug output in several ways: @@ -379,8 +386,6 @@ failing entirely. Areas for improvement in BUG's debug support: -- **Memory tracking** — Track memory allocations for local variable pointers -- **Stack variables** — Generate stack pointers during code generation - **Richer contexts** — Add frame information for function calls - **Source maps** — More granular source location tracking