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.d.ts b/Plugins/PackageToJS/Templates/runtime.d.ts index 353db3894..d1ef0eaf5 100644 --- a/Plugins/PackageToJS/Templates/runtime.d.ts +++ b/Plugins/PackageToJS/Templates/runtime.d.ts @@ -2,14 +2,16 @@ type ref = number; type pointer = number; declare class JSObjectSpace { - private _heapValueById; - private _heapEntryByValue; - private _heapNextKey; + private _slotByValue; + private _values; + 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 d79275476..4585004d6 100644 --- a/Plugins/PackageToJS/Templates/runtime.mjs +++ b/Plugins/PackageToJS/Templates/runtime.mjs @@ -238,44 +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._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._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) { - 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) { - return this.retain(this.getObject(ref)); - } - release(ref) { - const value = this._heapValueById.get(ref); - const entry = this._heapEntryByValue.get(value); - entry.rc--; - if (entry.rc != 0) + 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; + let state; + 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) { + 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; - this._heapEntryByValue.delete(value); - this._heapValueById.delete(ref); - } - getObject(ref) { - const value = this._heapValueById.get(ref); - if (value === undefined) { - throw new ReferenceError("Attempted to read invalid reference " + ref); } - 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/.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/_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/_version2.ts b/Runtime/bench/_version2.ts new file mode 100644 index 000000000..de8a7f076 --- /dev/null +++ b/Runtime/bench/_version2.ts @@ -0,0 +1,75 @@ +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 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/_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 new file mode 100644 index 000000000..2d4333fcd --- /dev/null +++ b/Runtime/bench/bench-runner.ts @@ -0,0 +1,152 @@ +/** + * 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 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..fa8363413 100644 --- a/Runtime/src/object-heap.ts +++ b/Runtime/src/object-heap.ts @@ -1,59 +1,113 @@ import { globalVariable } from "./find-global.js"; import { ref } from "./types.js"; -type SwiftRuntimeHeapEntry = { - id: number; - rc: number; -}; +const SLOT_BITS = 22; +const SLOT_MASK = (1 << SLOT_BITS) - 1; +const GEN_MASK = (1 << (32 - SLOT_BITS)) - 1; + export class JSObjectSpace { - private _heapValueById: Map; - private _heapEntryByValue: Map; - private _heapNextKey: number; + private _slotByValue: Map; + private _values: (any | undefined)[]; + private _stateBySlot: number[]; + private _freeSlotStack: number[]; constructor() { - this._heapValueById = new Map(); - this._heapValueById.set(1, globalVariable); - - this._heapEntryByValue = new Map(); - this._heapEntryByValue.set(globalVariable, { id: 1, rc: 1 }); + this._slotByValue = new Map(); + this._values = []; + this._stateBySlot = []; + this._freeSlotStack = []; - // Note: 0 is preserved for invalid references, 1 is preserved for globalThis - this._heapNextKey = 2; + 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 entry = this._heapEntryByValue.get(value); - if (entry) { - entry.rc++; - return entry.id; + 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; } - const id = this._heapNextKey++; - this._heapValueById.set(id, value); - this._heapEntryByValue.set(value, { id: id, rc: 1 }); - return id; + + this._stateBySlot[newSlot] = state; + this._values[newSlot] = value; + this._slotByValue.set(value, newSlot); + return ((state & ~SLOT_MASK) | newSlot) >>> 0; } - retainByRef(ref: ref) { - return this.retain(this.getObject(ref)); + 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(ref: ref) { - const value = this._heapValueById.get(ref); - const entry = this._heapEntryByValue.get(value)!; - entry.rc--; - if (entry.rc != 0) return; + 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); + } - this._heapEntryByValue.delete(value); - this._heapValueById.delete(ref); + getObject(reference: ref) { + this._getValidatedSlotState(reference); + return this._values[reference & SLOT_MASK]; } - getObject(ref: ref) { - const value = this._heapValueById.get(ref); - if (value === undefined) { + // 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 read invalid reference " + ref, + "Attempted to use stale reference " + reference, ); } - return value; + return state; } } 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/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..