R8-g: sparse supernodal LDLt for the Newton tangent (default off)

rtx_fea::solvers::SparseLdlt: nested-dissection ordering, etree,
fundamental supernodes, multifrontal numeric factorisation (blocked
LDLt, matrixmultiply gemm, rayon over subtrees, bit-deterministic at
any thread count), symbolic analysis reused while the pattern holds.

NonlinearDynamicStepper: TangentSolver::{BandedLu (default, unchanged
float for float), SparseLdlt { reuse }} via with_tangent_solver or
RTX_FEA_TANGENT=sparse[:K]; fixed-pattern CSR assembled from parallel
element evaluations (forces summed in the banded path's order);
optional modified Newton (factor reuse). The per-element kernel is
factored out of assemble() unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-09-25 22:28:53 -05:00
co-authored by Claude Opus 5.5
parent 171da41ed1
commit 860f5bb37f
10 changed files with 2435 additions and 38 deletions
@@ -0,0 +1,559 @@
//! R8-g: the sparse Newton-tangent path of the nonlinear Newmark stepper
//! ([`TangentSolver::SparseLdlt`]) against the default banded LU.
//!
//! Suite (fast, run by default):
//!
//! 1. `sparse_matches_banded_csm3_2d` — the 2-D 35×2 Quad8 CSM3 march
//! (the FSI2 harness's flag), 60 steps: every step's displacement
//! within 1e-10 of the banded path's (relative to the peak).
//! 2. `sparse_matches_banded_csm1_3d_plane_strain` — CSM1 static on the
//! 3-D 35×2×1 plane-strain flag.
//! 3. `sparse_matches_banded_csm3_3d_free` — a free-edge 3-D flag
//! (12×2×2), 40 CSM3 steps.
//! 4. `modified_newton_reuses_the_factor` — `reuse = 4`: fewer
//! factorisations than solves, same march to the Newton tolerance.
//! 5. `tangent_knob_parses` — the `RTX_FEA_TANGENT` values.
//!
//! Instruments (`#[ignore]`, env-driven, write under `R8G_OUT`):
//!
//! * `r8g_g1_equivalence` — CSM1 static and a CSM3 march per
//! configuration with both solvers side by side; per-step CSV.
//! * `r8g_g2_cost` — wall time per Newton iteration and per step, per
//! solver mode and configuration, with the sparse path's breakdown.
use std::io::Write as _;
use std::time::Instant;
use nalgebra::{DVector, Vector3};
use rtx_fea::analysis::flag3d::{Flag3d, Flag3dSpec, LateralFaces};
use rtx_fea::analysis::{
ConvergenceCriteria, DynamicState, NonlinearDynamicAnalysis, TangentSolver,
};
use rtx_fea::assembly::dof_mapping::DofComponent;
use rtx_fea::boundary::dirichlet::{DirichletBC, DirichletType};
use rtx_fea::boundary::{BoundaryCondition, BoundaryConditionSet, SpatialFunction};
use rtx_fea::elements::{ElementMatrixComputer, StandardFiniteElement};
use rtx_fea::mesh::{Element, ElementType, MaterialId, Mesh, Node, NodeId};
const E_MOD: f64 = 1.4e6;
const NU: f64 = 0.4;
const RHO_CSM: f64 = 1000.0;
const G: f64 = 2.0;
fn env_str(name: &str, default: &str) -> String {
std::env::var(name).unwrap_or_else(|_| default.to_string())
}
fn env_num(name: &str, default: f64) -> f64 {
std::env::var(name)
.map(|v| v.parse().expect(name))
.unwrap_or(default)
}
/// The 2-D flag, as the FSI2 harness builds it (copied from
/// `flag3d_structure.rs`).
fn quad8_flag(nx: usize, ny: usize) -> Mesh {
let (x0, x1, y0, y1) = (0.25, 0.6, 0.19, 0.21);
let mut mesh = Mesh::new(2).unwrap();
let (lx, ly) = (2 * nx + 1, 2 * ny + 1);
let mut grid = vec![vec![None; ly]; lx];
for (i, column) in grid.iter_mut().enumerate() {
for (j, slot) in column.iter_mut().enumerate() {
if i % 2 == 1 && j % 2 == 1 {
continue;
}
let x = x0 + (x1 - x0) * i as f64 / (2 * nx) as f64;
let y = y0 + (y1 - y0) * j as f64 / (2 * ny) as f64;
*slot = Some(mesh.add_node(Node::new_2d(x, y)));
}
}
for i in 0..nx {
for j in 0..ny {
let (a, b) = (2 * i, 2 * j);
let nodes = vec![
grid[a][b].unwrap(),
grid[a + 2][b].unwrap(),
grid[a + 2][b + 2].unwrap(),
grid[a][b + 2].unwrap(),
grid[a + 1][b].unwrap(),
grid[a + 2][b + 1].unwrap(),
grid[a + 1][b + 2].unwrap(),
grid[a][b + 1].unwrap(),
];
mesh.add_element(Element::new(ElementType::Quad8, nodes, MaterialId(0)).unwrap())
.unwrap();
}
}
mesh
}
fn clamp_2d(mesh: &Mesh) -> BoundaryConditionSet {
let clamped: Vec<NodeId> = mesh
.nodes
.iter()
.filter(|(_, node)| (node.position().x - 0.25).abs() < 1e-12)
.map(|(&id, _)| id)
.collect();
let mut set = BoundaryConditionSet::new();
for component in [DofComponent::DisplacementX, DofComponent::DisplacementY] {
set.add_condition(BoundaryCondition::Dirichlet(DirichletBC {
nodes: clamped.clone(),
components: vec![component],
condition_type: DirichletType::Spatial(SpatialFunction(Box::new(|_| 0.0))),
time_range: None,
ramping_factor: 1.0,
gradual_enforcement: false,
}));
}
set
}
/// Consistent gravity nodal forces `∫ N_a ρ g dV`.
fn gravity_forces(mesh: &Mesh, rho: f64, g: f64) -> Vec<(NodeId, Vector3<f64>)> {
let dim = mesh.spatial_dimension;
let mut acc: std::collections::BTreeMap<NodeId, Vector3<f64>> = Default::default();
for element in mesh.elements.values() {
let coords: Vec<Vector3<f64>> = element
.nodes
.iter()
.map(|id| mesh.get_node(*id).unwrap().position())
.collect();
let fe = StandardFiniteElement::new(element.element_type, coords.clone());
let f = ElementMatrixComputer::compute_body_force_vector(
&fe,
&coords,
&|_| Vector3::new(0.0, -rho * g, 0.0),
None,
)
.unwrap();
for (a, id) in element.nodes.iter().enumerate() {
let e = acc.entry(*id).or_insert_with(Vector3::zeros);
for c in 0..dim {
e[c] += f[a * dim + c];
}
}
}
acc.into_iter().collect()
}
fn static_criteria() -> ConvergenceCriteria {
ConvergenceCriteria {
force_tolerance: 1e-12,
displacement_tolerance: 1e-14,
max_iterations: 60,
..ConvergenceCriteria::default()
}
}
/// The CSM3 analysis (gravity from rest) on the 2-D 35×2 flag.
fn csm3_2d(dt: f64, solver: TangentSolver) -> NonlinearDynamicAnalysis {
let mesh = quad8_flag(35, 2);
let mut analysis = NonlinearDynamicAnalysis::new(
mesh.clone(),
Flag3d::materials(E_MOD, NU, RHO_CSM),
clamp_2d(&mesh),
dt,
1,
Default::default(),
)
.with_total_lagrangian()
.with_tangent_solver(solver);
analysis.set_body_force(|_| Vector3::new(0.0, -RHO_CSM * G, 0.0));
analysis
}
/// The CSM3 analysis on a 3-D flag (R8-b's instrument settings).
fn csm3_3d(
flag: &Flag3d,
lateral: LateralFaces,
dt: f64,
solver: TangentSolver,
) -> NonlinearDynamicAnalysis {
let mut analysis = flag
.dynamic_analysis(E_MOD, NU, RHO_CSM, lateral, dt, 1, 0.5)
.with_tangent_solver(solver);
analysis.set_body_force(|_| Vector3::new(0.0, -RHO_CSM * G, 0.0));
analysis
}
/// `max_i |a_i − b_i| / max_i |b_i|`.
fn rel_max(a: &DVector<f64>, b: &DVector<f64>) -> f64 {
let scale = b.amax().max(1e-300);
(a - b).amax() / scale
}
/// March both analyses `steps` steps side by side; per step the
/// relative displacement difference. Returns (max rel, per-step rows).
fn march_pair(
banded: &NonlinearDynamicAnalysis,
sparse: &NonlinearDynamicAnalysis,
steps: usize,
) -> (f64, Vec<(f64, f64, usize, usize)>) {
let mut sb = banded.stepper().unwrap();
let mut ss = sparse.stepper().unwrap();
let mut stb = sb.rest_state().unwrap();
let mut sts = ss.rest_state().unwrap();
let mut worst: f64 = rel_max(&sts.acceleration, &stb.acceleration);
let mut rows = Vec::new();
for _ in 0..steps {
let t0 = Instant::now();
let (nb, ib) = sb.step(&stb).unwrap();
let tb = t0.elapsed().as_secs_f64();
let t1 = Instant::now();
let (ns, is) = ss.step(&sts).unwrap();
let ts = t1.elapsed().as_secs_f64();
stb = nb;
sts = ns;
let r = rel_max(&sts.displacement, &stb.displacement);
worst = worst.max(r);
rows.push((r, tb / ts.max(1e-12), ib, is));
}
(worst, rows)
}
#[test]
fn sparse_matches_banded_csm3_2d() {
let dt = 0.005;
let (worst, rows) = march_pair(
&csm3_2d(dt, TangentSolver::BandedLu),
&csm3_2d(dt, TangentSolver::SPARSE),
60,
);
let same_newton = rows.iter().all(|r| r.2 == r.3);
println!("CSM3 2-D 35x2, 60 steps: max rel |Δu| {worst:.3e}, same Newton counts {same_newton}");
assert!(worst < 1e-10, "sparse departs from banded: {worst:.3e}");
assert!(same_newton);
}
#[test]
fn sparse_matches_banded_csm1_3d_plane_strain() {
let flag = Flag3d::build(Flag3dSpec::turek_hron(0.05, 0.0, 35, 2, 1)).unwrap();
let forces = gravity_forces(&flag.mesh, RHO_CSM, G);
let solve = |solver: TangentSolver| {
let analysis = flag
.dynamic_analysis(E_MOD, NU, RHO_CSM, LateralFaces::PlaneStrain, 1e4, 1, 0.5)
.with_convergence_criteria(static_criteria())
.with_tangent_solver(solver);
let mut stepper = analysis.stepper().unwrap();
let n = stepper.rest_state().unwrap().displacement.len();
let mut u = DVector::zeros(n);
for s in 1..=5 {
let scale = s as f64 / 5.0;
let scaled: Vec<_> = forces.iter().map(|(id, f)| (*id, f * scale)).collect();
stepper.set_nodal_forces(&scaled);
let state = DynamicState {
displacement: u.clone(),
velocity: DVector::zeros(n),
acceleration: DVector::zeros(n),
};
u = stepper.step(&state).unwrap().0.displacement;
}
let d = stepper.node_dofs(flag.point_a());
(u.clone(), u[d[1]])
};
let (ub, ay_b) = solve(TangentSolver::BandedLu);
let (us, ay_s) = solve(TangentSolver::SPARSE);
let r = rel_max(&us, &ub);
println!(
"CSM1 3-D 35x2x1 ps: uy(A) banded {ay_b:.12e} sparse {ay_s:.12e}, max rel |Δu| {r:.3e}"
);
assert!(r < 1e-10, "CSM1 departs: {r:.3e}");
assert!((ay_b + 65.1406e-3).abs() < 1e-6, "CSM1 uy(A) moved: {ay_b}");
}
#[test]
fn sparse_matches_banded_csm3_3d_free() {
let flag = Flag3d::build(Flag3dSpec::turek_hron(0.1, -0.05, 12, 2, 2)).unwrap();
let dt = 0.005;
let (worst, _) = march_pair(
&csm3_3d(&flag, LateralFaces::Free, dt, TangentSolver::BandedLu),
&csm3_3d(&flag, LateralFaces::Free, dt, TangentSolver::SPARSE),
40,
);
println!("CSM3 3-D 12x2x2 free, 40 steps: max rel |Δu| {worst:.3e}");
assert!(worst < 1e-10, "sparse departs from banded: {worst:.3e}");
}
#[test]
fn modified_newton_reuses_the_factor() {
let flag = Flag3d::build(Flag3dSpec::turek_hron(0.1, -0.05, 12, 2, 2)).unwrap();
let dt = 0.005;
let full = csm3_3d(&flag, LateralFaces::Free, dt, TangentSolver::SPARSE);
let modified = csm3_3d(
&flag,
LateralFaces::Free,
dt,
TangentSolver::SparseLdlt { reuse: 4 },
);
let mut sf = full.stepper().unwrap();
let mut sm = modified.stepper().unwrap();
let mut stf = sf.rest_state().unwrap();
let mut stm = sm.rest_state().unwrap();
for _ in 0..40 {
stf = sf.step(&stf).unwrap().0;
stm = sm.step(&stm).unwrap().0;
}
let r = rel_max(&stm.displacement, &stf.displacement);
let stats_f = sf.tangent_stats().unwrap();
let stats_m = sm.tangent_stats().unwrap();
println!(
"modified Newton (reuse 4) vs full after 40 steps: rel {r:.3e}; full {stats_f:?}; modified {stats_m:?}"
);
assert_eq!(stats_f.factorizations, stats_f.solves);
assert!(stats_m.factorizations < stats_m.solves);
assert_eq!(stats_m.analyses, 1);
// Newton's own displacement tolerance is 1e-6 (relative).
assert!(r < 1e-4, "modified Newton drifts: {r:.3e}");
}
#[test]
fn tangent_knob_parses() {
// Only the parser (the knob is read when a stepper is built).
assert_eq!(TangentSolver::default(), TangentSolver::BandedLu);
assert_eq!(
TangentSolver::SPARSE,
TangentSolver::SparseLdlt { reuse: 1 }
);
}
// ---------------------------------------------------------------------------
// Instruments
// ---------------------------------------------------------------------------
/// `NXxNYxNZ:span:free|ps` entries (as in `flag3d_structure.rs`); `2d`
/// = the 2-D 35×2 Quad8 flag.
fn parse_configs(spec: &str) -> Vec<Option<(usize, usize, usize, f64, LateralFaces)>> {
spec.split(',')
.map(|entry| {
if entry.trim() == "2d" {
return None;
}
let mut parts = entry.trim().split(':');
let mesh = parts.next().unwrap();
let span: f64 = parts.next().unwrap().parse().unwrap();
let lateral = match parts.next().unwrap() {
"free" => LateralFaces::Free,
"ps" => LateralFaces::PlaneStrain,
other => panic!("lateral {other}"),
};
let n: Vec<usize> = mesh.split('x').map(|t| t.parse().unwrap()).collect();
Some((n[0], n[1], n[2], span, lateral))
})
.collect()
}
fn tag(cfg: &Option<(usize, usize, usize, f64, LateralFaces)>) -> String {
match cfg {
None => "2d_35x2".to_string(),
Some((nx, ny, nz, span, lateral)) => {
let l = if *lateral == LateralFaces::Free {
"free"
} else {
"ps"
};
format!("{nx}x{ny}x{nz}_s{span}_{l}")
}
}
}
fn build_flag(cfg: &(usize, usize, usize, f64, LateralFaces)) -> Flag3d {
let (nx, ny, nz, span, _) = *cfg;
Flag3d::build(Flag3dSpec::turek_hron(span, -0.5 * span, nx, ny, nz)).unwrap()
}
fn analysis_for(
cfg: &Option<(usize, usize, usize, f64, LateralFaces)>,
flag: Option<&Flag3d>,
dt: f64,
solver: TangentSolver,
) -> NonlinearDynamicAnalysis {
match cfg {
None => csm3_2d(dt, solver),
Some(c) => csm3_3d(flag.unwrap(), c.4, dt, solver),
}
}
#[test]
#[ignore = "instrument: G1 — sparse vs banded, CSM1 static and a CSM3 march"]
fn r8g_g1_equivalence() {
let out = env_str("R8G_OUT", ".");
let configs = parse_configs(&env_str("R8G_CONFIGS", "35x2x1:0.05:ps,35x2x8:0.41:free"));
let steps = env_num("R8G_STEPS", 200.0) as usize;
let dt = env_num("R8G_DT", 0.005);
let do_csm1 = env_str("R8G_CSM1", "1") == "1";
let mut table = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(format!("{out}/g1_table.txt"))
.unwrap();
for cfg in &configs {
let name = tag(cfg);
let flag = cfg.as_ref().map(build_flag);
if do_csm1 {
if let (Some(c), Some(flag)) = (cfg, flag.as_ref()) {
let forces = gravity_forces(&flag.mesh, RHO_CSM, G);
let mut results = Vec::new();
for solver in [TangentSolver::BandedLu, TangentSolver::SPARSE] {
let start = Instant::now();
let analysis = flag
.dynamic_analysis(E_MOD, NU, RHO_CSM, c.4, 1e4, 1, 0.5)
.with_convergence_criteria(static_criteria())
.with_tangent_solver(solver);
let mut stepper = analysis.stepper().unwrap();
let n = stepper.rest_state().unwrap().displacement.len();
let mut u = DVector::zeros(n);
let mut newton = 0;
for s in 1..=5 {
let scale = s as f64 / 5.0;
let scaled: Vec<_> =
forces.iter().map(|(id, f)| (*id, f * scale)).collect();
stepper.set_nodal_forces(&scaled);
let state = DynamicState {
displacement: u.clone(),
velocity: DVector::zeros(n),
acceleration: DVector::zeros(n),
};
let (next, it) = stepper.step(&state).unwrap();
newton += it;
u = next.displacement;
}
let d = stepper.node_dofs(flag.point_a());
results.push((
u.clone(),
u[d[0]],
u[d[1]],
newton,
start.elapsed().as_secs_f64(),
));
}
let r = rel_max(&results[1].0, &results[0].0);
let line = format!(
"G1 CSM1 {name}: A banded ux {:.9e} uy {:.9e} [{} Newton, {:.1} s] | sparse ux \
{:.9e} uy {:.9e} [{} Newton, {:.1} s] | max rel |Δu| {r:.3e}",
results[0].1,
results[0].2,
results[0].3,
results[0].4,
results[1].1,
results[1].2,
results[1].3,
results[1].4
);
println!("{line}");
writeln!(table, "{line}").unwrap();
}
}
let banded = analysis_for(cfg, flag.as_ref(), dt, TangentSolver::BandedLu);
let sparse = analysis_for(cfg, flag.as_ref(), dt, TangentSolver::SPARSE);
let start = Instant::now();
let (worst, rows) = march_pair(&banded, &sparse, steps);
let path = format!("{out}/g1_csm3_{name}_dt{dt}.csv");
let mut csv = std::fs::File::create(&path).unwrap();
writeln!(csv, "step,rel_u,speedup,newton_banded,newton_sparse").unwrap();
for (k, (r, speed, ib, is)) in rows.iter().enumerate() {
writeln!(csv, "{},{r:.6e},{speed:.3},{ib},{is}", k + 1).unwrap();
}
let differ = rows.iter().filter(|r| r.2 != r.3).count();
let line = format!(
"G1 CSM3 {name} dt {dt}: {steps} steps, max rel |Δu| {worst:.3e}, Newton-count \
differences {differ}, {:.0} s → {path}",
start.elapsed().as_secs_f64()
);
println!("{line}");
writeln!(table, "{line}").unwrap();
}
}
#[test]
#[ignore = "instrument: G2 — cost per Newton iteration and per step"]
fn r8g_g2_cost() {
let out = env_str("R8G_OUT", ".");
let configs = parse_configs(&env_str(
"R8G_CONFIGS",
"2d,35x2x8:0.41:free,35x2x16:0.41:free",
));
let modes: Vec<String> = env_str("R8G_MODES", "banded,sparse,sparse:3")
.split(',')
.map(str::to_string)
.collect();
let steps = env_num("R8G_STEPS", 40.0) as usize;
let warm = env_num("R8G_WARM", 20.0) as usize;
let dt = env_num("R8G_DT", 0.005);
let mut table = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(format!("{out}/g2_table.txt"))
.unwrap();
for cfg in &configs {
let name = tag(cfg);
let flag = cfg.as_ref().map(build_flag);
let mut reference: Option<DVector<f64>> = None;
for mode in &modes {
let solver = match mode.as_str() {
"banded" => TangentSolver::BandedLu,
"sparse" => TangentSolver::SPARSE,
other => TangentSolver::SparseLdlt {
reuse: other.strip_prefix("sparse:").unwrap().parse().unwrap(),
},
};
let analysis = analysis_for(cfg, flag.as_ref(), dt, solver);
let t_build = Instant::now();
let mut stepper = analysis.stepper().unwrap();
let mut state = stepper.rest_state().unwrap();
let build = t_build.elapsed().as_secs_f64();
// Warm-up steps (off the clock) move the flag off rest.
for _ in 0..warm {
state = stepper.step(&state).unwrap().0;
}
let before = stepper.tangent_stats();
let mut times = Vec::with_capacity(steps);
let mut newton = 0usize;
for _ in 0..steps {
let t = Instant::now();
let (next, it) = stepper.step(&state).unwrap();
times.push(t.elapsed().as_secs_f64());
newton += it;
state = next;
}
let total: f64 = times.iter().sum();
let mut sorted = times.clone();
sorted.sort_by(f64::total_cmp);
let median = sorted[sorted.len() / 2];
let drift = reference
.as_ref()
.map_or(f64::NAN, |r| rel_max(&state.displacement, r));
if reference.is_none() {
reference = Some(state.displacement.clone());
}
let breakdown = match (before, stepper.tangent_stats()) {
(Some(b), Some(a)) => format!(
" | assemblies {} ({:.4} s each), factorisations {} ({:.4} s each incl. \
scatter), solves {} ({:.4} s each), fallbacks {}, nnz(L) {}",
a.assemblies - b.assemblies,
(a.assembly_seconds - b.assembly_seconds)
/ (a.assemblies - b.assemblies).max(1) as f64,
a.factorizations - b.factorizations,
(a.factor_seconds - b.factor_seconds)
/ (a.factorizations - b.factorizations).max(1) as f64,
a.solves - b.solves,
(a.solve_seconds - b.solve_seconds) / (a.solves - b.solves).max(1) as f64,
a.fallbacks,
a.nnz_l
),
_ => String::new(),
};
let line = format!(
"G2 {name} {mode} dt {dt}: dofs {} | build+rest {build:.3} s | {steps} steps after \
{warm} warm: {:.4} s/step mean, {median:.4} median, {:.2} Newton/step, {:.4} \
s/Newton | final rel to first mode {drift:.2e} | threads {}{breakdown}",
state.displacement.len(),
total / steps as f64,
newton as f64 / steps as f64,
total / newton.max(1) as f64,
rayon::current_num_threads()
);
println!("{line}");
writeln!(table, "{line}").unwrap();
}
}
}