From 0af135108b7de09f97850635c9b89af71eccacd5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 19:52:06 -0500 Subject: [PATCH 1/3] Emit each loop once in the body a parameter version leaves --- .../src/backend/llvm/emit-function.ts | 2 +- .../src/backend/llvm/emit-statements.ts | 9 +++ .../compiler/src/backend/llvm/loop-state.ts | 11 ++- packages/compiler/src/ir/ir.ts | 5 ++ .../compiler/src/ir/parameter-versions.ts | 4 +- .../test/parameter-version-emission.test.ts | 21 ++++++ .../loop-bound-parameter-plain-loops.ts.json | 6 ++ .../loop-bound-parameter-plain-loops.ts | 74 +++++++++++++++++++ 8 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/loop-bound-parameter-plain-loops.ts.json create mode 100644 tests/corpus/loop-bound-parameter-plain-loops.ts diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts index aab4459e0..8bebafc87 100644 --- a/packages/compiler/src/backend/llvm/emit-function.ts +++ b/packages/compiler/src/backend/llvm/emit-function.ts @@ -148,7 +148,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { emitter.integerTempsEnabled = emitter.debug === null && !fn.async && !fn.generator; beginFunctionLoops(emitter, fn, numericFn); emitter.speculativeLoopsEnabled = LOOPS.of(emitter).countedLoopsEnabled; - emitter.loopCopiesEnabled = true; + emitter.loopCopiesEnabled = fn.plainLoops !== true; emitter.speculativeFunction = fn.name; emitter.speculation = null; emitter.speculationResume = null; diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index 5eed52dce..e72afdbf0 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -1147,6 +1147,14 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { (countedLoop?.guarded || pairedLoop?.guarded) && !LOOPS.of(emitter).provenCountedGuards.has(s) ) { + // A function that emits each loop once takes only the copy without + // the integer form, which needs no safety test. + if (LOOPS.of(emitter).plainLoops) { + LOOPS.of(emitter).countedLoopsEnabled = false; + emitSlowCopy(emitter, s); + LOOPS.of(emitter).countedLoopsEnabled = true; + break; + } const safe = countedLoop ? emitCountedLoopSafety(emitter, countedLoop) : emitPairedLoopSafety(emitter, pairedLoop!); @@ -1318,6 +1326,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { // longer runs take the loop as it is emitted otherwise. const shortRuns: { stride: number; left: () => string } | null = !innermost || + LOOPS.of(emitter).plainLoops || bounded || capturedLets.length > 0 || !emitter.workerLoopPolls(s) || diff --git a/packages/compiler/src/backend/llvm/loop-state.ts b/packages/compiler/src/backend/llvm/loop-state.ts index be33ae517..e044b09bc 100644 --- a/packages/compiler/src/backend/llvm/loop-state.ts +++ b/packages/compiler/src/backend/llvm/loop-state.ts @@ -27,10 +27,11 @@ export function beginFunctionLoops( loops.unaliasedArrays = findUnaliasedLocalArrays(fn); loops.countedLoopsEnabled = emitter.debug === null && !fn.async && !fn.generator; loops.byteWindowsEnabled = loops.countedLoopsEnabled; - loops.bytesLoopVersionsEnabled = loops.countedLoopsEnabled; + loops.plainLoops = fn.plainLoops === true; + loops.bytesLoopVersionsEnabled = loops.countedLoopsEnabled && !loops.plainLoops; loops.bytesLoopEntries = null; loops.bytesLoopFunction = fn; - loops.denseLoopsEnabled = loops.countedLoopsEnabled; + loops.denseLoopsEnabled = loops.countedLoopsEnabled && !loops.plainLoops; loops.denseLoop = null; loops.denseLoopEntries = loops.denseLoopsEnabled && numericFn.locals.some((l) => l.type.kind === "array") @@ -65,8 +66,11 @@ export interface LoopState { byteWindowsEnabled: boolean; byteWindowEntries: ReturnType; /** Typed-array loop versioning (bytes-loop-versions.ts) for the current - * function: off in debug and suspending functions and inside a copy. */ + * function: off in debug and suspending functions, in a function that + * emits each loop once and inside a copy. */ bytesLoopVersionsEnabled: boolean; + /** The current function emits each loop once (IrFunction.plainLoops). */ + plainLoops: boolean; /** Loops being versioned around the statement being emitted: a loop is * never versioned inside its own copies, and versions nest boundedly. */ readonly bytesLoopVersioning: Set; @@ -89,6 +93,7 @@ export const LOOPS = defineModuleState(() => ({ byteWindowsEnabled: true, byteWindowEntries: new Map(), bytesLoopVersionsEnabled: false, + plainLoops: false, bytesLoopVersioning: new Set(), bytesLoopEntries: null, bytesLoopFunction: null, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index c58d287fa..a49ef234e 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -2000,6 +2000,11 @@ export interface IrFunction { * `index`: a computed read `(value, key)` whose callers pass a number * converted to a string as the key. */ fastRead?: "index"; + /** The body of a function whose parameter version takes its calls with + * int32 arguments (parameter-versions.ts). It runs for other arguments + * only, so the backend emits each of its loops once, as in the copy a + * failed version guard runs. */ + plainLoops?: true; /** Async: the body runs on a fiber; `returnType` is the INNER type T (a * `return v` fulfills with v) while ordinary call sites receive * Promise. With `generator` present, call sites receive the lazy diff --git a/packages/compiler/src/ir/parameter-versions.ts b/packages/compiler/src/ir/parameter-versions.ts index ca2958585..0732baf61 100644 --- a/packages/compiler/src/ir/parameter-versions.ts +++ b/packages/compiler/src/ir/parameter-versions.ts @@ -171,7 +171,8 @@ function loopBounds(fn: IrFunction, functions: ReadonlyMap): * -0 (`1 / p` is not -Infinity). The copy then receives `p | 0`, which is * the same double, so it computes what the body would; its only caller * proves the parameter an int32, so it gets the i32 entry. Other values - * run the body as before. + * run the body as before, whose loops the backend then emits once + * (`plainLoops`). * * Only plain synchronous functions qualify: not class members, no * captures, not the module entry, not dynamic-tier, at most @@ -301,6 +302,7 @@ export function versionLoopBoundParameters( : [{ kind: "return", value: call, loc }]; replaced.set(fn, { ...fn, + plainLoops: true, body: [{ kind: "if", cond: test!, then: run, else_: null, loc }, ...fn.body], }); } diff --git a/packages/compiler/test/parameter-version-emission.test.ts b/packages/compiler/test/parameter-version-emission.test.ts index eb6daff56..77c9d1a2f 100644 --- a/packages/compiler/test/parameter-version-emission.test.ts +++ b/packages/compiler/test/parameter-version-emission.test.ts @@ -65,6 +65,27 @@ test("a loop-bounding parameter some caller passes unproven gets an int32 versio expect(body).toMatch(/fdiv double 0x3FF0000000000000, %\S+/); }); +test("the body a version leaves emits each loop once", async () => { + const llvm = await emitLlvm(` +const fact: number[] = []; +function fillFact(n: number): void { + fact[0] = 1; + for (let i = 1; i <= n; i++) fact[i] = fact[i - 1]! * i; +} +fillFact(Number(process.argv.length > 5 ? "7.5" : "6")); +console.log(fact.join(" ")); +`); + expect(versions(llvm)).toEqual(["fillFact"]); + const loopBodies = (name: string): number => { + const body = new RegExp(`^define [^@]*@sc_\\w+?_${name}\\([^]*?^}`, "m").exec(llvm)?.[0] ?? ""; + return body.match(/^loop\.b\d+:/gm)?.length ?? 0; + }; + // The body runs only for numbers the version does not take: its loop gets + // no speculative integer copy beside the ordinary one. + expect(loopBodies("fillFact")).toBe(1); + expect(loopBodies("fillFact_int32Params\\.i32")).toBe(1); +}); + test("a module that reads stacks keeps a single body", async () => { const llvm = await emitLlvm(`${loops}\nconsole.log(typeof new Error("x").stack);\n`); expect(versions(llvm)).toEqual([]); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/loop-bound-parameter-plain-loops.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/loop-bound-parameter-plain-loops.ts.json new file mode 100644 index 000000000..a148a964e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/loop-bound-parameter-plain-loops.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/loop-bound-parameter-plain-loops.ts" + ], + "diags": [] +} diff --git a/tests/corpus/loop-bound-parameter-plain-loops.ts b/tests/corpus/loop-bound-parameter-plain-loops.ts new file mode 100644 index 000000000..fa9e38bad --- /dev/null +++ b/tests/corpus/loop-bound-parameter-plain-loops.ts @@ -0,0 +1,74 @@ +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; + +// Functions whose count parameters bound array loops: nested, counted, +// two-counter and dense array loops, called from the main thread and a +// worker with counts no caller proves to be int32s: integers, fractions, +// NaN, -0, negatives and runs longer than a worker loop's unpolled run. +// Counts past an array's end read undefined. + +const LENGTH = 40000; + +function rowSums(u: number[], v: number[], n: number, rows: number): void { + for (let i = 0; i < rows; i++) { + let t = 0; + for (let j = 0; j < n; j++) t += u[j]! / (i + j + 1); + v[i] = t; + } +} + +function scale(src: number[], dst: number[], n: number, k: number): number { + let sum = 0; + for (let i = 0; i < n && i < dst.length; i++) { + dst[i] = (src[i]! * k) | 0; + sum += dst[i]!; + } + return sum; +} + +function reverseRange(values: number[], n: number): number { + for (let lo = 0, hi = (n | 0) - 1; lo < hi; lo++, hi--) { + const t = values[lo]!; + values[lo] = values[hi]!; + values[hi] = t; + } + let check = 0; + for (let i = 0; i < 40; i++) check = (check * 31 + values[i]!) % 1000003; + return check; +} + +function countSevens(values: number[], n: number, step: number): number { + let seen = 0; + for (let i = 0; i < n; i += step) if (values[i] === 7) seen++; + return seen; +} + +function show(value: number): string { + return Object.is(value, -0) ? "-0" : Number.isInteger(value) ? String(value) : value.toFixed(6); +} + +function run(label: string, n: number): string { + const u: number[] = []; + const ints: number[] = []; + for (let i = 0; i < LENGTH; i++) { + u.push(1 + (i % 13) * 0.25); + ints.push((i % 101) * 7 - 300); + } + const v = [0, 0, 0, 0]; + const out = new Array(LENGTH).fill(0); + rowSums(u, v, n, 4); + const scaled = scale(ints, out, n, 3); + const reversed = reverseRange(ints.map((x) => x % 10), n); + const sevens = countSevens(ints.map((x) => (x + 300) % 11), n, 3); + return `${label} ${show(n)}: rows ${v.map(show).join(" ")}; scaled ${scaled}; reversed ${reversed}; sevens ${sevens}`; +} + +const counts: number[] = JSON.parse("[12, 12.5, 0, -0, -3, 7.25, 16385, 39999.5, 40000.5, 50000]"); +counts.push(NaN, Number("9")); + +if (isMainThread) { + for (const n of counts) console.log(run("main", n)); + const worker = new Worker(new URL(import.meta.url), { workerData: counts }); + worker.on("message", (line: string) => console.log(line)); +} else { + for (const n of workerData as number[]) parentPort!.postMessage(run("worker", n)); +} From 4b8894a8bf55d8ece86559910ae3df6c5fe50a23 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 20:36:01 -0500 Subject: [PATCH 2/3] Check strip-mining in the int32 parameter version of the bounded-loop test `open` in the bounded-loop poll test is versioned on its loop bounds, so its strip-mined loops are in its int32 parameter version. The test now checks them there, and checks that the body the version leaves polls its loops without strip-mining. Also records the CI cost of the loop-bound-parameter-plain-loops corpus program. --- packages/compiler/test/counted-loop-emission.test.ts | 11 +++++++++-- .../loop-bound-parameter-plain-loops.ts.json | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 tests/harness/corpus-costs/loop-bound-parameter-plain-loops.ts.json diff --git a/packages/compiler/test/counted-loop-emission.test.ts b/packages/compiler/test/counted-loop-emission.test.ts index 30a139a81..cec0a205b 100644 --- a/packages/compiler/test/counted-loop-emission.test.ts +++ b/packages/compiler/test/counted-loop-emission.test.ts @@ -280,9 +280,10 @@ test("worker programs poll no loop whose proven ranges bound its runs inside ano expect(rows).not.toMatch(/^loop\.poll\d+:$/m); expect(polls(rows)).toBeGreaterThan(0); // With a limit known only at run time the inner loops keep their polls. - // The innermost one is strip-mined, and a block that ends at the limit + // `open`'s int32 parameter version takes calls with int32 arguments. Its + // innermost loop is strip-mined, and a block that ends at the limit // leaves it without polling: only a block that ends short of it polls. - const open = body("open"); + const open = body("open_int32Params"); expect(polls(open)).toBeGreaterThan(polls(rows)); expect(open).toMatch(/^loop\.poll\d+:$/m); const ends = [...open.matchAll(/^loop\.end\d+:$[\s\S]*?^\S/gm)].map((match) => match[0]); @@ -291,6 +292,12 @@ test("worker programs poll no loop whose proven ranges bound its runs inside ano // A run of up to 16384 iterations is one block, polled as it leaves when // it took more than 64. expect(open).toMatch(/^loop\.poll\.run\d+:$/m); + // The body the version leaves runs for other arguments and emits each + // loop once: its loops poll without strip-mining. + const generic = body("open"); + expect(polls(generic)).toBeGreaterThan(polls(rows)); + expect(generic).not.toMatch(/^loop\.end\d+:$/m); + expect(generic).not.toMatch(/^loop\.poll\.run\d+:$/m); // Steps of 1 below 2^53 are exact, so 50 runs near it are bounded. From // 2^53, `j + 1 === j`: a counter ranging up to it can run without end // and keeps its poll. diff --git a/tests/harness/corpus-costs/loop-bound-parameter-plain-loops.ts.json b/tests/harness/corpus-costs/loop-bound-parameter-plain-loops.ts.json new file mode 100644 index 000000000..864b83e6f --- /dev/null +++ b/tests/harness/corpus-costs/loop-bound-parameter-plain-loops.ts.json @@ -0,0 +1 @@ +{ "plain": 4906, "san": 58104 } From 141464a6f7e930e74202a19d654df8aa9b9093a0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 21:43:38 -0500 Subject: [PATCH 3/3] Encode plainLoops in the generated IR codec `IrFunction.plainLoops` was missing from codec.generated.ts, so IR read back through the binary codec lost it. Regenerated with scripts/generate-ir-codec.mjs. --- packages/compiler/src/ir/codec.generated.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/compiler/src/ir/codec.generated.ts b/packages/compiler/src/ir/codec.generated.ts index 064757b5e..e576b30e2 100644 --- a/packages/compiler/src/ir/codec.generated.ts +++ b/packages/compiler/src/ir/codec.generated.ts @@ -5,7 +5,7 @@ import type { IrReader, IrWriter } from "./codec.js"; /** A digest of the coders below: an encoding written with other coders is * refused instead of misread, also after a change that keeps IR_VERSION. */ -export const IR_CODEC_LAYOUT = "6493ba8347f973e2"; +export const IR_CODEC_LAYOUT = "5940048d92e96642"; /** Writes `mod` field by field (codec.ts describes the bytes). */ export function encodeIrModule(w: IrWriter, mod: IrModule): void { @@ -25,7 +25,7 @@ export function decodeIrFunction(r: IrReader): IrFunction { } const KEYS0: readonly string[] = ["irVersion","sourceFile","functions","classes","globals","records","unions","entry","workers","declBoundary","inferredFactFiles","declOrigins","lifetimeRegions","foreignElementKeys","threadSlotKeys","freezableArrays","sloppyDynWrites","untypedIntegrity","commonJsEntry","nodeIsland","ffiImports","lib","contracts","int32ArrayCandidates","wasmModules","wasmUntranslated","wasmCustomSections"]; -const KEYS1: readonly string[] = ["name","sourceName","ownsPrototype","params","returnType","locals","captures","classCaptures","body","loc","tier","speculativeDispatch","fastRead","async","generator","asyncCacheGlobal","asyncCycleCacheGlobal"]; +const KEYS1: readonly string[] = ["name","sourceName","ownsPrototype","params","returnType","locals","captures","classCaptures","body","loc","tier","speculativeDispatch","fastRead","plainLoops","async","generator","asyncCacheGlobal","asyncCycleCacheGlobal"]; const KEYS2: readonly string[] = ["libnode","nodeVersion","modules"]; const KEYS3: readonly string[] = ["profileName","prefix","initSymbol","sinkRegisterSymbol","collectSymbol","resultResetSymbol","threadInstances","callbackRegisterSymbol","callbacks","exports","trapOverlays","identity"]; const KEYS4: readonly string[] = ["int32Fields","int32Params","int32Returns","int32Locals","int32Elements","noThrow","acyclic","immortalGlobals","programLifetime","inlineArrays"]; @@ -439,6 +439,11 @@ function encIrFunction(w: IrWriter, v: IrFunction): void { w.byte(1); w.str(v.fastRead); } + if (v.plainLoops === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.plainLoops); + } if (v.async === undefined) w.byte(0); else { w.byte(1); @@ -474,11 +479,12 @@ function decIrFunction(r: IrReader): IrFunction { const f10 = r.tag(2, "IrFunction.tier") === 0 ? undefined : (r.str() as NonNullable); const f11 = r.tag(2, "IrFunction.speculativeDispatch") === 0 ? undefined : r.bool(); const f12 = r.tag(2, "IrFunction.fastRead") === 0 ? undefined : (r.str() as NonNullable); - const f13 = r.tag(2, "IrFunction.async") === 0 ? undefined : (r.bool() as NonNullable); - const f14 = r.tag(2, "IrFunction.generator") === 0 ? undefined : decT21(r); - const f15 = r.tag(2, "IrFunction.asyncCacheGlobal") === 0 ? undefined : r.str(); - const f16 = r.tag(2, "IrFunction.asyncCycleCacheGlobal") === 0 ? undefined : r.str(); - const o: IrFunction = { name: f0, ...(f1 !== undefined ? { sourceName: f1 } : {}), ...(f2 !== undefined ? { ownsPrototype: f2 } : {}), params: f3, returnType: f4, locals: f5, ...(f6 !== undefined ? { captures: f6 } : {}), ...(f7 !== undefined ? { classCaptures: f7 } : {}), body: f8, loc: f9, ...(f10 !== undefined ? { tier: f10 } : {}), ...(f11 !== undefined ? { speculativeDispatch: f11 } : {}), ...(f12 !== undefined ? { fastRead: f12 } : {}), ...(f13 !== undefined ? { async: f13 } : {}), ...(f14 !== undefined ? { generator: f14 } : {}), ...(f15 !== undefined ? { asyncCacheGlobal: f15 } : {}), ...(f16 !== undefined ? { asyncCycleCacheGlobal: f16 } : {}) }; + const f13 = r.tag(2, "IrFunction.plainLoops") === 0 ? undefined : (r.bool() as NonNullable); + const f14 = r.tag(2, "IrFunction.async") === 0 ? undefined : (r.bool() as NonNullable); + const f15 = r.tag(2, "IrFunction.generator") === 0 ? undefined : decT21(r); + const f16 = r.tag(2, "IrFunction.asyncCacheGlobal") === 0 ? undefined : r.str(); + const f17 = r.tag(2, "IrFunction.asyncCycleCacheGlobal") === 0 ? undefined : r.str(); + const o: IrFunction = { name: f0, ...(f1 !== undefined ? { sourceName: f1 } : {}), ...(f2 !== undefined ? { ownsPrototype: f2 } : {}), params: f3, returnType: f4, locals: f5, ...(f6 !== undefined ? { captures: f6 } : {}), ...(f7 !== undefined ? { classCaptures: f7 } : {}), body: f8, loc: f9, ...(f10 !== undefined ? { tier: f10 } : {}), ...(f11 !== undefined ? { speculativeDispatch: f11 } : {}), ...(f12 !== undefined ? { fastRead: f12 } : {}), ...(f13 !== undefined ? { plainLoops: f13 } : {}), ...(f14 !== undefined ? { async: f14 } : {}), ...(f15 !== undefined ? { generator: f15 } : {}), ...(f16 !== undefined ? { asyncCacheGlobal: f16 } : {}), ...(f17 !== undefined ? { asyncCycleCacheGlobal: f17 } : {}) }; return o; }