Siguiendo el interés en esta pregunta , pensé que sería interesante hacer las respuestas un poco más objetivas y cuantitativas al proponer un concurso.
La idea es simple: he generado un archivo binario que contiene 50 millones de dobles distribuidos en gauss (promedio: 0, stdev 1). El objetivo es hacer un programa que los clasifique en la memoria lo más rápido posible. Una implementación de referencia muy simple en python tarda 1m4s en completarse. ¿Qué tan bajo podemos ir?
Las reglas son las siguientes: responda con un programa que abra el archivo "gaussian.dat" y clasifique los números en la memoria (sin necesidad de generarlos), e instrucciones para compilar y ejecutar el programa. El programa debe poder funcionar en mi máquina Arch Linux (lo que significa que puede usar cualquier lenguaje de programación o biblioteca que sea fácilmente instalable en este sistema).
El programa debe ser razonablemente legible, de modo que pueda asegurarme de que sea seguro iniciarlo (¡no hay una solución solo para ensambladores, por favor!).
Ejecutaré las respuestas en mi máquina (cuatro núcleos, 4 Gigabytes de RAM). La solución más rápida obtendrá la respuesta aceptada y una recompensa de 100 puntos :)
El programa utilizado para generar los números:
#!/usr/bin/env python
import random
from array import array
from sys import argv
count=int(argv[1])
a=array('d',(random.gauss(0,1) for x in xrange(count)))
f=open("gaussian.dat","wb")
a.tofile(f)
La implementación de referencia simple:
#!/usr/bin/env python
from array import array
from sys import argv
count=int(argv[1])
a=array('d')
a.fromfile(open("gaussian.dat"),count)
print "sorting..."
b=sorted(a)
EDITAR: solo 4 GB de RAM, lo siento
EDITAR # 2: Tenga en cuenta que el objetivo del concurso es ver si podemos usar información previa sobre los datos . ¡no se supone que sea una meada coincidencia entre diferentes implementaciones de lenguaje de programación!
fuente
Respuestas:
Aquí hay una solución en C ++ que primero divide los números en cubos con el mismo número esperado de elementos y luego clasifica cada cubo por separado. Precalcula una tabla de la función de distribución acumulativa basada en algunas fórmulas de Wikipedia y luego interpola los valores de esta tabla para obtener una aproximación rápida.
Varios pasos se ejecutan en múltiples hilos para hacer uso de los cuatro núcleos.
Para compilarlo y ejecutarlo, use este comando:
EDITAR: Todos los cubos ahora se colocan en la misma matriz para eliminar la necesidad de copiar los cubos nuevamente en la matriz. También se redujo el tamaño de la tabla con valores calculados previamente, porque los valores son lo suficientemente precisos. Aún así, si cambio el número de cubos por encima de 256, el programa tarda más en ejecutarse que con ese número de cubos.
EDITAR: El mismo algoritmo, diferente lenguaje de programación. Usé C ++ en lugar de Java y el tiempo de ejecución se redujo de ~ 3.2s a ~ 2.35s en mi máquina. El número óptimo de cubos sigue siendo de alrededor de 256 (de nuevo, en mi computadora).
Por cierto, tbb realmente es increíble.
EDITAR: Me inspiró la gran solución de Alexandru y reemplacé std :: sort en la última fase por una versión modificada de su clasificación radix. Utilicé un método diferente para lidiar con los números positivos / negativos, a pesar de que necesita más pases a través de la matriz. También decidí ordenar la matriz exactamente y eliminar la clasificación de inserción. Más tarde pasaré un tiempo probando cómo estos cambios influyen en el rendimiento y posiblemente los reviertan. Sin embargo, al usar la clasificación de radix, el tiempo disminuyó de ~ 2.35s a ~ 1.63s.
fuente
Sin ser inteligente, solo para proporcionar un clasificador ingenuo mucho más rápido, aquí hay uno en C que debería ser más o menos equivalente a su Python:
Compilado con
gcc -O3
, en mi máquina, esto lleva más de un minuto menos que el Python: aproximadamente 11 s en comparación con 87 s.fuente
Particioné en segmentos basados en la desviación estándar que mejor debería dividirlo en 4tos. Editar: reescrito en la partición en función del valor x en http://en.wikipedia.org/wiki/Error_function#Table_of_values
http://www.wolframalpha.com/input/?i=percentages+by++normal+distribution
Intenté usar cubos más pequeños, pero parecía tener poco efecto una vez 2 * más allá del número de núcleos disponibles. Sin ninguna colección paralela, tomaría 37 segundos en mi caja y 24 con las colecciones paralelas. Si particiona a través de la distribución, no puede simplemente usar una matriz, por lo que hay algo más de sobrecarga. No tengo claro cuándo un valor estaría encuadrado / sin encuadrar en scala.
Estoy usando scala 2.9, para la colección paralela. Simplemente puede descargar la distribución tar.gz de la misma.
Para compilar: scalac SortFile.scala (acabo de copiarlo directamente en la carpeta scala / bin.
Para ejecutar: JAVA_OPTS = "- Xmx4096M" ./scala SortFile (lo ejecuté con 2 gigas de ram y obtuve casi al mismo tiempo)
Editar: Eliminado allocateDirect, más lento que solo asignar. Se eliminó el cebado del tamaño inicial para los buffers de matriz. Realmente lo hizo leer los valores completos de 50000000. Reescrito para evitar problemas con el autoboxing (aún más lento que ingenuo c)
fuente
Simplemente ponga esto en un archivo cs y compílelo con csc en teoría: (Requiere mono)
fuente
Como sabe cuál es la distribución, puede usar una clasificación O (N) de indexación directa. (Si te estás preguntando qué es eso, supongamos que tienes un mazo de 52 cartas y quieres ordenarlo. Solo tienes 52 contenedores y tira cada tarjeta en su propio contenedor).
Tienes 5e7 dobles. Asigne una matriz de resultados R de 5e7 dobles. Toma cada número
x
y obténi = phi(x) * 5e7
. Básicamente hacerR[i] = x
. Tenga una manera de manejar las colisiones, como mover el número con el que puede estar colisionando (como en una simple codificación hash). Alternativamente, puede hacer que R sea un poco más grande, lleno de un valor vacío único . Al final, solo barres los elementos de R.phi
es solo la función de distribución acumulativa gaussiana. Convierte un número distribuido gaussiano entre +/- infinito en un número distribuido uniforme entre 0 y 1. Una forma sencilla de calcularlo es mediante la búsqueda de tablas y la interpolación.fuente
Aquí hay otra solución secuencial:
Dudo que supere la solución de subprocesos múltiples, pero los tiempos en mi computadora portátil i7 son (stdsort es la solución C ++ proporcionada en otra respuesta):
Tenga en cuenta que esta solución tiene una complejidad de tiempo lineal (porque utiliza la representación especial de dobles).
EDITAR : se corrigió el orden de los elementos para aumentar.
EDITAR : Velocidad mejorada en casi medio segundo.
EDITAR : Velocidad mejorada por otros 0.7 segundos. Hizo que el algoritmo sea más amigable con el caché.
EDITAR : Velocidad mejorada por otro 1 segundo. Como solo hay 50.000.000 de elementos, puedo clasificar parcialmente la mantisa y usar la ordenación por inserción (que es compatible con la caché) para arreglar elementos fuera de lugar. Esta idea elimina alrededor de dos iteraciones del último ciclo de clasificación de radix.
EDITAR : 0.16 menos segundos. Primero std :: reverse puede eliminarse si se invierte el orden de clasificación.
fuente
Tomando la solución de Christian Ammer y paralelizándola con los bloques de construcción roscados de Intel
Si tiene acceso a la biblioteca de Primitivas de rendimiento (IPP) de Intel, puede usar su clasificación de radix. Solo reemplaza
con
y
con
En mi computadora portátil de doble núcleo, los tiempos son
fuente
¿Qué tal una implementación de clasificación rápida paralela que elige sus valores de pivote basados en las estadísticas de la distribución, asegurando así particiones de igual tamaño? El primer pivote estaría en la media (cero en este caso), el siguiente par estaría en los percentiles 25 y 75 (+/- -0.67449 desviaciones estándar), y así sucesivamente, con cada partición dividiendo a la mitad el conjunto de datos restante más o menos perfectamente
fuente
Muy feo (por qué usar matrices cuando puedo usar variables que terminan con números), pero código rápido (mi primer intento de std :: hilos), todo el tiempo (tiempo real) en mi sistema 1,8 s (en comparación con std :: sort () 4,8 s), compile con g ++ -std = c ++ 0x -O3 -march = native -pthread Simplemente pase los datos a través de stdin (funciona solo para 50M).
// Editar cambiado para leer el archivo gaussian.dat.
fuente
Una solución C ++ usando
std::sort
(eventualmente más rápido que qsort, con respecto al rendimiento de qsort vs std :: sort )No puedo decir
gaussian.dat
con certeza cuánto tiempo toma porque solo tengo 1 GB en mi máquina y con el código Python dado solo pude hacer un archivo con solo 25 millones de duplicados (sin obtener un error de memoria). Pero estoy muy interesado en cuánto tiempo se ejecuta el algoritmo std :: sort.fuente
sort.h
archivo para compilarlo con C ++. Era aproximadamente el doble de lento questd::sort
. ¿No sé por qué, tal vez debido a las optimizaciones del compilador?Aquí hay una mezcla del tipo de radix de Alexandru con el pivote inteligente roscado de Zjarek. Compilarlo con
Puede cambiar el tamaño de la raíz definiendo STEP (por ejemplo, agregue -DSTEP = 11). Encontré que lo mejor para mi computadora portátil es 8 (el valor predeterminado).
Por defecto, divide el problema en 4 partes y lo ejecuta en múltiples hilos. Puede cambiar eso pasando un parámetro de profundidad a la línea de comando. Entonces, si tienes dos núcleos, ejecútalo como
y si tienes 16 núcleos
La profundidad máxima en este momento es 6 (64 hilos). Si coloca demasiados niveles, simplemente ralentizará el código.
Una cosa que también probé fue la clasificación por radix de la biblioteca Intel Performance Primitives (IPP). La implementación de Alexandru aplasta a IPP, con IPP siendo aproximadamente un 30% más lento. Esa variación también se incluye aquí (comentado).
EDITAR : implementé las mejoras de caché de Alexandru, y eso redujo aproximadamente el 30% del tiempo en mi máquina.
EDITAR : esto implementa un tipo recursivo, por lo que debería funcionar bien en la máquina de 16 núcleos de Alexandru. También usa la última mejora de Alexandru y elimina una de las reversas. Para mí, esto dio una mejora del 20%.
EDITAR : Se corrigió un error de señal que causaba ineficiencia cuando hay más de 2 núcleos.
EDITAR : se eliminó la lambda, por lo que se compilará con versiones anteriores de gcc. Incluye la variación del código IPP comentada. También arreglé la documentación para ejecutar en 16 núcleos. Por lo que puedo decir, esta es la implementación más rápida.
EDITAR : se corrigió un error cuando STEP no es 8. Se aumentó el número máximo de subprocesos a 64. Se agregó información de sincronización.
fuente
step
(11 fue óptimo en mi computadora portátil).int cnt[mask]
debería serloint cnt[mask + 1]
. Para mejores resultados, use un valor fijoint cnt[1 << 16]
.Supongo que esto realmente depende de lo que quieras hacer. Si quieres ordenar un grupo de gaussianos, entonces esto no te ayudará. Pero si quieres un montón de gaussianos ordenados, esto lo hará. Incluso si esto pierde un poco el problema, creo que será interesante comparar las rutinas de clasificación con las reales.
Si quieres que algo sea rápido, haz menos.
En lugar de generar un montón de muestras aleatorias de la distribución normal y luego ordenarlas, puede generar un montón de muestras de la distribución normal en orden ordenado.
Puede usar la solución aquí para generar n números aleatorios uniformes en orden ordenado. Luego puede usar el cdf inverso (scipy.stats.norm.ppf) de la distribución normal para convertir los números aleatorios uniformes en números de la distribución normal a través del muestreo de transformación inversa .
Si quiere ensuciarse las manos, supongo que podría acelerar los numerosos cálculos de cdf inversos utilizando algún tipo de método iterativo y utilizando el resultado anterior como su suposición inicial. Dado que las conjeturas van a ser muy cercanas, probablemente una sola iteración le dará una gran precisión.
fuente
Intente esto cambiando la solución de Guvante con este Main (), comienza a ordenar tan pronto como se realiza la lectura de 1/4 IO, es más rápido en mi prueba:
fuente
Dado que conoce la distribución, mi idea sería hacer k cubos, cada uno con el mismo número esperado de elementos (dado que conoce la distribución, puede calcular esto). Luego, en el tiempo O (n), barra la matriz y coloque los elementos en sus cubos.
Luego, simultáneamente, clasifique los cubos. Supongamos que tiene k cubos y n elementos. Un cubo tomará (n / k) lg (n / k) tiempo para ordenar. Ahora suponga que tiene procesadores p que puede usar. Dado que los cubos se pueden clasificar de forma independiente, tiene un multiplicador de techo (k / p) con el que lidiar. Esto proporciona un tiempo de ejecución final de n + ceil (k / p) * (n / k) lg (n / k), que debería ser mucho más rápido que n lg n si elige k bien.
fuente
std::sort()
, pero es mucho más lento que la solución radixsort de Alexandru.Una idea de optimización de bajo nivel es colocar dos dobles en un registro SSE, por lo que cada subproceso funcionaría con dos elementos a la vez. Esto puede ser complicado de hacer para algunos algoritmos.
Otra cosa que hacer es ordenar la matriz en trozos compatibles con la caché y luego fusionar los resultados. Deben usarse dos niveles: por ejemplo, primero 4 KB para L1 y luego 64 KB para L2.
Esto debería ser muy amigable con el caché, ya que la clasificación del depósito no saldrá del caché, y la fusión final recorrerá la memoria secuencialmente.
En estos días, la computación es mucho más barata que los accesos a la memoria. Sin embargo, tenemos una gran cantidad de elementos, por lo que es difícil saber cuál es el tamaño de la matriz cuando la clasificación tonta con reconocimiento de caché es más lenta que una versión de baja complejidad sin reconocimiento de caché.
Pero no proporcionaré una implementación de lo anterior ya que lo haría en Windows (VC ++).
fuente
Aquí hay una implementación de clasificación de cubeta de exploración lineal. Creo que es más rápido que todas las implementaciones actuales de un solo subproceso, excepto para la clasificación de radix. Debería tener un tiempo de ejecución lineal esperado si estoy estimando el cdf con suficiente precisión (estoy usando la interpolación lineal de valores que encontré en la web) y no he cometido ningún error que pueda causar un escaneo excesivo:
fuente
No sé por qué no puedo editar mi publicación anterior, así que aquí está la nueva versión, 0,2 segundos más rápido (pero aproximadamente 1,5 s más rápido en tiempo de CPU (usuario)). Esta solución tiene 2 programas, primero calcula previamente los cuantiles para la distribución normal para la clasificación de cubetas, y los almacena en la tabla, t [doble * escala] = índice de cubetas, donde la escala es un número arbitrario que hace posible la conversión al doble. Entonces el programa principal puede usar estos datos para colocar los dobles en el cubo correcto. Tiene un inconveniente, si los datos no son gaussianos, no funcionará correctamente (y también hay casi cero posibilidades de trabajar incorrectamente para la distribución normal), pero la modificación para un caso especial es fácil y rápida (solo el número de comprobaciones de cubos y caer a estándar) ::ordenar()).
Compilación: g ++ => http://pastebin.com/WG7pZEzH programa de ayuda
g ++ -std = c ++ 0x -O3 -march = native -pthread => http://pastebin.com/T3yzViZP programa de clasificación principal
fuente
Aquí hay otra solución secuencial. Éste utiliza el hecho de que los elementos están distribuidos normalmente, y creo que la idea es generalmente aplicable para obtener una clasificación cercana al tiempo lineal.
El algoritmo es así:
phi()
función en la implementación)size * phi(x)
Desafortunadamente, la constante oculta es bastante grande y esta solución es dos veces más lenta que el algoritmo de clasificación de radix.
fuente
Mi favorito personal usando los bloques de construcción roscados de Intel ya se ha publicado, pero aquí hay una solución paralela cruda usando JDK 7 y su nueva API fork / join:
Descargo de responsabilidad importante : tomé la adaptación de clasificación rápida para fork / join de: https://github.com/pmbauer/parallel/tree/master/src/main/java/pmbauer/parallel
Para ejecutar esto, necesita una versión beta de JDK 7 (http://jdk7.java.net/download.html).
En mi 2.93Ghz Quad core i7 (OS X):
Referencia de Python
Java JDK 7 fork / join
También intenté experimentar un poco con la lectura paralela y convertir los bytes a dobles, pero no vi ninguna diferencia allí.
Actualizar:
Si alguien quiere experimentar con la carga paralela de los datos, la versión de carga paralela está a continuación. En teoría, esto podría hacerlo ir un poco más rápido aún, si su dispositivo IO tiene suficiente capacidad paralela (los SSD generalmente lo hacen). También hay algo de sobrecarga en la creación de Dobles a partir de bytes, por lo que también podría ir más rápido en paralelo. En mis sistemas (Ubuntu 10.10 / Nehalem Quad / Intel X25M SSD y OS X 10.6 / i7 Quad / Samsung SSD) no vi ninguna diferencia real.
Actualización2:
Ejecuté el código en una de nuestras máquinas de desarrollo de 12 núcleos con una ligera modificación para establecer una cantidad fija de núcleos. Esto dio los siguientes resultados:
En este sistema también probé la versión de Python que tomó 1m2.994s y la versión C ++ de Zjarek que tomó 1.925s (por alguna razón, la versión C ++ de Zjarek parece correr relativamente más rápido en la computadora static_rtti).
También probé lo que sucedió si duplicaba el tamaño del archivo a 100,000,000 de dobles:
En este caso, la versión C ++ de Zjarek tomó 3.968s. Python solo tardó demasiado aquí.
150,000,000 de dobles:
En este caso, la versión C ++ de Zjarek fue 6.044s. Ni siquiera intenté Python.
La versión C ++ es muy consistente con sus resultados, donde Java se balancea un poco. Primero se vuelve un poco más eficiente cuando el problema se agrava, pero luego vuelve a ser menos eficiente.
fuente
Una versión con pthreads tradicionales. Código de fusión copiado de la respuesta de Guvante. Compilar con
g++ -O3 -pthread
.En mi computadora portátil obtengo los siguientes resultados:
fuente
Aquí hay una implementación secuencial de C99 que intenta realmente hacer uso de la distribución conocida. Básicamente, realiza una sola ronda de clasificación de cubetas utilizando la información de distribución, luego unas pocas rondas de clasificación rápida en cada cubeta suponiendo una distribución uniforme dentro de los límites de la cubeta y, finalmente, una clasificación de selección modificada para copiar los datos de nuevo al búfer original. La clasificación rápida memoriza los puntos divididos, por lo que la clasificación por selección solo necesita operar en pequeños pedazos. Y a pesar (¿porque?) De toda esa complejidad, ni siquiera es realmente rápido.
Para hacer que la evaluación sea rápida, los valores se muestrean en unos pocos puntos y luego solo se utiliza la interpolación lineal. En realidad, no importa si Φ se evalúa exactamente, siempre y cuando la aproximación sea estrictamente monotónica.
Los tamaños de los contenedores se eligen de manera que la posibilidad de un desbordamiento del contenedor sea insignificante. Más precisamente, con los parámetros actuales, la posibilidad de que un conjunto de datos de 50000000 elementos cause un desbordamiento de bin es 3.65e-09. (Esto se puede calcular utilizando la función de supervivencia de la distribución de Poisson ).
Para compilar, utilice
Como hay mucho más cálculo que en las otras soluciones, estos indicadores de compilación son necesarios para que sea al menos razonablemente rápido. Sin
-msse3
las conversiones dedouble
aint
convertirse muy lento. Si su arquitectura no es compatible con SSE3, estas conversiones también se pueden hacer usando lalrint()
función.El código es bastante feo, no estoy seguro si cumple con el requisito de ser "razonablemente legible" ...
fuente
Esto usa erf () para colocar cada elemento apropiadamente en un bin, luego ordena cada bin. Mantiene la matriz completamente en su lugar.
Primer paso: docensus () cuenta el número de elementos en cada contenedor.
Segunda pasada: la partición () permuta la matriz, colocando cada elemento en su contenedor apropiado
Tercer paso: sortbins () realiza un qsort en cada bin.
Es un poco ingenuo, y llama a la costosa función erf () dos veces por cada valor. El primer y tercer pases son potencialmente paralelizables. El segundo es altamente serial y probablemente se ralentiza por sus patrones de acceso a memoria altamente aleatorios. También podría valer la pena almacenar en caché el número de bin de cada doble, dependiendo de la relación de potencia de la CPU a la velocidad de la memoria.
Este programa le permite elegir la cantidad de contenedores que usará. Simplemente agregue un segundo número a la línea de comando. Lo compilé con gcc -O3, pero mi máquina es tan débil que no puedo decirle ningún buen número de rendimiento.
Editar: Poof! Mi programa C se ha transformado mágicamente en un programa C ++ usando std :: sort!
fuente
Eche un vistazo a la implementación de clasificación de radix por Michael Herf ( Radix Tricks ). En mi máquina, la clasificación fue 5 veces más rápida en comparación con el
std::sort
algoritmo de mi primera respuesta. El nombre de la función de clasificación esRadixSort11
.fuente