DuckDB on the Apple Silicon GPU: Plain SQL, 19 of 22 TPC-H Queries on the Mac's Own GPU, and a Rule That Says Never Slower
DuckDB has no GPU backend of its own, and the GPU engines built for it need an NVIDIA card. gpudb 0.7 is my Apache-2.0 DuckDB extension for the GPU already inside your Mac, and for CUDA too. You write plain DuckDB SQL; the GPU takes a statement only where it has been measured faster than DuckDB on your own machine. On an Apple M4 Max, 19 of 22 TPC-H SF10 queries run on the Metal GPU at 1.06x to 48x with zero rows differing. The one row below parity is printed, not dropped.
DuckDB has no GPU backend of its own, and the GPU engines built for it need an NVIDIA card. Your Mac already has a GPU sitting on the same chip as the CPU, sharing the same memory, doing nothing while DuckDB works. gpudb 0.7, released on 20 September 2026, is my Apache-2.0 DuckDB extension for that GPU — Apple silicon through Metal, and NVIDIA through CUDA — and from this version you reach it by writing plain DuckDB SQL.
Every result in the new gpudb shell ends with one line that says where the statement ran and why. GPU (topk: the resident GROUP BY) · 38.8 ms. Or DuckDB (threshold: 6 groups < 1000) · 1.7 ms. That footer is the whole idea: the GPU answers only the statements it has been measured to answer faster than DuckDB on your own machine, and everything else DuckDB answers itself, untouched, at its usual speed.
Release build of 20 September 2026, TPC-H SF10 (60,000,000-row lineitem), MacBook M4 Max, warm, every table the query reads already resident, default memory budget, N=5, every row compared with native before any time was counted.
gpudb is my Apache-2.0 GPU extension for DuckDB, with a Metal backend for Apple silicon and a CUDA backend for NVIDIA, and it is listed in DuckDB's community extension registry. The last time I wrote about it here was version 0.3 in August, when it became a community extension. Since then it has shipped resident columns (0.4), fused GPU joins (0.5), resident GROUP BY and top-k from SQL (0.6), and now this. Until 0.6 you reached the GPU by calling gpu_* functions by name. 0.7 makes those calls unnecessary.
What changed: the SQL you already write
There are two pieces. The extension is the GPU code, and it lives inside DuckDB. The wrapper is the gpudb command and gpudb.connect() in Python, and it is the piece that puts plain SQL on the device. The wrapper exists for a reason I could not engineer around: DuckDB's stable C API has no hook that sees a statement before it is planned. So the wrapper reads the statement first, runs it through DuckDB's own parser, and asks a pure function in the extension whether there is a rewrite. Same rows, same column names, same column types as native, either way.
pip install duckdb-gpudb # the gpudb command, the gpudb module, and the extension binary
gpudb my.duckdb # a shell whose footer says where each statement ran
-- or, from ANY DuckDB >= 1.5.5 client, the explicit gpu_* functions only:
INSTALL gpudb FROM community;
LOAD gpudb;
On Apple silicon (macOS 15 or later) and x86-64 Linux the wheel carries the matching extension binary, so there is nothing to INSTALL and nothing to build. The registry install gives any DuckDB client, in any language, the explicit gpu_* functions, wrapper or no wrapper; 0.6 registered 38 of them, 0.7 registers 65, and nothing was removed or changed shape. Here is one session on an M4 Max over TPC-H SF1, from the release build:
gpudb> SELECT l_partkey, sum(l_quantity) AS qty FROM lineitem GROUP BY l_partkey ORDER BY qty DESC LIMIT 5;
┌───────────┬───────────────┐
│ l_partkey │ qty │
│ int64 │ decimal(38,2) │
├───────────┼───────────────┤
│ 125009 │ 1642.00 │
…
└───────────┴───────────────┘
DuckDB (not_resident: the resident set is not ready yet) · 25.7 ms -- first ask: DuckDB answers while columns go to the device
…
GPU (topk: the resident GROUP BY) · 38.8 ms -- next ask: on the GPU; the footer names the rule
The first ask lands on DuckDB because the columns are still going to the device in idle segments; the next one is on the GPU. Nine more runs each way in the same session: median 22.1 ms with the path off against 14.4 ms with it on, 1.53x, both series printed whole in the shell guide so the warm-ups and the wrapper's own measuring run stay visible. The same decision from Python:
import gpudb
con = gpudb.connect("data/tpch_sf1/tpch.duckdb", read_only=True) # same surface as duckdb.connect()
rows = con.execute(
"SELECT l_partkey, sum(l_quantity) AS qty FROM lineitem "
"GROUP BY l_partkey ORDER BY qty DESC LIMIT 5").fetchall()
r = con.last_rewrite() # the decision, in a sentence
print(r["rewritten"], r["reason"], r["detail"])
print(con.residents()) # which tables live on the device
print(con.memory()) # against the memory budget
The two rules, and how they are enforced
Never slower than DuckDB. Not on average. Per statement, per shape, per size. Which shapes may be rewritten at all comes from bounds a gate measures against native before a release; one row under 1.0x stops the release, and the losing measurements stay published. Then, because your machine is not the gate's machine, the decision is taken again where you are: after a statement template's first three rewritten runs, the wrapper times the native form once on a side cursor in your own process, hands the template back to DuckDB if the rewritten runs were not faster, and re-measures every 60 seconds. Your own statement is never the experiment.
Never a different answer. A rewritten statement returns what native returns: the same rows, the same order where native guarantees one, the same names, the same types. Integer and DECIMAL aggregates are bit-exact, with 128-bit sums on the device. Every scenario in the test suite runs three ways in one process, native, rewritten and explicit gpu_*, and all three must agree on ordered rows, names and typeof() of every column. Three consequences of taking that rule seriously:
sumoverDOUBLEis never rewritten. DuckDB itself computes it in an order-dependent way, so "the same as native" is not definable, and the shape stays on DuckDB.- A top-k with a tie inside the first k rows goes back to DuckDB. Plain DuckDB above one thread returned 3 different row sets, and up to 6 different orderings, over 20 runs of one such statement. Native has no tie order of its own there, so there is nothing for the device to reproduce. The device is asked for one row past the limit and stops itself when it sees the tie.
avgis finalised the way DuckDB finalises it. Native computes the quotient as along double, which overDECIMALis not expressible in SQL at all (80 bits on x86-64, and SQL has no 80-bit type), so the division moved into C++.
The numbers, both machines, losing row included
| TPC-H | machine | asked through | on the GPU | rows differing | speed-up on those queries |
|---|---|---|---|---|---|
| SF1, 6M-row lineitem | MacBook M4 Max, Metal | execute() | 17 of 22 | 0 | 1.52x (Q15) to 15.32x (Q9) |
| SF1 | MacBook M4 Max, Metal | sql(), the shell's path | 17 of 22 | 0 | 1.37x (Q15) to 9.49x (Q13) |
| SF10, 60M-row lineitem | MacBook M4 Max, Metal | execute() | 19 of 22 | 0 | 1.06x (Q11) to 48.10x (Q5) |
| SF10 | MacBook M4 Max, Metal | sql() | 19 of 22 | 0 | 0.92x (Q11) to 26.39x (Q5) |
| SF1 | RTX 4090 Laptop, CUDA | execute() | 17 of 22 | 0 | 1.21x (Q15) to 31.44x (Q9) |
| SF1 | RTX 4090 Laptop, CUDA | sql() | 17 of 22 | 0 | 1.66x (Q15) to 16.27x (Q9) |
One row in that table is below 1.0x and it is printed rather than dropped. On Metal, Q11 at SF10 straddles parity: a 6 to 7 ms statement, 1.06x through execute() and 0.92x through sql() in the timed run, 1.06x and 0.89x in two immediate re-runs of the same build. It is exactly the case the per-process rule exists to settle: it times the template against native where you are and hands it back where it loses. Nothing on the CUDA card is below 1.0x in these runs, but it was: TPC-H Q1 first measured 0.96x and 0.98x there on the release build, the cause was found (a few-group key was being answered by sorting the whole column), CUDA got a direct grouped reduce for such keys, and Q1 now measures 1.99x through execute() and 2.07x through sql().
Which queries stay on DuckDB depends on the scale factor. At SF10, three. At SF1, five: those three plus Q6 and Q11, which sit below the measured size floors at 6,000,000 rows. Each runs on DuckDB unchanged. The gate behind all of this, transparent_gate.py on the M4 Max, is 1,630 cells with 970 rewritten and passing, 0 slower than native, 0 differing, from 1.04x to 55.2x. The same gate on the RTX 4090 Laptop release build is 1,631 cells, 1,014 rewritten and passing, 616 declined on a threshold, 0 below 1.0x, 0 differing.
Stated plainly, as the repository states it: these are two machines in one state each, and yours will differ. That is why the wrapper re-measures each statement template in your own process rather than trusting a published ratio.
What stays on DuckDB, and why that is the design
Window functions, FULL joins, median, stddev and quantiles, sum and avg over DOUBLE or FLOAT, prepared-statement parameters, statements inside an explicit transaction, and anything the measured bounds decline: all of it stays on DuckDB, answered at DuckDB's speed. Two of the bounds explain most of what you will see:
- A key estimated at fewer than 1,000 distinct values does not rewrite on a single table. Native aggregates a tiny integer domain through a perfect hash in 1.5 to 5 ms per 6,000,000 rows, and the GPU cannot beat that. A
VARCHARkey is exempt when the statement carries at least two computed-expression payloads, because native then hashes the strings and evaluates the expressions on every row. That is why TPC-H Q1 (twoVARCHARkeys, eight aggregates over expressions, 98% of rows kept) is on the GPU at 7.63x at SF10, while a plainsumandcount(*)over the same two keys declines at6 groups < 1000. - Over a join there is no group floor at all. Native has to run the join whatever the group count, so a join returning one group is rewritten.
How the decision is made, in one paragraph. The statement is rewritten before DuckDB plans it, through DuckDB's own parser (json_serialize_sql) and a pure function in the extension (gpu_rewrite_ast): no plan surgery, no C++ API. The run-time measurement then overrides the bounds in either direction, and last_rewrite()["detail"] names the rule that decided. Any error on the rewritten path re-runs your original statement on DuckDB, so an error there can never reach you as a different or a missing answer. Every rewritten statement carries a staleness guard that re-counts the rows of each table it reads inside the same transaction, and on a file-backed database the file and its write-ahead log are stat'ed (2 to 3 µs) before every rewritten statement, so a committed write from any connection is noticed. Reason by reason, form by form: what runs where and KNOWN_ISSUES.md.
Where it sits: the other GPU engines need a different computer
GPU-accelerated DuckDB stopped being a thought experiment this year. Sirius, from the University of Washington and NVIDIA, is a GPU-native engine that loads into DuckDB through an optimizer hook and is built on NVIDIA's cuDF; NVIDIA has published ClickBench results for it. cuDF itself is a GPU dataframe library, and HeavyDB is a standalone GPU SQL engine. All three are good work, all three are Apache-2.0, and all three require an NVIDIA GPU. None of them runs on the machine most data people actually open their laptop to: a Mac.
| Sirius | cuDF / RAPIDS | HeavyDB | gpudb | |
|---|---|---|---|---|
| Runs on an Apple silicon GPU | no, NVIDIA compute capability 7.5+ | no, NVIDIA 7.0+ | no, NVIDIA; CPU-only elsewhere | yes, Metal |
| Runs as a DuckDB extension | yes, via an optimizer hook | no, a dataframe library | no, a standalone engine | yes; a client rewrites the statement before DuckDB plans it |
| CUDA backend | yes | yes | yes | yes, plain SQL on by default |
| What sends work back to the CPU | operators it does not support | an operation it does not implement | operations that cannot run on the GPU, or need more memory than it has | a per-statement speed measurement, re-taken on your machine |
| Window functions on the GPU | not in its supported-operator list | not applicable | documented as computed in CPU mode | no, they run on DuckDB |
| Licence | Apache-2.0 | Apache-2.0 | Apache-2.0 | Apache-2.0 |
Why an extension and not a new database: a parser, an optimizer, a storage format, a type system and a client ecosystem are most of the work of an engine, and DuckDB already has them. gpudb adds the GPU underneath them and nothing else. The extension touches DuckDB through its stable C API only: it links no libduckdb, includes no DuckDB C++ headers and does no plan surgery, which is why release binaries load in any DuckDB from 1.2 on. DuckDB 2.0 is in alpha with a release projected for the second half of October; the repository does not yet record a run against it, so I will say only what it does record: one binary has kept working across DuckDB versions because it depends on nothing that changes between them.
Try it
pip install duckdb-gpudb on an Apple silicon Mac running macOS 15 or later, or on x86-64 Linux with an NVIDIA driver R525 or newer, then gpudb your.duckdb and read the footer under your own statements. .gpu, .residents and .memory show what is on the device and why. The full install matrix, including what a registry install on Linux gives you and how to tell which piece is missing when one is, is in the install guide. Every number in this post, every losing row the sweeps found, and the commands that take the runs again are in BENCHMARK.md. If you run it on a different Apple silicon or NVIDIA machine, the gate's output on it is new information and I would like to see it as an issue on GitHub.
FAQ: running DuckDB on a GPU
Can DuckDB use the GPU?
Not by itself. gpudb is an Apache-2.0 DuckDB extension, listed in the DuckDB community extension registry, that adds GPU operators underneath DuckDB. From version 0.7, plain DuckDB SQL reaches the GPU through the gpudb shell or gpudb.connect() in Python; the explicit gpu_* functions work from any DuckDB client, including the stock CLI.
Does DuckDB run on the Apple silicon GPU?
With gpudb, yes: it has a Metal backend for Apple silicon and a CUDA backend for NVIDIA GPUs. On a MacBook M4 Max at TPC-H scale factor 10, 19 of the 22 queries run on the GPU through the plain-SQL path, 1.06x to 48.1x sooner than native DuckDB, with zero rows differing.
Is gpudb always faster than DuckDB?
No, and it does not try to be. It rewrites a statement only for shapes a gate has measured faster than native, then re-measures each statement template against DuckDB inside your own process and hands it back where it loses. Statements DuckDB wins, such as low-cardinality GROUP BY on a single table, small tables and selective filters, stay on DuckDB at DuckDB's speed. The losing measurements are published in the repository.
Can gpudb return a different answer from DuckDB?
The rule is never a different answer: same rows, same order where DuckDB guarantees one, same column names and types. Integer and DECIMAL aggregates are bit-exact with 128-bit sums on the device. Where sameness is not definable, such as sum over DOUBLE, which DuckDB itself computes in an order-dependent way, the shape is never rewritten. Any error on the rewritten path re-runs your original statement on DuckDB.
Is there a GPU version of DuckDB for Mac?
gpudb is the one I know of. It is an Apache-2.0 DuckDB extension with a Metal backend, listed in the DuckDB community extension registry, and it runs on Apple silicon Macs on macOS 15 or later. The other GPU engines for DuckDB — Sirius, cuDF and HeavyDB — all require an NVIDIA GPU, so none of them runs on a Mac at all.
gpudb vs Sirius: what is the difference?
Sirius is a GPU-native engine that loads into DuckDB through an optimizer hook and is built on NVIDIA's cuDF, so it needs an NVIDIA GPU. gpudb runs on Apple silicon through Metal as well as on NVIDIA through CUDA, reaches DuckDB through its stable C API rather than plan surgery, and decides per statement by measuring against native DuckDB in your own process instead of by a supported-operator list. Both are Apache-2.0. The comparison table in this post is built from each project's own published documentation.
Does gpudb work with NVIDIA CUDA GPUs?
Yes. The Linux x86-64 wheel carries a CUDA-enabled build; on an RTX 4090 Laptop at TPC-H scale factor 1, 17 of 22 queries run on the GPU at 1.21x to 31.44x. Without an NVIDIA driver it loads and falls back to the CPU backend.
Read next
References & Citations
- gpudb (2026). Release v0.7.0, “Plain DuckDB SQL on the GPU”, 20 September 2026, and docs/RELEASE_NOTES_v0.7.md. Source of the two rules, the correctness work and the install matrix.
- gpudb (2026). README.md: “Plain DuckDB SQL on the GPU”, “Numbers — measured, not promised”, “The two rules”, “How it decides”, “What runs on the GPU, and what stays on DuckDB”, “Why DuckDB? Why not a new database?” (landscape table, checked 20 September 2026 against each project's own documentation).
- gpudb (2026). BENCHMARK.md, “2026-09-20 — the release build”: the per-query TPC-H tables (SF1 and SF10, execute() and sql(), MacBook M4 Max Metal) and the RTX 4090 Laptop CUDA runs. Source of every number in the chart and the tables.
- gpudb (2026). docs/USING_THE_SHELL.md (the M4 Max session quoted above, all eighteen runs), docs/USING_PYTHON.md, docs/INSTALL.md, KNOWN_ISSUES.md.
- gpudb in the DuckDB community extension registry; duckdb-gpudb on PyPI.
- DuckDB (2026). “Try DuckDB v2.0-alpha”, 2 September 2026.
- Sirius; cuDF / RAPIDS; HeavyDB documentation, as cited under the landscape table in the gpudb README.
Subscribe to new posts from theaivibe.org
Related Posts
Apple's GPU Has No 64-bit Floats. I Made It Sort 50 Million Doubles Anyway
The Metal Shading Language has no double type, and float64 is the default number in Python, pandas and Apache Arrow. Building ArrowMetal meant getting past three walls: a GPU with no 64-bit floats, a missing 64-bit atomic add, and a Swift compiler bug that reports errors nobody threw. Here is how each one was solved, what it cost, and why a GPU that cannot add two doubles sorts 50,000,000 of them in 32 ms.

Polars vs DuckDB vs ArrowMetal GPU on Apple Silicon: Sort and Group-By Benchmarks
Polars, DuckDB and ArrowMetal on an Apple M4 Max: sort and group-by benchmarks at 10M and 50M rows, wall time next to CPU time, and the rows where the CPU is still ahead.
Apache Arrow Compute on the Apple Silicon GPU: The First Arrow Project I Created That Does It, With 173 Operations Measured Against Polars, pyarrow and pandas
I built ArrowMetal, the first Apache Arrow project I could find that runs compute on the Apple silicon GPU. Apple silicon has one memory for CPU and GPU, and an Arrow buffer in shared Metal memory is already a GPU buffer; no Arrow project used that. ArrowMetal does: 307 of Arrow's 307 compute functions, seven languages, take at 24.2x pyarrow on an M4 Max, and 339 benchmark rows against the fastest CPU idiom of Polars, pyarrow, pandas and numpy, including the 77 where the CPU is still ahead.