diff --git a/.claude/sweep-performance-state.csv b/.claude/sweep-performance-state.csv index 266f9a8d0..eb943e655 100644 --- a/.claude/sweep-performance-state.csv +++ b/.claude/sweep-performance-state.csv @@ -28,7 +28,7 @@ interpolate_spline,2026-06-04,SAFE,compute-bound,0,,"scope=spline-only. Audited kde,2026-04-14T12:00:00Z,SAFE,compute-bound,0,,Graph construction serialized per-tile. _filter_points_to_tile scans all points per tile. No HIGH findings. mahalanobis,2026-03-31T18:00:00Z,SAFE,compute-bound,0,,False positive. Numpy path materializes by design. Dask path uses lazy reductions + map_blocks. mcda,2026-06-10,SAFE,memory-bound,2,3150,"2 HIGH fixed in PR #3158: owa() dask path crashed (da.sort does not exist; memory guard pointed users at the crashing path) and wpm validation ran one compute() per criterion. MEDIUM fixed in PR #3159 (#3151): cupy piecewise + dask+cupy piecewise/categorical raised TypeError via np.asarray on cupy chunks. MEDIUM fixed in PR #3160 (#3152): monte_carlo sensitivity materialized full dask dataset (now chunk-bounded map_blocks, ~8 tasks/chunk at n_samples=1000) and crashed on cupy via per-sample .values; constrain() deep copy dropped. LOW documented, not fixed: fuzzy_overlay builds ones via layers[0]*0+1; _categorical does one full-array pass per mapping key. Verdict SAFE assumes the 3 PRs merge (pre-fix: WILL OOM for MC-on-dask, owa dask broken). GPU paths validated on CUDA host (cupy 13.6)." -morphology,2026-03-31T18:00:00Z,SAFE,compute-bound,0,, +morphology,2026-06-20,SAFE,compute-bound,1,3401,memory guard fired on full lazy-dask shape (false MemoryError); skip guard for dask-backed inputs; eager numpy/cupy guard preserved multispectral,2026-05-02,SAFE,compute-bound,0,,"Re-audit 2026-05-02 after PRs 1292 (true_color memory guard) and 1301 (validate_arrays in true_color). Verified SAFE. No HIGH. MEDIUM: da.stack in _true_color_dask/_true_color_dask_cupy at L1702/L1731 creates (1,1,1,1) chunks along band axis (4 bands so impact is minor, scheduling overhead not OOM). LOW: np.zeros((h,w,4)) at L1681 then full overwrite -- np.empty would suffice. All 17 indices use plain map_blocks with no halo; 8192x8192 ndvi graph is 80 tasks, evi/arvi/ebbi 112 tasks." normalize,2026-03-31T18:00:00Z,SAFE,compute-bound,0,1124,Boolean indexing replaced with lazy nanmin/nanmax/nanmean/nanstd. pathfinding,2026-04-15T12:00:00Z,SAFE,compute-bound,0,false-positive,Downgraded. CuPy .get() is required -- A* has no GPU kernel. Per-pixel .compute() is only 2 calls for start/goal validation. seg.values in multi_stop_search collects already-computed results for stitching. diff --git a/xrspatial/morphology.py b/xrspatial/morphology.py index 87c01bb95..d183b11c1 100644 --- a/xrspatial/morphology.py +++ b/xrspatial/morphology.py @@ -41,6 +41,7 @@ class cupy: _validate_raster, calc_cuda_dims, has_cuda_and_cupy, + has_dask_array, ngjit, not_implemented_func, ) @@ -443,7 +444,14 @@ def _dispatch(agg, kernel, boundary, name, numpy_fn, cupy_fn, dask_fn, dask_cupy rows, cols = agg.shape ky, kx = kernel.shape - _check_kernel_memory(rows, cols, ky, kx, name) + # The guard budgets a full padded float64 copy of the input, which only + # the eager numpy/cupy backends allocate. Dask processes the array + # chunk-by-chunk via map_overlap, so peak memory scales with chunk size, + # not the full shape -- skip the full-shape check for dask-backed inputs. + # (This assumes reasonable chunking; a single giant chunk would still + # materialize a full padded copy per block.) + if not (has_dask_array() and isinstance(agg.data, da.Array)): + _check_kernel_memory(rows, cols, ky, kx, name) mapper = ArrayTypeFunctionMapping( numpy_func=partial(numpy_fn, kernel=kernel, boundary=boundary), diff --git a/xrspatial/tests/test_morphology.py b/xrspatial/tests/test_morphology.py index 8bf066768..0b7722fc2 100644 --- a/xrspatial/tests/test_morphology.py +++ b/xrspatial/tests/test_morphology.py @@ -531,6 +531,29 @@ def test_kernel_memory_guard_allows_normal_use(monkeypatch): general_output_checks(agg, result, verify_attrs=True) +@dask_array_available +def test_memory_guard_skipped_for_lazy_dask(monkeypatch): + """The full-shape guard must not fire on a lazy dask array. + + Dask processes the raster chunk-by-chunk via map_overlap, so peak + memory scales with chunk size, not the full logical shape. A guard + sized against the full array would reject workloads that run fine. + """ + import dask.array as da + + # Tiny memory budget: the full-shape guard would trip immediately, but + # the dask path never allocates a full padded copy. + monkeypatch.setattr( + morphology_mod, "_available_memory_bytes", lambda: 1 * 1024 * 1024, + ) + arr = da.zeros((4096, 4096), chunks=(512, 512), dtype=np.float64) + agg = xr.DataArray(arr, dims=['y', 'x']) + # Graph construction only -- no .compute(). + result = morph_erode(agg, kernel=_KERNEL_3x3, boundary='reflect') + assert isinstance(result.data, da.Array) + assert result.shape == (4096, 4096) + + def test_kernel_memory_guard_runs_for_all_public_apis(monkeypatch): """Every public API entry point should trigger the guard via _dispatch.""" monkeypatch.setattr(