Skip to content

perf: avoid rebuilding untouched operators in PlanDataInjector.injectPlanData - #5220

Merged
andygrove merged 2 commits into
apache:mainfrom
andygrove:perf-inject-plan-data-share-subtrees
Aug 3, 2026
Merged

perf: avoid rebuilding untouched operators in PlanDataInjector.injectPlanData#5220
andygrove merged 2 commits into
apache:mainfrom
andygrove:perf-inject-plan-data-share-subtrees

Conversation

@andygrove

@andygrove andygrove commented Aug 2, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

Addresses item 6 of #5199.

Rationale for this change

PlanDataInjector.injectPlanData runs once per task on the executor. It recursed into every subtree and rebuilt every operator through a builder (op.toBuilder ... clearChildren() ... build()), even though at most one or two leaf scans in a plan actually receive injected data. Every Filter, Projection, HashJoin, etc. above and beside the scans was reconstructed for nothing, once per task.

What changes are included in this PR?

Operators are protobuf messages and therefore immutable, so a subtree that needs no injection can be returned by reference rather than rebuilt. injectPlanData now creates a node's builder lazily, on the first child that actually changed, and sets only the changed slots — so a node whose subtree needed no injection is returned as-is and allocates nothing.

Only the root-to-scan paths are rebuilt; untouched siblings and their descendants are shared with the input tree. The injection logic, key lookup, and the "missing planning data" error are unchanged.

The PlanDataInjector.inject scaladoc now states the invariant the walker relies on (implementations must return the node with its child list unchanged), since contribs implement that trait via ServiceLoader and would otherwise not see the constraint.

Local measurement of injectPlanData alone (new vs. old implementation, warmed up, interleaved rounds; synthetic plan = N unary operators over a hash join of two native scans with C columns each):

plan shape old new speedup
60 ops, 1 col 8.32 us 4.85 us 1.72x
20 ops, 1 col 3.56 us 2.52 us 1.41x
60 ops, 20 cols 21.98 us 18.78 us 1.17x
20 ops, 50 cols 39.57 us 38.55 us 1.03x
5 ops, 200 cols 149.26 us 146.97 us 1.02x

The saving is the tree rebuild, so it grows with plan size and shrinks in relative terms as the injection itself (parsing the scan's common bytes, which scales with schema width) comes to dominate. It also removes the corresponding garbage from the per-task path.

Worth noting for the epic: after this change the dominant per-task cost in CometExecRDD.compute is the full Operator.parseFrom + re-serialize that brackets the injection, and NativeScanPlanDataInjector.getKey recomputing its string key per scan per task. Both are separate follow-ups.

How are these changes tested?

  • PlanDataInjectorSuite — the existing "non-scan operator tree unchanged" test now asserts reference identity, and a new test injects into a scan nested under a filter beside an untouched sibling subtree, asserting the sibling is shared, the injected data is correct, and surrounding fields are preserved. This suite is already in the pr_build_linux / pr_build_macos matrices.
  • Existing suites pass locally (Spark 4.1, JDK 17): PlanDataInjectorSuite, CometScanWithPlanDataSuite, CometExecSuite + CometJoinSuite (172 tests).

…PlanData

`injectPlanData` runs once per task on the executor and rebuilt every
operator in the plan tree through a builder, even though at most one or
two leaf scans actually receive injected data.

Operators are protobuf messages and therefore immutable, so a subtree
that needs no injection can be returned by reference instead. Only the
root-to-scan paths are rebuilt now; untouched siblings and their
descendants are shared.

Addresses item 6 of apache#5199.
@andygrove andygrove added this to the 1.1.0 milestone Aug 2, 2026
…ilder

Replaces the per-node child array + changed flag + zero-child special
case with a builder created lazily on the first changed child, setting
only the changed slots. Unchanged nodes now allocate nothing at all
(the previous form allocated a throwaway array per interior node), and
the zero-child fast path is no longer needed.

Also documents the "injectors must preserve the child list" invariant on
`PlanDataInjector.inject`, since contribs implement that trait via
ServiceLoader and the walker relies on child reference identity.

@mbutrovich mbutrovich 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.

Makes sense to me, thanks @andygrove!

@andygrove
andygrove merged commit 41d887d into apache:main Aug 3, 2026
71 checks passed
@andygrove
andygrove deleted the perf-inject-plan-data-share-subtrees branch August 3, 2026 13:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants