Skip to content

Add an ml extra for gradients in SQL, and three ML demos - #269

Draft
alxmrs wants to merge 8 commits into
mainfrom
ml-demos
Draft

alxmrs wants to merge 8 commits into
mainfrom
ml-demos

Conversation

@alxmrs

@alxmrs alxmrs commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Adds an ml extra that brings the gradients of ddx, which differentiates SQL queries, into xarray_sql.Context, and three ML demos in benchmarks/ml built on it. The demos are the three target demos of einfold, a query optimizer that rewrites SQL plans into array algorithms (docs/demos.md), written for legibility rather than speed, so they can serve as benchmarks for ddx, xarray-sql and einfold.

The extra

pip install "xarray-sql[ml]" installs ddxdb>=0.3.0,<0.4. xarray_sql/ad.py follows the pattern of the geo extension:

  • every Context registers ddx_stop_gradient;
  • Context.sql() computes grad, vjp and jvp calls in a select list. A query that doesn't call them never reaches ddx's parser;
  • xql.ad.sql_all(ctx, statements) runs several statements over one backward pass, which is how a training step updates each parameter table.
WITH pred AS (
  SELECT x.sample, SUM(x.value * w.val) AS y
  FROM x JOIN w ON x.feature = w.feature GROUP BY x.sample),
loss AS (
  SELECT AVG(power(pred.y - t.value, 2)) AS l
  FROM pred JOIN t ON pred.sample = t.sample)
SELECT feature, val - 0.1 * grad(loss, val) AS val FROM w

The demos

Script Model Checked against JAX
01_attention.py causal attention in einfold's window and GROUP BY spellings, each a view (CREATE VIEW attention AS …): the model. A separate loss reads it, as a Python loss calls its model. Plus a Nadaraya–Watson control forward pass; gradients with respect to Q, K and V, in one statement and one backward pass; masked pairs are absent rows
02_nanogpt.py nanoGPT's model at 1 layer and 4,416 parameters, as a view (logits) with a separate cross-entropy loss, trained on Tiny Shakespeare with SGD, or with nanoGPT's AdamW (--optimizer adamw, the learning-rate schedule as a table) the loss, and all 11 parameter tables (and Adam's moments) after every step
03_graphcast.py GraphCast-shaped encoder, processor and decoder on an icosahedral multi-mesh, over WeatherBench 2 ERA5 global and regional forecasts; reports the receptive field per stage

Each script runs the same SQL on every host in _hosts.py (DataFusion through xarray_sql.Context, DuckDB through xql.register). It reports time, error against JAX, and the number of operators in each host's plan. Adding a host, such as an einfold host, is one class.

Findings

The full write-up, with numbers, is in benchmarks/ml/README.md:

Testing

  • tests/test_ad.py: 5 tests over Datasets registered with from_dataset; the unit suite passes (553 passed).
  • All three demos pass end to end on DataFusion and DuckDB. GraphCast was run at 1 and 2 processor rounds, with real ERA5.

Not done yet

  • Peak memory per host.
  • Part B of the NanoGPT demo (the sparsity tricks).
  • The top-level README's section on gradients, held back until the feature is ready to announce.
  • GraphCast's default of 2 rounds takes about 6 minutes on DataFusion.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NyN7idACeDMh94qrnFMu6h

The `ml` extra installs ddxdb. With it, every Context registers
ddx_stop_gradient, and Context.sql computes grad(), vjp() and jvp() calls
in a select list. xarray_sql.ad.sql_all shares one backward pass across
statements that differentiate the same loss.

benchmarks/ml writes einfold's three target demos as SQL: causal
attention in both of einfold's spellings, a 1-layer GPT trained with SGD
through ddx, and a GraphCast-shaped GNN over WeatherBench 2 ERA5. Each
checks its results against the same model in JAX and times the same SQL
on DataFusion and DuckDB, reporting plan sizes alongside.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyN7idACeDMh94qrnFMu6h
@coderabbitai

coderabbitai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@alxmrs alxmrs left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good so far! :)

Comment thread README.md Outdated
* Dense tensor operations are slower in SQL than Xarray. It seems like the vanilla HashJoin implementation in standard DB engines are not as performant as array ops (they don't take advantage of contiguous array layouts), though this gives us some idea for how it could [one day be possible](https://github.com/xqlsystems/einfold).
* Sparse tensor operations are just as fast (or faster!) than Xarray today. Specifically, SQL is an excellent way to perform regridding and reprojecting between different spatial scales (see also: we include a `proj` udf via the `xarray-sql[geo]` extra).

## Gradients in SQL

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's omit this section for now. I want to publicize it when it is really ready.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌀 Claude: Removed in 21f99d6. The section's example still lives in benchmarks/ml/README.md and in xarray_sql.ad's docstring. I listed it under "Not done yet" in the PR description so it isn't forgotten.

Comment thread benchmarks/ml/01_attention.py Outdated
# [tool.uv.sources]
# xarray-sql = { path = "../../", editable = true }
# ///
"""Demo 1: attention in SQL, the query einfold should turn into FlashAttention.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general in all documentation, if you make a reference to something, please introduce it first.

Let's put this note of feedback in the Agents.md file so future agents don't make the same mistake.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌀 Claude: Done in 21f99d6. This docstring now says what einfold, FlashAttention (with the paper), JAX, ddx and a host are before using them. I went through every document in the PR for the same problem: the other two demos, benchmarks/ml/README.md, _hosts.py, _bench.py and xarray_sql/ad.py (Substrait, JAX), plus the GraphCast docs on #270 (GraphCast, ERA5, forcings, swish).

AGENTS.md now has the rule: introduce every project, paper, algorithm, dataset or term of art the first time a document mentions it, and link it where there is a link.

Comment thread benchmarks/ml/01_attention.py
Comment thread benchmarks/ml/01_attention.py Outdated
Comment on lines +170 to +175
# One statement per input, sharing one backward pass.
gradients = [
f"WITH {ctes}, {LOSS} "
f"SELECT t, d, grad(loss, x) AS x FROM {name} ORDER BY t, d"
for name in "qkv"
]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is compact code, but I'd rather make it legible / instructive SQL code, even if it is a bit more verbose. If it's all the same perf anyway, I'd rather demo what attention looks like as a long SQL statement.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌀 Claude: Done in 21f99d6. Each spelling is now two complete SQL statements, written out in full: the forward pass, and its gradient. The gradient statement is the forward query plus a loss CTE and a different final SELECT, so the two can be read side by side.

While doing this, I found that ddx can take the gradient with respect to all three inputs in one query, from a join of the three tables:

SELECT q.t, q.d,
       grad(loss, q.x) AS dq,
       grad(loss, k.x) AS dk,
       grad(loss, v.x) AS dv
FROM q
JOIN k ON q.t = k.t AND q.d = k.d
JOIN v ON q.t = v.t AND q.d = v.d
ORDER BY q.t, q.d

At 256 tokens it takes 0.59–0.67 s, against 0.61–0.62 s for three statements sharing one backward pass through sql_all, and it matches jax.grad to 3e-15. So the comprehension is gone, and so is sql_all in this demo. The cost is that the four CTEs appear twice per spelling.

Comment thread benchmarks/ml/02_nanogpt.py Outdated
Comment on lines +59 to +65
LAYERS = 1 # transformer blocks
EMBED = 16 # e: channels of the residual stream
HEADS = 2 # h: attention heads
HEAD = EMBED // HEADS # c: channels per head
HIDDEN = 4 * EMBED # f: channels of the MLP
CONTEXT = 16 # pos: characters per sequence
LEARNING_RATE = 0.5

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just want to double check: is this the same param count (and hparams) as NanoGPT? Can you add a link in a comment above this so we can double check?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, will you double check that our model matches the reference?

https://github.com/karpathy/nanoGPT/blob/master/model.py

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌀 Claude: Thanks for asking me to check. The hyperparameters are not nanoGPT's, on purpose. The model wasn't quite nanoGPT's either, which wasn't on purpose, and 21f99d6 fixes that.

Size. nanoGPT's Shakespeare model (config/train_shakespeare_char.py) has 6 layers, 6 heads, 384 channels and 256 characters of context: 10.65M parameters. This one has 1 layer, 2 heads, 16 channels and 16 characters: 4,416 parameters. Even at this size, the 1-layer loss is 12,794 operators and 9.4 s on DataFusion, so nanoGPT's size isn't reachable yet. Each constant now names its GPTConfig counterpart and that config's value, with links to model.py and the config, and the script prints its parameter count. The vocabulary matches: 65 characters, from sorted(set(text)), as in nanoGPT's prepare.py.

What I compared against model.py, and fixed:

  • Layer-norm gains: LayerNorm has a learned weight, and the SQL had none. There are now tables ln_1, ln_2 and ln_f, with gains initialized to 1. Biases stay off, matching train.py's default bias = False.
  • Activation: nanoGPT's MLP uses GELU, and mine used ReLU² (from modded-nanogpt). It's now GELU in its tanh approximation, as GPT-2 computed it. nanoGPT's nn.GELU() is the exact erf form, which DataFusion doesn't have. The two differ by less than 5e-4. ReLU² can come back as a one-line diff in Part B, where einfold's zero-skipping needs it.
  • Initialization: nanoGPT scales c_proj (wo and w_proj here) to 0.02/√(2·n_layer). Mine used 0.02 for every weight; it now uses nanoGPT's scaling.

What already matched: tied wte and lm_head, learned position embeddings, pre-norm residual blocks, eps 1e-5, the 1/√head_size scale, the causal mask, and mean cross-entropy. c_attn holds the same parameters as wq, wk and wv, in one matrix.

Still different, now listed in the docstring:

  • SGD at a fixed learning rate. nanoGPT uses AdamW with warmup, cosine decay, weight decay and gradient clipping.
  • No dropout, so that a run can be checked against JAX. nanoGPT's Shakespeare config trains with dropout 0.2.

AdamW in SQL means keeping the optimizer's state in versioned tables, which einfold's Demo 2 calls out as an open question. I'd do it with Part B unless you want it here.

After the changes, five SGD steps still match JAX on every step: the loss to 2e-16, and all 11 parameter tables to 1e-15.

Comment thread benchmarks/ml/02_nanogpt.py
- Attention: each spelling's forward pass and gradient is now one complete
  SQL statement. The gradient with respect to Q, K and V is a single query
  (grad of three columns of a join), as fast as three statements sharing a
  backward pass.
- nanoGPT: match model.py. Layer norms have learned gains (ln_1, ln_2,
  ln_f), the MLP uses GELU (its tanh approximation; DataFusion has no erf),
  and the residual projections use GPT-2's scaled initialization. The
  hyperparameters name their GPTConfig counterparts, and the docstring
  lists what still differs: size, optimizer, dropout.
- TODO(xqlsystems/ddx#142) on each window MAX wrapped in
  ddx_stop_gradient.
- Introduce every project, paper and term before using it, in the demos,
  their README and xarray_sql.ad; record the rule, and the TODO exception
  to the no-issue-numbers rule, in AGENTS.md.
- Drop the README's "Gradients in SQL" section until the feature is ready
  to announce, and the stale --lazy flag from benchmarks/ml/README.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyN7idACeDMh94qrnFMu6h

@alxmrs alxmrs left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More feedback

Comment thread benchmarks/ml/01_attention.py
Comment thread benchmarks/ml/02_nanogpt.py Outdated
* **GELU** is its tanh approximation, as GPT-2 computed it. nanoGPT's
``nn.GELU()`` uses the exact form, through the error function ``erf``,
which DataFusion doesn't have. The two differ by less than 5e-4.
* **The optimizer** is SGD at a fixed learning rate. nanoGPT trains with

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we devise a way to match NanoGPT in the optimizer? For example, could we use a table of learning rates to define a LR schedule?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should keep simple SDG and have the more complex thing behind a flag.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌀 Claude: Done in 5f92e69, along the lines you suggested in the follow-up: SGD stays the default, and --optimizer adamw trains as nanoGPT does. The learning rate is a table, schedule(step, lr), built from nanoGPT's get_lr (linear warmup, then cosine decay to min_lr). Each step's update joins it on s.step = {step}.

An AdamW step is two phases:

  1. The gradient of every parameter table, SELECT …, grad(loss, w) AS g FROM wte and so on, through sql_all, so all eleven share one backward pass. Each result is registered as the table {name}_grad.
  2. One plain-SQL statement per parameter table, with no ddx in it:
    • grad_norm and clip: the global norm of all eleven gradients, through a UNION ALL of the gradient tables, and the clip factor LEAST(1.0, 1.0 / (norm + 1e-6)), as clip_grad_norm_ computes it;
    • moments: Adam's two moments, which live as columns m and v beside w in each parameter table, so the optimizer's state is versioned with the weights;
    • the update: decoupled weight decay (0 for the layer-norm gains, as in nanoGPT's configure_optimizers), bias correction from s.step + 1, and eps.

Settings: nanoGPT's Shakespeare model's: lr 1e-3 to 1e-4, β = (0.9, 0.99), weight decay 0.1, clip 1.0. One difference: the schedule is stretched to --steps (warmup is 2% of the steps, as 100 of 5,000 is), because nanoGPT's spans 5,000 steps.

Checks:

  • The JAX reference step matches torch.optim.AdamW after clip_grad_norm_ exactly (difference 0.0 over four steps, with clipping active). A control that changes torch's weight decay shows a difference of 5e-4, so the comparison isn't vacuous.
  • The SQL matches that reference to 6e-15 after each of five steps: the weights and both moments of all eleven tables.

Because the update is plain SQL, it would also run on DuckDB once ddx can take gradients there.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌀 Claude: Agreed, and that's how 5f92e69 does it: --optimizer sgd (the default) or --optimizer adamw. The SGD step is still one statement per table, w - 0.5 * grad(loss, w). Details in the reply above.

alxmrs and others added 2 commits October 17, 2026 20:56
SGD at a fixed learning rate stays the default. With --optimizer adamw, a
step stores each parameter table's gradient as a table (one backward pass
for all of them), then updates each table with one SQL statement:
gradients clipped to a global norm of 1, Adam's moments as columns beside
the weights, weight decay on all but the layer norms, and the learning rate
read from a schedule(step, lr) table with nanoGPT's warmup and cosine
decay, stretched to --steps. The JAX reference step matches
torch.optim.AdamW after clip_grad_norm_ exactly, and the SQL matches it to
6e-15 after each of five steps.

Also say in the attention demo that its three grad() calls share one
backward pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyN7idACeDMh94qrnFMu6h
Each spelling of attention is now the model alone: CREATE OR REPLACE VIEW
attention AS .... The forward pass selects from the view, and the gradient
statement's loss CTE reads it, as a Python loss function calls the model.
The plans are the same as before (DataFusion and DuckDB inline the view;
operator counts are unchanged), and every check still matches JAX.

Hosts gain execute(sql), to run a statement such as CREATE VIEW for its
effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyN7idACeDMh94qrnFMu6h
alxmrs and others added 3 commits October 17, 2026 21:46
The model is the view logits; the cross-entropy loss is a LOSS of CTEs
that reads it, as nanoGPT's F.cross_entropy reads the model's logits. The
loss reads the view once, into a CTE, because DuckDB expands a view at
every read but computes a CTE read twice only once (232 operators and
0.097 s reading it twice, 126 and 0.06 s reading it once).

A DataFusion view keeps reading the tables that existed when it was
created (#271), so each step recreates the view after registering the
batch and the parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyN7idACeDMh94qrnFMu6h
ddxdb 0.3.1 differentiates window aggregates (xqlsystems/ddx#142), so
attention and the GPT's cross-entropy no longer wrap the row max in
ddx_stop_gradient. Gradients still match JAX (1e-15), and a GPT training
step takes the same time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyN7idACeDMh94qrnFMu6h
ddxdb 0.3.2 no longer plans a gradient statement's unused objective CTEs
(xqlsystems/ddx#160), and differentiates two-operand products without
rebuilding the forward join (xqlsystems/ddx#126). On DataFusion, a GPT
training step goes from 3.0 s to 2.7 s with SGD and from 3.4 s to 3.1 s
with AdamW (median of three). Attention's gradient is unchanged: its
products are under a causal mask, which the new rule doesn't cover yet
(xqlsystems/ddx#153). Every check still passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyN7idACeDMh94qrnFMu6h

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant