From dbb51e1afd76fc4bc5684b6c8ed62c58b6a17ed3 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 1/6] 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 40df92237849c91acb15cc12171d1495f891bad5 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 2/6] 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 cd9c52f504c9af47b292d943408174248d19e616 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 3/6] 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 96207357d9ac11fff54cfc9afd643b4a26c0b55c 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 4/6] 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 e7b5b8ce612bc5be897cc4ab5bf698f50931a7e4 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 5/6] 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 6/6] 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 } ];