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. Only selected when output cones are large AND
23 /// partially stable.
24 PushPull,
25}
26
27/// Graph analysis results used by the engine selection heuristic.
28#[derive(Debug, Clone)]
29pub struct GraphAnalysis {
30 /// Nodes in the graph.
31 pub total_nodes: usize,
32 /// Inputs.
33 pub num_inputs: usize,
34 /// Named outputs.
35 pub num_outputs: usize,
36 /// Per-output cone size (number of nodes in transitive dependency).
37 pub output_cone_sizes: Vec<(String, usize)>,
38 /// max(cone_size) / total_nodes
39 pub max_cone_ratio: f64,
40 /// Average cone_size / total_nodes
41 pub avg_cone_ratio: f64,
42}
43
44/// Analyze a resolved DAG to compute structural metrics.
45pub fn analyze_graph(
46 nodes: &[Box<dyn PolydatNode>],
47 wiring: &[Vec<WireSource>],
48 output_map: &HashMap<String, (usize, usize)>,
49) -> GraphAnalysis {
50 let total_nodes = nodes.len();
51
52 // Compute per-output cone size: count nodes reachable from each output.
53 // A node is in the cone if its provenance overlaps with the output's.
54 // Actually, we need the transitive upstream set, which is the set of
55 // nodes that can reach this output. We compute this by walking backward
56 // from the output node.
57 let mut output_cone_sizes = Vec::new();
58 for (name, &(node_idx, _port)) in output_map {
59 let cone_size = compute_cone_size(node_idx, wiring);
60 output_cone_sizes.push((name.clone(), cone_size));
61 }
62
63 let max_cone = output_cone_sizes.iter().map(|(_, s)| *s).max().unwrap_or(0);
64 let avg_cone: f64 = if output_cone_sizes.is_empty() {
65 0.0
66 } else {
67 output_cone_sizes
68 .iter()
69 .map(|(_, s)| *s as f64)
70 .sum::<f64>()
71 / output_cone_sizes.len() as f64
72 };
73
74 let max_cone_ratio = if total_nodes > 0 {
75 max_cone as f64 / total_nodes as f64
76 } else {
77 1.0
78 };
79 let avg_cone_ratio = if total_nodes > 0 {
80 avg_cone / total_nodes as f64
81 } else {
82 1.0
83 };
84
85 // Count distinct inputs
86 let mut max_input = 0usize;
87 for sources in wiring {
88 for s in sources {
89 if let WireSource::Input(idx) = s {
90 max_input = max_input.max(*idx + 1);
91 }
92 }
93 }
94
95 GraphAnalysis {
96 total_nodes,
97 num_inputs: max_input,
98 num_outputs: output_map.len(),
99 output_cone_sizes,
100 max_cone_ratio,
101 avg_cone_ratio,
102 }
103}
104
105/// Count the number of nodes in the transitive upstream cone of a node.
106fn compute_cone_size(node_idx: usize, wiring: &[Vec<WireSource>]) -> usize {
107 let mut visited = vec![false; wiring.len()];
108 let mut stack = vec![node_idx];
109 let mut count = 0;
110 while let Some(idx) = stack.pop() {
111 if idx >= visited.len() || visited[idx] {
112 continue;
113 }
114 visited[idx] = true;
115 count += 1;
116 for source in &wiring[idx] {
117 if let WireSource::NodeOutput(upstream, _) = source
118 && !visited[*upstream]
119 {
120 stack.push(*upstream);
121 }
122 }
123 }
124 count
125}
126
127/// Select the optimal provenance mode based on graph analysis.
128///
129/// Heuristic (from benchmark findings in memo 09):
130/// - Pull has zero overhead on all-dirty graphs (cone check ~2ns)
131/// - Pull is the safe default for selective output access
132/// - PushPull when multiple inputs exist (push skip helps within
133/// dirty cones when some subgraphs are stable)
134/// - Raw only for tiny single-input graphs
135pub fn select_prov_mode(analysis: &GraphAnalysis) -> ProvMode {
136 // Tiny single-input graphs: skip provenance data entirely.
137 // The overhead of tracking changed_mask isn't worth it.
138 if analysis.total_nodes < 15 && analysis.num_inputs <= 1 {
139 return ProvMode::Raw;
140 }
141
142 // Multiple inputs: some may be stable at runtime, enabling both
143 // push-side skip (within dirty cones) and pull-side skip (clean cones).
144 // PushPull is the right choice because:
145 // - If an output's cone is clean: pull guard skips eval entirely
146 // - If an output's cone is dirty: push skip avoids stable nodes
147 // The push overhead (~10ns in set_inputs for dependent marking)
148 // is justified by the potential to skip 30-80% of nodes within
149 // dirty cones.
150 if analysis.num_inputs >= 2 {
151 return ProvMode::PushPull;
152 }
153
154 // Single input: every node's cone includes the one input, so the
155 // pull guard will always see "dirty" and fall through. Pull still
156 // has zero overhead (the AND + branch costs ~2ns), so prefer it
157 // over Raw as free insurance for future multi-output scenarios
158 // where not all outputs are pulled every cycle.
159 ProvMode::Pull
160}
161
162// ── The engine a host chooses (engine_parity.md, step 4) ──────────
163
164/// How much of a kernel's work is skipped when inputs repeat: the
165/// provenance mode a compiled engine is built with. Every mode computes
166/// the same values; the modes differ in what they recompute.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
168pub enum Provenance {
169 /// Every evaluation runs every step.
170 Raw,
171 /// A changed input reruns only the steps downstream of it.
172 Push,
173 /// An output whose cone no changed input reaches is not recomputed.
174 Pull,
175 /// Both: per-step skipping and the cone guard.
176 PushPull,
177 /// The selector's choice from the graph's shape
178 /// ([`select_prov_mode`]).
179 Auto,
180}
181
182/// The engine a program runs on. Every engine accepts every program the
183/// interpreter accepts, or refuses it with a reason
184/// ([`KernelError::Refused`]); the choice changes how fast the program
185/// runs and nothing else (docs/design/engine_parity.md).
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
187pub enum Engine {
188 /// The interpreter, with as much of its graph fused into native
189 /// cones as the [`JitMode`](crate::JitMode) says: what `compile()`
190 /// builds, under the assembler's mode.
191 Interpreter(crate::compile::cone::JitMode),
192 /// The closure tier: every node runs its generated closure over
193 /// one slot buffer.
194 Closures(Provenance),
195 /// Native code where a node has a lowering, its closure elsewhere:
196 /// the P3 tier. Refused by a build without the `jit` feature.
197 Native(Provenance),
198}
199
200impl Default for Engine {
201 /// The engine a host gets when it names none: the fastest this build
202 /// has, P3 with the `jit` feature and the closure tier without, with
203 /// the provenance mode left to the selector. Compiled code is the
204 /// default; the interpreter is a choice.
205 fn default() -> Self {
206 #[cfg(feature = "jit")]
207 {
208 Engine::Native(Provenance::Auto)
209 }
210 #[cfg(not(feature = "jit"))]
211 {
212 Engine::Closures(Provenance::Auto)
213 }
214 }
215}
216
217impl std::fmt::Display for Engine {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 match self {
220 Engine::Interpreter(crate::compile::cone::JitMode::Auto) => write!(f, "interpreter"),
221 Engine::Interpreter(crate::compile::cone::JitMode::Off) => {
222 write!(f, "interpreter (cones off)")
223 }
224 Engine::Interpreter(crate::compile::cone::JitMode::Force) => {
225 write!(f, "interpreter (cones forced)")
226 }
227 Engine::Closures(p) => write!(f, "closures ({p:?})"),
228 Engine::Native(p) => write!(f, "native ({p:?})"),
229 }
230 }
231}
232
233/// What a kernel's engine decided for its program: how much of it runs
234/// as native segments, as closure steps, and on the interpreter. The one
235/// planning detail a kernel exposes, on every engine
236/// ([`Kernel::plan`](crate::Kernel::plan)).
237#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
238pub struct EnginePlan {
239 /// Runs of nodes compiled to one native function each; on the
240 /// interpreter, its native cones.
241 pub native_segments: usize,
242 /// Nodes that run their generated closure.
243 pub closure_steps: usize,
244 /// Nodes the interpreter dispatches itself.
245 pub interpreted_nodes: usize,
246}
247
248impl std::fmt::Display for EnginePlan {
249 /// The non-zero counts, native first: `4 native segment(s), 3
250 /// closure step(s)`; `nothing` for an empty program.
251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 let mut parts = Vec::new();
253 if self.native_segments > 0 {
254 parts.push(format!("{} native segment(s)", self.native_segments));
255 }
256 if self.closure_steps > 0 {
257 parts.push(format!("{} closure step(s)", self.closure_steps));
258 }
259 if self.interpreted_nodes > 0 {
260 parts.push(format!("{} interpreted node(s)", self.interpreted_nodes));
261 }
262 if parts.is_empty() {
263 write!(f, "nothing")
264 } else {
265 write!(f, "{}", parts.join(", "))
266 }
267 }
268}
269
270/// Why a kernel was not built: the one error type of every constructor
271/// that takes an [`Engine`].
272#[derive(Debug)]
273pub enum KernelError {
274 /// The source did not parse or compile; the message is the DSL
275 /// front end's.
276 Source(String),
277 /// The graph did not assemble.
278 Assembly(crate::compile::assembly::AssemblyError),
279 /// The engine refuses this graph, which the interpreter accepts;
280 /// `reason` names the node or construct.
281 Refused {
282 /// The engine that refused.
283 engine: Engine,
284 /// The node or construct it cannot run.
285 reason: String,
286 },
287}
288
289impl std::fmt::Display for KernelError {
290 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291 match self {
292 KernelError::Source(e) => write!(f, "{e}"),
293 KernelError::Assembly(e) => write!(f, "{e}"),
294 KernelError::Refused { engine, reason } => {
295 write!(f, "the {engine} engine refuses this program: {reason}")
296 }
297 }
298 }
299}
300
301impl std::error::Error for KernelError {}
302
303impl From<crate::compile::assembly::AssemblyError> for KernelError {
304 fn from(e: crate::compile::assembly::AssemblyError) -> Self {
305 KernelError::Assembly(e)
306 }
307}