← all demos

timing_benchmark

Timing of assembly and both solve backends on a 3D elastic box over a range of sizes.

uv run python examples/cli.py run timing_benchmark

Direct factorization grows super-linearly with the fill-in a 3D mesh brings; AMG-preconditioned CG scales closer to linearly, and overtakes it as the mesh grows: the crossover this benchmark exists to measure.
Direct factorization grows super-linearly with the fill-in a 3D mesh brings; AMG-preconditioned CG scales closer to linearly, and overtakes it as the mesh grows: the crossover this benchmark exists to measure.
n=  5  tets=     320  dofs=     375  assemble=  0.00s  direct=   0.00s  amg_cg=  0.01s
n=  9  tets=    2560  dofs=    2187  assemble=  0.02s  direct=   0.01s  amg_cg=  0.02s
n= 13  tets=    8640  dofs=    6591  assemble=  0.06s  direct=   0.06s  amg_cg=  0.06s
n= 17  tets=   20480  dofs=   14739  assemble=  0.16s  direct=   0.39s  amg_cg=  2.59s
n= 21  tets=   40000  dofs=   27783  assemble=  0.26s  direct=   2.84s  amg_cg=  2.60s

Source

The functions that pose and solve the problem. The figures are below the fold.

"""Time assembly and the two solve backends versus mesh size, on a 3D elastic box.

Makes the scaling work concrete and guards against regressions: run it before and
after a change to see where the time goes. Sparse matrices moved the cost off the
solve and onto assembly; batching assembly moved it back onto the sparse
factorization, which dominates at any interesting 3D resolution. This benchmark is
what motivated the iterative backend, so it now times both: the direct `splu`
factor+solve against AMG-preconditioned CG. The direct cost grows super-linearly
with fill-in; the AMG-CG cost should overtake it as the mesh grows.

`benchmark` times one box size; `run` sweeps the sizes into a `BenchmarkStudy`.
Nothing here draws: `figures.py` charts the study, and this file is what the gallery
shows. It also runs as a script, printing the table over the default sizes:

    cd examples && uv run python -m demos.timing_benchmark.physics
    uv run python examples/cli.py run timing_benchmark
"""
import contextlib
import logging
import time
from dataclasses import dataclass

from fem.algebra.backends import DirectBackend, IterativeBackend
from fem.algebra.system import DiscreteSystem
from fem.boundary import Dirichlet
from fem.conditions import Conditions
from fem.loads import Source
from fem.mesh.structured import box_mesh
from fem.physics.equations import LinearElastic
from fem.regions import everywhere

DEFAULT_SIZES = (5, 9, 13, 17, 21)


@contextlib.contextmanager
def _quiet_logging():
    """Silence per-solve logging for the duration of a timed section, then restore it.

    Scoped rather than disabled at import: a module-level `logging.disable` would mute
    logging for the whole process the moment this demo is imported, including the CLI's
    own solver-progress output for every other demo.
    """
    previous = logging.root.manager.disable
    logging.disable(logging.CRITICAL)
    try:
        yield
    finally:
        logging.disable(previous)


def _time(fn):
    start = time.perf_counter()
    result = fn()
    return result, time.perf_counter() - start


@dataclass
class Timing:
    """One box size's measurements: kept as numbers, not a formatted string, so the
    figure can chart the scaling trend as well as print it."""
    n: int
    tets: int
    dofs: int
    assemble: float
    direct: float
    amg_cg: float

    def __str__(self) -> str:
        return (f'n={self.n:>3}  tets={self.tets:>8}  dofs={self.dofs:>8}  '
                f'assemble={self.assemble:>6.2f}s  direct={self.direct:>7.2f}s  '
                f'amg_cg={self.amg_cg:>6.2f}s')


def benchmark(n: int) -> Timing:
    with _quiet_logging():
        mesh = box_mesh(corners=[[0, 0, 0], [1, 1, 1]], resolution=(n, n, n))
        bc = Conditions(Dirichlet(everywhere(), [0.0, 0.0, 0.0]))
        equation = LinearElastic(E=200.0, nu=0.3)

        # Building the LinearProblem assembles the stiffness and the load.
        problem, t_assemble = _time(lambda: equation.problem(mesh, bc + Source(lambda p: [1.0, 0.0, 0.0])))
        A, b = problem.tangent(None), problem.load
        partition, values = problem.partition, problem.fixed_values

        # Each backend factors/preconditions in DiscreteSystem's constructor and solves
        # once; timing the whole construct+solve captures the setup each pays.
        _, t_direct = _time(lambda: DiscreteSystem(A, partition, DirectBackend()).solve(b, values))
        _, t_iter = _time(lambda: DiscreteSystem(A, partition, IterativeBackend()).solve(b, values))

    return Timing(n, len(mesh.elements), problem.space.n_dofs, t_assemble, t_direct, t_iter)


@dataclass
class BenchmarkStudy:
    """The timings over the sizes swept, for the chart and the table to read."""
    timings: list[Timing]

    @property
    def dofs(self) -> list[int]:
        return [t.dofs for t in self.timings]

    @property
    def table(self) -> str:
        return '\n'.join(str(t) for t in self.timings)


def run(sizes=DEFAULT_SIZES) -> BenchmarkStudy:
    """Benchmark each box size in `sizes`."""
    return BenchmarkStudy([benchmark(n) for n in sizes])


if __name__ == '__main__':
    print(run().table)
The figures, and the demo that assembles them
"""The chart and table of the timing benchmark, drawn from a `BenchmarkStudy`."""
from demo_registry import Demo, DemoResult, Figure

from demos.timing_benchmark import physics
from demos.timing_benchmark.physics import DEFAULT_SIZES, BenchmarkStudy, run
from fem.plot.plotter import Plotter


def _scaling_figure(s: BenchmarkStudy) -> Figure:
    plotter = Plotter(title='Assembly and solve time vs problem size')
    ax = plotter.chart_ax(xlabel='degrees of freedom', ylabel='time (s)')
    ax.loglog(s.dofs, [t.assemble for t in s.timings], 'o-', label='assemble')
    ax.loglog(s.dofs, [t.direct for t in s.timings], 'o-', label='direct (splu)')
    ax.loglog(s.dofs, [t.amg_cg for t in s.timings], 'o-', label='AMG-CG')
    ax.grid(True, which='both', alpha=0.3)
    return Figure(
        plotter,
        'Direct factorization grows super-linearly with the fill-in a 3D mesh '
        'brings; AMG-preconditioned CG scales closer to linearly, and overtakes '
        'it as the mesh grows: the crossover this benchmark exists to measure.')


def demo(sizes=DEFAULT_SIZES) -> DemoResult:
    """Timing of assembly and both solve backends on a 3D elastic box over a range of
    sizes."""
    s = run(sizes)
    return DemoResult([_scaling_figure(s)], text=s.table)


# The sweep shows the crossover, so the CLI and the gallery run all five sizes. The
# test only needs to know that assembly and both backends still compose, which n=5
# answers in 0.01s where the full sweep takes 11.6s, over half of it one sparse
# factorisation at n=21.
DEMO = Demo('timing_benchmark', demo, section='Accuracy & performance',
            show_source=physics, smoke_kwargs={'sizes': (5,)})