Skip to content

MWE 17 - Solver-options benchmark for constant and variable viscosity

This notebook benchmarks the solver configurations currently available in voids.

It has three sections: 1. Network constant-viscosity linear solves 2. Network pressure-dependent viscosity solves using both Picard and damped Newton 3. FEM micro-continuum linear backend comparison with PETSc, SciPy, and UMFPACK

Scientific scope: - the geometry is fixed so the comparison isolates solver behavior - all runs report the same physical quantity (Kabs) and are compared against a reference solve - the PyAMG path is treated as a linear preconditioner, not as a different physical model - FEM backend comparisons keep the same DOLFINx/UFL weak form and boundary conditions; only the assembled linear algebra backend changes

from __future__ import annotations

import sys
from pathlib import Path
from statistics import median
from time import perf_counter

import matplotlib

if "ipykernel" not in sys.modules:
    matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

try:
    from IPython.display import display
except ImportError:  # pragma: no cover - plain Python fallback for minimal environments
    display = print

from voids.examples import make_cartesian_mesh_network
from voids.fem.singlephase import (
    FEMMapProblem,
    FEniCSSolverOptions,
    solve_brinkman_taylor_hood,
    solve_brinkman_usfem,
    solve_darcy_taylor_hood,
)
from voids.fem.singlephase._common import _require_dolfinx_core
from voids.image.porosity import PermeabilityMap, PorosityMap
from voids.physics.singlephase import (
    FluidSinglePhase,
    PressureBC,
    SinglePhaseOptions,
    solve,
)
from voids.physics.thermo import TabulatedWaterViscosityModel

plt.style.use("seaborn-v0_8-whitegrid")
def find_notebooks_base() -> Path:
    """Return the notebook root used for saved figures and CSV artifacts."""

    from os import environ

    if "VOIDS_NOTEBOOKS_PATH" in environ:
        return Path(environ["VOIDS_NOTEBOOKS_PATH"]).expanduser().resolve()

    cwd = Path.cwd().resolve()
    for candidate in (cwd, *cwd.parents):
        if (candidate / "notebooks").is_dir() and (
            candidate / "jupytext.toml"
        ).exists():
            return candidate / "notebooks"
    return cwd


NET = make_cartesian_mesh_network(
    (35, 35),
    spacing=1.0,
    thickness=1.0,
    pore_radius=0.22,
    throat_radius=0.09,
)
CONDUCTANCE_MODEL = "valvatne_blunt"
LINEAR_BC = PressureBC("inlet_xmin", "outlet_xmax", pin=1.0, pout=0.0)
NONLINEAR_BC = PressureBC("inlet_xmin", "outlet_xmax", pin=8.0e6, pout=5.0e6)
THERMO_MODEL = TabulatedWaterViscosityModel.from_backend(
    "thermo",
    temperature=298.15,
    pressure_points=192,
)
REFERENCE_MU = THERMO_MODEL.reference_viscosity(
    pin=NONLINEAR_BC.pin, pout=NONLINEAR_BC.pout
)
REPEATS = 3
FEM_REPEATS = 3
FEM_SHAPES = [(5, 5), (10, 10), (20, 20)]
NOTEBOOKS_BASE = find_notebooks_base()
OUTPUT_DIR = NOTEBOOKS_BASE / "outputs" / "17_mwe_solver_options_benchmark"
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
DOCS_ASSET_DIR = NOTEBOOKS_BASE.parent / "docs" / "assets" / "solver_backends"
DOCS_ASSET_DIR.mkdir(parents=True, exist_ok=True)

constant_configs = {
    "direct": SinglePhaseOptions(conductance_model=CONDUCTANCE_MODEL, solver="direct"),
    "direct_umfpack": SinglePhaseOptions(
        conductance_model=CONDUCTANCE_MODEL,
        solver="umfpack",
    ),
    "cg": SinglePhaseOptions(
        conductance_model=CONDUCTANCE_MODEL,
        solver="cg",
        solver_parameters={"rtol": 1.0e-10, "maxiter": 3_000},
    ),
    "cg_pyamg": SinglePhaseOptions(
        conductance_model=CONDUCTANCE_MODEL,
        solver="cg",
        solver_parameters={
            "rtol": 1.0e-10,
            "maxiter": 3_000,
            "preconditioner": "pyamg",
        },
    ),
    "gmres": SinglePhaseOptions(
        conductance_model=CONDUCTANCE_MODEL,
        solver="gmres",
        solver_parameters={"rtol": 1.0e-10, "maxiter": 800, "restart": 80},
    ),
    "gmres_pyamg": SinglePhaseOptions(
        conductance_model=CONDUCTANCE_MODEL,
        solver="gmres",
        solver_parameters={
            "rtol": 1.0e-10,
            "maxiter": 800,
            "restart": 80,
            "preconditioner": "pyamg",
        },
    ),
}
variable_configs = {
    "picard_direct": SinglePhaseOptions(
        conductance_model=CONDUCTANCE_MODEL,
        solver="direct",
        nonlinear_solver="picard",
        nonlinear_pressure_tolerance=1.0e-10,
    ),
    "picard_umfpack": SinglePhaseOptions(
        conductance_model=CONDUCTANCE_MODEL,
        solver="umfpack",
        nonlinear_solver="picard",
        nonlinear_pressure_tolerance=1.0e-10,
    ),
    "picard_gmres_pyamg": SinglePhaseOptions(
        conductance_model=CONDUCTANCE_MODEL,
        solver="gmres",
        nonlinear_solver="picard",
        nonlinear_pressure_tolerance=1.0e-10,
        solver_parameters={
            "rtol": 1.0e-10,
            "maxiter": 800,
            "restart": 80,
            "preconditioner": "pyamg",
        },
    ),
    "newton_direct": SinglePhaseOptions(
        conductance_model=CONDUCTANCE_MODEL,
        solver="direct",
        nonlinear_solver="newton",
        nonlinear_pressure_tolerance=1.0e-10,
    ),
    "newton_umfpack": SinglePhaseOptions(
        conductance_model=CONDUCTANCE_MODEL,
        solver="umfpack",
        nonlinear_solver="newton",
        nonlinear_pressure_tolerance=1.0e-10,
    ),
    "newton_gmres": SinglePhaseOptions(
        conductance_model=CONDUCTANCE_MODEL,
        solver="gmres",
        nonlinear_solver="newton",
        nonlinear_pressure_tolerance=1.0e-10,
        solver_parameters={"rtol": 1.0e-10, "maxiter": 800, "restart": 80},
    ),
    "newton_gmres_pyamg": SinglePhaseOptions(
        conductance_model=CONDUCTANCE_MODEL,
        solver="gmres",
        nonlinear_solver="newton",
        nonlinear_pressure_tolerance=1.0e-10,
        solver_parameters={
            "rtol": 1.0e-10,
            "maxiter": 800,
            "restart": 80,
            "preconditioner": "pyamg",
        },
    ),
}
fem_solver_configs = {
    "petsc_mumps": FEniCSSolverOptions.direct_lu("mumps"),
    "scipy_spsolve": FEniCSSolverOptions.scipy_direct(),
    "umfpack": FEniCSSolverOptions.umfpack_direct(),
}
fem_formulations = {
    "darcy_taylor_hood": solve_darcy_taylor_hood,
    "brinkman_taylor_hood": solve_brinkman_taylor_hood,
    "brinkman_usfem": solve_brinkman_usfem,
}
def benchmark_case(
    *,
    fluid: FluidSinglePhase,
    bc: PressureBC,
    axis: str,
    options: SinglePhaseOptions,
    repeats: int,
) -> tuple[dict[str, float], object]:
    """Run one solver configuration multiple times and summarize timing."""

    times: list[float] = []
    last_result = None
    for _ in range(repeats):
        tic = perf_counter()
        last_result = solve(
            NET,
            fluid=fluid,
            bc=bc,
            axis=axis,
            options=options,
        )
        times.append(perf_counter() - tic)
    assert last_result is not None
    return (
        {
            "time_median_s": median(times),
            "time_min_s": min(times),
            "time_max_s": max(times),
        },
        last_result,
    )


def finalize_figure(fig) -> None:
    """Show figures in notebooks and close them in non-interactive script runs."""

    backend = matplotlib.get_backend().lower()
    if "agg" in backend:
        plt.close(fig)
    else:
        plt.show()


def save_figure(fig, name: str) -> None:
    """Save a figure to notebook outputs and public docs assets."""

    for directory in (OUTPUT_DIR, DOCS_ASSET_DIR):
        fig.savefig(directory / name, dpi=170, bbox_inches="tight")


def save_table(df: pd.DataFrame, name: str) -> None:
    """Save a table to notebook outputs and public docs assets."""

    for directory in (OUTPUT_DIR, DOCS_ASSET_DIR):
        df.to_csv(directory / name, index=False)


def summarize_configs(
    *,
    title: str,
    fluid: FluidSinglePhase,
    bc: PressureBC,
    configs: dict[str, SinglePhaseOptions],
    reference_label: str,
) -> pd.DataFrame:
    """Benchmark a set of solver configurations and compare against one reference run."""

    rows: list[dict[str, float | str]] = []
    reference_kabs = None
    reference_q = None
    for label, options in configs.items():
        timing, result = benchmark_case(
            fluid=fluid,
            bc=bc,
            axis="x",
            options=options,
            repeats=REPEATS,
        )
        if label == reference_label:
            reference_kabs = result.permeability["x"]
            reference_q = result.total_flow_rate
        rows.append(
            {
                "section": title,
                "config": label,
                "solver": options.solver,
                "nonlinear_solver": options.nonlinear_solver,
                "linear_backend": str(result.solver_info.get("backend", options.solver)),
                "kabs": result.permeability["x"],
                "Q": result.total_flow_rate,
                "residual_norm": result.residual_norm,
                "mass_balance_error": result.mass_balance_error,
                "linear_info": float(result.solver_info.get("info", 0)),
                "nonlinear_iterations": float(
                    result.solver_info.get("nonlinear_iterations", 0)
                ),
                **timing,
            }
        )
    assert reference_kabs is not None and reference_q is not None
    df = pd.DataFrame(rows)
    df["kabs_rel_diff_to_reference"] = (df["kabs"] - reference_kabs) / reference_kabs
    df["Q_rel_diff_to_reference"] = (df["Q"] - reference_q) / reference_q
    return df


def fem_stack_status() -> tuple[bool, str]:
    """Return whether the DOLFINx core stack needed by FEM notebooks is available."""

    try:
        _require_dolfinx_core()
    except ImportError as exc:
        return False, str(exc)
    return True, "DOLFINx core imports are available."


def constant_fem_problem(shape: tuple[int, int], permeability: float = 2.0) -> FEMMapProblem:
    """Build a homogeneous 2-D FEM map problem with matching porosity and permeability grids."""

    return FEMMapProblem(
        permeability_map=PermeabilityMap(np.full(shape, permeability), cell_size=1.0),
        porosity_map=PorosityMap(np.ones(shape), cell_size=1.0),
        viscosity=1.0,
    )


def relative_l2(candidate: np.ndarray, reference: np.ndarray) -> float:
    """Return the relative L2 norm of two arrays using an absolute norm for zero references."""

    reference_norm = float(np.linalg.norm(reference))
    difference_norm = float(np.linalg.norm(candidate - reference))
    if reference_norm == 0.0:
        return difference_norm
    return difference_norm / reference_norm


def benchmark_fem_case(
    *,
    problem: FEMMapProblem,
    solver,
    options: FEniCSSolverOptions,
    repeats: int,
) -> tuple[dict[str, float], object]:
    """Run one FEM formulation/backend pair multiple times and summarize timing."""

    times: list[float] = []
    last_result = None
    for _ in range(repeats):
        tic = perf_counter()
        last_result = solver(problem, flow_axis="x", options=options)
        times.append(perf_counter() - tic)
    assert last_result is not None
    return (
        {
            "time_median_s": median(times),
            "time_min_s": min(times),
            "time_max_s": max(times),
            "reported_solve_seconds": float(last_result.solve_seconds),
        },
        last_result,
    )


def summarize_fem_backends() -> tuple[pd.DataFrame, pd.DataFrame, str]:
    """Benchmark FEM linear backends and compare solution arrays within each formulation/mesh."""

    available, message = fem_stack_status()
    if not available:
        return pd.DataFrame(), pd.DataFrame(), message

    rows: list[dict[str, float | int | str]] = []
    failure_rows: list[dict[str, int | str]] = []
    successful_results: dict[tuple[str, str, int], object] = {}
    for shape in FEM_SHAPES:
        problem = constant_fem_problem(shape)
        cells = int(np.prod(shape))
        for formulation, solver in fem_formulations.items():
            for backend_label, options in fem_solver_configs.items():
                try:
                    timing, result = benchmark_fem_case(
                        problem=problem,
                        solver=solver,
                        options=options,
                        repeats=FEM_REPEATS,
                    )
                except (ImportError, NotImplementedError, RuntimeError) as exc:
                    failure_rows.append(
                        {
                            "formulation": formulation,
                            "backend": backend_label,
                            "cells": cells,
                            "status": type(exc).__name__,
                            "message": str(exc),
                        }
                    )
                    continue

                successful_results[(formulation, backend_label, cells)] = result
                rows.append(
                    {
                        "formulation": formulation,
                        "backend": backend_label,
                        "resolved_backend": str(result.metadata["linear_backend"]),
                        "cells": cells,
                        "permeability": float(result.permeability),
                        "flow_rate": float(result.flow_rate),
                        "velocity_dofs": int(result.velocity.x.array.size),
                        "pressure_dofs": int(result.pressure.x.array.size),
                        **timing,
                    }
                )

    df = pd.DataFrame(rows)
    if df.empty:
        return df, pd.DataFrame(failure_rows), message

    for (formulation, cells), group in df.groupby(["formulation", "cells"]):
        available_backends = group["backend"].tolist()
        if "petsc_mumps" in available_backends:
            reference_backend = "petsc_mumps"
        elif "scipy_spsolve" in available_backends:
            reference_backend = "scipy_spsolve"
        else:
            reference_backend = str(available_backends[0])

        reference = successful_results[(str(formulation), reference_backend, int(cells))]
        reference_velocity = np.asarray(reference.velocity.x.array, dtype=float)
        reference_pressure = np.asarray(reference.pressure.x.array, dtype=float)
        reference_permeability = float(reference.permeability)
        reference_flow_rate = float(reference.flow_rate)

        mask = (df["formulation"] == formulation) & (df["cells"] == cells)
        df.loc[mask, "reference_backend"] = reference_backend
        df.loc[mask, "permeability_rel_diff_to_reference"] = (
            df.loc[mask, "permeability"] - reference_permeability
        ) / reference_permeability
        df.loc[mask, "flow_rate_rel_diff_to_reference"] = (
            df.loc[mask, "flow_rate"] - reference_flow_rate
        ) / reference_flow_rate

        for index, row in df.loc[mask].iterrows():
            result = successful_results[(str(formulation), str(row["backend"]), int(cells))]
            df.loc[index, "velocity_rel_l2_to_reference"] = relative_l2(
                np.asarray(result.velocity.x.array, dtype=float),
                reference_velocity,
            )
            df.loc[index, "pressure_rel_l2_to_reference"] = relative_l2(
                np.asarray(result.pressure.x.array, dtype=float),
                reference_pressure,
            )

    return df, pd.DataFrame(failure_rows), message

Benchmark design

This notebook is intended to answer two separate numerical questions: - which linear solver/preconditioner pair is most efficient for the constant-viscosity pressure solve - whether Picard or Newton is the better outer strategy once viscosity depends on pressure

The same mesh network is used throughout, and each configuration is benchmarked multiple times so the tables report representative rather than single-shot timings.

design_df = pd.DataFrame(
    {
        "quantity": [
            "mesh_shape",
            "pore_radius",
            "throat_radius",
            "conductance_model",
            "constant_reference_viscosity [Pa s]",
            "nonlinear_pin [MPa]",
            "nonlinear_pout [MPa]",
            "repeats_per_configuration",
            "fem_map_shapes",
            "fem_repeats_per_configuration",
        ],
        "value": [
            str((35, 35)),
            0.22,
            0.09,
            CONDUCTANCE_MODEL,
            REFERENCE_MU,
            NONLINEAR_BC.pin / 1.0e6,
            NONLINEAR_BC.pout / 1.0e6,
            REPEATS,
            str(FEM_SHAPES),
            FEM_REPEATS,
        ],
    }
)
design_df
quantity value
0 mesh_shape (35, 35)
1 pore_radius 0.22
2 throat_radius 0.09
3 conductance_model valvatne_blunt
4 constant_reference_viscosity [Pa s] 0.000889
5 nonlinear_pin [MPa] 8.0
6 nonlinear_pout [MPa] 5.0
7 repeats_per_configuration 3
8 fem_map_shapes [(5, 5), (10, 10), (20, 20)]
9 fem_repeats_per_configuration 3

Constant-viscosity benchmark

This is the easiest case for AMG because the pressure matrix is the standard linear elliptic operator.

constant_df = summarize_configs(
    title="constant_viscosity",
    fluid=FluidSinglePhase(viscosity=REFERENCE_MU),
    bc=LINEAR_BC,
    configs=constant_configs,
    reference_label="direct",
)
constant_df
save_table(constant_df, "constant_solver_benchmark.csv")
constant_ranked = constant_df.sort_values("time_median_s")[
    [
        "config",
        "solver",
        "linear_backend",
        "time_median_s",
        "kabs_rel_diff_to_reference",
        "Q_rel_diff_to_reference",
    ]
]
constant_ranked
config solver linear_backend time_median_s kabs_rel_diff_to_reference Q_rel_diff_to_reference
2 cg cg cg 0.009297 -3.757906e-14 -3.754577e-14
1 direct_umfpack umfpack scikits.umfpack.spsolve 0.010103 -3.655550e-15 -3.727949e-15
0 direct direct scipy.sparse.linalg.spsolve 0.010382 0.000000e+00 0.000000e+00
3 cg_pyamg cg cg 0.013463 1.308030e-09 1.308030e-09
4 gmres gmres gmres 0.014050 -2.485774e-14 -2.489737e-14
5 gmres_pyamg gmres gmres 0.014461 4.577814e-10 4.577813e-10

Variable-viscosity benchmark

The nonlinear runs use absolute pressures and the same thermodynamic viscosity model as the previous notebook. Here the question is not only linear-solver speed but also whether Picard or Newton is the better outer strategy.

variable_df = summarize_configs(
    title="variable_viscosity",
    fluid=FluidSinglePhase(viscosity_model=THERMO_MODEL),
    bc=NONLINEAR_BC,
    configs=variable_configs,
    reference_label="newton_direct",
)
variable_df
save_table(variable_df, "variable_solver_benchmark.csv")
variable_ranked = variable_df.sort_values("time_median_s")[
    [
        "config",
        "solver",
        "linear_backend",
        "time_median_s",
        "nonlinear_iterations",
        "kabs_rel_diff_to_reference",
        "Q_rel_diff_to_reference",
    ]
]
variable_ranked
config solver linear_backend time_median_s nonlinear_iterations kabs_rel_diff_to_reference Q_rel_diff_to_reference
3 newton_direct direct scipy.sparse.linalg.spsolve 0.017992 2.0 0.000000e+00 0.000000e+00
4 newton_umfpack umfpack scikits.umfpack.spsolve 0.018786 2.0 0.000000e+00 0.000000e+00
6 newton_gmres_pyamg gmres gmres 0.027811 2.0 0.000000e+00 0.000000e+00
5 newton_gmres gmres gmres 0.028991 2.0 0.000000e+00 0.000000e+00
1 picard_umfpack umfpack scikits.umfpack.spsolve 0.049092 3.0 2.446298e-13 2.444083e-13
0 picard_direct direct scipy.sparse.linalg.spsolve 0.049212 3.0 2.593982e-13 2.592999e-13
2 picard_gmres_pyamg gmres gmres 0.070648 3.0 2.701555e-09 2.701555e-09
fig, axes = plt.subplots(1, 2, figsize=(14, 4.8))

constant_df.plot(
    x="config",
    y="time_median_s",
    kind="bar",
    ax=axes[0],
    legend=False,
    color="tab:blue",
    rot=20,
)
axes[0].set_title("Constant viscosity: median wall time")
axes[0].set_xlabel("Solver configuration")
axes[0].set_ylabel("seconds")

variable_df.plot(
    x="config",
    y="time_median_s",
    kind="bar",
    ax=axes[1],
    legend=False,
    color="tab:orange",
    rot=20,
)
axes[1].set_title("Variable viscosity: median wall time")
axes[1].set_xlabel("Solver configuration")
axes[1].set_ylabel("seconds")

plt.tight_layout()
save_figure(fig, "solver_runtime_bars.png")
finalize_figure(fig)

png

fig, axes = plt.subplots(1, 2, figsize=(14, 4.8))

constant_df.plot(
    x="config",
    y="kabs_rel_diff_to_reference",
    kind="bar",
    ax=axes[0],
    legend=False,
    color="tab:green",
    rot=20,
)
axes[0].axhline(0.0, color="k", linestyle="--", linewidth=1.0)
axes[0].set_title("Constant viscosity: relative Kabs difference to direct")
axes[0].set_xlabel("Solver configuration")
axes[0].set_ylabel("relative difference")

variable_df.plot(
    x="config",
    y="kabs_rel_diff_to_reference",
    kind="bar",
    ax=axes[1],
    legend=False,
    color="tab:red",
    rot=20,
)
axes[1].axhline(0.0, color="k", linestyle="--", linewidth=1.0)
axes[1].set_title("Variable viscosity: relative Kabs difference to Newton-direct")
axes[1].set_xlabel("Solver configuration")
axes[1].set_ylabel("relative difference")

plt.tight_layout()
save_figure(fig, "solver_accuracy_bars.png")
finalize_figure(fig)

png

Pareto-style view: accuracy vs runtime

The reference solves sit at zero relative error by construction. The more interesting question is whether faster iterative/preconditioned configurations stay close enough to the same Kabs.

fig, axes = plt.subplots(1, 2, figsize=(14, 4.8))

for row in constant_df.itertuples(index=False):
    axes[0].scatter(
        row.time_median_s,
        abs(row.kabs_rel_diff_to_reference),
        s=90,
        label=row.config,
    )
axes[0].set_title("Constant viscosity: runtime vs |relative Kabs error|")
axes[0].set_xlabel("median runtime [s]")
axes[0].set_ylabel("|relative Kabs error|")
axes[0].set_yscale("log")
axes[0].legend(fontsize=8)

for row in variable_df.itertuples(index=False):
    axes[1].scatter(
        row.time_median_s,
        abs(row.kabs_rel_diff_to_reference) + 1.0e-20,
        s=90,
        label=row.config,
    )
axes[1].set_title("Variable viscosity: runtime vs |relative Kabs error|")
axes[1].set_xlabel("median runtime [s]")
axes[1].set_ylabel("|relative Kabs error|")
axes[1].set_yscale("log")
axes[1].legend(fontsize=8)

plt.tight_layout()
save_figure(fig, "runtime_vs_accuracy_scatter.png")
finalize_figure(fig)

png

Nonlinear iteration count

For the variable-viscosity problem, wall time alone can be misleading. The next chart separates the outer nonlinear work from the cost of each inner linear solve.

fig, ax = plt.subplots(figsize=(7.2, 4.8))
variable_df.plot(
    x="config",
    y="nonlinear_iterations",
    kind="bar",
    ax=ax,
    legend=False,
    color="tab:purple",
    rot=20,
)
ax.set_title("Variable viscosity: nonlinear iteration count")
ax.set_xlabel("Solver configuration")
ax.set_ylabel("outer iterations")
fig.tight_layout()
save_figure(fig, "nonlinear_iterations.png")
finalize_figure(fig)

png

Direct-solver and PyAMG speedup factors

These ratios are often more interpretable than raw times because they answer the practical question: "How much do I gain from turning this backend on?"

speedup_rows = []
if {"cg", "cg_pyamg"}.issubset(constant_df["config"].tolist()):
    base = float(
        constant_df.loc[constant_df["config"] == "cg", "time_median_s"].iloc[0]
    )
    accelerated = float(
        constant_df.loc[constant_df["config"] == "cg_pyamg", "time_median_s"].iloc[0]
    )
    speedup_rows.append({"case": "constant_cg", "speedup": base / accelerated})
if {"gmres", "gmres_pyamg"}.issubset(constant_df["config"].tolist()):
    base = float(
        constant_df.loc[constant_df["config"] == "gmres", "time_median_s"].iloc[0]
    )
    accelerated = float(
        constant_df.loc[constant_df["config"] == "gmres_pyamg", "time_median_s"].iloc[0]
    )
    speedup_rows.append({"case": "constant_gmres", "speedup": base / accelerated})
if {"newton_gmres", "newton_gmres_pyamg"}.issubset(variable_df["config"].tolist()):
    base = float(
        variable_df.loc[variable_df["config"] == "newton_gmres", "time_median_s"].iloc[
            0
        ]
    )
    accelerated = float(
        variable_df.loc[
            variable_df["config"] == "newton_gmres_pyamg", "time_median_s"
        ].iloc[0]
    )
    speedup_rows.append({"case": "newton_gmres", "speedup": base / accelerated})
if {"direct", "direct_umfpack"}.issubset(constant_df["config"].tolist()):
    base = float(
        constant_df.loc[constant_df["config"] == "direct", "time_median_s"].iloc[0]
    )
    accelerated = float(
        constant_df.loc[
            constant_df["config"] == "direct_umfpack", "time_median_s"
        ].iloc[0]
    )
    speedup_rows.append({"case": "constant_direct_umfpack", "speedup": base / accelerated})
if {"newton_direct", "newton_umfpack"}.issubset(variable_df["config"].tolist()):
    base = float(
        variable_df.loc[
            variable_df["config"] == "newton_direct", "time_median_s"
        ].iloc[0]
    )
    accelerated = float(
        variable_df.loc[
            variable_df["config"] == "newton_umfpack", "time_median_s"
        ].iloc[0]
    )
    speedup_rows.append({"case": "newton_direct_umfpack", "speedup": base / accelerated})
speedup_df = pd.DataFrame(speedup_rows)
speedup_df
save_table(speedup_df, "solver_speedup.csv")
fig, ax = plt.subplots(figsize=(9.0, 4.4))
ax.bar(speedup_df["case"], speedup_df["speedup"], color="tab:cyan")
ax.axhline(1.0, color="k", linestyle="--", linewidth=1.0)
ax.set_title("Observed solver speedup")
ax.set_xlabel("solver family")
ax.set_ylabel("baseline runtime / selected runtime")
ax.tick_params(axis="x", labelrotation=25)
for label in ax.get_xticklabels():
    label.set_ha("right")
fig.tight_layout()
save_figure(fig, "solver_speedup.png")
finalize_figure(fig)

png

FEM linear backend benchmark

The FEM comparison isolates the new FEniCSSolverOptions.linear_backend choices:

  • petsc_mumps: DOLFINx assembly solved through the PETSc backend with MUMPS LU
  • scipy_spsolve: DOLFINx assembly converted to SciPy sparse format and solved by scipy.sparse.linalg.spsolve
  • umfpack: DOLFINx assembly converted to SciPy sparse format and solved by scikits.umfpack

These are linear-algebra backends for the same FEM formulation. They do not switch from FEM to TPFA, LBM, or a different physical model. The pressure and velocity arrays are compared directly against the PETSc run when PETSc is available; otherwise the first available serial direct backend becomes the local reference.

fem_df, fem_failures_df, fem_status_message = summarize_fem_backends()
print(fem_status_message)
if not fem_failures_df.empty:
    display(fem_failures_df)
fem_df
if not fem_df.empty:
    save_table(fem_df, "fem_linear_backend_benchmark.csv")
if not fem_failures_df.empty:
    save_table(fem_failures_df, "fem_linear_backend_failures.csv")
DOLFINx core imports are available.
if not fem_df.empty:
    fem_accuracy_df = fem_df.sort_values(["formulation", "cells", "backend"])[
        [
            "formulation",
            "cells",
            "backend",
            "resolved_backend",
            "reference_backend",
            "permeability_rel_diff_to_reference",
            "flow_rate_rel_diff_to_reference",
            "velocity_rel_l2_to_reference",
            "pressure_rel_l2_to_reference",
        ]
    ]
    display(fem_accuracy_df)
formulation cells backend resolved_backend reference_backend permeability_rel_diff_to_reference flow_rate_rel_diff_to_reference velocity_rel_l2_to_reference pressure_rel_l2_to_reference
3 brinkman_taylor_hood 25 petsc_mumps petsc petsc_mumps 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00
4 brinkman_taylor_hood 25 scipy_spsolve scipy petsc_mumps 6.661338e-16 6.661338e-16 1.026559e-15 1.923096e-15
5 brinkman_taylor_hood 25 umfpack umfpack petsc_mumps 6.661338e-16 6.661338e-16 1.174661e-15 1.748292e-15
12 brinkman_taylor_hood 100 petsc_mumps petsc petsc_mumps 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00
13 brinkman_taylor_hood 100 scipy_spsolve scipy petsc_mumps 4.440892e-16 4.440892e-16 1.021200e-15 1.192634e-15
14 brinkman_taylor_hood 100 umfpack umfpack petsc_mumps 4.440892e-16 4.440892e-16 9.709282e-16 1.143904e-15
21 brinkman_taylor_hood 400 petsc_mumps petsc petsc_mumps 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00
22 brinkman_taylor_hood 400 scipy_spsolve scipy petsc_mumps 1.998401e-15 1.998401e-15 1.709635e-15 7.630837e-16
23 brinkman_taylor_hood 400 umfpack umfpack petsc_mumps 1.998401e-15 1.998401e-15 1.700746e-15 7.492658e-16
6 brinkman_usfem 25 petsc_mumps petsc petsc_mumps 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00
7 brinkman_usfem 25 scipy_spsolve scipy petsc_mumps 5.551115e-16 6.661338e-16 6.678529e-16 1.190482e-15
8 brinkman_usfem 25 umfpack umfpack petsc_mumps 5.551115e-16 6.661338e-16 6.678529e-16 1.190482e-15
15 brinkman_usfem 100 petsc_mumps petsc petsc_mumps 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00
16 brinkman_usfem 100 scipy_spsolve scipy petsc_mumps -4.440892e-16 -4.440892e-16 6.523696e-16 8.518415e-16
17 brinkman_usfem 100 umfpack umfpack petsc_mumps -4.440892e-16 -4.440892e-16 6.523696e-16 8.518415e-16
24 brinkman_usfem 400 petsc_mumps petsc petsc_mumps 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00
25 brinkman_usfem 400 scipy_spsolve scipy petsc_mumps 4.440892e-16 4.440892e-16 1.080903e-15 1.274838e-15
26 brinkman_usfem 400 umfpack umfpack petsc_mumps 4.440892e-16 4.440892e-16 1.080903e-15 1.274838e-15
0 darcy_taylor_hood 25 petsc_mumps petsc petsc_mumps 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00
1 darcy_taylor_hood 25 scipy_spsolve scipy petsc_mumps 2.220446e-16 2.220446e-16 1.141210e-15 5.584413e-16
2 darcy_taylor_hood 25 umfpack umfpack petsc_mumps 2.220446e-16 2.220446e-16 1.066697e-15 6.153724e-16
9 darcy_taylor_hood 100 petsc_mumps petsc petsc_mumps 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00
10 darcy_taylor_hood 100 scipy_spsolve scipy petsc_mumps 6.661338e-16 6.661338e-16 1.390989e-15 6.495629e-16
11 darcy_taylor_hood 100 umfpack umfpack petsc_mumps 4.440892e-16 5.551115e-16 1.359505e-15 6.369641e-16
18 darcy_taylor_hood 400 petsc_mumps petsc petsc_mumps 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00
19 darcy_taylor_hood 400 scipy_spsolve scipy petsc_mumps 8.881784e-16 7.771561e-16 2.533585e-15 1.165625e-15
20 darcy_taylor_hood 400 umfpack umfpack petsc_mumps 8.881784e-16 7.771561e-16 2.502467e-15 1.125021e-15
if not fem_df.empty:
    fem_timing_df = fem_df.sort_values(["formulation", "cells", "time_median_s"])[
        [
            "formulation",
            "cells",
            "backend",
            "velocity_dofs",
            "pressure_dofs",
            "time_median_s",
            "time_min_s",
            "time_max_s",
            "reported_solve_seconds",
        ]
    ]
    display(fem_timing_df)
formulation cells backend velocity_dofs pressure_dofs time_median_s time_min_s time_max_s reported_solve_seconds
5 brinkman_taylor_hood 25 umfpack 242 36 0.008123 0.008107 0.008490 0.001064
4 brinkman_taylor_hood 25 scipy_spsolve 242 36 0.008355 0.008297 0.008461 0.000902
3 brinkman_taylor_hood 25 petsc_mumps 242 36 0.017267 0.014952 0.026390 0.008344
14 brinkman_taylor_hood 100 umfpack 882 121 0.009931 0.009898 0.010463 0.002912
13 brinkman_taylor_hood 100 scipy_spsolve 882 121 0.010385 0.010348 0.010516 0.002984
12 brinkman_taylor_hood 100 petsc_mumps 882 121 0.023709 0.022842 0.026538 0.020118
23 brinkman_taylor_hood 400 umfpack 3362 441 0.028205 0.028121 0.028450 0.019910
22 brinkman_taylor_hood 400 scipy_spsolve 3362 441 0.028450 0.028182 0.028673 0.019694
21 brinkman_taylor_hood 400 petsc_mumps 3362 441 0.039175 0.038587 0.041745 0.031385
7 brinkman_usfem 25 scipy_spsolve 72 150 0.009478 0.009352 0.009665 0.001127
8 brinkman_usfem 25 umfpack 72 150 0.009607 0.008971 0.009877 0.001045
6 brinkman_usfem 25 petsc_mumps 72 150 0.017463 0.013636 0.044026 0.006638
16 brinkman_usfem 100 scipy_spsolve 242 600 0.012606 0.012523 0.013506 0.004201
17 brinkman_usfem 100 umfpack 242 600 0.012769 0.011886 0.012871 0.003932
15 brinkman_usfem 100 petsc_mumps 242 600 0.020143 0.019955 0.028164 0.020749
26 brinkman_usfem 400 umfpack 882 2400 0.029491 0.029024 0.029781 0.020029
25 brinkman_usfem 400 scipy_spsolve 882 2400 0.030922 0.030204 0.032560 0.020733
24 brinkman_usfem 400 petsc_mumps 882 2400 0.042060 0.033998 0.044205 0.025281
2 darcy_taylor_hood 25 umfpack 242 36 0.008114 0.007847 0.009144 0.001271
1 darcy_taylor_hood 25 scipy_spsolve 242 36 0.008441 0.007283 0.009743 0.000896
0 darcy_taylor_hood 25 petsc_mumps 242 36 0.016541 0.015921 0.087464 0.009192
11 darcy_taylor_hood 100 umfpack 882 121 0.009904 0.009089 0.009916 0.002651
10 darcy_taylor_hood 100 scipy_spsolve 882 121 0.010061 0.009717 0.010637 0.003001
9 darcy_taylor_hood 100 petsc_mumps 882 121 0.021094 0.018722 0.022287 0.012217
20 darcy_taylor_hood 400 umfpack 3362 441 0.028357 0.028189 0.029185 0.020073
19 darcy_taylor_hood 400 scipy_spsolve 3362 441 0.028371 0.027439 0.029189 0.019843
18 darcy_taylor_hood 400 petsc_mumps 3362 441 0.031277 0.030776 0.038583 0.030505
if not fem_df.empty:
    fig, axes = plt.subplots(1, len(fem_formulations), figsize=(16, 4.6), sharey=True)
    for ax, (formulation, group) in zip(axes, fem_df.groupby("formulation"), strict=False):
        pivot = group.pivot(index="cells", columns="backend", values="time_median_s")
        pivot.plot(kind="bar", ax=ax, rot=0)
        ax.set_title(formulation.replace("_", " "))
        ax.set_xlabel("map cells")
        ax.set_ylabel("median runtime [s]")
        ax.legend(fontsize=8)
    fig.suptitle("FEM linear backend runtime by formulation")
    fig.tight_layout()
    save_figure(fig, "fem_linear_backend_runtime.png")
    finalize_figure(fig)

png

if not fem_df.empty:
    fig, axes = plt.subplots(1, 2, figsize=(13.5, 4.8))
    accuracy_columns = [
        "permeability_rel_diff_to_reference",
        "velocity_rel_l2_to_reference",
        "pressure_rel_l2_to_reference",
    ]
    for backend, group in fem_df.groupby("backend"):
        axes[0].scatter(
            group["time_median_s"],
            group["velocity_rel_l2_to_reference"].clip(lower=1.0e-18),
            s=80,
            label=backend,
        )
        axes[1].scatter(
            group["time_median_s"],
            group["pressure_rel_l2_to_reference"].clip(lower=1.0e-18),
            s=80,
            label=backend,
        )
    for ax, field in zip(axes, ["velocity", "pressure"], strict=True):
        ax.set_title(f"{field.capitalize()} field parity")
        ax.set_xlabel("median runtime [s]")
        ax.set_ylabel(f"{field} relative L2 difference")
        ax.set_yscale("log")
        ax.legend(fontsize=8)
    fig.tight_layout()
    save_figure(fig, "fem_field_parity_scatter.png")
    finalize_figure(fig)

png

if not fem_df.empty:
    fem_summary_rows = []
    for backend, group in fem_df.groupby("backend"):
        non_reference = group[group["backend"] != group["reference_backend"]]
        comparison_group = non_reference if not non_reference.empty else group
        fem_summary_rows.append(
            {
                "backend": backend,
                "cases_completed": len(group),
                "median_runtime_s": group["time_median_s"].median(),
                "max_abs_k_rel_diff": group[
                    "permeability_rel_diff_to_reference"
                ].abs().max(),
                "max_velocity_rel_l2": comparison_group[
                    "velocity_rel_l2_to_reference"
                ].max(),
                "max_pressure_rel_l2": comparison_group[
                    "pressure_rel_l2_to_reference"
                ].max(),
            }
        )
    fem_summary_df = pd.DataFrame(fem_summary_rows).sort_values("median_runtime_s")
    display(fem_summary_df)
    save_table(fem_summary_df, "fem_linear_backend_summary.csv")
backend cases_completed median_runtime_s max_abs_k_rel_diff max_velocity_rel_l2 max_pressure_rel_l2
2 umfpack 9 0.009931 1.998401e-15 2.502467e-15 1.748292e-15
1 scipy_spsolve 9 0.010385 1.998401e-15 2.533585e-15 1.923096e-15
0 petsc_mumps 9 0.021094 0.000000e+00 0.000000e+00 0.000000e+00

Reading the tables

Suggested interpretation: - for constant viscosity, the main question is whether cg + pyamg or gmres + pyamg reduces runtime without changing Kabs - for variable viscosity, the main question is whether Newton reduces outer iterations enough to offset the cost of assembling and solving the exact Jacobian - for FEM, the main question is whether PETSc, SciPy, and UMFPACK produce the same permeability, flow rate, pressure field, and velocity field for the same weak form and coefficient maps - if the Kabs and FEM field differences remain near machine precision, the benchmark is comparing linear algebra backends, not changing the physics

Suggested documentation use: - the runtime bars are the high-level headline figure - the runtime-vs-accuracy scatter is the defensible numerical-quality figure - the nonlinear-iteration bar chart explains why Newton may outperform Picard even when each Newton iteration is more expensive - the FEM field-parity table is the defensible check for the Windows-compatible direct sparse backends because matching permeability alone could hide local pressure or velocity differences