diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 879cf7a7c..d55ed626a 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -191,6 +191,7 @@ import { matchMapRead, type MapReadLifetimes, } from "./map-read-lifetimes.js"; +import { findMapEntryFusions, type MapEntryFusion } from "./map-entry-fusion.js"; import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js"; import { borrowableInputs, emitBorrowedInput, emitBorrowedInputs } from "./borrowed-inputs.js"; import { analyzeWalks, findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js"; @@ -728,6 +729,8 @@ export class LlEmitter { callArrayReads = new Map(); mapReadLifetimes: MapReadLifetimes = { locals: new Map(), arguments: new Map() }; readNumberMapWrites: ReadonlySet = new Set(); + /** The current function's fused string-key Map get-then-set pairs (map-entry-fusion.ts). */ + mapEntryFusion: MapEntryFusion | null = null; readonly callLifetimes: CallLifetimes; readonly constantCallbacks: Map>; currentConstantCallbacks: ReadonlyMap = new Map(); @@ -5474,7 +5477,11 @@ export class LlEmitter { } const g = this.globalTypes.get(id); if (!g) throw new InternalCompilerError(`llvm emitter bug: unknown binding ${id}`); - return { kind: "global", slot: `@${mangleGlobal(id)}`, type: g }; + return { + kind: "global", + slot: `@${mangleGlobal(id)}`, + type: g, + }; } private emitDebugLocal(local: IrLocal, slot: string, arg = 0, captured = false): void { @@ -5945,6 +5952,7 @@ export class LlEmitter { this.debug === null ? findScalarStringSlices(fn, this.callLifetimes) : new Map(); this.mapReadLifetimes = findMapReadLifetimes(fn, this.boxedUnionsById, this.callLifetimes); this.readNumberMapWrites = findReadNumberMapWrites(fn); + this.mapEntryFusion = this.debug === null ? findMapEntryFusions(fn) : null; this.localStackUnions = findLocalStackUnions(fn, this.callLifetimes, this.boxedUnionsById); this.localStackObjects = this.stackObjects ? findLocalStackObjects(this, fn, this.callLifetimes) diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index f286af86a..a2d5fd35a 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -41,6 +41,7 @@ import { emitStringMapHas, } from "./map-number-lookup.js"; import { emitBorrowedInput } from "./borrowed-inputs.js"; +import { emitFusedMapSet, rememberMapEntry } from "./map-entry-fusion.js"; import { emitStringSliceRead } from "./string-slices.js"; import { STRING_CHAR_CODE_AT_DECL, @@ -1265,6 +1266,7 @@ export function emitMapLikeIntrinsic( ? emitNumberMapGetScalar(host, r.name, numberKey) : emitStringMapGetScalar(host, r.name, stringKey!); found = scalar.found; + if (stringKey !== null) rememberMapEntry(host, e, r.name, stringKey, scalar.entry); readHit = () => { const hit = B.tmp(); B.line( @@ -1335,6 +1337,7 @@ export function emitMapLikeIntrinsic( const v = host.emitExpr(e.args[1]!); const vAcc = elemAccess(receiverType.value); if (vAcc === "ref") host.moveTemp(v); + const ordinary = (): void => host.mapSet(r.name, kAcc, vAcc, k.name, v.name); if (kAcc === "f64" && vAcc !== "ref" && host.readNumberMapWrites.has(e)) { const bits = B.tmp(); B.line( @@ -1342,10 +1345,13 @@ export function emitMapLikeIntrinsic( ? `${bits} = bitcast double ${v.name} to i64` : `${bits} = zext i1 ${v.name} to i64`, ); - emitNumberMapSetScalar(host, r.name, k.name, bits, () => - host.mapSet(r.name, kAcc, vAcc, k.name, v.name), - ); - } else host.mapSet(r.name, kAcc, vAcc, k.name, v.name); + emitNumberMapSetScalar(host, r.name, k.name, bits, ordinary); + } else if ( + kAcc !== "str" || + vAcc === "ref" || + !emitFusedMapSet(host, e, r.name, k.name, { name: v.name, access: vAcc }, ordinary) + ) + ordinary(); return { name: "", type: e.type }; } case "add": { diff --git a/packages/compiler/src/backend/llvm/map-entry-fusion.test.ts b/packages/compiler/src/backend/llvm/map-entry-fusion.test.ts new file mode 100644 index 000000000..26f2a7c3c --- /dev/null +++ b/packages/compiler/src/backend/llvm/map-entry-fusion.test.ts @@ -0,0 +1,223 @@ +import { expect, test } from "vitest"; +import { + BOOL, + F64, + STRING, + UNDEFINED_T, + VOID, + 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"; +import { findMapEntryFusions } from "./map-entry-fusion.js"; + +const loc = { file: "map-fusion.ts", start: 0, end: 1 }; +const optional: IrType = { kind: "union", unionId: "optional" }; +const ref = (localId: string, type: IrType): IrExpr => ({ kind: "varRef", localId, type, loc }); + +function mapType(key: IrType, value: IrType): IrType { + return { kind: "map", key, value }; +} + +function get(map: IrExpr, key: IrExpr): IrExpr { + return { kind: "mapIntrinsic", method: "get", receiver: map, args: [key], type: optional, loc }; +} + +function set(map: IrExpr, key: IrExpr, value: IrExpr): IrStmt { + return { + kind: "exprStmt", + loc, + expr: { + kind: "mapIntrinsic", + method: "set", + receiver: map, + args: [key, value], + type: VOID, + loc, + }, + }; +} + +/** `(cur is undefined ? 0 : narrow(cur)) + 1` over the optional local. */ +function increment(value: IrType): IrExpr { + const cur = ref("cur", optional); + if (value.kind === "bool") return { kind: "boolLit", value: true, type: BOOL, loc }; + return { + kind: "bin", + op: "+", + left: { + kind: "ternary", + cond: { + kind: "unionIsTag", + value: cur, + unionId: "optional", + tag: 1, + negated: false, + type: BOOL, + loc, + }, + then: { kind: "numLit", value: 0, type: F64, loc }, + else_: { kind: "unionNarrow", unionId: "optional", tag: 0, value: cur, type: F64, loc }, + type: F64, + loc, + }, + right: { kind: "numLit", value: 1, type: F64, loc }, + type: F64, + loc, + }; +} + +function fixture( + body: (map: IrExpr, key: IrExpr, other: IrExpr) => IrStmt[], + key: IrType = STRING, + value: IrType = F64, +): IrModule { + const map = mapType(key, value); + const params = [ + { localId: "map", name: "map", type: map }, + { localId: "other", name: "other", type: map }, + { localId: "key", name: "key", type: key }, + ]; + const work: IrFunction = { + name: "work", + params, + returnType: VOID, + loc, + locals: [ + ...params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: true })), + { id: "cur", name: "cur", type: optional, mutable: false }, + ], + body: body(ref("map", map), ref("key", key), ref("other", map)), + }; + return { + irVersion: 16, + sourceFile: loc.file, + entry: "main", + unions: [{ id: "optional", arms: [value, UNDEFINED_T] }], + functions: [{ name: "main", params: [], locals: [], returnType: VOID, body: [], loc }, work], + }; +} + +const declare = (map: IrExpr, key: IrExpr): IrStmt => ({ + kind: "varDecl", + localId: "cur", + init: get(map, key), + loc, +}); + +function pairs(mod: IrModule): number { + return findMapEntryFusions(mod.functions[1]!).writes.size; +} + +function body(mod: IrModule, bits: 32 | 64 = 64): string { + expect(validateModule(mod)).toEqual([]); + const llvm = emitLlvmModule(mod, { pointerBits: bits }); + return /^define internal [^\n]*@sc_(?:b)?f_work\([^]*?^}/m.exec(llvm)![0]; +} + +test("a read followed by a write of the same map pairs, and the write reuses the entry", () => { + for (const value of [F64, BOOL]) { + const mod = fixture( + (map, k) => [declare(map, k), set(map, k, increment(value))], + STRING, + value, + ); + expect(pairs(mod)).toBe(1); + for (const bits of [32, 64] as const) { + const ir = body(mod, bits); + expect(ir).toContain("mapfuse.hit"); + // The miss still inserts through the runtime. + expect(ir).toMatch(/call void @scr_map_set_str_(?:f64|bool)\(/); + expect(ir).toContain("@sc_map_entry_str("); + } + } +}); + +test("number keys keep the write-after-read lookup instead of pairing", () => { + // findReadNumberMapWrites stores such writes into the found entry through + // a read-only probe that LLVM merges with the read's. + const mod = fixture((map, k) => [declare(map, k), set(map, k, increment(F64))], F64, F64); + expect(pairs(mod)).toBe(0); + expect(body(mod)).not.toContain("mapfuse.hit"); +}); + +test("the single-statement form pairs an unconditional nested read", () => { + const mod = fixture((map, k) => [ + set(map, k, { + kind: "bin", + op: "+", + left: { + kind: "nullish", + left: get(map, k), + right: { kind: "numLit", value: 0, type: F64, loc }, + type: F64, + loc, + }, + right: { kind: "numLit", value: 1, type: F64, loc }, + type: F64, + loc, + }), + ]); + expect(pairs(mod)).toBe(1); +}); + +test("reads behind a condition, intervening statements and other effects do not pair", () => { + // The read only runs on one arm. + const conditional = fixture((map, k) => [ + set(map, k, { + kind: "ternary", + cond: { kind: "boolLit", value: true, type: BOOL, loc }, + then: { + kind: "nullish", + left: get(map, k), + right: { kind: "numLit", value: 0, type: F64, loc }, + type: F64, + loc, + }, + else_: { kind: "numLit", value: 0, type: F64, loc }, + type: F64, + loc, + }), + ]); + expect(pairs(conditional)).toBe(0); + // A mutation of any map between the read and the write. + const between = fixture((map, k, other) => [ + declare(map, k), + set(other, k, { kind: "numLit", value: 1, type: F64, loc }), + set(map, k, increment(F64)), + ]); + expect(findMapEntryFusions(between.functions[1]!).reads.size).toBe(1); + expect( + [...findMapEntryFusions(between.functions[1]!).writes.keys()].map( + (w) => w.kind === "mapIntrinsic" && w.receiver.kind === "varRef" && w.receiver.localId, + ), + ).toEqual(["other"]); + // A call in the written value may run arbitrary code. + const call = fixture((map, k) => [ + declare(map, k), + set(map, k, { kind: "call", callee: "main", args: [], type: F64, loc }), + ]); + expect(pairs(call)).toBe(0); +}); + +test("writes to another map or key stay correct at run time", () => { + // The pairing is syntactic; identity checks decide reuse when it runs. + const mod = fixture((map, k, other) => [declare(map, k), set(other, k, increment(F64))]); + expect(pairs(mod)).toBe(1); + const ir = body(mod); + expect(ir).toMatch(/icmp eq ptr %\w+, %\w+/); + expect(ir).toContain("mapfuse.miss"); +}); + +test("reference values never fuse", () => { + const mod = fixture( + (map, k) => [declare(map, k), set(map, k, { kind: "strLit", value: "x", type: STRING, loc })], + STRING, + STRING, + ); + expect(pairs(mod)).toBe(0); +}); diff --git a/packages/compiler/src/backend/llvm/map-entry-fusion.ts b/packages/compiler/src/backend/llvm/map-entry-fusion.ts new file mode 100644 index 000000000..bc2ca07f3 --- /dev/null +++ b/packages/compiler/src/backend/llvm/map-entry-fusion.ts @@ -0,0 +1,299 @@ +/* Map get-then-set fusion. + * + * `const cur = m.get(k); m.set(k, f(cur))` and `m.set(k, (m.get(k) ?? 0) + 1)` + * probe the hash index twice for one key. The read already resolves the + * entry (sc_map_entry_str, map-number-lookup.ts), and an entry stays valid + * until the next mutation of its map. When nothing that + * runs between the read and the write can mutate any collection or run user + * code, the write reuses that entry: the read parks the map, the key and the + * entry in function-local slots, and the write overwrites the entry's value + * slot in place when the map and key are the very same (identical + * pointers) and the entry was found. Every other case, a miss + * included, takes the ordinary set, which inserts at the end. An existing + * key's overwrite keeps its position, so insertion order is unchanged, and a + * key match by identity implies the SameValueZero match the set would find. + * + * Only scalar values (number, boolean) fuse: overwriting them releases + * nothing, exactly like the runtime's own overwrite. Only string-keyed + * typed maps fuse here: a number-key write after a read of the same key + * already probes through the read-only entry lookup that LLVM merges with + * the read's (findReadNumberMapWrites, map-read-lifetimes.ts). + */ +import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; +import type { LlvmEmitterContext } from "./expr-context.js"; +import { borrowsMapReadInputs, borrowsMapMutationReceiver } from "./map-read-lifetimes.js"; +import { mapKeyAccess } from "./shapes.js"; + +type MapOp = Extract; + +/** One fused read/write pair. The slots exist once the read emits through + * an entry path; a write whose read took another path stays ordinary. */ +export interface MapEntryPair { + slots: { map: string; key: string; entry: string } | null; +} + +export interface MapEntryFusion { + reads: Map; + writes: Map; +} + +/** A typed string-keyed Map read/write with a scalar value. */ +function fusableOp(e: IrExpr, method: "get" | "set"): e is MapOp { + if (e.kind !== "mapIntrinsic" || e.method !== method) return false; + const receiver = e.receiver.type; + if (receiver.kind !== "map") return false; + if (receiver.value.kind !== "f64" && receiver.value.kind !== "bool") return false; + if (mapKeyAccess(receiver.key) !== "str") return false; + return method === "get" ? borrowsMapReadInputs(e) : borrowsMapMutationReceiver(e); +} + +/** Expression kinds that run no user code and mutate no collection or + * reference (reads, scalar arithmetic, control within the expression, and + * compiler-resolved throws, which leave the statement). */ +const INERT_KINDS: ReadonlySet = new Set([ + "numLit", + "strLit", + "boolLit", + "unitLit", + "varRef", + "bin", + "unary", + "toBool", + "logical", + "ternary", + "nullish", + "orDefault", + "strEq", + "unionIsTag", + "unionNarrow", + "recordGet", + "fieldGet", +]); + +function inert(e: IrExpr, allowed: IrExpr | null): boolean { + if (e === allowed) return true; + if (e.kind === "mapIntrinsic") { + if (!borrowsMapReadInputs(e) || (e.method !== "get" && e.method !== "has")) return false; + } else if (e.kind === "libCall") { + if (e.fn !== "error.nodeThrow") return false; + } else if (!INERT_KINDS.has(e.kind)) return false; + return everyExprChild( + e, + (child) => inert(child, allowed), + () => false, + ); +} + +/** Whether evaluating `e` always evaluates `target` (it is not behind a + * short-circuit or a conditional arm). */ +function alwaysEvaluates(e: IrExpr, target: IrExpr): boolean { + if (e === target) return true; + switch (e.kind) { + case "bin": + return alwaysEvaluates(e.left, target) || alwaysEvaluates(e.right, target); + case "unary": + case "toBool": + return alwaysEvaluates(e.operand, target); + case "logical": + case "nullish": + case "orDefault": + return alwaysEvaluates(e.left, target); + case "ternary": + return alwaysEvaluates(e.cond, target); + case "unionIsTag": + case "unionNarrow": + return alwaysEvaluates(e.value, target); + default: + return false; + } +} + +/** The single fusable read of `key`'s map inside `e`, if exactly one. */ +function soleRead(e: IrExpr): MapOp | null { + let found: MapOp | null = null; + let count = 0; + const visit = (node: IrExpr): boolean => { + if (node.kind === "mapIntrinsic" && node.method === "get") { + count++; + if (fusableOp(node, "get")) found = node; + } + return everyExprChild(node, visit, () => true); + }; + visit(e); + return count === 1 ? found : null; +} + +/** Same scalar value kind and key access on both sides. */ +function compatible(read: MapOp, write: MapOp): boolean { + const r = read.receiver.type, + w = write.receiver.type; + return ( + r.kind === "map" && + w.kind === "map" && + r.value.kind === w.value.kind && + mapKeyAccess(r.key) === mapKeyAccess(w.key) + ); +} + +/** Find the fusable read/write pairs of one function. Coroutines keep their + * frames in their own storage and are left alone. */ +export function findMapEntryFusions(fn: IrFunction): MapEntryFusion { + const out: MapEntryFusion = { reads: new Map(), writes: new Map() }; + if (fn.async || fn.generator) return out; + const pair = (read: MapOp, write: MapOp): void => { + if (!compatible(read, write) || out.reads.has(read) || out.writes.has(write)) return; + const entry: MapEntryPair = { slots: null }; + out.reads.set(read, entry); + out.writes.set(write, entry); + }; + const writeOf = (s: IrStmt | undefined): MapOp | null => + s?.kind === "exprStmt" && fusableOp(s.expr, "set") ? s.expr : null; + const visitList = (list: readonly IrStmt[]): void => { + for (let i = 0; i < list.length; i++) { + const s = list[i]!; + // m.set(k, ...m.get(k)...): the read is the only one and runs + // unconditionally after the receiver and key. + const single = writeOf(s); + if (single) { + const read = soleRead(single.args[1]!); + if ( + read && + alwaysEvaluates(single.args[1]!, read) && + inert(single.receiver, null) && + inert(single.args[0]!, null) && + inert(single.args[1]!, read) + ) + pair(read, single); + } + // const cur = m.get(k); m.set(k, ...): the read is the declaration's + // whole initializer, and the write follows directly. + const value = + s.kind === "varDecl" ? s.init : s.kind === "assign" && !s.initializes ? s.value : null; + const next = writeOf(list[i + 1]); + if ( + value && + next && + fusableOp(value, "get") && + inert(value.receiver, null) && + inert(value.args[0]!, null) && + inert(next.receiver, null) && + next.args.every((arg) => inert(arg, null)) + ) + pair(value, next); + } + for (const s of list) visitStmt(s); + }; + const visitStmt = (s: IrStmt): void => { + switch (s.kind) { + case "if": + visitList(s.then); + if (s.else_) visitList(s.else_); + return; + case "while": + case "doWhile": + case "forOf": + case "block": + visitList(s.body); + return; + case "for": + visitList(s.body); + return; + case "switch": + for (const c of s.cases) visitList(c.body); + return; + case "tryCatch": + visitList(s.tryBody); + if (s.catchBody) visitList(s.catchBody); + if (s.finallyBody) visitList(s.finallyBody); + return; + default: + everyStmtChild( + s, + () => true, + (child) => (visitStmt(child), true), + ); + } + }; + visitList(fn.body); + return out; +} + +/** After a fused read resolved `entry` for the string `key` in `map`: park + * all three for the paired write. */ +export function rememberMapEntry( + host: LlvmEmitterContext, + read: IrExpr, + map: string, + key: string, + entry: string, +): void { + const pair = host.mapEntryFusion?.reads.get(read); + if (!pair) return; + const B = host.B; + if (pair.slots === null) { + const slots = { map: B.slot(), key: B.slot(), entry: B.slot() }; + B.entryAllocas.push( + `${slots.map} = alloca ptr ; fused map read: map`, + `${slots.key} = alloca ptr ; fused map read: key`, + `${slots.entry} = alloca ptr ; fused map read: entry`, + `store ptr null, ptr ${slots.entry}`, + ); + pair.slots = slots; + } + const slots = pair.slots; + B.line(`store ptr ${map}, ptr ${slots.map}`); + B.line(`store ptr ${key}, ptr ${slots.key}`); + B.line(`store ptr ${entry}, ptr ${slots.entry}`); +} + +/** Emit a fused write: overwrite the parked entry's value in place when it + * belongs to this map and key, else run `ordinary`. False when `write` is + * not fused or its read never parked an entry (emit the ordinary set). */ +export function emitFusedMapSet( + host: LlvmEmitterContext, + write: IrExpr, + map: string, + key: string, + value: { name: string; access: "f64" | "bool" }, + ordinary: () => void, +): boolean { + const slots = host.mapEntryFusion?.writes.get(write)?.slots; + if (!slots) return false; + const B = host.B; + const parkedMap = B.tmp(), + parkedKey = B.tmp(), + entry = B.tmp(), + sameMap = B.tmp(), + sameKey = B.tmp(), + found = B.tmp(), + both = B.tmp(), + reuse = B.tmp(); + B.line(`${parkedMap} = load ptr, ptr ${slots.map}`); + B.line(`${parkedKey} = load ptr, ptr ${slots.key}`); + B.line(`${entry} = load ptr, ptr ${slots.entry}`); + // The entry is valid only between its read and this write. + B.line(`store ptr null, ptr ${slots.entry}`); + B.line(`${found} = icmp ne ptr ${entry}, null`); + B.line(`${sameMap} = icmp eq ptr ${parkedMap}, ${map}`); + B.line(`${sameKey} = icmp eq ptr ${parkedKey}, ${key}`); + B.line(`${both} = and i1 ${sameMap}, ${sameKey}`); + B.line(`${reuse} = select i1 ${found}, i1 ${both}, i1 false`); + const hit = B.newLabel("mapfuse.hit"), + miss = B.newLabel("mapfuse.miss"), + done = B.newLabel("mapfuse.done"); + B.condBr(reuse, hit, miss); + B.startBlock(hit); + const slot = B.tmp(), + bits = B.tmp(); + B.line(`${slot} = getelementptr inbounds %ScrMapEntry, ptr ${entry}, i32 0, i32 1`); + if (value.access === "f64") B.line(`${bits} = bitcast double ${value.name} to i64`); + else B.line(`${bits} = zext i1 ${value.name} to i64`); + B.line(`store i64 ${bits}, ptr ${slot}`); + B.br(done); + B.startBlock(miss); + ordinary(); + B.br(done); + B.startBlock(done); + return true; +} diff --git a/packages/compiler/src/backend/llvm/map-number-lookup.ts b/packages/compiler/src/backend/llvm/map-number-lookup.ts index 58b57e5ce..57c517f6a 100644 --- a/packages/compiler/src/backend/llvm/map-number-lookup.ts +++ b/packages/compiler/src/backend/llvm/map-number-lookup.ts @@ -68,7 +68,7 @@ export function emitStringMapGetScalar( host: LlvmEmitterContext, map: string, key: string, -): { found: string; bits: string } { +): { found: string; bits: string; entry: string } { return emitMapEntryGetScalar(host, emitStringMapEntry(host, map, key)); } @@ -121,7 +121,7 @@ export function emitNumberMapGetScalar( host: LlvmEmitterContext, map: string, key: string, -): { found: string; bits: string } { +): { found: string; bits: string; entry: string } { return emitMapEntryGetScalar(host, emitNumberMapEntry(host, map, key)); } @@ -129,7 +129,7 @@ export function emitNumberMapGetScalar( function emitMapEntryGetScalar( host: LlvmEmitterContext, entry: string, -): { found: string; bits: string } { +): { found: string; bits: string; entry: string } { const B = host.B; const found = B.tmp(); B.line(`${found} = icmp ne ptr ${entry}, null`); @@ -145,7 +145,7 @@ function emitMapEntryGetScalar( B.startBlock(join); const bits = B.tmp(); B.line(`${bits} = phi i64 [ ${loaded}, %${hit} ], [ 0, %${miss} ]`); - return { found, bits }; + return { found, bits, entry }; } /** Map.has / Set.has. */ diff --git a/packages/compiler/src/backend/llvm/map-read-lifetimes.ts b/packages/compiler/src/backend/llvm/map-read-lifetimes.ts index d89ecdde1..005f88f13 100644 --- a/packages/compiler/src/backend/llvm/map-read-lifetimes.ts +++ b/packages/compiler/src/backend/llvm/map-read-lifetimes.ts @@ -17,6 +17,7 @@ import { emitStringMapGetRef, } from "./map-number-lookup.js"; import { mapKeyAccess, mapKeyParamType } from "./shapes.js"; +import { rememberMapEntry } from "./map-entry-fusion.js"; function hasTypedCollectionReceiver( expr: IrExpr, @@ -64,6 +65,8 @@ export function borrowsMapMutationReceiver(expr: IrExpr): boolean { } export interface LocalMapRead { + /** The Map.get expression itself. */ + expr: IrExpr; type: IrType; receiver: IrExpr; key: IrExpr; @@ -99,6 +102,7 @@ export function matchMapRead( const missingTag = arms.findIndex((arm) => arm.kind === "undefinedT"); if (presentTag < 0 || missingTag < 0) return null; return { + expr, type: expr.type, receiver: expr.receiver, key: expr.args[0]!, @@ -314,6 +318,7 @@ export function emitStackMapRead(host: LlvmEmitterContext, read: LocalMapRead): } else if (string !== null && !isRefCounted(read.value)) { const scalar = emitStringMapGetScalar(host, receiver.name, string); found = scalar.found; + rememberMapEntry(host, read.expr, receiver.name, string, scalar.entry); B.line(`store i64 ${scalar.bits}, ptr ${payload}`); } else if (isRefCounted(read.value)) { const stringKey = entryStringKey(key); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-get-set-fusion.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-get-set-fusion.ts.json new file mode 100644 index 000000000..a9caeafcf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/map-get-set-fusion.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/map-get-set-fusion.ts" + ], + "diags": [] +} diff --git a/tests/corpus/map-get-set-fusion.ts b/tests/corpus/map-get-set-fusion.ts new file mode 100644 index 000000000..459702e3a --- /dev/null +++ b/tests/corpus/map-get-set-fusion.ts @@ -0,0 +1,135 @@ +// Map get followed by set on the same key reuses the read's entry when +// nothing in between can touch a collection. Present and absent keys, NaN +// and signed zeros, aliases, other maps and keys, and intervening mutations +// must keep Node's values and insertion order. + +function show(label: string, m: Map): void { + const parts: string[] = []; + for (const [k, v] of m) parts.push(k + "=" + v); + console.log(label, m.size, parts.join(" ")); +} + +function showNumbers(label: string, m: Map): void { + const parts: string[] = []; + for (const [k, v] of m) parts.push((Object.is(k, -0) ? "-0" : String(k)) + "=" + v); + console.log(label, m.size, parts.join(" ")); +} + +// Counting: the first occurrence inserts, later ones overwrite in place. +const words = "the cat and the hat and the bat".split(" "); +const counts: Map = new Map(); +for (const w of words) { + const cur = counts.get(w); + counts.set(w, (cur === undefined ? 0 : cur) + 1); +} +show("counts", counts); + +// The single-statement form. +const lengths: Map = new Map(); +for (const w of words) lengths.set(w, (lengths.get(w) ?? 0) + w.length); +show("lengths", lengths); + +// Number keys: NaN finds NaN, -0 and +0 are one key (SameValueZero). +const nums: Map = new Map(); +const keys = [1, NaN, -0, 0, 2, NaN, 0, -0, 1, Number.NaN]; +for (const k of keys) { + const prev = nums.get(k); + nums.set(k, prev === undefined ? 100 : prev + 1); +} +showNumbers("nums", nums); +console.log("has -0", nums.has(-0), "get 0", nums.get(0), "get NaN", nums.get(NaN)); + +// A NaN value is stored and read back. +const nanValues: Map = new Map(); +nanValues.set("x", 1); +const x0 = nanValues.get("x"); +nanValues.set("x", (x0 ?? 0) * NaN); +console.log("nan value", nanValues.get("x")); + +// An alias of the same map, and a write to a different map or key. +const a: Map = new Map([ + ["p", 1], + ["q", 2], +]); +const alias = a; +const other: Map = new Map([["p", 50]]); +const p1 = a.get("p"); +alias.set("p", (p1 ?? 0) + 10); +const p2 = a.get("p"); +other.set("p", (p2 ?? 0) + 1); +const q1 = a.get("q"); +a.set("r", (q1 ?? 0) + 3); +show("a", a); +show("other", other); + +// The map changes between the read and the write: delete re-appends, +// clear empties, a fresh insert grows the table. +const m: Map = new Map([ + ["k1", 1], + ["k2", 2], + ["k3", 3], +]); +const v1 = m.get("k1"); +m.delete("k1"); +m.set("k1", (v1 ?? 0) + 1); +show("deleted", m); +const v2 = m.get("k2"); +m.set("k2", (v2 ?? 0) + (m.delete("k3") ? 100 : 0)); +show("delete inside", m); +const v3 = m.get("k2"); +m.clear(); +m.set("k2", (v3 ?? 0) + 1); +show("cleared", m); +for (let i = 0; i < 40; i++) { + const key = "g" + (i % 17); + const before = m.get(key); + m.set(key, (before ?? 0) + i); +} +show("grown", m); + +// Keys built fresh for the write still update the same entry. +const fresh: Map = new Map(); +for (let i = 0; i < 6; i++) { + const got = fresh.get("id" + (i % 2)); + fresh.set("id" + (i % 2), (got ?? 0) + i); +} +show("fresh", fresh); + +// Boolean values toggle in place. +const flags: Map = new Map(); +for (let i = 0; i < 7; i++) { + const k = i % 3; + const f = flags.get(k); + flags.set(k, f === undefined ? true : !f); +} +console.log("flags", [...flags.entries()].map(([k, v]) => k + ":" + v).join(" ")); + +// Inside a function, behind a condition, and with a throwing key read. +type Row = { cat: string; v: number }; +function aggregate(rows: Row[]): Map { + const agg: Map = new Map(); + for (const r of rows) { + if (r.v < 0) continue; + const cur = agg.get(r.cat); + agg.set(r.cat, (cur === undefined ? 0 : cur) + r.v); + } + return agg; +} +const rows: Row[] = [ + { cat: "b", v: 2 }, + { cat: "a", v: 1 }, + { cat: "b", v: -5 }, + { cat: "c", v: 4 }, + { cat: "a", v: 8 }, +]; +show("aggregate", aggregate(rows)); +try { + const maybe: (Row | undefined)[] = [rows[0], undefined]; + const agg: Map = new Map(); + for (const r of maybe) { + const cur = agg.get((r as Row).cat); + agg.set((r as Row).cat, (cur ?? 0) + 1); + } +} catch (e) { + console.log("threw", e instanceof TypeError); +}