Skip to content

Commit 21afa35

Browse files
committed
api: Precompute sparse gridpoints and interpolation weights in fp64 on host
1 parent 110463b commit 21afa35

8 files changed

Lines changed: 314 additions & 70 deletions

File tree

‎devito/operations/interpolators.py‎

Lines changed: 209 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
from devito.finite_differences.elementary import floor
1616
from devito.logger import warning
1717
from devito.symbolics import INT, retrieve_function_carriers, retrieve_functions
18-
from devito.tools import Pickable, as_tuple, filter_ordered, flatten, memoized_meth
19-
from devito.types import Eq, Evaluable, Inc, SubFunction, Symbol
18+
from devito.tools import (
19+
Pickable, as_fp64_decimal, as_tuple, filter_ordered, flatten, memoized_meth
20+
)
21+
from devito.types import CustomDimension, Eq, Evaluable, Inc, SubFunction, Symbol
2022
from devito.types.utils import DimensionTuple
2123

2224
__all__ = ['LinearInterpolator', 'PrecomputedInterpolator', 'SincInterpolator']
@@ -510,46 +512,220 @@ def _inject(self, field, expr, implicit_dims=None):
510512
return filter_ordered(temps) + eqns
511513

512514

515+
def _shift_tag(shifts):
516+
"""Suffix used to distinguish per-staggering table names ("_s10", ...)."""
517+
if not shifts or not any(shifts):
518+
return ''
519+
return '_s' + ''.join('1' if s else '0' for s in shifts)
520+
521+
522+
def _shift_values(shifts, grid, spacing):
523+
"""Physical half-cell offsets for each grid dim, as fp64."""
524+
if not shifts:
525+
return np.zeros(grid.dim, dtype=np.float64)
526+
subs = {d.spacing: float(h)
527+
for d, h in zip(grid.dimensions, spacing, strict=True)}
528+
return np.array([float(sympy.sympify(s).xreplace(subs)) for s in shifts])
529+
530+
531+
class _HostTable(SubFunction):
532+
"""SubFunction filled on the host from the parent SparseFunction's
533+
scattered coordinates by ``LinearInterpolator._arg_defaults`` (invoked
534+
via ``SparseFunction._arg_defaults``). Gathering is a no-op — these
535+
tables are input-only."""
536+
537+
def _arg_apply(self, *args, **kwargs):
538+
return
539+
540+
def _arg_values(self, estimate_memory=False, **kwargs):
541+
if self._parent.name in kwargs:
542+
return {}
543+
return self._parent._arg_defaults(
544+
alias=self._parent, estimate_memory=estimate_memory
545+
).reduce_all()
546+
547+
def compute(self, coords, grid, spacing, origin): # pragma: no cover
548+
raise NotImplementedError
549+
550+
def _arg_defaults_data(self, coords, grid, spacing, origin):
551+
"""``(name -> data)`` plus this table's dim entries."""
552+
data = self.compute(coords, grid, spacing, origin)
553+
args = {self.name: data}
554+
for d, s in zip(self.dimensions, data.shape, strict=True):
555+
args.update(d._arg_defaults(_min=0, size=s))
556+
return args
557+
558+
559+
class Gridpoints(_HostTable):
560+
"""int32 cell indices per sparse point, shape ``(npoint, ndim)``."""
561+
562+
__rkwargs__ = _HostTable.__rkwargs__ + ('shifts',)
563+
564+
def __init_finalize__(self, *args, **kwargs):
565+
self._shifts = kwargs.pop('shifts', None)
566+
super().__init_finalize__(*args, **kwargs)
567+
568+
@property
569+
def shifts(self):
570+
return self._shifts
571+
572+
def compute(self, coords, grid, spacing, origin):
573+
return _cell_indices(coords, grid, self._shifts, spacing, origin)
574+
575+
576+
class Coeffs(_HostTable):
577+
"""Per-dim `(1 - frac, frac)` interpolation weights, shape ``(npoint, 2)``."""
578+
579+
__rkwargs__ = _HostTable.__rkwargs__ + ('shifts', 'dim_index')
580+
581+
def __init_finalize__(self, *args, **kwargs):
582+
self._shifts = kwargs.pop('shifts', None)
583+
self._dim_index = kwargs.pop('dim_index')
584+
super().__init_finalize__(*args, **kwargs)
585+
586+
@property
587+
def shifts(self):
588+
return self._shifts
589+
590+
@property
591+
def dim_index(self):
592+
return self._dim_index
593+
594+
def compute(self, coords, grid, spacing, origin):
595+
return _linear_weights(coords, grid, self._shifts, self._dim_index,
596+
self.dtype, spacing, origin)
597+
598+
599+
def _resolved_geometry(grid, kwargs):
600+
"""Fp64 (spacing, origin) tuple honoring runtime `h_x`/`o_x`/... overrides."""
601+
spacing = np.array([as_fp64_decimal(kwargs.get(s.name, v)) for s, v
602+
in zip(grid.spacing_symbols, grid.spacing, strict=True)])
603+
origin = np.array([as_fp64_decimal(kwargs.get(o.name, v)) for o, v
604+
in zip(grid.origin_symbols, grid.origin, strict=True)])
605+
return spacing, origin
606+
607+
608+
def _positions_fp64(coords, grid, shifts, spacing, origin):
609+
c64 = np.asarray(coords, dtype=np.float64)
610+
return (c64 - origin - _shift_values(shifts, grid, spacing)) / spacing
611+
612+
613+
def _cell_indices(coords, grid, shifts, spacing, origin):
614+
return np.floor(_positions_fp64(coords, grid, shifts, spacing,
615+
origin)).astype(np.int32)
616+
617+
618+
def _linear_weights(coords, grid, shifts, j, dtype, spacing, origin):
619+
pos = _positions_fp64(coords, grid, shifts, spacing, origin)
620+
frac = pos[:, j] - np.floor(pos[:, j])
621+
data = np.empty((pos.shape[0], 2), dtype=dtype)
622+
data[:, 0] = 1.0 - frac
623+
data[:, 1] = frac
624+
return data
625+
626+
513627
class LinearInterpolator(WeightedInterpolator):
514628
"""
515-
Concrete implementation of WeightedInterpolator implementing a Linear interpolation
516-
scheme, i.e. Bilinear for 2D and Trilinear for 3D problems.
629+
Linear (bilinear/trilinear) interpolator.
517630
518-
Parameters
519-
----------
520-
sfunction: The SparseFunction that this Interpolator operates on.
631+
Gridpoints and per-dim `(1-frac, frac)` weights are precomputed on the
632+
host in fp64 (see `_arg_defaults`) and passed to the kernel as int32/fp
633+
SubFunctions. The generated C only indexes those tables and never sees
634+
`(c-o)/h` or `floor` on fp32.
521635
"""
522636

523637
_name = 'linear'
524638

525-
@memoized_meth
526-
def _weights(self, subdomain=None, shifts=None):
527-
rdim = self._rdim(subdomain=subdomain, shifts=shifts)
528-
c = [(1 - p) * (1 - r) + p * r
529-
for (p, d, r) in zip(self._point_symbols(shifts), self._gdims, rdim,
530-
strict=True)]
531-
return Mul(*c)
639+
def __init__(self, sfunction):
640+
super().__init__(sfunction)
641+
# Per-shift `(gridpoints, coeffs_per_dim)` produced by `_positions` /
642+
# `_weights` during equation construction. Iterated at arg-prep time
643+
# by `_arg_defaults` to populate the host-side tables.
644+
self._tables = {}
532645

533-
@memoized_meth
534-
def _point_symbols(self, shifts=None):
535-
"""Symbol for coordinate value in each Dimension of the point."""
536-
dtype = self.sfunction.coordinates.dtype
537-
symbols = []
538-
for d in self.grid.dimensions:
539-
if shifts and shifts[self.grid.dimensions.index(d)] != 0:
540-
symbols.append(Symbol(name=f'p{d}_s1', dtype=dtype))
541-
else:
542-
symbols.append(Symbol(name=f'p{d}', dtype=dtype))
543-
return DimensionTuple(*symbols, getters=self.grid.dimensions)
646+
@cached_property
647+
def _coeff_dtype(self):
648+
# Weights are real even for complex-valued sparse fields.
649+
dtype = np.dtype(self.sfunction.dtype)
650+
if np.issubdtype(dtype, np.complexfloating):
651+
return np.finfo(dtype).dtype.type
652+
return dtype.type
653+
654+
def _make_tables(self, shifts):
655+
# Names must sort alphabetically *after* the parent SparseFunction:
656+
# the operator's default-arg loop populates the parent (which fills
657+
# these tables via `interpolator._arg_defaults`) before it would fall
658+
# back to each SubFunction's own zero-buffer default. See how the
659+
# sinc `wsinc*` tables rely on the same ordering.
660+
tag = _shift_tag(shifts)
661+
sfname = self.sfunction.name
662+
sfdim = self.sfunction._sparse_dim
663+
gp_name = f'{sfname}_gp{tag}'
664+
ddim = CustomDimension(f'{gp_name}d', 0, self.grid.dim - 1,
665+
self.grid.dim, sfdim)
666+
gp = Gridpoints(name=gp_name, dtype=np.int32,
667+
shape=(self.sfunction.npoint, self.grid.dim),
668+
dimensions=(sfdim, ddim), space_order=0,
669+
alias=self.sfunction.alias,
670+
parent=self.sfunction, shifts=shifts)
671+
coeffs = tuple(
672+
Coeffs(name=f'{sfname}_w{d.name}{tag}',
673+
dtype=self._coeff_dtype,
674+
shape=(self.sfunction.npoint, 2),
675+
dimensions=(sfdim, r), space_order=0,
676+
alias=self.sfunction.alias,
677+
parent=self.sfunction, shifts=shifts, dim_index=i)
678+
for i, (d, r) in enumerate(zip(self._gdims, self._cdim, strict=True))
679+
)
680+
return gp, coeffs
681+
682+
def _gridpoints(self, shifts=None):
683+
key = tuple(shifts) if shifts else None
684+
if key not in self._tables:
685+
self._tables[key] = self._make_tables(shifts)
686+
return self._tables[key][0]
687+
688+
def _coeffs(self, shifts=None):
689+
key = tuple(shifts) if shifts else None
690+
if key not in self._tables:
691+
self._tables[key] = self._make_tables(shifts)
692+
return self._tables[key][1]
693+
694+
def _positions(self, implicit_dims, shifts=None):
695+
gp = self._gridpoints(shifts=shifts)
696+
ddim = gp.dimensions[-1]
697+
return [Eq(p, gp._subs(ddim, di), implicit_dims=implicit_dims)
698+
for (di, p) in enumerate(
699+
self.sfunction._pos_symbols(shifts=shifts))]
544700

545701
def _coeff_temps(self, implicit_dims, shifts=None):
546-
# Positions
547-
pmap = self.sfunction._position_map(shifts=shifts)
548-
psyms = self._point_symbols(shifts)
549-
poseq = [Eq(psyms[d], pos - floor(pos),
550-
implicit_dims=implicit_dims)
551-
for (d, pos) in zip(self._gdims, pmap.keys(), strict=True)]
552-
return poseq
702+
return []
703+
704+
@memoized_meth
705+
def _weights(self, subdomain=None, shifts=None):
706+
rdims = self._rdim(subdomain=subdomain, shifts=shifts)
707+
coeffs = self._coeffs(shifts=shifts)
708+
return Mul(*[
709+
w._subs(rd, rd - rd.parent.symbolic_min)
710+
for (rd, w) in zip(rdims, coeffs, strict=True)
711+
])
712+
713+
def _arg_defaults(self, coords=None, sfunc=None, origin=None):
714+
"""Fill the gridpoints/coeffs tables referenced by the operator from
715+
the already-scattered ``coords`` handed in by
716+
``SparseFunction._arg_defaults``."""
717+
if coords is None or sfunc is None:
718+
raise ValueError("No coordinates or sparse function provided")
719+
grid = sfunc.grid
720+
spacing = np.array([as_fp64_decimal(h) for h in grid.spacing])
721+
origin = np.array([as_fp64_decimal(o)
722+
for o in (origin or grid.origin)])
723+
args = {}
724+
for gp, coeffs in self._tables.values():
725+
args.update(gp._arg_defaults_data(coords, grid, spacing, origin))
726+
for w in coeffs:
727+
args.update(w._arg_defaults_data(coords, grid, spacing, origin))
728+
return args
553729

554730

555731
class PrecomputedInterpolator(WeightedInterpolator):
@@ -634,7 +810,7 @@ def _weights(self, subdomain=None, shifts=None):
634810
for (rd, w) in zip(rdims, self.interpolation_coeffs, strict=True)
635811
])
636812

637-
def _arg_defaults(self, coords=None, sfunc=None):
813+
def _arg_defaults(self, coords=None, sfunc=None, origin=None):
638814
args = {}
639815
b = self._b_table[self.r]
640816
b0 = i0(b)

‎devito/tools/dtypes_lowering.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,17 @@
1616
'dtype_to_cstr', 'dtype_to_ctype', 'infer_datasize', 'dtype_to_mpitype',
1717
'dtype_len', 'ctypes_to_cstr', 'c_restrict_void_p', 'ctypes_vector_mapper',
1818
'is_external_ctype', 'infer_dtype', 'extract_dtype', 'CustomDtype',
19-
'mpi4py_mapper']
19+
'mpi4py_mapper', 'as_fp64_decimal']
20+
21+
22+
def as_fp64_decimal(v):
23+
"""
24+
fp64 value of ``v`` matching its shortest round-tripping decimal.
25+
For an `np.float32` this recovers the decimal the user wrote (e.g.
26+
``np.float32(0.1)`` -> ``0.1`` exact in fp64) rather than the widened
27+
fp32 bit pattern (``0.10000000149...``).
28+
"""
29+
return np.float64(np.format_float_positional(v, unique=True, trim='0'))
2030

2131

2232
# *** Custom np.dtypes

‎devito/types/sparse.py‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -760,8 +760,12 @@ def _arg_values(self, estimate_memory=False, **kwargs):
760760
values = new._arg_defaults(alias=self,
761761
estimate_memory=estimate_memory).reduce_all()
762762
else:
763-
# We've been provided a pure-data replacement (array)
764-
values = {}
763+
# Pure-data replacement (ndarray). Re-derive full defaults so
764+
# any interpolator-owned SubFunctions get rebuilt alongside
765+
# the scattered data.
766+
values = self._arg_defaults(
767+
alias=self, estimate_memory=estimate_memory
768+
).reduce_all()
765769
for k, v in self._dist_scatter(data=new).items():
766770
values[k.name] = v
767771
for i, s in zip(k.indices, v.shape, strict=True):
@@ -995,13 +999,25 @@ def _arg_defaults(self, alias=None, estimate_memory=False):
995999
defaults = super()._arg_defaults(alias=alias, estimate_memory=estimate_memory)
9961000
if estimate_memory:
9971001
return defaults
998-
9991002
key = alias or self
10001003
coords = defaults.get(key.coordinates.name, key.coordinates.data)
10011004
defaults.update(key.interpolator._arg_defaults(coords=coords,
10021005
sfunc=key))
10031006
return defaults
10041007

1008+
def _arg_values(self, estimate_memory=False, **kwargs):
1009+
values = super()._arg_values(estimate_memory=estimate_memory, **kwargs)
1010+
if estimate_memory:
1011+
return values
1012+
onames = [o.name for o in self.grid.origin_symbols]
1013+
origin = tuple(kwargs.get(n, o) for n, o in
1014+
zip(onames, self.grid.origin, strict=True))
1015+
coords = values.get(self.coordinates.name, self.coordinates.data)
1016+
values.update(self.interpolator._arg_defaults(
1017+
coords=coords, sfunc=self, origin=origin
1018+
))
1019+
return values
1020+
10051021

10061022
class SparseTimeFunction(AbstractSparseTimeFunction, SparseFunction):
10071023
"""

‎examples/userapi/06_sparse_operations.ipynb‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -277,12 +277,10 @@
277277
"name": "stdout",
278278
"output_type": "stream",
279279
"text": [
280-
"Eq(posx, (int)floor((-o_x + s_coords(p_s, 0))/h_x))\n",
281-
"Eq(posy, (int)floor((-o_y + s_coords(p_s, 1))/h_y))\n",
282-
"Eq(px, -floor((-o_x + s_coords(p_s, 0))/h_x) + (-o_x + s_coords(p_s, 0))/h_x)\n",
283-
"Eq(py, -floor((-o_y + s_coords(p_s, 1))/h_y) + (-o_y + s_coords(p_s, 1))/h_y)\n",
280+
"Eq(posx, s_gp(p_s, 0))\n",
281+
"Eq(posy, s_gp(p_s, 1))\n",
284282
"Eq(sums, 0.0)\n",
285-
"Inc(sums, (rp_sx*px + (1 - rp_sx)*(1 - px))*(rp_sy*py + (1 - rp_sy)*(1 - py))*f(t, rp_sx + posx, rp_sy + posy))\n",
283+
"Inc(sums, s_wx(p_s, rp_sx)*s_wy(p_s, rp_sy)*f(t, rp_sx + posx, rp_sy + posy))\n",
286284
"Eq(s(time, p_s), sums)\n"
287285
]
288286
}

‎tests/test_dse.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2957,12 +2957,12 @@ def test_fullopt(self):
29572957
bns, _ = assert_blocking(op1, {'x0_blk0'}) # due to loop blocking
29582958

29592959
assert summary0[('section0', None)].ops == 55
2960-
assert summary0[('section1', None)].ops == 44
2960+
assert summary0[('section1', None)].ops == 8
29612961
assert np.isclose(summary0[('section0', None)].oi, 3.136, atol=0.001)
29622962

29632963
assert summary1[('section0', None)].ops == 31
2964-
assert summary1[('section1', None)].ops == 88
2965-
assert summary1[('section2', None)].ops == 25
2964+
assert summary1[('section1', None)].ops == 16
2965+
assert summary1[('section2', None)].ops == 4
29662966
assert np.isclose(summary1[('section0', None)].oi, 1.767, atol=0.001)
29672967

29682968
assert np.allclose(u0.data, u1.data, atol=10e-5)

0 commit comments

Comments
 (0)