From 56bc1745cda599f29dc055b00a2830e2f8e8c1f9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 8 Oct 2026 17:36:39 -0700 Subject: [PATCH] Inline number-key Map/Set get and has Number-key reads called scr_map_get_f64_ref and friends, which hashed the key and retained reference values through the map's val_retain function pointer. They now go through sc_map_entry_f64, a small internal IR helper LLVM inlines at each site: with the runtime's direct index present, an integral in-range key reads its slot and any other key is absent, with no call; maps without the index defer to scr_map_entry_f64. The generated code reads the entry's value slot itself and retains reference values with the value type's own (inlinable) retain, the adapter the collection was constructed with. Map.get with stack-boxed results, union and scalar values, and Map/Set.has all use it. The helper and its %ScrMapIx/%ScrMapEntry types follow the target's size type, so wasm32 builds verify as well. --- packages/compiler/src/backend/llvm/emitter.ts | 59 ++++++++++++ .../src/backend/llvm/expr-containers.ts | 75 +++++++++++---- .../backend/llvm/local-union-storage.test.ts | 2 +- .../src/backend/llvm/map-number-lookup.ts | 93 +++++++++++++++++++ .../backend/llvm/map-read-lifetimes.test.ts | 31 ++++--- .../src/backend/llvm/map-read-lifetimes.ts | 28 +++++- 6 files changed, 251 insertions(+), 37 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/map-number-lookup.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 279193239..5ab6cded1 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -290,6 +290,7 @@ import { LlWalkers } from "./walkers.js"; import { isObjectArm, NULLABLE_ABSENT, NULLABLE_NULL, NullableUnions } from "./nullable-unions.js"; import { emitNullablePresent, emitUnionPeek, emitUnionTag } from "./union-repr.js"; import { literalCapWord } from "./string-key-hash.js"; +import { NUMBER_MAP_ENTRY_DECL } from "./map-number-lookup.js"; import { arrNewCall, boxAccess, @@ -1662,6 +1663,15 @@ export class LlEmitter { ...(this.rcHelpers === null ? [] : [`%ScrMapRc = type { ${this.sizeType}, i32, i32, ptr, ptr, ptr, ptr, ptr, ptr }`]), + // The complete ScrMap and its { key, val, hash } entry, read by the + // inline number-key lookup (sc_map_entry_f64): entries is field 12, + // the direct index and its length fields 18 and 19. + ...(this.decls.has(NUMBER_MAP_ENTRY_DECL) + ? [ + `%ScrMapIx = type { ${this.sizeType}, i32, i32, ptr, ptr, ptr, ptr, ptr, ptr, ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, ptr, ${this.sizeType}, ptr, ${this.sizeType}, ptr, ptr, ptr, ${this.sizeType} }`, + `%ScrMapEntry = type { i64, i64, i64 }`, + ] + : []), // The runtime error prefix { rc, vt, name, message, code, cause } and the // class-object shape { rc, pre, post, ctor, name } — field reads on // builtin errors and classval loads GEP through these. @@ -2550,6 +2560,55 @@ export class LlEmitter { ``, ); } + // Number-key Map/Set lookups probe the runtime's direct index inline: + // while ndense is nonzero every live key is an integer below it, so an + // in-range integral key reads its slot (entry index + 1, 0 = absent) and + // any other key is absent. Maps without the index (ndense 0) and every + // non-integral probe outside the table take the runtime lookup, which + // keeps the complete SameValueZero semantics. + if (this.decls.has(NUMBER_MAP_ENTRY_DECL)) { + const sz = this.sizeType; + defs.push( + `define internal ptr @sc_map_entry_f64(ptr %m, double %k) ${FN_ATTRS} {`, + `entry:`, + ` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 19`, + ` %nd = load ${sz}, ptr %ndp`, + ` %ndf = uitofp ${sz} %nd to double`, + ` %lo = fcmp oge double %k, 0.0`, + ` %hi = fcmp olt double %k, %ndf`, + ` %in = and i1 %lo, %hi`, + ` br i1 %in, label %direct, label %outside`, + `direct:`, + ` %i = fptoui double %k to ${sz}`, + ` %back = uitofp ${sz} %i to double`, + ` %int = fcmp oeq double %back, %k`, + ` br i1 %int, label %slot, label %miss`, + `slot:`, + ` %dp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 18`, + ` %dense = load ptr, ptr %dp`, + ` %sp = getelementptr inbounds i32, ptr %dense, ${sz} %i`, + ` %s = load i32, ptr %sp`, + ` %empty = icmp eq i32 %s, 0`, + ` br i1 %empty, label %miss, label %hit`, + `hit:`, + ` %e32 = sub i32 %s, 1`, + sz === "i32" ? ` %e = add i32 %e32, 0` : ` %e = zext i32 %e32 to ${sz}`, + ` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 12`, + ` %entries = load ptr, ptr %ep`, + ` %found = getelementptr inbounds %ScrMapEntry, ptr %entries, ${sz} %e`, + ` ret ptr %found`, + `miss:`, + ` ret ptr null`, + `outside:`, + ` %dense.any = icmp ne ${sz} %nd, 0`, + ` br i1 %dense.any, label %miss, label %slow`, + `slow:`, + ` %r = call ptr @scr_map_entry_f64(ptr %m, double %k)`, + ` ret ptr %r`, + `}`, + ``, + ); + } // String comparisons enter through small internal wrappers that LLVM // inlines at each site: interned literals and shared values compare by // identity, and equality rejects different byte lengths, before any diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index c12c6218c..0957c992f 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -21,7 +21,13 @@ import { emitMapLookupKey, borrowsMapReadInputs, borrowsMapMutationReceiver, + inlineNumberKey, } from "./map-read-lifetimes.js"; +import { + emitNumberMapGetRef, + emitNumberMapGetScalar, + emitNumberMapHas, +} from "./map-number-lookup.js"; import { emitBorrowedInput } from "./borrowed-inputs.js"; import { emitStringSliceRead } from "./string-slices.js"; import { emitDenseArrayNumber, emitDenseArrayPush } from "./dense-array-access.js"; @@ -991,12 +997,18 @@ export function emitMapLikeIntrinsic( B.line(`${t} = load ptr, ptr ${slot}`); return host.own({ name: t, type: e.type }); } + const numberKey = inlineNumberKey(k); if (value.kind === "union") { - host.declare(`declare ptr @scr_map_get_${kAcc}_ref(ptr, ${kTy})`); - const raw = B.tmp(); const isnull = B.tmp(); const t = B.tmp(); - B.line(`${raw} = call ptr @scr_map_get_${kAcc}_ref(ptr ${r.name}, ${k.args})`); + let raw: string; + if (numberKey !== null) { + raw = emitNumberMapGetRef(host, r.name, numberKey, value); + } else { + host.declare(`declare ptr @scr_map_get_${kAcc}_ref(ptr, ${kTy})`); + raw = B.tmp(); + B.line(`${raw} = call ptr @scr_map_get_${kAcc}_ref(ptr ${r.name}, ${k.args})`); + } B.line(`${isnull} = icmp eq ptr ${raw}, null`); B.line(`${t} = select i1 ${isnull}, ptr ${absent}, ptr ${raw}`); return host.own({ name: t, type: e.type }); @@ -1006,16 +1018,40 @@ export function emitMapLikeIntrinsic( throw new InternalCompilerError("llvm emitter bug: map get union lacks its value arm"); if (value.kind === "f64" || value.kind === "bool") { const outTy = value.kind === "f64" ? "double" : "i8"; - const outSlot = B.slot(); - B.entryAllocas.push(`${outSlot} = alloca ${outTy}`); - B.line(`store ${outTy} ${value.kind === "f64" ? f64Lit(0) : "0"}, ptr ${outSlot}`); - host.declare( - `declare zeroext i1 @scr_map_get_${kAcc}_${value.kind === "f64" ? "f64" : "bool"}(ptr, ${kTy}, ptr)`, - ); - const found = B.tmp(); - B.line( - `${found} = call zeroext i1 @scr_map_get_${kAcc}_${value.kind === "f64" ? "f64" : "bool"}(ptr ${r.name}, ${k.args}, ptr ${outSlot})`, - ); + let found: string; + let readHit: () => string; + if (numberKey !== null) { + const scalar = emitNumberMapGetScalar(host, r.name, numberKey); + found = scalar.found; + readHit = () => { + const hit = B.tmp(); + B.line( + value.kind === "f64" + ? `${hit} = bitcast i64 ${scalar.bits} to double` + : `${hit} = icmp ne i64 ${scalar.bits}, 0`, + ); + return hit; + }; + } else { + const outSlot = B.slot(); + B.entryAllocas.push(`${outSlot} = alloca ${outTy}`); + B.line(`store ${outTy} ${value.kind === "f64" ? f64Lit(0) : "0"}, ptr ${outSlot}`); + host.declare( + `declare zeroext i1 @scr_map_get_${kAcc}_${value.kind === "f64" ? "f64" : "bool"}(ptr, ${kTy}, ptr)`, + ); + found = B.tmp(); + B.line( + `${found} = call zeroext i1 @scr_map_get_${kAcc}_${value.kind === "f64" ? "f64" : "bool"}(ptr ${r.name}, ${k.args}, ptr ${outSlot})`, + ); + readHit = () => { + const rawOut = B.tmp(); + B.line(`${rawOut} = load ${outTy}, ptr ${outSlot}`); + if (value.kind !== "bool") return rawOut; + const hit = B.tmp(); + B.line(`${hit} = trunc i8 ${rawOut} to i1`); + return hit; + }; + } const slot = B.slot(); B.entryAllocas.push(`${slot} = alloca ptr`); const lp = B.newLabel("mg.p"); @@ -1023,13 +1059,7 @@ export function emitMapLikeIntrinsic( const lj = B.newLabel("mg.j"); B.condBr(found, lp, la); B.startBlock(lp); - const rawOut = B.tmp(); - B.line(`${rawOut} = load ${outTy}, ptr ${outSlot}`); - let hit = rawOut; - if (value.kind === "bool") { - hit = B.tmp(); - B.line(`${hit} = trunc i8 ${rawOut} to i1`); - } + const hit = readHit(); B.line( `store ptr ${host.unionNewOwned(e.type.unionId, valueTag, { name: hit, type: value })}, ptr ${slot}`, ); @@ -1042,6 +1072,10 @@ export function emitMapLikeIntrinsic( B.line(`${t} = load ptr, ptr ${slot}`); return host.own({ name: t, type: e.type }); } + if (numberKey !== null && isRefCounted(value)) { + const raw = emitNumberMapGetRef(host, r.name, numberKey, value); + return host.wrapNullable(raw, raw, value, valueTag, e.type, undefTag); + } host.declare(`declare ptr @scr_map_get_${kAcc}_ref(ptr, ${kTy})`); const raw = B.tmp(); B.line(`${raw} = call ptr @scr_map_get_${kAcc}_ref(ptr ${r.name}, ${k.args})`); @@ -1068,6 +1102,7 @@ export function emitMapLikeIntrinsic( } case "has": { const k = borrowInputs ? host.emitReadReceiver(e.args[0]!) : host.emitExpr(e.args[0]!); + if (kAcc === "f64") return { name: emitNumberMapHas(host, r.name, k.name), type: e.type }; host.declare(`declare zeroext i1 @scr_map_has_${kAcc}(ptr, ${kTy})`); const t = B.tmp(); B.line(`${t} = call zeroext i1 @scr_map_has_${kAcc}(ptr ${r.name}, ${kTy} ${k.name})`); 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 d1a325b07..f19deb721 100644 --- a/packages/compiler/src/backend/llvm/local-union-storage.test.ts +++ b/packages/compiler/src/backend/llvm/local-union-storage.test.ts @@ -109,7 +109,7 @@ test("conditional and reassigned locals own payload snapshots without heap wrapp expect(facts(mod).get("item")?.ownerType).toEqual(STRING); for (const bits of [32, 64] as const) { const ir = body(mod, bits); - expect(ir).toContain("@scr_map_get_f64_ref"); + expect(ir).toContain("call ptr @sc_map_entry_f64("); // inline number-key read expect(ir).toContain("@scr_str_release"); expect(ir).not.toMatch(/@scr_union_(?:new|retain|release)/); expect(ir).toContain("store i64 0"); diff --git a/packages/compiler/src/backend/llvm/map-number-lookup.ts b/packages/compiler/src/backend/llvm/map-number-lookup.ts new file mode 100644 index 000000000..af0b91bed --- /dev/null +++ b/packages/compiler/src/backend/llvm/map-number-lookup.ts @@ -0,0 +1,93 @@ +/* Inline number-key Map/Set reads. + * + * Number-keyed collections whose keys are small non-negative integers keep a + * direct index in the runtime (scr_map.c). Reads go through the internal + * sc_map_entry_f64 helper (emitter.ts helperDefs), which LLVM inlines: it + * resolves directly indexed maps without a call and defers to + * scr_map_entry_f64 otherwise. The generated code then reads the entry's + * value slot itself and retains a reference value with the value type's own + * retain, which is the adapter the collection was constructed with. + */ +import type { IrType } from "../../ir/ir.js"; +import type { LlvmEmitterContext } from "./expr-context.js"; + +export const NUMBER_MAP_ENTRY_DECL = "declare ptr @scr_map_entry_f64(ptr, double)"; + +/** The entry for `key`, or null when absent. Valid until the next mutation. */ +export function emitNumberMapEntry(host: LlvmEmitterContext, map: string, key: string): string { + host.declare(NUMBER_MAP_ENTRY_DECL); + const entry = host.B.tmp(); + host.B.line(`${entry} = call ptr @sc_map_entry_f64(ptr ${map}, double ${key})`); + return entry; +} + +/** Load a found entry's value slot as `type` (double, i64 or ptr). */ +export function emitNumberMapSlot(host: LlvmEmitterContext, entry: string, type: string): string { + const B = host.B; + const slot = B.tmp(), + value = B.tmp(); + B.line(`${slot} = getelementptr inbounds %ScrMapEntry, ptr ${entry}, i32 0, i32 1`); + B.line(`${value} = load ${type}, ptr ${slot}`); + return value; +} + +/** Map.get of a reference value: a +1 value, or null when absent — the + * contract of scr_map_get_f64_ref. */ +export function emitNumberMapGetRef( + host: LlvmEmitterContext, + map: string, + key: string, + value: IrType, +): string { + const B = host.B; + const entry = emitNumberMapEntry(host, map, key); + const found = B.tmp(); + B.line(`${found} = icmp ne ptr ${entry}, null`); + const hit = B.newLabel("mapn.hit"), + miss = B.newLabel("mapn.miss"), + join = B.newLabel("mapn.join"); + B.condBr(found, hit, miss); + B.startBlock(hit); + const retained = host.retainValue(emitNumberMapSlot(host, entry, "ptr"), value); + B.br(join); + B.startBlock(miss); + B.br(join); + B.startBlock(join); + const result = B.tmp(); + B.line(`${result} = phi ptr [ ${retained}, %${hit} ], [ null, %${miss} ]`); + return result; +} + +/** Map.get of a scalar value: the found flag, and the raw 64-bit slot + * (zero when absent). Booleans are stored as 0/1. */ +export function emitNumberMapGetScalar( + host: LlvmEmitterContext, + map: string, + key: string, +): { found: string; bits: string } { + const B = host.B; + const entry = emitNumberMapEntry(host, map, key); + const found = B.tmp(); + B.line(`${found} = icmp ne ptr ${entry}, null`); + const hit = B.newLabel("mapn.hit"), + miss = B.newLabel("mapn.miss"), + join = B.newLabel("mapn.join"); + B.condBr(found, hit, miss); + B.startBlock(hit); + const loaded = emitNumberMapSlot(host, entry, "i64"); + B.br(join); + B.startBlock(miss); + B.br(join); + B.startBlock(join); + const bits = B.tmp(); + B.line(`${bits} = phi i64 [ ${loaded}, %${hit} ], [ 0, %${miss} ]`); + return { found, bits }; +} + +/** Map.has / Set.has. */ +export function emitNumberMapHas(host: LlvmEmitterContext, map: string, key: string): string { + const entry = emitNumberMapEntry(host, map, key); + const found = host.B.tmp(); + host.B.line(`${found} = icmp ne ptr ${entry}, null`); + return found; +} diff --git a/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts b/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts index 3bd47d2ac..cd1fdf455 100644 --- a/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts @@ -94,19 +94,26 @@ function body(mod: IrModule, bits: 32 | 64 = 64): string { } test("local map results keep scalar and owned reference payloads without heap wrappers on both ABIs", () => { - for (const value of [F64, BOOL, STRING]) { - const mod = fixture(value); - expect(facts(mod).locals.has("value")).toBe(true); - for (const bits of [32, 64] as const) { - const ir = body(mod, bits); - expect(ir).toContain("alloca %ScrUnion"); - expect(ir).toContain("select i1"); - expect(ir).not.toContain("@scr_union_new"); - expect(ir).not.toContain("@scr_union_release"); - if (value === BOOL) expect(ir).toMatch(/zext i8 %\w+ to i64/); - if (value === STRING) expect(ir).toContain("@scr_str_release"); + const keys: IrType[] = [F64, STRING]; + const values: IrType[] = [F64, BOOL, STRING]; + for (const key of keys) + for (const value of values) { + const mod = fixture(value, key); + expect(facts(mod).locals.has("value")).toBe(true); + for (const bits of [32, 64] as const) { + const ir = body(mod, bits); + expect(ir).toContain("alloca %ScrUnion"); + expect(ir).toContain("select i1"); + expect(ir).not.toContain("@scr_union_new"); + expect(ir).not.toContain("@scr_union_release"); + if (key === F64) { + // Number keys read the entry's slot inline; booleans are stored 0/1. + expect(ir).toContain("call ptr @sc_map_entry_f64("); + expect(ir).not.toContain("@scr_map_get_"); + } else if (value === BOOL) expect(ir).toMatch(/zext i8 %\w+ to i64/); + if (value === STRING) expect(ir).toContain("@scr_str_release"); + } } - } }); test("nullable record results are the looked-up pointer itself on both ABIs", () => { diff --git a/packages/compiler/src/backend/llvm/map-read-lifetimes.ts b/packages/compiler/src/backend/llvm/map-read-lifetimes.ts index fa7846433..27a2fd195 100644 --- a/packages/compiler/src/backend/llvm/map-read-lifetimes.ts +++ b/packages/compiler/src/backend/llvm/map-read-lifetimes.ts @@ -9,6 +9,7 @@ import { import { everyStmtList } from "../../ir/traverse.js"; import type { CallLifetimes } from "./call-lifetimes.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; +import { emitNumberMapGetRef, emitNumberMapGetScalar } from "./map-number-lookup.js"; import { mapKeyAccess, mapKeyParamType } from "./shapes.js"; function hasTypedCollectionReceiver( @@ -144,7 +145,7 @@ export function emitMapLookupKey( host: LlvmEmitterContext, key: IrExpr, borrow: boolean, -): { access: string; types: string; args: string } { +): { access: string; types: string; args: string; value?: string } { const span = borrow && key.kind === "varRef" && key.type.kind === "string" ? host.splitSpans.get(key.localId) @@ -158,7 +159,12 @@ export function emitMapLookupKey( const value = borrow ? host.emitReadReceiver(key) : host.emitExpr(key); const access = mapKeyAccess(key.type); const type = mapKeyParamType(access); - return { access, types: type, args: `${type} ${value.name}` }; + return { access, types: type, args: `${type} ${value.name}`, value: value.name }; +} + +/** The key's double operand when the lookup can take the inline number path. */ +export function inlineNumberKey(key: { access: string; value?: string }): string | null { + return key.access === "f64" && key.value !== undefined ? key.value : null; } export interface StackMapRead { @@ -182,9 +188,23 @@ export function emitStackMapRead(host: LlvmEmitterContext, read: LocalMapRead): B.entryAllocas.push(`${box} = alloca %ScrUnion`); B.entryAllocas.push(`${payload} = getelementptr inbounds %ScrUnion, ptr ${box}, i32 0, i32 5`); B.entryAllocas.push(`${tag} = getelementptr inbounds %ScrUnion, ptr ${box}, i32 0, i32 1`); - const found = B.tmp(); + let found = B.tmp(); let owner: StackMapRead["owner"] = null; - if (isRefCounted(read.value)) { + const number = inlineNumberKey(key); + if (number !== null) { + if (isRefCounted(read.value)) { + const raw = emitNumberMapGetRef(host, receiver.name, number, read.value); + B.line(`${found} = icmp ne ptr ${raw}, null`); + B.line(`store i64 0, ptr ${payload}`); + B.line(`store ptr ${raw}, ptr ${payload}`); + owner = { slot: payload, type: read.value }; + } else { + // Booleans are stored as 0/1, already the widened union payload. + const scalar = emitNumberMapGetScalar(host, receiver.name, number); + found = scalar.found; + B.line(`store i64 ${scalar.bits}, ptr ${payload}`); + } + } else if (isRefCounted(read.value)) { const raw = B.tmp(); host.declare(`declare ptr @scr_map_get_${access}_ref(ptr, ${keyType})`); B.line(`${raw} = call ptr @scr_map_get_${access}_ref(ptr ${receiver.name}, ${key.args})`);