2012-03-04 12 views
15

Implementé recientemente un algoritmo QuickSort en C#. Clasificando en una matriz de enteros que contiene millones de elementos, el rendimiento del código es aproximadamente 10% detrás de la implementación de .NET.Costo de métodos de alineación en C#

private static void QS(int[] arr, int left, int right) 
{ 
    if (left >= right) return; 

    var pIndex = Partition(arr, left, right); 
    QS(arr, left, pIndex); 
    QS(arr, pIndex + 1, right); 
} 

en una serie de 5 millones de artículos, este código es de aproximadamente 60 ms lento que .NET de.

Posteriormente, he creado un otro método que tiene el método Partition() inline en QS() (eliminando la llamada al método y la declaración return). Sin embargo, esto dio como resultado un descenso en el rendimiento a aproximadamente 250 ms más lento que el método de ordenación de .NET.

¿Por qué sucede esto?

Editar: Este es el código del método Partition(). En la versión impresa de QS(), todo el contenido de este método con la excepción de la declaración return reemplaza la línea var pIndex = Partition(arr, left, right);.

private static int Partition(int[] arr, int left, int right) 
{ 
    int pivot = arr[left]; 
    int leftPoint = left - 1; 
    int pIndex = right + 1; 
    int temp = 0; 

    while (true) 
    { 
     do { pIndex--; } while (arr[pIndex] > pivot); 
     do { leftPoint++; } while (arr[leftPoint] < pivot); 

     if (leftPoint < pIndex) 
     { 
      temp = arr[leftPoint]; 
      arr[leftPoint] = arr[pIndex]; 
      arr[pIndex] = temp; 
     } 
     else { break; } 
    } 
    return pIndex; 
} 

Edición # 2: En caso de que alguien está interesado en la compilación, aquí está el código que llama a los algoritmos:

Edición # 3: nuevo código de prueba de la sugerencia de Haymo.

private static void Main(string[] args) 
{ 
    const int globalRuns = 10; 
    const int localRuns = 1000; 

    var source = Enumerable.Range(1, 200000).OrderBy(n => Guid.NewGuid()).ToArray(); 
    var a = new int[source.Length]; 

    int start, end, total; 

    for (int z = 0; z < globalRuns; z++) 
    { 
     Console.WriteLine("Run #{0}", z+1); 

     total = 0; 
     for (int i = 0; i < localRuns; i++) 
     { 
      Array.Copy(source, a, source.Length); 
      start = Environment.TickCount; 
      Array.Sort(a); 
      end = Environment.TickCount; 
      total += end - start; 
     } 
     Console.WriteLine("{0}\t\tTtl: {1}ms\tAvg: {2}ms", ".NET", total, total/localRuns); 

     total = 0; 
     for (int i = 0; i < localRuns; i++) 
     { 
      Array.Copy(source, a, source.Length); 
      start = Environment.TickCount; 
      Quicksort.SortInline(a); 
      end = Environment.TickCount; 
      total += end - start; 
     } 
     Console.WriteLine("{0}\t\tTtl: {1}ms\tAvg: {2}ms", "Inlined", total, total/localRuns); 

     total = 0; 
     for (int i = 0; i < localRuns; i++) 
     { 
      Array.Copy(source, a, source.Length); 
      start = Environment.TickCount; 
      Quicksort.SortNonInline(a); 
      end = Environment.TickCount; 
      total += end - start; 
     } 
     Console.WriteLine("{0}\tTtl: {1}ms\tAvg: {2}ms\n", "Not inlined", total, total/localRuns); 
    } 
} 
+0

¿Puede proporcionar un ejemplo compilable? – CodesInChaos

+0

¿Podría publicar el código completo que utiliza, incluido el código que usa para medir la hora? – svick

+2

El optimizador just-in-time ya incluye métodos. Puede tomar mejores decisiones que usted, en realidad sabe cómo es el código de la máquina y puede juzgar si la alineación es realmente una optimización o simplemente lleva a la saturación del código. Una optimización real hace que el código sea más inteligente. La optimización de QS está bien documentada, basta con mirar el artículo de Wikipedia para empezar. –

Respuesta

13

Sobre la base de la información dada en la pregunta, uno sólo puede adivinar el nombre y algunas ideas.

¿Mide correctamente? Tenga en cuenta, a fin de obtener los resultados de rendimiento fiable, se debe (al menos)

  • liberación Run builds sin depurador asociado
  • Repita la prueba con suficiente frecuencia y promediar los resultados
  • Hacer la feria de prueba, es decir. dar toda prueba somete la misma 'configuración ressource'

Con el fin de asegurarse de que, la fuente de la (supuesta) caída de rendimiento es realmente conectado para funcionar inlining uno puede investigar el código IL generado. O incluso mejor: las instrucciones de la máquina generadas por el compilador JIT.

Para ILNumerics implementamos una ordenación rápida personalizada e hicimos muchas medidas de rendimiento. El algoritmo final es varias veces más rápido que la versión CLR. La inclusión manual es solo una mejora, que fue necesaria para un mejor rendimiento.Otros son:

  • No usar recursión
  • Uso de comparación inseguro/intercambio de
  • Uso de ordenación por inserción para particiones más pequeñas
  • Optimzing para el tamaño limitado de la (sustitución pila) temporal array

Muy a menudo, la fuente de resultados de rendimiento extraños se encuentra en el camino, la memoria es (mal) utilizada por un algoritmo. Otro podría residir en el flujo de instrucción diferente que, con el tiempo, tendrá más o menos éxito en ser optimizado por cualquier compilador/procesador involucrado. El rendimiento general de ejecución es una bestia muy compleja, difícil de adivinar determinísticamente y, por lo tanto, ¡un perfilador es tu mejor amigo!

@Editar: Al observar su rutina de prueba principal, parece que está midiendo principalmente el ancho de banda de memoria de su procesador/memoria principal. Una matriz int [] de longitud 5 * 10e6 tiene aproximadamente 19 MB de tamaño. Esto probablemente está fuera del alcance de sus cachés. Por lo tanto, el procesador esperará la memoria debido al caché obligatorio que falta la mayor parte del tiempo. Esto hace que sea difícil adivinar la influencia de cualquier reformulación de código. Sugiero a tratar de medir la manera siguiente: (pseudo código)

  • generar datos de prueba
  • asignar matriz para copias
  • iterar sobre el número de repeticiones globales (por no decir 10)

    • repeticiones internas para Array.Sort (por ejemplo. 1000)
      • copiar los datos de prueba (sin clasificar)
      • tipo la copia por Array.Sort
      • añadir tiempo
    • tiempo promedio para Array.Sort

    • repeticiones internas para Quicksort.Sort (por ejemplo. 1000)

      • copia (sin clasificar) los datos de prueba
      • Ordenar la copia por Quicksort.Sort
      • agregar tiempo
    • tiempo promedio para Quicksort.Sort

    • repeticiones internas para Quicksort.Sort2 (por ejemplo, 1000)

      • copiar los (sin clasificar) los datos de prueba
      • Ordenar la copia por la ordenación rápida.Sort2
      • añadir tiempo
    • tiempo promedio para Quicksort.Sort2

El objetivo es hacer que el quicksort sólo utilizan los datos de las cachés. Por lo tanto, asegúrese de no volver a crear las copias de la memoria nueva, sino tener solo dos instancias globales: el original y la copia para ordenar. ¡Ambos deben caber en tu caché (de último nivel) al mismo tiempo! Con algo de margen (para otros procesos en el sistema), una buena suposición es usar solo la mitad del tamaño de caché de último nivel disponible para ambas matrices. Dependiendo de su verdadero tamaño de caché, una longitud de prueba de 250k parece más razonable.

@ Edit2: Ejecuté su código, obtuve los mismos resultados y observé las instrucciones (optimizadas) de la máquina en el depurador VS. Aquí viene la parte pertinente de las dos versiones:

Not inlined: 
    69:     do { pIndex--; } while (arr[pIndex] > pivot); 
00000017 dec   ebx 
00000018 cmp   ebx,esi 
0000001a jae   00000053 
0000001c cmp   dword ptr [ecx+ebx*4+8],edi 
00000020 jg   00000017 
    70:     do { leftPoint++; } while (arr[leftPoint] < pivot); 
00000022 inc   edx 
00000023 cmp   edx,esi 
00000025 jae   00000053 
00000027 cmp   dword ptr [ecx+edx*4+8],edi 
0000002b jl   00000022 


Inlined: 
    97:     do { pIndex--; } while (arr[pIndex] > pivot); 
00000038 dec   dword ptr [ebp-14h] 
0000003b mov   eax,dword ptr [ebp-14h] 
0000003e cmp   eax,edi 
00000040 jae   00000097 
00000042 cmp   dword ptr [esi+eax*4+8],ebx 
00000046 jg   00000038 
    98:     do { leftPoint++; } while (arr[leftPoint] < pivot); 
00000048 inc   ecx 
00000049 cmp   ecx,edi 
0000004b jae   00000097 
0000004d cmp   dword ptr [esi+ecx*4+8],ebx 
00000051 jl   00000048 

Como se puede observar, el 'No inline' versión no utilizar mejor los registros encontrados para decrementar el índice consecutivo (la línea 69/97). Obviamente, el JIT decidió no presionar y mostrar el registro correspondiente en la pila, debido a que el otro código en la misma función está haciendo uso del mismo registro. Como se trata de un bucle en caliente (y el CLR no lo reconoce), la velocidad de ejecución general sufre. Entonces, en este caso específico, la función de partición manual no es rentable.

Sin embargo, como usted sabe, no hay garantía de que otras versiones del CLR hagan lo mismo. Las diferencias pueden surgir incluso para 64 bit.

+0

Gracias, continuaré jugando con el algoritmo. Sin embargo, intenté obtener resultados confiables al hacer las tres sugerencias. He adjuntado el código del programa principal si está interesado en compilarlo. – Raintree

+0

+1 fuiste (otra vez) más rápido ... argh;) –

+0

@PaulWendler Lo siento –

Cuestiones relacionadas