From f01089e9d02cbef97fdbb72067cd3005911ee105 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 5 May 2026 12:03:52 -0600 Subject: [PATCH] perf(hypervolume): index-sort instead of cloning point vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The M≥3 branch of `hso_recursive` cloned every input point into `sorted: Vec>` solely so it could sort. Each clone is M f64s allocated; with N points per call and ~30 HV calls per SMS-EMOA generation × 30 k generations, that's millions of small Vec allocations. Sort indices into a `Vec` instead, then iterate the original points by index. The pre-projection step still produces a Vec> (which the active-prefix slicing requires), but we save the outer N inner-Vec clones per call. gungraun (instructions): - hypervolume_nd_3d n=30: 87 969 → 70 334 (-20 %, 1.25×) - hypervolume_nd_3d n=100: 422 767 → 367 767 (-13 %, 1.15×) Cumulative vs the v0.3.0 baseline: - hypervolume_nd_3d n=30: 676 902 → 70 334 (9.6×) - hypervolume_nd_3d n=100: 13 523 760 → 367 767 (37×) Wall-clock impact is in the noise on the compare harness because the SMS-EMOA worst-front HV calls operate on small fronts (5–10 points once converged). The win is most visible in synthetic dense-front HV benchmarks. --- src/metrics/hypervolume.rs | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/metrics/hypervolume.rs b/src/metrics/hypervolume.rs index 875c44c..c3bcfc3 100644 --- a/src/metrics/hypervolume.rs +++ b/src/metrics/hypervolume.rs @@ -224,25 +224,28 @@ fn hso_recursive(points: &[Vec], reference: &[f64]) -> f64 { // `k` is exactly the prefix `sorted[..=k]` — no allocations or // linear-scan removals needed. let last = m - 1; - let mut sorted: Vec> = points.to_vec(); - sorted.sort_by(|a, b| { - a[last] - .partial_cmp(&b[last]) + // Index-sort instead of cloning every point's inner vector. The + // recursion stays bit-identical because we still iterate the same + // points in the same order. + let mut order: Vec = (0..points.len()).collect(); + order.sort_by(|&i, &j| { + points[i][last] + .partial_cmp(&points[j][last]) .unwrap_or(std::cmp::Ordering::Equal) }); - // Pre-project all points onto the first M-1 axes once. The active - // set at each band is the prefix `projected_all[..=k]`; we slice - // that prefix instead of rebuilding it per band. - let projected_all: Vec> = sorted.iter().map(|q| q[..last].to_vec()).collect(); + // Pre-project once onto the first M-1 axes, in the sorted order. + // The active set at iteration `k` is the prefix `projected[..=k]`, + // so the inner recursion just slices the prefix. + let projected: Vec> = order.iter().map(|&i| points[i][..last].to_vec()).collect(); let sub_reference: &[f64] = &reference[..last]; let mut total = 0.0; let mut prev = reference[last]; - for k in (0..sorted.len()).rev() { - let p = &sorted[k]; - let depth = prev - p[last]; + for k in (0..order.len()).rev() { + let p_last = points[order[k]][last]; + let depth = prev - p_last; if depth > 0.0 { - let active = &projected_all[..=k]; + let active = &projected[..=k]; // The 2-D base case sweeps in sorted-x order and skips any // point with `y >= last_y`, which is exactly the dominance // filter — so for M=3 (sub_reference len 2) we can hand @@ -258,7 +261,7 @@ fn hso_recursive(points: &[Vec], reference: &[f64]) -> f64 { }; total += depth * inner; } - prev = p[last]; + prev = p_last; } total