Repository navigation
Faster Array.Sort for large integer arrays with unpredictable order - #136670
armanossiloko wants to merge 2 commits into
Conversation
|
Azure Pipelines: Successfully started running 3 pipeline(s). 13 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
|
Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch |
|
Tagging subscribers to this area: @dotnet/area-system-runtime |
|
@dotnet-policy-service agree |
a600250 to
3b73e77
Compare
Sort primitive integer keys (the default-comparer path of Array.Sort, Span.Sort and List.Sort) with a quicksort that partitions without data-dependent branches, following the design of Rust's ipnsort: branchless Lomuto partitioning, recursive pseudo-median pivots, an equal-elements partition when the pivot repeats the parent pivot, an up-front check for sorted or strictly descending input, and the existing heapsort fallback to keep the worst case O(n log n). For primitive integers, elements that compare equal are bitwise identical, so the result is exactly the same as with the existing introsort; other types keep using introsort. Contributes to dotnet#68325 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The branchless quicksort was slower than the introsort on nearly sorted input (down to 0.5x with 0.1% of elements swapped) and when the same small input is re-sorted repeatedly, because the introsort's branches are then predicted correctly. Route each input to whichever sort is faster: - up to 2048 elements, always use the introsort; - otherwise sample 32 adjacent pairs and use the branchless sort only if at least a quarter of them straddle a reference value; - otherwise detect already sorted or strictly descending input with a vectorized scan, skipping it when a sampled pair is out of order; - sort everything else with the introsort. Also inline IsPrimitiveInteger, which ReadyToRun code otherwise calls on every sort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3b73e77 to
889d697
Compare
| // consecutive elements fall on different sides of the pivot in a pattern the branch predictor can't follow. Estimate how often | ||
| // that happens by checking pairs of adjacent elements sampled across the input against a reference value. A reference far from | ||
| // the median makes the input look more predictable than it is, which errs on the side of the introsort. | ||
| ref T zeroRef = ref MemoryMarshal.GetReference(keys); |
There was a problem hiding this comment.
We're trying to remove/reduce unsafe code, not increase it. Any new unsafe code needs significant justification and extra explicit checks for why it is correct.
| // Written with integer arithmetic rather than conditions, so that the loop doesn't branch on the data. | ||
| ref T first = ref Unsafe.Add(ref zeroRef, sampleIndex); | ||
| ref T second = ref Unsafe.Add(ref first, 1); | ||
| int firstIsBelow = LessThan(ref first, ref reference) ? 1 : 0; |
There was a problem hiding this comment.
There's quite a bit of repetitive/redundant work on these that wouldn't exist using bools and/or indices
| nuint lastIndex = (uint)(keys.Length - 1); | ||
| nuint i = 0; | ||
|
|
||
| if (Vector256.IsHardwareAccelerated && lastIndex >= (uint)Vector256<T>.Count) |
There was a problem hiding this comment.
This is reducing iterations as you become more sorted, but it still requires doing a lot of redundant memory accesses. It feels like tracking the known sorted runs could help mitigate some of that
| { | ||
| Vector256<T> current = Vector256.LoadUnsafe(ref zeroRef, i); | ||
| Vector256<T> next = Vector256.LoadUnsafe(ref zeroRef, i + 1); | ||
| if (strictlyDescending ? Vector256.GreaterThanOrEqualAny(next, current) : Vector256.LessThanAny(next, current)) |
There was a problem hiding this comment.
You're currently doing: true: >= and false: <, which can also just be false: !(>=) or true: !(<) for integer types
So this could just be LessThanAny ^ strictlyDescending (because false ^ LessThanAny preserves it, while true ^ LessThanAny inverts it), avoiding the extra branch and work.
|
This is a lot of extra code, including unsafe code; and while the perf is there, it still seems to be doing a lot of work without strictly moving the needle on the overall premise and problem that people are wanting solved. |
|
Thanks for taking the time to review this, and for the detailed comments. That's fair so I'll close the PR instead of iterating on it. |
Contributes to #68325.
Summary
Array.Sort,Span<T>.SortandList<T>.Sortof more than 2048 primitive integers with the default comparer now check how predictable the input is, and sort unpredictable input with a quicksort whose partition loop has no data-dependent branches. On random input the introsort partition loop mispredicts about half of its comparisons. The new loop's cost per element doesn't depend on how predictable the data is.Sorting 1M random
ints is 2.9× faster, sorted and reversed input is 27× faster, and inputs with few distinct values are 4–6× faster. Every other input, and every input of up to 2048 elements, is sorted by the existing introsort exactly as before. The change is a single file (ArraySortHelper.cs) with no public API changes.Why this doesn't hit the concern raised on #68436
In #68436, jkotas pointed out that replacing the unstable sort algorithm is a semi-breaking change, because the relative order of equal elements changes and breaks test baselines. This PR only switches the algorithm when
Tis one ofbyte,sbyte,short,ushort,int,uint,long,ulong,nintornuint, and only on the default-comparer path. For these types, elements that compare equal are bitwise identical. Every correct sort therefore produces exactly the same output, and the change is unobservable apart from speed.These stay on the existing introsort:
-0.0/+0.0compare equal but differ in bits)charand all other typesWhen the new sort is used
A branchless sort isn't faster on every input. When the introsort's branches are predicted correctly, it does less work per element. That happens for nearly sorted input, and for small inputs that are sorted over and over again (as the
Sort<Int32>microbenchmark does). So the new code routes each input to whichever sort is faster:The threshold in step 2 was calibrated against inputs where the introsort wins: sorted input with random pairs swapped is the hardest case. The two sorts break even at about 12% straddling pairs; 25% leaves a 2× margin. A reference far from the median can only make an input look more predictable than it is, so errors fall on the side of the introsort.
Algorithm
This closely follows the design of Rust's
slice::sort_unstable(ipnsort, by Lukas Bergdoll and Orson Peters), which descends from pdqsort:(value < pivot) ? 1 : 0. The JIT compiles the loop tocmp/setl/addwith no branches on the data.i % 1000, which fell back to heapsort. The recursive pseudo-median fixes that.HeapSort, after2 * (log2(n) + 1)levels, the same limit introsort uses. The existingInsertionSorthandles partitions of ≤ 20 elements.The sort is in place and allocates nothing. The only hardware intrinsics are the cross-platform
Vector128/Vector256APIs in the sorted-input scan, with a scalar fallback.The C# is my own, written in the style of the surrounding file, but the design and the pivot-selection scheme follow ipnsort (MIT/Apache-2.0). Please let me know if you'd like a
THIRD-PARTY-NOTICESentry for that; per CONTRIBUTING.md I haven't touched licensing files.Performance
Windows 10 x64, Intel Core i9-9900K.
mainat 72509c6 vs this PR, both Release builds, run through the testhost. Times are ns per element (lower is better), best of 9 after a warmup. Each measurement cycles through up to 2048 distinct inputs of the given shape, so the branch predictor can't memorize a single input. Rows marked † were re-run 5 times interleaved between the two builds; the median is shown.Inputs that take the new paths (1M elements unless noted):
Inputs that stay on the introsort (unchanged apart from the routing check):
i % 1000) † / organ pipe † / two interleaved runs †The routing check costs about 60 ns per sort (32 sampled pairs, a median of three and 8 trailing pairs), and only for more than 2048 elements. On the most introsort-friendly input just above that size this is about 0.5%, and less than 0.2% from 16K elements up. That's below what these measurements resolve.
Re-sorting an identical small input, as the dotnet/performance benchmark
System.Collections.Sort<Int32>.Array/.List(Size = 512) does, is unchanged, since inputs of up to 2048 elements never leave the introsort. Reproducing that benchmark's setup (one fixedRandom(12345)input, 5000 fresh copies per iteration):Sort<Int32>.Array-style, n = 512Sort<Int32>.List-style, n = 512A note for anyone re-measuring: when the same input of roughly 700–2000 elements is re-sorted, the timing depends on how much of the branch history the predictor memorizes. In short runs that still execute ReadyToRun code, this varies by ±10–20% between builds whose executed code is identical: two builds of this branch that differ only in code reached above 2048 elements measured −12% to +18% apart at n = 768–896. With
DOTNET_ReadyToRun=0(or after tier-up) these sizes measure the same onmainand this PR.Validation
System.Memory.Tests(52,935),System.Runtime.Tests(78,658),System.Collections.Tests(33,958),System.Linq.Tests(52,260) andSystem.Collections.Immutable.Tests(22,093 + 22,621) suites all pass locally against this build. No tests were changed.Array.Sort(T[])(new paths) againstArray.Sort(T[], IComparer<T>)with a non-default comparer, which still runs introsort. It ran 70,000 cases across all ten integer types and theArray.Sort(T[], int, int),Span<T>.Sort()andList<T>.Sort()entry points. Inputs covered random values with 2 to 2⁶⁴ distinct values; ascending, descending and both with duplicates; a single out-of-order element at a random position or near the end (covering the vector scan's tail); sawtooth; organ pipe; sorted with a random tail or with 3% noise; andMinValue/MaxValueextremes. Sizes ranged over 0–70,000, with extra weight on 2040–2120 around the threshold. The output was identical in every case.I have only measured x64. I'd appreciate a run on Arm64, where the branchless loop should benefit similarly.
Benchmark program (Array.Sort with distinct inputs)
🤖 Generated with Claude Code