sv_sim_sim/sched/dedup.rs
1//! SCHED::DEDUP — the `queued_this_pass` per-process dedup gate: the exact
2//! `if !process.queued_this_pass.swap(true, Ordering::AcqRel) { ... }` idiom
3//! from scheduler.md §4, plus the `notify_change`-time wiring that combines
4//! it with Task 9's [`SensitivityTable::matches`] (scheduler.md §4/§Risks;
5//! ledger SC-055, SC-056).
6//!
7//! A process sensitive to several signals that all change in the same
8//! Active/Inactive/NBA delta pass must be pushed onto the ready set exactly
9//! once (SC-055) — otherwise it would run twice for what LRM semantics treats
10//! as a single reactivation. The dedup flag's lifecycle is
11//! enqueue -> (stays set across any further matches this pass) -> reset at
12//! *dequeue* time, not at enqueue time (SC-056): resetting on enqueue would
13//! make a legitimate re-trigger arriving in the gap between a process being
14//! popped off the ready set and its compiled kernel re-registering its next
15//! sensitivity look identical to "already queued, skip it", silently
16//! swallowing a real stimulus. [`QueuedGate::reset_on_dequeue`] is therefore
17//! the caller's responsibility to invoke the instant a process is actually
18//! popped off the ready set to run, strictly *before* `resume()` is called.
19
20use std::collections::HashMap;
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Mutex, RwLock};
23
24use crate::sched::ProcessId;
25use crate::sched::sensitivity::SensitivityTable;
26use crate::state::{EdgeKind, StateCellId};
27
28/// Per-process "already queued this pass" flags (scheduler.md §4's literal
29/// `queued_this_pass` bit, lifted out of any one process's storage into its
30/// own gate so `sensitivity_match_to_enqueue` can dedup across every cell a
31/// process is sensitive to, not just one).
32///
33/// The flags are a `Vec<AtomicBool>` indexed by `ProcessId` behind an
34/// `RwLock`: both methods take `&self` and must create a flag on first touch
35/// (no registration step pre-populates them), which needs the write lock
36/// once per process; every later touch takes only the read lock, and the
37/// flag itself is flipped and reset by a genuine atomic swap/store (SC-056's
38/// acceptance: "the `AtomicBool` must be real, not a `Cell<bool>`"), so
39/// `QueuedGate` stays `Send + Sync` with no lock needed around it. (It was a
40/// `Mutex<HashMap<ProcessId, Arc<AtomicBool>>>`, whose hash, lock and `Arc`
41/// clone on every match cost 3-5% of a fan-out-heavy run.)
42pub struct QueuedGate {
43 /// Indexed by `ProcessId` (dense from 0), grown on first touch; a read
44 /// lock suffices for every flag that already exists, so the per-pass hot
45 /// path is one uncontended read lock and one atomic.
46 flags: RwLock<Vec<AtomicBool>>,
47 /// A process id too large to index densely (never produced by the
48 /// engine's own sequential allocation, but accepted).
49 sparse: Mutex<HashMap<ProcessId, bool>>,
50}
51
52/// Ids at or above this go to [`QueuedGate::sparse`].
53const DENSE_LIMIT: ProcessId = 1 << 24;
54
55impl QueuedGate {
56 pub fn new() -> Self {
57 Self {
58 flags: RwLock::new(Vec::new()),
59 sparse: Mutex::new(HashMap::new()),
60 }
61 }
62
63 /// Runs `f` on `proc`'s flag, creating it (`false`) on first touch.
64 fn with_flag<R>(&self, proc: ProcessId, f: impl FnOnce(&AtomicBool) -> R) -> R {
65 if proc >= DENSE_LIMIT {
66 let mut sparse = self.sparse.lock().expect("QueuedGate mutex poisoned");
67 let flag = AtomicBool::new(*sparse.get(&proc).unwrap_or(&false));
68 let r = f(&flag);
69 sparse.insert(proc, flag.into_inner());
70 return r;
71 }
72 let i = proc as usize;
73 {
74 let flags = self.flags.read().expect("QueuedGate lock poisoned");
75 if let Some(flag) = flags.get(i) {
76 return f(flag);
77 }
78 }
79 let mut flags = self.flags.write().expect("QueuedGate lock poisoned");
80 if flags.len() <= i {
81 flags.resize_with(i + 1, || AtomicBool::new(false));
82 }
83 f(&flags[i])
84 }
85
86 /// The exact `if !process.queued_this_pass.swap(true, Ordering::AcqRel)
87 /// { ... }` idiom from scheduler.md §4: atomically marks `proc` as
88 /// queued and returns `true` iff *this* call is the one that transitioned
89 /// the flag from unqueued to queued — i.e. iff this call is the one that
90 /// should push `proc` onto `active_q`. A second call for the same `proc`
91 /// before the flag is reset returns `false` (SC-055's "queued only
92 /// once").
93 pub fn mark_queued_if_not_already(&self, proc: ProcessId) -> bool {
94 self.with_flag(proc, |flag| !flag.swap(true, Ordering::AcqRel))
95 }
96
97 /// Clears `proc`'s queued flag. Callers invoke this the instant a
98 /// process is popped off the ready set to run, **before** `resume()` is
99 /// called — never at enqueue time (SC-056) — so a legitimate re-trigger
100 /// landing in the dequeue-to-re-registration gap is correctly seen as a
101 /// fresh, unqueued state and re-queues the process rather than being
102 /// silently swallowed.
103 pub fn reset_on_dequeue(&self, proc: ProcessId) {
104 self.with_flag(proc, |flag| flag.store(false, Ordering::Release));
105 }
106}
107
108impl Default for QueuedGate {
109 fn default() -> Self {
110 Self::new()
111 }
112}
113
114/// The actual `notify_change`-time dedup step: combines Task 9's
115/// [`SensitivityTable::matches`] with `gate`, returning only the
116/// [`ProcessId`]s that were *not* already queued this pass — i.e. exactly
117/// the set that should be pushed onto the ready set for this one committed
118/// change. Every `ProcessId` `matches` yields for `cell`/`edge` is passed
119/// through `gate.mark_queued_if_not_already`, so a process matched by more
120/// than one cell/edge combination in the same pass appears in the returned
121/// `Vec` at most once, on whichever call first marks it queued.
122pub fn sensitivity_match_to_enqueue(
123 table: &SensitivityTable,
124 gate: &QueuedGate,
125 cell: StateCellId,
126 edge: EdgeKind,
127) -> Vec<ProcessId> {
128 // Task 3 (ledger SC-052) widened `SensitivityTable::matches`'s item type
129 // to `(ProcessId, usize, Option<u64>)`, and Task 5 (SC-068) widened it
130 // again to `(ProcessId, usize, Option<u64>, Option<PredicateFn>)` — so
131 // `EngineNotifySink::notify_change` can see a `Repeat` term's
132 // `term_index`/declared count and a `Dynamic` entry's `iff` predicate.
133 // This dedup gate has no repeat-counting or `iff`-evaluation role
134 // (both happen one layer up, in `sched::control`, before a matched
135 // firing ever reaches here as something to enqueue) and only ever
136 // needed the `ProcessId`.
137 table
138 .matches(cell, edge)
139 .map(|(proc, _, _, _)| proc)
140 .filter(|&proc| gate.mark_queued_if_not_already(proc))
141 .collect()
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use crate::sched::StaticSensitivity;
148
149 const PROC_MAIN: ProcessId = 1;
150
151 /// SC-055 — an `always_comb`-shaped process sensitive to two signals
152 /// (`a`, `b`) that both change in the same Active pass must be queued
153 /// exactly once: the first `sensitivity_match_to_enqueue` call (for `a`)
154 /// returns `[proc]`, the second (for `b`, against the *same* gate)
155 /// returns `[]` because the process is already queued this pass.
156 #[test]
157 fn process_sensitive_to_two_signals_changing_same_pass_queues_once() {
158 let a = StateCellId(1);
159 let b = StateCellId(2);
160
161 let mut table = SensitivityTable::new();
162 table.install_static(
163 &StaticSensitivity {
164 edges: vec![(a, None)],
165 },
166 PROC_MAIN,
167 );
168 table.install_static(
169 &StaticSensitivity {
170 edges: vec![(b, None)],
171 },
172 PROC_MAIN,
173 );
174
175 let gate = QueuedGate::new();
176
177 let first = sensitivity_match_to_enqueue(&table, &gate, a, EdgeKind::None);
178 assert_eq!(
179 first,
180 vec![PROC_MAIN],
181 "the first match this pass must be the one that enqueues the process"
182 );
183
184 let second = sensitivity_match_to_enqueue(&table, &gate, b, EdgeKind::None);
185 assert!(
186 second.is_empty(),
187 "a second signal changing in the same pass must not re-enqueue an already-queued process"
188 );
189 }
190
191 /// SC-056 — the flag's lifecycle is enqueue -> (stays set across
192 /// multiple matches) -> dequeue-reset, not enqueue -> immediate-reset.
193 #[test]
194 fn reset_on_dequeue_happens_at_dequeue_not_enqueue() {
195 let gate = QueuedGate::new();
196
197 assert!(
198 gate.mark_queued_if_not_already(PROC_MAIN),
199 "first mark must succeed (transitions unqueued -> queued)"
200 );
201 assert!(
202 !gate.mark_queued_if_not_already(PROC_MAIN),
203 "a repeat mark before any dequeue-reset must report already-queued"
204 );
205
206 gate.reset_on_dequeue(PROC_MAIN);
207
208 assert!(
209 gate.mark_queued_if_not_already(PROC_MAIN),
210 "after reset_on_dequeue, the process must be markable as queued again"
211 );
212 }
213
214 /// SC-056 — the literal "stimulus timing that lands exactly in the gap"
215 /// test: mark queued, dequeue-reset (the process is now "running", about
216 /// to re-register its next sensitivity, which in this fixture hasn't
217 /// happened yet), then — *before* any re-registration — a legitimate new
218 /// change arrives and marks the process queued again. This must succeed:
219 /// a buggy "reset on enqueue instead of dequeue" implementation would
220 /// have already reset the flag before step (2) even runs, making its own
221 /// step (3) indistinguishable from correct — ordering the reset strictly
222 /// between the two `mark_queued_if_not_already` calls is what catches
223 /// that exact class of bug.
224 #[test]
225 fn retrigger_arriving_in_the_dequeue_to_reregister_gap_is_not_swallowed() {
226 let gate = QueuedGate::new();
227
228 // (1) queued.
229 assert!(gate.mark_queued_if_not_already(PROC_MAIN));
230
231 // (2) dequeued to run — reset happens here, not at step (1)'s enqueue.
232 gate.reset_on_dequeue(PROC_MAIN);
233
234 // (3) before re-registration, a legitimate new change arrives.
235 assert!(
236 gate.mark_queued_if_not_already(PROC_MAIN),
237 "a retrigger landing in the dequeue-to-re-registration gap must \
238 still be able to re-queue the process, not be silently eaten"
239 );
240 }
241
242 /// Guards against an implementation that accidentally gates on
243 /// `StateCellId` instead of `ProcessId`: two *different* processes both
244 /// sensitive to the same cell must both appear in the one
245 /// `sensitivity_match_to_enqueue` call for that cell's change — the gate
246 /// is per-`ProcessId`, so neither one's dedup entry should suppress the
247 /// other.
248 #[test]
249 fn dedup_does_not_falsely_eat_a_second_legitimate_process() {
250 let cell = StateCellId(5);
251 const PROC_B: ProcessId = 2;
252
253 let mut table = SensitivityTable::new();
254 table.install_static(
255 &StaticSensitivity {
256 edges: vec![(cell, None)],
257 },
258 PROC_MAIN,
259 );
260 table.install_static(
261 &StaticSensitivity {
262 edges: vec![(cell, None)],
263 },
264 PROC_B,
265 );
266
267 let gate = QueuedGate::new();
268
269 let mut hits = sensitivity_match_to_enqueue(&table, &gate, cell, EdgeKind::None);
270 hits.sort_unstable();
271 assert_eq!(
272 hits,
273 vec![PROC_MAIN, PROC_B],
274 "both processes sensitive to the same cell must be enqueued on its change"
275 );
276 }
277}