PHP, combien d'abstraction pour les gens?


Joy: Que se passe-t-il?
Tristesse: nous abstenons! Il y a quatre étapes. Ceci est le premier. Fragmentation non objective!
Bing Bong: D'accord, pas de panique. Ce qui est important, c'est que nous restions tous ensemble. [tout à coup son bras abstrait tombe]
Joy: Oh! [La tristesse et la joie commencent aussi à s'effondrer]
Tristesse: nous en sommes à la deuxième étape. Nous déconstruisons! [Alors que Bing Bong tombe en morceaux]
Bing Bong: Je ne sens pas mes jambes! [prend une jambe en l'air] Oh, ils sont là.
© Cartoon Inside Out

Tout le monde aime écrire du beau code. Aux abstractions, lambdas, SOLIDES, SECS, DI, etc. etc. Dans cet article, je veux explorer combien cela coûte en termes de performances et pourquoi.

Pour ce faire, nous prenons une tâche simple, séparée de la réalité, et nous y apporterons progressivement de la beauté, en mesurant la performance et en regardant sous le capot.

Avertissement: Cet article ne doit en aucun cas être interprété comme un appel à écrire du mauvais code. Il est préférable de régler à l'avance pour dire après avoir lu «Cool! Maintenant je sais comment c'est à l'intérieur. Mais, bien sûr, je ne l'utiliserai pas. » :)

Défi:

  1. Fichier texte Dan.
  2. Nous le divisons en lignes.
  3. Coupez les espaces à gauche et à droite
  4. Jetez toutes les lignes vides.
  5. Nous remplaçons tous les espaces non simples par des espaces simples («ABC» -> «ABC»).
  6. Les lignes de plus de 10 mots, selon les mots, sont inversées ("An Bn Cn" -> "Cn Bn An").
  7. Nous comptons le nombre de fois où chaque ligne se produit.
  8. Imprimez toutes les lignes qui se produisent plus de N fois.

En tant que fichier d'entrée, par tradition, nous prenons php-src / Zend / zend_vm_execute.h pour environ 70 000 lignes.

En tant que runtime, prenez PHP 7.3.6.
Regardons les opcodes compilés ici https://3v4l.org .

Les mesures seront effectuées comme suit:

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

Première approche, naïve


Écrivons un simple code impératif:

 $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; } } 

Autonomie ~ 0,148 s.

Tout est simple et il n'y a rien à dire.

La deuxième approche, procédurale


Nous refactorisons notre code et supprimons les actions élémentaires de la fonction.
Nous essaierons d'adhérer au principe de la responsabilité exclusive.

Chausson sous le spoiler.
 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 ); 


Durée d'exécution ~ 0,275 s ... WTF!? La différence est presque 2 fois!

Voyons ce qu'est une fonction PHP du point de vue d'une machine virtuelle.

Code:

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

Compile pour:

 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 

Mettons l'ajout dans une fonction:

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

Ce code est compilé en deux ensembles d'opcodes: un pour l'espace de noms racine et le second pour la fonction.

Racine:

 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 

Fonction:

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

C'est-à-dire même si vous comptez simplement par opcodes, alors chaque appel de fonction ajoute 3 + 2N opcodes, où N est le nombre d'arguments passés.

Et si vous creusez un peu plus profondément, alors ici nous changeons également le contexte d'exécution.

Une estimation approximative de notre code refactorisé donne de tels nombres (rappelez-vous environ 70 000 itérations).
Le nombre d'opcodes exécutés "supplémentaires": ~ 17 000 000.
Nombre de changements de contexte: ~ 280 000.

La troisième approche, classique


Surtout sans philosopher, nous enveloppons toutes ces fonctions avec une classe.

Drap de lit sous le spoiler
 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); 


Délai: 0,297. Ça a empiré. Pas beaucoup, mais perceptible. La création d'un objet (10 fois dans notre cas) est-elle si chère? Nuuu ... Pas seulement ça.

Voyons comment une machine virtuelle fonctionne avec une classe.

 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(); 

Il y aura trois ensembles d'opcodes, ce qui est logique: la racine et deux méthodes.

Racine:

 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 

Constructeur:

 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éthode de somme :

 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 

Le nouveau mot-clé est en fait converti en appel de fonction (lignes 3-6).
Il crée une instance de la classe et appelle le constructeur avec les paramètres passés dessus.

Dans le code des méthodes, nous serons intéressés à travailler avec les champs de classe. Veuillez noter que si vous affectez un opcode ASSIGN simple avec des variables ordinaires pour l'affectation, alors pour les champs de classe, tout est quelque peu différent.

Affectation - 2 opcodes

  7 2 ASSIGN_OBJ 'a' 3 OP_DATA !0 

Lecture - 1 opcode

  1 FETCH_OBJ_R ~1 'b' 

Ici, vous devez savoir que ASSIGN_OBJ et FETCH_OBJ_R sont beaucoup plus compliqués et, par conséquent, plus gourmands en ressources qu'un simple ASSIGN , qui, en gros, copie simplement zval d'un morceau de mémoire à un autre.

OpcodeLe nombre de lignes du gestionnaire (code C)
ASSIGN_OBJ149
OP_DATA30
FETCH_OBJ_R112
ASSIGNER26

Il est clair qu'une telle comparaison est très loin d'être correcte, mais donne quand même une idée. Un peu plus loin je ferai des mesures.

Voyons maintenant combien il est coûteux d'instancier un objet. Mesurons sur un million d'itérations:

 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); 

Affectation variable: 0,092.
Objet d'instance: 0,889.

Quelque chose comme ça. Pas complètement gratuit, surtout si plusieurs fois.

Eh bien, pour ne pas se lever deux fois, mesurons la différence entre travailler avec des propriétés et des variables locales. Pour ce faire, modifiez notre code de cette façon:

 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); 


Échange par affectation: 0,830.
Échange par la propriété: 0,862.

Juste un peu, mais plus longtemps. Juste le même ordre de différence que vous avez obtenu après avoir encapsulé des fonctions dans une classe.

Conclusions banales


  1. La prochaine fois que vous souhaitez instancier un million d'objets, pensez à en avoir vraiment besoin. Peut-être juste un tableau, hein?
  2. Écrire un code de spaghetti pour économiser une milliseconde - enfin, ça. L'échappement est bon marché et les collègues peuvent les battre plus tard.
  3. Mais pour économiser 500 millisecondes, cela peut parfois avoir du sens. L'essentiel est de ne pas aller trop loin et de se rappeler que ces 500 millisecondes sont très susceptibles d'être enregistrées uniquement par une petite section de code très chaud, et de ne pas transformer l'ensemble du projet en un vide de chagrin.

PS À propos de lambdas la prochaine fois. C'est intéressant là-bas. :)

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


All Articles