rssn_advanced/parallel/simplify.rs
1//! Staged global simplification.
2//!
3//! `plan.md §4.1` calls for "stage-wise simplification" — running a
4//! lightweight local simplifier every N rounds during a long-running
5//! computation rather than only at the end. Per `parallel_review §1`
6//! the `SimplifyConfig` knob existed but had no implementation;
7//! [`run_staged_simplification`] supplies it.
8//!
9//! The loop is intentionally simple:
10//!
11//! 1. Caller passes a `DagBuilder` and a root `DagNodeId`.
12//! 2. We invoke the heuristic engine once per round, threading the
13//! rewritten root through.
14//! 3. Each round goes through the full dedup-aware engine, so even
15//! aggressive multi-round configurations cannot leak duplicates.
16
17use std::sync::atomic::{AtomicU64, Ordering};
18
19use crate::dag::builder::DagBuilder;
20use crate::dag::node::DagNodeId;
21use crate::heuristic::{HeuristicConfig, HeuristicEngine, SearchStrategy};
22
23/// Staged simplification configuration parameters.
24#[derive(Debug, Clone, Copy)]
25pub struct SimplifyConfig {
26 /// Number of intermediate local simplification rounds to perform.
27 pub intermediate_rounds: usize,
28 /// Whether to trigger global deduplication and constant folding.
29 pub enable_global_dedup: bool,
30}
31
32impl Default for SimplifyConfig {
33 fn default() -> Self {
34 Self {
35 intermediate_rounds: 3,
36 enable_global_dedup: true,
37 }
38 }
39}
40
41impl SimplifyConfig {
42 /// Creates a new configuration builder.
43 #[must_use]
44 pub const fn new() -> Self {
45 Self {
46 intermediate_rounds: 0,
47 enable_global_dedup: true,
48 }
49 }
50
51 /// Sets the number of intermediate local simplification rounds.
52 #[must_use]
53 pub const fn intermediate_rounds(mut self, rounds: usize) -> Self {
54 self.intermediate_rounds = rounds;
55 self
56 }
57}
58
59/// Per-thread telemetry for staged simplification.
60///
61/// Tracks three per-thread metrics (`parallel_review §3.1`):
62/// - `steps_count` — total simplification rounds performed by this thread
63/// - `nodes_visited` — total DAG nodes inspected during traversal
64/// - `rewrites_applied` — total rewrite rules that fired (pattern matches)
65///
66/// The 128-byte `repr(align)` was removed: cache-line padding guards against
67/// false-sharing only when *different threads* write to *adjacent* memory. A
68/// `thread_local!` value lives in per-thread storage — no other thread can
69/// touch it — so over-alignment is pure waste.
70#[derive(Debug)]
71pub struct ThreadLocalState {
72 /// Total simplification steps (one per [`run_staged_simplification`] round).
73 pub steps_count: AtomicU64,
74 /// Total DAG nodes visited across all steps. Useful for throughput profiling.
75 pub nodes_visited: AtomicU64,
76 /// Total rewrite rules that fired. Non-zero means the expression changed.
77 pub rewrites_applied: AtomicU64,
78}
79
80impl Default for ThreadLocalState {
81 fn default() -> Self {
82 Self {
83 steps_count: AtomicU64::new(0),
84 nodes_visited: AtomicU64::new(0),
85 rewrites_applied: AtomicU64::new(0),
86 }
87 }
88}
89
90impl ThreadLocalState {
91 /// Creates a new instance with all counters at zero.
92 #[must_use]
93 pub const fn new() -> Self {
94 Self {
95 steps_count: AtomicU64::new(0),
96 nodes_visited: AtomicU64::new(0),
97 rewrites_applied: AtomicU64::new(0),
98 }
99 }
100
101 /// Increments the simplification-step counter.
102 pub fn increment(&self) {
103 self.steps_count.fetch_add(1, Ordering::Release);
104 }
105
106 /// Retrieves the simplification-step count.
107 #[must_use]
108 pub fn get_count(&self) -> u64 {
109 self.steps_count.load(Ordering::Acquire)
110 }
111
112 /// Records that `n` additional DAG nodes were visited.
113 pub fn record_nodes_visited(&self, n: u64) {
114 self.nodes_visited.fetch_add(n, Ordering::Relaxed);
115 }
116
117 /// Records that `n` rewrite rules fired.
118 pub fn record_rewrites(&self, n: u64) {
119 self.rewrites_applied.fetch_add(n, Ordering::Relaxed);
120 }
121
122 /// Returns the total number of nodes visited.
123 #[must_use]
124 pub fn get_nodes_visited(&self) -> u64 {
125 self.nodes_visited.load(Ordering::Relaxed)
126 }
127
128 /// Returns the total number of rewrites applied.
129 #[must_use]
130 pub fn get_rewrites_applied(&self) -> u64 {
131 self.rewrites_applied.load(Ordering::Relaxed)
132 }
133
134 /// Resets all counters to zero.
135 pub fn reset(&self) {
136 self.steps_count.store(0, Ordering::Release);
137 self.nodes_visited.store(0, Ordering::Relaxed);
138 self.rewrites_applied.store(0, Ordering::Relaxed);
139 }
140}
141
142// =========================================================================
143// Staged simplification driver (T4.3)
144// =========================================================================
145
146/// Runs the heuristic engine `cfg.intermediate_rounds` times against
147/// `root`, threading each round's output back into the next.
148///
149/// The engine itself preserves the dedup invariant, so spinning the
150/// loop multiple times cannot accidentally fork the arena.
151///
152/// When `cfg.intermediate_rounds == 0` the function is a no-op pass-
153/// through, matching the previous behaviour exactly.
154///
155/// When `cfg.enable_global_dedup == false`, the heuristic still runs
156/// but only with a single final round — useful for low-overhead
157/// configurations that don't need the per-round local cleanup.
158#[must_use]
159pub fn run_staged_simplification(
160 builder: &mut DagBuilder,
161 root: DagNodeId,
162 cfg: SimplifyConfig,
163) -> DagNodeId {
164 if root.is_none() {
165 return root;
166 }
167
168 let rounds = if cfg.enable_global_dedup {
169 cfg.intermediate_rounds
170 } else {
171 // Even when global dedup is off, we still want at least one
172 // pass so callers see *some* simplification effect.
173 cfg.intermediate_rounds.max(1)
174 };
175
176 if rounds == 0 {
177 return root;
178 }
179
180 let mut engine = HeuristicEngine::new(HeuristicConfig::default(), SearchStrategy::Greedy);
181 let mut current = root;
182 let mut prev = DagNodeId::NONE;
183
184 for _ in 0..rounds {
185 if current == prev {
186 // Fixpoint reached — additional rounds are wasted work.
187 break;
188 }
189 prev = current;
190 current = engine.simplify(builder, current);
191 }
192 current
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn zero_rounds_is_passthrough() {
201 let mut b = DagBuilder::new();
202 let x = b.variable("x");
203 let zero = b.constant(0.0);
204 let expr = b.add(x, zero);
205 let cfg = SimplifyConfig {
206 intermediate_rounds: 0,
207 enable_global_dedup: true,
208 };
209 assert_eq!(run_staged_simplification(&mut b, expr, cfg), expr);
210 }
211
212 #[test]
213 fn single_round_folds_identity() {
214 let mut b = DagBuilder::new();
215 let x = b.variable("x");
216 let zero = b.constant(0.0);
217 let expr = b.add(x, zero);
218 let cfg = SimplifyConfig {
219 intermediate_rounds: 1,
220 enable_global_dedup: true,
221 };
222 assert_eq!(run_staged_simplification(&mut b, expr, cfg), x);
223 }
224
225 #[test]
226 fn multiple_rounds_reach_fixpoint() {
227 let mut b = DagBuilder::new();
228 let x = b.variable("x");
229 let zero = b.constant(0.0);
230 let one = b.constant(1.0);
231 // ((x + 0) * 1) — takes two passes through identities to
232 // collapse, but our iterative engine already does it in one.
233 let inner = b.add(x, zero);
234 let outer = b.mul(inner, one);
235 let cfg = SimplifyConfig {
236 intermediate_rounds: 5,
237 enable_global_dedup: true,
238 };
239 let out = run_staged_simplification(&mut b, outer, cfg);
240 assert_eq!(out, x);
241 }
242}