From c1bb553964ddfe547c74932ad5ecef14c622147f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 08:30:09 -0500 Subject: [PATCH 1/4] Give ProgramLifetime class instances a 32-bit header and packed fields --- .../compiler/src/backend/llvm/classes.test.ts | 72 ++++- packages/compiler/src/backend/llvm/classes.ts | 256 +++++++++++++++--- packages/compiler/src/backend/llvm/emitter.ts | 33 ++- packages/compiler/src/backend/llvm/publish.ts | 82 ++++-- packages/runtime/src/scr_message.c | 2 +- packages/runtime/src/scr_publish.c | 18 +- packages/runtime/src/scr_rc.h | 22 ++ .../4635-contract-program-lifetime-layouts.ts | 224 +++++++++++++++ ...-contract-program-lifetime-layouts.ts.json | 1 + 9 files changed, 625 insertions(+), 85 deletions(-) create mode 100644 tests/corpus/4635-contract-program-lifetime-layouts.ts create mode 100644 tests/harness/corpus-costs/4635-contract-program-lifetime-layouts.ts.json diff --git a/packages/compiler/src/backend/llvm/classes.test.ts b/packages/compiler/src/backend/llvm/classes.test.ts index 8a2a4659a..3c2b0095c 100644 --- a/packages/compiler/src/backend/llvm/classes.test.ts +++ b/packages/compiler/src/backend/llvm/classes.test.ts @@ -7,6 +7,7 @@ import { mangleClassReleaseDirect, mangleClassRelease, mangleClassRetain, + mangleClassStruct, mangleClassTrace, mangleRecordRelease, } from "../mangle.js"; @@ -129,18 +130,19 @@ test("ProgramLifetime hierarchies are never freed and take no reference counts", expect(body(mangleClassRetain(cls)), cls).toMatch(/entry:\n {2}ret ptr %o\n}/); expect(body(mangleClassRelease(cls)), cls).toMatch(/entry:\n {2}ret void\n}/); // new: no collector header (no descriptor, no scr_cyc_alloc) and the - // program-lifetime count 2^31 under the class id (Node's preorder number - // 0 + 1, Leaf's 1 + 1), not SIZE_MAX (which means published). + // 32-bit header: the tag 0x70000000 and the class id (Node's preorder + // number 0 + 1, Leaf's 1 + 1), without a count. const nw = body(mangleClassNew(cls)); const id = cls === "Node" ? 1 : 2; - expect(nw, cls).toContain(`store i64 ${id * 2 ** 32 + 2 ** 31}, ptr %o.vt`); - expect(nw, cls).not.toContain("store i64 -1, ptr %o"); + expect(nw, cls).toContain(`store i32 ${0x70000000 + id}, ptr %o.vt`); + expect(nw, cls).not.toContain("store i64"); expect(nw, cls).not.toContain("scr_cyc_alloc"); expect(llvm, cls).not.toContain(`@${mangleClassTrace(cls)}.cyctype`); // The RC audit (sanitize builds) learns that the program keeps // program-lifetime objects; other builds emit nothing for it. expect(nw, cls).toContain("call void @scr_obj_lifetime_note(ptr %o)"); } + expect(llvm).toContain(`%${mangleClassStruct("Leaf")} = type { i32, ptr }`); const plain = emitLlvmModule(mod, { pointerBits: 64, objectAudit: false }); expect(plain).not.toContain("scr_obj_lifetime_note"); // The mortal Owner keeps its header, counts and possible-root step. @@ -149,6 +151,44 @@ test("ProgramLifetime hierarchies are never freed and take no reference counts", expect(body(mangleClassRelease("Owner"))).toContain("load i32, ptr %bufp"); }); +test("ProgramLifetime classes pack their fields after a 32-bit header", () => { + const node = { kind: "object" as const, className: "Node" }; + const leaf = { kind: "object" as const, className: "Leaf" }; + const BOOL = { kind: "bool" as const }; + const nodeFields = [ + { name: "parent", type: node }, + { name: "flag", type: BOOL }, + { name: "weight", type: F64 }, + ]; + const mod: IrModule = { + irVersion: 17, + sourceFile: loc.file, + entry: "main", + functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }], + classes: [ + { name: "Node", fields: nodeFields, methods: [], loc }, + { + name: "Leaf", + base: "Node", + fields: [...nodeFields, { name: "next", type: leaf }, { name: "mark", type: BOOL }], + methods: [], + loc, + }, + ], + contracts: { programLifetime: ["Node"] }, + }; + const llvm = emitLlvmModule(mod, { pointerBits: 64 }); + // Fields go widest first, each at the first offset that fits it. Node's + // weight (an int32 slot: nothing stores a fraction) takes the word the + // header shares (24 bytes, not 32); Leaf keeps Node's offsets and puts its + // bool next to Node's (32 bytes, not 48). + expect(llvm).toContain(`%${mangleClassStruct("Node")} = type { i32, i32, ptr, i8 }`); + expect(llvm).toContain(`%${mangleClassStruct("Leaf")} = type { i32, i32, ptr, i8, i8, ptr }`); + // Without the contract, the layout is the declared one. + const mortal = emitLlvmModule({ ...mod, contracts: {} }, { pointerBits: 64 }); + expect(mortal).toContain(`%${mangleClassStruct("Leaf")} = type { i64, ptr, i8, i32, ptr, i8 }`); +}); + test("published ProgramLifetime instances use the published mark every other object uses", () => { const node = { kind: "object" as const, className: "Node" }; const leaf = { kind: "object" as const, className: "Leaf" }; @@ -212,19 +252,25 @@ test("published ProgramLifetime instances use the published mark every other obj contracts: { programLifetime: ["Node"] }, }; const llvm = emitLlvmModule(mod, { pointerBits: 64 }); - // The walker marks each instance like a mortal object (SIZE_MAX, logged so - // a refusal restores the program-lifetime count); untraced, so it never - // leaves a collector buffer. + // The walker marks each instance by the top bit of its 32-bit header, + // logging the word so a refusal restores it; untraced, so it never leaves + // a collector buffer. expect(llvm).toContain("call void @scr_publish(ptr"); - // Kind 4 (an object) | 0x100 (the rc word keeps a class id: the mark - // writes only the count). - expect(llvm).toMatch(/@scr_pub_mark\(ptr %ctx, ptr %o, i1 false, i32 260\)/); - // The frozen-object guard of the store tests the count against UINT32_MAX: - // an unpublished instance (2^31) stays writable. + // Kind 4 (an object) | 0x200 (the 32-bit header: the mark sets its top + // bit). + expect(llvm).toMatch(/@scr_pub_mark\(ptr %ctx, ptr %o, i1 false, i32 516\)/); + const plain = emitLlvmModule(mod, { pointerBits: 64, objectAudit: false }); + expect(plain).toContain("%mpub = or i32 %mrc, -2147483648"); + // Pushing a reference skips one published before: a set top bit. + expect(llvm).toMatch(/%immortal0 = icmp slt i32 %rc0, 0/); + // The walker dispatches on the class id in the header's low 28 bits. + expect(llvm).toContain("%id = and i32 %idw, 268435455"); + // The frozen-object guard of the store tests the top bit: an unpublished + // instance stays writable. const store = llvm.split(/^define /m).find((fn) => fn.includes("call void @scr_throw_published_field(")) ?? ""; - expect(store).toMatch(/icmp eq i32 %\S+, -1/); + expect(store).toMatch(/icmp slt i32 %\S+, 0/); expect(store).toContain("call void @scr_publish(ptr"); // 32-bit targets use 2^31. const llvm32 = emitLlvmModule(mod, { pointerBits: 32 }); diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index 5ad29430d..061bd06a2 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -32,6 +32,7 @@ import { } from "../mangle.js"; import { cycTypeDescriptor, emitObjectAlloc, emitObjectFree } from "./alloc.js"; import { llvmCommentText } from "./common.js"; +import { INLINE_UNION_FIELD } from "./inline-unions.js"; import { NullableRefFields } from "./nullable-fields.js"; import type { Int32Slots } from "../../ir/int32-slots.js"; import { @@ -88,6 +89,15 @@ export class LlClassMeta { * bits, and the hierarchy's vtables are one array indexed by the id * (mangleVtArray). */ idInRc = false; + /** A ProgramLifetime class with the 32-bit header (assignCompactLayouts): + * the i32 at offset 0 is compactHeaderWord, with the class id in place of + * the rc word's high half, and the fields are packed after it. */ + compact = false; + /** Compact classes: the struct's elements by offset (the header, field + * -1, then the fields by their index in def.fields). */ + compactElems: { offset: number; size: number; align: number; type: string; field: number }[] = []; + /** Compact classes: each field's GEP index, by its index in def.fields. */ + compactSlots: number[] = []; constructor(readonly def: IrClassDef) { this.root = this; @@ -371,6 +381,10 @@ export function classSlotWordIndex(meta: LlClassMeta): number { /** The GEP index where a class's own fields start: after the header words * and the slot word. */ export function classFieldBase(meta: LlClassMeta): number { + if (meta.compact) + throw new InternalCompilerError( + `llvm emitter bug: compact class ${meta.def.name} packs its fields`, + ); return headerEnd(meta) + (meta.threadSlot ? 1 : 0); } @@ -387,7 +401,12 @@ export function classFieldIndex(meta: LlClassMeta, field: string): { index: numb throw new InternalCompilerError( `llvm emitter bug: unknown field ${field} on class ${meta.def.name}`, ); - return { index: classFieldBase(meta) + idx, type: meta.def.fields[idx]!.type }; + return { index: classFieldSlot(meta, idx), type: meta.def.fields[idx]!.type }; +} + +/** The GEP index of the field at `idx` in `meta.def.fields`. */ +export function classFieldSlot(meta: LlClassMeta, idx: number): number { + return meta.compact ? meta.compactSlots[idx]! : classFieldBase(meta) + idx; } /** Lines that release the ThreadSlots values of the object `%o` before it @@ -472,8 +491,7 @@ export function classPreLines( ): string[] { if (meta.idInRc) return [ - ` ${pre}.idp = getelementptr inbounds i8, ptr ${obj}, i64 ${CLASS_ID_OFFSET}`, - ` ${pre}.id = load i32, ptr ${pre}.idp`, + ...classIdLoadLines(meta, obj, `${pre}.id`, ""), ` ${pre}.n = add i32 ${pre}.id, -1 ; the class id is the preorder number + 1`, ` ${pre} = zext i32 ${pre}.n to ${sizeType}`, ]; @@ -499,14 +517,33 @@ export function classIdVtLines( const vtt = `%${mangleVtStruct(root.def.name)}`; const n = root.post - root.pre + 1; return [ - ` ${t}idp = getelementptr inbounds i8, ptr ${obj}, i64 ${CLASS_ID_OFFSET}`, - ` ${t}id = load i32, ptr ${t}idp${group}`, + ...classIdLoadLines(meta, `${obj}`, `${t}id`, group), ` ${t}ix = sub i32 ${t}id, ${root.pre + 1}`, ` ${t}ix64 = zext i32 ${t}ix to i64`, ` ${vt} = getelementptr inbounds [${n} x ${vtt}], ptr @${mangleVtArray(root.def.name)}, i64 0, i64 ${t}ix64`, ]; } +/** Lines that load the class id of the LlClassMeta.idInRc instance `obj` + * into the i32 `id`: the high half of the rc word, or the low 28 bits of a + * compact instance's header word (compactHeaderWord). */ +export function classIdLoadLines( + meta: LlClassMeta, + obj: string, + id: string, + group: string, +): string[] { + if (meta.compact) + return [ + ` ${id}.w = load i32, ptr ${obj}${group}`, + ` ${id} = and i32 ${id}.w, ${COMPACT_ID_MASK}`, + ]; + return [ + ` ${id}p = getelementptr inbounds i8, ptr ${obj}, i64 ${CLASS_ID_OFFSET}`, + ` ${id} = load i32, ptr ${id}p${group}`, + ]; +} + /** The byte offset of the class id in the rc word of an instance that * keeps it there (LlClassMeta.idInRc): the high half of the little-endian * 64-bit word. The count is the i32 at offset 0. */ @@ -522,6 +559,124 @@ export function classIdRcWord(meta: LlClassMeta, lifetime: boolean): string { return (BigInt(meta.pre + 1) * 2n ** 32n + (lifetime ? 2n ** 31n : 1n)).toString(); } +/** The 32-bit header of a compact class instance (LlClassMeta.compact): + * COMPACT_HEADER_TAG, the class id (classIdRcWord's preorder number + 1, 0 + * outside a hierarchy) in the low 28 bits, and COMPACT_PUBLISHED once + * publication marks the object. No other object's count reaches + * 0x70000000, so the runtime reads a word of 0xf0000000 and up as published + * for every object (scr_rc.h SCR_RC_PUBLISHED). */ +export const COMPACT_HEADER_TAG = 0x70000000; +export const COMPACT_PUBLISHED = 0x80000000; +export const COMPACT_ID_MASK = 0x0fffffff; + +/** The header word a compact instance is created with. */ +export function compactHeaderWord(meta: LlClassMeta): number { + return COMPACT_HEADER_TAG | (meta.hierarchy ? meta.pre + 1 : 0); +} + +/** Byte size and alignment of a class field's LLVM storage type. */ +function fieldStorage(type: string): { size: number; align: number } { + switch (type) { + case "i8": + return { size: 1, align: 1 }; + case "i32": + return { size: 4, align: 4 }; + case "ptr": + case "double": + return { size: 8, align: 8 }; + case INLINE_UNION_FIELD: + return { size: 16, align: 8 }; + default: + throw new InternalCompilerError(`llvm emitter bug: class field storage ${type}`); + } +} + +/** The LLVM storage type of each of a class's flattened fields. */ +export function classFieldTypes(host: ShapeHost, cls: IrClassDef, int32?: Int32Slots): string[] { + // Whole-program int32 fields (int32-slots.ts) store the exact i32. + return cls.fields.map((f) => + int32?.isField(cls.name, f.name) + ? "i32" + : llFieldType(f.type, host.inlineUnions.classField(cls.name, f.name)), + ); +} + +/** Gives the classes `compact` admits (whole hierarchies: the test is the + * root's) the 32-bit header and packed fields. A class keeps its base's + * field offsets, and places each of its own fields, widest first, at the + * first offset after the header that fits it: in a hole another field's + * alignment left, in the base's tail padding, or at the end. So 32-bit and + * smaller fields share 8-byte words. The struct lists the header and the + * fields by offset, which LLVM's layout of the type reproduces (checked). */ +export function assignCompactLayouts( + metaMap: ReadonlyMap, + compact: (root: LlClassMeta) => boolean, + fieldTypes: (cls: IrClassDef) => string[], +): void { + const byPre = [...metaMap.values()].sort((a, b) => a.pre - b.pre); + for (const meta of byPre) { + if (meta.def.runtime === true || !compact(meta.root)) continue; + const base = meta.base; + const inherited = base?.compactElems ?? [ + { offset: 0, size: 4, align: 4, type: "i32", field: -1 }, + ]; + const baseCount = base?.def.fields.length ?? 0; + if (base !== null && (!base.compact || !sameFieldPrefix(base.def, meta.def))) + throw new InternalCompilerError( + `llvm emitter bug: compact class ${meta.def.name} does not extend its base's layout`, + ); + const elems = inherited.map((e) => ({ ...e })); + const types = fieldTypes(meta.def); + const own = types + .map((type, field) => ({ type, field, ...fieldStorage(type) })) + .slice(baseCount) + .sort((a, b) => b.size - a.size || a.field - b.field); + for (const f of own) { + const sorted = [...elems].sort((a, b) => a.offset - b.offset); + let at = -1; + let cursor = 0; + for (const e of sorted) { + const start = alignTo(cursor, f.align); + if (start + f.size <= e.offset) { + at = start; + break; + } + cursor = Math.max(cursor, e.offset + e.size); + } + if (at < 0) at = alignTo(cursor, f.align); + elems.push({ offset: at, size: f.size, align: f.align, type: f.type, field: f.field }); + } + elems.sort((a, b) => a.offset - b.offset); + // LLVM places each element at the next offset its alignment allows. + let end = 0; + for (const e of elems) { + if (alignTo(end, e.align) !== e.offset) + throw new InternalCompilerError( + `llvm emitter bug: compact layout of ${meta.def.name} is not LLVM's`, + ); + end = e.offset + e.size; + } + const slots = new Array(types.length).fill(-1); + elems.forEach((e, index) => { + if (e.field >= 0) slots[e.field] = index; + }); + meta.compact = true; + meta.compactElems = elems; + meta.compactSlots = slots; + } +} + +function alignTo(n: number, align: number): number { + return Math.ceil(n / align) * align; +} + +function sameFieldPrefix(base: IrClassDef, cls: IrClassDef): boolean { + return ( + base.fields.length <= cls.fields.length && + base.fields.every((f, i) => cls.fields[i]!.name === f.name) + ); +} + function undefFieldInits( host: ClassHost, meta: LlClassMeta, @@ -589,12 +744,17 @@ export function emitClassShapes( for (const cls of emitted) { const meta = metaMap.get(cls.name)!; - // Whole-program int32 fields (int32-slots.ts) store the exact i32. - const fieldTys = cls.fields.map((f) => - int32?.isField(cls.name, f.name) - ? "i32" - : llFieldType(f.type, host.inlineUnions.classField(cls.name, f.name)), - ); + if (meta.compact) { + typeDefs.push( + `%${mangleClassStruct(cls.name)} = type { ${meta.compactElems.map((e) => e.type).join(", ")} } ` + + `; class ${cls.name} (32-bit header) { ${meta.compactElems + .slice(1) + .map((e) => llvmCommentText(cls.fields[e.field]!.name)) + .join("; ")} }`, + ); + continue; + } + const fieldTys = classFieldTypes(host, cls, int32); // Subclasses of a runtime base embed its remaining prefix (the state // and display-name slots) between the vtable word and the field list; // stream subclasses add the stream-state pointer — upcasts to @@ -718,7 +878,7 @@ export function emitClassShapes( const traced = host.tracedShapes.has(`object:${cls.name}`); const runtimePrefix = runtimePrefixOf(meta); const isStreamRooted = streamRooted(meta); - const fieldIndex = (i: number): number => classFieldBase(meta) + i; + const fieldIndex = (i: number): number => classFieldSlot(meta, i); // Teardown, trace and gcFree see each slot's storage type: a nullable // class field holds the instance pointer (NULL for the unit arm), and // every class retain/release/visit entry point is NULL-tolerant. @@ -851,30 +1011,41 @@ export function emitClassShapes( ``, ); // Direct release (the vtable entry): the class's own teardown. The - // caller already handled NULL/immortal. - const reld: string[] = [ - `define internal void @${mangleClassReleaseDirect(cls.name)}(ptr %o) ${FN_ATTRS} { ; direct release ${cls.name}`, - `entry:`, - ` %rc = load ${count}, ptr %o`, - ` %n = sub ${count} %rc, 1`, - ` store ${count} %n, ptr %o`, - ` %dead = icmp eq ${count} %n, 0`, - ` br i1 %dead, label %free, label %${traced ? "root" : "done"}`, - `free:`, - ]; - if (traced) reld.push(...cycleDeadLines(host, "teardown")); - if (bounded) { - const destroy = `${mangleClassReleaseDirect(cls.name)}_destroy`; - const destroyBody: string[] = []; - teardown(destroyBody); - defs.push(...destroyDefinitions(destroy, destroyBody, chained), ``); - host.declare(`declare void @scr_rc_destroy(ptr, ptr)`); - reld.push(` call void @scr_rc_destroy(ptr %o, ptr @${destroy})`); - } else teardown(reld); - reld.push(` br label %done`); - if (traced) reld.push(`root:`, ...cycleRootLines(host, "done")); - reld.push(`done:`, ` ret void`, `}`, ``); - defs.push(...reld); + // caller already handled NULL/immortal. A compact instance has no + // count: nothing releases it. + if (meta.compact) { + defs.push( + `define internal void @${mangleClassReleaseDirect(cls.name)}(ptr %o) ${FN_ATTRS} { ; direct release ${cls.name} (immortal)`, + `entry:`, + ` ret void`, + `}`, + ``, + ); + } else { + const reld: string[] = [ + `define internal void @${mangleClassReleaseDirect(cls.name)}(ptr %o) ${FN_ATTRS} { ; direct release ${cls.name}`, + `entry:`, + ` %rc = load ${count}, ptr %o`, + ` %n = sub ${count} %rc, 1`, + ` store ${count} %n, ptr %o`, + ` %dead = icmp eq ${count} %n, 0`, + ` br i1 %dead, label %free, label %${traced ? "root" : "done"}`, + `free:`, + ]; + if (traced) reld.push(...cycleDeadLines(host, "teardown")); + if (bounded) { + const destroy = `${mangleClassReleaseDirect(cls.name)}_destroy`; + const destroyBody: string[] = []; + teardown(destroyBody); + defs.push(...destroyDefinitions(destroy, destroyBody, chained), ``); + host.declare(`declare void @scr_rc_destroy(ptr, ptr)`); + reld.push(` call void @scr_rc_destroy(ptr %o, ptr @${destroy})`); + } else teardown(reld); + reld.push(` br label %done`); + if (traced) reld.push(`root:`, ...cycleRootLines(host, "done")); + reld.push(`done:`, ` ret void`, `}`, ``); + defs.push(...reld); + } } else { const freeBody: string[] = []; teardown(freeBody); @@ -930,7 +1101,18 @@ export function emitClassShapes( meta.hierarchy && !meta.idInRc ? 16 : 8, ), ); - if (meta.idInRc) { + if (meta.compact && !meta.hierarchy) + nw.push(` store i32 ${compactHeaderWord(meta)}, ptr %o ; no count (compactHeaderWord)`); + else if (meta.compact) { + // The header word: the class id and no count (compactHeaderWord). + // Only publication writes it, setting COMPACT_PUBLISHED, which the + // class id reads mask off, so they keep !invariant.group. + host.declare(`declare ptr @llvm.launder.invariant.group.p0(ptr)`); + nw.push( + ` %o.vt = call ptr @llvm.launder.invariant.group.p0(ptr %o)`, + ` store i32 ${compactHeaderWord(meta)}, ptr %o.vt ; class ${llvmCommentText(cls.name)} is ${meta.pre}`, + ); + } else if (meta.idInRc) { // The class id in the high half of the rc word, the count in the low // half (classIdRcWord). It never changes while the object lives: // retains, releases and the runtime write only the low half. diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 18cee0501..0382efc17 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -264,7 +264,9 @@ import { emitLibCall, } from "./lib-dispatch.js"; import { + assignCompactLayouts, buildClassGraph, + classFieldTypes, classMembershipIntervals, classFieldIndex, classPreLines, @@ -1492,6 +1494,15 @@ export class LlEmitter { classDefinitions: (cls, sizeOf) => regions.classDefs(cls, sizeOf), }; } + // ProgramLifetime hierarchies count nothing: their instances get the + // 32-bit header and packed fields (classes.ts LlClassMeta.compact), + // except where regions count them or a slot word follows the header. + if (this.sizeType === "i64" && mod.lifetimeRegions !== true) + assignCompactLayouts( + this.classMeta, + (root) => programLifetimeClass(this.shapeHost, root) && !root.threadSlot, + (cls) => classFieldTypes(this.shapeHost, cls, this.int32Slots), + ); for (const [name, rec] of RUNTIME_ERROR_CLASSES) { const meta = this.classMeta.get(name); if (!meta) break; // hand-written IR without the builtin defs: no stamps @@ -5114,6 +5125,7 @@ export class LlEmitter { post: meta.post, runtimeRooted: meta.root.def.runtime === true, idInRc: meta.idInRc, + compact: meta.compact, // Inline scalar-union fields are plain bits: nothing to push. fields: meta.def.fields.flatMap((f) => this.inlineUnions.classField(className, f.name) @@ -5154,15 +5166,21 @@ export class LlEmitter { /** Branches on "`obj` is published" (rc == SIZE_MAX, or a count of * UINT32_MAX in the low half for `count` i32: classes.ts - * LlClassMeta.idInRc); leaves the builder in the cold block and answers - * the continuation label. */ - private emitPublishedTest(obj: string, count: "i32" | "i64" = this.sizeType): string { + * LlClassMeta.idInRc, or the top bit of a compact header word for + * "compact": classes.ts COMPACT_PUBLISHED); leaves the builder in the cold + * block and answers the continuation label. */ + private emitPublishedTest(obj: string, count: "i32" | "i64" | "compact" = this.sizeType): string { const B = this.B; const rc = B.tmp(), frozen = B.tmp(), expected = B.tmp(); - B.line(`${rc} = load ${count}, ptr ${obj}`); - B.line(`${frozen} = icmp eq ${count} ${rc}, -1`); + if (count === "compact") { + B.line(`${rc} = load i32, ptr ${obj}`); + B.line(`${frozen} = icmp slt i32 ${rc}, 0`); + } else { + B.line(`${rc} = load ${count}, ptr ${obj}`); + B.line(`${frozen} = icmp eq ${count} ${rc}, -1`); + } this.declare(`declare i1 @llvm.expect.i1(i1, i1)`); B.line(`${expected} = call i1 @llvm.expect.i1(i1 ${frozen}, i1 false)`); const cold = B.newLabel("pub.frozen"), @@ -5203,7 +5221,10 @@ export class LlEmitter { if (className !== null && !published.classGuarded(className)) return; const B = this.B; const meta = className === null ? undefined : this.classMeta.get(className); - const ok = this.emitPublishedTest(obj, meta?.idInRc === true ? "i32" : this.sizeType); + const ok = this.emitPublishedTest( + obj, + meta?.compact === true ? "compact" : meta?.idInRc === true ? "i32" : this.sizeType, + ); let owner: string; if (meta === undefined) owner = this.cstr("Object"); else if (!meta.hierarchy) owner = this.cstr(meta.def.jsName ?? meta.def.name); diff --git a/packages/compiler/src/backend/llvm/publish.ts b/packages/compiler/src/backend/llvm/publish.ts index 15221bbc3..ebe9b1a64 100644 --- a/packages/compiler/src/backend/llvm/publish.ts +++ b/packages/compiler/src/backend/llvm/publish.ts @@ -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 { COMPACT_ID_MASK } from "./classes.js"; import { shapeTagFn } from "./nullable-unions.js"; import type { InlineUnionFields } from "./inline-unions.js"; import { LlvmUnsupportedError } from "./unsupported.js"; @@ -66,6 +67,8 @@ export interface PublishClassInfo { /** Instances keep their class id in the rc word (classes.ts * LlClassMeta.idInRc). */ idInRc: boolean; + /** Instances have the 32-bit header (classes.ts LlClassMeta.compact). */ + compact: boolean; /** The class's own and inherited fields: GEP index and storage type. */ fields: { name: string; index: number; type: IrType }[]; } @@ -76,6 +79,11 @@ const PUB_KIND_UNION = 5; /** SCR_PUB_CLASS_ID, or'ed into a kind: the object keeps its class id * in the high half of its rc word (classes.ts LlClassMeta.idInRc). */ const PUB_CLASS_ID = 0x100; +/** SCR_PUB_COMPACT, or'ed into a kind: the object has the 32-bit header + * (classes.ts compactHeaderWord), whose top bit is the mark. */ +const PUB_COMPACT = 0x200; +/** COMPACT_PUBLISHED as an i32 constant. */ +const COMPACT_MARK = "-2147483648"; /** SCR_PUB_LOG_COUNT (scr_publish.c), in a log entry's `buf`: `rc` is the * count of the low half. */ const PUB_LOG_COUNT = "-2147483648"; @@ -223,7 +231,11 @@ export class PublishWalkers { // hold time-value bits that no walker may push. 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 }])); + const live = arms.flatMap((a) => + a.fn === null + ? [] + : [{ tag: a.tag, fn: a.fn, compact: this.compactRef(def.arms[a.tag]!) }], + ); 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. @@ -256,7 +268,7 @@ export class PublishWalkers { for (const a of live) lines.push( `arm${a.tag}:`, - ...this.push("%slot", a.fn, `u${a.tag}`), + ...this.push("%slot", a.fn, `u${a.tag}`, a.compact), ` br label %done`, ); } @@ -316,18 +328,25 @@ export class PublishWalkers { const lines = [ head, `entry:`, - ...(info.idInRc + ...(info.compact ? [ - ` %idp = getelementptr inbounds i8, ptr %o, i64 4 ; the class id (classes.ts classIdRcWord)`, - ` %id = load i32, ptr %idp`, + ` %idw = load i32, ptr %o ; the class id (classes.ts compactHeaderWord)`, + ` %id = and i32 %idw, ${COMPACT_ID_MASK}`, ` %idpre = add i32 %id, -1`, ` %pre = zext i32 %idpre to ${this.host.sizeType}`, ] - : [ - ` %vtp = getelementptr inbounds ptr, ptr %o, i64 1`, - ` %vt = load ptr, ptr %vtp`, - ` %pre = load ${this.host.sizeType}, ptr %vt`, - ]), + : info.idInRc + ? [ + ` %idp = getelementptr inbounds i8, ptr %o, i64 4 ; the class id (classes.ts classIdRcWord)`, + ` %id = load i32, ptr %idp`, + ` %idpre = add i32 %id, -1`, + ` %pre = zext i32 %idpre to ${this.host.sizeType}`, + ] + : [ + ` %vtp = getelementptr inbounds ptr, ptr %o, i64 1`, + ` %vt = load ptr, ptr %vtp`, + ` %pre = load ${this.host.sizeType}, ptr %vt`, + ]), ` switch ${this.host.sizeType} %pre, label %other [ ${cases.map((c) => `${this.host.sizeType} ${c.pre}, label %c${c.pre}`).join(" ")} ]`, ]; for (const c of cases) @@ -363,6 +382,7 @@ export class PublishWalkers { path, loc, info.idInRc, + info.compact, ); } @@ -374,23 +394,26 @@ export class PublishWalkers { * targets), and an object with a collector header leaves its candidate * buffer through scr_cyc_pub_take. Anything else calls scr_pub_mark, as * does every mark of an RC-audit build. */ - private markLines(cyc: boolean, kind: number, classId = false): string[] { + private markLines(cyc: boolean, kind: number, classId = false, compact = false): string[] { + const flags = compact ? PUB_COMPACT : classId ? PUB_CLASS_ID : 0; const call = (name: string): string[] => [ - ` %${name} = call zeroext i1 @scr_pub_mark(ptr %ctx, ptr %o, i1 ${cyc ? "true" : "false"}, i32 ${classId ? kind | PUB_CLASS_ID : kind})`, + ` %${name} = call zeroext i1 @scr_pub_mark(ptr %ctx, ptr %o, i1 ${cyc ? "true" : "false"}, i32 ${kind | flags})`, ` br i1 %${name}, label %walk, label %done`, ]; if (this.host.rcAudit === true) return call("m"); const S = this.host.sizeType; // An object that keeps its class id in the rc word is marked and // logged through its count, the low half. - const C = classId ? "i32" : S; + // A compact object's header word is logged whole (the restore writes + // the low 32 bits back) and marked by its top bit. + const C = classId || compact ? "i32" : S; const field = (name: string, slot: number): string[] => [ ` %${name}p = getelementptr inbounds ptr, ptr %ctx, i32 ${slot}`, ` %${name} = load ${S === "i64" ? "i64" : "i32"}, ptr %${name}p`, ]; const lines = [ ` %mrc = load ${C}, ptr %o`, - ` %mimm = icmp eq ${C} %mrc, -1`, + compact ? ` %mimm = icmp slt i32 %mrc, 0` : ` %mimm = icmp eq ${C} %mrc, -1`, ` br i1 %mimm, label %done, label %mtest`, `mtest:`, ` %mrefp = getelementptr inbounds ptr, ptr %ctx, i32 6`, @@ -408,7 +431,7 @@ export class PublishWalkers { ok = "%mok2"; } lines.push(` br i1 ${ok}, label %mfast, label %mslow`, `mslow:`, ...call("ms"), `mfast:`); - let buf = classId ? String(PUB_LOG_COUNT) : "0"; + let buf = classId || compact ? String(PUB_LOG_COUNT) : "0"; if (cyc) { // ScrCycHdr.buf, the word before the object: its top bit while the // object is a buffered candidate. @@ -443,7 +466,9 @@ export class PublishWalkers { ` store i32 ${buf}, ptr %mebuf`, ` %mlogged1 = add ${S} %mlogged, 1`, ` store ${S} %mlogged1, ptr %mloggedp`, - ` store ${C} -1, ptr %o`, + ...(compact + ? [` %mpub = or i32 %mrc, ${COMPACT_MARK}`, ` store i32 %mpub, ptr %o`] + : [` store ${C} -1, ptr %o`]), ` br label %walk`, ); return lines; @@ -453,15 +478,18 @@ export class PublishWalkers { * immortal references (the parent links of a tree, values published * before) are the ones scr_pub_push skips: they are tested here, without * the call. The lines end in a fresh block labelled `pushed${id}`. */ - private push(v: string, fn: string, id: string): string[] { + private push(v: string, fn: string, id: string, compact = false): string[] { return [ ` %null${id} = icmp eq ptr ${v}, null`, ` br i1 %null${id}, label %pushed${id}, label %live${id}`, `live${id}:`, // The count (the low half of a 64-bit rc word) is UINT32_MAX for an - // immortal object of every kind (scr_rc.h SCR_RC_COUNT). + // immortal object of every kind (scr_rc.h SCR_RC_COUNT), and a + // compact object's header word has its top bit set once published. ` %rc${id} = load i32, ptr ${v}`, - ` %immortal${id} = icmp eq i32 %rc${id}, -1`, + compact + ? ` %immortal${id} = icmp slt i32 %rc${id}, 0` + : ` %immortal${id} = icmp eq i32 %rc${id}, -1`, ` br i1 %immortal${id}, label %pushed${id}, label %push${id}`, `push${id}:`, ...this.pushLines(v, fn, id), @@ -497,6 +525,12 @@ export class PublishWalkers { ]; } + /** Whether references of type `t` are compact class instances. */ + private compactRef(t: IrType): boolean { + const arm = this.host.nullableArm(t) ?? t; + return arm.kind === "object" && this.host.classInfo(arm.className).compact; + } + private objectBody( head: string, struct: string, @@ -505,8 +539,14 @@ export class PublishWalkers { path: string, loc: SrcLoc, classId = false, + compact = false, ): string[] { - const lines = [head, `entry:`, ...this.markLines(traced, PUB_KIND_OBJECT, classId), `walk:`]; + const lines = [ + head, + `entry:`, + ...this.markLines(traced, PUB_KIND_OBJECT, classId, compact), + `walk:`, + ]; let n = 0; for (const member of members) { const fn = this.walker(member.type, `${path}.${member.name}`, loc); @@ -519,7 +559,7 @@ export class PublishWalkers { lines.push( ` %f${n} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${member.index} ; ${llvmComment(member.name)}`, ` %v${n} = load ptr, ptr %f${n}`, - ...this.push(`%v${n}`, fn, `${n}`), + ...this.push(`%v${n}`, fn, `${n}`, this.compactRef(member.type)), ); n++; } diff --git a/packages/runtime/src/scr_message.c b/packages/runtime/src/scr_message.c index 750dcc97d..cd0e92740 100644 --- a/packages/runtime/src/scr_message.c +++ b/packages/runtime/src/scr_message.c @@ -282,7 +282,7 @@ static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input) /* Published values (immortal) travel by pointer: a typed value as a * capsule over the same object, a dyn graph as itself. */ if (input->kind == SCR_DYN_TYPED_REF && input->v.typed_ref.ptr && - SCR_RC_COUNT(input->v.typed_ref.ptr) == SCR_RC_IMMORTAL) { + SCR_RC_PUBLISHED(input->v.typed_ref.ptr)) { scr_sa_thread_shares(); encoder->visits[id].view = (ScrDyn *)input; ScrMessageNode *node = &encoder->message->nodes[id]; diff --git a/packages/runtime/src/scr_publish.c b/packages/runtime/src/scr_publish.c index 0cbb02c8f..218f94d51 100644 --- a/packages/runtime/src/scr_publish.c +++ b/packages/runtime/src/scr_publish.c @@ -42,7 +42,8 @@ typedef struct { } ScrPubLog; /* In ScrPubLog.buf: the object keeps its class id in the high half of - * its rc word (SCR_PUB_CLASS_ID), and `rc` is the count of the low half. */ + * its rc word (SCR_PUB_CLASS_ID), and `rc` is the count of the low half, + * or it is compact (SCR_PUB_COMPACT), and `rc` is its header word. */ #define SCR_PUB_LOG_COUNT UINT32_C(0x80000000) #define SCR_PUB_DYN ((uintptr_t)1) @@ -108,7 +109,7 @@ static void *scr_pub_grow(void *p, size_t *cap, size_t size, void *inline_buf) { void scr_pub_str_fn(void *v, ScrPub *ctx); void scr_pub_push(ScrPub *ctx, void *v, ScrPubFn fn) { - if (!v || SCR_RC_COUNT(v) == SCR_RC_IMMORTAL || ctx->refusal) return; + if (!v || SCR_RC_PUBLISHED(v) || ctx->refusal) return; if (fn == scr_pub_str_fn) { /* a leaf: mark it now */ scr_pub_str_fn(v, ctx); return; @@ -125,9 +126,11 @@ void scr_pub_push(ScrPub *ctx, void *v, ScrPubFn fn) { * touch the objects again. */ bool scr_pub_mark(ScrPub *ctx, void *obj, bool cyc, int kind) { size_t *rc = obj; - if (SCR_RC_COUNT(obj) == SCR_RC_IMMORTAL || ctx->refusal) return false; - bool class_id = (kind & SCR_PUB_CLASS_ID) != 0; - kind &= ~SCR_PUB_CLASS_ID; + if (SCR_RC_PUBLISHED(obj) || ctx->refusal) return false; + bool compact = (kind & SCR_PUB_COMPACT) != 0; + /* Both log the low 32 bits: a count, or a compact header word. */ + bool class_id = compact || (kind & SCR_PUB_CLASS_ID) != 0; + kind &= ~(SCR_PUB_CLASS_ID | SCR_PUB_COMPACT); if (ctx->logged == ctx->log_cap) ctx->log = scr_pub_grow(ctx->log, &ctx->log_cap, sizeof *ctx->log, ctx->log_inline); #ifdef SCR_RC_AUDIT @@ -156,7 +159,8 @@ bool scr_pub_mark(ScrPub *ctx, void *obj, bool cyc, int kind) { } #endif *e = (ScrPubLog){(uintptr_t)obj, logged, buf}; - if (class_id) SCR_RC_COUNT(obj) = SCR_RC_IMMORTAL; + if (compact) SCR_RC_COUNT(obj) |= SCR_RC_COMPACT_PUBLISHED; + else if (class_id) SCR_RC_COUNT(obj) = SCR_RC_IMMORTAL; else *rc = SIZE_MAX; return true; } @@ -382,7 +386,7 @@ static bool scr_pub_keep_ok(size_t bytes) { } void scr_publish(void *root, ScrPubFn fn) { - if (!root || SCR_RC_COUNT(root) == SCR_RC_IMMORTAL) return; + if (!root || SCR_RC_PUBLISHED(root)) return; ScrPub ctx; if (scr_pub_keep.stack) { ctx.stack = scr_pub_keep.stack; diff --git a/packages/runtime/src/scr_rc.h b/packages/runtime/src/scr_rc.h index 889202975..9ce6e385d 100644 --- a/packages/runtime/src/scr_rc.h +++ b/packages/runtime/src/scr_rc.h @@ -27,6 +27,24 @@ typedef size_t ScrRcCount; #define SCR_RC_IMMORTAL ((ScrRcCount)-1) #define SCR_RC_COUNT(obj) (*(ScrRcCount *)(obj)) +/* Compact class instances (64-bit targets; llvm/classes.ts + * LlClassMeta.compact): the instances of a ProgramLifetime hierarchy count + * nothing, so their header is one 32-bit word instead of the rc word, and + * their fields start at offset 4. The word is SCR_RC_COMPACT_TAG, the class + * id (0 outside a hierarchy) in the low 28 bits, and SCR_RC_COMPACT_PUBLISHED + * once publication marks it. No count reaches the tag, so a word read through + * SCR_RC_COUNT tells the two apart, and a count or word of + * SCR_RC_PUBLISHED_MIN and up is published for every object. Only emitted + * code holds compact instances in typed slots; the runtime reaches them + * through their emitted adapters, publication walkers (SCR_PUB_COMPACT) and + * the published test. */ +#define SCR_RC_COMPACT_TAG UINT32_C(0x70000000) +#define SCR_RC_COMPACT_PUBLISHED UINT32_C(0x80000000) +#define SCR_RC_COMPACT_ID_MASK UINT32_C(0x0fffffff) +#define SCR_RC_PUBLISHED_MIN ((ScrRcCount)UINT32_C(0xf0000000)) +/* Whether `obj`, an object of any kind, is published (immortal). */ +#define SCR_RC_PUBLISHED(obj) (SCR_RC_COUNT(obj) >= SCR_RC_PUBLISHED_MIN) + /* The class id in the high half of `obj`'s rc word, or 0 for an object * without one: the high half of any other object's word is 0, or 0xffffffff * (immortal) or 0x80000000 (program-lifetime), and a class id is never one of @@ -636,6 +654,10 @@ enum { * the high half (SCR_RC_COUNT). The mark, and a refusal's restore, write * only the count. */ SCR_PUB_CLASS_ID = 0x100, + /* Or'ed into a kind: a compact class instance (SCR_RC_COMPACT_TAG). The + * mark sets SCR_RC_COMPACT_PUBLISHED; a refusal's restore writes the + * logged word back. */ + SCR_PUB_COMPACT = 0x200, }; void scr_publish(void *root, ScrPubFn fn); void scr_pub_push(ScrPub *ctx, void *v, ScrPubFn fn); diff --git a/tests/corpus/4635-contract-program-lifetime-layouts.ts b/tests/corpus/4635-contract-program-lifetime-layouts.ts new file mode 100644 index 000000000..b62b7fa1a --- /dev/null +++ b/tests/corpus/4635-contract-program-lifetime-layouts.ts @@ -0,0 +1,224 @@ +import { publish } from "@scriptc/threads"; +import type { ProgramLifetime } from "@scriptc/types"; + +// ProgramLifetime classes whose fields mix widths: booleans, small integers, +// numbers, strings, references and optional values, declared in an order +// that leaves alignment holes, with subclasses that add fields of each width. +// Field reads and writes, virtual dispatch, instanceof, boxing into unknown, +// thrown instances and publication behave the same before and after the +// objects are published. + +abstract class Node implements ProgramLifetime { + readonly kind: number; + flagged = false; + readonly pos: number; + end: number; + parent: Node | undefined = undefined; + constructor(kind: number, pos: number, end: number) { + this.kind = kind; + this.pos = pos; + this.end = end; + } + abstract label(): string; + width(): number { + return this.end - this.pos; + } +} + +class Ident extends Node { + readonly text: string; + id = 0; + escaped = false; + constructor(pos: number, text: string) { + super(1, pos, pos + text.length); + this.text = text; + } + label(): string { + return `Ident(${this.text}#${this.id}${this.escaped ? "!" : ""})`; + } +} + +class Literal extends Node { + readonly value: number; + negative: boolean; + radix = 10; + constructor(pos: number, value: number) { + super(2, pos, pos + String(value).length); + this.value = value; + this.negative = value < 0; + } + label(): string { + return `Literal(${this.value.toString(this.radix)}${this.negative ? "-" : ""})`; + } +} + +class Binary extends Node { + readonly left: Node; + readonly right: Node; + operator: string; + precedence = 0; + parenthesized = false; + constructor(left: Node, operator: string, right: Node) { + super(3, left.pos, right.end); + this.left = left; + this.right = right; + this.operator = operator; + left.parent = this; + right.parent = this; + } + label(): string { + const inner = `${this.left.label()} ${this.operator} ${this.right.label()}`; + return this.parenthesized ? `(${inner})` : inner; + } +} + +class Tagged extends Ident { + tag: string | undefined = undefined; + weight = 0.5; + marked = false; + label(): string { + return `Tagged(${super.label()}, ${this.tag ?? "-"}, ${this.weight}, ${this.marked})`; + } +} + +class Flow implements ProgramLifetime { + flags: number; + node: Node | undefined; + antecedent: Flow | undefined; + reachable = true; + constructor(flags: number, node: Node | undefined, antecedent: Flow | undefined) { + this.flags = flags; + this.node = node; + this.antecedent = antecedent; + } +} + +function describe(n: Node): string { + const parent = n.parent === undefined ? "-" : String(n.parent.kind); + return `${n.label()} k${n.kind} ${n.pos}-${n.end} w${n.width()} ${n.flagged ? "F" : "f"} up ${parent}`; +} + +function classify(value: unknown): string { + if (value instanceof Tagged) return `tagged ${value.tag ?? "-"}`; + if (value instanceof Ident) return `ident ${value.text}`; + if (value instanceof Literal) return `literal ${value.value}`; + if (value instanceof Binary) return `binary ${value.operator}`; + if (value instanceof Flow) return `flow ${value.flags}`; + return typeof value; +} + +function isNode(value: unknown): boolean { + return value instanceof Node; +} + +function isLiteral(value: unknown): boolean { + return value instanceof Literal; +} + +function walk(n: Node, out: string[]): void { + out.push(describe(n)); + if (n instanceof Binary) { + walk(n.left, out); + walk(n.right, out); + } +} + +function build(prefix: string): { root: Binary; flows: Flow[]; idents: Ident[] } { + const a = new Ident(0, `${prefix}a`); + a.id = 7; + const b = new Tagged(4, `${prefix}b`); + b.tag = "t"; + b.weight = 1.25; + b.marked = true; + b.escaped = true; + const lit = new Literal(8, -255); + lit.radix = 16; + const sum = new Binary(a, "+", b); + sum.precedence = 12; + sum.parenthesized = true; + const root = new Binary(sum, "*", lit); + root.precedence = 13; + root.flagged = true; + const flows: Flow[] = []; + let prev: Flow | undefined = undefined; + for (const n of [a, b, lit, sum, root]) { + prev = new Flow(flows.length * 3 + 1, n, prev); + flows.push(prev); + } + flows[2]!.reachable = false; + return { root, flows, idents: [a, b] }; +} + +function report(side: string, g: { root: Binary; flows: Flow[]; idents: Ident[] }): void { + const out: string[] = []; + walk(g.root, out); + for (const line of out) console.log(side, line); + for (const f of g.flows) { + const node = f.node === undefined ? "-" : classify(f.node); + const before = f.antecedent === undefined ? "-" : String(f.antecedent.flags); + console.log(side, "flow", f.flags, f.reachable, node, before); + } + const boxed: unknown[] = [g.root, g.root.left, g.idents[1], g.flows[0], 3, "s"]; + console.log(side, boxed.map(classify).join(", ")); +} + +function attempt(what: string, write: () => void): void { + try { + write(); + console.log(what, "wrote"); + } catch (e) { + console.log(what, (e as Error).name, (e as Error).message); + } +} + +const g = build("x"); +report("before", g); + +// Writes to fields that share an 8-byte word with their neighbours. +g.idents[0]!.id = 9; +g.idents[0]!.escaped = true; +const tagged = g.idents[1] as Tagged; +tagged.marked = false; +tagged.weight = -2; +g.root.flagged = false; +g.flows[1]!.flags = 40; +report("written", g); + +// A thrown instance keeps its class. +try { + throw g.idents[1]; +} catch (e) { + console.log("caught", classify(e), isNode(e), isLiteral(e)); +} + +// Instances as map keys and set members. +const seen = new Map(); +const visited = new Set(); +for (const f of g.flows) { + if (f.node !== undefined) seen.set(f.node, (seen.get(f.node) ?? 0) + f.flags); + visited.add(f); +} +console.log("keys", [...seen.entries()].map(([n, v]) => `${n.kind}:${v}`).join(" "), visited.size); + +const published = publish(g); +console.log("same", published === g, publish(g.root) === g.root); +report("published", published); +attempt("ident id", () => { + published.idents[0]!.id = 1; +}); +attempt("tagged flag", () => { + (published.idents[1] as Tagged).marked = true; +}); +attempt("root flag", () => { + published.root.flagged = true; +}); +attempt("flow reachable", () => { + published.flows[2]!.reachable = true; +}); +report("after writes", published); + +// New instances next to published ones stay writable. +const fresh = new Binary(new Ident(16, "y"), "-", new Literal(20, 3)); +fresh.precedence = 11; +fresh.right.flagged = true; +console.log("fresh", describe(fresh), describe(fresh.right), classify(fresh.left), classify(published.root)); diff --git a/tests/harness/corpus-costs/4635-contract-program-lifetime-layouts.ts.json b/tests/harness/corpus-costs/4635-contract-program-lifetime-layouts.ts.json new file mode 100644 index 000000000..2b790bed2 --- /dev/null +++ b/tests/harness/corpus-costs/4635-contract-program-lifetime-layouts.ts.json @@ -0,0 +1 @@ +{ "plain": 4944, "san": 7176 } From d78bd871f68f7e038c056cb71298d8a73c5de1a1 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 09:16:06 -0500 Subject: [PATCH 2/4] Clear recycled blocks for compact instances and keep TypeScript 7 parity --- packages/compiler/src/backend/llvm/alloc.ts | 7 ++- .../compiler/src/backend/llvm/classes.test.ts | 25 ++++++++ packages/compiler/src/backend/llvm/classes.ts | 4 +- ...-contract-program-lifetime-layouts.ts.json | 6 ++ .../4635-contract-program-lifetime-layouts.ts | 61 +++++++++++++++++++ ...-contract-program-lifetime-layouts.ts.json | 2 +- 6 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4635-contract-program-lifetime-layouts.ts.json diff --git a/packages/compiler/src/backend/llvm/alloc.ts b/packages/compiler/src/backend/llvm/alloc.ts index c1fc5998d..c28a63e62 100644 --- a/packages/compiler/src/backend/llvm/alloc.ts +++ b/packages/compiler/src/backend/llvm/alloc.ts @@ -269,8 +269,9 @@ export function cycTypeDescriptor(name: string, trace: string, free: string, kin * expression) in `%o`, aborting on OOM, with the RC-audit allocation note * on the out-of-line path. With `cyc`, the object carries a collector * header (scr_cyc_alloc's contract). `stored` is the number of leading - * object bytes the caller stores unconditionally right after (at least the - * refcount word), which a recycled block need not clear. Labels use the + * object bytes the caller stores unconditionally right after (the refcount + * word, or the 4-byte header of a compact class instance), which a recycled + * block need not clear; it is a multiple of 4 and at most `size`. Labels use the * `sa.` prefix; the lines end in an open block. */ export function emitObjectAlloc( @@ -350,7 +351,7 @@ function allocLines( ` store ptr %sa.next, ptr %sa.fp`, ` %sa.z = getelementptr inbounds i8, ptr %sa.b, i64 ${keep}`, ` %sa.zn = sub i64 %sa.n, ${keep}`, - ` call void @llvm.memset.p0.i64(ptr align 8 %sa.z, i8 0, i64 %sa.zn, i1 false)`, + ` call void @llvm.memset.p0.i64(ptr align ${keep % 8 === 0 ? 8 : 4} %sa.z, i8 0, i64 %sa.zn, i1 false)`, ` br label %sa.fast`, `sa.bump: ; fresh reservation memory is already zero`, ` %sa.c1 = add i64 %sa.c, 1`, diff --git a/packages/compiler/src/backend/llvm/classes.test.ts b/packages/compiler/src/backend/llvm/classes.test.ts index 3c2b0095c..2f554b458 100644 --- a/packages/compiler/src/backend/llvm/classes.test.ts +++ b/packages/compiler/src/backend/llvm/classes.test.ts @@ -189,6 +189,31 @@ test("ProgramLifetime classes pack their fields after a 32-bit header", () => { expect(mortal).toContain(`%${mangleClassStruct("Leaf")} = type { i64, ptr, i8, i32, ptr, i8 }`); }); +test("a recycled block is cleared from the end of a ProgramLifetime instance's 32-bit header", () => { + const BOOL = { kind: "bool" as const }; + const mod: IrModule = { + irVersion: 17, + sourceFile: loc.file, + entry: "main", + functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }], + classes: [ + { name: "Marker", fields: [], methods: [], loc }, + { name: "Toggle", fields: [{ name: "on", type: BOOL }], methods: [], loc }, + ], + contracts: { programLifetime: ["Marker", "Toggle"] }, + }; + const llvm = emitLlvmModule(mod, { pointerBits: 64 }); + for (const name of ["Marker", "Toggle"]) { + const start = llvm.indexOf(`define internal ptr @${mangleClassNew(name)}(`); + const body = llvm.slice(start, llvm.indexOf("\n}\n", start)); + // The constructor stores the 4-byte header itself; a 4-byte instance + // (Marker) clears nothing, and Toggle's field at offset 4 is cleared. + expect(body).toContain("%sa.z = getelementptr inbounds i8, ptr %sa.b, i64 4"); + expect(body).toContain("%sa.zn = sub i64 %sa.n, 4"); + expect(body).toContain("call void @llvm.memset.p0.i64(ptr align 4 %sa.z,"); + } +}); + test("published ProgramLifetime instances use the published mark every other object uses", () => { const node = { kind: "object" as const, className: "Node" }; const leaf = { kind: "object" as const, className: "Leaf" }; diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index 061bd06a2..1634f1f9e 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -1098,7 +1098,9 @@ export function emitClassShapes( host, sizeOf, traced && !lifetime ? { type: cycType } : null, - meta.hierarchy && !meta.idInRc ? 16 : 8, + // The words stored next: a compact instance's 4-byte header, the + // rc word, or the rc word and the vtable word. + meta.compact ? 4 : meta.hierarchy && !meta.idInRc ? 16 : 8, ), ); if (meta.compact && !meta.hierarchy) diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4635-contract-program-lifetime-layouts.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4635-contract-program-lifetime-layouts.ts.json new file mode 100644 index 000000000..31a42c38b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4635-contract-program-lifetime-layouts.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4635-contract-program-lifetime-layouts.ts" + ], + "diags": [] +} diff --git a/tests/corpus/4635-contract-program-lifetime-layouts.ts b/tests/corpus/4635-contract-program-lifetime-layouts.ts index b62b7fa1a..7b300091e 100644 --- a/tests/corpus/4635-contract-program-lifetime-layouts.ts +++ b/tests/corpus/4635-contract-program-lifetime-layouts.ts @@ -222,3 +222,64 @@ const fresh = new Binary(new Ident(16, "y"), "-", new Literal(20, 3)); fresh.precedence = 11; fresh.right.flagged = true; console.log("fresh", describe(fresh), describe(fresh.right), classify(fresh.left), classify(published.root)); + +// Instances in recycled blocks: freed 8- and 16-byte objects leave their +// blocks on the allocator's free lists, and the next instances of the same +// sizes reuse them. Marker has no fields (a 4-byte instance); Toggle's +// field and Pair's first field sit right after the header. +class Scrap { + readonly n: number; + constructor(n: number) { + this.n = n; + } +} + +class Blank {} + +class Marker implements ProgramLifetime {} + +class Toggle implements ProgramLifetime { + on: boolean; + constructor(on: boolean) { + this.on = on; + } +} + +class Pair implements ProgramLifetime { + readonly low: number; + readonly high: Node | undefined; + constructor(low: number, high: Node | undefined) { + this.low = low | 0; + this.high = high; + } +} + +const blanks: Blank[] = []; +const scraps: Scrap[] = []; +const markers: Marker[] = []; +const toggles: Toggle[] = []; +const pairs: Pair[] = []; +for (let round = 0; round < 3; round++) { + for (let i = 0; i < 200; i++) { + blanks.push(new Blank()); + scraps.push(new Scrap(i * 1.5)); + } + let sum = 0; + for (const s of scraps) sum += s.n; + blanks.length = 0; + scraps.length = 0; + for (let i = 0; i < 200; i++) { + markers.push(new Marker()); + toggles.push(new Toggle(i % 3 === 0)); + pairs.push(new Pair(i - 100, i % 2 === 0 ? undefined : published.root)); + } + let on = 0; + let low = 0; + let high = 0; + for (const t of toggles) if (t.on) on++; + for (const p of pairs) { + low += p.low; + if (p.high !== undefined) high++; + } + console.log("recycled", round, sum, markers.length, on, low, high); +} diff --git a/tests/harness/corpus-costs/4635-contract-program-lifetime-layouts.ts.json b/tests/harness/corpus-costs/4635-contract-program-lifetime-layouts.ts.json index 2b790bed2..4eba5d094 100644 --- a/tests/harness/corpus-costs/4635-contract-program-lifetime-layouts.ts.json +++ b/tests/harness/corpus-costs/4635-contract-program-lifetime-layouts.ts.json @@ -1 +1 @@ -{ "plain": 4944, "san": 7176 } +{ "plain": 4944, "san": 7757 } From 9d898d0affc1a32146c41962ec219a0aec4dea85 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 09:25:11 -0500 Subject: [PATCH 3/4] Name the stored header size for compact instances --- packages/compiler/src/backend/llvm/classes.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index 1634f1f9e..9963fad5b 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -569,6 +569,16 @@ export const COMPACT_HEADER_TAG = 0x70000000; export const COMPACT_PUBLISHED = 0x80000000; export const COMPACT_ID_MASK = 0x0fffffff; +/** The leading bytes of a new instance that its allocation function stores + * right after allocating it (emitObjectAlloc's `stored`), which a recycled + * block therefore need not clear: a compact instance's 4-byte header word + * (its fields start at offset 4), the 8-byte rc word, or the rc word and + * the vtable word of a hierarchy member that keeps its class in a vtable. */ +export function storedHeaderBytes(meta: LlClassMeta): number { + if (meta.compact) return 4; + return meta.hierarchy && !meta.idInRc ? 16 : 8; +} + /** The header word a compact instance is created with. */ export function compactHeaderWord(meta: LlClassMeta): number { return COMPACT_HEADER_TAG | (meta.hierarchy ? meta.pre + 1 : 0); @@ -1098,9 +1108,7 @@ export function emitClassShapes( host, sizeOf, traced && !lifetime ? { type: cycType } : null, - // The words stored next: a compact instance's 4-byte header, the - // rc word, or the rc word and the vtable word. - meta.compact ? 4 : meta.hierarchy && !meta.idInRc ? 16 : 8, + storedHeaderBytes(meta), ), ); if (meta.compact && !meta.hierarchy) From 6fd37b33c5470901f7b2558cf527cbfdc48202af Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 09:55:28 -0500 Subject: [PATCH 4/4] Mark compact header loads invariant only where the header cannot change --- .../compiler/src/backend/llvm/classes.test.ts | 23 ++++++++++++++++++- packages/compiler/src/backend/llvm/classes.ts | 23 ++++++++++++------- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/backend/llvm/classes.test.ts b/packages/compiler/src/backend/llvm/classes.test.ts index 2f554b458..5daaf4300 100644 --- a/packages/compiler/src/backend/llvm/classes.test.ts +++ b/packages/compiler/src/backend/llvm/classes.test.ts @@ -1,6 +1,12 @@ import { expect, test } from "vitest"; import { DYN, F64, STRING, VOID, type IrModule } from "../../ir/ir.js"; -import { buildClassGraph, classMembershipIntervals, emitClassMembershipHelper } from "./classes.js"; +import { + buildClassGraph, + classIdLoadLines, + classMembershipIntervals, + emitClassMembershipHelper, + type LlClassMeta, +} from "./classes.js"; import { emitLlvmModule } from "./emitter.js"; import { mangleClassNew, @@ -214,6 +220,21 @@ test("a recycled block is cleared from the end of a ProgramLifetime instance's 3 } }); +test("invariant class id reads of compact instances leave out the published bit", () => { + const group = ", !invariant.group !{}"; + const compact = (post: number) => ({ compact: true, root: { post } }) as unknown as LlClassMeta; + // Publication sets the header word's top bit; ids below 2^16 are read + // from the low 16 bits, which never change after construction. + expect(classIdLoadLines(compact(40), "%o", "%id", group)).toEqual([ + " %id.h = load i16, ptr %o, !invariant.group !{}", + " %id = zext i16 %id.h to i32", + ]); + // Wider ids, and reads without the metadata, mask the whole word. + const masked = [" %id.w = load i32, ptr %o", " %id = and i32 %id.w, 268435455"]; + expect(classIdLoadLines(compact(0xffff), "%o", "%id", group)).toEqual(masked); + expect(classIdLoadLines(compact(40), "%o", "%id", "")).toEqual(masked); +}); + test("published ProgramLifetime instances use the published mark every other object uses", () => { const node = { kind: "object" as const, className: "Node" }; const leaf = { kind: "object" as const, className: "Leaf" }; diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index 9963fad5b..03ee8caa1 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -526,18 +526,24 @@ export function classIdVtLines( /** Lines that load the class id of the LlClassMeta.idInRc instance `obj` * into the i32 `id`: the high half of the rc word, or the low 28 bits of a - * compact instance's header word (compactHeaderWord). */ + * compact instance's header word (compactHeaderWord). `group` is the + * metadata of the load (`, !invariant.group !{}` for a load LLVM may treat + * as invariant), and it only goes on bytes that never change: publication + * sets the top bit of a compact header word, so a compact instance's id is + * then read as the word's low 16 bits, which hold the whole id when the + * hierarchy's ids are below 2^16, and otherwise as the masked word without + * the metadata. */ export function classIdLoadLines( meta: LlClassMeta, obj: string, id: string, group: string, ): string[] { - if (meta.compact) - return [ - ` ${id}.w = load i32, ptr ${obj}${group}`, - ` ${id} = and i32 ${id}.w, ${COMPACT_ID_MASK}`, - ]; + if (meta.compact) { + if (group !== "" && meta.root.post + 1 <= 0xffff) + return [` ${id}.h = load i16, ptr ${obj}${group}`, ` ${id} = zext i16 ${id}.h to i32`]; + return [` ${id}.w = load i32, ptr ${obj}`, ` ${id} = and i32 ${id}.w, ${COMPACT_ID_MASK}`]; + } return [ ` ${id}p = getelementptr inbounds i8, ptr ${obj}, i64 ${CLASS_ID_OFFSET}`, ` ${id} = load i32, ptr ${id}p${group}`, @@ -1115,8 +1121,9 @@ export function emitClassShapes( nw.push(` store i32 ${compactHeaderWord(meta)}, ptr %o ; no count (compactHeaderWord)`); else if (meta.compact) { // The header word: the class id and no count (compactHeaderWord). - // Only publication writes it, setting COMPACT_PUBLISHED, which the - // class id reads mask off, so they keep !invariant.group. + // Only publication writes it again, setting COMPACT_PUBLISHED in the + // top byte, so the class id reads that carry !invariant.group read + // the low 16 bits only (classIdLoadLines). host.declare(`declare ptr @llvm.launder.invariant.group.p0(ptr)`); nw.push( ` %o.vt = call ptr @llvm.launder.invariant.group.p0(ptr %o)`,