DEV Community

Cover image for 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
AI Explore
AI Explore

Posted on

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. 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 an 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 saying where the statement ran and why:

GPU (topk: the resident GROUP BY) · 38.8 ms
DuckDB (threshold: 6 groups < 1000) · 1.7 ms
Enter fullscreen mode Exit fullscreen mode

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. Everything else DuckDB answers itself, untouched, at its usual speed.

Try it in a minute

pip install duckdb-gpudb # the gpudb command, the module, and the extension binary
gpudb my.duckdb # a shell whose footer says where each statement ran
Enter fullscreen mode Exit fullscreen mode

On Apple silicon (macOS 15+) and x86-64 Linux the wheel carries the matching extension binary — nothing to INSTALL, nothing to build. From any DuckDB ≥ 1.5.5 client you can also get the explicit gpu_* functions alone:

INSTALL gpudb FROM community;
LOAD gpudb;
Enter fullscreen mode Exit fullscreen mode

There are two pieces. The extension is the GPU code and lives inside DuckDB. The wrapper is the gpudb command and gpudb.connect(), and it is the piece that puts plain SQL on the device — it exists because 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.

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"])
Enter fullscreen mode Exit fullscreen mode

The two rules

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. Same rows, same order where native guarantees one, same column names, same column types. Integer and DECIMAL aggregates are bit-exact, with 128-bit sums on the device. Three consequences of taking that seriously:

  • sum over DOUBLE is never rewritten. DuckDB itself computes it order-dependently, so "the same as native" is not definable.
  • 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.
  • avg is finalised the way DuckDB finalises it. Native computes the quotient as a long double, which over DECIMAL is not expressible in SQL at all, 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) – 15.32x (Q9)
SF1 MacBook M4 Max · Metal sql() 17 of 22 0 1.37x (Q15) – 9.49x (Q13)
SF10 (60M-row lineitem) MacBook M4 Max · Metal execute() 19 of 22 0 1.06x (Q11) – 48.10x (Q5)
SF10 MacBook M4 Max · Metal sql() 19 of 22 0 0.92x (Q11) – 26.39x (Q5)
SF1 RTX 4090 Laptop · CUDA execute() 17 of 22 0 1.21x (Q15) – 31.44x (Q9)
SF1 RTX 4090 Laptop · CUDA sql() 17 of 22 0 1.66x (Q15) – 16.27x (Q9)

Release build of 20 September 2026, TPC-H, warm, every table the query reads already resident, default memory budget, N=5, every row compared with native before any time was counted. execute() and sql() are different code paths and are timed separately.

TPC-H SF10 on an M4 Max: all 22 queries, native DuckDB ms divided by gpudb ms

Query Path native ms gpudb ms ratio
Q5 GPU (plain) 39.1 0.8 48.10x
Q9 GPU (plain) 112.4 5.5 20.33x
Q22 GPU (plain) 26.2 1.5 17.32x
Q4 GPU (plain) 43.6 2.5 17.12x
Q19 GPU (projected) 63.4 4.2 15.24x
Q18 GPU (plain) 111.7 9.1 12.33x
Q1 GPU (plain) 109.5 14.3 7.63x
Q11 GPU (nested) 6.2 5.9 1.06x
Q2 DuckDB (shape) 16.8 — correlated subquery
Q16 DuckDB (threshold) 34.8 — inner GROUP BY declined
Q20 DuckDB (shape) 32.6 — correlated subquery

One row is below 1.0x and it is printed rather than dropped. On Metal, Q11 at SF10 straddles parity: a 6–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. It is exactly the case the per-process rule exists to settle. Nothing on the CUDA card is below 1.0x in these runs — but Q1 first measured 0.96x and 0.98x there, the cause was found (a few-group key answered by sorting the whole column), CUDA got a direct grouped reduce, and Q1 now measures 1.99x.

The gate behind all of this on the M4 Max: 1,630 cells, 970 rewritten and passing, 0 slower than native, 0 differing, 1.04x to 55.2x. On the RTX 4090 Laptop release build: 1,631 cells, 1,014 rewritten and passing, 616 declined on a threshold, 0 below 1.0x, 0 differing.

These are two machines in one state each, and yours will differ. That is why the wrapper re-measures in your own process rather than trusting a published ratio.

What stays on DuckDB

Window functions, FULL joins, median/stddev/quantiles, sum/avg over DOUBLE or FLOAT, prepared-statement parameters, statements inside an explicit transaction, and anything the measured bounds decline. Two 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–5 ms per 6M rows. A VARCHAR key 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 is on the GPU at 7.63x at SF10 while a plain sum and count(*) over the same two keys declines at 6 groups < 1000.
  • Over a join there is no group floor at all. Native has to run the join whatever the group count.

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 missing answer. Every rewritten statement carries a staleness guard that re-counts the rows of each table it reads inside the same transaction.

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. 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.

Sirius cuDF / RAPIDS HeavyDB gpudb
Runs on an Apple silicon GPU no — NVIDIA cc 7.5+ no — NVIDIA cc 7.0+ no — NVIDIA; CPU-only elsewhere yes — Metal
Runs as a DuckDB extension yes, optimizer hook no, a dataframe library no, standalone engine yes, client rewrites before planning
CUDA backend yes yes yes yes, plain SQL on by default
What sends work back to the CPU unsupported operators an op it does not implement ops that cannot run on GPU a per-statement speed measurement
Window functions on the GPU not in its list n/a documented as CPU mode no — they run on DuckDB
Licence Apache-2.0 Apache-2.0 Apache-2.0 Apache-2.0

Axes checkable from each project's own documentation, checked 20 September 2026; sources are under the table in the README.

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 — no libduckdb linkage, no DuckDB C++ headers, no plan surgery — which is why release binaries load in any DuckDB from 1.2 on.

Try it

pip install duckdb-gpudb on an Apple silicon Mac (macOS 15+) or x86-64 Linux with an NVIDIA driver R525+, then gpudb your.duckdb and read the footer under your own statements. .gpu, .residents and .memory show what is on the device and why.

Every number here, every losing row the sweeps found, and the commands that take the runs again: BENCHMARK.md. Every trade-off, reason by reason: KNOWN_ISSUES.md. If you run it on a different Apple silicon or NVIDIA machine, the gate's output on it is new information — an issue on GitHub would be welcome.

Top comments (0)