Skip to content

Commit 1dcae14

Browse files
committed
api: Add Derivative halo=0 for SubDomain-restricted derivatives and adjoints
`expr.dx(halo=0)` treats `expr` as zero outside the SubDomain of its equation, and `.T` keeps the flag. Hence `Inc(q_bar, out_bar.dx(halo=0).T, subdomain=S)` is the adjoint of `Eq(out, q.dx, subdomain=S)`, i.e. D^T R^T with R the restriction to S, without a user-side zero-padded work field. Equations without halo=0 derivatives, or without a SubDomain, are evaluated as before. - An equation with halo=0 derivatives evaluates its rhs with `_eval_at(lhs, subdomain=S)`. The halo=0 derivatives record S, and multiply each stencil tap by a SubDomainIndicator (SubDomain.indicator) of the grid point it reads: 1 inside S, 0 outside, recording the stencil radius (IndexSet.radius). The other terms of a sum are restricted to S at the evaluation point (SubDomain.restrict); factors are not. - The equation then iterates over S grown by the largest radius (SubDomain.grow, a SubDomain with a parent), and expands the indicators into branch-free integer MIN/MAX, which invariant hoisting computes once. - It works for expanded, unexpanded and staggered derivatives. Derivatives of halo=0 derivatives, MultiSubDomains and methods other than FD raise NotImplementedError. Tests: dot tests of `Eq(out, g.dx, subdomain=S)` against `Inc(g_bar, out_bar.dx(halo=0).T, subdomain=S)` for left/right/middle SubDomains, first and second derivatives, orders 2/4/8, Eq and Inc, 2D and unexpanded forms, in 1D and 3D; staggered dot tests against `-out_bar.dx(halo=0)`, as in elastic adjoints; sums and factors; SubDomain growth; a forward check; and a one-face CPML operator.
1 parent 66418f7 commit 1dcae14

7 files changed

Lines changed: 603 additions & 23 deletions

File tree

‎devito/finite_differences/derivative.py‎

Lines changed: 62 additions & 4 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_halo(self):
492+
return self.halo is not None or getattr(self.expr, '_has_halo', False)
493+
460494
@property
461495
def is_TimeDependent(self):
462496
return self.expr.is_TimeDependent
@@ -481,12 +515,18 @@ 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`.
489525
"""
526+
if subdomain is not None and self.halo is not None:
527+
return self._rebuild(subdomain=subdomain)._eval_at(
528+
func, interp_mode=interp_mode, **kwargs
529+
)
490530
# No staggering, don't waste time
491531
if not self.expr.staggered and not func.staggered:
492532
return self
@@ -581,6 +621,13 @@ def _eval_fd(self, expr, **kwargs):
581621
if expr.is_Add and any(len(indices_at(expr, d)) > 1 for d in self.dims):
582622
return expr.func(*[self._eval_fd(a, **kwargs) for a in expr.args])
583623

624+
# The SubDomain mask read by a halo=0 derivative can't be shifted again
625+
if any(d.halo is not None for d in expr.find(Derivative)):
626+
raise NotImplementedError(
627+
f"{self} differentiates a derivative with halo=0, which is not "
628+
"supported"
629+
)
630+
584631
# Step 1: Evaluate non-derivative x0. We currently enforce a simple 2nd order
585632
# interpolation to avoid very expensive finite differences on top of it
586633
x0_deriv = self._filter_dims(self.x0)
@@ -598,6 +645,15 @@ def _eval_fd(self, expr, **kwargs):
598645
# otherwise an IndexSum will returned
599646
expand = kwargs.get('expand', True)
600647

648+
# With halo=0, `expr` is treated as zero outside the equation's SubDomain
649+
subdomain = self.subdomain
650+
if subdomain is not None and (subdomain.is_MultiSubDomain or
651+
self.method != 'FD'):
652+
raise NotImplementedError(
653+
f"halo=0 is only supported with method='FD' on a SubDomain, not "
654+
f"with method={self.method} on {subdomain}"
655+
)
656+
601657
# Step 3: Evaluate FD of the new expression
602658
if self.method == 'RSFD':
603659
assert len(self.dims) == 1
@@ -607,13 +663,15 @@ def _eval_fd(self, expr, **kwargs):
607663
assert self.method == 'FD'
608664
res = cross_derivative(expr, self.dims, self.fd_order, self.deriv_order,
609665
matvec=self.transpose, x0=x0_deriv, expand=expand,
610-
side=self.side, weights=self.weights)
666+
side=self.side, weights=self.weights,
667+
subdomain=subdomain)
611668
else:
612669
assert self.method == 'FD'
613670
res = generic_derivative(expr, self.dims[0], self.fd_order[0],
614671
self.deriv_order[0], weights=self.weights,
615672
side=self.side, matvec=self.transpose,
616-
x0=self.x0, expand=expand)
673+
x0=self.x0, expand=expand,
674+
subdomain=subdomain)
617675

618676
# Step 4: Apply substitutions
619677
for e in self._ppsubs:

‎devito/finite_differences/differentiable.py‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,37 @@ def coefficients(self):
185185
key = lambda x: coeff_priority.get(x, -1)
186186
return sorted(coefficients, key=key, reverse=True)[0]
187187

188-
def _eval_at(self, func, **kwargs):
189-
return self.func(*[
190-
getattr(a, '_eval_at', lambda x, **kw: a)(func, **kwargs) # noqa: B023
191-
for a in self.args # false positive: lambda is invoked in-place
192-
])
188+
def _eval_at(self, func, subdomain=None, **kwargs):
189+
"""
190+
Evaluate the expression at the location of `func`.
191+
192+
With a `subdomain`, the derivatives with halo=0 treat their argument as
193+
zero outside it, and extend the expression past it. The terms of a sum
194+
without such derivatives are then restricted to `subdomain` at the
195+
evaluation point.
196+
"""
197+
args = []
198+
for a in self.args:
199+
try:
200+
eval_at = a._eval_at
201+
except AttributeError:
202+
args.append(a)
203+
continue
204+
if subdomain is None:
205+
args.append(eval_at(func, **kwargs))
206+
elif getattr(a, '_has_halo', False):
207+
args.append(eval_at(func, subdomain=subdomain, **kwargs))
208+
elif self.is_Add:
209+
args.append(subdomain.restrict(eval_at(func, **kwargs)))
210+
else:
211+
# A factor, e.g. `c` in `c*f.dx(halo=0)`, is not restricted
212+
args.append(eval_at(func, **kwargs))
213+
return self.func(*args)
214+
215+
@cached_property
216+
def _has_halo(self):
217+
"""True if the expression has derivatives with halo=0."""
218+
return any(getattr(a, '_has_halo', False) for a in self.args)
193219

194220
def _subs(self, old, new, **hints):
195221
if old == self:

‎devito/finite_differences/finite_difference.py‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ def index_at(expr, dim):
125125

126126
@check_input
127127
def generic_derivative(expr, dim, fd_order, deriv_order, matvec=direct, x0=None,
128-
coefficients='taylor', expand=True, weights=None, side=None):
128+
coefficients='taylor', expand=True, weights=None, side=None,
129+
subdomain=None):
129130
"""
130131
Arbitrary-order derivative of a given expression.
131132
@@ -151,6 +152,10 @@ def generic_derivative(expr, dim, fd_order, deriv_order, matvec=direct, x0=None,
151152
expand : bool, optional, default=True
152153
If True, the derivative is fully expanded as a sum of products,
153154
otherwise an IndexSum is returned.
155+
subdomain : SubDomain, optional
156+
If given, `expr` is treated as zero outside `subdomain` (see
157+
`Derivative`'s `halo`): each stencil tap is multiplied by the
158+
indicator of `subdomain` at the point it reads.
154159
155160
Returns
156161
-------
@@ -171,7 +176,7 @@ def generic_derivative(expr, dim, fd_order, deriv_order, matvec=direct, x0=None,
171176
coefficients = 'taylor' if dim.is_Time else expr.coefficients
172177

173178
return make_derivative(expr, dim, fd_order, deriv_order, side,
174-
matvec, x0, coefficients, expand, weights)
179+
matvec, x0, coefficients, expand, weights, subdomain)
175180

176181

177182
# Backward compatibility
@@ -180,7 +185,7 @@ def first_derivative(expr, dim, fd_order, **kwargs):
180185

181186

182187
def make_derivative(expr, dim, fd_order, deriv_order, side, matvec, x0, coefficients,
183-
expand, weights=None):
188+
expand, weights=None, subdomain=None):
184189
# Always expand time derivatives to avoid issue with buffering and streaming.
185190
# Time derivative are almost always short stencils and won't benefit from
186191
# unexpansion in the rare case the derivative is not evaluated for time stepping.
@@ -222,13 +227,20 @@ def make_derivative(expr, dim, fd_order, deriv_order, side, matvec, x0, coeffici
222227
if callable(expand):
223228
expand = expand(dim)
224229

230+
# With a `subdomain`, `expr` is treated as zero outside it: every stencil tap
231+
# is multiplied by the indicator of the point it reads, 1 if `subdomain`
232+
# spans the whole of `dim`
233+
225234
if not expand and indices.expr is not None:
226235
weights = Weights(name='w', dimensions=indices.free_dim,
227236
initvalue=weights, dtype=expr.dtype)
228237

229238
# Inject the StencilDimension
230239
# E.g. `x + i*h_x` into `f(x)` s.t. `f(x + i*h_x)`
231240
expr = expr.shift(dim, indices.expr - dim)
241+
if subdomain is not None:
242+
offset = indices.offset(indices.expr)
243+
expr = expr * subdomain.indicator(dim.root, offset, indices.radius)
232244

233245
# Re-evaluate any off-the-grid Functions potentially impacted by the FD
234246
# unless a pure number
@@ -243,6 +255,9 @@ def make_derivative(expr, dim, fd_order, deriv_order, side, matvec, x0, coeffici
243255
for i, c in zip(indices, weights, strict=True):
244256
# The FD term
245257
term = expr.shift(dim, i - dim) * c
258+
if subdomain is not None:
259+
term = term * subdomain.indicator(dim.root, indices.offset(i),
260+
indices.radius)
246261

247262
# Re-evaluate any off-the-grid Functions potentially impacted by the FD
248263
# unless a pure number

‎devito/finite_differences/tools.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from functools import partial, wraps
1+
from functools import cached_property, partial, wraps
22
from itertools import product
33

44
import numpy as np
@@ -177,6 +177,18 @@ def __repr__(self):
177177
def spacing(self):
178178
return self.dim.spacing
179179

180+
def offset(self, index):
181+
"""
182+
Offset, in grid points, of the point `index` from `dim`: e.g. 2 for
183+
`x + 2*h_x`, or `i0` for `x + i0*h_x` in unexpanded form.
184+
"""
185+
return sympify((index - self.dim) / self.spacing)
186+
187+
@cached_property
188+
def radius(self):
189+
"""Largest distance, in grid points, between `dim` and the points."""
190+
return max(abs(self.offset(i)) for i in self)
191+
180192
def transpose(self):
181193
"""
182194
Transpose the IndexSet.

‎devito/types/equation.py‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,18 +125,48 @@ def _evaluate(self, **kwargs):
125125
"""
126126
if self._interp_mode is not None:
127127
kwargs['interp_mode'] = self._interp_mode
128+
129+
subdomain = self.subdomain
130+
restrict = subdomain is not None and getattr(self.rhs, '_has_halo', False)
131+
132+
# Derivatives with halo=0 treat their argument as zero outside the
133+
# SubDomain, and extend the rhs past it
134+
at = {'subdomain': subdomain} if restrict else {}
128135
try:
129136
lhs = self.lhs._evaluate(**kwargs)
130-
rhs = self.rhs._eval_at(self.lhs, **kwargs)._evaluate(**kwargs)
137+
rhs = self.rhs._eval_at(self.lhs, **at, **kwargs)._evaluate(**kwargs)
131138
except AttributeError:
132139
lhs, rhs = self._evaluate_args(**kwargs)
133-
eq = self.func(lhs, rhs, subdomain=self.subdomain,
140+
141+
if restrict:
142+
rhs, subdomain = self._extend(rhs, subdomain)
143+
144+
eq = self.func(lhs, rhs, subdomain=subdomain,
134145
coefficients=self.substitutions,
135146
implicit_dims=self._implicit_dims,
136147
interp_mode=self._interp_mode)
137148

138149
return eq
139150

151+
@staticmethod
152+
def _extend(rhs, subdomain):
153+
"""
154+
The evaluated `rhs`, with its SubDomainIndicators expanded, and the region
155+
over which it is evaluated: `subdomain` grown by the largest radius of the
156+
stencils reading them.
157+
"""
158+
from devito.symbolics import search # noqa
159+
160+
indicators = search(rhs, lambda e: getattr(e, 'is_SubDomainIndicator', False))
161+
162+
radius = {}
163+
for i in indicators:
164+
if i.radius > 0:
165+
radius[i.dim] = max(radius.get(i.dim, 0), i.radius)
166+
region = subdomain.grow(radius) if radius else subdomain
167+
168+
return rhs.xreplace({i: i.branchless for i in indicators}), region
169+
140170
@property
141171
def _flatten(self):
142172
"""

0 commit comments

Comments
 (0)