beck_core/
fuse.rs

1//! Query fusion: a plan rewritten into a smaller plan that computes the same thing.
2//!
3//! [`docs/05-tier-lowering.md`](../../../../../docs/05-tier-lowering.md) §5.3:
4//!
5//! > Query fusion still matters (a `for` over a view of a view should become one plan, not N+1
6//! > lookups); it is a plan-rewrite on symbolic `Query` nodes, kept symbolic in `Core` precisely
7//! > for this.
8//!
9//! [`crate::plan`] decomposes a view into operators one construct at a time, so it produces the
10//! operators the *source* names: `concat_lists(map_list(xs, f))` is a `map_list` whose arrangement
11//! holds one list per element and a `flatten` that takes them apart again. Nothing reads the
12//! arrangement in between. This module is the pass that says so.
13//!
14//! # What a rewrite has to preserve, and it is not only the values
15//!
16//! An arrangement's **key** is what makes iteration order a consequence of the plan rather than of
17//! a sort at the end ([`crate::plan`]), and the order reaches the rendered page and the replay
18//! digest. So a rewrite here has three obligations, not one: the same values, in the same order,
19//! and the same *deltas* — a fused operator has to move exactly the entries the pair moved, or a
20//! subscriber woken late updates by a delta that does not describe what happened.
21//!
22//! Every rule below is stated with the property that makes it sound, and each is a *local* rewrite
23//! of one consumer and its producer. [`38`](../../../../../docs/38-literature-survey.md) §38.2
24//! points at equality saturation (egg, egglog) as the machinery for this, and it is not used:
25//! equality saturation earns its keep when rewrites conflict and the phase order would decide the
26//! answer. None of these conflict — every one *removes* an operator and none adds one — so the
27//! extraction that an e-graph would do by cost is done here by applying rules to a fixed point.
28//! §89.6 names what would need one.
29//!
30//! # The three conditions, and the second is the interesting one
31//!
32//! A producer may be fused into its consumer only when:
33//!
34//! 1. **nothing else reads it** — `consumers == 1`. An arrangement read twice is
35//!    [`26`](../../../../../docs/26-arrangement-sharing-report.md)'s shared prefix, and fusing it
36//!    into one consumer computes it twice;
37//! 2. **the fusion does not cross §5.3's session cut.** A shared operator fused into a per-session
38//!    one stops being shared: its work moves from *once per event* to *once per event per
39//!    subscriber*, which on a 256-subscriber feed is the 55× that report measured, spent rather
40//!    than saved. A local rewrite that is an improvement everywhere else is a pessimisation here,
41//!    and nothing but the cut can tell;
42//! 3. **no name points at it.** A declared signal is projected as a read-model table
43//!    ([`88`](../../../../../docs/88-read-models-and-pgwire-report.md)), so an operator a developer
44//!    named is observable to a SQL client even when the page does not read it.
45
46use std::collections::BTreeMap;
47
48use crate::core::{Const, Core, CoreKind, VarId};
49use crate::plan::{Fun, Op, OpId, Plan};
50use crate::ty::{Tier, Ty};
51
52/// Every rule this pass has, by name.
53///
54/// Published so that `fusion.rs` can hold the set to the programs that exercise it: a rule no
55/// program reaches is a rule the differential harness says nothing about, and it would sit here
56/// looking like coverage.
57pub const RULES: &[&str] = &[
58    "map_list over map_list",
59    "filter_list over filter_list",
60    "flatten over map_list",
61    "a count over a cardinality-preserving operator",
62    "concat_lists of one list",
63];
64
65/// One rewrite that fired.
66#[derive(Clone, Debug)]
67pub struct Fusion {
68    /// The rule's name, which is the shape it matched: `"flatten over map_list"`.
69    pub rule: &'static str,
70    /// The operator that remains, in the fused plan's numbering.
71    pub at: OpId,
72    /// What the operator became.
73    pub became: &'static str,
74    /// The property that makes it sound.
75    pub why: &'static str,
76}
77
78/// One rewrite that matched a shape and was refused, with the condition that refused it.
79///
80/// Printed by `beck explain query`, because "this could have fused and here is what stopped it" is
81/// the sentence a developer can act on — usually by moving where the program reads the session.
82#[derive(Clone, Debug)]
83pub struct Refusal {
84    pub rule: &'static str,
85    /// The consumer, in the fused plan's numbering.
86    pub at: OpId,
87    /// The producer that stayed.
88    pub kept: OpId,
89    pub why: String,
90}
91
92/// What one run of the pass did.
93#[derive(Clone, Debug, Default)]
94pub struct Fusions {
95    pub fired: Vec<Fusion>,
96    pub refused: Vec<Refusal>,
97    /// Operators before and after, and the arrangements among them — the two numbers the pass is
98    /// for, since an arrangement removed is memory per subscriber as well as work per event.
99    pub operators: (usize, usize),
100    pub arrangements: (usize, usize),
101}
102
103/// Rewrite a plan to a fixed point.
104pub fn fuse(mut plan: Plan) -> (Plan, Fusions) {
105    let mut rec = Fusions {
106        operators: (plan.nodes.len(), 0),
107        arrangements: (arrangements(&plan), 0),
108        ..Fusions::default()
109    };
110    // Recorded against the numbering of the round they fired in, then carried through each
111    // compaction, so what a report prints is where the operator is *now*.
112    let mut fired: Vec<(OpId, Fusion)> = Vec::new();
113    let mut refused: Vec<(OpId, OpId, Refusal)> = Vec::new();
114
115    // A round is bounded by the node count and each round removes at least one node, so this
116    // terminates for the same reason the plan is finite.
117    for _ in 0..plan.nodes.len() + 1 {
118        let Some((absorbed, survivor)) = round(&mut plan, &mut fired, &mut refused) else {
119            break;
120        };
121        // A refusal recorded against an operator that has since been absorbed is still a refusal —
122        // it is the operator that absorbed it that now reads the thing it could not fuse. Carrying
123        // it over rather than dropping it is what keeps `beck explain query` able to say why the
124        // shared half of a view stayed shared, which is the one refusal a developer can act on.
125        refused.retain(|(at, kept, _)| !(*at == survivor && *kept == absorbed));
126        for (at, kept, _) in refused.iter_mut() {
127            if *at == absorbed {
128                *at = survivor;
129            }
130            if *kept == absorbed {
131                *kept = survivor;
132            }
133        }
134        let map = plan.prune();
135        remap(&mut fired, &mut refused, &map);
136    }
137
138    rec.operators.1 = plan.nodes.len();
139    rec.arrangements.1 = arrangements(&plan);
140    rec.fired = fired
141        .into_iter()
142        .map(|(at, f)| Fusion { at, ..f })
143        .collect();
144    rec.refused = refused
145        .into_iter()
146        .map(|(at, kept, r)| Refusal { at, kept, ..r })
147        .collect();
148    rec.refused.sort_by_key(|r| (r.at, r.kept));
149    rec.refused.dedup_by_key(|r| (r.at, r.kept, r.rule));
150    (plan, rec)
151}
152
153fn arrangements(plan: &Plan) -> usize {
154    plan.nodes.iter().filter(|n| n.op.is_arrangement()).count()
155}
156
157/// One pass over the plan, stopping at the first rewrite.
158///
159/// Returns the operator that was absorbed and the one that absorbed it, so that a refusal recorded
160/// against the first can be carried to the second.
161fn round(
162    plan: &mut Plan,
163    fired: &mut Vec<(OpId, Fusion)>,
164    refused: &mut Vec<(OpId, OpId, Refusal)>,
165) -> Option<(OpId, OpId)> {
166    for i in 0..plan.nodes.len() {
167        // A `concat_lists` of one list is that list. It is the only rewrite here that removes the
168        // *consumer* rather than the producer, because what it removes is a re-keying — every
169        // entry gains the same `[0]` prefix, so the order the prefix decides is the order it
170        // already had.
171        if matches!(plan.nodes[i].op, Op::Concat) && plan.nodes[i].inputs.len() == 1 {
172            let input = plan.nodes[i].inputs[0];
173            if plan.nodes[input].op.is_arrangement() && i != plan.state && i != plan.session {
174                substitute(plan, i, input);
175                fired.push((
176                    input,
177                    Fusion {
178                        rule: "concat_lists of one list",
179                        at: input,
180                        became: plan.nodes[input].op.name(),
181                        why: "a union of one delta stream is that delta stream, and every entry \
182                              gained the same key prefix",
183                    },
184                ));
185                return Some((i, input));
186            }
187        }
188
189        let Some(&p) = plan.nodes[i].inputs.first() else {
190            continue;
191        };
192        let Some(rule) = matching(&plan.nodes[i].op, &plan.nodes[p].op) else {
193            continue;
194        };
195        if let Some(why) = refuses(plan, i, p, rule) {
196            refused.push((
197                i,
198                p,
199                Refusal {
200                    rule: rule.name,
201                    at: i,
202                    kept: p,
203                    why,
204                },
205            ));
206            continue;
207        }
208        (rule.apply)(plan, i, p);
209        fired.push((
210            i,
211            Fusion {
212                rule: rule.name,
213                at: i,
214                became: plan.nodes[i].op.name(),
215                why: rule.why,
216            },
217        ));
218        return Some((p, i));
219    }
220    None
221}
222
223/// A rule: the shape it matches, why it is sound, and what it does.
224struct Rule {
225    name: &'static str,
226    why: &'static str,
227    /// True when this rule moves the producer's per-element work into the consumer, which is what
228    /// makes crossing the session cut a pessimisation rather than a saving.
229    carries_work: bool,
230    apply: fn(&mut Plan, OpId, OpId),
231}
232
233fn matching(consumer: &Op, producer: &Op) -> Option<&'static Rule> {
234    match (consumer, producer) {
235        (Op::MapList { .. }, Op::MapList { .. }) => Some(&MAP_OVER_MAP),
236        (Op::FilterList { .. }, Op::FilterList { .. }) => Some(&FILTER_OVER_FILTER),
237        (Op::Flatten, Op::MapList { .. }) => Some(&FLATTEN_OVER_MAP),
238        (Op::Count | Op::IsEmpty, Op::MapList { .. } | Op::SortBy { .. }) => Some(&COUNT_OVER),
239        _ => None,
240    }
241}
242
243static MAP_OVER_MAP: Rule = Rule {
244    name: "map_list over map_list",
245    why: "neither moves an element, so both arrangements have the input's key and the composition \
246          has it too",
247    carries_work: true,
248    apply: |plan, i, p| {
249        let inner = fun_of(&plan.nodes[p].op)
250            .expect("the rule matched a map_list")
251            .clone();
252        let outer = fun_of(&plan.nodes[i].op)
253            .expect("the rule matched a map_list")
254            .clone();
255        plan.nodes[i].op = Op::MapList {
256            f: compose(&outer, &inner),
257        };
258        plan.nodes[i].inputs = plan.nodes[p].inputs.clone();
259    },
260};
261
262static FILTER_OVER_FILTER: Rule = Rule {
263    name: "filter_list over filter_list",
264    why: "a conjunction, and it short-circuits — the outer predicate is applied to exactly the \
265          elements the inner one kept, which is what the pair did",
266    carries_work: true,
267    apply: |plan, i, p| {
268        let inner = fun_of(&plan.nodes[p].op)
269            .expect("the rule matched a filter_list")
270            .clone();
271        let outer = fun_of(&plan.nodes[i].op)
272            .expect("the rule matched a filter_list")
273            .clone();
274        plan.nodes[i].op = Op::FilterList {
275            f: conjoin(&outer, &inner),
276        };
277        plan.nodes[i].inputs = plan.nodes[p].inputs.clone();
278    },
279};
280
281static FLATTEN_OVER_MAP: Rule = Rule {
282    name: "flatten over map_list",
283    why: "the map's key is the input's and the flatten's is the map's followed by a position, so \
284          one operator keyed by the input's key and a position is the same order",
285    carries_work: true,
286    apply: |plan, i, p| {
287        let f = fun_of(&plan.nodes[p].op)
288            .expect("the rule matched a map_list")
289            .clone();
290        plan.nodes[i].op = Op::FlatMap { f };
291        plan.nodes[i].inputs = plan.nodes[p].inputs.clone();
292    },
293};
294
295static COUNT_OVER: Rule = Rule {
296    name: "a count over a cardinality-preserving operator",
297    why: "`map_list` and `sort_by` produce one entry per entry, so how many there are is a \
298          question about the input and the arrangement between them is never read",
299    // A count does not apply the producer's function at all — it drops it — so this one is a
300    // saving on whichever side of the cut it lands.
301    carries_work: false,
302    apply: |plan, i, p| {
303        plan.nodes[i].inputs = vec![plan.nodes[p].inputs[0]];
304    },
305};
306
307/// The three conditions, in the order a reader needs them.
308fn refuses(plan: &Plan, i: OpId, p: OpId, rule: &Rule) -> Option<String> {
309    if p == plan.state || p == plan.session || p == plan.root {
310        return Some("it is the plan's root or one of its sources".to_string());
311    }
312    if plan.nodes[p].consumers > 1 {
313        return Some(format!(
314            "#{p} is read by {} operators, and fusing it into one of them would compute it {} \
315             times (docs/26)",
316            plan.nodes[p].consumers, plan.nodes[p].consumers
317        ));
318    }
319    let names = plan.names_of(p);
320    if !names.is_empty() {
321        return Some(format!(
322            "`{}` is a declared signal, so the read model projects it as a table (docs/88)",
323            names.join("`, `")
324        ));
325    }
326    if rule.carries_work && !plan.nodes[p].per_session && plan.nodes[i].per_session {
327        return Some(format!(
328            "#{p} is shared and #{i} is per session, so fusing would move work the process does \
329             once per event to work it does once per event per subscriber (docs/26 §5.3)"
330        ));
331    }
332    None
333}
334
335fn fun_of(op: &Op) -> Option<&Fun> {
336    match op {
337        Op::MapList { f } | Op::FilterList { f } | Op::SortBy { f } | Op::FlatMap { f } => Some(f),
338        _ => None,
339    }
340}
341
342// -------------------------------------------------------------------------------------------
343// Composing two per-element functions
344// -------------------------------------------------------------------------------------------
345
346/// `g ∘ f`, as one [`Fun`].
347///
348/// A [`Fun`]'s code is a `Lam` over its captured operators followed by the element, and both of
349/// these are closed — every variable either is a parameter or is bound inside. So the composition
350/// does not substitute anything: it binds fresh parameters and *applies* both lambdas, which is
351/// why nothing here has to reason about variable capture.
352fn compose(outer: &Fun, inner: &Fun) -> Fun {
353    let (params, inner_args, outer_caps, x) = frame(outer, inner);
354    let applied = apply(&inner.code, inner_args);
355    let mut outer_args: Vec<Core> = outer_caps;
356    outer_args.push(applied);
357    let _ = x;
358    Fun {
359        code: lam(params, apply(&outer.code, outer_args)),
360        captures: inner
361            .captures
362            .iter()
363            .chain(outer.captures.iter())
364            .copied()
365            .collect(),
366    }
367}
368
369/// `λx. if f(x): g(x) else false` — the conjunction of two predicates, short-circuiting.
370///
371/// Written as an `If` rather than as `Prim::And` for the reason
372/// [`53`](../../../../../docs/53-are-we-fast-yet-report.md) gives: `and` *is* an `If` in `Core`, and
373/// building the strict primitive here would apply the outer predicate to elements the inner one
374/// rejected — which the pair of operators never did.
375fn conjoin(outer: &Fun, inner: &Fun) -> Fun {
376    let (params, inner_args, outer_caps, x) = frame(outer, inner);
377    let mut outer_args: Vec<Core> = outer_caps;
378    outer_args.push(var(x));
379    Fun {
380        code: lam(
381            params,
382            Core {
383                kind: CoreKind::If {
384                    cond: Box::new(apply(&inner.code, inner_args)),
385                    then: Box::new(apply(&outer.code, outer_args)),
386                    alt: Box::new(Core {
387                        kind: CoreKind::Const(Const::Bool(false)),
388                        ty: Ty::bool_(),
389                        tier: Tier::Any,
390                        span: beck_diag::Span::NONE,
391                        last_use: false,
392                        order: crate::fields::UNORDERED,
393                        locals: 0,
394                    }),
395                },
396                ty: Ty::bool_(),
397                tier: Tier::Any,
398                span: beck_diag::Span::NONE,
399                last_use: false,
400                order: crate::fields::UNORDERED,
401                locals: 0,
402            },
403        ),
404        captures: inner
405            .captures
406            .iter()
407            .chain(outer.captures.iter())
408            .copied()
409            .collect(),
410    }
411}
412
413/// The parameter list both compositions share: the inner's captures, the outer's, then the
414/// element — the order [`crate::engine`] supplies arguments in.
415fn frame(outer: &Fun, inner: &Fun) -> (Vec<VarId>, Vec<Core>, Vec<Core>, VarId) {
416    let n = inner.captures.len() + outer.captures.len() + 1;
417    // Fresh, so a parameter cannot shadow a variable either body binds. Both bodies are closed
418    // under their own parameters, so any distinct names would do; distinct *and above everything*
419    // keeps a debug print readable.
420    let base = 1 + max_var(&inner.code).max(max_var(&outer.code));
421    let params: Vec<VarId> = (0..n as VarId).map(|k| base + k).collect();
422    let inner_args: Vec<Core> = params[..inner.captures.len()]
423        .iter()
424        .copied()
425        .chain(std::iter::once(params[n - 1]))
426        .map(var)
427        .collect();
428    let outer_caps: Vec<Core> = params[inner.captures.len()..n - 1]
429        .iter()
430        .copied()
431        .map(var)
432        .collect();
433    let x = params[n - 1];
434    (params, inner_args, outer_caps, x)
435}
436
437fn max_var(c: &Core) -> VarId {
438    let mut top = 0;
439    walk(c, &mut |x| {
440        if let CoreKind::Var(v) = &x.kind {
441            top = top.max(*v);
442        }
443        if let CoreKind::Lam { params, .. } = &x.kind {
444            for p in params.iter() {
445                top = top.max(*p);
446            }
447        }
448        if let CoreKind::Let { var, .. } = &x.kind {
449            top = top.max(*var);
450        }
451    });
452    top
453}
454
455fn walk(c: &Core, f: &mut impl FnMut(&Core)) {
456    f(c);
457    match &c.kind {
458        CoreKind::Var(_) | CoreKind::Const(_) | CoreKind::Global(_) => {}
459        CoreKind::Lam { body, .. } => walk(body, f),
460        CoreKind::App { func, args } => {
461            walk(func, f);
462            args.iter().for_each(|a| walk(a, f));
463        }
464        CoreKind::Prim { args, .. } => args.iter().for_each(|a| walk(a, f)),
465        CoreKind::Let { value, body, .. } => {
466            walk(value, f);
467            walk(body, f);
468        }
469        CoreKind::If { cond, then, alt } => {
470            walk(cond, f);
471            walk(then, f);
472            walk(alt, f);
473        }
474        CoreKind::Match { scrutinee, arms } => {
475            walk(scrutinee, f);
476            arms.iter().flat_map(|a| a.exprs()).for_each(|e| walk(e, f));
477        }
478        CoreKind::Make { fields, .. } => fields.iter().for_each(|(_, v)| walk(v, f)),
479        CoreKind::Field { base, .. } => walk(base, f),
480        CoreKind::With { base, fields } => {
481            walk(base, f);
482            fields.iter().for_each(|(_, v)| walk(v, f));
483        }
484        CoreKind::ListLit(items) => items.iter().for_each(|i| walk(i, f)),
485        CoreKind::MapLit(pairs) => pairs.iter().for_each(|(k, v)| {
486            walk(k, f);
487            walk(v, f);
488        }),
489    }
490}
491
492fn apply(f: &Core, args: Vec<Core>) -> Core {
493    let ty = match &f.ty {
494        Ty::Fun(_, ret, _) => (**ret).clone(),
495        _ => Ty::unit(),
496    };
497    Core {
498        kind: CoreKind::App {
499            func: Box::new(f.clone()),
500            args,
501        },
502        ty,
503        tier: Tier::Any,
504        span: f.span,
505        last_use: false,
506        order: crate::fields::UNORDERED,
507        locals: 0,
508    }
509}
510
511fn lam(params: Vec<VarId>, body: Core) -> Core {
512    Core {
513        ty: Ty::fun(params.iter().map(|_| Ty::unit()).collect(), body.ty.clone()),
514        tier: body.tier,
515        span: body.span,
516        kind: CoreKind::Lam {
517            params: params.into(),
518            body: std::sync::Arc::new(body),
519        },
520        last_use: false,
521        order: crate::fields::UNORDERED,
522        locals: 0,
523    }
524}
525
526fn var(v: VarId) -> Core {
527    Core {
528        kind: CoreKind::Var(v),
529        ty: Ty::unit(),
530        tier: Tier::Any,
531        span: beck_diag::Span::NONE,
532        last_use: false,
533        order: crate::fields::UNORDERED,
534        locals: 0,
535    }
536}
537
538// -------------------------------------------------------------------------------------------
539// Keeping the plan a plan
540// -------------------------------------------------------------------------------------------
541
542/// Point every reader of `from` at `to`, including the plan's own roots and names.
543fn substitute(plan: &mut Plan, from: OpId, to: OpId) {
544    let swap = |id: &mut OpId| {
545        if *id == from {
546            *id = to;
547        }
548    };
549    for node in &mut plan.nodes {
550        node.inputs.iter_mut().for_each(swap);
551        if let Op::MapList { f } | Op::FilterList { f } | Op::SortBy { f } | Op::FlatMap { f } =
552            &mut node.op
553        {
554            f.captures.iter_mut().for_each(swap);
555        }
556    }
557    swap(&mut plan.root);
558    for (_, id) in &mut plan.signals {
559        swap(id);
560    }
561}
562
563fn remap(
564    fired: &mut [(OpId, Fusion)],
565    refused: &mut Vec<(OpId, OpId, Refusal)>,
566    map: &BTreeMap<OpId, OpId>,
567) {
568    for (at, _) in fired.iter_mut() {
569        if let Some(&n) = map.get(at) {
570            *at = n;
571        }
572    }
573    // A refusal whose producer was removed by another rule is not a refusal any more.
574    refused.retain(|(at, kept, _)| map.contains_key(at) && map.contains_key(kept));
575    for (at, kept, _) in refused.iter_mut() {
576        *at = map[at];
577        *kept = map[kept];
578    }
579}
580
581// -------------------------------------------------------------------------------------------
582// The report
583// -------------------------------------------------------------------------------------------
584
585/// The fusion half of `beck explain query`.
586pub fn report(f: &Fusions) -> String {
587    use std::fmt::Write;
588    let mut out = String::new();
589    let _ = writeln!(out, "\nwhat fused (§5.3)");
590    if f.fired.is_empty() {
591        let _ = writeln!(
592            out,
593            "  nothing.{}",
594            if f.arrangements.0 == 0 {
595                " This view holds no collection, so there is no pair of collection\n  \
596                 operators for a rule to match."
597            } else {
598                " No operator here is read by exactly one operator that could absorb\n  it."
599            }
600        );
601    }
602    for fusion in &f.fired {
603        let _ = writeln!(
604            out,
605            "  #{:<3} {:<38} → {}",
606            fusion.at, fusion.rule, fusion.became
607        );
608        let _ = writeln!(out, "       {}", fusion.why);
609    }
610    if !f.refused.is_empty() {
611        let _ = writeln!(out, "\nwhat matched a rule and did not fuse");
612        for r in &f.refused {
613            let _ = writeln!(out, "  #{:<3} {:<38} kept #{}", r.at, r.rule, r.kept);
614            let _ = writeln!(out, "       {}", r.why);
615        }
616    }
617    let _ = writeln!(
618        out,
619        "\n  {} operators before, {} after; {} arrangements before, {} after. An arrangement is \n  \
620         memory per subscriber as well as work per event (docs/26 §26.7), which is why the second \n  \
621         pair is the one to read.",
622        f.operators.0, f.operators.1, f.arrangements.0, f.arrangements.1
623    );
624    out
625}