diff --git a/packages/compiler/src/backend/llvm/constant-callbacks.test.ts b/packages/compiler/src/backend/llvm/constant-callbacks.test.ts index 8bdab0408..a54f3294c 100644 --- a/packages/compiler/src/backend/llvm/constant-callbacks.test.ts +++ b/packages/compiler/src/backend/llvm/constant-callbacks.test.ts @@ -1,7 +1,10 @@ import { expect, test } from "vitest"; import { BOOL, + BYTES_U8, + DYN, F64, + STRING, VOID, funcOf, type IrExpr, @@ -260,6 +263,59 @@ test("receiver elision admits only synchronous receiver-free work", () => { expect(callbackIgnoresReceiver(fn)).toBe(false); }); +test("receiver elision admits library work that runs no user code", () => { + const str: IrExpr = { kind: "strLit", value: "a", type: STRING, loc }; + const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); + const dyn = ref("d", DYN); + const ignores = (expr: IrExpr): boolean => { + const fn = fixture().functions[0]!; + fn.body.unshift({ kind: "exprStmt", expr, loc }); + return callbackIgnoresReceiver(fn); + }; + const lib = (fn: string, args: IrExpr[]): IrExpr => + ({ kind: "libCall", fn, args, type: F64, loc }) as IrExpr; + const intrinsic = (name: string, args: IrExpr[]): IrExpr => + ({ kind: "intrinsic", name, args, type: VOID, loc }) as IrExpr; + // String and number members over primitives; a callback operand may run. + expect(ignores(lib("string.lastIndexOfFrom", [str, str, num(0)]))).toBe(true); + expect(ignores(lib("num.sameValue", [num(0), num(1)]))).toBe(true); + expect(ignores(lib("string.lastIndexOf", [str, closure("predicate")]))).toBe(false); + expect(ignores(lib("perf.now", []))).toBe(true); + // `new Error(message, options)` with a primitive message and no options. + const message: IrExpr = { kind: "dynFrom", value: str, type: DYN, loc }; + expect(ignores(lib("error.newOptions", [message, message]))).toBe(true); + expect(ignores(lib("error.newOptions", [message, dyn]))).toBe(false); + // The module binding check only throws; console output may inspect. + const initialized: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc }; + expect(ignores(intrinsic("module.tdzCheck", [initialized, str]))).toBe(true); + expect(ignores(intrinsic("console.log", [dyn]))).toBe(false); + // A typed array built from nothing or a typed source; a dynamic source + // may be an iterable. + const bytes = (source: IrExpr | null): IrExpr => ({ + kind: "bytesNew", + source, + type: BYTES_U8, + loc, + }); + expect(ignores(bytes(null))).toBe(true); + expect(ignores(bytes(dyn))).toBe(false); +}); + +test("receiver elision admits array for-of loops", () => { + const fn = fixture().functions[0]!; + const numbers = { kind: "array", elem: F64 } as const; + const one: IrExpr = { kind: "numLit", value: 1, type: F64, loc }; + fn.locals.push({ id: "x", name: "x", type: F64, mutable: false }); + fn.body.unshift({ + kind: "forOf", + localId: "x", + iterable: { kind: "arrayLit", elems: [one], type: numbers, loc }, + body: [], + loc, + }); + expect(callbackIgnoresReceiver(fn)).toBe(true); +}); + function callsOf(name: string, callee: string, extra: IrStmt[] = []): IrFunction { return { name, diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index 9ad8e5f05..e62fae52a 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -1003,6 +1003,16 @@ export function emitCallExpr( } else if (needsReceiver) B.line("call void @scr_dyn_this_pop()"); return result; }; + // The direct entry of a known target brackets the call when the target + // may observe the receiver. A signature adapter that only forwards the + // receiver to its original carries the original's RECEIVER_FREE bit + // (set where the adapter closure is built), so its entry tests the bit, + // like the entry through the closure's own pointer. + const invokeKnown = (target: string): string => { + const readers = host.receiverReaders; + if (readers.ignores(known!)) return invoke(target, false); + return invoke(target, true, args, readers.forwardsOnly(known!)); + }; const lending = lent && !dynamic ? known!.captures !== undefined @@ -1025,7 +1035,7 @@ export function emitCallExpr( (e.callee.kind === "closure" || (e.callee.kind === "varRef" && host.knownCallbacks.has(e.callee.localId))) ) { - t = invoke(lending ?? direct, !host.receiverReaders.ignores(known!)); + t = invokeKnown(lending ?? direct); if (quiet) pending = false; } else { const fnp = B.tmp(), @@ -1072,7 +1082,7 @@ export function emitCallExpr( B.line(`${matches} = icmp eq ptr ${fn}, ${direct}`); B.condBr(matches, fast, fallback); B.startBlock(fast); - const specialized = invoke(lending ?? direct, !host.receiverReaders.ignores(known!)); + const specialized = invokeKnown(lending ?? direct); // `fast` may have split for exception checks; join from the end. const fastEnd = B.currentLabel(); B.br(done); diff --git a/packages/compiler/src/backend/llvm/receiver-brackets.test.ts b/packages/compiler/src/backend/llvm/receiver-brackets.test.ts new file mode 100644 index 000000000..65ceb8f85 --- /dev/null +++ b/packages/compiler/src/backend/llvm/receiver-brackets.test.ts @@ -0,0 +1,68 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../../index.js"; +import { emitLlvmModule } from "./emitter.js"; + +async function llvmFor(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-receiver-brackets-")); + try { + const entry = join(dir, "main.ts"), + output = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n")); + return emitLlvmModule(deserializeModule(await readFile(output, "utf8"))); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function body(ir: string, name: string): string { + const match = new RegExp(`^define [^\\n]*@${name}\\(.*?^}`, "ms").exec(ir); + if (!match) throw new Error(`no function ${name}`); + return match[0]; +} + +test("a known signature adapter's direct entry brackets the receiver only when its original reads it", async () => { + const ir = await llvmFor(` +class Node { + kids: Node[] = []; + constructor(readonly kind: number) {} +} +function walk(root: Node, cb: (node: Node, depth: number) => void | "skip" | "stop"): void { + const stack: Node[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + const verdict = cb(node, stack.length); + if (verdict === "stop") return; + if (verdict === "skip") continue; + for (const kid of node.kids) stack.push(kid); + } +} +class Counter { + count = 0; + run(root: Node): number { + walk(root, (node) => { + if (node.kind === 3) this.count++; + }); + return this.count; + } +} +const root = new Node(0); +root.kids.push(new Node(3)); +console.log(new Counter().run(root)); +`); + const walk = body(ir, "sc_bf_walk"); + const blocks = walk.split(/^(?=[\w.]+:$)/m); + const direct = blocks.find((block) => block.startsWith("callback.direct")); + // The adapter forwards the receiver to the arrow function, which never + // reads it: the direct entry tests the closure's RECEIVER_FREE bit, and + // every receiver push is behind such a test. + expect(direct).toMatch(/ = and i32 %\S+, 16\n/); + const pushes = blocks.filter((block) => block.includes("@scr_dyn_this_push_dyn")); + expect(pushes.length).toBeGreaterThan(0); + for (const block of pushes) expect(block).toMatch(/^receiver\.push\d+:/); +}); diff --git a/packages/compiler/src/ir/receiver-readers.ts b/packages/compiler/src/ir/receiver-readers.ts index 067c474ca..84e2a49d1 100644 --- a/packages/compiler/src/ir/receiver-readers.ts +++ b/packages/compiler/src/ir/receiver-readers.ts @@ -27,6 +27,8 @@ const RECEIVER_FREE_EXPRS = new Set([ "strCmp", "nullish", "orDefault", + "optChain", + "chainRecv", "unionWrap", "unionRetype", "unionNarrow", @@ -35,9 +37,12 @@ const RECEIVER_FREE_EXPRS = new Set([ "unionDisc", "upcast", "downcast", + "dynFrom", "instanceOf", "fieldGet", + "fieldAbsent", "recordGet", + "recordHas", "recordLit", "recordClone", "arrayGet", @@ -58,6 +63,9 @@ const RECEIVER_FREE_EXPRS = new Set([ "setIntrinsic", "mapNew", "setNew", + "bytesIntrinsic", + "bytesNew", + "intrinsic", ]); const RECEIVER_FREE_STMTS = new Set([ @@ -69,6 +77,7 @@ const RECEIVER_FREE_STMTS = new Set([ "while", "doWhile", "for", + "forOf", "switch", "block", "break", @@ -78,6 +87,7 @@ const RECEIVER_FREE_STMTS = new Set([ "arraySet", "arraySetUndefined", "arraySetLength", + "bytesSet", "throw", "rethrow", "tryCatch", @@ -101,7 +111,10 @@ const TRIVIALLY_RECEIVER_FREE = new Set([ kind !== "arrIntrinsic" && kind !== "strIntrinsic" && kind !== "mapIntrinsic" && - kind !== "setIntrinsic", + kind !== "setIntrinsic" && + kind !== "bytesIntrinsic" && + kind !== "bytesNew" && + kind !== "intrinsic", ), ]); @@ -114,8 +127,44 @@ const RECEIVER_FREE_LIB_FNS = new Set([ "math.minArr", "error.new", "error.nodeThrow", + "perf.now", + "atomics.op", ]); +/** Intrinsics that only check a flag or switch the current lifetime + * region: the module binding check (which throws a fixed ReferenceError) + * and the LifetimeRegion operations. */ +const RECEIVER_FREE_INTRINSICS = new Set([ + "module.tdzCheck", + "region.create", + "region.enter", + "region.leave", + "region.release", +]); + +function primitiveOperand(e: IrExpr): boolean { + const kind = e.type.kind; + return kind === "string" || kind === "f64" || kind === "bool"; +} + +/** A dynamic value boxed from a primitive, which converts to a string + * without user code. */ +function primitiveDynOperand(e: IrExpr): boolean { + if (e.kind !== "dynFrom") return false; + const kind = e.value.type.kind; + return kind === "string" || kind === "f64" || kind === "bool" || kind === "undefinedT"; +} + +/** Whether a library call runs no user code: scalar Math, the members + * above, `new Error(message, options)` over a primitive message without an + * options object, and string and number members over primitive operands. */ +function receiverFreeLibCall(e: Extract): boolean { + if (isBorrowSafeMath(e.fn) || RECEIVER_FREE_LIB_FNS.has(e.fn)) return true; + if (e.fn === "error.newOptions") return e.args.every(primitiveDynOperand); + if (e.fn.startsWith("string.") || e.fn.startsWith("num.")) return e.args.every(primitiveOperand); + return false; +} + /** Typed container and string operations. Their runtime paths compare and * copy values but never call back into compiled code, as long as no * operand is a function or a checked value. */ @@ -128,7 +177,12 @@ function dataIntrinsicOperands(e: IrExpr): IrExpr[] | null { case "strIntrinsic": case "mapIntrinsic": case "setIntrinsic": + case "bytesIntrinsic": return [e.receiver, ...e.args]; + // A typed source is copied element by element; a dynamic one may be an + // iterable whose iterator is user code. + case "bytesNew": + return e.source === null ? [] : [e.source]; case "mapNew": case "setNew": return []; @@ -210,7 +264,9 @@ export class AmbientReceiverReaders { return targets.length > 0 && targets.every(edge); } case "libCall": - return isBorrowSafeMath(expr.fn) || RECEIVER_FREE_LIB_FNS.has(expr.fn); + return receiverFreeLibCall(expr); + case "intrinsic": + return RECEIVER_FREE_INTRINSICS.has(expr.name); case "toString": return !opaqueOperand(expr.operand); default: { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-free-adapter.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-free-adapter.ts.json new file mode 100644 index 000000000..79b9bc7f0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/receiver-free-adapter.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/receiver-free-adapter.ts" + ], + "diags": [] +} diff --git a/tests/corpus/receiver-free-adapter.ts b/tests/corpus/receiver-free-adapter.ts new file mode 100644 index 000000000..92582bd49 --- /dev/null +++ b/tests/corpus/receiver-free-adapter.ts @@ -0,0 +1,112 @@ +// Callbacks that reach a walker through a signature adapter (the walker's +// callback takes more parameters and returns a verdict), some reading +// `this` and some not. A plain call leaves `this` undefined; an arrow keeps +// its enclosing `this`. +class Tree { + readonly kind: number; + readonly kids: Tree[] = []; + constructor(kind: number) { + this.kind = kind; + } +} + +function walk(root: Tree, visit: (node: Tree, depth: number) => void | "skip" | "stop"): number { + const stack: [Tree, number][] = [[root, 0]]; + let visited = 0; + while (stack.length > 0) { + const [node, depth] = stack.pop()!; + visited++; + const verdict = visit(node, depth); + if (verdict === "stop") break; + if (verdict === "skip") continue; + for (let i = node.kids.length - 1; i >= 0; i--) stack.push([node.kids[i]!, depth + 1]); + } + return visited; +} + +const root = new Tree(0); +for (let i = 1; i <= 4; i++) { + const kid = new Tree(i); + for (let j = 0; j < 3; j++) kid.kids.push(new Tree(i * 10 + j)); + root.kids.push(kid); +} + +const out: string[] = []; + +// An arrow that ignores the receiver and one that captures its class's. +class Counter { + count = 0; + label = "counter"; + run(): string { + walk(root, (node) => { + if (node.kind % 2 === 0) this.count++; + }); + const labels: string[] = []; + walk(root, (node) => { + if (node.kind === 2) return "skip"; + labels.push(`${this.label}:${node.kind}`); + return undefined; + }); + return `${this.count} ${labels.length}`; + } +} +out.push(new Counter().run()); + +// Function expressions that read `this`: a plain call binds undefined. +const receivers: string[] = []; +walk(root, function (this: unknown, node: Tree) { + if (node.kind < 2) receivers.push(this === undefined ? "undefined" : typeof this); +}); +out.push(receivers.join(",")); + +// An unbound method: its `this` is undefined when the walker calls it. +const holder = { + name: "holder", + visit(this: { name: string } | undefined, node: Tree): void { + if (node.kind === 1) out.push(this === undefined ? "unbound" : this.name); + }, +}; +walk(root, holder.visit); +// A bound method keeps its receiver. +walk(root, holder.visit.bind(holder)); + +// Receiver-free bodies with optional chains, record presence and array loops. +type Info = { name?: string; tags: string[] }; +const infos = new Map([ + [1, { name: "one", tags: ["a", "b"] }], + [3, { tags: [] }], +]); +const described: string[] = []; +walk(root, (node) => { + const info = infos.get(node.kind); + let tags = 0; + for (const tag of info?.tags ?? []) tags += tag.length; + if (info !== undefined) described.push(`${info.name?.toUpperCase() ?? "anonymous"}/${"name" in info}/${tags}`); + return node.kind === 3 ? "stop" : undefined; +}); +out.push(described.join(" ")); + +// Walkers with one callback each, which their calls enter directly: one +// reads `this`, the other ignores it. +function eachKid(node: Tree, visit: (kid: Tree, index: number) => boolean | void): number { + let calls = 0; + for (let i = 0; i < node.kids.length; i++) { + calls++; + if (visit(node.kids[i]!, i) === false) break; + } + return calls; +} +const kidReceivers: string[] = []; +eachKid(root, function (this: unknown, kid: Tree) { + kidReceivers.push(`${kid.kind}:${this === undefined ? "undefined" : typeof this}`); +}); +out.push(kidReceivers.join(",")); +function sumKinds(node: Tree, add: (kind: number, depth: number) => number | void): number { + let total = 0; + for (const kid of node.kids) total += add(kid.kind, 1) ?? 0; + return total; +} +let added = 0; +out.push(String(sumKinds(root.kids[2]!, (kind) => (added += kind))), String(added)); + +console.log(out.join("\n")); diff --git a/tests/harness/corpus-costs/receiver-free-adapter.ts.json b/tests/harness/corpus-costs/receiver-free-adapter.ts.json new file mode 100644 index 000000000..74247d8d2 --- /dev/null +++ b/tests/harness/corpus-costs/receiver-free-adapter.ts.json @@ -0,0 +1 @@ +{ "plain": 4000, "san": 63000 }