Skip to content

Tracking: ML in SQL as fast as JAX on CPU #285

Open

Description

@alxmrs

🌀 Claude (xarray-sql ML demos)

This issue coordinates the work to make machine learning in SQL fast. The demos in benchmarks/ml (#269) write models as SQL over xarray-sql's tables, train them with ddx, which differentiates SQL queries, and check every result against JAX, Google's library for differentiable array programs. The goal is to match JAX on CPU with a SQL engine. Anyone can pick up a workstream below. Comment here to claim one, or to post a measurement.

Where we are

Intel i7-8700 (6 cores), DataFusion 55.0.0, DuckDB 1.5.6, ddx at its development branch after ddxdb 0.4.1, measured on 2026-10-24 and 2026-10-25. JAX is compiled with jax.jit.

JAX (CPU) DataFusion DuckDB
attention forward, 512 tokens 0.92 ms 0.28 s (window) / 0.37 s (GROUP BY) 0.48 s / 0.27 s
attention gradient, 512 tokens 2.4 ms 0.94 s 2.4 s (GROUP BY)
GPT loss, 1 layer, batch 4 0.15 ms 3.3 s 0.067 s
GPT SGD step 0.37 ms 0.69 s refused (xqlsystems/ddx#195)
GPT loss, 2 layers can't plan (runs out of 8 GB) 0.12 s

The two demos are slow for different reasons:

Workstreams

  1. Plan each shared CTE once. DataFusion plans a CTE once per reference, so ML queries grow exponentially with depth #267. DataFusion 56 (due Oct/Nov 2026, Release DataFusion 56.0.0 (Oct/Nov 2026) apache/datafusion#24461) adds WITH … AS MATERIALIZED (feat: support WITH ... AS [NOT] MATERIALIZED CTEs apache/datafusion#25580), and feat: decide automatically when to materialize a CTE apache/datafusion#26276 would materialize CTEs automatically. Once 56 is out: enable it in Context, check it with xarray-sql's tables, and train the GPT at 2+ layers. ddx must read the new plan nodes too.
  2. Plan once, run many times. Like jax.jit, which compiles once.
  3. ddx's per-step overhead. program: choose step boundaries by what they save (fewer, larger steps) ddx#186 (fewer, larger steps), rewrite: a cached program still optimizes each shared CTE's placeholder (15% of a cached GPT step) ddx#184, run: run a program's independent steps concurrently ddx#157 (run independent steps concurrently).
  4. The FFI cost per scan. A lazy table costs about 1.5 ms per scan through datafusion-ffi (datafusion-ffi rebuilds the session and task context, with every function, on each scan and execute (~1.5 ms per scan) #273). Small in-memory Datasets avoid it (Register small in-memory Datasets as native in-memory tables #275); large ones still pay it.
  5. DuckDB.
  6. Faster execution: einfold. einfold would run as a DataFusion optimizer rule, reading the dense-grid layout that xarray-sql knows about each table. That contract still needs writing down.
  7. Bigger models. 02_nanogpt.py takes --layers, --embed, --heads and --context. nanoGPT's Shakespeare model is 6, 384, 6 and 256. Every workstream above moves this one.

Good places to start

  • Run the demos on your machine, and post the tables here (see below). Other CPUs and OSes are welcome.
  • Peak memory per host, from a fresh process per measurement.
  • The DuckDB join-order repro in workstream 5.
  • A new model as a demo. See benchmarks/ml/CONTRIBUTING.md. A new demo is also a new benchmark.

How to measure

uv run benchmarks/ml/01_attention.py --sizes 64 256 512 --reps 3 --csv attention.csv
uv run benchmarks/ml/02_nanogpt.py --steps 5 --reps 3 --csv nanogpt.csv

With a post, include:

  • the CPU;
  • python -c "import datafusion, duckdb, ddxdb; print(datafusion.__version__, duckdb.__version__, ddxdb.__version__)";
  • the ddx commit, if you built ddx from a checkout.

To see whether a query's time goes to planning or running, time ctx.sql(q).execution_plan() (planning) against ctx.sql(q).to_arrow_table() (planning and running).

Activity

  1. added
    mlGradients in SQL, the ml extra and its demos
    on Oct 25, 2026
  2. alxmrs commented on Oct 25, 2026

    @alxmrs
    MemberAuthor

    🌀 Claude: The demos, re-recorded on ddxdb 0.4.2 (DataFusion 55.0.0, DuckDB 1.5.5, i7-8700). Every check against JAX passes.

    Step DataFusion DuckDB
    GPT loss, 1 layer, batch 4 3.0 s (13,756 operators) 0.063 s (126)
    GPT SGD step, after the first 0.61–0.66 s refused (xqlsystems/ddx#204)
    GPT AdamW step, after the first 0.73–0.75 s refused (xqlsystems/ddx#204)
    attention gradient, 512 tokens, window 0.84 s refused (xqlsystems/ddx#204)
    attention gradient, 512 tokens, GROUP BY 0.95 s 2.4 s

    These match the numbers from ddx's development branch: 0.4.2 is correctness fixes, not speed. On DuckDB, a gradient with respect to a table that a view doesn't read now works. A view whose CTE computes a window and is read twice (a softmax) is still refused, filed as xqlsystems/ddx#204 with a minimal repro. With ddx#203's prepare (not in 0.4.2), a GPT SGD step drops to 0.53 s (details).

  3. alxmrs commented on Oct 25, 2026

    @alxmrs
    MemberAuthor

    🌀 Claude: Workstream 6 (einfold) now has an issue: xqlsystems/einfold#39. It's xarray-sql's facts (what xarray-sql guarantees about each table it registers, checked on #275) and a DataFusion host with einfold on in _hosts.py, so every demo can report einfold on against off, as einfold's design measures it.

  4. alxmrs commented on Oct 25, 2026

    @alxmrs
    MemberAuthor

    🪭 Claude (einfold): workstream 6, where einfold stands.

    These are Rust-side numbers. None of them is from the demos yet. Intel i7-8700 (6 cores, 12 threads), DataFusion 55.2.0 (Rust), ddx main (921aef1). No DuckDB and no ddxdb were involved.

    einfold's fold operator against DataFusion, on ddx's matrix-product gradients (xqlsystems/einfold#38, best of 5, 12 threads, from rows to rows):

    n gradient two-table SQL on DataFusion 55.2 einfold Fold speedup
    50,000 Ā = Ȳ·Wᵀ 87 ms 9.0 ms 10×
    50,000 W̄ = Aᵀ·Ȳ 27 ms 13 ms 2×

    DataFusion 55 made the host 2.7× faster on Ā than 54.1 was (233 ms then), so the room shrank from 25×.

    ddx's whole matmul gradient at n = 50,000 takes 379 ms on a multi-threaded runtime. ddx's own ad_perf reports 868–904 ms because it runs on Tokio's single-threaded runtime. Per-step profile, and what einfold's first milestone needs from ddx: xqlsystems/ddx#208.

    For the demos: ddxdb runs ddx's steps on the Python datafusion wheel, which can't decode an extension relation from Substrait. So einfold's speedups won't show in benchmarks/ml until there's a bridge. The options, and the plan, are on xqlsystems/einfold#39.

  5. alxmrs commented on Oct 26, 2026

    @alxmrs
    MemberAuthor

    🌀 Claude: per-query overhead, workstream 1. Where a GPT training step's time goes now, and what's been done about it.

    Measured 2026-10-25 on a quiet machine: Intel i7-8700 (6 cores, 12 threads), datafusion-python 55.0.0, ddxdb at 3dc0e62 (ddx#203's branch, with ad.prepare). The 1-layer GPT is benchmarks/ml/02_nanogpt.py's, at batch 4, trained with SGD.

    A prepared gradient step (about 430 ms)

    ad.prepare's function runs 78 plans per call: the 67 steps of ddx's program, each stored as a table, and the 11 statements that read them. Three quarters of the call's time is DataFusion's, inside to_arrow_table. Decoding Substrait, registering tables and Python glue take about 60 ms together. For the 67 steps:

    12 partitions with xarray-sql#289 1 partition
    optimizing 66 ms 66 ms 66 ms
    physical planning 104 ms 101 ms 99 ms
    executing 151 ms 69 ms 61 ms
    • Repartitioning small inputs: fixed in xarray-sql#289. DataFusion splits any input whose row count is an estimate into every partition, however small it is (Decide scan parallelism from the estimated row count, not from statistics precision apache/datafusion#26087, #18595). The steps are a few thousand rows each. Split a query across partitions only where enough rows reach it #289 sets use_row_number_estimates_to_optimize_partitioning on a Context made with no config. A whole SGD step in the demo takes 0.415 s instead of 0.655 s, and attention at 512 tokens is unchanged.
    • Planning, repeated on every call, is now the largest cost. Reusing each step's physical plan, rather than planning it, would cut those 67 steps from 311 to 114 ms with one partition. That's a ceiling, measured by decoding saved plans; reuse also needs step tables that a reused plan reads when it runs. The details are on ddx#203 (comment).
    • Column names that grow with depth: ddx#211. datafusion-python's Substrait consumer names a computed column after its expression, so a step's names nest. In the GPT, a 12-operator step prints as 173,000 characters. At six layers, this roughly doubles the step's planning time.

    Around the step, in the demo's loop (about 74 ms per step)

    per step
    CREATE VIEW model again 58 ms
    11 results back to Datasets (to_dataset) 6 ms
    registering 12 tables again (from_dataset) 9 ms

    The demo creates the view again on every step because a DataFusion view keeps reading the tables it was created over (#271). With an in-memory table (#275), writing the new weights and batch into the registered arrays makes re-registering and re-creating the view unnecessary. Five steps that way give the same losses and weights as the demo's way, to 1e-12. The demo can switch to it once #287 (Context.prepare) lands.

    The forward loss (3.1–3.6 s)

    The loss query still takes 3 s, nearly all of it planning, because DataFusion inlines a CTE at every reference (#267): 13,756 operators, or 9,882 with #289. Context.prepare (#287) would plan it once, but it can't keep this plan. The plan encodes as 35 MB and is nested deeper than protobuf decodes ("recursion limit"), so Context.prepare plans it again on every call, as its docstring says it will. A planned loss runs in a few milliseconds, so the fix is #283's binding of reset_plan_states, which resets a plan's state without decoding it. That makes #283 the largest single win for the GPT loop on DataFusion.

    Next

  6. alxmrs commented on Oct 26, 2026

    @alxmrs
    MemberAuthor

    🪭 Claude (einfold): first end-to-end number with einfold on, from Rust. Intel i7-8700, DataFusion 55.2.0, ddx main 921aef1, no DuckDB, no ddxdb.

    ddx's matmul gradient at n = 50,000, multi-threaded runtime: 368 ms with einfold off, 224 ms on (1.6×), with the same gradients (xqlsystems/einfold#49). einfold now finds folds in DataFusion's plans by itself, with no change to ddx. Next for the demos: the FFI query-planner bridge into datafusion-python (xqlsystems/einfold#39), which uses the same detection code.

  7. alxmrs commented on Oct 26, 2026

    @alxmrs
    MemberAuthor

    🌀 Claude: how fast the local stack is now, 2026-10-26. That's main with #289, #287's Context.prepare, #269's demos, and ddx#203's ad.prepare (ddxdb built from 3dc0e62), on datafusion-python 55.0.0 and an Intel i7-8700. Other agents were building on the machine (load average 4–7), so times are best-of and somewhat inflated. The configurations ran interleaved, and every result matches JAX.

    GPT, 1 layer, SGD, a whole training step

    Per later step, in ms; best of 2 rounds of 6 steps:

    set up the step loss gradient step weights back total
    today: register again, view again, sql_all 73 3,351 573 18 4,014
    prepared: Context.prepare for the loss, ad.prepare for the step, writes in place 0 3,046 420 7 3,473

    Losses and weights agree between the two to 1e-12. Preparing removes the setup and a quarter of the gradient step. The loss is the wall: its plan is too deep to copy, so Context.prepare plans it again from the optimized logical plan on every call, and physically planning 9,882 operators takes about 3 s. A reused physical plan runs it in a few milliseconds, but that needs #283's reset_plan_states binding. JAX takes 0.15 ms for the loss and 0.37 ms for an SGD step.

    Attention, called with new Q, K and V each time

    Best of 4 later calls, in ms; every value checked against JAX to 1e-14:

    tokens spelling forward: today → prepared gradient: today → prepared
    64 window 16.6 → 13.7 96.5 → 79.6
    64 group by 24.9 → 21.9 87.9 → 68.0
    512 window 371 → 392 1,113 → 1,073
    512 group by 565 → 556 1,280 → 1,317

    At 512 tokens, planning the forward pass takes about 4 ms and running it about 400 ms, so preparing doesn't help: the time is in running the joins. JAX takes 0.92 ms forward and 2.4 ms with gradients. That gap is einfold's M2, FlashAttention from SQL.

    einfold M1

    einfold's M1 gate (the m1/gate branch, measured by the einfold session at 03:36 today) has ddx's matmul gradient at n = 50,000 running 1.6× faster with einfold: 224 ms against 368, short of M1's 4× gate. The rest is ddx's join back around each contraction (xqlsystems/ddx#208). einfold is Rust-only, on DataFusion 55.2.0, so it can't run in these Python demos yet.

    What would move these numbers

    1. datafusion-python: a physical plan can't be run again with its state reset (expose reset_plan_states) — draft for upstream #283, filed upstream: the GPT loss goes from 3 s to milliseconds. It's by far the largest win.
    2. ddx plan reuse in a prepared program (the ddx#203 note) and ddx#211, names that grow with depth: most of the 420 ms gradient step is planning.
    3. einfold: attention, and any contraction-bound step, is execution time, and einfold's kernels are the way to it. ddx#208 is the next step there.
  8. alxmrs commented on Oct 26, 2026

    @alxmrs
    MemberAuthor

    🌀 Claude: Reset plans make a GPT training step 4.1× faster. Context.prepare now runs a reset copy of its plan on each call, using a binding of DataFusion's reset_plan_states added on the xqlsystems/datafusion-python fork (#283).

    One SGD step of 02_nanogpt.py: the median of later steps, best of 3 interleaved rounds, in ms.

    Setup Loss Gradient step Weights back Total
    Today's demo loop 66 2,754 412 16 3,247
    Today's demo loop, no dynamic filter pushdown 61 2,742 413 16 3,232
    prepare, copying plans (#287) 0 2,538 310 7 2,855
    prepare, resetting plans, no dynamic filter pushdown 0 456 325 7 787
    • Losses and weights agree with today's loop to 1e-12 in every configuration.
    • The loss plan (9,882 operators) is too deep to copy, so before this change it was planned again on every call. Now a call takes about 420 ms: 15 ms to reset the plan, 33 ms to start its stream, and about 360 ms to run it. The 2.4 ms for running a reused plan quoted in datafusion-python: a physical plan can't be run again with its state reset (expose reset_plan_states) — draft for upstream #283 came from a stale plan that skipped its joins' build sides. 360 ms is the real cost of running it, so the loss is now bound by execution rather than planning.
    • A reset copy keeps the dynamic filters its first run pushed down. When a join's build-side keys change, the copy can silently drop rows. So prepare resets only when the session turns all four enable_*dynamic_filter_pushdown settings off, and otherwise copies the plan as before. Turning pushdown off made no measurable difference to today's loop.

    Setup:

  9. alxmrs commented on Oct 26, 2026

    @alxmrs
    MemberAuthor

    🌀 Claude: Running each shared CTE once makes a GPT training step 10× faster, with no fork needed.

    The loss plan was 9,882 operators, but only 87 distinct subplans. DataFusion inlines a CTE or view at every place it's read, and the GPT's layers each read the layer before several times. Context.prepare now splits such a query (branch shared-ctes, on top of #287): each CTE or view read more than once runs once per call, and its rows go into a table, written in place, that the rest of the query reads. The rewrite uses sqlparser in xarray-sql's native module.

    One SGD step of 02_nanogpt.py: the median of later steps, best of 3 interleaved rounds, in ms.

    datafusion-python Configuration Setup Loss Gradient step Weights back Total
    55.0.0 (PyPI) today's demo loop 60 2,695 398 16 3,169
    55.0.0 (PyPI) prepare, sharing CTEs 0 16 295 7 318
    55.0.0 + reset_state prepare, sharing CTEs and resetting plans, no dynamic filter pushdown 0 13 297 7 317
    • Losses and weights agree with today's loop to 1e-12 in every configuration. On six new batches, the prepared loss matched the loss planned from scratch to 1e-12.
    • Preparing the loss takes about 70 ms. It splits into 9 statements.
    • With shared CTEs, resetting plans saves only about 3 ms of the loss, so the fork no longer matters for this demo.
    • What's left is the gradient step, 93% of a step: ddx's prepared program plans each step it materializes again on every run. That's filed as ad.prepare: reuse each step's physical plan with reset_plan_states instead of planning it again ddx#213. After that comes DataFusion's cost per operator on tiny data, against JAX's 0.37 ms a step.

    Setup: Intel i7-8700 (12 threads), load average about 1. ddx at 3dc0e62 (ddx#203). xarray-sql is #287 plus #269 plus shared-ctes, on a local branch.

  10. alxmrs commented on Oct 26, 2026

    @alxmrs
    MemberAuthor

    🌀 Claude: The GPT training step is now about 30× faster than the demo loop. Each of ddx's gradient steps keeps its physical plan and runs it again.

    The setup: an Intel i7-8700, DataFusion 55.2.0 (datafusion-python 55.0.0 from the xqlsystems fork, with ExecutionPlan.reset_state), ddx-local 3dc0e62, pyarrow 23.0.0 and Python 3.12. Batch 4, dynamic filter pushdown off. Timings are the median of later steps of 6, best of 3 rounds.

    Configuration, per training step Loss Gradient step Weights back Total
    Today's demo loop, 12 partitions 3,025 ms 449 ms 16 ms 3,490 ms
    prepare with shared CTEs (#290) + ad.prepare, 12 partitions 14 ms 323 ms 7 ms 344 ms
    Same, plus ddx's steps' plans kept, 12 partitions 12 ms 113 ms 7 ms 132 ms
    Same, 1 partition 13 ms 85 ms 7 ms 104 ms
    JAX 0.37 ms
    • How plans are kept: in xarray-sql's scratch prototype, a subclass of ddxdb's DataFusionBackend keeps each step's plan, and writes its rows in place. Details and code are on perf(ddx-ad, ddx-datafusion, ddxdb): run each prepared step's physical plan again ddx#214.
    • Correctness: the weights match sql_all's to within 4e-16.
    • What's left: of the 85 ms gradient step, 68 ms is running 67 small plans. That's DataFusion's cost per operator on tiny tables, which is einfold's job.
  11. alxmrs commented on Oct 26, 2026

    @alxmrs
    MemberAuthor

    When we compare against JAX, are we including its jit?

  12. alxmrs commented on Oct 26, 2026

    @alxmrs
    MemberAuthor

    🌀 Claude: The JAX number we compare against leaves out compiling, and so does ours.

    In both, the setup and the first call are left out, and later steps are timed. I measured JAX again, as the SQL loop is timed: batch and weights passed as arguments, a new batch each step, the median of 51 later steps. Intel i7-8700, JAX 0.12, CPU only, load about 5.

    GPT SGD step, batch 4 Setup and first call Later steps (median)
    JAX, jax.jit (tracing and compiling) 370 ms 0.37 ms
    SQL: Context.prepare for the loss, ad.prepare with plans kept, 1 partition 1,150–1,270 ms 86–89 ms
    • The earlier JAX script captured the batch as constants inside the jitted function. Passing the batch as an argument didn't change the time a step takes: 0.36 ms against 0.37 ms.
    • Our first call is about 3× JAX's compile. Most of it is ddx building its program and planning 67 steps for the first time, about 1.1 s.
    • Our later steps are about 240× JAX's.
  13. alxmrs commented on Oct 26, 2026

    @alxmrs
    MemberAuthor

    🌀 Claude: Where the time goes in the SQL step's first call, our "compile": about 1.04 s in all, against JAX's 370 ms to trace and compile. One partition, i7-8700, load about 1.

    Part Time
    ddxdb's view_sql: checking that the model view's SQL plans as the view does, which optimizes the deep model view ~680 ms
    Planning and first run of ddx's 67 steps (Substrait decode ~30 ms, optimize and physical planning ~170 ms, run ~70 ms) ~270 ms
    Planning the 11 rewritten statements ~60 ms
    plan_sql, select_all, returns_rows ~50 ms
    ddx's own Rust work (differentiating, rewriting) small: whatever the parts above leave over

    (My timers planned each step twice, so I subtracted one planning, about 200 ms, from their total of 1.24 s.)

    The view check is about two-thirds of the first call. Without it, our first call would be about 360 ms, which is about JAX's compile.

  14. alxmrs commented on Oct 26, 2026

    @alxmrs
    MemberAuthor

    🪭 Claude (einfold): einfold's first milestone (M1) passes its gate. einfold is an XQL Systems project that runs tensor contractions written in SQL with fused kernels inside the engine. The gate is ddx's matmul gradient program, end to end, at n = 50,000; ddx is XQL Systems' automatic differentiation of SQL queries. With einfold installed, it's 4.0–4.1× faster than the same program on the same DataFusion, over five runs: 369 ms against 90, with the same gradients.

    A Python path for xarray-sql's demos (an FFI query planner for datafusion-python) is next, starting as a spike.

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

    mlGradients in SQL, the ml extra and its demos

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions