diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.ts b/packages/compiler/src/backend/llvm/call-lifetimes.ts index 17d69e4d6..555e96d7f 100644 --- a/packages/compiler/src/backend/llvm/call-lifetimes.ts +++ b/packages/compiler/src/backend/llvm/call-lifetimes.ts @@ -73,7 +73,11 @@ function eligible(local: IrLocal): boolean { * extracts and retains the payload, so the stored union is only projected. */ export type NullableFieldTest = (className: string, field: string) => boolean; -function collectUses(fn: IrFunction, nullableField: NullableFieldTest): Uses { +function collectUses( + fn: IrFunction, + nullableField: NullableFieldTest, + registerReturn: boolean, +): Uses { const uses: Uses = { invalid: new Set(), written: new Set(), @@ -161,6 +165,10 @@ function collectUses(fn: IrFunction, nullableField: NullableFieldTest): Uses { } function stmt(node: IrStmt, inExpression: boolean): boolean { switch (node.kind) { + case "return": + // A register result reads the returned binding's tag and bits. + if (registerReturn && node.value?.kind === "varRef") return true; + break; case "varDecl": uses.declarations.set(node.localId, (uses.declarations.get(node.localId) ?? 0) + 1); break; @@ -220,6 +228,9 @@ export function analyzeCallLifetimes( /** Rebound parameters that only ever hold borrowed walk pointers * (walk-borrows.ts): they keep the borrowed convention although written. */ walkParameters: ReadonlyMap> = new Map(), + /** Functions returning a scalar union as tag and payload bits + * (Int32Abi.union): `return x` only projects `x`. */ + registerReturns: ReadonlySet = new Set(), ): CallLifetimes { const usesByFunction = new Map(); const lazyCaptures = new LazyCaptures(functions); @@ -234,7 +245,7 @@ export function analyzeCallLifetimes( }; for (const fn of functions.values()) { if (fn.async || fn.generator) continue; - const uses = collectUses(fn, nullableField); + const uses = collectUses(fn, nullableField, registerReturns.has(fn.name)); usesByFunction.set(fn.name, uses); // Class-capturing bodies get only local facts (computed below from // their uses; class captures are already invalid). Binding and diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 29bacf1c1..631bfebc6 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -82,7 +82,13 @@ import { analyzeInt32Slots, type Int32Slots } from "../../ir/int32-slots.js"; import { analyzeFinalFields, constructorClass, type FinalFields } from "../../ir/final-fields.js"; import { foldConstantGlobals } from "../../ir/constant-globals.js"; import { findIntegerViews } from "./integer-views.js"; -import { findInt32Abis, INT32_ABI_SUFFIX, type Int32Abi } from "./int32-abi.js"; +import { + findInt32Abis, + INT32_ABI_SUFFIX, + SCALAR_UNION_DUMMY, + SCALAR_UNION_RESULT, + type Int32Abi, +} from "./int32-abi.js"; import { findInt32Locals, type Int32Local } from "./int32-locals.js"; import { exactInteger, narrowInteger } from "./integer-values.js"; import { findInitializerBindings, withInitializerBindings } from "../../ir/initializer-bindings.js"; @@ -119,10 +125,13 @@ import { analyzeCallLifetimes, type CallLifetimes } from "./call-lifetimes.js"; import { canStackUnion, emitStackUnion, findLocalStackUnions } from "./stack-unions.js"; import { allocateLocalUnion, + emitScalarOptional, findLocalUnionStorage, + isScalarOptionalSource, storeLocalUnion, type LocalUnionStorageProof, type LocalUnionStorage, + type UnionSources, } from "./local-union-storage.js"; import { BlockBuilder } from "./blocks.js"; import { NullableRefFields, type NullableRefField } from "./nullable-fields.js"; @@ -610,6 +619,8 @@ export class LlEmitter { * whose bindings end with the list (emitBlock and function bodies). */ private stmtCursor: { stmts: IrStmt[]; index: number } | null = null; readonly optionalArrayReads: OptionalArrayReads; + /** Union sources a binding or projection can hold in a stack box. */ + readonly unionSources: UnionSources; readonly checkedNarrows: CheckedNarrows; callArrayReads = new Map(); mapReadLifetimes: MapReadLifetimes = { locals: new Map(), arguments: new Map() }; @@ -843,6 +854,9 @@ export class LlEmitter { currentReturnType: IrType = VOID; /** The current body returns its number result as i32. */ private currentInt32Result = false; + /** The function's i32 entry returns its scalar union result as tag and + * payload bits (Int32Abi.union). */ + private currentUnionResult = false; /** Active only while emitting a body lowered with LLVM's * switched-coroutine intrinsics (wasm32 async bodies and generators, * native synchronous generators; see coroLowered). */ @@ -967,6 +981,13 @@ export class LlEmitter { this.optionalArrayReads = new OptionalArrayReads(this.fnByName, this.unionsById, (id) => this.nullableUnions.has(id), ); + this.unionSources = { + unions: this.boxedUnionsById, + receiverUnions: this.unionsById, + nullableField: (e) => this.nullableFieldGet(e) !== null, + arrayRead: (e) => this.optionalArrayReads.get(e) !== null, + scalarCall: (e) => this.scalarResultCall(e), + }; this.checkedNarrows = new CheckedNarrows(this.fnByName); this.referenceEffects = new ReferenceEffects( this.fnByName, @@ -1026,6 +1047,7 @@ export class LlEmitter { this.fnByName, (className, field) => this.nullableFields.get(className, field) !== null, walkParameters, + new Set([...this.int32Abis].filter(([, abi]) => abi.union).map(([name]) => name)), ); this.constantCallbacks = findConstantCallbacks(mod, this.callLifetimes); this.stackCallbacks = new StackCallbacks(this.fnByName); @@ -4138,11 +4160,13 @@ export class LlEmitter { ? "ret void" : this.currentInt32Result ? "ret i32 0" - : t === "double" - ? `ret double ${f64Lit(0)}` - : t === "i1" - ? "ret i1 false" - : "ret ptr null"; + : this.currentUnionResult + ? `ret ${SCALAR_UNION_DUMMY}` + : t === "double" + ? `ret double ${f64Lit(0)}` + : t === "i1" + ? "ret i1 false" + : "ret ptr null"; } // Copy the entries now: later declarations extend these lexical scopes. // Release order and the handler are part of the shared block's identity. @@ -5434,12 +5458,7 @@ export class LlEmitter { this.debug === null ? findScalarStringSlices(fn, this.callLifetimes) : new Map(); this.mapReadLifetimes = findMapReadLifetimes(fn, this.boxedUnionsById, this.callLifetimes); this.localStackUnions = findLocalStackUnions(fn, this.callLifetimes, this.boxedUnionsById); - this.localUnionStorageProofs = findLocalUnionStorage( - fn, - this.callLifetimes, - this.boxedUnionsById, - (e) => this.nullableFieldGet(e) !== null, - ); + this.localUnionStorageProofs = findLocalUnionStorage(fn, this.callLifetimes, this.unionSources); this.localUnionStorage = new Map(); this.walkBorrows = this.walkBorrowsByFunction.get(fn.name) ?? new Set(); this.integerRanges = analyzeIntegerRanges(numericFn, this.int32Slots.facts(fn.name)); @@ -5465,6 +5484,7 @@ export class LlEmitter { this.currentReturnType = fn.returnType; const int32Abi = this.int32Abis.get(fn.name); this.currentInt32Result = int32Abi?.result === true; + this.currentUnionResult = int32Abi?.union === true; this.currentGenerator = fn.generator ?? null; this.currentAsyncGenerator = fn.async === true && fn.generator !== undefined; this.currentBorrowedReturn = this.borrowedReturns.has(fn.name); @@ -5734,7 +5754,11 @@ export class LlEmitter { }); // Lifted functions receive their closure first (the callValue ABI). if (fn.captures !== undefined) params.unshift("ptr %sc_env"); - const ret = int32Abi?.result ? "i32" : this.llType(fn.returnType); + const ret = int32Abi?.result + ? "i32" + : int32Abi?.union + ? SCALAR_UNION_RESULT + : this.llType(fn.returnType); let attrs = coro !== null ? "#1" : FN_ATTRS; // The shared indexed-read helper (lowerSafeIndexRead) is a dense bounds // test, a state test and a load behind cold runtime fallbacks, called @@ -5771,7 +5795,18 @@ export class LlEmitter { } else args.push(`${type} %p${index}`); }); const ret = this.llType(fn.returnType); - const call = `call ${abi.result ? "i32" : ret} @${symbol}${INT32_ABI_SUFFIX}(${args.join(", ")})`; + const entry = `@${symbol}${INT32_ABI_SUFFIX}(${args.join(", ")})`; + if (abi.union) { + // Box the register pair for every caller of the ordinary ABI. + const B = new BlockBuilder(); + this.B = B; + for (const line of lines) B.line(line.trim()); + const pair = B.tmp(); + B.line(`${pair} = call ${SCALAR_UNION_RESULT} ${entry}`); + B.terminate(`ret ptr ${this.boxScalarUnion(pair, fn.returnType)}`); + return `define internal ptr @${symbol}(${params.join(", ")}) ${FN_ATTRS} {\n${B.render()}\n}`; + } + const call = `call ${abi.result ? "i32" : ret} ${entry}`; if (ret === "void") lines.push(` ${call}`, " ret void"); else if (abi.result) lines.push(` %r = ${call}`, ` %d = sitofp i32 %r to double`, ` ret double %d`); @@ -5779,6 +5814,86 @@ export class LlEmitter { return `define internal ${ret} @${symbol}(${params.join(", ")}) ${FN_ATTRS} {\nentry:\n${lines.join("\n")}\n}`; } + /** The SCALAR_UNION_RESULT pair of a `%ScrUnion` box of `type`. */ + scalarUnionPair(box: string, type: IrType): string { + const B = this.B; + const unionId = type.kind === "union" ? type.unionId : ""; + const tag = this.unionTag(box, unionId); + const payload = B.tmp(), + bits = B.tmp(), + partial = B.tmp(), + pair = B.tmp(); + B.line(`${payload} = getelementptr inbounds %ScrUnion, ptr ${box}, i32 0, i32 5`); + B.line(`${bits} = load i64, ptr ${payload}`); + B.line(`${partial} = insertvalue ${SCALAR_UNION_RESULT} poison, i32 ${tag}, 0`); + B.line(`${pair} = insertvalue ${SCALAR_UNION_RESULT} ${partial}, i64 ${bits}, 1`); + return pair; + } + + /** A private stack box holding a SCALAR_UNION_RESULT pair of `type`. */ + stackScalarUnion(pair: string, type: IrType): LlValue { + const B = this.B; + const box = B.slot(), + tagSlot = B.slot(), + payloadSlot = B.slot(); + B.entryAllocas.push(`${box} = alloca %ScrUnion`); + B.entryAllocas.push(`${tagSlot} = getelementptr inbounds %ScrUnion, ptr ${box}, i32 0, i32 1`); + B.entryAllocas.push( + `${payloadSlot} = getelementptr inbounds %ScrUnion, ptr ${box}, i32 0, i32 5`, + ); + const tag = B.tmp(), + bits = B.tmp(); + B.line(`${tag} = extractvalue ${SCALAR_UNION_RESULT} ${pair}, 0`); + B.line(`${bits} = extractvalue ${SCALAR_UNION_RESULT} ${pair}, 1`); + B.line(`store i32 ${tag}, ptr ${tagSlot}`); + B.line(`store i64 ${bits}, ptr ${payloadSlot}`); + return { name: box, type }; + } + + /** A direct call whose i32 entry returns its scalar union in registers. */ + scalarResultCall(e: IrExpr): boolean { + return e.kind === "call" && this.int32Abis.get(e.callee)?.union === true; + } + + /** A fresh heap box (+1) for a SCALAR_UNION_RESULT pair of `type`; the + * exception dummy (tag -1) answers NULL. */ + boxScalarUnion(pair: string, type: IrType): string { + const B = this.B; + const unionId = type.kind === "union" ? type.unionId : ""; + const arms = this.unionsById.get(unionId)!.arms; + const tag = B.tmp(), + bits = B.tmp(); + B.line(`${tag} = extractvalue ${SCALAR_UNION_RESULT} ${pair}, 0`); + B.line(`${bits} = extractvalue ${SCALAR_UNION_RESULT} ${pair}, 1`); + const join = B.newLabel("union.box.join"), + none = B.newLabel("union.box.none"); + const labels = arms.map(() => B.newLabel("union.box.arm")); + B.terminate( + `switch i32 ${tag}, label %${none} [ ${labels.map((label, armTag) => `i32 ${armTag}, label %${label}`).join(" ")} ]`, + ); + const incoming: string[] = []; + arms.forEach((arm, armTag) => { + B.startBlock(labels[armTag]!); + let value: string; + if (isUnitType(arm)) value = this.unitInstanceRef(unionId, armTag); + else { + const payload = B.tmp(); + if (arm.kind === "bool") B.line(`${payload} = trunc i64 ${bits} to i1`); + else B.line(`${payload} = bitcast i64 ${bits} to double`); + value = this.unionNewOwned(unionId, armTag, { name: payload, type: arm }); + } + incoming.push(`[ ${value}, %${B.currentLabel()} ]`); + B.br(join); + }); + B.startBlock(none); + incoming.push(`[ null, %${none} ]`); + B.br(join); + B.startBlock(join); + const box = B.tmp(); + B.line(`${box} = phi ptr ${incoming.join(", ")}`); + return box; + } + /** The i32 bits of a number proven an int32 (a direct call's i32 * argument or an i32 result): its exact integer when the value carries * one, else the conversion the proof makes exact. */ @@ -7317,8 +7432,16 @@ export class LlEmitter { break; } let v: LlValue | null = null; - const moved = this.finallyStack.length === 0 ? this.movableReturn(s.value) : null; - if (moved !== null) { + const moved = + this.finallyStack.length === 0 && !this.currentUnionResult + ? this.movableReturn(s.value) + : null; + if (s.value !== null && this.currentUnionResult) { + // The tag and payload bits leave in registers; a heap box the + // value needed is released with its frame below. + const box = this.emitUnionProjection(s.value); + v = { name: this.scalarUnionPair(box.name, s.value.type), type: s.value.type }; + } else if (moved !== null) { // Returning an owned local hands its reference to the caller: no // retain now and no release of the local below. const t = B.tmp(); @@ -7359,7 +7482,7 @@ export class LlEmitter { } if (this.finallyStack.length > 0) { let pretSlot: string | null = null; - if (v !== null && isRefCounted(v.type)) { + if (v !== null && isRefCounted(v.type) && !this.currentUnionResult) { pretSlot = B.slot(); B.entryAllocas.push(`${pretSlot} = alloca ptr ; pending return (through finally)`); B.line(`store ptr ${v.name}, ptr ${pretSlot}`); @@ -7395,6 +7518,7 @@ export class LlEmitter { } else if (v === null) B.terminate("ret void"); else if (this.currentInt32Result) B.terminate(`ret i32 ${this.int32Argument(v, s.value!)}`); + else if (this.currentUnionResult) B.terminate(`ret ${SCALAR_UNION_RESULT} ${v.name}`); else B.terminate(`ret ${this.llType(s.value!.type)} ${v.name}`); } break; @@ -8328,6 +8452,17 @@ export class LlEmitter { B.line(`${value} = load ptr, ptr ${slot}`); return { name: value, type: e.type }; } + // Scalar optional values and register results answer a private stack + // box. A ternary of borrowable arms above keeps lending its arm. + if ( + (e.kind === "optChain" || + e.kind === "nullish" || + e.kind === "ternary" || + e.kind === "arrIntrinsic") && + isScalarOptionalSource(e, this.unionSources) + ) + return emitScalarOptional(this, e); + if (e.kind === "call" && this.scalarResultCall(e)) return emitCallExpr(this, e, false, true); if (e.kind === "call" && this.canBorrowReceiver(e)) return emitBorrowedResultCall(this, e); return this.emitExpr(e); } @@ -8344,7 +8479,9 @@ export class LlEmitter { return ( canStackUnion(e, this.boxedUnionsById) || matchMapRead(e, this.boxedUnionsById) !== null || - this.optionalArrayReads.get(e) !== null + this.optionalArrayReads.get(e) !== null || + isScalarOptionalSource(e, this.unionSources) || + this.scalarResultCall(e) ); } diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index ccfd05875..b38ae0bb1 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -9,7 +9,7 @@ import { type IrExpr, type IrFunction, } from "../../ir/ir.js"; -import { INT32_ABI_SUFFIX } from "./int32-abi.js"; +import { INT32_ABI_SUFFIX, SCALAR_UNION_RESULT } from "./int32-abi.js"; import { collectFfiRetainedOps, parseFfiCallbackKey } from "../ffi-callbacks.js"; import { mangleAsyncAdoptCall, @@ -30,6 +30,7 @@ import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js"; import { f64Lit, ffiNativeTypeLl, ffiNativeParamLl, ffiNativeReturnLl } from "./common.js"; import { canStackUnion, emitStackUnion } from "./stack-unions.js"; import { emitCallArrayRead } from "./local-array-reads.js"; +import { emitScalarOptional, isScalarOptionalSource } from "./local-union-storage.js"; import { emitStackMapRead } from "./map-read-lifetimes.js"; import { borrowableInputs, @@ -58,6 +59,9 @@ export function emitCallExpr( | "virtualCall" >, awaitEntry: false | "await" | "adopt" = false, + /** A projection consumes a scalar union result (Int32Abi.union): answer + * a private stack box instead of a heap box. */ + stackResult = false, ): LlValue { const B = host.B; switch (e.kind) { @@ -111,6 +115,16 @@ export function emitCallExpr( return result.value; } if (canStackUnion(a, host.boxedUnionsById)) return emitStackUnion(host, a).value; + if ( + (a.kind === "optChain" || + a.kind === "nullish" || + a.kind === "ternary" || + a.kind === "arrIntrinsic") && + isScalarOptionalSource(a, host.unionSources) + ) + return emitScalarOptional(host, a); + if (a.kind === "call" && host.scalarResultCall(a)) + return emitCallExpr(host, a, false, true); if (host.nullableFieldGet(a)) return { name: host.emitOwnedNullableStack(a).box, type: a.type }; } @@ -164,6 +178,13 @@ export function emitCallExpr( return { name: "", type: e.type }; } const t = B.tmp(); + if (abi?.union) { + B.line(`${t} = call ${SCALAR_UNION_RESULT} ${target}(${argList})`); + if (host.mayThrow.has(e.callee)) host.emitPendingCheck(); + if (borrowed) host.releaseFrame(host.frames.pop()!); + if (stackResult) return host.stackScalarUnion(t, e.type); + return host.own({ name: host.boxScalarUnion(t, e.type), type: e.type }); + } if (abi?.result) { B.line(`${t} = call i32 ${target}(${argList})`); const out = host.int32Result(t, e.type); diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index 0b6c060db..fe6170c39 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -513,6 +513,9 @@ export function emitStableReceiver( export function emitArrIntrinsic( host: LlvmEmitterContext, e: IrExpr & { kind: "arrIntrinsic" }, + /** A `pop`/`shift` of number or boolean elements stores its scalar + * union result's tag and payload bits here instead of boxing it. */ + into?: { box: string; tag: string; payload: string }, ): LlValue { const B = host.B; // Every array intrinsic borrows its receiver, including mutation and @@ -656,6 +659,25 @@ export function emitArrIntrinsic( const raw = B.tmp(); const value = B.tmp(); B.line(`${raw} = load i64, ptr ${rawSlot}`); + if (into) { + // Slot bits for f64; a canonical 0/1 for bool, like scr_union_new_bool. + let bits = raw; + if (elem.kind === "bool") { + const nonzero = B.tmp(); + bits = B.tmp(); + B.line(`${nonzero} = icmp ne i64 ${raw}, 0`); + B.line(`${bits} = zext i1 ${nonzero} to i64`); + } + B.line(`store i32 ${tag}, ptr ${into.tag}`); + B.line(`store i64 ${bits}, ptr ${into.payload}`); + B.br(lj); + B.startBlock(la); + B.line(`store i32 ${undefTag}, ptr ${into.tag}`); + B.line(`store i64 0, ptr ${into.payload}`); + B.br(lj); + B.startBlock(lj); + return { name: into.box, type: e.type }; + } if (elem.kind === "f64") B.line(`${value} = bitcast i64 ${raw} to double`); else if (elem.kind === "bool") B.line(`${value} = icmp ne i64 ${raw}, 0`); else B.line(`${value} = inttoptr i64 ${raw} to ptr`); diff --git a/packages/compiler/src/backend/llvm/expr-control.ts b/packages/compiler/src/backend/llvm/expr-control.ts index 44ecd81c4..298ea0199 100644 --- a/packages/compiler/src/backend/llvm/expr-control.ts +++ b/packages/compiler/src/backend/llvm/expr-control.ts @@ -193,81 +193,14 @@ export function emitControlExpr( return host.own({ name: t, type: e.type }); } - if (e.receiver.type.kind !== "union") - throw new LlvmUnsupportedError(`optChain:${e.receiver.type.kind}`, e.loc); - const def = host.unionsById.get(e.receiver.type.unionId); - if (!def) - throw new InternalCompilerError( - `llvm emitter bug: optChain of unknown union ${e.receiver.type.unionId}`, - ); - const unitTags = def.arms.flatMap((a, i) => (isUnitType(a) ? [i] : [])); - const narrowIdx = def.arms.findIndex((a) => !isUnitType(a)); - if (unitTags.length === 0 || narrowIdx < 0) - throw new InternalCompilerError("llvm emitter bug: optChain union arms"); - const multiple = def.arms.length - unitTags.length > 1; - const narrowed = multiple ? e.receiver.type : def.arms[narrowIdx]!; - // `owner.field?.member`: a receiver projected from a borrowable owner - // needs no reference of its own while the body cannot remove an edge - // (so nothing can overwrite the projection before the body finishes). - const borrowReceiver = - !multiple && - isRefCounted(narrowed) && - host.canBorrowReceiver(e.receiver) && - host.referenceEffects.preserves(e.body); - // An optional array read of a nullable-pointer union is the element - // pointer itself, read inline and owned by the statement frame until - // the body finishes; the bind slot borrows it like an emitExpr value. - const nullableRead = - !borrowReceiver && - !multiple && - host.nullableUnions.has(def.id) && - host.optionalArrayReads.get(e.receiver) !== null; - // A single present arm is only tested and extracted (+1) before the - // body runs, so a fresh receiver box can stay on the stack. - const stacked = - !borrowReceiver && !nullableRead && !multiple && host.canStackReceiver(e.receiver); - let r: LlValue; - const nullableField = borrowReceiver ? host.nullableFieldGet(e.receiver) : null; - if (nullableField !== null && e.receiver.kind === "fieldGet") { - // A nullable-pointer field stores no union: test and peek the - // pointer itself, through a private stack box when the union's own - // representation is boxed (emitUnionProjection's form). - const owner = host.emitReadReceiver(e.receiver.obj); - const { ptr } = host.classFieldPtr(owner.name, e.receiver.className, e.receiver.field); - const p = B.tmp(); - B.line(`${p} = load ptr, ptr ${ptr}${host.fieldAliasAttachment(ptr)}`); - r = { - name: host.nullableUnions.get(def.id) ? p : host.stackNullableBox(p, nullableField), - type: e.receiver.type, - }; - } else if (borrowReceiver || stacked || nullableRead) r = host.emitReadReceiver(e.receiver); - else r = host.emitExpr(e.receiver); - // The bind slot borrows a ref payload whenever something else keeps it - // alive until the body finishes: the borrowed projection above, or the - // receiver value the statement frame owns (emitExpr). Stack receivers - // may borrow their payload from a container, so they keep their +1. - const borrowBind = !multiple && !stacked && isRefCounted(narrowed); - const bind = B.slot(); - B.entryAllocas.push(`${bind} = alloca ${host.llType(narrowed)}`); - B.line( - `store ${host.llType(narrowed)} ${host.llType(narrowed) === "ptr" ? "null" : host.llType(narrowed) === "double" ? f64Lit(0) : "false"}, ptr ${bind}`, - ); - if (!borrowBind) host.ownSlot(bind, narrowed); - const isUnit = host.tagInSet(r.name, def.id, unitTags); - const extract = (): string => - multiple - ? host.retainValue(r.name, narrowed) - : borrowBind - ? host.unionPeek(r.name, def.id) - : host.unionExtract(r.name, def.id, narrowed); + const { isUnit, bindPresent } = emitOptionalChainReceiver(host, e); if (e.type.kind === "void") { // Statement form (cb?.()): no result value at all. const lb = B.newLabel("oc.b"); const lj = B.newLabel("oc.j"); B.condBr(isUnit, lj, lb); B.startBlock(lb); - B.line(`store ${host.llType(narrowed)} ${extract()}, ptr ${bind}`); - host.chainSlots.set(e.id, { name: bind, type: narrowed, slot: true }); + bindPresent(); host.frames.push([]); host.emitExpr(e.body); host.releaseFrame(host.frames.pop()!); @@ -296,8 +229,7 @@ export function emitControlExpr( B.line(`store ptr ${ur}, ptr ${slotD}`); B.br(lj); B.startBlock(lb); - B.line(`store ${host.llType(narrowed)} ${extract()}, ptr ${bind}`); - host.chainSlots.set(e.id, { name: bind, type: narrowed, slot: true }); + bindPresent(); host.emitBranchInto(slotD, e.body); host.chainSlots.delete(e.id); B.br(lj); @@ -324,8 +256,7 @@ export function emitControlExpr( B.line(`store ptr ${host.unitInstanceRef(e.type.unionId, undefTag)}, ptr ${slot}`); B.br(lj); B.startBlock(lb); - B.line(`store ${host.llType(narrowed)} ${extract()}, ptr ${bind}`); - host.chainSlots.set(e.id, { name: bind, type: narrowed, slot: true }); + bindPresent(); host.emitBranchInto(slot, e.body); host.chainSlots.delete(e.id); B.br(lj); @@ -349,6 +280,34 @@ export function emitControlExpr( // arm (+1 for ref kinds), falsy releases and runs the default. if (e.left.type.kind !== "union") throw new InternalCompilerError("llvm emitter bug: orDefault left is not a union"); + if ( + e.retag === undefined && + !host.nullableUnions.has(e.left.type.unionId) && + host.isStackUnionSource(e.left) + ) { + // A stack box (`arr[i] || d`): test truthiness and extract the + // payload (+1). The box is never retained or released. + const l = host.emitUnionProjection(e.left); + const ty = host.llType(e.type); + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ${ty}`); + const lt = B.newLabel("ord.t"), + lf = B.newLabel("ord.f"), + lj = B.newLabel("ord.j"); + B.condBr(host.truthy(l), lt, lf); + B.startBlock(lt); + B.line( + `store ${ty} ${host.unionExtract(l.name, e.left.type.unionId, e.type)}, ptr ${slot}`, + ); + B.br(lj); + B.startBlock(lf); + host.emitBranchInto(slot, e.right); + B.br(lj); + B.startBlock(lj); + const t = B.tmp(); + B.line(`${t} = load ${ty}, ptr ${slot}`); + return host.own({ name: t, type: e.type }); + } const l = host.emitExpr(e.left); host.moveTemp(l); const ty = host.llType(e.type); @@ -502,6 +461,91 @@ export function emitControlExpr( } } +/** The receiver half of a union-receiver `a?.b`. The receiver evaluates + * once; `isUnit` tests its unit arms, and `bindPresent` fills the chain's + * bind slot with the narrowed payload for the body to read through + * chainRecv. The caller deletes the chain slot after the body. */ +export function emitOptionalChainReceiver( + host: LlvmEmitterContext, + e: ExprOf<"optChain">, +): { isUnit: string; bindPresent: () => void } { + const B = host.B; + if (e.receiver.type.kind !== "union") + throw new LlvmUnsupportedError(`optChain:${e.receiver.type.kind}`, e.loc); + const def = host.unionsById.get(e.receiver.type.unionId); + if (!def) + throw new InternalCompilerError( + `llvm emitter bug: optChain of unknown union ${e.receiver.type.unionId}`, + ); + const unitTags = def.arms.flatMap((a, i) => (isUnitType(a) ? [i] : [])); + const narrowIdx = def.arms.findIndex((a) => !isUnitType(a)); + if (unitTags.length === 0 || narrowIdx < 0) + throw new InternalCompilerError("llvm emitter bug: optChain union arms"); + const multiple = def.arms.length - unitTags.length > 1; + const narrowed = multiple ? e.receiver.type : def.arms[narrowIdx]!; + // `owner.field?.member`: a receiver projected from a borrowable owner + // needs no reference of its own while the body cannot remove an edge + // (so nothing can overwrite the projection before the body finishes). + const borrowReceiver = + !multiple && + isRefCounted(narrowed) && + host.canBorrowReceiver(e.receiver) && + host.referenceEffects.preserves(e.body); + // An optional array read of a nullable-pointer union is the element + // pointer itself, read inline and owned by the statement frame until + // the body finishes; the bind slot borrows it like an emitExpr value. + const nullableRead = + !borrowReceiver && + !multiple && + host.nullableUnions.has(def.id) && + host.optionalArrayReads.get(e.receiver) !== null; + // A single present arm is only tested and extracted (+1) before the + // body runs, so a fresh receiver box can stay on the stack. + const stacked = + !borrowReceiver && !nullableRead && !multiple && host.canStackReceiver(e.receiver); + let r: LlValue; + const nullableField = borrowReceiver ? host.nullableFieldGet(e.receiver) : null; + if (nullableField !== null && e.receiver.kind === "fieldGet") { + // A nullable-pointer field stores no union: test and peek the + // pointer itself, through a private stack box when the union's own + // representation is boxed (emitUnionProjection's form). + const owner = host.emitReadReceiver(e.receiver.obj); + const { ptr } = host.classFieldPtr(owner.name, e.receiver.className, e.receiver.field); + const p = B.tmp(); + B.line(`${p} = load ptr, ptr ${ptr}${host.fieldAliasAttachment(ptr)}`); + r = { + name: host.nullableUnions.get(def.id) ? p : host.stackNullableBox(p, nullableField), + type: e.receiver.type, + }; + } else if (borrowReceiver || stacked || nullableRead) r = host.emitReadReceiver(e.receiver); + else r = host.emitExpr(e.receiver); + // The bind slot borrows a ref payload whenever something else keeps it + // alive until the body finishes: the borrowed projection above, or the + // receiver value the statement frame owns (emitExpr). Stack receivers + // may borrow their payload from a container, so they keep their +1. + const borrowBind = !multiple && !stacked && isRefCounted(narrowed); + const bind = B.slot(); + B.entryAllocas.push(`${bind} = alloca ${host.llType(narrowed)}`); + B.line( + `store ${host.llType(narrowed)} ${host.llType(narrowed) === "ptr" ? "null" : host.llType(narrowed) === "double" ? f64Lit(0) : "false"}, ptr ${bind}`, + ); + if (!borrowBind) host.ownSlot(bind, narrowed); + const isUnit = host.tagInSet(r.name, def.id, unitTags); + const extract = (): string => + multiple + ? host.retainValue(r.name, narrowed) + : borrowBind + ? host.unionPeek(r.name, def.id) + : host.unionExtract(r.name, def.id, narrowed); + return { + isUnit, + bindPresent: () => { + B.line(`store ${host.llType(narrowed)} ${extract()}, ptr ${bind}`); + host.chainSlots.set(e.id, { name: bind, type: narrowed, slot: true }); + }, + }; +} + /** `a?.b ?? d` narrowed to the chain body's present arm. The body wraps a * value of the result type in a non-unit arm, so the default runs exactly * when the receiver is nullish and the intermediate result box is never diff --git a/packages/compiler/src/backend/llvm/int32-abi.test.ts b/packages/compiler/src/backend/llvm/int32-abi.test.ts index d268a9e5a..0dad7a998 100644 --- a/packages/compiler/src/backend/llvm/int32-abi.test.ts +++ b/packages/compiler/src/backend/llvm/int32-abi.test.ts @@ -3,12 +3,14 @@ import { BOOL, F64, STRING, + UNDEFINED_T, VOID, funcOf, type IrExpr, type IrFunction, type IrModule, type IrStmt, + type IrType, } from "../../ir/ir.js"; import { validateModule } from "../../ir/validate.js"; import { emitLlvmModule } from "./emitter.js"; @@ -133,3 +135,96 @@ test("debug builds keep the double ABI", () => { expect(llvm).not.toContain(".i32("); expect(llvm).toMatch(/define internal double @sc_f_mix\(double %p_\w+, double %p_\w+\)/); }); + +const optional: IrType = { kind: "union", unionId: "optional" }; +const wrap = (value: IrExpr, tag: number): IrExpr => ({ + kind: "unionWrap", + value, + tag, + unionId: "optional", + type: optional, + loc, +}); +const missing = (): IrExpr => + wrap({ kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, 1); + +function unionModule(): IrModule { + const positive: IrExpr = { + kind: "bin", + op: ">", + left: ref("n"), + right: num(0), + type: BOOL, + loc, + }; + return { + irVersion: 16, + sourceFile: loc.file, + entry: "main", + unions: [{ id: "optional", arms: [F64, UNDEFINED_T] }], + functions: [ + fn( + "main", + [], + [ + stmt({ + kind: "nullish", + left: call("maybe", [num(3)], optional), + right: num(0), + type: F64, + loc, + }), + stmt({ + kind: "closure", + fnName: "maybe", + captures: [], + type: funcOf([F64], optional), + loc, + }), + ], + VOID, + ), + fn( + "maybe", + ["n"], + [ + stmt(call("throws", [], VOID)), + ret({ + kind: "ternary", + cond: positive, + then: wrap(ref("n"), 0), + else_: missing(), + type: optional, + loc, + }), + ], + optional, + ), + fn( + "throws", + [], + [{ kind: "throw", value: { kind: "strLit", value: "no", type: STRING, loc }, loc }], + VOID, + ), + ], + }; +} + +test("scalar union results return tag and payload bits on direct calls", () => { + const mod = unionModule(); + expect(validateModule(mod)).toEqual([]); + const llvm = emitLlvmModule(mod); + const entry = define(llvm, "sc_f_maybe.i32"); + expect(entry).toMatch(/^define internal \{ i32, i64 \} @sc_f_maybe\.i32\(double %p_\w+\)/); + expect(entry).toContain("ret { i32, i64 } { i32 -1, i64 0 }"); + expect(entry).not.toContain("@scr_union_new"); + // A projecting caller reads the pair without a heap box. + const main = define(llvm, "sc_f_main"); + expect(main).toContain("call { i32, i64 } @sc_f_maybe.i32(double "); + expect(main).not.toContain("@scr_union_new"); + // The closure keeps the boxed result of the ordinary symbol. + const wrapper = define(llvm, "sc_f_maybe"); + expect(wrapper).toContain("define internal ptr @sc_f_maybe(double %p0)"); + expect(wrapper).toContain("call { i32, i64 } @sc_f_maybe.i32(double %p0)"); + expect(wrapper).toContain("call ptr @scr_union_new_f64(i32 0, double "); +}); diff --git a/packages/compiler/src/backend/llvm/int32-abi.ts b/packages/compiler/src/backend/llvm/int32-abi.ts index 3bf7b0943..74cc5b974 100644 --- a/packages/compiler/src/backend/llvm/int32-abi.ts +++ b/packages/compiler/src/backend/llvm/int32-abi.ts @@ -1,14 +1,33 @@ -import type { IrExpr, IrFunction, IrModule } from "../../ir/ir.js"; +import { + isUnitType, + type IrExpr, + type IrFunction, + type IrModule, + type IrType, +} from "../../ir/ir.js"; import type { Int32Slots } from "../../ir/int32-slots.js"; import { everyStmtList } from "../../ir/traverse.js"; /** The i32 entry of a function: which number parameters it receives as - * i32, and whether its number result is returned as i32. */ + * i32, and whether its number result is returned as i32 or its scalar + * union result as tag and payload bits. */ export interface Int32Abi { params: readonly boolean[]; result: boolean; + /** The result is a union of numbers, booleans and unit values (such as + * `number | undefined`), returned as SCALAR_UNION_RESULT instead of a + * heap box. */ + union: boolean; } +/** The register pair of a scalar union result: the union tag and the + * payload bits of the `%ScrUnion` box it replaces. */ +export const SCALAR_UNION_RESULT = "{ i32, i64 }"; + +/** The result of an entry that left a pending exception: no arm has tag + * -1, so the double-ABI wrapper answers the usual NULL dummy. */ +export const SCALAR_UNION_DUMMY = `${SCALAR_UNION_RESULT} { i32 -1, i64 0 }`; + /** Suffix of a function's i32 entry symbol. The ordinary symbol stays a * double-ABI wrapper around it. */ export const INT32_ABI_SUFFIX = ".i32"; @@ -26,6 +45,11 @@ export const INT32_ABI_SUFFIX = ".i32"; * fib(n - 2)`, keeps its double: the incoming double is that result, where * an i32 would add a conversion to the path that returns it. * + * A scalar union result (every arm a number, a boolean or a unit value) + * owns no reference, so the entry returns its tag and payload bits in + * registers. Callers that only project it never build the heap box; the + * others box it at the call, as the double-ABI wrapper does. + * * Only direct `call` and `new` sites use the i32 entry. Every other way to * reach the function (closure and virtual-dispatch entries, constructor * thunks, adapters, the runtime) keeps the double-ABI symbol, which becomes @@ -34,10 +58,12 @@ export const INT32_ABI_SUFFIX = ".i32"; * debug builds (numbers stay doubles for the debugger) select none. */ export function findInt32Abis(mod: IrModule, slots: Int32Slots): Map { const result = new Map(); + const unions = new Map((mod.unions ?? []).map((def) => [def.id, def.arms])); for (const fn of mod.functions) { if (fn.async || fn.generator || fn.captures !== undefined || !slots.called.has(fn.name)) continue; const ret = fn.returnType.kind === "f64" && slots.hasReturn(fn.name); + const union = scalarUnionResult(fn, unions); let params = fn.params.map( (param) => param.type.kind === "f64" && slots.hasParam(fn.name, param.localId), ); @@ -45,11 +71,23 @@ export function findInt32Abis(mod: IrModule, slots: Int32Slots): Map i32 && !returned.has(fn.params[index]!.localId)); } - if (ret || params.includes(true)) result.set(fn.name, { params, result: ret }); + if (ret || union || params.includes(true)) result.set(fn.name, { params, result: ret, union }); } return result; } +function scalarUnionResult( + fn: IrFunction, + unions: ReadonlyMap, +): boolean { + if (fn.returnType.kind !== "union") return false; + const arms = unions.get(fn.returnType.unionId); + return ( + arms !== undefined && + arms.every((arm) => arm.kind === "f64" || arm.kind === "bool" || isUnitType(arm)) + ); +} + /** Locals some `return` hands back as its value (directly or as an arm of * a conditional). */ function returnedLocals(fn: IrFunction): Set { 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 d86a3a9f6..98e9f78fd 100644 --- a/packages/compiler/src/backend/llvm/local-union-storage.test.ts +++ b/packages/compiler/src/backend/llvm/local-union-storage.test.ts @@ -91,11 +91,14 @@ function fixture(): IrModule { } function facts(mod: IrModule) { const lifetimes = analyzeCallLifetimes(new Map(mod.functions.map((fn) => [fn.name, fn]))); - return findLocalUnionStorage( - mod.functions[1]!, - lifetimes, - new Map(mod.unions!.map((u) => [u.id, u])), - ); + const unions = new Map(mod.unions!.map((u) => [u.id, u])); + return findLocalUnionStorage(mod.functions[1]!, lifetimes, { + unions, + receiverUnions: unions, + nullableField: () => false, + arrayRead: () => false, + scalarCall: () => false, + }); } function body(mod: IrModule, pointerBits: 32 | 64): string { expect(validateModule(mod)).toEqual([]); @@ -320,3 +323,102 @@ test("equality whose other operand captures the whole local keeps the heap path" }; expect(facts(mod).has("item")).toBe(false); }); + +test("number bindings of optional reads and `??` chains stay in stack storage", () => { + const maybe: IrType = { kind: "union", unionId: "maybe" }; + const numbers: IrType = { kind: "array", elem: F64 }; + const read = (index: IrExpr): IrExpr => ({ + kind: "ternary", + cond: { + kind: "bin", + op: "===", + left: { kind: "arrayState", arr: ref("values", numbers), index, type: F64, loc }, + right: { kind: "numLit", value: 1, type: F64, loc }, + type: BOOL, + loc, + }, + then: { + kind: "unionWrap", + value: { kind: "arrayGet", arr: ref("values", numbers), index, type: F64, loc }, + tag: 0, + unionId: "maybe", + type: maybe, + loc, + }, + else_: { + kind: "unionWrap", + value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + tag: 1, + unionId: "maybe", + type: maybe, + loc, + }, + type: maybe, + loc, + }); + const zero: IrExpr = { kind: "numLit", value: 0, type: F64, loc }; + const mod: IrModule = { + irVersion: 16, + sourceFile: loc.file, + entry: "main", + unions: [{ id: "maybe", arms: [F64, UNDEFINED_T] }], + functions: [ + { name: "main", params: [], locals: [], body: [], returnType: VOID, loc }, + { + name: "work", + loc, + returnType: F64, + params: [ + { localId: "values", name: "values", type: numbers }, + { localId: "i", name: "i", type: F64 }, + { localId: "choose", name: "choose", type: BOOL }, + ], + locals: [ + { id: "values", name: "values", type: numbers, mutable: true }, + { id: "i", name: "i", type: F64, mutable: true }, + { id: "choose", name: "choose", type: BOOL, mutable: true }, + { id: "value", name: "value", type: maybe, mutable: true }, + ], + body: [ + { + kind: "varDecl", + localId: "value", + loc, + init: { + kind: "nullish", + left: read(ref("i", F64)), + right: read(zero), + type: maybe, + loc, + }, + }, + { + kind: "assign", + localId: "value", + loc, + value: { + kind: "ternary", + cond: ref("choose", BOOL), + then: read(zero), + else_: read(ref("i", F64)), + type: maybe, + loc, + }, + }, + { + kind: "return", + loc, + value: { + kind: "nullish", + left: ref("value", maybe), + right: { kind: "numLit", value: -1, type: F64, loc }, + type: F64, + loc, + }, + }, + ], + }, + ], + }; + expect(body(mod, 64)).not.toMatch(/@scr_union_(?:new|retain|release)/); +}); diff --git a/packages/compiler/src/backend/llvm/local-union-storage.ts b/packages/compiler/src/backend/llvm/local-union-storage.ts index aec3aaece..f1ec82df5 100644 --- a/packages/compiler/src/backend/llvm/local-union-storage.ts +++ b/packages/compiler/src/backend/llvm/local-union-storage.ts @@ -1,5 +1,7 @@ +import { undefinedArmTag } from "../../ir/analysis.js"; import { isRefCounted, + isUnitType, type IrExpr, type IrFunction, type IrType, @@ -7,7 +9,10 @@ import { } from "../../ir/ir.js"; import { everyStmtList } from "../../ir/traverse.js"; import type { CallLifetimes } from "./call-lifetimes.js"; -import type { LlvmEmitterContext } from "./expr-context.js"; +import { emitArrIntrinsic } from "./expr-containers.js"; +import { emitOptionalChainReceiver } from "./expr-control.js"; +import type { ExprOf, LlValue, LlvmEmitterContext } from "./expr-context.js"; +import { emitLocalArrayRead } from "./local-array-reads.js"; import { emitStackMapRead, matchMapRead } from "./map-read-lifetimes.js"; import { canStackUnion, emitStackUnion } from "./stack-unions.js"; @@ -31,22 +36,81 @@ function isUnionCopy(expr: IrExpr, type: IrType): boolean { ); } -function supportedSource( - expr: IrExpr, - type: IrType, - unions: ReadonlyMap, - nullableField: CopiedUnionSource, -): boolean { +/** The union sources a binding or projection can snapshot. */ +export interface UnionSources { + /** The tagged-box unions a stack box can hold. */ + unions: ReadonlyMap; + /** Every union, including nullable-pointer receivers of `a?.b`. */ + receiverUnions: ReadonlyMap; + /** Nullable-pointer field reads (copied with their own owner). */ + nullableField: CopiedUnionSource; + /** Optional array element reads (OptionalArrayReads). */ + arrayRead: CopiedUnionSource; + /** Direct calls returning a scalar union in registers (Int32Abi.union). */ + scalarCall: CopiedUnionSource; +} + +/** Every arm is a number, a boolean or a unit value: the box owns nothing, + * so its tag and payload bits are the whole value. */ +function isScalarUnion(type: IrType, unions: ReadonlyMap): boolean { + if (type.kind !== "union") return false; + const arms = unions.get(type.unionId)?.arms; + return ( + arms !== undefined && + arms.every((arm) => arm.kind === "f64" || arm.kind === "bool" || isUnitType(arm)) + ); +} + +function sameUnion(expr: IrExpr, type: IrType): boolean { + return expr.type.kind === "union" && type.kind === "union" && expr.type.unionId === type.unionId; +} + +/** `a?.b`, pass-through `a ?? b`, `c ? a : b` and `pop()`/`shift()` of + * number or boolean elements, as a scalar union built from other sources + * in a private stack box. Ownerless payloads make every snapshot a copy of + * bits, so no heap box is needed while consumers only project. */ +export function isScalarOptionalSource(expr: IrExpr, sources: UnionSources): boolean { + if (!isScalarUnion(expr.type, sources.unions)) return false; + if (expr.kind === "arrIntrinsic") + return ( + (expr.method === "pop" || expr.method === "shift") && + expr.receiver.type.kind === "array" && + (expr.receiver.type.elem.kind === "f64" || expr.receiver.type.elem.kind === "bool") + ); + if (expr.kind === "optChain") { + if (expr.receiver.type.kind !== "union") return false; + const arms = sources.receiverUnions.get(expr.receiver.type.unionId)?.arms; + return ( + arms !== undefined && + arms.filter((arm) => !isUnitType(arm)).length === 1 && + undefinedArmTag(expr.type, sources.unions) >= 0 && + supportedSource(expr.body, expr.type, sources) + ); + } if (expr.kind === "ternary") return ( - supportedSource(expr.then, type, unions, nullableField) && - supportedSource(expr.else_, type, unions, nullableField) + supportedSource(expr.then, expr.type, sources) && + supportedSource(expr.else_, expr.type, sources) ); return ( - canStackUnion(expr, unions) || - matchMapRead(expr, unions) !== null || - nullableField(expr) || - isUnionCopy(expr, type) + expr.kind === "nullish" && + supportedSource(expr.left, expr.type, sources) && + supportedSource(expr.right, expr.type, sources) + ); +} + +function supportedSource(expr: IrExpr, type: IrType, sources: UnionSources): boolean { + if (!sameUnion(expr, type)) return false; + if (sources.arrayRead(expr)) return true; + if (expr.kind === "ternary") + return supportedSource(expr.then, type, sources) && supportedSource(expr.else_, type, sources); + return ( + canStackUnion(expr, sources.unions) || + matchMapRead(expr, sources.unions) !== null || + sources.nullableField(expr) || + isUnionCopy(expr, type) || + isScalarOptionalSource(expr, sources) || + sources.scalarCall(expr) ); } @@ -57,9 +121,9 @@ function supportedSource( export function findLocalUnionStorage( fn: IrFunction, lifetimes: CallLifetimes, - unions: ReadonlyMap, - nullableField: CopiedUnionSource = () => false, + sources: UnionSources, ): Map { + const unions = sources.unions; const types = new Map(fn.locals.map((local) => [local.id, local.type])); const result = new Map(); const safe = lifetimes.projectedLocals.get(fn.name); @@ -77,8 +141,7 @@ export function findLocalUnionStorage( if (stmt.kind === "varDecl" || stmt.kind === "assign") { const value = stmt.kind === "varDecl" ? stmt.init : stmt.value; const type = types.get(stmt.localId); - if (!value || !type || !supportedSource(value, type, unions, nullableField)) - result.delete(stmt.localId); + if (!value || !type || !supportedSource(value, type, sources)) result.delete(stmt.localId); } return true; }, @@ -122,7 +185,8 @@ export function storeLocalUnion( expr: IrExpr, ): void { const B = host.B; - if (expr.kind === "ternary") { + const read = host.optionalArrayReads.get(expr); + if (expr.kind === "ternary" && read === null) { const cond = host.emitExpr(expr.cond); const yes = B.newLabel("union.then"), no = B.newLabel("union.else"), @@ -147,7 +211,27 @@ export function storeLocalUnion( // boxed union table), and every source has the binding's own union. const unionId = expr.type.kind === "union" ? expr.type.unionId : ""; host.requireBoxedUnion(unionId); - if (host.nullableFieldGet(expr) !== null) { + if (read) { + // The snapshot owns its reference element, if any; a missing element + // leaves a NULL owner. + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ptr`); + const owned = emitLocalArrayRead(host, { ...read, borrow: false }, slot); + source = B.tmp(); + B.line(`${source} = load ptr, ptr ${slot}`); + if (owned) { + owner = B.tmp(); + B.line(`${owner} = load ptr, ptr ${owned.slot}`); + } + } else if ( + expr.kind === "optChain" || + expr.kind === "nullish" || + expr.kind === "call" || + expr.kind === "arrIntrinsic" + ) { + // Scalar optional sources and register results: a private stack box. + source = host.emitReadReceiver(expr).name; + } else if (host.nullableFieldGet(expr) !== null) { const copied = host.emitOwnedNullableStack(expr); source = copied.box; owner = copied.owner.name; @@ -207,3 +291,51 @@ export function storeLocalUnion( host.releaseValue(old, storage.ownerType); } } + +/** Build a scalar optional source (isScalarOptionalSource) in a fresh + * stack box. Operands evaluate in source order; a binding being assigned + * is only overwritten after the whole value is known. */ +export function emitScalarOptional( + host: LlvmEmitterContext, + expr: ExprOf<"optChain" | "nullish" | "ternary" | "arrIntrinsic">, +): LlValue { + const B = host.B; + const storage = allocateLocalUnion(host, { ownerType: null }); + if (expr.kind === "arrIntrinsic") return emitArrIntrinsic(host, expr, storage); + if (expr.kind === "ternary") { + storeLocalUnion(host, storage, expr); + return { name: storage.box, type: expr.type }; + } + const join = B.newLabel("optional.join"); + if (expr.kind === "optChain") { + const { isUnit, bindPresent } = emitOptionalChainReceiver(host, expr); + const missing = B.newLabel("optional.missing"), + present = B.newLabel("optional.present"); + B.condBr(isUnit, missing, present); + B.startBlock(missing); + B.line(`store i32 ${undefinedArmTag(expr.type, host.unionsById)}, ptr ${storage.tag}`); + B.line(`store i64 0, ptr ${storage.payload}`); + B.br(join); + B.startBlock(present); + bindPresent(); + host.frames.push([]); + storeLocalUnion(host, storage, expr.body); + host.releaseFrame(host.frames.pop()!); + host.chainSlots.delete(expr.id); + B.br(join); + } else { + storeLocalUnion(host, storage, expr.left); + const unionId = expr.type.kind === "union" ? expr.type.unionId : ""; + const arms = host.unionsById.get(unionId)!.arms; + const unitTags = arms.flatMap((arm, tag) => (isUnitType(arm) ? [tag] : [])); + const fallback = B.newLabel("optional.default"); + B.condBr(host.tagInSet(storage.box, unionId, unitTags), fallback, join); + B.startBlock(fallback); + host.frames.push([]); + storeLocalUnion(host, storage, expr.right); + host.releaseFrame(host.frames.pop()!); + B.br(join); + } + B.startBlock(join); + return { name: storage.box, type: expr.type }; +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5070-optional-read-bindings.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5070-optional-read-bindings.ts.json new file mode 100644 index 000000000..ce0ba48d3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5070-optional-read-bindings.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5070-optional-read-bindings.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5071-optional-chain-scalars.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5071-optional-chain-scalars.ts.json new file mode 100644 index 000000000..12dd109ee --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5071-optional-chain-scalars.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5071-optional-chain-scalars.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5072-optional-nullish-ternary.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5072-optional-nullish-ternary.ts.json new file mode 100644 index 000000000..70a74f690 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5072-optional-nullish-ternary.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5072-optional-nullish-ternary.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5073-optional-results.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5073-optional-results.ts.json new file mode 100644 index 000000000..67ffc5797 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5073-optional-results.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5073-optional-results.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5074-optional-pop-shift.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5074-optional-pop-shift.ts.json new file mode 100644 index 000000000..572180b0a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5074-optional-pop-shift.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5074-optional-pop-shift.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5075-optional-reads-unchecked/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5075-optional-reads-unchecked/main.ts.json new file mode 100644 index 000000000..1cbd64442 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5075-optional-reads-unchecked/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5075-optional-reads-unchecked/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5076-optional-escapes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5076-optional-escapes.ts.json new file mode 100644 index 000000000..98827f97c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5076-optional-escapes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5076-optional-escapes.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5070-optional-read-bindings.ts b/tests/corpus/5070-optional-read-bindings.ts new file mode 100644 index 000000000..c62509fce --- /dev/null +++ b/tests/corpus/5070-optional-read-bindings.ts @@ -0,0 +1,48 @@ +// Reassigned bindings of optional element reads keep each snapshot's +// value: holes, present undefined, missing and noncanonical indices read +// as undefined, and later array writes do not change an earlier read. +const numbers = [5, 0, NaN, -1]; +for (let i = -1; i <= numbers.length; i++) { + let value = numbers[i]; + if (value === undefined) value = 100; + let next = numbers[i + 1]; + next = next ?? numbers[i]; + console.log(i, value, next, next === undefined); +} + +const holey: number[] = [1, , 3] as number[]; +let hole = holey[1]; +console.log(hole === undefined, hole); +hole = holey[2]; +console.log(hole, holey[1.5], holey[-1]); + +const words = ["x", "yy"]; +let word = words[0]; +words[0] = "changed"; +console.log(word, word?.length, words[0]); +word = words[5]; +console.log(word === undefined, word ?? "fallback"); + +const flags = [true, false]; +let flag = flags[1]; +console.log(flag, flag === false, flag === undefined); +flag = flags[9]; +console.log(flag, flag ?? "none"); + +let total = 0; +let cursor = numbers[0]; +for (let i = 1; cursor !== undefined; i++) { + total += cursor; + cursor = numbers[i]; +} +console.log(total); + +function firstAbove(values: number[], limit: number): number { + let found = values[0]; + for (let i = 1; i < values.length + 2; i++) { + const candidate = values[i]; + if (candidate !== undefined && candidate > limit) found = candidate; + } + return found ?? -1; +} +console.log(firstAbove([1, 9, 4, 12], 5), firstAbove([], 0)); diff --git a/tests/corpus/5071-optional-chain-scalars.ts b/tests/corpus/5071-optional-chain-scalars.ts new file mode 100644 index 000000000..04116f90f --- /dev/null +++ b/tests/corpus/5071-optional-chain-scalars.ts @@ -0,0 +1,67 @@ +// Optional chains that end in number or boolean values: missing elements, +// null receivers and nested reads give undefined, the chain body runs only +// for a present receiver, and `??` evaluates its default lazily. +interface Rec { + id: number; + score: number; + flag: boolean; +} +const recs: Rec[] = [ + { id: 1, score: 3, flag: true }, + { id: 2, score: 0, flag: false }, + { id: 3, score: NaN, flag: true }, +]; + +let lo = Infinity; +let hi = -Infinity; +let count = 0; +for (let i = 0; i <= recs.length; i++) { + const score = recs[i]?.score; + if (score === undefined) { + console.log("missing", i); + continue; + } + lo = Math.min(lo, score); + hi = Math.max(hi, score); + count++; +} +console.log(lo, hi, count); + +for (let i = 0; i <= recs.length; i++) { + console.log( + recs[i]?.score ?? -1, + recs[i]?.flag ?? "none", + recs[i]?.score || "falsy", + recs[i]?.flag === true, + recs[i]?.score === 3, + !recs[i]?.flag, + ); + let flag = recs[i]?.flag; + if (flag === undefined) flag = false; + console.log(flag); +} + +const grid: number[][] = [[1, 2], [3]]; +for (let r = 0; r < 3; r++) + for (let c = 0; c < 3; c++) { + const cell = grid[r]?.[c]; + console.log(r, c, cell, grid[r]?.[c] ?? 9, cell === undefined ? "u" : cell > 1); + } + +const nothing: Rec | null | undefined = recs.length > 5 ? recs[0] : null; +console.log(nothing?.score, nothing?.score ?? 5, nothing?.flag === undefined); + +const log: string[] = []; +function fallback(label: string): number { + log.push(label); + return 7; +} +let k = 0; +const first = recs[k++]?.score ?? fallback("first"); +const second = recs[k + 10]?.score ?? fallback("second"); +console.log(first, second, k, log.join(",")); + +function describe(value: number | undefined): string { + return value === undefined ? "none" : `value ${value * 2}`; +} +for (let i = 0; i < 5; i++) console.log(describe(recs[i]?.score)); diff --git a/tests/corpus/5072-optional-nullish-ternary.ts b/tests/corpus/5072-optional-nullish-ternary.ts new file mode 100644 index 000000000..cec989c46 --- /dev/null +++ b/tests/corpus/5072-optional-nullish-ternary.ts @@ -0,0 +1,29 @@ +// `??`, `||` and conditional chains over optional element reads choose +// the first present (or truthy) value without changing evaluation order. +const numbers = [5, 0, NaN, -1]; +for (let i = 0; i < 6; i++) { + const a = numbers[i] ?? numbers[i - 2] ?? 0; + let b = numbers[i + 3] ?? numbers[i]; + b = numbers[i + 7] ?? b; + console.log(a, b, numbers[i] || 11, numbers[i + 1] || numbers[i] || 12); +} + +let calls = 0; +function index(i: number): number { + calls++; + return i; +} +console.log(numbers[index(0)] ?? numbers[index(1)], calls); +console.log(numbers[index(9)] ?? numbers[index(1)], calls); + +function pick(values: number[], i: number): number { + return values[i] ?? values[values.length - 1] ?? NaN; +} +console.log(pick([], 0), pick([4], 3), pick([4, 8], 0)); + +let best: number | undefined; +for (const i of [3, 0, 2, 9, 1]) { + const candidate = numbers[i]; + best = candidate !== undefined && (best === undefined || candidate > best) ? candidate : best; +} +console.log(best); diff --git a/tests/corpus/5073-optional-results.ts b/tests/corpus/5073-optional-results.ts new file mode 100644 index 000000000..4ecd9e9db --- /dev/null +++ b/tests/corpus/5073-optional-results.ts @@ -0,0 +1,99 @@ +// Functions whose results are `number | undefined` and similar unions: +// direct calls, function values, overridden methods, exceptions and +// finally blocks all observe the same values. +function at(values: number[], i: number): number | undefined { + return values[i]; +} +function flagAt(values: boolean[], i: number): boolean | undefined { + return values[i]; +} +function positive(n: number): number | null { + return n > 0 ? n : null; +} +function three(n: number): number | null | undefined { + return n > 1 ? n : n === 1 ? null : undefined; +} +function same(value: number | undefined): number | undefined { + return value; +} +function thrower(n: number): number | undefined { + if (n === 3) throw new Error(`boom ${n}`); + return n > 4 ? undefined : n * 2; +} +function guarded(n: number): number | undefined { + try { + if (n > 2) return n; + return undefined; + } finally { + console.log("finally", n); + } +} +function find(values: number[], limit: number): number | undefined { + for (let i = 0; i < values.length; i++) { + const value = values[i]; + if (value !== undefined && value > limit) return value; + } + return undefined; +} +function last(values: number[], i: number): number | undefined { + return i < 0 ? undefined : (at(values, i) ?? last(values, i - 1)); +} +class Store { + data: number[] = [4, 5]; + get(i: number): number | undefined { + return this.data[i]; + } +} +class Override extends Store { + get(i: number): number | undefined { + return i === 0 ? 99 : super.get(i); + } +} + +const values = [1, 2, 3]; +for (let i = -1; i < 6; i++) { + console.log(i, at(values, i), at(values, i) ?? "none", at(values, i) === undefined); + console.log(flagAt([true, false], i), positive(i), three(i), same(at(values, i))); + try { + console.log(thrower(i)); + } catch (error) { + console.log("caught", (error as Error).message); + } + console.log(guarded(i), find(values, i), last(values, i + 3)); +} + +const value: (values: number[], i: number) => number | undefined = at; +console.log(value(values, 1), value(values, 7), [0, 1, 5].map((i) => at(values, i))); +const stores: Store[] = [new Store(), new Override()]; +for (const store of stores) console.log(store.get(0), store.get(1), store.get(2)); + +const kept: (number | undefined)[] = []; +for (let i = 0; i < 4; i++) kept.push(at(values, i)); +console.log(kept); +let carried: number | undefined = at(values, 0); +for (let i = 0; i < 5; i++) carried = at(values, i) ?? carried; +console.log(carried); +const record = { missing: at(values, 9), present: at(values, 0) }; +console.log(record, JSON.stringify(record)); + +function sum(a: number[], lo: number, hi: number): number | undefined { + if (hi - lo <= 1) return a[lo]; + const mid = (lo + hi) >> 1; + const left = sum(a, lo, mid); + const right = sum(a, mid, hi); + if (left === undefined) return right; + if (right === undefined) return left; + return left + right; +} +console.log(sum(values, 0, 10), sum([], 0, 4)); + +function base(a: number[], lo: number, hi: number): number { + if (hi - lo <= 1) { + let v = a[lo]; + if (v === undefined) v = 0; + return v; + } + const mid = (lo + hi) >> 1; + return base(a, lo, mid) + base(a, mid, hi); +} +console.log(base(values, 0, 8)); diff --git a/tests/corpus/5074-optional-pop-shift.ts b/tests/corpus/5074-optional-pop-shift.ts new file mode 100644 index 000000000..c7779f506 --- /dev/null +++ b/tests/corpus/5074-optional-pop-shift.ts @@ -0,0 +1,36 @@ +// pop() and shift() of number and boolean arrays answer undefined once +// the array is empty; consumers that test or default the result see the +// removed element otherwise. +const stack = [1, 0, NaN, 4]; +let total = 0; +for (;;) { + const top = stack.pop(); + if (top === undefined) break; + total += top; + console.log(top, stack.length); +} +console.log(total, stack.pop(), stack.pop() ?? "empty"); + +const queue = [true, false, true]; +while (queue.length > 0) console.log(queue.shift() ?? "none", queue.length); +console.log(queue.shift(), queue.shift() === undefined); + +const work = [3, 1, 2]; +let visited = 0; +while (work.length) { + const next = work.shift()!; + visited += next; + if (next > 2) work.push(next - 2); +} +console.log(visited); + +function drain(values: number[]): number { + let last = values.pop() ?? -1; + let count = 0; + for (let item = values.pop(); item !== undefined; item = values.pop()) { + last = item; + count++; + } + return last * 10 + count; +} +console.log(drain([7, 8, 9]), drain([])); diff --git a/tests/corpus/5075-optional-reads-unchecked/main.ts b/tests/corpus/5075-optional-reads-unchecked/main.ts new file mode 100644 index 000000000..8de2b3c5f --- /dev/null +++ b/tests/corpus/5075-optional-reads-unchecked/main.ts @@ -0,0 +1,72 @@ +// Kernels over maybe-missing elements with noUncheckedIndexedAccess: game +// tree minimax over ragged rows, divide-and-conquer sums that read past +// the end, and min/max of record fields. +interface Rec { + id: number; + score: number; +} + +function leaf(grid: number[][], node: number): number { + return grid[node >> 3]?.[node & 7] ?? 0; +} +function minimax(grid: number[][], node: number, depth: number, maxing: boolean): number { + if (depth === 0) return leaf(grid, node % 90); + let best = maxing ? -Infinity : Infinity; + for (let c = 0; c < 3; c++) { + const score = minimax(grid, node * 3 + c, depth - 1, !maxing); + best = maxing ? Math.max(best, score) : Math.min(best, score); + } + return best; +} +const grid: number[][] = []; +for (let r = 0; r < 10; r++) { + const row: number[] = []; + for (let c = 0; c < (r % 3 === 0 ? 5 : 8); c++) row.push((r * 37 + c * 11) % 23); + grid.push(row); +} +console.log(minimax(grid, 0, 5, true), minimax(grid, 1, 4, false)); + +function sum(a: number[], lo: number, hi: number): number { + if (hi - lo <= 1) { + let v = a[lo]; + if (v === undefined) v = 0; + return v; + } + const mid = (lo + hi) >> 1; + return sum(a, lo, mid) + sum(a, mid, hi); +} +function maybeSum(a: number[], lo: number, hi: number): number | undefined { + if (hi - lo <= 1) return a[lo]; + const mid = (lo + hi) >> 1; + const left = maybeSum(a, lo, mid); + const right = maybeSum(a, mid, hi); + return left === undefined ? right : right === undefined ? left : left + right; +} +const values: number[] = []; +for (let i = 0; i < 100; i++) values.push((i * 7) % 13); +console.log(sum(values, 0, values.length + 9), maybeSum(values, 0, 140), maybeSum(values, 100, 120)); + +const recs: Rec[] = []; +for (let i = 0; i < 50; i++) recs.push({ id: i, score: (i * 7919) % 101 }); +let lo = Infinity; +let hi = -Infinity; +for (let i = 0; i <= recs.length + 1; i++) { + const score = recs[i]?.score; + if (score === undefined) continue; + lo = Math.min(lo, score); + hi = Math.max(hi, score); +} +let best = -Infinity; +for (let i = 0; i < recs.length; i++) best = Math.max(best, recs[i]!.score); +function choose(values: number[], useFirst: boolean): number | string { + const value = useFirst ? values[0] : values[1]; + if (value === undefined) return "missing"; + return value; +} +for (let i = 0; i < 4; i++) { + const c = i % 2 === 0 ? values[i * 40] : values[i * 40 + 1]; + console.log(c, c ?? "none", choose(values.slice(i * 50), i > 1)); +} +const first = values[0]; +const missing = values[500]; +console.log(lo, hi, best, first ?? -1, missing ?? -1, missing === undefined); diff --git a/tests/corpus/5075-optional-reads-unchecked/tsconfig.json b/tests/corpus/5075-optional-reads-unchecked/tsconfig.json new file mode 100644 index 000000000..f2e1c10cd --- /dev/null +++ b/tests/corpus/5075-optional-reads-unchecked/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true + } +} diff --git a/tests/corpus/5076-optional-escapes.ts b/tests/corpus/5076-optional-escapes.ts new file mode 100644 index 000000000..fd84fb54f --- /dev/null +++ b/tests/corpus/5076-optional-escapes.ts @@ -0,0 +1,30 @@ +// Scalar optional values that escape into arrays, fields, maps, closures +// and stored parameters keep their own heap values. +const kept: (number | undefined)[] = []; +function keep(x: number | undefined): void { kept.push(x); } +function keepTwice(x: number | undefined): number | undefined { kept.push(x); return x; } +class Holder { v: number | undefined = undefined; set(x: number | undefined) { this.v = x; } } +const h = new Holder(); +const m = new Map(); +const fns: (() => number | undefined)[] = []; +function at(xs: number[], i: number): number | undefined { return xs[i]; } +const xs = [10, 20]; +for (let i = 0; i < 4; i++) { + let a: number | undefined = xs[i]; + let b: number | undefined = xs[i + 1]; + if (i === 2) { a = 5; b = undefined; } + const pick = i % 2 === 0 ? a : b; + keep(i % 2 === 0 ? a : b); + keep(xs[i] ?? xs[0]); + keep(at(xs, i)); + keepTwice(pick); + h.set(i > 1 ? at(xs, i) : xs[i]); + m.set("k" + i, at(xs, i) ?? at(xs, 0)); + const captured = at(xs, i); + fns.push(() => captured); + console.log(pick, h.v, m.get("k" + i)); +} +console.log(kept, fns.map((f) => f())); +function deep(n: number): number | undefined { return n === 0 ? undefined : (deep(n - 1) ?? n); } +try { console.log(deep(1e7)); } catch (e) { console.log("caught", (e as Error).name); } +console.log(deep(100));