Android Base64 Performance Benchmark

도입

안드로이드에서 String나 ByteArray를 Base64 로 인코딩 / 디코딩 하는 라이브러리는 많다. 기본적으로 안드로이드가 제공하는 Base64도 있고, Apache의 Commons-codec에도 있고, JDK 1.8에 (이제야) 구현된 Base64가 있다.

그러면 ‘어떤 것을 사용하는 것이 더 성능이 나올까?’ 라는 단순한 질문이 나올 수 있는데, 이미 자바로는 2014년에 비교한 Base64 encoding and decoding performance 라는 글이 있다.

다만 해당 글에는 안드로이드에선 사용하지 못하는 DataTypeConverter 나 sun 패키지가 있어 참고로 하기에는 어려운 점이 많다.

그래서 이번 기회로 안드로이드에서 사용할 수 있는 Base64 라이브러리 6종에 대해 성능 비교를 하려고 한다.

비교 대상

성능 비교 방법

비교 방법으로는 크게 3가지가 있다.

  • ByteArray <-> ByteArray 인코딩/디코딩
  • ByteArray <-> String 인코딩/디코딩
  • 이미지 / 비디오 파일 인코딩

ByteArray <-> ByteArray 인코딩/디코딩

fun testBytes(bufferSize: Int): HashMap<String, TestResult> {
    val r = Random(125)
    val buffers = ArrayList<ByteArray>()
    for (i in 0 until bufferSize) {
        val buf = ByteArray(bufferSize)
        r.nextBytes(buf)
        buffers.add(buf)
    }

    val results = HashMap<String, TestResult>()
    for (codec in byteCodecList) {
        val name = codec.javaClass.simpleName
        results[name] = testByteCodec(codec, buffers)
    }

    return results
}

@Throws(IOException::class)
private fun testByteCodec(codec: Base64ByteCodec, buffers: List<ByteArray>): TestResult {
    val encoded = ArrayList<ByteArray>()
    val result = ArrayList<ByteArray>()

    val encodeTime =
        measureTimeStopWatch { for (buf in buffers) encoded.add(codec.encodeBytes(buf)) }

    val decodeTime =
        measureTimeStopWatch { for (ar in encoded) result.add(codec.decodeBytes(ar)) }

    return TestResult(encodeTime.toDouble(), decodeTime.toDouble())
}

buffersize 만큼의 사이즈를 가지는 ByteArray를 bufferSize개 만큼 생성하고, 이를 각 라이브러리마다 인코딩과 디코딩 시간을 측정한다. 예를 들어 1024로 지정했다면 length가 1024인 ByteArray를 1024개를 생성하여 인코딩 시간과 디코딩 시간을 측정하는 방식이다.

여기서 Base64ByteCodec 는 인터페이스로 이 인터페이스를 구현하는 클래스는 AndroidImpl, ApacheImpl, IHarderImpl, Java8Impl, MiGImpl 총 5개로 Guava는 아쉽게도 지원하지 않는다.

class AndroidImpl : Base64Codec, Base64ByteCodec {
    private val flag = Base64.DEFAULT

    override fun decodeBytes(base64: ByteArray): ByteArray {
        return Base64.decode(base64, flag)
    }

    override fun encodeBytes(data: ByteArray): ByteArray {
        return Base64.encode(data, flag)
    }

    override fun encode(data: ByteArray): String {
        return Base64.encodeToString(data, flag)
    }

    override fun decode(base64: String): ByteArray {
        return Base64.decode(base64, flag)
    }
}

내부 구현체는 이런 식으로 testByteCodec 메서드에서 decodeBytes, encodeBytes 등의 메서드를 호출하면 각 라이브러리의 코드가 호출되는 형식이다.

ByteArray <-> String 인코딩/디코딩

위 ByteArray <-> String 와 비슷한 방식이나 호출되는 메서드만 encode, decode가 불린다.

이미지 / 비디오 파일 인코딩

fun testFile(file: File): HashMap<String, TestResult> {
    val results = HashMap<String, TestResult>()
    val fileBytes = file.readBytes()

    for (codec in byteCodecList) {
        val name = codec.javaClass.simpleName
        results[name] = testByteCodecFile(codec, fileBytes)
    }

    // Guava doesn't support ByteArray -> ByteArray. so we ignore them.
    return results
}

@Throws(IOException::class)
private fun testByteCodecFile(codec: Base64ByteCodec, buffer: ByteArray): TestResult {
    val encodeTime = measureTimeStopWatch { codec.encodeBytes(buffer) }
    return TestResult(encodeTime.toDouble(), 0.0)
}

파일을 객체로 받아서 각 라이브러리마다 인코딩 시간만을 측정한다. (디코딩은 측정하지 않았다.)

벤치마크 결과

테스트 기기는 Galaxy S8, 데이터의 정확성을 위해서 전체 테스트 셋트를 3번 반복하여 나온 평균치로 산정했다.

ByteArray <-> ByteArray 인코딩/디코딩

전체적으로 Java8Impl 와 MiGImpl 가 비슷한 속도를 보이고, AndroidImpl는 디코딩에서는 강점을 보이는 반면 인코딩에서는 Apache와 다를바가 없는 속도를 보여주었다.

ByteArray <-> String 인코딩/디코딩

ByteArray <-> ByteArray를 지원하지 않는 Guava가 추가되었는데, 수치가 많이 튄 것을 볼 수 있다. 어떤 한번만 그런 것이 아닌 지속해서 발생하는 것으로 봐서는 테스트 방법이 적절치 않았다고 판단하거나 Guava가 다른 라이브러리와는 조금 다른 구현체를 가지고 있는 것 같다.

ByteArray <-> ByteArray때와 마찬가지로 인코딩 때는 Java8Impl, MiGImpl가 강점을 보이고 디코딩 때에는 Java8Impl 와 AndroidImpl가 강점을 보였다.

이미지 인코딩 (1.5MB JPG / 5MB PNG / 10MB JPG)

1.5MB에서는 5개 라이브러리가 큰 차이를 보이고 있지는 않았으나, 5MB와 10MB에는 Java8Impl – MiGImpl 가 서로 비슷하고, AndroidImpl – IHarderImpl > ApacheImpl 가 서로 비슷했다.

비디오 인코딩 (10MB MP4 / 30MB MP4)

이미지 인코딩의 5MB, 10MB 이미지와 큰 차이는 보이지 않았다. 마찬가지로 Java8Impl 와 MiGImpl가 서로 비슷했다.

정리

결론은 아래와 같이 정리된다.

  • JDK 1.8을 사용하고,  minSDKVersion을 26 이상으로 설정할 수 있다면 JDK 1.8 Base64가 평균적으로 제일 나은 성능을 보여준다.
  • 현실적으로  minSDKVersion를 그정도까지 높이는 것이 어렵다면, MiG Base64 가 제일 현실적이다.
  • Base64 디코딩을 주로 한다면 Android Base64가 그나마 대안이 될 수 있다. 다만 인코딩은 Apache와 크게 다를바가 없음을 알아두자. 그리고 클라이언트 입장에서는 인코딩을 자주 하지, 디코딩을 자주 하지는 않는다.
  • Apache, IHarder는 사용하지 않는 것으로 한다.
  • Guava의 경우 이번 테스트에서는 수치가 튀었지만, 구성하는 데에 문제가 없다면 굳이 바꿀 필요는 없을 것 같다.

벤치마크시에 사용된 샘플 앱은 Github에 공개되어 있다.

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