Skip to content

Commit aaa950d

Browse files
alxmrsclaude
andcommitted
Plan a query once and run it many times: Context.prepare
A training loop runs the same query on every step, and over small tables DataFusion's planning takes longer than running. Context.prepare plans a query once; calling the PreparedQuery runs that plan on the tables' current values, after writing any Datasets given into their tables in place. On a join of two 1,024-row tables a call takes 0.33 ms, against 0.91 ms for ctx.sql. A plan keeps state from a run (a hash join keeps its build side, a repartition its channels), and datafusion-python can't reset it (#283). Each call runs a copy decoded from the plan's serialized form. A plan that can't be serialized (a lazy table) or decoded (nested too deep), and a query that calls grad (xqlsystems/ddx#202), are planned again on each call. A write must keep its NaNs in place (#284). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NyN7idACeDMh94qrnFMu6h
1 parent 0af487b commit aaa950d

4 files changed

Lines changed: 343 additions & 2 deletions

File tree

‎docs/performance.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,28 @@ it or nothing happens:
177177
ctx.sql("CREATE OR REPLACE TABLE grid_cube AS ...").collect()
178178
```
179179

180+
## Plan once: prepare a query you run many times
181+
182+
DataFusion plans a query each time it runs. For a query over small
183+
tables, as in a training loop, planning can take longer than running.
184+
`Context.prepare` plans the query once. A call runs that plan on the
185+
tables' current values, and can first write new values into a table
186+
in place:
187+
188+
```python
189+
step = ctx.prepare(
190+
"SELECT x.i, SUM(x.v * w.v) AS y FROM x JOIN w ON x.j = w.j GROUP BY x.i"
191+
)
192+
for weights in trajectory: # Datasets shaped like w's
193+
rows = step(w=weights) # a pyarrow.Table
194+
```
195+
196+
On a join of two 1,024-row tables, a call takes 0.33 ms, and `ctx.sql`
197+
takes 0.91 ms (one target partition). A write must keep the table's
198+
shape, dtypes, coordinates and NaN positions. A plan over a lazy table
199+
(Dask or a lazily opened file), and a query that calls `grad`, are
200+
planned again on each call for now.
201+
180202
## Round-trip faster with ORDER BY
181203

182204
Results that arrive **grid-ordered** — sorted by the dimension columns,

‎tests/test_prepare.py‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Tests for queries planned once and run many times (`Context.prepare`)."""
2+
3+
import importlib.util
4+
5+
import numpy as np
6+
import pytest
7+
import xarray as xr
8+
from datafusion import SessionConfig
9+
10+
from xarray_sql import Context
11+
12+
# The build side of the join, `a`, is what a reused hash join would keep.
13+
JOIN = """
14+
SELECT b.i, SUM(t.v2 * b.w) AS s
15+
FROM (SELECT i, j, 2 * v AS v2 FROM a) t JOIN b ON t.j = b.j
16+
GROUP BY b.i ORDER BY b.i
17+
"""
18+
19+
20+
def _a(v):
21+
return xr.Dataset(
22+
{"v": (("i", "j"), v)},
23+
coords={"i": np.arange(v.shape[0]), "j": np.arange(v.shape[1])},
24+
)
25+
26+
27+
def _b(w):
28+
return xr.Dataset(
29+
{"w": (("i", "j"), w)},
30+
coords={"i": np.arange(w.shape[0]), "j": np.arange(w.shape[1])},
31+
)
32+
33+
34+
@pytest.fixture
35+
def ctx():
36+
rng = np.random.default_rng(0)
37+
ctx = Context()
38+
ctx.from_dataset("a", _a(rng.normal(size=(4, 3))))
39+
ctx.from_dataset("b", _b(rng.normal(size=(4, 3))))
40+
return ctx
41+
42+
43+
def test_a_prepared_query_returns_the_query_rows(ctx):
44+
query = ctx.prepare(JOIN)
45+
want = ctx.sql(JOIN).to_arrow_table()
46+
assert query().equals(want)
47+
assert query().equals(want)
48+
49+
50+
def test_a_call_reads_the_values_it_writes(ctx):
51+
query = ctx.prepare(JOIN)
52+
rng = np.random.default_rng(1)
53+
for _ in range(3):
54+
a, b = _a(rng.normal(size=(4, 3))), _b(rng.normal(size=(4, 3)))
55+
rows = query(a=a, b=b)
56+
fresh = Context()
57+
fresh.from_dataset("a", a)
58+
fresh.from_dataset("b", b)
59+
assert rows.equals(fresh.sql(JOIN).to_arrow_table())
60+
61+
62+
def test_a_write_changes_the_registered_dataset():
63+
v = np.zeros((4, 3))
64+
ctx = Context()
65+
ctx.from_dataset("a", _a(v))
66+
query = ctx.prepare("SELECT SUM(v) AS s FROM a")
67+
assert query(a=_a(np.ones((4, 3)))).column("s").to_pylist() == [12.0]
68+
assert (v == 1).all()
69+
assert ctx.sql("SELECT SUM(v) AS s FROM a").to_pydict() == {"s": [12.0]}
70+
71+
72+
def test_a_plan_with_several_partitions_runs_again():
73+
ctx = Context(SessionConfig().with_target_partitions(4))
74+
n = 64
75+
ctx.from_dataset("a", _a(np.ones((n, 3))), chunks={"i": n // 4})
76+
ctx.from_dataset("b", _b(np.ones((n, 3))), chunks={"i": n // 4})
77+
query = ctx.prepare(
78+
"SELECT a.i, SUM(v * w) AS s FROM a JOIN b ON a.i = b.i AND a.j = b.j "
79+
"GROUP BY a.i"
80+
)
81+
for scale in (1.0, 2.0):
82+
rows = query(a=_a(np.full((n, 3), scale)))
83+
assert sorted(rows.column("s").to_pylist()) == [3 * scale] * n
84+
85+
86+
def test_a_lazy_table_is_planned_on_each_call():
87+
pytest.importorskip("dask")
88+
ctx = Context()
89+
ctx.from_dataset("a", _a(np.ones((4, 3))).chunk({"i": 2}))
90+
query = ctx.prepare("SELECT SUM(v) AS s FROM a")
91+
assert query().column("s").to_pylist() == [12.0]
92+
assert query().column("s").to_pylist() == [12.0]
93+
with pytest.raises(ValueError, match="NumPy"):
94+
query(a=_a(np.ones((4, 3))))
95+
96+
97+
@pytest.mark.parametrize(
98+
"new, match",
99+
[
100+
(_a(np.ones((4, 2))), "coordinates"),
101+
(_a(np.ones((4, 3), dtype=np.float32)), "float32"),
102+
(_a(np.ones((4, 3))).rename({"v": "u"}), "data variables"),
103+
(_a(np.full((4, 3), np.nan)), "NaN"),
104+
],
105+
)
106+
def test_a_write_must_keep_the_table_shape(ctx, new, match):
107+
query = ctx.prepare(JOIN)
108+
want = query()
109+
with pytest.raises(ValueError, match=match):
110+
query(a=new)
111+
assert query().equals(want)
112+
113+
114+
def test_a_write_needs_the_table_the_query_was_planned_with(ctx):
115+
query = ctx.prepare(JOIN)
116+
with pytest.raises(ValueError, match="no Dataset"):
117+
query(c=_a(np.ones((4, 3))))
118+
ctx.deregister_table("a")
119+
ctx.from_dataset("a", _a(np.ones((4, 3))))
120+
with pytest.raises(ValueError, match="registered again"):
121+
query(a=_a(np.ones((4, 3))))
122+
123+
124+
@pytest.mark.skipif(
125+
importlib.util.find_spec("ddxdb") is None, reason="needs ddxdb"
126+
)
127+
def test_a_prepared_gradient_reads_the_values_it_writes():
128+
ctx = Context()
129+
ctx.from_dataset("w", xr.Dataset({"v": ("i", np.arange(3.0))}))
130+
step = (
131+
"WITH loss AS (SELECT SUM(v * v) AS l FROM w) "
132+
"SELECT i, grad(loss, v) AS g FROM w ORDER BY i"
133+
)
134+
query = ctx.prepare(step)
135+
assert query().column("g").to_pylist() == [0.0, 2.0, 4.0]
136+
new = xr.Dataset({"v": ("i", np.array([1.0, -1.0, 5.0]))})
137+
assert query(w=new).column("g").to_pylist() == [2.0, -2.0, 10.0]

‎xarray_sql/__init__.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
__all__ = [
1515
"cftime",
1616
"Context",
17+
"PreparedQuery",
1718
"XarrayContext",
1819
"FlightSQLServer",
1920
"read_xarray_table",
@@ -28,17 +29,18 @@
2829
]
2930

3031
try:
31-
from .sql import Context, XarrayContext
32+
from .sql import Context, PreparedQuery, XarrayContext
3233
except ModuleNotFoundError as e:
3334
# DataFusion's Python bindings have no wheels for some platforms
3435
# (e.g. Pyodide); every other engine adapter still works there.
3536
if e.name != "datafusion":
3637
raise
3738
__all__.remove("Context")
39+
__all__.remove("PreparedQuery")
3840
__all__.remove("XarrayContext")
3941

4042
def __getattr__(name: str):
41-
if name in ("Context", "XarrayContext"):
43+
if name in ("Context", "PreparedQuery", "XarrayContext"):
4244
raise AttributeError(
4345
f"{name} requires the datafusion package, which is "
4446
"not installed."

‎xarray_sql/sql.py‎

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import uuid
22

3+
import numpy as np
34
import pyarrow as pa
45
import xarray as xr
56
from datafusion import SessionContext
67
from datafusion.catalog import Schema
8+
from datafusion.plan import ExecutionPlan
79
from types import ModuleType
810

911
from . import cftime as cft
@@ -244,10 +246,188 @@ def sql(self, query: str, *args, **kwargs) -> XarrayDataFrame:
244246
return self._wrap(_ad.sql(self, query))
245247
return self._wrap(super().sql(query, *args, **kwargs))
246248

249+
def prepare(self, query: str) -> "PreparedQuery":
250+
"""Plan a query once, to run it many times.
251+
252+
A training loop runs the same query on every step, and only the
253+
values in its tables change. DataFusion plans a query each time it
254+
runs, and for a query over small tables, planning can take far
255+
longer than running. ``prepare`` plans the query once, and calling
256+
the result runs that plan, as ``jax.jit`` traces a function once
257+
and runs the compiled function after that. A call can first write
258+
new values into the query's tables::
259+
260+
step = ctx.prepare(
261+
"SELECT x.i, SUM(x.v * w.v) AS y "
262+
"FROM x JOIN w ON x.j = w.j GROUP BY x.i"
263+
)
264+
for weights in trajectory: # Datasets shaped like w's
265+
rows = step(w=weights)
266+
267+
See [PreparedQuery][xarray_sql.PreparedQuery] for what a call
268+
can write, and which plans are run again rather than planned again.
269+
270+
Args:
271+
query: A SQL query string.
272+
273+
Returns:
274+
A [PreparedQuery][xarray_sql.PreparedQuery]; call it to run
275+
the query.
276+
"""
277+
return PreparedQuery(self, query)
278+
279+
def _write(self, writes: dict[str, xr.Dataset]) -> None:
280+
"""Write each Dataset's values into the arrays of the Dataset
281+
registered under its name, all or none of them."""
282+
copies = []
283+
for name, ds in writes.items():
284+
registered = self._registered_datasets[name]
285+
if set(ds.data_vars) != set(registered.data_vars):
286+
raise ValueError(
287+
f"{name!r} has the data variables "
288+
f"{sorted(registered.data_vars)}, and the new Dataset "
289+
f"has {sorted(ds.data_vars)}"
290+
)
291+
for dim in registered.dims:
292+
if dim not in ds.dims or not ds[dim].equals(registered[dim]):
293+
raise ValueError(
294+
f"the new Dataset for {name!r} has other coordinates "
295+
f"along {dim!r}; register it again, and prepare the "
296+
"query again"
297+
)
298+
for var in registered.data_vars:
299+
old, new = registered[var].variable, ds[var].variable
300+
if new.dims != old.dims or new.dtype != old.dtype:
301+
raise ValueError(
302+
f"{name}.{var} is {old.dtype} over {old.dims}, and "
303+
f"the new values are {new.dtype} over {new.dims}"
304+
)
305+
target = old.data
306+
# A table shares a C-contiguous NumPy array's memory; any
307+
# other array, it read or copied when it was registered.
308+
if not (
309+
isinstance(target, np.ndarray)
310+
and target.flags.c_contiguous
311+
and target.flags.writeable
312+
):
313+
raise ValueError(
314+
f"{name}.{var} isn't a writable, C-contiguous NumPy "
315+
"array, which its table would share; register the "
316+
"new Dataset instead, and prepare the query again"
317+
)
318+
values = np.asarray(new.values)
319+
# A table works out which values are NULL (NaN) when it is
320+
# registered, so a write must keep its NaNs in place.
321+
if values.dtype.kind in "fc" and not np.array_equal(
322+
np.isnan(values), np.isnan(target)
323+
):
324+
raise ValueError(
325+
f"the new values of {name}.{var} have NaN in other "
326+
"places; xarray-sql reads a NaN as NULL, so register "
327+
"the new Dataset instead, and prepare the query again"
328+
)
329+
copies.append((target, values))
330+
for target, values in copies:
331+
np.copyto(target, values)
332+
247333
def _wrap(self, inner) -> XarrayDataFrame:
248334
"""Wrap a DataFusion DataFrame so it can round-trip to xarray."""
249335
return XarrayDataFrame(inner, templates=self._registered_datasets)
250336

251337

338+
class PreparedQuery:
339+
"""A query planned once by [Context.prepare][xarray_sql.Context.prepare].
340+
341+
Call it to run the query, and get its rows as a ``pyarrow.Table``
342+
([to_dataset][xarray_sql.to_dataset] turns them back into a Dataset).
343+
``query()`` runs it on the values its tables hold now. ``query(w=ds)``
344+
first writes ``ds``'s values into the table ``w``.
345+
346+
A write goes into the arrays of the Dataset registered as ``w``, as
347+
``w_ds["v"].values[...] = ds["v"].values`` would. That Dataset changes
348+
too, and so does what any other query of the table returns. The new
349+
Dataset must have the registered one's data variables, dtypes, dims and
350+
coordinates. Its NaNs must be in the same places too, since xarray-sql
351+
reads a NaN as NULL, and a table works out which values are NULL when it
352+
is registered. To change anything else, register the new Dataset, and
353+
prepare the query again: a prepared query reads the tables that were
354+
registered when it was planned, and refuses to write into a name that
355+
has been registered again since.
356+
357+
A plan keeps state from a run: a hash join, for one, keeps the table it
358+
built. So each call runs a fresh copy of the plan, decoded from its
359+
serialized form, which is much cheaper than planning. Some plans can't
360+
be copied that way: a plan that reads a lazy table (a Dask array or a
361+
lazily opened file), or one nested too deep to decode. A query that
362+
calls ``grad``, ``vjp`` or ``jvp`` isn't planned ahead either. These are
363+
planned again on every call, so they return the right rows, without
364+
the savings.
365+
"""
366+
367+
def __init__(self, ctx: Context, query: str):
368+
self._ctx = ctx
369+
self._query = query
370+
# The Datasets a call may write into: those registered now.
371+
self._registered = dict(ctx._registered_datasets)
372+
self._plan: ExecutionPlan | None = None
373+
if _ad is not None and _ad.calls_ad(query):
374+
# TODO(xqlsystems/ddx#202): run ddx's prepared program, whose
375+
# steps are planned once, rather than rewriting on each call.
376+
return
377+
df = SessionContext.sql(ctx, query)
378+
self._schema = df.schema()
379+
plan = df.execution_plan()
380+
try:
381+
_copy(ctx, plan)
382+
except Exception: # noqa: BLE001 - DataFusion raises a bare Exception
383+
# It reads a table the plan's serialized form can't hold, or is
384+
# nested deeper than protobuf decodes.
385+
return
386+
self._plan = plan
387+
388+
def __call__(self, **tables: xr.Dataset) -> pa.Table:
389+
"""Run the query, after writing each Dataset given into the table
390+
of its name.
391+
392+
Args:
393+
**tables: A Dataset for each table to write new values into.
394+
395+
Returns:
396+
The query's rows.
397+
"""
398+
for name in tables:
399+
if name not in self._registered:
400+
raise ValueError(
401+
f"no Dataset was registered as {name!r} when the query "
402+
"was prepared"
403+
)
404+
now = self._ctx._registered_datasets.get(name)
405+
if now is not self._registered[name]:
406+
raise ValueError(
407+
f"{name!r} has been registered again since the query was "
408+
"prepared, and the query reads the table it was planned "
409+
"with; prepare the query again"
410+
)
411+
self._ctx._write(tables)
412+
if self._plan is None:
413+
return self._ctx.sql(self._query).to_arrow_table()
414+
plan = _copy(self._ctx, self._plan)
415+
batches = [
416+
batch.to_pyarrow()
417+
for partition in range(plan.partition_count)
418+
for batch in self._ctx.execute(plan, partition)
419+
]
420+
return pa.Table.from_batches(batches, self._schema)
421+
422+
423+
def _copy(ctx: SessionContext, plan: ExecutionPlan) -> ExecutionPlan:
424+
"""A copy of ``plan`` with none of its state from a run, reading its
425+
in-memory tables' values as they are now."""
426+
# TODO(#283): reset the plan's state with datafusion-python's binding of
427+
# reset_plan_states, once it has one, rather than decode it again.
428+
# `plan` itself never runs, so it has no state to leave behind.
429+
return ExecutionPlan.from_bytes(ctx, plan.to_bytes())
430+
431+
252432
XarrayContext = Context
253433
"""The original name of [Context][xarray_sql.Context], kept for compatibility."""

0 commit comments

Comments
 (0)