Skip to main content

polydat_core/compile/
select.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! The engine a host chooses, the provenance mode a compiled engine is
5//! built with, and the selector that picks a mode from a graph's shape
6//! when the host leaves it to `Provenance::Auto`.
7
8use crate::ast::PolydatNode;
9use crate::kernel::WireSource;
10use std::collections::HashMap;
11
12/// Which provenance optimization the compiler selected.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ProvMode {
15    /// No provenance — eval runs all nodes unconditionally.
16    Raw,
17    /// Pull-side cone guard only. `set_inputs` tracks `changed_mask`,
18    /// `eval_for_slot` skips eval when the output cone is clean.
19    /// Zero overhead on all-dirty graphs.
20    Pull,
21    /// Push + pull. Per-node dirty tracking in `set_inputs` +
22    /// cone guard. Selected whenever the graph has two or more inputs.
23    PushPull,
24}
25
26/// Graph analysis results used by the engine selection heuristic.
27#[derive(Debug, Clone)]
28pub struct GraphAnalysis {
29    /// Nodes in the graph.
30    pub total_nodes: usize,
31    /// Inputs.
32    pub num_inputs: usize,
33    /// Named outputs.
34    pub num_outputs: usize,
35    /// Per-output cone size (number of nodes in transitive dependency).
36    pub output_cone_sizes: Vec<(String, usize)>,
37    /// max(cone_size) / total_nodes
38    pub max_cone_ratio: f64,
39    /// Average cone_size / total_nodes
40    pub avg_cone_ratio: f64,
41}
42
43/// Analyze a resolved DAG to compute structural metrics.
44pub fn analyze_graph(
45    nodes: &[Box<dyn PolydatNode>],
46    wiring: &[Vec<WireSource>],
47    output_map: &HashMap<String, (usize, usize)>,
48) -> GraphAnalysis {
49    let total_nodes = nodes.len();
50
51    // Compute per-output cone size: the transitive upstream set, the
52    // nodes that can reach the output, found by walking backward from
53    // the output node.
54    let mut output_cone_sizes = Vec::new();
55    for (name, &(node_idx, _port)) in output_map {
56        let cone_size = compute_cone_size(node_idx, wiring);
57        output_cone_sizes.push((name.clone(), cone_size));
58    }
59
60    let max_cone = output_cone_sizes.iter().map(|(_, s)| *s).max().unwrap_or(0);
61    let avg_cone: f64 = if output_cone_sizes.is_empty() {
62        0.0
63    } else {
64        output_cone_sizes
65            .iter()
66            .map(|(_, s)| *s as f64)
67            .sum::<f64>()
68            / output_cone_sizes.len() as f64
69    };
70
71    let max_cone_ratio = if total_nodes > 0 {
72        max_cone as f64 / total_nodes as f64
73    } else {
74        1.0
75    };
76    let avg_cone_ratio = if total_nodes > 0 {
77        avg_cone / total_nodes as f64
78    } else {
79        1.0
80    };
81
82    // Count distinct inputs
83    let mut max_input = 0usize;
84    for sources in wiring {
85        for s in sources {
86            if let WireSource::Input(idx) = s {
87                max_input = max_input.max(*idx + 1);
88            }
89        }
90    }
91
92    GraphAnalysis {
93        total_nodes,
94        num_inputs: max_input,
95        num_outputs: output_map.len(),
96        output_cone_sizes,
97        max_cone_ratio,
98        avg_cone_ratio,
99    }
100}
101
102/// Count the number of nodes in the transitive upstream cone of a node.
103fn compute_cone_size(node_idx: usize, wiring: &[Vec<WireSource>]) -> usize {
104    let mut visited = vec![false; wiring.len()];
105    let mut stack = vec![node_idx];
106    let mut count = 0;
107    while let Some(idx) = stack.pop() {
108        if idx >= visited.len() || visited[idx] {
109            continue;
110        }
111        visited[idx] = true;
112        count += 1;
113        for source in &wiring[idx] {
114            if let WireSource::NodeOutput(upstream, _) = source
115                && !visited[*upstream]
116            {
117                stack.push(*upstream);
118            }
119        }
120    }
121    count
122}
123
124/// Select the optimal provenance mode based on graph analysis.
125///
126/// Heuristic (engines.md §5):
127/// - Pull has zero overhead on all-dirty graphs (cone check ~2ns)
128/// - Pull is the safe default for selective output access
129/// - PushPull when multiple inputs exist (push skip helps within
130///   dirty cones when some subgraphs are stable)
131/// - Raw only for tiny single-input graphs
132pub fn select_prov_mode(analysis: &GraphAnalysis) -> ProvMode {
133    // Tiny single-input graphs: skip provenance data entirely.
134    // The overhead of tracking changed_mask isn't worth it.
135    if analysis.total_nodes < 15 && analysis.num_inputs <= 1 {
136        return ProvMode::Raw;
137    }
138
139    // Multiple inputs: some may be stable at runtime, enabling both
140    // push-side skip (within dirty cones) and pull-side skip (clean cones).
141    // PushPull is the right choice because:
142    // - If an output's cone is clean: pull guard skips eval entirely
143    // - If an output's cone is dirty: push skip avoids stable nodes
144    // The push overhead (~10ns in set_inputs for dependent marking)
145    // is justified by the potential to skip 30-80% of nodes within
146    // dirty cones.
147    if analysis.num_inputs >= 2 {
148        return ProvMode::PushPull;
149    }
150
151    // Single input: every node's cone includes the one input, so the
152    // pull guard will always see "dirty" and fall through. Pull still
153    // has zero overhead (the AND + branch costs ~2ns), so prefer it
154    // over Raw for multi-output programs where not all outputs are
155    // pulled every cycle.
156    ProvMode::Pull
157}
158
159// ── The engine a host chooses (engines.md §3.5) ──────────
160
161/// How much of a kernel's work is skipped when inputs repeat: the
162/// provenance mode a compiled engine is built with. Every mode computes
163/// the same values; the modes differ in what they recompute.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
165pub enum Provenance {
166    /// Every evaluation runs every step.
167    Raw,
168    /// A changed input reruns only the steps downstream of it.
169    Push,
170    /// An output whose cone no changed input reaches is not recomputed.
171    Pull,
172    /// Both: per-step skipping and the cone guard.
173    PushPull,
174    /// The selector's choice from the graph's shape
175    /// ([`select_prov_mode`]).
176    Auto,
177}
178
179/// The engine a program runs on. Every engine accepts every program the
180/// interpreter accepts, or refuses it with a reason
181/// ([`KernelError::Refused`]); the choice changes how fast the program
182/// runs and nothing else (docs/design/engines.md §7).
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
184pub enum Engine {
185    /// The interpreter, with as much of its graph fused into native
186    /// cones as the [`JitMode`](crate::JitMode) says: what `compile()`
187    /// builds, under the assembler's mode.
188    Interpreter(crate::compile::cone::JitMode),
189    /// The closure tier: every node runs its generated closure over
190    /// one slot buffer.
191    Closures(Provenance),
192    /// Native code where a node has a lowering, its closure elsewhere:
193    /// the P3 tier. Built in every configuration: a build without the
194    /// `jit` feature runs the same kernel with every step a closure and
195    /// no native segment in it, which `plan()` reports.
196    Native(Provenance),
197    /// Native code and nothing else: the differential tier behind
198    /// [`Engine::Native`] (engines.md §8). It differs from `Native` in
199    /// what it does with a node that has no native lowering, which is
200    /// to refuse the program rather than run that node's closure. A
201    /// host asks for it to be told whether its program is fully native,
202    /// which `Native` can never answer because it always succeeds.
203    /// Refused by a build without the `jit` feature.
204    ///
205    /// Only [`Provenance::Raw`] and [`Provenance::PushPull`] have a
206    /// pure kernel; the other named modes are refused, and
207    /// [`Provenance::Auto`] resolves to one of the two.
208    PureNative(Provenance),
209}
210
211impl Default for Engine {
212    /// The engine a host gets when it names none: the fastest this build
213    /// has, P3 with the `jit` feature and the closure tier without, with
214    /// the provenance mode left to the selector. Compiled code is the
215    /// default; the interpreter is a choice.
216    fn default() -> Self {
217        #[cfg(feature = "jit")]
218        {
219            Engine::Native(Provenance::Auto)
220        }
221        #[cfg(not(feature = "jit"))]
222        {
223            Engine::Closures(Provenance::Auto)
224        }
225    }
226}
227
228impl std::fmt::Display for Engine {
229    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        match self {
231            Engine::Interpreter(crate::compile::cone::JitMode::Auto) => write!(f, "interpreter"),
232            Engine::Interpreter(crate::compile::cone::JitMode::Off) => {
233                write!(f, "interpreter (cones off)")
234            }
235            Engine::Interpreter(crate::compile::cone::JitMode::Force) => {
236                write!(f, "interpreter (cones forced)")
237            }
238            Engine::Closures(p) => write!(f, "closures ({p:?})"),
239            Engine::Native(p) => write!(f, "native ({p:?})"),
240            Engine::PureNative(p) => write!(f, "pure native ({p:?})"),
241        }
242    }
243}
244
245/// What a kernel's engine decided for its program: how much of it runs
246/// as native segments, as closure steps, and on the interpreter. The one
247/// planning detail a kernel exposes, on every engine
248/// ([`Kernel::plan`](crate::Kernel::plan)).
249#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
250pub struct EnginePlan {
251    /// Runs of nodes compiled to one native function each; on the
252    /// interpreter, its native cones.
253    pub native_segments: usize,
254    /// Nodes that run their generated closure.
255    pub closure_steps: usize,
256    /// Nodes the interpreter dispatches itself.
257    pub interpreted_nodes: usize,
258}
259
260impl std::fmt::Display for EnginePlan {
261    /// The non-zero counts, native first: `4 native segment(s), 3
262    /// closure step(s)`; `nothing` for an empty program.
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        let mut parts = Vec::new();
265        if self.native_segments > 0 {
266            parts.push(format!("{} native segment(s)", self.native_segments));
267        }
268        if self.closure_steps > 0 {
269            parts.push(format!("{} closure step(s)", self.closure_steps));
270        }
271        if self.interpreted_nodes > 0 {
272            parts.push(format!("{} interpreted node(s)", self.interpreted_nodes));
273        }
274        if parts.is_empty() {
275            write!(f, "nothing")
276        } else {
277            write!(f, "{}", parts.join(", "))
278        }
279    }
280}
281
282/// Why a kernel was not built: the one error type of every constructor
283/// that takes an [`Engine`].
284#[derive(Debug)]
285pub enum KernelError {
286    /// The source did not parse or compile; the message is the DSL
287    /// front end's.
288    Source(String),
289    /// The graph did not assemble.
290    Assembly(crate::compile::assembly::AssemblyError),
291    /// The engine refuses this graph, which the interpreter accepts;
292    /// `reason` names the node or construct.
293    Refused {
294        /// The engine that refused.
295        engine: Engine,
296        /// The node or construct it cannot run.
297        reason: String,
298    },
299    /// A compile-constant step could not be computed. A step no input
300    /// reaches runs once at build, so what it does there it will do on
301    /// every pull; there is nothing a later evaluation could supply
302    /// that would make it succeed. Distinct from [`Self::Refused`],
303    /// which is one engine declining a program the others accept:
304    /// every engine reports this one, and reports it at build.
305    ConstantFold {
306        /// The failure as the node reported it, enriched with the
307        /// node's name, the outputs it feeds, and the program's
308        /// diagnostic context, as an evaluation failure would be.
309        reason: String,
310    },
311    /// A value written into a kernel while building it was refused: an
312    /// iteration binding or a value a binder copied from a parent that
313    /// does not satisfy the child's declared input.
314    Write(crate::kernel::WriteError),
315    /// A `const` binding could not be computed when the kernel was
316    /// initialized. A const is evaluated once, at initialization, so a
317    /// failing const makes initialization fail.
318    ConstInit {
319        /// The const.
320        name: String,
321        /// Why its expression failed, as the node reported it.
322        reason: String,
323    },
324    /// Binding a child under a parent could not join the child
325    /// program's resource scope to the parent's
326    /// ([`ResourceScope::join`](crate::ResourceScope::join)).
327    Resources(crate::resource::ScopeJoinError),
328}
329
330impl std::fmt::Display for KernelError {
331    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332        match self {
333            KernelError::Source(e) => write!(f, "{e}"),
334            KernelError::Assembly(e) => write!(f, "{e}"),
335            KernelError::Refused { engine, reason } => {
336                write!(f, "the {engine} engine refuses this program: {reason}")
337            }
338            KernelError::ConstantFold { reason } => {
339                write!(
340                    f,
341                    "a value this program computes at build could not be computed: {reason}"
342                )
343            }
344            KernelError::Write(e) => write!(f, "a value written while binding was refused: {e}"),
345            KernelError::ConstInit { name, reason } => write!(
346                f,
347                "the const '{name}' could not be computed when the kernel was initialized: {reason}"
348            ),
349            KernelError::Resources(e) => write!(f, "binding under the parent was refused: {e}"),
350        }
351    }
352}
353
354impl std::error::Error for KernelError {}
355
356impl From<crate::compile::assembly::AssemblyError> for KernelError {
357    fn from(e: crate::compile::assembly::AssemblyError) -> Self {
358        // The interpreter's build path speaks `AssemblyError`, so a
359        // fold failure travels as one; it is the same failure the
360        // compiled engines report directly, and reads as the same kind
361        // here.
362        match e {
363            crate::compile::assembly::AssemblyError::ConstantFold(reason) => {
364                KernelError::ConstantFold { reason }
365            }
366            crate::compile::assembly::AssemblyError::ConstInit { name, reason } => {
367                KernelError::ConstInit { name, reason }
368            }
369            other => KernelError::Assembly(other),
370        }
371    }
372}