¿Por qué las matrices de Python son lentas?

153

Esperaba array.arrayser más rápido que las listas, ya que las matrices parecen estar sin caja.

Sin embargo, obtengo el siguiente resultado:

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

¿Cuál podría ser la causa de tal diferencia?

Valentin Lorentz
fuente
44
las herramientas numpy pueden explotar eficientemente su matriz:% timeit np.sum (A): 100 bucles, lo mejor de 3: 8.87 ms por bucle
BM
66
Nunca me he encontrado con una situación en la que necesite usar el arraypaquete. Si quieres hacer cantidades significativas de matemáticas, Numpy opera a la velocidad de la luz (es decir, C), y generalmente es mejor que implementaciones ingenuas de cosas como sum()).
Nick T
40
Votantes cercanos: ¿Por qué se basa exactamente esta opinión? OP parece estar haciendo una pregunta técnica específica sobre un fenómeno medible y repetible.
Kevin
55
@NickT Read Una anécdota de optimización . Resulta que arrayes bastante rápido convertir una cadena de enteros (que representan bytes ASCII) en un strobjeto. El propio Guido solo se le ocurrió esto después de muchas otras soluciones y quedó bastante sorprendido por el rendimiento. De todos modos, este es el único lugar donde recuerdo haberlo visto útil. numpyes mucho mejor para manejar matrices, pero es una dependencia de terceros.
Bakuriu

Respuestas:

220

El almacenamiento está "sin caja", pero cada vez que accede a un elemento, Python tiene que "encajonarlo" (incrustarlo en un objeto Python normal) para poder hacer algo con él. Por ejemplo, su sum(A)iteración sobre la matriz y encuadra cada número entero, uno a la vez, en un intobjeto Python normal . Eso cuesta tiempo. En su sum(L), todo el boxeo se realizó en el momento en que se creó la lista.

Entonces, al final, una matriz es generalmente más lenta, pero requiere sustancialmente menos memoria.


Aquí está el código relevante de una versión reciente de Python 3, pero las mismas ideas básicas se aplican a todas las implementaciones de CPython desde que se lanzó Python por primera vez.

Aquí está el código para acceder a un elemento de la lista:

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

Hay muy poco: somelist[i]solo devuelve el i'th objeto en la lista (y todos los objetos de Python en CPython son punteros a una estructura cuyo segmento inicial se ajusta al diseño de a struct PyObject).

Y aquí está la __getitem__implementación de un arraycódigo de tipo con l:

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

La memoria sin procesar se trata como un vector de C longenteros nativos de la plataforma ; el i'th C longse lee; y luego PyLong_FromLong()se llama a envolver ("recuadro") al nativo C longen un longobjeto Python (que, en Python 3, que elimina la distinción de Python 2 entre inty long, en realidad se muestra como tipo int).

Este boxeo tiene que asignar nueva memoria para un intobjeto Python y rociar los C longbits del nativo en él. En el contexto del ejemplo original, la vida útil de este objeto es muy breve (lo suficiente como sum()para agregar el contenido a un total acumulado), y luego se necesita más tiempo para desasignar el nuevo intobjeto.

Aquí es de donde viene la diferencia de velocidad, siempre ha venido, y siempre vendrá en la implementación de CPython.

Tim Peters
fuente
87

Para agregar a la excelente respuesta de Tim Peters, las matrices implementan el protocolo de buffer , mientras que las listas no. Esto significa que, si está escribiendo una extensión C (o el equivalente moral, como escribir un módulo Cython ), puede acceder y trabajar con los elementos de una matriz mucho más rápido que cualquier cosa que Python pueda hacer. Esto le dará mejoras considerables de velocidad, posiblemente por encima de un orden de magnitud. Sin embargo, tiene una serie de inconvenientes:

  1. Ahora está en el negocio de escribir C en lugar de Python. Cython es una forma de mejorar esto, pero no elimina muchas diferencias fundamentales entre los idiomas; debe estar familiarizado con la semántica en C y comprender lo que está haciendo.
  2. La API C de PyPy funciona hasta cierto punto , pero no es muy rápida. Si está apuntando a PyPy, probablemente debería simplemente escribir código simple con listas regulares y luego dejar que JITter lo optimice por usted.
  3. Las extensiones C son más difíciles de distribuir que el código Python puro porque deben compilarse. La compilación tiende a depender de la arquitectura y del sistema operativo, por lo que deberá asegurarse de que está compilando para su plataforma de destino.

Ir directamente a las extensiones C puede estar usando un mazo para aplastar una mosca, dependiendo de su caso de uso. Primero debe investigar NumPy y ver si es lo suficientemente poderoso como para hacer cualquier matemática que intente hacer. También será mucho más rápido que Python nativo, si se usa correctamente.

Kevin
fuente
10

Tim Peters respondió por qué esto es lento, pero veamos cómo mejorar .

Siguiendo con su ejemplo de sum(range(...))(factor 10 más pequeño que su ejemplo para caber en la memoria aquí):

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 esta forma, Numpy también necesita box / unbox, que tiene una sobrecarga adicional. Para hacerlo rápido, uno debe permanecer dentro del código c numpy:

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

Entonces, desde la solución de la lista hasta la versión numpy, este es un factor 16 en tiempo de ejecución.

Veamos también cuánto tiempo lleva crear esas estructuras de datos.

%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

Claro ganador: Numpy

También tenga en cuenta que crear la estructura de datos lleva casi tanto tiempo como sumar, si no más. La asignación de memoria es lenta.

Uso de memoria de aquellos:

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

Entonces estos toman 8 bytes por número con una sobrecarga variable. Para el rango que utilizamos, los bits de 32 bits son suficientes, por lo que podemos proteger algo de memoria.

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

Pero resulta que agregar entradas de 64 bits es más rápido que las entradas de 32 bits en mi máquina, por lo que solo vale la pena si está limitado por la memoria / ancho de banda.

Robin Roth
fuente
-1

tenga en cuenta que 100000000es igual a 10^8no hacerlo 10^7, y mis resultados son los siguientes:

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
fuente