March 27, 2022

Go에서의 빠른 Hex decode

인코딩은 어떤 데이터를 원하는 포맷으로 변경시키는 것을 말한다. 만약 stringbase64로 변경시켜야 하는 경우 string을 b64로 인코딩한다고 한다. 반대 방향의 변환은 디코딩이라고 부른다. 정확히는 방향이 반대일 때 디코딩은 아니고, 주체가 어떤 것이냐에 따라 다르다고 할 수 있다. 예를 들어 b64를 디코딩하여 문자열을 만들 수 있고, 또 b64를 문자열로 인코딩할 수도 있을 것이다. 두 연산의 결과는 같겠지만.

아무튼 go에서는 encoding/hex패키지에서는 다음과 같은 함수를 제공한다.

func Encode(dst, src []byte) int // Hello Gopher! -> 48656c6c6f20476f7068657221
func Decode(dst, src []byte) int, error // 48656c6c6f20476f7068657221 -> Hello Gopher!

Encode를 사용하여 문자열을 hex string으로 변경한다. 반대로 Decode를 사용하여 hex string을 다시 문자열로 변경한다.

Encode

hex string은 문자열이지만 가능한 문자가 한정적이다. 16진수는 0~F까지 표현하기 때문이다. 16문자를 가지고 문자열을 만들어낸다. 그래서 함수 Encode에서는 파라미터 src를 사용하여 다음과 같은 dst를 만들어낼 수 있다.

const hextable = "0123456789abcdef"

func Encode(dst, src []byte) int {
    j := 0
    for _, v := range src {
        dst[j] = hextable[v>>4]
        dst[j+1] = hextable[v&0x0f]
        j += 2
    }
    return len(src) * 2
}
// https://cs.opensource.google/go/go/+/refs/tags/go1.18:src/encoding/hex/hex.go;l=25

바이트는 0x00~0xFF의 범위를 가지므로 위와 같이 변형하면 된다. src의 크기만큼 루프를 돌면서 다음의 작업을 수행한다.

  • dst[j] = hextable[v>>4]: >>4는 바이트 하나를 오른쪽 4번 시프트하는데 이렇게 하면 leftmost 비트 4개를 얻을 수 있다. 이 값을 인덱스로 해서 hextable에 있는 값을 가져온다. 예를 들어 v가 00101111이었다면 시프트해서 00000010이 되고, 이는 2라서 hextable[2]가 된다.
  • dst[j+1] = hextable[v&0x0f]: rightmost 비트를 가져온다. 비트 오른쪽 4개를 얻기 위해서 &를 하였다.

이렇게 돌면서 비트로 인덱스를 찾아 dst를 한 글자씩 채워나가면 바로 Encode가 된다.

Decode

Decode는 hex string 두 글자를 보고 이를 다시 바이트로 역변환하면 된다. 들어올 수 있는 가능한 문자는 0부터 9, a부터 f까지라서, 숫자와 문자를 각각 그룹이라고 생각하면 해당 그룹에 포함된 문자를 그 그룹의 처음 문자로 아스키코드 뺄셈을 한다. 그러면 결과는 그 그룹의 순서가 리턴된다. 만약 그룹이 문자였다면 +10을 해야 16진수 a가 된다.

그 코드는 아래와 같다.

func Decode(dst, src []byte) (int, error) {
    i, j := 0, 1
    for ; j < len(src); j += 2 {
        a, ok := fromHexChar(src[j-1])
        if !ok {
            return i, InvalidByteError(src[j-1])
        }
        b, ok := fromHexChar(src[j])
        if !ok {
            return i, InvalidByteError(src[j])
        }
        dst[i] = (a << 4) | b
        i++
    }
    if len(src)%2 == 1 {
        // Check for invalid char before reporting bad length,
        // since the invalid char (if present) is an earlier problem.
        if _, ok := fromHexChar(src[j-1]); !ok {
            return i, InvalidByteError(src[j-1])
        }
        return i, ErrLength
    }
    return i, nil
}

// fromHexChar converts a hex character into its value and a success flag.
func fromHexChar(c byte) (byte, bool) {
    switch {
    case '0' <= c && c <= '9':
        return c - '0', true
    case 'a' <= c && c <= 'f':
        return c - 'a' + 10, true
    case 'A' <= c && c <= 'F':
        return c - 'A' + 10, true
    }

    return 0, false
}
// https://cs.opensource.google/go/go/+/refs/tags/go1.18:src/encoding/hex/hex.go;l=84

Decode에서는 루프를 돌며 값을 처리한다. Encode와 비슷하므로 크게 설명할 것은 없다. fromHexChar를 통해 switch-case에서 hex string의 그룹을 찾는다. aA는 같은 hex이므로 부득이하게 그룹을 나눈다. case의 조건은 yoda condition으로 작성되었다.

변경된 Decode

사실 이 글을 쓰게 된 이유는 최근 go 리파지터리에 올라온 다음과 같은 커밋을 보았기 때문이다.

const (
    reverseHexTable = "" +
        "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
        "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
        "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
        "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\xff\xff\xff\xff\xff\xff" +
        "\xff\x0a\x0b\x0c\x0d\x0e\x0f\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
        "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
        "\xff\x0a\x0b\x0c\x0d\x0e\x0f\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
        "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
        "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
        "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
        "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
        "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
        "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
        "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
        "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" +
        "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff"
)

func Decode(dst, src []byte) (int, error) {
    i, j := 0, 1
    for ; j < len(src); j += 2 {
        p := src[j-1]
        q := src[j]

        a := reverseHexTable[p]
        b := reverseHexTable[q]
        if a > 0x0f {
            return i, InvalidByteError(p)
        }
        if b > 0x0f {
            return i, InvalidByteError(q)
        }
        dst[i] = (a << 4) | b
        i++
    }
    if len(src)%2 == 1 {
        // Check for invalid char before reporting bad length,
        // since the invalid char (if present) is an earlier problem.
        if reverseHexTable[src[j-1]] > 0x0f {
            return i, InvalidByteError(src[j-1])
        }
        return i, ErrLength
    }
    return i, nil
}

// https://github.com/golang/go/pull/51432/files

Encode에서 hexTable을 참조하는 것처럼, Decode에서는 reverseHexTable을 참조하도록 변경했다. switch-case에서 조건별로 분기하지 않고, 그 값을 그대로 테이블에서 찾아 사용하도록 한 것이다.

reverseHextable은 아스키 코드에서 hex에 대응하는 값만을 실제 바이트로 매핑했다. 따라서 fromHexChar에서 세 그룹으로 나누었던 부분만 바이트로 매핑하고 나머지는 전부 \xff로 채워 두었다. 그래서 이 테이블을 참조하는 루프에서는 0x0f보다 큰 값이 디코딩 대상인 경우 InvalidByteError()를 리턴하도록 처리되었다.

이렇게 하여 기존 코드 대비해서 함수 하나가 삭제되고 대신에 256바이트짜리 테이블이 추가된다. 속도는 해당 유저가 올린 내용을 보면 벤치마크를 해 두어서 여기에 한번 옮겨본다.

Implement hex decode using a 256 byte lookup table instead of branching logic.

In happy flow, uses 3x 64 byte (or 5x 32 byte) cache lines.

name             old time/op    new time/op    delta
Decode/256-64       223ns ± 3%     135ns ± 2%  -39.64%  (p=0.000 n=8+8)
Decode/1024-64      872ns ± 2%     512ns ± 2%  -41.25%  (p=0.000 n=8+8)
Decode/4096-64     3.43µs ± 1%    2.01µs ± 2%  -41.31%  (p=0.001 n=7+7)
Decode/16384-64    13.9µs ± 1%     8.0µs ± 1%  -42.69%  (p=0.000 n=8+7)

name             old speed      new speed      delta
Decode/256-64    1.15GB/s ± 3%  1.90GB/s ± 2%  +65.66%  (p=0.000 n=8+8)
Decode/1024-64   1.17GB/s ± 2%  2.00GB/s ± 2%  +70.22%  (p=0.000 n=8+8)
Decode/4096-64   1.20GB/s ± 1%  2.04GB/s ± 2%  +70.39%  (p=0.001 n=7+7)
Decode/16384-64  1.18GB/s ± 1%  2.06GB/s ± 1%  +74.49%  (p=0.000 n=8+7)

코드를 작성한 사람의 코멘트로는 다음과 같은 장점이 있다고 되어 있다.

Also reduces amd64 object size by 766 bytes, despite the extra RODATA due to removal of fromHexChar() and duplicated inlined versions of it and simplification of Decode().

왜 map이 아닌가?

그런데 잘 생각해보면 256바이트의 추가 테이블을 쓰지 않고, 맵으로 키와 매핑하면 더 효율적이 아닌가? 하는 생각이 들 수 있다. 왜냐하면 결국 reverseHexTable이 하는 일은 어떤 인덱스로 해당 값을 찾을 수 있게 하는 것이 목적이고, 그렇다면 똑같은 인덱싱 연산을 하는 map을 쓰는 것이 더 낫지 않을까? 하는 것이다. 만약 맵으로 한다면 ASCII에 대응하는 키 16개만 가지고 있으면 똑같이 값을 변환할 수 있으니까 공간적으로도 더 효율적일 수 있다. 단순 따져서 맵에 우리가 모르는 추가 구현이 있어서 실 사용량은 16바이트+@라 하더라도 설마 256바이트보다 클까? 하는 것이다.

그러나 아쉽게도 그렇지는 않다. 간단하게 이야기하면 맵 억세스 최적화는 컴파일 타임에 이루어지지 않는다. 반면 switchcase에 둔 식이 상수라면 컴파일 타임에 최적화시키는 것이 가능하다. 그래서 인터넷에서 흔히 검색할 수 있는 switch-case vs. map의 경우는 거의 모든 케이스에서 switch-case가 항상 빠르다.

Hash Table

slice, 슬라이스 또한 맵처럼 인덱싱할 수 있다. 인덱스를 주고 해당 값에 억세스할 수 있다. 차이점이 있다면 슬라이스는 연속된 메모리가 늘어져 있는 형태이므로 단순 연산으로도 해당 위치의 값을 찾을 수 있다. 반면 맵은 다르다. go의 맵은 hashmap형태로 구현되어 있는데, 해시 테이블을 통해 키로서 값을 찾는다. 즉 키로 값을 찾는 것에 슬라이스보다 추가적인 연산이 필요하다는 뜻이다. 해시 테이블이라 하면 아무래도 효율적이고 대부분 빠르긴 하겠지만 대부분의 연산에 충돌 케이스를 핸들링하거나, 탐색을 할 때 링크드 리스트를 탐색하거나 하는 케이스가 발생하므로 간단한 덧셈을 통해 값에 접근할 수 있는 슬라이스보다 느릴 수 밖에 없겠다. 멀리서 보면 큰 차이는 없겠지만.

결론

나름대로 결론을 내리면 다음과 같다.

  • if-else가 좋아요 switch-case가 좋아요?: 그냥 아무거나 써도 컴파일되면 똑같다. 조건절이 constant인 것이 효율적이다.
  • 조건절이 너무 많으면 어떻게 될까요?: 이진 탐색으로 최적화되니까 그냥 적당히 써도 된다.
  • go의 map은 hashmap으로 구현되어 있음
  • mapO(1)이니까 빠를 것 같아요: 일반적인 상황에서는 제일 빠르고 무난하다. 몸 비틀면서 최적화한다면 이런저런 방법을 찾을 수 있다.

읽어보면 괜찮은 참고 사항들

  • How the Go runtime implement maps efficiently: 다른 언어의 map과 비교해서 어떻게 다른지, Go에서는 어떻게 동작하는지 심플한 설명이 작성되어 있음
  • Go Slice vs. Map: direct lookup의 경우 slice가 압도적으로 빠르다 - 위 pull request의 케이스
  • switch-case implementaed is as follows…: if-else, switch, 상수 파라미터… 관련. 스위치만 이야기하면, 3개 이상의 상수 case일 때는 이진 탐색한다.(groups of larger than 3 constant cases are binary divided and conquered.)
  • map vs switch performance in go: branch 가능한 방법 중 Map/Slice/Switch 세 개의 퍼포먼스가 대략적으로 어떤지 답변에 달려 있다.