Run this tutorial
Click here to run this tutorial on mybinder.org:Example: custom Python functions as C++ (grid) functions¶
So far, every coefficient function in these examples has been either a constant
(ConstantFunction) or a parsed expression string (ExpressionFunction, e.g.
'exp(x[0]*x[1])'). The expression parser is convenient, but it is also a fragile input channel:
it understands only what its grammar implements, and it silently mishandles some inputs (e.g. it
rejects subnormal float literals).
dune.xt.functions.GenericFunction and GenericGridFunction close that gap: instead of a string
to be parsed, they take a plain Python callable and invoke it directly from the C++ grid walk.
This means any Python code – piecewise definitions, random coefficients, calls into numpy/scipy –
can serve as a dune-gdt coefficient function.
GenericFunctionis evaluated in global (physical) coordinates, likeExpressionFunction.GenericGridFunctionis evaluated locally, in reference-element coordinates, and additionally supports apost_bindcallback invoked once per element during the grid walk – so the function can depend on the element itself (its index, its center, …), not just on the point being evaluated.
Both take an order (the polynomial degree used to pick a quadrature rule) exactly like
ExpressionFunction, since the callback is opaque to the C++ side; there is no way to infer the
polynomial degree of a Python function automatically.
# wurlitzer: display dune's output in the notebook
%load_ext wurlitzer
import time
import numpy as np
from dune.xt.grid import Dim
try:
from dune.xt.functions import GenericFunction, GenericGridFunction
HAVE_GENERIC_FUNCTION = True
except ImportError:
HAVE_GENERIC_FUNCTION = False
print(
"This wheel was not built with the GenericFunction/GenericGridFunction bindings, so the\n"
"cells below are skipped instead of failing."
)
1: GenericFunction vs. ExpressionFunction¶
We solve the same stationary heat equation as in
example__ipdg_stationary_heat_equation.md, once with the diffusion and source given as
ExpressionFunction (a parsed string), and once with the same math given as a GenericFunction
(a Python lambda). The two must produce the same discrete solution, since they describe the same
function through two independent code paths.
d = 2
omega = ([0, 0], [1, 1])
if HAVE_GENERIC_FUNCTION:
from dune.xt.functions import ConstantFunction, ExpressionFunction
kappa_expression = ConstantFunction(
dim_domain=Dim(d), dim_range=Dim(1), value=[1.0], name="kappa"
)
f_expression = ExpressionFunction(
dim_domain=Dim(d),
variable="x",
expression="exp(x[0]*x[1])",
order=3,
name="f",
)
# the same source term, but evaluated by a Python callable instead of the expression parser
def f_evaluate(x, mu):
return [float(np.exp(x[0] * x[1]))]
f_generic = GenericFunction(
dim_domain=Dim(d), dim_range=Dim(1), order=3, evaluate=f_evaluate, name="f_generic"
)
from dune.xt.grid import Simplex, make_cube_grid
if HAVE_GENERIC_FUNCTION:
grid = make_cube_grid(Dim(d), Simplex(), lower_left=omega[0], upper_right=omega[1], num_elements=[2, 2])
grid.global_refine(1) # we need to refine once to obtain a symmetric grid
print(f"grid has {grid.size(0)} elements, {grid.size(d - 1)} edges and {grid.size(d)} vertices")
grid has 16 elements, 28 edges and 13 vertices
if HAVE_GENERIC_FUNCTION:
from discretize_elliptic_ipdg import discretize_elliptic_ipdg_dirichlet_zero
# penalty_parameter is given explicitly (as in example__ipdg_stationary_heat_equation.md) to
# avoid the LAPACKE-dependent auto-estimation path (estimate_combined_inverse_trace_inequality_
# constant), which is not available in every build
u_h_expression = discretize_elliptic_ipdg_dirichlet_zero(
grid, kappa_expression, f_expression, penalty_parameter=16
)
u_h_generic = discretize_elliptic_ipdg_dirichlet_zero(
grid, kappa_expression, f_generic, penalty_parameter=16
)
dofs_expression = np.array(u_h_expression.dofs.vector, copy=False)
dofs_generic = np.array(u_h_generic.dofs.vector, copy=False)
max_difference = np.abs(dofs_expression - dofs_generic).max()
print(f"max |dofs_expression - dofs_generic| = {max_difference:.3e}")
assert max_difference < 1e-13
max |dofs_expression - dofs_generic| = 0.000e+00
if HAVE_GENERIC_FUNCTION:
from dune.gdt import visualize_function
_ = visualize_function(u_h_generic)
2: GenericGridFunction with post_bind – an element-dependent coefficient¶
A GenericFunction can only see the (global) evaluation point, so it cannot depend on which
element it is being evaluated on – e.g. to implement a piecewise-constant “checkerboard”
coefficient without going through dune.xt.functions.CheckerboardFunction. GenericGridFunction’s
post_bind callback is invoked once whenever the grid walk moves to a new element, before any
evaluate calls on that element, so we can look up a per-element value there and stash it in a
Python closure for evaluate to read:
if HAVE_GENERIC_FUNCTION:
def make_checkerboard_diffusion(grid, blocks, values):
"""A piecewise-constant, element-wise isotropic diffusion tensor kappa(x) = values[cell(x)].
`values` is indexed by the coarse "checkerboard" cell (row-major over `blocks`) the
element's center falls into.
"""
d = grid.dimension
blocks = np.asarray(blocks[:d])
state = {"value": values[0]}
def post_bind(element):
cell = np.minimum((np.asarray(element.center) * blocks).astype(int), blocks - 1)
state["value"] = values[int(np.ravel_multi_index(tuple(cell), tuple(blocks)))]
def evaluate(x, mu):
value = float(state["value"])
return [[value if i == j else 0.0 for j in range(d)] for i in range(d)]
# dim_range disambiguates the scalar/vector/matrix GenericGridFunction overloads sharing
# this factory name -- it is not otherwise inferable from the post_bind/evaluate callables
return GenericGridFunction(
grid,
order=0,
post_bind=post_bind,
evaluate=evaluate,
dim_range=(Dim(d), Dim(d)),
name="checkerboard_kappa",
)
rng = np.random.default_rng(0)
checkerboard_values = rng.uniform(0.1, 10.0, size=4)
kappa_checkerboard = make_checkerboard_diffusion(grid, blocks=(2, 2), values=checkerboard_values)
u_h_checkerboard = discretize_elliptic_ipdg_dirichlet_zero(
grid, kappa_checkerboard, f_expression, penalty_parameter=16
)
_ = visualize_function(u_h_checkerboard)
3: overhead of a Python callback vs. a parsed expression¶
Every GenericFunction/GenericGridFunction evaluation calls back into Python, which means
pybind11 has to acquire the GIL for each call (this happens automatically as part of the
std::function <-> Python-callable conversion pybind11 generates – no code in the bindings
manages the GIL explicitly). ExpressionFunction never leaves C++. We measure the per-call
overhead by assembling the same Laplace matrix with each and dividing the wall-clock difference by
the number of evaluate calls (elements times quadrature points times repetitions):
if HAVE_GENERIC_FUNCTION:
from dune.gdt import (
BilinearForm,
ContinuousLagrangeSpace,
LocalElementIntegralBilinearForm,
LocalLaplaceIntegrand,
MatrixOperator,
make_element_sparsity_pattern,
)
from dune.xt.functions import GridFunction as GF
def assemble_laplace(grid, diffusion, repetitions=5):
space = ContinuousLagrangeSpace(grid, order=1)
start = time.perf_counter()
for _ in range(repetitions):
form = BilinearForm(grid)
form += LocalElementIntegralBilinearForm(
LocalLaplaceIntegrand(GF(grid, diffusion, dim_range=(Dim(d), Dim(d))))
)
op = MatrixOperator(
grid,
source_space=space,
range_space=space,
sparsity_pattern=make_element_sparsity_pattern(space),
)
op.append(form)
op.assemble()
return time.perf_counter() - start
fine_grid = make_cube_grid(Dim(d), Simplex(), lower_left=omega[0], upper_right=omega[1], num_elements=[8, 8])
fine_grid.global_refine(1)
num_elements = fine_grid.size(0)
# order-1 CG on a 2d simplex: a 2nd order quadrature rule has 3 points per element
quadrature_points_per_element = 3
repetitions = 5
time_expression = assemble_laplace(fine_grid, kappa_expression, repetitions)
# scalar, matching kappa_expression exactly (GF's dim_range=(Dim(d), Dim(d)) tag in
# assemble_laplace promotes both identically) -- so the timing difference below isolates the
# cost of the Python callback itself, not an unrelated scalar-vs-matrix evaluation difference
def kappa_evaluate(x, mu):
return [1.0]
kappa_generic = GenericFunction(
dim_domain=Dim(d), dim_range=Dim(1), order=0, evaluate=kappa_evaluate, name="kappa_generic"
)
time_generic = assemble_laplace(fine_grid, kappa_generic, repetitions)
total_calls = num_elements * quadrature_points_per_element * repetitions
overhead_per_call = (time_generic - time_expression) / total_calls
print(f"ExpressionFunction assembly: {time_expression:.4f} s")
print(f"GenericFunction assembly: {time_generic:.4f} s")
print(f"~{total_calls} evaluate() calls total")
print(f"estimated overhead per Python callback: {overhead_per_call * 1e6:.2f} us")
ExpressionFunction assembly: 0.0082 s
GenericFunction assembly: 0.0102 s
~3840 evaluate() calls total
estimated overhead per Python callback: 0.53 us
This overhead (typically low single-digit microseconds per call on CPython, dominated by the GIL acquisition and the argument/return-value conversion) is the price of the flexibility gained: any Python code, however irregular, becomes usable as a coefficient function.
Download the code:
example__custom_python_functions.md
example__custom_python_functions.ipynb