From 039ca078ca354bc07fb190df8b6f4e830e83e246 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 16:54:44 -0500 Subject: [PATCH] Check unit records against one content snapshot per wave instead of per-unit copies Recording a unit copied the keys and values of every appendable table before the unit and compared them after it. Each appendable table now keeps one snapshot of its entries' content per recorded wave (JournalTable.contents): a unit is checked only on the entries its IR and new entries name, by key or by the helper name its value holds, and the wave's end checks the content and key order of every entry. The name scan keeps only the strings the tables are keyed by (names that start with % and union ids). ShapeRegistry.journalSizes counts its declared-order candidates and attached names as they are added. On tsc-ts the serial mode's overhead over plain lowering falls from 12.7 s to 10.4 s. --- .../frontend/lowering/unit-journal.test.ts | 25 ++- .../src/frontend/lowering/unit-journal.ts | 197 ++++++++++-------- packages/compiler/src/frontend/type-mapper.ts | 19 +- 3 files changed, 140 insertions(+), 101 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/unit-journal.test.ts b/packages/compiler/src/frontend/lowering/unit-journal.test.ts index f92f11dc7..3b9599d7b 100644 --- a/packages/compiler/src/frontend/lowering/unit-journal.test.ts +++ b/packages/compiler/src/frontend/lowering/unit-journal.test.ts @@ -147,20 +147,27 @@ test("a map table hands over the entries added after a size", () => { expect([...map]).toEqual([["a", "%h.1"]]); }); -test("a held table notices a replaced entry", () => { +test("a table's content snapshot notices changed, reordered and removed entries", () => { const map = new Map([["a", "%h.1"]]); - const held = mapTable("helpers", map).hold!(); + const held = mapTable("helpers", map).contents!(); map.set("b", "%h.2"); - expect(held.same()).toBe(true); + expect(held.changed()).toBeNull(); map.set("a", "%h.9"); - expect(held.same()).toBe(false); - const set = new Set(["x"]); - const heldSet = setTable("names", set).hold!(); + expect(held.changed()).toBe("a"); + // A unit names the helper it got, not the key it was looked up by. + expect(held.changedAmong(new Set(["%h.1"]))).toBe("a"); + expect(held.changedAmong(new Set(["%h.2"]))).toBeNull(); + held.refresh(new Set(["%h.1"])); + expect(held.changed()).toBeNull(); + const set = new Set(["x", "y"]); + const heldSet = setTable("names", set).contents!(); set.delete("x"); set.add("x"); - expect(heldSet.same()).toBe(true); + expect(heldSet.changed()).toBe("x"); set.delete("x"); - expect(heldSet.same()).toBe(false); + expect(heldSet.changedAmong(new Set(["x"]))).toBe("x"); + heldSet.refresh(new Set(["x"])); + expect(heldSet.changed()).toBeNull(); }); test("a function table keeps the function an earlier unit made", () => { @@ -280,7 +287,7 @@ test("a unit that changes an entry it did not add has no record", () => { state.helpers.set("shared", "%h.first"); const unit = recordUnit(subject, "a"); expect(unit.record).toBeNull(); - expect(unit.reasons).toEqual(["helpers:replaced"]); + expect(unit.reasons).toEqual(["helpers:changed"]); }); test("a recorded wave throws when a unit changes a held function in place", () => { diff --git a/packages/compiler/src/frontend/lowering/unit-journal.ts b/packages/compiler/src/frontend/lowering/unit-journal.ts index 991b1368c..e349bb2cf 100644 --- a/packages/compiler/src/frontend/lowering/unit-journal.ts +++ b/packages/compiler/src/frontend/lowering/unit-journal.ts @@ -96,8 +96,6 @@ export interface JournalTable { readonly digest?: (key: string, before: number) => string | null; /** Removes the entries from position `from` on. */ readonly truncate?: (from: number) => void; - /** The entries the table holds now. */ - readonly hold?: () => HeldEntries; /** The content of the entries the table holds now, for tables whose * values are objects a unit could change in place. */ readonly contents?: () => HeldContents; @@ -127,10 +125,13 @@ export function contentDigest(text: string): string { return `${text.length}:${(a >>> 0).toString(16)}${(b >>> 0).toString(16)}`; } -/** The strings quoted in JSON `texts`, unescaped only as far as their - * delimiting quotes: the names a unit's encoded IR and entries refer to. */ +/** The names quoted in JSON `texts`, unescaped only as far as their + * delimiting quotes: the strings that start with `%` (helper, lifted and + * IR data names) and union ids (`u` and 12 hex digits), which the tables + * a unit reads and changes are keyed by. */ export function quotedStrings(texts: readonly string[]): Set { const out = new Set(); + const hex = (char: number): boolean => (char >= 48 && char <= 57) || (char >= 97 && char <= 102); for (const text of texts) { let start = -1; for (let index = 0; index < text.length; index++) { @@ -139,7 +140,13 @@ export function quotedStrings(texts: readonly string[]): Set { else if (char === 34 /* " */) { if (start < 0) start = index + 1; else { - out.add(text.slice(start, index)); + const first = text.charCodeAt(start); + let name = first === 37; /* % */ + if (!name && first === 117 /* u */ && index - start === 13) { + name = true; + for (let at = start + 1; at < index && name; at++) name = hex(text.charCodeAt(at)); + } + if (name) out.add(text.slice(start, index)); start = -1; } } @@ -148,12 +155,6 @@ export function quotedStrings(texts: readonly string[]): Set { return out; } -/** A table's entries at one point (JournalTable.hold). */ -export interface HeldEntries { - /** Whether the table's first entries are still these keys and values. */ - same(): boolean; -} - /** A field a unit's lowering must leave as it found it (fixedScalar), or a * flag it may set, which applying its record sets too (flagScalar). */ export interface JournalScalar { @@ -288,7 +289,11 @@ export function recordUnit( ): RecordedUnit { const tables = host.tables; const before = tables.map((table) => table.size()); - const held = tables.map((table) => (table.hold === undefined ? null : table.hold())); + // Outside a recorded wave, the unit's own snapshot of the tables' content. + const own = + contents === null + ? tables.map((table) => (table.contents === undefined ? null : table.contents())) + : null; const marks = host.scalars.map((scalar) => scalar.mark()); const counts = host.statementCounts(); const recorder = new UnitEvents(); @@ -296,8 +301,6 @@ export function recordUnit( const reasons: string[] = []; const grown: number[] = []; tables.forEach((table, index) => { - const check = held[index] ?? null; - if (check !== null && !check.same()) reasons.push(`${table.name}:replaced`); if (table.size() === before[index]) return; if (table.entries === undefined) reasons.push(table.name); else grown.push(index); @@ -339,7 +342,12 @@ export function recordUnit( if (entries !== null) for (const entry of entries) texts.push(entry); } const names = quotedStrings(texts); - if (contents !== null) { + if (own !== null) { + if (reasons.length === 0) + tables.forEach((table, index) => { + if ((own[index] ?? null)?.changed() != null) reasons.push(`${table.name}:changed`); + }); + } else if (contents !== null) { // A unit with a record must leave the entries it names as they were; a // unit without one keeps its changes. if (reasons.length === 0) @@ -419,6 +427,69 @@ export function lowerWaveRecorded( }); } +/** The content of a keyed table's entries (JournalTable.contents): each + * entry's encoded value, and the order of the keys, since a table only adds + * entries at the end. An entry is found by its key, or by the name its value + * holds (`valueName`): a unit names the helper it got, not the key it was + * looked up by. `text` answers undefined for a key the table does not + * hold. */ +function keyedContents( + size: () => number, + keys: () => Iterable, + text: (key: string) => string | null | undefined, + valueName: (key: string) => string | null, +): HeldContents { + const texts = new Map(); + let order: string[] = []; + const byName = new Map(); + const differs = (key: string): boolean => text(key) !== texts.get(key); + const keyOf = (name: string): string | undefined => (texts.has(name) ? name : byName.get(name)); + const held: HeldContents = { + extend: () => { + if (texts.size >= size()) return; + for (const key of keys()) { + if (texts.has(key)) continue; + texts.set(key, text(key) ?? null); + order.push(key); + const named = valueName(key); + if (named !== null) byName.set(named, key); + } + }, + changed: () => { + let index = 0; + for (const key of keys()) { + if (index >= order.length) break; + if (key !== order[index]) return order[index]!; + index++; + } + if (index < order.length) return order[index]!; + for (const key of order) if (differs(key)) return key; + return null; + }, + changedAmong: (names) => { + for (const name of names) { + const key = keyOf(name); + if (key !== undefined && differs(key)) return key; + } + return null; + }, + refresh: (names) => { + for (const name of names) { + const key = keyOf(name); + if (key === undefined) continue; + const now = text(key); + if (now !== undefined) texts.set(key, now); + else { + texts.delete(key); + order = order.filter((held) => held !== key); + } + } + }, + }; + held.extend(); + return held; +} + /** An appendable table over a Map from strings to strings, such as helper * names by key. */ export function mapTable(name: string, map: Map): JournalTable { @@ -427,6 +498,7 @@ export function mapTable(name: string, map: Map): JournalTable { map, (value) => JSON.stringify(value), (text) => JSON.parse(text) as string, + (value) => value, ); } @@ -444,42 +516,15 @@ export function countTable(name: string, map: Map): JournalTable * JSON. JSON writes a number that is not finite as null, so a value whose * encoding holds a null does not encode. */ export function irDataTable(name: string, map: Map): JournalTable { - const encode = (value: V): string | null => { - const text = JSON.stringify(value); - return /[:,[]null\b/.test(text) ? null : text; - }; - return { - ...codecTable(name, map, encode, (text) => JSON.parse(text) as V), - contents: () => { - const texts = new Map(); - const differs = (key: string): boolean => { - const value = map.get(key); - return value === undefined || encode(value) !== texts.get(key); - }; - const held: HeldContents = { - extend: () => { - if (texts.size === map.size) return; - for (const [key, value] of map) if (!texts.has(key)) texts.set(key, encode(value)); - }, - changed: () => { - for (const key of texts.keys()) if (differs(key)) return key; - return null; - }, - changedAmong: (keys) => { - for (const key of keys) if (texts.has(key) && differs(key)) return key; - return null; - }, - refresh: (keys) => { - for (const key of keys) { - const value = map.get(key); - if (texts.has(key) && value !== undefined) texts.set(key, encode(value)); - } - }, - }; - held.extend(); - return held; + return codecTable( + name, + map, + (value) => { + const text = JSON.stringify(value); + return /[:,[]null\b/.test(text) ? null : text; }, - }; + (text) => JSON.parse(text) as V, + ); } /** An appendable table over a Map from strings to values that `encode` @@ -491,6 +536,7 @@ export function codecTable( map: Map, encode: (value: V) => string | null, decode: (text: string) => V, + nameOf: ((value: V) => string) | null = null, ): JournalTable { return { name, @@ -517,21 +563,19 @@ export function codecTable( const keys = [...map.keys()].slice(from); for (const key of keys) map.delete(key); }, - hold: () => { - const keys = [...map.keys()]; - const values = [...map.values()]; - return { - same: () => { - let index = 0; - for (const [key, value] of map) { - if (index >= keys.length) break; - if (key !== keys[index] || value !== values[index]) return false; - index++; - } - return index === keys.length; + contents: () => + keyedContents( + () => map.size, + () => map.keys(), + (key) => { + const value = map.get(key); + return value === undefined ? undefined : encode(value); }, - }; - }, + (key) => { + const value = map.get(key); + return value === undefined || nameOf === null ? null : nameOf(value); + }, + ), }; } @@ -548,20 +592,13 @@ export function setTable(name: string, set: Set): JournalTable { const entries = [...set].slice(from); for (const entry of entries) set.delete(entry); }, - hold: () => { - const entries = [...set]; - return { - same: () => { - let index = 0; - for (const entry of set) { - if (index >= entries.length) break; - if (entry !== entries[index]) return false; - index++; - } - return index === entries.length; - }, - }; - }, + contents: () => + keyedContents( + () => set.size, + () => set.keys(), + (key) => (set.has(key) ? "" : undefined), + () => null, + ), }; } @@ -626,10 +663,6 @@ export function functionTable( while (functions.length > from) functions.pop(); changed(); }, - hold: () => { - const held = [...functions]; - return { same: () => held.every((fn, index) => functions[index] === fn) }; - }, contents: () => { const texts: (string | null)[] = []; const indexes = new Map(); diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 2dee0d62a..062d19973 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -323,16 +323,16 @@ export class ShapeRegistry { * writer after all priority candidates have closed. */ private readonly derivedDeclaredOrderFinalizers: (() => void)[] = []; private currentDeclaredOrderPriority: DeclaredOrderPriority = [0, 0]; + /** The declared-order candidates and the attached names the registry + * holds, counted as they are added (journalSizes). */ + private candidateCount = 0; + private extendedCount = 0; /** All interned shapes in first-seen order. */ readonly shapes: IrRecordShape[] = []; /** The sizes of the registry's tables, which only grow while shapes are * interned (unit-journal.ts). */ journalSizes(): number[] { - let candidates = 0; - for (const list of this.declaredOrderCandidates.values()) candidates += list.length; - let extended = 0; - for (const names of this.extendedNames.values()) extended += names.length; return [ this.revision, this.shapes.length, @@ -341,12 +341,12 @@ export class ShapeRegistry { this.keyedNameKeys.size, this.declaredOrderPriority.size, this.declaredOrderCandidates.size, - candidates, + this.candidateCount, this.derivedDeclaredOrderFinalizers.length, this.recIds.size, this.pendingRec.size, this.extendedNames.size, - extended, + this.extendedCount, ]; } /** ts.Types currently being mapped — a BACK-REFERENCE to one of these is @@ -401,6 +401,7 @@ export class ShapeRegistry { private adoptDeclaredOrder(shape: IrRecordShape, declaredOrder: string[] | undefined): void { if (declaredOrder === undefined) return; + this.candidateCount++; const candidates = this.declaredOrderCandidates.get(shape.id); const candidate = { order: declaredOrder, priority: this.currentDeclaredOrderPriority }; if (candidates) candidates.push(candidate); @@ -556,6 +557,7 @@ export class ShapeRegistry { this.byId.set(id, shape); this.shapes.push(shape); if (declaredOrder !== undefined) { + this.candidateCount++; this.declaredOrderCandidates.set(id, [ { order: declaredOrder, priority: this.currentDeclaredOrderPriority }, ]); @@ -618,6 +620,7 @@ export class ShapeRegistry { shape.fields = [...shape.fields, ...fresh].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0, ); + this.extendedCount += fresh.length; this.extendedNames.set(shapeId, [ ...(this.extendedNames.get(shapeId) ?? []), ...fresh.map((f) => f.name), @@ -745,10 +748,6 @@ export class UnionRegistry { this.keyedNameKeys.delete(def.id); } }, - hold: () => { - const held = [...this.unions]; - return { same: () => held.every((def, index) => this.unions[index] === def) }; - }, contents: () => { // A recursive placeholder still pending fills in place when its // outer frame completes, which changes journalSizes.