Repository navigation
Conversation
Assisted-by: gpt-6.1-sol
Benchmark results show no change in performance for single-threaded test and speedup in multithreaded test for the columns marked with |
MaanasArora
left a comment
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| from .common import TYPES1, Benchmark | ||
|
|
||
|
|
||
| def _repeat_array(rows, width, dtype): |
There was a problem hiding this comment.
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.
| # 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], |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Do you have suggestions on what params I should use?
3a83d7e to
7291daa
Compare
| 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) |
There was a problem hiding this comment.
dtype, value = None, None will overwrite the parameters and always produce an object array. Instead, along with the suggestion inline above:
| 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) |
There was a problem hiding this comment.
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)There was a problem hiding this comment.
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"], |
There was a problem hiding this comment.
| ["float64", "string", "object"], | |
| TYPES1 + ["O", "i,O", "T"], |
A few more cases, but same as the others.
- 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
7291daa to
b763e9c
Compare
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
| 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
There was a problem hiding this comment.
You are effectively suggesting dropping the !(flags & NPY_METH_REQUIRES_PYAPI) part, right?
There was a problem hiding this comment.
Indeed, just doing it with else, as we're just using the opposite condition!
There was a problem hiding this comment.
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?
|
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 Fixing this problem is scope creep and @lakshayg shouldn’t be responsible to fix it here. FWIW I’ll try to take a look at this next week. |
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? |
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:
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-solto 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.