Skip to content

ext/standard: Add a fast path for sort() and rsort() on packed int arrays - #25389

Closed
adamcable wants to merge 1 commit into
php:masterfrom
adamcable:sort-packed-long-fast-path
Closed

adamcable wants to merge 1 commit into
php:masterfrom
adamcable:sort-packed-long-fast-path

Conversation

@adamcable

@adamcable adamcable commented Nov 11, 2026 •

Copy link
Copy Markdown

Have been interested in whether the AlphaDev sorting array algorithm finds from 2023 - https://deepmind.google/blog/alphadev-discovers-faster-sorting-algorithms/ - would benefit PHP at all.

I don't have C skills, but have used Claude to help investigate. For integer-only sorting we have the ability to use this updated algorithm to help speed up these sorts.

Comments below detail the update, but are not my words:

With the default SORT_REGULAR flag, a packed array whose elements are all integers is now sorted as a plain zend_long buffer, instead of being converted to a hash, sorted as Buckets through comparison and swap callbacks, and converted back. The result is identical: comparing two integers cannot call user code, emit a diagnostic or throw, and since the keys are renumbered, the order of equal integers is unobservable. Holes are squeezed out and the next free key and internal pointer are reset, as the generic path does. Any other array, including one holding a reference, takes the existing path.

Runs of 2 to 5 elements are sorted with the branchless sorting networks AlphaDev found for libc++, which std::sort uses for arithmetic types. They are only correct for a strict weak ordering, which integer comparison is, so they are not reachable from user comparators.

sort() and rsort() on lists of integers become 2-4x faster; other arrays are unaffected.

…rays

With the default SORT_REGULAR flag, a packed array whose elements are all
integers is now sorted as a plain zend_long buffer, instead of being
converted to a hash, sorted as Buckets through comparison and swap
callbacks, and converted back. The result is identical: comparing two
integers cannot call user code, emit a diagnostic or throw, and since the
keys are renumbered, the order of equal integers is unobservable. Holes are
squeezed out and the next free key and internal pointer are reset, as the
generic path does. Any other array, including one holding a reference,
takes the existing path.

Runs of 2 to 5 elements are sorted with the branchless sorting networks
AlphaDev found for libc++ (Mankowitz et al., Nature 618, 2023), which
std::sort uses for arithmetic types. They are only correct for a strict
weak ordering, which integer comparison is, so they are not reachable
from user comparators.

sort() and rsort() on lists of integers become 2-4x faster; other arrays
are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@adamcable
adamcable requested a review from bukka as a code owner November 11, 2026 14:57
@adamcable adamcable changed the title ext/standard: Add a fast path for sort() and rsort() on packed int ar… ext/standard: Add a fast path for sort() and rsort() on packed int arrays Nov 11, 2026
@LamentXU123

LamentXU123 commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Hey. I've written the same stuff before #23585. Instead of opening a new PR, can you please review the previous ones?
Thx.

@adamcable

Copy link
Copy Markdown
Author

Hey. I've written the same stuff before #23585. Instead of opening a new PR, can you please review the previous ones? Thx.

Sorry didn't realise - will close this one...

@adamcable adamcable closed this Nov 11, 2026
@LamentXU123

LamentXU123 commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Yeah this is interesting improvements I want to land a while ago and have put my thought into. Do you have any ideas of the (potential) regressions in mixed arrays sortings with our new similiar implementation. Or, will you expect your optimization only land specificly on packed int arrays?

@adapik

adapik commented Nov 11, 2026

Copy link
Copy Markdown
Contributor

@LamentXU123

I'm putting aside the code quality and whether these changes are worth it. I just wanted to point out that, technically, the two ideas don't conflict and could be combined.

As far as I can tell, they optimize different things in the same area. The other PR (#23585) avoids the packed → hash → packed round trip and sorts packed arrays directly as zvals. This one specializes the sort itself for integers.

That said, my guess is that the maintenance cost of a custom, type-specific sort would likely outweigh its gain, since most of the overhead seems to come from the conversion rather than the comparison and swapping. So the int specialization would only be worth considering as a possible follow-up if it turns out to be measurably beneficial on top of your work.

@adamcable

Copy link
Copy Markdown
Author

Here's further details, including speed improvements. Feel free to take this code and add into your PR if easier.

What changes

Today, sort($list) on a packed array:

  1. converts it to a hash, allocating 32-byte Buckets and a hash index;
  2. sorts the Buckets with zend_sort(), which calls back into php_array_data_compare (zend_compare() plus a stable tie-break) and zend_hash_bucket_renum_swap for every comparison and swap;
  3. converts it back to a packed array.

php_sort() now first tries php_sort_packed_longs(), but only for sort()/rsort() (renumber), and only when the compare function is php_array_data_compare or php_array_reverse_data_compare. In one pass, it checks that every element is IS_LONG and copies the values into a scratch buffer. It bails out on any other type, including references, without having changed the array. Then it:

  • sorts the buffer with zend_sort()'s algorithm specialised to zend_long (insertion sort up to 16 elements; quicksort with a median-of-3/median-of-5 pivot above that);
  • writes the values back into arPacked;
  • squeezes out holes and resets nNextFreeElement and nInternalPointer, as zend_hash_sort_internal() does.

The scratch buffer takes 8 bytes per element: do_alloca() up to 32 KB (4,096 elements), emalloc() above that. That is less than the hash conversion it replaces.

Why the result is identical

  • With SORT_REGULAR, zend_compare() on two IS_LONG values is a plain integer comparison. It cannot call user code, emit a diagnostic or throw. The enum special case in php_array_data_compare_unstable_i only applies to objects.
  • sort() and rsort() renumber the keys, so equal integers can't be told apart afterwards. The stable tie-break therefore has no observable effect.
  • SORT_NUMERIC is deliberately not covered. It compares as doubles, so integers beyond ±2^53 tie, and their original order shows. A test pins this.
  • Flags that php_get_data_compare_func() maps to SORT_REGULAR (SORT_FLAG_CASE alone, unknown values) take the fast path too, because the check is on the compare function, not the flag.

Sorting networks for 2-5 elements

Short runs are sorted with the branchless sorting networks that AlphaDev found for LLVM's libc++ (Mankowitz et al., Faster sorting algorithms discovered using deep reinforcement learning, Nature 618, 2023). These are __cond_swap/__partially_sorted_swap, which std::sort uses for arithmetic types. gcc compiles them to cmov.

  • Gain: in a prototype of this patch on PHP 8.4.24, they took 29%, 40% and 50% less time than zend_sort_3/4/5-style decision trees on the same buffer, for 3, 4 and 5 elements. On larger arrays, the difference was within noise.
  • Safety: they are only correct for a strict weak ordering, because the "swap move" assumes *y <= *z holds. An inconsistent user comparator would make them duplicate elements. So they are static, internal to this path, and unreachable from usort() and friends.
  • Alternative: if you'd rather have less code, I'm happy to swap them for plain decision trees. In the same prototype, the fast path with trees was 1.6-2.6× faster than stock.

Benchmarks

php-src master (8.7.0-dev, bd8e73473e), gcc 13.3 -O2, minimal CLI build, Linux x86-64 (WSL2), Intel Core Ultra 7 255H. Each figure is the time per call: the median over 5 fresh processes per build, interleaved, and pinned to one CPU.

call n master patched change
sort() ints 3 55 ns 29 ns 1.91× faster
sort() ints 5 95 ns 24 ns 4.01× faster
sort() ints 16 417 ns 179 ns 2.33× faster
sort() ints 100 4.20 µs 1.90 µs 2.21× faster
sort() ints 1,000 62.46 µs 26.77 µs 2.33× faster
sort() ints 100,000 11.47 ms 4.69 ms 2.44× faster
sort() ints 1,000,000 151.94 ms 57.85 ms 2.63× faster
rsort() ints 1,000 75.26 µs 27.43 µs 2.74× faster
sort() ints with holes 1,000 64.04 µs 26.70 µs 2.40× faster
sort() ints, last one a float (rejected after a full scan) 1,000 63.09 µs 64.08 µs +1.6%
sort() ints with string keys (not packed) 1,000 65.76 µs 64.98 µs −1.2%
sort() strings 1,000 123.65 µs 124.81 µs +0.9%

Run-to-run noise on this machine is a few percent. A 15-process re-run on 8.4 measured the rejected-input cases at −2% to +3%.

Tests

The new tests are in ext/standard/tests/array/sort/:

  • sort_packed_int.phpt covers:
    • every order of 2-6 elements, duplicates included;
    • PHP_INT_MIN/MAX;
    • holes, key renumbering, the internal pointer and the next free key;
    • separation from copies;
    • flags that alias SORT_REGULAR.
  • sort_packed_int_random.phpt compares sort()/rsort() with usort():
    • on 5 data shapes, with and without holes;
    • at sizes 0-40, 63-65, 1023-1025, 4095-4097 and 20,000. These straddle the insertion-sort cut-off, the median-of-5 pivot and the alloca/emalloc switch.
  • sort_packed_int_fallback.phpt covers:
    • inputs that must keep the generic behaviour: floats, numeric strings, null/bool, string keys, references (which survive sorting);
    • SORT_NUMERIC ties between large integers;
    • SORT_STRING.

All three pass on unpatched master too, so they pin down existing behaviour.

To check that the tests can actually catch bugs, I broke the patch on purpose in six ways:

  • a missing network step;
  • the next key not reset;
  • rsort() writing in ascending order;
  • holes not removed;
  • the type check skipped;
  • a wrong pivot sample.

Each break failed at least one new test, while a behaviour-preserving change passed.

ext/standard/tests and Zend/tests were run on master, on master with this patch, and on master with this patch built with --enable-debug. Each run has the same single failure, proc_nice_basic, which fails under WSL without the patch too. The debug build reported no leaks or assertion failures.

I've only built and tested this on Linux x86-64 with gcc. CI will cover the rest.

Possible follow-ups (not in this PR)

  • String-keyed arrays of integers could use the same path, because sort() discards keys.
  • SORT_NUMERIC could use it after checking that every value is within ±2^53.
  • If the integer sort is wanted elsewhere, it could move to Zend/zend_sort.c as zend_sort_long().

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants