Skip to content

Commit 1b1862b

Browse files
fluidnumericsJoeclaudepre-commit-ci[bot]erikvansebille
authored
Spatialhash regional spherical improvements (#2744)
* Tighten bounding cube for regional spherical domains When the mesh type is spherical, we now use the actual x,y,z bounding box rather than the unit cube. For regional runs that leverage the spherical barycentric coordinates and built in unit conversion in the velocity field interpolators, this reduces the hit count per hash cell and can improve the search time in the spatialhash.query() Note that quantize_coordinates now clips in float space before casting to uint32. This matters now that spherical queries can fall outside the hash-grid bounds (previously impossible with the unit cube). A negative normalized value cast to uint32 wraps to a huge number, which would have mapped below-range queries to the top bin (with morton code 2^32) erroneously. For spherical grids, points outside the regional box now quantize to edge bins rather than interior bins. In this case, they either fail the exact Morton-key match or are rejected by the point-in-cell check; either case results in the same outcome as before (GRID_SEARCH_ERROR) * Add structured grid spherical spatialhash query tests Tests for both points inside the domain and explicitly outside the domain to confirm correct behavior for out of bounds PIC checks * Update documentation on the unstructured grid search to note change to spherical bounding box * Remove dead valid-face branch and identity scatter from hash table build num_hash_per_face is never zero (quantization is monotone), so the valid mask was always all-True and the scatter an identity permutation. Removing them drops three entry-length temporaries (~35% peak build memory). Accumulate entry counts in int64 to avoid overflow. Hash table output verified byte-identical. Co-authored-by: Claude <noreply@anthropic.com> * Sort packed (morton code, face id) uint64 pairs in place in hash table build Fusing each pair as (code << 32) | face_id and sorting in place replaces argsort, its int64 permutation array, and two gather passes. Pairs are unique so the ordering is deterministic (ties by ascending face id). Also free decode temporaries before the sort. Peak build memory drops 17.2 GB -> 6.35 GB (with previous commit) and build time 40 s -> 32 s on a 101M-entry spherical UxGrid table. CSR arrays verified byte-identical; per-cell face sets verified identical. Co-authored-by: Claude <noreply@anthropic.com> * Replace np.unique function with a simpler set of calls that exploits sorting The np.unique function makes no assumption about whether or not the input is sorted. Since we've done the work to sort the list of morton codes and face id pairings, computing the start, count, and unique keys of the hash table can be done with just three operations: 1. Find the indices where the morton codes in the sorted list change value - prepending this list of indices with [0] gives the starting indices for a CSR format matrix 2. The keys for the hash table are just the unique morton code values. Once we know the `start` indices, we can quickly index for the unique morton codes 3. The count (the number of faces per morton code / hash table key) is just the np.diff of the `start` indices. This results in a minor improvement in construction speed. * Store the face id rather than i,j in the hash table to reduce memory footprint Instead, we opt to unravel the face during the query. This cuts the hash table size down by only storing a single uint64 array (with the start and count) rather than two arrays. The unravel cost in the query is small in comparison to particle in cell checks, which dominate the query costs. * Enforce a budget on the number of hash cells per face Enforcing this upper bound on the number of hash cells per face helps considerably in reducing the memory footprint of the hash table construction; this also significantly speeds up the construction process. The max budget value was tuned to balance keeping the number of PIC checks low while keeping the memory peak during construction small. Claude code was used to design tests for this new feature * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove erroneous test that relied on bitwidth parameter for hash table construction I've removed the bitwidth parameter for construction and opted for this to be calculated internally for optimal memory footprint and query cost * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Erik van Sebille <e.vansebille@uu.nl>
1 parent 173c73e commit 1b1862b

3 files changed

Lines changed: 238 additions & 104 deletions

File tree

docs/development/unstructured_grid_search.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,12 @@ The algorithm has two phases: **initialisation** (once, when the grid is first u
4242

4343
The hash grid is three-dimensional regardless of the source grid:
4444

45-
| Source grid | Mesh type | Hash-grid space |
46-
| ----------- | --------- | ------------------------------------- |
47-
| `XGrid` | spherical | Cartesian unit cube (lon/lat → x,y,z) |
48-
| `XGrid` | flat | 2-D lon/lat bounding box (z set to 0) |
49-
| `UxGrid` | spherical | Cartesian unit cube |
50-
| `UxGrid` | flat | 2-D lon/lat bounding box |
45+
| Source grid | Mesh type | Hash-grid space |
46+
| ----------- | --------- | ---------------------------------------- |
47+
| `XGrid` | spherical | Cartesian bounding box (lon/lat → x,y,z) |
48+
| `XGrid` | flat | 2-D lon/lat bounding box (z set to 0) |
49+
| `UxGrid` | spherical | Cartesian bounding box (lon/lat → x,y,z) |
50+
| `UxGrid` | flat | 2-D lon/lat bounding box |
5151

5252
For spherical grids, node coordinates are converted from degrees to radians and then to Cartesian (x, y, z) on the unit sphere using `parcels._core.index_search._latlon_rad_to_xyz`:
5353

@@ -57,7 +57,7 @@ y = sin(lon) * cos(lat)
5757
z = sin(lat)
5858
```
5959

60-
The hash grid then spans the unit cube `[-1, 1]³`. Working in Cartesian space avoids the longitude wrap-around discontinuity that would otherwise cause the bounding boxes of cells crossing the antimeridian to erroneously span the entire domain.
60+
The hash grid then spans the axis-aligned Cartesian bounding box of the transformed grid nodes. For a global grid this is (nearly) the unit cube `[-1, 1]³`; for a regional grid it is much tighter, so the full quantization resolution (1024 bins per axis) is spent on the region actually covered by the grid rather than the whole sphere. Working in Cartesian space avoids the longitude wrap-around discontinuity that would otherwise cause the bounding boxes of cells crossing the antimeridian to erroneously span the entire domain.
6161

6262
For flat meshes the hash grid simply spans `[lon_min, lon_max] × [lat_min, lat_max]`, with z fixed at 0.
6363

src/parcels/_core/spatialhash.py

Lines changed: 170 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@
1111
from parcels._core.warnings import FieldSetWarning
1212
from parcels._python import isinstance_noimport
1313

14+
# Budget on the total number of (face, hash cell) pairs in the hash table:
15+
# max(_HASH_ENTRIES_PER_FACE * nfaces, _HASH_ENTRY_BUDGET_MIN).
16+
# When the hash grid cell size, set by the bitwidth, would result in too many
17+
# hash entries per face, the hash grid is coarsened by lowering the bitwidth
18+
# The target value for the number of hash entries per face is chosen to keep
19+
# the number of particle in cell checks as small as possible, while also
20+
# minimizing the hash table construction memory footprint.
21+
_HASH_ENTRIES_PER_FACE = 16
22+
_HASH_ENTRY_BUDGET_MIN = 2**22
23+
_HASH_MAX_BITWIDTH = 1023
24+
1425

1526
class SpatialHash:
1627
"""Custom data structure that is used for performing grid searches using Spatial Hashing. This class constructs an overlying
@@ -31,7 +42,6 @@ class SpatialHash:
3142
def __init__(
3243
self,
3344
grid,
34-
bitwidth=1023,
3545
):
3646
if isinstance_noimport(grid, "XGrid"):
3747
self._point_in_cell = curvilinear_point_in_cell
@@ -41,21 +51,23 @@ def __init__(
4151
raise ValueError("Expected `grid` to be a parcels.XGrid or parcels.UxGrid")
4252

4353
self._source_grid = grid
44-
self._bitwidth = bitwidth # Max integer to use per coordinate in quantization (10 bits = 0..1023)
54+
self._bitwidth = _HASH_MAX_BITWIDTH # Max integer to use per coordinate in quantization (10 bits = 0..1023)
4555

4656
if isinstance_noimport(grid, "XGrid"):
4757
self._coord_dim = 2 # Number of computational coordinates is 2 (bilinear interpolation)
4858
if self._source_grid._mesh == "spherical":
49-
# Boundaries of the hash grid are the unit cube
50-
self._xmin = -1.0
51-
self._ymin = -1.0
52-
self._zmin = -1.0
53-
self._xmax = 1.0
54-
self._ymax = 1.0
55-
self._zmax = 1.0 # Compute the cell centers of the source grid (for now, assuming Xgrid)
5659
lon = np.deg2rad(self._source_grid.lon)
5760
lat = np.deg2rad(self._source_grid.lat)
5861
x, y, z = _latlon_rad_to_xyz(lat, lon)
62+
# Boundaries of the hash grid are the Cartesian bounding box of the
63+
# transformed grid, so that regional domains retain full quantization
64+
# resolution instead of spreading it over the whole unit cube
65+
self._xmin = x.min()
66+
self._xmax = x.max()
67+
self._ymin = y.min()
68+
self._ymax = y.max()
69+
self._zmin = z.min()
70+
self._zmax = z.max()
5971
_xbound = np.stack(
6072
(
6173
x[:-1, :-1],
@@ -149,19 +161,20 @@ def __init__(
149161
elif isinstance_noimport(grid, "UxGrid"):
150162
self._coord_dim = grid.uxgrid.n_max_face_nodes # Number of barycentric coordinates
151163
if self._source_grid._mesh == "spherical":
152-
# Boundaries of the hash grid are the unit cube
153-
self._xmin = -1.0
154-
self._ymin = -1.0
155-
self._zmin = -1.0
156-
self._xmax = 1.0
157-
self._ymax = 1.0
158-
self._zmax = 1.0 # Compute the cell centers of the source grid (for now, assuming Xgrid)
159164
# Reshape node coordinates to (nfaces, nnodes_per_face)
160165
nids = self._source_grid.uxgrid.face_node_connectivity.values
161166
lon = self._source_grid.uxgrid.node_lon.values[nids]
162167
lat = self._source_grid.uxgrid.node_lat.values[nids]
163-
x, y, z = _latlon_rad_to_xyz(np.deg2rad(lat), np.deg2rad(lon))
164168
_xbound, _ybound, _zbound = _latlon_rad_to_xyz(np.deg2rad(lat), np.deg2rad(lon))
169+
# Boundaries of the hash grid are the Cartesian bounding box of the
170+
# transformed grid, so that regional domains retain full quantization
171+
# resolution instead of spreading it over the whole unit cube
172+
self._xmin = _xbound.min()
173+
self._xmax = _xbound.max()
174+
self._ymin = _ybound.min()
175+
self._ymax = _ybound.max()
176+
self._zmin = _zbound.min()
177+
self._zmax = _zbound.max()
165178

166179
# Compute bounding box of each face
167180
self._xlow = np.atleast_2d(np.min(_xbound, axis=-1))
@@ -193,9 +206,60 @@ def __init__(
193206
self._zlow = np.zeros_like(self._xlow)
194207
self._zhigh = np.zeros_like(self._xlow)
195208

209+
# Cap the quantization resolution so the hash table stays within a fixed entry
210+
# budget.
211+
budget = max(_HASH_ENTRIES_PER_FACE * np.size(self._xlow), _HASH_ENTRY_BUDGET_MIN)
212+
if self._total_hash_entries(self._bitwidth) > budget:
213+
# Binary search for the largest bitwidth whose table fits the budget. The
214+
# entry count is not perfectly monotone in bitwidth (cell-boundary flooring
215+
# effects), so the result may sit marginally below the true maximum; any
216+
# in-budget bitwidth is valid. At bitwidth 1 the count equals nfaces, which
217+
# is always within budget, so the search cannot fail.
218+
lo, hi = 1, self._bitwidth
219+
while lo < hi:
220+
mid = (lo + hi + 1) // 2
221+
if self._total_hash_entries(mid) <= budget:
222+
lo = mid
223+
else:
224+
hi = mid - 1
225+
self._bitwidth = lo
226+
196227
# Generate the mapping from the hash indices to unstructured grid elements
197228
self._hash_table = self._initialize_hash_table()
198229

230+
def _total_hash_entries(self, bitwidth):
231+
"""Total number of (face, hash cell) pairs the hash table would hold at a given
232+
quantization resolution, i.e. the summed hash-cell count of all face bounding boxes.
233+
"""
234+
xqlow, yqlow, zqlow = quantize_coordinates(
235+
self._xlow,
236+
self._ylow,
237+
self._zlow,
238+
self._xmin,
239+
self._xmax,
240+
self._ymin,
241+
self._ymax,
242+
self._zmin,
243+
self._zmax,
244+
bitwidth,
245+
)
246+
xqhigh, yqhigh, zqhigh = quantize_coordinates(
247+
self._xhigh,
248+
self._yhigh,
249+
self._zhigh,
250+
self._xmin,
251+
self._xmax,
252+
self._ymin,
253+
self._ymax,
254+
self._zmin,
255+
self._zmax,
256+
bitwidth,
257+
)
258+
nx = xqhigh.astype(np.int64) - xqlow + 1
259+
ny = yqhigh.astype(np.int64) - yqlow + 1
260+
nz = zqhigh.astype(np.int64) - zqlow + 1
261+
return int((nx * ny * nz).sum())
262+
199263
def _initialize_hash_table(self):
200264
"""Create a mapping that relates unstructured grid faces to hash indices by determining
201265
which faces overlap with which hash cells
@@ -238,82 +302,77 @@ def _initialize_hash_table(self):
238302
num_hash_per_face = (nx * ny * nz).astype(
239303
np.int32, copy=False
240304
) # Since nx, ny, nz are in the 10-bit range, their product fits in int32
241-
total_hash_entries = int(num_hash_per_face.sum())
242-
243-
# Preallocate output arrays
244-
morton_codes = np.zeros(total_hash_entries, dtype=np.uint32)
245-
246-
# Compute the j, i indices corresponding to each hash entry
305+
# Sums over faces can exceed int32, so accumulate in int64
306+
total_hash_entries = int(num_hash_per_face.sum(dtype=np.int64))
307+
# Entry indices fit in int32 for all but extreme cases; fall back to int64 when needed
308+
idx_dtype = np.int64 if total_hash_entries > np.iinfo(np.int32).max else np.int32
309+
310+
# Every face overlaps at least one hash cell (nx, ny, nz >= 1 since quantization
311+
# is monotone), and contributes one hash entry per cell of its quantized bounding
312+
# box. Entries are generated in face-major order: face_ids maps each entry to its
313+
# face, and intra enumerates the cells of that face's box (0..num_hash_per_face-1).
247314
nface = np.size(self._xlow)
248-
face_ids = np.repeat(np.arange(nface, dtype=np.int32), num_hash_per_face)
249-
offsets = np.concatenate(([0], np.cumsum(num_hash_per_face))).astype(np.int32)[:-1]
250-
251-
valid = num_hash_per_face != 0
252-
if not np.any(valid):
253-
# nothing to do
254-
pass
255-
else:
256-
# Grab only valid faces to avoid empty arrays
257-
nx_v = np.asarray(nx[valid], dtype=np.int32)
258-
ny_v = np.asarray(ny[valid], dtype=np.int32)
259-
nz_v = np.asarray(nz[valid], dtype=np.int32)
260-
xlow_v = np.asarray(xqlow[valid], dtype=np.int32)
261-
ylow_v = np.asarray(yqlow[valid], dtype=np.int32)
262-
zlow_v = np.asarray(zqlow[valid], dtype=np.int32)
263-
starts_v = np.asarray(offsets[valid], dtype=np.int32)
264-
265-
# Count of elements per valid face (should match num_hash_per_face[valid])
266-
counts = (nx_v * ny_v * nz_v).astype(np.int32)
267-
total = int(counts.sum())
268-
269-
# Map each global element to its face and output position
270-
start_for_elem = np.repeat(starts_v, counts) # shape (total,)
271-
272-
# Intra-face linear index for each element (0..counts_i-1)
273-
# Offsets per face within the concatenation of valid faces:
274-
face_starts_local = np.cumsum(np.r_[0, counts[:-1]])
275-
intra = np.arange(total, dtype=np.int32) - np.repeat(face_starts_local, counts)
276-
277-
# Derive (zi, yi, xi) from intra using per-face sizes
278-
ny_nz = np.repeat(ny_v * nz_v, counts)
279-
nz_rep = np.repeat(nz_v, counts)
280-
281-
xi = intra // ny_nz
282-
rem = intra % ny_nz
283-
yi = rem // nz_rep
284-
zi = rem % nz_rep
285-
286-
# Add per-face lows
287-
x0 = np.repeat(xlow_v, counts)
288-
y0 = np.repeat(ylow_v, counts)
289-
z0 = np.repeat(zlow_v, counts)
290-
291-
xq = x0 + xi
292-
yq = y0 + yi
293-
zq = z0 + zi
294-
295-
# Vectorized morton encode for all elements at once
296-
codes_all = _encode_quantized_morton3d(xq, yq, zq)
297-
298-
# Scatter into the preallocated output using computed absolute indices
299-
out_idx = start_for_elem + intra
300-
morton_codes[out_idx] = codes_all
301-
302-
# Sort face indices by morton code
303-
order = np.argsort(morton_codes)
304-
morton_codes_sorted = morton_codes[order]
305-
face_sorted = face_ids[order]
306-
j_sorted, i_sorted = np.unravel_index(face_sorted, self._xlow.shape)
307-
308-
# Get a list of unique morton codes and their corresponding starts and counts (CSR format)
309-
keys, starts, counts = np.unique(morton_codes_sorted, return_index=True, return_counts=True)
315+
face_ids = np.repeat(np.arange(nface, dtype=np.uint32), num_hash_per_face)
316+
face_starts = np.concatenate(([0], np.cumsum(num_hash_per_face, dtype=np.int64)))[:-1]
317+
intra = np.arange(total_hash_entries, dtype=idx_dtype) - np.repeat(
318+
face_starts.astype(idx_dtype, copy=False), num_hash_per_face
319+
)
310320

321+
# Derive (xi, yi, zi) cell offsets within each face's box from intra,
322+
# then shift by the per-face low corner to get quantized cell coordinates
323+
ny_nz = np.repeat(ny * nz, num_hash_per_face)
324+
nz_rep = np.repeat(nz, num_hash_per_face)
325+
326+
xi = intra // ny_nz
327+
rem = intra % ny_nz
328+
yi = rem // nz_rep
329+
zi = rem % nz_rep
330+
331+
xq = np.repeat(xqlow, num_hash_per_face) + xi
332+
yq = np.repeat(yqlow, num_hash_per_face) + yi
333+
zq = np.repeat(zqlow, num_hash_per_face) + zi
334+
335+
# Vectorized morton encode for all entries at once, already in face-major order
336+
morton_codes = _encode_quantized_morton3d(xq, yq, zq)
337+
del intra, rem, xi, yi, zi, ny_nz, nz_rep, xq, yq, zq
338+
339+
# Sort entries by morton code. Each (code, face) pair is fused into one uint64
340+
# with the code in the high 32 bits and the face id in the low 32 bits: unsigned
341+
# comparison then orders by code, with ties broken by ascending face id. Sorting
342+
# the fused array in place avoids the argsort permutation array and the gather
343+
# copies it would imply. Pairs are unique, so the ordering is deterministic.
344+
packed = morton_codes.astype(np.uint64)
345+
del morton_codes
346+
packed <<= np.uint64(32)
347+
np.bitwise_or(packed, face_ids, out=packed)
348+
del face_ids
349+
# Perform a single sort on the packed (morton_code | face_id ) list
350+
packed.sort()
351+
# Trunctating back to a uint32 keeps the lower 32 bits (the face_id's)
352+
face_sorted = packed.astype(np.uint32)
353+
# Purge the face ids from the packed list to retain only the morton codes
354+
packed >>= np.uint64(32)
355+
# Cast the morton codes back to uint32
356+
morton_codes_sorted = packed.astype(np.uint32)
357+
del packed
358+
359+
# Get a list of unique morton codes and their corresponding starts and counts (CSR format).
360+
# The codes are already sorted at this point, first by morton code, then by face_id
361+
# Starting indices of the matrix rows are located by finding indices where the morton codes differ
362+
starts = np.concatenate(([0], np.flatnonzero(morton_codes_sorted[1:] != morton_codes_sorted[:-1]) + 1))
363+
# The unique keys for the hash table are the unique morton codes
364+
keys = morton_codes_sorted[starts]
365+
# The number of faces per hash keys (morton codes) is easily calculated as the difference betwee the start values
366+
counts = np.diff(np.concatenate((starts, [morton_codes_sorted.size])))
367+
368+
# The flat face id is stored (4 bytes per entry); query() unravels the gathered
369+
# candidates to (j, i) on demand, instead of holding two precomputed int64
370+
# index arrays (16 bytes per entry) for the lifetime of the grid.
311371
hash_table = {
312372
"keys": keys,
313373
"starts": starts,
314374
"counts": counts,
315-
"i": i_sorted,
316-
"j": j_sorted,
375+
"faces": face_sorted,
317376
}
318377
return hash_table
319378

@@ -341,8 +400,7 @@ def query(self, y, x):
341400
keys = self._hash_table["keys"]
342401
starts = self._hash_table["starts"]
343402
counts = self._hash_table["counts"]
344-
i = self._hash_table["i"]
345-
j = self._hash_table["j"]
403+
faces = self._hash_table["faces"]
346404

347405
y = np.asarray(y)
348406
x = np.asarray(x)
@@ -358,7 +416,16 @@ def query(self, y, x):
358416
qz = np.zeros_like(qx)
359417

360418
query_codes = _encode_morton3d(
361-
qx, qy, qz, self._xmin, self._xmax, self._ymin, self._ymax, self._zmin, self._zmax
419+
qx,
420+
qy,
421+
qz,
422+
self._xmin,
423+
self._xmax,
424+
self._ymin,
425+
self._ymax,
426+
self._zmin,
427+
self._zmax,
428+
bitwidth=self._bitwidth,
362429
).ravel()
363430
num_queries = query_codes.size
364431

@@ -418,9 +485,10 @@ def query(self, y, x):
418485
# use to quickly gather the (i,j) pairs for each query
419486
source_idx = starts[hash_positions].astype(np.int32) + intra
420487

421-
# Gather all candidate (j,i) pairs in one shot
422-
j_all = j[source_idx]
423-
i_all = i[source_idx]
488+
# Gather all candidate face ids in one shot and unravel them to (j, i) pairs;
489+
# only the gathered candidates are unraveled, not the whole table
490+
face_all = faces[source_idx]
491+
j_all, i_all = np.unravel_index(face_all, self._xlow.shape)
424492

425493
# Now we need to construct arrays that repeats the y and x coordinates for each candidate
426494
# to enable vectorized point-in-cell checks
@@ -588,10 +656,15 @@ def quantize_coordinates(x, y, z, xmin, xmax, ymin, ymax, zmin, zmax, bitwidth=1
588656
zn = np.where(dz != 0, (z - zmin) / dz, 0.0)
589657

590658
# --- 2) Quantize to (0..bitwidth). ---
591-
# Multiply by bitwidth, round down, and clip to be safe against slight overshoot.
592-
xq = np.clip((xn * bitwidth).astype(np.uint32), 0, bitwidth)
593-
yq = np.clip((yn * bitwidth).astype(np.uint32), 0, bitwidth)
594-
zq = np.clip((zn * bitwidth).astype(np.uint32), 0, bitwidth)
659+
# Multiply by bitwidth, round down, and clip to be safe against overshoot.
660+
# Clip in float space before casting: out-of-range queries (e.g., points outside
661+
# a regional domain) would otherwise wrap around when a negative float is cast to uint32.
662+
# NaN queries produce arbitrary codes here; they are discarded downstream by the
663+
# finite-coordinate mask in SpatialHash.query.
664+
with np.errstate(invalid="ignore"):
665+
xq = np.clip(xn * bitwidth, 0, bitwidth).astype(np.uint32)
666+
yq = np.clip(yn * bitwidth, 0, bitwidth).astype(np.uint32)
667+
zq = np.clip(zn * bitwidth, 0, bitwidth).astype(np.uint32)
595668

596669
return xq, yq, zq
597670

0 commit comments

Comments
 (0)