Back to Blog

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

Prateek SinghSeptember 22, 202615 min read10 views
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.

TPC-H SF10, M4 Max, plain SQL
19 of 22
queries answered on the GPU, 1.06x to 48.1x sooner than native DuckDB
Rows differing from DuckDB
0
across all 22 queries and 1,630 gate cells
Rows below parity
1
Q11 through the shell path, 0.92x. Printed, not dropped.
Lines of code to change
0
pip install duckdb-gpudb, then the SQL you already write

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:

  • sum over DOUBLE is 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.
  • 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 (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-Hmachineasked throughon the GPUrows differingspeed-up on those queries
SF1, 6M-row lineitemMacBook M4 Max, Metalexecute()17 of 2201.52x (Q15) to 15.32x (Q9)
SF1MacBook M4 Max, Metalsql(), the shell's path17 of 2201.37x (Q15) to 9.49x (Q13)
SF10, 60M-row lineitemMacBook M4 Max, Metalexecute()19 of 2201.06x (Q11) to 48.10x (Q5)
SF10MacBook M4 Max, Metalsql()19 of 2200.92x (Q11) to 26.39x (Q5)
SF1RTX 4090 Laptop, CUDAexecute()17 of 2201.21x (Q15) to 31.44x (Q9)
SF1RTX 4090 Laptop, CUDAsql()17 of 2201.66x (Q15) to 16.27x (Q9)
Plain SQL through the transparent path. 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; sql() is the lazy-relation path the shell takes and pays for its guards inside the call. From the README and BENCHMARK.md.

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

TPC-H SF10, plain SQL, M4 Max (Metal): native DuckDB ms ÷ gpudb ms, log scale execute() path · release build 20 Sep 2026 · every row identical to native · dashed = declined by rule, answered by DuckDB 0.5x1x2x5x10x20x50x100x Q17.63x109.5 → 14.3 ms Q2stays on DuckDB: (shape) · native 16.8 ms Q33.10x43 → 13.9 ms Q417.12x43.6 → 2.5 ms Q548.10x39.1 → 0.8 ms Q62.39x14 → 5.9 ms Q73.27x35.8 → 11 ms Q84.51x35.7 → 7.9 ms Q920.33x112.4 → 5.5 ms Q105.49x76.8 → 14 ms Q111.06x6.2 → 5.9 ms Q123.88x40.2 → 10.4 ms Q1310.84x147.8 → 13.6 ms Q146.74x29.1 → 4.3 ms Q151.34x20.5 → 15.3 ms Q16stays on DuckDB: (threshold) · native 34.8 ms Q1711.61x37.5 → 3.2 ms Q1812.33x111.7 → 9.1 ms Q1915.24x63.4 → 4.2 ms Q20stays on DuckDB: (shape) · native 32.6 ms Q216.40x138.9 → 21.7 ms Q2217.32x26.2 → 1.5 ms
All 22 TPC-H queries at SF10 on the M4 Max, execute() path, release build of 20 September 2026: native DuckDB milliseconds divided by gpudb milliseconds, log scale, with the native and gpudb times beside each bar. Q2 and Q20 read a correlated subquery from inside another subquery and do not bind on their own; Q16's inner GROUP BY declines on its own threshold, and forced past it measures 0.02x to 0.08x. From BENCHMARK.md.

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 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 (two VARCHAR keys, eight aggregates over expressions, 98% of rows kept) 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, 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.

SiriuscuDF / RAPIDSHeavyDBgpudb
Runs on an Apple silicon GPUno, NVIDIA compute capability 7.5+no, NVIDIA 7.0+no, NVIDIA; CPU-only elsewhereyes, Metal
Runs as a DuckDB extensionyes, via an optimizer hookno, a dataframe libraryno, a standalone engineyes; a client rewrites the statement before DuckDB plans it
CUDA backendyesyesyesyes, plain SQL on by default
What sends work back to the CPUoperators it does not supportan operation it does not implementoperations that cannot run on the GPU, or need more memory than it hasa per-statement speed measurement, re-taken on your machine
Window functions on the GPUnot in its supported-operator listnot applicabledocumented as computed in CPU modeno, they run on DuckDB
LicenceApache-2.0Apache-2.0Apache-2.0Apache-2.0
GPU query engines on axes checkable from their own documentation, as checked by the project on 20 September 2026; the sources are under the table in the README. The Apple silicon row is the project's own survey: no other published SQL engine it could find has an Apple silicon backend.

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.

References & Citations

Subscribe to new posts from theaivibe.org

No spam — just new posts. One-click unsubscribe.
Share this article

Related Posts

Apple's GPU Has No 64-bit Floats. I Made It Sort 50 Million Doubles Anyway
Data Engineering15 min read

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.

53 views
Read
Polars vs DuckDB vs ArrowMetal GPU on Apple Silicon: Sort and Group-By Benchmarks
Data Engineering12 min read

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.

113 views
Read
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
Data Engineering11 min read

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.

150 views
Read