Skip to content

Commit 2c5ba75

Browse files
author
Gerard Gorman
committed
FD weights: 17-digit literals + canonicalized Taylor weights (fixes #2976)
_PRECISION 9 -> 17: 17 significant digits is the minimum that round-trips an IEEE-754 double, so the C literals parse back to the exact rational weight (sub-ULP) instead of carrying a ~1.7e-10 relative truncation that silently floors fp64 cross-implementation checks. Raising the precision exposed a latent route-inconsistency the 9-digit rendering had been masking: Taylor weights built through a float x0 shift (e.g. div(f, shift=.5)) accumulate ~1e-14 relative float error, so the same stencil built through two routes compared unequal at 17 digits. Taylor-computed weights are now canonicalized (nearby-rational recovery within 5e-14 relative, then evalf) so identical stencils are identical expressions; user-supplied weights are rendered as-is. Tests: test_derivatives now imports the library _PRECISION instead of a local copy; 9-digit string expectations regenerated at 17 digits; new TestCoefficientPrecision regression class (weight-literal fp64 round-trip + evaluated-derivative Float atoms round-trip to their exact rationals).
1 parent 0aa772b commit 2c5ba75

2 files changed

Lines changed: 94 additions & 18 deletions

File tree

devito/finite_differences/finite_difference.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from collections.abc import Iterable
22
from contextlib import suppress
33

4+
import sympy
45
from sympy import sympify
56

67
from devito.logger import warning
@@ -22,9 +23,38 @@
2223
'transpose',
2324
]
2425

25-
# Number of digits for FD coefficients to avoid roundup errors and non-deterministic
26-
# code generation
27-
_PRECISION = 9
26+
# Number of digits for FD coefficients. 17 significant digits is the minimum that
27+
# round-trips an IEEE-754 double exactly, so the C literals the printer emits parse
28+
# back to the exact rational weight (sub-ULP); evalf is deterministic, so code
29+
# generation stays deterministic. (The previous value, 9, truncated the weights at
30+
# ~1.7e-10 relative -- see issue #2976.)
31+
_PRECISION = 17
32+
33+
34+
def _canonicalize_weight(w):
35+
"""Render a weight's numeric atoms as canonical fixed-precision Floats.
36+
37+
Taylor weights are mathematically rational, but depending on the route a
38+
stencil is built through (e.g. a float ``x0`` shift) they can arrive as
39+
binary-float approximations that differ in the last ULP between routes,
40+
which would make otherwise-identical stencils compare unequal. Recover the
41+
nearby rational when one exists within 5e-14 relative (float-route Fornberg
42+
recursions accumulate ~1e-14 relative error; an arbitrary float far from a
43+
small rational maps to a rational of the same double value, so
44+
non-rational user-supplied coefficients are value-preserved), then render
45+
at ``_PRECISION`` so the emitted C literal round-trips the intended double
46+
exactly.
47+
"""
48+
def _canon(a):
49+
try:
50+
r = sympy.Rational(a).limit_denominator(10**12)
51+
except (TypeError, ValueError, ZeroDivisionError):
52+
return a
53+
if a == 0 or abs(float(r) - float(a)) <= 5e-14 * abs(float(a)):
54+
return r
55+
return a
56+
57+
return w.replace(lambda a: a.is_Float, _canon).evalf(_PRECISION)
2858

2959

3060
@check_input
@@ -168,23 +198,32 @@ def make_derivative(expr, dim, fd_order, deriv_order, side, matvec, x0, coeffici
168198
x0=x0, nweights=nweights)
169199
# Finite difference weights corresponding to the indices. Computed via the
170200
# `coefficients` method (`taylor` or `symbolic`)
201+
computed_weights = False
171202
if weights is None:
172203
weights = fd_weights_registry[coefficients](expr, deriv_order, indices, x0)
173204
_, wdim, _ = process_weights(weights, expr, dim)
205+
computed_weights = coefficients == 'taylor'
174206
elif isinstance(weights, Iterable) and len(weights) != len(indices):
175207
warning(f"Number of weights ({len(weights)}) does not match "
176208
f"number of indices ({len(indices)}), reverting to Taylor")
177209
scale = False
178210
wdim = None
179211
weights = fd_weights_registry['taylor'](expr, deriv_order, indices, x0)
212+
computed_weights = True
180213

181214
# Did fd_weights_registry return a new Function/Expression instead of a values?
182215
if wdim is not None:
183216
weights = [weights._subs(wdim, i) for i in range(len(indices))]
184217

185-
# Enforce fixed precision FD coefficients to avoid variations in results
218+
# Enforce fixed precision FD coefficients to avoid variations in results.
219+
# Taylor-computed weights are additionally canonicalized (rational
220+
# recovery) so route-dependent float error cannot make identical stencils
221+
# compare unequal; user-supplied weights are rendered as-is.
186222
scale = dim.spacing**(-deriv_order) if scale else 1
187-
weights = [sympify(scale * w).evalf(_PRECISION) for w in weights]
223+
if computed_weights:
224+
weights = [_canonicalize_weight(sympify(scale * w)) for w in weights]
225+
else:
226+
weights = [sympify(scale * w).evalf(_PRECISION) for w in weights]
188227

189228
# Transpose the FD, if necessary
190229
if matvec == transpose:

tests/test_derivatives.py

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import numpy as np
22
import pytest
3+
import sympy
34
from sympy import Float, Symbol, diff, simplify, sympify
45

56
from conftest import assert_structure
@@ -16,7 +17,7 @@
1617
from devito.types.dimension import StencilDimension
1718
from devito.warnings import DevitoWarning
1819

19-
_PRECISION = 9
20+
from devito.finite_differences.finite_difference import _PRECISION # noqa
2021

2122

2223
def x(grid):
@@ -423,15 +424,15 @@ def test_fd_new_side(self):
423424

424425
@pytest.mark.parametrize('so, expected', [
425426
(2, 'u(x)/h_x - u(x - h_x)/h_x'),
426-
(4, '1.125*u(x)/h_x + 0.0416666667*u(x - 2*h_x)/h_x - '
427-
'1.125*u(x - h_x)/h_x - 0.0416666667*u(x + h_x)/h_x'),
427+
(4, '1.125*u(x)/h_x + 0.041666666666666667*u(x - 2*h_x)/h_x - '
428+
'1.125*u(x - h_x)/h_x - 0.041666666666666667*u(x + h_x)/h_x'),
428429
(6, '1.171875*u(x)/h_x - 0.0046875*u(x - 3*h_x)/h_x + '
429-
'0.0651041667*u(x - 2*h_x)/h_x - 1.171875*u(x - h_x)/h_x - '
430-
'0.0651041667*u(x + h_x)/h_x + 0.0046875*u(x + 2*h_x)/h_x'),
431-
(8, '1.19628906*u(x)/h_x + 0.000697544643*u(x - 4*h_x)/h_x - '
432-
'0.0095703125*u(x - 3*h_x)/h_x + 0.0797526042*u(x - 2*h_x)/h_x - '
433-
'1.19628906*u(x - h_x)/h_x - 0.0797526042*u(x + h_x)/h_x + '
434-
'0.0095703125*u(x + 2*h_x)/h_x - 0.000697544643*u(x + 3*h_x)/h_x')])
430+
'0.065104166666666667*u(x - 2*h_x)/h_x - 1.171875*u(x - h_x)/h_x - '
431+
'0.065104166666666667*u(x + h_x)/h_x + 0.0046875*u(x + 2*h_x)/h_x'),
432+
(8, '1.1962890625*u(x)/h_x + 0.00069754464285714286*u(x - 4*h_x)/h_x - '
433+
'0.0095703125*u(x - 3*h_x)/h_x + 0.079752604166666667*u(x - 2*h_x)/h_x - '
434+
'1.1962890625*u(x - h_x)/h_x - 0.079752604166666667*u(x + h_x)/h_x + '
435+
'0.0095703125*u(x + 2*h_x)/h_x - 0.00069754464285714286*u(x + 3*h_x)/h_x')])
435436
def test_fd_new_x0(self, so, expected):
436437
grid = Grid((10,))
437438
x = grid.dimensions[0]
@@ -560,10 +561,10 @@ def test_transpose_simple(self):
560561

561562
f = TimeFunction(name='f', grid=grid, space_order=4)
562563

563-
assert str(f.dx.T.evaluate) == ("-0.0833333333*f(t, x - 2*h_x, y)/h_x "
564-
"+ 0.666666667*f(t, x - h_x, y)/h_x "
565-
"- 0.666666667*f(t, x + h_x, y)/h_x "
566-
"+ 0.0833333333*f(t, x + 2*h_x, y)/h_x")
564+
assert str(f.dx.T.evaluate) == ("-0.083333333333333333*f(t, x - 2*h_x, y)/h_x "
565+
"+ 0.66666666666666667*f(t, x - h_x, y)/h_x "
566+
"- 0.66666666666666667*f(t, x + h_x, y)/h_x "
567+
"+ 0.083333333333333333*f(t, x + 2*h_x, y)/h_x")
567568

568569
@pytest.mark.parametrize('so', [2, 4, 8, 12])
569570
@pytest.mark.parametrize('ndim', [1, 2])
@@ -1461,3 +1462,39 @@ def test_unevaluated(self):
14611462
assert Derivative(self.x, self.t)
14621463
assert Derivative(self.x, self.y, self.t)
14631464
assert Derivative(self.x, (self.x, 0))
1465+
1466+
1467+
class TestCoefficientPrecision:
1468+
1469+
"""
1470+
_PRECISION must round-trip every FD weight to its exact rational value as an
1471+
IEEE-754 double: the C literal the printer emits is parsed by the compiler to
1472+
the nearest double, so >= 17 significant digits makes the truncation sub-ULP.
1473+
With the previous _PRECISION = 9 the order-8 weight 8/315 was emitted as
1474+
2.53968254e-2 -- a ~1.7e-10 relative coefficient error that silently floored
1475+
fp64 cross-implementation checks (issue #2976).
1476+
"""
1477+
1478+
@pytest.mark.parametrize('p, q', [
1479+
(-205, 72), (8, 5), (-1, 5), (8, 315), (-1, 560), # 8th-order d2 weights
1480+
(-49, 18), (3, 2), (-3, 20), (1, 90), # 6th-order d2 weights
1481+
(9, 8), (-1, 24), # 4th-order staggered d1
1482+
])
1483+
def test_fd_weight_literal_roundtrips_fp64(self, p, q):
1484+
w = sympy.Rational(p, q)
1485+
emitted = float(sympy.sympify(w).evalf(_PRECISION))
1486+
exact = p / q
1487+
assert emitted == exact, (
1488+
f"{p}/{q}: evalf(_PRECISION={_PRECISION}) -> {emitted!r} != {exact!r} "
1489+
f"(rel err {abs(emitted - exact) / abs(exact):.3e})")
1490+
1491+
def test_evaluated_derivative_floats_roundtrip_fp64(self):
1492+
grid = Grid(shape=(11, 11))
1493+
u = Function(name='u_prec', grid=grid, space_order=8)
1494+
floats = u.dx2.evaluate.atoms(sympy.Float)
1495+
assert floats, "expected Float weights in the evaluated derivative"
1496+
for f in floats:
1497+
r = sympy.nsimplify(f, rational=True, tolerance=1e-8, full=True)
1498+
assert float(f) == float(r), (
1499+
f"weight {f} does not round-trip to its rational {r} "
1500+
f"(rel err {abs(float(f) - float(r)) / abs(float(r)):.3e})")

0 commit comments

Comments
 (0)