Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/sweep-performance-state.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion xrspatial/morphology.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class cupy:
_validate_raster,
calc_cuda_dims,
has_cuda_and_cupy,
has_dask_array,
ngjit,
not_implemented_func,
)
Expand Down Expand Up @@ -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),
Expand Down
23 changes: 23 additions & 0 deletions xrspatial/tests/test_morphology.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading