TL;DR** — gpudb, the first SQL execution engine for Apple Silicon GPUs, is now an official DuckDB Community Extension. In any DuckDB ≥ 1.5.5: INSTALL gpudb FROM community; — signed, no flags, full Metal backend on M-series Macs. v0.3.0's streaming rewrite reached parity with native DuckDB end-to-end (worst TPC-H cell improved ~100×), while the Metal operator kernels keep their 3–25× wins. Next up: a community-contributed GPU hash join, verified 9.9×.
In May 2026 I shipped something that, as far as I can tell, had never existed: a SQL execution engine that runs on Apple Silicon GPUs. Not a paper, not a prototype in a notebook — a loadable DuckDB extension with real Metal compute pipelines, benchmarked on TPC-H, with a CUDA backend riding the same codebase on Linux. That was gpudb v0.1.0, and the claim was narrow but real: nobody had wired a Mac's GPU into a database before.
Three months and three releases later, two lines crossed at once. The v0.3.0 rewrite reached parity with native DuckDB on end-to-end TPC-H queries — the worst cell improved by roughly 100×. And gpudb became an official DuckDB Community Extension, which means the entire install procedure is now:
INSTALL gpudb FROM community;
LOAD gpudb;
Any DuckDB ≥ 1.5.5, signed binaries, no flags, no downloads. This post is the story of how it got from first kernel to one-line install — including the release where the honest answer was "the SQL path loses by 109×," because that release is the reason the current one works.
Three months from first Metal kernel to signed one-line install
v0.1.0
v0.1.3
v0.2.0
v0.3.0
community
May 9, 2026
first Metal SQL kernels —
the Apple Silicon first
May 10 · hybrid Metal
GROUP BY, 9/10 TPC-H wins
Jul 19 · SQL-correct NULLs,
honest audit: end-to-end loses
Jul 19 · streaming rewrite:
native parity end-to-end
INSTALL gpudb
FROM community
registry: v0.3.0
Fig 1 — Three months from the first Metal SQL kernel to a signed one-line install. v0.2.0's honest audit is the hinge of the story.
Why an extension, and why Apple Silicon
The GPU-database idea is not new. It is, in fact, a graveyard. MapD became OmniSci became HEAVY.AI and was acqui-hired by NVIDIA in 2025. BlazingSQL went dormant in 2021. Voltron Data cut half its staff in 2025. Sirius — the strongest current academic entry, out of UW with NVIDIA backing at CIDR 2026 — is CUDA-only. Every standalone GPU database asked users to migrate their data and their queries to a new engine, and users kept declining.
The standalone GPU-database graveyard, 2013–2026
20132016201920222025
MapD → HEAVY.AI
acqui-hired by NVIDIA, 2025
BlazingSQL
dormant since 2021
Voltron Data
50% layoff
Sirius (CIDR 2026)
CUDA-only
SQL engines targeting Apple Silicon GPUs before May 2026: none
gpudb opened the lane — as a DuckDB extension, not another standalone database
Every standalone GPU database was acqui-hired, went dormant, or pivoted.
The wedge that survived is an extension riding an engine people already run.
Fig 2 — The standalone GPU-database graveyard, 2013–2026. The Apple Silicon lane was empty until May 2026.
gpudb makes two bets against that history. First: be an extension, not a database. DuckDB is already on the machines that matter — analysts' laptops, CI boxes, notebooks — and a loadable extension means zero migration: your files, your SQL, one LOAD. Second: target the hardware nobody else targets. Every engine in that graveyard, and every survivor, is CUDA-only. Meanwhile Apple ships up to 512 GB of unified memory at 819 GB/s on the M3 Ultra — a memory subsystem that would embarrass most server hardware — and no SQL engine had ever touched its GPU.
Why Apple Silicon changes the GPU-database math
Discrete GPU (every prior engine)
CPUsystem RAM
GPUVRAM
PCIe
copy tax on every query
SUM 100M int64, cold: 80.6 ms — PCIe-bound
Apple Silicon (unified memory)
one memory pool — CPU + GPU
up to 512 GB @ 819 GB/s (M3 Ultra)
no copy — the GPU reads the same bytes
M4 Max sustains ~470 GiB/s in gpudb kernels
The double edge: unified memory deletes the transfer cost that killed discrete-GPU
databases — but it also deletes the excuse. If the CPU reads the same bytes at full
bandwidth, the GPU must win on compute per byte. v0.3 is that lesson, learned.
Fig 3 — Unified memory deletes the PCIe copy tax that killed discrete-GPU databases — and with it, the excuse. On UMA the GPU has to win on compute, not memory locality.
What v0.1 proved: the Apple Silicon GPU is a real analytics device
The first release was about existence proofs, and the numbers were better than I expected. On an M4 Max, against DuckDB's own CPU engine using all 16 threads — the honest baseline, not a strawman single-threaded loop — the Metal operator scorecard came back 9 wins, 1 loss on TPC-H lineitem cells.
Metal v0.1.3 vs DuckDB CPU (16 threads), Apple M4 Max — operator scorecard
multi-agg fusion · SF10 l_quantity
multi-agg fusion · SF10 l_extendedprice
multi-agg fusion · SF10 l_orderkey
SUM l_quantity · SF10 hot
GROUP BY l_extendedprice · 1.35M unique
GROUP BY 500M × 1M synthetic
GROUP BY 1B × 1M synthetic
SUM 1B int64 hot
SUM l_extendedprice · SF10 hot
GROUP BY l_orderkey · 15M unique
GROUP BY l_quantity · 50 unique
25.5×
22.0×
9.7×
4.3×
3.9×
3.4×
3.2×
2.6×
2.2×
1.3×
CPU wins 14× — structural
The loss is reported, not hidden: at 50 unique groups the CPU keeps everything in L1
and no GPU dispatch can pay for itself. The hybrid planner exists for exactly this —
it routes those shapes to CPU. 9 wins, 1 honest loss on the lineitem card.
Fig 4 — The v0.1.3 Metal scorecard vs DuckDB CPU (16 threads). The 14× loss at 50 unique groups is structural and stays on the card.
The headline is the top of that chart: multi-aggregate fusion. Most real analytical queries don't compute one aggregate — they compute several over the same column (SELECT SUM(x), MIN(x), MAX(x), COUNT(x) is the median TPC-H pattern). Executed naively, that reads the column from DRAM once per aggregate. The fused Metal kernel reads it once and computes all four in a single pass, sustaining about 470 GiB/s on M4 Max — roughly 87% of the machine's memory bandwidth. Fusing four operations into one pass costs zero extra bandwidth, so per-op throughput effectively quadruples. That is where the 22–25× cells come from.
The fusion wedge: SUM(x), MIN(x), MAX(x), COUNT(x) — one read, not three
separate: 3 passes over DRAM
SUM
MIN
MAX
fused: 1 pass, 4 results
SUM·MIN·MAX·COUNT
1B rows hot: 48.8 ms → 16.1 ms (3.04×)
Sustained kernel bandwidth, M4 Max:
~470 GiB/s
peak
≈ 87% of LPDDR5X memory bandwidth — fusing four ops into one pass costs zero extra,
so per-op throughput effectively quadruples — the shape of half of TPC-H (Q1, Q6, …).
Fig 5 — The fusion wedge: one pass over DRAM instead of three, at ~87% of the M4 Max memory peak.
And the honest loss stays on the card: a GROUP BY with only 50 unique values loses to the CPU by 14×, because the CPU keeps its entire hash table in L1 cache and no GPU dispatch can pay for itself. That's not a bug to fix — it's a boundary to respect, which is why gpudb ships a hybrid planner that routes low-cardinality shapes to the CPU and keeps the GPU for the shapes it wins.
The Linux side: same codebase, opposite physics
The CUDA backend tells the same story from the other direction. On an RTX 4090, a resident-column SUM over 100 million rows sustains 1187 GiB/s — 17.9× over the CPU, finishing in 0.04 ms. But run it cold, and the PCIe transfer costs 80 ms before the 0.04 ms kernel even starts.
The CUDA lane (RTX 4090 Laptop, sm_89) — SUM 100M int64
CPU (DuckDB)
54 GiB/s · 13.8 ms
CUDA · resident
1187 GiB/s · 0.04 ms · 17.9×
CUDA · cold
80.6 ms — PCIe-bound: the copy costs more than the kernel
Same lesson seen from the other side: on a discrete GPU the transfer is the enemy,
so the design pushes toward resident columns; on unified memory the CPU reads the
same bytes — so the design pushes toward compute-dense operators. One codebase, both.
Fig 6 — The CUDA lane on RTX 4090: 1187 GiB/s resident, PCIe-bound cold. Discrete GPUs push the design toward resident columns; unified memory pushes it toward compute-dense operators.
On a discrete GPU the transfer is the enemy, so the design wants resident columns. On unified memory the transfer is free — but so is the CPU's access to the same bytes, so the design wants compute-dense operators. One codebase, two backends, and the two lessons turn out to be mirror images of each other. Which brings us to the release where I had to learn that the hard way.
v0.2: the release that lost, on the record
The operator scorecard above is measured at operator level — the kernels against the CPU doing the same work. v0.2.0 asked the harsher question: what happens end-to-end, through the DuckDB CLI, on real rewritten TPC-H queries, extension versus native? The answer, recorded in BENCHMARK.md with a straight face: the extension lost every cell, by 3× to 109×.
The cause wasn't the GPU — it was the plumbing around it. The v0.2 aggregate path buffered every incoming value into per-state vectors and reduced at finalize. On unified memory, that copy is the whole cost: the query spends its time duplicating a column DuckDB already holds in memory, then a fast reduction at the end saves nothing. The 4-point analysis in the benchmark log predicted the loss was structural, not tunable. It was right.
v0.3: parity, by deleting work instead of adding it
The v0.3.0 rewrite replaced the buffered path with streaming running accumulators — the same algorithmic shape as a native DuckDB aggregate. No buffering, no copy, no finalize-time reduction. And on unified memory, one more consequence falls out: for a plain scalar aggregate over a column, the rewritten path doesn't dispatch to the GPU at all, because shipping bytes to a coprocessor that shares your memory just to add them up is pure overhead.
v0.3.0 streaming rewrite — end-to-end TPC-H vs native (lower = better)
Q6 · SF1
Q6 · SF10
Q1 · SF1
Q1 · SF10
GROUP BY · SF1
GROUP BY · SF10
v0.2.0: ~3× slowerv0.3.0: 1.00×
~3.4×1.06×
~30×1.09×
~35×1.03×
~86×1.20×
~109×1.18×
SF10 GROUP BY, one cell:
11.05 s → 0.109 s
~100× — identical results
Same queries, methodology and machine (M4 Max, DuckDB CLI, 16 threads, median of 5).
Correctness gated first: Q6 totals match native to the printed digit, Q1 row sets match
row-for-row, GROUP BY checksums identical at both scales.
Bars are log-scaled. Worst v0.3.0 cell: 1.20× — recorded, not rounded away.
Fig 7 — v0.2.0's 3×–109× end-to-end losses collapse to 1.00×–1.20× in v0.3.0, with identical results. The SF10 GROUP BY cell alone improved ~100×.
Every cell is now within 0–20% of native — Q6 at SF1 is a dead-even 1.00×, and the worst cell (SF1 GROUP BY) is 1.20×. The SF10 GROUP BY went from 11.05 seconds to 0.109 seconds. Correctness was gated before any timing counted: Q6 revenue totals match native to the printed digit, Q1 row sets match row-for-row, GROUP BY checksums are identical at both scale factors. The remaining 0–20% is recorded as unprofiled, because it is.
I want to be precise about what this means, because it's the most honest sentence in the release: v0.3's parity came from teaching the extension when not to use the GPU. The GPU's wins remain at operator level — the fused multi-aggregates, the large GROUP BYs, the shapes in Fig 4 — and the hybrid planner routes to them. What v0.3 fixed is that using gpu_* aggregates in an ordinary query is no longer a footgun. The extension now costs nothing where the GPU can't help, and pays off where it can.
What v0.3 taught us: where the GPU belongs on unified memory
bandwidth-bound scalar work
one SUM over one column
→ CPU wins. Don't dispatch.
v0.3 streams it like a native aggregate — parity
compute-dense operator work
fused multi-agg · millions of groups · joins
→ GPU wins. 3×–25× measured.
hybrid planner routes by shape, per cardinality
next in line: joins
community PR #43 — a real Metal hash join + gpu_inner_join,
verified 9.9× on a 1M × 10M inner join on M4 Max — landing in v0.4
Fig 8 — The v0.3 lesson: scalar bandwidth-bound work goes to the CPU, compute-dense operator work goes to the GPU — and joins are the next compute-dense frontier.
Distribution: the part indie projects usually never solve
A database extension you have to compile from source, or load with security flags disabled, is a demo. The DuckDB Community Extensions registry is what turns it into software: the DuckDB team's infrastructure builds the extension from source on every platform, signs the binaries, and serves them to every DuckDB client. Two merged PRs later (#1898 for acceptance, #2404 for the version bump), the registry now ships gpudb v0.3.0 on all four platforms — with the full Metal backend in the Apple Silicon build, and a clean CPU fallback elsewhere. If you have DuckDB 1.5.5 or newer on an M-series Mac, you are one SQL statement away from Metal kernels. (The CUDA backend still needs a source build on Linux — toolchain packaging is a v0.4 item.)
Distribution, solved: the community registry
INSTALL gpudb FROM community;
LOAD gpudb;
-- any DuckDB ≥ 1.5.5 · signed · no flags
macOS arm64 — full Metal backend
3 more platforms — CPU fallback
Two merged community-extensions PRs (#1898 acceptance, #2404 version bump) —
the registry now builds, signs and serves v0.3.0 on all platforms. Already installed?
UPDATE EXTENSIONS; pulls the latest.
The CUDA backend still needs a source build on Linux (toolchain packaging is a v0.4 item).
Fig 9 — Two merged community-extensions PRs later, the registry builds, signs and serves v0.3.0. Apple Silicon gets the full Metal backend.
What's next: joins, and a contributor I didn't recruit
The best signal a small open-source project can get is a stranger showing up with working code. gpudb's PR #43 is exactly that — a real Metal hash join with an on-device segment reduce and a gpu_inner_join surface, contributed from outside, verified at 9.9× on a 1M × 10M inner join on M4 Max. It's landing after a rebase pass, and it defines the v0.4 arc: joins are compute-dense in precisely the way scalar aggregates aren't, which makes them the right SQL-path GPU story on unified memory. The roadmap after that — resident-column SQL hooks, GPU window operators, and string operators on Metal where nothing like libcudf exists — is in the repo.
Try it
If you have an Apple Silicon Mac and DuckDB ≥ 1.5.5, the whole experiment is thirty seconds:
INSTALL gpudb FROM community;
LOAD gpudb;
SELECT gpu_sum(value::BIGINT) FROM range(1000000) AS t(value);
-- 499999500000, via a Metal streaming aggregate
The repo is github.com/singhpratech/duckdbgpumetaldbram — Apache-2.0, with every number in this post reproducible from BENCHMARK.md, losses included — and the extension's official home is its DuckDB Community Extensions page. The full technical write-up — architecture, kernel design, and the complete benchmark tables with an August 2026 addendum — is in the gpudb paper on this site. Thirteen years of GPU-database history says the standalone play doesn't survive. The bet here is different: ride an engine people already trust, target the hardware nobody else does, and report the 14× losses next to the 25× wins. So far, that bet is one merged registry and one external contributor ahead.










