Skip to content

fix: ship the functions of a native plan's ScalaUDFs once, in one serialization stream - #23

Merged
msafonov merged 2 commits into
branch-1.1from
joom/1.1-scalaudf-serialize-once
Oct 17, 2026
Merged

msafonov merged 2 commits into
branch-1.1from
joom/1.1-scalaudf-serialize-once

Conversation

@msafonov

Copy link
Copy Markdown
Collaborator

Problem

experiment-platform.ltv_flag on Comet 1.1-11 died with Java heap space in the 3G driver (vanilla passes). The codegen dispatcher (CometScalaUDF.emitJvmCodegenDispatch) closure-serialized every ScalaUDF expression in its own stream. The job's project has 10 UDFs, 9 of them capture one ~150 MB expResults map, so each plan conversion built ~1.35 GB of byte arrays (plus copies), and AQE re-converts the plan after every stage. Vanilla serializes the plan in one stream and pays 155 MiB once.

Fix

  • emitJvmCodegenDispatch serializes the bound expression with each ScalaUDF.function replaced by a small arity-matching reference (CometSharedFunctionRef(id)); functions are checked for serializability once per function identity (null stream) so non-serializable UDFs still fall back to Spark at plan time.
  • CometExec.serializeNativePlan (every native plan serialization) appends one function pool for the functions the plan's expressions reference (unknown Operator field 99, ignored by the native side): all functions in one Java-serialization stream, so objects they share are written once. The pool is cached per function set (soft values), so AQE re-conversions reuse the bytes instead of serializing again.
  • CometExecIterator registers the pools of its plan for the task (root and nested children, e.g. the shuffle writer's inlined child); a pool is deserialized once per task on first use and dropped at task completion. CometScalaUDFCodegen resolves the references when it deserializes an expression.
  • Expressions without ScalaUDFs (other dispatched built-ins) keep the old self-contained bytes.

Tests

  • New CometCodegenSuite test "UDFs that capture one large object ship it once per serialized plan": 8 UDF expressions over 3 functions sharing a 4 MiB array. Serialized native plan < 2 x 4 MiB (the same test without the references measured 37.9 MB, i.e. one copy per expression); one pool build, pool deserialized on executors, a second run of the query reuses the cached pool (no rebuild); results match Spark.
  • CometCodegenSuite, CometScalaUDFClassLoaderSuite, CometCodegenSourceSuite (Spark 3.5): 168 passed, 0 failed.

🤖 Generated with Claude Code

…ialization stream

The codegen dispatcher closure-serialized every ScalaUDF expression on its
own, so a function captured by several expressions, and an object several
functions capture, was written once per expression: ltv_flag's project of
ten UDFs over one ~150 MB map built ~1.35 GB of bytes on the driver per
plan conversion, again on every AQE re-plan, and failed with Java heap space.

Each expression now carries its ScalaUDF functions as references. The
native plan serialization appends one pool holding all functions its
expressions reference, written in one Java serialization stream so shared
captured objects are written once, and caches it per function set for AQE
re-conversions. Each task registers the pools of its plans and deserializes
a pool once, on its first use.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added bug Something isn't working area:expressions area:udf labels Oct 17, 2026
…which the shaded jar cannot load

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@msafonov
msafonov merged commit eed1a22 into branch-1.1 Oct 17, 2026
35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:expressions area:udf bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant