Grow the surface_distance Dijkstra heap on demand instead of capping it at height*width (#3723) - #3728
Grow the surface_distance Dijkstra heap on demand instead of capping it at height*width (#3723)#3728brendancol wants to merge 2 commits into
Conversation
_dijkstra and _dijkstra_geodesic sized their lazy-deletion min-heap at height * width. A pixel is re-pushed every time its tentative distance improves, so live occupancy is bounded by the number of improving relaxations, not by the pixel count. On a 48x48 raster with 40% target pixels over 200 m of relief, peak occupancy reached 1.09x the cap and _heap_push wrote past the end of h_keys / h_rows / h_cols, aborting the interpreter with "double free or corruption". The heap now starts at one entry per pixel (which the seeding loop can never exceed) and doubles when full. cost_distance fixed the same defect with a static height * width * (n_neighbors + 1) bound, but that would multiply this module's heap allocation by 9 and force _BYTES_PER_PIXEL from 80 to about 272, tightening the memory guard on every caller. Adds regression tests for the numpy, dask iterative and geodesic paths, plus a SurfaceDistanceDenseTargets asv benchmark. The existing benchmark misses this regime: its integer source raster makes nearly every pixel a target, so almost no relaxation improves a distance.
brendancol
left a comment
There was a problem hiding this comment.
PR Review: Grow the surface_distance Dijkstra heap on demand instead of capping it at height*width (#3723)
Self-review from the /sweep-performance agent that wrote the patch, posted as the audit trail. Read it with that bias in mind.
Blockers (must fix before merge)
None.
Suggestions (should fix, not blocking)
-
xrspatial/surface_distance.py:80-92-- the_BYTES_PER_PIXEL = 80comment still itemizesh_keys/h_rows/h_colsat 8 bytes each, which was exact when the heap was a fixedheight * width. It no longer is. After one doubling the heap is 48 bytes per pixel rather than 24, and_heap_growallocates the replacement arrays before the originals go out of scope, so a growth event transiently holds three times the current heap. On the dense-target scenes this PR is about,_check_memorycan therefore under-report peak by roughly 60%. The guard was advisory before and is not made worse by this change (the old code corrupted memory instead of over-allocating), but the constant should say the heap can grow so the next person reading it is not misled. -
xrspatial/surface_distance.py:246-251and the geodesic equivalent -- the seeding loop pushes without a capacity check. The invariant holding it up is real: the loop visits each pixel once and pushes at most once per pixel, into a heap sizedmax(height * width, 1). It is stated in the comment at line 236. But it is exactly the kind of invariant that a later change to seeding would break silently, and the failure mode is the out-of-bounds write this PR exists to remove.
Nits (optional improvements)
-
xrspatial/surface_distance.py:174-192--_heap_grow's docstring is longer than its body, and the part that matters (why the old bound was wrong) belongs where the capacity is chosen, in_dijkstra. Line 236 has a short version already; consider making that the canonical explanation and trimming the helper to one line. -
xrspatial/tests/test_surface_distance.py-- none of the four new tests assert that the heap actually grew. They assert the call completes and matches aheapqreference, which is what a user cares about, but if someone later changed the growth trigger to fire only above, say, four times capacity, the tests would still pass while the bug returned for denser inputs. Checking that the pre-fix code fails all four (verified:IndexErrorunderNUMBA_BOUNDSCHECK=1) is decent evidence the tests bite today; it says nothing about tomorrow.
What looks good
- The bound in the issue is right, and the reasoning holds for both kernels. A lazy-deletion heap's live occupancy tracks improving relaxations, not pixels, and
_heap_pushhas no bounds check because@ngjitdoes not add one. - Rejecting
cost_distance's staticheight * width * (n_neighbors + 1)bound is the right call here. It is correct but multiplies the heap by 9 for 8-connectivity, and this module, unlikecost_distance, enforces a documented per-pixel budget that would have to move from 80 to about 272. - The regression tests were checked against the pre-fix source and all four fail there, including the geodesic one, so the geodesic kernel is genuinely covered rather than just touched.
- The new benchmark targets the regime the old one structurally could not reach. The existing
SurfaceDistanceclass feedsget_xr_dataarray(is_int=True), which makes almost every pixel a target, so relaxations almost never improve anything and the heap never fills. - Backend claims in the PR body check out:
_surface_distance_cupyuses parallel relaxation with no heap, and the dask+cupy unbounded branch converts to numpy and inherits the fix.
Coverage the PR does not claim, and does not have
- cupy and dask+cupy are untested for this scenario because there is nothing to test; neither allocates a heap.
- The bounded
map_overlapdask branch is not covered by a dense-target test. It calls_surface_distance_numpyper chunk, so it is fixed, but only the iterative branch has a test.
Checklist
- Algorithm matches reference/paper -- output verified against a pure-Python
heapqmulti-source Dijkstra - All implemented backends produce consistent results -- dask iterative matches numpy at
rtol=1e-5 - NaN handling is correct -- untouched by this change; barrier and unreachable tests still pass
- Edge cases are covered by tests -- dense targets at 40%, geodesic, dask tiles
- Dask chunk boundaries handled correctly --
_run_tileseeds boundaries before_dijkstra, so seeds stay within one entry per pixel - No premature materialization or unnecessary copies -- the only new allocation is the heap doubling
- Benchmark exists --
SurfaceDistanceDenseTargetsadded - README feature matrix updated -- not applicable, no new function and no backend change
- Docstrings present and accurate
- Guard the seeding loops in both kernels as well. The old comment argued the loop can push at most one entry per pixel into a heap sized for one entry per pixel, which is true today, but it leaves a memory-safety invariant resting on a comment. Every push site now checks capacity. - Say in the _BYTES_PER_PIXEL comment that the heap terms are a starting capacity, not a bound, so 80 reads as a floor rather than an estimate that quietly went stale. - Move the rationale for the capacity choice from _heap_grow into _dijkstra, where the capacity is picked. - Cover the bounded map_overlap dask branch with a dense-target test. It calls _surface_distance_numpy per padded chunk, so it was already fixed, but only the iterative branch had a test.
brendancol
left a comment
There was a problem hiding this comment.
PR Review: follow-up pass after 4afb223
Second review pass over the same PR, covering the commit that responded to the first one.
Disposition of the first pass
- Fixed: the
_BYTES_PER_PIXELcomment now says the heap terms are a starting capacity rather than a bound, so 80 reads as a floor. - Fixed: both seeding loops now check capacity before pushing, so every push site in both kernels is guarded. The invariant that made the unguarded version safe was real but sat only in a comment, and the failure mode it protected was the out-of-bounds write this PR removes.
- Fixed: the capacity rationale moved from
_heap_growinto_dijkstraat the point the capacity is chosen;_heap_growis back to a one-line docstring. - Fixed:
test_dense_targets_dask_bounded_does_not_overflowcovers the boundedmap_overlapbranch atmax_distance=10.0with 16x16 chunks, matching numpy atrtol=1e-5. - Dismissed: asserting that the heap actually grew would mean reaching into
@ngjitinternals or exporting occupancy from the kernel, which costs more than it buys. The pre-fix check (all regression tests fail withIndexErrorunderNUMBA_BOUNDSCHECK=1) stands as the evidence that the tests bite.
Blockers
None.
Suggestions
None outstanding.
Nits
- The seeding guard is dead code on every input that exists today, since a per-pixel loop cannot exceed a per-pixel capacity. It is one comparison inside a loop that already does a
log nsift, so the cost is not the concern; someone reading it may just wonder why it is there. The comment abovemax_heapexplains the class of bug, which should be enough.
Verification
pytest xrspatial/tests/test_surface_distance.py-- 42 passed- Same file under
NUMBA_BOUNDSCHECK=1-- 42 passed pytest xrspatial/tests/test_surface_distance.py xrspatial/tests/test_cost_distance.py-- 133 passed, so the shared_heap_push/_heap_popare unaffectedflake8clean onxrspatial/surface_distance.pyandbenchmarks/benchmarks/surface_distance.py
|
Heads-up for whoever reviews this: PR #3727 (issue #3722) fixes the same defect, found independently by the security 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 |
Closes #3723
_dijkstraand_dijkstra_geodesicsized their lazy-deletion min-heap atheight * width. That bound is wrong: a pixel is pushed again every time its tentative distance improves, and stale entries stay in the heap until a pop skips them, so live occupancy tracks the number of improving relaxations rather than the pixel count._heap_pushis@ngjitwith no bounds checking, so passing the cap writes into whatever allocation followsh_keys/h_rows/h_cols.On a 48x48 raster with 40% target pixels over 200 m of relief, peak occupancy hit 1.09x the cap and the call aborted the interpreter with
double free or corruption (!prev). WithNUMBA_BOUNDSCHECK=1the same input raisesIndexError: index is out of boundsfrom_surface_distance_numpy.What changed
_heap_growdoubles it when a relaxation would overflow.heapqDijkstra reference.SurfaceDistanceDenseTargetsasv benchmark over 5%, 20% and 40% target density, with apeakmemvariant. The existingSurfaceDistancebenchmark cannot catch a regression here: its integer source raster makes nearly every pixel a target, so almost no relaxation ever improves a distance and the heap barely fills.Why not the cost_distance approach
cost_distancefixed this defect atxrspatial/cost_distance.py:161with a staticheight * width * (n_neighbors + 1)bound. That is also correct, but for 8-connectivity it multiplies the heap allocation by 9, from 24 to 216 bytes per pixel. This module documents an 80 bytes-per-pixel working set in_BYTES_PER_PIXELand enforces it in_check_memory, so the constant would have to rise to roughly 272 and the guard would start refusing rasters about 3.4x smaller than it does today. Growing on demand keeps the documented footprint accurate and still removes the cap.Backends
_surface_distance_numpycalls both kernels directly.map_overlappath calls_surface_distance_numpyper chunk and_run_tilecalls_dijkstraper tile.Test plan
pytest xrspatial/tests/test_surface_distance.py-- 41 passed (37 before, 4 new)#3723reproduction underNUMBA_BOUNDSCHECK=1returns a full finite grid instead of raisingheapqDijkstra tortol=1e-5flake8clean on the changed files (the oneF841in the test file predates this branch)SurfaceDistanceDenseTargetsruns end to end at both grid sizes and all three densities