Skip to content
목록으로 돌아가기
Iceberg V3 Deletion Vector 성능 해부 → CPU 레벨 최적화

Iceberg V3 Deletion Vector 성능 해부 → CPU 레벨 최적화

2026년 9월 10일엔지니어링

Merge-on-read 테이블에서 행을 지우면 Iceberg 는 데이터 파일을 다시 쓰지 않는다. 대신 "이 파일의 몇 번째 행이 지워졌다" 를 Roaring bitmap 으로 적어 옆에 둔다. 읽을 때는 행마다 isDeleted(position) 을 물어보고 살아남은 행만 통과시킨다.

그래서 상식적인 기대는 이렇게 된다. 많이 지울수록 읽기가 느려진다. 지워진 행이 많으면 비트맵이 크고, 체크할 게 많고, 그러니 비싸질 것이다.

8백만 행 테이블에 삭제율만 바꿔가며 프로파일을 찍었다. 곡선이 반대로 나왔다.

삭제율 6.1% 가 7% 보다 2.22배 비쌌다

삭제율 d DV 체크 샘플 (범위)
0.50% 599 (576–633)
5.00% 746 (700–798) 오르막
6.10% 872 (838–909) ← 정점
6.25% 654 (617–702) 절벽
7.00% 394 (381–406) ← 최저
12.00% 422 (410–447)
50.00% 496 (454–529) 다시 오름

더 적게 지웠는데 더 비싸다. 그리고 최적점은 6.4~7% 라는 좁은 골짜기다. 비싼 쪽은 한 점이 아니라 경계 아래 구간 전체다.

범인은 4,096 이라는 숫자였다

Roaring bitmap 은 값 공간을 65,536 짜리 청크로 나누고, 청크마다 자료구조를 따로 고른다. 청크에 든 원소가 4,096개 이하면 array(정렬된 16비트 배열), 넘으면 bitmap(8KB 고정 비트 배열)이다.

4,096 / 65,536 = 6.25%

곡선의 절벽이 정확히 여기다. 삭제율이 청크 밀도 6.25% 를 넘으면 컨테이너가 array 에서 bitmap 으로 바뀌고, 조회가 O(log n) 이진 탐색에서 O(1) 비트 테스트가 된다.

경계 바로 아래가 최악인 이유도 같다. d=6.1% 일 때 array 의 카디널리티가 최대(3,998)라 이진 탐색이 가장 깊다. 더 지우면 오히려 자료구조가 좋은 쪽으로 넘어간다.

전이 구간의 폭도 쟀다. 청크마다 카디널리티가 조금씩 다르니 경계는 한 점이 아니라 0.30%p 폭으로 번지는데, 이항분포로 예측한 값과 3.7%p 이내로 맞았다.

"스캔 CPU 의 53%" 는 그 자체로는 거의 거짓말이다

이 프로젝트에서 가장 많이 신경 쓴 게 이 문장이다.

투영 폭 DV 체크가 스캔 CPU 에서 차지하는 비중
1 컬럼 53.2%
3 컬럼 24%
5 컬럼 16%
20 컬럼 3.75%

분모인 Parquet 디코딩이 컬럼 수만큼 커지기 때문이다. 그래서 큰 쪽만 인용하지 않고 항상 범위로 적었다.

그리고 축이 '컬럼 수' 가 아니라는 것도 재서 알았다. 정수 컬럼 10개(21.6%)가 문자열 컬럼 3개(10.6%)보다 비중이 높다. md5 문자열 컬럼 하나가 정수 컬럼 8~10개어치다. 진짜 축은 컬럼 수가 아니라 디코딩 비용이었고, 손익분기는 하나의 숫자로 수렴한다. DV 가 스캔의 21% 를 넘느냐.

그 21% 는 환경에 잘 안 흔들린다.

바꾼 것 결과
CPU (Zen 3 / Sapphire Rapids / Graviton3) 49% / 56% / 54% — ±7%p
스토리지 (웜 / 콜드 / EBS / S3) 51.2% / 52.0% / 56.2% / 58.0%
병렬도 (local[1] ~ local[16]) 보정비 1.30 × N^0.141 — 16배에 1.5배
분산 클러스터 42% — 여기서만 내려앉는다

쿼리에 무거운 집계가 붙으면 쿼리 전체 대비로는 빠르게 밀려난다. 스캔만 하는 쿼리 19.7%, 작은 집계 10.5%, 큰 집계 3.4%. 그런데 스캔 안에서의 비중은 거의 안 움직인다(59.6 / 58.0 / 55.6%, 폭 4.0%p).

절감되는 절대 시간이 쿼리 종류와 무관하게 상수이기 때문이다(+0.086 / +0.065 / +0.100초, 세 범위가 전부 겹친다). 같은 행에 같은 DV 작업을 하니 당연한 결과인데, 재보기 전까지는 확인도 반박도 못 했다.

175줄을 고쳐 최악 지점에서 9.28배를 줄였다

문제의 코드는 한 곳이다. ColumnarBatchUtil.buildRowIdMapping 이다.

java
for (int rowId = 0; rowId < batchSize; rowId++) {
  long pos = rowStartPosInBatch + rowId;
  if (!deletedPositions.isDeleted(pos)) {
    rowIdMapping[live++] = rowId;
  }
}

배치(기본 5,000행)마다 5,000번의 독립적인 조회를 한다. pos 가 연속 단조 증가인데도 그 사실을 쓰지 않는다. RoaringBitmap 라이브러리에는 이미 구간 API 가 있으니, forEachInRange 로 삭제된 위치만 순회하고 사이를 순차로 채우면 된다.

apache-iceberg-1.11.0 을 클론해 175줄을 고치고 다시 빌드했다. 같은 테이블에 jar 만 바꿔서 쟀다.

설정 컨테이너 현행 패치본 개선
0.5% array 674 79 8.56배
6.1% array 873 94 9.28배
7.0% bitmap 420 113 3.73배
0.5% 정렬 run 185 61 3.05배

_deleted 투영 경로는 18.9배다. 빈틈을 채울 필요가 없어 이득이 더 크다. equality delete 테이블은 회귀가 없다. 게이트가 기존 루프로 정확히 폴백한다.

비교가 성립하도록 베이스라인도 릴리스 jar 을 쓰지 않았다. 같은 소스 트리를 패치만 stash 한 상태로 다시 빌드했다. 두 jar 의 유일한 차이가 이 diff 여야 하기 때문이다.

이 패치는 업스트림에 올려두고 리뷰를 기다리는 중이다.

My PR: Core, Spark 4.2: Add PositionDeleteIndex.forEachInRange and use it in vectorized delete checks

그런데 대부분의 쿼리에서는 이 개선이 안 보인다

이 프로젝트에서 가장 쓰기 싫었던 절이다.

20컬럼 넓은 투영에서도 DV 체크 CPU 는 6.33배 줄어든다. 그런데 그 CPU 가 전체 스캔의 4.8% 뿐이라 전체 쿼리 시간으로는 측정되지 않는다.

"그래서 실무에서 몇 % 빨라지나" 의 정직한 답은 "몇 컬럼을 읽느냐에 달렸고, 20컬럼이면 측정 불가" 다. 절감되는 CPU-초는 실재한다. 클라우드 청구서는 CPU-초로 온다. 그러나 대시보드의 쿼리 시간은 안 움직인다. 둘 다 사실이고, 둘 다 말해야 한다.

파일이 잘게 쪼개져 있으면 이득이 절반이 된다

같은 데이터(3,200만 행)를 파일 4개와 파일 488개로 나눠 담고 다시 쟀다. 파일 경계를 청크(65,536) 배수로 맞춰서 두 테이블의 Roaring 컨테이너가 바이트까지 동일하게 만들었다. 488개, array:460 + bitmap:28, 3,901,922 B. 삭제된 행도 같다. 남은 차이는 그게 puffin 파일 4개에 담겼느냐 488개에 담겼느냐뿐이다.

DV 로드/역직렬화 배치 루프 비-DV 스캔
파일 4개 70 1,292 1,062
파일 488개 212 (3.0배) 1,236 (−4%) 1,720 (+62%)

배치 루프는 안 움직인다. 하는 일이 같으니 당연하다. 움직이는 건 파일당 고정비, 즉 Parquet 푸터 파싱과 DV 를 하나 더 읽는 비용이다. 그래서 DV 비중이 56.2% 에서 45.5% 로 떨어지고, 패치 이득도 6.97배에서 3.43배로 준다. 패치가 나빠진 게 아니라, 패치가 못 건드리는 고정비가 분모를 지배하게 된 것이다.

이건 내가 "파일이 많으면 요청 지연이 지배하는데 그건 대기라 비중은 안 변한다" 고 추측으로 적어뒀던 자리다. 틀렸고, S3 를 켜기도 전에 로컬 디스크에서 틀렸다. S3 에서 다시 재보니 스토리지는 여전히 아무것도 안 바꿨다. 같은 파일 수에서 EBS 60.7% 대 S3 61.1%, 파일이 488개일 때도 49.7% 대 49.0% 다. 움직이는 건 파일 수뿐이고 그 크기도 두 스토리지에서 같다.

기전은 세 번 다 예상과 달랐다

처음에 나는 이렇게 적었다. "array 컨테이너는 이진 탐색이라 분기 예측 실패가 많아 비싸다."

근거는 비용 곡선의 모양뿐이었다. 그리고 8개월 동안 "WSL2 에는 하드웨어 PMU 가 없다" 고 여덟 군데에 적어두고 확인을 안 했다. 어느 날 확인해보니 있었다.

재보니 분기는 비용 차이의 1.4% 만 설명했다. 실제 쿼리 경로에서는 0.2% 였다. IPC 가 4.7 로 상수라 파이프라인은 아예 막히지 않았다. 기전 설명이 반증된 것이다.

그러면 왜 비싼가. 명령어 수다. 그냥 일을 더 한다. 그리고 그건 컨테이너 타입 이야기가 아니라 "행마다 호출한다" 는 구조 자체의 이야기다. 가장 싼 bitmap 도 행당 86.5 명령어를 쓴다. 기전이 패치를 가리켰다.

예외가 딱 하나 있었다. 삭제율이 아주 높을 때(d=8%에서 50%로)의 반등이다. 여기서는 if (!contains(pos)) 가 정확히 동전 던지기가 된다. 분기 실패가 40% 늘었는데, 처음 계산으로는 늘어난 사이클의 28.5% 만 설명됐고 나머지 55% 는 이름이 없었다.

루프 몸통을 변형 6종으로 잘라 다시 쟀다.

변형 남긴 것 d=8% d=50% 증가폭
현행 분기 O · 저장 O 28.88 37.30 +29.1%
분기 제거 분기 X · 저장 O 28.02 27.90 −0.4%
조회만 분기 X · 저장 X 27.06 21.78 −19.5%

분기를 지우면 반등이 100% 사라진다. 그런데 branch-misses 카운터가 세는 건 그중 33% 뿐이다. 나머지는 IPC 에 있었다.

d=8% d=50%
명령어/op 587,777 604,491 (+2.8%)
IPC 4.62 3.68 (−20.3%)

일이 늘어서가 아니라 파이프라인이 막혀서다. 예측 불가능한 분기는 투기 실행을 끊어 루프를 직렬화하고, 그 정지 시간은 오예측 카운터가 아니라 IPC 로만 보인다. 미상 55% 는 새 범인이 아니라, 카운터가 3분의 1만 세고 있던 같은 비용이었다.

이 실험에서 변형 6개 중 둘은 설계 실수였다. 저장을 없앤 변형은 JIT 이 조건문을 산술로 바꿔버려서(분기 실패 1,084 에서 4.1로) 대조군이 못 됐다. 하드웨어 카운터를 같이 안 찍었으면 "저장이 범인" 이라는 정반대 결론을 냈을 것이다.

패치를 기다리는 동안 할 수 있는 것, 정렬

삭제 키로 정렬해서 테이블을 쓰면 삭제가 소수 청크에 몰리고 DV 체크가 2.80배 싸진다. 패치가 와도 둘은 서로 대체하지 않는다. 둘 다 하면 12.39배다.

그런데 조건이 네 개 붙는다. 전부 측정된 것이다.

조건 안 지키면
1 삭제가 소수 키 값에 몰릴 때만 고유값 ~1천 2.78배 / ~10억 0.89배(손해)
2 삭제율이 경계 아래일 때 크다 경계 위에서는 1.26배로 줄어든다
3 한 번 쓰고 300번 이상 읽을 때만 쓰기 +63%, 저장 +2.6%
4 어중간하게 뭉치면 오히려 손해 run 컨테이너 nRuns 850 이면 bitmap 보다 1.48배 비싸다

조건 3 을 재면서 놀란 게 하나 있다. 정렬하면 파일이 작아질 줄 알았는데 2.6% 커졌다. 컬럼별로 쪼개보니 이유가 정확했다. 정렬 키는 399배 작아지는데, 원래 파일 안에서 완벽한 등차수열이던 id 가 2배가 된다. 델타 인코딩이 먹던 이득이 날아가는 것이다.

삭제 키로 정렬하면 원래 정렬돼 있던 컬럼, 그러니까 자동증가 id 나 적재 타임스탬프가 커진다. 아무도 말해주지 않는 청구서다.

정렬이 좋은 이유도 생각과 달랐다

"정렬하면 run 컨테이너가 되어서 싸진다" 고 1년 가까이 적어왔다. 그런데 이 설명에는 구멍이 있었다. 정렬하면 컨테이너 타입(array에서 run으로)과 개수(124에서 12로)가 같이 움직인다. 그래서 타입은 그대로 두고 개수만 줄이는 축을 따로 만들어 쟀다.

컨테이너 감소 비용 이득
정렬 (구조 + 개수) 10.3배 3.53배
개수만 7.2배 2.22배
나머지 (run 구조 몫) 약 1.59배

타입을 하나도 안 바꾸고 개수만 줄였는데 2.22배가 나왔다. 정렬이 좋은 이유는 run 컨테이너가 되어서가 아니라 삭제가 있는 청크를 비우기 때문이다. 조건 4 와 같은 이야기다. run 이 되는 것 자체는 이득이 아니었다.

그래서 처방의 문장이 바뀐다. "run 이 되게 하라" 가 아니라 "청크를 비워라" 다.

남는 건 숫자가 아니라 방법이었다

이 프로젝트에서 예측이 19번 빗나갔다. 몇 개만 옮기면 이렇다.

  • "분기 예측이 컨테이너 비용을 설명한다" → 반증
  • "손익분기는 컬럼 수다" → 축은 디코딩 비용이었다
  • "정렬하면 파일이 작아진다" → 반대로 커졌다
  • "본전 회수 10회 미만" → 약 300회. 30배 빗나갔다
  • "컨테이너 개수만 줄이면 1.5배 미만" → 2.22배

세어질 수 있었던 건 측정 전에 예측을 적어뒀기 때문이다. 모든 실험 스크립트의 헤더에 예측을 먼저 적었고, 채점 도구는 그 예측을 다시 적지 않는다. 다시 적게 두면 사후에 조용히 고칠 수 있게 된다. 적어두지 않았다면 19개 전부 "예상대로였다" 가 됐을 것이다.

빗나가는 방식도 두 가지였다. 예측이 틀린 경우와, 예측을 시험하지도 못한 경우다. 후자가 더 무섭다. 위의 JIT 사례가 그거다.

노이즈를 먼저 재라

가장 비싼 교훈이다. 처음엔 노이즈를 Poisson 근사 ±√n 으로 추정하고 "350 ± 19 이므로 명확히 분리됨" 이라고 적었다. 같은 설정을 실수로 두 번 재보니 실제 흩어짐은 20~41% 였다. 두 배가 아니라 자릿수가 틀렸다.

그래서 모든 설정을 3회 반복으로 다시 쟀고, 흩어짐 중앙값 9.7% 를 확정한 뒤 "이 값보다 작은 차이는 주장하지 않는다" 를 채점 코드에 박아넣었다. 사람이 표를 보고 판단하면 반드시 노이즈를 신호로 읽는다.

큰 주장 셋은 살아남았고 오히려 커졌다. 죽은 건 미세 구조뿐이었다. 다만 이건 사후적으로 운이 좋았던 것이지, 반복 없이 주장했던 방법이 옳았던 건 아니다.

전제도 측정 대상이다

"WSL2 에는 PMU 가 없다" 를 여덟 군데에 적고 8개월을 버렸다. 확인해보니 있었고, .metal 인스턴스를 빌리려던 계획이 통째로 필요 없었다.

"환경 때문에 못 한다" 는 문장을 쓸 때마다, 그게 측정된 것인지 물어야 한다.

게이트는 로그가 아니라 산출물을 봐라

✅ 완료 (0초) 는 거짓말을 한다. 스크립트가 아무것도 안 하고 성공으로 끝날 수 있다. 그래서 모든 게이트가 로그가 아니라 산출물, 즉 파일 수와 행 수와 컨테이너 수를 센다.

격자를 만들 때 실제로 서로 다른 데이터가 나오는지 먼저 검증하는 것도 같은 이야기다. 한 축은 이걸 안 해서 통째로 날아갔다. 파일당 청크가 30개뿐이라 파라미터를 바꿔도 같은 테이블이 나왔고, 서로 다른 두 설정을 만들고 있다고 믿으면서 같은 걸 두 번 쟀다.

한 번은 "총 추가 실패의 183.9%" 라는 값을 내고 그 상태로 "맞음" 판정이 났다. 분모가 섞여 있었다. 맞추니 34.9% 였고 판정이 "절반만 맞음" 으로 뒤집혔다. 100% 를 넘는 비율이 나오면 거기서 멈춰야 한다.

아직 모르는 것

정직하게 적는다.

  • 파일 수천 개짜리 테이블. 488개까지 EBS 와 S3 양쪽에서 쟀다. 수천 개는 안 쟀다.
  • 크로스 리전 S3. 같은 리전에서만 쟀다. 대기가 훨씬 크겠지만 그건 CPU 시간이 아니므로 비중을 못 바꾼다는 게 이 축의 결론인데, 다만 안 쟀다.
  • 더 큰 클러스터. 익스큐터 2개짜리에서 42% 였고, 이건 하한이다.
  • 조인이 붙은 쿼리. 집계 두 종만 쟀다.
  • 샘플에서 벽시계로 넘어가는 보정 0.53 의 나머지. 정체가 JIT 이라는 것까지는 밝혔다(프로세스 CPU 의 41.6%). 즉 이 글의 벽시계 숫자는 실무에 대해 보수적이다. 오래 사는 익스큐터에서는 희석이 약하다.

부록 — 측정 조건

모든 수치는 설정당 3회 이상 반복의 중앙값이다. 반복 흩어짐은 중앙값 9.7%, 최대 25.9%.

  • Ryzen 5 5600X (Zen 3) · WSL2 · Spark 4.0 local[4] · JDK 17
  • 로컬 NVMe 웜 캐시 (825MB 테이블, 측정 중 I/O 는 0에 가깝다)
  • 배치 크기 Iceberg 기본 5,000 · 1컬럼 좁은 투영 (달리 적지 않은 한)
  • 클라우드 축은 m7i.xlarge(Sapphire Rapids) 와 m7g.xlarge(Graviton3) 전용 인스턴스
  • 벤치는 JDK 21, Spark 측정은 JDK 17. 서로 다른 축이므로 섞어 읽으면 안 된다

노이즈 바닥 9.7% 는 측정 대상의 성질이 아니라 WSL2 의 성질이었다. 같은 테이블, 같은 jar, 같은 스크립트로 기계만 바꿔 재면 흩어짐이 49.4% 에서 15.0% 로 떨어진다.

그리고 그게 실제로 한 축의 부호를 바꿨다. 집계-대 쿼리에서 노트북은 "패치가 느리다"(6라운드 중 5라운드)를, 전용 인스턴스는 "빠르다"(12라운드 중 12라운드)를 냈다. 없는 회귀를 보고 있었던 것이다. 로컬에서 "노이즈 안" 이라고 적은 것들은 조용한 기계에서 갈릴 수 있다.

전체 측정 기록과 이 결과를 공격하는 방법은 저장소에 정리해 뒀다.

저장소: JeonDaehong/iceberg-dv-anatomy

Comments