Repository navigation
Conversation
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
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
alxmrs
left a comment
There was a problem hiding this comment.
Looking good so far! :)
| * 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 |
There was a problem hiding this comment.
Let's omit this section for now. I want to publicize it when it is really ready.
There was a problem hiding this comment.
🌀 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.
| # [tool.uv.sources] | ||
| # xarray-sql = { path = "../../", editable = true } | ||
| # /// | ||
| """Demo 1: attention in SQL, the query einfold should turn into FlashAttention. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🌀 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.
| # 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" | ||
| ] |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🌀 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.dAt 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.
| 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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
In general, will you double check that our model matches the reference?
There was a problem hiding this comment.
🌀 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:
LayerNormhas a learnedweight, and the SQL had none. There are now tablesln_1,ln_2andln_f, with gains initialized to 1. Biases stay off, matchingtrain.py's defaultbias = 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 exacterfform, 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(woandw_projhere) 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.
- 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
| * **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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Maybe we should keep simple SDG and have the more complex thing behind a flag.
There was a problem hiding this comment.
🌀 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:
- The gradient of every parameter table,
SELECT …, grad(loss, w) AS g FROM wteand so on, throughsql_all, so all eleven share one backward pass. Each result is registered as the table{name}_grad. - One plain-SQL statement per parameter table, with no ddx in it:
grad_normandclip: the global norm of all eleven gradients, through aUNION ALLof the gradient tables, and the clip factorLEAST(1.0, 1.0 / (norm + 1e-6)), asclip_grad_norm_computes it;moments: Adam's two moments, which live as columnsmandvbesidewin 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 froms.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.AdamWafterclip_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.
There was a problem hiding this comment.
🌀 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.
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
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
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
Adds an
mlextra that brings the gradients of ddx, which differentiates SQL queries, intoxarray_sql.Context, and three ML demos inbenchmarks/mlbuilt 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]"installsddxdb>=0.3.0,<0.4.xarray_sql/ad.pyfollows the pattern of thegeoextension:Contextregistersddx_stop_gradient;Context.sql()computesgrad,vjpandjvpcalls 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.The demos
01_attention.pyGROUP BYspellings, 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 control02_nanogpt.pylogits) 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)03_graphcast.pyEach script runs the same SQL on every host in
_hosts.py(DataFusion throughxarray_sql.Context, DuckDB throughxql.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:MAX), Increase cargo version (minor). #143 (GROUP BYon a dependent key) and cftime support #144 (UNION ALL, which blocks GraphCast's gradient; that row records the error).Testing
tests/test_ad.py: 5 tests over Datasets registered withfrom_dataset; the unit suite passes (553 passed).Not done yet
🤖 Generated with Claude Code
https://claude.ai/code/session_01NyN7idACeDMh94qrnFMu6h