Commit eedda2a03c6 for php.net

commit eedda2a03c681f12e7aaef87fc747191bd047a67
Author: Alexandru Pătrănescu <drealecs@gmail.com>
Date:   Fri May 8 15:42:23 2026 +0200

    Add consumed_args callback optimization and apply to array_reduce() for carry to improve performance (GH-21340)

    * Add consumed_args support for fcall arguments

    * Use consumed_args for array_reduce and some other callback-using functions

diff --git a/UPGRADING.INTERNALS b/UPGRADING.INTERNALS
index a4297eec587..b17922f84c3 100644
--- a/UPGRADING.INTERNALS
+++ b/UPGRADING.INTERNALS
@@ -103,6 +103,10 @@ PHP 8.6 INTERNALS UPGRADE NOTES
   . The deprecated Z_IMMUTABLE(), Z_IMMUTABLE_P(), Z_OPT_IMMUTABLE(), and
     Z_OPT_IMMUTABLE_P() macros have been removed. Check for
     IS_ARRAY && !REFCOUNTED directly.
+  . Added zend_fcall_info.consumed_args together with
+    zend_fci_consumed_arg(), which allows moving a selected callback argument
+    instead of copying it in zend_call_function(). Currently only a single
+    consumed argument is supported.

 ========================
 2. Build system changes
diff --git a/Zend/zend_API.c b/Zend/zend_API.c
index ccb770a9963..004b4fd81e9 100644
--- a/Zend/zend_API.c
+++ b/Zend/zend_API.c
@@ -4243,6 +4243,7 @@ ZEND_API zend_result zend_fcall_info_init(const zval *callable, uint32_t check_f
 	fci->param_count = 0;
 	fci->params = NULL;
 	fci->named_params = NULL;
+	fci->consumed_args = 0;

 	return SUCCESS;
 }
diff --git a/Zend/zend_API.h b/Zend/zend_API.h
index 01a9202be1c..2487c8b632f 100644
--- a/Zend/zend_API.h
+++ b/Zend/zend_API.h
@@ -48,6 +48,7 @@ typedef struct _zend_fcall_info {
 	zval *params;
 	zend_object *object;
 	uint32_t param_count;
+	uint32_t consumed_args;
 	/* This hashtable can also contain positional arguments (with integer keys),
 	 * which will be appended to the normal params[]. This makes it easier to
 	 * integrate APIs like call_user_func_array(). The usual restriction that
@@ -341,6 +342,13 @@ typedef struct _zend_fcall_info_cache {
 #define ZEND_FCI_INITIALIZED(fci) ((fci).size != 0)
 #define ZEND_FCC_INITIALIZED(fcc) ((fcc).function_handler != NULL)

+static zend_always_inline uint32_t zend_fci_consumed_arg(uint32_t arg_index) {
+	return arg_index < 32 ? (UINT32_C(1) << arg_index) : UINT32_C(0);
+}
+static zend_always_inline bool zend_fci_is_consumed_arg(uint32_t consumed_args, uint32_t arg_index) {
+	return arg_index < 32 && (consumed_args & (UINT32_C(1) << arg_index));
+}
+
 ZEND_API int zend_next_free_module(void);

 BEGIN_EXTERN_C()
diff --git a/Zend/zend_closures.c b/Zend/zend_closures.c
index b46085c80af..9763dd34613 100644
--- a/Zend/zend_closures.c
+++ b/Zend/zend_closures.c
@@ -154,6 +154,7 @@ ZEND_METHOD(Closure, call)
 	fci_cache.object = fci.object = newobj;

 	fci.size = sizeof(fci);
+	fci.consumed_args = 0;
 	ZVAL_OBJ(&fci.function_name, &closure->std);
 	ZVAL_UNDEF(&closure_result);
 	fci.retval = &closure_result;
diff --git a/Zend/zend_execute_API.c b/Zend/zend_execute_API.c
index 0022eb4a1df..71e0c56a51c 100644
--- a/Zend/zend_execute_API.c
+++ b/Zend/zend_execute_API.c
@@ -795,6 +795,7 @@ zend_result _call_user_function_impl(zval *object, zval *function_name, zval *re
 	fci.param_count = param_count;
 	fci.params = params;
 	fci.named_params = named_params;
+	fci.consumed_args = 0;

 	return zend_call_function(&fci, NULL);
 }
@@ -862,6 +863,9 @@ zend_result zend_call_function(zend_fcall_info *fci, zend_fcall_info_cache *fci_

 	call = zend_vm_stack_push_call_frame(call_info,
 		func, fci->param_count, object_or_called_scope);
+	uint32_t consumed_args = fci->param_count ? fci->consumed_args : 0;
+
+	ZEND_ASSERT((consumed_args & (consumed_args - 1)) == 0);

 	for (uint32_t i = 0; i < fci->param_count; i++) {
 		zval *param = ZEND_CALL_ARG(call, i+1);
@@ -903,7 +907,15 @@ zend_result zend_call_function(zend_fcall_info *fci, zend_fcall_info_cache *fci_
 		}

 		if (EXPECTED(!must_wrap)) {
-			ZVAL_COPY(param, arg);
+			if (EXPECTED(consumed_args == 0)
+					|| !zend_fci_is_consumed_arg(consumed_args, i)
+					|| Z_ISREF_P(arg)
+					|| arg != &fci->params[i]) {
+				ZVAL_COPY(param, arg);
+			} else {
+				ZVAL_COPY_VALUE(param, arg);
+				ZVAL_UNDEF(arg);
+			}
 		} else {
 			Z_TRY_ADDREF_P(arg);
 			ZVAL_NEW_REF(param, arg);
@@ -1092,6 +1104,7 @@ ZEND_API void zend_call_known_function(
 	fci.param_count = param_count;
 	fci.params = params;
 	fci.named_params = named_params;
+	fci.consumed_args = 0;
 	ZVAL_UNDEF(&fci.function_name); /* Unused */

 	fcic.function_handler = fn;
diff --git a/ext/dom/xpath_callbacks.c b/ext/dom/xpath_callbacks.c
index b1a2f808ca6..90395dc15f2 100644
--- a/ext/dom/xpath_callbacks.c
+++ b/ext/dom/xpath_callbacks.c
@@ -406,6 +406,7 @@ static zend_result php_dom_xpath_callback_dispatch(php_dom_xpath_callbacks *xpat
 		fci.param_count = param_count;
 		fci.params = params;
 		fci.named_params = NULL;
+		fci.consumed_args = 0;
 		ZVAL_STRINGL(&fci.function_name, function_name, function_name_length);

 		zend_call_function(&fci, NULL);
diff --git a/ext/ffi/ffi.c b/ext/ffi/ffi.c
index cc0c4c29744..779b41fcad3 100644
--- a/ext/ffi/ffi.c
+++ b/ext/ffi/ffi.c
@@ -948,6 +948,7 @@ static void zend_ffi_callback_trampoline(ffi_cif* cif, void* ret, void** args, v
 	fci.object = NULL;
 	fci.param_count = callback_data->arg_count;
 	fci.named_params = NULL;
+	fci.consumed_args = 0;

 	if (callback_data->type->func.args) {
 		int n = 0;
diff --git a/ext/pcre/php_pcre.c b/ext/pcre/php_pcre.c
index 29c2551f38f..b9f8e1211ff 100644
--- a/ext/pcre/php_pcre.c
+++ b/ext/pcre/php_pcre.c
@@ -1559,6 +1559,7 @@ static zend_string *preg_do_repl_func(zend_fcall_info *fci, zend_fcall_info_cach
 	fci->retval = &retval;
 	fci->param_count = 1;
 	fci->params = &arg;
+	fci->consumed_args = zend_fci_consumed_arg(0);
 	zend_call_function(fci, fcc);
 	zval_ptr_dtor(&arg);
 	if (EXPECTED(Z_TYPE(retval) == IS_STRING)) {
diff --git a/ext/pcre/tests/preg_replace_callback_matches_refcount.phpt b/ext/pcre/tests/preg_replace_callback_matches_refcount.phpt
new file mode 100644
index 00000000000..0ae802654c9
--- /dev/null
+++ b/ext/pcre/tests/preg_replace_callback_matches_refcount.phpt
@@ -0,0 +1,37 @@
+--TEST--
+preg_replace_callback(): capture match array refcount stays low during callback
+--FILE--
+<?php
+var_dump(preg_replace_callback('/(.)(.)(.)/', static function ($matches) {
+    debug_zval_dump($matches);
+    return '';
+}, 'abc'));
+
+var_dump(preg_replace_callback_array(['/(.)(.)(.)/' => static function ($matches) {
+    debug_zval_dump($matches);
+    return '';
+}], 'abc'));
+?>
+--EXPECTF--
+array(4) packed refcount(2){
+  [0]=>
+  string(3) "abc"%s
+  [1]=>
+  string(1) "a" interned
+  [2]=>
+  string(1) "b" interned
+  [3]=>
+  string(1) "c" interned
+}
+string(0) ""
+array(4) packed refcount(2){
+  [0]=>
+  string(3) "abc"%s
+  [1]=>
+  string(1) "a" interned
+  [2]=>
+  string(1) "b" interned
+  [3]=>
+  string(1) "c" interned
+}
+string(0) ""
diff --git a/ext/standard/array.c b/ext/standard/array.c
index e175b7b8d2f..49a3bbd557b 100644
--- a/ext/standard/array.c
+++ b/ext/standard/array.c
@@ -6417,6 +6417,7 @@ PHP_FUNCTION(array_reduce)
 	fci.retval = return_value;
 	fci.param_count = 2;
 	fci.params = args;
+	fci.consumed_args = zend_fci_consumed_arg(0);

 	ZEND_HASH_FOREACH_VAL(htbl, operand) {
 		ZVAL_COPY_VALUE(&args[0], return_value);
diff --git a/ext/standard/tests/array/array_reduce_accumulator_refcount.phpt b/ext/standard/tests/array/array_reduce_accumulator_refcount.phpt
new file mode 100644
index 00000000000..f2a0639b345
--- /dev/null
+++ b/ext/standard/tests/array/array_reduce_accumulator_refcount.phpt
@@ -0,0 +1,35 @@
+--TEST--
+array_reduce(): accumulator refcount stays low during callback
+--FILE--
+<?php
+
+$result = array_reduce([1, 2, 3], function ($acc, $val) {
+    debug_zval_dump($acc);
+    $acc[] = $val;
+    return $acc;
+}, []);
+
+debug_zval_dump($result);
+
+?>
+--EXPECT--
+array(0) interned {
+}
+array(1) packed refcount(2){
+  [0]=>
+  int(1)
+}
+array(2) packed refcount(2){
+  [0]=>
+  int(1)
+  [1]=>
+  int(2)
+}
+array(3) packed refcount(2){
+  [0]=>
+  int(1)
+  [1]=>
+  int(2)
+  [2]=>
+  int(3)
+}
diff --git a/ext/zend_test/test.c b/ext/zend_test/test.c
index b947ad3302a..48d28a8a4c3 100644
--- a/ext/zend_test/test.c
+++ b/ext/zend_test/test.c
@@ -545,6 +545,76 @@ static ZEND_FUNCTION(zend_call_method_if_exists)
 	}
 }

+static ZEND_FUNCTION(zend_test_call_with_consumed_args)
+{
+	zend_fcall_info fci = empty_fcall_info;
+	zend_fcall_info_cache fcc = empty_fcall_info_cache;
+	zval *args;
+	zend_long consumed_args;
+	zval retval;
+	uint32_t actual_consumed_args = 0;
+	uint32_t i;
+	zend_result call_result;
+
+	ZEND_PARSE_PARAMETERS_START(3, 3)
+		Z_PARAM_FUNC(fci, fcc)
+		Z_PARAM_ARRAY(args)
+		Z_PARAM_LONG(consumed_args)
+	ZEND_PARSE_PARAMETERS_END();
+
+	if (UNEXPECTED(consumed_args < 0 || consumed_args > UINT32_MAX)) {
+		zend_argument_value_error(3, "must be between 0 and 4294967295");
+		RETURN_THROWS();
+	}
+
+	zend_fcall_info_args(&fci, args);
+
+	ZVAL_UNDEF(&retval);
+	fci.retval = &retval;
+	fci.consumed_args = (uint32_t) consumed_args;
+
+	call_result = zend_call_function(&fci, &fcc);
+
+	for (i = 0; i < fci.param_count && i < 32; i++) {
+		if (Z_ISUNDEF(fci.params[i])) {
+			actual_consumed_args |= (1u << i);
+		}
+	}
+
+	zend_fcall_info_args_clear(&fci, true);
+
+	if (call_result == FAILURE || EG(exception)) {
+		if (!Z_ISUNDEF(retval)) {
+			zval_ptr_dtor(&retval);
+		}
+		RETURN_THROWS();
+	}
+
+	array_init(return_value);
+	add_assoc_long(return_value, "consumed_args", actual_consumed_args);
+
+	if (Z_ISUNDEF(retval)) {
+		add_assoc_null(return_value, "retval");
+	} else {
+		add_assoc_zval(return_value, "retval", &retval);
+	}
+}
+
+static ZEND_FUNCTION(zend_test_refcount)
+{
+	zval *value;
+
+	ZEND_PARSE_PARAMETERS_START(1, 1)
+		Z_PARAM_ZVAL(value)
+	ZEND_PARSE_PARAMETERS_END();
+
+	if (!Z_REFCOUNTED_P(value)) {
+		RETURN_LONG(-1);
+	}
+
+	RETURN_LONG(Z_REFCOUNT_P(value));
+}
+
 static ZEND_FUNCTION(zend_get_unit_enum)
 {
 	ZEND_PARSE_PARAMETERS_NONE();
diff --git a/ext/zend_test/test.stub.php b/ext/zend_test/test.stub.php
index 653630ed73b..489d7d0a260 100644
--- a/ext/zend_test/test.stub.php
+++ b/ext/zend_test/test.stub.php
@@ -305,6 +305,10 @@ function zend_object_init_with_constructor(string $class, mixed ...$args): mixed

     function zend_call_method_if_exists(object $obj, string $method, mixed ...$args): mixed {}

+    function zend_test_call_with_consumed_args(callable $cb, array $args, int $consumed_args): array {}
+
+    function zend_test_refcount(mixed $value): int {}
+
     function zend_test_zend_ini_parse_quantity(string $str): int {}
     function zend_test_zend_ini_parse_uquantity(string $str): int {}

diff --git a/ext/zend_test/test_arginfo.h b/ext/zend_test/test_arginfo.h
index adcae16cdf6..94f75cdb360 100644
Binary files a/ext/zend_test/test_arginfo.h and b/ext/zend_test/test_arginfo.h differ
diff --git a/ext/zend_test/test_decl.h b/ext/zend_test/test_decl.h
index ba6aab90228..4a6babbe12b 100644
Binary files a/ext/zend_test/test_decl.h and b/ext/zend_test/test_decl.h differ
diff --git a/ext/zend_test/test_legacy_arginfo.h b/ext/zend_test/test_legacy_arginfo.h
index b42d524d7a8..a4c1ae3f2c9 100644
Binary files a/ext/zend_test/test_legacy_arginfo.h and b/ext/zend_test/test_legacy_arginfo.h differ
diff --git a/ext/zend_test/tests/consumed_args_basic.phpt b/ext/zend_test/tests/consumed_args_basic.phpt
new file mode 100644
index 00000000000..711ac812457
--- /dev/null
+++ b/ext/zend_test/tests/consumed_args_basic.phpt
@@ -0,0 +1,43 @@
+--TEST--
+zend_test_call_with_consumed_args(): consume a non-reference arg
+--EXTENSIONS--
+zend_test
+--FILE--
+<?php
+
+$result = zend_test_call_with_consumed_args(
+    static function ($a, $b) {
+        debug_zval_dump($a);
+        return [$a, $b];
+    },
+    [range(1, 3), "x"],
+    1,
+);
+
+var_dump($result["consumed_args"]);
+var_dump($result["retval"]);
+
+?>
+--EXPECTF--
+array(3) %srefcount(3){
+  [0]=>
+  int(1)
+  [1]=>
+  int(2)
+  [2]=>
+  int(3)
+}
+int(1)
+array(2) {
+  [0]=>
+  array(3) {
+    [0]=>
+    int(1)
+    [1]=>
+    int(2)
+    [2]=>
+    int(3)
+  }
+  [1]=>
+  string(1) "x"
+}
diff --git a/ext/zend_test/tests/consumed_args_ob_start_refcount.phpt b/ext/zend_test/tests/consumed_args_ob_start_refcount.phpt
new file mode 100644
index 00000000000..3d5088d284d
--- /dev/null
+++ b/ext/zend_test/tests/consumed_args_ob_start_refcount.phpt
@@ -0,0 +1,23 @@
+--TEST--
+ob_start(): consumed callback arg has low refcount
+--EXTENSIONS--
+zend_test
+--FILE--
+<?php
+
+$counts = [];
+ob_start(static function ($buffer) use (&$counts) {
+    $counts[] = zend_test_refcount($buffer);
+    return '';
+});
+echo 'abc';
+ob_end_flush();
+
+var_dump($counts);
+
+?>
+--EXPECT--
+array(1) {
+  [0]=>
+  int(2)
+}
diff --git a/ext/zend_test/tests/consumed_args_ref_arg.phpt b/ext/zend_test/tests/consumed_args_ref_arg.phpt
new file mode 100644
index 00000000000..a528860006b
--- /dev/null
+++ b/ext/zend_test/tests/consumed_args_ref_arg.phpt
@@ -0,0 +1,35 @@
+--TEST--
+zend_test_call_with_consumed_args(): do not consume reference args
+--EXTENSIONS--
+zend_test
+--FILE--
+<?php
+
+$value = [10];
+
+$result = zend_test_call_with_consumed_args(
+    static function ($a) {
+        $a[] = 20;
+        return $a;
+    },
+    [&$value],
+    1,
+);
+
+var_dump($result["consumed_args"]);
+var_dump($result["retval"]);
+var_dump($value);
+
+?>
+--EXPECT--
+int(0)
+array(2) {
+  [0]=>
+  int(10)
+  [1]=>
+  int(20)
+}
+array(1) {
+  [0]=>
+  int(10)
+}
diff --git a/ext/zend_test/tests/consumed_args_ref_param_and_ref_arg.phpt b/ext/zend_test/tests/consumed_args_ref_param_and_ref_arg.phpt
new file mode 100644
index 00000000000..01d4025842a
--- /dev/null
+++ b/ext/zend_test/tests/consumed_args_ref_param_and_ref_arg.phpt
@@ -0,0 +1,37 @@
+--TEST--
+zend_test_call_with_consumed_args(): ref-required params with reference args are not consumed
+--EXTENSIONS--
+zend_test
+--FILE--
+<?php
+
+$value = [10];
+
+$result = zend_test_call_with_consumed_args(
+    static function (&$a) {
+        $a[] = 20;
+        return $a;
+    },
+    [&$value],
+    1,
+);
+
+var_dump($result["consumed_args"]);
+var_dump($result["retval"]);
+var_dump($value);
+
+?>
+--EXPECT--
+int(0)
+array(2) {
+  [0]=>
+  int(10)
+  [1]=>
+  int(20)
+}
+array(2) {
+  [0]=>
+  int(10)
+  [1]=>
+  int(20)
+}
diff --git a/ext/zend_test/tests/consumed_args_ref_required.phpt b/ext/zend_test/tests/consumed_args_ref_required.phpt
new file mode 100644
index 00000000000..f261131a686
--- /dev/null
+++ b/ext/zend_test/tests/consumed_args_ref_required.phpt
@@ -0,0 +1,24 @@
+--TEST--
+zend_test_call_with_consumed_args(): ref-required params are not consumed
+--EXTENSIONS--
+zend_test
+--FILE--
+<?php
+
+$result = zend_test_call_with_consumed_args(
+    static function (&$a) {
+        $a = 42;
+        return $a;
+    },
+    [1],
+    1,
+);
+
+var_dump($result["consumed_args"]);
+var_dump($result["retval"]);
+
+?>
+--EXPECTF--
+Warning: {closure:%s:%d}(): Argument #1 ($a) must be passed by reference, value given in %s on line %d
+int(0)
+int(42)
diff --git a/main/output.c b/main/output.c
index 72b42301a72..664adf7f168 100644
--- a/main/output.c
+++ b/main/output.c
@@ -970,6 +970,7 @@ static inline php_output_handler_status_t php_output_handler_op(php_output_handl
 			handler->func.user->fci.param_count = 2;
 			handler->func.user->fci.params = ob_args;
 			handler->func.user->fci.retval = &retval;
+			handler->func.user->fci.consumed_args = zend_fci_consumed_arg(0);

 			if (SUCCESS == zend_call_function(&handler->func.user->fci, &handler->func.user->fcc) && Z_TYPE(retval) != IS_UNDEF) {
 				if (handler->flags & PHP_OUTPUT_HANDLER_PRODUCED_OUTPUT) {