Visitar URL original
ENH: Release GIL in np.repeat when Python API is not needed by lakshayg · Pull Request #32910 · numpy/numpy · GitHub
Skip to content

ENH: Release GIL in np.repeat when Python API is not needed - #32910

Open
lakshayg wants to merge 4 commits into
numpy:mainfrom
lakshayg:repeat-release-gil
Open

lakshayg wants to merge 4 commits into
numpy:mainfrom
lakshayg:repeat-release-gil

Conversation

@lakshayg

@lakshayg lakshayg commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

PR summary

Most of the functions in numpy/_core/src/multiarray/item_selection.c release the GIL when performing operations that don't need to call python c apis. PyArray_Repeat looks like an oversight.

This commit follows npy_fasttake_impl's implementation and releases the GIL when doing a memcpy or other non-Python API work.

@ngoldbaum informed me that this change should help both GIL and free-threaded builds. The benefits for the GIL build are obvious but for the free-threaded build:

attached threads that spend a lot of time inside native calls can block other threads from proceeding with a GC pass on the free-threaded build, this is why it’s still correct to detach before any expensive native calls on the free-threaded build

First time contributor introduction

(Not a first time contributor but it has been a while since #10786)

I have been a regular PyTorch contributor and have been a NumPy user for a long time. I am interested in low level systems optimization. I discovered this gap in the implementation of PyArray_Repeat when I came across an old PR (#4497) that made a similar change to another function.

AI Disclosure

I used gpt-6.1-sol to create an initial version of the benchmark. I have simplified the benchmark code since that version. I also used the same LLM to do a local code review but all the code was written by me.

Assisted-by: gpt-6.1-sol
@lakshayg

lakshayg commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author
        +======== ============= ============= ============= ============= ============= =============
        |--                                          width / dtype
        |-------- -----------------------------------------------------------------------------------
        |  rows    1 / float64    1 / string    1 / object   8 / float64    8 / string    8 / object
        |======== ============= ============= ============= ============= ============= =============
BEFORE  |   16       741±50ns      946±10ns      944±9ns      935±10ns      1.36±0.02μs   1.57±0.04μs
AFTER   |   16       760±30ns      993±10ns    1.02±0.04μs    850±30ns      1.45±0.05μs   1.57±0.04μs
BEFORE  |  250     1.25±0.01μs    3.19±0.2μs   3.58±0.04μs   2.02±0.01μs   12.70±0.60μs  11.80±0.02μs
AFTER   |  250     1.32±0.07μs   3.08±0.04μs   3.67±0.10μs   2.13±0.07μs   14.70±1.00μs  11.90±0.03μs
BEFORE  |  251      1.32±0.1μs   3.01±0.03μs   3.58±0.07μs   1.95±0.02μs   14.00±0.40μs  12.60±0.70μs
AFTER   |  251     1.34±0.09μs   3.14±0.04μs   3.75±0.20μs   2.06±0.01μs   14.20±0.90μs  11.90±0.01μs
BEFORE  | 125000    212±0.1μs    2.48±0.02ms   1.44±0.03ms   1.06±0.03ms   31.10±0.10ms   6.49±0.08ms
AFTER   | 125000     212±2μs     2.47±0.02ms   1.40±0.01ms   1.06±0.05ms   30.80±0.10ms   6.51±0.10ms
        |======== ============= ============= ============= ============= ============= =============
        |
        |========= ============= ============ ============ ============= ============ ============
        |--                                         width / dtype
        |--------- -------------------------------------------------------------------------------
        | workers   1 / float64   1 / string   1 / object   8 / float64   8 / string   8 / object
        |========= ============= ============ ============ ============= ============ ============
BEFORE  |    1      1.85±0.03ms   12.5±0.4ms   10.7±0.3ms    6.20±0.30ms    72.6±5ms     33.3±4ms
AFTER   |    1      1.94±0.02ms   12.2±0.3ms   11.2±0.2ms    5.62±0.50ms    63.3±1ms     36.0±2ms
BEFORE  |    2      1.92±0.08ms   13.5±0.5ms   11.1±0.3ms    6.71±0.30ms    68.2±3ms     37.3±5ms
AFTER   |    2       996±10μs      8.7±0.2ms   11.3±0.1ms    3.53±0.04ms    43.1±2ms     40.6±2ms
        +========== ============= ============ ============ ============= ============ ============
                    ^^^^^^^^^^^^  ^^^^^^^^^^                 ^^^^^^^^^^^    ^^^^^^^^^

Benchmark results show no change in performance for single-threaded test and speedup in multithreaded test for the columns marked with ^^^^^^^^. This matches expectations because we cannot release the GIL when dealing with object

@jorenham jorenham added the sustain-2026 Issues reserved for NumFOCUS Sustaining Open Source Series 2026 label Oct 6, 2026

@MaanasArora MaanasArora left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, the code looks good and the performance improvement is nice! My review comments are just about the benchmarks: I'm not sure if we need the threaded one, the Repeat one is good to work on as it seems to have been missed.

self.arr.repeat(2, axis=0)


class RepeatThreads(Benchmark):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think we should add threaded benchmarks to the suite like this; they are too environment-defined. At least, there doesn't seem to be precedent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I didn't find any other benchmarks like this but the performance improvement is visible only when multiple threads are in use. Is the concern that the benchmark numbers won't be stable?

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.

IMO just delete the benchmark from this PR. It's OK if perf improvements come without a benchmark if writing one would make the benchmark suite overall harder to deal with.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

from .common import TYPES1, Benchmark


def _repeat_array(rows, width, dtype):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We don't need a dictionary to map the dtype, could you please use the pattern in the rest of the benchmarks, removing this helper and merging it into the setup?

In case you intended to test StringDType, this string would instead use fixed-width strings, though I think the usual types are fine for the suite. For measuring the improvement with StringDType you might use a one-off script with threading.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

# elements, respectively, straddling the GIL-release threshold.
# float64 widths 1 and 8 exercise 8-byte and 64-byte chunks,
# covering both the specialized and fallback copy paths.
params = [[16, 250, 251, 125000], [1, 8],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Benchmarks in the NumPy suite will continue to track performance, so if we want to add a benchmark, it should adjust these parameters to be more representative to the use of np.repeat in general, rather than specific to this PR. I think we need at least repeat counts and perhaps fewer varations over rows?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Do you have suggestions on what params I should use?

@lakshayg
lakshayg force-pushed the repeat-release-gil branch 2 times, most recently from 3a83d7e to 7291daa Compare October 9, 2026 18:41
Comment on lines +13 to +20
dtype, value = None, None
if dtype == "float64":
dtype, value = np.float64, 3.14
elif dtype == "string":
dtype, value = np.StringDType, "repeat benchmark value"
else: # dtype == "object"
dtype, value = object, {1, 2, 3, 4, 5, 6, 7}
self.arr = np.full(shape, value, dtype=dtype)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

dtype, value = None, None will overwrite the parameters and always produce an object array. Instead, along with the suggestion inline above:

Suggested change
dtype, value = None, None
if dtype == "float64":
dtype, value = np.float64, 3.14
elif dtype == "string":
dtype, value = np.StringDType, "repeat benchmark value"
else: # dtype == "object"
dtype, value = object, {1, 2, 3, 4, 5, 6, 7}
self.arr = np.full(shape, value, dtype=dtype)
self.arr = np.ones(shape, dtype=dtype)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PS I'm fine with special-casing StringDType since you need a longer string to perform an alllocation:

if dtype == "T":
    self.arr = np.full(shape, "repeat benchmark value", dtype=dtype)
else:
    self.arr = np.ones(shape, dtype=dtype)

@lakshayg lakshayg Oct 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ah, my bad, I renamed things without looking carefully. Took your suggestion.


class Repeat(Benchmark):
params = [[(16, 16), (500, 2), (125000, 1)],
["float64", "string", "object"],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
["float64", "string", "object"],
TYPES1 + ["O", "i,O", "T"],

A few more cases, but same as the others.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

- Remove multithreaded benchmark
- Replace str with np.StringDType
- Make number of reps a parameter
Most of the functions in numpy/_core/src/multiarray/item_selection.c
release the GIL when performing operations that don't need to call
python c apis. PyArray_Repeat looks like an oversight.

This commit releases the GIL when performing the memory operation.
npy_fasttake_impl releases the GIL in two scenarios:
1. needs_refcounting is false, OR
2. casting function flags indicate that python API is not needed

I already implemented the first condition in the
previous commit, this one adds the second condition
@lakshayg
lakshayg force-pushed the repeat-release-gil branch from 7291daa to b763e9c Compare October 9, 2026 19:53

@MaanasArora MaanasArora left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @lakshayg, the benchmark looks good now! But sorry, I seem to have missed a regression (likely only on GIL builds) with StringDType. It seems that releasing the GIL causes a slowdown:

Single-threaded repeat time: 4.426519 seconds
Multi-threaded repeat time: 19.652601 seconds
Script
from concurrent.futures import ThreadPoolExecutor
import numpy as np
import timeit


def time_repeat(arr):
    def repeat():
        np.repeat(arr, 10)

    execution_time = timeit.timeit(repeat, number=10)
    return execution_time


def time_repeat_threaded(arr):
    def repeat():
        np.repeat(arr, 10)

    start_time = timeit.default_timer()

    with ThreadPoolExecutor(max_workers=4) as executor:
        futures = [executor.submit(repeat) for _ in range(10)]
        for future in futures:
            future.result()

    execution_time = timeit.default_timer() - start_time
    return execution_time


def main():
    gen = np.random.default_rng(seed=42)
    arr = gen.integers(
        low=0, high=100_000_000, size=(10_000, 100), dtype=np.int32
    ).astype("T")

    single_thread_time = time_repeat(arr)
    print(f"Single-threaded repeat time: {single_thread_time:.6f} seconds")

    start_time = timeit.default_timer()
    time_repeat_threaded(arr)
    multi_thread_time = timeit.default_timer() - start_time
    print(f"Multi-threaded repeat time: {multi_thread_time:.6f} seconds")


if __name__ == "__main__":
    main()

This seems to be due to a conflict between the allocator locks. I've added a suggestion that I think may work around the issue at least in the short run, but it would be great to briefly benchmark (with a one-off script) for all relevant dtype cases before we finish this.


if (npy_fastrepeat(n_outer, n, nel, chunk, broadcast, counts, new_data,
old_data, elsize, &cast_info, needs_custom_copy) < 0) {
if (!needs_custom_copy || !(flags & NPY_METH_REQUIRES_PYAPI)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
if (!needs_custom_copy || !(flags & NPY_METH_REQUIRES_PYAPI)) {
else {

This makes the behavior different from take, but we don't get the GIL regression (could you check too?), and built-in types like object require a copy anyway.

(Let's remove the empty line above.)

Benchmark with change on my machine:

Single-threaded repeat time: 4.423745 seconds
Multi-threaded repeat time: 4.471762 seconds

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You are effectively suggesting dropping the !(flags & NPY_METH_REQUIRES_PYAPI) part, right?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Indeed, just doing it with else, as we're just using the opposite condition!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I used your script on all the commits in this PR (d0a4449 is essentially the same as your suggestion). The numbers don't show any regression on my setup.

baseline (3a420df) !needs_custom_copy (d0a4449) both conds (b763e9c)
single threaded 8.659093 s 8.637753 s 8.837712 s
multi threaded 8.976340 s 9.100565 s 9.056935 s

I guess we need to dig in deeper. I am on an x86_64 machine, running this inside a docker container. Is the 4s/19s measurement reproducible?

@ngoldbaum

Copy link
Copy Markdown
Member

I was able to reproduce the slowdown on an ARM64 Mac. However, I don’t think we should do anything special in this PR for StringDType. Instead, this slowdown points to an improvement we can make in both np.take and np.repeat: acquire StringDType’s allocator lock outside the inner loop, rather than acquiring it and releasing it over and over again via the generic copy function.

Fixing this problem is scope creep and @lakshayg shouldn’t be responsible to fix it here.

FWIW np.take has the exact same problem right now because it releases the GIL.

I’ll try to take a look at this next week.

@MaanasArora

MaanasArora commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Instead, this slowdown points to an improvement we can make in both np.take and np.repeat: acquire StringDType’s allocator lock outside the inner loop, rather than acquiring it and releasing it over and over again via the generic copy function.

That should solve the problem for good indeed. My suggestion was mostly scoping down the PR until we have that, given StringDType would be the only dtype affected by the final commit. But happy to merge this as-is if you'd rather not bundle this change with the later improvement which should be soon?

This branch has not been deployed

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

Labels

01 - Enhancement sustain-2026 Issues reserved for NumFOCUS Sustaining Open Source Series 2026

Projects

Development

Successfully merging this pull request may close these issues.

5 participants