Skip to main content

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}