From a462d0f6c09892395647fa7388cc5cc528b5528b Mon Sep 17 00:00:00 2001 From: vii Date: Mon, 10 Aug 2026 02:48:26 -0500 Subject: [PATCH] Grow into memory instead of reserving it, and raise the ceiling The banks move off .bss to computed offsets, and the module grows linear memory to reach them. An instance starts at 1.25 MiB (u32) or 1.75 MiB (u64) instead of 21 or 29. The hash widens to 64 bits so h1 can address the whole slot space. The ceiling is 117,440,512 entries for u32 and 58,720,256 for u64. The bindings rebuild their cached views after any call that can rehash, since a grow detaches them. --- CHANGELOG.md | 29 +++ CONTRIBUTING.md | 31 ++-- README.md | 52 +++--- SECURITY.md | 34 ++-- benches/bench.ts | 15 +- benches/compare.ts | 10 +- docs/api.md | 95 +++++----- docs/assets/crossover.svg | 2 +- docs/assets/crossover.vl.json | 56 +++--- docs/assets/speedup.svg | 2 +- docs/assets/speedup.vl.json | 176 +++++++++--------- docs/design.md | 106 +++++++---- docs/performance.md | 318 +++++++++++++++++---------------- examples/01-basic.ts | 2 +- examples/04-multiple-tables.ts | 10 +- native/swiss_core.h | 309 ++++++++++++++++++++++++-------- native/swiss_u32.c | 26 ++- native/swiss_u64.c | 28 ++- package.json | 2 +- scripts/build-wasm.ts | 211 ++++++++++++++++------ scripts/check-ubsan.ts | 45 ++++- src/abi.ts | 18 +- src/generated/swiss_u32.ts | 2 +- src/generated/swiss_u64.ts | 2 +- src/swiss-u32.ts | 153 +++++++++++----- src/swiss-u64.ts | 162 +++++++++++------ test/embedded.test.ts | 2 +- test/memory-layout.test.ts | 66 ++++++- test/stress.test.ts | 67 +++++-- test/swiss-u32.test.ts | 45 ++++- 30 files changed, 1364 insertions(+), 712 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76834bd..5002780 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,35 @@ The public API is the exports of `swisstable` and the two `.wasm` subpaths. The `*WasmExports` interfaces mirror the native sources, are marked `@internal`, and change without a major version. +## 1.1.0 - 2026-08-10 + +### Changed + +- The capacity ceiling is 117,440,512 entries for `SwissU32ToU32` and + 58,720,256 for `SwissU32ToU64`, up from 917,504. The hash is 64 bits now, + which gives the probe position enough bits to address the whole slot + space. The two modules differ because a u64 entry is wider. +- Tables grow into their memory instead of reserving it. The banks are no + longer static arrays; they sit at computed offsets above the module's + static data, and linear memory grows to reach them. An instance starts at + 1.25 MiB (`SwissU32ToU32`) or 1.75 MiB (`SwissU32ToU64`) rather than + 21 MiB or 29 MiB, and grows with what it holds. +- Each module now declares a maximum linear memory of 3.4 GiB + (`SwissU32ToU32`) or 2.4 GiB (`SwissU32ToU64`). This is address space, not + a reservation, but a memory-constrained or 32-bit host may decline it at + instantiation where the previous 21 MiB succeeded. +- `SWISS_MAX_CAPACITY_LOG2` accepts `[4, 29]`, and each source keeps its own + default. Lowering it caps how far a table can grow and how much address + space the module declares. It no longer changes what an instance reserves. +- Memory is never returned to the host. `clear()` and `shrinkToFit()` ready + a table for reuse, but an instance holds the high-water mark of every bank + it used until `dispose()`. + +### Fixed + +- A host that refuses to grow linear memory now reports a capacity error and + leaves the table unchanged, instead of trapping. + ## 1.0.0 - 2026-08-09 Initial release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a3d7b8..ae6b8a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,6 +71,8 @@ examples/ runnable examples, start at 01-basic.ts test/ bun test suites docs/ api.md, design.md, performance.md, and plans/ dist/wasm/ build output (generated, not committed) + also holds two test-only modules, swiss_u32_capped and + swiss_u32_starved, which are never embedded or published ``` ## The loop @@ -81,7 +83,7 @@ bun run build # both steps below bun run build:wasm # native/*.c -> dist/wasm/*.wasm + src/generated/*.ts bun run build:js # src/*.ts -> dist/js/*.js + .d.ts -bun test # 246 tests across 24 suites +bun test # 252 tests across 24 suites bun run typecheck # tsc --noEmit bun run check:ubsan # rebuild with UBSan trapping and exercise the arithmetic bun run smoke # the built package under plain Node @@ -132,17 +134,22 @@ If you add or rename an export, update the `exports` list in The linker does not fail on a missing `--export=` symbol — the error surfaces at `load()` as a `TypeError`, so tests are what catch it. -`MAX_CAPACITY` is set by `MAX_CAPACITY_LOG2`, a power-of-two exponent -defaulting to 20 via `#ifndef` and overridable with the -`SWISS_MAX_CAPACITY_LOG2` environment variable at build time. It is accepted -in `[4, 25]`, enforced identically in `native/swiss_core.h` and -`scripts/build-wasm.ts`: below 4 a bank holds less than one SIMD group, and -above 25 `h1()` has no bits left to address the extra slots with. Linear memory is -derived from it in `scripts/build-wasm.ts` (bytes-per-slot times slots, plus -fixed overhead), so there is no second figure to keep in step — but if you add -a static array, raise that target's `overheadBytes`. The statics have to fit -in the linked linear memory, and the link fails with "initial memory too -small" if they do not, so the arithmetic is checked by every build. +`MAX_CAPACITY` is set by `MAX_CAPACITY_LOG2`, a power-of-two exponent. Each +source sets its own via `#ifndef` (27 for `swiss_u32.c`, 26 for +`swiss_u64.c`), and `SWISS_MAX_CAPACITY_LOG2` overrides both at build time. +The accepted range is `[4, 29]`, checked in `native/swiss_core.h` and +`scripts/build-wasm.ts`. Below 4 a bank holds less than one SIMD group; +above 29 the 32-bit product in `MAX_LIVE()` wraps. + +What binds first is narrower and depends on the entry width: three banks +have to address inside wasm32. A `_Static_assert` in `swiss_core.h` checks +that against `sizeof(Entry)`, so an exponent a module cannot address fails +the build. That is why the two modules differ by one. + +If you add a static array, raise that target's `staticBytes` in +`scripts/build-wasm.ts`. Statics have to fit below the initial memory, and +the link fails with "initial memory too small" if they do not, so every +build checks it. ## Changing the benchmarks diff --git a/README.md b/README.md index 658c6df..27e61d5 100644 --- a/README.md +++ b/README.md @@ -31,21 +31,21 @@ Docs: [API](docs/api.md) · [Design](docs/design.md) · - One byte of metadata per slot, compared sixteen at a time with `wasm_simd128`. - 10.3 bytes per entry at full occupancy, 20.6 with the standby bank a rehash needs, against a measured 37 for `Map` on V8 and 67 on JavaScriptCore. -- An instance reserves 21 MiB (u32) or 29 MiB (u64) up front and commits it - by page: an empty table costs 1.7 MiB RSS, each further one about 50 KiB. - Lower `SWISS_MAX_CAPACITY_LOG2` for many small tables — see +- Holds up to 117,440,512 entries (u32) or 58,720,256 (u64). +- An instance starts at 1.25 MiB (u32) or 1.75 MiB (u64) and grows with the + table, so a small table costs what a small table costs. See [Footprint](docs/design.md#footprint). `dispose()`, or a `using` declaration, hands an instance back without waiting for the collector. - Bulk `setMany`/`getMany`/`deleteMany` on both tables cross once per batch: - a 100,000-entry u32 fill is 6.2–6.7 ns/op against `Map`'s 44–65, and the - matching lookup 4.1–4.7 against 10–22. + a 100,000-entry u32 fill is 6.1–6.9 ns/op against `Map`'s 48–70, and the + matching lookup 3.9–4.9 against 11–24. - `getOrInsert` and `increment` do a read-modify-write in one crossing and - one probe: counting is 1.25–1.8x faster than `get` plus `set`. -- No allocator — fixed linear memory, linked `-nostdlib`, never calls - `memory.grow`, nothing allocated on a hot path. + one probe. Inserting a missing key is 5.0–6.3x faster than `Map`. +- No allocator. Linked `-nostdlib`, with the banks at computed offsets and + linear memory grown to reach them. Nothing allocates on a hot path. - Ships compiled ESM with type declarations, and the modules are compiled in: no `.wasm` to serve, no loader to write, no install step, no dependencies. -- Runs in Node, Bun, Deno, bundlers, and browsers — anything with WebAssembly +- Runs in Node, Bun, Deno, bundlers, and browsers. Anything with WebAssembly SIMD (Node 16.9+, Chrome 91+, Firefox 89+, Safari 16.4+), which `supportsSimd()` reports for the current runtime. @@ -83,11 +83,11 @@ Four exports: | `StringInterner` | `string -> u32` | stable IDs in first-seen order | | `InternedSwissMap` | `string -> V` | string keys over a numeric table | -Keys and values are strictly `u32` and anything else throws `RangeError`; -capacity is fixed at build time (917,504 entries); a stored `0` is always -distinguishable from an absent key. See [docs/api.md](docs/api.md) for every -method and thrown error, and [`examples/`](examples/README.md) for five -runnable programs. +Keys and values are strictly `u32` and anything else throws `RangeError`. +Capacity is bounded at 117,440,512 entries (u32) or 58,720,256 (u64). A +stored `0` is always distinguishable from an absent key. See +[docs/api.md](docs/api.md) for every method and thrown error, and +[`examples/`](examples/README.md) for five runnable programs. Two operational notes. Each table seeds its hash from the runtime's CSPRNG, so a colliding key set cannot be computed offline and reused across @@ -117,18 +117,18 @@ its own, probed in a shuffled order. i9-13900K on x64 Linux. | Workload | Bun 1.3 | Node 24 | Deno 2.9 | Chrome 151 | Firefox 153 | | --- | --- | --- | --- | --- | --- | -| fill (pre-sized) | 8.3x | 6.4x | 5.6x | 4.2x | 5.2x | -| lookup hit | 1.60x | 2.8x | 3.2x | 2.6x | 1.79x | -| lookup miss | 1.38x | 3.3x | 3.4x | 2.6x | 1.73x | -| `has` | 1.84x | 3.4x | 3.6x | 3.1x | 1.98x | -| overwrite existing key | 2.3x | 2.7x | 2.9x | 2.7x | 3.2x | -| delete | 5.6x | 5.5x | 5.4x | 4.3x | 4.3x | -| churn (delete + reinsert) | 3.2x | 3.6x | 3.5x | 2.9x | 3.2x | -| count (`increment`) | 1.41x | 1.39x | 1.47x | 1.49x | 0.96x | -| u32 bulk fill (`setMany`) | 9.8x | 9.6x | 8.0x | 6.6x | 8.6x | -| u32 bulk lookup (`getMany`) | 2.4x | 5.4x | 4.8x | 5.0x | 3.8x | -| u64 bulk fill (`setMany`) | 6.0x | 8.0x | 6.9x | 6.2x | 9.1x | -| u64 bulk lookup (`getMany`) | 1.56x | 3.8x | 3.7x | 3.2x | 2.3x | +| fill (pre-sized) | 5.2x | 6.2x | 5.0x | 3.6x | 4.7x | +| lookup hit | 1.50x | 2.7x | 2.8x | 2.3x | 1.60x | +| lookup miss | 1.29x | 3.2x | 3.4x | 2.5x | 1.70x | +| `has` | 1.77x | 3.2x | 3.5x | 2.9x | 1.86x | +| overwrite existing key | 2.2x | 2.5x | 2.5x | 2.2x | 2.8x | +| delete | 6.1x | 5.5x | 5.8x | 4.1x | 4.7x | +| churn (delete + reinsert) | 3.3x | 3.6x | 3.5x | 2.6x | 3.2x | +| count (`increment`) | 1.26x | 1.33x | 1.35x | 1.28x | 0.89x | +| u32 bulk fill (`setMany`) | 8.4x | 11x | 8.3x | 7.6x | 9.2x | +| u32 bulk lookup (`getMany`) | 2.7x | 5.6x | 4.9x | 5.4x | 3.6x | +| u64 bulk fill (`setMany`) | 10x | 9.7x | 8.6x | 6.4x | 8.5x | +| u64 bulk lookup (`getMany`) | 2.1x | 4.1x | 3.8x | 3.6x | 2.3x | The table costs about the same on every engine. The columns differ because `Map` does. Counting is the one row a browser engine takes: at 1,000 distinct diff --git a/SECURITY.md b/SECURITY.md index e36fb99..53c5d10 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,9 +22,10 @@ memory and nothing else, and cannot reach past it — a bug in the C is confined to the instance's memory by the WebAssembly sandbox, which is the main reason the table is compiled rather than written in JavaScript. -The modules are built `-nostdlib` with no allocator and never call -`memory.grow`, so there is no allocation path to exhaust and no `malloc` to -corrupt. +The modules are built `-nostdlib` with no allocator, so there is no `malloc` +to corrupt. Linear memory grows only when a rehash reaches for its next +bank. A host that refuses the growth gets a capacity error, and the table is +left exactly as it was. ## In scope @@ -64,14 +65,25 @@ plain memory with no atomics, and sharing an instance across workers corrupts it. Share the compiled `WebAssembly.Module` instead and give each worker its own instance. -**Capacity is fixed at build time.** Exceeding it throws `RangeError` -rather than growing. An input that drives a table past 917,504 entries is a -capacity-planning problem in the calling service. - -**Every instance reserves its full linear memory up front** — 21 MiB for -`SwissU32ToU32`, 29 MiB for `SwissU32ToU64` — committed by page, so an empty -one costs about 1.7 MiB RSS. Code that creates a table per request and never -calls `dispose()` will accumulate them until the collector runs. +**Capacity is bounded** at 117,440,512 entries for `SwissU32ToU32` and +58,720,256 for `SwissU32ToU64`. Exceeding it throws `RangeError` rather than +growing. If untrusted input decides how many entries a table holds, that is +a capacity-planning problem in the calling service. The ceiling is high +enough that such input reaches host memory first. + +**An instance never hands memory back.** It starts at 1.25 MiB +(`SwissU32ToU32`) or 1.75 MiB (`SwissU32ToU64`) and grows with the table. +Neither `clear()` nor `shrinkToFit()` returns pages, so an instance holds +the high-water mark of every bank it used. Code that creates a table per +request and never calls `dispose()` accumulates them until the collector +runs. + +**Each module declares a large maximum memory**, 3.4 GiB for +`SwissU32ToU32` and 2.4 GiB for `SwissU32ToU64`. This is address space, not +a reservation, and no page is committed until a table reaches it. A +memory-constrained or 32-bit host may still decline it at instantiation, +which surfaces as a construction failure. Build with a lower +`SWISS_MAX_CAPACITY_LOG2` for those hosts. ## Supported versions diff --git a/benches/bench.ts b/benches/bench.ts index 2e7c01a..ee260f5 100644 --- a/benches/bench.ts +++ b/benches/bench.ts @@ -1424,11 +1424,10 @@ async function shrinkScenario(peak: number, remaining: number): Promise { /** * Bytes of linear memory each slot costs. * - * Two banks are reserved so a rehash has somewhere to move entries to, and - * both are static arrays sized to the compiled ceiling — so a slot costs its - * per-bank layout twice, whether or not a rehash is in progress. Kept in - * step with `bankBytesPerSlot` in scripts/build-wasm.ts, which is what the - * modules are actually linked against. + * Each module addresses two banks so a rehash has somewhere to move entries + * to, and the address extent a table holds covers both — so a slot costs its + * per-bank layout twice. Kept in step with `bankBytesPerSlot` in + * scripts/build-wasm.ts, which is the per-bank half of the same number. */ const U32_BYTES_PER_SLOT = 2 * (1 + 8); const U64_BYTES_PER_SLOT = 2 * (1 + 12); @@ -1681,9 +1680,9 @@ async function memoryScenario(keys: Uint32Array): Promise { `in the live bank alone`, ); console.log( - ` linear memory reserved per instance: ` + - `${(reserved.u32 / (1024 * 1024)).toFixed(0)} MiB (u32), ` + - `${(reserved.u64 / (1024 * 1024)).toFixed(0)} MiB (u64)`, + ` linear memory an instance starts at: ` + + `${(reserved.u32 / (1024 * 1024)).toFixed(2)} MiB (u32), ` + + `${(reserved.u64 / (1024 * 1024)).toFixed(2)} MiB (u64)`, ); } diff --git a/benches/compare.ts b/benches/compare.ts index b9ae64f..70c0e50 100644 --- a/benches/compare.ts +++ b/benches/compare.ts @@ -544,15 +544,15 @@ function memorySummary(): string | null { `The tables are not on the heap and are not measured the same way. A ` + `slot costs ${report.slotBytes.u32} B (u32) or ` + `${report.slotBytes.u64} B (u64) of linear memory, both banks ` + - `counted — at the 7/8 load ceiling that is ` + + `counted. At the 7/8 load factor that is ` + `${perEntry(report.slotBytes.u32)} B/entry and ` + `${perEntry(report.slotBytes.u64)} B/entry, of which ` + `${perEntry(report.slotBytes.u32 / 2)} B and ` + `${perEntry(report.slotBytes.u64 / 2)} B are the live bank the ` + - `entries are actually in. An instance reserves ` + - `${(report.reservedBytes.u32 / (1024 * 1024)).toFixed(0)} MiB (u32) or ` + - `${(report.reservedBytes.u64 / (1024 * 1024)).toFixed(0)} MiB (u64) of ` + - `address space up front, committed page by page as it is touched.` + `entries are actually in. An instance starts at ` + + `${(report.reservedBytes.u32 / (1024 * 1024)).toFixed(2)} MiB (u32) or ` + + `${(report.reservedBytes.u64 / (1024 * 1024)).toFixed(2)} MiB (u64) ` + + `and grows linear memory as the table reaches each new bank.` ); } } diff --git a/docs/api.md b/docs/api.md index e108b81..10bf2c7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -44,10 +44,10 @@ validated. That cost is real and you only pay it for a source that needs it: | Source | `getMany` over 100k keys | | --- | --- | -| `Uint32Array`, `Int32Array` | 8.1 ns/key | -| `Float64Array` | 8.9 ns/key | -| plain `number[]` | 16.9 ns/key | -| `BigUint64Array` | 82.9 ns/key | +| `Uint32Array`, `Int32Array` | 5.3 ns/key | +| `Float64Array` | 8.1 ns/key | +| plain `number[]` | 8.0 ns/key | +| `BigUint64Array` | 32 ns/key | If you control the data, keep it in a `Uint32Array`. The rest exist so interop is possible, not so it is free. @@ -73,43 +73,42 @@ machine int32, which is why keys above `2^31` cost no more than keys below it. Where the caller keeps its keys still matters; see [performance.md](performance.md#where-the-callers-keys-are-stored). -**Capacity is fixed at build time.** The modules are freestanding and link -without an allocator, so the ceiling is `1 << 20` slots — 917,504 live -entries at the 7/8 load factor. Requests past it throw `RangeError`. Rebuild -with `SWISS_MAX_CAPACITY_LOG2` set to a different power-of-two exponent in -`[4, 25]` to move it either way; the build script sizes linear memory from the -same number. +**Capacity is bounded.** The u32 module holds `1 << 27` slots, or +117,440,512 live entries at the 7/8 load factor. The u64 module holds +`1 << 26` slots, or 58,720,256 entries; its entries are wider, so three of +its banks run out of wasm32 address space an exponent earlier. + +Requests past the ceiling throw `RangeError`, and so does an operation the +host refuses to grow linear memory for. Rebuild with a lower +`SWISS_MAX_CAPACITY_LOG2` to cap what a module asks the host for. Run a table below its ceiling, not at it. A table holding close to the maximum while inserting and deleting at the same rate has no room left to grow into, -so it compacts on every insert instead of amortizing — raise the exponent, or +so it compacts on every insert instead of amortizing. Raise the exponent or shard across instances. -**Each module instance owns exactly one table, and reserves its whole budget -up front.** The banks are static arrays, so an instance reserves 21 MiB (u32) -or 29 MiB (u64) of linear memory from instantiation whether it holds one -entry or its maximum. The reservation is address space and is committed page -by page as the table touches it, so a small table still resides small — but -two tables mean two instances and twice the reservation. See -[performance.md](performance.md#memory) for what an entry costs. `create()` -shares one compiled module across every table it makes; with `load`, compile -once with `WebAssembly.compile` and pass the module in each time. Several -instances alongside each other are shown in -[`examples/04-multiple-tables.ts`](../examples/04-multiple-tables.ts). For -many small tables, build a second pair of modules with a lower -`SWISS_MAX_CAPACITY_LOG2`: at `2^16` a u32 instance costs 4.1 MiB instead of -21, capped at 57,344 entries. +**Each module instance owns exactly one table, and grows into it.** An +instance starts at 1.25 MiB (u32) or 1.75 MiB (u64), which is its staging +buffers and stack. From there it grows linear memory as the table reaches +each new bank, so an instance costs what it holds rather than what it could +hold. See [performance.md](performance.md#memory) for what an entry costs. + +`create()` shares one compiled module across every table it makes. With +`load`, compile once with `WebAssembly.compile` and pass the module in each +time. Several instances alongside each other are shown in +[`examples/04-multiple-tables.ts`](../examples/04-multiple-tables.ts). **Dropping a table does not release it promptly.** Its memory belongs to the `WebAssembly.Instance` behind it and comes back when the garbage collector runs, not when the last reference goes out of scope — so a table built per -request holds its reservation until then, and a burst of them holds several -at once. Call `dispose()`, or bind the table with `using`, to hand the +request holds its memory until then, and a burst of them holds several at +once. Call `dispose()`, or bind the table with `using`, to hand the instance back at a point you choose. Reusing one long-lived table and calling `clear()` between rounds avoids the question entirely: one pass over -the control bytes, no new reservation. Linear memory never shrinks, so -neither `clear()` nor `shrinkToFit()` returns pages to the host; they ready -the table for the next round rather than shrink the process. +the control bytes, and no bank it has not already grown into. Linear memory +never shrinks, so neither `clear()` nor `shrinkToFit()` returns pages to the +host; they ready the table for the next round rather than shrink the +process. An instance holds the high-water mark of every bank it ever used. **A stored `0` is never confused with an absent key.** Presence is reported separately from the value, so no sentinel is overloaded anywhere in the API. @@ -134,7 +133,7 @@ is involved, so this behaves identically on every runtime. The module is compiled on the first call and shared by every later one, so only instantiation is paid per table. Measured on Bun 1.3.14 / x64 Linux: -1.49 ms for the first table, 152 µs for each one after, against 276 µs when +2.2 ms for the first table, 167 µs for each one after, against 259 µs when recompiling bytes on every call. `expectedEntries` behaves as it does for `load`. @@ -193,7 +192,7 @@ already compiled with `WebAssembly.compile`; pass the compiled module when creating several tables, to skip validation and codegen each time. `expectedEntries` sizes the table up front. It is the cheapest optimization -available here — filling a pre-sized table is ~3.2x faster than growing one +available here: filling a pre-sized table is 2.7x to 3.2x faster than growing one from empty, because growth rehashes through every doubling. Seeding matches `create`: random per instance, with `loadWithSeed(wasmBytes, @@ -288,8 +287,8 @@ table.increment(key); // one of each ``` `increment` always saves a crossing, because the read and the write are one -operation whichever way the key falls. Counting 100,000 keys costs 6.1 ns -each on Bun against 10.7 for `get` plus `set`; the gain is **1.25x to 1.8x** +operation whichever way the key falls. Counting 100,000 keys costs 6.7 ns +each on Bun against 12.0 for `get` plus `set`; the gain is **1.25x to 1.8x** depending on the engine. **`getOrInsert` only saves one when the key is absent.** Written out, the @@ -299,7 +298,7 @@ second crossing is inside the branch: if (table.get(key) === undefined) table.set(key, value); // hit: 1, miss: 2 ``` -So the gain tracks the miss rate: **1.23x to 1.33x** over keys none of which +So the gain tracks the miss rate: **1.22x to 1.29x** over keys none of which are present, and nothing at all over keys that are all present, where the two tie on every engine. Reach for it where misses are common, which is what memoizing is; on a read-mostly table `get` is still the right call. @@ -315,12 +314,12 @@ reproduce with `bun run bench --scenario=upsert`. These stage a whole batch into memory the module owns and cross the boundary once per chunk instead of once per key — the widest margin over `Map` in the -suite. Over 100,000 sparse keys on Bun, `setMany` fills at 6.6 ns/key against -8.1 for a `set` loop and 65.1 for `Map`; `getMany` reads at 4.2 ns/key -against 6.6 for a `get` loop and 10.0 for `Map`. +suite. Over 100,000 sparse keys on Bun, `setMany` fills at 6.1 ns/key against +8.5 for a `set` loop and 51.2 for `Map`; `getMany` reads at 3.9 ns/key +against 5.1 for a `get` loop and 10.6 for `Map`. -That 4.2 is with an `out` buffer passed back in; allocating fresh result -arrays each call costs 5.6. Reuse them on a hot loop: +That figure is with an `out` buffer passed back in. Allocating fresh result +arrays each call costs 6.3 against 4.2. Reuse them on a hot loop: ```ts table.setMany(keys, values); @@ -340,8 +339,8 @@ zeroed, so output buffers never carry stale values from a previous batch. `setMany` is **not atomic** on a capacity ceiling — see [Rules that apply everywhere](#rules-that-apply-everywhere). | `reserve(entries)` | `void` | Makes room for `entries` total without a further rehash, preserving contents. Rehashes when growth spent on since-deleted entries is what stands in the way, even where the capacity would suffice. No-op when the remaining growth already covers it. | -| `shrinkToFit()` | `void` | Rehashes down to the smallest capacity holding the live entries. No-op if already there. | -| `clear()` | `void` | Empties the table but **retains capacity**. Follow with `shrinkToFit()` to hand the slots back. | +| `shrinkToFit()` | `void` | Rehashes down to the smallest capacity holding the live entries. No-op if already there. Recovers walk cost, not memory. | +| `clear()` | `void` | Empties the table but **retains capacity**. Follow with `shrinkToFit()` to hand the slots back. Neither returns memory to the host. | | `dispose()` | `void` | Releases the module instance. Idempotent, and aliased as `Symbol.dispose` so a table works with `using`. Afterwards `size` and `capacity` read 0, and every method that would touch the instance throws. An iterator opened beforehand keeps walking, and keeps the instance alive until it ends. | ### Iteration @@ -361,8 +360,8 @@ compiled ceiling. so the cost tracks the slot space rather than what is in it — and capacity only ever rises on its own: `reserve` and the growth path raise it, `clear` retains it, and a `delete` leaves a tombstone rather than a freed slot. A -table that peaked at 100k entries and now holds 8 takes 41–72 µs per walk; -`shrinkToFit()` brings that to 1.3–1.5 µs. Call it after a bulk removal on a +table that peaked at 100k entries and now holds 8 takes 72–131 µs per walk; +`shrinkToFit()` brings that to 1.3–1.9 µs. Call it after a bulk removal on a long-lived table that is walked repeatedly. **Order is unspecified.** It is slot order, which depends on the hash and @@ -399,8 +398,8 @@ table.reserve(500_000); // rehashes ``` **Cost.** `forEach` allocates nothing per entry, and over 100k entries it -beats `Map.prototype.forEach` by 2.3x on JavaScriptCore while losing to it by -about 25% on V8. The iterator protocol is more expensive everywhere: +beats `Map.prototype.forEach` by 2.6x on JavaScriptCore while losing to it by +28% to 37% on V8. The iterator protocol is more expensive everywhere: `keys()`, `values()`, and `entries()` allocate a result record per entry the way the built-ins do, and `entries()` runs slower than `Map`'s, whose iterator is engine-internal. Prefer `forEach` when the values are needed and @@ -454,8 +453,8 @@ value argument, so it must box the two lanes into a `U64Lanes` per entry. calls `callback(lo, hi, key, table)` and allocates nothing. When the callback discards the lanes the two run at the same speed, because escape analysis removes the object; when it keeps them it cannot, and `forEachLanes` is -faster on every engine — by 29% on Bun, by 2.2x on Firefox. Prefer it on a hot -path. +faster on every engine: 19% to 33% on V8, and about 2x on JavaScriptCore and +SpiderMonkey. Prefer it on a hot path. ```ts table.forEachLanes((lo, hi, key) => { diff --git a/docs/assets/crossover.svg b/docs/assets/crossover.svg index 4809bc7..dc230ea 100644 --- a/docs/assets/crossover.svg +++ b/docs/assets/crossover.svg @@ -1 +1 @@ -2k8k16k32k128k512kentries0x1x2x3xspeedup against MapBun 1.3Node 24Deno 2.9Chrome 151Firefox 153runtimeWhere the WASM crossing pays for itselfSparse u32 lookup hit. Above the 1x line the table beats Map. Log scale on entries. \ No newline at end of file +2k8k16k32k128k512kentries0x1x2x3xspeedup against MapBun 1.3Node 24Deno 2.9Chrome 151Firefox 153runtimeWhere the WASM crossing pays for itselfSparse u32 lookup hit. Above the 1x line the table beats Map. Log scale on entries. \ No newline at end of file diff --git a/docs/assets/crossover.vl.json b/docs/assets/crossover.vl.json index f44eb9b..04450d3 100644 --- a/docs/assets/crossover.vl.json +++ b/docs/assets/crossover.vl.json @@ -58,27 +58,27 @@ { "entries": 2000, "runtime": "Bun 1.3", - "speedup": 0.68 + "speedup": 0.65 }, { "entries": 2000, "runtime": "Node 24", - "speedup": 0.75 + "speedup": 0.77 }, { "entries": 2000, "runtime": "Deno 2.9", - "speedup": 1.12 + "speedup": 1.03 }, { "entries": 2000, "runtime": "Chrome 151", - "speedup": 0.79 + "speedup": 0.8 }, { "entries": 2000, "runtime": "Firefox 153", - "speedup": 0.33 + "speedup": 0.35 }, { "entries": 8000, @@ -93,117 +93,117 @@ { "entries": 8000, "runtime": "Deno 2.9", - "speedup": 2.92 + "speedup": 2.71 }, { "entries": 8000, "runtime": "Chrome 151", - "speedup": 2.4 + "speedup": 2.26 }, { "entries": 8000, "runtime": "Firefox 153", - "speedup": 1.71 + "speedup": 1.69 }, { "entries": 16000, "runtime": "Bun 1.3", - "speedup": 1.41 + "speedup": 1.37 }, { "entries": 16000, "runtime": "Node 24", - "speedup": 3.07 + "speedup": 2.76 }, { "entries": 16000, "runtime": "Deno 2.9", - "speedup": 3.3 + "speedup": 2.98 }, { "entries": 16000, "runtime": "Chrome 151", - "speedup": 2.56 + "speedup": 2.61 }, { "entries": 16000, "runtime": "Firefox 153", - "speedup": 1.84 + "speedup": 1.8 }, { "entries": 32000, "runtime": "Bun 1.3", - "speedup": 1.27 + "speedup": 1.26 }, { "entries": 32000, "runtime": "Node 24", - "speedup": 3.19 + "speedup": 2.82 }, { "entries": 32000, "runtime": "Deno 2.9", - "speedup": 3.42 + "speedup": 3.08 }, { "entries": 32000, "runtime": "Chrome 151", - "speedup": 2.83 + "speedup": 2.7 }, { "entries": 32000, "runtime": "Firefox 153", - "speedup": 1.94 + "speedup": 1.79 }, { "entries": 128000, "runtime": "Bun 1.3", - "speedup": 1.53 + "speedup": 1.46 }, { "entries": 128000, "runtime": "Node 24", - "speedup": 3.04 + "speedup": 2.91 }, { "entries": 128000, "runtime": "Deno 2.9", - "speedup": 3.49 + "speedup": 3.13 }, { "entries": 128000, "runtime": "Chrome 151", - "speedup": 2.93 + "speedup": 2.74 }, { "entries": 128000, "runtime": "Firefox 153", - "speedup": 1.96 + "speedup": 1.88 }, { "entries": 512000, "runtime": "Bun 1.3", - "speedup": 1.39 + "speedup": 1.65 }, { "entries": 512000, "runtime": "Node 24", - "speedup": 2.68 + "speedup": 2.81 }, { "entries": 512000, "runtime": "Deno 2.9", - "speedup": 3.14 + "speedup": 3.27 }, { "entries": 512000, "runtime": "Chrome 151", - "speedup": 2.58 + "speedup": 2.66 }, { "entries": 512000, "runtime": "Firefox 153", - "speedup": 1.78 + "speedup": 1.63 } ] }, diff --git a/docs/assets/speedup.svg b/docs/assets/speedup.svg index ade192b..d868336 100644 --- a/docs/assets/speedup.svg +++ b/docs/assets/speedup.svg @@ -1 +1 @@ -0.5x1x2x3x5x8x12xspeedup against Mapu64 bulk delete (deleteMany)u32 bulk fill (setMany)fill, sparse (pre-sized)getOrInsert, key absentu64 bulk fill (setMany)deletefill, dense (pre-sized)u32 bulk lookup (getMany)churn (delete + reinsert)has, sparseoverwrite an existing keylookup miss, sparselookup hit, sparseu64 bulk lookup (getMany)fill, sparse (grown from empty)lookup miss, denselookup hit, densecount (increment)iterate (forEach)string keys, repeated lookupBun 1.3Node 24Deno 2.9Chrome 151Firefox 153runtimeSwissTable speedup against Map, 100,000 sparse u32 keysRight of the 1x line the table is faster. Log scale. i9-13900K, Linux x64. \ No newline at end of file +0.5x1x2x3x5x8x12xspeedup against Mapu64 bulk delete (deleteM…u32 bulk fill (setMany)fill, sparse (pre-sized)getOrInsert, key absentu64 bulk fill (setMany)deletefill, dense (pre-sized)u32 bulk lookup (getMany…churn (delete + reinsert…has, sparseoverwrite an existing ke…lookup miss, sparselookup hit, sparseu64 bulk lookup (getMany…fill, sparse (grown from…lookup miss, denselookup hit, densecount (increment)iterate (forEach)string keys, repeated lo…Bun 1.3Node 24Deno 2.9Chrome 151Firefox 153runtimeSwissTable speedup against Map, 100,000 sparse u32 keysRight of the 1x line the table is faster. Log scale. i9-13900K, Linux x64. \ No newline at end of file diff --git a/docs/assets/speedup.vl.json b/docs/assets/speedup.vl.json index ee6c5d6..a02d33e 100644 --- a/docs/assets/speedup.vl.json +++ b/docs/assets/speedup.vl.json @@ -61,7 +61,7 @@ { "workload": "u64 bulk delete (deleteMany)", "runtime": "Bun 1.3", - "speedup": 5.4 + "speedup": 7.1 }, { "workload": "u64 bulk delete (deleteMany)", @@ -71,112 +71,112 @@ { "workload": "u64 bulk delete (deleteMany)", "runtime": "Deno 2.9", - "speedup": 8.9 + "speedup": 9.1 }, { "workload": "u32 bulk fill (setMany)", "runtime": "Bun 1.3", - "speedup": 9.8 + "speedup": 8.4 }, { "workload": "u32 bulk fill (setMany)", "runtime": "Node 24", - "speedup": 9.6 + "speedup": 11 }, { "workload": "u32 bulk fill (setMany)", "runtime": "Deno 2.9", - "speedup": 8 + "speedup": 8.3 }, { "workload": "u32 bulk fill (setMany)", "runtime": "Chrome 151", - "speedup": 6.6 + "speedup": 7.6 }, { "workload": "u32 bulk fill (setMany)", "runtime": "Firefox 153", - "speedup": 8.6 + "speedup": 9.2 }, { "workload": "fill, sparse (pre-sized)", "runtime": "Bun 1.3", - "speedup": 8.3 + "speedup": 5.2 }, { "workload": "fill, sparse (pre-sized)", "runtime": "Node 24", - "speedup": 6.4 + "speedup": 6.2 }, { "workload": "fill, sparse (pre-sized)", "runtime": "Deno 2.9", - "speedup": 5.6 + "speedup": 5 }, { "workload": "fill, sparse (pre-sized)", "runtime": "Chrome 151", - "speedup": 4.2 + "speedup": 3.6 }, { "workload": "fill, sparse (pre-sized)", "runtime": "Firefox 153", - "speedup": 5.2 + "speedup": 4.7 }, { "workload": "getOrInsert, key absent", "runtime": "Bun 1.3", - "speedup": 8 + "speedup": 5.3 }, { "workload": "getOrInsert, key absent", "runtime": "Node 24", - "speedup": 6.6 + "speedup": 6.3 }, { "workload": "getOrInsert, key absent", "runtime": "Deno 2.9", - "speedup": 6.4 + "speedup": 5.4 }, { "workload": "getOrInsert, key absent", "runtime": "Chrome 151", - "speedup": 5.8 + "speedup": 5 }, { "workload": "getOrInsert, key absent", "runtime": "Firefox 153", - "speedup": 5.6 + "speedup": 5.1 }, { "workload": "u64 bulk fill (setMany)", "runtime": "Bun 1.3", - "speedup": 6 + "speedup": 10 }, { "workload": "u64 bulk fill (setMany)", "runtime": "Node 24", - "speedup": 8 + "speedup": 9.7 }, { "workload": "u64 bulk fill (setMany)", "runtime": "Deno 2.9", - "speedup": 6.9 + "speedup": 8.6 }, { "workload": "u64 bulk fill (setMany)", "runtime": "Chrome 151", - "speedup": 6.2 + "speedup": 6.4 }, { "workload": "u64 bulk fill (setMany)", "runtime": "Firefox 153", - "speedup": 9.1 + "speedup": 8.5 }, { "workload": "delete", "runtime": "Bun 1.3", - "speedup": 5.6 + "speedup": 6.1 }, { "workload": "delete", @@ -186,72 +186,72 @@ { "workload": "delete", "runtime": "Deno 2.9", - "speedup": 5.4 + "speedup": 5.8 }, { "workload": "delete", "runtime": "Chrome 151", - "speedup": 4.3 + "speedup": 4.1 }, { "workload": "delete", "runtime": "Firefox 153", - "speedup": 4.3 + "speedup": 4.7 }, { "workload": "fill, dense (pre-sized)", "runtime": "Bun 1.3", - "speedup": 7.4 + "speedup": 4 }, { "workload": "fill, dense (pre-sized)", "runtime": "Node 24", - "speedup": 4.4 + "speedup": 4.1 }, { "workload": "fill, dense (pre-sized)", "runtime": "Deno 2.9", - "speedup": 3.5 + "speedup": 3.2 }, { "workload": "fill, dense (pre-sized)", "runtime": "Chrome 151", - "speedup": 2.7 + "speedup": 2.4 }, { "workload": "fill, dense (pre-sized)", "runtime": "Firefox 153", - "speedup": 3.9 + "speedup": 3.6 }, { "workload": "u32 bulk lookup (getMany)", "runtime": "Bun 1.3", - "speedup": 2.4 + "speedup": 2.7 }, { "workload": "u32 bulk lookup (getMany)", "runtime": "Node 24", - "speedup": 5.4 + "speedup": 5.6 }, { "workload": "u32 bulk lookup (getMany)", "runtime": "Deno 2.9", - "speedup": 4.8 + "speedup": 4.9 }, { "workload": "u32 bulk lookup (getMany)", "runtime": "Chrome 151", - "speedup": 5 + "speedup": 5.4 }, { "workload": "u32 bulk lookup (getMany)", "runtime": "Firefox 153", - "speedup": 3.8 + "speedup": 3.6 }, { "workload": "churn (delete + reinsert)", "runtime": "Bun 1.3", - "speedup": 3.2 + "speedup": 3.3 }, { "workload": "churn (delete + reinsert)", @@ -266,7 +266,7 @@ { "workload": "churn (delete + reinsert)", "runtime": "Chrome 151", - "speedup": 2.9 + "speedup": 2.6 }, { "workload": "churn (delete + reinsert)", @@ -276,62 +276,62 @@ { "workload": "has, sparse", "runtime": "Bun 1.3", - "speedup": 1.84 + "speedup": 1.77 }, { "workload": "has, sparse", "runtime": "Node 24", - "speedup": 3.4 + "speedup": 3.2 }, { "workload": "has, sparse", "runtime": "Deno 2.9", - "speedup": 3.6 + "speedup": 3.5 }, { "workload": "has, sparse", "runtime": "Chrome 151", - "speedup": 3.1 + "speedup": 2.9 }, { "workload": "has, sparse", "runtime": "Firefox 153", - "speedup": 1.98 + "speedup": 1.86 }, { "workload": "overwrite an existing key", "runtime": "Bun 1.3", - "speedup": 2.3 + "speedup": 2.2 }, { "workload": "overwrite an existing key", "runtime": "Node 24", - "speedup": 2.7 + "speedup": 2.5 }, { "workload": "overwrite an existing key", "runtime": "Deno 2.9", - "speedup": 2.9 + "speedup": 2.5 }, { "workload": "overwrite an existing key", "runtime": "Chrome 151", - "speedup": 2.7 + "speedup": 2.2 }, { "workload": "overwrite an existing key", "runtime": "Firefox 153", - "speedup": 3.2 + "speedup": 2.8 }, { "workload": "lookup miss, sparse", "runtime": "Bun 1.3", - "speedup": 1.38 + "speedup": 1.29 }, { "workload": "lookup miss, sparse", "runtime": "Node 24", - "speedup": 3.3 + "speedup": 3.2 }, { "workload": "lookup miss, sparse", @@ -341,57 +341,57 @@ { "workload": "lookup miss, sparse", "runtime": "Chrome 151", - "speedup": 2.6 + "speedup": 2.5 }, { "workload": "lookup miss, sparse", "runtime": "Firefox 153", - "speedup": 1.73 + "speedup": 1.7 }, { "workload": "lookup hit, sparse", "runtime": "Bun 1.3", - "speedup": 1.6 + "speedup": 1.5 }, { "workload": "lookup hit, sparse", "runtime": "Node 24", - "speedup": 2.8 + "speedup": 2.7 }, { "workload": "lookup hit, sparse", "runtime": "Deno 2.9", - "speedup": 3.2 + "speedup": 2.8 }, { "workload": "lookup hit, sparse", "runtime": "Chrome 151", - "speedup": 2.6 + "speedup": 2.3 }, { "workload": "lookup hit, sparse", "runtime": "Firefox 153", - "speedup": 1.79 + "speedup": 1.6 }, { "workload": "u64 bulk lookup (getMany)", "runtime": "Bun 1.3", - "speedup": 1.56 + "speedup": 2.1 }, { "workload": "u64 bulk lookup (getMany)", "runtime": "Node 24", - "speedup": 3.8 + "speedup": 4.1 }, { "workload": "u64 bulk lookup (getMany)", "runtime": "Deno 2.9", - "speedup": 3.7 + "speedup": 3.8 }, { "workload": "u64 bulk lookup (getMany)", "runtime": "Chrome 151", - "speedup": 3.2 + "speedup": 3.6 }, { "workload": "u64 bulk lookup (getMany)", @@ -401,7 +401,7 @@ { "workload": "fill, sparse (grown from empty)", "runtime": "Bun 1.3", - "speedup": 2.7 + "speedup": 1.59 }, { "workload": "fill, sparse (grown from empty)", @@ -411,102 +411,102 @@ { "workload": "fill, sparse (grown from empty)", "runtime": "Deno 2.9", - "speedup": 2.1 + "speedup": 1.93 }, { "workload": "fill, sparse (grown from empty)", "runtime": "Chrome 151", - "speedup": 1.5 + "speedup": 1.37 }, { "workload": "fill, sparse (grown from empty)", "runtime": "Firefox 153", - "speedup": 2.2 + "speedup": 2.1 }, { "workload": "lookup miss, dense", "runtime": "Bun 1.3", - "speedup": 0.85 + "speedup": 0.89 }, { "workload": "lookup miss, dense", "runtime": "Node 24", - "speedup": 1.89 + "speedup": 1.79 }, { "workload": "lookup miss, dense", "runtime": "Deno 2.9", - "speedup": 1.88 + "speedup": 1.89 }, { "workload": "lookup miss, dense", "runtime": "Chrome 151", - "speedup": 1.99 + "speedup": 1.94 }, { "workload": "lookup miss, dense", "runtime": "Firefox 153", - "speedup": 1.42 + "speedup": 1.38 }, { "workload": "lookup hit, dense", "runtime": "Bun 1.3", - "speedup": 1.12 + "speedup": 1.08 }, { "workload": "lookup hit, dense", "runtime": "Node 24", - "speedup": 1.76 + "speedup": 1.67 }, { "workload": "lookup hit, dense", "runtime": "Deno 2.9", - "speedup": 1.98 + "speedup": 1.77 }, { "workload": "lookup hit, dense", "runtime": "Chrome 151", - "speedup": 1.39 + "speedup": 1.33 }, { "workload": "lookup hit, dense", "runtime": "Firefox 153", - "speedup": 1.36 + "speedup": 1.25 }, { "workload": "count (increment)", "runtime": "Bun 1.3", - "speedup": 1.41 + "speedup": 1.26 }, { "workload": "count (increment)", "runtime": "Node 24", - "speedup": 1.39 + "speedup": 1.33 }, { "workload": "count (increment)", "runtime": "Deno 2.9", - "speedup": 1.47 + "speedup": 1.35 }, { "workload": "count (increment)", "runtime": "Chrome 151", - "speedup": 1.49 + "speedup": 1.28 }, { "workload": "count (increment)", "runtime": "Firefox 153", - "speedup": 0.96 + "speedup": 0.89 }, { "workload": "iterate (forEach)", "runtime": "Bun 1.3", - "speedup": 2.3 + "speedup": 2.6 }, { "workload": "iterate (forEach)", "runtime": "Node 24", - "speedup": 0.79 + "speedup": 0.78 }, { "workload": "iterate (forEach)", @@ -516,17 +516,17 @@ { "workload": "iterate (forEach)", "runtime": "Chrome 151", - "speedup": 0.74 + "speedup": 0.73 }, { "workload": "iterate (forEach)", "runtime": "Firefox 153", - "speedup": 1.37 + "speedup": 1.38 }, { "workload": "string keys, repeated lookup", "runtime": "Bun 1.3", - "speedup": 0.35 + "speedup": 0.34 }, { "workload": "string keys, repeated lookup", @@ -536,17 +536,17 @@ { "workload": "string keys, repeated lookup", "runtime": "Deno 2.9", - "speedup": 0.49 + "speedup": 0.44 }, { "workload": "string keys, repeated lookup", "runtime": "Chrome 151", - "speedup": 0.46 + "speedup": 0.42 }, { "workload": "string keys, repeated lookup", "runtime": "Firefox 153", - "speedup": 0.76 + "speedup": 0.72 } ] }, diff --git a/docs/design.md b/docs/design.md index 07d0100..b60f13e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -117,9 +117,34 @@ could place a key ahead of a live duplicate further along the same sequence. ## Two banks instead of an allocator The modules link `-nostdlib` and have no allocator, so a rehash cannot -allocate a new table. Instead each module statically reserves **two** banks -and rehashes by copying live entries from the active one into the idle one, -then swapping. +allocate a new table. Instead each module addresses **two** banks and +rehashes by copying live entries from the active one into the idle one, then +swapping. + +A bank is a computed offset above the module's static data, not a static +array, and the module grows linear memory to reach one before writing to it. +Static arrays would have to be sized at the ceiling, and `wasm-ld` reserves +all static data at instantiation, so the largest table a module could hold +would set what every instance paid for. + +Growing extends linear memory without moving what is already in it, so a +pointer held across a grow stays valid. Only views built on the JavaScript +side are detached, and the bindings rebuild those. + +Placement alternates with the bank index: bank 0 at the base of the heap, +bank 1 two of its own lengths above it. Consecutive banks are then always +disjoint, which is all a rehash needs, since only the old and the new are +ever live together. Banks of the same parity overlap and reuse pages already +committed, so repeated compaction at one capacity ping-pongs between two +fixed regions rather than walking up a heap that can never shrink. A +`reserve()` crossing several doublings at once produces a bank too large for +the anchor to clear; that one goes directly above the live bank instead. + +Address extent is 3x a bank on the odd index and 1.5x on the even one, and +which one a fill ends on depends on how many rehashes it took. The 3x case +is what sets each module's ceiling, because three banks have to address +inside wasm32's 4 GiB. Resident memory is lower, since same-parity banks +share pages: measured at 1.8x to 1.9x a bank across a fill. The cost is double the memory. The benefit is that nothing on any path allocates, the module has no libc dependency, and capacity is knowable at @@ -148,34 +173,33 @@ Interleaving removes one from the critical path — worth ~3 ns per lookup at ## Footprint -The banks are fixed static arrays and the module links with -`--initial-memory == --max-memory`, so an instance reserves its whole linear -memory — 21 MiB for u32, 29 MiB for u64 — the moment it is instantiated, -whether it holds one entry or its maximum. +An instance reserves its static data and stack, 1.25 MiB for u32 and +1.75 MiB for u64, and nothing proportional to the ceiling. Most of that is +the staging buffers the bulk and scan APIs use, sized by `BULK_CAPACITY` and +`SCAN_WINDOW` rather than by `MAX_CAPACITY`. -Both figures are derived from `MAX_CAPACITY`, not written down beside it: -`scripts/build-wasm.ts` computes them from bytes-per-slot times the slot -count plus fixed overhead, so lowering `SWISS_MAX_CAPACITY_LOG2` shrinks the -reservation instead of leaving it stranded at the default. At `2^16` a u32 -instance costs 4.1 MiB and a u64 instance 4.6 MiB, which is the build to -reach for when the workload is many small tables rather than one large one. +From there it grows with the table: 9 bytes per slot for u32, 13 for u64, +against a capacity that is the next power of two above `entries * 8 / 7`. +The `--max-memory` the module declares is the three-bank extent at its +ceiling, 3.4 GiB for u32 and 2.4 GiB for u64. That is address space the host +reserves and does not commit. -Reserved is not resident. The host commits pages as they are touched, and an +Declared is not resident. The host commits pages as they are touched, and an empty table touches only the control bytes of its initial 64 slots. Measured -on x64 Linux: +on x64 Linux with the module already compiled: | | RSS | virtual | | --- | --- | --- | -| empty u32 table | +1.7 MiB | +15.6 MiB | -| 64 empty u32 tables | +5.0 MiB | — | -| one table after 900k inserts | +32.9 MiB | +16.6 MiB | +| empty u32 table | +0.3 MiB | +0.0 MiB | +| 64 empty u32 tables | +4.8 MiB | +1.0 MiB | +| one table after 900k inserts | +26.3 MiB | +0.0 MiB | -Two things follow. The first is that address space is claimed up front: -virtual size barely moves across a fill that adds 27 MiB of resident memory. -The second is that the marginal cost of an extra instance is small — roughly -50 KiB once the first has paid for module compilation — so a program holding -dozens of tables is fine, and the fixed cost that matters is per *process*, -not per table. +Two things follow. The first is that the declared maximum is not claimed: +virtual size does not move across a fill that adds 26 MiB of resident +memory, even though the module declares 3.4 GiB it may grow into. The second +is that the marginal cost of an extra instance is about 75 KiB, so a program +holding dozens of tables is fine, and the fixed cost that matters is per +*process*, not per table. What this does not buy back is the small-table case. The reason `Map` wins below ~8k entries is the boundary crossing, not the footprint; see @@ -192,10 +216,18 @@ the value from it. A packed `u64` return would instead box a `BigInt` on every lookup — ~14 ns, more than a second crossing would cost — and could not distinguish a stored `0` from absence anyway. -**Views are built once.** The modules are linked with initial memory equal to -maximum memory and never call `memory.grow`, so the backing `ArrayBuffer` is -never detached or reallocated and a view stays valid for the module's -lifetime. +**Views are rebuilt after a grow.** A `memory.grow` replaces the backing +`ArrayBuffer` and detaches every view over it, so each binding re-derives +its cached views after any call that can rehash. The test is the length of a +view it already holds: a detached typed array reports 0, which costs a field +load, where reading `memory.buffer` would be a call into the engine's +`Memory` object on a path that runs per `set`. The addresses never move, +because every buffer the views cover is static data sitting below the heap. + +An open walk needs no such check. A grow only happens inside a rehash, a +rehash bumps the generation counter, and the walk tests that counter before +each window, so it is abandoned rather than allowed to read through a +detached view. **`size` and `capacity` are read the same way**, through `size_ptr()` and `capacity_ptr()`. They are properties on the JavaScript side, and a property @@ -323,12 +355,18 @@ that actually catches it is in the bindings — `load()` verifies every expected symbol is present on the instance and throws `TypeError` otherwise, and `create()` routes through `load()` for the same reason. -Capacity is `1 << 20` slots per bank, giving 917,504 live entries at the 7/8 -load factor. Override it at build time with `SWISS_MAX_CAPACITY_LOG2`, a -power-of-two exponent in `[4, 25]` — a power of two because the mask -arithmetic depends on it. The C sources take it as `-DMAX_CAPACITY_LOG2` and -fall back to 20 via `#ifndef`, and the build script sizes linear memory from -the same number, so there are no longer two places to keep in step. +Capacity is `1 << 27` slots per bank for u32, giving 117,440,512 live +entries at the 7/8 load factor, and `1 << 26` for u64, giving 58,720,256. +They differ because a u64 entry is wider and three of its banks run out of +address space an exponent earlier. Each source carries its own exponent and +asserts it against `sizeof(Entry)` at compile time. + +Override it at build time with `SWISS_MAX_CAPACITY_LOG2`, a power-of-two +exponent in `[4, 29]`. A power of two because the mask arithmetic depends on +it. The C sources take it as `-DMAX_CAPACITY_LOG2` and fall back to their +own value via `#ifndef`. Lowering it caps how far one table can grow and how +much address space the module asks the host for. It does not change what an +instance reserves at construction, which is fixed by the staging buffers. ## Embedding diff --git a/docs/performance.md b/docs/performance.md index 4bab465..6d8a8a0 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1,19 +1,19 @@ # Performance **On sparse `u32` keys at 100,000 entries these tables beat `Map` on every -runtime measured — by 1.4x to 12x, with the widest margins on bulk transfer +runtime measured, by 1.3x to 12x, with the widest margins on bulk transfer and mutation rather than on lookup. Below ~2,000 entries `Map` wins nearly -everywhere; by 8,000 the tables are ahead on every engine except +everywhere. By 8,000 the tables are ahead on every engine except JavaScriptCore, which crosses over near 10,000. For string keys `Map` wins everywhere, and for dense keys a typed array beats both.** Four things explain the whole picture: 1. A crossing into WASM costs a few nanoseconds before any work happens, so - the tables need a working-set advantage large enough to repay it — which + the tables need a working-set advantage large enough to repay it. That only exists once a table outgrows cache. 2. **`Map` is not one container.** JavaScriptCore's `Map` looks up a sparse - `u32` key in 10.7 ns; V8's takes 24–25 ns for the same work. The tables + `u32` key in 11.1 ns; V8's takes 26–27 ns for the same work. The tables cost about the same everywhere, so most of the spread between columns below is `Map` moving, not the table. 3. The largest single cost on the JavaScriptCore path is not in the table at @@ -27,21 +27,21 @@ Four things explain the whole picture: | --- | --- | --- | --- | | Bun 1.3.14 | JavaScriptCore | yes | 0.03 µs | | Node 24.15.0 | V8 | yes | 0.06 µs | -| Deno 2.9.4 | V8 | yes | 0.15 µs | +| Deno 2.9.4 | V8 | yes | 0.17 µs | | Chrome 151 | V8 | yes | 5 µs | | Firefox 153 | SpiderMonkey | no | 20 µs | 13th Gen Intel Core i9-13900K, Linux x64. 100,000 entries, median of 21 rounds, median of 3 passes, one isolate per contender. Treat the ratios within a column as the portable part and the absolute figures as specific to -this machine — but not the ratios *across* columns, which is the point of -publishing five. +this machine. The ratios *across* columns are portable too, and are the point +of publishing five. Five properties of the harness are load-bearing: - **Every runtime executes the same scenario code.** `benches/runtime.ts` - supplies the four things that differ between hosts — a nanosecond clock, - module loading, isolation, and where results go — and the scenarios import + supplies the four things that differ between hosts (a nanosecond clock, + module loading, isolation, and where results go) and the scenarios import nothing else, so a column difference is an engine difference. - **Each contender runs in an isolate of its own**: a child process on the server runtimes, a `Worker` in a browser. Contenders sharing one isolate @@ -61,7 +61,7 @@ Five properties of the harness are load-bearing: ## Results at 100,000 entries -Speedup against `Map` — above 1.00x the table is faster: +Speedup against `Map`. Above 1.00x the table is faster: ![Speedup against Map by workload, one point per runtime, on a log scale](assets/speedup.svg) @@ -71,78 +71,78 @@ same library on five engines, so their spread is `Map` varying, not the table. | workload | Bun 1.3.14 | Node 24.15.0 | Deno 2.9.4 | Chrome 151 | Firefox 153 | | --- | --- | --- | --- | --- | --- | -| fill, sparse (pre-sized) | 8.3x | 6.4x | 5.6x | 4.2x | 5.2x | -| fill, sparse (grown from empty) | 2.7x | 2.3x | 2.1x | 1.50x | 2.2x | -| fill, dense (pre-sized) | 7.4x | 4.4x | 3.5x | 2.7x | 3.9x | -| lookup hit, sparse | 1.60x | 2.8x | 3.2x | 2.6x | 1.79x | -| lookup miss, sparse | 1.38x | 3.3x | 3.4x | 2.6x | 1.73x | -| lookup hit, dense | 1.12x | 1.76x | 1.98x | 1.39x | 1.36x | -| lookup miss, dense | 0.85x | 1.89x | 1.88x | 1.99x | 1.42x | -| `has`, sparse | 1.84x | 3.4x | 3.6x | 3.1x | 1.98x | -| overwrite an existing key | 2.3x | 2.7x | 2.9x | 2.7x | 3.2x | -| delete | 5.6x | 5.5x | 5.4x | 4.3x | 4.3x | -| churn (delete + reinsert) | 3.2x | 3.6x | 3.5x | 2.9x | 3.2x | -| count (`increment`) | 1.41x | 1.39x | 1.47x | 1.49x | 0.96x | -| `getOrInsert`, key absent | 8.0x | 6.6x | 6.4x | 5.8x | 5.6x | -| u32 bulk fill (`setMany`) | 9.8x | 9.6x | 8.0x | 6.6x | 8.6x | -| u32 bulk lookup (`getMany`) | 2.4x | 5.4x | 4.8x | 5.0x | 3.8x | -| u64 bulk fill (`setMany`) | 6.0x | 8.0x | 6.9x | 6.2x | 9.1x | -| u64 bulk lookup (`getMany`) | 1.56x | 3.8x | 3.7x | 3.2x | 2.3x | -| u64 bulk delete (`deleteMany`) | 5.4x | 12x | 8.9x | — | — | -| iterate (`forEach`) | 2.3x | 0.79x | 0.75x | 0.74x | 1.37x | -| string keys, repeated lookup | 0.35x | 0.49x | 0.49x | 0.46x | 0.76x | +| fill, sparse (pre-sized) | 5.2x | 6.2x | 5.0x | 3.6x | 4.7x | +| fill, sparse (grown from empty) | 1.59x | 2.3x | 1.93x | 1.37x | 2.1x | +| fill, dense (pre-sized) | 4.0x | 4.1x | 3.2x | 2.4x | 3.6x | +| lookup hit, sparse | 1.50x | 2.7x | 2.8x | 2.3x | 1.60x | +| lookup miss, sparse | 1.29x | 3.2x | 3.4x | 2.5x | 1.70x | +| lookup hit, dense | 1.08x | 1.67x | 1.77x | 1.33x | 1.25x | +| lookup miss, dense | 0.89x | 1.79x | 1.89x | 1.94x | 1.38x | +| `has`, sparse | 1.77x | 3.2x | 3.5x | 2.9x | 1.86x | +| overwrite an existing key | 2.2x | 2.5x | 2.5x | 2.2x | 2.8x | +| delete | 6.1x | 5.5x | 5.8x | 4.1x | 4.7x | +| churn (delete + reinsert) | 3.3x | 3.6x | 3.5x | 2.6x | 3.2x | +| count (`increment`) | 1.26x | 1.33x | 1.35x | 1.28x | 0.89x | +| `getOrInsert`, key absent | 5.3x | 6.3x | 5.4x | 5.0x | 5.1x | +| u32 bulk fill (`setMany`) | 8.4x | 11x | 8.3x | 7.6x | 9.2x | +| u32 bulk lookup (`getMany`) | 2.7x | 5.6x | 4.9x | 5.4x | 3.6x | +| u64 bulk fill (`setMany`) | 10x | 9.7x | 8.6x | 6.4x | 8.5x | +| u64 bulk lookup (`getMany`) | 2.1x | 4.1x | 3.8x | 3.6x | 2.3x | +| u64 bulk delete (`deleteMany`) | 7.1x | 12x | 9.1x | — | — | +| iterate (`forEach`) | 2.6x | 0.78x | 0.75x | 0.73x | 1.38x | +| string keys, repeated lookup | 0.34x | 0.49x | 0.44x | 0.42x | 0.72x | The same rows as nanoseconds per operation, SwissTable / `Map`: | workload | Bun 1.3.14 | Node 24.15.0 | Deno 2.9.4 | Chrome 151 | Firefox 153 | | --- | --- | --- | --- | --- | --- | -| fill, sparse (pre-sized) | 8.1 / 67.5 | 9.9 / 63.5 | 9.7 / 54.4 | 10.6 / 45.0 | 10.6 / 54.6 | -| fill, sparse (grown from empty) | 25.3 / 67.5 | 27.1 / 63.5 | 26.5 / 54.4 | 30.0 / 45.0 | 24.4 / 54.6 | -| fill, dense (pre-sized) | 8.6 / 64.2 | 9.7 / 43.1 | 9.7 / 34.0 | 10.2 / 27.9 | 9.6 / 37.4 | -| lookup hit, sparse | 6.7 / 10.7 | 8.9 / 25.3 | 7.7 / 24.6 | 9.3 / 24.1 | 10.6 / 19.0 | -| lookup miss, sparse | 7.9 / 10.9 | 9.3 / 30.8 | 9.1 / 31.1 | 10.1 / 25.7 | 11.4 / 19.7 | -| lookup hit, dense | 6.7 / 7.5 | 8.9 / 15.7 | 7.7 / 15.4 | 9.3 / 13.0 | 9.5 / 12.9 | -| lookup miss, dense | 7.8 / 6.7 | 9.0 / 17.1 | 8.8 / 16.4 | 9.8 / 19.4 | 10.1 / 14.4 | -| `has`, sparse | 5.8 / 10.7 | 7.4 / 24.8 | 6.7 / 24.2 | 7.7 / 23.6 | 9.2 / 18.3 | -| overwrite an existing key | 6.0 / 14.0 | 9.1 / 24.7 | 8.4 / 24.3 | 8.8 / 23.7 | 9.0 / 28.9 | -| delete | 5.6 / 31.1 | 8.7 / 47.5 | 7.4 / 39.9 | 8.7 / 37.7 | 7.8 / 33.4 | -| churn (delete + reinsert) | 9.7 / 30.7 | 12.2 / 44.5 | 11.8 / 41.9 | 12.7 / 36.5 | 12.2 / 39.2 | -| count (`increment`) | 6.1 / 8.5 | 8.3 / 11.6 | 7.9 / 11.6 | 8.5 / 12.7 | 11.0 / 10.6 | -| `getOrInsert`, key absent | 8.3 / 66.5 | 10.3 / 67.6 | 9.8 / 62.6 | 10.7 / 61.7 | 10.6 / 59.8 | -| u32 bulk fill (`setMany`) | 6.6 / 65.1 | 6.7 / 64.7 | 6.6 / 52.6 | 6.7 / 44.3 | 6.2 / 53.4 | -| u32 bulk lookup (`getMany`) | 4.2 / 10.0 | 4.1 / 21.9 | 4.7 / 22.2 | 4.2 / 20.8 | 4.1 / 15.6 | -| u64 bulk fill (`setMany`) | 7.4 / 44.3 | 8.8 / 70.8 | 9.0 / 61.9 | 7.5 / 46.6 | 7.2 / 65.4 | -| u64 bulk lookup (`getMany`) | 6.5 / 10.2 | 6.0 / 22.8 | 6.3 / 23.0 | 6.8 / 22.1 | 6.8 / 15.7 | -| u64 bulk delete (`deleteMany`) | 4.8 / 25.6 | 4.1 / 47.9 | 4.6 / 40.9 | — | — | -| iterate (`forEach`) | 7.2 / 16.4 | 12.6 / 10.0 | 13.1 / 9.9 | 12.2 / 9.0 | 5.8 / 8.0 | -| string keys, repeated lookup | 23.4 / 8.2 | 34.2 / 16.7 | 25.6 / 12.4 | 24.3 / 11.2 | 85.3 / 64.9 | +| fill, sparse (pre-sized) | 8.5 / 43.9 | 11.4 / 70.6 | 11.5 / 57.7 | 13.3 / 47.6 | 12.4 / 58.0 | +| fill, sparse (grown from empty) | 27.5 / 43.9 | 30.5 / 70.6 | 30.0 / 57.7 | 34.7 / 47.6 | 28.2 / 58.0 | +| fill, dense (pre-sized) | 8.8 / 35.7 | 11.4 / 46.2 | 11.1 / 35.1 | 12.6 / 29.8 | 11.4 / 40.6 | +| lookup hit, sparse | 7.4 / 11.1 | 9.9 / 26.4 | 9.4 / 26.8 | 11.1 / 26.0 | 12.2 / 19.5 | +| lookup miss, sparse | 8.7 / 11.2 | 10.2 / 32.3 | 10.1 / 34.0 | 11.4 / 28.3 | 12.2 / 20.8 | +| lookup hit, dense | 7.3 / 7.8 | 9.9 / 16.5 | 9.5 / 16.8 | 10.9 / 14.5 | 10.7 / 13.5 | +| lookup miss, dense | 8.0 / 7.2 | 10.0 / 18.0 | 9.7 / 18.4 | 10.8 / 21.0 | 10.6 / 14.7 | +| `has`, sparse | 6.3 / 11.2 | 8.2 / 26.6 | 7.8 / 27.2 | 9.0 / 25.8 | 10.2 / 18.9 | +| overwrite an existing key | 6.8 / 14.8 | 10.4 / 25.8 | 10.1 / 25.6 | 11.4 / 25.5 | 10.8 / 30.8 | +| delete | 5.7 / 35.0 | 9.4 / 52.4 | 7.9 / 45.5 | 10.2 / 41.3 | 8.4 / 39.8 | +| churn (delete + reinsert) | 10.4 / 34.5 | 13.4 / 47.5 | 13.1 / 46.1 | 15.3 / 40.2 | 13.7 / 44.4 | +| count (`increment`) | 6.7 / 8.4 | 9.4 / 12.5 | 9.2 / 12.4 | 10.9 / 13.8 | 13.0 / 11.6 | +| `getOrInsert`, key absent | 8.8 / 46.5 | 12.0 / 75.9 | 12.0 / 64.2 | 13.7 / 68.9 | 12.6 / 64.2 | +| u32 bulk fill (`setMany`) | 6.1 / 51.2 | 6.4 / 70.3 | 6.9 / 56.7 | 6.3 / 47.7 | 6.6 / 60.8 | +| u32 bulk lookup (`getMany`) | 3.9 / 10.5 | 4.0 / 22.5 | 4.9 / 24.0 | 4.2 / 22.6 | 4.4 / 16.1 | +| u64 bulk fill (`setMany`) | 7.3 / 73.5 | 8.0 / 77.5 | 8.3 / 71.7 | 7.8 / 50.5 | 7.8 / 66.4 | +| u64 bulk lookup (`getMany`) | 5.1 / 10.6 | 5.7 / 23.6 | 6.5 / 24.9 | 7.0 / 24.9 | 7.0 / 16.1 | +| u64 bulk delete (`deleteMany`) | 4.2 / 30.0 | 4.2 / 49.0 | 4.7 / 42.6 | — | — | +| iterate (`forEach`) | 6.6 / 17.0 | 13.2 / 10.3 | 13.8 / 10.4 | 12.9 / 9.4 | 6.0 / 8.3 | +| string keys, repeated lookup | 26.5 / 9.1 | 37.6 / 18.5 | 29.0 / 12.7 | 27.3 / 11.4 | 98.0 / 70.5 | `Map` columns are `Map`, or `Map` for the -u64 rows. A `Map` is worse again — 58 to 100 ns to fill, +u64 rows. A `Map` is worse again, 61 to 106 ns to fill, because every value is boxed. One row does not share the others' working set. **`count (increment)` is 100,000 increments over 1,000 distinct keys**, because a counter that never sees a key twice is not a counter. Those 1,000 entries fit in cache, which is -why `Map` costs 8.5–12.7 ns there against the 24–31 it costs at 100,000 +why `Map` costs 8.4–13.8 ns there against the 26–34 it costs at 100,000 keys, and why it is the only row a browser engine takes. Read the table column by column, not row by row. The SwissTable numbers move -little between engines: a sparse lookup hit costs 6.7–10.6 ns everywhere, +little between engines: a sparse lookup hit costs 7.4–12.2 ns everywhere, because the work is a WASM call and two memory accesses no engine is involved -in. `Map` moves a lot — 10.7 ns on JavaScriptCore against 24–25 ns on V8 — -and that is what makes the same library look 1.60x faster on Bun and 3.2x -faster on Deno. +in. `Map` moves a lot, 11.1 ns on JavaScriptCore against 26–27 ns on V8, and +that is what makes the same library look 1.50x faster on Bun and 2.8x faster +on Deno. ## Mutation, not lookup, is where the margin is Lookup is the workload most often benchmarked and the one these tables win least. The spread opens on everything that writes. -`delete` is 4.3x to 5.6x. `Map` unlinks an entry from its insertion-ordered +`delete` is 4.1x to 6.1x. `Map` unlinks an entry from its insertion-ordered chain and eventually compacts it; a control byte here goes from its -fingerprint to `DELETED` and nothing else moves. Churn — delete a key and put -it straight back — is 2.9x to 3.6x, and holds capacity while doing it, +fingerprint to `DELETED` and nothing else moves. Churn, meaning delete a key +and put it straight back, is 2.6x to 3.6x, and holds capacity while doing it, because a re-insert reclaims the tombstone without consuming growth. `has` is cheaper than `get` on the same table on every runtime: both cross @@ -151,16 +151,15 @@ that `get` reads back out of linear memory. Key distribution costs the table nothing: dense, sparse and scattered-but-small keys all look up at the same speed, because the Murmur3 -finalizer spreads every input bit before the hash is split — see +finalizer spreads every input bit before the hash is split. See [design.md](design.md#hash-splitting). The dense rows are closer because -`Map` is faster there, not because the table is slower — and on -JavaScriptCore a dense miss is the one row `Map` wins outright, at 6.7 ns -against 7.8. +`Map` is faster there, not because the table is slower. On JavaScriptCore a +dense miss is the one row `Map` wins outright, at 7.2 ns against 8.0. Neither is the right answer for dense keys anyway: a directly-indexed -`Int32Array` fills at 0.9–1.3 ns and looks up at 0.6 ns on every runtime, and -a plain object looks up at 0.5–0.7 ns. If the keys really are dense, use the -array. +`Int32Array` fills at 0.9–1.3 ns and looks up at 0.6–0.7 ns on every runtime, +and a plain object looks up at 0.5–0.8 ns. If the keys really are dense, use +the array. ## Reading and writing in one crossing @@ -170,28 +169,28 @@ each. Nanoseconds per key, against the alternative written out beside it: | workload | Bun 1.3.14 | Node 24.15.0 | Deno 2.9.4 | Chrome 151 | Firefox 153 | | --- | --- | --- | --- | --- | --- | -| `increment` | 6.1 | 8.3 | 7.9 | 8.5 | 11.0 | -| `get` + `set` | 10.7 | 13.8 | 12.9 | 14.4 | 13.8 | -| `getOrInsert`, key absent | 8.3 | 10.3 | 9.8 | 10.7 | 10.6 | -| `get`, then `set` if absent | 10.2 | 13.2 | 12.7 | 14.2 | 13.4 | -| `getOrInsert`, key present | 5.6 | 8.3 | 7.8 | 8.8 | 8.7 | -| `get`, then `set` if absent | 5.5 | 8.3 | 7.8 | 9.4 | 8.4 | +| `increment` | 6.7 | 9.4 | 9.2 | 10.9 | 13.0 | +| `get` + `set` | 12.0 | 15.6 | 15.0 | 17.9 | 16.2 | +| `getOrInsert`, key absent | 8.8 | 12.0 | 12.0 | 13.7 | 12.6 | +| `get`, then `set` if absent | 10.8 | 14.8 | 14.9 | 17.7 | 15.4 | +| `getOrInsert`, key present | 6.7 | 9.4 | 9.6 | 11.6 | 10.6 | +| `get`, then `set` if absent | 6.0 | 9.4 | 8.8 | 10.7 | 9.2 | -`increment` wins on every engine — 1.25x on SpiderMonkey, 1.6–1.8x elsewhere -— because the read and the write are one operation whichever way the key -falls. +`increment` wins on every engine, 1.25x on SpiderMonkey and 1.6–1.8x +elsewhere, because the read and the write are one operation whichever way the +key falls. **`getOrInsert` only wins when the key is absent**, and the last two rows are why: `get`-then-`set` puts its second crossing inside the `undefined` branch, so on a hit it never issues one and the two tie. Its gain therefore tracks -the miss rate — 1.23x to 1.33x when nothing is present, nothing when +the miss rate: 1.22x to 1.29x when nothing is present, nothing when everything is. Reach for it where misses are common, which is what memoizing is. -Against `Map` the counting row is the one place a browser engine wins: 0.96x -on Firefox, 1.39x to 1.49x everywhere else. The working set is 1,000 keys, +Against `Map` the counting row is the one place a browser engine wins: 0.89x +on Firefox, 1.26x to 1.35x everywhere else. The working set is 1,000 keys, small enough that `Map` stays in cache and the crossing is most of the -budget — the same effect the crossover section describes, seen from the +budget. It is the same effect the crossover section describes, seen from the other side. ## Memory @@ -216,26 +215,36 @@ ceiling that is | `SwissU32ToU32` | 18 B | 20.6 B | 10.3 B | | `SwissU32ToU64` | 26 B | 29.7 B | 14.9 B | -Two banks are reserved because a rehash needs somewhere to move entries to, -and both are static arrays at the compiled ceiling. Only one holds entries at -a time, so the live-bank column is what the data occupies and the both-banks -column is what the module reserves for it. Against `Map`'s 37.4 B on V8 that -is 3.6x less live, or 1.8x less counting the standby bank; against -JavaScriptCore's 67.1 B, 6.5x and 3.3x. - -**An instance reserves its whole arena up front**: 21 MiB of linear memory -for the u32 module, 29 MiB for the u64 one, whether it holds one entry or -917,504. That is address space, committed page by page as the table touches -it, so what a process actually resides tracks the entries rather than the -reservation — 9 MiB at 500,000 u32 entries on Node. The reservation still -matters twice over: every table is its own module instance, so ten tables -reserve ten arenas, and a wasm32 module cannot exceed 4 GiB of them. Build a -second pair of modules with a lower `SWISS_MAX_CAPACITY_LOG2` when the -working set is known to be small — at `2^16` a u32 instance reserves 4.1 MiB -and caps at 57,344 entries. - -A resident-set reading is the wrong instrument for the comparison and is -reported by `--scenario=memory` only as a cross-check: it counts a WASM arena +A rehash needs somewhere to move entries to, so a second bank exists, but +only one holds entries at a time. The live-bank column is what the data +occupies; the both-banks column is what the module addresses while a rehash +is in flight. Against `Map`'s 37.4 B on V8 that is 3.6x less live, or 1.8x +less counting the standby bank. Against JavaScriptCore's 67.1 B, 6.5x and +3.3x. + +**An instance grows with its table.** It starts at 1.25 MiB of linear memory +for the u32 module and 1.75 MiB for the u64 one, then adds 9 or 13 bytes per +slot as the table reaches each new bank. Resident memory at 500,000 entries +on Node: + +| | pre-sized with `create(500_000)` | grown from empty | +| --- | --- | --- | +| `SwissU32ToU32` | +10.8 MiB | +20.6 MiB | +| `SwissU32ToU64` | +16.5 MiB | +23.6 MiB | + +Pre-sizing is worth roughly half the memory as well as the rehashes: a table +that grows into its capacity keeps the pages of the banks it passed through. +Memory is never returned, so an instance holds the high-water mark of every +bank it used, measured at 1.8x to 1.9x the final bank across a fill. + +Every table is its own module instance, and each declares a `--max-memory` +at its ceiling: 3.4 GiB for u32, 2.4 GiB for u64. That is address space the +host reserves without committing, but a 32-bit or memory-constrained host +may decline it at instantiation. Build with a lower +`SWISS_MAX_CAPACITY_LOG2` to shrink what the module asks for. + +A resident-set reading is the wrong instrument for the comparison, and +`--scenario=memory` reports it only as a cross-check. It counts a WASM bank in full the moment it is touched, while a `Map` disappears into heap pages the engine had already committed. Measured that way Deno reports its `Map` at 15.5 B/entry, which is not a property of the `Map`. @@ -257,8 +266,8 @@ T = c + g + r + a * mu(N) | `mu(N)` | mean latency of one such access, set by working-set size | 1–10 ns | Those terms are measured on JavaScriptCore; `c` is what differs most between -engines, and it is visible in the tables above as the ~1–2 ns the V8 hosts -add to every single-key row. +engines, and it is visible in the tables above as the ~2 ns the V8 hosts add +to every single-key row. `Map` pays no `c`, `g`, or `r`, and has `a = 2` as well — bucket table, then entry record. **So these tables win only when their smaller working set makes @@ -291,13 +300,13 @@ reach the caller's own element load (`bun run bench --scenario=tagging`): | key source | Bun | Node | Deno | | --- | --- | --- | --- | -| `Uint32Array`, keys `< 2^31` | 5.9 ns | 7.3 ns | 6.7 ns | -| `Uint32Array`, keys `>= 2^31` | 5.7 ns | 7.3 ns | 6.7 ns | -| plain `Array` (always a double) | 9.8 ns | 7.9 ns | 7.2 ns | +| `Uint32Array`, keys `< 2^31` | 6.4 ns | 8.0 ns | 7.7 ns | +| `Uint32Array`, keys `>= 2^31` | 6.2 ns | 8.1 ns | 7.6 ns | +| plain `Array` (always a double) | 10.5 ns | 9.2 ns | 8.3 ns | Keys held in a plain `Array` are already boxed doubles before the binding -sees them, and unboxing them costs ~1.7x on JavaScriptCore against ~1.1x on -V8. Keep keys in a typed array; it is free where it does not matter and worth +sees them, and unboxing them costs ~1.6x on JavaScriptCore against ~1.1x on +V8. Keep keys in a typed array. It is free where it does not matter and worth a lot where it does. ## Why fill is measured twice @@ -307,11 +316,12 @@ empty and pay for every internal rehash inside the timed region, so timing only a pre-sized table would compare against containers handed their final capacity for free. -Growing from empty costs about 3x — 25.3 ns against 8.1 on Bun, 27.1 against -9.9 on Node — the price of rehashing through 11 doublings (64 to 131,072 -slots), each re-probing every live entry. It still beats `Map` by 1.50x to -2.7x, so the ranking never depended on the advantage. Pre-size anyway when -the count is known; it is the cheapest optimization available here. +Growing from empty costs about 3x: 27.5 ns against 8.5 on Bun, 30.5 against +11.4 on Node. That is the price of rehashing through 11 doublings, 64 to +131,072 slots, each re-probing every live entry. It still beats `Map` by +1.37x to 2.3x, so the ranking never depended on the advantage. Pre-size +anyway when the count is known. It is the cheapest optimization available +here, and it also halves the memory the table ends up holding. Lookups need only one row: a grown table converges on exactly the capacity a pre-sized one starts with — both reach 131,072 slots at 100,000 entries — so @@ -325,14 +335,14 @@ batch handed to one call changes: | batch | `getMany` (Bun / Node / Deno) | `setMany` (Bun / Node / Deno) | | --- | --- | --- | -| 1 | 177 / 142 / 135 ns | 165 / 144 / 134 ns | -| 8 | 24.5 / 20.8 / 20.8 ns | 41.7 / 24.9 / 25.0 ns | -| 64 | 8.7 / 6.3 / 7.2 ns | 11.9 / 9.5 / 11.0 ns | -| 512 | 5.2 / 4.5 / 5.4 ns | 8.2 / 8.6 / 8.6 ns | -| 4,096 | 4.8 / 4.3 / 5.3 ns | 7.5 / 8.2 / 8.6 ns | -| 100,000 | 5.4 / 4.8 / 5.7 ns | 7.4 / 8.6 / 8.0 ns | - -A batch of 1 is worse than the per-key API — it pays the staging setup with +| 1 | 195 / 149 / 145 ns | 195 / 157 / 150 ns | +| 8 | 27.1 / 22.1 / 21.8 ns | 32.6 / 24.8 / 25.4 ns | +| 64 | 6.9 / 6.6 / 7.3 ns | 11.8 / 10.0 / 10.8 ns | +| 512 | 5.0 / 4.5 / 5.6 ns | 7.9 / 8.4 / 9.0 ns | +| 4,096 | 4.6 / 4.3 / 5.3 ns | 7.4 / 7.9 / 8.2 ns | +| 100,000 | 4.6 / 4.9 / 5.9 ns | 7.2 / 7.8 / 8.3 ns | + +A batch of 1 is worse than the per-key API, paying the staging setup with nothing to divide it into. From ~512 the crossing has stopped mattering, and past ~4,096 the curve is flat: there is no reason to hand-tune batch sizes above that, and `maxBatch` chunking makes larger batches free anyway. @@ -343,53 +353,55 @@ Holding entries fixed at 50,000 and varying capacity: | load | slots | Bun | Node | Deno | | --- | --- | --- | --- | --- | -| 76% | 65,536 | 6.6 ns | 7.8 ns | 7.4 ns | -| 38% | 131,072 | 6.4 ns | 7.4 ns | 7.0 ns | -| 19% | 262,144 | 7.5 ns | 9.0 ns | 8.1 ns | -| 10% | 524,288 | 9.4 ns | 10.5 ns | 9.7 ns | +| 76% | 65,536 | 7.4 ns | 9.0 ns | 8.9 ns | +| 38% | 131,072 | 7.2 ns | 8.8 ns | 8.6 ns | +| 19% | 262,144 | 8.6 ns | 10.2 ns | 10.0 ns | +| 10% | 524,288 | 10.1 ns | 12.2 ns | 11.7 ns | -Running at the 7/8 ceiling costs nothing against running half empty — the +Running at the 7/8 load factor costs nothing against running half empty. The group scan resolves in its first iteration either way. What does cost is the -control array outgrowing cache: at 10% full the table is 30% to 42% slower -than at 76%. Over-reserving is not free, so size `expectedEntries` to what you expect -rather than padding it. +control array outgrowing cache: at 10% full the table is 32% to 36% slower +than at 76%. Over-reserving costs memory as well as speed, so size +`expectedEntries` to what you expect rather than padding it. ## Whole-table operations | operation | Bun | Node | Deno | | --- | --- | --- | --- | -| `clear()` | 4.2 µs | 3.5 µs | 3.0 µs | -| `shrinkToFit()` after emptying | 155 µs | 59 µs | 67 µs | -| `reserve()` forcing one rehash | 1.56 ms | 1.48 ms | 1.45 ms | +| `clear()` | 5.6 µs | 4.1 µs | 3.4 µs | +| `shrinkToFit()` after emptying | 144 µs | 59 µs | 60 µs | +| `reserve()` forcing one rehash | 1.75 ms | 1.49 ms | 1.48 ms | `clear()` is a memset of the control bytes. The other two rehash, and a -rehash is the expensive thing in this design — which is the whole argument -for passing `expectedEntries` up front. +rehash is the expensive thing in this design. That is the whole argument for +passing `expectedEntries` up front. Capacity only ever rises on its own, and a scan visits every slot, so a table that peaked large and was then emptied keeps paying peak walk cost until -`shrinkToFit()` hands the slots back: walking the 8 entries left from a peak -of 100,000 costs 46–76 µs before the call and 1.4–1.5 µs after it. +`shrinkToFit()` hands the slots back. Walking the 8 entries left from a peak +of 100,000 costs 72–131 µs before the call and 1.3–1.9 µs after it. It +recovers walk cost, not memory: the pages stay with the instance. ## Iteration is the one place the engine changes the answer | walk | Bun | Node | Deno | Chrome | Firefox | | --- | --- | --- | --- | --- | --- | -| `keys()` | 8.1 ns | 7.9 ns | 7.5 ns | 6.0 ns | 6.1 ns | -| `forEach` | 7.2 ns | 12.6 ns | 13.1 ns | 12.2 ns | 5.8 ns | -| `Map.forEach` | 16.4 ns | 10.0 ns | 9.9 ns | 9.0 ns | 8.0 ns | -| u64 `forEachLanes`, lanes kept | 4.9 ns | 11.6 ns | 11.4 ns | 11.7 ns | 5.9 ns | -| u64 `forEach`, lanes kept | 6.3 ns | 14.3 ns | 15.5 ns | 14.6 ns | 12.9 ns | - -`forEach` beats `Map.forEach` by 2.3x on JavaScriptCore and loses to it by -25% to 30% on V8, where the per-entry callback through the WASM scan costs -more than V8's own iteration. `keys()` is within 8.1 ns everywhere and is the +| `keys()` | 8.4 ns | 8.0 ns | 7.9 ns | 6.5 ns | 6.2 ns | +| `forEach` | 6.6 ns | 13.2 ns | 13.8 ns | 12.9 ns | 6.0 ns | +| `Map.forEach` | 17.0 ns | 10.3 ns | 10.4 ns | 9.4 ns | 8.3 ns | +| u64 `forEachLanes`, lanes kept | 3.6 ns | 12.4 ns | 12.9 ns | 13.1 ns | 6.1 ns | +| u64 `forEach`, lanes kept | 7.2 ns | 14.8 ns | 17.1 ns | 15.7 ns | 12.6 ns | + +`forEach` beats `Map.forEach` by 2.6x on JavaScriptCore and loses to it by +28% to 37% on V8, where the per-entry callback through the WASM scan costs +more than V8's own iteration. `keys()` is within 8.4 ns everywhere and is the walk to reach for when the values are not needed. `forEachLanes` exists for the u64 table because the boxed `{lo, hi}` object is only free while the JIT can prove it does not outlive the call. A caller that keeps the lanes does not let it prove that, and the lane-wise callback -is then faster on every engine — by 29% on Bun and by 2.2x on Firefox. +is then faster on every engine: 19% to 33% on V8, and about 2x on +JavaScriptCore and SpiderMonkey. ## The two limits that cannot be engineered away @@ -402,12 +414,12 @@ because it depends on how fast the engine's own `Map` is: | entries | Bun 1.3.14 | Node 24.15.0 | Deno 2.9.4 | Chrome 151 | Firefox 153 | | --- | --- | --- | --- | --- | --- | -| 2,000 | 0.68x | 0.75x | 1.12x | 0.79x | 0.33x | -| 8,000 | 0.94x | 1.81x | 2.92x | 2.40x | 1.71x | -| 16,000 | 1.41x | 3.07x | 3.30x | 2.56x | 1.84x | -| 32,000 | 1.27x | 3.19x | 3.42x | 2.83x | 1.94x | -| 128,000 | 1.53x | 3.04x | 3.49x | 2.93x | 1.96x | -| 512,000 | 1.39x | 2.68x | 3.14x | 2.58x | 1.78x | +| 2,000 | 0.65x | 0.77x | 1.03x | 0.80x | 0.35x | +| 8,000 | 0.94x | 1.81x | 2.71x | 2.26x | 1.69x | +| 16,000 | 1.37x | 2.76x | 2.98x | 2.61x | 1.80x | +| 32,000 | 1.26x | 2.82x | 3.08x | 2.70x | 1.79x | +| 128,000 | 1.46x | 2.91x | 3.13x | 2.74x | 1.88x | +| 512,000 | 1.65x | 2.81x | 3.27x | 2.66x | 1.63x | ![Speedup against Map as the table grows, one line per runtime, crossing 1x between 2,000 and 16,000 entries](assets/crossover.svg) @@ -428,7 +440,7 @@ JavaScript engines cache a string's hash on the string object, so a copy and hash the bytes — `O(len)` against `O(1)`, per lookup — and no implementation choice removes that asymmetry. -`InternedSwissMap` is therefore 1.3x to 2.9x slower than `Map` +`InternedSwissMap` is therefore 1.4x to 2.9x slower than `Map` on repeated string lookups, on every engine, and always will be. Its purpose is different: intern once, then use the u32 ID for every subsequent operation, at which point the hot path is the numeric table and the string @@ -450,8 +462,8 @@ The two charts are Vega-Lite specs with their data inlined, in with: ```bash -bunx --package vega-lite vl2vg docs/assets/speedup.vl.json /tmp/speedup.vg.json -bunx --package vega-cli vg2svg /tmp/speedup.vg.json docs/assets/speedup.svg +bunx -p vega -p vega-lite vl2svg docs/assets/speedup.vl.json docs/assets/speedup.svg +bunx -p vega -p vega-lite vl2svg docs/assets/crossover.vl.json docs/assets/crossover.svg ``` `bun run bench` also runs under the other hosts directly, which is useful diff --git a/examples/01-basic.ts b/examples/01-basic.ts index 2ceec92..e2a4f5e 100644 --- a/examples/01-basic.ts +++ b/examples/01-basic.ts @@ -3,7 +3,7 @@ * * Covers creating a table, the read/write methods, and the two behaviours * that differ from `Map`: keys and values are strictly unsigned 32-bit, and - * capacity is fixed at build time rather than grown on demand. + * capacity is bounded at build time rather than unbounded. * * Run with `bun run examples/01-basic.ts`. */ diff --git a/examples/04-multiple-tables.ts b/examples/04-multiple-tables.ts index ba771bf..19006e5 100644 --- a/examples/04-multiple-tables.ts +++ b/examples/04-multiple-tables.ts @@ -29,12 +29,12 @@ console.log("reverse has 2:", reverse.has(2)); // false — separate tables // ── Capacity ─────────────────────────────────────────────────────────── -// Capacity is bounded at build time by MAX_CAPACITY in the native source, -// so a request past it fails loudly instead of degrading. Raising it means -// rebuilding, since the linear memory reserved by scripts/build-wasm.ts has -// to cover the larger banks. +// Capacity is bounded at build time by MAX_CAPACITY in the native source: +// 117,440,512 entries here, and a request past it fails loudly instead of +// degrading. The table grows into its memory on demand up to that point, so +// the bound is on the table, not on what an instance reserves. try { - await SwissU32ToU32.create(2_000_000); + await SwissU32ToU32.create(200_000_000); } catch (error) { console.log("oversized create:", (error as RangeError).message); } diff --git a/native/swiss_core.h b/native/swiss_core.h index bcde0c0..0147164 100644 --- a/native/swiss_core.h +++ b/native/swiss_core.h @@ -20,9 +20,11 @@ * scanned 16 at a time with SIMD, and a parallel array of entries. See * https://abseil.io/about/design/swisstables and google/cwisstable. * - * The module holds exactly one table in static storage. There is no - * allocator (it links -nostdlib), so capacity is bounded at build time by - * MAX_CAPACITY and instances are created by instantiating the module again. + * The module holds exactly one table, and instances are created by + * instantiating the module again. There is no allocator (it links + * -nostdlib): the banks are laid out at computed offsets above the static + * data and reached by growing linear memory, so an instance costs what its + * table costs rather than what MAX_CAPACITY would. */ #ifndef SWISS_ENTRY_FIELDS @@ -70,16 +72,15 @@ #define CTRL_DELETED 0x80u /* - * Slots per bank. Two banks of this size are reserved statically. + * Slots per bank, as a power-of-two exponent, which is what keeps the mask + * arithmetic valid. * - * This is the module's whole memory budget: the banks are reserved in .bss - * at instantiation whether the table holds one entry or a million. One - * instance is one table, so a workload with many small tables pays it per - * table — build a second module with a lower exponent for that case. - * - * Overridable at build time as a power-of-two exponent, which is what keeps - * the mask arithmetic valid; scripts/build-wasm.ts sizes linear memory from - * the same number. See SWISS_MAX_CAPACITY_LOG2 there. + * This bounds the table, not the memory an instance reserves: the banks are + * laid out on the heap and linear memory is grown to reach them, so raising + * it costs address space rather than pages. The module that includes this + * header sets it — see swiss_u32.c and swiss_u64.c, whose ceilings differ + * because their entries do. scripts/build-wasm.ts can override it with + * SWISS_MAX_CAPACITY_LOG2. */ #ifndef MAX_CAPACITY_LOG2 #define MAX_CAPACITY_LOG2 20 @@ -91,17 +92,15 @@ * scripts/build-wasm.ts applies the same bounds, but the sources compile * standalone with -DMAX_CAPACITY_LOG2 and would otherwise miscompute in * silence. Below 4 a bank holds less than one SIMD group, so a group load - * would run past it. Above 25 h1() runs out of bits: it discards the 7 the - * fingerprint consumes, so it yields 25, and a wider mask would leave the - * upper half of the slot space unreachable as a probe start — still correct, - * since probing enumerates every group regardless, but with probe lengths - * roughly doubled and the load factor no longer describing the table. - * Further out the two banks and the staging buffers stop addressing inside - * wasm32's 4 GiB at 27, MAX_LIVE()'s 32-bit product wraps at 30, and at 32 + * would run past it. Above 29 MAX_LIVE()'s 32-bit product wraps, and at 32 * the shift below is undefined. + * + * What actually binds first is the address space the three-bank high-water + * mark needs, which depends on sizeof(Entry) and so cannot be tested here. + * The _Static_assert below the Entry definition does that. */ -#if MAX_CAPACITY_LOG2 < 4 || MAX_CAPACITY_LOG2 > 25 -#error "MAX_CAPACITY_LOG2 must be in [4, 25]" +#if MAX_CAPACITY_LOG2 < 4 || MAX_CAPACITY_LOG2 > 29 +#error "MAX_CAPACITY_LOG2 must be in [4, 29]" #endif #define MAX_CAPACITY (1u << MAX_CAPACITY_LOG2) @@ -144,17 +143,57 @@ typedef struct { SWISS_ENTRY_FIELDS } Entry; +/* + * Three banks' worth of address space is the high-water mark of the + * placement below, and it has to address inside wasm32 with room left for + * the static data underneath it. + * + * This is what sets each module's ceiling, and why the two modules differ: + * a u64 entry is half again as wide as a u32 one, so it runs out an + * exponent earlier. + */ +_Static_assert( + 3ull * (uint64_t)MAX_CAPACITY * (1u + sizeof(Entry)) <= 0xf0000000ull, + "MAX_CAPACITY_LOG2 leaves three banks unable to address inside wasm32" +); + +/* First address above the module's static data, defined by wasm-ld. */ +extern uint8_t __heap_base; + +/* WebAssembly reserves and grows linear memory in units of this. */ +#define WASM_PAGE 65536u + /* * Two banks, so a rehash can copy from the old table into the new one * without allocating. Only one is live at a time; the other is scratch. - * This trades double the memory for needing no allocator at all. + * This trades address space for needing no allocator at all. + * + * Neither is a static array. Sizing them at MAX_CAPACITY would make wasm-ld + * reserve the whole ceiling at instantiation, which is what tied the largest + * table the module can hold to the memory every instance pays for. They are + * laid out on the heap instead, and linear memory is grown to reach them. + * + * Placement alternates with the bank index: bank 0 sits at the base of the + * heap, bank 1 two of its own lengths above it. Consecutive banks are then + * always disjoint — which is all a rehash needs, since only the old and new + * banks are ever live together — while banks of the same parity overlap and + * reuse pages already committed. Repeated compaction at one capacity + * therefore ping-pongs between two fixed regions rather than walking up a + * heap that can never shrink. + * + * The cost is the 3x in the assertion above: a bank 1 of S bytes puts the + * high-water mark at 3S. Growth that ends on bank 0 peaks at 1.5x its final + * bank instead, because the bank it grew out of is half the size and sits + * directly above it. */ -static uint8_t g_ctrl[2][MAX_CAPACITY]; -static Entry g_entries[2][MAX_CAPACITY]; /* Index of the live bank, 0 or 1. */ static uint32_t g_active_bank = 0; +/* The live bank's arrays. Rebuilt by bind_bank() on every bank switch. */ +static uint8_t *g_ctrl = 0; +static Entry *g_entries = 0; + /* Slots in the live bank; always a power of two, or 0 before init(). */ static uint32_t g_capacity = 0; @@ -221,6 +260,80 @@ void *memset(void *destination, int byte, size_t count) { return destination; } +/* Bytes one bank of `capacity` slots occupies: control array, then entries. */ +static inline uint64_t bank_bytes(uint32_t capacity) { + return (uint64_t)capacity * (1u + sizeof(Entry)); +} + +/* + * Grows linear memory until `end` is addressable, reporting refusal rather + * than trapping. + * + * memory.grow answers -1 when the engine declines, which is the only way a + * module linked with a maximum far above its initial size learns the host + * will go no further. Callers must not have mutated any table state before + * this returns, so that a refusal leaves the table exactly as it was. + */ +static int32_t ensure_memory(uint64_t end) { + const uint64_t needed = (end + (WASM_PAGE - 1u)) / WASM_PAGE; + const uint64_t have = (uint64_t)__builtin_wasm_memory_size(0); + + if (needed <= have) return STATUS_OK; + + /* wasm32 addresses 65536 pages; asking for more is refused, not attempted. */ + if (needed > 65536u) return STATUS_CAPACITY_EXCEEDED; + + const size_t previous = + __builtin_wasm_memory_grow(0, (size_t)(needed - have)); + + if (previous == (size_t)-1) return STATUS_CAPACITY_EXCEEDED; + + return STATUS_OK; +} + +/* + * Points `control` and `entries` at bank `bank` sized for `capacity`, + * growing linear memory to cover it. See the placement note above. + * + * Growing extends linear memory without moving what is already in it, so + * pointers held across this call stay valid — only views built on the + * JavaScript side are detached. + */ +static int32_t bind_bank( + uint32_t bank, + uint32_t capacity, + uint32_t live_capacity, + uint8_t **control, + Entry **entries +) { + const uint64_t bytes = bank_bytes(capacity); + + uint64_t base = + (uint64_t)(uintptr_t)&__heap_base + (bank != 0u ? 2u * bytes : 0u); + + if (live_capacity != 0u) { + const uint64_t live = (uint64_t)(uintptr_t)g_ctrl; + const uint64_t live_end = live + bank_bytes(live_capacity); + + /* + * The parity anchor separates consecutive banks whenever the capacity + * doubles, which is every growth the insert path takes. reserve() can + * cross several doublings at once, and a bank that much larger no + * longer clears the live one — so it goes directly above it instead, + * giving up the page reuse to stay disjoint. + */ + if (base < live_end && live < base + bytes) base = live_end; + } + + const int32_t status = ensure_memory(base + bytes); + if (status != STATUS_OK) return status; + + *control = (uint8_t *)(uintptr_t)base; + *entries = (Entry *)(uintptr_t)(base + capacity); + + return STATUS_OK; +} + /* True for a slot holding an entry: fingerprints have their high bit clear. */ static inline uint32_t is_full(uint8_t control) { return control < 0x80u; } @@ -230,24 +343,33 @@ static inline uint32_t ctz32(uint32_t value) { } /* - * Murmur3 finalizer, keyed by g_seed. + * Murmur3's 64-bit finalizer, keyed by g_seed. * * Keys are frequently dense or strided (indices, IDs, pointers >> 3), which * a bare identity hash would map onto a handful of groups. The finalizer * spreads every input bit across the whole word, which both h1 and h2 need. * - * The seed is mixed in ahead of the finalizer rather than xored onto its - * result: the finalizer is what spreads a one-bit difference across the - * word, so a seed applied afterwards would leave keys differing in their - * low bits landing in the same group whatever the seed was. - */ -static inline uint32_t mix_u32(uint32_t value) { - value ^= g_seed; - value ^= value >> 16; - value *= 0x85ebca6bu; - value ^= value >> 13; - value *= 0xc2b2ae35u; - value ^= value >> 16; + * 64 bits rather than 32 because h1 discards the 7 the fingerprint consumes: + * a 32-bit hash leaves 25, which caps the addressable slot space at 2^25 and + * bound the table long before wasm32's address space did. This leaves 57. + * + * The seed is spread across the whole word before the key is folded in, and + * mixed ahead of the finalizer rather than xored onto its result: the + * finalizer is what spreads a one-bit difference across the word, so a seed + * applied afterwards would leave keys differing in their low bits landing in + * the same group whatever the seed was. + * + * wasm32 has no native 64-bit multiply, so each of the two below lowers to + * several 32-bit ones. It costs nothing measurable: a pre-sized fill of + * 500,000 keys is within noise of the 32-bit finalizer this replaced. + */ +static inline uint64_t swiss_hash(uint32_t key) { + uint64_t value = (uint64_t)key ^ ((uint64_t)g_seed * 0x9e3779b97f4a7c15ull); + value ^= value >> 33; + value *= 0xff51afd7ed558ccdull; + value ^= value >> 33; + value *= 0xc4ceb9fe1a85ec53ull; + value ^= value >> 33; return value; } @@ -259,8 +381,8 @@ static inline uint32_t mix_u32(uint32_t value) { * every slot in a group shares part of its fingerprint and the SIMD match * degenerates into a near-constant candidate set. */ -static inline uint32_t h1(uint32_t hash) { return hash >> 7; } -static inline uint32_t h2(uint32_t hash) { return hash & 0x7fu; } +static inline uint64_t h1(uint64_t hash) { return hash >> 7; } +static inline uint32_t h2(uint64_t hash) { return (uint32_t)(hash & 0x7fu); } /* * Bitmask of the lanes in the group at `position` equal to `needle`. @@ -322,8 +444,8 @@ static uint32_t capacity_for_entries(uint32_t entries) { * that would otherwise cost this path a megabyte of scalar stores per * clear, reserve, and rehash. */ -static void initialize_bank(uint32_t bank, uint32_t capacity) { - __builtin_memset(g_ctrl[bank], CTRL_EMPTY, capacity); +static void initialize_bank(uint8_t *control, uint32_t capacity) { + __builtin_memset(control, CTRL_EMPTY, capacity); } /* @@ -352,13 +474,12 @@ static void initialize_bank(uint32_t bank, uint32_t capacity) { * deletion writes DELETED rather than EMPTY. */ static uint32_t find_slot( - uint32_t bank, uint32_t mask, uint32_t key, uint32_t hash + const uint8_t *control, const Entry *entries, + uint32_t mask, uint32_t key, uint64_t hash ) { const uint8_t fingerprint = (uint8_t)h2(hash); - const uint8_t *control = g_ctrl[bank]; - const Entry *entries = g_entries[bank]; - uint32_t position = (h1(hash) & mask) & ~(GROUP_WIDTH - 1u); + uint32_t position = (uint32_t)(h1(hash) & mask) & ~(GROUP_WIDTH - 1u); uint32_t step = GROUP_WIDTH; for (;;) { @@ -390,7 +511,7 @@ static uint32_t find_slot( */ static uint32_t lookup_slot(uint32_t key) { if (g_capacity == 0) return UINT32_MAX; - return find_slot(g_active_bank, g_mask, key, mix_u32(key)); + return find_slot(g_ctrl, g_entries, g_mask, key, swiss_hash(key)); } /* @@ -407,9 +528,10 @@ static uint32_t lookup_slot(uint32_t key) { * slot left to take, and taking it is sound: the caller has already * established that the key is absent. */ -static uint32_t find_insert_slot(uint32_t bank, uint32_t mask, uint32_t hash) { - const uint8_t *control = g_ctrl[bank]; - uint32_t position = (h1(hash) & mask) & ~(GROUP_WIDTH - 1u); +static uint32_t find_insert_slot( + const uint8_t *control, uint32_t mask, uint64_t hash +) { + uint32_t position = (uint32_t)(h1(hash) & mask) & ~(GROUP_WIDTH - 1u); uint32_t step = GROUP_WIDTH; uint32_t first_deleted = UINT32_MAX; @@ -442,10 +564,10 @@ static uint32_t find_insert_slot(uint32_t bank, uint32_t mask, uint32_t hash) { * shape. */ static void insert_known_absent( - uint32_t bank, uint32_t mask, const Entry *entry + uint8_t *control, Entry *entries, uint32_t mask, const Entry *entry ) { - const uint32_t hash = mix_u32(entry->key); - const uint32_t slot = find_insert_slot(bank, mask, hash); + const uint64_t hash = swiss_hash(entry->key); + const uint32_t slot = find_insert_slot(control, mask, hash); /* * find_insert_slot() reports UINT32_MAX for a bank with neither an empty @@ -457,8 +579,8 @@ static void insert_known_absent( */ if (slot == UINT32_MAX) __builtin_trap(); - g_ctrl[bank][slot] = (uint8_t)h2(hash); - g_entries[bank][slot] = *entry; + control[slot] = (uint8_t)h2(hash); + entries[slot] = *entry; } /* @@ -471,22 +593,35 @@ static void insert_known_absent( static int32_t rehash(uint32_t next_capacity) { if (next_capacity > MAX_CAPACITY) return STATUS_CAPACITY_EXCEEDED; - const uint32_t old_bank = g_active_bank; - const uint32_t new_bank = old_bank ^ 1u; + const uint32_t new_bank = g_active_bank ^ 1u; const uint32_t old_capacity = g_capacity; + const uint8_t *old_ctrl = g_ctrl; + const Entry *old_entries = g_entries; + + /* + * Before anything is mutated, so a host that refuses the growth leaves + * the table exactly as it was rather than half rebuilt. + */ + uint8_t *new_ctrl; + Entry *new_entries; + const int32_t status = + bind_bank(new_bank, next_capacity, g_capacity, &new_ctrl, &new_entries); + if (status != STATUS_OK) return status; - initialize_bank(new_bank, next_capacity); + initialize_bank(new_ctrl, next_capacity); const uint32_t new_mask = next_capacity - 1u; uint32_t new_size = 0; for (uint32_t i = 0; i < old_capacity; i++) { - if (!is_full(g_ctrl[old_bank][i])) continue; + if (!is_full(old_ctrl[i])) continue; - insert_known_absent(new_bank, new_mask, &g_entries[old_bank][i]); + insert_known_absent(new_ctrl, new_entries, new_mask, &old_entries[i]); new_size++; } g_active_bank = new_bank; + g_ctrl = new_ctrl; + g_entries = new_entries; g_capacity = next_capacity; g_mask = new_mask; g_size = new_size; @@ -507,7 +642,15 @@ static int32_t rehash(uint32_t next_capacity) { static int32_t ensure_insert_space(void) { if (g_capacity == 0) { const uint32_t initial_capacity = GROUP_WIDTH; - initialize_bank(g_active_bank, initial_capacity); + uint8_t *control; + Entry *entries; + const int32_t status = + bind_bank(g_active_bank, initial_capacity, 0, &control, &entries); + if (status != STATUS_OK) return status; + + initialize_bank(control, initial_capacity); + g_ctrl = control; + g_entries = entries; g_capacity = initial_capacity; g_mask = initial_capacity - 1u; g_size = 0; @@ -575,10 +718,10 @@ __attribute__((always_inline)) static inline int32_t upsert_slot_tracked( uint32_t key, uint32_t *slot_out, uint32_t *inserted_out ) { - const uint32_t hash = mix_u32(key); + const uint64_t hash = swiss_hash(key); if (g_capacity != 0) { - const uint32_t existing = find_slot(g_active_bank, g_mask, key, hash); + const uint32_t existing = find_slot(g_ctrl, g_entries, g_mask, key, hash); if (existing != UINT32_MAX) { *slot_out = existing; @@ -591,7 +734,7 @@ static inline int32_t upsert_slot_tracked( const int32_t space_status = ensure_insert_space(); if (space_status != STATUS_OK) return space_status; - const uint32_t slot = find_insert_slot(g_active_bank, g_mask, hash); + const uint32_t slot = find_insert_slot(g_ctrl, g_mask, hash); /* * find_insert_slot() reports UINT32_MAX only if it walked the whole probe @@ -602,10 +745,10 @@ static inline int32_t upsert_slot_tracked( if (slot == UINT32_MAX) return STATUS_CAPACITY_EXCEEDED; /* Reusing a tombstone consumes no growth: the slot was already spent. */ - if (g_ctrl[g_active_bank][slot] == CTRL_EMPTY) g_growth_left--; + if (g_ctrl[slot] == CTRL_EMPTY) g_growth_left--; - g_ctrl[g_active_bank][slot] = (uint8_t)h2(hash); - g_entries[g_active_bank][slot].key = key; + g_ctrl[slot] = (uint8_t)h2(hash); + g_entries[slot].key = key; g_size++; *slot_out = slot; @@ -653,13 +796,22 @@ int32_t init(uint32_t expected_entries) { return STATUS_CAPACITY_EXCEEDED; } + uint8_t *control; + Entry *entries; + /* init() discards whatever the table held, so nothing has to be kept + * clear of and the bank goes straight to its anchor. */ + const int32_t status = bind_bank(0, next_capacity, 0, &control, &entries); + if (status != STATUS_OK) return status; + g_active_bank = 0; + g_ctrl = control; + g_entries = entries; g_capacity = next_capacity; g_mask = next_capacity - 1u; g_size = 0; g_growth_left = MAX_LIVE(next_capacity); g_generation++; - initialize_bank(g_active_bank, g_capacity); + initialize_bank(g_ctrl, g_capacity); return STATUS_OK; } @@ -727,7 +879,7 @@ int32_t shrink_to_fit(void) { __attribute__((export_name("clear"))) void clear(void) { if (g_capacity == 0) return; - initialize_bank(g_active_bank, g_capacity); + initialize_bank(g_ctrl, g_capacity); g_size = 0; g_growth_left = MAX_LIVE(g_capacity); g_generation++; @@ -748,7 +900,7 @@ int32_t has_get(uint32_t key) { if (slot == UINT32_MAX) return 0; - latch_value(&g_entries[g_active_bank][slot]); + latch_value(&g_entries[slot]); return 1; } @@ -772,7 +924,7 @@ int32_t delete_key(uint32_t key) { if (slot == UINT32_MAX) return 0; - g_ctrl[g_active_bank][slot] = CTRL_DELETED; + g_ctrl[slot] = CTRL_DELETED; g_size--; return 1; @@ -790,6 +942,11 @@ uint32_t capacity(void) { return g_capacity; } * Addresses of the two counters above, so the binding can read them as * memory rather than as calls. * + * A grow replaces the buffer these are viewed through on the JavaScript + * side, so the binding rebuilds its views after any call that can rehash. + * The addresses themselves never move: both counters are static data, + * below the heap the banks are laid out on. + * * `size` and `capacity` are properties on the JavaScript side, and a * property that costs a boundary crossing sets the wrong expectation: a * caller writing `for (i = 0; i < table.size; i++)` pays per iteration for @@ -798,10 +955,8 @@ uint32_t capacity(void) { return g_capacity; } * 0.95 ns for the export call. * * This is the same trick last_value_ptr() uses, and it is sound for the - * same two reasons: the counters are ordinary statics in linear memory, so - * exposing an address costs the hot path nothing, and the module links with - * initial memory equal to maximum memory and never calls memory.grow, so a - * view built once is never detached. + * same reason: the counters are ordinary statics in linear memory, so + * exposing an address costs the hot path nothing. * * The exports above stay: they are the module's ABI, they are what the * binding validates on load, and they are the definition these addresses @@ -852,8 +1007,8 @@ int32_t scan(uint32_t cursor) { if ((cursor & (GROUP_WIDTH - 1u)) != 0) return STATUS_INVALID_ARGUMENT; if (g_capacity == 0 || cursor >= g_capacity) return 0; - const uint8_t *control = g_ctrl[g_active_bank]; - const Entry *entries = g_entries[g_active_bank]; + const uint8_t *control = g_ctrl; + const Entry *entries = g_entries; uint32_t end = cursor + SCAN_WINDOW; if (end > g_capacity) end = g_capacity; @@ -975,7 +1130,7 @@ uint32_t delete_many( continue; } - g_ctrl[g_active_bank][slot] = CTRL_DELETED; + g_ctrl[slot] = CTRL_DELETED; g_size--; deleted[i] = 1; removed++; diff --git a/native/swiss_u32.c b/native/swiss_u32.c index 73d3cd7..a02cb31 100644 --- a/native/swiss_u32.c +++ b/native/swiss_u32.c @@ -40,6 +40,22 @@ */ #define SCAN_WINDOW BULK_CAPACITY +/* + * Slots this module's table can reach, as a power-of-two exponent. + * + * Set by the widest bank three of these entries can address inside wasm32 — + * see the _Static_assert in swiss_core.h — which is what the ceiling is now + * bounded by. At 7/8 that is 117,440,512 live entries. + * + * It costs address space, not memory: an instance reserves its static data + * and grows into a bank only as the table reaches it. + * + * Guarded so scripts/build-wasm.ts can override it with -D. + */ +#ifndef MAX_CAPACITY_LOG2 +#define MAX_CAPACITY_LOG2 27 +#endif + #include "swiss_core.h" /* @@ -95,7 +111,7 @@ int32_t set(uint32_t key, uint32_t value) { const int32_t status = upsert_slot(key, &slot); if (status != STATUS_OK) return status; - g_entries[g_active_bank][slot].value = value; + g_entries[slot].value = value; return STATUS_OK; } @@ -114,7 +130,7 @@ int32_t get_or_insert(uint32_t key, uint32_t value) { const int32_t status = upsert_slot_tracked(key, &slot, &inserted); if (status != STATUS_OK) return status; - Entry *entry = &g_entries[g_active_bank][slot]; + Entry *entry = &g_entries[slot]; if (inserted) entry->value = value; @@ -138,7 +154,7 @@ int32_t increment(uint32_t key, uint32_t delta) { const int32_t status = upsert_slot_tracked(key, &slot, &inserted); if (status != STATUS_OK) return status; - Entry *entry = &g_entries[g_active_bank][slot]; + Entry *entry = &g_entries[slot]; entry->value = inserted ? delta : entry->value + delta; @@ -191,7 +207,7 @@ int32_t set_many(uint32_t keys_ptr, uint32_t vals_ptr, uint32_t count) { const int32_t status = upsert_slot(keys[i], &slot); if (status != STATUS_OK) return status; - g_entries[g_active_bank][slot].value = values[i]; + g_entries[slot].value = values[i]; } return STATUS_OK; @@ -231,7 +247,7 @@ int32_t get_many( values[i] = 0; found[i] = 0; } else { - values[i] = g_entries[g_active_bank][slot].value; + values[i] = g_entries[slot].value; found[i] = 1; } } diff --git a/native/swiss_u64.c b/native/swiss_u64.c index b3578f0..7f5b907 100644 --- a/native/swiss_u64.c +++ b/native/swiss_u64.c @@ -53,6 +53,22 @@ */ #define SCAN_WINDOW BULK_CAPACITY +/* + * Slots this module's table can reach, as a power-of-two exponent. + * + * One lower than swiss_u32.c: a 12-byte payload makes each bank half again + * as wide, and three of them have to address inside wasm32 — see the + * _Static_assert in swiss_core.h. At 7/8 that is 58,720,256 live entries. + * + * It costs address space, not memory: an instance reserves its static data + * and grows into a bank only as the table reaches it. + * + * Guarded so scripts/build-wasm.ts can override it with -D. + */ +#ifndef MAX_CAPACITY_LOG2 +#define MAX_CAPACITY_LOG2 26 +#endif + #include "swiss_core.h" /* @@ -103,8 +119,8 @@ static int32_t set_one(uint32_t key, uint32_t lo, uint32_t hi) { const int32_t status = upsert_slot(key, &slot); if (status != STATUS_OK) return status; - g_entries[g_active_bank][slot].lo = lo; - g_entries[g_active_bank][slot].hi = hi; + g_entries[slot].lo = lo; + g_entries[slot].hi = hi; return STATUS_OK; } @@ -137,7 +153,7 @@ int32_t get_or_insert(uint32_t key, uint32_t lo, uint32_t hi) { const int32_t status = upsert_slot_tracked(key, &slot, &inserted); if (status != STATUS_OK) return status; - Entry *entry = &g_entries[g_active_bank][slot]; + Entry *entry = &g_entries[slot]; if (inserted) { entry->lo = lo; @@ -164,7 +180,7 @@ int32_t increment(uint32_t key, uint32_t delta_lo, uint32_t delta_hi) { const int32_t status = upsert_slot_tracked(key, &slot, &inserted); if (status != STATUS_OK) return status; - Entry *entry = &g_entries[g_active_bank][slot]; + Entry *entry = &g_entries[slot]; const uint64_t current = inserted ? 0u : ((uint64_t)entry->hi << 32) | (uint64_t)entry->lo; @@ -270,8 +286,8 @@ int32_t get_many( if (slot == UINT32_MAX) { los[i] = 0; his[i] = 0; found[i] = 0; } else { - los[i] = g_entries[g_active_bank][slot].lo; - his[i] = g_entries[g_active_bank][slot].hi; + los[i] = g_entries[slot].lo; + his[i] = g_entries[slot].hi; found[i] = 1; } } diff --git a/package.json b/package.json index 8162aec..f0ed46f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "swisstable", - "version": "1.0.0", + "version": "1.1.0", "description": "WASM-resident SwissTable hash maps (u32 keys) with zero-callback hot paths and bulk transfer APIs", "type": "module", "license": "MIT", diff --git a/scripts/build-wasm.ts b/scripts/build-wasm.ts index 13aa561..e727591 100644 --- a/scripts/build-wasm.ts +++ b/scripts/build-wasm.ts @@ -37,35 +37,33 @@ const MIB = 1024 * 1024; /** WebAssembly linear memory is reserved in units of this. */ const PAGE = 64 * 1024; -/** - * Default `MAX_CAPACITY_LOG2`, matching the `#ifndef` in both sources. - * - * A table is bounded by this, and so is the memory an instance reserves — - * the banks are static arrays, so the full reservation exists from - * instantiation whether the table holds one entry or its maximum. One - * instance is one table, so a workload holding many small tables pays the - * whole budget per table. - * - * Lower it with SWISS_MAX_CAPACITY_LOG2 to build modules for that case: - * 2^16 slots costs about 4 MiB per u32 instance instead of 21, at - * the price of a table that cannot exceed 57,344 entries. - */ -const DEFAULT_MAX_CAPACITY_LOG2 = 20; - /** Smallest exponent that still leaves room for one group of slots. */ const MIN_MAX_CAPACITY_LOG2 = 4; /** - * Largest exponent h1() has bits for: it discards the 7 the fingerprint - * consumes, leaving 25, and a wider mask would leave the upper half of the - * slot space unreachable as a probe start. Enforced identically in - * native/swiss_core.h, which the sources also compile standalone against. + * Largest exponent MAX_LIVE()'s 32-bit product stays exact for. Enforced + * identically in native/swiss_core.h, which the sources also compile + * standalone against. + * + * What binds first is narrower and per-module: three banks have to address + * inside wasm32, which depends on the entry width. Each source carries its + * own ceiling and asserts it at compile time, so an override past what a + * module can address fails the build rather than this check. */ -const MAX_MAX_CAPACITY_LOG2 = 25; +const MAX_MAX_CAPACITY_LOG2 = 29; -function maxCapacityLog2(): number { +/** + * Exponent override, or `undefined` to let each source keep its own. + * + * Lowering it builds modules for a workload holding many small tables. It no + * longer changes what an instance reserves — the banks are reached by + * growing memory, so an instance costs what its table costs either way — but + * it does cap how far one table can grow, and caps the address space the + * module asks the host to reserve. + */ +function maxCapacityLog2(): number | undefined { const raw = Bun.env.SWISS_MAX_CAPACITY_LOG2; - if (raw === undefined || raw === "") return DEFAULT_MAX_CAPACITY_LOG2; + if (raw === undefined || raw === "") return undefined; const parsed = Number(raw); if ( @@ -132,31 +130,55 @@ interface WasmTarget { /** Single translation unit, relative to `native/`. */ readonly source: string; /** - * Bytes of bank storage per slot: one control byte plus one entry, times - * the two banks a rehash swaps between. Memory is derived from this and - * MAX_CAPACITY_LOG2 rather than written out, so lowering the exponent - * shrinks the reservation instead of leaving it stranded at the default. + * Bytes one bank spends per slot: one control byte plus one entry. The + * maximum memory is derived from this and MAX_CAPACITY_LOG2 rather than + * written out, so lowering the exponent lowers the ceiling with it. */ readonly bankBytesPerSlot: number; /** - * Everything not proportional to capacity: the fixed-size staging buffers - * (which are sized by SCAN_WINDOW / BULK_CAPACITY, not by MAX_CAPACITY), - * the linker-placed stack, and the module's own sections. + * The source's own `MAX_CAPACITY_LOG2`, matching the `#ifndef` in it. + * + * Repeated here because `--max-memory` is derived from it, and the linker + * needs the number before the compiler has read the source. A mismatch is + * caught by the `_Static_assert` in swiss_core.h, not left to be found at + * runtime. */ - readonly overheadBytes: number; + readonly ceilingLog2: number; + /** + * Static data below the heap: the fixed-size staging buffers, which are + * sized by SCAN_WINDOW / BULK_CAPACITY and not by MAX_CAPACITY, plus the + * module's own sections. This is what the initial memory has to cover. + */ + readonly staticBytes: number; /** Symbols the JavaScript bindings expect to find on the instance. */ readonly exports: readonly string[]; + /** + * Whether the module is also emitted as a base64 TypeScript source, which + * is what makes `create()` work without a loader. + * + * False for a module built only for the test suite to load off disk. Such + * a module also ignores SWISS_MAX_CAPACITY_LOG2: it exists to put a + * ceiling within reach of a test, and an override would defeat that. + */ + readonly embed: boolean; + /** + * Linear memory the module may grow to, overriding what its ceiling + * implies. Only set for a test module that has to be refused a grow. + */ + readonly maxMemoryOverride?: number; } const TARGETS: readonly WasmTarget[] = [ { name: "swiss_u32", source: "swiss_u32.c", - // 2 banks x (1 control byte + an 8-byte Entry) per slot: 18 MiB at 2^20. - bankBytesPerSlot: 2 * (1 + 8), - // ~1.1 MiB of staging buffers — bulk and scan hold separate arrays — - // plus stack and section headroom. - overheadBytes: 3 * MIB, + embed: true, + // 1 control byte + an 8-byte Entry per slot. + bankBytesPerSlot: 1 + 8, + ceilingLog2: 27, + // 65536 x (bulk keys, values, flags; scan keys, values) = ~1.1 MiB, + // plus section headroom. + staticBytes: 65536 * 17 + 128 * 1024, exports: [ "set_seed", "init", @@ -191,11 +213,13 @@ const TARGETS: readonly WasmTarget[] = [ { name: "swiss_u64", source: "swiss_u64.c", - // 2 banks x (1 control byte + a 12-byte Entry): 26 MiB at 2^20. - bankBytesPerSlot: 2 * (1 + 12), - // ~1.6 MiB of staging buffers — bulk and scan hold separate arrays — - // plus stack and section headroom. - overheadBytes: 3 * MIB, + embed: true, + // 1 control byte + a 12-byte Entry per slot. + bankBytesPerSlot: 1 + 12, + ceilingLog2: 26, + // 65536 x (bulk keys, lo, hi, flags; scan keys, lo, hi) = ~1.6 MiB, + // plus section headroom. + staticBytes: 65536 * 25 + 128 * 1024, exports: [ "set_seed", "init", @@ -232,22 +256,92 @@ const TARGETS: readonly WasmTarget[] = [ ]; /** - * Linear memory for a target, rounded up to a whole page. + * The u32 module again, capped low enough that a test can fill it. + * + * The shipped ceiling is 117,440,512 entries, which no test can reach in + * bounded time or memory — but the paths that report it are worth covering: + * a refused insert, an overwrite that must still succeed on a full table, + * and a table left usable afterwards. This module puts all three within a + * few tens of thousands of inserts. + * + * Never embedded and never published; the test suite loads it from + * dist/wasm, the same way it loads the real ones. + */ +const CAPPED_U32: WasmTarget = { + ...TARGETS[0]!, + name: "swiss_u32_capped", + ceilingLog2: 16, + embed: false, +}; + +/** + * The u32 module with a ceiling it cannot afford: 2^20 slots would need + * 28 MiB of banks, and it is allowed 8. + * + * This is the only way to exercise the other refusal — the host declining a + * `memory.grow` — which reports the same status as the slot ceiling but + * from a different place, and has to leave the table just as usable. + * + * Never embedded and never published. + */ +const STARVED_U32: WasmTarget = { + ...TARGETS[0]!, + name: "swiss_u32_starved", + ceilingLog2: 20, + embed: false, + maxMemoryOverride: 8 * MIB, +}; + +/** Linker-placed stack, from -Wl,-z,stack-size in COMMON_FLAGS. */ +const STACK_BYTES = 64 * 1024; + +/** wasm32 addresses 65536 pages, and no engine accepts a larger maximum. */ +const MAX_PAGES = 65536; + +/** + * What an instance reserves at instantiation: static data and stack, and + * nothing proportional to the capacity ceiling. * - * Undersizing it is caught by the link rather than at runtime: wasm-ld - * fails outright when .bss does not fit below the initial memory, so this - * arithmetic is checked by every build rather than trusted. + * The banks live on the heap and are reached by growing memory, so this is + * what a table that stays small actually costs. Undersizing it is caught by + * the link rather than at runtime: wasm-ld fails outright when static data + * does not fit below the initial memory, so the arithmetic is checked by + * every build rather than trusted. */ -function memoryBytes(target: WasmTarget): number { - const slots = 2 ** MAX_CAPACITY_LOG2; - const required = target.bankBytesPerSlot * slots + target.overheadBytes; - return Math.ceil(required / PAGE) * PAGE; +function initialMemoryBytes(target: WasmTarget): number { + return Math.ceil((target.staticBytes + STACK_BYTES) / PAGE) * PAGE; } -function linkFlags(target: WasmTarget, memory: number): string[] { +/** + * The most an instance may ever grow to: the high-water mark of the bank + * placement, which is three banks at MAX_CAPACITY — see the placement note + * in native/swiss_core.h — above the static data. + * + * This is address space, not a reservation. Only the pages a bank has + * actually written are ever committed. + */ +/** The exponent this target is built at: the override, or its own. */ +function exponentFor(target: WasmTarget): number { + if (!target.embed) return target.ceilingLog2; + return MAX_CAPACITY_LOG2 ?? target.ceilingLog2; +} + +function maxMemoryBytes(target: WasmTarget): number { + if (target.maxMemoryOverride !== undefined) { + return Math.ceil(target.maxMemoryOverride / PAGE) * PAGE; + } + + const slots = 2 ** exponentFor(target); + const peak = 3 * target.bankBytesPerSlot * slots; + const required = peak + initialMemoryBytes(target); + + return Math.min(Math.ceil(required / PAGE) * PAGE, MAX_PAGES * PAGE); +} + +function linkFlags(target: WasmTarget): string[] { return [ - `-Wl,--initial-memory=${memory}`, - `-Wl,--max-memory=${memory}`, + `-Wl,--initial-memory=${initialMemoryBytes(target)}`, + `-Wl,--max-memory=${maxMemoryBytes(target)}`, ...target.exports.map((symbol) => `-Wl,--export=${symbol}`), ]; } @@ -256,14 +350,12 @@ async function build(zig: string, target: WasmTarget): Promise { const outputPath = join(OUTPUT_DIR, `${target.name}.wasm`); await mkdir(dirname(outputPath), { recursive: true }); - const memory = memoryBytes(target); - const argv = [ zig, "cc", ...COMMON_FLAGS, - `-DMAX_CAPACITY_LOG2=${MAX_CAPACITY_LOG2}`, - ...linkFlags(target, memory), + `-DMAX_CAPACITY_LOG2=${exponentFor(target)}`, + ...linkFlags(target), "-o", outputPath, join(NATIVE_DIR, target.source), @@ -279,12 +371,13 @@ async function build(zig: string, target: WasmTarget): Promise { // The checked build is a throwaway the tests instantiate directly. Writing // it into src/generated would ship trapping code and, worse, make the // committed payload depend on which variant was built last. - if (!UBSAN) await emitEmbedded(target, bytes); + if (!UBSAN && target.embed) await emitEmbedded(target, bytes); console.log( `built ${target.name}.wasm (${bytes.length} bytes, ` + - `${(memory / MIB).toFixed(1)} MiB of linear memory, ` + - `max capacity 2^${MAX_CAPACITY_LOG2}${UBSAN ? ", ubsan" : ""})`, + `${(initialMemoryBytes(target) / MIB).toFixed(2)} MiB initial memory, ` + + `${(maxMemoryBytes(target) / MIB).toFixed(0)} MiB maximum, ` + + `max capacity 2^${exponentFor(target)}${UBSAN ? ", ubsan" : ""})`, ); } @@ -322,6 +415,6 @@ const zig = await zigExecutable(); await mkdir(GENERATED_DIR, { recursive: true }); -for (const target of TARGETS) { +for (const target of [...TARGETS, CAPPED_U32, STARVED_U32]) { await build(zig, target); } diff --git a/scripts/check-ubsan.ts b/scripts/check-ubsan.ts index c463b28..b268b6a 100644 --- a/scripts/check-ubsan.ts +++ b/scripts/check-ubsan.ts @@ -198,10 +198,45 @@ function checkScan( ); } +/** + * A `DataView` over a module's linear memory that survives a grow. + * + * The banks are reached by growing memory, which replaces the backing + * `ArrayBuffer` and detaches every view over it. The bindings rebuild theirs; + * this harness drives the raw exports, so it has to do the same. The check + * is buffer identity: a grow hands `memory.buffer` back as a new object, and + * a detached `DataView` throws even on `byteLength`. + */ +class Linear { + private view: DataView; + + constructor(private readonly memory: WebAssembly.Memory) { + this.view = new DataView(memory.buffer); + } + + private live(): DataView { + const buffer = this.memory.buffer; + if (this.view.buffer !== buffer) this.view = new DataView(buffer); + return this.view; + } + + getUint8(offset: number): number { + return this.live().getUint8(offset); + } + + getUint32(offset: number, littleEndian: boolean): number { + return this.live().getUint32(offset, littleEndian); + } + + setUint32(offset: number, value: number, littleEndian: boolean): void { + this.live().setUint32(offset, value, littleEndian); + } +} + async function exerciseU32(): Promise { const module = "swiss_u32"; const wasm = await instantiate(module); - const memory = new DataView(wasm.memory.buffer); + const memory = new Linear(wasm.memory); assert(wasm.init(0) === STATUS_OK, module, "init failed"); // Seeded, so mix_u32() runs on something other than the zero seed the @@ -274,7 +309,7 @@ async function exerciseU32(): Promise { */ function exerciseU32Upserts( wasm: U32Exports, - memory: DataView, + memory: Linear, next: () => number, expected: Map, ): void { @@ -323,7 +358,7 @@ function exerciseU32Upserts( */ function exerciseU32Bulk( wasm: U32Exports, - memory: DataView, + memory: Linear, next: () => number, expected: Map, ): void { @@ -384,7 +419,7 @@ function exerciseU32Bulk( async function exerciseU64(): Promise { const module = "swiss_u64"; const wasm = await instantiate(module); - const memory = new DataView(wasm.memory.buffer); + const memory = new Linear(wasm.memory); assert(wasm.init(0) === STATUS_OK, module, "init failed"); assert(wasm.set_seed(0xc0ffee02) === STATUS_OK, module, "set_seed failed"); @@ -512,7 +547,7 @@ async function exerciseU64(): Promise { */ function exerciseU64Upserts( wasm: U64Exports, - memory: DataView, + memory: Linear, next: () => number, expected: Map, expectedHi: Map, diff --git a/src/abi.ts b/src/abi.ts index 6d584a2..1dfd8b4 100644 --- a/src/abi.ts +++ b/src/abi.ts @@ -16,11 +16,12 @@ export const STATUS_OK = 0; /** - * Status returned when a request would exceed the capacity the module was - * compiled with (`MAX_CAPACITY` in the native sources). + * Status returned when a request cannot be satisfied: either it would + * exceed the capacity the module was compiled with (`MAX_CAPACITY` in the + * native sources), or the host refused to grow linear memory far enough to + * reach the bank it would need. * - * The modules are freestanding and have no allocator, so this ceiling is - * fixed at build time and cannot be raised at runtime. + * Both leave the table exactly as it was. * * @internal */ @@ -142,11 +143,10 @@ export interface ColumnScan { * walk pinned, so there is no mixture left to guard against. Callers should * read the error as "may throw". * - * `sources` are views over the module's linear memory, built once and held - * for the table's lifetime. That is only sound because the modules are - * linked with initial memory equal to maximum memory and never call - * `memory.grow`, so the backing buffer is never detached or reallocated — - * any future change to grow memory would have to rebuild these views. + * `sources` are views over the module's linear memory, pinned for the walk. + * A grow would detach them, but a grow only happens inside a rehash, and a + * rehash bumps the generation this checks before each window — so the walk + * is abandoned rather than allowed to read through a detached view. * * @param wasm - The module being iterated. * @param window - Slots per call, from `scan_window()`. diff --git a/src/generated/swiss_u32.ts b/src/generated/swiss_u32.ts index 82ca59a..26e5fa0 100644 --- a/src/generated/swiss_u32.ts +++ b/src/generated/swiss_u32.ts @@ -3,4 +3,4 @@ /** swiss_u32.wasm, base64 encoded. */ export const SWISS_U32_WASM_BASE64: string = - "AGFzbQEAAAABIgZgAAF/YAAAYAF/AX9gA39/fwF/YAR/f39/AX9gAn9/AX8DHh0AAAAAAAABAgMABAUCAgIFAgACAgAAAAUDAgAAAAQFAXABAQEFBgEB0ALQAgYIAX8BQYCABAsH5QIdBm1lbW9yeQIACHNldF9zZWVkABkEaW5pdAAQB3Jlc2VydmUAEg1zaHJpbmtfdG9fZml0ABoFY2xlYXIABgdoYXNfZ2V0AA4DaGFzAA0KZGVsZXRlX2tleQAHBHNpemUAGwhjYXBhY2l0eQAECHNpemVfcHRyABwMY2FwYWNpdHlfcHRyAAULc2Nhbl93aW5kb3cAFgpnZW5lcmF0aW9uAAkEc2NhbgATC2RlbGV0ZV9tYW55AAgNYnVsa19jYXBhY2l0eQAADWJ1bGtfa2V5c19wdHIAAg5idWxrX2ZsYWdzX3B0cgABDmxhc3RfdmFsdWVfcHRyABEDc2V0ABcNZ2V0X29yX2luc2VydAALCWluY3JlbWVudAAPCHNldF9tYW55ABgIZ2V0X21hbnkACg9idWxrX3ZhbHVlc19wdHIAAw1zY2FuX2tleXNfcHRyABQPc2Nhbl92YWx1ZXNfcHRyABUK4z4dBgBBgIAECwgAQaCAlIkACwgAQaCAhIkACwgAQbCAmIkACwsAQQAoAoyAhIAACwgAQYyAhIAAC2kBAX8CQEEAKAKMgISAACIARQ0AAkAgAEUNAEEAKAKIgISAAEEUdEGggISIAGpB/wEgAPwLAAtBACAAQQdsQQN2NgKUgISAAEEAQQAoApiAhIAAQQFqNgKYgISAAEEAQQA2AoCAhIAACwvZAgUDfwF7BX8BewF/QQAhAQJAQQAoAoyAhIAARQ0AQQAoApCAhIAAIgJBACgChICEgAAgAHMiAUEQdiABc0HrlK+veGwiAUENdiABc0G13MqVfGwiAUEQdiABcyIBQQd2cUHw//8PcSEDIAFB/wBx/Q8hBCACQQFqIQVBACgCiICEgAAiBkEXdEGggISAAGohByAGQRR0QaCAhIgAaiEIQRAhCQNAIAggA2r9AAAAIgogBP0j/WQhAQJAAkADQCABRQ0BIAFoIQsgAUF/aiABcSEBIAcgCyADaiILQQN0aigCACAARw0ACyALQX9HDQFBAA8LQQAhASAK/Qz//////////////////////SP9ZA0CIAMgCWogAnEhAyAJQRBqIgkgBU0NAQwCCwsgBkEUdCALakGAAToAoICEiABBAEEAKAKAgISAAEF/ajYCgICEgABBASEBCyABC8UDAg5/AntBfyEDAkAgAUGggJSJAEcNACAAQaCAhIkARw0AIAJBgIAESw0AAkAgAg0AQQAPC0EAKAKQgISAACIEQQFqIQUgBEHw//8PcSEGQQAoAoiAhIAAIgFBF3RBoICEgABqIQcgAUEUdCIIQaCAhIgAaiEJQQAoAoCAhIAAIQpBACgChICEgAAhC0EAKAKMgISAACEMQQAhDUEAIQMDQAJAAkAgDEUNAEEQIQ4gBiALIA1BAnQoAqCAhIkAIg9zIgFBEHYgAXNB65Svr3hsIgFBDXYgAXNBtdzKlXxsIgFBEHYgAXMiAUEHdnEhECABQf8Acf0PIREDQCAJIBBq/QAAACISIBH9I/1kIQECQANAIAFFDQEgAWghACABQX9qIAFxIQEgByAAIBBqIgBBA3RqKAIAIA9HDQALIABBf0YNAiANQQE6AKCAlIkAQQAgCkF/aiIKNgKAgISAACAIIABqQYABOgCggISIACADQQFqIQMMAwsgEv0M//////////////////////0j/WQNASAQIA5qIARxIRAgDkEQaiIOIAVNDQALCyANQQA6AKCAlIkACyANQQFqIg0gAkcNAAsLIAMLCwBBACgCmICEgAALvwMDDH8CewF/QX0hBAJAIAJBoICUiQBHDQAgAUGwgJiJAEcNACAAQaCAhIkARw0AIANBgIAESw0AAkAgA0UNAEEAIQVBACgCkICEgAAiBkEBaiEHIAZB8P//D3EhCEEAKAKIgISAACIEQRd0IglBoICEgABqIQogBEEUdEGggISIAGohC0EAKAKEgISAACEMQQAoAoyAhIAAIQ0DQCAFQQJ0IQ4CQAJAIA1FDQBBECEPIAggDCAOKAKggISJACIAcyIEQRB2IARzQeuUr694bCIEQQ12IARzQbXcypV8bCIEQRB2IARzIgRBB3ZxIQEgBEH/AHH9DyEQA0AgCyABav0AAAAiESAQ/SP9ZCEEAkADQCAERQ0BIARoIQIgBEF/aiAEcSEEIAogAiABaiICQQN0IhJqKAIAIABHDQALIAJBf0YNAiAJIBJqKAKkgISAACEEQQEhAgwDCyAR/Qz//////////////////////SP9ZA0BIAEgD2ogBnEhASAPQRBqIg8gB00NAAsLQQAhBEEAIQILIAUgAjoAoICUiQAgDiAENgKwgJiJACAFQQFqIgUgA0cNAAsLQQAhBAsgBAvLBgUIfwF7A38BewF/QQAoAoSAhIAAIABzIgJBEHYgAnNB65Svr3hsIgJBDXYgAnNBtdzKlXxsIgJBEHYgAnMhA0EAKAKIgISAACEEAkACQAJAAkACQEEAKAKMgISAACIFRQ0AQQAoApCAhIAAIgZBAWohByAEQRd0QaCAhIAAaiEIIARBFHRBoICEiABqIQkgA0H/AHH9DyEKQRAhCyADQQd2QfD//w9xIgwhAgNAIAkgAiAGcSINav0AAAAiDiAK/SP9ZCECAkADQCACRQ0BIAJoIQ8gAkF/aiACcSECIAggDyANaiIPQQN0aigCACAARw0ACyAEQRd0IA9BA3RqKAKkgISAACEBDAULAkAgDv0M//////////////////////0j/WQNACANIAtqIQIgC0EQaiILIAdNDQELC0EAKAKUgISAAA0CQQAoAoCAhIAAIQICQCAFQf//P0sNACACrUIFhiAFrUIZflgNACAFQQF0IQUMAgsgAiAFQQdsQQN2SQ0BQX4PC0EAQRA2AoyAhIAAQQ8hBkEAQQ82ApCAhIAAQQBBDjYClICEgAAgBEEUdCICQn83A6CAhIgAIAJBqICEiABqQn83AwBBAEEAKAKYgISAAEEBajYCmICEgABBAEEANgKAgISAACADQQd2QfD//w9xIQwMAQsgBRCMgICAACICDQJBACgCkICEgAAhBkEAKAKIgISAACEECyAGQQFqIQcgBEEUdEGggISIAGohCEF/IQ9BECEJA0AgCCAMIAZxIgtq/QAAAP0MgICAgICAgICAgICAgICAgP1O/WQhAgJAAkADQCACRQ0BIAJoIAtqIg0gDyAPQX9GGyEPIAJBf2ogAnEhAiAIIA1qLQAAQf8BRw0ADAILCyALIAlqIQwgCUEQaiIJIAdNDQELCwJAIA9Bf0cNAEF+DwsgBEEUdCAPaiICQaCAhIgAaiENAkAgAi0AoICEiABB/wFHDQBBAEEAKAKUgISAAEF/ajYClICEgAALIA0gA0H/AHE6AAAgBEEXdCAPQQN0aiICIAA2AqCAhIAAQQBBACgCgICEgABBAWo2AoCAhIAAIAIgATYCpICEgAALQQAhAkEAIAE2AqCAmIkACyACC5QEARF/QX4hAQJAAkAgAEGAgMAASw0AQQAhAkEAKAKIgISAACIBQQFzIgNBFHRBoICEiABqIQRBACgCjICEgAAhBQJAIABFDQAgBEH/ASAA/AsACyAAQX9qIQYCQCAFRQ0AIAZB8P//D3EhB0EAKAKEgISAACEIIAFBFHQhCSABQRd0IQpBACELQQAhAgNAAkAgCSALaiwAoICEiABBAEgNAEEQIQwgByAIIAogC0EDdGoiASgCoICEgABzIg1BEHYgDXNB65Svr3hsIg1BDXYgDXNBtdzKlXxsIg1BEHYgDXMiDkEHdnEhDyABQaCAhIAAaiEQQX8hDQNAIAQgD2r9AAAA/QyAgICAgICAgICAgICAgICA/U79ZCEBAkACQANAIAFFDQEgAWggD2oiESANIA1Bf0YbIQ0gAUF/aiABcSEBIAQgEWotAABB/wFHDQAMAgsLIA8gDGogBnEhDyAMQRBqIgwgAE0NAQsLIA1Bf0YNBCADQRR0IA1qIA5B/wBxOgCggISIACADQRd0IA1BA3RqIBApAwA3A6CAhIAAIAJBAWohAgsgC0EBaiILIAVHDQALC0EAIQFBACAANgKMgISAAEEAIAM2AoiAhIAAQQAgBjYCkICEgABBACACNgKAgISAAEEAQQAoApiAhIAAQQFqNgKYgISAAEEAIABBB2xBA3YgAms2ApSAhIAACyABDwsAC6UCBQN/AXsEfwF7AX9BACEBAkBBACgCjICEgABFDQBBACgCkICEgAAiAkEAKAKEgISAACAAcyIBQRB2IAFzQeuUr694bCIBQQ12IAFzQbXcypV8bCIBQRB2IAFzIgFBB3ZxQfD//w9xIQMgAUH/AHH9DyEEIAJBAWohBUEAKAKIgISAACIBQRd0QaCAhIAAaiEGIAFBFHRBoICEiABqIQdBECEIA0AgByADav0AAAAiCSAE/SP9ZCEBAkADQCABRQ0BIAFoIQogAUF/aiABcSEBIAYgCiADaiIKQQN0aigCACAARw0ACyAKQX9HDwtBACEBIAn9DP/////////////////////9I/1kDQEgAyAIaiACcSEDIAhBEGoiCCAFTQ0ACwsgAQvNAgUDfwF7BX8BewF/QQAhAQJAQQAoAoyAhIAARQ0AQQAoApCAhIAAIgJBACgChICEgAAgAHMiAUEQdiABc0HrlK+veGwiAUENdiABc0G13MqVfGwiAUEQdiABcyIBQQd2cUHw//8PcSEDIAFB/wBx/Q8hBCACQQFqIQVBACgCiICEgAAiBkEXdEGggISAAGohByAGQRR0QaCAhIgAaiEIQRAhCQNAIAggA2r9AAAAIgogBP0j/WQhAQJAAkADQCABRQ0BIAFoIQsgAUF/aiABcSEBIAcgCyADaiILQQN0aigCACAARw0ACyALQX9HDQFBAA8LQQAhASAK/Qz//////////////////////SP9ZA0CIAMgCWogAnEhAyAJQRBqIgkgBU0NAQwCCwtBACAGQRd0IAtBA3RqKAKkgISAADYCoICYiQBBASEBCyABC+IGBQh/AXsDfwF7AX9BACgChICEgAAgAHMiAkEQdiACc0HrlK+veGwiAkENdiACc0G13MqVfGwiAkEQdiACcyEDQQAoAoiAhIAAIQQCQAJAAkACQAJAQQAoAoyAhIAAIgVFDQBBACgCkICEgAAiBkEBaiEHIARBF3RBoICEgABqIQggBEEUdEGggISIAGohCSADQf8Acf0PIQpBECELIANBB3ZB8P//D3EiDCECA0AgCSACIAZxIg1q/QAAACIOIAr9I/1kIQICQANAIAJFDQEgAmghDyACQX9qIAJxIQIgCCAPIA1qIg9BA3RqKAIAIABHDQALIARBF3QgD0EDdGoiAkGggISAAGohDyACKAKkgISAACABaiEBDAULAkAgDv0M//////////////////////0j/WQNACANIAtqIQIgC0EQaiILIAdNDQELC0EAKAKUgISAAA0CQQAoAoCAhIAAIQICQCAFQf//P0sNACACrUIFhiAFrUIZflgNACAFQQF0IQUMAgsgAiAFQQdsQQN2SQ0BQX4PC0EAQRA2AoyAhIAAQQ8hBkEAQQ82ApCAhIAAQQBBDjYClICEgAAgBEEUdCICQn83A6CAhIgAIAJBqICEiABqQn83AwBBAEEAKAKYgISAAEEBajYCmICEgABBAEEANgKAgISAACADQQd2QfD//w9xIQwMAQsgBRCMgICAACICDQJBACgCkICEgAAhBkEAKAKIgISAACEECyAGQQFqIQcgBEEUdEGggISIAGohCEF/IQ9BECEJA0AgCCAMIAZxIgtq/QAAAP0MgICAgICAgICAgICAgICAgP1O/WQhAgJAAkADQCACRQ0BIAJoIAtqIg0gDyAPQX9GGyEPIAJBf2ogAnEhAiAIIA1qLQAAQf8BRw0ADAILCyALIAlqIQwgCUEQaiIJIAdNDQELCwJAIA9Bf0cNAEF+DwsgBEEUdCAPaiICQaCAhIgAaiENAkAgAi0AoICEiABB/wFHDQBBAEEAKAKUgISAAEF/ajYClICEgAALIA0gA0H/AHE6AAAgBEEXdCAPQQN0aiICIAA2AqCAhIAAQQBBACgCgICEgABBAWo2AoCAhIAAIAJBoICEgABqIQ8LQQAhAkEAIAE2AqCAmIkAIA8gATYCBAsgAgulAwIBfgN/AkACQCAAQRAgAEEQSxutQgOGQgaEIgFC1wJaDQBBwAAhAgwBCwJAIAFClwZaDQBBgAEhAgwBCwJAIAFClw1aDQBBgAIhAgwBCwJAIAFClxtaDQBBgAQhAgwBCwJAIAFClzdaDQBBgAghAgwBCwJAIAFCl+8AWg0AQYAQIQIMAQsCQCABQpffAVoNAEGAICECDAELAkAgAUKXvwNaDQBBgMAAIQIMAQsCQCABQpf/BloNAEGAgAEhAgwBCwJAIAFCl/8NWg0AQYCAAiECDAELAkAgAUKX/xtaDQBBgIAEIQIMAQsCQCABQpf/N1oNAEGAgAghAgwBCwJAIAFCl//vAFoNAEGAgBAhAgwBC0GAgMAAQYCAICABQpb/3wFWGyECC0F+IQMCQCAAIAJBB2xBA3YiBEsNAEEAIQNBACACNgKMgISAAEEAIAQ2ApSAhIAAQQAgAkF/ajYCkICEgABBAEEAKAKYgISAAEEBajYCmICEgABBAEEANgKIgISAAEEAQQA2AoCAhIAAIAJFDQBBoICEiABB/wEgAvwLAAsgAwsIAEGggJiJAAuwBgMCfwF+AX9BACEBAkACQEEAKAKMgISAACICDQACQAJAIABBECAAQRBLG61CA4ZCBoQiA0LXAloNAEHAACECDAELAkAgA0KXBloNAEGAASECDAELAkAgA0KXDVoNAEGAAiECDAELAkAgA0KXG1oNAEGABCECDAELAkAgA0KXN1oNAEGACCECDAELAkAgA0KX7wBaDQBBgBAhAgwBCwJAIANCl98BWg0AQYAgIQIMAQsCQCADQpe/A1oNAEGAwAAhAgwBCwJAIANCl/8GWg0AQYCAASECDAELAkAgA0KX/w1aDQBBgIACIQIMAQsCQCADQpf/G1oNAEGAgAQhAgwBCwJAIANCl/83Wg0AQYCACCECDAELAkAgA0KX/+8AWg0AQYCAECECDAELQYCAwABBgIAgIANClv/fAVYbIQILAkAgACACQQdsQQN2IgRNDQBBfg8LQQAhAUEAIAI2AoyAhIAAQQAgBDYClICEgABBACACQX9qNgKQgISAAEEAQQAoApiAhIAAQQFqNgKYgISAAEEAQQA2AoiAhIAAQQBBADYCgICEgAAgAkUNAUGggISIAEH/ASAC/AsAQQAPCyAAQQAoAoCAhIAAIgRNDQBBACEBIAAgBGtBACgClICEgABNDQACQCAAIAJBB2xBA3ZNDQACQAJAIABBECAAQRBLG61CA4ZCBoQiA0LXAloNAEHAACECDAELAkAgA0KXBloNAEGAASECDAELAkAgA0KXDVoNAEGAAiECDAELAkAgA0KXG1oNAEGABCECDAELAkAgA0KXN1oNAEGACCECDAELAkAgA0KX7wBaDQBBgBAhAgwBCwJAIANCl98BWg0AQYAgIQIMAQsCQCADQpe/A1oNAEGAwAAhAgwBCwJAIANCl/8GWg0AQYCAASECDAELAkAgA0KX/w1aDQBBgIACIQIMAQsCQCADQpf/G1oNAEGAgAQhAgwBCwJAIANCl/83Wg0AQYCACCECDAELAkAgA0KX/+8AWg0AQYCAECECDAELQYCAwABBgIAgIANClv/fAVYbIQILIAAgAkEHbEEDdk0NAEF+DwsgAhCMgICAACEBCyABC40CAQh/QX0hAQJAIABBD3ENAEEAIQEgAEEAKAKMgISAACICTw0AQQAhASAAQf//e0sNACAAQYCABGoiAyACIAMgAkkbIQRBACgCiICEgAAiAkEXdEGggISAAGohBSACQRR0QaCAhIgAaiEGQQAhAQNAAkAgBiAAav0AAAD9DICAgICAgICAgICAgICAgID9Tv1kIgNB//8DRg0AIAFBAnQhAiADQf//A3MhAyAFIABBA3RqIQcDQCACQbCAqIkAaiAHIANoQQN0aiIIKAIANgIAIAJBsIC4iQBqIAgoAgQ2AgAgAkEEaiECIAFBAWohASADQX9qIANxIgMNAAsLIABBEGoiACAESQ0ACwsgAQsIAEGwgKiJAAsIAEGwgLiJAAsGAEGAgAQLswYFCH8BewN/AXsBf0EAKAKEgISAACAAcyICQRB2IAJzQeuUr694bCICQQ12IAJzQbXcypV8bCICQRB2IAJzIQNBACgCiICEgAAhBAJAAkACQAJAAkBBACgCjICEgAAiBUUNAEEAKAKQgISAACIGQQFqIQcgBEEXdEGggISAAGohCCAEQRR0QaCAhIgAaiEJIANB/wBx/Q8hCkEQIQsgA0EHdkHw//8PcSIMIQIDQCAJIAIgBnEiDWr9AAAAIg4gCv0j/WQhAgJAA0AgAkUNASACaCEPIAJBf2ogAnEhAiAIIA8gDWoiD0EDdGooAgAgAEcNAAwGCwsCQCAO/Qz//////////////////////SP9ZA0AIA0gC2ohAiALQRBqIgsgB00NAQsLQQAoApSAhIAADQJBACgCgICEgAAhAgJAIAVB//8/Sw0AIAKtQgWGIAWtQhl+WA0AIAVBAXQhBQwCCyACIAVBB2xBA3ZJDQFBfg8LQQBBEDYCjICEgABBDyEGQQBBDzYCkICEgABBAEEONgKUgISAACAEQRR0IgJCfzcDoICEiAAgAkGogISIAGpCfzcDAEEAQQAoApiAhIAAQQFqNgKYgISAAEEAQQA2AoCAhIAAIANBB3ZB8P//D3EhDAwBCyAFEIyAgIAAIgINAkEAKAKQgISAACEGQQAoAoiAhIAAIQQLIAZBAWohByAEQRR0QaCAhIgAaiEIQX8hD0EQIQkDQCAIIAwgBnEiC2r9AAAA/QyAgICAgICAgICAgICAgICA/U79ZCECAkACQANAIAJFDQEgAmggC2oiDSAPIA9Bf0YbIQ8gAkF/aiACcSECIAggDWotAABB/wFHDQAMAgsLIAsgCWohDCAJQRBqIgkgB00NAQsLAkAgD0F/Rw0AQX4PCyAEQRR0IA9qIgJBoICEiABqIQ0CQCACLQCggISIAEH/AUcNAEEAQQAoApSAhIAAQX9qNgKUgISAAAsgDSADQf8AcToAACAEQRd0IA9BA3RqIAA2AqCAhIAAQQBBACgCgICEgABBAWo2AoCAhIAACyAEQRd0IA9BA3RqIAE2AqSAhIAAQQAhAgsgAgu5BwUMfwF7An8BewF/QX0hAwJAIAFBsICYiQBHDQAgAEGggISJAEcNACACQYCABEsNAEEAIQNBACgCgICEgAAgAmoQkoCAgAAaIAJFDQBBACgCiICEgAAhBEEAKAKMgISAACEFQQAoAoSAhIAAIQZBACEHA0AgBiAHQQJ0IggoAqCAhIkAIglzIgNBEHYgA3NB65Svr3hsIgNBDXYgA3NBtdzKlXxsIgNBEHYgA3MhCgJAAkACQAJAIAVFDQBBACgCkICEgAAiC0EBaiEMIARBF3RBoICEgABqIQ0gBEEUdEGggISIAGohDiAKQf8Acf0PIQ9BECEQIApBB3ZB8P//D3EiESEDA0AgDiADIAtxIgBq/QAAACISIA/9I/1kIQMCQANAIANFDQEgA2ghASADQX9qIANxIQMgDSABIABqIgFBA3RqKAIAIAlHDQAMBgsLAkAgEv0M//////////////////////0j/WQNACAAIBBqIQMgEEEQaiIQIAxNDQELC0EAKAKUgISAAA0CQQAoAoCAhIAAIQMCQCAFQf//P0sNACADrUIFhiAFrUIZflgNACAFQQF0IQUMAgsgAyAFQQdsQQN2SQ0BQX4PC0EQIQVBAEEQNgKMgISAAEEPIQtBAEEPNgKQgISAAEEAQQ42ApSAhIAAIARBFHQiA0J/NwOggISIACADQaiAhIgAakJ/NwMAQQBBACgCmICEgABBAWo2ApiAhIAAQQBBADYCgICEgAAgCkEHdkHw//8PcSERDAELIAUQjICAgAAiAw0DQQAoApCAhIAAIQtBACgCiICEgAAhBEEAKAKEgISAACEGQQAoAoyAhIAAIQULIAtBAWohDCAEQRR0IhNBoICEiABqIRBBECEOQX8hAQNAIBAgESALcSINav0AAAD9DICAgICAgICAgICAgICAgID9Tv1kIQMCQAJAA0AgA0UNASADaCANaiIAIAEgAUF/RhshASADQX9qIANxIQMgECAAai0AAEH/AUcNAAwCCwsgDSAOaiERIA5BEGoiDiAMTQ0BCwsCQCABQX9HDQBBfg8LIBMgAWoiA0GggISIAGohAAJAIAMtAKCAhIgAQf8BRw0AQQBBACgClICEgABBf2o2ApSAhIAACyAAIApB/wBxOgAAIARBF3QgAUEDdGogCTYCoICEgABBAEEAKAKAgISAAEEBajYCgICEgAALIARBF3QgAUEDdGogCCgCsICYiQA2AqSAhIAAIAdBAWoiByACRw0AC0EAIQMLIAMLJwEBf0F9IQECQEEAKAKAgISAAA0AQQAgADYChICEgABBACEBCyABC9wCAgN/AX5BACEAAkBBACgCjICEgAAiAUUNAEEAIQACQAJAQQAoAoCAhIAAIgJBECACQRBLG61CA4ZCBoQiA0LXAloNAEHAACECDAELAkAgA0KXBloNAEGAASECDAELAkAgA0KXDVoNAEGAAiECDAELAkAgA0KXG1oNAEGABCECDAELAkAgA0KXN1oNAEGACCECDAELAkAgA0KX7wBaDQBBgBAhAgwBCwJAIANCl98BWg0AQYAgIQIMAQsCQCADQpe/A1oNAEGAwAAhAgwBCwJAIANCl/8GWg0AQYCAASECDAELAkAgA0KX/w1aDQBBgIACIQIMAQsCQCADQpf/G1oNAEGAgAQhAgwBCwJAIANCl/83Wg0AQYCACCECDAELAkAgA0KX/+8AWg0AQYCAECECDAELQYCAwABBgIAgIANClv/fAVYbIQILIAIgAU8NACACEIyAgIAAIQALIAALCwBBACgCgICEgAALCABBgICEgAAL"; + "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AwsgEf0M//////////////////////0j/WQNASABIBBqIAhxIQEgEEEQaiIQIAlNDQALC0EAIQRBACECCyAFIAI6ALCAlIAAIA4gBDYCwICYgAAgBUEBaiIFIANHDQALC0EAIQQLIAQL5QQGAX4DfwF7BX8BewF/QQA1AoSAhIAAQpX4qfqXt96bnn9+IgJCIYggAK2FIAKFQs2Z1ur++uuof34iAkIhiCAChULT2JfU4b+u50R+IgJCIYggAoUhAgJAAkACQEEAKAKUgISAAEUNAEEAKAKYgISAACIDQQFqIQQgAkIHiKdBcHEhBSACp0H/AHH9DyEGQQAoApCAhIAAIQdBACgCjICEgAAhCEEQIQkDQCAHIAUgA3EiCkEDdGohCyAIIApq/QAAACIMIAb9I/1kIQUCQANAIAVFDQEgBWghDSAFQX9qIAVxIQUgCyANQQN0aiINKAIAIABHDQALIA0oAgQhAQwDCyAM/Qz//////////////////////SP9ZA0BIAogCWohBSAJQRBqIgkgBE0NAAsLEIyAgIAAIgUNAUEAKAKYgISAACIHQQFqIQggAkIHiKdBcHEhBUEAKAKMgISAACEKQX8hDUEQIQMDQCAKIAUgB3EiCWr9AAAA/QyAgICAgICAgICAgICAgICA/U79ZCEFAkACQANAIAVFDQEgBWggCWoiCyANIA1Bf0YbIQ0gBUF/aiAFcSEFIAogC2otAABB/wFHDQAMAgsLIAkgA2ohBSADQRBqIgMgCE0NAQsLAkAgDUF/Rw0AQX4PCwJAIAogDWoiBS0AAEH/AUcNAEEAQQAoApyAhIAAQX9qNgKcgISAAAsgBSACp0H/AHE6AABBACgCkICEgAAgDUEDdGoiBSAANgIAQQBBACgCgICEgABBAWo2AoCAhIAAIAUgATYCBAtBACEFQQAgATYCsICYgAALIAULtQIDAn8CfgF/QQAhAAJAAkBBACgClICEgAAiAQ0AAkBCoAJCAEEAKAKIgISAABtBwIDIgACtfCICQo+BBHxCEIgiAz8AIgGtWA0AQX4hACACQvD+//8PVg0CIAOnIAFrQABBf0YNAgtBAEEQNgKUgISAAEEAQQ82ApiAhIAAQQBBDjYCnICEgAAgAqciAEJ/NwAAQQAgADYCjICEgAAgAEEIakJ/NwAAQQAgAEEQajYCkICEgABBAEEAKAKggISAAEEBajYCoICEgABBAEEANgKAgISAAEEADwtBACgCnICEgAANAEEAKAKAgISAACEEAkACQCABQf///z9LDQAgBK1CBYYgAa1CGX5YDQAgAUEBdCEBDAELQX4hACAEIAFBB2xBA3ZPDQELIAEQk4CAgAAhAAsgAAuuAgcCfwF+AX8BewR/AXsBf0EAIQECQEEAKAKUgISAAEUNAEEAKAKYgISAACICQQA1AoSAhIAAQpX4qfqXt96bnn9+IgNCIYggAK2FIAOFQs2Z1ur++uuof34iA0IhiCADhULT2JfU4b+u50R+IgNCIYggA4UiA0IHiKdxQXBxIQQgA6dB/wBx/Q8hBSACQQFqIQZBACgCkICEgAAhB0EAKAKMgISAACEIQRAhCQNAIAggBGr9AAAAIgogBf0j/WQhAQJAA0AgAUUNASABaCELIAFBf2ogAXEhASAHIAsgBGoiC0EDdGooAgAgAEcNAAsgC0F/Rw8LQQAhASAK/Qz//////////////////////SP9ZA0BIAQgCWogAnEhBCAJQRBqIgkgBk0NAAsLIAELzwIHAn8BfgF/AXsEfwF7AX9BACEBAkBBACgClICEgABFDQBBACgCmICEgAAiAkEANQKEgISAAEKV+Kn6l7fem55/fiIDQiGIIACthSADhULNmdbq/vrrqH9+IgNCIYggA4VC09iX1OG/rudEfiIDQiGIIAOFIgNCB4incUFwcSEEIAOnQf8Acf0PIQUgAkEBaiEGQQAoApCAhIAAIQdBACgCjICEgAAhCEEQIQkDQCAIIARq/QAAACIKIAX9I/1kIQECQAJAA0AgAUUNASABaCELIAFBf2ogAXEhASAHIAsgBGoiC0EDdGooAgAgAEcNAAsgC0F/Rw0BQQAPC0EAIQEgCv0M//////////////////////0j/WQNAiAEIAlqIAJxIQQgCUEQaiIJIAZNDQEMAgsLQQAgByALQQN0aigCBDYCsICYgABBASEBCyABC+gEBgF+A38BewV/AXsBf0EANQKEgISAAEKV+Kn6l7fem55/fiICQiGIIACthSAChULNmdbq/vrrqH9+IgJCIYggAoVC09iX1OG/rudEfiICQiGIIAKFIQICQAJAAkBBACgClICEgABFDQBBACgCmICEgAAiA0EBaiEEIAJCB4in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A0F/aiADcSEDIAAgASAOaiIBQQN0aigCACAIRw0ADAQLCyAP/Qz//////////////////////SP9ZA0BIA4gDWohAyANQRBqIg0gCk0NAAsLEIyAgIAAIgMNAkEAKAKEgISAACEEQQAoApiAhIAAIgxBAWohCiAGQgeIp0FwcSEDQQAoAoyAhIAAIQ5BECEJQX8hAQNAIA4gAyAMcSINav0AAAD9DICAgICAgICAgICAgICAgID9Tv1kIQMCQAJAA0AgA0UNASADaCANaiIAIAEgAUF/RhshASADQX9qIANxIQMgDiAAai0AAEH/AUcNAAwCCwsgDSAJaiEDIAlBEGoiCSAKTQ0BCwsCQCABQX9HDQBBfg8LAkAgDiABaiIDLQAAQf8BRw0AQQBBACgCnICEgABBf2o2ApyAhIAACyADIAanQf8AcToAAEEAKAKQgISAACIAIAFBA3RqIAg2AgBBAEEAKAKAgISAAEEBajYCgICEgAALIAAgAUEDdGogBygCwICYgAA2AgQgBUEBaiIFIAJHDQALQQAhAwsgAwsnAQF/QX0hAQJAQQAoAoCAhIAADQBBACAANgKEgISAAEEAIQELIAEL+QMCA38BfkEAIQACQEEAKAKUgISAACIBRQ0AQQAhAAJAAkBBACgCgICEgAAiAkEQIAJBEEsbrUIDhkIGhCIDQtcCWg0AQcAAIQIMAQsCQCADQpcGWg0AQYABIQIMAQsCQCADQpcNWg0AQYACIQIMAQsCQCADQpcbWg0AQYAEIQIMAQsCQCADQpc3Wg0AQYAIIQIMAQsCQCADQpfvAFoNAEGAECECDAELAkAgA0KX3wFaDQBBgCAhAgwBCwJAIANCl78DWg0AQYDAACECDAELAkAgA0KX/wZaDQBBgIABIQIMAQsCQCADQpf/DVoNAEGAgAIhAgwBCwJAIANCl/8bWg0AQYCABCECDAELAkAgA0KX/zdaDQBBgIAIIQIMAQsCQCADQpf/7wBaDQBBgIAQIQIMAQsCQCADQpf/3wFaDQBBgIAgIQIMAQsCQCADQpf/vwNaDQBBgIDAACECDAELAkAgA0KX//8GWg0AQYCAgAEhAgwBCwJAIANCl///DVoNAEGAgIACIQIMAQsCQCADQpf//xtaDQBBgICABCECDAELAkAgA0KX//83Wg0AQYCAgAghAgwBCwJAIANCl///7wBaDQBBgICAECECDAELQYCAgMAAQYCAgCAgA0KW///fAVYbIQILIAIgAU8NACACEJOAgIAAIQALIAALCwBBACgCgICEgAALCABBgICEgAAL"; diff --git a/src/generated/swiss_u64.ts b/src/generated/swiss_u64.ts index 8b6dd47..02e70f0 100644 --- a/src/generated/swiss_u64.ts +++ b/src/generated/swiss_u64.ts @@ -3,4 +3,4 @@ /** swiss_u64.wasm, base64 encoded. */ export const SWISS_U64_WASM_BASE64: string = - "AGFzbQEAAAABJQZgAAF/YAAAYAF/AX9gA39/fwF/YAV/f39/fwF/YAR/f39/AX8DISAAAAAAAAAAAQIDAAQDAgICAwIAAgIAAAAAAwMFAgAAAAQFAXABAQEFBgEB0APQAwYIAX8BQYCABAsHlQMfBm1lbW9yeQIACHNldF9zZWVkABwEaW5pdAARB3Jlc2VydmUAEw1zaHJpbmtfdG9fZml0AB0FY2xlYXIABwdoYXNfZ2V0AA8DaGFzAA4KZGVsZXRlX2tleQAIBHNpemUAHghjYXBhY2l0eQAFCHNpemVfcHRyAB8MY2FwYWNpdHlfcHRyAAYLc2Nhbl93aW5kb3cAGApnZW5lcmF0aW9uAAoEc2NhbgAUC2RlbGV0ZV9tYW55AAkNYnVsa19jYXBhY2l0eQAADWJ1bGtfa2V5c19wdHIAAg5idWxrX2ZsYWdzX3B0cgABDmxhc3RfdmFsdWVfcHRyABIDc2V0ABkNZ2V0X29yX2luc2VydAAMCWluY3JlbWVudAAQCHNldF9tYW55ABsIZ2V0X21hbnkACxJidWxrX3ZhbHVlc19sb19wdHIABBJidWxrX3ZhbHVlc19oaV9wdHIAAw1zY2FuX2tleXNfcHRyABUSc2Nhbl92YWx1ZXNfbG9fcHRyABcSc2Nhbl92YWx1ZXNfaGlfcHRyABYKvTogBgBBgIAECwgAQaCAlI0ACwgAQaCAhI0ACwgAQbCAqI0ACwgAQbCAmI0ACwsAQQAoAoyAhIAACwgAQYyAhIAAC2kBAX8CQEEAKAKMgISAACIARQ0AAkAgAEUNAEEAKAKIgISAAEEUdEGggISMAGpB/wEgAPwLAAtBACAAQQdsQQN2NgKUgISAAEEAQQAoApiAhIAAQQFqNgKYgISAAEEAQQA2AoCAhIAACwvcAgUDfwF7BX8BewF/QQAhAQJAQQAoAoyAhIAARQ0AQQAoApCAhIAAIgJBACgChICEgAAgAHMiAUEQdiABc0HrlK+veGwiAUENdiABc0G13MqVfGwiAUEQdiABcyIBQQd2cUHw//8PcSEDIAFB/wBx/Q8hBCACQQFqIQVBACgCiICEgAAiBkGAgIAGbEGggISAAGohByAGQRR0QaCAhIwAaiEIQRAhCQNAIAggA2r9AAAAIgogBP0j/WQhAQJAAkADQCABRQ0BIAFoIQsgAUF/aiABcSEBIAcgCyADaiILQQxsaigCACAARw0ACyALQX9HDQFBAA8LQQAhASAK/Qz//////////////////////SP9ZA0CIAMgCWogAnEhAyAJQRBqIgkgBU0NAQwCCwsgBkEUdCALakGAAToAoICEjABBAEEAKAKAgISAAEF/ajYCgICEgABBASEBCyABC8gDAg5/AntBfyEDAkAgAUGggJSNAEcNACAAQaCAhI0ARw0AIAJBgIAESw0AAkAgAg0AQQAPC0EAKAKQgISAACIEQQFqIQUgBEHw//8PcSEGQQAoAoiAhIAAIgFBgICABmxBoICEgABqIQcgAUEUdCIIQaCAhIwAaiEJQQAoAoCAhIAAIQpBACgChICEgAAhC0EAKAKMgISAACEMQQAhDUEAIQMDQAJAAkAgDEUNAEEQIQ4gBiALIA1BAnQoAqCAhI0AIg9zIgFBEHYgAXNB65Svr3hsIgFBDXYgAXNBtdzKlXxsIgFBEHYgAXMiAUEHdnEhECABQf8Acf0PIREDQCAJIBBq/QAAACISIBH9I/1kIQECQANAIAFFDQEgAWghACABQX9qIAFxIQEgByAAIBBqIgBBDGxqKAIAIA9HDQALIABBf0YNAiANQQE6AKCAlI0AQQAgCkF/aiIKNgKAgISAACAIIABqQYABOgCggISMACADQQFqIQMMAwsgEv0M//////////////////////0j/WQNASAQIA5qIARxIRAgDkEQaiIOIAVNDQALCyANQQA6AKCAlI0ACyANQQFqIg0gAkcNAAsLIAMLCwBBACgCmICEgAAL6gMCDH8Ce0F9IQUCQCADQaCAlI0ARw0AIAJBsICojQBHDQAgAUGwgJiNAEcNACAAQaCAhI0ARw0AIARBgIAESw0AAkAgBEUNAEEAIQZBACgCkICEgAAiB0EBaiEIIAdB8P//D3EhCUEAKAKIgISAACIFQYCAgAZsIgpBoICEgABqIQsgBUEUdEGggISMAGohDEEAKAKEgISAACENQQAoAoyAhIAAIQ4DQCAGQQJ0IQ8CQAJAIA5FDQBBECEQIAkgDSAPKAKggISNACIBcyIFQRB2IAVzQeuUr694bCIFQQ12IAVzQbXcypV8bCIFQRB2IAVzIgVBB3ZxIQIgBUH/AHH9DyERA0AgDCACav0AAAAiEiAR/SP9ZCEFAkADQCAFRQ0BIAVoIQMgBUF/aiAFcSEFIAsgAyACaiIDQQxsIgBqKAIAIAFHDQALIANBf0YNAiAPIAogAGoiBSgCpICEgAA2ArCAmI0AIAUoAqiAhIAAIQVBASEDDAMLIBL9DP/////////////////////9I/1kDQEgAiAQaiAHcSECIBBBEGoiECAITQ0ACwtBACEFIA9BADYCsICYjQBBACEDCyAGIAM6AKCAlI0AIA8gBTYCsICojQAgBkEBaiIGIARHDQALC0EAIQULIAUL9wYFCH8BewN/AXsBf0EAKAKEgISAACAAcyIDQRB2IANzQeuUr694bCIDQQ12IANzQbXcypV8bCIDQRB2IANzIQRBACgCiICEgAAhBQJAAkACQAJAAkBBACgCjICEgAAiBkUNAEEAKAKQgISAACIHQQFqIQggBUGAgIAGbEGggISAAGohCSAFQRR0QaCAhIwAaiEKIARB/wBx/Q8hC0EQIQwgBEEHdkHw//8PcSINIQMDQCAKIAMgB3EiDmr9AAAAIg8gC/0j/WQhAwJAA0AgA0UNASADaCEQIANBf2ogA3EhAyAJIBAgDmoiEEEMbGooAgAgAEcNAAsgBUGAgIAGbCAQQQxsaiIDKAKogISAACECIAMoAqSAhIAAIQEMBQsCQCAP/Qz//////////////////////SP9ZA0AIA4gDGohAyAMQRBqIgwgCE0NAQsLQQAoApSAhIAADQJBACgCgICEgAAhAwJAIAZB//8/Sw0AIAOtQgWGIAatQhl+WA0AIAZBAXQhBgwCCyADIAZBB2xBA3ZJDQFBfg8LQQBBEDYCjICEgABBDyEHQQBBDzYCkICEgABBAEEONgKUgISAACAFQRR0IgNCfzcDoICEjAAgA0GogISMAGpCfzcDAEEAQQAoApiAhIAAQQFqNgKYgISAAEEAQQA2AoCAhIAAIARBB3ZB8P//D3EhDQwBCyAGEI2AgIAAIgMNAkEAKAKQgISAACEHQQAoAoiAhIAAIQULIAdBAWohCCAFQRR0QaCAhIwAaiEJQX8hEEEQIQoDQCAJIA0gB3EiDGr9AAAA/QyAgICAgICAgICAgICAgICA/U79ZCEDAkACQANAIANFDQEgA2ggDGoiDiAQIBBBf0YbIRAgA0F/aiADcSEDIAkgDmotAABB/wFHDQAMAgsLIAwgCmohDSAKQRBqIgogCE0NAQsLAkAgEEF/Rw0AQX4PCyAFQRR0IBBqIgNBoICEjABqIQ4CQCADLQCggISMAEH/AUcNAEEAQQAoApSAhIAAQX9qNgKUgISAAAsgDiAEQf8AcToAACAFQYCAgAZsIBBBDGxqIgMgADYCoICEgABBAEEAKAKAgISAAEEBajYCgICEgAAgAyACNgKogISAACADIAE2AqSAhIAAC0EAIQNBACACNgKkgJiNAEEAIAE2AqCAmI0ACyADC7AEARF/QX4hAQJAAkAgAEGAgMAASw0AQQAhAkEAKAKIgISAACIBQQFzIgNBFHRBoICEjABqIQRBACgCjICEgAAhBQJAIABFDQAgBEH/ASAA/AsACyAAQX9qIQYCQCAFRQ0AIAZB8P//D3EhB0EAKAKEgISAACEIIAFBFHQhCSABQYCAgAZsIQpBACELQQAhAgNAAkAgCSALaiwAoICEjABBAEgNAEEQIQwgByAIIAogC0EMbGoiASgCoICEgABzIg1BEHYgDXNB65Svr3hsIg1BDXYgDXNBtdzKlXxsIg1BEHYgDXMiDkEHdnEhDyABQaCAhIAAaiEQQX8hDQNAIAQgD2r9AAAA/QyAgICAgICAgICAgICAgICA/U79ZCEBAkACQANAIAFFDQEgAWggD2oiESANIA1Bf0YbIQ0gAUF/aiABcSEBIAQgEWotAABB/wFHDQAMAgsLIA8gDGogBnEhDyAMQRBqIgwgAE0NAQsLIA1Bf0YNBCADQRR0IA1qIA5B/wBxOgCggISMACADQYCAgAZsIA1BDGxqIgEgECkCADcCoICEgAAgAUGogISAAGogEEEIaigCADYCACACQQFqIQILIAtBAWoiCyAFRw0ACwtBACEBQQAgADYCjICEgABBACADNgKIgISAAEEAIAY2ApCAhIAAQQAgAjYCgICEgABBAEEAKAKYgISAAEEBajYCmICEgABBACAAQQdsQQN2IAJrNgKUgISAAAsgAQ8LAAuoAgUDfwF7BH8BewF/QQAhAQJAQQAoAoyAhIAARQ0AQQAoApCAhIAAIgJBACgChICEgAAgAHMiAUEQdiABc0HrlK+veGwiAUENdiABc0G13MqVfGwiAUEQdiABcyIBQQd2cUHw//8PcSEDIAFB/wBx/Q8hBCACQQFqIQVBACgCiICEgAAiAUGAgIAGbEGggISAAGohBiABQRR0QaCAhIwAaiEHQRAhCANAIAcgA2r9AAAAIgkgBP0j/WQhAQJAA0AgAUUNASABaCEKIAFBf2ogAXEhASAGIAogA2oiCkEMbGooAgAgAEcNAAsgCkF/Rw8LQQAhASAJ/Qz//////////////////////SP9ZA0BIAMgCGogAnEhAyAIQRBqIgggBU0NAAsLIAEL0wIFA38BewV/AXsBf0EAIQECQEEAKAKMgISAAEUNAEEAKAKQgISAACICQQAoAoSAhIAAIABzIgFBEHYgAXNB65Svr3hsIgFBDXYgAXNBtdzKlXxsIgFBEHYgAXMiAUEHdnFB8P//D3EhAyABQf8Acf0PIQQgAkEBaiEFQQAoAoiAhIAAIgZBgICABmxBoICEgABqIQcgBkEUdEGggISMAGohCEEQIQkDQCAIIANq/QAAACIKIAT9I/1kIQECQAJAA0AgAUUNASABaCELIAFBf2ogAXEhASAHIAsgA2oiC0EMbGooAgAgAEcNAAsgC0F/Rw0BQQAPC0EAIQEgCv0M//////////////////////0j/WQNAiADIAlqIAJxIQMgCUEQaiIJIAVNDQEMAgsLQQAgBkGAgIAGbCALQQxsaikCpICEgAA3AqCAmI0AQQEhAQsgAQuMBwYIfwF7A38BewF/AX5BACgChICEgAAgAHMiA0EQdiADc0HrlK+veGwiA0ENdiADc0G13MqVfGwiA0EQdiADcyEEQQAoAoiAhIAAIQUCQAJAAkACQAJAQQAoAoyAhIAAIgZFDQBBACgCkICEgAAiB0EBaiEIIAVBgICABmxBoICEgABqIQkgBUEUdEGggISMAGohCiAEQf8Acf0PIQtBECEMIARBB3ZB8P//D3EiDSEDA0AgCiADIAdxIg5q/QAAACIPIAv9I/1kIQMCQANAIANFDQEgA2ghECADQX9qIANxIQMgCSAQIA5qIhBBDGxqKAIAIABHDQALIAVBgICABmwgEEEMbGoiA0GggISAAGohECADQaSAhIAAaikCACERDAULAkAgD/0M//////////////////////0j/WQNACAOIAxqIQMgDEEQaiIMIAhNDQELC0EAKAKUgISAAA0CQQAoAoCAhIAAIQMCQCAGQf//P0sNACADrUIFhiAGrUIZflgNACAGQQF0IQYMAgsgAyAGQQdsQQN2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"; + "AGFzbQEAAAABJQZgAAF/YAAAYAF/AX9gA39/fwF/YAV/f39/fwF/YAR/f39/AX8DIiEAAAAAAAAAAQIDAAQDAAICAwIAAgICAAAAAAMDBQIAAAAEBQFwAQEBBQYBARycuAIGCAF/AUGAgAQLB5UDHwZtZW1vcnkCAAhzZXRfc2VlZAAdBGluaXQAEQdyZXNlcnZlABMNc2hyaW5rX3RvX2ZpdAAeBWNsZWFyAAcHaGFzX2dldAAPA2hhcwAOCmRlbGV0ZV9rZXkACARzaXplAB8IY2FwYWNpdHkABQhzaXplX3B0cgAgDGNhcGFjaXR5X3B0cgAGC3NjYW5fd2luZG93ABkKZ2VuZXJhdGlvbgAKBHNjYW4AFQtkZWxldGVfbWFueQAJDWJ1bGtfY2FwYWNpdHkAAA1idWxrX2tleXNfcHRyAAIOYnVsa19mbGFnc19wdHIAAQ5sYXN0X3ZhbHVlX3B0cgASA3NldAAaDWdldF9vcl9pbnNlcnQADAlpbmNyZW1lbnQAEAhzZXRfbWFueQAcCGdldF9tYW55AAsSYnVsa192YWx1ZXNfbG9fcHRyAAQSYnVsa192YWx1ZXNfaGlfcHRyAAMNc2Nhbl9rZXlzX3B0cgAWEnNjYW5fdmFsdWVzX2xvX3B0cgAYEnNjYW5fdmFsdWVzX2hpX3B0cgAXCu84IQYAQYCABAsIAEGwgJSAAAsIAEGwgISAAAsIAEHAgKiAAAsIAEHAgJiAAAsLAEEAKAKUgISAAAsIAEGUgISAAAtmAQF/AkBBACgClICEgAAiAEUNAAJAIABFDQBBACgCjICEgABB/wEgAPwLAAtBAEEAKAKggISAAEEBajYCoICEgABBAEEAKAKUgISAAEEHbEEDdjYCnICEgABBAEEANgKAgISAAAsL2wIHAn8BfgF/AXsEfwF7AX9BACEBAkBBACgClICEgABFDQBBACgCmICEgAAiAkEANQKEgISAAEKV+Kn6l7fem55/fiIDQiGIIACthSADhULNmdbq/vrrqH9+IgNCIYggA4VC09iX1OG/rudEfiIDQiGIIAOFIgNCB4incUFwcSEEIAOnQf8Acf0PIQUgAkEBaiEGQQAoApCAhIAAIQdBACgCjICEgAAhCEEQIQkDQCAIIARq/QAAACIKIAX9I/1kIQECQAJAA0AgAUUNASABaCELIAFBf2ogAXEhASAHIAsgBGoiC0EMbGooAgAgAEcNAAsgC0F/Rw0BQQAPC0EAIQEgCv0M//////////////////////0j/WQNAiAEIAlqIAJxIQQgCUEQaiIJIAZNDQEMAgsLIAggC2pBgAE6AABBAEEAKAKAgISAAEF/ajYCgICEgABBASEBCyABC9EDBgF/An4JfwF7AX8Be0F/IQMCQCABQbCAlIAARw0AIABBsICEgABHDQAgAkGAgARLDQACQCACDQBBAA8LQQA1AoSAhIAAQpX4qfqXt96bnn9+IgRCIYggBIUhBUEAKAKYgISAACIGQQFqIQcgBkFwcSEIQQAoApCAhIAAIQlBACgCjICEgAAhCkEAKAKUgISAACELQQAhDEEAIQMDQAJAAkAgC0UNACAIIAxBAnQoArCAhIAAIg2tIAWFQs2Z1ur++uuof34iBEIhiCAEhULT2JfU4b+u50R+IgRCIYggBIUiBEIHiKdxIQ4gBKdB/wBx/Q8hD0EQIRADQCAKIA5q/QAAACIRIA/9I/1kIQECQANAIAFFDQEgAWghACABQX9qIAFxIQEgCSAAIA5qIgBBDGxqKAIAIA1HDQALIABBf0YNAiAKIABqQYABOgAAIAxBAToAsICUgABBAEEAKAKAgISAAEF/ajYCgICEgAAgA0EBaiEDQQAoApSAhIAAIQsMAwsgEf0M//////////////////////0j/WQNASAOIBBqIAZxIQ4gEEEQaiIQIAdNDQALCyAMQQA6ALCAlIAACyAMQQFqIgwgAkcNAAsLIAMLCwBBACgCoICEgAAL5QMGAn8Cfgd/AXsBfwF7QX0hBQJAIANBsICUgABHDQAgAkHAgKiAAEcNACABQcCAmIAARw0AIABBsICEgABHDQAgBEGAgARLDQACQCAERQ0AQQAhBkEANQKEgISAAEKV+Kn6l7fem55/fiIHQiGIIAeFIQhBACgCmICEgAAiCUEBaiEKIAlBcHEhC0EAKAKQgISAACEMQQAoAoyAhIAAIQ1BACgClICEgAAhDgNAIAZBAnQhDwJAAkAgDkUNACALIA8oArCAhIAAIgGtIAiFQs2Z1ur++uuof34iB0IhiCAHhULT2JfU4b+u50R+IgdCIYggB4UiB0IHiKdxIQIgB6dB/wBx/Q8hEEEQIREDQCANIAJq/QAAACISIBD9I/1kIQUCQANAIAVFDQEgBWghAyAFQX9qIAVxIQUgDCADIAJqIgBBDGxqIgMoAgAgAUcNAAsgAEF/Rg0CIA8gAygCBDYCwICYgAAgAygCCCEFQQEhAwwDCyAS/Qz//////////////////////SP9ZA0BIAIgEWogCXEhAiARQRBqIhEgCk0NAAsLQQAhBSAPQQA2AsCAmIAAQQAhAwsgBiADOgCwgJSAACAPIAU2AsCAqIAAIAZBAWoiBiAERw0ACwtBACEFCyAFC/4EBgF+A38BewV/AXsBf0EANQKEgISAAEKV+Kn6l7fem55/fiIDQiGIIACthSADhULNmdbq/vrrqH9+IgNCIYggA4VC09iX1OG/rudEfiIDQiGIIAOFIQMCQAJAAkBBACgClICEgABFDQBBACgCmICEgAAiBEEBaiEFIANCB4inQXBxIQYgA6dB/wBx/Q8hB0EAKAKQgISAACEIQQAoAoyAhIAAIQlBECEKA0AgCCAGIARxIgtBDGxqIQwgCSALav0AAAAiDSAH/SP9ZCEGAkADQCAGRQ0BIAZoIQ4gBkF/aiAGcSEGIAwgDkEMbGoiDigCACAARw0ACyAOKAIIIQIgDigCBCEBDAMLIA39DP/////////////////////9I/1kDQEgCyAKaiEGIApBEGoiCiAFTQ0ACwsQjYCAgAAiBg0BQQAoApiAhIAAIghBAWohCSADQgeIp0FwcSEGQQAoAoyAhIAAIQtBfyEOQRAhBANAIAsgBiAIcSIKav0AAAD9DICAgICAgICAgICAgICAgID9Tv1kIQYCQAJAA0AgBkUNASAGaCAKaiIMIA4gDkF/RhshDiAGQX9qIAZxIQYgCyAMai0AAEH/AUcNAAwCCwsgCiAEaiEGIARBEGoiBCAJTQ0BCwsCQCAOQX9HDQBBfg8LAkAgCyAOaiIGLQAAQf8BRw0AQQBBACgCnICEgABBf2o2ApyAhIAACyAGIAOnQf8AcToAAEEAKAKQgISAACAOQQxsaiIGIAA2AgBBAEEAKAKAgISAAEEBajYCgICEgAAgBiACNgIIIAYgATYCBAtBACEGQQAgAjYCtICYgABBACABNgKwgJiAAAsgBgu1AgMCfwJ+AX9BACEAAkACQEEAKAKUgISAACIBDQACQEKgA0IAQQAoAoiAhIAAG0HAgOiAAK18IgJCz4EEfEIQiCIDPwAiAa1YDQBBfiEAIAJCsP7//w9WDQIgA6cgAWtAAEF/Rg0CC0EAQRA2ApSAhIAAQQBBDzYCmICEgABBAEEONgKcgISAACACpyIAQn83AABBACAANgKMgISAACAAQQhqQn83AABBACAAQRBqNgKQgISAAEEAQQAoAqCAhIAAQQFqNgKggISAAEEAQQA2AoCAhIAAQQAPC0EAKAKcgISAAA0AQQAoAoCAhIAAIQQCQAJAIAFB////H0sNACAErUIFhiABrUIZflgNACABQQF0IQEMAQtBfiEAIAQgAUEHbEEDdk8NAQsgARCUgICAACEACyAAC64CBwJ/AX4BfwF7BH8BewF/QQAhAQJAQQAoApSAhIAARQ0AQQAoApiAhIAAIgJBADUChICEgABClfip+pe33puef34iA0IhiCAArYUgA4VCzZnW6v7666h/fiIDQiGIIAOFQtPYl9Thv67nRH4iA0IhiCADhSIDQgeIp3FBcHEhBCADp0H/AHH9DyEFIAJBAWohBkEAKAKQgISAACEHQQAoAoyAhIAAIQhBECEJA0AgCCAEav0AAAAiCiAF/SP9ZCEBAkADQCABRQ0BIAFoIQsgAUF/aiABcSEBIAcgCyAEaiILQQxsaigCACAARw0ACyALQX9HDwtBACEBIAr9DP/////////////////////9I/1kDQEgBCAJaiACcSEEIAlBEGoiCSAGTQ0ACwsgAQvZAgcCfwF+AX8BewR/AXsCf0EAIQECQEEAKAKUgISAAEUNAEEAKAKYgISAACICQQA1AoSAhIAAQpX4qfqXt96bnn9+IgNCIYggAK2FIAOFQs2Z1ur++uuof34iA0IhiCADhULT2JfU4b+u50R+IgNCIYggA4UiA0IHiKdxQXBxIQQgA6dB/wBx/Q8hBSACQQFqIQZBACgCkICEgAAhB0EAKAKMgISAACEIQRAhCQNAIAggBGr9AAAAIgogBf0j/WQhAQJAAkADQCABRQ0BIAFoIQsgAUF/aiABcSEBIAcgCyAEaiIMQQxsaiILKAIAIABHDQALIAxBf0cNAUEADwtBACEBIAr9DP/////////////////////9I/1kDQIgBCAJaiACcSEEIAlBEGoiCSAGTQ0BDAILC0EAIAsoAgQ2ArCAmIAAQQAgCygCCDYCtICYgABBASEB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IgNB//8DRg0AIAFBAnQhAiADQf//A3MhAyAFIABBDGxqIQcDQCACQcCAuIAAaiAHIANoQQxsaiIIKAIANgIAIAJBwIDIgABqIAgoAgQ2AgAgAkHAgNiAAGogCCgCCDYCACACQQRqIQIgAUEBaiEBIANBf2ogA3EiAw0ACwsgAEEQaiIAIARJDQALCyABCwgAQcCAuIAACwgAQcCA2IAACwgAQcCAyIAACwYAQYCABAsOACAAIAEgAhCbgICAAAvbBAYBfgN/AXsEfwF7AX9BADUChICEgABClfip+pe33puef34iA0IhiCAArYUgA4VCzZnW6v7666h/fiIDQiGIIAOFQtPYl9Thv67nRH4iA0IhiCADhSEDAkACQAJAQQAoApSAhIAARQ0AQQAoApiAhIAAIgRBAWohBSADQgeIp0FwcSEGIAOnQf8Acf0PIQdBACgCkICEgAAhCEEAKAKMgISAACEJQRAhCgNAIAkgBiAEcSILav0AAAAiDCAH/SP9ZCEGAkADQCAGRQ0BIAZoIQ0gBkF/aiAGcSEGIAggDSALaiINQQxsaigCACAARw0ADAQLCyAM/Qz//////////////////////SP9ZA0BIAsgCmohBiAKQRBqIgogBU0NAAsLEI2AgIAAIgYNAUEAKAKYgISAACIJQQFqIQUgA0IHiKdBcHEhBkEAKAKMgISAACEIQX8hDUEQIQQDQCAIIAYgCXEiCmr9AAAA/QyAgICAgICAgICAgICAgICA/U79ZCEGAkACQANAIAZFDQEgBmggCmoiCyANIA1Bf0YbIQ0gBkF/aiAGcSEGIAggC2otAABB/wFHDQAMAgsLIAogBGohBiAEQRBqIgQgBU0NAQsLAkAgDUF/Rw0AQX4PCwJAIAggDWoiBi0AAEH/AUcNAEEAQQAoApyAhIAAQX9qNgKcgISAAAsgBiADp0H/AHE6AABBACgCkICEgAAiCCANQQxsaiAANgIAQQBBACgCgICEgABBAWo2AoCAhIAACyAIIA1BDGxqIgYgAjYCCCAGIAE2AgRBACEGCyAGC5wBAQF/QX0hBAJAIAJBwICogABHDQAgAUHAgJiAAEcNACAAQbCAhIAARw0AIANBgIAESw0AQQAhBEEAKAKAgISAACADahCTgICAABogA0UNAEEAIQIDQCACQbCAhIAAaigCACACQcCAmIAAaigCACACQcCAqIAAaigCABCbgICAACIEDQEgAkEEaiECIANBf2oiAw0AC0EAIQQLIAQLJwEBf0F9IQECQEEAKAKAgISAAA0AQQAgADYChICEgABBACEBCyABC+EDAgN/AX5BACEAAkBBACgClICEgAAiAUUNAEEAIQACQAJAQQAoAoCAhIAAIgJBECACQRBLG61CA4ZCBoQiA0LXAloNAEHAACECDAELAkAgA0KXBloNAEGAASECDAELAkAgA0KXDVoNAEGAAiECDAELAkAgA0KXG1oNAEGABCECDAELAkAgA0KXN1oNAEGACCECDAELAkAgA0KX7wBaDQBBgBAhAgwBCwJAIANCl98BWg0AQYAgIQIMAQsCQCADQpe/A1oNAEGAwAAhAgwBCwJAIANCl/8GWg0AQYCAASECDAELAkAgA0KX/w1aDQBBgIACIQIMAQsCQCADQpf/G1oNAEGAgAQhAgwBCwJAIANCl/83Wg0AQYCACCECDAELAkAgA0KX/+8AWg0AQYCAECECDAELAkAgA0KX/98BWg0AQYCAICECDAELAkAgA0KX/78DWg0AQYCAwAAhAgwBCwJAIANCl///BloNAEGAgIABIQIMAQsCQCADQpf//w1aDQBBgICAAiECDAELAkAgA0KX//8bWg0AQYCAgAQhAgwBCwJAIANCl///N1oNAEGAgIAIIQIMAQtBgICAIEGAgIAQIANClv//7wBWGyECCyACIAFPDQAgAhCUgICAACEACyAACwsAQQAoAoCAhIAACwgAQYCAhIAACw=="; diff --git a/src/swiss-u32.ts b/src/swiss-u32.ts index 7ed65e5..1219be7 100644 --- a/src/swiss-u32.ts +++ b/src/swiss-u32.ts @@ -222,9 +222,11 @@ export interface BulkU32GetResult { * Views over the module's own bulk staging buffers. * * The addresses and the batch size come from the module itself, so the - * buffers can never overlap the table banks. The views are built once: the - * modules are linked with initial memory equal to maximum memory and never - * call `memory.grow`, so the backing buffer is never detached. + * buffers can never overlap the table banks. The addresses are fixed for + * the module's lifetime — the staging buffers are static data, below the + * heap the banks are laid out on — but the views over them are not: a + * `memory.grow` replaces the backing buffer, so {@link BulkScratch.rebind} + * builds them again. */ class BulkScratch { /** Maximum keys per WASM call; larger batches are chunked. */ @@ -278,6 +280,19 @@ class BulkScratch { * does, so disposing a table has to let these go along with the exports — * otherwise the staging views alone would pin the whole reservation. */ + /** + * Builds the views again over `buffer`, after a grow replaced the old one. + * + * The addresses are unchanged — only the buffer object is new. + */ + rebind(buffer: ArrayBuffer): void { + if (this.released) return; + + this.keys = new Uint32Array(buffer, this.keysPtr, this.maxBatch); + this.values = new Uint32Array(buffer, this.valuesPtr, this.maxBatch); + this.found = new Uint8Array(buffer, this.foundPtr, this.maxBatch); + } + release(): void { this.released = true; this.keys = DISPOSED_STAGING; @@ -293,9 +308,11 @@ class BulkScratch { * memory. Every method issues exactly one call into WASM and there are no * JavaScript callbacks on the hot path. * - * Capacity is fixed at build time: the module is freestanding and has no - * allocator, so operations that would exceed it throw {@link RangeError} - * rather than growing. + * The table grows itself: the module reaches each new bank by growing its + * linear memory, so an instance costs what its table costs rather than a + * fixed reservation. The ceiling is 117,440,512 entries, and an operation + * past it — or one the host refuses the memory for — throws + * {@link RangeError} rather than growing. * * @example * ```ts @@ -322,8 +339,8 @@ export class SwissU32ToU32 { * View over the module's latched-result slot. * * Reading the value through linear memory keeps a lookup at one boundary - * crossing instead of two. The view is built once because the module's - * memory is fixed and never grows, so the backing buffer is never detached. + * crossing instead of two. Rebuilt by {@link SwissU32ToU32.resync} when a + * grow replaces the buffer it views. */ private lastValue: Uint32Array; @@ -505,7 +522,7 @@ export class SwissU32ToU32 { seed: number, ): Promise { // Validated before the module is instantiated, so a bad seed costs a - // throw rather than a 21 MiB instance the caller never receives. + // throw rather than an instance the caller never receives. const checked = asWasmI32(seed, "seed"); return SwissU32ToU32.fromInstance( @@ -621,6 +638,10 @@ export class SwissU32ToU32 { "SwissU32ToU32", ); + // Sizing the table for `expectedEntries` is itself a growth, so the + // views the constructor built are already stale by here. + table.resync(); + return table; } @@ -628,6 +649,43 @@ export class SwissU32ToU32 { * Keys one bulk WASM call carries. Longer batches are chunked * automatically; sizing batches to this avoids the extra copy. */ + /** + * Rebuilds every cached view, if the module has grown its memory. + * + * The table's banks live on the heap and the module grows linear memory to + * reach them, which replaces the backing `ArrayBuffer` and detaches every + * view over the old one. A detached typed array reports a length of 0, + * which is what this tests: a field load on a view the caller already + * holds, where reading `memory.buffer` would be a call into the engine's + * `Memory` object on a path that runs per `set`. + * + * Called after every export that can rehash, and before anything reads a + * view, so no view is ever observed detached from outside this class. That + * includes the calls that report a failure: an export can grow memory on + * its way to refusing, so the refusal has to leave the views rebuilt. + */ + private resync(): void { + if (this.sizeView.length !== 0) return; + + const wasm = this.wasm; + const buffer = wasm.memory.buffer; + + this.lastValue = new Uint32Array(buffer, wasm.last_value_ptr(), 1); + this.scanKeys = new Uint32Array( + buffer, + wasm.scan_keys_ptr(), + this.scanWindow, + ); + this.scanValues = new Uint32Array( + buffer, + wasm.scan_values_ptr(), + this.scanWindow, + ); + this.sizeView = new Uint32Array(buffer, wasm.size_ptr(), 1); + this.capacityView = new Uint32Array(buffer, wasm.capacity_ptr(), 1); + this.scratch.rebind(buffer); + } + get maxBatch(): number { return this.scratch.maxBatch; } @@ -662,11 +720,9 @@ export class SwissU32ToU32 { * capacity. */ reserve(entries: number): void { - assertStatus( - this.wasm.reserve(asWasmI32(entries, "entries")), - "reserve", - "SwissU32ToU32", - ); + const status = this.wasm.reserve(asWasmI32(entries, "entries")); + this.resync(); + assertStatus(status, "reserve", "SwissU32ToU32"); } /** @@ -682,23 +738,27 @@ export class SwissU32ToU32 { * a bulk removal costs a comparison rather than a rehash. * * This rehashes, which invalidates any open iterator exactly as a growth - * rehash would. + * rehash would. It recovers walk cost, not memory: the pages the larger + * bank touched stay with the instance until it is disposed. * * @throws {Error} If the module reports a failure. */ shrinkToFit(): void { - assertStatus(this.wasm.shrink_to_fit(), "shrinkToFit", "SwissU32ToU32"); + const status = this.wasm.shrink_to_fit(); + this.resync(); + assertStatus(status, "shrinkToFit", "SwissU32ToU32"); } /** * Releases the module instance backing this table. * - * One table is one instance, and an instance reserves its whole capacity - * of linear memory up front — 21 MiB by default — which nothing can - * reclaim while the instance is reachable. Dropping the last reference to - * the table lets the collector take it eventually; this is how to make - * "eventually" now, which is what a process building a table per request - * or per document needs. + * One table is one instance, and an instance holds every page its table + * ever grew into — memory is never handed back, not even by + * {@link SwissU32ToU32.shrinkToFit} — and nothing can reclaim it while the + * instance is reachable. Dropping the last reference to the table lets the + * collector take it eventually; this is how to make "eventually" now, + * which is what a process building a table per request or per document + * needs. * * Idempotent. Afterwards {@link SwissU32ToU32.size} and * {@link SwissU32ToU32.capacity} read 0 and every other method throws, @@ -765,11 +825,12 @@ export class SwissU32ToU32 { * integer, or if the insert would exceed the compiled capacity. */ set(key: number, value: number): this { - assertStatus( - this.wasm.set(asWasmI32(key, "key"), asWasmI32(value, "value")), - "set", - "SwissU32ToU32", + const status = this.wasm.set( + asWasmI32(key, "key"), + asWasmI32(value, "value"), ); + this.resync(); + assertStatus(status, "set", "SwissU32ToU32"); return this; } @@ -789,14 +850,12 @@ export class SwissU32ToU32 { * integer, or if the insert would exceed the compiled capacity. */ getOrInsert(key: number, value: number): number { - assertStatus( - this.wasm.get_or_insert( - asWasmI32(key, "key"), - asWasmI32(value, "value"), - ), - "getOrInsert", - "SwissU32ToU32", + const status = this.wasm.get_or_insert( + asWasmI32(key, "key"), + asWasmI32(value, "value"), ); + this.resync(); + assertStatus(status, "getOrInsert", "SwissU32ToU32"); return this.lastValue[0]!; } @@ -813,11 +872,12 @@ export class SwissU32ToU32 { * integer, or if the insert would exceed the compiled capacity. */ increment(key: number, delta = 1): number { - assertStatus( - this.wasm.increment(asWasmI32(key, "key"), asWasmI32(delta, "delta")), - "increment", - "SwissU32ToU32", + const status = this.wasm.increment( + asWasmI32(key, "key"), + asWasmI32(delta, "delta"), ); + this.resync(); + assertStatus(status, "increment", "SwissU32ToU32"); return this.lastValue[0]!; } @@ -876,15 +936,18 @@ export class SwissU32ToU32 { stageU32(keys, this.scratch.keys, offset, chunk, "keys"); stageU32(values, this.scratch.values, offset, chunk, "values"); - assertStatus( - this.wasm.set_many( - this.scratch.keysPtr, - this.scratch.valuesPtr, - chunk, - ), - "setMany", - "SwissU32ToU32", + const status = this.wasm.set_many( + this.scratch.keysPtr, + this.scratch.valuesPtr, + chunk, ); + + // A chunk can grow the table, and the next one stages through the + // same views this replaces. A chunk that runs out of capacity can have + // grown several times before it did, so this runs ahead of the throw. + this.resync(); + + assertStatus(status, "setMany", "SwissU32ToU32"); } } diff --git a/src/swiss-u64.ts b/src/swiss-u64.ts index 8a13d3f..86aa8b4 100644 --- a/src/swiss-u64.ts +++ b/src/swiss-u64.ts @@ -332,9 +332,10 @@ const compileEmbedded = embeddedModule(SWISS_U64_WASM_BASE64); * buffers can never overlap the table banks — an earlier revision picked the * offsets on the JavaScript side and silently aliased them. * - * The views are built once: the modules are linked with initial memory equal - * to maximum memory and never call `memory.grow`, so the backing buffer is - * never detached and never reallocated. + * The addresses are fixed for the module's lifetime — the staging buffers + * are static data, below the heap the banks are laid out on — but the views + * over them are not: a `memory.grow` replaces the backing buffer, so + * {@link BulkScratch.rebind} builds them again. */ class BulkScratch { /** Maximum keys per WASM call; larger batches are chunked. */ @@ -387,6 +388,21 @@ class BulkScratch { this.found = new Uint8Array(buffer, this.foundPtr, this.maxBatch); } + /** + * Builds the views again over `buffer`, after a grow replaced the old one. + * + * The addresses are unchanged — the staging buffers are static data, below + * the heap the banks are laid out on — so only the buffer object is new. + */ + rebind(buffer: ArrayBuffer): void { + if (this.released) return; + + this.keys = new Uint32Array(buffer, this.keysPtr, this.maxBatch); + this.valsLo = new Uint32Array(buffer, this.valsLoPtr, this.maxBatch); + this.valsHi = new Uint32Array(buffer, this.valsHiPtr, this.maxBatch); + this.found = new Uint8Array(buffer, this.foundPtr, this.maxBatch); + } + /** * Drops the views onto the module's memory. * @@ -412,9 +428,11 @@ class BulkScratch { * module's memory and process it in one crossing — the widest margin over * `Map`, since the boundary cost is paid once per batch instead of per key. * - * Capacity is fixed at build time: the module is freestanding and has no - * allocator, so operations that would exceed it throw {@link RangeError} - * rather than growing. + * The table grows itself: the module reaches each new bank by growing its + * linear memory, so an instance costs what its table costs rather than a + * fixed reservation. The ceiling is 58,720,256 entries, and an operation + * past it — or one the host refuses the memory for — throws + * {@link RangeError} rather than growing. * * @example * ```ts @@ -450,8 +468,8 @@ export class SwissU32ToU64 { * View over the module's latched-result lanes. * * Reading them through linear memory keeps a lookup at one boundary - * crossing instead of three. The view is built once because the module's - * memory is fixed and never grows. + * crossing instead of three. Rebuilt by {@link SwissU32ToU64.resync} when + * a grow replaces the buffer it views. */ private lastValue: Uint32Array; @@ -609,7 +627,7 @@ export class SwissU32ToU64 { seed: number, ): Promise { // Validated before the module is instantiated, so a bad seed costs a - // throw rather than a 29 MiB instance the caller never receives. + // throw rather than an instance the caller never receives. const checked = asWasmI32(seed, "seed"); return SwissU32ToU64.fromInstance( @@ -725,6 +743,10 @@ export class SwissU32ToU64 { "SwissU32ToU64", ); + // Sizing the table for `expectedEntries` is itself a growth, so the + // views the constructor built are already stale by here. + table.resync(); + return table; } @@ -754,6 +776,37 @@ export class SwissU32ToU64 { * Larger batches are chunked automatically; this only matters when sizing * batches to avoid the copy that chunking implies. */ + /** + * Rebuilds every cached view, if the module has grown its memory. + * + * The table's banks live on the heap and the module grows linear memory to + * reach them, which replaces the backing `ArrayBuffer` and detaches every + * view over the old one. A detached typed array reports a length of 0, + * which is what this tests: a field load on a view the caller already + * holds, where reading `memory.buffer` would be a call into the engine's + * `Memory` object on a path that runs per `set`. + * + * Called after every export that can rehash, and before anything reads a + * view, so no view is ever observed detached from outside this class. That + * includes the calls that report a failure: an export can grow memory on + * its way to refusing, so the refusal has to leave the views rebuilt. + */ + private resync(): void { + if (this.sizeView.length !== 0) return; + + const wasm = this.wasm; + const buffer = wasm.memory.buffer; + const window = this.scanWindow; + + this.lastValue = new Uint32Array(buffer, wasm.last_value_ptr(), 2); + this.scanKeys = new Uint32Array(buffer, wasm.scan_keys_ptr(), window); + this.scanValsLo = new Uint32Array(buffer, wasm.scan_values_lo_ptr(), window); + this.scanValsHi = new Uint32Array(buffer, wasm.scan_values_hi_ptr(), window); + this.sizeView = new Uint32Array(buffer, wasm.size_ptr(), 1); + this.capacityView = new Uint32Array(buffer, wasm.capacity_ptr(), 1); + this.scratch.rebind(buffer); + } + get maxBatch(): number { return this.scratch.maxBatch; } @@ -768,22 +821,21 @@ export class SwissU32ToU64 { * capacity. */ reserve(entries: number): void { - assertStatus( - this.wasm.reserve(asWasmI32(entries, "entries")), - "reserve", - "SwissU32ToU64", - ); + const status = this.wasm.reserve(asWasmI32(entries, "entries")); + this.resync(); + assertStatus(status, "reserve", "SwissU32ToU64"); } /** * Releases the module instance backing this table. * - * One table is one instance, and an instance reserves its whole capacity - * of linear memory up front — 29 MiB by default — which nothing can - * reclaim while the instance is reachable. Dropping the last reference to - * the table lets the collector take it eventually; this is how to make - * "eventually" now, which is what a process building a table per request - * or per document needs. + * One table is one instance, and an instance holds every page its table + * ever grew into — memory is never handed back, not even by + * {@link SwissU32ToU64.shrinkToFit} — and nothing can reclaim it while the + * instance is reachable. Dropping the last reference to the table lets the + * collector take it eventually; this is how to make "eventually" now, + * which is what a process building a table per request or per document + * needs. * * Idempotent. Afterwards {@link SwissU32ToU64.size} and * {@link SwissU32ToU64.capacity} read 0, and every method that would touch @@ -820,12 +872,15 @@ export class SwissU32ToU64 { * a bulk removal costs a comparison rather than a rehash. * * This rehashes, which invalidates any open iterator exactly as a growth - * rehash would. + * rehash would. It recovers walk cost, not memory: the pages the larger + * bank touched stay with the instance until it is disposed. * * @throws {Error} If the module reports a failure. */ shrinkToFit(): void { - assertStatus(this.wasm.shrink_to_fit(), "shrinkToFit", "SwissU32ToU64"); + const status = this.wasm.shrink_to_fit(); + this.resync(); + assertStatus(status, "shrinkToFit", "SwissU32ToU64"); } /** @@ -887,15 +942,13 @@ export class SwissU32ToU64 { * or if the insert would exceed the compiled capacity. */ set(key: number, lo: number, hi: number): this { - assertStatus( - this.wasm.set( - asWasmI32(key, "key"), - asWasmI32(lo, "lo"), - asWasmI32(hi, "hi"), - ), - "set", - "SwissU32ToU64", + const status = this.wasm.set( + asWasmI32(key, "key"), + asWasmI32(lo, "lo"), + asWasmI32(hi, "hi"), ); + this.resync(); + assertStatus(status, "set", "SwissU32ToU64"); return this; } @@ -940,15 +993,13 @@ export class SwissU32ToU64 { * or if the insert would exceed the compiled capacity. */ getOrInsert(key: number, lo: number, hi: number): U64Lanes { - assertStatus( - this.wasm.get_or_insert( - asWasmI32(key, "key"), - asWasmI32(lo, "lo"), - asWasmI32(hi, "hi"), - ), - "getOrInsert", - "SwissU32ToU64", + const status = this.wasm.get_or_insert( + asWasmI32(key, "key"), + asWasmI32(lo, "lo"), + asWasmI32(hi, "hi"), ); + this.resync(); + assertStatus(status, "getOrInsert", "SwissU32ToU64"); return { lo: this.lastValue[0]!, hi: this.lastValue[1]! }; } @@ -967,15 +1018,13 @@ export class SwissU32ToU64 { * or if the insert would exceed the compiled capacity. */ increment(key: number, deltaLo = 1, deltaHi = 0): U64Lanes { - assertStatus( - this.wasm.increment( - asWasmI32(key, "key"), - asWasmI32(deltaLo, "deltaLo"), - asWasmI32(deltaHi, "deltaHi"), - ), - "increment", - "SwissU32ToU64", + const status = this.wasm.increment( + asWasmI32(key, "key"), + asWasmI32(deltaLo, "deltaLo"), + asWasmI32(deltaHi, "deltaHi"), ); + this.resync(); + assertStatus(status, "increment", "SwissU32ToU64"); return { lo: this.lastValue[0]!, hi: this.lastValue[1]! }; } @@ -1043,16 +1092,19 @@ export class SwissU32ToU64 { stageU32(valsLo, this.scratch.valsLo, offset, chunk, "valsLo"); stageU32(valsHi, this.scratch.valsHi, offset, chunk, "valsHi"); - assertStatus( - this.wasm.set_many( - this.scratch.keysPtr, - this.scratch.valsLoPtr, - this.scratch.valsHiPtr, - chunk, - ), - "setMany", - "SwissU32ToU64", + const status = this.wasm.set_many( + this.scratch.keysPtr, + this.scratch.valsLoPtr, + this.scratch.valsHiPtr, + chunk, ); + + // A chunk can grow the table, and the next one stages through the + // same views this replaces. A chunk that runs out of capacity can have + // grown several times before it did, so this runs ahead of the throw. + this.resync(); + + assertStatus(status, "setMany", "SwissU32ToU64"); } } diff --git a/test/embedded.test.ts b/test/embedded.test.ts index 084ec3f..650e417 100644 --- a/test/embedded.test.ts +++ b/test/embedded.test.ts @@ -44,7 +44,7 @@ describe("create()", () => { }); test("rejects an expected count beyond the compiled capacity", async () => { - await expect(SwissU32ToU32.create(2_000_000)).rejects.toThrow(RangeError); + await expect(SwissU32ToU32.create(200_000_000)).rejects.toThrow(RangeError); }); // The compiled module is shared, so a caller must not be able to observe diff --git a/test/memory-layout.test.ts b/test/memory-layout.test.ts index c627d3e..3408ec4 100644 --- a/test/memory-layout.test.ts +++ b/test/memory-layout.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { SwissU32ToU32 } from "../src/index.ts"; import { decodeBase64 } from "../src/embedded.ts"; import { SWISS_U32_WASM_BASE64 } from "../src/generated/swiss_u32.ts"; import { SWISS_U64_WASM_BASE64 } from "../src/generated/swiss_u64.ts"; @@ -22,6 +23,8 @@ import { SWISS_U64_WASM_BASE64 } from "../src/generated/swiss_u64.ts"; /** -Wl,-z,stack-size in scripts/build-wasm.ts. */ const STACK_SIZE = 65_536; +const MIB = 1024 * 1024; + interface Layout { readonly name: string; readonly base64: string; @@ -64,6 +67,46 @@ async function instantiate(base64: string): Promise> { return instance.exports as Record; } +/** + * The u32 module with a ceiling it cannot afford — 2^20 slots against 8 MiB + * of linear memory. See STARVED_U32 in scripts/build-wasm.ts. + * + * A rehash that cannot reach its new bank has to report the refusal before + * it touches anything, so the table it was called on is left exactly as it + * was rather than half rebuilt. + */ +const starvedFile = Bun.file( + new URL("../dist/wasm/swiss_u32_starved.wasm", import.meta.url), +); + +// Requires `bun run build`; skipped when the module has not been compiled. +describe.skipIf(!(await starvedFile.exists()))("a host that refuses a grow", () => { + test("reports it without disturbing the table", async () => { + const table = await SwissU32ToU32.load(await starvedFile.arrayBuffer(), 0); + + let inserted = 0; + expect(() => { + for (;; inserted += 1) table.set(inserted, inserted * 3); + }).toThrow(RangeError); + + // The memory ran out well before the slots did, which is what makes + // this a different path from the capacity ceiling. + expect(table.capacity).toBeLessThan(1 << 20); + expect(table.size).toBe(inserted); + + let wrong = 0; + for (let i = 0; i < inserted; i += 1) { + if (table.get(i) !== i * 3) wrong += 1; + } + expect(wrong).toBe(0); + + // An overwrite consumes no slot, so it needs no growth and must still + // succeed on a table that can no longer grow. + table.set(5, 12_345); + expect(table.get(5)).toBe(12_345); + }); +}); + describe.each(LAYOUTS)("$name memory layout", ({ base64, pointers }) => { test("places every static above the stack", async () => { const exports = await instantiate(base64); @@ -74,13 +117,28 @@ describe.each(LAYOUTS)("$name memory layout", ({ base64, pointers }) => { } }); - test("refuses to grow linear memory", async () => { + test("reserves only what its statics need at instantiation", async () => { + const exports = await instantiate(base64); + const memory = exports["memory"] as WebAssembly.Memory; + + // The banks are laid out on the heap, so an untouched instance + // reserves the staging buffers and the stack and nothing else. What + // this pins is that the reservation does not scale with the capacity + // ceiling: a table holding a few thousand entries must not pay for one + // that reaches the maximum. + expect(memory.buffer.byteLength).toBeLessThan(2 * MIB); + }); + + test("grows linear memory to reach a bank it cannot already address", async () => { const exports = await instantiate(base64); const memory = exports["memory"] as WebAssembly.Memory; const before = memory.buffer.byteLength; - expect(() => memory.grow(1)).toThrow(); - expect(memory.buffer.byteLength).toBe(before); - expect(memory.buffer.detached).toBe(false); + // 200,000 entries need more bank than the initial reservation holds, + // so reaching them is what proves the module grows rather than + // failing — and that the growth is driven by the table, not reserved + // up front. + expect((exports["init"] as (n: number) => number)(200_000)).toBe(0); + expect(memory.buffer.byteLength).toBeGreaterThan(before); }); }); diff --git a/test/stress.test.ts b/test/stress.test.ts index 8c285f5..891f5b9 100644 --- a/test/stress.test.ts +++ b/test/stress.test.ts @@ -7,8 +7,18 @@ import { SwissU32ToU64, } from "../src/index.ts"; -/** Live entries at the 7/8 load factor over `1 << 20` slots. */ -const MAX_ENTRIES = 917_504; +/** + * Live entries at the 7/8 load factor over the module's `1 << 27` slots. + * + * Reaching it would take about 1.2 GiB of banks and several minutes, so + * nothing here fills to it — what the tests below check is that a request + * past it is refused cleanly, and that ordinary tables well short of it + * grow without limit. + */ +const MAX_ENTRIES = ((1 << 27) * 7) / 8; + +/** Entries filled by the tests that want a table spanning many rehashes. */ +const MANY_ENTRIES = 1_400_000; /** xorshift32, so a failure reproduces from the seed alone. */ function rng(seed: number): () => number { @@ -56,30 +66,53 @@ describe("differential against Map", () => { }); describe("capacity ceiling", () => { - test("fills to the compiled maximum and reads every entry back", async () => { - const table = await SwissU32ToU32.create(MAX_ENTRIES); + test("grows past the 2^20 slots the module used to top out at", async () => { + const table = await SwissU32ToU32.create(); + + for (let i = 0; i < MANY_ENTRIES; i += 1) table.set(i, i ^ 0xa5a5); + + expect(table.size).toBe(MANY_ENTRIES); + + let wrong = 0; + for (let i = 0; i < MANY_ENTRIES; i += 1) { + if (table.get(i) !== (i ^ 0xa5a5)) wrong += 1; + } + expect(wrong).toBe(0); + }); + + test("fills a pre-sized table and reads every entry back", async () => { + const table = await SwissU32ToU32.create(MANY_ENTRIES); - for (let i = 0; i < MAX_ENTRIES; i += 1) table.set(i, i ^ 0xa5a5); + for (let i = 0; i < MANY_ENTRIES; i += 1) table.set(i, i ^ 0xa5a5); - expect(table.size).toBe(MAX_ENTRIES); + expect(table.size).toBe(MANY_ENTRIES); let wrong = 0; - for (let i = 0; i < MAX_ENTRIES; i += 1) { + for (let i = 0; i < MANY_ENTRIES; i += 1) { if (table.get(i) !== (i ^ 0xa5a5)) wrong += 1; } expect(wrong).toBe(0); }); - // A full table has no room for a new key, but overwriting one that is - // already present needs none, so set() has to look for the key before it - // reserves space rather than after. - test("overwrites at capacity but rejects a new key", async () => { - const table = await SwissU32ToU32.create(MAX_ENTRIES); - for (let i = 0; i < MAX_ENTRIES; i += 1) table.set(i, i); + // A refusal has to leave the table exactly as it was: reserve() computes + // its target capacity and checks the ceiling before it rehashes anything. + test("a refused reserve leaves every entry in place", async () => { + const table = await SwissU32ToU32.create(1000); + for (let i = 0; i < 1000; i += 1) table.set(i, i); + + const before = table.capacity; + + expect(() => table.reserve(MAX_ENTRIES + 1)).toThrow(RangeError); + + expect(table.capacity).toBe(before); + expect(table.size).toBe(1000); + + let wrong = 0; + for (let i = 0; i < 1000; i += 1) if (table.get(i) !== i) wrong += 1; + expect(wrong).toBe(0); - expect(() => table.set(5, 12_345)).not.toThrow(); - expect(table.get(5)).toBe(12_345); - expect(() => table.set(MAX_ENTRIES + 1, 1)).toThrow(RangeError); + table.set(1, 12_345); + expect(table.get(1)).toBe(12_345); }); test("rejects a create beyond capacity without poisoning later creates", async () => { @@ -142,7 +175,7 @@ describe("reserve", () => { table.reserve(10); expect(table.capacity).toBe(before); - expect(() => table.reserve(2_000_000)).toThrow(RangeError); + expect(() => table.reserve(MAX_ENTRIES + 1)).toThrow(RangeError); expect(table.capacity).toBe(before); table.set(1, 1); diff --git a/test/swiss-u32.test.ts b/test/swiss-u32.test.ts index 62d67be..84b3116 100644 --- a/test/swiss-u32.test.ts +++ b/test/swiss-u32.test.ts @@ -6,6 +6,16 @@ const WASM_PATH = new URL("../dist/wasm/swiss_u32.wasm", import.meta.url); const wasmFile = Bun.file(WASM_PATH); const wasmBuilt = await wasmFile.exists(); +/** + * The same module built at 2^16 slots, so its ceiling is 57,344 entries + * rather than the shipped 117,440,512 and a test can actually reach it. + * See CAPPED_U32 in scripts/build-wasm.ts. + */ +const cappedFile = Bun.file( + new URL("../dist/wasm/swiss_u32_capped.wasm", import.meta.url), +); +const cappedBuilt = await cappedFile.exists(); + // Requires `bun run build`; skipped when the module has not been compiled. describe.skipIf(!wasmBuilt)("SwissU32ToU32", () => { async function loadTable(expectedEntries = 1024): Promise { @@ -102,8 +112,8 @@ describe.skipIf(!wasmBuilt)("SwissU32ToU32", () => { expect(table.capacity).toBeGreaterThan(0); }); - test("overwrites an existing key on a table at its compiled ceiling", async () => { - const table = await loadTable(0); + test.skipIf(!cappedBuilt)("overwrites an existing key on a table at its compiled ceiling", async () => { + const table = await SwissU32ToU32.load(await cappedFile.arrayBuffer(), 0); // Fill until the compiled capacity is genuinely exhausted. let inserted = 0; @@ -121,14 +131,39 @@ describe.skipIf(!wasmBuilt)("SwissU32ToU32", () => { expect(() => table.set(inserted + 1, 1)).toThrow(RangeError); }); + // A bulk chunk can grow linear memory several times before it runs out of + // slots, and a grow detaches every view the binding caches. The refusal + // has to leave those rebuilt, or the table reads back as empty afterwards. + test.skipIf(!cappedBuilt)("stays readable after a bulk call hits the ceiling", async () => { + const table = await SwissU32ToU32.load(await cappedFile.arrayBuffer(), 0); + + const count = table.maxBatch; + const keys = new Uint32Array(count); + const values = new Uint32Array(count); + for (let i = 0; i < count; i++) { + keys[i] = i; + values[i] = i * 3; + } + + expect(() => table.setMany(keys, values)).toThrow(RangeError); + + expect(table.size).toBeGreaterThan(0); + expect(table.capacity).toBeGreaterThan(0); + expect(table.get(5)).toBe(15); + + const probe = table.getMany(new Uint32Array([1, 2, 3])); + expect(Array.from(probe.values)).toEqual([3, 6, 9]); + expect(Array.from(probe.found)).toEqual([1, 1, 1]); + }); + // A ceiling is reported against what the caller wrote, so the message can // never name a WASM export the public API does not mention. - test("a capacity ceiling names the caller's own argument", async () => { - await expect(SwissU32ToU32.create(2_000_000)).rejects.toThrow( + test.skipIf(!cappedBuilt)("a capacity ceiling names the caller's own argument", async () => { + await expect(SwissU32ToU32.create(200_000_000)).rejects.toThrow( "expectedEntries exceeded the compiled SwissU32ToU32 capacity", ); - const table = await loadTable(0); + const table = await SwissU32ToU32.load(await cappedFile.arrayBuffer(), 0); expect(() => { for (let key = 0; ; key++) table.set(key, key); }).toThrow("set exceeded the compiled SwissU32ToU32 capacity");