Split View: 300배는 PostgreSQL을 튜닝해서 나온 숫자가 아니다 — 화산 모델과 벡터화 실행
300배는 PostgreSQL을 튜닝해서 나온 숫자가 아니다 — 화산 모델과 벡터화 실행
- 들어가며 — 헤드라인의 주어를 확인하는 데 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 가 실제로 몇인지 본다
둘째, 행 개수를 줄이는 쪽이 행 하나당 비용을 줄이는 쪽보다 거의 항상 큽니다. 화산 모델의 오버헤드는 행 수에 비례하므로, 사전 집계 테이블이나 부분 인덱스로 스캔 대상 행 자체를 줄이면 위 표의 모든 단계를 한 번에 건너뜁니다.
셋째, 컬럼 지향 저장이 필요하다면 그걸 하는 확장을 쓰는 편이 낫습니다. 다만 확장의 성능 주장 역시 이 글에서 한 것처럼 "무엇을, 어떤 하드웨어에서, 어떤 설정으로" 측정했는지 확인하고 인용하세요. 그게 이 글에서 가져갈 가장 실용적인 습관입니다.
참고 자료
The 300x Is Not a Number You Get by Tuning PostgreSQL — The Volcano Model and Vectorized Execution
- Introduction — Thirty seconds to check the subject of the headline
- 300x and 10x are different experiments
- What the 20-second SUM query actually is
- The volcano model — one function call per row
- Batching — dividing the call count by 1024
- Operator fusion — and the point where the author calls it cheating himself
- SIMD — why the compiler does not do it for you
- What the table says, and what you can do today
- References
Introduction — Thirty seconds to check the subject of the headline
When a post saying "we made PostgreSQL 300x faster on analytical queries" starts going around the timeline, the first thing to check is not the number 300 but the subject of the sentence.
The original is Rebuilding Postgres for 300x faster analytics, posted on malisper.me on 3 August 2026, and its first paragraph begins like this: "Last week we released pgrust 0.2."
pgrust is a separate database that reimplements PostgreSQL in Rust. Its wire protocol and SQL dialect are compatible and it passes all 46,066 regression tests, but the first line of the repository README says it is not production ready and that you should not put precious data in it. In other words, 300x is not a number you can obtain by editing the configuration file of the PostgreSQL you are running in production today.
That does not at all mean the post is not worth reading. Quite the opposite. The author breaks out the roughly 10x share of the 300x that the query engine contributed into 60 lines of Rust that anyone can reproduce. That decomposition is the real content of the post, and it is what we should be learning.
300x and 10x are different experiments
To prevent confusion, let me separate the two numbers first.
300x is a ClickBench score. According to the README it was measured on a c8g.4xlarge (AWS Graviton4) against PostgreSQL 18.3, and it used pgrcolumnar, pgrust's own columnar storage format. It is also written that on the same benchmark it was 18.5 percent faster than ClickHouse.
9.6x is the SUM query experiment in the body of the blog post. This is a comparison of four versions of pure Rust code that have nothing to do with pgrust at all.
The caveats the author wrote into the README himself have to be read alongside. The benchmark build was tuned for Graviton4 with -Ctarget-cpu=neoverse-v2 while the distributed binaries are not, so he states explicitly that downloading it will not reproduce the result as-is. The JIT compiler also targets Graviton4 only. He also discloses that on Kubernetes the OLTP gap actually came out larger (50 to 60 percent), but since he could not determine the cause he cites the lower number, 30 percent.
It is rare for a benchmark post to write down its own conditions to this degree. The polite thing is to carry that diligence over intact when quoting it.
What the 20-second SUM query actually is
The query the author takes as his starting point is this.
CREATE TABLE my_table AS
SELECT col::float8 FROM generate_series(1.0, 500000000.0) g(col);
SELECT SUM(col) FROM my_table;
The measurement conditions are c8g.4xlarge, PostgreSQL 18.4, max_parallel_workers_per_gather = 0, data loaded into shared buffers, median of five runs. Under those conditions it took about 20 seconds.
Adding the same 500 million f64 values with a simple Rust for loop takes 358 milliseconds. That is a 55x difference, and the author immediately nails down that this is not an apples-to-apples comparison, because the PostgreSQL side additionally does locking, storage format parsing, tuple extraction, and so on.
That parallel query was turned off here matters too. On a real production PostgreSQL, max_parallel_workers_per_gather is on by default and this query would be split across several workers. The author turned it off in order to see the single-core efficiency of the query engine itself, not to disadvantage PostgreSQL. But when you quote this number you have to quote the conditions with it.
The volcano model — one function call per row
The PostgreSQL executor uses the volcano model. Every plan node has a next(), and calling it once produces one row. Keep calling next() at the root and the query finishes. The miniature version the author built looks like this.
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)
}
}
The strength of this structure is clear. Each node only has to implement a single method, and any node can be stacked on any other node. It is why PostgreSQL can manage more than 40 kinds of plan node without suffering a combinatorial explosion.
The problem is the cost. With 500 million rows, next() is called 500 million times. On top of that, a call hidden behind Box<dyn Node> is an indirect call whose target is only determined at run time, so branch prediction and inlining do not work well. This miniature takes 1.3 seconds; the for loop takes 358 milliseconds. Most of the difference is this call overhead.
Batching — dividing the call count by 1024
The first optimization is batching. next() is changed to fill and return an array of 1024 elements instead of a single row.
const BATCH: usize = 1024;
trait BatchNode {
fn next_batch(&mut self, out: &mut [f64; BATCH]) -> usize;
}
The number of calls drops from 500 million to about 490,000, and the time goes from 1.3 seconds to about 480 milliseconds. That is 2.7x.
There is one detail the author points out that matters more in practice. It is that the batch buffer was taken on the stack. [0.0f64; BATCH] is not a heap allocation, so the aggregate node allocates no memory at all during execution. If you switch to batch-based execution but create a fresh Vec per batch, you hand back the function call cost you saved as allocation cost.
Operator fusion — and the point where the author calls it cheating himself
Profile the batched version and now copy_from_slice is the hotspot. The scan copies into the buffer and the aggregate reads that buffer, so an intermediate copy remains.
Operator fusion merges the scan and the aggregate into a single node and removes that copy. The result is 358 milliseconds, exactly the same as the for loop. Naturally so: once merged, it is literally the same code.
The author applies the brakes to himself here. In the original wording he writes that this may seem like cheating, and that it definitely is. That is because he knew in advance what query would come and hard-coded only that combination. Preparing a few common combinations in advance is meaningful, but combinations you did not prepare for show up quickly.
The general solution for that is JIT compilation. If you generate machine code fitted exactly to a query after receiving it, you can "cheat" on every query. That is what pgrust actually uses, and the post does not cover it, deferring it to a later piece.
SIMD — why the compiler does not do it for you
The last one is SIMD. Using aarch64 NEON, four accumulators are kept and eight values are added per chunk.
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 milliseconds. That is 2.7x faster than even the for loop, and 9.6x against the original volcano model.
Why did the compiler not perform this transformation on its own? The author answers explicitly: because floating point addition is not associative. Split the accumulator into four and the order of addition changes, and the result can differ in the last bit. By default the compiler does not permit that transformation. The reason the author picked this example is precisely so that the compiler would not secretly vectorize it and ruin the experiment.
Had it been an integer sum, the compiler would have vectorized it on its own and the gain at this stage would have looked far smaller. It is a good example of benchmark design producing the result.
What the table says, and what you can do today
| Implementation | Time | Multiple |
|---|---|---|
| PostgreSQL 18.4 | about 20 seconds | — |
| Volcano model | 1.3 seconds | 1x |
| Batching added | 480 milliseconds | 2.7x |
| Operator fusion added | 358 milliseconds | 3.6x |
| SIMD added | 135 milliseconds | 9.6x |
The left end and the right end of this table are of different natures. From 1.3 seconds down to 135 milliseconds is the result of changing only the execution strategy over the same language, the same process, the same data structure. From 20 seconds down to 1.3 seconds is the result of storage format, locking, MVCC visibility checks, and tuple deserialization being removed wholesale. The former is a story about execution engine design; the latter is a story about "the cost a database pays for being a database."
So summarizing this post as "PostgreSQL is slow" gets it half wrong. Precisely, the story is that an executor designed in the 1980s on the assumption that disk I/O is the bottleneck reveals a CPU bottleneck on the 2026 analytical workloads whose data fits entirely in memory. The author writes exactly that in his opening.
So, while pgrust is not usable, how far can the same principles be applied? The items below do not appear in the original post and are a separate discussion, so please check the defaults and support status in the documentation for your own version before using them.
First, do not turn parallel query off. The experiment above turned it off deliberately, but on a real aggregate scan, adding workers is still the easiest multiplier available. Start by checking whether Gather appears in the plan and how many workers attached.
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT SUM(col) FROM my_table;
-- look at what Workers Planned / Workers Launched actually are
Second, reducing the number of rows almost always beats reducing the cost per row. Volcano model overhead is proportional to row count, so if you cut the rows to be scanned themselves with a pre-aggregated table or a partial index, you skip every stage of the table above at once.
Third, if you need columnar storage, you are better off using an extension that does that. But check and quote an extension's performance claims the same way this post did — what was measured, on what hardware, with what settings. That is the most practical habit to take away from this post.
References
- Rebuilding Postgres for 300x faster analytics: batching, operator fusion, and SIMD — malisper.me, 2026-08-03
- pgrust README — GitHub (status, benchmark conditions, the limits the author states)
- Hacker News discussion thread
- ClickBench — ClickHouse