Perché l'elaborazione di una matrice ordinata è più lenta di una matrice non ordinata?


233

Ho un elenco di 500000 Tuple<long,long,string>oggetti generati casualmente su cui sto eseguendo una semplice ricerca "tra":

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

Quando generi il mio array casuale ed eseguo la mia ricerca di 100 valori generati casualmente di x, le ricerche si completano in circa quattro secondi. Conoscendo le grandi meraviglie che l'ordinamento fa alla ricerca , tuttavia, ho deciso di ordinare i miei dati - prima per Item1, poi per Item2, e infine per Item3- prima di eseguire le mie 100 ricerche. Mi aspettavo che la versione ordinata avrebbe funzionato un po 'più velocemente a causa della previsione del ramo: il mio pensiero è stato che una volta arrivati ​​al punto in cui Item1 == x, tutti gli ulteriori controlli t.Item1 <= xprevedessero il ramo correttamente come "no take", accelerando la porzione di coda del ricerca. Con mia grande sorpresa, le ricerche hanno impiegato il doppio del tempo su un array ordinato !

Ho provato a cambiare l'ordine in cui ho eseguito i miei esperimenti e ho usato seme differenti per il generatore di numeri casuali, ma l'effetto è stato lo stesso: le ricerche in un array non ordinato sono state quasi due volte più veloci delle ricerche nello stesso array, ma smistato!

Qualcuno ha una buona spiegazione di questo strano effetto? Segue il codice sorgente dei miei test; Sto 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)

15
A causa della previsione del ramo: p
Soner Gönül,

8
@jalf Mi aspettavo che la versione ordinata funzionasse un po 'più veloce a causa della previsione del ramo. Pensavo che, una volta arrivati ​​al punto in cui Item1 == x, tutti gli ulteriori controlli t.Item1 <= xprevedessero correttamente il ramo come "no take", accelerando la parte di coda della ricerca. Ovviamente, questa linea di pensiero è stata smentita dalla dura realtà :)
dasblinkenlight,

1
@ChrisSinclair buona osservazione! Ho aggiunto una spiegazione nella mia risposta.
usr

39
Questa domanda NON è un duplicato di una domanda esistente qui. Non votare per chiuderlo come uno.
ThiefMaster il

2
@ Sar009 Niente affatto! Le due domande considerano due scenari molto diversi, arrivando naturalmente a risultati diversi.
dasblinkenlight,

Risposte:


269

Quando si utilizza l'elenco non ordinato, si accede a tutte le tuple in ordine di memoria . Sono stati allocati consecutivamente nella RAM. Le CPU adorano accedere alla memoria in sequenza perché possono richiedere speculativamente la riga di cache successiva, in modo che sia sempre presente quando necessario.

Quando si ordina l'elenco, lo si mette in ordine casuale perché le chiavi di ordinamento vengono generate casualmente. Ciò significa che gli accessi alla memoria ai membri delle tuple sono imprevedibili. La CPU non può precaricare la memoria e quasi ogni accesso a una tupla è un errore nella cache.

Questo è un bell'esempio per un vantaggio specifico della gestione della memoria GC : le strutture di dati che sono state allocate insieme e utilizzate insieme funzionano molto bene. Hanno un'ottima località di riferimento .

In questo caso, la penalità mancata dalla cache supera la penalità di previsione del ramo salvata .

Prova a passare a struct-tupla. Ciò ripristinerà le prestazioni poiché non è necessario che si verifichi alcuna dereferenza del puntatore durante l'esecuzione per accedere ai membri delle tuple.

Chris Sinclair osserva nei commenti che "per TotalCount circa 10.000 o meno, la versione ordinata funziona più velocemente ". Questo perché un piccolo elenco si adatta interamente alla cache della CPU . Gli accessi alla memoria potrebbero essere imprevedibili ma la destinazione è sempre nella cache. Credo che ci sia ancora una piccola penalità perché anche un carico dalla cache richiede alcuni cicli. Ma questo non sembra essere un problema perché la CPU è in grado di destreggiarsi tra più carichi eccezionali , aumentando così la produttività. Ogni volta che la CPU colpisce un'attesa di memoria, continuerà comunque ad avanzare nel flusso di istruzioni per mettere in coda quante più operazioni di memoria possibile. Questa tecnica viene utilizzata per nascondere la latenza.

Questo tipo di comportamento mostra quanto sia difficile prevedere le prestazioni su CPU moderne. Il fatto che siamo solo 2 volte più lenti quando si passa dall'accesso alla memoria sequenziale a quello casuale, mi dice quanto succede sotto le copertine per nascondere la latenza della memoria. Un accesso alla memoria può arrestare la CPU per 50-200 cicli. Dato che il numero uno potrebbe aspettarsi che il programma diventi> 10 volte più lento quando si introducono accessi casuali alla memoria.


5
Un buon motivo per cui tutto ciò che impari in C / C ++ non si applica alla lettera in un linguaggio come C #!
user541686

37
È possibile confermare questo comportamento copiando manualmente i dati ordinati in new List<Tuple<long,long,string>>(500000)uno a uno prima di testare quel nuovo elenco. In questo scenario, il test ordinato è veloce quanto quello non ordinato, che corrisponde al ragionamento su questa risposta.
Bobson,

3
Eccellente, grazie mille! Ho realizzato una Tuplestruttura equivalente e il programma ha iniziato a comportarsi come previsto: la versione ordinata era un po 'più veloce. Inoltre, la versione non ordinata è diventata due volte più veloce! Quindi i numeri con structsono 2s non ordinati e 1.9s ordinati.
dasblinkenlight,

2
Quindi possiamo dedurre da ciò che cache-miss fa più male di una cattiva previsione del ramo? Penso di sì, e l'ho sempre pensato. In C ++, std::vectorquasi sempre funziona meglio di std::list.
Nawaz,

3
@Mehrdad: No. Questo vale anche per C ++. Anche in C ++, le strutture di dati compatte sono veloci. Evitare cache-miss è importante in C ++ come in qualsiasi altra lingua. std::vectorvs std::listè un buon esempio.
Nawaz,

4

LINQ non sa se la tua lista è ordinata o meno.

Poiché Count con parametro predicato è il metodo di estensione per tutti gli IEnumerables, penso che non sappia nemmeno se è in esecuzione sulla raccolta con un accesso casuale efficiente. Quindi, controlla semplicemente ogni elemento e Usr ha spiegato perché le prestazioni sono diminuite.

Per sfruttare i vantaggi in termini di prestazioni dell'array ordinato (come la ricerca binaria), dovrai fare un po 'più di codifica.


5
Penso che tu abbia frainteso la domanda: ovviamente non lo speravo Counto Whereavrei "in qualche modo" raccolto l'idea che i miei dati fossero ordinati, ed eseguissi una ricerca binaria invece di una semplice ricerca "controlla tutto". Tutto quello che speravo era un miglioramento dovuto alla migliore previsione del ramo (vedi il link all'interno della mia domanda), ma a quanto pare, la località di riferimento batte la previsione del ramo alla grande.
dasblinkenlight,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.