From d26674cfdd13e18480093aef52db8aadf7c72153 Mon Sep 17 00:00:00 2001 From: mehmetcansahin Date: Fri, 2 Oct 2026 00:39:22 +0300 Subject: [PATCH 1/4] array_push(): Append to packed arrays in bulk When the next index of a packed array is the end of its used slots, reserve the room once and copy the values instead of inserting them one by one. Arrays with holes only take this path when no growth is needed. A single value pushed onto an uninitialized array still goes through zend_hash_next_index_insert(), which initializes the array inline and is cheaper than a separate initialization. --- NEWS | 1 + UPGRADING | 1 + ext/standard/array.c | 41 ++++++++++++++- .../array_push_packed_bulk_metadata.phpt | 52 +++++++++++++++++++ 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 ext/standard/tests/array/array_push_packed_bulk_metadata.phpt diff --git a/NEWS b/NEWS index 2595e055ddfe..57ace1b730e9 100644 --- a/NEWS +++ b/NEWS @@ -98,6 +98,7 @@ PHP NEWS elements. (mehmetcansahin) . Enforce max_filter_count: limit the number of filters that can be chained in a php://filter URL. (Sjoerd Langkemper) + . Improved performance of array_push() on packed arrays. (mehmetcansahin) - URI: . Fix casing of enum cases in UriHostType and UrlHostType to match the RFC diff --git a/UPGRADING b/UPGRADING index a6cdf9829ab1..926cd2c000b3 100644 --- a/UPGRADING +++ b/UPGRADING @@ -96,6 +96,7 @@ PHP 8.7 UPGRADE NOTES - Standard: . Improved performance of strcspn() and strpbrk() when searching longer strings with a single-byte character mask. + . Improved performance of array_push() on packed arrays. . Improved performance of array_splice() when inserting without removing elements. . Improved performance of str_rot13(). diff --git a/ext/standard/array.c b/ext/standard/array.c index a434589771b3..52adb1e2e2f5 100644 --- a/ext/standard/array.c +++ b/ext/standard/array.c @@ -3476,11 +3476,48 @@ PHP_FUNCTION(array_push) Z_PARAM_VARIADIC('+', args, argc) ZEND_PARSE_PARAMETERS_END(); + HashTable *ht = Z_ARRVAL_P(stack); + + /* Pushing nothing must not change the array, not even initialize it. A + * single value is added to an uninitialized array more cheaply by + * zend_hash_next_index_insert(), which initializes it inline. */ + if (argc > 0 && (HT_IS_PACKED(ht) || (argc > 1 && (HT_FLAGS(ht) & HASH_FLAG_UNINITIALIZED)))) { + /* Like zend_hash_next_index_insert(), treat ZEND_LONG_MIN (no integer + * key used yet) as 0. */ + zend_long next_index = ht->nNextFreeElement == ZEND_LONG_MIN ? 0 : ht->nNextFreeElement; + bool appends_after_last = next_index == (zend_long) ht->nNumUsed; + uint32_t room = ht->nTableSize - ht->nNumUsed; + + /* The values can be stored after the last used slot. Growing an array + * with holes may turn it into a hash, so it must already have room. */ + if (appends_after_last + && argc <= HT_MAX_SIZE - ht->nNumUsed /* nNumUsed + argc must not wrap */ + && (argc <= room || HT_IS_WITHOUT_HOLES(ht))) { + if (UNEXPECTED(argc > room)) { + zend_hash_extend(ht, ht->nNumUsed + argc, true); + } else if (UNEXPECTED(HT_FLAGS(ht) & HASH_FLAG_UNINITIALIZED)) { + zend_hash_real_init_packed(ht); + } + + /* ZEND_HASH_FILL_END() resets the internal pointer, array_push() keeps it. */ + uint32_t pos = ht->nInternalPointer; + ZEND_HASH_FILL_PACKED(ht) { + for (uint32_t i = 0; i < argc; i++) { + Z_TRY_ADDREF(args[i]); + ZEND_HASH_FILL_ADD(&args[i]); + } + } ZEND_HASH_FILL_END(); + ht->nInternalPointer = pos; + + RETURN_LONG(zend_hash_num_elements(ht)); + } + } + /* For each subsequent argument, make it a reference, increase refcount, and add it to the end of the array */ for (uint32_t i = 0; i < argc; i++) { Z_TRY_ADDREF(args[i]); - if (zend_hash_next_index_insert(Z_ARRVAL_P(stack), &args[i]) == NULL) { + if (zend_hash_next_index_insert(ht, &args[i]) == NULL) { Z_TRY_DELREF(args[i]); zend_throw_error(NULL, "Cannot add element to the array as the next element is already occupied"); RETURN_THROWS(); @@ -3488,7 +3525,7 @@ PHP_FUNCTION(array_push) } /* Clean up and return the number of values in the stack */ - RETVAL_LONG(zend_hash_num_elements(Z_ARRVAL_P(stack))); + RETVAL_LONG(zend_hash_num_elements(ht)); } /* }}} */ diff --git a/ext/standard/tests/array/array_push_packed_bulk_metadata.phpt b/ext/standard/tests/array/array_push_packed_bulk_metadata.phpt new file mode 100644 index 000000000000..3c8b0a466323 --- /dev/null +++ b/ext/standard/tests/array/array_push_packed_bulk_metadata.phpt @@ -0,0 +1,52 @@ +--TEST-- +array_push() keeps the internal pointer and the next index +--FILE-- + 0]; +unset($a[-3]); +$copy = $a; +array_push($a, 1, 2); +var_dump(array_keys($a)); +?> +--EXPECT-- +int(1) +array(3) { + [0]=> + int(0) + [1]=> + int(1) + [2]=> + int(3) +} +array(2) { + [0]=> + int(-5) + [1]=> + int(-4) +} +array(2) { + [0]=> + int(-2) + [1]=> + int(-1) +} From 9d1523cb6e16dd186ef3d91afd989448babfcb3c Mon Sep 17 00:00:00 2001 From: mehmetcansahin Date: Tue, 6 Oct 2026 13:38:51 +0300 Subject: [PATCH 2/4] array_push(): Address review comments Drop UNEXPECTED() on the uninitialized array branch, pushing several values onto an empty array is common. Fix the stale comment on the insertion loop and trim the comments that restate the code. --- ext/standard/array.c | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/ext/standard/array.c b/ext/standard/array.c index 52adb1e2e2f5..bc9870747ece 100644 --- a/ext/standard/array.c +++ b/ext/standard/array.c @@ -3478,12 +3478,9 @@ PHP_FUNCTION(array_push) HashTable *ht = Z_ARRVAL_P(stack); - /* Pushing nothing must not change the array, not even initialize it. A - * single value is added to an uninitialized array more cheaply by - * zend_hash_next_index_insert(), which initializes it inline. */ + /* Pushing nothing must leave the array untouched. A single value is added + * to an uninitialized array more cheaply by zend_hash_next_index_insert(). */ if (argc > 0 && (HT_IS_PACKED(ht) || (argc > 1 && (HT_FLAGS(ht) & HASH_FLAG_UNINITIALIZED)))) { - /* Like zend_hash_next_index_insert(), treat ZEND_LONG_MIN (no integer - * key used yet) as 0. */ zend_long next_index = ht->nNextFreeElement == ZEND_LONG_MIN ? 0 : ht->nNextFreeElement; bool appends_after_last = next_index == (zend_long) ht->nNumUsed; uint32_t room = ht->nTableSize - ht->nNumUsed; @@ -3495,7 +3492,7 @@ PHP_FUNCTION(array_push) && (argc <= room || HT_IS_WITHOUT_HOLES(ht))) { if (UNEXPECTED(argc > room)) { zend_hash_extend(ht, ht->nNumUsed + argc, true); - } else if (UNEXPECTED(HT_FLAGS(ht) & HASH_FLAG_UNINITIALIZED)) { + } else if (HT_FLAGS(ht) & HASH_FLAG_UNINITIALIZED) { zend_hash_real_init_packed(ht); } @@ -3513,7 +3510,7 @@ PHP_FUNCTION(array_push) } } - /* For each subsequent argument, make it a reference, increase refcount, and add it to the end of the array */ + /* Otherwise, insert the values one by one. */ for (uint32_t i = 0; i < argc; i++) { Z_TRY_ADDREF(args[i]); From f1c7c2542fd82e685d67daffe40e8676fc1dd7a3 Mon Sep 17 00:00:00 2001 From: mehmetcansahin Date: Tue, 20 Oct 2026 12:04:26 +0300 Subject: [PATCH 3/4] array_push(): Move bulk insertion into zend_hash Add zend_hash_next_index_insert_copies(), which inserts several values like repeated zend_hash_next_index_insert() calls and adds a reference to each. Values appended right after the last used slot of a packed or uninitialized array are stored in one go. array_push() uses it for two or more values and no longer touches HashTable internals; a single value still goes through zend_hash_next_index_insert(). Rewrite the test to cover the room and growth boundaries, holes, the next index, refcounts and a partial insert. --- NEWS | 2 +- UPGRADING | 2 +- UPGRADING.INTERNALS | 4 + Zend/zend_hash.c | 42 +++++ Zend/zend_hash.h | 5 + ext/standard/array.c | 50 +----- .../array/array_push_multiple_values.phpt | 163 ++++++++++++++++++ .../array_push_packed_bulk_metadata.phpt | 52 ------ 8 files changed, 225 insertions(+), 95 deletions(-) create mode 100644 ext/standard/tests/array/array_push_multiple_values.phpt delete mode 100644 ext/standard/tests/array/array_push_packed_bulk_metadata.phpt diff --git a/NEWS b/NEWS index 8a98fffade31..adf07f087f52 100644 --- a/NEWS +++ b/NEWS @@ -118,7 +118,7 @@ PHP NEWS elements. (mehmetcansahin) . Enforce max_filter_count: limit the number of filters that can be chained in a php://filter URL. (Sjoerd Langkemper) - . Improved performance of array_push() on packed arrays. (mehmetcansahin) + . Improved performance of array_push() with several values. (mehmetcansahin) . Improved performance of array_chunk() when not preserving keys. (mehmetcansahin) diff --git a/UPGRADING b/UPGRADING index dc60dc771271..14cc382a890b 100644 --- a/UPGRADING +++ b/UPGRADING @@ -98,7 +98,7 @@ PHP 8.7 UPGRADE NOTES - Standard: . Improved performance of strcspn() and strpbrk() when searching longer strings with a single-byte character mask. - . Improved performance of array_push() on packed arrays. + . Improved performance of array_push() with several values. . Improved performance of array_chunk() when not preserving keys. . Improved performance of array_splice() when inserting without removing elements. diff --git a/UPGRADING.INTERNALS b/UPGRADING.INTERNALS index efcf9c00544e..b4f554dd2728 100644 --- a/UPGRADING.INTERNALS +++ b/UPGRADING.INTERNALS @@ -45,6 +45,10 @@ PHP 8.7 INTERNALS UPGRADE NOTES - The INIT_CLASS_ENTRY_INIT_METHODS() macro, which was previously used as part of handling disabled classes, was removed following the removal of the `disable_classes` INI setting in PHP 8.5. +- Added zend_hash_next_index_insert_copies(), which inserts several values like + repeated zend_hash_next_index_insert() calls and adds a reference to each + inserted value. Values appended right after the last used slot of a packed + array are stored in one go. ======================== 2. Build system changes diff --git a/Zend/zend_hash.c b/Zend/zend_hash.c index 4640846ecc76..e478a48aeeba 100644 --- a/Zend/zend_hash.c +++ b/Zend/zend_hash.c @@ -1246,6 +1246,48 @@ ZEND_API zval* ZEND_FASTCALL zend_hash_next_index_insert_new(HashTable *ht, zval return _zend_hash_index_add_or_update_i(ht, ht->nNextFreeElement, pData, HASH_ADD | HASH_ADD_NEW | HASH_ADD_NEXT); } +ZEND_API uint32_t ZEND_FASTCALL zend_hash_next_index_insert_copies(HashTable *ht, zval *values, uint32_t count) +{ + IS_CONSISTENT(ht); + HT_ASSERT_RC1(ht); + + /* Several values can be stored directly after the last used slot of a + * packed or uninitialized array. */ + if (count > 1 && (HT_IS_PACKED(ht) || (HT_FLAGS(ht) & HASH_FLAG_UNINITIALIZED))) { + zend_long next = ht->nNextFreeElement == ZEND_LONG_MIN ? 0 : ht->nNextFreeElement; + bool appends_after_last = next == (zend_long) ht->nNumUsed; + uint32_t room = ht->nTableSize - ht->nNumUsed; + /* Growing a packed array with holes may convert it to a hash, which + * the one-by-one path below handles. */ + bool can_grow = HT_IS_WITHOUT_HOLES(ht) && count <= HT_MAX_SIZE - ht->nNumUsed; + + if (appends_after_last && (count <= room || can_grow)) { + if (count > room) { + zend_hash_extend(ht, ht->nNumUsed + count, true); + } else if (HT_FLAGS(ht) & HASH_FLAG_UNINITIALIZED) { + zend_hash_real_init_packed_ex(ht); + } + + zval *zv = ht->arPacked + ht->nNumUsed; + for (uint32_t i = 0; i < count; i++) { + ZVAL_COPY(&zv[i], &values[i]); + } + ht->nNumUsed += count; + ht->nNumOfElements += count; + ht->nNextFreeElement = ht->nNumUsed; + return count; + } + } + + for (uint32_t i = 0; i < count; i++) { + if (!_zend_hash_index_add_or_update_i(ht, ht->nNextFreeElement, &values[i], HASH_ADD | HASH_ADD_NEXT)) { + return i; + } + Z_TRY_ADDREF(values[i]); + } + return count; +} + ZEND_API zval* ZEND_FASTCALL zend_hash_index_lookup(HashTable *ht, zend_ulong h) { return _zend_hash_index_add_or_update_i(ht, h, NULL, HASH_LOOKUP); diff --git a/Zend/zend_hash.h b/Zend/zend_hash.h index c7cdbe95866e..33cc1510f380 100644 --- a/Zend/zend_hash.h +++ b/Zend/zend_hash.h @@ -138,6 +138,11 @@ ZEND_API zval* ZEND_FASTCALL zend_hash_index_add_new(HashTable *ht, zend_ulong h ZEND_API zval* ZEND_FASTCALL zend_hash_index_update(HashTable *ht, zend_ulong h, zval *pData); ZEND_API zval* ZEND_FASTCALL zend_hash_next_index_insert(HashTable *ht, zval *pData); ZEND_API zval* ZEND_FASTCALL zend_hash_next_index_insert_new(HashTable *ht, zval *pData); +/* Inserts the count values in order like zend_hash_next_index_insert(), and + * adds a reference to each inserted value (like ZVAL_COPY()). Returns the + * number of inserted values, which is less than count only if an insertion + * failed; the values after it are then not inserted. */ +ZEND_API uint32_t ZEND_FASTCALL zend_hash_next_index_insert_copies(HashTable *ht, zval *values, uint32_t count); ZEND_API zval* ZEND_FASTCALL zend_hash_index_add_empty_element(HashTable *ht, zend_ulong h); ZEND_API zval* ZEND_FASTCALL zend_hash_add_empty_element(HashTable *ht, zend_string *key); diff --git a/ext/standard/array.c b/ext/standard/array.c index fae8b70895c0..8730b929d35b 100644 --- a/ext/standard/array.c +++ b/ext/standard/array.c @@ -3476,53 +3476,21 @@ PHP_FUNCTION(array_push) Z_PARAM_VARIADIC('+', args, argc) ZEND_PARSE_PARAMETERS_END(); - HashTable *ht = Z_ARRVAL_P(stack); - - /* Pushing nothing must leave the array untouched. A single value is added - * to an uninitialized array more cheaply by zend_hash_next_index_insert(). */ - if (argc > 0 && (HT_IS_PACKED(ht) || (argc > 1 && (HT_FLAGS(ht) & HASH_FLAG_UNINITIALIZED)))) { - zend_long next_index = ht->nNextFreeElement == ZEND_LONG_MIN ? 0 : ht->nNextFreeElement; - bool appends_after_last = next_index == (zend_long) ht->nNumUsed; - uint32_t room = ht->nTableSize - ht->nNumUsed; - - /* The values can be stored after the last used slot. Growing an array - * with holes may turn it into a hash, so it must already have room. */ - if (appends_after_last - && argc <= HT_MAX_SIZE - ht->nNumUsed /* nNumUsed + argc must not wrap */ - && (argc <= room || HT_IS_WITHOUT_HOLES(ht))) { - if (UNEXPECTED(argc > room)) { - zend_hash_extend(ht, ht->nNumUsed + argc, true); - } else if (HT_FLAGS(ht) & HASH_FLAG_UNINITIALIZED) { - zend_hash_real_init_packed(ht); - } - - /* ZEND_HASH_FILL_END() resets the internal pointer, array_push() keeps it. */ - uint32_t pos = ht->nInternalPointer; - ZEND_HASH_FILL_PACKED(ht) { - for (uint32_t i = 0; i < argc; i++) { - Z_TRY_ADDREF(args[i]); - ZEND_HASH_FILL_ADD(&args[i]); - } - } ZEND_HASH_FILL_END(); - ht->nInternalPointer = pos; - - RETURN_LONG(zend_hash_num_elements(ht)); - } - } - - /* Otherwise, insert the values one by one. */ - for (uint32_t i = 0; i < argc; i++) { - Z_TRY_ADDREF(args[i]); - - if (zend_hash_next_index_insert(ht, &args[i]) == NULL) { - Z_TRY_DELREF(args[i]); + /* A single value is added faster by zend_hash_next_index_insert(). */ + if (argc == 1) { + Z_TRY_ADDREF(args[0]); + if (zend_hash_next_index_insert(Z_ARRVAL_P(stack), &args[0]) == NULL) { + Z_TRY_DELREF(args[0]); zend_throw_error(NULL, "Cannot add element to the array as the next element is already occupied"); RETURN_THROWS(); } + } else if (zend_hash_next_index_insert_copies(Z_ARRVAL_P(stack), args, argc) < argc) { + zend_throw_error(NULL, "Cannot add element to the array as the next element is already occupied"); + RETURN_THROWS(); } /* Clean up and return the number of values in the stack */ - RETVAL_LONG(zend_hash_num_elements(ht)); + RETVAL_LONG(zend_hash_num_elements(Z_ARRVAL_P(stack))); } /* }}} */ diff --git a/ext/standard/tests/array/array_push_multiple_values.phpt b/ext/standard/tests/array/array_push_multiple_values.phpt new file mode 100644 index 000000000000..07e2a7218d7d --- /dev/null +++ b/ext/standard/tests/array/array_push_multiple_values.phpt @@ -0,0 +1,163 @@ +--TEST-- +array_push() with several values +--FILE-- + 0]; +unset($a[-3]); +$copy = $a; +var_dump(array_push($a, 1, 2), array_keys($a), $copy); + +echo "Internal pointer\n"; +$a = [1, 2, 3]; +next($a); +array_push($a, 4, 5); +var_dump(key($a)); + +echo "Refcounted values\n"; +$o = new stdClass; +$a = [1]; +array_push($a, $o, $o); +debug_zval_dump($o); +unset($a); +debug_zval_dump($o); + +echo "Next index already used\n"; +$a = [PHP_INT_MAX - 1 => 0]; +try { + array_push($a, $o, $o, $o); +} catch (Error $e) { + echo $e->getMessage(), "\n"; + unset($e); +} +var_dump(array_keys($a) === [PHP_INT_MAX - 1, PHP_INT_MAX]); +debug_zval_dump($o); +?> +--EXPECT-- +Room for all values +int(8) +bool(true) +One value more than the room +int(9) +bool(true) +More values than twice the table size +int(25) +bool(true) +Empty array +int(2) +bool(true) +int(9) +bool(true) +No values keep the next index of an empty array +int(0) +array(2) { + [0]=> + int(-5) + [1]=> + int(-4) +} +Hole with room +int(5) +array(5) { + [0]=> + int(0) + [1]=> + int(2) + [2]=> + int(3) + [3]=> + int(4) + [4]=> + int(5) +} +Holes without room +int(4) +NULL +array(4) { + [0]=> + int(14) + [1]=> + int(15) + [2]=> + int(16) + [3]=> + int(17) +} +Next index after the last used slot +int(4) +array(4) { + [0]=> + int(0) + [1]=> + int(1) + [2]=> + int(3) + [3]=> + int(4) +} +Negative next index of an empty copy +int(2) +array(2) { + [0]=> + int(-2) + [1]=> + int(-1) +} +array(0) { +} +Internal pointer +int(1) +Refcounted values +object(stdClass)#1 (0) refcount(4){ +} +object(stdClass)#1 (0) refcount(2){ +} +Next index already used +Cannot add element to the array as the next element is already occupied +bool(true) +object(stdClass)#1 (0) refcount(3){ +} diff --git a/ext/standard/tests/array/array_push_packed_bulk_metadata.phpt b/ext/standard/tests/array/array_push_packed_bulk_metadata.phpt deleted file mode 100644 index 3c8b0a466323..000000000000 --- a/ext/standard/tests/array/array_push_packed_bulk_metadata.phpt +++ /dev/null @@ -1,52 +0,0 @@ ---TEST-- -array_push() keeps the internal pointer and the next index ---FILE-- - 0]; -unset($a[-3]); -$copy = $a; -array_push($a, 1, 2); -var_dump(array_keys($a)); -?> ---EXPECT-- -int(1) -array(3) { - [0]=> - int(0) - [1]=> - int(1) - [2]=> - int(3) -} -array(2) { - [0]=> - int(-5) - [1]=> - int(-4) -} -array(2) { - [0]=> - int(-2) - [1]=> - int(-1) -} From 67c59787d96ac0a9a5f8e421e400fc88849b34f2 Mon Sep 17 00:00:00 2001 From: mehmetcansahin Date: Tue, 20 Oct 2026 13:08:40 +0300 Subject: [PATCH 4/4] zend_hash: Clarify why packed arrays with holes are inserted one by one --- Zend/zend_hash.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Zend/zend_hash.c b/Zend/zend_hash.c index e478a48aeeba..222cfc484d11 100644 --- a/Zend/zend_hash.c +++ b/Zend/zend_hash.c @@ -1257,8 +1257,9 @@ ZEND_API uint32_t ZEND_FASTCALL zend_hash_next_index_insert_copies(HashTable *ht zend_long next = ht->nNextFreeElement == ZEND_LONG_MIN ? 0 : ht->nNextFreeElement; bool appends_after_last = next == (zend_long) ht->nNumUsed; uint32_t room = ht->nTableSize - ht->nNumUsed; - /* Growing a packed array with holes may convert it to a hash, which - * the one-by-one path below handles. */ + /* Whether a packed array with holes stays packed when it grows + * depends on how full it is (see _zend_hash_index_add_or_update_i()), + * so those go through the one-by-one path below. */ bool can_grow = HT_IS_WITHOUT_HOLES(ht) && count <= HT_MAX_SIZE - ht->nNumUsed; if (appends_after_last && (count <= room || can_grow)) {