diff --git a/Plugins/PackageToJS/Templates/runtime.d.ts b/Plugins/PackageToJS/Templates/runtime.d.ts index 353db3894..912354a6c 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 _valueRefMap; + private _values; + 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 d79275476..24ec8ee85 100644 --- a/Plugins/PackageToJS/Templates/runtime.mjs +++ b/Plugins/PackageToJS/Templates/runtime.mjs @@ -240,38 +240,48 @@ 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._valueRefMap = new Map(); + this._valueRefMap.set(globalVariable, 1); + this._refCounts = []; + this._refCounts[0] = 0; + this._refCounts[1] = 1; + this._freeSlotStack = []; } retain(value) { - const entry = this._heapEntryByValue.get(value); - if (entry) { - entry.rc++; - return entry.id; + const id = this._valueRefMap.get(value); + if (id !== undefined) { + this._refCounts[id]++; + return id; } - const id = this._heapNextKey++; - this._heapValueById.set(id, value); - this._heapEntryByValue.set(value, { id: id, rc: 1 }); - 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) { - return this.retain(this.getObject(ref)); + this._refCounts[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._refCounts[ref] !== 0) return; - this._heapEntryByValue.delete(value); - this._heapValueById.delete(ref); + 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) { - 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..e5273ea93 100644 --- a/Runtime/src/object-heap.ts +++ b/Runtime/src/object-heap.ts @@ -1,54 +1,65 @@ 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 _valueRefMap: Map; + private _values: (any | undefined)[]; + private _refCounts: number[]; + private _freeSlotStack: 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._valueRefMap = new Map(); + this._valueRefMap.set(globalVariable, 1); - // Note: 0 is preserved for invalid references, 1 is preserved for globalThis - this._heapNextKey = 2; + this._refCounts = []; + this._refCounts[0] = 0; + this._refCounts[1] = 1; + + this._freeSlotStack = []; } retain(value: any) { - const entry = this._heapEntryByValue.get(value); - if (entry) { - entry.rc++; - return entry.id; + const id = this._valueRefMap.get(value); + if (id !== undefined) { + this._refCounts[id]++; + return id; } - const id = this._heapNextKey++; - this._heapValueById.set(id, value); - this._heapEntryByValue.set(value, { id: id, rc: 1 }); - 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) { - return this.retain(this.getObject(ref)); + this._refCounts[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._refCounts[ref] !== 0) return; - this._heapEntryByValue.delete(value); - this._heapValueById.delete(ref); + 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._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"