From 0760cc240d2666b759db7c6ba56a28a5cf529b54 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 13 Oct 2026 07:55:53 -0500 Subject: [PATCH] Fuse array callback chains into single loops --- .../lowering/containers/array-callbacks.ts | 40 + .../lowering/containers/array-pipelines.ts | 1937 +++++++++++++++++ .../compiler/src/frontend/lowering/lowerer.ts | 5 + .../test/array-pipeline-fusion.test.ts | 112 + .../corpus/4870-array-pipeline-fusion.ts.json | 6 + .../4871-array-pipeline-analytics.ts.json | 6 + .../4872-array-pipeline-temporaries.ts.json | 6 + tests/corpus/4870-array-pipeline-fusion.ts | 215 ++ tests/corpus/4871-array-pipeline-analytics.ts | 107 + .../corpus/4872-array-pipeline-temporaries.ts | 123 ++ 10 files changed, 2557 insertions(+) create mode 100644 packages/compiler/src/frontend/lowering/containers/array-pipelines.ts create mode 100644 packages/compiler/test/array-pipeline-fusion.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4870-array-pipeline-fusion.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4871-array-pipeline-analytics.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4872-array-pipeline-temporaries.ts.json create mode 100644 tests/corpus/4870-array-pipeline-fusion.ts create mode 100644 tests/corpus/4871-array-pipeline-analytics.ts create mode 100644 tests/corpus/4872-array-pipeline-temporaries.ts diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index 630880fbc..4d977ce0e 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -524,6 +524,7 @@ function arrayHofHelper( if (existing) return existing; const name = `%arr.${method}.${lowerer.arrHofHelpers.size}`; lowerer.arrHofHelpers.set(key, name); + lowerer.arrHofInfo.set(name, { method, elem, fnRet, arity, outElem, present, split: !!fastT }); const fn = buildArrayHofFn( lowerer, name, @@ -1288,6 +1289,16 @@ function findIndexHelper( if (existing) return existing; const name = `%arr.${method}.${lowerer.arrHofHelpers.size}`; lowerer.arrHofHelpers.set(key, name); + if (!last) + lowerer.arrHofInfo.set(name, { + method: "findIndex", + elem, + fnRet, + arity, + outElem: elem, + present, + split: !!fastT, + }); const arrT = arrayOf(elem); const fnT = funcOf([callbackValueType(lowerer, elem, present), F64, arrT].slice(0, arity), fnRet); @@ -1362,6 +1373,15 @@ function someEveryHelper( if (existing) return existing; const name = `%arr.${method}.${lowerer.arrHofHelpers.size}`; lowerer.arrHofHelpers.set(key, name); + lowerer.arrHofInfo.set(name, { + method, + elem, + fnRet, + arity, + outElem: elem, + present, + split: !!fastT, + }); const arrT = arrayOf(elem); const fnT = funcOf([callbackValueType(lowerer, elem, present), F64, arrT].slice(0, arity), fnRet); const callF: IrExpr = predicateToBoolean( @@ -1552,6 +1572,15 @@ function flatMapHelper( if (existing) return existing; const name = `%arr.flatMap.${lowerer.arrHofHelpers.size}`; lowerer.arrHofHelpers.set(key, name); + lowerer.arrHofInfo.set(name, { + method: "flatMapArray", + elem, + fnRet, + arity, + outElem: fnRet.elem, + present, + split: !!fastT, + }); const arrT = arrayOf(elem); const inner = fnRet.elem; const fnT = funcOf([callbackValueType(lowerer, elem, present), F64, arrT].slice(0, arity), fnRet); @@ -1785,6 +1814,17 @@ function reduceHelper( if (existing) return existing; const name = `%arr.${method}.${lowerer.arrHofHelpers.size}`; lowerer.arrHofHelpers.set(key, name); + if (method === "reduce") + lowerer.arrHofInfo.set(name, { + method, + elem, + fnRet, + arity, + outElem: accT, + present: false, + split: !!fastT, + hasInit, + }); const arrT = arrayOf(elem); const fnT = funcOf([accT, arrayValueType(lowerer, elem), F64, arrT].slice(0, arity), fnRet); diff --git a/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts b/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts new file mode 100644 index 000000000..aee923757 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts @@ -0,0 +1,1937 @@ +import { boolLit, countedFor, numLit, varRef } from "../../../ir/build.js"; +import { + BOOL, + F64, + VOID, + type IrExpr, + type IrFunction, + type IrGlobal, + type IrLocal, + type IrParam, + type IrStmt, + type IrType, + type SrcLoc, + typeEquals, + typeKey, +} from "../../../ir/ir.js"; +import { + everyExprChild, + everyStmtChild, + everyStmtList, + transformStmtList, +} from "../../../ir/traverse.js"; +import { + arrayIndexPresent, + arrayValueRead, + arrayValueStore, + arrayValueType, +} from "../array-values.js"; +import type { Lowerer } from "../lowerer.js"; + +/** What one interned array-HOF helper computes. Recorded when the helper is + * built; the helper's call arguments are `(receiver, callback, [init], + * [fast])`, where `fast` is the split-visit closure over the plain element + * and `init` is reduce's initial value. */ +export interface ArrayHofInfo { + method: + | "map" + | "filter" + | "forEach" + | "flatMap" + | "flatMapArray" + | "reduce" + | "some" + | "every" + | "findIndex"; + elem: IrType; + fnRet: IrType; + arity: number; + /** map/flatMap/filter: the produced array's element; reduce: the accumulator. */ + outElem: IrType; + /** The callback takes the element type itself (no hole/undefined arm). */ + present: boolean; + split: boolean; + hasInit?: boolean; +} + +type FuncType = IrType & { kind: "func" }; + +const PRODUCERS = new Set(["map", "filter", "flatMap", "flatMapArray"]); +const TERMINALS = new Set([ + "reduce", + "some", + "every", + "findIndex", + "forEach", +]); + +/* ── callback effect summaries ───────────────────────────────────────── */ + +/** What calling a function can do, beyond allocating. `reads`/`writes` + * name the shared bindings (captured locals and module globals, by id) it + * reads or assigns; locals of its own frame are not listed. Anything not + * modeled here (heap writes, host calls, dynamic operations, unknown + * callees, recursion) makes the summary null: such a function is never + * reordered. */ +interface Effects { + throws: boolean; + loops: boolean; + reads: Set; + writes: Set; +} + +function emptyEffects(): Effects { + return { throws: false, loops: false, reads: new Set(), writes: new Set() }; +} + +function mergeEffects(into: Effects, from: Effects): void { + into.throws ||= from.throws; + into.loops ||= from.loops; + for (const id of from.reads) into.reads.add(id); + for (const id of from.writes) into.writes.add(id); +} + +const PURE_MATH = new Set([ + "math.abs", + "math.acos", + "math.acosh", + "math.asin", + "math.asinh", + "math.atan", + "math.atan2", + "math.atanh", + "math.cbrt", + "math.ceil", + "math.clz32", + "math.cos", + "math.cosh", + "math.exp", + "math.expm1", + "math.floor", + "math.fround", + "math.imul", + "math.log", + "math.log10", + "math.log1p", + "math.log2", + "math.max", + "math.min", + "math.pow", + "math.round", + "math.sign", + "math.sin", + "math.sinh", + "math.sqrt", + "math.tan", + "math.tanh", + "math.trunc", +]); + +/** String methods that cannot throw or call user code on string/number + * arguments. */ +const PURE_STRING = new Set([ + "length", + "charCodeAt", + "charAt", + "indexOf", + "includes", + "startsWith", + "endsWith", + "slice", + "substring", + "trim", + "trimStart", + "trimEnd", + "toLowerCase", + "toUpperCase", + "isWellFormed", + "toWellFormed", + "cpAt", +]); + +function primitive(t: IrType): boolean { + return t.kind === "f64" || t.kind === "string" || t.kind === "bool"; +} + +/** Analysis scope: whose locals a node's ids name. `all` records every + * read, including the scope's own locals (call-site expressions). */ +interface Scope { + fn: IrFunction; + locals: Map; + captures: Set; + all: boolean; +} + +class CallbackEffects { + private readonly fns: Map; + /** The unique function that creates each closure, or null when several do. */ + private readonly creators = new Map(); + private readonly memo = new Map(); + private readonly active = new Set(); + private readonly globals: Map; + + constructor(functions: readonly IrFunction[], globals: readonly IrGlobal[]) { + this.fns = new Map(functions.map((fn) => [fn.name, fn])); + this.globals = new Map(globals.map((g) => [g.id, g])); + for (const fn of functions) { + everyStmtList(fn.body, { + expr: (e) => { + if (e.kind === "closure") { + const prior = this.creators.get(e.fnName); + this.creators.set(e.fnName, prior === undefined || prior === fn ? fn : null); + } + return true; + }, + stmt: () => true, + }); + } + } + + fn(name: string): IrFunction | undefined { + return this.fns.get(name); + } + + scope(fn: IrFunction, all: boolean): Scope { + return { + fn, + locals: new Map(fn.locals.map((l) => [l.id, l])), + captures: new Set((fn.captures ?? []).map((c) => c.localId)), + all, + }; + } + + /** Effects of calling `fn`, or null when they are not modeled. */ + ofFunction(fn: IrFunction): Effects | null { + const cached = this.memo.get(fn.name); + if (cached !== undefined) return cached; + if (this.active.has(fn.name)) return null; // recursion + if (fn.async || fn.generator || fn.classCaptures?.length) { + this.memo.set(fn.name, null); + return null; + } + this.active.add(fn.name); + const effects = emptyEffects(); + const scope = this.scope(fn, false); + const ok = fn.body.every((s) => this.stmt(s, scope, effects)); + this.active.delete(fn.name); + const result = ok ? effects : null; + this.memo.set(fn.name, result); + return result; + } + + /** Effects of evaluating `e` in `scope`; false when not modeled. */ + expr(e: IrExpr, scope: Scope, out: Effects): boolean { + const child = (c: IrExpr): boolean => this.expr(c, scope, out); + const stmt = (s: IrStmt): boolean => this.stmt(s, scope, out); + switch (e.kind) { + case "numLit": + case "strLit": + case "boolLit": + case "unitLit": + return true; + case "varRef": + return this.read(e.localId, scope, out); + case "assignExpr": + return this.write(e.localId, scope, out) && child(e.value); + case "incDec": + return this.read(e.localId, scope, out) && this.write(e.localId, scope, out); + case "bin": + return ( + e.left.type.kind !== "bigint" && + e.right.type.kind !== "bigint" && + child(e.left) && + child(e.right) + ); + case "logical": + case "ternary": + case "nullish": + case "strConcat": + case "strEq": + case "strCmp": + case "toBool": + case "recordGet": + case "recordLit": + case "recordClone": + case "fieldGet": + case "unionWrap": + case "unionNarrow": + case "unionIsTag": + case "unionDisc": + case "upcast": + case "seqExpr": + return everyExprChild(e, child, stmt); + case "unary": + return primitive(e.operand.type) && child(e.operand); + case "toString": + return (primitive(e.operand.type) || e.operand.type.kind === "union") && child(e.operand); + case "arrayLit": + return !e.spreads?.length && everyExprChild(e, child, stmt); + case "arrIntrinsic": + return e.method === "length" && e.receiver.type.kind === "array" && child(e.receiver); + case "strIntrinsic": + return ( + PURE_STRING.has(e.method) && + e.args.every((a) => a.type.kind === "f64" || a.type.kind === "string") && + everyExprChild(e, child, stmt) + ); + case "libCall": + if (e.fn === "error.nodeThrow") { + out.throws = true; + return everyExprChild(e, child, stmt); + } + if (e.fn === "error.new") return everyExprChild(e, child, stmt); + return PURE_MATH.has(e.fn) && everyExprChild(e, child, stmt); + case "closure": + return !e.adapts; + case "call": { + const callee = this.fns.get(e.callee); + if (!callee || callee.captures?.length) return false; + const effects = this.ofFunction(callee); + if (!effects) return false; + mergeEffects(out, effects); + return e.args.every(child); + } + case "callValue": { + if (e.receiver) return false; + const callee = this.resolve(e.callee, scope.fn, 0); + if (!callee) return false; + const effects = this.ofFunction(callee); + if (!effects) return false; + mergeEffects(out, effects); + return child(e.callee) && e.args.every(child); + } + default: + return false; + } + } + + stmt(s: IrStmt, scope: Scope, out: Effects): boolean { + const child = (c: IrExpr): boolean => this.expr(c, scope, out); + const stmt = (c: IrStmt): boolean => this.stmt(c, scope, out); + switch (s.kind) { + case "varDecl": + return s.init === null || child(s.init); + case "assign": + return this.write(s.localId, scope, out) && child(s.value); + case "throw": + out.throws = true; + return child(s.value); + case "while": + case "doWhile": + case "for": + out.loops = true; + return everyStmtChild(s, child, stmt); + case "exprStmt": + case "return": + case "if": + case "block": + case "break": + case "continue": + return everyStmtChild(s, child, stmt); + default: + return false; + } + } + + private read(id: string, scope: Scope, out: Effects): boolean { + const local = scope.locals.get(id); + if (local) { + if (local.tdz) out.throws = true; + if (scope.all || scope.captures.has(id)) out.reads.add(id); + return true; + } + const global = this.globals.get(id); + if (!global) return false; + if (global.tdz || global.initFlag) out.throws = true; + out.reads.add(id); + return true; + } + + private write(id: string, scope: Scope, out: Effects): boolean { + const local = scope.locals.get(id); + if (local) { + if (local.tdz) out.throws = true; + if (scope.all || scope.captures.has(id)) out.writes.add(id); + return true; + } + const global = this.globals.get(id); + if (!global) return false; + if (global.tdz || global.initFlag) out.throws = true; + out.writes.add(id); + return true; + } + + /** The one function a function-valued expression evaluated in `fn` can + * hold: a closure literal, or a never-reassigned binding initialized by + * one (directly, or through a call whose every return is the same + * closure literal). Null when unknown. */ + resolve(e: IrExpr, fn: IrFunction, depth: number): IrFunction | null { + if (depth > 4) return null; + if (e.kind === "closure") { + const target = this.fns.get(e.fnName); + return target && !e.adapts ? target : null; + } + if (e.kind !== "varRef") return null; + const id = e.localId; + if (!fn.locals.some((l) => l.id === id)) return null; + if (fn.params.some((p) => p.localId === id)) return null; + if ((fn.captures ?? []).some((c) => c.localId === id)) { + const creator = this.creators.get(fn.name); + return creator ? this.resolveBinding(id, creator, depth + 1) : null; + } + return this.resolveBinding(id, fn, depth + 1); + } + + private resolveBinding(id: string, fn: IrFunction, depth: number): IrFunction | null { + const local = fn.locals.find((l) => l.id === id); + if (!local || local.mutable || local.tdz) return null; + if (fn.params.some((p) => p.localId === id)) return null; + let init: IrExpr | null = null; + let definitions = 0; + everyStmtList(fn.body, { + expr: (node) => { + if ((node.kind === "assignExpr" || node.kind === "incDec") && node.localId === id) + definitions += 2; + return true; + }, + stmt: (node) => { + if (node.kind === "varDecl" && node.localId === id) { + definitions++; + init = node.init; + } else if ((node.kind === "assign" || node.kind === "forOf") && node.localId === id) { + definitions += 2; + } + return true; + }, + }); + const value = init as IrExpr | null; + if (definitions !== 1 || value === null) return null; + return this.resolveValue(value, fn, depth); + } + + private resolveValue(e: IrExpr, fn: IrFunction, depth: number): IrFunction | null { + if (e.kind === "closure" || e.kind === "varRef") return this.resolve(e, fn, depth); + if (e.kind !== "call") return null; + const factory = this.fns.get(e.callee); + if (!factory || factory.captures?.length || factory.async || factory.generator) return null; + let target: string | null = null; + let returns = 0; + const ok = everyStmtList(factory.body, { + expr: () => true, + stmt: (node) => { + if (node.kind !== "return") return true; + returns++; + const value = node.value; + if (value?.kind !== "closure" || value.adapts) return false; + if (target !== null && target !== value.fnName) return false; + target = value.fnName; + return true; + }, + }); + const name = target as string | null; + if (!ok || returns === 0 || name === null) return null; + return this.fns.get(name) ?? null; + } +} + +/* ── chain recognition ───────────────────────────────────────────────── */ + +interface Stage { + info: ArrayHofInfo; + slow: IrExpr & { type: FuncType }; + fast: (IrExpr & { type: FuncType }) | null; + loc: SrcLoc; +} + +type Terminal = + | { kind: "collect" } + | { kind: "length" } + | { kind: "call"; stage: Stage; init: IrExpr | null }; + +interface Chain { + source: IrExpr; + sourceType: IrType & { kind: "array" }; + slice: { start: IrExpr | null; end: IrExpr | null } | null; + stages: Stage[]; + terminal: Terminal; + type: IrType; + loc: SrcLoc; +} + +function isFunc(e: IrExpr | undefined): e is IrExpr & { type: FuncType } { + return e !== undefined && e.type.kind === "func"; +} + +function stageOf(lowerer: Lowerer, e: IrExpr): { stage: Stage; receiver: IrExpr } | null { + if (e.kind !== "call") return null; + const info = lowerer.arrHofInfo.get(e.callee); + if (!info) return null; + const expected = 2 + (info.method === "reduce" && info.hasInit ? 1 : 0) + (info.split ? 1 : 0); + if (e.args.length !== expected) return null; + const receiver = e.args[0]!; + const slow = e.args[1]; + const fast = info.split ? e.args[e.args.length - 1] : undefined; + if (!isFunc(slow) || (info.split && !isFunc(fast))) return null; + if (receiver.type.kind !== "array" || !typeEquals(receiver.type.elem, info.elem)) return null; + return { + stage: { info, slow, fast: fast && isFunc(fast) ? fast : null, loc: e.loc }, + receiver, + }; +} + +function matchChain(lowerer: Lowerer, e: IrExpr): Chain | null { + let terminal: Terminal; + let receiver: IrExpr; + const stages: Stage[] = []; + if (e.kind === "arrIntrinsic" && e.method === "length" && e.args.length === 0) { + if (e.receiver.type.kind !== "array") return null; + terminal = { kind: "length" }; + receiver = e.receiver; + } else { + const top = stageOf(lowerer, e); + if (!top) return null; + receiver = top.receiver; + if (TERMINALS.has(top.stage.info.method)) { + const init = + top.stage.info.method === "reduce" && top.stage.info.hasInit && e.kind === "call" + ? e.args[2]! + : null; + terminal = { kind: "call", stage: top.stage, init }; + } else if (PRODUCERS.has(top.stage.info.method)) { + terminal = { kind: "collect" }; + stages.push(top.stage); + } else return null; + } + for (;;) { + const inner = stageOf(lowerer, receiver); + if (!inner || !PRODUCERS.has(inner.stage.info.method)) break; + stages.unshift(inner.stage); + receiver = inner.receiver; + } + let slice: Chain["slice"] = null; + if ( + receiver.kind === "arrIntrinsic" && + receiver.method === "slice" && + receiver.args.length <= 2 && + receiver.receiver.type.kind === "array" && + receiver.type.kind === "array" && + typeEquals(receiver.type, receiver.receiver.type) && + receiver.args.every((a) => a.type.kind === "f64") + ) { + slice = { start: receiver.args[0] ?? null, end: receiver.args[1] ?? null }; + receiver = receiver.receiver; + } + if (receiver.type.kind !== "array") return null; + const removed = stages.length + (slice ? 1 : 0) - (terminal.kind === "collect" ? 1 : 0); + if (removed < 1) return null; + return { + source: receiver, + sourceType: receiver.type, + slice, + stages, + terminal, + type: e.type, + loc: e.loc, + }; +} + +/* ── the fusion pass ─────────────────────────────────────────────────── */ + +/** The values a fused loop hands from one stage to the next: a local of a + * known type, or (before the first read) the source slot itself. */ +type Flow = { kind: "value"; id: string; type: IrType } | { kind: "slot" }; + +interface Plan { + chain: Chain; + callbacks: Stage[]; + /** Bindings the callbacks assign. */ + writes: Set; + /** Some callback may throw or loop; at most one does. */ + risky: boolean; +} + +/** Fuse chains of array-HOF helper calls into one loop. + * + * `xs.filter(f).map(g).reduce(h, z)` lowers to nested helper calls, each + * materializing a full intermediate array. When every callback's effects + * are modeled (see CallbackEffects) and reordering them cannot be + * observed, the chain becomes one synthetic helper that runs each element + * through all stages before reading the next, and never builds the + * intermediate arrays. The rules, from strictest to weakest need: + * + * - No callback writes the heap, calls host code or an unknown function, + * or recurses. Allocation and reads are fine: with no heap writes, every + * read sees the same value in either order. + * - Callbacks may assign shared bindings (captured locals, globals), but + * no other callback in the chain reads or assigns those bindings. + * - At most one callback may throw (or contain a loop, which might not + * terminate); if one does, no other callback assigns anything. A throw + * then leaves the same observable state in either order. + * - some/every/findIndex stop early, so the stages before them must not + * throw, loop or assign at all: the unfused chain would have run them on + * every element. + * - Index parameters receive the position in the array the unfused + * stage would have visited: the source index while only maps (which + * keep holes in place) precede it, else a count of the values that + * reached the stage. Only the first stage over an unsliced source may + * take the array parameter; any other would name an intermediate array. + * - Holes: map and slice preserve them, filter/flatMap drop them; every + * consumer here skips them except findIndex, which is not fused when a + * hole can reach it. Lengths are read once, before any callback, and no + * callback can write an array. + * + * A `const` holding a chain that is read once, as the receiver of the next + * statement's consumer, is folded into that consumer when the rest of that + * statement is free of effects. */ +export function fuseArrayPipelines(lowerer: Lowerer, functions: IrFunction[]): void { + if (lowerer.arrHofInfo.size === 0) return; + const pass = new PipelineFusion(lowerer, functions); + for (const fn of functions.slice()) pass.run(fn); + functions.push(...pass.added); +} + +class PipelineFusion { + readonly added: IrFunction[] = []; + private readonly effects: CallbackEffects; + private readonly helpers = new Map(); + + constructor( + private readonly lowerer: Lowerer, + functions: readonly IrFunction[], + ) { + this.effects = new CallbackEffects(functions, lowerer.globalsList); + } + + run(fn: IrFunction): void { + const infos = this.lowerer.arrHofInfo; + const candidate = !everyStmtList(fn.body, { + expr: (e) => !(e.kind === "call" && infos.has(e.callee)), + stmt: () => true, + }); + if (!candidate) return; + const removed = new Set(); + let body = this.lists(fn.body, fn, removed); + body = transformStmtList(body, { + stmt: (s) => s, + expr: (e) => this.fuse(e, fn) ?? e, + }); + fn.body = body; + if (removed.size > 0) fn.locals = fn.locals.filter((l) => !removed.has(l.id)); + } + + /* ── statement lists: fold single-use chain bindings ── */ + + private lists(list: IrStmt[], fn: IrFunction, removed: Set): IrStmt[] { + const nested = list.map((s) => this.nested(s, fn, removed)); + const out: IrStmt[] = []; + for (let i = 0; i < nested.length; i++) { + let current = nested[i]!; + while (i + 1 < nested.length) { + const merged = this.fold(current, nested[i + 1]!, fn); + if (!merged) break; + removed.add((current as IrStmt & { kind: "varDecl" }).localId); + current = merged; + i++; + } + out.push(current); + } + return out; + } + + private nested(s: IrStmt, fn: IrFunction, removed: Set): IrStmt { + const l = (body: IrStmt[]): IrStmt[] => this.lists(body, fn, removed); + switch (s.kind) { + case "if": + return { ...s, then: l(s.then), else_: s.else_ === null ? null : l(s.else_) }; + case "while": + case "doWhile": + case "for": + case "forOf": + case "block": + return { ...s, body: l(s.body) }; + case "switch": + return { ...s, cases: s.cases.map((c) => ({ ...c, body: l(c.body) })) }; + case "tryCatch": + return { + ...s, + tryBody: l(s.tryBody), + catchBody: s.catchBody === null ? null : l(s.catchBody), + finallyBody: s.finallyBody === null ? null : l(s.finallyBody), + }; + default: + return s; + } + } + + /** `const L = ; `. */ + private fold(decl: IrStmt, next: IrStmt, fn: IrFunction): IrStmt | null { + if (decl.kind !== "varDecl" || decl.init === null) return null; + const init = decl.init; + const id = decl.localId; + const local = fn.locals.find((l) => l.id === id); + if (!local || local.boxed || local.tdz || !typeEquals(local.type, init.type)) return null; + if (!matchChain(this.lowerer, init) && !stageOf(this.lowerer, init)) { + if (!(init.kind === "arrIntrinsic" && init.method === "slice")) return null; + } + if (useCount(fn, id) !== 1) return null; + // Only expression statements whose value is computed before any store. + let root: IrExpr; + switch (next.kind) { + case "varDecl": + if (next.init === null) return null; + root = next.init; + break; + case "assign": + root = next.value; + break; + case "exprStmt": + root = next.expr; + break; + case "return": + if (next.value === null) return null; + root = next.value; + break; + default: + return null; + } + const site = locateUse(this.lowerer, root, id); + if (!site) return null; + const substituted = substituteReceiver(site.node, init); + // The chain now runs after the rest of `next`'s operands are evaluated; + // those must not observe or change anything the chain touches. + const plan = this.plan(substituted, fn); + const chainArgs = matchChain(this.lowerer, substituted); + if (!plan || !chainArgs) return null; + if (site.conditional && (plan.risky || plan.writes.size > 0)) return null; + const scope = this.effects.scope(fn, true); + const rest = emptyEffects(); + const placeholder: IrExpr = { kind: "numLit", value: 0, type: F64, loc: site.node.loc }; + if (!this.effects.expr(replaceNode(root, site.node, placeholder), scope, rest)) return null; + if (rest.throws || rest.writes.size > 0 || intersects(rest.reads, plan.writes)) return null; + const moved = emptyEffects(); + const movedArgs: IrExpr[] = [chainArgs.source]; + if (chainArgs.slice?.start) movedArgs.push(chainArgs.slice.start); + if (chainArgs.slice?.end) movedArgs.push(chainArgs.slice.end); + if (!movedArgs.every((a) => this.effects.expr(a, scope, moved))) return null; + if (moved.throws || moved.writes.size > 0 || moved.loops) return null; + const value = replaceNode(root, site.node, substituted); + switch (next.kind) { + case "varDecl": + return { ...next, init: value }; + case "assign": + return { ...next, value }; + case "exprStmt": + return { ...next, expr: value }; + case "return": + return { ...next, value }; + } + return null; + } + + /* ── expressions ── */ + + private fuse(e: IrExpr, fn: IrFunction): IrExpr | null { + const plan = this.plan(e, fn); + return plan ? this.emit(plan) : null; + } + + /** 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 { 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[] = []; + const argReads = emptyEffects(); + for (const stage of callbacks) { + for (const arg of [stage.slow, stage.fast]) { + if (!arg) continue; + if (!this.effects.expr(arg, scope, argReads)) return null; + } + const target = this.effects.resolve(stage.fast ?? stage.slow, fn, 0); + const summary = target ? this.effects.ofFunction(target) : null; + if (!summary) return null; + if (stage.fast) { + const slow = this.effects.resolve(stage.slow, fn, 0); + const slowSummary = slow ? this.effects.ofFunction(slow) : null; + if (!slowSummary) return null; + mergeEffects(summary, slowSummary); + } + summaries.push(summary); + } + if (argReads.throws || argReads.writes.size > 0) return null; + const init = terminal.kind === "call" ? terminal.init : null; + if (init) { + if (!this.effects.expr(init, scope, argReads)) return null; + if (argReads.throws || argReads.writes.size > 0 || argReads.loops) return null; + } + const writes = new Set(); + let riskyCount = 0; + for (const s of summaries) { + for (const id of s.writes) writes.add(id); + if (s.throws || s.loops) riskyCount++; + } + if (riskyCount > 1) return null; + for (let j = 0; j < summaries.length; j++) { + const sj = summaries[j]!; + if (riskyCount === 1 && !(sj.throws || sj.loops) && sj.writes.size > 0) return null; + for (let k = 0; k < summaries.length; k++) { + if (j === k) continue; + const sk = summaries[k]!; + if (intersects(sj.writes, sk.reads) || intersects(sj.writes, sk.writes)) return null; + } + } + if (intersects(argReads.reads, writes)) return null; + const early = + terminalStage !== null && + (terminalStage.info.method === "some" || + terminalStage.info.method === "every" || + terminalStage.info.method === "findIndex"); + if (early) { + for (let k = 0; k < stages.length; k++) { + const s = summaries[k]!; + if (s.throws || s.loops || s.writes.size > 0) return null; + } + } + return { chain, callbacks, writes, risky: riskyCount > 0 }; + } + + /* ── helper construction ── */ + + /** A closure-literal callback whose body can be copied into the loop: + * small, no nested closures, and its captured bindings only read (they + * 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) => { + 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; + }; + + private emit(plan: Plan): IrExpr | null { + const built = new FusedLoop(this.lowerer, plan.chain, this.inlineTarget).build(); + if (!built) return null; + let name = this.helpers.get(built.key); + if (!name) { + name = `%arr.fused.${this.helpers.size}`; + this.helpers.set(built.key, name); + this.added.push({ ...built.fn, name }); + } + return { + kind: "call", + callee: name, + args: built.args, + type: plan.chain.type, + loc: plan.chain.loc, + }; + } +} + +function intersects(a: ReadonlySet, b: ReadonlySet): boolean { + for (const id of a) if (b.has(id)) return true; + return false; +} + +/** Map and slice keep holes in place; filter and flatMap drop them. */ +function holeCarrying(chain: Chain): boolean { + return chain.stages.every((s) => s.info.method === "map"); +} + +function useCount(fn: IrFunction, id: string): number { + let uses = 0; + everyStmtList(fn.body, { + expr: (e) => { + switch (e.kind) { + case "varRef": + case "assignExpr": + case "incDec": + if (e.localId === id) uses++; + break; + case "closure": + if (e.captures.includes(id)) uses += 2; + break; + } + return true; + }, + stmt: (s) => { + if ((s.kind === "assign" || s.kind === "forOf") && s.localId === id) uses += 2; + if (s.kind === "varDecl" && s.localId === id && s.init === null) uses += 2; + return true; + }, + }); + return uses; +} + +/** The consumer node in `root` whose receiver is `varRef id`, and whether + * it is evaluated only conditionally. */ +function locateUse( + lowerer: Lowerer, + root: IrExpr, + id: string, +): { node: IrExpr; conditional: boolean } | null { + let found: { node: IrExpr; conditional: boolean } | null = null; + const isUse = (e: IrExpr): boolean => { + const receiver = + e.kind === "arrIntrinsic" && e.method === "length" + ? e.receiver + : e.kind === "call" && lowerer.arrHofInfo.has(e.callee) + ? e.args[0] + : undefined; + return receiver?.kind === "varRef" && receiver.localId === id; + }; + const walk = (e: IrExpr, conditional: boolean): boolean => { + if (isUse(e)) { + found = { node: e, conditional }; + return false; + } + const branching = + conditional || + e.kind === "ternary" || + e.kind === "logical" || + e.kind === "nullish" || + e.kind === "orDefault" || + e.kind === "optChain" || + e.kind === "seqExpr" || + e.kind === "closure"; + return everyExprChild( + e, + (c) => walk(c, branching && !(e.kind === "ternary" && c === e.cond)), + () => true, + ); + }; + walk(root, false); + return found; +} + +function substituteReceiver(node: IrExpr, receiver: IrExpr): IrExpr { + if (node.kind === "arrIntrinsic") return { ...node, receiver }; + if (node.kind === "call") return { ...node, args: [receiver, ...node.args.slice(1)] }; + return node; +} + +function replaceNode(root: IrExpr, target: IrExpr, replacement: IrExpr): IrExpr { + if (root === target) return replacement; + return transformExprOnce(root, target, replacement); +} + +function transformExprOnce(root: IrExpr, target: IrExpr, replacement: IrExpr): IrExpr { + const [stmt] = transformStmtList([{ kind: "exprStmt", expr: root, loc: root.loc }], { + stmt: (s) => s, + expr: (e) => (e === target ? replacement : e), + }); + return (stmt as IrStmt & { kind: "exprStmt" }).expr; +} + +/* ── the fused loop ──────────────────────────────────────────────────── */ + +/** A callback the fused loop calls: either through a parameter holding the + * closure value, or inlined (its body copied into the loop, its captured + * bindings passed in by value). */ +interface Callee { + arg: IrExpr & { type: FuncType }; + ref: IrExpr | null; + inline: IrFunction | null; + captures: Map | null; +} + +/** The inlinable body behind a closure-literal callback, or null. */ +export type InlineTarget = (arg: IrExpr) => IrFunction | null; + +const MAX_INLINE_NODES = 400; + +class FusedLoop { + private readonly params: IrParam[] = []; + private readonly locals: IrLocal[] = []; + private readonly args: IrExpr[] = []; + private readonly keyParts: string[] = []; + private readonly callees: Callee[] = []; + private readonly loc: SrcLoc; + private next = 0; + private failed = false; + + constructor( + private readonly lowerer: Lowerer, + private readonly chain: Chain, + private readonly inlineTarget: InlineTarget, + ) { + this.loc = chain.loc; + } + + private fresh(name: string): string { + return `${name}.${this.next++}`; + } + + private local(name: string, type: IrType, mutable = false): string { + const id = this.fresh(name); + this.locals.push({ id, name, type, mutable }); + return id; + } + + private param(name: string, value: IrExpr, key: string): IrExpr { + const id = this.fresh(name); + this.params.push({ localId: id, name, type: value.type }); + this.locals.push({ id, name, type: value.type, mutable: false }); + this.args.push(value); + this.keyParts.push(`${key}:${typeKey(value.type)}`); + return varRef(id, value.type, this.loc); + } + + /** How the loop reaches one callback argument; decided at first use. */ + private callee(arg: IrExpr & { type: FuncType }): Callee { + const known = this.callees.find((c) => c.arg === arg); + if (known) return known; + const target = this.inlineTarget(arg); + let callee: Callee; + if (target && arg.kind === "closure") { + const captures = new Map(); + (target.captures ?? []).forEach((capture, index) => { + const source = arg.captures[index]!; + const ref = this.param("cap", varRef(source, capture.type, this.loc), `cap@${target.name}`); + captures.set(capture.localId, (ref as IrExpr & { kind: "varRef" }).localId); + }); + this.keyParts.push(`inline@${target.name}`); + callee = { arg, ref: null, inline: target, captures }; + } else { + const site = arg.kind === "closure" ? `@${arg.fnName}` : ""; + callee = { arg, ref: this.param("f", arg, `cb${site}`), inline: null, captures: null }; + } + this.callees.push(callee); + return callee; + } + + /** Statements and the value of calling `callee` with `args`. */ + private invoke(callee: Callee, args: IrExpr[], ret: IrType): { pre: IrStmt[]; value: IrExpr } { + const loc = this.loc; + if (!callee.inline) { + return { + pre: [], + value: { kind: "callValue", callee: callee.ref!, args, type: ret, loc }, + }; + } + const fn = callee.inline; + if (fn.params.length !== args.length) { + this.failed = true; + return { pre: [], value: { kind: "numLit", value: 0, type: F64, loc } }; + } + const renamed = new Map(callee.captures); + // A parameter the body never assigns can name its argument's local + // directly: the loop's value locals are not assigned while the + // inlined body runs. + const assigned = new Set(); + everyStmtList(fn.body, { + expr: (e) => { + if (e.kind === "assignExpr" || e.kind === "incDec") assigned.add(e.localId); + return true; + }, + stmt: (st) => { + if (st.kind === "assign" || st.kind === "forOf") assigned.add(st.localId); + return true; + }, + }); + const aliased = new Set(); + fn.params.forEach((p, index) => { + const arg = args[index]!; + if (arg.kind === "varRef" && !assigned.has(p.localId) && typeEquals(arg.type, p.type)) { + 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); + renamed.set(l.id, id); + } + const labels = new Map(); + const exit = this.fresh("%fused.exit"); + const label = (name: string): string => { + let id = labels.get(name); + if (!id) { + id = this.fresh("%fused.label"); + labels.set(name, id); + } + 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; + } + }, + stmt: (s): IrStmt => { + switch (s.kind) { + case "return": { + const exitStmt: IrStmt = { kind: "break", label: exit, loc: s.loc }; + if (s.value === null || result === null) { + return s.value === null + ? exitStmt + : { + kind: "block", + body: [{ kind: "exprStmt", expr: s.value, loc: s.loc }, exitStmt], + loc: s.loc, + }; + } + return { + kind: "block", + body: [{ kind: "assign", localId: result, value: s.value, loc: s.loc }, exitStmt], + 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; + } + }, + }); + 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 }); + return { + pre, + value: + result === null + ? { kind: "numLit", value: 0, type: F64, loc } + : varRef(result, fn.returnType, loc), + }; + } + + build(): { fn: Omit; args: IrExpr[]; key: string } | null { + const { chain, loc } = this; + const arrT = chain.sourceType; + const a = this.param("a", chain.source, "src"); + const n = this.local("n", F64); + const body: IrStmt[] = [ + { + kind: "varDecl", + localId: n, + init: { kind: "arrIntrinsic", method: "length", receiver: a, args: [], type: F64, loc }, + loc, + }, + ]; + const nRef = varRef(n, F64, loc); + // Slice bounds: ToIntegerOrInfinity, then relative to the length. + let lo: IrExpr = numLit(0, loc); + let hi: IrExpr = nRef; + if (chain.slice) { + const bound = (value: IrExpr | null, name: string, fallback: IrExpr): IrExpr => { + if (!value) return fallback; + const raw = this.param(name, value, name); + const t = this.local(`${name}Int`, F64); + const at = this.local(`${name}At`, F64); + const tRef = varRef(t, F64, loc); + const sum: IrExpr = { kind: "bin", op: "+", left: nRef, right: tRef, type: F64, loc }; + const lt = (left: IrExpr, right: IrExpr): IrExpr => ({ + kind: "bin", + op: "<", + left, + right, + type: BOOL, + loc, + }); + const pick = (cond: IrExpr, then: IrExpr, else_: IrExpr): IrExpr => ({ + kind: "ternary", + cond, + then, + else_, + type: F64, + loc, + }); + body.push( + { + kind: "varDecl", + localId: t, + init: pick( + { kind: "bin", op: "!==", left: raw, right: raw, type: BOOL, loc }, + numLit(0, loc), + { kind: "libCall", fn: "math.trunc", args: [raw], type: F64, loc }, + ), + loc, + }, + { + kind: "varDecl", + localId: at, + init: pick( + lt(tRef, numLit(0, loc)), + pick(lt(sum, numLit(0, loc)), numLit(0, loc), sum), + pick(lt(nRef, tRef), nRef, tRef), + ), + loc, + }, + ); + return varRef(at, F64, loc); + }; + lo = bound(chain.slice.start, "start", numLit(0, loc)); + hi = bound(chain.slice.end, "end", nRef); + this.keyParts.push(`slice:${chain.slice.start ? 1 : 0}${chain.slice.end ? 1 : 0}`); + } + const terminalStage = chain.terminal.kind === "call" ? chain.terminal.stage : null; + for (const stage of terminalStage ? [...chain.stages, terminalStage] : chain.stages) { + this.keyParts.push( + `${stage.info.method}:${typeKey(stage.info.elem)}:${typeKey(stage.info.fnRet)}:${stage.info.arity}:${stage.info.present ? "p" : ""}`, + ); + } + const holes = holeCarrying(chain); + const resultT = chain.type; + const terminal = chain.terminal; + const epilogue: IrStmt[] = []; + // Terminal state. The reduce seed is a parameter evaluated after the + // callbacks, like the unfused call's argument. + let acc: string | null = null; + let count: string | null = null; + let out: string | null = null; + const stateDecls: IrStmt[] = []; + if (terminal.kind === "call" && terminal.stage.info.method === "reduce") { + acc = this.local("acc", terminal.stage.info.outElem, true); + epilogue.push({ kind: "return", value: varRef(acc, terminal.stage.info.outElem, loc), loc }); + } else if ( + terminal.kind === "length" || + (terminal.kind === "call" && terminal.stage.info.method === "findIndex") + ) { + count = this.local("count", F64, true); + stateDecls.push({ kind: "varDecl", localId: count, init: numLit(0, loc), loc }); + epilogue.push({ + kind: "return", + value: terminal.kind === "length" ? varRef(count, F64, loc) : numLit(-1, loc), + loc, + }); + } else if (terminal.kind === "collect") { + out = this.local("out", resultT); + stateDecls.push({ + kind: "varDecl", + localId: out, + init: { kind: "arrayLit", elems: [], type: resultT, loc }, + loc, + }); + if (holes) { + stateDecls.push({ + kind: "arraySetLength", + arr: varRef(out, resultT, loc), + length: { + kind: "ternary", + cond: { kind: "bin", op: "<", left: lo, right: hi, type: BOOL, loc }, + then: { kind: "bin", op: "-", left: hi, right: lo, type: F64, loc }, + else_: numLit(0, loc), + type: F64, + loc, + }, + loc, + }); + } + epilogue.push({ kind: "return", value: varRef(out, resultT, loc), loc }); + } else if (terminal.kind === "call") { + const method = terminal.stage.info.method; + if (method === "some" || method === "every") + epilogue.push({ kind: "return", value: boolLit(method === "every", loc), loc }); + } + const i = varRef("i.0", F64, loc); + this.locals.push({ id: "i.0", name: "i", type: F64, mutable: true }); + const slotRead = (): IrExpr => ({ kind: "arrayGet", arr: a, index: i, type: arrT.elem, loc }); + const outIndex = (): IrExpr => + chain.slice ? { kind: "bin", op: "-", left: i, right: lo, type: F64, loc } : i; + + const counters = new Map(); + /** The index argument for the value now reaching callback `k`. */ + const indexArg = (k: number, pre: IrStmt[]): IrExpr => { + if (chain.stages.slice(0, k).every((s) => s.info.method === "map")) return outIndex(); + let counter = counters.get(k); + if (counter === undefined) { + counter = this.local("index", F64, true); + counters.set(k, counter); + stateDecls.push({ kind: "varDecl", localId: counter, init: numLit(0, loc), loc }); + } + const ix = this.local("ix", F64); + pre.push( + { kind: "varDecl", localId: ix, init: varRef(counter, F64, loc), loc }, + increment(counter, loc), + ); + return varRef(ix, F64, loc); + }; + + /** Call callback `k` with `lead` arguments, a value, and the index and + * array arguments it declares. */ + const deliver = ( + stage: Stage, + k: number, + lead: IrExpr[], + flow: Flow & { kind: "value" }, + ): { pre: IrStmt[]; value: IrExpr } | null => { + const arity = stage.info.arity; + const value = varRef(flow.id, flow.type, loc); + const at = lead.length; + const pick = (): { arg: IrExpr & { type: FuncType }; value: IrExpr | null } | null => { + if (arity <= at) return { arg: stage.slow, value: null }; + const fast = stage.fast; + if (fast && typeEquals(fast.type.params[at]!, flow.type)) return { arg: fast, value }; + const paramT = stage.slow.type.params[at]!; + if (typeEquals(paramT, flow.type)) return { arg: stage.slow, value }; + if (paramT.kind === "union" && flow.type.kind !== "union") { + const tag = this.lowerer.armTag(paramT.unionId, flow.type); + if (tag >= 0) + return { + arg: stage.slow, + value: { kind: "unionWrap", unionId: paramT.unionId, tag, value, type: paramT, loc }, + }; + } + return null; + }; + const chosen = pick(); + if (!chosen) return null; + const before: IrStmt[] = []; + const args = chosen.value ? [...lead, chosen.value] : lead.slice(); + if (chosen.value && arity > args.length) args.push(indexArg(k, before)); + if (chosen.value && arity > args.length) args.push(a); + const called = this.invoke( + this.callee(chosen.arg), + args.slice(0, arity), + chosen.arg.type.ret, + ); + return { pre: [...before, ...called.pre], value: called.value }; + }; + + const fail = (): IrStmt[] => { + this.failed = true; + return []; + }; + + /** Turn the source slot into a value for the callback of `stage`. */ + const read = (stage: Stage, rest: (flow: Flow & { kind: "value" }) => IrStmt[]): IrStmt[] => { + if (stage.info.present) { + const v = this.local("v", arrT.elem); + return [ + { kind: "varDecl", localId: v, init: slotRead(), loc }, + ...rest({ kind: "value", id: v, type: arrT.elem }), + ]; + } + const valueT = arrayValueType(this.lowerer, arrT.elem); + if (stage.fast) { + // The split visit: a value goes on as the plain element, a present + // undefined as the undefined arm. + const missing = this.lowerer.wrappedUndefined(valueT, loc); + if (!missing) return fail(); + const v = this.local("v", arrT.elem); + const u = this.local("u", valueT); + return [ + { + kind: "if", + cond: { + kind: "bin", + op: "===", + left: { kind: "arrayState", arr: a, index: i, type: F64, loc }, + right: numLit(1, loc), + type: BOOL, + loc, + }, + then: [ + { kind: "varDecl", localId: v, init: slotRead(), loc }, + ...rest({ kind: "value", id: v, type: arrT.elem }), + ], + else_: [ + { kind: "varDecl", localId: u, init: missing, loc }, + ...rest({ kind: "value", id: u, type: valueT }), + ], + loc, + }, + ]; + } + const u = this.local("u", valueT); + return [ + { + kind: "varDecl", + localId: u, + init: arrayValueRead(this.lowerer, a, i, arrT.elem, loc), + loc, + }, + ...rest({ kind: "value", id: u, type: valueT }), + ]; + }; + + /** The statements that run one value through stages k.. and the terminal. */ + const visit = (k: number, flow: Flow): IrStmt[] => { + if (k === chain.stages.length) return finish(flow); + const stage = chain.stages[k]!; + const info = stage.info; + if (flow.kind === "slot") return read(stage, (value) => visit(k, value)); + const called = deliver(stage, k, [], flow); + if (!called) return fail(); + const { pre, value: result } = called; + switch (info.method) { + case "map": + case "flatMap": { + const id = this.local("m", info.fnRet); + return [ + ...pre, + { kind: "varDecl", localId: id, init: result, loc }, + ...visit(k + 1, { kind: "value", id, type: info.fnRet }), + ]; + } + case "filter": + return [ + ...pre, + { + kind: "if", + cond: + result.type.kind === "bool" + ? result + : { kind: "toBool", operand: result, type: BOOL, loc }, + then: visit(k + 1, flow), + else_: null, + loc, + }, + ]; + case "flatMapArray": { + const r = this.local("r", info.fnRet); + const m = this.local("len", F64); + const j = this.local("j", F64, true); + const inner = (info.fnRet as IrType & { kind: "array" }).elem; + const rRef = varRef(r, info.fnRet, loc); + const jRef = varRef(j, F64, loc); + let each: IrStmt[]; + if (k + 1 === chain.stages.length && terminal.kind === "length") { + each = finish({ kind: "slot" }); + } else { + const w = this.local("w", arrayValueType(this.lowerer, inner)); + const element = arrayValueRead(this.lowerer, rRef, jRef, inner, loc); + each = [ + { kind: "varDecl", localId: w, init: element, loc }, + ...visit(k + 1, { kind: "value", id: w, type: element.type }), + ]; + } + return [ + ...pre, + { kind: "varDecl", localId: r, init: result, loc }, + { + kind: "varDecl", + localId: m, + init: { + kind: "arrIntrinsic", + method: "length", + receiver: rRef, + args: [], + type: F64, + loc, + }, + loc, + }, + { + kind: "for", + init: { kind: "varDecl", localId: j, init: numLit(0, loc), loc }, + cond: { + kind: "bin", + op: "<", + left: jRef, + right: varRef(m, F64, loc), + type: BOOL, + loc, + }, + update: { + kind: "assign", + localId: j, + value: { kind: "bin", op: "+", left: jRef, right: numLit(1, loc), type: F64, loc }, + loc, + }, + body: [ + { + kind: "if", + cond: arrayIndexPresent(rRef, jRef, loc), + then: each, + else_: null, + loc, + }, + ], + loc, + }, + ]; + } + default: + return fail(); + } + }; + + /** The terminal's action for one value. */ + const finish = (flow: Flow): IrStmt[] => { + if (terminal.kind === "length") return [increment(count!, loc)]; + if (terminal.kind === "collect") { + if (flow.kind !== "value") return fail(); + const last = chain.stages[chain.stages.length - 1]!; + const outT = resultT as IrType & { kind: "array" }; + if (!typeEquals(outT.elem, last.info.outElem)) return fail(); + const outRef = varRef(out!, resultT, loc); + const index: IrExpr = holes + ? outIndex() + : { kind: "arrIntrinsic", method: "length", receiver: outRef, args: [], type: F64, loc }; + return [ + arrayValueStore( + this.lowerer, + outRef, + index, + varRef(flow.id, flow.type, loc), + outT.elem, + loc, + ), + ]; + } + const stage = terminal.stage; + const method = stage.info.method; + if (flow.kind === "slot") return read(stage, (value) => finish(value)); + if (method === "reduce") { + const accT = stage.info.outElem; + const called = deliver(stage, chain.stages.length, [varRef(acc!, accT, loc)], flow); + if (!called || !typeEquals(called.value.type, accT)) return fail(); + return [...called.pre, { kind: "assign", localId: acc!, value: called.value, loc }]; + } + const called = deliver(stage, chain.stages.length, [], flow); + if (!called) return fail(); + const { pre, value: result } = called; + if (method === "forEach") { + return stage.slow.type.ret.kind === "void" && result.kind === "numLit" + ? pre + : [...pre, { kind: "exprStmt", expr: result, loc }]; + } + const test = predicateToBoolean(result); + if (method === "some") + return [ + ...pre, + { + kind: "if", + cond: test, + then: [{ kind: "return", value: boolLit(true, loc), loc }], + else_: null, + loc, + }, + ]; + if (method === "every") + return [ + ...pre, + { + kind: "if", + cond: { kind: "unary", op: "!", operand: test, type: BOOL, loc }, + then: [{ kind: "return", value: boolLit(false, loc), loc }], + else_: null, + loc, + }, + ]; + if (method === "findIndex") + return [ + ...pre, + { + kind: "if", + cond: test, + then: [{ kind: "return", value: varRef(count!, F64, loc), loc }], + else_: null, + loc, + }, + increment(count!, loc), + ]; + return fail(); + }; + + // Callbacks are parameters in stage order; the reduce seed follows them. + for (const stage of terminalStage ? [...chain.stages, terminalStage] : chain.stages) { + void stage; + } + const present = visit(0, { kind: "slot" }); + if (this.failed) return null; + if (acc !== null && terminal.kind === "call") { + const z = this.param("z", terminal.init!, "init"); + stateDecls.unshift({ kind: "varDecl", localId: acc, init: z, loc }); + } + body.push(...stateDecls); + const hole: IrStmt[] | null = + holes && terminal.kind === "length" ? [increment(count!, loc)] : null; + const loopBody: IrStmt[] = [ + { kind: "if", cond: arrayIndexPresent(a, i, loc), then: present, else_: hole, loc }, + ]; + const loop: IrStmt = chain.slice + ? { + kind: "for", + init: { kind: "varDecl", localId: "i.0", init: lo, loc }, + cond: { kind: "bin", op: "<", left: i, right: hi, type: BOOL, loc }, + update: { + kind: "assign", + localId: "i.0", + value: { kind: "bin", op: "+", left: i, right: numLit(1, loc), type: F64, loc }, + loc, + }, + body: loopBody, + loc, + } + : countedFor(loc, nRef, () => loopBody); + body.push(loop, ...epilogue); + this.keyParts.push(`${terminal.kind}:${typeKey(resultT)}`); + const returnType: IrType = + terminal.kind === "call" && terminal.stage.info.method === "forEach" ? VOID : resultT; + const fn: Omit = { + params: this.params, + returnType, + locals: this.locals, + body, + loc, + }; + foldKnownUnions(fn); + scalarizeRecordTemporaries(this.lowerer, fn); + return { fn, args: this.args, key: this.keyParts.join("|") }; + } +} + +function increment(id: string, loc: SrcLoc): IrStmt { + return { + kind: "assign", + localId: id, + value: { + kind: "bin", + op: "+", + left: varRef(id, F64, loc), + right: numLit(1, loc), + type: F64, + loc, + }, + loc, + }; +} + +/** ToBoolean of a predicate result, as the some/every/findIndex helpers apply it. */ +function predicateToBoolean(e: IrExpr): IrExpr { + if (e.type.kind === "bool") return e; + if (e.type.kind === "dyn") + return { kind: "dynTest", test: "truthy", value: e, type: BOOL, loc: e.loc }; + return { kind: "toBool", operand: e, type: BOOL, loc: e.loc }; +} + +/** A union local the fused loop wrapped from a plain value has a known + * tag: its tag tests fold, its narrowing is the plain value, and the wrap + * itself goes away once nothing reads it. */ +function foldKnownUnions(fn: Omit): void { + const writes = new Map(); + const count = (id: string): void => { + writes.set(id, (writes.get(id) ?? 0) + 1); + }; + everyStmtList(fn.body, { + expr: (e) => { + if (e.kind === "assignExpr" || e.kind === "incDec") count(e.localId); + return true; + }, + stmt: (s) => { + if (s.kind === "varDecl" || s.kind === "assign" || s.kind === "forOf") count(s.localId); + return true; + }, + }); + const known = new Map(); + everyStmtList(fn.body, { + expr: () => true, + stmt: (s) => { + if ( + s.kind === "varDecl" && + s.init?.kind === "unionWrap" && + s.init.value.kind === "varRef" && + writes.get(s.localId) === 1 && + writes.get(s.init.value.localId) === 1 + ) + known.set(s.localId, { tag: s.init.tag, value: s.init.value }); + return true; + }, + }); + if (known.size === 0) return; + const fold = (e: IrExpr): IrExpr => { + if (e.kind === "unionIsTag" && e.value.kind === "varRef") { + const k = known.get(e.value.localId); + if (k) return boolLit((k.tag === e.tag) !== e.negated, e.loc); + } + if (e.kind === "unionNarrow" && e.value.kind === "varRef") { + const k = known.get(e.value.localId); + if (k && k.tag === e.tag && typeEquals(k.value.type, e.type)) + return { ...k.value, loc: e.loc }; + } + if (e.kind === "ternary") { + const cond = fold(e.cond); + if (cond.kind === "boolLit") return fold(cond.value ? e.then : e.else_); + } + return e; + }; + let body = transformStmtList(fn.body, { stmt: (s) => s, expr: fold }); + const reads = new Set(); + everyStmtList(body, { + expr: (e) => { + if (e.kind === "varRef") reads.add(e.localId); + return true; + }, + stmt: () => true, + }); + const dead = new Set([...known.keys()].filter((id) => !reads.has(id))); + if (dead.size > 0) { + const prune = (list: IrStmt[]): IrStmt[] => + list + .filter((s) => !(s.kind === "varDecl" && dead.has(s.localId))) + .map((s) => { + switch (s.kind) { + case "if": + return { ...s, then: prune(s.then), else_: s.else_ === null ? null : prune(s.else_) }; + case "for": + case "while": + case "doWhile": + case "block": + return { ...s, body: prune(s.body) }; + default: + return s; + } + }); + body = prune(body); + fn.locals = fn.locals.filter((l) => !dead.has(l.id)); + } + fn.body = body; +} + +/* ── per-element record temporaries ──────────────────────────────────── */ + +/** Inside a fused loop, a record one inlined stage builds and the next + * only reads field by field never needs to exist. A group of record locals + * connected by copies, each defined only by a literal of their shape (or a + * copy within the group) and used only through field reads, becomes one + * scalar local per field. Literal fields still evaluate in source order + * (through temporaries, so a literal may read the fields it replaces). + * Identity cannot be observed: no use besides a field read survives. */ +function scalarizeRecordTemporaries(lowerer: Lowerer, fn: Omit): void { + const params = new Set(fn.params.map((p) => p.localId)); + const candidates = new Map(); + for (const l of fn.locals) { + if (l.type.kind !== "record" || l.boxed || l.tdz || params.has(l.id)) continue; + const shape = lowerer.shapes.get(l.type.shapeId); + if ( + !shape || + shape.tuple || + shape.indexValue || + shape.fields.length === 0 || + shape.fields.length > 8 || + !shape.fields.every((f) => f.type.kind === "f64" || f.type.kind === "bool") + ) + continue; + candidates.set(l.id, l as IrLocal & { type: IrType & { kind: "record" } }); + } + if (candidates.size === 0) return; + const parent = new Map(); + const find = (id: string): string => { + let root = id; + while (parent.has(root)) root = parent.get(root)!; + return root; + }; + const union = (x: string, y: string): void => { + const rx = find(x); + const ry = find(y); + if (rx !== ry) parent.set(rx, ry); + }; + const bad = new Set(); + const sameShape = (x: string, y: string): boolean => + candidates.get(x)!.type.shapeId === candidates.get(y)!.type.shapeId; + const literalOk = (e: IrExpr, shapeId: string): boolean => + e.kind === "recordLit" && + e.type.kind === "record" && + e.type.shapeId === shapeId && + e.fields.every((f) => !f.overflow && !f.drop); + const expr = (e: IrExpr): boolean => { + if (e.kind === "recordGet" && e.obj.kind === "varRef" && candidates.has(e.obj.localId)) + return true; + if ( + (e.kind === "varRef" || e.kind === "assignExpr" || e.kind === "incDec") && + candidates.has(e.localId) + ) + bad.add(e.localId); + if (e.kind === "closure") for (const id of e.captures) if (candidates.has(id)) bad.add(id); + return everyExprChild(e, expr, stmt); + }; + const definition = (id: string, value: IrExpr | null): void => { + const target = candidates.get(id)!; + if (value === null) return; + if (value.kind === "varRef" && candidates.has(value.localId)) { + if (sameShape(id, value.localId)) union(id, value.localId); + else bad.add(id); + return; + } + if (literalOk(value, target.type.shapeId)) { + for (const f of (value as IrExpr & { kind: "recordLit" }).fields) expr(f.value); + return; + } + bad.add(id); + expr(value); + }; + const stmt = (s: IrStmt): boolean => { + if ((s.kind === "varDecl" || s.kind === "assign") && candidates.has(s.localId)) { + definition(s.localId, s.kind === "varDecl" ? s.init : s.value); + return true; + } + if (s.kind === "forOf" && candidates.has(s.localId)) bad.add(s.localId); + return everyStmtChild(s, expr, stmt); + }; + fn.body.forEach(stmt); + const badRoots = new Set([...bad].map(find)); + const chosen = [...candidates.keys()].filter((id) => !badRoots.has(find(id))); + if (chosen.length === 0) return; + const fields = new Map>(); + const added: IrLocal[] = []; + let next = 0; + const used = new Set(fn.locals.map((l) => l.id)); + const fresh = (name: string): string => { + let id: string; + do id = `${name}.s${next++}`; + while (used.has(id)); + used.add(id); + return id; + }; + for (const id of chosen) { + const local = candidates.get(id)!; + const shape = lowerer.shapes.get(local.type.shapeId)!; + const map = new Map(); + for (const f of shape.fields) { + const fid = fresh(`${local.name}.${f.name}`); + map.set(f.name, fid); + added.push({ id: fid, name: `${local.name}.${f.name}`, type: f.type, mutable: true }); + } + fields.set(id, map); + } + const fieldType = (id: string, field: string): IrType => + lowerer.shapes.get(candidates.get(id)!.type.shapeId)!.fields.find((f) => f.name === field)! + .type; + const rewriteExpr = (e: IrExpr): IrExpr => { + const [s] = transformStmtList([{ kind: "exprStmt", expr: e, loc: e.loc }], { + stmt: (x) => x, + expr: (x) => { + if (x.kind === "recordGet" && x.obj.kind === "varRef") { + const id = fields.get(x.obj.localId)?.get(x.field); + if (id) return { kind: "varRef", localId: id, type: x.type, loc: x.loc }; + } + return x; + }, + }); + return (s as IrStmt & { kind: "exprStmt" }).expr; + }; + const rewriteList = (list: IrStmt[]): IrStmt[] => list.flatMap(rewriteStmt); + const rewriteStmt = (s: IrStmt): IrStmt[] => { + if ((s.kind === "varDecl" || s.kind === "assign") && fields.has(s.localId)) { + const map = fields.get(s.localId)!; + const value = s.kind === "varDecl" ? s.init : s.value; + const store = (fid: string, v: IrExpr | null): IrStmt => + s.kind === "varDecl" + ? { kind: "varDecl", localId: fid, init: v, loc: s.loc } + : { kind: "assign", localId: fid, value: v!, loc: s.loc }; + if (value === null) return [...map.values()].map((fid) => store(fid, null)); + if (value.kind === "varRef") { + const from = fields.get(value.localId)!; + return [...map].map(([name, fid]) => + store(fid, varRef(from.get(name)!, fieldType(s.localId, name), s.loc)), + ); + } + const literal = value as IrExpr & { kind: "recordLit" }; + const temps: IrStmt[] = []; + const values = new Map(); + for (const f of literal.fields) { + const t = fresh(`${candidates.get(s.localId)!.name}.${f.name}.t`); + const type = fieldType(s.localId, f.name); + added.push({ id: t, name: f.name, type, mutable: false }); + temps.push({ kind: "varDecl", localId: t, init: rewriteExpr(f.value), loc: s.loc }); + values.set(f.name, varRef(t, type, s.loc)); + } + return [...temps, ...[...map].map(([name, fid]) => store(fid, values.get(name)!))]; + } + return rewriteNested(s); + }; + const rewriteNested = (s: IrStmt): IrStmt[] => { + switch (s.kind) { + case "if": + return [ + { + ...s, + cond: rewriteExpr(s.cond), + then: rewriteList(s.then), + else_: s.else_ === null ? null : rewriteList(s.else_), + }, + ]; + case "while": + return [{ ...s, cond: rewriteExpr(s.cond), body: rewriteList(s.body) }]; + case "doWhile": + return [{ ...s, cond: rewriteExpr(s.cond), body: rewriteList(s.body) }]; + case "for": + return [ + { + ...s, + init: s.init === null ? null : (rewriteStmt(s.init)[0] ?? null), + cond: s.cond === null ? null : rewriteExpr(s.cond), + update: s.update === null ? null : (rewriteStmt(s.update)[0] ?? null), + body: rewriteList(s.body), + }, + ]; + case "block": + return [{ ...s, body: rewriteList(s.body) }]; + default: { + const [x] = transformStmtList([s], { + stmt: (y) => y, + expr: (y) => { + if (y.kind === "recordGet" && y.obj.kind === "varRef") { + const id = fields.get(y.obj.localId)?.get(y.field); + if (id) return { kind: "varRef", localId: id, type: y.type, loc: y.loc }; + } + return y; + }, + }); + return [x!]; + } + } + }; + fn.body = rewriteList(fn.body); + fn.locals = [...fn.locals.filter((l) => !fields.has(l.id)), ...added]; +} diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 8a7b0b94d..0d2b1612b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -344,6 +344,7 @@ import { lowerFunction, } from "./lower-functions.js"; import { lowerArrayMethodCall } from "./containers/array-methods.js"; +import { type ArrayHofInfo, fuseArrayPipelines } from "./containers/array-pipelines.js"; import { lowerMapMethodCall, lowerMapForEachCall, @@ -2111,6 +2112,9 @@ export class Lowerer { /** Synthetic array-HOF loop functions (map/filter/forEach desugar), * interned per method + element/callback-result type: key → fn name. */ readonly arrHofHelpers = new Map(); + /** What each array-HOF helper computes, keyed by helper name: the chain + * fusion pass reads it to recognize pipelines of helper calls. */ + readonly arrHofInfo = new Map(); /** Per-arm calls through union-typed class receivers, interned by the * receiver union, member, completed argument ABI, and result type. */ readonly unionCallHelpers = new Map(); @@ -5699,6 +5703,7 @@ export class Lowerer { /** Final retention, pruning, and module assembly shared by ordinary emit * and the retained reachability worklist. */ finishModule(functions: IrFunction[]): LowerResult { + if (this.diags.length === 0) fuseArrayPipelines(this, functions); finalizeClassMethodValues(this, functions); trackClassFieldCreation(this, functions); diff --git a/packages/compiler/test/array-pipeline-fusion.test.ts b/packages/compiler/test/array-pipeline-fusion.test.ts new file mode 100644 index 000000000..f5d743265 --- /dev/null +++ b/packages/compiler/test/array-pipeline-fusion.test.ts @@ -0,0 +1,112 @@ +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 "../src/index.js"; +import type { IrFunction, IrModule } from "../src/ir/ir.js"; +import { everyStmtList } from "../src/ir/traverse.js"; + +async function lowerSource(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-array-fusion-")); + try { + const entry = join(dir, "main.ts"); + await writeFile(entry, source); + const outPath = join(dir, "main.ir.json"); + const result = await compile(entry, { outPath, outDir: dir, outputKind: "ir" }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); + return deserializeModule(await readFile(outPath, "utf8")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** Helper callees named in `fn`, by their `%arr.` prefix. */ +function helperCalls(fn: IrFunction): string[] { + const calls: string[] = []; + everyStmtList(fn.body, { + expr: (e) => { + if (e.kind === "call" && e.callee.startsWith("%arr.")) + calls.push(e.callee.replace(/\.\d+$/, "")); + return true; + }, + stmt: () => true, + }); + return calls.sort(); +} + +/** The authored function `run` every fixture wraps its code in. */ +function init(mod: IrModule): IrFunction { + const fn = mod.functions.find((f) => f.name === "run"); + if (!fn) throw new Error("no run function"); + return fn; +} + +function count(fn: IrFunction, kind: string): number { + let n = 0; + everyStmtList(fn.body, { + expr: (e) => { + if (e.kind === kind) n++; + return true; + }, + stmt: () => true, + }); + return n; +} + +const prelude = ` +interface Row { id: number; group: string; value: number } +const rows: Row[] = []; +for (let i = 0; i < 10; i++) rows.push({ id: i, group: i % 2 === 0 ? "a" : "b", value: i * 3 }); +function run(): void { +`; +const postlude = ` +} +run(); +`; + +test("pure chains become one loop without intermediate arrays", async () => { + const mod = await lowerSource(`${prelude} +const total = rows + .filter((r) => r.group === "a") + .map((r) => ({ id: r.id, score: r.value * 2 })) + .filter((s) => s.score > 4) + .reduce((sum, s) => sum + s.score, 0); +const picked = rows.filter((r) => r.value > 6); +const n = picked.length; +console.log(total, n, rows.slice(2, 8).map((r) => r.id).some((id) => id === 5)); +${postlude}`); + expect(helperCalls(init(mod))).toEqual(["%arr.fused", "%arr.fused", "%arr.fused"]); + expect(init(mod).locals.some((l) => l.name === "picked")).toBe(false); + // The per-element { id, score } record exists only as scalar locals. + const fused = mod.functions.filter((f) => f.name.startsWith("%arr.fused.")); + expect(fused).toHaveLength(3); + for (const fn of fused) { + expect(count(fn, "recordLit")).toBe(0); + expect(count(fn, "arrayLit")).toBe(0); + } +}); + +test("observable callback order keeps the stage-by-stage helpers", async () => { + const mod = await lowerSource(`${prelude} +const log: string[] = []; +const a = rows.map((r) => { log.push("m" + r.id); return r.value; }).filter((v) => v > 3); +let seen = 0; +const b = rows.map((r) => { seen++; return r.id; }).filter((id) => id < seen); +let mapped = 0; +const c = rows.map((r) => { mapped++; return r.id; }).filter((id) => { if (id > 5) throw new Error("x"); return true; }); +let found = 0; +const d = rows.map((r) => { found++; return r.id; }).some((id) => id === 1); +const e = rows.map((r) => r.id).filter((id, i, all) => all.length > i); +console.log(a.length, b.length, c.length, d, e.length, log.length, seen, mapped, found); +${postlude}`); + expect(helperCalls(init(mod))).not.toContain("%arr.fused"); +}); + +test("a binding read twice stays an array", async () => { + const mod = await lowerSource(`${prelude} +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"]); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4870-array-pipeline-fusion.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4870-array-pipeline-fusion.ts.json new file mode 100644 index 000000000..b9cb6a0ac --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4870-array-pipeline-fusion.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4870-array-pipeline-fusion.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4871-array-pipeline-analytics.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4871-array-pipeline-analytics.ts.json new file mode 100644 index 000000000..7bc701e47 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4871-array-pipeline-analytics.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4871-array-pipeline-analytics.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4872-array-pipeline-temporaries.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4872-array-pipeline-temporaries.ts.json new file mode 100644 index 000000000..3192bebfd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4872-array-pipeline-temporaries.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4872-array-pipeline-temporaries.ts" + ], + "diags": [] +} diff --git a/tests/corpus/4870-array-pipeline-fusion.ts b/tests/corpus/4870-array-pipeline-fusion.ts new file mode 100644 index 000000000..706d5fd13 --- /dev/null +++ b/tests/corpus/4870-array-pipeline-fusion.ts @@ -0,0 +1,215 @@ +// Chains of array callbacks (filter/map/flatMap/slice feeding reduce, some, +// every, findIndex, forEach, length or a final array) keep JavaScript's +// stage-by-stage semantics: callback order, holes, sparse arrays, +// exceptions part-way through, early exits and slice bounds. + +interface Item { + id: number; + group: string; + weight: number; +} + +const items: Item[] = []; +for (let i = 0; i < 40; i++) { + items.push({ id: i, group: i % 3 === 0 ? "a" : i % 3 === 1 ? "b" : "c", weight: (i * 7) % 11 }); +} + +// Pure chains. +const heavy = items + .filter((it) => it.weight > 4) + .map((it) => ({ id: it.id, score: it.weight * 2 })) + .filter((s) => s.score % 3 !== 0); +console.log("heavy", heavy.length, heavy.reduce((sum, s) => sum + s.score, 0)); +console.log( + "sum", + items.filter((it) => it.group === "b").map((it) => it.weight).reduce((a, b) => a + b, 0), +); +console.log("some", items.map((it) => it.weight).some((w) => w === 10)); +console.log("every", items.filter((it) => it.group !== "a").every((it) => it.id % 3 !== 0)); +console.log("findIndex", items.filter((it) => it.group === "c").findIndex((it) => it.weight === 0)); +console.log("findIndex miss", items.filter((it) => it.group === "c").findIndex((it) => it.weight > 99)); +console.log("length", items.filter((it) => it.weight < 3).length); +console.log("map length", items.map((it) => it.id * 2).length); +console.log( + "flatMap length", + items.slice(0, 12).flatMap((it) => (it.group === "a" ? [it.id, it.weight] : [])).length, +); +console.log( + "collect", + items + .filter((it) => it.weight === 3) + .map((it) => `${it.group}${it.id}`) + .join(","), +); +let forEachTotal = 0; +items.filter((it) => it.group === "a").forEach((it) => { + forEachTotal += it.weight; +}); +console.log("forEach", forEachTotal); +const pairs = items.slice(5, 15).flatMap((it) => [it.id, -it.id]); +console.log("pairs", pairs.length, pairs.slice(0, 4).join(",")); +const centroid = items + .slice(0, 20) + .map((it) => ({ x: it.id / 2, y: it.weight })) + .reduce((acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }), { x: 0, y: 0 }); +console.log("centroid", centroid.x, centroid.y); + +// A const read once by the next statement's consumer. +const picked = items.filter((it) => it.weight % 2 === 1); +const pickedSum = picked.reduce((acc, it) => acc + it.id, 0); +console.log("picked", pickedSum); +const firstMapped = items.map((it) => it.weight + 1); +console.log("firstMapped", firstMapped.filter((w) => w > 5).length); +// Read twice: it stays an array. +const twice = items.filter((it) => it.id < 5); +console.log("twice", twice.length, twice.map((it) => it.id).join("")); + +// Slice bounds: negative, NaN, past the end, reversed. +const nums = [5, 1, 4, 2, 8, 7]; +console.log("slice -3", nums.slice(-3).map((n) => n * 10).join(",")); +console.log("slice NaN", nums.slice(NaN, 2).map((n) => n + 1).join(",")); +console.log("slice past", nums.slice(4, 100).reduce((a, n) => a + n, 0)); +console.log("slice reversed", nums.slice(4, 2).map((n) => n).length); +console.log("slice fraction", nums.slice(1.7, -1.2).filter((n) => n > 1).join(",")); +console.log("slice -inf", nums.slice(-Infinity, Infinity).filter((n) => n % 2 === 0).length); + +// Holes: map and slice keep them, filter and flatMap drop them; findIndex +// visits them as undefined. +const holes: number[] = []; +holes[0] = 1; +holes[2] = 3; +holes[3] = 4; +holes[5] = 6; +function keys(xs: number[]): string { + const out: number[] = []; + xs.forEach((_, i) => { + out.push(i); + }); + return out.join(","); +} +console.log("holes map length", holes.map((n) => n * 2).length); +console.log("holes map reduce", holes.map((n) => n * 2).reduce((a, n) => a + n, 0)); +console.log("holes map filter", holes.map((n) => n * 2).filter((n) => n > 2).join(",")); +console.log("holes map keys", keys(holes.map((n) => n + 1).map((n) => n * 3))); +console.log("holes slice keys", keys(holes.slice(1).map((n) => n))); +console.log("holes map every", holes.map((n) => n > 0).every((b) => b)); +const visited: string[] = []; +const hit = holes.map((n) => n).findIndex((n) => { + visited.push(String(n)); + return n === undefined; +}); +console.log("holes findIndex", hit, visited.join(",")); +const sparse: number[] = []; +sparse[3] = 30; +sparse[10] = 100; +console.log("sparse", sparse.map((n) => n / 10).filter((n) => n > 1).join(","), sparse.map((n) => n).length); + +// Explicit undefined elements reach the callbacks. +const maybe: (number | undefined)[] = [1, undefined, 3]; +console.log( + "maybe", + maybe.map((n) => (n === undefined ? -1 : n * 2)).filter((n) => n !== 3).join(","), +); +console.log("maybe filter", maybe.filter((n) => n !== 1).map((n) => String(n)).join("/")); + +// Callback order is observable: these keep stage-by-stage order. +const log: string[] = []; +const ordered = [1, 2, 3] + .map((n) => { + log.push(`m${n}`); + return n * 2; + }) + .filter((n) => { + log.push(`f${n}`); + return n > 2; + }); +console.log("ordered", ordered.join(","), log.join(" ")); + +let seen = 0; +const counted = [1, 2, 3, 4] + .map((n) => { + seen++; + return n; + }) + .filter((n) => n <= seen); +console.log("counted", counted.join(","), seen); + +// An exception part-way through a later stage: earlier stages completed. +let mapped = 0; +try { + [1, 2, 3, 4] + .map((n) => { + mapped++; + return n; + }) + .filter((n) => { + if (n === 2) throw new Error("stop at " + n); + return true; + }); +} catch (e) { + console.log("caught", (e as Error).message, "mapped", mapped); +} + +// An exception in the first stage: the terminal never ran. +let consumed = 0; +try { + [1, 2, 3, 4] + .map((n) => { + if (n === 3) throw new Error("bad " + n); + return n; + }) + .forEach((n) => { + consumed += n; + }); +} catch (e) { + console.log("caught", (e as Error).message, "consumed", consumed); +} + +// Early exit does not skip an exception the first stage would raise. +try { + const found = [1, 2, 3].map((n) => { + if (n === 3) throw new Error("late " + n); + return n; + }).some((n) => n === 1); + console.log("found", found); +} catch (e) { + console.log("caught", (e as Error).message); +} + +// Index and array parameters. +console.log( + "indexed", + [10, 20, 30, 40].filter((n, i) => i !== 1).map((n) => n + 1).join(","), + [3, 1, 2].map((n) => n * 2).filter((n, i, arr) => arr.length === 3 && i > 0).join(","), +); + +console.log( + "positions", + [5, 6, 7, 8, 9].filter((n) => n % 2 === 1).map((n, i) => n * 10 + i).join(","), + [5, 6, 7].map((n) => n + 1).map((n, i) => `${i}:${n}`).join(","), + [1, 2, 3, 4, 5, 6].slice(2).map((n, i) => n - i).join(","), + [1, 2, 3, 4, 5].filter((n) => n !== 3).reduce((acc, n, i) => acc + n * i, 0), + keys(holes.map((n) => n).map((n, i) => i)), + holes.map((n) => n * 2).filter((n, i) => i > 2).join(","), + [9, 8, 7, 6].map((n, i, arr) => n + i + arr.length).filter((n) => n > 12).length, +); +const positions: string[] = []; +[4, 5, 6, 7].filter((n) => n > 4).forEach((n, i) => { + positions.push(`${i}=${n}`); +}); +console.log("forEach positions", positions.join(" ")); + +// Callbacks that mutate the source keep the unfused semantics. +const grow = [1, 2, 3]; +const grown = grow + .map((n) => { + if (n === 1) grow.push(9); + return n; + }) + .filter((n) => n > 0); +console.log("grow", grown.join(","), grow.length); + +// Boolean filters and string elements. +const words = ["alpha", "", "beta", "", "gamma"]; +console.log("words", words.filter(Boolean).map((w) => w.toUpperCase()).join("-")); +console.log("chars", words.map((w) => w.length).filter((n) => n > 0).reduce((a, n) => a * n, 1)); diff --git a/tests/corpus/4871-array-pipeline-analytics.ts b/tests/corpus/4871-array-pipeline-analytics.ts new file mode 100644 index 000000000..be47a32a0 --- /dev/null +++ b/tests/corpus/4871-array-pipeline-analytics.ts @@ -0,0 +1,107 @@ +// Order and request-log analytics written the idiomatic way: one +// filter/map/reduce chain per question. +interface Order { + id: number; + customer: number; + region: string; + status: string; + qty: number; + price: number; + discount: number; + day: number; +} +interface Request { + route: string; + status: number; + ms: number; + bytes: number; +} + +let seed = 77; +function random(): number { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return seed / 4294967296; +} +const regions = ["north", "south", "east", "west", "central"]; +const statuses = ["done", "done", "done", "pending", "cancelled", "refunded"]; +const routes = ["/", "/api/items", "/api/cart", "/api/user", "/static/app.js", "/health"]; + +function makeOrders(count: number): Order[] { + const out: Order[] = []; + for (let i = 0; i < count; i++) { + out.push({ + id: i, + customer: Math.floor(random() * 20000), + region: regions[Math.floor(random() * regions.length)]!, + status: statuses[Math.floor(random() * statuses.length)]!, + qty: 1 + Math.floor(random() * 9), + price: Math.floor(random() * 50000) / 100, + discount: random() < 0.3 ? Math.floor(random() * 30) / 100 : 0, + day: Math.floor(random() * 365), + }); + } + return out; +} + +function makeRequests(count: number): Request[] { + const out: Request[] = []; + for (let i = 0; i < count; i++) { + const r = random(); + out.push({ + route: routes[Math.floor(random() * routes.length)]!, + status: r < 0.9 ? 200 : r < 0.95 ? 404 : 500, + ms: Math.floor(random() * random() * 2000), + bytes: Math.floor(random() * 100000), + }); + } + return out; +} + +const scale = 0.02; +const orders = makeOrders(Math.floor(150000 * scale)); +const requests = makeRequests(Math.floor(150000 * scale)); +const lines: string[] = []; +let check = 0; + +for (let pass = 0; pass < 4; pass++) { + const minDay = pass * 30; + for (const region of regions) { + const revenue = orders + .filter((o) => o.status === "done" && o.region === region && o.day >= minDay) + .map((o) => ({ gross: o.qty * o.price, net: o.qty * o.price * (1 - o.discount) })) + .reduce((sum, line) => sum + line.net, 0); + const big = orders.filter((o) => o.region === region && o.qty * o.price > 2000).length; + if (pass < 2) lines.push(`${region} revenue=${revenue.toFixed(2)} big=${big}`); + check = (check + Math.round(revenue) + big) % 1000000007; + } + const discounted = orders + .filter((o) => o.discount > 0) + .map((o) => o.qty * o.price * o.discount) + .reduce((a, b) => a + b, 0); + const refunds = orders.filter((o) => o.status === "refunded").map((o) => o.qty).reduce((a, b) => a + b, 0); + const anyHuge = orders.some((o) => o.qty * o.price > 4490 + pass); + const allPositive = orders.map((o) => o.price).every((p) => p >= 0); + const firstLate = orders.filter((o) => o.status === "pending").findIndex((o) => o.day > 360 - pass); + check = (check + Math.round(discounted) + refunds + (anyHuge ? 1 : 0) + (allPositive ? 1 : 0) + firstLate) % 1000000007; + + for (const route of routes) { + const hits = requests.filter((q) => q.route === route); + const slow = hits.filter((q) => q.ms > 1000 + pass).length; + const errors = hits.filter((q) => q.status >= 500).length; + const traffic = hits.map((q) => q.bytes / 1024).reduce((a, kb) => a + kb, 0); + const latency = requests + .filter((q) => q.route === route && q.status === 200) + .map((q) => ({ ms: q.ms, weight: q.bytes > 50000 ? 2 : 1 })) + .reduce((acc, s) => acc + s.ms * s.weight, 0); + if (pass < 2) + lines.push(`${route} hits=${hits.length} slow=${slow} errors=${errors} kb=${Math.round(traffic)} lat=${latency}`); + check = (check + slow + errors + Math.round(traffic) + latency) % 1000000007; + } + const recent = orders.slice(orders.length - 20000).filter((o) => o.status === "done").map((o) => o.customer); + const vip = new Set(recent.filter((c) => c % 97 === pass)); + check = (check + vip.size + recent.length) % 1000000007; + const tags = orders.slice(0, 30000).flatMap((o) => (o.discount > 0 ? [o.region, o.status] : [o.region])).length; + check = (check + tags) % 1000000007; +} +for (const line of lines) console.log(line); +console.log("check", check); diff --git a/tests/corpus/4872-array-pipeline-temporaries.ts b/tests/corpus/4872-array-pipeline-temporaries.ts new file mode 100644 index 000000000..de4b9936b --- /dev/null +++ b/tests/corpus/4872-array-pipeline-temporaries.ts @@ -0,0 +1,123 @@ +// Per-element temporaries inside array callback chains: records built by +// one stage and read by the next, reduce accumulators, captured +// configuration, and consumers that write shared state. + +interface Point { + x: number; + y: number; +} +class Sample { + readonly label: string; + readonly value: number; + constructor(label: string, value: number) { + this.label = label; + this.value = value; + } +} + +const points: Point[] = []; +for (let i = 0; i < 25; i++) points.push({ x: i, y: (i * 13) % 7 }); + +function makeScale(factor: number, offset: number): (p: Point) => number { + return (p) => p.x * factor + p.y + offset; +} + +for (let round = 0; round < 3; round++) { + const scale = makeScale(round + 1, round * 2); + const scored = points + .filter((p) => p.x % 3 !== round) + .map((p) => ({ id: p.x, score: scale(p), flag: p.y > 3 })) + .filter((s) => s.flag || s.score > 20); + console.log( + "round", + round, + scored.reduce((sum, s) => sum + s.score, 0), + points.map((p) => ({ x: p.x * 2, y: p.y })).reduce((acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }), { x: 0, y: 0 }).x, + ); +} + +// The initial accumulator object comes back unchanged for an empty chain. +const seedPoint: Point = { x: -1, y: -1 }; +const none = points.filter((p) => p.x > 100).map((p) => ({ x: p.x, y: p.y })).reduce((acc, p) => (p.x > acc.x ? p : acc), seedPoint); +console.log("seed identity", none === seedPoint, none.x); +const best = points.filter((p) => p.y === 6).map((p) => ({ x: p.x, y: p.y })).reduce((acc, p) => (p.x > acc.x ? p : acc), seedPoint); +console.log("best", best === seedPoint, best.x, best.y); + +// Records with string fields, and records that escape into the result. +const labels = points + .filter((p) => p.y % 2 === 0) + .map((p) => ({ name: `p${p.x}`, size: p.y })) + .filter((r) => r.size > 0) + .map((r) => r.name) + .join(" "); +console.log("labels", labels); +const kept = points.filter((p) => p.x < 4).map((p) => ({ x: p.y, y: p.x })); +console.log("kept", kept.map((p) => `${p.x}/${p.y}`).join(",")); + +// Class instances. +const samples = points.map((p) => new Sample(p.x % 2 === 0 ? "even" : "odd", p.y)); +console.log( + "samples", + samples.filter((s) => s.label === "even").map((s) => s.value).reduce((a, b) => a + b, 0), + samples.filter((s) => s.value > 4).every((s) => s.value <= 6), +); + +// Consumers that write shared state the stages never read. +let evens = 0; +let largest = -Infinity; +points + .filter((p) => p.x % 2 === 0) + .map((p) => p.y * 10) + .forEach((v) => { + evens++; + if (v > largest) largest = v; + }); +console.log("forEach state", evens, largest); + +// A consumer that throws after earlier elements were consumed. +let partial = 0; +try { + points + .filter((p) => p.x > 2) + .map((p) => p.x) + .forEach((x) => { + if (x === 7) throw new RangeError("seven"); + partial += x; + }); +} catch (e) { + console.log("forEach threw", (e as Error).message, partial); +} + +// A stage that reads state the consumer writes keeps unfused order. +let budget = 3; +const spent = points + .filter((p) => budget > 0) + .map((p) => p.x) + .reduce((acc, x) => { + budget--; + return acc + x; + }, 0); +console.log("budget", spent, budget); + +// flatMap with holes and explicit undefined in the returned arrays. +const inner: (number | undefined)[] = [1, undefined, 3]; +const sparseInner: number[] = []; +sparseInner[2] = 5; +console.log( + "flatMap", + [0, 1].flatMap(() => inner).length, + [0, 1].flatMap(() => sparseInner).length, + [0, 1, 2].flatMap((n) => [n, n * 10]).filter((n) => n > 0).map((n) => n + 1).join(","), + [0, 1].flatMap(() => inner).map((v) => (v === undefined ? "u" : String(v))).join(""), +); + +// Chains inside callbacks of other chains. +const grid = [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], +]; +console.log( + "nested", + grid.map((row) => row.filter((v) => v % 2 === 1).map((v) => v * v).reduce((a, b) => a + b, 0)).filter((s) => s > 10).join(","), +);