Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'
This commit is contained in:
310
vendor/ruvector/crates/ruvector-solver/src/budget.rs
vendored
Normal file
310
vendor/ruvector/crates/ruvector-solver/src/budget.rs
vendored
Normal file
@@ -0,0 +1,310 @@
|
||||
//! Compute budget enforcement for solver operations.
|
||||
//!
|
||||
//! [`BudgetEnforcer`] tracks wall-clock time, iteration count, and memory
|
||||
//! allocation against a [`ComputeBudget`]. Solvers call
|
||||
//! [`check_iteration`](BudgetEnforcer::check_iteration) at the top of each
|
||||
//! iteration loop and
|
||||
//! [`check_memory`](BudgetEnforcer::check_memory) before any allocation that
|
||||
//! could exceed the memory ceiling.
|
||||
//!
|
||||
//! Budget violations are reported as [`SolverError::BudgetExhausted`] with a
|
||||
//! human-readable reason describing which limit was hit.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::error::SolverError;
|
||||
use crate::types::ComputeBudget;
|
||||
|
||||
/// Default memory ceiling when none is specified (256 MiB).
|
||||
const DEFAULT_MEMORY_LIMIT: usize = 256 * 1024 * 1024;
|
||||
|
||||
/// Enforces wall-time, iteration, and memory budgets during a solve.
|
||||
///
|
||||
/// Create one at the start of a solve and call the `check_*` methods at each
|
||||
/// iteration or before allocating scratch space. The enforcer is intentionally
|
||||
/// non-`Clone` so that each solve owns exactly one.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use ruvector_solver::budget::BudgetEnforcer;
|
||||
/// use ruvector_solver::types::ComputeBudget;
|
||||
///
|
||||
/// let budget = ComputeBudget::default();
|
||||
/// let mut enforcer = BudgetEnforcer::new(budget);
|
||||
///
|
||||
/// // At the top of each solver iteration:
|
||||
/// enforcer.check_iteration().unwrap();
|
||||
///
|
||||
/// // Before allocating scratch memory:
|
||||
/// enforcer.check_memory(1024).unwrap();
|
||||
/// ```
|
||||
pub struct BudgetEnforcer {
|
||||
/// Monotonic clock snapshot taken when the enforcer was created.
|
||||
start_time: Instant,
|
||||
|
||||
/// The budget limits to enforce.
|
||||
budget: ComputeBudget,
|
||||
|
||||
/// Number of iterations consumed so far.
|
||||
iterations_used: usize,
|
||||
|
||||
/// Cumulative memory allocated (tracked by the caller, not measured).
|
||||
memory_used: usize,
|
||||
|
||||
/// Maximum memory allowed. Defaults to [`DEFAULT_MEMORY_LIMIT`] if
|
||||
/// the `ComputeBudget` does not carry a memory field.
|
||||
memory_limit: usize,
|
||||
}
|
||||
|
||||
impl BudgetEnforcer {
|
||||
/// Create a new enforcer with the given budget.
|
||||
///
|
||||
/// The wall-clock timer starts immediately.
|
||||
pub fn new(budget: ComputeBudget) -> Self {
|
||||
Self {
|
||||
start_time: Instant::now(),
|
||||
budget,
|
||||
iterations_used: 0,
|
||||
memory_used: 0,
|
||||
memory_limit: DEFAULT_MEMORY_LIMIT,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an enforcer with a custom memory ceiling.
|
||||
///
|
||||
/// Use this when the caller knows the available memory and wants to
|
||||
/// enforce a tighter or looser bound than the default 256 MiB.
|
||||
pub fn with_memory_limit(budget: ComputeBudget, memory_limit: usize) -> Self {
|
||||
Self {
|
||||
start_time: Instant::now(),
|
||||
budget,
|
||||
iterations_used: 0,
|
||||
memory_used: 0,
|
||||
memory_limit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the next iteration is within budget.
|
||||
///
|
||||
/// Must be called **once per iteration**, at the top of the loop body.
|
||||
/// Increments the internal iteration counter and checks both the iteration
|
||||
/// limit and the wall-clock time limit.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SolverError::BudgetExhausted`] if either the iteration count
|
||||
/// or wall-clock time has been exceeded.
|
||||
pub fn check_iteration(&mut self) -> Result<(), SolverError> {
|
||||
self.iterations_used += 1;
|
||||
|
||||
// Iteration budget
|
||||
if self.iterations_used > self.budget.max_iterations {
|
||||
return Err(SolverError::BudgetExhausted {
|
||||
reason: format!(
|
||||
"iteration limit reached ({} > {})",
|
||||
self.iterations_used, self.budget.max_iterations,
|
||||
),
|
||||
elapsed: self.start_time.elapsed(),
|
||||
});
|
||||
}
|
||||
|
||||
// Wall-clock budget
|
||||
let elapsed = self.start_time.elapsed();
|
||||
if elapsed > self.budget.max_time {
|
||||
return Err(SolverError::BudgetExhausted {
|
||||
reason: format!(
|
||||
"wall-clock time limit reached ({:.2?} > {:.2?})",
|
||||
elapsed, self.budget.max_time,
|
||||
),
|
||||
elapsed,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check whether an additional memory allocation is within budget.
|
||||
///
|
||||
/// Call this **before** performing the allocation. The `additional` parameter
|
||||
/// is the number of bytes the caller intends to allocate. If the allocation
|
||||
/// would push cumulative usage over the memory ceiling, the call fails
|
||||
/// without modifying the internal counter.
|
||||
///
|
||||
/// On success the internal counter is incremented by `additional`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SolverError::BudgetExhausted`] if the allocation would exceed
|
||||
/// the memory limit.
|
||||
pub fn check_memory(&mut self, additional: usize) -> Result<(), SolverError> {
|
||||
let new_total = self.memory_used.saturating_add(additional);
|
||||
if new_total > self.memory_limit {
|
||||
return Err(SolverError::BudgetExhausted {
|
||||
reason: format!(
|
||||
"memory limit reached ({} + {} = {} > {} bytes)",
|
||||
self.memory_used, additional, new_total, self.memory_limit,
|
||||
),
|
||||
elapsed: self.start_time.elapsed(),
|
||||
});
|
||||
}
|
||||
self.memory_used = new_total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wall-clock microseconds elapsed since the enforcer was created.
|
||||
#[inline]
|
||||
pub fn elapsed_us(&self) -> u64 {
|
||||
self.start_time.elapsed().as_micros() as u64
|
||||
}
|
||||
|
||||
/// Wall-clock duration elapsed since the enforcer was created.
|
||||
#[inline]
|
||||
pub fn elapsed(&self) -> std::time::Duration {
|
||||
self.start_time.elapsed()
|
||||
}
|
||||
|
||||
/// Number of iterations consumed so far.
|
||||
#[inline]
|
||||
pub fn iterations_used(&self) -> usize {
|
||||
self.iterations_used
|
||||
}
|
||||
|
||||
/// Cumulative memory tracked so far (in bytes).
|
||||
#[inline]
|
||||
pub fn memory_used(&self) -> usize {
|
||||
self.memory_used
|
||||
}
|
||||
|
||||
/// The tolerance target from the budget (convenience accessor).
|
||||
#[inline]
|
||||
pub fn tolerance(&self) -> f64 {
|
||||
self.budget.tolerance
|
||||
}
|
||||
|
||||
/// A reference to the underlying budget configuration.
|
||||
#[inline]
|
||||
pub fn budget(&self) -> &ComputeBudget {
|
||||
&self.budget
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::ComputeBudget;
|
||||
use std::time::Duration;
|
||||
|
||||
fn tiny_budget() -> ComputeBudget {
|
||||
ComputeBudget {
|
||||
max_time: Duration::from_secs(60),
|
||||
max_iterations: 5,
|
||||
tolerance: 1e-6,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterations_within_budget() {
|
||||
let mut enforcer = BudgetEnforcer::new(tiny_budget());
|
||||
for _ in 0..5 {
|
||||
enforcer.check_iteration().unwrap();
|
||||
}
|
||||
assert_eq!(enforcer.iterations_used(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iteration_limit_exceeded() {
|
||||
let mut enforcer = BudgetEnforcer::new(tiny_budget());
|
||||
for _ in 0..5 {
|
||||
enforcer.check_iteration().unwrap();
|
||||
}
|
||||
// 6th iteration should fail
|
||||
let err = enforcer.check_iteration().unwrap_err();
|
||||
match err {
|
||||
SolverError::BudgetExhausted { ref reason, .. } => {
|
||||
assert!(reason.contains("iteration"), "reason: {reason}");
|
||||
}
|
||||
other => panic!("expected BudgetExhausted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_clock_limit_exceeded() {
|
||||
let budget = ComputeBudget {
|
||||
max_time: Duration::from_nanos(1), // Impossibly short
|
||||
max_iterations: 1_000_000,
|
||||
tolerance: 1e-6,
|
||||
};
|
||||
let mut enforcer = BudgetEnforcer::new(budget);
|
||||
|
||||
// Burn a tiny bit of time so Instant::now() moves forward
|
||||
std::thread::sleep(Duration::from_micros(10));
|
||||
|
||||
let err = enforcer.check_iteration().unwrap_err();
|
||||
match err {
|
||||
SolverError::BudgetExhausted { ref reason, .. } => {
|
||||
assert!(reason.contains("wall-clock"), "reason: {reason}");
|
||||
}
|
||||
other => panic!("expected BudgetExhausted for time, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_within_budget() {
|
||||
let mut enforcer = BudgetEnforcer::with_memory_limit(tiny_budget(), 1024);
|
||||
enforcer.check_memory(512).unwrap();
|
||||
enforcer.check_memory(512).unwrap();
|
||||
assert_eq!(enforcer.memory_used(), 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_limit_exceeded() {
|
||||
let mut enforcer = BudgetEnforcer::with_memory_limit(tiny_budget(), 1024);
|
||||
enforcer.check_memory(800).unwrap();
|
||||
|
||||
let err = enforcer.check_memory(300).unwrap_err();
|
||||
match err {
|
||||
SolverError::BudgetExhausted { ref reason, .. } => {
|
||||
assert!(reason.contains("memory"), "reason: {reason}");
|
||||
}
|
||||
other => panic!("expected BudgetExhausted for memory, got {other:?}"),
|
||||
}
|
||||
// Memory should not have been incremented on failure
|
||||
assert_eq!(enforcer.memory_used(), 800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_saturating_add_no_panic() {
|
||||
// Use a limit smaller than usize::MAX so that saturation triggers an error.
|
||||
let limit = usize::MAX / 2;
|
||||
let mut enforcer = BudgetEnforcer::with_memory_limit(tiny_budget(), limit);
|
||||
enforcer.check_memory(limit - 1).unwrap();
|
||||
// Adding another large amount should saturate to usize::MAX which exceeds the limit.
|
||||
let err = enforcer.check_memory(usize::MAX).unwrap_err();
|
||||
assert!(matches!(err, SolverError::BudgetExhausted { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elapsed_us_positive() {
|
||||
let enforcer = BudgetEnforcer::new(tiny_budget());
|
||||
// Just ensure it does not panic; the value may be 0 on fast machines.
|
||||
let _ = enforcer.elapsed_us();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerance_accessor() {
|
||||
let enforcer = BudgetEnforcer::new(tiny_budget());
|
||||
assert!((enforcer.tolerance() - 1e-6).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_accessor() {
|
||||
let budget = tiny_budget();
|
||||
let enforcer = BudgetEnforcer::new(budget.clone());
|
||||
assert_eq!(enforcer.budget().max_iterations, 5);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user