web-dev-qa-db-fra.com

Pourquoi le traitement d'un tableau trié * est-il plus lent * qu'un tableau non trié? (ArrayList.indexOf de Java)

Le titre fait référence à Pourquoi est-il plus rapide de traiter un tableau trié qu'un tableau non trié?

Est-ce aussi un effet de prédiction de branche? Attention: ici le traitement du tableau trié est plus lent !!

Considérez le code suivant:

private static final int LIST_LENGTH = 1000 * 1000;
private static final long SLOW_ITERATION_MILLIS = 1000L * 10L;

@Test
public void testBinarySearch() {
    Random r = new Random(0);
    List<Double> list = new ArrayList<>(LIST_LENGTH);
    for (int i = 0; i < LIST_LENGTH; i++) {
        list.add(r.nextDouble());
    }
    //Collections.sort(list);
    // remove possible artifacts due to the sorting call
    // and rebuild the list from scratch:
    list = new ArrayList<>(list);

    int nIterations = 0;
    long startTime = System.currentTimeMillis();
    do {
        int index = r.nextInt(LIST_LENGTH);
        assertEquals(index, list.indexOf(list.get(index)));
        nIterations++;
    } while (System.currentTimeMillis() < startTime + SLOW_ITERATION_MILLIS);
    long duration = System.currentTimeMillis() - startTime;
    double slowFindsPerSec = (double) nIterations / duration * 1000;
    System.out.println(slowFindsPerSec);

    ...
}

Cela imprime une valeur d'environ 720 sur ma machine.

Maintenant, si j'active l'appel de tri des collections, cette valeur tombe à 142. Pourquoi?!?

Les résultats sont concluants, ils ne changent pas si j'augmente le nombre d'itérations/temps.

La version Java est 1.8.0_71 (Oracle VM, 64 bits), exécutée sous Windows 10, test JUnit dans Eclipse Mars.

[~ # ~] mise à jour [~ # ~]

Semble être lié à l'accès à la mémoire contiguë (objets doubles accédés dans un ordre séquentiel vs dans un ordre aléatoire). L'effet commence à disparaître pour moi pour des longueurs de tableau d'environ 10k et moins.

Merci à assylias d'avoir fourni les résultats :

/**
 * Benchmark                     Mode  Cnt  Score   Error  Units
 * SO35018999.shuffled           avgt   10  8.895 ± 1.534  ms/op
 * SO35018999.sorted             avgt   10  8.093 ± 3.093  ms/op
 * SO35018999.sorted_contiguous  avgt   10  1.665 ± 0.397  ms/op
 * SO35018999.unsorted           avgt   10  2.700 ± 0.302  ms/op
 */
80
user1050755

Cela ressemble à un effet de mise en cache/prélecture.

L'indice est que vous comparez des doubles (objets), pas des doubles (primitifs). Lorsque vous allouez des objets dans un thread, ils sont généralement alloués séquentiellement en mémoire. Ainsi, lorsque indexOf analyse une liste, elle passe par des adresses mémoire séquentielles. C'est bon pour les heuristiques de prélecture du cache CPU.

Mais après avoir trié la liste, vous devez toujours faire le même nombre de recherches de mémoire en moyenne, mais cette fois, l'accès à la mémoire sera dans un ordre aléatoire.

[~ # ~] mise à jour [~ # ~]

Voici le cas-test pour prouver que l'ordre des objets alloués est important.

Benchmark            (generator)  (length)  (postprocess)  Mode  Cnt  Score   Error  Units
ListIndexOf.indexOf       random   1000000           none  avgt   10  1,243 ± 0,031  ms/op
ListIndexOf.indexOf       random   1000000           sort  avgt   10  6,496 ± 0,456  ms/op
ListIndexOf.indexOf       random   1000000        shuffle  avgt   10  6,485 ± 0,412  ms/op
ListIndexOf.indexOf   sequential   1000000           none  avgt   10  1,249 ± 0,053  ms/op
ListIndexOf.indexOf   sequential   1000000           sort  avgt   10  1,247 ± 0,037  ms/op
ListIndexOf.indexOf   sequential   1000000        shuffle  avgt   10  6,579 ± 0,448  ms/op
87
apangin

Je pense que nous voyons l'effet des échecs de cache mémoire:

Lorsque vous créez la liste non triée

for (int i = 0; i < LIST_LENGTH; i++) {
    list.add(r.nextDouble());
}

tous les doubles sont très probablement alloués dans une zone de mémoire contiguë. Itérer à travers cela produira peu d'échecs de cache.

En revanche, dans la liste triée, les références pointent vers la mémoire de manière chaotique.

Maintenant, si vous créez une liste triée avec une mémoire contiguë:

Collection.sort(list);
List<Double> list2 = new ArrayList<>();
for (int i = 0; i < LIST_LENGTH; i++) {
    list2.add(new Double(list.get(i).doubleValue()));
}

cette liste triée a les mêmes performances que l'original (mon timing).

25
wero

Comme exemple simple qui confirme le réponse par wero et le réponse par apangin (+1!): Ce qui suit fait une comparaison simple des deux options:

  • Créer des nombres aléatoires et les trier éventuellement
  • Créer des nombres séquentiels et les mélanger éventuellement

Il n'est pas non plus implémenté en tant que benchmark JMH, mais similaire au code d'origine, avec seulement de légères modifications pour observer l'effet:

import Java.util.ArrayList;
import Java.util.Collections;
import Java.util.List;
import Java.util.Random;

public class SortedListTest
{
    private static final long SLOW_ITERATION_MILLIS = 1000L * 3L;

    public static void main(String[] args)
    {
        int size = 100000;
        testBinarySearchOriginal(size, true);
        testBinarySearchOriginal(size, false);
        testBinarySearchShuffled(size, true);
        testBinarySearchShuffled(size, false);
    }

    public static void testBinarySearchOriginal(int size, boolean sort)
    {
        Random r = new Random(0);
        List<Double> list = new ArrayList<>(size);
        for (int i = 0; i < size; i++)
        {
            list.add(r.nextDouble());
        }
        if (sort)
        {
            Collections.sort(list);
        }
        list = new ArrayList<>(list);

        int count = 0;
        int nIterations = 0;
        long startTime = System.currentTimeMillis();
        do
        {
            int index = r.nextInt(size);
            if (index == list.indexOf(list.get(index)))
            {
                count++;
            }
            nIterations++;
        }
        while (System.currentTimeMillis() < startTime + SLOW_ITERATION_MILLIS);
        long duration = System.currentTimeMillis() - startTime;
        double slowFindsPerSec = (double) nIterations / duration * 1000;

        System.out.printf("Size %8d sort %5s iterations %10.3f count %10d\n",
            size, sort, slowFindsPerSec, count);
    }

    public static void testBinarySearchShuffled(int size, boolean sort)
    {
        Random r = new Random(0);
        List<Double> list = new ArrayList<>(size);
        for (int i = 0; i < size; i++)
        {
            list.add((double) i / size);
        }
        if (!sort)
        {
            Collections.shuffle(list);
        }
        list = new ArrayList<>(list);

        int count = 0;
        int nIterations = 0;
        long startTime = System.currentTimeMillis();
        do
        {
            int index = r.nextInt(size);
            if (index == list.indexOf(list.get(index)))
            {
                count++;
            }
            nIterations++;
        }
        while (System.currentTimeMillis() < startTime + SLOW_ITERATION_MILLIS);
        long duration = System.currentTimeMillis() - startTime;
        double slowFindsPerSec = (double) nIterations / duration * 1000;

        System.out.printf("Size %8d sort %5s iterations %10.3f count %10d\n",
            size, sort, slowFindsPerSec, count);
    }

}

La sortie sur ma machine est

Size   100000 sort  true iterations   8560,333 count      25681
Size   100000 sort false iterations  19358,667 count      58076
Size   100000 sort  true iterations  18554,000 count      55662
Size   100000 sort false iterations   8845,333 count      26536

montrant bien que les timings sont exactement les contraires d'un autre: Si des nombres aléatoires sont triés, alors la version triée est plus lente. Si les nombres séquentiels sont mélangés, la version mélangée est plus lente.

8
Marco13