[MSE] Build ORDER BY on SortOperator instead of SortedMailboxReceiveOperator - #19412
Open
gortiz wants to merge 5 commits into
Open
[MSE] Build ORDER BY on SortOperator instead of SortedMailboxReceiveOperator#19412gortiz wants to merge 5 commits into
gortiz wants to merge 5 commits into
Conversation
SortedMailboxReceiveOperator establishes the global order for every MSE ORDER BY by buffering every row from every mailbox and sorting once. It has no access to the fetch/offset of the SortNode above it, so a 'SELECT ... ORDER BY k LIMIT 10' over k senders holds k * (limit + offset) rows at the receiver to return limit rows. That placement dates to apache#10408, which moved sorting into the receive operator as a stand-in until sender-side sorting existed, and to apache#10570, which split the class because one loop could not serve both consumption disciplines. PinotSortExchangeNodeInsertRule always re-parents the Sort on top of the exchange it creates, so a SortOperator is always available to do the work; marking the receive sort-on-receiver only relocated it to the operator that knows least. - PinotSortExchangeNodeInsertRule no longer sets isSortOnReceiver, so a plain ORDER BY now uses MailboxReceiveOperator plus SortOperator. The exchange stays a PinotLogicalSortExchange so PinotSortExchangeCopyRule still pushes the sender-side top-N down. - SortOperator becomes an abstract base with a factory that picks by what bounds the result, and each implementation names itself in the explain plan: LimitSortOperator (SORT_LIMIT) streams and only applies offset/fetch; TopNSortOperator (SORT_TOP_N) keeps a bounded heap of fetch + offset entries; FullSortOperator (SORT_FULL) buffers and sorts once, and is chosen only when nothing bounds the result. A full sort beats the previous unbounded PriorityQueue, which paid O(n log n) in and out. - All three emit their result as a stream of blocks of at most 10000 rows instead of one block holding everything, which was expensive to serialize and forced the consumer to materialize the whole result at once. - SortedMailboxReceiveOperator is deprecated. It is still reached through the window and ordered-aggregate exchange rules, which have no SortNode above the exchange, and through plans from a broker that predates this change, so it cannot be removed yet.
The factory also routed a SortNode whose input was a SortedMailboxReceiveOperator to LimitSortOperator, carrying over master's 'input is already sorted, skip the sort' optimization. Dropping it makes the invariant simpler: LimitSortOperator assumes nothing about its input and is chosen only when no ordering is required. Skipping the sort was never a correctness requirement - re-sorting an ordered stream yields the same rows - and the type test it rested on never checked that the input was ordered on this SortNode's collation. Since PinotSortExchangeNodeInsertRule stopped marking the receive sort-on-receiver, the only plans that still pair a SortNode with a SortedMailboxReceiveOperator come from a broker predating that change, where the saving is one redundant pass over a stream that operator had already buffered and sorted in full. SortOperator also stops referencing the deprecated class as a side effect.
…ator The previous commit replaced an unbounded PriorityQueue with a single sort on the strength of a complexity argument. Both are O(n log n), so the claim rested entirely on constants and locality and deserved measuring. BenchmarkMseSortImplementations compares the two paths using the real SortUtils.SortComparator and SelectionOperatorUtils.addToPriorityQueue, over 3-column rows, at 3 forks x 10 iterations: | input | rows | sort | heap | speedup | |----------------|------|--------|--------|---------| | random | 10K | 0.70ms | 1.09ms | 1.6x | | random | 1M | 302ms | 590ms | 2.0x | | 64 sorted runs | 10K | 0.23ms | 0.91ms | 4.0x | | 64 sorted runs | 1M | 82ms | 369ms | 4.5x | The sorted-runs rows are the ones that matter. Once PinotSortExchangeCopyRule pushes the sort down to the senders, a receive stage sees one sorted run per sender concatenated; TimSort detects those runs and a heap cannot. The trade is allocation, which was not part of the original claim: the merge buffer costs 1.2x (1M rows) to 1.7x (10K rows) what the heap allocates. Faster and hungrier, not strictly better. The benchmark also showed FullSortOperator should pre-size its list the way the queue it replaced was pre-sized. Rows arrive one block at a time, so a default-capacity list grows repeatedly: 242KB against 133KB at 10K rows. The gap closes as the result grows and the sort dominates.
…egate rules PinotSortExchangeNodeInsertRule already stopped marking its receive sort-on-receiver, but the window and ordered-aggregate rules still did, and those are the worse case: they insert SortedMailboxReceiveOperator with no SortOperator above it at all, because WindowAggregateOperator requires ordered input and does no ordering of its own. Both rules now place an explicit Sort over the exchange instead. The Sort carries no fetch, so getValueAsInt(null) returns -1, numRowsToKeep falls back to Integer.MAX_VALUE and every row is kept - the same semantics as the unbounded list the receive operator used. An explicit Integer.MAX_VALUE fetch would be wrong, because SortOperator computes fetch + offset and would overflow. PinotWindowExchangeNodeInsertRule.matches() had to change with it. Its idempotence guard was !isExchange(window.getInput()); placing a Sort over the exchange makes the window's input a Sort, so the rule stopped recognising its own output and re-fired forever. SELECT AVG(col3), AVG(col3) OVER(PARTITION BY col3) FROM a GROUP BY col3 ORDER BY col3 hung the planner. The guard now also treats a Sort over an exchange as already processed. The aggregate rules need no such change: they match LogicalAggregate and emit PinotLogicalAggregate, so they cannot re-fire on their own output. No planner path produces SortedMailboxReceiveOperator after this. The class stays deprecated rather than deleted because MailboxReceiveNode.sort is a proto field and an older broker still sets it. 121 window plans regenerated from actual planner output.
…o the benchmark Two review fixes. The idempotence guard added for the Sort this rule now emits accepted any Sort over an exchange. A Sort that orders by different keys, or that trims, belongs to another part of the plan, and treating it as this rule's own output would leave the window with neither its exchange nor the ordering it requires - WindowAggregateOperator does none of its own. The guard now also requires the collation to equal the window group's order keys and the Sort to carry no fetch or offset, which is exactly what onMatch emits. Window groups are validated to be a single group, and updateLiteralArgumentsInWindowGroup rewrites only agg call operands and frame bounds, so the order keys read here are the ones onMatch uses. FullSortOperator carried the benchmark result table in its javadoc, where the numbers go stale silently. The table now lives in BenchmarkMseSortImplementations next to the code that produces it, marked as orientation rather than fact, and the operator refers to the benchmark instead.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #19412 +/- ##
============================================
+ Coverage 67.56% 67.58% +0.02%
+ Complexity 1430 1424 -6
============================================
Files 3486 3489 +3
Lines 224173 224236 +63
Branches 35381 35391 +10
============================================
+ Hits 151462 151553 +91
+ Misses 60684 60646 -38
- Partials 12027 12037 +10
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two operators solve the same problem, and one of them is worse
The multi-stage engine has two ways to produce sorted output.
SortedMailboxReceiveOperatorSortOperatorfetch/offsetfetch + offset, or the whole input when there is no limitSortedMailboxReceiveOperatorsits in an awkward middle: it neither requires its senders to be sorted nor takes any advantage when they are, and it cannot see thefetch/offsetthatSortOperatorhas been given.What master builds
PinotSortExchangeNodeInsertRulere-parents theSorton top of the exchange it creates, so anORDER BYalways gets both operators:SortOperatorrecognises its input by type and stands down:So the operator that knows the bound does nothing but trim, and the operator that does the work is blind to the bound. Window functions and
WITHIN GROUPaggregates are worse still:PinotWindowExchangeNodeInsertRuleandPinotAggregateExchangeNodeInsertRuleinsertSortedMailboxReceiveOperatoralone, with noSortOperatorabove it at all.WindowAggregateOperatorstates the contract it depends on and does no ordering of its own:How it got this way
The split is not a considered design. #10408 (Mar 2023) moved sorting into the receive operator, adding a
PriorityQueue,_collationKeysand_isSortOnReceiverto what was then a singleMailboxReceiveOperator. Its owncommit body says what it was waiting for:
That forced one loop to serve two incompatible disciplines — return the first block that arrives for an unsorted
receive, and drain every mailbox before emitting anything for a sorted one. #10570 split the class along exactly
that seam a month later; its message says the non-sorted half restores "behavior identical to prior to #10408". The
sorted half was born with the TODO it still carries today:
Sender-side sorting arrived later, through
PinotSortExchangeCopyRule. The receive operator never caught up, andSortOperator— which can be placed anywhere and already knows the bound — was the better home for the work allalong.
Why the pairing is worse than the alternative
It fetches everything, then applies the limit.
SortedMailboxReceiveOperatorhas nonumRowsToKeep; it accumulates unconditionally:For
SELECT ... ORDER BY k LIMIT 10acrossksenders that isk × (limit + offset)rows held to returnlimit. A plainMailboxReceiveOperatorfeedingSortOperator's bounded heap holdslimit + offset. TheearlyTerminate()SortOperatorsends afterwards saves nothing — the buffering has already happened.It is slower even with no limit. Its
ArrayList+sortis fine, but it emits the whole result as a single block, which is expensive to serialize and forces the consumer to materialize all of it at once.It makes the work built on top of it harder than it needs to be. #19121 adds a streaming k-way merge so a receive can finally exploit sorted senders — the right feature, and the measured payoff is real. But because the merge has to live inside
SortedMailboxReceiveOperator, it also needs a per-stream handle abstraction onBlockingMultiStreamConsumer, a read-mode latch, a planner gate and a runtime order check; and it lands in the one operator that cannot seefetch/offset, so it has nothing to bound what it buffers. The same feature implemented overSortOperatorhas the limit in hand and one fewer abstraction to introduce. The goal here is to make that PR smaller, not to argue against it.This PR
Deprecates
SortedMailboxReceiveOperatorand buildsORDER BYasMailboxReceiveOperator+SortOperator, so that sorting has one home instead of two — and so that work layered on top of it, #19121 included, has one place to go rather than the more awkward of the two.All three exchange rules stop setting
isSortOnReceiver, so nothing in the planner producesSortedMailboxReceiveOperatorany more:PinotSortExchangeNodeInsertRuleSortit re-parents only trimsSortdoes the workPinotWindowExchangeNodeInsertRuleSortover the exchangePinotAggregateExchangeNodeInsertRuleSortover the exchangeThe exchange stays a
PinotLogicalSortExchangethroughout, soPinotSortExchangeCopyRulestill matches and pushes the sender-side top-N down — emitting a plain exchange would look tidier and would silently cost far more than this saves.The
Sortinserted for a window or ordered aggregate carries nofetch, soRexExpressionUtils.getValueAsInt(null)returns-1,_numRowsToKeepfalls back toInteger.MAX_VALUE, and every row is kept — the same semantics as the unbounded list the receive operator used. An explicitInteger.MAX_VALUEfetch would be wrong here, becauseSortOperatorcomputesfetch + offsetand would overflow.PinotWindowExchangeNodeInsertRule.matches()had to change too, and this is a behavioural fix rather than cleanup. Its idempotence guard was!isExchange(window.getInput()). Placing aSortover the exchange makes the window's input aSort, so the rule stopped recognising its own output and re-fired forever —SELECT AVG(col3), AVG(col3) OVER(PARTITION BY col3) FROM a GROUP BY col3 ORDER BY col3hung the planner. The guard now also treatsSortover an exchange as already-processed.SortOperatorthen has to cover everything the receive operator used to, so it becomes an abstract base with a factory that picks by what bounds the result. Each implementation names itself in the explain plan:LimitSortOperatorSORT_LIMITTopNSortOperatorSORT_TOP_Nfetch, or a finite response limit, bounds the resultfetch + offsetFullSortOperatorSORT_FULLAll three emit their result as blocks of at most 10000 rows instead of one block holding everything.
LimitSortOperatoradditionally streams — input blocks are forwarded as they arrive withoffsetskipped and at mostfetchrows emitted, then the input is early-terminated.The
instanceof SortedMailboxReceiveOperatorcoupling inSortOperatorgoes away with it.Relationship to #19396 and #19121
Two open PRs add a k-way merge so a receive can exploit sorted senders: #19396 (window exchanges, sender-side sorting plus a
sortedOnSenderconfirmation through the mailbox protocol) and #19121 (thestreamingSortedMailboxReceiveoption). They were written independently and neither references the other. Both build onSortedMailboxReceiveOperator.Nothing here argues against the merge. It is the right feature and both PRs measure a real payoff. The argument is only about where it should live, and the proposal is one of ordering:
fetch/offset. Today the same job is done in two places and neither is the one that can bound it; adding a merge on top of that is building on the wrong base.Sortin the sending opchain rather than sorting insideMailboxSendOperator, and no sort at all when the sending stage's output is ordered by construction, as a sorted merge join's would be.The one thing that must not serve as the proof is the table's sorted-column property. It guarantees only that each segment is sorted internally, and says nothing about the order of a stage's output stream.
One related observation for #19396: it sorts inside
MailboxSendOperator. Pinot already sorts in the sender fragment —PinotSortExchangeCopyRulehas been emitting aLogicalSortbelow the exchange forORDER BYfor years, executed bySortOperator:Doing it in the send operator instead adds a third place that sorts, one that cannot see a limit and that the planner cannot reason about — #19396's own comment on
PinotSortExchangeNodeInsertRulenotes the sender would then be "sorting rows that are sorted already". KeepingMailboxSendOperatoragnostic about ordering and putting aSortin the sender opchain avoids that.Why the class is deprecated rather than deleted
No planner path reaches
SortedMailboxReceiveOperatorafter this change, butMailboxReceiveNode.sortis a proto field, so a broker running an older build still sendssort=trueand the server must still honour it. Deleting the class needs a release of overlap.The other direction is already safe: a new broker sends
sort=false, and an older server builds a plain receive plus its ownSortOperator, whoseinstanceof SortedMailboxReceiveOperatorcheck correctly fails so it does sort.Effect
ORDER BY k LIMIT nk × (limit + offset)rows at the receiverlimit + offsetORDER BY k, no limitWITHIN GROUPBenchmark
FullSortOperatorreplaces an unboundedPriorityQueuewith a single sort. Both areO(n log n), so the claim rests on constants and locality.BenchmarkMseSortImplementationsmeasures it with the realSortUtils.SortComparatorandSelectionOperatorUtils.addToPriorityQueue, 3-column rows, 3 forks × 10 iterations:The sorted-runs rows are the realistic ones: once the sender-side sort is pushed down, a receive stage sees one sorted run per sender concatenated, and TimSort detects those runs while a heap cannot.
The trade is allocation: the merge buffer costs 1.2× (1M rows) to 1.7× (10K rows) what the heap allocates. Faster and hungrier, not strictly better.
Testing
pinot-query-planner: 1505 tests, 0 failurespinot-query-runtime: 4566 tests, 0 failuresSortOperatorTestextended to pin implementation selection, streaming, offset/fetch across block boundaries, block splitting, and therequireSortstat per implementation.LogicalSort, and 121 window / ordered-aggregate plans that now show an explicitLogicalSortover the exchange. The 121 were regenerated from actual planner output rather than hand-edited.Follow-ups
Sortin their opchain, or from a stage that is ordered by construction — the work [MSE] Merge sender-sorted streams for ordered windows #19396 and Add streaming k-way merge to SortedMailboxReceiveOperator #19121 are doing.SortedMailboxReceiveOperatoronce no supported broker setsMailboxReceiveNode.sort, and dropisSortOnReceiverfromPinotLogicalSortExchangewith it.PinotSortExchangeCopyRuleonly pushes the sender-side sort down whenlimit + offset <= sortExchangeCopyThreshold(10000 by default). Above that, and with noLIMIT, senders ship unsorted and the receiver must full-sort.