perf(hypervolume): index-sort instead of cloning point vectors

The M≥3 branch of `hso_recursive` cloned every input point into
`sorted: Vec<Vec<f64>>` 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<f64>
allocations.

Sort indices into a `Vec<usize>` instead, then iterate the original
points by index. The pre-projection step still produces a
Vec<Vec<f64>> (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.
This commit is contained in:
2026-05-05 13:28:18 -06:00
parent adf18950dc
commit f01089e9d0
+16 -13
View File
@@ -224,25 +224,28 @@ fn hso_recursive(points: &[Vec<f64>], 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<Vec<f64>> = 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<usize> = (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<Vec<f64>> = 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<Vec<f64>> = 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<f64>], reference: &[f64]) -> f64 {
};
total += depth * inner;
}
prev = p[last];
prev = p_last;
}
total