- Published on
300배는 PostgreSQL을 튜닝해서 나온 숫자가 아니다 — 화산 모델과 벡터화 실행
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 헤드라인의 주어를 확인하는 데 30초
- 300배와 10배는 서로 다른 실험이다
- 20초짜리 SUM 쿼리의 정체
- 화산 모델 — 행 하나에 함수 호출 하나
- 배치 — 호출 횟수를 1024로 나눈다
- 연산자 융합 — 그리고 저자가 스스로 "반칙"이라 부른 지점
- SIMD — 컴파일러가 알아서 해 주지 않는 이유
- 표가 말하는 것, 그리고 오늘 할 수 있는 것
- 참고 자료
들어가며 — 헤드라인의 주어를 확인하는 데 30초
"PostgreSQL을 분석 질의에서 300배 빠르게 만들었다"는 글이 타임라인에 돌기 시작하면, 가장 먼저 확인해야 하는 것은 300이라는 숫자가 아니라 문장의 주어입니다.
원문은 malisper.me에 2026년 8월 3일 올라온 Rebuilding Postgres for 300x faster analytics이고, 첫 문단은 이렇게 시작합니다. "지난주 pgrust 0.2를 릴리스했다."
pgrust는 PostgreSQL을 Rust로 다시 구현한 별개의 데이터베이스입니다. 와이어 프로토콜과 SQL 방언이 호환되고 회귀 테스트 46,066개를 전부 통과하지만, 저장소 README 첫 줄에 "프로덕션 준비가 되지 않았다, 소중한 데이터를 넣지 말라"고 적혀 있습니다. 즉 300배는 여러분이 오늘 운영 중인 PostgreSQL의 설정 파일을 고쳐서 얻을 수 있는 숫자가 아닙니다.
그렇다고 이 글이 읽을 가치가 없다는 뜻은 전혀 아닙니다. 오히려 반대입니다. 저자는 300배 중 질의 엔진이 기여한 약 10배분을 누구나 재현 가능한 60줄짜리 Rust 코드로 분해해 보여 줍니다. 그 분해가 이 글의 진짜 내용이고, 우리가 배워야 할 것도 그것입니다.
300배와 10배는 서로 다른 실험이다
혼동을 막기 위해 두 숫자를 먼저 분리하겠습니다.
300배는 ClickBench 점수입니다. README에 따르면 c8g.4xlarge(AWS Graviton4)에서 PostgreSQL 18.3을 상대로 측정했고, pgrust의 자체 컬럼 저장 포맷인 pgrcolumnar를 사용했습니다. 같은 벤치마크에서 ClickHouse보다 18.5퍼센트 빨랐다고도 적혀 있습니다.
9.6배는 블로그 본문의 SUM 쿼리 실험입니다. 이것은 pgrust와 전혀 관계없는, 순수 Rust 코드 네 가지 버전을 비교한 결과입니다.
저자가 README에 직접 적어 둔 단서도 함께 읽어야 합니다. 벤치마크 빌드는 Graviton4용으로 -Ctarget-cpu=neoverse-v2 튜닝을 거쳤고 배포되는 바이너리는 그렇지 않으므로 "다운로드해서는 그대로 재현되지 않는다"고 명시합니다. JIT 컴파일러도 Graviton4만 대상으로 합니다. 쿠버네티스에서는 OLTP 격차가 오히려 더 크게(50~60퍼센트) 나왔지만 원인을 규명하지 못해 낮은 쪽 숫자인 30퍼센트를 인용한다고도 밝힙니다.
벤치마크 글을 읽을 때 이 정도로 자기 조건을 적어 두는 경우는 드뭅니다. 그 성실함을 그대로 받아 인용하는 것이 예의입니다.
20초짜리 SUM 쿼리의 정체
저자가 출발점으로 삼은 질의는 이것입니다.
CREATE TABLE my_table AS
SELECT col::float8 FROM generate_series(1.0, 500000000.0) g(col);
SELECT SUM(col) FROM my_table;
측정 조건은 c8g.4xlarge, PostgreSQL 18.4, max_parallel_workers_per_gather = 0, 데이터는 공유 버퍼에 올라간 상태, 5회 중앙값입니다. 이 조건에서 약 20초가 걸렸습니다.
같은 5억 개 f64를 Rust의 단순 for 루프로 더하면 358밀리초입니다. 55배 차이인데, 저자는 곧바로 "이건 동등 비교가 아니다"라고 못 박습니다. PostgreSQL 쪽에는 잠금 처리와 저장 포맷 파싱, 튜플 추출 같은 일이 더 있기 때문입니다.
여기서 병렬 질의를 끈 것도 중요합니다. 실제 운영 PostgreSQL이라면 max_parallel_workers_per_gather가 기본적으로 켜져 있어 이 질의는 워커 여러 개로 나뉩니다. 저자가 이를 끈 이유는 질의 엔진 자체의 단일 코어 효율을 보려는 것이지, PostgreSQL을 불리하게 만들려는 것이 아닙니다. 다만 이 숫자를 인용할 때는 조건도 같이 인용해야 합니다.
화산 모델 — 행 하나에 함수 호출 하나
PostgreSQL 실행기는 화산 모델(Volcano model)을 씁니다. 계획 노드마다 next()가 있고, 한 번 호출하면 행 하나가 나옵니다. 루트에서 next()를 계속 부르면 질의가 끝납니다. 저자가 만든 축소판은 이렇습니다.
trait Node {
fn next(&mut self) -> Option<f64>;
}
struct SeqScan<'a> { table: &'a [f64], pos: usize }
impl Node for SeqScan<'_> {
fn next(&mut self) -> Option<f64> {
if self.pos >= self.table.len() { return None }
let value = self.table[self.pos];
self.pos += 1;
Some(value)
}
}
이 구조의 장점은 명확합니다. 노드마다 메서드 하나만 구현하면 되고, 어떤 노드든 어떤 노드 위에 얹을 수 있습니다. PostgreSQL이 40종이 넘는 계획 노드를 관리하면서도 조합 폭발을 겪지 않는 이유입니다.
문제는 비용입니다. 5억 행이면 next()가 5억 번 호출됩니다. 게다가 Box<dyn Node> 뒤에 숨은 호출은 런타임에야 대상이 정해지는 간접 호출이라 분기 예측과 인라이닝이 잘 듣지 않습니다. 이 축소판이 1.3초, for 루프가 358밀리초. 차이의 대부분이 이 호출 오버헤드입니다.
배치 — 호출 횟수를 1024로 나눈다
첫 번째 최적화는 배치입니다. next()가 행 하나 대신 1024개짜리 배열을 채워 반환하게 바꿉니다.
const BATCH: usize = 1024;
trait BatchNode {
fn next_batch(&mut self, out: &mut [f64; BATCH]) -> usize;
}
호출 횟수가 5억에서 약 49만으로 줄고, 시간은 1.3초에서 약 480밀리초가 됩니다. 2.7배입니다.
저자가 짚는 디테일이 하나 있는데 이게 실무적으로 더 중요합니다. 배치 버퍼를 스택에 잡았다는 점입니다. [0.0f64; BATCH]는 힙 할당이 아니므로 집계 노드는 실행 중 메모리를 전혀 할당하지 않습니다. 배치 단위 실행으로 바꾸면서 배치마다 Vec을 새로 만들면 줄인 함수 호출 비용을 할당 비용으로 그대로 되돌려 받게 됩니다.
연산자 융합 — 그리고 저자가 스스로 "반칙"이라 부른 지점
배치 버전을 프로파일하면 이제 copy_from_slice가 핫스팟입니다. 스캔이 버퍼에 복사하고 집계가 그 버퍼를 읽는 구조라 중간 복사가 남아 있습니다.
연산자 융합은 스캔과 집계를 노드 하나로 합쳐 이 복사를 없앱니다. 결과는 358밀리초, 정확히 for 루프와 같습니다. 당연합니다. 합치고 나면 문자 그대로 같은 코드가 되기 때문입니다.
저자는 여기서 스스로 브레이크를 겁니다. 원문 표현으로 "이건 반칙처럼 보일 수 있고 실제로 반칙"이라고 씁니다. 어떤 질의가 올지 미리 알고 그 조합만 하드코딩했기 때문입니다. 흔한 조합 몇 개를 미리 만들어 두는 것은 의미가 있지만, 준비하지 못한 조합은 금방 나타납니다.
그 일반해가 JIT 컴파일입니다. 질의를 받은 뒤 그 질의에 딱 맞는 기계어를 생성하면 모든 질의에서 "반칙"할 수 있습니다. pgrust가 실제로 쓰는 방식이고, 본문에서는 다루지 않고 다음 글로 미룹니다.
SIMD — 컴파일러가 알아서 해 주지 않는 이유
마지막이 SIMD입니다. aarch64 NEON으로 누산기 4개를 두고 청크마다 8개씩 더합니다.
use std::arch::aarch64::*;
let mut acc = unsafe { [vdupq_n_f64(0.0); 4] };
let (chunks, rest) = self.table.as_chunks::<8>();
for chunk in chunks {
for lane in 0..4 {
unsafe {
let v = vld1q_f64(chunk.as_ptr().add(2 * lane));
acc[lane] = vaddq_f64(acc[lane], v);
}
}
}
135밀리초. for 루프보다도 2.7배 빠르고, 처음 화산 모델 대비 9.6배입니다.
왜 컴파일러가 알아서 이 변환을 해 주지 않았을까요. 저자가 명시적으로 답합니다. 부동소수점 덧셈은 결합법칙이 성립하지 않기 때문입니다. 누산기를 4개로 쪼개면 더하는 순서가 달라지고, 결과가 마지막 비트에서 달라질 수 있습니다. 컴파일러는 기본적으로 그 변환을 허용하지 않습니다. 저자가 이 예제를 고른 이유도 컴파일러가 몰래 벡터화해서 실험을 망치지 않게 하기 위해서입니다.
정수 합계였다면 컴파일러가 알아서 벡터화했을 것이고, 이 단계의 이득은 훨씬 작게 보였을 것입니다. 벤치마크 설계가 결과를 만든다는 좋은 예입니다.
표가 말하는 것, 그리고 오늘 할 수 있는 것
| 구현 | 시간 | 배수 |
|---|---|---|
| PostgreSQL 18.4 | 약 20초 | — |
| 화산 모델 | 1.3초 | 1배 |
| 배치 추가 | 480밀리초 | 2.7배 |
| 연산자 융합 추가 | 358밀리초 | 3.6배 |
| SIMD 추가 | 135밀리초 | 9.6배 |
이 표의 왼쪽 끝과 오른쪽 끝은 성격이 다릅니다. 1.3초에서 135밀리초까지는 같은 언어, 같은 프로세스, 같은 데이터 구조를 놓고 실행 전략만 바꾼 결과입니다. 20초에서 1.3초까지는 저장 포맷, 잠금, MVCC 가시성 판정, 튜플 역직렬화 같은 것들이 통째로 빠진 결과입니다. 앞의 것은 실행 엔진 설계 이야기이고, 뒤의 것은 "데이터베이스가 데이터베이스이기 때문에 내는 비용" 이야기입니다.
그래서 이 글을 "PostgreSQL이 느리다"로 요약하면 절반은 틀립니다. 정확히는 1980년대에 디스크 I/O를 병목으로 가정하고 설계된 실행기가 데이터가 메모리에 다 들어오는 2026년의 분석 워크로드에서 CPU 병목을 드러낸다는 이야기입니다. 저자도 서두에서 정확히 그렇게 씁니다.
그러면 pgrust를 쓸 수 없는 지금, 같은 원리를 어디까지 쓸 수 있을까요. 아래 항목들은 원문에 나오지 않는 별개의 이야기이므로, 각자 자신의 버전 문서에서 기본값과 지원 여부를 확인하고 쓰시기 바랍니다.
첫째, 병렬 질의를 끄지 마세요. 위 실험은 일부러 껐지만 실제 집계 스캔에서 워커를 늘리는 것은 여전히 가장 손쉬운 배수입니다. 계획에 Gather가 보이는지, 워커가 몇 개 붙었는지부터 확인합니다.
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT SUM(col) FROM my_table;
-- Workers Planned / Workers Launched 가 실제로 몇인지 본다
둘째, 행 개수를 줄이는 쪽이 행 하나당 비용을 줄이는 쪽보다 거의 항상 큽니다. 화산 모델의 오버헤드는 행 수에 비례하므로, 사전 집계 테이블이나 부분 인덱스로 스캔 대상 행 자체를 줄이면 위 표의 모든 단계를 한 번에 건너뜁니다.
셋째, 컬럼 지향 저장이 필요하다면 그걸 하는 확장을 쓰는 편이 낫습니다. 다만 확장의 성능 주장 역시 이 글에서 한 것처럼 "무엇을, 어떤 하드웨어에서, 어떤 설정으로" 측정했는지 확인하고 인용하세요. 그게 이 글에서 가져갈 가장 실용적인 습관입니다.