Skip to content

geotiff: gds chunked gpu chunk task casts to declared dtype (#1909)#1918

Merged
brendancol merged 2 commits into
mainfrom
deep-sweep-metadata-geotiff-2026-05-15
May 15, 2026
Merged

geotiff: gds chunked gpu chunk task casts to declared dtype (#1909)#1918
brendancol merged 2 commits into
mainfrom
deep-sweep-metadata-geotiff-2026-05-15

Conversation

@brendancol
Copy link
Copy Markdown
Contributor

Summary

Fixes #1909. The GDS chunked GPU read path (_read_geotiff_gpu_chunked_gds in xrspatial/geotiff/_backends/gpu.py) declared the dask graph dtype as float64 whenever the source had an in-range integer nodata sentinel, matching the CPU dask path's always-promote contract from #1597. The per-chunk _chunk_task ran the eager-style _apply_nodata_mask_gpu which only promotes when a sentinel pixel actually hits, so chunks with no sentinel hit returned the raw source dtype while the graph advertised float64 -- a silent declared/actual dtype mismatch.

Reproducer (pre-fix)

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(rdg.data.dtype)             # float64
print(rdg.data.compute().dtype)   # uint16  <-- silent mismatch

Fix

Move the declared_dtype computation above _chunk_task so the closure can capture it, then cast arr to declared_dtype before returning. The cast is gated on arr.dtype != declared_dtype to skip the no-op astype(copy=True) allocation (mirrors the #1624 fix on the CPU dask path).

Backend parity matrix (post-fix)

uint16 file, nodata=9999 declared, no sentinel pixel hits:

backend declared computed
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 float64

The eager paths only promote when a sentinel pixel hits (preserves source dtype for memory-budgeted users); the dask paths always promote so chunk concatenation cannot silently downcast (#1597 contract). Both contracts are valid; the bug was the dask+cupy graph declaring one contract and the chunks returning the other.

Test plan

  • 6 new regression tests in xrspatial/geotiff/tests/test_chunked_gpu_declared_dtype_1909.py:
    • declared vs computed parity
    • CPU/GPU dask declared-dtype agreement
    • eager paths preserve source dtype
    • no-nodata round-trip stays at source dtype
    • explicit dtype= kwarg threads through
    • sentinel-hit float64 promotion still NaN-masks correctly
  • Existing test_gds_chunked_gpu_parity_1896.py and test_dask_cupy_combined.py pass (11 tests).
  • Broad geotiff suite (xrspatial/geotiff/tests/): 2875 passed, 7 skipped. The pre-existing failures in test_predictor2_big_endian_gpu_1517.py (AttributeError: module 'xrspatial.geotiff' has no attribute 'read_to_array' after the geotiff: split __init__.py into per-backend modules with shared validation #1813 modular refactor renamed it to _read_to_array) and test_size_param_validation_gpu_vrt_1776.py (tile_size=4 rejected by stricter _validate_tile_size_arg) exist on main and are unrelated to this fix.

Detection

Caught by the deep-sweep metadata propagation audit on 2026-05-15. Cat 4 (dtype/nodata semantics) and Cat 5 (backend-inconsistent metadata).

The GDS chunked GPU read path declared the dask graph dtype as float64
whenever the source had an in-range integer nodata sentinel, matching
the CPU dask path's always-promote contract from #1597. The per-chunk
_chunk_task ran the eager-style _apply_nodata_mask_gpu which only
promotes when a sentinel pixel actually hits, so chunks with no
sentinel hit returned the raw source dtype while the graph advertised
float64 -- a silent declared/actual dtype mismatch.

The fix moves the declared_dtype computation above _chunk_task so the
closure can capture it, then casts arr to declared_dtype before
returning. The cast is gated on arr.dtype != declared_dtype to skip
the no-op astype(copy=True) allocation (mirrors the #1624 fix on the
CPU dask path).

6 regression tests in test_chunked_gpu_declared_dtype_1909.py cover
declared vs computed parity, CPU/GPU dask declared-dtype agreement,
eager paths preserving source dtype, no-nodata round-trip, explicit
dtype= kwarg, and sentinel-hit float64 promotion.

Caught by the deep-sweep metadata propagation audit on 2026-05-15.
Copilot AI review requested due to automatic review settings May 15, 2026 13:27
@github-actions github-actions Bot added the performance PR touches performance-sensitive code label May 15, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a declared-vs-computed dtype mismatch in the GeoTIFF dask+cupy GDS chunked read path, ensuring that each computed chunk is cast to the same dtype that the dask graph advertises (notably for integer rasters with an in-range nodata sentinel).

Changes:

  • Compute declared_dtype before defining the GDS _chunk_task, and cast each chunk result to declared_dtype (skipping no-op casts) in xrspatial/geotiff/_backends/gpu.py.
  • Add regression tests intended to cover declared vs computed dtype parity and related backend behaviors.
  • Update the .claude sweep/audit state log to record the new finding and fix.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
xrspatial/geotiff/_backends/gpu.py Ensures GDS chunk tasks cast outputs to the dask graph’s declared dtype to avoid silent dtype mismatches.
xrspatial/geotiff/tests/test_chunked_gpu_declared_dtype_1909.py Adds regression tests around chunked GPU dtype declaration/computation parity and nodata/dtype interactions.
.claude/sweep-metadata-state.csv Updates audit metadata to reflect the #1909 finding and verification notes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

to_geotiff,
)

cupy = pytest.importorskip("cupy")
Comment on lines +48 to +52
def test_chunked_gpu_declared_dtype_matches_computed(uint16_no_sentinel_path):
"""Declared dask graph dtype must equal the computed chunk dtype."""
da = read_geotiff_gpu(str(uint16_no_sentinel_path), chunks=4)
declared = da.data.dtype
computed = da.data.compute().dtype
assert da.data.dtype == np.float64
computed = da.data.compute()
assert computed.dtype == np.float64
assert np.isnan(computed[0, 0])
Address copilot review on #1918:
- Use _gpu_only gate (cupy + cupy.cuda.is_available) instead of bare
  importorskip so the suite skips on hosts without CUDA, matching
  test_gds_chunked_gpu_parity_1896.py.
- Call _read_geotiff_gpu_chunked_gds directly with a tiled fixture
  so the GDS chunked path is exercised regardless of kvikio
  availability. read_geotiff_gpu(chunks=...) only enters the GDS
  path when _gds_chunk_path_available qualifies; the previous
  fixture used a stripped file, so the test asserted a property
  that already held on the CPU-dask + upload fallback.
- Use computed.get() before np.isnan so the host-side NumPy check
  is unambiguous on cupy buffers.
@brendancol brendancol merged commit b176169 into main May 15, 2026
10 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance PR touches performance-sensitive code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

geotiff: read_geotiff_gpu(chunks=...) GDS path declares float64 but returns source dtype when nodata sentinel is declared

2 participants