Skip to content

Commit 5914853

Browse files
authored
Add GPU spill-to-CPU fallback for cost_distance (#863)
* Add GPU spill-to-CPU fallback for cost_distance (CuPy & Dask+CuPy) Match the A* pathfinding pattern: accept CuPy-backed DataArrays by transferring to CPU numpy, running the existing numba kernel, and wrapping the result back as a CuPy array. Update the README feature matrix to reflect 🔄 (CPU fallback) support. * Add GPU test parametrization for cost_distance CuPy/Dask+CuPy fallback Tests verify the CPU-spill paths produce results identical to numpy: - cupy matches numpy on random friction grid - cupy respects max_cost truncation - cupy input returns cupy-backed result - dask+cupy matches numpy on multi-source grid
1 parent 260656c commit 5914853

File tree

3 files changed

+147
-8
lines changed

3 files changed

+147
-8
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ In the GIS world, rasters are used for representing continuous phenomena (e.g. e
194194
| Name | Description | NumPy xr.DataArray | Dask xr.DataArray | CuPy GPU xr.DataArray | Dask GPU xr.DataArray |
195195
|:----------:|:------------|:----------------------:|:--------------------:|:-------------------:|:------:|
196196
| [Allocation](xrspatial/proximity.py) | Assigns each cell to the identity of the nearest source feature | ✅️ || | |
197-
| [Cost Distance](xrspatial/cost_distance.py) | Computes minimum accumulated cost to the nearest source through a friction surface | ✅️ || | |
197+
| [Cost Distance](xrspatial/cost_distance.py) | Computes minimum accumulated cost to the nearest source through a friction surface | ✅️ || 🔄 | 🔄 |
198198
| [Direction](xrspatial/proximity.py) | Computes the direction from each cell to the nearest source feature | ✅️ || | |
199199
| [Proximity](xrspatial/proximity.py) | Computes the distance from each cell to the nearest source feature | ✅️ || | |
200200

xrspatial/cost_distance.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@
3838
except ImportError:
3939
da = None
4040

41-
from xrspatial.utils import get_dataarray_resolution, ngjit
41+
from xrspatial.utils import (
42+
get_dataarray_resolution, ngjit,
43+
has_cuda_and_cupy, is_cupy_array, is_dask_cupy,
44+
)
4245
from xrspatial.dataset_support import supports_dataset
4346

4447
# ---------------------------------------------------------------------------
@@ -391,15 +394,50 @@ def cost_distance(
391394
source_data = raster.data
392395
friction_data = friction.data
393396

394-
if da is not None and isinstance(source_data, da.Array):
397+
_is_dask = da is not None and isinstance(source_data, da.Array)
398+
_is_cupy_backend = (
399+
not _is_dask
400+
and has_cuda_and_cupy()
401+
and is_cupy_array(source_data)
402+
)
403+
_is_dask_cupy = _is_dask and has_cuda_and_cupy() and is_dask_cupy(raster)
404+
405+
if _is_dask:
395406
# Rechunk friction to match raster
396407
if isinstance(friction_data, da.Array):
397408
friction_data = friction_data.rechunk(source_data.chunks)
398409
else:
399410
friction_data = da.from_array(friction_data,
400411
chunks=source_data.chunks)
401412

402-
if isinstance(source_data, np.ndarray):
413+
if _is_cupy_backend:
414+
import cupy
415+
# Transfer to CPU, run numpy kernel, transfer back
416+
source_np = source_data.get()
417+
friction_np = np.asarray(friction.data.get(), dtype=np.float64)
418+
result_data = cupy.asarray(
419+
_cost_distance_numpy(
420+
source_np, friction_np,
421+
cellsize_x, cellsize_y, max_cost_f,
422+
target_values, dy, dx, dd,
423+
)
424+
)
425+
elif _is_dask_cupy:
426+
# Spill each chunk to CPU via map_overlap, then wrap back as dask
427+
source_data = source_data.map_blocks(
428+
lambda b: b.get(), dtype=source_data.dtype,
429+
meta=np.array((), dtype=source_data.dtype),
430+
)
431+
friction_data = friction_data.map_blocks(
432+
lambda b: b.get(), dtype=friction_data.dtype,
433+
meta=np.array((), dtype=friction_data.dtype),
434+
)
435+
result_data = _cost_distance_dask(
436+
source_data, friction_data,
437+
cellsize_x, cellsize_y, max_cost_f,
438+
target_values, dy, dx, dd,
439+
)
440+
elif isinstance(source_data, np.ndarray):
403441
if isinstance(friction_data, np.ndarray):
404442
result_data = _cost_distance_numpy(
405443
source_data, friction_data,
@@ -408,7 +446,7 @@ def cost_distance(
408446
)
409447
else:
410448
raise TypeError("friction must be numpy-backed when raster is")
411-
elif da is not None and isinstance(source_data, da.Array):
449+
elif _is_dask:
412450
result_data = _cost_distance_dask(
413451
source_data, friction_data,
414452
cellsize_x, cellsize_y, max_cost_f,

xrspatial/tests/test_cost_distance.py

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
import xarray as xr
1111

1212
from xrspatial.cost_distance import cost_distance
13+
from xrspatial.tests.general_checks import cuda_and_cupy_available
14+
from xrspatial.utils import has_cuda_and_cupy, has_dask_array
1315

1416

1517
def _make_raster(data, backend='numpy', chunks=(3, 3)):
16-
"""Build a DataArray with y/x coords, optionally dask-backed."""
18+
"""Build a DataArray with y/x coords, optionally dask/cupy-backed."""
1719
h, w = data.shape
1820
raster = xr.DataArray(
1921
data.astype(np.float64),
@@ -24,13 +26,24 @@ def _make_raster(data, backend='numpy', chunks=(3, 3)):
2426
raster['x'] = np.arange(w, dtype=np.float64)
2527
if 'dask' in backend and da is not None:
2628
raster.data = da.from_array(raster.data, chunks=chunks)
29+
if 'cupy' in backend and has_cuda_and_cupy():
30+
import cupy
31+
if isinstance(raster.data, da.Array):
32+
raster.data = raster.data.map_blocks(cupy.asarray)
33+
else:
34+
raster.data = cupy.asarray(raster.data)
2735
return raster
2836

2937

3038
def _compute(arr):
31-
"""Extract numpy data from DataArray (works for numpy or dask)."""
39+
"""Extract numpy data from DataArray (works for numpy, dask, or cupy)."""
3240
if da is not None and isinstance(arr.data, da.Array):
33-
return arr.values
41+
val = arr.data.compute()
42+
if hasattr(val, 'get'):
43+
return val.get()
44+
return val
45+
if hasattr(arr.data, 'get'):
46+
return arr.data.get()
3447
return arr.data
3548

3649

@@ -400,3 +413,91 @@ def test_source_on_impassable_cell(backend):
400413

401414
# Everything should be NaN — the only source is on impassable terrain
402415
assert np.all(np.isnan(out))
416+
417+
418+
# -----------------------------------------------------------------------
419+
# CuPy GPU spill-to-CPU tests
420+
# -----------------------------------------------------------------------
421+
422+
@cuda_and_cupy_available
423+
def test_cupy_matches_numpy():
424+
"""CuPy (CPU fallback) path should produce identical results to numpy."""
425+
np.random.seed(42)
426+
source = np.zeros((7, 7))
427+
source[3, 3] = 1.0
428+
429+
friction_data = np.random.uniform(0.5, 5.0, (7, 7))
430+
431+
result_np = _compute(cost_distance(
432+
_make_raster(source, backend='numpy'),
433+
_make_raster(friction_data, backend='numpy'),
434+
))
435+
result_cupy = _compute(cost_distance(
436+
_make_raster(source, backend='cupy'),
437+
_make_raster(friction_data, backend='cupy'),
438+
))
439+
440+
np.testing.assert_allclose(result_cupy, result_np, equal_nan=True, atol=1e-5)
441+
442+
443+
@cuda_and_cupy_available
444+
def test_cupy_max_cost():
445+
"""CuPy path respects max_cost truncation."""
446+
source = np.zeros((1, 10))
447+
source[0, 0] = 1.0
448+
friction_data = np.ones((1, 10))
449+
450+
result = _compute(cost_distance(
451+
_make_raster(source, backend='cupy'),
452+
_make_raster(friction_data, backend='cupy'),
453+
max_cost=3.5,
454+
))
455+
456+
np.testing.assert_allclose(result[0, 3], 3.0, atol=1e-5)
457+
assert np.isnan(result[0, 4])
458+
459+
460+
@cuda_and_cupy_available
461+
def test_cupy_returns_cupy_array():
462+
"""Result should be CuPy-backed when input is CuPy-backed."""
463+
import cupy
464+
from xrspatial.utils import is_cupy_array
465+
466+
source = np.zeros((3, 3))
467+
source[1, 1] = 1.0
468+
friction_data = np.ones((3, 3))
469+
470+
result = cost_distance(
471+
_make_raster(source, backend='cupy'),
472+
_make_raster(friction_data, backend='cupy'),
473+
)
474+
assert is_cupy_array(result.data)
475+
476+
477+
# -----------------------------------------------------------------------
478+
# Dask + CuPy GPU spill-to-CPU tests
479+
# -----------------------------------------------------------------------
480+
481+
@cuda_and_cupy_available
482+
@pytest.mark.skipif(not has_dask_array(), reason="Requires dask.Array")
483+
def test_dask_cupy_matches_numpy():
484+
"""Dask+CuPy (CPU fallback) should produce identical results to numpy."""
485+
np.random.seed(42)
486+
source = np.zeros((10, 12))
487+
source[2, 3] = 1.0
488+
source[7, 9] = 2.0
489+
490+
friction_data = np.random.uniform(0.5, 5.0, (10, 12))
491+
492+
result_np = _compute(cost_distance(
493+
_make_raster(source, backend='numpy'),
494+
_make_raster(friction_data, backend='numpy'),
495+
max_cost=20.0,
496+
))
497+
result_dc = _compute(cost_distance(
498+
_make_raster(source, backend='dask+cupy', chunks=(5, 6)),
499+
_make_raster(friction_data, backend='dask+cupy', chunks=(5, 6)),
500+
max_cost=20.0,
501+
))
502+
503+
np.testing.assert_allclose(result_dc, result_np, equal_nan=True, atol=1e-5)

0 commit comments

Comments
 (0)