Skip to content

Replace searchsorted with a hash index in the reverse-pivot scatter - #3

Open
Mmoncadaisla wants to merge 2 commits into
mainfrom
reverse-pivot-hash-index
Open

Replace searchsorted with a hash index in the reverse-pivot scatter#3
Mmoncadaisla wants to merge 2 commits into
mainfrom
reverse-pivot-hash-index

Conversation

@Mmoncadaisla

Copy link
Copy Markdown
Owner

Why

The reverse pivot (to_dataset) resolves each result row's dim-coord values to array positions before scatter-writing into the dense output. Irregular (non-uniformly-spaced) axes — station networks, arbitrary point sets — used np.argsort once plus np.searchsorted per batch: O(log n) per row. Every fast engine (DuckDB's PhysicalPivot, ClickHouse's aggregator, Daft's pivot) does this same value-to-slot resolution with a hash table built once and probed vectorized; this PR adopts that discipline via pd.Index.get_indexer, whose persistent hash table is built once per dimension and probed per batch: O(1) amortized per row.

Uniformly spaced axes keep the existing affine fast path, so regular-grid workloads (ERA5 lat/lon/time) are completely unaffected — verified: ordered/unordered ERA5 reconstruction ratios stay ~1.0 on DataFusion, DuckDB, and Polars.

Measured

Isolated reconstruction through xql.to_dataset on shuffled rows over an irregular axis (GCP n2-standard-16, median of 5):

cells searchsorted (main) hash (this PR) speedup
24 K 4.0 ms 1.9 ms 2.1x
240 K 40.4 ms 15.2 ms 2.7x
2.4 M 481 ms 162 ms 3.0x
6 M 1 945 ms 624 ms 3.1x
24 M 13 685 ms 4 168 ms 3.3x

Thread-scaling check (4 dask-style threads, 5 M probes over a 200 K-value axis): identical 3.7x scaling for both implementations — pandas' hash probe releases the GIL as well as searchsorted does — with the hash path ~7x faster per call.

What

The three position-resolution strategies now live in one place, _CoordLookup, built once per dimension per reconstruction:

  • affine formula for uniformly spaced axes (unchanged);
  • hash (pd.Index, built once, get_indexer per batch) for irregular axes with unique values;
  • the previous argsort+searchsorted for axes with duplicate values, which a unique-key hash table cannot represent — each value keeps resolving to one of the positions holding it.

Behavior change: a result value absent from an irregular unique axis now raises ValueError naming the dimension (previously a searchsorted misplacement surfaced as an opaque AssertionError deep in the scatter, or a silent wrong-cell write). Both real callers construct requested such that this cannot fire on well-formed results (_dataset_from_batches derives coords via pd.unique of the same batches; SQLBackendArray._raw_getitem windows come from the engine filter), so it only converts an existing latent corruption into a loud error.

Tradeoff, quantified: the pandas hash table holds ~34 bytes/value transient vs ~16 bytes/value for the sorted copy it replaces (measured on a 2 M-value float64 axis) — per non-affine dimension, per reconstruction call, freed with the call frame. Affine axes build neither.

Validation

  • New tests/test_coord_lookup.py pins each strategy on shuffled input, the descending-affine regression, NaN axis values, the duplicate-values fallback, the missing-value error, and an end-to-end to_dataset round-trip.
  • Adversarial review passes (dtype coercion incl. datetime64 unit mismatches, NaN/NaT, empty/len-1 axes, thread safety of per-call state, callers' reachability of the new ValueError) found no correctness refutation.
  • mypy and ruff check/format match main's baseline exactly.
  • Full-suite run on a clean VM build: in progress at time of opening; result will be posted as a comment. (The suite previously ran green minus known-unrelated failures on a superset branch containing this change.)

🤖 Generated with Claude Code

The reverse pivot resolves each result row's dim-coord values to array
positions before scatter-writing into the dense output. Irregular
(non-uniformly-spaced) axes previously used np.argsort once plus
np.searchsorted per batch: O(log n) per row. They now use a pd.Index
whose hash table is built once per dimension and probed per batch with
get_indexer: O(1) amortized per row. Measured 2.1-3.3x faster
reconstruction on shuffled irregular-axis results from 24K to 24M cells
(the speedup grows with axis cardinality); uniformly spaced axes keep
the existing affine fast path and regular-grid workloads (e.g. ERA5
lat/lon/time) are unaffected.

The three strategies now live in one place, _CoordLookup:

* affine formula for uniformly spaced axes (unchanged);
* hash index for irregular axes with unique values;
* the previous argsort+searchsorted for axes with duplicate values,
  which a unique-key hash table cannot represent.

A result value absent from the axis now raises a ValueError naming the
dimension (previously a searchsorted misplacement surfaced as an
opaque AssertionError or a silent wrong-cell write).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8af8d7dd72

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/test_coord_lookup.py Outdated
Comment thread tests/test_coord_lookup.py Outdated
Comment thread xarray_sql/ds.py Outdated
Comment thread xarray_sql/ds.py Outdated
…cate semantics

- Tests now exercise each lookup strategy through the public
  to_dataset contract (values, dims, coords) on shuffled rows, with
  dims inferred from the template. Only two behaviors stay at the
  _scatter_batches_to_ndarray seam, with the reason documented: the
  missing-value error and the duplicate-axis fallback, both
  unreachable through the eager public path because to_dataset derives
  each axis from the same rows it scatters.
- pd.Index construction falls back to sorted search for dtypes pandas
  cannot index (float16 raises NotImplementedError on pandas 2.3.0),
  preserving the previous behavior for those axes; regression-tested
  through to_dataset.
- The _CoordLookup docstring no longer claims to_dataset raises on
  duplicate dim tuples (no reconstruction path does); it now describes
  the actual behavior: sorted-search resolution to one of the holding
  positions plus the scatter's last-write-wins overwrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Mmoncadaisla

Copy link
Copy Markdown
Owner Author

Full test suite on a clean VM build (n2-standard-16, maturin release build): 323 passed, 2 skipped, 0 failed at 8af8d7d. Re-running on e1acfb7 (review-fix commit; delta is tests, docstrings, and the float16 index guard) to cover the exact head — will confirm here.

@Mmoncadaisla

Copy link
Copy Markdown
Owner Author

@codex review

@Mmoncadaisla

Copy link
Copy Markdown
Owner Author

Confirmed: full suite on a clean VM build at head e1acfb7322 passed, 2 skipped, 0 failed (the count delta vs 8af8d7d is the test-file consolidation from 7 to 6 tests, not a lost test).

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: e1acfb7f18

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant