¿Por qué procesar una matriz ordenada es más lento que una matriz sin clasificar?


Tengo una lista de 500000 objetos generados aleatoriamente Tuple<long,long,string> en los que estoy realizando una simple búsqueda" entre":

var data = new List<Tuple<long,long,string>>(500000);
...
var cnt = data.Count(t => t.Item1 <= x && t.Item2 >= x);

Cuando genero mi matriz aleatoria y corro mi búsqueda de 100 valores generados aleatoriamente de x, las búsquedas se completan en unos cuatro segundos. Sabiendo de las grandes maravillas que la ordenación hace a la búsqueda , sin embargo, decidí ordenar mis datos - primero por Item1, luego por Item2, y finalmente por Item3 - antes de ejecutar mis 100 búsquedas. Me esperaba el ordenado versión para realizar un poco más rápido debido a la predicción de ramas: mi pensamiento ha sido que una vez que lleguemos al punto donde Item1 == x, todas las comprobaciones adicionales de t.Item1 <= x predecirían la rama correctamente como "no toma", acelerando la porción de cola de la búsqueda. Para mi sorpresa, las búsquedas tomaron el doble de tiempo en una matriz ordenada!

Intenté cambiar el orden en el que ejecuté mis experimentos, y usé diferentes semillas para el generador de números aleatorios, pero el efecto ha sido el lo mismo: las búsquedas en una matriz sin clasificar se ejecutaron casi el doble de rápido que las búsquedas en la misma matriz, ¡pero ordenadas!

¿alguien tiene una buena explicación de este extraño efecto? El código fuente de mis pruebas sigue; estoy usando. NET 4.0.


private const int TotalCount = 500000;
private const int TotalQueries = 100;
private static long NextLong(Random r) {
    var data = new byte[8];
    r.NextBytes(data);
    return BitConverter.ToInt64(data, 0);
}
private class TupleComparer : IComparer<Tuple<long,long,string>> {
    public int Compare(Tuple<long,long,string> x, Tuple<long,long,string> y) {
        var res = x.Item1.CompareTo(y.Item1);
        if (res != 0) return res;
        res = x.Item2.CompareTo(y.Item2);
        return (res != 0) ? res : String.CompareOrdinal(x.Item3, y.Item3);
    }
}
static void Test(bool doSort) {
    var data = new List<Tuple<long,long,string>>(TotalCount);
    var random = new Random(1000000007);
    var sw = new Stopwatch();
    sw.Start();
    for (var i = 0 ; i != TotalCount ; i++) {
        var a = NextLong(random);
        var b = NextLong(random);
        if (a > b) {
            var tmp = a;
            a = b;
            b = tmp;
        }
        var s = string.Format("{0}-{1}", a, b);
        data.Add(Tuple.Create(a, b, s));
    }
    sw.Stop();
    if (doSort) {
        data.Sort(new TupleComparer());
    }
    Console.WriteLine("Populated in {0}", sw.Elapsed);
    sw.Reset();
    var total = 0L;
    sw.Start();
    for (var i = 0 ; i != TotalQueries ; i++) {
        var x = NextLong(random);
        var cnt = data.Count(t => t.Item1 <= x && t.Item2 >= x);
        total += cnt;
    }
    sw.Stop();
    Console.WriteLine("Found {0} matches in {1} ({2})", total, sw.Elapsed, doSort ? "Sorted" : "Unsorted");
}
static void Main() {
    Test(false);
    Test(true);
    Test(false);
    Test(true);
}

Populated in 00:00:01.3176257
Found 15614281 matches in 00:00:04.2463478 (Unsorted)
Populated in 00:00:01.3345087
Found 15614281 matches in 00:00:08.5393730 (Sorted)
Populated in 00:00:01.3665681
Found 15614281 matches in 00:00:04.1796578 (Unsorted)
Populated in 00:00:01.3326378
Found 15614281 matches in 00:00:08.6027886 (Sorted)
Author: Community, 2012-12-24

2 answers

Cuando se utiliza la lista sin clasificar, se accede a todas las tuplas en orden de memoria. Se han asignado consecutivamente en la GEA. A las CPU les encanta acceder a la memoria secuencialmente porque pueden solicitar especulativamente la siguiente línea de caché para que siempre esté presente cuando sea necesario.

Cuando está ordenando la lista, la coloca en orden aleatorio porque sus claves de ordenación se generan aleatoriamente. Esto significa que los accesos de memoria a los miembros de la tupla son impredecibles. La CPU no puede la memoria de prefetch y casi cada acceso a una tupla es un error de caché.

Este es un buen ejemplo para una ventaja específica de GC memory management: las estructuras de datos que se han asignado juntas y se utilizan juntas funcionan muy bien. Tienen una gran localidad de referencia.

La penalización por error de caché supera la penalización por predicción de rama guardada en este caso.

Intenta cambiar a una struct-tupla. Esto restaurará el rendimiento porque no es necesario que se produzca ninguna desreferencia de puntero en tiempo de ejecución para acceder a los miembros de la tupla.

Chris Sinclair señala en los comentarios que "para totalCount alrededor de 10,000 o menos, la versión ordenada funciona más rápido ". Esto se debe a que una pequeña lista encaja completamente en la caché de la CPU. Los accesos a la memoria pueden ser impredecibles, pero el destino siempre está en caché. Creo que todavía hay una pequeña penalización porque incluso una carga de caché toma algunos ciclos. Pero eso no parece ser un problema porque la CPU puede hacer malabares con múltiples cargas pendientes, aumentando así el rendimiento. Siempre que la CPU llegue a una espera de memoria, seguirá avanzando en el flujo de instrucciones para poner en cola tantas operaciones de memoria como pueda. Esta técnica se utiliza para ocultar la latencia.

Este tipo de comportamiento muestra lo difícil que es predecir el rendimiento en las CPU modernas. El hecho de que somos solo 2 veces más lento cuando vamos de acceso secuencial a memoria aleatoria me dice cuánto está pasando bajo las cubiertas para ocultar la latencia de la memoria. Un acceso a la memoria puede detener la CPU durante 50-200 ciclos. Dado que el número uno podría esperar que el programa se vuelva >10 veces más lento al introducir accesos de memoria aleatorios.

 259
Author: usr,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2012-12-24 17:58:47

LINQ no sabe si tu lista está ordenada o no.

Dado que el parámetro Count with predicate es el método de extensión para todos losEnumerables, creo que ni siquiera sabe si se está ejecutando sobre la colección con acceso aleatorio eficiente. Por lo tanto, simplemente comprueba cada elemento y Usr explicó por qué el rendimiento se redujo.

Para aprovechar los beneficios de rendimiento de la matriz ordenada (como la búsqueda binaria), tendrá que hacer un poco más de codificación.

 3
Author: Emperor Orionii,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2012-12-25 15:43:35