perf(non_dominated_sort): cache oriented values + inline pareto_compare

The Deb fast non-dominated sort calls `pareto_compare` twice for
every (i, j) pair, and each `pareto_compare` call invokes
`ObjectiveSpace::as_minimization` twice — so for an N-point
population that's 4·N·(N-1) fresh `Vec<f64>` allocations per sort.
At N=100 with thousands of generations across the compare harness,
this dominated the per-generation cost of every Pareto-based MOEA.

Cache `as_minimization`/feasibility/violation once per individual
up front, then inline the dominance test against those cached
arrays. The output (per-pair dominance outcome and the per-i
`dominates` lists) is bit-identical to `pareto_compare`.

gungraun (instructions):
- non_dominated_sort_2d n=50:    852 317 →   198 574 (-77 %, 4.3×)
- non_dominated_sort_2d n=200: 13 513 271 → 2 601 813 (-81 %, 5.2×)

Wall-clock (compare harness, 10-seed mean):
- NSGA-II / ZDT1:        268 →  65 ms (4.1×)
- NSGA-II / ZDT3:        267 →  65 ms (4.1×)
- NSGA-II / DTLZ2:       344 → 106 ms (3.2×)
- NSGA-II / Rastrigin:   260 →  71 ms (3.7×)
- NSGA-III / DTLZ2:      318 → 122 ms (2.6×)
- NSGA-III / DTLZ1:      303 → 122 ms (2.5×)
- SMS-EMOA / DTLZ2:     1413 → 1369 ms (small additional win on top of HV)
- AGE-MOEA / DTLZ1:      430 → 229 ms (1.9×, on top of the AGE-MOEA caching)
- HypE / DTLZ2:           80 →  44 ms (1.8×)
This commit is contained in:
2026-05-05 13:28:18 -06:00
parent 4745a6bb16
commit 214f07975a
+63 -9
View File
@@ -2,7 +2,6 @@
use crate::core::candidate::Candidate;
use crate::core::objective::ObjectiveSpace;
use crate::pareto::dominance::{Dominance, pareto_compare};
/// Partition the population into Pareto fronts by dominance rank.
///
@@ -19,24 +18,79 @@ pub fn non_dominated_sort<D>(
return Vec::new();
}
// Precompute the per-individual feasibility, violation, and
// minimization-oriented objective vectors. The naïve formulation
// calls `pareto_compare` (and therefore `as_minimization`) twice for
// every pair, allocating two fresh Vec<f64>s per call; doing it once
// up front cuts that to one allocation per individual.
let feasible: Vec<bool> = population
.iter()
.map(|c| c.evaluation.is_feasible())
.collect();
let violation: Vec<f64> = population
.iter()
.map(|c| c.evaluation.constraint_violation)
.collect();
let oriented: Vec<Vec<f64>> = population
.iter()
.map(|c| objectives.as_minimization(&c.evaluation.objectives))
.collect();
let m = objectives.len();
let mut dominates: Vec<Vec<usize>> = vec![Vec::new(); n];
let mut dominated_by_count: Vec<usize> = vec![0; n];
let mut fronts: Vec<Vec<usize>> = Vec::new();
let mut first_front: Vec<usize> = Vec::new();
for i in 0..n {
let ai_feasible = feasible[i];
let ai_violation = violation[i];
let ai = &oriented[i];
for j in 0..n {
if i == j {
continue;
}
match pareto_compare(
&population[i].evaluation,
&population[j].evaluation,
objectives,
) {
Dominance::Dominates => dominates[i].push(j),
Dominance::DominatedBy => dominated_by_count[i] += 1,
_ => {}
let bi_feasible = feasible[j];
let bi_violation = violation[j];
// Inline the body of `pareto_compare`. We only care about
// `Dominates` vs `DominatedBy`; `Equal` and `NonDominated`
// are no-ops here.
let dominates_outcome = match (ai_feasible, bi_feasible) {
(true, false) => Some(true), // i dominates j
(false, true) => Some(false), // i is dominated
(false, false) => {
if ai_violation < bi_violation {
Some(true)
} else if ai_violation > bi_violation {
Some(false)
} else {
None
}
}
(true, true) => {
let bj = &oriented[j];
let mut a_better_anywhere = false;
let mut b_better_anywhere = false;
for k in 0..m {
let av = ai[k];
let bv = bj[k];
if av < bv {
a_better_anywhere = true;
} else if av > bv {
b_better_anywhere = true;
}
}
match (a_better_anywhere, b_better_anywhere) {
(true, false) => Some(true),
(false, true) => Some(false),
_ => None,
}
}
};
match dominates_outcome {
Some(true) => dominates[i].push(j),
Some(false) => dominated_by_count[i] += 1,
None => {}
}
}
if dominated_by_count[i] == 0 {