Skip to content

Commit cc63a20

Browse files
committed
read_geotiff_gpu: warn on CPU fallback, add gpu='strict' (#1516)
The GPU decode path was wrapped in a too-broad ``try/except Exception: pass`` that silently fell back to CPU on any error. Real GPU bugs (#1508 was an AttributeError) lived undetected because the user-visible result stayed correct. Behaviour now: - Default ``gpu='auto'`` keeps the CPU fallback but emits a ``RuntimeWarning`` that includes the original exception type and message. Stacklevel=2 so the warning points at the caller. - New ``gpu='strict'`` re-raises the original exception instead of falling back. Useful for tests and CI that exercise the GPU fast path. - Unknown ``gpu=`` values raise ``ValueError`` early. Catch is still ``Exception`` rather than a narrower set: CUDA failures arrive as many distinct types (``cuda.RuntimeError``, ``CudaAPIError``, ``CUDARuntimeError``, plain ``RuntimeError``, ...). ``BaseException`` is intentionally not caught so KeyboardInterrupt and SystemExit propagate. Test ``xrspatial/geotiff/tests/test_gpu_strict_fallback_1516.py`` monkeypatches ``gpu_decode_tiles_from_file`` to raise a synthetic ``RuntimeError`` and asserts the warn-and-fallback and strict-reraise paths. Tests do not need a real GPU; a ``cupy`` shim is installed when the real package is unavailable. Closes #1516
1 parent dc194e5 commit cc63a20

2 files changed

Lines changed: 185 additions & 3 deletions

File tree

‎xrspatial/geotiff/__init__.py‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
"""
1414
from __future__ import annotations
1515

16+
import warnings
17+
1618
import numpy as np
1719
import xarray as xr
1820

@@ -1399,7 +1401,8 @@ def read_geotiff_gpu(source: str, *,
13991401
overview_level: int | None = None,
14001402
name: str | None = None,
14011403
chunks: int | tuple | None = None,
1402-
max_pixels: int | None = None) -> xr.DataArray:
1404+
max_pixels: int | None = None,
1405+
gpu: str = 'auto') -> xr.DataArray:
14031406
"""Read a GeoTIFF with GPU-accelerated decompression via Numba CUDA.
14041407
14051408
Decompresses all tiles in parallel on the GPU and returns a
@@ -1425,12 +1428,26 @@ def read_geotiff_gpu(source: str, *,
14251428
max_pixels : int or None
14261429
Maximum allowed pixel count (width * height * samples). None
14271430
uses the default (~1 billion).
1431+
gpu : {'auto', 'strict'}, default 'auto'
1432+
Behaviour when the GPU decode path raises an exception.
1433+
1434+
- ``'auto'``: emit a ``RuntimeWarning`` reporting the original
1435+
exception type and message, then fall back to the CPU path
1436+
and transfer the result onto the GPU. This preserves
1437+
backward-compatible behaviour while making GPU regressions
1438+
visible.
1439+
- ``'strict'``: re-raise the original exception so GPU bugs
1440+
surface immediately. Useful in tests and CI for the GPU
1441+
fast path.
14281442
14291443
Returns
14301444
-------
14311445
xr.DataArray
14321446
CuPy-backed DataArray on GPU device.
14331447
"""
1448+
if gpu not in ('auto', 'strict'):
1449+
raise ValueError(
1450+
f"gpu must be 'auto' or 'strict', got {gpu!r}")
14341451
try:
14351452
import cupy
14361453
except ImportError:
@@ -1537,8 +1554,16 @@ def read_geotiff_gpu(source: str, *,
15371554
compression, predictor, file_dtype, samples,
15381555
byte_order=header.byte_order,
15391556
)
1540-
except Exception:
1541-
pass
1557+
except Exception as e:
1558+
if gpu == 'strict':
1559+
raise
1560+
warnings.warn(
1561+
f"read_geotiff_gpu: GPU decode failed "
1562+
f"({type(e).__name__}: {e}); falling back to CPU.",
1563+
RuntimeWarning,
1564+
stacklevel=2,
1565+
)
1566+
arr_gpu = None
15421567

15431568
if arr_gpu is None:
15441569
# Fallback: extract tiles via CPU mmap, then GPU decode
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Regression tests for issue #1516.
2+
3+
``read_geotiff_gpu`` previously wrapped the GPU decode in a too-broad
4+
``try/except Exception: pass`` that silently swallowed any failure and
5+
fell through to the CPU path. Real GPU regressions (#1508 was an
6+
``AttributeError``) lived undetected because the user-visible result
7+
was still numerically correct.
8+
9+
The fix:
10+
11+
1. Default ``gpu='auto'`` still falls back to CPU, but emits a
12+
``RuntimeWarning`` reporting the original exception type and
13+
message so failures are visible.
14+
2. New ``gpu='strict'`` mode re-raises instead of falling back, so
15+
tests and CI for the GPU fast path see real errors.
16+
17+
These tests monkeypatch ``gpu_decode_tiles_from_file`` to raise a
18+
synthetic exception. They do not require a real GPU because we stub
19+
``cupy`` at the ``sys.modules`` level when it is not already
20+
available; ``cupy.asarray`` is only called in the CPU-fallback branch
21+
and is satisfied by a thin numpy-backed shim.
22+
"""
23+
from __future__ import annotations
24+
25+
import importlib
26+
import sys
27+
import types
28+
29+
import numpy as np
30+
import pytest
31+
32+
from .conftest import make_minimal_tiff
33+
34+
35+
def _ensure_cupy_stub():
36+
"""Install a numpy-backed ``cupy`` shim if cupy is unavailable.
37+
38+
The CPU fallback path inside ``read_geotiff_gpu`` calls
39+
``cupy.asarray(arr_cpu)`` to upload the CPU result onto the GPU.
40+
On CPU-only CI we replace cupy with a numpy passthrough so the
41+
function still returns a DataArray we can assert on.
42+
"""
43+
if 'cupy' in sys.modules:
44+
return False # real cupy already imported, leave it alone
45+
try:
46+
import cupy # noqa: F401
47+
return False
48+
except ImportError:
49+
pass
50+
51+
stub = types.ModuleType('cupy')
52+
stub.ndarray = np.ndarray
53+
stub.asarray = np.asarray
54+
55+
cuda_mod = types.ModuleType('cupy.cuda')
56+
57+
def _is_available():
58+
return False
59+
60+
cuda_mod.is_available = _is_available
61+
stub.cuda = cuda_mod
62+
63+
sys.modules['cupy'] = stub
64+
sys.modules['cupy.cuda'] = cuda_mod
65+
return True
66+
67+
68+
@pytest.fixture
69+
def tiled_tiff_path(tmp_path):
70+
"""A small tiled TIFF on disk that exercises the GPU tile path."""
71+
data = np.arange(64, dtype=np.float32).reshape(8, 8)
72+
raw = make_minimal_tiff(
73+
8, 8, np.dtype('float32'),
74+
pixel_data=data,
75+
tiled=True,
76+
tile_size=4,
77+
)
78+
path = tmp_path / "strict_fallback_1516.tif"
79+
path.write_bytes(raw)
80+
return str(path), data
81+
82+
83+
def _patch_gpu_decode_to_raise(monkeypatch, exc):
84+
"""Replace ``gpu_decode_tiles_from_file`` with one that raises ``exc``."""
85+
from xrspatial.geotiff import _gpu_decode
86+
87+
def _boom(*args, **kwargs):
88+
raise exc
89+
90+
monkeypatch.setattr(
91+
_gpu_decode, 'gpu_decode_tiles_from_file', _boom, raising=True,
92+
)
93+
94+
95+
def test_default_mode_warns_on_gpu_failure(tiled_tiff_path, monkeypatch):
96+
"""Default ``gpu='auto'`` warns and falls back to the CPU result."""
97+
inserted_stub = _ensure_cupy_stub()
98+
try:
99+
from xrspatial.geotiff import read_geotiff_gpu
100+
101+
path, expected = tiled_tiff_path
102+
103+
synthetic = RuntimeError("simulated GPU failure")
104+
_patch_gpu_decode_to_raise(monkeypatch, synthetic)
105+
106+
with pytest.warns(RuntimeWarning, match="GPU decode failed"):
107+
result = read_geotiff_gpu(path)
108+
109+
# Fallback returned the CPU-decoded data. Real cupy arrays expose
110+
# ``.get()`` to copy back to host; the numpy stub returns a
111+
# plain ndarray.
112+
out = result.data
113+
if hasattr(out, 'get'):
114+
out = out.get()
115+
np.testing.assert_array_equal(np.asarray(out), expected)
116+
finally:
117+
if inserted_stub:
118+
sys.modules.pop('cupy', None)
119+
sys.modules.pop('cupy.cuda', None)
120+
importlib.invalidate_caches()
121+
122+
123+
def test_strict_mode_reraises(tiled_tiff_path, monkeypatch):
124+
"""``gpu='strict'`` re-raises the original GPU exception."""
125+
inserted_stub = _ensure_cupy_stub()
126+
try:
127+
from xrspatial.geotiff import read_geotiff_gpu
128+
129+
path, _ = tiled_tiff_path
130+
131+
synthetic = RuntimeError("simulated GPU failure")
132+
_patch_gpu_decode_to_raise(monkeypatch, synthetic)
133+
134+
with pytest.raises(RuntimeError, match="simulated GPU failure"):
135+
read_geotiff_gpu(path, gpu='strict')
136+
finally:
137+
if inserted_stub:
138+
sys.modules.pop('cupy', None)
139+
sys.modules.pop('cupy.cuda', None)
140+
importlib.invalidate_caches()
141+
142+
143+
def test_invalid_gpu_kwarg_rejected(tiled_tiff_path):
144+
"""An unknown ``gpu=`` value raises ``ValueError`` with a clear message."""
145+
inserted_stub = _ensure_cupy_stub()
146+
try:
147+
from xrspatial.geotiff import read_geotiff_gpu
148+
149+
path, _ = tiled_tiff_path
150+
151+
with pytest.raises(ValueError, match="gpu must be 'auto' or 'strict'"):
152+
read_geotiff_gpu(path, gpu='loose')
153+
finally:
154+
if inserted_stub:
155+
sys.modules.pop('cupy', None)
156+
sys.modules.pop('cupy.cuda', None)
157+
importlib.invalidate_caches()

0 commit comments

Comments
 (0)