본문으로 건너뛰기

벤치마크 — JSON, compact JSON, 바이너리

문서 목록으로

내보내기가 바이너리를 쓰는 이유를 말로 설명한다면, 이 문서는 그 차이를 숫자로 측정합니다. 같은 게임 데이터를 세 형식으로 내보내고, 각각을 런타임이 하는 방식 그대로 실어서 파일 크기·로드 시간·CPU·메모리를 측정했습니다.

데이터셋

sprout 코퍼스를 전량 규모로 만든 것입니다 — 71개 테이블에 109,218행.

값은 합성입니다. 생성기가 재현하는 것은 값이 아니라 분포이고, 그것이 이 측정이 성립하는 지점입니다 — 서로 다른 값이 셋뿐인 문자열 컬럼, 구간마다 등차인 수치, 긴 접두어를 공유하는 경로 풀, 그리고 95,490행짜리 성장 곡선 테이블 하나가 코퍼스의 87%를 차지하는 것.

수십 행짜리 설정 테이블부터 그 성장 테이블까지 있고, 문자열과 경로와 가변 배열과 64비트 정수가 섞여 있습니다.

dotnet run --project samples/sprout/gen -- --scale live --out samples/sprout/xlsx-live
dotnet run --project src/Tabbit.csproj -- --recipe bench/recipe.jsonc

세 형식은 한 번의 빌드에서 나옵니다(bench/recipe.jsonc).

그래서 내용이 다를 수가 없고, 그 사실을 하네스의 verify 모드가 믿음이 아니라 값으로 확인합니다 — 세 경로로 로드한 109,213행을 테이블마다 필드 단위로 대조합니다.

형식무엇
바이너리.tcb, 컬럼 지향 형식. 런타임 형식이므로 생성된 테이블 리더가 그대로 읽습니다. 컬럼마다 인코딩을 고르지만 범용 압축은 쓰지 않습니다
JSON이름 있는 행({"id": 1, "name": …}), 공백 없음
compact JSON배열 행([1, "…", …]), 공백 없음. recipe의 UseCompactRowFormat

이 수치는 이전 판과 기준이 다릅니다. 예전 판은 상용 프로젝트의 워크북에서 측정했고, 그 데이터는 저장소에 없습니다(경위). 배율이 그때보다 작습니다 — 그쪽 성장 테이블의 값 컬럼이 서로 다른 값을 훨씬 적게 갖고 있었기 때문이고, 합성본을 거기까지 맞추는 것은 데이터를 도구에 유리하게 짜는 일이 됩니다.

결과

Windows 11 · i5-13600KF(14코어 20스레드) · .NET 10 · 워크스테이션 GC. 로드 수치는 실행 5번(각각 워밍업 1회 + 30회 반복)의 중앙값입니다. 형식 버전 107.

측정바이너리JSONcompact JSON
파일 크기0.51 MB14.08 MB (27.5배)5.03 MB (9.8배)
파일 크기, gzip 후0.10 MB0.65 MB (6.2배)0.55 MB (5.3배)
전체 로드, 소요시간12.6 ms45.3 ms (3.6배)20.7 ms (1.6배)
전체 로드, CPU 시간36.5 ms86.5 ms (2.4배)51.0 ms (1.4배)
로드 중 총 할당12.2 MB30.6 MB21.3 MB
로드 후 상주 메모리11.4 MB15.1 MB15.1 MB

읽는 법

크기 차이를 만드는 것은 바이너리라는 사실이 아니라 컬럼 인코딩입니다. 값을 그대로 나열하기만 하면 이 코퍼스는 4.74 MB이고, 그것은 compact JSON과 별 차이가 없습니다 — 값 하나하나는 조밀하지만 값 사이의 관계를 전혀 모델링하지 않기 때문입니다.

그 관계가 실은 파일의 대부분입니다. 컬럼마다 후보를 전부 인코딩해 보고 가장 작은 것을 고르게 하면 4.74 MB가 0.53 MB가 됩니다. 남는 비율이 11.2%입니다.

어느 인코딩이 무엇을 담았는지가 인코딩별 기여에 있습니다.

파일 크기가 곧 할당량 차이입니다. 어느 경로든 파일 전체를 한 번 버퍼로 읽으므로, 로드가 끝나면 버려지는 쓰레기의 대부분이 그 버퍼입니다. 바이너리 쪽은 파일이 0.51 MB라 버릴 것 자체가 거의 없습니다. 텍스트 형식의 부피는 전송량으로 한 번, 로드할 때 가비지로 또 한 번 청구됩니다. 모바일에서 로딩 중 프레임 끊김을 만드는 것이 이 가비지입니다.

상주 메모리도 다릅니다. 로드가 끝나면 남는 것은 형식과 무관하게 같은 형태의 레코드 객체·문자열·인덱스 사전일 것 같지만, 사전 인코딩이 그 전제를 깹니다. 유니크 값 3개짜리 문자열 컬럼을 읽을 때 JSON 경로는 문자열 객체를 95,490개 만들고, 바이너리 경로는 사전을 한 번 풀어 3개를 만든 뒤 행마다 그 참조를 나눠 줍니다. 파일 크기를 줄이려고 넣은 인코딩이 상주 메모리를 함께 줄인 셈인데, 우연이 아니라 같은 사실의 두 얼굴입니다 — 반복되는 값은 파일에서도 힙에서도 한 번만 있으면 됩니다.

행이 많을수록 이름 있는 JSON이 불리해집니다. 키 문자열이 행마다 반복되기 때문입니다. 그리고 컬럼 인코딩은 compact JSON이 키에 대해 수행하는 것(반복되는 것을 한 번만 기재)을 값에 대해서도 합니다. 그 둘이 합쳐지면 어디까지 가는지는 한 테이블을 끝까지 따라가 보는 편이 빠릅니다.

compact JSON은 좋은 결과를 냅니다. 키 반복을 지우면 크기와 파싱의 상당 부분이 함께 사라지고, 이 코퍼스에서는 이름 있는 JSON의 3분의 1입니다. 그래도 숫자는 여전히 십진 텍스트라 값마다 문자열→숫자 변환을 거치고, 토큰 하나하나(따옴표·쉼표·대괄호)를 찾는 일은 남습니다. 그 몫이 바이너리 대비 소요시간 1.6배·CPU 1.4배입니다. 그리고 compact는 위치가 계약입니다 — 컬럼 순서가 바뀌면 읽는 쪽 코드가 함께 바뀌어야 하고, 어긋나면 형식이 그것을 검출하지 않습니다.

gzip은 차이를 좁히지만 뒤집지 못합니다. 텍스트의 반복은 압축이 잘 먹으므로 JSON은 21분의 1이 됩니다. 그런데 컬럼 인코딩이 이미 같은 반복을 형식 안에서 지운 뒤라 바이너리에게는 gzip이 할 일이 적고, 압축 후에도 6.2배가 남습니다. 압축하지 않은 .tcb(0.51 MB)가 gzip한 JSON(0.65 MB)보다 작습니다. 압축 라이브러리를 모든 언어에 다는 대신 형식이 스스로 해낸 결과이고, flags 바이트에 압축 비트를 비워 둔 채로 그렇습니다.

디코드가 공짜는 아닙니다. CPU 시간이 소요시간의 세 배인 것이 그 값입니다 — 로드가 테이블별로 병렬이라 소요시간은 코어 수만큼 압축되고, CPU 시간이 총 작업량을 그대로 보여줍니다. 코어가 적은 기기일수록 소요시간이 CPU 시간 쪽으로 다가간다고 읽으면 됩니다. 그 교환이 이득인 것은 이 형식이 디코드가 CPU가 아니라 바이트에 묶여 있다고 전제하기 때문이고, 세 형식의 소요시간 순서가 그 전제를 확인해 줍니다.

한 테이블을 끝까지: LevelCurve

이 데이터셋의 최대 테이블입니다. 95,490행 × 9컬럼, 성장 수치.

형식크기
JSON (이름 있는)13,244,687 B
compact JSON4,459,607 B
.tcb360,594 B

compact JSON 대비 12.4배, 이름 있는 JSON 대비 36.7배입니다. 컬럼별로 무엇이 남았는지 보면 그 배율이 어디서 나오는지 보입니다.

컬럼타입인코딩블록그 컬럼이 실제로 담고 있는 것
EndValuei64DICT140,554 B서로 다른 값이 많아 사전이 가장 작았습니다 — 18.4%
BaseValuei32DELTA_RLE109,126 B구간마다 등차. 델타를 다시 런으로 접습니다
StepValuei32DELTA96,292 B등차이되 런이 생기지 않아 델타만
CapValuei64DICT_RLE9,573 B곡선 하나에 값 하나. 1.3%
Ticki32DELTA_RLE4,815 B0부터 118까지 800번 반복. 1.3%
Id · TierName · CurveStat · Revision + 헤더234 B아래

마지막 줄이 이 표의 요점입니다. 네 컬럼에 행이 95,490개씩 있는데 합쳐서 234바이트입니다.

  • Id는 서로 다른 정수 95,490개입니다. JSON에서는 약 800 KB이고, 담고 있는 정보는 「1,401,000에서 시작해 1씩 95,489번」입니다
  • TierName은 서로 다른 값이 셋인데 행마다 문자열을 통째로 적고 있었습니다. 지금은 사전에 세 번 적고 「이 값이 몇 번 이어진다」를 세 번 적습니다
  • CurveStatRevision도 같습니다

어느 것도 이 테이블을 노린 인코딩이 아닙니다. 변환기는 컬럼마다 후보를 전부 인코딩해 보고 가장 작은 것을 골랐을 뿐이고, 기획 데이터의 컬럼이 대체로 이렇게 생겼기 때문에 그 선택이 이런 결과를 냅니다 — id는 증가하고, 분류는 몇 가지뿐이고, 수치는 구간마다 등차입니다.

처음 보면 무언가 빠진 것 아닌가 싶은 숫자이고, 그 의심이 정상입니다. 그래서 verify가 이 테이블의 95,490행을 세 형식으로 로드해 필드 단위로 대조합니다. 값은 하나도 빠지지 않았습니다.

인코딩별 기여

인코딩컬럼 수바이트비중
DICT4141,58626.7%
DELTA_RLE85118,45122.3%
DELTA296,35318.2%
DICT_SEG361,06811.5%
DICT_FRONT5454,13010.2%
BITPACK20235,7206.7%
ARRAY1212,4842.4%
DICT_RLE29,6041.8%
RAW · VARINT · RLE51,3270.3%

컬럼 수와 바이트가 반대로 갑니다. BITPACK이 컬럼 202개를 담고도 6.7%인 것과 DICT가 4개로 26.7%인 것이 같은 사실의 양면입니다 — 작은 테이블 수백 개의 컬럼은 이미 작고, 바이트를 쓰는 것은 큰 테이블의 몇 컬럼입니다. 어디를 재야 하는지를 이 표가 정합니다.

데이터셋에 따른 차이

같은 측정을 canopy로 하면 배율이 크게 달라집니다.

코퍼스인코딩 전인코딩 후남는 비율
sprout — 71개 테이블, 이 문서의 데이터셋4,736,718 B530,723 B11.2%
canopy — 516개 테이블3,478,414 B1,052,206 B30.2%

canopy는 테이블이 7배 많고 컬럼이 12배 많은데 바이트는 두 배입니다. 그리고 인코딩이 지울 반복이 훨씬 적습니다 — 테이블 하나가 수백 행이고, 값이 대체로 서로 다르기 때문입니다.

어느 인코딩이 담았는지도 갈립니다.

코퍼스1위2위3위
sproutDICT 26.7%DELTA_RLE 22.3%DELTA 18.2%
canopyDICT_SEG 39.1%ARRAY 27.6%DICT_FRONT 13.9%

형식은 데이터에 맞는 만큼만 이득을 냅니다, 그리고 그것이 이 형식의 전제입니다. canopy 쪽에서 ARRAY가 27.6%인 것은 그 시트가 배열을 많이 쓰기 때문이고, 배열 인코딩이 v104에서야 들어간 것은 그때까지의 데이터셋에서 배열이 바이트의 1.8%였기 때문입니다.

어느 쪽도 형식의 성질이 아니라 시트의 성질이고, 그래서 무엇을 만들지는 짐작이 아니라 계측으로 정할 문제였습니다.

무엇이 어떻게 담기는지는 컬럼 인코딩에, 결정의 근거는 TCB v104에 있습니다.

형식이 여기까지 온 과정

이 절의 수치는 이전 데이터셋에서 측정한 것입니다. 상용 프로젝트의 워크북이었고, 지금은 저장소에 없습니다. 결론은 형식에 남아 있고 근거는 남아 있지 않으므로, 재현되는 수치가 아니라 기록으로 읽으십시오.

런 단위 디코드

컬럼 인코딩을 넣자 소요시간은 24 ms에서 8 ms로 줄었는데 CPU 시간은 23 ms에서 28 ms로 조금 늘었습니다. 읽을 바이트가 47분의 1이 된 대신 델타를 누적하고 런을 세고 사전을 인덱싱하는 작업이 생겼기 때문입니다.

그 8 ms를 다시 측정해 보니 로드 시간의 거의 전부가 최대 테이블 하나였고, 그 안에서는 레코드 할당 0.66 ms · 인덱스 사전 0.65 ms에 디코드가 3.99 ms였습니다. 그런데 그 테이블의 컬럼은 전부 런 계열이라 4,467 B에 값 93만 개가 접혀 있었습니다 — 즉 같은 값이 만 번 이어져도 커서를 만 번 부르고 있었고, 부를 때마다 인코딩을 다시 분기하고 런 잔량을 다시 확인했습니다. 필드 대입만 남긴 바닥이 1.55 ms였으니 나머지 2.4 ms가 그 반복 오버헤드입니다.

그래서 커서가 「다음 값과 그 값이 몇 행 이어지는지」를 한 번에 답하게 하고 (NextSameI32NextSameString), 생성되는 루프가 그 횟수만큼 평범한 대입을 돌게 했습니다.

런이 없는 컬럼은 1을 답하므로 루프는 모든 인코딩에서 그대로 맞고, 런이 있는 컬럼에서만 빨라집니다.

디코드가 3.99 ms에서 2.13 ms로, 전체 로드가 8 ms에서 5 ms로 줄었습니다. 형식은 한 바이트도 바뀌지 않았습니다. 런 길이 인코딩이 파일에서 지운 반복을 디코드에서도 지운 것뿐입니다.

같은 최적화, 언어마다 다른 값

리더는 모든 언어에 같은 형태로 있으므로 이 변경도 전부에 넣었습니다. 그런데 이득이 언어마다 크게 다릅니다. 같은 데이터셋을 같은 기계에서, 런 루프만 되돌린 것을 기준선으로 재면:

언어기준선런 단위 디코드차이
Python241 ms214 ms−11%
Go9.9 ms8.7 ms−12%
C#8 ms5 ms−32%
Rust23.4 ms23.0 ms노이즈 안
Java27.0 ms27.0 ms노이즈 안

차이를 만드는 것은 언어의 속도가 아니라 「호출 하나가 얼마나 비싼가」입니다. 이 최적화가 없애는 것은 값을 읽는 일이 아니라 값을 읽으러 가는 일 — 호출, 그 안의 인코딩 분기, 런 잔량 확인 — 이기 때문입니다. Python은 호출 하나가 비싸서 없앤 만큼 그대로 돌아오고, C#은 로드가 테이블별 병렬이라 최대 테이블이 임계 경로여서 소요시간에 크게 드러납니다. Rust와 Java에서 변화가 없는 것은 최적화가 듣지 않아서가 아니라, 인라이닝과 JIT가 이미 같은 일을 해 둔 뒤라 없앨 것이 남아 있지 않아서입니다.

느려진 언어는 없습니다. 그리고 전부 같은 형태여야 다음에 리더를 읽는 사람이 한 곳만 읽으면 되므로, 이득이 0인 두 언어에도 같은 형태로 두었습니다.

계측이 정한 우선순위

v104 작업은 「문자열 컬럼을 더 줄이자」에서 출발했는데, 문자열이 가장 크게 회수한 자리가 아니었습니다. 인코딩을 더하기 전에 계측부터 만든 것이 그 순서를 바꿨습니다 — 보고서가 측정한 것은 문자열 사전에 남은 여지 14.4 KB와, 다른 코퍼스에서 어떤 인코딩도 닿지 않는 컬럼 2,171 KB였습니다.

배열이 v102에서 RAW로 남은 근거는 「한 데이터셋 바이트의 1.8%」였습니다. 다른 데이터셋에서 배열은 바이트의 60.3%였습니다.

표에 없는 것

이 표는 크기와 속도만 측정합니다.

바이너리를 쓰는 이유의 나머지 절반은 벤치마크로 측정할 수 있는 성질이 아닙니다. 텍스트를 거치지 않아 값이 변하지 않는다는 것과, 스키마가 바뀌어도 드러나지 않은 채 어긋나지 않는다는 것입니다.

그 둘은 세 형식 중 바이너리만 가집니다.

측정 방법

하네스는 bench/에 있고, 공정성 규칙은 이렇습니다.

  • 바이너리는 생성된 테이블 리더의 ReadAllAsync 그대로입니다 — 파일별 병렬 읽기, 인덱스 사전 구성, 상호참조 연결까지. samples/sprout/out/cs의 커밋된 생성 코드를 그대로 컴파일해 씁니다
  • JSON 두 형식은 생성된 리더가 없으므로(그것이 없다는 것 자체가 JSON을 싣는 팀의 조건입니다), 그런 팀이 썼을 코드를 하네스가 대신 씁니다: 테이블마다 타입이 정확한 DTO, System.Text.Json 역직렬화(compact는 위치 기반 Utf8JsonReader 루프), 그리고 바이너리 쪽과 같은 인덱스 사전 구성. 파일별 병렬 구조도 같습니다
  • DTO 타입 생성과 컴파일된 세터 준비는 측정 밖입니다 — JSON을 싣는 프로젝트라면 컴파일 타임에 있었을 코드이므로
  • 파서 선택은 JSON에 유리한 쪽입니다. System.Text.Json은 주류 .NET JSON 파서 중 가장 빠르고 할당이 적은 축이고, 유니티에서 흔한 Newtonsoft.Json이라면 차이는 이보다 벌어집니다
  • 워밍업 1회 뒤에 재므로 파일은 OS 캐시에 있습니다 — 디스크 속도가 아니라 디코딩 비용을 재는 것이고, 그쪽이 기기가 바뀌어도 유지되는 비교입니다
  • 「로드 후 상주」는 로드 결과 한 벌만 살아 있는 상태에서 전체 GC 뒤의 관리 힙 증가분입니다. 로더 자신이 데워두는 것(직렬화기의 버퍼 풀과 메타데이터)은 데이터에 청구하지 않고 따로 셉니다
  • CPU 시간은 OS가 15.625 ms 단위로 집계하므로 ±10 ms 안팎의 흔들림이 있습니다

재현은 저장소 루트에서 네 줄입니다.

dotnet run --project samples/sprout/gen -- --scale live --out samples/sprout/xlsx-live
dotnet run --project src/Tabbit.csproj -- --recipe bench/recipe.jsonc
dotnet run --project bench/Tabbit.Bench.csproj -c Release -- verify bench/data
dotnet run --project bench/Tabbit.Bench.csproj -c Release -- binary bench/data 30

마지막 줄의 binary 자리에 json·json-compact·sizes를 넣으면 나머지가 나옵니다. 절대 수치는 기계를 타므로, 다른 기계에서는 배율을 보세요.

첫 줄이 있는 것은 벤치마크 코퍼스가 커밋되어 있지 않기 때문입니다. 같은 격자에서 언제든 같은 값이 다시 나오므로 109,218행을 저장소에 둘 이유가 없습니다.