«X <y <z» est-il plus rapide que «x <y et y <z»?

129

De cette page , nous savons que:

Les comparaisons chaînées sont plus rapides que l'utilisation de l' andopérateur. Écrivez x < y < zau lieu de x < y and y < z.

Cependant, j'ai obtenu un résultat différent en testant les extraits de code suivants:

$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.8" "x < y < z"
1000000 loops, best of 3: 0.322 usec per loop
$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.8" "x < y and y < z"
1000000 loops, best of 3: 0.22 usec per loop
$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.1" "x < y < z"
1000000 loops, best of 3: 0.279 usec per loop
$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.1" "x < y and y < z"
1000000 loops, best of 3: 0.215 usec per loop

Il semble que ce x < y and y < zsoit plus rapide que x < y < z. Pourquoi?

Après avoir recherché quelques articles sur ce site (comme celui-ci ), je sais que "évalué une seule fois" est la clé x < y < z, mais je suis toujours confus. Pour approfondir mes études, j'ai démonté ces deux fonctions en utilisant dis.dis:

import dis

def chained_compare():
        x = 1.2
        y = 1.3
        z = 1.1
        x < y < z

def and_compare():
        x = 1.2
        y = 1.3
        z = 1.1
        x < y and y < z

dis.dis(chained_compare)
dis.dis(and_compare)

Et le résultat est:

## chained_compare ##

  4           0 LOAD_CONST               1 (1.2)
              3 STORE_FAST               0 (x)

  5           6 LOAD_CONST               2 (1.3)
              9 STORE_FAST               1 (y)

  6          12 LOAD_CONST               3 (1.1)
             15 STORE_FAST               2 (z)

  7          18 LOAD_FAST                0 (x)
             21 LOAD_FAST                1 (y)
             24 DUP_TOP
             25 ROT_THREE
             26 COMPARE_OP               0 (<)
             29 JUMP_IF_FALSE_OR_POP    41
             32 LOAD_FAST                2 (z)
             35 COMPARE_OP               0 (<)
             38 JUMP_FORWARD             2 (to 43)
        >>   41 ROT_TWO
             42 POP_TOP
        >>   43 POP_TOP
             44 LOAD_CONST               0 (None)
             47 RETURN_VALUE

## and_compare ##

 10           0 LOAD_CONST               1 (1.2)
              3 STORE_FAST               0 (x)

 11           6 LOAD_CONST               2 (1.3)
              9 STORE_FAST               1 (y)

 12          12 LOAD_CONST               3 (1.1)
             15 STORE_FAST               2 (z)

 13          18 LOAD_FAST                0 (x)
             21 LOAD_FAST                1 (y)
             24 COMPARE_OP               0 (<)
             27 JUMP_IF_FALSE_OR_POP    39
             30 LOAD_FAST                1 (y)
             33 LOAD_FAST                2 (z)
             36 COMPARE_OP               0 (<)
        >>   39 POP_TOP
             40 LOAD_CONST               0 (None)

Il semble que le x < y and y < za moins de commandes dissimulées que x < y < z. Dois-je envisager x < y and y < zplus rapide que x < y < z?

Testé avec Python 2.7.6 sur un processeur Intel (R) Xeon (R) E5640 à 2,67 GHz.

zangw
la source
8
Plus de commandes désassemblées ne signifient pas plus de complexité ni un code plus lent. Cependant, en voyant vos timeittests, cela m'a intéressé.
Marco Bonelli
6
J'ai supposé que la différence de vitesse par rapport à «évalué une fois» venait quand il yn'y avait pas qu'une simple recherche de variable, mais un processus plus coûteux comme un appel de fonction? Ie 10 < max(range(100)) < 15est plus rapide que 10 < max(range(100)) and max(range(100)) < 15car max(range(100))est appelé une fois pour les deux comparaisons.
zehnpaard
2
@MarcoBonelli C'est le cas lorsque le code désassemblé 1) ne contient pas de boucles et 2) chaque bytecode est très très rapide, car à ce moment-là, la surcharge de la boucle principale devient significative.
Bakuriu
2
La prédiction de branche peut gâcher vos tests.
Corey Ogburn
2
@zehnpaard Je suis d'accord avec vous. Lorsque "y" est plus qu'une simple valeur (par exemple, un appel de fonction ou un calcul), je m'attendrais à ce que le fait que "y" soit évalué une fois dans x <y <z ait un impact beaucoup plus notable. Dans le cas présenté, nous sommes dans les barres d'erreur: les effets de la prédiction de branche (échouée), du bytecode moins qu'optimal et d'autres effets spécifiques à la plate-forme / au processeur dominent. Cela n'invalide pas la règle générale selon laquelle évaluer une fois est mieux (en plus d'être plus lisible), mais montre que cela peut ne pas ajouter autant de valeur aux extrêmes.
MartyMacGyver

Réponses:

111

La différence est que in x < y < z yn'est évalué qu'une seule fois. Cela ne fait pas une grande différence si y est une variable, mais c'est le cas lorsqu'il s'agit d'un appel de fonction, ce qui prend un certain temps à calculer.

from time import sleep
def y():
    sleep(.2)
    return 1.3
%timeit 1.2 < y() < 1.8
10 loops, best of 3: 203 ms per loop
%timeit 1.2 < y() and y() < 1.8
1 loops, best of 3: 405 ms per loop
Rob
la source
18
Bien sûr, il pourrait aussi y avoir une différence sémantique. Non seulement y () pourrait renvoyer deux valeurs différentes, mais avec une variable, l'évaluation de l'opérateur inférieur à dans x <y pourrait changer la valeur de y. C'est pourquoi il n'est souvent pas optimisé dans le code d'octet (si y est une variable non locale ou participe à une fermeture, par exemple)
Random832
Juste curieux, pourquoi avez-vous besoin d'un sleep()intérieur de la fonction?
Prof
@Prof C'est pour simuler une fonction qui prend un certain temps pour calculer le résultat. Si les fonctions reviennent tout de suite, il n'y aura pas beaucoup de différence entre les deux résultats timeit.
Rob
@Rob Pourquoi n'y aurait-il pas beaucoup de différence? Ce serait 3ms contre 205ms, cela le démontre assez bien n'est-ce pas?
Prof
@Prof Vous manquez le point où y () est calculé deux fois, donc c'est 2x200ms au lieu de 1x200ms. Le reste (3/5 ms) est un bruit non pertinent dans la mesure de la synchronisation.
Rob
22

Le bytecode optimal pour les deux fonctions que vous avez définies serait

          0 LOAD_CONST               0 (None)
          3 RETURN_VALUE

car le résultat de la comparaison n'est pas utilisé. Rendons la situation plus intéressante en renvoyant le résultat de la comparaison. Faisons également en sorte que le résultat ne soit pas connaissable au moment de la compilation.

def interesting_compare(y):
    x = 1.1
    z = 1.3
    return x < y < z  # or: x < y and y < z

Encore une fois, les deux versions de la comparaison sont sémantiquement identiques, de sorte que le bytecode optimal est le même pour les deux constructions. Du mieux que je peux y arriver, cela ressemblerait à ceci. J'ai annoté chaque ligne avec le contenu de la pile avant et après chaque opcode, en notation Forth (haut de la pile à droite, se --divise avant et après, la fin ?indique quelque chose qui pourrait ou non être là). Notez que RETURN_VALUEsupprime tout ce qui reste sur la pile sous la valeur renvoyée.

          0 LOAD_FAST                0 (y)    ;          -- y
          3 DUP_TOP                           ; y        -- y y
          4 LOAD_CONST               0 (1.1)  ; y y      -- y y 1.1
          7 COMPARE_OP               4 (>)    ; y y 1.1  -- y pred
         10 JUMP_IF_FALSE_OR_POP     19       ; y pred   -- y
         13 LOAD_CONST               1 (1.3)  ; y        -- y 1.3
         16 COMPARE_OP               0 (<)    ; y 1.3    -- pred
     >>  19 RETURN_VALUE                      ; y? pred  --

Si une implémentation du langage, CPython, PyPy, peu importe, ne génère pas ce bytecode (ou sa propre séquence équivalente d'opérations) pour les deux variantes, cela démontre la mauvaise qualité de ce compilateur de bytecode . Obtenir des séquences de bytecode que vous avez publiées ci-dessus est un problème résolu (je pense que tout ce dont vous avez besoin dans ce cas est un pliage constant , une élimination du code mort et une meilleure modélisation du contenu de la pile; l'élimination des sous-expressions courantes serait également bon marché et précieuse ), et il n'y a vraiment aucune excuse pour ne pas le faire dans une implémentation en langage moderne.

Maintenant, il arrive que toutes les implémentations actuelles du langage aient des compilateurs bytecode de mauvaise qualité. Mais vous devez ignorer cela lors du codage! Prétendez que le compilateur de bytecode est bon et écrivez le code le plus lisible . Ce sera probablement assez rapide de toute façon. Si ce n'est pas le cas, recherchez d'abord les améliorations algorithmiques et essayez ensuite Cython - cela fournira beaucoup plus d'amélioration pour le même effort que tout ajustement au niveau de l'expression que vous pourriez appliquer.

zwol
la source
Eh bien, la plus importante de toutes les optimisations devrait être possible en premier lieu: l'inlining. Et c'est loin d'être un "problème résolu" pour les langages dynamiques qui permettent de modifier dynamiquement l'implémentation d'une fonction (faisable cependant - HotSpot peut faire des choses similaires et des choses comme Graal travaillent à rendre ce type d'optimisations disponibles pour Python et d'autres langages dynamiques ). Et comme la fonction elle-même peut être appelée à partir de différents modules (ou un appel peut être généré dynamiquement!), Vous ne pouvez vraiment pas faire ces optimisations ici.
Voo
1
@Voo Mon bytecode optimisé à la main a exactement la même sémantique que l'original même en présence d'un dynamisme arbitraire (une exception: a <b ≡ b> a est supposé). En outre, l'inlining est surestimé. Si vous en faites trop - et qu'il est beaucoup trop facile d'en faire trop - vous faites sauter le cache-I et perdez tout ce que vous avez gagné et plus encore.
zwol
Vous avez raison, je pensais que vous vouliez optimiser votre interesting_comparebytecode simple en haut (qui ne fonctionnerait qu'avec l'inlining). Complètement hors-sujet mais: Inlining est l'une des optimisations les plus essentielles pour tout compilateur. Vous pouvez essayer d'exécuter des tests avec, disons HotSpot, sur de vrais programmes (pas des tests mathématiques qui passent 99% de leur temps dans une boucle chaude optimisée à la main [et donc n'a plus d'appels de fonction de toute façon]) lorsque vous désactivez son capacité à tout insérer - vous verrez de grandes régressions.
Voo
@Voo Oui, le simple bytecode en haut était censé être la "version optimale" du code original de l'OP, non interesting_compare.
zwol
"une exception: a <b ≡ b> a est supposé" → ce qui n'est tout simplement pas vrai en Python. De plus, je pense que CPython ne peut même pas vraiment supposer que les opérations yne changent pas la pile, car il dispose de nombreux outils de débogage.
Veedrac
8

Étant donné que la différence de sortie semble être due à un manque d'optimisation, je pense que vous devriez ignorer cette différence dans la plupart des cas - il se peut que la différence disparaisse. La différence est que yne devrait être évalué qu'une seule fois et que cela est résolu en le dupliquant sur la pile, ce qui nécessite un supplément POP_TOP- la solution à utiliser LOAD_FASTpourrait cependant être possible.

La différence importante est cependant que dans x<y and y<zla seconde ydevrait être évaluée deux fois si elle est x<yévaluée à vrai, cela a des implications si l'évaluation de yprend beaucoup de temps ou a des effets secondaires.

Dans la plupart des scénarios, vous devez utiliser x<y<z malgré le fait qu'il soit un peu plus lent.

skier
la source
6

Tout d'abord, votre comparaison n'a pratiquement aucun sens, car les deux constructions différentes n'ont pas été introduites pour améliorer les performances, vous ne devriez donc pas décider d'utiliser l'une à la place de l'autre en fonction de cela.

La x < y < zconstruction:

  1. Est plus clair et plus direct dans sa signification.
  2. Sa sémantique correspond à ce que vous attendez de la "signification mathématique" de la comparaison: évaluer x, yet z une fois et vérifier si la condition entière est vérifiée . L'utilisation andchange la sémantique en évaluant yplusieurs fois, ce qui peut changer le résultat .

Alors choisissez-en un à la place de l'autre en fonction de la sémantique que vous souhaitez et, si sont équivalents, si l'un est plus lisible que l'autre.

Cela dit: plus de code désassemblé n'implique pas un code plus lent. Cependant, exécuter plus d'opérations de bytecode signifie que chaque opération est plus simple et nécessite cependant une itération de la boucle principale. Cela signifie que si les opérations que vous effectuez sont extrêmement rapides (par exemple, la recherche de variables locales comme vous le faites là-bas), alors la surcharge liée à l'exécution de plusieurs opérations de bytecode peut avoir de l'importance.

Mais notez que ce résultat ne vaut pas dans la situation plus générique, mais uniquement dans le «pire des cas» que vous profilez. Comme d'autres l'ont noté, si vous changezy à quelque chose qui prend encore un peu plus de temps, vous verrez que les résultats changent, car la notation chaînée ne l'évalue qu'une seule fois.

En résumé:

  • Considérez la sémantique avant la performance.
  • Tenez compte de la lisibilité.
  • Ne faites pas confiance aux micro-benchmarks. Toujours profiler avec différents types de paramètres pour voir comment une fonction / expression temporelle se comporte par rapport auxdits paramètres et considérer comment vous prévoyez de l'utiliser.
Bakuriu
la source
5
Je pense que votre réponse n'inclut pas le fait simple et pertinent que la page citée, dans le cas particulier de la question - comparer les flottants, est tout simplement fausse. La comparaison chaînée n'est pas plus rapide comme on le voit dans les deux mesures et dans le bytecode généré.
pvg
30
Répondre à une question sur les performances avec "peut-être que vous ne devriez pas trop penser aux performances" ne me semble pas utile. Vous faites des hypothèses potentiellement condescendantes sur la compréhension par le questionneur des principes généraux de programmation, puis vous en parlez principalement au lieu du problème en question.
Ben Millwood
@Veerdac vous avez mal interprété le commentaire. L'optimisation proposée dans le document original sur lequel le PO s'appuyait est erronée, dans le cas des flottants au minimum. Ce n'est pas plus rapide.
pvg