From 4c7126070b12ba1dc5add767e08c1f8b7715a516 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 5 May 2026 12:03:52 -0600 Subject: [PATCH] perf(age_moea): cache lp_norm + maintain nearest-neighbor incrementally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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×) --- src/algorithms/age_moea.rs | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/algorithms/age_moea.rs b/src/algorithms/age_moea.rs index cc53b0b..f902e4e 100644 --- a/src/algorithms/age_moea.rs +++ b/src/algorithms/age_moea.rs @@ -187,17 +187,34 @@ fn environmental_selection( // proximity = ||translated||_p // diversity = nearest-neighbor distance in the same L_p frame // 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 remaining: Vec = splitting.clone(); + let prox: Vec = (0..combined.len()) + .map(|i| lp_norm(&translated[i], p)) + .collect(); + let mut nearest: Vec = (0..combined.len()) + .map(|i| nearest_neighbor_distance(i, &translated, &keep, p)) + .collect(); while keep.len() < n { - // Compute scores for every remaining candidate; pick the one with - // the largest combined score. + // Pick the remaining candidate with the largest score. let mut best_idx: Option = None; let mut best_score = f64::NEG_INFINITY; for &i in &remaining { - let prox = lp_norm(&translated[i], p); - let div = nearest_neighbor_distance(i, &translated, &keep, p); - let score = div / (prox.max(1e-12)); + let score = nearest[i] / (prox[i].max(1e-12)); if score > best_score { best_score = score; best_idx = Some(i); @@ -208,6 +225,14 @@ fn environmental_selection( Some(pick) => { keep.push(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; + } + } } } }