TPC-H run across four Postgres-compatible engines, on matched hardware, to see what the AlloyDB columnar engine actually buys you and where the "up to 100x" number holds up. Companion data for the boringSQL post on AlloyDB.
Everything here is meant to be re-run. If a number doesn't reproduce for you, that's a bug worth an issue.
| label | engine | shape | notes |
|---|---|---|---|
| A | PostgreSQL 18.4 (native) | n2-highmem-8, pd-ssd | stock, tuned by hand |
| B | Cloud SQL 18.4, Enterprise | 8 vCPU / 52 GB | managed vanilla Postgres |
| C | AlloyDB Omni 17.7 | same VM as A | AlloyDB engine, self-run |
| D | AlloyDB managed 18.3 | 8 vCPU / 64 GB | AlloyDB engine, hosted |
All europe-west1, TPC-H scale factor 10 (same dbgen output everywhere, checksums
in checksums-sf10.sha256). Query set: Q1, Q5, Q6, Q12, Q14, Q18, plus a
point-lookup control. At SF10 each query is run 5 times warm with the cold run
dropped; the SF100 runs below used 3 warm runs. Median and p90 are reported over
the runs that completed, and each summary table states how many that was. Verbatim EXPLAIN (ANALYZE, BUFFERS) for every run is
under results/raw/<label>/.
The engine variants (Omni and managed only) carry a suffix:
C0/D0columnar engine offC1/D1columnar on, auto-populated (the engine picks columns from the workload)C2columnar on, columns pinned by hand
C0-47g / C0-16g are the same run at two shared_buffers sizes (see caveats).
Parity is the whole point of a four-engine comparison, so the places it breaks are stated, not hidden:
- Postgres major. A, B, D are on 18; C (Omni) is on 17. The current Omni Docker image lagged a major behind managed AlloyDB at the time of the run. The columnar deltas (C0 vs C1) are internal to one engine and unaffected; only the absolute A-vs-C comparison carries the gap.
- Memory. Cloud SQL Enterprise caps at 6.5 GB/vCPU, so 8 vCPU tops out at 52 GB, not the 64 GB AlloyDB gives you at the same core count. vCPU parity was kept (parallelism matters more for TPC-H); the 52-vs-64 gap is immaterial here (see the buffer-size result below).
- work_mem and parallelism. Managed services ship conservative defaults
(Cloud SQL:
work_mem=4MB, 2 workers per gather). Left as-is those dominate the result. B and D are reported both as-shipped and withwork_mem=256MBand 4 workers to match A, because otherwise you are benchmarking the default config, not the engine. Both rows are in the tables. - Omni shared_buffers. Omni auto-tunes to ~47 GB on a 64 GB box. It was run at both 47 GB (its default) and 16 GB (matched to A). The two are within noise at SF10, so buffer size is not the story here. Might be at SF100.
Local smoke test (Docker, small scale, proves the pipeline; not a data point):
make up
make gen SCALE=1
make all
Real runs live on matched cloud instances. A and C share one VM; snapshot the
loaded disk once and restore it for each run rather than re-loading. B and D go
over their auth proxies / an authorized client. The PGURI env var points the
harness at a remote target; unset, it talks to the local Docker container.
PGURI=postgresql://user@host:5432/tpch make schema load index analyze
PGURI=postgresql://user@host:5432/tpch make bench LABEL=B-par
For the columnar engine (Omni and managed), confirm it actually engaged before
trusting a number. If the plan says Seq Scan instead of
Custom Scan (columnar scan), the store wasn't populated for that query:
make imcs-status # what's resident (g_columnar_columns)
Four-engine baseline plus the columnar sweep. B and D shown parity-matched (work_mem/workers = A); B-stock is Cloud SQL's out-of-box default for contrast.
| query | A · PG18 | B-par · CloudSQL | C0 · Omni off | C1 · Omni on | D0 · mgd off | D1 · mgd on |
|---|---|---|---|---|---|---|
| Q1 | 11969 | 11717 | 14056 | 10159 | 12697 | 9922 |
| Q5 | 2496 | 1810 | 2374 | 1870 | 1810 | 1521 |
| Q6 | 2669 | 4009 | 2681 | 69 | 2108 | 62 |
| Q12 | 2357 | 2735 | 2909 | 706 | 2551 | 682 |
| Q14 | 1293 | 1536 | 1005* | 555 | 866 | 481 |
| Q18 | 34301 | 38520 | 29924 | 26168 | 31427 | 31353 |
| pkup | 0.107 | 0.099 | 0.096 | 0.119 | 0.099 | 0.120 |
* C0-47g Q14, from results/tables/summary-C0-47g.md. (Earlier revisions of this
README printed 1596 here, a value in no results file; the C0 Q14 medians are 1004.595
at 47g and 980.897 at 16g.) Full per-run numbers and the 16g/47g and B-stock rows are
in results/tables/.
Cloud SQL at its shipped defaults (B-stock), for the "what you get if you don't
touch the flags" case: Q5 12436 ms, Q1 16506 ms. Seven times slower on the join,
entirely from work_mem=4MB. That gap is a config default, not an engine.
- The columnar engine is real where the shape fits. Q6 (selective scan, few columns) drops ~38x, Q12 by 4.1x and Q14 by 1.8x. The "100x" headline is a best case for one query shape at larger scale, not a general multiplier. At SF100 that best case shows up: Q6 hits 311x (see "At scale" below).
- It is not magic on everything. Q1 sums four
numeric(15,2)columns over all 60M rows and moves only ~1.4x; the numeric arithmetic dominates even in columnar. Q18 (an uncorrelatedINsubquery) moves 1.14x, the smallest win in the set. Both are honest ceilings. - Auto-population beat hand-pinning. On the join queries the engine's own column recommendation (C1) outperformed the columns we pinned by hand (C2), because it pulled in join keys the manual pin missed.
- Managed (D1) and self-run Omni (C1) columnar results track each other closely. The disaggregated storage doesn't change the columnar query profile at SF10.
- The point-lookup control moves nowhere with columnar on, as it should. If it had regressed, that would matter.
The four-engine matrix above is SF10. To see where the "100x" claim comes from, one
config was rerun at SF100 (scale factor 100, ~600M lineitem rows, ~130 GB heap):
AlloyDB Omni on the same 8 vCPU / 64 GB box, shared_buffers=16GB so the row store
is genuinely I/O-bound, columnar store 32 GB. Row-store baseline (C0) vs columnar
(C1), both gate-verified Custom Scan (columnar scan) on every query. Raw plans in
results/raw/C0-sf100/ and results/raw/C1-sf100/.
| query | C0 row-store | C1 columnar | speedup | shape |
|---|---|---|---|---|
| q6 | 182,517 ms | 587 ms | 311x | selective scan, few columns |
| q5 | 242,559 ms | 24,688 ms | 9.8x | six-table join |
| q12 | 201,411 ms | 41,708 ms | 4.8x | join + date filter |
| q1 | 186,377 ms | 94,195 ms | 2.0x | numeric full aggregate (ceiling) |
| pkup | 0.146 ms | 0.110 ms | ~1x | point lookup (control) |
q14 and q18 are absent from this table on purpose. Neither yields a defensible speedup at SF100, and earlier revisions of this README reported both as though it did:
- q14 has no row-store baseline. Both C0 attempts died with
could not resize shared memory segment ... (errno=28)before producing a timing -- the harness under-provisioned/dev/shm, which is my fault and not either engine's. The columnar side ran fine (5,465 ms), but it is unpaired. - q18 ran to completion on the row store, twice, at 655 s and 661 s. Every columnar
attempt was cancelled by a statement timeout. So the two sides did not run under the
same effective limit and the pair cannot be turned into a ratio. Note that no script
in this repo sets
statement_timeout: it came from the session/environment, and it was evidently not applied uniformly, since the C0 runs were allowed past 650 s.
Per-query detail, including how many runs completed, is in
results/tables/summary-C0-sf100.md and summary-C1-sf100.md.
The mechanism is in the q6 plans. Row store: Buffers: shared read=11549743,
I/O Timings: shared read=814349 ms, a parallel seq scan that reads 11.5M pages off
disk. Columnar: Columnar cache search mode: native, no heap reads at all. That is why
the SF10 gap (38x, mostly in RAM on both sides) widens to 311x at SF100: the row store
spills to disk while the columnar store stays memory-resident. q1 (2.0x) is the same
ceiling as at SF10, just larger.
One thing this pair is not: a single-variable experiment. To hold its columns, C1 had
about 31 GB of columnar store on top of the 16 GB buffer pool, while C0 got the 16 GB and
nothing else (results/tables/imcs-status-C1-sf100.txt). So it measures the engine and
roughly 3x more memory over the hot data together. The two cannot be separated here,
because making the hot set fit is the mechanism -- the heap is ~100 GB, which no buffer
pool on a 64 GB box accommodates. The SF10 C0/C1 pair has no such confound.
One trap, documented because it silently produces wrong numbers: at SF100,
google_columnar_engine_run_recommendation(mb, 'FIXED_SIZE', true) populated zero
columns (no columnar workload history right after enabling the engine), so the store
stayed empty and every plan fell back to a seq scan with C1 == C0. Explicit
google_columnar_engine_add(table, cols) (schema/omni/imcs_pin.sql) is what
populated it. Always confirm residency in g_columnar_columns and a columnar scan
node in EXPLAIN before trusting a columnar number.
These ran on separate harnesses and their results are in this tree:
| track | data | caveats |
|---|---|---|
| Concurrency (HTAP), SF10 | results/tables/htap-writehot.txt |
one run per config |
| Concurrency (HTAP), SF100 | results/tables/htap-scale-sf100.txt, results/raw/htap_scale.log |
median of three 120 s runs; writes and scan hit different tables, so there is no columnar invalidation -- unlike the SF10 test, which is same-table |
| INSERT throughput | results/tables/htap-writehot.txt |
one run each; the pinned run went second off a warmed source, biasing its cost down |
| Read-pool lag | results/tables/lag-alloydb-readpool.txt |
30 samples, sustained load; no burst test, no Cloud SQL head-to-head |
| Vacuum | results/tables/vacuum-alloydb.txt, vacuum-cloudsql.txt |
churn workload, not the pgbench mix in run/vacuum.sh |
| ScaNN vectors | results/tables/vec-*-bgem3-10m.md, summary-vec-bgem3-10m.md |
read results/raw/scann_run.NOTES.md first: two runs, only one logged, size and build time reproduce but recall does not |
| Extensions | results/tables/extensions-omni-available.txt |
from pg_available_extensions on the Omni image |
cost/ holds the break-even model behind the article's read-pool charts. It is
list-price modelling, not measured spend -- see cost/README.md.
Stated so you don't have to find them:
- The orchestration scripts that drove the SF100, HTAP, vacuum, lag, crossover and
vector runs are not published here;
run/holds the per-step scripts they called. run/vacuum.shruns a pgbench TPC-B mix, but the committed vacuum data came from a 1M-row churn table with repeated full-table UPDATEs.run/crossover.shcounts every run including the cold one, unlikerun/bench.sh.- The
p90column is computed asint(0.9*n+0.999999), which at n=5 is the maximum. - Nothing here verifies
checksums-sf10.sha256automatically.