¡Deja de usar i++ en tus bucles de PHP!

Publicado por Alejandro Escario en

En PHP es muy común el uso de los bucles for y foreach (casi más que los while y do-while) para realizar tareas sobre elementos iterables. El bucle foreach recorre los distintos elementos sin necesidad de contadores externos; sin embargo, los bucles for, while y do-while necesitan condiciones de salida y, a menudo, contadores.

Sustituir $i++ por ++$i es uno de los más de 20 consejos de optimización de códigos PHP que describimos en 2009. Si bien desde ese año, muchos lenguajes han hecho mejoras al respecto, como Java, PHP no ha tenido la misma suerte.

Con la actualización a PHP 7 se ha optimizado la velocidad de ejecución de los scripts gracias a un motor de ejecución renovado. No obstante, sigue siendo un código interpretado.

Los lenguajes compilados o transpilados, como Java, C o Rust permiten realizar optimizaciones automáticas durante el proceso de conversión a código máquina o bytecode. Sin embargo, en lenguajes de scripting, como PHP, esto no es así. Estos lenguajes no realizan varias pasadas de forma previa a la ejecución que permitan buscar patrones de optimización automática. Al menos no sin afectar gravemente al rendimiento.

Vale, es cierto, existen sistemas que permiten cachea el código, almacenarlo en memoria, etc. Con la finalidad de acelerar significativamente el tiempo de ejecución.

Teoría

Con la finalidad de entender los resultados, antes vamos a analizar detenidamente lo que sucede con cada una de las posibilidades básicas que tenemos para incrementar en 1 un valor.

Para hacer este análisis vamos a hacer uso de la herramienta phpdbg con el código

~$ phpdbg -p <archivo>

Phpdbg con el parámetro -p nos permite listar los opcodes de un script.

Post-incremento [$i++]

Para el código

<?php
$i = 0;
$i++;

Si analizamos el opcode generado

~$ phpdbg -p 001_additions_i++.php
function name: (null)
L1-3 {main}() ./20210718 PHP optimization/001_additions_i++.php - 0x10967e7e0 + 6 ops
 L2    #0     EXT_STMT                                                                              
 L2    #1     ASSIGN                  $i                   0                                        
 L3    #2     EXT_STMT                                                                              
 L3    #3     POST_INC                $i                                        ~1                  
 L3    #4     FREE                    ~1                                                            
 L3    #5     RETURN<-1>              1               
[Script ended normally]                                                            

Vemos como las líneas marcadas (#2-#4) son las que se corresponden con $i++:

  1. EX_STMT: la podemos obviar porque es la que ejecuta el statement.
  2. POST_INC: realiza un post_incremento. Este incremento debe almacenar el resultado para luego devolverlo.
  3. FREE: libera el valor una vez devuelto.

En este caso, el valor de $i++ devolvería, según la iteración:

  • Iteración 0 => 0
  • Iteración 1 => 1
  • Iteración 2 => 2
  • Iteración n => n

Pre-incremento [++$i]

<?php
$i = 0;
++$i;

De forma análoga al caso anterior, se incrementa el valor de la variable en 1, sin embargo, en este caso se realizan menos operaciones a bajo nivel, realizando el incremento antes de obtener el valor.

~$ phpdbg -p 001_additions_++i.php
function name: (null)
L1-3 {main}() ./20210718 PHP optimization/001_additions_i++.php - 0x10a08c000 + 5 ops
 L2    #0     EXT_STMT                                                                              
 L2    #1     ASSIGN                  $i                   0                                        
 L3    #2     EXT_STMT                                                                              
 L3    #3     PRE_INC                 $i                                                            
 L3    #4     RETURN<-1>              1                                                             
[Script ended normally]
  1. EX_STMT: la podemos obviar porque es la que ejecuta el statement.
  2. PRE_INC: realiza un pre_incremento. En este caso al no tener que almacenar el valor intermedio para devolverlo, no hay que liberarlo a posteriori.

De este modo, el código, para cada iteración, la instrucción ++$i devolverá:

  • Iteración 0 => 1
  • Iteración 1 => 2
  • Iteración 2 => 3
  • Iteración n => n+1

Es decir, se ahorran opcodes a bajo nivel con respecto al $i++, es decir, teóricamente debería de ser más rápido.

Suma [$i = $i + 1]

<?php
$i = 0;
$i = $i + 1;
~$ phpdbg -p 001_additions_i=i+1.php 
function name: (null)
L1-3 {main}() ./20210718 PHP optimization/001_additions_i=i+1.php - 0x10528b100 + 6 ops
 L2    #0     EXT_STMT                                                                              
 L2    #1     ASSIGN                  $i                   0                                        
 L3    #2     EXT_STMT                                                                              
 L3    #3     ADD                     $i                   1                    ~1                  
 L3    #4     ASSIGN                  $i                   ~1                                       
 L3    #5     RETURN<-1>              1                                                             
[Script ended normally]

Este bucle, el incremento realiza una suma de un entero, es decir, no es exactamente un incremento aunque el resultado sea el mismo. Pero obtiene un valor, suma un valor constante y, finalmente almacena el valor. En opcodes:

  1. EX_STMT: la podemos obviar porque es la que ejecuta el statement.
  2. ADD: ejecuta la adición de los dos valores
  3. ASSIGN: asigna el valor a $i

Suma acortada [$i += 1]

<?php
$i = 0;
$i += 1;
~$ phpdbg -p 001_additions_i+=1.php 
function name: (null)
L1-3 {main}() ./20210718 PHP optimization/001_additions_i+=1.php - 0x11227e7e0 + 5 ops
 L2    #0     EXT_STMT                                                                              
 L2    #1     ASSIGN                  $i                   0                                        
 L3    #2     EXT_STMT                                                                              
 L3    #3     ASSIGN_ADD              $i                   1                                        
 L3    #4     RETURN<-1>              1                                                             
[Script ended normally]

De forma similar al caso anterior se realiza una suma pero sin devolver el valor $i inicialmente (realiza una operación menos, reduciendo en varios opcodes cada iteración), por lo que debería de ser algo más rápido que la suma tradicional y que el post-incremento.

Vamos a analizarlo:

  1. EX_STMT: la podemos obviar porque es la que ejecuta el statement.
  2. ASSIGN_ADD: ejecuta la adición de los dos valores y la asigna en el mismo opcode.

Resultados teóricos

Según los opcodes analizados anteriormente, podríamos intentar definir las formas más y menos costosas de realizar estas operaciones. En este caso, según los opcode, claramente el ++$i sería la opción más rápida, mientras que $i = $i + 1 sería la más lenta.

Entre $i++ y $i+=1, podríamos decantarnos por que, a pesar de que añadir es más costoso que incrementar, nos ahorramos la tarea de liberar. Por ello el orden teórico según los opcode sería:

  • ++$i: 2op code
  • $i+=1: 2 opcode
  • $i++: 3 opcode
  • $i=$i+1: 3 opcode

Código para el análisis

Para ilustrar y analizar el caso del pre-incremento, post-incremento y dos tipos de operación suma, vamos a hacer un pequeño test que evalúe el rendimiento en PHP.

El script que vamos a utilizar para realizar las pruebas de rendimiento es el siguiente:

<?php
define('ITERATIONS', 1000000000);
define('TIMES', 100);
$result_array = [
    '$i++' => array(),
    '++$i' => array(),
    '$i+=1' => array(),
    '$i=$i+1' => array(),
];

for ($j = 0; $j < TIMES; $j++) {
    /**
     * i++ enlapsed time
     */
    $start = microtime(true);
    for ($i = 0; $i < ITERATIONS; $i++);
    $time_elapsed_secs = microtime(true) - $start;
    echo '$i++ (', ITERATIONS,' times) took ', $time_elapsed_secs, "s\n";
    $result_array['$i++'][] = $time_elapsed_secs;


    /**
     * ++i enlapsed time
     */
    $start = microtime(true);
    for ($i = 0; $i < ITERATIONS; ++$i);
    $time_elapsed_secs = microtime(true) - $start;
    echo '++$i (', ITERATIONS,' times) took ', $time_elapsed_secs, "s\n";
    $result_array['++$i'][] = $time_elapsed_secs;


    /**
     * $i+=1 enlapsed time
     */
    $start = microtime(true);
    for ($i = 0; $i < ITERATIONS; $i+=1);
    $time_elapsed_secs = microtime(true) - $start;
    echo '$i += 1 (', ITERATIONS,' times) took ', $time_elapsed_secs, "s\n";
    $result_array['$i+=1'][] = $time_elapsed_secs;

    /**
     * $i= $i+1 enlapsed time
     */
    $start = microtime(true);
    for ($i = 0; $i < ITERATIONS; $i=$i + 1);
    $time_elapsed_secs = microtime(true) - $start;
    echo '$i = $i + 1 (', ITERATIONS,' times) took ', $time_elapsed_secs, "s\n";
    $result_array['$i=$i+1'][] = $time_elapsed_secs;
}

$fp = fopen('001_additions.csv', 'w');

foreach ($result_array as $key => $row) {
    array_unshift($row, $key);
    fputcsv($fp, $row);
}

fclose($fp);

El código, si se analiza en detalle, veremos cómo ejecuta 100 veces cada una de las cuatro pruebas. En cada prueba ejecutará 1.000.000.000 (mil millones de veces) la operación suma (además de comparaciones y moverá el puntero de ejecución). Además, para cada una de las 100 iteraciones, ejecutaremos cuatro tipos de incremento distintas. El resultado de cada prueba se almacenará en una variable que se utilizará para generar finalmente un CSV que podremos analizar.

Resultados

La ejecución de una única iteración nos muestra los siguientes resultados:

$i++ (1000000000 times) took 13.006237030029s
++$i (1000000000 times) took 9.5504179000854s
$i += 1 (1000000000 times) took 11.834012031555s
$i = $i + 1 (1000000000 times) took 14.221917152405s

Estos resultados podrían verse afectados por las tareas que se están realizando en paralelo en el equipo, por lo que, para mejorar el análisis, se ejecutan 100 veces cada una de los bucles de forma intercalada, permitiendo reducir la variabilidad en los tiempos asociada a la carga del equipo en otras tareas.

La ejecución se realiza en un Macbook Pro de 15″ con 16GB de RAM y PHP 7.3.

Tras ejecutar 100 veces el test completo obtenemos los siguientes resultados:

CSV php increment times (263 descargas )

A menor valor, menor tiempo de ejecución y por lo tanto es una aproximación de ejecución más rápida.

Como puede observarse, los datos ejecutados 100 veces son consistentes con los de la primera ejecución que hemos mostrado más arriba y, si somos algo curiosos, podemos observar cómo ha ido variando la carga del equipo durante las horas que duró la prueba.

Además de ver una clara diferencia que es consistente, podemos ver como hay unas variaciones de hasta el 50% en el tiempo de ejecución haciendo uso de las diferentes sintaxis, siendo el pre-incremento la opción claramente ganadora y el incremento en formato suma tradicional el más costoso.

Conclusiones

Cada forma de incrementar un valor tiene sus ventajas e inconvenientes como hemos visto en este artículo porque cubren necesidades diferentes. No obstante, para bucles for hay un claro ganador: el pre-incremento ++$i.

En la inmensa mayoría de los casos, no notaremos mejoras de rendimiento significativas. Esto es así porque normalmente la carga de la ejecución de un bucle viene dada por el contenido del mismo. Es decir, el número de instrucciones a bajo nivel ejecutadas en el cuerpo del bucle suele ser muy superior al del cálculo del propio bucle.

En cualquier caso, esta optimización debería preocuparnos únicamente en el caso de ejecutar grandes tareas por lotes o contar con una gran cantidad de visitas en paralelo.

No obstante, y aunque sobre el código final a ejecutar pueda no tener un gran impacto de tiempo, todo suma; sobre todo si es cambiar una costumbre y poner el símbolo de suma antes de la variable en lugar de después.

Categorías: Programación

0 comentarios

Deja una respuesta

Marcador de posición del avatar

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *