Skip to content

Remove component tensors #339

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 6 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
32 changes: 32 additions & 0 deletions test/test_algorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
FacetNormal,
FunctionSpace,
Mesh,
SpatialCoordinate,
TestFunction,
TrialFunction,
adjoint,
Expand All @@ -21,9 +22,11 @@
dx,
grad,
inner,
sin,
triangle,
)
from ufl.algorithms import (
compute_form_data,
expand_derivatives,
expand_indices,
extract_arguments,
Expand Down Expand Up @@ -182,3 +185,32 @@ def test_adjoint(domain):
d = adjoint(b)
d_arg_degrees = [arg.ufl_element().embedded_superdegree for arg in extract_arguments(d)]
assert d_arg_degrees == [2, 1]


def test_remove_component_tensors(domain):
x = SpatialCoordinate(domain)
u = sin(x[0])

f = div(grad(div(grad(u))))
form = f * dx

fd = compute_form_data(form)

assert "ComponentTensor" not in repr(fd.preprocessed_form)


def test_grad_cellwise_constant(domain):
element = FiniteElement("Lagrange", triangle, 3, (), identity_pullback, H1)
space = FunctionSpace(domain, element)
u = Coefficient(space)

# Applying four derivatives to a cubic should simplify to zero
f = div(grad(div(grad(u))))
form = f * dx

fd = compute_form_data(
form,
do_apply_function_pullbacks=True,
)
assert fd.preprocessed_form.empty()
assert fd.num_coefficients == 0
4 changes: 2 additions & 2 deletions test/test_apply_function_pullbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,8 @@ def test_apply_single_function_pullbacks_triangle3d():
vc: as_vector(Jinv[j, i] * rvc[j], i),
t: rt,
s: as_tensor([[rs[0], rs[1], rs[2]], [rs[1], rs[3], rs[4]], [rs[2], rs[4], rs[5]]]),
cov2t: as_tensor(Jinv[k, i] * rcov2t[k, l] * Jinv[l, j], (i, j)),
contra2t: as_tensor((1.0 / detJ) ** 2 * J[i, k] * rcontra2t[k, l] * J[j, l], (i, j)),
cov2t: as_tensor(Jinv[k, i] * (rcov2t[k, l] * Jinv[l, j]), (i, j)),
contra2t: (1.0 / detJ) ** 2 * as_tensor(J[i, k] * (rcontra2t[k, l] * J[j, l]), (i, j)),
# Mixed elements become a bit more complicated
uml2: as_vector([ruml2[0] / detJ, ruml2[1] / detJ]),
um: rum,
Expand Down
4 changes: 2 additions & 2 deletions test/test_derivative.py
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,7 @@ def test_vector_coefficient_scalar_derivatives(self):
integrand = inner(f, g)

i0, i1, i2, i3, i4 = [Index(count=c) for c in range(5)]
expected = as_tensor(df[i1] * dv, (i1,))[i0] * g[i0]
expected = as_tensor(df[i1], (i1,))[i0] * dv * g[i0]

F = integrand * dx
J = derivative(F, u, dv, cd)
Expand Down Expand Up @@ -693,7 +693,7 @@ def test_vector_coefficient_derivatives(self):
integrand = inner(f, g)

i0, i1, i2, i3, i4 = [Index(count=c) for c in range(5)]
expected = as_tensor(df[i2, i1] * dv[i1], (i2,))[i0] * g[i0]
expected = as_tensor(df[i2, i1], (i2,))[i0] * dv[i1] * g[i0]

F = integrand * dx
J = derivative(F, u, dv, cd)
Expand Down
6 changes: 6 additions & 0 deletions ufl/algebra.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from ufl.core.operator import Operator
from ufl.core.ufl_type import ufl_type
from ufl.index_combination_utils import merge_unique_indices
from ufl.indexed import Indexed
from ufl.precedence import parstr
from ufl.sorting import sorted_expr

Expand Down Expand Up @@ -89,6 +90,11 @@ def __init__(self, a, b):
"""Initialise."""
Operator.__init__(self)

def _simplify_indexed(self, multiindex):
"""Return a simplified Expr used in the constructor of Indexed(self, multiindex)."""
a, b = self.ufl_operands
return Sum(Indexed(a, multiindex), Indexed(b, multiindex))

def evaluate(self, x, mapping, component, index_values):
"""Evaluate."""
return sum(o.evaluate(x, mapping, component, index_values) for o in self.ufl_operands)
Expand Down
17 changes: 13 additions & 4 deletions ufl/algorithms/apply_derivatives.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from ufl.action import Action
from ufl.algorithms.analysis import extract_arguments
from ufl.algorithms.estimate_degrees import SumDegreeEstimator
from ufl.algorithms.map_integrands import map_integrand_dags
from ufl.algorithms.replace_derivative_nodes import replace_derivative_nodes
from ufl.argument import BaseArgument
Expand Down Expand Up @@ -562,6 +563,14 @@ def __init__(self, geometric_dimension):
"""Initialise."""
GenericDerivativeRuleset.__init__(self, var_shape=(geometric_dimension,))
self._Id = Identity(geometric_dimension)
self.degree_estimator = SumDegreeEstimator(1, {})

def is_cellwise_constant(self, o):
"""More precise checks for cellwise constants."""
if is_cellwise_constant(o):
return True
degree = map_expr_dag(self.degree_estimator, o)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You've introduced here that is_cellwise_constant logic is based on degree estimation, which is the only place in the code. How does it work, since the degree estimation for geometric quantities calls is_cellwise_constant itself?

def geometric_quantity(self, v):
"""Apply to geometric_quantity.
Some geometric quantities are cellwise constant. Others are
nonpolynomial and thus hard to estimate.
"""
if is_cellwise_constant(v):
return 0
else:
# As a heuristic, just returning domain degree to bump up degree somewhat
return extract_unique_domain(v).ufl_coordinate_element().embedded_superdegree

If there is no infinite loop, could this be used in the generic cell-wise constant check? Or is it that only GradRuleset does not cause infinite loop?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I first tried to add this logic to the generic is_cellwise_constant in #334, but simplifying grad(cell_wise_constant) on instantiation turns out to be problematic for nested grad expression simply because grad(f) expects f to have a domain and Zero does not have a domain, so grad(grad(grad(linear))) would simplify to grad(Zero).

return degree == 0

# --- Specialized rules for geometric quantities

Expand All @@ -572,7 +581,7 @@ def geometric_quantity(self, o):
otherwise transform derivatives to reference derivatives.
Override for specific types if other behaviour is needed.
"""
if is_cellwise_constant(o):
if self.is_cellwise_constant(o):
return self.independent_terminal(o)
else:
domain = extract_unique_domain(o)
Expand All @@ -583,7 +592,7 @@ def geometric_quantity(self, o):
def jacobian_inverse(self, o):
"""Differentiate a jacobian_inverse."""
# grad(K) == K_ji rgrad(K)_rj
if is_cellwise_constant(o):
if self.is_cellwise_constant(o):
return self.independent_terminal(o)
if not o._ufl_is_terminal_:
raise ValueError("ReferenceValue can only wrap a terminal")
Expand Down Expand Up @@ -653,9 +662,10 @@ def reference_value(self, o):

def reference_grad(self, o):
"""Differentiate a reference_grad."""
if self.is_cellwise_constant(o):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this correct? Looks like one derivative is lost.

Copy link
Contributor Author

@pbrubeck pbrubeck Feb 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we are simplifying grad(o) where o = grad(f), if o is constant then we should return zero with the correct shape.

Copy link
Contributor

@michalhabera michalhabera Feb 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, thanks. I tried following simple UFL code

import ufl
import ufl.algorithms
import ufl.algorithms.apply_algebra_lowering
import ufl.algorithms.apply_function_pullbacks
import ufl.utils.formatting


mesh_el = ufl.finiteelement.FiniteElement("P", ufl.Cell("triangle"), 1, (2, ), ufl.pullback.IdentityPullback(), ufl.sobolevspace.H1)
mesh = ufl.Mesh(mesh_el)

V_el = ufl.finiteelement.FiniteElement("JM", ufl.Cell("triangle"), 1, (2, 2), ufl.pullback.DoubleCovariantPiola(), ufl.sobolevspace.L2)
V = ufl.FunctionSpace(mesh, V_el)
u = ufl.Coefficient(V)
x = ufl.SpatialCoordinate(mesh)
a = ufl.div(u) 

a = ufl.algorithms.apply_algebra_lowering.apply_algebra_lowering(a)
a = ufl.algorithms.apply_derivatives.apply_derivatives(a)
a = ufl.algorithms.apply_function_pullbacks.apply_function_pullbacks(a)
a = ufl.algorithms.apply_derivatives.apply_derivatives(a)

print(ufl.utils.formatting.tree_format(a))

which mimics the order in compute_form_data. Yet, I do not see the ReferenceGrad applied to JacobianInverse in the tree. Geometric quantities makes it correctly outside of the ReferenceGrad. For non-affine cell ReferenceGrad(JacobianInverse) is present as expected. Do you have UFL snippet that shows the problem?

But lets assume that such node will exist and remain unsimplified even if it shouldn't. Would ReferenceGradruleset.jacobian_inverse be more appropriate place for improved constness check?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is always confusing is the order of preprocessing operations in compute_form_data. So my understanding is that for such expressions as div(u) where u is (some) Piola mapped you we essentially do

  1. apply algebra lowering, div(u) -> sum(grad(u)[i]),
  2. 1st derivate pass, grad(u) -> grad(u),
  3. function pullbacks, grad(u) -> grad(piola_maps*reference_value(u)),
  4. 2nd derivative pass, grad(piola_maps*u) -> piola_maps*reference_grad(reference_value(u)),
  5. 3rd derivative pass, piola_maps*reference_grad(reference_value(u)) -> piola_maps*reference_grad(reference_value(u))

In the code above I am seeing GradRuleset.jacobian_inverse hit constness of JacobianInverse, that makes it in step 4.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Firedrake we are getting lots of grad(grad(x[j])). This could be because we do not include JacobianInverse in preserve_geometry_types https://github.com/firedrakeproject/firedrake/blob/96501362c73e17e28131294ccc7ee611b9b64e64/tsfc/ufl_utils.py#L38

return self.independent_terminal(o)
# grad(o) == grad(rgrad(rv(f))) -> K_ji*rgrad(rgrad(rv(f)))_rj
f = o.ufl_operands[0]

valid_operand = f._ufl_is_in_reference_frame_ or isinstance(
f, (JacobianInverse, SpatialCoordinate, Jacobian, JacobianDeterminant)
)
Expand All @@ -676,7 +686,6 @@ def grad(self, o):
# Check that o is a "differential terminal"
if not isinstance(o.ufl_operands[0], (Grad, Terminal)):
raise ValueError("Expecting only grads applied to a terminal.")

return Grad(o)

def _grad(self, o):
Expand Down
4 changes: 4 additions & 0 deletions ufl/algorithms/compute_form_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from ufl.algorithms.formdata import FormData
from ufl.algorithms.formtransformations import compute_form_arities
from ufl.algorithms.remove_complex_nodes import remove_complex_nodes
from ufl.algorithms.remove_component_tensors import remove_component_tensors
from ufl.classes import Coefficient, Form, FunctionSpace, GeometricFacetQuantity
from ufl.corealg.traversal import traverse_unique_terminals
from ufl.domain import extract_unique_domain
Expand Down Expand Up @@ -328,6 +329,9 @@ def compute_form_data(

form = apply_coordinate_derivatives(form)

# Remove component tensors
form = remove_component_tensors(form)

# Propagate restrictions to terminals
if do_apply_restrictions:
form = apply_restrictions(form, apply_default=do_apply_default_restrictions)
Expand Down
117 changes: 117 additions & 0 deletions ufl/algorithms/remove_component_tensors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Remove component tensors.

This module contains classes and functions to remove component tensors.
"""
# Copyright (C) 2008-2016 Martin Sandve Alnæs
#
# This file is part of UFL (https://www.fenicsproject.org)
#
# SPDX-License-Identifier: LGPL-3.0-or-later

from ufl.classes import ComponentTensor, Form, Index, MultiIndex, Zero
from ufl.corealg.map_dag import map_expr_dag
from ufl.corealg.multifunction import MultiFunction, memoized_handler


class IndexReplacer(MultiFunction):
"""Replace Indices."""

def __init__(self, fimap: dict):
"""Initialise.

Args:
fimap: map for index replacements.

"""
MultiFunction.__init__(self)
self.fimap = fimap
self._object_cache = {}

expr = MultiFunction.reuse_if_untouched

@memoized_handler
def zero(self, o):
"""Handle Zero."""
free_indices = []
index_dimensions = []
for i, d in zip(o.ufl_free_indices, o.ufl_index_dimensions):
k = Index(i)
j = self.fimap.get(k, k)
if isinstance(j, Index):
free_indices.append(j.count())
index_dimensions.append(d)
return Zero(
shape=o.ufl_shape,
free_indices=tuple(free_indices),
index_dimensions=tuple(index_dimensions),
)

@memoized_handler
def multi_index(self, o):
"""Handle MultiIndex."""
return MultiIndex(tuple(self.fimap.get(i, i) for i in o.indices()))


class IndexRemover(MultiFunction):
"""Remove Indexed."""

def __init__(self):
"""Initialise."""
MultiFunction.__init__(self)
self._object_cache = {}

expr = MultiFunction.reuse_if_untouched

@memoized_handler
def _unary_operator(self, o):
"""Simplify UnaryOperator(Zero)."""
(operand,) = o.ufl_operands
f = map_expr_dag(self, operand)
if isinstance(f, Zero):
return Zero(
shape=o.ufl_shape,
free_indices=o.ufl_free_indices,
index_dimensions=o.ufl_index_dimensions,
)
if f is operand:
# Reuse if untouched
return o
return o._ufl_expr_reconstruct_(f)

@memoized_handler
def indexed(self, o):
"""Simplify Indexed."""
o1, i1 = o.ufl_operands
if isinstance(o1, ComponentTensor):
# Simplify Indexed ComponentTensor
o2, i2 = o1.ufl_operands
# Replace inner indices first
v = map_expr_dag(self, o2)
# Replace outer indices
assert len(i2) == len(i1)
fimap = dict(zip(i2, i1))
rule = IndexReplacer(fimap)
return map_expr_dag(rule, v)

expr = map_expr_dag(self, o1)
if expr is o1:
# Reuse if untouched
return o
return o._ufl_expr_reconstruct_(expr, i1)

reference_grad = _unary_operator
reference_value = _unary_operator


def remove_component_tensors(o):
"""Remove component tensors."""
if isinstance(o, Form):
integrals = []
for integral in o.integrals():
integrand = remove_component_tensors(integral.integrand())
if not isinstance(integrand, Zero):
integrals.append(integral.reconstruct(integrand=integrand))
return o._ufl_expr_reconstruct_(integrals)
else:
rule = IndexRemover()
return map_expr_dag(rule, o)
13 changes: 5 additions & 8 deletions ufl/exproperators.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,20 +406,17 @@ def _getitem(self, component):
mi = MultiIndex(all_indices)
a = Indexed(self, mi)

# TODO: I think applying as_tensor after index sums results in
# cleaner expression graphs.

# If the Ellipsis or any slices were found, wrap as tensor valued
# with the slice indices created at the top here
if slice_indices:
a = as_tensor(a, slice_indices)

# If any repeated indices were found, apply implicit summation
# over those
for i in repeated_indices:
mi = MultiIndex((i,))
a = IndexSum(a, mi)

# If the Ellipsis or any slices were found, wrap as tensor valued
# with the slice indices created at the top here
if slice_indices:
a = as_tensor(a, slice_indices)

# Check for zero (last so we can get indices etc from a, could
# possibly be done faster by checking early instead)
if isinstance(self, Zero):
Expand Down
Loading
Loading