Run lone array callbacks as loops with their bodies inlined (#1997)

This commit is contained in:
Chris Tate
2026-10-25 05:24:07 -05:00
committed by GitHub
18 changed files with 677 additions and 107 deletions
+1 -1
View File
@@ -245,7 +245,7 @@ $ scriptc build scale.ts
1 error.
```
A copy such as `slice()` or a spread is checked as if it were the same array as its source. The results of callbacks are not followed, so an array built by `map` or `Array.from` with a mapping function cannot be `Int32[]`; build it with a loop that stores `value | 0`. scriptc refuses an `Int32[]` slot whose array can come from code it cannot see, such as `JSON.parse`, dynamic values or the callers of a function used as a value, and an array that can reach one and escapes to such code, such as a call through a function value (SC7003).
A copy such as `slice()` or a spread is checked as if it were the same array as its source. The results of a callback are followed only when scriptc copies it into the loop that runs the array method: a callback written at a `map` or `filter` call whose captured variables are not reassigned, such as `xs.map((x) => (x * 2) | 0)`. Other callback results are not followed, so for example an array built by `Array.from` with a mapping function cannot be `Int32[]`; build it with a loop that stores `value | 0`. scriptc refuses an `Int32[]` slot whose array can come from code it cannot see, such as `JSON.parse`, dynamic values or the callers of a function used as a value, and an array that can reach one and escapes to such code, such as a call through a function value (SC7003).
## Immutable
+1 -1
View File
@@ -1,7 +1,7 @@
[Contract types](/docs/contracts) are checked only where they are written as the annotation of a parameter, variable, class instance field or function result, `Acyclic` only on classes, interfaces and object type aliases, and `ProgramLifetime` only in the `implements` clause of a class declaration; other aliases, interface members and nested type positions are reported.
In programs that use worker threads, a surrounding `try`/`catch` does not discharge `NoThrow`.
`Int32` refuses parameters of functions used as values and fields of classes whose instances are stored in `any` or `unknown` values.
`Int32[]` refuses arrays that can come from or escape to code scriptc cannot see, does not follow callback results (such as `map` results), and checks a copy made by `slice` or a spread as if it were its source array.
`Int32[]` refuses arrays that can come from or escape to code scriptc cannot see, follows callback results only for callbacks written at a `map` or `filter` call that scriptc runs inline, and checks a copy made by `slice` or a spread as if it were its source array.
Element reads that find a value are int32s, but a scalar `Int32` slot still needs `a[i] | 0`, because a read can miss and produce `undefined`.
`Immutable` refuses values stored in `any` or `unknown` or that another reference can still change.
`ProgramLifetime` applies to root classes of executables only; instances the program drops are never reclaimed unless they belong to a released [lifetime region](/docs/experimental#lifetimeregion).
@@ -9,7 +9,7 @@
* one directly or through a binding read from it. The choice never changes
* behavior. */
import type { IrExpr, IrModule } from "../../ir/ir.js";
import type { IrExpr, IrModule, IrStmt } from "../../ir/ir.js";
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
const APPENDING_METHODS: ReadonlySet<string> = new Set([
@@ -120,3 +120,36 @@ export function findAppendedArrayLiterals(
}
return result;
}
/** `const xs = []` directly followed by a capacity hint for `xs` (an
* `arraySetLength` with `reserve`) whose length reads only literals and
* other locals: the literal can be constructed with that capacity, and the
* hint then has nothing left to do. */
export function reservedArrayLength(
stmts: readonly IrStmt[],
i: number,
): { literal: IrExpr; hint: IrStmt & { kind: "arraySetLength" } } | null {
const decl = stmts[i];
const next = stmts[i + 1];
if (decl?.kind !== "varDecl" || decl.init === null || !isEmptyLiteral(decl.init)) return null;
if (next?.kind !== "arraySetLength" || next.reserve !== true) return null;
if (next.arr.kind !== "varRef" || next.arr.localId !== decl.localId) return null;
return plainLength(next.length, decl.localId) ? { literal: decl.init, hint: next } : null;
}
function plainLength(e: IrExpr, array: string): boolean {
switch (e.kind) {
case "numLit":
return true;
case "varRef":
return e.localId !== array;
case "bin":
return plainLength(e.left, array) && plainLength(e.right, array);
case "ternary":
return (
plainLength(e.cond, array) && plainLength(e.then, array) && plainLength(e.else_, array)
);
default:
return false;
}
}
@@ -478,6 +478,9 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
emitter.publishedTypes?.containerGuarded(s.arr.type) !== true
)
break;
// The literal it follows was constructed with this capacity
// (reservedArrayLength).
if (emitter.consumedReserves.delete(s)) break;
const arr = emitBorrowedInput(emitter, s.arr);
const length = emitter.emitExpr(s.length);
if (s.arr.type.kind !== "array")
@@ -202,7 +202,7 @@ import { ReferenceEffects } from "./reference-effects.js";
import { ReferenceWrites, type Writes } from "./reference-writes.js";
import { LlvmDebugInfo } from "./debug-info.js";
import { StackCallbacks } from "./stack-callbacks.js";
import { findAppendedArrayLiterals } from "./appended-array-literals.js";
import { findAppendedArrayLiterals, reservedArrayLength } from "./appended-array-literals.js";
import { AmbientReceiverReaders, findConstantCallbacks } from "./constant-callbacks.js";
import {
findLentRecordFields,
@@ -1479,6 +1479,11 @@ export class LlEmitter {
/** Locals whose array literal emitStmts left for the `await Promise.all`
* that reads them (deferredPromiseAllLocal). */
private readonly deferredPromiseAllEntries = new Map<string, ExprOf<"arrayLit">>();
/** Empty array literals that a capacity hint follows directly
* (reservedArrayLength), with that hint. Constructing the literal with
* the hinted capacity consumes the hint, which then emits nothing. */
readonly reservedArrayLengths = new Map<IrExpr, IrStmt & { kind: "arraySetLength" }>();
readonly consumedReserves = new Set<IrStmt>();
/** The promise a `return` emitted past its finally regions already
* produced for its adopting await (borrowed from the return's parked
* slot); that await takes it instead of evaluating its operand. */
@@ -6044,6 +6049,8 @@ export class LlEmitter {
if (this.B.isTerminated()) return;
if (cursor) cursor.index = i;
this.stmtCursor = cursor;
const reserve = reservedArrayLength(stmts, i);
if (reserve !== null) this.reservedArrayLengths.set(reserve.literal, reserve.hint);
const deferred = this.deferredPromiseAllLocal(stmts, i);
if (deferred !== null) {
this.deferredPromiseAllEntries.set((stmts[i] as { localId: string }).localId, deferred);
@@ -946,6 +946,34 @@ export function emitStringExpr(
}
}
/** scr_arr_reserve_len reserves dense storage only up to this many slots
* (SCR_ARR_DENSE_LIMIT, scr_array.c). */
const RESERVE_LIMIT = 2 ** 20;
/** Elements the inline slots of a small array construction
* (scr_arr_new_small, scr_arr_new_ops_small) hold at least
* (SCR_ARR_SMALL_SLOTS, scr_array.c). */
const SMALL_ARRAY_SLOTS = 3;
/** The capacity scr_arr_reserve_len would reserve for `length` on an empty
* array: the length truncated when it is at least 1 and at most
* RESERVE_LIMIT, else 0. */
function reservedCapacity(host: LlvmEmitterContext, length: IrExpr): string {
const B = host.B;
const n = host.emitExpr(length);
const atLeastOne = B.tmp(),
withinLimit = B.tmp(),
reserves = B.tmp(),
slots = B.tmp(),
capacity = B.tmp();
B.line(`${atLeastOne} = fcmp oge double ${n.name}, ${f64Lit(1)}`);
B.line(`${withinLimit} = fcmp ole double ${n.name}, ${f64Lit(RESERVE_LIMIT)}`);
B.line(`${reserves} = and i1 ${atLeastOne}, ${withinLimit}`);
B.line(`${slots} = select i1 ${reserves}, double ${n.name}, double ${f64Lit(0)}`);
B.line(`${capacity} = fptoui double ${slots} to ${host.sizeType}`);
return capacity;
}
export function emitContainerExpr(
host: LlvmEmitterContext,
e: ExprOf<
@@ -1017,7 +1045,34 @@ export function emitContainerExpr(
? `${arr} = call ptr @scr_arr_new_small(i32 ${SCR_ELEM_I32})`
: `${arr} = call ptr @scr_arr_new(i32 ${SCR_ELEM_I32}, ${host.sizeType} ${capacity})`,
);
} else B.line(`${arr} = ${arrNewCall(host.shapeHost, elem, String(capacity), small)}`);
} else {
const hint = host.reservedArrayLengths.get(e);
if (hint !== undefined) {
host.reservedArrayLengths.delete(e);
host.consumedReserves.add(hint);
const capacity = reservedCapacity(host, hint.length);
// The small construction's inline slots hold at least
// SMALL_ARRAY_SLOTS elements; larger capacities are sized.
const fits = B.tmp(),
smallArr = B.tmp(),
sizedArr = B.tmp();
const smallLabel = B.newLabel("arr.cap.small"),
sizedLabel = B.newLabel("arr.cap.sized"),
joined = B.newLabel("arr.cap.j");
B.line(`${fits} = icmp ule ${host.sizeType} ${capacity}, ${SMALL_ARRAY_SLOTS}`);
B.condBr(fits, smallLabel, sizedLabel);
B.startBlock(smallLabel);
B.line(`${smallArr} = ${arrNewCall(host.shapeHost, elem, "0", true)}`);
B.br(joined);
B.startBlock(sizedLabel);
B.line(`${sizedArr} = ${arrNewCall(host.shapeHost, elem, capacity)}`);
B.br(joined);
B.startBlock(joined);
B.line(
`${arr} = phi ptr [ ${smallArr}, %${smallLabel} ], [ ${sizedArr}, %${sizedLabel} ]`,
);
} else B.line(`${arr} = ${arrNewCall(host.shapeHost, elem, String(capacity), small)}`);
}
const out = host.own({ name: arr, type: e.type });
const acc: ArrayAccess = int32 ? "i32" : elemAccess(elem);
let pending: LlValue[] = [];
@@ -827,15 +827,16 @@ function buildArrayHofFn(
);
returnType = outT;
body = [
readLen,
{
kind: "varDecl",
localId: "out.0",
init: { kind: "arrayLit", elems: [], type: outT, loc },
loc,
},
readLen,
// map's result has the receiver's length, holes where the receiver
// has them. Storage for it is reserved first and the length is set
// has them. Storage for it is reserved first (with the result's
// construction, which directly precedes it) and the length is set
// after the loop, so a result without holes is filled by appends and
// gets no state bytes.
...(method === "map"
@@ -1,9 +1,14 @@
import * as ts from "../../ts7/adapter.js";
import { CONTRACT_TYPES_MODULE } from "../../contract-types.js";
import { keyedHelperName } from "../stable-names.js";
import { writtenLocals } from "../../../ir/callback-specialization.js";
import { AmbientReceiverReaders } from "../../../ir/receiver-readers.js";
import { boolLit, countedFor, numLit, varRef } from "../../../ir/build.js";
import {
BOOL,
F64,
VOID,
type IrBindingSource,
type IrExpr,
type IrFunction,
type IrGlobal,
@@ -16,9 +21,12 @@ import {
typeKey,
} from "../../../ir/ir.js";
import {
everyExpr,
everyExprChild,
everyStmt,
everyStmtChild,
everyStmtList,
transformExpr,
transformStmtList,
} from "../../../ir/traverse.js";
import {
@@ -537,6 +545,65 @@ function matchChain(lowerer: Lowerer, e: IrExpr): Chain | null {
};
}
/** The outermost array-HOF call of `e` on its own, over its receiver. */
function loneChain(lowerer: Lowerer, e: IrExpr): Chain | null {
const top = stageOf(lowerer, e);
if (!top) return null;
const { stage, receiver } = top;
if (receiver.type.kind !== "array") return null;
const stages: Stage[] = [];
let terminal: Terminal;
if (TERMINALS.has(stage.info.method)) {
const init =
stage.info.method === "reduce" && stage.info.hasInit && e.kind === "call" ? e.args[2]! : null;
terminal = { kind: "call", stage, init };
} else if (PRODUCERS.has(stage.info.method)) {
terminal = { kind: "collect" };
stages.push(stage);
} else return null;
return {
source: receiver,
sourceType: receiver.type,
slice: null,
stages,
terminal,
type: e.type,
loc: e.loc,
};
}
/** The callbacks of `chain` in order, or null when its stages cannot run
* in one loop: element types that do not line up, an array argument past
* the first stage, reduce without a seed, or findIndex over holes. */
function chainCallbacks(chain: Chain): Stage[] | null {
const { stages, terminal } = chain;
const terminalStage = terminal.kind === "call" ? terminal.stage : null;
const callbacks = terminalStage ? [...stages, terminalStage] : stages;
// Element types must line up stage to stage.
let elem = chain.sourceType.elem;
for (const stage of callbacks) {
if (!typeEquals(stage.info.elem, elem)) return null;
if (stage.info.method === "filter" && !typeEquals(stage.info.outElem, stage.info.elem))
return null;
if (
(stage.info.method === "map" || stage.info.method === "flatMap") &&
!typeEquals(stage.info.outElem, stage.info.fnRet)
)
return null;
elem = stage.info.outElem;
}
// Index arguments are reproduced; an array argument only names the source.
for (let k = 0; k < callbacks.length; k++) {
const stage = callbacks[k]!;
const lead = stage.info.method === "reduce" ? 1 : 0;
if (stage.info.arity > lead + 3) return null;
if (stage.info.arity === lead + 3 && (k !== 0 || chain.slice)) return null;
}
if (terminalStage?.info.method === "reduce" && !terminalStage.info.hasInit) return null;
if (terminalStage?.info.method === "findIndex" && holeCarrying(chain)) return null;
return callbacks;
}
/* ── the fusion pass ─────────────────────────────────────────────────── */
/** The values a fused loop hands from one stage to the next: a local of a
@@ -550,6 +617,10 @@ interface Plan {
writes: Set<string>;
/** Some callback may throw or loop; at most one does. */
risky: boolean;
/** How the loop inlines callbacks, when not by their effect summaries. */
inline?: InlineTarget;
/** Stack frame name of the loop: the inlined callback's, for a lone call. */
frame?: string;
}
/** Fuse chains of array-HOF helper calls into one loop.
@@ -596,12 +667,35 @@ class PipelineFusion {
readonly added: IrFunction[] = [];
private readonly effects: CallbackEffects;
private readonly helpers = new Map<string, string>();
/** Bindings each function, or a closure it creates, writes after their
* declaration. */
private readonly written: Map<string, Set<string>>;
/** classCaptured's answers by function name. */
private readonly classCaptures = new Map<string, ReadonlySet<string>>();
/** Whether a module imports or re-exports the contract types. */
private readonly contractTypes: boolean;
/** Bindings of the function being fused that inlined callbacks captured. */
private inlinedCaptures = new Set<string>();
/** The helper calls each callback with an undefined receiver; a copied
* body runs under the receiver around the array call instead. */
private readonly receivers: AmbientReceiverReaders;
constructor(
private readonly lowerer: Lowerer,
functions: readonly IrFunction[],
) {
this.effects = new CallbackEffects(functions, lowerer.globalsList);
this.written = writtenLocals({ functions });
this.receivers = new AmbientReceiverReaders(new Map(functions.map((fn) => [fn.name, fn])));
this.contractTypes = lowerer.moduleOrder.some((sf) =>
sf.statements.nodes.some(
(st) =>
(ts.isImportDeclaration(st) || ts.isExportDeclaration(st)) &&
st.moduleSpecifier !== undefined &&
ts.isStringLiteral(st.moduleSpecifier) &&
st.moduleSpecifier.text === CONTRACT_TYPES_MODULE,
),
);
}
run(fn: IrFunction): void {
@@ -612,6 +706,7 @@ class PipelineFusion {
});
if (!candidate) return;
const removed = new Set<string>();
this.inlinedCaptures = new Set();
let body = this.lists(fn.body, fn, removed);
body = transformStmtList(body, {
stmt: (s) => s,
@@ -619,6 +714,7 @@ class PipelineFusion {
});
fn.body = body;
if (removed.size > 0) fn.locals = fn.locals.filter((l) => !removed.has(l.id));
if (this.inlinedCaptures.size > 0) unboxUncaptured(fn, this.inlinedCaptures);
}
/* ── statement lists: fold single-use chain bindings ── */
@@ -733,40 +829,51 @@ class PipelineFusion {
/* ── expressions ── */
private fuse(e: IrExpr, fn: IrFunction): IrExpr | null {
const plan = this.plan(e, fn);
const plan = this.plan(e, fn) ?? this.lonePlan(e, fn);
return plan ? this.emit(plan) : null;
}
/** One array-HOF call on its own (or the last call of a chain that does
* not fuse), when every callback is a closure literal whose body can be
* copied into the loop. A single stage reorders nothing, so the
* callbacks' effects need no model; their captured bindings must keep
* one value while the loop runs (stableTarget), since the loop reads
* them once, when it starts. The loop keeps the helper's order: the
* length read once, each element read when it is visited, holes
* skipped (and kept by map). */
private lonePlan(e: IrExpr, fn: IrFunction): Plan | null {
const chain = loneChain(this.lowerer, e);
if (!chain) return null;
const callbacks = chainCallbacks(chain);
if (!callbacks) return null;
const inline = this.stableTarget(fn);
let frame: string | undefined;
for (const stage of callbacks) {
for (const arg of [stage.slow, stage.fast]) {
if (!arg) continue;
const target = inline(arg);
if (!target) return null;
frame ??= target.sourceName;
}
}
return {
chain,
callbacks,
writes: new Set(),
risky: true,
inline,
...(frame === undefined ? {} : { frame }),
};
}
/** Decide whether `e` is a fusable chain; null when it is not. */
private plan(e: IrExpr, fn: IrFunction): Plan | null {
const chain = matchChain(this.lowerer, e);
if (!chain) return null;
const callbacks = chainCallbacks(chain);
if (!callbacks) return null;
const { stages, terminal } = chain;
const terminalStage = terminal.kind === "call" ? terminal.stage : null;
const callbacks = terminalStage ? [...stages, terminalStage] : stages;
// Element types must line up stage to stage.
let elem = chain.sourceType.elem;
for (const stage of callbacks) {
if (!typeEquals(stage.info.elem, elem)) return null;
if (stage.info.method === "filter" && !typeEquals(stage.info.outElem, stage.info.elem))
return null;
if (
(stage.info.method === "map" || stage.info.method === "flatMap") &&
!typeEquals(stage.info.outElem, stage.info.fnRet)
)
return null;
elem = stage.info.outElem;
}
// Index arguments are reproduced; an array argument only names the source.
for (let k = 0; k < callbacks.length; k++) {
const stage = callbacks[k]!;
const lead = stage.info.method === "reduce" ? 1 : 0;
if (stage.info.arity > lead + 3) return null;
if (stage.info.arity === lead + 3 && (k !== 0 || chain.slice)) return null;
}
if (terminalStage?.info.method === "reduce" && !terminalStage.info.hasInit) return null;
const holes = holeCarrying(chain);
if (terminalStage?.info.method === "findIndex" && holes) return null;
// Effects of each callback, in chain order.
const scope = this.effects.scope(fn, true);
const summaries: Effects[] = [];
@@ -831,30 +938,89 @@ class PipelineFusion {
* are passed in by value, read when the fused call starts; no callback
* in the chain assigns a binding another one reads). */
private readonly inlineTarget: InlineTarget = (arg) => {
const fn = this.closureTarget(arg);
if (!fn) return null;
const captures = new Set((fn.captures ?? []).map((c) => c.localId));
const effects = this.effects.ofFunction(fn);
if (!effects || intersects(effects.writes, captures)) return null;
return inlinableBody(fn, captures) ? fn : null;
};
/** inlineTarget for a callback created in `fn` whose captured bindings
* nothing assigns after their declaration: `fn`'s own locals and
* parameters, written neither by `fn` nor by any closure that shares
* them, and not by the callback body. A binding a local class captures
* counts as written, since its methods may assign it; so does a binding
* a method reads through its class's captures. The callback must
* not observe the ambient receiver (AmbientReceiverReaders). */
private stableTarget(fn: IrFunction): InlineTarget {
const locals = new Map(fn.locals.map((l) => [l.id, l]));
const inherited = new Set(
[...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId),
);
const written = this.written.get(fn.name);
const classCaptured = this.classCaptured(fn);
return (arg) => {
const target = this.closureTarget(arg);
if (!target || !written || arg.kind !== "closure") return null;
if (!this.receivers.ignores(target)) return null;
for (const id of arg.captures) {
const local = locals.get(id);
if (!local || local.tdz || inherited.has(id) || written.has(id) || classCaptured.has(id))
return null;
}
const captures = new Set((target.captures ?? []).map((c) => c.localId));
const assigns = !everyStmtList(target.body, {
stmt: (st) => !((st.kind === "assign" || st.kind === "forOf") && captures.has(st.localId)),
expr: (ex) =>
!((ex.kind === "assignExpr" || ex.kind === "incDec") && captures.has(ex.localId)),
});
return !assigns && inlinableBody(target, captures) ? target : null;
};
}
/** The bindings of `fn` that a local class it creates captures. */
private classCaptured(fn: IrFunction): ReadonlySet<string> {
const known = this.classCaptures.get(fn.name);
if (known) return known;
const ids = new Set<string>();
everyStmtList(fn.body, {
expr: (e) => {
if (e.kind === "classRef") for (const id of e.captures ?? []) ids.add(id);
return true;
},
stmt: () => true,
});
this.classCaptures.set(fn.name, ids);
return ids;
}
/** The function behind a closure-literal callback the loop can inline. */
private closureTarget(arg: IrExpr): IrFunction | null {
if (arg.kind !== "closure" || arg.adapts) return null;
const fn = this.effects.fn(arg.fnName);
if (!fn || fn.async || fn.generator || fn.classCaptures?.length) return null;
const captures = new Set((fn.captures ?? []).map((c) => c.localId));
if (captures.size !== arg.captures.length) return null;
const effects = this.effects.ofFunction(fn);
if (!effects || intersects(effects.writes, captures)) return null;
if (fn.locals.some((l) => l.tdz || (l.boxed && !captures.has(l.id)))) return null;
let size = 0;
const small = everyStmtList(fn.body, {
expr: (e) => ++size <= MAX_INLINE_NODES && e.kind !== "closure" && e.kind !== "selfRef",
stmt: (s) => ++size <= MAX_INLINE_NODES && s.kind !== "tryCatch",
});
return small ? fn : null;
};
if ((fn.captures ?? []).length !== arg.captures.length) return null;
return fn;
}
private emit(plan: Plan): IrExpr | null {
const built = new FusedLoop(this.lowerer, plan.chain, this.inlineTarget).build();
const built = new FusedLoop(
this.lowerer,
plan.chain,
plan.inline ?? this.inlineTarget,
this.contractTypes,
).build();
if (!built) return null;
for (const id of built.inlinedCaptures) this.inlinedCaptures.add(id);
let name = this.helpers.get(built.key);
if (!name) {
name = keyedHelperName(this.lowerer, "%arr.fused", built.key);
name = keyedHelperName(this.lowerer, plan.inline ? "%arr.loop" : "%arr.fused", built.key);
this.helpers.set(built.key, name);
this.added.push({ ...built.fn, name });
const helper: IrFunction = { ...built.fn, name };
// A lone call's loop stands in the inlined callback's stack frame.
if (plan.frame !== undefined) helper.sourceName = plan.frame;
this.added.push(helper);
}
return {
kind: "call",
@@ -866,6 +1032,48 @@ class PipelineFusion {
}
}
/** Whether a statement inside `e` (a sequence expression's) returns. */
function returns(e: IrExpr): boolean {
return !everyExpr(e, { expr: () => true, stmt: (s) => s.kind !== "return" });
}
/** Drops the box of each binding in `inlined` that no closure or class
* created in `fn` captures any more: the callbacks that captured it were
* copied into loops, which receive its value. A dead-zone binding keeps the
* box that represents its uninitialized state, and bindings `fn` itself
* captures keep theirs. */
function unboxUncaptured(fn: IrFunction, inlined: ReadonlySet<string>): void {
const captured = new Set<string>();
everyStmtList(fn.body, {
expr: (e) => {
if (e.kind === "closure" || e.kind === "classRef")
for (const id of e.captures ?? []) captured.add(id);
return true;
},
stmt: () => true,
});
const inherited = new Set(
[...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId),
);
fn.locals = fn.locals.map((local): IrLocal => {
if (local.boxed !== true || local.tdz === true || !inlined.has(local.id)) return local;
if (captured.has(local.id) || inherited.has(local.id)) return local;
const { boxed: _boxed, ...plain } = local;
return plain;
});
}
/** A callback body the loop can copy: small, no nested closures, recursion
* or try statements, and no boxed locals besides its captures. */
function inlinableBody(fn: IrFunction, captures: ReadonlySet<string>): boolean {
if (fn.locals.some((l) => l.tdz || (l.boxed && !captures.has(l.id)))) return false;
let size = 0;
return everyStmtList(fn.body, {
expr: (e) => ++size <= MAX_INLINE_NODES && e.kind !== "closure" && e.kind !== "selfRef",
stmt: (s) => ++size <= MAX_INLINE_NODES && s.kind !== "tryCatch",
});
}
function intersects(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
for (const id of a) if (b.has(id)) return true;
return false;
@@ -988,10 +1196,13 @@ class FusedLoop {
private next = 0;
private failed = false;
/** `contractTypes`: the program writes contract types, which the
* contract checker binds to source bindings. */
constructor(
private readonly lowerer: Lowerer,
private readonly chain: Chain,
private readonly inlineTarget: InlineTarget,
private readonly contractTypes: boolean,
) {
this.loc = chain.loc;
}
@@ -1000,9 +1211,9 @@ class FusedLoop {
return `${name}.${this.next++}`;
}
private local(name: string, type: IrType, mutable = false): string {
private local(name: string, type: IrType, mutable = false, source?: IrBindingSource): string {
const id = this.fresh(name);
this.locals.push({ id, name, type, mutable });
this.locals.push(source ? { id, name, type, mutable, source } : { id, name, type, mutable });
return id;
}
@@ -1069,21 +1280,50 @@ class FusedLoop {
return true;
},
});
// Copies keep their source bindings, so contract types written on
// them are checked against what the loop stores. In a program that
// writes contract types, a parameter with a source binding keeps its
// own local for the same reason.
const sources = new Map(fn.locals.map((l) => [l.id, l.source]));
const aliased = new Set<string>();
fn.params.forEach((p, index) => {
const arg = args[index]!;
if (arg.kind === "varRef" && !assigned.has(p.localId) && typeEquals(arg.type, p.type)) {
if (
arg.kind === "varRef" &&
!assigned.has(p.localId) &&
typeEquals(arg.type, p.type) &&
!(this.contractTypes && sources.get(p.localId) !== undefined)
) {
renamed.set(p.localId, arg.localId);
aliased.add(p.localId);
}
});
for (const l of fn.locals) {
if (renamed.has(l.id)) continue;
const id = this.local(l.name, l.type, true);
const id = this.local(l.name, l.type, true, l.source);
renamed.set(l.id, id);
}
const rename = (id: string): string => renamed.get(id) ?? id;
const renameExpr = (e: IrExpr): IrExpr => {
switch (e.kind) {
case "varRef":
case "incDec":
case "assignExpr":
return { ...e, localId: rename(e.localId) };
default:
return e;
}
};
// A bound parameter's value is an element or index with no source text
// of its own; it is located at the parameter, without a span.
const bind: IrStmt[] = fn.params.flatMap((p, index): IrStmt[] => {
if (aliased.has(p.localId)) return [];
const source = sources.get(p.localId);
const arg = args[index]!;
const init = source ? { ...arg, loc: { ...source.loc, end: source.loc.start } } : arg;
return [{ kind: "varDecl", localId: rename(p.localId), init, loc }];
});
const labels = new Map<string, string>();
const exit = this.fresh("%fused.exit");
const label = (name: string): string => {
let id = labels.get(name);
if (!id) {
@@ -1092,19 +1332,53 @@ class FusedLoop {
}
return id;
};
const result = fn.returnType.kind === "void" ? null : this.local("ret", fn.returnType, true);
const rename = (id: string): string => renamed.get(id) ?? id;
const body = transformStmtList(fn.body, {
expr: (e) => {
switch (e.kind) {
case "varRef":
case "incDec":
case "assignExpr":
return { ...e, localId: rename(e.localId) };
default:
return e;
/** Locals and labels of a copied statement; returns are the caller's. */
const renameStmt = (st: IrStmt): IrStmt => {
switch (st.kind) {
case "varDecl":
case "assign":
return { ...st, localId: rename(st.localId) };
case "break":
case "continue":
return st.label ? { ...st, label: label(st.label) } : st;
case "forOf": {
const renamedFor = { ...st, localId: rename(st.localId) };
return st.labels ? { ...renamedFor, labels: st.labels.map(label) } : renamedFor;
}
},
case "while":
case "doWhile":
case "for":
case "switch":
case "block":
return st.labels ? { ...st, labels: st.labels.map(label) } : st;
default:
return st;
}
};
// A body whose only return is its last statement (`(x) => x * k`,
// `{ const y = x * 2; return y > 4; }`) needs no exit block: its other
// statements run after the bindings, and the call's value is the
// returned expression.
const last = fn.body[fn.body.length - 1];
const prefix = fn.body.slice(0, -1);
if (
last !== undefined &&
last.kind === "return" &&
last.value !== null &&
typeEquals(last.value.type, fn.returnType) &&
!returns(last.value) &&
prefix.every((st) => everyStmt(st, { expr: () => true, stmt: (x) => x.kind !== "return" }))
) {
const copy = { expr: renameExpr, stmt: renameStmt };
return {
pre: [...bind, ...transformStmtList(prefix, copy)],
value: transformExpr(last.value, copy),
};
}
const exit = this.fresh("%fused.exit");
const result = fn.returnType.kind === "void" ? null : this.local("ret", fn.returnType, true);
const body = transformStmtList(fn.body, {
expr: renameExpr,
stmt: (s): IrStmt => {
switch (s.kind) {
case "return": {
@@ -1124,27 +1398,14 @@ class FusedLoop {
loc: s.loc,
};
}
case "varDecl":
case "assign":
return { ...s, localId: rename(s.localId) };
case "break":
case "continue":
return s.label && s.label !== exit ? { ...s, label: label(s.label) } : s;
case "while":
case "doWhile":
case "for":
case "block":
return s.labels ? { ...s, labels: s.labels.map(label) } : s;
default:
return s;
return renameStmt(s);
}
},
});
const bind: IrStmt[] = fn.params.flatMap((p, index): IrStmt[] =>
aliased.has(p.localId)
? []
: [{ kind: "varDecl", localId: rename(p.localId), init: args[index]!, loc }],
);
const pre: IrStmt[] = [];
if (result !== null) pre.push({ kind: "varDecl", localId: result, init: null, loc });
pre.push({ kind: "block", labels: [exit], body: [...bind, ...body], loc });
@@ -1157,7 +1418,13 @@ class FusedLoop {
};
}
build(): { fn: Omit<IrFunction, "name">; args: IrExpr[]; key: string } | null {
build(): {
fn: Omit<IrFunction, "name">;
args: IrExpr[];
key: string;
/** Bindings the callbacks copied into the loop captured at this site. */
inlinedCaptures: string[];
} | null {
const { chain, loc } = this;
const arrT = chain.sourceType;
const a = this.param("a", chain.source, "src");
@@ -1642,7 +1909,10 @@ class FusedLoop {
};
foldKnownUnions(fn);
scalarizeRecordTemporaries(this.lowerer, fn);
return { fn, args: this.args, key: this.keyParts.join("|") };
const inlinedCaptures = this.callees.flatMap((c) =>
c.inline && c.arg.kind === "closure" ? c.arg.captures : [],
);
return { fn, args: this.args, key: this.keyParts.join("|"), inlinedCaptures };
}
}
@@ -1741,8 +2011,11 @@ function foldKnownUnions(fn: Omit<IrFunction, "name">): void {
case "for":
case "while":
case "doWhile":
case "forOf":
case "block":
return { ...s, body: prune(s.body) };
case "switch":
return { ...s, cases: s.cases.map((c) => ({ ...c, body: prune(c.body) })) };
default:
return s;
}
@@ -1930,6 +2203,19 @@ function scalarizeRecordTemporaries(lowerer: Lowerer, fn: Omit<IrFunction, "name
body: rewriteList(s.body),
},
];
case "forOf":
return [{ ...s, iterable: rewriteExpr(s.iterable), body: rewriteList(s.body) }];
case "switch":
return [
{
...s,
disc: rewriteExpr(s.disc),
cases: s.cases.map((c) => ({
test: c.test === null ? null : rewriteExpr(c.test),
body: rewriteList(c.body),
})),
},
];
case "block":
return [{ ...s, body: rewriteList(s.body) }];
default: {
@@ -16,7 +16,10 @@ const COPIED_NODE_FRACTION = 0.25;
* closure it creates writes through its environment (and transitively the
* closures those create). A parameter or capture absent from its function's
* set holds the value it entered with for the whole call. */
export function writtenLocals(mod: IrModule, nodes?: IrNodeIndex): Map<string, Set<string>> {
export function writtenLocals(
mod: { readonly functions: readonly IrFunction[] },
nodes?: IrNodeIndex,
): Map<string, Set<string>> {
return rebindings(mod, nodes).written;
}
@@ -31,7 +34,7 @@ const REBINDING_KINDS: readonly IrNodeKind[] = [
];
function rebindings(
mod: IrModule,
mod: { readonly functions: readonly IrFunction[] },
nodes?: IrNodeIndex,
): {
written: Map<string, Set<string>>;
@@ -108,5 +108,161 @@ const kept = rows.filter((r) => r.value > 3);
console.log(kept.map((r) => r.id).length, kept.length);
${postlude}`);
expect(init(mod).locals.some((l) => l.name === "kept")).toBe(true);
expect(helperCalls(init(mod))).toEqual(["%arr.filter", "%arr.fused"]);
expect(helperCalls(init(mod))).toEqual(["%arr.fused", "%arr.loop"]);
});
test("a lone call with a stable closure-literal callback runs as a loop", async () => {
const mod = await lowerSource(`${prelude}
const scale = 3;
const scaled = rows.map((r) => r.value * scale);
let total = 0;
rows.forEach((r) => { total += r.value; });
const big = rows.filter((r) => r.value > scale);
const sum = scaled.reduce((acc, v) => acc + v, 0);
console.log(scaled.length, total, big.length, sum, rows.some((r) => r.id === scale));
${postlude}`);
// forEach assigns \`total\`, a binding of \`run\`: it keeps its helper.
expect(helperCalls(init(mod))).toEqual([
"%arr.forEach",
"%arr.loop",
"%arr.loop",
"%arr.loop",
"%arr.loop",
]);
const loops = mod.functions.filter((f) => f.name.startsWith("%arr.loop."));
expect(loops).toHaveLength(4);
// The callback bodies are copied into the loops: no closure value is
// created or called, and the captured \`scale\` is a plain parameter.
for (const fn of loops) {
expect(count(fn, "callValue")).toBe(0);
expect(count(fn, "closure")).toBe(0);
}
expect(count(init(mod), "closure")).toBe(1);
// No closure captures \`scale\` any more, so it needs no box.
expect(init(mod).locals.find((l) => l.name === "scale")?.boxed).toBeUndefined();
});
test("a lone call keeps its helper when a capture can change while it runs", async () => {
const mod = await lowerSource(`${prelude}
let limit = 2;
const bump = (): void => { limit++; };
const a = rows.filter((r) => { bump(); return r.id < limit; });
let step = 1;
step = 2;
const b = rows.map((r) => r.id * step);
console.log(a.length, b.length, limit);
${postlude}`);
expect(helperCalls(init(mod))).toEqual(["%arr.filter", "%arr.map"]);
});
test("a lone call keeps its helper when a local class's method can assign a capture", async () => {
const mod = await lowerSource(`${prelude}
let k = 1;
class Bump {
go(): void { k = 10; }
}
const b = new Bump();
const out = rows.map((r) => { if (r.id === 2) b.go(); return r.id * k; });
console.log(out.join(","));
${postlude}`);
expect(helperCalls(init(mod))).toEqual(["%arr.map"]);
});
test("a copied callback keeps its switch and for-of labels and bindings", async () => {
const mod = await lowerSource(`${prelude}
const out = rows.map((r) => {
let n = 0;
outer: switch (r.id % 3) {
case 0:
n = 10;
break outer;
case 1:
inner: for (const v of [1, 2, 3]) {
n += v;
if (v === 2) break outer;
if (v === 3) continue inner;
}
break;
default:
n = r.value;
}
return n;
});
console.log(out.join(","));
${postlude}`);
expect(helperCalls(init(mod))).toEqual(["%arr.loop"]);
});
test("a copied callback's switch-local records and known unions are rewritten in its cases", async () => {
const mod = await lowerSource(`${prelude}
type Shape = { kind: "sq"; side: number } | { kind: "rect"; w: number; h: number };
const out = rows.map((r) => {
switch (r.id % 3) {
case 0:
const p = { a: r.id, b: 2 };
return p.a + p.b;
case 1:
const sq = { kind: "sq" as const, side: r.value };
const s: Shape = sq;
return s.kind === "sq" ? s.side * 10 : 0;
default:
return r.value;
}
});
console.log(out.join(","));
${postlude}`);
expect(helperCalls(init(mod))).toEqual(["%arr.loop"]);
});
test("a copied callback with early returns keeps its switch labels", async () => {
const mod = await lowerSource(`${prelude}
const out = rows.map((r) => {
let n = 0;
outer: switch (r.id % 4) {
case 0:
return -1;
case 1:
n = 10;
break outer;
default:
inner: switch (r.value % 2) {
case 0:
n = r.value;
break outer;
default:
break inner;
}
n = 1;
}
return n;
});
const hit = rows.some((r) => {
pick: switch (r.id) {
case 3:
break pick;
default:
return false;
}
return true;
});
console.log(out.join(","), hit);
${postlude}`);
expect(helperCalls(init(mod))).toEqual(["%arr.loop", "%arr.loop"]);
});
test("a lone call in a local class's method keeps its helper for the class's captures", async () => {
const mod = await lowerSource(`${prelude}
let k = 1;
class Box {
set(v: number): void { k = v; }
go(): number[] {
const b = new Box();
return rows.map((r) => { b.set(r.id); return r.value + k; });
}
}
console.log(new Box().go().join(","));
${postlude}`);
const go = mod.functions.find((f) => f.name.endsWith(".Box.go"));
expect(go).toBeDefined();
expect(helperCalls(go!)).toEqual(["%arr.map"]);
});
@@ -9,7 +9,9 @@ import { emitLlvmModule } from "../src/backend/llvm/emitter.js";
// A specialized array helper calls its known callback directly after
// checking the closure's entry. A callback that cannot throw skips the
// pending test on that path, and the fallback through the closure's own
// entry keeps it; a callback that can throw keeps it on both paths.
// entry keeps it; a callback that can throw keeps it on both paths. The
// callbacks are passed through bindings: a closure literal at the call
// would run as a loop with its body copied in (array-pipelines.ts).
test("known callbacks outside the may-throw set test the pending flag only on the fallback", async () => {
const dir = mkdtempSync(join(tmpdir(), "scriptc-callback-pending-"));
try {
@@ -18,13 +20,16 @@ test("known callbacks outside the may-throw set test the pending flag only on th
writeFileSync(
entry,
`const values = [1, 2, 3, 4];
console.log(values.every((v) => v > 0));
console.log(values.map((v) => v * 2).join(","));
const positive = (v: number): boolean => v > 0;
const double = (v: number): number => v * 2;
const stopAtThree = (v: number): boolean => {
if (v > 2) throw new Error("stop at " + v);
return true;
};
console.log(values.every(positive));
console.log(values.map(double).join(","));
try {
console.log(values.every((v) => {
if (v > 2) throw new Error("stop at " + v);
return true;
}));
console.log(values.every(stopAtThree));
} catch (error) {
console.log("caught", (error as Error).message);
}
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule, validateModule } from "../src/index.js";
import { everyStmtList } from "../src/ir/traverse.js";
test("util.format %f lowers a boolean argument once", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-format-float-"));
@@ -22,14 +23,20 @@ test("util.format %f lowers a boolean argument once", async () => {
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const mod = deserializeModule(await readFile(outPath, "utf8"));
expect(validateModule(mod)).toEqual([]);
// One lifted helper per argument: the evaluated-for-effect statement
// reuses the lowered value instead of lowering the argument again.
const helpers = mod.functions
.map((f) => f.name)
.filter((name) => name.startsWith("%arr."))
.map((name) => name.replace(/\.[0-9a-f]+$/, ""))
.sort();
expect(helpers).toEqual(["%arr.every", "%arr.some"]);
// One lifted helper call per argument: the evaluated-for-effect
// statement reuses the lowered value instead of lowering the argument
// again. Each call runs its callback as a loop (array-pipelines.ts).
const helpers: string[] = [];
for (const fn of mod.functions)
everyStmtList(fn.body, {
expr: (e) => {
if (e.kind === "call" && e.callee.startsWith("%arr."))
helpers.push(e.callee.replace(/\.[0-9a-f]+$/, ""));
return true;
},
stmt: () => true,
});
expect(helpers).toEqual(["%arr.loop", "%arr.loop"]);
} finally {
await rm(dir, { recursive: true, force: true });
}
@@ -158,7 +158,9 @@ test("an unchanged captured parameter is borrowed and boxed only where a closure
const module = await lower(`${ITEM}
function heavier(item: Item, limits: number[]): boolean {
if (limits.length === 0) return false;
return limits.some((limit) => weigh(item) > limit);
// A closure value: a closure literal at the call would run as a loop.
const over = (limit: number): boolean => weigh(item) > limit;
return limits.some(over);
}
function replaced(item: Item, other: Item): number {
const read = () => item.weight;
+2 -1
View File
@@ -14,4 +14,5 @@ class Box {
const box = new Box();
const loose: any = box;
loose.size = 0.5;
console.log([1.5, 2].map(double).join(","), box.size);
const pick: (x: number) => number = double;
console.log([1.5, 2].map(pick).join(","), box.size);
+3 -1
View File
@@ -29,8 +29,10 @@ function bump(): void {
const sizes = [1, 2, 3];
const len: Int32 = sizes.length;
const weights = [1.5, 2];
const shifted = weights.map((w: Int32) => w + 1);
const span = new Span(1.5);
span.advance();
bump();
console.log(width(span), half(7), count, len);
console.log(width(span), half(7), count, len, shifted);
@@ -34,14 +34,14 @@ contract-int32-arrays.ts:16:13 - error SC7002: the element stored into 'counts'
hint: make the int32 conversion explicit, for example 'Math.PI | 0', so Node stores the same value
contract-int32-arrays.ts:18:26 - error SC7002: the elements 'counts.map((x) => x * 2)' stores are not provably int32s (the array can reach 'doubled', declared Int32[])
contract-int32-arrays.ts:18:44 - error SC7002: the element stored into 'counts.map((x) => x * 2)' is not provably an int32: 'x * 2' (the array can reach 'doubled', declared Int32[])
17 | halveInto(counts, ids);
18 | const doubled: Int32[] = counts.map((x) => x * 2);
| ^~~~~~~~~~~~~~~~~~~~~~~~
| ^~~~~
19 | const parsed: Int32[] = JSON.parse("[1, 2]");
hint: callback results are not followed; build the array with a loop that stores 'value | 0'
hint: make the int32 conversion explicit, for example '(x * 2) | 0', so Node stores the same value
contract-int32-arrays.ts:19:15 - error SC7003: the Int32[] contract on 'parsed' cannot be checked: the array can come from dynamic (any or unknown) code (contract-int32-arrays.ts:19:25), whose element stores scriptc does not see
@@ -21,6 +21,6 @@ contract-int32-escapes.ts:16:1 - error SC7002: the value stored into 'Box.size'
15 | const loose: any = box;
16 | loose.size = 0.5;
| ^~~~~~~~~~~~~~~~
17 | console.log([1.5, 2].map(double).join(","), box.size);
17 | const pick: (x: number) => number = double;
hint: make the int32 conversion explicit, for example 'loose.size = 0.5 | 0', so Node computes the same value
@@ -48,6 +48,15 @@ contract-int32.ts:31:20 - error SC7002: the value stored into 'len' (Int32) is n
30 | const sizes = [1, 2, 3];
31 | const len: Int32 = sizes.length;
| ^~~~~~~~~~~~
32 |
32 | const weights = [1.5, 2];
hint: make the int32 conversion explicit, for example 'sizes.length | 0', so Node computes the same value
hint: make the int32 conversion explicit, for example 'sizes.length | 0', so Node computes the same value
contract-int32.ts:33:30 - error SC7002: the value stored into 'w' (Int32) is not provably an int32
32 | const weights = [1.5, 2];
33 | const shifted = weights.map((w: Int32) => w + 1);
| ^
34 |
hint: make the int32 conversion explicit with '| 0', so Node computes the same value