From 7b79d73a550e530a4a20541ef5428e854a331fa5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 20:52:38 -0500 Subject: [PATCH] Store local number arrays of proven int32s in 4-byte slots A local number[] that only compiled element operations reach, and whose every stored element the int32 slot analysis proves an int32 (never -0), is now stored as SCR_ELEM_I32: 4-byte int32 slots, with state bytes once it holds a hole or undefined, as for every array. Element reads give consumers an exact int32, so indexing with an element skips the double round trip, and the slots take half the memory. The runtime's dense slot accessors convert between the int32 slot and the double, so its double-valued entry points serve int32 arrays unchanged; growth, copies, fill and length changes use the 4-byte width. ir/int32-arrays.ts proposes the candidates and inlines their element-reader helper calls; the int32 slot analysis keeps the proven ones, and selectInt32Arrays rewrites the module for exactly those, so a program without an int32 array emits the same LLVM IR as before. --- .../llvm/appended-array-literals.test.ts | 7 +- .../src/backend/llvm/dense-array-access.ts | 119 ++++- .../src/backend/llvm/emit-function.ts | 1 + .../src/backend/llvm/emit-statements.ts | 4 +- packages/compiler/src/backend/llvm/emitter.ts | 115 +++-- .../src/backend/llvm/expr-containers.ts | 31 +- .../src/backend/llvm/expr-primitives.ts | 52 ++- .../src/backend/llvm/local-array-reads.ts | 47 +- .../src/ir/function-references.test.ts | 1 + packages/compiler/src/ir/int32-arrays.ts | 442 ++++++++++++++++++ packages/compiler/src/ir/int32-slots.ts | 55 ++- packages/compiler/src/ir/integer-ranges.ts | 146 +++++- packages/compiler/src/ir/ir.ts | 5 + .../test/int32-element-array-emission.test.ts | 130 ++++++ .../corpus/7326-int32-element-arrays.ts.json | 6 + packages/runtime/src/scr_array.c | 7 +- packages/runtime/src/scr_array.h | 48 +- packages/runtime/test/test_array.c | 59 +++ tests/corpus/7326-int32-element-arrays.ts | 165 +++++++ .../7326-int32-element-arrays.ts.json | 1 + 20 files changed, 1335 insertions(+), 106 deletions(-) create mode 100644 packages/compiler/src/ir/int32-arrays.ts create mode 100644 packages/compiler/test/int32-element-array-emission.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7326-int32-element-arrays.ts.json create mode 100644 tests/corpus/7326-int32-element-arrays.ts create mode 100644 tests/harness/corpus-costs/7326-int32-element-arrays.ts.json diff --git a/packages/compiler/src/backend/llvm/appended-array-literals.test.ts b/packages/compiler/src/backend/llvm/appended-array-literals.test.ts index c483b7848..11a9704f6 100644 --- a/packages/compiler/src/backend/llvm/appended-array-literals.test.ts +++ b/packages/compiler/src/backend/llvm/appended-array-literals.test.ts @@ -57,10 +57,11 @@ test("only empty literals stored where the module appends construct small", () = expect(found.has(idle)).toBe(false); expect(found.has(returned)).toBe(false); - // Only `fill` builds arrays. + // Only `fill` builds arrays. They may hold int32 slots (element kind 7), + // which does not change the choice of constructor. const llvm = emitLlvmModule(module(body, ["xs", "ys"])); - expect(llvm.match(/call ptr @scr_arr_new_small\(i32 0\)/g)).toHaveLength(2); - expect(llvm.match(/call ptr @scr_arr_new\(i32 0, i64 0\)/g)).toHaveLength(2); + expect(llvm.match(/call ptr @scr_arr_new_small\(i32 [07]\)/g)).toHaveLength(2); + expect(llvm.match(/call ptr @scr_arr_new\(i32 [07], i64 0\)/g)).toHaveLength(2); }); test("a binding with the same id in another function does not count", () => { diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts index 9158d12cd..cc8d21ced 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.ts @@ -239,9 +239,10 @@ function statePointer( return statePtr; } -/** How an array's dense slots hold its elements: the access kind, or - * `cref` for references to compact class instances (SCR_ELEM_CREF: a 4-byte - * slot holding the address over 8; LlEmitter.compactArrayElem). */ +/** How an array's dense slots hold its elements: the access kind (`i32` + * for int32 arrays, ir/int32-arrays.ts), or `cref` for references to compact + * class instances (SCR_ELEM_CREF: a 4-byte slot holding the address over 8; + * LlEmitter.compactArrayElem). */ type SlotKind = ArrayAccess | "cref"; function slotKind(host: LlvmEmitterContext, acc: ArrayAccess, element: IrType): SlotKind { @@ -249,14 +250,14 @@ function slotKind(host: LlvmEmitterContext, acc: ArrayAccess, element: IrType): } /** The dense slot type: boolean arrays store one byte per element, compact - * references four, every other element kind an 8-byte slot. */ + * references and int32 arrays four, every other element kind an 8-byte slot. */ function slotType(kind: SlotKind): "i8" | "i32" | "i64" { - return kind === "bool" ? "i8" : kind === "cref" ? "i32" : "i64"; + return kind === "bool" ? "i8" : kind === "cref" || kind === "i32" ? "i32" : "i64"; } /** Bytes per dense slot (scr_arr_slot_width). */ -function slotWidth(kind: SlotKind): number { - return kind === "bool" ? 1 : kind === "cref" ? 4 : 8; +export function slotWidth(kind: SlotKind): number { + return kind === "bool" ? 1 : kind === "cref" || kind === "i32" ? 4 : 8; } /** Bytes per dense slot of an array of `element` (scr_arr_slot_width). */ @@ -264,6 +265,17 @@ export function denseSlotWidth(host: LlvmEmitterContext, element: IrType): numbe return slotWidth(slotKind(host, elemAccess(element), element)); } +/** The runtime accessor family of an access: int32 arrays answer through + * the double-valued entry points, which convert their slots. */ +export function runtimeAccess(acc: ArrayAccess): "f64" | "bool" | "ref" { + return acc === "i32" ? "f64" : acc; +} + +/** The LLVM type of an access's values at the emitter's interface. */ +function valueType(acc: ArrayAccess): "double" | "i1" | "ptr" { + return acc === "f64" || acc === "i32" ? "double" : acc === "bool" ? "i1" : "ptr"; +} + function valuePointer( host: LlvmEmitterContext, guard: Pick, @@ -285,7 +297,13 @@ function loadState(host: LlvmEmitterContext, guard: DenseGuard): string { return state; } -export type ArrayAccess = "f64" | "bool" | "ref"; +/** The ScrElemKind of int32 arrays (scr_array.h). */ +export const SCR_ELEM_I32 = 7; + +/** How a dense array's slots are read and written: doubles, booleans, + * references, or the int32 slots of SCR_ELEM_I32 arrays (ir/int32-arrays.ts), + * whose values are doubles at the emitter's interface. */ +export type ArrayAccess = "f64" | "bool" | "ref" | "i32"; /** Before a self-append (`a[i] = a[i] + suffix`) replaces a reference * element: when `index` lies in the hole-free prefix and the slot still @@ -341,15 +359,33 @@ export function emitDenseArrayGet( retain: boolean, proven = false, ): string { + return emitDenseArrayRead(host, array, index, indexExpr, acc, element, getter, retain, proven) + .value; +} + +/** emitDenseArrayGet, with the int32 of an int32 array's element: the + * slot itself on the dense path, the runtime getter's double converted + * (exactly: the element is an int32) on the other. */ +export function emitDenseArrayRead( + host: LlvmEmitterContext, + array: string, + index: LlValue, + indexExpr: IrExpr | undefined, + acc: ArrayAccess, + element: IrType, + getter: string, + retain: boolean, + proven = false, +): { value: string; int32: string | null } { const B = host.B; - const ty = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr"; + const ty = valueType(acc); const ret = acc === "bool" ? "zeroext i1" : ty; const kind = slotKind(host, acc, element); host.declare(`declare ${ret} @${getter}(ptr, double)`); if (acc === "ref" && retain && !isRefCounted(element)) { const t = B.tmp(); B.line(`${t} = call ${ty} @${getter}(ptr ${array}, double ${index.name})`); - return t; + return { value: t, int32: null }; } const slow = B.newLabel("arr.get.slow"), join = B.newLabel("arr.get.join"), @@ -380,10 +416,16 @@ export function emitDenseArrayGet( B.startBlock(valueLabel); const ptr = valuePointer(host, guard, kind); let fast: string; + let fastInt: string | null = null; let fastLabel = valueLabel; if (acc === "f64") { fast = B.tmp(); B.line(`${fast} = load double, ptr ${ptr}${host.fieldAliasAttachment(ptr)}`); + } else if (acc === "i32") { + fastInt = B.tmp(); + fast = B.tmp(); + B.line(`${fastInt} = load i32, ptr ${ptr}${host.fieldAliasAttachment(ptr)}`); + B.line(`${fast} = sitofp i32 ${fastInt} to double`); } else if (acc === "bool") { const raw = B.tmp(); fast = B.tmp(); @@ -410,11 +452,21 @@ export function emitDenseArrayGet( const slowValue = B.tmp(); const attrs = acc === "ref" && retain ? " cold" : READ_FALLBACK; B.line(`${slowValue} = call ${ty} @${getter}(ptr ${array}, double ${index.name})${attrs}`); + let slowInt: string | null = null; + if (fastInt !== null) { + slowInt = B.tmp(); + B.line(`${slowInt} = fptosi double ${slowValue} to i32`); + } B.br(join); B.startBlock(join); const result = B.tmp(); B.line(`${result} = phi ${ty} [ ${fast}, %${fastLabel} ], [ ${slowValue}, %${slow} ]`); - return result; + let int32: string | null = null; + if (fastInt !== null && slowInt !== null) { + int32 = B.tmp(); + B.line(`${int32} = phi i32 [ ${fastInt}, %${fastLabel} ], [ ${slowInt}, %${slow} ]`); + } + return { value: result, int32 }; } /** `scr_arr_get_number`: a numeric element, or NaN for every missing, @@ -425,13 +477,14 @@ export function emitDenseArrayNumber( index: LlValue, indexExpr: IrExpr | undefined, proven = false, + acc: "f64" | "i32" = "f64", ): string { return emitDenseArrayGet( host, array, index, indexExpr, - "f64", + acc, { kind: "f64" }, "scr_arr_get_number", false, @@ -498,9 +551,9 @@ export function emitDenseArraySet( proven = false, ): void { const B = host.B; - const ty = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr"; + const ty = valueType(acc); const kind = slotKind(host, acc, element); - const setter = `scr_arr_set_${acc}`; + const setter = `scr_arr_set_${runtimeAccess(acc)}`; host.declare(`declare void @${setter}(ptr, double, ${acc === "bool" ? "i1 zeroext" : ty})`); const call = (attrs: string): void => B.line(`call void @${setter}(ptr ${array}, double ${index.name}, ${ty} ${value})${attrs}`); @@ -508,6 +561,13 @@ export function emitDenseArraySet( call(""); return; } + // An int32 array's stores are proven int32s (ir/int32-arrays.ts); both + // dense paths below store the converted value. + let int32Value: string | null = null; + if (acc === "i32") { + int32Value = B.tmp(); + B.line(`${int32Value} = fptosi double ${value} to i32`); + } const slow = B.newLabel("arr.set.slow"), join = B.newLabel("arr.set.join"), filledLabel = B.newLabel("arr.set.filled"); @@ -526,6 +586,8 @@ export function emitDenseArraySet( const storeValue = (valuePtr: string): void => { if (acc === "f64") { B.line(`store double ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); + } else if (acc === "i32") { + B.line(`store i32 ${int32Value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); } else if (acc === "bool") { const bits = B.tmp(); B.line(`${bits} = zext i1 ${value} to i8`); @@ -713,12 +775,21 @@ export function emitDenseArrayPop( B.line(`${wide} = zext i8 ${raw} to i64`); raw = wide; } else if (slotTy === "i32") { - // The 8-byte slot of a compact reference is its address. - const wide = B.tmp(), - address = B.tmp(); - B.line(`${wide} = zext i32 ${raw} to i64`); - B.line(`${address} = shl nuw nsw i64 ${wide}, 3`); - raw = address; + if (kind === "cref") { + // The 8-byte slot of a compact reference is its address. + const wide = B.tmp(), + address = B.tmp(); + B.line(`${wide} = zext i32 ${raw} to i64`); + B.line(`${address} = shl nuw nsw i64 ${wide}, 3`); + raw = address; + } else { + // The slot bits of an int32 element's double, as the f64 path returns them. + const number = B.tmp(), + bits = B.tmp(); + B.line(`${number} = sitofp i32 ${raw} to double`); + B.line(`${bits} = bitcast double ${number} to i64`); + raw = bits; + } } B.line(`store ${slotTy} 0, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); // Only an array with states has a state byte to clear. @@ -755,8 +826,8 @@ export function emitDenseArrayPush( const B = host.B; const size = host.sizeType; const kind = element === null ? acc : slotKind(host, acc, element); - const ty = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr"; - const pusher = `scr_arr_push_${acc}`; + const ty = valueType(acc); + const pusher = `scr_arr_push_${runtimeAccess(acc)}`; host.declare(`declare double @${pusher}(ptr, ${acc === "bool" ? "i1 zeroext" : ty})`); const header = (field: number): string => { const ptr = B.tmp(); @@ -794,6 +865,10 @@ export function emitDenseArrayPush( const valuePtr = valuePointer(host, { offset: len, data }, kind); if (acc === "f64") { B.line(`store double ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); + } else if (acc === "i32") { + const int32 = B.tmp(); + B.line(`${int32} = fptosi double ${value} to i32`); + B.line(`store i32 ${int32}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); } else if (acc === "bool") { const bits = B.tmp(); B.line(`${bits} = zext i1 ${value} to i8`); diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts index 16e654393..2484bc38d 100644 --- a/packages/compiler/src/backend/llvm/emit-function.ts +++ b/packages/compiler/src/backend/llvm/emit-function.ts @@ -61,6 +61,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { const B = new BlockBuilder(); emitter.B = B; emitter.currentFunctionName = fn.name; + emitter.findInt32Allocations(fn); emitter.currentTier = fn.tier; // Fiber switches restore the active exception cell before control // returns to a synchronous frame (emitPendingCheck). diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index b530f794d..11d375a55 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -411,7 +411,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { const v = emitter.emitExpr(s.value); if (s.arr.type.kind !== "array") throw new InternalCompilerError("llvm emitter bug: arraySet on non-array"); - const acc = elemAccess(s.arr.type.elem); + const acc = emitter.arrayAccess(s.arr, s.arr.type.elem); emitter.emitPublishedArrayGuard(arr.name, s.arr.type, 0, idx.name); if (acc === "ref") emitter.toHeap(v); emitDenseArraySet( @@ -478,7 +478,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { B.condBr(dropsLast, pop, call); B.startBlock(pop); const elem = s.arr.type.elem; - const acc = elemAccess(elem); + const acc = emitter.arrayAccess(s.arr, elem); const raw = emitDenseArrayPop(emitter, arr.name, acc, elem, call, call); if (acc === "ref" && isRefCounted(elem)) { const old = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 9c42920b9..8c6c61953 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -79,6 +79,7 @@ import { } from "./emit-journal.js"; import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js"; import { versionLoopBoundParameters } from "../../ir/parameter-versions.js"; +import { allocatedLiteral, selectInt32Arrays } from "../../ir/int32-arrays.js"; import { everyExpr, everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js"; import { INT32_RANGE, type IntegerRange, type IntegerRanges } from "../../ir/integer-ranges.js"; import { unshareNodes } from "../../ir/unshare.js"; @@ -147,7 +148,7 @@ import { } from "./inline-unions.js"; import { OptionalArrayReads, type LocalArrayRead } from "./local-array-reads.js"; import { CheckedNarrows } from "./checked-narrows.js"; -import { emitDenseArraySet, emitDenseSlotUnlink } from "./dense-array-access.js"; +import { emitDenseArraySet, emitDenseSlotUnlink, type ArrayAccess } from "./dense-array-access.js"; import { matchMapRead, type MapReadLifetimes } from "./map-read-lifetimes.js"; import { type MapEntryFusion } from "./map-entry-fusion.js"; import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js"; @@ -483,6 +484,15 @@ function versionParameters(mod: IrModule): IrModule { return versionLoopBoundParameters(mod, mangleFunction); } +/** The int32 arrays the int32 slot analysis keeps in `mod`, under the + * inputs LlEmitter gives it (selectInt32Arrays). */ +function provenInt32Arrays(mod: IrModule): ReadonlySet { + return analyzeInt32Slots(mod, { + dynamicStores: dynamicFieldStores(mod), + initializerBindings: findInitializerBindings(mod), + }).int32Arrays; +} + export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}): string { // Facts are keyed by node identity: emit the same module whether it was // lowered in memory or read back from its serialized form. @@ -490,28 +500,31 @@ export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}) // Keep source storage intact for debugger inspection in dev builds. return new LlEmitter( options.debugSources === undefined - ? versionParameters( - addRecordResultEntries( - scalarizeNumericRecords( - specializeCallbackCalls( - specializeNumericCalls( - foldConstantGlobals( - copyStableCaptures( - resolveSelfCaptures( - hoistFinalFieldReads( - freezeInitOnlyGlobals( - holdCharLocalsAsUnits(dropUnwrittenPropertyBags(mod)), + ? selectInt32Arrays( + versionParameters( + addRecordResultEntries( + scalarizeNumericRecords( + specializeCallbackCalls( + specializeNumericCalls( + foldConstantGlobals( + copyStableCaptures( + resolveSelfCaptures( + hoistFinalFieldReads( + freezeInitOnlyGlobals( + holdCharLocalsAsUnits(dropUnwrittenPropertyBags(mod)), + ), ), ), ), ), + mangleFunction, ), mangleFunction, ), - mangleFunction, ), ), ), + provenInt32Arrays, ) : mod, options, @@ -528,28 +541,31 @@ export function emitLlvmModuleSource( const mod = unshareNodes(input); const parts = new LlEmitter( options.debugSources === undefined - ? versionParameters( - addRecordResultEntries( - scalarizeNumericRecords( - specializeCallbackCalls( - specializeNumericCalls( - foldConstantGlobals( - copyStableCaptures( - resolveSelfCaptures( - hoistFinalFieldReads( - freezeInitOnlyGlobals( - holdCharLocalsAsUnits(dropUnwrittenPropertyBags(mod)), + ? selectInt32Arrays( + versionParameters( + addRecordResultEntries( + scalarizeNumericRecords( + specializeCallbackCalls( + specializeNumericCalls( + foldConstantGlobals( + copyStableCaptures( + resolveSelfCaptures( + hoistFinalFieldReads( + freezeInitOnlyGlobals( + holdCharLocalsAsUnits(dropUnwrittenPropertyBags(mod)), + ), ), ), ), ), + mangleFunction, ), mangleFunction, ), - mangleFunction, ), ), ), + provenInt32Arrays, ) : mod, options, @@ -734,6 +750,53 @@ export class LlEmitter { return `, !tbaa !${tag}`; } + /** The current function's int32 array allocations: the array literals + * its int32 arrays are written with. They construct SCR_ELEM_I32 arrays. */ + int32Allocations = new Set(); + /** The fill temporaries (`new Array(n).fill(v)`) of those allocations: + * the same arrays, so int32 too. */ + int32ArrayTemps = new Set(); + + /** Collect the int32 allocations of `fn`, about to be emitted. */ + findInt32Allocations(fn: IrFunction): void { + this.int32Allocations.clear(); + this.int32ArrayTemps.clear(); + if (this.int32Slots.int32Arrays.size === 0) return; + everyStmtList(fn.body, { + stmt: (s) => { + if ( + (s.kind === "varDecl" || s.kind === "assign") && + this.int32Slots.int32Arrays.has(`${fn.name}\0${s.localId}`) + ) { + const value = s.kind === "varDecl" ? s.init : s.value; + const allocation = value && allocatedLiteral(value); + if (allocation) { + this.int32Allocations.add(allocation.literal); + if (allocation.temporary !== null) this.int32ArrayTemps.add(allocation.temporary); + } + } + return true; + }, + expr: () => true, + }); + } + + /** Whether `arr` is one of the current function's int32 arrays + * (ir/int32-arrays.ts, kept by the int32 slot analysis). */ + int32Array(arr: IrExpr): boolean { + if (arr.kind !== "varRef" || this.int32Slots.int32Arrays.size === 0) return false; + return ( + this.int32ArrayTemps.has(arr.localId) || + this.int32Slots.int32Arrays.has(`${this.currentFunctionName}\0${arr.localId}`) + ); + } + + /** The access an element operation on `arr` (of element type `elem`) + * takes: int32 arrays read and write their 4-byte slots. */ + arrayAccess(arr: IrExpr, elem: IrType): ArrayAccess { + return this.int32Array(arr) ? "i32" : elemAccess(elem); + } + /** Whether an element access at `index` of `arr` lies in the hole-free * prefix: the counter of a dense array loop's fast copy indexing one of * the arrays its entry test covered. */ @@ -6518,7 +6581,7 @@ export class LlEmitter { return tagInSet(this, uName, unionId, tags); } - arrPush(arr: string, acc: "f64" | "bool" | "ref", value: string, element: IrType | null): string { + arrPush(arr: string, acc: ArrayAccess, value: string, element: IrType | null): string { return arrPush(this, arr, acc, value, element); } diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index 2f4cd98e9..5ec780277 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -60,7 +60,10 @@ import { emitDenseArrayNumber, emitDenseArrayPop, emitDenseArrayPush, + runtimeAccess, + type ArrayAccess, } from "./dense-array-access.js"; +import { INT32_ARRAY_METHODS } from "../../ir/int32-arrays.js"; import { emitUnionWiden } from "./expr-records.js"; import { registerReplay } from "./emit-journal.js"; @@ -115,7 +118,7 @@ export function tagInSet( export function arrPush( host: LlvmEmitterContext, arr: string, - acc: "f64" | "bool" | "ref", + acc: ArrayAccess, value: string, element: IrType | null, ): string { @@ -126,7 +129,7 @@ export function arrPush( export function emitArrayValues( host: LlvmEmitterContext, arr: string, - acc: "f64" | "bool" | "ref", + acc: ArrayAccess, element: IrType, values: LlValue[], prepend = false, @@ -142,12 +145,15 @@ export function emitArrayValues( } if (values.length === 1) { if (!prepend) return arrPush(host, arr, acc, values[0]!.name, element); - const ty = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr"; + const flat = runtimeAccess(acc); + const ty = flat === "f64" ? "double" : flat === "bool" ? "i1" : "ptr"; host.declare( - `declare double @scr_arr_unshift_${acc}(ptr, ${ty}${acc === "bool" ? " zeroext" : ""})`, + `declare double @scr_arr_unshift_${flat}(ptr, ${ty}${flat === "bool" ? " zeroext" : ""})`, ); const result = B.tmp(); - B.line(`${result} = call double @scr_arr_unshift_${acc}(ptr ${arr}, ${ty} ${values[0]!.name})`); + B.line( + `${result} = call double @scr_arr_unshift_${flat}(ptr ${arr}, ${ty} ${values[0]!.name})`, + ); return result; } const capacity = Math.min(values.length, 64); @@ -165,8 +171,9 @@ export function emitArrayValues( const slot = B.tmp(), pointer = B.tmp(); const value = values[start + i]!.name; + // The runtime takes double bits for int32 arrays too, and converts. const pack = - acc === "f64" + acc === "f64" || acc === "i32" ? `bitcast double ${value} to i64` : acc === "bool" ? `zext i1 ${value} to i64` @@ -610,7 +617,12 @@ export function emitArrIntrinsic( if (e.receiver.type.kind !== "array") throw new InternalCompilerError("llvm emitter bug: arrIntrinsic on non-array"); const elem = e.receiver.type.elem; - const acc = elemAccess(elem); + // Int32 arrays (ir/int32-arrays.ts) reach only these methods; the others + // take the runtime family's flat access. + const access = host.arrayAccess(e.receiver, elem); + if (access === "i32" && !INT32_ARRAY_METHODS.has(e.method)) + throw new InternalCompilerError(`llvm emitter bug: ${e.method} on an int32 array`); + const acc = runtimeAccess(access); const accTy = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr"; const accArg = acc === "bool" ? "i1 zeroext" : accTy; const method = e.method; @@ -639,6 +651,7 @@ export function emitArrIntrinsic( index, e.args[0], host.provenDenseIndex(e.receiver, e.args[0]!), + access === "i32" ? "i32" : "f64", ), type: e.type, }; @@ -675,7 +688,7 @@ export function emitArrIntrinsic( // no-op zero-argument call. const vs = e.args.map((a) => host.emitExpr(a)); host.emitPublishedArrayGuard(r.name, e.receiver.type, 1, f64Lit(0), f64Lit(vs.length)); - return { name: emitArrayValues(host, r.name, acc, elem, vs), type: e.type }; + return { name: emitArrayValues(host, r.name, access, elem, vs), type: e.type }; } case "pushSpread": { // `a.push(...src)`: append src's elements in order (borrowed src, @@ -749,7 +762,7 @@ export function emitArrIntrinsic( let slowLabel = ""; if (method === "pop") { slowLabel = B.newLabel("remove.slow"); - denseRaw = emitDenseArrayPop(host, r.name, acc, elem, la, slowLabel); + denseRaw = emitDenseArrayPop(host, r.name, access, elem, la, slowLabel); denseLabel = B.currentLabel(); B.br(lp); B.startBlock(slowLabel); diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 72392956a..7d259f412 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -20,6 +20,7 @@ import { } from "./integer-values.js"; import { bitwiseIdentityOperand, + INT32_RANGE, integerArithmeticRange, isBitwiseOp, withinInt32, @@ -32,7 +33,15 @@ import { emitPowerOfTwoRemainder, emitSignedIntegerRemainder, } from "./integer-remainder.js"; -import { denseSlotWidth, emitDenseArrayGet, emitDenseArrayState } from "./dense-array-access.js"; +import { + denseSlotWidth, + emitDenseArrayRead, + emitDenseArrayState, + runtimeAccess, + SCR_ELEM_I32, + slotWidth, + type ArrayAccess, +} from "./dense-array-access.js"; import { emitWrappingSum, markWrappingOperands } from "./wrapping-sums.js"; export function emitLiteralExpr( @@ -886,9 +895,23 @@ export function emitContainerExpr( // the plain elements up to the next one (scr_arr_reserve_spread). const firstSpread = e.elems.findIndex((_, i) => spreadSet.has(i)); const capacity = small ? 0 : (e.capacity ?? (firstSpread < 0 ? e.elems.length : firstSpread)); - B.line(`${arr} = ${arrNewCall(host.shapeHost, elem, String(capacity), small)}`); + // An int32 array's allocation (ir/int32-arrays.ts) constructs + // SCR_ELEM_I32 storage; its elements are proven int32s. + const int32 = host.int32Allocations.has(e); + if (int32) { + host.declare( + small + ? `declare ptr @scr_arr_new_small(i32)` + : `declare ptr @scr_arr_new(i32, ${host.sizeType})`, + ); + B.line( + small + ? `${arr} = call ptr @scr_arr_new_small(i32 ${SCR_ELEM_I32})` + : `${arr} = call ptr @scr_arr_new(i32 ${SCR_ELEM_I32}, ${host.sizeType} ${capacity})`, + ); + } else B.line(`${arr} = ${arrNewCall(host.shapeHost, elem, String(capacity), small)}`); const out = host.own({ name: arr, type: e.type }); - const acc = elemAccess(elem); + const acc: ArrayAccess = int32 ? "i32" : elemAccess(elem); let pending: LlValue[] = []; let buffer: string | undefined; const flush = (): void => { @@ -909,7 +932,8 @@ export function emitContainerExpr( B.line( `call void @scr_arr_reserve_spread(ptr ${arr}, ptr ${v.name}, ${host.sizeType} ${plain})`, ); - host.emitArrayCopyLoop(arr, v.name, acc); + // A literal with spreads is never an int32 allocation. + host.emitArrayCopyLoop(arr, v.name, runtimeAccess(acc)); return; } pending.push(host.emitExpr(el)); @@ -954,19 +978,27 @@ export function emitContainerExpr( // Ref-element reads return +1 (the inline dense path retains like // the runtime getter); own registers the owned temp in the frame. const elem = e.arr.type.elem; - const acc = elemAccess(elem); - const t = emitDenseArrayGet( + const acc = host.arrayAccess(e.arr, elem); + const read = emitDenseArrayRead( host, arr.name, idx, e.index, acc, elem, - `scr_arr_get_${acc}`, + `scr_arr_get_${runtimeAccess(acc)}`, true, host.provenDenseIndex(e.arr, e.index), ); - return host.own({ name: t, type: e.type }); + if (read.int32 !== null) + // An int32 array's element: its i32 is exact for index consumers. + return { + name: read.value, + type: e.type, + uint32: read.int32, + integer: { name: read.int32, type: "i32", signed: true, range: INT32_RANGE }, + }; + return host.own({ name: read.value, type: e.type }); } case "arrayHas": { const arr = host.emitStableReceiver(e.arr, [e.index]); @@ -979,7 +1011,7 @@ export function emitContainerExpr( idx, e.index, "has", - denseSlotWidth(host, e.arr.type.elem), + host.int32Array(e.arr) ? slotWidth("i32") : denseSlotWidth(host, e.arr.type.elem), ); return { name: t, type: e.type }; } @@ -994,7 +1026,7 @@ export function emitContainerExpr( idx, e.index, "state", - denseSlotWidth(host, e.arr.type.elem), + host.int32Array(e.arr) ? slotWidth("i32") : denseSlotWidth(host, e.arr.type.elem), ); return { name: t, type: e.type }; } diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index 8df187736..759d20927 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -476,7 +476,8 @@ function emitDenseReferenceArrayRead( no: string, slow: string, join: string, - loadType: "ptr" | "cref" | "i64" | "i8" = "ptr", + /** `i32`: an int32 array's slot, presented as the int32. */ + loadType: "ptr" | "cref" | "i64" | "i8" | "i32" = "ptr", ): void { const B = host.B; const header = (field: number): string => { @@ -567,7 +568,7 @@ function emitDenseReferenceArrayRead( statePtr, data, cap, - loadType === "i8" ? 1 : loadType === "cref" ? 4 : 8, + loadType === "i8" ? 1 : loadType === "cref" || loadType === "i32" ? 4 : 8, offset, ); host.markMemoryPointer(statePtr, "array:present"); @@ -578,9 +579,10 @@ function emitDenseReferenceArrayRead( B.startBlock(valueLabel); const valuePtr = B.tmp(), raw = B.tmp(); - // Boolean arrays store one byte per element, compact references four, - // every other kind an 8-byte slot. - const slotType = loadType === "i8" ? "i8" : loadType === "cref" ? "i32" : "i64"; + // Boolean arrays store one byte per element, compact references and int32 + // arrays four, every other kind an 8-byte slot. + const slotType = + loadType === "i8" ? "i8" : loadType === "cref" || loadType === "i32" ? "i32" : "i64"; B.line( `${valuePtr} = getelementptr inbounds ${slotType}, ptr ${data}, ${host.sizeType} ${offset}`, ); @@ -743,8 +745,15 @@ export function emitLocalArrayRead( const no = B.newLabel("local.array.missing"), join = B.newLabel("local.array.join"); const scalar = isScalarElement(read.element); + // An int32 array's arms join on the int32 element, converted to the + // payload's double once at the join, so a consumer converting the payload + // back to an integer folds to the element. + const int32 = scalar && read.element.kind === "f64" && host.int32Array(read.array); + const element = int32 ? B.slot() : null; + if (element) B.entryAllocas.push(`${element} = alloca i32`); const storeValue = (value: string): void => { - if (scalar) { + if (element) B.line(`store i32 ${value}, ptr ${element}`); + else if (scalar) { // Slot bits for f64; a canonical 0/1 for bool, like scr_union_new_bool. let bits = value; if (read.element.kind === "bool") { @@ -772,7 +781,13 @@ export function emitLocalArrayRead( no, slow, join, - !scalar ? referenceLoad(host, read.array.type) : read.element.kind === "bool" ? "i8" : "i64", + !scalar + ? referenceLoad(host, read.array.type) + : read.element.kind === "bool" + ? "i8" + : int32 + ? "i32" + : "i64", ); else B.br(slow); B.startBlock(slow); @@ -799,7 +814,8 @@ export function emitLocalArrayRead( B.line( `${value} = call double @scr_arr_get_f64(ptr ${array.name}, double ${index.name})${READ_FALLBACK}`, ); - B.line(`${bits} = bitcast double ${value} to i64`); + if (element) B.line(`${bits} = fptosi double ${value} to i32`); + else B.line(`${bits} = bitcast double ${value} to i64`); } else { host.declare(`declare zeroext i1 @scr_arr_get_bool(ptr, double)`); B.line( @@ -807,7 +823,8 @@ export function emitLocalArrayRead( ); B.line(`${bits} = zext i1 ${value} to i64`); } - B.line(`store i64 ${bits}, ptr ${payload}`); + if (element) B.line(`store i32 ${bits}, ptr ${element}`); + else B.line(`store i64 ${bits}, ptr ${payload}`); B.line(`store i32 ${read.presentTag}, ptr ${tag}`); } else { const value = B.tmp(), @@ -821,10 +838,20 @@ export function emitLocalArrayRead( } B.br(join); B.startBlock(no); - B.line(`store i64 0, ptr ${payload}`); + if (element) B.line(`store i32 0, ptr ${element}`); + else B.line(`store i64 0, ptr ${payload}`); B.line(`store i32 ${read.missingTag}, ptr ${tag}`); B.br(join); B.startBlock(join); + if (element) { + const value = B.tmp(), + number = B.tmp(), + bits = B.tmp(); + B.line(`${value} = load i32, ptr ${element}`); + B.line(`${number} = sitofp i32 ${value} to double`); + B.line(`${bits} = bitcast double ${number} to i64`); + B.line(`store i64 ${bits}, ptr ${payload}`); + } B.line(`store ptr ${box}, ptr ${localSlot}`); return read.borrow || scalar ? null : { slot: payload, type: read.element }; } diff --git a/packages/compiler/src/ir/function-references.test.ts b/packages/compiler/src/ir/function-references.test.ts index 7bef40aa6..45d046db9 100644 --- a/packages/compiler/src/ir/function-references.test.ts +++ b/packages/compiler/src/ir/function-references.test.ts @@ -208,6 +208,7 @@ const MODULE_FIELDS: Record>, Role> = { "contracts.int32Returns[]": "fact", "contracts.int32Locals[]": "other", "contracts.int32Elements[]": "other", + "int32ArrayCandidates[]": "other", "contracts.noThrow[]": "fact", "contracts.acyclic[]": "other", "contracts.immortalGlobals[]": "other", diff --git a/packages/compiler/src/ir/int32-arrays.ts b/packages/compiler/src/ir/int32-arrays.ts new file mode 100644 index 000000000..82dcf4470 --- /dev/null +++ b/packages/compiler/src/ir/int32-arrays.ts @@ -0,0 +1,442 @@ +import { F64, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js"; +import { reparseExpr } from "./serialize.js"; +import { + everyExprChild, + everyStmtChild, + transformExpr, + transformStmt, + transformStmtList, + type IrTransform, +} from "./traverse.js"; + +/** Local number arrays the backend may store as int32 slots. + * + * A candidate is a local `number[]` of a synchronous function that no + * other binding can hold and only compiled element operations reach: + * every write of the local stores a fresh array (an array literal, or + * `new Array(n).fill(v)`), and every read of it is the receiver of an + * element read, write, state test, `length`, `push` or `pop` (a discarded + * `pop` included), or the array of an element-reader helper call. The + * array never reaches a runtime function that reads its slots, a library + * call, a closure, a return or untyped code. + * + * Candidates are only proposed here. The int32 slot analysis + * (int32-slots.ts) keeps the ones whose every stored element is a proven + * int32 (never -0), assuming the candidates' own elements are int32s, at + * a fixpoint, and the LLVM backend stores those as SCR_ELEM_I32. Their + * element-reader helper calls are inlined first (rewriteCandidates), so + * no shared helper ever receives an int32 array. */ + +/** Array methods a candidate may be the receiver of: the runtime paths + * behind them read and write int32 slots through the double-valued slot + * accessors. */ +export const INT32_ARRAY_METHODS: ReadonlySet = new Set([ + "length", + "push", + "pop", + "getNumber", +]); + +/** An element-reader helper (`%arr.idxOr.*`): `(a, i) => a's state at i is + * a value ? the element : undefined`, as one returned ternary. */ +export function isElementReader(fn: IrFunction): boolean { + if (fn.params.length !== 2 || fn.body.length !== 1) return false; + const [array, index] = fn.params; + const ret = fn.body[0]!; + if (array!.type.kind !== "array" || array!.type.elem.kind !== "f64") return false; + if (index!.type.kind !== "f64" || ret.kind !== "return" || ret.value?.kind !== "ternary") + return false; + const isParam = (e: IrExpr, id: string): boolean => e.kind === "varRef" && e.localId === id; + const { cond, then, else_ } = ret.value; + return ( + cond.kind === "bin" && + cond.op === "===" && + cond.left.kind === "arrayState" && + isParam(cond.left.arr, array!.localId) && + isParam(cond.left.index, index!.localId) && + cond.right.kind === "numLit" && + cond.right.value === 1 && + then.kind === "unionWrap" && + then.value.kind === "arrayGet" && + isParam(then.value.arr, array!.localId) && + isParam(then.value.index, index!.localId) && + else_.kind === "unionWrap" && + else_.value.kind === "unitLit" + ); +} + +/** `new Array(n).fill(v)` as lowering spells it: `fill` over a sequence + * that declares a temporary empty array, sets its length and yields it. */ +function fillAllocation(e: IrExpr): (IrExpr & { kind: "arrIntrinsic" }) | null { + if (e.kind !== "arrIntrinsic" || e.method !== "fill" || e.receiver.kind !== "seqExpr") + return null; + const seq = e.receiver; + const [decl, length] = seq.stmts; + if ( + seq.stmts.length !== 2 || + decl?.kind !== "varDecl" || + decl.init?.kind !== "arrayLit" || + decl.init.elems.length !== 0 || + length?.kind !== "arraySetLength" || + length.arr.kind !== "varRef" || + length.arr.localId !== decl.localId || + seq.result.kind !== "varRef" || + seq.result.localId !== decl.localId + ) + return null; + return e; +} + +/** A fresh number array: a literal without spreads, or the fill form. */ +export function int32ArrayAllocation(e: IrExpr): boolean { + return (e.kind === "arrayLit" && (e.spreads?.length ?? 0) === 0) || fillAllocation(e) !== null; +} + +/** The array literal a candidate's allocation builds (the literal itself, + * or the fill form's temporary's), and the fill form's temporary. */ +export function allocatedLiteral( + e: IrExpr, +): { literal: IrExpr & { kind: "arrayLit" }; temporary: string | null } | null { + if (e.kind === "arrayLit") return { literal: e, temporary: null }; + const fill = fillAllocation(e); + if (!fill || fill.receiver.kind !== "seqExpr") return null; + const decl = fill.receiver.stmts[0]!; + return decl.kind === "varDecl" && decl.init?.kind === "arrayLit" + ? { literal: decl.init, temporary: decl.localId } + : null; +} + +/** `xs.pop()` whose value is unused, as lowering spells it: a sequence + * that binds the array to a hidden local, then only reads that local's + * length and truncates it with pop stores. Returns the hidden local and the + * array read it binds. */ +function discardedPop(e: IrExpr): { local: string; array: IrExpr & { kind: "varRef" } } | null { + if (e.kind !== "seqExpr") return null; + const [bind, ...rest] = e.stmts; + if (bind?.kind !== "varDecl" || bind.init?.kind !== "varRef") return null; + const local = bind.localId; + const isLocal = (x: IrExpr): boolean => x.kind === "varRef" && x.localId === local; + const expr = (x: IrExpr): boolean => { + if (x.kind === "arrIntrinsic" && x.method === "length" && isLocal(x.receiver)) return true; + return !isLocal(x) && everyExprChild(x, expr, stmt); + }; + const stmt = (x: IrStmt): boolean => { + if (x.kind === "arraySetLength" && x.pop === true && isLocal(x.arr)) return expr(x.length); + return ( + !((x.kind === "varDecl" || x.kind === "assign") && x.localId === local) && + everyStmtChild(x, expr, stmt) + ); + }; + return rest.every(stmt) && expr(e.result) ? { local, array: bind.init } : null; +} + +/** Whether evaluating `e` may assign the local `id`. */ +function writesLocal(e: IrExpr, id: string): boolean { + const expr = (x: IrExpr): boolean => + !(x.kind === "assignExpr" && x.localId === id) && everyExprChild(x, expr, stmt); + const stmt = (x: IrStmt): boolean => + !((x.kind === "varDecl" || x.kind === "assign") && x.localId === id) && + everyStmtChild(x, expr, stmt); + return !expr(e); +} + +/** The candidate locals of `fn` (see the module comment). */ +export function int32ArrayCandidates(fn: IrFunction, readers: ReadonlySet): Set { + const result = new Set(); + if (fn.async || fn.generator) return result; + const captured = new Set( + [...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId), + ); + const params = new Set(fn.params.map((p) => p.localId)); + for (const local of fn.locals) + if ( + local.type.kind === "array" && + local.type.elem.kind === "f64" && + !local.boxed && + !local.tdz && + !captured.has(local.id) && + !params.has(local.id) + ) + result.add(local.id); + if (result.size === 0) return result; + const written = new Set(); + // A receiver position: the candidate is read there without escaping. + const receiver = (e: IrExpr): void => { + if (e.kind !== "varRef") expr(e); + }; + const expr = (e: IrExpr): boolean => { + switch (e.kind) { + case "varRef": + result.delete(e.localId); + return true; + case "assignExpr": + result.delete(e.localId); + break; + case "arrayGet": + case "arrayState": + case "arrayHas": + receiver(e.arr); + expr(e.index); + return true; + case "arrIntrinsic": + if (!INT32_ARRAY_METHODS.has(e.method)) break; + receiver(e.receiver); + e.args.forEach(expr); + return true; + case "seqExpr": { + // The pop's hidden local is the array itself; every use of it is a + // length read or a pop store. + const pop = discardedPop(e); + if (!pop) break; + e.stmts.slice(1).every(stmt); + expr(e.result); + return true; + } + case "call": { + if (!readers.has(e.callee) || e.args.length !== 2) break; + const [array, index] = e.args; + // Inlining evaluates the index before the array read. + if (array!.kind === "varRef" && writesLocal(index!, array!.localId)) + result.delete(array!.localId); + receiver(array!); + expr(index!); + return true; + } + } + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + switch (s.kind) { + case "varDecl": + case "assign": { + const value = s.kind === "varDecl" ? s.init : s.value; + if (value && int32ArrayAllocation(value)) written.add(s.localId); + else if (value) result.delete(s.localId); + if (value) expr(value); + return true; + } + case "arraySet": + receiver(s.arr); + expr(s.index); + expr(s.value); + return true; + case "arraySetUndefined": + // Stores a present undefined: a state, and a cleared (+0) slot. + receiver(s.arr); + expr(s.index); + return true; + case "forOf": + result.delete(s.localId); + break; + case "tryCatch": + if (s.catchLocalId) result.delete(s.catchLocalId); + break; + } + return everyStmtChild(s, expr, stmt); + }; + fn.body.every(stmt); + for (const id of result) if (!written.has(id)) result.delete(id); + return result; +} + +/** The element values stored into each candidate: element writes, pushed + * values, literal elements and fill values. */ +export function int32ArrayStores( + fn: IrFunction, + candidates: ReadonlySet, +): Map { + const stores = new Map(); + const add = (id: string, value: IrExpr): void => { + const list = stores.get(id); + if (list) list.push(value); + else stores.set(id, [value]); + }; + const target = (e: IrExpr): string | null => + e.kind === "varRef" && candidates.has(e.localId) ? e.localId : null; + const expr = (e: IrExpr): boolean => { + if (e.kind === "arrIntrinsic" && e.method === "push") { + const id = target(e.receiver); + if (id !== null) for (const arg of e.args) add(id, arg); + } + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + if ((s.kind === "varDecl" || s.kind === "assign") && candidates.has(s.localId)) { + const value = s.kind === "varDecl" ? s.init : s.value; + if (value?.kind === "arrayLit") for (const el of value.elems) add(s.localId, el); + else if (value) { + const fill = fillAllocation(value); + if (fill) add(s.localId, fill.args[0]!); + } + } + if (s.kind === "arraySet") { + const id = target(s.arr); + if (id !== null) add(id, s.value); + } + return everyStmtChild(s, expr, stmt); + }; + fn.body.every(stmt); + return stores; +} + +/** Rewrite a function's candidates for the backend. A discarded pop uses + * the candidate in place of its hidden local. An element-reader helper call + * whose array is a candidate is inlined: the helper's returned ternary, with + * the candidate for its array and the index for its index, so the read means + * the same. An index that is not a plain read or literal is bound to a fresh + * local first: in its own declaration just before the statement when the + * call is the whole value of a declaration or assignment (the form lowering + * gives `a[i]!`, and the one the backend reads without a box), otherwise in a + * sequence around the ternary. Selection keeps out indexes that write the + * array, so evaluating the index before the array read is unobservable. */ +function rewriteCandidates( + fn: IrFunction, + candidates: ReadonlySet, + readers: ReadonlyMap, +): IrFunction { + const locals = [...fn.locals]; + let fresh = 0; + const bind = (index: IrExpr): { decl: IrStmt; ref: IrExpr } => { + const id = `%int32.read.${fresh++}`; + locals.push({ id, name: id, type: F64, mutable: false }); + return { + decl: { kind: "varDecl", localId: id, init: index, loc: index.loc }, + ref: { kind: "varRef", localId: id, type: F64, loc: index.loc }, + }; + }; + const readerCall = (e: IrExpr | null): (IrExpr & { kind: "call" }) | null => + e?.kind === "call" && + e.args.length === 2 && + readers.has(e.callee) && + e.args[0]!.kind === "varRef" && + candidates.has(e.args[0]!.localId) + ? e + : null; + const simple = (e: IrExpr): boolean => e.kind === "varRef" || e.kind === "numLit"; + // The statements of a list, with complex read indexes declared first. + const hoist = (list: IrStmt[]): IrStmt[] => + list.flatMap((s): IrStmt[] => { + if (s.kind !== "varDecl" && s.kind !== "assign") return [s]; + const value = s.kind === "varDecl" ? s.init : s.value; + const call = readerCall(value); + if (!call || simple(call.args[1]!)) return [s]; + const { decl, ref } = bind(call.args[1]!); + const read: IrExpr = { ...call, args: [call.args[0]!, ref] }; + return [decl, s.kind === "varDecl" ? { ...s, init: read } : { ...s, value: read }]; + }); + const lists = (s: IrStmt): IrStmt => { + switch (s.kind) { + case "if": + return { ...s, then: hoist(s.then), else_: s.else_ === null ? null : hoist(s.else_) }; + case "block": + case "while": + case "doWhile": + case "for": + case "forOf": + return { ...s, body: hoist(s.body) }; + case "switch": + return { ...s, cases: s.cases.map((c) => ({ ...c, body: hoist(c.body) })) }; + case "tryCatch": + return { + ...s, + tryBody: hoist(s.tryBody), + catchBody: s.catchBody === null ? null : hoist(s.catchBody), + finallyBody: s.finallyBody === null ? null : hoist(s.finallyBody), + }; + } + return s; + }; + // Hidden pop locals replaced by their candidate array. + const popLocals = new Set(); + const rewrite = (e: IrExpr): IrExpr => { + const pop = discardedPop(e); + if (e.kind === "seqExpr" && pop && candidates.has(pop.array.localId)) { + popLocals.add(pop.local); + const array = pop.array; + const substitute: IrTransform = { + expr: (x) => + x.kind === "varRef" && x.localId === pop.local ? { ...array, loc: x.loc } : x, + stmt: (x) => x, + }; + return { + ...e, + stmts: e.stmts.slice(1).map((x) => transformStmt(x, substitute)), + result: transformExpr(e.result, substitute), + }; + } + const call = readerCall(e); + if (!call) return e.kind === "seqExpr" ? { ...e, stmts: hoist(e.stmts) } : e; + const helper = readers.get(call.callee)!; + const array = call.args[0]!; + const [arrayParam, indexParam] = helper.params; + const ret = helper.body[0]!; + if (ret.kind !== "return" || !ret.value) return e; + let index = call.args[1]!; + const stmts: IrStmt[] = []; + if (!simple(index)) { + const { decl, ref } = bind(index); + stmts.push(decl); + index = ref; + } + const operand = index; + const body = transformExpr(reparseExpr(ret.value), { + expr: (x) => { + if (x.kind !== "varRef") return x; + if (x.localId === arrayParam!.localId) return reparseExpr(array); + if (x.localId === indexParam!.localId) return reparseExpr(operand); + return x; + }, + stmt: (x) => x, + }); + const typed: IrExpr = { ...body, loc: e.loc }; + return stmts.length === 0 + ? typed + : { kind: "seqExpr", stmts, result: typed, type: typed.type, loc: e.loc }; + }; + const body = transformStmtList(hoist(fn.body), { expr: rewrite, stmt: lists }); + if (fresh === 0 && popLocals.size === 0) return { ...fn, body }; + return { ...fn, body, locals: locals.filter((local) => !popLocals.has(local.id)) }; +} + +/** Propose the module's int32 array candidates (`function\0local` keys in + * `int32ArrayCandidates`), only those in `keep` when given, and rewrite + * their functions. */ +export function markInt32ArrayCandidates( + mod: IrModule, + keep: ReadonlySet | null = null, +): IrModule { + if (mod.lib !== undefined) return mod; + const readers = new Map(); + for (const fn of mod.functions) if (isElementReader(fn)) readers.set(fn.name, fn); + const names = new Set(readers.keys()); + const keys: string[] = []; + const functions = mod.functions.map((fn) => { + const candidates = int32ArrayCandidates(fn, names); + for (const id of candidates) + if (keep === null || keep.has(`${fn.name}\0${id}`)) keys.push(`${fn.name}\0${id}`); + else candidates.delete(id); + return candidates.size === 0 ? fn : rewriteCandidates(fn, candidates, readers); + }); + return keys.length === 0 ? mod : { ...mod, functions, int32ArrayCandidates: keys }; +} + +/** The module the backend emits: rewritten for exactly the candidates the + * int32 slot analysis (`prove`, returning its int32Arrays) keeps, so a + * module without int32 arrays is `mod` itself. A dropped candidate's + * rewrite is undone by marking again without it, until every candidate + * marked is kept. */ +export function selectInt32Arrays( + mod: IrModule, + prove: (marked: IrModule) => ReadonlySet, +): IrModule { + let keep: ReadonlySet | null = null; + for (let round = 0; round < 4; round++) { + const marked = markInt32ArrayCandidates(mod, keep); + if (marked === mod) return mod; + const kept = prove(marked); + const proposed = marked.int32ArrayCandidates ?? []; + if (proposed.every((key) => kept.has(key))) return marked; + keep = new Set(proposed.filter((key) => kept.has(key))); + } + return mod; +} diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index ac0747276..bb6a06867 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -10,6 +10,7 @@ import { } from "./integer-ranges.js"; import { exprFunctionReference, moduleFunctionReferences } from "./function-references.js"; import { findInitializerBindings, withInitializerBindings } from "./initializer-bindings.js"; +import { int32ArrayStores } from "./int32-arrays.js"; import { everyExprChild, everyStmtChild, everyStmtList, everyTypeChild } from "./traverse.js"; /** The storage slot an array-valued expression reads, as an @@ -169,7 +170,10 @@ export class Int32Slots { this.int32Locals.has(`${fnName}\0${localId}`) || this.int32Locals.has(`\0${localId}`) ? INT32_RANGE : null; - if (this.int32Elements.size > 0) result.elements = (arr) => this.hasInt32Elements(fnName, arr); + if (this.int32Elements.size > 0) { + result.elements = (arr) => this.hasInt32Elements(fnName, arr); + if (this.narrowedReads) result.narrowedReads = true; + } const params = this.paramFacts.get(fnName); if (params) result.params = @@ -234,6 +238,19 @@ export class Int32Slots { addInt32Elements(key: string): void { this.int32Elements.add(key); } + dropInt32Elements(key: string): boolean { + return this.int32Elements.delete(key); + } + hasInt32ElementSlot(key: string): boolean { + return this.int32Elements.has(key); + } + /** Whether the module has int32 array candidates (the narrowedReads + * range fact). */ + narrowedReads = false; + /** Local number arrays (`function\0local`) stored as int32 slots: the + * module's int32 array candidates whose every stored element the + * fixpoint proved an int32. */ + readonly int32Arrays = new Set(); /** Whether every present element of the number array `arr` evaluates to * (read inside `fnName`) is an int32. */ hasInt32Elements(fnName: string, arr: IrExpr): boolean { @@ -752,6 +769,18 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): } for (const key of contracts?.int32Locals ?? []) slots.addInt32Local(key); for (const key of contracts?.int32Elements ?? []) slots.addInt32Elements(key); + // Int32 array candidates (ir/int32-arrays.ts): assumed to hold int32 + // elements until a store the fixpoint cannot prove drops one. + const arrayCandidates = new Map>(); + slots.narrowedReads = (mod.int32ArrayCandidates?.length ?? 0) > 0; + for (const key of mod.int32ArrayCandidates ?? []) { + const cut = key.indexOf("\0"); + const fnName = key.slice(0, cut); + const set = arrayCandidates.get(fnName) ?? new Set(); + set.add(key.slice(cut + 1)); + arrayCandidates.set(fnName, set); + slots.addInt32Elements(`l:${key}`); + } /* ── candidate fields ── */ const children = new Map(); @@ -1031,6 +1060,7 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): return false; }; const hasCandidateWrite = (fn: IrFunction): boolean => { + if (arrayCandidates.has(fn.name)) return true; const box = (localId: string): boolean => slots.hasBox(slots.boxGroup(fn.name, localId)); const returns = slots.hasReturn(fn.name); return !everyStmtList(fn.body, { @@ -1076,6 +1106,25 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): continue; } const int32 = (e: IrExpr): boolean => withinInt32(ranges.get(e)); + // Every element stored into an int32 array candidate must be an int32 + // under the facts that assume the candidates' own elements are; a + // failing store drops its array, and the function is analyzed again + // without it. + const arrays = arrayCandidates.get(fn.name); + if (arrays) { + let dropped = false; + for (const [id, values] of int32ArrayStores(fn, arrays)) { + const key = `l:${fn.name}\0${id}`; + if (!slots.hasInt32ElementSlot(key) || values.every(int32)) continue; + slots.dropInt32Elements(key); + reason(`array:${fn.name}\0${id}`, "an element store is not int32"); + dropped = true; + } + if (dropped && !queued.has(fn.name)) { + queued.add(fn.name); + queue.push(fn.name); + } + } // An int32 written into a slot still assumed non-negative: widen the // assumption when the value may be negative, and reanalyze its readers. const settle = (slot: string, e: IrExpr): void => { @@ -1144,6 +1193,10 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): } for (const proven of provenByFunction.values()) for (const value of proven) slots.provenWrites.add(value); + for (const [fnName, ids] of arrayCandidates) + for (const id of ids) + if (slots.hasInt32ElementSlot(`l:${fnName}\0${id}`)) + slots.int32Arrays.add(`${fnName}\0${id}`); const ranged = rangedParameters(slots, functions, retry, numeric, (callee) => readers.get(`ret:${callee}`), ); diff --git a/packages/compiler/src/ir/integer-ranges.ts b/packages/compiler/src/ir/integer-ranges.ts index 1d295ca24..0e5ca426d 100644 --- a/packages/compiler/src/ir/integer-ranges.ts +++ b/packages/compiler/src/ir/integer-ranges.ts @@ -220,6 +220,11 @@ export interface IntegerSlotFacts { /** Number arrays whose every present element is an int32 (Int32[] * contracts): an element read that found a value reads an int32. */ elements?: (arr: IrExpr) => boolean; + /** Element reads narrowed out of a union (`xs[i]!`, also when a union + * local holds the read) keep the element's range, and a ternary arm that + * always throws yields no value. Only modules with int32 array candidates + * (ir/int32-arrays.ts) set it, so no other program's ranges change. */ + narrowedReads?: boolean; } /** A local read, or a numeric local plus or minus an integer literal @@ -240,6 +245,50 @@ function offsetRef(e: IrExpr): { localId: string; offset: number } | null { return null; } +/** Library calls that always throw. */ +const THROWING_LIB = /^error\.\w*Throw$/; + +function throws(e: IrExpr): boolean { + return e.kind === "libCall" && THROWING_LIB.test(e.fn); +} + +/** The values written to each union-typed local of `fn` by declarations + * and assignments; null for a local that another write reaches or that a + * closure captures. */ +function unionLocalWrites( + fn: IrFunction, + captures: ReadonlySet, +): Map { + const unionLocals = new Set( + fn.locals + .filter((l) => l.type.kind === "union" && !l.boxed && !captures.has(l.id)) + .map((l) => l.id), + ); + const result = new Map(); + const add = (id: string, value: IrExpr | null): void => { + if (!unionLocals.has(id)) return; + const list = result.get(id); + if (list === null) return; + if (value === null) result.set(id, null); + else if (list) list.push(value); + else result.set(id, [value]); + }; + everyStmtList(fn.body, { + stmt: (s) => { + if (s.kind === "varDecl" && s.init) add(s.localId, s.init); + else if (s.kind === "assign") add(s.localId, s.value); + else if (s.kind === "forOf") add(s.localId, null); + else if (s.kind === "tryCatch" && s.catchLocalId) add(s.catchLocalId, null); + return true; + }, + expr: (e) => { + if (e.kind === "assignExpr") add(e.localId, null); + return true; + }, + }); + return result; +} + /** Structured local dataflow. Calls cannot write uncaptured local slots, * but argument expressions can; unknown evaluation forms invalidate all * syntactically written slots before their children are inspected. Loops @@ -272,6 +321,10 @@ export function analyzeIntegerRangeFacts( .map((l) => l.id), ); const loopLocals = new Map(fn.locals.filter((l) => !captures.has(l.id)).map((l) => [l.id, l])); + // With narrowed reads, the values written to union locals (an element + // read held before it is narrowed), or null for a local some other write + // (assignExpr, for-of, catch, capture) reaches. + const unionWrites = slots.narrowedReads ? unionLocalWrites(fn, captures) : null; // A captured binding has no local dataflow: only a proof that covers // every write of the shared box anywhere applies to its reads. const boxedRange = (localId: string): IntegerRange | null => { @@ -490,6 +543,45 @@ export function analyzeIntegerRangeFacts( return range; } + /** narrowedRange through a union local: every value written to it. */ + function narrowedValue( + value: IrExpr, + unionId: string, + tag: number, + ): IntegerRange | null | "never" { + if (value.kind !== "varRef") return narrowedRange(value, unionId, tag); + const written = unionWrites?.get(value.localId); + if (!written || written.length === 0) return null; + let result: IntegerRange | null | "never" = "never"; + for (const w of written) { + const arm = narrowedRange(w, unionId, tag); + if (arm === "never") continue; + if (arm === null) return null; + result = result === "never" ? arm : union(result, arm); + } + return result; + } + /** The range of the values of union tag `tag` that `value` (already + * analyzed) can hold: "never" when no arm holds that tag. */ + function narrowedRange( + value: IrExpr, + unionId: string, + tag: number, + ): IntegerRange | null | "never" { + if (value.kind === "seqExpr") return narrowedRange(value.result, unionId, tag); + if (value.kind === "ternary") { + const yes = narrowedRange(value.then, unionId, tag), + no = narrowedRange(value.else_, unionId, tag); + if (yes === "never") return no; + if (no === "never") return yes; + return yes && no ? union(yes, no) : null; + } + if (value.kind === "unionWrap" && value.unionId === unionId) { + if (value.tag !== tag) return "never"; + return value.value.kind === "arrayGet" ? (ranges.get(value.value) ?? null) : null; + } + return null; + } function remember(e: IrExpr, range: IntegerRange | null): IntegerRange | null { // Shared IR objects have to satisfy the proof at every occurrence. const previous = ranges.get(e); @@ -683,6 +775,10 @@ export function analyzeIntegerRangeFacts( right = expr(e.else_, no); merge(facts, yes, no); if (left && right) range = union(left, right); + // With narrowed reads: an arm that always throws yields no value, + // so the other arm's range is the result's. + else if (slots.narrowedReads && throws(e.then)) range = right; + else if (slots.narrowedReads && throws(e.else_)) range = left; break; } case "logical": { @@ -777,27 +873,45 @@ export function analyzeIntegerRangeFacts( expr(e.obj, facts); range = slotRange(e); break; + case "unionNarrow": + // Only modules with narrowed reads take this rule: the narrow + // evaluates its operand first, and throws for every arm of another + // tag, so an element read `xs[i]!` (a ternary of the wrapped + // element and a wrapped undefined) has the element's range. + if (!slots.narrowedReads || e.type.kind !== "f64") { + range = conservative(e, facts); + break; + } + expr(e.value, facts); + { + const arm = narrowedValue(e.value, e.unionId, e.tag); + range = arm === "never" ? null : arm; + } + break; default: - // Do not assume the traversal order is an evaluation order for an - // unknown node. Every child gets the same conservative entry state. - havoc(facts, [e], facts); - everyExprChild( - e, - (child) => { - expr(child, new Map(facts)); - return true; - }, - (child) => { - body([child], new Map(facts)); - return true; - }, - ); - // A direct call's result carries its whole-program return proof. - range = slotRange(e); + range = conservative(e, facts); break; } return remember(e, range); } + /** An unknown node: do not assume the traversal order is an evaluation + * order. Every child gets the same conservative entry state. */ + function conservative(e: IrExpr, facts: Facts): IntegerRange | null { + havoc(facts, [e], facts); + everyExprChild( + e, + (child) => { + expr(child, new Map(facts)); + return true; + }, + (child) => { + body([child], new Map(facts)); + return true; + }, + ); + // A direct call's result carries its whole-program return proof. + return slotRange(e); + } function induction( s: IrStmt & { kind: "for" }, facts: Facts, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 12400fee9..0fa873a16 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1073,6 +1073,11 @@ export interface IrModule { * write in the program (ir/contract-check.ts). Backends may rely on them * unconditionally. Absent when the program writes no contract. */ contracts?: IrContracts; + /** Local number arrays (`function\0local`) the backend may store as + * int32 slots, proposed by ir/int32-arrays.ts after their element-reader + * calls were inlined; the int32 slot analysis keeps the ones whose every + * stored element it proves an int32. Absent when there is none. */ + int32ArrayCandidates?: string[]; /** The WebAssembly modules translated into this program (wasm/ * translate.ts), present exactly when the program uses the WebAssembly * API (possibly empty). The backend appends each module's translation diff --git a/packages/compiler/test/int32-element-array-emission.test.ts b/packages/compiler/test/int32-element-array-emission.test.ts new file mode 100644 index 000000000..8e89c8562 --- /dev/null +++ b/packages/compiler/test/int32-element-array-emission.test.ts @@ -0,0 +1,130 @@ +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, deserializeModule } from "../src/index.js"; +import type { IrModule } from "../src/ir/ir.js"; +import { markInt32ArrayCandidates, selectInt32Arrays } from "../src/ir/int32-arrays.js"; + +async function emitLlvm(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-int32-elements-")); + 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 }); + } +} + +async function lowerIr(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-int32-elements-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + return deserializeModule(await readFile(outPath, "utf8")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const source = ` +function compose(n: number, rounds: number): number { + const p: number[] = []; + const q: number[] = []; + for (let i = 0; i < n; i++) p.push((i * 7919 + 3) & (n - 1)); + for (let i = 0; i < n; i++) q.push(i); + let check = 0; + for (let r = 0; r < rounds; r++) { + for (let i = 0; i < n; i++) q[i] = p[q[i]!]!; + check = (check + q[r % n]!) | 0; + } + return check; +} +function counts(n: number): number { + const c = new Array(n).fill(0) as number[]; + for (let i = 0; i < n; i++) c[(i * 5) % n] = i; + while (c.length > 1) c.pop(); + return c[0]!; +} +function fraction(n: number): number { + const a = [1, 2, 3]; + a[1] = n / 3; + return a[1]!; +} +function negativeZero(): number { + const a = [1, -0]; + return 1 / a[1]!; +} +function counter(n: number): number { + const a = [0]; + for (let i = 0; i < n; i++) a[0] = a[0]! + 1; + return a[0]!; +} +function joined(n: number): string { + const a = [n | 0, 2]; + return a.join(","); +} +const { n } = JSON.parse('{"n": 8}') as { n: number }; +console.log(compose(n | 0, 3), counts(n | 0), fraction(n), negativeZero(), counter(n), joined(n)); +`; + +/** Every function emitted for the source function \`name\` (its int32 ABI + * body and its wrapper included). */ +function functions(llvm: string, name: string): string { + const pattern = new RegExp(`^define [^@]*@sc_b?f_${name}[.\\w]*\\([^]*?^}`, "gm"); + return [...llvm.matchAll(pattern)].map((m) => m[0]).join("\n"); +} + +test("arrays storing only int32 elements get 4-byte slots", async () => { + const llvm = await emitLlvm(source); + const compose = functions(llvm, "compose"); + expect(compose).not.toBe(""); + // Both arrays are allocated as SCR_ELEM_I32 (7). + expect(compose.match(/@scr_arr_new_small\(i32 7\)/g)?.length).toBe(2); + // Reads load an i32 slot; writes store one. + expect(compose).toMatch(/getelementptr inbounds i32, ptr %\w+, i64 %\w+\n\s+%\w+ = load i32/); + expect(compose).toMatch(/store i32 %\w+, ptr %\w+, !tbaa/); + // The element read through the helper's ternary stays in a stack box. + expect(compose).not.toContain("@scr_union_new_f64"); + expect(compose).not.toMatch(/call [^\n]*@sc_b?f__x25_arr_idxOr/); + // The fill form and a discarded pop. + const counts = functions(llvm, "counts"); + expect(counts).toMatch(/@scr_arr_new\(i32 7, i64 0\)/); + expect(counts).not.toContain("popArray"); +}); + +test("arrays with a store that may not be an int32, or that escape, keep doubles", async () => { + const llvm = await emitLlvm(source); + for (const name of ["fraction", "negativeZero", "counter", "joined"]) { + const fn = functions(llvm, name); + expect(fn, name).not.toBe(""); + expect(fn, name).not.toMatch(/@scr_arr_new(_small)?\(i32 7/); + } +}); + +test("only the candidates the proof keeps are rewritten", async () => { + const mod = await lowerIr(source); + const proposed = markInt32ArrayCandidates(mod).int32ArrayCandidates ?? []; + expect(proposed).toEqual( + expect.arrayContaining(["compose\0p.0", "compose\0q.0", "counts\0c.0", "counter\0a.0"]), + ); + // An array that reaches `join` is never proposed. + expect(proposed.some((key) => key.startsWith("joined\0"))).toBe(false); + // Without a kept candidate, the module is the input itself. + expect(selectInt32Arrays(mod, () => new Set())).toBe(mod); + const kept = new Set(["compose\0p.0", "compose\0q.0"]); + const selected = selectInt32Arrays( + mod, + (marked) => new Set((marked.int32ArrayCandidates ?? []).filter((key) => kept.has(key))), + ); + expect(selected.int32ArrayCandidates).toEqual([...kept]); + for (const fn of selected.functions) + if (fn.name !== "compose") expect(fn).toBe(mod.functions.find((f) => f.name === fn.name)); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7326-int32-element-arrays.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7326-int32-element-arrays.ts.json new file mode 100644 index 000000000..f00823a6e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7326-int32-element-arrays.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7326-int32-element-arrays.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index f3e216a36..bbe66e26e 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -277,6 +277,7 @@ typedef struct { static uint64_t scr_arr_storage_get(const ScrArrStorage *s, size_t index) { if (s->elem == SCR_ELEM_BOOL) return ((const uint8_t *)s->data)[index]; if (s->elem == SCR_ELEM_CREF) return scr_cref_decode(((const uint32_t *)s->data)[index]); + if (s->elem == SCR_ELEM_I32) return scr_arr_f64_slot((double)((const int32_t *)s->data)[index]); return s->data[index]; } @@ -621,7 +622,7 @@ static void scr_arr_grow_dense_slow(ScrArr *a, size_t need, ScrArrGrow mode); * paths pick the same capacity. */ static inline void scr_arr_grow_dense_to(ScrArr *a, size_t need, ScrArrGrow mode) { if (need <= a->cap) return; - if (a->elem == SCR_ELEM_BOOL || a->elem == SCR_ELEM_CREF || + if (a->elem == SCR_ELEM_BOOL || a->elem == SCR_ELEM_CREF || a->elem == SCR_ELEM_I32 || (a->spare & (SCR_ARR_INLINE_SLOTS | SCR_ARR_HEAD_ROOM | SCR_ARR_HAS_EXT)) || need > SCR_ARR_DENSE_LIMIT) { scr_arr_grow_dense_slow(a, need, mode); return; @@ -1904,6 +1905,10 @@ static ScrArr *scr_arr_fill_slot(ScrArr *a, uint64_t slot, uint8_t state, if (state != SCR_ARR_VALUE) scr_arr_cut(a, from); if (a->elem == SCR_ELEM_BOOL) memset(scr_arr_dense_at(a, from), (int)slot, until - from); else if (a->elem == SCR_ELEM_CREF) for (size_t i = from; i < until; i++) scr_arr_dense_put(a, i, slot); + else if (a->elem == SCR_ELEM_I32) { + int32_t v = scr_arr_i32_of(slot); + for (size_t i = from; i < until; i++) ((int32_t *)a->data)[i] = v; + } else for (size_t i = from; i < until; i++) a->data[i] = slot; if (scr_arr_has_states(a)) memset(scr_arr_state_run(a, from, until - from), state, until - from); } else { diff --git a/packages/runtime/src/scr_array.h b/packages/runtime/src/scr_array.h index e13acb233..6034462e9 100644 --- a/packages/runtime/src/scr_array.h +++ b/packages/runtime/src/scr_array.h @@ -48,6 +48,12 @@ typedef enum { * address over 8, 0 for NULL. Compact instances are never counted or * traced, so these arrays retain, release and trace nothing. */ SCR_ELEM_CREF, + /* Numbers that the compiler proves are always int32 (never -0), in + * 4-byte slots: arrays that only compiled element accesses ever reach + * (backend/llvm int32 arrays). Every runtime slot is the value's double + * bits, as for SCR_ELEM_F64: scr_arr_dense_get and scr_arr_dense_put + * convert, so the paths compiled code calls need no case of their own. */ + SCR_ELEM_I32, } ScrElemKind; typedef enum { @@ -134,11 +140,12 @@ typedef struct ScrArr { * 0 is always valid. */ uint32_t filled; /* Dense values: `cap` 8-byte slots, `cap` bytes for SCR_ELEM_BOOL or - * `cap` 4-byte slots for SCR_ELEM_CREF (scr_arr_slot_width), followed in - * the same allocation, when the array has states (SCR_ARR_STATES), by one - * state byte per slot in reverse slot order (scr_arr_states_end). Sparse - * entries and properties keep 8-byte slots for every kind; a bool slot is - * 0 or 1 wherever it lives, and a CREF slot's 8-byte form is the pointer + * `cap` 4-byte slots for SCR_ELEM_CREF and SCR_ELEM_I32 + * (scr_arr_slot_width), followed in the same allocation, when the array + * has states (SCR_ARR_STATES), by one state byte per slot in reverse slot + * order (scr_arr_states_end). Sparse entries and properties keep 8-byte + * slots for every kind; a bool slot is 0 or 1 wherever it lives, a CREF + * slot's 8-byte form is the pointer and an I32 slot's the double's bits * (scr_arr_dense_get). Rarely * used storage (sparse indices, noncanonical numeric properties, * match-result metadata) lives out of line, and an array that has it @@ -260,7 +267,7 @@ static inline const ScrArrOps *scr_arr_ops(const ScrArr *a) { /* Bytes per dense value slot of element kind `elem`. */ static inline size_t scr_elem_slot_width(uint8_t elem) { if (elem == SCR_ELEM_BOOL) return 1; - if (__builtin_expect(elem == SCR_ELEM_CREF, 0)) return sizeof(uint32_t); + if (__builtin_expect(elem == SCR_ELEM_CREF || elem == SCR_ELEM_I32, 0)) return sizeof(uint32_t); return sizeof(uint64_t); } @@ -325,11 +332,38 @@ static inline void scr_arr_need_states(ScrArr *a) { static inline uint64_t scr_cref_decode(uint32_t ref) { return (uint64_t)ref << 3; } static inline uint32_t scr_cref_encode(uint64_t slot) { return (uint32_t)(slot >> 3); } +/* The bits of `d` as a slot, and back. */ +static inline uint64_t scr_arr_f64_slot(double d) { + uint64_t slot; + memcpy(&slot, &d, sizeof slot); + return slot; +} + +static inline double scr_arr_slot_f64(uint64_t slot) { + double d; + memcpy(&d, &slot, sizeof d); + return d; +} + +/* An int32 array's slot for a double: its stores are proven int32s that + * are not -0 (and a cleared slot is +0), so anything else is a compiler + * bug. */ +static inline int32_t scr_arr_i32_of(uint64_t slot) { + double d = scr_arr_slot_f64(slot); + if (__builtin_expect(!(d >= -2147483648.0 && d <= 2147483647.0 && d == (double)(int32_t)d) || + slot == UINT64_C(0x8000000000000000), + 0)) + __builtin_trap(); + return (int32_t)d; +} + /* The dense value at `index` (< cap) as an 8-byte slot, and its store. */ static inline uint64_t scr_arr_dense_get(const ScrArr *a, size_t index) { if (a->elem == SCR_ELEM_BOOL) return ((const uint8_t *)a->data)[index]; if (__builtin_expect(a->elem == SCR_ELEM_CREF, 0)) return scr_cref_decode(((const uint32_t *)a->data)[index]); + if (__builtin_expect(a->elem == SCR_ELEM_I32, 0)) + return scr_arr_f64_slot((double)((const int32_t *)a->data)[index]); return a->data[index]; } @@ -337,6 +371,8 @@ static inline void scr_arr_dense_put(ScrArr *a, size_t index, uint64_t slot) { if (a->elem == SCR_ELEM_BOOL) ((uint8_t *)a->data)[index] = (uint8_t)slot; else if (__builtin_expect(a->elem == SCR_ELEM_CREF, 0)) ((uint32_t *)a->data)[index] = scr_cref_encode(slot); + else if (__builtin_expect(a->elem == SCR_ELEM_I32, 0)) + ((int32_t *)a->data)[index] = scr_arr_i32_of(slot); else a->data[index] = slot; } diff --git a/packages/runtime/test/test_array.c b/packages/runtime/test/test_array.c index 769b8ef3a..8cbac7f57 100644 --- a/packages/runtime/test/test_array.c +++ b/packages/runtime/test/test_array.c @@ -1614,6 +1614,64 @@ static bool inline_slots(const ScrArr *a) { return a->data == (const uint64_t *)((const char *)a + ((sizeof(ScrArr) + 7) & ~(size_t)7)); } +/* Int32 slots: 4-byte values, then the state bytes when the array has them. */ +static bool i32_layout_ok(const ScrArr *a) { + return a->elem == SCR_ELEM_I32 && scr_arr_slot_width(a) == sizeof(int32_t); +} + +/* SCR_ELEM_I32: 4-byte int32 slots behind the double-valued entry points. */ +static void test_i32_slots(void) { + ScrArr *a = scr_arr_new_small(SCR_ELEM_I32); + for (int i = 0; i < 1000; i++) scr_arr_push_f64(a, (double)(i * 7 - 3000)); + check(i32_layout_ok(a) && !scr_arr_has_states(a) && a->filled == 1000, + "int32 pushes grow four-byte storage without states"); + bool values_ok = true; + for (int i = 0; i < 1000; i++) { + values_ok &= ((const int32_t *)a->data)[i] == i * 7 - 3000; + values_ok &= scr_arr_get_f64(a, i) == (double)(i * 7 - 3000); + values_ok &= scr_arr_get_number(a, i) == (double)(i * 7 - 3000); + } + check(values_ok, "int32 values are stored as 4-byte integers and read as doubles"); + scr_arr_set_f64(a, 10, 2147483647); + scr_arr_set_f64(a, 11, -2147483648.0); + check(scr_arr_get_f64(a, 10) == 2147483647 && scr_arr_get_f64(a, 11) == -2147483648.0, + "int32 extremes round-trip"); + check(scr_arr_pop_f64(a) == 999 * 7 - 3000 && scr_arr_len(a) == 999, "pop reads the last int32"); + scr_arr_set_len(a, 5); + scr_arr_set_len(a, 8); + check(i32_layout_ok(a) && scr_arr_has_states(a) && scr_arr_state(a, 6) == SCR_ARR_HOLE && + isnan(scr_arr_get_number(a, 6)) && scr_arr_get_f64(a, 4) == 4 * 7 - 3000, + "int32 truncation clears slots and extension adds holes"); + scr_arr_set_f64(a, 20, 5); + check(scr_arr_len(a) == 21 && scr_arr_state(a, 15) == SCR_ARR_HOLE && scr_arr_get_f64(a, 20) == 5, + "an int32 store past the length leaves holes"); + scr_arr_release(scr_arr_fill_f64(a, -9, 0, 21)); + bool filled_ok = a->filled == 21; + for (int i = 0; i < 21; i++) filled_ok &= scr_arr_get_f64(a, i) == -9; + check(filled_ok, "int32 fill writes every slot"); + scr_arr_release(a); + + ScrArr *s = scr_arr_new(SCR_ELEM_I32, 0); + scr_arr_set_f64(s, 0, 1); + scr_arr_set_f64(s, 3000000, 3); + scr_arr_set_f64(s, 2000000, -2); + check(arr_sparse_len(s) == 2 && scr_arr_get_f64(s, 2000000) == -2 && scr_arr_get_f64(s, 3000000) == 3, + "int32 sparse entries keep double slots"); + scr_arr_set_len(s, 2000001); + scr_arr_release(scr_arr_fill_f64(s, 4, 1, 2000000)); + check(arr_sparse_len(s) == 0 && i32_layout_ok(s) && scr_arr_get_f64(s, 2000000) == -2 && + scr_arr_get_f64(s, 1999999) == 4 && scr_arr_get_f64(s, 0) == 1, + "densifying absorbs sparse entries into int32 slots"); + scr_arr_release(s); + + ScrArr *k = scr_arr_new(SCR_ELEM_I32, 6); + for (int i = 0; i < 6; i++) scr_arr_push_f64(k, i); + scr_arr_push_f64(k, 6); + check(i32_layout_ok(k) && scr_arr_get_f64(k, 6) == 6 && scr_arr_get_f64(k, 0) == 0, + "a known-count int32 array grows out of its block"); + scr_arr_release(k); +} + static void test_inline_slots(void) { #ifdef SCR_RC_AUDIT long strings0 = scr_str_live_count(); @@ -2472,6 +2530,7 @@ int main(int argc, char **argv) { test_splice_drop(); test_filled_prefix(); test_bool_bytes(); + test_i32_slots(); test_inline_slots(); test_head_room(); test_states_on_demand(); diff --git a/tests/corpus/7326-int32-element-arrays.ts b/tests/corpus/7326-int32-element-arrays.ts new file mode 100644 index 000000000..f5dc0d787 --- /dev/null +++ b/tests/corpus/7326-int32-element-arrays.ts @@ -0,0 +1,165 @@ +// Local number arrays whose every stored element is an int32: reads, writes, +// holes, present undefined, out-of-range and noncanonical indexes, growth, +// pop, the fill form, reassignment, and arrays that do not qualify. + +function compose(n: number, rounds: number): number { + const p: number[] = []; + const q: number[] = []; + for (let i = 0; i < n; i++) p.push((i * 7919 + 3) & (n - 1)); + for (let i = 0; i < n; i++) q.push(i); + let check = 0; + for (let r = 0; r < rounds; r++) { + for (let i = 0; i < n; i++) q[i] = p[q[i]!]!; + check = (check + q[r % n]!) | 0; + } + return check; +} + +function insertionSort(n: number): string { + const a: number[] = []; + let seed = 12345; + for (let i = 0; i < n; i++) { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + a.push((seed % 2001) - 1000); + } + for (let i = 1; i < n; i++) { + for (let j = i; j > 0 && a[j - 1]! > a[j]!; j--) { + const t = a[j]!; + a[j] = a[j - 1]!; + a[j - 1] = t; + } + } + let sorted = true; + for (let i = 1; i < n; i++) if (a[i - 1]! > a[i]!) sorted = false; + return `${sorted} ${a[0]} ${a[n >> 1]} ${a[n - 1]} ${a.length}`; +} + +function reads(): string[] { + const a = [7, -3, 2147483647, -2147483648, 0]; + const out: string[] = []; + out.push(`${a[0]} ${a[1]} ${a[2]} ${a[3]} ${a[4]} ${a[5]}`); + out.push(`${a[2]! + 1} ${a[3]! - 1} ${a[1]! / 2} ${a[2]! * 2}`); + const idx = [0, 4, 5, -1, 1.5, NaN, -0, 2 ** 32, Infinity]; + for (const i of idx) out.push(`${i}: ${a[i]} ${a[i] === undefined} ${i in a}`); + out.push(`len ${a.length}`); + return out; +} + +function holes(): string[] { + const a = [1, 2, 3]; + const out: string[] = []; + a[6] = 9; + out.push(`len ${a.length} ${a[3]} ${a[6]} ${4 in a} ${6 in a}`); + a[4] = -5; + let sum = 0, + present = 0; + for (let i = 0; i < a.length; i++) { + if (a[i] !== undefined) { + sum += a[i]!; + present++; + } + } + out.push(`sum ${sum} present ${present}`); + // A hole read through a non-null assertion stores a present undefined. + const q = [10, 20, 30]; + q[1] = a[3]!; + out.push(`q ${q[0]} ${q[1]} ${q[2]} ${1 in q} ${q.length}`); + q[1] = 21; + out.push(`q ${q[1]} ${(q[0]! + q[1]!) | 0}`); + return out; +} + +function sparse(): string[] { + const a = [1, 2]; + const out: string[] = []; + a[100000] = 42; + a[3] = 4; + out.push(`len ${a.length} ${a[100000]} ${a[3]} ${a[2]} ${a[99999]}`); + a[100000] = -42; + out.push(`${a[100000]} ${a[100001]}`); + return out; +} + +function growth(n: number): string[] { + const a: number[] = []; + const out: string[] = []; + for (let i = 0; i < n; i++) a.push((i * i) | 0); + out.push(`len ${a.length} ${a[0]} ${a[n - 1]} ${a[n]}`); + let sum = 0; + while (a.length > n / 2) sum = (sum + a.pop()!) | 0; + out.push(`popped ${sum} len ${a.length} last ${a[a.length - 1]}`); + for (let i = 0; i < 3; i++) a.push(i - 3); + out.push(`len ${a.length} ${a[a.length - 3]} ${a[a.length - 1]}`); + while (a.length > 0) a.pop(); + out.push(`empty ${a.length} ${a.pop()} ${a[0]}`); + a.push(5); + out.push(`again ${a.length} ${a[0]}`); + return out; +} + +function filled(n: number): string[] { + const counts = new Array(n).fill(0) as number[]; + const marks: number[] = new Array(n).fill(-1); + for (let i = 0; i < 3 * n; i++) { + const k = ((i * 37) % n) | 0; + counts[k] = (i * 3 - 7) | 0; + marks[(k + 1) % n] = k; + } + let a = 0, + b = 0; + for (let i = 0; i < n; i++) { + a = (a + counts[i]!) | 0; + b = (b + marks[i]!) | 0; + } + return [`fill ${counts.length} ${a} ${b} ${counts[n]} ${marks[0]}`]; +} + +function reassigned(rounds: number): string { + let a = [1, 2, 3]; + let total = 0; + for (let r = 0; r < rounds; r++) { + if (r % 3 === 0) a = [r, r + 1]; + else a.push(r); + total = (total + a[a.length - 1]! + a.length) | 0; + } + return `reassigned ${total} ${a.length} ${a[0]}`; +} + +// These arrays do not qualify; they print the same values as before. +function disqualified(): string[] { + const out: string[] = []; + const negZero = [0, -0]; + out.push(`${Object.is(negZero[1], -0)} ${1 / negZero[1]!}`); + const fraction = [1, 2]; + fraction[1] = 2.5; + out.push(`${fraction[1]}`); + const big = [1]; + big[0] = big[0]! + 2147483647; + out.push(`${big[0]}`); + const joined = [3, 1, 2]; + joined.sort((x, y) => x - y); + out.push(joined.join(",")); + const escaped = [4, 5]; + const alias = escaped; + alias[0] = 6; + out.push(`${escaped[0]} ${alias[1]}`); + const counter = [0]; + for (let i = 0; i < 10; i++) counter[0] = counter[0]! + 1; + out.push(`${counter[0]}`); + return out; +} + +const config = JSON.parse('{"n": 256, "rounds": 50, "sort": 300}') as { + n: number; + rounds: number; + sort: number; +}; +console.log(compose(config.n | 0, config.rounds | 0)); +console.log(insertionSort(config.sort | 0)); +for (const line of reads()) console.log(line); +for (const line of holes()) console.log(line); +for (const line of sparse()) console.log(line); +for (const line of growth(config.n | 0)) console.log(line); +for (const line of filled(17)) console.log(line); +console.log(reassigned(config.rounds | 0)); +for (const line of disqualified()) console.log(line); diff --git a/tests/harness/corpus-costs/7326-int32-element-arrays.ts.json b/tests/harness/corpus-costs/7326-int32-element-arrays.ts.json new file mode 100644 index 000000000..168ae57fb --- /dev/null +++ b/tests/harness/corpus-costs/7326-int32-element-arrays.ts.json @@ -0,0 +1 @@ +{ "plain": 3700, "san": 4100 }