Pourquoi le traitement d'un tableau trié plus lent qu'un tableau non trié?

J'ai une liste de 500000 objets Tuple<long,long,string> générés aléatoirement sur lesquels j'effectue une simple recherche "entre":

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

Lorsque je génère mon tableau aléatoire et lance ma recherche pour 100 valeurs générées aléatoirement de x, les recherches se terminent en environ quatre secondes. Connaissant les grandes merveilles que le tri fait à la recherche , cependant, j'ai décidé de trier mes données - d'abord par Item1, puis par Item2, et enfin par Item3 - avant d'exécuter mes 100 recherches. Je m'attendais à ce que le trié version pour effectuer un peu plus vite à cause de la prédiction de la branche: ma pensée a été qu'une fois que nous arrivons au point où Item1 == x, toutes les vérifications supplémentaires de t.Item1 <= x prédiraient la branche correctement comme "pas de prise", accélérant la partie arrière de la recherche. À ma grande surprise, les recherches ont pris deux fois plus de temps sur un tableau trié !

J'ai essayé de changer l'ordre dans lequel j'ai couru mes expériences, et utilisé une graine différente pour le générateur de nombres aléatoires, mais l'effet a été le idem: les recherches dans un tableau non trié ont été presque deux fois plus rapides que les recherches dans le même tableau, mais triées!

Quelqu'un a-t-il une bonne explication de cet effet étrange? Le code source de mes tests suit; j'utilise. 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)
226
demandé sur Community 2012-12-24 21:09:49

2 réponses

Lorsque vous utilisez la liste non triée tous les tuples sont accessibles dans mémoire-commande. Ils ont été attribués consécutivement en RAM. Les processeurs aiment accéder à la mémoire séquentiellement car ils peuvent demander spéculativement la ligne de cache suivante afin qu'elle soit toujours présente en cas de besoin.

Lorsque vous triez la liste, vous la placez dans ordre aléatoire car vos clés de tri sont générées aléatoirement. Cela signifie que les accès à la mémoire des membres du tuple sont imprévisibles. Le processeur ne peut pas la mémoire prefetch et presque chaque accès à un tuple est un manque de cache.

Ceci est un bel exemple pour un avantage spécifique de GC memory management : les structures de données qui ont été allouées ensemble et sont utilisées ensemble fonctionnent très bien. Ils ont une grande localité de référence.

La pénalité de cache manque l'emporte sur la pénalité de prédiction de branche enregistrée dans ce cas.

Essayez de passer à un struct-n-uplet. Cela rétablira les performances car aucun déréférencement de pointeur ne doit se produire au moment de l'exécution pour accéder aux membres du tuple.

Chris Sinclair note dans les commentaires que " pour un total d'environ 10 000 ou moins, la version triée fonctionne plus rapidement ". En effet, une petite liste s'intègre entièrement dans le cache du processeur. Les accès à la mémoire peuvent être imprévisibles, mais la cible est toujours en cache. Je crois qu'il y a encore une petite pénalité car même une charge du cache prend quelques cycles. Mais cela ne semble pas être un problème parce que le processeur peut jongler avec plusieurs charges exceptionnelles , augmentant ainsi le débit. Chaque fois que le processeur frappe une attente pour la mémoire, il accélérera toujours dans le flux d'instructions pour mettre en file d'attente autant d'opérations de mémoire que possible. Cette technique est utilisée pour masquer la latence.

Ce type de comportement montre à quel point il est difficile de prédire les performances sur les processeurs modernes. Le fait que nous ne soyons que 2 fois plus lents en passant d'un accès mémoire séquentiel à un accès mémoire aléatoire me dit combien il se passe sous les couvertures pour masquer la latence de la mémoire. Un accès à la mémoire peut bloquer le CPU pour 50-200 cycles. Étant donné que le numéro un pourrait s'attendre à ce que le programme devienne > 10x plus lent lors de l'introduction d'accès mémoire aléatoires.

259
répondu usr 2012-12-24 17:58:47

LINQ ne sait pas si votre liste est triée ou non.

Puisque Count avec le paramètre predicate est une méthode d'extension pour tous les IEnumerables, je pense qu'il ne sait même pas s'il s'exécute sur la collection avec un accès aléatoire efficace. Donc, il vérifie simplement chaque élément et Usr a expliqué pourquoi les performances ont diminué.

Pour exploiter les avantages de performance du tableau trié (comme la recherche binaire), vous devrez faire un peu plus de codage.

3
répondu Emperor Orionii 2012-12-25 15:43:35