Skip to content

program: choose step boundaries by what they save (fewer, larger steps) #186

Closed

Description

@alxmrs

🌀 Claude (xarray-sql ML demos)

Summary

A program's steps are where ddx's work is now. With ddxdb 0.3.3, a cached training step of demo 2's GPT (xarray-sql's ML benchmarks, xqlsystems/xarray-sql#269) takes 1.0 s on DataFusion, and 0.70 s of that is running the program's 68 steps (1,335 operators in all, at most 16,384 rows in any step). This issue tracks choosing better step boundaries: fewer steps where a boundary buys nothing, larger steps that share their inputs, and occasionally one more step where many steps recompute the same relation.

The measurements below change what "fewer, larger steps" should mean. A step's fixed cost is small. What costs is work repeated across steps.

What a step costs

I timed DataFusionBackend.materialize (consume the Substrait plan, run it, register the result) on DataFusion 54.0.0, on an idle Intel i7-8700:

step per step
SELECT i, x FROM t (1,024 rows) 0.2 ms
a GROUP BY over it 0.6–1.0 ms
a join and a GROUP BY 1.4–2.2 ms
the GPT's steps, on average 10 ms

Merging steps only to have fewer of them would save little: 68 steps at 0.2–2 ms is 15–135 ms. Over the whole GPT program, consuming the Substrait plans takes 39 ms and registering the results 10 ms. The rest is planning and running each step's operators, so the lever is the operators.

Where the GPT's steps repeat work

  • 27 of the 68 steps rebuild the token embedding, batch ⋈ wte ⋈ wpe. Every step that reaches the residual stream reads it. It's a join with no aggregate, so ddx doesn't save it, and each step rebuilds it from the base tables: batch is read by 30 steps, wte by 28, wpe by 27. On DataFusion, each of those reads is also a slow scan when the table is an xarray-sql Dataset (Each scan of a registered Dataset rebuilds its batches in Python: 73% of the GPT demo's forward pass xarray-sql#272).

  • Q, K and V are three copies of one computation. These step triples have the same shape and read the same inputs (saved_0, saved_1, the embedding and ln_1):

    • the forward projections saved_2, saved_3 and saved_5;
    • the contractions contraction_12, _14 and _16;
    • the regions region_13, _15 and _17;
    • the gradients grad_3_wq, grad_4_wk and grad_5_wv.

    Each triple reads its inputs three times. nanoGPT computes the same thing as one product with a stacked weight (c_attn).

  • 13 steps are read by exactly one later step:

    • contraction_1, _5, _12, _14 and _16;
    • window_0, _10 and _11;
    • cotangent_1, _9, _10, _13 and _14.

    Storing them buys nothing: their reader could compute them inline, with no recomputation.

Proposal: choose step boundaries by what they save

  1. Inline a step that one step reads. This is the inverse of opt::split and has to respect its MAX_DEPTH. It is always safe and saves the step's fixed cost and its write: small, about 13 × 1–2 ms on the GPT, but free.
  2. Stack independent steps that share their inputs. Steps of the same shape over the same inputs (Q, K and V above, or the per-table gradients of a weight read by every layer) become one step with a tag column. Each reader filters on its tag, and the gradient outputs become filters or views of the stacked table. That reads the shared inputs once instead of three times, which is what a fused QKV projection does. This needs a rule for recognizing "same shape, same inputs" in the plans ddx emits; the contraction rule's output is regular enough that it may only need to compare plans with the weight table abstracted.
  3. Store a relation that many steps rebuild, even if it isn't an aggregate. The embedding is one more step, and 26 fewer rebuilds. An embedding join costs 1.8 ms over in-memory tables and 5.6 ms through xarray-sql's scan today, so this is roughly 50–150 ms of the GPT's 0.70 s. In general, step boundaries are a materialization choice: store a relation that more than one step reads and that costs more to recompute than to write; inline one read once.
  4. On an engine that computes a CTE once, use CTEs. DuckDB computes a CTE that is read twice only once, and PostgreSQL has AS MATERIALIZED. There, a chain of steps, or a whole program, can be one statement, with each would-be step a CTE, and nothing goes to the client between steps. This bears on the DuckDB backend (ddxdb: one backend for SQL engines, DuckDB-Python first #148, Exit: xarray-sql's ML demos differentiate on DuckDB #150), which is starting now: its materialize should be CREATE TEMP TABLE … AS, keeping the data in the engine, and its target profile could say that CTEs are computed once. DataFusion recomputes a CTE at every read (DataFusion plans a CTE once per reference, so ML queries grow exponentially with depth xarray-sql#267), so it needs steps, as Plan queries for engines with no Substrait producer (Polars, ADBC) #159 notes.

Items 1–3 change the program and help every engine. Item 4 is a per-engine way of running it.

A cheaper knob, separately

The steps are small, and DataFusion plans each one for every core. Setting datafusion.execution.target_partitions = 1 takes the GPT's 68 steps from 0.70 s to 0.57 s (−19%). With 2 or 4 partitions it's 0.63–0.64 s. A backend could run steps below some row count with one partition, using DataFusion's statistics or the program's own knowledge of each step's size. This is independent of the boundaries above and stacks with #157 (running independent steps concurrently): fewer partitions per step leaves cores free for other steps.

How to measure

The profile example (#158) gives each step's time. The gate for this issue is the GPT's cached step, comparing the step count, the total operators and the base-table reads before and after. Attention (demo 1) is the other check. In #153's profile, made before #162 changed its program, window_0 rebuilt the (i, j, d) join that region_1 stores, which would be case 3 again. I haven't checked whether 0.3.3's program still does.

Part of #158.

Activity

  1. alxmrs commented on Oct 21, 2026

    @alxmrs
    MemberAuthor

    A related lever for item 4, from the SQL writer in #189 (ddx_ad::to_sql, which writes each step as SQL for engines whose Substrait consumer can't run it, DuckDB first):

    to_sql writes each relation as a CTE, and a relation the plan already shares (a ReferenceRel) as one CTE read wherever it is shared. DuckDB computes a CTE that is read twice only once. So if to_sql also wrote structurally identical subtrees as one CTE, DuckDB would compute them once without ddx adding a step. This is hash-consing: key each subtree by its structure and write the first copy only. In the GPT's steps that covers the batch ⋈ wte ⋈ wpe embedding join, which 27 steps rebuild. Within one step it would also cover the inputs that the Q, K and V branches read in common, though not the branches themselves, which differ in their weight table.

    It doesn't help DataFusion, which inlines a CTE at every read (xqlsystems/xarray-sql#267). So it belongs with this issue's per-engine item 4, not items 1–3. It needs no new step boundary, so it's cheap to try once the DuckDB backend (#148) can be measured.

  2. alxmrs commented on Oct 25, 2026

    @alxmrs
    MemberAuthor

    A profile of a cached GPT SGD step with ddx at 3c95aa7 (after ddxdb 0.4.1), DataFusion 55.0.0, Intel i7-8700. The step takes 0.55 s after the first, almost all inside rewrite; collecting the 11 statements' results takes 13 ms.

    • 76 materialize calls take 0.41 s, 5.4 ms each, for steps whose median plan has 16 operators and whose largest result is 16,384 rows (74,627 rows in all).
    • Timing each phase of a step separately (which plans twice, so the parts add up to more than 0.41 s): decoding Substrait takes 0.035 s, optimized_logical_plan 0.10 s, execution_plan 0.20 s, and collecting (which plans again, then runs) 0.34 s. About three quarters of a step is planning; running it is about 1–1.5 ms.
    • The rest is view checks (view_sql, 13 calls, 0.07 s, with _same_plans 0.05 s of it), select_all (0.015 s), plan_sql and plan_key.

    Session settings move it a little:

    settings first step later steps
    default (12 partitions) 1.45 s 0.66 s
    target_partitions = 1 1.22 s 0.43 s
    target_partitions = 4 1.33 s 0.56 s
    1 partition, optimizer.max_passes = 1 0.86 s 0.40 s

    For comparison, JAX (jax.jit, same CPU) takes 0.37 ms for the whole SGD step. Since planning dominates, keeping each step's physical plan between runs looks like the biggest win, ahead of choosing step boundaries. That needs the step's inputs to be tables that can be swapped under a live plan, plus ExecutionPlan::reset_state between runs (a HashJoinExec keeps its build side in the plan). A smaller, independent win: run small steps with one partition.

  3. alxmrs commented on Oct 29, 2026

    @alxmrs
    MemberAuthor

    🌀 Claude: A re-measure for #186, and a sketch. It's on ddx main plus #229 (ddxdb 0.4.5), DuckDB 1.5.6, DataFusion 55.0.0, Intel i7-8700. It uses xarray-sql's GPT step (demo 2) at two sizes: small (16 channels, context 16, batch 4) and base (1 layer, 4 heads, 128 channels, context 64, batch 12).

    Where the time goes now

    On DuckDB, at base size, it's execution, mostly the matrix products. A cached sql_all step takes 13.7 s, of which 13.4 s is its 77 steps' CREATE TABLE AS.

    • The slowest steps are the MLP's products: cotangent_11 1.7 s, grad_9_w_proj 1.4 s, saved_11 1.2 s, each joining 768 × 512 × 128 rows. Next are Q, K and V's projections, gradients and contractions, about 0.3 s each.
    • DuckDB's profiler, summed over the steps (CPU time, 21 s in all), splits it like this:
    CPU s share
    the products: large hash joins, their group-bys and projections ~15 70%
    windows (the AVG/MAX rules' counts, the softmax) 1.2 6%
    writing the step tables (CREATE_TABLE_AS) 0.9 4%
    every other join (the embedding and region rebuilds among them) 0.8 4%

    On DataFusion, at small size, it's per-step overhead. A prepared step (ddxdb.ad.prepare, without the fork's in_place) takes about 300 ms for 77 steps, about 4 ms a step, mostly planning each step's physical plan and writing it.

    What each of #186's items can buy now

    1. Inline a step that one later step reads, once. 13 of the 77 steps qualify: contraction_0/4/11/13/15, cotangent_1/9/10/13/14/16 and window_9/10. Two kinds of step are materialized on purpose and must not be inlined:

      • a contraction's t that its reader reads more than once;
      • a window's summed cotangents, which stop a stack of windows from growing as a power of its depth.

      So the rule is: inline a step only when its one reader reads it exactly once, and the result stays under opt::split's MAX_DEPTH. Expected: about a sixth of the steps on DataFusion, roughly 10–15% of a small prepared step. On DuckDB it saves only their writes, about 1–2%.

    2. Stack Q, K and V. What stacking shares is each triple's input rebuild. All rebuild joins together are under 4% of DuckDB's time, so stacking can save at most a few percent, at the cost of a new rule. Defer.

    3. Store the embedding that 27 steps rebuild. Also within that 4% on DuckDB, and it needs a new kind of saved relation (one with no aggregate, whose cotangent accumulates by its inputs' dims). Defer until a model makes rebuilds matter.

    4. Run a whole program as one DuckDB statement with CTEs. At most the writes' 4% plus statement overhead. Defer.

    The products themselves (70%) are einfold's to speed up, not step boundaries'.

    The plan

    Build item 1 as opt::inline, run on the final steps before opt::split:

    • Inline a step when it isn't a result (the value, a gradient, a check), exactly one later step reads it, and that step reads it exactly once.
    • The inlined plan is the step's own relation in place of the read, renamed to the read's columns.
    • Skip it if the merged plan would be deeper than MAX_DEPTH or its size passes inlined_size's budget (perf(ddx-ad, ddxdb): run a step whose names would grow large as SQL #221).

    I'll measure step counts and the GPT's small (DataFusion) and base (DuckDB) steps before and after, and keep it only if it's faster.

  4. alxmrs commented on Oct 31, 2026

    @alxmrs
    MemberAuthor

    Closing as done.

    • Item 1, inlining a step that only one later step reads (once): shipped in perf(ddx-ad): compute a step that one later step reads once inside it #231 (opt::inline), released in ddxdb 0.4.6.
    • Items 2–4 are deferred: stacking Q, K and V; storing the embedding join that 27 steps rebuild; and running a program as one DuckDB statement. On the re-measure above, each can save at most about 4% of a DuckDB GPT step. 70% of that step is the matrix products, which is einfold's to speed up, not a question of step boundaries.

    If a larger model makes the embedding rebuilds matter, item 3 gets its own issue.

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