Skip to content

Commit 4d8674a

Browse files
authored
#118 Support for Page-Level Column Index Pushdown/Filtering
* Added design plan for column-index pushdown * Added RowRanges utility for supporting page-level predicate push-down * PageFilterEvaluator (and tests) to support page-level, column-based filtering * Added PageFilter JFR event to track page filtering * Extended PageScanner to support row matching for filtering * Added PageFilter plumbing into various supported reader implementations * Added simple PageFilterBenchmarkTest to verify filtering behavior * Added S3 column index page filtering test * #118 Updated PageRange to support selective, page-ranged I/O
1 parent a513a96 commit 4d8674a

23 files changed

Lines changed: 2841 additions & 88 deletions

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ while (rowReader.hasNext()) {
362362

363363
### Predicate Pushdown (Filter)
364364

365-
Filter predicates allow Hardwood to skip entire row groups whose statistics prove that no rows can match the predicate, avoiding unnecessary I/O and decoding.
365+
Filter predicates enable two levels of predicate pushdown. At the row-group level, entire row groups whose statistics prove no rows can match are skipped. Within surviving row groups, the Column Index (per-page min/max statistics) is used to skip individual pages, avoiding unnecessary decompression and decoding. On remote backends like S3, only the matching pages are fetched, reducing network I/O.
366366

367367
```java
368368
import dev.hardwood.reader.FilterPredicate;
@@ -836,12 +836,13 @@ Or attach dynamically via `jcmd <pid> JFR.start`.
836836
| `dev.hardwood.RowGroupScanned` | Decode | Page boundaries scanned in a column chunk. Fields: file, rowGroupIndex, column, pageCount, scanStrategy (`sequential` or `offset-index`) |
837837
| `dev.hardwood.PageDecoded` | Decode | Single data page decoded. Fields: column, compressedSize, uncompressedSize |
838838
| `dev.hardwood.RowGroupFilter` | Filter | Row groups filtered by predicate pushdown. Fields: file, totalRowGroups, rowGroupsKept, rowGroupsSkipped |
839+
| `dev.hardwood.PageFilter` | Filter | Pages filtered by Column Index predicate pushdown. Fields: file, rowGroupIndex, column, totalPages, pagesKept, pagesSkipped |
839840
| `dev.hardwood.BatchWait` | Pipeline | Consumer blocked waiting for the assembly pipeline. Fields: column |
840841
| `dev.hardwood.PrefetchMiss` | Pipeline | Prefetch queue miss requiring synchronous decode. Fields: file, column, newDepth, queueEmpty |
841842

842843
Events appear under the **Hardwood** category in JDK Mission Control (JMC) or any JFR analysis tool. Use them to identify:
843844
- **I/O bottlenecks** — large `FileMapping` durations or frequent `PrefetchMiss` events
844-
- **Filter effectiveness**`RowGroupFilter` shows how many row groups were skipped
845+
- **Filter effectiveness**`RowGroupFilter` shows how many row groups were skipped; `PageFilter` shows how many pages were skipped within surviving row groups
845846
- **Decode hotspots**`PageDecoded` events with large uncompressed sizes or high frequency
846847
- **Pipeline stalls**`BatchWait` events indicate the reader is waiting for decoded data
847848

core/src/main/java/dev/hardwood/internal/reader/FileManager.java

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@
1616
import java.util.concurrent.ConcurrentHashMap;
1717

1818
import dev.hardwood.InputFile;
19+
import dev.hardwood.internal.thrift.OffsetIndexReader;
20+
import dev.hardwood.internal.thrift.ThriftCompactReader;
1921
import dev.hardwood.jfr.FileOpenedEvent;
2022
import dev.hardwood.jfr.RowGroupFilterEvent;
2123
import dev.hardwood.metadata.ColumnChunk;
2224
import dev.hardwood.metadata.FileMetaData;
25+
import dev.hardwood.metadata.OffsetIndex;
2326
import dev.hardwood.metadata.PhysicalType;
2427
import dev.hardwood.metadata.RowGroup;
2528
import dev.hardwood.reader.FilterPredicate;
@@ -331,33 +334,85 @@ private List<List<PageInfo>> scanAllProjectedColumns(InputFile inputFile, Opened
331334
throw new UncheckedIOException("Failed to fetch index buffers for row group " + rgIdx, e);
332335
}
333336

334-
List<ChunkRange> chunkRanges = ChunkRange.coalesce(
335-
rowGroup.columns(), columnIndices, ChunkRange.MAX_GAP_BYTES);
336-
ByteBuffer[] rangeBuffers = new ByteBuffer[chunkRanges.size()];
337-
try {
338-
for (int r = 0; r < chunkRanges.size(); r++) {
339-
ChunkRange range = chunkRanges.get(r);
340-
rangeBuffers[r] = inputFile.readRange(range.offset(), range.length());
337+
// Compute matching row ranges for page-level Column Index filtering
338+
RowRanges matchingRows = RowRanges.ALL;
339+
if (filterPredicate != null) {
340+
matchingRows = PageFilterEvaluator.computeMatchingRows(
341+
filterPredicate, rowGroup, openedFile.schema, indexBuffers);
342+
}
343+
344+
// Try page-range I/O for each column when filtering is active.
345+
// Columns that don't benefit fall back to full chunk fetching.
346+
final RowRanges rowRanges = matchingRows;
347+
PageRangeData[] pageRangeDataPerColumn = new PageRangeData[projectedColumnCount];
348+
349+
if (!matchingRows.isAll()) {
350+
for (int projectedIndex = 0; projectedIndex < projectedColumnCount; projectedIndex++) {
351+
int columnIndex = columnIndices[projectedIndex];
352+
ColumnIndexBuffers colBuffers = indexBuffers.forColumn(columnIndex);
353+
if (colBuffers != null && colBuffers.offsetIndex() != null) {
354+
try {
355+
OffsetIndex offsetIndex = OffsetIndexReader.read(
356+
new ThriftCompactReader(colBuffers.offsetIndex()));
357+
ColumnChunk columnChunk = rowGroup.columns().get(columnIndex);
358+
List<PageRange> pageRanges = PageRange.forColumn(
359+
offsetIndex, matchingRows, columnChunk, rowGroup.numRows(), ChunkRange.MAX_GAP_BYTES);
360+
if (!pageRanges.isEmpty()) {
361+
pageRangeDataPerColumn[projectedIndex] = PageRangeData.fetch(inputFile, pageRanges);
362+
}
363+
}
364+
catch (IOException e) {
365+
throw new UncheckedIOException("Failed to fetch page ranges for row group " + rgIdx, e);
366+
}
367+
}
341368
}
342369
}
343-
catch (IOException e) {
344-
throw new UncheckedIOException("Failed to fetch column chunk data for row group " + rgIdx, e);
370+
371+
// Fetch full chunks for columns not using page-range I/O
372+
List<ChunkRange> chunkRanges = null;
373+
ByteBuffer[] rangeBuffers = null;
374+
for (int projectedIndex = 0; projectedIndex < projectedColumnCount; projectedIndex++) {
375+
if (pageRangeDataPerColumn[projectedIndex] == null) {
376+
// At least one column needs full chunk data — fetch all chunks once
377+
chunkRanges = ChunkRange.coalesce(
378+
rowGroup.columns(), columnIndices, ChunkRange.MAX_GAP_BYTES);
379+
rangeBuffers = new ByteBuffer[chunkRanges.size()];
380+
try {
381+
for (int r = 0; r < chunkRanges.size(); r++) {
382+
ChunkRange range = chunkRanges.get(r);
383+
rangeBuffers[r] = inputFile.readRange(range.offset(), range.length());
384+
}
385+
}
386+
catch (IOException e) {
387+
throw new UncheckedIOException("Failed to fetch column chunk data for row group " + rgIdx, e);
388+
}
389+
break;
390+
}
345391
}
346392

347393
for (int projectedIndex = 0; projectedIndex < projectedColumnCount; projectedIndex++) {
348394
final int columnIndex = columnIndices[projectedIndex];
349395
final ColumnSchema columnSchema = columnSchemas[projectedIndex];
350396
final ColumnChunk columnChunk = rowGroup.columns().get(columnIndex);
397+
final PageRangeData colPageRangeData = pageRangeDataPerColumn[projectedIndex];
351398

352-
final ByteBuffer colChunkData = sliceColumnChunk(chunkRanges, rangeBuffers, columnChunk);
353-
final long colChunkOffset = chunkStartOffset(columnChunk);
399+
final PageScanner scanner;
400+
if (colPageRangeData != null) {
401+
scanner = new PageScanner(columnSchema, columnChunk, context,
402+
colPageRangeData, indexBuffers.forColumn(columnIndex),
403+
rgIdx, fileName, rowRanges);
404+
}
405+
else {
406+
final ByteBuffer colChunkData = sliceColumnChunk(chunkRanges, rangeBuffers, columnChunk);
407+
final long colChunkOffset = chunkStartOffset(columnChunk);
408+
scanner = new PageScanner(columnSchema, columnChunk, context,
409+
colChunkData, colChunkOffset, indexBuffers.forColumn(columnIndex),
410+
rgIdx, fileName, rowRanges);
411+
}
354412

355413
final CompletableFuture<List<PageInfo>> previousFuture = scanFutures[projectedIndex];
356414
scanFutures[projectedIndex] = previousFuture.thenCombineAsync(
357415
CompletableFuture.supplyAsync(() -> {
358-
PageScanner scanner = new PageScanner(columnSchema, columnChunk, context,
359-
colChunkData, colChunkOffset, indexBuffers.forColumn(columnIndex),
360-
rgIdx, fileName);
361416
try {
362417
return scanner.scanPages();
363418
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* Copyright The original authors
5+
*
6+
* Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
7+
*/
8+
package dev.hardwood.internal.reader;
9+
10+
import java.io.IOException;
11+
import java.io.UncheckedIOException;
12+
import java.util.List;
13+
14+
import dev.hardwood.internal.thrift.ColumnIndexReader;
15+
import dev.hardwood.internal.thrift.OffsetIndexReader;
16+
import dev.hardwood.internal.thrift.ThriftCompactReader;
17+
import dev.hardwood.metadata.ColumnIndex;
18+
import dev.hardwood.metadata.OffsetIndex;
19+
import dev.hardwood.metadata.PageLocation;
20+
import dev.hardwood.metadata.RowGroup;
21+
import dev.hardwood.reader.FilterPredicate;
22+
import dev.hardwood.reader.FilterPredicate.And;
23+
import dev.hardwood.reader.FilterPredicate.BinaryColumnPredicate;
24+
import dev.hardwood.reader.FilterPredicate.BooleanColumnPredicate;
25+
import dev.hardwood.reader.FilterPredicate.DoubleColumnPredicate;
26+
import dev.hardwood.reader.FilterPredicate.FloatColumnPredicate;
27+
import dev.hardwood.reader.FilterPredicate.IntColumnPredicate;
28+
import dev.hardwood.reader.FilterPredicate.LongColumnPredicate;
29+
import dev.hardwood.reader.FilterPredicate.Not;
30+
import dev.hardwood.reader.FilterPredicate.Or;
31+
import dev.hardwood.schema.FileSchema;
32+
33+
/// Evaluates a [FilterPredicate] against per-page statistics from the Column Index
34+
/// to produce [RowRanges] representing rows that might match.
35+
///
36+
/// This is the page-level equivalent of [RowGroupFilterEvaluator]. While that class
37+
/// decides whether an entire row group can be skipped, this class determines which
38+
/// pages within a surviving row group can be skipped.
39+
public class PageFilterEvaluator {
40+
41+
/// Computes the row ranges within a row group that might match the given predicate,
42+
/// based on per-page min/max statistics from the Column Index.
43+
///
44+
/// Returns `RowRanges.all()` when the Column Index is absent or the predicate
45+
/// cannot be evaluated at the page level (conservative fallback).
46+
///
47+
/// @param predicate the filter predicate to evaluate
48+
/// @param rowGroup the row group to evaluate against
49+
/// @param schema the file schema for column resolution
50+
/// @param indexBuffers pre-fetched index buffers for the row group
51+
/// @return row ranges that might contain matching rows
52+
public static RowRanges computeMatchingRows(FilterPredicate predicate, RowGroup rowGroup,
53+
FileSchema schema, RowGroupIndexBuffers indexBuffers) {
54+
long rowCount = rowGroup.numRows();
55+
return evaluate(predicate, rowGroup, schema, indexBuffers, rowCount);
56+
}
57+
58+
private static RowRanges evaluate(FilterPredicate predicate, RowGroup rowGroup,
59+
FileSchema schema, RowGroupIndexBuffers indexBuffers, long rowCount) {
60+
return switch (predicate) {
61+
case IntColumnPredicate p -> evaluateLeaf(p.column(), rowGroup, schema, indexBuffers, rowCount,
62+
(ci, i) -> {
63+
int min = StatisticsDecoder.decodeInt(ci.minValues().get(i));
64+
int max = StatisticsDecoder.decodeInt(ci.maxValues().get(i));
65+
return RowGroupFilterEvaluator.canDrop(p.op(), p.value(), min, max);
66+
});
67+
case LongColumnPredicate p -> evaluateLeaf(p.column(), rowGroup, schema, indexBuffers, rowCount,
68+
(ci, i) -> {
69+
long min = StatisticsDecoder.decodeLong(ci.minValues().get(i));
70+
long max = StatisticsDecoder.decodeLong(ci.maxValues().get(i));
71+
return RowGroupFilterEvaluator.canDrop(p.op(), p.value(), min, max);
72+
});
73+
case FloatColumnPredicate p -> evaluateLeaf(p.column(), rowGroup, schema, indexBuffers, rowCount,
74+
(ci, i) -> {
75+
float min = StatisticsDecoder.decodeFloat(ci.minValues().get(i));
76+
float max = StatisticsDecoder.decodeFloat(ci.maxValues().get(i));
77+
return RowGroupFilterEvaluator.canDropFloat(p.op(), p.value(), min, max);
78+
});
79+
case DoubleColumnPredicate p -> evaluateLeaf(p.column(), rowGroup, schema, indexBuffers, rowCount,
80+
(ci, i) -> {
81+
double min = StatisticsDecoder.decodeDouble(ci.minValues().get(i));
82+
double max = StatisticsDecoder.decodeDouble(ci.maxValues().get(i));
83+
return RowGroupFilterEvaluator.canDropDouble(p.op(), p.value(), min, max);
84+
});
85+
case BooleanColumnPredicate p -> evaluateLeaf(p.column(), rowGroup, schema, indexBuffers, rowCount,
86+
(ci, i) -> {
87+
int min = StatisticsDecoder.decodeBoolean(ci.minValues().get(i)) ? 1 : 0;
88+
int max = StatisticsDecoder.decodeBoolean(ci.maxValues().get(i)) ? 1 : 0;
89+
int value = p.value() ? 1 : 0;
90+
return RowGroupFilterEvaluator.canDrop(p.op(), value, min, max);
91+
});
92+
case BinaryColumnPredicate p -> evaluateLeaf(p.column(), rowGroup, schema, indexBuffers, rowCount,
93+
(ci, i) -> {
94+
byte[] min = ci.minValues().get(i);
95+
byte[] max = ci.maxValues().get(i);
96+
int cmpMin = StatisticsDecoder.compareBinary(p.value(), min);
97+
int cmpMax = StatisticsDecoder.compareBinary(p.value(), max);
98+
return RowGroupFilterEvaluator.canDropCompared(p.op(), cmpMin, cmpMax,
99+
StatisticsDecoder.compareBinary(min, max));
100+
});
101+
case And a -> {
102+
RowRanges result = RowRanges.all(rowCount);
103+
for (FilterPredicate child : a.filters()) {
104+
result = result.intersect(evaluate(child, rowGroup, schema, indexBuffers, rowCount));
105+
}
106+
yield result;
107+
}
108+
case Or o -> {
109+
RowRanges result = null;
110+
for (FilterPredicate child : o.filters()) {
111+
RowRanges childRanges = evaluate(child, rowGroup, schema, indexBuffers, rowCount);
112+
result = (result == null) ? childRanges : result.union(childRanges);
113+
}
114+
yield (result != null) ? result : RowRanges.all(rowCount);
115+
}
116+
case Not ignored -> RowRanges.all(rowCount);
117+
};
118+
}
119+
120+
/// Evaluates a leaf predicate against the Column Index for a single column,
121+
/// producing RowRanges for pages that cannot be dropped.
122+
private static RowRanges evaluateLeaf(String columnName, RowGroup rowGroup,
123+
FileSchema schema, RowGroupIndexBuffers indexBuffers, long rowCount,
124+
PageCanDropTest canDropTest) {
125+
126+
int columnIndex = RowGroupFilterEvaluator.resolveColumnIndex(columnName, rowGroup, schema);
127+
if (columnIndex < 0) {
128+
return RowRanges.all(rowCount);
129+
}
130+
131+
ColumnIndexBuffers colBuffers = indexBuffers.forColumn(columnIndex);
132+
if (colBuffers == null || colBuffers.columnIndex() == null || colBuffers.offsetIndex() == null) {
133+
return RowRanges.all(rowCount);
134+
}
135+
136+
ColumnIndex columnIdx;
137+
OffsetIndex offsetIdx;
138+
try {
139+
columnIdx = ColumnIndexReader.read(new ThriftCompactReader(colBuffers.columnIndex()));
140+
offsetIdx = OffsetIndexReader.read(new ThriftCompactReader(colBuffers.offsetIndex()));
141+
}
142+
catch (IOException e) {
143+
throw new UncheckedIOException("Failed to parse Column/Offset Index for column '" + columnName + "'", e);
144+
}
145+
146+
return evaluatePages(columnIdx, offsetIdx, rowCount, canDropTest);
147+
}
148+
149+
/// Evaluates a keep bitmap for pages using pre-parsed Column Index and Offset Index.
150+
static RowRanges evaluatePages(ColumnIndex columnIdx, OffsetIndex offsetIdx,
151+
long rowCount, PageCanDropTest canDropTest) {
152+
List<PageLocation> pages = offsetIdx.pageLocations();
153+
int pageCount = pages.size();
154+
boolean[] keep = new boolean[pageCount];
155+
156+
for (int i = 0; i < pageCount; i++) {
157+
// Null-only pages cannot match any value predicate
158+
if (columnIdx.nullPages().get(i)) {
159+
continue;
160+
}
161+
// Keep the page if it cannot be dropped
162+
keep[i] = !canDropTest.canDrop(columnIdx, i);
163+
}
164+
165+
return RowRanges.fromPages(pages, keep, rowCount);
166+
}
167+
168+
/// Functional interface for testing whether a page can be dropped based on its
169+
/// Column Index min/max values.
170+
@FunctionalInterface
171+
interface PageCanDropTest {
172+
boolean canDrop(ColumnIndex columnIndex, int pageIndex);
173+
}
174+
}

0 commit comments

Comments
 (0)