Skip to content

Commit 6eed3c3

Browse files
committed
Dictionary-encode coordinate columns
A dense grid repeats every coordinate value across the whole partition (a chunk of shape (time, lat, lon) carries each latitude time×lon times). The reader materialized coordinate columns as dense, fully-repeated Arrow arrays, so every GROUP BY / JOIN on a coordinate re-hashed a hugely redundant column and the pivot moved far more bytes than the data itself. Encode coordinate columns as Arrow dictionaries: the distinct coordinate values are the dictionary and the strided per-row indices we already compute are the dictionary indices — no broadcast of repeated values. The index type is sized to the dimension length (int8/int16/int32), so a 6-step time chunk uses 1 byte/row and a 721/1440-point lat/lon uses 2. On an ERA5-shaped chunk the coordinate columns shrink ~4.8x (and equality GROUP BY / JOIN keys become small integers). - df.py: _parse_schema declares dimension coordinates as dictionary(index, value) with the value type/metadata preserved; iter_record_batches and dataset_to_record_batch emit DictionaryArrays. - src/lib.rs: keep partition pruning and exact statistics working on the new encoding — DataFusion coerces a coordinate filter to either a Dictionary literal (timestamp) or Cast(col AS value_type) (float), so compare_to_scalar unwraps Dictionary scalars, the pruning matchers strip decode casts, and bound_to_scalar / total_byte_size unwrap the dictionary value type. - tests: pin the encoding contract and the group-by round-trip; update schema and memory-characterization expectations to the (smaller) encoded columns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VuSeCio99NcME5eubcN3N
1 parent f085271 commit 6eed3c3

5 files changed

Lines changed: 234 additions & 37 deletions

File tree

src/lib.rs

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ impl ScalarBound {
9393
/// Compare this bound with a DataFusion ScalarValue.
9494
/// Returns None if types are incompatible.
9595
fn compare_to_scalar(&self, scalar: &ScalarValue) -> Option<std::cmp::Ordering> {
96+
// Coordinate columns are dictionary-encoded, so DataFusion coerces the
97+
// comparison literal to a `Dictionary` scalar (the column stays a bare
98+
// Column). Unwrap it to the underlying value and compare against that,
99+
// so partition pruning keeps working on dictionary coordinates.
100+
if let ScalarValue::Dictionary(_, value) = scalar {
101+
return self.compare_to_scalar(value);
102+
}
96103
match (self, scalar) {
97104
// Integer comparisons
98105
(ScalarBound::Int64(a), ScalarValue::Int64(Some(b))) => Some(a.cmp(b)),
@@ -265,8 +272,11 @@ impl PrunableStreamingTable {
265272
right: &Expr,
266273
meta: &PartitionMetadata,
267274
) -> bool {
268-
// Try to extract column and literal from either side
269-
let (col_name, scalar, flipped) = match (left, right) {
275+
// Try to extract column and literal from either side. Coordinate
276+
// columns are dictionary-encoded, so a filter may arrive as
277+
// `Cast(col AS value_type) op literal`; strip the (lossless) decode cast
278+
// to reach the column.
279+
let (col_name, scalar, flipped) = match (strip_cast(left), strip_cast(right)) {
270280
(Expr::Column(c), Expr::Literal(s, _)) => (c.name.clone(), s, false),
271281
(Expr::Literal(s, _), Expr::Column(c)) => (c.name.clone(), s, true),
272282
_ => return false, // Not a simple column-literal comparison
@@ -348,8 +358,8 @@ impl PrunableStreamingTable {
348358
return false;
349359
}
350360

351-
// Extract column name
352-
let col_name = match between.expr.as_ref() {
361+
// Extract column name (peeling any dictionary-decode cast)
362+
let col_name = match strip_cast(between.expr.as_ref()) {
353363
Expr::Column(c) => c.name.clone(),
354364
_ => return false,
355365
};
@@ -387,8 +397,8 @@ impl PrunableStreamingTable {
387397
return false;
388398
}
389399

390-
// Extract column name
391-
let col_name = match in_list.expr.as_ref() {
400+
// Extract column name (peeling any dictionary-decode cast)
401+
let col_name = match strip_cast(in_list.expr.as_ref()) {
392402
Expr::Column(c) => c.name.clone(),
393403
_ => return false,
394404
};
@@ -440,13 +450,33 @@ impl PrunableStreamingTable {
440450

441451
/// Check if an expression references a dimension column.
442452
fn expr_references_dimension(&self, expr: &Expr) -> bool {
443-
match expr {
453+
match strip_cast(expr) {
444454
Expr::Column(c) => self.dimension_columns.contains(&c.name),
445455
_ => false,
446456
}
447457
}
448458
}
449459

460+
/// Peel `Cast`/`TryCast` wrappers to reach the inner expression.
461+
///
462+
/// Dictionary-encoded coordinate columns are decoded with a lossless
463+
/// `CAST(col AS value_type)` before a comparison, so a filter on a coordinate
464+
/// can arrive as `Cast(Column) op literal`. Stripping the cast lets pruning see
465+
/// the underlying column; the cast targets the dictionary's own value type, so
466+
/// it never changes the compared value. If a cast were ever lossy, the bound
467+
/// comparison would simply fail to match and the partition is conservatively
468+
/// kept — pruning never becomes wrong, only (at worst) less effective.
469+
fn strip_cast(expr: &Expr) -> &Expr {
470+
let mut current = expr;
471+
loop {
472+
match current {
473+
Expr::Cast(cast) => current = &cast.expr,
474+
Expr::TryCast(cast) => current = &cast.expr,
475+
_ => return current,
476+
}
477+
}
478+
}
479+
450480
/// Extension trait for partition streams that support column projection.
451481
///
452482
/// Implemented by `PyArrowStreamPartition` so that `PrunableStreamingTable`
@@ -702,6 +732,12 @@ fn fold_bound(a: &ScalarBound, b: &ScalarBound, keep_min: bool) -> Option<Scalar
702732
/// unit we can't scale exactly), in which case the column is left without
703733
/// min/max rather than risk a wrong value.
704734
fn bound_to_scalar(bound: &ScalarBound, dtype: &DataType) -> Option<ScalarValue> {
735+
// Coordinate columns are dictionary-encoded; their min/max is the min/max of
736+
// the underlying values, so report a value-type scalar (what DataFusion uses
737+
// for dictionary-column statistics).
738+
if let DataType::Dictionary(_, value_type) = dtype {
739+
return bound_to_scalar(bound, value_type);
740+
}
705741
match (bound, dtype) {
706742
(ScalarBound::Int64(v), DataType::Int64) => Some(ScalarValue::Int64(Some(*v))),
707743
(ScalarBound::Int64(v), DataType::Int32) => {
@@ -736,6 +772,19 @@ fn bound_to_scalar(bound: &ScalarBound, dtype: &DataType) -> Option<ScalarValue>
736772
}
737773
}
738774

775+
/// Fixed byte width of one value of `dtype`, or `None` if it is variable-width.
776+
///
777+
/// Dictionary-encoded coordinate columns are sized by their *value* type: the
778+
/// statistic estimates the logical data volume (a conservative upper bound on
779+
/// the encoded column's real footprint), and keeps the reported size stable
780+
/// whether or not a column is dictionary-encoded.
781+
fn fixed_width(dtype: &DataType) -> Option<usize> {
782+
match dtype {
783+
DataType::Dictionary(_, value_type) => fixed_width(value_type),
784+
other => other.primitive_width(),
785+
}
786+
}
787+
739788
/// Exact in-memory byte size of `num_rows` rows of `schema`, or `Absent` if any
740789
/// column is variable-width (e.g. Utf8) and cannot be sized from the row count
741790
/// alone. Our data model is dense fixed-width grids, so this is normally exact.
@@ -745,7 +794,7 @@ fn total_byte_size(schema: &Schema, num_rows: &Precision<usize>) -> Precision<us
745794
};
746795
let mut row_width = 0usize;
747796
for field in schema.fields() {
748-
match field.data_type().primitive_width() {
797+
match fixed_width(field.data_type()) {
749798
Some(w) => row_width += w,
750799
None => return Precision::Absent,
751800
}

tests/test_cft.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,18 +153,23 @@ class TestParseSchemaIntegration:
153153
def test_noleap_produces_timestamp_us(self, rasm_ds):
154154
schema = _parse_schema(rasm_ds[["Tair"]])
155155
time_field = schema.field("time")
156-
assert time_field.type == pa.timestamp("us")
156+
# Coordinate columns are dictionary-encoded; the cftime encoding lives
157+
# in the dictionary's value type (and the field metadata is preserved).
158+
assert pa.types.is_dictionary(time_field.type)
159+
assert time_field.type.value_type == pa.timestamp("us")
157160
assert time_field.metadata[b"xarray:calendar"] == b"noleap"
158161

159162
def test_360day_produces_int64(self, ds_360day):
160163
schema = _parse_schema(ds_360day)
161164
time_field = schema.field("time")
162-
assert time_field.type == pa.int64()
165+
assert pa.types.is_dictionary(time_field.type)
166+
assert time_field.type.value_type == pa.int64()
163167
assert time_field.metadata[b"xarray:calendar"] == b"360_day"
164168

165169
def test_datetime64_unchanged(self):
166170
ds = xr.tutorial.open_dataset("air_temperature")
167171
schema = _parse_schema(ds)
168172
time_field = schema.field("time")
169-
assert pa.types.is_timestamp(time_field.type)
173+
assert pa.types.is_dictionary(time_field.type)
174+
assert pa.types.is_timestamp(time_field.type.value_type)
170175
assert time_field.metadata is None # no xarray: metadata for native

tests/test_df.py

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -198,25 +198,28 @@ def test_dataset_to_record_batch_matches_pivot(air_small):
198198
we sort by the coordinate columns before comparing.
199199
"""
200200
schema = _parse_schema(air_small)
201+
schema_cols = [f.name for f in schema]
201202
dim_cols = [f.name for f in schema if f.name in air_small.dims]
202203
blocks = list(
203204
block_slices(air_small, chunks={"time": 4, "lat": 3, "lon": 4})
204205
)
205206

206207
for block in blocks:
207208
ds_block = air_small.isel(block)
208-
actual_df = (
209-
dataset_to_record_batch(ds_block, schema)
210-
.to_pandas()
211-
.sort_values(dim_cols)
212-
.reset_index(drop=True)
213-
)
214209
expected_df = (
215-
pa.RecordBatch.from_pandas(pivot(ds_block), schema=schema)
216-
.to_pandas()
210+
pivot(ds_block)[schema_cols]
217211
.sort_values(dim_cols)
218212
.reset_index(drop=True)
219213
)
214+
actual_df = dataset_to_record_batch(ds_block, schema).to_pandas()[
215+
schema_cols
216+
]
217+
# Coordinate columns are dictionary-encoded, so they come back from
218+
# Arrow as pandas Categorical; decode them to the plain dtype pivot uses
219+
# before comparing values.
220+
for col in dim_cols:
221+
actual_df[col] = actual_df[col].astype(expected_df[col].dtype)
222+
actual_df = actual_df.sort_values(dim_cols).reset_index(drop=True)
220223

221224
pd.testing.assert_frame_equal(actual_df, expected_df, check_like=False)
222225

@@ -403,19 +406,21 @@ def test_read_xarray_loads_one_chunk_at_a_time(large_ds):
403406
peaks.append(cur_peak)
404407

405408
for size in sizes:
406-
# Observed range: 1.59–1.83× on macOS, up to ~2.7× on Linux
407-
# (glibc + Arrow allocate more intermediate buffers).
408-
# iter_record_batches holds data-variable arrays (≈1× chunk) while
409-
# yielding sub-batches, plus the current Arrow batch (≈0.65× chunk).
410-
assert chunk_size * 1.3 < size, f"size {size} unexpectedly low"
409+
# iter_record_batches holds the data-variable arrays (≈1× chunk)
410+
# while yielding sub-batches, plus the current Arrow batch. The
411+
# batch's coordinate columns are dictionary-encoded (only the
412+
# distinct values plus small int32 indices), so they add far less
413+
# than a broadcast column would — steady state sits just above 1×.
414+
assert chunk_size * 1.1 < size, f"size {size} unexpectedly low"
411415
assert chunk_size * 3.5 > size, f"size {size} unexpectedly high"
412416

413417
for peak in peaks:
414-
# Observed range: 1.84–3.28× on macOS, up to ~4.15× on Linux
415-
# (glibc + Arrow hold more intermediate buffers at peak).
416-
# Peak includes data arrays + Arrow batch + temporary coordinate index
417-
# arrays; the first batch of each chunk is highest (Dask compute overhead).
418-
assert chunk_size * 1.5 < peak, f"peak {peak} unexpectedly low"
418+
# Peak includes the data arrays + the current Arrow batch +
419+
# temporary coordinate index arrays; the first batch of each chunk
420+
# is highest (Dask compute overhead). Dictionary-encoded coordinate
421+
# columns keep the batch (and so the peak) smaller than a broadcast
422+
# column would.
423+
assert chunk_size * 1.3 < peak, f"peak {peak} unexpectedly low"
419424
assert chunk_size * 5.0 > peak, f"peak {peak} unexpectedly high"
420425

421426
assert max(peaks) < large_ds.nbytes

tests/test_dict_coords.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Coordinate columns are dictionary-encoded.
2+
3+
A dense grid repeats every coordinate value across the whole partition (a chunk
4+
of shape ``(time, lat, lon)`` carries each latitude ``time × lon`` times).
5+
Encoding coordinate columns as Arrow dictionaries keeps only the distinct values
6+
plus small integer indices, which shrinks the bytes the engine moves and lets
7+
``GROUP BY`` / equality ``JOIN`` on coordinates compare integer keys. These
8+
tests pin that the coordinates are dictionary-encoded end to end and that the
9+
values still round-trip correctly.
10+
"""
11+
12+
import numpy as np
13+
import pyarrow as pa
14+
import xarray as xr
15+
16+
from xarray_sql import XarrayContext
17+
from xarray_sql.df import _parse_schema, block_slices, iter_record_batches
18+
19+
20+
def _grid() -> xr.Dataset:
21+
return xr.Dataset(
22+
{"v": (("time", "lat", "lon"), np.random.rand(6, 4, 5))},
23+
coords={
24+
"time": xr.date_range("2020-01-01", periods=6, freq="D"),
25+
"lat": np.array([-90.0, -30.0, 30.0, 90.0]),
26+
"lon": np.array([0.0, 72.0, 144.0, 216.0, 288.0]),
27+
},
28+
)
29+
30+
31+
def test_coordinate_fields_are_dictionary_encoded():
32+
"""Dimension coordinates are dictionary-typed; data variables are not."""
33+
schema = _parse_schema(_grid())
34+
for dim in ("time", "lat", "lon"):
35+
assert pa.types.is_dictionary(schema.field(dim).type), dim
36+
assert not pa.types.is_dictionary(schema.field("v").type)
37+
38+
39+
def test_iter_record_batches_emits_dictionary_coords():
40+
"""A coordinate column arrives as a DictionaryArray with correct values."""
41+
ds = _grid()
42+
schema = _parse_schema(ds)
43+
block = next(block_slices(ds, chunks={"time": 6}))
44+
batch = next(iter_record_batches(ds.isel(block), schema, batch_size=1024))
45+
46+
lat = batch.column(batch.schema.names.index("lat"))
47+
assert isinstance(lat, pa.DictionaryArray)
48+
# The dictionary holds only the distinct latitudes; decoding reproduces the
49+
# per-row values in row-major order.
50+
assert lat.dictionary.to_pylist() == [-90.0, -30.0, 30.0, 90.0]
51+
decoded = lat.to_numpy(zero_copy_only=False)
52+
assert decoded.shape == (batch.num_rows,)
53+
# First lon×... rows share lat=-90 (lat varies slower than lon in C order).
54+
assert decoded[0] == -90.0
55+
56+
# Data variables stay plain (not dictionary-encoded).
57+
v = batch.column(batch.schema.names.index("v"))
58+
assert not isinstance(v, pa.DictionaryArray)
59+
60+
61+
def test_groupby_coordinate_roundtrips_through_dictionary():
62+
"""GROUP BY on a dictionary coordinate returns the same numbers as xarray."""
63+
ds = _grid()
64+
ctx = XarrayContext()
65+
ctx.from_dataset("grid", ds, chunks={"time": 3})
66+
67+
got = ctx.sql(
68+
"SELECT lat, AVG(v) AS m FROM grid GROUP BY lat ORDER BY lat"
69+
).to_dataset(dims=["lat"])
70+
ref = ds["v"].mean(["time", "lon"])
71+
72+
xr.testing.assert_allclose(
73+
got.m, ref.reindex_like(got.m), rtol=1e-6, atol=1e-9
74+
)

0 commit comments

Comments
 (0)