Use an exact integer loop limit as the counted loop's endpoint

A counted for loop converts its limit to an i64 endpoint. A limit that is
not a literal went through the double every time: converted to double,
clamped to 2^53, rounded with llvm.floor or llvm.ceil and converted back.
At the default target CPU the rounding is a libm call, which a loop such as
`for (let i = values.length - 1; i > at; i--)` paid on every entry even
when `at` was an int32 local. A limit that is an exact integer (an int32
local, an integer view, or a proven range) is its own floor and ceiling and
within ±2^53, so it is now the endpoint directly; other limits keep the
double path.
This commit is contained in:
Chris Tate
2026-10-27 13:49:48 -05:00
parent 1feed2a3ec
commit a75cff7ab4
5 changed files with 204 additions and 1 deletions
@@ -13,7 +13,10 @@ import { integerOrNaNRange } from "./wrapping-sums.js";
/** The caller has proved or guarded the direction's finite endpoint. Clamp
* the opposite infinity before integer conversion. Fractional limits become
* exclusive integer endpoints for signed comparisons in either direction. */
* exclusive integer endpoints for signed comparisons in either direction. A
* limit that is an exact integer (an int32 local, an integer view, or a
* proven range) is its own floor and ceiling and within ±2^53, so it is the
* endpoint without the double conversion and rounding. */
export function emitCountedLoopLimit(
host: LlvmEmitterContext,
loop: IntegerCountedForLoop,
@@ -35,6 +38,14 @@ export function emitCountedLoopLimit(
}
const B = host.B;
const value = host.emitExpr(loop.limit);
const exact = exactInteger(host, value, loop.limit);
if (exact) {
const integer = /^-?\d+$/.test(exact.name) ? exact.name : widenInteger(host, exact);
if (!loop.inclusive) return integer;
const limit = B.tmp();
B.line(`${limit} = add nsw i64 ${integer}, ${ascending ? 1 : -1}`);
return limit;
}
const within = B.tmp(),
bounded = B.tmp(),
rounded = B.tmp(),
@@ -438,3 +438,36 @@ test("a loop that starts from a typed-array read guards the read's integer and o
await rm(dir, { recursive: true, force: true });
}
});
test("an exact integer loop limit becomes the endpoint without rounding", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-counted-limit-"));
try {
const entry = join(import.meta.dirname, "../../../tests/corpus/counted-loop-integer-limits.ts");
const outPath = join(dir, "limits.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 ll = emitLlvmModule(deserializeModule(await readFile(outPath, "utf8")), {});
const bodies = (method: string): string[] =>
[
...ll.matchAll(
new RegExp(
`define internal [^\\n]+ @\\w*Page_${method}(?:\\.i32)?\\([^\\n]*\\) #0 \\{ ; [^\\n]*([\\s\\S]*?)\\n\\}`,
"g",
),
),
].map((m) => m[1]!);
// The int32 rank is the endpoint of the loops in both directions, with
// exclusive and inclusive bounds: no floor, ceil or 2^53 clamp.
for (const method of ["set", "suffix"]) {
const found = bodies(method);
expect(found.length).toBeGreaterThan(0);
for (const body of found) {
expect(body).toContain("integer induction i");
expect(body).not.toMatch(/@llvm\.(floor|ceil)\.f64/);
expect(body).not.toContain("0x4340000000000000");
}
}
} finally {
await rm(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/counted-loop-integer-limits.ts"
],
"diags": []
}
+152
View File
@@ -0,0 +1,152 @@
// Counted for loops whose limit is an exact integer: an int32 local, an
// int32 field, the result of a function returning an int32, a value in a
// proven range (a masked byte, an array length), counting up and down,
// with exclusive and inclusive bounds, empty ranges and the int32 edges.
// Fractional, NaN and infinite limits take the double path and give the
// same trip counts as Node.
function bitCount(x: number): number {
x = x - ((x >>> 1) & 0x55555555);
x = (x & 0x33333333) + ((x >>> 2) & 0x33333333);
return Math.imul((x + (x >>> 4)) & 0x0f0f0f0f, 0x01010101) >>> 24;
}
class Window {
low = 0;
high = 0;
constructor(low: number, high: number) {
this.low = low | 0;
this.high = high | 0;
}
}
function countUp(from: number, to: number): number {
let trips = 0;
for (let i = from; i < to; i++) trips++;
return trips;
}
function countUpInclusive(from: number, to: number): number {
let trips = 0;
for (let i = from; i <= to; i++) trips++;
return trips;
}
function countDown(from: number, to: number): number {
let trips = 0;
for (let i = from; i > to; i--) trips++;
return trips;
}
function countDownInclusive(from: number, to: number): number {
let trips = 0;
for (let i = from; i >= to; i--) trips++;
return trips;
}
// An int32 local limit, as in an insertion that shifts the elements after a rank.
function insertAt(values: number[], at: number, value: number): void {
values.push(value);
for (let i = values.length - 1; i > at; i--) values[i] = values[i - 1]!;
values[at] = value;
}
function removeAt(values: number[], at: number): void {
for (let i = at; i < values.length - 1; i++) values[i] = values[i + 1]!;
values.pop();
}
const values = [10, 20, 30, 40];
const rank = bitCount(0b1011 & 0b0011);
insertAt(values, rank, 25);
insertAt(values, 0, 5);
insertAt(values, values.length - 1, 45);
console.log("after inserts", values.join(","));
removeAt(values, bitCount(1));
removeAt(values, values.length - 1);
console.log("after removes", values.join(","));
// A rank from a method as the limit: a page that keeps its values in id
// order and shifts the later ones on insert and remove.
class Page {
readonly values: number[] = [];
bits = 0;
rank(slot: number): number {
return bitCount(this.bits & ((1 << (slot & 31)) - 1));
}
set(slot: number, value: number): void {
const bit = 1 << (slot & 31);
const at = this.rank(slot);
const values = this.values;
if ((this.bits & bit) !== 0) {
for (let i = at; i < values.length - 1; i++) values[i] = values[i + 1]!;
values.pop();
this.bits = (this.bits & ~bit) | 0;
return;
}
values.push(value);
for (let i = values.length - 1; i > at; i--) values[i] = values[i - 1]!;
values[at] = value;
this.bits = (this.bits | bit) | 0;
}
// Inclusive bounds at a rank, in both directions.
suffix(slot: number): number {
const at = this.rank(slot);
const values = this.values;
let total = 0;
for (let i = values.length - 1; i >= at; i--) total += values[i]!;
for (let i = 0; i <= at - 1; i++) total -= values[i]!;
return total;
}
}
const page = new Page();
for (const slot of [5, 1, 9, 3, 31, 0, 9, 1, 17]) page.set(slot, slot * 10);
console.log("page", page.values.join(","), page.bits >>> 0);
console.log("suffix", page.suffix(0), page.suffix(4), page.suffix(17), page.suffix(31));
// Limits from fields, calls and proven ranges.
const window = new Window(-3, 7);
let sum = 0;
for (let i = window.low; i < window.high; i++) sum += i;
console.log("field limits", sum);
sum = 0;
for (let i = window.high; i >= window.low; i--) sum += i;
console.log("field limits down", sum);
let byte = 0;
for (let k = 0; k < 6; k++) {
const limit = (k * 77) & 0xff;
let trips = 0;
for (let i = 0; i <= limit; i += 37) trips++;
byte += trips;
}
console.log("masked limits", byte);
let fromCalls = 0;
for (let i = bitCount(0xff); i > bitCount(0x3); i--) fromCalls++;
console.log("call limits", fromCalls);
// Edges and empty ranges.
console.log("up", countUp(0, 5), countUp(5, 0), countUp(-5, -2), countUp(2147483645, 2147483647));
console.log(
"up inclusive",
countUpInclusive(0, 5),
countUpInclusive(3, 3),
countUpInclusive(2147483645, 2147483647),
);
console.log("down", countDown(5, 0), countDown(0, 5), countDown(-2147483646, -2147483648));
console.log(
"down inclusive",
countDownInclusive(5, 0),
countDownInclusive(-2147483646, -2147483648),
countDownInclusive(0, 0),
);
// Limits that are not exact integers.
console.log("fractional", countUp(0, 2.5), countUpInclusive(0, 2.5), countDown(3, 0.5), countDownInclusive(3, 0.5));
console.log("negative fractional", countUp(-3, -0.5), countDownInclusive(0, -2.5));
console.log("NaN", countUp(0, Number.NaN), countDown(3, Number.NaN));
let capped = 0;
for (let i = 0; i < Number.POSITIVE_INFINITY; i++) {
if (++capped === 4) break;
}
console.log("infinite", capped);
let floored = 0;
for (let i = 3; i > Number.NEGATIVE_INFINITY; i--) {
if (++floored === 5) break;
}
console.log("negative infinite", floored);
@@ -0,0 +1 @@
{ "plain": 3100, "san": 2000 }