web-dev-qa-db-fra.com

Pourquoi la comparaison de chaînes est-elle si rapide en python?

Je suis devenu curieux de comprendre comment fonctionne la comparaison de chaînes dans python lorsque je résolvais l'exemple de problème d'algorithme suivant:

Étant donné deux chaînes, retournez la longueur du préfixe commun le plus long

Solution 1: charByChar

Mon intuition m'a dit que la solution optimale serait de commencer avec un curseur au début des deux mots et d'itérer vers l'avant jusqu'à ce que les préfixes ne correspondent plus. Quelque chose comme

def charByChar(smaller, bigger):
  assert len(smaller) <= len(bigger)
  for p in range(len(smaller)):
    if smaller[p] != bigger[p]:
      return p
  return len(smaller)

Pour simplifier le code, la fonction suppose que la longueur de la première chaîne, smaller, est toujours inférieure ou égale à la longueur de la deuxième chaîne, bigger.

Solution 2: binarySearch

Une autre méthode consiste à couper les deux chaînes pour créer deux sous-chaînes de préfixe. Si les préfixes sont égaux, nous savons que le point de préfixe commun est au moins aussi long que le milieu. Sinon, le point de préfixe commun n'est au moins pas plus grand que le point médian. Nous pouvons alors recurse pour trouver la longueur du préfixe.

Aka recherche binaire.

def binarySearch(smaller, bigger):
  assert len(smaller) <= len(bigger)
  lo = 0
  hi = len(smaller)

  # binary search for prefix
  while lo < hi:
    # +1 for even lengths
    mid = ((hi - lo + 1) // 2) + lo

    if smaller[:mid] == bigger[:mid]:
      # prefixes equal
      lo = mid
    else:
      # prefixes not equal
      hi = mid - 1

  return lo

Au début, je supposais que binarySearch serait plus lent car la comparaison de chaînes comparerait tous les caractères plusieurs fois plutôt que les préfixes comme dans charByChar.

Étonnamment, le binarySearch s'est avéré être beaucoup plus rapide après quelques analyses préliminaires.

Figure A

lcp_fixed_suffix

Ci-dessus montre comment les performances sont affectées lorsque la longueur du préfixe augmente. La longueur du suffixe reste constante à 50 caractères.

Ce graphique montre deux choses:

  1. Comme prévu, les deux algorithmes fonctionnent linéairement moins bien à mesure que la longueur du préfixe augmente.
  2. Les performances de charByChar se dégradent à un rythme beaucoup plus rapide.

Pourquoi binarySearch est-il tellement mieux? Je pense que c'est parce que

  1. La comparaison des chaînes dans binarySearch est vraisemblablement optimisée par l'interpréteur/CPU en arrière-plan.
  2. charByChar crée en fait de nouvelles chaînes pour chaque caractère accédé, ce qui génère une surcharge importante.

Pour valider cela, j'ai comparé les performances de comparaison et de découpage d'une chaîne, étiquetées cmp et slice respectivement ci-dessous.

Figure B

cmp

Ce graphique montre deux choses importantes:

  1. Comme prévu, la comparaison et le découpage augmentent linéairement avec la longueur.
  2. Le coût de la comparaison et du découpage augmente très lentement avec la longueur par rapport aux performances de l'algorithme, figure A. Notez que les deux chiffres montent jusqu'à des chaînes de longueur 1 milliard de caractères. Par conséquent, le coût de la comparaison de 1 caractère 1 milliard de fois est beaucoup plus élevé que de comparer 1 milliard de caractères une fois. Mais cela ne répond toujours pas pourquoi ...

Cpython

Dans un effort pour découvrir comment l'interpréteur cpython optimise la comparaison de chaînes, j'ai généré le code d'octet pour la fonction suivante.

In [9]: def slice_cmp(a, b): return a[0] == b[0]

In [10]: dis.dis(slice_cmp)
            0 LOAD_FAST                0 (a)
            2 LOAD_CONST               1 (0)
            4 BINARY_SUBSCR
            6 LOAD_FAST                1 (b)
            8 LOAD_CONST               1 (0)
           10 BINARY_SUBSCR
           12 COMPARE_OP               2 (==)
           14 RETURN_VALUE

J'ai fouillé le code cpython et trouvé ce qui suit deuxpièces de code mais je ne suis pas sûr que c'est là que la comparaison de chaînes se produit.

La question

  • Où dans le cpython la comparaison des chaînes se produit-elle?
  • Y a-t-il une optimisation CPU? Existe-t-il une instruction spéciale x86 qui compare les chaînes? Comment puis-je voir quelles instructions d'assemblage sont générées par cpython? Vous pouvez supposer que j'utilise la dernière version de python3, Intel Core i5, OS X 10.11.6.
  • Pourquoi la comparaison d'une longue chaîne est-elle beaucoup plus rapide que la comparaison de chacun de ses caractères?

Question bonus: quand charByChar est-il plus performant?

Si le préfixe est suffisamment petit par rapport à la longueur restante de la chaîne, à un moment donné, le coût de création de sous-chaînes dans charByChar devient inférieur au coût de comparaison des sous-chaînes dans binarySearch.

Pour décrire cette relation, je me suis plongé dans l'analyse d'exécution.

Analyse de l'exécution

Pour simplifier les équations ci-dessous, supposons que smaller et bigger sont de la même taille et je les désignerai par s1 Et s2.

charByChar

charByChar(s1, s2) = costOfOneChar * prefixLen

Où le

costOfOneChar = cmp(1) + slice(s1Len, 1) + slice(s2Len, 1)

cmp(1) est le coût de comparaison de deux chaînes de longueur 1 car.

slice est le coût d'accès à un caractère, l'équivalent de charAt(i). Python a des chaînes immuables et l'accès à un caractère crée en fait une nouvelle chaîne de longueur 1. slice(string_len, slice_len) est le coût de découpage d'une chaîne de longueur string_len En un tranche de taille slice_len.

Alors

charByChar(s1, s2) = O((cmp(1) + slice(s1Len, 1)) * prefixLen)

recherche binaire

binarySearch(s1, s2) = costOfHalfOfEachString * log_2(s1Len)

log_2 Est le nombre de fois pour diviser les chaînes en deux jusqu'à atteindre une chaîne de longueur 1. Où

costOfHalfOfEachString = slice(s1Len, s1Len / 2) + slice(s2Len, s1Len / 2) + cmp(s1Len / 2)

Ainsi, le big-O de binarySearch augmentera selon

binarySearch(s1, s2) = O((slice(s2Len, s1Len) + cmp(s1Len)) * log_2(s1Len))

Sur la base de notre analyse précédente du coût de

Si nous supposons que costOfHalfOfEachString est approximativement le costOfComparingOneChar alors nous pouvons les désigner tous les deux comme x.

charByChar(s1, s2) = O(x * prefixLen)
binarySearch(s1, s2) = O(x * log_2(s1Len))

Si nous les égalisons

O(charByChar(s1, s2)) = O(binarySearch(s1, s2))
x * prefixLen = x * log_2(s1Len)
prefixLen = log_2(s1Len)
2 ** prefixLen = s1Len

Donc O(charByChar(s1, s2)) > O(binarySearch(s1, s2) quand

2 ** prefixLen = s1Len

Donc, en branchant la formule ci-dessus, j'ai régénéré les tests de la figure A, mais avec des chaînes de longueur totale 2 ** prefixLen En espérant que les performances des deux algorithmes soient à peu près égales.

img

Cependant, il est clair que charByChar fonctionne beaucoup mieux. Avec un peu d'essais et d'erreurs, les performances des deux algorithmes sont à peu près égales lorsque s1Len = 200 * prefixLen

img

Pourquoi la relation est-elle 200x?

36
david_adler

TL: DR : une comparaison de tranche est un certain Python surcharge + un memcmp hautement optimisé _ (sauf si il y a un traitement UTF-8?). Idéalement, utilisez des comparaisons de tranches pour trouver le premier décalage à moins de 128 octets ou quelque chose, puis bouclez un caractère à la fois.

Ou si c'est une option et que le problème est important, faites une copie modifiée d'un memcmp optimisé asm qui retourne la position de la première différence, au lieu de égal/non-égal; il s'exécutera aussi rapidement qu'un seul == de l'ensemble des chaînes. Python a des moyens d'appeler des fonctions natives C/asm dans les bibliothèques.

C'est une limitation frustrante que le CPU puisse le faire d'une manière incroyablement rapide, mais Python ne donne pas (AFAIK) vous donne accès à une boucle de comparaison optimisée qui vous indique la position de non-correspondance au lieu d'être juste égale/supérieure/Moins.


Il est tout à fait normal que la surcharge de l'interprète domine le coût du travail réel dans une simple boucle Python, avec CPython . un algorithme à partir de blocs de construction optimisés en vaut la peine même si cela signifie faire plus de travail total. C'est pourquoi NumPy est bon, mais boucler sur une matrice élément par élément est terrible. La différence de vitesse pourrait être quelque chose comme un facteur de 20 à 100, pour CPython vs une boucle C (asm) compilée pour comparer un octet à la fois (nombres composés, mais probablement à l'intérieur d'un ordre de grandeur).

La comparaison des blocs de mémoire pour l'égalité est probablement l'une des plus grandes disparités entre Python boucles vs opérant sur une liste/tranche entière. C'est un problème commun avec les solutions hautement optimisées (par exemple la plupart des implémentations libc ( y compris OS X) ont un asm codé manuellement vectorisé à la main memcmp qui utilise SIMD pour comparer 16 ou 32 octets en parallèle, et s'exécute beaucoupplus rapidement qu'un octet -a-time loop in C or Assembly). Il y a donc un autre facteur de 16 à 32 (si la bande passante mémoire n'est pas un goulot d'étranglement) multipliant le facteur de 20 à 100 différence de vitesse entre Python et Boucles C. Ou selon l'optimisation de votre memcmp, peut-être "seulement" 6 ou 8 octets par cycle.

Avec des données chaudes dans le cache L2 ou L1d pour les tampons de taille moyenne, il est raisonnable de s'attendre à 16 ou 32 octets par cycle pour memcmp sur un processeur Haswell ou ultérieur. (La dénomination i3/i5/i7 a commencé avec Nehalem; i5 seul ne suffit pas pour nous en dire beaucoup sur votre CPU.)

Je ne suis pas sûr si l'un ou les deux de vos comparaisons doivent traiter UTF-8 et vérifier les classes d'équivalence ou les différentes façons d'encoder le même caractère. Le pire des cas est si votre boucle Python char-at-a-time doit vérifier les caractères potentiellement multi-octets mais votre comparaison de tranche peut simplement utiliser memcmp.


Écrire une version efficace en Python:

Nous luttons totalement contre le langage pour gagner en efficacité: votre problème est presque exactement le même que la fonction de bibliothèque standard C memcmp, sauf que vous voulez la position du première différence au lieu d'un résultat -/0/+ vous indiquant quelle chaîne est la plus grande. La boucle de recherche est identique, c'est juste une différence dans ce que fait la fonction après avoir trouvé le résultat.

Votre recherche binaire n'est pas le meilleur moyen d'utiliser un bloc de construction à comparaison rapide. Une comparaison de tranche a toujours O(n) coût, pas O(1), juste avec un facteur constant beaucoup plus petit. Vous pouvez et devez éviter de comparer à plusieurs reprises les débuts des tampons en en utilisant des tranches pour comparer de gros morceaux jusqu'à ce que vous trouviez un décalage, puis revenez sur ce dernier morceau avec une taille de morceau plus petite.

# I don't actually know Python; consider this pseudo-code
# or leave an edit if I got this wrong :P
chunksize = min(8192, len(smaller))
# possibly round chunksize down to the next lowest power of 2?
start = 0
while start+chunksize < len(smaller):
    if smaller[start:start+chunksize] == bigger[start:start+chunksize]:
        start += chunksize
    else:
        if chunksize <= 128:
            return char_at_a_time(smaller[start:start+chunksize],  bigger[start:start+chunksize])
        else:
            chunksize /= 8        # from the same start

# TODO: verify this logic for corner cases like string length not a power of 2
# and/or a difference only in the last character: make sure it does check to the end

J'ai choisi 8192 car votre processeur dispose d'un cache L1d de 32 Ko, donc l'encombrement total du cache de deux tranches de 8 Ko est de 16 Ko, la moitié de votre L1d. Lorsque la boucle trouve une incompatibilité, elle analysera à nouveau les 8 derniers Ko en morceaux de 1 Ko, et ces comparaisons feront une boucle sur des données qui sont encore chaudes dans L1d. (Notez que si == A trouvé une non-concordance, il n'a probablement touché que les données jusqu'à ce point, pas l'intégralité du 8k. Mais la prélecture HW continuera d'aller un peu au-delà.)

Un facteur 8 devrait être un bon équilibre entre l'utilisation de grandes tranches pour localiser rapidement et le fait de ne pas avoir besoin de plusieurs passages sur les mêmes données. Il s'agit bien entendu d'un paramètre réglable, ainsi que de la taille des morceaux. Plus l'écart est grand entre Python et asm, plus ce facteur doit être petit pour réduire Python itérations de boucle.)

Avec un peu de chance, 8k est assez grand pour masquer le Python overhead de boucle/tranche; le prefetch matériel devrait toujours fonctionner pendant le Python overhead entre memcmp appels de l'interpréteur pour que nous n'ayons pas besoin que la granularité soit énorme. Mais pour les très grosses chaînes, si 8k ne saturent pas la bande passante mémoire, alors faites-en 64k (votre cache L2 est de 256ko; i5 nous en dit long).

Est-ce que memcmp est si rapide:

J'exécute cela sur Intel Core i5 mais j'imagine que j'obtiendrais les mêmes résultats sur la plupart des processeurs modernes.

Même en C, Pourquoi memcmp est-il beaucoup plus rapide qu'une vérification de boucle for?memcmp est plus rapide qu'une boucle de comparaison octet à la fois, car même les compilateurs C ne sont pas parfaits (ou totalement incapables de) vectoriser automatiquement les boucles de recherche.

Même sans prise en charge matérielle SIMD, un memcmp optimisé pourrait vérifier 4 ou 8 octets à la fois (taille de mot/largeur de registre), même sur un processeur simple sans SIMD de 16 ou 32 octets.

Mais la plupart des processeurs modernes, et tous les x86-64, ont des instructions SIMD. SSE2 est la base de référence pour x86-64 , et disponible en tant qu'extension en mode 32 bits.

Un SSE2 ou AVX2 memcmp peut utiliser pcmpeqb/pmovmskb pour comparer 16 ou 32 octets en parallèle. (Je ne vais pas entrer dans les détails sur la façon d'écrire memcmp en asm x86 ou avec intrinsèque C. Google qui séparément, et/ou rechercher ces instructions asm dans une référence de jeu d'instructions x86. Comme http: //felixcloutier.com/x86/index.html . Voir aussi la balise x86 wiki pour les liens asm et performance. eg Pourquoi Skylake est-il tellement meilleur que Broadwell-E pour le débit de mémoire à un seul thread? a quelques informations sur les limitations de la bande passante de la mémoire à cœur unique.)

J'ai trouvé une ancienne version de 2005 d'Apple x86-64 memcmp (en langage d'assemblage de syntaxe AT&T) sur leur site Web open source. Cela pourrait certainement être mieux; pour les grands tampons, il doit aligner un pointeur source et utiliser uniquement movdqu pour l'autre, autorisant movdqu puis pcmpeqb avec un opérande mémoire au lieu de 2x movdqu, même si le les chaînes sont désalignées les unes par rapport aux autres. xorl $0xFFFF,%eax/jnz n'est pas optimal non plus sur les CPU où cmp/jcc Peut fusionner les macro mais pas xor / jcc.

Dérouler pour vérifier une ligne de cache de 64 octets à la fois masquerait également la surcharge de la boucle. (C'est la même idée d'un gros morceau, puis de le boucler lorsque vous trouvez un hit). La version AVX2 -movbe de Glibc le fait avec vpand pour combiner les résultats de comparaison dans la boucle principale à grand tampon, la combinaison finale étant un vptest qui définit également les indicateurs du résultat. (Taille de code plus petite mais pas moins d'ups que vpand/vpmovmskb/cmp/jcc; mais pas d'inconvénient et peut-être une latence plus faible pour réduire les pénalités de mauvaise prévision de branche lors de la sortie de boucle ). Glibc fait une répartition dynamique du CPU au moment de la liaison dynamique; il choisit cette version sur les processeurs qui le prennent en charge.

Espérons que le memcmp d'Apple soit meilleur de nos jours; Je ne vois pas du tout la source dans le répertoire Libc le plus récent. Espérons qu'ils envoient au moment de l'exécution à une version AVX2 pour Haswell et les processeurs ultérieurs.

La boucle LLoopOverChunks dans la version que j'ai liée ne fonctionnerait qu'à 1 itération (16 octets de chaque entrée) par ~ 2,5 cycles sur Haswell; 10 uops à domaine fusionné. Mais c'est encore beaucoup plus rapide que 1 octet par cycle pour une boucle C naïve, ou bien pire que cela pour une boucle Python.

La boucle L(loop_4x_vec): de Glibc est composée de 18 uops de domaine fusionné, et peut donc fonctionner à un peu moins de 32 octets (à partir de chaque entrée) par cycle d'horloge, lorsque les données sont chaudes dans le cache L1d. Sinon, il goulot d'étranglement sur la bande passante L2. Cela aurait pu être 17 uops s'ils n'avaient pas utilisé une instruction supplémentaire à l'intérieur de la boucle décrémentant un compteur de boucle séparé et calculé un pointeur de fin à l'extérieur de la boucle.


Recherche d'instructions/de points chauds dans le code de l'interpréteur Python

Comment pourrais-je explorer les instructions C et CPU que mon code appelle?

Sous Linux, vous pouvez exécuter perf record python ... Puis perf report -Mintel Pour voir dans quelles fonctions le processeur passe le plus de temps et quelles instructions dans ces fonctions sont les plus chaudes. Vous obtiendrez des résultats comme ceux que j'ai publiés ici: Pourquoi float () est-il plus rapide que int ()? . (Accédez à n'importe quelle fonction pour voir les instructions réelles de la machine qui ont été exécutées, affichées en tant que langage d'assemblage car perf a un désassembleur intégré.)

Pour une vue plus nuancée qui échantillonne le call-graph sur chaque événement, voir linux perf: comment interpréter et trouver les hotspots .

(Lorsque vous cherchez à réellement optimiserun programme, vous voulez savoir quels appels de fonction sont chers afin que vous puissiez essayer de les éviter en premier lieu. Profilage pour juste "self" le temps trouvera des points chauds, mais vous ne saurez pas toujours quels appelants différents ont causé une boucle donnée pour exécuter la plupart des itérations. Voir la réponse de Mike Dunlavey à cette question de perf.)

Mais pour ce cas spécifique, le profilage de l'interpréteur exécutant une version de comparaison de tranche sur de grandes chaînes devrait, espérons-le, trouver la boucle memcmp où je pense qu'il passera la plupart de son temps. (Ou pour la version char-at-a-time, trouvez le code interprète qui est "chaud".)

Ensuite, vous pouvez voir directement les instructions asm dans la boucle. À partir des noms de fonction, en supposant que votre binaire a des symboles, vous pouvez probablement trouver la source. Ou si vous avez une version de Python construit avec des informations de débogage, vous pouvez accéder à la source directement à partir des informations de profil. (Pas une version de débogage avec optimisation désactivée, juste avec des symboles complets).

21
Peter Cordes

Cela dépend à la fois de l'implémentation et du matériel. Sans connaître votre machine cible et sa distribution spécifique, je ne pourrais pas le dire avec certitude. Cependant, je soupçonne fortement que le matériel sous-jacent, comme la plupart, a des instructions de bloc de mémoire. Entre autres choses, cela peut comparer une paire de chaînes arbitrairement longues (jusqu'à l'adressage des limites) de manière parallèle et pipelinée. Par exemple, il peut comparer des tranches de 8 octets à une tranche par cycle d'horloge. C'est un lot plus rapide que de jouer avec des indices au niveau des octets.

4
Prune