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