Skip to content

Commit 50d7818

Browse files
authored
Let the GPU surface-distance relaxation run to convergence (#3732)
* Let the GPU surface-distance relaxation run to convergence (#3721) _surface_distance_cupy capped its parallel relaxation at H + W passes. One pass moves distance information about one pixel hop, so the number of passes needed is the hop count of the longest shortest path. Those two agree on open terrain, but NaN barriers make paths wind, and the Bellman-Ford bound for a grid graph is the pixel count. Hitting the cap exited the loop with no warning and no signal in the output, so pixels the CPU reaches came back NaN, meaning unreachable. On a 16x16 serpentine corridor the GPU found 37 of 136 reachable pixels; at 24x24 it found 53 of 300. The ceiling is now H * W and reaching it warns. The changed-flag check still exits as soon as the solution settles, so open terrain runs the same number of passes as before: 256x256 and 512x512 random elevation both timed within noise of main (0.135 vs 0.136 s, 0.313 vs 0.326 s). Also covers the bounded dask+cupy path, which runs this function per chunk against the chunk's own H and W. * Address review: cover DIRECTION in the long-path test, fix the warning stacklevel (#3721) _surface_distance_cupy runs the same relaxation loop for all three modes, and DIRECTION reads srow/scol, which a truncated loop leaves unset just as it leaves dist at infinity. The long-path test now parametrizes over surface_direction too. The convergence warning used stacklevel=2, which lands on _compute. _surface_distance_dask already uses 4 for the same call depth, so the warning now points at the caller.
1 parent bc4925a commit 50d7818

2 files changed

Lines changed: 61 additions & 1 deletion

File tree

xrspatial/surface_distance.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -611,7 +611,15 @@ def _surface_distance_cupy(source_data, elev_data, cellsize_x, cellsize_y,
611611
changed = cp.zeros(1, dtype=cp.int32)
612612
griddim, blockdim = cuda_args((H, W))
613613

614-
max_iterations = H + W
614+
# One pass moves distance information roughly one pixel hop, so the
615+
# number of passes needed is the hop count of the longest shortest
616+
# path. On open terrain that is about H + W, but NaN barriers make
617+
# paths wind, and the Bellman-Ford bound for a grid graph is the
618+
# pixel count. The `changed` check below exits as soon as the
619+
# solution settles, so the ceiling only costs anything on the
620+
# pathological inputs that need it.
621+
max_iterations = H * W
622+
converged = False
615623
for _ in range(max_iterations):
616624
changed[0] = 0
617625
_sd_relax_kernel[griddim, blockdim](
@@ -621,8 +629,19 @@ def _surface_distance_cupy(source_data, elev_data, cellsize_x, cellsize_y,
621629
np.float64(max_distance),
622630
)
623631
if int(changed[0]) == 0:
632+
converged = True
624633
break
625634

635+
if not converged:
636+
warnings.warn(
637+
f"surface_distance: the GPU relaxation was still improving "
638+
f"distances after {max_iterations} passes on a {H}x{W} raster "
639+
f"and was stopped. Some pixels may be reported as unreachable "
640+
f"when a path exists. Please report this raster upstream.",
641+
UserWarning,
642+
stacklevel=4,
643+
)
644+
626645
# Extract output
627646
if mode == DISTANCE:
628647
out = cp.where(cp.isinf(dist) | (dist > max_distance),

xrspatial/tests/test_surface_distance.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,47 @@ def test_cupy_matches_numpy():
748748
equal_nan=True)
749749

750750

751+
def _serpentine_elevation(n):
752+
"""NaN barriers everywhere except one winding open corridor.
753+
754+
The corridor from (0, 0) is about n*n/2 pixel hops long, far more than
755+
the n+n the GPU relaxation used to allow itself.
756+
"""
757+
elev = np.full((n, n), np.nan, dtype=np.float64)
758+
for r in range(0, n, 2):
759+
elev[r, :] = 0.0
760+
for r in range(1, n, 2):
761+
elev[r, n - 1 if (r // 2) % 2 == 0 else 0] = 0.0
762+
return elev
763+
764+
765+
@pytest.mark.skipif(not has_cuda_and_cupy(), reason="cupy/cuda not available")
766+
@pytest.mark.parametrize(
767+
"mode", [surface_distance, surface_allocation, surface_direction])
768+
def test_cupy_long_path_matches_numpy(mode):
769+
"""CuPy must relax until it converges, not for a fixed H+W (#3721).
770+
771+
A winding corridor needs far more relaxation passes than the raster is
772+
wide plus tall. Stopping early makes reachable pixels come back NaN,
773+
which reads as "no path exists".
774+
"""
775+
n = 16
776+
elev = _serpentine_elevation(n)
777+
source = np.zeros((n, n), dtype=np.float64)
778+
source[0, 0] = 1.0
779+
780+
np_result = _compute(mode(_make_raster(source), _make_raster(elev)))
781+
cp_result = _compute(mode(_make_raster(source, backend='cupy'),
782+
_make_raster(elev, backend='cupy')))
783+
784+
# The corridor is genuinely reachable end to end on the CPU.
785+
assert np.isfinite(np_result[n - 1, 0])
786+
assert int(np.isfinite(np_result).sum()) > 4 * n
787+
788+
np.testing.assert_allclose(cp_result, np_result, rtol=1e-5,
789+
equal_nan=True)
790+
791+
751792
@pytest.mark.skipif(not has_cuda_and_cupy(), reason="cupy/cuda not available")
752793
def test_cupy_returns_cupy_array():
753794
"""CuPy input should produce CuPy output."""

0 commit comments

Comments
 (0)