Skip to content

Optimize azimuthal_mean with one sparse ring operator - #1847

Open
cmdupuis3 wants to merge 9 commits into
UXARRAY:mainfrom
cmdupuis3:cmd/lazy-azimuthal
Open

cmdupuis3 wants to merge 9 commits into
UXARRAY:mainfrom
cmdupuis3:cmd/lazy-azimuthal

Conversation

@cmdupuis3

@cmdupuis3 cmdupuis3 commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1848

Overview

Simple speedup for azimuthal means, and an incidental accuracy fix.

azimuthal_mean built a full subgrid for every ring (isel without ignore_grid), recomputed face_areas on it, and forced a compute per ring. The rings are disjoint, so the per-ring area-weighted means now form one sparse (n_radius, n_ring_faces) operator over the ring faces, gathered once with ignore_grid=True and weighted by the parent grid's areas. It is applied through a single apply_ufunc, so dask-backed input stays lazy.

Ring membership, hit counts, output dims/coords/attrs and NaN handling (NaN faces add nothing to the sum but still count toward the ring area) are unchanged. Accumulation is now float64: on float32 grids the old float32 sums were off by up to ~3e-7 relative against a float64 reference; the new result matches it to ~3e-16.

Adds AzimuthalAverage to the oQU benchmarks (21 rings, out to 20 degrees): 480km 43.9 ms -> 0.7 ms, 120km 50.0 ms -> 1.2 ms.

The old tests can be seen with these parameter combos:

Old test New test and parameters
test_gaussian test_monotone_profile[gaussian--1]: name="gaussian", sign=-1
test_inverse_gaussian test_monotone_profile[inverse_gaussian-1]: name="inverse_gaussian", sign=1
test_non_zero_hit_counts test_matches_reference[center0-None] and [center0-chunks1]: center=(45, 0), chunks is None or {"n_face": 50, "time": 1}
test_zero_hit_counts test_matches_reference[center1-None] and [center1-chunks1]: center=(-45, 0) (outside the subset), same two chunks values

PR Checklist

General

  • An issue is created and linked
  • Added appropriate labels (if your uxarray repo permissions allow it)
  • Filled out Overview and Expected Usage (if applicable) sections

Testing & Benchmarking

  • There is adequate test coverage of changes from this PR (add new tests if needed)
  • If this PR could affect performance, ran ASV benchmarks and confirmed they show expected behavior (add a new benchmark if necessary)

Documentation and Examples

  • Docstrings updated with any function changes, and included in all new functions
  • User (public) functions added to docs/api.rst; internal (private) function names start with an underscore (_)

AI Disclosure

AI Usage: Claude Opus 5.5

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

azimuthal_mean built a full subgrid for every ring (isel without
ignore_grid), recomputed face_areas on it, and forced a compute per ring.
The rings are disjoint, so the per-ring area-weighted means now form one
sparse (n_radius, n_ring_faces) operator over the ring faces, gathered
once with ignore_grid=True and weighted by the parent grid's areas. It is
applied through a single apply_ufunc, so dask-backed input stays lazy.

Ring membership, hit counts, output dims/coords/attrs and NaN handling
(NaN faces add nothing to the sum but still count toward the ring area)
are unchanged. Accumulation is now float64: on float32 grids the old
float32 sums were off by up to ~3e-7 relative against a float64
reference; the new result matches it to ~3e-16.

Adds AzimuthalAverage to the oQU benchmarks (21 rings, out to 20 degrees):
480km 43.9 ms -> 0.7 ms, 120km 50.0 ms -> 1.2 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cmdupuis3 cmdupuis3 added scalability Related to scalability & performance efforts run-benchmark Run ASV benchmark workflow accuracy Related to mathematical accuracy of operations, such as numerical precision or rounding errors labels Oct 23, 2026
@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have improved:

Change Before [55e15bb] After [78ada28] Ratio Benchmark (Parameter)
- 2.32M 2.11M 0.91 lazy_grid_construction.OpenGridChunked.track_peakmem_open_grid('480km')
- 277±4ms 1.91±0.01ms 0.01 mpas_ocean.AzimuthalAverage.time_azimuthal_average('120km')
- 239±3ms 811±4μs 0 mpas_ocean.AzimuthalAverage.time_azimuthal_average('480km')

Benchmarks that have stayed the same:

Change Before [55e15bb] After [78ada28] Ratio Benchmark (Parameter)
698±20μs 751±30μs 1.08 connectivity.Connectivity.time_edge_face('120km')
249±10μs 261±10μs 1.05 connectivity.Connectivity.time_edge_face('480km')
3.58±0.1ms 3.82±0.2ms 1.07 connectivity.Connectivity.time_edge_node('120km')
587±30μs 622±40μs 1.06 connectivity.Connectivity.time_edge_node('480km')
33.9±3μs 40.0±5μs ~1.18 connectivity.Connectivity.time_face_edge('120km')
30.9±8μs 33.1±4μs 1.07 connectivity.Connectivity.time_face_edge('480km')
425±10μs 431±10μs 1.01 connectivity.Connectivity.time_face_face('120km')
245±6μs 253±10μs 1.03 connectivity.Connectivity.time_face_face('480km')
32.2±3μs 30.8±3μs 0.96 connectivity.Connectivity.time_face_node('120km')
29.9±2μs 34.1±4μs ~1.14 connectivity.Connectivity.time_face_node('480km')
285±20μs 285±10μs 1.00 connectivity.Connectivity.time_n_nodes_per_face('120km')
222±20μs 212±10μs 0.95 connectivity.Connectivity.time_n_nodes_per_face('480km')
664±20μs 696±20μs 1.05 connectivity.Connectivity.time_node_edge('120km')
248±6μs 248±10μs 1.00 connectivity.Connectivity.time_node_edge('480km')
43.3±1ms 50.4±1ms ~1.16 connectivity.Connectivity.time_node_face('120km')
2.58±0.05ms 2.68±0.02ms 1.04 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')
6.03±0.3ms 5.62±0.3ms 0.93 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
1.80±0.04ms 1.77±0.03ms 0.99 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
7.90±2ms 7.18±7ms ~0.91 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
946±20μs 953±40μs 1.01 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.89M 1.89M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
1.88M 1.88M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
2.04M 2.04M 1.00 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'))
611±20ns 596±10ns 0.98 geometry_kernels.AccucrossKernels.time_accucross
1.33±0.01μs 1.35±0.02μs 1.02 geometry_kernels.AccucrossKernels.time_accucross_pair
255±9ns 271±8ns 1.06 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
241±9ns 261±0.5ns 1.08 geometry_kernels.EFTPrimitives.time_diff_of_products
210±4ns 220±10ns 1.05 geometry_kernels.EFTPrimitives.time_two_prod
236±20ns 211±10ns ~0.89 geometry_kernels.EFTPrimitives.time_two_sum
496±20ns 475±10ns 0.96 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
526±20ns 516±9ns 0.98 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
570±10ns 566±30ns 0.99 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
410±5ns 425±20ns 1.04 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
591±10ns 601±9ns 1.02 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
641±9ns 651±10ns 1.02 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
27.9±0.2μs 28.7±0.3μs 1.03 geometry_kernels.OrientPredicates.time_on_minor_arc
27.9±0.1μs 28.6±0.4μs 1.02 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
3.05±0.01ms 3.06±0ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_dispatch
1.16±0ms 1.17±0ms 1.01 geometry_samebody.SameBodyConstLat.time_accux_kernel
2.31±0.02ms 2.30±0.01ms 1.00 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
147±0.6μs 147±0.4μs 0.99 geometry_samebody.SameBodyConstLat.time_fp64_kernel
29.6±0.05ms 29.9±0.2ms 1.01 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
6.26±0.01ms 6.30±0ms 1.01 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
23.9±0.01ms 23.9±0.04ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
872±5μs 861±2μs 0.99 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
904±6ms 935±7ms 1.03 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
49.7±6ms 50.0±1ms 1.01 lazy_grid_construction.OpenGridChunked.time_open_grid('120km')
71.0±2ms 75.7±4ms 1.07 lazy_grid_construction.OpenGridChunked.time_open_grid('480km')
1.99M 2.01M 1.01 lazy_grid_construction.OpenGridChunked.track_peakmem_open_grid('120km')
2.40±0.01ms 2.36±0.05ms 0.98 mpas_ocean.CheckNorm.time_check_norm('120km')
1.90±0.01ms 1.91±0.04ms 1.00 mpas_ocean.CheckNorm.time_check_norm('480km')
1.09±0.02ms 1.12±0.03ms 1.03 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
485±9μs 490±10μs 1.01 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
636±10μs 657±3μs 1.03 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
533±9μs 554±10μs 1.04 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
5.10±0.04ms 5.12±0.02ms 1.00 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.58±0.02ms 3.60±0.01ms 1.01 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
95.9±0.2ms 95.7±0.4ms 1.00 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
9.12±0.02ms 9.49±0.4ms 1.04 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
13.6±0.07ms 13.8±0.05ms 1.02 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
631±10μs 679±30μs 1.08 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
6.55±0.1ms 6.97±0.1ms 1.06 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
371±40μs 421±30μs ~1.13 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
408±3ms 415±4ms 1.02 mpas_ocean.CrossSections.time_const_lat('120km', 1)
207±0.9ms 209±2ms 1.01 mpas_ocean.CrossSections.time_const_lat('120km', 2)
106±0.06ms 107±0.2ms 1.01 mpas_ocean.CrossSections.time_const_lat('120km', 4)
360±2ms 366±3ms 1.02 mpas_ocean.CrossSections.time_const_lat('480km', 1)
186±0.9ms 183±0.3ms 0.98 mpas_ocean.CrossSections.time_const_lat('480km', 2)
93.5±2ms 94.1±0.3ms 1.01 mpas_ocean.CrossSections.time_const_lat('480km', 4)
25.4±0.4ms 25.4±0.3ms 1.00 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
2.92±0.01ms 2.91±0.06ms 1.00 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
16.3±0.7ms 16.7±0.7ms 1.03 mpas_ocean.FaceAreas.time_face_areas('120km')
4.12±0.2ms 4.09±0.4ms 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')
696k 696k 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('480km')
107±3ms 106±0.6ms 0.99 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
41.3±0.9ms 42.3±0.8ms 1.02 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
19.2±0.2ms 19.4±0.2ms 1.01 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
3.77±0.2ms 3.70±0.1ms 0.98 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
10.2±0.1ms 10.1±0.03ms 0.98 mpas_ocean.Gradient.time_gradient('120km')
1.14±0.01ms 1.16±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')
320±6μs 335±10μs 1.05 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
146±6μs 149±4μs 1.02 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
170±2μs 170±2μs 1.00 mpas_ocean.Integrate.time_integrate('120km')
151±3μs 150±6μs 0.99 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')
138±0.7ms 138±3ms 1.00 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
137±0.7ms 137±2ms 1.00 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
137±2ms 137±0.4ms 1.00 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
9.86±0.1ms 9.79±0.2ms 0.99 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
9.76±0.1ms 10.7±0.6ms 1.09 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
9.83±0.1ms 9.79±0.2ms 1.00 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
209±1ms 208±0.6ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 1.0)
433±2ms 431±0.9ms 0.99 mpas_ocean.NeighborhoodBuild.time_build('120km', 5.0)
11.2±0.04ms 11.1±0.01ms 0.99 mpas_ocean.NeighborhoodBuild.time_build('480km', 1.0)
13.8±0.03ms 13.8±0.03ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('480km', 5.0)
206±0.6ms 205±0.4ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 1.0)
428±1ms 429±0.6ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 5.0)
10.9±0.02ms 10.9±0.04ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 1.0)
13.6±0.05ms 13.5±0.03ms 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.5±0.5ms 39.8±0.7ms 1.01 mpas_ocean.NeighborhoodDask.time_mean('120km', 'grid_chunks')
18.3±0.2ms 18.3±0.07ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'numpy')
35.9±0.4ms 35.9±0.5ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'time_chunks')
11.0±0.5ms 10.6±0.1ms 0.96 mpas_ocean.NeighborhoodDask.time_mean('480km', 'grid_chunks')
621±10μs 607±7μs 0.98 mpas_ocean.NeighborhoodDask.time_mean('480km', 'numpy')
7.37±0.09ms 7.55±0.07ms 1.03 mpas_ocean.NeighborhoodDask.time_mean('480km', 'time_chunks')
5.82M 5.82M 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.67M 5.67M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'time_chunks')
743k 755k 1.02 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'grid_chunks')
177k 177k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'numpy')
522k 529k 1.01 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'time_chunks')
3.47±0.01s 3.47±0s 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'mean')
3.54±0.01s 3.59±0.01s 1.01 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'median')
95.8±0.2ms 97.5±0.3ms 1.02 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'mean')
98.4±0.2ms 98.9±0.1ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'median')
373±0.9ms 373±0.6ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'mean')
404±2ms 403±0.6ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'median')
13.6±0.05ms 13.6±0.02ms 1.01 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'mean')
14.1±0.03ms 14.1±0.07ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'median')
4.95±0.06ms 5.06±0.02ms 1.02 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'mean')
34.5±0.06ms 34.5±0.05ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'median')
185±2μs 186±4μs 1.01 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'mean')
532±8μs 532±10μs 1.00 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')
261±9μs 258±8μs 0.99 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
245±3μs 245±6μs 1.00 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
236±6μs 235±10μs 1.00 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
232±10μs 232±5μs 1.00 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
112±0.7ms 112±0.2ms 1.00 mpas_ocean.RemapDownsample.time_bilinear_remapping
16.2±0.1ms 16.9±0.2ms 1.04 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
14.8±0.1ms 15.3±0.1ms 1.03 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
960±2ms 953±4ms 0.99 mpas_ocean.RemapUpsample.time_bilinear_remapping
25.9±0.4ms 25.7±0.3ms 0.99 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
12.3±0.2ms 12.3±0.1ms 1.00 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
9.04±0.2ms 9.12±0.2ms 1.01 mpas_ocean.ZonalAverage.time_zonal_average('120km')
4.56±0.2ms 4.56±0.09ms 1.00 mpas_ocean.ZonalAverage.time_zonal_average('480km')
1.0326134081537155 1.0340801380319704 1.00 nogil_scaling.GILScaling.track_gil_scaling
320±3ms 319±2ms 1.00 plots.PlotHealpix.time_default_plot(6)
2.63±0.02s 2.62±0.01s 0.99 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.56±0.2ms 7.54±0.1ms 1.00 quad_hexagon.QuadHexagon.time_open_dataset
6.28±0.1ms 6.37±0.1ms 1.01 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.9k 69.9k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_grid
744±10ms 752±7ms 1.01 to_raster.ToRaster.time_to_raster((10000.0, 10.0))
1.87±0.01s 1.88±0.02s 1.01 to_raster.ToRaster.time_to_raster((10000.0, 100.0))
2.11±0.03s 2.17±0.02s 1.03 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 [55e15bb] After [78ada28] Ratio Benchmark (Parameter)
+ 318M 402M 1.27 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
+ 318M 402M 1.26 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
+ 320M 402M 1.26 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
+ 319M 402M 1.26 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
+ 295M 403M 1.37 import.Imports.track_peakmem_import_uxarray
+ 339M 404M 1.19 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 1)
+ 339M 404M 1.19 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 2)
+ 339M 404M 1.19 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 4)
+ 322M 404M 1.25 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 1)
+ 323M 404M 1.25 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 2)
+ 323M 404M 1.25 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 4)
+ 335M 403M 1.2 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('120km')
+ 314M 403M 1.28 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('480km')
+ 323M 386M 1.2 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('120km')
+ 306M 386M 1.26 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('480km')

The ring loop queried the kd-tree once per radius for the whole disc,
then sorted it and ran setdiff1d against the previous disc, so its cost
grew with rings x faces inside. At 60 degrees on oQU120 it was 93% of
the runtime. One query at the outermost circle now finds every candidate
face, and each is binned by its squared chord distance, summed in the
kd-tree's own order.

The tree accepts or rejects whole nodes on distance bounds that carry
their own rounding, so within an ulp or so of a circle it can disagree
with d**2 <= r**2, in either direction. Faces within 1e-12 of a ring's
edge are settled by querying the tree at that radius, as the per-radius
loop did, which keeps ring membership identical. Checked against the
old loop on oQU480, oQU120 and the dyamond subset over 4050 cases,
15,000 of them exact ties on a ring's edge; results are bit-identical
to the previous commit.

The AzimuthalAverage benchmark now runs out to 60 degrees (61 rings),
where the ring faces rather than per-call overhead set the cost:

  480km  141 ms (before 833840d) -> 1.8 ms (833840d) -> 0.5 ms
  120km  169 ms                   -> 10.1 ms           -> 1.6 ms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cmdupuis3 cmdupuis3 self-assigned this Oct 23, 2026
@cmdupuis3 cmdupuis3 removed the run-benchmark Run ASV benchmark workflow label Oct 23, 2026
cmdupuis3 and others added 3 commits October 23, 2026 17:45
Fancy-indexing a lazily backed (netCDF) variable reads face by face, so
gathering the ring faces took 87 ms on oQU120 against 1.6 ms for the
whole call on loaded data; the benchmark fixture loads data, hiding it.
The span of ring faces is now read contiguously and indexed in memory.

Dask input with no face inside outer_radius raised ZeroDivisionError in
dask's gufunc rechunking; n_face is already one chunk, so allow_rechunk
is now off.

Ring membership is now d <= r on the computed squared distances, as
documented, rather than reproducing the kd-tree's pruning rounding at
ring edges. The single query is padded so the tree can't drop faces on
the outer circle. Only faces exactly on a ring edge can change ring.

  oQU, 61 rings      loaded            lazy
  480km              0.40 -> 0.33 ms   5.9 -> 0.40 ms
  120km              1.57 -> 0.92 ms   86.7 -> 1.0 ms

Tests share one brute-force ring oracle; the eager, dask and hit-count
checks run as one parametrized reference test, inside and outside the
grid subset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cmdupuis3 cmdupuis3 added run-benchmark Run ASV benchmark workflow and removed run-benchmark Run ASV benchmark workflow labels Oct 26, 2026
The consolidated monotone test dropped non-finite differences, so a NaN
in a non-empty ring passed silently. Only the empty ring at radius 0 is
NaN, and res[1:] already excludes it, so neither profile needs the
filter; the old test_gaussian had none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cmdupuis3
cmdupuis3 marked this pull request as ready for review October 26, 2026 23:21

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

The changes here look good overall, and a 100x speedup is awesome, I'm excited to get this merged into main!

I have two small concerns: (1) with the tests, and (2) in the possibility for tiny differences between these results and the results from main.

(1) I can tell that some of the new tests in test_azimuthal.py look like they should match the old tests (e.g. test_monotone_profile should cover the old test_gaussian and test_inverse_gaussian I think) but I'm not really sure why the old tests needed to be deleted. It would be easier to approve this if the old tests were maintained as-is, and new tests were added, instead of completely deleting/refactoring the old tests. Refactoring the tests at the same time as the code means there's nothing left to clearly demonstrate that the old test suite's guarantees are still 100% guaranteed to hold true after the changes too. Even though it looks like the new tests cover the same things, maybe there's some subtlety I'm missing.

(2) The results after this change are not bit-for-bit identical to the results before it. For a pure performance change I would have probably expected bit-for-bit identical results, and maybe a test that demonstrates as much, as well. Any guesses as to why the values are different? This PR would be very easy to approve if the results were bit-for-bit identical, but it's more difficult to approve when there are tiny differences.

Minimal reproducer for (2):

psi = ux.tutorial.open_dataset("outCSne30-vortex")['psi']
result = psi.azimuthal_mean((10, 20), 40, 2)

Running this on main and on this branch, and then subtracting the two, I see some values are exactly the same (diff=0) while some have diff=1.11e-16, 2.22e-16, or 4.44e-16.

@cmdupuis3

Copy link
Copy Markdown
Collaborator Author

@Sevans711

(1) They were refactored mostly to condense the code, but they are also more flexible. The old tests can be seen with these parameter combos:

Old test New test and parameters
test_gaussian test_monotone_profile[gaussian--1]: name="gaussian", sign=-1
test_inverse_gaussian test_monotone_profile[inverse_gaussian-1]: name="inverse_gaussian", sign=1
test_non_zero_hit_counts test_matches_reference[center0-None] and [center0-chunks1]: center=(45, 0), chunks is None or {"n_face": 50, "time": 1}
test_zero_hit_counts test_matches_reference[center1-None] and [center1-chunks1]: center=(-45, 0) (outside the subset), same two chunks values

I think the git diff is being stupid though, you'll have to compare them side-by-side, or maybe find a good diff tool (git's default tool is terrible even by 2010 standards). Claude could also be helpful.

(2) That is true, this code doesn't reproduce exactly. The new code should be more accurate since it's doing arithmetic n float64 rather than float32. The old error should be around 10^-8, while the error now should be around 10^-16.

@cmdupuis3 cmdupuis3 added run-benchmark Run ASV benchmark workflow and removed run-benchmark Run ASV benchmark workflow labels Oct 27, 2026
@Sevans711

Copy link
Copy Markdown
Collaborator

For (1), thank you for clarifying the parameter combos to get from old tests to new tests. I was also sort of hoping to just get the old tests restored since that makes review easier, but I do agree the new tests look like they should reproduce the old ones and look like they should be reasonable. If you don't want to restore the old tests, I'll just make sure to run them on this branch locally to ensure they still pass, before I approve.

For (2), can you provide some sort of evidence to demonstrate that the new code is indeed always (or at least almost always) accurate (or, more accurate)? It looks like there's 1 correctness test being introduced here, as test_matches_reference, with a couple different numerical parameter inputs, but I'm not sure yet if this covers a wide enough variety of cases to demonstrate that the new code is always correct / more accurate.

If precision is really the only reason there is any difference between here and main, one option could be to locally introduce float64 precision on main's version of the code and show that leads to a bit-for-bit match here. If that is indeed the case I would be happy to approve this PR as-is (assuming the old tests from (1) still pass).

@cmdupuis3

Copy link
Copy Markdown
Collaborator Author

(1) Yeah, I'd rather not restore them. The more horizontal alternative would be to do a massive test consolidation sweep, so I think it's reasonable to clean up bits as we go.

(2) Casting main to float64 inherently won't match float32, you'd basically be doing the same thing I'm doing here.

Try this:

from fractions import Fraction

import numpy as np

import uxarray as ux
from uxarray.core.azimuthal import _ring_membership
from uxarray.grid.coordinates import _lonlat_rad_to_xyz

base = "test/meshfiles/mpas/dyamond-30km/"
uxds = ux.open_dataset(base + "gradient_grid_subset.nc", base + "gradient_data_subset.nc")
center, outer_radius, radius_step = (45, 0), 2, 0.25

radii = np.arange(0.0, outer_radius + radius_step, radius_step)
chords = 2.0 * np.sin(np.deg2rad(radii) / 2.0)
center_xyz = np.array(_lonlat_rad_to_xyz(*np.deg2rad(center)))
faces, ring_ids = _ring_membership(uxds.uxgrid._get_scipy_kd_tree(), center_xyz, chords)
areas = uxds.uxgrid.face_areas.values


def exact_mean(x, a):
    """sum(x * a) / sum(a) in exact rational arithmetic."""
    return sum(Fraction(float(xi)) * Fraction(float(ai)) for xi, ai in zip(x, a)) / sum(
        Fraction(float(ai)) for ai in a
    )


def rel_err(value, exact):
    return float(abs(Fraction(float(value)) - exact) / abs(exact))


def report(name, data):
    print(f"\n{name}  (data {data.dtype}, areas {areas.dtype})")
    new = data.azimuthal_mean(
        center_coord=center, outer_radius=outer_radius, radius_step=radius_step
    ).values
    print(f"{'ring':>4} {'faces':>6} {'old (float32) err':>18} {'new (float64) err':>18}")
    for i in range(1, radii.size):
        f = faces[ring_ids == i]
        x, a = data.values[f], areas[f]
        exact = exact_mean(x, a)
        old = (x * a).sum() / a.sum()  # what weighted_mean() computed: all float32
        print(f"{i:>4} {f.size:>6} {rel_err(old, exact):>18.1e} {rel_err(new[i], exact):>18.1e}")


# 1. The real test field
report("gaussian", uxds["gaussian"])

# 2. A constant field: the exact mean is the constant itself
const = uxds["gaussian"].copy(data=np.full(uxds["gaussian"].shape, 0.1, dtype=np.float32))
report("constant 0.1f", const)

@Sevans711

Copy link
Copy Markdown
Collaborator

(1) Yeah, I'd rather not restore them. The more horizontal alternative would be to do a massive test consolidation sweep, so I think it's reasonable to clean up bits as we go.

Fair enough!

(2) Casting main to float64 inherently won't match float32, you'd basically be doing the same thing I'm doing here.

I think there was a misunderstanding: I wasn't expecting "main casted to float64" to match "main now (which is float32)". But, your previous claim:

the new code should be more accurate since it's doing arithmetic in float64 rather than float32.

made me expect "main casted to float64" should match "this PR". Is that the correct understanding / can you show whether that is actually true? If it's true then I would happily agree that the only reason there is a difference is the float64 versus float32, and approve the PR on the basis of "float64 is more accurate than float32"!

@cmdupuis3

cmdupuis3 commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Basically yes, there is no other reason why this code is more accurate than that we're using float64 instead of float32. It's not exactly the same as doing that on main, but it's close, the difference being that you'd need to have the data in float64 to have a fair comparison, but if it is, it would be nearly the same. The change in math on this branch necessarily results in some differences of a few ulps.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

accuracy Related to mathematical accuracy of operations, such as numerical precision or rounding errors scalability Related to scalability & performance efforts

Projects

None yet

Development

Successfully merging this pull request may close these issues.

azimuthal_mean builds a full subgrid for every ring

2 participants