Pourquoi les tableaux de Python sont-ils lents?

153

Je m'attendais array.arrayà être plus rapide que les listes, car les tableaux semblent être déballés.

Cependant, j'obtiens le résultat suivant:

In [1]: import array

In [2]: L = list(range(100000000))

In [3]: A = array.array('l', range(100000000))

In [4]: %timeit sum(L)
1 loop, best of 3: 667 ms per loop

In [5]: %timeit sum(A)
1 loop, best of 3: 1.41 s per loop

In [6]: %timeit sum(L)
1 loop, best of 3: 627 ms per loop

In [7]: %timeit sum(A)
1 loop, best of 3: 1.39 s per loop

Quelle pourrait être la cause d'une telle différence?

Valentin Lorentz
la source
4
Les outils numpy peuvent exploiter efficacement votre tableau:% timeit np.sum (A): 100 boucles, meilleur de 3: 8,87 ms par boucle
BM
6
Je n'ai jamais rencontré de situation où j'avais besoin d'utiliser le arraypackage. Si vous voulez faire des quantités significatives de mathématiques, Numpy fonctionne à la vitesse de la lumière (c'est-à-dire C), et généralement mieux que les implémentations naïves de choses comme sum()).
Nick T
40
Électeurs proches: pourquoi exactement cette opinion est-elle basée sur l'opinion? OP semble poser une question technique spécifique sur un phénomène mesurable et reproductible.
Kevin
5
@NickT Read Une anecdote d'optimisation . Il s'avère que la arrayconversion d'une chaîne d'entiers (représentant des octets ASCII) en un strobjet est assez rapide . Guido lui-même n'a proposé cela qu'après de nombreuses autres solutions et a été assez surpris de la performance. Quoi qu'il en soit, c'est le seul endroit où je me souviens de l'avoir vu être utile. numpyest bien meilleur pour traiter les tableaux, mais c'est une dépendance tierce.
Bakuriu

Réponses:

220

Le stockage est "déballé", mais chaque fois que vous accédez à un élément, Python doit le "boxer" (l'incorporer dans un objet Python normal) pour pouvoir faire quoi que ce soit avec lui. Par exemple, votre sum(A)itère sur le tableau et encadre chaque entier, un à la fois, dans un intobjet Python normal . Cela prend du temps. Dans votresum(L) , toute la boxe a été faite au moment où la liste a été créée.

Ainsi, à la fin, un tableau est généralement plus lent, mais nécessite beaucoup moins de mémoire.


Voici le code pertinent d'une version récente de Python 3, mais les mêmes idées de base s'appliquent à toutes les implémentations de CPython depuis la sortie de Python.

Voici le code pour accéder à un élément de liste:

PyObject *
PyList_GetItem(PyObject *op, Py_ssize_t i)
{
    /* error checking omitted */
    return ((PyListObject *)op) -> ob_item[i];
}

Il n'y a pas grand-chose à faire: somelist[i]renvoie simplement le i'ième objet de la liste (et tous les objets Python en CPython sont des pointeurs vers une structure dont le segment initial est conforme à la disposition de a struct PyObject).

Et voici l' __getitem__implémentation pour un arraycode avec type l:

static PyObject *
l_getitem(arrayobject *ap, Py_ssize_t i)
{
    return PyLong_FromLong(((long *)ap->ob_item)[i]);
}

La mémoire brute est traitée comme un vecteur d' C longentiers natifs de la plate-forme ; le i'th C longest lu; et PyLong_FromLong()est ensuite appelé pour envelopper ("box") le natif C longdans un longobjet Python (qui, en Python 3, qui élimine la distinction de Python 2 entre intet long, est en fait montré comme type int).

Ce boxing doit allouer une nouvelle mémoire pour un intobjet Python , et y pulvériser les C longbits du natif . Dans le contexte de l'exemple d'origine, la durée de vie de cet objet est très brève (juste assez longue pour sum()ajouter le contenu dans un total en cours), puis plus de temps est nécessaire pour désallouer le nouvel intobjet.

C'est de là que la différence de vitesse vient, est toujours venue et proviendra toujours dans l'implémentation CPython.

Tim Peters
la source
87

Pour ajouter à l'excellente réponse de Tim Peters, les tableaux implémentent le protocole de tampon , contrairement aux listes. Cela signifie que, si vous écrivez une extension C (ou l'équivalent moral, comme l'écriture d'un Cython module ), vous pouvez accéder et travailler avec les éléments d'un tableau beaucoup plus rapidement que tout ce que Python peut faire. Cela vous donnera des améliorations de vitesse considérables, peut-être bien sur un ordre de grandeur. Cependant, il présente un certain nombre d'inconvénients:

  1. Vous êtes maintenant en train d'écrire du C au lieu de Python. Cython est un moyen d'améliorer cela, mais il n'élimine pas de nombreuses différences fondamentales entre les langages; vous devez vous familiariser avec la sémantique C et comprendre ce qu'elle fait.
  2. L'API C de PyPy fonctionne dans une certaine mesure , mais n'est pas très rapide. Si vous ciblez PyPy, vous devriez probablement simplement écrire du code simple avec des listes régulières, puis laisser le JITter l'optimiser pour vous.
  3. Les extensions C sont plus difficiles à distribuer que le code Python pur car elles doivent être compilées. La compilation a tendance à dépendre de l'architecture et du système d'exploitation, vous devrez donc vous assurer que vous compilez pour votre plate-forme cible.

Pour passer directement aux extensions C, il se peut que vous utilisiez un marteau pour écraser une mouche, selon votre cas d'utilisation. Vous devriez d'abord étudier NumPy et voir s'il est assez puissant pour faire tout ce que vous essayez de faire. Il sera également beaucoup plus rapide que Python natif, s'il est utilisé correctement.

Kevin
la source
10

Tim Peters a répondu pourquoi c'était lent, mais voyons comment l'améliorer .

S'en tenir à votre exemple de sum(range(...))(facteur 10 plus petit que votre exemple pour tenir dans la mémoire ici):

import numpy
import array
L = list(range(10**7))
A = array.array('l', L)
N = numpy.array(L)

%timeit sum(L)
10 loops, best of 3: 101 ms per loop

%timeit sum(A)
1 loop, best of 3: 237 ms per loop

%timeit sum(N)
1 loop, best of 3: 743 ms per loop

De cette façon, numpy doit également boxer / déballer, ce qui a une surcharge supplémentaire. Pour le rendre rapide, il faut rester dans le code numpy c:

%timeit N.sum()
100 loops, best of 3: 6.27 ms per loop

Donc, de la solution de liste à la version numpy, il s'agit d'un facteur 16 à l'exécution.

Vérifions également combien de temps prend la création de ces structures de données

%timeit list(range(10**7))
1 loop, best of 3: 283 ms per loop

%timeit array.array('l', range(10**7))
1 loop, best of 3: 884 ms per loop

%timeit numpy.array(range(10**7))
1 loop, best of 3: 1.49 s per loop

%timeit numpy.arange(10**7)
10 loops, best of 3: 21.7 ms per loop

Vainqueur clair: Numpy

Notez également que la création de la structure de données prend à peu près autant de temps que la sommation, sinon plus. L'allocation de mémoire est lente.

Utilisation de la mémoire de ceux-ci:

sys.getsizeof(L)
90000112
sys.getsizeof(A)
81940352
sys.getsizeof(N)
80000096

Donc, ceux-ci prennent 8 octets par nombre avec des frais généraux variables. Pour la gamme, nous utilisons des ints 32 bits sont suffisants, nous pouvons donc conserver de la mémoire.

N=numpy.arange(10**7, dtype=numpy.int32)

sys.getsizeof(N)
40000096

%timeit N.sum()
100 loops, best of 3: 8.35 ms per loop

Mais il s'avère que l'ajout d'ints 64 bits est plus rapide que les ints 32 bits sur ma machine, donc cela ne vaut la peine que si vous êtes limité par la mémoire / la bande passante.

Robin Roth
la source
-1

veuillez noter que 100000000équivaut à 10^8ne pas faire 10^7, et mes résultats sont les suivants:

100000000 == 10**8

# my test results on a Linux virtual machine:
#<L = list(range(100000000))> Time: 0:00:03.263585
#<A = array.array('l', range(100000000))> Time: 0:00:16.728709
#<L = list(range(10**8))> Time: 0:00:03.119379
#<A = array.array('l', range(10**8))> Time: 0:00:18.042187
#<A = array.array('l', L)> Time: 0:00:07.524478
#<sum(L)> Time: 0:00:01.640671
#<np.sum(L)> Time: 0:00:20.762153
S. Cheraghifar
la source