From 2b056343ee217ff15310b10599623d3d9fcacc60 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:39:28 -0700 Subject: [PATCH 1/2] Run counted loops from an unproven start as integer loops `for (let i = start; i < end; i++)` kept a double counter when the start had no proven integer range, as in a hash over `[start, end)` whose bounds come from fields. Such a loop is now versioned like one with an unproven limit: when the start is an exact integer of at most 2^53 - 1 in magnitude (compared bit for bit, so -0 takes the original loop), the integer-counter copy runs. The usual bounds on duplicated bodies apply. --- packages/compiler/src/backend/llvm/emitter.ts | 45 ++++++++++++++++--- .../compiler/src/ir/integer-loops.test.ts | 18 ++++++++ packages/compiler/src/ir/integer-loops.ts | 28 ++++++++++-- tests/corpus/exact-index-round-trip.ts | 14 ++++++ 4 files changed, 96 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 26e0d95cf..d98165faf 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -6051,14 +6051,49 @@ export class LlEmitter { } let countedJoin: string | null = null; if (countedLoop?.guarded) { - const bound = this.emitExpr(countedLoop.limit); - const safe = B.tmp(); + let safe = "true"; + if (countedLoop.limitGuarded) { + const bound = this.emitExpr(countedLoop.limit); + safe = B.tmp(); + B.line( + `${safe} = fcmp ${countedLoop.step > 0 ? "ole" : "oge"} double ${bound.name}, ${f64Lit(countedLoop.guardLimit)}`, + ); + } + if (countedLoop.startGuarded) { + // An exact integer of at most 2^53 - 1 in magnitude, and not -0 + // (the bit patterns of the value and its round trip agree). + // NaN and infinities fail the ordered range tests. + const start = this.emitExpr(countedLoop.start); + const low = B.tmp(), + high = B.tmp(), + raw = B.tmp(), + wide = B.tmp(), + back = B.tmp(), + bits = B.tmp(), + backBits = B.tmp(), + exact = B.tmp(), + inRange = B.tmp(), + ok = B.tmp(); + B.line(`${low} = fcmp oge double ${start.name}, ${f64Lit(-Number.MAX_SAFE_INTEGER)}`); + B.line(`${high} = fcmp ole double ${start.name}, ${f64Lit(Number.MAX_SAFE_INTEGER)}`); + B.line(`${inRange} = and i1 ${low}, ${high}`); + B.line(`${raw} = fptosi double ${start.name} to i64`); + B.line(`${wide} = freeze i64 ${raw}`); + B.line(`${back} = sitofp i64 ${wide} to double`); + B.line(`${bits} = bitcast double ${start.name} to i64`); + B.line(`${backBits} = bitcast double ${back} to i64`); + B.line(`${exact} = icmp eq i64 ${bits}, ${backBits}`); + B.line(`${ok} = and i1 ${inRange}, ${exact}`); + if (safe === "true") safe = ok; + else { + const both = B.tmp(); + B.line(`${both} = and i1 ${safe}, ${ok}`); + safe = both; + } + } const fast = B.newLabel("counted.fast"); const slow = B.newLabel("counted.slow"); countedJoin = B.newLabel("counted.done"); - B.line( - `${safe} = fcmp ${countedLoop.step > 0 ? "ole" : "oge"} double ${bound.name}, ${f64Lit(countedLoop.guardLimit)}`, - ); B.condBr(safe, fast, slow); B.startBlock(slow); this.countedLoopsEnabled = false; diff --git a/packages/compiler/src/ir/integer-loops.test.ts b/packages/compiler/src/ir/integer-loops.test.ts index 61cea9810..89b2d57bf 100644 --- a/packages/compiler/src/ir/integer-loops.test.ts +++ b/packages/compiler/src/ir/integer-loops.test.ts @@ -164,3 +164,21 @@ test("versioning is bounded and requires an integer consumer", () => { expect(matchIntegerCountedForLoop(nested, withLimit)).toBeNull(); expect(matchIntegerCountedForLoop(counted(number(4)), locals)?.guarded).toBe(false); }); + +test("an unproven start versions the loop on an exact integer start", () => { + const withStart = new Map(locals); + withStart.set("start", { id: "start", name: "start", type: F64, mutable: false }); + const s = counted(number(8)); + s.init = { kind: "varDecl", localId: "i", init: ref("start"), loc }; + expect(matchIntegerCountedForLoop(s, withStart)).toMatchObject({ + guarded: true, + startGuarded: true, + limitGuarded: false, + range: { min: -Number.MAX_SAFE_INTEGER, max: 7 }, + }); + // The versioned body is duplicated, so the usual size bound applies. + const large = counted(number(8)); + large.init = { kind: "varDecl", localId: "i", init: ref("start"), loc }; + large.body = Array.from({ length: 81 }, () => large.body[0]!); + expect(matchIntegerCountedForLoop(large, withStart)).toBeNull(); +}); diff --git a/packages/compiler/src/ir/integer-loops.ts b/packages/compiler/src/ir/integer-loops.ts index 3b46cadf5..6daaf5bd1 100644 --- a/packages/compiler/src/ir/integer-loops.ts +++ b/packages/compiler/src/ir/integer-loops.ts @@ -20,6 +20,11 @@ export interface IntegerCountedForLoop { step: number; inclusive: boolean; guarded: boolean; + /** The limit needs the run-time `guardLimit` test (part of `guarded`). */ + limitGuarded: boolean; + /** The start has no proven integer range: the versioned loop also tests + * that it is an exact integer (not -0) of at most 2^53 in magnitude. */ + startGuarded: boolean; guardLimit: number; range: IntegerRange; } @@ -104,10 +109,13 @@ export function matchIntegerCountedForLoop( const local = locals.get(init.localId); if (local?.type.kind !== "f64" || !local.mutable || local.boxed || local.tdz) return null; const start = init.init; - const startRange = + let startRange = start.kind === "numLit" && Number.isSafeInteger(start.value) && !Object.is(start.value, -0) ? { min: start.value, max: start.value } : ranges.get(start); + // An unproven start is tested at run time, on a versioned loop. + const startGuarded = !startRange && start.kind !== "numLit" && start.type.kind === "f64"; + if (startGuarded) startRange = { min: -Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER }; if ( !startRange || startRange.min < -Number.MAX_SAFE_INTEGER || @@ -132,9 +140,10 @@ export function matchIntegerCountedForLoop( const guardLimit = step > 0 ? 2 ** 53 - (inclusive ? 1 : 0) : -(2 ** 53) + (inclusive ? 1 : 0); const boundRange = limit.kind === "numLit" ? { min: limit.value, max: limit.value } : ranges.get(limit); - const guarded = + const limitGuarded = !boundRange || (step > 0 ? !(boundRange.max <= guardLimit) : !(boundRange.min >= guardLimit)); - if (guarded && limit.kind === "numLit") return null; + if (limitGuarded && limit.kind === "numLit") return null; + const guarded = limitGuarded || startGuarded; let useful = false; let cost = 0; const counter = (e: IrExpr): boolean => @@ -204,7 +213,18 @@ export function matchIntegerCountedForLoop( ), max: startRange.max, }; - return { localId: local.id, start, limit, step, inclusive, guarded, guardLimit, range }; + return { + localId: local.id, + start, + limit, + step, + inclusive, + guarded, + limitGuarded, + startGuarded, + guardLimit, + range, + }; } /** True when a lowered subtree writes `localId`. Local ids are unique per diff --git a/tests/corpus/exact-index-round-trip.ts b/tests/corpus/exact-index-round-trip.ts index c3e8a6d9d..51748a5b2 100644 --- a/tests/corpus/exact-index-round-trip.ts +++ b/tests/corpus/exact-index-round-trip.ts @@ -96,3 +96,17 @@ console.log( rangeHash(cursor.units, 0.5, 4), rangeHash(cursor.units, opaque(odd, 1), opaque(odd, 3)), ); + +// Counted loops from an unproven start run as integer loops only when the +// start is an exact integer; -0, fractions, NaN and huge starts keep the +// double loop, and the counter's own value stays observable. +function observe(chars: Uint16Array, start: number, end: number): string { + const seen: string[] = []; + for (let i = start; i < end; i++) { + seen.push(`${Object.is(i, -0) ? "-0" : i}:${chars[i] ^ 1}:${(i * 2) | 0}`); + if (seen.length > 6) break; + } + return seen.join(" "); +} +for (const start of [0, -0, 1, 2.5, -2, NaN, 2 ** 53 - 2, -(2 ** 53), 1e300, -Infinity]) + console.log(String(start), observe(cursor.units, opaque([start], 0), opaque([start + 4, 9], start === -Infinity ? 1 : 0))); From b552bc135467816e49c06a725bb5a04cc0a31bcf Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:39:28 -0700 Subject: [PATCH 2/2] Read module constants as their literal values `const DOT = 0x2e` at module scope was a global that every reader loaded (a thread-local load in worker programs), hiding the value from integer ranges, switch dispatch and LLVM. foldConstantGlobals rewrites reads of an immutable number or boolean global that the program writes exactly once, with a literal, and that has neither a TDZ sentinel nor an initialization flag (globals that can be read early keep their checked load). The store stays, so namespace objects still find the value. Library builds keep their globals. --- packages/compiler/src/backend/llvm/emitter.ts | 5 +- .../compiler/src/ir/constant-globals.test.ts | 95 +++++++++++++++++++ packages/compiler/src/ir/constant-globals.ts | 76 +++++++++++++++ .../corpus/constant-module-globals/consts.ts | 10 ++ tests/corpus/constant-module-globals/main.ts | 51 ++++++++++ 5 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/src/ir/constant-globals.test.ts create mode 100644 packages/compiler/src/ir/constant-globals.ts create mode 100644 tests/corpus/constant-module-globals/consts.ts create mode 100644 tests/corpus/constant-module-globals/main.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 4f83f89df..26e0d95cf 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -100,6 +100,7 @@ import { everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse import { analyzeIntegerRanges, INT32_RANGE, type IntegerRanges } from "../../ir/integer-ranges.js"; import { analyzeInt32Slots, type Int32Slots } from "../../ir/int32-slots.js"; import { analyzeFinalFields, constructorClass, type FinalFields } from "../../ir/final-fields.js"; +import { foldConstantGlobals } from "../../ir/constant-globals.js"; import { findIntegerViews } from "./integer-views.js"; import { findInitializerBindings, withInitializerBindings } from "../../ir/initializer-bindings.js"; import { findConstantNumericTables, type ConstantNumericTable } from "../../ir/constant-tables.js"; @@ -405,7 +406,7 @@ export function emitLlvmModule(mod: IrModule, options: LlvmTargetOptions = {}): // Keep source storage intact for debugger inspection in dev builds. return new LlEmitter( options.debugSources === undefined - ? scalarizeNumericRecords(specializeNumericCalls(mod, mangleFunction)) + ? scalarizeNumericRecords(specializeNumericCalls(foldConstantGlobals(mod), mangleFunction)) : mod, options, ).emit(); @@ -418,7 +419,7 @@ export function emitLlvmModuleSource( ): string | readonly string[] { const parts = new LlEmitter( options.debugSources === undefined - ? scalarizeNumericRecords(specializeNumericCalls(mod, mangleFunction)) + ? scalarizeNumericRecords(specializeNumericCalls(foldConstantGlobals(mod), mangleFunction)) : mod, options, ).emitParts(); diff --git a/packages/compiler/src/ir/constant-globals.test.ts b/packages/compiler/src/ir/constant-globals.test.ts new file mode 100644 index 000000000..2f5627885 --- /dev/null +++ b/packages/compiler/src/ir/constant-globals.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from "vitest"; +import { foldConstantGlobals } from "./constant-globals.js"; +import { F64, VOID, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js"; + +const loc = { file: "constant-globals.ts", start: 0, end: 0 }; +const num = (value: number): IrExpr => ({ loc, kind: "numLit", value, type: F64 }); +const ref = (localId: string): IrExpr => ({ loc, kind: "varRef", localId, type: F64 }); +const assign = (localId: string, value: IrExpr): IrStmt => ({ + loc, + kind: "assign", + localId, + value, +}); +const ret = (value: IrExpr): IrStmt => ({ loc, kind: "return", value }); +const fn = (name: string, body: IrStmt[]): IrFunction => ({ + loc, + name, + params: [], + locals: [], + returnType: name === "%init" ? VOID : F64, + body, +}); +const global = (id: string, extra: object = {}) => ({ + id, + name: id, + type: F64, + mutable: false, + ...extra, +}); + +test("immutable globals stored once with a literal read as that literal", () => { + const mod: IrModule = { + irVersion: 15, + sourceFile: "constant-globals.ts", + entry: "%init", + functions: [ + fn("%init", [ + assign("%g.DOT", num(46)), + assign("%g.LATE", num(7)), + assign("%g.TWICE", num(1)), + assign("%g.TWICE", num(2)), + assign("%g.MUT", num(3)), + ]), + fn("read", [ + ret({ + loc, + kind: "bin", + op: "+", + left: ref("%g.DOT"), + right: { + loc, + kind: "bin", + op: "+", + left: ref("%g.LATE"), + right: { + loc, + kind: "bin", + op: "+", + left: ref("%g.TWICE"), + right: ref("%g.MUT"), + type: F64, + }, + type: F64, + }, + type: F64, + }), + ]), + ], + classes: [], + records: [], + unions: [], + globals: [ + global("%g.DOT"), + global("%g.LATE", { initFlag: "%g.LATE.initialized" }), + global("%g.TWICE"), + global("%g.MUT", { mutable: true }), + ], + }; + const folded = foldConstantGlobals(mod); + const reads: string[] = []; + const walk = (e: IrExpr): void => { + if (e.kind === "varRef") reads.push(e.localId); + else if (e.kind === "numLit") reads.push(String(e.value)); + else if (e.kind === "bin") { + walk(e.left); + walk(e.right); + } + }; + const body = folded.functions.find((f) => f.name === "read")!.body[0]!; + if (body.kind === "return" && body.value) walk(body.value); + expect(reads).toEqual(["46", "%g.LATE", "%g.TWICE", "%g.MUT"]); + // The initializer keeps its store; the input module is not modified. + expect(folded.functions[0]).toBe(mod.functions[0]); + expect(mod.functions[1]!.body[0]).not.toBe(body); +}); diff --git a/packages/compiler/src/ir/constant-globals.ts b/packages/compiler/src/ir/constant-globals.ts new file mode 100644 index 000000000..f18764223 --- /dev/null +++ b/packages/compiler/src/ir/constant-globals.ts @@ -0,0 +1,76 @@ +import type { IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js"; +import { everyStmtList, mapExprChildren, mapStmtChildren } from "./traverse.js"; + +type Literal = (IrExpr & { kind: "numLit" }) | (IrExpr & { kind: "boolLit" }); + +/** Module constants such as `const DOT = 0x2e` read as their literal. + * + * A module-level binding is a global that the module initializer stores + * once; every other function loads it (a thread-local load in worker + * programs), which also hides the value from integer ranges, switch + * dispatch and LLVM's constant folding. When the global is immutable, has + * exactly one write in the program and that write stores a number or + * boolean literal, every read sees that literal: reads that could run before + * the store carry the frontend's initialization flag (`initFlag`) or a TDZ + * sentinel, and those globals are left alone. The store stays, so module + * namespace views and debuggers still find the value in the global. Library + * builds keep their globals: a host may observe them across the boundary. */ +export function foldConstantGlobals(mod: IrModule): IrModule { + if (mod.lib || !mod.globals?.length) return mod; + const candidates = new Set(); + for (const g of mod.globals) + if ( + !g.mutable && + g.tdz !== true && + g.initFlag === undefined && + (g.type.kind === "f64" || g.type.kind === "bool") + ) + candidates.add(g.id); + if (candidates.size === 0) return mod; + + const writes = new Map(); + const values = new Map(); + const write = (id: string, value: IrExpr | null): void => { + if (!candidates.has(id)) return; + writes.set(id, (writes.get(id) ?? 0) + 1); + if (value && (value.kind === "numLit" || value.kind === "boolLit")) values.set(id, value); + else values.delete(id); + }; + for (const fn of mod.functions) + everyStmtList(fn.body, { + // Every node other than a read that names the binding is a write + // (assignments, loop and catch bindings, compound updates); only a + // plain assignment or declaration contributes a value. + stmt: (s) => { + if (s.kind === "assign") write(s.localId, s.value); + else if (s.kind === "varDecl") write(s.localId, s.init); + else if ("localId" in s && typeof s.localId === "string") write(s.localId, null); + return true; + }, + expr: (e) => { + if (e.kind !== "varRef" && "localId" in e && typeof e.localId === "string") + write(e.localId, null); + return true; + }, + }); + const constants = new Map(); + for (const [id, value] of values) if (writes.get(id) === 1) constants.set(id, value); + if (constants.size === 0) return mod; + + const reads = (e: IrExpr): boolean => !(e.kind === "varRef" && constants.has(e.localId)); + const rewriteExpr = (e: IrExpr): IrExpr => { + if (e.kind === "varRef") { + const value = constants.get(e.localId); + return value ? { ...value, type: e.type, loc: e.loc } : e; + } + return mapExprChildren(e, rewriteExpr, rewriteStmt); + }; + const rewriteStmt = (s: IrStmt): IrStmt => mapStmtChildren(s, rewriteExpr, rewriteStmt); + let changed = false; + const functions = mod.functions.map((fn): IrFunction => { + if (everyStmtList(fn.body, { stmt: () => true, expr: reads })) return fn; + changed = true; + return { ...fn, body: fn.body.map(rewriteStmt) }; + }); + return changed ? { ...mod, functions } : mod; +} diff --git a/tests/corpus/constant-module-globals/consts.ts b/tests/corpus/constant-module-globals/consts.ts new file mode 100644 index 000000000..14eccc3af --- /dev/null +++ b/tests/corpus/constant-module-globals/consts.ts @@ -0,0 +1,10 @@ +export const SLASH = 0x2f; +export const DOT = 0x2e; +export const NEGATIVE = -1; +export const HALF = 0.5; +export const ENABLED = true; +export const MINUS_ZERO = -0; +export let counter = 0; +export function bump(): number { + return ++counter; +} diff --git a/tests/corpus/constant-module-globals/main.ts b/tests/corpus/constant-module-globals/main.ts new file mode 100644 index 000000000..f71b5c0a5 --- /dev/null +++ b/tests/corpus/constant-module-globals/main.ts @@ -0,0 +1,51 @@ +// Module constants read as their literal values; bindings that change or +// can be read before their declaration keep their global. +import * as ns from "./consts.ts"; +import { DOT, ENABLED, HALF, MINUS_ZERO, NEGATIVE, SLASH, bump, counter } from "./consts.ts"; + +const COLON = 0x3a; +const LIMIT = 3; + +function classify(code: number): string { + switch (code) { + case SLASH: + return "slash"; + case DOT: + return "dot"; + case COLON: + return "colon"; + case NEGATIVE: + return "negative"; + default: + return "other"; + } +} + +const path = "a/b.c:d"; +const kinds: string[] = []; +for (let i = 0; i < path.length; i++) kinds.push(classify(path.charCodeAt(i))); +console.log(kinds.join(","), classify(-1), classify(NaN)); +console.log(HALF * 3, ENABLED && LIMIT > 2, Object.is(MINUS_ZERO, -0), 1 / MINUS_ZERO); +console.log(ns.DOT, ns.SLASH, Object.keys(ns).sort().join(" ")); + +// A mutable export keeps observing writes. +bump(); +bump(); +console.log(counter, ns.counter); + +// A function hoisted above the declaration it reads still throws in the TDZ. +try { + console.log(early()); +} catch (error) { + console.log((error as Error).name, (error as Error).message); +} +const LATE = 7; +function early(): number { + return LATE * 2; +} +console.log(early()); + +// A loop binding at module scope is written once per iteration. +const totals: number[] = []; +for (const step of [1, 2, 3]) totals.push(step * LIMIT); +console.log(totals.join(" "));