Commit e9deb0af19c for php.net
commit e9deb0af19c33bf458009c51d0750c981cff3eab
Author: mehmetcan <2010841+mehmetcansahin@users.noreply.github.com>
Date: Thu Aug 6 14:12:21 2026 +0300
Optimize array_intersect() using hash-based matching (#23019)
diff --git a/NEWS b/NEWS
index fd4d58dd3c2..a70b2d52d8b 100644
--- a/NEWS
+++ b/NEWS
@@ -52,6 +52,7 @@ PHP NEWS
. Added the "filter.max_filter_count" stream context option for php://filter
URLs. Using more than 16 filters without configuring this option is now
deprecated. (Sjoerd Langkemper)
+ . Improved performance of array_intersect(). (mehmetcansahin)
. Fixed bug GH-23006 (phpcredits() full-page HTML title says phpinfo()).
(Weilin Du)
. The following functions now raise a ValueError when the $filename argument
diff --git a/UPGRADING b/UPGRADING
index 453bae75701..d6ed37ef49b 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -201,6 +201,14 @@ PHP 8.6 UPGRADE NOTES
SplTempFileObject; the two previously returned different values.
- Standard:
+ . array_intersect() with at least two arrays now converts values to strings
+ while scanning its inputs instead of during sort comparisons. This can
+ change the number and order of conversion warnings and __toString() calls,
+ which conversion exception is reached, and the result for stateful
+ __toString() implementations. Argument types are validated before checking
+ for empty arrays or converting values, so an invalid later argument can
+ suppress conversion side effects from earlier arrays. Values are not
+ converted if any input array is empty.
. Form feed (\f) is now added in the default trimmed characters of trim(),
rtrim() and ltrim().
RFC: https://wiki.php.net/rfc/trim_form_feed
@@ -715,6 +723,7 @@ PHP 8.6 UPGRADE NOTES
- Standard:
. Improved performance of array_fill_keys().
+ . Improved performance of array_intersect().
. Improved performance of array_map() with multiple arrays passed.
. Improved performance of array_sum() and array_product() for
integer-only arrays.
diff --git a/Zend/tests/bug74093.phpt b/Zend/tests/bug74093.phpt
index d38d6e5109c..fea0d5ea9d0 100644
--- a/Zend/tests/bug74093.phpt
+++ b/Zend/tests/bug74093.phpt
@@ -14,9 +14,10 @@
hard_timeout=1
--FILE--
<?php
-$a1 = range(1, 3000000);
-$a2 = range(100000, 3999999);
-array_intersect($a1, $a2);
+$values = range(1, 6000000);
+/* array_intersect() now uses a linear-time hash implementation. Use a large
+ * internal string sort to retain the hard-timeout workload. */
+sort($values, SORT_STRING);
?>
--EXPECTF--
Fatal error: Maximum execution time of 1+1 seconds exceeded %s
diff --git a/Zend/tests/named_params/internal_variadics.phpt b/Zend/tests/named_params/internal_variadics.phpt
index 8312bde406d..8ec11ac2c34 100644
--- a/Zend/tests/named_params/internal_variadics.phpt
+++ b/Zend/tests/named_params/internal_variadics.phpt
@@ -15,6 +15,14 @@
echo $e->getMessage(), "\n";
}
+var_dump(array_intersect(array: [1, 2]) === [1, 2]);
+
+try {
+ array_intersect([1, 2], arrays: [2]);
+} catch (ArgumentCountError $e) {
+ echo $e->getMessage(), "\n";
+}
+
try {
$array = [1, 2];
array_push($array, ...['values' => 3]);
@@ -25,4 +33,6 @@
--EXPECT--
Internal function array_merge() does not accept named variadic arguments
Internal function array_diff_key() does not accept named variadic arguments
+bool(true)
+Internal function array_intersect() does not accept named variadic arguments
Internal function array_push() does not accept named variadic arguments
diff --git a/ext/standard/array.c b/ext/standard/array.c
index fe74e1e7881..584afada208 100644
--- a/ext/standard/array.c
+++ b/ext/standard/array.c
@@ -5369,9 +5369,218 @@ PHP_FUNCTION(array_intersect_ukey)
}
/* }}} */
+static zend_always_inline bool php_array_intersect_get_key(
+ zval *value, zend_ulong *num_key, zend_string **str_key, zend_string **tmp_key)
+{
+ ZVAL_DEREF(value);
+ *tmp_key = NULL;
+
+ if (Z_TYPE_P(value) == IS_LONG) {
+ *num_key = (zend_ulong) Z_LVAL_P(value);
+ *str_key = NULL;
+ return true;
+ }
+
+ if (Z_TYPE_P(value) == IS_STRING) {
+ *str_key = Z_STR_P(value);
+ return true;
+ }
+
+ *str_key = zval_try_get_tmp_string(value, tmp_key);
+ return *str_key != NULL;
+}
+
+static zend_always_inline void php_array_intersect_empty_result(zval *first, zval *return_value)
+{
+ HashTable *result;
+ bool in_place = zend_may_modify_arg_in_place(first);
+
+ if (in_place) {
+ result = Z_ARRVAL_P(first);
+ ZVAL_ARR(return_value, result);
+ } else {
+ result = zend_array_dup(Z_ARRVAL_P(first));
+ ZVAL_ARR(return_value, result);
+ }
+
+ ZEND_HASH_FOREACH_KEY(result, zend_ulong num_key, zend_string *key) {
+ if (key) {
+ zend_hash_del(result, key);
+ } else {
+ zend_hash_index_del(result, num_key);
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ if (in_place) {
+ Z_ADDREF_P(return_value);
+ }
+}
+
+/* {{{ Hash-based implementation of array_intersect(). Values are compared
+ * using their string representation. On the long|string domain, this is
+ * exactly key equality under symtable normalization: a long and a string
+ * compare equal iff the string is the canonical decimal representation of the
+ * long, which is precisely when ZEND_HANDLE_NUMERIC converts it to that long
+ * key. Other values are converted to string before the same normalization. */
+static zend_never_inline void php_array_intersect_hash(zval *args, uint32_t argc, zval *return_value)
+{
+ for (uint32_t i = 0; i < argc; i++) {
+ if (Z_TYPE(args[i]) != IS_ARRAY) {
+ zend_argument_type_error(i + 1, "must be of type array, %s given", zend_zval_value_name(&args[i]));
+ return;
+ }
+ }
+
+ /* An empty argument makes the intersection empty, so no values need to be
+ * converted to string. */
+ for (uint32_t i = 0; i < argc; i++) {
+ if (zend_hash_num_elements(Z_ARRVAL(args[i])) == 0) {
+ php_array_intersect_empty_result(&args[0], return_value);
+ return;
+ }
+ }
+
+ /* Map each value of args[1] to the number of consecutive arguments,
+ * starting from args[1], the value has been seen in. */
+ zval one;
+ ZVAL_LONG(&one, 1);
+ HashTable set;
+ zend_hash_init(&set, zend_hash_num_elements(Z_ARRVAL(args[1])), NULL, NULL, 0);
+ zend_bitset delete_bitset = NULL;
+ ALLOCA_FLAG(use_heap);
+ bool in_place = false;
+
+ ZEND_HASH_FOREACH_VAL(Z_ARRVAL(args[1]), zval *value) {
+ zend_ulong value_num_key = 0;
+ zend_string *value_str_key, *tmp_key;
+ if (!php_array_intersect_get_key(value, &value_num_key, &value_str_key, &tmp_key)) {
+ goto cleanup;
+ }
+ if (value_str_key) {
+ zend_symtable_update(&set, value_str_key, &one);
+ } else {
+ zend_hash_index_update(&set, value_num_key, &one);
+ }
+ zend_tmp_string_release(tmp_key);
+ } ZEND_HASH_FOREACH_END();
+
+ for (uint32_t i = 2; i < argc; i++) {
+ ZEND_HASH_FOREACH_VAL(Z_ARRVAL(args[i]), zval *value) {
+ zend_ulong value_num_key = 0;
+ zend_string *value_str_key, *tmp_key;
+ if (!php_array_intersect_get_key(value, &value_num_key, &value_str_key, &tmp_key)) {
+ goto cleanup;
+ }
+ zval *count;
+ if (value_str_key) {
+ count = zend_symtable_find(&set, value_str_key);
+ } else {
+ count = zend_hash_index_find(&set, value_num_key);
+ }
+ zend_tmp_string_release(tmp_key);
+ if (count && Z_LVAL_P(count) == (zend_long) i - 1) {
+ ZVAL_LONG(count, i);
+ }
+ } ZEND_HASH_FOREACH_END();
+ }
+
+ /* Match the generic path by filtering the first argument in place if
+ * possible and duplicating it otherwise. In particular, duplication keeps
+ * bucket holes whose positions are observable through array_rand(). */
+ HashTable *result;
+ in_place = zend_may_modify_arg_in_place(&args[0]);
+ if (in_place) {
+ result = Z_ARRVAL(args[0]);
+ ZVAL_ARR(return_value, result);
+ } else {
+ result = zend_array_dup(Z_ARRVAL(args[0]));
+ ZVAL_ARR(return_value, result);
+ }
+
+ /* Determine all entries to remove before deleting any. Deleting an entry may
+ * invoke a user destructor that changes subsequent string conversions. */
+ HashTable *scanned_result = result;
+ uint32_t scanned_num_used = result->nNumUsed;
+ uint32_t delete_bitset_len = zend_bitset_len(scanned_num_used);
+ delete_bitset = ZEND_BITSET_ALLOCA(delete_bitset_len, use_heap);
+ zend_bitset_clear(delete_bitset, delete_bitset_len);
+
+ size_t scanned_element_size = ZEND_HASH_ELEMENT_SIZE(scanned_result);
+ for (uint32_t result_idx = 0; result_idx < scanned_num_used; result_idx++) {
+ zval *entry = ZEND_HASH_ELEMENT_EX(scanned_result, result_idx, scanned_element_size);
+ if (UNEXPECTED(Z_TYPE_P(entry) == IS_UNDEF)) {
+ continue;
+ }
+ zend_ulong value_num_key = 0;
+ zend_string *value_str_key, *tmp_key;
+ if (!php_array_intersect_get_key(entry, &value_num_key, &value_str_key, &tmp_key)) {
+ goto cleanup;
+ }
+ zval *count;
+ if (value_str_key) {
+ count = zend_symtable_find(&set, value_str_key);
+ } else {
+ count = zend_hash_index_find(&set, value_num_key);
+ }
+ zend_tmp_string_release(tmp_key);
+ if (!count || Z_LVAL_P(count) != (zend_long) argc - 1) {
+ zend_bitset_incl(delete_bitset, result_idx);
+ }
+ }
+
+ /* A conversion may retain the first argument through reentrant user code,
+ * so it may no longer be safe to modify the original array in place. */
+ if (in_place && !zend_may_modify_arg_in_place(&args[0])) {
+ result = zend_array_dup(Z_ARRVAL(args[0]));
+ ZVAL_ARR(return_value, result);
+ in_place = false;
+ }
+
+ /* The late duplication may compact holes, so read keys from the table whose
+ * bucket indexes are stored in the bitset. */
+ uint32_t result_idx;
+ ZEND_BITSET_FOREACH(delete_bitset, delete_bitset_len, result_idx) {
+ if (HT_IS_PACKED(scanned_result)) {
+ zend_hash_index_del(result, result_idx);
+ } else {
+ zval *entry = ZEND_HASH_ELEMENT_EX(scanned_result, result_idx, scanned_element_size);
+ Bucket *bucket = (Bucket *) entry;
+ if (bucket->key) {
+ zend_hash_del(result, bucket->key);
+ } else {
+ zend_hash_index_del(result, bucket->h);
+ }
+ }
+ } ZEND_BITSET_FOREACH_END();
+
+cleanup:
+ if (delete_bitset) {
+ free_alloca(delete_bitset, use_heap);
+ }
+ zend_hash_destroy(&set);
+ if (in_place) {
+ Z_ADDREF_P(return_value);
+ }
+}
+/* }}} */
+
/* {{{ Returns the entries of arr1 that have values which are present in all the other arguments */
PHP_FUNCTION(array_intersect)
{
+ zval *args;
+ uint32_t argc;
+
+ if (zend_parse_parameters(ZEND_NUM_ARGS(), "+", &args, &argc) == FAILURE) {
+ RETURN_THROWS();
+ }
+
+ if (argc >= 2) {
+ php_array_intersect_hash(args, argc, return_value);
+ return;
+ }
+
+ /* Preserve the generic path and its conversion side effects for calls with
+ * a single array. */
php_array_intersect(INTERNAL_FUNCTION_PARAM_PASSTHRU, INTERSECT_NORMAL, INTERSECT_COMP_DATA_INTERNAL, INTERSECT_COMP_KEY_INTERNAL);
}
/* }}} */
diff --git a/ext/standard/tests/array/array_intersect_empty.phpt b/ext/standard/tests/array/array_intersect_empty.phpt
new file mode 100644
index 00000000000..1bf527a4269
--- /dev/null
+++ b/ext/standard/tests/array/array_intersect_empty.phpt
@@ -0,0 +1,55 @@
+--TEST--
+array_intersect() does not convert values when an argument is empty
+--FILE--
+<?php
+class ThrowingStringableValue {
+ public function __toString(): string {
+ throw new RuntimeException('conversion failed');
+ }
+}
+
+set_error_handler(static function (int $code, string $message): never {
+ throw new ErrorException($message, $code);
+});
+
+$cases = [
+ static fn() => array_intersect([], [[1]]),
+ static fn() => array_intersect([[1]], []),
+ static fn() => array_intersect([new ThrowingStringableValue()], ['value'], []),
+ static fn() => array_intersect([], [new stdClass()]),
+];
+
+foreach ($cases as $case) {
+ try {
+ var_dump($case());
+ } catch (Throwable $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+ }
+}
+
+restore_error_handler();
+
+$result = array_intersect([9 => 'value'], []);
+$result[] = 'appended';
+var_dump(array_keys($result));
+
+try {
+ array_intersect([], [], new stdClass());
+} catch (TypeError $e) {
+ echo $e->getMessage(), "\n";
+}
+?>
+--EXPECT--
+array(0) {
+}
+array(0) {
+}
+array(0) {
+}
+array(0) {
+}
+array(1) {
+ [0]=>
+ int(10)
+}
+array_intersect(): Argument #3 must be of type array, stdClass given
diff --git a/ext/standard/tests/array/array_intersect_reentrant_holes.phpt b/ext/standard/tests/array/array_intersect_reentrant_holes.phpt
new file mode 100644
index 00000000000..8a1d0968dda
--- /dev/null
+++ b/ext/standard/tests/array/array_intersect_reentrant_holes.phpt
@@ -0,0 +1,37 @@
+--TEST--
+array_intersect() preserves element selection when conversion retains a hash table with holes
+--FILE--
+<?php
+class CapturingStringableWithHole {
+ public static array $argument;
+
+ public function __toString(): string {
+ self::$argument = debug_backtrace()[1]['args'][0];
+ return 'drop';
+ }
+}
+
+function temporary_argument_with_hole(): array {
+ $array = [
+ 'hole' => null,
+ 'drop' => new CapturingStringableWithHole(),
+ 'keep' => 'keep',
+ ];
+ unset($array['hole']);
+ return $array;
+}
+
+$result = array_intersect(temporary_argument_with_hole(), ['keep']);
+var_dump(array_keys($result), array_keys(CapturingStringableWithHole::$argument));
+?>
+--EXPECT--
+array(1) {
+ [0]=>
+ string(4) "keep"
+}
+array(2) {
+ [0]=>
+ string(4) "drop"
+ [1]=>
+ string(4) "keep"
+}
diff --git a/ext/standard/tests/array/array_intersect_side_effects.phpt b/ext/standard/tests/array/array_intersect_side_effects.phpt
new file mode 100644
index 00000000000..00cacb9b4a3
--- /dev/null
+++ b/ext/standard/tests/array/array_intersect_side_effects.phpt
@@ -0,0 +1,70 @@
+--TEST--
+array_intersect() conversion side effects and argument validation
+--FILE--
+<?php
+ini_set('precision', '14');
+
+class StringableWithStatefulDestructor {
+ public function __toString(): string {
+ return 'unmatched';
+ }
+
+ public function __destruct() {
+ ini_set('precision', '3');
+ }
+}
+
+function temporary_values(): array {
+ return [new StringableWithStatefulDestructor(), 1.234567];
+}
+
+var_dump(array_intersect(temporary_values(), ['1.234567']));
+
+class CapturingStringableValue {
+ public static array $argument;
+
+ public function __toString(): string {
+ self::$argument = debug_backtrace()[1]['args'][0];
+ return 'drop';
+ }
+}
+
+function temporary_argument(): array {
+ return [
+ 'drop' => new CapturingStringableValue(),
+ 'keep' => 'keep',
+ ];
+}
+
+$result = array_intersect(temporary_argument(), ['keep']);
+var_dump(array_keys($result), array_keys(CapturingStringableValue::$argument));
+
+set_error_handler(static function (int $code, string $message): bool {
+ echo $message, "\n";
+ return true;
+});
+
+try {
+ array_intersect([[1], [2]], ['Array'], new stdClass());
+} catch (TypeError $e) {
+ echo $e->getMessage(), "\n";
+}
+
+restore_error_handler();
+?>
+--EXPECT--
+array(1) {
+ [1]=>
+ float(1.234567)
+}
+array(1) {
+ [0]=>
+ string(4) "keep"
+}
+array(2) {
+ [0]=>
+ string(4) "drop"
+ [1]=>
+ string(4) "keep"
+}
+array_intersect(): Argument #3 must be of type array, stdClass given
diff --git a/ext/standard/tests/array/array_intersect_values.phpt b/ext/standard/tests/array/array_intersect_values.phpt
new file mode 100644
index 00000000000..caac782346b
--- /dev/null
+++ b/ext/standard/tests/array/array_intersect_values.phpt
@@ -0,0 +1,103 @@
+--TEST--
+array_intersect() with all value types
+--FILE--
+<?php
+function dump_array(array $value): void {
+ echo json_encode($value), "\n";
+}
+
+dump_array(array_intersect(
+ ['first' => 1, 'duplicate' => 1, 'leading' => '01', 'plus' => '+1', 'exponent' => '1e0', 'zero' => 0, 'negative-zero' => '-0'],
+ ['1', '0'],
+));
+
+dump_array(array_intersect([1, 2, '2', 3], [1, 2, 2, '2', 3], [2, '2', 3]));
+
+$aboveMax = PHP_INT_SIZE === 8 ? "9223372036854775808" : "2147483648";
+$belowMin = PHP_INT_SIZE === 8 ? "-9223372036854775809" : "-2147483649";
+var_dump(
+ array_intersect([PHP_INT_MAX, PHP_INT_MIN], [(string) PHP_INT_MAX, (string) PHP_INT_MIN]) === [PHP_INT_MAX, PHP_INT_MIN],
+ array_intersect([PHP_INT_MAX, PHP_INT_MIN], [$aboveMax, $belowMin]) === [],
+ array_intersect([$aboveMax, $belowMin], [$aboveMax, $belowMin]) === [$aboveMax, $belowMin],
+);
+
+$integer = 2;
+$string = '2';
+$result = array_intersect([&$integer, 3], [&$string, 3]);
+$integer = 9;
+var_dump($result[0]);
+
+$array = [0 => 'drop', 100 => 'x', 200 => 'y'];
+$result = array_intersect($array, ['x', 'y']);
+$expected = $array;
+unset($expected[0]);
+mt_srand(0);
+$resultRandom = [array_rand($result), mt_rand()];
+mt_srand(0);
+$expectedRandom = [array_rand($expected), mt_rand()];
+var_dump($result === $expected, $resultRandom === $expectedRandom);
+
+class StringableValue {
+ public function __construct(private string $value) {}
+ public function __toString(): string { return $this->value; }
+}
+
+$resource = fopen('php://memory', 'r');
+$result = array_intersect(
+ [
+ 'null' => null,
+ 'false' => false,
+ 'true' => true,
+ 'zero-float' => 0.0,
+ 'float' => 1.5,
+ 'resource' => $resource,
+ 'object' => new StringableValue('object'),
+ ],
+ ['', '1', '0', '1.5', (string) $resource, 'object'],
+ [false, true, 0, '1.5', $resource, new StringableValue('object')],
+);
+echo implode(',', array_keys($result)), "\n";
+
+var_dump(@array_intersect([[1]], [[2]]) === [[1]]);
+
+class ThrowingStringableValue {
+ public function __toString(): string { throw new RuntimeException('conversion failed'); }
+}
+
+try {
+ array_intersect(['value'], [new ThrowingStringableValue(), 'value']);
+} catch (RuntimeException $e) {
+ echo $e->getMessage(), "\n";
+}
+
+try {
+ array_intersect([''], [new stdClass()]);
+} catch (Error $e) {
+ echo $e->getMessage(), "\n";
+}
+
+set_error_handler(static function (int $code, string $message): never {
+ throw new ErrorException($message, $code);
+});
+try {
+ array_intersect([[1]], [[2]]);
+} catch (ErrorException $e) {
+ echo $e->getMessage(), "\n";
+} finally {
+ restore_error_handler();
+}
+?>
+--EXPECT--
+{"first":1,"duplicate":1,"zero":0}
+{"1":2,"2":"2","3":3}
+bool(true)
+bool(true)
+bool(true)
+int(9)
+bool(true)
+bool(true)
+null,false,true,zero-float,float,resource,object
+bool(true)
+conversion failed
+Object of class stdClass could not be converted to string
+Array to string conversion
diff --git a/ext/standard/tests/array/array_intersect_variation9.phpt b/ext/standard/tests/array/array_intersect_variation9.phpt
index c73b0f3dc92..05588e85e59 100644
--- a/ext/standard/tests/array/array_intersect_variation9.phpt
+++ b/ext/standard/tests/array/array_intersect_variation9.phpt
@@ -66,26 +66,6 @@
Warning: Array to string conversion in %s on line %d
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
Warning: Array to string conversion in %s on line %d
array(4) {
[0]=>
@@ -149,34 +129,6 @@
Warning: Array to string conversion in %s on line %d
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
-Warning: Array to string conversion in %s on line %d
-
Warning: Array to string conversion in %s on line %d
array(4) {
[0]=>