Skip to content

timely-util: add co_reduce2 and render SetDifference through it#37637

Closed
antiguru wants to merge 30 commits into
MaterializeInc:mainfrom
antiguru:moritz/clu-168-co-reduce
Closed

timely-util: add co_reduce2 and render SetDifference through it#37637
antiguru wants to merge 30 commits into
MaterializeInc:mainfrom
antiguru:moritz/clu-168-co-reduce

Conversation

@antiguru

@antiguru antiguru commented Jul 14, 2026

Copy link
Copy Markdown
Member

Created in error. This work is included in #37595. Closing.

antiguru and others added 30 commits July 12, 2026 15:59
Single-time (one-shot SELECT) dataflows upgrade hierarchical Reduce and
TopK plans to their monotonic variants. This used to run as a
post-lowering pass, `refine_single_time_operator_selection`, that mutated
the LIR node's plan enum in place after lowering had already computed the
node's `AvailableCollections`. The in-place structural rewrite left the
advertised collections describing the pre-upgrade variant, which blocks
any later change that wants to re-advertise the node.

Absorb the selection into lowering. `lower` reads `is_single_time()` from
the dataflow and records it on the lowering `Context`, and the `TopK` and
`Reduce` arms pick the monotonic variant at construction time via
`as_monotonic(true)`. Lowering then computes `AvailableCollections` for
the final variant. The flag is forced off while lowering the recursive
bindings of a `LetRec`, whose values are not restricted to a single time,
matching the old pass, which descended only into the `LetRec` body.

Behavior is unchanged: `ReducePlan::keys()` and `extract_mfp_after` do not
depend on the hierarchical sub-variant, and `TopK` advertises no
arrangement, so the advertised collections are identical.
`refine_single_time_consolidation` still relaxes `must_consolidate` after
lowering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the arity field already present on MonotonicTopKPlan and
BasicTopKPlan, so that render/lowering can later derive the plan's
output arrangement layout from (group_key, arity). Pure plumbing: no
behavior change.
Make `render_top1_monotonic` build its output as a group-key-keyed
arrangement in the layout `permutation_for_arrangement` dictates, and have
`render_topk` return that arrangement in the bundle for the MonotonicTop1
arm, mirroring `Reduce`. The winning row is thinned to the value columns; the
group-key columns live in the key row.

Because lowering does not yet advertise the arrangement, the bundle also
carries a raw collection reconstructed from the arrangement via the
permutation, so consumers planned with `input_key = None` (for example a
Reduce over a DISTINCT ON) still find a collection. A later change advertises
the arrangement and can drop the reconstruction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lowering now advertises the group-key arrangement that
`render_top1_monotonic` builds, so a downstream consumer keyed on the
same group key reuses it instead of forcing a redundant `ArrangeBy`.
`MonotonicTopK`/`Basic` are unaffected: they key their arrangements by
(hash, group_key), which a group-key-only consumer can't reuse.

The render side keeps building both the arrangement and a reconstructed
raw collection. Dropping the reconstruction (as originally intended) is
unsound: `refine_single_time_operator_selection` can upgrade a `Basic`
plan into `MonotonicTop1` after lowering has already computed
`AvailableCollections` for the old (raw) variant, so a downstream
consumer can still expect a raw collection directly. Unlike
`Reduce::keys()`, which advertises the same arrangement for every
`Hierarchical` sub-variant, `TopK`'s variants differ in what they
arrange, so render can't tell which case it's in. Confirmed by a
reproduced crash (`keys.raw <= collection.collection.is_some()`
assertion) before adding the defensive raw reconstruction back.

Adds a regression test proving the `ArrangeBy` is elided when two
`DISTINCT ON` queries over the same monotonic source are joined on
their group key.

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

Task 3 defensively delivered a dual bundle (arrangement + reconstructed raw
collection) because render couldn't tell whether a MonotonicTop1 plan was
advertised as arranged by lowering, or was a Basic plan upgraded to
MonotonicTop1 after lowering by TopKPlan::as_monotonic (whose advertisement
stays raw). Add an explicit `arranged` flag to MonotonicTop1Plan, set true in
the lowering-time construction and false in the post-lowering upgrade, and
branch render on it to deliver exactly one shape.
`render_topk`'s `MonotonicTop1` arm computed `key` and `permutation` (via
`permutation_for_arrangement`) unconditionally, but they are only used in the
`!arranged` branch to reconstruct the raw collection from the arrangement.
Move the computation into that branch; the `arranged` branch never used them
and `key` duplicated work already done inside `render_top1_monotonic`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The arranged=true correctness test spun up a bounded LOAD GENERATOR
COUNTER source and waited with mz_sleep, which flakes. The arbitrary
(non-prefix) group-key permutation reconstruction it exercised is
already covered deterministically by the one-shot DISTINCT ON over a
plain table (the arranged=false path, same permutation_for_arrangement
output), and the arranged=true plan is covered by the join-elision
EXPLAIN test.

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

Rebasing on MaterializeInc#37593 (which inlines single-time monotonic operator selection
into lowering, running as_monotonic before the arrangement advertisement)
means every MonotonicTop1 plan, including a one-shot SELECT DISTINCT ON
upgraded during lowering, now advertises its group-key arrangement. That
makes the `arranged` flag on MonotonicTop1Plan always true, so drop the
field from the struct, create_from, as_monotonic, and the limit()
destructure, and make render_topk's MonotonicTop1 arm unconditionally
arrangement-only instead of branching on it. A consumer that needs the raw
collection reconstructs it from the advertised arrangement via its
permutation, same as for an index arrangement.

Regenerated sqllogictest goldens affected by the now-always-arranged
output: an explicit ArrangeBy on the group key is elided where the Top1
render used to require one, and Get/Reduce plans downstream of a
MonotonicTop1 now see an arrangement instead of a raw collection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 closed this Jul 14, 2026
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