Skip to content

Commit deb1d83

Browse files
committed
perf: tighten release profile, optimise state handling, and add performance audit
Updated Cargo.toml to use fat LTO, abort on panic, and strip symbols for smaller binaries, plus a new release-with-debug profile. Refactored Explorer to remove redundant store checks, improve view handling, and introduce a fast‑path stubborn‑set computation using bitmasks. Changed State to store variables in an Arc<[Value]> slice for better memory layout and reduced allocations, updating constructors accordingly. Added a comprehensive performance audit document outlining upcoming optimisation priorities.
1 parent 2a9624b commit deb1d83

4 files changed

Lines changed: 276 additions & 60 deletions

File tree

specl/Cargo.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ proptest = "1"
7272
criterion = { version = "0.5", features = ["html_reports"] }
7373

7474
[profile.release]
75-
lto = "thin"
75+
lto = "fat"
7676
codegen-units = 1
77+
panic = "abort"
78+
strip = "symbols"
79+
80+
[profile.release-with-debug]
81+
inherits = "release"
82+
strip = "none"
7783
debug = true

specl/crates/specl-mc/src/explorer.rs

Lines changed: 100 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2824,9 +2824,6 @@ impl Explorer {
28242824
for (next_state, action_idx, pvals) in successors {
28252825
let canonical = self.maybe_canonicalize(next_state);
28262826
let next_fp = canonical.fingerprint();
2827-
if self.store.contains(&next_fp) {
2828-
continue;
2829-
}
28302827
let is_new = if fp_only_par {
28312828
self.store.insert_fp_only(next_fp)
28322829
} else {
@@ -2882,9 +2879,6 @@ impl Explorer {
28822879
for (next_state, action_idx, pvals) in full_succ {
28832880
let canonical = self.maybe_canonicalize(next_state);
28842881
let next_fp = canonical.fingerprint();
2885-
if self.store.contains(&next_fp) {
2886-
continue;
2887-
}
28882882
let is_new = if fp_only_par {
28892883
self.store.insert_fp_only(next_fp)
28902884
} else {
@@ -3013,7 +3007,7 @@ impl Explorer {
30133007
if let Some(ref mask) = self.view_mask {
30143008
let states = states
30153009
.into_iter()
3016-
.map(|s| State::new_with_view((*s.vars).clone(), mask))
3010+
.map(|s| State::new_with_view(s.vars.to_vec(), mask))
30173011
.collect();
30183012
return Ok(states);
30193013
}
@@ -3549,17 +3543,106 @@ impl Explorer {
35493543
/// The cycle proviso (C3') is checked by the caller: at least one successor must be
35503544
/// new. If not, the caller falls back to full expansion.
35513545
fn compute_stubborn_set(&self, enabled: &[usize]) -> Vec<usize> {
3552-
use std::collections::HashSet;
3553-
35543546
if enabled.len() <= 1 {
35553547
return enabled.to_vec();
35563548
}
35573549

3558-
let enabled_set: HashSet<usize> = enabled.iter().copied().collect();
35593550
let n_actions = self.spec.actions.len();
35603551

3561-
// Key selection: prefer actions relevant to invariants (COI) to avoid missing
3562-
// violation traces. If no COI-relevant action is enabled, fall back to first enabled.
3552+
// Fast path: use u64 bitmasks when action count fits (covers most specs)
3553+
if n_actions <= 64 {
3554+
return self.compute_stubborn_set_bitmask(enabled, n_actions);
3555+
}
3556+
3557+
// Fallback for >64 actions
3558+
self.compute_stubborn_set_hashset(enabled, n_actions)
3559+
}
3560+
3561+
fn compute_stubborn_set_bitmask(&self, enabled: &[usize], n_actions: usize) -> Vec<usize> {
3562+
let enabled_mask: u64 = enabled.iter().fold(0u64, |m, &a| m | (1u64 << a));
3563+
let relevant_mask: u64 = self
3564+
.relevant_actions
3565+
.as_ref()
3566+
.map(|r| r.iter().fold(0u64, |m, &a| m | (1u64 << a)))
3567+
.unwrap_or(0);
3568+
let all_mask: u64 = if n_actions == 64 {
3569+
u64::MAX
3570+
} else {
3571+
(1u64 << n_actions) - 1
3572+
};
3573+
3574+
let t_key = if relevant_mask != 0 {
3575+
enabled
3576+
.iter()
3577+
.find(|&&a| relevant_mask & (1u64 << a) != 0)
3578+
.copied()
3579+
.unwrap_or(enabled[0])
3580+
} else {
3581+
enabled[0]
3582+
};
3583+
3584+
let mut stubborn: u64 = 1u64 << t_key;
3585+
let mut worklist: Vec<usize> = vec![t_key];
3586+
3587+
while let Some(t) = worklist.pop() {
3588+
if enabled_mask & (1u64 << t) != 0 {
3589+
for &t_nds in &self.nds[t] {
3590+
let bit = 1u64 << t_nds;
3591+
if enabled_mask & bit != 0 && stubborn & bit == 0 {
3592+
stubborn |= bit;
3593+
worklist.push(t_nds);
3594+
}
3595+
}
3596+
} else {
3597+
for &t_nes in &self.nes[t] {
3598+
let bit = 1u64 << t_nes;
3599+
if stubborn & bit == 0 {
3600+
stubborn |= bit;
3601+
worklist.push(t_nes);
3602+
break;
3603+
}
3604+
}
3605+
}
3606+
3607+
if stubborn & all_mask == all_mask {
3608+
return enabled.to_vec();
3609+
}
3610+
}
3611+
3612+
let mut result: Vec<usize> = enabled
3613+
.iter()
3614+
.filter(|&&a| stubborn & (1u64 << a) != 0)
3615+
.copied()
3616+
.collect();
3617+
3618+
// POR visibility condition
3619+
if result.len() < enabled.len() {
3620+
let result_mask: u64 = result.iter().fold(0u64, |m, &a| m | (1u64 << a));
3621+
if result_mask & self.visible_actions != 0 {
3622+
return enabled.to_vec();
3623+
}
3624+
}
3625+
3626+
// Ensure at least one COI-relevant action
3627+
if relevant_mask != 0 {
3628+
let has_relevant = result.iter().any(|&a| relevant_mask & (1u64 << a) != 0);
3629+
if !has_relevant {
3630+
for &a in enabled {
3631+
if relevant_mask & (1u64 << a) != 0 {
3632+
result.push(a);
3633+
}
3634+
}
3635+
}
3636+
}
3637+
3638+
result
3639+
}
3640+
3641+
fn compute_stubborn_set_hashset(&self, enabled: &[usize], n_actions: usize) -> Vec<usize> {
3642+
use std::collections::HashSet;
3643+
3644+
let enabled_set: HashSet<usize> = enabled.iter().copied().collect();
3645+
35633646
let t_key = if let Some(ref relevant) = self.relevant_actions {
35643647
let relevant_set: HashSet<usize> = relevant.iter().copied().collect();
35653648
enabled
@@ -3576,17 +3659,12 @@ impl Explorer {
35763659

35773660
while let Some(t) = worklist.pop() {
35783661
if enabled_set.contains(&t) {
3579-
// Enabled: add all NDS(t) that are also enabled
3580-
// (actions that could disable t's guard — must be in stubborn set
3581-
// to ensure t remains enabled throughout)
35823662
for &t_nds in &self.nds[t] {
35833663
if enabled_set.contains(&t_nds) && stubborn.insert(t_nds) {
35843664
worklist.push(t_nds);
35853665
}
35863666
}
35873667
} else {
3588-
// Disabled: pick one NES(t) to potentially enable this action.
3589-
// Deterministic: pick the smallest index not already in the set.
35903668
for &t_nes in &self.nes[t] {
35913669
if stubborn.insert(t_nes) {
35923670
worklist.push(t_nes);
@@ -3595,23 +3673,17 @@ impl Explorer {
35953673
}
35963674
}
35973675

3598-
// Early exit: if stubborn set already contains all actions, no reduction
35993676
if stubborn.len() >= n_actions {
36003677
return enabled.to_vec();
36013678
}
36023679
}
36033680

3604-
// Return only enabled actions in the stubborn set, preserving input order
36053681
let mut result: Vec<usize> = enabled
36063682
.iter()
36073683
.filter(|a| stubborn.contains(a))
36083684
.copied()
36093685
.collect();
36103686

3611-
// POR visibility condition: if the stubborn set contains any action that
3612-
// writes to a variable used in an invariant, the reduction is unsound because
3613-
// it may hide interleavings that lead to invariant violations. In that case,
3614-
// expand to all enabled actions (no reduction).
36153687
if result.len() < enabled.len() {
36163688
let stubborn_mask: u64 =
36173689
result
@@ -3622,20 +3694,14 @@ impl Explorer {
36223694
}
36233695
}
36243696

3625-
// Safety fix: ensure stubborn set includes at least one COI-relevant action
3626-
// to avoid missing invariant violation traces. If the stubborn set contains
3627-
// only irrelevant actions, add all enabled COI-relevant actions.
36283697
if let Some(ref relevant) = self.relevant_actions {
36293698
let relevant_set: HashSet<usize> = relevant.iter().copied().collect();
36303699
let has_relevant = result.iter().any(|a| relevant_set.contains(a));
36313700
if !has_relevant {
3632-
let coi_enabled: Vec<usize> = enabled
3633-
.iter()
3634-
.filter(|a| relevant_set.contains(a))
3635-
.copied()
3636-
.collect();
3637-
if !coi_enabled.is_empty() {
3638-
result.extend(coi_enabled);
3701+
for &a in enabled {
3702+
if relevant_set.contains(&a) {
3703+
result.push(a);
3704+
}
36393705
}
36403706
}
36413707
}
@@ -4415,7 +4481,7 @@ impl Explorer {
44154481
self.enumerate_changed(
44164482
&changed_domains,
44174483
0,
4418-
&mut (*state.vars).clone(),
4484+
&mut state.vars.to_vec(),
44194485
&mut |next_vars: Vec<Value>| {
44204486
let mut ctx = EvalContext::new(&state.vars, &next_vars, &self.consts, params);
44214487
if eval(&action.effect, &mut ctx)

specl/crates/specl-mc/src/state.rs

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ fn compute_var_hashes_and_fingerprint(
239239
#[derive(Debug, Clone)]
240240
pub struct State {
241241
/// Variable values indexed by variable index.
242-
pub vars: Arc<Vec<Value>>,
242+
pub vars: Arc<[Value]>,
243243
/// Cached per-variable hashes: var_hashes[i] = hash_var(i, vars[i]).
244244
/// Enables O(1) fingerprint updates and O(1) xor_hash_vars lookups.
245245
pub var_hashes: Arc<[u64]>,
@@ -260,7 +260,7 @@ impl State {
260260
pub fn new(vars: Vec<Value>) -> Self {
261261
let (hashes, fp) = compute_var_hashes_and_fingerprint(&vars, None);
262262
Self {
263-
vars: Arc::new(vars),
263+
vars: Arc::from(vars),
264264
var_hashes: Arc::from(hashes),
265265
fp,
266266
}
@@ -271,7 +271,7 @@ impl State {
271271
pub fn new_with_view(vars: Vec<Value>, view_mask: &[bool]) -> Self {
272272
let (hashes, fp) = compute_var_hashes_and_fingerprint(&vars, Some(view_mask));
273273
Self {
274-
vars: Arc::new(vars),
274+
vars: Arc::from(vars),
275275
var_hashes: Arc::from(hashes),
276276
fp,
277277
}
@@ -285,7 +285,7 @@ impl State {
285285
var_hashes: &[u64],
286286
) -> Self {
287287
Self {
288-
vars: Arc::new(vars),
288+
vars: Arc::from(vars),
289289
var_hashes: Arc::from(var_hashes),
290290
fp,
291291
}
@@ -300,7 +300,7 @@ impl State {
300300
.map(|(i, v)| hash_var(i, v))
301301
.collect();
302302
Self {
303-
vars: Arc::new(vars),
303+
vars: Arc::from(vars),
304304
var_hashes: Arc::from(hashes.as_slice()),
305305
fp,
306306
}
@@ -336,7 +336,7 @@ impl State {
336336
return self.clone();
337337
}
338338

339-
let mut vars = (*self.vars).clone();
339+
let mut vars = self.vars.to_vec();
340340

341341
for group in groups {
342342
if group.domain_size == 0 || group.variables.is_empty() {
@@ -365,46 +365,47 @@ impl State {
365365
/// 3. The canonical permutation maps each element to its sorted position
366366
///
367367
/// Complexity: O(n log n) instead of O(n!)
368-
/// Build serialized signatures for each domain element across all symmetric variables.
369-
/// signature[i] = serialized values for element i across all vars in the group.
370-
fn build_signatures(vars: &[Value], group: &SymmetryGroup) -> Vec<Vec<Vec<u8>>> {
368+
/// Compute a hash-based signature for each domain element across all symmetric variables.
369+
/// Each element gets a single u64 hash combining its values across all vars in the group.
370+
fn build_hash_signatures(vars: &[Value], group: &SymmetryGroup) -> Vec<u64> {
371371
(0..group.domain_size)
372372
.map(|i| {
373-
group
374-
.variables
375-
.iter()
376-
.map(|&var_idx| match vars[var_idx].kind() {
373+
let mut h: u64 = 0;
374+
for (vi, &var_idx) in group.variables.iter().enumerate() {
375+
let val_bits = match vars[var_idx].kind() {
377376
VK::IntMap(arr) => {
378377
if i < arr.len() {
379-
arr[i].to_bytes()
378+
arr[i].raw_bits()
380379
} else {
381-
vec![]
380+
0
382381
}
383382
}
384383
VK::Fn(map) => Value::fn_get(map, &Value::int(i as i64))
385-
.map(|v| v.to_bytes())
386-
.unwrap_or_default(),
387-
_ => vec![],
388-
})
389-
.collect()
384+
.map(|v| v.raw_bits())
385+
.unwrap_or(0),
386+
_ => 0,
387+
};
388+
h ^= splitmix_hash(vi, val_bits);
389+
}
390+
h
390391
})
391392
.collect()
392393
}
393394

394395
fn compute_canonical_permutation(vars: &[Value], group: &SymmetryGroup) -> Vec<usize> {
395396
let n = group.domain_size;
396397

397-
let mut signatures: Vec<(Vec<Vec<u8>>, usize)> = build_signatures(vars, group)
398+
let mut signatures: Vec<(u64, usize)> = build_hash_signatures(vars, group)
398399
.into_iter()
399400
.enumerate()
400401
.map(|(i, sig)| (sig, i))
401402
.collect();
402403

403-
signatures.sort_by(|a, b| a.0.cmp(&b.0));
404+
signatures.sort_unstable();
404405

405406
let mut perm = vec![0; n];
406-
for (new_idx, (_, old_idx)) in signatures.iter().enumerate() {
407-
perm[*old_idx] = new_idx;
407+
for (new_idx, &(_, old_idx)) in signatures.iter().enumerate() {
408+
perm[old_idx] = new_idx;
408409
}
409410

410411
perm
@@ -423,7 +424,7 @@ pub fn orbit_representatives(vars: &[Value], group: &SymmetryGroup) -> Vec<usize
423424
return vec![];
424425
}
425426

426-
let signatures = build_signatures(vars, group);
427+
let signatures = build_hash_signatures(vars, group);
427428

428429
// Since state is canonical, signatures are already sorted.
429430
// Pick one representative per distinct signature.

0 commit comments

Comments
 (0)