Parsing function calls in PHP

Original author: Julien Pauli
  • Transfer
This post is about optimizing PHP using the Blackfire profiler in a PHP script. The text below is a detailed technical explanation of the Blackfire Blog Article .

The strlen method is usually used:

if (strlen($name) > 49) {
...
}

However, this option is about 20% slower than this:

if (isset($name[49])) {
...
}

Looks good. Surely you are about to open your sources and replace all strlen () calls with isset () . But if you carefully read the original article , you can see that the reason for the 20 percent difference in performance is multiple calls to strlen () , of the order of 60-80 thousand iterations.

Why?


It is not a matter of how strlen () calculates the lengths of strings in PHP, because all of them are already known by the time this method is called. Whenever possible, most are calculated at compile time. The length of the PHP string sent to memory is encapsulated in a C structure containing this string. Therefore strlen () simply reads this information and returns as is. This is probably the fastest of the PHP functions, because it does not calculate anything at all. Here is its source code:

ZEND_FUNCTION(strlen)
{
    char *s1;
    int s1_len;
    if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "s", &s1, &s1_len) == FAILURE) {
        return;
    }
    RETVAL_LONG(s1_len);
}

Given that isset () is not a function, the reason for the 20 percent performance loss in strlen () is mostly due to the associated delays in calling the function in the Zend engine.

There is one more thing: when comparing the performance of strlen () with anything else, an additional opcode is added. And in the case of isset () , only one unique opcode is used.

An example of a disassembled if (strlen ()) structure :

line     #* I O op                           fetch          ext  return  operands
-----------------------------------------------------------------------------------
   3     0  >   SEND_VAR                                                 !0
         1      DO_FCALL                                      1  $0      'strlen'
         2      IS_SMALLER                                       ~1      42, $0
         3    > JMPZ                                                     ~1, ->5
   5     4  > > JMP                                                      ->5
   6     5  > > RETURN                                                   1

And here is the semantically equivalent structure of if (isset ()) :

line     #* I O op                           fetch          ext  return  operands
-----------------------------------------------------------------------------------
   3     0  >   ISSET_ISEMPTY_DIM_OBJ                       33554432  ~0      !0, 42
         1    > JMPZ                                                  ~0, ->3
   5     2  > > JMP                                                       ->3
   6     3  > > RETURN                                                     1

As you can see, the isset () code does not involve a call to any function (DO_FCALL). Also, there is no opcode IS_SMALLER (just ignore the RETURN statements); isset () directly returns a boolean value; strlen () first returns a temporary variable, then it is passed to opcode IS_SMALLER, and the final result is calculated using if () . That is , two opcode is used in the strlen () structure , and one is used in the isset () structure . Therefore, isset () demonstrates better performance, because one operation is usually faster than two.

Let's now see how function calls work in PHP and how they differ fromisset () .

PHP function calls


The most difficult part is to analyze the part of the virtual machine (the moment the PHP code is executed) that is associated with function calls. I will try to state the essence, without delving into the moments related to function calls.

To get started, let's analyze the runtime of calls. At compile time , a lot of resources are required to perform operations related to PHP functions. But if you use the opcode cache, then during compilation you will have no problems.

Suppose we have compiled a certain script. Let's analyze only what happens at runtime . Here's what the dump of the opcode call to the internal function (in this case strlen () looks like ):

strlen($a);
line     #* I O op                           fetch          ext  return  operands
-----------------------------------------------------------------------------------
3     0  >   SEND_VAR                                                 !0
      1      DO_FCALL                                      1          'strlen'

To understand the function call mechanism, you need to know two things:

  • function call and method call are the same thing
  • a user function call and an internal function call are handled differently

This is why the last example talks about calling the “internal” function: strlen () is a PHP function that is part of C code. If we dumped the opcode of a “user-defined” PHP function (that is, a function that is written in PHP), we could get either the exact same one or some other opcode.

The fact is that regardless of whether this function is known by PHP or not, it does not generate the same opcode at compile time. Obviously, internal PHP functions are known at compile time because they are declared before the compiler starts. But there may not be clarity regarding custom functions, because they may be called before they are declared. If we talk about execution, then internal PHP functions are more efficient than custom ones, moreover, they have more validation mechanisms available.

It can be seen from the above example that more than one opcode is used to control function calls. You also need to remember that functions have their own stack. In PHP, as in any other language, to call a function, you first need to create a stack frame and pass arguments to the function. Then you call a function that pulls these arguments from the stack for your needs. At the end of the call, you have to destroy the previously created frame.

This is how the scheme of working with function calls looks in general form. However, PHP provides for optimization of procedures for creating and deleting a stack frame; in addition, you can postpone their execution so that you do not have to do all these gestures with every function call.

Opcode SEND_VAR is responsible for sending arguments to the stack frame. The compiler without fail generates such an opcode before calling the function. Moreover, for each variable its own is created:

$a = '/';
setcookie('foo', 'bar', 128, $a);
line     #* I O op                           fetch          ext  return  operands
-----------------------------------------------------------------------------------
   3     0  >   ASSIGN                                                   !0, '%2F'
   4     1      SEND_VAL                                                 'foo'
         2      SEND_VAL                                                 'bar'
         3      SEND_VAL                                                 128
         4      SEND_VAR                                                 !0
         5      DO_FCALL                                      4          'setcookie'

Here you see another opcode - SEND_VAL. In total, there are 4 types of opcode for sending something to the function stack:

  • SEND_VAL : sends a constant value (string, integer, etc.)
  • SEND_VAR : sends a PHP variable ($ a)
  • SEND_REF : sends a PHP variable as a link to a function that takes an argument with a link
  • SEND_VAR_NO_REF : optimized handler used in cases with nested functions


What does SEND_VAR do?

ZEND_VM_HELPER(zend_send_by_var_helper, VAR|CV, ANY)
{
    USE_OPLINE
    zval *varptr;
    zend_free_op free_op1;
    varptr = GET_OP1_ZVAL_PTR(BP_VAR_R);
    if (varptr == &EG(uninitialized_zval)) {
        ALLOC_ZVAL(varptr);
        INIT_ZVAL(*varptr);
        Z_SET_REFCOUNT_P(varptr, 0);
    } else if (PZVAL_IS_REF(varptr)) {
        zval *original_var = varptr;
        ALLOC_ZVAL(varptr);
        ZVAL_COPY_VALUE(varptr, original_var);
        Z_UNSET_ISREF_P(varptr);
        Z_SET_REFCOUNT_P(varptr, 0);
        zval_copy_ctor(varptr);
    }
    Z_ADDREF_P(varptr);
    zend_vm_stack_push(varptr TSRMLS_CC);
    FREE_OP1();  /* for string offsets */
    CHECK_EXCEPTION();
    ZEND_VM_NEXT_OPCODE();
}

SEND_VAR checks if the variable is a reference. If so, then it separates it, thereby creating a link mismatch. Why this is very bad, you can read in another article of mine . Then SEND_VAR adds the number of links to it (the link here is not a link in terms of PHP, that is, not that &, but just an indicator of how many use this value) to the variable and sends it to the virtual machine stack:

Z_ADDREF_P(varptr);
zend_vm_stack_push(varptr TSRMLS_CC);

Each time you call a function, you increment by one the refcount of each variable argument to the stack. This is because the variable will be referenced not by the function code, but by its stack. Sending a variable to the stack has little effect on performance, but the stack takes up memory. It is placed in it at runtime, but its size is calculated at compile time. After we sent the variable to the stack, run DO_FCALL. Below is an example of how much code and checks are used only so that we consider calls to PHP functions as “slow statements”:

ZEND_VM_HANDLER(60, ZEND_DO_FCALL, CONST, ANY)
{
    USE_OPLINE
    zend_free_op free_op1;
    zval *fname = GET_OP1_ZVAL_PTR(BP_VAR_R);
    call_slot *call = EX(call_slots) + opline->op2.num;
    if (CACHED_PTR(opline->op1.literal->cache_slot)) {
        EX(function_state).function = CACHED_PTR(opline->op1.literal->cache_slot);
    } else if (UNEXPECTED(zend_hash_quick_find(EG(function_table), Z_STRVAL_P(fname), Z_STRLEN_P(fname)+1, Z_HASH_P(fname), (void **) &EX(function_state).function)==FAILURE)) {
        SAVE_OPLINE();
        zend_error_noreturn(E_ERROR, "Call to undefined function %s()", fname->value.str.val);
    } else {
        CACHE_PTR(opline->op1.literal->cache_slot, EX(function_state).function);
    }
    call->fbc = EX(function_state).function;
    call->object = NULL;
    call->called_scope = NULL;
    call->is_ctor_call = 0;
    EX(call) = call;
    FREE_OP1();
    ZEND_VM_DISPATCH_TO_HELPER(zend_do_fcall_common_helper);
}

As you can see, small checks were made here and various caches were used. For example, the handler pointer found the very first call, and then was cached into the main frame of the virtual machine so that each subsequent call could use this pointer.

Next, we call zend_do_fcall_common_helper () . I will not post the code of this function here, it is too voluminous. I will show only those operations that were performed there. In short, these are many different checks made during execution. PHP is a dynamic language, at runtime it can declare new functions and classes, simultaneously automatically uploading files. Therefore, PHP is forced to perform many checks at runtime, which adversely affects performance. But there’s no getting around it.

if (UNEXPECTED((fbc->common.fn_flags & (ZEND_ACC_ABSTRACT|ZEND_ACC_DEPRECATED)) != 0)) {
        if (UNEXPECTED((fbc->common.fn_flags & ZEND_ACC_ABSTRACT) != 0)) {
            zend_error_noreturn(E_ERROR, "Cannot call abstract method %s::%s()", fbc->common.scope->name, fbc->common.function_name);
            CHECK_EXCEPTION();
            ZEND_VM_NEXT_OPCODE(); /* Never reached */
        }
        if (UNEXPECTED((fbc->common.fn_flags & ZEND_ACC_DEPRECATED) != 0)) {
            zend_error(E_DEPRECATED, "Function %s%s%s() is deprecated",
                fbc->common.scope ? fbc->common.scope->name : "",
                fbc->common.scope ? "::" : "",
                fbc->common.function_name);
        }
    }
    if (fbc->common.scope &&
        !(fbc->common.fn_flags & ZEND_ACC_STATIC) &&
        !EX(object)) {
        if (fbc->common.fn_flags & ZEND_ACC_ALLOW_STATIC) {
            /* FIXME: output identifiers properly */
            zend_error(E_STRICT, "Non-static method %s::%s() should not be called statically", fbc->common.scope->name, fbc->common.function_name);
        } else {
            /* FIXME: output identifiers properly */
            /* An internal function assumes $this is present and won't check that. So PHP would crash by allowing the call. */
            zend_error_noreturn(E_ERROR, "Non-static method %s::%s() cannot be called statically", fbc->common.scope->name, fbc->common.function_name);
        }
    }

See how many checks? Move on:

if (fbc->type == ZEND_USER_FUNCTION || fbc->common.scope) {
    should_change_scope = 1;
    EX(current_this) = EG(This);
    EX(current_scope) = EG(scope);
    EX(current_called_scope) = EG(called_scope);
    EG(This) = EX(object);
    EG(scope) = (fbc->type == ZEND_USER_FUNCTION || !EX(object)) ? fbc->common.scope : NULL;
    EG(called_scope) = EX(call)->called_scope;
}

You know that each body of a function has its own scope for a variable. The engine switches the visibility tables before calling the function code, so if it requests a variable, it will be found in the corresponding table. And since functions and methods are essentially the same thing, you can read about how to bind the $ this pointer to a method .

if (fbc->type == ZEND_INTERNAL_FUNCTION) {

As I said above, internal functions (which are part of C) have a different execution path, not the same as user-defined functions. Usually it is shorter and better optimized, because we can tell the engine information about internal functions, which cannot be said about user-defined ones.

fbc->internal_function.handler(opline->extended_value, ret->var.ptr, (fbc->common.fn_flags & ZEND_ACC_RETURN_REFERENCE) ? &ret->var.ptr : NULL, EX(object), RETURN_VALUE_USED(opline) TSRMLS_CC);

The above line calls the internal function handler. In the case of our example regarding strlen (), this line will call the code:

/* PHP's strlen() source code */
ZEND_FUNCTION(strlen)
{
    char *s1;
    int s1_len;
    if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "s", &s1, &s1_len) == FAILURE) {
        return;
    }
    RETVAL_LONG(s1_len);
}

What does strlen () do? It retrieves an argument from the stack using zend_parse_parameters () . This is a “slow” function, because it has to raise the stack and convert the argument to the type expected by the function (in our case, a string). Therefore, regardless of what you pass to the stack for strlen () , it may need to convert the argument, and this is not the easiest process in terms of performance. The source code zend_parse_parameters () gives a good idea of ​​how many operations the processor has to perform while retrieving the arguments from the stack frame of the function.

Go to the next step. We just executed the function body code, now we need to “tidy up”. Let's start by restoring the scope:

if (should_change_scope) {
        if (EG(This)) {
            if (UNEXPECTED(EG(exception) != NULL) && EX(call)->is_ctor_call) {
                if (EX(call)->is_ctor_result_used) {
                    Z_DELREF_P(EG(This));
                }
                if (Z_REFCOUNT_P(EG(This)) == 1) {
                    zend_object_store_ctor_failed(EG(This) TSRMLS_CC);
                }
            }
            zval_ptr_dtor(&EG(This));
        }
        EG(This) = EX(current_this);
        EG(scope) = EX(current_scope);
        EG(called_scope) = EX(current_called_scope);
    }

Then clear the stack:

zend_vm_stack_clear_multiple(1 TSRMLS_CC);

And finally, if during the execution of the function some exceptions were made, you need to direct the virtual machine to the catch block of this exception:

if (UNEXPECTED(EG(exception) != NULL)) {
        zend_throw_exception_internal(NULL TSRMLS_CC);
        if (RETURN_VALUE_USED(opline) && EX_T(opline->result.var).var.ptr) {
            zval_ptr_dtor(&EX_T(opline->result.var).var.ptr);
        }
        HANDLE_EXCEPTION();
    }

About PHP function calls


Now you can imagine how much time the computer spends on calling the "very small and simple" strlen () function . And since it is called multiple times, increase this time, say, by 25,000 times. This is how micro- and milliseconds turn into full seconds ... Please note that I demonstrated only the most important instructions for us during each call to the PHP function. After that, much more interesting things happen. Also keep in mind that in the case of strlen () , only one line performs “useful work”, and the accompanying procedures for preparing a function call in volume are larger than the “useful” part of the code. However, in most cases, the native function code still affects performance more than the "auxiliary" engine code.

The part of PHP code that relates to function calls in PHP 7 has been redesigned to improve performance. However, this is far from the end, and the PHP source code will be optimized more than once with each new release. Older versions were not forgotten, function calls were optimized in versions from 5.3 to 5.5. For example, in versions from 5.4 to 5.5, the method for calculating and creating a stack frame (while maintaining compatibility) was changed. For the sake of interest, you can compare the changes in the runtime module and the method of calling functions made in version 5.5 compared to 5.4.

I want to emphasize: all of the above does not mean that PHP is bad. This language has been developing for 20 years, many very talented programmers have worked on its source code. Over this period, it has been processed many times, optimized and improved. Proof of this is the fact that you use PHP today and it demonstrates good overall performance in a variety of projects.

What about isset ()?


This is not a function; parentheses do not necessarily mean “function call”. isset () is included in the special opcode of the Zend virtual machine (ISSET_ISEMPTY), which does not initiate a function call and is not subject to the associated delays. Since isset () can use several types of parameters, its code in the Zend virtual machine is quite long. But if you leave only the part related to the offset parameter, you get something like this:

ZEND_VM_HELPER_EX(zend_isset_isempty_dim_prop_obj_handler, VAR|UNUSED|CV, CONST|TMP|VAR|CV, int prop_dim)
{
    USE_OPLINE zend_free_op free_op1, free_op2; zval *container; zval **value = NULL; int result = 0; ulong hval; zval *offset;
    SAVE_OPLINE();
    container = GET_OP1_OBJ_ZVAL_PTR(BP_VAR_IS);
    offset = GET_OP2_ZVAL_PTR(BP_VAR_R);
    /* ... code pruned ... */
    } else if (Z_TYPE_P(container) == IS_STRING && !prop_dim) { /* string offsets */
        zval tmp;
        /* ... code pruned ... */
        if (Z_TYPE_P(offset) == IS_LONG) { /* we passed an integer as offset */
            if (opline->extended_value & ZEND_ISSET) {
                if (offset->value.lval >= 0 && offset->value.lval < Z_STRLEN_P(container)) {
                    result = 1;
                }
            } else /* if (opline->extended_value & ZEND_ISEMPTY) */ {
                if (offset->value.lval >= 0 && offset->value.lval < Z_STRLEN_P(container) && Z_STRVAL_P(container)[offset->value.lval] != '0') {
                    result = 1;
                }
            }
        }
        FREE_OP2();
    } else {
        FREE_OP2();
    }
    Z_TYPE(EX_T(opline->result.var).tmp_var) = IS_BOOL;
    if (opline->extended_value & ZEND_ISSET) {
        Z_LVAL(EX_T(opline->result.var).tmp_var) = result;
    } else {
        Z_LVAL(EX_T(opline->result.var).tmp_var) = !result;
    }
    FREE_OP1_VAR_PTR();
    CHECK_EXCEPTION();
    ZEND_VM_NEXT_OPCODE();
}

If you remove the numerous decision points ( if constructs ), then the "main" computational algorithm can be expressed as a line:

if (offset->value.lval >= 0 && offset->value.lval < Z_STRLEN_P(container))

If offset is greater than zero (you did not mean isset ($ a [-42]) ) and strictly less than the length of the string, the result will be assumed to be 1. Then the result of the operation will be Boolean TRUE. Do not worry about calculating the length, Z_STRLEN_P (container) does not calculate anything. Remember that PHP already knows the length of your string. Z_STRLEN_P (container) simply reads this value into memory, which consumes extremely little processor resources.

Now you understand why, in terms of using line offset, handling strlen () calls requires MUCH more computational resources than isset (). The latter is substantially "easier." Don't be afraid of a large number of conditional if statements; this is not the hardest part of C code. In addition, they can be optimized using the C-compiler. The handler code isset () does not look in hash tables, does not perform complex checks, does not assign a pointer to one of the stack frames in order to get it later. The code is much lighter than the general function call code, and it accesses memory much less often (this is the most important point). And if you loop the multiple execution of such a line, you can achieve a big performance improvement. Of course, the results of one iteration of strlen () and isset () will not differ much - by about 5 ms. But if you spend 50,000 iterations ...

Also note that isset ()and empty () are almost the same source code . In the case of a line offset, empty () will differ from isset () only by additional reading, if the first character of the line is not 0. Since the empty () and isset () codes are almost the same, empty () will show the same performance as isset () (given that both are used with the same parameters).

How OPCache May Help Us


In short, nothing.

OPCache optimizes code. You can read about this in the presentation . People often ask whether it is possible to add an optimization pass in which strlen () switches to isset () . No, It is Immpossible.

OPCache optimization passes are implemented in OPArray before it is placed in shared memory. This happens at compile time, not at runtime. How do we know at compile time that the variable passed to strlen () is a string? This is a known PHP problem, and it is partly solved with HHVM / Hack. If we would write our variables with strict typing in PHP, then during compiler passes, much more things could be optimized (as in a virtual machine). Since PHP is a dynamic language, almost nothing is known at compile time. OPCache can only optimize static things that were known at the time the compilation started. For example, this one:

if (strlen("foo") > 8) {
/* do domething */
} else {
/* do something else */
}

At compile time, it is known that the length of the string “foo” is no more than 8, so you can throw out all opcode if (), and leave only part with else from the if construct.

if (strlen($a) > 8) {
/* do domething */
} else {
/* do something else */
}

But what is $ a ? Does it even exist? Is this a string? By the time the optimizer passes, we still cannot answer all these questions - this is the task for the executing module of the virtual machine. At compile time, we process the abstract structure, and the type and amount of memory needed will be known at runtime.

OPCache optimizes many things, but due to the nature of PHP, it cannot optimize everything. At least not as much as in the Java or C. compiler. Alas, PHP will never be a strongly typed language. There are also occasional suggestions for introducing read-only hints into the declaration of class properties:

class Foo {
    public read-only $a = "foo";
}

If you do not touch functionality, such proposals make sense in terms of optimizing performance. When we compile a similar class, we know the value of $ a . We know that it will not change, so you can store it somewhere, use a cached pointer and optimize each iteration of accessing a similar variable when passing the compiler or OPCache. The main idea here is that the more information you can give the compiler about the type and use of your variables or functions, the more OPCache can optimize, the closer the result will be to what the processor needs.

Optimization Tips & Final Words


First of all, I recommend not to mindlessly change your code in those places where it seems really good to you. Profile and see the results. Thanks to profilers like Blackfire, you can immediately see the process of executing your script, since all irrelevant information that interferes with the essence is automatically discarded. You will understand where to start work. After all, your work costs money, and it also needs to be optimized. This is a good balance between the money you spend on optimizing the script and the amount you save by lowering the cost of maintaining the cloud.

Second tip: PHP is really fast, efficient, and reliable. There are not many possibilities for optimizing PHP scripts - for example, they are fewer than in lower-level languages ​​like C. Therefore, optimization efforts should be directed to loops without specific conditions for exiting them. If the profiler shows a script bottleneck, most likely it will be inside the loop. It is here that tiny delays accumulate in full seconds, since the number of iterations in cycles is measured in tens of thousands. In PHP, such loops are the same, except for foreach () , and lead to the same opcode. Changing while in them to for is pointless, and the profiler will prove it to you.

As for function calls, there are little tricks to prevent some calls from being made because the information is available elsewhere.

phpversion() => use the PHP_VERSION constant
php_uname() => use the PHP_OS constant
php_sapi_name() => use the PHP_SAPI constant
time() => read $_SERVER['REQUEST_TIME']
session_id() => use the SID constant

The examples given are not always completely equivalent; for details, refer to the documentation.

Yes, and try to avoid such nonsense as:

function foo() {
    bar();
}

Or worse:

function foo() {
    call_user_func_array('bar', func_get_args());
}

Always take a critical approach to optimization, do not do it just because you heard something somewhere or someone advised you. Do not subordinate the structure of your application to achieving maximum performance; rather, work on it within the individual capabilities of your brainchild.

Often profile your script and check each assumption, do not blindly follow the instructions of others. Always check everything.

The developers of the Blackfire profiler have put in their product a mechanism for collecting interesting metrics that can help users in their work. Many parameters are logged (although the GUI does not yet show everything): for example, when the garbage collector starts, what it does, how many objects are created / destroyed in functions, how many link inconsistencies were created during function calls, serialization time, incorrect foreach () behavior and etc. etc.

Also, do not forget that one day you will come across language restrictions. Perhaps then it will be advisable to choose some other. PHP is not suitable for creating ORMs, video games, HTTP servers, and many other tasks. It can be used for this, but it will be inefficient. So for each task it is better to choose the most suitable language, since today there is plenty to choose from: Java, Go or, probably, the most effective language of our time - C / C ++ (Java and Go are written specifically on it).

Also popular now: