From 52792d8c52919b16f703246c305247d7a5ed835f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 09:54:58 -0500 Subject: [PATCH 1/2] Find weak collection entries through hashed indexes WeakMap and WeakSet kept each collection's entries in one list and found a key by walking it, and every collection's entries for a key in 1,024 shared buckets. Filling a collection and reading it back took quadratic time: 40,000 keys took 6.7 s against Node's 0.2 s, and 200,000 keys did not finish. Each thread now indexes its entries twice, in open-addressing tables with linear probing and backward-shift deletion: by collection, key and kind, for get, set, has and delete, and by key pointer, for a key's disposal, which ends the key's entries in every collection. A collection's entries and a key's entries are doubly linked, so an entry leaves both in constant time. The tables grow at half load and are freed when their last entry goes, so an exiting worker leaves none. 200,000 keys now take 0.24 s (Node 0.30 s). Closes #1702 Co-authored-by: ejc3 <1694850+ejc3@users.noreply.github.com> --- .../7742-weak-collections-many-keys.ts.json | 6 + packages/runtime/src/scr_json.c | 203 +++++++++++++++--- .../corpus/7742-weak-collections-many-keys.ts | 70 ++++++ .../7742-weak-collections-many-keys.ts.json | 1 + 4 files changed, 247 insertions(+), 33 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7742-weak-collections-many-keys.ts.json create mode 100644 tests/corpus/7742-weak-collections-many-keys.ts create mode 100644 tests/harness/corpus-costs/7742-weak-collections-many-keys.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7742-weak-collections-many-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7742-weak-collections-many-keys.ts.json new file mode 100644 index 000000000..e1a75c14e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7742-weak-collections-many-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7742-weak-collections-many-keys.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index da18a1c0b..4841da53d 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -15019,26 +15019,173 @@ typedef struct ScrWeakEntry { unsigned kind; ScrDyn *value; ScrWeakMap *owner; - struct ScrWeakEntry *next, *all_next; + /* The owner's entries, and every collection's entries for the same key + * pointer (whatever their kind): both doubly linked, so an entry leaves + * either list in constant time. */ + struct ScrWeakEntry *next, *prev, *key_next, *key_prev; } ScrWeakEntry; struct ScrWeakMap { size_t rc; ScrWeakEntry *entries; bool is_set; }; /* Weak observers borrow key identities. Values are traced strong edges; - * value-to-key cycles require explicit deletion or container disposal. */ -#define SCR_WEAK_BUCKETS 1024 -static SCR_TL ScrWeakEntry *scr_weak_entries[SCR_WEAK_BUCKETS]; -static size_t scr_weak_bucket(void *key) { - uintptr_t bits = (uintptr_t)key; - return ((bits >> 4) ^ (bits >> 14)) & (SCR_WEAK_BUCKETS - 1); + * value-to-key cycles require explicit deletion or container disposal. + * + * Two open-addressing indexes per thread find entries in expected constant + * time: scr_weak_index by (collection, key, kind), for get/set/has/delete, + * and scr_weak_keys by key pointer, the head of that key's entries, for a + * disposal, which ends the key's entries in every collection. Both use + * linear probing with backward-shift deletion, grow at half load, and are + * freed when their last entry goes. */ +static SCR_TL ScrWeakEntry **scr_weak_index; +static SCR_TL size_t scr_weak_index_mask, scr_weak_index_used; +typedef struct { void *key; ScrWeakEntry *head; } ScrWeakKeySlot; +static SCR_TL ScrWeakKeySlot *scr_weak_keys; +static SCR_TL size_t scr_weak_keys_mask, scr_weak_keys_used; + +static inline size_t scr_weak_mix(uintptr_t bits) { + bits ^= bits >> 33; + bits *= 0xff51afd7ed558ccdull; + bits ^= bits >> 29; + return (size_t)bits; +} +static inline size_t scr_weak_entry_hash(const ScrWeakMap *owner, const void *key, unsigned kind) { + return scr_weak_mix((uintptr_t)key ^ ((uintptr_t)owner * 0x9E3779B97F4A7C15ull) ^ ((uintptr_t)kind << 56)); +} +static inline size_t scr_weak_key_hash(const void *key) { return scr_weak_mix((uintptr_t)key); } + +static void scr_weak_index_put(ScrWeakEntry *entry) { + size_t j = scr_weak_entry_hash(entry->owner, entry->key, entry->kind) & scr_weak_index_mask; + while (scr_weak_index[j]) j = (j + 1) & scr_weak_index_mask; + scr_weak_index[j] = entry; +} +static void scr_weak_index_reserve(void) { + if (scr_weak_index && (scr_weak_index_used + 1) * 2 <= scr_weak_index_mask + 1) return; + ScrWeakEntry **old = scr_weak_index; + size_t n = old ? scr_weak_index_mask + 1 : 0, cap = n ? n * 2 : 64; + scr_weak_index = calloc(cap, sizeof *scr_weak_index); + if (!scr_weak_index) scr_json_oom(); + scr_weak_index_mask = cap - 1; + for (size_t i = 0; i < n; i++) + if (old[i]) scr_weak_index_put(old[i]); + free(old); +} +static ScrWeakEntry *scr_weak_find(ScrWeakMap *map, void *ptr, unsigned kind) { + if (!scr_weak_index) return NULL; + for (size_t j = scr_weak_entry_hash(map, ptr, kind) & scr_weak_index_mask;; j = (j + 1) & scr_weak_index_mask) { + ScrWeakEntry *entry = scr_weak_index[j]; + if (!entry) return NULL; + if (entry->owner == map && entry->key == ptr && entry->kind == kind) return entry; + } +} +static void scr_weak_index_delete(ScrWeakEntry *entry) { + size_t mask = scr_weak_index_mask; + size_t i = scr_weak_entry_hash(entry->owner, entry->key, entry->kind) & mask; + while (scr_weak_index[i] != entry) i = (i + 1) & mask; + if (--scr_weak_index_used == 0) { + /* Empty: give the table back, so a thread that exits leaves none. */ + free(scr_weak_index); + scr_weak_index = NULL; + return; + } + for (size_t k = i;;) { + scr_weak_index[i] = NULL; + for (;;) { + k = (k + 1) & mask; + ScrWeakEntry *e = scr_weak_index[k]; + if (!e) return; + size_t home = scr_weak_entry_hash(e->owner, e->key, e->kind) & mask; + /* e stays unless its home lies cyclically outside (i, k]. */ + if (i <= k ? (i < home && home <= k) : (i < home || home <= k)) continue; + scr_weak_index[i] = e; + i = k; + break; + } + } +} + +static ScrWeakKeySlot *scr_weak_key_slot(const void *key) { + if (!scr_weak_keys) return NULL; + for (size_t j = scr_weak_key_hash(key) & scr_weak_keys_mask;; j = (j + 1) & scr_weak_keys_mask) { + if (scr_weak_keys[j].key == key) return &scr_weak_keys[j]; + if (!scr_weak_keys[j].key) return NULL; + } +} +static ScrWeakKeySlot *scr_weak_key_slot_put(void *key) { + if (!scr_weak_keys || (scr_weak_keys_used + 1) * 2 > scr_weak_keys_mask + 1) { + ScrWeakKeySlot *old = scr_weak_keys; + size_t n = old ? scr_weak_keys_mask + 1 : 0, cap = n ? n * 2 : 64; + scr_weak_keys = calloc(cap, sizeof *scr_weak_keys); + if (!scr_weak_keys) scr_json_oom(); + scr_weak_keys_mask = cap - 1; + for (size_t i = 0; i < n; i++) { + if (!old[i].key) continue; + size_t j = scr_weak_key_hash(old[i].key) & scr_weak_keys_mask; + while (scr_weak_keys[j].key) j = (j + 1) & scr_weak_keys_mask; + scr_weak_keys[j] = old[i]; + } + free(old); + } + size_t j = scr_weak_key_hash(key) & scr_weak_keys_mask; + while (scr_weak_keys[j].key && scr_weak_keys[j].key != key) j = (j + 1) & scr_weak_keys_mask; + if (!scr_weak_keys[j].key) { + scr_weak_keys[j].key = key; + scr_weak_keys[j].head = NULL; + scr_weak_keys_used++; + } + return &scr_weak_keys[j]; +} +static void scr_weak_key_slot_delete(ScrWeakKeySlot *slot) { + size_t mask = scr_weak_keys_mask; + size_t i = (size_t)(slot - scr_weak_keys); + if (--scr_weak_keys_used == 0) { + free(scr_weak_keys); + scr_weak_keys = NULL; + return; + } + for (size_t k = i;;) { + scr_weak_keys[i].key = NULL; + scr_weak_keys[i].head = NULL; + for (;;) { + k = (k + 1) & mask; + if (!scr_weak_keys[k].key) return; + size_t home = scr_weak_key_hash(scr_weak_keys[k].key) & mask; + if (i <= k ? (i < home && home <= k) : (i < home || home <= k)) continue; + scr_weak_keys[i] = scr_weak_keys[k]; + i = k; + break; + } + } +} + +/* Indexes a new entry (its key, kind and owner set) under its key. */ +static void scr_weak_link(ScrWeakEntry *entry) { + scr_weak_index_reserve(); + scr_weak_index_put(entry); + scr_weak_index_used++; + ScrWeakKeySlot *slot = scr_weak_key_slot_put(entry->key); + entry->key_prev = NULL; + entry->key_next = slot->head; + if (slot->head) slot->head->key_prev = entry; + slot->head = entry; +} + +/* Takes an entry out of both indexes (it stays in its owner's list). */ +static void scr_weak_unlink(ScrWeakEntry *entry) { + scr_weak_index_delete(entry); + if (entry->key_next) entry->key_next->key_prev = entry->key_prev; + if (entry->key_prev) entry->key_prev->key_next = entry->key_next; + else { + ScrWeakKeySlot *slot = scr_weak_key_slot(entry->key); + if (entry->key_next) slot->head = entry->key_next; + else scr_weak_key_slot_delete(slot); + } } static void scr_weak_remove_impl(ScrWeakEntry *entry, bool collected) { - ScrWeakEntry **slot = &entry->owner->entries; - while (*slot != entry) slot = &(*slot)->next; - *slot = entry->next; - slot = &scr_weak_entries[scr_weak_bucket(entry->key)]; - while (*slot != entry) slot = &(*slot)->all_next; - *slot = entry->all_next; + ScrWeakMap *owner = entry->owner; + if (entry->next) entry->next->prev = entry->prev; + if (entry->prev) entry->prev->next = entry->next; + else owner->entries = entry->next; + scr_weak_unlink(entry); ScrDyn *value = entry->value; free(entry); /* Unlink before release: releasing a value can dispose another key. */ @@ -15062,10 +15209,9 @@ static void scr_weak_map_gcfree(void *ptr) { static void scr_weak_disposed(void *key) { for (;;) { - ScrWeakEntry *entry = scr_weak_entries[scr_weak_bucket(key)]; - while (entry && entry->key != key) entry = entry->all_next; - if (!entry) return; - scr_weak_remove(entry); + ScrWeakKeySlot *slot = scr_weak_key_slot(key); + if (!slot) return; + scr_weak_remove(slot->head); } } @@ -15122,12 +15268,6 @@ static int scr_weak_key(const ScrDyn *key, void **ptr, unsigned *kind) { } } -static ScrWeakEntry *scr_weak_find(ScrWeakMap *map, void *ptr, unsigned kind) { - for (ScrWeakEntry *entry = map->entries; entry; entry = entry->next) - if (entry->key == ptr && entry->kind == kind) return entry; - return NULL; -} - static bool scr_weak_set(ScrWeakMap *map, ScrDyn *key, ScrDyn *value) { void *ptr = NULL; unsigned kind = 0; @@ -15156,10 +15296,9 @@ static bool scr_weak_set(ScrWeakMap *map, ScrDyn *key, ScrDyn *value) { entry->value = scr_dyn_retain(value); entry->owner = map; entry->next = map->entries; + if (map->entries) map->entries->prev = entry; map->entries = entry; - size_t bucket = scr_weak_bucket(ptr); - entry->all_next = scr_weak_entries[bucket]; - scr_weak_entries[bucket] = entry; + scr_weak_link(entry); return true; } @@ -16371,16 +16510,14 @@ static bool scr_dyn_decl_rekey(ScrDyn *node, void *ptr) { bool moved = false; if (!scr_weak_observing) return false; for (;;) { - ScrWeakEntry **slot = &scr_weak_entries[scr_weak_bucket(node)]; - while (*slot && !((*slot)->key == (void *)node && (*slot)->kind == 0)) slot = &(*slot)->all_next; - ScrWeakEntry *entry = *slot; + ScrWeakKeySlot *slot = scr_weak_key_slot(node); + ScrWeakEntry *entry = slot ? slot->head : NULL; + while (entry && entry->kind != 0) entry = entry->key_next; if (!entry) return moved; - *slot = entry->all_next; + scr_weak_unlink(entry); entry->key = ptr; entry->kind = 32; - ScrWeakEntry **head = &scr_weak_entries[scr_weak_bucket(ptr)]; - entry->all_next = *head; - *head = entry; + scr_weak_link(entry); moved = true; } } diff --git a/tests/corpus/7742-weak-collections-many-keys.ts b/tests/corpus/7742-weak-collections-many-keys.ts new file mode 100644 index 000000000..2cb2b103e --- /dev/null +++ b/tests/corpus/7742-weak-collections-many-keys.ts @@ -0,0 +1,70 @@ +// Weak collections with many keys: entries found among tens of thousands +// in WeakMaps and WeakSets that share keys, deleted and replaced, and gone +// with their keys, so blocks a new key reuses are not found under it. +// Keys in reference cycles die by the cycle collector. +class Key { + readonly id: number; + peer: Key | null = null; + constructor(id: number) { + this.id = id; + } +} + +const n = 40000; +let keys: Key[] = []; +for (let i = 0; i < n; i++) keys.push(new Key(i)); +const values = new WeakMap(); +const labels = new WeakMap(); +const marked = new WeakSet(); +for (const k of keys) { + values.set(k, k.id * 2); + if (k.id % 3 === 0) labels.set(k, "k" + k.id); + if (k.id % 2 === 0) marked.add(k); +} +let sum = 0; +let labelled = 0; +let counted = 0; +for (const k of keys) { + sum += values.get(k) ?? 0; + if (labels.has(k)) labelled++; + if (marked.has(k)) counted++; +} +console.log("filled", sum, labelled, counted); + +// Deleting every fourth key from one collection leaves the others alone. +let deleted = 0; +for (const k of keys) if (k.id % 4 === 0 && values.delete(k)) deleted++; +let remaining = 0; +for (const k of keys) if (values.has(k)) remaining++; +console.log("deleted", deleted, remaining, values.delete(keys[0]!), marked.has(keys[0]!), labels.get(keys[3]!)); + +// Overwriting keeps one entry per key. +for (const k of keys) if (k.id % 5 === 0) values.set(k, -1); +let negative = 0; +for (const k of keys) if (values.get(k) === -1) negative++; +console.log("overwritten", negative); + +// Dropping the keys ends their entries; new keys are not found. +keys = []; +const fresh: Key[] = []; +for (let i = 0; i < n; i++) fresh.push(new Key(n + i)); +let stale = 0; +for (const k of fresh) if (values.has(k) || labels.has(k) || marked.has(k)) stale++; +console.log("fresh", fresh.length, stale); + +// Keys that reach each other die together through the cycle collector. +for (let round = 0; round < 3; round++) { + let ring: Key[] = []; + for (let i = 0; i < 1000; i++) ring.push(new Key(i)); + for (let i = 0; i < ring.length; i++) ring[i]!.peer = ring[(i + 1) % ring.length]!; + for (const k of ring) { + values.set(k, round); + marked.add(k); + } + ring = []; +} +const after: Key[] = []; +for (let i = 0; i < 3000; i++) after.push(new Key(i)); +let found = 0; +for (const k of after) if (values.has(k) || marked.has(k)) found++; +console.log("cycles", found); diff --git a/tests/harness/corpus-costs/7742-weak-collections-many-keys.ts.json b/tests/harness/corpus-costs/7742-weak-collections-many-keys.ts.json new file mode 100644 index 000000000..97e5fc450 --- /dev/null +++ b/tests/harness/corpus-costs/7742-weak-collections-many-keys.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 6000 } From 190fb8d58e75f0165e7be2b973bc32a802a3aac8 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 24 Oct 2026 16:01:33 -0500 Subject: [PATCH 2/2] Mix weak-collection hashes in 64 bits on 32-bit targets uintptr_t is 32 bits on wasm32, where the weak-collection hashes shifted it by 33 and 56 bits. The hashes now widen keys, owners and kinds to uint64_t before mixing; 64-bit targets compute the same values as before. --- packages/runtime/src/scr_json.c | 11 +++++---- packages/runtime/test/json.test.ts | 31 ++++++++++++++++++++++++- tests/harness/wasi-differential.test.ts | 3 +++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index afe3315de..7df866a4c 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -16157,16 +16157,19 @@ typedef struct { void *key; ScrWeakEntry *head; } ScrWeakKeySlot; static SCR_TL ScrWeakKeySlot *scr_weak_keys; static SCR_TL size_t scr_weak_keys_mask, scr_weak_keys_used; -static inline size_t scr_weak_mix(uintptr_t bits) { +/* Mixed in 64 bits on every target: uintptr_t is 32 bits on wasm32, where + * these shifts would exceed its width. */ +static inline size_t scr_weak_mix(uint64_t bits) { bits ^= bits >> 33; - bits *= 0xff51afd7ed558ccdull; + bits *= UINT64_C(0xff51afd7ed558ccd); bits ^= bits >> 29; return (size_t)bits; } static inline size_t scr_weak_entry_hash(const ScrWeakMap *owner, const void *key, unsigned kind) { - return scr_weak_mix((uintptr_t)key ^ ((uintptr_t)owner * 0x9E3779B97F4A7C15ull) ^ ((uintptr_t)kind << 56)); + return scr_weak_mix((uint64_t)(uintptr_t)key ^ ((uint64_t)(uintptr_t)owner * UINT64_C(0x9E3779B97F4A7C15)) ^ + ((uint64_t)kind << 56)); } -static inline size_t scr_weak_key_hash(const void *key) { return scr_weak_mix((uintptr_t)key); } +static inline size_t scr_weak_key_hash(const void *key) { return scr_weak_mix((uint64_t)(uintptr_t)key); } static void scr_weak_index_put(ScrWeakEntry *entry) { size_t j = scr_weak_entry_hash(entry->owner, entry->key, entry->kind) & scr_weak_index_mask; diff --git a/packages/runtime/test/json.test.ts b/packages/runtime/test/json.test.ts index 3315cb415..f83f5ad65 100644 --- a/packages/runtime/test/json.test.ts +++ b/packages/runtime/test/json.test.ts @@ -1,4 +1,4 @@ -import { execFile } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -85,3 +85,32 @@ test("json runtime: parser, dyn, dynCheck failure path, stringify buffer", async ].join("\n"), ); }); + +function zigOnPath(): boolean { + try { + execFileSync("zig", ["version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +// 32-bit uintptr_t: the weak-collection hashes mix in 64 bits, so no shift +// exceeds the width of its operand on wasm32. +test.skipIf(!zigOnPath())("json runtime builds for wasm32 without oversized shifts", async () => { + await execFileAsync("zig", [ + "cc", + "-target", + "wasm32-wasi", + "-std=c11", + "-O2", + "-D_GNU_SOURCE", + "-D_WASI_EMULATED_SIGNAL", + "-D_WASI_EMULATED_PROCESS_CLOCKS", + "-Werror=shift-count-overflow", + "-c", + "-o", + join(testDir, "build", "scr_json.wasm32.o"), + join(testDir, "../src/scr_json.c"), + ]); +}); diff --git a/tests/harness/wasi-differential.test.ts b/tests/harness/wasi-differential.test.ts index 5d814b384..66c2934f2 100644 --- a/tests/harness/wasi-differential.test.ts +++ b/tests/harness/wasi-differential.test.ts @@ -313,6 +313,9 @@ describe.skipIf(!zigOnPath())("wasm32-wasi differential", () => { // ThreadSlots blocks and pages hold 32-bit pointers. "5727-thread-slots-basics.ts", "5729-thread-slots-values.ts", + // Weak-collection hashes mix 32-bit pointers in 64 bits. + "4591-weak-keys-union-records.ts", + "7742-weak-collections-many-keys.ts", ])("the LLVM target runs %s byte-identically to Node", async (fixture) => { const entry = join(repoRoot, "tests/corpus", fixture); const outDir = await mkdtemp("/tmp/scriptc-wasi-");