diff --git a/benchmarks/runtime/README.md b/benchmarks/runtime/README.md index 2653ec51a..e177e36c0 100644 --- a/benchmarks/runtime/README.md +++ b/benchmarks/runtime/README.md @@ -11,6 +11,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `channel-messages` | cpu | 200,000 MessageChannel messages in one thread, half numbers and half small objects, all posted before the port delivers any | | `csv-numbers` | cpu | `split`, `Number`, `parseInt`, `toFixed` | | `cyclic-graphs` | cpu | object graphs with back references built and dropped: trees with parent and sibling links, rings of records sharing a long-lived registry, closures capturing their owner | +| `dictionary-walks` | cpu | `Record` dictionaries walked with for-in and `Object.keys`/`values`/`entries` while entries are added, updated and deleted | | `functional-pipeline` | cpu | `filter`/`map`/`reduce`/`flatMap` chains, closures, small records | | `intern-table`, `intern-table-map`, `intern-table-1`, `intern-table-map-1` | cpu | worker threads interning the same type-like objects under string keys and records under number keys, then hits and misses: one shared `InternTable` per kind of key, or a module-level `Map` per thread (the `-map` entries), with 8 threads or 1 (the `-1` entries); under Node.js every thread has its own tables | | `json-records` | cpu | `JSON.stringify`, typed `JSON.parse`, Map aggregation | diff --git a/benchmarks/runtime/workloads/dictionary-walks.json b/benchmarks/runtime/workloads/dictionary-walks.json new file mode 100644 index 000000000..ebfb6ea3f --- /dev/null +++ b/benchmarks/runtime/workloads/dictionary-walks.json @@ -0,0 +1,6 @@ +{ + "entry": "workloads/dictionary-walks.ts", + "args": [ + "1" + ] +} diff --git a/benchmarks/runtime/workloads/dictionary-walks.ts b/benchmarks/runtime/workloads/dictionary-walks.ts new file mode 100644 index 000000000..30b18be63 --- /dev/null +++ b/benchmarks/runtime/workloads/dictionary-walks.ts @@ -0,0 +1,53 @@ +// String-keyed dictionaries (`Record`) walked with for-in, +// Object.keys, Object.values and Object.entries while entries are added, +// updated and deleted: per-category totals, a pruning pass and a merge. +let seed = 777; +function random(): number { + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; + return seed / 4294967296; +} + +const scale = Number(process.argv[2] ?? "1"); +const categories = 4000; +const counts: Record = {}; +for (let i = 0; i < Math.floor(200000 * scale); i++) { + const key = "c" + Math.floor(random() * categories); + counts[key] = (counts[key] ?? 0) + 1; +} + +let walked = 0; +let sum = 0; +for (let pass = 0; pass < 40; pass++) { + for (const key in counts) { + walked++; + sum += counts[key]!; + } +} + +// Prune: delete the small categories during the walk, including keys the +// walk has not reached yet. +let pruned = 0; +for (const key in counts) { + const n = counts[key]!; + if (n < 45) { + delete counts[key]; + pruned++; + } + const ahead = "c" + ((Number(key.slice(1)) + 1) % categories); + if (n > 60 && ahead in counts && counts[ahead]! < 45) { + delete counts[ahead]; + pruned++; + } +} + +// Merge into a second dictionary and read it back every way. +const merged: Record = {}; +for (const key of Object.keys(counts)) merged[key.toUpperCase()] = counts[key]! * 2; +let values = 0; +for (const v of Object.values(merged)) values += v; +let entries = 0; +for (const [key, v] of Object.entries(merged)) entries += key.length + v; + +console.log( + `walked ${walked}, sum ${sum}, pruned ${pruned}, kept ${Object.keys(counts).length}, values ${values}, entries ${entries}`, +); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 213abfec6..d49c7643e 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -11484,11 +11484,13 @@ function lowerForIn(lowerer: Lowerer, stmt: ts.ForInStatement): IrStmt { keysT, loc, ); + // The overflow map's hashed key set answers presence directly; a + // fresh key list per visit would make the walk quadratic. const guard = (kRef: IrExpr): IrExpr => ({ - kind: "arrIntrinsic", - method: "includes", - receiver: { kind: "recordOvfKeys", obj: recvRef(), shapeId, type: keysT, loc }, - args: [kRef], + kind: "recordOvfHas", + obj: recvRef(), + shapeId, + key: kRef, type: BOOL, loc, }); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7011-for-in-dictionary-deletes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7011-for-in-dictionary-deletes.ts.json new file mode 100644 index 000000000..3b2b79c3b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7011-for-in-dictionary-deletes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7011-for-in-dictionary-deletes.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7011-for-in-dictionary-deletes.ts b/tests/corpus/7011-for-in-dictionary-deletes.ts new file mode 100644 index 000000000..91dac573b --- /dev/null +++ b/tests/corpus/7011-for-in-dictionary-deletes.ts @@ -0,0 +1,42 @@ +// for-in over a string-keyed dictionary visits the keys present when the +// walk starts, in insertion order, and skips a key deleted before its +// turn; keys added during the walk are not visited. A key deleted and +// added again before its turn is visited. +const d: Record = {}; +for (let i = 0; i < 8; i++) d["k" + i] = i; +const seen: string[] = []; +for (const key in d) { + seen.push(key); + if (key === "k1") { + delete d["k3"]; + delete d["k0"]; + d["k8"] = 8; + } + if (key === "k2") { + delete d["k5"]; + d["k5"] = 50; + } + if (key === "k4") delete d["k4"]; +} +console.log(seen.join(",")); +console.log(Object.keys(d).join(","), JSON.stringify(d)); + +// A numeric-looking key is walked in integer order before string keys. +const n: Record = {}; +n["b"] = "x"; +n["2"] = "y"; +n["10"] = "z"; +n["a"] = "w"; +const order: string[] = []; +for (const key in n) { + order.push(key); + if (key === "2") delete n["a"]; +} +console.log(order.join(",")); + +// A larger dictionary walked many times. +const big: Record = {}; +for (let i = 0; i < 3000; i++) big["e" + i] = i; +let total = 0; +for (let pass = 0; pass < 20; pass++) for (const key in big) total += big[key]!; +console.log(total); diff --git a/tests/harness/corpus-costs/7011-for-in-dictionary-deletes.ts.json b/tests/harness/corpus-costs/7011-for-in-dictionary-deletes.ts.json new file mode 100644 index 000000000..e172eed8a --- /dev/null +++ b/tests/harness/corpus-costs/7011-for-in-dictionary-deletes.ts.json @@ -0,0 +1 @@ +{ "plain": 1527, "san": 2135 }