- Published on
The 300x Is Not a Number You Get by Tuning PostgreSQL — The Volcano Model and Vectorized Execution
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 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