From 6cc5345a4127df254ab6ae6ed00f24a1fbe49f0a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 16:22:38 -0500 Subject: [PATCH 1/2] Key Sets and Maps of identity-arm unions by their pointer representation A union that is a Set element or a Map key was a tagged box in the whole program, because the C map hashes and compares union keys by tag and payload. One `Set` made every `Node | undefined` value of the program a box, at every read. A union whose arms all compare by identity (records, class instances, arrays, functions), beside at most one undefined and one null arm, now keeps its pointer representation in collections too. The map keys such a union by reference: the pointer carries the identity, and NULL and the null sentinel are two more distinct keys. Unions with a string or another value arm keep their boxes, and library builds keep the earlier rule. The tests of the box paths keep their unions boxed through a new `boxedUnions` emission option instead of a Set of each union. --- benchmarks/runtime/README.md | 1 + .../workloads/optional-child-sets.json | 6 ++ .../runtime/workloads/optional-child-sets.ts | 48 +++++++++++ packages/compiler/src/backend/llvm/emitter.ts | 5 ++ .../src/backend/llvm/expr-containers.ts | 8 +- .../backend/llvm/local-array-reads.test.ts | 2 +- .../backend/llvm/local-union-storage.test.ts | 2 +- .../backend/llvm/map-read-lifetimes.test.ts | 9 +- .../src/backend/llvm/nullable-unions.test.ts | 30 +++++++ .../src/backend/llvm/nullable-unions.ts | 48 +++++++++-- .../backend/llvm/record-union-keys.test.ts | 31 +++++++ .../src/backend/llvm/stack-unions.test.ts | 2 +- .../src/backend/llvm/test-boxed-unions.ts | 34 ++------ .../src/backend/llvm/union-retype.test.ts | 4 +- ...313-identity-union-collection-keys.ts.json | 6 ++ .../6313-identity-union-collection-keys.ts | 86 +++++++++++++++++++ 16 files changed, 274 insertions(+), 48 deletions(-) create mode 100644 benchmarks/runtime/workloads/optional-child-sets.json create mode 100644 benchmarks/runtime/workloads/optional-child-sets.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6313-identity-union-collection-keys.ts.json create mode 100644 tests/corpus/6313-identity-union-collection-keys.ts diff --git a/benchmarks/runtime/README.md b/benchmarks/runtime/README.md index 149c348d0..373931654 100644 --- a/benchmarks/runtime/README.md +++ b/benchmarks/runtime/README.md @@ -27,6 +27,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `log-summary`, `inventory-report` | cpu | the multi-module applications from `benchmarks/builds`, on 300k-line inputs | | `number-maps` | cpu | number-keyed Map/Set: a dense-id table, sparse ids in many small maps, get/has/set mixes | | `numeric-kernels` | cpu | sieve, hashing, `number[][]` matmul, `Float64Array` | +| `optional-child-sets` | cpu | syntax-tree walks through class nodes whose getters return `TreeNode \| undefined` children, in a program that also keeps a `Set` | | `optional-fields` | cpu | records and class instances with `number \| undefined` and other scalar-union fields | | `published-graphs` | cpu | syntax trees with parent links and a symbol table, published with `@scriptc/threads` and then read | | `record-untyped-writes` | cpu | typed records written through untyped views: a counter helper taking `unknown` bumps fields of a stats record, and `Object.assign` merges patches into a settings record, with typed reads in between | diff --git a/benchmarks/runtime/workloads/optional-child-sets.json b/benchmarks/runtime/workloads/optional-child-sets.json new file mode 100644 index 000000000..d275b869c --- /dev/null +++ b/benchmarks/runtime/workloads/optional-child-sets.json @@ -0,0 +1,6 @@ +{ + "entry": "workloads/optional-child-sets.ts", + "args": [ + "1" + ] +} diff --git a/benchmarks/runtime/workloads/optional-child-sets.ts b/benchmarks/runtime/workloads/optional-child-sets.ts new file mode 100644 index 000000000..205d8ce45 --- /dev/null +++ b/benchmarks/runtime/workloads/optional-child-sets.ts @@ -0,0 +1,48 @@ +// Syntax-tree walks through class nodes whose getters return optional +// children, in a program that also keeps a `Set` of the optional node type. +class TreeNode { + readonly kind: number; + private readonly kids: (TreeNode | undefined)[]; + parentNode: TreeNode | undefined; + constructor(kind: number, kids: (TreeNode | undefined)[]) { + this.kind = kind; + this.kids = kids; + this.parentNode = undefined; + for (const kid of kids) if (kid !== undefined) kid.parentNode = this; + } + get left(): TreeNode | undefined { + return this.kids[0]; + } + get right(): TreeNode | undefined { + return this.kids[1]; + } + get parent(): TreeNode | undefined { + return this.parentNode; + } +} + +function build(depth: number, seed: number): TreeNode { + if (depth === 0) return new TreeNode(seed % 7, [undefined, undefined]); + const right = seed % 11 === 0 ? undefined : build(depth - 1, seed * 7 + 3); + return new TreeNode(seed % 7, [build(depth - 1, seed * 5 + 1), right]); +} + +function sumKinds(node: TreeNode | undefined): number { + let total = 0; + while (node !== undefined) { + total += node.kind; + const up = node.parent; + if (up !== undefined && up.kind === node.kind) total++; + total += sumKinds(node.right); + node = node.left; + } + return total; +} + +const root = build(20, 1); +// The one Set of the optional node type. +const marked = new Set([root, root.left, undefined]); +let total = 0; +const rounds = 30 * Number(process.argv[2] ?? "1"); +for (let i = 0; i < rounds; i++) total += sumKinds(root); +console.log(total, marked.size, marked.has(root.right)); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 2cb5d2f87..a7cedf223 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -561,6 +561,10 @@ export interface LlvmTargetOptions { debugSources?: ReadonlyMap; /** Pointer width of the target C ABI. Native targets are 64-bit today. */ pointerBits?: 32 | 64; + /** Unions emitted as tagged boxes whatever their arms, for tests of the + * box paths: in a program, a union that has a pointer representation + * (nullable-unions.ts) never holds a box. */ + boxedUnions?: ReadonlySet; /** Select the WASI libc entry-point convention. */ wasi?: boolean; /** Library archive assembly may move the volatile identity getters into a @@ -2089,6 +2093,7 @@ export class LlEmitter { options.debugSources === undefined, this.nodeIndex, options.pointerBits !== 32, + options.boxedUnions, ); this.heapCensus = heapCensusOf(mod, this.nullableUnions); for (const decl of this.heapCensus?.declarations() ?? []) this.declare(decl); diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index 51a81bd07..9ea1ee9ca 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -1327,7 +1327,7 @@ function collectionOps(host: LlvmEmitterContext, key: IrType, value: IrType): st : { retain: "null", release: "null", trace: "null" }; const arms = key.kind === "union" ? host.unionsById.get(key.unionId)?.arms : undefined; let unionKeys = "null"; - if (mapKeyKindNum(key, arms) === 7) { + if (mapKeyKindNum(key, arms) === 7 && host.nullableUnions.of(key) === null) { if (!arms) throw new InternalCompilerError("llvm emitter bug: collection key union missing"); unionKeys = host.cstr(arms.map((arm) => String.fromCharCode(mapKeyKindNum(arm))).join("")); } @@ -1346,9 +1346,9 @@ function collectionOps(host: LlvmEmitterContext, key: IrType, value: IrType): st function newCollection(host: LlvmEmitterContext, key: IrType, value: IrType): string { const B = host.B; const arms = key.kind === "union" ? host.unionsById.get(key.unionId)?.arms : undefined; - // SCR_MAP_KEY_REF (2): shape-union keys are record pointers. - const kind = - key.kind === "union" && host.nullableUnions.shapes(key.unionId) ? 2 : mapKeyKindNum(key, arms); + // SCR_MAP_KEY_REF (2): a union key with a pointer representation is that + // pointer (boxedUnionIds). + const kind = host.nullableUnions.of(key) !== null ? 2 : mapKeyKindNum(key, arms); const m = B.tmp(); host.declare(`declare ptr @scr_map_new(i32, i32, ptr)`); // SCR_MAP_VAL_CREF (3): string keys and compact values (compactMapValues). diff --git a/packages/compiler/src/backend/llvm/local-array-reads.test.ts b/packages/compiler/src/backend/llvm/local-array-reads.test.ts index 7af495d42..a4dc566e3 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.test.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.test.ts @@ -153,7 +153,7 @@ function candidates(mod: IrModule) { } function workBody(mod: IrModule, pointerBits: 32 | 64 = 64) { return /^define internal [^\n]*@sc_(?:b)?f_work(?:\.i32)?\([^]*?^}/m.exec( - emitLlvmModule(keepUnionsBoxed(mod), { pointerBits }), + emitLlvmModule(mod, { pointerBits, ...keepUnionsBoxed(mod) }), )![0]; } diff --git a/packages/compiler/src/backend/llvm/local-union-storage.test.ts b/packages/compiler/src/backend/llvm/local-union-storage.test.ts index 81019cadc..15b6bcf47 100644 --- a/packages/compiler/src/backend/llvm/local-union-storage.test.ts +++ b/packages/compiler/src/backend/llvm/local-union-storage.test.ts @@ -104,7 +104,7 @@ function facts(mod: IrModule) { function body(mod: IrModule, pointerBits: 32 | 64): string { expect(validateModule(mod)).toEqual([]); return /^define internal [^\n]*@sc_(?:b)?f_work(?:\.i32)?\([^]*?^}/m.exec( - emitLlvmModule(keepUnionsBoxed(mod), { pointerBits }), + emitLlvmModule(mod, { pointerBits, ...keepUnionsBoxed(mod) }), )![0]; } diff --git a/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts b/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts index d91e2b211..8a0b406f8 100644 --- a/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts @@ -93,7 +93,10 @@ function body(mod: IrModule, bits: 32 | 64 = 64): string { expect(validateModule(mod)).toEqual([]); // Record values are nullable pointers; the other fixtures pin stack boxes. const nullable = mod.unions?.[0]?.arms[0]?.kind === "record"; - const llvm = emitLlvmModule(nullable ? mod : keepUnionsBoxed(mod), { pointerBits: bits }); + const llvm = emitLlvmModule(mod, { + pointerBits: bits, + ...(nullable ? {} : keepUnionsBoxed(mod)), + }); return /^define internal [^\n]*@sc_(?:b)?f_work(?:\.i32)?\([^]*?^}/m.exec(llvm)![0]; } @@ -255,11 +258,11 @@ test("reference-preserving keys borrow receivers across unrelated later assignme }); test("string-key lookups test the absent-key filter inline on 64-bit targets only", () => { - const mod = keepUnionsBoxed(fixture(F64, STRING)); + const mod = fixture(F64, STRING); expect(validateModule(mod)).toEqual([]); const helper = (bits: 32 | 64) => /^define internal ptr @sc_map_entry_str(?:\.i32)?\([^]*?^}/m.exec( - emitLlvmModule(mod, { pointerBits: bits }), + emitLlvmModule(mod, { pointerBits: bits, ...keepUnionsBoxed(mod) }), )![0]; const wide = helper(64); // The cached hash is the capacity word's high half; the filter bits are its diff --git a/packages/compiler/src/backend/llvm/nullable-unions.test.ts b/packages/compiler/src/backend/llvm/nullable-unions.test.ts index c155a86f6..185b910a0 100644 --- a/packages/compiler/src/backend/llvm/nullable-unions.test.ts +++ b/packages/compiler/src/backend/llvm/nullable-unions.test.ts @@ -78,6 +78,36 @@ test("records of runtime-inspected unions keep every union over them boxed", () expect(unions.shapeNumber("c")).toBe(0); }); +test("Set elements and Map keys over identity arms keep their pointer representation", () => { + const union = (unionId: string): IrType => ({ kind: "union", unionId }); + const globals = [ + { id: "%g.s", name: "s", type: { kind: "set", elem: union("au") } as IrType, mutable: false }, + { + id: "%g.m", + name: "m", + type: { kind: "map", key: union("abnu"), value: F64 } as IrType, + mutable: false, + }, + { id: "%g.t", name: "t", type: { kind: "set", elem: union("su") } as IrType, mutable: false }, + ]; + const unions: IrUnionDef[] = [ + { id: "au", arms: [a, UNDEFINED_T] }, + { id: "abnu", arms: [a, b, NULL_T, UNDEFINED_T] }, + { id: "su", arms: [{ kind: "string" }, UNDEFINED_T] }, + ]; + const program = new NullableUnions(module(unions, { globals })); + expect(program.single("au")).not.toBeNull(); + expect(program.shapes("abnu")).toMatchObject({ unitTag: 3, nullTag: 2 }); + // Strings compare by value: such keys stay boxed. + expect(program.get("su")).toBeNull(); + // Library builds keep them boxed. + const library = new NullableUnions( + module(unions, { globals, lib: { exports: [] } as unknown as NonNullable }), + ); + expect(library.get("au")).toBeNull(); + expect(library.get("abnu")).toBeNull(); +}); + test("unions of records alone stay shape unions as Map keys and Set elements", () => { const set: IrType = { kind: "set", elem: { kind: "union", unionId: "ab" } }; const map: IrType = { kind: "map", key: { kind: "union", unionId: "abc" }, value: F64 }; diff --git a/packages/compiler/src/backend/llvm/nullable-unions.ts b/packages/compiler/src/backend/llvm/nullable-unions.ts index 3f7ca8ca1..675064c74 100644 --- a/packages/compiler/src/backend/llvm/nullable-unions.ts +++ b/packages/compiler/src/backend/llvm/nullable-unions.ts @@ -1,4 +1,5 @@ import { + isIdentityCollectionKey, isUnitType, type IrClassDef, type IrModule, @@ -190,13 +191,21 @@ export class NullableUnions { /** `shapes`: allow shape and class unions (debug builds keep boxes, * whose debug-info layouts describe every arm). `classIds`: the target * keeps class ids in instance headers (64-bit targets), which class - * unions read their arm from. */ - constructor(mod: IrModule, shapes = true, nodes?: IrNodeIndex, classIds = true) { + * unions read their arm from. `kept`: unions that stay boxes whatever + * their arms (LlvmTargetOptions.boxedUnions). */ + constructor( + mod: IrModule, + shapes = true, + nodes?: IrNodeIndex, + classIds = true, + kept: ReadonlySet = new Set(), + ) { const emitted = new Set(); for (const cls of mod.classes ?? []) if (!cls.runtime) emitted.add(cls.name); const records = new Set(); for (const shape of mod.records ?? []) records.add(shape.id); const boxed = boxedUnionIds(mod, nodes); + for (const id of kept) boxed.add(id); // 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). @@ -500,19 +509,21 @@ function classUnion(def: IrUnionDef, classes: ClassForest): ClassUnion | null { /** 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. A union of records alone is the - * exception: its keys compare by record identity, which a shape union's - * record pointer carries as well (the map keys it by reference). */ + * tag and payload inside the C map. A union whose arms all compare by + * identity (records, class instances, arrays, functions), with at most an + * undefined and a null arm, is the exception: a pointer representation + * carries that identity, with NULL and the null sentinel as two more + * distinct keys, so the map keys it by reference. Library builds keep + * such unions boxed, except unions of records alone. */ function boxedUnionIds(mod: IrModule, nodes?: IrNodeIndex): Set { const boxed = new Set(); // Only the module's union definitions consult the result. if (!mod.unions?.length) return boxed; - const recordsOnly = new Set(); + const identityKeys = new Set(); for (const def of mod.unions) - if (def.arms.length >= 2 && def.arms.every((arm) => arm.kind === "record")) - recordsOnly.add(def.id); + if (mod.lib === undefined ? identityKeyUnion(def) : recordsOnly(def)) identityKeys.add(def.id); const mark = (t: IrType): void => { - if (t.kind === "union" && !recordsOnly.has(t.unionId)) boxed.add(t.unionId); + if (t.kind === "union" && !identityKeys.has(t.unionId)) boxed.add(t.unionId); }; if (nodes !== undefined) { for (const t of nodes.types) { @@ -533,6 +544,25 @@ function boxedUnionIds(mod: IrModule, nodes?: IrNodeIndex): Set { return boxed; } +function recordsOnly(def: IrUnionDef): boolean { + return def.arms.length >= 2 && def.arms.every((arm) => arm.kind === "record"); +} + +/** Every arm compares by identity as a key, beside at most one undefined + * and one null arm. */ +function identityKeyUnion(def: IrUnionDef): boolean { + let refs = 0, + undefinedArms = 0, + nullArms = 0; + for (const arm of def.arms) { + if (arm.kind === "undefinedT") undefinedArms++; + else if (arm.kind === "nullT") nullArms++; + else if (isIdentityCollectionKey(arm)) refs++; + else return false; + } + return refs > 0 && undefinedArms <= 1 && nullArms <= 1; +} + /** Emitted classes keyed by name, for callers that only hold the class * list (nullable class fields). */ export function emittedClassNames(classes: readonly IrClassDef[]): Set { diff --git a/packages/compiler/src/backend/llvm/record-union-keys.test.ts b/packages/compiler/src/backend/llvm/record-union-keys.test.ts index 3c961cabb..94f3ff0ed 100644 --- a/packages/compiler/src/backend/llvm/record-union-keys.test.ts +++ b/packages/compiler/src/backend/llvm/record-union-keys.test.ts @@ -58,6 +58,37 @@ console.log(seen.size, depth.get(nodes[1]!), seen.has({ kind: "num", value: 1 }) expect(ir).not.toMatch(/call ptr @scr_union_new_ref/); }); +test("Sets and Maps of a class, null or undefined key the instance pointer", async () => { + const ir = await llvmFor(` +class Leaf { + name: string; + constructor(name: string) { + this.name = name; + } +} +const a = new Leaf("a"); +const leaves = new Set([a, undefined, new Leaf("b")]); +const counts = new Map(); +for (const leaf of [a, null, undefined, a]) counts.set(leaf, (counts.get(leaf) ?? 0) + 1); +const child = (i: number): Leaf | undefined => (i < 2 ? a : undefined); +console.log(leaves.has(undefined), counts.get(a), counts.get(null), child(1)?.name); +`); + expect(ir).toMatch(/call ptr @scr_map_new\(i32 2,/); + expect(ir).not.toMatch(/call ptr @scr_map_new\(i32 [37],/); + expect(ir).not.toMatch(/call ptr @scr_union_new_ref/); +}); + +test("Maps keyed by a string or undefined keep union keys, which compare by value", async () => { + const ir = await llvmFor(` +const names = new Map([["a", 1], [undefined, 2]]); +names.set(["a"].join(""), 3); +console.log(names.size, names.get("a"), names.get(undefined)); +`); + // SCR_MAP_KEY_UNION_VALUE (7): the strings compare by their text. + expect(ir).toMatch(/call ptr @scr_map_new\(i32 7,/); + expect(ir).not.toMatch(/call ptr @scr_map_new\(i32 2,/); +}); + const recordType = (shapeId: string): IrType => ({ kind: "record", shapeId }); const wide: IrType = { kind: "union", unionId: "wide" }; diff --git a/packages/compiler/src/backend/llvm/stack-unions.test.ts b/packages/compiler/src/backend/llvm/stack-unions.test.ts index d4fd6c170..d828066ea 100644 --- a/packages/compiler/src/backend/llvm/stack-unions.test.ts +++ b/packages/compiler/src/backend/llvm/stack-unions.test.ts @@ -90,7 +90,7 @@ function body(ir: string, name: string): string { function emit(mod: IrModule, pointerBits: 32 | 64 = 64): string { expect(validateModule(mod)).toEqual([]); - return emitLlvmModule(keepUnionsBoxed(mod), { pointerBits }); + return emitLlvmModule(mod, { pointerBits, ...keepUnionsBoxed(mod) }); } function declareLocal(mod: IrModule, init: IrExpr): void { diff --git a/packages/compiler/src/backend/llvm/test-boxed-unions.ts b/packages/compiler/src/backend/llvm/test-boxed-unions.ts index 8847d53a3..01818170c 100644 --- a/packages/compiler/src/backend/llvm/test-boxed-unions.ts +++ b/packages/compiler/src/backend/llvm/test-boxed-unions.ts @@ -1,29 +1,9 @@ -import { F64, type IrModule, type IrType, type IrUnionDef } from "../../ir/ir.js"; +import type { IrModule } from "../../ir/ir.js"; +import type { LlvmTargetOptions } from "./emitter.js"; -/** Test helper: a Set of each union keeps it a tagged `%ScrUnion` box (Set - * elements are runtime-inspected boxes), so stack-box tests exercise the - * paths that nullable-pointer unions (nullable-unions.ts) bypass. A union of - * records alone is a Set element by record identity, so for those the Set - * holds a companion union with a number arm, which keeps the records out of - * every shape union. */ -export function keepUnionsBoxed(mod: IrModule): IrModule { - const companions: IrUnionDef[] = []; - const keep = (mod.unions ?? []).map((union) => { - let elem = union.id; - if (union.arms.every((arm) => arm.kind === "record")) { - elem = `%keep.${union.id}`; - companions.push({ id: elem, arms: [...union.arms, F64] }); - } - return { - id: `%g.keep.${union.id}`, - name: `keep_${union.id}`, - type: { kind: "set", elem: { kind: "union", unionId: elem } } as IrType, - mutable: false, - }; - }); - return { - ...mod, - unions: [...(mod.unions ?? []), ...companions], - globals: [...(mod.globals ?? []), ...keep], - }; +/** Test helper: emission options that keep every union of `mod` a tagged + * `%ScrUnion` box, so stack-box tests exercise the paths that + * nullable-pointer unions (nullable-unions.ts) bypass. */ +export function keepUnionsBoxed(mod: IrModule): LlvmTargetOptions { + return { boxedUnions: new Set((mod.unions ?? []).map((union) => union.id)) }; } diff --git a/packages/compiler/src/backend/llvm/union-retype.test.ts b/packages/compiler/src/backend/llvm/union-retype.test.ts index a4d1aaa90..502940e71 100644 --- a/packages/compiler/src/backend/llvm/union-retype.test.ts +++ b/packages/compiler/src/backend/llvm/union-retype.test.ts @@ -103,7 +103,7 @@ function fixture(): IrModule { function emit(mod: IrModule): string { expect(validateModule(mod)).toEqual([]); - return emitLlvmModule(keepUnionsBoxed(mod), { pointerBits: 64 }); + return emitLlvmModule(mod, { pointerBits: 64, ...keepUnionsBoxed(mod) }); } test("an identity retype retains the input box instead of allocating", () => { @@ -196,7 +196,7 @@ async function llvmFor(source: string, boxed = true): Promise<{ module: IrModule 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(boxed ? keepUnionsBoxed(module) : module) }; + return { module, ir: emitLlvmModule(module, boxed ? keepUnionsBoxed(module) : {}) }; } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6313-identity-union-collection-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6313-identity-union-collection-keys.ts.json new file mode 100644 index 000000000..f1d3c615f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6313-identity-union-collection-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6313-identity-union-collection-keys.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6313-identity-union-collection-keys.ts b/tests/corpus/6313-identity-union-collection-keys.ts new file mode 100644 index 000000000..ba64c9375 --- /dev/null +++ b/tests/corpus/6313-identity-union-collection-keys.ts @@ -0,0 +1,86 @@ +// Sets and Maps keyed by unions whose arms compare by identity, beside +// undefined and null keys. + +class Leaf { + readonly name: string; + constructor(name: string) { + this.name = name; + } +} + +class Branch { + readonly name: string; + readonly children: Leaf[]; + constructor(name: string, children: Leaf[]) { + this.name = name; + this.children = children; + } +} + +const a = new Leaf("a"); +const b = new Leaf("b"); +const twin = new Leaf("a"); + +// A class or undefined. +const leaves = new Set(); +for (const item of [a, undefined, b, a, undefined, twin]) leaves.add(item); +console.log(leaves.size, leaves.has(a), leaves.has(undefined), leaves.has(new Leaf("b"))); +console.log([...leaves].map((leaf) => (leaf === undefined ? "undefined" : leaf.name)).join(",")); +leaves.delete(undefined); +console.log(leaves.size, leaves.has(undefined)); +console.log(leaves); + +// A class, null or undefined, as Map keys. +const counts = new Map(); +for (const item of [a, null, undefined, b, null, a]) + counts.set(item, (counts.get(item) ?? 0) + 1); +console.log(counts.get(a), counts.get(null), counts.get(undefined), counts.get(twin)); +for (const [key, count] of counts) + console.log(key === null ? "null" : key === undefined ? "undefined" : key.name, count); +console.log(Array.from(counts.keys()).length, counts.has(null), counts.delete(null), counts.has(null)); +console.log(counts); + +// Two classes or undefined. +const nodes: (Leaf | Branch | undefined)[] = [a, new Branch("root", [a, b]), undefined, b]; +const seen = new Set(nodes); +seen.add(nodes[1]); +seen.add(undefined); +console.log(seen.size); +seen.forEach((node) => { + if (node === undefined) console.log("undefined"); + else if (node instanceof Branch) console.log("branch", node.name, node.children.length); + else console.log("leaf", node.name); +}); + +// Records, arrays and functions, or undefined. +type Point = { x: number; y: number }; +const origin: Point = { x: 0, y: 0 }; +const points = new Map([ + [origin, "origin"], + [undefined, "none"], + [{ x: 0, y: 0 }, "copy"], +]); +console.log(points.size, points.get(origin), points.get(undefined), points.get({ x: 0, y: 0 })); + +const row = [1, 2, 3]; +const rows = new Set([row, undefined, [1, 2, 3], row]); +console.log(rows.size, rows.has(row), rows.has([1, 2, 3])); + +const greet = (): string => "hi"; +const handlers = new Map<(() => string) | undefined, number>(); +handlers.set(greet, 1); +handlers.set(undefined, 2); +handlers.set(greet, 3); +console.log(handlers.size, handlers.get(greet), handlers.get(undefined)); + +// Strings compare by value, beside undefined. +const names = new Map([ + ["a", 1], + [undefined, 2], +]); +names.set(["a"].join(""), 3); +console.log(names.size, names.get("a"), names.get(undefined)); + +// The values keep their identity through the collection. +const copied = new Set(leaves); +console.log([...copied][0] === a, copied.has(b), copied.size); From 33799052e52f9e1bf12543633deba0d965106c12 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 18:22:49 -0500 Subject: [PATCH 2/2] Keep the source stack-box tests' Set element unions boxed through the emission option The call-lifetime and string-and-array lifetime tests kept their optional element union a box with a `Set`, which no longer boxes a union of identity arms. They now emit with keepSetElementsBoxed, which keeps the unions the module's Sets hold boxed. --- .../src/backend/llvm/test-boxed-unions.ts | 17 ++++++++++++ packages/compiler/test/call-lifetimes.test.ts | 5 ++-- .../test/string-and-array-lifetimes.test.ts | 26 +++++++++---------- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/compiler/src/backend/llvm/test-boxed-unions.ts b/packages/compiler/src/backend/llvm/test-boxed-unions.ts index 01818170c..d244cf44f 100644 --- a/packages/compiler/src/backend/llvm/test-boxed-unions.ts +++ b/packages/compiler/src/backend/llvm/test-boxed-unions.ts @@ -1,4 +1,5 @@ import type { IrModule } from "../../ir/ir.js"; +import { everyModuleNode } from "../../ir/traverse.js"; import type { LlvmTargetOptions } from "./emitter.js"; /** Test helper: emission options that keep every union of `mod` a tagged @@ -7,3 +8,19 @@ import type { LlvmTargetOptions } from "./emitter.js"; export function keepUnionsBoxed(mod: IrModule): LlvmTargetOptions { return { boxedUnions: new Set((mod.unions ?? []).map((union) => union.id)) }; } + +/** Test helper: emission options that keep each union the Sets of `mod` + * hold a tagged box. Source tests of the stack-box paths declare a + * `Set` of the union they exercise. */ +export function keepSetElementsBoxed(mod: IrModule): LlvmTargetOptions { + const boxedUnions = new Set(); + everyModuleNode(mod, { + expr: () => true, + stmt: () => true, + type: (type) => { + if (type.kind === "set" && type.elem.kind === "union") boxedUnions.add(type.elem.unionId); + return true; + }, + }); + return { boxedUnions }; +} diff --git a/packages/compiler/test/call-lifetimes.test.ts b/packages/compiler/test/call-lifetimes.test.ts index 39db0a399..5c33c343e 100644 --- a/packages/compiler/test/call-lifetimes.test.ts +++ b/packages/compiler/test/call-lifetimes.test.ts @@ -5,6 +5,7 @@ import { expect, test } from "vitest"; import { compile, deserializeModule, validateModule } from "../src/index.js"; import { analyzeCallLifetimes } from "../src/backend/llvm/call-lifetimes.js"; import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; +import { keepSetElementsBoxed } from "../src/backend/llvm/test-boxed-unions.js"; import { type IrModule, type IrFunction } from "../src/ir/ir.js"; async function lower(source: string): Promise { @@ -170,7 +171,7 @@ function work(items: Item[]): number { } console.log(work([new Item(7)])); -// Keeps the optional element union a tagged box (Set elements are boxes), +// The Set's element union stays a tagged box under keepSetElementsBoxed, // so the stack-box paths stay exercised; nullable pointers bypass them. const keepBoxed = new Set(); void keepBoxed; @@ -181,7 +182,7 @@ void keepBoxed; const facts = analyzeCallLifetimes(new Map(mod.functions.map((item) => [item.name, item]))); expect(facts.parameters.get("read")).toEqual(new Set([0])); expect(facts.parameters.get("relay")).toEqual(new Set([0])); - const llvm = emitLlvmModule(mod); + const llvm = emitLlvmModule(mod, keepSetElementsBoxed(mod)); const work = body(llvm, "sc_bf_work"); expect(work).toContain("alloca %ScrUnion"); expect(work).toContain("@sc_bf_relay"); diff --git a/packages/compiler/test/string-and-array-lifetimes.test.ts b/packages/compiler/test/string-and-array-lifetimes.test.ts index f22abd753..c042b2518 100644 --- a/packages/compiler/test/string-and-array-lifetimes.test.ts +++ b/packages/compiler/test/string-and-array-lifetimes.test.ts @@ -5,6 +5,7 @@ import { expect, test } from "vitest"; import { compile, deserializeModule, serializeModule, validateModule } from "../src/index.js"; import { analyzeCallLifetimes } from "../src/backend/llvm/call-lifetimes.js"; import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; +import { keepSetElementsBoxed } from "../src/backend/llvm/test-boxed-unions.js"; import { type IrModule } from "../src/ir/ir.js"; /** A pending-exception check: the inline active-cell test or a runtime call. */ @@ -114,14 +115,14 @@ function value(item: Item): number { return item.value; } function work(items: Item[], index: number): number { return value(items[index]); } console.log(work([new Item(7)], 0)); -// Keeps the optional element union a tagged box (Set elements are boxes), +// The Set's element union stays a tagged box under keepSetElementsBoxed, // so the stack-box paths stay exercised; nullable pointers bypass them. const keepBoxed = new Set(); void keepBoxed; `); const value = module.functions.find((fn) => fn.name === "value")!; expect(value.params[0]!.type.kind).toBe("union"); - const llvm = emitLlvmModule(module); + const llvm = emitLlvmModule(module, keepSetElementsBoxed(module)); const work = body(llvm, "sc_bf_work"); expect(work).toContain("alloca %ScrUnion"); expect(work).toContain("@sc_bf_value"); @@ -139,12 +140,12 @@ function clear(items: Item[]): number { items.length = 0; return 1; } function work(items: Item[]): number { return value(items[0], clear(items)); } console.log(work([new Item(7)])); -// Keeps the optional element union a tagged box (Set elements are boxes), +// The Set's element union stays a tagged box under keepSetElementsBoxed, // so the stack-box paths stay exercised; nullable pointers bypass them. const keepBoxed = new Set(); void keepBoxed; `); - const llvm = emitLlvmModule(module); + const llvm = emitLlvmModule(module, keepSetElementsBoxed(module)); const work = body(llvm, "sc_bf_work"); expect(work).toContain("alloca %ScrUnion"); expect(work).toContain("@sc_retain_Item"); @@ -172,12 +173,12 @@ function label(item: Item): string { return item.label; } function work(items: Item[]): string { return label(items[0]); } console.log(work([new Item("kept")])); -// Keeps the optional element union a tagged box (Set elements are boxes), +// The Set's element union stays a tagged box under keepSetElementsBoxed, // so the stack-box paths stay exercised; nullable pointers bypass them. const keepBoxed = new Set(); void keepBoxed; `); - const llvm = emitLlvmModule(module); + const llvm = emitLlvmModule(module, keepSetElementsBoxed(module)); expect(body(llvm, "sc_bf_work")).toContain("alloca %ScrUnion"); expect(body(llvm, "sc_bf_label")).toContain("@scr_str_retain_v"); expect(body(llvm, "sc_bf_label")).not.toContain("@scr_union_retain_v"); @@ -202,12 +203,12 @@ function pair(left: Item, right: Item): number { return left.value + right.value function work(items: Item[]): number { return pair(items[0], items[1]) + pair(items[1], items[0]); } console.log(work([new Item(), new Item()])); -// Keeps the optional element union a tagged box (Set elements are boxes), +// The Set's element union stays a tagged box under keepSetElementsBoxed, // so the stack-box paths stay exercised; nullable pointers bypass them. const keepBoxed = new Set(); void keepBoxed; `); - const work = body(emitLlvmModule(module), "sc_bf_work"); + const work = body(emitLlvmModule(module, keepSetElementsBoxed(module)), "sc_bf_work"); expect(work.match(/alloca %ScrUnion/g)).toHaveLength(4); expect(work.match(/@sc_bf_pair/g)).toHaveLength(2); expect(work).not.toContain("@scr_union_new_ref"); @@ -347,14 +348,13 @@ function count(parts: Part[], probe: Part): number { const parts = [new Part()]; console.log(same(parts, 0, parts[0]), missing(parts, 3), weight(parts, 2), count(parts, parts[0])); `; - // Keeps the optional element union a tagged box (Set elements are boxes), + // The Set's element union stays a tagged box under keepSetElementsBoxed, // so the stack-tag paths stay exercised; nullable pointers bypass them. - const boxed = emitLlvmModule( - await lower(`${source} + const kept = await lower(`${source} const keepBoxed = new Set(); void keepBoxed; -`), - ); +`); + const boxed = emitLlvmModule(kept, keepSetElementsBoxed(kept)); const nullable = emitLlvmModule(await lower(source)); for (const name of ["same", "missing", "weight", "count"]) { const work = body(boxed, `sc_bf_${name}`);