벤치마크 — 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.
| 측정 | 바이너리 | JSON | compact JSON |
|---|---|---|---|
| 파일 크기 | 0.51 MB | 14.08 MB (27.5배) | 5.03 MB (9.8배) |
| 파일 크기, gzip 후 | 0.10 MB | 0.65 MB (6.2배) | 0.55 MB (5.3배) |
| 전체 로드, 소요시간 | 12.6 ms | 45.3 ms (3.6배) | 20.7 ms (1.6배) |
| 전체 로드, CPU 시간 | 36.5 ms | 86.5 ms (2.4배) | 51.0 ms (1.4배) |
| 로드 중 총 할당 | 12.2 MB | 30.6 MB | 21.3 MB |
| 로드 후 상주 메모리 | 11.4 MB | 15.1 MB | 15.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 JSON | 4,459,607 B |
.tcb | 360,594 B |
compact JSON 대비 12.4배, 이름 있는 JSON 대비 36.7배입니다. 컬럼별로 무엇이 남았는 지 보면 그 배율이 어디서 나오는지 보입니다.
| 컬럼 | 타입 | 인코딩 | 블록 | 그 컬럼이 실제로 담고 있는 것 |
|---|---|---|---|---|
EndValue | i64 | DICT | 140,554 B | 서로 다른 값이 많아 사전이 가장 작았습니다 — 18.4% |
BaseValue | i32 | DELTA_RLE | 109,126 B | 구간마다 등차. 델타를 다시 런으로 접습니다 |
StepValue | i32 | DELTA | 96,292 B | 등차이되 런이 생기지 않아 델타만 |
CapValue | i64 | DICT_RLE | 9,573 B | 곡선 하나에 값 하나. 1.3% |
Tick | i32 | DELTA_RLE | 4,815 B | 0부터 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은 서로 다른 값이 셋인데 행마다 문자열을 통째로 적고 있었습니다. 지금은 사전에 세 번 적고 「이 값이 몇 번 이어진다」를 세 번 적습니다CurveStat과Revision도 같습니다
어느 것도 이 테이블을 노린 인코딩이 아닙니다. 변환기는 컬럼마다 후보를 전부 인코딩해 보고 가장 작은 것을 골랐을 뿐이고, 기획 데이터의 컬럼이 대체로 이렇게 생겼기 때문에 그 선택이 이런 결과를 냅니다 — id는 증가하고, 분류는 몇 가지뿐이고, 수치는 구간마다 등차입니다.
처음 보면 무언가 빠진 것 아닌가 싶은 숫자이고, 그 의심이 정상입니다. 그래서
verify가 이 테이블의 95,490행을 세 형식으로 로드해 필드 단위로 대조합니다.
값은 하나도 빠지지 않았습니다.
인코딩별 기여
| 인코딩 | 컬럼 수 | 바이트 | 비중 |
|---|---|---|---|
| DICT | 4 | 141,586 | 26.7% |
| DELTA_RLE | 85 | 118,451 | 22.3% |
| DELTA | 2 | 96,353 | 18.2% |
| DICT_SEG | 3 | 61,068 | 11.5% |
| DICT_FRONT | 54 | 54,130 | 10.2% |
| BITPACK | 202 | 35,720 | 6.7% |
| ARRAY | 12 | 12,484 | 2.4% |
| DICT_RLE | 2 | 9,604 | 1.8% |
| RAW · VARINT · RLE | 5 | 1,327 | 0.3% |
컬럼 수와 바이트가 반대로 갑니다. BITPACK이 컬럼 202개를 담고도 6.7%인 것과 DICT가 4개로 26.7%인 것이 같은 사실의 양면입니다 — 작은 테이블 수백 개의 컬럼은 이미 작고, 바이트를 쓰는 것은 큰 테이블의 몇 컬럼입니다. 어디를 재야 하는지를 이 표가 정합니다.
데이터셋에 따른 차이
같은 측정을 canopy로 하면 배율이 크게 달라집니다.
| 코퍼스 | 인코딩 전 | 인코딩 후 | 남는 비율 |
|---|---|---|---|
sprout — 71개 테이블, 이 문서의 데이터셋 | 4,736,718 B | 530,723 B | 11.2% |
canopy — 516개 테이블 | 3,478,414 B | 1,052,206 B | 30.2% |
canopy는 테이블이 7배 많고 컬럼이 12배 많은데 바이트는 두 배입니다. 그리고 인코딩이 지울
반복이 훨씬 적습니다 — 테이블 하나가 수백 행이고, 값이 대체로 서로 다르기 때문입니다.
어느 인코딩이 담았는지도 갈립니다.
| 코퍼스 | 1위 | 2위 | 3위 |
|---|---|---|---|
sprout | DICT 26.7% | DELTA_RLE 22.3% | DELTA 18.2% |
canopy | DICT_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가 그 반복 오버헤드입니다.
그래서 커서가 「다음 값과 그 값이 몇 행 이어지는지」를 한 번에 답하게 하고
(NextSameI32와 NextSameString), 생성되는 루프가 그 횟수만큼 평범한 대입을 돌게 했습니다.
런이 없는 컬럼은 1을 답하므로 루프는 모든 인코딩에서 그대로 맞고, 런이 있는 컬럼에서만 빨라집니다.
디코드가 3.99 ms에서 2.13 ms로, 전체 로드가 8 ms에서 5 ms로 줄었습니다. 형식은 한 바이트도 바뀌지 않았습니다. 런 길이 인코딩이 파일에서 지운 반복을 디코드에서도 지운 것뿐입니다.
같은 최적화, 언어마다 다른 값
리더는 모든 언어에 같은 형태로 있으므로 이 변경도 전부에 넣었습니다. 그런데 이득이 언어마다 크게 다릅니다. 같은 데이터셋을 같은 기계에서, 런 루프만 되돌린 것을 기준선으로 재면:
| 언어 | 기준선 | 런 단위 디코드 | 차이 |
|---|---|---|---|
| Python | 241 ms | 214 ms | −11% |
| Go | 9.9 ms | 8.7 ms | −12% |
| C# | 8 ms | 5 ms | −32% |
| Rust | 23.4 ms | 23.0 ms | 노이즈 안 |
| Java | 27.0 ms | 27.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행을 저장소에 둘 이유가 없습니다.