Skip to content

Commit fc27112

Browse files
committed
Speed up the proximity brute-force kernel (#3740)
_process_numpy_bruteforce serves allocation(), direction(), every GREAT_CIRCLE call and proximity() without scipy, on numpy and per chunk on dask+numpy. It called _distance for every pixel/target pair, which branched on the metric and (for GREAT_CIRCLE) ran four range checks, a sqrt and an asin per pair, re-read the target coordinates through the row/col index arrays each time, and its prange over rows was serial because @ngjit has no parallel=True. Gather the target coordinates into flat arrays once, give each metric its own inner loop, and compile the pixel loop with parallel=True. The inner loops compare a monotone proxy of the distance (squared distance, |dx|+|dy|, the haversine term) and only take the sqrt/asin and the float32 rounding when the proxy beats the running best. The strict < still runs on the float32 distance, so the lowest-flat-index tie-break at float32 precision (#3689) is unchanged. GREAT_CIRCLE validates the coordinate grids once up front and raises the same messages the per-pair guards raised. The parallel kernel launch is serialized behind a module-level lock, same as convolution and terrain (#3141), because the dask path calls it from worker threads. 300x600 raster, 1000 random targets, 20-core host, median of 5: EUCLIDEAN/PROXIMITY 496 ms -> 87 ms (1 thread), 8.6 ms (20) EUCLIDEAN/ALLOCATION 501 ms -> 91 ms (1 thread), 10.2 ms (20) MANHATTAN/DIRECTION 488 ms -> 95 ms (1 thread), 15.5 ms (20) GREAT_CIRCLE/PROXIMITY 3563 ms -> 1136 ms (1 thread), 87.6 ms (20) Results are bit-identical to the previous kernel across all three metrics, all three modes, bounded and unbounded max_distance, explicit and default target_values, and NaN cells in the image.
1 parent 1dbeb02 commit fc27112

2 files changed

Lines changed: 322 additions & 30 deletions

File tree

xrspatial/proximity.py

Lines changed: 214 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import threading
12
import warnings
23
from functools import partial
34

@@ -15,7 +16,7 @@
1516

1617
import numpy as np
1718
import xarray as xr
18-
from numba import cuda, prange
19+
from numba import cuda, jit, prange
1920

2021
try:
2122
import cupy
@@ -493,33 +494,27 @@ def _is_target_value(v, target_values):
493494
return False
494495

495496

496-
@ngjit
497-
def _process_numpy_bruteforce(
498-
img, xs, ys, target_values, max_distance, distance_metric, process_mode
499-
):
500-
"""Exact nearest-target proximity / allocation / direction on the CPU.
497+
# Numba parallel=True kernels must not be launched concurrently from multiple
498+
# Python threads: the default 'workqueue' threading layer is not threadsafe and
499+
# aborts the process (SIGABRT on macOS) when two host threads enter a parallel
500+
# region at once. _process_dask maps _process_numpy over chunks under dask's
501+
# threaded scheduler, and that reaches the brute-force kernel for GREAT_CIRCLE,
502+
# ALLOCATION, DIRECTION and the no-scipy PROXIMITY fallback, so the kernel
503+
# launch is serialized behind this lock. Same hazard and fix as the
504+
# convolution, terrain and reproject kernels (#3141).
505+
_PARALLEL_KERNEL_LOCK = threading.Lock()
501506

502-
For every pixel, scan all target pixels and keep the closest one under the
503-
chosen distance metric. This is the same brute-force search the CUDA kernel
504-
runs (see ``_proximity_cuda_kernel``). It covers what the cKDTree path
505-
cannot: GREAT_CIRCLE (not a Minkowski metric), the tie-break-sensitive
506-
ALLOCATION/DIRECTION modes, and PROXIMITY when scipy is missing.
507507

508-
``xs`` and ``ys`` are the per-pixel 2D coordinate grids built by the caller.
509-
"""
508+
@ngjit
509+
def _collect_targets(img, target_values):
510+
"""Row/col indices of every target pixel, in flat (row-major) order."""
510511
height, width = img.shape
511-
512-
# Collect target pixel rows/cols in flat arrays (two passes: count, fill).
513512
n_targets = 0
514513
for line in range(height):
515514
for col in range(width):
516515
if _is_target_value(img[line, col], target_values):
517516
n_targets += 1
518517

519-
output = np.full((height, width), np.nan, dtype=np.float32)
520-
if n_targets == 0:
521-
return output
522-
523518
target_rows = np.empty(n_targets, dtype=np.int64)
524519
target_cols = np.empty(n_targets, dtype=np.int64)
525520
t = 0
@@ -529,20 +524,165 @@ def _process_numpy_bruteforce(
529524
target_rows[t] = line
530525
target_cols[t] = col
531526
t += 1
527+
return target_rows, target_cols
528+
529+
530+
# The three inner loops below each scan every target for one pixel and return
531+
# (index, float32 distance) of the nearest one, or (-1, inf) with no targets.
532+
#
533+
# They compare a cheap proxy that is monotone in the distance (squared
534+
# distance, |dx| + |dy|, the haversine term) and only evaluate the sqrt /
535+
# arcsin and the float32 rounding when the proxy beats the running best. The
536+
# strict ``<`` that decides the winner still runs on the float32 distance, the
537+
# same value ``_distance`` returns, so the documented tie-break is unchanged:
538+
# two targets whose float64 distances differ only past the float32 mantissa
539+
# are a tie and the lowest flat index wins (issue #3689, and the matching
540+
# comment in ``_proximity_cuda_kernel``). A candidate whose proxy does not
541+
# beat the running best has a float32 distance >= the running best and could
542+
# never have won under that rule, so skipping it changes nothing.
543+
544+
@ngjit
545+
def _nearest_euclidean(px, py, txs, tys):
546+
best_proxy = np.inf
547+
best_dist = np.float32(np.inf)
548+
best_idx = -1
549+
for k in range(len(txs)):
550+
dx = px - txs[k]
551+
dy = py - tys[k]
552+
proxy = dx * dx + dy * dy
553+
if proxy < best_proxy:
554+
d = np.float32(np.sqrt(proxy))
555+
better = d < best_dist
556+
best_idx = k if better else best_idx
557+
best_dist = d
558+
best_proxy = proxy
559+
return best_idx, best_dist
560+
561+
562+
@ngjit
563+
def _nearest_manhattan(px, py, txs, tys):
564+
best_proxy = np.inf
565+
best_dist = np.float32(np.inf)
566+
best_idx = -1
567+
for k in range(len(txs)):
568+
dx = px - txs[k]
569+
dy = py - tys[k]
570+
proxy = abs(dx) + abs(dy)
571+
if proxy < best_proxy:
572+
d = np.float32(proxy)
573+
better = d < best_dist
574+
best_idx = k if better else best_idx
575+
best_dist = d
576+
best_proxy = proxy
577+
return best_idx, best_dist
578+
579+
580+
@ngjit
581+
def _nearest_great_circle(px, py, tlons, tlats, tcoslats):
582+
# Same arithmetic, in the same order, as great_circle_distance with the
583+
# default radius, so the float32 distance is bit-identical to _distance.
584+
lat1 = np.radians(py)
585+
lon1 = np.radians(px)
586+
coslat1 = np.cos(lat1)
587+
best_proxy = np.inf
588+
best_dist = np.float32(np.inf)
589+
best_idx = -1
590+
for k in range(len(tlons)):
591+
dlon = tlons[k] - lon1
592+
dlat = tlats[k] - lat1
593+
proxy = np.sin(dlat / 2.0) ** 2 + \
594+
coslat1 * tcoslats[k] * np.sin(dlon / 2.0) ** 2
595+
if proxy < best_proxy:
596+
d = np.float32(6378137 * 2 * np.arcsin(np.sqrt(proxy)))
597+
better = d < best_dist
598+
best_idx = k if better else best_idx
599+
best_dist = d
600+
best_proxy = proxy
601+
return best_idx, best_dist
602+
603+
604+
@ngjit
605+
def _great_circle_target_terms(txs, tys):
606+
# Precompute the per-target radians and cos(lat) with numba's np.radians /
607+
# np.cos rather than numpy's, so they match what the per-pixel side of
608+
# _nearest_great_circle (and great_circle_distance) computes bit for bit.
609+
n = len(txs)
610+
tlons = np.empty(n, dtype=np.float64)
611+
tlats = np.empty(n, dtype=np.float64)
612+
tcoslats = np.empty(n, dtype=np.float64)
613+
for k in range(n):
614+
tlons[k] = np.radians(txs[k])
615+
tlats[k] = np.radians(tys[k])
616+
tcoslats[k] = np.cos(tlats[k])
617+
return tlons, tlats, tcoslats
618+
532619

620+
@ngjit
621+
def _great_circle_range_violation(xs, ys, txs, tys):
622+
# Report the first out-of-range coordinate in the order the per-pair
623+
# guards in great_circle_distance would have hit it when the pixel loop
624+
# called it pair by pair: pixel (0, 0) against every target, then the
625+
# remaining pixels. 0 = no violation, otherwise the guard number (1: x of
626+
# the first point, 2: x of the second, 3: y of the first, 4: y of the
627+
# second). NaN coordinates (dask halo padding) fail no comparison, as
628+
# before.
629+
px = xs[0, 0]
630+
py = ys[0, 0]
631+
if px > 180 or px < -180:
632+
return 1
633+
if txs[0] > 180 or txs[0] < -180:
634+
return 2
635+
if py > 90 or py < -90:
636+
return 3
637+
if tys[0] > 90 or tys[0] < -90:
638+
return 4
639+
for k in range(1, len(txs)):
640+
if txs[k] > 180 or txs[k] < -180:
641+
return 2
642+
if tys[k] > 90 or tys[k] < -90:
643+
return 4
644+
height, width = xs.shape
645+
for line in range(height):
646+
for col in range(width):
647+
if xs[line, col] > 180 or xs[line, col] < -180:
648+
return 1
649+
if ys[line, col] > 90 or ys[line, col] < -90:
650+
return 3
651+
return 0
652+
653+
654+
_GREAT_CIRCLE_RANGE_MESSAGES = {
655+
1: "Invalid x-coordinate of the first point."
656+
"Must be in the range [-180, 180]",
657+
2: "Invalid x-coordinate of the second point."
658+
"Must be in the range [-180, 180]",
659+
3: "Invalid y-coordinate of the first point."
660+
"Must be in the range [-90, 90]",
661+
4: "Invalid y-coordinate of the second point."
662+
"Must be in the range [-90, 90]",
663+
}
664+
665+
666+
@jit(nopython=True, nogil=True, parallel=True)
667+
def _bruteforce_kernel(
668+
img, xs, ys, target_rows, target_cols, txs, tys, tlons, tlats, tcoslats,
669+
max_distance, distance_metric, process_mode, output
670+
):
671+
# The metric branch sits per pixel, outside the target loop, and the
672+
# metric stays a runtime value so one compiled specialization serves all
673+
# three. Rows are independent, so prange over them.
674+
height, width = img.shape
533675
for line in prange(height):
534676
for col in range(width):
535677
px = xs[line, col]
536678
py = ys[line, col]
537-
best_dist = np.float32(np.inf)
538-
best_idx = -1
539-
for k in range(n_targets):
540-
tx = xs[target_rows[k], target_cols[k]]
541-
ty = ys[target_rows[k], target_cols[k]]
542-
d = _distance(px, tx, py, ty, distance_metric)
543-
if d < best_dist:
544-
best_dist = d
545-
best_idx = k
679+
if distance_metric == EUCLIDEAN:
680+
best_idx, best_dist = _nearest_euclidean(px, py, txs, tys)
681+
elif distance_metric == GREAT_CIRCLE:
682+
best_idx, best_dist = _nearest_great_circle(
683+
px, py, tlons, tlats, tcoslats)
684+
else:
685+
best_idx, best_dist = _nearest_manhattan(px, py, txs, tys)
546686
if best_idx >= 0 and best_dist <= max_distance:
547687
if process_mode == PROXIMITY:
548688
output[line, col] = best_dist
@@ -551,8 +691,52 @@ def _process_numpy_bruteforce(
551691
target_rows[best_idx], target_cols[best_idx]]
552692
else:
553693
output[line, col] = _calc_direction(
554-
px, xs[target_rows[best_idx], target_cols[best_idx]],
555-
py, ys[target_rows[best_idx], target_cols[best_idx]])
694+
px, txs[best_idx], py, tys[best_idx])
695+
696+
697+
def _process_numpy_bruteforce(
698+
img, xs, ys, target_values, max_distance, distance_metric, process_mode
699+
):
700+
"""Exact nearest-target proximity / allocation / direction on the CPU.
701+
702+
For every pixel, scan all target pixels and keep the closest one under the
703+
chosen distance metric. This is the same brute-force search the CUDA kernel
704+
runs (see ``_proximity_cuda_kernel``). It covers what the cKDTree path
705+
cannot: GREAT_CIRCLE (not a Minkowski metric), the tie-break-sensitive
706+
ALLOCATION/DIRECTION modes, and PROXIMITY when scipy is missing.
707+
708+
``xs`` and ``ys`` are the per-pixel 2D coordinate grids built by the caller.
709+
710+
The target coordinates are gathered into flat arrays once, the per-metric
711+
inner loops live in ``_nearest_*`` and the pixel loop runs in parallel over
712+
rows in ``_bruteforce_kernel``, serialized behind ``_PARALLEL_KERNEL_LOCK``
713+
because the dask path calls this per chunk from worker threads.
714+
"""
715+
target_rows, target_cols = _collect_targets(img, target_values)
716+
717+
output = np.full(img.shape, np.nan, dtype=np.float32)
718+
if len(target_rows) == 0:
719+
return output
720+
721+
txs = xs[target_rows, target_cols]
722+
tys = ys[target_rows, target_cols]
723+
724+
if distance_metric == GREAT_CIRCLE:
725+
# The per-pair guards in great_circle_distance no longer run inside
726+
# the loop; check the grids once up front and raise the same message.
727+
violation = _great_circle_range_violation(xs, ys, txs, tys)
728+
if violation:
729+
raise ValueError(_GREAT_CIRCLE_RANGE_MESSAGES[violation])
730+
tlons, tlats, tcoslats = _great_circle_target_terms(txs, tys)
731+
else:
732+
tlons = tlats = tcoslats = np.empty(0, dtype=np.float64)
733+
734+
with _PARALLEL_KERNEL_LOCK:
735+
_bruteforce_kernel(
736+
img, xs, ys, target_rows, target_cols, txs, tys,
737+
tlons, tlats, tcoslats,
738+
max_distance, distance_metric, process_mode, output,
739+
)
556740
return output
557741

558742

0 commit comments

Comments
 (0)