diff --git a/packages/compiler/src/backend/llvm/bytes-alias.ts b/packages/compiler/src/backend/llvm/bytes-alias.ts index b7a672754..2dff9f70b 100644 --- a/packages/compiler/src/backend/llvm/bytes-alias.ts +++ b/packages/compiler/src/backend/llvm/bytes-alias.ts @@ -9,15 +9,25 @@ import type { LlvmEmitterContext } from "./expr-context.js"; * receiver slots loop-invariant. Untagged accesses and calls still alias * every category. * - * Header loads are also `!invariant.load`: no store or call between the - * construction of a view and its release changes them. Without it, a data - * pointer load ahead of a bounds branch is sunk into the in-bounds block, - * and LICM keeps a conditionally executed load inside the loop. */ + * Header loads that only bound or address an access to the view are also + * `!invariant.load`: no store or call between the construction of a view + * and its release changes them, and their values reach only that access, + * which runs while the view is alive. Without it, a data pointer load + * ahead of a bounds branch is sunk into the in-bounds block, and LICM keeps + * a conditionally executed load inside the loop. */ export function bytesHeaderAlias(host: LlvmEmitterContext): string { const tag = host.memoryAliasAttachment("bytes:header"); return tag === "" ? "" : `${tag}, !invariant.load !{}`; } +/** A header load whose value the program reads (`length`, `byteLength`) + * keeps the alias tag only. The value can outlive the view, and LLVM may + * move an invariant load to its use, past the release that frees the + * view. */ +export function bytesHeaderValueAlias(host: LlvmEmitterContext): string { + return host.memoryAliasAttachment("bytes:header"); +} + /** Element storage's tag, and the alias scope of the access being emitted * in a loop version's fast copy (bytesAccessScope). */ export function bytesElementAlias(host: LlvmEmitterContext): string { diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index a50464cd6..6f0b0380d 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -8,7 +8,7 @@ import { alwaysExactInteger, exactInteger } from "./integer-values.js"; import { byteNumberAccess } from "../../ir/byte-numbers.js"; import { emitByteNumber } from "./byte-numbers.js"; import { integerOrNaNIndex, integerOrNaNRange } from "./wrapping-sums.js"; -import { bytesElementAlias, bytesHeaderAlias } from "./bytes-alias.js"; +import { bytesElementAlias, bytesHeaderAlias, bytesHeaderValueAlias } from "./bytes-alias.js"; const BYTES_NUM_KIND: Record = { u8: { kind: 0, le: false }, @@ -160,12 +160,19 @@ export function emitBytesIndex( return idx; } -/** The element count of a typed array, in the target's size type. */ -export function emitBytesHeaderLength(host: LlvmEmitterContext, receiver: string): string { +/** The element count of a typed array, in the target's size type. `value`: + * the program reads the count itself (bytesHeaderValueAlias), rather than + * only bounding an access. */ +export function emitBytesHeaderLength( + host: LlvmEmitterContext, + receiver: string, + value = false, +): string { const p = host.B.tmp(); const len = host.B.tmp(); + const alias = value ? bytesHeaderValueAlias(host) : bytesHeaderAlias(host); host.B.line(`${p} = getelementptr inbounds %ScrBytes, ptr ${receiver}, i64 0, i32 1`); - host.B.line(`${len} = load ${host.sizeType}, ptr ${p}${bytesHeaderAlias(host)}`); + host.B.line(`${len} = load ${host.sizeType}, ptr ${p}${alias}`); return len; } @@ -184,7 +191,7 @@ export function emitBytesLength( bytes: boolean, ): LlValue { const B = host.B; - const len = emitBytesHeaderLength(host, receiver); + const len = emitBytesHeaderLength(host, receiver, true); const size = BYTES_ELEMENT_SIZE[elem]; const count = bytes && size !== 1 ? B.tmp() : len; if (count !== len) B.line(`${count} = shl ${host.sizeType} ${len}, ${Math.log2(size)}`); diff --git a/packages/compiler/test/bytes-header-load-emission.test.ts b/packages/compiler/test/bytes-header-load-emission.test.ts new file mode 100644 index 000000000..bc7cb9803 --- /dev/null +++ b/packages/compiler/test/bytes-header-load-emission.test.ts @@ -0,0 +1,60 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; +import { type IrModule } from "../src/ir/ir.js"; + +async function lower(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-bytes-header-")); + try { + const entry = join(dir, "main.ts"), + output = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n")); + const module = deserializeModule(await readFile(output, "utf8")); + expect(validateModule(module)).toEqual([]); + return module; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function body(llvm: string, symbol: string): string { + const found = new RegExp(`^define internal [^\\n]*@${symbol}(?:\\.i32)?\\([^]*?^}`, "m").exec( + llvm, + ); + expect(found, symbol).not.toBeNull(); + return found![0]; +} + +const INVARIANT_HEADER = /load (?:i64|i32|ptr), ptr [^\n]*, !tbaa !\d+, !invariant\.load !\{\}$/m; + +test("a typed array's length read as a value is an ordinary load; access bounds stay invariant", async () => { + const module = await lower(` +class Table { + slots: Int32Array = new Int32Array(4); + swap(): number { + const slots = this.slots; + this.slots = new Int32Array(1); + return slots.length + slots.byteLength; + } +} +function sum(values: Float64Array, scale: number): number { + let total = 0; + for (let i = 0; i < values.length; i++) total += values[i]! * scale; + return total; +} +const table = new Table(); +console.log(table.swap(), sum(new Float64Array([1, 2]), 2)); +`); + const llvm = emitLlvmModule(module); + // The length outlives the view: the release must not be crossed. + const swap = body(llvm, "sc_bf__x25_Table_swap"); + expect(swap).toMatch(/load i64, ptr [^\n]*, !tbaa !\d+$/m); + expect(swap).not.toContain("!invariant.load"); + expect(body(llvm, "sc_bf_sum")).toMatch(INVARIANT_HEADER); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/typed-array-length-after-release.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/typed-array-length-after-release.ts.json new file mode 100644 index 000000000..ca6fcb551 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/typed-array-length-after-release.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/typed-array-length-after-release.ts" + ], + "diags": [] +} diff --git a/tests/corpus/typed-array-length-after-release.ts b/tests/corpus/typed-array-length-after-release.ts new file mode 100644 index 000000000..81fad4c77 --- /dev/null +++ b/tests/corpus/typed-array-length-after-release.ts @@ -0,0 +1,56 @@ +// A typed array's length and byteLength read just before the view's last +// reference goes away: the values stay those of the old view after a field +// replaces it, a map entry is deleted, or a local is rebound. +function grow(slots: Int32Array): Int32Array { + const next = new Int32Array(slots.length * 2); + next.set(slots); + return next; +} + +class Table { + slots: Int32Array = new Int32Array(4); + count = 0; + add(value: number): number { + const slots = this.slots; + slots[this.count % slots.length] = value; + this.count++; + if (this.count === slots.length) this.slots = grow(slots); + return this.slots.length; + } + swap(): number { + const slots = this.slots; + this.slots = new Int32Array(1); + return slots.length; + } + swapBytes(): number { + const slots = this.slots; + const bytes = slots.byteLength; + this.slots = new Int32Array(2); + return bytes + slots.length; + } +} + +const table = new Table(); +let size = 0; +for (let i = 0; i < 9; i++) size = table.add(i); +console.log(size, table.swap(), table.slots.length, table.add(5)); +console.log(table.swapBytes(), table.slots.length); + +const views = new Map(); +views.set("a", new Float64Array(6)); +function take(key: string): number { + const view = views.get(key); + if (view === undefined) return -1; + const length = view.length; + views.delete(key); + return length + view.byteLength; +} +console.log(take("a"), take("a"), views.size); + +let current = new Uint8Array(3); +function rebind(): number { + const old = current; + current = new Uint8Array(old.length + 1); + return old.length * 10 + current.length; +} +console.log(rebind(), rebind(), current.length); diff --git a/tests/harness/corpus-costs/typed-array-length-after-release.ts.json b/tests/harness/corpus-costs/typed-array-length-after-release.ts.json new file mode 100644 index 000000000..b062243b7 --- /dev/null +++ b/tests/harness/corpus-costs/typed-array-length-after-release.ts.json @@ -0,0 +1 @@ +{ "plain": 2800, "san": 2500 }