Pourquoi le code Python s'exécute-t-il plus rapidement dans une fonction?


836
def main():
    for i in xrange(10**8):
        pass
main()

Ce morceau de code en Python s'exécute dans (Remarque: Le timing est fait avec la fonction de temps dans BASH sous Linux.)

real    0m1.841s
user    0m1.828s
sys     0m0.012s

Cependant, si la boucle for n'est pas placée dans une fonction,

for i in xrange(10**8):
    pass

puis il fonctionne beaucoup plus longtemps:

real    0m4.543s
user    0m4.524s
sys     0m0.012s

Pourquoi est-ce?


16
Comment avez-vous fait le timing?
Andrew Jaffe

53
Juste une intuition, je ne sais pas si c'est vrai: je suppose que c'est à cause des portées. Dans le cas de la fonction, une nouvelle portée est créée (c'est-à-dire une sorte de hachage avec des noms de variables liés à leur valeur). Sans fonction, les variables sont dans la portée globale, lorsque vous pouvez trouver beaucoup de choses, ralentissant ainsi la boucle.
Scharron

4
@Scharron Cela ne semble pas être le cas. Défini 200k variables factices dans la portée sans que cela affecte visiblement le temps d'exécution.
Deestan

2
Alex Martelli a écrit une bonne réponse concernant ce stackoverflow.com/a/1813167/174728
John La Rooy

53
@Scharron, vous avez à moitié raison. Il s'agit d'étendues, mais la raison pour laquelle c'est plus rapide dans les sections locales est que les étendues locales sont en fait implémentées sous forme de tableaux au lieu de dictionnaires (puisque leur taille est connue au moment de la compilation).
Katriel

Réponses:


532

Vous vous demandez peut-être pourquoi il est plus rapide de stocker des variables locales que des variables globales. Il s'agit d'un détail d'implémentation CPython.

N'oubliez pas que CPython est compilé en bytecode, que l'interpréteur exécute. Lorsqu'une fonction est compilée, les variables locales sont stockées dans un tableau de taille fixe ( pas a dict) et des noms de variables sont attribués aux index. Cela est possible car vous ne pouvez pas ajouter dynamiquement des variables locales à une fonction. Ensuite, récupérer une variable locale est littéralement une recherche de pointeur dans la liste et une augmentation de refcount sur ce PyObjectqui est trivial.

Comparez cela à une recherche globale ( LOAD_GLOBAL), qui est une véritable dictrecherche impliquant un hachage et ainsi de suite. Soit dit en passant, c'est pourquoi vous devez spécifier global isi vous voulez qu'elle soit globale: si vous attribuez jamais à une variable à l'intérieur d'une portée, le compilateur émettra des STORE_FASTs pour son accès, sauf si vous lui dites de ne pas le faire.

Soit dit en passant, les recherches globales sont encore assez optimisées. Les recherches d'attributs foo.barsont les plus lentes!

Voici une petite illustration de l'efficacité variable locale.


6
Cela s'applique également à PyPy, jusqu'à la version actuelle (1.8 au moment d'écrire ces lignes). Le code de test de l'OP s'exécute environ quatre fois plus lentement dans la portée globale par rapport à l'intérieur d'une fonction.
GDorn

4
@Walkerneo Ce n'est pas le cas, sauf si vous l'avez dit à l'envers. Selon ce que disent katrielalex et ecatmur, les recherches de variables globales sont plus lentes que les recherches de variables locales en raison de la méthode de stockage.
Jeremy Pridemore

2
@Walkerneo La conversation principale en cours ici est la comparaison entre les recherches de variables locales au sein d'une fonction et les recherches de variables globales qui sont définies au niveau du module. Si vous remarquez dans votre commentaire d'origine répondre à cette réponse, vous avez dit "Je n'aurais pas pensé que les recherches de variables globales étaient plus rapides que les recherches de propriétés de variables locales." et ils ne le sont pas. katrielalex a déclaré que, bien que les recherches de variables locales soient plus rapides que celles globales, même les recherches globales sont assez optimisées et plus rapides que les recherches d'attributs (qui sont différentes). Je n'ai pas assez de place dans ce commentaire pour en savoir plus.
Jeremy Pridemore

3
@Walkerneo foo.bar n'est pas un accès local. C'est un attribut d'un objet. (Pardonnez le manque de formatage) def foo_func: x = 5, xest local à une fonction. L'accès xest local. foo = SomeClass(), foo.barest l'accès aux attributs. val = 5global est global. Quant à l'attribut speed local> global> selon ce que j'ai lu ici. Ainsi , l' accès xà foo_funcest le plus rapide, suivi val, suivi foo.bar. foo.attrn'est pas une recherche locale car dans le contexte de ce convo, nous parlons de recherches locales comme une recherche d'une variable qui appartient à une fonction.
Jeremy Pridemore

3
@thedoctar a un aperçu de la globals()fonction. Si vous voulez plus d'informations que cela, vous devrez peut-être commencer à regarder le code source de Python. Et CPython n'est que le nom de l'implémentation habituelle de Python - vous l'utilisez probablement déjà!
Katriel

661

A l'intérieur d'une fonction, le bytecode est:

  2           0 SETUP_LOOP              20 (to 23)
              3 LOAD_GLOBAL              0 (xrange)
              6 LOAD_CONST               3 (100000000)
              9 CALL_FUNCTION            1
             12 GET_ITER            
        >>   13 FOR_ITER                 6 (to 22)
             16 STORE_FAST               0 (i)

  3          19 JUMP_ABSOLUTE           13
        >>   22 POP_BLOCK           
        >>   23 LOAD_CONST               0 (None)
             26 RETURN_VALUE        

Au niveau supérieur, le bytecode est:

  1           0 SETUP_LOOP              20 (to 23)
              3 LOAD_NAME                0 (xrange)
              6 LOAD_CONST               3 (100000000)
              9 CALL_FUNCTION            1
             12 GET_ITER            
        >>   13 FOR_ITER                 6 (to 22)
             16 STORE_NAME               1 (i)

  2          19 JUMP_ABSOLUTE           13
        >>   22 POP_BLOCK           
        >>   23 LOAD_CONST               2 (None)
             26 RETURN_VALUE        

La différence est que STORE_FASTc'est plus rapide (!) Que STORE_NAME. C'est parce que dans une fonction, ic'est un local mais au niveau supérieur c'est un global.

Pour examiner le bytecode, utilisez le dismodule . J'ai pu démonter la fonction directement, mais pour démonter le code de haut niveau, j'ai dû utiliser la fonction compileintégrée .


171
Confirmé par l'expérience. L'insertion global idans la mainfonction rend les temps d'exécution équivalents.
Deestan

44
Cela répond à la question sans répondre à la question :) Dans le cas des variables de fonction locales, CPython les stocke en fait dans un tuple (qui est modifiable à partir du code C) jusqu'à ce qu'un dictionnaire soit demandé (par exemple via locals(), ou inspect.getframe()etc.). La recherche d'un élément de tableau par un entier constant est beaucoup plus rapide que la recherche d'un dict.
dmw

3
C'est la même chose avec C / C ++ également, l'utilisation de variables globales provoque un ralentissement significatif
codejammer

3
C'est le premier que j'ai vu de bytecode. Comment le regarde-t-on et est important de savoir?
Zack

4
@gkimsey Je suis d'accord. Je voulais juste partager deux choses i) Ce comportement est noté dans d'autres langages de programmation ii) L'agent causal est plus le côté architectural et non le langage lui-même au vrai sens
codejammer

42

Mis à part les temps de stockage variables locaux / globaux, la prédiction d'opcode rend la fonction plus rapide.

Comme les autres réponses l'expliquent, la fonction utilise l' STORE_FASTopcode dans la boucle. Voici le bytecode pour la boucle de la fonction:

    >>   13 FOR_ITER                 6 (to 22)   # get next value from iterator
         16 STORE_FAST               0 (x)       # set local variable
         19 JUMP_ABSOLUTE           13           # back to FOR_ITER

Normalement, lorsqu'un programme est exécuté, Python exécute chaque opcode l'un après l'autre, en gardant une trace de la pile et en effectuant d'autres vérifications sur le cadre de la pile après l'exécution de chaque opcode. La prédiction d'opcode signifie que dans certains cas, Python est capable de passer directement à l'opcode suivant, évitant ainsi une partie de cette surcharge.

Dans ce cas, chaque fois que Python voit FOR_ITER(le haut de la boucle), il "prédira" que STORE_FASTc'est le prochain opcode qu'il devra exécuter. Python jette ensuite un œil à l'opcode suivant et, si la prédiction était correcte, il passe directement à STORE_FAST. Cela a pour effet de compresser les deux opcodes en un seul opcode.

En revanche, l' STORE_NAMEopcode est utilisé dans la boucle au niveau global. Python ne fait * pas * de prédictions similaires lorsqu'il voit cet opcode. Au lieu de cela, il doit remonter en haut de la boucle d'évaluation, ce qui a des implications évidentes pour la vitesse à laquelle la boucle est exécutée.

Pour donner plus de détails techniques sur cette optimisation, voici une citation du ceval.cfichier (le "moteur" de la machine virtuelle de Python):

Certains opcodes ont tendance à venir par paires, ce qui permet de prédire le second code lorsque le premier est exécuté. Par exemple, GET_ITERest souvent suivi de FOR_ITER. Et FOR_ITERest souvent suivi deSTORE_FAST ou UNPACK_SEQUENCE.

La vérification de la prédiction coûte un seul test à grande vitesse d'une variable de registre par rapport à une constante. Si le couplage était bon, alors la propre prédiction de branche interne du processeur a une forte probabilité de succès, ce qui entraîne une transition presque nulle pour le prochain opcode. Une prédiction réussie enregistre un voyage à travers la boucle d'évaluation, y compris ses deux branches imprévisibles, le HAS_ARGtest et le boîtier de commutation. Combiné avec la prédiction de branche interne du processeur, un succès PREDICTa pour effet de faire fonctionner les deux opcodes comme s'ils étaient un seul nouvel opcode avec les corps combinés.

Nous pouvons voir dans le code source de l' FOR_ITERopcode exactement où la prédiction STORE_FASTest faite:

case FOR_ITER:                         // the FOR_ITER opcode case
    v = TOP();
    x = (*v->ob_type->tp_iternext)(v); // x is the next value from iterator
    if (x != NULL) {                     
        PUSH(x);                       // put x on top of the stack
        PREDICT(STORE_FAST);           // predict STORE_FAST will follow - success!
        PREDICT(UNPACK_SEQUENCE);      // this and everything below is skipped
        continue;
    }
    // error-checking and more code for when the iterator ends normally                                     

La PREDICTfonction se développe pour if (*next_instr == op) goto PRED_##opdire que nous venons de sauter au début de l'opcode prédit. Dans ce cas, nous sautons ici:

PREDICTED_WITH_ARG(STORE_FAST);
case STORE_FAST:
    v = POP();                     // pop x back off the stack
    SETLOCAL(oparg, v);            // set it as the new local variable
    goto fast_next_opcode;

La variable locale est maintenant définie et l'opcode suivant est prêt à être exécuté. Python continue à travers l'itérable jusqu'à ce qu'il atteigne la fin, faisant la prédiction réussie à chaque fois.

La page wiki Python contient plus d'informations sur le fonctionnement de la machine virtuelle de CPython.


Mise à jour mineure: depuis CPython 3.6, les économies de prédiction diminuent un peu; au lieu de deux branches imprévisibles, il n'y en a qu'une. Le changement est dû au passage du bytecode au wordcode ; maintenant tous les "wordcodes" ont un argument, il est simplement mis à zéro lorsque l'instruction ne prend pas logiquement un argument. Ainsi, le HAS_ARGtest ne se produit jamais (sauf lorsque le traçage de bas niveau est activé à la fois lors de la compilation et de l'exécution, ce qui n'est pas le cas pour une construction normale), ne laissant qu'un seul saut imprévisible.
ShadowRanger

Même ce saut imprévisible ne se produit pas dans la plupart des versions de CPython, en raison du nouveau comportement gotos calculé (à partir de Python 3.1 , activé par défaut dans 3.2 ); lorsqu'elle est utilisée, la PREDICTmacro est complètement désactivée; à la place, la plupart des cas se terminent par un DISPATCHbranchement direct. Mais sur les CPU de prédiction de branche, l'effet est similaire à celui de PREDICT, puisque la branche (et la prédiction) est par opcode, augmentant les chances de prédiction de branche réussie.
ShadowRanger
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.