Repository navigation
Description
Summary
The GDS chunked GPU read path (_read_geotiff_gpu_chunked_gds in xrspatial/geotiff/_backends/gpu.py) declares the dask graph's dtype as float64 whenever the source file declares an integer nodata sentinel that round-trips through the source dtype, but the per-chunk task does not actually cast its returned cupy array to that declared dtype.
The result is a silent declared vs actual dtype mismatch:
dask_arr.dtypeisfloat64(matches the CPU dask path).- Each computed chunk comes back as the source
uint16/int16/ etc.
This is the same class of bug #1597 fixed for the CPU dask path — declared float64 but chunks return raw integer — and the same fix pattern (cast each chunk to declared_dtype before returning) applies here.
Backend-divergence table for uint16 file + nodata=9999, no sentinel pixel hits the array
| backend | declared dtype | computed dtype |
|---|---|---|
numpy (open_geotiff) |
uint16 | uint16 |
cupy (read_geotiff_gpu) |
uint16 | uint16 |
dask+numpy (read_geotiff_dask(chunks=...)) |
float64 | float64 |
dask+cupy (read_geotiff_gpu(chunks=...)) |
float64 | uint16 |
The CPU dask path is allowed to always-promote-to-float64 (the trade-off documented in #1597); declaring float64 and returning uint16 from chunks is the actual bug.
Reproducer
import tempfile, numpy as np
from xrspatial.geotiff import to_geotiff, read_geotiff_gpu
arr = np.arange(40, dtype=np.uint16).reshape(5, 8) + 100 # no 9999 pixel
with tempfile.NamedTemporaryFile(suffix='.tif', delete=False) as f:
tmp = f.name
to_geotiff(arr, tmp, compression='none', nodata=9999)
rdg = read_geotiff_gpu(tmp, chunks=4)
print('graph dtype:', rdg.data.dtype) # float64
print('computed dtype:', rdg.data.compute().dtype) # uint16 <-- mismatchThe probe runs through the GDS path whenever kvikio is importable (the source qualifier only does importlib.util.find_spec, not a runtime probe), which is the common case on RAPIDS installs.
Root cause
_chunk_task in _read_geotiff_gpu_chunked_gds at xrspatial/geotiff/_backends/gpu.py:1035-1053:
@dask.delayed
def _chunk_task(meta, r0, c0, r1, c1):
...
if nodata is not None:
arr = _apply_nodata_mask_gpu(arr, nodata)
if dtype is not None:
target = np.dtype(dtype)
_validate_dtype_cast(np.dtype(str(arr.dtype)), target)
arr = arr.astype(target)
...
return arr_apply_nodata_mask_gpu only promotes to float64 when an actual sentinel pixel hits, mirroring the eager GPU semantics. The declared graph dtype (lines 1057-1062) always promotes whenever an in-range integer sentinel is declared, mirroring the CPU dask path. With no chunk-side cast to declared_dtype, a graph that promises float64 emits uint16 buffers from any chunk whose pixels did not hit the sentinel.
The CPU dask path (_backends/dask.py:_delayed_read_window) already threads target_dtype and casts at the end of each chunk task (the fix for #1597); the GDS path needs the same treatment.
Fix
Thread declared_dtype into _chunk_task and cast arr to it before returning, gated on arr.dtype != declared_dtype to skip the no-op cast (mirroring the #1624 optimisation in the CPU path).
Detection
Caught by the deep-sweep metadata propagation audit on 2026-05-15. Cat 4 (dtype/nodata semantics) and Cat 5 (backend-inconsistent metadata).
Activity
geotiff: drive _read_geotiff_gpu_chunked_gds directly in #1909 tests
geotiff: gds chunked gpu chunk task casts to declared dtype (#1909) (#…
geotiff tests: consolidate GPU reader cluster (Sub-PR A of #2438) (#2471