diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index ff9f4cc6d..114e03e0f 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -301,7 +301,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, literalCapWord32 } from "./string-key-hash.js"; -import { NUMBER_MAP_ENTRY_DECL } from "./map-number-lookup.js"; +import { NUMBER_MAP_ENTRY_DECL, STRING_MAP_ENTRY_DECL } from "./map-number-lookup.js"; import { STRING_READ_HELPERS } from "./string-reads.js"; import { emitConcatInputs, @@ -1899,9 +1899,13 @@ export class LlEmitter { ...(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 }`, ] : []), + // String-keyed reference reads load the value slot of the entry + // scr_map_entry_str returns (map-number-lookup.ts). + ...(this.decls.has(NUMBER_MAP_ENTRY_DECL) || this.decls.has(STRING_MAP_ENTRY_DECL) + ? [`%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. diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index a3ee2a63d..5a2366d50 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -23,11 +23,13 @@ import { borrowsMapReadInputs, borrowsMapMutationReceiver, inlineNumberKey, + entryStringKey, } from "./map-read-lifetimes.js"; import { emitNumberMapGetRef, emitNumberMapGetScalar, emitNumberMapHas, + emitStringMapGetRef, } from "./map-number-lookup.js"; import { emitBorrowedInput } from "./borrowed-inputs.js"; import { emitStringSliceRead } from "./string-slices.js"; @@ -1119,12 +1121,15 @@ export function emitMapLikeIntrinsic( return host.own({ name: t, type: e.type }); } const numberKey = inlineNumberKey(k); + const stringKey = entryStringKey(k); if (value.kind === "union") { const isnull = B.tmp(); const t = B.tmp(); let raw: string; if (numberKey !== null) { raw = emitNumberMapGetRef(host, r.name, numberKey, value); + } else if (stringKey !== null) { + raw = emitStringMapGetRef(host, r.name, stringKey, value); } else { host.declare(`declare ptr @scr_map_get_${kAcc}_ref(ptr, ${kTy})`); raw = B.tmp(); @@ -1197,6 +1202,10 @@ export function emitMapLikeIntrinsic( const raw = emitNumberMapGetRef(host, r.name, numberKey, value); return host.wrapNullable(raw, raw, value, valueTag, e.type, undefTag); } + if (stringKey !== null && isRefCounted(value)) { + const raw = emitStringMapGetRef(host, r.name, stringKey, 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})`); diff --git a/packages/compiler/src/backend/llvm/map-number-lookup.ts b/packages/compiler/src/backend/llvm/map-number-lookup.ts index af0b91bed..552770852 100644 --- a/packages/compiler/src/backend/llvm/map-number-lookup.ts +++ b/packages/compiler/src/backend/llvm/map-number-lookup.ts @@ -7,6 +7,9 @@ * 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. + * String-keyed reference reads use the same entry contract through + * scr_map_entry_str, so their retain is inline rather than the runtime's + * indirect adapter call. */ import type { IrType } from "../../ir/ir.js"; import type { LlvmEmitterContext } from "./expr-context.js"; @@ -31,6 +34,16 @@ export function emitNumberMapSlot(host: LlvmEmitterContext, entry: string, type: return value; } +export const STRING_MAP_ENTRY_DECL = "declare ptr @scr_map_entry_str(ptr, ptr)"; + +/** The entry for a string `key`, or null when absent. Valid until the next mutation. */ +export function emitStringMapEntry(host: LlvmEmitterContext, map: string, key: string): string { + host.declare(STRING_MAP_ENTRY_DECL); + const entry = host.B.tmp(); + host.B.line(`${entry} = call ptr @scr_map_entry_str(ptr ${map}, ptr ${key})`); + return entry; +} + /** Map.get of a reference value: a +1 value, or null when absent — the * contract of scr_map_get_f64_ref. */ export function emitNumberMapGetRef( @@ -39,8 +52,24 @@ export function emitNumberMapGetRef( key: string, value: IrType, ): string { + return emitMapEntryGetRef(host, emitNumberMapEntry(host, map, key), value); +} + +/** Map.get of a reference value under a string key, with scr_map_get_str_ref's + * contract. The value type's own retain replaces the collection's adapter, so + * the retain is inline instead of an indirect call in the runtime. */ +export function emitStringMapGetRef( + host: LlvmEmitterContext, + map: string, + key: string, + value: IrType, +): string { + return emitMapEntryGetRef(host, emitStringMapEntry(host, map, key), value); +} + +/** The +1 value of a found entry, or null for a missing (null) entry. */ +function emitMapEntryGetRef(host: LlvmEmitterContext, entry: 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"), 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 cd1fdf455..3d75432ec 100644 --- a/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/map-read-lifetimes.test.ts @@ -111,6 +111,11 @@ test("local map results keep scalar and owned reference payloads without heap wr 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/); + else if (value === STRING) { + // String keys read reference values through the entry and retain inline. + expect(ir).toContain("call ptr @scr_map_entry_str("); + expect(ir).not.toContain("@scr_map_get_str_ref"); + } if (value === STRING) expect(ir).toContain("@scr_str_release"); } } diff --git a/packages/compiler/src/backend/llvm/map-read-lifetimes.ts b/packages/compiler/src/backend/llvm/map-read-lifetimes.ts index 27a2fd195..7de971af7 100644 --- a/packages/compiler/src/backend/llvm/map-read-lifetimes.ts +++ b/packages/compiler/src/backend/llvm/map-read-lifetimes.ts @@ -9,7 +9,11 @@ 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 { + emitNumberMapGetRef, + emitNumberMapGetScalar, + emitStringMapGetRef, +} from "./map-number-lookup.js"; import { mapKeyAccess, mapKeyParamType } from "./shapes.js"; function hasTypedCollectionReceiver( @@ -167,6 +171,11 @@ export function inlineNumberKey(key: { access: string; value?: string }): string return key.access === "f64" && key.value !== undefined ? key.value : null; } +/** The key's string operand when a reference read can take the entry path. */ +export function entryStringKey(key: { access: string; value?: string }): string | null { + return key.access === "str" && key.value !== undefined ? key.value : null; +} + export interface StackMapRead { value: LlValue; owner: { slot: string; type: IrType } | null; @@ -205,9 +214,14 @@ export function emitStackMapRead(host: LlvmEmitterContext, read: LocalMapRead): 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})`); + const stringKey = entryStringKey(key); + let raw: string; + if (stringKey !== null) raw = emitStringMapGetRef(host, receiver.name, stringKey, read.value); + else { + 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})`); + } B.line(`${found} = icmp ne ptr ${raw}, null`); B.line(`store i64 0, ptr ${payload}`); B.line(`store ptr ${raw}, ptr ${payload}`); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index d43180b60..b7f4a71ef 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15901,6 +15901,12 @@ "/tests/corpus/switch-integer-dispatch.ts" ], "diags": [] + }, + "/tests/corpus/4651-map-string-get-entry.ts": { + "order": [ + "/tests/corpus/4651-map-string-get-entry.ts" + ], + "diags": [] } } } diff --git a/packages/runtime/src/scr_map.c b/packages/runtime/src/scr_map.c index a5a0cb62c..481b0d241 100644 --- a/packages/runtime/src/scr_map.c +++ b/packages/runtime/src/scr_map.c @@ -1086,6 +1086,11 @@ bool scr_map_get_str_bool(const ScrMap *m, const ScrStr *key, bool *out) { return true; } +const ScrMapEntry *scr_map_entry_str(const ScrMap *m, const ScrStr *key) { + size_t e = scr_map_find_str(m, key); + return e == SCR_MAP_EMPTY ? NULL : &m->entries[e]; +} + void *scr_map_get_str_ref(const ScrMap *m, const ScrStr *key) { size_t e = scr_map_find_str(m, key); if (e == SCR_MAP_EMPTY) return NULL; diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 7b8544211..fc4ab7ef5 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -2032,6 +2032,10 @@ _Static_assert(sizeof(ScrMapEntry) == 24 && offsetof(ScrMapEntry, val) == 8, /* Entry for a number key, or NULL when absent. The pointer is valid until * the next mutation; generated code reads the value slot through it. */ const ScrMapEntry *scr_map_entry_f64(const ScrMap *m, double key); +/* Entry for a string key, or NULL when absent, on the same terms. Typed + * reference reads retain the value with its static type's retain instead + * of the collection's adapter. */ +const ScrMapEntry *scr_map_entry_str(const ScrMap *m, const ScrStr *key); /* Called once on a fresh UNION_VALUE collection, before its first insert. */ void scr_map_union_keys(ScrMap *map, const uint8_t *kinds); diff --git a/packages/runtime/test/test_map.c b/packages/runtime/test/test_map.c index 82c46b590..09630fb31 100644 --- a/packages/runtime/test/test_map.c +++ b/packages/runtime/test/test_map.c @@ -126,6 +126,36 @@ static void test_string_keys(void) { scr_map_release(m); } +/* scr_map_entry_str: the entry of a string key, for generated code that + * reads the value slot and retains it with the value type's own retain. The + * lookup itself takes no reference, in linear and bucketed maps alike. */ +static void test_string_entries(void) { + ScrMap *m = scr_map_new(SCR_MAP_KEY_STR, SCR_MAP_VAL_REF, scr_str_retain_v, scr_str_release_v, NULL); + ScrStr *missing = S("missing"); + check(scr_map_entry_str(m, missing) == NULL, "empty map has no entry"); + char name[16]; + for (int i = 0; i < 40; i++) { + snprintf(name, sizeof name, "key%d", i); + ScrStr *k = S(name); + scr_map_set_str_ref(m, k, S(name)); /* value +1 moves in */ + scr_str_release(k); + ScrStr *probe = S("key0"); /* a different object with equal bytes */ + const ScrMapEntry *e = scr_map_entry_str(m, probe); + check(e != NULL, "entry found by content"); + ScrStr *v = (ScrStr *)(uintptr_t)e->val; + check(v->len == 4 && memcmp(v->data, "key0", 4) == 0, "entry value slot holds the value"); + check(v->rc == 1, "entry lookup takes no reference"); + scr_str_release(probe); + } + check(scr_map_entry_str(m, missing) == NULL, "absent key has no entry"); + ScrStr *k7 = S("key7"); + check(scr_map_delete_str(m, k7), "delete"); + check(scr_map_entry_str(m, k7) == NULL, "deleted key has no entry"); + scr_str_release(k7); + scr_str_release(missing); + scr_map_release(m); +} + /* String keys hash to 32 bits; heap strings cache the value in their header * and static strings (compiler literals, runtime one-character strings) * carry it precomputed. The vectors are shared with the compiler's @@ -601,6 +631,7 @@ int main(void) { test_key_bytes(); test_string_bucket_collisions(); test_string_keys(); + test_string_entries(); test_same_value_zero(); test_span_keys(); test_string_hash_cache(); diff --git a/tests/corpus/4651-map-string-get-entry.ts b/tests/corpus/4651-map-string-get-entry.ts new file mode 100644 index 000000000..6319d3a13 --- /dev/null +++ b/tests/corpus/4651-map-string-get-entry.ts @@ -0,0 +1,99 @@ +// String-keyed Map.get of reference values reads the entry and retains the +// value with its static type's retain. Results must outlive the entry: they +// survive delete, overwrite, clear and the map itself, for class instances, +// records, arrays, nested maps, cyclic objects and union-typed values, with +// literal, computed and sliced keys. +class Node { + name: string; + next: Node | undefined = undefined; + constructor(name: string) { + this.name = name; + } +} + +interface Box { + label: string; + items: number[]; +} + +const nodes = new Map(); +for (let i = 0; i < 20; i++) nodes.set("n" + i, new Node("node-" + i)); +const a = nodes.get("n3"); +const b = nodes.get("n" + (1 + 2) * 4); +const missing = nodes.get("nope"); +console.log(a?.name, b?.name, missing === undefined); +nodes.delete("n3"); +nodes.set("n12", new Node("replacement")); +console.log("after delete/overwrite:", a?.name, b?.name, nodes.get("n12")?.name); + +// A cycle through the map value and the result. +const ring = new Map(); +const first = new Node("first"); +const second = new Node("second"); +first.next = second; +second.next = first; +ring.set("first", first); +ring.set("second", second); +const viaMap = ring.get("second"); +ring.clear(); +console.log("cycle:", viaMap?.next?.next?.name, ring.size); + +// Records and arrays as values; keys sliced from a larger string. +const boxes = new Map(); +const source = "alpha,beta,gamma"; +for (const part of source.split(",")) boxes.set(part, { label: part.toUpperCase(), items: [part.length] }); +const keyed = boxes.get(source.slice(6, 10)); +boxes.set("beta", { label: "B2", items: [] }); +console.log("box:", keyed?.label, keyed?.items.join("|"), boxes.get("beta")?.label); +const lists = new Map([["evens", [0, 2, 4]], ["odds", [1, 3]]]); +const evens = lists.get("evens"); +lists.delete("evens"); +evens?.push(6); +console.log("array:", evens?.join(","), lists.has("evens")); + +// Maps as values: the inner map outlives its entry in the outer one. +const outer = new Map>(); +outer.set("inner", new Map([["x", new Node("inner-x")]])); +const inner = outer.get("inner"); +outer.delete("inner"); +inner?.set("y", new Node("inner-y")); +console.log("nested:", inner?.get("x")?.name, inner?.get("y")?.name, outer.size); + +// Union-typed values: nullable instances and mixed arms. +const maybe = new Map(); +maybe.set("set", new Node("present")); +maybe.set("null", null); +const present = maybe.get("set"); +const isNull = maybe.get("null"); +const absent = maybe.get("absent"); +maybe.delete("set"); +console.log("nullable:", present?.name, isNull === null, absent === undefined); +const mixed = new Map(); +mixed.set("node", new Node("mixed-node")); +mixed.set("text", "plain"); +const m1 = mixed.get("node"); +const m2 = mixed.get("text"); +mixed.set("node", "overwritten"); +console.log("mixed:", typeof m1 === "string" ? m1 : m1?.name, m2, mixed.get("node")); + +// Many lookups through a growing map (linear scan, then buckets), keeping +// every result alive past the map. +function collect(): Node[] { + const table = new Map(); + const out: Node[] = []; + for (let i = 0; i < 64; i++) { + table.set(`k${i}`, new Node(`v${i}`)); + const back = table.get(`k${Math.floor(i / 2)}`); + if (back !== undefined) out.push(back); + } + return out; +} +const kept = collect(); +console.log("kept:", kept.length, kept[0]!.name, kept[63]!.name); + +// A result held in a local across a loop that rewrites the entry. +const counters = new Map(); +counters.set("c", { label: "start", items: [] }); +const held = counters.get("c")!; +for (let i = 0; i < 5; i++) counters.set("c", { label: "round" + i, items: [i] }); +console.log("held:", held.label, counters.get("c")?.label);