Skip to content

[CALCITE-7809] Avoid copying unchanged SQL and Rex operands - #5283

Open
FrankChen021 wants to merge 1 commit into
apache:mainfrom
FrankChen021:codex/copy-on-write-shuttles
Open

FrankChen021 wants to merge 1 commit into
apache:mainfrom
FrankChen021:codex/copy-on-write-shuttles

Conversation

@FrankChen021

@FrankChen021 FrankChen021 commented Sep 22, 2026

Copy link
Copy Markdown
Member

Fixes CALCITE-7809.

Why

RexShuttle and SqlShuttle copy operand collections before they know whether the traversal will replace any operand. Read-only traversals therefore allocate and discard copies of unchanged operands, which adds memory pressure for large expressions.

In Druid's string-IN planning benchmark, this change reduced allocation by 2.72% at 100,000 literals and 1.55% at 1,000,000 literals. Timing confidence intervals overlapped, so no latency improvement is claimed.

What

Before this change:

Visit operands
  ↓
Always allocate a new operand array/list
  ↓
Copy every operand
  ↓
Discover that nothing changed
  ↓
Return the original node anyway

This change makes the operand collections copy-on-write:

Visit operands without copying
  ↓
Did any child return a different instance?
  ├─ No  → reuse the existing operand collection
  └─ Yes → allocate at the first change and copy the operands

RexShuttle.visitList creates its immutable-list builder only when the first child changes. An unchanged non-immutable input is still copied to preserve the immutable-result contract. SqlShuttle creates its operand array only when a child changes, while preserving the explicit alwaysCopy behavior.

The visitors still traverse every operand; this change does not skip validation, type inference, or conversion.

Verification

  • ./gradlew :core:compileJava :core:compileTestJava
  • ./gradlew :core:test --tests org.apache.calcite.test.RexShuttleTest
  • ./gradlew :core:autostyleJavaCheck :core:checkstyleAll
  • Druid InPlanningBenchmark.queryStringInSqlPlanOnly with -prof gc
Druid benchmark results
String literals Base time PR time Base allocation PR allocation Allocation reduction
100,000 740.60 ms/op 739.56 ms/op 1.534 GB/op 1.492 GB/op 2.72%
1,000,000 7,832.82 ms/op 7,895.67 ms/op 15.447 GB/op 15.207 GB/op 1.55%

Configuration: inSubQueryThreshold=2147483647, rowsPerSegment=500000, 2 forks, 2 one-second warmup iterations, and 5 one-second measurement iterations. Allocation is cumulative bytes per operation, not retained or peak heap.

The focused identity and boundary tests requested in review, and a Calcite-local ubenchmark, are not yet included.

Scope

This change covers RexShuttle.visitList and SqlShuttle call operands. It does not change SqlShuttle.visit(SqlNodeList), RexShuttle.visitArray, visitFieldCollations, or the visitList overrides in ProjectFilterTransposeRule and DateRangeRules.

Copilot AI lite review requested due to automatic review settings September 22, 2026 10:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@sonarqubecloud

Copy link
Copy Markdown

@FrankChen021 FrankChen021 changed the title [CALCITE-7805] Avoid copying unchanged SQL and Rex operands [CALCITE-7809] Avoid copying unchanged SQL and Rex operands Sep 22, 2026

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

This is slop. Do not merge.

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

This PR description looks very AI slop.

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

Thanks for the change. I ran SqlValidatorTest, SqlToRelConverterTest, RelOptRulesTest, RexProgramTest, and RexShuttleTest on the PR head: 2444 tests, 0 failures. The approach is sound and the risk is low. A few things before merge:

  1. Nothing tests the property the PR adds. If visitList goes back to always copying, every existing test still passes, so the optimization can regress without anyone noticing. Could you add tests along these lines?
    • RexShuttle.visitList on an unchanged ImmutableList returns the same instance and leaves update[0] false.
    • A shuttle that replaces a middle operand returns a new list with the prefix and suffix intact and sets update[0]. This catches an off-by-one in subList(0, i). Replacing the first and the last operand covers the boundaries.
    • An unchanged input that is not an ImmutableList still comes back as an ImmutableList with equal elements.
    • SqlShuttle returns the same SqlCall when no operand changes, returns a new call with the other operands kept as the same instances when one operand changes, and returns a new instance when alwaysCopy is true and nothing changes.
  2. The measurement comes from Druid's benchmark, which nobody can rerun from this repository. ubenchmark already has RelNodeConversionBenchmark. A case with a large ARRAY[...] or IN list, run with -prof gc, would show the effect in Calcite itself and catch a regression later.
  3. Scope: a plain x IN ('1', ..., 'N') in Calcite (no Druid rewrite) goes through SqlShuttle.visit(SqlNodeList), which still allocates a full ArrayList on every traversal. RexShuttle.visitArray, visitFieldCollations, and the visitList overrides in ProjectFilterTransposeRule and DateRangeRules still copy eagerly too. Either cover visit(SqlNodeList) here or say in the description that this PR targets only the call-operand paths.
  4. The commit subject says [CALCITE-7805], but this PR is CALCITE-7809. CALCITE-7805 is the umbrella allocation issue.

Minor, for the description: the "based directly on main at 38413ec" sentence only matters while the PR is open, and the arrow diagrams could each be one sentence.

Comment on lines +185 to +189
if (exprs instanceof ImmutableList) {
//noinspection unchecked
return (List<RexNode>) exprs;
}
return ImmutableList.copyOf(exprs);

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.

ImmutableList.copyOf already returns its argument when that argument is a full ImmutableList, so the instanceof branch and the unchecked cast aren't needed:

return clonedOperands != null ? clonedOperands.build() : ImmutableList.copyOf(exprs);

It also handles a case the current branch gets wrong: ImmutableList.subList(...) is an instanceof ImmutableList view that keeps the whole backing array reachable. copyOf compacts such a view, and this branch returns it as is. I checked both cases on Guava 33.4.8.

if ((clonedOperand != operand) && (update != null)) {
update[0] = true;
if (clonedOperand != operand && clonedOperands == null) {
clonedOperands = ImmutableList.builder();

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.

Nit: ImmutableList.builderWithExpectedSize(exprs.size()) avoids growing the builder on the path where an operand changed.

@@ -163,15 +163,30 @@ protected RexNode[] visitArray(RexNode[] exprs, boolean @Nullable [] update) {
*/
protected List<RexNode> visitList(

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.

The method now can return exprs itself, and subclasses will start relying on that, so the Javadoc of this method should say it. Its @return is also wrong: the method returns a list, not an array. Suggestion:

   * @return List of visited expressions; {@code exprs} itself if it is an
   *         {@link ImmutableList} and no expression was modified

@@ -100,24 +108,23 @@ public class SqlShuttle extends SqlBasicVisitor<@Nullable SqlNode> {
*/
protected class CallCopyingArgHandler implements ArgHandler<@Nullable SqlNode> {

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.

The class Javadoc says the handler "deep-copies SqlCalls and their operands". That wasn't accurate before this PR and is less accurate now: the handler creates a new call only when an operand changes or when alwaysCopy is true. Since the PR touches this class, could you fix the sentence?

final List<@Nullable SqlNode> operands = (List<@Nullable SqlNode>) call.getOperandList();
this.clonedOperands = operands.toArray(new SqlNode[0]);
this.alwaysCopy = alwaysCopy;
this.clonedOperands = alwaysCopy ? copyOperands(call) : null;

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.

Optional simplification: if result() copies the operands when clonedOperands is still null, the constructor needs no branch and alwaysCopy gets lazy copying too. When nothing changed, copying at result() gives the same array as copying here.

@Override public SqlNode result() {
  if (!update && !alwaysCopy) {
    return call;
  }
  final @Nullable SqlNode[] operands =
      clonedOperands != null ? clonedOperands : copyOperands(call);
  return call.getOperator().createCall(
      call.getFunctionQuantifier(), call.getParserPosition(), operands);
}

clonedOperands[i] = newOperand;
return newOperand;
}

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.

Nit: stray blank line before the closing brace.

@FrankChen021

Copy link
Copy Markdown
Member Author

This PR description looks very AI slop.

@julianhyde @caicancai I don't accept the word 'slop' here.

I deliberately guided the AI to generate the description, to include:

  • The problem description
  • Code execution path and new code path for illustration for people to better understand the changes before and after
  • Benchmark data as evidence to show the outcome

It may be long, but it's clear.

I wrote an email in the dev mailing list, we can discuss about this(non-implementation details) in that thread.

@FrankChen021

Copy link
Copy Markdown
Member Author

@vlsi Thank you very much for the feedback. I will address these problems later this week.

@vlsi

vlsi commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

It may be long, but it's clear.

From my point of view, it is hard to answer "why do we need the change at all?" question.
As a reviewer and Calcite maintainer, I don't want to dig into details until I understand why is that needed at all.

Frank, I wonder if you could try this skill: https://github.com/Netcracker/qubership-ai-packages/tree/main/agent-packages/change-description-authoring

See https://github.com/Netcracker/qubership-ai-packages/blob/main/agent-packages/change-description-authoring/.apm/skills/change-description-authoring/SKILL.md#pull-request-title

From my point of view, it produces decent commit messages: #5278, #5230, #5213

@FrankChen021

Copy link
Copy Markdown
Member Author

It may be long, but it's clear.

From my point of view, it is hard to answer "why do we need the change at all?" question. As a reviewer and Calcite maintainer, I don't want to dig into details until I understand why is that needed at all.

Frank, I wonder if you could try this skill: https://github.com/Netcracker/qubership-ai-packages/tree/main/agent-packages/change-description-authoring

See https://github.com/Netcracker/qubership-ai-packages/blob/main/agent-packages/change-description-authoring/.apm/skills/change-description-authoring/SKILL.md#pull-request-title

From my point of view, it produces decent commit messages: #5278, #5230, #5213

That's a good suggestion. Let's talk about this case here, the "why" question I think is stated in the JIRA as:

https://issues.apache.org/jira/browse/CALCITE-7809

RexShuttle and SqlShuttle visit every child and return either the original child or a replacement. They currently copy operand collections before knowing whether any child changes, then discard those copies when the traversal is read-only.

Make these operand collections copy-on-write. Allocate only when the first changed child is encountered, while preserving RexShuttle's immutable-result contract and SqlShuttle's explicit alwaysCopy behavior.

The isolated Druid benchmark reduced allocation by 2.72% at 100,000 string literals and 1.55% at 1,000,000 string literals.

Do you think the description is clear to answer the why ?
If not, then I can improve the description, if yes, I think the problem here is that we have PR and JIRA separated, next time I can attach these description in PR, and to focus on why first, I can fold the details by default in the description. does it sound good to you?

@FrankChen021

Copy link
Copy Markdown
Member Author

@vlsi I updated the description, does it sound good to you?

@vlsi

vlsi commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

The updated description is better:

  • The first sentence in "Why" clarifies everything. From my point of view it makes a huge difference.
  • I think "what" section is excessively large. There's no point in those ascii-charts. A mere "reuse input operand list and create a new only when at least one operand changes" would probably do.
  • I'm not sure listing "I ran tests, I ran autostyle" adds value. We have CI for that. I would prefer "verification" to include "targeted" information for the PR. For instance, it could be "here's a test I added, here are Calcite benchmark results.

PS. The PR still misses calcite-level tests, and calcite-level benchmarks. If the PR had those tests, the description could be way better.

@FrankChen021

Copy link
Copy Markdown
Member Author

PS. The PR still misses calcite-level tests, and calcite-level benchmarks. If the PR had those tests, the description could be way better.

Correct. I have not done this yet, it takes time. Thanks.

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.

5 participants