Writing Parquet That VertiPaq Likes

Author: mim (datamonkeysite) | Source: datamonkeysite.com | Published: 2026-08-29


한 줄 요약

Direct Lake는 Parquet을 컬럼 단위로 on-demand transcode하므로 쿼리 레이턴시와 용량 소비가 “Parquet을 어떻게 썼는가”의 속성이 되고, 핵심은 row group 2~6M행 + 모든 컬럼 dictionary 인코딩(그리고 footer가 그렇다고 선언할 것) + 저카디널리티 우선 global ORDER BY 세 가지다.

핵심 주장/내용

  • 왜 지금 이 질문이 정당한 데이터 엔지니어링 문제가 되었나: 예전 멘탈 모델은 단순했다 — Spark로 쓰면 V-Order를 얻고 Power BI에 최적 레이아웃을 얻는다. 2026년에는 상황이 바뀌었다
    • 새 Fabric 워크스페이스는 Spark를 writeHeavy 리소스 프로파일로 기본 설정하는데 그것은 V-Order를 쓰지 않는다
    • 특히 작은 SKU의 고객은 Python 노트북에서 delta-rs로 일상적으로 쓴다
    • Snowflake·Databricks·BigQuery가 쓴 테이블을 Direct Lake로 읽는 것이 프로덕션 패턴이다
  • 짧은 버전 (4줄 요약):
    1. 수백만 행의 row group — 그룹당 2~6M행, VertiPaq의 segment 상한인 16M을 절대 넘기지 말 것
    2. 모든 컬럼을 dictionary 인코딩하고 — 파일 footer가 그렇다고 말하게 할 것
    3. 하나의 global ORDER BY, 저카디널리티 컬럼 먼저, 날짜를 앞에. clustering도 Z-ordering도 아무것도 하지 말 것
    4. Delta vs Iceberg는 중요하지 않다. 테이블 안의 Parquet만 중요하다
  • 방법론 — VertiPaq을 블랙박스로 두고 실험과학처럼: 변수 하나를 바꾸고 측정하고 반복. 기밀이나 내부 정보 없이 누구나 재구성할 수 있는 테이블에서 바깥에서 측정했다. “올해 바뀐 것 하나 더 — AI가 이런 작업에 진짜 유용해졌는데, 지루해하지 않기 때문이다. writer × row-group 크기 × 파일 크기 × 정렬 순서를 수백 번 sweep하는 것이 정확히 그것이 느끼지 않는 지루함이다.”
    • 캐비엇: hot 동작은 잘 문서화되어 있으므로(import mode와 같은 in-memory 포맷) cold 실행(fresh 모델의 첫 접촉)에 더 관심을 뒀다. 실세계 트래픽 대부분은 hot이지만 — 라이브 모델은 한 번 transcode하고 RAM에서 서빙한다
  • Direct Lake가 Parquet을 읽는 메커니즘 — 거의 모든 발견을 설명한다: transcoding은 컬럼 단위 on-demand다. 아직 메모리에 없는 컬럼을 건드리는 첫 DAX 쿼리가 그 컬럼만 VertiPaq in-memory 포맷으로 변환한다
    • 그 컬럼의 row group별 Parquet dictionary들이 하나의 global VertiPaq dictionary로 병합되고, 컬럼의 각 row group이 하나의 resident column segment로 로드되며 그 과정에서 Parquet data ID를 VertiPaq ID로 remap한다
    • 이후 모든 쿼리는 그 transcode가 만든 segment를 스캔한다. 따라서 쿼리 레이턴시 — 그리고 용량 소비 — 는 Parquet이 어떻게 쓰였는지의 속성이다
  • 발견 1: dictionary 인코딩이 가장 큰 것: VertiPaq 자체가 dictionary 기반 엔진이다. chunk가 dictionary 인코딩된 채 도착하면 transcode가 Parquet dictionary를 컬럼의 global dictionary에 병합하고 data ID를 remap한다 — 값을 절대 디코딩하지 않는다. 그 외의 것은 로드 시점에 값 하나하나 디코딩되고 재해시되어야 한다
    • 놀라운 점은 인코딩만으로는 부족하다는 것 — 선언이 인코딩의 일부다. 엔진은 footer의 encoding_stats가 chunk가 전부 dictionary 인코딩임을 페이지를 디코딩하지 않고 증명할 때만 저렴한 remap 경로를 탄다
    • DuckDB의 writer는 encoding_stats를 아예 내보내지 않았다 — duckdb#24957 (2026-08-24 머지, 현재 main에만) 전까지. 그 PR이 측정한 바로 142M행 dictionary string 컬럼의 cold first-touch가 10,857.5 ms → 689.3 ms, 약 15배, 페이지는 동일한 채로
    • 귀속을 어렵게 검증: 그 footer 필드만 합성해 다른 부분은 손대지 않은 파일에 넣으니 speedup이 재현되었다
    • 두 번째 PR duckdb#24645 (2026-08-10 머지)가 data_page_size_limit 옵션을 추가 — 그 전에는 DuckDB가 컬럼 chunk당 거대한 data page 하나를 쓰는 경우가 많았다
  • 발견 2: row group 크기 — cold와 hot의 긴장: 단일 최적 크기는 없지만 양 끝이 측정 가능하게 실패한다. 모든 row group은 컬럼당 dictionary 병합 한 번과 segment 셋업 한 번을 더한다
    • 그래서 작은 그룹이 cold tier를 죽인다 — 라이브러리 기본값이 144M행 테이블을 ~123k행짜리 1,172 그룹으로 자르자 같은 노트북의 같은 DuckDB가 cold에서 3.5배 느렸다 (96,503 ms vs 27,785 ms)
    • 반대쪽 끝에서 16M행(VertiPaq segment 상한)이 측정된 정렬 geometry 중 최악이었다 — segment 9개로는 scan pool이 굶는다
    • cold는 hot보다 약간 큰 그룹을 선호하지만, 아주 큰 그룹은 둘 다에 나쁘다. Power BI가 코어를 몇 개 쓰는지 공개하지 않으므로 실용 규칙은 “코어를 바쁘게 유지할 만큼 충분한 row group”. 144M행 테이블에서 warm 쿼리 시간이 19~24 그룹 사이에서 계단식 하락(≈5,700 ms → 3,221 ms) 했고 72 그룹은 24 대비 아무것도 사지 못했다. 그래서 plateau가 그룹당 2~6M행
  • 발견 3: global sort는 메모리에 들어간 뒤에도 계속 갚는다: transcoding은 행 순서를 바꾸지 않는다 — 본질적으로 메모리로의 working data copy다. 따라서 쓰기 시점에 적용한 정렬이 resident segment까지 살아남고, 그래서 정렬이 압축뿐 아니라 hot 실행에도 중요하다
    • 저카디널리티 컬럼 먼저(그리고 날짜 선호)의 단일 global ORDER BY가 긴 RLE run을 만든다. V-Order는 아니지만 어떤 경우엔 충분히 좋다
    • V-Order가 작동할 때 그 가치는 행 수가 아니라 surface — 컬럼 수 × 범주형 skew — 에 달렸다: skew된 17컬럼 taxi 테이블에서 가장 반복적인 컬럼을 run 3,371배 감소로 무너뜨렸고, 거의 unique한 5컬럼 테이블에서는 행 순서를 그대로 두고도 파일을 16% 축소
    • 주의 하나: 대안 정렬 키가 파일 크기를 추가로 30% 줄였지만 쿼리 시간은 통계적으로 0을 샀다 — 디스크 크기가 아니라 쿼리가 필터하는 컬럼으로 정렬하라
    • 이것이 V-Order의 reorder가 실제로 무엇인지 보는 가장 깨끗한 방법이다. 손으로 쓴 ORDER BY는 지목한 컬럼만 무너뜨리고 나머지는 파편화된 채 남긴다. V-Order는 여러 컬럼을 한 번에, 가장 반복적인 것 먼저 정렬한다 — taxi 측정이 그 서명이고 run이 인코딩 주도 정렬이 예측하는 대로 정확히 떨어진다
  • 발견 4: VertiPaq은 ragged row group을 싫어한다: delta-rs는 크기 상한에 닿는 순간 파일을 닫아 진행 중이던 row group을 잘라낸다 — 측정된 바로 선언된 행 수의 0.43배로 그룹을 쓴다. 잘린 그룹은 그냥 작은 게 아니라 segment 크기를 고르지 않게 만든다 — 정확히 row group 크기를 조정해서 피하려던 비균일 scan 부하다. 수정 제안 delta-rs#4677 — 아직 열려 있고 opt-in — 이 row group 경계에서만 파일을 roll하게 한다
  • 발견 5: 쓰기 시점 동적 row group 크기 결정은 매우 어렵다: duckrun 패키지에서 영리한 최적화를 시도했다 — 쿼리 결과를 쓰기 전에 행 수를 추정해 1M~16M 스케일에서 완벽한 row group 크기를 계산. 완전히 실패했다. 쿼리 플래너가 출력 크기 추정에 형편없기 때문 — DuckDB는 실제로 143,980,961행을 담은 테이블에 대해 ~14.9M행으로 추정, 9.7배 과소. 그래서 “최적화된” geometry가 자릿수만큼 틀렸다
    • 정확한 count에서 geometry를 유도하라 — 재작성 중인 테이블, Delta log에서. 절대 추정에서 유도하지 말 것. 게다가 초기 버전에서는 추정 비용이 테이블 쓰기 비용과 거의 같았다
    • 결론: “결국 6M을 어디서나 기본 row group으로 쓴다. 충분히 좋은 타협이라고 느낀다”

주요 수치 / 사실

  • encoding_stats footer 필드 하나로 cold first-touch 10,857.5 ms → 689.3 ms (~15×), 페이지 내용은 동일
  • 144M행 DECIMAL(18,4) 단일 컬럼: PLAIN 618.6 MB vs dictionary 423.1 MB — 약 200MB 추가 + 재인코딩 비용
  • 1,172개 작은 그룹(~123k행) vs 적정 그룹: cold 96,503 ms vs 27,785 ms (3.5×)
  • warm 쿼리: 19~24 그룹에서 5,700 ms → 3,221 ms, 72 그룹은 추가 이득 없음
  • V-Order: skew된 17컬럼 taxi 테이블에서 run 3,371배 감소; 5컬럼 near-unique 테이블에서 파일 16% 축소
  • delta-rs가 실제로 쓰는 row group = 선언 행 수의 0.43배
  • DuckDB 행 수 추정 오차 9.7배 과소 (14.9M 추정 vs 143,980,961 실제)
  • V-Order 총평: 빌드 컴퓨트의 ~8%를 더 써서 쿼리 용량을 최대 2.8배 절감 (동일 데이터에서 1,332 CU vs 3,769 CU)
  • 핵심 결론 — V-Order는 흔히 행 재정렬로 이해되고 재정렬은 실재하지만, 어려운 부분은 재정렬 자체가 아니라 그것을 빠르게 하는 것(그게 비밀 소스)이다. 분명히 하자면 V-Order는 local sort이지 global sort가 아니다. 그리고 V-Order 쓰기는 재정렬만이 아니라 row group geometry를 설정하고 인코딩 패스를 돌려 모든 컬럼에 선언된 dictionary를 유지한다
  • Fabric 엔진으로 쓴다면 V-Order를 켜는 것은 no-brainer이고 솔직히 기본값이어야 한다. 다만 켜기가 쉽지 않다 — Spark write 프로파일 변경이 명확하지 않고 많은 사용자가 그 존재조차 모른다. 쓰기 프리미엄은 한 번 내지만 쿼리는 매일 낸다
  • Snowflake·Databricks·delta-rs·DuckDB 등 다른 것으로 쓴다면 VertiPaq을 위한 Parquet 레이아웃 최적화 방법에 대한 더 많은 공개 명세가 나오기를 바란다

관련 위키


Source: 원문 보기