Repository navigation
Conversation
Merging this PR will improve performance by 2.05%1 warning
Performance Changes
Tip Curious why performance improved? Comment Comparing |
This was referenced Oct 20, 2026
Open
…hunk shorter than one vector
…nes of the last vector can win
…overflow test short
…t overflow on 32-bit x86
…asked 16-bit slices
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The SIMD functions leave the elements after the last full vector (up to one vector minus one) to a scalar loop. On short slices that loop is a large part of the call. Where it compiles to branches (for example
argminmaxfor every type, and f32/f64argmin/argmaxbefore #133), its run time depends on the data: fast on sorted data, slow on random data.This hurts most where the vectors are wide. With AVX-512 (the default dispatch on stable since #83), i16, u16 and f16 hold 32 lanes, so slices of 16-31 and 48-63 elements are 1.2-2.5x slower than with argminmax 0.6.3.
Changes
SIMDCore::_core_argminmax) takes any slice of at least one vector. If the length is not a multiple of the vector length, the core also compares the last vector of the slice, before its one horizontal reduction. That vector overlaps elements that the core already saw, and these keep their first index on ties. Its lanes get the indices of the vector after the last full one, and the core maps the result back after the reduction. This replaces the scalar loop on the remainder:task.rsno longer handles a remainder. (Only when one slice holds NaNs with different bit patterns can the unmaskednanarg*functions now return another of those NaNs, which the README limitations already allow.)halfcomparison, which branches on the sign bit. A last chunk shorter than one vector now starts in the previous chunk.split_arrayand its four-way matches are gone. The 8-bit SSE comment is updated to the new numbers.Rework (since ed58966)
The first version of this PR (c9f093a to ed58966) called the SIMD core a second time on the last vector when 8 or more elements remained (a second horizontal reduction plus a merge), and kept the scalar loop for 1-7 remaining elements (
MIN_SIMD_REMAINDER). The rework (26c60bf, 14e21a2) folds that vector into the first core call and adds the one-chunk shortcut.MIN_SIMD_REMAINDER,find_final_index_min/maxand the remainder blocks intask.rsare gone. In the library code (lib.rs,generic.rs,task.rs) the PR is now +127 −226 lines (first version: +129 −156); the tests grow by +93 −29.Reworked PR vs the first version (median of the per-cell time ratios; in brackets the share of cells that are more than 5% faster / slower against both copies of the base):
Faster: every slice with a remainder (one horizontal reduction instead of two, and no scalar loop), and short slices on f16/bf16 (no merge). Slower than the first version:
argmin/argmaxon x86: the index mapping after the reduction costs 0.4-0.9 ns. The one-chunk shortcut hides it in most cells; it shows where the slice spans two overflow chunks, e.g. u8 at 256 and 512 elements on AVX-512: +3-8% (up to 0.8 ns at 256, 0.8-1.5 ns at 512; one cell +4 ns, 12%).argmin/argmaxon NEON at 31 and 63 elements: +7-11% (about 1 ns); i64argminmaxon AVX-512 at 12 and 20 elements: +5-9% on sorted and reversed data, where the old scalar tail predicted well (random data: 0.72x).nanarg*slice of one chunk whose first element is NaN is now scanned in full; before, the merge loop returned at once. The result is the same (unless the NaNs have different bit patterns, see above), and the run time no longer depends on where the NaN is.Benchmarks
i7-1185G7 (AVX-512), Ryzen 9 5950X (AVX2; other users' jobs on other cores) and M5 (NEON). 11 types (f16, f32, f64, i16, i32, i64, u8, u16, u32, and f32/f64
nanarg*),argmin,argmaxandargminmax, random, sorted, reversed and pairwise-swapped data, 25 lengths from 1 to 65536 and streams of random lengths 1..=k. Every call gets fresh data from an L2-resident pool; p25 of 11 rounds. main and the new head each have an identical copy as a control.This PR vs main (median per group; share of cells more than 5% faster / slower against both copies):
Typical calls:
argmin, 16 elements (Lance 4-bit PQ)argmin, 256 elements (Lance 8-bit PQ)argmax, 127 elementsargmax, 63 elementsargminmax, 40 elementsargminmax, 12 elementsargminmax, random lengths 1..=256argmin, random lengths 1..=64argmax, 65536 elementsRegressions vs main
argmin/argmax: the index mapping (see above). It shows where the one-chunk shortcut does not apply: u8 at 256-512 elements on AVX-512 +6-11% (1.1-2.0 ns; one cell +4.9 ns, 15%).argmin/argmaxat 31 and 63 elements +9-15% (about 1 ns), f64 +5-7%; AVX-512 i64argminmaxat 12 and 20 elements +7-10% (0.7-1 ns) on sorted and reversed data (random data: 0.64x).argminmaxat 4096 (+8-20%), M5 u8 below 16 elements (up to 1.3x, 8 elements 6.1 to 8.1 ns), M5 i16/i32 at 40-48 elements (+6-13%). With every loop aligned to 64 bytes (both x86 CPUs), most of these cells are back at parity. A few long-array cells stay 6-10% slower (Ryzen u16argminmaxat 65536 and i16 at 4096, i7 u32argminat 65536); I could not explain them, and the medians of these groups stay at 1.00.argmax/argminon random lengths 1..=16 on AVX-512 were about 2 ns (1.18x) slower than with the first version of this PR: there LLVM now unrolls the unchanged scalar loop 4x, which mispredicts more when the length varies. At fixed lengths the unrolled loop is 0.94-0.98x.Code placement
A change to these functions moves the code after it. In the study for this rework, cells that changed by ±5-23% while their code did not change went back to parity in a build that aligns every loop to 64 bytes (
-C llvm-args=-align-loops=64). In a user's build, functions are aligned to 16 bytes only, so where a loop falls relative to the 64-byte lines depends on the rest of the binary, for main and for this PR alike.Follow-up
The index mapping is the only cost that comes from the design. Giving the last vector its true indices (
new_index + broadcast(rem)) instead removes it: in a prototype, exact multiples on x86 were 0.92-1.00x of the version without the mapping. That needs an index broadcast for each of the 56 SIMD implementations (for example in place of theINDEX_INCREMENTconstant, about +110 lines), so it is a separate PR.Checked
cargo test --all-featuresand--no-default-featureson an M5 (NEON). On the i7-1185G7, native tests (debug, release, no default features) and Intel SDE runs with-spr,-hsw,-nhmand an AVX-512F-only CPU (21 of 21 runs pass). On the Ryzen, release with all features and debug without default features. Clippy with-D warningsfor x86_64, i686, aarch64 and armv7 (tests/argminmax_test.rshas auseless_conversionwarning with clippy 1.95 on main as well).usize(1 << 33in a debug build on i686; CI only builds that target): it is now computed only when no length is given. Opus found that the masked 16-bit route to AVX2 was neither stated nor measured: it is now measured (see above).