Skip to main content

feral_metis/
lib.rs

1//! Multilevel nested-dissection fill-reducing ordering.
2//!
3//! Clean-room Rust implementation of the algorithm described in
4//! Karypis & Kumar, "A Fast and High Quality Multilevel Scheme for
5//! Partitioning Irregular Graphs" (SIAM J. Sci. Comput., 1998), and
6//! George, "Nested Dissection of a Regular Finite Element Mesh"
7//! (SIAM J. Numer. Anal., 1973).
8//!
9//! The public surface conforms to the FERAL ordering-crate contract
10//! (`dev/plans/ordering-crate-contract.md`): `CscPattern`,
11//! `OrderingStats`, `OrderingError`, and `CONTRACT_VERSION` are
12//! re-exported from `feral-ordering-core`.
13//!
14//! **Status: M1–M7 complete.** `metis_order_full` coarsens the graph
15//! (SHEM + 2-hop), picks the best of `niparts` initial bisections
16//! scored on their post-FM cut, uncoarsens with FM refinement, turns
17//! the final edge bisection into a node separator via min vertex
18//! cover (König's theorem), and recursively orders the two sides —
19//! handing off to AMD on subgraphs no larger than
20//! `nd_to_amd_switch`. M8 (integration into the main solver) is
21//! tracked separately in `dev/plans/ordering-metis.md`.
22
23#![forbid(unsafe_code)]
24#![deny(missing_docs)]
25
26// Modules are exercised only by `metis_order_full` once all
27// milestones land; until then, dead-code lint is suppressed at the
28// module root for internal helpers.
29#[doc(hidden)]
30#[allow(dead_code, missing_docs)]
31pub mod coarsen;
32#[doc(hidden)]
33#[allow(dead_code, missing_docs)]
34pub mod fm_refine;
35#[doc(hidden)]
36#[allow(dead_code, missing_docs)]
37pub mod graph;
38#[doc(hidden)]
39#[allow(dead_code, missing_docs)]
40pub mod initial_partition;
41mod node_nd;
42#[doc(hidden)]
43#[allow(dead_code, missing_docs)]
44pub mod rng;
45#[doc(hidden)]
46#[allow(dead_code, missing_docs)]
47pub mod separator;
48
49/// Crate-internal infrastructure exposed for sibling ordering
50/// crates (notably `feral-scotch`) that share the multilevel
51/// coarsening, initial-bisection, and FM-refinement plumbing.
52///
53/// **Not part of the stable public API.** No semver guarantees on
54/// signatures inside `internals`; consumers re-export it at their
55/// own risk. This module exists solely so feral-scotch does not
56/// have to clone the multilevel framework.
57#[doc(hidden)]
58pub mod internals {
59    pub use crate::coarsen;
60    pub use crate::fm_refine;
61    pub use crate::graph;
62    pub use crate::initial_partition;
63    pub use crate::rng;
64    pub use crate::separator;
65}
66
67pub use feral_ordering_core::{CscPattern, OrderingError, OrderingStats, CONTRACT_VERSION};
68
69/// Tunable parameters for METIS nested-dissection ordering.
70///
71/// Defaults mirror METIS 5.2.0's `METIS_NodeND` defaults as documented
72/// in `dev/plans/ordering-metis.md` audit (MUMPS uses stock METIS
73/// defaults for KKT problems: `METIS_OPTION_NUMBERING = 1`, all other
74/// options at library default).
75#[derive(Debug, Clone)]
76pub struct MetisOptions {
77    /// Deterministic RNG seed. Defaults to 1. Two runs with the same
78    /// seed on the same input must produce the same permutation.
79    pub seed: u64,
80    /// Number of initial-bisection trials at the coarsest level
81    /// (METIS 5.2.0 default: 7). Each trial alternates GGP and random
82    /// BFS and is scored on its post-FM cut.
83    pub niparts: u32,
84    /// Stop coarsening when the graph has fewer than this many
85    /// vertices (METIS 5.2.0 default: 120).
86    pub coarsen_floor: u32,
87    /// Switch from recursive ND to AMD on uncoarsened subproblems of
88    /// at most this many vertices (METIS 5.2.0 default: 200).
89    pub nd_to_amd_switch: u32,
90    /// Reduction-ratio threshold below which SHEM falls back to
91    /// 2-hop matching (METIS 5.2.0 default: 0.85).
92    pub two_hop_ratio_threshold: f64,
93    /// Maximum partition imbalance factor (`ufactor` in METIS terms,
94    /// encoded as a fraction here). METIS 5.2.0 uses 200, which
95    /// corresponds to 1.20 load balance tolerance; expressed as the
96    /// fractional deviation 0.20.
97    pub max_imbalance: f64,
98    /// Number of FM passes at each uncoarsening level (METIS 5.2.0
99    /// default: 10).
100    pub fm_passes: u32,
101    /// Carry the **node separator** through uncoarsening and refine it
102    /// with FM at every level (METIS's `Refine2WayNode` /
103    /// `FM_2WayNodeRefine1Sided`), instead of refining the edge cut
104    /// through uncoarsening and converting to a separator once at the
105    /// finest level.
106    ///
107    /// **Default: `true`.** The two paths share coarsening and initial
108    /// bisection; they differ only in what the uncoarsening loop
109    /// optimises. Measured against MA57's bundled real METIS as the
110    /// oracle (`dev/research/feral-metis-node-separator-fm-2026-09-17.md`),
111    /// on the six matrices in this repo large enough for nested
112    /// dissection to engage, `node_refine` cuts the elimination flop
113    /// count by 2.4-3.5x on collocation KKTs. It is **not a uniform
114    /// win**: measured in wall-clock on grid Laplacians (min over 3
115    /// runs of 5 pairs each, non-overlapping), a 40^3 grid factors
116    /// 12.8% *faster* while a 300x300 grid factors 5.8% **slower**
117    /// despite 10.3% less fill — one more case of fill not predicting
118    /// speed. On every other matrix measured (seven real IPM families
119    /// up to n=607,500, and 37 of 38 parity matrices) the permutation
120    /// is bit-identical, so there is nothing to win or lose.
121    ///
122    /// Set to `false` to recover the pre-2026-09-17 edge-cut
123    /// behaviour.
124    pub node_refine: bool,
125    /// Pull near-dense columns out of the ND graph before recursive
126    /// bisection and append them at the *end* of the returned
127    /// permutation.
128    ///
129    /// **Default: `false`.** The technique was implemented to mimic
130    /// what we believed MUMPS's `ICNTL(6)` and SSIDS did, but expert
131    /// review of the MUMPS and SPRAL sources (2026-04-27) found:
132    /// (a) `ICNTL(6)` is MC64 matching, not dense-row removal;
133    /// (b) MUMPS handles dense rows *inside* its AMD/AMF
134    /// (`MUMPS_QAMD` in `ana_orderings.F:5226+` with the `THRESM`
135    /// parameter and `HEAD(N)` quasi-dense list); and
136    /// (c) SSIDS does not special-case dense rows at all — it relies
137    /// on METIS placing them in the top separator and supernodal
138    /// amalgamation collapsing the resulting chain into one dense
139    /// BLAS-3 root frontal. Neither solver pre-strips the graph.
140    /// Empirically, on ORBIT2_0000 (n=4795, one column of off-degree
141    /// 1794) Fix A *increased* `nnz_L` from 1.54M to 2.25M because
142    /// removing the dense column destroys the structural signal that
143    /// makes it the natural top separator. The opt-in path is kept
144    /// for diagnostic experimentation; the correct fix lives in
145    /// `feral-amd` (a QAMD-style deferral, future work).
146    ///
147    /// References (kept for the opt-in code path):
148    /// - Davis & Hager, "Dynamic supernodes in sparse Cholesky
149    ///   update/downdate and triangular solves" (2009), §3.2.
150    /// - Davis (1996) AMD paper, §5 ("dense rows / `Alpha` parameter").
151    /// - MUMPS source: `ana_orderings.F:5226-5650` (QAMD).
152    pub dense_quotient_enabled: bool,
153    /// Override the off-diagonal-degree threshold above which a column
154    /// is treated as quasi-dense.
155    ///
156    /// When `None` (the default) the threshold is computed as
157    /// `max(40, ceil(10 * sqrt(n)))` per Davis & Hager / AMD §5. Set
158    /// to `Some(usize::MAX)` to effectively disable the quotient
159    /// without flipping `dense_quotient_enabled` (useful for
160    /// regression sweeps).
161    pub dense_quotient_threshold: Option<usize>,
162}
163
164impl Default for MetisOptions {
165    fn default() -> Self {
166        Self {
167            seed: 1,
168            niparts: 7,
169            coarsen_floor: 120,
170            nd_to_amd_switch: 200,
171            two_hop_ratio_threshold: 0.85,
172            max_imbalance: 0.20,
173            fm_passes: 10,
174            node_refine: true,
175            dense_quotient_enabled: false,
176            dense_quotient_threshold: None,
177        }
178    }
179}
180
181/// Crate-specific diagnostic counters for METIS nested dissection.
182///
183/// Populated per call to [`metis_order_full`]. Callers that only need
184/// the permutation should use [`metis_order`]; callers that need the
185/// shared [`OrderingStats`] (wall time) should use
186/// [`metis_order_full`].
187#[derive(Debug, Default, Clone, PartialEq, Eq)]
188pub struct MetisStats {
189    /// Number of coarsening levels built.
190    pub n_levels: u32,
191    /// Number of top-level connected components encountered.
192    pub n_components: u32,
193    /// Number of vertices assigned to a separator at any level.
194    pub n_separator_vertices: u32,
195    /// Number of FM passes executed across all levels.
196    pub n_fm_passes: u32,
197    /// Number of times SHEM fell through to the 2-hop matching path.
198    pub n_two_hop_fallbacks: u32,
199    /// Number of subgraphs handed off to the AMD leaf solver (when
200    /// `nd_to_amd_switch` triggers).
201    pub n_amd_leaf_calls: u32,
202}
203
204/// Compute a fill-reducing METIS nested-dissection ordering.
205///
206/// Thin wrapper over [`metis_order_full`] that discards the
207/// diagnostic stats. Returns a permutation `perm` (new-to-old).
208pub fn metis_order(pattern: &CscPattern<'_>) -> Result<Vec<i32>, OrderingError> {
209    metis_order_full(pattern, &MetisOptions::default()).map(|(perm, _, _)| perm)
210}
211
212/// Contract-conforming ordering producer.
213///
214/// Signature matches the shape every FERAL ordering crate must expose
215/// per `dev/plans/ordering-crate-contract.md`: input is a
216/// full-symmetric [`CscPattern`] and options; output is a three-tuple
217/// of `(perm, OrderingStats, crate-stats)`, with errors in
218/// [`OrderingError`].
219///
220/// `OrderingStats.time_us` is the wall-clock time of this call.
221/// `fill_estimate` and `flop_estimate` stay `None` — METIS does not
222/// produce them at the ordering boundary; they belong to a downstream
223/// symbolic analysis.
224///
225/// Runs the M1–M7 pipeline: coarsen, initial bisection, FM, separator
226/// construction, and recursive nested dissection with an AMD leaf
227/// fallback for subgraphs of at most `nd_to_amd_switch` vertices.
228pub fn metis_order_full(
229    pattern: &CscPattern<'_>,
230    opts: &MetisOptions,
231) -> Result<(Vec<i32>, OrderingStats, MetisStats), OrderingError> {
232    if pattern.col_ptr.len() != pattern.n + 1 {
233        return Err(OrderingError::MalformedInput);
234    }
235    let t0 = std::time::Instant::now();
236    let mut stats = MetisStats::default();
237
238    // Fix A — quasi-dense column quotient.
239    //
240    // Pull columns with off-diagonal degree above the
241    // `dense_quotient_threshold` (default `max(40, 10*sqrt(n))`) out
242    // of the ND input graph, run M1–M7 ND on the *sparse-induced*
243    // subgraph, and append the dense columns at the end of the
244    // returned permutation. This was originally modelled on a belief
245    // that HSL_MC68 / MUMPS ICNTL(6) / SSIDS pre-strip dense rows, but
246    // a 2026-04-27 audit of the MUMPS and SPRAL sources found that
247    // belief wrong: ICNTL(6) is MC64 matching, MUMPS defers dense rows
248    // inside QAMD, and SSIDS does not special-case them — neither
249    // pre-strips the graph. See `MetisOptions::dense_quotient_enabled`
250    // for the full finding. The path is kept opt-in (default off) for
251    // diagnostic use only.
252    let (sparse_pat_storage, dense_cols, sparse_to_orig) =
253        if opts.dense_quotient_enabled && pattern.n > 0 {
254            split_dense_columns(pattern, opts)?
255        } else {
256            (None, Vec::new(), Vec::new())
257        };
258
259    let perm = if let Some((cp, ri, sub_n)) = sparse_pat_storage.as_ref().map(|s| {
260        let (cp, ri, sub_n) = s;
261        (cp.as_slice(), ri.as_slice(), *sub_n)
262    }) {
263        // Run ND on the sparse-induced subgraph.
264        let sub_pat = CscPattern::new(sub_n, cp, ri).ok_or(OrderingError::MalformedInput)?;
265        let sub_perm = node_nd::nd_order(&sub_pat, opts, &mut stats)?;
266        // Lift sub-perm back to original indices and append dense
267        // columns at the end (in descending degree order — Davis &
268        // Hager 2009 §3.2 ordering choice; ties broken by ascending
269        // original index).
270        let mut perm: Vec<i32> = Vec::with_capacity(pattern.n);
271        for &local in &sub_perm {
272            let idx = local as usize;
273            if idx >= sparse_to_orig.len() {
274                return Err(OrderingError::Internal(
275                    "dense-quotient: subgraph perm index out of range",
276                ));
277            }
278            perm.push(sparse_to_orig[idx]);
279        }
280        for &c in &dense_cols {
281            perm.push(c);
282        }
283        if perm.len() != pattern.n {
284            return Err(OrderingError::Internal(
285                "dense-quotient: assembled perm has wrong length",
286            ));
287        }
288        perm
289    } else {
290        node_nd::nd_order(pattern, opts, &mut stats)?
291    };
292
293    let ordering_stats = OrderingStats {
294        time_us: t0.elapsed().as_micros() as u64,
295        fill_estimate: None,
296        flop_estimate: None,
297    };
298    Ok((perm, ordering_stats, stats))
299}
300
301/// Resolve the dense-column threshold for an `n`-vertex graph.
302///
303/// `max(40, ceil(10 * sqrt(n)))` per Davis & Hager 2009 §3.2 and
304/// MUMPS `ICNTL(6)` defaults. Honours the caller's override when
305/// `opts.dense_quotient_threshold` is `Some(_)`.
306fn resolve_dense_threshold(n: usize, opts: &MetisOptions) -> usize {
307    if let Some(t) = opts.dense_quotient_threshold {
308        return t;
309    }
310    let computed = (10.0 * (n as f64).sqrt()).ceil() as usize;
311    computed.max(40)
312}
313
314/// Partition `pattern`'s columns into "dense" and "sparse" sets using
315/// off-diagonal degree, and produce the CSC pattern of the
316/// sparse-induced subgraph.
317///
318/// Returns:
319/// - `Some((col_ptr, row_idx, sub_n))` carrying the induced
320///   sub-pattern, plus the dense column list (in descending degree
321///   order) and the `sparse_local → original` mapping. When the
322///   dense set is empty, returns `(None, Vec::new(), Vec::new())` so
323///   the caller can fast-path to the original pattern.
324type DenseSplit = (Option<(Vec<i32>, Vec<i32>, usize)>, Vec<i32>, Vec<i32>);
325fn split_dense_columns(
326    pattern: &CscPattern<'_>,
327    opts: &MetisOptions,
328) -> Result<DenseSplit, OrderingError> {
329    let n = pattern.n;
330    let thresh = resolve_dense_threshold(n, opts);
331
332    // Off-diagonal degree per column. The pattern is full-symmetric
333    // with the diagonal optionally present; we count entries `r != c`.
334    let mut deg: Vec<usize> = vec![0; n];
335    for (c, d) in deg.iter_mut().enumerate() {
336        let lo = pattern.col_ptr[c] as usize;
337        let hi = pattern.col_ptr[c + 1] as usize;
338        if hi < lo || hi > pattern.row_idx.len() {
339            return Err(OrderingError::MalformedInput);
340        }
341        let mut acc: usize = 0;
342        for k in lo..hi {
343            let r = pattern.row_idx[k] as usize;
344            if r != c {
345                acc += 1;
346            }
347        }
348        *d = acc;
349    }
350
351    // Collect dense columns.
352    let mut dense: Vec<i32> = (0..n)
353        .filter(|&c| deg[c] > thresh)
354        .map(|c| c as i32)
355        .collect();
356
357    // No-op fast path: dense set empty.
358    if dense.is_empty() {
359        return Ok((None, Vec::new(), Vec::new()));
360    }
361
362    // Sort dense columns by *descending* degree, ties by ascending
363    // original index — Davis & Hager 2009 §3.2: "eliminate the densest
364    // last".
365    dense.sort_by(|&a, &b| {
366        deg[b as usize]
367            .cmp(&deg[a as usize])
368            .then_with(|| a.cmp(&b))
369    });
370
371    // Build the local-id maps for the sparse subgraph.
372    //
373    // `sparse_to_orig[local] = original`
374    // `orig_to_local[original] = sparse local id, or -1 if dense`.
375    let mut is_dense = vec![false; n];
376    for &c in &dense {
377        is_dense[c as usize] = true;
378    }
379    let mut sparse_to_orig: Vec<i32> = Vec::with_capacity(n - dense.len());
380    let mut orig_to_local: Vec<i32> = vec![-1; n];
381    for c in 0..n {
382        if !is_dense[c] {
383            orig_to_local[c] = sparse_to_orig.len() as i32;
384            sparse_to_orig.push(c as i32);
385        }
386    }
387    let sub_n = sparse_to_orig.len();
388
389    // Build the induced CSC pattern. Re-include the diagonal entry so
390    // downstream consumers (Graph::from_csc_pattern, AMD leaf) see a
391    // well-formed pattern. Row indices stay sorted because we walk
392    // each original column in ascending row order.
393    let mut col_ptr: Vec<i32> = Vec::with_capacity(sub_n + 1);
394    let mut row_idx: Vec<i32> = Vec::new();
395    col_ptr.push(0);
396    for &orig in &sparse_to_orig {
397        let c = orig as usize;
398        let lo = pattern.col_ptr[c] as usize;
399        let hi = pattern.col_ptr[c + 1] as usize;
400        let mut diag_inserted = false;
401        let local_c = orig_to_local[c];
402        for k in lo..hi {
403            let r = pattern.row_idx[k] as usize;
404            if r == c {
405                // Diagonal handled below; skip here so we control its
406                // placement (input may or may not carry the diagonal).
407                continue;
408            }
409            let lr = orig_to_local[r];
410            if lr < 0 {
411                // Edge crosses into the dense set — drop it from the
412                // sparse-induced subgraph; the dense column carries
413                // that coupling and is eliminated at the end.
414                continue;
415            }
416            if !diag_inserted && lr > local_c {
417                row_idx.push(local_c);
418                diag_inserted = true;
419            }
420            row_idx.push(lr);
421        }
422        if !diag_inserted {
423            row_idx.push(local_c);
424        }
425        col_ptr.push(row_idx.len() as i32);
426    }
427
428    Ok((Some((col_ptr, row_idx, sub_n)), dense, sparse_to_orig))
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    fn trivial_pattern() -> (Vec<i32>, Vec<i32>) {
436        // Diagonal n=3: col_ptr=[0,1,2,3], row_idx=[0,1,2]
437        (vec![0, 1, 2, 3], vec![0, 1, 2])
438    }
439
440    #[test]
441    fn options_defaults_match_metis_5_2_0() {
442        let o = MetisOptions::default();
443        assert_eq!(o.niparts, 7);
444        assert_eq!(o.coarsen_floor, 120);
445        assert_eq!(o.nd_to_amd_switch, 200);
446        assert_eq!(o.seed, 1);
447    }
448
449    #[test]
450    fn stats_default_is_zeros() {
451        let s = MetisStats::default();
452        assert_eq!(s.n_levels, 0);
453        assert_eq!(s.n_components, 0);
454        assert_eq!(s.n_separator_vertices, 0);
455        assert_eq!(s.n_fm_passes, 0);
456        assert_eq!(s.n_two_hop_fallbacks, 0);
457        assert_eq!(s.n_amd_leaf_calls, 0);
458    }
459
460    #[test]
461    fn diagonal_pattern_yields_permutation() {
462        let (cp, ri) = trivial_pattern();
463        let pat = CscPattern::new(3, &cp, &ri).unwrap();
464        let (perm, ostats, _mstats) = metis_order_full(&pat, &MetisOptions::default()).expect("ok");
465        assert_eq!(perm.len(), 3);
466        let mut seen = [false; 3];
467        for &p in &perm {
468            assert!((0..3).contains(&p));
469            seen[p as usize] = true;
470        }
471        assert!(seen.iter().all(|&s| s));
472        // time_us is populated; fill/flop remain None.
473        assert!(ostats.fill_estimate.is_none());
474        assert!(ostats.flop_estimate.is_none());
475    }
476
477    #[test]
478    fn convenience_wrapper_returns_permutation() {
479        let (cp, ri) = trivial_pattern();
480        let pat = CscPattern::new(3, &cp, &ri).unwrap();
481        let perm = metis_order(&pat).expect("ok");
482        assert_eq!(perm.len(), 3);
483    }
484
485    #[test]
486    fn contract_version_matches_core() {
487        assert_eq!(CONTRACT_VERSION, feral_ordering_core::CONTRACT_VERSION);
488    }
489}