Skip to content

Commit

Permalink
perf: improve byte to hex string conversion optimizations (#701)
Browse files Browse the repository at this point in the history
* Improve hex string to DataView conversion performance in TypeScript

Benchmarks can be found at https://www.measurethat.net/Benchmarks/Show/32317/0/parse-hex-to-bytes---test-capacitor-bluetooth-le-conver

Performance improvement is platform dependent but appears to be about 9x faster on iOS and Android

* Improve DataView to hex string conversion in Kotlin

In the future the Kotlin Stdlib may support this conversion, but it is currently marked as experimental. This implementation is less configurable but perhaps a bit faster.

Passing about 512KB of data through a notification characteristic with the old method utilized about 21.5 seconds of wall clock time. This implementation utilized  124.61 milliseconds, a whopping 172X speedup. When using a notification characteristic to transfer binary data to the central over BLE, this speedup is very noticeable.

* Improve Data to Hex String Conversion in Swift

Unfortunately, I don't have a Mac to build an iOS test case, so I ran some toy benchmarking on my PC using the Swift 6.0.1 toolchain for Windows.

I ran 1000 iterations of converting a 4KB Data blob to a hex string using the existing algorithm, which took 25.14 seconds to finish. Running the same test with this proposed implementation took 0.91 seconds, yielding a speedup of "only" ~27x. While this isn't as significant as the Kotlin optimization, this is my first time writing any Swift code, so there may be additional performance improvements to explore. On that front, I would appreciate scrutiny (and testing, if possible!) from iOS developers. I ran some test cases on Windows, and everything looked fine.

* Swift: Provide a fallback path for iOS < 14

Benchmarking this implementation on my PC shows that it runs at about half the speed of the fast path but is still substantially faster than the existing implementation:

iOS14+Conv: Time taken for 1000 iterations with data size 4096 bytes: 0.8684617 seconds
FallbackConv: Time taken for 1000 iterations with data size 4096 bytes: 1.3471951 seconds
BaselineConv: Time taken for 1000 iterations with data size 4096 bytes: 23.8809594 seconds
  • Loading branch information
NaterGator authored Oct 28, 2024
1 parent 83e2b1b commit e064587
Show file tree
Hide file tree
Showing 6 changed files with 88 additions and 25 deletions.
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
package com.capacitorjs.community.plugins.bluetoothle

// Create a LUT for high performance ByteArray conversion
val HEX_LOOKUP_TABLE = IntArray(256) {
val hexChars = "0123456789ABCDEF"
val h: Int = (hexChars[(it shr 4)].code shl 8)
val l: Int = hexChars[(it and 0x0F)].code
(h or l)
}

fun bytesToString(bytes: ByteArray): String {
val stringBuilder = StringBuilder(bytes.size)
for (byte in bytes) {
// byte to hex string
stringBuilder.append(String.format("%02X ", byte))
// Custom implementation of ByteArray.toHexString until stdlib stabilizes
private fun ByteArray.toHexString(): String {
val result = CharArray(this.size * 2);
var i = 0;
for (byte in this) {
val hx = HEX_LOOKUP_TABLE[byte.toInt() and 0xFF]
result[i] = (hx shr 8).toChar()
result[i+1] = (hx and 0xFF).toChar()
i+=2
}
return stringBuilder.toString()
return result.concatToString()
}

fun bytesToString(bytes: ByteArray): String {
return bytes.toHexString()
}

fun stringToBytes(value: String): ByteArray {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class ConversionKtTest : TestCase() {
fun testBytesToString() {
val input = byteArrayOfInts(0xA1, 0x2E, 0x38, 0xD4, 0x89, 0xC3)
val output = bytesToString(input)
assertEquals("A1 2E 38 D4 89 C3 ", output)
assertEquals("A12E38D489C3", output)
}

fun testEmptyBytesToString() {
Expand Down
34 changes: 29 additions & 5 deletions ios/Plugin/Conversion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,36 @@ func descriptorValueToString(_ value: Any) -> String {
return ""
}

func dataToString(_ data: Data) -> String {
var valueString = ""
for byte in data {
valueString += String(format: "%02hhx ", byte)
extension Data {
func toHexString() -> String {
let hexChars = Array("0123456789abcdef".utf8)
if #available(iOS 14, *) {
return String(unsafeUninitializedCapacity: self.count*2) { (ptr) -> Int in
var strp = ptr.baseAddress!
for byte in self {
strp[0] = hexChars[Int(byte >> 4)]
strp[1] = hexChars[Int(byte & 0xF)]
strp += 2
}
return 2 * self.count
}
} else {
// Fallback implementation for iOS < 14, a bit slower
var result = ""
result.reserveCapacity(self.count * 2)
for byte in self {
let high = Int(byte >> 4)
let low = Int(byte & 0xF)
result.append(Character(UnicodeScalar(hexChars[high])))
result.append(Character(UnicodeScalar(hexChars[low])))
}
return result
}
}
return valueString
}

func dataToString(_ data: Data) -> String {
return data.toHexString()
}

func stringToData(_ dataString: String) -> Data {
Expand Down
12 changes: 6 additions & 6 deletions ios/PluginTests/ConversionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class ConversionTests: XCTestCase {
func testDataToString() throws {
let input = Data([0xA1, 0x2E, 0x38, 0xD4, 0x89, 0xC3])
let output = dataToString(input)
XCTAssertEqual(output, "a1 2e 38 d4 89 c3 ")
XCTAssertEqual(output, "a12e38d489c3")
}

func testStringToData() throws {
Expand Down Expand Up @@ -44,11 +44,11 @@ class ConversionTests: XCTestCase {
}

func testDescriptorValueToString() throws {
XCTAssertEqual(descriptorValueToString("Hello"), "48 65 6c 6c 6f ")
XCTAssertEqual(descriptorValueToString(Data([0, 5, 255])), "00 05 ff ")
XCTAssertEqual(descriptorValueToString(UInt16(258)), "02 01 ")
XCTAssertEqual(descriptorValueToString(UInt16(1)), "01 00 ")
XCTAssertEqual(descriptorValueToString(NSNumber(1)), "01 00 ")
XCTAssertEqual(descriptorValueToString("Hello"), "48656c6c6f")
XCTAssertEqual(descriptorValueToString(Data([0, 5, 255])), "0005ff")
XCTAssertEqual(descriptorValueToString(UInt16(258)), "0201")
XCTAssertEqual(descriptorValueToString(UInt16(1)), "0100")
XCTAssertEqual(descriptorValueToString(NSNumber(1)), "0100")
XCTAssertEqual(descriptorValueToString(0), "")
}

Expand Down
9 changes: 9 additions & 0 deletions src/conversion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ describe('hexStringToDataView', () => {
expect(result.getUint8(2)).toEqual(200);
});

it('should work without spaces', () => {
const value = '0005C8';
const result = hexStringToDataView(value);
expect(result.byteLength).toEqual(3);
expect(result.getUint8(0)).toEqual(0);
expect(result.getUint8(1)).toEqual(5);
expect(result.getUint8(2)).toEqual(200);
});

it('should convert an empty hex string to a DataView', () => {
const value = '';
const result = hexStringToDataView(value);
Expand Down
29 changes: 22 additions & 7 deletions src/conversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,28 @@ export function numberToUUID(value: number): string {
return `0000${value.toString(16).padStart(4, '0')}-0000-1000-8000-00805f9b34fb`;
}

export function hexStringToDataView(value: string): DataView {
const numbers: number[] = value
.trim()
.split(' ')
.filter((e) => e !== '')
.map((s) => parseInt(s, 16));
return numbersToDataView(numbers);
/**
* Convert a string of hex into a DataView of raw bytes.
* Note: characters other than [0-9a-fA-F] are ignored
* @param hex string of values, e.g. "00 01 02" or "000102"
* @return DataView of raw bytes
*/
export function hexStringToDataView(hex: string): DataView {
const bin = [];
let i,
c,
isEmpty = 1,
buffer = 0;
for (i = 0; i < hex.length; i++) {
c = hex.charCodeAt(i);
if ((c > 47 && c < 58) || (c > 64 && c < 71) || (c > 96 && c < 103)) {
buffer = (buffer << 4) ^ ((c > 64 ? c + 9 : c) & 15);
if ((isEmpty ^= 1)) {
bin.push(buffer & 0xff);
}
}
}
return numbersToDataView(bin);
}

export function dataViewToHexString(value: DataView): string {
Expand Down

0 comments on commit e064587

Please sign in to comment.