collective

Browse / github.com/storytold/effectcraft / patch

Subspace Warp: a QL eigen solver and a banded mesh solve make plan() 5-6x cheaper (#2319)

track: Subspace Warp plan: Jacobi eigen solver and dense Cholesky dominate (4.8-6.2x) ## Problem Subspace Warp `plan()`: `subspace.rs:339` calls `sym_eigen` (cyclic Jacobi, `camtrack/linalg.rs:244`) once per frame on an m x m Gram matrix (m up to 61). The stop test `off < 1e-30` is absolute while the entries are about 1e8, so Jacobi needs about 15 sweeps on real analyses and up to the 100 cap on clean trajectories. `sym_eigen` is 60 % (Automatic), 43 % (Enhanced) and 94-96 % (clean low-rank trajectories) of `plan()`. Then `solve_mesh` (`subspace.rs:229-286`) runs a dense O(n^3) Cholesky (n = 234 / 442) on a matrix with bandwidth about 30, and allocates a Vec per data row. ## P

Status
open
Score
0.00
Adopters
0 other operators
Author
vangel76 (imported from pull request #2332, open)
Base
991904888a20b7da72b2a4880b21c7d912154736
Size
+275 −8 in 1 file
Review
pending
Shared
2026-10-28
Id
30280c4734d4d8c48f79846a

How it went

No other operator has reported using it yet.

Diff

raw · crates/track/src/subspace.rs

diff --git a/crates/track/src/subspace.rs b/crates/track/src/subspace.rs--- a/crates/track/src/subspace.rs+++ b/crates/track/src/subspace.rs@@ -27,7 +27,7 @@ use rayon::prelude::*;  use crate::camtrack::Track2D;-use crate::camtrack::linalg::{cholesky_solve, sym_eigen};+use crate::camtrack::linalg::cholesky_solve; use crate::fit::{Model, least_squares}; use crate::solve::Homography; @@ -205,22 +205,73 @@ impl Default for MeshOpts {     } } -/// Normal equations `AᵀA x = Aᵀb`, accumulated row by row.+/// Normal equations `AᵀA x = Aᵀb`, accumulated row by row. `AᵀA` is symmetric and banded (every+/// row couples unknowns at most `bw` apart), so only its lower band is stored: entry `(i, j)`+/// with `i − bw ≤ j ≤ i` sits at `i * (bw + 1) + j + bw − i`. struct Normal {-    n: usize,+    bw: usize,     a: Vec<f64>,     b: Vec<f64>, }  impl Normal {     fn row(&mut self, coefs: &[(usize, f64)], rhs: f64, w: f64) {+        let width = self.bw + 1;         for (ia, ca) in coefs {             self.b[*ia] += w * ca * rhs;             for (ib, cb) in coefs {-                self.a[ia * self.n + ib] += w * ca * cb;+                if ib <= ia {+                    self.a[ia * width + ib + self.bw - ia] += w * ca * cb;+                }+            }+        }+    }+}++/// Cholesky solve of `A x = b` for the banded `Normal::a`, in place. Returns false when `A` is not+/// positive definite.+fn cholesky_band(a: &mut [f64], n: usize, bw: usize, b: &mut [f64]) -> bool {+    let w = bw + 1;+    for j in 0..n {+        let kj = j.saturating_sub(bw);+        let rowj = j * w + kj + bw - j;+        let mut d = a[j * w + bw];+        for v in &a[rowj..j * w + bw] {+            d -= v * v;+        }+        if d <= 0.0 || !d.is_finite() {+            return false;+        }+        let d = d.sqrt();+        a[j * w + bw] = d;+        for i in j + 1..(j + bw + 1).min(n) {+            let ki = i - bw.min(i);+            let mut s = a[i * w + j + bw - i];+            let len = j - ki;+            let (head, tail) = a.split_at_mut(i * w);+            let li = &tail[ki + bw - i..ki + bw - i + len];+            let lj = &head[j * w + ki + bw - j..j * w + ki + bw - j + len];+            for (x, y) in li.iter().zip(lj) {+                s -= x * y;             }+            a[i * w + j + bw - i] = s / d;         }     }+    for i in 0..n {+        let mut s = b[i];+        for k in i.saturating_sub(bw)..i {+            s -= a[i * w + k + bw - i] * b[k];+        }+        b[i] = s / a[i * w + bw];+    }+    for i in (0..n).rev() {+        let mut s = b[i];+        for k in i + 1..(i + bw + 1).min(n) {+            s -= a[k * w + i + bw - k] * b[k];+        }+        b[i] = s / a[i * w + bw];+    }+    b.iter().all(|v| v.is_finite()) }  /// Fit the content-preserving warp of one frame: `pairs` are (source position, wanted@@ -230,7 +281,9 @@ pub fn solve_mesh(size: [f64; 2], o: &MeshOpts, pairs: &[([f64; 2], [f64; 2])],     let mut m = Mesh::from_homography(o.cols, o.rows, size, base);     let nv = (m.cols + 1) * (m.rows + 1);     let n = 2 * nv;-    let mut ne = Normal { n, a: vec![0.0; n * n], b: vec![0.0; n] };+    // A cell couples its four corners and, through the triangles, vertices up to `cols + 2` apart.+    let bw = (2 * (m.cols + 2) + 1).min(n - 1);+    let mut ne = Normal { bw, a: vec![0.0; n * (bw + 1)], b: vec![0.0; n] };     // Prior.     for k in 0..nv {         let v = m.verts[k];@@ -247,7 +300,7 @@ pub fn solve_mesh(size: [f64; 2], o: &MeshOpts, pairs: &[([f64; 2], [f64; 2])],         let ks = [m.idx(i, j), m.idx(i + 1, j), m.idx(i, j + 1), m.idx(i + 1, j + 1)];         let w = [(1.0 - u) * (1.0 - v), u * (1.0 - v), (1.0 - u) * v, u * v];         for d in 0..2 {-            let coefs: Vec<(usize, f64)> = ks.iter().zip(w).map(|(k, w)| (2 * k + d, w)).collect();+            let coefs: [(usize, f64); 4] = std::array::from_fn(|c| (2 * ks[c] + d, w[c]));             ne.row(&coefs, src[d], 1.0);         }         used += 1;@@ -277,7 +330,7 @@ pub fn solve_mesh(size: [f64; 2], o: &MeshOpts, pairs: &[([f64; 2], [f64; 2])],         }     }     let mut x = ne.b;-    if cholesky_solve(&mut ne.a, n, &mut x) {+    if cholesky_band(&mut ne.a, n, bw, &mut x) {         for k in 0..nv {             m.verts[k] = [x[2 * k], x[2 * k + 1]];         }@@ -285,6 +338,157 @@ pub fn solve_mesh(size: [f64; 2], o: &MeshOpts, pairs: &[([f64; 2], [f64; 2])],     m } +/// Symmetric eigen-decomposition by Householder tridiagonalisation and implicit QL (textbook:+/// Golub & Van Loan §8.3; Wilkinson & Reinsch). Same contract as `sym_eigen`: eigenvalues+/// ascending, eigenvectors as columns of a row-major matrix.+fn sym_eigen_ql(a_in: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {+    // z: row-major n x n, starts as A, ends as eigenvectors (columns).+    let mut z = a_in.to_vec();+    let mut d = vec![0.0; n];+    let mut e = vec![0.0; n];+    // tred2+    for i in (1..n).rev() {+        let l = i - 1;+        let mut h = 0.0;+        if l > 0 {+            let scale: f64 = (0..=l).map(|k| z[i * n + k].abs()).sum();+            if scale == 0.0 {+                e[i] = z[i * n + l];+            } else {+                for k in 0..=l {+                    z[i * n + k] /= scale;+                    h += z[i * n + k] * z[i * n + k];+                }+                let f = z[i * n + l];+                let g = if f >= 0.0 { -h.sqrt() } else { h.sqrt() };+                e[i] = scale * g;+                h -= f * g;+                z[i * n + l] = f - g;+                let mut f = 0.0;+                for j in 0..=l {+                    z[j * n + i] = z[i * n + j] / h;+                    let mut g = 0.0;+                    for k in 0..=j {+                        g += z[j * n + k] * z[i * n + k];+                    }+                    for k in j + 1..=l {+                        g += z[k * n + j] * z[i * n + k];+                    }+                    e[j] = g / h;+                    f += e[j] * z[i * n + j];+                }+                let hh = f / (h + h);+                for j in 0..=l {+                    let f = z[i * n + j];+                    let g = e[j] - hh * f;+                    e[j] = g;+                    for k in 0..=j {+                        z[j * n + k] -= f * e[k] + g * z[i * n + k];+                    }+                }+            }+        } else {+            e[i] = z[i * n + l];+        }+        d[i] = h;+    }+    d[0] = 0.0;+    e[0] = 0.0;+    for i in 0..n {+        if d[i] != 0.0 {+            for j in 0..i {+                let mut g = 0.0;+                for k in 0..i {+                    g += z[i * n + k] * z[k * n + j];+                }+                for k in 0..i {+                    z[k * n + j] -= g * z[k * n + i];+                }+            }+        }+        d[i] = z[i * n + i];+        z[i * n + i] = 1.0;+        for j in 0..i {+            z[j * n + i] = 0.0;+            z[i * n + j] = 0.0;+        }+    }+    // tqli+    for i in 1..n {+        e[i - 1] = e[i];+    }+    if n > 0 {+        e[n - 1] = 0.0;+    }+    for l in 0..n {+        let mut iter = 0;+        loop {+            let mut m = l;+            while m + 1 < n {+                let dd = d[m].abs() + d[m + 1].abs();+                if e[m].abs() <= f64::EPSILON * dd {+                    break;+                }+                m += 1;+            }+            if m == l {+                break;+            }+            iter += 1;+            if iter > 60 {+                break;+            }+            let mut g = (d[l + 1] - d[l]) / (2.0 * e[l]);+            let mut r = g.hypot(1.0);+            g = d[m] - d[l] + e[l] / (g + if g >= 0.0 { r.abs() } else { -r.abs() });+            let (mut s, mut c, mut p) = (1.0, 1.0, 0.0);+            let mut i = m;+            let mut early = false;+            while i > l {+                i -= 1;+                let f = s * e[i];+                let b = c * e[i];+                r = f.hypot(g);+                e[i + 1] = r;+                if r == 0.0 {+                    d[i + 1] -= p;+                    e[m] = 0.0;+                    early = true;+                    break;+                }+                s = f / r;+                c = g / r;+                g = d[i + 1] - p;+                r = (d[i] - g) * s + 2.0 * c * b;+                p = s * r;+                d[i + 1] = g + p;+                g = c * r - b;+                for k in 0..n {+                    let f = z[k * n + i + 1];+                    z[k * n + i + 1] = s * z[k * n + i] + c * f;+                    z[k * n + i] = c * z[k * n + i] - s * f;+                }+            }+            if early {+                continue;+            }+            d[l] -= p;+            e[l] = g;+            e[m] = 0.0;+        }+    }+    let mut order: Vec<usize> = (0..n).collect();+    order.sort_by(|&x, &y| d[x].total_cmp(&d[y]));+    let vals = order.iter().map(|&j| d[j]).collect();+    let mut vecs = vec![0.0; n * n];+    for (k, &j) in order.iter().enumerate() {+        for r in 0..n {+            vecs[r * n + k] = z[r * n + j];+        }+    }+    (vals, vecs)+}+ /// Gaussian weights of the frames `lo..=hi` around `t`. fn gauss(lo: usize, hi: usize, t: usize, sigma: f64) -> Vec<f64> {     let g: Vec<f64> = (lo..=hi).map(|j| (-((j as f64 - t as f64).powi(2)) / (2.0 * sigma * sigma)).exp()).collect();@@ -336,7 +540,7 @@ pub fn smooth_trajectories(tracks: &[Track2D], frames: usize, sigma: f64, rank:                     gram[b * m + a] = s;                 }             }-            let (_, vecs) = sym_eigen(&gram, m);+            let (_, vecs) = sym_eigen_ql(&gram, m);             // C[q][c] = vecs[c][m − 1 − q] (largest first).             let cmat: Vec<Vec<f64>> = (0..k).map(|q| (0..m).map(|c| vecs[c * m + (m - 1 - q)]).collect()).collect();             let g = gauss(lo, hi, t, sigma.max(1e-6));@@ -444,4 +648,67 @@ mod tests {         }         assert!(err_mesh < 0.35 * err_h, "{err_mesh} vs {err_h}");     }++    fn lcg(seed: &mut u64) -> f64 {+        *seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);+        (*seed >> 11) as f64 / (1u64 << 53) as f64 - 0.5+    }++    #[test]+    fn ql_eigen_matches_jacobi() {+        use crate::camtrack::linalg::sym_eigen;+        let mut seed = 7;+        for n in [1, 2, 5, 31, 61] {+            // Large magnitudes like a trajectory Gram matrix, with a low-rank part.+            let rank = 3.min(n);+            let f: Vec<f64> = (0..n * rank).map(|_| 300.0 * lcg(&mut seed)).collect();+            let mut a = vec![0.0; n * n];+            for i in 0..n {+                for j in 0..n {+                    a[i * n + j] = (0..rank).map(|k| f[i * rank + k] * f[j * rank + k]).sum::<f64>() * 40.0;+                }+            }+            let (v0, e0) = sym_eigen(&a, n);+            let (v1, e1) = sym_eigen_ql(&a, n);+            let top = v0[n - 1].abs().max(1.0);+            for k in 0..n {+                assert!((v0[k] - v1[k]).abs() < 1e-9 * top, "n {n} value {k}: {} vs {}", v0[k], v1[k]);+            }+            // The leading subspace (as a projector; signs and basis are free).+            let r = rank.min(n);+            for i in 0..n {+                for j in 0..n {+                    let p0: f64 = (0..r).map(|q| e0[i * n + n - 1 - q] * e0[j * n + n - 1 - q]).sum();+                    let p1: f64 = (0..r).map(|q| e1[i * n + n - 1 - q] * e1[j * n + n - 1 - q]).sum();+                    assert!((p0 - p1).abs() < 1e-9, "n {n} projector ({i},{j}): {p0} vs {p1}");+                }+            }+        }+    }++    #[test]+    fn band_cholesky_matches_dense() {+        let mut seed = 11;+        for (n, bw) in [(1, 0), (6, 2), (40, 7), (40, 39)] {+            let mut dense = vec![0.0; n * n];+            let mut band = vec![0.0; n * (bw + 1)];+            for i in 0..n {+                for j in i.saturating_sub(bw)..=i {+                    let v = if i == j { 4.0 * bw as f64 + 2.0 + lcg(&mut seed) } else { lcg(&mut seed) };+                    dense[i * n + j] = v;+                    dense[j * n + i] = v;+                    band[i * (bw + 1) + j + bw - i] = v;+                }+            }+            let b: Vec<f64> = (0..n).map(|_| 10.0 * lcg(&mut seed)).collect();+            let (mut x0, mut x1) = (b.clone(), b);+            assert!(cholesky_solve(&mut dense, n, &mut x0));+            assert!(cholesky_band(&mut band, n, bw, &mut x1));+            for (a, b) in x0.iter().zip(&x1) {+                assert!((a - b).abs() < 1e-10, "{a} vs {b}");+            }+        }+        let mut band = vec![-1.0, 0.0, 0.0, 1.0];+        assert!(!cholesky_band(&mut band, 2, 1, &mut [1.0, 1.0]));+    } }