diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx index db1360d31..cd2ba8647 100644 --- a/docs/content/docs/how-it-works.mdx +++ b/docs/content/docs/how-it-works.mdx @@ -70,7 +70,7 @@ Without `-o`, artifacts are written to `.scriptc/` next to the input. Different ### External linking -Program objects define `main` and leave selected `scr_*` runtime functions undefined. A strong reference to `scr_runtime_abi_v14` checks runtime compatibility at link time. +Program objects define `main` and leave selected `scr_*` runtime functions undefined. A strong reference to `scr_runtime_abi_v15` checks runtime compatibility at link time. `--print=native-link-info` emits the program object and a JSON recipe containing the runtime pack and link order for external builds. This object ABI is experimental and requires an exact runtime version. See [Native Program Objects](/docs/native-objects) for examples. diff --git a/examples/native-object/README.md b/examples/native-object/README.md index 80105456f..21673ef88 100644 --- a/examples/native-object/README.md +++ b/examples/native-object/README.md @@ -15,4 +15,4 @@ $ ./app-ld `cc` uses the C compiler as a linker driver. `ld` links the same precompiled runtime objects directly with the selected Apple SDK. The object defines `main`; it is a complete program object, not a library to load into another process. Use `scriptc build --lib --profile ...` for a host-callable static library. -The external object ABI is experimental. Always consume the runtime pack at the exact `runtime_pack.version` reported by the same scriptc installation. The object requires `scr_runtime_abi_v14`, so a mismatched runtime fails during the link instead of starting with an incompatible ABI. +The external object ABI is experimental. Always consume the runtime pack at the exact `runtime_pack.version` reported by the same scriptc installation. The object requires `scr_runtime_abi_v15`, so a mismatched runtime fails during the link instead of starting with an incompatible ABI. diff --git a/packages/cli/test/native-link-info.test.ts b/packages/cli/test/native-link-info.test.ts index bb41f8520..a497eeac8 100644 --- a/packages/cli/test/native-link-info.test.ts +++ b/packages/cli/test/native-link-info.test.ts @@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => { minimum_os: "14.0", }, program: { object, entry_symbol: "main" }, - runtime_abi: { version: 14, marker: "scr_runtime_abi_v14" }, + runtime_abi: { version: 15, marker: "scr_runtime_abi_v15" }, runtime_pack: { kind: "precompiled", package: "@scriptc/runtime-darwin-arm64", @@ -165,6 +165,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => { () => null, (failure: { stderr?: string }) => failure, ); - expect(error?.stderr).toContain("scr_runtime_abi_v14"); + expect(error?.stderr).toContain("scr_runtime_abi_v15"); }); }); diff --git a/packages/compiler/src/backend/llvm/common.ts b/packages/compiler/src/backend/llvm/common.ts index c409fb87b..013d7a8ea 100644 --- a/packages/compiler/src/backend/llvm/common.ts +++ b/packages/compiler/src/backend/llvm/common.ts @@ -10,17 +10,19 @@ import type { import type { LlvmEmitterContext } from "./expr-context.js"; /** Field numbers of %ScrArr, which mirrors scr_runtime.h's ScrArr field for - * field (its static assertions pin `cyc` and `data`). */ + * field (its static assertions pin `cyc`, `filled` and `data`). */ export const SCR_ARR_FIELD = { rc: 0, len: 1, cap: 2, elem: 3, cyc: 4, - ops: 5, - data: 6, - present: 7, - ext: 8, + spare: 5, + filled: 6, + ops: 7, + data: 8, + present: 9, + ext: 10, } as const; /** Lines that decide whether the double `index` is an integer in diff --git a/packages/compiler/src/backend/llvm/constant-callbacks.ts b/packages/compiler/src/backend/llvm/constant-callbacks.ts index 17c97edb1..31e2f74db 100644 --- a/packages/compiler/src/backend/llvm/constant-callbacks.ts +++ b/packages/compiler/src/backend/llvm/constant-callbacks.ts @@ -1,5 +1,6 @@ import { isBorrowSafeMath } from "../../ir/analysis.js"; import type { IrExpr, IrFunction, IrLibFn, IrModule, IrStmt } from "../../ir/ir.js"; +import { exprFunctionReference, moduleFunctionReferences } from "../../ir/function-references.js"; import { everyStmtList } from "../../ir/traverse.js"; import type { CallLifetimes } from "./call-lifetimes.js"; @@ -38,31 +39,14 @@ export function findConstantCallbacks( ); const globals = new Map(); const writes = new Set(); - const escaped = new Set([ - mod.entry, - ...(mod.lib?.exports.map((entry) => entry.fnName) ?? []), - ]); + // A function named anywhere but as a direct call's callee can be entered + // by a generated adapter or the runtime, which a direct-call census cannot + // specialize: the entry, exports, class helpers and methods here, and + // closures and retag helpers in the bodies (collected by the walk below). + const escaped = new Set(moduleFunctionReferences(mod)); for (const cls of mod.classes ?? []) for (const method of ["constructor", ...(cls.methods ?? [])]) escaped.add(`%${cls.name}.${method}`); - // Metadata and non-call expression references can expose an entry to a - // generated adapter or runtime callback. A direct-call census cannot - // specialize those entries. Conservatively reject every such reference, - // including future metadata that names a function. - function externalReferences(value: unknown): void { - if (typeof value === "string") { - if (functions.has(value)) escaped.add(value); - } else if (Array.isArray(value)) value.forEach(externalReferences); - else if (value && typeof value === "object") { - const object = value as Record; - for (const key in object) { - if (object["kind"] === "call" && key === "callee") continue; - externalReferences(object[key]); - } - } - } - const { functions: _functions, ...metadata } = mod; - externalReferences(metadata); const incoming = new Map(); // Where each lifted function's environment is created, for resolving its // immutable captures in the creating scope. @@ -100,8 +84,9 @@ export function findConstantCallbacks( } if (expr.kind === "callValue" && expr.callee.kind === "varRef") callees.add(expr.callee.localId); + const referenced = exprFunctionReference(expr); + if (referenced !== undefined) escaped.add(referenced); if (expr.kind === "closure") { - escaped.add(expr.fnName); const sites = closureSites.get(expr.fnName) ?? []; sites.push({ owner: fn.name, captures: expr.captures }); closureSites.set(expr.fnName, sites); @@ -111,7 +96,6 @@ export function findConstantCallbacks( return true; }, }); - externalReferences(fn.body); definitions.set(fn.name, defs); constDefinitions.set(fn.name, consts); indirectCallees.set(fn.name, callees); diff --git a/packages/compiler/src/backend/llvm/dense-array-access.test.ts b/packages/compiler/src/backend/llvm/dense-array-access.test.ts index bf9eb3118..df771a1d2 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.test.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.test.ts @@ -152,6 +152,56 @@ test.each([32, 64] as const)( }, ); +test.each([32, 64] as const)( + "indices below the hole-free prefix bound skip the state bytes (%i-bit)", + (bits) => { + const read: IrExpr = { kind: "arrayGet", arr: ref("a", nums), index, type: F64, loc }; + const llvm = emitLlvmModule( + module("bound", VOID, [ + { + kind: "arraySet", + arr: ref("a", nums), + index, + value: { kind: "bin", op: "+", left: read, right: read, type: F64, loc }, + loc, + }, + { + kind: "arraySet", + arr: ref("s", words), + index, + value: { kind: "strLit", value: "x", type: STRING, loc }, + loc, + }, + ]), + { pointerBits: bits }, + ); + const fn = body(llvm, "bound"); + const size = bits === 32 ? "i32" : "i64"; + // The bound is %ScrArr field 6, an i32 compared against the index. + const bound = fn.match(/getelementptr inbounds %ScrArr, ptr .*, i32 0, i32 6$/gm) ?? []; + expect(bound.length).toBeGreaterThanOrEqual(4); + expect(fn).toMatch(/load i32, ptr /); + // The prefix blocks load or store the slot without a state access. + const blocks = (label: string): string[] => + [...fn.matchAll(new RegExp(`^${label}\\d+:\\n((?: .*\\n)*)`, "gm"))].map((m) => m[1]!); + const reads = blocks("arr\\.get\\.value"); + expect(reads).toHaveLength(2); + for (const block of reads) { + expect(block).toMatch(/load double, ptr /); + expect(block).not.toMatch(/i8/); + } + const writes = blocks("arr\\.set\\.filled"); + expect(writes).toHaveLength(2); + for (const block of writes) expect(block).not.toMatch(/i8/); + expect(writes[0]).toMatch(/store double .*, ptr /); + // The replaced string below the bound is released after the store. + expect(writes[1]).toMatch(/store ptr .*\n[^]*call void @scr_str_release\(/); + // A state-checked store at the bound extends it. + expect(fn).toMatch(new RegExp(`icmp eq ${size} .*`)); + expect(fn).toMatch(/store i32 .*, ptr /); + }, +); + test("state and presence queries answer dense slots inline", () => { const state: IrExpr = { kind: "arrayState", arr: ref("b", flags), index, type: F64, loc }; const has: IrExpr = { kind: "arrayHas", arr: ref("s", words), index, type: BOOL, loc }; diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts index dd0c20588..c42a4c0f5 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.ts @@ -8,10 +8,16 @@ * `undefined`, sparse indices, noncanonical numeric properties (negative, * fractional, NaN, infinite, 2^32-1), and out-of-range reads branch to the * unchanged runtime call, which is marked cold. The ScrArr field indices - * (SCR_ARR_FIELD) match the C layout in scr_runtime.h. */ + * (SCR_ARR_FIELD) match the C layout in scr_runtime.h. + * + * Indices below the array's hole-free prefix bound (`filled`) skip the + * state bytes altogether: the runtime keeps every state below it a present + * value, so reads load the slot directly and writes replace it without + * touching its state. Other dense indices take the state-checked path, + * whose writes extend the bound when they continue it. */ import { isRefCounted, type IrExpr, type IrType } from "../../ir/ir.js"; -import { SCR_ARR_FIELD } from "./common.js"; +import { f64Lit, SCR_ARR_FIELD } from "./common.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import { exactInteger, widenInteger } from "./integer-values.js"; @@ -28,6 +34,8 @@ interface DenseGuard { offset: string; /** The array length loaded by the guard, in the target's size type. */ len: string; + /** The hole-free prefix bound loaded by the guard (i32). */ + filled: string; /** Dense value and state storage base pointers. */ data: string; states: string; @@ -36,7 +44,10 @@ interface DenseGuard { /** Branch to `slow` unless `index` names a canonical array index inside * dense storage. Reads also require it below the length; writes may land * at or past the length while still inside the dense capacity (the runtime - * then extends the length over hole states exactly the same way). */ + * then extends the length over hole states exactly the same way). An index + * below the hole-free prefix bound branches to `filledLabel` first; both + * that block and the returned (state-checked) dense block may use the + * guard's values. */ function emitDenseGuard( host: LlvmEmitterContext, array: string, @@ -44,6 +55,7 @@ function emitDenseGuard( indexExpr: IrExpr | undefined, slow: string, requireBelowLen: boolean, + filledLabel: string, ): DenseGuard { const B = host.B; const size = host.sizeType; @@ -72,17 +84,8 @@ function emitDenseGuard( const lenPtr = B.tmp(), len = B.tmp(), capPtr = B.tmp(), - cap = B.tmp(); - B.line(`${lenPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 1`); - host.markMemoryPointer(lenPtr, "array:header"); - B.line(`${len} = load ${size}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`); - B.line(`${capPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 2`); - host.markMemoryPointer(capPtr, "array:header"); - B.line(`${cap} = load ${size}, ptr ${capPtr}${host.fieldAliasAttachment(capPtr)}`); - // Load the storage bases before branching: the header of a live array is - // always readable, and unconditional loads let LLVM hoist them out of - // loops whose only calls are read-only fallbacks. - const dataPtr = B.tmp(), + cap = B.tmp(), + dataPtr = B.tmp(), data = B.tmp(), statesPtr = B.tmp(), states = B.tmp(); @@ -91,17 +94,58 @@ function emitDenseGuard( ); host.markMemoryPointer(dataPtr, "array:header"); B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`); + const loadChecked = (): void => { + B.line(`${lenPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 1`); + host.markMemoryPointer(lenPtr, "array:header"); + B.line(`${len} = load ${size}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`); + B.line(`${capPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 2`); + host.markMemoryPointer(capPtr, "array:header"); + B.line(`${cap} = load ${size}, ptr ${capPtr}${host.fieldAliasAttachment(capPtr)}`); + B.line( + `${statesPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${SCR_ARR_FIELD.present}`, + ); + host.markMemoryPointer(statesPtr, "array:header"); + B.line(`${states} = load ptr, ptr ${statesPtr}${host.fieldAliasAttachment(statesPtr)}`); + }; + // Reads load the storage bases before branching: the header of a live + // array is always readable, and unconditional loads let LLVM hoist them + // out of loops whose only calls are read-only fallbacks. Writes load the + // fields that only the state-checked path needs inside it, which keeps + // them off a hot loop of prefix stores (that path stores to the header). + const eagerHeader = requireBelowLen; + if (eagerHeader) loadChecked(); + const filledPtr = B.tmp(), + filled = B.tmp(), + filledWide = B.tmp(); B.line( - `${statesPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${SCR_ARR_FIELD.present}`, + `${filledPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${SCR_ARR_FIELD.filled}`, ); - host.markMemoryPointer(statesPtr, "array:header"); - B.line(`${states} = load ptr, ptr ${statesPtr}${host.fieldAliasAttachment(statesPtr)}`); + host.markMemoryPointer(filledPtr, "array:header"); + B.line(`${filled} = load i32, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`); + B.line(`${filledWide} = zext i32 ${filled} to i64`); const widen = (value: string): string => { if (size === "i64") return value; const t = B.tmp(); B.line(`${t} = zext i32 ${value} to i64`); return t; }; + let offset = wide; + if (size !== "i64") { + offset = B.tmp(); + B.line(`${offset} = trunc i64 ${wide} to i32`); + } + // The bound never exceeds the length or the dense capacity. + let inFilled = B.tmp(); + B.line(`${inFilled} = icmp ult i64 ${wide}, ${filledWide}`); + if (integral) { + const t = B.tmp(); + B.line(`${t} = and i1 ${integral}, ${inFilled}`); + inFilled = t; + } + const check = B.newLabel("arr.check"); + B.condBr(inFilled, filledLabel, check); + B.startBlock(check); + if (!eagerHeader) loadChecked(); const conditions: string[] = []; if (integral) conditions.push(integral); const belowCap = B.tmp(); @@ -121,15 +165,21 @@ function emitDenseGuard( const dense = B.newLabel("arr.dense"); B.condBr(ok, dense, slow); B.startBlock(dense); - let offset = wide; - if (size !== "i64") { - offset = B.tmp(); - B.line(`${offset} = trunc i64 ${wide} to i32`); - } - return { offset, len, data, states }; + return { offset, len, filled, data, states }; } -function statePointer(host: LlvmEmitterContext, guard: DenseGuard): string { +/** The i32 prefix bound in the target's size type. */ +function sizedFilled(host: LlvmEmitterContext, filled: string): string { + if (host.sizeType !== "i64") return filled; + const t = host.B.tmp(); + host.B.line(`${t} = zext i32 ${filled} to i64`); + return t; +} + +function statePointer( + host: LlvmEmitterContext, + guard: Pick, +): string { const B = host.B; const statePtr = B.tmp(); B.line( @@ -139,7 +189,10 @@ function statePointer(host: LlvmEmitterContext, guard: DenseGuard): string { return statePtr; } -function valuePointer(host: LlvmEmitterContext, guard: DenseGuard): string { +function valuePointer( + host: LlvmEmitterContext, + guard: Pick, +): string { const B = host.B; const valuePtr = B.tmp(); B.line( @@ -183,12 +236,12 @@ export function emitDenseArrayGet( return t; } const slow = B.newLabel("arr.get.slow"), - join = B.newLabel("arr.get.join"); - const guard = emitDenseGuard(host, array, index, indexExpr, slow, true); + join = B.newLabel("arr.get.join"), + valueLabel = B.newLabel("arr.get.value"); + const guard = emitDenseGuard(host, array, index, indexExpr, slow, true, valueLabel); const state = loadState(host, guard); const present = B.tmp(); B.line(`${present} = icmp eq i8 ${state}, ${SCR_ARR_VALUE}`); - const valueLabel = B.newLabel("arr.get.value"); B.condBr(present, valueLabel, slow); B.startBlock(valueLabel); const ptr = valuePointer(host, guard); @@ -259,8 +312,9 @@ export function emitDenseArrayState( const ty = kind === "state" ? "double" : "i1"; host.declare(`declare ${kind === "state" ? "double" : "zeroext i1"} @${fn}(ptr, double)`); const slow = B.newLabel("arr.state.slow"), - join = B.newLabel("arr.state.join"); - const guard = emitDenseGuard(host, array, index, indexExpr, slow, true); + join = B.newLabel("arr.state.join"), + filledLabel = B.newLabel("arr.state.filled"); + const guard = emitDenseGuard(host, array, index, indexExpr, slow, true, filledLabel); const state = loadState(host, guard); const fast = B.tmp(); if (kind === "state") B.line(`${fast} = uitofp i8 ${state} to double`); @@ -269,13 +323,19 @@ export function emitDenseArrayState( B.br(fastLabel); B.startBlock(fastLabel); B.br(join); + // Below the bound every state is a present value. + B.startBlock(filledLabel); + B.br(join); B.startBlock(slow); const slowValue = B.tmp(); B.line(`${slowValue} = call ${ty} @${fn}(ptr ${array}, double ${index.name})${READ_FALLBACK}`); B.br(join); B.startBlock(join); const result = B.tmp(); - B.line(`${result} = phi ${ty} [ ${fast}, %${fastLabel} ], [ ${slowValue}, %${slow} ]`); + const filledValue = kind === "state" ? f64Lit(SCR_ARR_VALUE) : "true"; + B.line( + `${result} = phi ${ty} [ ${fast}, %${fastLabel} ], [ ${filledValue}, %${filledLabel} ], [ ${slowValue}, %${slow} ]`, + ); return result; } @@ -304,9 +364,21 @@ export function emitDenseArraySet( return; } const slow = B.newLabel("arr.set.slow"), - join = B.newLabel("arr.set.join"); - const guard = emitDenseGuard(host, array, index, indexExpr, slow, false); + join = B.newLabel("arr.set.join"), + filledLabel = B.newLabel("arr.set.filled"); + const guard = emitDenseGuard(host, array, index, indexExpr, slow, false, filledLabel); const { offset, len } = guard; + const storeValue = (valuePtr: string): void => { + if (acc === "f64") { + B.line(`store double ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); + } else if (acc === "bool") { + const bits = B.tmp(); + B.line(`${bits} = zext i1 ${value} to i64`); + B.line(`store i64 ${bits}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); + } else { + B.line(`store ptr ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); + } + }; const statePtr = statePointer(host, guard); const valuePtr = valuePointer(host, guard); let oldState: string | null = null; @@ -317,16 +389,26 @@ export function emitDenseArraySet( B.line(`${oldState} = load i8, ptr ${statePtr}${host.fieldAliasAttachment(statePtr)}`); B.line(`${old} = load ptr, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); } - if (acc === "f64") { - B.line(`store double ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); - } else if (acc === "bool") { - const bits = B.tmp(); - B.line(`${bits} = zext i1 ${value} to i64`); - B.line(`store i64 ${bits}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); - } else { - B.line(`store ptr ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); - } + storeValue(valuePtr); B.line(`store i8 ${SCR_ARR_VALUE}, ptr ${statePtr}${host.fieldAliasAttachment(statePtr)}`); + // A store at the bound continues the hole-free prefix. + const filledWide = sizedFilled(host, guard.filled), + continues = B.tmp(); + B.line(`${continues} = icmp eq ${host.sizeType} ${offset}, ${filledWide}`); + const bumpLabel = B.newLabel("arr.set.bump"), + afterBump = B.newLabel("arr.set.state"); + B.condBr(continues, bumpLabel, afterBump); + B.startBlock(bumpLabel); + const nextFilled = B.tmp(), + filledPtr = B.tmp(); + B.line(`${nextFilled} = add i32 ${guard.filled}, 1`); + B.line( + `${filledPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${SCR_ARR_FIELD.filled}`, + ); + host.markMemoryPointer(filledPtr, "array:header"); + B.line(`store i32 ${nextFilled}, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`); + B.br(afterBump); + B.startBlock(afterBump); // Writes at or past the length extend it over the (hole) gap, exactly // like the runtime's dense store. const grows = B.tmp(); @@ -352,6 +434,20 @@ export function emitDenseArraySet( host.releaseValue(old, element); } B.br(join); + // Below the bound the slot holds a present value: replace it, leaving + // the state alone, and release a replaced reference after the store. + B.startBlock(filledLabel); + const filledValuePtr = valuePointer(host, guard); + let filledOld: string | null = null; + if (acc === "ref") { + filledOld = B.tmp(); + B.line( + `${filledOld} = load ptr, ptr ${filledValuePtr}${host.fieldAliasAttachment(filledValuePtr)}`, + ); + } + storeValue(filledValuePtr); + if (filledOld) host.releaseValue(filledOld, element); + B.br(join); B.startBlock(slow); call(" cold"); B.br(join); @@ -400,8 +496,8 @@ export function emitDenseArrayPush( states = B.tmp(); B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`); B.line(`${states} = load ptr, ptr ${statesPtr}${host.fieldAliasAttachment(statesPtr)}`); - const statePtr = statePointer(host, { offset: len, len, data, states }); - const valuePtr = valuePointer(host, { offset: len, len, data, states }); + const statePtr = statePointer(host, { offset: len, states }); + const valuePtr = valuePointer(host, { offset: len, data }); if (acc === "f64") { B.line(`store double ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); } else if (acc === "bool") { @@ -416,6 +512,18 @@ export function emitDenseArrayPush( fastValue = B.tmp(); B.line(`${next} = add nuw ${size} ${len}, 1`); B.line(`store ${size} ${next}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`); + // An append to a hole-free array keeps it hole-free. + const filledPtr = header(SCR_ARR_FIELD.filled), + filled = B.tmp(), + packed = B.tmp(), + nextFilled = B.tmp(), + bumped = B.tmp(); + B.line(`${filled} = load i32, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`); + const filledWide = sizedFilled(host, filled); + B.line(`${packed} = icmp eq ${size} ${filledWide}, ${len}`); + B.line(`${nextFilled} = add i32 ${filled}, 1`); + B.line(`${bumped} = select i1 ${packed}, i32 ${nextFilled}, i32 ${filled}`); + B.line(`store i32 ${bumped}, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`); B.line(`${fastValue} = sitofp ${size} ${next} to double`); B.br(join); B.startBlock(slow); diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index b8491be97..3e71c05c7 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1204,6 +1204,7 @@ export class LlDyn { { index: 2, type: host.sizeType, name: "capacity" }, { index: SCR_ARR_FIELD.data, type: "ptr" as const, name: "data" }, { index: SCR_ARR_FIELD.present, type: "ptr" as const, name: "presence" }, + { index: SCR_ARR_FIELD.filled, type: "i32" as const, name: "filled" }, ]; members.forEach((member) => { const cachedPtr = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 5f9569bf0..b01d12c29 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -81,6 +81,8 @@ import { analyzeInt32Slots, type Int32Slots } from "../../ir/int32-slots.js"; import { analyzeFinalFields, constructorClass, type FinalFields } from "../../ir/final-fields.js"; import { foldConstantGlobals } from "../../ir/constant-globals.js"; import { findIntegerViews } from "./integer-views.js"; +import { findInt32Locals, type Int32Local } from "./int32-locals.js"; +import { exactInteger, narrowInteger } from "./integer-values.js"; import { findInitializerBindings, withInitializerBindings } from "../../ir/initializer-bindings.js"; import { findConstantNumericTables, type ConstantNumericTable } from "../../ir/constant-tables.js"; import { @@ -768,6 +770,11 @@ export class LlEmitter { private byteWindowEntries: ReturnType = new Map(); bytesBounds: ReadonlySet = new Set(); integerViews = new Map(); + /** Number locals of the current function stored only as an i32 + * (int32-locals.ts); their slots are `alloca i32`. */ + int32Locals = new Map(); + /** The written values (and `++`/`--` nodes) the int32 proof covers. */ + int32LocalWrites: ReadonlySet = new Set(); /** Enclosing try-with-FINALLY regions, innermost last: a `return` * inside one runs every crossed finally (innermost first) before the * actual ret — the runtime ABI’s pending-return path, with the finally @@ -1837,13 +1844,13 @@ export class LlEmitter { `%ScrFfiTable = type { ptr, ${this.sizeType}, ${this.sizeType}, ptr, i8, ptr, ptr, ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, ptr, ptr }`, `%ScrRegex = type { ${this.sizeType}, ptr, ptr, ptr, double, ptr, ptr }`, // ScrArr mirrors scr_runtime.h field-for-field: { rc, len, cap, elem, - // cyc, ops, data, present, ext } (field numbers in SCR_ARR_FIELD), - // where ops points at the element type's constant RC descriptor + // cyc, spare, filled, ops, data, present, ext } (field numbers in + // SCR_ARR_FIELD), where filled bounds the hole-free prefix, ops points at the element type's constant RC descriptor // (%ScrArrOps, SCR_ELEM_REF only) and ext at the out-of-line sparse, // property and metadata storage (NULL until first used). Live dynamic // stream commits swap its dense, presence and out-of-line storage while // preserving the target object's identity. - `%ScrArr = type { ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, i32, i32, ptr, ptr, ptr, ptr }`, + `%ScrArr = type { ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, i8, i8, i16, i32, ptr, ptr, ptr, ptr }`, `%ScrArrOps = type { ptr, ptr, ptr }`, // The ScrMap prefix { rc, key_kind, val_kind, val_retain, val_release, // val_trace, key_retain, key_release, key_trace } — the inline RC @@ -2027,16 +2034,16 @@ export class LlEmitter { for (const [, inst] of this.templateStringsInstances) { // One immortal ScrArr per tagged-template site: a [N x ptr] data // global of interned cooked-string literals, and the ScrArr header - // over it (rc == SIZE_MAX, len == cap, SCR_ELEM_STR = 2, no REF - // entry points). Every dense slot is present; reads retain immortal - // strings — a no-op. + // over it (rc == SIZE_MAX, len == cap == filled, SCR_ELEM_STR = 2, + // no REF entry points). Every dense slot is present; reads retain + // immortal strings — a no-op. const n = inst.slots.length; const present = n === 0 ? "zeroinitializer" : `[ ${inst.slots.map(() => "i8 1").join(", ")} ]`; out.push( `@${inst.sym}_data = internal constant [${n} x ptr] [ ${inst.slots.map((s) => `ptr ${s}`).join(", ")} ]`, `@${inst.sym}_present = internal constant [${n} x i8] ${present}`, - `@${inst.sym} = internal global %ScrArr { ${this.sizeType} -1, ${this.sizeType} ${n}, ${this.sizeType} ${n}, i32 2, i32 0, ptr null, ptr @${inst.sym}_data, ptr @${inst.sym}_present, ptr null }`, + `@${inst.sym} = internal global %ScrArr { ${this.sizeType} -1, ${this.sizeType} ${n}, ${this.sizeType} ${n}, i8 2, i8 0, i16 0, i32 ${n}, ptr null, ptr @${inst.sym}_data, ptr @${inst.sym}_present, ptr null }`, ); } if (this.templateStringsInstances.size > 0) out.push(``); @@ -5096,6 +5103,20 @@ export class LlEmitter { this.walkBorrows = this.walkBorrowsByFunction.get(fn.name) ?? new Set(); this.integerRanges = analyzeIntegerRanges(numericFn, this.int32Slots.facts(fn.name)); this.bytesBounds = findBytesBounds(numericFn, this.integerRanges); + // Debug builds keep every number local a double for the debugger's + // variable view. Lazily boxed captured parameters keep their double, + // which the box is created from. + const int32Locals = + this.debug === null + ? findInt32Locals( + fn, + this.integerRanges, + this.int32Slots.facts(fn.name), + new Set(this.lazyCaptureBoxes.keys()), + ) + : null; + this.int32Locals = int32Locals?.locals ?? new Map(); + this.int32LocalWrites = int32Locals?.values ?? new Set(); this.chainSlots.clear(); this.finallyStack = []; this.tryStack = []; @@ -5144,6 +5165,8 @@ export class LlEmitter { const paramIds = new Set(fn.params.map((p) => p.localId)); if (this.debug === null) { for (const id of findIntegerViews(numericFn)) { + // An i32 slot is its own view. + if (this.int32Locals.has(id)) continue; const slot = B.slot(); B.entryAllocas.push( `${slot} = alloca i32 ; integer view ${this.numericLocals.get(id)!.name}`, @@ -5165,7 +5188,9 @@ export class LlEmitter { const slotTy = local.boxed || this.captureIds.has(local.id) || local.type.kind === "caught" ? "ptr" - : this.llType(local.type); + : this.int32Locals.has(local.id) + ? "i32" + : this.llType(local.type); B.entryAllocas.push(`%${mangleLocal(local.id)} = alloca ${slotTy} ; ${local.name}`); this.emitDebugLocal( local, @@ -5244,6 +5269,8 @@ export class LlEmitter { fnScope.push({ slot, type: p.type, boxed: true }); continue; } + const incoming = { name: `%p_${mangleLocal(p.localId)}`, type: p.type }; + if (this.storeInt32Local(p.localId, incoming)) continue; B.line(`store ${this.llType(p.type)} %p_${mangleLocal(p.localId)}, ptr ${slot}`); this.storeIntegerView(p.localId, { name: `%p_${mangleLocal(p.localId)}`, type: p.type }); if (isRefCounted(p.type) && !borrowed?.has(index)) fnScope.push({ slot, type: p.type }); @@ -5712,6 +5739,10 @@ export class LlEmitter { this.frames.push([]); switch (s.kind) { case "varDecl": { + if (s.init && this.int32Locals.has(s.localId)) { + this.storeInt32Local(s.localId, this.emitExpr(s.init), s.init); + break; + } const b = this.binding(s.localId); if (this.walkBorrows.has(s.localId) && b.kind === "local") { // A borrowed walk pointer: no retain now, no release at scope exit. @@ -5862,6 +5893,10 @@ export class LlEmitter { break; } case "assign": { + if (this.int32Locals.has(s.localId)) { + this.storeInt32Local(s.localId, this.emitExpr(s.value), s.value); + break; + } const owner = this.reboundOwnerSlots.get(s.localId); if (owner !== undefined && !this.walkBorrows.has(s.localId)) { // A rebound borrowed parameter: the new value moves into the owner @@ -8317,6 +8352,43 @@ export class LlEmitter { return emitToUint32(this, value, expr, uint32); } + /** Stores a number into its int32-stored local, when it is one. The + * value is proven to be in the local's range, so its low 32 bits are the + * exact value. */ + storeInt32Local(localId: string, value: LlValue, expr?: IrExpr): boolean { + const local = this.int32Locals.get(localId); + if (local === undefined) return false; + if (expr !== undefined && !this.int32LocalWrites.has(expr)) + throw new InternalCompilerError(`llvm emitter bug: unproven write of int32 local ${localId}`); + const B = this.B; + const integer = exactInteger(this, value, expr); + let bits: string; + if (integer && /^-?\d+$/.test(integer.name)) bits = String(Number(integer.name) | 0); // a literal's i32 bits + else if (integer) bits = narrowInteger(this, integer); + else if (value.uint32 !== undefined) bits = value.uint32; + else { + // An Int32 contract proved this write without a range at the value. + bits = B.tmp(); + B.line(`${bits} = ${local.signed ? "fptosi" : "fptoui"} double ${value.name} to i32`); + } + B.line(`store i32 ${bits}, ptr %${mangleLocal(localId)}`); + return true; + } + + /** The number of an int32-stored local's i32, with the i32 as its + * integer and ToUint32 forms. */ + int32LocalValue(localId: string, bits: string, type: IrType): LlValue { + const local = this.int32Locals.get(localId)!; + const number = this.B.tmp(); + this.B.line(`${number} = ${local.signed ? "sitofp" : "uitofp"} i32 ${bits} to double`); + return { + name: number, + type, + uint32: bits, + integer: { name: bits, type: "i32", signed: local.signed, range: local.range }, + }; + } + storeIntegerView(localId: string, value: LlValue, expr?: IrExpr): void { const slot = this.integerViews.get(localId); if (slot === undefined) return; diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 2ea6995e9..7469beb27 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -81,6 +81,11 @@ export function emitLiteralExpr( }, }; } + if (host.int32Locals.has(e.localId)) { + const bits = B.tmp(); + B.line(`${bits} = load i32, ptr ${host.binding(e.localId).slot}`); + return host.int32LocalValue(e.localId, bits, e.type); + } const b = host.binding(e.localId); if (b.kind === "global") host.checkGlobalTdz(e.localId); if (b.kind === "boxed") { @@ -368,6 +373,17 @@ export function emitOperatorExpr( // globals, capture boxes): read, write ±1, yield old (postfix) or // new (prefix). const b = host.binding(e.localId); + if (host.int32Locals.has(e.localId)) { + if (!host.int32LocalWrites.has(e)) + throw new InternalCompilerError(`llvm emitter bug: unproven ++/-- of ${e.localId}`); + // The written value is proven in the local's range: no wrap. + const old = B.tmp(); + const next = B.tmp(); + B.line(`${old} = load i32, ptr ${b.slot}`); + B.line(`${next} = ${e.op === "+" ? "add" : "sub"} i32 ${old}, 1`); + B.line(`store i32 ${next}, ptr ${b.slot}`); + return host.int32LocalValue(e.localId, e.prefix ? next : old, e.type); + } if (b.kind === "boxed") { const box = host.loadBox(b.slot); const old = b.local!.tdz @@ -434,6 +450,7 @@ export function emitOperatorExpr( } const b = host.binding(e.localId); const v = host.emitExpr(e.value); + if (host.storeInt32Local(e.localId, v, e.value)) return v; if (b.kind === "global") host.checkGlobalTdz(e.localId); if (b.kind === "boxed") { // box_set takes ownership of the passed reference, so hand it a diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index cdce1000c..83c28f7e0 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -178,6 +178,7 @@ export function streamTypedRefCommitAdapter( { name: "cap", index: 2, type: host.sizeType }, { name: "data", index: SCR_ARR_FIELD.data, type: "ptr" }, { name: "present", index: SCR_ARR_FIELD.present, type: "ptr" }, + { name: "filled", index: SCR_ARR_FIELD.filled, type: "i32" }, { name: "ext", index: SCR_ARR_FIELD.ext, type: "ptr" }, ]; for (const member of storageMembers) { diff --git a/packages/compiler/src/backend/llvm/int32-locals.ts b/packages/compiler/src/backend/llvm/int32-locals.ts new file mode 100644 index 000000000..59c7fe1ea --- /dev/null +++ b/packages/compiler/src/backend/llvm/int32-locals.ts @@ -0,0 +1,102 @@ +import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js"; +import { + integerArithmeticRange, + withinInt32, + withinUint32, + type IntegerRange, + type IntegerRanges, + type IntegerSlotFacts, +} from "../../ir/integer-ranges.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; + +/** How an int32-stored local holds its value: the i32 bits of an int32 + * (`signed`) or of a uint32, and the range every write lands in. */ +export interface Int32Local { + signed: boolean; + range: IntegerRange; +} + +/** Number locals whose every write is an exact int32 (or every write an + * exact uint32), so an i32 slot can be their only storage. A read widens + * the i32 back to the same double, which is exact because the range + * analysis never proves -0, NaN or a fraction; integer consumers take the + * i32 directly, and no double is carried through loops. + * + * A write qualifies through its proven range (analyzeIntegerRanges), or, + * for an Int32 contract local, through the contract, which checked every + * write with the same analysis. `++`/`--` qualify through the range of the + * value they write. A parameter also needs a proof for its incoming value + * (every call site, or its contract). For-of and catch bindings, captured, + * boxed and TDZ locals keep their ordinary storage, as do async functions + * and generators, whose locals live in frames the range analysis does not + * follow (it proves nothing there). `excluded` names further locals the + * caller reads through other storage. */ +export function findInt32Locals( + fn: IrFunction, + ranges: IntegerRanges, + facts: IntegerSlotFacts, + excluded: ReadonlySet, +): { locals: Map; values: ReadonlySet } { + const result = new Map(); + // The written values the proof covers; the emitter stores no other. + const values = new Set(); + if (fn.async || fn.generator || ranges.size === 0) return { locals: result, values }; + const captured = new Set( + [...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId), + ); + const contract = (id: string): IntegerRange | null => facts.locals?.(id) ?? null; + const writes = new Map(); + for (const local of fn.locals) { + if (local.type.kind !== "f64" || local.boxed || local.tdz) continue; + if (captured.has(local.id) || excluded.has(local.id)) continue; + writes.set(local.id, []); + } + for (const param of fn.params) { + const list = writes.get(param.localId); + if (list) list.push(facts.params?.get(param.localId) ?? contract(param.localId)); + } + const write = (id: string, range: IntegerRange | null | undefined, value?: IrExpr): void => { + const list = writes.get(id); + if (!list) return; + list.push(range ?? contract(id)); + if (value) values.add(value); + }; + const expr = (node: IrExpr): boolean => { + if (node.kind === "assignExpr") write(node.localId, ranges.get(node.value), node.value); + else if (node.kind === "incDec") { + // The analysis records the value the expression yields: the new + // value for prefix forms, the old one for postfix forms. + const yielded = ranges.get(node) ?? null; + write( + node.localId, + yielded && !node.prefix + ? integerArithmeticRange(node.op, yielded, { min: 1, max: 1 }) + : yielded, + node, + ); + } + return everyExprChild(node, expr, stmt); + }; + const stmt = (node: IrStmt): boolean => { + if (node.kind === "varDecl") { + if (node.init) write(node.localId, ranges.get(node.init), node.init); + } else if (node.kind === "assign") write(node.localId, ranges.get(node.value), node.value); + else if (node.kind === "forOf") writes.get(node.localId)?.push(null); + else if (node.kind === "tryCatch" && node.catchLocalId) + writes.get(node.catchLocalId)?.push(null); + return everyStmtChild(node, expr, stmt); + }; + fn.body.every(stmt); + for (const [id, list] of writes) { + // An unwritten local is never read as a number. + if (list.length === 0 || list.some((range) => range === null)) continue; + const range = { min: Infinity, max: -Infinity }; + for (const write of list as IntegerRange[]) { + range.min = Math.min(range.min, write.min); + range.max = Math.max(range.max, write.max); + } + if (withinInt32(range)) result.set(id, { signed: true, range }); + else if (withinUint32(range)) result.set(id, { signed: false, range }); + } + return { locals: result, values }; +} diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index 73b79b741..31be91490 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -497,6 +497,25 @@ function emitDenseReferenceArrayRead( B.startBlock(range); B.br(dense); B.startBlock(dense); + // Below the hole-free prefix bound every state is a present value. + const filledPtr = B.tmp(), + filled = B.tmp(), + belowFilled = B.tmp(), + lenLabel = B.newLabel("local.array.len"); + B.line( + `${filledPtr} = getelementptr inbounds %ScrArr, ptr ${array.name}, i32 0, i32 ${SCR_ARR_FIELD.filled}`, + ); + host.markMemoryPointer(filledPtr, "array:header"); + B.line(`${filled} = load i32, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`); + let filledSized = filled; + if (host.sizeType !== "i32") { + filledSized = B.tmp(); + B.line(`${filledSized} = zext i32 ${filled} to ${host.sizeType}`); + } + B.line(`${belowFilled} = icmp ult ${host.sizeType} ${offset}, ${filledSized}`); + const valueLabel = B.newLabel("local.array.value"); + B.condBr(belowFilled, valueLabel, lenLabel); + B.startBlock(lenLabel); const lenPtr = B.tmp(), len = B.tmp(), belowLen = B.tmp(); @@ -504,8 +523,7 @@ function emitDenseReferenceArrayRead( host.markMemoryPointer(lenPtr, "array:header"); B.line(`${len} = load ${host.sizeType}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`); B.line(`${belowLen} = icmp ult ${host.sizeType} ${offset}, ${len}`); - const stateLabel = B.newLabel("local.array.state"), - valueLabel = B.newLabel("local.array.value"); + const stateLabel = B.newLabel("local.array.state"); B.condBr(belowLen, stateLabel, no); B.startBlock(stateLabel); const statesPtr = B.tmp(), diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 970a64367..ff9102b39 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -319,8 +319,8 @@ function rcHeaderTest(cycle: "arr" | "map", yes: string, no: string): string[] { if (cycle === "arr") { return [ ` %cycp = getelementptr inbounds %ScrArr, ptr %o, i32 0, i32 ${SCR_ARR_FIELD.cyc} ; cyc`, - ` %cyc = load i32, ptr %cycp`, - ` %headered = icmp ne i32 %cyc, 0`, + ` %cyc = load i8, ptr %cycp`, + ` %headered = icmp ne i8 %cyc, 0`, ` br i1 %headered, label %${yes}, label %${no}`, ]; } diff --git a/packages/compiler/src/backend/runtime-abi.ts b/packages/compiler/src/backend/runtime-abi.ts index 802e97540..eb8d85418 100644 --- a/packages/compiler/src/backend/runtime-abi.ts +++ b/packages/compiler/src/backend/runtime-abi.ts @@ -2,8 +2,8 @@ * of the link contract: a program object keeps this symbol undefined and the * matching runtime defines it, so incompatible manual links fail before the * program can start. */ -export const RUNTIME_ABI_VERSION = 14 as const; -export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v14" as const; +export const RUNTIME_ABI_VERSION = 15 as const; +export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v15" as const; /** Object consumption is useful today, but the complete scr_* surface may * still change before 1.0. The versioned marker prevents accidental mixing; diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts index 2939d12d3..fbf21cab9 100644 --- a/packages/compiler/src/backend/runtime-pack-targets.test.ts +++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts @@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 14, marker: "scr_runtime_abi_v14" }, + runtime_abi: { version: 15, marker: "scr_runtime_abi_v15" }, compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple }, macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" }, flavors: { diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts index 1ce5f6db1..4be6bf14d 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -116,7 +116,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 14, marker: "scr_runtime_abi_v14" }, + runtime_abi: { version: 15, marker: "scr_runtime_abi_v15" }, compiler: { command: "clang", identity: "fixture clang", diff --git a/packages/compiler/src/ir/function-references.test.ts b/packages/compiler/src/ir/function-references.test.ts new file mode 100644 index 000000000..fa0e64fc5 --- /dev/null +++ b/packages/compiler/src/ir/function-references.test.ts @@ -0,0 +1,340 @@ +import { expect, test } from "vitest"; +import { exprFunctionReference, moduleFunctionReferences } from "./function-references.js"; +import { functionCallFacts } from "./int32-slots.js"; +import { + F64, + VOID, + type IrExpr, + type IrFunction, + type IrModule, + type IrStmt, + type IrType, + type SrcLoc, +} from "./ir.js"; +import { everyStmtList } from "./traverse.js"; + +/* Every plain `string` field of the IR, classified. The analyses that ask + * which functions escape (constant-callbacks.ts, int32-slots.ts) read only + * the fields classified "reference" here, through function-references.ts. + * A new string field anywhere in the IR fails to compile until it is + * classified, and a new "reference" fails the tests below until the shared + * helpers report it. */ +type Role = + /** Names a module function as a value or a runtime entry. */ + | "reference" + /** A direct call's callee. */ + | "callee" + /** A class method name: the function is `%.`. */ + | "method" + /** A verified contract fact about a function, not a way to reach it. */ + | "fact" + /** Never a module function's name. */ + | "other"; + +type Leaf = IrExpr | IrStmt | IrType | SrcLoc; +type PathsOf = V extends string + ? string extends V + ? P + : never + : V extends readonly (infer E)[] + ? PathsOf + : V extends Leaf + ? never + : V extends object + ? StringFields + : never; +type StringFields = { + [K in keyof T & string]-?: PathsOf, `${P}${K}`>; +}[keyof T & string]; +type Classified = { + [K in U["kind"] as [StringFields>] extends [never] ? never : K]: Record< + StringFields>, + Role + >; +}; + +const EXPR_FIELDS: Classified = { + numLit: { spelling: "other" }, + strLit: { value: "other" }, + moduleNsRef: { moduleId: "other" }, + varRef: { localId: "other" }, + incDec: { localId: "other" }, + fieldIncDec: { className: "other", field: "other" }, + assignExpr: { localId: "other" }, + orDefault: { retag: "reference" }, + optChain: { id: "other" }, + chainRecv: { id: "other" }, + regexLit: { pattern: "other", flags: "other" }, + templateStrings: { key: "other", "cooked[]": "other" }, + call: { callee: "callee" }, + ffiCall: { import: "other" }, + closure: { fnName: "reference", "captures[]": "other" }, + new: { className: "other" }, + classRef: { className: "other", "captures[]": "other" }, + instanceOf: { className: "other" }, + virtualCall: { className: "other", method: "method" }, + fieldGet: { className: "other", field: "other" }, + recordLit: { "fields[].name": "other" }, + recordClone: { "overrides[].name": "other" }, + recordGet: { shapeId: "other", field: "other" }, + recordKeyGet: { shapeId: "other" }, + dynFrom: { fnName: "other" }, + dynCall: { calleeName: "other", "spreads[].what": "other" }, + dynInvoke: { method: "other", calleeName: "other" }, + dynHasKey: { key: "other" }, + dynDestrCheck: { spelling: "other", firstProp: "other" }, + dynIterN: { notIterableMessage: "other" }, + recordOvfKeys: { shapeId: "other" }, + recordOvfHas: { shapeId: "other" }, + recordHas: { shapeId: "other", field: "other" }, + fieldAbsent: { unionId: "other" }, + unionWrap: { unionId: "other" }, + unionRetype: { unionId: "other", fromId: "other" }, + unionFuncEq: { unionId: "other" }, + caughtTest: { className: "other" }, + caughtCheck: { className: "other" }, + unionNarrow: { unionId: "other" }, + unionDisc: { unionId: "other", field: "other" }, + unionKeyGet: { unionId: "other" }, + unionIsTag: { unionId: "other" }, + unionEq: { unionId: "other" }, + jsonStringify: { indent: "other" }, +}; + +const STMT_FIELDS: Classified = { + varDecl: { localId: "other" }, + assign: { localId: "other" }, + while: { "labels[]": "other" }, + doWhile: { "labels[]": "other" }, + switch: { "labels[]": "other" }, + for: { "labels[]": "other" }, + forOf: { localId: "other", "labels[]": "other" }, + fieldSet: { className: "other", field: "other" }, + recordSet: { shapeId: "other", field: "other" }, + recordKeySet: { shapeId: "other" }, + recordKeyDelete: { shapeId: "other" }, + break: { label: "other" }, + continue: { label: "other" }, + block: { "labels[]": "other" }, + runtimeFence: { code: "other", message: "other" }, + rethrow: { localId: "other" }, + tryCatch: { catchLocalId: "other" }, +}; + +const TYPE_FIELDS: Classified = { + object: { className: "other" }, + record: { shapeId: "other" }, + classval: { className: "other" }, + moduleNs: { moduleId: "other" }, + union: { unionId: "other" }, +}; + +const FUNCTION_FIELDS: Record>, Role> = { + name: "other", + sourceName: "other", + asyncCacheGlobal: "other", + asyncCycleCacheGlobal: "other", + "params[].name": "other", + "params[].localId": "other", + "locals[].name": "other", + "locals[].id": "other", + "captures[].name": "other", + "captures[].localId": "other", + "classCaptures[].name": "other", + "classCaptures[].localId": "other", +}; + +const MODULE_FIELDS: Record>, Role> = { + sourceFile: "other", + entry: "reference", + "classes[].name": "other", + "classes[].base": "other", + "classes[].jsName": "other", + "classes[].genericOf": "other", + "classes[].baseValueGlobal": "other", + "classes[].prototypeDataHelper": "reference", + "classes[].instancePrototypeHelper": "reference", + "classes[].localCaptures[].name": "other", + "classes[].localCaptures[].localId": "other", + "classes[].symbolFields[].field": "other", + "classes[].symbolFields[].globalId": "other", + "classes[].fields[].name": "other", + "classes[].methods[]": "method", + "classes[].abstractMethods[]": "method", + "globals[].id": "other", + "globals[].name": "other", + "globals[].initFlag": "other", + "records[].id": "other", + "records[].fields[].name": "other", + "records[].declaredOrder[]": "other", + "unions[].id": "other", + "unions[].discriminant.field": "other", + "unions[].discriminant.cases[].values[]": "other", + "ffiImports[].name": "other", + "ffiImports[].symbol": "other", + "ffiImports[].library": "other", + "ffiImports[].callbackTarget": "other", + "ffiImports[].params[].context": "other", + "ffiImports[].params[].callback.id": "other", + "ffiImports[].params[].callback.release": "other", + "ffiImports[].params[].callback.params[].context": "other", + "lib.profileName": "other", + "lib.prefix": "other", + "lib.initSymbol": "other", + "lib.sinkRegisterSymbol": "other", + "lib.collectSymbol": "other", + "lib.resultResetSymbol": "other", + "lib.callbackRegisterSymbol": "other", + "lib.callbacks[].name": "other", + "lib.callbacks[].unregisteredTrap": "other", + "lib.exports[].symbol": "other", + "lib.exports[].fnName": "reference", + "lib.exports[].inboundBytesTrap": "other", + "lib.exports[].inboundIntTrap": "other", + "lib.trapOverlays[].code": "other", + "lib.trapOverlays[].teaching": "other", + "lib.trapOverlays[].remediation": "other", + "lib.identity.buildIdSymbol": "other", + "lib.identity.abiVersionSymbol": "other", + "lib.identity.buildId": "other", + "contracts.int32Fields[]": "other", + "contracts.int32Params[]": "other", + "contracts.int32Returns[]": "fact", + "contracts.int32Locals[]": "other", + "contracts.noThrow[]": "fact", + "contracts.acyclic[]": "other", + "contracts.immortalGlobals[]": "other", + "contracts.programLifetime[]": "other", +}; + +const loc = { file: "references.ts", start: 0, end: 0 }; +const TARGET = "%m0.target"; +const target: IrFunction = { + name: TARGET, + params: [{ localId: "n", name: "n", type: F64 }], + locals: [{ id: "n", name: "n", type: F64, mutable: true }], + returnType: VOID, + body: [], + loc, +}; +const entry = (body: IrStmt[] = []): IrFunction => ({ + name: "%main", + params: [], + locals: [], + returnType: VOID, + body, + loc, +}); +const module = (body: IrStmt[] = []): IrModule => ({ + irVersion: 16, + sourceFile: loc.file, + entry: "%main", + functions: [target, entry(body)], +}); +const exprStmt = (expr: IrExpr): IrStmt => ({ kind: "exprStmt", expr, loc }); + +/** A module whose only mention of TARGET, other than its definition, is + * through the given reference field. */ +const REFERENCE_FIXTURES: Record IrModule> = { + "closure.fnName": () => + module([ + exprStmt({ + kind: "closure", + fnName: TARGET, + captures: [], + type: { kind: "func", params: [F64], ret: VOID }, + loc, + }), + ]), + "orDefault.retag": () => + module([ + exprStmt({ + kind: "orDefault", + left: { kind: "numLit", value: 0, type: F64, loc }, + right: { kind: "numLit", value: 1, type: F64, loc }, + retag: TARGET, + type: F64, + loc, + }), + ]), + entry: () => ({ ...module(), entry: TARGET }), + "classes[].prototypeDataHelper": () => ({ + ...module(), + classes: [{ name: "%m0.Cell", fields: [], prototypeDataHelper: TARGET, loc }], + }), + "classes[].instancePrototypeHelper": () => ({ + ...module(), + classes: [{ name: "%m0.Cell", fields: [], instancePrototypeHelper: TARGET, loc }], + }), + "lib.exports[].fnName": () => ({ + ...module(), + lib: { + profileName: "references", + prefix: "ref_", + initSymbol: "ref_init", + sinkRegisterSymbol: "ref_sink", + collectSymbol: null, + resultResetSymbol: null, + threadInstances: false, + exports: [{ symbol: "ref_target", fnName: TARGET, params: ["f64"], returns: "void" }], + trapOverlays: [], + }, + }), +}; + +function referencesOf(mod: IrModule): Set { + const names = new Set(moduleFunctionReferences(mod)); + for (const fn of mod.functions) + everyStmtList(fn.body, { + expr: (expr) => { + const name = exprFunctionReference(expr); + if (name !== undefined) names.add(name); + return true; + }, + stmt: () => true, + }); + return names; +} + +const referencePaths = (table: object, prefix: string): string[] => + Object.entries(table).flatMap(([key, value]) => + typeof value === "string" + ? value === "reference" + ? [prefix + key] + : [] + : referencePaths(value as object, `${key}.`), + ); + +test("every IR field that can name a function has a reference fixture", () => { + const references = [ + ...referencePaths(EXPR_FIELDS, ""), + ...referencePaths(STMT_FIELDS, ""), + ...referencePaths(TYPE_FIELDS, ""), + ...referencePaths(FUNCTION_FIELDS, "functions[]."), + ...referencePaths(MODULE_FIELDS, ""), + ]; + expect(references.sort()).toEqual(Object.keys(REFERENCE_FIXTURES).sort()); +}); + +test.each(Object.keys(REFERENCE_FIXTURES))("%s escapes its function", (path) => { + const mod = REFERENCE_FIXTURES[path]!(); + expect(referencesOf(mod).has(TARGET)).toBe(true); + const facts = functionCallFacts(mod, new Map(), (name) => [name]); + expect(facts.valueEscaped.has(TARGET)).toBe(true); +}); + +test("direct calls and look-alike strings do not escape a function", () => { + const mod = module([ + exprStmt({ kind: "call", callee: TARGET, args: [], type: VOID, loc }), + exprStmt({ kind: "strLit", value: TARGET, type: { kind: "string" }, loc }), + exprStmt({ + kind: "dynFrom", + value: { kind: "numLit", value: 0, type: F64, loc }, + fnName: TARGET, + type: { kind: "dyn" }, + loc, + }), + ]); + expect(referencesOf(mod).has(TARGET)).toBe(false); + expect(functionCallFacts(mod, new Map(), (name) => [name]).valueEscaped.has(TARGET)).toBe(false); +}); diff --git a/packages/compiler/src/ir/function-references.ts b/packages/compiler/src/ir/function-references.ts new file mode 100644 index 000000000..0342172b6 --- /dev/null +++ b/packages/compiler/src/ir/function-references.ts @@ -0,0 +1,26 @@ +import type { IrExpr, IrModule } from "./ir.js"; + +/** The function an expression names as a value rather than as a direct + * call's callee: a closure's code, or the union→union retag helper an + * `orDefault` hands its left box to. These are the only expression fields + * that hold a module function's name besides `call.callee` + * (function-references.test.ts classifies every string field of the IR). */ +export function exprFunctionReference(expr: IrExpr): string | undefined { + if (expr.kind === "closure") return expr.fnName; + if (expr.kind === "orDefault") return expr.retag; + return undefined; +} + +/** Functions the module's metadata names: the entry, library exports and + * the class prototype helpers the runtime calls. Class methods and + * constructors follow the `%.` convention instead of being + * named here; callers that need them derive them from `mod.classes`. */ +export function moduleFunctionReferences(mod: IrModule): string[] { + const names = [mod.entry]; + for (const entry of mod.lib?.exports ?? []) names.push(entry.fnName); + for (const cls of mod.classes ?? []) { + if (cls.prototypeDataHelper !== undefined) names.push(cls.prototypeDataHelper); + if (cls.instancePrototypeHelper !== undefined) names.push(cls.instancePrototypeHelper); + } + return names; +} diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index b25f3dc14..e4311e58c 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -7,8 +7,9 @@ import { type IntegerRanges, type IntegerSlotFacts, } from "./integer-ranges.js"; +import { exprFunctionReference, moduleFunctionReferences } from "./function-references.js"; import { findInitializerBindings, withInitializerBindings } from "./initializer-bindings.js"; -import { everyExprChild, everyStmtChild, everyTypeChild } from "./traverse.js"; +import { everyExprChild, everyStmtChild, everyStmtList, everyTypeChild } from "./traverse.js"; /** Whole-program int32 specialization of numeric storage. * @@ -265,18 +266,6 @@ const DYN_READ_ONLY = new Set([ "text.decodeStream", "worker.new", ]); -/** IR metadata keys whose strings never name a function. */ -const NON_REFERENCE_KEYS = new Set([ - "loc", - "source", - "name", - "sourceName", - "jsName", - "localId", - "type", - "returnType", -]); - function walkFunction( fn: IrFunction, visit: { expr?: (e: IrExpr) => void; stmt?: (s: IrStmt) => void }, @@ -480,36 +469,8 @@ export function functionCallFacts( virtualTargets: Set; runtimeVirtual: (name: string) => boolean; } { - const functions = new Map(mod.functions.map((fn) => [fn.name, fn])); - const escaped = new Set(); + const escaped = new Set(moduleFunctionReferences(mod)); const virtualTargets = new Set(); - const seenObjects = new Set(); - const scan = ( - value: unknown, - key: string | null, - parent: Record | null, - ): void => { - if (typeof value === "string") { - if (functions.has(value)) { - const direct = - key === "callee" && parent?.kind === "call" - ? true - : key === "value" && parent?.kind === "strLit"; - if (!direct) escaped.add(value); - } - return; - } - if (value === null || typeof value !== "object" || seenObjects.has(value)) return; - seenObjects.add(value); - if (Array.isArray(value)) { - for (const item of value) scan(item, null, null); - return; - } - const record = value as Record; - for (const [k, v] of Object.entries(record)) if (!NON_REFERENCE_KEYS.has(k)) scan(v, k, record); - }; - scan(mod, null, null); - if (typeof mod.entry === "string") escaped.add(mod.entry); // Virtual dispatch: a `virtualCall` can reach the method's implementation // in any class of the receiver's subtree or the one it inherits. Classes // under a runtime-provided root keep every method escaped: the runtime @@ -525,6 +486,8 @@ export function functionCallFacts( for (const fn of mod.functions) walkFunction(fn, { expr: (e) => { + const referenced = exprFunctionReference(e); + if (referenced !== undefined) escaped.add(referenced); if (e.kind !== "virtualCall") return; for (const name of subtree(e.className)) virtualTargets.add(`%${name}.${e.method}`); for (let c = classes.get(e.className); c; c = c.base ? classes.get(c.base) : undefined) @@ -825,13 +788,47 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots { queue.push(fnName); } }; + const candidateArgs = (callee: IrFunction | undefined, args: number, offset: number): boolean => { + if (!callee) return false; + for (let i = 0; i < args; i++) { + const param = callee.params[i + offset]; + if (param && slots.hasParam(callee.name, param.localId)) return true; + } + return false; + }; + const hasCandidateWrite = (fn: IrFunction): boolean => { + const box = (localId: string): boolean => slots.hasBox(slots.boxGroup(fn.name, localId)); + const returns = slots.hasReturn(fn.name); + return !everyStmtList(fn.body, { + stmt: (s) => { + if (s.kind === "assign") return !box(s.localId); + if (s.kind === "varDecl") return s.init === null || !box(s.localId); + if (s.kind === "fieldSet") { + const family = slots.family(s.className, s.field); + return !family || !slots.hasFamily(family); + } + return !(s.kind === "return" && returns); + }, + expr: (e) => { + if (e.kind === "call") return !candidateArgs(functions.get(e.callee), e.args.length, 0); + if (e.kind === "new") + return !candidateArgs(functions.get(`%${e.className}.constructor`), e.args.length, 1); + if (e.kind === "assignExpr") return !box(e.localId); + return true; + }, + }); + }; const provenByFunction = new Map(); while (queue.length) { const fn = functions.get(queue.pop()!)!; queued.delete(fn.name); const params = slots.facts(fn.name).params; if (params) for (const id of params.keys()) reads(`param:${fn.name}\0${id}`, fn.name); - const ranges: IntegerRanges = analyzeIntegerRanges(numeric(fn), slots.facts(fn.name)); + // Range facts are consulted only at writes into candidate slots; slots + // are only ever dropped, so a body without such a write needs none. + const ranges: IntegerRanges = hasCandidateWrite(fn) + ? analyzeIntegerRanges(numeric(fn), slots.facts(fn.name)) + : new Map(); const proven: IrExpr[] = []; const int32 = (e: IrExpr): boolean => withinInt32(ranges.get(e)); const args = (callee: IrFunction | undefined, list: IrExpr[], offset: number): void => { diff --git a/packages/compiler/test/int32-local-emission.test.ts b/packages/compiler/test/int32-local-emission.test.ts new file mode 100644 index 000000000..be7e9a722 --- /dev/null +++ b/packages/compiler/test/int32-local-emission.test.ts @@ -0,0 +1,221 @@ +import { mkdtemp, readFile, rm, symlink, 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 { emitLlvmModule } from "../src/backend/llvm/emitter.js"; + +const repoRoot = join(import.meta.dirname, "../../.."); + +const source = ` +import type { Int32 } from "@scriptc/types"; +function rsync(a: Uint8Array): number { + let s1 = 0, + s2 = 0; + for (let i = 0; i < a.length; i++) { + s1 = (s1 + a[i]) & 0xffff; + s2 = (s2 + s1) & 0xffff; + } + return ((s2 << 16) | s1) >>> 0; +} +function mix(s: string): number { + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + i) | 0; + return h; +} +function counters(): number { + let up = 0; + const before = up++; + const after = ++up; + let down = 3; + const was = down--; + return before * 1000 + after * 100 + was * 10 + down; +} +function unsigned(a: Uint32Array): number { + let acc = 0; + for (let i = 0; i < a.length; i++) acc = (acc + (a[i] ^ i)) >>> 0; + return acc; +} +function fraction(n: number): number { + let h = 0; + for (let i = 0; i < n; i++) h = (h + i) | 0; + h = h / 2; + return h; +} +function mixed(n: number): number { + let m = -1 | 0; + if (n > 1) m = m >>> 0; + return m; +} +function captured(words: string[]): number { + let h = 17; + const add = (w: string): void => { + for (let i = 0; i < w.length; i++) h = (Math.imul(h, 31) + w.charCodeAt(i)) | 0; + }; + for (const w of words) add(w); + return h; +} +function capturedParam(seed: number): () => number { + return () => seed; +} +function* frames(words: string[]): Generator { + let h = 0; + for (const w of words) { + for (let i = 0; i < w.length; i++) h = (Math.imul(h, 33) ^ w.charCodeAt(i)) | 0; + yield h; + } +} +async function suspended(words: string[]): Promise { + let total = 0; + for (const w of words) { + await Promise.resolve(); + total = (total + w.length) | 0; + } + return total; +} +function contract(seed: Int32, steps: Int32): Int32 { + let acc: Int32 = seed; + for (let i: Int32 = 0; i < steps; i = (i + 1) | 0) acc = (Math.imul(acc, 3) + i) | 0; + return acc; +} +const bytes = new Uint8Array([1, 2, 3, 250]); +console.log(rsync(bytes), mix("abcd"), counters(), unsigned(new Uint32Array([4294967295, 7]))); +console.log(fraction(5), mixed(3), captured(["ab", "c"]), capturedParam(3)()); +console.log([...frames(["ab", "cd"])].join(" "), contract(7, 20)); +suspended(["ab"]).then((t) => console.log(t)); +`; + +const body = (ll: string, name: string): string => + ll.match( + new RegExp(`define internal [^\\n]+ @sc_(?:b)?f_${name}\\([^\\n]*\\) #0 \\{([\\s\\S]*?)\\n\\}`), + )![1]!; + +/** Every function definition whose header or body matches. */ +const functionsWith = (ll: string, pattern: RegExp): string[] => + ll.split(/^(?=define )/m).filter((fn) => fn.startsWith("define ") && pattern.test(fn)); + +/** The blocks of a function's first loop, from its header to its exit. */ +const loopOf = (fn: string): string => { + const start = fn.search(/\nloop\.c\d+:/); + expect(start).toBeGreaterThan(0); + const end = fn.indexOf("\nloop.e", start); + return fn.slice(start, end < 0 ? undefined : end); +}; + +/** The function without pure instructions whose results nothing uses, + * repeatedly, as LLVM's dead code elimination leaves it. */ +const live = (fn: string): string => { + const pure = + /^\s*(%[\w.]+) = (?:[su]itofp|fpto[su]i|f(?:add|sub|mul|div|neg|cmp)|select|[sz]ext|trunc|add|sub|mul|and|or|xor|shl|lshr|ashr|icmp|phi|freeze|bitcast|getelementptr|load) /; + let lines = fn.split("\n"); + for (let changed = true; changed;) { + changed = false; + const text = lines.join("\n"); + lines = lines.filter((line) => { + const name = line.match(pure)?.[1]; + if (!name) return true; + const uses = text.split(new RegExp(`${name.replace(/\./g, "\\.")}(?![\\w.])`)).length - 2; + if (uses > 0) return true; + changed = true; + return false; + }); + } + return lines.join("\n"); +}; + +/** What keeps a double carrier of a local in a function's loop once dead + * values are gone: double loads and stores of local slots, and widened + * reads of a local's i32. */ +const carriers = (fn: string): string[] => { + const loop = loopOf(live(fn)); + const reads = new Set( + [...loop.matchAll(/^\s*(%[\w.]+) = load i32, ptr %sc_l_\w+$/gm)].map((m) => m[1]), + ); + return [ + ...loop.matchAll(/^\s*(?:%[\w.]+ = load double, ptr|store double [^,]+, ptr) %sc_l_\w+$/gm), + ...[...loop.matchAll(/^\s*%[\w.]+ = [su]itofp i32 (%[\w.]+) to double$/gm)].filter((m) => + reads.has(m[1]), + ), + ].map((m) => m[0].trim()); +}; + +test("int32-closed locals are stored as i32 and keep doubles only for floating consumers", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-int32-locals-")); + try { + await symlink(join(repoRoot, "node_modules"), join(dir, "node_modules")); + 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" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + for (const pointerBits of [32, 64] as const) { + const ll = emitLlvmModule(mod, { pointerBits, wasi: pointerBits === 32 }); + + // Loop-carried accumulators and counters: i32 slots, no per-iteration + // conversion to or from a double, no integer view beside them. + const rsync = body(ll, "rsync"); + expect(rsync).toMatch(/alloca i32 ; s1\n/); + expect(rsync).toMatch(/alloca i32 ; s2\n/); + expect(rsync).not.toMatch(/alloca double ; s[12]\n/); + expect(rsync).not.toContain("integer view"); + expect(carriers(rsync)).toEqual([]); + const mix = body(ll, "mix"); + expect(mix).toMatch(/alloca i32 ; h\n/); + expect(mix).toMatch(/alloca i32 ; i\n/); + expect(carriers(mix)).toEqual([]); + // The result is the one double the function needs. + expect(mix).toMatch(/ = sitofp i32 %\w+ to double\n\s+ret double/); + + // ++ and -- write proven int32s in place. + const counters = body(ll, "counters"); + expect(counters).toMatch(/alloca i32 ; up\n/); + expect(counters).toMatch(/alloca i32 ; down\n/); + expect(counters.match(/ = add i32 %\w+, 1\n/g)).toHaveLength(2); + expect(counters).toMatch(/ = sub i32 %\w+, 1\n/); + + // A uint32 accumulator holds its bits and widens unsigned. + const unsigned = body(ll, "unsigned"); + expect(unsigned).toMatch(/alloca i32 ; acc\n/); + expect(carriers(unsigned)).toEqual([]); + expect(unsigned).toMatch(/ = uitofp i32 %\w+ to double\n\s+ret double/); + + // Locals that leave int32, or hold both int32 and uint32 values. + expect(body(ll, "fraction")).toMatch(/alloca double ; h\n/); + expect(body(ll, "mixed")).toMatch(/alloca double ; m\n/); + + // Captured bindings keep their boxes, inside and outside the closure. + const captured = body(ll, "captured"); + expect(captured).toMatch(/alloca ptr ; h\n/); + expect(captured).not.toMatch(/alloca i32 ; h\n/); + const closures = functionsWith(ll, /; captured h\n/); + expect(closures.length).toBeGreaterThan(0); + for (const fn of closures) expect(fn).not.toMatch(/alloca i32 ; h\n/); + expect(body(ll, "capturedParam")).not.toMatch(/alloca i32 ; seed\n/); + + // Generator and async frames keep doubles. + for (const name of ["frames", "suspended"]) { + const fns = functionsWith(ll, new RegExp(`^define [^\\n]*${name}`)); + expect(fns.length).toBeGreaterThan(0); + for (const fn of fns) expect(fn).not.toMatch(/alloca i32 ; (?:h|total)\n/); + } + + // Int32 contract locals and parameters use the same storage; the + // parameter converts once on entry. + const contract = body(ll, "contract"); + expect(contract).toMatch(/alloca i32 ; acc\n/); + expect(contract).toMatch(/alloca i32 ; seed\n/); + expect(contract).toMatch(/ = fptosi double %p_\w+ to i32\n/); + expect(carriers(contract)).toEqual([]); + } + + // Debug builds keep every number local a double for the variable view. + const debug = emitLlvmModule(mod, { debugSources: new Map([[entry, source]]) }); + expect(debug).not.toMatch(/alloca i32 ; (?:s1|h|acc)\n/); + expect(debug).toMatch(/alloca double ; s1\n/); + expect(debug).toContain("@llvm.dbg.declare"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/native-codegen-integration.test.ts b/packages/compiler/test/native-codegen-integration.test.ts index d36974b8a..36f30054d 100644 --- a/packages/compiler/test/native-codegen-integration.test.ts +++ b/packages/compiler/test/native-codegen-integration.test.ts @@ -312,7 +312,7 @@ ${[0, 1, 2, 3, 4, 5].map((index) => ` call void @add${index}(i64 ${index + 2})` expect(await symbols(helperObject, ["-u"])).toEqual(await symbols(clangObject, ["-u"])); expect(await symbols(helperObject, ["-gU"])).toEqual(await symbols(clangObject, ["-gU"])); expect(await symbols(helperObject, ["-gU"])).toEqual(["0000000000000000 T _main"]); - expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v14"); + expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v15"); const clangExe = join(dir, "clang-program"); const linkDriver = join(dir, "clang-driver.c"); @@ -646,7 +646,7 @@ ${[0, 1, 2, 3, 4, 5].map((index) => ` call void @add${index}(i64 ${index + 2})` .split("\n"); expect(undefinedSymbols).toContain("_sf_scale"); expect(undefinedSymbols).toContain("_sf_callback_mix"); - expect(undefinedSymbols).toContain("_scr_runtime_abi_v14"); + expect(undefinedSymbols).toContain("_scr_runtime_abi_v15"); }); test("helper and clang object paths retain the same outbound FFI C ABI", async () => { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4840-array-hole-free-prefix.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4840-array-hole-free-prefix.ts.json new file mode 100644 index 000000000..a97c25b5a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4840-array-hole-free-prefix.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4840-array-hole-free-prefix.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4860-int32-local-storage.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4860-int32-local-storage.ts.json new file mode 100644 index 000000000..c82d0fd6d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4860-int32-local-storage.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4860-int32-local-storage.ts" + ], + "diags": [] +} diff --git a/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs index f21193724..123f63900 100644 --- a/packages/runtime-darwin-arm64/scripts/build.mjs +++ b/packages/runtime-darwin-arm64/scripts/build.mjs @@ -306,7 +306,7 @@ async function build() { package: packageManifest.name, version: packageManifest.version, target: RUNTIME_PACK_MATRIX.target, - runtime_abi: { version: 14, marker: "scr_runtime_abi_v14" }, + runtime_abi: { version: 15, marker: "scr_runtime_abi_v15" }, compiler: { command: compiler, identity: compilerVersion, diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index 9dd8c5912..e338d6b5c 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -402,7 +402,7 @@ async function build() { package: packageManifest.name, version: packageManifest.version, target: matrix.target, - runtime_abi: { version: 14, marker: "scr_runtime_abi_v14" }, + runtime_abi: { version: 15, marker: "scr_runtime_abi_v15" }, compiler: { command: compiler, identity: compilerVersion, diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index 1ef4c51d6..54f334a3e 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -446,6 +446,7 @@ static uint8_t scr_arr_state_at(const ScrArr *a, size_t index, uint64_t *out) { * the destination length and does not need a retain. */ static void scr_arr_store_state_owned(ScrArr *a, size_t index, uint64_t slot, uint8_t state) { + if (state != SCR_ARR_VALUE) scr_arr_cut(a, index); scr_arr_grow_for_index(a, index); if (index < a->cap) { a->data[index] = slot; @@ -470,6 +471,7 @@ static bool scr_arr_take_state(ScrArr *a, size_t index, uint64_t *out, if (index < a->cap) { uint8_t state = a->present[index]; if (state == SCR_ARR_HOLE) return false; + scr_arr_cut(a, index); *out = a->data[index]; a->present[index] = SCR_ARR_HOLE; a->data[index] = 0; @@ -493,6 +495,7 @@ static void scr_arr_replace_state_owned(ScrArr *a, size_t index, uint64_t slot, uint8_t state) { uint8_t old_state; uint64_t old = 0; + if (state != SCR_ARR_VALUE) scr_arr_cut(a, index); if (index < a->cap) { old_state = a->present[index]; if (old_state == SCR_ARR_VALUE) old = a->data[index]; @@ -516,6 +519,22 @@ static void scr_arr_replace_owned(ScrArr *a, size_t index, uint64_t slot) { scr_arr_replace_state_owned(a, index, slot, SCR_ARR_VALUE); } +/* A value was just stored at `index` (with the length already covering it): + * extend the hole-free prefix when the store continues it. */ +static void scr_arr_note_value(ScrArr *a, size_t index) { + if (index == a->filled && index < a->cap && index < a->len) a->filled = (uint32_t)index + 1; +} + +/* scr_arr_refill bounded to [0, limit): for range operations whose cost is + * proportional to the range, not to the whole array. */ +static void scr_arr_refill_to(ScrArr *a, size_t limit) { + size_t end = a->len < a->cap ? a->len : a->cap; + if (limit < end) end = limit; + size_t f = a->filled; + while (f < end && a->present[f] == SCR_ARR_VALUE) f++; + a->filled = (uint32_t)f; +} + static bool scr_arr_prop_get_state(const ScrArr *a, double key, uint64_t *out, uint8_t *state_out) { char text[64]; @@ -597,6 +616,7 @@ static ScrArrStorage scr_arr_take_storage(ScrArr *a) { a->data = NULL; a->present = NULL; a->cap = 0; + a->filled = 0; if (a->ext) { a->ext->sparse = NULL; a->ext->sparse_len = 0; @@ -620,12 +640,14 @@ static bool scr_arr_is_dense(const ScrArr *a) { static void scr_arr_move_dense(ScrArr *a, size_t to, size_t from, size_t count) { if (count == 0) return; + scr_arr_cut(a, to); memmove(a->data + to, a->data + from, count * sizeof(*a->data)); memmove(a->present + to, a->present + from, count); } static void scr_arr_clear_dense(ScrArr *a, size_t from, size_t count) { if (count == 0) return; + scr_arr_cut(a, from); memset(a->present + from, SCR_ARR_HOLE, count); memset(a->data + from, 0, count * sizeof(*a->data)); } @@ -637,6 +659,7 @@ static void scr_arr_copy_dense(ScrArr *dst, size_t to, const ScrArr *src, size_t from, size_t count, bool reverse, bool materialize_holes) { if (count == 0) return; + scr_arr_cut(dst, to); if (!reverse) { memcpy(dst->data + to, src->data + from, count * sizeof(*dst->data)); memcpy(dst->present + to, src->present + from, count); @@ -665,8 +688,10 @@ ScrArr *scr_arr_new(ScrElemKind elem, size_t initial_cap) { a->rc = 1; a->len = 0; a->cap = 0; - a->elem = elem; + a->elem = (uint8_t)elem; a->cyc = 0; + a->spare = 0; + a->filled = 0; a->ops = NULL; a->data = NULL; a->present = NULL; @@ -756,6 +781,8 @@ ScrArr *scr_arr_new_ops(const ScrArrOps *ops, size_t initial_cap) { a->cap = 0; a->elem = SCR_ELEM_REF; a->cyc = cyc; + a->spare = 0; + a->filled = 0; a->ops = ops; a->data = NULL; a->present = NULL; @@ -873,6 +900,7 @@ void scr_arr_set_len(ScrArr *a, double length) { } size_t next = (size_t)length; if (next < a->len) { + scr_arr_cut(a, next); /* Spare capacity is already empty. Repeatedly clearing a reused work * array must cost only the elements removed, not its historical peak. */ size_t dense_end = a->len < a->cap ? a->len : a->cap; @@ -922,10 +950,21 @@ void scr_arr_copy_range(ScrArr *dst, size_t dst_start, const ScrArr *src, scr_arr_copy_range_ex(dst, dst_start, src, src_start, count, reverse, false); } +static void scr_arr_copy_range_states(ScrArr *dst, size_t dst_start, const ScrArr *src, + size_t src_start, size_t count, bool reverse, + bool materialize_holes); + void scr_arr_copy_range_ex(ScrArr *dst, size_t dst_start, const ScrArr *src, size_t src_start, size_t count, bool reverse, bool materialize_holes) { if (count == 0) return; + scr_arr_copy_range_states(dst, dst_start, src, src_start, count, reverse, materialize_holes); + scr_arr_refill_to(dst, dst_start + count); +} + +static void scr_arr_copy_range_states(ScrArr *dst, size_t dst_start, const ScrArr *src, + size_t src_start, size_t count, bool reverse, + bool materialize_holes) { if (src != dst && src_start <= src->cap && count <= src->cap - src_start && dst_start <= dst->cap && count <= dst->cap - dst_start) { size_t empty = 0; @@ -980,7 +1019,11 @@ ScrArr *scr_arr_copy_within(ScrArr *a, double target, double start, double end) if (count == 0 || from == to) return scr_arr_retain(a); if (!scr_elem_is_ref(a->elem) && from < a->cap && count <= a->cap - from && to < a->cap && count <= a->cap - to) { + /* Values moved within the hole-free prefix keep it. */ + size_t f = a->filled; scr_arr_move_dense(a, to, from, count); + if (from + count <= f && to + count <= f) a->filled = (uint32_t)f; + else scr_arr_refill_to(a, to + count); return scr_arr_retain(a); } bool backwards = from < to && to - from < count; @@ -997,6 +1040,7 @@ ScrArr *scr_arr_copy_within(ScrArr *a, double target, double start, double end) scr_arr_replace_state_owned(a, to + offset, slot, state); } } + scr_arr_refill_to(a, to + count); return scr_arr_retain(a); } @@ -1008,6 +1052,7 @@ static ScrArr *scr_arr_fill_slot(ScrArr *a, uint64_t slot, uint8_t state, bool refs = scr_elem_is_ref(a->elem); if (scr_arr_dense_ok(a, until, until - from)) scr_arr_grow_dense(a, until); if (!refs && until <= a->cap) { + if (state != SCR_ARR_VALUE) scr_arr_cut(a, from); for (size_t i = from; i < until; i++) a->data[i] = slot; memset(a->present + from, state, until - from); } else { @@ -1016,6 +1061,7 @@ static ScrArr *scr_arr_fill_slot(ScrArr *a, uint64_t slot, uint8_t state, scr_arr_replace_state_owned(a, i, value, state); } } + if (state == SCR_ARR_VALUE) scr_arr_refill(a); return scr_arr_retain(a); } @@ -1161,6 +1207,7 @@ void *scr_arr_peek_ref(const ScrArr *a, double i) { if (i >= 0 && i < (double)a->cap) { size_t idx = (size_t)i; if ((double)idx == i) { + if (idx < a->filled) return scr_slot_to_ptr(a->data[idx]); if (idx >= a->len || a->present[idx] != SCR_ARR_VALUE) return NULL; return scr_slot_to_ptr(a->data[idx]); } @@ -1236,6 +1283,7 @@ static void scr_arr_set_slot(ScrArr *a, double i, uint64_t slot) { /* Unlink-then-release: a release can trigger a cycle collection, which * must never see a heap edge whose count was already given up. */ scr_arr_replace_owned(a, idx, slot); + scr_arr_note_value(a, idx); } void scr_arr_set_f64(ScrArr *a, double i, double v) { @@ -1282,6 +1330,7 @@ static double scr_arr_push_slot(ScrArr *a, uint64_t slot) { size_t idx = a->len; scr_arr_store_owned(a, idx, slot); a->len++; + scr_arr_note_value(a, idx); return SCR_ARR_LEN_F64(a->len); } @@ -1310,6 +1359,7 @@ double scr_arr_push_many(ScrArr *a, size_t count, const uint64_t *slots) { for (size_t i = 0; i < count; i++) scr_arr_store_owned(a, from + i, slots[i]); } a->len = next; + scr_arr_refill(a); return (double)next; } @@ -1322,6 +1372,7 @@ double scr_arr_push_spread(ScrArr *a, const ScrArr *src) { scr_arr_grow_dense(a, old_len + add); scr_arr_copy_dense(a, old_len, src, 0, add, false, true); a->len += add; + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } if (src == a) { @@ -1339,6 +1390,7 @@ double scr_arr_push_spread(ScrArr *a, const ScrArr *src) { } a->len = old_len + add; scr_arr_free_storage(&old); + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } a->len = old_len + add; @@ -1351,6 +1403,7 @@ double scr_arr_push_spread(ScrArr *a, const ScrArr *src) { scr_arr_store_state_owned(a, old_len + i, 0, SCR_ARR_UNDEFINED); } } + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } @@ -1363,6 +1416,7 @@ double scr_arr_concat_copy(ScrArr *a, const ScrArr *src) { scr_arr_grow_dense(a, old_len + add); scr_arr_copy_dense(a, old_len, src, 0, add, false, false); a->len += add; + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } if (src == a) { @@ -1380,6 +1434,7 @@ double scr_arr_concat_copy(ScrArr *a, const ScrArr *src) { } a->len = old_len + add; scr_arr_free_storage(&old); + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } a->len = old_len + add; @@ -1392,6 +1447,7 @@ double scr_arr_concat_copy(ScrArr *a, const ScrArr *src) { scr_arr_copy_index(a, old_len + SCR_AX(src)->sparse[i].index, src, SCR_AX(src)->sparse[i].index); } + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } @@ -1408,10 +1464,13 @@ static double scr_arr_unshift_slot(ScrArr *a, uint64_t slot) { size_t old_len = a->len; if (scr_arr_is_dense(a) && scr_arr_dense_ok(a, old_len + 1, 1)) { scr_arr_grow_dense(a, old_len + 1); + /* The prefix moves up behind the new first value. */ + size_t f = a->filled; scr_arr_move_dense(a, 1, 0, old_len); a->data[0] = slot; a->present[0] = SCR_ARR_VALUE; a->len++; + a->filled = (uint32_t)(f + 1); return SCR_ARR_LEN_F64(a->len); } ScrArrStorage old = scr_arr_take_storage(a); @@ -1426,6 +1485,7 @@ static double scr_arr_unshift_slot(ScrArr *a, uint64_t slot) { } a->len = old_len + 1; scr_arr_free_storage(&old); + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } @@ -1447,9 +1507,13 @@ double scr_arr_unshift_many(ScrArr *a, size_t count, const uint64_t *slots) { if (count > SCR_ARR_MAX_LENGTH - old_len) scr_arr_oom(); if (scr_arr_is_dense(a) && scr_arr_dense_ok(a, old_len + count, count)) { scr_arr_grow_dense(a, old_len + count); + size_t f = a->filled; scr_arr_move_dense(a, count, 0, old_len); memcpy(a->data, slots, count * sizeof(*slots)); memset(a->present, SCR_ARR_VALUE, count); + a->len = old_len + count; + a->filled = (uint32_t)(f + count); + return SCR_ARR_LEN_F64(a->len); } else { ScrArrStorage old = scr_arr_take_storage(a); for (size_t i = 0; i < count; i++) scr_arr_store_owned(a, i, slots[i]); @@ -1464,6 +1528,7 @@ double scr_arr_unshift_many(ScrArr *a, size_t count, const uint64_t *slots) { scr_arr_free_storage(&old); } a->len = old_len + count; + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } @@ -1479,6 +1544,7 @@ double scr_arr_unshift_spread(ScrArr *a, const ScrArr *src) { /* A self-spread reads the original values at their moved location. */ scr_arr_copy_dense(a, 0, src, src == a ? add : 0, add, false, true); a->len += add; + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } ScrArrStorage old = scr_arr_take_storage(a); @@ -1505,12 +1571,15 @@ double scr_arr_unshift_spread(ScrArr *a, const ScrArr *src) { } a->len = old_len + add; scr_arr_free_storage(&old); + scr_arr_refill(a); return SCR_ARR_LEN_F64(a->len); } ScrArr *scr_arr_reverse(ScrArr *a) { size_t len = a->len; if (scr_arr_is_dense(a)) { + /* A hole-free array stays hole-free; any other prefix is recomputed. */ + if (a->filled != len) scr_arr_cut(a, 0); for (size_t i = 0; i < len / 2; i++) { size_t j = len - i - 1; uint64_t slot; @@ -1521,6 +1590,7 @@ ScrArr *scr_arr_reverse(ScrArr *a) { a->present[i] = a->present[j]; a->present[j] = state; } + scr_arr_refill(a); return scr_arr_retain(a); } ScrArrStorage old = scr_arr_take_storage(a); @@ -1533,6 +1603,7 @@ ScrArr *scr_arr_reverse(ScrArr *a) { old.sparse[i].slot, old.sparse[i].state); } scr_arr_free_storage(&old); + scr_arr_refill(a); return scr_arr_retain(a); } @@ -1575,9 +1646,12 @@ uint8_t scr_arr_shift_state(ScrArr *a, uint64_t *slot_out) { if (scr_arr_is_dense(a)) { uint8_t state = a->present[0]; if (slot_out && state == SCR_ARR_VALUE) *slot_out = a->data[0]; + /* The prefix slides down with the tail. */ + size_t f = a->filled; scr_arr_move_dense(a, 0, 1, old_len - 1); scr_arr_clear_dense(a, old_len - 1, 1); a->len--; + a->filled = (uint32_t)(f ? f - 1 : 0); return state; } ScrArrStorage old = scr_arr_take_storage(a); @@ -1595,6 +1669,7 @@ uint8_t scr_arr_shift_state(ScrArr *a, uint64_t *slot_out) { } a->len = old_len - 1; scr_arr_free_storage(&old); + scr_arr_refill(a); if (slot_out) *slot_out = s; return state; } @@ -1644,9 +1719,15 @@ ScrArr *scr_arr_splice(ScrArr *a, double start, double deleteCount) { memcpy(out->data, a->data + from, n * sizeof(*a->data)); memcpy(out->present, a->present + from, n); } + scr_arr_refill(out); + /* The tail moves down over the removed range; values below the prefix + * bound that stay values keep it. */ + size_t f = a->filled; scr_arr_move_dense(a, from, from + n, old_len - from - n); scr_arr_clear_dense(a, old_len - n, n); a->len -= n; + if (f == old_len) a->filled = (uint32_t)a->len; + else scr_arr_refill(a); return out; } ScrArrStorage old = scr_arr_take_storage(a); @@ -1678,6 +1759,8 @@ ScrArr *scr_arr_splice(ScrArr *a, double start, double deleteCount) { } a->len = old_len - n; scr_arr_free_storage(&old); + scr_arr_refill(a); + scr_arr_refill(out); return out; } @@ -1711,11 +1794,13 @@ ScrArr *scr_arr_splice_insert(ScrArr *a, double start, double deleteCount, memcpy(removed->present, a->present + from, n); } removed->len = n; + scr_arr_refill(removed); scr_arr_grow_dense(a, next_len); scr_arr_move_dense(a, from + add, from + n, a->len - from - n); scr_arr_copy_dense(a, from, items, 0, add, false, true); if (next_len < a->len) scr_arr_clear_dense(a, next_len, a->len - next_len); a->len = next_len; + scr_arr_refill(a); scr_arr_release(snapshot); return removed; } @@ -1770,6 +1855,7 @@ void scr_arr_splice_drop(ScrArr *a, double start, double deleteCount, size_t cou } size_t old_len = a->len; scr_arr_grow_dense(a, next_len); + bool packed = a->filled == old_len; scr_arr_move_dense(a, from + count, from + n, old_len - from - n); if (count) { memcpy(a->data + from, slots, count * sizeof(*slots)); @@ -1777,6 +1863,9 @@ void scr_arr_splice_drop(ScrArr *a, double start, double deleteCount, size_t cou } if (next_len < old_len) scr_arr_clear_dense(a, next_len, old_len - next_len); a->len = next_len; + /* Values inserted into a hole-free array keep it hole-free. */ + if (packed) a->filled = (uint32_t)next_len; + else scr_arr_refill(a); for (size_t i = 0; i < kept; i++) scr_elem_release(a, removed[i]); if (removed != inline_removed) scr_mem_free(removed); } @@ -1804,6 +1893,7 @@ ScrArr *scr_arr_flat_copy(const ScrArr *a, ScrArr *out, bool flatten) { scr_arr_copy_index(out, at, a, i); } } + scr_arr_refill(out); return scr_arr_retain(out); } @@ -1942,6 +2032,7 @@ ScrArr *scr_arr_slice(ScrArr *a, double start, double end) { out->len = n; if (to <= a->cap) { scr_arr_copy_dense(out, 0, a, from, n, false, false); + scr_arr_refill(out); return out; } for (size_t i = 0; i < a->cap && i < to; i++) { @@ -1960,6 +2051,7 @@ ScrArr *scr_arr_slice(ScrArr *a, double start, double end) { SCR_AX(a)->sparse[i].state); } } + scr_arr_refill(out); return out; } @@ -2109,6 +2201,7 @@ ScrArr *scr_arr_sort_primitive(ScrArr *a, bool copy) { if (copy) out->len = a->len; if (!copy && scr_arr_is_dense(a)) { if (count) memcpy(a->data, values, count * sizeof(*values)); + scr_arr_cut(a, count); memset(a->present, SCR_ARR_VALUE, count); memset(a->present + count, SCR_ARR_UNDEFINED, undefined_count); scr_arr_clear_dense(a, count + undefined_count, a->len - count - undefined_count); @@ -2123,6 +2216,7 @@ ScrArr *scr_arr_sort_primitive(ScrArr *a, bool copy) { if (!copy) scr_arr_free_storage(&old); } free(values); + scr_arr_refill(out); return copy ? out : scr_arr_retain(a); } @@ -2368,6 +2462,7 @@ void scr_arr_sort_values(ScrArr *a, double count, ScrClosure *f, uint32_t arity) * holes, no storage-level undefined and no sparse tail. */ static bool scr_arr_dense_values(const ScrArr *a) { if (!scr_arr_is_dense(a)) return false; + if (a->filled == a->len) return true; const uint8_t *present = a->present; bool all = true; for (size_t i = 0; i < a->len; i++) all &= present[i] == SCR_ARR_VALUE; @@ -2443,6 +2538,7 @@ bool scr_arr_sort_dense(ScrArr *a, ScrClosure *f, uint32_t arity, bool owned) { } else { for (size_t i = 0; i < n; i++) scr_arr_set_slot(a, (double)i, src[i]); } + scr_arr_refill(a); free(src); return true; } diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index c461a8769..15cd0ca7e 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -126,7 +126,7 @@ static void scr_enable_utf8_console_output(void) { } #endif -void scr_runtime_abi_v14(void) {} +void scr_runtime_abi_v15(void) {} void scr_init(void) { #ifdef SCR_WORKERS diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 8e3c3a9ca..176c2be65 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -74,7 +74,7 @@ void scr_init(void); /* Program objects emitted by the bundled LLVM helper reference this symbol. * Its versioned spelling makes a mismatched manual runtime link fail before * the program can start. */ -void scr_runtime_abi_v14(void); +void scr_runtime_abi_v15(void); void scr_runtime_workers_v8(void); /* ── the trap funnel (scr_console.c; scr_library.c under -DSCR_LIB) ────── @@ -1583,7 +1583,8 @@ typedef struct ScrArr { size_t rc; /* SIZE_MAX = immortal (unused for arrays; kept per convention) */ size_t len; size_t cap; /* dense capacity; sparse entries cover indices >= cap */ - ScrElemKind elem; + /* One ScrElemKind. */ + uint8_t elem; /* 1 exactly when ops->trace is non-NULL: the element type carries a cycle * header, so the array is CYCLE-CAPABLE (an element can point back at the * array that owns it) and allocates with the hidden collector header — @@ -1591,7 +1592,15 @@ typedef struct ScrArr { * the slot storage (all elements are traced children). Arrays of acyclic * ref elements and the historic element kinds keep the lean 1-word * header. Kept beside `elem` so the RC fast paths test it with one load. */ - uint32_t cyc; + uint8_t cyc; + uint16_t spare; + /* A lower bound on the hole-free prefix: filled <= len, filled <= cap, + * and every dense state below it is SCR_ARR_VALUE. Element reads below + * it need no state check and element writes below it leave the states + * alone. Any operation that may store another state (a hole, present + * undefined) or move states below it lowers it first (scr_arr_cut); + * 0 is always valid. */ + uint32_t filled; /* SCR_ELEM_REF only; NULL for every other element kind. */ const ScrArrOps *ops; uint64_t *data; @@ -1611,15 +1620,31 @@ typedef struct ScrArrExt { size_t prop_cap; struct ScrDyn *metadata; /* heterogeneous named properties of match results */ } ScrArrExt; -/* The LLVM backend's inline retain/release read `cyc` through its mirrored - * %ScrArr type (field 4, i32) to decide mark-live / candidate buffering - * exactly like scr_arr_retain and scr_arr_release; its inline element reads - * use data (field 6) and present (field 7). */ -_Static_assert(sizeof(ScrElemKind) == sizeof(uint32_t), "ScrArr.elem is an i32 (%ScrArr field 3)"); -_Static_assert(offsetof(ScrArr, cyc) == 3 * sizeof(size_t) + sizeof(uint32_t), +/* The LLVM backend mirrors ScrArr as %ScrArr: its inline retain/release read + * `cyc` (field 4, i8) to decide mark-live / candidate buffering exactly like + * scr_arr_retain and scr_arr_release; its inline element access uses filled + * (field 6, i32), data (field 8) and present (field 9). */ +_Static_assert(offsetof(ScrArr, cyc) == 3 * sizeof(size_t) + 1, "inline RC fast paths read ScrArr.cyc as %ScrArr field 4"); -_Static_assert(offsetof(ScrArr, data) == 3 * sizeof(size_t) + 2 * sizeof(uint32_t) + sizeof(void *), - "inline element reads use ScrArr.data as %ScrArr field 6"); +_Static_assert(offsetof(ScrArr, filled) == 3 * sizeof(size_t) + 4, + "inline element access reads ScrArr.filled as %ScrArr field 6"); +_Static_assert(offsetof(ScrArr, data) == 3 * sizeof(size_t) + 8 + sizeof(void *), + "inline element reads use ScrArr.data as %ScrArr field 8"); + +/* Lower the hole-free prefix to at most `index`: call before storing a + * non-value state at `index`, or before moving states into [index, ...). */ +static inline void scr_arr_cut(ScrArr *a, size_t index) { + if (a->filled > index) a->filled = (uint32_t)index; +} + +/* Extend the hole-free prefix over populated dense states. Starts at the + * current bound, so after an append it costs only the appended range. */ +static inline void scr_arr_refill(ScrArr *a) { + size_t end = a->len < a->cap ? a->len : a->cap; + size_t f = a->filled; + while (f < end && a->present[f] == SCR_ARR_VALUE) f++; + a->filled = (uint32_t)f; +} typedef struct ScrArrSparseSlot { size_t index; diff --git a/packages/runtime/test/test_array.c b/packages/runtime/test/test_array.c index 858f088d7..a9dd3d20e 100644 --- a/packages/runtime/test/test_array.c +++ b/packages/runtime/test/test_array.c @@ -1266,6 +1266,86 @@ static void test_exact_construction(void) { scr_arr_release(refs); } +/* The hole-free prefix bound never covers a non-value state, and the + * operations that keep an array hole-free keep the bound at its length. */ +static bool filled_ok(const ScrArr *a) { + if (a->filled > a->len || a->filled > a->cap) return false; + for (size_t i = 0; i < a->filled; i++) + if (a->present[i] != SCR_ARR_VALUE) return false; + return true; +} + +static void test_filled_prefix(void) { + ScrArr *a = scr_arr_new(SCR_ELEM_F64, 0); + for (int i = 0; i < 8; i++) scr_arr_push_f64(a, i); + check(a->filled == 8 && filled_ok(a), "pushes keep the bound at the length"); + scr_arr_set_len(a, 12); + check(a->filled == 8 && filled_ok(a), "length growth leaves the bound below the holes"); + scr_arr_set_f64(a, 8, 8); + check(a->filled == 9 && filled_ok(a), "a write at the bound extends it"); + scr_arr_set_f64(a, 11, 11); + check(a->filled == 9 && filled_ok(a), "a write past a hole does not extend it"); + scr_arr_release(scr_arr_fill_f64(a, 0, 0, INFINITY)); + check(a->filled == 12 && filled_ok(a), "fill over the whole array restores it"); + scr_arr_delete(a, 3); + check(a->filled == 3 && filled_ok(a), "delete cuts the bound at the hole"); + scr_arr_set_f64(a, 3, 3); + check(a->filled == 4 && filled_ok(a), "refilling the hole continues the prefix"); + scr_arr_set_undefined(a, 1); + check(a->filled == 1 && filled_ok(a), "present undefined cuts the bound"); + scr_arr_set_len(a, 0); + check(a->filled == 0 && filled_ok(a), "truncation cuts the bound"); + scr_arr_release(a); + + ScrArr *b = scr_arr_new(SCR_ELEM_F64, 0); + for (int i = 0; i < 10; i++) scr_arr_push_f64(b, i); + check(scr_arr_shift_f64(b) == 0 && b->filled == 9 && filled_ok(b), "shift keeps a full bound"); + scr_arr_unshift_f64(b, -1); + check(b->filled == 10 && filled_ok(b), "unshift keeps a full bound"); + scr_arr_release(scr_arr_splice(b, 2, 3)); + check(b->filled == 7 && filled_ok(b), "splice removal keeps a full bound"); + double values[2] = {5, 6}; + uint64_t two[2]; + memcpy(two, values, sizeof two); + scr_arr_splice_drop(b, 1, 0, 2, two); + check(b->filled == 9 && filled_ok(b), "splice insertion keeps a full bound"); + scr_arr_release(scr_arr_reverse(b)); + check(b->filled == 9 && filled_ok(b), "reverse keeps a full bound"); + scr_arr_release(scr_arr_copy_within(b, 0, 4, 6)); + check(b->filled == 9 && filled_ok(b), "copyWithin inside the prefix keeps it"); + scr_arr_set_len(b, 12); + scr_arr_release(scr_arr_copy_within(b, 2, 10, 12)); + check(b->filled == 2 && filled_ok(b), "copyWithin of holes cuts the bound"); + ScrArr *s = scr_arr_slice(b, 0, 2); + check(s->filled == 2 && filled_ok(s), "slice of a hole-free range is hole-free"); + scr_arr_release(s); + s = scr_arr_slice(b, 0, INFINITY); + check(s->filled == 2 && filled_ok(s), "slice keeps holes out of the bound"); + scr_arr_release(s); + scr_arr_release(scr_arr_reverse(b)); + check(filled_ok(b), "reverse of a holey array recomputes the bound"); + scr_arr_release(scr_arr_sort_primitive(b, false)); + check(filled_ok(b), "sorting moves holes past the bound"); + for (size_t i = 0; i < b->len; i++) { + if (i < b->cap && b->present[i] != SCR_ARR_VALUE) break; + check(i < b->filled, "sorted values form the bound"); + } + while (scr_arr_len(b) > 0) { + scr_arr_pop_state(b, NULL); + check(filled_ok(b), "pop keeps the bound below the length"); + } + scr_arr_release(b); + + ScrArr *r = scr_arr_new(SCR_ELEM_STR, 0); + for (int i = 0; i < 4; i++) scr_arr_push_ref(r, scr_str_new("ab", 2)); + check(r->filled == 4 && filled_ok(r), "reference pushes keep the bound"); + scr_arr_delete(r, 0); + check(r->filled == 0 && filled_ok(r), "deleting the first reference empties the bound"); + scr_arr_set_ref(r, 0, scr_str_new("c", 1)); + check(r->filled == 1 && filled_ok(r), "a write at an empty bound extends it by one"); + scr_arr_release(r); +} + int main(int argc, char **argv) { if (argc > 1) { ScrArr *a = scr_arr_new(SCR_ELEM_F64, 0); @@ -1310,6 +1390,7 @@ int main(int argc, char **argv) { test_large_storage(); test_bulk_reference_ownership(); test_splice_drop(); + test_filled_prefix(); fprintf(stderr, "%ld/%ld cases passed\n", total - failed, total); return failed == 0 ? 0 : 1; diff --git a/tests/corpus/4840-array-hole-free-prefix.ts b/tests/corpus/4840-array-hole-free-prefix.ts new file mode 100644 index 000000000..666739e03 --- /dev/null +++ b/tests/corpus/4840-array-hole-free-prefix.ts @@ -0,0 +1,199 @@ +// Arrays that start hole-free and then gain holes, present undefined, or +// moved elements through every mutating operation. After each step the +// whole array is read back by index, by `in`, and by forEach (which skips +// holes), and then written again by index, so element reads and writes +// below and past the hole-free prefix all agree with JS semantics. +function show(label: string, xs: (number | undefined)[]): void { + const parts: string[] = []; + for (let i = 0; i < xs.length; i++) parts.push(i in xs ? String(xs[i]) : "_"); + let visits = 0; + xs.forEach(() => visits++); + console.log(label, xs.length, parts.join(","), "visits", visits); +} + +function showNumbers(label: string, xs: number[]): void { + const parts: string[] = []; + for (let i = 0; i < xs.length; i++) parts.push(i in xs ? String(xs[i]) : "_"); + let visits = 0; + let sum = 0; + xs.forEach((x) => { + visits++; + sum += x; + }); + console.log(label, xs.length, parts.join(","), "visits", visits, "sum", sum); +} + +function rewrite(xs: number[], from: number, to: number, base: number): void { + for (let i = from; i < to; i++) xs[i] = base + i; +} + +// A hole at `at` without `delete`: copying a hole deletes the target. +function punch(xs: T[], at: number): void { + const n = xs.length; + xs.length = n + 1; + xs.copyWithin(at, n, n + 1); + xs.length = n; +} + +// fill, then length growth, then writes into and past the old length. +const a: number[] = new Array(5).fill(1); +showNumbers("fill", a); +a[2] = 9; +showNumbers("write-in-prefix", a); +a.length = 8; +showNumbers("grow", a); +console.log("grow-in", 4 in a, 5 in a, a[6]); +a[5] = 50; +showNumbers("write-hole", a); +a[7] = 70; +showNumbers("write-last", a); +a[6] = 60; +showNumbers("close-gap", a); +a.length = 3; +showNumbers("truncate", a); +a.length = 6; +showNumbers("regrow", a); +rewrite(a, 0, 6, 100); +showNumbers("rewrite", a); + +// a hole in the middle, then the hole is refilled. +const b: number[] = [1, 2, 3, 4, 5, 6]; +punch(b, 2); +showNumbers("punch", b); +b[1] = 20; +b[4] = 40; +showNumbers("after-punch-writes", b); +b[2] = 30; +showNumbers("refill", b); +punch(b, 0); +punch(b, 5); +showNumbers("punch-ends", b); +b.push(7); +showNumbers("push-after-holes", b); +console.log("pop", b.pop(), b.pop(), b.length); +showNumbers("popped", b); + +// sparse writes past the length and appends at the length. +const c: number[] = []; +for (let i = 0; i < 4; i++) c[i] = i * 2; +showNumbers("append-by-index", c); +c[10] = 1; +showNumbers("sparse-write", c); +for (let i = 4; i < 10; i++) c[i] = -i; +showNumbers("fill-gap", c); + +// new Array(n) filled by index from the front and from the back. +const d: number[] = new Array(6); +showNumbers("new-array", d); +for (let i = 0; i < 3; i++) d[i] = i; +showNumbers("front-half", d); +for (let i = 5; i >= 3; i--) d[i] = i; +showNumbers("back-half", d); + +// present undefined in a nullable array. +const e: (number | undefined)[] = [1, 2, 3, 4]; +e[1] = undefined; +show("undefined", e); +e[1] = 5; +e[3] = undefined; +show("undefined-last", e); +e.length = 6; +show("undefined-grow", e); +e.fill(undefined, 4); +show("fill-undefined", e); +e.fill(8); +show("fill-all", e); + +// structural operations on hole-free and holey arrays. +for (const holey of [false, true]) { + const tag = holey ? "holey" : "packed"; + const make = (): number[] => { + const xs: number[] = [10, 11, 12, 13, 14, 15, 16]; + if (holey) punch(xs, 3); + return xs; + }; + const s = make(); + console.log(tag, "shift", s.shift()); + showNumbers(tag + "-shift", s); + s[0] = 1; + s[s.length - 1] = 2; + showNumbers(tag + "-shift-write", s); + const u = make(); + u.unshift(1, 2); + showNumbers(tag + "-unshift", u); + u[3] = 3; + showNumbers(tag + "-unshift-write", u); + const sp = make(); + const removed = sp.splice(1, 2); + showNumbers(tag + "-splice", sp); + showNumbers(tag + "-removed", removed); + sp.splice(2, 0, 7, 8, 9); + showNumbers(tag + "-splice-insert", sp); + sp[4] = 4; + showNumbers(tag + "-splice-write", sp); + const r = make(); + r.reverse(); + showNumbers(tag + "-reverse", r); + r[3] = 33; + showNumbers(tag + "-reverse-write", r); + const so = make(); + so.sort((x, y) => y - x); + showNumbers(tag + "-sort", so); + so[so.length - 1] = 99; + showNumbers(tag + "-sort-write", so); + const cw = make(); + cw.copyWithin(0, 3, 6); + showNumbers(tag + "-copy-within", cw); + cw[1] = 1; + showNumbers(tag + "-copy-within-write", cw); + const sl = make().slice(2, 6); + showNumbers(tag + "-slice", sl); + sl[1] = 1; + showNumbers(tag + "-slice-write", sl); + const cc = make().concat(make()); + showNumbers(tag + "-concat", cc); + cc[3] = 3; + showNumbers(tag + "-concat-write", cc); + const m = make().map((x) => x * 2); + showNumbers(tag + "-map", m); + const f = make().filter((x) => x % 2 === 0); + showNumbers(tag + "-filter", f); +} + +// references: overwrite, punch, truncate and regrow string and object arrays. +class Box { + v: number; + constructor(v: number) { + this.v = v; + } +} +const names: string[] = ["a", "b", "c", "d"].map((s) => s + s); +names[1] = "x" + names[0]; +punch(names, 2); +names.length = 6; +names[5] = "tail"; +const nameParts: string[] = []; +for (let i = 0; i < names.length; i++) nameParts.push(i in names ? names[i]! : "_"); +console.log("names", nameParts.join(",")); +const boxes: Box[] = []; +for (let i = 0; i < 5; i++) boxes.push(new Box(i)); +boxes[2] = new Box(20); +punch(boxes, 3); +boxes.length = 4; +boxes[1] = new Box(10); +let boxSum = 0; +boxes.forEach((box) => (boxSum += box.v)); +console.log("boxes", boxes.length, 3 in boxes, boxSum, boxes[0]!.v, boxes[2]!.v); + +// boolean flags: holes read as undefined (falsy) until written. +const flags: boolean[] = new Array(8).fill(false); +for (let i = 2; i < 8; i += 2) flags[i] = true; +flags.length = 10; +let truthy = 0; +for (let i = 0; i < flags.length; i++) if (flags[i]) truthy++; +console.log("flags", truthy, 9 in flags, flags.indexOf(true), flags.lastIndexOf(false)); +flags[9] = true; +flags[8] = true; +truthy = 0; +for (let i = 0; i < flags.length; i++) if (flags[i]) truthy++; +console.log("flags-written", truthy, flags.join("")); diff --git a/tests/corpus/4860-int32-local-storage.ts b/tests/corpus/4860-int32-local-storage.ts new file mode 100644 index 000000000..20d201b5a --- /dev/null +++ b/tests/corpus/4860-int32-local-storage.ts @@ -0,0 +1,326 @@ +// Number locals whose every write is an int32 (or every write a uint32): +// string and byte hashes, checksums and PRNGs, counters that cross 2^31 +// through `| 0`, uint32 accumulators above 2^31, ++/-- counters, Int32 +// contract locals and parameters. Locals that also hold fractions, -0, NaN +// or values beyond 32 bits, and locals that closures capture or that live +// in generator and async frames, read back the same numbers. +import type { Int32 } from "@scriptc/types"; + +function show(label: string, value: number): void { + console.log(label, value, Object.is(value, -0) ? "negative zero" : ""); +} + +function words(n: number): string[] { + const out: string[] = []; + let s = 12345; + for (let i = 0; i < n; i++) { + s = (Math.imul(s, 1103515245) + 12345) | 0; + const len = 1 + ((s >>> 16) & 15); + let w = ""; + for (let j = 0; j < len; j++) { + s = (Math.imul(s, 1103515245) + 12345) | 0; + w += String.fromCharCode(97 + ((s >>> 16) % 26)); + } + out.push(w); + } + out.push("", "é漢字🙂", "x".repeat(300)); + return out; +} + +function bytes(n: number, seed: number): Uint8Array { + const out = new Uint8Array(n); + let s = seed | 0; + for (let i = 0; i < n; i++) { + s = (Math.imul(s, 1103515245) + 12345) | 0; + out[i] = s >>> 24; + } + return out; +} + +// String hashes, as a hash table or an interner computes them. +function djb2(s: string): number { + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; + return h; +} +function javaHash(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; + return h; +} +function sdbm(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = (s.charCodeAt(i) + (h << 6) + (h << 16) - h) | 0; + return h; +} +function fnv1a(s: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); + return h >>> 0; +} + +// Checksums over bytes. +function rsyncWeak(a: Uint8Array): number { + let s1 = 0, + s2 = 0; + for (let i = 0; i < a.length; i++) { + s1 = (s1 + a[i]) & 0xffff; + s2 = (s2 + s1) & 0xffff; + } + return ((s2 << 16) | s1) >>> 0; +} +function rolling(a: Uint8Array, window: number): number { + // rsync's rolling update: drop the leaving byte, add the entering one. + let s1 = 0, + s2 = 0, + best = 0; + for (let i = 0; i < window; i++) { + s1 = (s1 + a[i]) & 0xffff; + s2 = (s2 + s1) & 0xffff; + } + for (let i = window; i < a.length; i++) { + s1 = (s1 - a[i - window] + a[i]) & 0xffff; + s2 = (s2 - window * a[i - window] + s1) & 0xffff; + const digest = ((s2 << 16) | s1) >>> 0; + if (digest > best) best = digest; + } + return best; +} +function fletcherWrap(a: Uint8Array): number { + let s1 = 0, + s2 = 0; + for (let i = 0; i < a.length; i++) { + s1 = (s1 + a[i]) | 0; + s2 = (s2 + s1) | 0; + } + return (s1 ^ s2) >>> 0; +} +function adler32(a: Uint8Array): number { + // `%` results stay ordinary numbers. + let x = 1, + y = 0; + for (let i = 0; i < a.length; i++) { + x = (x + a[i]) % 65521; + y = (y + x) % 65521; + } + return ((y << 16) | x) >>> 0; +} +function murmur3(a: Uint8Array, seed: number): number { + let h = seed | 0; + const n = a.length & ~3; + for (let i = 0; i < n; i += 4) { + let k = a[i] | (a[i + 1] << 8) | (a[i + 2] << 16) | (a[i + 3] << 24); + k = Math.imul(k, 0xcc9e2d51); + k = (k << 15) | (k >>> 17); + k = Math.imul(k, 0x1b873593); + h ^= k; + h = (h << 13) | (h >>> 19); + h = (Math.imul(h, 5) + 0xe6546b64) | 0; + } + h ^= a.length; + h ^= h >>> 16; + h = Math.imul(h, 0x85ebca6b); + h ^= h >>> 13; + h = Math.imul(h, 0xc2b2ae35); + h ^= h >>> 16; + return h >>> 0; +} +function inet(a: Uint8Array): number { + let sum = 0; + for (let i = 0; i + 1 < a.length; i += 2) sum = (sum + ((a[i] << 8) | a[i + 1])) >>> 0; + while (sum >>> 16) sum = ((sum & 0xffff) + (sum >>> 16)) >>> 0; + return ~sum & 0xffff; +} + +// PRNGs. +function xorshift32(seed: number, n: number): number { + let x = seed | 0, + acc = 0; + for (let i = 0; i < n; i++) { + x ^= x << 13; + x ^= x >>> 17; + x ^= x << 5; + acc = (acc + (x >>> 0)) >>> 0; + } + return acc; +} +function sfc32(a: number, b: number, c: number, d: number, n: number): number { + let total = 0; + for (let i = 0; i < n; i++) { + a |= 0; + b |= 0; + c |= 0; + d |= 0; + const t = (((a + b) | 0) + d) | 0; + d = (d + 1) | 0; + a = b ^ (b >>> 9); + b = (c + (c << 3)) | 0; + c = (c << 21) | (c >>> 11); + c = (c + t) | 0; + total = (total + (t >>> 0)) >>> 0; + } + return total; +} +function mulberry32(seed: number): () => number { + // The state is captured by the generator closure. + let a = seed | 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// Counters crossing 2^31 through `| 0`, and uint32 counters above 2^31. +function wrapping(): void { + let c = 2147483645; + const seen: number[] = []; + for (let i = 0; i < 5; i++) { + c = (c + 1) | 0; + seen.push(c); + } + console.log("wrap up", seen.join(" ")); + let d = -2147483646; + seen.length = 0; + for (let i = 0; i < 4; i++) { + d = (d - 1) | 0; + seen.push(d); + } + console.log("wrap down", seen.join(" ")); + let u = 4294967290; + seen.length = 0; + for (let i = 0; i < 8; i++) { + u = (u + 1) >>> 0; + seen.push(u); + } + console.log("uint32 wrap", seen.join(" ")); + let big = 0; + for (let i = 0; i < 40; i++) big = (big + 0x7654321f) >>> 0; + show("uint32 big", big); + show("uint32 half", big / 2); + show("uint32 string", Number(String(big))); +} + +// ++ and -- on int32 counters, read as values. +function counters(limit: number): void { + let up = 0, + down = 10; + const log: string[] = []; + for (let i = 0; i < 3; i++) { + const before = up++; + const after = ++up; + log.push(`${before}/${after}`); + } + while (down > 6) log.push(String(down--)); + let k = 0; + while (k < (limit | 0)) k++; + log.push(String(--k), String(k)); + console.log("counters", log.join(" ")); +} + +// Locals that leave int32 keep their exact values. +function leaves(n: number): void { + let h = 0; + for (let i = 0; i < n; i++) h = (h + i) | 0; + h = h * 1.5; + show("fraction", h); + let z = 7 | 0; + if (n > 0) z = -0; + show("negative zero", z); + let m = 5 | 0; + for (let i = 0; i < n; i++) m = m % 2 === 0 ? m >>> 0 : m | 0; + show("mixed", m); + let both = -1 | 0; + if (n > 2) both = both >>> 0; + show("signed then unsigned", both); + let wide = 1 | 0; + for (let i = 0; i < 40; i++) wide = wide * 2; + show("past 2^32", wide); + let nan = 3 | 0; + if (n > 1) nan = (nan * 0) / 0; + show("nan", nan); + let divided = 1 | 0; + divided = (divided / 3) | 0; + show("divided", 1 / (divided - 0)); +} + +// Captured accumulators: the closure and the function share one binding. +function captured(list: string[]): void { + let h = 17; + const mixIn = (s: string): void => { + for (let i = 0; i < s.length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) | 0; + }; + for (const w of list) mixIn(w); + let count = 0; + const bump = (): number => ++count; + for (let i = 0; i < 5; i++) bump(); + const reads: (() => number)[] = []; + for (let i = 0; i < 3; i++) { + let local = (i * 7) | 0; + reads.push(() => local); + local = (local + 1) | 0; + } + console.log("captured", h, count, reads.map((r) => r()).join(" ")); +} + +// Generators and async functions keep their locals in frames. +function* hashes(list: string[]): Generator { + let h = 0; + for (const w of list) { + for (let i = 0; i < w.length; i++) h = (Math.imul(h, 33) ^ w.charCodeAt(i)) | 0; + yield h; + } +} +async function asyncSum(list: string[]): Promise { + let total = 0; + for (const w of list) { + await Promise.resolve(); + total = (total + djb2(w)) | 0; + } + return total; +} + +// Int32 contract locals and parameters. +function contract(seed: Int32, steps: Int32): Int32 { + let acc: Int32 = seed; + for (let i: Int32 = 0; i < steps; i = (i + 1) | 0) acc = (Math.imul(acc, 3) + i) | 0; + return acc; +} + +const list = words(400); +let a = 0, + b = 0, + c = 0, + d = 0; +for (const w of list) { + a = (a + djb2(w)) | 0; + b = (b ^ javaHash(w)) | 0; + c = (c + sdbm(w)) | 0; + d = (d ^ fnv1a(w)) >>> 0; +} +console.log("string hashes", a, b, c, d); +console.log("samples", djb2("hello"), javaHash("hello"), sdbm("hello"), fnv1a("hello")); +console.log("empty", djb2(""), javaHash(""), sdbm(""), fnv1a("")); + +const data = bytes(1 << 14, 7); +console.log("rsync", rsyncWeak(data), rolling(data, 64)); +console.log("fletcher", fletcherWrap(data), "adler32", adler32(data)); +console.log("murmur3", murmur3(data, 0), murmur3(data, -1), murmur3(data.subarray(0, 7), 42)); +console.log("inet", inet(data), inet(new Uint8Array([0xff, 0xff, 0xff, 0xff, 0x01]))); + +console.log("xorshift32", xorshift32(2463534242, 1000), xorshift32(1, 1)); +console.log("sfc32", sfc32(1, 2, 3, 4, 10000), sfc32(-1, 0x7fffffff, -2147483648, 0, 3)); +const next = mulberry32(42); +const draws: string[] = []; +for (let i = 0; i < 4; i++) draws.push(next().toFixed(6)); +console.log("mulberry32", draws.join(" ")); + +wrapping(); +counters(5); +leaves(4); +leaves(0); +captured(list.slice(0, 50)); +console.log("generator", [...hashes(["ab", "cd", "efg"])].join(" ")); +console.log("contract", contract(7, 20), contract(-2147483648, 3)); +asyncSum(list).then((total) => console.log("async", total)); diff --git a/tests/harness/self-hosting-native-toolchain.test.ts b/tests/harness/self-hosting-native-toolchain.test.ts index 3723a1132..d5104aa82 100644 --- a/tests/harness/self-hosting-native-toolchain.test.ts +++ b/tests/harness/self-hosting-native-toolchain.test.ts @@ -96,7 +96,7 @@ for (const backend of ["llvm"] as const) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 14, marker: "scr_runtime_abi_v14" }, + runtime_abi: { version: 15, marker: "scr_runtime_abi_v15" }, compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple }, macros: { executable: [], excluded: [], sanitizer: "external-toolchain-required" }, flavors: {