diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts index 4488f90cb..ba3b48b02 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.ts @@ -27,7 +27,7 @@ import { storeArrCount, } from "./common.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; -import { exactInteger, widenInteger } from "./integer-values.js"; +import { indexInteger } from "./integer-values.js"; const SCR_ARR_VALUE = 1; @@ -74,13 +74,16 @@ function emitDenseGuard( ): DenseGuard { const B = host.B; const size = host.sizeType; - const integer = exactInteger(host, index, indexExpr); + const integer = indexInteger(host, index, indexExpr); let wide: string; let integral: string | null = null; - if (integer) { - // A proven integer needs no double round-trip. Negative signed values - // compare above any length or capacity as unsigned 64-bit integers. - wide = widenInteger(host, integer); + if (integer !== null) { + // A proven integer, or a wide view that equals the double at every + // index below a length, needs no double round-trip. Negative signed + // values compare above any length or capacity as unsigned 64-bit + // integers, and every index that fails the comparisons takes the + // double's path. + wide = integer; } else { // freeze keeps NaN/out-of-range conversions from producing poison; the // exact round-trip comparison then rejects every value that is not an diff --git a/packages/compiler/src/backend/llvm/expr-context.ts b/packages/compiler/src/backend/llvm/expr-context.ts index 9dd6bbcac..df6dfc608 100644 --- a/packages/compiler/src/backend/llvm/expr-context.ts +++ b/packages/compiler/src/backend/llvm/expr-context.ts @@ -15,11 +15,23 @@ export interface LlValue { /** An exact integer snapshot, excluding negative zero, with no discarded * high bits and the proven range of its original value. */ integer?: { name: string; type: "i32" | "i64"; signed: boolean; range: IntegerRange }; + /** An i64 holding the exact integer that the arithmetic producing this + * number computes, when that integer has no proven safe range: its + * absolute value is at most `bound`, and the double equals it whenever + * its absolute value is below `below` (a zero may be -0). Index consumers + * use it in place of a round trip (integer-values.ts `indexInteger`). */ + wideInteger?: WideInteger; slot?: boolean; /** A sequence local held in a capture box; release the box itself. */ boxed?: boolean; } +export interface WideInteger { + name: string; + below: number; + bound: number; +} + export type ExprOf = Extract; export type LibCallExpr = ExprOf<"libCall">; type LibCallPrefixOf = T extends `${infer Prefix}.${string}` ? Prefix : never; diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 354004e9e..4d3904bde 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -16,6 +16,7 @@ import { int32Number, integerNumber, narrowInteger, + wideArithmetic, widenInteger, } from "./integer-values.js"; import { @@ -337,7 +338,14 @@ export function emitOperatorExpr( if (arith[e.op] !== undefined) { B.line(`${t} = ${arith[e.op]} double ${l.name}, ${r.name}`); const uint32 = emitWrappingSum(host, e, l, r, li, ri); - if (uint32 !== undefined) return { name: t, type: e.type, uint32 }; + // The exact integer beside the double, for index consumers. + const wideInteger = wideArithmetic(host, e.op, l, li, r, ri); + if (uint32 !== undefined || wideInteger) { + const value: LlValue = { name: t, type: e.type }; + if (uint32 !== undefined) value.uint32 = uint32; + if (wideInteger) value.wideInteger = wideInteger; + return value; + } } else B.line(`${t} = fcmp ${cmp[e.op]} double ${l.name}, ${r.name}`); } else if (bit[e.op] !== undefined) { if (e.left.type.kind !== "f64" || e.right.type.kind !== "f64") { diff --git a/packages/compiler/src/backend/llvm/integer-values.ts b/packages/compiler/src/backend/llvm/integer-values.ts index 63e279d04..ddbcdd8da 100644 --- a/packages/compiler/src/backend/llvm/integer-values.ts +++ b/packages/compiler/src/backend/llvm/integer-values.ts @@ -1,6 +1,6 @@ import type { IrExpr, IrType } from "../../ir/ir.js"; import type { IntegerRange } from "../../ir/integer-ranges.js"; -import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; +import type { LlValue, LlvmEmitterContext, WideInteger } from "./expr-context.js"; export type LlInteger = NonNullable; @@ -51,6 +51,89 @@ export function exactInteger( return { name, type: "i64", signed: true, range }; } +/** Doubles hold every integer of smaller magnitude exactly. */ +const EXACT_DOUBLES = 2 ** 53; +/** Wide views stay within this magnitude: a product of two int32s reaches + * 2^62, one exact term of at most 2^53 may be added to it, and no i64 + * result wraps. */ +const WIDE_BOUND = 2 ** 62 + 2 ** 53; +/** Array and string indexes are below 2^32 - 1: a wide view exact below + * this many is exact at every index a length can admit. */ +const INDEX_LIMIT = 2 ** 32; + +function magnitude(range: IntegerRange): number { + return Math.max(Math.abs(range.min), Math.abs(range.max)); +} + +/** The wide view of `l op r` for `+`, `-` and `*` when the exact operands + * have no proven result range (their product or sum may pass 2^53). + * + * - A product of two exact integers equals its double whenever its + * magnitude is below 2^53: the double product of integers is exact there. + * - A sum or difference of two exact integers likewise. + * - A sum or difference of a wide view (exact below T) and an exact integer + * of magnitude at most B equals its double below min(T - B, 2^53): the + * view's magnitude is then below T, so its double is exact, and the + * double sum of two exact integers below 2^53 is exact. + * + * Every i64 result stays within 2^62 + 2^53 in magnitude, so none wraps. A view + * exact below fewer than 2^32 helps no index and is not kept. */ +export function wideArithmetic( + host: LlvmEmitterContext, + op: string, + l: LlValue, + li: LlInteger | null, + r: LlValue, + ri: LlInteger | null, +): WideInteger | null { + if (op !== "+" && op !== "-" && op !== "*") return null; + const view = (value: LlValue, integer: LlInteger | null): WideInteger | null => + integer + ? { name: "", below: Infinity, bound: magnitude(integer.range) } + : (value.wideInteger ?? null); + const lv = view(l, li), + rv = view(r, ri); + if (!lv || !rv) return null; + let below: number, bound: number; + if (op === "*") { + if (lv.below !== Infinity || rv.below !== Infinity) return null; + below = EXACT_DOUBLES; + bound = lv.bound * rv.bound; + } else if (lv.below === Infinity && rv.below === Infinity) { + below = EXACT_DOUBLES; + bound = lv.bound + rv.bound; + } else if (lv.below === Infinity || rv.below === Infinity) { + const [exact, wide] = lv.below === Infinity ? [lv, rv] : [rv, lv]; + below = Math.min(wide.below - exact.bound, EXACT_DOUBLES); + bound = exact.bound + wide.bound; + } else return null; + if (!(bound <= WIDE_BOUND) || below < INDEX_LIMIT) return null; + const operand = (value: LlValue, integer: LlInteger | null): string => + integer ? widenInteger(host, integer) : value.wideInteger!.name; + const left = operand(l, li), + right = operand(r, ri); + const name = host.B.tmp(); + host.B.line( + `${name} = ${op === "+" ? "add" : op === "-" ? "sub" : "mul"} nsw i64 ${left}, ${right}`, + ); + return { name, below, bound }; +} + +/** An i64 index for a consumer that compares it, unsigned, with a length or + * capacity below 2^32 and takes the double's path otherwise: the exact + * integer of `value`, or its wide view, which equals the double at every + * such index. Null when the index needs the double round trip. */ +export function indexInteger( + host: LlvmEmitterContext, + value: LlValue, + expr?: IrExpr, +): string | null { + const integer = exactInteger(host, value, expr); + if (integer) return widenInteger(host, integer); + const wide = value.wideInteger; + return wide && wide.below >= INDEX_LIMIT ? wide.name : null; +} + export function widenInteger(host: LlvmEmitterContext, value: LlInteger): string { if (value.type === "i64") return value.name; const wide = host.B.tmp(); diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index cdd00c4e7..b9fc42392 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -13,7 +13,7 @@ import { analyzeCallLifetimes, type CallLifetimes } from "./call-lifetimes.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import { ReferenceEffects } from "./reference-effects.js"; import { SCR_ARR_FIELD, loadArrCount, scrArrStateAt } from "./common.js"; -import { exactInteger, widenInteger } from "./integer-values.js"; +import { indexInteger } from "./integer-values.js"; import { READ_FALLBACK } from "./dense-array-access.js"; export interface LocalArrayRead { @@ -605,11 +605,11 @@ export function emitBorrowedArrayRead( return; } const index = host.emitExpr(read.index); - // A proven integer index (an int32 local, say) needs no double - // round-trip; a negative one compares above the capacity as unsigned. - const exact = - loopIndex === null && host.sizeType === "i64" ? exactInteger(host, index, read.index) : null; - const integerIndex = loopIndex ?? (exact ? widenInteger(host, exact) : null); + // A proven integer index (an int32 local, say), or a wide view exact at + // every index below the capacity, needs no double round-trip; a negative + // one compares above the capacity as unsigned. + const integerIndex = + loopIndex ?? (host.sizeType === "i64" ? indexInteger(host, index, read.index) : null); const slow = B.newLabel("borrow.array.slow"), join = B.newLabel("borrow.array.join"); emitDenseReferenceArrayRead( @@ -644,8 +644,12 @@ export function emitLocalArrayRead( ): { slot: string; type: IrType } | null { const B = host.B; const array = host.emitStableReceiver(read.array, [read.index]); - const integerIndex = inline ? host.emitIntegerLoopIndex(read.index) : null; + const loopIndex = inline ? host.emitIntegerLoopIndex(read.index) : null; const index = host.emitExpr(read.index); + // As in emitBorrowedArrayRead: an exact integer or wide view needs no + // round trip, and only the loop's own counter is an induction variable. + const integerIndex = + loopIndex ?? (inline && host.sizeType === "i64" ? indexInteger(host, index, read.index) : null); const nullable = read.type.kind === "union" ? host.nullableUnions.get(read.type.unionId) : null; if (nullable) { // A nullable union is the element pointer itself: borrowed from an @@ -665,7 +669,7 @@ export function emitLocalArrayRead( array, index, integerIndex, - integerIndex !== null, + loopIndex !== null, store, no, slow, @@ -726,7 +730,7 @@ export function emitLocalArrayRead( array, index, integerIndex, - integerIndex !== null, + loopIndex !== null, storeValue, no, slow, diff --git a/packages/compiler/test/array-index-product-emission.test.ts b/packages/compiler/test/array-index-product-emission.test.ts new file mode 100644 index 000000000..bfd891398 --- /dev/null +++ b/packages/compiler/test/array-index-product-emission.test.ts @@ -0,0 +1,55 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +async function emitLlvm(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-index-product-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ll"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "llvm" }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + return await readFile(outPath, "utf8"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const source = ` +function at(a: number[], x: number, y: number, k: number): number | undefined { + return a[x * y + k]; +} +function cancel(a: number[], p: number, q: number, r: number, s: number): number | undefined { + return a[p * q - r * s]; +} +const a = [1, 2, 3]; +const v = process.argv.length; +console.log(at(a, v | 0, (v + 1) | 0, -v | 0), cancel(a, v | 0, v | 0, v | 0, v | 0)); +`; + +function body(llvm: string, name: string): string { + return new RegExp(`^define [^@]*@sc_\\w+?_${name}(\\.i32)?\\([^]*?^}`, "m").exec(llvm)?.[0] ?? ""; +} + +test("an int32 product plus an int32 indexes as an exact i64 without a round trip", async () => { + const llvm = await emitLlvm(source); + // The int32 entry: the index is the i64 product and sum, compared with + // the array's counts; the double goes only to the slow path's runtime call. + const at = /^define [^@]*@sc_bf_at\.i32\([^]*?^}/m.exec(llvm)?.[0] ?? ""; + const product = /(%\S+) = mul nsw i64 %\S+, %\S+/.exec(at)?.[1]; + expect(product).toBeDefined(); + expect(at).toContain(`= add nsw i64 ${product}, `); + expect(at).not.toMatch(/fptosi double %\S+ to i64/); +}); + +test("a difference of two int32 products keeps the double's round trip", async () => { + const llvm = await emitLlvm(source); + // Each product may pass 2^53, where its double rounds before the + // difference: the index keeps the exactness test of the double. + const cancel = body(llvm, "cancel"); + expect(cancel).toMatch(/fptosi double %\S+ to i64/); + expect(cancel).toMatch(/fcmp oeq double/); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7325-array-index-products-near-2-53.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7325-array-index-products-near-2-53.ts.json new file mode 100644 index 000000000..3239c26bf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7325-array-index-products-near-2-53.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7325-array-index-products-near-2-53.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7325-array-index-products-near-2-53.ts b/tests/corpus/7325-array-index-products-near-2-53.ts new file mode 100644 index 000000000..41e60b09d --- /dev/null +++ b/tests/corpus/7325-array-index-products-near-2-53.ts @@ -0,0 +1,82 @@ +// Element reads and writes indexed by products of int32 values plus or +// minus int32 terms. The products can pass the int32 range and 2^53, where +// a double product rounds: an index that lands in an array's range reads +// and writes that element, and every other one (negative, past the length, +// rounded past 2^53) keeps the property key Node computes from the double. + +function show(label: string, value: unknown): void { + console.log(label, Object.is(value, -0) ? "-0" : String(value)); +} + +function read(a: number[], x: number, y: number, k: number): number | undefined { + return a[x * y + k]; +} + +function readBack(a: number[], x: number, y: number, k: number): number | undefined { + return a[x * y - k]; +} + +function write(a: number[], x: number, y: number, k: number, v: number): void { + a[x * y + k] = v; +} + +function rows(n: number, cols: number, a: number[]): number { + let s = 0; + for (let i = 0; i < n; i++) for (let j = 0; j < cols; j++) s += a[i * cols + j]! * (j + 1); + return s; +} + +const a: number[] = []; +for (let i = 0; i < 64; i++) a.push(i * 10); + +const big = 2147483647; +const half = 65536; + +// In range. +show("3*5+2", read(a, 3 | 0, 5 | 0, 2 | 0)); +show("7*-1+9", read(a, 7 | 0, -1 | 0, 9 | 0)); +show("-0 index", read(a, -1 | 0, 0 | 0, 0 | 0)); +// The product passes the int32 range; the difference lands in range. +show("2^16*2^15-2^31+12", readBack(a, half | 0, 32768 | 0, (-2147483648 * -1 - 12) | 0)); +show("(2^31-1)*1-(2^31-1)+5", read(a, big | 0, 1 | 0, -2147483642 | 0)); +show("2^16*2^16-big+20", readBack(a, half | 0, half | 0, (big - 20) | 0)); +// Out of range or negative: holes and named keys. +show("past end", read(a, 8 | 0, 8 | 0, 1 | 0)); +show("negative", read(a, -3 | 0, 5 | 0, 0 | 0)); +show("2^31*2^31", read(a, -2147483648 | 0, -2147483648 | 0, 7 | 0)); + +// Products around 2^53, where the double product rounds. +const keys: number[] = []; +const x = 134217727; // 2^27 - 1 +const y = 67108865; // 2^26 + 1: x * y = 2^53 + 2^26 - 1 +const near: [number, number, number][] = [ + [x, y, 0], + [x, y, 1], + [x, y, -67108864], + [x, y, -67108863], + [94906265, 94906265, 0], + [94906267, 94906267, -3], + [-2147483648, 2147483647, 2147483647], +]; +for (const [p, q, k] of near) { + const sparse: number[] = []; + write(sparse, p | 0, q | 0, k | 0, 1); + // The key the double computes; a local holds only the double. + const key: number = p * q + k; + keys.push(key); + show(`write ${p}*${q}+${k}`, `${sparse[key]} ${sparse[key + 1]} ${sparse[key - 1]} ${sparse.length}`); + show(`read ${p}*${q}+${k}`, read(sparse, p | 0, q | 0, k | 0)); +} +show("keys", keys.join(",")); + +// A difference of two products above 2^53 keeps the double's rounding. +function cancel(sparse: number[], p: number, q: number, r: number, s: number): number | undefined { + return sparse[p * q - r * s]; +} +const cancelled: number[] = [7, 8, 9]; +show("cancel", cancel(cancelled, x | 0, y | 0, x | 0, y | 0)); +show("cancel+1", cancel(cancelled, x | 0, y | 0, (9007199254740992 / 67108864) | 0, 67108864 | 0)); + +// Row-major sums with a width from JSON. +const dims = JSON.parse("[[8, 8], [4, 16], [16, 4], [3, 7]]") as [number, number][]; +for (const [n, cols] of dims) show(`rows ${n}x${cols}`, rows(n | 0, cols | 0, a)); diff --git a/tests/harness/corpus-costs/7325-array-index-products-near-2-53.ts.json b/tests/harness/corpus-costs/7325-array-index-products-near-2-53.ts.json new file mode 100644 index 000000000..f71f3eb98 --- /dev/null +++ b/tests/harness/corpus-costs/7325-array-index-products-near-2-53.ts.json @@ -0,0 +1 @@ +{ "plain": 1800, "san": 2400 }