Version while loops on the integer range of locals they enter with (#2382)

This commit is contained in:
Chris Tate
2026-10-26 23:32:40 -05:00
committed by GitHub
11 changed files with 831 additions and 2 deletions
@@ -31,6 +31,7 @@ import { findLocalStackUnions } from "./stack-unions.js";
import { findLocalStackObjects, findReassignedStackObjects } from "./stack-objects.js";
import { findLocalUnionStorage } from "./local-union-storage.js";
import { INT32_RANGE, analyzeIntegerRangeFacts } from "../../ir/integer-ranges.js";
import { beginLoopEntryVersions } from "./loop-entry-versions.js";
import { findBytesBounds } from "./bytes-bounds.js";
import { findInt32Locals } from "./int32-locals.js";
import { findIntegerViews } from "./integer-views.js";
@@ -246,6 +247,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
const rangeFacts = analyzeIntegerRangeFacts(numericFn, slotFacts);
emitter.integerRanges = rangeFacts.ranges;
emitter.integerLoopRanges = rangeFacts.loopRanges;
beginLoopEntryVersions(emitter, numericFn, slotFacts, rangeFacts);
emitter.int32Elements = slotFacts.elements ?? null;
emitter.integerOrNaNLocals = findIntegerOrNaNLocals(emitter, numericFn);
emitter.bytesBounds = findBytesBounds(numericFn, emitter.integerRanges);
@@ -46,6 +46,7 @@ import { emitBorrowedInput } from "./borrowed-inputs.js";
import { f64Lit, llvmCommentText, loadArrCount, SCR_ARR_FIELD, sizeToDouble } from "./common.js";
import { planInt32KindLoop, type Int32KindLoopPlan } from "./int32-kind-loops.js";
import { withinInt32 } from "../../ir/integer-ranges.js";
import { emitLoopEntryVersion, loopEntryVersion } from "./loop-entry-versions.js";
import { resultSlotStore } from "../result-slots.js";
import {
matchIntegerArrayForLoop,
@@ -2349,6 +2350,13 @@ export function emitLoopCopies(
exit: string,
emitCopy: (exit: string) => void,
): void {
const entry = allowed ? loopEntryVersion(emitter, loop) : null;
if (entry) {
emitLoopEntryVersion(emitter, loop, entry, () =>
emitLoopCopies(emitter, loop, allowed, exit, emitCopy),
);
return;
}
const plan =
allowed &&
emitter.speculativeLoopsEnabled &&
@@ -1,4 +1,5 @@
import type { TargetCpu } from "../target-cpu.js";
import { loopEntryInlines } from "./loop-entry-versions.js";
import type { FunctionLayout } from "./function-layout.js";
import { relieveHeapPressure } from "../../heap-pressure.js";
import { wasmBoundsChecks } from "./wasm-modules.js";
@@ -4009,8 +4010,10 @@ export class LlEmitter {
emitDirectCall(line: string, callee: string): boolean {
const B = this.B;
// A small leaf called only from loops inlines into each of them, past
// LLVM's cost threshold (loop-callees.ts). Debug builds keep the call.
if (this.debug === null && this.loopCallees.has(callee)) {
// LLVM's cost threshold (loop-callees.ts), and so does a function called
// only from a loop's entry-range version (loop-entry-versions.ts).
// Debug builds keep the call.
if (this.debug === null && (this.loopCallees.has(callee) || loopEntryInlines(this, callee))) {
const comment = line.indexOf(" ; ");
line =
comment < 0
@@ -0,0 +1,231 @@
/* Loop-entry range versions (ir/loop-entry-ranges.ts): a while loop whose
* integer work depends on locals entering it without range facts runs as
* two copies. A test on entry checks that each such local holds an integer
* in its assumed range; the fast copy is emitted under the facts that hold
* then, and the other copy is the loop as it would be emitted otherwise. */
import type { IrFunction, IrModule, IrStmt } from "../../ir/ir.js";
import { everyStmt, everyStmtList } from "../../ir/traverse.js";
import { exprFunctionReference, moduleFunctionReferences } from "../../ir/function-references.js";
import type { IntegerRangeFacts, IntegerSlotFacts } from "../../ir/integer-ranges.js";
import { findLoopEntryVersions, type LoopEntryVersion } from "../../ir/loop-entry-ranges.js";
import { f64Lit } from "./common.js";
import type { LlEmitter } from "./emitter.js";
import { defineModuleState } from "./emitter-state.js";
interface LoopEntryState {
fn: IrFunction | null;
facts: IntegerRangeFacts | null;
slots: IntegerSlotFacts;
/** Found on the first while loop the function emits. */
versions: Map<IrStmt, LoopEntryVersion> | null;
/** Loops whose copies are being emitted: a loop is versioned once. */
readonly versioning: Set<IrStmt>;
/** Direct calls per module function, or -1 for a function some value or
* the module's metadata references. */
calls: Map<string, number> | null;
/** The callees whose calls the fast copy being emitted inlines. */
inlined: ReadonlySet<string> | null;
}
const ENTRY_VERSIONS = defineModuleState<LoopEntryState>(() => ({
fn: null,
facts: null,
slots: {},
versions: null,
versioning: new Set<IrStmt>(),
calls: null,
inlined: null,
}));
/** The most IR nodes a callee may have for its call in a fast copy to be
* inlined (singleCallees). */
const MAX_INLINED_NODES = 400;
/** Sets the state for the function about to be emitted: `numericFn` and the
* facts emitFunction computed for it. Debug builds keep every loop as is. */
export function beginLoopEntryVersions(
emitter: LlEmitter,
numericFn: IrFunction,
slots: IntegerSlotFacts,
facts: IntegerRangeFacts,
): void {
const state = ENTRY_VERSIONS.of(emitter);
state.fn = emitter.debug === null ? numericFn : null;
state.slots = slots;
state.facts = facts;
state.versions = null;
state.versioning.clear();
}
/** The entry version of `loop`, when it has one and the emitter can test
* its locals: plain double slots of the current function, outside a
* speculative copy and a coroutine. */
export function loopEntryVersion(emitter: LlEmitter, loop: IrStmt): LoopEntryVersion | null {
if (loop.kind !== "while") return null;
const state = ENTRY_VERSIONS.of(emitter);
if (state.fn === null || state.facts === null || state.versioning.has(loop)) return null;
if (
!emitter.speculativeLoopsEnabled ||
emitter.speculation !== null ||
emitter.speculationResume !== null ||
emitter.currentCoro !== null
)
return null;
state.versions ??= findLoopEntryVersions(state.fn, state.facts, state.slots);
const version = state.versions.get(loop);
if (!version) return null;
for (const id of version.entry.keys())
if (!emitter.currentLocals.has(id) || !emitter.speculativeLocal(id)) return null;
return version;
}
/** Emit the entry test and both copies of `loop`. `emitCopies` emits the
* loop as it would be emitted without the version; it runs once under the
* version's facts and once as is. */
export function emitLoopEntryVersion(
emitter: LlEmitter,
loop: IrStmt,
version: LoopEntryVersion,
emitCopies: () => void,
): void {
const B = emitter.B;
let ok: string | null = null;
for (const [id, range] of version.entry) {
// An integer in [min, max] other than -0: the ordered comparisons
// reject NaN, and the bits of the value and its round trip agree only
// for integers that are not -0. A local the loop writes before it reads
// may be unset on entry, and a conversion out of range is poison:
// `freeze` makes both an arbitrary fixed value.
const raw = B.tmp(),
value = B.tmp(),
low = B.tmp(),
high = B.tmp(),
converted = B.tmp(),
integer = B.tmp(),
back = B.tmp(),
bits = B.tmp(),
backBits = B.tmp(),
exact = B.tmp(),
inRange = B.tmp(),
passed = B.tmp();
B.line(`${raw} = load double, ptr ${emitter.binding(id).slot}`);
B.line(`${value} = freeze double ${raw}`);
B.line(`${low} = fcmp oge double ${value}, ${f64Lit(range.min)}`);
B.line(`${high} = fcmp ole double ${value}, ${f64Lit(range.max)}`);
B.line(`${converted} = fptosi double ${value} to i64`);
B.line(`${integer} = freeze i64 ${converted}`);
B.line(`${back} = sitofp i64 ${integer} to double`);
B.line(`${bits} = bitcast double ${value} to i64`);
B.line(`${backBits} = bitcast double ${back} to i64`);
B.line(`${exact} = icmp eq i64 ${bits}, ${backBits}`);
B.line(`${inRange} = and i1 ${low}, ${high}`);
B.line(`${passed} = and i1 ${inRange}, ${exact}`);
if (ok === null) ok = passed;
else {
const both = B.tmp();
B.line(`${both} = and i1 ${ok}, ${passed}`);
ok = both;
}
}
const fast = B.newLabel("entry.fast"),
slow = B.newLabel("entry.slow");
// The test is expected to pass, which keeps the ordinary copy's calls
// cold for the inliner.
let likely = "true";
if (ok !== null) {
emitter.declare("declare i1 @llvm.expect.i1(i1, i1)");
likely = B.tmp();
B.line(`${likely} = call i1 @llvm.expect.i1(i1 ${ok}, i1 true)`);
}
B.condBr(likely, fast, slow);
const state = ENTRY_VERSIONS.of(emitter);
const originalRanges = emitter.integerRanges;
const originalLoopRanges = emitter.integerLoopRanges;
state.versioning.add(loop);
try {
B.startBlock(fast);
// An enclosing version may have narrowed a range already.
const ranges = new Map(originalRanges);
for (const [node, range] of version.ranges) {
const known = originalRanges.get(node);
ranges.set(
node,
known
? { min: Math.max(known.min, range.min), max: Math.min(known.max, range.max) }
: range,
);
}
emitter.integerRanges = ranges;
state.inlined = singleCallees(emitter, state, loop);
if (version.loopRanges) {
const loopRanges = new Map(originalLoopRanges);
loopRanges.set(loop, version.loopRanges);
emitter.integerLoopRanges = loopRanges;
}
emitCopies();
state.inlined = null;
emitter.integerRanges = originalRanges;
emitter.integerLoopRanges = originalLoopRanges;
B.startBlock(slow);
emitCopies();
} finally {
emitter.integerRanges = originalRanges;
emitter.integerLoopRanges = originalLoopRanges;
state.versioning.delete(loop);
state.inlined = null;
}
}
/** Whether the fast copy being emitted inlines its calls of `callee`. */
export function loopEntryInlines(emitter: LlEmitter, callee: string): boolean {
return ENTRY_VERSIONS.of(emitter).inlined?.has(callee) === true;
}
/** The functions the loop calls that have no other call or reference in
* the module and at most MAX_INLINED_NODES nodes. LLVM inlines a function's
* only call at almost any size; the two copies give each such function a
* second call, so the fast copy's call is marked to keep that inlining. */
function singleCallees(emitter: LlEmitter, state: LoopEntryState, loop: IrStmt): Set<string> {
const callees = new Set<string>();
everyStmt(loop, {
expr: (e) => {
if (e.kind === "call") callees.add(e.callee);
return true;
},
stmt: () => true,
});
const result = new Set<string>();
if (callees.size === 0) return result;
state.calls ??= countCalls(emitter.mod);
for (const name of callees) {
if (state.calls.get(name) !== 1 || name === state.fn?.name) continue;
const fn = emitter.mod.functions.find((f) => f.name === name);
if (fn && !fn.async && !fn.generator && nodeCount(fn.body) <= MAX_INLINED_NODES)
result.add(name);
}
return result;
}
function countCalls(mod: IrModule): Map<string, number> {
const calls = new Map<string, number>();
const referenced = new Set(moduleFunctionReferences(mod));
for (const fn of mod.functions)
everyStmtList(fn.body, {
expr: (e) => {
if (e.kind === "call") calls.set(e.callee, (calls.get(e.callee) ?? 0) + 1);
const name = exprFunctionReference(e);
if (name !== undefined) referenced.add(name);
return true;
},
stmt: () => true,
});
for (const name of referenced) calls.set(name, -1);
return calls;
}
function nodeCount(body: IrStmt[]): number {
let count = 0;
const visit = (): boolean => ++count <= MAX_INLINED_NODES;
everyStmtList(body, { expr: visit, stmt: visit });
return count;
}
@@ -23,6 +23,11 @@ export interface IntegerRangeFacts {
ranges: IntegerRanges;
loopRanges: ReadonlyMap<IrStmt, ReadonlyMap<string, IntegerRange>>;
}
/** Ranges assumed for locals on entry to while loops (loop-entry-ranges.ts):
* the facts inside such a loop hold in a copy of it that runs only after a
* test proves every assumed local is an integer in its range. After the
* loop, its assumed locals and its writes carry no facts. */
export type LoopEntryAssumptions = ReadonlyMap<IrStmt, ReadonlyMap<string, IntegerRange>>;
const SIGNED: IntegerRange = { min: -2147483648, max: 2147483647 };
const UNSIGNED: IntegerRange = { min: 0, max: 4294967295 };
/** Int32 and uint32 at once: the bound under which `%` (modeled only for
@@ -309,6 +314,7 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = {
export function analyzeIntegerRangeFacts(
fn: IrFunction,
slots: IntegerSlotFacts = {},
entries?: LoopEntryAssumptions,
): IntegerRangeFacts {
const ranges = new Map<IrExpr, IntegerRange | null>();
const loopRanges = new Map<IrStmt, Map<string, IntegerRange>>();
@@ -1074,6 +1080,8 @@ export function analyzeIntegerRangeFacts(
}
case "while":
case "doWhile": {
const assumed = entries?.get(s);
if (assumed) for (const [id, range] of assumed) if (!facts.has(id)) set(facts, id, range);
// Counters the condition bounds keep their range at every read
// inside and after the loop.
const guarded =
@@ -1102,6 +1110,10 @@ export function analyzeIntegerRangeFacts(
} else body(s.body, new Map(loop));
havocWith(facts, [s], facts, guarded);
for (const [id, range] of guarded) set(facts, id, range);
if (assumed) {
invalidate(facts, [s]);
for (const id of assumed.keys()) facts.delete(id);
}
break;
}
case "forOf": {
@@ -0,0 +1,143 @@
import { expect, test } from "vitest";
import { analyzeIntegerRangeFacts } from "./integer-ranges.js";
import { findLoopEntryVersions, LOOP_ENTRY_RANGE } from "./loop-entry-ranges.js";
import { BOOL, F64, type IrExpr, type IrFunction, type IrStmt } from "./ir.js";
const loc = { file: "test.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 bin = (op: "+" | "-" | "*" | "%", left: IrExpr, right: IrExpr): IrExpr => ({
loc,
kind: "bin",
op,
left,
right,
type: F64,
});
const condition = (op: "<" | "===", left: IrExpr, right: IrExpr): IrExpr => ({
loc,
kind: "bin",
op,
left,
right,
type: BOOL,
});
const set = (localId: string, value: IrExpr): IrStmt => ({ loc, kind: "assign", localId, value });
const bump = (localId: string): IrStmt => ({
loc,
kind: "exprStmt",
expr: { loc, kind: "incDec", op: "+", prefix: false, localId, type: F64 },
});
const observe = (value: IrExpr): IrStmt => ({ loc, kind: "exprStmt", expr: value });
const fn = (body: IrStmt[], locals: string[]): IrFunction => ({
loc,
name: "f",
params: [],
returnType: F64,
locals: locals.map((id) => ({ id, name: id, type: F64, mutable: true })),
body,
});
/** base = unknown; breaks = unknown; count = 0; i = 0;
* while (i < 300) { ...extra; i++; count++; last = (count + base - breaks) % 8;
* if (last === 0) breaks++; }
* observe(base) */
function lineBreaks(extra: IrStmt[] = [], locals: string[] = []) {
const remainder = bin("%", bin("-", bin("+", ref("count"), ref("base")), ref("breaks")), num(8));
const after = ref("base");
const loop: IrStmt = {
loc,
kind: "while",
cond: condition("<", ref("i"), num(300)),
body: [
...extra,
bump("i"),
bump("count"),
set("last", remainder),
{
loc,
kind: "if",
cond: condition("===", ref("last"), num(0)),
then: [bump("breaks")],
else_: null,
},
],
};
const f = fn(
[
set("base", ref("unknown")),
set("breaks", ref("unknown")),
set("count", num(0)),
set("i", num(0)),
set("last", num(1)),
loop,
observe(after),
],
["base", "breaks", "count", "i", "last", ...locals],
);
return { f, loop, remainder, after };
}
test("versions a while loop whose remainder reads locals without entry facts", () => {
const { f, loop, remainder } = lineBreaks();
const facts = analyzeIntegerRangeFacts(f);
expect(facts.ranges.get(remainder)).toBeFalsy();
const version = findLoopEntryVersions(f, facts).get(loop);
expect(version).toBeDefined();
expect([...version!.entry]).toEqual([
["base", LOOP_ENTRY_RANGE],
["breaks", LOOP_ENTRY_RANGE],
]);
// The dividend is an exact integer; `%` itself is modeled only for
// non-negative dividends.
const dividend = (remainder as Extract<IrExpr, { kind: "bin" }>).left;
expect(version!.ranges.get(dividend)).toBeDefined();
// The loop's counters, `breaks` among them, are bounded in the copy.
expect(version!.loopRanges?.get("breaks")).toEqual({
min: LOOP_ENTRY_RANGE.min,
max: LOOP_ENTRY_RANGE.max + 300,
});
});
test("the entry test covers only the locals the remainder depends on", () => {
// acc = acc + scale reads two more locals without facts.
const { f, loop } = lineBreaks(
[set("acc", bin("+", ref("acc"), ref("scale")))],
["acc", "scale"],
);
f.body.unshift(set("acc", ref("unknown")), set("scale", ref("unknown")));
const version = findLoopEntryVersions(f, analyzeIntegerRangeFacts(f)).get(loop);
expect([...version!.entry.keys()]).toEqual(["base", "breaks"]);
});
test("assumed facts do not leave the loop", () => {
const { f, loop, after } = lineBreaks();
const assumed = new Map([[loop, new Map([["base", LOOP_ENTRY_RANGE]])]]);
expect(analyzeIntegerRangeFacts(f, {}, assumed).ranges.get(after)).toBeFalsy();
});
test("loops without a remainder the assumption proves keep one copy", () => {
// A local the loop makes fractional, a product without a remainder, and a
// remainder the function already proves.
const fractional = lineBreaks([set("base", bin("+", ref("base"), num(0.5)))]);
expect(findLoopEntryVersions(fractional.f, analyzeIntegerRangeFacts(fractional.f)).size).toBe(0);
const product: IrStmt = {
loc,
kind: "while",
cond: condition("<", ref("i"), num(300)),
body: [set("acc", bin("*", ref("acc"), ref("scale"))), bump("i")],
};
const products = fn(
[set("acc", ref("unknown")), set("scale", ref("unknown")), set("i", num(0)), product],
["acc", "scale", "i"],
);
expect(findLoopEntryVersions(products, analyzeIntegerRangeFacts(products)).size).toBe(0);
const proven: IrStmt = {
loc,
kind: "while",
cond: condition("<", ref("i"), num(300)),
body: [set("acc", bin("%", ref("i"), num(7))), bump("i")],
};
const known = fn([set("acc", ref("unknown")), set("i", num(0)), proven], ["acc", "i"]);
expect(findLoopEntryVersions(known, analyzeIntegerRangeFacts(known)).size).toBe(0);
});
@@ -0,0 +1,218 @@
import type { IrExpr, IrFunction, IrStmt } from "./ir.js";
import { everyExpr, everyStmt, everyStmtList } from "./traverse.js";
import {
analyzeIntegerRangeFacts,
type IntegerRange,
type IntegerRangeFacts,
type IntegerSlotFacts,
type LoopEntryAssumptions,
} from "./integer-ranges.js";
type WhileLoop = Extract<IrStmt, { kind: "while" }>;
/** The range a versioned loop requires of each assumed local on entry. A
* loop's counters stay below a typed array or string length (at most 2^48),
* so sums of a few such values and assumed locals stay exact integers. */
export const LOOP_ENTRY_RANGE: IntegerRange = { min: -(2 ** 48), max: 2 ** 48 };
/** A copy of a while loop that runs when every local in `entry` holds an
* integer in its range on entry (never -0). `ranges` are the facts that
* hold for the loop's expressions in that copy and that the function's own
* analysis does not prove; `loopRanges` are the loop's bounded bindings
* there. */
export interface LoopEntryVersion {
entry: ReadonlyMap<string, IntegerRange>;
ranges: ReadonlyMap<IrExpr, IntegerRange>;
loopRanges: ReadonlyMap<string, IntegerRange> | undefined;
}
/** Versions of innermost while loops whose integer work depends on locals
* that enter the loop without range facts: copies of module globals,
* parameters, fields or call results (`const base = offset;` before a loop
* that computes `(count + base) % 60`).
*
* The function is analyzed again with each number local the loop reads
* without a fact assumed in LOOP_ENTRY_RANGE at the loop's entry. A loop is
* versioned when that analysis proves both operands of a remainder that the
* function's own analysis leaves unproven. Only remainders qualify: the
* operands of a product or a sum of such locals are as often fractional,
* and a loop whose test fails runs the ordinary copy in a larger function.
* The entry test covers only the assumed locals that such an operation reads, directly,
* through the loop's writes of other locals, or through the loop condition;
* when that drops any, the function is analyzed once more with the smaller
* sets, so the facts never rest on a local the test does not check. No
* fact leaves an assumed loop, so the loops' assumptions are independent. */
export function findLoopEntryVersions(
fn: IrFunction,
facts: IntegerRangeFacts,
slots: IntegerSlotFacts = {},
): Map<IrStmt, LoopEntryVersion> {
const result = new Map<IrStmt, LoopEntryVersion>();
if (fn.async || fn.generator) return result;
const captures = new Set(
[...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId),
);
const eligible = new Set(
fn.locals
.filter((l) => l.type.kind === "f64" && !l.boxed && !l.tdz && !captures.has(l.id))
.map((l) => l.id),
);
const known = (e: IrExpr): boolean => !!facts.ranges.get(e);
let candidates = new Map<WhileLoop, ReadonlyMap<string, IntegerRange>>();
everyStmtList(fn.body, {
expr: () => true,
stmt: (s) => {
if (s.kind !== "while" || containsLoop(s.body)) return true;
const assumed = entryCandidates(s, eligible, known);
if (assumed.size > 0) candidates.set(s, assumed);
return true;
},
});
if (candidates.size === 0) return result;
let versioned = analyzeIntegerRangeFacts(fn, slots, assumptionsOf(candidates));
const pruned = new Map<WhileLoop, ReadonlyMap<string, IntegerRange>>();
let dropped = false;
for (const [loop, entry] of candidates) {
const roots = gainedOperands(loop, known, versioned);
if (roots.length === 0) continue;
const relevant = relevantLocals(loop, roots);
const kept = new Map<string, IntegerRange>();
for (const [id, range] of entry) if (relevant.has(id)) kept.set(id, range);
if (kept.size < entry.size) dropped = true;
if (kept.size > 0) pruned.set(loop, kept);
}
if (dropped) {
candidates = pruned;
if (candidates.size === 0) return result;
versioned = analyzeIntegerRangeFacts(fn, slots, assumptionsOf(candidates));
}
for (const [loop, entry] of candidates) {
if (gainedOperands(loop, known, versioned).length === 0) continue;
const ranges = new Map<IrExpr, IntegerRange>();
const visit = (e: IrExpr): boolean => {
const range = versioned.ranges.get(e);
const prior = facts.ranges.get(e);
if (range && !prior) ranges.set(e, range);
else if (range && prior && (range.min > prior.min || range.max < prior.max))
ranges.set(e, { min: Math.max(range.min, prior.min), max: Math.min(range.max, prior.max) });
return true;
};
everyStmt(loop, { expr: visit, stmt: () => true });
result.set(loop, { entry, ranges, loopRanges: versioned.loopRanges.get(loop) });
}
return result;
}
function assumptionsOf(
candidates: ReadonlyMap<WhileLoop, ReadonlyMap<string, IntegerRange>>,
): LoopEntryAssumptions {
const result = new Map<IrStmt, ReadonlyMap<string, IntegerRange>>();
for (const [loop, entry] of candidates) result.set(loop, entry);
return result;
}
/** Number locals the loop reads without a range fact and does not declare,
* when the loop has a remainder without one. */
function entryCandidates(
loop: WhileLoop,
eligible: ReadonlySet<string>,
known: (e: IrExpr) => boolean,
): Map<string, IntegerRange> {
const read = new Set<string>();
const declared = new Set<string>();
let integerWork = false;
everyStmt(loop, {
expr: (e) => {
if (e.kind === "varRef" && eligible.has(e.localId) && !known(e)) read.add(e.localId);
else if (e.kind === "bin" && e.op === "%" && !known(e)) integerWork = true;
return true;
},
stmt: (s) => {
if (s.kind === "varDecl") declared.add(s.localId);
return true;
},
});
const result = new Map<string, IntegerRange>();
if (!integerWork) return result;
for (const id of read) if (!declared.has(id)) result.set(id, LOOP_ENTRY_RANGE);
return result;
}
/** Operands of the loop's remainders that the versioned analysis proves
* where the function's own analysis does not prove both. */
function gainedOperands(
loop: WhileLoop,
known: (e: IrExpr) => boolean,
versioned: IntegerRangeFacts,
): IrExpr[] {
const operands: IrExpr[] = [];
everyStmt(loop, {
expr: (e) => {
if (
e.kind === "bin" &&
e.op === "%" &&
!(known(e.left) && known(e.right)) &&
!!versioned.ranges.get(e.left) &&
!!versioned.ranges.get(e.right)
)
operands.push(e.left, e.right);
return true;
},
stmt: () => true,
});
return operands;
}
/** The locals `roots` read, closed over the loop's writes (a local written
* from others reads them) and over its condition (a local the condition
* reads makes every local of the condition relevant). */
function relevantLocals(loop: WhileLoop, roots: readonly IrExpr[]): Set<string> {
const relevant = new Set<string>();
const readsOf = (e: IrExpr): string[] => {
const ids: string[] = [];
everyExpr(e, {
expr: (x) => {
if (x.kind === "varRef") ids.push(x.localId);
return true;
},
stmt: () => true,
});
return ids;
};
const written = new Map<string, IrExpr[]>();
everyStmt(loop, {
expr: (e) => {
if (e.kind === "assignExpr")
written.set(e.localId, [...(written.get(e.localId) ?? []), e.value]);
return true;
},
stmt: (s) => {
const value = s.kind === "assign" ? s.value : s.kind === "varDecl" ? s.init : undefined;
if (value && (s.kind === "assign" || s.kind === "varDecl"))
written.set(s.localId, [...(written.get(s.localId) ?? []), value]);
return true;
},
});
const condition = readsOf(loop.cond);
const pending: string[] = [];
const add = (id: string): void => {
if (relevant.has(id)) return;
relevant.add(id);
pending.push(id);
};
for (const root of roots) for (const id of readsOf(root)) add(id);
while (pending.length > 0) {
const id = pending.pop()!;
for (const value of written.get(id) ?? []) for (const other of readsOf(value)) add(other);
if (condition.includes(id)) for (const other of condition) add(other);
}
return relevant;
}
function containsLoop(body: IrStmt[]): boolean {
return !everyStmtList(body, {
expr: () => true,
stmt: (s) =>
s.kind !== "while" && s.kind !== "doWhile" && s.kind !== "for" && s.kind !== "forOf",
});
}
@@ -0,0 +1,45 @@
import { mkdtemp, readFile, rm } 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 { emitLlvmModule } from "../src/backend/llvm/emitter.js";
test("while loops test their entry values and run integer remainders in the fast copy", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-entry-versions-"));
try {
const entry = join(import.meta.dirname, "../../../tests/corpus/while-entry-range-versions.ts");
const outPath = join(dir, "versions.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"));
const mod = deserializeModule(await readFile(outPath, "utf8"));
const ll = emitLlvmModule(mod, { pointerBits: 64 });
const body = (name: string): string =>
ll.match(
new RegExp(`define internal [^\\n]+ @sc_bf_${name}\\([^\\n]*\\{([\\s\\S]*?)\\n\\}`),
)![1]!;
for (const name of ["wrap", "ring", "_x25_Cursor_stripes"]) {
const fn = body(name);
const fast = fn.slice(fn.indexOf("\nentry.fast"), fn.indexOf("\nentry.slow"));
const slow = fn.slice(fn.indexOf("\nentry.slow"));
// The fast copy adds the dividend's integers; the ordinary copy adds
// doubles and tests the sum before its integer remainder.
const adds = (text: string): number => text.split("fadd double").length - 1;
expect(fast, name).toContain("srem i64");
expect(adds(fast), name).toBeLessThan(adds(slow));
}
// `complement` has no other caller: the fast copy's call keeps it inlined.
const wrap = body("wrap");
const fast = wrap.slice(wrap.indexOf("\nentry.fast"), wrap.indexOf("\nentry.slow"));
expect(fast).toMatch(/call i32 @sc_f_complement\.i32\([^)]*\) alwaysinline/);
expect(wrap.slice(wrap.indexOf("\nentry.slow"))).not.toContain("alwaysinline");
// Debug builds keep one copy.
const debug = emitLlvmModule(mod, {
pointerBits: 64,
debugSources: new Map([[entry, await readFile(entry, "utf8")]]),
});
expect(debug).not.toContain("entry.fast");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/while-entry-range-versions.ts"
],
"diags": []
}
+160
View File
@@ -0,0 +1,160 @@
// While loops whose remainder reads locals copied from module globals,
// parameters and fields. Entry values cover integers inside and outside
// [-2^48, 2^48], fractions, -0, NaN and infinities.
let column = 0;
let lines = 0;
// A switch with more cases than a small leaf function has statements.
function complement(code: number): number {
switch (code) {
case 65:
return 84;
case 67:
return 71;
case 71:
return 67;
case 84:
return 65;
case 97:
return 116;
case 99:
return 103;
case 103:
return 99;
case 116:
return 97;
case 85:
return 65;
case 117:
return 97;
case 82:
return 89;
case 89:
return 82;
case 75:
return 77;
case 77:
return 75;
}
return 78;
}
// Copies bytes in reverse, complemented, with a newline after every 8
// columns counted from the module-level column and line counters.
function wrap(input: Uint8Array, out: Uint8Array): number {
const base = column;
let breaks = lines;
let count = 0;
let o = 0;
let i = 0;
const len = input.length;
let last = 1;
let negativeZeros = 0;
while (i < len) {
out[o] = complement(input[len - 1 - i]!);
i++;
o++;
count++;
last = (count + base - breaks) % 8;
if (Object.is(last, -0)) negativeZeros++;
if (last === 0) {
out[o] = 10;
o++;
count++;
breaks++;
}
}
column = base + count;
lines = breaks;
console.log(` last ${Object.is(last, -0) ? "-0" : last}, ${negativeZeros} of them -0`);
return o;
}
function text(out: Uint8Array, n: number): string {
let s = "";
for (let i = 0; i < n; i++) s += out[i] === 10 ? "|" : String.fromCharCode(out[i]!);
return s;
}
const input = new Uint8Array(29);
const bases = "ACGTTGCAAGCTTCGA";
for (let i = 0; i < input.length; i++) input[i] = bases.charCodeAt((i * 7) % bases.length);
const out = new Uint8Array(64);
const starts: [number, number][] = [
[0, 0],
[5, 2],
[-13, 0],
[-16, 0],
[2 ** 48, 3],
[-(2 ** 48), 0],
[2 ** 48 + 1, 0],
[2 ** 50, 1],
[2 ** 53 - 5, 0],
[2 ** 53, 0],
[2 ** 53 + 2, 1],
[0.5, 0],
[3, 0.25],
[3, 2 ** 49],
[-0, 0],
[0, -0],
[NaN, 0],
[Infinity, 0],
[-Infinity, 0],
[1e300, 0],
];
for (const [c, l] of starts) {
column = c;
lines = l;
console.log(`start column ${c} lines ${l}`);
const n = wrap(input, out);
console.log(` ${text(out, n)}`);
console.log(` column ${column} lines ${lines}`);
}
// Two calls in a row: the second starts from the first one's counters.
column = 3;
lines = 0;
wrap(input, out);
const n = wrap(input, out);
console.log(`again ${text(out, n)} column ${column} lines ${lines}`);
// A parameter and a field as the loop's entry values.
function ring(values: Int32Array, offset: number, size: number): number {
let sum = 0;
let i = 0;
while (i < values.length) {
sum += values[(i + offset) % size]!;
i++;
}
return sum;
}
const values = new Int32Array([3, 1, 4, 1, 5, 9, 2, 6]);
for (const offset of [0, 3, 7, 2 ** 40, 2 ** 49, 1.5, -0, -2]) {
const sum = ring(values, offset, values.length);
console.log(`ring ${offset} ${Number.isNaN(sum) ? "NaN" : sum}`);
}
class Cursor {
position: number;
constructor(position: number) {
this.position = position;
}
stripes(bytes: Uint8Array): string {
const start = this.position;
let i = 0;
let marks = "";
while (i < bytes.length) {
const r = (start + i) % 3;
marks += Object.is(r, -0) ? "z" : String(r);
i++;
}
this.position = start + i;
return marks;
}
}
for (const position of [0, 4, -5, 2 ** 48 + 7, 0.75, 2 ** 60]) {
const cursor = new Cursor(position);
console.log(`stripes ${position} ${cursor.stripes(input.subarray(0, 7))} ${cursor.position}`);
}
@@ -0,0 +1 @@
{ "plain": 3600, "san": 3000 }