Examples¶
This chapter provides step-by-step examples for building numerical solvers with µGrid. We present two complete examples: a Poisson solver and a linear elasticity solver for micromechanical homogenization.
For details on the different operator types available and when to use each one, see the Operators chapter.
Poisson Solver¶
The Poisson equation is a fundamental PDE that appears in many physical contexts (heat conduction, electrostatics, etc.). We solve:
with periodic boundary conditions on a unit domain.
Setting up the grid¶
First, we import the necessary modules and set up a 2D grid with ghost regions for the stencil operations:
import numpy as np
import muGrid
from muGrid.Solvers import conjugate_gradients
# Create a communicator (serial execution)
comm = muGrid.Communicator()
# Grid parameters
nb_grid_pts = (64, 64)
dim = len(nb_grid_pts)
# Ghost layers: 1 cell on each side for the 5-point stencil
nb_ghosts_left = (1, 1)
nb_ghosts_right = (1, 1)
# Create the domain decomposition
# (works for both serial and MPI-parallel execution)
decomposition = muGrid.CartesianDecomposition(
comm,
nb_domain_grid_pts=nb_grid_pts,
nb_ghosts_left=nb_ghosts_left,
nb_ghosts_right=nb_ghosts_right,
)
Creating the Laplacian operator¶
µGrid provides an optimized LaplaceOperator for the discrete Laplacian:
# Grid spacing (assuming unit domain)
h = 1.0 / nb_grid_pts[0]
# Scale factor: negative because -∇² must be positive definite for CG
scale = -1.0 / h**2
# Hard-coded Laplacian operator (optimized implementation)
laplace = muGrid.LaplaceOperator(dim, scale)
The LaplaceOperator implements the standard 5-point stencil in 2D (7-point in
3D) with optimized memory access patterns for both CPU and GPU.
Creating fields and setting up the RHS¶
# Create fields using the decomposition
rhs = decomposition.real_field("rhs")
solution = decomposition.real_field("solution")
# Set up a smooth right-hand side
# Get coordinates for each pixel in the local domain
coords = decomposition.coords
X, Y = coords[0], coords[1]
rhs.p[...] = np.sin(2 * np.pi * X) * np.sin(2 * np.pi * Y)
# Remove mean (necessary for periodic Poisson with Neumann-like conditions)
rhs.p[...] -= np.mean(rhs.p)
Solving with conjugate gradients¶
The conjugate gradient solver requires a function that applies the linear operator:
def apply_laplacian(x, Ax):
"""Apply the Laplacian operator: Ax = L @ x"""
# Fill ghost regions with periodic boundary values
decomposition.communicate_ghosts(x)
# Apply the stencil
laplace.apply(x, Ax)
# Solve the system
conjugate_gradients(
comm,
decomposition,
rhs,
solution,
hessp=apply_laplacian,
tol=1e-6,
maxiter=1000,
)
print(f"Solution range: [{solution.p.min():.6f}, {solution.p.max():.6f}]")
muGrid.Solvers also provides conjugate_gradients_pipelined, a drop-in
pipelined (Ghysels–Vanroose) variant with the same signature. It fuses the three
per-iteration inner products into a single, non-blocking global reduction that
is overlapped with the operator and preconditioner applies, hiding the
allreduce latency on many-rank runs. The extra vector work makes it a small net
loss on a single node, so it is opt-in; prefer it when the global reduction is on
the critical path (high rank counts across a network).
Complete Poisson solver¶
Here is the complete, minimal Poisson solver (examples/poisson.py):
import numpy as np
import muGrid
from muGrid.Solvers import conjugate_gradients
# Setup
comm = muGrid.Communicator()
nb_grid_pts = (64, 64)
h = 1.0 / nb_grid_pts[0]
decomposition = muGrid.CartesianDecomposition(
comm,
nb_domain_grid_pts=nb_grid_pts,
nb_ghosts_left=(1, 1),
nb_ghosts_right=(1, 1),
)
# Laplacian operator (negative for positive-definiteness)
laplace = muGrid.LaplaceOperator(2, -1.0 / h**2)
# Fields
rhs = decomposition.real_field("rhs")
solution = decomposition.real_field("solution")
# RHS: smooth function with zero mean
coords = decomposition.coords
X, Y = coords[0], coords[1]
rhs.p[...] = np.sin(2 * np.pi * X) * np.sin(2 * np.pi * Y)
rhs.p[...] -= np.mean(rhs.p)
# Linear operator for CG
def apply_laplacian(x, Ax):
decomposition.communicate_ghosts(x)
laplace.apply(x, Ax)
# Solve
conjugate_gradients(comm, decomposition, rhs, solution,
hessp=apply_laplacian, tol=1e-6, maxiter=1000)
print(f"Solved! Solution range: [{solution.p.min():.4f}, {solution.p.max():.4f}]")
Preconditioning¶
The conjugate-gradient solver accepts a preconditioner through its prec
argument: any callable prec(r, z) that overwrites z with M⁻¹ r. The
muGrid.Preconditioners module provides a small class hierarchy implementing
this contract:
IdentityPreconditioner— no-op, equivalent toprec=None;JacobiPreconditioner(diagonal)— divides by the operator diagonal. Useful for strongly heterogeneous coefficients, where it equilibrates the spectrum (for a constant diagonal it merely rescales the system and does not change the iteration). The diagonal may be spatial-only (shared across field components) or per-component;FourierPreconditioner(engine, kernel)— applies a scalar spectral kernel,z = F⁻¹[k(q) · F r], using a muGridFFTEngine. With the inverse symbol of (an approximation to) the operator as the kernel, this is the classic FFT preconditioner;BlockFourierPreconditioner(engine, blocks)— the matrix-valued generalization, applying a per-Fourier-moden×nblock inverse (for vector/tensor unknowns).make_reference_stiffness_preconditioner(...)builds one from a homogeneous reference stiffness — the reference-material (Green's-function) preconditioner of Ladecký et al. (2023) used by the homogenization example (-P reference), which makes the CG iteration count nearly independent of grid size (see the preconditioner benchmark).
Spectral (FFT) preconditioning of the Poisson problem¶
The finite-difference Laplacian diagonalizes in Fourier space, so its exact inverse symbol is available in closed form — with it, conjugate gradients converges in a single iteration (it becomes a direct solver). For operators that are only approximately diagonalized by the FFT (e.g. weakly heterogeneous coefficients), the same kernel built from the homogeneous reference operator still yields mesh-independent iteration counts.
One detail is essential for MPI runs: the solver fields and the FFT must share a
single domain decomposition. A stand-alone CartesianDecomposition would in
general split the domain differently than the FFT's own decomposition. Since
the FFTEngine is a CartesianDecomposition (it supports ghost buffers in
real space), the engine itself serves as the decomposition for everything — the
stencil operator, the solver work fields, and the transforms:
import numpy as np
import muGrid
from muGrid.Preconditioners import FourierPreconditioner
from muGrid.Solvers import conjugate_gradients
comm = muGrid.Communicator()
nb_grid_pts = (64, 64)
h = 1.0 / nb_grid_pts[0]
# The FFT engine doubles as the (ghosted) domain decomposition
engine = muGrid.FFTEngine(nb_grid_pts, comm,
nb_ghosts_left=(1, 1), nb_ghosts_right=(1, 1))
laplace = muGrid.LaplaceOperator(len(nb_grid_pts), -1.0 / h**2)
rhs = engine.real_space_field("rhs")
solution = engine.real_space_field("solution")
X, Y = engine.coords
rhs.p[...] = np.sin(2 * np.pi * X) * np.sin(2 * np.pi * Y)
def hessp(x, Ax):
engine.communicate_ghosts(x)
laplace.apply(x, Ax)
# Exact inverse symbol of -FD-Laplacian/h²; the q = 0 mode of the
# periodic Laplacian is singular and is projected out by a zero entry
# (the right-hand side must be free of it, i.e. have zero mean).
def inverse_fd_laplacian(engine):
q = engine.fftfreq # shape [dim, *local_fourier_shape]
denom = (4 * np.sin(np.pi * q) ** 2 / h**2).sum(axis=0)
return np.where(denom > 0, 1 / np.where(denom > 0, denom, 1), 0.0)
prec = FourierPreconditioner(engine, inverse_fd_laplacian)
conjugate_gradients(comm, engine.real_space_collection, rhs, solution,
hessp=hessp, prec=prec, tol=1e-8, maxiter=10)
The kernel is evaluated once on the rank-local Fourier subdomain
(engine.fftfreq only exposes local frequencies), so the code is unchanged
between serial and MPI-parallel execution. The FFT normalisation is folded into
the kernel; the kernel broadcasts over field components, so vector- and
tensor-valued unknowns are preconditioned per component.
Jacobi preconditioning of heterogeneous problems¶
For a screened Poisson problem (-∇²/h² + c(x)) u = b with a coefficient c(x)
varying over many orders of magnitude, dividing by the operator diagonal restores
a well-clustered spectrum:
from muGrid.Preconditioners import JacobiPreconditioner
diag = engine.real_space_field("diagonal")
diag.p[...] = 4 / h**2 + c # stencil center plus screening coefficient
conjugate_gradients(comm, engine.real_space_collection, rhs, solution,
hessp=hessp_screened,
prec=JacobiPreconditioner(diag),
tol=1e-8, maxiter=1000)
In the test suite (tests/python_preconditioner_tests.py), this reduces the
iteration count on a 32×32 problem with c spanning six orders of magnitude from
164 to 21; the FFT-preconditioned Poisson solve converges in a single iteration.
Both preconditioners run wherever the solver fields live: the Fourier kernel and
the inverse diagonal are stored in fields on the engine's collections and applied
with muGrid's fused linear-algebra kernels (linalg.scal with a field-valued
alpha), so a solve on a GPU device stays on the device with no host transfers in
the iteration loop.
Linear Elasticity Solver¶
This example computes the effective elastic properties of a heterogeneous material using FEM-based homogenization. The governing equation is:
where the stress is related to strain by Hooke's law:
and strain is the symmetric gradient of displacement:
For isotropic materials, µGrid provides the fused IsotropicStiffnessOperator
which computes the entire stiffness operation
\(\mathbf{K}\mathbf{u} = \mathbf{B}^T \mathbf{C} \mathbf{B} \mathbf{u}\)
efficiently without explicitly forming intermediate tensors.
Material properties¶
Isotropic elastic materials are characterized by two Lamé parameters (λ, μ), which can be computed from Young's modulus E and Poisson's ratio ν:
import numpy as np
import muGrid
from muGrid.Solvers import conjugate_gradients
def lame_parameters(E, nu):
"""Compute Lamé parameters from Young's modulus and Poisson's ratio."""
lam = E * nu / ((1 + nu) * (1 - 2 * nu))
mu = E / (2 * (1 + nu))
return lam, mu
# Material parameters
E_matrix = 1.0 # Young's modulus of matrix
E_inclusion = 10.0 # Young's modulus of inclusion (10x stiffer)
nu = 0.3 # Poisson's ratio (same for both)
lam_matrix, mu_matrix = lame_parameters(E_matrix, nu)
lam_inclusion, mu_inclusion = lame_parameters(E_inclusion, nu)
Setting up the grid and operator¶
The IsotropicStiffnessOperator operates on nodal displacement fields and
requires material properties (λ, μ) defined per element:
nb_grid_pts = (32, 32)
dim = 2
# Grid spacing
grid_spacing = tuple(1.0 / n for n in nb_grid_pts)
# Create the fused stiffness operator
stiffness_op = muGrid.IsotropicStiffnessOperator2D(grid_spacing)
# For 3D problems:
# stiffness_op = muGrid.IsotropicStiffnessOperator3D(grid_spacing)
Setting up the microstructure¶
We create a simple circular inclusion in the center. Material fields are defined per element (one fewer grid point in each direction than nodal fields):
comm = muGrid.Communicator()
# Domain decomposition for nodal fields (displacements, forces)
decomposition = muGrid.CartesianDecomposition(
comm,
nb_domain_grid_pts=nb_grid_pts,
nb_ghosts_left=(1,) * dim,
nb_ghosts_right=(1,) * dim,
)
# Domain decomposition for element fields (material properties)
# Elements are defined between nodes, so one fewer in each direction
element_grid_pts = tuple(n - 1 for n in nb_grid_pts)
element_decomposition = muGrid.CartesianDecomposition(
comm,
nb_domain_grid_pts=element_grid_pts,
nb_ghosts_left=(1,) * dim,
nb_ghosts_right=(1,) * dim,
)
# Create material fields
lambda_field = element_decomposition.real_field("lambda")
mu_field = element_decomposition.real_field("mu")
# Get element coordinates (centers of elements)
coords = element_decomposition.coords
X, Y = coords[0], coords[1]
# Circular inclusion at center with radius 0.25
radius = 0.25
distance = np.sqrt((X - 0.5)**2 + (Y - 0.5)**2)
phase = (distance < radius).astype(float) # 1 = inclusion, 0 = matrix
# Set material properties
lambda_field.p[...] = lam_matrix * (1 - phase) + lam_inclusion * phase
mu_field.p[...] = mu_matrix * (1 - phase) + mu_inclusion * phase
# Fill ghost regions (only needs to be done once)
element_decomposition.communicate_ghosts(lambda_field)
element_decomposition.communicate_ghosts(mu_field)
print(f"Inclusion volume fraction: {np.mean(phase):.4f}")
Creating displacement and force fields¶
# Displacement field: vector with dim components at nodes
u_field = decomposition.real_field("displacement", (dim,))
# Force field (RHS): vector with dim components at nodes
f_field = decomposition.real_field("force", (dim,))
The stiffness operator¶
The fused operator combines gradient, constitutive, and divergence operations:
def apply_stiffness(u_in, f_out):
"""
Apply stiffness operator: f = K @ u = B^T C B u
"""
decomposition.communicate_ghosts(u_in)
stiffness_op.apply(u_in, lambda_field, mu_field, f_out)
This is significantly faster than manually computing the sequence \(\varepsilon = \mathbf{B}\mathbf{u}\), \(\sigma = \mathbf{C}:\varepsilon\), \(\mathbf{f} = \mathbf{B}^T\sigma\) because:
- No intermediate storage for strain and stress tensors
- Optimized memory access patterns
- Material properties stored as just two scalars per element
See the Operators chapter for detailed performance comparisons.
Solving for effective properties¶
To compute effective properties, we apply unit macroscopic strains and measure the resulting average stress. Here's a simplified approach:
# For homogenization, we solve: K @ u = -K @ (E_macro · x)
# where E_macro is the applied macroscopic strain
# This requires computing the RHS by applying the stiffness operator
# to a linear displacement field u_linear = E_macro · x
# Initialize with the macroscopic strain contribution
# (Full implementation in examples/homogenization.py)
# Solve equilibrium
conjugate_gradients(
comm,
decomposition,
f_field,
u_field,
hessp=apply_stiffness,
tol=1e-6,
maxiter=500,
)
Complete homogenization example¶
A complete homogenization example that computes effective elastic properties is
provided in examples/homogenization.py. It includes:
- Full RHS computation for applied macroscopic strains
- Computation of all independent effective stiffness components
- Validation against analytical bounds (Voigt, Reuss, Hashin-Shtrikman)
- Support for both 2D and 3D problems
- MPI parallelization for large-scale computations
- GPU acceleration
Run the example:
# 2D, 64×64 grid
python examples/homogenization.py -n 64,64
# 3D, 32×32×32 grid
python examples/homogenization.py -n 32,32,32
# With MPI parallelization
mpiexec -n 4 python examples/homogenization.py -n 128,128
# On GPU
python examples/homogenization.py -n 256,256 -d gpu
Scaling benchmarks¶
Three benchmark pages report CPU and GPU timings across a range of grid sizes, together with the hardware they were run on:
- Benchmark (Poisson) — the CG Poisson solve, generated by
examples/benchmark.py. - Benchmark (homogenization) — the 3D FEM
homogenization solve (incl. MPI strong scaling), generated by
examples/benchmark_homogenization.py. - Benchmark (preconditioner) — the
effect of the reference-material Fourier preconditioner (
-P reference) on iteration count and wall time, generated byexamples/benchmark_homogenization_preconditioner.py.
Re-run any of these scripts to refresh the numbers for your own hardware.