Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/compiler/src/backend/c/c-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import type {
} from "../../ir/ir.js";
import { ffiCallbackType, funcOf, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isRefCounted, isUnitType, mapOf, moduleEmbedsCompressedNpm, moduleUsesChildProcess, moduleUsesDgram, moduleUsesDynInvoke, moduleEmbedsBuiltin, moduleUsesFetch, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesNet, moduleUsesNodeTest, moduleUsesProcessEvents, moduleUsesStream, moduleUsesTls, moduleUsesTlsCa, POINTER_KINDS, type PointerKind, RUNTIME_EMITTER_CLASS, STRING, VOID } from "../../ir/ir.js";
import { undefinedArmTag } from "../../ir/analysis.js";
import { scalarizeNumericRecords } from "../../ir/scalar-records.js";
import { allocateFfiCallbackAdapters, hasForeignFfiCallback, hasRetainedFfiCallback, type FfiCallbackAdapter } from "../ffi-callbacks.js";
import {
mangleAsyncSpawn,
Expand Down Expand Up @@ -77,7 +78,7 @@ export function emitCModule(
sourceText?: string,
options: CEmitOptions = {},
): string {
return new CEmitter(mod, sourceText, options).emit();
return new CEmitter(scalarizeNumericRecords(mod), sourceText, options).emit();
}

// Box construction moved onto CEmitter (boxNewC method): obj-kind boxes now
Expand Down
3 changes: 2 additions & 1 deletion packages/compiler/src/backend/llvm/emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import type {
} from "../../ir/ir.js";
import { CAUGHT, ffiCallbackType, isDynTypedRefType, isFfiContextParam, isRefCounted, isUnitType, moduleEmbedsBuiltin, moduleEmbedsCompressedNpm, moduleUsesChildProcess, moduleUsesDynInvoke, moduleUsesFetch, moduleUsesFsWatch, moduleUsesHttpServer, moduleUsesNet, moduleUsesNodeTest, moduleUsesProcessEvents, moduleUsesStream, moduleUsesTls, moduleUsesTlsCa, NPM_COMPRESS_MIN, POINTER_KINDS, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, typeKey, VOID } from "../../ir/ir.js";
import { matchIntegerBytesForLoop } from "../../ir/integer-loops.js";
import { scalarizeNumericRecords } from "../../ir/scalar-records.js";
import { allocateFfiCallbackAdapters, hasForeignFfiCallback, hasRetainedFfiCallback, type FfiCallbackAdapter } from "../ffi-callbacks.js";
import { RUNTIME_ABI_MARKER } from "../runtime-abi.js";
import { computeMayThrow } from "../c/may-throw.js";
Expand Down Expand Up @@ -180,7 +181,7 @@ export interface LlvmTargetOptions {
}

export function emitLlvmModule(mod: IrModule, options: LlvmTargetOptions = {}): string {
return new LlEmitter(mod, options).emit();
return new LlEmitter(scalarizeNumericRecords(mod), options).emit();
}

/** LLVM c"..." payload for a UTF-8 literal, NUL-terminated like the C
Expand Down
144 changes: 144 additions & 0 deletions packages/compiler/src/ir/scalar-records.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { expect, test } from "vitest";
import { BOOL, F64, STRING, VOID, type IrExpr, type IrFunction, type IrModule, type IrStmt, type IrType } from "./ir.js";
import { scalarizeNumericRecords } from "./scalar-records.js";
import { validateModule } from "./validate.js";

const loc = { file: "scalar-records.ts", start: 0, end: 0 };
const record: IrType = { kind: "record", shapeId: "r0" };
const ref = (localId: string, type: IrType = F64): IrExpr => ({ kind: "varRef", localId, type, loc });
const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc });
const call = (callee: string, args: IrExpr[] = [], type: IrType = F64): IrExpr => ({ kind: "call", callee, args, type, loc });
const read = (localId: string, field = "a"): IrExpr => ({ kind: "recordGet", obj: ref(localId, record), shapeId: "r0", field, type: F64, loc });

function fixture(): IrModule {
return {
irVersion: 11, sourceFile: loc.file, entry: "main",
records: [{ id: "r0", fields: [{ name: "a", type: F64 }, { name: "b", type: F64 }] }],
functions: [
{
name: "pair", params: [{ localId: "x", name: "x", type: F64 }], returnType: record,
locals: [{ id: "x", name: "x", type: F64, mutable: true }],
body: [{ kind: "return", value: { kind: "recordLit", type: record, fields: [
{ name: "b", value: call("effect", [num(2)]) }, { name: "a", value: ref("x") },
], loc }, loc }], loc,
},
{ name: "effect", params: [{ localId: "x", name: "x", type: F64 }], returnType: F64,
locals: [{ id: "x", name: "x", type: F64, mutable: true }], body: [{ kind: "return", value: ref("x"), loc }], loc },
{
name: "main", params: [], returnType: VOID,
locals: [{ id: "result", name: "result", type: record, mutable: false }],
body: [{ kind: "varDecl", localId: "result", init: call("pair", [call("effect", [num(1)])], record), loc },
{ kind: "exprStmt", expr: read("result"), loc }], loc,
},
],
};
}

function nodes(value: unknown, kind: string): Record<string, unknown>[] {
if (Array.isArray(value)) return value.flatMap((v) => nodes(v, kind));
if (value === null || typeof value !== "object") return [];
const node = value as Record<string, unknown>;
return [...(node["kind"] === kind ? [node] : []), ...Object.entries(node)
.flatMap(([key, child]) => key === "loc" || key === "type" ? [] : nodes(child, kind))];
}

function main(mod: IrModule): IrFunction { return mod.functions.find((fn) => fn.name === "main")!; }
function transformed(mod: IrModule): IrModule {
expect(validateModule(mod)).toEqual([]);
const out = scalarizeNumericRecords(mod);
expect(validateModule(out)).toEqual([]);
return out;
}

test("eliminates fresh field-only records without mutating input or dropping unused field effects", () => {
const mod = fixture();
const snapshot = structuredClone(mod);
const out = transformed(mod);
expect(mod).toEqual(snapshot);
expect(out.functions[0]).toBe(mod.functions[0]);
const caller = main(out);
expect(nodes(caller.body, "recordGet")).toEqual([]);
expect(caller.locals.every((local) => local.type.kind === "f64")).toBe(true);
const calls = nodes(caller.body, "call");
expect(calls.map((c) => c["callee"])).toEqual(["effect", "effect"]);
expect(calls.map((c) => (c["args"] as IrExpr[])[0])).toEqual([num(1), num(2)]);
const writes = nodes(caller.body, "assign");
expect(writes.map((w) => caller.locals.find((l) => l.id === w["localId"])?.name)).toEqual(["result.b", "result.a"]);
});

test("renames producer locals and labels at repeated calls inside caller control flow", () => {
const mod = fixture();
const producer = mod.functions[0]!;
producer.locals.push({ id: "%scalar.0", name: "collision", type: F64, mutable: false });
producer.body = [{ kind: "block", labels: ["same", "%scalar.1"], body: [
{ kind: "varDecl", localId: "%scalar.0", init: num(9), loc },
{ kind: "if", cond: { kind: "boolLit", value: false, type: BOOL, loc }, then: [{ kind: "break", label: "same", loc }], else_: null, loc },
...producer.body,
], loc }, ...producer.body];
const caller = main(mod);
caller.locals.push({ id: "again", name: "again", type: record, mutable: false });
caller.body.push({ kind: "varDecl", localId: "again", init: call("pair", [num(4)], record), loc },
{ kind: "exprStmt", expr: read("again"), loc });
caller.body = [{ kind: "block", labels: ["same"], body: caller.body, loc }];
const out = main(transformed(mod));
expect(nodes(out.body, "call").every((n) => n["callee"] !== "pair")).toBe(true);
expect(new Set(out.locals.map((l) => l.id)).size).toBe(out.locals.length);
expect(nodes(out.body, "recordGet")).toEqual([]);
});

test.each(["alias", "mutation", "identity", "return", "capture", "reassignment"])("keeps objects observed through %s", (use) => {
const mod = fixture();
const caller = main(mod);
if (use === "alias") {
caller.locals.push({ id: "alias", name: "alias", type: record, mutable: false });
caller.body.push({ kind: "varDecl", localId: "alias", init: ref("result", record), loc });
} else if (use === "mutation") {
caller.body.push({ kind: "recordSet", obj: ref("result", record), shapeId: "r0", field: "a", value: num(7), loc });
} else if (use === "identity") {
caller.body.push({ kind: "exprStmt", expr: { kind: "bin", op: "===", left: ref("result", record), right: ref("result", record), type: BOOL, loc }, loc });
} else if (use === "return") {
caller.returnType = record;
caller.body.push({ kind: "return", value: ref("result", record), loc });
} else if (use === "capture") {
caller.locals[0]!.boxed = true;
} else {
caller.locals[0]!.mutable = true;
caller.body.push({ kind: "assign", localId: "result", value: call("pair", [num(7)], record), loc });
}
expect(transformed(mod)).toBe(mod);
});

test("keeps for-header declarations in their original IR form", () => {
const mod = fixture();
const caller = main(mod);
caller.body = [{ kind: "for", init: caller.body[0]!, cond: { kind: "boolLit", value: false, type: BOOL, loc }, update: null, body: caller.body.slice(1), loc }];
expect(transformed(mod)).toBe(mod);
});

test.each(["argument", "field"])("retains reference temporaries in the original %s evaluation frame", (position) => {
const mod = fixture();
const length: IrExpr = { kind: "strIntrinsic", method: "length", receiver: { kind: "strLit", value: "abc", type: STRING, loc }, args: [], type: F64, loc };
if (position === "argument") {
(main(mod).body[0] as IrStmt & { kind: "varDecl" }).init = call("pair", [length], record);
} else {
const ret = mod.functions[0]!.body[0] as IrStmt & { kind: "return" };
(ret.value as IrExpr & { kind: "recordLit" }).fields[0]!.value = length;
}
expect(transformed(mod)).toBe(mod);
});

test("bounds expansion in callers with many eligible calls", () => {
const mod = fixture();
const caller = main(mod);
caller.locals = [];
caller.body = [];
for (let i = 0; i < 300; i++) {
const id = `result${i}`;
caller.locals.push({ id, name: id, type: record, mutable: false });
caller.body.push({ kind: "varDecl", localId: id, init: call("pair", [num(i)], record), loc }, { kind: "exprStmt", expr: read(id), loc });
}
const out = main(transformed(mod));
const remaining = nodes(out.body, "call").filter((n) => n["callee"] === "pair").length;
expect(remaining).toBeGreaterThan(0);
expect(remaining).toBeLessThan(300);
});
192 changes: 192 additions & 0 deletions packages/compiler/src/ir/scalar-records.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { F64, isRefCounted, type IrExpr, type IrFunction, type IrLocal, type IrModule, type IrRecordShape, type IrStmt, type IrType } from "./ir.js";

type Node = Record<string, unknown>;
const MAX_FIELDS = 4;
const MAX_CALLEE_NODES = 256;
const MAX_INLINE_NODES = 1024;

/** IR is a plain tree. Types and source locations are metadata, not uses. */
function everyNode(value: unknown, visit: (node: Node) => boolean): boolean {
if (Array.isArray(value)) return value.every((v) => everyNode(v, visit));
if (value === null || typeof value !== "object") return true;
const node = value as Node;
return visit(node) && Object.entries(node).every(([key, child]) =>
key === "type" || key === "loc" || everyNode(child, visit));
}

function mapTree<T>(value: T, visit: (node: Node) => Node): T {
if (Array.isArray(value)) return value.map((v) => mapTree(v, visit)) as T;
if (value === null || typeof value !== "object") return value;
const node = visit(value as Node);
return Object.fromEntries(Object.entries(node).map(([key, child]) =>
[key, key === "type" || key === "loc" ? child : mapTree(child, visit)])) as T;
}

interface Producer { fn: IrFunction; shape: IrRecordShape; size: number }

/** Splitting an expression frame must not shorten a reference temporary's
* lifetime across later arguments, fields, or the original call itself. */
function scalarTemporaries(value: unknown): boolean {
return everyNode(value, (node) => !node["type"] || !isRefCounted(node["type"] as IrType));
}

function producer(fn: IrFunction, shapes: ReadonlyMap<string, IrRecordShape>): Producer | null {
if (fn.async || fn.generator || fn.captures?.length || fn.returnType.kind !== "record") return null;
const shape = shapes.get(fn.returnType.shapeId);
if (!shape || shape.tuple || shape.indexValue || shape.fields.length === 0 || shape.fields.length > MAX_FIELDS ||
shape.fields.some((f) => f.type.kind !== "f64")) return null;
// Scalar parameters/locals need no ownership cleanup across the inlined
// return. Closures, suspension and finally completions remain out of scope.
if (fn.locals.some((l) => l.boxed || l.tdz || (l.type.kind !== "f64" && l.type.kind !== "bool")) ||
fn.params.some((p) => p.type.kind !== "f64" && p.type.kind !== "bool")) return null;
let size = 0;
let returns = 0;
const eligible = everyNode(fn.body, (node) => {
if (typeof node["kind"] !== "string") return true;
if (++size > MAX_CALLEE_NODES) return false;
switch (node["kind"]) {
case "closure": case "selfRef": case "tryCatch":
case "awaitExpr": case "awaitUnionExpr": case "yieldExpr":
return false;
case "return": {
returns++;
const value = (node as Extract<IrStmt, { kind: "return" }>).value;
return value?.kind === "recordLit" && value.type.kind === "record" && value.type.shapeId === shape.id &&
value.fields.length === shape.fields.length && value.fields.every((f) =>
!f.drop && !f.overflow && f.value.type.kind === "f64" && scalarTemporaries(f.value) && shape.fields.some((sf) => sf.name === f.name));
}
default: return true;
}
});
return eligible && returns > 0 ? { fn, shape, size } : null;
}

/** A result may only be read through its declared scalar fields. Even an
* apparently harmless alias, identity test, mutation or closure capture
* keeps the original object. Count declarations too: no reinitialization. */
function fieldOnlyUses(fn: IrFunction, localId: string, shape: IrRecordShape): boolean {
let declarations = 0;
function visit(value: unknown): boolean {
if (Array.isArray(value)) return value.every(visit);
if (value === null || typeof value !== "object") return true;
const node = value as Node;
if (node["kind"] === "recordGet") {
const read = node as Extract<IrExpr, { kind: "recordGet" }>;
if (read.obj.kind === "varRef" && read.obj.localId === localId) {
return read.shapeId === shape.id && read.type.kind === "f64" && shape.fields.some((f) => f.name === read.field);
}
}
if (node["localId"] === localId) {
if (node["kind"] !== "varDecl" || ++declarations !== 1) return false;
}
return Object.entries(node).every(([key, child]) => key === "type" || key === "loc" || visit(child));
}
return visit(fn.body) && declarations === 1;
}

interface Replacement {
fields: Map<string, string>;
body: IrStmt[];
}

/** Eliminate small fresh numeric result records at direct, field-only call
* sites. This is a bounded shared backend pass, not a change to record ABI:
* the original producer remains available to all other callers. Arguments
* and literal fields retain source evaluation order. A labeled block models
* return, including returns inside loops, without changing caller control
* flow. Unknown uses and non-scalar producer locals keep the heap path. */
export function scalarizeNumericRecords(mod: IrModule): IrModule {
const shapes = new Map((mod.records ?? []).map((s) => [s.id, s]));
const producers = new Map<string, Producer>();
for (const fn of mod.functions) {
const p = producer(fn, shapes);
if (p) producers.set(fn.name, p);
}
if (producers.size === 0) return mod;
let changed = false;
const functions = mod.functions.map((fn): IrFunction => {
if (fn.async || fn.generator) return fn;
const locals = new Map(fn.locals.map((l) => [l.id, l]));
const used = new Set([...locals.keys(), ...(mod.globals ?? []).map((g) => g.id)]);
const loopHeaders = new Set<unknown>();
everyNode(fn.body, (node) => {
if (Array.isArray(node["labels"])) for (const label of node["labels"]) used.add(String(label));
// A for header accepts one declaration/assignment, not the statement
// block this pass produces. Leave those declarations on the heap path.
if (node["kind"] === "for") { loopHeaders.add(node["init"]); loopHeaders.add(node["update"]); }
return true;
});
let next = 0;
const fresh = (): string => {
let id: string;
do { id = `%scalar.${next++}`; } while (used.has(id));
used.add(id);
return id;
};
const added: IrLocal[] = [];
const replacements = new Map<string, Replacement>();
let budget = MAX_INLINE_NODES;
everyNode(fn.body, (node) => {
if (node["kind"] !== "varDecl" || loopHeaders.has(node)) return true;
const decl = node as Extract<IrStmt, { kind: "varDecl" }>;
const local = locals.get(decl.localId);
const call = decl.init;
if (!local || local.mutable || local.boxed || local.tdz || local.type.kind !== "record" || call?.kind !== "call") return true;
const p = producers.get(call.callee);
if (!p || p.fn.name === fn.name || p.size > budget || p.shape.id !== local.type.shapeId ||
call.args.length !== p.fn.params.length || !scalarTemporaries(call.args) || !fieldOnlyUses(fn, local.id, p.shape)) return true;
budget -= p.size;
for (const l of p.fn.locals) used.add(l.id);
everyNode(p.fn.body, (n) => {
if (Array.isArray(n["labels"])) for (const l of n["labels"]) used.add(String(l));
return true;
});
const fields = new Map(p.shape.fields.map((f) => [f.name, fresh()]));
const renamed = new Map(p.fn.locals.map((l) => [l.id, fresh()]));
const labels = new Map<string, string>();
const exit = fresh();
const label = (name: string): string => {
if (!labels.has(name)) labels.set(name, fresh());
return labels.get(name)!;
};
for (const f of p.shape.fields) added.push({ id: fields.get(f.name)!, name: `${local.name}.${f.name}`, type: F64, mutable: true });
for (const l of p.fn.locals) added.push({ ...l, id: renamed.get(l.id)! });
const body = mapTree(p.fn.body, (n): Node => {
if (n["kind"] === "return") {
const ret = n as Extract<IrStmt, { kind: "return" }>;
const literal = ret.value as Extract<IrExpr, { kind: "recordLit" }>;
const assignments: IrStmt[] = literal.fields.map((f) => ({ kind: "assign", localId: fields.get(f.name)!, value: f.value, loc: ret.loc }));
return { kind: "block", body: [...assignments, { kind: "break", label: exit, loc: ret.loc }], loc: ret.loc };
}
const out = { ...n };
if (typeof out["localId"] === "string" && renamed.has(out["localId"])) out["localId"] = renamed.get(out["localId"]);
if (typeof out["label"] === "string" && out["label"] !== exit) out["label"] = label(out["label"]);
if (Array.isArray(out["labels"])) out["labels"] = out["labels"].map((l) => label(String(l)));
return out;
});
const parameters: IrStmt[] = p.fn.params.map((param, i) => ({ kind: "varDecl", localId: renamed.get(param.localId)!, init: call.args[i]!, loc: decl.loc }));
const declarations: IrStmt[] = [...fields.values()].map((id) => ({ kind: "varDecl", localId: id, init: null, loc: decl.loc }));
replacements.set(local.id, { fields, body: [...declarations, { kind: "block", labels: [exit], body: [...parameters, ...body], loc: decl.loc }] });
return true;
});
if (replacements.size === 0) return fn;
changed = true;
const body = mapTree(fn.body, (node): Node => {
if (node["kind"] === "varDecl") {
const decl = node as Extract<IrStmt, { kind: "varDecl" }>;
const replacement = replacements.get(decl.localId);
if (replacement) return { kind: "block", body: replacement.body, loc: decl.loc };
}
if (node["kind"] === "recordGet") {
const read = node as Extract<IrExpr, { kind: "recordGet" }>;
if (read.obj.kind === "varRef") {
const id = replacements.get(read.obj.localId)?.fields.get(read.field);
if (id) return { kind: "varRef", localId: id, type: F64, loc: read.loc };
}
}
return node;
});
return { ...fn, locals: [...fn.locals.filter((l) => !replacements.has(l.id)), ...added], body };
});
return changed ? { ...mod, functions } : mod;
}
Loading
Loading