Size the surface_distance Dijkstra heap to its real push bound (#3722) - #3727
Size the surface_distance Dijkstra heap to its real push bound (#3722)#3727brendancol wants to merge 2 commits into
Conversation
_dijkstra and _dijkstra_geodesic allocated the heap backing arrays as height * width. Both are lazy-deletion Dijkstra: a pixel is enqueued again every time its tentative distance improves, so it can be pushed once per settled neighbour. The real bound is height * width * (n_neighbors + 1), which is what cost_distance.py already uses. _heap_push writes keys[size] with no bounds check and numba compiles without bounds checking by default, so a dense source mask walked off the end of three arrays. An 80x80 checkerboard source raster over random elevation aborts the process with "double free or corruption (!prev)"; under NUMBA_BOUNDSCHECK=1 it raises IndexError from _dijkstra. The numpy planar, numpy geodesic, and dask+numpy tile paths were all affected. The cupy and dask+cupy backends use parallel relaxation with no heap and were not. _BYTES_PER_PIXEL follows the new allocation, 80 -> 272, so the memory guard added in #1305 keeps modelling what the eager numpy pass actually reserves.
|
Heads-up for whoever reviews this: PR #3728 (issue #3723) fixes the same defect, found independently by the performance sweep in the same /deep-sweep run over The fixes are mutually exclusive and touch the same two kernels:
Only one should land. Merging both will conflict. |
|
Follow-up to the cross-reference above, now that both sweeps have reported in full. The tradeoff between #3727 and #3728 is narrower than my first comment implied, and both fixes are internally consistent:
So it is a straight choice: consistency with One item worth carrying forward whichever way this lands: #3722 records that |
Fixes #3722.
What was wrong
_dijkstraand_dijkstra_geodesicsized the heap backing arrays asheight * width. Both are lazy-deletion Dijkstra: a pixel is enqueued againevery time its tentative distance improves, so it can be pushed once per settled
neighbour. The bound is
height * width * (n_neighbors + 1)._heap_pushwriteskeys[size]with no bounds check, and numba compiles withoutbounds checking by default, so once the heap outgrew the allocation the kernel
wrote past the end of three arrays.
cost_distance.pyalready carries the corrected sizing and a comment explainingit (lines 155-161).
surface_distance.pywas never brought along.What triggers it
Dense source masks. An 80x80 checkerboard source raster over random elevation
aborts the interpreter:
Under
NUMBA_BOUNDSCHECK=1the same input raisesIndexError: index is out of boundsfrom_dijkstra. Reproduced on numpy planar, numpy geodesic, and thedask+numpy iterative tile path.
Peak concurrent heap size against the old cap, 8-connectivity, random elevation:
Sparse single-source rasters stay well clear, which is why the existing tests
never caught it. Rasterized road or stream networks, landcover class masks, and
broad
target_values=selections all land in the overflowing regime.The change
Both kernels now allocate
height * width * (n_neighbors + 1)slots, matchingcost_distance._BYTES_PER_PIXELgoes 80 -> 272 so_check_memorykeeps modelling what theeager numpy pass actually reserves. The heap is 24 bytes per slot and there are
up to 9 slots per pixel at 8-connectivity, so the heap alone is 216 bytes/pixel.
Without this the guard added in #1305 would under-count the footprint by about
3.4x and the module would OOM instead of raising the guard's MemoryError. The
guard now rejects eager rasters above roughly 117 million pixels on a 64 GB host
and still points at
max_distance=and dask in its message.Tests
Four new tests in
xrspatial/tests/test_surface_distance.py:test_dense_sources_do_not_overflow_heap[4]/[8]— checkerboard sources onnumpy, both connectivities
test_dense_sources_do_not_overflow_heap_geodesic— same for_dijkstra_geodesictest_dense_sources_do_not_overflow_heap_dask— same through the iterativedask tile path
test_heap_bound_covers_worst_case_push_count— instruments an oversized copyof the kernel and asserts peak usage exceeds
height * widthbut stays insideheight * width * (n_neighbors + 1)On the unpatched module the first three kill the pytest process outright with a
glibc
free(): invalid pointerabort, which is the point.Verification
pytest xrspatial/tests/test_surface_distance.py— 42 passed (was 37)pytest xrspatial/tests/test_{surface_distance,cost_distance,pathfinding}.py—285 passed
NUMBA_BOUNDSCHECK=1compute-sanitizer --tool memcheckover nine shapes (2x2, 2x97, 97x2, 3x101,101x3, 61x59, 127x127, 33x512, 512x33) across all three public functions
reports
ERROR SUMMARY: 0 errors, unchanged before and afterF841attest_surface_distance.py:234, left aloneNot in this PR
xrspatial/pathfinding.py:304uses the samemax_heap = height * widthsizingwith the same
_heap_push. It is single-source A* and I could not get it tooverflow, so it is recorded in #3722 as a site to re-check rather than changed
here.
From the
/deep-sweepsecurity audit ofsurface_distance, 2026-08-16.