Skip to content

Commit 6e6d7dd

Browse files
dfa1claude
andcommitted
perf(compute): monomorphic fold read in the dict aggregate lane
Async-profiler put 39% of fusedFilteredAggregateDict's CPU in what looked like a non-inlined MemorySegment VarHandle chain, but PrintInlining showed the chain force-inlined fine — the frames were attribution artifacts. The real finding was the fold's aggregate read: LongAggFold.accept read through NumericColumns.widenLong, which the match-table pool evaluation also calls, so widenLong's merged receiver profile (pool MaterializedLongArray + aggregate LazyForLongArray) compiled the per-match read as a bimorphic guard whose lost receiver type degraded the inner segment reads to virtual calls — one set per matching row. Each fold now reads through its own private copy of the widening logic, so its call-site profile only ever sees aggregate receivers and the read stays monomorphic, like the sum lane's. The run-loop variants also moved to one-loop-per-method (performance-neutral, but keeps inline decisions independent and profiles attributable per shape). fusedFilteredAggregateDict (100M rows): 178.3 -> 36.2 ms/op, ~27x over the original 982.5 ms and within ~10 ms of the sum lane — the honest per-match cost of the min/max/count fold. Lesson recorded in ADR 0013: value-read helpers shared between a cold setup path and a hot loop poison the receiver profile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c659e78 commit 6e6d7dd

5 files changed

Lines changed: 177 additions & 55 deletions

File tree

TODO.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,12 @@ Per-encoding gotchas:
9797
Encoded-domain value specialization was measured as a no-win (decode is dispatch-bound, see
9898
`forLoopDictEncoded`); the real dict lever — the monomorphic code-segment scan — shipped as the
9999
`DictFilter` lane in both kernels (`fusedFilteredSumDict` 762 → 25.5 ms/op ≈ 30×;
100-
`fusedFilteredAggregateDict` 983 → 178 ms/op ≈ 5.5×, single-leaf filters and `COUNT(*)`).
100+
`fusedFilteredAggregateDict` 983 → 36 ms/op ≈ 27× after the profile-guided monomorphic fold
101+
read; single-leaf filters and `COUNT(*)`).
101102
Next: (1) the columnar transducer façade — [ADR-0019](docs/adr/0019-columnar-transducer-facade.md)
102103
drafted (Proposed): declarative column-bound stages compiled to one fused pass, multi-aggregate
103-
folds for the Calcite boundary tier; review, then implement. (2) Optional: specialize the dict
104-
aggregate lane's no-null long fold to close the 178 → 25 ms gap if the boundary tier needs it;
105-
(3) extend the dict lane to multi-leaf `AND`s (dict leaf as the driving scan, residual leaves
106-
tested per match).
104+
folds for the Calcite boundary tier; review, then implement. (2) Extend the dict lane to
105+
multi-leaf `AND`s (dict leaf as the driving scan, residual leaves tested per match).
107106

108107
## Encodings
109108

docs/adr/0013-compute-primitives.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,19 @@ backing segment — no per-element `findChunk` binary search, no broadcast-guard
8787
| `fusedFilteredSumDict` after (`DictFilter` lane) | 25.5 ± 2.5 ms/op |
8888
| `fusedFilteredAggregateDict` before (per-leaf `RowPredicate`) | 982.5 ± 30.2 ms/op |
8989
| `fusedFilteredAggregateDict` after (`DictFilter` lane) | 178.3 ± 4.1 ms/op |
90+
| `fusedFilteredAggregateDict` after profile-guided fold fix | 36.2 ± 1.1 ms/op |
9091

9192
The `filteredSum` lane reproduces the raw-scan ceiling (≈ 30×) with no measurable kernel overhead.
9293
The `filteredAggregate` lane (a single comparison leaf driving the same code scan, extended to the
93-
full `SUM`/`MIN`/`MAX`/count fold and `COUNT(*)`) lands at ≈ 5.5× — off the pure-scan ceiling
94-
because the per-match fold (sum, min, max, two counters) keeps its loop body scalar; specializing
95-
the common no-null long-aggregate fold is a possible follow-up if the boundary tier ever needs it.
94+
full `SUM`/`MIN`/`MAX`/count fold and `COUNT(*)`) first landed at ≈ 5.5×; async-profiler +
95+
`PrintInlining` then showed the residual was NOT the fold arithmetic but a bimorphic aggregate
96+
read — the fold shared `NumericColumns.widenLong` with the match-table pool evaluation, so the
97+
merged receiver profile compiled the per-match read as a bimorphic guard with partially
98+
devirtualized segment reads. Giving the fold its own private read (its profile only ever sees
99+
aggregate receivers) brought the lane to ≈ 27×, within ≈ 10 ms of the sum lane — the honest
100+
per-match cost of the min/max/count fold. Lesson recorded: shared value-read helpers between a
101+
cold setup path and a hot loop poison the receiver profile; hot folds read through their own call
102+
sites.
96103

97104
## Context
98105

docs/adr/0019-columnar-transducer-facade.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ off twice:
2020
2. The dict code-scan lane (`DictFilter`) showed that the wins now come from **encoding-aware
2121
structural dispatch**: the kernel inspects the filter column's shape (an offset slice over a
2222
dict view with `u8` codes) and swaps the whole loop, not the per-row callback. Measured on 100M
23-
rows: 762 → 25.5 ms/op for `filteredSum`, 983 → 178 ms/op for `filteredAggregate` (the fuller
24-
per-match fold — sum, min, max, two counters — keeps the aggregate lane off the pure-scan
25-
ceiling). A per-row lambda (`RowPredicate`) cannot express that — the lane needs to see the
26-
*description* of the filter, not a compiled `test(i)`.
23+
rows: 762 → 25.5 ms/op for `filteredSum`, 983 → 36 ms/op for `filteredAggregate` (the last 5×
24+
of which required a profile-guided fix: the fold's aggregate read had to become monomorphic —
25+
see ADR 0013's result table). A per-row lambda (`RowPredicate`) cannot express that — the lane
26+
needs to see the *description* of the filter, not a compiled `test(i)`.
2727

2828
Meanwhile the caller side is growing. The Calcite adapter (ADR 0018) invokes `filteredAggregate`
2929
from the boundary-chunk tier of the aggregate push-down; each new capability (a second aggregate

performance/src/main/java/io/github/dfa1/vortex/performance/ComputeKernelBenchmark.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,9 +255,11 @@ public long fusedFilteredSumDict() {
255255
/// the return value so JMH cannot eliminate the loop.
256256
///
257257
/// Result (2026-07-02, 100M rows): 982.5 ± 30.2 ms/op through the per-leaf accessor path;
258-
/// 178.3 ± 4.1 ms/op with the `DictFilter` lane — ≈ 5.5×. The gap to [#fusedFilteredSumDict()]'s
259-
/// 25.5 ms is the fuller per-match fold (sum, min, max, two counters) whose heavier loop body
260-
/// stays scalar; closing it is a possible follow-up specialization.
258+
/// 178.3 ± 4.1 ms/op with the `DictFilter` lane; 36.2 ± 1.1 ms/op (≈ 27×) once async-profiler +
259+
/// PrintInlining exposed the fold's aggregate read as bimorphic — `NumericColumns.widenLong`
260+
/// was shared with the match-table pool reads, polluting its receiver profile — and the fold
261+
/// got its own monomorphic read. The remaining gap to [#fusedFilteredSumDict()]'s 25.5 ms is
262+
/// the genuine per-match fold work (min, max, two counters).
261263
///
262264
/// @return the selected row count plus the sum of `measure` where `category == 7`, folded
263265
/// across the whole dataset

reader/src/main/java/io/github/dfa1/vortex/reader/compute/DictFilter.java

Lines changed: 154 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
import io.github.dfa1.vortex.reader.array.DictLongArray;
1111
import io.github.dfa1.vortex.reader.array.DoubleArray;
1212
import io.github.dfa1.vortex.reader.array.FloatArray;
13+
import io.github.dfa1.vortex.reader.array.IntArray;
1314
import io.github.dfa1.vortex.reader.array.LongArray;
15+
import io.github.dfa1.vortex.reader.array.ShortArray;
1416
import io.github.dfa1.vortex.reader.array.OffsetDoubleArray;
1517
import io.github.dfa1.vortex.reader.array.OffsetFloatArray;
1618
import io.github.dfa1.vortex.reader.array.OffsetIntArray;
@@ -454,6 +456,11 @@ private static long count(List<Run> runs, CodeTable table, BoolArray fVal) {
454456

455457
/// Folds the long-domain aggregate accumulator over the code-matching rows of every run.
456458
///
459+
/// Each run-loop variant lives in its own method: one loop per compilation unit keeps every
460+
/// loop's inline decisions independent and its profile attributable per shape. (The measured
461+
/// hot-path fix was elsewhere — the fold's monomorphic aggregate read, see
462+
/// [LongAggFold#read(long)] — the split itself was performance-neutral.)
463+
///
457464
/// @param runs the code runs in row order
458465
/// @param table the lowered match table
459466
/// @param fold the accumulator receiving each matching row
@@ -463,34 +470,71 @@ private static void scanRunsLong(List<Run> runs, CodeTable table, LongAggFold fo
463470
byte only = (byte) table.only();
464471
for (Run run : runs) {
465472
MemorySegment seg = run.seg();
466-
long base = run.childBase();
467-
long rowBase = run.rowBase();
468-
long len = run.len();
469473
if (seg != null && single) {
470-
for (long j = 0; j < len; j++) {
471-
if (seg.get(ValueLayout.JAVA_BYTE, base + j) == only) {
472-
fold.accept(rowBase + j);
473-
}
474-
}
474+
scanRunSingleLong(seg, run.childBase(), run.rowBase(), run.len(), only, fold);
475475
} else if (seg != null) {
476-
for (long j = 0; j < len; j++) {
477-
if (match[seg.get(ValueLayout.JAVA_BYTE, base + j) & 0xFF]) {
478-
fold.accept(rowBase + j);
479-
}
480-
}
476+
scanRunTableLong(seg, run.childBase(), run.rowBase(), run.len(), match, fold);
481477
} else {
482-
ByteArray child = run.child();
483-
for (long j = 0; j < len; j++) {
484-
if (match[child.getByte(base + j) & 0xFF]) {
485-
fold.accept(rowBase + j);
486-
}
487-
}
478+
scanRunAccessorLong(run.child(), run.childBase(), run.rowBase(), run.len(), match, fold);
479+
}
480+
}
481+
}
482+
483+
/// Single-match segment scan feeding the long-domain fold.
484+
///
485+
/// @param seg the run's backing segment
486+
/// @param base the run's first index within the segment
487+
/// @param rowBase the run's first row in the scanned view
488+
/// @param len the run length
489+
/// @param only the single matching code
490+
/// @param fold the accumulator receiving each matching row
491+
private static void scanRunSingleLong(MemorySegment seg, long base, long rowBase, long len,
492+
byte only, LongAggFold fold) {
493+
for (long j = 0; j < len; j++) {
494+
if (seg.get(ValueLayout.JAVA_BYTE, base + j) == only) {
495+
fold.accept(rowBase + j);
496+
}
497+
}
498+
}
499+
500+
/// Table-lookup segment scan feeding the long-domain fold.
501+
///
502+
/// @param seg the run's backing segment
503+
/// @param base the run's first index within the segment
504+
/// @param rowBase the run's first row in the scanned view
505+
/// @param len the run length
506+
/// @param match the lowered per-code match table
507+
/// @param fold the accumulator receiving each matching row
508+
private static void scanRunTableLong(MemorySegment seg, long base, long rowBase, long len,
509+
boolean[] match, LongAggFold fold) {
510+
for (long j = 0; j < len; j++) {
511+
if (match[seg.get(ValueLayout.JAVA_BYTE, base + j) & 0xFF]) {
512+
fold.accept(rowBase + j);
513+
}
514+
}
515+
}
516+
517+
/// Accessor-fallback scan feeding the long-domain fold (segmentless or broadcast children).
518+
///
519+
/// @param child the codes child owning the run
520+
/// @param base the run's first index within the child
521+
/// @param rowBase the run's first row in the scanned view
522+
/// @param len the run length
523+
/// @param match the lowered per-code match table
524+
/// @param fold the accumulator receiving each matching row
525+
private static void scanRunAccessorLong(ByteArray child, long base, long rowBase, long len,
526+
boolean[] match, LongAggFold fold) {
527+
for (long j = 0; j < len; j++) {
528+
if (match[child.getByte(base + j) & 0xFF]) {
529+
fold.accept(rowBase + j);
488530
}
489531
}
490532
}
491533

492534
/// Folds the double-domain aggregate accumulator over the code-matching rows of every run.
493535
///
536+
/// One run-loop variant per method, mirroring [#scanRunsLong(List, CodeTable, LongAggFold)].
537+
///
494538
/// @param runs the code runs in row order
495539
/// @param table the lowered match table
496540
/// @param fold the accumulator receiving each matching row
@@ -500,28 +544,63 @@ private static void scanRunsDouble(List<Run> runs, CodeTable table, DoubleAggFol
500544
byte only = (byte) table.only();
501545
for (Run run : runs) {
502546
MemorySegment seg = run.seg();
503-
long base = run.childBase();
504-
long rowBase = run.rowBase();
505-
long len = run.len();
506547
if (seg != null && single) {
507-
for (long j = 0; j < len; j++) {
508-
if (seg.get(ValueLayout.JAVA_BYTE, base + j) == only) {
509-
fold.accept(rowBase + j);
510-
}
511-
}
548+
scanRunSingleDouble(seg, run.childBase(), run.rowBase(), run.len(), only, fold);
512549
} else if (seg != null) {
513-
for (long j = 0; j < len; j++) {
514-
if (match[seg.get(ValueLayout.JAVA_BYTE, base + j) & 0xFF]) {
515-
fold.accept(rowBase + j);
516-
}
517-
}
550+
scanRunTableDouble(seg, run.childBase(), run.rowBase(), run.len(), match, fold);
518551
} else {
519-
ByteArray child = run.child();
520-
for (long j = 0; j < len; j++) {
521-
if (match[child.getByte(base + j) & 0xFF]) {
522-
fold.accept(rowBase + j);
523-
}
524-
}
552+
scanRunAccessorDouble(run.child(), run.childBase(), run.rowBase(), run.len(), match, fold);
553+
}
554+
}
555+
}
556+
557+
/// Single-match segment scan feeding the double-domain fold.
558+
///
559+
/// @param seg the run's backing segment
560+
/// @param base the run's first index within the segment
561+
/// @param rowBase the run's first row in the scanned view
562+
/// @param len the run length
563+
/// @param only the single matching code
564+
/// @param fold the accumulator receiving each matching row
565+
private static void scanRunSingleDouble(MemorySegment seg, long base, long rowBase, long len,
566+
byte only, DoubleAggFold fold) {
567+
for (long j = 0; j < len; j++) {
568+
if (seg.get(ValueLayout.JAVA_BYTE, base + j) == only) {
569+
fold.accept(rowBase + j);
570+
}
571+
}
572+
}
573+
574+
/// Table-lookup segment scan feeding the double-domain fold.
575+
///
576+
/// @param seg the run's backing segment
577+
/// @param base the run's first index within the segment
578+
/// @param rowBase the run's first row in the scanned view
579+
/// @param len the run length
580+
/// @param match the lowered per-code match table
581+
/// @param fold the accumulator receiving each matching row
582+
private static void scanRunTableDouble(MemorySegment seg, long base, long rowBase, long len,
583+
boolean[] match, DoubleAggFold fold) {
584+
for (long j = 0; j < len; j++) {
585+
if (match[seg.get(ValueLayout.JAVA_BYTE, base + j) & 0xFF]) {
586+
fold.accept(rowBase + j);
587+
}
588+
}
589+
}
590+
591+
/// Accessor-fallback scan feeding the double-domain fold (segmentless or broadcast children).
592+
///
593+
/// @param child the codes child owning the run
594+
/// @param base the run's first index within the child
595+
/// @param rowBase the run's first row in the scanned view
596+
/// @param len the run length
597+
/// @param match the lowered per-code match table
598+
/// @param fold the accumulator receiving each matching row
599+
private static void scanRunAccessorDouble(ByteArray child, long base, long rowBase, long len,
600+
boolean[] match, DoubleAggFold fold) {
601+
for (long j = 0; j < len; j++) {
602+
if (match[child.getByte(base + j) & 0xFF]) {
603+
fold.accept(rowBase + j);
525604
}
526605
}
527606
}
@@ -563,7 +642,7 @@ void accept(long row) {
563642
return;
564643
}
565644
nonNull++;
566-
long v = NumericColumns.widenLong(aggData, unsigned, row);
645+
long v = read(row);
567646
sum += v;
568647
if (!found) {
569648
min = v;
@@ -579,6 +658,31 @@ void accept(long row) {
579658
}
580659
}
581660

661+
/// Reads aggregate position `row` widened to a `long` — a deliberate private copy of
662+
/// [NumericColumns#widenLong(Array, boolean, long)]. The shared helper's receiver profile
663+
/// mixes every caller: [#matchTable(DictShape, Predicate)] feeds it the (materialized)
664+
/// dictionary pool, so the fold's hot aggregate read compiled as a bimorphic guard whose
665+
/// lost receiver type degraded the inner segment reads to virtual calls per match
666+
/// (async-profiler + PrintInlining, 2026-07-02). This copy's profile only ever sees
667+
/// aggregate receivers, keeping the read monomorphic like the sum lane's.
668+
///
669+
/// @param row the zero-based position
670+
/// @return the widened aggregate value
671+
private long read(long row) {
672+
Array data = aggData;
673+
if (data instanceof LongArray la) {
674+
return la.getLong(row);
675+
}
676+
if (data instanceof IntArray ia) {
677+
return unsigned ? (ia.getInt(row) & 0xFFFFFFFFL) : ia.getInt(row);
678+
}
679+
if (data instanceof ShortArray sa) {
680+
return unsigned ? (sa.getShort(row) & 0xFFFFL) : sa.getShort(row);
681+
}
682+
ByteArray ba = (ByteArray) data;
683+
return unsigned ? (ba.getByte(row) & 0xFFL) : ba.getByte(row);
684+
}
685+
582686
/// Boxes the fold exactly as the kernel's long lane does.
583687
///
584688
/// @return the fold: selected count, non-null count, the [Long] sum, and the [Long]
@@ -626,7 +730,7 @@ void accept(long row) {
626730
return;
627731
}
628732
nonNull++;
629-
double v = readDouble(aggData, aggIsFloat, row);
733+
double v = read(row);
630734
sum += v;
631735
if (!found) {
632736
min = v;
@@ -642,6 +746,16 @@ void accept(long row) {
642746
}
643747
}
644748

749+
/// Reads aggregate position `row` widened to a `double` — a private copy of the shared
750+
/// helper, for the same receiver-profile isolation as the long fold's read: this call
751+
/// site's profile only ever sees aggregate receivers, keeping the read monomorphic.
752+
///
753+
/// @param row the zero-based position
754+
/// @return the aggregate value widened to a double
755+
private double read(long row) {
756+
return aggIsFloat ? ((FloatArray) aggData).getFloat(row) : ((DoubleArray) aggData).getDouble(row);
757+
}
758+
645759
/// Boxes the fold exactly as the kernel's double lane does — the extremes carry the
646760
/// column's element box ([Float] for an `f32` aggregate).
647761
///

0 commit comments

Comments
 (0)