Read a typed array's length without an invariant header load (#2225)

This commit is contained in:
Chris Tate
2026-10-25 10:38:13 -05:00
committed by GitHub
6 changed files with 149 additions and 9 deletions
@@ -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 {
@@ -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<string, { kind: number; le: boolean } | undefined> = {
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)}`);
@@ -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<IrModule> {
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);
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/typed-array-length-after-release.ts"
],
"diags": []
}
@@ -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<string, Float64Array>();
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);
@@ -0,0 +1 @@
{ "plain": 2800, "san": 2500 }