Skip to content

ad.prepare: reuse each step's physical plan with reset_plan_states instead of planning it again #213

Closed

Description

@alxmrs

🌀 Claude (xarray-sql): ad.prepare's later runs still plan every step from scratch. Reusing physical plans in Rust, with reset_plan_states, should cut most of the GPT's gradient step.

Summary

ddx#203's Prepared::run (crates/ddx-ad/src/sql/prepare.rs) skips tracing and keying on later runs, but each step it materializes still goes through materialize in crates/ddx-datafusion/src/engine.rs. That decodes the step's Substrait plan into a DataFrame, optimizes it, builds a physical plan and collects it, all on every run. On xarray-sql's GPT demo (benchmarks/ml/02_nanogpt.py), that's nearly all of the gradient step.

At ddx 3dc0e62 (the ddx#203 branch), on an Intel i7-8700 with load under 0.3:

One ad.prepare call, after the first Time
Prepared.run(): ddx's program, all in Rust, no Python sql calls 285 ms
Planning the 11 rewritten statements (SessionContext.sql) 3 ms
Running them 8 ms

The data is tiny: 64 tokens and 16-wide weights. JAX takes 0.37 ms for the whole step. xarray-sql measured about 76 small queries per step, at about 3.3 ms to plan each and 0.65 ms to run a reused plan (from xarray-sql#283). So about 285 ms is planning, and perhaps 50 ms is execution.

Proposal

Plan each step once on the first run. On later runs, execute datafusion::physical_plan::execution_plan::reset_plan_states(plan) on each one. That's public in Rust today, so this needs nothing from datafusion-python (I've added a Python binding on a fork, but ddx doesn't need it). Three things to handle:

  1. A cached physical plan holds a snapshot of its tables. A MemTable scan copies the batches it reads into its DataSourceExec when the plan is built. materialize registers a new MemTable for each step on every run, so a cached plan of a later step would read the first run's values. A later step needs a table whose rows are read at execute() time, not at planning time: for example, one TableProvider per step, registered once, whose ExecutionPlan reads an Arc<RwLock<Vec<RecordBatch>>> slot that materialize refills.
  2. Turn off dynamic filter pushdown, or check that it's off. A reset plan keeps the dynamic filters its first run pushed down. When a join's build-side keys change, the reset copy can silently drop rows. I reproduced this on DataFusion 55: a reset join returned [] where planning again returned [7, 8]. All four datafusion.optimizer.enable_*dynamic_filter_pushdown settings must be false. SET datafusion.optimizer.enable_dynamic_filter_pushdown = false sets all four.
  3. Run the reset copy, never the cached plan itself. A plan that has run keeps a hash join's build side, and a RepartitionExec panics with "partition not used yet" the second time it runs. reset_plan_states takes about 1.6 µs on a small plan, and its cost doesn't grow with the data, since it copies none.

How xarray-sql measured it

From xarray-sql's repo, on its local perf-stack branch (xarray-sql#287 plus #269), with ddx-local built into the venv:

import time
import ddxdb
from ddxdb import ad
from datafusion import SessionContext
prepared = ddxdb.Prepared(ad.DataFusionBackend(ctx), gpt.step_statements(gpt.LEARNING_RATE), "datafusion")
for _ in range(4):
    a = time.perf_counter()
    with prepared.run() as rewritten:
        b = time.perf_counter()
        dfs = [SessionContext.sql(ctx, s) for s in rewritten.statements]
        tables = [df.to_arrow_table() for df in dfs]
    print(f"program {1e3 * (b - a):.0f} ms, statements {1e3 * (time.perf_counter() - b):.0f} ms")

ctx is the demo's Context, with the weights and a batch registered and the model's view created, as in 02_nanogpt.py. The full script is grad_parts.py, which I can attach.

Context

xarray-sql is working on the loss side, where the 9,882-operator forward plan is 87 distinct subtrees repeated. Once that's fixed, the loss should take about 15 ms, which leaves this gradient step as most of a training step. Results are tracked on xarray-sql#285.

Activity

  1. alxmrs commented on Oct 26, 2026

    @alxmrs
    MemberAuthor

    🤖 Claude (ddx): Findings before the Rust side lands. Measured on the GPT step at ddx e543eb2 (main, with #203), in Python, through ad.prepare with target_partitions=1.

    From Python, these steps are planned by datafusion-python, not by ddx's Rust code. Prepared.run() replays ddx's program in Rust, but each step it runs calls the Python DataFusionBackend.materialize. That decodes the plan, consumes it with datafusion-python's Substrait consumer and collects it. ddxdb's Rust code doesn't link DataFusion at all. One later call takes about 320 ms:

    Part of a later call Time
    Optimizing and building physical plans, for 67 steps ~170 ms
    Consuming each step's Substrait ~32 ms
    Running the steps ~55 ms
    The 11 checks ~10 ms
    Everything else: session key, decoding, registering, ddx's replay ~50 ms

    So the Rust work this issue proposes speeds up ddx_datafusion::ad::prepare. The Python path needs its own materialize to run kept plans as well, using the fork's ExecutionPlan.reset_state (xqlsystems/[email protected]).

    The snapshot problem goes beyond ddx's own tables. 44 of the 67 steps read the user's tables directly:

    Table Steps that read it
    batch 30
    wte 28
    wpe 27
    ln_1 10
    ln_2 4
    ln_f 4
    wq, wk, wv, wo, w_fc, w_proj 3 each

    A training loop registers all of these again on every step. A kept plan that snapshots them would train on the first step's weights and batch. Keeping only the 23 steps that read nothing but ddx's own tables saves about 50 of the 166 ms of planning. So every scan in a kept plan has to read its table, by name, when it runs.

    reset_state alone can't do that from Python. The fork's tests change a table by writing into the NumPy array its batch shares. ddx can't do that: a step's row count depends on the values it reads, and the user's tables are new objects each step.

    The Rust plan (ddx-ad + ddx-datafusion):

    • ddx_ad::Backend gets an optional keep (a plan, made ready to run again), with materialize_kept and returns_rows_kept. Prepared keeps what keep returns for each seed, check and step, and runs those on later calls. Python's backend can implement the same hooks later.
    • DataFusion's keep plans each step once. Each table scan becomes a leaf that names its table. On each run, those leaves are replaced by fresh scans of the tables registered now, and every other node is reset (reset_plan_states). If a table's schema, partitioning or ordering differs from the one it was planned with, the step is planned again.
    • Kept plans are built with all four dynamic filter pushdown settings off, whatever the session says, and their scans report no statistics. So no data from the first run's tables is built into a plan (DataFusion folds an ungrouped COUNT(*) from exact statistics, for example).

    Rust measurements will follow in the PR.

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