Skip to content

Commit ce74b8e

Browse files
dfa1claude
andcommitted
docs(adr-0013): record fused-kernel baseline + encoded-domain negative result
Baseline (100M rows, memory-bound): the fused Compute.filteredSum carries ~5% overhead over a hand-fused accessor loop (443.9 vs 421.4 ms/op) since it still decodes via the accessor — no per-row win yet, by design. Fail-fast probe on encoded-domain value comparison, per encoding: - ALP / FoR are memory-bound (46.5 ms/op, identical to a plain i64 scan): the decode arithmetic is free under bandwidth, nothing to save. - Dict is the only decode with real overhead (~11x a plain scan), but comparing codes instead of gathering values does NOT help (forLoopDictEncoded ties forLoopDict ~520 ms/op) — the gather is not the bottleneck, per-element accessor dispatch is. Adds forLoopDictEncoded as a negative control and documents the finding in the ADR: encoded-domain value specialization stays deferred because the value path is not compute-bound at these column widths; the real lever is a monomorphic/vectorized code scan, a separate effort. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6485db8 commit ce74b8e

2 files changed

Lines changed: 86 additions & 0 deletions

File tree

docs/adr/0013-compute-primitives.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,40 @@ consumer). The `Predicate` (§4) and `RowFilter` unification (§5) vocabulary is
4141
these kernels consume. The sections below are preserved as the original proposal; read §1–§3 as
4242
history, not as the built design.
4343

44+
### Baseline for the encoded-domain specialization (2026-07-01)
45+
46+
`ComputeKernelBenchmark` (100M rows, ≈ 800 MB/column, memory-bound, Apple silicon, JMH avg time):
47+
48+
| Benchmark | Score |
49+
| --- | --- |
50+
| `forLoopFilteredSum` — hand-fused accessor loop (`price > 500` ALP filter, FoR `measure` sum) | 421.4 ms/op |
51+
| `fusedFilteredSumAlp` — today's `Compute.filteredSum`, same predicate | 443.9 ms/op |
52+
53+
The fused kernel carries a ≈ 5 % overhead over the naive loop because it still decodes every element
54+
through the typed accessor before comparing — there is no per-row win yet, by design. This is the
55+
control the encoded-domain specialization must beat: compare and reduce in the ALP/FoR/Dict integer
56+
domain, below the naive-loop number. Note the fused kernel is still ≈ 1.5× faster than the *removed*
57+
`Mask` two-pass path it replaced — the overhead here is only against a hand-fused single loop.
58+
59+
A fail-fast probe measured whether encoded-domain value comparison actually wins, per encoding:
60+
61+
| Per-encoding count baseline | Score | Data read |
62+
| --- | --- | --- |
63+
| `forLoopPlainControl` (i64, no decode) | 46.4 ms/op | 800 MB |
64+
| `forLoopForLong` (FoR `+ ref`) | 46.7 ms/op | 800 MB |
65+
| `forLoopAlpDouble` (ALP `× f × e`) | 46.5 ms/op | 800 MB |
66+
| `forLoopDict` (gather `values[code]`) | ≈ 420–530 ms/op | 100 MB codes |
67+
| `forLoopDictEncoded` (compare codes, no gather) | ≈ 520 ms/op | 100 MB codes |
68+
69+
The finding is negative and it is why encoded-domain specialization stays deferred: ALP and FoR are
70+
**memory-bound** — their decode arithmetic is free under bandwidth, identical to a plain i64 scan, so
71+
there is nothing for an integer-domain compare to save. Dict is the only decode with real overhead
72+
(≈ 11× a plain scan while reading 8× less data), but comparing codes instead of gathering values
73+
does **not** help (`forLoopDictEncoded` ties `forLoopDict`): the gather is not the bottleneck — the
74+
per-element accessor dispatch is, and both paths pay it. So the lever for dict is a monomorphic /
75+
vectorized segment scan of the codes, not the encoded-domain compare this ADR envisioned. Encoded
76+
values buy a win only once the value path is compute-bound, which at these column widths it is not.
77+
4478
## Context
4579

4680
ADR 0010 establishes that the reader defers transform work until access

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import io.github.dfa1.vortex.reader.ReadRegistry;
66
import io.github.dfa1.vortex.reader.VortexReader;
77
import io.github.dfa1.vortex.reader.array.Array;
8+
import io.github.dfa1.vortex.reader.array.ByteArray;
89
import io.github.dfa1.vortex.reader.array.DictLongArray;
910
import io.github.dfa1.vortex.reader.array.DoubleArray;
1011
import io.github.dfa1.vortex.reader.array.LazyAlpDoubleArray;
@@ -296,6 +297,57 @@ public long forLoopDict() {
296297
return count;
297298
}
298299

300+
/// Encoded-domain `category == 7` count: the ADR-0013 encoded-domain control for a dictionary
301+
/// equality filter. Resolves the constant to its dictionary code **once per chunk** (a scan of
302+
/// the tiny value pool), then compares the raw `u8` codes directly — skipping the per-element
303+
/// `values.getLong(readCode(i))` gather that [#forLoopDict()] pays.
304+
///
305+
/// Result (2026-07-01, 100M rows): this is within noise of [#forLoopDict()] (≈ 520 vs ≈ 530
306+
/// ms/op), so the value gather is NOT the dict path's bottleneck — both pay the same per-element
307+
/// accessor cost (≈ 500 ms for a 100 MB code scan vs ≈ 46 ms for a plain 800 MB scan). Kept as a
308+
/// negative control: encoded-domain value specialization alone does not speed up dict equality;
309+
/// the lever is a monomorphic / vectorized segment scan of the codes, a separate optimization.
310+
///
311+
/// @return the number of rows with `category == 7` over the whole dataset
312+
@Benchmark
313+
public long forLoopDictEncoded() {
314+
long count = 0;
315+
for (LongArray array : categoryChunks) {
316+
// Chunked decode wraps some chunks in an OffsetLongArray slice view; unwrap it to the
317+
// DictLongArray and carry its offset into the code index (codes[i + offset]).
318+
LongArray base = array;
319+
long codeOffset = 0;
320+
if (base instanceof OffsetLongArray off) {
321+
codeOffset = off.offset();
322+
base = off.inner();
323+
}
324+
DictLongArray dict = (DictLongArray) base;
325+
LongArray values = dict.values();
326+
ByteArray codes = (ByteArray) dict.codes();
327+
// Resolve category==7 to its code once. The pool holds distinct values, so at most one
328+
// code matches; an absent value means no row in this chunk matches.
329+
long match = -1;
330+
long poolSize = values.length();
331+
for (long c = 0; c < poolSize; c++) {
332+
if (values.getLong(c) == CATEGORY_VALUE) {
333+
match = c;
334+
break;
335+
}
336+
}
337+
long n = array.length();
338+
if (match < 0) {
339+
continue;
340+
}
341+
byte matchByte = (byte) match;
342+
for (long i = 0; i < n; i++) {
343+
if (codes.getByte(i + codeOffset) == matchByte) {
344+
count++;
345+
}
346+
}
347+
}
348+
return count;
349+
}
350+
299351
/// Naive `plain > 0` count baseline: the hand-written count loop over the materialized accessor
300352
/// across every chunk, reading each long straight from the segment per element.
301353
///

0 commit comments

Comments
 (0)