From d09b00cb8a947e1c22c69dd3cfc672e892e4335e Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:37:47 +0100 Subject: [PATCH 01/10] faster object-heap --- Plugins/PackageToJS/Templates/runtime.d.ts | 7 +- Plugins/PackageToJS/Templates/runtime.mjs | 63 ++++++---- Runtime/.gitignore | 1 + Runtime/bench/_original.ts | 61 ++++++++++ Runtime/bench/bench-runner.ts | 131 +++++++++++++++++++++ Runtime/rollup.bench.mjs | 11 ++ Runtime/src/object-heap.ts | 72 ++++++----- Runtime/tsconfig.bench.json | 5 + package.json | 1 + 9 files changed, 297 insertions(+), 55 deletions(-) create mode 100644 Runtime/bench/_original.ts create mode 100644 Runtime/bench/bench-runner.ts create mode 100644 Runtime/rollup.bench.mjs create mode 100644 Runtime/tsconfig.bench.json diff --git a/Plugins/PackageToJS/Templates/runtime.d.ts b/Plugins/PackageToJS/Templates/runtime.d.ts index 353db3894..752fd772f 100644 --- a/Plugins/PackageToJS/Templates/runtime.d.ts +++ b/Plugins/PackageToJS/Templates/runtime.d.ts @@ -2,9 +2,10 @@ type ref = number; type pointer = number; declare class JSObjectSpace { - private _heapValueById; - private _heapEntryByValue; - private _heapNextKey; + private _valueMap; + private _values; + private _rcById; + private _freeStack; constructor(); retain(value: any): number; retainByRef(ref: ref): number; diff --git a/Plugins/PackageToJS/Templates/runtime.mjs b/Plugins/PackageToJS/Templates/runtime.mjs index d79275476..e6783d2e4 100644 --- a/Plugins/PackageToJS/Templates/runtime.mjs +++ b/Plugins/PackageToJS/Templates/runtime.mjs @@ -240,38 +240,55 @@ const globalVariable = globalThis; class JSObjectSpace { constructor() { - this._heapValueById = new Map(); - this._heapValueById.set(1, globalVariable); - this._heapEntryByValue = new Map(); - this._heapEntryByValue.set(globalVariable, { id: 1, rc: 1 }); - // Note: 0 is preserved for invalid references, 1 is preserved for globalThis - this._heapNextKey = 2; + this._values = []; + this._values[0] = undefined; + this._values[1] = globalVariable; + this._valueMap = new Map(); + this._valueMap.set(globalVariable, 1); + this._rcById = []; + this._rcById[0] = 0; + this._rcById[1] = 1; + this._freeStack = []; } retain(value) { - const entry = this._heapEntryByValue.get(value); - if (entry) { - entry.rc++; - return entry.id; - } - const id = this._heapNextKey++; - this._heapValueById.set(id, value); - this._heapEntryByValue.set(value, { id: id, rc: 1 }); - return id; + const id = this._valueMap.get(value); + if (id !== undefined) { + this._rcById[id]++; + return id; + } + if (this._freeStack.length > 0) { + const newId = this._freeStack.pop(); + this._values[newId] = value; + this._rcById[newId] = 1; + this._valueMap.set(value, newId); + return newId; + } + const newId = this._values.length; + this._values[newId] = value; + this._rcById[newId] = 1; + this._valueMap.set(value, newId); + return newId; } retainByRef(ref) { - return this.retain(this.getObject(ref)); + this._rcById[ref]++; + return ref; } release(ref) { - const value = this._heapValueById.get(ref); - const entry = this._heapEntryByValue.get(value); - entry.rc--; - if (entry.rc != 0) + if (--this._rcById[ref] !== 0) return; - this._heapEntryByValue.delete(value); - this._heapValueById.delete(ref); + const value = this._values[ref]; + this._valueMap.delete(value); + if (ref === this._values.length - 1) { + this._values.length = ref; + this._rcById.length = ref; + } + else { + this._values[ref] = undefined; + this._freeStack.push(ref); + } } getObject(ref) { - const value = this._heapValueById.get(ref); + const value = this._values[ref]; if (value === undefined) { throw new ReferenceError("Attempted to read invalid reference " + ref); } diff --git a/Runtime/.gitignore b/Runtime/.gitignore index 99dec66a6..a73d4418b 100644 --- a/Runtime/.gitignore +++ b/Runtime/.gitignore @@ -1,2 +1,3 @@ /lib +/bench/dist /node_modules \ No newline at end of file diff --git a/Runtime/bench/_original.ts b/Runtime/bench/_original.ts new file mode 100644 index 000000000..f0bfb0261 --- /dev/null +++ b/Runtime/bench/_original.ts @@ -0,0 +1,61 @@ +import { globalVariable } from "../src/find-global.js"; +import { ref } from "../src/types.js"; + +type SwiftRuntimeHeapEntry = { + id: number; + rc: number; +}; + +/** Original implementation kept for benchmark comparison. Same API as JSObjectSpace. */ +export class JSObjectSpaceOriginal { + private _heapValueById: Map; + private _heapEntryByValue: Map; + private _heapNextKey: number; + + constructor() { + this._heapValueById = new Map(); + this._heapValueById.set(1, globalVariable); + + this._heapEntryByValue = new Map(); + this._heapEntryByValue.set(globalVariable, { id: 1, rc: 1 }); + + // Note: 0 is preserved for invalid references, 1 is preserved for globalThis + this._heapNextKey = 2; + } + + retain(value: any) { + const entry = this._heapEntryByValue.get(value); + if (entry) { + entry.rc++; + return entry.id; + } + const id = this._heapNextKey++; + this._heapValueById.set(id, value); + this._heapEntryByValue.set(value, { id: id, rc: 1 }); + return id; + } + + retainByRef(ref: ref) { + return this.retain(this.getObject(ref)); + } + + release(ref: ref) { + const value = this._heapValueById.get(ref); + const entry = this._heapEntryByValue.get(value)!; + entry.rc--; + if (entry.rc != 0) return; + + this._heapEntryByValue.delete(value); + this._heapValueById.delete(ref); + } + + getObject(ref: ref) { + const value = this._heapValueById.get(ref); + if (value === undefined) { + throw new ReferenceError( + "Attempted to read invalid reference " + ref, + ); + } + return value; + } +} diff --git a/Runtime/bench/bench-runner.ts b/Runtime/bench/bench-runner.ts new file mode 100644 index 000000000..6f141f25d --- /dev/null +++ b/Runtime/bench/bench-runner.ts @@ -0,0 +1,131 @@ +/** + * Benchmark runner for JSObjectSpace implementations. + * Run with: npm run bench (builds via rollup.bench.mjs, then node bench/dist/bench.mjs) + */ + +import { JSObjectSpace } from "../src/object-heap.js"; +import { JSObjectSpaceOriginal } from "./_original.js"; + +export interface HeapLike { + retain(value: unknown): number; + release(ref: number): void; + getObject(ref: number): unknown; +} + +const ITERATIONS = 5; +const HEAVY_OPS = 200_000; +const FILL_LEVELS = [1_000, 10_000, 50_000] as const; +const MIXED_OPS_PER_LEVEL = 100_000; + +function median(numbers: number[]): number { + const sorted = [...numbers].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 !== 0 + ? sorted[mid]! + : (sorted[mid - 1]! + sorted[mid]!) / 2; +} + +function runHeavyRetain(Heap: new () => HeapLike): number { + const times: number[] = []; + for (let iter = 0; iter < ITERATIONS; iter++) { + const heap = new Heap(); + const start = performance.now(); + for (let i = 0; i < HEAVY_OPS; i++) { + heap.retain({ __i: i }); + } + times.push(performance.now() - start); + } + return median(times); +} + +function runHeavyRelease(Heap: new () => HeapLike): number { + const times: number[] = []; + for (let iter = 0; iter < ITERATIONS; iter++) { + const heap = new Heap(); + const refs: number[] = []; + for (let i = 0; i < HEAVY_OPS; i++) { + refs.push(heap.retain({ __i: i })); + } + const start = performance.now(); + for (let i = 0; i < HEAVY_OPS; i++) { + heap.release(refs[i]!); + } + times.push(performance.now() - start); + } + return median(times); +} + +function runMixedFillLevel(Heap: new () => HeapLike, fillLevel: number): number { + const times: number[] = []; + for (let iter = 0; iter < ITERATIONS; iter++) { + const heap = new Heap(); + const refs: number[] = []; + for (let i = 0; i < fillLevel; i++) { + refs.push(heap.retain({ __i: i })); + } + let nextId = fillLevel; + const start = performance.now(); + for (let i = 0; i < MIXED_OPS_PER_LEVEL; i++) { + const idx = i % fillLevel; + heap.release(refs[idx]!); + refs[idx] = heap.retain({ __i: nextId++ }); + } + times.push(performance.now() - start); + } + return median(times); +} + +function runBenchmark( + name: string, + Heap: new () => HeapLike, +): { name: string; heavyRetain: number; heavyRelease: number; mixed: Record } { + return { + name, + heavyRetain: runHeavyRetain(Heap), + heavyRelease: runHeavyRelease(Heap), + mixed: { + "1k": runMixedFillLevel(Heap, 1_000), + "10k": runMixedFillLevel(Heap, 10_000), + "50k": runMixedFillLevel(Heap, 50_000), + }, + }; +} + +function main() { + const implementations: Array<{ name: string; Heap: new () => HeapLike }> = [ + { name: "JSObjectSpaceOriginal", Heap: JSObjectSpaceOriginal }, + { name: "JSObjectSpace (current)", Heap: JSObjectSpace }, + ]; + + console.log("JSObjectSpace benchmark"); + console.log("======================\n"); + console.log( + `Heavy retain: ${HEAVY_OPS} ops, Heavy release: ${HEAVY_OPS} ops`, + ); + console.log( + `Mixed: ${MIXED_OPS_PER_LEVEL} ops per fill level (${FILL_LEVELS.join(", ")})`, + ); + console.log(`Median of ${ITERATIONS} runs per scenario.\n`); + + const results: Array> = []; + for (const { name, Heap } of implementations) { + console.log(`Running ${name}...`); + runBenchmark(name, Heap); + results.push(runBenchmark(name, Heap)); + } + + console.log("\nResults (median ms):\n"); + const pad = Math.max(...results.map((r) => r.name.length)); + for (const r of results) { + console.log( + `${r.name.padEnd(pad)} retain: ${r.heavyRetain.toFixed(2)}ms release: ${r.heavyRelease.toFixed(2)}ms mixed(1k): ${r.mixed["1k"].toFixed(2)}ms mixed(10k): ${r.mixed["10k"].toFixed(2)}ms mixed(50k): ${r.mixed["50k"].toFixed(2)}ms`, + ); + } + + const total = (r: (typeof results)[0]) => + r.heavyRetain + r.heavyRelease + r.mixed["1k"] + r.mixed["10k"] + r.mixed["50k"]; + const best = results.reduce((a, b) => (total(a) <= total(b) ? a : b)); + console.log(`\nFastest overall (sum of medians): ${best.name}`); +} + +main(); diff --git a/Runtime/rollup.bench.mjs b/Runtime/rollup.bench.mjs new file mode 100644 index 000000000..08534ce0b --- /dev/null +++ b/Runtime/rollup.bench.mjs @@ -0,0 +1,11 @@ +import typescript from "@rollup/plugin-typescript"; + +/** @type {import('rollup').RollupOptions} */ +export default { + input: "bench/bench-runner.ts", + output: { + file: "bench/dist/bench.mjs", + format: "esm", + }, + plugins: [typescript({ tsconfig: "tsconfig.bench.json" })], +}; diff --git a/Runtime/src/object-heap.ts b/Runtime/src/object-heap.ts index ba9cf8021..16abe3135 100644 --- a/Runtime/src/object-heap.ts +++ b/Runtime/src/object-heap.ts @@ -1,54 +1,68 @@ import { globalVariable } from "./find-global.js"; import { ref } from "./types.js"; -type SwiftRuntimeHeapEntry = { - id: number; - rc: number; -}; export class JSObjectSpace { - private _heapValueById: Map; - private _heapEntryByValue: Map; - private _heapNextKey: number; + private _valueMap: Map; + private _values: (any | undefined)[]; + private _rcById: number[]; + private _freeStack: number[]; constructor() { - this._heapValueById = new Map(); - this._heapValueById.set(1, globalVariable); + this._values = []; + this._values[0] = undefined; + this._values[1] = globalVariable; - this._heapEntryByValue = new Map(); - this._heapEntryByValue.set(globalVariable, { id: 1, rc: 1 }); + this._valueMap = new Map(); + this._valueMap.set(globalVariable, 1); - // Note: 0 is preserved for invalid references, 1 is preserved for globalThis - this._heapNextKey = 2; + this._rcById = []; + this._rcById[0] = 0; + this._rcById[1] = 1; + + this._freeStack = []; } retain(value: any) { - const entry = this._heapEntryByValue.get(value); - if (entry) { - entry.rc++; - return entry.id; + const id = this._valueMap.get(value); + if (id !== undefined) { + this._rcById[id]++; + return id; + } + if (this._freeStack.length > 0) { + const newId = this._freeStack.pop()!; + this._values[newId] = value; + this._rcById[newId] = 1; + this._valueMap.set(value, newId); + return newId; } - const id = this._heapNextKey++; - this._heapValueById.set(id, value); - this._heapEntryByValue.set(value, { id: id, rc: 1 }); - return id; + const newId = this._values.length; + this._values[newId] = value; + this._rcById[newId] = 1; + this._valueMap.set(value, newId); + return newId; } retainByRef(ref: ref) { - return this.retain(this.getObject(ref)); + this._rcById[ref]++; + return ref; } release(ref: ref) { - const value = this._heapValueById.get(ref); - const entry = this._heapEntryByValue.get(value)!; - entry.rc--; - if (entry.rc != 0) return; + if (--this._rcById[ref] !== 0) return; - this._heapEntryByValue.delete(value); - this._heapValueById.delete(ref); + const value = this._values[ref]; + this._valueMap.delete(value); + if (ref === this._values.length - 1) { + this._values.length = ref; + this._rcById.length = ref; + } else { + this._values[ref] = undefined; + this._freeStack.push(ref); + } } getObject(ref: ref) { - const value = this._heapValueById.get(ref); + const value = this._values[ref]; if (value === undefined) { throw new ReferenceError( "Attempted to read invalid reference " + ref, diff --git a/Runtime/tsconfig.bench.json b/Runtime/tsconfig.bench.json new file mode 100644 index 000000000..0195bd313 --- /dev/null +++ b/Runtime/tsconfig.bench.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "rootDir": "." }, + "include": ["src/**/*", "bench/**/*"] +} diff --git a/package.json b/package.json index 509cddde2..79c094f70 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build": "npm run build:clean && npm run build:ts", "build:clean": "rm -rf Runtime/lib", "build:ts": "cd Runtime; rollup -c", + "bench": "cd Runtime && rollup -c rollup.bench.mjs && node bench/dist/bench.mjs", "prepublishOnly": "npm run build", "format": "prettier --write Runtime/src", "check:bridgejs-dts": "tsc --project Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/tsconfig.json" From a498069b6b27bcf25ce8b0b88786e8897e5f36c6 Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:21:52 +0100 Subject: [PATCH 02/10] cleanup --- Runtime/src/object-heap.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Runtime/src/object-heap.ts b/Runtime/src/object-heap.ts index 16abe3135..623917f0c 100644 --- a/Runtime/src/object-heap.ts +++ b/Runtime/src/object-heap.ts @@ -28,14 +28,8 @@ export class JSObjectSpace { this._rcById[id]++; return id; } - if (this._freeStack.length > 0) { - const newId = this._freeStack.pop()!; - this._values[newId] = value; - this._rcById[newId] = 1; - this._valueMap.set(value, newId); - return newId; - } - const newId = this._values.length; + + const newId = this._freeStack.length > 0 ? this._freeStack.pop()! : this._values.length; this._values[newId] = value; this._rcById[newId] = 1; this._valueMap.set(value, newId); From 3d2ab5fc8725a204052d0ab992698f3bd5a33379 Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:24:07 +0100 Subject: [PATCH 03/10] renames --- Plugins/PackageToJS/Templates/runtime.d.ts | 6 ++-- Plugins/PackageToJS/Templates/runtime.mjs | 39 +++++++++------------- Runtime/src/object-heap.ts | 38 ++++++++++----------- 3 files changed, 38 insertions(+), 45 deletions(-) diff --git a/Plugins/PackageToJS/Templates/runtime.d.ts b/Plugins/PackageToJS/Templates/runtime.d.ts index 752fd772f..912354a6c 100644 --- a/Plugins/PackageToJS/Templates/runtime.d.ts +++ b/Plugins/PackageToJS/Templates/runtime.d.ts @@ -2,10 +2,10 @@ type ref = number; type pointer = number; declare class JSObjectSpace { - private _valueMap; + private _valueRefMap; private _values; - private _rcById; - private _freeStack; + private _refCounts; + private _freeSlotStack; constructor(); retain(value: any): number; retainByRef(ref: ref): number; diff --git a/Plugins/PackageToJS/Templates/runtime.mjs b/Plugins/PackageToJS/Templates/runtime.mjs index e6783d2e4..24ec8ee85 100644 --- a/Plugins/PackageToJS/Templates/runtime.mjs +++ b/Plugins/PackageToJS/Templates/runtime.mjs @@ -243,48 +243,41 @@ class JSObjectSpace { this._values = []; this._values[0] = undefined; this._values[1] = globalVariable; - this._valueMap = new Map(); - this._valueMap.set(globalVariable, 1); - this._rcById = []; - this._rcById[0] = 0; - this._rcById[1] = 1; - this._freeStack = []; + this._valueRefMap = new Map(); + this._valueRefMap.set(globalVariable, 1); + this._refCounts = []; + this._refCounts[0] = 0; + this._refCounts[1] = 1; + this._freeSlotStack = []; } retain(value) { - const id = this._valueMap.get(value); + const id = this._valueRefMap.get(value); if (id !== undefined) { - this._rcById[id]++; + this._refCounts[id]++; return id; } - if (this._freeStack.length > 0) { - const newId = this._freeStack.pop(); - this._values[newId] = value; - this._rcById[newId] = 1; - this._valueMap.set(value, newId); - return newId; - } - const newId = this._values.length; + const newId = this._freeSlotStack.length > 0 ? this._freeSlotStack.pop() : this._values.length; this._values[newId] = value; - this._rcById[newId] = 1; - this._valueMap.set(value, newId); + this._refCounts[newId] = 1; + this._valueRefMap.set(value, newId); return newId; } retainByRef(ref) { - this._rcById[ref]++; + this._refCounts[ref]++; return ref; } release(ref) { - if (--this._rcById[ref] !== 0) + if (--this._refCounts[ref] !== 0) return; const value = this._values[ref]; - this._valueMap.delete(value); + this._valueRefMap.delete(value); if (ref === this._values.length - 1) { this._values.length = ref; - this._rcById.length = ref; + this._refCounts.length = ref; } else { this._values[ref] = undefined; - this._freeStack.push(ref); + this._freeSlotStack.push(ref); } } getObject(ref) { diff --git a/Runtime/src/object-heap.ts b/Runtime/src/object-heap.ts index 623917f0c..7c834cb91 100644 --- a/Runtime/src/object-heap.ts +++ b/Runtime/src/object-heap.ts @@ -2,56 +2,56 @@ import { globalVariable } from "./find-global.js"; import { ref } from "./types.js"; export class JSObjectSpace { - private _valueMap: Map; + private _valueRefMap: Map; private _values: (any | undefined)[]; - private _rcById: number[]; - private _freeStack: number[]; + private _refCounts: number[]; + private _freeSlotStack: number[]; constructor() { this._values = []; this._values[0] = undefined; this._values[1] = globalVariable; - this._valueMap = new Map(); - this._valueMap.set(globalVariable, 1); + this._valueRefMap = new Map(); + this._valueRefMap.set(globalVariable, 1); - this._rcById = []; - this._rcById[0] = 0; - this._rcById[1] = 1; + this._refCounts = []; + this._refCounts[0] = 0; + this._refCounts[1] = 1; - this._freeStack = []; + this._freeSlotStack = []; } retain(value: any) { - const id = this._valueMap.get(value); + const id = this._valueRefMap.get(value); if (id !== undefined) { - this._rcById[id]++; + this._refCounts[id]++; return id; } - const newId = this._freeStack.length > 0 ? this._freeStack.pop()! : this._values.length; + const newId = this._freeSlotStack.length > 0 ? this._freeSlotStack.pop()! : this._values.length; this._values[newId] = value; - this._rcById[newId] = 1; - this._valueMap.set(value, newId); + this._refCounts[newId] = 1; + this._valueRefMap.set(value, newId); return newId; } retainByRef(ref: ref) { - this._rcById[ref]++; + this._refCounts[ref]++; return ref; } release(ref: ref) { - if (--this._rcById[ref] !== 0) return; + if (--this._refCounts[ref] !== 0) return; const value = this._values[ref]; - this._valueMap.delete(value); + this._valueRefMap.delete(value); if (ref === this._values.length - 1) { this._values.length = ref; - this._rcById.length = ref; + this._refCounts.length = ref; } else { this._values[ref] = undefined; - this._freeStack.push(ref); + this._freeSlotStack.push(ref); } } From 1503f17e1fe988a8361844fa0f32e5e27c994957 Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:35:33 +0100 Subject: [PATCH 04/10] format --- Runtime/src/object-heap.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Runtime/src/object-heap.ts b/Runtime/src/object-heap.ts index 7c834cb91..e5273ea93 100644 --- a/Runtime/src/object-heap.ts +++ b/Runtime/src/object-heap.ts @@ -29,7 +29,10 @@ export class JSObjectSpace { return id; } - const newId = this._freeSlotStack.length > 0 ? this._freeSlotStack.pop()! : this._values.length; + const newId = + this._freeSlotStack.length > 0 + ? this._freeSlotStack.pop()! + : this._values.length; this._values[newId] = value; this._refCounts[newId] = 1; this._valueRefMap.set(value, newId); From 735e2035dc46f226a930dd4e959b6eb5cf9549a5 Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:26:02 +0100 Subject: [PATCH 05/10] WIP: another version --- Makefile | 2 +- Plugins/PackageToJS/Templates/runtime.mjs | 7 +- Runtime/bench/_version2.ts | 67 +++++++++++++++++++ Runtime/bench/bench-runner.ts | 4 +- Runtime/src/object-heap.ts | 6 ++ Tests/JavaScriptKitTests/JSClosureTests.swift | 46 +++++-------- 6 files changed, 99 insertions(+), 33 deletions(-) create mode 100644 Runtime/bench/_version2.ts diff --git a/Makefile b/Makefile index 270eb9b36..6c7315308 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ unittest: swift package --swift-sdk "$(SWIFT_SDK_ID)" \ $(TRACING_ARGS) \ --disable-sandbox \ - js test --prelude ./Tests/prelude.mjs -Xnode --expose-gc + js test --prelude ./Tests/prelude.mjs -Xnode --expose-gc --verbose .PHONY: regenerate_swiftpm_resources regenerate_swiftpm_resources: diff --git a/Plugins/PackageToJS/Templates/runtime.mjs b/Plugins/PackageToJS/Templates/runtime.mjs index 24ec8ee85..9af3c1cf2 100644 --- a/Plugins/PackageToJS/Templates/runtime.mjs +++ b/Plugins/PackageToJS/Templates/runtime.mjs @@ -256,13 +256,18 @@ class JSObjectSpace { this._refCounts[id]++; return id; } - const newId = this._freeSlotStack.length > 0 ? this._freeSlotStack.pop() : this._values.length; + const newId = this._freeSlotStack.length > 0 + ? this._freeSlotStack.pop() + : this._values.length; this._values[newId] = value; this._refCounts[newId] = 1; this._valueRefMap.set(value, newId); return newId; } retainByRef(ref) { + if (this._refCounts[ref] === 0) { + throw new ReferenceError("Attempted to retain invalid reference " + ref); + } this._refCounts[ref]++; return ref; } diff --git a/Runtime/bench/_version2.ts b/Runtime/bench/_version2.ts new file mode 100644 index 000000000..b26049cb8 --- /dev/null +++ b/Runtime/bench/_version2.ts @@ -0,0 +1,67 @@ +import { globalVariable } from "../src/find-global.js"; +import { ref } from "../src/types.js"; + +export class JSObjectSpace_v2 { + private _entryByValue: Map; + private _values: (any | undefined)[]; + private _refCounts: number[]; + private _nextRef: number; + + constructor() { + this._values = []; + this._values[0] = undefined; + this._values[1] = globalVariable; + + this._entryByValue = new Map(); + this._entryByValue.set(globalVariable, { id: 1, rc: 1 }); + + this._refCounts = []; + this._refCounts[0] = 0; + this._refCounts[1] = 1; + + // 0 is invalid, 1 is globalThis. + this._nextRef = 2; + } + + retain(value: any) { + const entry = this._entryByValue.get(value); + if (entry) { + entry.rc++; + this._refCounts[entry.id]++; + return entry.id; + } + + const id = this._nextRef++; + this._values[id] = value; + this._refCounts[id] = 1; + this._entryByValue.set(value, { id, rc: 1 }); + return id; + } + + retainByRef(ref: ref) { + return this.retain(this.getObject(ref)); + } + + release(ref: ref) { + const value = this._values[ref]; + const entry = this._entryByValue.get(value)!; + entry.rc--; + this._refCounts[ref]--; + if (entry.rc != 0) return; + + this._entryByValue.delete(value); + // Keep IDs monotonic; clear slot and leave possible holes. + this._values[ref] = undefined; + this._refCounts[ref] = 0; + } + + getObject(ref: ref) { + const value = this._values[ref]; + if (value === undefined) { + throw new ReferenceError( + "Attempted to read invalid reference " + ref, + ); + } + return value; + } +} diff --git a/Runtime/bench/bench-runner.ts b/Runtime/bench/bench-runner.ts index 6f141f25d..e40c9d19e 100644 --- a/Runtime/bench/bench-runner.ts +++ b/Runtime/bench/bench-runner.ts @@ -5,6 +5,7 @@ import { JSObjectSpace } from "../src/object-heap.js"; import { JSObjectSpaceOriginal } from "./_original.js"; +import { JSObjectSpace_v2 } from "./_version2.js"; export interface HeapLike { retain(value: unknown): number; @@ -94,7 +95,8 @@ function runBenchmark( function main() { const implementations: Array<{ name: string; Heap: new () => HeapLike }> = [ { name: "JSObjectSpaceOriginal", Heap: JSObjectSpaceOriginal }, - { name: "JSObjectSpace (current)", Heap: JSObjectSpace }, + { name: "JSObjectSpace_v2 (ref++, single map)", Heap: JSObjectSpace_v2 }, + { name: "JSObjectSpace (current)", Heap: JSObjectSpace } ]; console.log("JSObjectSpace benchmark"); diff --git a/Runtime/src/object-heap.ts b/Runtime/src/object-heap.ts index e5273ea93..83af07784 100644 --- a/Runtime/src/object-heap.ts +++ b/Runtime/src/object-heap.ts @@ -40,6 +40,12 @@ export class JSObjectSpace { } retainByRef(ref: ref) { + if (this._refCounts[ref] === 0) { + throw new ReferenceError( + "Attempted to retain invalid reference " + ref, + ); + } + this._refCounts[ref]++; return ref; } diff --git a/Tests/JavaScriptKitTests/JSClosureTests.swift b/Tests/JavaScriptKitTests/JSClosureTests.swift index 3d609a9b9..e278656d8 100644 --- a/Tests/JavaScriptKitTests/JSClosureTests.swift +++ b/Tests/JavaScriptKitTests/JSClosureTests.swift @@ -92,52 +92,38 @@ class JSClosureTests: XCTestCase { throw XCTSkip("Missing --expose-gc flag") } - // Step 1: Create many JSClosure instances + // Step 1: Create many source closures and keep only JS references alive. + // These closures must remain callable even after heavy finalizer churn. let obj = JSObject() - var closurePointers: Set = [] let numberOfSourceClosures = 10_000 do { var closures: [JSClosure] = [] for i in 0.. maxClosurePointer { - break + let numberOfProbeClosures = 50_000 + for i in 0.. Date: Mon, 2 Mar 2026 11:40:51 +0100 Subject: [PATCH 06/10] a few version for comparison --- Runtime/bench/_version2.ts | 80 +++++++++++++++++++---------------- Runtime/bench/_version3.ts | 75 ++++++++++++++++++++++++++++++++ Runtime/bench/bench-runner.ts | 2 + 3 files changed, 121 insertions(+), 36 deletions(-) create mode 100644 Runtime/bench/_version3.ts diff --git a/Runtime/bench/_version2.ts b/Runtime/bench/_version2.ts index b26049cb8..de8a7f076 100644 --- a/Runtime/bench/_version2.ts +++ b/Runtime/bench/_version2.ts @@ -2,66 +2,74 @@ import { globalVariable } from "../src/find-global.js"; import { ref } from "../src/types.js"; export class JSObjectSpace_v2 { - private _entryByValue: Map; - private _values: (any | undefined)[]; - private _refCounts: number[]; + private _idByValue: Map; + private _valueById: Record; + private _refCountById: Record; private _nextRef: number; constructor() { - this._values = []; - this._values[0] = undefined; - this._values[1] = globalVariable; - - this._entryByValue = new Map(); - this._entryByValue.set(globalVariable, { id: 1, rc: 1 }); - - this._refCounts = []; - this._refCounts[0] = 0; - this._refCounts[1] = 1; + this._idByValue = new Map(); + this._idByValue.set(globalVariable, 1); + this._valueById = Object.create(null); + this._refCountById = Object.create(null); + this._valueById[1] = globalVariable; + this._refCountById[1] = 1; // 0 is invalid, 1 is globalThis. this._nextRef = 2; } retain(value: any) { - const entry = this._entryByValue.get(value); - if (entry) { - entry.rc++; - this._refCounts[entry.id]++; - return entry.id; + const id = this._idByValue.get(value); + if (id !== undefined) { + this._refCountById[id]!++; + return id; } - const id = this._nextRef++; - this._values[id] = value; - this._refCounts[id] = 1; - this._entryByValue.set(value, { id, rc: 1 }); - return id; + const newId = this._nextRef++; + this._valueById[newId] = value; + this._refCountById[newId] = 1; + this._idByValue.set(value, newId); + return newId; } retainByRef(ref: ref) { - return this.retain(this.getObject(ref)); + const rc = this._refCountById[ref]; + if (rc === undefined) { + throw new ReferenceError( + "Attempted to retain invalid reference " + ref, + ); + } + this._refCountById[ref] = rc + 1; + return ref; } release(ref: ref) { - const value = this._values[ref]; - const entry = this._entryByValue.get(value)!; - entry.rc--; - this._refCounts[ref]--; - if (entry.rc != 0) return; + const rc = this._refCountById[ref]; + if (rc === undefined) { + throw new ReferenceError( + "Attempted to release invalid reference " + ref, + ); + } + const next = rc - 1; + if (next !== 0) { + this._refCountById[ref] = next; + return; + } - this._entryByValue.delete(value); - // Keep IDs monotonic; clear slot and leave possible holes. - this._values[ref] = undefined; - this._refCounts[ref] = 0; + const value = this._valueById[ref]; + this._idByValue.delete(value); + delete this._valueById[ref]; + delete this._refCountById[ref]; } getObject(ref: ref) { - const value = this._values[ref]; - if (value === undefined) { + const rc = this._refCountById[ref]; + if (rc === undefined) { throw new ReferenceError( "Attempted to read invalid reference " + ref, ); } - return value; + return this._valueById[ref]; } } diff --git a/Runtime/bench/_version3.ts b/Runtime/bench/_version3.ts new file mode 100644 index 000000000..43dbc993d --- /dev/null +++ b/Runtime/bench/_version3.ts @@ -0,0 +1,75 @@ +import { globalVariable } from "../src/find-global.js"; +import { ref } from "../src/types.js"; + +export class JSObjectSpace_v3 { + private _idByValue: Map; + private _valueById: Map; + private _refCountById: Map; + private _nextRef: number; + + constructor() { + this._idByValue = new Map(); + this._idByValue.set(globalVariable, 1); + this._valueById = new Map(); + this._refCountById = new Map(); + this._valueById.set(1, globalVariable); + this._refCountById.set(1, 1); + + // 0 is invalid, 1 is globalThis. + this._nextRef = 2; + } + + retain(value: any) { + const id = this._idByValue.get(value); + if (id !== undefined) { + this._refCountById.set(id, this._refCountById.get(id)! + 1); + return id; + } + + const newId = this._nextRef++; + this._valueById.set(newId, value); + this._refCountById.set(newId, 1); + this._idByValue.set(value, newId); + return newId; + } + + retainByRef(ref: ref) { + const rc = this._refCountById.get(ref); + if (rc === undefined) { + throw new ReferenceError( + "Attempted to retain invalid reference " + ref, + ); + } + this._refCountById.set(ref, rc + 1); + return ref; + } + + release(ref: ref) { + const rc = this._refCountById.get(ref); + if (rc === undefined) { + throw new ReferenceError( + "Attempted to release invalid reference " + ref, + ); + } + const next = rc - 1; + if (next !== 0) { + this._refCountById.set(ref, next); + return; + } + + const value = this._valueById.get(ref); + this._idByValue.delete(value); + this._valueById.delete(ref); + this._refCountById.delete(ref); + } + + getObject(ref: ref) { + const rc = this._refCountById.get(ref); + if (rc === undefined) { + throw new ReferenceError( + "Attempted to read invalid reference " + ref, + ); + } + return this._valueById.get(ref); + } +} diff --git a/Runtime/bench/bench-runner.ts b/Runtime/bench/bench-runner.ts index e40c9d19e..5244fc33c 100644 --- a/Runtime/bench/bench-runner.ts +++ b/Runtime/bench/bench-runner.ts @@ -6,6 +6,7 @@ import { JSObjectSpace } from "../src/object-heap.js"; import { JSObjectSpaceOriginal } from "./_original.js"; import { JSObjectSpace_v2 } from "./_version2.js"; +import { JSObjectSpace_v3 } from "./_version3.js"; export interface HeapLike { retain(value: unknown): number; @@ -96,6 +97,7 @@ function main() { const implementations: Array<{ name: string; Heap: new () => HeapLike }> = [ { name: "JSObjectSpaceOriginal", Heap: JSObjectSpaceOriginal }, { name: "JSObjectSpace_v2 (ref++, single map)", Heap: JSObjectSpace_v2 }, + { name: "JSObjectSpace_v3 (ref++, all maps)", Heap: JSObjectSpace_v3 }, { name: "JSObjectSpace (current)", Heap: JSObjectSpace } ]; From 61bb9bedc5064eccce752c31744f276a98c30c08 Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:08:20 +0100 Subject: [PATCH 07/10] added generation tracking version --- Plugins/PackageToJS/Templates/runtime.d.ts | 11 +- Plugins/PackageToJS/Templates/runtime.mjs | 117 ++++++++++++-------- Runtime/bench/_version1.ts | 76 +++++++++++++ Runtime/bench/_version4.ts | 121 ++++++++++++++++++++ Runtime/bench/_version5.ts | 102 +++++++++++++++++ Runtime/bench/bench-runner.ts | 17 +++ Runtime/src/object-heap.ts | 122 +++++++++++++-------- 7 files changed, 470 insertions(+), 96 deletions(-) create mode 100644 Runtime/bench/_version1.ts create mode 100644 Runtime/bench/_version4.ts create mode 100644 Runtime/bench/_version5.ts diff --git a/Plugins/PackageToJS/Templates/runtime.d.ts b/Plugins/PackageToJS/Templates/runtime.d.ts index 912354a6c..d1ef0eaf5 100644 --- a/Plugins/PackageToJS/Templates/runtime.d.ts +++ b/Plugins/PackageToJS/Templates/runtime.d.ts @@ -2,15 +2,16 @@ type ref = number; type pointer = number; declare class JSObjectSpace { - private _valueRefMap; + private _slotByValue; private _values; - private _refCounts; + private _stateBySlot; private _freeSlotStack; constructor(); retain(value: any): number; - retainByRef(ref: ref): number; - release(ref: ref): void; - getObject(ref: ref): any; + retainByRef(reference: ref): number; + release(reference: ref): void; + getObject(reference: ref): any; + private _getValidatedSlotState; } /** diff --git a/Plugins/PackageToJS/Templates/runtime.mjs b/Plugins/PackageToJS/Templates/runtime.mjs index 9af3c1cf2..4585004d6 100644 --- a/Plugins/PackageToJS/Templates/runtime.mjs +++ b/Plugins/PackageToJS/Templates/runtime.mjs @@ -238,59 +238,90 @@ function deserializeError(error) { const globalVariable = globalThis; +const SLOT_BITS = 22; +const SLOT_MASK = (1 << SLOT_BITS) - 1; +const GEN_MASK = (1 << (32 - SLOT_BITS)) - 1; class JSObjectSpace { constructor() { + this._slotByValue = new Map(); this._values = []; + this._stateBySlot = []; + this._freeSlotStack = []; this._values[0] = undefined; this._values[1] = globalVariable; - this._valueRefMap = new Map(); - this._valueRefMap.set(globalVariable, 1); - this._refCounts = []; - this._refCounts[0] = 0; - this._refCounts[1] = 1; - this._freeSlotStack = []; + this._slotByValue.set(globalVariable, 1); + this._stateBySlot[1] = 1; // gen=0, rc=1 } retain(value) { - const id = this._valueRefMap.get(value); - if (id !== undefined) { - this._refCounts[id]++; - return id; - } - const newId = this._freeSlotStack.length > 0 - ? this._freeSlotStack.pop() - : this._values.length; - this._values[newId] = value; - this._refCounts[newId] = 1; - this._valueRefMap.set(value, newId); - return newId; - } - retainByRef(ref) { - if (this._refCounts[ref] === 0) { - throw new ReferenceError("Attempted to retain invalid reference " + ref); - } - this._refCounts[ref]++; - return ref; - } - release(ref) { - if (--this._refCounts[ref] !== 0) - return; - const value = this._values[ref]; - this._valueRefMap.delete(value); - if (ref === this._values.length - 1) { - this._values.length = ref; - this._refCounts.length = ref; + const slot = this._slotByValue.get(value); + if (slot !== undefined) { + const state = this._stateBySlot[slot]; + const nextState = (state + 1) >>> 0; + if ((nextState & SLOT_MASK) === 0) { + throw new RangeError(`Reference count overflow at slot ${slot}`); + } + this._stateBySlot[slot] = nextState; + return ((nextState & ~SLOT_MASK) | slot) >>> 0; } - else { - this._values[ref] = undefined; - this._freeSlotStack.push(ref); + let newSlot; + let state; + if (this._freeSlotStack.length > 0) { + newSlot = this._freeSlotStack.pop(); + const gen = this._stateBySlot[newSlot] >>> SLOT_BITS; + state = ((gen << SLOT_BITS) | 1) >>> 0; } - } - getObject(ref) { - const value = this._values[ref]; - if (value === undefined) { - throw new ReferenceError("Attempted to read invalid reference " + ref); + else { + newSlot = this._values.length; + if (newSlot > SLOT_MASK) { + throw new RangeError(`Reference slot overflow: ${newSlot} exceeds ${SLOT_MASK}`); + } + state = 1; + } + this._stateBySlot[newSlot] = state; + this._values[newSlot] = value; + this._slotByValue.set(value, newSlot); + return ((state & ~SLOT_MASK) | newSlot) >>> 0; + } + retainByRef(reference) { + const state = this._getValidatedSlotState(reference); + const slot = reference & SLOT_MASK; + const nextState = (state + 1) >>> 0; + if ((nextState & SLOT_MASK) === 0) { + throw new RangeError(`Reference count overflow at slot ${slot}`); + } + this._stateBySlot[slot] = nextState; + return reference; + } + release(reference) { + const state = this._getValidatedSlotState(reference); + const slot = reference & SLOT_MASK; + if ((state & SLOT_MASK) > 1) { + this._stateBySlot[slot] = (state - 1) >>> 0; + return; } - return value; + this._slotByValue.delete(this._values[slot]); + this._values[slot] = undefined; + const nextGen = ((state >>> SLOT_BITS) + 1) & GEN_MASK; + this._stateBySlot[slot] = (nextGen << SLOT_BITS) >>> 0; + this._freeSlotStack.push(slot); + } + getObject(reference) { + this._getValidatedSlotState(reference); + return this._values[reference & SLOT_MASK]; + } + // Returns the packed state for the slot, after validating the reference. + _getValidatedSlotState(reference) { + const slot = reference & SLOT_MASK; + if (slot === 0) + throw new ReferenceError("Attempted to use invalid reference " + reference); + const state = this._stateBySlot[slot]; + if (state === undefined || (state & SLOT_MASK) === 0) { + throw new ReferenceError("Attempted to use invalid reference " + reference); + } + if ((state >>> SLOT_BITS) !== (reference >>> SLOT_BITS)) { + throw new ReferenceError("Attempted to use stale reference " + reference); + } + return state; } } diff --git a/Runtime/bench/_version1.ts b/Runtime/bench/_version1.ts new file mode 100644 index 000000000..f8dc73795 --- /dev/null +++ b/Runtime/bench/_version1.ts @@ -0,0 +1,76 @@ +import { globalVariable } from "../src/find-global.js"; +import { ref } from "../src/types.js"; + +export class JSObjectSpace_v1 { + private _valueRefMap: Map; + private _values: (any | undefined)[]; + private _refCounts: number[]; + private _freeSlotStack: number[]; + + constructor() { + this._values = []; + this._values[0] = undefined; + this._values[1] = globalVariable; + + this._valueRefMap = new Map(); + this._valueRefMap.set(globalVariable, 1); + + this._refCounts = []; + this._refCounts[0] = 0; + this._refCounts[1] = 1; + + this._freeSlotStack = []; + } + + retain(value: any) { + const id = this._valueRefMap.get(value); + if (id !== undefined) { + this._refCounts[id]++; + return id; + } + + const newId = + this._freeSlotStack.length > 0 + ? this._freeSlotStack.pop()! + : this._values.length; + this._values[newId] = value; + this._refCounts[newId] = 1; + this._valueRefMap.set(value, newId); + return newId; + } + + retainByRef(ref: ref) { + if (this._refCounts[ref] === 0) { + throw new ReferenceError( + "Attempted to retain invalid reference " + ref, + ); + } + + this._refCounts[ref]++; + return ref; + } + + release(ref: ref) { + if (--this._refCounts[ref] !== 0) return; + + const value = this._values[ref]; + this._valueRefMap.delete(value); + if (ref === this._values.length - 1) { + this._values.length = ref; + this._refCounts.length = ref; + } else { + this._values[ref] = undefined; + this._freeSlotStack.push(ref); + } + } + + getObject(ref: ref) { + const value = this._values[ref]; + if (value === undefined) { + throw new ReferenceError( + "Attempted to read invalid reference " + ref, + ); + } + return value; + } +} diff --git a/Runtime/bench/_version4.ts b/Runtime/bench/_version4.ts new file mode 100644 index 000000000..0ab9c3eb4 --- /dev/null +++ b/Runtime/bench/_version4.ts @@ -0,0 +1,121 @@ +import { globalVariable } from "../src/find-global.js"; +import { ref } from "../src/types.js"; + +const SLOT_BITS = 22; +const SLOT_MASK = (1 << SLOT_BITS) - 1; +const GEN_MASK = (1 << (32 - SLOT_BITS)) - 1; + +export class JSObjectSpace_v4 { + private _slotByValue: Map; + private _values: (any | undefined)[]; + private _refCounts: number[]; + private _generations: number[]; + private _freeSlotStack: number[]; + + constructor() { + this._values = []; + this._values[0] = undefined; + this._values[1] = globalVariable; + + this._slotByValue = new Map(); + this._slotByValue.set(globalVariable, 1); + + this._refCounts = []; + this._refCounts[0] = 0; + this._refCounts[1] = 1; + + // Generation 0 for initial slots. + this._generations = []; + this._generations[0] = 0; + this._generations[1] = 0; + + this._freeSlotStack = []; + } + + private _encodeRef(slot: number): ref { + const generation = this._generations[slot] & GEN_MASK; + return ((generation << SLOT_BITS) | slot) >>> 0; + } + + private _expectValidSlot(reference: ref): number { + const slot = reference & SLOT_MASK; + if (slot === 0) { + throw new ReferenceError( + "Attempted to use invalid reference " + reference, + ); + } + const generation = reference >>> SLOT_BITS; + if ((this._generations[slot]! & GEN_MASK) !== generation) { + throw new ReferenceError( + "Attempted to use stale reference " + reference, + ); + } + const rc = this._refCounts[slot]; + if (rc === undefined || rc === 0) { + throw new ReferenceError( + "Attempted to use invalid reference " + reference, + ); + } + return slot; + } + + retain(value: any) { + const slot = this._slotByValue.get(value); + if (slot !== undefined) { + this._refCounts[slot]++; + return this._encodeRef(slot); + } + + let newSlot: number; + if (this._freeSlotStack.length > 0) { + newSlot = this._freeSlotStack.pop()!; + } else { + newSlot = this._values.length; + if (newSlot >= SLOT_MASK) { + throw new RangeError( + `Reference slot overflow: ${newSlot} exceeds ${SLOT_MASK}`, + ); + } + + if (this._generations[newSlot] === undefined) { + this._generations[newSlot] = 0; + } + } + + this._values[newSlot] = value; + this._refCounts[newSlot] = 1; + this._slotByValue.set(value, newSlot); + return this._encodeRef(newSlot); + } + + retainByRef(reference: ref) { + const slot = this._expectValidSlot(reference); + this._refCounts[slot]++; + // Return the exact incoming ref to preserve identity while live. + return reference; + } + + release(reference: ref) { + const slot = this._expectValidSlot(reference); + if (--this._refCounts[slot] !== 0) return; + + const value = this._values[slot]; + this._slotByValue.delete(value); + this._values[slot] = undefined; + + this._generations[slot] = ((this._generations[slot]! + 1) & GEN_MASK) >>> 0; + + if (slot === this._values.length - 1) { + // Compact trailing holes in fast arrays, but keep generations so + // future reuse of the same slot still gets a new generation. + this._values.length = slot; + this._refCounts.length = slot; + } else { + this._freeSlotStack.push(slot); + } + } + + getObject(reference: ref) { + return this._values[this._expectValidSlot(reference)]; + } +} diff --git a/Runtime/bench/_version5.ts b/Runtime/bench/_version5.ts new file mode 100644 index 000000000..472436796 --- /dev/null +++ b/Runtime/bench/_version5.ts @@ -0,0 +1,102 @@ +import { globalVariable } from "../src/find-global.js"; +import { ref } from "../src/types.js"; + +const SLOT_BITS = 22; +const SLOT_MASK = (1 << SLOT_BITS) - 1; +const GEN_MASK = (1 << (32 - SLOT_BITS)) - 1; + +export class JSObjectSpace_v5 { + private _slotByValue: Map; + private _values: (any | undefined)[]; + private _stateBySlot: number[]; + private _freeSlotStack: number[]; + + constructor() { + this._slotByValue = new Map(); + this._values = []; + this._stateBySlot = []; + this._freeSlotStack = []; + + this._values[0] = undefined; + this._values[1] = globalVariable; + this._slotByValue.set(globalVariable, 1); + this._stateBySlot[1] = 1; // gen=0, rc=1 + } + + retain(value: any) { + const slot = this._slotByValue.get(value); + if (slot !== undefined) { + const state = this._stateBySlot[slot]!; + const nextState = (state + 1) >>> 0; + if ((nextState & SLOT_MASK) === 0) { + throw new RangeError(`Reference count overflow at slot ${slot}`); + } + this._stateBySlot[slot] = nextState; + return ((nextState & ~SLOT_MASK) | slot) >>> 0; + } + + let newSlot: number; + let state: number; + if (this._freeSlotStack.length > 0) { + newSlot = this._freeSlotStack.pop()!; + const gen = this._stateBySlot[newSlot]! >>> SLOT_BITS; + state = ((gen << SLOT_BITS) | 1) >>> 0; + } else { + newSlot = this._values.length; + if (newSlot > SLOT_MASK) { + throw new RangeError(`Reference slot overflow: ${newSlot} exceeds ${SLOT_MASK}`); + } + state = 1; + } + + this._stateBySlot[newSlot] = state; + this._values[newSlot] = value; + this._slotByValue.set(value, newSlot); + return ((state & ~SLOT_MASK) | newSlot) >>> 0; + } + + retainByRef(reference: ref) { + const state = this._getValidatedSlotState(reference); + const slot = reference & SLOT_MASK; + const nextState = (state + 1) >>> 0; + if ((nextState & SLOT_MASK) === 0) { + throw new RangeError(`Reference count overflow at slot ${slot}`); + } + this._stateBySlot[slot] = nextState; + return reference; + } + + release(reference: ref) { + const state = this._getValidatedSlotState(reference); + const slot = reference & SLOT_MASK; + if ((state & SLOT_MASK) > 1) { + this._stateBySlot[slot] = (state - 1) >>> 0; + return; + } + + this._slotByValue.delete(this._values[slot]); + this._values[slot] = undefined; + const nextGen = ((state >>> SLOT_BITS) + 1) & GEN_MASK; + this._stateBySlot[slot] = (nextGen << SLOT_BITS) >>> 0; + this._freeSlotStack.push(slot); + } + + getObject(reference: ref) { + this._getValidatedSlotState(reference); + return this._values[reference & SLOT_MASK]; + } + + // Returns the packed state for the slot, after validating the reference. + private _getValidatedSlotState(reference: ref): number { + const slot = reference & SLOT_MASK; + if (slot === 0) throw new ReferenceError("Attempted to use invalid reference " + reference); + const state = this._stateBySlot[slot]; + if (state === undefined || (state & SLOT_MASK) === 0) { + throw new ReferenceError("Attempted to use invalid reference " + reference); + } + if ((state >>> SLOT_BITS) !== (reference >>> SLOT_BITS)) { + throw new ReferenceError("Attempted to use stale reference " + reference); + } + return state; + } +} diff --git a/Runtime/bench/bench-runner.ts b/Runtime/bench/bench-runner.ts index 5244fc33c..2d4333fcd 100644 --- a/Runtime/bench/bench-runner.ts +++ b/Runtime/bench/bench-runner.ts @@ -5,8 +5,11 @@ import { JSObjectSpace } from "../src/object-heap.js"; import { JSObjectSpaceOriginal } from "./_original.js"; +import { JSObjectSpace_v1 } from "./_version1.js"; import { JSObjectSpace_v2 } from "./_version2.js"; import { JSObjectSpace_v3 } from "./_version3.js"; +import { JSObjectSpace_v4 } from "./_version4.js"; +import { JSObjectSpace_v5 } from "./_version5.js"; export interface HeapLike { retain(value: unknown): number; @@ -18,6 +21,7 @@ const ITERATIONS = 5; const HEAVY_OPS = 200_000; const FILL_LEVELS = [1_000, 10_000, 50_000] as const; const MIXED_OPS_PER_LEVEL = 100_000; +const gcIfAvailable = (globalThis as { gc?: () => void }).gc; function median(numbers: number[]): number { const sorted = [...numbers].sort((a, b) => a - b); @@ -96,8 +100,11 @@ function runBenchmark( function main() { const implementations: Array<{ name: string; Heap: new () => HeapLike }> = [ { name: "JSObjectSpaceOriginal", Heap: JSObjectSpaceOriginal }, + { name: "JSObjectSpace_v1 (reused refs, single map)", Heap: JSObjectSpace_v1 }, { name: "JSObjectSpace_v2 (ref++, single map)", Heap: JSObjectSpace_v2 }, { name: "JSObjectSpace_v3 (ref++, all maps)", Heap: JSObjectSpace_v3 }, + { name: "JSObjectSpace_v4 (gen-tagged refs, single map)", Heap: JSObjectSpace_v4 }, + { name: "JSObjectSpace_v5 (gen-tagged refs, typed state)", Heap: JSObjectSpace_v5 }, { name: "JSObjectSpace (current)", Heap: JSObjectSpace } ]; @@ -110,11 +117,21 @@ function main() { `Mixed: ${MIXED_OPS_PER_LEVEL} ops per fill level (${FILL_LEVELS.join(", ")})`, ); console.log(`Median of ${ITERATIONS} runs per scenario.\n`); + if (!gcIfAvailable) { + console.warn( + "Warning: global.gc is unavailable (run Node with --expose-gc for lower-variance results).", + ); + } const results: Array> = []; for (const { name, Heap } of implementations) { console.log(`Running ${name}...`); + // Reduce cross-implementation variance from pending garbage. + gcIfAvailable?.(); + gcIfAvailable?.(); runBenchmark(name, Heap); + gcIfAvailable?.(); + gcIfAvailable?.(); results.push(runBenchmark(name, Heap)); } diff --git a/Runtime/src/object-heap.ts b/Runtime/src/object-heap.ts index 83af07784..c6b5fe7d7 100644 --- a/Runtime/src/object-heap.ts +++ b/Runtime/src/object-heap.ts @@ -1,76 +1,102 @@ import { globalVariable } from "./find-global.js"; import { ref } from "./types.js"; +const SLOT_BITS = 22; +const SLOT_MASK = (1 << SLOT_BITS) - 1; +const GEN_MASK = (1 << (32 - SLOT_BITS)) - 1; + export class JSObjectSpace { - private _valueRefMap: Map; + private _slotByValue: Map; private _values: (any | undefined)[]; - private _refCounts: number[]; + private _stateBySlot: number[]; private _freeSlotStack: number[]; constructor() { + this._slotByValue = new Map(); this._values = []; + this._stateBySlot = []; + this._freeSlotStack = []; + this._values[0] = undefined; this._values[1] = globalVariable; + this._slotByValue.set(globalVariable, 1); + this._stateBySlot[1] = 1; // gen=0, rc=1 + } - this._valueRefMap = new Map(); - this._valueRefMap.set(globalVariable, 1); + retain(value: any) { + const slot = this._slotByValue.get(value); + if (slot !== undefined) { + const state = this._stateBySlot[slot]!; + const nextState = (state + 1) >>> 0; + if ((nextState & SLOT_MASK) === 0) { + throw new RangeError(`Reference count overflow at slot ${slot}`); + } + this._stateBySlot[slot] = nextState; + return ((nextState & ~SLOT_MASK) | slot) >>> 0; + } - this._refCounts = []; - this._refCounts[0] = 0; - this._refCounts[1] = 1; + let newSlot: number; + let state: number; + if (this._freeSlotStack.length > 0) { + newSlot = this._freeSlotStack.pop()!; + const gen = this._stateBySlot[newSlot]! >>> SLOT_BITS; + state = ((gen << SLOT_BITS) | 1) >>> 0; + } else { + newSlot = this._values.length; + if (newSlot > SLOT_MASK) { + throw new RangeError(`Reference slot overflow: ${newSlot} exceeds ${SLOT_MASK}`); + } + state = 1; + } - this._freeSlotStack = []; + this._stateBySlot[newSlot] = state; + this._values[newSlot] = value; + this._slotByValue.set(value, newSlot); + return ((state & ~SLOT_MASK) | newSlot) >>> 0; } - retain(value: any) { - const id = this._valueRefMap.get(value); - if (id !== undefined) { - this._refCounts[id]++; - return id; + retainByRef(reference: ref) { + const state = this._getValidatedSlotState(reference); + const slot = reference & SLOT_MASK; + const nextState = (state + 1) >>> 0; + if ((nextState & SLOT_MASK) === 0) { + throw new RangeError(`Reference count overflow at slot ${slot}`); } - - const newId = - this._freeSlotStack.length > 0 - ? this._freeSlotStack.pop()! - : this._values.length; - this._values[newId] = value; - this._refCounts[newId] = 1; - this._valueRefMap.set(value, newId); - return newId; + this._stateBySlot[slot] = nextState; + return reference; } - retainByRef(ref: ref) { - if (this._refCounts[ref] === 0) { - throw new ReferenceError( - "Attempted to retain invalid reference " + ref, - ); + release(reference: ref) { + const state = this._getValidatedSlotState(reference); + const slot = reference & SLOT_MASK; + if ((state & SLOT_MASK) > 1) { + this._stateBySlot[slot] = (state - 1) >>> 0; + return; } - this._refCounts[ref]++; - return ref; + this._slotByValue.delete(this._values[slot]); + this._values[slot] = undefined; + const nextGen = ((state >>> SLOT_BITS) + 1) & GEN_MASK; + this._stateBySlot[slot] = (nextGen << SLOT_BITS) >>> 0; + this._freeSlotStack.push(slot); } - release(ref: ref) { - if (--this._refCounts[ref] !== 0) return; - - const value = this._values[ref]; - this._valueRefMap.delete(value); - if (ref === this._values.length - 1) { - this._values.length = ref; - this._refCounts.length = ref; - } else { - this._values[ref] = undefined; - this._freeSlotStack.push(ref); - } + getObject(reference: ref) { + this._getValidatedSlotState(reference); + return this._values[reference & SLOT_MASK]; } - getObject(ref: ref) { - const value = this._values[ref]; - if (value === undefined) { - throw new ReferenceError( - "Attempted to read invalid reference " + ref, - ); + // Returns the packed state for the slot, after validating the reference. + private _getValidatedSlotState(reference: ref): number { + const slot = reference & SLOT_MASK; + if (slot === 0) throw new ReferenceError("Attempted to use invalid reference " + reference); + const state = this._stateBySlot[slot]; + if (state === undefined || (state & SLOT_MASK) === 0) { + throw new ReferenceError("Attempted to use invalid reference " + reference); + } + if ((state >>> SLOT_BITS) !== (reference >>> SLOT_BITS)) { + throw new ReferenceError("Attempted to use stale reference " + reference); } - return value; + return state; } } From 441ea2a0371e2431bc6f9e4d9b095870dfd9d133 Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:21:19 +0100 Subject: [PATCH 08/10] prettier --- Runtime/src/object-heap.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/Runtime/src/object-heap.ts b/Runtime/src/object-heap.ts index c6b5fe7d7..fa8363413 100644 --- a/Runtime/src/object-heap.ts +++ b/Runtime/src/object-heap.ts @@ -29,7 +29,9 @@ export class JSObjectSpace { const state = this._stateBySlot[slot]!; const nextState = (state + 1) >>> 0; if ((nextState & SLOT_MASK) === 0) { - throw new RangeError(`Reference count overflow at slot ${slot}`); + throw new RangeError( + `Reference count overflow at slot ${slot}`, + ); } this._stateBySlot[slot] = nextState; return ((nextState & ~SLOT_MASK) | slot) >>> 0; @@ -44,7 +46,9 @@ export class JSObjectSpace { } else { newSlot = this._values.length; if (newSlot > SLOT_MASK) { - throw new RangeError(`Reference slot overflow: ${newSlot} exceeds ${SLOT_MASK}`); + throw new RangeError( + `Reference slot overflow: ${newSlot} exceeds ${SLOT_MASK}`, + ); } state = 1; } @@ -89,13 +93,20 @@ export class JSObjectSpace { // Returns the packed state for the slot, after validating the reference. private _getValidatedSlotState(reference: ref): number { const slot = reference & SLOT_MASK; - if (slot === 0) throw new ReferenceError("Attempted to use invalid reference " + reference); + if (slot === 0) + throw new ReferenceError( + "Attempted to use invalid reference " + reference, + ); const state = this._stateBySlot[slot]; if (state === undefined || (state & SLOT_MASK) === 0) { - throw new ReferenceError("Attempted to use invalid reference " + reference); + throw new ReferenceError( + "Attempted to use invalid reference " + reference, + ); } - if ((state >>> SLOT_BITS) !== (reference >>> SLOT_BITS)) { - throw new ReferenceError("Attempted to use stale reference " + reference); + if (state >>> SLOT_BITS !== reference >>> SLOT_BITS) { + throw new ReferenceError( + "Attempted to use stale reference " + reference, + ); } return state; } From ec64cd56916876069bf332968ea3a9860b9e088a Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:22:39 +0100 Subject: [PATCH 09/10] removed benchmarking --- Runtime/.gitignore | 1 - Runtime/bench/_original.ts | 61 -------------- Runtime/bench/_version1.ts | 76 ----------------- Runtime/bench/_version2.ts | 75 ----------------- Runtime/bench/_version3.ts | 75 ----------------- Runtime/bench/_version4.ts | 121 --------------------------- Runtime/bench/_version5.ts | 102 ----------------------- Runtime/bench/bench-runner.ts | 152 ---------------------------------- Runtime/rollup.bench.mjs | 11 --- Runtime/tsconfig.bench.json | 5 -- package.json | 1 - 11 files changed, 680 deletions(-) delete mode 100644 Runtime/bench/_original.ts delete mode 100644 Runtime/bench/_version1.ts delete mode 100644 Runtime/bench/_version2.ts delete mode 100644 Runtime/bench/_version3.ts delete mode 100644 Runtime/bench/_version4.ts delete mode 100644 Runtime/bench/_version5.ts delete mode 100644 Runtime/bench/bench-runner.ts delete mode 100644 Runtime/rollup.bench.mjs delete mode 100644 Runtime/tsconfig.bench.json diff --git a/Runtime/.gitignore b/Runtime/.gitignore index a73d4418b..99dec66a6 100644 --- a/Runtime/.gitignore +++ b/Runtime/.gitignore @@ -1,3 +1,2 @@ /lib -/bench/dist /node_modules \ No newline at end of file diff --git a/Runtime/bench/_original.ts b/Runtime/bench/_original.ts deleted file mode 100644 index f0bfb0261..000000000 --- a/Runtime/bench/_original.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { globalVariable } from "../src/find-global.js"; -import { ref } from "../src/types.js"; - -type SwiftRuntimeHeapEntry = { - id: number; - rc: number; -}; - -/** Original implementation kept for benchmark comparison. Same API as JSObjectSpace. */ -export class JSObjectSpaceOriginal { - private _heapValueById: Map; - private _heapEntryByValue: Map; - private _heapNextKey: number; - - constructor() { - this._heapValueById = new Map(); - this._heapValueById.set(1, globalVariable); - - this._heapEntryByValue = new Map(); - this._heapEntryByValue.set(globalVariable, { id: 1, rc: 1 }); - - // Note: 0 is preserved for invalid references, 1 is preserved for globalThis - this._heapNextKey = 2; - } - - retain(value: any) { - const entry = this._heapEntryByValue.get(value); - if (entry) { - entry.rc++; - return entry.id; - } - const id = this._heapNextKey++; - this._heapValueById.set(id, value); - this._heapEntryByValue.set(value, { id: id, rc: 1 }); - return id; - } - - retainByRef(ref: ref) { - return this.retain(this.getObject(ref)); - } - - release(ref: ref) { - const value = this._heapValueById.get(ref); - const entry = this._heapEntryByValue.get(value)!; - entry.rc--; - if (entry.rc != 0) return; - - this._heapEntryByValue.delete(value); - this._heapValueById.delete(ref); - } - - getObject(ref: ref) { - const value = this._heapValueById.get(ref); - if (value === undefined) { - throw new ReferenceError( - "Attempted to read invalid reference " + ref, - ); - } - return value; - } -} diff --git a/Runtime/bench/_version1.ts b/Runtime/bench/_version1.ts deleted file mode 100644 index f8dc73795..000000000 --- a/Runtime/bench/_version1.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { globalVariable } from "../src/find-global.js"; -import { ref } from "../src/types.js"; - -export class JSObjectSpace_v1 { - private _valueRefMap: Map; - private _values: (any | undefined)[]; - private _refCounts: number[]; - private _freeSlotStack: number[]; - - constructor() { - this._values = []; - this._values[0] = undefined; - this._values[1] = globalVariable; - - this._valueRefMap = new Map(); - this._valueRefMap.set(globalVariable, 1); - - this._refCounts = []; - this._refCounts[0] = 0; - this._refCounts[1] = 1; - - this._freeSlotStack = []; - } - - retain(value: any) { - const id = this._valueRefMap.get(value); - if (id !== undefined) { - this._refCounts[id]++; - return id; - } - - const newId = - this._freeSlotStack.length > 0 - ? this._freeSlotStack.pop()! - : this._values.length; - this._values[newId] = value; - this._refCounts[newId] = 1; - this._valueRefMap.set(value, newId); - return newId; - } - - retainByRef(ref: ref) { - if (this._refCounts[ref] === 0) { - throw new ReferenceError( - "Attempted to retain invalid reference " + ref, - ); - } - - this._refCounts[ref]++; - return ref; - } - - release(ref: ref) { - if (--this._refCounts[ref] !== 0) return; - - const value = this._values[ref]; - this._valueRefMap.delete(value); - if (ref === this._values.length - 1) { - this._values.length = ref; - this._refCounts.length = ref; - } else { - this._values[ref] = undefined; - this._freeSlotStack.push(ref); - } - } - - getObject(ref: ref) { - const value = this._values[ref]; - if (value === undefined) { - throw new ReferenceError( - "Attempted to read invalid reference " + ref, - ); - } - return value; - } -} diff --git a/Runtime/bench/_version2.ts b/Runtime/bench/_version2.ts deleted file mode 100644 index de8a7f076..000000000 --- a/Runtime/bench/_version2.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { globalVariable } from "../src/find-global.js"; -import { ref } from "../src/types.js"; - -export class JSObjectSpace_v2 { - private _idByValue: Map; - private _valueById: Record; - private _refCountById: Record; - private _nextRef: number; - - constructor() { - this._idByValue = new Map(); - this._idByValue.set(globalVariable, 1); - this._valueById = Object.create(null); - this._refCountById = Object.create(null); - this._valueById[1] = globalVariable; - this._refCountById[1] = 1; - - // 0 is invalid, 1 is globalThis. - this._nextRef = 2; - } - - retain(value: any) { - const id = this._idByValue.get(value); - if (id !== undefined) { - this._refCountById[id]!++; - return id; - } - - const newId = this._nextRef++; - this._valueById[newId] = value; - this._refCountById[newId] = 1; - this._idByValue.set(value, newId); - return newId; - } - - retainByRef(ref: ref) { - const rc = this._refCountById[ref]; - if (rc === undefined) { - throw new ReferenceError( - "Attempted to retain invalid reference " + ref, - ); - } - this._refCountById[ref] = rc + 1; - return ref; - } - - release(ref: ref) { - const rc = this._refCountById[ref]; - if (rc === undefined) { - throw new ReferenceError( - "Attempted to release invalid reference " + ref, - ); - } - const next = rc - 1; - if (next !== 0) { - this._refCountById[ref] = next; - return; - } - - const value = this._valueById[ref]; - this._idByValue.delete(value); - delete this._valueById[ref]; - delete this._refCountById[ref]; - } - - getObject(ref: ref) { - const rc = this._refCountById[ref]; - if (rc === undefined) { - throw new ReferenceError( - "Attempted to read invalid reference " + ref, - ); - } - return this._valueById[ref]; - } -} diff --git a/Runtime/bench/_version3.ts b/Runtime/bench/_version3.ts deleted file mode 100644 index 43dbc993d..000000000 --- a/Runtime/bench/_version3.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { globalVariable } from "../src/find-global.js"; -import { ref } from "../src/types.js"; - -export class JSObjectSpace_v3 { - private _idByValue: Map; - private _valueById: Map; - private _refCountById: Map; - private _nextRef: number; - - constructor() { - this._idByValue = new Map(); - this._idByValue.set(globalVariable, 1); - this._valueById = new Map(); - this._refCountById = new Map(); - this._valueById.set(1, globalVariable); - this._refCountById.set(1, 1); - - // 0 is invalid, 1 is globalThis. - this._nextRef = 2; - } - - retain(value: any) { - const id = this._idByValue.get(value); - if (id !== undefined) { - this._refCountById.set(id, this._refCountById.get(id)! + 1); - return id; - } - - const newId = this._nextRef++; - this._valueById.set(newId, value); - this._refCountById.set(newId, 1); - this._idByValue.set(value, newId); - return newId; - } - - retainByRef(ref: ref) { - const rc = this._refCountById.get(ref); - if (rc === undefined) { - throw new ReferenceError( - "Attempted to retain invalid reference " + ref, - ); - } - this._refCountById.set(ref, rc + 1); - return ref; - } - - release(ref: ref) { - const rc = this._refCountById.get(ref); - if (rc === undefined) { - throw new ReferenceError( - "Attempted to release invalid reference " + ref, - ); - } - const next = rc - 1; - if (next !== 0) { - this._refCountById.set(ref, next); - return; - } - - const value = this._valueById.get(ref); - this._idByValue.delete(value); - this._valueById.delete(ref); - this._refCountById.delete(ref); - } - - getObject(ref: ref) { - const rc = this._refCountById.get(ref); - if (rc === undefined) { - throw new ReferenceError( - "Attempted to read invalid reference " + ref, - ); - } - return this._valueById.get(ref); - } -} diff --git a/Runtime/bench/_version4.ts b/Runtime/bench/_version4.ts deleted file mode 100644 index 0ab9c3eb4..000000000 --- a/Runtime/bench/_version4.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { globalVariable } from "../src/find-global.js"; -import { ref } from "../src/types.js"; - -const SLOT_BITS = 22; -const SLOT_MASK = (1 << SLOT_BITS) - 1; -const GEN_MASK = (1 << (32 - SLOT_BITS)) - 1; - -export class JSObjectSpace_v4 { - private _slotByValue: Map; - private _values: (any | undefined)[]; - private _refCounts: number[]; - private _generations: number[]; - private _freeSlotStack: number[]; - - constructor() { - this._values = []; - this._values[0] = undefined; - this._values[1] = globalVariable; - - this._slotByValue = new Map(); - this._slotByValue.set(globalVariable, 1); - - this._refCounts = []; - this._refCounts[0] = 0; - this._refCounts[1] = 1; - - // Generation 0 for initial slots. - this._generations = []; - this._generations[0] = 0; - this._generations[1] = 0; - - this._freeSlotStack = []; - } - - private _encodeRef(slot: number): ref { - const generation = this._generations[slot] & GEN_MASK; - return ((generation << SLOT_BITS) | slot) >>> 0; - } - - private _expectValidSlot(reference: ref): number { - const slot = reference & SLOT_MASK; - if (slot === 0) { - throw new ReferenceError( - "Attempted to use invalid reference " + reference, - ); - } - const generation = reference >>> SLOT_BITS; - if ((this._generations[slot]! & GEN_MASK) !== generation) { - throw new ReferenceError( - "Attempted to use stale reference " + reference, - ); - } - const rc = this._refCounts[slot]; - if (rc === undefined || rc === 0) { - throw new ReferenceError( - "Attempted to use invalid reference " + reference, - ); - } - return slot; - } - - retain(value: any) { - const slot = this._slotByValue.get(value); - if (slot !== undefined) { - this._refCounts[slot]++; - return this._encodeRef(slot); - } - - let newSlot: number; - if (this._freeSlotStack.length > 0) { - newSlot = this._freeSlotStack.pop()!; - } else { - newSlot = this._values.length; - if (newSlot >= SLOT_MASK) { - throw new RangeError( - `Reference slot overflow: ${newSlot} exceeds ${SLOT_MASK}`, - ); - } - - if (this._generations[newSlot] === undefined) { - this._generations[newSlot] = 0; - } - } - - this._values[newSlot] = value; - this._refCounts[newSlot] = 1; - this._slotByValue.set(value, newSlot); - return this._encodeRef(newSlot); - } - - retainByRef(reference: ref) { - const slot = this._expectValidSlot(reference); - this._refCounts[slot]++; - // Return the exact incoming ref to preserve identity while live. - return reference; - } - - release(reference: ref) { - const slot = this._expectValidSlot(reference); - if (--this._refCounts[slot] !== 0) return; - - const value = this._values[slot]; - this._slotByValue.delete(value); - this._values[slot] = undefined; - - this._generations[slot] = ((this._generations[slot]! + 1) & GEN_MASK) >>> 0; - - if (slot === this._values.length - 1) { - // Compact trailing holes in fast arrays, but keep generations so - // future reuse of the same slot still gets a new generation. - this._values.length = slot; - this._refCounts.length = slot; - } else { - this._freeSlotStack.push(slot); - } - } - - getObject(reference: ref) { - return this._values[this._expectValidSlot(reference)]; - } -} diff --git a/Runtime/bench/_version5.ts b/Runtime/bench/_version5.ts deleted file mode 100644 index 472436796..000000000 --- a/Runtime/bench/_version5.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { globalVariable } from "../src/find-global.js"; -import { ref } from "../src/types.js"; - -const SLOT_BITS = 22; -const SLOT_MASK = (1 << SLOT_BITS) - 1; -const GEN_MASK = (1 << (32 - SLOT_BITS)) - 1; - -export class JSObjectSpace_v5 { - private _slotByValue: Map; - private _values: (any | undefined)[]; - private _stateBySlot: number[]; - private _freeSlotStack: number[]; - - constructor() { - this._slotByValue = new Map(); - this._values = []; - this._stateBySlot = []; - this._freeSlotStack = []; - - this._values[0] = undefined; - this._values[1] = globalVariable; - this._slotByValue.set(globalVariable, 1); - this._stateBySlot[1] = 1; // gen=0, rc=1 - } - - retain(value: any) { - const slot = this._slotByValue.get(value); - if (slot !== undefined) { - const state = this._stateBySlot[slot]!; - const nextState = (state + 1) >>> 0; - if ((nextState & SLOT_MASK) === 0) { - throw new RangeError(`Reference count overflow at slot ${slot}`); - } - this._stateBySlot[slot] = nextState; - return ((nextState & ~SLOT_MASK) | slot) >>> 0; - } - - let newSlot: number; - let state: number; - if (this._freeSlotStack.length > 0) { - newSlot = this._freeSlotStack.pop()!; - const gen = this._stateBySlot[newSlot]! >>> SLOT_BITS; - state = ((gen << SLOT_BITS) | 1) >>> 0; - } else { - newSlot = this._values.length; - if (newSlot > SLOT_MASK) { - throw new RangeError(`Reference slot overflow: ${newSlot} exceeds ${SLOT_MASK}`); - } - state = 1; - } - - this._stateBySlot[newSlot] = state; - this._values[newSlot] = value; - this._slotByValue.set(value, newSlot); - return ((state & ~SLOT_MASK) | newSlot) >>> 0; - } - - retainByRef(reference: ref) { - const state = this._getValidatedSlotState(reference); - const slot = reference & SLOT_MASK; - const nextState = (state + 1) >>> 0; - if ((nextState & SLOT_MASK) === 0) { - throw new RangeError(`Reference count overflow at slot ${slot}`); - } - this._stateBySlot[slot] = nextState; - return reference; - } - - release(reference: ref) { - const state = this._getValidatedSlotState(reference); - const slot = reference & SLOT_MASK; - if ((state & SLOT_MASK) > 1) { - this._stateBySlot[slot] = (state - 1) >>> 0; - return; - } - - this._slotByValue.delete(this._values[slot]); - this._values[slot] = undefined; - const nextGen = ((state >>> SLOT_BITS) + 1) & GEN_MASK; - this._stateBySlot[slot] = (nextGen << SLOT_BITS) >>> 0; - this._freeSlotStack.push(slot); - } - - getObject(reference: ref) { - this._getValidatedSlotState(reference); - return this._values[reference & SLOT_MASK]; - } - - // Returns the packed state for the slot, after validating the reference. - private _getValidatedSlotState(reference: ref): number { - const slot = reference & SLOT_MASK; - if (slot === 0) throw new ReferenceError("Attempted to use invalid reference " + reference); - const state = this._stateBySlot[slot]; - if (state === undefined || (state & SLOT_MASK) === 0) { - throw new ReferenceError("Attempted to use invalid reference " + reference); - } - if ((state >>> SLOT_BITS) !== (reference >>> SLOT_BITS)) { - throw new ReferenceError("Attempted to use stale reference " + reference); - } - return state; - } -} diff --git a/Runtime/bench/bench-runner.ts b/Runtime/bench/bench-runner.ts deleted file mode 100644 index 2d4333fcd..000000000 --- a/Runtime/bench/bench-runner.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Benchmark runner for JSObjectSpace implementations. - * Run with: npm run bench (builds via rollup.bench.mjs, then node bench/dist/bench.mjs) - */ - -import { JSObjectSpace } from "../src/object-heap.js"; -import { JSObjectSpaceOriginal } from "./_original.js"; -import { JSObjectSpace_v1 } from "./_version1.js"; -import { JSObjectSpace_v2 } from "./_version2.js"; -import { JSObjectSpace_v3 } from "./_version3.js"; -import { JSObjectSpace_v4 } from "./_version4.js"; -import { JSObjectSpace_v5 } from "./_version5.js"; - -export interface HeapLike { - retain(value: unknown): number; - release(ref: number): void; - getObject(ref: number): unknown; -} - -const ITERATIONS = 5; -const HEAVY_OPS = 200_000; -const FILL_LEVELS = [1_000, 10_000, 50_000] as const; -const MIXED_OPS_PER_LEVEL = 100_000; -const gcIfAvailable = (globalThis as { gc?: () => void }).gc; - -function median(numbers: number[]): number { - const sorted = [...numbers].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 !== 0 - ? sorted[mid]! - : (sorted[mid - 1]! + sorted[mid]!) / 2; -} - -function runHeavyRetain(Heap: new () => HeapLike): number { - const times: number[] = []; - for (let iter = 0; iter < ITERATIONS; iter++) { - const heap = new Heap(); - const start = performance.now(); - for (let i = 0; i < HEAVY_OPS; i++) { - heap.retain({ __i: i }); - } - times.push(performance.now() - start); - } - return median(times); -} - -function runHeavyRelease(Heap: new () => HeapLike): number { - const times: number[] = []; - for (let iter = 0; iter < ITERATIONS; iter++) { - const heap = new Heap(); - const refs: number[] = []; - for (let i = 0; i < HEAVY_OPS; i++) { - refs.push(heap.retain({ __i: i })); - } - const start = performance.now(); - for (let i = 0; i < HEAVY_OPS; i++) { - heap.release(refs[i]!); - } - times.push(performance.now() - start); - } - return median(times); -} - -function runMixedFillLevel(Heap: new () => HeapLike, fillLevel: number): number { - const times: number[] = []; - for (let iter = 0; iter < ITERATIONS; iter++) { - const heap = new Heap(); - const refs: number[] = []; - for (let i = 0; i < fillLevel; i++) { - refs.push(heap.retain({ __i: i })); - } - let nextId = fillLevel; - const start = performance.now(); - for (let i = 0; i < MIXED_OPS_PER_LEVEL; i++) { - const idx = i % fillLevel; - heap.release(refs[idx]!); - refs[idx] = heap.retain({ __i: nextId++ }); - } - times.push(performance.now() - start); - } - return median(times); -} - -function runBenchmark( - name: string, - Heap: new () => HeapLike, -): { name: string; heavyRetain: number; heavyRelease: number; mixed: Record } { - return { - name, - heavyRetain: runHeavyRetain(Heap), - heavyRelease: runHeavyRelease(Heap), - mixed: { - "1k": runMixedFillLevel(Heap, 1_000), - "10k": runMixedFillLevel(Heap, 10_000), - "50k": runMixedFillLevel(Heap, 50_000), - }, - }; -} - -function main() { - const implementations: Array<{ name: string; Heap: new () => HeapLike }> = [ - { name: "JSObjectSpaceOriginal", Heap: JSObjectSpaceOriginal }, - { name: "JSObjectSpace_v1 (reused refs, single map)", Heap: JSObjectSpace_v1 }, - { name: "JSObjectSpace_v2 (ref++, single map)", Heap: JSObjectSpace_v2 }, - { name: "JSObjectSpace_v3 (ref++, all maps)", Heap: JSObjectSpace_v3 }, - { name: "JSObjectSpace_v4 (gen-tagged refs, single map)", Heap: JSObjectSpace_v4 }, - { name: "JSObjectSpace_v5 (gen-tagged refs, typed state)", Heap: JSObjectSpace_v5 }, - { name: "JSObjectSpace (current)", Heap: JSObjectSpace } - ]; - - console.log("JSObjectSpace benchmark"); - console.log("======================\n"); - console.log( - `Heavy retain: ${HEAVY_OPS} ops, Heavy release: ${HEAVY_OPS} ops`, - ); - console.log( - `Mixed: ${MIXED_OPS_PER_LEVEL} ops per fill level (${FILL_LEVELS.join(", ")})`, - ); - console.log(`Median of ${ITERATIONS} runs per scenario.\n`); - if (!gcIfAvailable) { - console.warn( - "Warning: global.gc is unavailable (run Node with --expose-gc for lower-variance results).", - ); - } - - const results: Array> = []; - for (const { name, Heap } of implementations) { - console.log(`Running ${name}...`); - // Reduce cross-implementation variance from pending garbage. - gcIfAvailable?.(); - gcIfAvailable?.(); - runBenchmark(name, Heap); - gcIfAvailable?.(); - gcIfAvailable?.(); - results.push(runBenchmark(name, Heap)); - } - - console.log("\nResults (median ms):\n"); - const pad = Math.max(...results.map((r) => r.name.length)); - for (const r of results) { - console.log( - `${r.name.padEnd(pad)} retain: ${r.heavyRetain.toFixed(2)}ms release: ${r.heavyRelease.toFixed(2)}ms mixed(1k): ${r.mixed["1k"].toFixed(2)}ms mixed(10k): ${r.mixed["10k"].toFixed(2)}ms mixed(50k): ${r.mixed["50k"].toFixed(2)}ms`, - ); - } - - const total = (r: (typeof results)[0]) => - r.heavyRetain + r.heavyRelease + r.mixed["1k"] + r.mixed["10k"] + r.mixed["50k"]; - const best = results.reduce((a, b) => (total(a) <= total(b) ? a : b)); - console.log(`\nFastest overall (sum of medians): ${best.name}`); -} - -main(); diff --git a/Runtime/rollup.bench.mjs b/Runtime/rollup.bench.mjs deleted file mode 100644 index 08534ce0b..000000000 --- a/Runtime/rollup.bench.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import typescript from "@rollup/plugin-typescript"; - -/** @type {import('rollup').RollupOptions} */ -export default { - input: "bench/bench-runner.ts", - output: { - file: "bench/dist/bench.mjs", - format: "esm", - }, - plugins: [typescript({ tsconfig: "tsconfig.bench.json" })], -}; diff --git a/Runtime/tsconfig.bench.json b/Runtime/tsconfig.bench.json deleted file mode 100644 index 0195bd313..000000000 --- a/Runtime/tsconfig.bench.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { "rootDir": "." }, - "include": ["src/**/*", "bench/**/*"] -} diff --git a/package.json b/package.json index 79c094f70..509cddde2 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "build": "npm run build:clean && npm run build:ts", "build:clean": "rm -rf Runtime/lib", "build:ts": "cd Runtime; rollup -c", - "bench": "cd Runtime && rollup -c rollup.bench.mjs && node bench/dist/bench.mjs", "prepublishOnly": "npm run build", "format": "prettier --write Runtime/src", "check:bridgejs-dts": "tsc --project Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/tsconfig.json" From 2e1d2f957ed4174ebf3c72991648703063f3ea69 Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:25:01 +0100 Subject: [PATCH 10/10] makefile --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6c7315308..270eb9b36 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ unittest: swift package --swift-sdk "$(SWIFT_SDK_ID)" \ $(TRACING_ARGS) \ --disable-sandbox \ - js test --prelude ./Tests/prelude.mjs -Xnode --expose-gc --verbose + js test --prelude ./Tests/prelude.mjs -Xnode --expose-gc .PHONY: regenerate_swiftpm_resources regenerate_swiftpm_resources: