Skip to content

experiment: Swift-side per-class identity cache with shared memory flag#5

Draft
krodak wants to merge 1 commit intomainfrom
experiment/swift-weak-set-fork
Draft

experiment: Swift-side per-class identity cache with shared memory flag#5
krodak wants to merge 1 commit intomainfrom
experiment/swift-weak-set-fork

Conversation

@krodak
Copy link
Copy Markdown
Collaborator

@krodak krodak commented Apr 22, 2026

Overview

Experimental Swift-side identity cache as an alternative to the JS-side deinit(pointer) call on cache hits. Builds on top of PRs #2 and #3.

Currently, when the same Swift pointer is returned and the JS identity cache hits, JS calls deinit(pointer) back into WASM to balance the passRetained that Swift did. This PR moves the "already exported?" tracking to Swift, allowing the thunk to skip passRetained entirely on cache hits.

How it works

Each identity-mode class gets a generated Set<UnsafeMutableRawPointer> tracking exported pointers. The generated thunk checks the Set before returning:

return withExtendedLifetime(ret) {
    let pointer = Unmanaged.passUnretained(ret).toOpaque()
    if _MyModel_identityExported.contains(pointer) {
        _swift_js_set_identity_ref(1)  // tell JS: not retained
        return pointer
    }
    _MyModel_identityExported.insert(pointer)
    _ = Unmanaged.passRetained(ret)
    return pointer
}

Swift signals JS via @_extern(wasm, module: "bjs", name: "swift_js_set_identity_ref") — the same pattern as _swift_js_push_i32 and other existing BridgeJS intrinsics. No DataView, no shared memory buffer management.

JS checks the signal before calling __construct. On cache hit with signal set, JS skips deinit. On race condition (stale JS cache), JS calls bjs_identity_retain to recover.

The per-class Set and signal are implementation details in the generated code — no changes to user-facing @JS API.

What changed

  • BridgeJSIntrinsics.swift — Added _swift_js_set_identity_ref JS import (same pattern as _swift_js_push_i32). Added bjs_identity_retain WASM export for race recovery. Removed DataView-based shared memory flag.
  • ExportSwift.swift — Identity-mode thunks use withExtendedLifetime + passUnretained + Set check. Per-class Set<UnsafeMutableRawPointer> generated alongside thunks. Deinit cleans up Set entry.
  • BridgeJSLink.swift — JS import handler for swift_js_set_identity_ref. Modified __wrap cache hit/miss paths to check signal. Removed DataView infrastructure.

Benchmark comparison

Release build, adaptive sampling:

Scenario JS-only cache (PRs #2+#3) Swift-side Set (this) Change
passBothWaysRoundtrip (1M) 55 ms 84 ms +53% slower
getPoolRepeated_100 (1M) 90 ms 106 ms +18% slower
swiftCreatesObject (1M) 3048 ms 2337 ms -23% faster
churnObjects (100k) 162 ms ~9800 ms much slower (Set grows)

Analysis

The Swift-side approach trades cache-hit speed for create-path improvement. Set.contains with SipHash on WASM costs ~20-30ns per call (no hardware-accelerated hashing), making the cache-hit path slower than a single deinit WASM call (~4-8ns after V8 JIT optimization).

The churnObjects regression is severe — objects are created, crossed, and released in a tight loop. The per-class Set grows because FinalizationRegistry cleanup is asynchronous. This is a known limitation of the per-class Set approach.

When this approach wins

  • Create-heavy workloads with identity-mode classes (fewer retain/release cycles on first crossing)

When JS-only cache wins

  • Reuse-heavy workloads (tighter hot loop, no hash overhead)
  • Churn workloads (Set grows unboundedly until GC fires)
  • Bulk array returns

Replace DataView shared memory flag with @_extern(wasm) JS import for
signaling, following the existing BridgeJS intrinsics pattern
(_swift_js_push_i32, _swift_js_return_optional_heap_object, etc).

Per-class Set tracks exported pointers. On cache hit, thunk calls
_swift_js_set_identity_ref(1) and returns passUnretained. JS checks
the ref and skips deinit.

Trade-off vs JS-only cache: Set.contains with SipHash on WASM adds
~20-30ns per crossing, exceeding the ~4-8ns deinit WASM call it
replaces. Improves create-heavy paths by ~20% but regresses
roundtrip by ~50%.
@krodak krodak force-pushed the experiment/swift-weak-set-fork branch from 444417f to d632591 Compare April 22, 2026 16:49
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