From cdf0503df6718497f7d5701d1d301f9d1ff62bd3 Mon Sep 17 00:00:00 2001 From: Adam Cable Date: Wed, 11 Nov 2026 14:06:57 +0000 Subject: [PATCH] ext/standard: Add a fast path for sort() and rsort() on packed int arrays 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 --- UPGRADING | 2 + ext/standard/array.c | 207 ++++++++++++++++++ .../tests/array/sort/sort_packed_int.phpt | 97 ++++++++ .../array/sort/sort_packed_int_fallback.phpt | 95 ++++++++ .../array/sort/sort_packed_int_random.phpt | 53 +++++ 5 files changed, 454 insertions(+) create mode 100755 ext/standard/tests/array/sort/sort_packed_int.phpt create mode 100755 ext/standard/tests/array/sort/sort_packed_int_fallback.phpt create mode 100755 ext/standard/tests/array/sort/sort_packed_int_random.phpt diff --git a/UPGRADING b/UPGRADING index a4df23352639..8b55a3a63097 100644 --- a/UPGRADING +++ b/UPGRADING @@ -208,6 +208,8 @@ PHP 8.7 UPGRADE NOTES encoding. . Improved performance of in_array() and array_search() in strict mode when searching for a string. + . Improved performance of sort() and rsort() on lists of integers with the + default SORT_REGULAR flag. - MBString: . Improved performance of mb_strlen for UTF-8 strings. diff --git a/ext/standard/array.c b/ext/standard/array.c index fe5d7cfd55e5..4a31e5007ed2 100644 --- a/ext/standard/array.c +++ b/ext/standard/array.c @@ -693,6 +693,209 @@ PHP_FUNCTION(natcasesort) } /* }}} */ +/* {{{ Fast path for sort() and rsort() on packed arrays of integers + * + * With SORT_REGULAR, zend_compare() on two integers is a plain integer comparison: it + * cannot call user code, emit a diagnostic or throw. And as sort() and rsort() renumber + * the keys, equal integers cannot be told apart afterwards, so the stable order of ties + * is unobservable. A packed array of integers can therefore be sorted as a plain buffer + * of zend_long, instead of converting it to a hash, sorting its Buckets through + * comparison and swap callbacks, and converting it back. + * + * The algorithm is zend_sort()'s (insertion sort for up to 16 elements, quicksort with + * a median-of-3, or from 1024 elements median-of-5, pivot above that), specialised to + * zend_long. Runs of 2 to 5 elements are sorted with the branchless sorting networks + * that AlphaDev found for LLVM's 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: they must never be used with a user comparator. + */ + +#define PHP_SORT_LONG_SWAP(a, b) do { \ + zend_long _tmp = *(a); \ + *(a) = *(b); \ + *(b) = _tmp; \ + } while (0) + +/* Orders *x and *y without branching. */ +static zend_always_inline void php_sort_long_cond_swap(zend_long *x, zend_long *y) +{ + bool r = *x < *y; + zend_long tmp = r ? *x : *y; + *y = r ? *y : *x; + *x = tmp; +} + +/* Orders *x, *y and *z without branching, given *y <= *z. */ +static zend_always_inline void php_sort_long_partially_sorted_swap(zend_long *x, zend_long *y, zend_long *z) +{ + bool r = *z < *x; + zend_long tmp = r ? *z : *x; + *z = r ? *x : *z; + r = tmp < *y; + *x = r ? *x : *y; + *y = r ? *y : tmp; +} + +static zend_always_inline void php_sort_long_3(zend_long *a, zend_long *b, zend_long *c) +{ + php_sort_long_cond_swap(b, c); + php_sort_long_partially_sorted_swap(a, b, c); +} + +static zend_always_inline void php_sort_long_4(zend_long *a, zend_long *b, zend_long *c, zend_long *d) +{ + php_sort_long_cond_swap(a, c); + php_sort_long_cond_swap(b, d); + php_sort_long_cond_swap(a, b); + php_sort_long_cond_swap(c, d); + php_sort_long_cond_swap(b, c); +} + +static zend_always_inline void php_sort_long_5(zend_long *a, zend_long *b, zend_long *c, zend_long *d, zend_long *e) +{ + php_sort_long_cond_swap(a, b); + php_sort_long_cond_swap(d, e); + php_sort_long_partially_sorted_swap(c, d, e); + php_sort_long_cond_swap(b, e); + php_sort_long_partially_sorted_swap(a, c, d); + php_sort_long_partially_sorted_swap(b, c, d); +} + +static void php_sort_longs(zend_long *base, size_t nmemb) +{ + while (nmemb > 16) { + zend_long *start = base, *end = base + nmemb; + size_t offset = nmemb >> 1; + zend_long *pivot = start + offset, *i, *j; + zend_long p; + + if (nmemb >> 10) { + size_t delta = offset >> 1; + php_sort_long_5(start, start + delta, pivot, pivot + delta, end - 1); + } else { + php_sort_long_3(start, pivot, end - 1); + } + PHP_SORT_LONG_SWAP(start + 1, pivot); + pivot = start + 1; + p = *pivot; + i = pivot + 1; + j = end - 1; + while (1) { + while (p > *i) { + if (UNEXPECTED(++i == j)) { + goto done; + } + } + if (UNEXPECTED(--j == i)) { + goto done; + } + while (*j > p) { + if (UNEXPECTED(--j == i)) { + goto done; + } + } + PHP_SORT_LONG_SWAP(i, j); + if (UNEXPECTED(++i == j)) { + goto done; + } + } +done: + PHP_SORT_LONG_SWAP(pivot, i - 1); + /* Recurse into the smaller partition and loop on the larger one. */ + if ((i - 1) - start < end - i) { + php_sort_longs(start, (size_t)(i - start) - 1); + base = i; + nmemb = (size_t)(end - i); + } else { + php_sort_longs(i, (size_t)(end - i)); + nmemb = (size_t)(i - start) - 1; + } + } + + switch (nmemb) { + case 0: + case 1: + return; + case 2: + php_sort_long_cond_swap(base, base + 1); + return; + case 3: + php_sort_long_3(base, base + 1, base + 2); + return; + case 4: + php_sort_long_4(base, base + 1, base + 2, base + 3); + return; + case 5: + php_sort_long_5(base, base + 1, base + 2, base + 3, base + 4); + return; + } + for (size_t i = 1; i < nmemb; i++) { + zend_long x = base[i]; + size_t j = i; + while (j > 0 && base[j - 1] > x) { + base[j] = base[j - 1]; + j--; + } + base[j] = x; + } +} + +/* Sorts ht as zend_array_sort(ht, cmp, true) would, if cmp compares like SORT_REGULAR and + * ht is a packed array of integers. Otherwise returns false without changing ht. */ +static bool php_sort_packed_longs(HashTable *ht, bucket_compare_func_t cmp) +{ + uint32_t n = zend_hash_num_elements(ht), k = 0, idx; + bool reverse; + zend_long *buf; + zval *zv; + ALLOCA_FLAG(use_heap) + + if (cmp == php_array_data_compare) { + reverse = false; + } else if (cmp == php_array_reverse_data_compare) { + reverse = true; + } else { + return false; + } + if (!HT_IS_PACKED(ht) || n < 2) { + return false; + } + + /* A single pass checks the types and gathers the integers, skipping holes. */ + buf = do_alloca(sizeof(zend_long) * n, use_heap); + zv = ht->arPacked; + for (idx = 0; idx < ht->nNumUsed; idx++, zv++) { + if (EXPECTED(Z_TYPE_P(zv) == IS_LONG)) { + buf[k++] = Z_LVAL_P(zv); + } else if (Z_TYPE_P(zv) != IS_UNDEF) { + free_alloca(buf, use_heap); + return false; + } + } + ZEND_ASSERT(k == n); + + php_sort_longs(buf, n); + + /* Like zend_array_sort(), renumber the keys from 0 and squeeze out any holes. */ + zv = ht->arPacked; + if (reverse) { + for (idx = 0; idx < n; idx++) { + ZVAL_LONG(zv + idx, buf[n - 1 - idx]); + } + } else { + for (idx = 0; idx < n; idx++) { + ZVAL_LONG(zv + idx, buf[idx]); + } + } + free_alloca(buf, use_heap); + + ht->nNumUsed = n; + ht->nNextFreeElement = n; + ht->nInternalPointer = 0; + return true; +} +/* }}} */ + typedef bucket_compare_func_t(*get_compare_function)(zend_long); static zend_always_inline void php_sort(INTERNAL_FUNCTION_PARAMETERS, get_compare_function get_cmp, bool renumber) { @@ -708,6 +911,10 @@ static zend_always_inline void php_sort(INTERNAL_FUNCTION_PARAMETERS, get_compar cmp = get_cmp(sort_type); + if (renumber && php_sort_packed_longs(array, cmp)) { + RETURN_TRUE; + } + zend_array_sort(array, cmp, renumber); RETURN_TRUE; diff --git a/ext/standard/tests/array/sort/sort_packed_int.phpt b/ext/standard/tests/array/sort/sort_packed_int.phpt new file mode 100755 index 000000000000..3f9227161d8a --- /dev/null +++ b/ext/standard/tests/array/sort/sort_packed_int.phpt @@ -0,0 +1,97 @@ +--TEST-- +sort() and rsort() on packed arrays of integers +--FILE-- + $x) { + $rest = $xs; + unset($rest[$i]); + foreach (permutations(array_values($rest)) as $p) { + $out[] = [$x, ...$p]; + } + } + return $out; +} +foreach ([[1, 2], [1, 2, 3], [1, 2, 3, 4], [1, 2, 3, 4, 5], [1, 1, 2, 2, 3], [0, 1, 2, 3, 4, 5]] as $set) { + $ok = true; + foreach (permutations($set) as $p) { + $a = $p; + sort($a); + $ok = $ok && $a === $set; + $a = $p; + rsort($a); + $ok = $ok && $a === array_reverse($set); + } + echo json_encode($set), ': ', $ok ? 'ok' : 'FAILED', "\n"; +} + +echo "\nHoles are removed, keys renumbered, the internal pointer and next key reset:\n"; +$a = [50, 40, 30, 20, 10, 0]; +unset($a[0], $a[3]); +end($a); +sort($a); +var_dump($a, key($a)); +$a[] = 99; +var_dump(array_key_last($a)); + +echo "\nThe sorted array is separated from its copies:\n"; +$a = [3, 1, 2]; +$b = $a; +$c = [$a]; +sort($b); +sort($c[0]); +echo json_encode([$a, $b, $c]), "\n"; + +echo "\nFlags that compare like SORT_REGULAR:\n"; +foreach ([SORT_REGULAR, SORT_FLAG_CASE] as $flags) { + $a = [10, 9, 2, 1]; + sort($a, $flags); + echo json_encode($a), "\n"; +} +?> +--EXPECT-- +bool(true) +[-9223372036854775808,-1,0,2,3,3,9223372036854775807] +bool(true) +[9223372036854775807,3,3,2,0,-1,-9223372036854775808] + +Two to six elements, in every order: +[1,2]: ok +[1,2,3]: ok +[1,2,3,4]: ok +[1,2,3,4,5]: ok +[1,1,2,2,3]: ok +[0,1,2,3,4,5]: ok + +Holes are removed, keys renumbered, the internal pointer and next key reset: +array(4) { + [0]=> + int(0) + [1]=> + int(10) + [2]=> + int(30) + [3]=> + int(40) +} +int(0) +int(4) + +The sorted array is separated from its copies: +[[3,1,2],[1,2,3],[[1,2,3]]] + +Flags that compare like SORT_REGULAR: +[1,2,9,10] +[1,2,9,10] diff --git a/ext/standard/tests/array/sort/sort_packed_int_fallback.phpt b/ext/standard/tests/array/sort/sort_packed_int_fallback.phpt new file mode 100755 index 000000000000..1bb33d021ebc --- /dev/null +++ b/ext/standard/tests/array/sort/sort_packed_int_fallback.phpt @@ -0,0 +1,95 @@ +--TEST-- +sort() on arrays that are not packed lists of integers, or with other flags +--FILE-- + [3, 1.5, 2, 1], + 'numeric string' => [10, '9', 1, '1e1'], + 'null and bool' => [1, null, true, false, 0], + 'string keys' => ['b' => 2, 'a' => 1, 'c' => 3], + 'reference' => [3, &$x, 1], +]; +foreach ($inputs as $label => $a) { + sort($a); + echo "$label: "; + var_dump($a); +} + +echo "The reference survives sorting:\n"; +$a = [3, &$x, 1]; +sort($a); +$x = 100; +echo json_encode($a), "\n"; + +echo "SORT_NUMERIC compares as floats, so ties between large integers keep their order:\n"; +$a = [2 ** 53 + 1, 2 ** 53, 2 ** 53 + 1, 2 ** 53]; +sort($a, SORT_NUMERIC); +echo json_encode($a), "\n"; +sort($a); +echo json_encode($a), "\n"; + +echo "SORT_STRING compares as strings:\n"; +$a = [10, 9, 100, -1]; +sort($a, SORT_STRING); +echo json_encode($a), "\n"; +rsort($a, SORT_STRING); +echo json_encode($a), "\n"; +?> +--EXPECT-- +float: array(4) { + [0]=> + int(1) + [1]=> + float(1.5) + [2]=> + int(2) + [3]=> + int(3) +} +numeric string: array(4) { + [0]=> + int(1) + [1]=> + string(1) "9" + [2]=> + int(10) + [3]=> + string(3) "1e1" +} +null and bool: array(5) { + [0]=> + NULL + [1]=> + bool(false) + [2]=> + int(0) + [3]=> + int(1) + [4]=> + bool(true) +} +string keys: array(3) { + [0]=> + int(1) + [1]=> + int(2) + [2]=> + int(3) +} +reference: array(3) { + [0]=> + int(1) + [1]=> + &int(2) + [2]=> + int(3) +} +The reference survives sorting: +[1,100,3] +SORT_NUMERIC compares as floats, so ties between large integers keep their order: +[9007199254740993,9007199254740992,9007199254740993,9007199254740992] +[9007199254740992,9007199254740992,9007199254740993,9007199254740993] +SORT_STRING compares as strings: +[-1,10,100,9] +[9,100,10,-1] diff --git a/ext/standard/tests/array/sort/sort_packed_int_random.phpt b/ext/standard/tests/array/sort/sort_packed_int_random.phpt new file mode 100755 index 000000000000..80c7ebfad9aa --- /dev/null +++ b/ext/standard/tests/array/sort/sort_packed_int_random.phpt @@ -0,0 +1,53 @@ +--TEST-- +sort() and rsort() on packed arrays of integers match usort() for random input +--FILE-- + fn ($n, $i) => $rng->getInt(PHP_INT_MIN, PHP_INT_MAX), + 'few' => fn ($n, $i) => $rng->getInt(-3, 3), + 'sorted' => fn ($n, $i) => $i, + 'reversed' => fn ($n, $i) => $n - $i, + 'organ' => fn ($n, $i) => min($i, $n - 1 - $i), +]; +// Around the insertion sort cut-off (16), the median-of-5 pivot (1024) and the +// stack/heap switch for the scratch buffer. +$sizes = [...range(0, 40), 63, 64, 65, 1023, 1024, 1025, 4095, 4096, 4097, 20000]; + +$failures = 0; +foreach ($sizes as $n) { + foreach ($shapes as $shape => $gen) { + $a = []; + for ($i = 0; $i < $n; $i++) { + $a[] = $gen($n, $i); + } + $inputs = ['' => $a]; + if ($n > 2) { + unset($a[0], $a[$n >> 1]); + $inputs[' with holes'] = $a; + } + foreach ($inputs as $label => $input) { + $sorted = $input; + sort($sorted); + $expected = $input; + usort($expected, fn ($x, $y) => $x <=> $y); + if ($sorted !== $expected) { + echo "sort() failed: $shape$label, n = $n\n"; + $failures++; + } + + $sorted = $input; + rsort($sorted); + $expected = $input; + usort($expected, fn ($x, $y) => $y <=> $x); + if ($sorted !== $expected) { + echo "rsort() failed: $shape$label, n = $n\n"; + $failures++; + } + } + } +} +echo $failures ? "$failures failures\n" : "Done\n"; +?> +--EXPECT-- +Done