Les slices, c’est rapide et c’est beau


Si vous faites de l’analyse de gros jeux de données en Python, vous vous êtes vite rendu compte que c’est là que le langage montre sa lenteur.

Les boucles pour manipuler des arrays d’entiers, qui prennent quelque nanosecondes en C, prennent des microsecondes en Python. C’est pour ça que les scientifiques utilisent SciPy et les analystes Pandas, qui sont tous deux basés sur la lib C Numpy bien enrobée dans du joli Python.

Parfois néanmoins, garder le code en pur Python est un objectif, comme dans le cas du projet mss, un screenshotter qui ne souhaites avoir une extension compilée attachée à la patte.

Or la manipulation d’images, ça bouffe des grosses matrices de pixels, et l’auteur nous a posé une jolie colle sur IndexError : ayant un array de pixels en notation BGR (Blue, Green Red), comment le transposer en RGB de manière performante ?

Sa proposition était :

pixels = range(300) # je réduis le nombre de pixel pour le benchmark
buffer_len = len(pixels)
 
def version1():
    for idx in range(0, buffer_len - 2, 3):
        pixels[idx + 2], pixels[idx] = pixels[idx], pixels[idx + 2]

J’ai tenté :

from array import array
xrange = getattr(__builtins__, 'xrange', range)
 
def version2():
    def to_rgb(pixels, buffer_len):
        for i in xrange(0, buffer_len - 2, 3):
            yield pixels[i + 2]
            yield pixels[i + 1]
            yield pixels[i]
 
    return array('H', to_rgb(pixels, buffer_len))

Mais mesurer révèle que cette solution est au final bien plus lente que celle de l’auteur :

import timeit
print(timeit.timeit('version1()', setup='from __main__ import version1'))
#16.9543120861
print(timeit.timeit('version2()', setup='from __main__ import version2'))
#42.7808477879

C’est bubulle qui va nous pondre une solution originale, élégante, mais surtout 5 fois plus rapide, ce qui est complètement inattendu :

def version3():
    pixels[2:buffer_len:3], pixels[0:buffer_len:3] = pixels[0:buffer_len:3], pixels[2:buffer_len:3]

Il utilise 3 astuces en une :

  • Le swap de variable par unpacking: a, b = b, a. On sait que c’est plus rapide que deux assignation car Python saute la version intermédiaire.
  • Le slicing avec pas (l[debut:fin:pas]), et il parcours donc son array de 3 en 3. Super malin ! Non seulement je n’y aurais jamais pensé, mais en prime j’aurais imaginé que créer deux nouveaux arrays à partir du premier serait lent.
  • L’assignation de slices. Oui, on peut faire l[3:5] = [‘a’, ‘b’] en Python.

Le gain de perf est assez bluffant :

print(timeit.timeit('version3()', setup='from __main__ import version3'))
#1.84227705002

Pourquoi est-ce aussi rapide ? Je ne peux que faire des suppositions.

Je pense que faisant cette opération sans boucler explicitement, une bonne partie reste dans l’implémentation en C sous-jacente. On évite notamment pas mal d’appels à __getitem__ et __setitem__ qui sont toujours coûteux en Python.

Par curiosité, j’ai lancé le même test sur pypy :

0.359977960587
15.5387830734
0.750488996506

On voit l’effet de la compilation JIT puisque la boucle explicite redevient plus performante.

Mais du coup je me pose une question : pourquoi ma version, qui utilise une boucle explicite également, reste autant à la traine ?

Puis j’ai réalisé : l’array stock des entier C, mais quand on les sort, il faut les rewrapper dans des entiers Python. Donc ma boucle se tape cette conversion à chaque tour, et ça bouffe.

Je vire l’array :

def version4():
    def to_rgb(pixels, buffer_len):
        for i in xrange(0, buffer_len - 2, 3):
            yield pixels[i + 2]
            yield pixels[i + 1]
            yield pixels[i]
 
    return list(to_rgb(pixels, buffer_len))

Et pouf, le temps devient plus raisonnable.

Avec CPython : 1.84687900543
Avec Pypy : 0.274104118347

Sur CPython, la vitesse est du même ordre de grandeur que la version avec les slices.

Sur PyPy, la version est 2X plus rapide que les slices, est 25% plus rapide que la version originale.

Moralités :

  • Les slices, c’est rapide. Et beau.
  • Bien connaitre ses structures de données. Utiliser la mauvaise vous tue les perfs, et array sert à économiser de la place en mémoire, pas à gagner du CPU.
  • Pypy, ça trace, mais le gain de perf peut se déplacer d’un algo à l’autre.

19 thoughts on “Les slices, c’est rapide et c’est beau

  • Furankun

    Merci pour l’analyse. Tu peux expliquer ce que ton troisième point (“L’assignation de slices. Oui, on peut faire l[3:5] = [‘a’, ‘b’] en Python.”) a de surprenant/novateur/original? je ne vois pas la différence avec l’unpacking (bouh bouh).

  • mgautierfr

    Je crois qu’il manque l’utilisation de array dans la version 2

    (parce que je vois pas vraiment de diff avec la version4 :) )

  • bubulle

    Alors déjà que recevoir un commentaire de Sam comme “Putain mais c’est brillant.” avec cette verve si caractéristique, j’en avais les yeux qui s’humectent. Je n’ose pas dire où se déplace mon humidité corporelle de me voir ainsi cité dans un article sur sametmax…

    J’ai plus le réflexe de travailler avec Numpy où l’utilisation des slices est assez naturelle et je suis moi-même surpris qu’on puisse transférer ce type d’approche avec un certain succès.

    Au final, ton benchmark suggère que la version 4 est bonne sur le temps d’exécution tout en évitant des listes temporaires, je pense que c’est elle qu’il faut privilégier.

    @Furankun

    D’une certaine manière, tu démontres la cohérence de Python :) On aurait tout à fait raison de voir l’assignation par slice comme équivalente à l’unpacking.

    En pratique, il y a une vrai différence dans l’implémentation puisque l[3:5] = [‘a’, ‘b’] est en fait interprété comme l.setitem(slice(3, 5), ['a', 'b']). Mais on s’en fout de l’implémentation tant qu’on peut écrire facilement du code expressif et synthétique.

  • ashgan

    @bubulle:

    “si à 50 ans t’es pas sourcé dans un article chez sam et max, t’as raté ta vie” :)

  • Sam Post author

    @Furankun: dans son exemple, il a un pas, donc le slicing prend en compte le pas. C’est ça qui est funky.

    @mgautierfr: bien vu.

  • betepoilue

    J’ai encore appris quelque chose aujourd’hui grâce à vous et Bubulle du coup merci !! :)

    Juste un truc que j’ai pas capté, quand tu dis qu’il y a une conversion de type entre C et python pour les integer dans un array ça veut dire quoi ? Je pensais que python était basé sur C et que du coup il utilisait les même valeurs en mémoire pour stocker un int ? (Message de gros noob numéro 4529)

  • Tim

    “Mais mesurer révèle que”, c’est pas plutôt “Mes mesures révèlent que” ?

  • Furankun

    On s’en fout des fautes, on est trop excités :D

    (merci pour les précisions)

  • Sam Post author

    @betepoilue: les entiers Python sont bien écrits en C, mais sont des objets complexes. Alors qu’en C, définir un entier long peut se faire un simple “signed double var = “, faire un entier long en Python utilise une struture bien plus complexe :

    typedef struct _object {
        _PyObject_HEAD_EXTRA
        Py_ssize_t ob_refcnt;
        struct _typeobject *ob_type;
    } PyObject;
     
     
    struct _longobject {
        long ob_refcnt;
        PyTypeObject *ob_type;
        size_t ob_size;
        long ob_digit[1];
    };

    Le module array est un module Python un peu particulier, qui permet de stocker ses valeurs sous la forme qu’on utiliserait en C. Mais quand on sort une valeur de l’array, il faut de nouveau l’enrober dans une structure complexe.

  • joshuafr

    Alors pour le coup, j’ai voulu tester avec Pandas une autre solution:

    import pandas as pd

    def version5():

    df = pd.DataFrame(pd.np.array(pixels).reshape(100, 3))

    df = df[[0, 2, 1]]

    return df.stack().tolist()

    Mais timeit met un temps infini et je dois killer le test oO. Si quelqu’un sait pourquoi, ça m’interesse

  • Sam Post author

    Si on a pas le code timeit derrière ni tes données en pixels, ça va être dur.

  • joshuafr

    @Sam j’ai pris les mêmes que toi

     
    import pandas as pd
     
    import timeit
     
    pixels = range(300)
     
    def version5():
     
    df = pd.DataFrame(pd.np.array(pixels).reshape(100, 3))
     
    df = df[[0, 2, 1]]
     
    return df.stack().tolist()
     
    print(timeit.timeit('version5()', setup='from __main__ import version5'))
  • betepoilue

    @sam pardon je n’ai pas pu répondre plus tôt !! Merci beaucoup pour l’explication complète et le code, je ne savais pas du tout que les types en python fonctionnaient de cette manière. Ça a l’air super intéréssant en tout cas, je vais regarder ça et du côté d’array du coup. Merci !!

Comments are closed.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.