PHP, ¿cuánta abstracción para la gente?


Alegría: ¿Qué está pasando?
Tristeza: ¡Estamos abstrayendo! Hay cuatro etapas. Este es el primero. ¡Fragmentación no objetiva!
Bing Bong: Muy bien, no entres en pánico. Lo importante es que todos estemos juntos. [de repente su brazo abstracto se cae]
Alegría: ¡Oh! [La tristeza y la alegría comienzan a desmoronarse también]
Tristeza: estamos en la segunda etapa. Estamos deconstruyendo! [como Bing Bong se cae a pedazos]
Bing Bong: ¡No puedo sentir mis piernas! [levanta una pierna] Oh, ahí están.
© Cartoon Inside Out

A todos les encanta escribir código hermoso. A abstracciones, lambdas, SÓLIDAS, SECAS, DI, etc. etc. En este artículo, quiero explorar cuánto cuesta en términos de rendimiento y por qué.

Para hacer esto, tome una tarea simple que esté divorciada de la realidad y que gradualmente le aportará belleza, midiendo el rendimiento y mirando debajo del capó.

Descargo de responsabilidad: este artículo no debe interpretarse de ninguna manera como una llamada para escribir un código incorrecto. Es mejor si sintoniza con anticipación para decir después de leer "¡Genial! Ahora sé cómo es por dentro. Pero, por supuesto, no lo usaré ". :)

Desafío:

  1. Dan archivo de texto.
  2. Lo dividimos en líneas.
  3. Recorta los espacios a izquierda y derecha
  4. Deseche todas las líneas en blanco.
  5. Reemplazamos todos los espacios que no sean individuales con espacios individuales ("ABC" -> "ABC").
  6. Las líneas con más de 10 palabras, de acuerdo con las palabras, se vuelven hacia atrás ("An Bn Cn" -> "Cn Bn An").
  7. Contamos cuántas veces ocurre cada fila.
  8. Imprima todas las líneas que ocurren más de N veces.

Como un archivo de entrada, por tradición tomamos php-src / Zend / zend_vm_execute.h por ~ 70 mil líneas.

Como tiempo de ejecución, tome PHP 7.3.6.
Veamos los códigos de operación compilados aquí https://3v4l.org .

Las mediciones se realizarán de la siguiente manera:

//     $start = microtime(true); ob_start(); for ($i = 0; $i < 10; $i++) { //    } ob_clean(); echo "Time: " . (microtime(true) - $start) / 10; 

Primer acercamiento ingenuo


Escribamos un código imperativo simple:

 $array = explode("\n", file_get_contents('/Users/rjhdby/CLionProjects/php-src/Zend/zend_vm_execute.h')); $cache = []; foreach ($array as $row) { if (empty($row)) continue; $words = preg_split("/\s+/", trim($row)); if (count($words) > 10) { $words = array_reverse($words); } $row = implode(" ", $words); if (isset($cache[$row])) { $cache[$row]++; } else { $cache[$row] = 1; } } foreach ($cache as $key => $value) { if ($value > 1000) { echo "$key : $value" . PHP_EOL; } } 

Tiempo de ejecución ~ 0.148 s.

Todo es simple y no hay nada de qué hablar.

El segundo enfoque, procesal


Refactorizamos nuestro código y sacamos las acciones elementales en la función.
Intentaremos adherirnos al principio de responsabilidad exclusiva.

Paño debajo del alerón.
 function getContentFromFile(string $fileName): array { return explode("\n", file_get_contents($fileName)); } function reverseWordsIfNeeded(array &$input) { if (count($input) > 10) { $input = array_reverse($input); } } function prepareString(string $input): string { $words = preg_split("/\s+/", trim($input)); reverseWordsIfNeeded($words); return implode(" ", $words); } function printIfSuitable(array $input, int $threshold) { foreach ($input as $key => $value) { if ($value > $threshold) { echo "$key : $value" . PHP_EOL; } } } function addToCache(array &$cache, string $line) { if (isset($cache[$line])) { $cache[$line]++; } else { $cache[$line] = 1; } } function processContent(array $input): array { $cache = []; foreach ($input as $row) { if (empty($row)) continue; addToCache($cache, prepareString($row)); } return $cache; } printIfSuitable( processContent( getContentFromFile('/Users/rjhdby/CLionProjects/php-src/Zend/zend_vm_execute.h') ), 1000 ); 


Tiempo de ejecución ~ 0.275s ... ¿WTF? ¡La diferencia es casi 2 veces!

Veamos qué es una función PHP desde el punto de vista de una máquina virtual.

Código:

 $a = 1; $b = 2; $c = $a + $b; 

Compila a:

 line #* EIO op fetch ext return operands ------------------------------------------------------------------------------------- 2 0 E > ASSIGN !0, 1 3 1 ASSIGN !1, 2 4 2 ADD ~5 !0, !1 3 ASSIGN !2, ~5 

Pongamos la adición en una función:

 function sum($a, $b){ return $a + $b; } $a = 1; $b = 1; $c = sum($a, $b); 

Dicho código se compila en dos conjuntos de códigos de operación: uno para el espacio de nombres raíz y el segundo para la función.

Raíz:

 line #* EIO op fetch ext return operands ------------------------------------------------------------------------------------- 2 0 E > ASSIGN !0, 1 3 1 ASSIGN !1, 1 5 2 NOP 9 3 INIT_FCALL 'sum' 4 SEND_VAR !0 5 SEND_VAR !1 6 DO_FCALL 0 $5 7 ASSIGN !2, $5 

Función:

 line #* EIO op fetch ext return operands ------------------------------------------------------------------------------------- 5 0 E > RECV !0 1 RECV !1 6 2 ADD ~2 !0, !1 3 > RETURN ~2 

Es decir incluso si solo cuenta por códigos de operación, cada llamada de función agrega 3 + 2N códigos de operación, donde N es el número de argumentos pasados.

Y si profundizas un poco más, aquí también cambiamos el contexto de ejecución.

Una estimación aproximada de nuestro código refactorizado da tales números (recuerde unas 70,000 iteraciones).
El número de códigos de operación ejecutados "adicionales": ~ 17,000,000.
Número de cambios de contexto: ~ 280,000.

El tercer enfoque, clásico


Especialmente sin filosofar, envolvemos todas estas funciones con una clase.

Sábana debajo del alerón
 class ProcessFile { private $content; private $cache = []; function __construct(string $fileName) { $this->content = explode("\n", file_get_contents($fileName)); } private function reverseWordsIfNeeded(array &$input) { if (count($input) > 10) { $input = array_reverse($input); } } private function prepareString(string $input): string { $words = preg_split("/\s+/", trim($input)); $this->reverseWordsIfNeeded($words); return implode(" ", $words); } function printIfSuitable(int $threshold) { foreach ($this->cache as $key => $value) { if ($value > $threshold) { echo "$key : $value" . PHP_EOL; } } } private function addToCache(string $line) { if (isset($this->cache[$line])) { $this->cache[$line]++; } else { $this->cache[$line] = 1; } } function processContent() { foreach ($this->content as $row) { if (empty($row)) continue; $this->addToCache( $this->prepareString($row)); } } } $processFile = new ProcessFile('/Users/rjhdby/CLionProjects/php-src/Zend/zend_vm_execute.h'); $processFile->processContent(); $processFile->printIfSuitable(1000); 


Plazo de ejecución: 0.297. Se puso peor. No mucho, pero notable. ¿La creación de un objeto (10 veces en nuestro caso) es tan costosa? Nuuu ... No solo eso.

Veamos cómo funciona una máquina virtual con una clase.

 class Adder{ private $a; private $b; function __construct($a, $b) { $this->a = $a; $this->b = $b; } function sum(){ return $this->a + $this->b; } } $a = 1; $b = 1; $adder = new Adder($a, $b); $c = $adder->sum(); 

Habrá tres conjuntos de códigos de operación, lo cual es lógico: la raíz y dos métodos.

Raíz:

 line #* EIO op fetch ext return operands --------------------------------------------------------------------------- 2 0 E > NOP 16 1 ASSIGN !0, 1 17 2 ASSIGN !1, 1 18 3 NEW $7 :15 4 SEND_VAR_EX !0 5 SEND_VAR_EX !1 6 DO_FCALL 0 7 ASSIGN !2, $7 19 8 INIT_METHOD_CALL !2, 'sum' 9 DO_FCALL 0 $10 10 ASSIGN !3, $10 

Constructor:

 line #* EIO op fetch ext return operands --------------------------------------------------------------------------- 6 0 E > RECV !0 1 RECV !1 7 2 ASSIGN_OBJ 'a' 3 OP_DATA !0 8 4 ASSIGN_OBJ 'b' 5 OP_DATA !1 9 6 > RETURN null 

Método de suma :

 line #* EIO op fetch ext return operands --------------------------------------------------------------------------- 11 0 E > FETCH_OBJ_R ~0 'a' 1 FETCH_OBJ_R ~1 'b' 2 ADD ~2 ~0, ~1 3 > RETURN ~2 

La nueva palabra clave se convierte realmente en una llamada de función (líneas 3-6).
Crea una instancia de la clase y llama al constructor con los parámetros pasados.

En el código de métodos, nos interesará trabajar con campos de clase. Tenga en cuenta que si asigna un código de operación ASSIGN simple con variables ordinarias para la asignación, entonces, para los campos de clase, todo es algo diferente.

Asignación - 2 códigos de operación

  7 2 ASSIGN_OBJ 'a' 3 OP_DATA !0 

Leer - 1 código de operación

  1 FETCH_OBJ_R ~1 'b' 

Aquí debe saber que ASSIGN_OBJ y FETCH_OBJ_R son mucho más complicados y, en consecuencia, requieren más recursos que un simple ASSIGN , que, en términos generales, simplemente copia zval de una pieza de memoria a otra.

OpcodeEl número de líneas del manejador (código C)
ASSIGN_OBJ149
OP_DATA30
FETCH_OBJ_R112
ASIGNAR26

Está claro que tal comparación está muy lejos de ser correcta, pero aún da alguna idea. Un poco más adelante haré mediciones.

Ahora veamos lo caro que es crear una instancia de un objeto. Midamos en un millón de iteraciones:

 class ValueObject{ private $a; function __construct($a) { $this->a = $a; } } $start = microtime(true); for($i = 0; $i < 1000000; $i++){ // $a = $i; // $a = new ValueObject($i); } echo "Time: " . (microtime(true) - $start); 

Asignación variable: 0.092.
Objeto de instancia: 0.889.

Algo como esto No es completamente gratis, especialmente si muchas veces.

Bueno, para no levantarse dos veces, midamos la diferencia entre trabajar con propiedades y variables locales. Para hacer esto, cambie nuestro código de esta manera:

 class ValueObject{ private $b; function try($a) { //    // $this->b = $a; // $c = $this->b; //    // $b = $a; // $c = $b; return $c; } } $a = new ValueObject(); $start = microtime(true); for($i = 0; $i < 1000000; $i++){ $b = $a->try($i); } echo "Simple. Time: " . (microtime(true) - $start); 


Intercambio mediante asignación: 0.830.
Intercambio a través de propiedad: 0.862.

Solo un poco, pero por más tiempo. Justo el mismo orden de diferencia que obtuviste después de ajustar las funciones en una clase.

Conclusiones banales


  1. La próxima vez que desee crear una instancia de un millón de objetos, piense si realmente lo necesita. Tal vez solo una serie, ¿eh?
  2. Escribir un código de espagueti para ahorrar un milisegundo, bueno, eso. El escape es barato y los colegas pueden vencerlos más tarde.
  3. Pero en aras de ahorrar 500 milisegundos, tal vez a veces tenga sentido. Lo principal es no ir demasiado lejos y recordar que estos 500 milisegundos tienen más probabilidades de ser guardados solo por una pequeña sección de código muy activo, y no convertir todo el proyecto en un vacío de tristeza.

PD: sobre lambdas la próxima vez. Es interesante allí. :)

Source: https://habr.com/ru/post/468021/


All Articles