Skip to content

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

Draft
cmdupuis3 wants to merge 1 commit into
UXARRAY:mainfrom
cmdupuis3:cmd/nogil
Draft

Release the GIL in all numba kernels (nogil=True)#1755
cmdupuis3 wants to merge 1 commit 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 -- which is what dask="parallelized" does -- serialized them completely, regardless of how many workers were configured.

Adds nogil=True to the remaining 136 sites, making @njit(cache=True, nogil=True) the house default. These are all nopython, allocation-light leaf kernels with no object-mode fallback, so releasing the lock is safe: numba reacquires it to raise and on return.

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.

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

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 stayed the same:

Change Before [38b33d8] <v2026.09.0> After [7d00e2a] Ratio Benchmark (Parameter)
5.40±0.04ms 5.45±0.1ms 1.01 bench_connectivity.Connectivity.time_edge_face('120km')
1.76±0.01ms 1.76±0.01ms 1.00 bench_connectivity.Connectivity.time_edge_face('480km')
4.33±0.04ms 4.29±0.03ms 0.99 bench_connectivity.Connectivity.time_edge_node('120km')
1.39±0.02ms 1.37±0.02ms 0.99 bench_connectivity.Connectivity.time_edge_node('480km')
4.27±0.02ms 4.38±0.1ms 1.02 bench_connectivity.Connectivity.time_face_edge('120km')
1.37±0.01ms 1.36±0.02ms 0.99 bench_connectivity.Connectivity.time_face_edge('480km')
6.28±0.01ms 6.47±0.06ms 1.03 bench_connectivity.Connectivity.time_face_face('120km')
2.10±0.02ms 2.13±0.02ms 1.01 bench_connectivity.Connectivity.time_face_face('480km')
64.0±1μs 63.5±3μs 0.99 bench_connectivity.Connectivity.time_face_node('120km')
62.8±3μs 61.1±2μs 0.97 bench_connectivity.Connectivity.time_face_node('480km')
444±7μs 454±9μs 1.02 bench_connectivity.Connectivity.time_n_nodes_per_face('120km')
361±5μs 374±10μs 1.03 bench_connectivity.Connectivity.time_n_nodes_per_face('480km')
5.78±0.02ms 5.74±0.06ms 0.99 bench_connectivity.Connectivity.time_node_edge('120km')
1.76±0.01ms 1.76±0.01ms 1.00 bench_connectivity.Connectivity.time_node_edge('480km')
82.1±2ms 82.3±5ms 1.00 bench_connectivity.Connectivity.time_node_face('120km')
4.76±0.03ms 4.71±0.02ms 0.99 bench_connectivity.Connectivity.time_node_face('480km')
7.42±0.04ms 7.73±0.2ms 1.04 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.58±0.04ms 2.42±0.09ms 0.94 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
10.5±10s 9.51±10ms ~0.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.91±0.01ms 1.99±0.06ms 1.04 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.3k 38.3k 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
335M 335M 1.00 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
367M 365M 0.99 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
336M 337M 1.00 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
336M 336M 1.00 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.14±0.04μs 1.21±0.02μs 1.06 geometry_kernels.AccucrossKernels.time_accucross
2.59±0.01μs 2.66±0.03μs 1.03 geometry_kernels.AccucrossKernels.time_accucross_pair
400±10ns 395±10ns 0.99 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
396±20ns 395±20ns 1.00 geometry_kernels.EFTPrimitives.time_diff_of_products
370±20ns 351±10ns 0.95 geometry_kernels.EFTPrimitives.time_two_prod
351±10ns 355±20ns 1.01 geometry_kernels.EFTPrimitives.time_two_sum
605±10ns 651±10ns 1.07 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
681±10ns 651±20ns 0.96 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
721±9ns 716±9ns 0.99 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
761±20ns 756±30ns 0.99 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
831±20ns 831±30ns 1.00 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
936±20ns 931±7ns 0.99 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
37.1±0.4μs 36.3±0.7μs 0.98 geometry_kernels.OrientPredicates.time_on_minor_arc
701±40ns 696±10ns 0.99 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
3.12±0ms 3.14±0.01ms 1.01 geometry_samebody.SameBodyConstLat.time_accux_dispatch
1.33±0.01ms 1.33±0.01ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_kernel
2.10±0.01ms 2.10±0.01ms 1.00 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
155±0.4μs 158±2μs 1.02 geometry_samebody.SameBodyConstLat.time_fp64_kernel
27.8±0.01ms 27.7±0.01ms 0.99 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
6.80±0ms 6.84±0ms 1.01 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
21.0±0ms 21.0±0.01ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
883±1μs 879±2μs 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
831±4ms 865±10ms 1.04 import.Imports.timeraw_import_uxarray
292M 292M 1.00 import.Imports.track_peakmem_import_uxarray
2.25±0.02ms 2.34±0.1ms 1.04 mpas_ocean.CheckNorm.time_check_norm('120km')
1.81±0.01ms 1.81±0.02ms 1.00 mpas_ocean.CheckNorm.time_check_norm('480km')
1.07±0ms 1.10±0.01ms 1.03 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
556±7μs 566±5μs 1.02 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
668±10μs 690±10μs 1.03 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
598±9μs 593±20μs 0.99 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
5.04±0.04ms 5.03±0.03ms 1.00 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.51±0.01ms 3.56±0.04ms 1.01 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
102±0.5ms 102±0.4ms 1.00 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
10.3±0.03ms 10.3±0.4ms 1.00 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
19.7±0.06ms 20.2±0.5ms 1.02 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
1.13±0ms 1.17±0.03ms 1.04 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
10.6±0.01ms 10.7±0.06ms 1.01 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
771±8μs 789±10μs 1.02 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
536±10ms 534±10ms 1.00 mpas_ocean.CrossSections.time_const_lat('120km', 1)
270±4ms 274±4ms 1.02 mpas_ocean.CrossSections.time_const_lat('120km', 2)
141±3ms 140±2ms 0.99 mpas_ocean.CrossSections.time_const_lat('120km', 4)
468±3ms 486±3ms 1.04 mpas_ocean.CrossSections.time_const_lat('480km', 1)
234±2ms 238±2ms 1.01 mpas_ocean.CrossSections.time_const_lat('480km', 2)
121±0.7ms 125±1ms 1.03 mpas_ocean.CrossSections.time_const_lat('480km', 4)
355M 355M 1.00 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('120km', 1)
355M 355M 1.00 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('120km', 2)
355M 355M 1.00 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('120km', 4)
338M 338M 1.00 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('480km', 1)
338M 338M 1.00 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('480km', 2)
338M 338M 1.00 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('480km', 4)
22.8±0.2ms 22.6±0.1ms 0.99 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
2.60±0.06ms 2.67±0.05ms 1.03 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
13.9±0.1ms 14.0±0.2ms 1.01 mpas_ocean.FaceAreas.time_face_areas('120km')
3.79±0.05ms 3.81±0.08ms 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')
841±6ms 844±5ms 1.00 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
52.5±0.7ms 50.7±1ms 0.96 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
73.1±0.5ms 73.1±0.7ms 1.00 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
5.89±0.2ms 5.81±0.09ms 0.99 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
14.8±0.1ms 14.9±0.2ms 1.00 mpas_ocean.Gradient.time_gradient('120km')
2.03±0.02ms 2.06±0.02ms 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.43M 3.43M 1.00 mpas_ocean.Gradient.track_peakmem_gradient('120km')
218k 218k 1.00 mpas_ocean.Gradient.track_peakmem_gradient('480km')
350M 350M 1.00 mpas_ocean.GradientColdStartRss.peakmem_gradient('120km')
332M 330M 0.99 mpas_ocean.GradientColdStartRss.peakmem_gradient('480km')
372±6μs 377±10μs 1.01 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
200±4μs 210±10μs 1.05 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
571±7μs 607±10μs 1.06 mpas_ocean.Integrate.time_integrate('120km')
495±9μs 525±30μs 1.06 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')
185±3ms 187±2ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
185±2ms 190±2ms 1.03 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
187±2ms 187±0.8ms 1.00 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
14.0±0.1ms 14.4±0.4ms 1.03 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
14.0±0.2ms 14.3±0.2ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
14.0±0.1ms 14.2±0.5ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
257±0.08ms 257±0.2ms 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)
533±2ms 534±2ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 5.0)
14.1±0.04ms 14.1±0.01ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('480km', 1.0)
27.2±0.1ms 27.2±0.2ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('480km', 15.0)
17.5±0.08ms 17.4±0.05ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('480km', 5.0)
253±0.5ms 253±0.5ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 1.0)
1.36±0s 1.37±0.01s 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 15.0)
528±0.4ms 528±1ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 5.0)
13.8±0.03ms 13.7±0.04ms 0.99 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 1.0)
28.4±2ms 26.5±0.06ms 0.93 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 15.0)
17.1±0.05ms 17.1±0.04ms 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)
48.3±0.4ms 48.1±0.6ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'grid_chunks')
25.0±0.1ms 24.9±0.1ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'numpy')
44.8±0.5ms 46.0±0.4ms 1.03 mpas_ocean.NeighborhoodDask.time_mean('120km', 'time_chunks')
11.0±0.06ms 11.3±0.3ms 1.03 mpas_ocean.NeighborhoodDask.time_mean('480km', 'grid_chunks')
552±3μs 553±8μs 1.00 mpas_ocean.NeighborhoodDask.time_mean('480km', 'numpy')
8.04±0.2ms 7.97±0.1ms 0.99 mpas_ocean.NeighborhoodDask.time_mean('480km', 'time_chunks')
5.84M 5.83M 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.68M 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')
538k 529k 0.98 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'time_chunks')
13.3±0.01s 13.8±0.2s 1.03 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'mean')
14.1±0s 14.2±0s 1.01 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'median')
244±0.3ms 245±1ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'mean')
252±0.3ms 253±0.6ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'median')
1.41±0s 1.41±0s 1.00 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.1ms 27.9±0.3ms 1.02 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'mean')
29.0±0.3ms 29.3±0.1ms 1.01 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'median')
44.9±0.03ms 44.9±0.04ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'mean')
260±0.4ms 260±0.2ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'median')
610±20μs 726±50μs ~1.19 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'mean')
2.07±0.01ms 2.25±0.01ms 1.08 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')
356±10μs 381±4μs 1.07 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
342±9μs 359±10μs 1.05 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
326±5μs 341±10μs 1.05 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
309±10μs 339±10μs 1.10 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
206±0.5ms 206±2ms 1.00 mpas_ocean.RemapDownsample.time_bilinear_remapping
227±1ms 228±2ms 1.01 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
15.6±0.07ms 15.8±0.07ms 1.01 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
1.12±0s 1.11±0s 1.00 mpas_ocean.RemapUpsample.time_bilinear_remapping
36.5±0.6ms 37.1±0.4ms 1.01 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
11.3±0.2ms 11.5±0.3ms 1.02 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
9.53±0.1ms 9.55±0.05ms 1.00 mpas_ocean.ZonalAverage.time_zonal_average('120km')
5.05±0.09ms 5.41±0.06ms 1.07 mpas_ocean.ZonalAverage.time_zonal_average('480km')
357M 356M 1.00 mpas_ocean.ZonalAveragePeakMem.peakmem_zonal_average('120km')
340M 340M 1.00 mpas_ocean.ZonalAveragePeakMem.peakmem_zonal_average('480km')
6.21±0.03ms 6.47±0.2ms 1.04 quad_hexagon.QuadHexagon.time_open_dataset
5.34±0.1ms 5.70±0.2ms 1.07 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
73.8k 73.8k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_dataset
73k 73k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_grid

Benchmarks that have got worse:

Change Before [38b33d8] <v2026.09.0> After [7d00e2a] Ratio Benchmark (Parameter)
+ 632k 764k 1.21 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'grid_chunks')

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

1 participant