- Dave Cheney의 GopherCon Singapore 2023 발표는 Go에서 스트리밍 JSON 파서를 만들며
encoding/json과 비슷한 API를 유지하되 처리량을 높이고 할당을 줄이는 설계 과정을 다룸 - JSON은 길이 표시가 없어 입력을 끝까지 읽어야 하며, 성능 하한이 최소 read(N)+parse(N) 이라 바이트·토큰 재방문, 복사, 할당, 핫패스 함수 호출을 줄이는 일이 핵심 제약임
encoding/json.Decoder.Token은 토큰을interface{}로 돌려줘 편리하지만, 구체 값이 힙으로 escape되어 토큰 수에 비례한 할당을 만들고 단일"hello"토큰에서도 3 allocs/op가 발생함pkg/json은 입력의[]byte서브슬라이스를 반환하는NextToken,byteReader의 슬라이딩 윈도우, 수동 인라이닝, 상태 메서드 직접 호출, bounds check 제거로 핫패스 비용을 줄임- 최종적으로
pkg/json.Scanner는 버퍼가 주어지면 무할당으로 토큰화하고,Decoder.Token은encoding/json.Decoder.Token보다 2~3배 빠르며, 할당이 적은Decoder.NextToken은 8~10배 빠른 성능을 보임
목표와 기본 제약
- 목표는 Go 패키지 설계 사례로 고성능 JSON 파서를 만드는 것임
- 설계 목표는 세 가지임
- 전체 입력을 메모리에 올리지 않는 스트리밍 처리 지원
encoding/json의 고수준json.DecoderAPI와 합리적으로 호환되면서 더 높은 처리량과 적은 할당 제공encoding/jsonAPI 외에 더 효율적인 무할당 또는 상한 있는 API 제공
- 전체 입력을 먼저 메모리에 버퍼링하면 입력 크기를 알 수 없거나 무한할 때 가용성 위험이 생기고, 처리 전 대기 시간도 늘어남
- 스트리밍 읽기는 데이터가 도착하는 즉시 처리하고, 읽기와 처리를 겹칠 수 있음
JSON 파싱의 시간 복잡도
- JSON은 길이 마커가 없어 얼마나 읽어야 하는지 알려면 입력을 모두 읽어야 함
- JSON 배열의 1,000번째 요소를 파싱하려면 앞의 999개 요소도 읽고 처리해야 하므로 입력 처리를 건너뛸 수 없음
- 성능 하한은 입력 크기에 비례하며, 단순 읽기만이 아니라 JSON 상태 머신을 통과해 토큰 시작과 끝을 찾아야 하므로 최소
read(N)+parse(N)이 됨 - 추가 비용을 줄이는 기준은 다음과 같음
- N바이트를 읽었다면 각 바이트는 가능하면 한 번만 처리함
- 같은 토큰도 한 번만 처리함
Scanner나Decoder의 핫패스에서 함수 호출 수를O(bytes)가 아니라O(tokens)로 제한함- 복사를 줄여 같은 바이트를 다시 방문하는 횟수를 줄임
- 할당을 줄여 힙 할당, 공유 자료구조 접근, 락, 캐시 경합, GC 비용을 낮춤
토큰화와 API 설계
- JSON 디코더는 크게 두 단계로 나뉨
- 바이트 스트림을 JSON 토큰 스트림으로 바꾸는 스캐너 또는 토크나이저
- JSON 토큰 스트림을 Go 객체에 적용하는 언마셜러
encoding/json.Decoder.Token은 토큰을interface{}로 반환함- 문자열은
string, 숫자는float64, 불리언은bool,null은nil, 구분자는json.Delim으로 표현됨 - 이 방식은 토큰 값과 타입을 함께 표현해 사용하기 편함
- 문자열은
- 편의성에는 비용이 따름
- Brad Fitzpatrick은 Token API를 garbage factory라고 부름
Decoder.TokenAPI 설계상 각 토큰에 할당되는 구체 값이 힙으로 escape됨- 할당 수가 입력의 토큰 수에 묶임
- 단일
"hello"토큰 벤치마크에서encoding/json은 355ns/op, 19.7MB/s, 37.0B/op, 3.00 allocs/op를 보임 - API 설계가 할당을 좌우하고, 할당은 성능에 직접 영향을 줄 수 있음
[]byte 토큰과 암묵적 타입 정보
- JSON 토큰은 첫 글자만으로 타입을 알 수 있음
{,}: 객체 시작과 끝[,]: 배열 시작과 끝t: truef: falsen: null": 문자열-,0~9: 숫자
pkg/json의Decoder.NextTokenAPI는 입력[]byte를 Go 값으로 변환하지 않고, 토큰을 나타내는 바이트를 입력에서 바로 서브슬라이스로 반환함- 반환된
[]byte의 첫 바이트가 토큰 타입을 알려줌 - 이 API에는 제약이 있음
- 출력은 복사본이 아니라 입력의 서브슬라이스이므로 유효 기간 제한이 있음
- 이는
bufio.ScannerAPI와 비슷함 - 토큰 타입이나 실제 문자열·숫자 값을 더 편하게 다루려면 상위 추상화가 필요함
효율적인 읽기: byteReader
- 전통적인
io.Reader.Read방식은 데이터를 리더에서 버퍼로 복사하며, 이 복사 자체도 비용이 듦 io.Reader.Read는 버퍼 관리를 호출자에게 맡김- 한 바이트씩 읽으면 지나온 바이트를 저장하거나 되돌릴 공간이 필요할 수 있음
- 큰 버퍼에 읽은 뒤 토큰 시작과 끝을 찾는 방식은 토큰 끝이 버퍼 안에 없을 때 많은 관리 작업, 복사, 버퍼 확장이 필요함
- 대안으로 Steven Schveighoffer의 iopipe와 Phil Pearl의 아이디어에서 영감을 받은
byteReader를 사용함 byteReader는io.Reader위에 슬라이딩 윈도우를 제공하며bufio.Reader와 비슷하지만 더 효율적인 API를 가짐window()는 현재 읽지 않은 데이터 창을 반환함release(n)은 창 앞쪽의 n바이트를 버림extend()는 하위 리더에서 데이터를 더 읽어 창을 확장함
- 공백 문자 검색 벤치마크는 각 문자를 방문해 공백 여부만 검사하는 기준선이며, 여러 입력에서 약 2.04~2.07GB/s를 보임
- 공백 카운터 예제 코드는 github.com/davecheney/whitespace에 있음
스캐너 최적화
Scanner.Next는 중간 공백을 건너뛰고, 윈도우의 첫 문자로 토큰을 판별한 뒤 토큰 끝까지 읽음- 초기
Scanner.Next성능은 공백 기준선 대비 약 1/4~2/5 수준임- 예:
Scanner/canada510MB/s,citm_catalog677MB/s,sample837MB/s
- 예:
- 첫 최적화는
s.offset필드 갱신을 지역 변수offset으로 바꾸는 것임s.offset은 함수 진입과 종료 시 0이므로 내부 변경은 외부에서 보이지 않음- 지역 변수 사용으로 컴파일러가 임시 메모리 쓰기를 피함
citm_catalog는 2.52ms에서 1.80ms로 28.46% 감소,sample은 828µs에서 528µs로 36.24% 감소함
- 입력마다 효과가 다른 이유는 공백 수 차이 때문임
canada는 공백이 33개뿐임citm은 공백이 1,227,563개임
- 두 번째 최적화는
Scanner.token을Scanner.Next에 수동 인라이닝하는 것임- Go 컴파일러는
for문과 함수 복잡도 때문에Scanner.token,parseString,parseNumber,Scanner.Next등을 자동 인라인하지 못함 Scanner.Next와Scanner.token은 입력 토큰마다 호출되므로 토큰당 두 번의 함수 호출 비용이 발생함
- Go 컴파일러는
- 수동 인라이닝 후 처리량은 9~24% 개선됨
canada는 512MB/s에서 642MB/s로 24.50% 증가citm_catalog는 960MB/s에서 1105MB/s로 15.16% 증가sample은 1.33GB/s에서 1.46GB/s로 9.11% 증가
- 최적화의 효과는 두 가지로 정리됨
s.offset갱신을 바이트당 1회에서 토큰당 1회로 줄임- 핫패스에서 함수 호출을 피하면 성능이 개선될 수 있음
검증과 Decoder.NextToken
- 스캐너만으로는 토큰을 나눌 수 있지만, 완전한 JSON 처리에는 상태 검증이 필요함
- JSON은 상태 머신이며, 현재 토큰에 따라 다음에 올 수 있는 토큰이 제한됨
- 예를 들어
{,"username"을 읽은 뒤에는:만 유효함
- 예를 들어
Decoder.NextToken은Scanner.Next위에 상태 로직을 얹어 토큰 시퀀스가 유효한지 확인함- 상태는 값, 객체 키 문자열, 객체 콜론, 객체 값, 객체 콤마, 배열 값, 배열 콤마, 종료 상태 등으로 나뉨
- 초기 검증 구현에서도
pkg/json은encoding/json보다 8~10배 빠른 결과를 보임canada:pkg/json399MB/s,encoding/json34.6MB/scitm_catalog:pkg/json713MB/s,encoding/json87.1MB/ssample:pkg/json1.23GB/s,encoding/json216MB/s
상태 전이 최적화
Decoder.NextToken의 중심에는switch문이 있음- 일반적인
switch는 일련의if문처럼 구현될 수 있어 긴 분기열이 명령 스트림을 나누고 CPU의 분기 예측기에 부담을 줌 - 상태 값에서 상태 메서드를 찾기 위해 테이블을 쓰는 방법도 있지만, 예시 구현은 초기화 루프 때문에 컴파일되지 않음
- 대신 Go의 method expression을 사용해
d.state에 상태 열거값 대신 메서드를 직접 저장함Decoder.NextToken은return d.state(d, tok)처럼 현재 상태 메서드를 직접 호출함
- 이 computed goto 방식만으로는 성능 개선이 크지 않음
- 일부 입력은 거의 변화가 없고,
twitter,code,example에서는 소폭 느려짐 sample은 1.15% 빨라짐
- 일부 입력은 거의 변화가 없고,
- 이 변경은 다음 최적화인 아웃라이닝을 가능하게 함
아웃라이닝과 bounds check 제거
- 아웃라이닝 후
Decoder.NextToken은return d.state(d)만 수행하고, 각 상태 메서드가 직접d.scanner.Next()를 호출함 tok를 상태 메서드 인자로 넘기지 않으므로 호출 스택에서 3 words를 줄임len(tok) < 1검사와switch tok[0]가 같은 함수 안에 들어가면서 bounds check 제거가 가능해짐- 이전에는
len(tok)검사가Decoder.NextToken에 있고 상태 메서드는 메서드 표현식으로 호출되어 인라인되지 않음 - 따라서 상태 메서드의
tok[0]에는 bounds check가 필요했음 - 같은 함수 안에서 길이 검사를 수행하면 컴파일러가
tok길이가 최소 1임을 증명할 수 있음
- 이전에는
Decoder.NextToken자체도 단순해져 인라인 가능해짐- 호출자는
dec.NextToken()대신 사실상 현재 상태 메서드 직접 호출을 보게 됨 - 함수 호출 비용이 제거됨
- 호출자는
최종 벤치마크 결과
- 최하위
pkg/json.Scanner는 몇 KB 버퍼가 주어지면 무할당 스트리밍 토큰화를 수행함canada: 638.78MB/s, 0 B/op, 0 allocs/opcitm_catalog: 1110.51MB/s, 0 B/op, 0 allocs/opsample: 1471.01MB/s, 0 B/op, 0 allocs/op
pkg/json.Decoder.Token은encoding/json.Decoder.Token보다 2~3배 빠름canada: 101.98MB/s vs 33.19MB/scitm_catalog: 333.23MB/s vs 82.71MB/ssample: 788.59MB/s vs 209.12MB/s
pkg/json.Decoder.NextToken은 할당이 훨씬 적고 8~10배 빠름canada: 466.52MB/s, 136 B/op, 3 allocs/op vs 34.42MB/s, 17,740,399 B/op, 889,106 allocs/opcitm_catalog: 798.58MB/s, 136 B/op, 3 allocs/op vs 86.08MB/s, 5,661,597 B/op, 324,692 allocs/opsample: 1346.85MB/s, 1144 B/op, 9 allocs/op vs 217.44MB/s, 723,781 B/op, 26,095 allocs/op
- 가장 높은 수준의 API에서
pkg/json은encoding/json과 같은 방식으로 Go 객체에 unmarshal할 수 있음canada: 82.08MB/s vs 58.70MB/scitm_catalog: 215.66MB/s vs 104.00MB/ssample: 615.99MB/s vs 128.04MB/s
- 발표 링크는 dave.cheney.net/paste/gophercon-sg-2023.html, 코드는 github.com/pkg/json에 있음
설계에서 얻은 주제
-
할당은 성능에 영향을 줌
- GC가 빠르게 할당하고 효율적으로 수집하더라도, 할당하지 않는 편이 항상 더 빠름
- API 설계로 할당을 없앨 수 있음
- 이 패키지의 속도 향상 대부분은 할당 감소에서 나옴
- 힙 할당 경로와 GC 사이클에 쓰지 않은 시간이 스캔에 사용됨
encoding/json.DecoderAPI는 primitive 값을interface{}로 반환하기 때문에 할당을 요구함- 값이 힙으로 escape되어 사실상 값에 대한 포인터가 됨
- 데이터 처리에서 할당은 알고리듬의 가장 큰 성능 비용이 될 수 있음
- 바이트당 비용과 토큰당 비용을 주의 깊게 줄이는 것이 두 번째로 큰 성능 개선 요인임
- 바이트당 함수 호출을 토큰당 함수 호출로 바꾸는 방향이 중요함
encoding/json이 API 때문에 더 느릴 수 있다는 가정에서 시작했고, 다른 API를 받아들일 수 있다면 일부 unmarshal 경로에서 2~3배, 토큰화에서 8~10배 성능을 얻을 수 있음