Respondiendo a otra pregunta de Stack Overflow ( esta ), me topé con un subproblema interesante. ¿Cuál es la forma más rápida de ordenar una matriz de 6 enteros?
Como la pregunta es de muy bajo nivel:
- no podemos asumir que las bibliotecas están disponibles (y la llamada en sí tiene su costo), solo C simple
- Para evitar el vaciado de la tubería de instrucciones (que tiene un costo muy alto), probablemente deberíamos minimizar las ramas, los saltos y cualquier otro tipo de interrupción del flujo de control (como los ocultos detrás de los puntos de secuencia en
&&
o||
). - el espacio es limitado y la minimización de los registros y el uso de la memoria es un problema, idealmente en el lugar es probablemente el mejor.
Realmente esta pregunta es un tipo de Golf donde el objetivo no es minimizar la longitud de la fuente sino el tiempo de ejecución. Lo llamo código 'Zening' como se usa en el título del libro Zen of Code optimization de Michael Abrash y sus secuelas .
En cuanto a por qué es interesante, hay varias capas:
- el ejemplo es simple y fácil de entender y medir, no involucra mucha habilidad C
- muestra los efectos de elección de un buen algoritmo para el problema, pero también los efectos del compilador y el hardware subyacente.
Aquí está mi implementación de referencia (ingenua, no optimizada) y mi conjunto de pruebas.
#include <stdio.h>
static __inline__ int sort6(int * d){
char j, i, imin;
int tmp;
for (j = 0 ; j < 5 ; j++){
imin = j;
for (i = j + 1; i < 6 ; i++){
if (d[i] < d[imin]){
imin = i;
}
}
tmp = d[j];
d[j] = d[imin];
d[imin] = tmp;
}
}
static __inline__ unsigned long long rdtsc(void)
{
unsigned long long int x;
__asm__ volatile (".byte 0x0f, 0x31" : "=A" (x));
return x;
}
int main(int argc, char ** argv){
int i;
int d[6][5] = {
{1, 2, 3, 4, 5, 6},
{6, 5, 4, 3, 2, 1},
{100, 2, 300, 4, 500, 6},
{100, 2, 3, 4, 500, 6},
{1, 200, 3, 4, 5, 600},
{1, 1, 2, 1, 2, 1}
};
unsigned long long cycles = rdtsc();
for (i = 0; i < 6 ; i++){
sort6(d[i]);
/*
* printf("d%d : %d %d %d %d %d %d\n", i,
* d[i][0], d[i][6], d[i][7],
* d[i][8], d[i][9], d[i][10]);
*/
}
cycles = rdtsc() - cycles;
printf("Time is %d\n", (unsigned)cycles);
}
Resultados crudos
A medida que aumenta el número de variantes, las reuní todas en un conjunto de pruebas que se puede encontrar aquí . Las pruebas reales utilizadas son un poco menos ingenuas que las mostradas anteriormente, gracias a Kevin Stock. Puede compilarlo y ejecutarlo en su propio entorno. Estoy bastante interesado por el comportamiento en diferentes compilaciones / arquitectura de destino. (OK chicos, pónganlo en respuestas, haré +1 en cada contribuyente de un nuevo conjunto de resultados).
Le di la respuesta a Daniel Stutzbach (para jugar al golf) hace un año, ya que estaba en la fuente de la solución más rápida en ese momento (redes de clasificación).
Linux 64 bits, gcc 4.6.1 64 bits, Intel Core 2 Duo E8400, -O2
- Llamada directa a la función de biblioteca qsort: 689.38
- Implementación ingenua (tipo de inserción): 285.70
- Tipo de inserción (Daniel Stutzbach): 142.12
- Tipo de inserción desenrollado: 125.47
- Orden de rango: 102.26
- Orden de clasificación con registros: 58.03
- Redes de clasificación (Daniel Stutzbach): 111.68
- Redes de clasificación (Paul R): 66.36
- Clasificación de redes 12 con intercambio rápido: 58.86
- Ordenar redes 12 Reordenado Swap: 53.74
- Sorting Networks 12 reordenado Simple Swap: 31.54
- Red de clasificación reordenada con intercambio rápido: 31.54
- Red de clasificación reordenada con intercambio rápido V2: 33.63
- Clasificación de burbujas en línea (Paolo Bonzini): 48.85
- Tipo de inserción desenrollada (Paolo Bonzini): 75.30
Linux 64 bits, gcc 4.6.1 64 bits, Intel Core 2 Duo E8400, -O1
- Llamada directa a la función de biblioteca qsort: 705.93
- Implementación ingenua (tipo de inserción): 135,60
- Tipo de inserción (Daniel Stutzbach): 142.11
- Tipo de inserción desenrollado: 126.75
- Orden de rango: 46.42
- Orden de clasificación con registros: 43.58
- Redes de clasificación (Daniel Stutzbach): 115.57
- Redes de clasificación (Paul R): 64.44
- Clasificación de redes 12 con intercambio rápido: 61,98
- Ordenar redes 12 Reordenado Swap: 54.67
- Sorting Networks 12 reordenado Simple Swap: 31.54
- Red de clasificación reordenada con intercambio rápido: 31.24
- Red de clasificación reordenada con intercambio rápido V2: 33.07
- Clasificación de burbujas en línea (Paolo Bonzini): 45,79
- Tipo de inserción desenrollada (Paolo Bonzini): 80.15
Incluí los resultados de -O1 y -O2 porque, sorprendentemente, para varios programas, O2 es menos eficiente que O1. Me pregunto qué optimización específica tiene este efecto.
Comentarios sobre soluciones propuestas
Tipo de inserción (Daniel Stutzbach)
Como se esperaba, minimizar las ramas es una buena idea.
Redes de clasificación (Daniel Stutzbach)
Mejor que el tipo de inserción. Me preguntaba si el efecto principal no se obtenía al evitar el bucle externo. Lo probé mediante un tipo de inserción desenrollado para verificar y, de hecho, obtenemos aproximadamente las mismas cifras (el código está aquí ).
Redes de clasificación (Paul R)
Lo mejor por mucho. El código real que solía probar está aquí . Todavía no sé por qué es casi dos veces más rápido que la otra implementación de red de clasificación. Paso de parámetros? Max rápido?
Clasificación de redes 12 SWAP con intercambio rápido
Como sugirió Daniel Stutzbach, combiné su red de clasificación de 12 intercambios con un intercambio rápido sin ramificaciones (el código está aquí ). De hecho, es más rápido, el mejor hasta ahora con un pequeño margen (aproximadamente 5%) como se podría esperar con 1 intercambio menos.
También es interesante notar que el intercambio sin ramas parece ser mucho (4 veces) menos eficiente que el simple que usa if en la arquitectura PPC.
Llamar a la biblioteca qsort
Para dar otro punto de referencia, también intenté, como se sugiere, llamar a la biblioteca qsort (el código está aquí ). Como se esperaba, es mucho más lento: de 10 a 30 veces más lento ... como se hizo evidente con el nuevo conjunto de pruebas, el problema principal parece ser la carga inicial de la biblioteca después de la primera llamada, y no se compara tan mal con otros versión. Es solo entre 3 y 20 veces más lento en mi Linux. En algunas arquitecturas utilizadas para pruebas por otros, parece incluso más rápido (realmente estoy sorprendido por eso, ya que la biblioteca qsort usa una API más compleja).
Orden de rango
Rex Kerr propuso otro método completamente diferente: para cada elemento de la matriz, calcule directamente su posición final. Esto es eficiente porque el orden de rango de cómputo no necesita ramificación. El inconveniente de este método es que toma tres veces la cantidad de memoria de la matriz (una copia de la matriz y las variables para almacenar las órdenes de clasificación). Los resultados de rendimiento son muy sorprendentes (e interesantes). En mi arquitectura de referencia con sistema operativo de 32 bits e Intel Core2 Quad E8300, el recuento de ciclos fue ligeramente inferior a 1000 (como ordenar redes con intercambio de ramificación). Pero cuando se compiló y ejecutó en mi caja de 64 bits (Intel Core2 Duo) funcionó mucho mejor: se convirtió en el más rápido hasta ahora. Finalmente descubrí la verdadera razón. Mi caja de 32 bits usa gcc 4.4.1 y mi caja de 64 bits gcc 4.4.
actualización :
Como las cifras publicadas arriba muestran que este efecto aún se mejoró con versiones posteriores de gcc y el orden de clasificación se volvió consistentemente dos veces más rápido que cualquier otra alternativa.
Clasificación de redes 12 con intercambio reordenado
La sorprendente eficacia de la propuesta de Rex Kerr con gcc 4.4.3 me hizo preguntarme: ¿cómo podría un programa con 3 veces más uso de memoria ser más rápido que las redes de clasificación sin ramificaciones? Mi hipótesis era que tenía menos dependencias del tipo lectura después de escritura, lo que permite un mejor uso del planificador de instrucciones superescalar del x86. Eso me dio una idea: reordenar los intercambios para minimizar las dependencias de lectura después de escritura. En pocas palabras: cuando lo hace SWAP(1, 2); SWAP(0, 2);
, debe esperar a que termine el primer intercambio antes de realizar el segundo porque ambos acceden a una celda de memoria común. Cuando lo hace, SWAP(1, 2); SWAP(4, 5);
el procesador puede ejecutar ambos en paralelo. Lo probé y funciona como se esperaba, las redes de clasificación se ejecutan aproximadamente un 10% más rápido.
Clasificación de redes 12 con intercambio simple
Un año después de la publicación original, Steinar H. Gunderson sugirió que no deberíamos intentar burlar al compilador y mantener el código de intercambio simple. De hecho, es una buena idea ya que el código resultante es aproximadamente un 40% más rápido. También propuso un intercambio optimizado a mano utilizando el código de ensamblaje en línea x86 que aún puede ahorrar algunos ciclos más. Lo más sorprendente (dice mucho sobre la psicología del programador) es que hace un año ninguno de los usuarios intentó esa versión de intercambio. El código que solía probar está aquí . Otros sugirieron otras formas de escribir un intercambio rápido en C, pero produce el mismo rendimiento que el simple con un compilador decente.
El "mejor" código es ahora el siguiente:
static inline void sort6_sorting_network_simple_swap(int * d){
#define min(x, y) (x<y?x:y)
#define max(x, y) (x<y?y:x)
#define SWAP(x,y) { const int a = min(d[x], d[y]); \
const int b = max(d[x], d[y]); \
d[x] = a; d[y] = b; }
SWAP(1, 2);
SWAP(4, 5);
SWAP(0, 2);
SWAP(3, 5);
SWAP(0, 1);
SWAP(3, 4);
SWAP(1, 4);
SWAP(0, 3);
SWAP(2, 5);
SWAP(1, 3);
SWAP(2, 4);
SWAP(2, 3);
#undef SWAP
#undef min
#undef max
}
Si creemos que nuestro conjunto de pruebas (y, sí, es bastante pobre, su simple beneficio es ser corto, simple y fácil de entender lo que estamos midiendo), el número promedio de ciclos del código resultante para un tipo es inferior a 40 ciclos ( Se ejecutan 6 pruebas). Eso coloca cada intercambio en un promedio de 4 ciclos. A eso lo llamo asombrosamente rápido. ¿Alguna otra mejora posible?
__asm__ volatile (".byte 0x0f, 0x31; shlq $32, %%rdx; orq %%rdx, %0" : "=a" (x) : : "rdx");
debe a que rdtsc pone la respuesta en EDX: EAX mientras que GCC lo espera en un único registro de 64 bits. Puede ver el error compilando en -O3. También vea a continuación mi comentario a Paul R sobre un SWAP más rápido.
CMP EAX, EBX; SBB EAX, EAX
pondrá 0 o 0xFFFFFFFF EAX
dependiendo de si EAX
es mayor o menor que EBX
, respectivamente. SBB
es "restar con préstamo", la contrapartida de ADC
("agregar con acarreo"); el bit de estado al que se refiere es el bit de acarreo. Por otra parte, recuerdo eso ADC
y SBB
tuve una latencia y un rendimiento terribles en el Pentium 4 vs. ADD
y SUB
, y todavía eran dos veces más lentos en las CPU Core. Desde el 80386 también hay instrucciones de SETcc
almacenamiento CMOVcc
condicional y movimiento condicional, pero también son lentas.
x-y
yx+y
no causará desbordamiento o desbordamiento?