Skip to content

Refactor the per-cell aggregation handoff: sort/hash grouping + Arrow path (additive, benchmarked) #30

Description

@espg

Refactor the per-cell aggregation handoff: sort/hash grouping + Arrow path (additive, benchmarked)

Summary

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.

What process_shard does today (and the costs)

As of main (@ 83b92ab), src/zagg/processing.py:

  1. _read_group reads h5coro datasets (numpy) per beam, spatial-filters, wraps in
    pd.DataFrame(data_dict).
  2. df_all = pd.concat(all_dataframes, ignore_index=True) across the 6 beams.
  3. cell_col = grid.cells_of(df_all["leaf_id"].values) → cell id per observation.
  4. 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.
  5. calculate_cell_statistics pulls df_cell[col].values (numpy) and runs numpy reductions or an
    eval'd expression; each variable is float(func(...)) (scalar — see Support non-scalar aggregation outputs (vectors + ragged per-cell payloads) #29).
  6. 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)

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

Acceptance criteria

  • The grouping is O(n_obs log n_obs), not O(n_children × n_obs); scalar outputs unchanged
    byte-for-byte.
  • Both pandas and Arrow paths produce byte-for-byte identical scalar outputs.
  • A benchmark table reports wall-time + peak memory for pandas vs Arrow on a real shard.
  • Pandas remains the default; the Arrow path is opt-in. No change to HEALPix/ATL06 results.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions