gpudb — plain DuckDB SQL on the GPU
New in v0.7 — plain DuckDB SQL runs on the GPU. No gpu_* calls, no query
changes: write the SQL you already write, and the GPU answers it when that is
measured faster — DuckDB answers everything else, with the same rows either
way. Apple Silicon Metal and NVIDIA CUDA, from one extension.
Ordinary DuckDB SQL, unchanged: statements the GPU answers faster are answered on the device, everything else runs on DuckDB exactly as before, and the rows, names and types are identical either way.
Install
pip install duckdb-gpudb # the distribution is duckdb-gpudb; the import is gpudb
There are two pieces: this package is the wrapper — the gpudb command and
gpudb.connect() — and the GPU code itself is a DuckDB extension. On Apple
Silicon (macOS 15 or later) and x86-64 Linux (glibc 2.34 or newer, e.g.
Ubuntu 22.04 and later) the wheel carries both: the v0.7.0 extension binary
travels inside the package, so that one line is the whole install. No INSTALL,
no build, no environment variable. One binary, and it has been run under both
DuckDB 1.4.5 and 1.5.5. The Linux wheel carries the CUDA-enabled build and its
own libgomp.so.1, so it needs nothing installed and reaches an NVIDIA GPU with
a driver R525 or newer, falling back to the CPU backend where there is none.
Anywhere else pip installs the pure-Python wheel and the extension comes from
DuckDB's own install:
INSTALL gpudb FROM community; -- in any DuckDB >= 1.5.5 client
LOAD gpudb;
The wrapper looks for the extension in this order: an explicit extension=
path, the GPUDB_EXTENSION_PATH environment variable, a build-macos/ or
build-linux/ directory next to a source checkout, the copy bundled in this
package, and finally the extension DuckDB itself has installed. A checkout's
own build comes before the bundled copy deliberately — someone who has just
built the extension is testing that binary. If it finds none — or finds one
older than this client — con.extension_note says so in one sentence and every
statement runs on DuckDB.
Requires Python >= 3.9 and the duckdb module >= 1.4. A bundled or
downloaded binary needs only DuckDB >= 1.2, because the loadable extension is
built against the stable C API v1.2.0; the registry builds gpudb separately for
each DuckDB version from 1.5.5 on. Apple silicon for the Metal backend, an
NVIDIA GPU for the CUDA one; plain SQL runs on the GPU by default on both, and
GPUDB_CUDA_EXACT=0 turns the CUDA path off without a rebuild. On Linux the
extension needs libgomp.so.1 at load time (apt install libgomp1), which the
wheel bundles and a registry install does not; a binary installed from the
community registry there reports compiled=cpu and carries no CUDA at all —
SELECT gpu_build_info(); says which one you have.
The gpudb shell
The first way in, and the one that shows its work. This is one session on an
M4 Max over TPC-H SF1, opened read-only, on the v0.7.0 release build of
2026-09-20 (the three [gpudb] registered … lines
are the extension announcing itself on stderr as DuckDB loads it):
$ gpudb data/tpch_sf1/tpch.duckdb --readonly
[gpudb] registered gpu_inner_join + gpu_join_rows_resident
[gpudb] registered gpu_groupby_{sum,sum_f64,count,exact}_resident[_having|_topk] + gpu_topk_resident[_f64]
[gpudb] registered gpu_sum / gpu_min / gpu_max streaming aggregates + resident-column functions (gpu_upload, gpu_*_resident) (backend=Metal)
gpudb 0.7.0
backend: Metal · Apple M4 Max · 51.8 GiB device memory
transparent: available — every statement goes through the wrapper
database: data/tpch_sf1/tpch.duckdb
Enter .help for usage.
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 │
│ 140633 │ 1562.00 │
│ 49981 │ 1553.00 │
│ 149443 │ 1537.00 │
│ 10426 │ 1513.00 │
└───────────┴───────────────┘
DuckDB (not_resident: the resident set is not ready yet) · 25.7 ms
The first ask is on DuckDB on purpose: the columns are uploaded in short row-id segments taken only while the connection is idle, so an upload never runs a long scan beside a query you are waiting for. Ten seconds later, the same statement, nine runs each way in the same session:
gpudb> .gpu off
GPU path off — statements go straight to DuckDB.
… DuckDB · 30.4, 24.5, 16.5, 19.5, 20.9, 22.3, 21.6, 22.1, 23.0 ms
gpudb> .gpu on
GPU path on — residency: background.
… GPU (topk: the resident GROUP BY) · 12.9, 47.3, 10.0, 11.4, 14.1, 14.4, 14.4, 14.5, 14.8 ms
Median 22.1 ms against 14.4 ms — 1.53×, with both series printed whole so the warm-up runs and the wrapper's own measuring run stay visible. The nine GPU runs spread from 10.0 to 14.8 ms with nothing changed between them: a statement this short has more than one speed on Apple silicon depending on what else is waking, which is why the wrapper measures in your process instead of trusting a published ratio. Expect your own numbers rather than these.
The banner's backend: line names the runtime, the device as the driver
reports it, and the device memory the budget plans against; a build without a
GPU backend names no device, and transparent: says what is missing instead.
Options and dot-commands
gpudb my.duckdb -c "SELECT …" # one statement
gpudb -f script.sql # a file (or: gpudb < script.sql)
python -m gpudb # the same entry point
| Option | |
|---|---|
-c SQL |
run a statement and exit, repeatable |
-f FILE, --file FILE |
run a file and exit, repeatable; -c and -f run in the order given |
--readonly, --read-only |
open read-only |
--no-gpu |
every statement on DuckDB |
--residency background|eager|manual |
when tables become resident (default background) |
--memory-budget SIZE |
e.g. 16GB, or unlimited |
--timer / --no-timer |
force the footer line on or off |
--debug |
show tracebacks instead of one-line errors |
--version |
the gpudb and duckdb versions |
-h, --help |
the same list, from the program |
A statement that fails ends a -c / -f / piped run with a non-zero exit
code; at the terminal the session keeps going. The footer is on at a terminal
and off in scripted output unless --timer says otherwise; colour follows
NO_COLOR and whether the output is a terminal.
.help |
the list |
.quit, .exit |
leave (Ctrl-D does too) |
.timer on|off |
the footer line |
.gpu |
the last statement's whole last_rewrite(), detail included |
.gpu on|off |
the transparent path, live |
.residents |
the resident sets, then the columns behind them with rows and width |
.memory |
the device-memory budget and what holds it |
.read FILE, .open [DATABASE] |
run a file, open another database |
.tables, .schema [TABLE] |
plain SQL underneath (SHOW TABLES, DESCRIBE) |
.version |
gpudb and duckdb versions |
Statements may span lines and end at ;; Ctrl-C stops the running statement
(or clears what you were typing) and Ctrl-D leaves. History is kept in
~/.gpudb_history when the Python build has readline.
.residents prints two tables: the resident SETS — what a statement is waiting
on — and, under them, the COLUMNS those sets are views over. A lane is kept at
the narrowest signed width its values fit, so a column of small integers costs
one or two bytes a row rather than eight. Same session as above:
gpudb> .residents
table columns state bytes estimated worth
main.lineitem l_partkey,l_quantity ready 80.1 MiB 161.7 MiB 3.99
1 set · 80.1 MiB held · worth is ms saved per second per GiB · `.memory` for the budget
table column dtype rows width bytes state
lineitem l_partkey I64 6,001,215 4 B 68.7 MiB ready
lineitem l_quantity I64 6,001,215 2 B 11.4 MiB preparing
2 resident columns · width is the bytes a row of the lane is stored at · `-` where the backend does not say
worth is the DuckDB time a set saves per second of wall time, per GiB it
holds — what the memory budget compares when it has to choose. On a column
line, state means something narrower: whether that lane has a sort cache.
Only a key lane ever needs one, so a payload lane reads preparing and stays
there; the set's own state is what a statement waits on. Every dot-command and
option, and the session these came from, are in
the shell guide.
There is a shell because the transparent path cannot live in the extension.
DuckDB's stable C extension API — the one the loadable extension uses on
purpose, so that one binary keeps working across DuckDB versions — has no hook
that sees a statement before it is planned. LOAD gpudb in the stock duckdb
CLI therefore gives the explicit gpu_* functions and nothing more. This shell
is a client that can look first: it splits statements with DuckDB's own
tokenizer, hands each one to gpudb.connect(), and prints the result with
DuckDB's own box renderer.
Python
import gpudb
con = gpudb.connect("my.duckdb") # same surface as duckdb.connect
con.execute("SELECT k, sum(v) FROM t GROUP BY k").fetchall()
con.last_rewrite() # what happened to that statement
connect() takes database, read_only and config as duckdb.connect
does, plus extension= (an explicit path to the .duckdb_extension),
transparent= (default True), residency= (background | eager |
manual), floor_rows= (default 1,000,000), idle_ms= (default 20.0),
thresholds= (default True; False rewrites every exact shape regardless of
the predicted win — for parity testing only), memory_budget= (bytes or
"16GB"; 0 / "unlimited" removes the cap) and log= (a callable that
receives the wrapper's decisions as text).
Anything the wrapper does not define itself is delegated to the underlying
duckdb.DuckDBPyConnection. What it does define:
con.last_rewrite() |
rewritten, reason, detail, form, tag, sql, statement, engine, round_trip_ms, fallback, error |
con.memory() |
budget, evictions, evictions_wasted, sets |
con.residents() |
{identity tag: state} — missing, pending, uploading, ready, stale, failed |
con.store_columns() |
one dict per resident column: table, column, dtype, rows, width, bytes, prepared |
con.transparent |
readable and settable; False leaves every statement on DuckDB without dropping anything |
con.residency |
read-only: the mode passed to connect() |
con.cursor() / con.duplicate() |
another connection sharing the resident sets, with its own last_rewrite() |
con.interrupt() |
DuckDB's interrupt, on this connection's handle only |
con.extension_note |
empty while the loaded extension can serve the client; one sentence saying why not otherwise |
Give each thread its own con.cursor(): last_rewrite() is one record per
connection object, so two threads sharing one connection overwrite each other's.
Why a statement did not go to the GPU
The footer and last_rewrite()["reason"] carry a short code; detail is the
sentence behind it.
| Reason | What it means |
|---|---|
not_resident |
the columns are not on the device yet — the background uploader is working on it |
threshold |
a measured bound says DuckDB is faster for this shape and size, or this machine measured it slower and the template went back to DuckDB |
shape |
not a shape the rewrite expresses: a window function, median, ROLLUP, a set operation, a subquery in the select list |
double |
a sum / avg over DOUBLE or FLOAT — never rewritten, because native's own answer depends on the order the values are added |
ties |
a pushed ORDER BY … LIMIT k found two of the first k rows equal on the ordering value — which rows come back, and in what order, is DuckDB's to choose, and it answered the original. Decided against the data on every execution; a tie that keeps happening shows as threshold with the tie named in detail, until the 60-second re-measure. Through sql() — and so through the shell — the check runs on a side cursor inside the call, so the first statement of a data version asks the device once more and the verdict is remembered until the data changes |
backend |
this build has no GPU backend to rewrite for, or the installed extension is older than this client |
memory |
the set does not fit the device-memory budget; it is refused before the upload |
transaction |
a BEGIN is open, so the resident sets cannot be trusted |
params |
the statement takes prepared-statement parameters |
multi |
more than one statement in the call |
too_long |
the statement is longer than the 16 KB the wrapper parses |
error |
the rewritten statement raised and DuckDB answered the original — the text is in error |
off / manual |
the path is off (.gpu off, --no-gpu, transparent=False), or residency is manual and this set was not uploaded by hand |
nulls overflow decimal collation view temp ambiguous not_found |
a narrower refusal, each with its own sentence in detail |
What those look like in the shell's footer — these are the shapes, collected from different statements and different sessions, not one run:
GPU (plain: the resident GROUP BY) · 3.3 ms
GPU (topk: a key join materialised on the device) · 8.1 ms
DuckDB (threshold: 7 groups < 1000) · 5.0 ms
DuckDB (threshold: measured 4.20 ms rewritten vs 3.10 ms native (re-measured in 60 s)) · 3.2 ms
DuckDB (not_resident: the resident set is not ready yet) · 12.0 ms
DuckDB (ties: two of the first 5 rows tie on qty, so which rows come back — and in what order — is DuckDB's to choose, and DuckDB answered the original) · 49.9 ms
DuckDB (off: the transparent path is off on this connection) · 12.0 ms
The third way in: explicit gpu_* functions
The shell and gpudb.connect() are two of three routes. The third needs no
wrapper at all: INSTALL gpudb FROM community; LOAD gpudb; in any DuckDB
client, in any language, and then call the functions by name —
gpu_upload / gpu_upload_pair to make a column resident once, then
gpu_sum_resident, gpu_groupby_sum_resident (with _having and _topk
forms that filter on the device), gpu_topk_resident, gpu_inner_join and the
exact gpu_groupby_exact_* family to read it back. gpu_last_stats() says
which processor ran and for how long. v0.7 registers 65 of these; v0.6.0
registered 38, and every one of those is still there unchanged.
It is full manual control, and it is the only route from the stock duckdb
CLI. The repository's README documents the whole surface.
Going deeper
Release files for duckdb-gpudb 0.7.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| duckdb_gpudb-0.7.0.tar.gz | 236.8 kB | Details |
Built distributions (wheels)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| duckdb_gpudb-0.7.0-py3-none-manylinux_2_34_x86_64.whl | Python 3 | none | Linux glibc 2.34+ x86-64 | Details |
| duckdb_gpudb-0.7.0-py3-none-macosx_15_0_arm64.whl | Python 3 | none | macOS 15.0+ ARM64 | Details |
| duckdb_gpudb-0.7.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 9.2 MB
Release files / duckdb_gpudb-0.7.0.tar.gz
| Download URL | duckdb_gpudb-0.7.0.tar.gz |
|---|---|
| Size | 236.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2b42624951cbbce421e3abb17a4eb8443e779840bb00788b33ee0389f6f47e70
|
|
BLAKE2b-256 checksum How to use checksums |
78c5098c02148e364bb9e75d4de954577b948213865017b73e31de7c2361344a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.9
|
Release files / duckdb_gpudb-0.7.0-py3-none-manylinux_2_34_x86_64.whl
| Download URL | duckdb_gpudb-0.7.0-py3-none-manylinux_2_34_x86_64.whl |
|---|---|
| Size | 8.0 MB |
| Tags | Linux glibc 2.34+ x86-64 Python 3 |
|
SHA-256 checksum How to use checksums |
f1e88c3b44e05d81f83d26c4f37f2616c0cbe8c8b1b3608a5d0672763552e81a
|
|
BLAKE2b-256 checksum How to use checksums |
d28479a8aa83b0c92ce5aa039bc1338377887df6530d2a625067f7f82c65fc68
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.9
|
Release files / duckdb_gpudb-0.7.0-py3-none-macosx_15_0_arm64.whl
| Download URL | duckdb_gpudb-0.7.0-py3-none-macosx_15_0_arm64.whl |
|---|---|
| Size | 774.9 kB |
| Tags | Python 3 macOS 15.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
8d22155836ba54e1c7faeac12f4b00641724788b077bb65441791e90d396fb29
|
|
BLAKE2b-256 checksum How to use checksums |
9939010b66fc9adefbefb666fc397b49a4eced43657bebb69791e480049a1dd9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.9
|
Release files / duckdb_gpudb-0.7.0-py3-none-any.whl
| Download URL | duckdb_gpudb-0.7.0-py3-none-any.whl |
|---|---|
| Size | 171.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
781703c71b8541f6007606c058ec0424d4c120982c85a9ef055fa70a7f84049d
|
|
BLAKE2b-256 checksum How to use checksums |
2bb68dc0f989359b1a2b55285792477fee8c402f783b884ac0a963e836e2ffec
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.9
|