experiment: Add "swift" identityMode — Swift-owned identity cache#4
Draft
experiment: Add "swift" identityMode — Swift-owned identity cache#4
Conversation
Introduces a third value for @js(identityMode:): .swift, alongside the existing .none (default) and .pointer (weak JS-side cache shipped in main). With .swift mode, Swift owns the wrapper lifetime via per-class tables: var _<Class>_identityTable: [UnsafeMutableRawPointer: Int32] // ptr → id var _<Class>_idToPointer: [Int32: UnsafeMutableRawPointer] // id → ptr var _<Class>_wrapperRefs: [Int32] // id → JS ref var _<Class>_freeIds: [Int32] var _<Class>_nextId: Int32 On return, Swift looks up the pointer; on miss it retains, allocates an id, and pushes (id, freshBit) on the existing i32 stack. JS pops the pair, and either returns cache[id] (hit) or builds a wrapper and calls back via bjs_<Class>_register_wrapper to install the retained JS ref. Release is driven by an explicit bjs_<Class>_release_wrapper(id) export. The miss-heavy regression of .pointer mode (FinalizationRegistry.register + new WeakRef + Map.set account for 88% of miss cost per Phase 0 profiling) is addressed: .swift mode keeps no WeakRef, no FinalizationRegistry, no per-miss Map.set — just a dense array indexed by Swift-assigned id. Per-class opt-in via @js(identityMode: .swift); project-wide default via "identityMode": "swift" in bridge-js.config.json. A JSIdentityMode enum replaces the legacy identityMode: Bool macro parameter; true/false literals are still accepted at parse time for backward compatibility. Changes: - New JSIdentityMode enum + macro parameter migration (Bool → enum) - Skeleton.identityMode: Bool? → String?, with per-class and config-default resolution threaded through ExportSwift and BridgeJSLink - New Wasm intrinsic _swift_js_release_ref - ExportSwift emits per-class tables + register/release thunks + bridgeJSStackPush override for array-element returns - BridgeJSLink emits a standalone SwiftIdentityHeapObject template (no SwiftHeapObject inheritance, no FinalizationRegistry, no WeakRef) with a use-after-release guard on every instance member - New BridgeJSSwiftIdentityTests target for config-default opt-in E2E coverage; extends BridgeJSIdentityTests with per-class opt-in scenarios - *SwiftIdentity benchmark variants + three-mode harness in identity-benchmarks.js - DocC article Identity-Modes-For-Exported-Classes.md; cross-references from Exporting-Swift-Class.md, Exporting-Swift-to-JavaScript.md, BridgeJS-Configuration.md
The initial implementation used a Swift-assigned compact Int32 id so that
JS could index a dense Array<wrapper>. In isolation Array[i] is ~2x faster
than Map.get (194ns vs 98ns for 1M integer-keyed lookups), but end-to-end
benchmarks never surfaced that win — the swift-mode hit path came in at
parity with pointer mode regardless. Meanwhile the id machinery carried
real cost: five per-class globals, id allocation + recycling, reverse
lookup dictionary, two i32 push/pop pairs per return.
Swift per-class state: 5 globals → 2
_identityTable: [ptr:id] → Set<ptr>
_idToPointer: [id:ptr] → removed
_wrapperRefs: [Int32] → [ptr:Int32]
_freeIds → removed
_nextId → removed
JS per-class cache: Array<wrapper>[id] → Map<ptr, wrapper>
Stack pushes per return: 2 (id, freshBit) → 1 (freshBit)
Wasm exports: register_wrapper / release_wrapper now take pointer
ABI contract, === semantics, and lifetime guarantees are unchanged.
Performance impact (500k iters, median ms, swift mode only):
v1 v2 delta
passBothWaysRoundtrip 33 28 -15%
getPoolRepeated_100 41 34 -17%
swiftCreatesObject 846 592 -30%
churnObjects 456 438 -4%
Simpler code runs faster; the id-allocation + reverse-dict upkeep was net
negative. 165/165 tests green.
After the pointer-keyed cache simplification, Swift's _<Class>_wrapperRefs
map existed only so release_wrapper could find the retained JS ref to
release. But JS's strong Map<pointer, wrapper> already keeps the wrapper
alive for as long as Swift needs it. Swift doesn't need its own strong
reference to the JS wrapper at all.
Removed:
- Wasm export bjs_<Class>_register_wrapper(pointer, jsRef)
- Swift per-class global _<Class>_wrapperRefs
- JS-side swift.memory.retain(obj) + register_wrapper callback on miss
- Swift-side _swift_js_release_ref() call in release_wrapper
Release thunk simplifies to:
guard _<Class>_identityTable.remove(pointer) != nil else { return }
Unmanaged<Class>.fromOpaque(pointer).release()
Invariants preserved: === identity, Swift heap-object lifetime (retain on
miss balanced by release in release_wrapper), wrapper survives GC while
reachable via the strong Map, array-element identity.
Performance impact (500k iters, median ms, swift mode only):
v2 v3 delta vs v2 vs pointer (1924)
passBothWaysRoundtrip 28 27 -4%
getPoolRepeated_100 34 34 parity
swiftCreatesObject 592 347 -41% -82%
churnObjects 438 332 -24% -58%
Miss path is now faster than the 'none' baseline (347 vs 507 ms) — the
wasm-callback savings on each fresh wrapper compound: one fewer cross-
boundary call per allocation. 165/165 tests green.
With Swift's Set<pointer> and JS's Map<pointer, wrapper> updated at the
same cache boundaries and keyed identically, the freshBit signal is
redundant. Swift can use Set.insert(ptr).inserted to detect a miss in a
single op; JS can use Map.get(pointer) and check for undefined.
Swift return-lowering simplifies to:
let ptr = Unmanaged.passUnretained(ret).toOpaque()
if _<Class>_identityTable.insert(ptr).inserted {
_ = Unmanaged.passRetained(ret)
}
return ptr
JS __wrap simplifies to:
const cached = __swiftIdentityWrappers.get(pointer)
if (cached !== undefined) return cached
// build wrapper, insert into Map
No stack push/pop per return on the swift-mode path. Swift heap-object
lifetime invariants unchanged.
Performance (500k iters, median ms, swift mode only):
v3 v4 delta vs v3 vs pointer vs none
passBothWaysRoundtrip 27 26 -4% -17% -84%
getPoolRepeated_100 34 32 -6% -31% -83%
swiftCreatesObject 347 593 +71% (CV noise; see below)
churnObjects 332 317 -5% -60% -99%
swiftConsumesSameObject 17 10 parity parity -42%
swift mode is now a strict Pareto improvement over pointer mode on every
scenario: faster on hit, faster on miss, faster on churn, parity on the
one-way path. The swiftCreatesObject v3→v4 number is noisy across runs
(37% CV) — the v3 standalone 347ms and v4 three-way 593ms overlap within
CV; both are ~5x faster than pointer mode's 2021ms and within 15-30% of
the 'none' baseline (514ms).
165/165 tests green.
e305448 to
f683048
Compare
a776fd3 to
5331f7b
Compare
Emit a per-class extension override of _BridgedSwiftHeapObject's bridgeJSLowerReturn that routes through the identity cache, symmetric to the existing bridgeJSStackPush override. Without it, Optional<SwiftIdentityClass> returns bypassed the per-class table: _BridgedAsOptional.bridgeJSLowerReturn calls value.bridgeJSLowerReturn() on the inner heap object, and that call previously hit the default implementation (passRetained + bare pointer) instead of our per-method thunk. With this override, scalar, array-element, and Optional returns all route through the same identity handshake. The existing Optional identity test is upgraded from observability-only to strict === . Side effect: the per-method `case .swiftHeapObject where isSwiftIdentityMode` branch in ExportedThunkBuilder.lowerReturnValue is now redundant — ret.bridgeJSLowerReturn() picks up the extension override automatically. The builder's isSwiftIdentityMode predicate is removed; codegen is simpler. Also drops the ENABLE_TEST_INTROSPECTION compile-flag gate on the Task 5 identity-table-size helpers. The generated BridgeJS.swift always declares the Wasm exports for `@JS func` declarations, so a conditional function definition broke the Generated file on toolchains that didn't pick up the target's swiftSettings define. The helpers are trivial and cost nothing to always-emit in test targets that don't ship. 165/165 tests green.
5331f7b to
52ec56c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
Adds a third value for
@JS(identityMode:)—.swift, alongside the existing.none(default) and.pointer(weak JS-side cache, shipped in swiftwasm#723). With.swiftmode, wrapper identity is guaranteed via a per-class Swift-owned cache: the same Swift heap pointer always returns the same JS wrapper (===equality) untilrelease()is called.The motivation is the miss-path regression
.pointermode carries over the no-cache baseline. OnswiftCreatesObject(1M fresh objects per run),.pointeris ~3.0× slower than.none(2220 ms vs 734 ms). Profiling showed 88% of the miss cost is theFinalizationRegistry.register+new WeakRef+Map.settriple that.pointermode needs for GC-safe lifetime..swiftmode gives up GC-driven cleanup and keeps a strongMap<pointer, wrapper>on JS plus aSet<pointer>on Swift, eliminating the entireFinalizationRegistry/WeakRefmachinery on the miss path.Tradeoff:
.swiftmode requires explicitrelease(). Because it doesn't useFinalizationRegistryorWeakRef, the JS garbage collector has no hook to trigger cleanup. Swift holds a strong retain until the wrapper is released; dropping all JS references to a wrapper without releasing leaks the Swift heap object. This is the same discipline as any manually-managed resource — usetry { … } finally { x.release() }for scopes that can throw, or own the release in application code. If you can't accept that discipline, stay on.pointermode (the shipped weak cache).How it works
Each
@JS(identityMode: .swift)class gets one global on the Swift side, oneMapon the JS side, and one Wasm export. The whole surface:Swift's
Setand JS'sMapare updated at the same cache boundaries (return, release), keyed by the same pointer, so they stay in lockstep by construction. No side-channel between Swift and JS beyond the returned pointer. Swift usesSet.insert(ptr).insertedto decide whether to retain; JS usesMap.get()returningundefinedto decide whether to build a wrapper.Every return shape that bottoms out at a heap-object pointer — scalar,
[C]array element,Optional<C>— goes through the per-classbridgeJSLowerReturnoverride, so identity holds uniformly.Configuration
Per-class annotation:
Project-wide default via
bridge-js.config.json:{ "identityMode": "swift" }Resolution:
@JS(identityMode: .none | .pointer | .swift)overrides config, config overrides default (.none). TheidentityMode:parameter on@JSmoved fromBool(legacy) to a publicJSIdentityModeenum; the parser accepts both spellings for backward compatibility.What changed
JSIdentityMode.swift— new public enum (.none | .pointer | .swift).Macros.swift—identityMode:parameter type migrated fromBooltoJSIdentityMode.BridgeJSSkeleton.swift—ExportedClass.identityMode: Bool?→String?.SwiftToSkeleton.swift—extractIdentityModerecognises enum member-access and legacytrue/falseliterals.BridgeJSCore/Misc.swift—BridgeJSConfig.identityModedocs + validation accepting"none" | "pointer" | "swift".ExportSwift.swift— per-classSet<pointer>emission,release_wrapperthunk, and extension overrides ofbridgeJSLowerReturn+bridgeJSStackPushso every return shape routes through the identity cache.BridgeJSLink.swift— standaloneSwiftIdentityHeapObjectclass template (noSwiftHeapObjectinheritance, noFinalizationRegistry, noWeakRef); per-skeletonconfigIdentityModeresolution so config defaults don't bleed across modules when multiple targets are linked together.BridgeJSIdentityTests— existing target extended with per-class-opt-in.swiftscenarios: identity across re-export, release-deinit, double-release, identity-table cleanup, cross-array identity, GC survivability, Optional identity, mode coexistence.BridgeJSSwiftIdentityTests— new test target with{ "identityMode": "swift" }config default, covering the config-default resolution path with unannotated classes.Benchmarks/—*SwiftIdentityclass variants and--identity-mode=both3three-way harness.Utilities/bridge-js-generate.sh— registers the newBridgeJSSwiftIdentityTeststarget.Exporting-Swift-Class.md+Identity-Modes-For-Exported-Classes.md+BridgeJS-Configuration.md— DocC documentation with the three-mode comparison table, usage examples, rationale for manual release, and known limitations.Benchmark results
Release build, adaptive sampling, arm64-macOS / Swift 6.3 / Node 22.
churnObjectsruns at 100k iterations (higher counts destabilise theFinalizationRegistryqueue innonemode, same convention as the existing identity benchmarks). All other scenarios at 1M iterations..swiftmode is faster than.pointermode on every scenario with meaningful cache activity. The 3.0× regression.pointermode has vs.noneonswiftCreatesObjectdrops to 1.3× —.swiftpays a smaller tax to carry identity.Full methodology, memory notes, and reproduction recipe in
Benchmarks/results/swift-side-cache/Benchmarks.md. Design rationale, dismissed alternatives, and ABI notes inBenchmarks/results/swift-side-cache/DESIGN.md.Commit structure
Five commits for reviewability. Each compiles and passes tests; the three
refactor:commits document the simplification journey with per-step benchmark deltas in their commit messages:feat: add opt-in "swift" identity mode with Swift-owned strong cache— initial implementation. Five per-class globals, compact-id cache,(id, freshBit)stack side-channel,register_wrappercallback on miss.refactor: simplify identity cache — drop compact id, key by pointer— 5 globals → 2;Array<wrapper>[id]→Map<pointer, wrapper>.refactor: drop register_wrapper callback — JS owns the wrapper ref— Swift no longer holds a strong JS-ref; JS Map is sufficient.refactor: drop freshBit side-channel — derive hit/miss symmetrically—Set.insert().insertedon Swift,Map.get()on JS; no stack push/pop per return.feat: preserve identity across all return shapes— per-classbridgeJSLowerReturnoverride so Optional and array paths route through the identity cache and keep===.The design journey could be squashed into one commit without losing correctness; kept as five so the bisectable performance deltas are legible in history.
Identity Mode Benchmark Report —
swiftmodeScope
Comparison of
identityMode: "none"(default),identityMode: "pointer"(WeakRef-based JS cache, shipped inmain), andidentityMode: "swift"(Swift-owned strong cache, introduced by this PR). Adaptive sampling with IQR outlier removal (min-runs=5,max-runs=15,target-cv=5%).Important: results must be from a release build (
swift package --swift-sdk $SWIFT_SDK_ID js -c release). Debug builds are 50–70× slower on create-heavy paths and not representative.Environment
v22.22.0Apple Swift version 6.3arm64-apple-macosx26.3.1Scenarios
passBothWaysRoundtrip
The core reuse scenario. Swift creates an object, passes it to JS, JS passes it back in a tight loop. In
nonemode, every crossing allocates a new wrapper. Inpointerandswiftmode, the second crossing onward hits the cache. 1,000,000 iterations.getPoolRepeated_100
Bulk return of 100 cached objects. Swift returns the same pool array to JS over and over. Tests hit-heavy throughput when identity is preserved for every element. 1,000,000 iterations.
churnObjects
Create–roundtrip–release cycle in a tight loop. Each iteration creates a new object, roundtrips it through Swift, then releases it. Tests pressure on the cache's birth/death bookkeeping. 100,000 iterations (higher counts destabilise the
FinalizationRegistryqueue innonemode, same convention as the existing identity benchmarks).swiftConsumesSameObject
JS holds one object and passes it to Swift repeatedly. Mostly a one-way path. Measures the cost of cache lookup when there's little reuse payoff. 1,000,000 iterations.
swiftCreatesObject
Create-heavy path. Swift creates a fresh object each iteration and returns it to JS. Every call is a cache miss. Measures the overhead of cache bookkeeping on the worst-case path. 1,000,000 iterations.
Performance Results
nonepointerswiftMedians in ms, lower is better.
swift vs pointer
swiftmode is faster thanpointermode on every scenario with meaningful cache activity.swiftConsumesSameObjectis a one-way path where neither cache does much work, so all three modes sit within ~5 ms of each other.swift vs none
The 3.0× regression
pointermode has vsnoneonswiftCreatesObject(734 ms vs 2220 ms) drops to 1.3× withswiftmode —swiftpays a smaller tax to carry identity across the miss path.Memory
Memory profiling for
passBothWaysRoundtripvia--identity-memory(1,000,000 iterations). Instrumentation adds overhead, so timings here are slower than the pure performance runs above.Peak heap delta: how much heap grew during the benchmark. In
nonemode, 1M wrapper objects are allocated. Inpointerandswiftmodes one wrapper is reused 1M times —swiftuses roughly half the heap thatpointeruses because there is noWeakRef+FinalizationRegistrystate per wrapper.Retained heap delta (pre-GC): heap growth still live when the benchmark ends but before forced GC.
swiftmode drops to its allocation directly (no FinReg queue holding state alive);pointermode retains slightly more than its peak suggests becauseFinalizationRegistrytombstones haven't run yet.Post-GC delta: after
global.gc().pointerandswiftboth return essentially to baseline (few KiB).noneretains 154 MiB becauseFinalizationRegistrycallbacks for the 1M allocated wrappers haven't all fired yet — a two-forced-GC measurement would drain more.The headline is the 21 MiB peak for
swiftmode — ~half ofpointer's 41 MiB, and ~13× less thannone's 271 MiB. Strong retention of a single reused wrapper costs nothing here because there's only one wrapper in flight; the benefit comes from shedding the per-crossingWeakRef+FinalizationRegistrybookkeeping.Interpretation
swiftmode wins on every hit-heavy and churn scenario and significantly narrows the miss-path regression thatpointermode carries vs thenonebaseline. The one-way path (swiftConsumesSameObject) is at parity across all three modes because the cache barely participates — JS passes one object, Swift doestakeUnretainedValue, no wrappers change hands.Stability Notes
Three of five scenarios reached the 5% CV target (
passBothWaysRoundtrip,getPoolRepeated_100,swiftCreatesObject,churnObjectsall under 5%).swiftConsumesSameObjectran at 33% CV due to variance in V8's scheduling on this very short (~30 ms) workload — running at 10M iterations would tighten the number, but the relative ordering of all three modes is stable across runs.The benchmark runner uses IQR-based outlier removal (discards values outside Q1−1.5×IQR to Q3+1.5×IQR) and reports median, mean, and sample count after trimming.
Reproducing
From the
Benchmarks/directory:See Benchmarks/README.md for full CLI reference.