Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions UPGRADING
Original file line number Diff line number Diff line change
Expand Up @@ -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().
Expand Down
41 changes: 39 additions & 2 deletions ext/standard/array.c
Original file line number Diff line number Diff line change
Expand Up @@ -3476,19 +3476,56 @@ 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)))) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition is quite complex. Maybe the single value is indeed an edge case that's handled faster by the old code, but does it really have such a large performance difference that this complexity is warranted?

@mehmetcansahin mehmetcansahin Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped the argc > 1 special case, it made no measurable difference. The one split left is argc == 1 in array_push(): routing a single value through the new function cost ~5% on array_push($list, $x).

/* Like zend_hash_next_index_insert(), treat ZEND_LONG_MIN (no integer
* key used yet) as 0. */
Comment thread
LamentXU123 marked this conversation as resolved.
Outdated
zend_long next_index = ht->nNextFreeElement == ZEND_LONG_MIN ? 0 : ht->nNextFreeElement;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This leaks details of zend_hash into ext/standard, this should be abstracted somehow

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved it into Zend/zend_hash.c as a new ZEND_API function, zend_hash_next_index_insert_copies() (noted in UPGRADING.INTERNALS), next to _zend_hash_index_add_or_update_i(), whose next-index and growth rules it follows. array_push() has no HashTable internals left. Since the function writes the slots itself instead of using ZEND_HASH_FILL_*, the internal pointer no longer needs saving and restoring.

It also adds the references, like ZVAL_COPY(): array_push() needs that, and a separate addref loop made 1000 spread values ~6% slower. SplFixedArray's get_properties_for() and __serialize() fill arrays the same way; switching them locally made (array) $fixed 53% and __serialize() 70% faster for 1000 elements, which I'd send as a follow-up.

The call is out of line now, which costs a few points for 3 values compared with the inline version; single values still use zend_hash_next_index_insert() directly. Happy to change the name or semantics if you'd prefer something else.

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)) {
Comment thread
mehmetcansahin marked this conversation as resolved.
Outdated
zend_hash_extend(ht, ht->nNumUsed + argc, true);
} else if (UNEXPECTED(HT_FLAGS(ht) & HASH_FLAG_UNINITIALIZED)) {
Comment thread
mehmetcansahin marked this conversation as resolved.
Outdated
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 */
Comment thread
mehmetcansahin marked this conversation as resolved.
Outdated
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();
}
}

/* 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));
}
/* }}} */

Expand Down
52 changes: 52 additions & 0 deletions ext/standard/tests/array/array_push_packed_bulk_metadata.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
--TEST--
array_push() keeps the internal pointer and the next index
Comment thread
mehmetcansahin marked this conversation as resolved.
Outdated
--FILE--
<?php
// The internal pointer does not move
$a = [1, 2, 3];
next($a);
array_push($a, 4, 5);
var_dump(key($a));

// The next index left by a removed last element is used
$a = [0, 1, 2];
unset($a[2]);
array_push($a, 3);
var_dump(array_keys($a));

// Pushing nothing keeps the next index of an empty array
$a = [];
array_push($a);
$a[-5] = 1;
$a[] = 2;
var_dump(array_keys($a));

// A negative next index of an empty copy is used

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't reach new code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, it takes the one-by-one path: the copy is uninitialized with nNextFreeElement = -2. I kept it on purpose, it fails if the next-index check is dropped.

$a = [-3 => 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)
}
Loading