feat(parallel): add GIL-free parallel DE with Rust objectives

- Implement RustObjective trait for GIL-free parallelization
- Add 5 benchmark functions: Sphere, Rosenbrock, Rastrigin, Ackley, Griewank
  * Each implements RustObjective with evaluate(), dimension(), global_optimum()
  * Exposed to Python with __call__ method

- Add parallel_differential_evolution_rust() function:
  * Uses Rayon for parallel population evaluation
  * Works with RustObjective implementations only
  * Eliminates Python GIL overhead for 10-100× speedup
  * Supports all DE strategies and adaptive parameters

- Create comprehensive examples:
  * parallel_de_benchmark.py: Performance benchmarks showing speedup
  * polaroid_optimizr_integration.py: 4 workflows combining Polaroid + OptimizR
    - Regime detection with HMM
    - Strategy parameter optimization
    - Portfolio risk analysis
    - Pairs trading pipeline

- Module integration:
  * Export benchmark functions in Python API
  * Export parallel_differential_evolution_rust
  * Update __init__.py and core.py with new functions

- Technical implementation:
  * RustObjective trait in src/rust_objectives.rs
  * Parallel evaluation uses par_iter() from Rayon
  * Per-thread RNG seeding for reproducibility
  * Maintains same API as standard DE for easy comparison

Part of Priority 2: Enable Rust parallelization (Enhancement Strategy)
Expected speedup: 10-100× on multi-core systems for pure Rust objectives
This commit is contained in:
Melvin Alvarez
2026-01-03 00:03:29 +01:00
parent 7f77f29203
commit f5f6005f80
7 changed files with 1377 additions and 0 deletions
+283
View File
@@ -577,6 +577,289 @@ fn crossover<R: Rng>(target: &[f64], mutant: &[f64], cr: f64, rng: &mut R) -> Ve
.collect()
}
/// Parallel Differential Evolution for Rust objectives (GIL-free)
///
/// This function enables parallel evaluation of objective functions
/// that implement the RustObjective trait, achieving 10-100× speedup
/// on multi-core systems without Python GIL contention.
#[pyfunction]
#[pyo3(signature = (
objective_name,
dim,
bounds,
popsize=15,
maxiter=100,
f=None,
cr=None,
strategy="rand1",
seed=None,
tol=1e-6,
track_history=false,
adaptive=false
))]
pub fn parallel_differential_evolution_rust(
_py: Python,
objective_name: &str,
dim: usize,
bounds: Vec<(f64, f64)>,
popsize: usize,
maxiter: usize,
f: Option<f64>,
cr: Option<f64>,
strategy: &str,
seed: Option<u64>,
tol: f64,
track_history: bool,
adaptive: bool,
) -> PyResult<DEResult> {
use crate::rust_objectives::*;
use rayon::prelude::*;
// Create the appropriate objective function
let objective: Box<dyn RustObjective> = match objective_name.to_lowercase().as_str() {
"sphere" => Box::new(Sphere::new(dim)),
"rosenbrock" => Box::new(Rosenbrock::new(dim)),
"rastrigin" => Box::new(Rastrigin::new(dim)),
"ackley" => Box::new(Ackley::new(dim)),
"griewank" => Box::new(Griewank::new(dim)),
_ => return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
format!("Unknown objective function: {}. Use: sphere, rosenbrock, rastrigin, ackley, griewank", objective_name)
)),
};
// Validate inputs
let n_params = bounds.len();
if n_params != dim {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
format!("Dimension mismatch: bounds has {} dimensions but objective requires {}", n_params, dim)
));
}
for (i, (low, high)) in bounds.iter().enumerate() {
if low >= high {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"Invalid bounds at index {}: low={} >= high={}",
i, low, high
)));
}
}
let pop_size = popsize * n_params;
if pop_size < 4 {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"Population size too small (need at least 4 individuals)",
));
}
// Parse strategy
let de_strategy = DEStrategy::from_str(strategy).ok_or_else(|| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"Invalid strategy '{}'. Use: rand1, best1, currenttobest1, rand2, best2",
strategy
))
})?;
// Initialize RNG
let mut rng = if let Some(s) = seed {
StdRng::seed_from_u64(s)
} else {
StdRng::from_entropy()
};
// Adaptive parameters
let use_adaptive = adaptive || f.is_none() || cr.is_none();
let mut f_values = vec![f.unwrap_or(0.8); pop_size];
let mut cr_values = vec![cr.unwrap_or(0.9); pop_size];
// Initialize population uniformly in bounds
let mut population: Vec<Vec<f64>> = (0..pop_size)
.map(|_| {
bounds
.iter()
.map(|(low, high)| {
let uniform = Uniform::new(*low, *high);
uniform.sample(&mut rng)
})
.collect()
})
.collect();
// Evaluate initial population IN PARALLEL (GIL-free!)
let mut fitness: Vec<f64> = population
.par_iter()
.map(|individual| objective.evaluate(individual))
.collect();
let mut nfev = pop_size;
let mut history = if track_history {
Some(Vec::new())
} else {
None
};
// Main evolution loop
for generation in 0..maxiter {
let mut best_idx = 0;
let mut best_fitness = fitness[0];
for (i, &fit) in fitness.iter().enumerate() {
if fit < best_fitness {
best_fitness = fit;
best_idx = i;
}
}
// Track convergence
if let Some(ref mut hist) = history {
let mean_fitness = fitness.iter().sum::<f64>() / fitness.len() as f64;
let variance = fitness
.iter()
.map(|&f| (f - mean_fitness).powi(2))
.sum::<f64>()
/ fitness.len() as f64;
let std_fitness = variance.sqrt();
// Population diversity (average distance from best)
let diversity = population
.iter()
.map(|ind| {
ind.iter()
.zip(&population[best_idx])
.map(|(a, b)| (a - b).powi(2))
.sum::<f64>()
.sqrt()
})
.sum::<f64>()
/ pop_size as f64;
hist.push(ConvergenceRecord {
generation,
best_fitness,
mean_fitness,
std_fitness,
diversity,
});
}
// Check convergence
if generation > 0 {
let improvement = history.as_ref()
.and_then(|h| {
if h.len() >= 2 {
Some(h[h.len()-2].best_fitness - best_fitness)
} else {
None
}
});
if let Some(imp) = improvement {
if imp.abs() < tol {
break;
}
}
}
// Mutation, crossover, and selection - IN PARALLEL
let updates: Vec<(usize, Vec<f64>, f64)> = (0..pop_size)
.into_par_iter()
.filter_map(|i| {
// Need separate RNG per thread - using deterministic seed
let mut thread_rng = StdRng::seed_from_u64(
seed.unwrap_or(42) + (generation * pop_size + i) as u64
);
let f_i = if use_adaptive {
f_values[i]
} else {
f.unwrap_or(0.8)
};
let cr_i = if use_adaptive {
cr_values[i]
} else {
cr.unwrap_or(0.9)
};
// Mutation
let mutant = generate_mutant(
&population,
&fitness,
i,
best_idx,
f_i,
de_strategy,
&mut thread_rng,
&bounds,
);
// Crossover
let trial = crossover(&population[i], &mutant, cr_i, &mut thread_rng);
// Selection (evaluate trial - this is the expensive part)
let trial_fitness = objective.evaluate(&trial);
if trial_fitness < fitness[i] {
Some((i, trial, trial_fitness))
} else {
None
}
})
.collect();
nfev += pop_size;
// Apply updates
for (i, trial, trial_fitness) in updates {
population[i] = trial;
fitness[i] = trial_fitness;
// Adaptive parameter update (jDE-style)
if use_adaptive {
let tau = 0.1;
let fl = 0.1;
let fu = 0.9;
let mut thread_rng = StdRng::seed_from_u64(
seed.unwrap_or(42) + (generation * pop_size + i) as u64
);
if thread_rng.gen::<f64>() < tau {
f_values[i] = fl + thread_rng.gen::<f64>() * (fu - fl);
}
if thread_rng.gen::<f64>() < tau {
cr_values[i] = thread_rng.gen::<f64>();
}
}
}
}
// Find final best
let mut best_idx = 0;
let mut best_fitness = fitness[0];
for (i, &fit) in fitness.iter().enumerate() {
if fit < best_fitness {
best_fitness = fit;
best_idx = i;
}
}
Ok(DEResult {
x: population[best_idx].clone(),
fun: best_fitness,
nfev,
n_generations: if let Some(ref h) = history {
h.len()
} else {
maxiter
},
history,
success: best_fitness.is_finite(),
message: if best_fitness.is_finite() {
"Optimization converged".to_string()
} else {
"Optimization failed".to_string()
},
})
}
#[cfg(test)]
mod tests {
use super::*;