Skip to content

DuckDB backend: plan each step once, and skip catalog sync when nothing changed (0.87 s per GPT step, most of it repeated planning) #216

Closed

Description

@alxmrs

🌀 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:

  1. 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 … AS that reads an earlier step's table may be rebound each time. Writing into tables kept from the first call (DELETE, then INSERT 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.
  2. Skip _sync, plan_key and view_sql when 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.
  3. 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions