- 
                Notifications
    
You must be signed in to change notification settings  - Fork 177
 
          Implement reverse performance optimization
          #4056
        
          New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
reverse performance optimization
      There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm curious about the cases where the double reverse optimization matters in practice, seems unlikely that someone would reverse something twice like that except by accident
| // Check if the child is also a Reverse node | ||
| if (node.getChild().size() == 1 | ||
| && node.getChild().get(0) instanceof org.opensearch.sql.ast.tree.Reverse) { | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is logical optimization rule better way to do this? Or this is just edge case and usually users won't do reverse | reverse?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1, calcite opt-rule does not cover this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's not necessary to check duplicate Reverse nodes. Calcite will ensure the top sort collation by overriding the child's collation with current one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's not necessary to check duplicate
Reversenodes. Calcite will ensure the top sort collation by overriding the child's collation with current one.
For the previous implementation (adding row_number window function), Calcite can do nothing.
+1 to add a opt-rule, it could cover more cases:
... | reverse | reverse
... | reverse | fields ... | reverse
... | reverse | where ... | reverse
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you all for the excellent feedback about using Calcite optimization rules! After implementing and testing the SortDirectionOptRule, I discovered that you were right - Calcite's built-in optimization already handles sort merging effectively. The physical plans were identical with or without the custom rule.
Based on this, I've removed the custom optimization rule and now rely entirely on Calcite's mature optimization framework. The core improvement remains in PlanUtils: reverse detection that flips sort directions instead of using window functions.
        
          
                core/src/main/java/org/opensearch/sql/calcite/CalciteRelNodeVisitor.java
              
                Outdated
          
            Show resolved
            Hide resolved
        
      | // Remove row number column | ||
| context.relBuilder.projectExcept(context.relBuilder.field(REVERSE_ROW_NUM)); | ||
| 
               | 
          ||
| RelCollation collation = context.relBuilder.peek().getTraitSet().getCollation(); | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Be careful of using getTraitSet() to get fieldCollations because there is an edge case when it returns a different instance than RelCollation.
Sometimes, it returns a RelCompositeTrait instance containing all collation equivalent candidates. For example, this child PPL query source=EMP | sort SAL | eval a = cast(SAL as double) | where a > 2 will return RelCompositeTrait instance like [[5 ASC-nulls-first], [8]]. The eighth projected column is a cast expression but has the equivalent collation with [5 ASC-nulls-first]. The traitSet put equivalences together into a RelCompositeTrait.
A better way is probably List<RelCollation> collations =  context.relBuilder.getCluster().getMetadataQuery().collations(context.relBuilder.peek()). You can pick the first valid RelCollation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Calcite has some issue of carrying NullDirection when deducing collation for expressions. See: https://github.com/apache/calcite/blob/main/core/src/main/java/org/apache/calcite/rel/metadata/RelMdCollation.java#L358-L360.
If we really care about reversing null direction, another option is we can find the closest sort node and reverse based on that collation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! I switched from getTraitSet().getCollation() to getMetadataQuery().collations() to properly handle RelCompositeTrait instances and collation equivalences.
Null direction preservation: The optimization rule now properly handles and reverses null directions when flipping sort collations.
Signed-off-by: Selina Song <[email protected]>
Signed-off-by: Selina Song <[email protected]>
Signed-off-by: Selina Song <[email protected]>
Signed-off-by: Selina Song <[email protected]>
f6a6803    to
    4343b47      
    Compare
  
    Signed-off-by: Selina Song <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @selsong , thanks for putting this together, and I just left some comments.
        
          
                core/src/main/java/org/opensearch/sql/calcite/CalciteRelNodeVisitor.java
          
            Show resolved
            Hide resolved
        
              
          
                core/src/main/java/org/opensearch/sql/calcite/plan/OpenSearchRules.java
              
                Outdated
          
            Show resolved
            Hide resolved
        
              
          
                core/src/main/java/org/opensearch/sql/calcite/plan/OpenSearchRules.java
              
                Outdated
          
            Show resolved
            Hide resolved
        
              
          
                core/src/main/java/org/opensearch/sql/calcite/rule/SortReverseOptimizationRule.java
              
                Outdated
          
            Show resolved
            Hide resolved
        
              
          
                integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteExplainIT.java
              
                Outdated
          
            Show resolved
            Hide resolved
        
              
          
                core/src/main/java/org/opensearch/sql/calcite/utils/PlanUtils.java
              
                Outdated
          
            Show resolved
            Hide resolved
        
      Signed-off-by: Selina Song <[email protected]>
Signed-off-by: Selina Song <[email protected]>
Signed-off-by: Selina Song <[email protected]>
…mplify null direction handling Signed-off-by: Selina Song <[email protected]>
7feb3f3    to
    2501ed4      
    Compare
  
    Signed-off-by: Selina Song <[email protected]>
          
 Thanks for the note! I agree. It’s extremely unlikely that users would intentionally issue a double reverse and there is no usage as of now. This PR is now only focused on flipping the sort direction, and Calcite's default optimization takes care of the double reverse.  | 
    
        
          
                core/src/main/java/org/opensearch/sql/calcite/plan/OpenSearchRules.java
              
                Outdated
          
            Show resolved
            Hide resolved
        
              
          
                core/src/main/java/org/opensearch/sql/calcite/rule/SortReverseOptimizationRule.java
              
                Outdated
          
            Show resolved
            Hide resolved
        
              
          
                integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteExplainIT.java
              
                Outdated
          
            Show resolved
            Hide resolved
        
              
          
                core/src/main/java/org/opensearch/sql/calcite/rule/SortReverseOptimizationRule.java
              
                Outdated
          
            Show resolved
            Hide resolved
        
              
          
                core/src/main/java/org/opensearch/sql/calcite/rule/SortReverseOptimizationRule.java
              
                Outdated
          
            Show resolved
            Hide resolved
        
      | import org.apache.logging.log4j.Logger; | ||
| import org.opensearch.sql.calcite.plan.LogicalSystemLimit; | ||
| 
               | 
          ||
| /** Combines sort then reverse into 1 sort. */ | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a little about curious what's the behavior without this rule. Won't multiple sorts on the same fields be merged?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right. Multiple sorts on the same fields are merged so without this rule physical plans are identical due to Calcite's built-in physical optimization. I think this rule ensures consistent optimization across different query patterns, especially in post-aggregation scenarios where physical rules might not apply.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you provide an example where default physical rule might not apply?
There is a SortRemoveRule in Calcite that changes the traits based on the Sort collation requirement. For this two sorts case, I think it will pick one of them anyway. Seems this optimization might be overlapped with existing rule.
In physical plan rules, Calcite will ensure query do not lose sort semantics by adding AbstractConverter node over applicable nodes that require sorting. The AbstractConverter node checks if the child collation satisfies the expected collation to decide whether to add a sort between parent and child. The sort added by AbstractConverter should be winner of the two sorts.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you both for the insightful discussion! You were absolutely correct, and I was initially wrong in my assessment. After further testing and analysis, I've confirmed that:
Calcite's built-in optimization is sufficient - SortRemoveRule and AbstractConverter nodes handle sort merging effectively
Physical plans are identical with or without the custom rule, so the custom rule was redundant
Based on your feedback, I've removed the SortDirectionOptRule entirely. The core improvement remains: reverse detection that flips existing sort directions. Calcite's built-in optimization then handles the sort merging at the physical level.
| } | ||
| 
               | 
          ||
| @Override | ||
| public boolean matches(RelOptRuleCall call) { | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are there specific reasons on overriding the method instead of using a config?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think in this case override provides more control for complex matching logic like checking fetch limits and intermediate nodes.
| + " LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4]," | ||
| + " SAL=[$5], COMM=[$6], DEPTNO=[$7], __reverse_row_num__=[ROW_NUMBER() OVER ()])\n" | ||
| + " LogicalTableScan(table=[[scott, EMP]])\n"; | ||
| "LogicalSort(sort0=[$0], dir0=[DESC])\n" + " LogicalTableScan(table=[[scott, EMP]])\n"; | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In my understanding, it falls back to the original implementation with window functions when there is no preceding sort. In this test, if there is not reverse, are the fields sort by $0 (EMPNO) by default?
If not, this test case should fallback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, EMP table has implicit ordering by EMPNO ($0) in test data. This is detected as an existing sort and converts reverse to a simple DESC sort instead of using window functions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is EMP table implicitly ordered by EMPNO? How is the order inferred / detected?
Update: I tried debugging, and found that the table does contain a collation trait with EMPNO sorted even with source=EMP. I don't know where is this collation from though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
interesting, that's why there is no test case for using ROW_NUMBER in this test right?
Signed-off-by: Selina Song <[email protected]>
| LogicalSort outerSort = call.rel(0); | ||
| org.apache.calcite.rel.RelNode intermediate = call.rel(1); | ||
| LogicalSort innerSort = call.rel(2); | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this pattern needs careful consideration. There are lots of cases to be considered.
If intermediate is a Project node without WINDOW function, the pattern is supposed to be optimized by SortProjectTransposeRule. And it will be changed to Project - outerSort - innerSort. Other bunch of rules may optimize the changed pattern to keep outerSort.
If the Project contains WINDOW function, we should not change outerSort under the intermediate node. Because it may change its semantics.
If the intermediate node is aggregation, suppose the sort is on the group by column, I think the outerSort over aggregated result will be more efficient because the row count will be reduced a lot after aggregation. Check if Calcite can make it like outerSort(groupByColumn) - Aggregate(count) by groupByColumn
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, you're right about these cases. Given that Calcite's built-in optimization already handles these cases correctly and efficiently, I've removed the custom optimization rule entirely. This eliminates the risk of interfering with Calcite's sophisticated rule interactions while preserving the core performance improvement.
Signed-off-by: Selina Song <[email protected]>
Signed-off-by: Selina Song <[email protected]>
        
          
                integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteExplainIT.java
              
                Outdated
          
            Show resolved
            Hide resolved
        
      Signed-off-by: Selina Song <[email protected]>
Signed-off-by: Selina Song <[email protected]>
| + " LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4]," | ||
| + " SAL=[$5], COMM=[$6], DEPTNO=[$7], __reverse_row_num__=[ROW_NUMBER() OVER ()])\n" | ||
| + " LogicalTableScan(table=[[scott, EMP]])\n"; | ||
| "LogicalSort(sort0=[$0], dir0=[DESC])\n" + " LogicalTableScan(table=[[scott, EMP]])\n"; | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
interesting, that's why there is no test case for using ROW_NUMBER in this test right?
| context.relBuilder.projectExcept(context.relBuilder.field(REVERSE_ROW_NUM)); | ||
| 
               | 
          ||
| // Check if there's an existing sort to reverse | ||
| List<RelCollation> collations = | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So we assume the head of this collation list must come from the sort command right before reverse command right? Is it always true or the reverse logic below works even though it's not true?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Yes, this test uses the EMP table which has a natural ordering by EMPNO. When reverse is called, Calcite's metadata query detects this existing collation and reverses it directly, so we see LogicalSort(sort0=[$0], dir0=[DESC]) instead of ROW_NUMBER. The ROW_NUMBER fallback only occurs when there's no existing collation.
 - No, the assumption isn't always true, but I think the logic works correctly when its not true. getMetadataQuery().collations() returns all collations Calcite detects (natural orderings, index orderings, sort operations), not just the immediate sort command. We take the first available RelCollation (which may contain multiple sort fields) and reverse all its field collations at once.
 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For Q2: Take query ... | sort | X | Y | Z | reverse for example, we should be good as long as commands in-between (X, Y, Z) doesn't rely on the output order of sort command, right? Just thinking any counterexample, such as ... | sort | head | reverse?
I'm thinking is it too early to do this in visitReverse? Essentially, our CalciteRelNodeVisitor converts AST to its logical representation by RelNode. Just wondering what does it look like if we simply translate reverse to logical sort operator here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great question about edge cases! I tested both | sort | head 10 | reverse and | sort | eval | head | reverse patterns and they work correctly. In the first part of visitReverse the SORT ASC and LIMIT for head are combined into LogicalSort(sort0=[$0], dir0=[ASC-nulls-first], fetch=[10]) and then the reverseCollation is added with LogicalSort(sort0=[$0], dir0=[DESC-nulls-last]). The physical plan pushes SORT ASC + LIMIT N to OpenSearch, then applies DESC sort on the limited results. Let me know if there are any other edge cases, Thanks!
| { | ||
| "calcite": { | ||
| "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n LogicalProject(cpu_usage=[$0], @timestamp=[$1])\n LogicalSort(sort0=[$8], dir0=[DESC])\n LogicalProject(cpu_usage=[$0], @timestamp=[$1], _id=[$2], _index=[$3], _score=[$4], _maxscore=[$5], _sort=[$6], _routing=[$7], __reverse_row_num__=[ROW_NUMBER() OVER ()])\n LogicalSort(sort0=[$8], dir0=[DESC])\n LogicalProject(cpu_usage=[$0], @timestamp=[$1], _id=[$2], _index=[$3], _score=[$4], _maxscore=[$5], _sort=[$6], _routing=[$7], __reverse_row_num__=[ROW_NUMBER() OVER ()])\n CalciteLogicalIndexScan(table=[[OpenSearch, events]])\n", | ||
| "physical": "EnumerableCalc(expr#0..3=[{inputs}], proj#0..1=[{exprs}])\n EnumerableLimit(fetch=[10000])\n EnumerableSort(sort0=[$3], dir0=[DESC])\n EnumerableWindow(window#0=[window(rows between UNBOUNDED PRECEDING and CURRENT ROW aggs [ROW_NUMBER()])])\n EnumerableSort(sort0=[$2], dir0=[DESC])\n EnumerableWindow(window#0=[window(rows between UNBOUNDED PRECEDING and CURRENT ROW aggs [ROW_NUMBER()])])\n CalciteEnumerableIndexScan(table=[[OpenSearch, events]], PushDownContext=[[PROJECT->[cpu_usage, @timestamp]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"cpu_usage\",\"@timestamp\"],\"excludes\":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n" | ||
| } | ||
| } No newline at end of file | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this explain result and explain_double_reverse_sort_no_op used somewhere? I guess they were used for the elimination rule of double reverse. Then you delete the rule and relevant tests
| 
           @selsong Can you follow up on this? If not, we can take it over  | 
    
| 
           This PR is stalled because it has been open for 2 weeks with no activity.  | 
    
Description
This PR updates the Calcite planner to handle
reversecorrectly by flipping sort directions in pushdown.It also documents and tests edge cases such as double or consecutive
reverseusage, even though these are not expected in normal user queries.1. Flip sort direction
Query:
Logical Plan:
Physical Plan:
Explanation:
The single reverse flips the original sort order (balance ASC, firstname DESC) to the reversed order (balance DESC, firstname ASC).
2. Double reverse
Query:
Logical Plan:
Physical Plan:
Explanation:
The two reverses cancel each other out. The final sort order is the same as the original query (balance ASC, firstname DESC).
3. Reverse after multiple sorts
Query:
Logical Plan:
Physical Plan:
Explanation:
The last sort + reverse overrides the earlier sort + balance. The final pushed-down order is firstname ASC.
Related Issues
Resolves #3924
Check List
--signoff.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.