Skip to content

experiment: Add "swift" identityMode — Swift-owned identity cache#4

Draft
krodak wants to merge 5 commits intomainfrom
feat/swift-identity-mode
Draft

experiment: Add "swift" identityMode — Swift-owned identity cache#4
krodak wants to merge 5 commits intomainfrom
feat/swift-identity-mode

Conversation

@krodak
Copy link
Copy Markdown
Collaborator

@krodak krodak commented Apr 22, 2026

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 .swift mode, wrapper identity is guaranteed via a per-class Swift-owned cache: the same Swift heap pointer always returns the same JS wrapper (=== equality) until release() is called.

The motivation is the miss-path regression .pointer mode carries over the no-cache baseline. On swiftCreatesObject (1M fresh objects per run), .pointer is ~3.0× slower than .none (2220 ms vs 734 ms). Profiling showed 88% of the miss cost is the FinalizationRegistry.register + new WeakRef + Map.set triple that .pointer mode needs for GC-safe lifetime. .swift mode gives up GC-driven cleanup and keeps a strong Map<pointer, wrapper> on JS plus a Set<pointer> on Swift, eliminating the entire FinalizationRegistry/WeakRef machinery on the miss path.

Tradeoff: .swift mode requires explicit release(). Because it doesn't use FinalizationRegistry or WeakRef, 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 — use try { … } finally { x.release() } for scopes that can throw, or own the release in application code. If you can't accept that discipline, stay on .pointer mode (the shipped weak cache).

How it works

Each @JS(identityMode: .swift) class gets one global on the Swift side, one Map on the JS side, and one Wasm export. The whole surface:

// Per-class Swift state (emitted by ExportSwift)
nonisolated(unsafe) var _C_identityTable: Set<UnsafeMutableRawPointer> = []

// Per-class Wasm export
@_expose(wasm, "bjs_C_release_wrapper")
@_cdecl("bjs_C_release_wrapper")
public func _bjs_C_release_wrapper(_ pointer: UnsafeMutableRawPointer) {
    guard _C_identityTable.remove(pointer) != nil else { return }
    Unmanaged<C>.fromOpaque(pointer).release()
}

// Per-class extension override on C: every return shape (scalar, array
// element, Optional) bottoms out here and routes through the cache.
extension C {
    @_spi(BridgeJS) @_transparent
    public consuming func bridgeJSLowerReturn() -> UnsafeMutableRawPointer {
        return withExtendedLifetime(self) {
            let ptr = Unmanaged.passUnretained(self).toOpaque()
            if _C_identityTable.insert(ptr).inserted {
                _ = Unmanaged.passRetained(self)
            }
            return ptr
        }
    }
    @_spi(BridgeJS) public consuming func bridgeJSStackPush() { ... }
}
// Per-class JS state (emitted by BridgeJSLink)
class C {
    static __swiftIdentityWrappers = new Map()

    static __wrap(pointer) {
        const cached = C.__swiftIdentityWrappers.get(pointer)
        if (cached !== undefined) return cached
        const obj = Object.create(C.prototype)
        obj.pointer = pointer
        obj.__swiftIdentityHasReleased = false
        C.__swiftIdentityWrappers.set(pointer, obj)
        return obj
    }

    release() {
        if (this.__swiftIdentityHasReleased) return
        this.__swiftIdentityHasReleased = true
        instance.exports.bjs_C_release_wrapper(this.pointer)
        C.__swiftIdentityWrappers.delete(this.pointer)
    }
}

Swift's Set and JS's Map are 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 uses Set.insert(ptr).inserted to decide whether to retain; JS uses Map.get() returning undefined to 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-class bridgeJSLowerReturn override, so identity holds uniformly.

Configuration

Per-class annotation:

@JS(identityMode: .swift)
class MyModel {
    @JS var name: String
    @JS init(name: String) { self.name = name }
}

Project-wide default via bridge-js.config.json:

{ "identityMode": "swift" }

Resolution: @JS(identityMode: .none | .pointer | .swift) overrides config, config overrides default (.none). The identityMode: parameter on @JS moved from Bool (legacy) to a public JSIdentityMode enum; the parser accepts both spellings for backward compatibility.

What changed

  • JSIdentityMode.swift — new public enum (.none | .pointer | .swift).
  • Macros.swiftidentityMode: parameter type migrated from Bool to JSIdentityMode.
  • BridgeJSSkeleton.swiftExportedClass.identityMode: Bool?String?.
  • SwiftToSkeleton.swiftextractIdentityMode recognises enum member-access and legacy true/false literals.
  • BridgeJSCore/Misc.swiftBridgeJSConfig.identityMode docs + validation accepting "none" | "pointer" | "swift".
  • ExportSwift.swift — per-class Set<pointer> emission, release_wrapper thunk, and extension overrides of bridgeJSLowerReturn + bridgeJSStackPush so every return shape routes through the identity cache.
  • BridgeJSLink.swift — standalone SwiftIdentityHeapObject class template (no SwiftHeapObject inheritance, no FinalizationRegistry, no WeakRef); per-skeleton configIdentityMode resolution so config defaults don't bleed across modules when multiple targets are linked together.
  • BridgeJSIdentityTests — existing target extended with per-class-opt-in .swift scenarios: 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/*SwiftIdentity class variants and --identity-mode=both3 three-way harness.
  • Utilities/bridge-js-generate.sh — registers the new BridgeJSSwiftIdentityTests target.
  • 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. churnObjects runs at 100k iterations (higher counts destabilise the FinalizationRegistry queue in none mode, same convention as the existing identity benchmarks). All other scenarios at 1M iterations.

Scenario none pointer swift swift vs pointer
passBothWaysRoundtrip 239 ms 66 ms 40 ms 40% faster
getPoolRepeated_100 235 ms 82 ms 62 ms 24% faster
churnObjects (100k) 120 ms 108 ms 62 ms 43% faster
swiftConsumesSameObject 35 ms 30 ms 34 ms within noise
swiftCreatesObject 734 ms 2220 ms 960 ms 57% faster

.swift mode is faster than .pointer mode on every scenario with meaningful cache activity. The 3.0× regression .pointer mode has vs .none on swiftCreatesObject drops to 1.3× — .swift pays 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 in Benchmarks/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_wrapper callback 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 symmetricallySet.insert().inserted on Swift, Map.get() on JS; no stack push/pop per return.
  • feat: preserve identity across all return shapes — per-class bridgeJSLowerReturn override 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 — swift mode

Scope

Comparison of identityMode: "none" (default), identityMode: "pointer" (WeakRef-based JS cache, shipped in main), and identityMode: "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

  • Node.js: v22.22.0
  • Swift: Apple Swift version 6.3
  • Host target: arm64-apple-macosx26.3.1
  • Build: release
  • Adaptive sampling with IQR-based outlier removal

Scenarios

passBothWaysRoundtrip

The core reuse scenario. Swift creates an object, passes it to JS, JS passes it back in a tight loop. In none mode, every crossing allocates a new wrapper. In pointer and swift mode, 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 FinalizationRegistry queue in none mode, 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

Scenario none pointer swift
passBothWaysRoundtrip 239 ms 66 ms 40 ms
getPoolRepeated_100 235 ms 82 ms 62 ms
churnObjects (100k) 120 ms 108 ms 62 ms
swiftConsumesSameObject 35 ms 30 ms 34 ms
swiftCreatesObject 734 ms 2220 ms 960 ms

Medians in ms, lower is better.

swift vs pointer

Scenario pointer swift delta
passBothWaysRoundtrip 66 40 40% faster
getPoolRepeated_100 82 62 24% faster
churnObjects (100k) 108 62 43% faster
swiftConsumesSameObject 30 34 ~parity (within 33% CV)
swiftCreatesObject 2220 960 57% faster

swift mode is faster than pointer mode on every scenario with meaningful cache activity. swiftConsumesSameObject is a one-way path where neither cache does much work, so all three modes sit within ~5 ms of each other.

swift vs none

Scenario none swift delta
passBothWaysRoundtrip 239 40 6.0× faster
getPoolRepeated_100 235 62 3.8× faster
churnObjects (100k) 120 62 1.9× faster
swiftConsumesSameObject 35 34 parity
swiftCreatesObject 734 960 1.3× slower

The 3.0× regression pointer mode has vs none on swiftCreatesObject (734 ms vs 2220 ms) drops to 1.3× with swift mode — swift pays a smaller tax to carry identity across the miss path.

Memory

Memory profiling for passBothWaysRoundtrip via --identity-memory (1,000,000 iterations). Instrumentation adds overhead, so timings here are slower than the pure performance runs above.

Metric none pointer swift
Avg duration 429 ms 76 ms 60 ms
Peak JS heap delta 271 MiB 41 MiB 21 MiB
Retained heap delta (pre-GC) 271 MiB 37 MiB 21 MiB
Post-GC delta 154 MiB 6.7 KiB 11.6 KiB

Peak heap delta: how much heap grew during the benchmark. In none mode, 1M wrapper objects are allocated. In pointer and swift modes one wrapper is reused 1M times — swift uses roughly half the heap that pointer uses because there is no WeakRef + FinalizationRegistry state per wrapper.

Retained heap delta (pre-GC): heap growth still live when the benchmark ends but before forced GC. swift mode drops to its allocation directly (no FinReg queue holding state alive); pointer mode retains slightly more than its peak suggests because FinalizationRegistry tombstones haven't run yet.

Post-GC delta: after global.gc(). pointer and swift both return essentially to baseline (few KiB). none retains 154 MiB because FinalizationRegistry callbacks 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 swift mode — ~half of pointer's 41 MiB, and ~13× less than none'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-crossing WeakRef + FinalizationRegistry bookkeeping.

Interpretation

swift mode wins on every hit-heavy and churn scenario and significantly narrows the miss-path regression that pointer mode carries vs the none baseline. The one-way path (swiftConsumesSameObject) is at parity across all three modes because the cache barely participates — JS passes one object, Swift does takeUnretainedValue, no wrappers change hands.

Stability Notes

Three of five scenarios reached the 5% CV target (passBothWaysRoundtrip, getPoolRepeated_100, swiftCreatesObject, churnObjects all under 5%). swiftConsumesSameObject ran 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:

# Build in release mode first
swift package --swift-sdk $SWIFT_SDK_ID js -c release

# Three-way comparison (none, pointer, swift), one scenario at a time
node --expose-gc run.js --adaptive --filter=passBothWaysRoundtrip   --identity-mode=both3 --identity-iterations=1000000
node --expose-gc run.js --adaptive --filter=getPoolRepeated_100     --identity-mode=both3 --identity-iterations=1000000
node --expose-gc run.js --adaptive --filter=churnObjects            --identity-mode=both3 --identity-iterations=100000
node --expose-gc run.js --adaptive --filter=swiftConsumesSameObject --identity-mode=both3 --identity-iterations=1000000
node --expose-gc run.js --adaptive --filter=swiftCreatesObject      --identity-mode=both3 --identity-iterations=1000000

# With memory profiling
node --expose-gc run.js --adaptive --filter=passBothWaysRoundtrip \
    --identity-mode=both3 --identity-iterations=1000000 --identity-memory

See Benchmarks/README.md for full CLI reference.

krodak added 4 commits April 22, 2026 14:28
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.
@krodak krodak force-pushed the feat/swift-identity-mode branch from e305448 to f683048 Compare April 22, 2026 16:07
@krodak krodak changed the title feat: Add "swift" identityMode — Swift-owned identity cache experiment: Add "swift" identityMode — Swift-owned identity cache Apr 22, 2026
@krodak krodak force-pushed the feat/swift-identity-mode branch 2 times, most recently from a776fd3 to 5331f7b Compare April 22, 2026 16:37
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.
@krodak krodak force-pushed the feat/swift-identity-mode branch from 5331f7b to 52ec56c Compare April 22, 2026 17:16
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