Skip to content

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

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

rajeeja wants to merge 11 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 [04e2ace] After [ab72182] Ratio Benchmark (Parameter)
- 2.70±0.07ms 2.42±0.03ms 0.9 mpas_ocean.CheckNorm.time_check_norm('120km')
- 87.5±0.3ms 10.5±0.5ms 0.12 subset.BoundingBox.time_bounding_box('120km')
- 15.4±0.5ms 5.95±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')
- 1.67±0.01s 15.5±0.5ms 0.01 subset.BoundingBoxHealpix.time_bounding_box(8)
- 5.53M 2.26M 0.41 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 [04e2ace] After [ab72182] Ratio Benchmark (Parameter)
966±20μs 970±20μs 1.00 connectivity.Connectivity.time_edge_face('120km')
464±20μs 466±10μs 1.00 connectivity.Connectivity.time_edge_face('480km')
6.78±0.1ms 6.83±0.07ms 1.01 connectivity.Connectivity.time_edge_node('120km')
1.23±0.03ms 1.24±0.03ms 1.01 connectivity.Connectivity.time_edge_node('480km')
75.9±7μs 84.5±8μs ~1.11 connectivity.Connectivity.time_face_edge('120km')
67.6±10μs 62.3±2μs 0.92 connectivity.Connectivity.time_face_edge('480km')
1.04±0.04ms 1.00±0.03ms 0.96 connectivity.Connectivity.time_face_face('120km')
424±30μs 391±20μs 0.92 connectivity.Connectivity.time_face_face('480km')
55.8±20μs 59.4±10μs 1.06 connectivity.Connectivity.time_face_node('120km')
65.8±20μs 60.7±6μs 0.92 connectivity.Connectivity.time_face_node('480km')
448±30μs 463±30μs 1.03 connectivity.Connectivity.time_n_nodes_per_face('120km')
388±30μs 397±20μs 1.02 connectivity.Connectivity.time_n_nodes_per_face('480km')
1.38±0.04ms 1.34±0.04ms 0.97 connectivity.Connectivity.time_node_edge('120km')
490±20μs 485±20μs 0.99 connectivity.Connectivity.time_node_edge('480km')
96.8±0.9ms 96.1±0.7ms 0.99 connectivity.Connectivity.time_node_face('120km')
5.69±0.07ms 5.58±0.06ms 0.98 connectivity.Connectivity.time_node_face('480km')
1.42M 1.42M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_face('120km')
105k 105k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_face('480km')
6.48M 6.48M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_node('120km')
419k 419k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_node('480km')
2.05k 2.05k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_edge('120km')
2.05k 2.05k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_edge('480km')
1.6M 1.6M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_face('120km')
101k 101k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_face('480km')
2.18k 2.18k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_node('120km')
2.18k 2.18k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_node('480km')
240k 240k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_n_nodes_per_face('120km')
26k 26k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_n_nodes_per_face('480km')
1.9M 1.9M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_edge('120km')
127k 127k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_edge('480km')
11.9M 11.9M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_face('120km')
747k 747k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_face('480km')
8.34±0.2ms 8.44±0.08ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.64±0.1ms 2.60±0.04ms 0.99 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
9.80±10ms 10.7±10ms 1.09 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.47±0.03ms 1.50±0.08ms 1.02 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'))
2.02M 1.84M 0.91 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.01M 1.83M 0.91 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
2.17M 1.99M 0.92 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
35.3k 35.3k 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.21±0.02μs 1.19±0.04μs 0.99 geometry_kernels.AccucrossKernels.time_accucross
2.75±0.05μs 2.71±0.05μs 0.98 geometry_kernels.AccucrossKernels.time_accucross_pair
501±20ns 526±20ns 1.05 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
461±30ns 500±30ns 1.09 geometry_kernels.EFTPrimitives.time_diff_of_products
406±10ns 451±40ns ~1.11 geometry_kernels.EFTPrimitives.time_two_prod
396±30ns 470±20ns ~1.19 geometry_kernels.EFTPrimitives.time_two_sum
721±30ns 686±50ns 0.95 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
757±40ns 756±40ns 1.00 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
861±30ns 867±40ns 1.01 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
852±40ns 852±40ns 1.00 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
957±40ns 982±20ns 1.03 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
1.11±0.03μs 1.13±0.04μs 1.02 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
50.4±0.5μs 51.0±0.4μs 1.01 geometry_kernels.OrientPredicates.time_on_minor_arc
50.7±0.7μs 51.8±3μs 1.02 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
3.05±0ms 3.07±0.03ms 1.01 geometry_samebody.SameBodyConstLat.time_accux_dispatch
1.16±0.01ms 1.17±0ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_kernel
2.31±0.01ms 2.30±0.01ms 1.00 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
148±3μs 148±0.6μs 1.00 geometry_samebody.SameBodyConstLat.time_fp64_kernel
29.7±0.02ms 29.7±0.02ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
6.36±0.01ms 6.30±0.01ms 0.99 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
23.7±0.05ms 24.2±0.05ms 1.02 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
863±1μs 862±0.3μs 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
957±20ms 967±10ms 1.01 import.Imports.timeraw_import_uxarray
1 1 1.00 lazy_grid_construction.LazyGridConstruction.track_computes_isel
2 2 1.00 lazy_grid_construction.LazyGridConstruction.track_computes_open_grid_chunked
107±1ms 103±2ms 0.96 lazy_grid_construction.OpenGridChunked.time_open_grid('120km')
153±1ms 144±2ms 0.94 lazy_grid_construction.OpenGridChunked.time_open_grid('480km')
1.99M 1.99M 1.00 lazy_grid_construction.OpenGridChunked.track_peakmem_open_grid('120km')
2.3M 2.3M 1.00 lazy_grid_construction.OpenGridChunked.track_peakmem_open_grid('480km')
1.97±0.01ms 1.97±0.1ms 1.00 mpas_ocean.CheckNorm.time_check_norm('480km')
1.14±0.01ms 1.12±0.01ms 0.98 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
508±10μs 513±20μs 1.01 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
617±7μs 626±20μs 1.02 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
565±10μs 567±10μs 1.00 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
5.49±0.2ms 5.08±0.03ms 0.93 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.66±0.03ms 3.62±0.02ms 0.99 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
96.6±2ms 97.5±0.1ms 1.01 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
9.26±0.04ms 9.66±0.2ms 1.04 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
18.9±0.06ms 18.8±0.03ms 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
1.02±0.04ms 998±30μs 0.98 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
10.3±0.1ms 10.3±0.04ms 1.00 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
670±30μs 654±30μs 0.98 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
669±7ms 681±6ms 1.02 mpas_ocean.CrossSections.time_const_lat('120km', 1)
340±10ms 345±9ms 1.02 mpas_ocean.CrossSections.time_const_lat('120km', 2)
177±2ms 175±3ms 0.99 mpas_ocean.CrossSections.time_const_lat('120km', 4)
608±20ms 624±10ms 1.03 mpas_ocean.CrossSections.time_const_lat('480km', 1)
297±2ms 298±3ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 2)
158±3ms 158±1ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 4)
25.8±0.2ms 25.5±0.1ms 0.99 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
3.04±0.04ms 3.02±0.07ms 0.99 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
14.5±0.6ms 15.2±0.5ms 1.05 mpas_ocean.FaceAreas.time_face_areas('120km')
4.31±0.1ms 4.62±0.3ms 1.07 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')
722k 721k 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('480km')
55.8±1ms 55.8±0.5ms 1.00 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', 'exclude')
165±0.8ms 166±2ms 1.00 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', 'split')
5.60±0.2ms 5.23±0.2ms 0.93 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', 'exclude')
32.9±0.7ms 31.2±0.5ms 0.95 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', 'split')
13.7±0.06ms 13.8±0.08ms 1.00 mpas_ocean.Gradient.time_gradient('120km')
2.40±0.01ms 2.39±0.01ms 1.00 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.44M 3.44M 1.00 mpas_ocean.Gradient.track_peakmem_gradient('120km')
226k 226k 1.00 mpas_ocean.Gradient.track_peakmem_gradient('480km')
278±10μs 275±6μs 0.99 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
146±6μs 147±0.5μs 1.00 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
194±10μs 172±4μs ~0.89 mpas_ocean.Integrate.time_integrate('120km')
167±10μs 164±20μs 0.98 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')
140±1ms 145±2ms 1.03 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
140±1ms 142±1ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
143±2ms 141±1ms 0.99 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
10.0±0.1ms 10.4±0.1ms 1.04 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
10.2±0.2ms 10.6±0.3ms 1.04 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
9.84±0.07ms 10.4±0.2ms 1.05 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
245±0.6ms 244±0.9ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 1.0)
507±7ms 509±5ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 5.0)
13.3±0.2ms 13.4±0.2ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('480km', 1.0)
16.5±0.1ms 16.5±0.03ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('480km', 5.0)
241±1ms 241±0.6ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 1.0)
501±3ms 506±3ms 1.01 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 5.0)
13.0±0.06ms 12.9±0.1ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 1.0)
16.5±0.06ms 16.4±0.2ms 0.99 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 5.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)
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)
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)
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)
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)
824k 824k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 15.0)
384k 384k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 5.0)
39.0±0.6ms 39.7±0.8ms 1.02 mpas_ocean.NeighborhoodDask.time_mean('120km', 'grid_chunks')
18.1±0.01ms 18.0±0.1ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'numpy')
35.6±0.7ms 35.9±0.6ms 1.01 mpas_ocean.NeighborhoodDask.time_mean('120km', 'time_chunks')
10.4±0.2ms 10.4±0.07ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('480km', 'grid_chunks')
618±10μs 616±9μs 1.00 mpas_ocean.NeighborhoodDask.time_mean('480km', 'numpy')
7.20±0.1ms 7.29±0.05ms 1.01 mpas_ocean.NeighborhoodDask.time_mean('480km', 'time_chunks')
5.82M 5.9M 1.01 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'grid_chunks')
2.75M 2.75M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'numpy')
5.67M 5.67M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'time_chunks')
754k 745k 0.99 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'grid_chunks')
177k 177k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'numpy')
528k 528k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'time_chunks')
4.61±0.05s 4.58±0.01s 0.99 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'mean')
4.71±0.03s 4.69±0.01s 0.99 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'median')
120±0.2ms 123±0.4ms 1.02 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'mean')
122±0.2ms 123±1ms 1.01 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'median')
517±3ms 510±5ms 0.99 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'mean')
552±2ms 549±4ms 0.99 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'median')
17.0±0.2ms 16.7±0.04ms 0.99 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'mean')
17.3±0.1ms 17.1±0.07ms 0.99 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'median')
4.66±0.05ms 4.53±0.03ms 0.97 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'mean')
38.0±0.05ms 37.5±0.2ms 0.99 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'median')
191±1μs 196±10μs 1.03 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'mean')
466±7μs 476±10μs 1.02 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'median')
234k 234k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('120km', 'mean')
235k 235k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('120km', 'median')
19.5k 19.5k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'mean')
19.6k 19.6k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'median')
393±10μs 416±10μs 1.06 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
389±10μs 382±10μs 0.98 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
368±20μs 359±10μs 0.98 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
367±10μs 367±30μs 1.00 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
109±0.9ms 109±0.3ms 1.00 mpas_ocean.RemapDownsample.time_bilinear_remapping
17.4±0.4ms 17.3±0.07ms 0.99 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
15.8±0.2ms 15.7±0.3ms 0.99 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
1.11±0.01s 1.12±0s 1.00 mpas_ocean.RemapUpsample.time_bilinear_remapping
27.1±0.2ms 27.7±0.5ms 1.02 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
11.9±0.3ms 11.9±0.03ms 1.00 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
8.24±0.1ms 8.30±0.2ms 1.01 mpas_ocean.ZonalAverage.time_zonal_average('120km')
5.09±0.1ms 5.07±0.1ms 1.00 mpas_ocean.ZonalAverage.time_zonal_average('480km')
1.0363214976749167 1.0487714655240818 1.01 nogil_scaling.GILScaling.track_gil_scaling
326±4ms 330±3ms 1.01 plots.PlotHealpix.time_default_plot(6)
2.91±0.01s 2.91±0.02s 1.00 plots.PlotHealpix.time_default_plot(8)
28M 28M 1.00 plots.PlotHealpix.track_peakmem_default_plot(6)
443M 443M 1.00 plots.PlotHealpix.track_peakmem_default_plot(8)
7.85±0.04ms 7.77±0.02ms 0.99 quad_hexagon.QuadHexagon.time_open_dataset
6.87±0.2ms 6.72±0.03ms 0.98 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
70.1k 70.1k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_dataset
69.7k 69.7k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_grid
108±0.2ms 3.98±9ms ~0.04 subset.BoundingBoxHealpix.time_bounding_box(6)
1.13±0.01s 1.12±0s 1.00 to_raster.ToRaster.time_to_raster((10000.0, 10.0))
2.85±0.01s 2.82±0.01s 0.99 to_raster.ToRaster.time_to_raster((10000.0, 100.0))
3.24±0.01s 3.23±0.01s 1.00 to_raster.ToRaster.time_to_raster((100000.0, 100.0))
270M 270M 1.00 to_raster.ToRaster.track_peakmem((10000.0, 10.0))
680M 680M 1.00 to_raster.ToRaster.track_peakmem((10000.0, 100.0))
765M 765M 1.00 to_raster.ToRaster.track_peakmem((100000.0, 100.0))

Benchmarks that have got worse:

Change Before [04e2ace] After [ab72182] Ratio Benchmark (Parameter)
+ 304M 385M 1.27 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
+ 304M 385M 1.27 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
+ 304M 385M 1.27 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
+ 304M 385M 1.27 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
+ 304M 385M 1.26 import.Imports.track_peakmem_import_uxarray
+ 321M 385M 1.2 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 1)
+ 321M 385M 1.2 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 2)
+ 321M 385M 1.2 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 4)
+ 305M 385M 1.26 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 1)
+ 305M 385M 1.26 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 2)
+ 305M 385M 1.26 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 4)
+ 317M 385M 1.22 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('120km')
+ 304M 385M 1.27 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('480km')
+ 323M 385M 1.19 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('120km')
+ 307M 385M 1.26 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('480km')

@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 thread test/test_subset.py Outdated
Comment thread test/test_subset.py Outdated
Comment thread test/test_subset.py Outdated
Comment thread test/test_subset.py Outdated
Comment thread uxarray/subset/grid_accessor.py Outdated
Comment thread uxarray/subset/grid_accessor.py Outdated
Comment thread uxarray/subset/grid_accessor.py Outdated
Comment thread uxarray/subset/grid_accessor.py Outdated
Comment thread uxarray/subset/grid_accessor.py Outdated
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

@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.

Getting much closer! Some small inline comments.

The ASV benchmarks look good and show the relevant changes, which addresses my previous comment (2).

For my previous comment (1), your updated description is actually even more confusing to me. Do you think the connectivity point is in scope or out of scope for the original issue? (in scope --> this PR shouldn't close the issue. out of scope --> this PR should close the issue.)

Comment thread uxarray/subset/grid_accessor.py Outdated
Comment thread test/test_subset.py
Comment thread test/grid/geometry/test_bounds.py Outdated
def test_face_bounds_contain_every_node(gridpath):
"""A face's latitude bounds must not be narrower than its own nodes.

Each edge used to contribute either its interior latitude extreme or 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.

What do you mean by "used to"? Is this relative to main, or relative to an earlier commit in this PR?

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.

Relative to main — the bounds kernel on main has this bug today. 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 (70 faces in geoflow-small). Fixed in this PR's first commit, 7e577f7.

It is in scope here because the screen relies on a face's bounds containing its nodes; without the fix the screen could drop a face the exact test keeps. Docstring now says all of that explicitly.

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.

Ah, I see. My concern here was mostly just that after this PR merges, language like "on main" and "used to" (referring to this PR versus main) and "this PR" won't really make sense anymore, because the test will become part of main. Could you update the language in the docstring so that it will still make sense after merging? This could be as simple as swapping those phrases to something like "before PR 1779" and "after PR 1779" or "fixed in PR 1779" instead.

…ian logic

- Move bounds/intersections/coordinates imports to module scope; no
  circular import. Monkeypatch targets in the tests move with them.
- Add a docstring to each test in TestBoundingBoxSubset saying what it
  pins and why.
- Spell out the lon_min > lon_max case in _faces_with_nodes_within_box:
  the box is the union of two intervals, so a node is outside only in
  the gap between them.
- test_face_bounds_contain_every_node: say the behaviour it pins is
  relative to main, fixed in this PR's first commit.
@rajeeja

rajeeja commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Updated with latest main (brings in #1767) and pushed 513c6a6 addressing all four open threads: imports moved to module scope, a docstring on every test in TestBoundingBoxSubset, the antimeridian "either half" logic spelled out in the docstring with an example, and the "used to" in test_face_bounds_contain_every_node now says it is relative to main.

Locally: test/test_subset.py, test/grid, test/core = 797 passed, pre-commit clean.

@Sevans711 ready for another look when you have time.

@rajeeja
rajeeja requested a review from Sevans711 October 5, 2026 17:05

@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.

Looking great overall. Still one minor nit (see the last remaining unresolved inline comment from prior review).

My only other question now is whether the increase to the nogil_scaling.GILScaling.track_gil_scaling benchmark is something we should be concerned about. @cmdupuis3 do you have any insights about what to expect from this benchmark in this PR? Is it an issue with the PR itself, or more likely to be noise?

Aside from those points, I think I would be ready to approve!

@cmdupuis3 cmdupuis3 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.

Overall it seems good, the improvements are verified. I think there is more optimization work that can be done, but I think there are a few tradeoffs of peakmem vs speed that I didn't want to impose on this PR. Basically, np.intersect1d dominates in _faces_in_bounding_box, and there's a related np.unique that implements a sort we don't need. I'm also curious if some of the caching on main can be salvaged if the bounding box is relatively large compared to the grid.

Comment thread uxarray/grid/bounds.py
return bounds_lon


@njit(cache=True, parallel=True, nogil=True)

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.

Suggested change
@njit(cache=True, parallel=True, nogil=True)
@parallel_njit

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

3 participants