diff --git a/packages/compiler/src/backend/llvm/counted-loops.ts b/packages/compiler/src/backend/llvm/counted-loops.ts index 88af25e47..5d21e56f7 100644 --- a/packages/compiler/src/backend/llvm/counted-loops.ts +++ b/packages/compiler/src/backend/llvm/counted-loops.ts @@ -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(), diff --git a/packages/compiler/test/counted-loop-emission.test.ts b/packages/compiler/test/counted-loop-emission.test.ts index 914dc81d8..c42bda8b7 100644 --- a/packages/compiler/test/counted-loop-emission.test.ts +++ b/packages/compiler/test/counted-loop-emission.test.ts @@ -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 }); + } +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/counted-loop-integer-limits.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/counted-loop-integer-limits.ts.json new file mode 100644 index 000000000..2f219c543 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/counted-loop-integer-limits.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/counted-loop-integer-limits.ts" + ], + "diags": [] +} diff --git a/tests/corpus/counted-loop-integer-limits.ts b/tests/corpus/counted-loop-integer-limits.ts new file mode 100644 index 000000000..ff65f391d --- /dev/null +++ b/tests/corpus/counted-loop-integer-limits.ts @@ -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); diff --git a/tests/harness/corpus-costs/counted-loop-integer-limits.ts.json b/tests/harness/corpus-costs/counted-loop-integer-limits.ts.json new file mode 100644 index 000000000..bac451539 --- /dev/null +++ b/tests/harness/corpus-costs/counted-loop-integer-limits.ts.json @@ -0,0 +1 @@ +{ "plain": 3100, "san": 2000 }