Remove the last element of a packed array inline on pop (#1282)

This commit is contained in:
Chris Tate
2026-10-18 08:21:05 -05:00
committed by GitHub
7 changed files with 357 additions and 10 deletions
@@ -3,6 +3,7 @@ import {
BOOL,
F64,
STRING,
UNDEFINED_T,
VOID,
arrayOf,
type IrExpr,
@@ -23,11 +24,17 @@ const ref = (localId: string, type: IrExpr["type"]): IrExpr => ({
});
const index = ref("i", F64);
function module(name: string, returnType: IrExpr["type"], body: IrStmt[]): IrModule {
function module(
name: string,
returnType: IrExpr["type"],
body: IrStmt[],
unions: IrModule["unions"] = [],
): IrModule {
return {
irVersion: 16,
sourceFile: loc.file,
entry: "__main",
unions,
functions: [
{ name: "__main", params: [], returnType: VOID, locals: [], body: [], loc },
{
@@ -174,6 +181,76 @@ test.each([32, 64] as const)(
},
);
test.each([32, 64] as const)(
"pop removes the last element of a packed array inline and keeps a cold runtime pop (%i-bit)",
(bits) => {
const maybe = { kind: "union", unionId: "maybe" } as const;
const pop: IrExpr = {
kind: "arrIntrinsic",
method: "pop",
receiver: ref("a", nums),
args: [],
type: maybe,
loc,
};
const llvm = emitLlvmModule(
module(
"take",
maybe,
[{ kind: "return", value: pop, loc }],
[{ id: "maybe", arms: [F64, UNDEFINED_T] }],
),
{ pointerBits: bits },
);
const fn = body(llvm, "take");
const size = bits === 32 ? "i32" : "i64";
// An empty array answers undefined without a call.
expect(fn).toMatch(new RegExp(`icmp eq ${size} %t\\d+, 0\\n br i1 %t\\d+, label %remove\\.a`));
expect(fn).toMatch(new RegExp(`sub nuw ${size} %t\\d+, 1`));
expect(fn).toMatch(/store i64 0, ptr /);
expect(fn).toMatch(/store i8 0, ptr /);
expect(fn).toMatch(/phi i64 /);
expect(fn).toMatch(/call zeroext i8 @scr_arr_pop_state\(.*\) cold$/m);
},
);
test("a discarded pop releases the removed element inline", () => {
const words = ref("s", arrayOf(STRING));
const length: IrExpr = {
kind: "arrIntrinsic",
method: "length",
receiver: words,
args: [],
type: F64,
loc,
};
const llvm = emitLlvmModule(
module("drop", VOID, [
{
kind: "arraySetLength",
arr: words,
length: {
kind: "bin",
op: "-",
left: length,
right: { kind: "numLit", value: 1, type: F64, loc },
type: F64,
loc,
},
loc,
pop: true,
},
]),
);
const fn = body(llvm, "drop");
expect(fn).toMatch(/fsub double %t\d+, 1\.0/);
expect(fn).toMatch(/load i64, ptr .*\n(?:.*\n)*? store i64 0, ptr /);
expect(fn).toMatch(/inttoptr i64 %t\d+ to ptr/);
expect(fn).toContain("call void @scr_str_release(");
// Holes, sparse storage, and every other length keep the runtime call.
expect(fn).toContain("call void @scr_arr_set_len(");
});
test.each([32, 64] as const)(
"indices below the hole-free prefix bound skip the state bytes (%i-bit)",
(bits) => {
@@ -508,6 +508,75 @@ export function emitDenseArraySet(
B.startBlock(join);
}
/** The dense half of `pop`. An empty array branches to `empty` (the
* runtime's pop returns a hole state there and changes nothing). When the
* hole-free prefix bound equals the length, the last element is a present
* value in dense storage, and the runtime's pop is exactly: clear its slot
* and state, lower the bound and the length to its index, and move the
* slot out. That block is left open and its slot bits are returned as an
* i64 (a boolean's 0 or 1, zero-extended); every other array branches to
* `slow`, which keeps the runtime call. */
export function emitDenseArrayPop(
host: LlvmEmitterContext,
array: string,
acc: ArrayAccess,
empty: string,
slow: string,
): string {
const B = host.B;
const size = host.sizeType;
const header = (field: number): string => {
const ptr = B.tmp();
B.line(`${ptr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${field}`);
host.markMemoryPointer(ptr, "array:header");
return ptr;
};
const lenPtr = header(SCR_ARR_FIELD.len),
len = B.tmp(),
none = B.tmp();
B.line(`${len} = load ${size}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`);
B.line(`${none} = icmp eq ${size} ${len}, 0`);
const check = B.newLabel("arr.pop.check"),
dense = B.newLabel("arr.pop.dense");
B.condBr(none, empty, check);
B.startBlock(check);
const filledPtr = header(SCR_ARR_FIELD.filled),
filled = B.tmp(),
packed = B.tmp();
B.line(`${filled} = load i32, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`);
B.line(`${packed} = icmp eq ${size} ${sizedFilled(host, filled)}, ${len}`);
B.condBr(packed, dense, slow);
B.startBlock(dense);
const last = B.tmp();
B.line(`${last} = sub nuw ${size} ${len}, 1`);
const dataPtr = header(SCR_ARR_FIELD.data),
data = B.tmp(),
statesPtr = header(SCR_ARR_FIELD.present),
states = B.tmp();
B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`);
B.line(`${states} = load ptr, ptr ${statesPtr}${host.fieldAliasAttachment(statesPtr)}`);
const valuePtr = valuePointer(host, { offset: last, data }, acc);
const statePtr = statePointer(host, { offset: last, states });
const slotTy = slotType(acc);
let raw = B.tmp();
B.line(`${raw} = load ${slotTy}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
if (slotTy === "i8") {
const wide = B.tmp();
B.line(`${wide} = zext i8 ${raw} to i64`);
raw = wide;
}
B.line(`store ${slotTy} 0, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
B.line(`store i8 0, ptr ${statePtr}${host.fieldAliasAttachment(statePtr)}`);
let last32 = last;
if (size === "i64") {
last32 = B.tmp();
B.line(`${last32} = trunc i64 ${last} to i32`);
}
B.line(`store i32 ${last32}, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`);
B.line(`store ${size} ${last}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`);
return raw;
}
/** `scr_arr_push_*` (the array takes the value's reference) returning the
* new length as a double. When `len < cap` the runtime's append is exactly
* a dense store at `len` plus `len + 1`: no sparse entry can exist past the
+28 -1
View File
@@ -199,7 +199,12 @@ import {
type LocalArrayRead,
} from "./local-array-reads.js";
import { CheckedNarrows, emitCheckedNarrowReceiver } from "./checked-narrows.js";
import { emitDenseArrayGet, emitDenseArraySet, emitDenseSlotUnlink } from "./dense-array-access.js";
import {
emitDenseArrayGet,
emitDenseArrayPop,
emitDenseArraySet,
emitDenseSlotUnlink,
} from "./dense-array-access.js";
import {
emitStackMapRead,
findMapReadLifetimes,
@@ -8130,6 +8135,28 @@ export class LlEmitter {
B.line(`${same} = fcmp oeq double ${length.name}, ${lenNumber}`);
B.condBr(same, done, resize);
B.startBlock(resize);
if (s.pop === true) {
// A discarded pop's `length - 1` on a packed array removes the
// last element inline, then releases it.
const call = B.newLabel("arr.len.call"),
pop = B.newLabel("arr.len.pop");
const lastNumber = B.tmp(),
dropsLast = B.tmp();
B.line(`${lastNumber} = fsub double ${lenNumber}, 1.0`);
B.line(`${dropsLast} = fcmp oeq double ${length.name}, ${lastNumber}`);
B.condBr(dropsLast, pop, call);
B.startBlock(pop);
const elem = s.arr.type.elem;
const acc = elemAccess(elem);
const raw = emitDenseArrayPop(this, arr.name, acc, call, call);
if (acc === "ref" && isRefCounted(elem)) {
const old = B.tmp();
B.line(`${old} = inttoptr i64 ${raw} to ptr`);
this.releaseValue(old, elem);
}
B.br(done);
B.startBlock(call);
}
B.line(`call void @scr_arr_set_len(ptr ${arr.name}, double ${length.name})`);
this.emitPendingCheck();
B.br(done);
@@ -49,7 +49,11 @@ import {
STRING_UTF16_LEN_DECL,
} from "./string-reads.js";
import { exactInteger, widenInteger } from "./integer-values.js";
import { emitDenseArrayNumber, emitDenseArrayPush } from "./dense-array-access.js";
import {
emitDenseArrayNumber,
emitDenseArrayPop,
emitDenseArrayPush,
} from "./dense-array-access.js";
import { emitUnionWiden } from "./expr-records.js";
export function resolveThunkFor(host: LlvmEmitterContext, inner: IrType): string {
@@ -667,19 +671,38 @@ export function emitArrIntrinsic(
const rawSlot = B.slot();
const resultSlot = B.slot();
B.entryAllocas.push(`${rawSlot} = alloca i64`, `${resultSlot} = alloca ptr`);
B.line(`store i64 0, ptr ${rawSlot}`);
const state = B.tmp();
const has = B.tmp();
B.line(`${state} = call zeroext i8 @scr_arr_${method}_state(ptr ${r.name}, ptr ${rawSlot})`);
B.line(`${has} = icmp eq i8 ${state}, 1`);
const lp = B.newLabel("remove.p");
const la = B.newLabel("remove.a");
const lj = B.newLabel("remove.j");
// pop answers an empty or packed array inline; shift and every other
// pop take the runtime call.
let denseRaw: string | null = null;
let denseLabel = "";
let slowLabel = "";
if (method === "pop") {
slowLabel = B.newLabel("remove.slow");
denseRaw = emitDenseArrayPop(host, r.name, acc, la, slowLabel);
denseLabel = B.currentLabel();
B.br(lp);
B.startBlock(slowLabel);
}
B.line(`store i64 0, ptr ${rawSlot}`);
const state = B.tmp();
const has = B.tmp();
const slowRaw = B.tmp();
B.line(
`${state} = call zeroext i8 @scr_arr_${method}_state(ptr ${r.name}, ptr ${rawSlot})${denseRaw === null ? "" : " cold"}`,
);
B.line(`${slowRaw} = load i64, ptr ${rawSlot}`);
B.line(`${has} = icmp eq i8 ${state}, 1`);
B.condBr(has, lp, la);
B.startBlock(lp);
const raw = B.tmp();
let raw = slowRaw;
const value = B.tmp();
B.line(`${raw} = load i64, ptr ${rawSlot}`);
if (denseRaw !== null) {
raw = B.tmp();
B.line(`${raw} = phi i64 [ ${denseRaw}, %${denseLabel} ], [ ${slowRaw}, %${slowLabel} ]`);
}
if (into) {
// Slot bits for f64; a canonical 0/1 for bool, like scr_union_new_bool.
let bits = raw;
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5681-array-pop-and-length.ts"
],
"diags": []
}
+143
View File
@@ -0,0 +1,143 @@
// Removing the last element with a statement-level pop() or `length = n`,
// for every element kind, on dense arrays, arrays with holes and present
// undefined values, sparse arrays and arrays shared between bindings.
// Removed references are released; the remaining elements, the length and
// later appends match Node.
class Item {
readonly id: number;
constructor(id: number) {
this.id = id;
}
}
function show(label: string, values: unknown[]): void {
const parts: string[] = [];
for (let i = 0; i < values.length; i++) {
if (!(i in values)) parts.push("<hole>");
else {
const v = values[i];
parts.push(v instanceof Item ? `I${v.id}` : (JSON.stringify(v) ?? "undefined"));
}
}
console.log(label, values.length, parts.join(" "));
}
const numbers = [1.5, 2.5, 3.5, 4.5];
numbers.pop();
numbers.length = numbers.length - 1;
numbers.push(9);
show("numbers", numbers);
const flags = [true, false, true];
flags.pop();
flags.length = 1;
flags.push(false);
show("flags", flags);
const words = ["a", "b", "c", "d"];
const alias = words;
words.pop();
alias.length = alias.length - 1;
words.push("z");
show("words", words);
show("alias", alias);
const items = [new Item(1), new Item(2), new Item(3)];
const kept = items[2]!;
items.pop();
items.length = 1;
items.push(new Item(4));
show("items", items);
console.log("kept", kept.id);
// Holes and present undefined at the end.
const holes: (number | undefined)[] = [1, 2];
holes[4] = 5;
holes.length = 4;
show("holes trimmed", holes);
holes.length = 3;
show("holes trimmed again", holes);
const undef: (string | undefined)[] = ["x", undefined, undefined];
undef.pop();
undef.length = undef.length - 1;
show("undefined", undef);
// A sparse array: a far index then shrinking past it and back by one.
const sparse: number[] = [1, 2, 3];
sparse[2000000] = 4;
sparse.length = 2000000;
show("sparse cut", sparse.slice(0, 4));
console.log("sparse length", sparse.length, 1999999 in sparse);
sparse.length = sparse.length - 1;
console.log("sparse length", sparse.length);
// A stack used the way a checker pushes and pops contextual entries.
const stack: Item[] = [];
const top: number[] = [];
let checksum = 0;
for (let i = 0; i < 20000; i++) {
stack.push(new Item(i));
top.push(i);
if (i % 3 === 2) {
stack.pop();
top.length = top.length - 1;
}
if (stack.length > 50) {
stack.length = stack.length - 1;
top.pop();
}
checksum = (checksum + stack[stack.length - 1]!.id + top[top.length - 1]!) % 1000003;
}
console.log("stack", stack.length, top.length, checksum);
// Lengths that are not one less: unchanged, longer, invalid.
const other = [1, 2, 3];
other.length = 3;
other.length = 5;
show("longer", other);
other.length = 1;
show("shorter by many", other);
try {
other.length = -1;
} catch (e) {
console.log("invalid", (e as Error).name, other.length);
}
try {
other.length = 0.5;
} catch (e) {
console.log("fraction", (e as Error).name, other.length);
}
const empty: number[] = [];
empty.pop();
empty.length = 0;
show("empty", empty);
// pop() results: the last element, undefined for an empty array, a hole or a
// present undefined, and values taken from sparse storage.
const nums = [0.5, -0, 7];
console.log("pop number", nums.pop(), Object.is(nums.pop(), -0), nums.pop(), nums.pop(), nums.length);
const bools = [false, true];
console.log("pop bool", bools.pop(), bools.pop(), bools.pop(), bools.length);
const strs = ["p", "q"];
const strAlias = strs;
console.log("pop string", strs.pop(), strAlias.pop(), strs.pop(), strAlias.length);
const objs = [new Item(7), new Item(8)];
const popped = objs.pop();
console.log("pop item", popped?.id, objs.pop()?.id, objs.pop(), objs.length);
const gaps: number[] = [1];
gaps[3] = 4;
console.log("pop gaps", gaps.pop(), gaps.pop(), gaps.pop(), gaps.pop(), gaps.pop(), gaps.length);
const maybe: (number | undefined)[] = [1, undefined];
console.log("pop present undefined", maybe.pop(), maybe.length, maybe.pop(), maybe.length);
const far: string[] = ["a"];
far[100000] = "b";
console.log("pop sparse", far.pop(), far.length, far.pop(), far.length);
let popTotal = 0;
const work: number[] = [];
for (let i = 0; i < 10000; i++) {
work.push(i, i + 1);
popTotal += work.pop()!;
if (i % 5 === 0) popTotal += work.pop() ?? -1;
}
console.log("pop loop", work.length, popTotal);
+2
View File
@@ -1841,6 +1841,7 @@
"5648-dns-lookup-forms.ts": 2992,
"5649-dns-service-servers.ts": 2712,
"5650-dns-lookup-errors-untyped.cjs": 2822,
"5681-array-pop-and-length.ts": 3754,
"5800-broadcast-channel.ts": 2899,
"5801-broadcast-channel.cjs": 2727,
"5802-broadcast-channel-workers.ts": 2809,
@@ -4538,6 +4539,7 @@
"5648-dns-lookup-forms.ts": 3416,
"5649-dns-service-servers.ts": 4916,
"5650-dns-lookup-errors-untyped.cjs": 3889,
"5681-array-pop-and-length.ts": 3216,
"5800-broadcast-channel.ts": 28374,
"5801-broadcast-channel.cjs": 27251,
"5802-broadcast-channel-workers.ts": 27251,