Skip to content

Commit ffa465f

Browse files
authored
perf(shared-core): copy bytes across the bridge in one memcpy (#1503)
1 parent 1e2aad5 commit ffa465f

3 files changed

Lines changed: 65 additions & 14 deletions

File tree

‎kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift‎

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,15 @@ import SharedCore
44
extension Data {
55

66
/// Kotlin's `ByteArray` has no `Data` bridge of its own, so every exported function taking
7-
/// bytes needs this copy.
7+
/// bytes needs this copy. It goes through `NSData` because the only element-wise alternative,
8+
/// `KotlinByteArray.set(index:value:)`, costs one Objective-C message send per byte.
89
var kotlinByteArray: KotlinByteArray {
9-
let array = KotlinByteArray(size: Int32(count))
10-
for (offset, byte) in enumerated() {
11-
array.set(index: Int32(offset), value: Int8(bitPattern: byte))
12-
}
13-
return array
10+
SharedBytes.shared.byteArray(data: self)
1411
}
1512

1613
/// The reverse copy, for the exported functions that hand bytes back. Kotlin bytes are signed,
17-
/// so anything above 0x7F comes across negative and has to be reinterpreted rather than
18-
/// converted.
14+
/// but a bulk copy moves the bit patterns unchanged, so nothing has to be reinterpreted here.
1915
init(_ array: KotlinByteArray) {
20-
var bytes = [UInt8]()
21-
bytes.reserveCapacity(Int(array.size))
22-
for index in 0..<array.size {
23-
bytes.append(UInt8(bitPattern: array.get(index: index)))
24-
}
25-
self.init(bytes)
16+
self = SharedBytes.shared.data(bytes: array)
2617
}
2718
}

‎kmp/shared-core/spm/Tests/SharedCoreKitTests/DataBridgeTests.swift‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,25 @@ import SharedCore
2222
#expect(Data(original.kotlinByteArray) == original)
2323
}
2424
}
25+
26+
@Suite struct DataBridgeThroughputTests {
27+
28+
/// The size of the two discrete-curve tables the iOS app hands the bridge on first use.
29+
private static let curveTableByteCount = 6_720_032
30+
31+
/// A per-element bridge costs one Objective-C message send per byte, which put ~1.9s of the
32+
/// iOS app's launch inside `SharedBondingCurve.initialize`. The budget is two orders of
33+
/// magnitude above a bulk copy of this size and an order of magnitude below the per-element
34+
/// cost, so it fails on a regression rather than on a slow machine.
35+
@Test func convertsCurveSizedDataWithinBudget() {
36+
let original = Data(repeating: 0xAB, count: Self.curveTableByteCount)
37+
38+
let start = Date()
39+
let kotlin = original.kotlinByteArray
40+
let round = Data(kotlin)
41+
let elapsed = Date().timeIntervalSince(start)
42+
43+
#expect(round.count == original.count)
44+
#expect(elapsed < 0.2, "round trip of \(Self.curveTableByteCount) bytes took \(elapsed)s")
45+
}
46+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.flipcash.shared
2+
3+
import kotlinx.cinterop.BetaInteropApi
4+
import kotlinx.cinterop.ExperimentalForeignApi
5+
import kotlinx.cinterop.addressOf
6+
import kotlinx.cinterop.usePinned
7+
import platform.Foundation.NSData
8+
import platform.Foundation.create
9+
import platform.posix.memcpy
10+
11+
/**
12+
* Bulk conversions between Foundation's `NSData` and Kotlin's `ByteArray`.
13+
*
14+
* Swift callers have no other way to hand a `ByteArray` its contents than `set(index:value:)`,
15+
* which is one Objective-C message send per byte. Copying through `NSData` instead moves the
16+
* whole buffer in a single `memcpy`, which matters for anything larger than a key or a hash —
17+
* the discrete-curve tables are 6.7 MB.
18+
*/
19+
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
20+
object SharedBytes {
21+
22+
/** Copies [data]'s bytes into a new `ByteArray`. */
23+
fun byteArray(data: NSData): ByteArray {
24+
val size = data.length.toInt()
25+
if (size == 0) return ByteArray(0)
26+
val bytes = ByteArray(size)
27+
bytes.usePinned { memcpy(it.addressOf(0), data.bytes, data.length) }
28+
return bytes
29+
}
30+
31+
/** Copies [bytes] into a new `NSData`. */
32+
fun data(bytes: ByteArray): NSData {
33+
if (bytes.isEmpty()) return NSData()
34+
return bytes.usePinned {
35+
NSData.create(bytes = it.addressOf(0), length = bytes.size.toULong())
36+
}
37+
}
38+
}

0 commit comments

Comments
 (0)