Skip to content

feat: run user-defined generators inside the native generate operator - #7253

Open
andygrove wants to merge 4 commits into
apache:mainfrom
andygrove:feat/7252-jvm-generators
Open

andygrove wants to merge 4 commits into
apache:mainfrom
andygrove:feat/7252-jvm-generators

Conversation

@andygrove

Copy link
Copy Markdown
Member

Which issue does this PR close?

Closes #7252.

Stacked on #7251 (inline / stack), whose commits appear here until it merges. Only the last commit is new.

Rationale for this change

A GenerateExec whose generator Comet has no native implementation of falls back to Spark. That costs a columnar-to-row transition before it and a row-to-columnar transition after it, and leaves the rest of the stage in Spark. This affects:

  • Hive GenericUDTFs used in LATERAL VIEW;
  • json_tuple;
  • the ReplicateRows generator that EXCEPT ALL and INTERSECT ALL plan;
  • custom Catalyst generators.

The generator itself still runs on the JVM one row at a time, so the gain is keeping the stage native around it.

What changes are included in this PR?

The issue proposed returning generated columns plus a parent-row index. This uses a simpler shape that fits the existing UDF bridge, which requires one output value per input row:

  • CometGeneratorUDF (a CometUDF): for each input row, it returns the rows the generator emits as one array<struct> element. CometExplodeExec then unnests and flattens that the way feat: run inline, inline_outer, and stack generators natively #7251 does for inline. An input row that emits nothing gets an empty array, so outer keeps it as one row of nulls, through the existing null handling.
  • Inputs and outputs: it reads the bound generator's columns through CometVector rows, and writes the output with Comet's ArrowWriter into a vector from CometBatchKernelCodegen.allocateOutput, so the list and struct field names match what the native side declares.
  • State: each native plan gets its own generator instance, keyed by digest and plan, and dropped in releasePlan, so the state a Hive UDTF keeps carries across batches. The serde folds a per-occurrence number into the digest, so two identical generators in one plan don't share an instance.
  • terminate(): a new terminate expression on the Explode proto is the same UDF called with a flag set. ExplodeExec evaluates it once the input is exhausted and emits its rows with the last input row's other columns, which it keeps as a one-row copy. That matches Spark's GenerateExec, which pairs them with the last row as Hive does. outer does not apply to these rows. With no input rows, the other columns are null.
  • Serde: CometGeneratorUDF.declineReason and CometGeneratorUDF.calls. The generator falls back when:
    • it is nondeterministic (the existing gate);
    • its tree holds a subquery, an unevaluable node or a native UDF call;
    • it calls code outside Spark (CometInvokeTargets);
    • its output type cannot be serialized.
  • Config: spark.comet.exec.jvmGenerator.enabled, on by default.
  • Small refactors: CometScalaUDF.closureSerialize is pulled out for reuse, and ArrowWriter and ArrowFieldWriter are widened from private[arrow] to private[comet].
  • Docs: a Generators section in the Scala/Java UDF page, plus the operator, expression and plan pages.

Python UDTFs (@udtf) plan as their own exec nodes and are not covered.

How are these changes tested?

  • Rust ExplodeExec tests:
    • terminate rows carry the last input row across several batches;
    • with no input rows the other columns are null;
    • an empty terminate adds no outer row.
  • CometGenerateExecSuite:
    • a test Hive GenericUDTF (registered without a Hive session) that keeps a running count and emits a total from close(). The suite checks it runs natively against Spark: with and without LATERAL VIEW OUTER, with no input rows, and as two identical UDTFs in one plan;
    • a three-partition run with AQE off, checking that each partition ends with its own close() row;
    • json_tuple, and EXCEPT ALL through ReplicateRows, both checked to run natively;
    • the fallback reason when the config is off.
  • Local runs: on the default Spark 4.1 profile, CometGenerateExecSuite (59 passed), CometSetOpWithGroupBySuite, and the inline, stack, explode and posexplode SQL files.

Plan `inline` through the native explode operator by flattening each
unnested struct into one column per field, and plan `stack` as `inline`
over an array holding one struct per output row. Flattening now folds a
struct's null mask into its fields, so a null struct element yields a
row of nulls as Spark's `inline` does.

Closes apache#5125.
…atch

- Flatten structs with `children_with_parent_nulls` from comet-common,
  which skips the full revalidation that rebuilding each field did.
- Pick the generator in one match on `Inline`, `Stack` and `ExplodeBase`
  instead of mixing type matches with node-name checks.
- Lift the fallback reasons of the array `stack` builds onto the
  generator, so the operator reports the failing argument's own reason.
- Test a foldable `stack` row count and NULL structs built by `IF`.
A generator Comet has no native implementation of, such as a Hive UDTF,
`json_tuple`, or the `ReplicateRows` that `EXCEPT ALL` plans, now runs on
the JVM inside `CometExplodeExec` instead of falling back to Spark.

`CometGeneratorUDF` is a `CometUDF` that returns, for each input row, the
rows the generator emits as one `array<struct>` element, which the
operator unnests and flattens as it does for `inline`. Each native plan
gets its own generator instance, so state carries across batches. Once
the input ends, `ExplodeExec` evaluates a new `terminate` expression and
emits the rows of `Generator.terminate` with the last input row's other
columns, as Spark's `GenerateExec` does.

Gated by `spark.comet.exec.jvmGenerator.enabled`, on by default.

Closes apache#7252.
@andygrove andygrove added the run-spark-4.1-tests Run the Spark 4.1 SQL tests on this pull request instead of waiting for the merge queue label Oct 26, 2026
@github-actions github-actions Bot added enhancement New feature or request area:expressions Expression evaluation labels Oct 26, 2026

@rich7420 rich7420 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three correctness issues inline.

let input_schema = self.input.schema();
let mut columns = match self.last_row.take() {
Some(row) => row.columns().to_vec(),
None => input_schema

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discard empty termination output before creating null placeholders; nonnullable carried columns currently make empty partitions fail.

val outputRow = new GenericInternalRow(1)
def writeRows(rows: Any): Unit = {
val copies = new ArrayBuffer[Any]
iteratorOf(rows).foreach(row => copies += row.copy())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Snapshot rows with UnsafeProjection; GenericInternalRow.copy() retains reused binary buffers, producing duplicate values instead of the original outputs.

val copies = new ArrayBuffer[Any]
iteratorOf(rows).foreach(row => copies += row.copy())
outputRow.update(0, new GenericArrayData(copies.toArray))
writer.write(outputRow, 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep custom generators on Spark until this bridge preserves laziness; LIMIT 1 currently evaluates a throwing second row.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed edb56380fb95f1c57ca45fffedd4186442b46772 across five independent scopes. The three existing correctness findings are still present on this head, so I am requesting changes without adding duplicate inline threads:

The existing suite/helper reuse and per-plan generator state are reasonable. Please add focused regression cases for those three boundaries with the fixes. Current-head PR checks are green and suite registration checks passed, but the added tests do not cover these cases. I inspected the current production and test paths; I did not rerun the full Spark/Comet suites.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed edb56380fb95f1c57ca45fffedd4186442b46772 across five independent scopes. This head is unchanged, and the three existing correctness issues remain:

The implementation and tests still need the fixes and focused regressions requested in those threads. No additional finding from this pass. Current-head checks have 35 successes and 18 skips; I rechecked the source and test coverage without rerunning the unchanged full suites. Keeping changes requested.

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

Labels

area:expressions Expression evaluation enhancement New feature or request run-spark-4.1-tests Run the Spark 4.1 SQL tests on this pull request instead of waiting for the merge queue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Run user-defined generators (Hive UDTFs, custom Generators) in the Comet pipeline

3 participants