Skip to content

Commit a36e919

Browse files
authored
Add transform context for annotating compiler optimizations (#216)
* format: add the transform context for compiler optimizations Introduce the `transform` context: a flat sibling key on an instruction listing the optimization passes (fold, coalesce, tailcall, inline) the compiler applied to it, so a debugger can distinguish compiler-synthesized code from the source the user wrote. Adds the schema, the format TypeScript type, and the spec page, and registers the context page in the docs schema index. * tailcall: emit, render, and document the tail-call transform Emit `transform: ["tailcall"]` on the TCO back-edge JUMP, alongside the invoke/return pair it already carries, so a debugger sees the pair as a tail-call-optimized back-edge rather than a real frame push/pop and does not invent a spurious frame. Render it in the tracer drawer — an optimizer-level (O0-O3) selector and a tail-call-aware call stack — and document it with a tracing example and a walkthrough homed in the trace playground. Introduces the shared `Ir.Utils.addTransform` helper — the fold, coalesce, and inline emitters all route through it — and the transform-emission test file (`transform-contexts.test.ts`, with the `countTransform` helper), covering the tailcall marker: absent at O0/O1, present at O2/O3. * bugc: emit the fold transform on constant folding Mark constant-folded values with `transform: ["fold"]` through a shared `addTransform` helper, so the tracer can show a value as a compile-time-evaluated constant rather than source. The marker composes with later passes into a multiset (e.g. `["fold", "coalesce"]`). * bugc: emit the coalesce transform on read-write merging Mark the SHL/OR field-packing sequence produced by read-write merging with `transform: ["coalesce"]`, so a packed-storage write reads as compiler-synthesized rather than source the user wrote. Composes with a folded value into `["fold", "coalesce"]`. * 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. * format: correlate activations with an activation identifier Correlate activations with an `activation` identifier on invoke, return, and revert: the invoke that opens an activation and the return or revert that closes it carry the same value, so a debugger pairs a call with its return independent of trace order. It lives inside the invoke/return/revert object, keeping a tail call's two facts flat without a `gather`. * web: surface optimizer transforms in the tracer instruction list Show the tailcall and inline transform annotations inline in the tracer's instruction list, so the optimizer passes applied to each instruction are visible as you step through the trace.
1 parent c6f391c commit a36e919

38 files changed

Lines changed: 2963 additions & 436 deletions

‎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+
}

‎packages/bugc/src/evmgen/generation/control-flow/terminator.ts‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type * as Format from "@ethdebug/format";
22
import type * as Ir from "#ir";
3+
import { Utils as IrUtils } from "#ir";
34
import type * as Evm from "#evm";
45
import type { Stack } from "#evm";
56
import type { State } from "#evmgen/state";
@@ -411,17 +412,25 @@ function generateReturnEpilogue<S extends Stack>(
411412
/**
412413
* Build JUMP instruction options for a TCO-replaced tail call.
413414
*
414-
* The JUMP carries BOTH discriminators on a single flat
415-
* context object:
415+
* The JUMP carries three keys on a single flat context
416+
* object:
416417
* - return: the previous iteration's return
417418
* - invoke: the new iteration's call
419+
* - transform: ["tailcall"]
418420
*
419421
* Semantically the debugger sees frame depth stay constant
420422
* across the back-edge JUMP: the previous frame pops, the
421423
* new one pushes, on the same instruction. The function's
422424
* terminal RETURN (elsewhere) emits a return context
423425
* normally, popping the final iteration's frame.
424426
*
427+
* The `transform: ["tailcall"]` key is an additive
428+
* annotation: it does not replace the invoke/return pair
429+
* (which state the source-level facts) but tells debuggers
430+
* the pair was realized as a TCO back-edge rather than a
431+
* real frame push/pop, so they can avoid inventing a
432+
* spurious frame.
433+
*
425434
* The invoke mirrors the normal caller-JUMP invoke
426435
* (identity + declaration + code target, no argument
427436
* pointers). The return omits `data` because TCO does not
@@ -432,7 +441,7 @@ function generateReturnEpilogue<S extends Stack>(
432441
* resolved later by patchInvokeTarget.
433442
*/
434443
function buildTailCallJumpOptions(tailCall: Ir.Block.TailCall): {
435-
debug: { context: Format.Program.Context };
444+
debug: Ir.Instruction.Debug;
436445
} {
437446
const declaration =
438447
tailCall.declarationLoc && tailCall.declarationSourceId
@@ -462,7 +471,16 @@ function buildTailCallJumpOptions(tailCall: Ir.Block.TailCall): {
462471
},
463472
};
464473

465-
return { debug: { context: combined as Format.Program.Context } };
474+
// Route through the shared helper so all transform emission
475+
// (fold/tailcall/coalesce/...) composes consistently: the
476+
// `transform` marker becomes a flat sibling key appended to
477+
// any existing transform array.
478+
return {
479+
debug: IrUtils.addTransform(
480+
{ context: combined as Format.Program.Context },
481+
"tailcall",
482+
),
483+
};
466484
}
467485

468486
/** PUSH an integer as the smallest PUSHn. */

0 commit comments

Comments
 (0)