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

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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
@brendancol
brendancol deleted the deep-sweep-metadata-geotiff-2026-05-15 branch May 27, 2026 14:50
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