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(°[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}