Skip to content

Devalue uxarray to make it less expensive - #1588

Open
cmdupuis3 wants to merge 14 commits into
UXARRAY:mainfrom
cmdupuis3:cmd/devalue
Open

Devalue uxarray to make it less expensive#1588
cmdupuis3 wants to merge 14 commits into
UXARRAY:mainfrom
cmdupuis3:cmd/devalue

Conversation

@cmdupuis3

@cmdupuis3 cmdupuis3 commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Partly addresses #1583

Overview

This PR is to resolve suboptimal usage of .values throughout the repo, primarily by deferring to lazy xarray and dask operations. See the table of usage sites in Issue #1583.

This specific PR is for the first two tables of usage sites; dead code and bugfixes aren't really included here. Aside from simple cases, some cases were solved by branching on whether the data type was already chunked, so scalability for some parts will depend on if you're using numpy or xarray/dask arrays at those points.

PR Checklist

General

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

Testing

  • Adequate tests are created if there is new functionality
  • Tests cover all possible logical paths in your function
  • Tests are not too basic (such as simply calling a function and nothing else)

Documentation

  • Docstrings have been added to all new functions
  • Docstrings have updated with any function changes

@cmdupuis3 cmdupuis3 self-assigned this Jul 18, 2026
@cmdupuis3 cmdupuis3 added scalability Related to scalability & performance efforts run-benchmark Run ASV benchmark workflow labels Jul 18, 2026
@github-actions

github-actions Bot commented Jul 18, 2026

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have improved:

Change Before [058a991] After [c90499b] Ratio Benchmark (Parameter)
- 522M 337M 0.65 face_bounds.FaceBounds.peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
- 638M 337M 0.53 face_bounds.FaceBounds.peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
- 37.0±0.4ms 32.7±0.1ms 0.88 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
- 486±10ms 400±2ms 0.82 mpas_ocean.CrossSections.time_const_lat('120km', 1)
- 256±9ms 204±1ms 0.8 mpas_ocean.CrossSections.time_const_lat('120km', 2)
- 126±2ms 106±1ms 0.84 mpas_ocean.CrossSections.time_const_lat('120km', 4)
- 448M 328M 0.73 mpas_ocean.Gradient.peakmem_gradient('480km')
- 24.3±0.1ms 22.0±0.7ms 0.9 mpas_ocean.ZonalAverage.time_zonal_average('120km')

Benchmarks that have stayed the same:

Change Before [058a991] After [c90499b] Ratio Benchmark (Parameter)
6.96±0.06μs 7.19±0.08μs 1.03 bench_connectivity.Connectivity.time_edge_face('120km')
7.49±0.08μs 7.26±0.06μs 0.97 bench_connectivity.Connectivity.time_edge_face('480km')
7.24±0.1μs 7.43±0.5μs 1.03 bench_connectivity.Connectivity.time_edge_node('120km')
7.21±0.1μs 7.25±0.3μs 1.01 bench_connectivity.Connectivity.time_edge_node('480km')
7.24±0.02μs 7.17±0.07μs 0.99 bench_connectivity.Connectivity.time_face_edge('120km')
7.31±0.4μs 7.06±0.05μs 0.97 bench_connectivity.Connectivity.time_face_edge('480km')
6.99±0.06μs 6.98±0.2μs 1.00 bench_connectivity.Connectivity.time_face_face('120km')
7.38±0.3μs 7.02±0.1μs 0.95 bench_connectivity.Connectivity.time_face_face('480km')
14.0±0.2μs 14.2±0.4μs 1.01 bench_connectivity.Connectivity.time_face_node('120km')
14.3±0.2μs 15.2±1μs 1.06 bench_connectivity.Connectivity.time_face_node('480km')
7.05±0.2μs 7.04±0.04μs 1.00 bench_connectivity.Connectivity.time_node_edge('120km')
7.33±0.4μs 7.12±0.1μs 0.97 bench_connectivity.Connectivity.time_node_edge('480km')
7.02±0.1μs 6.92±0.1μs 0.99 bench_connectivity.Connectivity.time_node_face('120km')
7.17±0.3μs 7.16±0.1μs 1.00 bench_connectivity.Connectivity.time_node_face('480km')
333M 334M 1.00 face_bounds.FaceBounds.peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
365M 365M 1.00 face_bounds.FaceBounds.peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
13.6±0.2ms 13.4±0.06ms 0.99 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
3.05±0.06ms 2.96±0.03ms 0.97 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
18.0±0.1ms 18.0±0.09ms 1.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.44±0.09ms 1.31±0.04ms 0.91 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
401±4ms 388±1ms 0.97 import.Imports.timeraw_import_uxarray
608±20ns 558±5ns 0.92 mpas_ocean.CheckNorm.time_check_norm('120km')
557±30ns 511±8ns 0.92 mpas_ocean.CheckNorm.time_check_norm('480km')
565±20ms 545±10ms 0.96 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
9.73±0.5μs 9.57±0.1μs 0.98 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
424±20μs 453±30μs 1.07 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
3.63±0.04ms 3.51±0.1ms 0.97 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
2.64±0.01ms 2.66±0.05ms 1.01 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
2.38±0.03s 2.36±0.01s 0.99 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
160±10ms 151±0.8ms 0.94 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
9.27±0.1ms 9.24±0.1ms 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
699±30μs 656±20μs 0.94 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
7.47±0.3ms 6.87±0.02ms 0.92 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
541±10μs 488±10μs ~0.90 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
356±3ms 360±20ms 1.01 mpas_ocean.CrossSections.time_const_lat('480km', 1)
181±1ms 185±9ms 1.02 mpas_ocean.CrossSections.time_const_lat('480km', 2)
93.1±0.4ms 95.6±6ms 1.03 mpas_ocean.CrossSections.time_const_lat('480km', 4)
18.7±0.2ms 18.8±0.2ms 1.00 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
2.13±0.08ms 2.02±0.03ms 0.95 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
614±10ms 600±4ms 0.98 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
37.6±2ms 35.7±0.5ms 0.95 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
51.3±0.4ms 52.5±1ms 1.02 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
4.05±0.1ms 4.01±0.1ms 0.99 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
349M 349M 1.00 mpas_ocean.Gradient.peakmem_gradient('120km')
171±2ms 168±2ms 0.98 mpas_ocean.Gradient.time_gradient('120km')
11.6±0.3ms 11.4±0.07ms 0.99 mpas_ocean.Gradient.time_gradient('480km')
215±1μs 215±3μs 1.00 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
79.2±0.6μs 78.8±2μs 0.99 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
298M 298M 1.00 mpas_ocean.Integrate.peakmem_integrate('120km')
277M 277M 1.00 mpas_ocean.Integrate.peakmem_integrate('480km')
136±2μs 134±1μs 0.99 mpas_ocean.Integrate.time_integrate('120km')
126±2μs 124±1μs 0.99 mpas_ocean.Integrate.time_integrate('480km')
119±2ms 114±2ms 0.96 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
121±5ms 113±1ms 0.94 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
118±2ms 116±1ms 0.98 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
8.83±0.09ms 8.95±0.3ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
8.93±0.1ms 9.30±0.07ms 1.04 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
8.88±0.2ms 8.93±0.07ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
160±1μs 163±7μs 1.02 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
160±0.3μs 167±10μs 1.04 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
155±2μs 150±0.5μs 0.97 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
154±1μs 155±7μs 1.00 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
146±2ms 162±6ms ~1.11 mpas_ocean.RemapDownsample.time_bilinear_remapping
143±0.7ms 148±9ms 1.04 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
2.73±0.02ms 2.94±0.1ms 1.08 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
763±3ms 772±6ms 1.01 mpas_ocean.RemapUpsample.time_bilinear_remapping
28.2±0.8ms 27.2±1ms 0.96 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
6.07±0.08ms 6.48±0.2ms 1.07 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
4.21±0.06ms 4.45±0.3ms 1.06 mpas_ocean.ZonalAverage.time_zonal_average('480km')
273M 275M 1.01 quad_hexagon.QuadHexagon.peakmem_open_dataset
273M 273M 1.00 quad_hexagon.QuadHexagon.peakmem_open_grid
5.30±0.2ms 4.97±0.1ms 0.94 quad_hexagon.QuadHexagon.time_open_dataset
4.60±0.3ms 4.15±0.1ms ~0.90 quad_hexagon.QuadHexagon.time_open_grid

@cmdupuis3

Copy link
Copy Markdown
Collaborator Author

pre-commit.ci autofix

@cmdupuis3

Copy link
Copy Markdown
Collaborator Author

There's some spurious asv results from machine variability, but the benchmarks consistenly show peak-mem reductions and some speedups for cross-sections.

@cmdupuis3
cmdupuis3 requested a review from erogluorhan July 20, 2026 23:25
@cmdupuis3
cmdupuis3 marked this pull request as ready for review July 20, 2026 23:26
@cmdupuis3
cmdupuis3 requested a review from rajeeja July 23, 2026 23:26
@cmdupuis3

Copy link
Copy Markdown
Collaborator Author

pre-commit.ci autofix

@Sevans711
Sevans711 self-requested a review July 27, 2026 16:18

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

Hi @cmdupuis3, thank you for proposing these changes! Overall these look like good clean changes which should help improve the scalability of uxarray.

I have some notes/suggestions/requested changes. Primarily, I noticed that there are missing regression tests. This may be especially important for places with significant changes (more significant than just replacing obj.values with obj.data):

  • UxDataArray.integrate()
  • UxDataArrayCrossSectionAccessor.__call__()
  • uxarray.plot.matplotlib._nearest_neighbor_resample()
  • RemapAccessor.apply_weights()

It may be nice to add a regression test for UxDataset.to_xarray() too, even though the changes here are minor, since that feels like a very core part of the functionality.

The tests could be similar to what you added already in test_topological_agg.py (plus my additional request on that file): ensure that numpy and dask inputs ultimately give the same values (plus assert that the dask inputs lead to dask outputs).

Other parts of the code changes here might benefit from similar tests, but I don't know if that should be necessary. For example, the changes in _geos just replace obj.values with obj.data; does a regression test need to be added for that or no? Curious to hear from @erogluorhan and/or @rajeeja on this question in particular.

Misc. note: I'm not sure if I fully understand the changes to uxarray/cross_sections files. I need to make sure to take a closer look at those during a subsequent review.

# apply aggregation on dask array, TODO:
aggregated_var = _apply_node_to_face_aggregation_numpy(
# apply aggregation lazily on a dask array
return _apply_node_to_face_aggregation_dask(

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.

Style/documentation: it may improve readability to make _apply_node_to_face_aggregation_dask and _apply_node_to_face_aggregation_numpy have the same return type (e.g., both return xarray.DataArray).

If there's a compelling reason to not do that, please leave a small comment explaining why, and add a note in the docstring of each method saying the type of the returned object

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah that's a good idea, I'll move the ux.DataArray type inside

uxda, aggregation_func, aggregation_func_kwargs
):
"""Applies a Node to Face Topological aggregation on a Dask array, lazily"""
import xarray as xr

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.

Move xarray import to top of file (see #1575)

aggregation_var = _apply_node_to_edge_aggregation_numpy(
uxda, NUMPY_AGGREGATIONS[aggregation], aggregation_func_kwargs
# apply aggregation lazily on a dask array
return _apply_node_to_edge_aggregation_dask(

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.

Same style/documentation comment as above, but for node to edge case; make numpy and dask have same return type and/or clarify in function docstrings

dimensions are processed blockwise. Each edge reduces over its two endpoint
nodes.
"""
import xarray as xr

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.

(same as before, move xarray import to top of file, see #1575)

Comment thread uxarray/core/dataarray.py
)

if self.values.size == self.uxgrid.n_face:
if self.size == self.uxgrid.n_face:

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.

These changes look good here; no further revision needed in this area, just flagging #1616 as related. Fixing 1616 will lead to these lines being replaced by something like if self._face_centered() instead.

Comment thread uxarray/core/dataarray.py
"i,...i", self.uxgrid.face_areas.values, self.values
)
else:
# dask-backed data: xr.dot keeps the reduction lazy

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.

Does integration need a regression test which is sensitive to dask versus numpy? Perhaps, assert that the result is a dask array, and that the values equal the numpy version, similar to the tests already added by this PR into test_topological_agg.


assert numpy_result.dims == dask_result.dims
assert np.allclose(numpy_result.values, dask_result.values, equal_nan=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.

This test currently passes on main, so it does not serve as a regression test yet. One possible fix is to add: import dask; assert isinstance(dask_result.data, dask.array.Array). Then, it fails on main but passes on this branch.

This applies to the node_to_edge case below as well.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The idea is that if someone is expecting bitwise reproducibility and switching to dask flips bits, these tests will catch it.

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 makes sense. Clarifying, I think the bitwise reproducibility part of these tests are important to include and your implementation of them looks good!

Separately, I think there should also be tests to ensure the result is actually a dask array. But, those don't need to be separate test functions, for example it would be sufficient to add them via something like the assert statement above.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Separately, I think there should also be tests to ensure the result is actually a dask array. But, those don't need to be separate test functions, for example it would be sufficient to add them via something like the assert statement above.

This makes sense! However, either they need to be in separate tests or the naming of the test case here should slightly be adjusted (currently the name suggests whether input output values and dims are all close)

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.

3 participants