feat: Add opt-in identityMode pointer for SwiftHeapObject wrapper identity caching#2
feat: Add opt-in identityMode pointer for SwiftHeapObject wrapper identity caching#2
Conversation
…ntity caching Add identityMode: "pointer" option to BridgeJS instantiation. When enabled, a WeakRef-based identity cache keyed by pointer ensures the same Swift heap pointer returns the same JS wrapper (=== equality). Each class gets its own FinalizationRegistry and identity cache stored on the deinit function. Off by default, zero overhead when not enabled.
Replace per-class FinalizationRegistry instances with a single shared registry at module level. Move identity cache from deinit function property to per-class static __identityCache field. Cleaner codegen, fewer allocations, easier to inspect in DevTools.
…ence Each boundary crossing calls passRetained on the Swift side. On cache hit, the wrapper is returned without creating a new FinalizationRegistry entry, leaving the retain unbalanced. Call deinit(pointer) on cache hit to immediately release the extra retain. Also fix deinit reference for namespaced classes to use abiName instead of short class name.
Add Tests/BridgeJSRuntimeTests/IdentityModeSupportTests module covering: - Wrapper identity for shared Swift objects - Cache invalidation on release - Different classes don't collide on same pointer - Retain leak regression test for cache hits - Array identity preservation Wire IDENTITY_MODE env var through prelude.mjs to toggle instantiateOptions. Add unittest-pointer Makefile target for running tests with identityMode: "pointer".
57406e0 to
a865a00
Compare
Restore the no-op polyfill pattern for environments without FinalizationRegistry instead of null, matching the upstream convention. Remove finalizer parameter from makeFresh since the polyfill is always callable. Use has() guard before stale WeakRef cleanup. Remove formatting-only changes from instantiate.d.ts.
…ToGlobal pattern Add identityMode field to BridgeJSConfig, flow through SwiftToSkeleton and ExportedSkeleton to BridgeJSLink. Generated JS uses config value as default with runtime option as override via nullish coalescing. Create dedicated BridgeJSIdentityTests target with identityMode: pointer in its bridge-js.config.json. Remove IDENTITY_MODE env var, instantiateOptions spread from prelude.mjs, and unittest-pointer Makefile target. Identity tests now run as part of the normal test suite.
…x.js TypeScript strict excess property check rejects identityMode in the spread into DefaultNodeSetupOptions. Destructure it out before spreading, since it's already handled separately via the instantiateOptions pass-through.
…generate script The Generated files were incorrectly copied from BridgeJSRuntimeTests, containing types from the wrong module. Regenerate with BridgeJSTool for the BridgeJSIdentityTests target. Add target to bridge-js-generate.sh. Fix SwiftToSkeleton formatting.
When multiple targets share one createInstantiator (e.g. test package), use compactMap to find the first non-nil identityMode across all skeletons instead of reading from the first skeleton which may not have it set.
Extend run.js with --identity-mode, --identity-iterations, --identity-reuse-pools, and --identity-memory CLI flags. Extract identity scenarios into lib/identity-benchmarks.js: roundtrip reuse, bulk pool return (100 cached objects), churn (create-roundtrip-release), consume, and create paths. Memory telemetry via --identity-memory. Update README.md with identity mode flags and scenario descriptions.
Add IdentityCacheBenchmark with setupPool/getPoolRepeated for bulk array return scenarios. Update generated BridgeJS bindings for benchmark target.
Benchmark ResultsRelease build, adaptive sampling with IQR outlier removal. arm64-apple-macosx26.0, Node.js v22.22.0. Performance
What each scenario exercises
Memory (
|
| Metric | none | pointer | Reduction |
|---|---|---|---|
| Avg duration | 328 ms | 81 ms | 4.0x |
| Peak JS heap delta | 271 MiB | 41 MiB | 6.6x |
| Retained heap delta (pre-GC) | 271 MiB | 21 MiB | 12.9x |
| Post-GC delta | 154 MiB | ~0 | full reclamation |
In none mode, 1M distinct wrapper objects accumulate in the retained array. In pointer mode, all 1M array slots resolve to the same wrapper, so the array itself holds one live object and 999,999 duplicate references to it — negligible heap cost. Post-GC: pointer mode fully reclaims (delta ≈ 0); none mode leaves 154 MiB still live because the GC cannot collect wrappers the array is still pointing at during the measurement window.
Reproducing
swift package --swift-sdk $SWIFT_SDK_ID js -c release
node --expose-gc run.js --adaptive --identity-mode=both --identity-iterations=1000000For memory profiling:
node --expose-gc run.js --adaptive --identity-mode=both --identity-memorySee Benchmarks/README.md for full CLI reference.
Benchmark results can be noisy due to GC timing and V8 JIT compilation. IQR filtering discards values outside Q1-1.5*IQR to Q3+1.5*IQR before computing statistics. The Samples column shows retained count (e.g. '4 (-1)' means 4 kept, 1 discarded). Falls back to the full dataset if fewer than 4 samples. Applies to all benchmarks, not just identity mode.
6755581 to
2aac6d6
Compare
|
Implementation design summary |
|
Benchmarks overview |
Overview
Add opt-in
identityMode: "pointer"to BridgeJS. When enabled, the same Swift heap pointer always returns the same JS wrapper object (===equality) via a per-classWeakRef-based cache and a single sharedFinalizationRegistry.Off by default, zero overhead when not enabled. Configured via
bridge-js.config.jsonfollowing the same pattern asexposeToGlobal. Runtime override available viaoptions.identityModeat instantiation time.Motivated by PassiveLogic's Khasm project where QuantumInterface model objects cross the boundary repeatedly through relationship traversal.
Links
What changed
BridgeJSLink.swift- Identity cache codegen inSwiftHeapObject.__wrap. Per-classstatic __identityCachewithWeakRefentries. SharedFinalizationRegistrywith noop polyfill for environments without it.deinit(pointer)on cache hit to balancepassRetained. Namespace-qualifiedabiNamefor deinit references. Config-based default viacompactMap(\.identityMode).first.BridgeJSConfig/ExportedSkeleton-identityModefield flows through config → skeleton → linker. Global setting (not per-skeleton likeexposeToGlobal) because it controls the shared__wrapbehavior.nilmeans "no opinion", first explicit value wins.BridgeJSIdentityTests- Dedicated test target withbridge-js.config.jsonsettingidentityMode: "pointer". Tests: wrapper identity, cache invalidation, retain leak regression, array identity, churn.instantiate.d.ts-identityMode?: "none" | "pointer"onInstantiateOptions.index.js- DestructureidentityModeout of node setup options to avoid TS excess property check.bridge-js-generate.sh- AddedBridgeJSIdentityTeststarget.Benchmarks/lib/identity-benchmarks.js- Identity benchmark scenarios: roundtrip reuse, bulk pool return (100 cached objects), churn (create-roundtrip-release), consume, create. Memory telemetry via--identity-memory.Benchmarks/run.js- IQR-based outlier removal for all benchmark statistics. Reports median alongside mean. Identity mode CLI flags.Benchmarks/Sources/Benchmarks.swift-IdentityCacheBenchmarkwithsetupPool/getPoolRepeatedfor bulk array return.Benchmarks/README.md- Identity mode flags and scenario descriptions.Tested
UPDATE_SNAPSHOTS=1 swift test --package-path ./Plugins/BridgeJS