test: add explode operator microbenchmark - #5381
Conversation
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.
sunchao
left a comment
There was a problem hiding this comment.
Summary
This first review evaluates the new explode benchmark at commit bf0324024d27985096bc590153110a3fe9b7c5df. The coverage dimensions are useful, but four benchmark-design issues prevent several reported comparisons from isolating the native explode operator.
Prior state and problem
CometExplodeExec previously lacked a dedicated Scala microbenchmark, making it difficult to characterize fan-out, generator variants, nested element types, and columns carried through generation. Existing expression benchmarks already include Parquet scan and result-transfer costs, which become especially significant for an operator that multiplies its input cardinality.
Design approach
The change adds a standalone CometExplodeBenchmark that materializes deterministic 256K-row Parquet-backed temporary views and compares Spark with Comet through the shared runExpressionBenchmark harness. Its cases cover fan-outs of 2, 10, and 100; four generator variants; bigint, string, and struct elements; and zero versus three carried columns.
Correctness / compatibility analysis
The generated data includes exactly 26,215 null arrays and 26,215 empty arrays, with 209,714 nonempty arrays, and the relevant native explode operator is enabled by default. However, Catalyst filters null and empty arrays before non-outer generators, the noop sink forces Comet's generated columnar output through row conversion, and mismatched source distributions and projected Parquet schemas introduce additional work that is unrelated to the intended operator comparisons.
Key design decisions
The benchmark intentionally stores each array shape in a separate Parquet-backed view, reports rates per original input row, and delegates warmup, iteration control, and Spark-versus-Comet configuration to the common benchmark helper. Null and empty arrays are deliberately included to distinguish outer generators, while the carried-column case reuses a wider source view.
Implementation sketch
arrayColumn builds typed arrays with deterministic null and empty branches; createView writes each generated dataset and registers a temporary view. Four runBenchmark groups then call runExpressionBenchmark, whose Spark and Comet cases execute the corresponding SQL query via Spark's row-oriented noop writer.
Behavioral changes worth calling out
This is a benchmark-only change and does not modify production explode semantics. The benchmark-only CI route compiles and lints against Spark 4.0, while the broader Spark integration matrix is intentionally skipped; reported rates use 262,144 original input rows even though optimized non-outer generators receive only 209,714 rows.
Suggested improvements
Keep exploded results columnar or use a matched conversion baseline; apply generator-filter optimization controls symmetrically so all variants receive the intended inputs; align string distributions and Parquet encoding across element-type datasets; and hold the projected scan schema constant, or subtract a matched scan baseline, when comparing carried columns. The inline P2 comments identify the exact affected cases and their concrete measurement consequences.
| "arr_len100" -> Seq(arrayColumn("id + x", 100)), | ||
| "arr_str10" -> Seq(arrayColumn("concat('str_', CAST(id + x AS STRING))", 10)), | ||
| "arr_struct10" -> Seq( | ||
| arrayColumn("struct(id + x AS a, concat('s', CAST(x AS STRING)) AS b)", 10)), |
There was a problem hiding this comment.
[P2] Match string cardinality across the element-type datasets
arr_str10 contains 262,151 distinct str_<id+x> values, while this struct's string field contains only the 10 values s1 through s10, across the same 2,097,140 non-null elements. The default Parquet writer enables dictionaries with a 1 MiB dictionary-page threshold, so the struct field stays dictionary-encoded while the high-cardinality standalone string field exceeds that threshold. Consequently the advertised string-versus-struct comparison also measures substantially different Parquet encoding, decoding, compression, and scan work. Make struct.b row-varying like the standalone string case, or explicitly match the value distribution and encoding before comparing element types.
| // roughly 50 times as many rows as the 2-element case from the same 256K inputs. | ||
| runBenchmark("Explode - fan-out") { | ||
| Seq(2, 10, 100).foreach { len => | ||
| runExpressionBenchmark( |
There was a problem hiding this comment.
[P2] Isolate explode from per-generated-row columnar-to-row conversion
runExpressionBenchmark executes .noop(), which writes to a DataWriter[InternalRow]. Therefore the Comet case converts every generated columnar output row to a Spark row, whereas Spark's GenerateExec already emits rows; the existing CometColumnarToRowBenchmark deliberately uses this same .noop() pattern to measure that conversion. Here fan-out 2 produces 419,428 rows, while fan-out 100 produces 20,971,400 rows, so conversion work scales 50x with the dimension attributed to explode and also varies with strings, structs, and carried columns. This is not fixed result-transfer overhead, and a scan-only baseline will not remove it. Consume generated batches without a row boundary, or include a matched columnar-to-row baseline so the benchmark isolates CometExplodeExec.
| } | ||
|
|
||
| runBenchmark("Explode - generator variants") { | ||
| Seq("explode", "posexplode", "explode_outer", "posexplode_outer").foreach { generator => |
There was a problem hiding this comment.
[P2] Prevent optimizer filtering from changing generator inputs
Catalyst's InferFiltersFromGenerate inserts size(arr) > 0 AND arr IS NOT NULL below explode and posexplode, but explicitly does not apply to their outer variants. With these generated datasets, the non-outer operators therefore receive only 209,714 rows, while explode_outer and posexplode_outer receive all 262,144; none of the 52,430 null or empty arrays reaches the non-outer operator. The purported generator-variant comparison consequently mixes an extra upstream filter and different operator input cardinalities, and its non-outer rate is normalized using rows the generator never processes. Exclude InferFiltersFromGenerate symmetrically for Spark and Comet, preserving the helper's existing ConstantFolding exclusion, or otherwise give both variants matched operator inputs.
| runExpressionBenchmark( | ||
| "explode plus 3 carried columns", | ||
| numRows, | ||
| "SELECT k, s, v, explode(arr) FROM arr_carry") |
There was a problem hiding this comment.
[P2] Keep the Parquet scan schema constant when measuring carried columns
SELECT explode(arr) prunes k, s, and v from the Parquet scan, whereas this query must scan and decode all three additional columns before explode runs. Across 262,144 input rows, that adds 786,432 scalar values, including 262,144 distinct strings, although both cases produce the same 2,097,140 generated rows. The measured difference therefore combines extra Parquet I/O and string decoding with carried-column replication, so it cannot isolate the stated carried-column dimension. Keep the projected scan schema identical between the two cases, or subtract a schema-matched scan baseline.
Which issue does this PR close?
N/A. This adds a benchmark for an existing operator, so there is no behavior change to track.
Rationale for this change
CometExplodeExechas no benchmark. Generator cost is driven by dimensions that other operatorbenchmarks do not exercise: how far each input row fans out, whether the generator emits a
position column or preserves rows with null and empty arrays, the element type being unnested,
and how many columns have to be replicated alongside the generated one. Without numbers for
those, there is no way to tell whether a change to the operator helped, and no way to see which
shapes Comet declines to convert.
What changes are included in this PR?
A new
CometExplodeBenchmarkwith 12 cases over 256K input rows, in four groups:explodeoverarray<bigint>of length 2, 10, and 100.explode,posexplode,explode_outer,posexplode_outer.array<bigint>,array<string>,array<struct<a bigint, b string>>.explodealone againstexplodeplus three passthrough columns.Two details worth calling out for reviewers:
One in ten rows holds a null array and another one in ten holds an empty array. Without that,
explode_outerandexplodewould produce identical output and the variant group would bemeasuring the same query twice. The empty array is built with
slice, notarray(), becausearray()types asarray<null>.Each array column gets its own temp view rather than sharing one wide table, so a case is never
charged for scanning an array column it does not read.
Reported times are whole-query totals and include the Parquet scan and result transfer, which is
noted in the class comment: at fan-out 2 the scan is a large share of the total and the ratio
understates the difference between the two implementations. The
RateandPer Rowcolumns areper input row, not per generated row, since fan-out varies across cases.
How are these changes tested?
Run locally on an Apple M3 Ultra with
local[5]:All 12 cases complete, and none trips the harness warning for a plan that is not fully Comet
native, including the
array<struct>case. Comet is roughly 2x Spark across every case, andflat across fan-out, generator variant, and element type (Best ms, Spark then Comet):
Correctness of the query forms themselves is already covered by
CometGenerateExecSuite, whichtests explode and explode_outer over simple, empty, and null arrays.