Skip to content

Commit 619f5bd

Browse files
authored
Drop the per-cell NaN early-out in the planar CPU slope kernel (#3742)
* Drop the per-cell NaN early-out in the planar CPU slope kernel (#3739) _cpu started every interior cell with `if np.isnan(data[y, x]): continue`. On DEMs with scattered nodata that branch is data-dependent and mispredicts more than the few float32 ops and one arctan it skips. Remove the branch and fold the centre back in with a select after the arithmetic. Neighbour NaN already propagates through the Horn stencil, so the select only has to cover a NaN centre with valid neighbours. Results are bitwise identical to the old kernel on 2000x4000 float32 DEMs with no NaN, 30% random NaN and a NaN left half, and on 3x3, 1xN and int16 inputs. On the 30% random NaN raster the kernel goes from ~92 ms to ~55 ms; the NaN-free and contiguous-NaN cases are within noise. Add a SlopeNaN asv benchmark with 30% random NaN over the interior so the nodata path is timed from now on. get_xr_dataarray(include_nan=True) only sets the [0, 0] corner to NaN, which the kernel never visits, so the benchmark adds its own speckle. Add tests pinning the NaN footprint on a speckled raster: NaN at every centre-NaN cell and its 8-neighbours, finite elsewhere, on numpy and dask+numpy. * Address review notes on the slope NaN early-out PR (#3739) Note in SlopeNaN that its dask timing forces the compute while Slope.time_slope does not, so the two classes' dask numbers are not comparable. Build the speckle mask from a float32 draw to halve the setup allocation at nx=10000. State in the footprint test comment that the tests pass on the old kernel too, since they pin behaviour rather than reproduce a regression. Drop the stale "(unchanged)" from the planar section banner.
1 parent 3851c65 commit 619f5bd

3 files changed

Lines changed: 86 additions & 5 deletions

File tree

‎benchmarks/benchmarks/slope.py‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import numpy as np
2+
13
from xrspatial import slope
24

3-
from .common import Benchmarking
5+
from .common import Benchmarking, get_xr_dataarray
46

57

68
class Slope(Benchmarking):
@@ -9,3 +11,31 @@ def __init__(self):
911

1012
def time_slope(self, nx, type):
1113
return self.time(nx, type)
14+
15+
16+
class SlopeNaN(Benchmarking):
17+
# Speckled nodata: the planar CPU kernel's cost depends on whether the
18+
# NaN pattern is predictable, so time it separately from Slope.
19+
# get_xr_dataarray(include_nan=True) only sets the [0, 0] corner to NaN,
20+
# which is on the border the kernel never visits, so add 30% random NaN
21+
# over the interior on top of it.
22+
params = ([100, 300, 1000, 3000, 10000], ["numpy", "dask"])
23+
param_names = ("nx", "type")
24+
25+
def __init__(self):
26+
super().__init__(func=slope)
27+
28+
def setup(self, nx, type):
29+
ny = nx // 2
30+
agg = get_xr_dataarray((ny, nx), type, include_nan=True)
31+
rng = np.random.default_rng(71942)
32+
speckle = rng.random((ny, nx), dtype=np.float32) < 0.3
33+
self.xr = agg.where(~speckle)
34+
35+
def time_slope_nan(self, nx, type):
36+
# Force the compute so the dask case times the kernel rather than
37+
# graph construction. Slope.time_slope does not, so the dask numbers
38+
# of the two classes are not directly comparable.
39+
result = self.func(self.xr)
40+
if hasattr(result.data, "compute"):
41+
result.data.compute()

‎xrspatial/slope.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def _geodesic_cuda_dims(shape):
4343

4444

4545
# =====================================================================
46-
# Planar backend functions (unchanged)
46+
# Planar backend functions
4747
# =====================================================================
4848

4949
@ngjit
@@ -54,8 +54,11 @@ def _cpu(data, cellsize_x, cellsize_y):
5454
rows, cols = data.shape
5555
for y in range(1, rows - 1):
5656
for x in range(1, cols - 1):
57-
if np.isnan(data[y, x]):
58-
continue
57+
# No early-out on a NaN centre: the branch is unpredictable on
58+
# speckled nodata and costs more than the arithmetic it skips.
59+
# Neighbour NaN already propagates through the stencil; the centre
60+
# is folded back in with a select so out[y, x] is NaN whenever
61+
# data[y, x] is.
5962
a = data[y + 1, x - 1]
6063
b = data[y + 1, x]
6164
c = data[y + 1, x + 1]
@@ -67,7 +70,9 @@ def _cpu(data, cellsize_x, cellsize_y):
6770
dz_dx = ((c + 2 * f + i) - (a + 2 * d + g)) / (8 * cellsize_x)
6871
dz_dy = ((g + 2 * h + i) - (a + 2 * b + c)) / (8 * cellsize_y)
6972
p = (dz_dx * dz_dx + dz_dy * dz_dy) ** .5
70-
out[y, x] = np.arctan(p) * 57.29578
73+
r = np.arctan(p) * 57.29578
74+
ctr = data[y, x]
75+
out[y, x] = r if ctr == ctr else np.nan
7176
return out
7277

7378

‎xrspatial/tests/test_slope.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,3 +625,49 @@ def test_units_overridden_to_degrees_geodesic():
625625
result = slope(raster, method='geodesic')
626626
assert result.attrs['units'] == 'degrees'
627627
assert raster.attrs['units'] == 'm'
628+
629+
630+
# The planar CPU kernel has no per-cell NaN early-out (#3739): neighbour NaN
631+
# flows through the Horn stencil and the centre is masked with a select. Pin
632+
# the resulting footprint on speckled nodata so a future edit can't widen or
633+
# shrink it: NaN at every centre-NaN cell and its 8-neighbours, finite
634+
# everywhere else in the interior. The old kernel produced the same footprint,
635+
# so these pass on either version; they pin behaviour rather than reproduce a
636+
# regression.
637+
def _speckled_nan_data():
638+
rng = np.random.default_rng(3739)
639+
data = rng.normal(0, 2, size=(40, 60))
640+
data[rng.random(data.shape) < 0.1] = np.nan
641+
return data
642+
643+
644+
def _expected_nan_footprint(data):
645+
nan_mask = np.isnan(data)
646+
footprint = np.zeros_like(nan_mask)
647+
for dy in (-1, 0, 1):
648+
for dx in (-1, 0, 1):
649+
footprint[1:-1, 1:-1] |= nan_mask[1 + dy:data.shape[0] - 1 + dy,
650+
1 + dx:data.shape[1] - 1 + dx]
651+
footprint[0, :] = footprint[-1, :] = footprint[:, 0] = footprint[:, -1] = True
652+
return footprint
653+
654+
655+
def test_speckled_nan_footprint_numpy():
656+
data = _speckled_nan_data()
657+
agg = create_test_raster(data, backend='numpy', attrs={'res': (1, 1)})
658+
result = slope(agg)
659+
general_output_checks(agg, result, verify_attrs=False)
660+
np.testing.assert_array_equal(np.isnan(result.data), _expected_nan_footprint(data))
661+
assert np.all(np.isfinite(result.data[~np.isnan(result.data)]))
662+
663+
664+
@dask_array_available
665+
def test_speckled_nan_footprint_dask_numpy():
666+
data = _speckled_nan_data()
667+
numpy_agg = create_test_raster(data, backend='numpy', attrs={'res': (1, 1)})
668+
dask_agg = create_test_raster(data, backend='dask+numpy',
669+
attrs={'res': (1, 1)}, chunks=(16, 24))
670+
assert_numpy_equals_dask_numpy(numpy_agg, dask_agg, slope, nan_edges=False,
671+
verify_attrs=False)
672+
np.testing.assert_array_equal(np.isnan(slope(dask_agg).data.compute()),
673+
_expected_nan_footprint(data))

0 commit comments

Comments
 (0)