Skip to content

Screen bounding-box subset candidates on node coordinates before computing bounds - #1779

Open
rajeeja wants to merge 7 commits into
mainfrom
rajeeja/bbox-subset-prefilter
Open

rajeeja wants to merge 7 commits into
mainfrom
rajeeja/bbox-subset-prefilter

Conversation

@rajeeja

@rajeeja rajeeja commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1778 (its first point; the face_node_connectivity cost is a reader problem and stays open in #1778's discussion).

Overview

subset.bounding_box computed Grid.bounds for every face before testing which fall inside the box, so a small crop of a large mesh cost memory and time proportional to the mesh.

It now screens faces on their node coordinates first: a face's bounds contain each of its nodes, so a face can only lie inside the box if all its nodes do. Bounds are computed for those candidates only, on the nodes they use. The screen needs no margin and makes no assumption about face size or shape. The result is identical to the whole-mesh path, and cached bounds are used when present.

Along the way, the bounds kernel dropped a node from its own face's bounds when both adjacent edges bulge poleward (70 faces in geoflow-small); fixed in the first commit, since the screen relies on bounds containing the nodes.

faces_within_lon_bounds / faces_within_lat_bounds docstrings now say what they do (containment, not overlap).

Measured

12-corner SCRIP mesh from the #1778 MCVE, crop of ~0.007% of faces:

n_face before after
400,000 0.19 GiB, 0.77 s 0.003 GiB, 0.00 s
1,600,000 0.78 GiB, 2.77 s 0.003 GiB, 0.02 s

HEALPix zoom 8 (786k faces), Texas box: 88 MB / 973 ms → 13 MB / 11 ms.

Testing

TestBoundingBoxSubset compares the screened path against the whole-mesh path on 7 grids × 12 boxes (polar, antimeridian-crossing, whole globe, empty), asserts bounds is never populated, counts the faces the bounds kernel sees, checks cached bounds are used, that faces straddling a box edge are excluded, nodes on the box edge, antimeridian and pole faces explicitly, and an empty region on a partial grid. test_face_bounds_contain_every_node pins the kernel fix.

New benchmarks/subset.py: time_ and track_peakmem_ for bounding_box on fresh oQU and HEALPix grids.

PR Checklist

General

  • An issue is created and linked
  • Added appropriate labels
  • Filled out Overview and Expected Usage (if applicable) sections

Testing & Benchmarking

  • There is adequate test coverage of changes from this PR
  • If this PR could affect performance, ran ASV benchmarks and confirmed they show expected behavior (added benchmarks/subset.py)

Documentation and Examples

  • Docstrings updated with any function changes, and included in all new functions
  • [N/A] User (public) functions added to docs/api.rst
  • [N/A] If touched any notebook files, cleared the output of all cells before committing
  • [N/A] If added new notebook files, put into appropriate directories and referenced in appropriate files

AI Disclosure

AI Usage: Claude

  • I have tested and take responsibility for all AI-generated content in my PR.

subset.bounding_box asked for Grid.bounds, which builds a per-face box for
every face in the mesh -- materializing the node coordinate and connectivity
arrays to do it -- and only then tested which faces fall inside the region.
The cost was proportional to the mesh rather than the crop, so cropping a
few hundred faces out of a large grid needed memory proportional to the
whole grid. On a 300M-face SCRIP mesh that exceeds the machine, and the
subset is not slow but impossible (#1778).

Face latitudes are already resident, so they now discard faces that cannot
be in the result before any bounds are computed; exact bounds are built for
the survivors only. Measured on a 12-corner SCRIP mesh, crop ~0.007% of the
faces:

  1,600,000 faces   0.84 GiB, 2.6 s  ->  0.00 GiB, 0.8 s
  3,200,000 faces   1.48 GiB, 5.1 s  ->  0.00 GiB, 1.6 s

Two things measurement settled that reasoning got wrong. The first attempt
filtered longitude on face centres too, with a margin sized from the mean
face area; that is unsafe near the poles, where a face is recorded as
spanning every longitude and its bounds sit up to 354 degrees from its own
centre on a HEALPix z3 grid. No finite margin covers that, so longitude is
left to the exact stage. The second attempt kept a latitude margin for the
same reason -- and it is unnecessary, because faces_within_lat_bounds keeps
a face only when its bounds are fully contained in the interval, despite the
docstring saying "overlap". A contained face has a contained centre, so the
filter is conservative by equality rather than by a fudge factor.

The exact path is unchanged below a million faces and whenever bounds are
already computed, since the filter cannot repay itself there.

Tests compare the filtered and exact paths across three resolutions and
eight boxes including antimeridian-spanning and polar cases, and pin the two
measurements the design rests on: that longitude bounds reach far beyond a
face centre, and that the latitude test is containment rather than overlap.
If either stops being true the pre-filter is wrong and these fail.
The latitude prefilter alone leaves a global belt: every face at the crop's
latitude, at every longitude. On a HEALPix mesh a Texas-sized box still kept
8.3% of the faces.

Longitude needs a margin where latitude did not, and the margin has to widen
towards the poles because meridians converge -- a fixed-area face spans more
longitude the higher it sits. Normalising the measured reach by 1/cos(lat)
collapses it to a constant 4.39 face widths across zooms 4, 5 and 6, which is
what makes the bound derivable rather than tuned. Eight face widths,
latitude-corrected, had zero violations from zoom 3 through 7.

Two kinds of face cannot be filtered on a centre at all and are always kept:
one whose bounds are stored min > max crosses the antimeridian, so its centre
is not between them, and one containing a pole is recorded as spanning every
longitude. Both are under 1% of faces by zoom 6.

Texas candidates fall from 8.3% to 0.5% of the mesh, improving with
resolution. 44 of 44 boxes return exactly what the unfiltered path returns,
including antimeridian-spanning and polar queries.
@Sevans711 Sevans711 added scalability Related to scalability & performance efforts run-benchmark Run ASV benchmark workflow labels Sep 23, 2026
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have improved:

Change Before [379c895] <v2026.09.1> After [1541144] Ratio Benchmark (Parameter)
- 351M 317M 0.9 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
- 351M 318M 0.9 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
- 351M 319M 0.91 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
- 351M 318M 0.9 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
- 351M 275M 0.78 import.Imports.track_peakmem_import_uxarray
- 351M 312M 0.89 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('480km')
- 88.2±0.3ms 11.3±0.6ms 0.13 subset.BoundingBox.time_bounding_box('120km')
- 15.7±0.2ms 6.17±0.2ms 0.39 subset.BoundingBox.time_bounding_box('480km')
- 3.38M 2.38M 0.7 subset.BoundingBox.track_peakmem_bounding_box('120km')
- 328k 203k 0.62 subset.BoundingBox.track_peakmem_bounding_box('480km')
- 113±1ms 3.94±0.3ms 0.03 subset.BoundingBoxHealpix.time_bounding_box(6)
- 1.75±0.01s 15.5±0.7ms 0.01 subset.BoundingBoxHealpix.time_bounding_box(8)
- 5.53M 2.42M 0.44 subset.BoundingBoxHealpix.track_peakmem_bounding_box(6)
- 88.3M 13.2M 0.15 subset.BoundingBoxHealpix.track_peakmem_bounding_box(8)

Benchmarks that have stayed the same:

Change Before [379c895] <v2026.09.1> After [1541144] Ratio Benchmark (Parameter)
5.36±0.05ms 5.37±0.05ms 1.00 bench_connectivity.Connectivity.time_edge_face('120km')
1.99±0.01ms 2.00±0.02ms 1.01 bench_connectivity.Connectivity.time_edge_face('480km')
4.34±0.04ms 4.46±0.03ms 1.03 bench_connectivity.Connectivity.time_edge_node('120km')
1.57±0.01ms 1.61±0.02ms 1.03 bench_connectivity.Connectivity.time_edge_node('480km')
4.39±0.04ms 4.49±0.05ms 1.02 bench_connectivity.Connectivity.time_face_edge('120km')
1.58±0ms 1.59±0.02ms 1.01 bench_connectivity.Connectivity.time_face_edge('480km')
6.27±0.04ms 6.24±0.07ms 1.00 bench_connectivity.Connectivity.time_face_face('120km')
2.40±0.03ms 2.36±0.02ms 0.98 bench_connectivity.Connectivity.time_face_face('480km')
53.1±0.6μs 54.5±1μs 1.03 bench_connectivity.Connectivity.time_face_node('120km')
52.6±0.3μs 53.5±2μs 1.02 bench_connectivity.Connectivity.time_face_node('480km')
432±10μs 440±10μs 1.02 bench_connectivity.Connectivity.time_n_nodes_per_face('120km')
384±20μs 363±8μs 0.95 bench_connectivity.Connectivity.time_n_nodes_per_face('480km')
5.70±0.1ms 5.76±0.04ms 1.01 bench_connectivity.Connectivity.time_node_edge('120km')
2.02±0.02ms 2.03±0.01ms 1.00 bench_connectivity.Connectivity.time_node_edge('480km')
80.3±4ms 81.2±2ms 1.01 bench_connectivity.Connectivity.time_node_face('120km')
5.13±0.03ms 5.08±0.01ms 0.99 bench_connectivity.Connectivity.time_node_face('480km')
8.34±0.07ms 8.54±0.1ms 1.02 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.82±0.1ms 2.74±0.03ms 0.97 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
7.02±7s 10.3±10ms ~0.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.52±0.01ms 1.57±0.02ms 1.03 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
57.3k 57.3k 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
12.3k 12.3k 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
123k 123k 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
128 128 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.27M 1.27M 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
50.1k 50.1k 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
1.48M 1.48M 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
712 712 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.98M 1.98M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
1.97M 1.98M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
2.13M 2.14M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
35.5k 35.5k 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.18±0.04μs 1.13±0.03μs 0.96 geometry_kernels.AccucrossKernels.time_accucross
2.58±0.01μs 2.55±0.01μs 0.98 geometry_kernels.AccucrossKernels.time_accucross_pair
606±200ns 576±200ns 0.95 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
436±20ns 435±10ns 1.00 geometry_kernels.EFTPrimitives.time_diff_of_products
376±10ns 381±20ns 1.01 geometry_kernels.EFTPrimitives.time_two_prod
386±5ns 371±20ns 0.96 geometry_kernels.EFTPrimitives.time_two_sum
682±20ns 671±20ns 0.98 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
766±30ns 726±20ns 0.95 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
792±20ns 792±20ns 1.00 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
847±40ns 812±30ns 0.96 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
882±9ns 921±9ns 1.04 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
1.04±0.03μs 1.05±0.02μs 1.01 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
52.7±0.6μs 52.4±0.6μs 0.99 geometry_kernels.OrientPredicates.time_on_minor_arc
51.5±0.7μs 52.2±1μs 1.01 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
3.06±0ms 3.06±0ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_dispatch
1.16±0ms 1.16±0ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_kernel
2.28±0ms 2.29±0.01ms 1.01 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
147±2μs 147±0.7μs 1.01 geometry_samebody.SameBodyConstLat.time_fp64_kernel
29.6±0.05ms 29.6±0.07ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
6.35±0.06ms 6.28±0ms 0.99 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
23.6±0.05ms 23.6±0.07ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
861±5μs 860±1μs 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
942±9ms 927±7ms 0.98 import.Imports.timeraw_import_uxarray
2.48±0.01ms 2.66±0.01ms 1.07 mpas_ocean.CheckNorm.time_check_norm('120km')
2.05±0.02ms 2.07±0.04ms 1.01 mpas_ocean.CheckNorm.time_check_norm('480km')
1.16±0.01ms 1.17±0.02ms 1.01 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
554±10μs 554±10μs 1.00 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
662±6μs 651±10μs 0.98 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
610±10μs 619±6μs 1.02 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
5.31±0.03ms 5.35±0.03ms 1.01 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
4.00±0.1ms 3.84±0.02ms 0.96 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
97.1±0.3ms 97.1±0.3ms 1.00 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
10.0±0.1ms 9.71±0.4ms 0.97 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
18.6±0.03ms 18.7±0.09ms 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
981±20μs 971±20μs 0.99 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
10.0±0.09ms 10.0±0.1ms 1.00 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
613±10μs 616±10μs 1.00 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
599±3ms 609±7ms 1.02 mpas_ocean.CrossSections.time_const_lat('120km', 1)
301±2ms 307±2ms 1.02 mpas_ocean.CrossSections.time_const_lat('120km', 2)
154±1ms 164±5ms 1.06 mpas_ocean.CrossSections.time_const_lat('120km', 4)
555±8ms 552±1ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 1)
271±1ms 281±5ms 1.04 mpas_ocean.CrossSections.time_const_lat('480km', 2)
139±2ms 144±4ms 1.03 mpas_ocean.CrossSections.time_const_lat('480km', 4)
351M 337M 0.96 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 1)
351M 337M 0.96 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 2)
351M 338M 0.96 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 4)
351M 320M 0.91 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 1)
351M 321M 0.91 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 2)
351M 320M 0.91 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 4)
25.7±0.2ms 26.4±0.1ms 1.03 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
3.00±0.1ms 2.97±0.1ms 0.99 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
14.7±0.7ms 14.6±0.6ms 0.99 mpas_ocean.FaceAreas.time_face_areas('120km')
4.43±0.3ms 4.39±0.3ms 0.99 mpas_ocean.FaceAreas.time_face_areas('480km')
229k 229k 1.00 mpas_ocean.FaceAreas.track_nbytes_face_areas('120km')
14.3k 14.3k 1.00 mpas_ocean.FaceAreas.track_nbytes_face_areas('480km')
2.12M 2.12M 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('120km')
714k 715k 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('480km')
899±10ms 914±6ms 1.02 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
50.0±0.4ms 51.1±1ms 1.02 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
81.1±0.4ms 81.6±0.3ms 1.01 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
4.86±0.3ms 5.30±0.2ms 1.09 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
13.1±0.05ms 13.7±0.2ms 1.05 mpas_ocean.Gradient.time_gradient('120km')
1.80±0.02ms 1.81±0.01ms 1.01 mpas_ocean.Gradient.time_gradient('480km')
457k 457k 1.00 mpas_ocean.Gradient.track_nbytes_gradient('120km')
28.7k 28.7k 1.00 mpas_ocean.Gradient.track_nbytes_gradient('480km')
3.2M 3.2M 1.00 mpas_ocean.Gradient.track_peakmem_gradient('120km')
204k 204k 1.00 mpas_ocean.Gradient.track_peakmem_gradient('480km')
351M 332M 0.95 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('120km')
274±5μs 284±10μs 1.04 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
147±4μs 149±1μs 1.01 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
202±10μs 205±10μs 1.02 mpas_ocean.Integrate.time_integrate('120km')
187±1μs 188±9μs 1.00 mpas_ocean.Integrate.time_integrate('480km')
18.4M 18.4M 1.00 mpas_ocean.Integrate.track_nbytes_integrate('120km')
1.2M 1.2M 1.00 mpas_ocean.Integrate.track_nbytes_integrate('480km')
186±2ms 185±0.8ms 1.00 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
187±3ms 187±3ms 1.00 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
184±0.9ms 186±1ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
13.0±0.05ms 13.2±0.1ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
13.0±0.07ms 13.2±0.2ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
13.0±0.06ms 13.1±0.1ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
248±1ms 247±1ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 1.0)
1.31±0.01s 1.31±0s 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 15.0)
504±2ms 507±1ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('120km', 5.0)
13.4±0.1ms 13.3±0.01ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('480km', 1.0)
25.1±0.1ms 25.4±0.1ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('480km', 15.0)
16.4±0.03ms 16.5±0.08ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('480km', 5.0)
241±0.3ms 241±0.4ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 1.0)
1.28±0s 1.29±0s 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 15.0)
504±3ms 503±3ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 5.0)
12.9±0.01ms 12.9±0.03ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 1.0)
24.8±0.1ms 25.4±0.1ms 1.02 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 15.0)
16.2±0.1ms 16.1±0.09ms 0.99 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 5.0)
1.19 1.19 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('120km', 1.0)
612.76 612.76 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('120km', 15.0)
74.17 74.17 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('120km', 5.0)
1.0 1.0 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('480km', 1.0)
37.29 37.29 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('480km', 15.0)
6.57 6.57 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('480km', 5.0)
728k 728k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('120km', 1.0)
141M 141M 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('120km', 15.0)
17.4M 17.4M 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('120km', 5.0)
43k 43k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('480km', 1.0)
563k 563k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('480km', 15.0)
123k 123k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('480km', 5.0)
5.72M 5.72M 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('120km', 1.0)
145M 145M 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('120km', 15.0)
21.5M 21.5M 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('120km', 5.0)
362k 362k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 1.0)
825k 825k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 15.0)
384k 384k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 5.0)
44.4±0.9ms 44.3±0.8ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'grid_chunks')
22.6±0.06ms 22.7±0.06ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'numpy')
40.4±0.7ms 41.1±0.7ms 1.02 mpas_ocean.NeighborhoodDask.time_mean('120km', 'time_chunks')
11.5±0.2ms 11.8±0.2ms 1.03 mpas_ocean.NeighborhoodDask.time_mean('480km', 'grid_chunks')
683±20μs 694±20μs 1.02 mpas_ocean.NeighborhoodDask.time_mean('480km', 'numpy')
8.20±0.2ms 8.66±0.4ms 1.06 mpas_ocean.NeighborhoodDask.time_mean('480km', 'time_chunks')
5.84M 5.84M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'grid_chunks')
2.75M 2.75M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'numpy')
5.69M 5.68M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'time_chunks')
685k 676k 0.99 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'grid_chunks')
177k 177k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'numpy')
546k 542k 0.99 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'time_chunks')
12.6±0.02s 12.7±0s 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'mean')
13.3±0.02s 13.3±0.01s 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'median')
225±3ms 229±1ms 1.02 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'mean')
230±1ms 235±0.9ms 1.02 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'median')
1.35±0s 1.35±0s 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'mean')
1.54±0s 1.54±0s 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'median')
26.1±0.1ms 26.2±0.2ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'mean')
27.2±0.1ms 27.3±0.3ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'median')
38.0±0.09ms 38.0±0.2ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'mean')
232±0.9ms 232±0.4ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'median')
477±60μs 533±30μs ~1.12 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'mean')
1.67±0.03ms 1.70±0.04ms 1.01 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'median')
239k 239k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('120km', 'mean')
245k 245k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('120km', 'median')
19.4k 19.4k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'mean')
19.9k 19.9k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'median')
425±20μs 430±3μs 1.01 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
411±10μs 407±10μs 0.99 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
400±4μs 385±10μs 0.96 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
381±10μs 383±10μs 1.01 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
132±0.2ms 131±0.5ms 1.00 mpas_ocean.RemapDownsample.time_bilinear_remapping
17.6±0.2ms 17.4±0.1ms 0.99 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
15.6±0.2ms 15.7±0.09ms 1.00 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
1.42±0s 1.43±0.01s 1.01 mpas_ocean.RemapUpsample.time_bilinear_remapping
27.1±0.1ms 26.4±0.4ms 0.97 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
12.1±0.2ms 13.0±1ms 1.08 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
8.47±0.1ms 8.44±0.09ms 1.00 mpas_ocean.ZonalAverage.time_zonal_average('120km')
5.32±0.1ms 5.37±0.07ms 1.01 mpas_ocean.ZonalAverage.time_zonal_average('480km')
351M 339M 0.96 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('120km')
351M 322M 0.92 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('480km')
1.0219526243007233 1.0290990073313415 1.01 nogil_scaling.GILScaling.track_gil_scaling
7.41±0.4ms 7.37±0.03ms 0.99 quad_hexagon.QuadHexagon.time_open_dataset
6.44±0.3ms 6.25±0.05ms 0.97 quad_hexagon.QuadHexagon.time_open_grid
408 408 1.00 quad_hexagon.QuadHexagon.track_nbytes_open_dataset
392 392 1.00 quad_hexagon.QuadHexagon.track_nbytes_open_grid
72.9k 73.7k 1.01 quad_hexagon.QuadHexagon.track_peakmem_open_dataset
72.1k 72.8k 1.01 quad_hexagon.QuadHexagon.track_peakmem_open_grid

@Sevans711 Sevans711 left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great idea. It definitely still needs some work before it is ready to merge; see inline comments. Two more notes here:

(1) This doesn't fully close the original issue; #1778 is scoped to include the face_node_connectivity issue too. The original message in this PR should be updated accordingly, to avoid the "Closes #XXX" pattern. (EDIT: actually, upon a more careful read through of 1778, it's a bit unclear to me what the intent was. The title seems to indicate it is scoped just to the bounding-box subset issue, but the text seems to include the connectivity. If you meant to scope it only to the former, please clarify, and then feel free to keep this PR marked as "would close 1778"!)

(2) The ASV benchmarks suite does not show any relevant improvements. Can benchmarks be added which show improvement from this PR? Ideally, at least one showing memory improvement, and at least one showing speed improvement. That would help prevent performance degradation in the future.

Comment thread test/test_subset.py Outdated
Comment on lines +386 to +391
def test_a_precomputed_bounds_array_is_used_as_is(self):
"""A grid that already has ``bounds`` should not pay for the filter.

The saving comes from *not* building the whole-mesh index. Once it
exists the exact path is free, and taking the filter anyway would add
work for nothing.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this test is actually doing what its docstring suggests? It doesn't look like there is any check here which confirms that the grid has not paid for the filter; the code just tests whether the result is equal to what was expected (which is redundant with the test_prefilter_matches_the_exact_path test above).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced. test_bounds_are_computed_for_candidates_only now counts the faces the bounds kernel receives and asserts bounds is never populated; every screened call also asserts that.

Comment thread test/test_subset.py Outdated
Comment on lines +403 to +405
def test_an_empty_region_returns_no_faces(self):
"""A box over open ocean far from any face must come back empty, not
raise -- the candidate array is empty before the exact stage runs."""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, this test is not doing what its name and docstring suggest. The reason the result has length 0 here is that the bounds are tiny, so no face is fully contained within them; there is no empty region anywhere here at all (healpix grids span the entire sphere).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced with test_region_outside_a_partial_grid_is_empty, which uses the quad-hexagon grid and a box that is genuinely far from it.

Comment thread test/test_subset.py Outdated
Comment on lines +420 to +432
def test_a_polar_face_is_not_excluded_by_its_centre(self):
"""Longitude must not be filtered on face centres.

A face containing a pole is recorded as spanning every longitude, so
its bounds sit up to 354 degrees from its own centre on a HEALPix z3
grid. An early version of the pre-filter tested longitude centres
with a generous margin and still would have dropped those faces for
a narrow box -- no finite margin can cover a 354 degree gap. The
equivalence tests above did not catch it, because the boxes they use
happen not to need any face whose centre lies outside them.

This pins the measurement directly rather than through a subset, so
the reason survives even if the filter is rewritten.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, this test is not doing what its name and docstring suggest. Please fix name and docstring, or fix test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped. The centre filter and its margin are gone, so there is nothing for this test to pin.

Comment thread test/test_subset.py Outdated
"""The assumption the pre-filter rests on, checked rather than trusted.

``faces_within_lat_bounds`` reads as an overlap test -- its docstring
says "overlap" -- but the implementation keeps a face only when its

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That function's docstring should just be updated as part of this PR, instead of explaining this as a quirk in multiple places.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, both docstrings now say containment. The test is gone with the centre filter; test_partially_contained_faces_are_excluded now checks straddling faces directly.

Comment thread test/test_subset.py Outdated
Comment on lines +475 to +490
straddlers = 0
rng = np.random.default_rng(0)
for _ in range(40):
la = rng.uniform(-70, 60)
lat_bounds = [la, la + 4]
kept = grid.get_faces_between_latitudes(lat_bounds)
centre_inside = np.flatnonzero(
(face_lat >= lat_bounds[0]) & (face_lat <= lat_bounds[1])
)
straddlers += len(np.setdiff1d(kept, centre_inside))

assert straddlers == 0, (
f"{straddlers} faces were kept whose centre lies outside the "
"query interval, so the latitude filter is now an overlap test "
"and the centre pre-filter would silently drop them"
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this actually confirms that you've tested any relevant points? I would have expected a test like this to instead choose lat bounds based on face bounds, checking what happens to faces which are only-partially contained within the lat bounds. I think this is another case of the test contents not actually corresponding to the docstring.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten as you suggest: the box edge is placed through the middle of a face taken from the reference bounds, every straddling face must be excluded, and moving the edge to that face's own bound must bring exactly it in.

Comment thread uxarray/subset/grid_accessor.py Outdated
Comment on lines +481 to +495
# Longitude needs a margin, unlike latitude, and the margin has to widen
# towards the poles. Meridians converge, so a face of fixed area spans
# more longitude the higher its latitude -- the same cell that covers a
# degree at the equator covers many near the pole. Normalising the
# measured reach by ``1 / cos(lat)`` collapses it to a constant 4.39 face
# widths across HEALPix zooms 4, 5 and 6, which is what makes a bound
# derivable rather than tuned: 8 face widths, latitude-corrected, had zero
# violations across zooms 3 through 7.
#
# Two cases cannot be filtered on a centre at all and are always kept.
# A face whose bounds are stored min > max crosses the antimeridian, so
# its "centre" is not between them. A face containing a pole is recorded
# as spanning every longitude. Both are rare -- under 1% of faces by zoom
# 6 -- so keeping them unconditionally costs almost nothing, and the exact
# stage discards them if they do not belong.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed along with the face-width margin.

Comment thread uxarray/subset/grid_accessor.py Outdated
Comment on lines +503 to +504
face_width = np.degrees(np.sqrt(4.0 * np.pi / max(int(uxgrid.n_face), 1)))
margin = 8.0 * face_width / np.maximum(np.cos(np.radians(face_lat)), _MIN_COS_LAT)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What?

This function can be run with any grid, not just healpix. I think these are healpix estimates for face width. I could easily make a grid with a different face width. Right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You were right, that was HEALPix-specific. The new screen uses no face width at all: a face is a candidate iff all of its nodes lie in the box, which holds for any mesh.

Comment thread uxarray/subset/grid_accessor.py Outdated
bounds_lon = np.asarray(uxgrid.face_bounds_lon.values)

unfilterable = (bounds_lon[:, 0] > bounds_lon[:, 1]) | (
(bounds_lon[:, 0] <= -179.999) & (bounds_lon[:, 1] >= 179.999)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this actually doing? Why <= 179.999 and not <= 179.9999999 or <= 179.9 or < 180?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gone. Pole faces are no longer special-cased; their bounds are [-180, 180], so the exact test on candidates handles them.

Comment thread uxarray/subset/grid_accessor.py Outdated
Comment on lines +519 to +525
np.asarray(uxgrid.face_node_connectivity.values)[candidates],
np.asarray(uxgrid.n_nodes_per_face.values)[candidates],
np.asarray(uxgrid.node_x.values),
np.asarray(uxgrid.node_y.values),
np.asarray(uxgrid.node_z.values),
np.asarray(uxgrid.node_lon.values),
np.asarray(uxgrid.node_lat.values),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think any of these asarray calls are necessary? It probably won't hurt to include (except in special case of subclass, where np.asarray converts to numpy array while np.asanyarray does not), but these aren't included in other parts of uxarray from what I can tell, and it feels like better style to keep them out unless there is any plausible reason to expect that these .values objects won't be numpy arrays already.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

Comment thread uxarray/subset/grid_accessor.py Outdated
keep_lon = faces_within_lon_bounds(lon_bounds, bounds_lon)
keep_lat = faces_within_lat_bounds(lat_bounds, bounds_lat)

return candidates[np.intersect1d(keep_lon, keep_lat)]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't fully understand the longitude-handling logic in this function. That doesn't mean it is wrong, just leaving a reminder to myself to take a closer look and try to understand it before approving things.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The longitude logic is now the same as faces_within_lon_bounds: a node is in the interval when lon_min <= lon <= lon_max, or, for a box with lon_min > lon_max, when it is in either half. Nodes are normalized to [-180, 180) as the bounds are.

Each edge contributed either its interior latitude extreme or its first
node, never both, so a face whose edges all bulge poleward lost its lowest
node from its own bounds. geoflow-small has 70 such faces.
A face's bounds contain each of its nodes, so a face can only lie inside
the box if all its nodes do. Bounds are then computed for those candidates
alone, on the nodes they use, so the cost follows the region rather than
the mesh. Replaces the centre-based filter and its tuned margins.
@rajeeja rajeeja changed the title Filter a bounding-box subset on face latitudes before computing bounds Screen bounding-box subset candidates on node coordinates before computing bounds Sep 24, 2026
@rajeeja

rajeeja commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

@Sevans711 Reworked in 7e577f7..dd351b0: the centre filter is replaced by a node-coordinate screen with no margins, the bounds kernel now includes every node (it was dropping some), tests rewritten to check what they say, and benchmarks/subset.py added for time and peak memory. Description updated; #1778's connectivity point stays open there. Could you re-review?

@rajeeja
rajeeja requested a review from Sevans711 September 24, 2026 22:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-benchmark Run ASV benchmark workflow scalability Related to scalability & performance efforts

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bounding-box subset allocates memory proportional to the whole mesh, not the crop

2 participants