Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ Modified LSMR is now the sole iterative solver, replacing CG and GMRES.
`schwarz_precond::schwarz::{additive, multiplicative}` flattened into
`schwarz_precond::schwarz`; crate-root `SchwarzPreconditioner` re-export
unchanged.
- **BREAKING:** `AdditiveSchwarzDiagnostics` removed from Rust and Python
public APIs along with `SchwarzPreconditioner::diagnostics`,
`FePreconditioner::additive_schwarz_diagnostics`, and the Python
`AdditiveSchwarzDiagnostics` class. Scheduling metrics are now private
to the `Auto` heuristic (closes #34).

## [0.1.0] - 2026-03-12

Expand Down
2 changes: 1 addition & 1 deletion crates/schwarz-precond/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,5 +138,5 @@ pub use error::{
};
pub use local_solve::{LocalSolver, SubdomainEntry};
pub use lsmr::{lsmr, mlsmr, LsmrResult, LsmrStopReason};
pub use schwarz::{AdditiveSchwarzDiagnostics, ReductionStrategy, SchwarzPreconditioner};
pub use schwarz::{ReductionStrategy, SchwarzPreconditioner};
pub use sparse_matrix::SparseMatrix;
2 changes: 1 addition & 1 deletion crates/schwarz-precond/src/schwarz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,5 @@ mod executor;
mod planning;
mod preconditioner;

pub use planning::{AdditiveSchwarzDiagnostics, ReductionStrategy};
pub use planning::ReductionStrategy;
pub use preconditioner::SchwarzPreconditioner;
147 changes: 49 additions & 98 deletions crates/schwarz-precond/src/schwarz/planning.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
//! Reduction strategy selection and build-time diagnostics.
//! Reduction strategy selection.
//!
//! [`ReductionStrategy`] is the user-facing enum (`Auto`, `AtomicScatter`,
//! `ParallelReduction`). [`AdditiveScheduler`] resolves `Auto` at apply-time
//! using build-time metrics ([`AdditiveSchwarzDiagnostics`]) and the current
//! Rayon thread-pool width.
//! using build-time scheduling metrics and the current Rayon thread-pool
//! width.
//!
//! The heuristic balances two costs:
//! - **Atomic scatter**: contention grows with overlap (DOFs shared across
Expand Down Expand Up @@ -49,25 +49,48 @@ pub(super) struct ReductionPlan {
pub(super) allow_inner_parallelism: bool,
}

/// Build-time scheduling metrics + `Auto` resolution logic.
#[derive(Debug, Clone, Copy)]
pub(super) struct AdditiveScheduler {
diagnostics: AdditiveSchwarzDiagnostics,
n_subdomains: usize,
n_dofs: usize,
total_inner_parallel_work: usize,
max_inner_parallel_work: usize,
total_scatter_dofs: usize,
}

impl AdditiveScheduler {
const MIN_INNER_PARALLEL_WORK: usize = 200_000;
const OUTER_CAPACITY_TARGET: f64 = 0.75;
const AUTO_REDUCTION_SWEEP_FACTOR: f64 = 1.1;
const AUTO_INNER_REDUCTION_SWEEP_FACTOR: f64 = 6.0;
const AUTO_OVERLAP_FOR_REDUCTION: f64 = 4.0;

pub(super) fn from_entries<S: LocalSolver>(
entries: &[SubdomainEntry<S>],
n_dofs: usize,
) -> Self {
let (total_inner_parallel_work, max_inner_parallel_work, total_scatter_dofs) =
entries.iter().fold(
(0usize, 0usize, 0usize),
|(total_work, max_work, total_scatter), entry| {
let work = entry.solver().inner_parallelism_work_estimate();
(
total_work.saturating_add(work),
max_work.max(work),
total_scatter.saturating_add(entry.global_indices().len()),
)
},
);
Self {
diagnostics: AdditiveSchwarzDiagnostics::from_entries(entries, n_dofs),
n_subdomains: entries.len(),
n_dofs,
total_inner_parallel_work,
max_inner_parallel_work,
total_scatter_dofs,
}
}

pub(super) fn diagnostics(self) -> AdditiveSchwarzDiagnostics {
self.diagnostics
}

pub(super) fn reduction_plan(
self,
configured: ReductionStrategy,
Expand All @@ -81,12 +104,23 @@ impl AdditiveScheduler {
}
}

fn outer_parallel_capacity(self) -> f64 {
if self.max_inner_parallel_work == 0 {
return 0.0;
}
self.total_inner_parallel_work as f64 / self.max_inner_parallel_work as f64
}

fn scatter_overlap(self) -> f64 {
self.total_scatter_dofs as f64 / self.n_dofs.max(1) as f64
}

fn allow_inner_parallelism(self, threads: usize) -> bool {
if self.diagnostics.max_inner_parallel_work < Self::MIN_INNER_PARALLEL_WORK {
if self.max_inner_parallel_work < Self::MIN_INNER_PARALLEL_WORK {
return false;
}

self.diagnostics.outer_parallel_capacity() < (threads as f64 * Self::OUTER_CAPACITY_TARGET)
self.outer_parallel_capacity() < (threads as f64 * Self::OUTER_CAPACITY_TARGET)
}

fn resolve_strategy(
Expand All @@ -107,7 +141,7 @@ impl AdditiveScheduler {
threads: usize,
allow_inner_parallelism: bool,
) -> ResolvedReductionStrategy {
let overlap = self.diagnostics.scatter_overlap();
let overlap = self.scatter_overlap();
let reduction_to_scatter = self.reduction_sweep_to_scatter(threads);

if reduction_to_scatter <= Self::AUTO_REDUCTION_SWEEP_FACTOR {
Expand All @@ -128,92 +162,9 @@ impl AdditiveScheduler {
}

fn reduction_sweep_to_scatter(self, threads: usize) -> f64 {
let active_buffers = threads.min(self.diagnostics.n_subdomains).max(1);
let reduction_sweep = active_buffers.saturating_mul(self.diagnostics.n_dofs);
let scatter_work = self.diagnostics.total_scatter_dofs.max(1);
let active_buffers = threads.min(self.n_subdomains).max(1);
let reduction_sweep = active_buffers.saturating_mul(self.n_dofs);
let scatter_work = self.total_scatter_dofs.max(1);
reduction_sweep as f64 / scatter_work as f64
}
}

/// Build-time metrics that describe additive Schwarz scheduling pressure.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AdditiveSchwarzDiagnostics {
n_subdomains: usize,
n_dofs: usize,
total_inner_parallel_work: usize,
max_inner_parallel_work: usize,
total_scatter_dofs: usize,
}

impl AdditiveSchwarzDiagnostics {
pub(super) fn from_entries<S: LocalSolver>(
entries: &[SubdomainEntry<S>],
n_dofs: usize,
) -> Self {
let (total_inner_parallel_work, max_inner_parallel_work, total_scatter_dofs) =
entries.iter().fold(
(0usize, 0usize, 0usize),
|(total_work, max_work, total_scatter), entry| {
let work = entry.solver().inner_parallelism_work_estimate();
(
total_work.saturating_add(work),
max_work.max(work),
total_scatter.saturating_add(entry.global_indices().len()),
)
},
);
Self {
n_subdomains: entries.len(),
n_dofs,
total_inner_parallel_work,
max_inner_parallel_work,
total_scatter_dofs,
}
}

/// Number of additive Schwarz subdomains.
pub fn n_subdomains(&self) -> usize {
self.n_subdomains
}

/// Global number of degrees of freedom.
pub fn n_dofs(&self) -> usize {
self.n_dofs
}

/// Sum of per-subdomain work estimates that can benefit from nested Rayon.
pub fn total_inner_parallel_work(&self) -> usize {
self.total_inner_parallel_work
}

/// Largest single-subdomain inner-parallel work estimate.
pub fn max_inner_parallel_work(&self) -> usize {
self.max_inner_parallel_work
}

/// Sum of local scatter entries across all subdomains.
pub fn total_scatter_dofs(&self) -> usize {
self.total_scatter_dofs
}

/// Estimated outer parallel capacity: roughly how many heavy subdomains exist.
pub fn outer_parallel_capacity(&self) -> f64 {
if self.max_inner_parallel_work == 0 {
return 0.0;
}
self.total_inner_parallel_work as f64 / self.max_inner_parallel_work as f64
}

/// Average overlap multiplicity of the additive scatter.
pub fn scatter_overlap(&self) -> f64 {
self.total_scatter_dofs as f64 / self.n_dofs.max(1) as f64
}
}

impl AdditiveScheduler {
const MIN_INNER_PARALLEL_WORK: usize = 200_000;
const OUTER_CAPACITY_TARGET: f64 = 0.75;
const AUTO_REDUCTION_SWEEP_FACTOR: f64 = 1.1;
const AUTO_INNER_REDUCTION_SWEEP_FACTOR: f64 = 6.0;
const AUTO_OVERLAP_FOR_REDUCTION: f64 = 4.0;
}
9 changes: 1 addition & 8 deletions crates/schwarz-precond/src/schwarz/preconditioner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ use crate::local_solve::{LocalSolver, SubdomainEntry};
use crate::Operator;

use super::executor::AdditiveExecutor;
use super::planning::{
AdditiveScheduler, AdditiveSchwarzDiagnostics, ReductionPlan, ReductionStrategy,
};
use super::planning::{AdditiveScheduler, ReductionPlan, ReductionStrategy};

// ---------------------------------------------------------------------------
// Serde
Expand Down Expand Up @@ -121,11 +119,6 @@ impl<S: LocalSolver> SchwarzPreconditioner<S> {
self.reduction_plan().strategy.as_public()
}

/// Build-time metrics used by the additive scheduler.
pub fn diagnostics(&self) -> AdditiveSchwarzDiagnostics {
self.scheduler.diagnostics()
}

/// Return a copy that uses a different reduction strategy.
///
/// Shares the subdomain data via `Arc` (O(1)), but creates a fresh
Expand Down
78 changes: 0 additions & 78 deletions crates/within-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,83 +156,11 @@ impl PyReductionStrategy {
Self::ParallelReduction => ReductionStrategy::ParallelReduction,
}
}

fn from_native(strategy: ReductionStrategy) -> Self {
match strategy {
ReductionStrategy::Auto => Self::Auto,
ReductionStrategy::AtomicScatter => Self::AtomicScatter,
ReductionStrategy::ParallelReduction => Self::ParallelReduction,
}
}
}

#[pyclass(frozen)]
#[pyo3(name = "AdditiveSchwarzDiagnostics")]
pub struct PyAdditiveSchwarzDiagnostics {
#[pyo3(get)]
pub reduction_strategy: PyReductionStrategy,
#[pyo3(get)]
pub resolved_reduction_strategy: PyReductionStrategy,
#[pyo3(get)]
pub total_inner_parallel_work: usize,
#[pyo3(get)]
pub max_inner_parallel_work: usize,
#[pyo3(get)]
pub total_scatter_dofs: usize,
#[pyo3(get)]
pub outer_parallel_capacity: f64,
#[pyo3(get)]
pub scatter_overlap: f64,
}

impl PyAdditiveSchwarzDiagnostics {
fn from_native(preconditioner: &FePreconditioner) -> Option<Self> {
let diagnostics = preconditioner.additive_schwarz_diagnostics()?;
Some(Self {
reduction_strategy: PyReductionStrategy::from_native(
preconditioner.additive_reduction_strategy()?,
),
resolved_reduction_strategy: PyReductionStrategy::from_native(
preconditioner.resolved_additive_reduction_strategy()?,
),
total_inner_parallel_work: diagnostics.total_inner_parallel_work(),
max_inner_parallel_work: diagnostics.max_inner_parallel_work(),
total_scatter_dofs: diagnostics.total_scatter_dofs(),
outer_parallel_capacity: diagnostics.outer_parallel_capacity(),
scatter_overlap: diagnostics.scatter_overlap(),
})
}
}

#[pymethods]
impl PyAdditiveSchwarzDiagnostics {
fn __repr__(&self) -> String {
format!(
concat!(
"AdditiveSchwarzDiagnostics(",
"reduction_strategy={:?}, ",
"resolved_reduction_strategy={:?}, ",
"total_inner_parallel_work={}, ",
"max_inner_parallel_work={}, ",
"total_scatter_dofs={}, ",
"outer_parallel_capacity={:.3}, ",
"scatter_overlap={:.3})"
),
self.reduction_strategy,
self.resolved_reduction_strategy,
self.total_inner_parallel_work,
self.max_inner_parallel_work,
self.total_scatter_dofs,
self.outer_parallel_capacity,
self.scatter_overlap,
)
}
}

// ---------------------------------------------------------------------------
// Local solver config classes (available via `_within` for benchmarks)
// ---------------------------------------------------------------------------

#[pyclass(frozen)]
#[pyo3(name = "SchurComplement")]
pub struct PySchurComplement {
Expand Down Expand Up @@ -702,11 +630,6 @@ impl PyFePreconditioner {
self.inner.subdomain_inner_parallel_work()
}

/// Additive Schwarz diagnostics.
fn additive_schwarz_diagnostics(&self) -> Option<PyAdditiveSchwarzDiagnostics> {
PyAdditiveSchwarzDiagnostics::from_native(&self.inner)
}

fn __repr__(&self) -> String {
format!("FePreconditioner(Additive, n={})", self.inner.nrows())
}
Expand Down Expand Up @@ -878,7 +801,6 @@ fn _within(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyBatchSolveResult>()?;
m.add_class::<PyLSMR>()?;
m.add_class::<PyAdditiveSchwarz>()?;
m.add_class::<PyAdditiveSchwarzDiagnostics>()?;
m.add_class::<PyReductionStrategy>()?;
m.add_class::<PyPreconditioner>()?;
m.add_class::<PyApproxCholConfig>()?;
Expand Down
9 changes: 1 addition & 8 deletions crates/within/src/operator/preconditioner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
//! handling flows through `try_apply` for graceful reporting of local-solver
//! failures.

use schwarz_precond::{AdditiveSchwarzDiagnostics, LocalSolver, Operator, ReductionStrategy};
use schwarz_precond::{LocalSolver, Operator, ReductionStrategy};
use serde::{Deserialize, Serialize};

use crate::config::Preconditioner;
Expand Down Expand Up @@ -65,13 +65,6 @@ impl FePreconditioner {
Self::Additive(p) => Some(p.resolved_reduction_strategy()),
}
}

/// Build-time additive Schwarz scheduling diagnostics, if applicable.
pub fn additive_schwarz_diagnostics(&self) -> Option<AdditiveSchwarzDiagnostics> {
match self {
Self::Additive(p) => Some(p.diagnostics()),
}
}
}

impl Operator for FePreconditioner {
Expand Down
5 changes: 0 additions & 5 deletions crates/within/src/operator/schwarz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,6 @@ impl FeSchwarz {
self.0.resolved_reduction_strategy()
}

/// Subdomain diagnostics (sizes, overlap counts).
pub fn diagnostics(&self) -> schwarz_precond::AdditiveSchwarzDiagnostics {
self.0.diagnostics()
}

/// Apply the preconditioner, returning an error on local-solver failure.
pub fn try_apply(&self, r: &[f64], z: &mut [f64]) -> Result<(), schwarz_precond::ApplyError> {
self.0.try_apply(r, z)
Expand Down
Loading
Loading