ENH: use x86-simd-sort for descending sorts and partitions - #32690
Conversation
The x86 SIMD paths were gated behind `!reverse` since numpygh-31345, because x86-simd-sort placed NaNs at the start of a descending result while NumPy keeps them at the end (see `npy::cmp<Tag, reverse>`). The vendored library gained a separate `nans_last` switch in numpy/x86-simd-sort#235, which came in with the submodule bump in numpygh-31947, so `descending` can now be forwarded directly: `nans_last` defaults to true and gives exactly NumPy's ordering. Removes the dispatch guards in quicksort.hpp/selection.hpp and the `assert(!reverse)` in the three dispatch translation units, and threads `reverse` through QSelect/ArgQSelect so partition and argpartition are covered as well. Measured on AVX2 over 1M elements, descending now runs at the speed of ascending: ~4-9x faster than before for `sort`/`partition` and ~2-3x for `argsort`/`argpartition`. Closes part of numpygh-31423. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The `axis=0` example had a tied column (`[3, 3]`), so the indices it printed depended on how argpartition happens to break ties. With descending argpartition now going through x86-simd-sort, which implements descending as ascending followed by a reverse, that tie flips on x86 while the scalar path used on other platforms keeps the old order -- the expected output would be correct only on x86. Drop the tie instead of pinning one platform's answer: a 2x4 array keeps the "row and its reverse" structure of the example but has distinct values in every column and row, so the result is uniquely determined by the values and is the same for any implementation. top_k already documents that the indices it returns for duplicate values are not stable. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Is there any chance you could share the benchmark? I'd be interested to run it on my ryzen cpu that supports avx512 as well. |
Results on my machine: i7-13650HX (AVX2, no AVX-512), Linux, CPython 3.14, release builds. The number from the OP is on a different machine.
n = 10,000
bench_descending_sort.py"""
pyperf benchmark for numpy/numpy#32690: x86-simd-sort for descending
sort / argsort / partition / argpartition.
Run once per NumPy build and compare the results:
python bench_descending_sort.py -o main.json # in the env with main
python bench_descending_sort.py -o pr.json # in the env with the PR
python -m pyperf compare_to main.json pr.json --table
Options (on top of the usual pyperf ones such as --fast, --rigorous,
--affinity):
--dtypes float64,int32 dtypes to run (default: see DTYPES)
--sizes 10000,1000000 array sizes to run (default: 1000000)
--funcs sort,argsort functions to run (default: all four)
--kth-frac 0.5 kth for (arg)partition, as a fraction of n
All functions are called out-of-place (``np.sort(a)``, not ``a.sort()``) so
every call sees the same unsorted input. That includes a copy of the input
(or the creation of an index array), which is the same on both builds; the
``copy`` benchmark measures that fixed cost so it can be subtracted.
"""
from functools import partial
import numpy as np
import pyperf
DTYPES = ["float64", "float32", "float16", "int64", "int32", "int16"]
SIZES = [1_000_000]
FUNCS = ["sort", "argsort", "partition", "argpartition"]
def make_data(dtype, n):
rng = np.random.default_rng(12345)
dtype = np.dtype(dtype)
if dtype.kind == "f":
return rng.standard_normal(n).astype(dtype)
info = np.iinfo(dtype)
return rng.integers(info.min, info.max, size=n, dtype=dtype, endpoint=True)
def add_cmdline_args(cmd, args):
cmd.extend(["--dtypes", args.dtypes, "--sizes", args.sizes,
"--funcs", args.funcs, "--kth-frac", str(args.kth_frac)])
def main():
runner = pyperf.Runner(add_cmdline_args=add_cmdline_args)
parser = runner.argparser
parser.add_argument("--dtypes", default=",".join(DTYPES))
parser.add_argument("--sizes", default=",".join(map(str, SIZES)))
parser.add_argument("--funcs", default=",".join(FUNCS))
parser.add_argument("--kth-frac", type=float, default=0.5)
args = runner.parse_args()
# Recorded in the JSON so it is clear which build and which SIMD
# kernels produced the numbers (`pyperf metadata file.json`).
from numpy._core._multiarray_umath import __cpu_features__
runner.metadata["numpy_version"] = np.__version__
runner.metadata["numpy_file"] = np.__file__
runner.metadata["numpy_cpu_features"] = " ".join(
name for name, have in __cpu_features__.items() if have)
for dtype in args.dtypes.split(","):
for n in map(int, args.sizes.split(",")):
a = make_data(dtype, n)
kth = min(n - 1, int(n * args.kth_frac))
tag = f"{dtype} n={n}"
runner.bench_func(f"copy {tag}", a.copy)
for name in args.funcs.split(","):
func = getattr(np, name)
extra = (kth,) if "partition" in name else ()
for order, descending in (("asc", False), ("desc", True)):
# bench_func() does not forward keyword arguments
runner.bench_func(
f"{name} {tag} {order}",
partial(func, a, *extra, descending=descending))
if __name__ == "__main__":
main() |
ngoldbaum
left a comment
There was a problem hiding this comment.
I benchmarked this on an i5-8600K on Windows using an AI model. It supports AVX2 and X86_V3 but not AVX-512. I can confirm the performance improvement. I'd also like someone with an AVX-512 CPU to test. I have some nitpicks about the release note, see below.
| use the same SIMD kernels as the ascending versions for most integer and | ||
| floating point dtypes. On AVX2 this is 2-9x faster than before. As a | ||
| result the indices returned for tied elements may differ from previous | ||
| versions; as before, they are not guaranteed. |
There was a problem hiding this comment.
The release note filename should use the number for this PR. I've noticed Claude is uncomfortable with the uncertainty of not knowing the PR number ahead of time so it randomly does other things.
The last sentence doesn't talk about stable sorts. Rather than making it more precise, you could also just not include the sentence about differing results for unstable sorts: we don't guarantee those.
MaanasArora
left a comment
There was a problem hiding this comment.
Thanks @eendebakpt, overall looks good to me! Just one real change inline. This is largely straightforward as the major update was in x86-simd-sort.
It would be nice to run the asv sort and partition benchmarks too, I think.
|
|
||
| template<typename Tag, typename T> | ||
| inline bool quickselect_dispatch(T* v, npy_intp num, npy_intp kth) | ||
| inline bool quickselect_dispatch(T* v, npy_intp num, npy_intp kth, bool reverse) |
There was a problem hiding this comment.
Should this not be a template parameter as in quicksort_dispatch?
| --------------------------------------------------------------- | ||
| `numpy.sort`, `numpy.argsort`, `numpy.partition` and `numpy.argpartition` | ||
| with ``descending=True`` no longer fall back to scalar code on x86, and now | ||
| use the same SIMD kernels as the ascending versions for most integer and |
There was a problem hiding this comment.
Are there any exceptions (dtypes for which we don't use the same kernels)?
There was a problem hiding this comment.
There are some dtypes where no kernels are used, but if used they are the same.
There was a problem hiding this comment.
Right thanks, maybe something like this then? (But total nit)
... and now use the same SIMD kernels as the ascending versions for all dtypes that supported SIMD optimization.
or even
... and now use the same SIMD optimizations as with descending=False.
Make `reverse` a template parameter of quickselect_dispatch and argquickselect_dispatch, consistent with the quicksort dispatchers. Name the release note after the PR and drop the sentence on tie order. Co-Authored-By: Claude Fable 5.1 <[email protected]>
|
I'm not getting that significant speed up on AMD Ryzen 7 7800X3D. I ran the benchmark you posted earlier by installing numpy like
Benchmark hidden because not significant (21): sort float64 n=1000000 asc, partition float64 n=1000000 asc, argpartition float64 n=1000000 asc, copy float32 n=1000000, sort float32 n=1000000 asc, argsort float32 n=1000000 asc, partition float32 n=1000000 asc, copy float16 n=1000000, partition float16 n=1000000 asc, argpartition float16 n=1000000 desc, sort int64 n=1000000 asc, argsort int64 n=1000000 asc, partition int64 n=1000000 asc, copy int32 n=1000000, sort int32 n=1000000 asc, argsort int32 n=1000000 asc, partition int32 n=1000000 asc, argpartition int32 n=1000000 asc, sort int16 n=1000000 asc, partition int16 n=1000000 asc, argpartition int16 n=1000000 desc |
|
I was about to check on another AVX 512 arch on a local supercomputer (Intel(R) Xeon(R) Platinum 8480+). I'll move on though, since Ryzen should do it. |
yours supports |
There was a problem hiding this comment.
Thanks for the change, LGTM! I don't think we need to block for more benchmarking, though it's nice to have; descending SIMD is something we need to support anyway.
(If the benchmarking results are unexpected, I guess we should compare first with the ascending versions, then check with x86-simd-sort if they do diverge.)
Edit: sorry one thing, let's ping @seberg, as we even planned to backport this as a performance bug which might still be nice?
|
Okay yeah I ran the benchmarks with My AI model informs me that:
Edit: apparently this is know and there are issues already open for it So LGTM too! Not formally approving as I very quickly skimmed through the code but it looked like simple changes. Benchmark results with AVX2 only
Benchmark hidden because not significant (13): argsort float64 n=1000000 asc, argpartition float32 n=1000000 asc, copy float16 n=1000000, argsort float16 n=1000000 asc, partition float16 n=1000000 asc, partition float16 n=1000000 desc, argpartition float16 n=1000000 desc, copy int64 n=1000000, argsort int64 n=1000000 asc, argpartition int64 n=1000000 asc, argsort int32 n=1000000 asc, argsort int16 n=1000000 desc, argpartition int16 n=1000000 desc |
A runtime flag turns the comparator of the is_sorted early exit into a function pointer, slowing ascending argsort of sorted input by ~30%. Co-Authored-By: Claude Fable 5.1 <[email protected]>
ngoldbaum
left a comment
There was a problem hiding this comment.
Thank you for following through on this @eendebakpt!
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
PR summary
The x86 SIMD paths were gated behind
!reversesince gh-31345, because x86-simd-sort placed NaNs at the start of a descending result while NumPy keeps them at the end (seenpy::cmp<Tag, reverse>). The vendored library gained a separatenans_lastswitch in numpy/x86-simd-sort#235, which came in with the submodule bump in gh-31947, sodescendingcan now be forwarded directly:nans_lastdefaults to true and gives exactly NumPy's ordering.Measured on AVX2 over 1M elements, descending now runs at the speed of ascending: ~4-9x faster than before for
sort/partitionand ~2-3x forargsort/argpartition.AI Disclosure
Claude was used in creation of the PR. Identified while researching options for making stable sort the default.