Skip to content

compute: fused SetDifference operator over co-arranged inputs (CLU-168)#37595

Draft
antiguru wants to merge 23 commits into
MaterializeInc:mainfrom
antiguru:moritz/clu-168-spike-set-difference
Draft

compute: fused SetDifference operator over co-arranged inputs (CLU-168)#37595
antiguru wants to merge 23 commits into
MaterializeInc:mainfrom
antiguru:moritz/clu-168-spike-set-difference

Conversation

@antiguru

@antiguru antiguru commented Jul 12, 2026

Copy link
Copy Markdown
Member

Draft. CLU-168. Stacks on #37592 (those commits drop out once it merges).

A dedicated SetDifference LIR operator for the co-arranged set-difference anti-side that outer-join decorrelation and EXCEPT render, replacing Threshold(ArrangeBy(Union(Negate(A), B))). It reads the two input arrangements directly and produces one output arrangement, eliminating the intermediate ArrangeBy (trace T). Behind enable_fused_set_difference, default off. Design: doc/developer/design/20260713_fused_set_difference.md.

Measured win (maintained EXCEPT, a=1M / b=500K, local mz_arrangement_sizes): the dataflow's arrangement memory drops from 64.2 MB / 6.0M records to 15.0 MB / 3.5M records. Trace T (25.8 MB) is eliminated and the diff step collapses from two arrangements to one; the input DistinctBy arrangements now dominate.

Structure:

  • enable_fused_set_difference feature flag, default off in production.
  • LirRelationNode::SetDifference { base, subtract, ensure_arrangement } with EXPLAIN, abstract-interpreter (monotonicity identical to Threshold), and keys() plumbing.
  • A flag-gated LIR-layer recognizer that rewrites the co-arranged anti-side shape; declines on non-co-arranged arms, non-projection MFPs, reordering projections (key remapped through the projection), non-default temporal bucketing, non-Get/ArrangeBy arms, and inside LetRec.
  • The fused operator (src/compute/src/render/set_difference.rs) reads the two input arrangements directly and produces one output arrangement. Output finalizes at P = meet(upper_base, upper_subtract); each input is read at its own frontier (reads past P are inert); input logical compaction pinned to P, physical to each input's frontier. Per-key two-source net with reduce-style interesting-times and synthetic-time closure.

Correctness: results are byte-identical to the Threshold(Union(..)) path, verified by result-equivalence and multi-round incremental (retraction / reappearance / net-to-zero / multiplicity) sqllogictest against the old path as oracle. EXPLAIN goldens for the recognizer, plus a recursive-CTE golden confirming the operator declines inside LetRec.


Update: extracted into co_reduce2 (later commits on this branch).

The fused operator body described above is extracted into a generic two-input reduce, co_reduce2, in mz-timely-util, and SetDifference now renders through it. co_reduce2 reduces two co-arranged inputs per key via a caller closure and produces one output arrangement. The two inputs may carry distinct trace types, unified only on the key GAT, owned key and value, and diff, so the Local/Trace arrangement flavors are handled by monomorphization rather than a hand-written operator. The threshold becomes the closure net = sum(input0) - sum(input1), removing ~585 lines from set_difference.rs. It is Row-agnostic and value-aware, living beside differential's own reduce. outer_join.slt stays byte-identical.

The operator gains a fuel budget that bounds the keys processed per activation and reschedules, so a large dirty set no longer monopolizes the worker. The output frontier is held until a round's dirty set fully drains, so an incomplete round is never finalized.

The projecting LEFT-JOIN anti-branch passthrough (per-arm base_key/subtract_key) is implemented, so the outer-join case realizes the win, not only EXCEPT.

Design: doc/developer/design/20260714_co_reduce_operator.md. A variadic-N co_reduce over homogeneous inputs remains deferred future work. New co_reduce2 unit tests cover value-aware output ordering, incremental retraction, fuel yielding, and retraction under a fuel-split round.

Remaining follow-up (not a blocker): per-key recomputation is O(n^2) in per-key history; the Feature Benchmark scenario SetDifferenceCoArranged (included) should be run in CI as the A/B.

🤖 Generated with Claude Code

@antiguru antiguru changed the title feature-benchmark: measure co-arranged set-difference anti-side compute: SetDifference operator, phase 1 (recognizer + no-op render) + benchmark Jul 13, 2026
@antiguru antiguru changed the title compute: SetDifference operator, phase 1 (recognizer + no-op render) + benchmark compute: fused SetDifference operator over co-arranged inputs (CLU-168) Jul 13, 2026
antiguru and others added 22 commits July 14, 2026 17:01
Add a Feature Benchmark scenario, SetDifferenceCoArranged, that builds the
set-difference anti-side both outer-join decorrelation and EXCEPT render:
Threshold(Union(A, Negate(B))) over two inputs arranged on the difference
key. When the inputs are co-arranged, rendering still inserts an intermediate
ArrangeBy feeding the Threshold plus a UnionConsolidation over the concatenated
inputs. WALLCLOCK and MEMORY_CLUSTERD then baseline the cost of those two
operators.

This is the measure-first artifact for the CLU-168 spike: a dedicated
set-difference operator reading the two co-arranged traces directly would
elide both, and re-running this scenario against that change measures the win.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Design doc for a dedicated SetDifference LIR operator that reads two
co-arranged inputs directly and produces one output arrangement, eliminating
the intermediate ArrangeBy (trace T, measured ~40% of anti-side arrangement
memory) and UnionConsolidation from the Threshold(Union(Negate(A), B))
anti-side rendering. Phased: recognizer + node + no-op render first, fused
binary operator second.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes from adversarial spec review:
- Operator semantics now group by key and sum diffs over values, making
  heterogeneous input thinning sound (outer_join l0 thinning=(#1) vs l1 ()).
- Recognizer checks the full (key,permutation,thinning) triple, allows only a
  pure projection-to-key MFP, and declines on non-default temporal bucketing,
  non-Get/ArrangeBy arms, and non-projection MFPs.
- Name the Phase 2 central risk: output-trace compaction against two upstream
  frontiers plus reduce-style per-key time-revisit.
- Flag-gated refine pass called out as new plumbing; self-difference and
  heterogeneous-thinning test cases added; 40/51% marked as estimate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce the `SetDifference` LIR node and its `render_plan::Expr`
counterpart. The node holds two heterogeneous children, `base` and
`subtract`, plus an `ensure_arrangement` triple describing the output
arrangement, mirroring `BasicThresholdPlan`.

Wire it through `children`/`children_mut`, `depends_on_into`, the
`render_plan` flattening and `Display`, the EXPLAIN verbose and default
text arms, and the abstract-interpreter framework (`Interpreter` trait,
both fold implementations, and the `SingleTimeMonotonic` analysis).

Render it in phase 1 by reconstructing the dataflow it replaces:
consolidate `Union(base, Negate(subtract))`, arrange by the ensure key
via `ensure_collections`, and threshold through the existing basic path.
Output is byte-identical to the `Threshold` this node stands in for.

The node and render arm land in one commit because the `Expr` variant
makes the `render.rs` match non-exhaustive, so the render arm must exist
for the workspace to compile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a flag-gated LIR-layer refine pass that rewrites the co-arranged anti-side
shape Threshold::Basic(ArrangeBy(Union(base, Negate(subtract)))) into a single
SetDifference node. Declines unless both arms are arranged on the threshold key
via a projection-only read MFP, exactly one Union arm is a Negate, and neither
arm carries a non-Direct temporal bucketing strategy (the render does not
reproduce bucketing). Gated behind enable_fused_set_difference, dormant off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Proves the fused set-difference recognizer (CLU-168) is behavior-preserving:
EXCEPT and LEFT JOIN anti-side queries return identical rows with
enable_fused_set_difference on vs off.
Spike resolution for the binary_frontier operator: output-trace compaction
pinned to P = meet(upper_base, upper_subtract) for both inputs and the output,
reduce-style per-key time-revisit over the union of dirty keys with synthetic
times, forking reduce_trace's output machinery and mz_join_core's two-input
skeleton. Feasible, medium complexity; differential-oracle test mandated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…erence recognizer

`arm_arrangement_matches` stripped a projection-only `Mfp` between a `Union`
arm and its underlying arrangement, then compared the threshold key against the
underlying arrangement key without remapping through the projection. The
threshold key lives in the arm's output (post-projection) column space while the
arrangement key lives in the arm's underlying (pre-projection) space, so a
reordering or non-trivial projection produced a false `#i`-vs-`#i` match on
physically distinct columns.

Map the threshold key columns back through the projection
(`output_col[i] = underlying_col[proj[i]]`) into underlying-column space and
require exact ordered equality with the underlying arrangement key. Identity and
no-projection arms are unchanged; reordering or shifting projections now
decline, since the operator only serves an input keyed exactly as the output
needs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the Phase-1 SetDifference render arm, which reconstructed
Threshold(ArrangeBy(Union(base, Negate(subtract)))), with a fused
binary_frontier operator that reads the two co-arranged inputs directly and
produces one output arrangement, eliminating the intermediate union arrangement
(trace T).

The operator forks the two-input consumption skeleton of mz_join_core and the
output-trace machinery of differential's reduce_trace, and reimplements the
per-key signed-net walk (the ()-valued collapse of HistoryReplayer) with
synthetic interesting times.

Each input is read at its own frontier (cursor_through(upper_i)); the combined
frontier P = meet governs only output sealing and compaction. Reading both
inputs at P (as the spike proposed) straddles batches and panics.

Add a maintained-view incremental correctness section to outer_join.slt
covering retractions, key reappearance, net-zero keys, and net>1 multiplicity;
results are identical Phase 1 vs Phase 2 (oracle), goldens unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ide LetRec

bin/lint rejects std #[test]; switch the remap_*/arm_arrangement unit tests
to #[mz_ore::test], matching the convention used elsewhere in this crate.

Also stop refine_set_difference from descending into LetRec subtrees. The
fused SetDifference operator's partial-order/synthetic-time path is untested
under iterative re-evaluation, so declining to rewrite anywhere inside a
recursive CTE (both its bindings and its body) is the safe default until that
path is verified. Add a golden covering a WITH MUTUALLY RECURSIVE query with
a self-referencing EXCEPT plus a trailing EXCEPT in the body, showing neither
fuses while the existing non-recursive EXCEPT still does.
Extend the fused SetDifference recognizer so it captures the memory win
for the outer-join anti-side, not only EXCEPT.

An outer-join anti-side arm reads its arrangement with a projection
(`Get::Arrangement l0 project=(#0)`), which renders to a raw collection
and forces the fused operator to re-arrange it, reintroducing the very
arrangement the fusion removes. The recognizer now strips that
projection and records the key each arm's underlying arrangement
advertises, in two new `LirRelationNode::SetDifference` fields `base_key`
and `subtract_key`. The stripped arm exposes the existing arrangement via
`Get::PassArrangements`, so the operator reads it directly. Stripping is
sound because the operator sums diffs per key and ignores value columns,
and the underlying arrangement's key datums equal the output key datums.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Look up `base` by `base_key` and `subtract` by `subtract_key` instead of
the shared output key. For a projecting outer-join arm the recognizer now
strips the projection and exposes the underlying arrangement, so the
lookup hits and no per-arm re-arrangement is built. The key datums align
across arms, so per-key net accounting co-iterates unchanged, and the
output stays keyed by the output arrangement key. The `None` fallback
re-arrange remains for arms that genuinely have no arrangement.

Rewrites the outer_join.slt EXPLAIN goldens: the LEFT JOIN anti-side arm
now reads `Get::PassArrangements l0` directly rather than a re-arranged
`Get::Arrangement l0 project=(#0)`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion

An ArrangeBy anti-side arm can advertise its arrangement either through its own
`forms` (keyed in output column space) or through the `input_key` of the input
arrangement it reads (keyed in input column space). The recognizer compared
`input_key` directly against the projection-remapped output key, which only
aligns when `input_mfp` neither reorders nor drops columns.

Reading such an arm directly by that key (now that a projecting wrapper is
stripped instead of forcing a re-arrange) would collate different logical
columns across the two arms under a non-identity `input_mfp`, producing a wrong
difference. Require an identity `input_mfp` projection before matching on
`input_key`. No plan in the corpus exercises this shape, so goldens are
unchanged; the guard is defensive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-key push loop drained each capability's updates into the batch
builder without sorting. emit_deltas produces updates time-major,
value-minor, but OrdValBuilder::push assumes input sorted by
(key, val, time) and only compares each item against the last pushed. A
key emitting multiple distinct values across multiple times therefore
wrote a value-unsorted vals array, violating the batch's sorted-unique
cursor invariant, corrupting value seeks and spine merges.

Stable-sort each key's updates by value before draining, matching
differential's own reduce. Add co_reduce_multi_value_per_key, which reads
the output arrangement's batch stream and asserts stored values are
ascending (the invariant the bug breaks; a summed readback cannot observe
it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the variadic N-input co_reduce with a 2-input co_reduce2 built on
a binary_frontier shell, matching set_difference_core. The two inputs may
carry distinct trace types (key-equated, diff-pinned), which handles the
Local-vs-Trace arrangement-flavor demux by monomorphization and dissolves
the homogeneous Vec<Arranged<Tr>> type-unification blocker.

Generalize Bu::Input to Default + PushInto<((K,V2),T,R2)> so RowRow output
builders (whose input is a columnar stack, not Vec) are admissible. The
per-key value-aware core is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the private set_difference_core with a call to the generic
co_reduce2 over two co-arranged inputs. The per-key threshold arithmetic
moves into a free set_difference_threshold closure, and the four
Local/Trace flavor combinations demultiplex onto co_reduce2 by
monomorphization. The trace-walk helpers now live in
mz_timely_util::co_reduce.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bound the number of dirty keys co_reduce2 processes per activation so it
yields the timely worker instead of draining an unbounded dirty set in one
shot. A round in progress is finalized atomically: the operator accumulates
processed keys' updates into per-capability builders and only builds, ships,
and seals once the round's dirty set fully drains. Until then it holds the
round's output capabilities, leaves processed unadvanced, defers compaction,
and re-activates itself. This preserves the invariant that a round is never
sealed while a dirty key still owes output below upper_limit.

The four set_difference call sites pass a fixed fuel of 1_000_000, with a
TODO to track the differential/default_exert_logic config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Existing tests exercise retraction only with unbounded fuel
(co_reduce_incremental) and small fuel only with insertion
(co_reduce_fuels). Add co_reduce_fuels_retraction to close that gap:
a round that retracts many keys back to net-zero must fully drain
and retract them even when the dirty set is fuel-split across many
activations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ing)

Update the design doc to describe the shipped 2-input co_reduce2
(distinct trace types, fuel parameter, accumulate-then-ship-on-drain
fueling) instead of the originally-scoped variadic homogeneous
co_reduce, keeping the N-input generalization as noted future work.
Drop the dangling set_difference_core reference and the cross-crate
mz_join_core mention from co_reduce.rs's module doc. Rename co_reduce2's
base/subtract parameters to input0/input1 so the generic operator
doesn't carry set-difference vocabulary, note the O(N^2/fuel) cost of
re-acquiring cursors per activation, and clarify that the set-difference
fuel budget is a distinct knob from the exert/compaction-effort config.

Co-Authored-By: Claude Sonnet 5 (1M context) <noreply@anthropic.com>
@antiguru antiguru force-pushed the moritz/clu-168-spike-set-difference branch from b4ab4c6 to 4055393 Compare July 14, 2026 15:08
Turn the flag on in the slt runner's fixed-feature setup, mirroring the
enable_reduce_mfp_fusion coverage pattern, so CI exercises the fused
SetDifference operator wherever the co-arranged anti-side renders. Regenerate
the three EXPLAIN goldens that surface it (catalog_server_explain, explain/default,
outer_join); no query-result golden changes, the rewrite is behavior-preserving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant