Skip to content

Commit 888698f

Browse files
dfa1claude
andcommitted
perf(calcite): stream scan rows lazily instead of materialising a List<Object[]>
VortexTable.scan() built the entire result as a List<Object[]> (one fresh array + boxed cell per row) before returning. For a 1M-row full scan an async-profiler run showed ~72% of CPU in G1 GC: every row was promoted into the old gen and the whole result stayed live at once. Column decode itself was ~0.5%. Replace it with a streaming Enumerator that advances chunk by chunk, decoding each requested column once per chunk and yielding one row per moveNext(). Rows are no longer retained, so the working set is one chunk and rows die in the young gen. Fresh array per row is kept (correct for ORDER BY / joins that retain rows). Measured (CalciteDemo, 1M rows, MIN/MAX/COUNT full scan): GC 71% -> 3%, ~52 ms/query -> ~28 ms/query. CalciteDemo is a profiling harness, disabled unless -Ddemo.profile=true; run under async-profiler by attaching to the forked test JVM (argLine is owned by the byte-buddy agent goal, so attach by PID rather than -agentpath). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 22d3660 commit 888698f

2 files changed

Lines changed: 162 additions & 28 deletions

File tree

calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexTable.java

Lines changed: 101 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@
1515

1616
import org.apache.calcite.DataContext;
1717
import org.apache.calcite.jdbc.JavaTypeFactoryImpl;
18+
import org.apache.calcite.linq4j.AbstractEnumerable;
1819
import org.apache.calcite.linq4j.Enumerable;
19-
import org.apache.calcite.linq4j.Linq4j;
20+
import org.apache.calcite.linq4j.Enumerator;
2021
import org.apache.calcite.rel.type.RelDataType;
2122
import org.apache.calcite.rel.type.RelDataTypeFactory;
2223
import org.apache.calcite.rex.RexBuilder;
@@ -139,21 +140,108 @@ public Enumerable<Object[]> scan(DataContext root, List<RexNode> filters, int[]
139140
options = ScanOptions.columns(outNames);
140141
}
141142

142-
List<Object[]> rows = new ArrayList<>();
143-
long scanned = 0;
144-
try (VortexReader reader = VortexReader.open(file);
145-
ScanIterator scan = reader.scan(options)) {
146-
while (scan.hasNext()) {
147-
try (Chunk chunk = scan.next()) {
148-
scanned++;
149-
appendChunk(chunk, outNames, outTypes, rows);
143+
// Stream rows lazily: decode one chunk at a time and yield a fresh row, so rows die young
144+
// (in G1's young gen) instead of piling a whole-result List<Object[]> into the old gen — the
145+
// dominant cost an async-profiler run showed for the full-scan path (~72% in GC).
146+
ScanOptions scanOptions = options;
147+
return new AbstractEnumerable<>() {
148+
@Override
149+
public Enumerator<Object[]> enumerator() {
150+
return new VortexEnumerator(scanOptions, outNames, outTypes);
151+
}
152+
};
153+
}
154+
155+
/// Streaming [Enumerator] over a Vortex scan: advances chunk by chunk, decoding each requested
156+
/// column once per chunk and materialising one `Object[]` row per [#moveNext()]. Rows are not
157+
/// retained, so the working set stays at one chunk rather than the whole result.
158+
private final class VortexEnumerator implements Enumerator<Object[]> {
159+
160+
private final String[] names;
161+
private final DType[] types;
162+
private final VortexReader reader;
163+
private final ScanIterator scan;
164+
private Chunk chunk;
165+
private Object[] columns;
166+
private long rowInChunk;
167+
private long chunkRows;
168+
private Object[] current;
169+
170+
private VortexEnumerator(ScanOptions options, String[] names, DType[] types) {
171+
this.names = names;
172+
this.types = types;
173+
chunksScannedLastQuery.set(0);
174+
VortexReader openedReader = null;
175+
try {
176+
openedReader = VortexReader.open(file);
177+
this.reader = openedReader;
178+
this.scan = openedReader.scan(options);
179+
} catch (IOException e) {
180+
closeQuietly(openedReader);
181+
throw new UncheckedIOException("cannot scan " + file, e);
182+
} catch (RuntimeException e) {
183+
closeQuietly(openedReader);
184+
throw e;
185+
}
186+
}
187+
188+
private void closeQuietly(VortexReader r) {
189+
if (r != null) {
190+
r.close();
191+
}
192+
}
193+
194+
@Override
195+
public Object[] current() {
196+
return current;
197+
}
198+
199+
@Override
200+
public boolean moveNext() {
201+
while (true) {
202+
if (chunk != null && rowInChunk < chunkRows) {
203+
Object[] row = new Object[names.length];
204+
for (int c = 0; c < names.length; c++) {
205+
row[c] = value(columns[c], types[c], rowInChunk);
206+
}
207+
rowInChunk++;
208+
current = row;
209+
return true;
210+
}
211+
if (chunk != null) {
212+
chunk.close();
213+
chunk = null;
214+
}
215+
if (!scan.hasNext()) {
216+
return false;
217+
}
218+
chunk = scan.next();
219+
chunksScannedLastQuery.incrementAndGet();
220+
chunkRows = chunk.rowCount();
221+
rowInChunk = 0;
222+
columns = new Object[names.length];
223+
for (int c = 0; c < names.length; c++) {
224+
columns[c] = chunk.column(names[c]);
150225
}
151226
}
152-
} catch (IOException e) {
153-
throw new UncheckedIOException("cannot scan " + file, e);
154227
}
155-
chunksScannedLastQuery.set(scanned);
156-
return Linq4j.asEnumerable(rows);
228+
229+
@Override
230+
public void reset() {
231+
throw new UnsupportedOperationException("VortexEnumerator does not support reset");
232+
}
233+
234+
@Override
235+
public void close() {
236+
try {
237+
if (chunk != null) {
238+
chunk.close();
239+
}
240+
} finally {
241+
scan.close();
242+
reader.close();
243+
}
244+
}
157245
}
158246

159247
private DType.Struct struct() {
@@ -175,21 +263,6 @@ private static int[] allColumns(int n) {
175263
return all;
176264
}
177265

178-
private static void appendChunk(Chunk chunk, String[] names, DType[] types, List<Object[]> rows) {
179-
long n = chunk.rowCount();
180-
Object[] arrays = new Object[names.length];
181-
for (int c = 0; c < names.length; c++) {
182-
arrays[c] = chunk.column(names[c]);
183-
}
184-
for (long r = 0; r < n; r++) {
185-
Object[] row = new Object[names.length];
186-
for (int c = 0; c < names.length; c++) {
187-
row[c] = value(arrays[c], types[c], r);
188-
}
189-
rows.add(row);
190-
}
191-
}
192-
193266
private static Object value(Object array, DType type, long r) {
194267
return switch (type) {
195268
case DType.Primitive p -> switch (p.ptype()) {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package io.github.dfa1.vortex.calcite;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
5+
6+
import java.nio.file.Files;
7+
import java.nio.file.Path;
8+
import java.sql.Connection;
9+
import java.sql.DriverManager;
10+
import java.sql.ResultSet;
11+
import java.sql.Statement;
12+
import java.util.Map;
13+
import java.util.Properties;
14+
15+
/// Profiling harness (disabled in normal runs). Writes a 1M-row OHLC Vortex file once, then runs
16+
/// the full-table `MIN/MAX/COUNT` SQL many times through Calcite so a CPU profile has enough
17+
/// samples to show where the scan path spends its time.
18+
///
19+
/// Run under async-profiler:
20+
/// ```
21+
/// ./mvnw test -pl calcite -am -Dtest=CalciteDemo -Ddemo.profile=true \
22+
/// -DargLine="-agentpath:/opt/homebrew/lib/libasyncProfiler.dylib=start,event=cpu,file=/tmp/calcite.collapsed,collapsed"
23+
/// ```
24+
class CalciteDemo {
25+
26+
@Test
27+
@EnabledIfSystemProperty(named = "demo.profile", matches = "true")
28+
void profileFullScan() throws Exception {
29+
int rows = Integer.getInteger("demo.rows", 1_000_000);
30+
int iterations = Integer.getInteger("demo.iterations", 300);
31+
32+
Path file = Files.createTempFile("ohlc-demo", ".vortex");
33+
try {
34+
OhlcGenerator.write(file, rows, 10_000);
35+
System.out.printf("wrote %,d rows -> %.1f MB%n", rows, Files.size(file) / 1048576.0);
36+
37+
Properties info = new Properties();
38+
info.setProperty("lex", "JAVA");
39+
String sql = "select min(low) lo, max(high) hi, count(*) c from vtx.ohlc";
40+
41+
try (Connection conn = DriverManager.getConnection("jdbc:calcite:", info)) {
42+
conn.unwrap(org.apache.calcite.jdbc.CalciteConnection.class).getRootSchema()
43+
.add("vtx", new VortexSchema(Map.of("ohlc", file)));
44+
45+
long t0 = System.nanoTime();
46+
long count = 0;
47+
for (int i = 0; i < iterations; i++) {
48+
try (Statement st = conn.createStatement();
49+
ResultSet rs = st.executeQuery(sql)) {
50+
rs.next();
51+
count = rs.getLong("c");
52+
}
53+
}
54+
double ms = (System.nanoTime() - t0) / 1e6 / iterations;
55+
System.out.printf("full scan x%d: %.2f ms/query | count=%,d%n", iterations, ms, count);
56+
}
57+
} finally {
58+
Files.deleteIfExists(file);
59+
}
60+
}
61+
}

0 commit comments

Comments
 (0)