You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Rework the per-shard aggregation hot path in processing.py to (1) replace the per-child
boolean-mask loop with a single sort/hash-based group split, and (2) introduce an Arrow
table handoff alongside the existing pandas one so we can benchmark representation cost head to
head. Pandas is retained in this issue — the goal is measured comparison, not removal. The
pandas→Arrow cutover (and pandas removal) is a deferred follow-up gated on the benchmark.
This lands ahead of #29 (non-scalar outputs): #29's cell→table handoff is the exact seam under
test here, so a benchmarked answer + a cleaner grouping substrate should exist before #29 commits
its handoff design. Keeping the change additive means it does not block #29.
_read_group reads h5coro datasets (numpy) per beam, spatial-filters, wraps in pd.DataFrame(data_dict).
df_all = pd.concat(all_dataframes, ignore_index=True) across the 6 beams.
cell_col = grid.cells_of(df_all["leaf_id"].values) → cell id per observation.
Per-child loop:for i, child in enumerate(children): df_cell = df_all[cell_col == child]
→ calculate_cell_statistics(df_cell). This is O(n_children × n_obs) — a HEALPix O6→O12
shard scans ~4,096 boolean masks over the full obs array, and boolean indexing copies the
matched rows + builds a fresh DataFrame object per cell.
df_out = pd.DataFrame({var: stats_arrays[var]}) → write_dataframe_to_zarr reshapes each
column to grid.chunk_shape and array.set_block_selection(chunk_idx, values).
Pandas here is a thin columnar wrapper: no groupby, no joins — just concat, boolean-mask
selection, and .values. pyarrow is already a dependency; the catalog layer
(catalog/sources.py) is already Arrow/stac-geoparquet, so the per-cell handoff is the only
remaining pandas stack.
Two separable levers (keep them decoupled)
Lever 1 — grouping algorithm (backend-independent, behavior-preserving)
Replace the per-child mask loop with a single group split: sort cell_col once, find run
boundaries (np.diff/np.flatnonzero), and iterate contiguous slices — O(n_obs log n_obs)
instead of O(n_children × n_obs). (This is exactly agg_benchmark.build_groups.) Pure win, no
representation change, byte-for-byte identical outputs.
Lever 2 — representation (pandas vs Arrow, benchmarked, additive)
Add an Arrow handoff path behind a flag/config (default stays pandas), and measure it against
pandas on a real shard. Two parts:
Hybrid kernel/loop: route the kernel-able stats (count, min/max, mean, var, and tdigest) through pyarrow.compute hash-aggregate kernels (Table.group_by().aggregate()),
which vectorize the whole grouping in one call; keep the per-group loop only for the genuinely
custom fields (expression, weighted average).
Zero-copy extraction rule (implementation note): the custom/expression aggregations are the
same numpy math regardless of backend — they do not regress iff extraction stays zero-copy: combine_chunks() once after concat, group by contiguous slice (not scattered take), then column.to_numpy(zero_copy_only=True). The footgun that would make them slower: chunked or
null-bearing columns, or scattered take, force a per-cell copy (back to pandas-level cost, not
worse). At our density (median ~16 obs/cell) per-cell Python overhead dominates either way, so
the win is moving kernel-able stats out of the loop, not the array extraction itself.
Why bother (the case for eventually landing Arrow)
Consistency: catalog is already Arrow; this removes the second dataframe stack and the pandas==2.2.3 hard pin.
Memory: lighter than pandas (no index/object overhead) — relevant to the 2 GB Lambda budget.
Proposed work
Lever 1: sort/hash group split replacing the per-child mask loop in process_shard.
Byte-for-byte verified against current scalar outputs.
Pass numpy slices, not sub-DataFrames, into calculate_cell_statistics (drop per-cell
DataFrame construction); keep the function's numpy/eval math unchanged.
Lever 2 (additive): an Arrow handoff path (config/flag; pandas remains default) — _read_group returns an Arrow table, concat_tables + combine_chunks, hybrid
kernel/loop aggregation, zero-copy slice extraction.
Benchmark harness: one real shard (e.g. an ATL03/ATL06 HEALPix shard), measure wall-time and peak RSS for {pandas, arrow} × {old loop, new grouping}, asserting outputs
are byte-for-byte equal. Report a small table.
Write path:write_dataframe_to_zarr already only needs name → ndarray; adapt it (or a
thin sibling) to accept an Arrow table / dict of arrays so it is handoff-agnostic.
Scope / non-goals
In: grouping refactor, an additive Arrow handoff path, a benchmark, byte-for-byte parity.
Land this ahead of #29, additive. #29 then builds non-scalar outputs on the cleaner grouping
and consumes this benchmark's conclusion for its handoff (Arrow nested types vs pandas object
columns). The pandas removal is a separate later cleanup, not part of either issue.
References
Hot path: src/zagg/processing.py (_read_group, process_shard per-child loop, calculate_cell_statistics, write_dataframe_to_zarr).
Refactor the per-cell aggregation handoff: sort/hash grouping + Arrow path (additive, benchmarked)
Summary
Rework the per-shard aggregation hot path in
processing.pyto (1) replace the per-childboolean-mask loop with a single sort/hash-based group split, and (2) introduce an Arrow
table handoff alongside the existing pandas one so we can benchmark representation cost head to
head. Pandas is retained in this issue — the goal is measured comparison, not removal. The
pandas→Arrow cutover (and pandas removal) is a deferred follow-up gated on the benchmark.
This lands ahead of #29 (non-scalar outputs): #29's cell→table handoff is the exact seam under
test here, so a benchmarked answer + a cleaner grouping substrate should exist before #29 commits
its handoff design. Keeping the change additive means it does not block #29.
What
process_sharddoes today (and the costs)As of
main(@ 83b92ab),src/zagg/processing.py:_read_groupreads h5coro datasets (numpy) per beam, spatial-filters, wraps inpd.DataFrame(data_dict).df_all = pd.concat(all_dataframes, ignore_index=True)across the 6 beams.cell_col = grid.cells_of(df_all["leaf_id"].values)→ cell id per observation.for i, child in enumerate(children): df_cell = df_all[cell_col == child]→
calculate_cell_statistics(df_cell). This is O(n_children × n_obs) — a HEALPix O6→O12shard scans ~4,096 boolean masks over the full obs array, and boolean indexing copies the
matched rows + builds a fresh DataFrame object per cell.
calculate_cell_statisticspullsdf_cell[col].values(numpy) and runs numpy reductions or aneval'dexpression; each variable isfloat(func(...))(scalar — see Support non-scalar aggregation outputs (vectors + ragged per-cell payloads) #29).df_out = pd.DataFrame({var: stats_arrays[var]})→write_dataframe_to_zarrreshapes eachcolumn to
grid.chunk_shapeandarray.set_block_selection(chunk_idx, values).Pandas here is a thin columnar wrapper: no
groupby, no joins — justconcat, boolean-maskselection, and
.values.pyarrowis already a dependency; the catalog layer(
catalog/sources.py) is already Arrow/stac-geoparquet, so the per-cell handoff is the onlyremaining pandas stack.
Two separable levers (keep them decoupled)
Lever 1 — grouping algorithm (backend-independent, behavior-preserving)
Replace the per-child mask loop with a single group split: sort
cell_colonce, find runboundaries (
np.diff/np.flatnonzero), and iterate contiguous slices —O(n_obs log n_obs)instead of
O(n_children × n_obs). (This is exactlyagg_benchmark.build_groups.) Pure win, norepresentation change, byte-for-byte identical outputs.
Lever 2 — representation (pandas vs Arrow, benchmarked, additive)
Add an Arrow handoff path behind a flag/config (default stays pandas), and measure it against
pandas on a real shard. Two parts:
count,min/max,mean,var, andtdigest) throughpyarrow.computehash-aggregate kernels (Table.group_by().aggregate()),which vectorize the whole grouping in one call; keep the per-group loop only for the genuinely
custom fields (
expression, weightedaverage).same numpy math regardless of backend — they do not regress iff extraction stays zero-copy:
combine_chunks()once after concat, group by contiguous slice (not scatteredtake), thencolumn.to_numpy(zero_copy_only=True). The footgun that would make them slower: chunked ornull-bearing columns, or scattered
take, force a per-cell copy (back to pandas-level cost, notworse). At our density (median ~16 obs/cell) per-cell Python overhead dominates either way, so
the win is moving kernel-able stats out of the loop, not the array extraction itself.
Why bother (the case for eventually landing Arrow)
pandas==2.2.3hard pin.substrate for the local-parallel and BYO-cluster backends.
FixedSizeList(vectors) andList/LargeList(ragged)carry the non-scalar per-cell payloads natively, vs awkward pandas object columns.
Proposed work
process_shard.Byte-for-byte verified against current scalar outputs.
calculate_cell_statistics(drop per-cellDataFrame construction); keep the function's numpy/
evalmath unchanged._read_groupreturns an Arrow table,concat_tables+combine_chunks, hybridkernel/loop aggregation, zero-copy slice extraction.
wall-time and peak RSS for {pandas, arrow} × {old loop, new grouping}, asserting outputs
are byte-for-byte equal. Report a small table.
write_dataframe_to_zarralready only needsname → ndarray; adapt it (or athin sibling) to accept an Arrow table / dict of arrays so it is handoff-agnostic.
Scope / non-goals
semantics, non-scalar outputs (Support non-scalar aggregation outputs (vectors + ragged per-cell payloads) #29), new grid types (Design doc for broadening input/output data structures #11). HEALPix/ATL06 outputs must stay
byte-for-byte identical throughout.
Acceptance criteria
O(n_obs log n_obs), notO(n_children × n_obs); scalar outputs unchangedbyte-for-byte.
Phasing relative to #29
Land this ahead of #29, additive. #29 then builds non-scalar outputs on the cleaner grouping
and consumes this benchmark's conclusion for its handoff (Arrow nested types vs pandas object
columns). The pandas removal is a separate later cleanup, not part of either issue.
References
src/zagg/processing.py(_read_group,process_shardper-child loop,calculate_cell_statistics,write_dataframe_to_zarr).src/zagg/catalog/sources.py. Deps:pyproject.toml(pyarrowpresent,pandas==2.2.3pinned).waveform_benchmarks/agg_benchmark.pybuild_groups(sort + boundary split).