Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Custom extension types: register an `ExtensionDecoder` on `ReadRegistry` and an `ExtensionEncoder` on `WriteRegistry`, with a `validate` hook on both sides, and `Chunk.as` decodes any registered type. ([#530](https://github.com/dfa1/vortex-java/issues/530))

### Changed
- SQL `COUNT(DISTINCT col)` is up to 4 times faster: distinct values are counted in the scan, integers by their bits in a primitive map, instead of Calcite hashing a boxed value per row. ([#562](https://github.com/dfa1/vortex-java/pull/562))
- A SQL `WHERE` comparing a `SMALLINT` or `TINYINT` column with a literal is pushed into the scan and its aggregates, instead of being left to Calcite because of the cast Calcite adds. ([#560](https://github.com/dfa1/vortex-java/pull/560))
- SQL `MIN`/`MAX` of a date, time or timestamp column is answered from zone maps, and scans read those columns without building a `java.time` object per row. ([#559](https://github.com/dfa1/vortex-java/pull/559))
- The Calcite adapter declares `TIME` and `TIMESTAMP` columns with millisecond precision, so `getString` shows the milliseconds the values carry. ([#559](https://github.com/dfa1/vortex-java/pull/559))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,13 @@ public void onMatch(RelOptRuleCall call) {
return;
}
// Statistics describe every row of a zone, so they cannot answer an aggregate over a subset
// of its rows: COUNT(DISTINCT g) is not COUNT(g), and a FILTER (WHERE ...) clause narrows
// the rows one call sees. Leave either to the scan.
// of its rows: a FILTER (WHERE ...) clause narrows the rows one call sees, and DISTINCT is not
// the plain aggregate. Leave those to the scan, except an ungrouped COUNT(DISTINCT col),
// which VortexTable#distinctCount answers by its own pass.
for (AggregateCall aggCall : aggregate.getAggCallList()) {
if (aggCall.isDistinct() || aggCall.hasFilter()) {
boolean countDistinct = aggCall.isDistinct() && aggCall.getAggregation().getKind() == SqlKind.COUNT
&& aggCall.getArgList().size() == 1 && aggregate.getGroupCount() == 0;
if (aggCall.hasFilter() || aggCall.isDistinct() && !countDistinct) {
return;
}
}
Expand Down Expand Up @@ -287,6 +290,15 @@ private static RexLiteral evaluate(AggregateCall agg, RelDataType outType, Vorte
RelOptPlanner planner) {
return switch (agg.getAggregation().getKind()) {
case COUNT -> {
if (agg.isDistinct()) { // COUNT(DISTINCT col): no statistic holds it
ColumnName col = resolveColumn(agg.getArgList().getFirst(), scanColumns, project);
if (col == null) {
yield null;
}
List<Object> foldKey = List.of("distinct", table, filter == null ? List.of() : filter, col);
Optional<Long> distinct = memo(planner, foldKey, () -> table.distinctCount(col, filter));
yield distinct.map(count -> exact(rexBuilder, count, outType)).orElse(null);
}
if (agg.getArgList().isEmpty()) { // COUNT(*)
if (filter == null) {
yield exact(rexBuilder, table.totalRows(), outType);
Expand Down
129 changes: 122 additions & 7 deletions calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexTable.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.github.dfa1.vortex.calcite;

import io.github.dfa1.vortex.core.compute.LongIntMap;
import io.github.dfa1.vortex.core.error.VortexException;
import io.github.dfa1.vortex.core.model.ColumnName;
import io.github.dfa1.vortex.core.model.DType;
Expand All @@ -12,6 +13,19 @@
import io.github.dfa1.vortex.reader.ScanOptions;
import io.github.dfa1.vortex.reader.VortexReader;
import io.github.dfa1.vortex.reader.Zone;
import io.github.dfa1.vortex.reader.array.Array;
import io.github.dfa1.vortex.reader.array.BoolArray;
import io.github.dfa1.vortex.reader.array.ByteArray;
import io.github.dfa1.vortex.reader.array.DoubleArray;
import io.github.dfa1.vortex.reader.array.FloatArray;
import io.github.dfa1.vortex.reader.array.IntArray;
import io.github.dfa1.vortex.reader.array.LongArray;
import io.github.dfa1.vortex.reader.array.MaskedArray;
import io.github.dfa1.vortex.reader.array.ShortArray;
import io.github.dfa1.vortex.reader.array.VarBinArray;
import io.github.dfa1.vortex.reader.array.VarBinDictArray;
import io.github.dfa1.vortex.reader.array.VarBinOffsetArray;
import io.github.dfa1.vortex.reader.array.VarBinSlicedArray;
import io.github.dfa1.vortex.reader.compute.Compare;
import io.github.dfa1.vortex.reader.compute.Compute;
import io.github.dfa1.vortex.reader.compute.FilteredAggregate;
Expand Down Expand Up @@ -439,6 +453,98 @@ DType columnType(ColumnName column) {
return struct.fieldTypes().get(struct.fieldNames().indexOf(column));
}

/// `COUNT(DISTINCT column)` over the rows `filter` selects: the number of distinct non-null
/// values, counted without a boxed value per row. Integer and temporal storage, and doubles,
/// are counted by their raw bits in a primitive [LongIntMap] (for doubles the same equality as
/// `Double.equals`, which Calcite's own `DISTINCT` uses); a dictionary-encoded string column
/// marks the codes a chunk uses and decodes each once; other strings go through a set.
///
/// @param column the column to count distinct values of
/// @param filter the rows to count over, or `null` for every row
/// @return the count, or empty for a column type this does not count (decimals, binary, nested)
public Optional<Long> distinctCount(ColumnName column, RowFilter filter) {
java.util.LinkedHashSet<ColumnName> columns = new java.util.LinkedHashSet<>();
columns.add(column);
ScanOptions options;
if (filter == null) {
options = ScanOptions.columns(column);
} else {
RexFilterTranslator.collectColumns(filter, columns);
options = ScanOptions.columns(columns.toArray(ColumnName[]::new)).withFilter(filter);
}
LongIntMap bits = new LongIntMap(1024);
java.util.Set<String> strings = new java.util.HashSet<>();
try (VortexReader reader = VortexReader.open(file);
ScanIterator scan = reader.scan(options)) {
while (scan.hasNext()) {
try (Chunk chunk = scan.next()) {
java.util.function.LongPredicate selected = filter == null ? null : Compute.matcher(chunk, filter);
Array array = chunk.column(column);
BoolArray validity = array instanceof MaskedArray masked ? masked.validity() : null;
Array data = array instanceof MaskedArray masked ? masked.inner() : array;
long n = chunk.rowCount();
if (!countChunk(data, validity, selected, n, bits, strings)) {
return Optional.empty();
}
}
}
} catch (IOException e) {
throw new UncheckedIOException("cannot count distinct values of " + column + " in " + file, e);
}
return Optional.of((long) bits.size() + strings.size());
}

/// Adds one chunk's distinct values; `false` for a column type [#distinctCount] does not count.
private static boolean countChunk(Array data, BoolArray validity, java.util.function.LongPredicate selected,
long n, LongIntMap bits, java.util.Set<String> strings) {
java.util.function.LongUnaryOperator bitsOf = switch (data) {
case LongArray a -> a::getLong;
case IntArray a -> a::getInt;
case ShortArray a -> a::getShort;
case ByteArray a -> a::getByte;
case DoubleArray a -> i -> Double.doubleToLongBits(a.getDouble(i));
case FloatArray a -> i -> Double.doubleToLongBits(a.getFloat(i));
case BoolArray a -> i -> a.getBoolean(i) ? 1 : 0;
default -> null;
};
if (bitsOf != null) {
for (long i = 0; i < n; i++) {
if ((selected == null || selected.test(i)) && (validity == null || validity.getBoolean(i))) {
bits.add(bitsOf.applyAsLong(i));
}
}
return true;
}
VarBinDictArray dict = data instanceof VarBinDictArray d ? d
: data instanceof VarBinSlicedArray(var _, var _, VarBinDictArray d, var _) ? d : null;
if (dict != null && data.dtype() instanceof DType.Utf8) {
long offset = data instanceof VarBinSlicedArray sliced ? sliced.offset() : 0;
VarBinOffsetArray values = dict.values();
java.util.BitSet used = new java.util.BitSet();
for (long i = 0; i < n; i++) {
if ((selected == null || selected.test(i)) && (validity == null || validity.getBoolean(i))) {
long code = dict.code(i + offset);
if (code < 0 || code >= values.length()) {
throw new VortexException("dict code " + code + " out of range for "
+ values.length() + " dictionary entries");
}
used.set((int) code);
}
}
used.stream().forEach(code -> strings.add(values.getString(code)));
return true;
}
if (data instanceof VarBinArray varBin && data.dtype() instanceof DType.Utf8) {
for (long i = 0; i < n; i++) {
if ((selected == null || selected.test(i)) && (validity == null || validity.getBoolean(i))) {
strings.add(varBin.getString(i));
}
}
return true;
}
return false;
}

/// One `ORDER BY` key of [#topN(int[], RowFilter, List, int)].
///
/// @param field the key's position in the projected row
Expand Down Expand Up @@ -474,30 +580,39 @@ public List<Object[]> topN(int[] cols, RowFilter filter, List<SortKey> keys, int
options = ScanOptions.columns(scanColumns.toArray(ColumnName[]::new)).withFilter(filter);
}
java.util.Comparator<Object[]> order = rowOrder(keys);
// equal keys rank by scan position: Calcite's sort is stable, so ties come out in scan order
java.util.Comparator<Ranked> ranking = java.util.Comparator.<Ranked, Object[]>comparing(Ranked::row, order)
.thenComparingLong(Ranked::position);
// the worst kept row at the head, so a better row evicts it
java.util.PriorityQueue<Object[]> heap = new java.util.PriorityQueue<>(count + 1, order.reversed());
java.util.PriorityQueue<Ranked> heap = new java.util.PriorityQueue<>(count + 1, ranking.reversed());
VortexEnumerator rows = new VortexEnumerator(file, new AtomicLong(), options, outNames, outTypes, filter);
// only the key columns are read per row; a row is built once it beats the worst one kept
// only the key columns are read per row; a row is built once it beats the worst one kept. A
// later row never beats an equal key, so the first rows of a tie are the ones kept
Object[] probe = new Object[cols.length];
long position = 0;
try {
while (rows.advance()) {
if (heap.size() == count) {
for (SortKey key : keys) {
probe[key.field()] = rows.column(key.field());
}
if (order.compare(probe, heap.peek()) >= 0) {
if (order.compare(probe, heap.peek().row()) >= 0) {
position++;
continue;
}
heap.poll();
}
heap.add(rows.row());
heap.add(new Ranked(rows.row(), position++));
}
} finally {
rows.close();
}
List<Object[]> result = new ArrayList<>(heap);
result.sort(order);
return result;
return heap.stream().sorted(ranking).map(Ranked::row).toList();
}

/// A row kept by [#topN(int[], RowFilter, List, int)] with its position in the scan.
@SuppressWarnings("java:S6218") // internal data carrier; record components are arrays of immutable primitives or refs that flow through pipelines without ever being compared.
private record Ranked(Object[] row, long position) {
}

@SuppressWarnings({"unchecked", "rawtypes"})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,11 @@ void groupedAggregateTheFoldCannotAnswer_isLeftUntouched(String sql) {

/// Zone statistics cover every row, so they cannot answer an aggregate over a subset of rows:
/// answering these from stats returned the row count for `COUNT(DISTINCT)` and ignored `FILTER`.
/// An ungrouped `COUNT(DISTINCT)` is now counted by its own pass, so a `SUM(DISTINCT)` stands in.
@ParameterizedTest
@ValueSource(strings = {
"select count(distinct volume) from ohlc",
// COUNT(DISTINCT) has its own count (CountDistinctTest); any other DISTINCT stays with Calcite
"select sum(distinct volume) from ohlc",
"select count(*) filter (where volume > 0) from ohlc",
"select min(low) filter (where high > 0), count(*) from ohlc"})
void distinctOrFilteredAggregate_isLeftUntouched(String sql) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package io.github.dfa1.vortex.calcite;

import io.github.dfa1.vortex.core.model.ColumnName;
import io.github.dfa1.vortex.core.model.DType;
import io.github.dfa1.vortex.core.model.PType;
import io.github.dfa1.vortex.writer.VortexWriter;
import io.github.dfa1.vortex.writer.WriteOptions;

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;

import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

/// An ungrouped `COUNT(DISTINCT col)` is counted by [VortexTable#distinctCount(ColumnName,
/// io.github.dfa1.vortex.reader.RowFilter)] instead of Calcite hashing a boxed value per row. Each
/// query must equal Calcite's own count over `CASE WHEN id > 0 THEN col END` (`id` is always positive),
/// which the rule leaves alone; the test asserts that oracle really scans.
class CountDistinctTest {

@TempDir
static Path tmp;
private static Path file;

/// Two chunks. `n` repeats values across chunks and has nulls; `x` holds -0.0, 0.0 and NaN,
/// which Calcite's DISTINCT tells apart as `Double.equals` does; `s` repeats 3 symbols.
@BeforeAll
static void writeFile() throws Exception {
file = tmp.resolve("distinct.vortex");
DType.Struct schema = DType.structBuilder()
.field(ColumnName.of("id"), DType.I64)
.field(ColumnName.of("n"), DType.I64.asNullable())
.field(ColumnName.of("x"), new DType.Primitive(PType.F64, false))
.field(ColumnName.of("s"), new DType.Utf8(true))
.build();
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
var writer = VortexWriter.create(ch, schema, WriteOptions.defaults())) {
writer.writeChunk(Map.of(ColumnName.of("id"), new long[]{1, 2, 3, 4, 5},
ColumnName.of("n"), new Long[]{10L, null, 10L, 20L, 30L},
ColumnName.of("x"), new double[]{-0.0, 0.0, Double.NaN, 1.5, -0.0},
ColumnName.of("s"), new String[]{"a", "b", null, "a", "c"}));
writer.writeChunk(Map.of(ColumnName.of("id"), new long[]{6, 7, 8},
ColumnName.of("n"), new Long[]{30L, 40L, null},
ColumnName.of("x"), new double[]{Double.NaN, 2.5, 1.5},
ColumnName.of("s"), new String[]{"c", "a", "d"}));
}
}

@ParameterizedTest(name = "{0}")
@CsvSource(delimiter = '|', quoteCharacter = '"', value = {
"count(distinct n) | count(distinct case when id > 0 then n end)",
"count(distinct x) | count(distinct case when id > 0 then x end)",
"count(distinct s) | count(distinct case when id > 0 then s end)",
"count(distinct s), count(distinct n) | count(distinct case when id > 0 then s end), count(distinct case when id > 0 then n end)"})
void countDistinct_isPushedDownAndEqualsCalcite(String aggregates, String oracle) throws Exception {
// Given
String sql = "select " + aggregates + " from vtx.t";

try (Connection conn = VortexCalcite.connect("vtx", Map.of("t", file))) {
// When / Then
assertThat(single(conn, "explain plan for " + sql)).contains("Values").doesNotContain("TableScan");
assertThat(single(conn, "explain plan for select " + oracle + " from vtx.t")).contains("TableScan");
assertThat(row(conn, sql)).isEqualTo(row(conn, "select " + oracle + " from vtx.t"));
}
}

@ParameterizedTest
@ValueSource(strings = {
"select s, count(distinct n) from vtx.t group by s",
"select count(distinct n) filter (where id > 2) from vtx.t"})
void distinctTheRuleDoesNotCount_isLeftToCalcite(String sql) throws Exception {
// Given / When / Then
try (Connection conn = VortexCalcite.connect("vtx", Map.of("t", file))) {
assertThat(single(conn, "explain plan for " + sql)).contains("TableScan");
}
}

private static String single(Connection conn, String sql) throws Exception {
try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
rs.next();
return rs.getString(1);
}
}

private static String row(Connection conn, String sql) throws Exception {
try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
rs.next();
StringBuilder out = new StringBuilder();
for (int c = 1; c <= rs.getMetaData().getColumnCount(); c++) {
out.append(rs.getLong(c)).append('|');
}
return out.toString();
}
}
}
Loading
Loading