Skip to content

Commit cb04d8c

Browse files
committed
refactor: simplify identity cache — drop compact id, key by pointer
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.
1 parent d7e34a5 commit cb04d8c

14 files changed

Lines changed: 292 additions & 766 deletions

File tree

Benchmarks/Sources/Generated/BridgeJS.swift

Lines changed: 45 additions & 169 deletions
Large diffs are not rendered by default.

Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift

Lines changed: 36 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -352,35 +352,27 @@ public class ExportSwift {
352352
"""
353353
)
354354
case .swiftHeapObject(let className) where isSwiftIdentityMode(className):
355-
// identityMode: "swift" — Swift owns the wrapper lifetime and
356-
// the authoritative pointer→id table. On hit, skip the
357-
// passRetained; on miss, retain once and reserve a wrapper
358-
// slot. JS pops (id, freshBit) from the i32 stack after the
359-
// return pointer and either returns the cached wrapper or
360-
// builds a fresh one and calls back via
361-
// `bjs_<Class>_register_wrapper` to install the strong JS ref.
355+
// identityMode: "swift" — Swift tracks which pointers have
356+
// an associated JS wrapper via a per-class Set. On hit, skip
357+
// the passRetained; JS keeps the wrapper alive via its
358+
// strong `Map<pointer, wrapper>`. On miss, retain once and
359+
// signal freshBit=1 so JS builds the wrapper.
360+
//
361+
// ABI: one i32 push after the return pointer — `freshBit`.
362+
// JS pops `freshBit` and either returns its cached wrapper
363+
// (0) or builds a fresh one and calls `register_wrapper` to
364+
// give Swift the retained JS ref (1).
362365
append(
363366
"""
364367
return withExtendedLifetime(ret) {
365368
let ptr = Unmanaged.passUnretained(ret).toOpaque()
366-
if let id = _\(raw: className)_identityTable[ptr] {
367-
// Cache hit: do NOT retain. JS keeps the wrapper alive via _wrapperRefs[id].
368-
_swift_js_push_i32(id)
369+
if _\(raw: className)_identityTable.contains(ptr) {
370+
// Cache hit: do NOT retain. JS has the wrapper cached.
369371
_swift_js_push_i32(0)
370372
return ptr
371373
}
372374
_ = Unmanaged.passRetained(ret)
373-
let id: Int32
374-
if let recycled = _\(raw: className)_freeIds.popLast() {
375-
id = recycled
376-
} else {
377-
id = _\(raw: className)_nextId
378-
_\(raw: className)_nextId += 1
379-
_\(raw: className)_wrapperRefs.append(0)
380-
}
381-
_\(raw: className)_identityTable[ptr] = id
382-
_\(raw: className)_idToPointer[id] = ptr
383-
_swift_js_push_i32(id)
375+
_\(raw: className)_identityTable.insert(ptr)
384376
_swift_js_push_i32(1)
385377
return ptr
386378
}
@@ -719,25 +711,19 @@ public class ExportSwift {
719711
func renderSingleExportedClass(klass: ExportedClass) throws -> [DeclSyntax] {
720712
var decls: [DeclSyntax] = []
721713

722-
// identityMode: "swift" — emit the per-class Swift-owned identity cache
723-
// state (pointer→id forward map, id→pointer reverse map for O(1)
724-
// release, dense wrapper-ref array, free-id stack, monotonic counter).
725-
// See Docs/superpowers/specs/2026-04-21-swift-side-identity-cache-design.md §5.1.
714+
// identityMode: "swift" — per-class state. Simpler than the original
715+
// id-based design: the JS-side cache is keyed directly by pointer (as
716+
// a strong Map), so Swift only needs:
717+
// - a Set of pointers that already have a JS wrapper
718+
// - a Map of pointer → JS ref (so Swift can release the JS ref
719+
// when the wrapper is released)
720+
// See DECISIONS.md D18 for why this supersedes the 5-global design.
726721
if isSwiftIdentityMode(klass.name) {
727722
decls.append(
728-
"nonisolated(unsafe) var _\(raw: klass.name)_identityTable: [UnsafeMutableRawPointer: Int32] = [:]"
723+
"nonisolated(unsafe) var _\(raw: klass.name)_identityTable: Set<UnsafeMutableRawPointer> = []"
729724
)
730725
decls.append(
731-
"nonisolated(unsafe) var _\(raw: klass.name)_idToPointer: [Int32: UnsafeMutableRawPointer] = [:]"
732-
)
733-
decls.append(
734-
"nonisolated(unsafe) var _\(raw: klass.name)_wrapperRefs: [Int32] = []"
735-
)
736-
decls.append(
737-
"nonisolated(unsafe) var _\(raw: klass.name)_freeIds: [Int32] = []"
738-
)
739-
decls.append(
740-
"nonisolated(unsafe) var _\(raw: klass.name)_nextId: Int32 = 0"
726+
"nonisolated(unsafe) var _\(raw: klass.name)_wrapperRefs: [UnsafeMutableRawPointer: Int32] = [:]"
741727
)
742728
}
743729

@@ -781,18 +767,18 @@ public class ExportSwift {
781767
}
782768

783769
// identityMode: "swift" — emit the register/release thunks that pair
784-
// with the JS-side fresh-wrapper handshake. See spec §5.3 and §5.4.
785-
// Per D8.3, release uses the reverse dictionary (O(1) drop).
770+
// with the JS-side fresh-wrapper handshake. Both thunks are keyed by
771+
// raw pointer — no id indirection. See DECISIONS.md D18.
786772
if isSwiftIdentityMode(klass.name) {
787773
do {
788774
let registerDecl = SwiftCodePattern.buildExposedFunctionDecl(
789775
abiName: "bjs_\(klass.abiName)_register_wrapper",
790776
signature: SwiftSignatureBuilder.buildABIFunctionSignature(
791-
abiParameters: [("id", .i32), ("jsRef", .i32)],
777+
abiParameters: [("pointer", .pointer), ("jsRef", .i32)],
792778
returnType: nil
793779
)
794780
) { printer in
795-
printer.write("_\(klass.name)_wrapperRefs[Int(id)] = jsRef")
781+
printer.write("_\(klass.name)_wrapperRefs[pointer] = jsRef")
796782
}
797783
decls.append(DeclSyntax(registerDecl))
798784
}
@@ -801,59 +787,33 @@ public class ExportSwift {
801787
let releaseDecl = SwiftCodePattern.buildExposedFunctionDecl(
802788
abiName: "bjs_\(klass.abiName)_release_wrapper",
803789
signature: SwiftSignatureBuilder.buildABIFunctionSignature(
804-
abiParameters: [("id", .i32)],
790+
abiParameters: [("pointer", .pointer)],
805791
returnType: nil
806792
)
807793
) { printer in
808-
printer.write("let slot = Int(id)")
809-
printer.write("let jsRef = _\(klass.name)_wrapperRefs[slot]")
810-
printer.write("guard jsRef != 0 else { return }")
811-
printer.write("_\(klass.name)_wrapperRefs[slot] = 0")
812-
printer.write("if let ptr = _\(klass.name)_idToPointer.removeValue(forKey: id) {")
813-
printer.write(" _\(klass.name)_identityTable.removeValue(forKey: ptr)")
814-
printer.write(" Unmanaged<\(klass.swiftCallName)>.fromOpaque(ptr).release()")
815-
printer.write("}")
816-
printer.write("_\(klass.name)_freeIds.append(id)")
794+
printer.write("guard let jsRef = _\(klass.name)_wrapperRefs.removeValue(forKey: pointer) else { return }")
795+
printer.write("_\(klass.name)_identityTable.remove(pointer)")
796+
printer.write("Unmanaged<\(klass.swiftCallName)>.fromOpaque(pointer).release()")
817797
printer.write("_swift_js_release_ref(jsRef)")
818798
}
819799
decls.append(DeclSyntax(releaseDecl))
820800
}
821801

822-
// Override the default `_BridgedSwiftHeapObject.bridgeJSStackPush` so
823-
// array-element returns (`[SwiftCached]`) go through the same
824-
// identity-cache handshake as scalar returns.
825-
//
826-
// See DECISIONS.md D15. The default in BridgeJSIntrinsics.swift just
827-
// calls `_swift_js_push_pointer(Unmanaged.passRetained(self).toOpaque())`
828-
// — that bypasses `_<Class>_identityTable` and leaves JS's `__wrap`
829-
// popping garbage instead of `(id, freshBit)`.
830-
//
831-
// Push order: `(id, freshBit)` on the i32 stack first, then the
832-
// pointer on the pointer stack. JS pops in reverse (pointer, then
833-
// freshBit, then id), matching how the codegen-emitted `__wrap`
834-
// already reads them.
802+
// Override the default `_BridgedSwiftHeapObject.bridgeJSStackPush`
803+
// so array-element returns (`[SwiftCached]`) go through the same
804+
// identity-cache handshake. See DECISIONS.md D15 (still applies —
805+
// only the internals changed; D18 simplified them).
835806
let stackPushExt: DeclSyntax = """
836807
extension \(raw: klass.swiftCallName) {
837808
@_spi(BridgeJS) public consuming func bridgeJSStackPush() {
838809
let ptr: UnsafeMutableRawPointer = withExtendedLifetime(self) {
839810
let ptr = Unmanaged.passUnretained(self).toOpaque()
840-
if let id = _\(raw: klass.name)_identityTable[ptr] {
841-
_swift_js_push_i32(id)
811+
if _\(raw: klass.name)_identityTable.contains(ptr) {
842812
_swift_js_push_i32(0)
843813
return ptr
844814
}
845815
_ = Unmanaged.passRetained(self)
846-
let id: Int32
847-
if let recycled = _\(raw: klass.name)_freeIds.popLast() {
848-
id = recycled
849-
} else {
850-
id = _\(raw: klass.name)_nextId
851-
_\(raw: klass.name)_nextId += 1
852-
_\(raw: klass.name)_wrapperRefs.append(0)
853-
}
854-
_\(raw: klass.name)_identityTable[ptr] = id
855-
_\(raw: klass.name)_idToPointer[id] = ptr
856-
_swift_js_push_i32(id)
816+
_\(raw: klass.name)_identityTable.insert(ptr)
857817
_swift_js_push_i32(1)
858818
return ptr
859819
}

Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2060,42 +2060,37 @@ extension BridgeJSLink {
20602060
dtsExportEntryPrinter.write("\(klass.name): {")
20612061

20622062
if useSwiftIdentity {
2063-
// Spec §6.2: a `.swift`-mode class is STANDALONE — it does NOT extend
2064-
// `SwiftHeapObject`. It owns its own dense-array wrapper cache and lifetime
2065-
// (no `FinalizationRegistry`, no `WeakRef`). Swift holds wrappers strongly
2066-
// via `_<Class>_wrapperRefs`; JS clears the slot on `release()`.
2063+
// DECISIONS.md D18: the `.swift`-mode class is standalone — it does NOT
2064+
// extend `SwiftHeapObject`. It keeps a strong `Map<pointer, wrapper>` so
2065+
// re-exports of the same Swift pointer get the same JS wrapper. Swift
2066+
// signals "cache hit" via a single `freshBit` push on the i32 stack
2067+
// (0 = cached, 1 = fresh). No id indirection, no dense array.
20672068
jsPrinter.write("class \(klass.name) {")
20682069
jsPrinter.indent {
2069-
jsPrinter.write("static __swiftIdentityWrappers = [];")
2070+
jsPrinter.write("static __swiftIdentityWrappers = new Map();")
20702071
jsPrinter.nextLine()
20712072
jsPrinter.write("static __wrap(pointer) {")
20722073
jsPrinter.indent {
2073-
// Swift pushes (id, freshBit) to the i32 stack AFTER returning the
2074-
// pointer; JS pops LIFO — freshBit first, id second.
20752074
jsPrinter.write("const freshBit = bjs.swift_js_pop_i32();")
2076-
jsPrinter.write("const id = bjs.swift_js_pop_i32();")
20772075
jsPrinter.write("if (freshBit === 0) {")
20782076
jsPrinter.indent {
2079-
jsPrinter.write("return \(klass.name).__swiftIdentityWrappers[id];")
2077+
jsPrinter.write("return \(klass.name).__swiftIdentityWrappers.get(pointer);")
20802078
}
20812079
jsPrinter.write("}")
20822080
jsPrinter.write("const obj = Object.create(\(klass.name).prototype);")
20832081
jsPrinter.write("obj.pointer = pointer;")
2084-
jsPrinter.write("obj.__swiftIdentityId = id;")
20852082
jsPrinter.write("obj.__swiftIdentityHasReleased = false;")
2086-
jsPrinter.write("\(klass.name).__swiftIdentityWrappers[id] = obj;")
2083+
jsPrinter.write("\(klass.name).__swiftIdentityWrappers.set(pointer, obj);")
20872084
// Retain the wrapper in swift.memory so Swift can hold it strongly
2088-
// via `_<Class>_wrapperRefs[id]` until release_wrapper fires.
2085+
// via `_<Class>_wrapperRefs[pointer]` until release_wrapper fires.
20892086
jsPrinter.write("const jsRef = swift.memory.retain(obj);")
2090-
jsPrinter.write("instance.exports.bjs_\(klass.abiName)_register_wrapper(id, jsRef);")
2087+
jsPrinter.write("instance.exports.bjs_\(klass.abiName)_register_wrapper(pointer, jsRef);")
20912088
jsPrinter.write("return obj;")
20922089
}
20932090
jsPrinter.write("}")
20942091
jsPrinter.nextLine()
20952092
jsPrinter.write("static __construct(pointer) {")
20962093
jsPrinter.indent {
2097-
// Swift's `_init` export pushed (id, freshBit) itself. `__wrap`
2098-
// pops them and returns either the cached wrapper or a fresh one.
20992094
jsPrinter.write("return \(klass.name).__wrap(pointer);")
21002095
}
21012096
jsPrinter.write("}")
@@ -2104,11 +2099,11 @@ extension BridgeJSLink {
21042099
jsPrinter.indent {
21052100
jsPrinter.write("if (this.__swiftIdentityHasReleased) return;")
21062101
jsPrinter.write("this.__swiftIdentityHasReleased = true;")
2107-
jsPrinter.write("const id = this.__swiftIdentityId;")
2108-
jsPrinter.write("instance.exports.bjs_\(klass.abiName)_release_wrapper(id);")
2109-
// Swift's release_wrapper already called `_swift_js_release_ref(jsRef)`,
2110-
// so we just clear our JS-side slot so the id can be recycled.
2111-
jsPrinter.write("\(klass.name).__swiftIdentityWrappers[id] = undefined;")
2102+
jsPrinter.write("const pointer = this.pointer;")
2103+
jsPrinter.write("instance.exports.bjs_\(klass.abiName)_release_wrapper(pointer);")
2104+
// Swift's release_wrapper already released the JS ref; clear
2105+
// the JS-side map entry.
2106+
jsPrinter.write("\(klass.name).__swiftIdentityWrappers.delete(pointer);")
21122107
}
21132108
jsPrinter.write("}")
21142109
}

Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/IdentityModeSwiftClass.swift

Lines changed: 13 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
1-
nonisolated(unsafe) var _SwiftCached_identityTable: [UnsafeMutableRawPointer: Int32] = [:]
1+
nonisolated(unsafe) var _SwiftCached_identityTable: Set<UnsafeMutableRawPointer> = []
22

3-
nonisolated(unsafe) var _SwiftCached_idToPointer: [Int32: UnsafeMutableRawPointer] = [:]
4-
5-
nonisolated(unsafe) var _SwiftCached_wrapperRefs: [Int32] = []
6-
7-
nonisolated(unsafe) var _SwiftCached_freeIds: [Int32] = []
8-
9-
nonisolated(unsafe) var _SwiftCached_nextId: Int32 = 0
3+
nonisolated(unsafe) var _SwiftCached_wrapperRefs: [UnsafeMutableRawPointer: Int32] = [:]
104

115
@_expose(wasm, "bjs_SwiftCached_init")
126
@_cdecl("bjs_SwiftCached_init")
@@ -15,24 +9,13 @@ public func _bjs_SwiftCached_init(_ nameBytes: Int32, _ nameLength: Int32) -> Un
159
let ret = SwiftCached(name: String.bridgeJSLiftParameter(nameBytes, nameLength))
1610
return withExtendedLifetime(ret) {
1711
let ptr = Unmanaged.passUnretained(ret).toOpaque()
18-
if let id = _SwiftCached_identityTable[ptr] {
19-
// Cache hit: do NOT retain. JS keeps the wrapper alive via _wrapperRefs[id].
20-
_swift_js_push_i32(id)
12+
if _SwiftCached_identityTable.contains(ptr) {
13+
// Cache hit: do NOT retain. JS has the wrapper cached.
2114
_swift_js_push_i32(0)
2215
return ptr
2316
}
2417
_ = Unmanaged.passRetained(ret)
25-
let id: Int32
26-
if let recycled = _SwiftCached_freeIds.popLast() {
27-
id = recycled
28-
} else {
29-
id = _SwiftCached_nextId
30-
_SwiftCached_nextId += 1
31-
_SwiftCached_wrapperRefs.append(0)
32-
}
33-
_SwiftCached_identityTable[ptr] = id
34-
_SwiftCached_idToPointer[id] = ptr
35-
_swift_js_push_i32(id)
18+
_SwiftCached_identityTable.insert(ptr)
3619
_swift_js_push_i32(1)
3720
return ptr
3821
}
@@ -74,27 +57,21 @@ public func _bjs_SwiftCached_deinit(_ pointer: UnsafeMutableRawPointer) -> Void
7457

7558
@_expose(wasm, "bjs_SwiftCached_register_wrapper")
7659
@_cdecl("bjs_SwiftCached_register_wrapper")
77-
public func _bjs_SwiftCached_register_wrapper(_ id: Int32, _ jsRef: Int32) -> Void {
60+
public func _bjs_SwiftCached_register_wrapper(_ pointer: UnsafeMutableRawPointer, _ jsRef: Int32) -> Void {
7861
#if arch(wasm32)
79-
_SwiftCached_wrapperRefs[Int(id)] = jsRef
62+
_SwiftCached_wrapperRefs[pointer] = jsRef
8063
#else
8164
fatalError("Only available on WebAssembly")
8265
#endif
8366
}
8467

8568
@_expose(wasm, "bjs_SwiftCached_release_wrapper")
8669
@_cdecl("bjs_SwiftCached_release_wrapper")
87-
public func _bjs_SwiftCached_release_wrapper(_ id: Int32) -> Void {
70+
public func _bjs_SwiftCached_release_wrapper(_ pointer: UnsafeMutableRawPointer) -> Void {
8871
#if arch(wasm32)
89-
let slot = Int(id)
90-
let jsRef = _SwiftCached_wrapperRefs[slot]
91-
guard jsRef != 0 else { return }
92-
_SwiftCached_wrapperRefs[slot] = 0
93-
if let ptr = _SwiftCached_idToPointer.removeValue(forKey: id) {
94-
_SwiftCached_identityTable.removeValue(forKey: ptr)
95-
Unmanaged<SwiftCached>.fromOpaque(ptr).release()
96-
}
97-
_SwiftCached_freeIds.append(id)
72+
guard let jsRef = _SwiftCached_wrapperRefs.removeValue(forKey: pointer) else { return }
73+
_SwiftCached_identityTable.remove(pointer)
74+
Unmanaged<SwiftCached>.fromOpaque(pointer).release()
9875
_swift_js_release_ref(jsRef)
9976
#else
10077
fatalError("Only available on WebAssembly")
@@ -105,23 +82,12 @@ extension SwiftCached {
10582
@_spi(BridgeJS) public consuming func bridgeJSStackPush() {
10683
let ptr: UnsafeMutableRawPointer = withExtendedLifetime(self) {
10784
let ptr = Unmanaged.passUnretained(self).toOpaque()
108-
if let id = _SwiftCached_identityTable[ptr] {
109-
_swift_js_push_i32(id)
85+
if _SwiftCached_identityTable.contains(ptr) {
11086
_swift_js_push_i32(0)
11187
return ptr
11288
}
11389
_ = Unmanaged.passRetained(self)
114-
let id: Int32
115-
if let recycled = _SwiftCached_freeIds.popLast() {
116-
id = recycled
117-
} else {
118-
id = _SwiftCached_nextId
119-
_SwiftCached_nextId += 1
120-
_SwiftCached_wrapperRefs.append(0)
121-
}
122-
_SwiftCached_identityTable[ptr] = id
123-
_SwiftCached_idToPointer[id] = ptr
124-
_swift_js_push_i32(id)
90+
_SwiftCached_identityTable.insert(ptr)
12591
_swift_js_push_i32(1)
12692
return ptr
12793
}

0 commit comments

Comments
 (0)