Skip to content

Commit e54fca5

Browse files
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
1 parent 9a9b4f2 commit e54fca5

2 files changed

Lines changed: 118 additions & 7 deletions

File tree

src/parcels/_core/spatialhash.py

Lines changed: 73 additions & 3 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,7 +51,7 @@ 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)
@@ -196,9 +206,60 @@ def __init__(
196206
self._zlow = np.zeros_like(self._xlow)
197207
self._zhigh = np.zeros_like(self._xlow)
198208

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+
199227
# Generate the mapping from the hash indices to unstructured grid elements
200228
self._hash_table = self._initialize_hash_table()
201229

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+
202263
def _initialize_hash_table(self):
203264
"""Create a mapping that relates unstructured grid faces to hash indices by determining
204265
which faces overlap with which hash cells
@@ -355,7 +416,16 @@ def query(self, y, x):
355416
qz = np.zeros_like(qx)
356417

357418
query_codes = _encode_morton3d(
358-
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,
359429
).ravel()
360430
num_queries = query_codes.size
361431

tests/test_spatialhash.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import numpy as np
22

33
from parcels._core.fieldset import FieldSet
4+
from parcels._core.spatialhash import _HASH_ENTRIES_PER_FACE, _HASH_ENTRY_BUDGET_MIN, SpatialHash
45
from parcels._datasets.structured.generic import datasets
56

67

8+
def _cell_centers(grid):
9+
lon, lat = grid.lon, grid.lat
10+
clon = 0.25 * (lon[:-1, :-1] + lon[:-1, 1:] + lon[1:, :-1] + lon[1:, 1:])
11+
clat = 0.25 * (lat[:-1, :-1] + lat[:-1, 1:] + lat[1:, :-1] + lat[1:, 1:])
12+
jj, ii = np.meshgrid(np.arange(clat.shape[0]), np.arange(clat.shape[1]), indexing="ij")
13+
return clat, clon, jj, ii
14+
15+
716
def test_spatialhash_init():
817
ds = datasets["2d_left_rotated"]
918
grid = FieldSet.from_sgrid_conventions(ds, mesh="flat").data_g.grid
@@ -40,10 +49,7 @@ def test_spherical_regional_bounds():
4049
assert np.all(extents < 2.0) # strictly tighter than the unit cube
4150

4251
# Queries at cell centers must still resolve to the correct cell
43-
lon, lat = grid.lon, grid.lat
44-
clon = 0.25 * (lon[:-1, :-1] + lon[:-1, 1:] + lon[1:, :-1] + lon[1:, 1:])
45-
clat = 0.25 * (lat[:-1, :-1] + lat[:-1, 1:] + lat[1:, :-1] + lat[1:, 1:])
46-
jj, ii = np.meshgrid(np.arange(clat.shape[0]), np.arange(clat.shape[1]), indexing="ij")
52+
clat, clon, jj, ii = _cell_centers(grid)
4753
j, i, _ = spatialhash.query(clat.ravel(), clon.ravel())
4854
assert np.array_equal(j, jj.ravel())
4955
assert np.array_equal(i, ii.ravel())
@@ -54,6 +60,41 @@ def test_spherical_regional_bounds():
5460
assert np.all(i == -3)
5561

5662

63+
def test_query_honors_reduced_bitwidth():
64+
"""Queries must be encoded at the same quantization resolution the table was
65+
built with; a mismatch makes every Morton key miss and all queries fail.
66+
"""
67+
ds = datasets["2d_left_rotated"]
68+
grid = FieldSet.from_sgrid_conventions(ds, mesh="spherical").data_g.grid
69+
spatialhash = SpatialHash(grid, bitwidth=64)
70+
71+
clat, clon, jj, ii = _cell_centers(grid)
72+
j, i, _ = spatialhash.query(clat.ravel(), clon.ravel())
73+
assert np.array_equal(j, jj.ravel())
74+
assert np.array_equal(i, ii.ravel())
75+
76+
77+
def test_hash_entry_budget():
78+
"""When the requested bitwidth would blow the hash-entry budget (e.g. tilted
79+
regional spherical meshes, where face bounding boxes overlap 3-D blocks of
80+
hash cells), the resolution is reduced to fit; queries still resolve exactly.
81+
"""
82+
ds = datasets["2d_left_rotated"]
83+
grid = FieldSet.from_sgrid_conventions(ds, mesh="spherical").data_g.grid
84+
spatialhash = grid.get_spatial_hash()
85+
86+
budget = max(_HASH_ENTRIES_PER_FACE * np.size(spatialhash._xlow), _HASH_ENTRY_BUDGET_MIN)
87+
assert spatialhash._total_hash_entries(1023) > budget # this grid requires the cap
88+
assert spatialhash._bitwidth < 1023
89+
assert spatialhash._total_hash_entries(spatialhash._bitwidth) <= budget
90+
assert spatialhash._hash_table["faces"].size <= budget
91+
92+
clat, clon, jj, ii = _cell_centers(grid)
93+
j, i, _ = spatialhash.query(clat.ravel(), clon.ravel())
94+
assert np.array_equal(j, jj.ravel())
95+
assert np.array_equal(i, ii.ravel())
96+
97+
5798
def test_mixed_positions():
5899
ds = datasets["2d_left_rotated"]
59100
grid = FieldSet.from_sgrid_conventions(ds, mesh="flat").data_g.grid

0 commit comments

Comments
 (0)