You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pinot.apache.org by "arunkumarucet (via GitHub)" <gi...@apache.org> on 2026/10/15 08:52:56 UTC
[PR] Materialize non-order-by columns late on the sorted order-by path [pinot]
arunkumarucet opened a new pull request, #19879:
URL: https://github.com/apache/pinot/pull/19879
Fixes #19761
## Problem
`SELECT <wide projection> ... ORDER BY <sorted column> DESC LIMIT k` reads **every selected column for every document in the match set**, then keeps `k` rows. Cost scales with match-set size times projection width, not with `LIMIT`.
Pinot already avoids this on the *unsorted* order-by path — `SelectionPlanNode` projects only the order-by expressions there and `SelectionOrderByOperator` fetches the rest for the top-k doc ids. The sorted-column path, which is otherwise the faster one, did not.
## Change
In the `sortedColumnsPrefixSize > 0` branch, project only the order-by expressions, carry the doc id in the row at index `numOrderByExpressions`, and once the limit has been applied fetch the remaining expressions through `BitmapDocIdSetOperator.ascending` → `ProjectionOperator` → `TransformOperator` — the mechanism `SelectionOrderByOperator` already uses. The late fetch lives in `LinearSelectionOrderByOperator`, so both subclasses get it.
**It is gated to where it actually pays.** The linear operator stops as soon as the list builder is full, which with a fully sorted order-by prefix is after `limit + offset` documents; deferring there saves no reads and only adds a bitmap and a transform operator. So late materialization is enabled only when the scan is *not* bounded by the limit:
- `SelectionPartiallyOrderedByDescOperation` — no reverse cursor, so it must read the whole match set;
- an unsorted order-by suffix — which removes the limit bound on the scan.
A test asserts the entries read are *exactly equal* in the skipped case, so the gating cannot silently rot.
The project operator is built **exactly once**. Constructing one resolves a `DataSource` and a `ColumnContext` per selected column, and some implementations begin prefetching those columns in the constructor — so a build-and-discard probe to decide which operator will run would read exactly the columns this change exists to avoid reading. Whether the scan can iterate in query order is instead predicted from the order direction and `allowReverseOrder`; being wrong only costs the optimization, never correctness, because the operator choice still comes from the project operator itself.
Kill switch: `SET lateMaterializeSortedOrderBy = false` restores the previous behaviour.
## Measurements
SereneDB [SearchBench](https://github.com/serenedb/searchbench) `otel_logs`, 100M rows in 250 segments, single node. These are the suite's `recent` queries — "show me the N most recent matching log lines". Stock and patched builds interleaved over 4 cycles, medians reported, because the combine operator prunes segments by min/max once it has enough rows and single-shot latency is noisy.
| Query | stock p50 | this PR | speedup | stock entries post-filter | this PR | reduction |
|---|---|---|---|---|---|---|
| Q70 | 32.77 ms | 8.05 ms | **4.07x** | 1,343,488 | 359,414 | 3.7x |
| Q75 | 23.00 ms | 6.70 ms | **3.43x** | 927,720 | 275,467 | 3.4x |
| Q74 | 24.68 ms | 7.65 ms | **3.23x** | 978,264 | 247,266 | 4.0x |
| Q72 | 27.25 ms | 9.34 ms | **2.92x** | 436,302 | 144,162 | 3.0x |
| Q71 | 25.05 ms | 12.75 ms | **1.97x** | 490,896 | 135,252 | 3.6x |
| Q68 | 83.77 ms | 43.84 ms | **1.91x** | 118,900 | 71,350 | 1.7x |
| Q69 | 34.03 ms | 25.37 ms | 1.34x | 1,078,624 | 272,056 | 4.0x |
| Q76–Q83 (no `ORDER BY`) | — | — | **~1.00x** | 2,400 | 2,400 | 1.0x |
| **total** | **291.8 ms** | **152.7 ms** | **1.91x** | **5,390,994** | **1,521,767** | **3.5x** |
Entries post-filter goes from `numDocsScanned × 4` to `numDocsScanned × 1` plus the late fetch for the rows that survive the limit. The seven queries without an `ORDER BY` take a different operator and are the control: unchanged on both metrics. Row counts are identical throughout.
## Tests
Six new tests in `InnerSegmentSelectionSingleValueQueriesTest` run the same query with the option on and off and assert identical rows and schema, strictly fewer entries where late materialization applies and exactly equal entries where it is correctly skipped. They cover ASC, DESC in doc-id order, DESC in reverse order, `OFFSET`+`LIMIT`, an unsorted order-by suffix, a function in the projection (the late fetch builds its own `TransformOperator`, so the expression must resolve for both its values and its schema type) and null handling.
Null handling has its own test in `NullHandlingEnabledQueriesTest`, because `BaseSingleValueQueriesTest` has no null vectors and an assertion there would never reach the null branches. It uses a sorted, non-null order-by column and a deferred column containing nulls, and asserts the deferred nulls survive — mutating the late fetch's bitmap index makes it fail with `null` replaced by the INT default.
`testSelectionOrderBySingleSortedColumnDescDidOrder` previously asserted `numEntriesScannedPostFilter == 90000` (`30000 × 3` columns) — the exact waste this removes. It now asserts `30000 + 20`, and its row and schema assertions are unchanged.
Full `pinot-core` suite: **495 classes, 8,183 tests, 0 failures**.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org
Re: [PR] Materialize non-order-by columns late on the sorted order-by path [pinot]
Posted by "xiangfu0 (via GitHub)" <gi...@apache.org>.
xiangfu0 commented on code in PR #19879:
URL: https://github.com/apache/pinot/pull/19879#discussion_r4289848583
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -234,7 +312,105 @@ private void concatList(StringBuilder sb, List<?> list) {
@Override
protected SelectionResultsBlock getNextBlock() {
- return new SelectionResultsBlock(createDataSchema(), fetch(_listBuilderSupplier), _comparator, _queryContext);
+ List<Object[]> rows = fetch(_listBuilderSupplier);
+ if (!_lateMaterialize) {
+ return new SelectionResultsBlock(createDataSchema(), rows, _comparator, _queryContext);
+ }
+ return fetchRemainingExpressions(rows);
+ }
+
+ /// Fills the output expressions that were not projected during the scan, for the rows that survived the limit.
+ ///
+ /// The rows are visited in doc-id order because that is the order [BitmapDocIdSetOperator#ascending] returns, while
+ /// `rows` keeps the result order. Both views hold the same row arrays, so filling one fills the other.
+ private SelectionResultsBlock fetchRemainingExpressions(List<Object[]> rows) {
+ int numExpressions = _expressions.size();
+ List<ExpressionContext> nonOrderByExpressions = _expressions.subList(_numOrderByExpressions, numExpressions);
+ int numNonOrderByExpressions = nonOrderByExpressions.size();
+
+ int numRows = rows.size();
+ Object[][] rowsByDocId = rows.toArray(new Object[numRows][]);
+ Arrays.sort(rowsByDocId, Comparator.comparingInt(row -> (int) row[_numOrderByExpressions]));
+ RoaringBitmap docIds = new RoaringBitmap();
Review Comment:
The alignment of `rowsByDocId` with the ascending bitmap iteration quietly relies on every surviving row having a distinct doc id. That holds today, but if a future change ever let two rows share a doc id the bitmap would dedupe and the trailing rows would be left unfilled. A one-line `assert docIds.getCardinality() == numRows;` here would make that assumption fail loudly instead of silently misaligning.
##########
pinot-core/src/main/java/org/apache/pinot/core/plan/SelectionPlanNode.java:
##########
@@ -88,17 +88,38 @@ public Operator<SelectionResultsBlock> run() {
maxDocsPerCall = Math.min(limit + _queryContext.getOffset(), DocIdSetPlanNode.MAX_DOC_PER_CALL);
}
- BaseProjectOperator<?> projectOperator = getSortedByProject(expressions, maxDocsPerCall, orderByExpressions);
+ // Whether the scan can iterate in query order, and so stop once the list builder is full. Ascending always
+ // can; descending only when reverse iteration is permitted, because DocIdSetPlanNode only produces an
+ // ascending DocIdSetOperator. getSortedByProject below performs the actual switch and decides for real; this
+ // is a conservative prediction, and being wrong only costs the optimization, never correctness.
boolean asc = orderByExpressions.get(0).isAsc();
+ boolean canIterateInQueryOrder =
+ asc || QueryOptionsUtils.isReverseOrderAllowed(_queryContext.getQueryOptions());
+
+ // Projecting only the order-by expressions and fetching the rest for the rows that survive the limit pays off
+ // only when the scan cannot stop at the limit. The linear operator stops as soon as the list builder is full,
+ // which with a fully sorted order-by prefix is after limit+offset documents, so there is nothing to save
+ // there. The descending operator has to read the whole match set because there is no descending cursor.
+ boolean scanBoundedByLimit = canIterateInQueryOrder && sortedColumnsPrefixSize == orderByExpressions.size();
+ boolean lateMaterialize = expressions.size() > numOrderByExpressions && !scanBoundedByLimit
Review Comment:
The gate has no guard on limit size. When `limit + offset` reaches the match-set size, late materialization reads the same number of entries as the eager path but adds a whole extra pass: a bitmap build, a sort, an array copy, and a second decode/transform over every surviving row. With the option defaulting to on, a large-`LIMIT` export/pagination query could regress. Worth either a limit/selectivity guard here or shipping default-off until benchmarked.
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -234,7 +312,105 @@ private void concatList(StringBuilder sb, List<?> list) {
@Override
protected SelectionResultsBlock getNextBlock() {
- return new SelectionResultsBlock(createDataSchema(), fetch(_listBuilderSupplier), _comparator, _queryContext);
+ List<Object[]> rows = fetch(_listBuilderSupplier);
+ if (!_lateMaterialize) {
+ return new SelectionResultsBlock(createDataSchema(), rows, _comparator, _queryContext);
+ }
+ return fetchRemainingExpressions(rows);
+ }
+
+ /// Fills the output expressions that were not projected during the scan, for the rows that survived the limit.
+ ///
+ /// The rows are visited in doc-id order because that is the order [BitmapDocIdSetOperator#ascending] returns, while
+ /// `rows` keeps the result order. Both views hold the same row arrays, so filling one fills the other.
+ private SelectionResultsBlock fetchRemainingExpressions(List<Object[]> rows) {
+ int numExpressions = _expressions.size();
+ List<ExpressionContext> nonOrderByExpressions = _expressions.subList(_numOrderByExpressions, numExpressions);
+ int numNonOrderByExpressions = nonOrderByExpressions.size();
+
+ int numRows = rows.size();
+ Object[][] rowsByDocId = rows.toArray(new Object[numRows][]);
+ Arrays.sort(rowsByDocId, Comparator.comparingInt(row -> (int) row[_numOrderByExpressions]));
+ RoaringBitmap docIds = new RoaringBitmap();
+ for (Object[] row : rowsByDocId) {
+ docIds.add((int) row[_numOrderByExpressions]);
+ }
+
+ Set<String> columns = new HashSet<>();
+ for (ExpressionContext expression : nonOrderByExpressions) {
+ expression.getColumns(columns);
+ }
+ Map<String, DataSource> dataSourceMap = new HashMap<>();
+ for (String column : columns) {
+ dataSourceMap.put(column, _indexSegment.getDataSource(column, _queryContext.getSchema()));
Review Comment:
This uses the `getDataSource(column, schema)` overload, which is a new dependency on `_queryContext.getSchema()` being populated on the scan path (the eager path resolves columns through the project operator instead). Could you confirm the schema is always non-null here, e.g. for virtual/derived columns on the server side, so this can't NPE?
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -124,39 +159,76 @@ public IndexSegment getIndexSegment() {
public ExecutionStatistics getExecutionStatistics() {
long numEntriesScannedInFilter = _projectOperator.getExecutionStatistics().getNumEntriesScannedInFilter();
int numDocsScanned = getNumDocsScanned();
- long numEntriesScannedPostFilter = (long) numDocsScanned * _projectOperator.getNumColumnsProjected();
+ long numEntriesScannedPostFilter =
+ (long) numDocsScanned * _projectOperator.getNumColumnsProjected() + _numEntriesScannedPostFilterLate;
int numTotalDocs = _indexSegment.getSegmentMetadata().getTotalDocs();
return new ExecutionStatistics(numDocsScanned, numEntriesScannedInFilter, numEntriesScannedPostFilter,
numTotalDocs);
}
+ /// Number of expressions the project operator actually produces: all of them, or just the order-by prefix when
+ /// materialising late.
+ protected int getNumProjectedExpressions() {
+ return _columnContexts.length;
+ }
+
+ protected BlockValSet[] newBlockValSets() {
+ return new BlockValSet[getNumProjectedExpressions()];
+ }
+
protected IntFunction<Object[]> fetchBlock(ValueBlock valueBlock, BlockValSet[] blockValSets) {
- int numExpressions = _expressions.size();
+ int numProjected = getNumProjectedExpressions();
- for (int i = 0; i < numExpressions; i++) {
+ for (int i = 0; i < numProjected; i++) {
ExpressionContext expression = _expressions.get(i);
blockValSets[i] = valueBlock.getBlockValueSet(expression);
}
RowBasedBlockValueFetcher blockValueFetcher = new RowBasedBlockValueFetcher(blockValSets);
- if (!_nullHandlingEnabled) {
- return blockValueFetcher::getRow;
+ RoaringBitmap[] nullBitmaps;
+ if (_nullHandlingEnabled) {
+ nullBitmaps = new RoaringBitmap[numProjected];
+ for (int i = 0; i < numProjected; i++) {
+ nullBitmaps[i] = blockValSets[i].getNullBitmap();
+ }
+ } else {
+ nullBitmaps = null;
}
- RoaringBitmap[] nullBitmaps = new RoaringBitmap[numExpressions];
- for (int i = 0; i < numExpressions; i++) {
- nullBitmaps[i] = blockValSets[i].getNullBitmap();
+
+ if (!_lateMaterialize) {
+ if (nullBitmaps == null) {
+ return blockValueFetcher::getRow;
+ }
+ return (docId) -> {
+ Object[] row = blockValueFetcher.getRow(docId);
+ applyNulls(row, nullBitmaps, docId);
+ return row;
+ };
}
- return (docId) -> {
- Object[] row = blockValueFetcher.getRow(docId);
- for (int colId = 0; colId < nullBitmaps.length; colId++) {
- if (nullBitmaps[colId] != null && nullBitmaps[colId].contains(docId)) {
- row[colId] = null;
- }
+
+ // Leave room for the expressions fetched after the limit is applied, and carry the doc id in the first of those
+ // slots so the fetch knows which documents to read. That slot is overwritten by the first fetched value.
+ int numExpressions = _expressions.size();
+ int[] docIds = valueBlock.getDocIds();
+ return (rowId) -> {
+ Object[] row = new Object[numExpressions];
+ blockValueFetcher.getRow(rowId, row, 0);
+ if (nullBitmaps != null) {
+ applyNulls(row, nullBitmaps, rowId);
}
+ row[_numOrderByExpressions] = docIds[rowId];
return row;
};
}
+ private static void applyNulls(Object[] row, RoaringBitmap[] nullBitmaps, int rowId) {
Review Comment:
Small readability nit: the parameter is named `rowId` (and the sibling lambda at line 213 calls it `rowId` too) but it actually holds a block-local position, not a doc id. Since an off-by-one here is exactly the kind of bug this code has to avoid, a name like `rowIdInBlock` would make the intent clearer.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org
Re: [PR] Materialize non-order-by columns late on the sorted order-by path [pinot]
Posted by "arunkumarucet (via GitHub)" <gi...@apache.org>.
arunkumarucet commented on code in PR #19879:
URL: https://github.com/apache/pinot/pull/19879#discussion_r4291005452
##########
pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java:
##########
@@ -1048,6 +1048,11 @@ public static class QueryOptionKey {
public static final String ALLOW_REVERSE_ORDER = "allowReverseOrder";
public static final boolean DEFAULT_ALLOW_REVERSE_ORDER = false;
+ /// Selection with an order-by prefix on a sorted column: project only the order-by expressions during the
+ /// scan and fetch the remaining output expressions for the rows that survive the limit. Disabling it
+ /// restores reading every selected column for the whole match set.
+ public static final String LATE_MATERIALIZE_SORTED_ORDER_BY = "lateMaterializeSortedOrderBy";
Review Comment:
I agree with the principle, and I'd like to get there — just not in this PR. Two things pull against each other here.
Your argument is sound: the unsorted ORDER BY path already late-materializes with no query option, so an option on the sorted path is an inconsistency, and every flag is an execution mode that has to be kept tested for as long as it exists.
But @xiangfu0 raised a concrete regression on the planner gate: when `limit + offset` reaches the size of the match set, late materialization reads the same number of entries as the eager path and adds a whole second pass on top — a bitmap build, a sort, an array copy, and a second decode/transform over every surviving row. That shape is real (pagination and export queries), and the internal eligibility check doesn't catch it, because that check is about whether the scan is bounded by `LIMIT`, not about how large the limit is. With no flag there'd be no escape hatch for someone who hits it.
So I've shipped it default-off in `c4667fa792`, which also answers Xiang's request. What I'd propose:
1. This PR lands opt-in, with the eligibility check you asked to keep.
2. I benchmark where the crossover actually is — at what ratio of `limit + offset` to match-set size the second pass stops paying for itself.
3. If the regression turns out to be negligible, the flag comes out in a follow-up and this becomes automatic for eligible plans, exactly as you describe. If it isn't negligible, the numbers tell us what the eligibility check should test, and the flag can still come out once the check covers it.
That ordering is reversible: removing the flag later is easy, whereas shipping with no flag and discovering the regression in the wild needs another release to undo. Happy to file the follow-up issue now so it doesn't get lost. If you'd rather have it automatic in this PR regardless, say so and I'll make the change — I don't want to over-rotate on an edge case if you think it isn't one.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org
Re: [PR] Materialize non-order-by columns late on the sorted order-by path [pinot]
Posted by "xiangfu0 (via GitHub)" <gi...@apache.org>.
xiangfu0 merged PR #19879:
URL: https://github.com/apache/pinot/pull/19879
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org
Re: [PR] Materialize non-order-by columns late on the sorted order-by path [pinot]
Posted by "arunkumarucet (via GitHub)" <gi...@apache.org>.
arunkumarucet commented on code in PR #19879:
URL: https://github.com/apache/pinot/pull/19879#discussion_r4290974932
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -234,7 +312,105 @@ private void concatList(StringBuilder sb, List<?> list) {
@Override
protected SelectionResultsBlock getNextBlock() {
- return new SelectionResultsBlock(createDataSchema(), fetch(_listBuilderSupplier), _comparator, _queryContext);
+ List<Object[]> rows = fetch(_listBuilderSupplier);
+ if (!_lateMaterialize) {
+ return new SelectionResultsBlock(createDataSchema(), rows, _comparator, _queryContext);
+ }
+ return fetchRemainingExpressions(rows);
+ }
+
+ /// Fills the output expressions that were not projected during the scan, for the rows that survived the limit.
+ ///
+ /// The rows are visited in doc-id order because that is the order [BitmapDocIdSetOperator#ascending] returns, while
+ /// `rows` keeps the result order. Both views hold the same row arrays, so filling one fills the other.
+ private SelectionResultsBlock fetchRemainingExpressions(List<Object[]> rows) {
+ int numExpressions = _expressions.size();
+ List<ExpressionContext> nonOrderByExpressions = _expressions.subList(_numOrderByExpressions, numExpressions);
+ int numNonOrderByExpressions = nonOrderByExpressions.size();
+
+ int numRows = rows.size();
+ Object[][] rowsByDocId = rows.toArray(new Object[numRows][]);
+ Arrays.sort(rowsByDocId, Comparator.comparingInt(row -> (int) row[_numOrderByExpressions]));
+ RoaringBitmap docIds = new RoaringBitmap();
Review Comment:
Done in `c4667fa792`, though I upgraded it from an `assert` — the repo's surefire `argLine` has no `-ea` and I couldn't find it enabled anywhere else, so a Java `assert` here would never execute, in tests or in production. Since the failure you describe returns wrong data rather than an error, I used `Preconditions.checkState`. The cost is one `getCardinality()` read per query, against a projection pass over every surviving row. Say the word if you'd rather have the inert `assert` as documentation.
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -234,7 +312,105 @@ private void concatList(StringBuilder sb, List<?> list) {
@Override
protected SelectionResultsBlock getNextBlock() {
- return new SelectionResultsBlock(createDataSchema(), fetch(_listBuilderSupplier), _comparator, _queryContext);
+ List<Object[]> rows = fetch(_listBuilderSupplier);
+ if (!_lateMaterialize) {
+ return new SelectionResultsBlock(createDataSchema(), rows, _comparator, _queryContext);
+ }
+ return fetchRemainingExpressions(rows);
+ }
+
+ /// Fills the output expressions that were not projected during the scan, for the rows that survived the limit.
+ ///
+ /// The rows are visited in doc-id order because that is the order [BitmapDocIdSetOperator#ascending] returns, while
+ /// `rows` keeps the result order. Both views hold the same row arrays, so filling one fills the other.
+ private SelectionResultsBlock fetchRemainingExpressions(List<Object[]> rows) {
+ int numExpressions = _expressions.size();
+ List<ExpressionContext> nonOrderByExpressions = _expressions.subList(_numOrderByExpressions, numExpressions);
+ int numNonOrderByExpressions = nonOrderByExpressions.size();
+
+ int numRows = rows.size();
+ Object[][] rowsByDocId = rows.toArray(new Object[numRows][]);
+ Arrays.sort(rowsByDocId, Comparator.comparingInt(row -> (int) row[_numOrderByExpressions]));
+ RoaringBitmap docIds = new RoaringBitmap();
+ for (Object[] row : rowsByDocId) {
+ docIds.add((int) row[_numOrderByExpressions]);
+ }
+
+ Set<String> columns = new HashSet<>();
+ for (ExpressionContext expression : nonOrderByExpressions) {
+ expression.getColumns(columns);
+ }
+ Map<String, DataSource> dataSourceMap = new HashMap<>();
+ for (String column : columns) {
+ dataSourceMap.put(column, _indexSegment.getDataSource(column, _queryContext.getSchema()));
Review Comment:
Confirmed, and I've added a comment at that line. It isn't a new dependency: `ProjectPlanNode:77` resolves every selected column through this exact `getDataSource(column, _queryContext.getSchema())` call, as do `FilterPlanNode`, `DistinctPlanNode` and `AggregationPlanNode`. The late fetch resolves a strict subset of the columns the eager path would have resolved the same way, so if the schema were null the eager path would NPE first on the same query. The schema is only dereferenced when `getDataSourceNullable` returns null, i.e. for a virtual column the segment does not physically have, and it is set for every server query in `ServerQueryExecutorV1Impl:244`.
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -234,7 +312,105 @@ private void concatList(StringBuilder sb, List<?> list) {
@Override
protected SelectionResultsBlock getNextBlock() {
- return new SelectionResultsBlock(createDataSchema(), fetch(_listBuilderSupplier), _comparator, _queryContext);
+ List<Object[]> rows = fetch(_listBuilderSupplier);
+ if (!_lateMaterialize) {
+ return new SelectionResultsBlock(createDataSchema(), rows, _comparator, _queryContext);
+ }
+ return fetchRemainingExpressions(rows);
+ }
+
+ /// Fills the output expressions that were not projected during the scan, for the rows that survived the limit.
+ ///
+ /// The rows are visited in doc-id order because that is the order [BitmapDocIdSetOperator#ascending] returns, while
+ /// `rows` keeps the result order. Both views hold the same row arrays, so filling one fills the other.
+ private SelectionResultsBlock fetchRemainingExpressions(List<Object[]> rows) {
+ int numExpressions = _expressions.size();
+ List<ExpressionContext> nonOrderByExpressions = _expressions.subList(_numOrderByExpressions, numExpressions);
+ int numNonOrderByExpressions = nonOrderByExpressions.size();
+
+ int numRows = rows.size();
+ Object[][] rowsByDocId = rows.toArray(new Object[numRows][]);
+ Arrays.sort(rowsByDocId, Comparator.comparingInt(row -> (int) row[_numOrderByExpressions]));
+ RoaringBitmap docIds = new RoaringBitmap();
+ for (Object[] row : rowsByDocId) {
+ docIds.add((int) row[_numOrderByExpressions]);
+ }
+
+ Set<String> columns = new HashSet<>();
+ for (ExpressionContext expression : nonOrderByExpressions) {
+ expression.getColumns(columns);
+ }
+ Map<String, DataSource> dataSourceMap = new HashMap<>();
+ for (String column : columns) {
+ dataSourceMap.put(column, _indexSegment.getDataSource(column, _queryContext.getSchema()));
+ }
+ int numColumns = columns.size();
+
+ BitmapDocIdSetOperator docIdSetOperator = BitmapDocIdSetOperator.ascending(docIds, numRows);
+ try (ProjectionOperator projectionOperator =
+ ProjectionOperatorUtils.getProjectionOperator(dataSourceMap, docIdSetOperator, _queryContext)) {
+ TransformOperator transformOperator =
+ new TransformOperator(_queryContext, projectionOperator, nonOrderByExpressions);
+
+ BlockValSet[] blockValSets = new BlockValSet[numNonOrderByExpressions];
+ int rowBaseId = 0;
+ ValueBlock valueBlock;
+ while ((valueBlock = transformOperator.nextBlock()) != null) {
+ for (int i = 0; i < numNonOrderByExpressions; i++) {
+ blockValSets[i] = valueBlock.getBlockValueSet(nonOrderByExpressions.get(i));
+ }
+ RowBasedBlockValueFetcher blockValueFetcher = new RowBasedBlockValueFetcher(blockValSets);
+ int numDocsFetched = valueBlock.getNumDocs();
+ for (int i = 0; i < numDocsFetched; i++) {
+ blockValueFetcher.getRow(i, rowsByDocId[rowBaseId + i], _numOrderByExpressions);
+ }
+ if (_nullHandlingEnabled) {
+ RoaringBitmap[] nullBitmaps = new RoaringBitmap[numNonOrderByExpressions];
+ for (int i = 0; i < numNonOrderByExpressions; i++) {
+ nullBitmaps[i] = blockValSets[i].getNullBitmap();
+ }
+ for (int i = 0; i < numDocsFetched; i++) {
+ Object[] row = rowsByDocId[rowBaseId + i];
+ for (int colId = 0; colId < numNonOrderByExpressions; colId++) {
+ if (nullBitmaps[colId] != null && nullBitmaps[colId].contains(i)) {
+ row[_numOrderByExpressions + colId] = null;
+ }
+ }
+ }
+ }
+ long entriesScanned = (long) numDocsFetched * numColumns;
+ _numEntriesScannedPostFilterLate += entriesScanned;
+ QueryScanCostContext scanCost = getScanCostContext();
+ if (scanCost != null) {
+ scanCost.addEntriesScannedPostFilter(entriesScanned);
+ }
+ rowBaseId += numDocsFetched;
+ }
+ return new SelectionResultsBlock(createDataSchema(transformOperator, nonOrderByExpressions), rows, _comparator,
+ _queryContext);
+ }
+ }
+
+ /// Data schema for the late-materialised case: order-by types come from the project operator, the rest from the
+ /// transform operator built for the late fetch.
+ private DataSchema createDataSchema(TransformOperator transformOperator,
Review Comment:
Done in `c4667fa792`. There's now one construction loop that resolves each expression's context from `_columnContexts` when the scan projected it and from the late transform otherwise. The protected no-argument method delegates with an empty late expression list, so its signature is preserved and the eager path falls out of the same loop.
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -124,39 +159,76 @@ public IndexSegment getIndexSegment() {
public ExecutionStatistics getExecutionStatistics() {
long numEntriesScannedInFilter = _projectOperator.getExecutionStatistics().getNumEntriesScannedInFilter();
int numDocsScanned = getNumDocsScanned();
- long numEntriesScannedPostFilter = (long) numDocsScanned * _projectOperator.getNumColumnsProjected();
+ long numEntriesScannedPostFilter =
+ (long) numDocsScanned * _projectOperator.getNumColumnsProjected() + _numEntriesScannedPostFilterLate;
int numTotalDocs = _indexSegment.getSegmentMetadata().getTotalDocs();
return new ExecutionStatistics(numDocsScanned, numEntriesScannedInFilter, numEntriesScannedPostFilter,
numTotalDocs);
}
+ /// Number of expressions the project operator actually produces: all of them, or just the order-by prefix when
+ /// materialising late.
+ protected int getNumProjectedExpressions() {
+ return _columnContexts.length;
+ }
+
+ protected BlockValSet[] newBlockValSets() {
Review Comment:
Done in `c4667fa792`. Dropped `newBlockValSets()` and kept `getNumProjectedExpressions()`; the two subclasses now allocate the array directly.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org
Re: [PR] Materialize non-order-by columns late on the sorted order-by path [pinot]
Posted by "xiangfu0 (via GitHub)" <gi...@apache.org>.
xiangfu0 commented on PR #19879:
URL: https://github.com/apache/pinot/pull/19879#issuecomment-6334026560
Documentation follow-up merged: https://github.com/pinot-contrib/pinot-docs/pull/1130 — documents lateMaterializeSortedOrderBy, its disabled default, sorted-prefix eligibility and limit-bounded scan exclusion.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org
Re: [PR] Materialize non-order-by columns late on the sorted order-by path [pinot]
Posted by "xiangfu0 (via GitHub)" <gi...@apache.org>.
xiangfu0 commented on code in PR #19879:
URL: https://github.com/apache/pinot/pull/19879#discussion_r4285734070
##########
pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java:
##########
@@ -1048,6 +1048,11 @@ public static class QueryOptionKey {
public static final String ALLOW_REVERSE_ORDER = "allowReverseOrder";
public static final boolean DEFAULT_ALLOW_REVERSE_ORDER = false;
+ /// Selection with an order-by prefix on a sorted column: project only the order-by expressions during the
+ /// scan and fetch the remaining output expressions for the rows that survive the limit. Disabling it
+ /// restores reading every selected column for the whole match set.
+ public static final String LATE_MATERIALIZE_SORTED_ORDER_BY = "lateMaterializeSortedOrderBy";
+ public static final boolean DEFAULT_LATE_MATERIALIZE_SORTED_ORDER_BY = true;
Review Comment:
This defaults a new, behavior-changing query optimization to ON for everyone. The project convention (and the neighbouring `allowReverseOrder`, `useMaterializedViewRewrite`, `usePhysicalOptimizer`) is that new features default to OFF until they've baked for a release. Enabling by default also silently changes the observable `numEntriesScannedPostFilter` metric on these queries, which dashboards/alerts may track. Could we default this to `false` and flip it later once it's proven in the wild?
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -234,7 +312,105 @@ private void concatList(StringBuilder sb, List<?> list) {
@Override
protected SelectionResultsBlock getNextBlock() {
- return new SelectionResultsBlock(createDataSchema(), fetch(_listBuilderSupplier), _comparator, _queryContext);
+ List<Object[]> rows = fetch(_listBuilderSupplier);
+ if (!_lateMaterialize) {
+ return new SelectionResultsBlock(createDataSchema(), rows, _comparator, _queryContext);
+ }
+ return fetchRemainingExpressions(rows);
+ }
+
+ /// Fills the output expressions that were not projected during the scan, for the rows that survived the limit.
+ ///
+ /// The rows are visited in doc-id order because that is the order [BitmapDocIdSetOperator#ascending] returns, while
+ /// `rows` keeps the result order. Both views hold the same row arrays, so filling one fills the other.
+ private SelectionResultsBlock fetchRemainingExpressions(List<Object[]> rows) {
+ int numExpressions = _expressions.size();
+ List<ExpressionContext> nonOrderByExpressions = _expressions.subList(_numOrderByExpressions, numExpressions);
+ int numNonOrderByExpressions = nonOrderByExpressions.size();
+
+ int numRows = rows.size();
+ Object[][] rowsByDocId = rows.toArray(new Object[numRows][]);
+ Arrays.sort(rowsByDocId, Comparator.comparingInt(row -> (int) row[_numOrderByExpressions]));
+ RoaringBitmap docIds = new RoaringBitmap();
+ for (Object[] row : rowsByDocId) {
+ docIds.add((int) row[_numOrderByExpressions]);
+ }
+
+ Set<String> columns = new HashSet<>();
+ for (ExpressionContext expression : nonOrderByExpressions) {
+ expression.getColumns(columns);
+ }
+ Map<String, DataSource> dataSourceMap = new HashMap<>();
+ for (String column : columns) {
+ dataSourceMap.put(column, _indexSegment.getDataSource(column, _queryContext.getSchema()));
+ }
+ int numColumns = columns.size();
Review Comment:
Minor: `numColumns` counts distinct physical columns, but if a non-order-by expression references a column that's also the order-by column, that column gets re-read here and counted again. Harmless for correctness, just a small over-read in the entries-scanned accounting. Worth a one-line note, no change required.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org
Re: [PR] Materialize non-order-by columns late on the sorted order-by path [pinot]
Posted by "Jackie-Jiang (via GitHub)" <gi...@apache.org>.
Jackie-Jiang commented on code in PR #19879:
URL: https://github.com/apache/pinot/pull/19879#discussion_r4288660665
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -234,7 +312,105 @@ private void concatList(StringBuilder sb, List<?> list) {
@Override
protected SelectionResultsBlock getNextBlock() {
- return new SelectionResultsBlock(createDataSchema(), fetch(_listBuilderSupplier), _comparator, _queryContext);
+ List<Object[]> rows = fetch(_listBuilderSupplier);
+ if (!_lateMaterialize) {
+ return new SelectionResultsBlock(createDataSchema(), rows, _comparator, _queryContext);
+ }
+ return fetchRemainingExpressions(rows);
+ }
+
+ /// Fills the output expressions that were not projected during the scan, for the rows that survived the limit.
+ ///
+ /// The rows are visited in doc-id order because that is the order [BitmapDocIdSetOperator#ascending] returns, while
+ /// `rows` keeps the result order. Both views hold the same row arrays, so filling one fills the other.
+ private SelectionResultsBlock fetchRemainingExpressions(List<Object[]> rows) {
+ int numExpressions = _expressions.size();
+ List<ExpressionContext> nonOrderByExpressions = _expressions.subList(_numOrderByExpressions, numExpressions);
+ int numNonOrderByExpressions = nonOrderByExpressions.size();
+
+ int numRows = rows.size();
+ Object[][] rowsByDocId = rows.toArray(new Object[numRows][]);
+ Arrays.sort(rowsByDocId, Comparator.comparingInt(row -> (int) row[_numOrderByExpressions]));
+ RoaringBitmap docIds = new RoaringBitmap();
+ for (Object[] row : rowsByDocId) {
+ docIds.add((int) row[_numOrderByExpressions]);
+ }
+
+ Set<String> columns = new HashSet<>();
+ for (ExpressionContext expression : nonOrderByExpressions) {
+ expression.getColumns(columns);
+ }
+ Map<String, DataSource> dataSourceMap = new HashMap<>();
+ for (String column : columns) {
+ dataSourceMap.put(column, _indexSegment.getDataSource(column, _queryContext.getSchema()));
+ }
+ int numColumns = columns.size();
+
+ BitmapDocIdSetOperator docIdSetOperator = BitmapDocIdSetOperator.ascending(docIds, numRows);
+ try (ProjectionOperator projectionOperator =
+ ProjectionOperatorUtils.getProjectionOperator(dataSourceMap, docIdSetOperator, _queryContext)) {
+ TransformOperator transformOperator =
+ new TransformOperator(_queryContext, projectionOperator, nonOrderByExpressions);
+
+ BlockValSet[] blockValSets = new BlockValSet[numNonOrderByExpressions];
+ int rowBaseId = 0;
+ ValueBlock valueBlock;
+ while ((valueBlock = transformOperator.nextBlock()) != null) {
+ for (int i = 0; i < numNonOrderByExpressions; i++) {
+ blockValSets[i] = valueBlock.getBlockValueSet(nonOrderByExpressions.get(i));
+ }
+ RowBasedBlockValueFetcher blockValueFetcher = new RowBasedBlockValueFetcher(blockValSets);
+ int numDocsFetched = valueBlock.getNumDocs();
+ for (int i = 0; i < numDocsFetched; i++) {
+ blockValueFetcher.getRow(i, rowsByDocId[rowBaseId + i], _numOrderByExpressions);
+ }
+ if (_nullHandlingEnabled) {
+ RoaringBitmap[] nullBitmaps = new RoaringBitmap[numNonOrderByExpressions];
+ for (int i = 0; i < numNonOrderByExpressions; i++) {
+ nullBitmaps[i] = blockValSets[i].getNullBitmap();
+ }
+ for (int i = 0; i < numDocsFetched; i++) {
+ Object[] row = rowsByDocId[rowBaseId + i];
+ for (int colId = 0; colId < numNonOrderByExpressions; colId++) {
+ if (nullBitmaps[colId] != null && nullBitmaps[colId].contains(i)) {
+ row[_numOrderByExpressions + colId] = null;
+ }
+ }
+ }
+ }
+ long entriesScanned = (long) numDocsFetched * numColumns;
+ _numEntriesScannedPostFilterLate += entriesScanned;
+ QueryScanCostContext scanCost = getScanCostContext();
+ if (scanCost != null) {
+ scanCost.addEntriesScannedPostFilter(entriesScanned);
+ }
+ rowBaseId += numDocsFetched;
+ }
+ return new SelectionResultsBlock(createDataSchema(transformOperator, nonOrderByExpressions), rows, _comparator,
+ _queryContext);
+ }
+ }
+
+ /// Data schema for the late-materialised case: order-by types come from the project operator, the rest from the
+ /// transform operator built for the late fetch.
+ private DataSchema createDataSchema(TransformOperator transformOperator,
Review Comment:
The two createDataSchema implementations can share one construction loop. Resolve the context from _columnContexts for projected expressions and from the late transform for the remaining expressions, then build the names and types once. The existing protected no-argument method can delegate if we want to preserve its signature.
##########
pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java:
##########
@@ -1048,6 +1048,11 @@ public static class QueryOptionKey {
public static final String ALLOW_REVERSE_ORDER = "allowReverseOrder";
public static final boolean DEFAULT_ALLOW_REVERSE_ORDER = false;
+ /// Selection with an order-by prefix on a sorted column: project only the order-by expressions during the
+ /// scan and fetch the remaining output expressions for the rows that survive the limit. Disabling it
+ /// restores reading every selected column for the whole match set.
+ public static final String LATE_MATERIALIZE_SORTED_ORDER_BY = "lateMaterializeSortedOrderBy";
Review Comment:
I'd remove lateMaterializeSortedOrderBy and make this the automatic behavior for eligible plans. The existing unsorted ORDER BY path already late-materializes without a query option, and another flag adds an execution mode to maintain. Keep the internal planner eligibility check so scans already bounded by LIMIT + OFFSET can still avoid an unnecessary second pass.
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -124,39 +159,76 @@ public IndexSegment getIndexSegment() {
public ExecutionStatistics getExecutionStatistics() {
long numEntriesScannedInFilter = _projectOperator.getExecutionStatistics().getNumEntriesScannedInFilter();
int numDocsScanned = getNumDocsScanned();
- long numEntriesScannedPostFilter = (long) numDocsScanned * _projectOperator.getNumColumnsProjected();
+ long numEntriesScannedPostFilter =
+ (long) numDocsScanned * _projectOperator.getNumColumnsProjected() + _numEntriesScannedPostFilterLate;
int numTotalDocs = _indexSegment.getSegmentMetadata().getTotalDocs();
return new ExecutionStatistics(numDocsScanned, numEntriesScannedInFilter, numEntriesScannedPostFilter,
numTotalDocs);
}
+ /// Number of expressions the project operator actually produces: all of them, or just the order-by prefix when
+ /// materialising late.
+ protected int getNumProjectedExpressions() {
+ return _columnContexts.length;
+ }
+
+ protected BlockValSet[] newBlockValSets() {
Review Comment:
Minor simplification: getNumProjectedExpressions() and newBlockValSets() form a helper chain around a single array-length allocation. Keeping just the count accessor and allocating the BlockValSet array in the two subclasses would be sufficient.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org
Re: [PR] Materialize non-order-by columns late on the sorted order-by path [pinot]
Posted by "xiangfu0 (via GitHub)" <gi...@apache.org>.
xiangfu0 commented on code in PR #19879:
URL: https://github.com/apache/pinot/pull/19879#discussion_r4294479757
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -234,25 +309,122 @@ private void concatList(StringBuilder sb, List<?> list) {
@Override
protected SelectionResultsBlock getNextBlock() {
- return new SelectionResultsBlock(createDataSchema(), fetch(_listBuilderSupplier), _comparator, _queryContext);
+ List<Object[]> rows = fetch(_listBuilderSupplier);
+ if (!_lateMaterialize) {
+ return new SelectionResultsBlock(createDataSchema(), rows, _comparator, _queryContext);
+ }
+ return fetchRemainingExpressions(rows);
}
- protected DataSchema createDataSchema() {
+ /// Fills the output expressions that were not projected during the scan, for the rows that survived the limit.
+ ///
+ /// The rows are visited in doc-id order because that is the order [BitmapDocIdSetOperator#ascending] returns, while
+ /// `rows` keeps the result order. Both views hold the same row arrays, so filling one fills the other.
+ private SelectionResultsBlock fetchRemainingExpressions(List<Object[]> rows) {
int numExpressions = _expressions.size();
+ List<ExpressionContext> nonOrderByExpressions = _expressions.subList(_numOrderByExpressions, numExpressions);
+ int numNonOrderByExpressions = nonOrderByExpressions.size();
+
+ int numRows = rows.size();
+ Object[][] rowsByDocId = rows.toArray(new Object[numRows][]);
+ Arrays.sort(rowsByDocId, Comparator.comparingInt(row -> (int) row[_numOrderByExpressions]));
+ RoaringBitmap docIds = new RoaringBitmap();
+ for (Object[] row : rowsByDocId) {
+ docIds.add((int) row[_numOrderByExpressions]);
+ }
+ // rowsByDocId is filled by walking the bitmap in ascending order, so the two must line up one for one.
+ // They do only because surviving rows have distinct doc ids. If that ever stopped holding, the bitmap would
+ // dedupe and the trailing rows would keep their unfilled values instead of failing -- wrong data returned,
+ // not an error. Checked rather than asserted because this build never runs with -ea, and the cost is one
+ // cardinality read per query against a projection pass over every surviving row.
+ Preconditions.checkState(docIds.getCardinality() == numRows,
Review Comment:
Nice -- this is exactly the guard I was hoping for, and using `checkState` (not `assert`) since the build runs without `-ea` is the right call. The per-query cardinality read is negligible.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org
Re: [PR] Materialize non-order-by columns late on the sorted order-by path [pinot]
Posted by "codecov-commenter (via GitHub)" <gi...@apache.org>.
codecov-commenter commented on PR #19879:
URL: https://github.com/apache/pinot/pull/19879#issuecomment-6190313671
## [Codecov](https://app.codecov.io/gh/apache/pinot/pull/19879?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) Report
:x: Patch coverage is `89.76378%` with `13 lines` in your changes missing coverage. Please review.
:white_check_mark: Project coverage is 58.69%. Comparing base ([`b72a7b8`](https://app.codecov.io/gh/apache/pinot/commit/b72a7b8ff44bad07612ef2033449eeba8b6d1b7d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)) to head ([`4d6bcb7`](https://app.codecov.io/gh/apache/pinot/commit/4d6bcb7e4809165ab8861d9224e7aacff5a86208?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)).
:warning: Report is 15 commits behind head on master.
| [Files with missing lines](https://app.codecov.io/gh/apache/pinot/pull/19879?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | Patch % | Lines |
|---|---|---|
| [...operator/query/LinearSelectionOrderByOperator.java](https://app.codecov.io/gh/apache/pinot/pull/19879?src=pr&el=tree&filepath=pinot-core%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fpinot%2Fcore%2Foperator%2Fquery%2FLinearSelectionOrderByOperator.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-cGlub3QtY29yZS9zcmMvbWFpbi9qYXZhL29yZy9hcGFjaGUvcGlub3QvY29yZS9vcGVyYXRvci9xdWVyeS9MaW5lYXJTZWxlY3Rpb25PcmRlckJ5T3BlcmF0b3IuamF2YQ==) | 91.58% | [6 Missing and 3 partials :warning: ](https://app.codecov.io/gh/apache/pinot/pull/19879?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [...uery/SelectionPartiallyOrderedByDescOperation.java](https://app.codecov.io/gh/apache/pinot/pull/19879?src=pr&el=tree&filepath=pinot-core%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fpinot%2Fcore%2Foperator%2Fquery%2FSelectionPartiallyOrderedByDescOperation.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-cGlub3QtY29yZS9zcmMvbWFpbi9qYXZhL29yZy9hcGFjaGUvcGlub3QvY29yZS9vcGVyYXRvci9xdWVyeS9TZWxlY3Rpb25QYXJ0aWFsbHlPcmRlcmVkQnlEZXNjT3BlcmF0aW9uLmphdmE=) | 50.00% | [2 Missing :warning: ](https://app.codecov.io/gh/apache/pinot/pull/19879?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [...ery/SelectionPartiallyOrderedByLinearOperator.java](https://app.codecov.io/gh/apache/pinot/pull/19879?src=pr&el=tree&filepath=pinot-core%2Fsrc%2Fmain%2Fjava%2Forg%2Fapache%2Fpinot%2Fcore%2Foperator%2Fquery%2FSelectionPartiallyOrderedByLinearOperator.java&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-cGlub3QtY29yZS9zcmMvbWFpbi9qYXZhL29yZy9hcGFjaGUvcGlub3QvY29yZS9vcGVyYXRvci9xdWVyeS9TZWxlY3Rpb25QYXJ0aWFsbHlPcmRlcmVkQnlMaW5lYXJPcGVyYXRvci5qYXZh) | 50.00% | [2 Missing :warning: ](https://app.codecov.io/gh/apache/pinot/pull/19879?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
> :exclamation: There is a different number of reports uploaded between BASE (b72a7b8) and HEAD (4d6bcb7). Click for more details.
>
> <details><summary>HEAD has 14 uploads less than BASE</summary>
>
>| Flag | BASE (b72a7b8) | HEAD (4d6bcb7) |
>|------|------|------|
>|lane-a|2|1|
>|integration|4|2|
>|java-25|6|3|
>|temurin|6|3|
>|lane-b|2|1|
>|integration2|2|0|
>|unittests|2|1|
>|unittests2|1|0|
></details>
<details><summary>Additional details and impacted files</summary>
```diff
@@ Coverage Diff @@
## master #19879 +/- ##
=============================================
- Coverage 68.96% 58.69% -10.28%
+ Complexity 1491 7 -1484
=============================================
Files 3533 2726 -807
Lines 231734 170452 -61282
Branches 36979 28037 -8942
=============================================
- Hits 159824 100044 -59780
- Misses 59502 61952 +2450
+ Partials 12408 8456 -3952
```
| [Flag](https://app.codecov.io/gh/apache/pinot/pull/19879/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | Coverage Δ | |
|---|---|---|
| [integration](https://app.codecov.io/gh/apache/pinot/pull/19879/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `100.00% <ø> (ø)` | |
| [integration1](https://app.codecov.io/gh/apache/pinot/pull/19879/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `100.00% <ø> (ø)` | |
| [integration2](https://app.codecov.io/gh/apache/pinot/pull/19879/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `?` | |
| [java-25](https://app.codecov.io/gh/apache/pinot/pull/19879/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `58.69% <89.76%> (-10.28%)` | :arrow_down: |
| [lane-a](https://app.codecov.io/gh/apache/pinot/pull/19879/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `100.00% <ø> (ø)` | |
| [lane-b](https://app.codecov.io/gh/apache/pinot/pull/19879/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `0.00% <ø> (ø)` | |
| [temurin](https://app.codecov.io/gh/apache/pinot/pull/19879/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `58.69% <89.76%> (-10.28%)` | :arrow_down: |
| [unittests](https://app.codecov.io/gh/apache/pinot/pull/19879/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `58.69% <89.76%> (-10.28%)` | :arrow_down: |
| [unittests1](https://app.codecov.io/gh/apache/pinot/pull/19879/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `58.69% <89.76%> (+0.13%)` | :arrow_up: |
| [unittests2](https://app.codecov.io/gh/apache/pinot/pull/19879/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `?` | |
Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#carryforward-flags-in-the-pull-request-comment) to find out more.
</details>
[:umbrella: View full report in Codecov by Harness](https://app.codecov.io/gh/apache/pinot/pull/19879?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache).
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache).
<details><summary> :rocket: New features to boost your workflow: </summary>
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org
Re: [PR] Materialize non-order-by columns late on the sorted order-by path [pinot]
Posted by "Jackie-Jiang (via GitHub)" <gi...@apache.org>.
Jackie-Jiang commented on code in PR #19879:
URL: https://github.com/apache/pinot/pull/19879#discussion_r4298071865
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -75,11 +87,25 @@ public abstract class LinearSelectionOrderByOperator extends BaseOperator<Select
protected final int _numRowsToKeep;
protected final Comparator<Object[]> _comparator;
protected final Supplier<ListBuilder> _listBuilderSupplier;
+ protected final int _numOrderByExpressions;
+ /// When true the project operator only produces the order-by expressions, and the remaining output expressions are
+ /// fetched for the rows that survive the limit. Rows carry their doc id at index [#_numOrderByExpressions] until
+ /// that fetch overwrites it.
+ protected final boolean _lateMaterialize;
Review Comment:
Please remove the lateMaterialize boolean plumbing as well as the lateMaterializeSortedOrderBy query option: the planner switch, constructor arguments, and _lateMaterialize field/branches. Use the same two cases as SelectionOrderByOperator: when all output expressions are order-by expressions there is nothing to defer; otherwise, fetch the remaining expressions after selection. This keeps the sorted and unsorted paths consistent and avoids maintaining a separate eager/late mode.
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -234,25 +309,122 @@ private void concatList(StringBuilder sb, List<?> list) {
@Override
protected SelectionResultsBlock getNextBlock() {
- return new SelectionResultsBlock(createDataSchema(), fetch(_listBuilderSupplier), _comparator, _queryContext);
+ List<Object[]> rows = fetch(_listBuilderSupplier);
+ if (!_lateMaterialize) {
+ return new SelectionResultsBlock(createDataSchema(), rows, _comparator, _queryContext);
+ }
+ return fetchRemainingExpressions(rows);
}
- protected DataSchema createDataSchema() {
+ /// Fills the output expressions that were not projected during the scan, for the rows that survived the limit.
+ ///
+ /// The rows are visited in doc-id order because that is the order [BitmapDocIdSetOperator#ascending] returns, while
+ /// `rows` keeps the result order. Both views hold the same row arrays, so filling one fills the other.
+ private SelectionResultsBlock fetchRemainingExpressions(List<Object[]> rows) {
int numExpressions = _expressions.size();
+ List<ExpressionContext> nonOrderByExpressions = _expressions.subList(_numOrderByExpressions, numExpressions);
+ int numNonOrderByExpressions = nonOrderByExpressions.size();
+
+ int numRows = rows.size();
+ Object[][] rowsByDocId = rows.toArray(new Object[numRows][]);
+ Arrays.sort(rowsByDocId, Comparator.comparingInt(row -> (int) row[_numOrderByExpressions]));
+ RoaringBitmap docIds = new RoaringBitmap();
+ for (Object[] row : rowsByDocId) {
+ docIds.add((int) row[_numOrderByExpressions]);
+ }
+ // rowsByDocId is filled by walking the bitmap in ascending order, so the two must line up one for one.
+ // They do only because surviving rows have distinct doc ids. If that ever stopped holding, the bitmap would
+ // dedupe and the trailing rows would keep their unfilled values instead of failing -- wrong data returned,
+ // not an error. Checked rather than asserted because this build never runs with -ea, and the cost is one
+ // cardinality read per query against a projection pass over every surviving row.
+ Preconditions.checkState(docIds.getCardinality() == numRows,
+ "Expected one distinct doc id per surviving row, got %s doc ids for %s rows", docIds.getCardinality(),
+ numRows);
+
+ Set<String> columns = new HashSet<>();
+ for (ExpressionContext expression : nonOrderByExpressions) {
+ expression.getColumns(columns);
+ }
+ Map<String, DataSource> dataSourceMap = new HashMap<>();
+ for (String column : columns) {
+ // Same resolution the eager path uses: ProjectPlanNode resolves every selected column through this exact
+ // call, including the ones fetched here. The schema is only dereferenced for a column the segment does not
+ // physically have, and it is set for every server query in ServerQueryExecutorV1Impl.
+ dataSourceMap.put(column, _indexSegment.getDataSource(column, _queryContext.getSchema()));
+ }
+ // Distinct physical columns. A column that is both an order-by column and referenced by a non-order-by
+ // expression is read again here and counted again, which slightly over-reports entries scanned.
+ int numColumns = columns.size();
+
+ BitmapDocIdSetOperator docIdSetOperator = BitmapDocIdSetOperator.ascending(docIds, numRows);
+ try (ProjectionOperator projectionOperator =
+ ProjectionOperatorUtils.getProjectionOperator(dataSourceMap, docIdSetOperator, _queryContext)) {
+ TransformOperator transformOperator =
+ new TransformOperator(_queryContext, projectionOperator, nonOrderByExpressions);
+
+ BlockValSet[] blockValSets = new BlockValSet[numNonOrderByExpressions];
+ int rowBaseId = 0;
+ ValueBlock valueBlock;
+ while ((valueBlock = transformOperator.nextBlock()) != null) {
+ for (int i = 0; i < numNonOrderByExpressions; i++) {
+ blockValSets[i] = valueBlock.getBlockValueSet(nonOrderByExpressions.get(i));
+ }
+ RowBasedBlockValueFetcher blockValueFetcher = new RowBasedBlockValueFetcher(blockValSets);
+ int numDocsFetched = valueBlock.getNumDocs();
+ for (int i = 0; i < numDocsFetched; i++) {
+ blockValueFetcher.getRow(i, rowsByDocId[rowBaseId + i], _numOrderByExpressions);
+ }
+ if (_nullHandlingEnabled) {
+ RoaringBitmap[] nullBitmaps = new RoaringBitmap[numNonOrderByExpressions];
+ for (int i = 0; i < numNonOrderByExpressions; i++) {
+ nullBitmaps[i] = blockValSets[i].getNullBitmap();
+ }
+ for (int i = 0; i < numDocsFetched; i++) {
+ Object[] row = rowsByDocId[rowBaseId + i];
+ for (int colId = 0; colId < numNonOrderByExpressions; colId++) {
+ if (nullBitmaps[colId] != null && nullBitmaps[colId].contains(i)) {
+ row[_numOrderByExpressions + colId] = null;
+ }
+ }
+ }
+ }
+ long entriesScanned = (long) numDocsFetched * numColumns;
+ _numEntriesScannedPostFilterLate += entriesScanned;
+ QueryScanCostContext scanCost = getScanCostContext();
+ if (scanCost != null) {
+ scanCost.addEntriesScannedPostFilter(entriesScanned);
+ }
+ rowBaseId += numDocsFetched;
+ }
+ return new SelectionResultsBlock(createDataSchema(transformOperator, nonOrderByExpressions), rows, _comparator,
+ _queryContext);
+ }
+ }
- // Create the data schema
+ /// Data schema covering both cases. Types for the expressions the scan projected come from the project
+ /// operator's column contexts; types for any expressions fetched late come from the transform operator built
+ /// for that fetch. `lateExpressions` is empty when nothing was materialised late, which leaves the first
+ /// branch covering every expression.
+ private DataSchema createDataSchema(@Nullable TransformOperator lateTransformOperator,
Review Comment:
This helper can drop the lateExpressions parameter. getNumProjectedExpressions() already gives the split point, and _expressions.get(i) is the expression to resolve from lateTransformOperator for the remaining slots. That removes a redundant list argument and the i - numProjected mapping. The no-argument wrapper can delegate to createDataSchema(null).
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -234,25 +309,122 @@ private void concatList(StringBuilder sb, List<?> list) {
@Override
protected SelectionResultsBlock getNextBlock() {
- return new SelectionResultsBlock(createDataSchema(), fetch(_listBuilderSupplier), _comparator, _queryContext);
+ List<Object[]> rows = fetch(_listBuilderSupplier);
+ if (!_lateMaterialize) {
+ return new SelectionResultsBlock(createDataSchema(), rows, _comparator, _queryContext);
+ }
+ return fetchRemainingExpressions(rows);
}
- protected DataSchema createDataSchema() {
+ /// Fills the output expressions that were not projected during the scan, for the rows that survived the limit.
+ ///
+ /// The rows are visited in doc-id order because that is the order [BitmapDocIdSetOperator#ascending] returns, while
+ /// `rows` keeps the result order. Both views hold the same row arrays, so filling one fills the other.
+ private SelectionResultsBlock fetchRemainingExpressions(List<Object[]> rows) {
int numExpressions = _expressions.size();
+ List<ExpressionContext> nonOrderByExpressions = _expressions.subList(_numOrderByExpressions, numExpressions);
+ int numNonOrderByExpressions = nonOrderByExpressions.size();
+
+ int numRows = rows.size();
+ Object[][] rowsByDocId = rows.toArray(new Object[numRows][]);
+ Arrays.sort(rowsByDocId, Comparator.comparingInt(row -> (int) row[_numOrderByExpressions]));
+ RoaringBitmap docIds = new RoaringBitmap();
+ for (Object[] row : rowsByDocId) {
+ docIds.add((int) row[_numOrderByExpressions]);
+ }
+ // rowsByDocId is filled by walking the bitmap in ascending order, so the two must line up one for one.
+ // They do only because surviving rows have distinct doc ids. If that ever stopped holding, the bitmap would
+ // dedupe and the trailing rows would keep their unfilled values instead of failing -- wrong data returned,
+ // not an error. Checked rather than asserted because this build never runs with -ea, and the cost is one
Review Comment:
Please shorten this to the invariant that surviving doc IDs must be unique for bitmap/row alignment. The statement that the build never runs with -ea is incorrect: Surefire enables assertions by default, independently of an explicit -ea in argLine. If keeping checkState, cache docIds.getCardinality() once; the current call computes it twice, including on success when evaluating the message arguments.
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -234,25 +309,122 @@ private void concatList(StringBuilder sb, List<?> list) {
@Override
protected SelectionResultsBlock getNextBlock() {
- return new SelectionResultsBlock(createDataSchema(), fetch(_listBuilderSupplier), _comparator, _queryContext);
+ List<Object[]> rows = fetch(_listBuilderSupplier);
+ if (!_lateMaterialize) {
+ return new SelectionResultsBlock(createDataSchema(), rows, _comparator, _queryContext);
+ }
+ return fetchRemainingExpressions(rows);
}
- protected DataSchema createDataSchema() {
+ /// Fills the output expressions that were not projected during the scan, for the rows that survived the limit.
+ ///
+ /// The rows are visited in doc-id order because that is the order [BitmapDocIdSetOperator#ascending] returns, while
+ /// `rows` keeps the result order. Both views hold the same row arrays, so filling one fills the other.
+ private SelectionResultsBlock fetchRemainingExpressions(List<Object[]> rows) {
int numExpressions = _expressions.size();
+ List<ExpressionContext> nonOrderByExpressions = _expressions.subList(_numOrderByExpressions, numExpressions);
+ int numNonOrderByExpressions = nonOrderByExpressions.size();
+
+ int numRows = rows.size();
+ Object[][] rowsByDocId = rows.toArray(new Object[numRows][]);
+ Arrays.sort(rowsByDocId, Comparator.comparingInt(row -> (int) row[_numOrderByExpressions]));
+ RoaringBitmap docIds = new RoaringBitmap();
+ for (Object[] row : rowsByDocId) {
+ docIds.add((int) row[_numOrderByExpressions]);
+ }
+ // rowsByDocId is filled by walking the bitmap in ascending order, so the two must line up one for one.
+ // They do only because surviving rows have distinct doc ids. If that ever stopped holding, the bitmap would
+ // dedupe and the trailing rows would keep their unfilled values instead of failing -- wrong data returned,
+ // not an error. Checked rather than asserted because this build never runs with -ea, and the cost is one
+ // cardinality read per query against a projection pass over every surviving row.
+ Preconditions.checkState(docIds.getCardinality() == numRows,
+ "Expected one distinct doc id per surviving row, got %s doc ids for %s rows", docIds.getCardinality(),
+ numRows);
+
+ Set<String> columns = new HashSet<>();
+ for (ExpressionContext expression : nonOrderByExpressions) {
+ expression.getColumns(columns);
+ }
+ Map<String, DataSource> dataSourceMap = new HashMap<>();
+ for (String column : columns) {
+ // Same resolution the eager path uses: ProjectPlanNode resolves every selected column through this exact
+ // call, including the ones fetched here. The schema is only dereferenced for a column the segment does not
+ // physically have, and it is set for every server query in ServerQueryExecutorV1Impl.
+ dataSourceMap.put(column, _indexSegment.getDataSource(column, _queryContext.getSchema()));
+ }
+ // Distinct physical columns. A column that is both an order-by column and referenced by a non-order-by
+ // expression is read again here and counted again, which slightly over-reports entries scanned.
Review Comment:
Counting an actual reread is correct scan-work accounting, so this does not over-report entries scanned. The comment can simply say that the count includes columns reread from the order-by pass.
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -234,25 +309,122 @@ private void concatList(StringBuilder sb, List<?> list) {
@Override
protected SelectionResultsBlock getNextBlock() {
- return new SelectionResultsBlock(createDataSchema(), fetch(_listBuilderSupplier), _comparator, _queryContext);
+ List<Object[]> rows = fetch(_listBuilderSupplier);
+ if (!_lateMaterialize) {
+ return new SelectionResultsBlock(createDataSchema(), rows, _comparator, _queryContext);
+ }
+ return fetchRemainingExpressions(rows);
}
- protected DataSchema createDataSchema() {
+ /// Fills the output expressions that were not projected during the scan, for the rows that survived the limit.
+ ///
+ /// The rows are visited in doc-id order because that is the order [BitmapDocIdSetOperator#ascending] returns, while
+ /// `rows` keeps the result order. Both views hold the same row arrays, so filling one fills the other.
+ private SelectionResultsBlock fetchRemainingExpressions(List<Object[]> rows) {
int numExpressions = _expressions.size();
+ List<ExpressionContext> nonOrderByExpressions = _expressions.subList(_numOrderByExpressions, numExpressions);
+ int numNonOrderByExpressions = nonOrderByExpressions.size();
+
+ int numRows = rows.size();
+ Object[][] rowsByDocId = rows.toArray(new Object[numRows][]);
+ Arrays.sort(rowsByDocId, Comparator.comparingInt(row -> (int) row[_numOrderByExpressions]));
+ RoaringBitmap docIds = new RoaringBitmap();
+ for (Object[] row : rowsByDocId) {
+ docIds.add((int) row[_numOrderByExpressions]);
+ }
+ // rowsByDocId is filled by walking the bitmap in ascending order, so the two must line up one for one.
+ // They do only because surviving rows have distinct doc ids. If that ever stopped holding, the bitmap would
+ // dedupe and the trailing rows would keep their unfilled values instead of failing -- wrong data returned,
+ // not an error. Checked rather than asserted because this build never runs with -ea, and the cost is one
+ // cardinality read per query against a projection pass over every surviving row.
+ Preconditions.checkState(docIds.getCardinality() == numRows,
+ "Expected one distinct doc id per surviving row, got %s doc ids for %s rows", docIds.getCardinality(),
+ numRows);
+
+ Set<String> columns = new HashSet<>();
+ for (ExpressionContext expression : nonOrderByExpressions) {
+ expression.getColumns(columns);
+ }
+ Map<String, DataSource> dataSourceMap = new HashMap<>();
+ for (String column : columns) {
+ // Same resolution the eager path uses: ProjectPlanNode resolves every selected column through this exact
+ // call, including the ones fetched here. The schema is only dereferenced for a column the segment does not
+ // physically have, and it is set for every server query in ServerQueryExecutorV1Impl.
+ dataSourceMap.put(column, _indexSegment.getDataSource(column, _queryContext.getSchema()));
+ }
+ // Distinct physical columns. A column that is both an order-by column and referenced by a non-order-by
+ // expression is read again here and counted again, which slightly over-reports entries scanned.
+ int numColumns = columns.size();
+
+ BitmapDocIdSetOperator docIdSetOperator = BitmapDocIdSetOperator.ascending(docIds, numRows);
+ try (ProjectionOperator projectionOperator =
+ ProjectionOperatorUtils.getProjectionOperator(dataSourceMap, docIdSetOperator, _queryContext)) {
+ TransformOperator transformOperator =
+ new TransformOperator(_queryContext, projectionOperator, nonOrderByExpressions);
+
+ BlockValSet[] blockValSets = new BlockValSet[numNonOrderByExpressions];
+ int rowBaseId = 0;
+ ValueBlock valueBlock;
+ while ((valueBlock = transformOperator.nextBlock()) != null) {
+ for (int i = 0; i < numNonOrderByExpressions; i++) {
+ blockValSets[i] = valueBlock.getBlockValueSet(nonOrderByExpressions.get(i));
+ }
+ RowBasedBlockValueFetcher blockValueFetcher = new RowBasedBlockValueFetcher(blockValSets);
+ int numDocsFetched = valueBlock.getNumDocs();
+ for (int i = 0; i < numDocsFetched; i++) {
+ blockValueFetcher.getRow(i, rowsByDocId[rowBaseId + i], _numOrderByExpressions);
+ }
+ if (_nullHandlingEnabled) {
+ RoaringBitmap[] nullBitmaps = new RoaringBitmap[numNonOrderByExpressions];
+ for (int i = 0; i < numNonOrderByExpressions; i++) {
+ nullBitmaps[i] = blockValSets[i].getNullBitmap();
+ }
+ for (int i = 0; i < numDocsFetched; i++) {
+ Object[] row = rowsByDocId[rowBaseId + i];
+ for (int colId = 0; colId < numNonOrderByExpressions; colId++) {
Review Comment:
We can reuse applyNulls here by adding a destination-column offset: 0 during selection and _numOrderByExpressions during the late fetch. That puts null restoration in one helper; the null-bitmap position remains the block-local row index i in this pass.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org
Re: [PR] Materialize non-order-by columns late on the sorted order-by path [pinot]
Posted by "arunkumarucet (via GitHub)" <gi...@apache.org>.
arunkumarucet commented on code in PR #19879:
URL: https://github.com/apache/pinot/pull/19879#discussion_r4290973516
##########
pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java:
##########
@@ -1048,6 +1048,11 @@ public static class QueryOptionKey {
public static final String ALLOW_REVERSE_ORDER = "allowReverseOrder";
public static final boolean DEFAULT_ALLOW_REVERSE_ORDER = false;
+ /// Selection with an order-by prefix on a sorted column: project only the order-by expressions during the
+ /// scan and fetch the remaining output expressions for the rows that survive the limit. Disabling it
+ /// restores reading every selected column for the whole match set.
+ public static final String LATE_MATERIALIZE_SORTED_ORDER_BY = "lateMaterializeSortedOrderBy";
+ public static final boolean DEFAULT_LATE_MATERIALIZE_SORTED_ORDER_BY = true;
Review Comment:
Agreed, done in `c4667fa792` — the option now defaults to `false`. You're right on both counts: it follows the convention of the neighbouring options, and the silent `numEntriesScannedPostFilter` change is a real reason not to flip it under people without warning. Added a test that pins the eager default so the flip has to be deliberate, and documented in `CommonConstants` why it's off.
##########
pinot-core/src/main/java/org/apache/pinot/core/plan/SelectionPlanNode.java:
##########
@@ -88,17 +88,38 @@ public Operator<SelectionResultsBlock> run() {
maxDocsPerCall = Math.min(limit + _queryContext.getOffset(), DocIdSetPlanNode.MAX_DOC_PER_CALL);
}
- BaseProjectOperator<?> projectOperator = getSortedByProject(expressions, maxDocsPerCall, orderByExpressions);
+ // Whether the scan can iterate in query order, and so stop once the list builder is full. Ascending always
+ // can; descending only when reverse iteration is permitted, because DocIdSetPlanNode only produces an
+ // ascending DocIdSetOperator. getSortedByProject below performs the actual switch and decides for real; this
+ // is a conservative prediction, and being wrong only costs the optimization, never correctness.
boolean asc = orderByExpressions.get(0).isAsc();
+ boolean canIterateInQueryOrder =
+ asc || QueryOptionsUtils.isReverseOrderAllowed(_queryContext.getQueryOptions());
+
+ // Projecting only the order-by expressions and fetching the rest for the rows that survive the limit pays off
+ // only when the scan cannot stop at the limit. The linear operator stops as soon as the list builder is full,
+ // which with a fully sorted order-by prefix is after limit+offset documents, so there is nothing to save
+ // there. The descending operator has to read the whole match set because there is no descending cursor.
+ boolean scanBoundedByLimit = canIterateInQueryOrder && sortedColumnsPrefixSize == orderByExpressions.size();
+ boolean lateMaterialize = expressions.size() > numOrderByExpressions && !scanBoundedByLimit
Review Comment:
Addressed by shipping default-off in `c4667fa792`, which is the second of the two options you offered. Your analysis of the edge is right: once `limit + offset` reaches the match-set size the second pass reads what the scan would have read anyway, and adds a bitmap build, a sort, an array copy and a second decode on top. I've noted that in the option's documentation. I'd rather not add a selectivity guard yet — picking the threshold needs the benchmark numbers we don't have, and opt-in means nobody meets the edge unasked. Happy to add one when there's data to set it from.
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -124,39 +159,76 @@ public IndexSegment getIndexSegment() {
public ExecutionStatistics getExecutionStatistics() {
long numEntriesScannedInFilter = _projectOperator.getExecutionStatistics().getNumEntriesScannedInFilter();
int numDocsScanned = getNumDocsScanned();
- long numEntriesScannedPostFilter = (long) numDocsScanned * _projectOperator.getNumColumnsProjected();
+ long numEntriesScannedPostFilter =
+ (long) numDocsScanned * _projectOperator.getNumColumnsProjected() + _numEntriesScannedPostFilterLate;
int numTotalDocs = _indexSegment.getSegmentMetadata().getTotalDocs();
return new ExecutionStatistics(numDocsScanned, numEntriesScannedInFilter, numEntriesScannedPostFilter,
numTotalDocs);
}
+ /// Number of expressions the project operator actually produces: all of them, or just the order-by prefix when
+ /// materialising late.
+ protected int getNumProjectedExpressions() {
+ return _columnContexts.length;
+ }
+
+ protected BlockValSet[] newBlockValSets() {
+ return new BlockValSet[getNumProjectedExpressions()];
+ }
+
protected IntFunction<Object[]> fetchBlock(ValueBlock valueBlock, BlockValSet[] blockValSets) {
- int numExpressions = _expressions.size();
+ int numProjected = getNumProjectedExpressions();
- for (int i = 0; i < numExpressions; i++) {
+ for (int i = 0; i < numProjected; i++) {
ExpressionContext expression = _expressions.get(i);
blockValSets[i] = valueBlock.getBlockValueSet(expression);
}
RowBasedBlockValueFetcher blockValueFetcher = new RowBasedBlockValueFetcher(blockValSets);
- if (!_nullHandlingEnabled) {
- return blockValueFetcher::getRow;
+ RoaringBitmap[] nullBitmaps;
+ if (_nullHandlingEnabled) {
+ nullBitmaps = new RoaringBitmap[numProjected];
+ for (int i = 0; i < numProjected; i++) {
+ nullBitmaps[i] = blockValSets[i].getNullBitmap();
+ }
+ } else {
+ nullBitmaps = null;
}
- RoaringBitmap[] nullBitmaps = new RoaringBitmap[numExpressions];
- for (int i = 0; i < numExpressions; i++) {
- nullBitmaps[i] = blockValSets[i].getNullBitmap();
+
+ if (!_lateMaterialize) {
+ if (nullBitmaps == null) {
+ return blockValueFetcher::getRow;
+ }
+ return (docId) -> {
+ Object[] row = blockValueFetcher.getRow(docId);
+ applyNulls(row, nullBitmaps, docId);
+ return row;
+ };
}
- return (docId) -> {
- Object[] row = blockValueFetcher.getRow(docId);
- for (int colId = 0; colId < nullBitmaps.length; colId++) {
- if (nullBitmaps[colId] != null && nullBitmaps[colId].contains(docId)) {
- row[colId] = null;
- }
+
+ // Leave room for the expressions fetched after the limit is applied, and carry the doc id in the first of those
+ // slots so the fetch knows which documents to read. That slot is overwritten by the first fetched value.
+ int numExpressions = _expressions.size();
+ int[] docIds = valueBlock.getDocIds();
+ return (rowId) -> {
+ Object[] row = new Object[numExpressions];
+ blockValueFetcher.getRow(rowId, row, 0);
+ if (nullBitmaps != null) {
+ applyNulls(row, nullBitmaps, rowId);
}
+ row[_numOrderByExpressions] = docIds[rowId];
return row;
};
}
+ private static void applyNulls(Object[] row, RoaringBitmap[] nullBitmaps, int rowId) {
Review Comment:
Done in `c4667fa792` — renamed to `rowIdInBlock` in both lambdas and in `applyNulls`. You're right that this is exactly the place to be unambiguous about it.
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -234,7 +312,105 @@ private void concatList(StringBuilder sb, List<?> list) {
@Override
protected SelectionResultsBlock getNextBlock() {
- return new SelectionResultsBlock(createDataSchema(), fetch(_listBuilderSupplier), _comparator, _queryContext);
+ List<Object[]> rows = fetch(_listBuilderSupplier);
+ if (!_lateMaterialize) {
+ return new SelectionResultsBlock(createDataSchema(), rows, _comparator, _queryContext);
+ }
+ return fetchRemainingExpressions(rows);
+ }
+
+ /// Fills the output expressions that were not projected during the scan, for the rows that survived the limit.
+ ///
+ /// The rows are visited in doc-id order because that is the order [BitmapDocIdSetOperator#ascending] returns, while
+ /// `rows` keeps the result order. Both views hold the same row arrays, so filling one fills the other.
+ private SelectionResultsBlock fetchRemainingExpressions(List<Object[]> rows) {
+ int numExpressions = _expressions.size();
+ List<ExpressionContext> nonOrderByExpressions = _expressions.subList(_numOrderByExpressions, numExpressions);
+ int numNonOrderByExpressions = nonOrderByExpressions.size();
+
+ int numRows = rows.size();
+ Object[][] rowsByDocId = rows.toArray(new Object[numRows][]);
+ Arrays.sort(rowsByDocId, Comparator.comparingInt(row -> (int) row[_numOrderByExpressions]));
+ RoaringBitmap docIds = new RoaringBitmap();
+ for (Object[] row : rowsByDocId) {
+ docIds.add((int) row[_numOrderByExpressions]);
+ }
+
+ Set<String> columns = new HashSet<>();
+ for (ExpressionContext expression : nonOrderByExpressions) {
+ expression.getColumns(columns);
+ }
+ Map<String, DataSource> dataSourceMap = new HashMap<>();
+ for (String column : columns) {
+ dataSourceMap.put(column, _indexSegment.getDataSource(column, _queryContext.getSchema()));
+ }
+ int numColumns = columns.size();
Review Comment:
Noted in `c4667fa792`. Added a comment where `numColumns` is computed saying that a column which is both an order-by column and referenced by a non-order-by expression is read again and counted again, so the accounting slightly over-reports. No behaviour change, as you said.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org