Replace searchsorted with a hash index in the reverse-pivot scatter - #245
Draft
Mmoncadaisla wants to merge 2 commits into
Draft
Replace searchsorted with a hash index in the reverse-pivot scatter#245Mmoncadaisla wants to merge 2 commits into
Mmoncadaisla wants to merge 2 commits into
Conversation
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 — usednp.argsortonce plusnp.searchsortedper batch: O(log n) per row. Every fast engine (DuckDB'sPhysicalPivot, 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 viapd.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_dataseton shuffled rows over an irregular axis (GCP n2-standard-16, median of 5):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
searchsorteddoes — 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:pd.Index, built once,get_indexerper batch) for irregular axes with unique values;pd.Indexrejects (float16 raisesNotImplementedErroron pandas 2.3.0).Behavior change: a result value absent from an irregular unique axis now raises
ValueErrornaming the dimension (previously asearchsortedmisplacement surfaced as an opaqueAssertionErrordeep in the scatter, or a silent wrong-cell write). Both callers constructrequestedsuch that this cannot fire on well-formed results (_dataset_from_batchesderives coords viapd.uniqueof the same batches;SQLBackendArray._raw_getitemwindows 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
tests/test_coord_lookup.pyexercises each strategy through the publicto_datasetcontract on shuffled rows (irregular, descending-affine, NaN dim values, float16), plus two seam-level tests for behaviors unreachable through the eager public path (missing-value error, duplicate-axis fallback), with the reason documented.ValueError) found no correctness refutation.mypyandruff check/formatmatchmain's baseline exactly.🤖 Generated with Claude Code