Skip to content

Commit db92840

Browse files
committed
inline: spec, compiler pass, tracer reconstruction, and examples
Add the function-inlining transform end to end: - spec the inlined-call virtual-activation contract — push/pop pairing via the virtual invoke/return versus the per-instruction `inline` membership marker; - add the bugc inlining pass (level 2): splice eligible leaf callees into their call sites, emit `transform: ["inline"]` on the spliced body, and bracket it with a virtual invoke/return; bracket those boundary ops in evmgen so the activation nests correctly; - reconstruct the inline virtual activations in the tracer's call stack; - add the inlining showcase examples and the trace-playground section.
1 parent 5640bfd commit db92840

18 files changed

Lines changed: 1466 additions & 176 deletions

File tree

‎packages/bugc/src/evmgen/generation/block.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Memory } from "#evmgen/analysis";
1414
import { calculateSize } from "#evmgen/serialize";
1515

1616
import * as Instruction from "./instruction.js";
17+
import { bracketActivation, carriesActivation } from "./bracket-activation.js";
1718
import { loadValue } from "./values/index.js";
1819
import {
1920
generateTerminator,
@@ -161,9 +162,31 @@ export function generate<S extends Stack>(
161162
// the runtime predecessor differs from the layout-order
162163
// predecessor.
163164

164-
// Process regular instructions
165+
// Process regular instructions. Invoke/return activation
166+
// discriminators must be bracketed to the first/last emitted op
167+
// of the instruction (see bracket-activation.ts); everything else
168+
// (source mapping, variables, transform markers) rides all ops.
165169
for (const inst of block.instructions) {
166-
result = result.then(Instruction.generate(inst));
170+
const gen = Instruction.generate(inst);
171+
const operationCtx = inst.operationDebug?.context;
172+
if (
173+
!carriesActivation(operationCtx, "invoke") &&
174+
!carriesActivation(operationCtx, "return")
175+
) {
176+
result = result.then(gen);
177+
continue;
178+
}
179+
result = result.peek((state, builder) => {
180+
const start = state.instructions.length;
181+
return builder.then(gen).then((s) => ({
182+
...s,
183+
instructions: bracketActivation(
184+
s.instructions,
185+
start,
186+
operationCtx,
187+
),
188+
}));
189+
});
167190
}
168191

169192
// Emit phi copies for successor blocks before the
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* Bracket invoke/return activation discriminators onto the boundary
3+
* ops of an IR instruction's emitted op-run.
4+
*
5+
* A single IR instruction lowers to N EVM micro-ops, and the generic
6+
* lowering attaches that instruction's whole `operationDebug` (source
7+
* mapping, variables, transform markers, AND any invoke/return
8+
* discriminators) to every one of those ops. That is correct for
9+
* source/variable/transform context — a debugger wants all N ops
10+
* mapped to the instruction — but WRONG for invoke/return: those are
11+
* positional activation boundaries. An `invoke` marks a single push
12+
* point; a `return` a single pop point. Broadcasting them across the
13+
* whole op-run makes a push/pop reconstruction see every op as both a
14+
* push and a pop.
15+
*
16+
* This module de-smears: for the ops emitted by one instruction, the
17+
* `invoke` discriminator is kept on only the FIRST op, `return` on only
18+
* the LAST op, and stripped from the interior. The `transform`
19+
* membership markers (and source/variables) stay on every op.
20+
*
21+
* It is a general evmgen invariant, not inline-specific: it is a no-op
22+
* for real calls (whose invoke/return already ride single-op JUMP /
23+
* JUMPDEST terminators) and fires only when invoke/return happen to
24+
* ride a multi-op instruction — which today is inlined virtual
25+
* activations.
26+
*/
27+
import type * as Format from "@ethdebug/format";
28+
import type * as Evm from "#evm";
29+
30+
type Ctx = Format.Program.Context;
31+
type Activation = "invoke" | "return";
32+
33+
function isPick(ctx: Ctx): ctx is Ctx & { pick: Ctx[] } {
34+
return (
35+
typeof ctx === "object" &&
36+
ctx !== null &&
37+
"pick" in ctx &&
38+
Array.isArray((ctx as { pick: unknown }).pick)
39+
);
40+
}
41+
42+
function isGather(ctx: Ctx): ctx is Ctx & { gather: Ctx[] } {
43+
return (
44+
typeof ctx === "object" &&
45+
ctx !== null &&
46+
"gather" in ctx &&
47+
Array.isArray((ctx as { gather: unknown }).gather)
48+
);
49+
}
50+
51+
/** Whether ctx carries the given activation key anywhere, reaching
52+
* into pick/gather composites. */
53+
export function carriesActivation(
54+
ctx: Ctx | undefined,
55+
key: Activation,
56+
): boolean {
57+
if (!ctx || typeof ctx !== "object") return false;
58+
if (isPick(ctx)) return ctx.pick.some((c) => carriesActivation(c, key));
59+
if (isGather(ctx)) return ctx.gather.some((c) => carriesActivation(c, key));
60+
return key in ctx;
61+
}
62+
63+
/** The first activation value found for the given key, reaching into
64+
* pick/gather composites. */
65+
function findActivation(ctx: Ctx | undefined, key: Activation): unknown {
66+
if (!ctx || typeof ctx !== "object") return undefined;
67+
if (isPick(ctx)) {
68+
for (const c of ctx.pick) {
69+
const v = findActivation(c, key);
70+
if (v !== undefined) return v;
71+
}
72+
return undefined;
73+
}
74+
if (isGather(ctx)) {
75+
for (const c of ctx.gather) {
76+
const v = findActivation(c, key);
77+
if (v !== undefined) return v;
78+
}
79+
return undefined;
80+
}
81+
return (ctx as Record<string, unknown>)[key];
82+
}
83+
84+
/** Remove invoke and return discriminators anywhere in ctx, reaching
85+
* into pick/gather composites. Returns undefined if nothing remains. */
86+
export function stripActivation(ctx: Ctx | undefined): Ctx | undefined {
87+
if (!ctx || typeof ctx !== "object") return ctx;
88+
if (isPick(ctx)) {
89+
const kids = ctx.pick
90+
.map(stripActivation)
91+
.filter((c): c is Ctx => c !== undefined);
92+
if (kids.length === 0) return undefined;
93+
if (kids.length === 1) return kids[0];
94+
return { pick: kids } as Ctx;
95+
}
96+
if (isGather(ctx)) {
97+
const kids = ctx.gather
98+
.map(stripActivation)
99+
.filter((c): c is Ctx => c !== undefined);
100+
if (kids.length === 0) return undefined;
101+
if (kids.length === 1) return kids[0];
102+
return { gather: kids } as Ctx;
103+
}
104+
const rest = { ...(ctx as Record<string, unknown>) };
105+
delete rest.invoke;
106+
delete rest.return;
107+
return Object.keys(rest).length > 0 ? (rest as Ctx) : undefined;
108+
}
109+
110+
/** Attach an activation discriminator, composing it as a flat sibling
111+
* key on a leaf context (per the flat-composition convention), or
112+
* appending it to a pick/gather composite. */
113+
function attachActivation(
114+
ctx: Ctx | undefined,
115+
key: Activation,
116+
value: unknown,
117+
): Ctx {
118+
const marker = { [key]: value } as Ctx;
119+
if (!ctx || typeof ctx !== "object") return marker;
120+
if (isPick(ctx)) return { pick: [...ctx.pick, marker] } as Ctx;
121+
if (isGather(ctx)) return { gather: [...ctx.gather, marker] } as Ctx;
122+
return { ...(ctx as Record<string, unknown>), [key]: value } as Ctx;
123+
}
124+
125+
/**
126+
* Rewrite the ops emitted by one IR instruction (the tail slice
127+
* `instructions[start..]`) so invoke rides only the first op and
128+
* return only the last op, using the discriminators found on the
129+
* instruction's `operationDebug` context. No-op unless that context
130+
* carries invoke and/or return, so it never touches ordinary code.
131+
*/
132+
export function bracketActivation(
133+
instructions: Evm.Instruction[],
134+
start: number,
135+
operationCtx: Ctx | undefined,
136+
): Evm.Instruction[] {
137+
const end = instructions.length; // exclusive
138+
if (end <= start) return instructions;
139+
140+
const hasInvoke = carriesActivation(operationCtx, "invoke");
141+
const hasReturn = carriesActivation(operationCtx, "return");
142+
if (!hasInvoke && !hasReturn) return instructions;
143+
144+
const invokeValue = hasInvoke
145+
? findActivation(operationCtx, "invoke")
146+
: undefined;
147+
const returnValue = hasReturn
148+
? findActivation(operationCtx, "return")
149+
: undefined;
150+
151+
const out = instructions.slice();
152+
for (let i = start; i < end; i++) {
153+
const op = out[i];
154+
let ctx = stripActivation(op.debug?.context);
155+
if (hasInvoke && i === start) {
156+
ctx = attachActivation(ctx, "invoke", invokeValue);
157+
}
158+
if (hasReturn && i === end - 1) {
159+
ctx = attachActivation(ctx, "return", returnValue);
160+
}
161+
out[i] = { ...op, debug: { ...op.debug, context: ctx } };
162+
}
163+
return out;
164+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* Verifies that inlined virtual-activation invoke/return contexts are
3+
* BRACKETED on the emitted bytecode, not smeared across every op.
4+
*
5+
* An IR instruction lowers to N EVM micro-ops. evmgen must attach the
6+
* `invoke` discriminator to only the FIRST emitted op of the
7+
* invoke-bearing instruction and the `return` discriminator to only the
8+
* LAST emitted op of the return-bearing instruction, while keeping the
9+
* `transform: ["inline"]` membership marker on ALL body ops.
10+
*
11+
* Without bracketing, the tracer's push/pop reconstruction sees every
12+
* body op as both a push and a pop -> phantom frames.
13+
*/
14+
import { describe, it, expect } from "vitest";
15+
16+
import { compile } from "#compiler";
17+
import { executeProgram } from "#test/evm/behavioral";
18+
import type * as Format from "@ethdebug/format";
19+
import { Program } from "@ethdebug/format";
20+
21+
const { Context } = Program;
22+
23+
type OptLevel = 0 | 1 | 2 | 3;
24+
25+
async function runtimeInstructions(source: string, level: OptLevel) {
26+
const result = await compile({
27+
to: "bytecode",
28+
source,
29+
optimizer: { level },
30+
});
31+
if (!result.success) {
32+
const errors = result.messages.error ?? [];
33+
throw new Error(
34+
`Compilation failed at level ${level}:\n` +
35+
errors
36+
.map((e: { message?: string }) => e.message ?? String(e))
37+
.join("\n"),
38+
);
39+
}
40+
return result.value.bytecode.runtimeInstructions;
41+
}
42+
43+
/** Flatten a context into leaves, unwrapping gather/pick. */
44+
function leaves(ctx: Format.Program.Context): Format.Program.Context[] {
45+
if (Context.isGather(ctx)) return ctx.gather.flatMap(leaves);
46+
if ("pick" in ctx && Array.isArray((ctx as { pick: unknown[] }).pick)) {
47+
return (ctx as { pick: Format.Program.Context[] }).pick.flatMap(leaves);
48+
}
49+
return [ctx];
50+
}
51+
52+
/** Per-op discriminator/marker presence, reaching nested pick/gather. */
53+
function flags(instr: { debug?: { context?: Format.Program.Context } }) {
54+
const ctx = instr.debug?.context;
55+
if (!ctx) return { invoke: false, return: false, inline: false };
56+
const all = [ctx, ...leaves(ctx)];
57+
return {
58+
invoke: all.some((c) => Context.isInvoke(c)),
59+
return: all.some((c) => Context.isReturn(c)),
60+
inline: all.some(
61+
(c) => Context.isTransform(c) && c.transform.includes("inline"),
62+
),
63+
};
64+
}
65+
66+
function tally(instrs: ReturnType<typeof flags>[]) {
67+
let invoke = 0,
68+
ret = 0,
69+
both = 0,
70+
inline = 0;
71+
for (const f of instrs) {
72+
if (f.invoke) invoke += 1;
73+
if (f.return) ret += 1;
74+
if (f.invoke && f.return) both += 1;
75+
if (f.inline) inline += 1;
76+
}
77+
return { invoke, ret, both, inline };
78+
}
79+
80+
// The exact fixture the UI reported mis-rendering: a leaf helper
81+
// inlined at two sites.
82+
const dblTwoSites = `name Multi;
83+
define { function dbl(x: uint256) -> uint256 { return x + x; }; }
84+
storage { [0] r: uint256; }
85+
create { r = 0; }
86+
code {
87+
let a = dbl(5);
88+
let b = dbl(10);
89+
r = a + b;
90+
}`;
91+
92+
// A multi-instruction body: entry (t = x + x) differs from exit
93+
// (t * x), so invoke and return live on distinct IR instructions.
94+
const multiInstrBody = `name Poly;
95+
define { function poly(x: uint256) -> uint256 { let t = x + x; return t * x; }; }
96+
storage { [0] a: uint256; [1] r: uint256; }
97+
create { a = 3; r = 0; }
98+
code { r = poly(a); }`;
99+
100+
describe("inlined invoke/return are bracketed on emitted bytecode", () => {
101+
it("dbl@2-sites: one push and one pop per site, never both on an op", async () => {
102+
const instrs = await runtimeInstructions(dblTwoSites, 2);
103+
const t = tally(instrs.map(flags));
104+
// Two inlined sites => exactly one invoke op and one return op each.
105+
expect(t.invoke).toBe(2);
106+
expect(t.ret).toBe(2);
107+
// No op may be both a push and a pop (that breaks push/pop).
108+
expect(t.both).toBe(0);
109+
// Membership marker stays on every body op (more than the 4
110+
// boundary ops).
111+
expect(t.inline).toBeGreaterThan(4);
112+
});
113+
114+
it("dbl@2-sites: each site's invoke op precedes its return op", async () => {
115+
const instrs = await runtimeInstructions(dblTwoSites, 2);
116+
const seq = instrs
117+
.map((instr, i) => ({ i, f: flags(instr) }))
118+
.filter(({ f }) => f.invoke || f.return)
119+
.map(({ f }) => (f.invoke ? "invoke" : "return"));
120+
// Bracketed order across two sites: push,pop,push,pop.
121+
expect(seq).toEqual(["invoke", "return", "invoke", "return"]);
122+
});
123+
124+
it("multi-instruction body: invoke on entry, return on exit, both=0", async () => {
125+
const instrs = await runtimeInstructions(multiInstrBody, 2);
126+
const t = tally(instrs.map(flags));
127+
expect(t.invoke).toBe(1);
128+
expect(t.ret).toBe(1);
129+
expect(t.both).toBe(0);
130+
});
131+
132+
it("preserves runtime behavior at every level", async () => {
133+
for (const level of [0, 1, 2, 3] as const) {
134+
const res = await executeProgram(dblTwoSites, {
135+
calldata: "",
136+
optimizationLevel: level,
137+
});
138+
expect(res.callSuccess).toBe(true);
139+
// dbl(5)=10, dbl(10)=20, r=30
140+
expect(await res.getStorage(0n)).toBe(30n);
141+
}
142+
});
143+
});
144+
145+
// A self-tail-recursive accumulator: TCO turns the recursive call
146+
// into a single back-edge JUMP that legitimately carries BOTH invoke
147+
// and return on its one op (end one iteration + begin the next).
148+
const tailRecursive = `name TailSum;
149+
define {
150+
function sum(n: uint256, acc: uint256) -> uint256 {
151+
if (n == 0) { return acc; }
152+
else { return sum(n - 1, acc + n); }
153+
};
154+
}
155+
storage { [0] result: uint256; }
156+
create { result = 0; }
157+
code { result = sum(5, 0); }`;
158+
159+
// Mutually recursive functions never inline, so their calls stay real
160+
// (invoke on a 1-op JUMP, return on a 1-op JUMPDEST).
161+
const mutualRecursion = `name EvenOdd;
162+
define {
163+
function isEven(n: uint256) -> uint256 {
164+
if (n == 0) { return 1; } else { return isOdd(n - 1); }
165+
};
166+
function isOdd(n: uint256) -> uint256 {
167+
if (n == 0) { return 0; } else { return isEven(n - 1); }
168+
};
169+
}
170+
storage { [0] result: uint256; }
171+
create { result = 0; }
172+
code { result = isEven(4); }`;
173+
174+
describe("bracketing is a no-op for single-op invoke/return carriers", () => {
175+
it("tailcall back-edge keeps its combined invoke+return on one op", async () => {
176+
// The back-edge JUMP is a single op carrying both markers; bracketing
177+
// to first-op/last-op is first==last, so both must survive.
178+
const instrs = await runtimeInstructions(tailRecursive, 2);
179+
const t = tally(instrs.map(flags));
180+
expect(t.both).toBeGreaterThanOrEqual(1);
181+
});
182+
183+
it("real (non-inlined) calls never carry both on an op", async () => {
184+
const instrs = await runtimeInstructions(mutualRecursion, 2);
185+
const t = tally(instrs.map(flags));
186+
// Real calls put invoke on a 1-op JUMP and return on a 1-op JUMPDEST,
187+
// distinct ops — the fix must not fabricate a both.
188+
expect(t.both).toBe(0);
189+
expect(t.invoke).toBeGreaterThan(0);
190+
expect(t.ret).toBeGreaterThan(0);
191+
});
192+
});

0 commit comments

Comments
 (0)