Skip to content

Comet's explode is slower than Spark when the element is a struct, worst on nested rows #5685

Closed

Description

@andygrove

What is the problem the feature request solves?

CometExplodeExec is slower than Spark's GenerateExec whenever the exploded element is a struct, and the gap is largest on the deeply nested, wide row shapes that motivate a generator in the first place.

Measured with CometExplodeBenchmark (added in #5381) on an Apple M3 Max, JDK 17, local[1], Spark 4.1 / Scala 2.13, release native build. These are whole-query times including the Parquet scan and the counting aggregate, so they understate the operator's share (Best ms):

Case Spark Comet Relative
explode array<struct<a bigint, b string>>[10] 61 86 0.7X
explode array<struct>[4] at depth 1 69 156 0.4X
explode array<struct>[4] at depth 8 84 167 0.5X
explode array<struct>[4] at depth 8, then its inner array 96 181 0.5X

For contrast, over array<bigint> at fan-out 10 Comet is 1.2X faster, and at fan-out 2 it is 1.5X faster. So this is not a general generator problem — it tracks the element type.

The nesting itself is cheap. Going from depth 1 to depth 8 costs Comet 11ms and Spark 15ms, and nested schema pruning narrows the scan to exactly the events path either way, so both depths read the same leaf payload. What costs is the element being a wide struct: the depth-8 element here is struct<platform string, entries array<struct<type string, ts bigint, page string, source string>>>, and the plain array<struct<bigint, string>> case shows the same direction at a smaller magnitude.

The nested cases are the most reproducible numbers in the whole benchmark — across five runs Comet's arm varied by a few ms while Spark's swung under machine contention.

Describe the potential solution

ExplodeExec is Comet's fork of DataFusion's UnnestExec, and it gathers the unnested column through a take-index buffer sized to the output. For a struct element that gather recurses into every field, which is where the cost concentrates.

#5667 is in flight and replaces the gather with a slice of the child when the take indices would be a contiguous run, which covers plain explode and both columns of posexplode. Its Rust-level benchmark shows explode_element_type/struct down 91%, so it should close a large part of this. This issue is for whatever remains after that lands, and for the cases #5667 deliberately does not reach:

  • explode_outer / posexplode_outer still gather, because a NULL or empty row is padded with a NULL that no slice of the child contains.
  • Carried passthrough columns are still a real gather.
  • Whether a struct element benefits from slicing as much as a primitive one needs measuring rather than assuming, since ListArray::slice leaves values whole and a struct's field arrays are sliced independently.

Additional context

The benchmark's nested group deliberately counts the whole event struct rather than one of its fields, so nested schema pruning cannot narrow the exploded element and leave the case measuring a two-column gather.

A generator over a map cannot be measured against Comet at all yet — see #2837 — so the shape a real schema would use for this data (a map keyed by platform) is an array<struct<platform, entries>> in the benchmark.

Activity

  1. andygrove commented on Sep 4, 2026

    @andygrove
    MemberAuthor

    #5667 is now rebased on merged main. I measured what it does to the cases above, so this issue can be scoped to what is left rather than guessed at.

    CometExplodeBenchmark, main at 719cba110 against the rebased branch, both built and run back to back on one machine: Apple M3 Ultra, JDK 17, local[1], Spark 4.1 / Scala 2.13, release native build (Best ms):

    Case Spark Comet before Comet after Before After
    explode array<struct<a bigint, b string>>[10] 57 65 54 0.9X 1.1X
    explode array<struct>[4] at depth 1 63 114 97 0.6X 0.7X
    explode array<struct>[4] at depth 8 77 117 100 0.7X 0.8X
    explode array<struct>[4] at depth 8, then its inner array 83 155 115 0.5X 0.7X

    The Spark arm moves by at most 3ms between the two runs across the whole benchmark, so it holds as a control and the movement is Comet's.

    The flat array<struct> case, the one in the title, crosses over: 0.9X to 1.1X. The three nested cases improve by 15% to 26% and stay behind Spark at 0.7X to 0.8X. So #5667 closes the first row and narrows the rest without closing them, which is what this issue anticipated. It should stay open for the nested gap.

    The numbers here are on a different machine from the ones in the issue body, which were an M3 Max, so read the before/after columns against each other rather than against the table above.

    One thing outside this issue that the same run turned up: explode array<bigint>[100] was also behind Spark on main (148ms against 127, 0.9X) and is 112ms / 1.1X after. That was not visible in the original M3 Max run, where the case was not regressed.

  2. andygrove commented on Sep 4, 2026

    @andygrove
    MemberAuthor

    I decomposed the depth-1 nested case to find out where the remaining time actually goes, and the answer reframes this issue: the generator is a small part of it. The dominant term is the nested Parquet scan, which is #4859.

    All numbers below are the same machine and build as my previous comment (Apple M3 Ultra, JDK 17, local[1], Spark 4.1 / Scala 2.13, release native, 100K rows of the benchmark's nested view), on this branch with #5667 applied. Best ms.

    Stripping the case down

    Query Spark Comet
    count(k), flat column only 17 10 1.6X
    count(events), scan the whole struct, no generator at all 60 92 0.7X
    plus explode, no carried columns 61 111 0.6X
    plus carried k, region, which is this issue's depth 1 case 63 113 0.6X

    Of Comet's 113ms, 92ms is already spent before any generator exists. ExplodeExec adds about 19ms and the carried-column gather about 2ms. Spark's generator adds close to nothing, because whole-stage codegen fuses it into the scan loop, but it is a small absolute number under either engine. Further work on the operator is chasing a fifth of the gap.

    It is not general scan overhead, it is nested pruning

    Comet wins on the flat column, so this is specific to nested reads. And this pair is the tell:

    Query Spark Comet
    count(events), 5 leaves 60 92 0.7X
    count(events.platform), 1 leaf 22 93 0.2X

    Spark drops to a third of its time when only one leaf is needed. Comet does not move at all. Both executed plans print the identical pruned ReadSchema: struct<events:array<struct<platform:string>>>, so the pruning reaches the plan and then has no effect on what Comet reads. That is #4859.

    What #5262 does to it

    Same queries built and run on apache/df55:

    Query Spark (main / df55) Comet on main Comet on df55
    count(events) whole struct 60 / 65 92 (0.7X) 59 (1.1X)
    count(events.platform) one leaf 22 / 25 93 (0.2X) 13 (1.9X)
    count(events.entries) inner array 61 / 63 93 (0.7X) 57 (1.1X)
    explode then count one leaf 23 / 34 95 (0.2X) 21 (1.6X)

    Pruning works there, and the whole-struct read also improves where there is nothing to prune, so DF55 helps the nested reader beyond leaf selection alone.

    One caveat for anyone repeating this: df55 has no explode.rs, it predates CometExplodeExec by 114 commits, so a generator query on that branch falls back to Spark's GenerateExec. I measured the full nested case at 0.5X there and discarded it as a different code path. Only the scan-only rows above are valid across the two branches.

    Suggested order

    1. Land deps: bump DataFusion 55.0 and Arrow/Parquet 59.2 #5262. Nothing available in ExplodeExec competes with 92ms to 59ms on the whole struct, or 93ms to 13ms on a pruned leaf.
    2. Re-measure this issue afterwards before doing more operator work. Composing what is measured above, DF55's 59ms scan plus main's roughly 19ms of explode against Spark's roughly 63ms, projects these cases to around 0.8X: better than today's 0.6X to 0.7X, and possibly still short of parity. Neither branch has both halves today, so nobody has seen the combined number yet.
    3. Only then is the residual worth attacking, and it would be the carried-column gather and the aggregate over the four-times-expanded batch rather than unnest_list_array.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions