Skip to content

Release the GIL in all numba kernels (nogil=True) - #1755

Open
cmdupuis3 wants to merge 5 commits into
UXARRAY:mainfrom
cmdupuis3:cmd/nogil
Open

cmdupuis3 wants to merge 5 commits into
UXARRAY:mainfrom
cmdupuis3:cmd/nogil

Conversation

@cmdupuis3

@cmdupuis3 cmdupuis3 commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Closes #1756

Overview

Only 2 of the 140 @njit sites in the package passed nogil=True (grid/connectivity.py, grid/area.py). Every other compiled kernel held the GIL for its whole execution, so calling them from dask's threaded scheduler as dask="parallelized" does serialized them completely, regardless of how many workers were configured.

Adds nogil=True to the 20 or so remaining top-level sites making @njit(cache=True, nogil=True) the default pattern. These are all nopython, allocation-light leaf kernels with no object-mode fallback.

Measured on _construct_edge_node_distances (2M edges, best of 3), the same kernel jitted with and without nogil:

threads     GIL held        nogil   speedup
      1        95.0ms        95.3ms    1.00x
      2       191.5ms       101.1ms    1.89x
      4       381.0ms       104.1ms    3.66x
      8       762.8ms       115.1ms    6.63x

The GIL-held column scales linearly with thread count -- that is the signature of zero parallelism.

Two sites are deliberately left alone:

  • the guvectorize kernel in grid/neighbors.py -- numba rejects nogil as a @guvectorize option, and the gufunc machinery already drops the GIL around the loop.
  • the 15 parallel=True kernels keep parallel=True for now. Nested under dask's threadpool it oversubscribes; dropping it belongs with the work that gives dask the chunk loop.

_build_node_edge_connectivity in grid/connectivity.py is also the one kernel still missing cache=True; left as-is to keep this diff to nogil.

The nogil_scaling.GILScaling.track_gil_scaling is added to prove that nogil is working as expected.

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

  • If this PR could affect performance, ran ASV benchmarks and confirmed they show expected behavior (add a new benchmark if necessary)

AI Disclosure

AI Usage: Claude Opus 5

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

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

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have improved:

Change Before [1e079ce] After [0f14982] Ratio Benchmark (Parameter)
- 765k 696k 0.91 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'grid_chunks')
- 1.9995031874904765 1.0245615955457068 0.51 nogil_scaling.GILScaling.track_gil_scaling

Benchmarks that have stayed the same:

Change Before [1e079ce] After [0f14982] Ratio Benchmark (Parameter)
5.44±0.09ms 5.47±0.03ms 1.01 bench_connectivity.Connectivity.time_edge_face('120km')
1.75±0.01ms 1.71±0.02ms 0.98 bench_connectivity.Connectivity.time_edge_face('480km')
4.29±0.03ms 4.42±0.07ms 1.03 bench_connectivity.Connectivity.time_edge_node('120km')
1.36±0.01ms 1.34±0.02ms 0.98 bench_connectivity.Connectivity.time_edge_node('480km')
4.32±0.05ms 4.35±0.03ms 1.01 bench_connectivity.Connectivity.time_face_edge('120km')
1.37±0.02ms 1.34±0.01ms 0.98 bench_connectivity.Connectivity.time_face_edge('480km')
6.28±0.02ms 6.41±0.07ms 1.02 bench_connectivity.Connectivity.time_face_face('120km')
2.11±0.01ms 2.07±0.02ms 0.98 bench_connectivity.Connectivity.time_face_face('480km')
59.1±1μs 60.5±1μs 1.02 bench_connectivity.Connectivity.time_face_node('480km')
363±8μs 355±6μs 0.98 bench_connectivity.Connectivity.time_n_nodes_per_face('480km')
5.86±0.05ms 5.84±0.06ms 1.00 bench_connectivity.Connectivity.time_node_edge('120km')
1.74±0.02ms 1.73±0.02ms 1.00 bench_connectivity.Connectivity.time_node_edge('480km')
81.0±2ms 80.5±1ms 0.99 bench_connectivity.Connectivity.time_node_face('120km')
4.77±0.03ms 4.99±0.2ms 1.04 bench_connectivity.Connectivity.time_node_face('480km')
7.54±0.1ms 7.37±0.08ms 0.98 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.48±0.09ms 2.53±0.1ms 1.02 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
10.6±10s 9.45±10ms ~0.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.98±0.01ms 2.03±0.03ms 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.99M 1.99M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2M 2M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
2.16M 2.16M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
38.4k 38.3k 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
330M 330M 1.00 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
360M 360M 1.00 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
331M 331M 1.00 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
331M 330M 1.00 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.18±0.02μs 1.17±0.04μs 0.99 geometry_kernels.AccucrossKernels.time_accucross
2.60±0.02μs 2.53±0.03μs 0.97 geometry_kernels.AccucrossKernels.time_accucross_pair
415±20ns 401±7ns 0.97 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
400±9ns 401±20ns 1.00 geometry_kernels.EFTPrimitives.time_diff_of_products
341±5ns 360±0.5ns 1.06 geometry_kernels.EFTPrimitives.time_two_prod
355±10ns 345±30ns 0.97 geometry_kernels.EFTPrimitives.time_two_sum
606±20ns 601±9ns 0.99 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
656±10ns 681±20ns 1.04 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
771±70ns 701±20ns 0.91 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
751±40ns 746±20ns 0.99 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
816±20ns 851±30ns 1.04 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
932±9ns 951±30ns 1.02 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
37.1±0.1μs 36.3±0.5μs 0.98 geometry_kernels.OrientPredicates.time_on_minor_arc
716±40ns 686±20ns 0.96 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
3.13±0.01ms 3.13±0.01ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_dispatch
1.33±0.01ms 1.33±0ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_kernel
2.10±0.01ms 2.10±0.01ms 1.00 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
156±0.8μs 157±1μs 1.01 geometry_samebody.SameBodyConstLat.time_fp64_kernel
27.8±0.06ms 27.8±0.01ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
6.82±0ms 6.84±0.01ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
22.2±0.7ms 21.0±0ms 0.95 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
873±4μs 875±0.9μs 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
842±20ms 849±6ms 1.01 import.Imports.timeraw_import_uxarray
288M 288M 1.00 import.Imports.track_peakmem_import_uxarray
2.25±0.01ms 2.25±0.02ms 1.00 mpas_ocean.CheckNorm.time_check_norm('120km')
1.74±0.01ms 1.80±0.04ms 1.03 mpas_ocean.CheckNorm.time_check_norm('480km')
1.08±0.01ms 1.11±0.01ms 1.03 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
531±7μs 543±10μs 1.02 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
663±10μs 667±7μs 1.01 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
581±10μs 601±20μs 1.03 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
4.99±0.02ms 5.03±0.02ms 1.01 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.44±0.04ms 3.50±0.05ms 1.02 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
102±0.8ms 102±0.6ms 1.00 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
10.4±0.06ms 10.2±0.4ms 0.98 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
19.7±0.06ms 19.8±0.04ms 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
1.19±0.06ms 1.12±0.01ms 0.95 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
10.6±0.02ms 10.6±0.02ms 1.00 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
756±6μs 751±7μs 0.99 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
536±6ms 541±10ms 1.01 mpas_ocean.CrossSections.time_const_lat('120km', 1)
268±2ms 273±7ms 1.02 mpas_ocean.CrossSections.time_const_lat('120km', 2)
139±2ms 139±4ms 1.00 mpas_ocean.CrossSections.time_const_lat('120km', 4)
480±5ms 482±10ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 1)
239±2ms 237±5ms 0.99 mpas_ocean.CrossSections.time_const_lat('480km', 2)
123±1ms 123±3ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 4)
356M 356M 1.00 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('120km', 1)
356M 356M 1.00 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('120km', 2)
356M 356M 1.00 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('120km', 4)
335M 335M 1.00 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('480km', 1)
335M 336M 1.00 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('480km', 2)
335M 335M 1.00 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('480km', 4)
22.3±0.04ms 22.4±0.1ms 1.00 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
2.62±0.02ms 2.62±0.07ms 1.00 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
13.7±0.3ms 14.1±0.3ms 1.03 mpas_ocean.FaceAreas.time_face_areas('120km')
3.72±0.08ms 3.76±0.05ms 1.01 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')
743k 743k 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('480km')
failed failed n/a mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
failed failed n/a mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
failed failed n/a mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
failed failed n/a mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
14.5±0.2ms 15.2±0.5ms 1.05 mpas_ocean.Gradient.time_gradient('120km')
1.94±0.01ms 2.00±0.05ms 1.04 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.43M 3.43M 1.00 mpas_ocean.Gradient.track_peakmem_gradient('120km')
218k 218k 1.00 mpas_ocean.Gradient.track_peakmem_gradient('480km')
348M 348M 1.00 mpas_ocean.GradientColdStartRss.peakmem_gradient('120km')
328M 327M 1.00 mpas_ocean.GradientColdStartRss.peakmem_gradient('480km')
362±9μs 373±8μs 1.03 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
197±5μs 201±9μs 1.02 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
535±10μs 534±8μs 1.00 mpas_ocean.Integrate.time_integrate('120km')
457±20μs 496±20μs 1.08 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')
failed failed n/a mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
failed failed n/a mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
failed failed n/a mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
failed failed n/a mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
failed failed n/a mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
failed failed n/a mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
259±0.8ms 258±0.6ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 1.0)
1.37±0s 1.38±0s 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 15.0)
534±3ms 534±1ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 5.0)
14.0±0.03ms 14.0±0.04ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('480km', 1.0)
27.0±0.09ms 26.9±0.08ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('480km', 15.0)
17.3±0.05ms 17.6±0.09ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('480km', 5.0)
253±0.7ms 253±0.2ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 1.0)
1.36±0s 1.39±0.01s 1.02 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 15.0)
528±1ms 530±2ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 5.0)
13.7±0.04ms 13.7±0.02ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 1.0)
26.2±0.07ms 26.3±0.07ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 15.0)
17.0±0.04ms 17.1±0.08ms 1.00 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)
47.6±0.3ms 48.4±0.7ms 1.02 mpas_ocean.NeighborhoodDask.time_mean('120km', 'grid_chunks')
25.0±0.03ms 25.0±0.05ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'numpy')
43.9±0.4ms 44.9±0.6ms 1.02 mpas_ocean.NeighborhoodDask.time_mean('120km', 'time_chunks')
11.4±0.3ms 11.3±0.2ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('480km', 'grid_chunks')
551±5μs 552±6μs 1.00 mpas_ocean.NeighborhoodDask.time_mean('480km', 'numpy')
8.24±0.2ms 8.18±0.08ms 0.99 mpas_ocean.NeighborhoodDask.time_mean('480km', 'time_chunks')
5.75M 5.75M 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')
177k 177k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'numpy')
545k 536k 0.98 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'time_chunks')
13.5±0.1s 13.5±0.01s 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'mean')
14.1±0.05s 14.4±0.03s 1.02 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'median')
243±0.3ms 244±0.3ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'mean')
251±0.5ms 251±0.3ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'median')
1.41±0s 1.42±0.01s 1.01 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'mean')
1.63±0s 1.63±0s 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'median')
27.4±0.2ms 27.7±0.07ms 1.01 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'mean')
29.0±0.08ms 29.2±0.07ms 1.01 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'median')
44.8±0.05ms 44.8±0.04ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'mean')
260±0.4ms 259±0.3ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'median')
671±30μs 711±10μs 1.06 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'mean')
2.18±0.03ms 2.20±0.01ms 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')
357±10μs 364±10μs 1.02 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
347±7μs 341±6μs 0.98 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
333±8μs 330±7μs 0.99 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
332±9μs 321±10μs 0.97 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
203±0.4ms 204±0.8ms 1.01 mpas_ocean.RemapDownsample.time_bilinear_remapping
227±0.5ms 226±3ms 1.00 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
15.6±0.07ms 15.5±0.08ms 0.99 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
1.11±0.01s 1.11±0s 1.00 mpas_ocean.RemapUpsample.time_bilinear_remapping
36.6±0.3ms 36.4±0.3ms 0.99 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
11.3±0.3ms 11.1±0.1ms 0.99 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
9.29±0.1ms 9.22±0.04ms 0.99 mpas_ocean.ZonalAverage.time_zonal_average('120km')
5.14±0.07ms 4.84±0.02ms 0.94 mpas_ocean.ZonalAverage.time_zonal_average('480km')
358M 358M 1.00 mpas_ocean.ZonalAveragePeakMem.peakmem_zonal_average('120km')
336M 336M 1.00 mpas_ocean.ZonalAveragePeakMem.peakmem_zonal_average('480km')
6.39±0.3ms 6.44±0.1ms 1.01 quad_hexagon.QuadHexagon.time_open_dataset
5.31±0.05ms 5.27±0.04ms 0.99 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
74k 74k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_dataset
73.1k 73.1k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_grid

Benchmarks that have got worse:

Change Before [1e079ce] After [0f14982] Ratio Benchmark (Parameter)
+ 63.6±1μs 78.8±3μs 1.24 bench_connectivity.Connectivity.time_face_node('120km')
+ 445±3μs 498±10μs 1.12 bench_connectivity.Connectivity.time_n_nodes_per_face('120km')

dask="parallelized" on the threaded scheduler can only overlap work that
releases the GIL, and only 2 of the package's 140 @njit sites passed
nogil=True. Adds it to 19 more: the kernels that Python invokes once per
array, which is exactly the set dask will call once per chunk.

Measured on _construct_edge_node_distances (2M edges, best of 3), the
same kernel jitted with and without nogil:

    threads     GIL held        nogil   speedup
          1        96.9ms        95.7ms    1.01x
          2       192.8ms       103.8ms    1.86x
          4       401.4ms       105.3ms    3.81x
          8       780.7ms       119.5ms    6.53x

The GIL-held column scales linearly with thread count -- that is the
signature of zero parallelism.

Deliberately NOT a blanket sweep. nogil is not free and not universally
inert:

  * It only takes effect at the Python->native boundary. An njit->njit
    call never touches the GIL, so on the 106 kernels reachable only
    from other jitted code the flag is dead weight. That includes every
    inline="always" primitive in utils/computing.py, which by
    construction is inlined into its caller and has no boundary at all.

  * At that boundary it costs a PyEval_SaveThread/RestoreThread pair,
    measured here at ~40ns per call. Against a whole-array kernel that
    is free; against a scalar kernel called from a Python loop it is a
    20-30% regression. An earlier revision of this commit did apply
    nogil everywhere and asv caught exactly that -- two_sum 1.26x,
    two_prod 1.21x, diff_of_products ~1.25x, acc_sqrt_re 1.37x,
    orient3d_on_sphere 1.17x, all of them sub-microsecond kernels.

So the rule is per-call work, not per-decorator uniformity. Kernels left
alone for that reason include gca_const_lat_intersection and
get_number_of_intersections (per-edge, from the Python loops in
zonal.py and integrate.py), _compute_band_overlap_area (per-face,
zonal.py:327), _barycentric_coordinates (per-candidate, neighbors.py:954)
and the EFT primitives above. Several of these are slated to become
gufuncs; they can take nogil when they stop being called per element.

Two further sites stay as they are:

  * the guvectorize kernel in grid/neighbors.py -- numba rejects nogil
    as a @guvectorize option, and the gufunc machinery already drops the
    GIL around the loop.
  * the 15 parallel=True kernels keep parallel=True. Nested under dask's
    threadpool it oversubscribes, but dropping it belongs with the work
    that gives dask the chunk loop.

_build_node_edge_connectivity was the package's only bare @njit and so
also its only kernel without cache=True; normalized to
@njit(cache=True, nogil=True) while adding the flag.

Test suite: 953 passed, 1 skipped. test_plot_with_features fails
identically before and after (matplotlib figure size, unrelated).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cmdupuis3 and others added 2 commits September 14, 2026 12:35
The previous commit's flag placement is not self-evident from reading a
decorator, and nothing in the repo could catch it drifting. Both failure
directions are silent:

  * dropping nogil from an entry point serializes it under dask's
    threaded scheduler with no error, and no timing in the asv suite
    moves, because every benchmark in it drives uxarray from one thread;

  * adding nogil to a kernel only jitted code calls does nothing useful
    -- its caller already released the GIL for the whole call tree -- but
    still pays ~40ns per direct Python call, which is how the earlier
    blanket sweep turned into six asv regressions.

test_nogil_policy.py states the rule as four assertions over an AST scan
of the package: Python-reachable njit kernels must release the GIL;
jit-only ones must not set the flag; inline="always" kernels must not
(they have no call boundary to release at, and the plan commits to
keeping them inlined); and gufuncs must not (numba rejects the option).

"Reachable" counts a bare reference, not just a call. The kernels that
matter most are passed to xr.apply_ufunc rather than called -- notably
_build_n_nodes_per_face at connectivity.py:136 -- and a call-only scan
would classify exactly those as jit-only, which is backwards.

Seven kernels are exempt, each with its reason recorded; a further test
fails if an exemption stops being live, so the list cannot rot as these
become gufuncs. Each assertion was mutation-tested: reverting the flag on
an entry point, adding it to a jit-only kernel, to an inline kernel, and
to an exempt kernel are all caught.

nogil_scaling.py measures wall(2 threads)/wall(1 thread) on
_construct_edge_node_distances, alongside the same kernel recompiled
without the flag as a control:

    track_gil_scaling          1.07   (overlaps)
    track_gil_scaling_control  1.97   (serializes)

This benchmarks the flag rather than a user-facing operation, which is a
fair criticism -- but no user-facing path reaches a kernel from two
threads until core/ grows the chunked call sites, so there is nothing
else to measure yet. Its docstring says to retire it for a real chunked
workload once one exists.

Test suite: 965 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test_nogil_policy.py parsed every module with ``path.read_text()`` and no
encoding, so it decoded with the locale's. On Linux and macOS that is
UTF-8 and the scan passed; on Windows it is cp1252, which has no mapping
for byte 0x90.

Four modules carry a U+2010 HYPHEN (core/dataarray.py, core/dataset.py,
cross_sections/dataarray_accessor.py, grid/point_in_face.py), encoded
E2 80 90 -- and that 0x90 is undefined in cp1252:

    UnicodeDecodeError: 'charmap' codec can't decode byte 0x90 in
    position 82386: character maps to <undefined>

The scan runs at module scope, so the failure aborted collection and
errored all 12 tests on every Windows job in the matrix. Python 3.11
through 3.14 all default to the locale encoding there, which matches the
failure appearing on the Windows builds alone.

Pass encoding="utf-8" explicitly. Verified with -X warn_default_encoding
and EncodingWarning promoted to an error, so no implicit-encoding read
remains in the file.

Test suite: 965 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cmdupuis3
cmdupuis3 requested a review from Sevans711 September 21, 2026 22:42
Drops test_nogil_policy.py and the control arm of the scaling benchmark,
leaving the branch at the nogil=True decorator lines plus a single
track_gil_scaling number.

The policy test encoded, as four AST assertions and seven documented
exemptions, a rule that applies to 20 decorator lines. That is a lot of
machinery for the size of the change, and the scan carried its own
maintenance cost -- it already needed one fix for reading package
sources with the locale encoding, which broke the Windows matrix.

What is lost is the guard against the flag spreading back onto kernels
that do not want it. The regression it mattered most for is still
covered: dropping nogil from an entry point moves track_gil_scaling from
~1.07 toward ~2.0, which asv flags. Nothing now catches the other
direction -- a blanket re-application -- except asv noticing the
sub-microsecond kernels regress, which is how it was caught the first
time.

track_gil_scaling_control was the same kernel recompiled without the
flag, there so a broken harness could not read as a good result. Without
it the absolute value carries less weight; the regression signal does
not depend on it.

Test suite: 953 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cmdupuis3
cmdupuis3 marked this pull request as ready for review September 21, 2026 22:59

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

Looks ready to merge! Simple changes which should help with future scalability work, and shouldn't hurt in the meantime (no regressions in benchmarks). The new benchmark is a good spot check to help prevent regression, and demonstrates clearly that the nogil=True flag can cause improvements. Approving as-is!

@cmdupuis3
cmdupuis3 requested a review from rajeeja September 22, 2026 15: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.

GIL blocks parallelism for many kernels

2 participants