mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-27 06:43:19 +08:00
Index arrays with exact i64 products of int32 values
`a[i * n + k]` with int32 operands kept the index in doubles whenever the product's range could pass 2^53: integer arithmetic needs a proven safe range, and an int32 product can reach 2^62. Every access then converted the double back with an exactness test. Such arithmetic now also carries a wide view: the exact integer as an i64, the magnitude it is bounded by, and a threshold below which the double equals it. A product of two exact integers equals its double below 2^53 in magnitude, and 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). Every i64 result stays within 2^62 + 2^53, so none wraps. An array index below a length or capacity is below 2^32, under every threshold kept, so the dense array guards and local array reads compare the wide view, unsigned, with the array's counts and skip the round trip. Any index that fails those comparisons, including every index whose double rounds, takes the double's path as before. Typed arrays and strings are unchanged.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<K extends IrExpr["kind"]> = Extract<IrExpr, { kind: K }>;
|
||||
export type LibCallExpr = ExprOf<"libCall">;
|
||||
type LibCallPrefixOf<T extends string> = T extends `${infer Prefix}.${string}` ? Prefix : never;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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<LlValue["integer"]>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string> {
|
||||
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/);
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/7325-array-index-products-near-2-53.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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));
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 1800, "san": 2400 }
|
||||
Reference in New Issue
Block a user