Skip to content

Commit ffaed30

Browse files
authored
test: peak-memory integration tests for the lazy round-trip (#199)
1 parent fe07ff7 commit ffaed30

1 file changed

Lines changed: 115 additions & 0 deletions

File tree

tests/test_to_dataset_perf.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Peak-memory tests for the lazy SQL -> xarray round-trip.
2+
3+
Asserts the lazy backend honors its contract: a single-chunk access
4+
peaks far below an eager whole-grid materialization, and a streaming
5+
aggregation does not balloon past the source size.
6+
"""
7+
8+
import gc
9+
import tracemalloc
10+
11+
import numpy as np
12+
import pytest
13+
import xarray as xr
14+
15+
from xarray_sql import XarrayContext
16+
17+
18+
def _peak_mb(fn):
19+
"""Return ``(result, peak_memory_mb)`` for a single call to ``fn``."""
20+
gc.collect()
21+
tracemalloc.start()
22+
tracemalloc.reset_peak()
23+
out = fn()
24+
_, peak = tracemalloc.get_traced_memory()
25+
tracemalloc.stop()
26+
return out, peak / 1e6
27+
28+
29+
@pytest.fixture(scope="module")
30+
def air_source():
31+
"""NCEP ``air_temperature`` chunked along time, ~31 MB dense.
32+
33+
Module-scoped so the pooch download (~3.5 MB) is amortized across
34+
tests in this file. Skips when the tutorial dataset is unreachable.
35+
"""
36+
try:
37+
return xr.tutorial.open_dataset("air_temperature").chunk({"time": 24})
38+
except (OSError, ValueError, ImportError) as e:
39+
pytest.skip(f"air_temperature tutorial dataset unavailable: {e}")
40+
41+
42+
def test_lazy_chunk_peak_memory_is_bounded(air_source):
43+
"""``.sel(time=t0).load()`` materializes only one chunk, not the cube.
44+
45+
Reference observation: lazy chunk peak is ~1.8 MB on a 31 MB
46+
dense source. A regression that quietly buffers the whole result
47+
would push past 10 MB. Eager ``to_dataset(chunks=None)`` is
48+
measured too as a sanity floor for the gap: the eager path should
49+
be at least an order of magnitude heavier than the lazy chunk,
50+
otherwise the lazy path isn't actually lazy.
51+
"""
52+
ctx = XarrayContext()
53+
ctx.from_dataset("air", air_source, chunks={"time": 24})
54+
t0 = air_source["time"].values[0]
55+
56+
out = ctx.sql('SELECT * FROM "air"').to_dataset()
57+
chunk, chunk_peak = _peak_mb(lambda: out["air"].sel(time=t0).load())
58+
assert chunk.size == air_source.sizes["lat"] * air_source.sizes["lon"]
59+
assert chunk_peak < 10.0, (
60+
f"lazy single-chunk access should stay under 10 MB on "
61+
f"air_temperature, got {chunk_peak:.2f} MB"
62+
)
63+
64+
_, eager_peak = _peak_mb(
65+
lambda: ctx.sql('SELECT * FROM "air"').to_dataset(chunks=None)
66+
)
67+
assert eager_peak > 50.0, (
68+
f"eager whole-grid materialization should peak above 50 MB; "
69+
f"if it doesn't, the eager path may have silently gone lazy "
70+
f"and the lazy assertion above no longer means anything. "
71+
f"Got {eager_peak:.1f} MB"
72+
)
73+
assert eager_peak / max(chunk_peak, 0.1) > 10.0, (
74+
f"eager peak should be at least 10x the lazy chunk peak; "
75+
f"if it isn't, the lazy path isn't actually lazy. "
76+
f"Got eager={eager_peak:.1f} MB, lazy={chunk_peak:.2f} MB"
77+
)
78+
79+
80+
def test_streaming_aggregation_does_not_explode(air_source):
81+
"""A ``GROUP BY`` reducing the long axis streams in a single pass.
82+
83+
Reduces 3.86M rows of ``air_temperature`` to ~1.3K group cells. The
84+
aggregation must stream the source once and emit a tiny result, not
85+
buffer the entire row set into memory. Reference observation: peak
86+
~54 MB on a 31 MB dense source (within 2x, well below a "buffer
87+
the whole thing twice" failure mode). Threshold is 4x source size
88+
so transient pandas / DataFusion buffers fit.
89+
"""
90+
ctx = XarrayContext()
91+
ctx.from_dataset("air", air_source, chunks={"time": 24})
92+
source_mb = air_source.nbytes / 1e6
93+
94+
agg, agg_peak = _peak_mb(
95+
lambda: ctx.sql(
96+
'SELECT lat, lon, AVG(air) AS air_avg FROM "air" GROUP BY lat, lon'
97+
).to_dataset(dims=["lat", "lon"])
98+
)
99+
assert agg.sizes["lat"] * agg.sizes["lon"] == (
100+
air_source.sizes["lat"] * air_source.sizes["lon"]
101+
)
102+
assert agg_peak < 4 * source_mb, (
103+
f"GROUP BY reduction should not balloon past 4x source size; "
104+
f"got peak={agg_peak:.1f} MB on a {source_mb:.1f} MB source"
105+
)
106+
107+
# Sanity: values agree with the xarray-native reduction.
108+
ref = (
109+
air_source.compute()
110+
.mean(dim="time")["air"]
111+
.sortby(["lat", "lon"])
112+
.values
113+
)
114+
got = agg.sortby(["lat", "lon"])["air_avg"].values
115+
np.testing.assert_allclose(got, ref, rtol=1e-5)

0 commit comments

Comments
 (0)