Represent unions of records as the record pointer itself

A union of two or more record arms and at most two unit arms (the
discriminated unions of object literals behind Result, token, shape and
AST types) now holds the record pointer instead of a reference-counted
%ScrUnion box, like nullable unions already do. NULL and the existing
null sentinel encode the unit arms.

Every record shape that is an arm of such a union carries its
module-wide shape number in a hidden i32 after the reference count; the
arm of a value is that word mapped through a per-union tag helper, which
LLVM folds into the consumer's switch. Records share one retain; release
and trace dispatch on the arm, and every arm of a union with a traced arm
carries a collector header. Wraps move the record, retypes between such
unions reuse the pointer, and equality is identity. Record field indices
come from the shape's field base.

Unions over records that are also arms of runtime-inspected unions (Map
keys, Set elements), library builds, lifetime-region programs and debug
builds keep boxes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-17 13:57:35 -07:00
co-authored by Claude Opus 5.5
parent 8620fa533a
commit 250097dd32
29 changed files with 886 additions and 82 deletions
+12 -8
View File
@@ -1179,12 +1179,13 @@ export class LlDyn {
`llvm emitter bug: JSON schema of unknown shape ${t.shapeId}`,
);
const struct = mangleRecordStruct(t.shapeId);
const index = new Map(shape.fields.map((f, i) => [f.name, i + 1]));
const base = host.nullableUnions.fieldBase(t.shapeId);
const index = new Map(shape.fields.map((f, i) => [f.name, i + base]));
// The runtime names kept-source keys by this index (record-sources.ts).
const order = recordSourceFieldOrder(shape);
const fieldTy = `{ ptr, ${S}, ${S}, ptr }`;
const entries = order.map((fieldName) => {
const field = shape.fields[index.get(fieldName)! - 1]!;
const field = shape.fields[index.get(fieldName)! - base]!;
const sub = this.jsonSchema(field.type);
const offset = `ptrtoint (ptr getelementptr (%${struct}, ptr null, i64 0, i32 ${index.get(fieldName)}) to ${S})`;
return `${fieldTy} { ptr ${host.cstr(fieldName)}, ${S} ${Buffer.byteLength(fieldName, "utf8")}, ${S} ${offset}, ptr @${sub} }`;
@@ -1421,18 +1422,19 @@ export class LlDyn {
B.line(`call void @scr_arr_outline(ptr ${cached})`);
B.line(`call void @scr_arr_outline(ptr ${checked})`);
}
const base = t.kind === "record" ? host.nullableUnions.fieldBase(t.shapeId) : 1;
const members =
t.kind === "record"
? [
...shape!.fields.map((field, index) => ({
index: index + 1,
index: index + base,
type: llFieldType(field.type, host.inlineUnions.record(field.type)),
name: field.name,
})),
...(shape!.indexValue
? [
{
index: shape!.fields.length + 1,
index: shape!.fields.length + base,
type: "ptr" as const,
name: "[key: string] overflow",
},
@@ -1693,7 +1695,8 @@ export class LlDyn {
`llvm emitter bug: dynCheck of unknown shape ${t.shapeId}`,
);
const struct = mangleRecordStruct(t.shapeId);
const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + 1]));
const base = host.nullableUnions.fieldBase(t.shapeId);
const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + base]));
const releaseR = (): void => {
B.line(`call void ${releaseSym(host, t)}(ptr %r0)`);
};
@@ -1870,7 +1873,7 @@ export class LlDyn {
const ovfp = B.tmp();
const ovf = B.tmp();
B.line(
`${ovfp} = getelementptr inbounds %${struct}, ptr %r0, i64 0, i32 ${shape.fields.length + 1}`,
`${ovfp} = getelementptr inbounds %${struct}, ptr %r0, i64 0, i32 ${shape.fields.length + base}`,
);
B.line(`${ovf} = load ptr, ptr ${ovfp} ; overflow map`);
if (shape.fields.length === 0) {
@@ -2443,7 +2446,8 @@ export class LlDyn {
if (!shape)
throw new InternalCompilerError(`llvm emitter bug: to-dyn of unknown shape ${t.shapeId}`);
const struct = mangleRecordStruct(t.shapeId);
const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + 1]));
const base = host.nullableUnions.fieldBase(t.shapeId);
const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + base]));
const loadFieldOf = (fname: string, ft: IrType): string => {
const p = B.tmp();
B.line(
@@ -2567,7 +2571,7 @@ export class LlDyn {
const ovfp = B.tmp();
const ovf = B.tmp();
B.line(
`${ovfp} = getelementptr inbounds %${struct}, ptr %v, i64 0, i32 ${shape.fields.length + 1}`,
`${ovfp} = getelementptr inbounds %${struct}, ptr %v, i64 0, i32 ${shape.fields.length + base}`,
);
B.line(`${ovf} = load ptr, ptr ${ovfp} ; overflow map`);
const ks = B.tmp();
+37 -10
View File
@@ -373,6 +373,7 @@ import {
elemAccess,
emitInlineRcHelpers,
emitNullableRetainWrappers,
emitShapeUnionHelpers,
FN_ATTRS,
inlineRcDecls,
type InlineRcMode,
@@ -1071,7 +1072,7 @@ export class LlEmitter {
expr: (expr) => !(expr.kind === "libCall" && expr.fn === "error.stack"),
}),
);
this.nullableUnions = new NullableUnions(mod);
this.nullableUnions = new NullableUnions(mod, options.debugSources === undefined);
this.debug =
options.debugSources === undefined
? null
@@ -1165,8 +1166,10 @@ export class LlEmitter {
// Optional array reads also cover nullable unions: emitLocalArrayRead
// then yields the (borrowed or owned) element pointer, not a stack box.
// Arrays of such a union are read the same way.
this.optionalArrayReads = new OptionalArrayReads(this.fnByName, this.unionsById, (id) =>
this.nullableUnions.has(id),
this.optionalArrayReads = new OptionalArrayReads(
this.fnByName,
this.unionsById,
(id) => this.nullableUnions.single(id) !== null,
);
this.unionSources = {
unions: this.boxedUnionsById,
@@ -1219,7 +1222,8 @@ export class LlEmitter {
(type) =>
this.plainReference(type) &&
type.kind !== "string" &&
(type.kind !== "union" || this.nullableUnions.get(type.unionId)?.arm.kind !== "string"),
(type.kind !== "union" ||
this.nullableUnions.single(type.unionId)?.arm.kind !== "string"),
this.walkBorrowsByFunction.get(fn.name) ?? new Set(),
);
if (rebound.size === 0) continue;
@@ -1270,6 +1274,15 @@ export class LlEmitter {
const traced = computeTraced(mod);
this.tracedShapes = traced.shapes;
this.tracedUnions = traced.unions;
// A shape union's value is whichever record it holds, and the collector
// visits it as that record: when one arm carries a collector header,
// every arm must (nullable-unions.ts).
for (const union of this.nullableUnions.allShapeUnions())
if (
this.tracedUnions.has(union.unionId) ||
union.arms.some((arm) => this.tracedShapes.has(`record:${arm.shapeId}`))
)
for (const arm of union.arms) this.tracedShapes.add(`record:${arm.shapeId}`);
this.shapeHost = {
sizeType: this.sizeType,
cycleBufOffset: this.cycleBufOffset,
@@ -3551,6 +3564,7 @@ export class LlEmitter {
return [
...late,
...emitNullableRetainWrappers(this.shapeHost),
...emitShapeUnionHelpers(this.shapeHost),
...emitInlineRcHelpers(this.shapeHost),
];
}
@@ -5527,7 +5541,11 @@ export class LlEmitter {
const nullable = this.nullableUnions.get(unionId);
if (nullable) {
// The owned payload IS the nullable union's value.
if (tag !== nullable.refTag)
if (
nullable.repr === "single"
? tag !== nullable.refTag
: !nullable.arms.some((arm) => arm.tag === tag)
)
throw new InternalCompilerError(
`llvm emitter bug: payload wrap into unit arm ${tag} of ${unionId}`,
);
@@ -5632,7 +5650,7 @@ export class LlEmitter {
const index = shape.fields.findIndex((entry) => entry.name === field);
if (index < 0)
throw new InternalCompilerError(`llvm emitter bug: missing union field ${field}`);
layout = "record:";
layout = this.nullableUnions.fieldBase(arm.shapeId) === 2 ? "record:tagged;" : "record:";
for (let i = 0; i <= index; i++) {
const type = shape.fields[i]!.type;
layout += `${llFieldType(type, this.inlineUnions.record(type))};`;
@@ -5668,7 +5686,7 @@ export class LlEmitter {
);
const p = this.B.tmp();
this.B.line(
`${p} = getelementptr inbounds %${mangleRecordStruct(shapeId)}, ptr ${objName}, i64 0, i32 ${idx + 1}`,
`${p} = getelementptr inbounds %${mangleRecordStruct(shapeId)}, ptr ${objName}, i64 0, i32 ${idx + this.nullableUnions.fieldBase(shapeId)}`,
);
const type = shape.fields[idx]!.type;
return { ptr: p, type, inline: this.inlineUnions.record(type) };
@@ -5682,7 +5700,7 @@ export class LlEmitter {
const p = this.B.tmp();
const v = this.B.tmp();
this.B.line(
`${p} = getelementptr inbounds %${mangleRecordStruct(shapeId)}, ptr ${objName}, i64 0, i32 ${shape.fields.length + 1}`,
`${p} = getelementptr inbounds %${mangleRecordStruct(shapeId)}, ptr ${objName}, i64 0, i32 ${shape.fields.length + this.nullableUnions.fieldBase(shapeId)}`,
);
this.B.line(`${v} = load ptr, ptr ${p}`);
return v;
@@ -9564,7 +9582,12 @@ export class LlEmitter {
unionsById: this.unionsById,
recordsById: this.recordsById,
inlineUnions: this.inlineUnions,
nullableArm: (t) => this.nullableUnions.of(t)?.arm ?? null,
nullableArm: (t) => {
const nullable = this.nullableUnions.of(t);
return nullable?.repr === "single" ? nullable.arm : null;
},
shapeUnion: (t) => this.nullableUnions.of(t)?.repr === "shapes",
fieldBase: (shapeId) => this.nullableUnions.fieldBase(shapeId),
classInfo: (className) => {
const meta = this.classMetaOf(className);
return {
@@ -10280,7 +10303,11 @@ export class LlEmitter {
const nullable = this.nullableUnions.get(e.unionId);
if (!nullable) return null;
if (isUnitType(e.value.type)) return e.value.kind === "unitLit" ? "unit" : null;
return e.tag === nullable.refTag && isRefCounted(e.value.type) ? "ref" : null;
const refArm =
nullable.repr === "single"
? e.tag === nullable.refTag
: nullable.arms.some((arm) => arm.tag === e.tag);
return refArm && isRefCounted(e.value.type) ? "ref" : null;
}
materializeSplitLocal(localId: string): void {
@@ -182,6 +182,7 @@ export function genResultThunkFor(
sym = mangleGenResThunk(host.resolveThunks.size);
host.resolveThunks.set(key, sym);
const shape = host.recordsById.get(recT.shapeId);
const base = host.nullableUnions.fieldBase(recT.shapeId);
const valueT = shape?.fields.find((f) => f.name === "value")?.type;
if (!shape || !valueT)
throw new InternalCompilerError("llvm emitter bug: genResume record lacks its value field");
@@ -242,7 +243,7 @@ export function genResultThunkFor(
` %r = call ptr @${mangleRecordNew(recT.shapeId)}()`,
` %d = call zeroext i1 @scr_gen_done(ptr %g)`,
` %dz = zext i1 %d to i8`,
` %dp = getelementptr inbounds %${mangleRecordStruct(recT.shapeId)}, ptr %r, i64 0, i32 ${doneIdxD + 1}`,
` %dp = getelementptr inbounds %${mangleRecordStruct(recT.shapeId)}, ptr %r, i64 0, i32 ${doneIdxD + base}`,
` store i8 %dz, ptr %dp`,
` %has = call zeroext i1 @scr_gen_out_has(ptr %g)`,
` br i1 %has, label %take, label %undefv`,
@@ -259,7 +260,7 @@ export function genResultThunkFor(
` br label %join`,
`join:`,
` %v = load ptr, ptr %vslot`,
` %vp = getelementptr inbounds %${mangleRecordStruct(recT.shapeId)}, ptr %r, i64 0, i32 ${valueIdxD + 1}`,
` %vp = getelementptr inbounds %${mangleRecordStruct(recT.shapeId)}, ptr %r, i64 0, i32 ${valueIdxD + base}`,
` store ptr %v, ptr %vp`,
` ret ptr %r`,
`}`,
@@ -293,7 +294,7 @@ export function genResultThunkFor(
` %r = call ptr @${mangleRecordNew(recT.shapeId)}()`,
` %d = call zeroext i1 @scr_gen_done(ptr %g)`,
` %dz = zext i1 %d to i8`,
` %dp = getelementptr inbounds %${mangleRecordStruct(recT.shapeId)}, ptr %r, i64 0, i32 ${doneIdx + 1}`,
` %dp = getelementptr inbounds %${mangleRecordStruct(recT.shapeId)}, ptr %r, i64 0, i32 ${doneIdx + base}`,
` store i8 %dz, ptr %dp`,
` br i1 %d, label %doneb, label %susp`,
];
@@ -410,7 +411,7 @@ export function genResultThunkFor(
` br label %join`,
`join:`,
` %v = load ptr, ptr %vslot`,
` %vp = getelementptr inbounds %${mangleRecordStruct(recT.shapeId)}, ptr %r, i64 0, i32 ${valueIdx + 1}`,
` %vp = getelementptr inbounds %${mangleRecordStruct(recT.shapeId)}, ptr %r, i64 0, i32 ${valueIdx + base}`,
// An inline value field copies the box's bits and drops the box.
...(host.inlineUnions.record(valueT)
? [
@@ -1092,7 +1093,8 @@ export function settledStoreFor(
return { tag, arm };
};
const fieldIndex = (arm: IrType & { kind: "record" }, field: string): number =>
host.recordsById.get(arm.shapeId)!.fields.findIndex((f) => f.name === field) + 1;
host.recordsById.get(arm.shapeId)!.fields.findIndex((f) => f.name === field) +
host.nullableUnions.fieldBase(arm.shapeId);
const fulfilled = armOf("value");
const rejected = armOf("reason");
const valueT = host.recordsById
@@ -330,7 +330,7 @@ export function emitControlExpr(
);
B.line(`store ${ty} ${t}, ptr ${slot}`);
if (host.mayThrow.has(e.retag)) host.emitPendingCheck();
} else if (host.nullableUnions.has(e.left.type.unionId)) {
} else if (host.nullableUnions.single(e.left.type.unionId)) {
// The owned nullable pointer IS the owned arm value.
B.line(`store ${ty} ${l.name}, ptr ${slot}`);
} else {
@@ -440,7 +440,7 @@ export function emitControlExpr(
host.emitBranchInto(slot, e.right);
B.br(lj);
B.startBlock(lv);
if (typeEquals(e.type, e.left.type) || host.nullableUnions.has(def.id)) {
if (typeEquals(e.type, e.left.type) || host.nullableUnions.single(def.id)) {
// Pass-through, or a nullable pointer that IS its owned arm value.
B.line(`store ${ty} ${l.name}, ptr ${slot}`);
} else {
@@ -62,9 +62,17 @@ export function emitUnionRetype(
const from = host.nullableUnions.get(e.fromId);
const to = host.nullableUnions.get(e.unionId);
if (from && to) {
// Equal encodings make the pointer itself the target value.
if (from.refTag === to.refTag && from.unitTag === to.unitTag && from.nullTag === to.nullTag)
return host.own({ name: value.name, type: e.type });
// Equal encodings make the pointer itself the target value. A shape
// union derives its record arms from the record, so only the unit
// encodings must agree.
const same =
from.repr === "single" && to.repr === "single"
? from.refTag === to.refTag && from.unitTag === to.unitTag && from.nullTag === to.nullTag
: from.repr === "shapes" &&
to.repr === "shapes" &&
from.unitTag === to.unitTag &&
from.nullTag === to.nullTag;
if (same) return host.own({ name: value.name, type: e.type });
return host.own({ name: rebuildRetype(host, value.name, e), type: e.type });
}
if (from || to) return host.own({ name: rebuildRetype(host, value.name, e), type: e.type });
@@ -500,16 +500,17 @@ export function streamTypedRefCommitAdapter(
` br i1 %missing, label %done, label %swap`,
`swap:`,
];
const base = host.nullableUnions.fieldBase(t.shapeId);
const members = [
...shape.fields.map((field, index) => ({
index: index + 1,
index: index + base,
type: llFieldType(field.type, host.inlineUnions.record(field.type)),
name: field.name,
})),
...(shape.indexValue
? [
{
index: shape.fields.length + 1,
index: shape.fields.length + base,
type: "ptr" as const,
name: "[key: string] overflow",
},
@@ -943,7 +944,7 @@ export function streamTypedRefMaterializeAdapter(
B.line(`${out} = call ptr @scr_dyn_new_arr()`);
const fields = [...shape.fields].sort((a, b) => Number(a.name) - Number(b.name));
for (const field of fields) {
const index = shape.fields.indexOf(field) + 1;
const index = shape.fields.indexOf(field) + host.nullableUnions.fieldBase(t.shapeId);
const fieldPtr = B.tmp();
B.line(
`${fieldPtr} = getelementptr inbounds %${mangleRecordStruct(t.shapeId)}, ptr %p, i64 0, i32 ${index}`,
@@ -980,7 +981,7 @@ export function streamTypedRefMaterializeAdapter(
liveRecordOps(host, t, snapshot, fields);
}
for (const field of fields) {
const index = shape.fields.indexOf(field) + 1;
const index = shape.fields.indexOf(field) + host.nullableUnions.fieldBase(t.shapeId);
const fieldPtr = B.tmp();
B.line(
`${fieldPtr} = getelementptr inbounds %${mangleRecordStruct(t.shapeId)}, ptr %p, i64 0, i32 ${index}`,
@@ -1202,7 +1203,7 @@ function liveRecordOps(
const names = `${snapshot}_names`;
const ops = `${snapshot}_rops`;
const loadField = (B: BlockBuilder, field: IrRecordShape["fields"][number]): string => {
const index = shape.fields.indexOf(field) + 1;
const index = shape.fields.indexOf(field) + host.nullableUnions.fieldBase(t.shapeId);
const fieldPtr = B.tmp();
B.line(`${fieldPtr} = getelementptr inbounds %${struct}, ptr %p, i64 0, i32 ${index}`);
return loadStructField(
@@ -29,7 +29,7 @@ function messageAdapter(host: LlvmEmitterContext, type: IrType): string {
const isString = field === "address" || field === "family";
if (isString) lines.push(` %${field}.owned = call ptr @scr_str_retain_v(ptr %${field})`);
lines.push(
` %${field}.slot = getelementptr inbounds %${mangleRecordStruct(type.shapeId)}, ptr %record, i64 0, i32 ${index + 1}`,
` %${field}.slot = getelementptr inbounds %${mangleRecordStruct(type.shapeId)}, ptr %record, i64 0, i32 ${index + host.nullableUnions.fieldBase(type.shapeId)}`,
` store ${isString ? "ptr" : "double"} %${field}${isString ? ".owned" : ""}, ptr %${field}.slot`,
);
}
@@ -167,7 +167,7 @@ export function emitDatagramLibCall(host: LlvmEmitterContext, e: LibCallExpr): L
);
const slot = host.B.tmp();
host.B.line(
`${slot} = getelementptr inbounds %${mangleRecordStruct(type.shapeId)}, ptr ${record}, i64 0, i32 ${index + 1}`,
`${slot} = getelementptr inbounds %${mangleRecordStruct(type.shapeId)}, ptr ${record}, i64 0, i32 ${index + host.nullableUnions.fieldBase(type.shapeId)}`,
);
host.B.line(`store ${ty} ${value}, ptr ${slot}`);
}
@@ -910,11 +910,8 @@ export function emitPathUrlLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
scopeid.inline,
);
}
const rc = vAdapters(host.shapeHost, t);
const rowU = B.tmp();
B.line(
`${rowU} = call ptr @scr_union_new_ref(i32 ${tag}, ptr ${r}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host.shapeHost, t)})`,
);
// The row (+1) moves into its union arm (a shape union is the row).
const rowU = host.unionNewOwned(infoT.unionId, tag, { name: r, type: t });
B.line(`store ptr ${rowU}, ptr ${rowSlot}`);
B.br(lRow);
};
@@ -65,7 +65,7 @@ export function emitLookupConnect(host: LlvmEmitterContext, e: LibCallExpr): LlV
" br i1 %more, label %body, label %finish",
"body:",
" %record = call ptr @scr_arr_get_ref(ptr %addresses, double %index)",
` %addressp = getelementptr inbounds %${struct}, ptr %record, i64 0, i32 ${field + 1}`,
` %addressp = getelementptr inbounds %${struct}, ptr %record, i64 0, i32 ${field + host.nullableUnions.fieldBase(addresses.elem.shapeId)}`,
" %address = load ptr, ptr %addressp",
" %owned.address = call ptr @scr_str_retain_v(ptr %address)",
" call double @scr_arr_push_ref(ptr %ips, ptr %owned.address)",
@@ -168,7 +168,7 @@ export function emitNetworkHttpLibCall(host: LlvmEmitterContext, e: LibCallExpr)
const i = shape.fields.findIndex((f) => f.name === name);
if (i < 0)
throw new InternalCompilerError(`llvm emitter bug: net.serverAddress record lacks ${name}`);
return i + 1;
return i + host.nullableUnions.fieldBase(recT.shapeId);
};
const store = (name: string, ty: string, v: string): void => {
const p = B.tmp();
@@ -0,0 +1,83 @@
import { expect, test } from "vitest";
import {
F64,
NULL_T,
UNDEFINED_T,
type IrModule,
type IrType,
type IrUnionDef,
} from "../../ir/ir.js";
import { NullableUnions } from "./nullable-unions.js";
const loc = { file: "nullable-unions.ts", start: 0, end: 0 };
const a: IrType = { kind: "record", shapeId: "a" };
const b: IrType = { kind: "record", shapeId: "b" };
const c: IrType = { kind: "record", shapeId: "c" };
function module(unions: IrUnionDef[], extra: Partial<IrModule> = {}): IrModule {
return {
irVersion: 16,
sourceFile: loc.file,
entry: "main",
records: ["a", "b", "c"].map((id) => ({ id, fields: [{ name: id, type: F64 }] })),
unions,
functions: [],
...extra,
};
}
test("unions of two or more records become shape unions", () => {
const unions = new NullableUnions(
module([
{ id: "ab", arms: [a, b] },
{ id: "abu", arms: [a, UNDEFINED_T, b] },
{ id: "abnu", arms: [a, b, NULL_T, UNDEFINED_T] },
{ id: "af", arms: [a, F64] },
{ id: "au", arms: [a, UNDEFINED_T] },
]),
);
expect(unions.shapes("ab")).toMatchObject({ unitTag: -1, nullTag: -1 });
expect(unions.shapes("abu")).toMatchObject({ unitTag: 1, nullTag: -1 });
expect(unions.shapes("abnu")).toMatchObject({ unitTag: 3, nullTag: 2 });
expect(unions.shapes("abnu")?.arms).toEqual([
{ tag: 0, shapeId: "a" },
{ tag: 1, shapeId: "b" },
]);
// A scalar arm keeps the box; one record arm is a single-arm nullable.
expect(unions.get("af")).toBeNull();
expect(unions.single("au")).not.toBeNull();
expect(unions.shapes("au")).toBeNull();
// Tagged shapes number from 2 and move their fields after the word.
expect([unions.shapeNumber("a"), unions.shapeNumber("b"), unions.shapeNumber("c")]).toEqual([
2, 3, 0,
]);
expect([unions.fieldBase("a"), unions.fieldBase("c")]).toEqual([2, 1]);
expect(unions.boxedUnions(new Map([["af", { id: "af", arms: [a, F64] }]])).size).toBe(1);
});
test("records of runtime-inspected unions keep every union over them boxed", () => {
const set: IrType = { kind: "set", elem: { kind: "union", unionId: "ab" } };
const unions = new NullableUnions(
module(
[
{ id: "ab", arms: [a, b] },
{ id: "abu", arms: [a, b, UNDEFINED_T] },
{ id: "bc", arms: [b, c] },
{ id: "ac", arms: [a, c] },
],
{ globals: [{ id: "%g.s", name: "s", type: set, mutable: false }] },
),
);
expect(unions.get("ab")).toBeNull();
expect(unions.get("abu")).toBeNull();
expect(unions.get("bc")).toBeNull();
expect(unions.get("ac")).toBeNull();
expect(unions.shapeNumber("c")).toBe(0);
});
test("library, lifetime-region and debug builds keep record unions boxed", () => {
const unions = [{ id: "ab", arms: [a, b] }];
expect(new NullableUnions(module(unions), false).get("ab")).toBeNull();
expect(new NullableUnions(module(unions, { lifetimeRegions: true })).get("ab")).toBeNull();
expect(new NullableUnions(module(unions)).shapes("ab")).not.toBeNull();
});
@@ -17,6 +17,7 @@ import { everyModuleNode } from "../../ir/traverse.js";
* every container, field, capture, parameter, and return slot holds the
* plain pointer. Every other union keeps the tagged `%ScrUnion` box. */
export interface NullableUnion {
repr: "single";
unionId: string;
refTag: number;
/** The unit arm NULL encodes. */
@@ -24,7 +25,57 @@ export interface NullableUnion {
/** The `null` arm of a two-unit-arm union, encoded by NULLABLE_NULL;
* -1 when the union has one unit arm. */
nullTag: number;
arm: NullableRefArm;
/** One of NullableRefArm. Typed as the plain IrType so the self-hosted
* frontend keeps IrType's structurally identical arms one record shape
* (a PointerUnion arm holding the narrowed intersection would split them). */
arm: IrType;
}
/** A union of two or more record arms and at most two unit arms (`A | B`,
* `A | B | undefined`, discriminated unions of object literals) whose
* VALUES are the record pointer itself, like a NullableUnion: NULL is the
* unit arm (`undefined` when both exist, NULLABLE_NULL is then `null`).
* Every record shape that is an arm of such a union is a TAGGED shape: its
* struct carries the shape's module-wide number in a hidden `i32` word
* right after the reference count (struct index 1), stored by every
* allocation, so the arm of a pointer is that word mapped through the
* union's arm table (the per-union `@sc_stag.<id>` helper). Records share
* one retain; release and trace dispatch on the arm. */
export interface ShapeUnion {
repr: "shapes";
unionId: string;
/** The unit arm NULL encodes; -1 when the union has no unit arm. */
unitTag: number;
/** The `null` arm of a two-unit-arm union, encoded by NULLABLE_NULL;
* -1 otherwise. */
nullTag: number;
/** The record arms in tag order. */
arms: readonly { tag: number; shapeId: string }[];
}
/** A union whose values are plain pointers (no `%ScrUnion` box). */
export type PointerUnion = NullableUnion | ShapeUnion;
/** The struct index of a tagged record shape's hidden shape-number word. */
export const SHAPE_WORD_INDEX = 1;
/** The LLVM type through which any tagged record's shape word is read
* (the reference count, then the word). */
export function shapeWordHeader(sizeType: "i32" | "i64"): string {
return `{ ${sizeType}, i32 }`;
}
/** The per-union helper mapping a shape-union value to its tag. */
export function shapeTagFn(unionId: string): string {
return `@sc_stag.${unionId.replace(/[^A-Za-z0-9_.]/g, "_")}`;
}
/** The per-union release and trace dispatchers of a shape union. */
export function shapeReleaseFn(unionId: string): string {
return `@sc_srelease.${unionId.replace(/[^A-Za-z0-9_.]/g, "_")}`;
}
export function shapeTraceFn(unionId: string): string {
return `@sc_strace.${unionId.replace(/[^A-Za-z0-9_.]/g, "_")}`;
}
/** The module-scope immortal sentinel standing for `null` in a
@@ -48,44 +99,98 @@ export type NullableRefArm =
| (IrType & { kind: "array" | "map" | "set" });
/** The arm is a JS object: always truthy, compared by identity. */
export function isObjectArm(nullable: NullableUnion): boolean {
return nullable.arm.kind !== "string";
export function isObjectArm(nullable: PointerUnion): boolean {
return nullable.repr === "shapes" || nullable.arm.kind !== "string";
}
/** The representation decision for every union of one module. It is a
* pure function of the module, so serialized-IR and in-memory emission
* agree. */
export class NullableUnions {
private readonly byId = new Map<string, NullableUnion>();
private readonly byId = new Map<string, PointerUnion>();
/** Tagged record shapes (arms of a shape union) by their module-wide
* shape number (2 and up: 0 and 1 are the unit encodings' pseudo
* numbers, never stored). */
private readonly shapeNumbers = new Map<string, number>();
/** NULL-skipping retain wrappers requested so far: symbol → inner retain
* call target. The emitter defines each once. */
readonly retainWrappers = new Map<string, string>();
/** Some nullable union encodes `null` with NULLABLE_NULL. */
readonly usesNullSentinel: boolean = false;
constructor(mod: IrModule) {
/** `shapes`: allow shape unions (debug builds keep boxes, whose
* debug-info layouts describe every arm). */
constructor(mod: IrModule, shapes = true) {
const emitted = new Set<string>();
for (const cls of mod.classes ?? []) if (!cls.runtime) emitted.add(cls.name);
const records = new Set<string>();
for (const shape of mod.records ?? []) records.add(shape.id);
const boxed = boxedUnionIds(mod);
// Shape unions stay out of library builds (their host ABI marshals
// boxes) and of programs with lifetime regions (whose walks report a
// reference slot by its type's release entry point).
const allowShapes = shapes && mod.lib === undefined && mod.lifetimeRegions !== true;
// A record that is an arm of a boxed union never becomes a shape
// union's arm: conversions between unions over the same records then
// keep one representation.
const boxedRecords = new Set<string>();
for (const def of mod.unions ?? [])
if (boxed.has(def.id))
for (const arm of def.arms) if (arm.kind === "record") boxedRecords.add(arm.shapeId);
for (const def of mod.unions ?? []) {
if (boxed.has(def.id)) continue;
const nullable = nullableShape(def, emitted, records);
const nullable =
nullableShape(def, emitted, records) ??
(allowShapes ? shapeUnion(def, records, boxedRecords) : null);
if (!nullable) continue;
this.byId.set(def.id, nullable);
if (nullable.nullTag >= 0) this.usesNullSentinel = true;
if (nullable.repr === "shapes")
for (const arm of nullable.arms)
if (!this.shapeNumbers.has(arm.shapeId))
this.shapeNumbers.set(arm.shapeId, this.shapeNumbers.size + 2);
}
}
get(unionId: string): NullableUnion | null {
get(unionId: string): PointerUnion | null {
return this.byId.get(unionId) ?? null;
}
of(type: IrType): NullableUnion | null {
of(type: IrType): PointerUnion | null {
return type.kind === "union" ? this.get(type.unionId) : null;
}
/** The single-reference-arm form only (shape unions answer null). */
single(unionId: string): NullableUnion | null {
const nullable = this.byId.get(unionId);
return nullable?.repr === "single" ? nullable : null;
}
/** The shape-union form only. */
shapes(unionId: string): ShapeUnion | null {
const nullable = this.byId.get(unionId);
return nullable?.repr === "shapes" ? nullable : null;
}
/** Every shape union, in module order. */
allShapeUnions(): ShapeUnion[] {
const out: ShapeUnion[] = [];
for (const nullable of this.byId.values()) if (nullable.repr === "shapes") out.push(nullable);
return out;
}
/** The module-wide number a tagged record shape stores in its shape
* word, or 0 for shapes that carry none. */
shapeNumber(shapeId: string): number {
return this.shapeNumbers.get(shapeId) ?? 0;
}
/** The struct index of a record shape's first declared field: 2 for
* tagged shapes (after the shape word), 1 otherwise. */
fieldBase(shapeId: string): number {
return this.shapeNumbers.has(shapeId) ? 2 : 1;
}
has(unionId: string): boolean {
return this.byId.has(unionId);
}
@@ -94,10 +199,11 @@ export class NullableUnions {
return this.byId.size;
}
/** The reference arm of every nullable union, by union id. */
/** The reference arm of every single-arm nullable union, by union id. */
armTypes(): Map<string, IrType> {
const result = new Map<string, IrType>();
for (const [id, nullable] of this.byId) result.set(id, nullable.arm);
for (const [id, nullable] of this.byId)
if (nullable.repr === "single") result.set(id, nullable.arm);
return result;
}
@@ -129,7 +235,7 @@ function nullableShape(
if (unitTag < 0 || nullTag < 0) return null;
}
const arm = def.arms[refTag]!;
const base = { unionId: def.id, refTag, unitTag, nullTag };
const base = { repr: "single" as const, unionId: def.id, refTag, unitTag, nullTag };
if (arm.kind === "object" && emittedClasses.has(arm.className))
return { ...base, arm: { kind: "object", className: arm.className } };
if (arm.kind === "record" && records.has(arm.shapeId))
@@ -140,6 +246,34 @@ function nullableShape(
return null;
}
/** Two or more emitted record arms and at most two unit arms (both:
* undefined and null). */
function shapeUnion(
def: IrUnionDef,
records: ReadonlySet<string>,
boxedRecords: ReadonlySet<string>,
): ShapeUnion | null {
const units = def.arms.filter(isUnitType).length;
if (units > 2 || def.arms.length - units < 2) return null;
const arms: { tag: number; shapeId: string }[] = [];
for (let tag = 0; tag < def.arms.length; tag++) {
const arm = def.arms[tag]!;
if (isUnitType(arm)) continue;
if (arm.kind !== "record" || !records.has(arm.shapeId) || boxedRecords.has(arm.shapeId))
return null;
arms.push({ tag, shapeId: arm.shapeId });
}
let unitTag = -1,
nullTag = -1;
if (units === 1) unitTag = def.arms.findIndex(isUnitType);
else if (units === 2) {
unitTag = def.arms.findIndex((arm) => arm.kind === "undefinedT");
nullTag = def.arms.findIndex((arm) => arm.kind === "nullT");
if (unitTag < 0 || nullTag < 0) return null;
}
return { repr: "shapes", unionId: def.id, unitTag, nullTag, arms };
}
/** Unions the runtime itself inspects as tagged boxes keep that
* representation: Map keys and Set elements hash and compare union keys by
* tag and payload inside the C map. */
+21 -2
View File
@@ -25,6 +25,7 @@ import type { IrClassDef, IrRecordShape, IrType, IrUnionDef, SrcLoc } from "../.
import { mapOf, STRING, typeKey } from "../../ir/ir.js";
import { mangleClassStruct, mangleRecordStruct } from "../mangle.js";
import { FN_ATTRS } from "./shapes.js";
import { shapeTagFn } from "./nullable-unions.js";
import type { InlineUnionFields } from "./inline-unions.js";
import { LlvmUnsupportedError } from "./unsupported.js";
@@ -44,6 +45,10 @@ export interface PublishHost {
readonly inlineUnions: InlineUnionFields;
/** The nullable-pointer representation of a union, when it has one. */
nullableArm(t: IrType): IrType | null;
/** The union's values are tagged record pointers (nullable-unions.ts). */
shapeUnion(t: IrType): boolean;
/** The struct index of a record shape's first field. */
fieldBase(shapeId: string): number;
/** Class layout: hierarchy membership, preorder interval, field slots
* (with their storage types), runtime roots. */
classInfo(className: string): PublishClassInfo;
@@ -210,6 +215,19 @@ export class PublishWalkers {
if (def.arms.some((arm) => scalarRefusal(arm)))
throw new LlvmUnsupportedError(`publish of ${typeKey(t)}`, loc);
const live = arms.flatMap((a) => (a.fn === null ? [] : [{ tag: a.tag, fn: a.fn }]));
if (this.host.shapeUnion(t)) {
// The value is the record itself (or a unit encoding): no box to
// mark; the arm's walker marks and walks the record.
const lines = [head, `entry:`];
lines.push(
` %tag = call i32 ${shapeTagFn(t.unionId)}(ptr %o)`,
` switch i32 %tag, label %done [ ${live.map((a) => `i32 ${a.tag}, label %arm${a.tag}`).join(" ")} ]`,
);
for (const a of live)
lines.push(`arm${a.tag}:`, ` call void ${a.fn}(ptr %o, ptr %ctx)`, ` br label %done`);
lines.push(`done:`, ` ret void`, `}`);
return lines;
}
const lines = [
head,
`entry:`,
@@ -240,17 +258,18 @@ export class PublishWalkers {
const shape = this.host.recordsById.get(t.shapeId);
if (!shape)
throw new LlvmUnsupportedError(`publish of an unknown record ${t.shapeId}`, loc);
const base = this.host.fieldBase(shape.id);
const members = [
...shape.fields.flatMap((f, i) =>
this.host.inlineUnions.record(f.type)
? []
: [{ name: f.name, index: i + 1, type: f.type }],
: [{ name: f.name, index: i + base, type: f.type }],
),
...(shape.indexValue
? [
{
name: "[key: string]",
index: shape.fields.length + 1,
index: shape.fields.length + base,
type: mapOf(STRING, shape.indexValue),
},
]
@@ -101,8 +101,8 @@ export class LifetimeRegions {
/** Can a value of type `t` hold (or own something that holds) a
* reference to a ProgramLifetime instance the walk can explain? */
private reaches(t: IrType): boolean {
const arm = this.host.nullableUnions.of(t)?.arm;
if (arm !== undefined) return this.reaches(arm);
const nullable = this.host.nullableUnions.of(t);
if (nullable?.repr === "single") return this.reaches(nullable.arm);
switch (t.kind) {
case "object":
return this.isLifetime(t.className) || this.reach.get(`object:${t.className}`) === true;
@@ -287,7 +287,8 @@ export class LifetimeRegions {
* ProgramLifetime instance). */
private reportLines(struct: string, f: Field, i: number): string[] {
if (!isRefCounted(f.type) || !this.reaches(f.type)) return [];
const arm = this.host.nullableUnions.of(f.type)?.arm ?? f.type;
const nullable = this.host.nullableUnions.of(f.type);
const arm = nullable?.repr === "single" ? nullable.arm : f.type;
const load = [
` %f${i} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${f.index}`,
` %v${i} = load ptr, ptr %f${i}`,
@@ -411,7 +412,7 @@ export class LifetimeRegions {
...this.reportBody(
`define internal void ${walk}(ptr %o, ptr %w) ${FN_ATTRS} { ; region walk record ${shape.id}`,
mangleRecordStruct(shape.id),
rcMembers(shape, this.host.inlineUnions),
rcMembers(shape, this.host.inlineUnions, this.host.nullableUnions.fieldBase(shape.id)),
),
);
rows.push(`{ ptr, ptr } { ptr @${mangleRecordRelease(shape.id)}, ptr ${walk} }`);
+134 -9
View File
@@ -31,7 +31,16 @@ import {
import { cycTypeDescriptor, emitObjectAlloc, emitObjectFree } from "./alloc.js";
import type { BlockBuilder } from "./blocks.js";
import { SCR_ARR_FIELD, llvmCommentText } from "./common.js";
import type { NullableUnions } from "./nullable-unions.js";
import {
NULLABLE_NULL,
SHAPE_WORD_INDEX,
shapeReleaseFn,
shapeTagFn,
shapeTraceFn,
shapeWordHeader,
type NullableUnions,
type ShapeUnion,
} from "./nullable-unions.js";
import {
INLINE_UNION_FIELD,
loadInlineUnion,
@@ -129,6 +138,7 @@ export { computeTraced } from "../cycle-analysis.js";
* `_v`-shaped. */
export function vAdapters(host: ShapeHost, t: IrType): { retain: string; release: string } {
const nullable = host.nullableUnions.of(t);
if (nullable?.repr === "shapes") return shapeUnionRc(nullable);
if (nullable) {
const arm = vAdapters(host, nullable.arm);
return { retain: nullableRetainSym(host, nullable.arm), release: arm.release };
@@ -184,6 +194,7 @@ export function vAdapters(host: ShapeHost, t: IrType): { retain: string; release
* Families with a mirrored fast path call the inline helper instead. */
export function retainSym(host: ShapeHost, t: IrType): string {
const nullable = host.nullableUnions.of(t);
if (nullable?.repr === "shapes") return shapeUnionRc(nullable).retain;
if (nullable) return nullableRetainSym(host, nullable.arm);
const stem = runtimeRcStem(t);
if (host.rcHelpers !== null && stem !== null && inlineRcFamily(host, stem, "retain") !== null) {
@@ -195,6 +206,99 @@ export function retainSym(host: ShapeHost, t: IrType): string {
return vAdapters(host, t).retain;
}
/** A shape union's RC entry points: every record retain is the same
* NULL- and immortal-skipping increment (the null sentinel is immortal),
* so the first arm's serves; release dispatches on the arm. */
function shapeUnionRc(union: ShapeUnion): { retain: string; release: string } {
return {
retain: `@${mangleRecordRetain(union.arms[0]!.shapeId)}`,
release: shapeReleaseFn(union.unionId),
};
}
/** The tag, release and trace helpers of every shape union
* (nullable-unions.ts). The tag helper maps a value to its arm: NULL and
* the null sentinel are the unit arms, anything else a tagged record whose
* shape word names its arm (the switch folds to a lookup table). */
export function emitShapeUnionHelpers(host: ShapeHost): string[] {
const out: string[] = [];
const S = host.sizeType;
for (const union of host.nullableUnions.allShapeUnions()) {
const header = shapeWordHeader(S);
const tagFn = shapeTagFn(union.unionId);
const cases = union.arms
.map((arm) => `i32 ${host.nullableUnions.shapeNumber(arm.shapeId)}, label %a${arm.tag}`)
.join(" ");
out.push(
`define internal i32 ${tagFn}(ptr %u) alwaysinline ${FN_ATTRS} { ; shape union tag`,
`entry:`,
` %isnull = icmp eq ptr %u, null`,
` br i1 %isnull, label %unit, label %ref`,
`unit:`,
` ret i32 ${union.unitTag}`,
`ref:`,
);
if (union.nullTag >= 0)
out.push(
` %isnullarm = icmp eq ptr %u, ${NULLABLE_NULL}`,
` br i1 %isnullarm, label %nullarm, label %rec`,
`nullarm:`,
` ret i32 ${union.nullTag}`,
`rec:`,
);
out.push(
` %wp = getelementptr inbounds ${header}, ptr %u, i32 0, i32 ${SHAPE_WORD_INDEX}`,
` %w = load i32, ptr %wp`,
` switch i32 %w, label %bad [ ${cases} ]`,
...union.arms.flatMap((arm) => [`a${arm.tag}:`, ` ret i32 ${arm.tag}`]),
`bad:`,
` ret i32 -1`,
`}`,
``,
);
// release: unit encodings are NULL or immortal; each arm releases
// through its own (NULL- and immortal-tolerant) helper.
out.push(
`define internal void ${shapeReleaseFn(union.unionId)}(ptr %u) ${FN_ATTRS} { ; shape union release`,
`entry:`,
` %t = call i32 ${tagFn}(ptr %u)`,
` switch i32 %t, label %done [ ${union.arms.map((arm) => `i32 ${arm.tag}, label %a${arm.tag}`).join(" ")} ]`,
...union.arms.flatMap((arm) => [
`a${arm.tag}:`,
` call void @${mangleRecordRelease(arm.shapeId)}(ptr %u)`,
` br label %done`,
]),
`done:`,
` ret void`,
`}`,
``,
);
if (shapeUnionTraced(host, union))
out.push(
`define internal void ${shapeTraceFn(union.unionId)}(ptr %u, ptr %visit, ptr %ctx) ${FN_ATTRS} { ; shape union trace`,
`entry:`,
` %t = call i32 ${tagFn}(ptr %u)`,
` switch i32 %t, label %done [ ${union.arms.map((arm) => `i32 ${arm.tag}, label %a${arm.tag}`).join(" ")} ]`,
...union.arms.flatMap((arm) => [
`a${arm.tag}:`,
` call void @${mangleRecordTrace(arm.shapeId)}(ptr %u, ptr %visit, ptr %ctx)`,
` br label %done`,
]),
`done:`,
` ret void`,
`}`,
``,
);
}
return out;
}
/** A shape union's arms carry collector headers (all or none: the emitter
* traces every arm of a union with one traced arm). */
function shapeUnionTraced(host: ShapeHost, union: ShapeUnion): boolean {
return union.arms.some((arm) => host.tracedShapes.has(`record:${arm.shapeId}`));
}
/** The retain entry point of a nullable union's value: the arm's own when
* it tolerates NULL (emitted class and record helpers), otherwise a
* NULL-skipping wrapper around it (the null sentinel and literals are
@@ -233,6 +337,7 @@ export function emitNullableRetainWrappers(host: ShapeHost): string[] {
* Families with a mirrored fast path call the inline helper instead. */
export function releaseSym(host: ShapeHost, t: IrType): string {
const nullable = host.nullableUnions.of(t);
if (nullable?.repr === "shapes") return shapeUnionRc(nullable).release;
if (nullable) return releaseSym(host, nullable.arm);
const stem = runtimeRcStem(t);
if (host.rcHelpers !== null && stem !== null && inlineRcFamily(host, stem, "release") !== null) {
@@ -643,6 +748,8 @@ export function traceAdapter(host: ShapeHost, t: IrType): string | null {
return "@scr_promise_trace_v";
case "union": {
const nullable = host.nullableUnions.get(t.unionId);
if (nullable?.repr === "shapes")
return shapeUnionTraced(host, nullable) ? shapeTraceFn(nullable.unionId) : null;
if (nullable) return traceAdapter(host, nullable.arm);
if (!host.tracedUnions.has(t.unionId)) return null;
host.declare(`declare void @scr_union_trace_v(ptr, ptr, ptr)`);
@@ -945,15 +1052,17 @@ export function mapValKindNum(value: IrType): number {
export function rcMembers(
shape: IrRecordShape,
inline: InlineUnionFields,
/** The struct index of the first field (2 on tagged shapes). */
base = 1,
): { index: number; type: IrType; name: string }[] {
return [
...shape.fields.flatMap((f, i) =>
inline.record(f.type) ? [] : [{ index: i + 1, type: f.type, name: f.name }],
inline.record(f.type) ? [] : [{ index: i + base, type: f.type, name: f.name }],
),
...(shape.indexValue
? [
{
index: shape.fields.length + 1,
index: shape.fields.length + base,
type: mapOf(STRING, shape.indexValue),
name: "[key: string] overflow",
},
@@ -1107,7 +1216,12 @@ export function directDestroyTarget(
}
function directDestroyKey(host: ShapeHost, type: IrType): string | null {
const t = host.nullableUnions.of(type)?.arm ?? type;
const nullable = host.nullableUnions.of(type);
let t = type;
if (nullable !== null) {
if (nullable.repr === "shapes") return null;
t = nullable.arm;
}
if (t.kind === "record") return `record:${t.shapeId}`;
return t.kind === "object" ? `object:${t.className}` : null;
}
@@ -1338,8 +1452,12 @@ export function emitRecordShapes(
for (const shape of records) {
const struct = mangleRecordStruct(shape.id);
const fieldTys = shape.fields.map((f) => llFieldType(f.type, host.inlineUnions.record(f.type)));
const fieldTys: string[] = shape.fields.map((f) =>
llFieldType(f.type, host.inlineUnions.record(f.type)),
);
if (shape.indexValue) fieldTys.push("ptr"); // the overflow ScrMap *
// A tagged shape (an arm of a shape union) carries its shape number.
if (host.nullableUnions.shapeNumber(shape.id) !== 0) fieldTys.unshift("i32");
typeDefs.push(
`%${struct} = type { ${host.sizeType}${fieldTys.length ? ", " + fieldTys.join(", ") : ""} } ` +
`; record ${shape.id} { ${shape.fields.map((f) => llvmCommentText(f.name)).join("; ")}${shape.indexValue ? "; [key: string]" : ""} }`,
@@ -1352,7 +1470,7 @@ export function emitRecordShapes(
const candidates: [string, DirectDestroy][] = [];
const memberTypes: IrType[] = [];
for (const shape of records) {
const members = rcMembers(shape, host.inlineUnions);
const members = rcMembers(shape, host.inlineUnions, host.nullableUnions.fieldBase(shape.id));
for (const m of members) memberTypes.push(m.type);
if (!members.some((m) => isRefCounted(m.type) && needsBoundedRelease(m.type))) continue;
const release = mangleRecordRelease(shape.id);
@@ -1370,7 +1488,9 @@ export function emitRecordShapes(
for (const shape of records) {
const struct = mangleRecordStruct(shape.id);
const traced = host.tracedShapes.has(`record:${shape.id}`);
const members = rcMembers(shape, host.inlineUnions);
const base = host.nullableUnions.fieldBase(shape.id);
const shapeNumber = host.nullableUnions.shapeNumber(shape.id);
const members = rcMembers(shape, host.inlineUnions, base);
const refMembers = members.filter((m) => isRefCounted(m.type));
const sizeOf = `ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 1) to ${host.sizeType})`;
@@ -1439,6 +1559,11 @@ export function emitRecordShapes(
);
nw.push(...emitObjectAlloc(host, sizeOf, traced ? { type: cycType } : null));
nw.push(` store ${host.sizeType} 1, ptr %o`);
if (shapeNumber !== 0)
nw.push(
` %shp = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${SHAPE_WORD_INDEX}`,
` store i32 ${shapeNumber}, ptr %shp ; shape number`,
);
if (shape.indexValue) {
// The overflow map (string-keyed): value handling is type-directed
// exactly like shapes.ts's overflowNewC.
@@ -1449,7 +1574,7 @@ export function emitRecordShapes(
const trace = valKind === 2 ? traceArg(host, v) : "null";
nw.push(
` %ovf = call ptr @scr_map_new(i32 1, i32 ${valKind}, ptr ${host.mapOps(["null", "null", "null", rc.retain, rc.release, trace, "null"])})`,
` %ovfp = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${shape.fields.length + 1}`,
` %ovfp = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${shape.fields.length + base}`,
` store ptr %ovf, ptr %ovfp`,
);
}
@@ -1465,7 +1590,7 @@ export function emitRecordShapes(
];
let i = 0;
for (const field of shape.fields) {
const index = i + 1;
const index = i + base;
const inline = host.inlineUnions.record(field.type);
const fieldTy = llFieldType(field.type, inline);
clone.push(
@@ -14,6 +14,7 @@ import type { CallLifetimes } from "./call-lifetimes.js";
import { emitCallExpr, emitConstruction } from "./expr-calls.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import { emitRecordLitFields } from "./expr-primitives.js";
import { SHAPE_WORD_INDEX } from "./nullable-unions.js";
export type StackObjectSource = IrExpr & { kind: "recordLit" | "new" | "call" };
@@ -159,6 +160,17 @@ export function emitStackObject(
}
}
B.line(`store ${host.sizeType} -1, ptr ${slot}`);
if (type.kind === "record") {
// A tagged shape's word names it to every shape union's tag read.
const shapeNumber = host.nullableUnions.shapeNumber(type.shapeId);
if (shapeNumber !== 0) {
const p = B.tmp();
B.line(
`${p} = getelementptr inbounds ${struct}, ptr ${slot}, i64 0, i32 ${SHAPE_WORD_INDEX}`,
);
B.line(`store i32 ${shapeNumber}, ptr ${p}`);
}
}
if (e.kind === "call") {
// The entry writes the result into the slot it receives last.
emitCallExpr(
@@ -2,14 +2,19 @@
* helper generators (walkers, dyn). A tagged union is a `%ScrUnion` box; a
* nullable union (nullable-unions.ts) is the arm pointer itself: NULL for
* its NULL-encoded unit arm and, in `C | null | undefined`, the immortal
* NULLABLE_NULL sentinel for `null`. Every structural read of a union value
* NULLABLE_NULL sentinel for `null`. A shape union's value is likewise the
* record pointer; its tag comes from the record's shape word. Every structural read of a union value
* goes through here. */
import type { BlockBuilder } from "./blocks.js";
import { NULLABLE_NULL, type NullableUnion } from "./nullable-unions.js";
import { NULLABLE_NULL, shapeTagFn, type PointerUnion } from "./nullable-unions.js";
/** The i32 tag of a union value. */
export function emitUnionTag(B: BlockBuilder, nullable: NullableUnion | null, u: string): string {
export function emitUnionTag(B: BlockBuilder, nullable: PointerUnion | null, u: string): string {
const t = B.tmp();
if (nullable?.repr === "shapes") {
B.line(`${t} = call i32 ${shapeTagFn(nullable.unionId)}(ptr ${u})`);
return t;
}
if (nullable) {
const isNull = B.tmp();
B.line(`${isNull} = icmp eq ptr ${u}, null`);
@@ -32,8 +37,13 @@ export function emitUnionTag(B: BlockBuilder, nullable: NullableUnion | null, u:
/** i1: a nullable union value holds its reference arm (neither NULL nor
* the `null` sentinel). */
export function emitNullablePresent(B: BlockBuilder, nullable: NullableUnion, u: string): string {
export function emitNullablePresent(B: BlockBuilder, nullable: PointerUnion, u: string): string {
const present = B.tmp();
if (nullable.repr === "shapes" && nullable.unitTag < 0) {
// No unit arm: every value is a record (NULL only as a pending dummy).
B.line(`${present} = icmp ne ptr ${u}, null`);
return present;
}
B.line(`${present} = icmp ne ptr ${u}, null`);
if (nullable.nullTag < 0) return present;
const notNullArm = B.tmp(),
@@ -46,12 +56,18 @@ export function emitNullablePresent(B: BlockBuilder, nullable: NullableUnion, u:
/** i1: a nullable union value holds the arm `tag` (`negated`: does not). */
export function emitNullableIsTag(
B: BlockBuilder,
nullable: NullableUnion,
nullable: PointerUnion,
u: string,
tag: number,
negated: boolean,
): string {
if (tag === nullable.refTag) {
if (nullable.repr === "shapes" && tag !== nullable.unitTag && tag !== nullable.nullTag) {
const actual = emitUnionTag(B, nullable, u),
t = B.tmp();
B.line(`${t} = icmp ${negated ? "ne" : "eq"} i32 ${actual}, ${tag}`);
return t;
}
if (nullable.repr === "single" && tag === nullable.refTag) {
const present = emitNullablePresent(B, nullable, u);
if (!negated) return present;
const t = B.tmp();
@@ -65,7 +81,7 @@ export function emitNullableIsTag(
}
/** The BORROWED payload pointer of a reference arm. */
export function emitUnionPeek(B: BlockBuilder, nullable: NullableUnion | null, u: string): string {
export function emitUnionPeek(B: BlockBuilder, nullable: PointerUnion | null, u: string): string {
if (nullable) return u;
const p = B.tmp();
const t = B.tmp();
@@ -76,7 +92,9 @@ export function emitUnionPeek(B: BlockBuilder, nullable: NullableUnion | null, u
/** Text-template forms for helpers written as raw LLVM lines: `%out`
* receives the tag / payload of `u`. */
export function unionTagLines(nullable: NullableUnion | null, u: string, out: string): string[] {
export function unionTagLines(nullable: PointerUnion | null, u: string, out: string): string[] {
if (nullable?.repr === "shapes")
return [` ${out} = call i32 ${shapeTagFn(nullable.unionId)}(ptr ${u})`];
if (nullable && nullable.nullTag < 0)
return [
` ${out}.isnull = icmp eq ptr ${u}, null`,
@@ -95,7 +113,7 @@ export function unionTagLines(nullable: NullableUnion | null, u: string, out: st
];
}
export function unionPeekLines(nullable: NullableUnion | null, u: string, out: string): string[] {
export function unionPeekLines(nullable: PointerUnion | null, u: string, out: string): string[] {
// A plain copy of the pointer (NULL included) under the template's name.
if (nullable) return [` ${out} = getelementptr i8, ptr ${u}, i64 0`];
return [
@@ -183,7 +183,9 @@ test("a nullable-pointer source rebuilds the target box", () => {
expect(retag).not.toContain("retype.share");
});
async function llvmFor(source: string): Promise<{ module: IrModule; ir: string }> {
/** `boxed`: keep every union a tagged box (record unions otherwise become
* shape unions, which retype without any box). */
async function llvmFor(source: string, boxed = true): Promise<{ module: IrModule; ir: string }> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-union-retype-"));
try {
const entry = join(dir, "main.ts"),
@@ -194,7 +196,7 @@ async function llvmFor(source: string): Promise<{ module: IrModule; ir: string }
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, ir: emitLlvmModule(module) };
return { module, ir: emitLlvmModule(boxed ? keepUnionsBoxed(module) : module) };
} finally {
await rm(dir, { recursive: true, force: true });
}
@@ -253,3 +255,16 @@ console.log(leafValue(parsed));
expect(stored).not.toBeNull();
expect(ir).toContain(`load i8, ptr ${stored![1]}`);
});
test("record unions retype as the record pointer itself", async () => {
const { module, ir } = await llvmFor(VISITOR, false);
const helpers = module.functions.filter((fn) => fn.name.startsWith("%union.retag."));
expect(helpers.length).toBeGreaterThan(0);
for (const helper of helpers) {
const symbol = `sc_bf__x25_union_retag_${helper.name.slice("%union.retag.".length)}`;
const retag = body(ir, symbol);
expect(retag).not.toMatch(/@scr_union_new|retype\.share|sc_retype_guard/);
}
// No record of the visitor is ever boxed.
expect(ir).not.toMatch(/call ptr @scr_union_new_ref/);
});
@@ -287,7 +287,8 @@ export class LlWalkers {
throw new InternalCompilerError(
`llvm emitter bug: jsonStringify of unknown shape ${shapeId}`,
);
const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + 1]));
const fieldBase = this.host.nullableUnions.fieldBase(shapeId);
const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + fieldBase]));
if (recordMayKeepSource(shape)) this.emitRecordSourceWriter(B, shapeId);
// CYCLE-CAPABLE shapes bracket the walk with the circular-detection
// stack; edge labels stamp before members whose walk can re-enter —
@@ -436,7 +437,8 @@ export class LlWalkers {
throw new InternalCompilerError(`llvm emitter bug: field writer of unknown shape ${shapeId}`);
const name = `sc_jwf_${this.recordFieldWriters.size}`;
this.recordFieldWriters.set(shapeId, name);
const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + 1]));
const fieldBase = this.host.nullableUnions.fieldBase(shapeId);
const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + fieldBase]));
const byName = new Map(shape.fields.map((f) => [f.name, f]));
const cyclic = traceAdapter(this.host, { kind: "record", shapeId }) !== null;
const order = recordSourceFieldOrder(shape);
@@ -529,7 +531,7 @@ export class LlWalkers {
const ovfp = B.tmp();
const ovf = B.tmp();
B.line(
`${ovfp} = getelementptr inbounds %${mangleRecordStruct(shape.id)}, ptr %v, i64 0, i32 ${shape.fields.length + 1}`,
`${ovfp} = getelementptr inbounds %${mangleRecordStruct(shape.id)}, ptr %v, i64 0, i32 ${shape.fields.length + this.host.nullableUnions.fieldBase(shape.id)}`,
);
B.line(`${ovf} = load ptr, ptr ${ovfp} ; overflow map`);
host.declare(`declare ptr @scr_map_keys_js_order(ptr)`);
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5930-record-union-pointers.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5931-record-union-unit-arms.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5932-record-union-trees.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5933-record-union-conversions.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5934-record-union-set-members.ts"
],
"diags": []
}
@@ -0,0 +1,79 @@
// Unions whose arms are all object literals (discriminated unions) hold the
// record itself: construction, narrowing, storage in arrays, maps, fields
// and closures, identity, and printing behave exactly as in Node.
type Shape =
| { kind: "circle"; r: number }
| { kind: "rect"; w: number; h: number }
| { kind: "tri"; b: number; h: number; label: string };
function area(s: Shape): number {
switch (s.kind) {
case "circle":
return Math.round(3.14159 * s.r * s.r);
case "rect":
return s.w * s.h;
case "tri":
return 0.5 * s.b * s.h;
}
}
function make(i: number): Shape {
if (i % 3 === 0) return { kind: "circle", r: i };
if (i % 3 === 1) return { kind: "rect", w: i, h: i + 1 };
return { kind: "tri", b: i, h: 2, label: "t" + i };
}
const shapes: Shape[] = [];
for (let i = 0; i < 9; i++) shapes.push(make(i));
let total = 0;
for (const s of shapes) total += area(s);
console.log("total", total);
console.log(shapes.map((s) => s.kind).join(","));
console.log(shapes.filter((s) => s.kind === "rect").length);
// Identity: the same record compares equal, a structurally equal one does not.
const first = shapes[0]!;
const again: Shape = shapes[0]!;
const copy: Shape = { kind: "circle", r: 0 };
console.log(first === again, first === copy, shapes.indexOf(again), shapes.includes(copy));
// Mutation through one alias is visible through the union value.
const rect = { kind: "rect" as const, w: 2, h: 3 };
const asShape: Shape = rect;
rect.w = 10;
console.log(area(asShape), asShape === rect);
// Fields, maps and closures holding union values.
interface Holder {
current: Shape;
history: Shape[];
}
const holder: Holder = { current: make(1), history: [] };
for (let i = 0; i < 4; i++) {
holder.history.push(holder.current);
holder.current = make(i + 5);
}
console.log(holder.current.kind, holder.history.map(area).join(" "));
const byName = new Map<string, Shape>();
for (const s of shapes) byName.set(s.kind + area(s), s);
console.log(byName.size, byName.get("rect2")?.kind, byName.get("missing") === undefined);
const pick = (k: Shape["kind"]): (() => Shape | undefined) => {
const found = shapes.find((s) => s.kind === k);
return () => found;
};
const getTri = pick("tri");
const tri = getTri();
console.log(tri !== undefined && tri.kind === "tri" ? tri.label : "none");
// Printing and serialization see the plain objects.
console.log(shapes.slice(0, 3));
console.log(JSON.stringify(shapes.slice(0, 3)));
const second = shapes[2]!;
console.log(`${String(shapes[1]!.kind)} ${"label" in second} ${"label" in shapes[1]!}`);
// Spread copies and sorting.
const grown = shapes.map((s): Shape => (s.kind === "circle" ? { ...s, r: s.r + 1 } : s));
grown.sort((a, b) => area(a) - area(b));
console.log(grown.map((s) => `${s.kind}:${area(s)}`).join(" "));
@@ -0,0 +1,67 @@
// Record unions with undefined and null arms: NULL and the null sentinel
// encode the unit arms, optional properties keep their absent state, and
// narrowing, nullish operators and equality agree with Node.
type Ok = { ok: true; value: number };
type Err = { ok: false; error: string };
type Res = Ok | Err;
function parse(text: string): Res {
const n = Number(text);
return Number.isNaN(n) ? { ok: false, error: `bad ${text}` } : { ok: true, value: n };
}
function lookup(table: Res[], i: number): Res | undefined {
return i < table.length ? table[i] : undefined;
}
function maybe(i: number): Res | null | undefined {
if (i % 3 === 0) return null;
if (i % 3 === 1) return undefined;
return parse(String(i));
}
const table = ["1", "x", "3"].map(parse);
for (let i = 0; i < 4; i++) {
const r = lookup(table, i);
if (r === undefined) console.log(i, "missing");
else if (r.ok) console.log(i, "value", r.value);
else console.log(i, "error", r.error);
}
for (let i = 0; i < 6; i++) {
const m = maybe(i);
const tag = m === null ? "null" : m === undefined ? "undefined" : m.ok ? "ok" : "err";
console.log(i, tag, m == null, m ?? "default", typeof m);
}
// Optional properties of a record-union type.
interface Slot {
name: string;
result?: Res;
last?: Res | null;
}
const slots: Slot[] = [
{ name: "a" },
{ name: "b", result: parse("7") },
{ name: "c", result: parse("q"), last: null },
];
for (const s of slots) {
console.log(s.name, "result" in s, s.result?.ok, s.last === null, s.last === undefined);
}
console.log(JSON.stringify(slots));
slots[0]!.result = table[1];
slots[1]!.result = undefined;
console.log(slots.map((s) => (s.result === undefined ? "-" : s.result.ok ? "ok" : "err")).join(""));
// Equality across arms and unit encodings.
const a: Res | undefined = table[0];
const b: Res | undefined = undefined;
const c: Res | null = null;
console.log(a === table[0], a === b, b === undefined, c === null, (c as Res | null) === table[2]);
// Unions of records reaching unknown and back.
const values: unknown[] = [table[0], table[1], undefined, null];
for (const v of values) {
if (v === null || v === undefined) console.log("unit", v);
else console.log("object", JSON.stringify(v));
}
+77
View File
@@ -0,0 +1,77 @@
// A recursive discriminated union (an expression tree) whose leaf arm holds
// no references: every arm shares the cycle collector's header, so trees
// can be built, rewritten, shared and dropped in cycles.
type Expr =
| { kind: "num"; value: number }
| { kind: "neg"; arg: Expr }
| { kind: "add"; left: Expr; right: Expr }
| { kind: "ref"; target: { expr: Expr | null } };
function evaluate(e: Expr): number {
switch (e.kind) {
case "num":
return e.value;
case "neg":
return -evaluate(e.arg);
case "add":
return evaluate(e.left) + evaluate(e.right);
case "ref":
return e.target.expr === null ? 0 : evaluate(e.target.expr);
}
}
function build(depth: number, seed: number): Expr {
if (depth === 0) return { kind: "num", value: seed % 10 };
if (seed % 4 === 0) return { kind: "neg", arg: build(depth - 1, seed + 1) };
return { kind: "add", left: build(depth - 1, seed * 3 + 1), right: build(depth - 1, seed + 7) };
}
function simplify(e: Expr): Expr {
switch (e.kind) {
case "neg": {
const inner = simplify(e.arg);
return inner.kind === "neg" ? inner.arg : inner.kind === "num" ? { kind: "num", value: -inner.value } : { kind: "neg", arg: inner };
}
case "add": {
const l = simplify(e.left);
const r = simplify(e.right);
if (l.kind === "num" && r.kind === "num") return { kind: "num", value: l.value + r.value };
return l === e.left && r === e.right ? e : { kind: "add", left: l, right: r };
}
default:
return e;
}
}
function show(e: Expr): string {
switch (e.kind) {
case "num":
return String(e.value);
case "neg":
return `-(${show(e.arg)})`;
case "add":
return `(${show(e.left)} + ${show(e.right)})`;
case "ref":
return "ref";
}
}
let checksum = 0;
for (let round = 0; round < 50; round++) {
const tree = build(6, round);
const simple = simplify(tree);
checksum += evaluate(tree) * 3 + evaluate(simple);
// A cycle through a record arm: the tree refers back to itself.
const target: { expr: Expr | null } = { expr: null };
const loop: Expr = { kind: "add", left: tree, right: { kind: "ref", target } };
target.expr = { kind: "num", value: round };
checksum += evaluate(loop);
target.expr = loop.left;
}
console.log(checksum);
console.log(show(simplify(build(3, 5))));
console.log(show(build(2, 4)), evaluate(build(4, 2)));
const shared: Expr = { kind: "num", value: 4 };
const twice: Expr = { kind: "add", left: shared, right: shared };
console.log(evaluate(twice), twice.kind === "add" && twice.left === twice.right);
@@ -0,0 +1,78 @@
// Record unions meet other representations: widening into unions with
// string and number arms, narrowing back, `in` tests on undiscriminated
// arms, generic helpers, class fields and promises.
type Point = { x: number; y: number };
type Named = { name: string };
type Item = Point | Named;
type Wide = Item | string | number;
function describe(i: Item): string {
return "x" in i ? `point(${i.x},${i.y})` : `named(${i.name})`;
}
function widen(i: Item): Wide {
return i;
}
function narrow(w: Wide): Item | undefined {
if (typeof w === "string" || typeof w === "number") return undefined;
return w;
}
const items: Item[] = [{ x: 1, y: 2 }, { name: "a" }, { x: 3, y: 4 }];
const wide: Wide[] = items.map(widen);
wide.push("text", 42);
for (const w of wide) {
const back = narrow(w);
console.log(typeof w, back === undefined ? "-" : describe(back), back !== undefined && items.includes(back));
}
// Generic helpers instantiate over the union.
function firstWhere<T>(xs: T[], pred: (x: T) => boolean): T | undefined {
for (const x of xs) if (pred(x)) return x;
return undefined;
}
const named = firstWhere(items, (i) => "name" in i);
console.log(named === items[1], named !== undefined ? describe(named) : "none");
const pair: [Item, Item] = [items[2]!, items[0]!];
const [p0, p1] = pair;
console.log(describe(p0), describe(p1));
// Class fields and methods.
class Cursor {
private at: Item;
readonly seen: Item[] = [];
constructor(start: Item) {
this.at = start;
}
move(next: Item): Item {
const prev = this.at;
this.seen.push(prev);
this.at = next;
return prev;
}
get current(): Item {
return this.at;
}
}
const cursor = new Cursor(items[0]!);
for (const i of items) cursor.move(i);
console.log(describe(cursor.current), cursor.seen.map(describe).join(" "));
// Promises carry union payloads.
async function load(i: number): Promise<Item> {
await null;
return items[i % items.length]!;
}
async function main(): Promise<void> {
const loaded: Item[] = [];
for (const i of [0, 1, 5]) loaded.push(await load(i));
console.log(loaded.map(describe).join(" "), loaded[0] === items[0]);
const slow = new Promise<Item>((resolve) => setTimeout(() => resolve({ x: 9, y: 9 }), 5));
const raced = await Promise.race([load(1), slow]);
console.log(describe(raced));
const pending: Promise<Item>[] = [load(2), load(0)];
const all = await Promise.all(pending);
console.log(all.map(describe).join(" "), all[1] === items[0]);
}
main().then(() => console.log("done"));
@@ -0,0 +1,25 @@
// A record union used as Set elements and Map keys keeps the runtime's
// tagged representation; other unions over the same records convert to and
// from it without changing identity semantics.
type Point = { x: number; y: number };
type Named = { name: string };
type Item = Point | Named;
const items: Item[] = [{ x: 1, y: 2 }, { name: "a" }, { x: 3, y: 4 }];
const marked = new Set<Item>();
marked.add(items[0]!);
marked.add(items[0]!);
marked.add({ x: 1, y: 2 });
console.log(marked.size, marked.has(items[0]!), marked.has(items[1]!));
const counts = new Map<Item, number>();
for (const i of [...items, items[0]!, items[2]!]) counts.set(i, (counts.get(i) ?? 0) + 1);
console.log([...counts.values()].join(","));
function maybeItem(i: number): Item | undefined {
return i < items.length ? items[i] : undefined;
}
for (let i = 0; i < 4; i++) {
const m = maybeItem(i);
console.log(i, m === undefined ? "none" : "x" in m ? m.x : m.name, m !== undefined && marked.has(m));
}