Nous préparons PHP. Comment sont while, foreach, array_walk et quelques autres mots effrayants



C'était le soir, il n'y avait rien à faire. Il est temps d'organiser une petite analyse de la façon dont certaines méthodes de tri des tableaux en PHP diffèrent en interne.

Sources de la branche maître (c'est maintenant 7.4 entrecoupé de 8)
Le générateur d'opcode de php 7.3.0.
Des mesures ont été effectuées le 7.3.6.

Avertissement pour les nerds: mentionner quelques nanosecondes et cycles de processeur est une astuce polémique appelée "hyperbole".

Peut-être, en fait, il y a des dizaines ou des centaines de nanosecondes et des milliers de mesures, mais il est encore si petit que la nécessité de les enregistrer suggère que quelque chose dans votre code est incorrect.

Étape de compilation


for , foreach , do et while sont des mots clés du langage, tandis que les fonctions array_ * sont des fonctions de la bibliothèque standard. Par conséquent, ceteris paribus, selon le premier, l'analyseur exécutera quelques nanosecondes plus rapidement.

Analyseur


Jusqu'à ce que le jeton d' instruction , le chemin sera le même pour tous

start -> top_statement_list -> top_statement -> statement 

Les boucles sont définies au niveau de l' instruction :
 ->T_FOR '(' for_exprs ';' for_exprs ';' for_exprs ')' for_statement ->T_FOREACH '(' expr T_AS foreach_variable ')' foreach_statement ->T_FOREACH '(' expr T_AS foreach_variable T_DOUBLE_ARROW foreach_variable ')' foreach_statement ->T_DO statement T_WHILE '(' expr ')' ';' ->T_WHILE '(' expr ')' while_statement 

La différence entre for_exprs et just expr est seulement que pour le premier, écrire plusieurs expr est autorisé, séparés par des virgules.

foreach_variable est une construction qui, en plus de la variable , suit également la décompression à l'aide de list ou [] .

for_statement , foreach_statement , while_statement diffèrent de l' instruction standard en ce qu'ils ajoutent la possibilité d'analyser une syntaxe alternative:

 foreach($array as $element): #do something endforeach; 

L'appel de fonction est enterré beaucoup plus profondément:

 -> expr -> variable -> callable_variable -> function_call -> name argument_list 

callable_variable , hmm ... Drôle, n'est-ce pas? :)

Passons aux opcodes.


À titre d'exemple, prenons une simple itération d'un tableau indexé avec impression de chaque clé et valeur. Il est clair que l'utilisation de for , while et do pour une telle tâche n'est pas justifiée, mais notre objectif est simplement de montrer le périphérique interne.

foreach


 $arr = ['a', 'b', 'c']; //        foreach($arr as $key => $value) echo $key.$value; 

 line #* EIO op fetch ext return operands --------------------------------------------------------------------------- 2 0 E > ASSIGN !0, <array> 4 1 > FE_RESET_R $4 !0, ->7 2 > > FE_FETCH_R ~5 $4, !1, ->7 3 > ASSIGN !2, ~5 4 CONCAT ~7 !2, !1 5 ECHO ~7 6 > JMP ->2 7 > FE_FREE $4 5 8 > RETURN 1 

Que se passe-t-il ici:


FE_RESET_R : crée un itérateur de 4 $ pour le tableau ! 0 . Ou, si le tableau est vide, il passe immédiatement à l'instruction 7 .
FE_FETCH_R : effectue une étape d'itération, récupère la clé actuelle à ~ 5 et la valeur à ! 1 . Ou, si la fin du tableau est atteinte, il passe à l'instruction 7 .
Les instructions 3-6 ne sont pas particulièrement intéressantes. Ici, il y a une conclusion et retour à FE_FETCH_R .
FE_FREE : détruit l'itérateur.

car pendant que ...


 $length = count($arr); for($i = 0; $i < $length; $i++) echo $i.$arr[$i]; 

Il s'agit en fait d'un cas particulier de

 $i = 0; while($i < $length) { echo $i.$arr[$i]; $i++; } 

Il s'agit en fait d'un cas spécial de if + goto

 $i = 0; goto check; body: echo $i.$arr[$i]; $i++; check: if($i < $length) goto body; 

Les opcodes pour les trois cas seront presque identiques. Sauf dans le cas de if , JMPNZ passera à une paire de JMPZ + JMP en raison de l'entrée dans le corps de if .
Pour la boucle do , les opcodes varieront légèrement en raison de leur nature post-vérification.

 line #* EIO op fetch ext return operands --------------------------------------------------------------------------- 3 0 E > ASSIGN !0, <array> 5 1 COUNT ~4 !0 2 ASSIGN !1, ~4 6 3 ASSIGN !2, 0 4 > JMP ->10 5 > FETCH_DIM_R ~7 !0, !2 6 CONCAT ~8 !2, ~7 7 ECHO ~8 8 POST_INC ~9 !2 9 FREE ~9 10 > IS_SMALLER ~10 !2, !1 11 > JMPNZ ~10, ->5 12 > > RETURN 1 

Comme prévu, il y a plus d'opcodes.
0-2 : calcul de la longueur $ .
3: $ i = 0
4, 10, 11 : contrôle initial de la condition de sortie et, en fait, soit la sortie soit la transition vers le corps du cycle.
5 (FETCH_DIM_R): $ arr [$ i]
6-7 : conclusion
8-9: $ i ++ (notez que l'opcode increment retourne dans tous les cas une valeur, même si elle n'est pas nécessaire. Par conséquent, une instruction FREE supplémentaire est nécessaire pour l'effacer).
10-11 : vérification de la condition de sortie après l'incrément.

Ou vous pouvez toujours répéter


 $value = reset($arr); $key = key($arr); while($key !== null) { echo $key.$value; $value = next($arr); $key = key($arr); } 

Drap de lit donc sous le spoiler
 line #* EIO op fetch ext return operands --------------------------------------------------------------------------- 3 0 E > ASSIGN !0, <array> 5 1 INIT_FCALL 'reset' 2 SEND_REF !0 3 DO_ICALL $4 4 ASSIGN !1, $4 6 5 INIT_FCALL 'key' 6 SEND_VAR !0 7 DO_ICALL $6 8 ASSIGN !2, $6 7 9 > JMP ->20 8 10 > CONCAT ~8 !2, !1 11 ECHO ~8 9 12 INIT_FCALL 'next' 13 SEND_REF !0 14 DO_ICALL $9 15 ASSIGN !1, $9 10 16 INIT_FCALL 'key' 17 SEND_VAR !0 18 DO_ICALL $11 19 ASSIGN !2, $11 7 20 > IS_NOT_IDENTICAL ~13 !2, null 21 > JMPNZ ~13, ->10 11 22 > > RETURN 1 


Cette option est bonne car elle convient pour itérer sur un tableau avec n'importe quelle clé, et pas seulement avec des entiers à augmentation monotone.

Les fonctions reset , next et key sont assez légères, mais il y a encore des frais généraux pour les appeler. Et, comme nous le verrons plus loin, ces coûts sont élevés.

Bien que cette approche soit très similaire au principe de foreach , il existe deux différences fondamentales entre elles.

1) Alors que reset , next et key (et current aussi) fonctionnent directement avec le pointeur interne d'un tableau, foreach utilise son propre itérateur et ne change pas l'état du pointeur interne.

C'est-à-dire

 foreach($arr as $v) echo $v.' - '.current($arr).PHP_EOL; //---------------- a - a b - a c - a 

2) Lorsque vous utilisez foreach pour itérer par valeur, quoi que vous fassiez avec le tableau à l'intérieur de la boucle, l'ensemble de données d'origine sera itéré

 foreach($arr as $v) { $arr = ['X','Y','Z']; echo $v.PHP_EOL; } var_dump($arr); //---------------- a b c array(3) { [0]=> string(1) "X" [1]=> string(1) "Y" [2]=> string(1) "Z" } 

Ce qui se passera lors de l'itération sur le lien peut être lu dans ce RFC . Tout n'y est pas très simple.

array_walk avec fonction anonyme


 array_walk($arr, function($value, $key){ echo $key.$value;}); 

Puisqu'une fonction personnalisée est utilisée, il y aura un ensemble supplémentaire d'opcodes.

Fonction


 line #* EIO op fetch ext return operands --------------------------------------------------------------------------- 0 E > RECV !0 1 RECV !1 2 CONCAT ~2 !1, !0 3 ECHO ~2 4 > RETURN null 

Code principal


 line #* EIO op fetch ext return operands --------------------------------------------------------------------------- 2 0 E > ASSIGN !0, <array> 4 1 INIT_FCALL 'array_walk' 2 SEND_REF !0 3 DECLARE_LAMBDA_FUNCTION '%00%7Bclosure%7D%2Fin%2FHuNEK0x7f9fa62d105a' 4 SEND_VAL ~2 5 DO_ICALL 6 > RETURN 1 

Comme array_walk , comme le reste des fonctions de la bibliothèque standard, est intrinsèque, il n'y a pas de mécanisme d'itération dans les opcodes compilés.

INIT_FCALL : initialisez l'appel à array_walk
SEND_REF : mettre une référence de tableau sur la pile d'appels
DECLARE_LAMBDA_FUNCTION : déclarer une fonction anonyme
SEND_VAL : Nous avons mis une fonction anonyme sur la pile des appels
DO_ICALL : exécutez array_walk pour exécuter

Ensuite, la magie se produit avec l'appel de notre lambda pour chaque élément du tableau.

array_walk à l' aide d'une fonction prédéfinie


Pas très différent d'appeler avec un anonyme, sauf peut-être un peu moins que la surcharge de création d'un lambda au moment de l'exécution.

 line #* EIO op fetch ext return operands --------------------------------------------------------------------------- 3 0 E > ASSIGN !0, <array> 9 2 INIT_FCALL 'array_walk' 3 SEND_REF !0 4 SEND_VAL 'my_echo' 5 DO_ICALL 6 > RETURN 1 

Les conclusions sont banales. foreach est conçu pour itérer les tableaux, tandis que le reste des boucles n'est qu'un wrapper sur if + goto .

Les fonctions de la bibliothèque standard fonctionnent sur le principe d'une boîte noire.

Plonger un peu plus profondément


Considérons d'abord le cas avec for et son opcode FETCH_DIM_R , qui est utilisé pour extraire la valeur par clé. L'élément est extrait via une recherche dans la table de hachage ( ZEND_HASH_INDEX_FIND ). Dans notre cas, l'extraction provient d'un tableau emballé (les clés sont une séquence numérique continue) - c'est une opération assez facile et rapide. Pour les baies non emballées, ce sera légèrement plus cher.

Passons maintenant à chacun ( FE_FETCH_R ). Tout est banal ici:

  1. Vérification si le pointeur de l'itérateur est en dehors du tableau
  2. Récupérer la clé et la valeur actuelles
  3. Pointeur d'incrément

Eh bien, qu'advient- il des fonctions comme array_walk ? Bien qu'ils fassent tous des choses différentes, ils ont tous un seul et même principe.

S'il est complètement simplifié, alors (pseudo-code):

 reset_pointer() do { value = get_current_value() if (value == NULL) break //  NULL,   zval  NULL key = get_current_key() call_function(myFunction, key, value) increment_pointer() } while(true) 

En fait, tout est plus compliqué à l'intérieur, mais l'essentiel est le même - il y a une recherche assez rapide de la table de hachage sans la participation de la machine virtuelle PHP (sans tenir compte de l'appel à la fonction utilisateur).

Eh bien, quelques mesures


Et puis après tout, quel est l'article sans mesures (de mémoire, il s'est avéré si égal qu'il a supprimé sa mesure).

En tant que tableau, selon la tradition, nous prenons zend_vm_execute.h pour 70,108 lignes.

Script de mesure du spoiler
 <?php $array = explode("\n", file_get_contents('/Users/rjhdby/CLionProjects/php-src/Zend/zend_vm_execute.h')); function myEcho($key, $value){ echo $key . $value; } ob_start(); $startTime = microtime(true); for ($i = 0; $i < 10; $i++) { // foreach ($array as $key => $value) { // echo $key . $value; // } // foreach ($array as $key => &$value) { // echo $key . $value; // } // $length = count($array); // for ($j = 0; $j < $length; $j++) { // echo $j . $array[$j]; // } // array_walk($array, function($value, $key) { // echo $key . $value; // }); // array_walk($array, static function($value, $key) { // echo $key . $value; // }); // array_walk($array, 'myEcho'); // $value = reset($array); // $key = key($array); // while($key !== null) { // echo $key.$value; // $value = next($array); // $key = key($array); // } } $endTime = microtime(true); ob_clean(); echo "time: " . ($endTime - $startTime); 


Chaque mesure a été exécutée 10 fois, en choisissant la plus fréquente sur les 4 premiers chiffres.

 foreach time: 0.12159085273743 foreach(reference) time: 0.11191201210022 for, while time: 0.13130807876587 array_walk(lambda) time: 0.87953400611877 array_walk(static lambda) time: 0.87544417497211 array_walk(name) time: 0.50753092765808 next,key time: 1.06258893013 

Pour résumer


En analysant les résultats, n'oubliez pas de prendre en compte qu'ils ont été obtenus en 10 passages à travers un tableau de 70 000 éléments.

L'anti-héros absolu était «l'émulation» de foreach avec la touche suivante / . Ne le faites que si cela est absolument nécessaire.

array_walk avec un lambda respire dans le dos, mais il y a une nuance. Le prochain JIT peut radicalement changer la situation. Ou peut-être ne pas changer. Ce sera intéressant à voir.
array_walk utilisant une fonction prête à l'emploi est un moyen fort.

Étant donné que foreach fonctionne un peu différemment lors de l'itération sur un lien (il utilise l'opcode FE_FETCH_RW au lieu de FE_FETCH_R ), il a fait une mesure distincte pour lui. Il s'est vraiment avéré un peu plus vite.

Il s'est avéré que la création d'un lambda à la volée n'est pas l'opération la moins chère. Il semblerait qu'il ne soit créé que 10 fois. Il faudra étudier.

Toutes les autres méthodes ont montré approximativement les mêmes résultats, avec un très léger écart.

Merci de votre attention!

Si vous avez des suggestions, que pouvez-vous "choisir" d'autre - écrivez dans les commentaires. Je pense toujours aux lambdas - une telle baisse des performances est très étrange.

UPD
Ajout de la mesure pour array_walk avec lambda statique. La différence n'est pas visible.

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


All Articles