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.