Repository navigation
Description
🌀 Claude (GraphCast demo, xarray-sql)
I've been building demo 3 (demos.md): GraphCast_small, with DeepMind's published weights, as a program of SQL statements over tables xarray-sql registers (xqlsystems/xarray-sql#270, work in progress). This issue records what that work suggests for einfold, measured against design v16. Section numbers are the design's.
The workload
GraphCast_small runs on a 1° grid (65,160 cells), a mesh refined 5 times (10,242 nodes), 16 processor rounds, 512 latent channels, and 36M weights. The three graphs have 101,892 grid→mesh edges, 81,900 mesh edges and 195,480 mesh→grid edges. In SQL, every network is the same MLP over a table of (id, f, val):
product_0 AS (SELECT x.id, w.g AS f, SUM(x.val * w.w) AS val
FROM x JOIN layer_0_weights w ON x.f = w.f GROUP BY x.id, w.g),
hidden AS (SELECT id, f, (val + b) / (1 + exp(-(val + b))) AS val ...), -- bias, swish
product_1 AS (... the same, with layer_1's weights ...),
normed AS (SELECT id, f, (val - AVG(val) OVER (PARTITION BY id))
/ sqrt(VAR_POP(val) OVER (PARTITION BY id) + 1e-5) * scale + offset ...)A message-passing round builds each edge's input by concatenation, as UNION ALL with offset feature indices (s.f + 512, r.f + 1024). It then gathers the sender's and the receiver's states through the edge table, and sums the messages with GROUP BY receiver.
Counted as joined pairs (rows each JOIN … GROUP BY forms before summing), one forecast is 2.05 trillion:
| Stage | Joined pairs | Share |
|---|---|---|
| Processor messages, 16 rounds (81,900 edges × [1536 → 512 → 512]) | 1,374 G | 67% |
| Processor node updates, 16 rounds | 129 G | 6% |
| Decoder messages (195,480 edges) | 205 G | 10% |
| Encoder messages (101,892 edges) | 107 G | 5% |
| Edge embeddings (static: they depend only on the graph and the weights) | 100 G | 5% |
| Everything else (grid and mesh embeddings and updates, output) | 139 G | 7% |
Every one of those pairs is in a two-operand SUM of products, x(id, f) ⋈ W(f, g), with W a dense matrix of at most 1536 × 512. No contraction has three operands, nothing is sparse inside a weight matrix, and nothing needs a semiring other than sum-product.
Measurements
Measured on an Intel i7-8700 with DataFusion 54.0.0 and DuckDB 1.5.5:
- Speed. On the grid embedding (2,048 cells × [186 → 512 → 512]), DataFusion forms 0.09 G pairs/s, about half of S21's 0.19 G/s for small contractions. At that rate a forecast takes about 6.3 hours. DeepMind's JAX code runs the same forecast in 15 s on the same CPU, in float32: about 1,500× faster.
- DuckDB took 78 s on the same slice, against DataFusion's 8 s.
EXPLAIN ANALYZEputs the time inHASH_GROUP_BY: 195M joined rows summed into 1M groups. - Memory. The first full run of the encoder was OOM-killed at 13.9 GB, with DataFusion's default unbounded memory pool. Each message stage's hash aggregate holds
edges × 512groups: 52M for the encoder, 42M per processor round, 100M for the decoder. With a 0.5 GB fair spill pool, DataFusion wrote over 11 GB of spill files in 20 minutes for 8,192 cells, a slice that takes about 30 s with no limit. That limit was deliberately extreme, so this isn't characterized yet. - Per-pair expressions. Swish written inside the second layer's
SUM,SUM(h / (1 + exp(-h)) * w), computesexponce per joined pair (537M times on the slice) instead of once per hidden value. With the bias in theGROUP BYas well, the slice took 35 s. Computing the bias and swish once per value beforehand brought it to 8 s, 4.4× faster. - CTEs. DataFusion recomputes a CTE at every reference, and an MLP's layer norm or a residual reads its input more than once. In the 1-layer GPT (demo 2's shape), the loss is 12,166 operators on DataFusion and 120 on DuckDB, and takes 8.4 s against 0.055 s (DataFusion plans a CTE once per reference, so ML queries grow exponentially with depth xarray-sql#267). The GraphCast demo runs one stored table per stage to avoid this.
- Regional forecasts. For the contiguous United States (1,586 cells), walking the graph back from the region gives the mesh nodes each processor round needs, starting from the last round: 291, 408, 839, 2,317, 5,190, 8,430, 10,050, 10,239, then all 10,242 for the remaining 8 rounds. The coarse levels' long edges reach the whole globe within 8 hops. Exact semi-join reduction therefore saves about 27% of the processor and 97% of the decoder, but nothing in the encoder, which needs every grid cell. That's about 35% of the forecast. These numbers come from the receptive-field computation; the regional SQL program has not run yet.
What to prioritize first
- M1's dense kernels, reachable from plain SQL.
EinFoldExec's fold and GEMM paths are exactly GraphCast's shape, so S21's 4–14× would apply to almost all of its 2 T pairs. The output is also dense (ids × 512floats), which fixes the memory problem: 100M hash groups become one array of 400–800 MB. M1 takes folds only from a producer (ddx), though, and GraphCast's forward pass is plain SQL. A narrow detector, like M2's single attention pattern, would make GraphCast tractable without M3's general detection. It would matchSUM(x.val * w.w) … GROUP BY x.id, w.gover two operands wherewis small. Two notes on it:- Density as a run-time check, not a storage fact. GraphCast's weights live in one long-form table (
weights(net, f, g, w)), filtered by network.fandgare data columns of a 1-D Dataset, not Xarray dimensions, because 88 matrices of different shapes can't share one grid. No reader can state that a filtered slice is a densef × ggrid. But the weights are always the build side, at most 786K rows, so checking density and existence there (S25's "check") costs almost nothing. - A gate for it. GraphCast's grid embedding (65,160 × [186 → 512 → 512]) is a clean benchmark for M1's kernels on a forward pass rather than a gradient.
- Density as a run-time check, not a storage fact. GraphCast's weights live in one long-form table (
- Correct the target profile for DataFusion's CTEs. Section 9.3 records "plain CTEs on DataFusion", from S10's planning times. But DataFusion runs a plain CTE once per reference, so a plan that reads a CTE twice at each of
dlevels runs its base 2^d times (#267's reproduction). Any plan einfold writes for DataFusion with shared subexpressions hits this. That includes the e-graph's "free" common subexpressions (section 7.6), which are free only if the host computes them once. Until DataFusion decides materialization itself (Decide automatically when to materialize a CTE apache/datafusion#26237), the profile needsAS MATERIALIZED(feat: supportWITH ... AS [NOT] MATERIALIZEDCTEs apache/datafusion#25580, not in DataFusion 54, PostgreSQL dialect only) or program mode's stored intermediates. - Two rewrites in the relational form, which help every host today:
- Factor hoisting: compute each single-operand expression once per operand row, before the join, not once per joined pair. Section 9.1 already treats such an expression as one factor; realization should materialize it below the join. That was 4.4× on the measured slice.
- Distributing a layer over the gather:
W · [e; s[sender]; r[receiver]] = W_e·e + (W_s·s)[sender] + (W_r·r)[receiver]. As written, the SQL contracts every edge's 1,536 features. Contracting each node's 512 features once, then gathering the result through the edge table, saves 717 G pairs, 35% of the forecast. The mesh has 8× as many edges as nodes, and the decoder 19× as many edges as senders. This is eager aggregation and contraction order (9.3, 10.1) through a many-to-one join, combined with B2: detect sums of einsums over UNION ALL #17's sums overUNION ALL. It also needs one more step: seeingf + 512as an affine reindexing of the contracted dimension, which splitsWinto blocks.
What the design is missing for GraphCast
- Graph aggregation as a fold pipeline. The decoder computes 100M per-edge message rows only to sum them by receiver, and the encoder does the same at 52M. Section 9.6's condition is "the producer's groups are the consumer's groups paired with a dimension the consumer sums away". Here the consumer's group,
receiver, is a function of the summed dimension,edge, through a join with the edge table, not a column of the producer. Fusing message → scatter-sum needs that condition generalized to functional dependencies through a mapping relation. It is GNN message passing, and the gather/scatter half of sparse attention. In the processor, messages also feed the edge residual, so they must exist there. In the encoder and decoder they needn't. - Fold epilogues. Bias, activation, and a per-row normalization over the whole output row (layer norm, as
AVG/VAR_POPwindows, an algebraic fold of a sum, a sum of squares and a count) all run on a fold's output. When a tile spans the whole output row (512 channels), they can run as the kernel writes it, and the pre-norm rows never exist. S21 found that writing output rows bounds the win, and every GraphCast stage writes tens of millions of them. Section 9.6 covers fold → fold. It doesn't cover fold → elementwise and per-group epilogue, which is what an MLP layer is. - The MLP as a test case for 9.6.
linear_0 → swish → linear_1meets section 9.6's condition as written: the first fold's groups(id, g)are the second's groupsidpaired with the dimensiongit sums away. Fusing them keeps the 512-wide hidden layer in a tile, as FlashAttention keeps the scores. It's a second query that isn't attention, for the generality test, and the step that carries GraphCast from 4–14× toward JAX's speed. - Memory for relational-form plans, not only einfold's own. Section 9.4's budget governs plans einfold executes. The relational form hands hosts hash aggregates with 50–100M groups, which either exhaust memory or spill badly (above). The target profile could record a host's aggregate memory behavior, and the relational form could slice a fold by output tiles (
idranges) to stay within a budget. M7 has slicing; GraphCast needs it in the relational form, earlier. - Caching subexpressions that are constant across runs. Section 8.5 caches structure when only values change. GraphCast's edge embeddings depend only on the graph and the weights, both fixed. They are the same in every step of an autoregressive rollout and in every forecast: 100 G pairs, 5% of each forecast step. Program mode could cache any subplan whose inputs are all constant across invocations, not only plans.
- Semi-join reduction through 16 layers. The open question in section 14 now has numbers for GraphCast: exact pruning saves about 35%, and the receptive field saturates the mesh within 8 rounds. Larger savings would need approximations, which principle 8 leaves to the user. The demo will add an approximate regional forecast written that way (
ORDER BYdistanceLIMIT kper round), with its error measured against the exact forecast. It would be useful to know which rewrites einfold could still prove exact on such a query, for example the semi-join reduction on top of the user'sLIMIT. - Precision. DeepMind runs GraphCast in bfloat16, and its weights are float32. The demo uses
DOUBLEthroughout, so a host'sfloat32kernels would matter here, along with section 8.6's precision levels. M4 hasfloat32. bfloat16 appears only as something thefastprecision level may use (supplement section 8.6). For a model DeepMind runs in bfloat16, bfloat16 is the reference to match, not a speedup to opt into.
Suggested order, for GraphCast
- The narrow matmul detector plus run-time density check, feeding M1's kernels, with the grid embedding as its gate.
- The DataFusion target-profile fix for CTEs.
- Factor hoisting and gather distributivity, in the relational form.
- Epilogues and the MLP pipeline (with M2's pipeline work).
- Scatter-sum fusion.
- Constant-subplan caching in program mode.
The first three are relational or narrow, and would cut the forecast from about 6 hours to tens of minutes. The rest is what it would take to approach JAX's 15 s.
Activity
alxmrs commentedon Oct 18, 2026
🌀 Claude (GraphCast demo, xarray-sql)
@alxmrs asked whether these suggestions call for a new design, or whether v16 can absorb them. My answer: v16 can absorb them. Almost every suggestion fits an abstraction einfold already has. What GraphCast changes is mostly the roadmap's order, plus one real generalization of a rule.
Already in the design: reorder or extend
x ⋈ W GROUP BY id, gUNION ALL(9.1, #17); thef + 512offset is an affine coordinate map (8.1)The one real generalization
Fusing a message MLP with its sum by receiver. Section 9.6 requires the consumer's group to be the producer's group minus a dimension the consumer sums away. In GNN message passing, the consumer's group (
receiver) is a function of the summed dimension (edge), through a join with the edge table. Allowing a functional dependency through a mapping relation changes one rule's precondition, not the architecture. Its test cases would be GNN message passing and the gather/scatter half of sparse attention, next to Nadaraya–Watson.Where it would become a redesign
If the goal is to approach JAX (DeepMind's code runs the forecast in 15 s), not to beat the hosts by 4–14×. Most of the remaining gap is that every fold's output becomes rows before the next fold reads it, about ten times per layer. S21 already found that writing output rows bounds the win. Closing that gap means keeping a whole layer's intermediates as dense tiles across many folds. That's still einfold's frame (folds, tiles, pipelines, program mode), but it moves "pipelines of more than two folds" (section 14, M7) from deferred to central.
Proposed v17
linear_0 → swish → linear_1) and GNN message passing as test cases.This rests on one demo that hasn't run end to end, so before rewriting the roadmap I'd run the two spikes below. Either result could firm up or overturn items 1 and 3.
Spikes for another agent
Both spikes use GraphCast_small's real weights and graphs. The code that fetches and tables them is in xarray-sql's
benchmarks/ml/_graphcast.py, on branchgraphcast-weights(xqlsystems/xarray-sql#270). xarray-sql is XQL Systems' library for querying Xarray datasets with SQL. Install it from that branch with itsmlandduckdbextras, plusweathernext @ git+https://github.com/google-deepmind/graphcast, DeepMind's GraphCast package. Then use:_graphcast.checkpoint(),graphs(),param_tables(),channels_table()andgraph_tables(): the published weights, the three graphs, and their tables, downloaded once to~/.cache/xarray-sql/graphcast;_graphcast.mlp(net, x): one of DeepMind's MLPs in plain JAX, float64. This is the reference. Its stages match DeepMind's own model to about 1e-6 of each stage's largest value;03_graphcast.py'smlp(),messages()andgrid_inputs(): the SQL as the demo writes it.Memory safety, required. This machine has 15 GB, and an uncapped run of the demo's encoder was OOM-killed at 13.9 GB, taking the agent session with it. Run every measurement in its own process inside a memory cap:
Set each host's memory limit explicitly, and report it: DataFusion's
RuntimeEnvBuilder().with_fair_spill_pool(...), DuckDB'smemory_limit. At a 0.5 GB limit, DataFusion spilled more than 11 GB in 20 minutes on a slice that takes 30 s unlimited, so watch spill size and free disk.Follow
docs/spikes/README.md: one folder per spike, a report with method, results, findings and limitations, and SPDX headers.S28: dense kernels on GraphCast's forward MLPs
Question. Do S21's dense kernels give their 4–14× on GraphCast's MLPs, from Arrow rows to Arrow rows? Does a dense output remove the hosts' memory problem? And how much more does fusing the MLP's epilogues (bias, swish, layer norm) add?
Workloads, from the real weights and inputs:
grid2mesh_gnn/encoder_nodes_grid_nodes: 65,160 cells × [186 → 512 → 512], then layer norm, about 23 G joined pairs. Its input is the demo'sgrid_intable, whichgrid_inputs()builds.mesh_gnn/processor_edges_0_mesh: 81,900 edges × [1536 → 512 → 512], then layer norm, about 86 G pairs. Its input is each edge's state, sender and receiver concatenated. Build it from_graphcast.mirror()'s mesh state after the encoder and the embedded mesh edges,_graphcast.mlp("mesh_gnn/encoder_edges_mesh", graphs()["mesh"]["features"]). If either host can't finish within the cap, use a fixed slice of edges, the same on every method.Methods.
mlp(): each matrix product alone, and the whole MLP.Report. For each workload and method: time, peak memory, joined pairs per second, and error against
_graphcast.mlpin float64.Prediction. DataFusion runs about 0.09 G pairs/s, about 260 s for workload 1. Its hash aggregates hold
ids × 512groups, which is where memory goes. S21 predicts 4–14× for the kernels. Fusion's further gain is unmeasured.S29: distributing the first layer over the gather
Question. Does writing a message MLP's first layer as
W_e·e + (W_s·s)[sender] + (W_r·r)[receiver]save in plain SQL, on both hosts, what the pair counts predict? The demo writesW · [e; s[sender]; r[receiver]]instead: concatenation byUNION ALLwith offset feature indices, then one join with a 1536 × 512 matrix. And which of einfold's rules would each step of the rewrite need?Method. One processor round on the real mesh: 10,242 nodes, 81,900 edges. Take the inputs from
_graphcast.mirror()and the edge embedding as in S28, so the SQL encoder never runs, and register them as tables.messages()SQL, as written.mesh ⋈ W[512:1024], and receiver part,mesh ⋈ W[1024:1536], each 10,242 × 512;edge_state ⋈ W[0:512];Check that (a) and (b) agree, within the float sum bound of section 8.6, with
_graphcast.mlp("mesh_gnn/processor_edges_0_mesh", …).Report. Time, peak memory and joined pairs on DataFusion and DuckDB. Then, for each step of the rewrite, say which existing rule covers it and what's missing. The steps are: the offset
f + 512as an affine reindexing of the contracted dimension; splittingWby ranges of that dimension; the sum overUNION ALL; and contracting before a many-to-one join.Prediction. The first layer goes from 64.4 G pairs to 26.8 G per round, and the whole message MLP from 85.9 G to 48.3 G (−44%). Across the forecast, the rewrite saves 717 G of 2,054 G pairs (35%).
alxmrs commentedon Oct 18, 2026
🌀 Claude (GraphCast demo, xarray-sql)
@alxmrs asked for notes on how einfold might approach JAX's time on GraphCast. Here they are. The numbers are estimates from the measurements above, not results.
The target
DeepMind's JAX code runs one GraphCast_small forecast in 15 s on this machine, an Intel i7-8700 with 6 cores. JAX is Google's array library, and XLA is its compiler. That forecast uses float32 and the same 2.05 trillion multiply-adds the SQL forms as joined pairs, so it ran at about 137 G multiply-adds per second.
Two caveats. First, the 15 s was the first call of a
jax.jitfunction, so it includes XLA's compilation, and the steady-state forecast is faster. A fair target needs a warm, repeated timing. Second, JAX's run is the floor this machine has demonstrated, not its peak. Six AVX2 cores can do several hundred GFLOP/s in float32.DataFusion runs the demo's SQL at about 0.09 G joined pairs per second: roughly 1,500× slower. The gap has four parts, and each needs a different fix.
1. Arithmetic: every product as a GEMM on dense float32 tiles
All 2.05 T pairs are two-operand products with a dense weight matrix of at most 1536 × 512 (see above). As GEMMs, these shapes are compute-bound, with K from 186 to 1536 and N = 512. That's the regime where a blocked GEMM kernel reaches a large fraction of peak, unlike S21's ddx shapes (K = 16), where writing the output dominated.
float32(M4) a requirement for this goal, not a later option. bfloat16, which DeepMind runs on accelerators, is a further 2× where a host supports it.2. Data movement: rows only at the edges of the program
This is the largest gap, and the one the design defers. Each stage of the demo writes its output as
(id, f, val)rows: two 8-byte keys per 8-byte value, where a dense float32 array spends 4 bytes per value. Each MLP writes its hidden and pre-norm layers as rows too. Per processor round, the message stage alone writes about 168M values (81,900 edges × 512 channels, materialized about four times: two hash aggregates, the layer norm's window, and the stored result). Over 16 rounds, that's about 2.7 G values, or 64 GB of row traffic at 24 bytes each. The hosts then hash it all again in the next stage's join andGROUP BY. S21 found that writing output rows bounds the win, and here it would dominate even after (1).JAX writes activations as dense arrays, and XLA fuses the elementwise work into the matrix products' loops. To get close, einfold needs dense residency at three scopes, from narrow to wide:
linear_0 → swish → linear_1 → layer normis one 9.6 pipeline, so the hidden layer lives in a tile, as FlashAttention keeps its scores.3. Graph joins as gathers and scatters, not hash joins
Besides the products with weights, the joins in a message-passing round aren't contractions. They join the edge table to node states by
senderorreceiver(a gather), and sum messages byreceiver(a scatter-add, JAX'ssegment_sum). As hash joins, they rehash tens of millions of rows per round. When facts say thatsenderandreceiverare positions into the node tables (the edge table is fixed, and its keys are dense ranges), they become indexed loads and adds.EinFolddoesn't cover it today. The design has positions (8.1, coordinate maps and dictionary encoding), but no realization for a positional join without an aggregate.4. Do less arithmetic than JAX
einfold's algebra can do less work than JAX's program, because DeepMind's code concatenates before each edge MLP, and XLA doesn't distribute the product over the concatenation:
Even at half JAX's GEMM efficiency, a 35–50% smaller plan would bring einfold within about 2× of JAX. That makes "within 2× of JAX, and below it for regional forecasts" a realistic stretch goal, and a strong research result: a declarative model, optimized by algebra JAX's program can't express, against a hand-built array program.
An order of work toward it
The first two rows are near-term and measurable now. The last three are the redesign-shaped part from the previous comment: dense residency across folds and statements. That puts M7's "pipelines of more than two folds" and program mode at the center of the roadmap, not the end.
A spike that would bound all of this: S30, the roofline
Before committing to that order, measure the ceiling on this machine:
Those three numbers tell us how close a perfect einfold could get, and which of the four gaps is worth closing first. Same rules as S28 and S29: run each measurement inside a memory cap (the machine has 15 GB), in its own process.
alxmrs commentedon Oct 31, 2026
🌀 Claude: GraphCast on einfold 0.1.0rc2. Measured on an Intel i7-8700 with DataFusion 55.0.0 and ddxdb 0.4.6, and checked against DeepMind's model.
It runs, and it's correct. The demo's earlier form stored each stage as a table. In that form, the encoder and one processor round ran over the full 1° grid in about 10 minutes, and each checked stage matched DeepMind's model to 1e-6 or better:
Summing these puts a full forecast at about 45–50 minutes. DeepMind's JAX takes 15 s. Peak memory reached the run's 12 GB limit, with 3.9 GB in swap.
einfold runs the matrix products; the layer norm is now the cost. On a 2,048-cell slice of the grid embedding, the whole plan is one einfold operator at every stage:
SUM(x.val * w.w) … GROUP BY x.id, w.g)AVGandVAR_POPOVER (PARTITION BY id))The two linear layers run at about 2.3G joined pairs/s, about 25× DataFusion's 0.09G/s. Adding the layer norm then adds 1.5 s to the 1.8 s. That isn't the windows on their own. On synthetic tables of the same shape (2,048 × 512, with 512 × 512 weights), a linear layer and a layer norm with a learned scale and offset, joined from a long-form
normstable throughnets, takes 0.21 s on einfold, against 3.5 s on DataFusion. So the 1.5 s comes from how the layer norm combines with GraphCast's full chain: weights and biases selected from long-form tables holding all 88 weight matrices, then bias, swish, the second layer and the norm. I haven't narrowed it further yet.plans_with_pipelines()stays at 0 on these plans, andplans_with_einfold()counts them.The demo is now written as views, one per stage (#270 in xarray-sql), so that it reads like the model. As views, it doesn't run past the encoder anywhere yet:
Once DataFusion computes each shared read once, the views should run at the stored speeds above.