PLINQ (część II)–porównanie wydajności prostych zapytań. Wymuszenie PLINQ.

W ostatnim poście omówiłem podstawy PLINQ. Dzisiaj już czysta praktyka. Zacznijmy od prostego zapytania, które może zostać wykonane równolegle:

internal static class Sample
{
   public static void Main()
   {
       IEnumerable<int> numbers = Enumerable.Range(1, 10000000);

       Stopwatch stopwatch = Stopwatch.StartNew();
       IEnumerable<int> subset = numbers.Where(i => i < 10000);

       stopwatch.Stop();
       Console.WriteLine("Time:{0}",stopwatch.ElapsedTicks);
   }
}

Powyższe, sekwencyjne wykonanie zapytania zajmuje 6500. Następnie spróbujmy użyć PLINQ:

internal static class Sample
{
   public static void Main()
   {
       IEnumerable<int> numbers = Enumerable.Range(1, 10000000);

       Stopwatch stopwatch = Stopwatch.StartNew();
       IEnumerable<int> subset = numbers.AsParallel().Where(i => i < 10000);

       stopwatch.Stop();
       Console.WriteLine("Time:{0}",stopwatch.ElapsedTicks);
   }
}

Wynik to 5000 (na maszynie dwurdzeniowej). Wynik nie powala, ale zależy to oczywiście od liczby rdzeni oraz samego zapytania. Spróbujmy sprawdzić zapytanie, które w Select ma skomplikowaną operację. Wersja sekwencyjna:

internal static class Sample
{
   public static void Main()
   {
       IEnumerable<int> numbers = Enumerable.Range(1, 10000000);

       Stopwatch stopwatch = Stopwatch.StartNew();
       IEnumerable<int> subset = numbers.Where(i => i < 10000).
           Select(TimeConsumingOperation);
      
       stopwatch.Stop();
       Console.WriteLine("Time:{0}",stopwatch.ElapsedTicks);
   }
   private static int TimeConsumingOperation(int number)
   {
       Thread.Sleep(10);
       return number;
   }
}

Wynik to 15 000. Wersja PLINQ:

internal static class Sample
{
   public static void Main()
   {
       IEnumerable<int> numbers = Enumerable.Range(1, 10000000);

       Stopwatch stopwatch = Stopwatch.StartNew();
       IEnumerable<int> subset = numbers.AsParallel().
           Where(i => i < 10000).
           Select(TimeConsumingOperation);
      
       stopwatch.Stop();
       Console.WriteLine("Time:{0}",stopwatch.ElapsedTicks);
   }
   private static int TimeConsumingOperation(int number)
   {
       Thread.Sleep(10);
       return number;
   }
}

W PLINQ na tym samym CPU (2 rdzenie) uzyskano wynik 8500. Widać znaczącą poprawę. Nie ma co się dziwić ponieważ w LINQ TimeConsumingOperation blokuje dalsze przetwarzanie. Na dwóch rdzeniach, całość została podzielona i jest przetwarzana niezależnie. Pamiętajmy, że zyskujemy przez PLINQ najwięcej gdy pojedyncza operacja trwa jak najdłużej. Z tego względu, dla zwykłej filtracji danych nie zawsze PLINQ przynosi dobre efekty. Zawsze należy sprawdzać wydajność PLINQ przez umieszczeniem zapytania w kodzie produkcyjnym.

PLINQ zawsze analizuje zapytanie i sprawdza czy warto je wykonać równolegle. Oznacza to jednak, że gdy zapytanie, które najlepiej wykonać sekwencyjnie, będziemy próbować wykonać równolegle to wiąże to się z pewnym overhead. Sprawdźmy np. poniższy kod:

internal static class Sample
{
   public static void Main()
   {
       int[] numbers = Enumerable.Range(1, 10000000).ToArray();

       Stopwatch stopwatch = Stopwatch.StartNew();
       IEnumerable<int> subset = numbers.Take(1);
      
       stopwatch.Stop();
       Console.WriteLine("Time:{0}",stopwatch.ElapsedTicks);
   }        
}

Prosty Take(1) nie ma sensu wykonywać w PLINQ – nie ma co tutaj zrównoleglić.  W czystym LINQ otrzymano wynik 2500, z kolei w PLINQ 6110:

internal static class Sample
{
   public static void Main()
   {
       int[] numbers = Enumerable.Range(1, 10000000).ToArray();

       Stopwatch stopwatch = Stopwatch.StartNew();
       IEnumerable<int> subset = numbers.AsParallel().Take(1);
      
       stopwatch.Stop();
       Console.WriteLine("Time:{0}",stopwatch.ElapsedTicks);
   }        
}

Powyższe zapytanie mimo, że jest PLINQ, zostało wykonane sekwencyjnie. Wynik jest gorszy ponieważ najpierw trzeba było przeanalizować zapytanie aby podjąć tą decyzje. W PLINQ istnieje jednak sposób na zmuszenie wykonania kodu równolegle. Oczywiście dla powyższego przykładu przyniesie to skutki bardzo negatywne:

internal static class Sample
{
   public static void Main()
   {
       int[] numbers = Enumerable.Range(1, 10000000).ToArray();

       Stopwatch stopwatch = Stopwatch.StartNew();
       IEnumerable<int> subset = numbers.AsParallel().
           WithExecutionMode(ParallelExecutionMode.ForceParallelism).
           Take(1);
      
       stopwatch.Stop();
       Console.WriteLine("Time:{0}",stopwatch.ElapsedTicks);
   }        
}

Wynik to 9000! Funkcja WithExecutionMode przyjmuje jako parametr ParallelExecutionMode, który z kolei ma dwie wartości: Default i ForceParallelism.

Leave a Reply

Your email address will not be published.