From 40e2a53ccb9920741eb1d988f4dc2e0a8570822d Mon Sep 17 00:00:00 2001 From: Florian Engelhardt Date: Mon, 1 Sep 2025 13:15:35 +0200 Subject: [PATCH 1/9] re-use `sprintf()` optimisation for `printf()` --- Zend/Optimizer/block_pass.c | 49 ++++++++++++++++++++++++++++++++++++- Zend/zend_compile.c | 21 ++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/Zend/Optimizer/block_pass.c b/Zend/Optimizer/block_pass.c index 275c519d431be..603bc4d6989ec 100644 --- a/Zend/Optimizer/block_pass.c +++ b/Zend/Optimizer/block_pass.c @@ -431,6 +431,53 @@ static void zend_optimize_block(zend_basic_block *block, zend_op_array *op_array case ZEND_CASE: case ZEND_CASE_STRICT: case ZEND_COPY_TMP: + /* Check for printf optimization from `zend_compile_func_printf()` + * where the result of `printf()` is actually unused and remove the + * superflous COPY_TMP, STRLEN and FREE opcodes: + * T1 = COPY_TMP T0 + * ECHO T0 + * T2 = STRLEN T1 + * FREE T2 + */ + if (opline->op1_type == IS_TMP_VAR && + opline + 1 < end && (opline + 1)->opcode == ZEND_ECHO && + opline + 2 < end && (opline + 2)->opcode == ZEND_STRLEN && + opline + 3 < end && (opline + 3)->opcode == ZEND_FREE) { + + zend_op *echo_op = opline + 1; + zend_op *strlen_op = opline + 2; + zend_op *free_op = opline + 3; + + /* Verify the pattern: + * - ECHO uses the same source as COPY_TMP + * - STRLEN uses the result of COPY_TMP + * - FREE uses the result of STRLEN + */ + if (echo_op->op1_type == IS_TMP_VAR && + echo_op->op1.var == opline->op1.var && + strlen_op->op1_type == IS_TMP_VAR && + strlen_op->op1.var == opline->result.var && + free_op->op1_type == IS_TMP_VAR && + free_op->op1.var == strlen_op->result.var) { + + /* Remove COPY_TMP, STRLEN, and FREE */ + MAKE_NOP(opline); + MAKE_NOP(strlen_op); + MAKE_NOP(free_op); + + /* Update source tracking */ + if (opline->result_type == IS_TMP_VAR) { + VAR_SOURCE(opline->result) = NULL; + } + if (strlen_op->result_type == IS_TMP_VAR) { + VAR_SOURCE(strlen_op->result) = NULL; + } + + ++(*opt_count); + break; + } + } + if (opline->op1_type & (IS_TMP_VAR|IS_VAR)) { /* Variable will be deleted later by FREE, so we can't optimize it */ Tsource[VAR_NUM(opline->op1.var)] = NULL; @@ -538,7 +585,7 @@ static void zend_optimize_block(zend_basic_block *block, zend_op_array *op_array } } break; - + case ZEND_BOOL: case ZEND_BOOL_NOT: optimize_bool: diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index e01b985b6d68f..006b927591980 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -4954,6 +4954,25 @@ static zend_result zend_compile_func_sprintf(znode *result, zend_ast_list *args) return SUCCESS; } +static zend_result zend_compile_func_printf(znode *result, zend_ast_list *args) /* {{{ */ +{ + znode rope_result; + if (zend_compile_func_sprintf(&rope_result, args) != SUCCESS) { + return FAILURE; + } + + /* printf() returns the amount of bytes written, so just an ECHO of the resulting sprintf() + * optimisation might not be enough. At this early stage we can't detect if the result is + * actually used, so we just emit the opcodes and cleanup if they are not used in the + * optimizer later. */ + znode copy; + zend_emit_op_tmp(©, ZEND_COPY_TMP, &rope_result, NULL); + zend_emit_op(NULL, ZEND_ECHO, &rope_result, NULL); + zend_emit_op_tmp(result, ZEND_STRLEN, ©, NULL); + + return SUCCESS; +} + static zend_result zend_compile_func_clone(znode *result, zend_ast_list *args) { znode arg_node; @@ -5036,6 +5055,8 @@ static zend_result zend_try_compile_special_func_ex(znode *result, zend_string * return zend_compile_func_array_key_exists(result, args); } else if (zend_string_equals_literal(lcname, "sprintf")) { return zend_compile_func_sprintf(result, args); + } else if (zend_string_equals_literal(lcname, "printf")) { + return zend_compile_func_printf(result, args); } else if (zend_string_equals(lcname, ZSTR_KNOWN(ZEND_STR_CLONE))) { return zend_compile_func_clone(result, args); } else { From 353d28f8117fcc2406b66dac083d1f4c38ade584 Mon Sep 17 00:00:00 2001 From: Florian Engelhardt Date: Mon, 1 Sep 2025 14:59:43 +0200 Subject: [PATCH 2/9] handle special printf() case --- Zend/zend_compile.c | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index 006b927591980..4d7a55d922354 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -4956,6 +4956,45 @@ static zend_result zend_compile_func_sprintf(znode *result, zend_ast_list *args) static zend_result zend_compile_func_printf(znode *result, zend_ast_list *args) /* {{{ */ { + /* Special case: printf with a single constant string argument and no format specifiers. + * In this case, just emit ECHO and return the string length if needed. */ + if (args->children == 1) { + zend_eval_const_expr(&args->child[0]); + if (args->child[0]->kind == ZEND_AST_ZVAL) { + zval *format_string = zend_ast_get_zval(args->child[0]); + if (Z_TYPE_P(format_string) == IS_STRING) { + /* Check if there are any format specifiers */ + char *p = Z_STRVAL_P(format_string); + char *end = p + Z_STRLEN_P(format_string); + bool has_format_specs = false; + + while (p < end) { + if (*p == '%') { + p++; + if (p < end && *p != '%') { + has_format_specs = true; + break; + } + } + p++; + } + + if (!has_format_specs) { + /* No format specifiers - just emit ECHO and return string length */ + znode format_node; + zend_compile_expr(&format_node, args->child[0]); + zend_emit_op(NULL, ZEND_ECHO, &format_node, NULL); + + /* Return the string length as a constant if the result is used */ + result->op_type = IS_CONST; + ZVAL_LONG(&result->u.constant, Z_STRLEN_P(format_string)); + return SUCCESS; + } + } + } + } + + /* Fall back to sprintf optimization for format strings with specifiers */ znode rope_result; if (zend_compile_func_sprintf(&rope_result, args) != SUCCESS) { return FAILURE; @@ -4964,7 +5003,7 @@ static zend_result zend_compile_func_printf(znode *result, zend_ast_list *args) /* printf() returns the amount of bytes written, so just an ECHO of the resulting sprintf() * optimisation might not be enough. At this early stage we can't detect if the result is * actually used, so we just emit the opcodes and cleanup if they are not used in the - * optimizer later. */ + * optimizers block pass later. */ znode copy; zend_emit_op_tmp(©, ZEND_COPY_TMP, &rope_result, NULL); zend_emit_op(NULL, ZEND_ECHO, &rope_result, NULL); From 121da2ed6ada811500d3caa7291e6ca8c108d74f Mon Sep 17 00:00:00 2001 From: Florian Engelhardt Date: Mon, 1 Sep 2025 17:30:46 +0200 Subject: [PATCH 3/9] fix `printf(%%)` case --- Zend/zend_compile.c | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index 4d7a55d922354..33aaf0b6444bf 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -4966,20 +4966,8 @@ static zend_result zend_compile_func_printf(znode *result, zend_ast_list *args) /* Check if there are any format specifiers */ char *p = Z_STRVAL_P(format_string); char *end = p + Z_STRLEN_P(format_string); - bool has_format_specs = false; - while (p < end) { - if (*p == '%') { - p++; - if (p < end && *p != '%') { - has_format_specs = true; - break; - } - } - p++; - } - - if (!has_format_specs) { + if (!memchr(p, '%', end - p)) { /* No format specifiers - just emit ECHO and return string length */ znode format_node; zend_compile_expr(&format_node, args->child[0]); From 1526406e3abbd76da56aae4864b13050d5291fab Mon Sep 17 00:00:00 2001 From: Florian Engelhardt Date: Tue, 2 Sep 2025 12:06:54 +0200 Subject: [PATCH 4/9] do not collect constants after any UCALL/FCALL --- Zend/Optimizer/pass1.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Zend/Optimizer/pass1.c b/Zend/Optimizer/pass1.c index fe92db583fcd9..3fc73a40ac6a3 100644 --- a/Zend/Optimizer/pass1.c +++ b/Zend/Optimizer/pass1.c @@ -264,6 +264,12 @@ void zend_optimizer_pass1(zend_op_array *op_array, zend_optimizer_ctx *ctx) collect_constants = 0; break; } + case ZEND_DO_UCALL: + case ZEND_DO_FCALL: + case ZEND_DO_FCALL_BY_NAME: + /* don't collect constants after any UCALL/FCALL */ + collect_constants = 0; + break; case ZEND_STRLEN: if (opline->op1_type == IS_CONST && zend_optimizer_eval_strlen(&result, &ZEND_OP1_LITERAL(opline)) == SUCCESS) { From 3626cc0775f54143f9ef89f7809ff9d5b7249d60 Mon Sep 17 00:00:00 2001 From: Florian Engelhardt Date: Tue, 2 Sep 2025 15:38:19 +0200 Subject: [PATCH 5/9] do not collect constants after FRAMELESS calls either --- Zend/Optimizer/pass1.c | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Zend/Optimizer/pass1.c b/Zend/Optimizer/pass1.c index 3fc73a40ac6a3..b424f0756a73b 100644 --- a/Zend/Optimizer/pass1.c +++ b/Zend/Optimizer/pass1.c @@ -33,6 +33,7 @@ #include "zend_constants.h" #include "zend_execute.h" #include "zend_vm.h" +#include "zend_vm_opcodes.h" #define TO_STRING_NOWARN(val) do { \ if (Z_TYPE_P(val) < IS_ARRAY) { \ @@ -267,7 +268,11 @@ void zend_optimizer_pass1(zend_op_array *op_array, zend_optimizer_ctx *ctx) case ZEND_DO_UCALL: case ZEND_DO_FCALL: case ZEND_DO_FCALL_BY_NAME: - /* don't collect constants after any UCALL/FCALL */ + case ZEND_FRAMELESS_ICALL_0: + case ZEND_FRAMELESS_ICALL_1: + case ZEND_FRAMELESS_ICALL_2: + case ZEND_FRAMELESS_ICALL_3: + /* don't collect constants after any UCALL/FCALL/FRAMELESS ICALL */ collect_constants = 0; break; case ZEND_STRLEN: From 2ae379bd7be14736da0dae20971d49d8d1705210 Mon Sep 17 00:00:00 2001 From: Florian Engelhardt Date: Tue, 2 Sep 2025 15:38:39 +0200 Subject: [PATCH 6/9] code cleanup from pr review --- Zend/zend_compile.c | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index 33aaf0b6444bf..65b0c36299270 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -28,6 +28,7 @@ #include "zend_API.h" #include "zend_exceptions.h" #include "zend_interfaces.h" +#include "zend_types.h" #include "zend_virtual_cwd.h" #include "zend_multibyte.h" #include "zend_language_scanner.h" @@ -4960,25 +4961,24 @@ static zend_result zend_compile_func_printf(znode *result, zend_ast_list *args) * In this case, just emit ECHO and return the string length if needed. */ if (args->children == 1) { zend_eval_const_expr(&args->child[0]); - if (args->child[0]->kind == ZEND_AST_ZVAL) { - zval *format_string = zend_ast_get_zval(args->child[0]); - if (Z_TYPE_P(format_string) == IS_STRING) { - /* Check if there are any format specifiers */ - char *p = Z_STRVAL_P(format_string); - char *end = p + Z_STRLEN_P(format_string); - - if (!memchr(p, '%', end - p)) { - /* No format specifiers - just emit ECHO and return string length */ - znode format_node; - zend_compile_expr(&format_node, args->child[0]); - zend_emit_op(NULL, ZEND_ECHO, &format_node, NULL); - - /* Return the string length as a constant if the result is used */ - result->op_type = IS_CONST; - ZVAL_LONG(&result->u.constant, Z_STRLEN_P(format_string)); - return SUCCESS; - } - } + if (args->child[0]->kind != ZEND_AST_ZVAL) { + return FAILURE; + } + zval *format_string = zend_ast_get_zval(args->child[0]); + if (Z_TYPE_P(format_string) != IS_STRING) { + return FAILURE; + } + /* Check if there are any format specifiers */ + if (!memchr(Z_STRVAL_P(format_string), '%', Z_STRLEN_P(format_string))) { + /* No format specifiers - just emit ECHO and return string length */ + znode format_node; + zend_compile_expr(&format_node, args->child[0]); + zend_emit_op(NULL, ZEND_ECHO, &format_node, NULL); + + /* Return the string length as a constant if the result is used */ + result->op_type = IS_CONST; + ZVAL_LONG(&result->u.constant, Z_STRLEN_P(format_string)); + return SUCCESS; } } From f1f5c41e1999506597c51515e2c125be2fa2cd57 Mon Sep 17 00:00:00 2001 From: Florian Engelhardt Date: Wed, 3 Sep 2025 07:28:05 +0200 Subject: [PATCH 7/9] moved opcode fixing to dce --- Zend/Optimizer/block_pass.c | 47 ------------------------------------- Zend/Optimizer/dce.c | 7 ++++-- Zend/Optimizer/pass1.c | 1 - 3 files changed, 5 insertions(+), 50 deletions(-) diff --git a/Zend/Optimizer/block_pass.c b/Zend/Optimizer/block_pass.c index 603bc4d6989ec..f93f678fe9854 100644 --- a/Zend/Optimizer/block_pass.c +++ b/Zend/Optimizer/block_pass.c @@ -431,53 +431,6 @@ static void zend_optimize_block(zend_basic_block *block, zend_op_array *op_array case ZEND_CASE: case ZEND_CASE_STRICT: case ZEND_COPY_TMP: - /* Check for printf optimization from `zend_compile_func_printf()` - * where the result of `printf()` is actually unused and remove the - * superflous COPY_TMP, STRLEN and FREE opcodes: - * T1 = COPY_TMP T0 - * ECHO T0 - * T2 = STRLEN T1 - * FREE T2 - */ - if (opline->op1_type == IS_TMP_VAR && - opline + 1 < end && (opline + 1)->opcode == ZEND_ECHO && - opline + 2 < end && (opline + 2)->opcode == ZEND_STRLEN && - opline + 3 < end && (opline + 3)->opcode == ZEND_FREE) { - - zend_op *echo_op = opline + 1; - zend_op *strlen_op = opline + 2; - zend_op *free_op = opline + 3; - - /* Verify the pattern: - * - ECHO uses the same source as COPY_TMP - * - STRLEN uses the result of COPY_TMP - * - FREE uses the result of STRLEN - */ - if (echo_op->op1_type == IS_TMP_VAR && - echo_op->op1.var == opline->op1.var && - strlen_op->op1_type == IS_TMP_VAR && - strlen_op->op1.var == opline->result.var && - free_op->op1_type == IS_TMP_VAR && - free_op->op1.var == strlen_op->result.var) { - - /* Remove COPY_TMP, STRLEN, and FREE */ - MAKE_NOP(opline); - MAKE_NOP(strlen_op); - MAKE_NOP(free_op); - - /* Update source tracking */ - if (opline->result_type == IS_TMP_VAR) { - VAR_SOURCE(opline->result) = NULL; - } - if (strlen_op->result_type == IS_TMP_VAR) { - VAR_SOURCE(strlen_op->result) = NULL; - } - - ++(*opt_count); - break; - } - } - if (opline->op1_type & (IS_TMP_VAR|IS_VAR)) { /* Variable will be deleted later by FREE, so we can't optimize it */ Tsource[VAR_NUM(opline->op1.var)] = NULL; diff --git a/Zend/Optimizer/dce.c b/Zend/Optimizer/dce.c index a00fd8bc6ad30..6868d9cccbbd0 100644 --- a/Zend/Optimizer/dce.c +++ b/Zend/Optimizer/dce.c @@ -124,6 +124,7 @@ static inline bool may_have_side_effects( case ZEND_FUNC_NUM_ARGS: case ZEND_FUNC_GET_ARGS: case ZEND_ARRAY_KEY_EXISTS: + case ZEND_COPY_TMP: /* No side effects */ return 0; case ZEND_FREE: @@ -425,10 +426,12 @@ static bool dce_instr(context *ctx, zend_op *opline, zend_ssa_op *ssa_op) { return 0; } - if ((opline->op1_type & (IS_VAR|IS_TMP_VAR))&& !is_var_dead(ctx, ssa_op->op1_use)) { + if ((opline->op1_type & (IS_VAR|IS_TMP_VAR)) && !is_var_dead(ctx, ssa_op->op1_use)) { if (!try_remove_var_def(ctx, ssa_op->op1_use, ssa_op->op1_use_chain, opline)) { if (may_be_refcounted(ssa->var_info[ssa_op->op1_use].type) - && opline->opcode != ZEND_CASE && opline->opcode != ZEND_CASE_STRICT) { + && opline->opcode != ZEND_CASE + && opline->opcode != ZEND_CASE_STRICT + && opline->opcode != ZEND_COPY_TMP) { free_var = ssa_op->op1_use; free_var_type = opline->op1_type; } diff --git a/Zend/Optimizer/pass1.c b/Zend/Optimizer/pass1.c index b424f0756a73b..6911578feb33a 100644 --- a/Zend/Optimizer/pass1.c +++ b/Zend/Optimizer/pass1.c @@ -33,7 +33,6 @@ #include "zend_constants.h" #include "zend_execute.h" #include "zend_vm.h" -#include "zend_vm_opcodes.h" #define TO_STRING_NOWARN(val) do { \ if (Z_TYPE_P(val) < IS_ARRAY) { \ From 2e9cb21fefcfcb1958ea34cd8cae6bc08ab57d71 Mon Sep 17 00:00:00 2001 From: Florian Engelhardt Date: Wed, 3 Sep 2025 14:16:02 +0200 Subject: [PATCH 8/9] fix comment (cleanup moved from block pass to dce) --- Zend/zend_compile.c | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index 65b0c36299270..ea466239e79e6 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -4988,10 +4988,12 @@ static zend_result zend_compile_func_printf(znode *result, zend_ast_list *args) return FAILURE; } - /* printf() returns the amount of bytes written, so just an ECHO of the resulting sprintf() - * optimisation might not be enough. At this early stage we can't detect if the result is - * actually used, so we just emit the opcodes and cleanup if they are not used in the - * optimizers block pass later. */ + /* printf() returns the amount of bytes written, so just an ECHO of the + * resulting sprintf() optimisation might not be enough. At this early + * stage we can't detect if the result is actually used, so we just emit + * the opcodes and let them be cleaned up by the dead code elimination + * pass in the Zend Optimizer if the result of the printf() is in fact + * unused */ znode copy; zend_emit_op_tmp(©, ZEND_COPY_TMP, &rope_result, NULL); zend_emit_op(NULL, ZEND_ECHO, &rope_result, NULL); From a3e82139c524f29ac5c6c64643f30cad1f76ffe9 Mon Sep 17 00:00:00 2001 From: Florian Engelhardt Date: Wed, 3 Sep 2025 15:46:19 +0200 Subject: [PATCH 9/9] cleanup --- Zend/Optimizer/block_pass.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Zend/Optimizer/block_pass.c b/Zend/Optimizer/block_pass.c index f93f678fe9854..275c519d431be 100644 --- a/Zend/Optimizer/block_pass.c +++ b/Zend/Optimizer/block_pass.c @@ -538,7 +538,7 @@ static void zend_optimize_block(zend_basic_block *block, zend_op_array *op_array } } break; - + case ZEND_BOOL: case ZEND_BOOL_NOT: optimize_bool: