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