sv_sim_sim/process/liveness.rs
1//! Task S3b (`docs/superpowers/plans/2026-09-26-wave-A2-slice.md`): the
2//! Activation Record frame layout -- declared SV locals get a permanent
3//! slot unconditionally (PR-017, PR-018), and a backward liveness fixpoint
4//! over the segmented [`super::cfg::ProcessCfg`] computes which
5//! compiler-generated temporaries (if any) also need one (PR-021, PR-016,
6//! PR-019). Ledger PR-015..PR-027, CG-100. See `docs/design/process.md` §3.
7//!
8//! **Why this task never actually constructs a `FrameSlotSource::Temporary`
9//! slot (load-bearing, read before touching `lower_to_segments`'s fill
10//! rule):** design.md §3 is explicit that the only thing liveness ever
11//! decides is "which LLVM SSA temporaries ... need a frame slot at all" --
12//! i.e. a *compiler-generated* value with no corresponding `sv_lang::Symbol`
13//! at all, minted only when CODEGEN (Task S3c) decomposes an expression into
14//! named intermediate LLVM values. At this task's level -- a
15//! not-yet-lowered-to-LLVM `ProcessCfg` over plain `sv_lang::Statement`/
16//! `Expression` handles -- no such value exists yet: every `NamedValue` leaf
17//! this file's backward fixpoint can observe resolves to a real
18//! `sv_lang::Symbol`, and in the A2 slice's restricted grammar (no
19//! functions/tasks, no nested automatic scopes) that symbol is always
20//! either (a) a local declared inside this very process (found via
21//! [`collect_declared_locals`], see its own doc comment for why that -- not
22//! a per-statement symbol accessor -- is the only available discovery path
23//! against the pinned `sv-lang`=0.1.9 C API) or (b) an external
24//! module-level port/signal, which does not belong in this process's frame
25//! at all regardless of how "live" it is (its value is always fetched
26//! through the accessor ABI on demand; STATE, not this frame, owns it).
27//! **Neither case is a compiler temporary.** The backward fixpoint below is
28//! still a real, generically-correct implementation over the *whole*
29//! referenced-symbol universe (not narrowed to declared locals up front --
30//! narrowing early would make this a weaker, less faithful implementation
31//! of PR-021's literal "standard GEN/KILL per statement" algorithm), because
32//! its live-in/live-out results are exactly what a future Task S3c revision
33//! of this module intersects with codegen's own materialized temporary set
34//! once one exists, and because PR-022's oracle self-check needs a real
35//! computed live-in set to check in the first place. For every fixture this
36//! task can construct, filtering that result down to "symbols with no
37//! declared-local match" is provably empty by the slice grammar's own
38//! shape -- which is exactly what `compiler_temporary_dead_before_suspend_
39//! gets_no_frame_slot` (PR-019) checks.
40
41use crate::symid::SymbolUid;
42use std::collections::HashSet;
43
44use crate::symid::SymbolId;
45use sv_lang::kinds::{ExpressionKind, SymbolKind};
46use sv_lang::{Expression, Statement, Symbol};
47
48use super::FrameSlot;
49use super::cfg::{
50 ArgBinding, LoweringError, ProcessCfg, RepeatCount, RepeatCounterId, SuspendPoint, Terminator,
51};
52
53/// One frame slot's compile-time layout entry (PR-025, PR-026).
54///
55/// **Deviation from the plan's literal field list** (`{offset, width_bits,
56/// source}`, no `repr`): the ledger's own PR-025/PR-026 text asks for a
57/// `repr: FourStateI2N | WideFourState` tag per slot, and `process::
58/// FrameSlot` (already committed, `Narrow4State{word_count}` /
59/// `Wide4State{aval_word_count, bval_word_count}`) is exactly that shape --
60/// reused here rather than re-declared, so there is exactly one "frame
61/// slot's physical shape" enum in this crate, not two.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct FrameSlotDesc {
64 /// Word offset within the process TEMPLATE's frame (PR-024: decided once
65 /// per template, at lowering-plan build time -- never per instance).
66 /// Units match `repr`'s own word-count field (a private, per-`FrameLayout`
67 /// bump allocation -- see [`compute_liveness`]'s doc comment on why this
68 /// is deliberately *not* `state::arena::Arena`-allocated).
69 pub offset: u32,
70 /// The signal's real, untruncated bit width (CG-100: never a `u16`-
71 /// clamped or otherwise narrowed copy -- see
72 /// `declared_local_width_is_never_truncated_even_at_the_64_bit_boundary`).
73 pub width_bits: u32,
74 pub repr: FrameSlot,
75 pub source: FrameSlotSource,
76 /// A variable shared with a fork branch (`ProcessCfg::captured`): the
77 /// slot is one word, a pointer to the cell holding the value (`repr` is
78 /// the value's shape).
79 pub cell: bool,
80 /// A cell variable of the process that forked this one (never
81 /// initialized here: it arrives with the parent's pointer).
82 pub captured: bool,
83}
84
85/// PR-025: `DeclaredVar` is a permanent slot (PR-017/018 -- unconditional,
86/// regardless of what the liveness fixpoint computes); `Temporary` is the
87/// liveness-driven kind this task's own fixtures never construct -- see this
88/// module's own doc comment for exactly why.
89///
90/// **`SyntheticCounter` (R-run Task 8):** a `repeat(N)` loop's own
91/// compiler-synthesized iteration counter (`process::cfg::
92/// RepeatCounterId`), minted only when a suspend somewhere in the loop's own
93/// body forces `process::cfg::CfgBuilder::walk_repeat_loop` to build a real
94/// CFG shape for it (see that id's own doc comment). Unlike `DeclaredVar`,
95/// there is no `sv_lang::Symbol` behind it at all -- it is PERMANENT for
96/// exactly the same reason `DeclaredVar` is (it must survive the loop's own
97/// suspend/resume call boundary), so [`compute_liveness`]/`lower_to_segments`
98/// treat the two identically for the "always in live_in/live_out" rule,
99/// differing only in how a caller resolves the slot to a physical pointer
100/// (`process_lower.rs`'s `counter_locals`, keyed by `RepeatCounterId`, not
101/// the `SymbolId`-keyed `frame_locals`/`cx.locals` a `DeclaredVar` uses).
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum FrameSlotSource {
104 DeclaredVar(SymbolId),
105 Temporary(u32),
106 SyntheticCounter(RepeatCounterId),
107}
108
109/// PR-025: the per-TEMPLATE frame layout `compute_liveness` produces.
110#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct FrameLayout {
112 pub slots: Vec<FrameSlotDesc>,
113}
114
115impl FrameLayout {
116 /// Total 8-byte words this layout occupies -- the `frame_word_count` a
117 /// `CompiledKernelProcess` needs to size its owned frame buffer.
118 pub fn total_words(&self) -> u32 {
119 self.slots
120 .iter()
121 .map(|s| match s.repr {
122 _ if s.cell => 1,
123 FrameSlot::Narrow4State { word_count } => u32::from(word_count),
124 FrameSlot::Wide4State {
125 aval_word_count,
126 bval_word_count,
127 } => aval_word_count + bval_word_count,
128 FrameSlot::Array { rows, row_words } => rows * u32::from(row_words),
129 })
130 .sum()
131 }
132}
133
134/// A value's stored width (`state::storage`), 0 for none.
135fn storage_width(ty: &sv_lang::Type) -> u64 {
136 crate::state::storage::storage_width(ty).unwrap_or(0)
137}
138
139/// An automatic `event` variable's frame representation: its handle, the
140/// object id in the event pool (`state::event_pool`), 0 = `null`.
141pub const EVENT_HANDLE_WIDTH: u64 = 64;
142
143/// A declared local's frame slot: `(width_bits, repr)` -- a packed value of
144/// up to 128 bits (2 planes, `2 * width` bits in 64-bit words), or a
145/// fixed-size unpacked array of such elements (`FrameSlot::Array`, width
146/// the element's). `None` for anything else.
147fn local_frame_slot(ty: &sv_lang::Type) -> Option<(u32, FrameSlot)> {
148 let words_for = |w: u64| (2 * w).div_ceil(64).max(1);
149 // A queue, dynamic or associative array is its 64-bit handle (as in
150 // STATE); a fixed-size array of them is rows of handles.
151 let is_collection = crate::state::storage::is_collection;
152 if ty.is_unpacked_array() && !is_collection(ty) {
153 let mut rows: u64 = 1;
154 let mut cur = *ty;
155 while cur.is_unpacked_array() && !is_collection(&cur) {
156 rows *= u64::from(cur.fixed_unpacked_array_range().width());
157 cur = cur.element_type()?;
158 }
159 let w = if cur.is_event() {
160 EVENT_HANDLE_WIDTH
161 } else {
162 storage_width(&cur)
163 };
164 if w == 0 || w > u64::from(u16::MAX) {
165 return None;
166 }
167 return Some((
168 w as u32,
169 FrameSlot::Array {
170 rows: u32::try_from(rows).ok()?,
171 row_words: words_for(w) as u16,
172 },
173 ));
174 }
175 let w = if ty.is_event() {
176 EVENT_HANDLE_WIDTH
177 } else {
178 storage_width(ty)
179 };
180 if w == 0 || w > u64::from(u16::MAX) {
181 return None;
182 }
183 Some((
184 w as u32,
185 FrameSlot::Narrow4State {
186 word_count: words_for(w) as u16,
187 },
188 ))
189}
190
191/// PR-021: the backward-fixpoint, `AnalyzedProcedure::read_set()`-oracle-
192/// seeded liveness computation, restricted to the slice's own AST-level
193/// fixtures (see this module's doc comment for why `slots` is always
194/// `DeclaredVar`-only here).
195///
196/// **Deviation on the oracle (PR-022):** the plan's literal signature is
197/// exactly `compute_liveness(cfg: &ProcessCfg<'d>) -> Result<...>`, with no
198/// extra parameter for `AnalyzedProcedure::read_set()`. Verified against the
199/// pinned `sv-lang`=0.1.9 API: a bare `Statement<'d>`/`Expression<'d>`
200/// handle (everything `ProcessCfg` carries) has no accessor back to the
201/// `Design`/`Analysis` that produced it, so a live `read_set()` genuinely
202/// cannot be obtained from `&ProcessCfg<'d>` alone -- only from a caller
203/// that already holds `design.analyze(..)` (a live `Analysis<'a>`, a
204/// *different* lifetime than `ProcessCfg`'s own `'d` elaboration brand).
205/// This function therefore derives its own whole-procedure read-set
206/// directly from `cfg`'s own AST (every `NamedValue` read reference
207/// anywhere in `cfg.blocks`, via the exact same walk the fixpoint itself
208/// uses) as a *self-consistency* oracle -- it still catches a real class of
209/// bug (a live-in symbol the fixpoint's own successor/GEN-KILL propagation
210/// pulled in from the wrong place), just not the independent-implementation
211/// class an external `AnalyzedProcedure::read_set()` cross-check would also
212/// catch. [`compute_liveness_with_oracle`] is the version a caller that
213/// *does* hold a live `Analysis` (this crate's own tests; Task S7's real
214/// integration glue) should call instead, passing slang's own `read_set()`
215/// as the independent oracle -- exactly what PR-022 asks for.
216pub fn compute_liveness<'d>(cfg: &ProcessCfg<'d>) -> Result<FrameLayout, LoweringError> {
217 let read_set = aggregate_all_uses(cfg);
218 compute_liveness_with_oracle(cfg, &read_set)
219}
220
221/// PR-022's real oracle-cross-checked path: `read_set_oracle` should be
222/// `AnalyzedProcedure::read_set()`'s symbols (mapped to [`SymbolId`] via
223/// `Symbol::id()`) whenever a caller has one; every live-in set the backward
224/// fixpoint computes is checked against it, hard-failing
225/// ([`LoweringError::liveness_oracle_mismatch`]) the moment it is not a
226/// subset.
227pub fn compute_liveness_with_oracle<'d>(
228 cfg: &ProcessCfg<'d>,
229 read_set_oracle: &HashSet<SymbolId>,
230) -> Result<FrameLayout, LoweringError> {
231 compute_liveness_impl(cfg, read_set_oracle, None)
232}
233
234/// PR-022's fault-injection seam, exposed so a test can prove the self-check
235/// in [`compute_liveness_with_oracle`] is actually wired -- not merely
236/// described -- rather than only ever observing it pass on legitimate input.
237/// Behaves exactly like [`compute_liveness_with_oracle`], except the given
238/// `bogus` symbol is unconditionally inserted into block 0's computed
239/// live-in set before the oracle check runs, so a caller can construct any
240/// ordinary process, pick a `SymbolId` that is provably outside its
241/// `read_set`, and confirm this returns
242/// `Err(LoweringError)` naming it.
243pub fn compute_liveness_fault_injected<'d>(
244 cfg: &ProcessCfg<'d>,
245 read_set_oracle: &HashSet<SymbolId>,
246 bogus: SymbolId,
247) -> Result<FrameLayout, LoweringError> {
248 compute_liveness_impl(cfg, read_set_oracle, Some(bogus))
249}
250
251fn compute_liveness_impl<'d>(
252 cfg: &ProcessCfg<'d>,
253 read_set_oracle: &HashSet<SymbolId>,
254 inject_bogus: Option<SymbolId>,
255) -> Result<FrameLayout, LoweringError> {
256 let declared = collect_declared_locals(cfg)?;
257 let declared_ids: HashSet<SymbolId> = declared.iter().map(|(id, _)| id.clone()).collect();
258
259 let mut live_in = backward_fixpoint(cfg);
260 if let Some(bogus) = inject_bogus {
261 // Test-only: force the fault into block 0's own live-in set,
262 // whatever `backward_fixpoint` legitimately computed for it.
263 if let Some(first) = live_in.first_mut() {
264 first.insert(bogus);
265 }
266 }
267
268 // PR-022: every computed live-in symbol, at every block, must be a
269 // whole-procedure read -- never silently ignored.
270 for set in &live_in {
271 for sym in set {
272 if !read_set_oracle.contains(sym) {
273 return Err(LoweringError::liveness_oracle_mismatch(sym.to_string()));
274 }
275 }
276 }
277
278 // PR-017/018: every declared local gets a permanent slot, unconditionally
279 // -- never derived from (added or removed by) the fixpoint above. See
280 // this module's doc comment for why `Temporary` is never populated here.
281 // The variables this process shares with its fork branches, and those
282 // it shares with the process that forked it: pointer slots.
283 let mut shared: HashSet<SymbolId> = HashSet::new();
284 for b in &cfg.blocks {
285 if let Terminator::ForkJoin { branches, .. } = &b.terminator {
286 for br in branches {
287 shared.extend(br.captured.iter().map(|v| v.uid()));
288 }
289 }
290 }
291 let mut declared = declared;
292 let mut from_parent: HashSet<SymbolId> = HashSet::new();
293 for v in &cfg.captured {
294 let id = v.uid();
295 if !declared.iter().any(|(d, _)| *d == id) {
296 shared.insert(id.clone());
297 from_parent.insert(id.clone());
298 declared.push((id, *v));
299 }
300 }
301 let mut slots = Vec::with_capacity(declared.len());
302 let mut next_offset: u32 = 0;
303 for (id, sym) in &declared {
304 let ty = sym.value_type().ok_or_else(|| {
305 LoweringError::malformed(format!("declared local {sym:?} has no value_type()"))
306 })?;
307 let (width_bits, repr) = local_frame_slot(&ty).ok_or_else(|| {
308 LoweringError::unsupported(format!(
309 "declared local {} of type {} has no frame representation (a value wider \
310 than 128 bits, a dynamic array, a class handle, ...)",
311 sym.name(),
312 ty.to_sv_string()
313 ))
314 })?;
315 let words = match repr {
316 FrameSlot::Narrow4State { word_count } => u32::from(word_count),
317 FrameSlot::Array { rows, row_words } => rows * u32::from(row_words),
318 FrameSlot::Wide4State {
319 aval_word_count,
320 bval_word_count,
321 } => aval_word_count + bval_word_count,
322 };
323 let cell = shared.contains(id);
324 slots.push(FrameSlotDesc {
325 offset: next_offset,
326 width_bits,
327 repr,
328 source: FrameSlotSource::DeclaredVar(id.clone()),
329 cell,
330 captured: from_parent.contains(id),
331 });
332 next_offset += if cell { 1 } else { words };
333 }
334
335 // This task's own fixtures never populate a `Temporary` slot -- see this
336 // module's doc comment. `declared_ids` is retained (rather than removed
337 // as dead code) as the exact filter a future Task S3c revision applies
338 // to the fixpoint's raw `live_in`/`live_out` results once a genuine
339 // compiler temporary (a live symbol with no declared-local match) can
340 // exist; asserting it here would be a gate that cannot fail against
341 // today's grammar, so it is documented, not asserted.
342 let _ = &declared_ids;
343
344 // R-run Task 8: every `repeat(N)` loop's own synthetic counter
345 // (`FrameSlotSource::SyntheticCounter`'s own doc comment) also gets a
346 // permanent slot, unconditionally -- allocated after every declared
347 // local, in the same first-appearance-in-`cfg.blocks` order
348 // `collect_repeat_counters` discovers them (a `RepeatCounterId` is
349 // already dense/unique per process by construction, so no dedup pass is
350 // needed here the way `collect_declared_locals`'s `SymbolId`-keyed
351 // `HashSet` needs one).
352 for (counter, count) in collect_repeat_counters(cfg) {
353 // A `foreach` element count: a 64-bit counter.
354 let bit_width_u64 = match count {
355 RepeatCount::Const(_) | RepeatCount::Size(_) | RepeatCount::NestedSize { .. } => 64,
356 RepeatCount::Expr(count_expr) | RepeatCount::Inherited(count_expr) => count_expr
357 .expr_type()
358 .ok_or_else(|| {
359 LoweringError::malformed(format!(
360 "repeat({counter:?}) count expression has no expr_type()"
361 ))
362 })?
363 .bit_width(),
364 };
365 if bit_width_u64 == 0 || bit_width_u64 > 64 {
366 // Same slice-scope restriction as a declared local's width
367 // (see the loop above) -- a named, loud rejection, never a
368 // silent truncation.
369 return Err(LoweringError::unsupported(format!(
370 "repeat({counter:?}) count expression has bit_width {bit_width_u64} (0 or \
371 >64) -- wide/memory repeat counts are out of the A2 slice's scope \
372 (PR-025/CG-100, extended to R-run Task 8's synthetic counters)"
373 )));
374 }
375 let width_bits = bit_width_u64 as u32;
376 let word_count_u64 = (2u64 * bit_width_u64).div_ceil(64).max(1);
377 let word_count = word_count_u64 as u16;
378 slots.push(FrameSlotDesc {
379 offset: next_offset,
380 width_bits,
381 repr: FrameSlot::Narrow4State { word_count },
382 source: FrameSlotSource::SyntheticCounter(counter),
383 cell: false,
384 captured: false,
385 });
386 next_offset += u32::from(word_count);
387 }
388
389 // An intra-assignment-timed assignment's captured right-hand side: a
390 // permanent temporary slot shaped like its target.
391 let ref_temps: HashSet<u32> = cfg
392 .blocks
393 .iter()
394 .flat_map(|b| b.bindings.iter())
395 .filter_map(|b| match b {
396 ArgBinding::RefOpen { temp, .. } => Some(*temp),
397 _ => None,
398 })
399 .collect();
400 for (temp, target) in cfg.captures.iter().enumerate() {
401 // A `ref` to a collection element keeps its runtime reference id.
402 if ref_temps.contains(&(temp as u32)) {
403 slots.push(FrameSlotDesc {
404 offset: next_offset,
405 width_bits: 64,
406 repr: FrameSlot::Narrow4State { word_count: 2 },
407 source: FrameSlotSource::Temporary(temp as u32),
408 cell: false,
409 captured: false,
410 });
411 next_offset += 2;
412 continue;
413 }
414 let ty = match cfg.capture_types.get(temp).copied().flatten() {
415 Some(formal) => formal.value_type(),
416 None => target.expr_type(),
417 }
418 .ok_or_else(|| {
419 LoweringError::malformed("intra-assignment timing target has no expr_type()")
420 })?;
421 let (width_bits, repr) = local_frame_slot(&ty).ok_or_else(|| {
422 LoweringError::unsupported(format!(
423 "an intra-assignment-timed assignment to a {} (no frame representation)",
424 ty.to_sv_string()
425 ))
426 })?;
427 let words = match repr {
428 FrameSlot::Narrow4State { word_count } => u32::from(word_count),
429 FrameSlot::Array { rows, row_words } => rows * u32::from(row_words),
430 FrameSlot::Wide4State {
431 aval_word_count,
432 bval_word_count,
433 } => aval_word_count + bval_word_count,
434 };
435 slots.push(FrameSlotDesc {
436 offset: next_offset,
437 width_bits,
438 repr,
439 source: FrameSlotSource::Temporary(temp as u32),
440 cell: false,
441 captured: false,
442 });
443 next_offset += words;
444 }
445
446 Ok(FrameLayout { slots })
447}
448
449/// R-run Task 8: every distinct `RepeatCounterId` a suspend-containing
450/// `repeat(N)` loop minted anywhere in `cfg.blocks`, paired with its own
451/// count expression, in block-index (allocation) order -- the CFG-level
452/// analogue of [`collect_declared_locals`], but reading `BasicBlock::
453/// repeat_init` (a plain per-block field this crate's own `process::cfg`
454/// already populates) rather than walking the AST for a `Symbol`, since a
455/// synthetic counter has no `Symbol` to discover in the first place.
456fn collect_repeat_counters<'d>(
457 cfg: &ProcessCfg<'d>,
458) -> Vec<(RepeatCounterId, crate::process::cfg::RepeatCount<'d>)> {
459 cfg.blocks
460 .iter()
461 .filter_map(|b| b.repeat_init.clone())
462 .collect()
463}
464
465/// PR-017: every declared SV local referenced anywhere in `cfg.body`'s
466/// subtree, in first-appearance-in-source (AST pre-order) discovery order,
467/// deduplicated by [`SymbolId`].
468///
469/// **Why this walks `Block` statements' `Statement::block_symbol()` rather
470/// than looking at each `VariableDeclaration` statement directly:** verified
471/// against the pinned `sv-lang`=0.1.9 C API (`CApiAst.cpp`) -- there is no
472/// `slang_stmt_*` accessor that recovers a `VariableDeclStatement`'s own
473/// declared symbol (confirmed: `Statement::children()`/`::expressions()`
474/// both come back empty for a `VariableDeclaration`-kind leaf). What the
475/// binding *does* expose is `Statement::block_symbol()`
476/// (`slang::ast::BlockStatement::blockSymbol`), and slang's own
477/// `Statement::bind` (`Statement.cpp`) gives an UNNAMED `begin`/`end` block a
478/// real (anonymously-named) `StatementBlockSymbol` scope whenever it
479/// contains at least one declaration -- confirmed live against this
480/// project's own `sv-lang` build (an unnamed `begin int x; ... end` yields
481/// `block_symbol() == Some(<anonymous>)`, whose `.members()` lists `x`),
482/// not merely inferred from the doc comment's narrower "unnamed -> None"
483/// phrasing (which describes the truly decl-free case only). Recursing over
484/// every `Block` statement in the tree and collecting each one's
485/// `block_symbol()`'s `Variable`-kind members is therefore the only
486/// available, non-panicking discovery path.
487fn collect_declared_locals<'d>(
488 cfg: &ProcessCfg<'d>,
489) -> Result<Vec<(SymbolId, Symbol<'d>)>, LoweringError> {
490 let mut out: Vec<(SymbolId, Symbol<'d>)> = Vec::new();
491 let mut seen: HashSet<SymbolId> = HashSet::new();
492 let mut stack = vec![cfg.body.as_sem_node()];
493 while let Some(node) = stack.pop() {
494 if let Some(stmt) = node.as_statement() {
495 for block_sym in super::cfg::block_scopes(&stmt) {
496 for member in block_sym.members() {
497 // A static one is the instance's (a STATE signal).
498 if member.kind() == SymbolKind::Variable
499 && !crate::netlist::is_static_procedural_var(&member)
500 {
501 let id = member.uid();
502 if seen.insert(id.clone()) {
503 out.push((id, member));
504 }
505 }
506 }
507 }
508 }
509 stack.extend(super::cfg::live_children(&node));
510 }
511 // Pattern variables (LRM 12.6) are declared by no statement either.
512 for v in crate::patterns::pattern_vars(cfg.body.as_sem_node()) {
513 let id = v.uid();
514 if seen.insert(id.clone()) {
515 out.push((id, v));
516 }
517 }
518 // Formals/locals of tasks inlined into this process: declared by no
519 // `Block` under `cfg.body` (their declarations live in the task).
520 for sym in &cfg.inlined_locals {
521 let id = sym.uid();
522 if seen.insert(id.clone()) {
523 out.push((id, *sym));
524 }
525 }
526 Ok(out)
527}
528
529/// The ids of the variables `cfg` declares (its blocks' and its inlined
530/// tasks'), for `process::cfg`'s fork capture analysis.
531pub(crate) fn declared_local_ids(cfg: &ProcessCfg<'_>) -> HashSet<SymbolId> {
532 collect_declared_locals(cfg)
533 .map(|v| v.into_iter().map(|(id, _)| id).collect())
534 .unwrap_or_default()
535}
536
537/// One inlined-task [`ArgBinding`]'s reads and (whole-symbol) definition,
538/// in the same shape [`statement_use_def`] reports for a statement.
539fn binding_use_def<'d>(b: &ArgBinding<'d>) -> (Vec<Symbol<'d>>, Option<Symbol<'d>>) {
540 let mut uses = Vec::new();
541 match b {
542 ArgBinding::In { formal, actual } => {
543 collect_named_values(*actual, &mut uses);
544 (uses, Some(*formal))
545 }
546 ArgBinding::Init { var } => {
547 if let Some(init) = var.initializer() {
548 collect_named_values(init, &mut uses);
549 }
550 (uses, Some(*var))
551 }
552 ArgBinding::Out { actual, formal } => {
553 // Conservative: the target's own name counts as a use too (a
554 // select target is a read-modify-write), never as a kill.
555 collect_named_values(*actual, &mut uses);
556 uses.push(*formal);
557 (uses, None)
558 }
559 ArgBinding::Const { var, .. } | ArgBinding::ThisOf { var } => (uses, Some(*var)),
560 // Reads the handles the variable holds (and clears it).
561 ArgBinding::Release { var } => {
562 uses.push(*var);
563 (uses, None)
564 }
565 // A captured value lives in a compiler temporary, not a symbol.
566 ArgBinding::Capture { value, .. } => {
567 collect_named_values(*value, &mut uses);
568 (uses, None)
569 }
570 ArgBinding::AssignCaptured { target, .. } => {
571 collect_named_values(*target, &mut uses);
572 (uses, None)
573 }
574 // The value lives in a compiler temporary, not a symbol.
575 ArgBinding::FromCaptured { formal, .. } => (uses, Some(*formal)),
576 ArgBinding::ToCaptured { formal, .. } => {
577 uses.push(*formal);
578 (uses, None)
579 }
580 ArgBinding::CallResult { .. } | ArgBinding::ClassEventLeave => (uses, None),
581 // The caller's `this` (a compiler temporary, not a symbol).
582 ArgBinding::CaptureThis { .. } => (uses, None),
583 // The reference id lives in a compiler temporary.
584 ArgBinding::RefOpen { formal, actual, .. } => {
585 collect_named_values(*actual, &mut uses);
586 (uses, Some(*formal))
587 }
588 ArgBinding::RefClose { formal, actual, .. } => {
589 collect_named_values(*actual, &mut uses);
590 uses.push(*formal);
591 (uses, None)
592 }
593 ArgBinding::Step { var, .. } => {
594 uses.push(*var);
595 (uses, None)
596 }
597 ArgBinding::AssocKey {
598 var,
599 arr,
600 path,
601 next,
602 } => {
603 collect_named_values(*arr, &mut uses);
604 uses.extend(path.iter().map(|s| s.var));
605 if *next {
606 uses.push(*var);
607 }
608 (uses, Some(*var))
609 }
610 }
611}
612
613/// Every `NamedValue` expression's `referenced_symbol()`, reachable anywhere
614/// in `expr`'s subtree -- the one shared leaf-collection primitive both
615/// [`statement_use_def`] and the terminator-condition walks below reuse
616/// (mirrors `cfg.rs`'s own `contains_suspend_trigger` traversal shape).
617fn collect_named_values<'d>(expr: Expression<'d>, out: &mut Vec<Symbol<'d>>) {
618 let mut stack = vec![expr.as_sem_node()];
619 while let Some(node) = stack.pop() {
620 if let Some(e) = node.as_expression()
621 && crate::symid::is_value_name(e.kind())
622 && let Some(sym) = e.referenced_symbol()
623 {
624 out.push(sym);
625 }
626 // A call to a function inlined into the process reads its result
627 // variable (`CfgBuilder::hoist_suspending_function_calls`).
628 if let Some(e) = node.as_expression()
629 && e.kind() == ExpressionKind::Call
630 && e.is_system_call() != Some(true)
631 && let Some(callee) = e.call_subroutine()
632 && callee.subroutine_kind() == Some(sv_lang::SubroutineKind::Function)
633 && let Some(rv) = callee.subroutine_return_val_var()
634 {
635 out.push(rv);
636 }
637 stack.extend(node.children());
638 }
639}
640
641/// The symbols read by the index expressions of `e` when it selects an
642/// element of an unpacked array (`ea[i]`, `ea[i][j]`); nothing for any other
643/// event expression.
644fn event_index_reads<'d>(e: Expression<'d>, out: &mut Vec<Symbol<'d>>) {
645 let mut cur = e;
646 while cur.kind() == ExpressionKind::ElementSelect {
647 if let Some(sel) = cur.selector() {
648 collect_named_values(sel, out);
649 }
650 match cur.select_value() {
651 Some(base) => cur = base,
652 None => break,
653 }
654 }
655}
656
657/// One statement's GEN (read uses) / KILL (the one symbol it defines, if
658/// any) sets, per PR-021's "standard GEN/KILL per statement". The slice's
659/// grammar only ever defines a symbol through a whole-signal blocking/
660/// nonblocking `Assignment` with a bare `NamedValue` lvalue (its own "Slice
661/// scope" -- no concatenation LHS); anything else conservatively contributes
662/// no KILL (safe -- it only ever makes a symbol stay live *longer* than a
663/// fully precise analysis would, never shorter, which cannot manufacture a
664/// false oracle mismatch since every collected use still comes from a real
665/// read reference in the AST).
666fn statement_use_def<'d>(stmt: &Statement<'d>) -> (Vec<Symbol<'d>>, Option<Symbol<'d>>) {
667 if let Some(top) = stmt.expressions().into_iter().next()
668 && top.kind() == ExpressionKind::Assignment
669 {
670 let mut uses = Vec::new();
671 let mut def = None;
672 if let Some(lhs) = top.left() {
673 if crate::symid::is_value_name(lhs.kind()) {
674 def = lhs.referenced_symbol();
675 } else {
676 // Out-of-scope lvalue shape (e.g. a select) -- conservatively
677 // treat it as a read too, never as a silent def.
678 collect_named_values(lhs, &mut uses);
679 }
680 }
681 if let Some(rhs) = top.right() {
682 collect_named_values(rhs, &mut uses);
683 }
684 return (uses, def);
685 }
686
687 let mut uses = Vec::new();
688 for e in stmt.expressions() {
689 collect_named_values(e, &mut uses);
690 }
691 (uses, None)
692}
693
694fn successors(t: &Terminator<'_>) -> Vec<u32> {
695 match t {
696 Terminator::Fallthrough(b) => vec![b.0],
697 Terminator::Branch {
698 then_blk, else_blk, ..
699 } => vec![then_blk.0, else_blk.0],
700 Terminator::Assertion {
701 pass_blk,
702 fail_blk,
703 done_blk,
704 ..
705 } => vec![pass_blk.0, fail_blk.0, done_blk.0],
706 Terminator::Suspend { resume_blk, .. } => vec![resume_blk.0],
707 Terminator::End { loop_back } => loop_back.iter().map(|b| b.0).collect(),
708 // Task 1: each of these is an unconditional edge to exactly one
709 // other block in this same `cfg.blocks` graph -- `Loop`'s real
710 // per-iteration decision lives on `entry`'s own (separately
711 // visited) terminator, never on `Loop` itself (mirrors
712 // `lower_terminator`'s identical `Goto(entry)` mapping).
713 Terminator::Loop { entry, .. } => vec![entry.0],
714 Terminator::ForkJoin { join_blk, .. } => vec![join_blk.0],
715 // R-run Task 8: same two-successor shape as `Branch` above (this is
716 // `repeat`'s own header decision), just no `cond: Expression` to
717 // additionally walk in `terminator_uses` below.
718 Terminator::RepeatCheck {
719 then_blk, else_blk, ..
720 } => vec![then_blk.0, else_blk.0],
721 Terminator::Case {
722 arms, default_blk, ..
723 }
724 | Terminator::Switch {
725 arms, default_blk, ..
726 } => arms
727 .iter()
728 .chain(std::iter::once(default_blk))
729 .map(|b| b.0)
730 .collect(),
731 }
732}
733
734/// **Verified against real `sv-lang`=0.1.9 `AnalyzedProcedure::read_set()`
735/// output (not assumed):** a `SignalEventControl`'s edge signal (`clk` in
736/// `@(posedge clk)`) does NOT appear in `read_set()` -- it is a sensitivity
737/// *trigger*, not a data read, in slang's own dataflow model (consistent
738/// with `always_comb`'s implicit sensitivity list being derived separately
739/// from the read set, never by re-adding an already-explicit edge signal to
740/// it). `SuspendPoint::Edge`'s signal is therefore deliberately excluded
741/// here -- including it made the real, non-fault-injected path in
742/// `liveness_never_computes_a_symbol_outside_the_procedures_read_set` fail
743/// against a genuine `AnalyzedProcedure::read_set()` oracle before this fix.
744/// `SuspendPoint::Delay`'s expression is, by contrast, an ordinary value
745/// expression (the slice only ever elaborates it to a constant, so this
746/// never contributes a `NamedValue` in practice here, but the general case
747/// -- an expression delay -- is out of A2-slice scope entirely, PR-006) and
748/// is included for the same reason a `Branch` condition is.
749fn terminator_uses<'d>(t: &Terminator<'d>, out: &mut Vec<Symbol<'d>>) {
750 match t {
751 Terminator::Branch { cond, .. } => collect_named_values(*cond, out),
752 Terminator::Assertion { stmt, .. } => {
753 if let Some(c) = crate::process::cfg::assertion_cond(stmt) {
754 collect_named_values(c, out);
755 }
756 }
757 // What selects the arm (a virtual interface, weights, a selector)
758 // is read.
759 Terminator::Switch { on, .. } => {
760 for e in on.reads() {
761 collect_named_values(e, out);
762 }
763 }
764 // The selector and every label are read to pick the arm.
765 Terminator::Case { stmt, .. } => {
766 for e in case_reads(stmt) {
767 collect_named_values(e, out);
768 }
769 }
770 Terminator::Suspend {
771 point: SuspendPoint::Delay(e, _),
772 ..
773 } => collect_named_values(*e, out),
774 // Task 1: `wait(expr)` is a genuine data read (unlike an edge
775 // trigger) -- same treatment as a `Branch` condition.
776 Terminator::Suspend {
777 point: SuspendPoint::Level(e),
778 ..
779 } => collect_named_values(*e, out),
780 // Task 6: `ImplicitComb`'s captured read-set IS genuine data reads
781 // (the guarded body's own AST-derived sensitivity list) -- same
782 // treatment as `Level`'s condition above, extended to every member.
783 Terminator::Suspend {
784 point: SuspendPoint::ImplicitComb(exprs),
785 ..
786 } => {
787 for e in exprs {
788 collect_named_values(*e, out);
789 }
790 }
791 // Task 1: `EdgeList`'s member signals are sensitivity triggers, not
792 // data reads -- same verified-against-`read_set()` reasoning as
793 // plain `Edge` above, extended to every member. Task 6: the revised
794 // `CycleDelay`'s `clock` is a trigger too (an edge-
795 // sensitivity subscription, never a data read), same reasoning as
796 // plain `Edge`/`CycleDelay` before this task.
797 // The index of an event-array element in an event control
798 // (`@(ea[k])`) is evaluated when the wait starts (LRM 1800-2023
799 // 15.5.1): a data read, though the event itself is only a trigger.
800 Terminator::Suspend {
801 point: SuspendPoint::Edge { signal, iff, .. },
802 ..
803 } => {
804 event_index_reads(*signal, out);
805 // The `iff` guard is a data read at every wake (LRM 1800-2023
806 // 9.4.2.3), so a local read only there must outlive the suspend.
807 if let Some(g) = iff {
808 collect_named_values(*g, out);
809 }
810 }
811 Terminator::Suspend {
812 point: SuspendPoint::EdgeList(entries, iffs),
813 ..
814 } => {
815 for (e, _) in entries {
816 event_index_reads(*e, out);
817 }
818 for g in iffs.iter().flatten() {
819 collect_named_values(*g, out);
820 }
821 }
822 Terminator::Suspend {
823 point:
824 SuspendPoint::CycleDelay { .. }
825 | SuspendPoint::OneStep
826 | SuspendPoint::WaitFork
827 | SuspendPoint::ByRequest,
828 ..
829 }
830 | Terminator::Fallthrough(_)
831 | Terminator::End { .. }
832 | Terminator::Loop { .. }
833 | Terminator::ForkJoin { .. }
834 // R-run Task 8: `RepeatCheck` references no `sv_lang::Symbol` at all
835 // (its own counter is compiler-synthesized, `FrameSlotSource::
836 // SyntheticCounter`'s own doc comment) -- nothing for this
837 // Symbol-liveness walk to collect. Its own `repeat_init` count
838 // expression's symbols are, like `step_exprs`', deliberately not
839 // walked here either -- see `BasicBlock::step_exprs`'s own doc
840 // comment for why omitting them does not under-approximate any
841 // `DeclaredVar`/`SyntheticCounter` slot's permanence (both are
842 // unconditionally permanent regardless of this fixpoint's result).
843 | Terminator::RepeatCheck { .. } => {}
844 }
845}
846
847/// PR-021: the backward fixpoint itself, over the whole referenced-symbol
848/// universe (see this module's doc comment for why narrowing to declared
849/// locals up front would be a weaker implementation of the same algorithm).
850/// Returns `live_in[block_index]` for every block in `cfg.blocks`.
851fn backward_fixpoint<'d>(cfg: &ProcessCfg<'d>) -> Vec<HashSet<SymbolId>> {
852 let n = cfg.blocks.len();
853 let mut live_in: Vec<HashSet<SymbolId>> = vec![HashSet::new(); n];
854 let mut changed = true;
855 while changed {
856 changed = false;
857 for i in (0..n).rev() {
858 let block = &cfg.blocks[i];
859 let mut live: HashSet<SymbolId> = HashSet::new();
860 for succ in successors(&block.terminator) {
861 live.extend(live_in[succ as usize].iter().cloned());
862 }
863 let mut term_uses = Vec::new();
864 terminator_uses(&block.terminator, &mut term_uses);
865 for sym in term_uses {
866 live.insert(sym.uid());
867 }
868 for stmt in block.stmts.iter().rev() {
869 let (uses, def) = statement_use_def(stmt);
870 if let Some(d) = def {
871 live.remove(&d.uid());
872 }
873 for u in uses {
874 live.insert(u.uid());
875 }
876 }
877 for b in block.bindings.iter().rev() {
878 let (uses, def) = binding_use_def(b);
879 if let Some(d) = def {
880 live.remove(&d.uid());
881 }
882 for u in uses {
883 live.insert(u.uid());
884 }
885 }
886 if live != live_in[i] {
887 live_in[i] = live;
888 changed = true;
889 }
890 }
891 }
892 live_in
893}
894
895/// This task's own (see [`compute_liveness`]'s doc comment) self-derived
896/// oracle: every symbol read anywhere in `cfg`'s statements or terminator
897/// conditions, via the exact same [`collect_named_values`] leaf collector
898/// the fixpoint itself uses.
899fn aggregate_all_uses<'d>(cfg: &ProcessCfg<'d>) -> HashSet<SymbolId> {
900 let mut out = HashSet::new();
901 for block in &cfg.blocks {
902 for b in &block.bindings {
903 let (uses, _def) = binding_use_def(b);
904 for u in uses {
905 out.insert(u.uid());
906 }
907 }
908 for stmt in &block.stmts {
909 let (uses, _def) = statement_use_def(stmt);
910 for u in uses {
911 out.insert(u.uid());
912 }
913 }
914 let mut term_uses = Vec::new();
915 terminator_uses(&block.terminator, &mut term_uses);
916 for u in term_uses {
917 out.insert(u.uid());
918 }
919 }
920 out
921}
922
923// PR-023's negative gate lives in `tests/process_liveness.rs` as a
924// source-text scan of this file (mirrors `sched`'s own `scan_support`
925// pattern) -- not here, since a self-referential "this file does not
926// contain the string X" check belongs with the other structural/negative
927// gates, not inline in the implementation it is scanning.
928
929/// The expressions a [`Terminator::Case`] statement evaluates to choose its
930/// arm: for a `case`, the selector, then every item's labels (a `case ...
931/// inside` range label's bounds included, as children of its `ValueRange`);
932/// for a `unique`/`unique0`/`priority` `if` series, every condition.
933pub fn case_reads<'d>(stmt: &sv_lang::Statement<'d>) -> Vec<sv_lang::Expression<'d>> {
934 use sv_lang::kinds::StatementKind;
935 if stmt.kind() == StatementKind::Conditional {
936 // A series the CFG could not lower was rejected when it was built.
937 return crate::process::cfg::if_series(*stmt)
938 .map(|c| {
939 c.links
940 .iter()
941 .flat_map(|l| l.conditions().into_iter().map(|c| c.expr()))
942 .collect()
943 })
944 .unwrap_or_default();
945 }
946 if stmt.kind() == StatementKind::PatternCase {
947 // The matched expression and every item's `&&&` filter (a pattern
948 // itself reads nothing: only constants and bindings).
949 return crate::patterns::pattern_case_expr(*stmt)
950 .into_iter()
951 .chain(stmt.pattern_case_items().iter().filter_map(|i| i.filter()))
952 .collect();
953 }
954 stmt.expr()
955 .into_iter()
956 .chain(stmt.case_items().iter().flat_map(|i| i.expressions()))
957 .collect()
958}
959
960#[cfg(test)]
961mod tests {
962 use super::*;
963
964 /// CG-100's compile-time half (the actual spill/fill IR round-trip is
965 /// Task S3c/S4's job, once a real spill site exists -- see this crate's
966 /// deviations note): `FrameSlotDesc::width_bits` must be the real,
967 /// untruncated bit width, and `repr`'s `word_count` derived from the
968 /// FULL `2*width_bits` (never `width_bits` alone, which would silently
969 /// halve the unknown/xz plane's storage). A 64-bit declared local is
970 /// exactly the boundary case: `word_count` must be 2 (128 bits: 64 val +
971 /// 64 unk), never 1 (which would be truncating the unk plane away
972 /// entirely).
973 #[test]
974 fn declared_local_width_is_never_truncated_even_at_the_64_bit_boundary() {
975 let design = crate::state::compile_source(
976 "module m; initial begin automatic logic [63:0] w; w = 0; end endmodule\n",
977 )
978 .unwrap();
979 let body = design
980 .top_instances()
981 .next()
982 .unwrap()
983 .instance_body()
984 .unwrap();
985 let block = body
986 .members()
987 .find(|s| s.kind() == sv_lang::kinds::SymbolKind::ProceduralBlock)
988 .unwrap();
989 let root = block.body().unwrap();
990 let cfg = super::super::cfg::build_cfg(&root, super::super::cfg::ProcessLoopKind::OneShot)
991 .unwrap();
992 let frame = compute_liveness(&cfg).unwrap();
993 assert_eq!(frame.slots.len(), 1);
994 assert_eq!(frame.slots[0].width_bits, 64, "width must not be truncated");
995 match frame.slots[0].repr {
996 FrameSlot::Narrow4State { word_count } => {
997 assert_eq!(
998 word_count, 2,
999 "a 64-bit value's val+unk packing needs 2 u64 words, not 1"
1000 );
1001 }
1002 other => panic!("expected Narrow4State for a 64-bit local, got {other:?}"),
1003 }
1004 }
1005
1006 /// Mutation-coverage regression: `backward_fixpoint`'s per-statement GEN/
1007 /// KILL step must apply KILL (remove the statement's own def) *before*
1008 /// GEN (insert its uses) -- `live_in = (live_out - def) ∪ use`, per
1009 /// PR-021's own "standard GEN/KILL per statement" formula this module's
1010 /// doc comment cites. A statement whose LHS symbol also appears on its
1011 /// own RHS (`a = a + 1`) is exactly the case that distinguishes the two
1012 /// orders: swapping them (GEN then KILL) would insert `a` and then
1013 /// immediately remove it again, silently dropping a symbol that is
1014 /// genuinely read in this same statement from the computed live-in set.
1015 /// `compute_liveness`'s own PR-022 oracle self-check cannot catch this
1016 /// class of bug (it only rejects an over-approximated live-in symbol,
1017 /// never an under-approximated one), so this test calls
1018 /// `backward_fixpoint` directly.
1019 #[test]
1020 fn self_referencing_statement_keeps_its_own_rhs_use_live_into_the_block() {
1021 let design = crate::state::compile_source(
1022 "module m; initial begin int a; a = a + 1; end endmodule\n",
1023 )
1024 .unwrap();
1025 let body = design
1026 .top_instances()
1027 .next()
1028 .unwrap()
1029 .instance_body()
1030 .unwrap();
1031 let block = body
1032 .members()
1033 .find(|s| s.kind() == sv_lang::kinds::SymbolKind::ProceduralBlock)
1034 .unwrap();
1035 let root = block.body().unwrap();
1036 let cfg = super::super::cfg::build_cfg(&root, super::super::cfg::ProcessLoopKind::OneShot)
1037 .unwrap();
1038
1039 let read_set = aggregate_all_uses(&cfg);
1040 let a_id = read_set
1041 .iter()
1042 .next()
1043 .expect(
1044 "`a` is read on the RHS of `a = a + 1`, so the whole-procedure read-set \
1045 oracle must contain exactly its SymbolId",
1046 )
1047 .clone();
1048 assert_eq!(
1049 read_set.len(),
1050 1,
1051 "the only symbol ever read in this fixture is `a`"
1052 );
1053
1054 let live_in = backward_fixpoint(&cfg);
1055 assert!(
1056 live_in.iter().any(|set| set.contains(&a_id)),
1057 "`a` is used on the RHS of the very statement that defines it -- it must be live \
1058 going into that block, but backward_fixpoint's computed live-in sets {live_in:?} \
1059 contain no block with `a`"
1060 );
1061 }
1062
1063 /// LRM 1800-2023 9.4.2.3: an `iff` guard (on a plain edge or an event-list
1064 /// member) is evaluated at every wake, a data read of its operands. A local
1065 /// read only there is therefore live across the suspend; the backward
1066 /// fixpoint must see both guards.
1067 #[test]
1068 fn a_symbol_read_only_in_an_iff_guard_is_live_at_the_suspend() {
1069 let design = crate::state::compile_source(
1070 "module m; logic c, d; initial begin automatic int g; automatic int h; \
1071 @(posedge d); @(posedge c iff g); @(posedge c iff h or posedge d); end endmodule\n",
1072 )
1073 .unwrap();
1074 let body = design
1075 .top_instances()
1076 .next()
1077 .unwrap()
1078 .instance_body()
1079 .unwrap();
1080 let block = body
1081 .members()
1082 .find(|s| s.kind() == sv_lang::kinds::SymbolKind::ProceduralBlock)
1083 .unwrap();
1084 let root = block.body().unwrap();
1085 let cfg = super::super::cfg::build_cfg(&root, super::super::cfg::ProcessLoopKind::OneShot)
1086 .unwrap();
1087 let live_in = backward_fixpoint(&cfg);
1088 let all: HashSet<SymbolId> = live_in.iter().flatten().cloned().collect();
1089 let names: Vec<String> = all.iter().map(|id| format!("{id:?}")).collect();
1090 for local in ["g", "h"] {
1091 assert!(
1092 names
1093 .iter()
1094 .any(|n| n.ends_with(local) || n.contains(&format!(".{local}"))),
1095 "`{local}` is read only by an iff guard but is not live: {names:?}"
1096 );
1097 }
1098 }
1099}