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//! §23.19 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//!    [`docs/23`](../../../../../docs/23-incremental-views-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//!    ([`docs/23`](../../../../../docs/23-incremental-views-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        // The absorbed operator's *reason* moves with its work. A loop that looks something up and
210        // was not read as a join records why on the `map_list`
211        // ([`crate::plan::Node::relate`]) — and every `ui:` loop then fuses that `map_list` into the
212        // `flatten` above it, which kept its own empty field. So the one shape the explanation
213        // exists for was the one shape that never printed it: `beck explain cost` named the cost and
214        // said nothing about the cause on `board.beck` and `33-awareness.beck`, which are the only
215        // two programs in the tree that have it.
216        if plan.nodes[i].relate.is_none() {
217            plan.nodes[i].relate = plan.nodes[p].relate.take();
218        }
219        fired.push((
220            i,
221            Fusion {
222                rule: rule.name,
223                at: i,
224                became: plan.nodes[i].op.name(),
225                why: rule.why,
226            },
227        ));
228        return Some((p, i));
229    }
230    None
231}
232
233/// A rule: the shape it matches, why it is sound, and what it does.
234struct Rule {
235    name: &'static str,
236    why: &'static str,
237    /// True when this rule moves the producer's per-element work into the consumer, which is what
238    /// makes crossing the session cut a pessimisation rather than a saving.
239    carries_work: bool,
240    apply: fn(&mut Plan, OpId, OpId),
241}
242
243fn matching(consumer: &Op, producer: &Op) -> Option<&'static Rule> {
244    match (consumer, producer) {
245        (Op::MapList { .. }, Op::MapList { .. }) => Some(&MAP_OVER_MAP),
246        (Op::FilterList { .. }, Op::FilterList { .. }) => Some(&FILTER_OVER_FILTER),
247        (Op::Flatten, Op::MapList { .. }) => Some(&FLATTEN_OVER_MAP),
248        (Op::Count | Op::IsEmpty, Op::MapList { .. } | Op::SortBy { .. }) => Some(&COUNT_OVER),
249        _ => None,
250    }
251}
252
253static MAP_OVER_MAP: Rule = Rule {
254    name: "map_list over map_list",
255    why: "neither moves an element, so both arrangements have the input's key and the composition \
256          has it too",
257    carries_work: true,
258    apply: |plan, i, p| {
259        let inner = fun_of(&plan.nodes[p].op)
260            .expect("the rule matched a map_list")
261            .clone();
262        let outer = fun_of(&plan.nodes[i].op)
263            .expect("the rule matched a map_list")
264            .clone();
265        plan.nodes[i].op = Op::MapList {
266            f: compose(&outer, &inner),
267        };
268        plan.nodes[i].inputs = plan.nodes[p].inputs.clone();
269    },
270};
271
272static FILTER_OVER_FILTER: Rule = Rule {
273    name: "filter_list over filter_list",
274    why: "a conjunction, and it short-circuits — the outer predicate is applied to exactly the \
275          elements the inner one kept, which is what the pair did",
276    carries_work: true,
277    apply: |plan, i, p| {
278        let inner = fun_of(&plan.nodes[p].op)
279            .expect("the rule matched a filter_list")
280            .clone();
281        let outer = fun_of(&plan.nodes[i].op)
282            .expect("the rule matched a filter_list")
283            .clone();
284        plan.nodes[i].op = Op::FilterList {
285            f: conjoin(&outer, &inner),
286        };
287        plan.nodes[i].inputs = plan.nodes[p].inputs.clone();
288    },
289};
290
291static FLATTEN_OVER_MAP: Rule = Rule {
292    name: "flatten over map_list",
293    why: "the map's key is the input's and the flatten's is the map's followed by a position, so \
294          one operator keyed by the input's key and a position is the same order",
295    carries_work: true,
296    apply: |plan, i, p| {
297        let f = fun_of(&plan.nodes[p].op)
298            .expect("the rule matched a map_list")
299            .clone();
300        plan.nodes[i].op = Op::FlatMap { f };
301        plan.nodes[i].inputs = plan.nodes[p].inputs.clone();
302    },
303};
304
305static COUNT_OVER: Rule = Rule {
306    name: "a count over a cardinality-preserving operator",
307    why: "`map_list` and `sort_by` produce one entry per entry, so how many there are is a \
308          question about the input and the arrangement between them is never read",
309    // A count does not apply the producer's function at all — it drops it — so this one is a
310    // saving on whichever side of the cut it lands.
311    carries_work: false,
312    apply: |plan, i, p| {
313        plan.nodes[i].inputs = vec![plan.nodes[p].inputs[0]];
314    },
315};
316
317/// The three conditions, in the order a reader needs them.
318fn refuses(plan: &Plan, i: OpId, p: OpId, rule: &Rule) -> Option<String> {
319    if p == plan.state || p == plan.session || p == plan.root {
320        return Some("it is the plan's root or one of its sources".to_string());
321    }
322    if plan.nodes[p].consumers > 1 {
323        return Some(format!(
324            "#{p} is read by {} operators, and fusing it into one of them would compute it {} \
325             times (docs/23)",
326            plan.nodes[p].consumers, plan.nodes[p].consumers
327        ));
328    }
329    let names = plan.names_of(p);
330    if !names.is_empty() {
331        return Some(format!(
332            "`{}` is a declared signal, so the read model projects it as a table (docs/23)",
333            names.join("`, `")
334        ));
335    }
336    if rule.carries_work && !plan.nodes[p].per_session && plan.nodes[i].per_session {
337        return Some(format!(
338            "#{p} is shared and #{i} is per session, so fusing would move work the process does \
339             once per event to work it does once per event per subscriber (docs/23 §5.3)"
340        ));
341    }
342    None
343}
344
345fn fun_of(op: &Op) -> Option<&Fun> {
346    match op {
347        Op::MapList { f } | Op::FilterList { f } | Op::SortBy { f } | Op::FlatMap { f } => Some(f),
348        _ => None,
349    }
350}
351
352// -------------------------------------------------------------------------------------------
353// Composing two per-element functions
354// -------------------------------------------------------------------------------------------
355
356/// `g ∘ f`, as one [`Fun`].
357///
358/// A [`Fun`]'s code is a `Lam` over its captured operators followed by the element, and both of
359/// these are closed — every variable either is a parameter or is bound inside. So the composition
360/// does not substitute anything: it binds fresh parameters and *applies* both lambdas, which is
361/// why nothing here has to reason about variable capture.
362fn compose(outer: &Fun, inner: &Fun) -> Fun {
363    let (params, inner_args, outer_caps, x) = frame(outer, inner);
364    let applied = apply(&inner.code, inner_args);
365    let mut outer_args: Vec<Core> = outer_caps;
366    outer_args.push(applied);
367    let _ = x;
368    Fun {
369        code: lam(params, apply(&outer.code, outer_args)),
370        captures: inner
371            .captures
372            .iter()
373            .chain(outer.captures.iter())
374            .copied()
375            .collect(),
376    }
377}
378
379/// `λx. if f(x): g(x) else false` — the conjunction of two predicates, short-circuiting.
380///
381/// Written as an `If` rather than as `Prim::And` for the reason
382/// [`53`](../../../../../docs/53-are-we-fast-yet-report.md) gives: `and` *is* an `If` in `Core`, and
383/// building the strict primitive here would apply the outer predicate to elements the inner one
384/// rejected — which the pair of operators never did.
385fn conjoin(outer: &Fun, inner: &Fun) -> Fun {
386    let (params, inner_args, outer_caps, x) = frame(outer, inner);
387    let mut outer_args: Vec<Core> = outer_caps;
388    outer_args.push(var(x));
389    Fun {
390        code: lam(
391            params,
392            Core {
393                kind: CoreKind::If {
394                    cond: Box::new(apply(&inner.code, inner_args)),
395                    then: Box::new(apply(&outer.code, outer_args)),
396                    alt: Box::new(Core {
397                        kind: CoreKind::Const(Const::Bool(false)),
398                        ty: Ty::bool_(),
399                        tier: Tier::Any,
400                        span: beck_diag::Span::NONE,
401                        last_use: false,
402                        order: crate::fields::UNORDERED,
403                        locals: 0,
404                    }),
405                },
406                ty: Ty::bool_(),
407                tier: Tier::Any,
408                span: beck_diag::Span::NONE,
409                last_use: false,
410                order: crate::fields::UNORDERED,
411                locals: 0,
412            },
413        ),
414        captures: inner
415            .captures
416            .iter()
417            .chain(outer.captures.iter())
418            .copied()
419            .collect(),
420    }
421}
422
423/// The parameter list both compositions share: the inner's captures, the outer's, then the
424/// element — the order [`crate::engine`] supplies arguments in.
425fn frame(outer: &Fun, inner: &Fun) -> (Vec<VarId>, Vec<Core>, Vec<Core>, VarId) {
426    let n = inner.captures.len() + outer.captures.len() + 1;
427    // Fresh, so a parameter cannot shadow a variable either body binds. Both bodies are closed
428    // under their own parameters, so any distinct names would do; distinct *and above everything*
429    // keeps a debug print readable.
430    let base = 1 + max_var(&inner.code).max(max_var(&outer.code));
431    let params: Vec<VarId> = (0..n as VarId).map(|k| base + k).collect();
432    let inner_args: Vec<Core> = params[..inner.captures.len()]
433        .iter()
434        .copied()
435        .chain(std::iter::once(params[n - 1]))
436        .map(var)
437        .collect();
438    let outer_caps: Vec<Core> = params[inner.captures.len()..n - 1]
439        .iter()
440        .copied()
441        .map(var)
442        .collect();
443    let x = params[n - 1];
444    (params, inner_args, outer_caps, x)
445}
446
447fn max_var(c: &Core) -> VarId {
448    let mut top = 0;
449    walk(c, &mut |x| {
450        if let CoreKind::Var(v) = &x.kind {
451            top = top.max(*v);
452        }
453        if let CoreKind::Lam { params, .. } = &x.kind {
454            for p in params.iter() {
455                top = top.max(*p);
456            }
457        }
458        if let CoreKind::Let { var, .. } = &x.kind {
459            top = top.max(*var);
460        }
461    });
462    top
463}
464
465fn walk(c: &Core, f: &mut impl FnMut(&Core)) {
466    f(c);
467    match &c.kind {
468        CoreKind::Var(_) | CoreKind::Const(_) | CoreKind::Global(_) => {}
469        CoreKind::Lam { body, .. } => walk(body, f),
470        CoreKind::App { func, args } => {
471            walk(func, f);
472            args.iter().for_each(|a| walk(a, f));
473        }
474        CoreKind::Prim { args, .. } => args.iter().for_each(|a| walk(a, f)),
475        CoreKind::Let { value, body, .. } => {
476            walk(value, f);
477            walk(body, f);
478        }
479        CoreKind::If { cond, then, alt } => {
480            walk(cond, f);
481            walk(then, f);
482            walk(alt, f);
483        }
484        CoreKind::Match { scrutinee, arms } => {
485            walk(scrutinee, f);
486            arms.iter().flat_map(|a| a.exprs()).for_each(|e| walk(e, f));
487        }
488        CoreKind::Make { fields, .. } => fields.iter().for_each(|(_, v)| walk(v, f)),
489        CoreKind::Field { base, .. } => walk(base, f),
490        CoreKind::With { base, fields } => {
491            walk(base, f);
492            fields.iter().for_each(|(_, v)| walk(v, f));
493        }
494        CoreKind::ListLit(items) => items.iter().for_each(|i| walk(i, f)),
495        CoreKind::MapLit(pairs) => pairs.iter().for_each(|(k, v)| {
496            walk(k, f);
497            walk(v, f);
498        }),
499    }
500}
501
502fn apply(f: &Core, args: Vec<Core>) -> Core {
503    let ty = match &f.ty {
504        Ty::Fun(_, ret, _) => (**ret).clone(),
505        _ => Ty::unit(),
506    };
507    Core {
508        kind: CoreKind::App {
509            func: Box::new(f.clone()),
510            args,
511        },
512        ty,
513        tier: Tier::Any,
514        span: f.span,
515        last_use: false,
516        order: crate::fields::UNORDERED,
517        locals: 0,
518    }
519}
520
521fn lam(params: Vec<VarId>, body: Core) -> Core {
522    Core {
523        ty: Ty::fun(params.iter().map(|_| Ty::unit()).collect(), body.ty.clone()),
524        tier: body.tier,
525        span: body.span,
526        kind: CoreKind::Lam {
527            params: params.into(),
528            body: std::sync::Arc::new(body),
529        },
530        last_use: false,
531        order: crate::fields::UNORDERED,
532        locals: 0,
533    }
534}
535
536fn var(v: VarId) -> Core {
537    Core {
538        kind: CoreKind::Var(v),
539        ty: Ty::unit(),
540        tier: Tier::Any,
541        span: beck_diag::Span::NONE,
542        last_use: false,
543        order: crate::fields::UNORDERED,
544        locals: 0,
545    }
546}
547
548// -------------------------------------------------------------------------------------------
549// Keeping the plan a plan
550// -------------------------------------------------------------------------------------------
551
552/// Point every reader of `from` at `to`, including the plan's own roots and names.
553fn substitute(plan: &mut Plan, from: OpId, to: OpId) {
554    let swap = |id: &mut OpId| {
555        if *id == from {
556            *id = to;
557        }
558    };
559    for node in &mut plan.nodes {
560        node.inputs.iter_mut().for_each(swap);
561        for f in node.op.funs_mut() {
562            f.captures.iter_mut().for_each(swap);
563        }
564    }
565    swap(&mut plan.root);
566    for (_, id) in &mut plan.signals {
567        swap(id);
568    }
569}
570
571fn remap(
572    fired: &mut [(OpId, Fusion)],
573    refused: &mut Vec<(OpId, OpId, Refusal)>,
574    map: &BTreeMap<OpId, OpId>,
575) {
576    for (at, _) in fired.iter_mut() {
577        if let Some(&n) = map.get(at) {
578            *at = n;
579        }
580    }
581    // A refusal whose producer was removed by another rule is not a refusal any more.
582    refused.retain(|(at, kept, _)| map.contains_key(at) && map.contains_key(kept));
583    for (at, kept, _) in refused.iter_mut() {
584        *at = map[at];
585        *kept = map[kept];
586    }
587}
588
589// -------------------------------------------------------------------------------------------
590// The report
591// -------------------------------------------------------------------------------------------
592
593/// The fusion half of `beck explain query`.
594pub fn report(f: &Fusions) -> String {
595    use std::fmt::Write;
596    let mut out = String::new();
597    let _ = writeln!(out, "\nwhat fused (§5.3)");
598    if f.fired.is_empty() {
599        let _ = writeln!(
600            out,
601            "  nothing.{}",
602            if f.arrangements.0 == 0 {
603                " This view holds no collection, so there is no pair of collection\n  \
604                 operators for a rule to match."
605            } else {
606                " No operator here is read by exactly one operator that could absorb\n  it."
607            }
608        );
609    }
610    for fusion in &f.fired {
611        let _ = writeln!(
612            out,
613            "  #{:<3} {:<38} → {}",
614            fusion.at, fusion.rule, fusion.became
615        );
616        let _ = writeln!(out, "       {}", fusion.why);
617    }
618    if !f.refused.is_empty() {
619        let _ = writeln!(out, "\nwhat matched a rule and did not fuse");
620        for r in &f.refused {
621            let _ = writeln!(out, "  #{:<3} {:<38} kept #{}", r.at, r.rule, r.kept);
622            let _ = writeln!(out, "       {}", r.why);
623        }
624    }
625    let _ = writeln!(
626        out,
627        "\n  {} operators before, {} after; {} arrangements before, {} after. An arrangement is \n  \
628         memory per subscriber as well as work per event (docs/23 §23.14), which is why the second \n  \
629         pair is the one to read.",
630        f.operators.0, f.operators.1, f.arrangements.0, f.arrangements.1
631    );
632    out
633}