sulco · 논문 읽기

protobuf를 읽고 쓰는 데
CPU는 얼마나 드나

A Hardware Accelerator for Protocol Buffers (MICRO 2021)
계기

protobuf를 서로 다른 두 문제에서 만났다

BigQuery에는 배열의 배열이 없다
ARRAY 안에 ARRAY를 바로 둘 수 없다. 이유를 따라가 보면 BigQuery의 원형인 Dremel이 protobuf의 레코드 모델을 그대로 데이터 모델로 쓰고 있다.
관측 데이터의 기본 형식도 protobuf다
OpenTelemetry는 trace와 metric을 기본으로 protobuf로 인코딩해 보낸다. 받아서 저장하는 쪽은 그 bytes를 어디선가 한 번은 열어야 한다.
앞은 스키마 이야기, 뒤는 비용 이야기다. 오늘은 둘을 한 줄로 잇는다.
오늘의 질문

bytes 하나를 따라가며 세 가지를 묻는다

1
wire format은 어떻게 생겼나
이름 대신 번호를 싣는 방식이 왜 배열의 배열까지 막게 되나
2
그 bytes를 다루는 비용은 어디서 나오나
구글 전체 서버를 프로파일링한 결과와, 그걸 근거로 만든 하드웨어 가속기
3
수집 경로에서는 몇 번 모양을 바꾸나
앱에서 데이터 웨어하우스까지 protobuf가 열리고 닫히는 자리를 센다
1부

protobuf는 이름 대신
번호를 싣는다

받는 쪽은 필드 이름이 없는 bytes를 무엇으로 읽나
1부 · 바탕

직렬화는 메모리 속 객체를 한 줄짜리 bytes로 펴는 일이다

프로그램 A의 메모리 Item 객체 "hi" [1, 2] id = 150 08 96 01 12 02 … 네트워크로 보내거나 파일에 쓴다 프로그램 B의 메모리 다른 서버, 다른 언어일 수도 있다 새 Item 객체 serialize deserialize 왜 그냥 메모리를 복사해서 보내지 않나 객체는 메모리 여기저기에 흩어져 있고 서로 포인터로 이어져 있다. 그 주소는 프로그램 A 안에서만 뜻이 있다. 그래서 보내기 전에 값만 뽑아 정해진 순서로 늘어놓고(serialize), 받는 쪽은 그걸 읽어 객체를 새로 만든다(deserialize).
JSON도 직렬화 형식이다. protobuf는 그중 하나이고, 무엇을 bytes에 싣고 무엇을 빼느냐가 다르다.
1부 · 바탕

protobuf는 스키마를 먼저 적고, 코드는 생성해서 쓴다

item.proto 스키마 protoc Go 코드 Java 코드 Python 코드 생성된 코드 안에 serialize와 deserialize 함수가 들어 있다
JSONprotobuf
스키마없어도 된다반드시 먼저 적는다
bytes 안의 필드 이름매번 싣는다번호만 싣는다
사람이 읽기그대로 읽힌다스키마가 있어야 읽힌다
이름을 바꾸면받는 쪽이 깨진다번호가 같으면 괜찮다
Google이 2008년에 공개했다. 이 덱은 proto3 문법으로 예를 들지만, 2부 논문은 Google 안에서 아직 대부분인 proto2를 다룬다.
1부 · 같은 값

같은 레코드가 JSON은 35바이트, protobuf는 11바이트다

// .proto
message Item {
  int32  id   = 1;
  string name = 2;
  repeated int32 tags = 3;
}
// JSON, 35 bytes
{"id":150,"name":"hi","tags":[1,2]}
필드key값bytes
id = 1500896 013
name = "hi"1202 68 694
tags = [1, 2]1a02 01 024
합계11
key 한 바이트 안에 필드 번호와 wire type이 들어 있다. "id", "name" 같은 이름은 bytes 어디에도 없다.
tags는 proto3 기본값인 packed 인코딩으로 셌다
1부 · key와 varint

정수는 7비트씩 끊고, 맨 위 비트로 "더 있다"를 표시한다

key 바이트 08 0 0001 000 필드 번호 1 wire type 0 key = (번호 << 3) | wire type 값 150 = 1001 0110₂ 를 varint로 1 001 0110 0 000 0001 첫 바이트 0x96 · 계속 비트 1 둘째 바이트 0x01 · 끝 낮은 7비트부터 내보낸다. 읽을 때는 0010110 + 0000001 << 7 = 150 wire type은 6종(2종은 폐기)이고, 길이를 정하는 방식만 알려 준다 0 VARINT: int32, int64, uint, sint, bool, enum 1 I64: double, fixed64 · 5 I32: float, fixed32 2 LEN: string, bytes, 하위 message, packed repeated LEN이 string인지 message인지는 bytes만 봐서는 알 수 없다. 그래서 읽는 쪽에 스키마가 있어야 한다.
논문 2.1.2절, protobuf.dev Encoding 문서
1부 · 직접 읽어 보기

받는 쪽은 key를 읽고, 스키마를 보고, 값을 읽는다

읽은 byteskey 풀기스키마에서 찾기값 읽기
08번호 1, wire type 01번은 id, int32varint 96 01 → 150
12번호 2, wire type 22번은 name, string길이 02, 다음 2바이트 68 69 → "hi"
1a번호 3, wire type 23번은 tags, repeated int32길이 02, varint 둘 → [1, 2]
60번호 12, wire type 0스키마에 없음wire type이 varint이니 varint 하나를 건너뛴다
마지막 줄은 예시다. 모르는 번호를 건너뛸 수 있어서, 보내는 쪽이 필드를 새로 추가해도 옛 버전 프로그램이 깨지지 않는다.
1부 · 읽기가 더 어렵다

N번째 필드를 다 읽어야 N+1번째가 어디서 시작하는지 안다

08 96 01 12 02 68 69 1a 02 01 02 varint는 끝 비트를 만나야 길이를 안다 LEN은 길이 varint를 먼저 읽는다 deserialize: 순서대로만 읽는다 bytes 스트림 하나를 앞에서부터 순서대로 읽는다. string과 하위 message 객체도 새로 할당해야 한다. serialize: 필드별로 나눌 수 있다 필드마다 따로 인코딩하고 마지막에 이어 붙인다. 이미 있는 객체를 훑기만 하면 된다.
논문 2.2절. 이 비대칭이 2부에서 가속기 설계를 가른다.
1부 · 배열은 어떻게 싣나

배열은 원소마다 같은 필드 번호를 붙여서 싣는다

tags = [1, 2]를 풀어서 쓰면
18 0118 02
18은 "3번 필드, varint"라는 key다. 같은 key가 두 번 나오면 받는 쪽은 3번 배열에 원소를 하나씩 더한다.
묶어서 쓰면 (packed)
1a 0201 02
"3번 필드, 길이 2"를 한 번 적고 값만 늘어놓는다. 숫자 배열은 이쪽이 기본이다. 그래도 배열을 가리키는 건 여전히 3번이다.
그럼 배열 안의 배열은?
바깥 배열의 원소에는 3번을 붙이면 된다. 그런데 안쪽 배열의 원소에 붙일 번호가 없다. 번호는 스키마의 필드에만 있고, "배열 안의 배열"은 필드가 아니기 때문이다. 다음 장이 이걸 문법으로 보여 준다.
1부 · repeated

repeated는 타입이 아니라 이름 있는 필드에 붙는 라벨이다

// proto3 언어 명세의 생성 규칙
field = [ "repeated" | "optional" ] type fieldName
        "=" fieldNumber ";"
type  = "double" | ... | messageType

repeated Ring rings = 1;        // 됨
repeated repeated double c = 1; // 문법에 자리가 없다
Dremel(2010)의 데이터 모델도 같은 모양이다. Aᵢ : τ[∗|?]에서 반복 표시 ∗는 이름 Aᵢ가 있는 필드에만 붙는다.
GeoJSON Polygon의 coordinates
링의 배열 안에 좌표쌍의 배열이, 그 안에 숫자의 배열이 있다. 배열이 세 겹인데 이름은 coordinates 하나뿐이다. BigQuery 문서도 "An array cannot contain another array directly"라고 적는다.
사이에 struct를 끼우면 받는다
ARRAY<STRUCT<positions ARRAY<…>>>에서는 struct 필드 positions가 안쪽 배열의 이름이 된다.
repetition level도 경로에 있는 이름 있는 repeated 필드 개수만큼만 단계를 센다
1부 · 다른 형식은

배열의 배열은 인코딩이 아니라 타입 체계가 막는다

형식반복을 어디에 두나배열의 배열이름은 누가 짓나
protobuf, BigQuery필드의 라벨(repeated, REPEATED mode)직접은 안 됨사용자가 struct로 직접
Parquetrepeated group에 LIST 주석을 달아 세 단으로 고정됨포맷이 list, element를 대신 지음
Avroarray가 타입이고, items에 아무 스키마나 넣음됨이름이 필요 없음
Parquet도 Dremel의 repetition과 definition level을 쓴다. 같은 인코딩이라도 이름을 대신 지어 주면 배열의 배열을 담는다.
2부

그 bytes를 다루는 데
CPU가 얼마나 드나

구글 데이터센터 전체를 프로파일링해서 가속기를 설계한 논문
2부 · 논문을 한 장으로

재 보고, 상식을 고치고, 그에 맞춰 칩을 설계했다

1
구글 서버 전체를 잰다
CPU 시간이 protobuf 어디에 쓰이는지, 메시지는 얼마나 큰지, 어떤 필드가 많은지 운영 중인 서버에서 샘플링했다.
2
흔한 가정 세 개가 틀렸다
비용은 RPC에서 나온다, 메시지는 크다, 큰 문자열 복사만 빠르면 된다. 측정해 보니 셋 다 아니었다.
3
가속기를 만들어 잰다
CPU 코어 옆에 붙는 회로를 설계하고, 시뮬레이터에서 실제 서비스를 본뜬 벤치마크로 속도를 쟀다.
Karandikar 외, "A Hardware Accelerator for Protocol Buffers", MICRO 2021. Google과 UC Berkeley가 함께 썼다.
2부 · 나오는 낱말

2부를 듣기 전에 알아 둘 낱말 여덟 개

낱말여기서 뜻
fleet한 회사가 운영하는 서버 전체. "fleet 사이클의 9.6%"는 전체 서버 CPU 시간의 9.6%다.
가속기특정 일 하나만 빠르게 하도록 만든 전용 회로. GPU가 행렬 곱 가속기인 것과 같은 뜻이다.
RTL회로를 코드로 적은 설계도. 실제 칩을 만들기 전 단계다.
RISC-V, BOOM공개된 CPU 명령어 체계와, 그걸 따르는 공개 CPU 설계. 성능은 스마트폰 코어(ARM A72) 수준이다.
RoCCCPU가 옆에 붙은 가속기에게 전용 명령어로 일을 넘기는 연결 방식.
FireSimFPGA로 설계도를 실제 속도에 가깝게 돌리는 시뮬레이터. 이 위에서 Linux를 띄웠다.
arena메모리를 큰 덩어리로 미리 받아 두고, 포인터만 밀면서 나눠 쓰는 할당 방식.
hasbits객체 안에서 "이 필드에 값이 있다"를 필드마다 1비트로 적어 두는 칸.
2부 · 바탕

CPU 칩 안에는 코어 여러 개와 캐시 세 층이 있다

칩 하나 코어 0 코어 1 코어 2 … L1 (코어 전용) L1 (코어 전용) L1 (코어 전용) L2 L2 L2 LLC (칩 안의 모든 코어가 같이 쓴다) DRAM (메인 메모리) NIC, GPU (칩 밖 장치) PCIe 버스
어디서 읽나한 번 읽는 데
L1몇 사이클
L2십여 사이클
LLC수십 사이클
DRAM수백 사이클
PCIe 너머 장치수천 사이클 (마이크로초)
DRAM이 느려서, 최근에 쓴 데이터를 코어 가까이 복사해 둔다. 이게 캐시다. 가까울수록 작고 빠르다.
사이클 수는 일반적인 서버 CPU의 대략적인 규모다. 논문이 잰 값이 아니다. L2를 코어 몇 개가 나눠 쓰는 칩도 있다.
2부 · 바탕

가속기를 어디에 붙이느냐에 따라 일을 넘기는 비용이 달라진다

붙이는 자리일을 넘기는 방법데이터는잘 맞는 일
NIC 같은
칩 밖 장치
드라이버가 장치 큐에 명령을 쓰고, 끝나면 인터럽트로 알린다. 한 번 오가는 데 마이크로초 단위다.DRAM에서 장치 메모리로 PCIe를 건너 복사해 간다.크고 독립적인 덩어리. 네트워크로 막 들어온 패킷.
코어 바로 옆
(이 논문)
코어가 실행하는 명령어 한 줄이 그대로 가속기로 간다(RoCC). 몇 사이클이다.복사하지 않는다. 코어와 같은 캐시와 메모리를 그 자리에서 읽는다.작고 잦은 일. 포인터를 따라가며 객체를 읽고 쓰는 일.
protobuf에 NIC가 안 맞는 이유
deserialize의 83.7%는 RPC가 아니라 디스크에서 읽은 데이터나 메모리 안 자료 구조에서 나온다. 이걸 NIC까지 보냈다 받아오면 넘기는 비용이 처리 비용보다 크다.
코어 옆이 맞는 이유
메시지 절반이 32바이트 이하다. 몇 사이클로 넘길 수 있어야 작은 메시지에서도 이득이 남는다.
논문 3.4절, 3.9절, 4.1절. 뒤에 나오는 21장(규모)과 22장(RPC 아님)이 이 표의 근거 숫자다.
2부 · 바탕

논문의 칩은 코어 하나에 가속기 하나를 붙였고, 둘이 L2부터 같이 쓴다

BOOM 코어 (하나)ARM A72 급 protobuf 가속기자기 TLB를 가짐 L1 (코어 전용) L2 (코어와 가속기가 같이 쓴다) LLC DRAM RoCC L1을 건너뛰고 L2로 바로
"공유"는 코어끼리가 아니다
실험한 칩에는 코어가 하나뿐이다. 공유한다는 건 그 코어와 가속기가 같은 L2, LLC, DRAM을 본다는 뜻이다.
코어 L1에만 있는 최신 값은?
칩 안 버스가 coherent하다. 누가 최신 값을 가졌는지 하드웨어가 추적해서, 가속기가 읽으면 최신 값이 온다. 코어는 넘기기 전후로 fence 명령 하나만 넣는다.
코어가 여럿인 서버라면
논문은 코어 하나에 가속기 하나인 구성만 쟀다. 코어마다 하나씩 붙이는 모양이 자연스럽지만 잰 것은 아니다.
논문 4.1절 "all memory accesses made by the accelerator go through the L2 and LLC, which are shared with the application core", 5장 "single-core BOOM-based".
2부 · 바탕

포인터를 따라가려면 가속기도 주소를 바꿀 줄 알아야 한다

프로그램 안의 포인터 0x7f3a_1000 (가상 주소) DRAM의 실제 위치 0x0004_2000 (물리 주소) page table 운영체제가 만든 변환표 (DRAM에 있음) TLB: 최근 변환 결과 캐시
포인터는 가상 주소다
protobuf 객체 안의 string, 하위 message 필드는 모두 포인터다. 그 값은 프로세스마다 따로 있는 가상 주소라, 실제 DRAM 위치로 바꿔야 읽을 수 있다.
코어는 TLB로 바꾼다
바꿀 때마다 page table을 읽으면 느리니, 최근 결과를 TLB에 담아 둔다. TLB에 없으면 page table walker(PTW)가 표를 읽어 채운다.
가속기도 자기 TLB를 갖는다
그래서 프로그램은 주소를 바꾸거나 데이터를 옮기지 않고 포인터만 넘긴다. 가속기가 그 포인터를 직접 따라간다.
논문 4.1절. 가상 주소의 예시 값은 설명용으로 만든 것이다.
2부 · 규모

구글 서버 CPU 사이클의 9.6%가 protobuf 연산에 쓰인다

9.6%
fleet 전체 CPU 사이클 중 protobuf 연산이 차지하는 몫이다. 그중 88%가 C++에서 쓰인다.
2.2%
C++ deserialize 하나만으로 fleet 사이클의 2.2%를 쓴다. serialize는 1.25%다.
3.45%
둘을 합친 몫이 이 논문의 가속 대상이다. 나머지는 merge, copy, 생성자, 소멸자 같은 연산이다.
2015년 측정은 약 5%였다. Facebook도 2020년 핵심 서비스 7개가 사이클의 6% 넘게를 직렬화에 쓴다고 보고했다. (논문 1장, 3.2절)
2부 · 상식 하나

protobuf 비용 대부분은 RPC가 아닌 곳에서 나온다

사이클 중 RPC 스택에서 시작한 몫RPCRPC 아님
deserialize16.3%83.7%
serialize35.2%64.8%
protobuf는 RPC 메시지만이 아니라 저장 형식, 메모리 안 자료 구조, 컬럼 저장의 스키마로도 쓰인다.
흔한 가정: NIC에 붙이자
PCIe로 연결된 SmartNIC에서 처리하면 된다는 제안이 많았다. 그러면 RPC가 아닌 80% 남짓의 작업은 데이터를 일부러 PCIe 너머로 보내야 한다.
결론: CPU 코어 옆에 둔다
객체 접근은 작고 불규칙하며 포인터를 여러 번 따라간다. PCIe의 지연과 맞지 않는다.
논문 3.4절, 3.9절
2부 · 상식 둘

메시지 절반 이상이 32바이트 이하다

top-level 메시지 개수 비율 (초록 막대 셋은 누적) 24% 8B 이하 56% 32B 이하 93% 512B 이하 0.08% 32KB 초과 개수는 0.08%인데 bytes는 8B 이하 구간의 13.7배 이상
작은 메시지가 대부분이라, 가속기에 일을 넘기는 비용이 크면 가속 효과가 사라진다. 그래서 명령 한 줄로 넘기는 구조를 골랐다. (3.5절)
2부 · 상식 셋

bytes를 가장 많이 차지하는 필드가 시간을 가장 많이 쓰진 않는다

무엇으로 셌나결과읽는 법
필드 개수56% 넘게 varint 계열 (int32, int64, enum, bool)작은 필드가 압도적으로 많다
메시지 bytes92% 넘게 string, bytes와 그 repeated 필드memcpy만 빠르면 될 것처럼 보인다
추정 deserialize 시간1GB/s 넘게 처리되는 구간은 시간의 14%뿐큰 bytes 필드는 작은 필드보다 바이트당 100~500배 빠르다
필드 종류와 크기 조합 24개마다 마이크로벤치마크를 돌려 bytes를 사이클로 환산했다. "한 방에 해결되는 부분은 없다"가 결론이다. (3.6절)
2부 · 설계를 정한 숫자들

형식은 잘 안 바뀌고, 중첩은 얕고, 필드는 듬성듬성 찬다

96%
2016년에 proto3가 나왔는데도 처리되는 bytes의 96%가 아직 proto2다. 형식이 오래 안정적이라 하드웨어로 굳혀도 된다.
25
bytes의 99.999%가 중첩 깊이 25 이하다. 하위 message 문맥을 담는 하드웨어 스택을 깊이 25로 잡았다.
52%
메시지의 90% 넘게가 정의된 필드 중 52%도 채우지 않는다. 객체를 통째로 복사하는 방식은 낭비다.
논문 3.3절, 3.8절, 3.9절
2부 · 가속기 구조

코어가 명령 한 줄로 넘기고, 가속기가 같은 메모리를 직접 읽는다

RISC-V OoO 코어 BOOM, ARM A72 급 protobuf 가속기 deserializer · serializer TLB를 갖고 가상 주소로 접근 공유 L2, LLC, DRAM RoCC 명령 deserialize에 넘기는 것 deser_info ADT 포인터, 채울 객체 주소 do_proto_deser 입력 버퍼, 길이, 최소 필드 번호 ADT 메시지 타입마다 하나. protoc가 만들고 프로그램 로드 때 채운다. 필드 번호로 바로 찾는 표: C++ 타입, 객체 안 offset
ADT = Accelerator Descriptor Table. 필드 존재 여부(hasbits)도 필드 번호로 바로 찾도록 바꿨다. (4.1~4.2절)
2부 · deserializer

key를 읽고, 표를 보고, 값을 쓴다. 이걸 필드마다 반복한다

parseKey 10바이트를 엿보고 1사이클에 decode typeInfo 필드 번호로 ADT 항목 조회 스칼라: 바로 기록 string: 할당 후 복사 repeated: 열린 영역에 누적 하위 message: 스택 push push한 뒤 parseKey로 돌아가 하위 message를 읽고, 길이만큼 읽으면 pop해 부모로 돌아간다. signed는 zig-zag 회로 libstdc++ string을 가속기가 직접 만든다 같은 key가 끝나면 닫음
CPU를 거치지 않고 할당하려고, 미리 받은 accelerator arena에서 포인터만 늘려 할당한다. (4.3~4.4절)
2부 · serializer

뒤에서부터 쓰면 하위 message 길이를 미리 알 수 있다

하위 message는 key 뒤에 자기 길이가 먼저 와야 한다: [key][길이][내용…] key · 길이 ? 필드 1 필드 2 앞에서부터 쓰면 내용을 다 쓰기 전엔 길이를 모른다 key · 길이 ③ 필드 1 ② 필드 2 ① 높은 주소에서 낮은 주소로, 큰 필드 번호부터 쓴다 결과는 소프트웨어와 바이트 단위로 같다 작은 번호부터 앞으로 쓴 결과와 똑같은 bytes가 나온다. wire 호환이 유지된다. 필드는 병렬로 인코딩한다 frontend가 hasbits를 훑어 있는 필드만 여러 field serializer에 돌아가며 나눠 준다.
memwriter는 하위 message가 끝났다는 신호(필드 번호 0)를 받으면 그때 key와 길이를 앞에 써 넣는다. (4.5절)
2부 · 결과

구글 서비스를 본뜬 벤치마크에서 Xeon보다 3.8배 빨랐다

벤치마크기준 RISC-V 대비Xeon 대비
마이크로벤치마크 (필드 종류별, 기하평균)11.2배3.8배
HyperProtoBench (서비스를 본뜬 합성 6종)6.2배3.8배
가장 좋았던 경우15.5배6.9배
HyperProtoBench는 deserialize와 serialize를 가장 많이 쓰는 서비스 다섯 개씩의 메시지 "모양"을 실제 fleet에서 뽑아 만든 공개 벤치마크다.
2.5%+
fleet 사이클 절감 추정치. 저자들은 업계 전체로 보면 수억 달러 규모라고 쓴다.
22nm에서 면적 0.41mm²
deserializer 0.133mm²(1.95GHz), serializer 0.278mm²(1.84GHz)
논문 5장. FireSim(FPGA 기반 사이클 정확 시뮬레이션)에 Linux를 띄우고 RTL을 돌렸다.
2부 · 결과를 읽는 법

배수보다 "무엇과, 어떤 조건에서"를 같이 읽어야 한다

비교 대상
기준은 ARM A72 급 RISC-V 코어 하나, Xeon은 코어 하나(하이퍼스레드 2개)다. RISC-V SoC는 캐시와 메모리 쪽이 Xeon보다 약한데도 이겼다는 점은 눈여겨볼 만하다.
범위
C++ proto2만 다룬다. proto3는 UTF-8 검증만 더하면 된다고 하지만, Java나 Go처럼 다른 언어는 그 언어의 객체 배치를 가속기가 알아야 한다.
측정 방식
실리콘이 아니라 RTL 시뮬레이션이다. 다만 RTL과 벤치마크를 모두 공개했고 외부 아티팩트 평가에서 재현됐다.
숫자에 안 잡힌 이득
protoc가 만든 분기 많은 코드가 빠지면 명령어 캐시와 분기 예측기 부담도 줄어든다. 저자들은 이 효과가 가속만큼 클 수 있다고 본다.
논문 5장, 7장
3부

수집 경로에서 protobuf는
몇 번 모양을 바꾸나

흔한 구성 하나를 예로, OpenTelemetry trace가 BigQuery 행이 되기까지
3부 · 바탕

OpenTelemetry trace는 세 겹으로 묶여서 온다

요청 하나 (ExportTraceServiceRequest) resource_spans[]어느 앱, 어느 기기에서 scope_spans[]어느 계측 라이브러리가 spans[]실제로 일어난 작업 하나씩 화면 열기 · 시작 시각 · 걸린 시간 · attributes API 호출 · 시작 시각 · 걸린 시간 · attributes …
OpenTelemetry, OTLP
앱과 서버의 동작 기록(trace, metric, log)을 남기는 공개 표준이 OpenTelemetry다. 그 기록을 보내는 형식이 OTLP이고, 기본은 protobuf다.
span
"무엇을 언제부터 얼마나 했나" 한 건. 같은 앱에서 나온 span을 앱 정보 한 번 아래에 모아 보내려고 세 겹으로 묶는다.
3부 · 예시 경로

수집 서버는 열어 보지 않고, 스트림 처리에서 한 번만 연다

모바일 앱 OTLP protobuf + gzip 수집 서버 압축도 안 풂 body 그대로 publish Kafka 메시지 큐 bytes 그대로 보관 Flink gunzip → parseFrom → 행 → DynamicMessage BigQuery Storage Write API 행도 protobuf bytes 모양이 바뀌는 횟수 앱에서 protobuf로 인코딩하고 gzip한다(1). 수집 서버와 Kafka는 한 바이트도 바꾸지 않는다. Flink가 gzip을 풀고 OTLP 메시지로 decode해 span 행으로 펼친다(2). 그 행을 BigQuery 테이블 스키마로 만든 descriptor에 맞춰 다시 protobuf로 encode한다(3). protobuf에서 행으로, 다시 protobuf로.
설명을 위한 예시 구성이다. Kafka나 Flink를 다른 제품으로 바꿔도 bytes 모양이 바뀌는 자리는 같다.
3부 · 논문으로 다시 보면

비싼 deserialize를 한 군데로 모으는 구성이다

수집 서버는 bytes만 옮긴다
요청마다 decode하면 2부에서 본 직렬적이고 할당이 많은 작업을 앞단이 떠안는다. 열지 않으면 길이 검사와 헤더 붙이기만 남는다.
스트림 처리에서 한 번 연다
decode가 한 곳에서만 일어나니, 거기서 병렬도를 늘려 감당하면 된다. 깨진 bytes도 여기서 걸러 낼 수 있다.
그래도 마지막엔 다시 encode
BigQuery Storage Write API가 protobuf 행을 받기 때문이다. 논문이 관찰한 "RPC 밖에서 생기는 직렬화 비용"이 바로 이 자리다.
구조를 논문 관점으로 읽은 해석이다. 실제 비용은 구성마다 재 봐야 안다.
3부 · 펼치기

세 겹 repeated를 span 하나당 행 하나로 펼친다

// OTLP 요청 하나를 행 여러 개로
request.resourceSpansList.forEach { rs ->
  rs.scopeSpansList.forEach { ss ->
    ss.spansList.forEach { span ->
      rows += toRow(span, rs.resource, ss.scope)
    } } }

// 예: attribute 값(AnyValue oneof)은 스칼라만 받는다
when (valueCase) {
  STRING_VALUE, INT_VALUE, DOUBLE_VALUE, BOOL_VALUE -> 값
  else -> null  // array, kvlist, bytes는 버림
}
resource와 scope는 행마다 복사
요청 안에서 한 번만 나오던 값을 span 행마다 다시 적는다. 쿼리 쪽에서 JOIN 없이 읽게 하려는 선택이다.
배열의 배열은 입구에서 막는다
attribute의 array_value를 그대로 받으면 1부의 그 문제가 BigQuery 앞에서 다시 생긴다. 나머지 attribute는 JSON 컬럼 하나에 모으는 식으로 피한다.
개념을 보이려고 줄인 코드다. 자주 쓰는 attribute 키는 컬럼으로 끌어올리기도 한다.
3부 · 웹이라면

브라우저 이벤트라면 JSON이 더 나을 수 있다

protobuf를 쓰면 얻는 것웹에서는
bytes가 작다필드 이름이 반복되는 JSON은 gzip이 잘 줄인다. 이벤트 하나가 몇 KB 수준이면 크기가 결정 근거가 되기 어렵다.
스키마로 타입을 검증한다측정값을 자유 형식 map으로 받는다면 검증 이득은 id, 시각 같은 공통 필드 몇 개에만 남는다.
모바일 경로를 재사용한다가장 의미 있는 이점이다. 대신 브라우저 번들에 인코더가 들어가고, 페이지가 열릴 때 인코딩 비용을 낸다.
CPU를 아낀다논문의 이득은 fleet 규모 서버 이야기다. 초당 몇 건 수준의 이벤트에서는 서버 CPU가 쟁점이 아니다.
페이지를 떠날 때 쓰는 sendBeacon은 preflight 없이 보내야 해서, 서버는 text/plain 본문도 받는다.
3부 · 그럼 왜 쓰나

실무에서 protobuf는 bytes 형식보다 스키마 언어로 더 쓰인다

이름을 바꿔도 통신이 안 깨진다
bytes에는 번호만 실리니, 필드 이름을 바꾸면 코드는 고쳐야 해도 이미 오가는 데이터는 그대로 읽힌다. 새 필드는 옛 프로그램이 건너뛴다.
언어마다 코드를 생성한다
팀마다 Go, Kotlin, Python, TypeScript를 써도 같은 .proto에서 각자 코드를 만든다. 명세와 코드가 따로 놀지 않는다.
같은 스키마로 JSON API도 만든다
.proto에서 OpenAPI 문서와 JSON HTTP 서버를 생성하는 도구가 있다. 그래서 스키마는 protobuf인데 실제로는 JSON이 오가는 경우도 흔하다.
명세를 레포 하나에 모으고, RPC뿐 아니라 메시지 큐의 메시지나 외부 API 명세까지 .proto로 적는 회사도 있다.
3부 · 더 생각해 볼 것

구조만으로는 답이 안 나오는 것

decode 비용은 재 봐야 안다
gunzip, parseFrom, 다시 encode하는 단계가 각각 CPU를 얼마나 쓰는지는 프로파일을 떠 봐야 안다. 3부의 해석은 구조만 보고 한 것이다.
metric은 repeated 숫자가 많다
OTLP metric의 histogram은 bucket_counts 같은 repeated 숫자를 싣는다. 이걸 BigQuery 행으로 어떻게 펼칠지는 trace보다 고를 게 많다.
struct로 감싸는 우회
1부의 GeoJSON처럼 배열이 겹친 값은 한 겹씩 struct로 감싸 넣을 수 있다. 대신 쿼리가 길어진다.
마무리

protobuf는 이름을 빼서 bytes를 줄이고,
그 값을 CPU로 치른다

"직렬화 비용은 네트워크보다 CPU 쪽에, RPC보다 저장 쪽에 쌓인다."
이름이 빠진 bytes는 스키마 없이는 못 읽고, 이름이 없는 반복은 선언조차 못 한다.