Skip to main content

ginger/
execution_policy.rs

1//! Execution policies for parallelizable root-finding solvers.
2//!
3//! Strategy + Template-Method decomposition:
4//!
5//! - Each algorithm supplies a per-root *step* (the `Step` trait) that computes one
6//!   Newton correction for a root/factor. The step reads the current values of
7//!   the other roots through a `get` closure provided by the policy, and
8//!   returns the corrected value; the policy owns all writes to its data
9//!   layout (live state, frozen snapshot, or seqlock buffer).
10//! - Each execution policy owns the iteration loop, the scheduling of the
11//!   steps, and the convergence aggregation.
12//!
13//! This mirrors `execution_policy.hpp` in ginger-cpp and `_policy.py` in
14//! ginger (Python): the algorithm (Aberth/Bairstow variant) is decoupled from
15//! the execution mode (sequential Gauss-Seidel, Jacobi-MT, or atomic
16//! decoupled).
17
18use crate::seqlock::{AtomicComplex, AtomicVec2};
19use crate::Options;
20use num_complex::Complex;
21use rayon::prelude::*;
22use std::sync::Mutex;
23
24/// Number of roots above which the multi-threaded policies are used by default.
25pub const PARALLEL_THRESHOLD: usize = 4;
26
27/// Whether `num_roots` should use the multi-threaded execution policy.
28#[inline]
29pub fn should_parallelize(num_roots: usize) -> bool {
30    num_roots > PARALLEL_THRESHOLD
31}
32
33/// Atomic cell abstraction over a pair of `f64` values (seqlock-backed).
34///
35/// Implemented by [`AtomicComplex`] and [`AtomicVec2`] so the atomic policy can
36/// be generic over the state element type.
37pub trait AtomicCell<T>: Sync {
38    /// Create a cell initialized with `value`.
39    fn new(value: T) -> Self;
40    /// Torn-free read of the current value.
41    fn load(&self) -> T;
42    /// Store a new value; single-writer per slot.
43    fn store(&self, value: T);
44}
45
46impl AtomicCell<Complex<f64>> for AtomicComplex {
47    #[inline]
48    fn new(value: Complex<f64>) -> Self {
49        AtomicComplex::new(value)
50    }
51    #[inline]
52    fn load(&self) -> Complex<f64> {
53        self.load()
54    }
55    #[inline]
56    fn store(&self, value: Complex<f64>) {
57        self.store(value);
58    }
59}
60
61impl AtomicCell<Vector2F64> for AtomicVec2 {
62    #[inline]
63    fn new(value: Vector2F64) -> Self {
64        AtomicVec2::new(value)
65    }
66    #[inline]
67    fn load(&self) -> Vector2F64 {
68        self.load()
69    }
70    #[inline]
71    fn store(&self, value: Vector2F64) {
72        self.store(value);
73    }
74}
75
76type Vector2F64 = crate::vector2::Vector2<f64>;
77
78/// A per-root Newton correction step shared by every execution policy.
79///
80/// `run(idx, get, neighbors)` computes one correction for root/factor `idx`:
81///
82/// - `get(j)` reads the current value of root/factor `j` from the policy's
83///   data layout (live state, frozen snapshot, or seqlock buffer).
84/// - `neighbors` enumerates the indices of the other roots/factors to
85///   suppress (ascending order, or round-robin for the atomic variant).
86///
87/// Returns `(tol, Some(new_value))` when the root was updated, or
88/// `(0.0, None)` when it is already converged (below `options.tol_ind`).
89///
90/// This mirrors the per-algorithm step functors (`even_bairstow_step`,
91/// `autocorr_bairstow_step`, `aberth_step`, ...) in ginger-cpp's
92/// `execution_policy.hpp`.
93pub trait Step<T>: Sync {
94    /// Atomic cell type used by the decoupled atomic policy for this step.
95    type Cell: AtomicCell<T>;
96
97    /// Compute one Newton correction for root/factor `idx`.
98    fn run<G, N>(&self, idx: usize, get: G, neighbors: N) -> (f64, Option<T>)
99    where
100        G: Fn(usize) -> T,
101        N: Iterator<Item = usize>;
102}
103
104/// Sequential Gauss-Seidel execution: roots are updated in-place in ascending
105/// index order within each iteration (later roots see earlier updates).
106///
107/// Converged roots contribute a zero tolerance and are not written; their
108/// values stay frozen as neighbors for the remaining sweeps.
109pub fn sequential_run<T, S>(state: &mut [T], options: &Options, step: &S) -> (usize, bool)
110where
111    T: Copy,
112    S: Step<T>,
113{
114    let m = state.len();
115    for niter in 0..options.max_iters {
116        let mut tolerance: f64 = 0.0;
117        for idx in 0..m {
118            let (tol_i, new_value) = step.run(idx, |j| state[j], (0..m).filter(move |&j| j != idx));
119            tolerance = tolerance.max(tol_i);
120            if let Some(value) = new_value {
121                state[idx] = value;
122            }
123        }
124        if tolerance < options.tolerance {
125            return (niter, true);
126        }
127    }
128    (options.max_iters, false)
129}
130
131/// Jacobi multi-threaded execution: each iteration reads a frozen snapshot of
132/// the roots and updates them in parallel, so the iteration is
133/// order-independent. Small problems (<= 4 roots) fall back to the sequential
134/// policy.
135pub fn jacobi_mt_run<T, S>(state: &mut [T], options: &Options, step: &S) -> (usize, bool)
136where
137    T: Copy + Default + Send + Sync,
138    S: Step<T> + Sync,
139{
140    if !should_parallelize(state.len()) {
141        return sequential_run(state, options, step);
142    }
143    let m = state.len();
144    let mut snapshot = vec![T::default(); m];
145    for niter in 0..options.max_iters {
146        snapshot.copy_from_slice(state);
147
148        let updates: Vec<(f64, Option<T>)> = (0..m)
149            .into_par_iter()
150            .map(|idx| step.run(idx, |j| snapshot[j], (0..m).filter(move |&j| j != idx)))
151            .collect();
152
153        let mut tolerance: f64 = 0.0;
154        for (idx, (tol_i, new_value)) in updates.into_iter().enumerate() {
155            tolerance = tolerance.max(tol_i);
156            if let Some(value) = new_value {
157                state[idx] = value;
158            }
159        }
160        if tolerance < options.tolerance {
161            return (niter, true);
162        }
163    }
164    (options.max_iters, false)
165}
166
167/// Atomic decoupled execution: a single seqlock buffer is built once; each
168/// thread owns a chunk of slots (single-writer, multi-reader) and runs its own
169/// iteration loop independently with no per-iteration barrier, reading the
170/// other slots in round-robin order to reduce contention. The returned
171/// iteration count is the maximum across threads and is non-deterministic.
172pub fn atomic_decoupled_run<T, S>(state: &mut [T], options: &Options, step: &S) -> (usize, bool)
173where
174    T: Copy + Send + Sync,
175    S: Step<T>,
176{
177    let num_roots = state.len();
178    let buffer: Vec<S::Cell> = state.iter().copied().map(S::Cell::new).collect();
179    let buffer_ref = &buffer;
180
181    let use_mt = should_parallelize(num_roots);
182    let num_threads = if use_mt {
183        rayon::current_num_threads().min(num_roots)
184    } else {
185        1
186    };
187    let chunk_size = (num_roots + num_threads - 1) / num_threads;
188
189    let results: Mutex<Vec<(usize, bool)>> = Mutex::new(Vec::new());
190    let results_ref = &results;
191    rayon::scope(|s| {
192        for t in 0..num_threads {
193            let start = t * chunk_size;
194            let end = (start + chunk_size).min(num_roots);
195            if start >= end {
196                break;
197            }
198            s.spawn(move |_| {
199                let mut niter = 0;
200                loop {
201                    if niter == options.max_iters {
202                        results_ref.lock().unwrap().push((options.max_iters, false));
203                        return;
204                    }
205                    let mut max_tol: f64 = 0.0;
206                    for idx in start..end {
207                        // Round-robin suppression order: each thread reads the
208                        // other slots in a different rotation, reducing
209                        // concurrent access to the same slot.
210                        let neighbors = (0..num_roots - 1).map(move |k| (idx + k + 1) % num_roots);
211                        let (tol_i, new_value) = step.run(idx, |j| buffer_ref[j].load(), neighbors);
212                        max_tol = max_tol.max(tol_i);
213                        if let Some(value) = new_value {
214                            buffer_ref[idx].store(value);
215                        }
216                    }
217                    if max_tol < options.tolerance {
218                        results_ref.lock().unwrap().push((niter, true));
219                        return;
220                    }
221                    niter += 1;
222                }
223            });
224        }
225    });
226
227    let mut niter_max = 0;
228    let mut all_converged = true;
229    for (niter, converged) in results.into_inner().unwrap() {
230        niter_max = niter_max.max(niter);
231        all_converged &= converged;
232    }
233
234    for (i, z) in state.iter_mut().enumerate() {
235        *z = buffer[i].load();
236    }
237    (niter_max, all_converged)
238}