Skip to content

[EPIC] Reimplement Scala expression microbenchmarks so they measure expression cost, not scan and result transfer #5363

Open

Description

@andygrove

Background

We now have ~25 Scala end-to-end microbenchmark suites for expressions, almost all of which funnel through runExpressionBenchmark in spark/src/test/scala/org/apache/spark/sql/benchmark/CometBenchmarkBase.scala. I reviewed them to check whether they actually isolate expression evaluation cost. They largely do not — a large and non-uniform share of each timed region is Parquet scan plus columnar-to-row conversion of the result set, and there are several fairness bugs that make individual rows misleading.

These benchmarks are what we use to justify optimization work and to catch regressions, so it is worth reimplementing the harness rather than patching individual suites.

Problem 1: scan and result transfer are inside the timed region

Every timed case is:

spark.sql("SELECT expr(c1) FROM parquetV1Table").noop()

Two costs that are not expression evaluation are inside the timer:

Parquet scan. Read on every iteration, and critically the two arms use different readers — Spark's vectorized reader vs. Comet's native scan. So the contamination does not cancel. The relative column is a ratio of sums, (scan_spark + expr_spark + c2r_spark) / (scan_comet + expr_comet + c2r_comet), which for any expression cheaper than the scan converges to the scan ratio rather than the expression ratio.

Result transfer + columnar-to-row. The noop sink writer is a DataWriter[InternalRow], so a ColumnarToRowExec sits on top of every plan. This is known and tolerated — CometPlanChecker.findFirstNonCometOperator explicitly whitelists ColumnarToRowExec, CometColumnarToRowExec, CometNativeColumnarToRowExec and WholeStageCodegenExec. Since spark.comet.exec.columnarToRow.native.enabled defaults to false, the Comet arm pays Arrow FFI export to JVM CometVector to UnsafeRow, while Spark emits rows directly from a single fused codegen stage.

The second cost tracks the output type of the expression, so contamination varies a lot within a single suite:

Output type Affected suites c2r cost
int / bool / long comparison, hash, length/ascii/instr, like/rlike, unix_timestamp, cast-to-numeric small
String all cast-to-string suites, most of CometStringExpressionBenchmark, to_json, to_csv, get_json_object, string-result CASE WHEN large
struct / array from_json, sort_array, make_interval dominant

So cross-expression rankings within a single results file are distorted too, not only the absolute numbers.

Worst case is CometArrayExpressionBenchmark: 4M rows of 16- and 32-element int arrays through .noop(), where building UnsafeArrayData per row very likely costs more than sort_array itself. The suite's own element_at(sort_array(x), 1) variant collapses the output to a single int, and the gap between those two rows is essentially the c2r cost.

CometAggregateExpressionBenchmark is the one suite that is clean on this axis, because aggregate queries emit one row per group and nothing crosses the boundary.

Problem 2: fairness bugs

ConstantFolding is excluded only for the Comet arm. cometExecConfigs sets spark.sql.optimizer.excludedRules to ConstantFolding; the Spark case runs with folding enabled. Concrete casualty: select space(2) from parquetV1Table in CometStringExpressionBenchmark — Spark folds it to a literal and does no per-row work while Comet evaluates it per row, so that row reports a fabricated Comet regression. The conf is also set rather than appended, clobbering any pre-existing exclusions.

CometPredicateExpressionBenchmark measures Parquet filter pushdown, not In. The query is select * from parquetV1Table where c1 in ('positive','zero') over a column with three distinct values. Both engines push that into the reader, so the In expression may never be evaluated as an expression at all. CometComparisonExpressionBenchmark gets this right by putting predicates in the SELECT list instead.

Two suites use a cardinality too low to measure anything. CometStringExpressionBenchmark and CometRegExpBenchmark both use 1024 rows — a single batch. Spark's Benchmark runs 2s of warmup and then at least 2s of timed iterations, each iteration being a full Spark job, so these are measuring job submission and Comet's per-query native plan construction rather than expression throughput. The relative column will read approximately 1.0x regardless of the expression. That is 31 string expressions and 5 regex patterns currently producing noise.

Problem 3: uncontrolled inputs

Parquet dictionary encoding is left at default in every expression suite, while CometColumnarToRowBenchmark, CometExecBenchmark and CometShuffleBenchmark all set parquet.enable.dictionary=false for exactly this reason. The expression suites diverged from an existing convention in the same package. This matters because kernels with a dictionary fast path only compute over distinct values, and distinct counts happen to range from 3 (CometPredicateExpressionBenchmark) to 1M (CometConditionalExpressionBenchmark.c4) across suites — so some expressions get a large unrepresentative win by accident. CometArithmeticBenchmark is the only suite that varies this, and only indirectly via useDictionary reducing distinct value counts.

Input data is non-deterministic. runBenchmarkWithTable builds the base table from an unseeded Random.nextLong(), so committed results are not reproducible. This is not merely cosmetic: CometStringExpressionBenchmark derives REPEAT(CAST(value AS STRING), 10) from it, giving 10-200 character strings that vary run to run, which means lpad(c1, 150, 'x') and rpad are sometimes padding and sometimes truncating.

Fallback warnings never reach the results file. runExpressionBenchmark reports a non-native plan with println, which goes to the console, whereas Benchmark(output = output) writes to the .txt. Anyone reading a committed results file cannot tell that a row labelled "Comet" actually ran on Spark.

Proposed work

Rebuild the harness so that scan and result-transfer costs are either removed from the timed region or explicitly reported, then migrate the suites onto it.

  • Add a no-expression baseline case to the shared harness. Take a baselineQuery (default: project the raw input columns) and emit Spark (baseline) / Comet (baseline) rows alongside every measurement, so the scan-plus-c2r floor is visible in every committed results file. This is mechanical and immediately stops people misreading a 1.1x as an expression win.
  • Add an aggregate-sink measurement mode for expressions whose output is a string or a complex type, e.g. SELECT sum(xxhash64(<expr>)) FROM t, so the result never crosses the JNI or row boundary. Comet aggregates natively, output is one row, c2r goes to zero. The added hash per row is small relative to UnsafeRow/UnsafeArrayData construction and is identical in both arms.
  • (test: make expression benchmark harness fair and reproducible #5371) Apply excludedRules to both arms rather than only the Comet arm, and append to the existing value instead of overwriting it.
  • (test: make expression benchmark harness fair and reproducible #5371) Assert that the benchmarked expression is actually present in the Spark baseline plan. Note: with excludedRules applied to both arms, the space(2) case is already fixed by that change alone, and this check cannot catch the In case below, because Spark retains FilterExec above the scan even when the filter is pushed down. What it does catch is expressions removed by rules that are not excluded, such as SimplifyCasts on a no-op cast.
  • Raise CometStringExpressionBenchmark and CometRegExpBenchmark to at least 1M rows.
  • Move the In predicate in CometPredicateExpressionBenchmark into the SELECT list, or disable spark.sql.parquet.filterPushdown for that suite.
  • Set parquet.enable.dictionary explicitly in CometBenchmarkBase.getSparkSession to match the three other suites in the package, and make dictionary-encoded vs. plain a deliberate variant where it is interesting rather than an accident of the generated data.
  • (test: make expression benchmark harness fair and reproducible #5371) Seed the Random in runBenchmarkWithTable so results are reproducible. Note: the closure runs per row on the executor, so a driver-side seed has no effect; test: make expression benchmark harness fair and reproducible #5371 uses a pure function of the row id instead.
  • (test: make expression benchmark harness fair and reproducible #5371) Route the not-fully-native warning to output so it lands in the results file, and consider failing the run outright.
  • Migrate the existing suites onto the new harness and regenerate the committed results.

CometRegExpBenchmark is worth calling out as already having close to the right shape: its Comet (Scan) case isolates the exec contribution far better than a plain Spark-vs-Comet pair does. It just runs at 1024 rows.

Progress

Corrections to the above

Two premises in this issue turned out to be wrong, found while working on #5371:

  • This issue refers throughout to "committed results files". spark/benchmarks is listed in .gitignore, so no Scala microbenchmark results are tracked in git. The only committed benchmark results are the TPC JSON files under benchmarks/results/. The .txt files are local artifacts that get pasted into PRs and issues by hand, which is where a console-only warning does its damage. The last checklist item therefore has nothing to regenerate.
  • The plan assertion cannot catch the In predicate case, for the reason noted inline above. Moving the In into the SELECT list stays a separate manual fix.

Found along the way

Activity

  1. self-assigned this
    on Aug 15, 2026
  2. andygrove commented on Aug 15, 2026

    @andygrove
    MemberAuthor

    cc @mbutrovich - this is largely based on a conversation we had some time ago. I plan on working on this.

  3. added
    priority:mediumFunctional bugs, performance regressions, broken features
    and removed on Aug 15, 2026
  4. coderfender commented on Aug 19, 2026

    @coderfender
    Contributor

    looking into this

  5. andygrove commented on Aug 19, 2026

    @andygrove
    MemberAuthor

    Ran a probe on the current worktree to put actual numbers on the review. Apple M3 Max, JDK 17, release build, 1M rows, parquet.enable.dictionary=false, spark.master=local[1], spark.comet.exec.columnarToRow.native.enabled=false (default). Deterministic input: SELECT REPEAT(CAST(id AS STRING), 10) AS c1, id AS c_long FROM range(1M).

    For each expression the probe times four query shapes per engine:

    # Query What's timed
    1 SELECT <base_col> FROM t scan + c2r baseline
    2 SELECT <expr> FROM t current methodology — scan + expr + c2r
    3 SELECT sum(xxhash64(<base_col>)) FROM t scan + hash + agg baseline (no c2r)
    4 SELECT sum(xxhash64(<expr>)) FROM t scan + expr + hash + agg

    Row 4 minus row 3 approximates the expression alone (hash-agg absorbs the result server-side, so nothing crosses JNI/row). Compare against row 2 to see what the current benchmark is really measuring.

    Best-time results (ms per 1M rows)

    upper(c1) — string in, string out (worst case: full-width string c2r)

    Case Spark Comet
    1 scan+c2r 78 47
    2 scan+EXPR+c2r (current methodology) 245 237
    3 scan+hash+agg 67 52
    4 scan+EXPR+hash+agg 234 253
    isolated expr ≈ (4)–(3) ~167 ~201

    Reported (row 2) says Spark/Comet ≈ 1.03× — essentially a tie. Isolated (row 4 − row 3) says Spark's upper is actually ~20% faster than Comet's here. The current methodology is hiding a regression.

    length(c1) — string in, int out (cheap c2r)

    Case Spark Comet
    1 scan+c2r 67 43
    2 scan+EXPR+c2r (current) 124 35
    3 scan+hash+agg 62 49
    4 scan+EXPR+hash+agg 172 42
    isolated expr ≈ (4)–(3) ~110 ~0

    Reported ratio 3.5×. In reality Spark spends ~110 ms in the expression while Comet's length on a scanned string is essentially free — the isolated ratio is well over 100×. The methodology understates Comet's advantage. (Note that Comet row 2 = 35 ms is even lower than the row-1 baseline of 43 ms, i.e. adding length made the timed region go faster — the int output is cheaper to c2r than the string input, so the "baseline" already includes cost that the "with expression" version avoids. That should be impossible for a real per-row expression benchmark; it's a giveaway that the timer is dominated by c2r, not by the expression.)

    lpad(c1, 150, 'x') — string in, much wider string out

    Case Spark Comet
    1 scan+c2r 66 43
    2 scan+EXPR+c2r (current) 751 50
    3 scan+hash+agg 59 48
    4 scan+EXPR+hash+agg 700 71
    isolated expr ≈ (4)–(3) ~641 ~23

    Reported ratio 15×; isolated ratio ~28×. Comet row 2 is ~54% c2r-overhead — the reported ratio understates Comet by ~2×. This is the "output much wider than input" class and it hits every string-widening case in CometStringExpressionBenchmark.

    CAST(c_long AS STRING) — narrow in, string out

    Case Spark Comet
    1 scan+c2r 37 29
    2 scan+EXPR+c2r (current) 84 43
    3 scan+hash+agg 30 20
    4 scan+EXPR+hash+agg 83 33
    isolated expr ≈ (4)–(3) ~53 ~13

    Reported ratio 1.95×; isolated ratio ~4.08×. About 70% of Comet's timed region is scan+c2r rather than the cast. Every row in CometCastNumericToStringBenchmark and CometCastTemporalToStringBenchmark has this shape.

    Plans (confirm the mechanism)

    Current methodology, Comet side, has a JNI-crossing CometColumnarToRow on top:

    *(1) CometColumnarToRow
    +- CometProject [upper(c1)#6], [upper(c1#3) AS upper(c1)#6]
       +- CometNativeScan parquet [c1#3] ...
    

    Aggregate sink stays fully native — nothing crosses:

    CometHashAggregate [sum#14L], [Final], [sum(xxhash64(upper(c1#3), 42))]
    +- CometExchange SinglePartition, ENSURE_REQUIREMENTS, CometNativeShuffle, [plan_id=94]
       +- CometHashAggregate [c1#3], [Partial], [partial_sum(xxhash64(upper(c1#3), 42))]
          +- CometNativeScan parquet [c1#3] ...
    

    Takeaways

    • The reported "Comet vs Spark" ratio is a mix of scan ratio, c2r ratio, and expression ratio, in proportions that depend on the output type of the expression — so cross-expression comparisons within a single results file are also distorted.
    • Direction of the error is not consistent. For upper the reported number hides a Comet regression. For length/lpad/cast it understates Comet's advantage by 2×–100×. Anyone comparing numbers across PRs is comparing distorted-in-different-ways figures.
    • The aggregate-sink shape (row 4 in the probe) is what the reimplementation should use for expressions whose output is a string or a complex type. Cost is one extra hash per row, identical in both arms, and c2r drops to zero.

    Environment: OpenJDK 17.0.10, macOS 26.5.1, Apple M3 Max; probe was a ScratchOverheadProbe/ScratchOverheadProbeSuite in spark/src/test/scala/org/apache/spark/sql/benchmark/ (not committed).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

EPICenhancementNew feature or requestperformancepriority:mediumFunctional bugs, performance regressions, broken featurestestTesting related

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions