Skip to content

feat: Add opt-in identityMode pointer for SwiftHeapObject wrapper identity caching#2

Merged
krodak merged 12 commits intomainfrom
feat/pointer-mode-v2
Apr 21, 2026
Merged

feat: Add opt-in identityMode pointer for SwiftHeapObject wrapper identity caching#2
krodak merged 12 commits intomainfrom
feat/pointer-mode-v2

Conversation

@krodak
Copy link
Copy Markdown
Collaborator

@krodak krodak commented Apr 17, 2026

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-class WeakRef-based cache and a single shared FinalizationRegistry.

Off by default, zero overhead when not enabled. Configured via bridge-js.config.json following the same pattern as exposeToGlobal. Runtime override available via options.identityMode at 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 in SwiftHeapObject.__wrap. Per-class static __identityCache with WeakRef entries. Shared FinalizationRegistry with noop polyfill for environments without it. deinit(pointer) on cache hit to balance passRetained. Namespace-qualified abiName for deinit references. Config-based default via compactMap(\.identityMode).first.
  • BridgeJSConfig / ExportedSkeleton - identityMode field flows through config → skeleton → linker. Global setting (not per-skeleton like exposeToGlobal) because it controls the shared __wrap behavior. nil means "no opinion", first explicit value wins.
  • BridgeJSIdentityTests - Dedicated test target with bridge-js.config.json setting identityMode: "pointer". Tests: wrapper identity, cache invalidation, retain leak regression, array identity, churn.
  • instantiate.d.ts - identityMode?: "none" | "pointer" on InstantiateOptions.
  • index.js - Destructure identityMode out of node setup options to avoid TS excess property check.
  • bridge-js-generate.sh - Added BridgeJSIdentityTests target.
  • 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 - IdentityCacheBenchmark with setupPool/getPoolRepeated for bulk array return.
  • Benchmarks/README.md - Identity mode flags and scenario descriptions.

Tested

  • UPDATE_SNAPSHOTS=1 swift test --package-path ./Plugins/BridgeJS
  • CI: all 13 checks green (format, prettier, check-bridgejs-generated, BridgeJS tests 6.1/6.2/6.3, native build, build-examples, Build and Test × 4)
  • Retain leak regression: verified test fails without fix, passes with fix
  • Identity benchmarks: release build, adaptive sampling with IQR filtering

@krodak krodak marked this pull request as draft April 17, 2026 09:17
@krodak krodak self-assigned this Apr 17, 2026
krodak added 4 commits April 17, 2026 11:17
…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".
@krodak krodak force-pushed the feat/pointer-mode-v2 branch from 57406e0 to a865a00 Compare April 17, 2026 09:18
krodak added 5 commits April 17, 2026 11:25
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.
@krodak krodak marked this pull request as ready for review April 17, 2026 14:26
krodak added 2 commits April 21, 2026 11:54
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.
@krodak
Copy link
Copy Markdown
Collaborator Author

krodak commented Apr 21, 2026

Benchmark Results

Release build, adaptive sampling with IQR outlier removal. arm64-apple-macosx26.0, Node.js v22.22.0.

Performance

Scenario Iterations none pointer Result
passBothWaysRoundtrip 1M 245 ms 64 ms 3.8x faster
getPoolRepeated_100 10k × 100 288 ms 86 ms 3.4x faster
churnObjects 100k 133 ms 117 ms 1.1x faster
swiftConsumesSameObject 1M 21 ms 30 ms 1.4x slower
swiftCreatesObject 1M 696 ms 2147 ms 3.1x slower

What each scenario exercises

passBothWaysRoundtrip — The core reuse path. JS holds a single SimpleClass wrapper and passes it to Swift via roundtripSimpleClass() which immediately returns it back, 1 million times. In pointer mode every call after the first is a cache hit: the lookup resolves to the existing wrapper and deinit is called immediately to balance the passRetained Swift already issued. In none mode, a fresh wrapper is allocated and discarded on every iteration — 1 million short-lived objects competing for GC.

getPoolRepeated_100 — Bulk collection return. Swift holds a pre-built pool of 100 SimpleClass objects and returns the whole array on each getPoolRepeated() call, called 10k times (= 1M total object crossings). This models building.floors, zone.sensors, or any property that returns a cached collection repeatedly. In pointer mode all 100 wrappers are resolved from cache on each call; in none mode each call allocates 100 fresh wrappers.

churnObjects — Create-roundtrip-release loop. Each iteration creates a fresh SimpleClass, crosses it once via roundtripSimpleClass, then explicitly releases it. Objects are never reused, so pointer mode gets no cache hits here — every crossing is a miss that still pays the Map.set + WeakRef bookkeeping cost. Also stresses FinalizationRegistry cleanup: pointer mode accumulates stale WeakRef entries in __identityCache that the registry eventually needs to prune.

swiftConsumesSameObject — One-way consumption. JS holds a single wrapper and calls takeSimpleClass(obj) 1 million times — Swift receives the object but does not return it, so the pointer never comes back to JS. No __wrap is called on the JS side; this purely measures the overhead of pointer mode on the incoming (JS→Swift) direction. The ~1.4x slower result is consistent with the extra reference counting from deinit calls on cache misses for objects Swift creates internally during the call.

swiftCreatesObject — Worst case for cache bookkeeping. Swift allocates a brand-new SimpleClass on every makeSimpleClass() call and returns it to JS. Every crossing is a cold cache miss: pointer mode must do a Map.get (miss), create the wrapper, then Map.set + WeakRef + FinalizationRegistry.register. None mode just creates the wrapper. The 3.1x slowdown is the raw cost of cache bookkeeping on a pure allocation path with no reuse at all.

Memory (passBothWaysRoundtrip, 1M iterations)

Results are held in a retained array throughout the run to measure worst-case heap growth, then the array is cleared and GC is forced.

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=1000000

For memory profiling:

node --expose-gc run.js --adaptive --identity-mode=both --identity-memory

See 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.
@krodak krodak force-pushed the feat/pointer-mode-v2 branch from 6755581 to 2aac6d6 Compare April 21, 2026 10:05
@krodak
Copy link
Copy Markdown
Collaborator Author

krodak commented Apr 21, 2026

Pointer-Identity-Mode.md

Implementation design summary

@krodak
Copy link
Copy Markdown
Collaborator Author

krodak commented Apr 21, 2026

Benchmarks-overview.md

Benchmarks overview

@krodak krodak merged commit f562fcb into main Apr 21, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant