Skip to content

Commit f22febc

Browse files
authored
Merge pull request #3035 from devitocodes/fix-mixed-subdomain-derivative
api: Fix mixed subdomain derivative
2 parents 8192c0f + 026764d commit f22febc

9 files changed

Lines changed: 891 additions & 47 deletions

File tree

‎devito/finite_differences/derivative.py‎

Lines changed: 96 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def _fd_priority(self):
102102

103103
__rargs__ = ('expr', '*dims')
104104
__rkwargs__ = ('side', 'deriv_order', 'fd_order', 'transpose', '_ppsubs',
105-
'x0', 'method', 'weights')
105+
'x0', 'method', 'weights', 'halo', 'subdomain')
106106

107107
def __new__(cls, expr, *dims, **kwargs):
108108
# Validate the input arguments `expr`, `dims` and `deriv_order`
@@ -161,6 +161,8 @@ def __new__(cls, expr, *dims, **kwargs):
161161
obj._transpose = kwargs.get("transpose", direct)
162162
obj._method = kwargs.get("method", 'FD')
163163
obj._weights = cls._process_weights(**kwargs)
164+
obj._halo = cls._validate_halo(kwargs.get("halo"))
165+
obj._subdomain = kwargs.get("subdomain")
164166

165167
ppsubs = kwargs.get("subs", kwargs.get("_ppsubs", []))
166168
processed = []
@@ -177,6 +179,16 @@ def __new__(cls, expr, *dims, **kwargs):
177179

178180
return obj
179181

182+
@staticmethod
183+
def _validate_halo(halo):
184+
"""
185+
Validate `halo`. Only None (read the argument everywhere) and 0 (treat
186+
the argument as zero outside the equation's SubDomain) are supported.
187+
"""
188+
if halo not in (None, 0):
189+
raise ValueError(f"Expected halo=None or halo=0, got halo={halo}")
190+
return halo
191+
180192
@staticmethod
181193
def _validate_expr(expr):
182194
"""
@@ -325,6 +337,8 @@ def _process_weights(cls, **kwargs):
325337
def __call__(self, x0=None, fd_order=None, side=None, method=None, **kwargs):
326338
weights = kwargs.get('weights', kwargs.get('w'))
327339
rkw = {}
340+
if 'halo' in kwargs:
341+
rkw['halo'] = kwargs['halo']
328342
if side is not None:
329343
rkw['side'] = side
330344
if method is not None:
@@ -457,6 +471,26 @@ def side(self):
457471
def transpose(self):
458472
return self._transpose
459473

474+
@property
475+
def halo(self):
476+
"""
477+
None if the argument is read everywhere, 0 if it is treated as zero
478+
outside the SubDomain of the equation the Derivative belongs to.
479+
"""
480+
return self._halo
481+
482+
@property
483+
def subdomain(self):
484+
"""
485+
With halo=0, the SubDomain outside of which the argument is treated as
486+
zero, set upon evaluation within an equation restricted to it.
487+
"""
488+
return self._subdomain
489+
490+
@cached_property
491+
def _has_zero_halo(self):
492+
return self.halo is not None or self.expr._has_zero_halo
493+
460494
@property
461495
def is_TimeDependent(self):
462496
return self.expr.is_TimeDependent
@@ -481,26 +515,21 @@ def T(self):
481515

482516
return self._rebuild(transpose=adjoint)
483517

484-
def _eval_at(self, func, interp_mode='direct', **kwargs):
518+
def _eval_at(self, func, interp_mode='direct', subdomain=None, **kwargs):
485519
"""
486520
Evaluates the derivative at the location of `func`. It is necessary for staggered
487521
setup where one could have Eq(u(x + h_x/2), v(x).dx)) in which case v(x).dx
488522
has to be computed at x=x + h_x/2.
523+
524+
With halo=0, the argument is treated as zero outside `subdomain`, which
525+
the Derivative records.
489526
"""
490-
# No staggering, don't waste time
491-
if not self.expr.staggered and not func.staggered:
492-
return self
493-
# If an x0 already exists or evaluating at the same function (i.e u = u.dx)
494-
# do not overwrite it
495-
if self.x0 or self.side is not None or func.function is self.expr.function:
496-
return self
497-
# For basic equation of the form f = Derivative(g, ...) we can just
498-
# compare staggering
499-
if self.expr.staggered == func.staggered and self.expr.is_Function:
500-
return self
501-
# Time derivatives are not affected by space staggering
502-
if all(d.is_Time for d in self.dims):
503-
return self
527+
rkw = {}
528+
if subdomain is not None and self.halo is not None:
529+
rkw['subdomain'] = subdomain
530+
531+
if not self._is_relocated(func):
532+
return self._rebuild(**rkw) if rkw else self
504533

505534
# Check if x0's keys come from a DerivedDimension
506535
x0 = func.indices_ref.getters
@@ -519,7 +548,7 @@ def _eval_at(self, func, interp_mode='direct', **kwargs):
519548
# e.g f.dx(x0={x: x + h_x/2}).subs({x: ix})
520549
psubs[sd] = d
521550
nx0[sd] = nx0.pop(d)._subs(d, sd)
522-
rkw = {'x0': nx0}
551+
rkw['x0'] = nx0
523552
if psubs:
524553
rkw['subs'] = (psubs,)
525554

@@ -534,7 +563,8 @@ def _eval_at(self, func, interp_mode='direct', **kwargs):
534563
return self._rebuild(self.expr, **rkw)
535564
args = [self.expr.func(*v) for v in mapper.values()]
536565
args.extend([a for a in self.expr.args if a not in self.expr._args_diff])
537-
args = [self._rebuild(a)._eval_at(func, interp_mode=interp_mode, **kwargs)
566+
args = [self._rebuild(a)._eval_at(func, interp_mode=interp_mode,
567+
subdomain=subdomain, **kwargs)
538568
for a in args]
539569
return self.expr.func(*args)
540570
elif self.expr.is_Mul:
@@ -549,6 +579,25 @@ def _eval_at(self, func, interp_mode='direct', **kwargs):
549579
# the expression as is.
550580
return self._rebuild(self.expr, **rkw)
551581

582+
def _is_relocated(self, func):
583+
"""
584+
True if the Derivative must be evaluated at the location of `func`, e.g.
585+
with `func` and the argument staggered apart.
586+
"""
587+
# No staggering, don't waste time
588+
if not self.expr.staggered and not func.staggered:
589+
return False
590+
# If an x0 already exists or evaluating at the same function (i.e u = u.dx)
591+
# do not overwrite it
592+
if self.x0 or self.side is not None or func.function is self.expr.function:
593+
return False
594+
# For basic equation of the form f = Derivative(g, ...) we can just
595+
# compare staggering
596+
if self.expr.staggered == func.staggered and self.expr.is_Function:
597+
return False
598+
# Time derivatives are not affected by space staggering
599+
return not all(d.is_Time for d in self.dims)
600+
552601
def _evaluate(self, **kwargs):
553602
# Evaluate finite-difference.
554603
# NOTE: `evaluate` and `_eval_fd` split for potential future different
@@ -581,6 +630,13 @@ def _eval_fd(self, expr, **kwargs):
581630
if expr.is_Add and any(len(indices_at(expr, d)) > 1 for d in self.dims):
582631
return expr.func(*[self._eval_fd(a, **kwargs) for a in expr.args])
583632

633+
# The SubDomain mask read by a halo=0 derivative can't be shifted again
634+
if expr._has_zero_halo:
635+
raise NotImplementedError(
636+
f"{self} differentiates a derivative with halo=0, which is not "
637+
"supported"
638+
)
639+
584640
# Step 1: Evaluate non-derivative x0. We currently enforce a simple 2nd order
585641
# interpolation to avoid very expensive finite differences on top of it
586642
x0_deriv = self._filter_dims(self.x0)
@@ -598,6 +654,15 @@ def _eval_fd(self, expr, **kwargs):
598654
# otherwise an IndexSum will returned
599655
expand = kwargs.get('expand', True)
600656

657+
# With halo=0, `expr` is treated as zero outside the equation's SubDomain
658+
subdomain = self.subdomain
659+
if subdomain is not None and (subdomain.is_MultiSubDomain or
660+
self.method != 'FD'):
661+
raise NotImplementedError(
662+
f"halo=0 is only supported with method='FD' on a SubDomain, not "
663+
f"with method={self.method} on {subdomain}"
664+
)
665+
601666
# Step 3: Evaluate FD of the new expression
602667
if self.method == 'RSFD':
603668
assert len(self.dims) == 1
@@ -607,18 +672,29 @@ def _eval_fd(self, expr, **kwargs):
607672
assert self.method == 'FD'
608673
res = cross_derivative(expr, self.dims, self.fd_order, self.deriv_order,
609674
matvec=self.transpose, x0=x0_deriv, expand=expand,
610-
side=self.side, weights=self.weights)
675+
side=self.side, weights=self.weights,
676+
subdomain=subdomain)
611677
else:
612678
assert self.method == 'FD'
613679
res = generic_derivative(expr, self.dims[0], self.fd_order[0],
614680
self.deriv_order[0], weights=self.weights,
615681
side=self.side, matvec=self.transpose,
616-
x0=self.x0, expand=expand)
682+
x0=self.x0, expand=expand,
683+
subdomain=subdomain)
617684

618685
# Step 4: Apply substitutions
619686
for e in self._ppsubs:
620687
res = res.xreplace(e)
621688

689+
# With `halo=0`, the stencil taps above are zeroed outside the SubDomain
690+
# along `self.dims` only. Along any other Dimension, the argument is read
691+
# at the evaluation point, which other `halo=0` derivatives may push
692+
# outside the SubDomain. E.g., in `f.dx(halo=0) + f.dy(halo=0)`, the
693+
# equation iterates over the SubDomain grown along both `x` and `y`, so
694+
# `f.dx` must also be zeroed where `y` lies outside the SubDomain
695+
if subdomain is not None:
696+
res = subdomain._restrict(res, exclude={d.root for d in self.dims})
697+
622698
return res
623699

624700
def _eval_expand_nest(self, **hints):

‎devito/finite_differences/differentiable.py‎

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,26 @@ def _eval_at(self, func, **kwargs):
191191
for a in self.args # false positive: lambda is invoked in-place
192192
])
193193

194+
@cached_property
195+
def _has_zero_halo(self):
196+
"""True if the expression has derivatives with `halo=0`."""
197+
return any(a._has_zero_halo for a in self._args_diff)
198+
199+
@cached_property
200+
def _growth(self):
201+
"""
202+
Number of points, per root Dimension, by which the evaluated expression
203+
extends past the SubDomain that its `halo=0` derivatives restrict their
204+
argument to: the largest stencil radius of such derivatives. Empty if the
205+
expression has none.
206+
207+
For example, with `S` a SubDomain restricting `x` and an 8th-order `g`,
208+
`g.dx(halo=0)` evaluated in an equation on `S` has a growth of `{x: 4}`:
209+
the equation must iterate over `S` grown by 4 points along `x` (see
210+
`DerivedSubDomain`).
211+
"""
212+
return merge_growth(a._growth for a in self._args_diff)
213+
194214
def _subs(self, old, new, **hints):
195215
if old == self:
196216
return new
@@ -329,8 +349,18 @@ def shift(self, dim, shift):
329349
"""
330350
Shift expression by `shift` along the Dimension `dim`.
331351
For example u.shift(x, x.spacing) = u(x + h_x).
352+
353+
Every other space Dimension of the expression sharing the root of `dim`
354+
is shifted too: an expression may mix Functions defined on the Grid,
355+
e.g. `f(x)`, with Functions defined on a SubDomain, e.g. `p(ix)`.
332356
"""
333-
return self._subs(dim, dim + shift)
357+
from devito.symbolics import retrieve_dimensions # noqa
358+
359+
expr = self
360+
for d in retrieve_dimensions(self, mode='unique'):
361+
if d is not dim and dim.root in d._defines and not d.is_NonlinearDerived:
362+
expr = expr._subs(d, d + shift)
363+
return expr._subs(dim, dim + shift)
334364

335365
@property
336366
def laplace(self):
@@ -530,6 +560,18 @@ def highest_priority(diff_op, candidates=None):
530560
return prio_func
531561

532562

563+
def merge_growth(growths):
564+
"""
565+
Merge the growths `growths`, each a mapping from root Dimension to a number
566+
of points, keeping the largest per Dimension (see `Differentiable._growth`).
567+
"""
568+
growth = {}
569+
for i in growths:
570+
for d, v in i.items():
571+
growth[d] = max(growth.get(d, 0), v)
572+
return frozendict(growth)
573+
574+
533575
class DifferentiableOp(Differentiable):
534576

535577
__sympy_class__ = None
@@ -630,6 +672,19 @@ def __new__(cls, *args, **kwargs):
630672

631673
return super().__new__(cls, *args, **kwargs)
632674

675+
def _eval_at(self, func, subdomain=None, **kwargs):
676+
"""
677+
Evaluate the sum at the location of `func`.
678+
679+
The derivatives with `halo=0` extend the sum past `subdomain`, so the
680+
other terms are restricted to `subdomain` at the evaluation point.
681+
"""
682+
expr = super()._eval_at(func, subdomain=subdomain, **kwargs)
683+
if subdomain is None or not expr.is_Add or not expr._has_zero_halo:
684+
return expr
685+
halo = {a for a in expr._args_diff if a._has_zero_halo}
686+
return self.func(*[a if a in halo else subdomain._restrict(a) for a in expr.args])
687+
633688

634689
class Mul(DifferentiableOp, sympy.Mul):
635690
__sympy_class__ = sympy.Mul
@@ -1318,6 +1373,30 @@ def _subs(self, old, new, **hints):
13181373

13191374
class DiffDerivative(IndexDerivative, DifferentiableOp):
13201375

1376+
"""
1377+
A Derivative evaluated in unexpanded form.
1378+
1379+
Parameters
1380+
----------
1381+
growth : dict of {Dimension: int}, optional
1382+
For a derivative with `halo=0`, its stencil radius per root Dimension
1383+
(see `Differentiable._growth`).
1384+
*args, **kwargs
1385+
As for IndexDerivative.
1386+
"""
1387+
1388+
__rkwargs__ = IndexDerivative.__rkwargs__ + ('growth',)
1389+
1390+
def __new__(cls, *args, growth=None, **kwargs):
1391+
obj = super().__new__(cls, *args, **kwargs)
1392+
obj._growth = frozendict(growth or {})
1393+
return obj
1394+
1395+
@property
1396+
def growth(self):
1397+
"""For a derivative with `halo=0`, its stencil radius per root Dimension."""
1398+
return self._growth
1399+
13211400
def _eval_at(self, func, **kwargs):
13221401
# Like EvalDerivative, a DiffDerivative must have already been evaluated
13231402
# at a valid x0 and should not be re-evaluated at a different location
@@ -1331,11 +1410,25 @@ def _eval_at(self, func, **kwargs):
13311410

13321411
class EvalDerivative(DifferentiableOp, sympy.Add):
13331412

1413+
"""
1414+
A Derivative evaluated in expanded form, as the sum of its stencil taps.
1415+
1416+
Parameters
1417+
----------
1418+
*args : expr-like
1419+
The stencil taps.
1420+
base : expr-like, optional
1421+
The expression the derivative was taken of.
1422+
growth : dict of {Dimension: int}, optional
1423+
For a derivative with `halo=0`, its stencil radius per root Dimension
1424+
(see `Differentiable._growth`).
1425+
"""
1426+
13341427
is_commutative = True
13351428

1336-
__rkwargs__ = ('base',)
1429+
__rkwargs__ = ('base', 'growth')
13371430

1338-
def __new__(cls, *args, base=None, **kwargs):
1431+
def __new__(cls, *args, base=None, growth=None, **kwargs):
13391432
kwargs['evaluate'] = False
13401433

13411434
# a+0 -> a
@@ -1351,6 +1444,7 @@ def __new__(cls, *args, base=None, **kwargs):
13511444
# In some rare cases (rebuild?) base may be obj itself
13521445
base = base.base
13531446
obj.base = base
1447+
obj._growth = frozendict(growth or {})
13541448
except AttributeError:
13551449
# This might happen if e.g. one attempts a (re)construction with
13561450
# one sole argument. The (re)constructed EvalDerivative degenerates
@@ -1363,6 +1457,11 @@ def __new__(cls, *args, base=None, **kwargs):
13631457

13641458
return obj
13651459

1460+
@property
1461+
def growth(self):
1462+
"""For a derivative with `halo=0`, its stencil radius per root Dimension."""
1463+
return self._growth
1464+
13661465
func = DifferentiableOp._rebuild
13671466

13681467
# Since obj.base = base, then Differentiable.__eq__ leads to infinite recursion

0 commit comments

Comments
 (0)