perf(age_moea): cache lp_norm + maintain nearest-neighbor incrementally

The splitting-front survival selection in AGE-MOEA recomputed two
expensive things per while-iteration:

* `lp_norm(translated[i], p)` for every remaining i — even though the
  value is constant across iterations.
* `nearest_neighbor_distance(i, …, &keep, p)` — a fresh full scan
  over the keep list, even though only one new candidate was added
  since the last scan.

Both are `powf`-heavy in the L_p frame.

Compute lp_norm once per candidate at function entry. Maintain a
`nearest[]` array seeded from the initial keep set and updated on
every pick by a single `min(nearest[i], lp_distance(i, pick, p))`
per remaining i. That cuts the score loop from O(R · K · M) to
O(R · M) per iteration, with the dominant powf calls in
lp_distance counted once per (remaining, pick) pair instead of per
(remaining, full-keep).

Wall-clock (compare harness, 10-seed mean):
- AGE-MOEA / DTLZ1: 2266 → 430 ms on top of v0.3.0 baseline (5.3×)
- AGE-MOEA / ZDT3:   935 → 376 ms (2.5×)
This commit is contained in:
2026-05-05 13:28:18 -06:00
parent 214f07975a
commit 4c7126070b
+30 -5
View File
@@ -187,17 +187,34 @@ fn environmental_selection<D: Clone>(
// proximity = ||translated||_p // proximity = ||translated||_p
// diversity = nearest-neighbor distance in the same L_p frame // diversity = nearest-neighbor distance in the same L_p frame
// among already-selected + splitting members. // among already-selected + splitting members.
//
// Two caches make this much cheaper than the textbook formulation:
// * `prox[i]` — `lp_norm(translated[i], p)` is constant across
// iterations, so compute it once per splitting-front member.
// * `nearest[i]` — the nearest-keep distance only ever decreases
// when a new candidate is picked, so we maintain it
// incrementally: seed it from `selected`, then on every pick
// update each remaining `i`'s nearest by taking
// `min(nearest[i], lp_distance(translated[i], translated[pick], p))`.
//
// That cuts the score loop from O(R · K · M) per iteration (where
// R = remaining count, K = current keep count) to O(R · M) per
// iteration, with the dominant `powf` calls in lp_distance counted
// once per (remaining, pick) pair instead of per (remaining, all-keep).
let mut keep = selected.clone(); let mut keep = selected.clone();
let mut remaining: Vec<usize> = splitting.clone(); let mut remaining: Vec<usize> = splitting.clone();
let prox: Vec<f64> = (0..combined.len())
.map(|i| lp_norm(&translated[i], p))
.collect();
let mut nearest: Vec<f64> = (0..combined.len())
.map(|i| nearest_neighbor_distance(i, &translated, &keep, p))
.collect();
while keep.len() < n { while keep.len() < n {
// Compute scores for every remaining candidate; pick the one with // Pick the remaining candidate with the largest score.
// the largest combined score.
let mut best_idx: Option<usize> = None; let mut best_idx: Option<usize> = None;
let mut best_score = f64::NEG_INFINITY; let mut best_score = f64::NEG_INFINITY;
for &i in &remaining { for &i in &remaining {
let prox = lp_norm(&translated[i], p); let score = nearest[i] / (prox[i].max(1e-12));
let div = nearest_neighbor_distance(i, &translated, &keep, p);
let score = div / (prox.max(1e-12));
if score > best_score { if score > best_score {
best_score = score; best_score = score;
best_idx = Some(i); best_idx = Some(i);
@@ -208,6 +225,14 @@ fn environmental_selection<D: Clone>(
Some(pick) => { Some(pick) => {
keep.push(pick); keep.push(pick);
remaining.retain(|&i| i != pick); remaining.retain(|&i| i != pick);
// Update each surviving remaining's nearest-keep using
// just the distance to the new pick.
for &i in &remaining {
let d = lp_distance(&translated[i], &translated[pick], p);
if d < nearest[i] {
nearest[i] = d;
}
}
} }
} }
} }