Skip to content

Commit c5914d7

Browse files
committed
lgj-abi minor 12: the grouped sum that never builds a selection
`lgj_plan_group_sum_i32` lowers the fused plan and `GROUP BY key SUM(val)` as ONE mask-risc program (`plan_lower::lower_group_sum`, the same prefix rewrite and survivor skip as `plan_eval`, ending in `Terminal::GroupSumI32` over the accumulator instead of `Keep`). The executor runs it tile by tile; the only state sized by the answer is the caller's `i64` buffer, one per group, and that is all that crosses back. Java's `View.sumByGroup` is therefore one crossing for 16 groups at any row count, where the path it replaces (plan_eval into a mask, then reduce per group) measured 32. `via_res`/`via_lane` give the fk-keyed form: the group of a row is the second table's `u32` lane read through this table's key lane, fused inside `Terminal::GroupSumViaI32` — no partner-side mask, no remapped key lane. `View.sumByGroupVia` is the Java spelling, still one crossing. An empty plan is legal here (there is no destination mask a caller could fill itself) and lowers to the whole-lane `Pred::Range { 0, n_rows }` — the one Range this crate emits, pinned to be that range and nothing else so `exec_error_to_status`'s RangeOutOfBounds arm stays an internal-bug mapping. Native: 191 tests (8 new: two-crossing-path parity over the combine sweep on single- and multi-tile populations, empty/all-OR plans against a hand walk, n_groups as a sink bound, tiled executor vs scalar reference on both key shapes, the fk-keyed form against a hand-walked join with zero-fallback drops, every refusal leaving out_sums untouched, and a quack-lowering convergence arm with Agg::GroupSumI32). Clippy -D warnings and fmt clean. Java (JDK 28, --enable-preview): 612 checks (409 + GroupSumTest 203): parity against per-group sumOf, one-crossing cost measured beside the 32-crossing path, the keyed form against an oracle that reads the second resource's keys through the one named materialiser, argument checks before any crossing. Minor12 lazy holder, requireMinor(12), an OldAbiCompatTest gate, and the DoctrineFenceTest pin for the eighth named materialisation site (Engine.groupSumI32's toArray, sized by groups, never rows). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXUahz73MZxtxWcfpHp9dG
1 parent 07e044f commit c5914d7

14 files changed

Lines changed: 1494 additions & 8 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.adaworldapi.lancegraph;
2+
3+
/**
4+
* The answer to a grouped sum: one widened total per group, addressed by the group's key value.
5+
*
6+
* <p>This is what {@code SELECT key, SUM(value) … GROUP BY key} hands back — the same thing a
7+
* {@code ResultSet} would, without the cursor. {@link #total(int)} is the row for key {@code g};
8+
* {@link #groups()} is how many keys were asked for. A key no selected row carries has a total of
9+
* {@code 0}, exactly as SQL would report it with an outer join onto the key domain.
10+
*
11+
* <p>Sized by the question, never by the data: a {@code GroupTotals} over 16 keys is 16 numbers
12+
* whether the view spans a thousand rows or a billion. No row, no selection and no index list is
13+
* behind it — the totals were folded natively in one pass and only the totals crossed.
14+
*
15+
* <p>Immutable. There is no accessor for the underlying storage; the totals are read one at a
16+
* time by key, which is also the only way a caller ever needs them.
17+
*/
18+
public final class GroupTotals {
19+
20+
private final long[] totals;
21+
22+
GroupTotals(long[] totals) {
23+
this.totals = totals;
24+
}
25+
26+
/** How many groups this answer covers — the {@code groups} the caller asked for. */
27+
public int groups() {
28+
return totals.length;
29+
}
30+
31+
/**
32+
* The sum for key {@code group}, widened to 64 bits; {@code 0} when no selected row carried
33+
* that key.
34+
*
35+
* @throws IndexOutOfBoundsException if {@code group} is not in {@code [0, groups())}
36+
*/
37+
public long total(int group) {
38+
java.util.Objects.checkIndex(group, totals.length);
39+
return totals[group];
40+
}
41+
42+
@Override
43+
public String toString() {
44+
StringBuilder sb = new StringBuilder("GroupTotals[");
45+
for (int g = 0; g < totals.length; g++) {
46+
if (g > 0) {
47+
sb.append(", ");
48+
}
49+
sb.append(g).append('=').append(totals[g]);
50+
}
51+
return sb.append(']').toString();
52+
}
53+
}

‎java/src/main/java/com/adaworldapi/lancegraph/NativePattern.java‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,41 @@ OptionalLong maxOf(List<Predicate> predicates, I32Field field) {
226226
return reduceI32(predicates, field, Layouts.REDUCE_OP_MAX);
227227
}
228228

229+
/**
230+
* {@code GROUP BY key SUM(value)} over the rows the plan selects, as ONE native program
231+
* (docs/abi.md §20). No selection is built: the plan is not evaluated into a mask and then
232+
* reduced per group, it is folded straight into the group totals tile by tile. An empty
233+
* plan crosses as such — the ABI treats it as every row — rather than being routed through
234+
* the cached all-rows selection {@link #sumOf} uses, because here there is nothing for a
235+
* selection to be consumed by.
236+
*/
237+
GroupTotals sumByGroup(List<Predicate> predicates, U32Field key, I32Field value, int groups) {
238+
requireOpen("sumByGroup()");
239+
synchronized (lock) {
240+
requireOpen("sumByGroup()");
241+
return new GroupTotals(Engine.groupSumI32(handle, plan(predicates),
242+
key.lane().index(), value.lane().index(), 0L, 0, groups));
243+
}
244+
}
245+
246+
/**
247+
* The fk-keyed form of {@link #sumByGroup}: the group of a row is {@code viaKey} read on
248+
* {@code via} at the row index this resource's {@code key} names — {@code SUM(line.amount)
249+
* GROUP BY partner.country}, still one program and one crossing, with no selection on either
250+
* resource. {@code via} may be this resource.
251+
*/
252+
GroupTotals sumByGroupVia(List<Predicate> predicates, U32Field key, NativePattern via,
253+
U32Field viaKey, I32Field value, int groups) {
254+
requireOpen("sumByGroupVia()");
255+
via.requireOpen("sumByGroupVia() via");
256+
synchronized (lock) {
257+
requireOpen("sumByGroupVia()");
258+
return new GroupTotals(Engine.groupSumI32(handle, plan(predicates),
259+
key.lane().index(), value.lane().index(), via.handle(),
260+
viaKey.lane().index(), groups));
261+
}
262+
}
263+
229264
/** Materialise a selection the caller owns and closes. */
230265
Mask selectInto(List<Predicate> predicates) {
231266
requireOpen("select()");

‎java/src/main/java/com/adaworldapi/lancegraph/View.java‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,64 @@ public OptionalLong maxOf(I32Field field) {
114114
return owner.maxOf(predicates, field);
115115
}
116116

117+
/**
118+
* {@code SELECT key, SUM(value) FROM … WHERE <this view> GROUP BY key}, for keys
119+
* {@code 0..groups}.
120+
*
121+
* <p><strong>One crossing</strong>, whatever the number of conditions, groups or rows. The
122+
* chain and the grouped sum are one fused native program; nothing is selected first and
123+
* nothing per group is asked separately. Before this existed the same question cost two
124+
* crossings per group — a {@code sumOf} over {@code where(key.eq(g))} for each {@code g} —
125+
* which the {@code bricks} consumer measured at 32 for 16 groups; it is now 1 for any number.
126+
*
127+
* <p>A key no selected row carries totals {@code 0}. A selected row whose key is
128+
* {@code >= groups} belongs to no requested group and is dropped, as a {@code WHERE key < n}
129+
* would drop it; ask for more groups to see it.
130+
*
131+
* @param key an unsigned 32-bit column whose values are the group keys
132+
* @param value the signed 32-bit column to sum, widened to 64 bits
133+
* @param groups how many keys to answer for, {@code > 0}
134+
* @throws AbiMismatchException if the loaded library reports ABI minor &lt; 12
135+
*/
136+
public GroupTotals sumByGroup(U32Field key, I32Field value, int groups) {
137+
java.util.Objects.requireNonNull(key, "key");
138+
java.util.Objects.requireNonNull(value, "value");
139+
requirePositiveGroups(groups);
140+
return owner.sumByGroup(predicates, key, value, groups);
141+
}
142+
143+
/**
144+
* {@code SELECT via.viaKey, SUM(value) FROM this JOIN via ON via.row = this.key … GROUP BY
145+
* via.viaKey} — the grouped sum keyed through a second resource, still <strong>one
146+
* crossing</strong>.
147+
*
148+
* <p>{@code key} holds, for each row here, the row index in {@code via} it refers to; the
149+
* group of that row is the {@code viaKey} value found there. The lookup is fused into the
150+
* native fold — no selection on either resource, no remapped key column. A {@code key} that
151+
* names no row of {@code via} drops the row, as an inner join would.
152+
*
153+
* @param via the resource whose {@code viaKey} column supplies the group; may be this view's
154+
* own resource
155+
* @throws AbiMismatchException if the loaded library reports ABI minor &lt; 12
156+
*/
157+
public GroupTotals sumByGroupVia(U32Field key, NativePattern via, U32Field viaKey,
158+
I32Field value, int groups) {
159+
java.util.Objects.requireNonNull(key, "key");
160+
java.util.Objects.requireNonNull(via, "via");
161+
java.util.Objects.requireNonNull(viaKey, "viaKey");
162+
java.util.Objects.requireNonNull(value, "value");
163+
requirePositiveGroups(groups);
164+
return owner.sumByGroupVia(predicates, key, via, viaKey, value, groups);
165+
}
166+
167+
private static void requirePositiveGroups(int groups) {
168+
if (groups <= 0) {
169+
throw new IllegalArgumentException(
170+
"groups must be positive (the number of key values to answer for), was "
171+
+ groups);
172+
}
173+
}
174+
117175
/**
118176
* A projection of one column through this view.
119177
*

‎java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Downcalls.java‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,21 @@ private static final class Minor11 {
491491
private Minor11() {}
492492
}
493493

494+
/**
495+
* ABI minor 12 symbol (docs/abi.md §20): the fused plan run straight into a grouped-sum
496+
* terminal — one crossing returns one {@code i64} per group and no selection ever exists.
497+
* Lazy per the minor-2..11 rule.
498+
*/
499+
private static final class Minor12 {
500+
static final MethodHandle PLAN_GROUP_SUM_I32 = mh("lgj_plan_group_sum_i32",
501+
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG,
502+
ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT,
503+
ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT,
504+
ValueLayout.ADDRESS, ValueLayout.JAVA_LONG));
505+
506+
private Minor12() {}
507+
}
508+
494509
/**
495510
* Sum one facet's 12-byte register, under {@code carving}, over the rows a mask selects.
496511
*
@@ -639,6 +654,29 @@ public static long reduceI32(long res, int laneId, int reduceOp, long mask,
639654
return outValue.get(ValueLayout.JAVA_LONG, 0);
640655
}
641656

657+
/**
658+
* {@code GROUP BY groupLane SUM(valLane)} over the rows the plan selects, in ONE crossing
659+
* (docs/abi.md §20, minor 12). {@code outSums} receives {@code nGroups} widened totals; a
660+
* selected row whose key is past {@code nGroups} is dropped, never an error. With
661+
* {@code viaRes != 0} the key is read THROUGH {@code groupLane} into {@code viaRes}'s
662+
* {@code viaLane} (the fk-keyed form). {@code nOps == 0} is legal and means every row.
663+
*
664+
* <p>Bulk in the §6 sense: one pass over the predicate lanes, one read of the key and value
665+
* lanes for the selected rows, no per-row and no per-group crossing.
666+
*/
667+
public static void planGroupSumI32(long res, MemorySegment ops, int nOps, int groupLane,
668+
int valLane, long viaRes, int viaLane, MemorySegment outSums, long nGroups) {
669+
crossed();
670+
int st;
671+
try {
672+
st = (int) Minor12.PLAN_GROUP_SUM_I32.invokeExact(res, ops, nOps, groupLane, valLane,
673+
viaRes, viaLane, outSums, nGroups);
674+
} catch (Throwable t) {
675+
throw wrap("lgj_plan_group_sum_i32", t);
676+
}
677+
Status.check("lgj_plan_group_sum_i32", st);
678+
}
679+
642680
// ── row store (docs/abi.md §11, ABI minor 2) ─────────────────────────────────────────────
643681
//
644682
// Callers above this class are expected to have already checked Abi.requireMinor(2) — these

‎java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Engine.java‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,37 @@ public static ReduceOutcome reduceI32(long resource, int laneId, int reduceOp, l
379379
return new ReduceOutcome(value, present);
380380
}
381381

382+
/**
383+
* The grouped sum that never builds a selection (docs/abi.md §20, ABI minor 12):
384+
* {@code GROUP BY groupLane SUM(valLane)} over the rows {@code plan} selects, evaluated as
385+
* ONE native program and returned as one widened total per group. Unlike every reduce
386+
* above, no mask handle is involved at any point — the plan is not evaluated INTO anything;
387+
* the fold reads it tile by tile and lands only the group totals.
388+
*
389+
* <p>{@code viaResource != 0} selects the fk-keyed form: the group of a row is
390+
* {@code viaLane[groupLane[row]]}, read on {@code viaResource}, with no partner-side
391+
* selection and no remapped key lane (the indirection is fused inside the native terminal).
392+
*
393+
* <p>An empty {@code plan} is legal here and means every row — the ABI has no destination
394+
* mask for Java to fill on its own, so the whole-lane case crosses as a plan of zero ops
395+
* rather than being answered locally. Requires ABI minor &gt;= 12.
396+
*
397+
* <p>The returned array is sized by {@code groups} — the shape of the QUESTION the caller
398+
* typed, never by the row count — which is what keeps it on the right side of the
399+
* materialization rule (the eighth named site; see the repo's zero-copy section).
400+
*/
401+
public static long[] groupSumI32(long resource, List<PlanOp> plan, int groupLane, int valLane,
402+
long viaResource, int viaLane, int groups) {
403+
Abi.requireMinor(12);
404+
MemorySegment ops = plan.isEmpty() ? MemorySegment.NULL : marshal(plan);
405+
try (Arena a = Arena.ofConfined()) {
406+
MemorySegment out = a.allocate(ValueLayout.JAVA_LONG, groups);
407+
Downcalls.planGroupSumI32(resource, ops, plan.size(), groupLane, valLane, viaResource,
408+
viaLane, out, groups);
409+
return out.toArray(ValueLayout.JAVA_LONG);
410+
}
411+
}
412+
382413
// ── mask complement + hop (docs/abi.md §13, ABI minor ≥ 4) ─────────────────────────────
383414
//
384415
// Same requireMinor-before-any-downcall discipline as the row store section above: a Java

‎java/src/test/java/com/adaworldapi/lancegraph/AllTests.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public static void main(String[] args) {
3434
suites.put("CarvingTableTest", CarvingTableTest::run);
3535
suites.put("ColumnarStoreTest", ColumnarStoreTest::run);
3636
suites.put("MaskingOpCompletionTest", MaskingOpCompletionTest::run);
37+
suites.put("GroupSumTest", GroupSumTest::run);
3738

3839
if (!NativeRuntime.isAvailable()) {
3940
// ApiSurfaceTest and DoctrineFenceTest need no native library — the API's shape is a

‎java/src/test/java/com/adaworldapi/lancegraph/DoctrineFenceTest.java‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ private DoctrineFenceTest() {}
100100
// five named sites, mechanically:
101101
// Mask.materializeRows() → Arrays.copyOf ×2 + new long[ ×1 (the named terminal)
102102
// Engine.rowLayoutProbe → .toArray( ×1 (≤32 B diagnostic)
103+
// Engine.groupSumI32 → .toArray( ×1 (one i64 per GROUP —
104+
// sized by the question the caller typed, never by rows;
105+
// the eighth named site, minor 12)
103106
// Engine.facetSumResolved → new long[ ×1 (fixed [2] pair)
104107
// Abi manifest-name read (cString) → new byte[ ×1 + MemorySegment.copy ×1
105108
// Abi.readCarvings → new int[ ×2 (incl. empty arm)
@@ -108,7 +111,7 @@ private DoctrineFenceTest() {}
108111
static {
109112
MATERIALIZATION_PINS.put("Mask.java|Arrays.copyOf", 2);
110113
MATERIALIZATION_PINS.put("Mask.java|new long[", 1);
111-
MATERIALIZATION_PINS.put("Engine.java|.toArray(", 1);
114+
MATERIALIZATION_PINS.put("Engine.java|.toArray(", 2);
112115
MATERIALIZATION_PINS.put("Engine.java|new long[", 1);
113116
MATERIALIZATION_PINS.put("Abi.java|new int[", 2);
114117
MATERIALIZATION_PINS.put("Abi.java|new byte[", 1);

0 commit comments

Comments
 (0)