Skip to content

perf: slice the child instead of gathering it when unnesting - #5667

Draft
andygrove wants to merge 7 commits into
apache:mainfrom
andygrove:explode-perf
Draft

perf: slice the child instead of gathering it when unnesting#5667
andygrove wants to merge 7 commits into
apache:mainfrom
andygrove:explode-perf

Conversation

@andygrove

Copy link
Copy Markdown
Member

Which issue does this PR close?

N/A — no existing issue. Happy to file one if reviewers would rather track it that way.

Note

Stacked on #5381. The first commit here is that PR's benchmark, which is what
measures the end-to-end effect of the rest. Review the last two commits; this needs a
rebase once #5381 lands. The diff against main will shrink to the native changes then.

Rationale for this change

ExplodeExec is a fork of DataFusion's UnnestExec, and it inherited kernels written to be
generic over every list type DataFusion supports. Comet plans exactly one shape: a List,
depth 1, one column for explode and two for posexplode. Three costs in the general path are
avoidable at that shape.

The largest is in unnest_list_array. Unnesting a single list column pads nothing, so the take
indices it builds — one i64 per output element — are the contiguous run
offsets.first()..offsets.last(), and the gather through them reads the child straight through
in order. Rows of a ListArray are adjacent by construction, since row i is
[offsets[i], offsets[i + 1]), so the answer is already sitting in the child as one slice. The
operator was allocating an index buffer the size of its output and copying every element to
reproduce it.

Two smaller ones. predict_output_lens derives per-row lengths through find_longest_length,
which chains length, cast, is_not_null and zip to stay generic — four allocating passes
to subtract adjacent offsets, once per input batch, because length returns Int32 for List
and NULL rows need substituting. And create_take_indices appended the repeat indices one
element at a time through a builder that has no validity to track.

What changes are included in this PR?

unnest_list_array returns a slice of the child when the gather would be a contiguous run.
Comet takes this for plain explode, and for both columns of posexplode, whose position array
is built with the same per-row lengths and null mask. explode_outer still gathers: a NULL or
empty row is padded with a NULL that no slice of the child contains.

The guard is not just arithmetic. Checking that the offset span equals the capacity is not
sufficient, because Arrow permits a NULL list slot to span elements — the gather skips those and
a slice would include them — and such a row can cancel out padding elsewhere and leave the totals
agreeing with a run that is not the one to take. populated_null_row_falls_back_even_when_the_totals_agree
pins that case. The view types are rejected outright, since their per-row offsets are independent
and need not be ordered.

Worth noting for the guard's reach: it accepts NULL rows whose range is empty, which is what
builders and the Parquet reader emit. That matters because InferFiltersFromGenerate puts
size(arr) > 0 AND arr IS NOT NULL below every non-outer generator over a column, so a plain
explode in production is usually handed an array column with the NULL rows already filtered
out — but when it is not, the rows still contribute nothing and the run still holds.

predict_output_lens computes lengths in one pass for the single-List case and keeps
find_longest_length as the fallback. create_take_indices fills the buffer per run.

The module header said a change to the forked region "either belongs upstream or does not belong
at all". This PR deliberately reverses that: the specializations are for the shape Comet plans,
not upstream's, and the header now says so and says what retiring the fork would cost.

Trade-off

Output batches now alias the input's child buffer rather than owning a compacted copy, and
ListArray::slice leaves values whole, so the alias is to the child of the whole input batch,
not of the chunk. All chunks from one input batch share that buffer and between them fill it, so
there is no amplification when they are all retained; a downstream operator that keeps only some
of them pins all of it. That is bounded by one input batch's expansion, which pending_input
already holds materialized, and BatchSplitStream above it already hands out slices of the
input.

How are these changes tested?

Eight new unit tests in explode.rs cover the fast path — child aliasing, a non-zero offset base
from slicing, empty and dropped-NULL rows, the padded fallback, the populated-NULL-row case
above, and view input — plus equivalence of the fused length computation against
find_longest_length across NULL handling, empty batches, and slicing. The existing chunking
tests already assert that chunked output matches unchunked output exactly, which is what would
catch a fast path that fired when it should not have.

CometGenerateExecSuite (37 tests) and CometExecSuite (144 tests) pass.

Measurements

cargo bench --bench explode, added in the second commit, on an Apple M3 Max:

Case Change
explode_fan_out/2 −74%
explode_fan_out/10 −82%
explode_fan_out/100 −90%
explode_element_type/bigint −82%
explode_element_type/string −91%
explode_element_type/struct −91%
explode_carried_columns/0 −82%
explode_carried_columns/3 −60%
explode_outer_with_nulls/bigint −13%
explode_outer_with_nulls/string −8%

The outer cases keep gathering, so they get only the length and index changes. The carried-column
case improves least of the sliceable ones because replicating the passthrough columns is a real
gather that this does not touch.

CometExplodeBenchmark from #5381, same machine, local[1], Spark 4.1 / Scala 2.13. These are
whole-query times including the Parquet scan and the counting aggregate, so they are the diluted
view (Best ms):

Case Spark before / after Comet before / after Relative before / after
fan-out 2 42 / 41 25 / 23 1.7X / 1.8X
fan-out 10 59 / 58 34 / 29 1.7X / 2.0X
fan-out 100 239 / 231 180 / 136 1.3X / 1.7X
posexplode 55 / 54 40 / 34 1.4X / 1.6X
explode_outer 55 / 55 40 / 36 1.4X / 1.5X
posexplode_outer 56 / 57 48 / 45 1.2X / 1.3X
element bigint 54 / 55 34 / 28 1.6X / 2.0X
element string 81 / 82 53 / 43 1.5X / 1.9X
element struct 91 / 92 77 / 67 1.2X / 1.4X
explode alone 71 / 72 44 / 38 1.6X / 1.9X
plus 3 carried columns 75 / 73 53 / 49 1.4X / 1.5X

The Spark arm is unchanged across every case, which is the control: the two runs are comparable
and the movement is Comet's.

Measures CometExplodeExec against Spark's GenerateExec across the four
dimensions that drive generator cost: fan-out (array length 2, 10, 100),
generator variant (explode, posexplode, and their outer forms), element
type (bigint, string, struct), and the number of columns replicated
alongside the generated one.

One in ten rows holds a null array and another one in ten holds an empty
array, so the outer variants do different work from the plain ones rather
than measuring the same query twice.

Each array column gets its own temp view so that no case is charged for
scanning a column it does not read.
Inherit the session from CometBenchmarkBase instead of copying the
override a fifth time. The copy differed from the base only in using
local[5], while silently dropping the base defaults for the vectorized
reader, whole-stage codegen, the Comet toggles, and ANSI mode, and
carrying a shuffle-partitions setting that no query here shuffles.

Also drive view creation and cleanup from one dataset table rather than
maintaining the view names in two places, drop generator aliases that no
measurement depends on, and attribute the whole-query-total caveat to the
harness with a pointer to apache#5363.
Four fixes, each to a measurement that did not isolate what its case
claimed to.

Count the generated columns instead of writing them. `.noop()` writes
`InternalRow`, so the Comet arm was converting every generated row and
the Spark arm, whose `GenerateExec` already emits rows, was not: 419K
conversions at fan-out 2 against 21M at fan-out 100, scaling with the
dimension the group exists to measure. Terminating in an aggregate puts
the only row boundary above the final exchange.

Exclude `InferFiltersFromGenerate` for both engines. It matches on
`outer = false`, so it gave `explode` and `posexplode` 209,714 rows and
an extra filter while their outer variants got all 262,144, and the rate
was normalized on rows the non-outer arms never saw.

Give the struct dataset the same string field as the string dataset.
`s1` through `s10` stayed under the writer's dictionary page threshold
where 260,000-odd distinct values do not, so the element-type group was
also comparing a dictionary-encoded column against a plain one.

Equalize the scan between the carried-column cases with an always-true
filter over k, s and v. Column pruning drops them from the generator's
input, so `explode alone` still does not replicate them, but the scan
reads and decodes all four columns in both cases rather than three fewer
in one of them.

Every counted column is now nullable. `NullPropagation` rewrites
`count(c)` to `count(1)` when `c` is not, which would leave the carried
columns unreferenced and let pruning drop them before the generator --
the whole dimension.
Addresses the second round of review.

The position cases now end in `sum(pos)` rather than `count(pos)`. `pos` is declared
non-null, so `NullPropagation` rewrote the count to `count(1)` and no position value
was ever read -- which matters here because Spark hands the generator its existing
loop index while Comet materializes a parallel List<Int32> through `ListPositionsExpr`
and unnests it alongside the values.

Every case now declares the aggregate row it must produce, and both engines are run
against it untimed before the case is timed. A sink that stops reading what it names
still reports a rate; this makes the next one fail loudly instead.

Adds an `Explode - nested input` group for the wide, deeply nested shape asked for in
review: a customer profile whose event list sits eight struct accessors down, with a
second array of four-field structs inside each element, over 100K rows. The requested
outer container was a map; that is an array of structs here because Comet has no native
generator over maps yet (apache#2837), so the Comet arm of a map case would silently be Spark.
Measures the operator over in-memory batches, so a change to the
unnesting kernels is not diluted by the Parquet scan and the aggregate
that CometExplodeBenchmark necessarily includes.
Unnesting a single list column pads nothing, so `unnest_list_array` was
building an index array holding one i64 per output element and then
gathering the child through it, when the indices it built were the
contiguous run the elements already sit in. Return a slice of the child
instead. Comet takes this path for plain `explode` and for both columns
of `posexplode`, whose position array is built with the same per-row
lengths; `explode_outer` still gathers, since a NULL or empty row is
padded and breaks the run.

Guarding on the offset span alone is not enough. Arrow permits a NULL
list slot to span elements, which the gather skips and a slice would
not, and such a row can cancel out padding elsewhere and leave the
totals agreeing with a run that is not the one to take. So the check
also rejects a NULL row holding elements, and the view types, whose
per-row offsets are independent.

Two smaller things on the way past. `predict_output_lens` derived its
per-row lengths through `find_longest_length`, which chains `length`,
`cast`, `is_not_null` and `zip` to stay generic over list types: four
allocating passes to subtract adjacent offsets, once per input batch.
Comet only plans `List`, so take that case in one pass and keep the
general version as the fallback. And `create_take_indices` appended the
repeat indices one element at a time through a builder that has no
validity to track; fill the buffer per run instead.

Measured with the new native benchmark, on an Apple M3 Max:

  explode_fan_out/2                   -74%
  explode_fan_out/10                  -82%
  explode_fan_out/100                 -90%
  explode_element_type/bigint         -82%
  explode_element_type/string         -91%
  explode_element_type/struct         -91%
  explode_carried_columns/0           -82%
  explode_carried_columns/3           -60%
  explode_outer_with_nulls/bigint     -13%
  explode_outer_with_nulls/string      -8%

The outer cases keep gathering, so they get only the length and index
changes.
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