Repository navigation
Description
🌀 Claude: With ddxdb 0.4.3, xarray-sql's GPT demo trains on DuckDB, and its weights match JAX's after every step. But a training step costs 0.87 s on DuckDB, against 0.41 s on DataFusion and 0.37 ms in JAX (jax.jit, after compiling). That's for the demo's 1-layer, 4,416-parameter model at batch 4 (benchmarks/ml/02_nanogpt.py in xqlsystems/xarray-sql#269). At this size the time isn't arithmetic: a model 4× wider, or with 4× the context or batch, steps in 1.4–1.5 s. Six layers take 5.7 s, because each layer adds statements. Most of the step is planning that ddx's DuckDB backend repeats on every call.
Where 0.87 s goes (i7-8700, 6 cores and 12 threads, at load average 1; DuckDB 1.5.6, ddxdb 0.4.3 from PyPI, DataFusion 55.0.0):
| ms | |
|---|---|
materialize: 77 CREATE OR REPLACE TEMP TABLE … AS, about 2.5 KB of SQL each |
557 |
… of which DuckDB plans the SQL (an EXPLAIN of each takes 309 ms in all) |
~300 |
| … of which xarray-sql's adapter reads the parameter Datasets in Python (cProfile) | ~190 |
_sync, 19 calls (the planner's mirror of the catalog) |
85 |
plan_sql (9), plan_key (10), view_sql (13), select_all (56) |
46, 46, 38, 24 |
drop_table (77), DESCRIBE + the planner's copy of each step table (77) |
29, 49 |
returns_rows (11), to_sql (88) |
22, 8 |
Rows overlap: plan_sql, plan_key and select_all call _sync.
The program is built once and kept in _programs. But each call sends DuckDB new SQL text for every step, so DuckDB parses, binds and optimizes all 77 steps again. Each call also checks the catalog again and copies each step table's schema into the planner.
Repro, from an xarray-sql checkout of ml-demos, with ddxdb[duckdb]==0.4.3 installed. It times each backend method, and runs an EXPLAIN of each step's SQL to measure its planning, which adds that time to the step:
"""Where a DuckDB training step of xarray-sql's GPT demo spends its time.
Run from an xarray-sql checkout of the ml-demos branch, with ddxdb[duckdb]
0.4.3 installed."""
import importlib.util, sys, time
from collections import defaultdict
import numpy as np
import ddxdb.duckdb as dd
sys.path.insert(0, "benchmarks/ml")
spec = importlib.util.spec_from_file_location("gpt", "benchmarks/ml/02_nanogpt.py")
gpt = importlib.util.module_from_spec(spec); spec.loader.exec_module(gpt)
from _hosts import DuckDB
T = defaultdict(float)
def timed(name, f):
def g(*a, **k):
t = time.perf_counter()
try:
return f(*a, **k)
finally:
T[name] += time.perf_counter() - t
return g
def materialize(self, name, plan):
# DuckDBBackend.materialize, its statements timed one by one.
query = dd.to_sql(plan, "duckdb")
t = time.perf_counter(); self.con.execute(f"EXPLAIN {query}").fetchall()
T[" of which planning (EXPLAIN of the same SQL)"] += time.perf_counter() - t
t = time.perf_counter(); self.con.execute(f"CREATE OR REPLACE TEMP TABLE {dd._ident(name)} AS {query}")
T["materialize: CREATE TEMP TABLE AS"] += time.perf_counter() - t
t = time.perf_counter()
columns = self.con.execute(f"DESCRIBE temp.main.{dd._ident(name)}").fetchall()
cols = ", ".join(f"{dd._ident(c[0])} {c[1]}" for c in columns)
self._planner.execute(f"CREATE OR REPLACE TEMP TABLE {dd._ident(name)} ({cols})")
self._written.add(name)
T["materialize: DESCRIBE, and the planner's copy"] += time.perf_counter() - t
dd.DuckDBBackend.materialize = materialize
for m in ("_sync", "plan_sql", "plan_key", "view_sql", "select_all", "returns_rows", "drop_table"):
setattr(dd.DuckDBBackend, m, timed(m, getattr(dd.DuckDBBackend, m)))
text = gpt.shakespeare()
init = gpt.init_params(len(set(text)), np.random.default_rng(0))
h = DuckDB()
for n, v in init.items():
h.load(n, gpt.as_table(n, v.copy()))
h.load("batch", next(gpt.batches(text, 4, np.random.default_rng(1))))
h.execute(gpt.model())
statements = gpt.step_statements(gpt.LEARNING_RATE)
h.query_all(statements) # ddx builds its program
T.clear()
t = time.perf_counter(); h.query_all(statements); total = time.perf_counter() - t
print(f"step: {total * 1e3:.0f} ms, of which the EXPLAINs added {T[' of which planning (EXPLAIN of the same SQL)'] * 1e3:.0f} ms")
for k, v in sorted(T.items(), key=lambda kv: -kv[1]):
print(f" {k:<48} {v * 1e3:6.0f} ms")It printed (the step is 0.87 s plus the 0.31 s of EXPLAINs it adds):
step: 1172 ms, of which the EXPLAINs added 307 ms
materialize: CREATE TEMP TABLE AS 551 ms
of which planning (EXPLAIN of the same SQL) 307 ms
_sync 84 ms
materialize: DESCRIBE, and the planner's copy 50 ms
plan_sql 46 ms
plan_key 44 ms
view_sql 36 ms
drop_table 28 ms
returns_rows 24 ms
select_all 24 ms
Asks, in the order I'd expect them to pay:
- Plan each step once. Run each step's SQL as a DuckDB prepared statement, prepared on the first call, and executed on later ones. Replacing a temp table changes the catalog, so a prepared
CREATE TABLE … ASthat reads an earlier step's table may be rebound each time. Writing into tables kept from the first call (DELETE, thenINSERT INTO … SELECT) keeps the catalog unchanged. This is DuckDB's own plan reuse, the counterpart of prepare: plan each program step once, and run its physical plan on every training step #202 for DataFusion. - Skip
_sync,plan_keyandview_sqlwhen the catalog hasn't changed. For example, compare DuckDB's catalog version or a cheap fingerprint first, and keep the planner's copies of ddx's own step tables, whose schemas don't change between calls. - Keep the step tables between calls rather than dropping and creating 77 each call; that pays off with 1.
The adapter's 190 ms is xarray-sql's to fix, and I'll take that there. A step's floor on DuckDB is then roughly its execution, about 250 ms today, and less with the parameters read natively.
Related: #202 (the DataFusion counterpart), #151 (a DuckDB community extension for ddx, after performance), xqlsystems/xarray-sql#285 (tracking performance for the ML demos).
Activity