From f593fa875cf76cf1de7eb88df49436dd1bdd95bf Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Mon, 9 Nov 2026 12:41:53 +0100 Subject: [PATCH] perf(list): keep the nearest destinations in one bounded buffer per cursor The nearby list change and swap cursors collected every reachable destination of a source position into a fresh vector, sorted and truncated it to max_nearby, then copied the survivors into a second destination list. Every source position paid for an allocation that grew with the whole solution, and on the zero-regression gate a quarter of nearby_change's branch misses sat in malloc, realloc, memmove and free. Each cursor now owns a BoundedNearbyCandidates buffer that keeps only the max_nearby nearest destinations offered so far, in ascending distance with offer order between equal distances. That is exactly the set and order the stable sort and truncation produced, so candidate streams are unchanged. The buffer is reused across source positions and moves read from it directly, with no second copy. Closes #228 --- .../selector/list_kernel/nearby_change.rs | 34 ++---- .../selector/list_kernel/nearby_swap.rs | 30 ++--- .../heuristic/selector/nearby_list_support.rs | 114 +++++++++--------- .../selector/nearby_list_support_tests.rs | 66 ++++++++++ 4 files changed, 148 insertions(+), 96 deletions(-) create mode 100644 crates/solverforge-solver/src/heuristic/selector/nearby_list_support_tests.rs diff --git a/crates/solverforge-solver/src/heuristic/selector/list_kernel/nearby_change.rs b/crates/solverforge-solver/src/heuristic/selector/list_kernel/nearby_change.rs index 7450b91e1..401fee7c0 100644 --- a/crates/solverforge-solver/src/heuristic/selector/list_kernel/nearby_change.rs +++ b/crates/solverforge-solver/src/heuristic/selector/list_kernel/nearby_change.rs @@ -6,9 +6,7 @@ use crate::heuristic::selector::list_support::ordered_index; use crate::heuristic::selector::move_selector::{ CandidateId, CandidateStore, MoveCandidateRef, MoveCursor, MoveStreamContext, }; -use crate::heuristic::selector::nearby_list_support::{ - sort_and_limit_nearby_candidates, NearbyCandidate, -}; +use crate::heuristic::selector::nearby_list_support::BoundedNearbyCandidates; use crate::heuristic::selector::precedence_route::PrecedenceRouteGraph; use crate::list_placement::OwnerRestriction; @@ -34,11 +32,10 @@ where source_idx: usize, source_pos_offset: usize, current_source: (usize, usize), - destinations: Vec<(usize, usize)>, + destinations: BoundedNearbyCandidates, destination_offset: usize, fixed_to_current_entity: bool, precedence_route_graph: Option, - max_nearby: usize, descriptor_index: usize, source_salt: u64, } @@ -73,11 +70,10 @@ where source_idx: 0, source_pos_offset: 0, current_source: (0, 0), - destinations: Vec::new(), + destinations: BoundedNearbyCandidates::new(max_nearby), destination_offset: 0, fixed_to_current_entity, precedence_route_graph: None, - max_nearby, descriptor_index, source_salt, } @@ -102,8 +98,6 @@ where } fn load_next_source(&mut self, solution: &S) -> bool { - let mut candidates: Vec = Vec::new(); - while self.source_idx < self.entities.len() { let source_entity = self.entities[self.source_idx]; let source_len = self.route_lens[self.source_idx]; @@ -131,7 +125,7 @@ where continue; } - candidates.clear(); + self.destinations.clear(); let source_allowed = source_restriction.is_none_or(|restriction| restriction.allows(source_entity)); if source_allowed { @@ -159,7 +153,11 @@ where destination_position.min(source_len.saturating_sub(1)), ); if distance.is_finite() { - candidates.push((source_entity, destination_position, distance)); + self.destinations.offer(( + source_entity, + destination_position, + distance, + )); } } } @@ -187,7 +185,7 @@ where reference_position, ); if distance.is_finite() { - candidates.push(( + self.destinations.offer(( destination_entity, destination_position, distance, @@ -197,17 +195,10 @@ where } } - sort_and_limit_nearby_candidates(&mut candidates, self.max_nearby); - if candidates.is_empty() { + if self.destinations.is_empty() { continue; } self.current_source = (source_entity, source_position); - self.destinations.clear(); - self.destinations.extend( - candidates - .iter() - .map(|&(entity, position, _)| (entity, position)), - ); self.destination_offset = 0; return true; } @@ -224,7 +215,8 @@ where return None; } let (source_entity, source_position) = self.current_source; - let (destination_entity, destination_position) = self.destinations[self.destination_offset]; + let (destination_entity, destination_position) = + self.destinations.destination(self.destination_offset); self.destination_offset += 1; Some(self.emitter.emit_change( source_entity, diff --git a/crates/solverforge-solver/src/heuristic/selector/list_kernel/nearby_swap.rs b/crates/solverforge-solver/src/heuristic/selector/list_kernel/nearby_swap.rs index dae26f455..a4d328354 100644 --- a/crates/solverforge-solver/src/heuristic/selector/list_kernel/nearby_swap.rs +++ b/crates/solverforge-solver/src/heuristic/selector/list_kernel/nearby_swap.rs @@ -6,9 +6,7 @@ use crate::heuristic::selector::list_support::ordered_index; use crate::heuristic::selector::move_selector::{ CandidateId, CandidateStore, MoveCandidateRef, MoveCursor, MoveStreamContext, }; -use crate::heuristic::selector::nearby_list_support::{ - sort_and_limit_nearby_candidates, NearbyCandidate, -}; +use crate::heuristic::selector::nearby_list_support::BoundedNearbyCandidates; use crate::heuristic::selector::precedence_route::PrecedenceRouteGraph; use crate::list_placement::OwnerRestriction; @@ -34,11 +32,10 @@ where source_idx: usize, source_pos_offset: usize, current_source: (usize, usize), - destinations: Vec<(usize, usize)>, + destinations: BoundedNearbyCandidates, destination_offset: usize, fixed_to_current_entity: bool, precedence_route_graph: Option, - max_nearby: usize, descriptor_index: usize, source_salt: u64, } @@ -73,11 +70,10 @@ where source_idx: 0, source_pos_offset: 0, current_source: (0, 0), - destinations: Vec::new(), + destinations: BoundedNearbyCandidates::new(max_nearby), destination_offset: 0, fixed_to_current_entity, precedence_route_graph: None, - max_nearby, descriptor_index, source_salt, } @@ -102,7 +98,6 @@ where } fn load_next_source(&mut self, solution: &S) -> bool { - let mut candidates: Vec = Vec::new(); while self.source_idx < self.entities.len() { let source_entity = self.entities[self.source_idx]; let source_len = self.route_lens[self.source_idx]; @@ -129,7 +124,7 @@ where continue; } - candidates.clear(); + self.destinations.clear(); for destination_position in source_position + 1..source_len { let destination_restriction = self .probe @@ -163,7 +158,8 @@ where destination_position, ); if distance.is_finite() { - candidates.push((source_entity, destination_position, distance)); + self.destinations + .offer((source_entity, destination_position, distance)); } } @@ -206,7 +202,7 @@ where destination_position, ); if distance.is_finite() { - candidates.push(( + self.destinations.offer(( destination_entity, destination_position, distance, @@ -216,17 +212,10 @@ where } } - sort_and_limit_nearby_candidates(&mut candidates, self.max_nearby); - if candidates.is_empty() { + if self.destinations.is_empty() { continue; } self.current_source = (source_entity, source_position); - self.destinations.clear(); - self.destinations.extend( - candidates - .iter() - .map(|&(entity, position, _)| (entity, position)), - ); self.destination_offset = 0; return true; } @@ -243,7 +232,8 @@ where return None; } let (source_entity, source_position) = self.current_source; - let (destination_entity, destination_position) = self.destinations[self.destination_offset]; + let (destination_entity, destination_position) = + self.destinations.destination(self.destination_offset); self.destination_offset += 1; Some(self.emitter.emit_swap( source_entity, diff --git a/crates/solverforge-solver/src/heuristic/selector/nearby_list_support.rs b/crates/solverforge-solver/src/heuristic/selector/nearby_list_support.rs index 28c24a56a..840f3f3dd 100644 --- a/crates/solverforge-solver/src/heuristic/selector/nearby_list_support.rs +++ b/crates/solverforge-solver/src/heuristic/selector/nearby_list_support.rs @@ -1,74 +1,78 @@ +//! Bounded nearest-destination selection shared by the nearby list cursors. + +use std::cmp::Ordering; + +/// One nearby destination: `(entity, position, distance)`. pub(crate) type NearbyCandidate = (usize, usize, f64); -pub(crate) fn sort_and_limit_nearby_candidates( - candidates: &mut Vec, - max_nearby: usize, -) { - use std::cmp::Ordering; +/// The `limit` nearest destinations offered so far, in ascending distance. +/// +/// Destinations at equal distance keep the order they were offered in, so the +/// retained set is exactly what a stable sort by distance followed by a +/// truncation to `limit` would keep. The buffer never grows past `limit` and +/// keeps its capacity across [`Self::clear`], so a cursor reuses one buffer for +/// every source position instead of collecting every reachable destination. +#[derive(Debug)] +pub(crate) struct BoundedNearbyCandidates { + retained: Vec, + limit: usize, +} + +impl BoundedNearbyCandidates { + /// Creates an empty buffer that retains at most `limit` destinations. + /// + /// Capacity grows on demand to the number of destinations actually + /// retained, so an unbounded `limit` reserves nothing up front. + pub(crate) fn new(limit: usize) -> Self { + Self { + retained: Vec::new(), + limit, + } + } - if max_nearby == 0 { - candidates.clear(); - return; + /// Drops every retained destination while keeping the capacity. + pub(crate) fn clear(&mut self) { + self.retained.clear(); } - let mut retained = 0; - for read in 0..candidates.len() { - let candidate = candidates[read]; - let insertion = candidates[..retained].partition_point(|existing| { + /// Offers one destination, keeping it only if it ranks among the nearest + /// `limit` destinations offered since the last [`Self::clear`]. + #[inline] + pub(crate) fn offer(&mut self, candidate: NearbyCandidate) { + let insertion = self.retained.partition_point(|existing| { existing .2 .partial_cmp(&candidate.2) .unwrap_or(Ordering::Equal) != Ordering::Greater }); - if insertion >= max_nearby { - continue; + if insertion >= self.limit { + return; } - - let next_retained = (retained + 1).min(max_nearby); - candidates.copy_within(insertion..next_retained - 1, insertion + 1); - candidates[insertion] = candidate; - retained = next_retained; + if self.retained.len() == self.limit { + self.retained.pop(); + } + self.retained.insert(insertion, candidate); } - candidates.truncate(retained); -} - -#[cfg(test)] -mod tests { - use std::cmp::Ordering; - use super::{sort_and_limit_nearby_candidates, NearbyCandidate}; + /// Number of retained destinations. + pub(crate) fn len(&self) -> usize { + self.retained.len() + } - fn stable_sort_and_limit( - mut candidates: Vec, - max_nearby: usize, - ) -> Vec { - candidates.sort_by(|left, right| left.2.partial_cmp(&right.2).unwrap_or(Ordering::Equal)); - candidates.truncate(max_nearby); - candidates + /// Whether no destination is retained. + pub(crate) fn is_empty(&self) -> bool { + self.retained.is_empty() } - #[test] - fn bounded_order_matches_stable_full_sort() { - for len in 0_usize..96 { - let mut state = 0x9E37_79B9_u32 ^ len as u32; - let candidates = (0..len) - .map(|index| { - state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); - let distance = if index % 17 == 0 { - -0.0 - } else { - f64::from(state % 11) - }; - (index / 7, index, distance) - }) - .collect::>(); - for max_nearby in 0..=len + 2 { - let expected = stable_sort_and_limit(candidates.clone(), max_nearby); - let mut actual = candidates.clone(); - sort_and_limit_nearby_candidates(&mut actual, max_nearby); - assert_eq!(actual, expected, "len={len}, max_nearby={max_nearby}"); - } - } + /// The `(entity, position)` of the retained destination at `index`. + #[inline] + pub(crate) fn destination(&self, index: usize) -> (usize, usize) { + let (entity, position, _) = self.retained[index]; + (entity, position) } } + +#[cfg(test)] +#[path = "nearby_list_support_tests.rs"] +mod tests; diff --git a/crates/solverforge-solver/src/heuristic/selector/nearby_list_support_tests.rs b/crates/solverforge-solver/src/heuristic/selector/nearby_list_support_tests.rs new file mode 100644 index 000000000..8c725fdbc --- /dev/null +++ b/crates/solverforge-solver/src/heuristic/selector/nearby_list_support_tests.rs @@ -0,0 +1,66 @@ +use std::cmp::Ordering; + +use super::{BoundedNearbyCandidates, NearbyCandidate}; + +fn stable_sort_and_limit( + mut candidates: Vec, + limit: usize, +) -> Vec { + candidates.sort_by(|left, right| left.2.partial_cmp(&right.2).unwrap_or(Ordering::Equal)); + candidates.truncate(limit); + candidates +} + +#[test] +fn bounded_order_matches_stable_full_sort() { + for len in 0_usize..96 { + let mut state = 0x9E37_79B9_u32 ^ len as u32; + let candidates = (0..len) + .map(|index| { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let distance = if index % 17 == 0 { + -0.0 + } else { + f64::from(state % 11) + }; + (index / 7, index, distance) + }) + .collect::>(); + for limit in 0..=len + 2 { + let expected = stable_sort_and_limit(candidates.clone(), limit); + let mut bounded = BoundedNearbyCandidates::new(limit); + for &candidate in &candidates { + bounded.offer(candidate); + } + assert_eq!(bounded.retained, expected, "len={len}, limit={limit}"); + assert_eq!(bounded.len(), expected.len()); + for (index, &(entity, position, _)) in expected.iter().enumerate() { + assert_eq!(bounded.destination(index), (entity, position)); + } + } + } +} + +#[test] +fn clear_reuses_capacity_without_growing_past_limit() { + let mut bounded = BoundedNearbyCandidates::new(4); + let mut capacity = None; + for round in 0..3 { + bounded.clear(); + assert!(bounded.is_empty()); + for index in 0..64 { + bounded.offer((round, index, f64::from((index * 37 % 64) as u32))); + } + assert_eq!(bounded.len(), 4); + let after_round = bounded.retained.capacity(); + assert!( + after_round <= 8, + "capacity {after_round} grew past the limit" + ); + assert_eq!(*capacity.get_or_insert(after_round), after_round); + } + assert_eq!( + bounded.retained, + [(2, 0, 0.0), (2, 45, 1.0), (2, 26, 2.0), (2, 7, 3.0)] + ); +}