ByteArray to Hex Performance Comparison in Kotlin

최근, BLE 관련 프로젝트를 진행하다가 Notify로 받아온 ByteArray 를 Hex String 로 변환하는 과정에서 딜레이가 발생하는 일이 있었다.

따라서 이번에는 구글로 찾아보면 나오는 ByteArray to Hex 에 대해 여러 방법에 대해 알아보고, 간단한 벤치마크를 작성하여 어느 방법이 성능이 더 잘 나오는지 비교해보려 한다.

비교 대상

비교 대상은 총 5개로, 코틀린 코드와 JVM 디컴파일 결과는 다음과 같다. 괄호 안의 텍스트는 벤치마크 코드 / 결과에 있어서의 이름으로 간주된다.

StringBuilder + String.format (BuilderFormat)

fun toHexString(byteArray: ByteArray): String {
    val sbx = StringBuilder()
    for (i in byteArray.indices) {
        sbx.append(String.format("%02X", byteArray[i]))
    }
    return sbx.toString()
}

StringBuilder 를 매번 메서드 실행할 때 마다 생성하고, 각 바이트를 %02X, 즉 Hex 형태로 format 시키는 단순한 코드이다.

String.format (StringFormat)

fun toHexString(byteArray: ByteArray): String {
    var target = ""
    for (i in byteArray.indices) {
        target += String.format("%02X", byteArray[i])
    }
    return target
}

위 방법에서 StringBuilder 만 해제한 것이다.

다만 JVM으로 컴파일 되었을 때 똑같이 StringBuilder 로 컴파일 되므로 차이가 없다고 봐도 무방하다.

Right Shift (CharArrayRightShift)

private val digits = "0123456789ABCDEF"

fun toHexString(byteArray: ByteArray): String {
    val hexChars = CharArray(byteArray.size * 2)
    for (i in byteArray.indices) {
        val v = byteArray[i].toInt() and 0xFF
        hexChars[i * 2] = digits[v.ushr(4)]
        hexChars[i * 2 + 1] = digits[v and 0x0F]
    }

    return String(hexChars)
}

StringBuilder 를 사용하지 않는 대신, 바이트의 2배 길이만큼 가진 CharArray를 만들고 각 쌍의 첫번째 인자에는 digits 에서 해당 바이트 >>> 4 한 만큼의 위치값, 두번째 인자에는 해당 바이트 & 0x0F 한 만큼의 위치값을 넣는다. 마지막으로 CharArray 를 String 로 만들어 반환한다.

StringBuilder + Shift (BuilderShift)

private val digits = "0123456789ABCDEF"

fun toHexString(byteArray: ByteArray): String {
    val buf = StringBuilder(byteArray.size * 2)
    for (i in byteArray.indices) {
        val v = byteArray[i].toInt() and 0xff
        buf.append(digits[v shr 4])
        buf.append(digits[v and 0xf])
    }
    return buf.toString()
}

StringBuilder 를 사용하지만,  String.format 를 사용하는 것이 아닌 해당 바이트 >> 4 와 해당 바이트 & 0xF 한 값을 각각 append 하여 String로 만든다.

CharArray + Shift (CharArrayShift)

private val digits = "0123456789ABCDEF"

fun toHexString(byteArray: ByteArray): String {
    val hexChars = CharArray(byteArray.size * 2)
    for (i in byteArray.indices) {
        val v = byteArray[i].toInt() and 0xff
        hexChars[i * 2] = digits[v shr 4]
        hexChars[i * 2 + 1] = digits[v and 0xf]
    }
    return String(hexChars)
}

기본 접근 방법은 CharArray + Right Shift 와 동일하나, 여기에서는 Right Shift 가 아닌 일반 Shift 가 사용되었다.

성능 비교 방법

먼저, 측정에 들어가기 전 32의 사이즈를 가진 ByteArray 를 랜덤으로 생성하고, 지정된 카운트 만큼 반복하여 걸린 시간을 나노초 단위로 구한다.

private fun measure(block: () -> Unit): String {
    val nano = measureNanoTime {
        for (i in 0 until count) {
            block()
        }
    }

    val ms = nano / 1000000.0
    return String.format("%.3f", ms.toFloat())
}

일반적으로 Kotlin 에서 제공되는 measureNanoTime 를 약간 변형하여 block 에는 toHexString 메서드가 실행되도록 만들었다.

결과 분석

결과에 쓰여진 기기의 사양은 다음과 같다.

OS: Apple macOS 10.13.5 (High Sierra) build 17F77
Processors: Intel(R) Core(TM) i7-4770HQ CPU @ 2.20GHz
Total Memory: 16GB
Time: 2018-10-08 AM 02:39:56

벤치마크를 구동했을 때, 아래와 같은 결과가 나온다.

먼저, 코드 상에서 StringBuilder가 있음과 없음은 그렇게 많이 차이나지않는다는 것이다.  중요한 것은 String.format 가 있는가 없는가의 차이로, 최대 58배 이상 시간이 소요된다.

비트 연산을 진행하는 3개 방법의 경우 큰 차이점은 보이지 않으나, 대체적으로 마지막 방법인 CharArray + Shift 가 다소 빠른 측정 결과를 보여주었다.

코드 공유

벤치마크 구동에 있어 작성된 코드는 WindSekirun/ByteArrayToHexPerformance 레포지토리에서 찾아볼 수 있고, 구동시 출력되는 결과는 다음과 같다.

Please enter the number you want to execute.
10
Please enter the length of bytes to be generated.
32

==== ByteArray to Hex String Benchmark ====
OS: Apple macOS 10.13.5 (High Sierra) build 17F77
Processors: Intel(R) Core(TM) i7-4770HQ CPU @ 2.20GHz
Total Memory: 16GB
Time: 2018-10-08 AM 03:02:10

Starting with count as 10 with length of ByteArray as 32
Measure in progress...

BuilderFormat 9.632ms
StringFormat 5.407ms
CharArrayRightShift 0.321ms
BuilderShift 0.338ms
CharArrayShift 0.273ms

Finished! in 15.971ms