Skip to main content

rssn_advanced/parallel/
solver.rs

1//! Async parallel solver with work distribution.
2//!
3//! Per `parallel_review §2` / `summary_review §2`, the previous
4//! implementation cloned the entire `DagArena` once per worker chunk
5//! and spun an OS thread per chunk via `std::thread::spawn`. For a
6//! million-node DAG split into 16 chunks that's 16 × ~88 B × 1 M ≈
7//! 1.4 GB of redundant allocations and 16 native thread spawns.
8//!
9//! The rewrite:
10//!
11//! 1. Arena is shared read-only via `Arc<DagArena>` — **one** Arc bump
12//!    per fan-out instead of N clones.
13//! 2. Tasks dispatch through [`crate::runtime::parallel_for_each`],
14//!    which uses `dtact` fibers (`plan.md §4.3`).
15//! 3. [`evaluate_node`] is now iterative (worklist + value stack)
16//!    instead of recursive — no more stack-overflow risk on deep
17//!    expressions.
18
19use std::cmp::Ordering;
20use std::sync::Arc;
21
22use crate::dag::arena::DagArena;
23use crate::dag::node::DagNodeId;
24use crate::dag::symbol::{OpKind, SymbolKind};
25use crate::runtime::{ensure_runtime, parallel_for_each};
26
27/// Registry of function-pointer callbacks for parallel evaluation of
28/// `SymbolKind::Function` nodes.
29///
30/// Keys are [`crate::dag::symbol::FnId`] values. The registered function
31/// receives a pointer to the variable array (same layout as JIT functions)
32/// and returns an `f64`.
33///
34/// Pass a `&FnEvalRegistry` to [`evaluate_node_with_fns`] to enable
35/// function-node evaluation in the parallel path.
36#[derive(Default)]
37pub struct FnEvalRegistry {
38    fns: std::collections::HashMap<u32, fn(*const f64) -> f64>,
39}
40
41impl FnEvalRegistry {
42    /// Creates an empty registry.
43    #[must_use]
44    pub fn new() -> Self {
45        Self {
46            fns: std::collections::HashMap::new(),
47        }
48    }
49
50    /// Registers a function callback for `fn_id`.
51    pub fn register(&mut self, fn_id: crate::dag::symbol::FnId, f: fn(*const f64) -> f64) {
52        self.fns.insert(fn_id.0, f);
53    }
54
55    /// Looks up the callback for `fn_id`.
56    #[must_use]
57    pub fn get(&self, fn_id: crate::dag::symbol::FnId) -> Option<fn(*const f64) -> f64> {
58        self.fns.get(&fn_id.0).copied()
59    }
60}
61
62/// Registry of operator-evaluation overrides for the parallel evaluator.
63///
64/// Overrides are keyed by `OpKind` discriminant. When a registered override
65/// exists for an op, it is called instead of `apply_op`.
66#[derive(Default)]
67pub struct OpEvalRegistry {
68    overrides: std::collections::HashMap<u8, fn(&[f64]) -> f64>,
69}
70
71impl OpEvalRegistry {
72    /// Creates an empty registry.
73    #[must_use]
74    pub fn new() -> Self {
75        Self {
76            overrides: std::collections::HashMap::new(),
77        }
78    }
79
80    /// Registers a custom evaluation function for `op`.
81    pub fn register(&mut self, op: crate::dag::symbol::OpKind, f: fn(&[f64]) -> f64) {
82        self.overrides.insert(op as u8, f);
83    }
84
85    /// Looks up an override for `op`, if any.
86    #[must_use]
87    pub fn get(&self, op: crate::dag::symbol::OpKind) -> Option<fn(&[f64]) -> f64> {
88        self.overrides.get(&(op as u8)).copied()
89    }
90}
91
92/// Variant of [`evaluate_node_with_fns`] that also accepts an [`OpEvalRegistry`]
93/// for overriding built-in operator evaluation.
94///
95/// When an operator has a registered override, it is called instead of
96/// `apply_op`. Unregistered operators use the default `apply_op` logic.
97#[must_use]
98pub fn evaluate_node_with_overrides(
99    arena: &DagArena,
100    id: DagNodeId,
101    vars: &[f64],
102    fn_registry: &FnEvalRegistry,
103    op_registry: &OpEvalRegistry,
104) -> f64 {
105    if id.is_none() {
106        return 0.0;
107    }
108
109    let mut stack: Vec<Frame> = Vec::with_capacity(64);
110    let mut values: Vec<f64> = Vec::with_capacity(64);
111
112    let root_arity = arena.get(id).map_or(0, |n| n.children.len());
113    stack.push(Frame {
114        id,
115        arity: root_arity,
116        cursor: 0,
117    });
118
119    while let Some(top) = stack.last_mut() {
120        let next_child: Option<DagNodeId> = arena.get(top.id).and_then(|node| {
121            let kids = node.children.as_slice();
122            kids.get(top.cursor).copied()
123        });
124
125        if let Some(child_id) = next_child {
126            top.cursor += 1;
127            let child_arity = arena.get(child_id).map_or(0, |c| c.children.len());
128            stack.push(Frame {
129                id: child_id,
130                arity: child_arity,
131                cursor: 0,
132            });
133        } else {
134            let Some(frame) = stack.pop() else { break };
135            let v = reduce_frame_with_overrides(
136                arena,
137                frame.id,
138                frame.arity,
139                &mut values,
140                vars,
141                fn_registry,
142                op_registry,
143            );
144            values.push(v);
145        }
146    }
147
148    values.pop().unwrap_or(0.0)
149}
150
151fn reduce_frame_with_overrides(
152    arena: &DagArena,
153    id: DagNodeId,
154    arity: usize,
155    values: &mut Vec<f64>,
156    vars: &[f64],
157    fn_registry: &FnEvalRegistry,
158    op_registry: &OpEvalRegistry,
159) -> f64 {
160    let Some(node) = arena.get(id) else {
161        values.truncate(values.len().saturating_sub(arity));
162        return 0.0;
163    };
164
165    match node.kind {
166        SymbolKind::Constant(v) => v,
167        SymbolKind::Variable(sym_id) => vars.get(sym_id.0 as usize).copied().unwrap_or(0.0),
168        SymbolKind::Function(fn_id) => {
169            values.truncate(values.len().saturating_sub(arity));
170            fn_registry.get(fn_id).map_or(0.0, |f| f(vars.as_ptr()))
171        }
172        SymbolKind::Operator(op) => {
173            let split_at = values.len().saturating_sub(arity);
174            let result = op_registry.get(op).map_or_else(
175                || apply_op(op, &values[split_at..]),
176                |override_fn| override_fn(&values[split_at..]),
177            );
178            values.truncate(split_at);
179            result
180        }
181        SymbolKind::ControlFlow(ctrl) => {
182            use crate::dag::symbol::CtrlKind;
183            let split_at = values.len().saturating_sub(arity);
184            let child_vals = &values[split_at..];
185            let result = match ctrl {
186                CtrlKind::Select | CtrlKind::IfElse => {
187                    if child_vals.len() == 3 {
188                        let cond = child_vals[0];
189                        let then_val = child_vals[1];
190                        let else_val = child_vals[2];
191                        if cond == 0.0 { else_val } else { then_val }
192                    } else {
193                        0.0
194                    }
195                }
196                CtrlKind::ForLoop => {
197                    if child_vals.len() == 4 {
198                        let init_val = child_vals[0];
199                        let limit_val = child_vals[1];
200                        let step_val = child_vals[2];
201                        let body_val = child_vals[3];
202                        let mut acc = init_val;
203                        let mut idx = 0.0;
204                        while idx.partial_cmp(&limit_val) == Some(Ordering::Less) {
205                            acc += body_val;
206                            idx += step_val;
207                        }
208                        acc
209                    } else {
210                        0.0
211                    }
212                }
213            };
214            values.truncate(split_at);
215            result
216        }
217    }
218}
219
220/// Solves and evaluates a set of expression-leaf chunks in parallel.
221///
222/// Convenience wrapper: if you already hold an `Arc<DagArena>`, call
223/// [`parallel_evaluate_shared`] directly to avoid even the one-shot
224/// `arena.clone()` here. For callers that only have a borrow, this
225/// wraps once and dispatches.
226#[must_use]
227pub fn parallel_evaluate(arena: &DagArena, chunks: Vec<Vec<DagNodeId>>, variables: &[f64]) -> f64 {
228    let arc = Arc::new(arena.clone());
229    parallel_evaluate_shared(&arc, chunks, variables)
230}
231
232/// Zero-clone parallel evaluator. The arena is shared read-only across
233/// all worker fibers via `Arc`; no per-chunk duplication.
234///
235/// Takes `&Arc<DagArena>` so callers that already hold a long-lived
236/// handle don't pay an `Arc::clone` for the outer call (the inner
237/// task spawn still bumps the refcount once per chunk).
238#[must_use]
239pub fn parallel_evaluate_shared(
240    arena: &Arc<DagArena>,
241    chunks: Vec<Vec<DagNodeId>>,
242    variables: &[f64],
243) -> f64 {
244    if chunks.is_empty() {
245        return 0.0;
246    }
247
248    let gate = ensure_runtime();
249    let vars_arc: Arc<Vec<f64>> = Arc::new(variables.to_vec());
250
251    let tasks: Vec<_> = chunks
252        .into_iter()
253        .map(|chunk| {
254            let arena_local = Arc::clone(arena);
255            let vars_local = Arc::clone(&vars_arc);
256            move || -> f64 {
257                let mut sum = 0.0;
258                for id in chunk {
259                    sum += evaluate_node(&arena_local, id, &vars_local);
260                }
261                sum
262            }
263        })
264        .collect();
265
266    let partials = parallel_for_each(gate, tasks);
267    partials.into_iter().flatten().sum()
268}
269
270// =========================================================================
271// Iterative single-node evaluator
272// =========================================================================
273
274/// Evaluates a single DAG node against the variable bindings, iteratively.
275///
276/// Uses an explicit worklist + value-stack pattern (mirroring the JIT
277/// iterative codegen in Phase 2). Depth is limited by heap, not by OS
278/// stack, so a million-deep expression no longer overflows.
279///
280/// Panic-free: if the arena is corrupt the function returns whatever
281/// partial result it has built so far (typically `0.0`).
282#[must_use]
283pub fn evaluate_node(arena: &DagArena, id: DagNodeId, vars: &[f64]) -> f64 {
284    if id.is_none() {
285        return 0.0;
286    }
287
288    let mut stack: Vec<Frame> = Vec::with_capacity(64);
289    let mut values: Vec<f64> = Vec::with_capacity(64);
290
291    let root_arity = arena.get(id).map_or(0, |n| n.children.len());
292    stack.push(Frame {
293        id,
294        arity: root_arity,
295        cursor: 0,
296    });
297
298    while let Some(top) = stack.last_mut() {
299        // Pull the next child id, if any, before any &mut self mutation.
300        let next_child: Option<DagNodeId> = arena.get(top.id).and_then(|node| {
301            let kids = node.children.as_slice();
302            kids.get(top.cursor).copied()
303        });
304
305        if let Some(child_id) = next_child {
306            top.cursor += 1;
307            let child_arity = arena.get(child_id).map_or(0, |c| c.children.len());
308            stack.push(Frame {
309                id: child_id,
310                arity: child_arity,
311                cursor: 0,
312            });
313        } else {
314            // All children evaluated → reduce this frame. The `Some`
315            // here is guaranteed by the outer `while let Some(top)`
316            // we just exited; if it somehow isn't, we treat it as
317            // "no more work" and return whatever we have on the
318            // value stack.
319            let Some(frame) = stack.pop() else { break };
320            let v = reduce_frame(arena, frame.id, frame.arity, &mut values, vars);
321            values.push(v);
322        }
323    }
324
325    values.pop().unwrap_or(0.0)
326}
327
328struct Frame {
329    id: DagNodeId,
330    arity: usize,
331    cursor: usize,
332}
333
334fn reduce_frame(
335    arena: &DagArena,
336    id: DagNodeId,
337    arity: usize,
338    values: &mut Vec<f64>,
339    vars: &[f64],
340) -> f64 {
341    let Some(node) = arena.get(id) else {
342        // Drop any spurious children we might have pushed.
343        values.truncate(values.len().saturating_sub(arity));
344        return 0.0;
345    };
346
347    match node.kind {
348        SymbolKind::Constant(v) => v,
349        SymbolKind::Variable(sym_id) => vars.get(sym_id.0 as usize).copied().unwrap_or(0.0),
350        SymbolKind::Function(_) => {
351            // Custom functions aren't lowered here; consume their args.
352            values.truncate(values.len().saturating_sub(arity));
353            0.0
354        }
355        SymbolKind::Operator(op) => {
356            let split_at = values.len().saturating_sub(arity);
357            // Borrow as slice to avoid a per-node Vec allocation. NLL
358            // guarantees the immutable borrow ends before `truncate`.
359            let result = apply_op(op, &values[split_at..]);
360            values.truncate(split_at);
361            result
362        }
363        SymbolKind::ControlFlow(ctrl) => {
364            use crate::dag::symbol::CtrlKind;
365            let split_at = values.len().saturating_sub(arity);
366            let child_vals = &values[split_at..];
367            let result = match ctrl {
368                CtrlKind::Select | CtrlKind::IfElse => {
369                    if child_vals.len() == 3 {
370                        let cond = child_vals[0];
371                        let then_val = child_vals[1];
372                        let else_val = child_vals[2];
373                        if cond == 0.0 { else_val } else { then_val }
374                    } else {
375                        0.0
376                    }
377                }
378                CtrlKind::ForLoop => {
379                    if child_vals.len() == 4 {
380                        let init_val = child_vals[0];
381                        let limit_val = child_vals[1];
382                        let step_val = child_vals[2];
383                        let body_val = child_vals[3];
384                        let mut acc = init_val;
385                        let mut idx = 0.0;
386                        while idx.partial_cmp(&limit_val) == Some(Ordering::Less) {
387                            acc += body_val;
388                            idx += step_val;
389                        }
390                        acc
391                    } else {
392                        0.0
393                    }
394                }
395            };
396            values.truncate(split_at);
397            result
398        }
399    }
400}
401
402/// Variant of [`evaluate_node`] that supports `SymbolKind::Function` nodes
403/// via a user-supplied [`FnEvalRegistry`].
404///
405/// When a `Function` node is encountered the registry is consulted; if a
406/// matching callback is found it is called with the variable pointer.
407/// Unregistered functions fall back to `0.0` (consistent with the JIT
408/// behaviour for unresolved symbols).
409#[must_use]
410pub fn evaluate_node_with_fns(
411    arena: &DagArena,
412    id: DagNodeId,
413    vars: &[f64],
414    registry: &FnEvalRegistry,
415) -> f64 {
416    if id.is_none() {
417        return 0.0;
418    }
419
420    let mut stack: Vec<Frame> = Vec::with_capacity(64);
421    let mut values: Vec<f64> = Vec::with_capacity(64);
422
423    let root_arity = arena.get(id).map_or(0, |n| n.children.len());
424    stack.push(Frame {
425        id,
426        arity: root_arity,
427        cursor: 0,
428    });
429
430    while let Some(top) = stack.last_mut() {
431        let next_child: Option<DagNodeId> = arena.get(top.id).and_then(|node| {
432            let kids = node.children.as_slice();
433            kids.get(top.cursor).copied()
434        });
435
436        if let Some(child_id) = next_child {
437            top.cursor += 1;
438            let child_arity = arena.get(child_id).map_or(0, |c| c.children.len());
439            stack.push(Frame {
440                id: child_id,
441                arity: child_arity,
442                cursor: 0,
443            });
444        } else {
445            let Some(frame) = stack.pop() else { break };
446            let v =
447                reduce_frame_with_fns(arena, frame.id, frame.arity, &mut values, vars, registry);
448            values.push(v);
449        }
450    }
451
452    values.pop().unwrap_or(0.0)
453}
454
455fn reduce_frame_with_fns(
456    arena: &DagArena,
457    id: DagNodeId,
458    arity: usize,
459    values: &mut Vec<f64>,
460    vars: &[f64],
461    registry: &FnEvalRegistry,
462) -> f64 {
463    let Some(node) = arena.get(id) else {
464        values.truncate(values.len().saturating_sub(arity));
465        return 0.0;
466    };
467
468    match node.kind {
469        SymbolKind::Constant(v) => v,
470        SymbolKind::Variable(sym_id) => vars.get(sym_id.0 as usize).copied().unwrap_or(0.0),
471        SymbolKind::Function(fn_id) => {
472            values.truncate(values.len().saturating_sub(arity));
473            // Invoke the registered callback with the variable pointer.
474            registry.get(fn_id).map_or(0.0, |f| f(vars.as_ptr()))
475        }
476        SymbolKind::Operator(op) => {
477            let split_at = values.len().saturating_sub(arity);
478            let result = apply_op(op, &values[split_at..]);
479            values.truncate(split_at);
480            result
481        }
482        SymbolKind::ControlFlow(ctrl) => {
483            use crate::dag::symbol::CtrlKind;
484            let split_at = values.len().saturating_sub(arity);
485            let child_vals = &values[split_at..];
486            let result = match ctrl {
487                CtrlKind::Select | CtrlKind::IfElse => {
488                    if child_vals.len() == 3 {
489                        let cond = child_vals[0];
490                        let then_val = child_vals[1];
491                        let else_val = child_vals[2];
492                        if cond == 0.0 { else_val } else { then_val }
493                    } else {
494                        0.0
495                    }
496                }
497                CtrlKind::ForLoop => {
498                    if child_vals.len() == 4 {
499                        let init_val = child_vals[0];
500                        let limit_val = child_vals[1];
501                        let step_val = child_vals[2];
502                        let body_val = child_vals[3];
503                        let mut acc = init_val;
504                        let mut idx = 0.0;
505                        while idx.partial_cmp(&limit_val) == Some(Ordering::Less) {
506                            acc += body_val;
507                            idx += step_val;
508                        }
509                        acc
510                    } else {
511                        0.0
512                    }
513                }
514            };
515            values.truncate(split_at);
516            result
517        }
518    }
519}
520
521fn apply_op(op: OpKind, child_vals: &[f64]) -> f64 {
522    match op {
523        OpKind::Add => child_vals.iter().sum(),
524        OpKind::Sub => {
525            let lhs = child_vals.first().copied().unwrap_or(0.0);
526            let rhs = child_vals.get(1).copied().unwrap_or(0.0);
527            lhs - rhs
528        }
529        OpKind::Mul => child_vals.iter().product(),
530        OpKind::Div => {
531            let lhs = child_vals.first().copied().unwrap_or(0.0);
532            let rhs = child_vals.get(1).copied().unwrap_or(1.0);
533            // Return NaN on exact zero — consistent with IEEE-754 and the
534            // JIT path. Returning 0.0 or using EPSILON threshold masks bugs.
535            if rhs == 0.0 { f64::NAN } else { lhs / rhs }
536        }
537        OpKind::Mod => {
538            let lhs = child_vals.first().copied().unwrap_or(0.0);
539            let rhs = child_vals.get(1).copied().unwrap_or(1.0);
540            if rhs == 0.0 { f64::NAN } else { lhs % rhs }
541        }
542        OpKind::Pow => {
543            let base = child_vals.first().copied().unwrap_or(0.0);
544            let exp = child_vals.get(1).copied().unwrap_or(0.0);
545            base.powf(exp)
546        }
547        OpKind::Neg => -child_vals.first().copied().unwrap_or(0.0),
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use crate::dag::builder::DagBuilder;
555
556    #[test]
557    fn iterative_eval_matches_simple_expr() {
558        // 3*x + y with x=2, y=4 should be 10.
559        let mut b = DagBuilder::new();
560        let x = b.variable("x");
561        let y = b.variable("y");
562        let three = b.constant(3.0);
563        let mul = b.mul(three, x);
564        let add = b.add(mul, y);
565        let val = evaluate_node(b.arena(), add, &[2.0, 4.0]);
566        assert!((val - 10.0).abs() < f64::EPSILON);
567    }
568
569    #[test]
570    fn iterative_eval_handles_deep_chain() {
571        // ((x+x)+x)+x... 5000 deep should never blow the stack.
572        let mut b = DagBuilder::new();
573        let x = b.variable("x");
574        let mut acc = x;
575        for _ in 0..5000 {
576            acc = b.add(acc, x);
577        }
578        let val = evaluate_node(b.arena(), acc, &[1.0]);
579        // Each `add` contributes one extra +x.
580        assert!((val - 5001.0).abs() < f64::EPSILON);
581    }
582
583    #[test]
584    fn parallel_evaluate_sums_chunks() {
585        let mut b = DagBuilder::new();
586        let x = b.variable("x");
587        let chunks = vec![vec![x, x], vec![x], vec![x, x]];
588        // 5 references to x; with x=2.0 each chunk sums = 4 + 2 + 4 = 10.
589        let total = parallel_evaluate(b.arena(), chunks, &[2.0]);
590        assert!((total - 10.0).abs() < f64::EPSILON);
591    }
592}