hyperpb는 UPB 등 기존의 하이퍼포먼스 Protobuf 파서의 장점을 Go 환경에 맞게 새롭게 구현한 런타임입니다. 동적 실행, 제로 카피, 아레나 재사용 등 다양한 최적화 기법을 통해 기존 Go 생태계에서 최고의 프로토콜 버퍼 해석 성능을 보여줍니다.
저는 그동안 C++ 런타임, Rust 런타임, 그리고 동료 Josh Haberman이 작성한 가장 빠른 Protobuf 런타임인 UPB 통합 등, 하이퍼포먼스 Protobuf와 관련된 여러 프로젝트를 담당해왔습니다. 보통은 현 직장에서 하는 일 자체를 직접 포스팅하지 않지만, 최근 취미로 시작했다가 정식 제품이 된 hyperpb에 대해 정말 흥미진진하게 소개하고 싶었습니다.
아래는 여러 Go Protobuf 파서와 비교한 벤치마크 결과입니다. 제출한 벤치마크 중 일부이며, 전체 벤치마크 스위트에는 수십 가지 이상의 테스트 케이스가 있습니다. 결과는 AMD Zen 4 머신에서 측정했습니다.
hyperpb의 다양한 구성(컬러 바)과 경쟁 파서(회색 바)의 처리량 비교. 각 단계의 hyperpb는 모든 이전 단계의 최적화를 포함하며, 이는 제로카피 모드, 아레나 재사용, 프로파일 기반 최적화에 대응합니다. 클수록 성능이 좋습니다.
과거에는 Protobuf 백엔드가 각 타입에 특화된 소스코드를 생성하고, 이를 통해 파서를 생성하는 방식이 주류였습니다. 이런 방식은 각 메시지 타입에 맞춤화되므로 성능이 뛰어날 것 같았지만, 다음과 같은 단점이 있습니다.
이러한 현상은 일반적인 워크로드에서 바로 체감되지는 않지만, 대형 switch문 혹은 field 번호 이진 검색 등에 기인한 브랜치 체인에서 고 필드 번호의 파싱이 느려지는 문제가 나타날 수 있습니다. 하지만, 모든 Protobuf 코덱은 필드를 index 순(즉, .proto 파일 선언 순서)으로 내보내는 속성이 있음에도 불구하고 switch문에서는 이점을 활용하지 못합니다.
UPB는 이 문제를 해결합니다. UPB는 완전히 동적으로 동작하는 Protobuf 메시지 파서로, 실제로는 데이터 테이블의 집합이 _테이블 기반 파서_에서 해석되어 실제 VM에서 프로토콜 메시지를 바이트코드처럼 실행합니다. UPB는 또한 복잡한 메시지 파싱 시 할당량을 개선하기 위한 다양한 아레나 최적화를 제공합니다.
hyperpb는 Go 특유의 환경과 기괴함(?)에 맞추어 UPB의 다양한 최적화에 더해 새롭게 개발된 완전 새 라이브러리입니다. 거의 모든 벤치마크에서 경쟁작을 압도하며, 완전한 런타임 다이내믹 구조를 가집니다. 즉, Protobuf Go의 코드 생성 방식뿐 아니라, 보다 빠르다고 알려진 vtprotobuf(비표준 비호환 파서)까지 능가합니다.1
이번 글에서는 hyperpb의 내부 구현 일부를 소개합니다. 좀 더 "영업용"으로 풀어쓴 버전은 Buf 블로그에서 읽으실 수 있습니다.
UPB는 정말 멋집니다. 사실상 모든 언어가 C FFI를 지원하니, 어디서나 쓸 수 있습니다.
하지만, Go의 C FFI(cgo)는 정말 형편없습니다. GC와 호환되는 방식으로 메모리를 넘기는 게 거의 불가능하며, C에서 Go의 메모리를 다루려면 많은 문제가 따르기 때문입니다. C 메모리를 GC가 안전하게 청소하게 하려면 느린 파이널라이저가 필요하고, C 코드 진입/호출 시 Go 스케줄러에도 악영향을 미칩니다.
이런 것들은 우회할 방법이 없는 건 아니지만, 예를 들어 UPB를 어셈블리로 컴파일해서 Go의 구닥다리 어셈블리 문법으로 변환 후 Go에서 조립하게 하는 방법도 고민했었습니다.2 하지만 Go의 어셈블리 호출 규약은 아직 석기시대 급(인자 스택 전달)3이며, 결국 UPB를 protoreflect API에 맞추려 해도 많은 추가작업이 필요합니다.
또한, Go에는 프로토콜 파서를 만들 때 흥미로운 최적화 기회를 제공하는 독특한 특성들이 있습니다.
x86_64에서 아홉 개나 되는 인자/반환 레지스터를 지원하는 덕에 파서 상태를 전부 레지스터로 전달할 수 있습니다.또한, Go 생태계는 느린 startup이나 "main 실행 전 초기화"에 훨씬 관대하며, 이 덕분에 파서 프로그램을 런타임에 생성해 온라인 PGO(실시간 프로파일 기반 최적화) 설계가 가능해집니다. 즉, 세계 최초의 Protobuf JIT 컴파일러를 구현할 완벽한 조건이 갖추어진 셈입니다.
현재 hyperpb의 API는 매우 단순합니다. hyperpb.Compile* 계열 함수로 메시지 디스크립터에 대한 어떤 표현을 받아, *hyperpb.MessageType을 반환합니다. 이 타입은 protoreflect API를 구현해서, 새 *hyperpb.Message를 할당할 수 있고, 이를 proto.Unmarshal에 넣어 결과에 리플렉션을 수행할 수 있습니다. (참고로 현재는 메시지 변경은 지원하지 않으며, 모든 변경 시도는 패닉이 납니다.)
대표적인 사용 예시는, Buf의 protovalidate 라이브러리를 사용할 때 나타납니다. 리플렉션을 통해 검증 프리디케이트를 실행할 수 있습니다. 예시는 다음과 같습니다.
go// FileDescriptorSet로부터 메시지 타입을 컴파일 msgType := hyperpb.CompileForBytes(schema, "my.api.v1.Request") // 해당 타입의 메시지 생성 msg := hyperpb.NewMessage(msgType) // 평소대로 Unmarshal if err := proto.Unmarshal(data, msg); err != nil { // 파싱 실패 처리 } // 메시지 검증 (protovalidate는 리플렉션 사용) if err := protovalidate.Validate(msg); err != nil { // 검증 실패 처리 }
컴파일 단계는 느릴 수 있으니 반드시 캐시하라고 권장하고 있습니다. 마치 regexp.Compile 이용 시 컴파일 결과를 캐시하는 관행과 동일합니다.
이외에도 컴파일러, Unmarshal, 프로파일 기록용 성능 튜닝 옵션이 다양하게 제공됩니다. 실제 전송되는 메시지 유형에 특화된 최적화를 위해 프로필을 기록해 타입을 재컴파일할 수도 있습니다. hyperpb 자체의 PGO4는 아래 구현 설명에서 자세히 다룹니다.
핵심 구현은 대부분 internal/tdp 하위에 위치합니다. 주요 컴포넌트는 다음과 같습니다:
tdp: 인터프리터용 "오브젝트 코드 포맷" 정의. 타입/필드 설명 등 포함.tdp/compiler: protoreflect.MessageDescriptor를 tdp.Library로 변환하는 컴파일러.tdp/dynamic: 동적 메시지 타입 구조 정의. 필드 오프셋 등 레이아웃 정보 제공.tdp/vm: VM 상태, 파서 인터프리터 구현 및 고성능 varint/UTF-8 파싱 루틴 포함tdp/thunks: archetype 관리(필드 레이아웃+파서의 클래스화). 약 200여 archetype 존재.전체를 모두 설명하기엔 너무 방대하므로, 여기서는 오브젝트 코드의 구조와 파서가 해석하는 방식, 그리고 주요 최적화 몇 가지를 소개합니다. (아레나 설계 등은 별도 포스트 참고)
루트 메시지에서 도달 가능한 모든 MessageDescriptor(필드/익스텐션 포함)는 tdp.Type이 됩니다. 각 타입은 가변 크기, 파서 포인터(타입마다 파서가 다수일 수 있음), 그리고 가변 개수의 tdp.Field(필드 오프셋, 접근용 함수 포인터 포함)를 지닙니다.
tdp.TypeParser 객체는 실제 파서 VM에서 해석되는 실질적 파서 구성요소로, 이 메시지의 모든 필드/확장에 대한 tdp.FieldParser 집합, 태그별 필드 lookup용 해시테이블 등을 압축형태로 보유합니다.
각 tdp.FieldParser에는
이 포함됩니다.
각 필드는 가능한 tag별로 여러 FieldParser를 가질 수 있습니다. 예를 들어 repeated int32는 반복 표현(tag 타입 VARINT)과 packed 표현(tag 타입 LEN) 모두에 대응해야 합니다.
각 필드는 파싱 후 어떤 필드들을 다음 후보로 시도할지도 지정합니다. 즉, _필드 스케줄링_이 컴파일러에서 일어나고, 브랜치 예약과 유사한 최적화가 일어나 일반적인 field lookup 비용을 최소화합니다.
아직 완벽한 후속 필드 예측 알고리즘을 완성하지 못했지만, PGO 기반의 "branch prediction"을 적용하는 시스템을 설계 중입니다.
필드의 오프셋 정보는 단순 메모리 오프셋 그 이상입니다. tdp.Offset에는 비트 필드 오프셋(옵셔널, bool 등)과, 바이트 단위 오프셋이 모두 들어갑니다. 바이트 오프셋이 음수이면 cold region 영역을 의미합니다.
대개의 메시지는 필드 대다수가 세팅되지 않으며, 특히 익스텐션이 그렇습니다. 이런 희귀 필드는 cold region에 별도 할당되어, 필요한 경우에만 느린 경로(슬로우 패스)로 할당되며, cold 여부는 PGO에 의해 동적으로 변할 수 있습니다.
파서는 Go의 레지스터 ABI를 최대한 활용하되, 정말 필요할 때만 스택에 spill이 발생하도록 설계되어 있습니다. 파서 상태는 8개 64비트 정수(p1, p2)에 담기며, 이 상태는 각 파서 함수의 앞/뒤 인자로 전달됩니다. (컴파일러 버그 이슈 참고)
모든 파서 함수는 이 두 구조체를 인자로 받고, 반환도 마찬가지 방식입니다. 레지스터 할당 효율성을 최대로 유지해 중간 상태가 항상 레지스터에 있습니다. 흔한 구문은 다음과 같습니다.
govar n int p1, p2, n = DoSomething(p1, p2)
이렇게 하면 파서 상태가 항상 최신 함수 호출에서 업데이트된 값을 가지죠.
Go가 파서 상태를 스택에 잘못 spill해 stall을 만드는 경우를 잡기 위해 프로파일링과 코드 분석에 엄청난 시간을 들였습니다. #73589 버그도 그 과정에서 발견한 충격적 사례입니다.
VM의 핵심 루프는 대략 다음과 같습니다:
이 모든 것은 Go에서 좋은 컨트롤 플로우를 강제로 얻기 어려워, 메인 파서가 if와 goto만으로 구현되어 있습니다.
혹시 "핫 루프에 가상 함수 호출을 한다니 느린 거 아냐?"라는 의문이 들 수 있지만, 실제로는 이 경우가 더 빠릅니다. 그 이유는 다음과 같습니다:
실제로 대형 switch의 최적형은 jump table이며, 그 본질도 함수 포인터 배열 + 간접 분기입니다.
즉, 대부분 메시지가 실제로 필요한 archetype 수는 소수에 불과하므로, 런타임에 사용하는 archetype이 적을수록 간접 분기 예측이 매우 잘 작동합니다. 한편, 필요한 archetype이 늘어나도 사용빈도가 낮으면 성능에 영향이 없습니다. PGO가 적합한 후보 archetype을 선택해 최적화합니다.
이미 hot/cold 분할, hasbits와 bool 최적화 등을 다루었지만, 몇 가지 흥미로운 널리 적용되는 최적화들도 소개합니다.
복사가 가장 빠른 memcpy다! 입력 버퍼에서 가능한 곳은 아예 복사를 피합니다. Go의 string과 bytes는 모두 zc.Range로, offset+길이를 uint64에 패킹해 관리합니다. Protobuf는 2GB보다 긴 필드를 딱히 지원하지 않으므로, 8바이트에 전부 표현이 가능합니다.
패킹된(repeated double 등) 필드도 제로카피로 처리합니다. 기록 전체를 하나의 zc.Range로 보유하고, 필요하면 분리된 arena slice로도 저장할 수 있습니다. 변환 경계처리 등은 섬세하게 구현되어 있습니다.
놀랍게도, varint 필드(예: repeated int32)도 제로카피를 지원합니다. varint 크기가 모두 동일(1바이트: 0~127)하면 index 접근도 가능합니다. 이는 매우 흔한 시나리오이며, 엄청난 성능 이점을 줍니다.6
PGO는 각 반복/맵 필드의 중앙값(메시지 하나에 나타날 평균 크기)을 기록해, 필드에 할당할 시 효율적으로 적절한 크기로 미리 할당(preload)합니다. 중앙값을 쓰면 outlier(big message)에 의한 메모리 낭비를 막으면서, 최소 50% 필드는 arena 할당 1회만에 ok.
패킹된 필드는 대체로 1개 레코드만 나타나므로 preload를 사용하지 않습니다. 이 최적화는 string, message 타입 반복 필드에서 크게 작동합니다.
Go 내장 map은 삭제, 반복 중 변경 등 많은 general-case 오버헤드가 있습니다. 제 구현에서는 swiss.Table로 arena 관리 메모리 사용 및 maximum 성능을 냅니다.7
해시 함수는 Rust 컴파일러의 fxhash 변형을 현재 사용 중입니다. 큰 문자열은 Go의 maphash가 빠르지만, int용은 fxhash가 월등합니다.
Go의 메모리 할당기는 아무리 빨라져도 범용성 때문에 오버헤드가 남습니다. 반복적인 워크로드라면 각 요청마다 메시지 할당 후 처리 완료 시 arena를 reset(큰 블록을 retain), 다음 메시지 재할당 시 아레나가 새 블록을 잡지 않아도 됩니다.
메시지가 크기를 넘어서면 2배로 블록이 증가하며, 시간이 지날수록 최적 크기 블록만 캐싱하게 됩니다. 제로카피 최적화 덕분에 실제 메모리 사용도 최소화됩니다.
단, reset 후 이전 메시지가 남아있으면 메모리 안전성이 위험하므로, arena reset은 기본 불가 옵션이지만, 끄고 사용하면 10% 이상 성능 향상을 얻을 수 있습니다.
Go는 C++의 union 구조를 제대로 지원 못합니다. 그래서 원래 인터페이스 포인터를 사용하게 되어, 불필요할 만큼 자주 할당이 일어납니다.
그러나 저희 아레나 디자인에서는(설명은 여기) arena memory는 GC 스캔 대상이 아니므로 실제로 union처럼 integer/포인터 혼용 저장이 가능합니다. 즉, oneof도 진짜 union처럼 쓸 수 있습니다.
hyperpb는 성장하는 JIT 기능 덕분에 UPB 대비 비약적인 발전 가능성을 제시하는 존재입니다. Go 컴파일러의 다양한 버그를 피해가며 최최적화된 어셈블리 코드를 뽑아내는 도전도 정말 재미있습니다. 이미 자체 최적화가 너무 좋아서 Go의 PGO 모드로 벤치마크 프로필을 돌려봐도 실질적 성능 향상이 거의 없었습니다!
저는 일을 하며 계속 hyperpb를 개선하고 있고(급여도 받으니!), 새로운 최적화 아이디어도 항상 환영합니다. 기여를 원하시면, 코드 곳곳에 주석을 꼼꼼히 남겼으니, 몇 군데만 읽어보셔도 구조를 충분히 파악하실 수 있을 것입니다.
앞으로 더 깊이 구현 내부를 파헤치는 글을 쓸지도 모르겠습니다. 지금은 소스 다이빙을 마음껏 즐기시길! 할 것은 정말 많습니다. 예를 들어 SIMD로 varint 파싱을 더 빠르게 하거나, 파서 스케줄링을 더 똑똑하게 하거나, 작은 메시지를 아레나 인라인 할당으로 locality를 개선하는 등, 아직도 할 수 있는 최적화가 무궁무진합니다.
무엇보다도, 이번 글이 여러분이 성능 최적화에 대해 새로운 통찰을 얻는 데 도움이 되었길 바랍니다!
vtprotobuf는 비표준 행태 때문에 두세 벤치마크에서만 우리보다 빠릅니다. 예를 들어 UTF-8 검증을 생략하거나, map entry가 항시 순서대로 등장하고 값이 반드시 채워져 있다고 가정하는 등 실제 Protobuf 스펙에 어긋난 해석을 합니다. 이러한 설계 오류로 인해 vtprotobuf는 일부 valid Protobuf 메시지를 잘못 해석할 수 있습니다.↩
Rob을 이 일로는 평생 놀릴 겁니다. Go 어셈블러 문법은 Rob의 가장 용서받기 힘든 디자인 실수 중 하나입니다.↩
실제로 hand-written 어셈블리가 유의미한 구간은 varint 디코딩과 UTF-8 validation 딱 두 곳뿐입니다. 둘 다 벡터화가 잘 되지만, ABI0의 비효율성 탓에 핸드 코드가 더 빠른 경우는 거의 없습니다. 만약 하게 되면, 별도 빌드 태그와 함께 compile flag로 Go의 내부 ABI를 허용해야 하므로 번거롭습니다.↩
이건 hyperpb 자체 PGO이며, Go gc의 PGO 모드와는 상관없고 gc PGO가 hyperpb엔 성능 향상을 주지 않았습니다.↩
파서는 고루틴 스택과 별도의 스택을 직접 관리합니다. 이는 파서가 재진입 필요가 없도록 하기 위함이며, 서브메시지로 "재귀"할 때만 스택에 push합니다.↩
대형 packed 반복 필드에서 제로카피 효과가 극대화됩니다. 예를 들어 작은 값이 가득한 int32 필드는 다른 런타임에서는 생기는 모든 오버헤드를 제거할 수 있습니다.
여러 반복 필드 벤치마크의 처리량. (repeated fixed32 벤치마크는 너무 빨라(~20Gbps) 그래프가 안 보이므로 제외)
이 최적화들은 첫 벤치마크 차트의 descriptor/#00과 descriptor/#01의 차이를 설명합니다. 후자는 SourceCodeInfo가 포함된 FileDescriptorSet으로, repeated int32 필드가 지배적입니다.
주: 현재 이 차트는 Y축이 빠짐. 추후 수정 예정.↩
{1: {"key"}}(값은 "")나 {2: {"value"} 1: {"key"}}(field 순서 무관) 같은 valid한 map entry 인코딩을 거부하거나 잘못 해석합니다.여기 최신 맵 벤치마크 결과가 있습니다:
맵 파싱 벤치마크별 처리량.
프로토버프에서 맵 활용도가 낮다는 게 일반적이라, packed repeated 필드만큼 최적화에 집중하지 않았습니다.↩