Skip to content

compute: Re-encode LetRec read-edges to the columnar edge - #37794

Open
antiguru wants to merge 5 commits into
columnar-tc-retire-fueled-flagfrom
columnar-tp-letrec-reencode
Open

compute: Re-encode LetRec read-edges to the columnar edge#37794
antiguru wants to merge 5 commits into
columnar-tc-retire-fueled-flagfrom
columnar-tp-letrec-reencode

Conversation

@antiguru

@antiguru antiguru commented Jul 21, 2026

Copy link
Copy Markdown
Member

Re-encode LetRec recursive-binding read-edges to the columnar edge; the feedback Variable stays Vec. Removes the second mixed-variant edge source feeding concat_many.

Columnar dataflow-edge migration. Design doc: doc/developer/design/20260720_columnar_dataflow_edges.md (#37744).

Part of CPU-51.

@antiguru
antiguru force-pushed the columnar-tp-letrec-reencode branch from b045d8b to 53a7cb3 Compare July 22, 2026 08:41
@linear-code

linear-code Bot commented Jul 22, 2026

Copy link
Copy Markdown

CPU-51

@antiguru
antiguru force-pushed the columnar-tp-letrec-reencode branch from 53a7cb3 to 46a3139 Compare July 22, 2026 16:25
@antiguru
antiguru force-pushed the columnar-tp-letrec-reencode branch from 46a3139 to e507d11 Compare July 22, 2026 17:50
@antiguru
antiguru force-pushed the columnar-tp-letrec-reencode branch from e507d11 to fb1e5f7 Compare August 19, 2026 12:01
@antiguru
antiguru force-pushed the columnar-tp-letrec-reencode branch 2 times, most recently from b904adb to fb2a7a1 Compare August 20, 2026 08:49
@antiguru
antiguru force-pushed the columnar-tp-letrec-reencode branch from fb2a7a1 to dd93b9e Compare August 20, 2026 09:13
@antiguru
antiguru force-pushed the columnar-tp-letrec-reencode branch from dd93b9e to 520c038 Compare September 6, 2026 18:23
@antiguru
antiguru force-pushed the columnar-tp-letrec-reencode branch from 520c038 to 0faa280 Compare September 10, 2026 11:33
@antiguru
antiguru marked this pull request as ready for review September 10, 2026 11:34
@antiguru
antiguru requested a review from a team as a code owner September 10, 2026 11:34
@antiguru
antiguru force-pushed the columnar-tp-letrec-reencode branch 2 times, most recently from 0fb4ae1 to 5db06fc Compare September 10, 2026 12:09
@def-

def- commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- Rec-binding Variable re-encode is dead work for bindings nothing reads before they are bound

src/compute/src/render.rs:964

The columnar re-encode of a rec binding's feedback Variable is built unconditionally, but a Variable only has readers while it is unbound: once the rec loop calls insert_id with the rendered value, later bindings and the body resolve Gets to that bundle instead. For any rec binding that no earlier-or-same binding reads, the VecToColumnar operator this creates ends up with no consumers on its output, yet still repacks that binding's entire collection into columnar buffers on every iteration and drops it.

Details

Concretely, for the first query in test/sqllogictest/with_mutually_recursive.slt (foo AS (... FROM bar), bar AS (... FROM foo)): foo's value reads v_bar, bar's value reads the already-bound foo bundle, so vec_to_columnar(v_foo) has zero consumers. Before this change v_foo's stream had no consumers at all and the feedback operator's Tee dropped each batch for free. In general the first-rendered binding of every non-self-referential recursive SCC pays a full copy of its output per iteration for nothing. Plain self-recursive bindings (t AS (... t ...)) are unaffected, since the self-reference does read the Variable.

This is a performance regression, not a correctness one: timely handles an output port with no targets, and the extra operator holds no capabilities, so frontiers and the fixpoint are unchanged. But it lands in the inner loop of exactly the dataflows this PR touches.

The readers of v_i are exactly the values of recs[0..=i], which the comment above the loop already states, so the condition is a cheap static check before the Variable loop:

// `Get`s resolve to the `Variable` only until the binding is bound, so the
// re-encode has readers exactly when some binding up to and including this
// one depends on it.
let mut variable_read = BTreeSet::new();
let mut seen = BTreeSet::new();
for rec in recs.iter() {
    seen.extend(rec.value.depends());
    if seen.contains(&Id::Local(rec.id)) {
        variable_read.insert(rec.id);
    }
}

Then build vec_to_columnar(oks_collection) only for ids in variable_read. For the rest there is no Get to serve, so the binding can be left out of self.bindings until the rec loop inserts the real bundle (variables is a separate map, and the extraction loop runs after that insert, so neither is affected).

Copy link
Copy Markdown
Member Author

Confirmed and fixed in compute: skip the LetRec re-encode for unread variables.

The reasoning holds as written. Variables are installed before the rec loop and each binding's real bundle replaces its variable bundle at the end of its own iteration, so while recs[j] renders, ids < j already resolve to the rendered value and only ids >= j still resolve to a Variable. Readers of v_i are therefore exactly the values of recs[0..=i]. insert_id is a plain map insert rather than an assert-absent, so dropping the pre-insert costs nothing downstream, and the extraction loop reads the bundle the rec loop stored.

Two details worth recording. RenderPlan::depends walks nested binds and the body, so it also reports ids bound inside the value, which over-approximates. That is the safe direction: at worst a re-encode is built where none is needed, which is today's behavior. And a same-stage let cannot read a rec id, since the lets loop runs before the variables exist, so restricting the scan to recs misses nothing.

Went with the check as suggested, naming the accumulator read_so_far, and guarded the insert_id rather than leaving the id out of a separate list. Both variables are still created and set, so the feedback loop and fixpoint are untouched.

with_mutually_recursive.slt (61), recursion_limit.slt (2), recursive_type_unioning.slt (8), session-window-wmr.slt (33) and introspection/relations.slt (12) pass, on this branch and again at the top of the stack after the enum collapse, along with the 26 render unit tests.

Left the outer extraction site at render.rs:1037 alone. Its re-encode runs once per outer time rather than once per iteration, and a binding the body never reads would not survive the optimizer.

Posted by Claude Code

@antiguru
antiguru force-pushed the columnar-tp-letrec-reencode branch from 526eac3 to 1f30d0b Compare September 10, 2026 14:39
@antiguru
antiguru force-pushed the columnar-tp-letrec-reencode branch from 1f30d0b to 29bae81 Compare September 10, 2026 18:37
antiguru and others added 5 commits September 10, 2026 22:29
A rec binding's collection edge was `Vec`: the in-loop feedback bundle
and the outer-scope extraction both built the bundle from a
`VecCollection`. Union reads its inputs' `.collection` edges directly, so
an identity `Get` on a rec binding fed a `Vec` input into `concat_many`,
the last source of a `Vec` edge into a Union.

Re-encode the read-edge to columnar via `vec_to_columnar` at both sites
so `Get`s on a rec binding see a columnar edge. The feedback `Variable`
stays `Vec` (the recursive value still flows `Vec` through the loop); only
the externally-visible collection is re-containered. `vec_to_columnar` is
a stateless, timestamp-agnostic, non-consolidating pass-through, so in the
iterative scope it behaves like any loop-body operator and does not alter
the feedback frontier or fixed-point behavior. The value is already
consolidated (`LetRecConsolidation`) before the read, so the re-encode
just re-containers it.

With this, a Union over a rec binding receives all-columnar inputs, and
`from_collections` (the last `CollectionEdge::Vec` producer constructor
for real data) has no callers, so remove it.

Adds a `with_mutually_recursive` case whose recursive term is a bare
identity `Get` placed directly as a `Union` input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment-only, no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The rendered value was decoded twice, by two independent operators over the same
stream: once where the binding is fed back, and again where it is extracted into
the outer scope. Each decode allocates an owned row per record per iteration.
Keep the first decode and reuse it.

Also correct the comment above, which said the recursive value flows as `Vec`
through the loop. It does not: the read edge is encoded for the readers and the
value is decoded again where it is fed back, so each iteration crosses the
container boundary twice.
A rec binding's `Variable` serves `Get`s only until the rec loop binds the
real value, so its readers are exactly the values of `recs[0..=i]`. Building
the columnar re-encode for a binding no such value reads leaves a
`VecToColumnar` operator whose output has no targets, and it repacks that
binding's whole collection into columnar buffers on every iteration and
drops it. The first-rendered binding of a mutually recursive SCC hits this,
since the other binding's value reads the already-bound bundle instead.

Install the `Variable`-backed bundle only for the bindings whose `Variable`
some value reads. The rest get their bundle when the rec loop inserts the
rendered value, which is what every remaining `Get` on them resolves to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keep the boundary crossing and the frontier guarantee, drop the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antiguru
antiguru force-pushed the columnar-tp-letrec-reencode branch from 29bae81 to 3043cf2 Compare September 10, 2026 20:30
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.

2 participants