2009-09-20 10 views
8

Napotkano na dziwne zachowanie w aplikacji .NET, która wykonuje wysoce równoległe przetwarzanie na zbiorze danych w pamięci.Nieliniowe skalowanie operacji .NET na wielordzeniowej maszynie

Po uruchomieniu na procesorze wielordzeniowym (Intel Core 2 Quad Q6600 2,4 GHz) wykazuje nieliniowe skalowanie, ponieważ wiele wątków jest uruchamianych w celu przetworzenia danych.

Po uruchomieniu jako wielowątkowa pętla na pojedynczym rdzeniu proces może wykonać około 2,4 miliona obliczeń na sekundę. Po uruchomieniu jako cztery wątki można oczekiwać czterokrotnie większej przepustowości - gdzieś w sąsiedztwie 9 milionów obliczeń na sekundę - ale niestety, nie. W praktyce kończy się to około 4,1 miliona na sekundę ... całkiem niewiele od oczekiwanej przepustowości.

Co więcej, zachowanie to występuje bez względu na to, czy używam PLINQ, puli wątków, czy czterech bezpośrednio utworzonych wątków. Dość dziwne ...

Nic więcej nie jest uruchomione na komputerze przy użyciu czasu procesora, ani nie ma żadnych blokad ani innych obiektów synchronizacji zaangażowanych w obliczenia ... powinno tylko przedrzeć się przez dane. Potwierdziłem to (w miarę możliwości), patrząc na dane perfmon, podczas gdy proces jest uruchamiany ... i nie ma zgłaszanych wątków lub działań usuwania śmieci.

Moje teorie w tej chwili:

  1. szczytowy wszystkich technik (przełącza kontekst nici, etc) jest przytłaczająca obliczeń
  2. Nici nie są coraz przypisane do każdego z czterech rdzeni i spędzić trochę czasu czekając na ten sam rdzeń procesora .. nie wiesz jak przetestować tę teorię ...
  3. .NET CLR wątki nie działają zgodnie z oczekiwanym priorytetem lub mają jakiś ukryty wewnętrzny narzut.

Poniżej jest reprezentatywny fragment kodu, który powinien wykazywać takie samo zachowanie:

var evaluator = new LookupBasedEvaluator(); 

    // find all ten-vertex polygons that are a subset of the set of points 
    var ssg = new SubsetGenerator<PolygonData>(Points.All, 10); 

    const int TEST_SIZE = 10000000; // evaluate the first 10 million records 

    // materialize the data into memory... 
    var polygons = ssg.AsParallel() 
         .Take(TEST_SIZE) 
         .Cast<PolygonData>() 
         .ToArray(); 

    var sw1 = Stopwatch.StartNew(); 
    // for loop completes in about 4.02 seconds... ~ 2.483 million/sec 
    foreach(var polygon in polygons) 
     evaluator.Evaluate(polygon); 
    s1.Stop(); 
    Console.WriteLine("Linear, single core loop: {0}", s1.ElapsedMilliseconds); 

    // now attempt the same thing in parallel using Parallel.ForEach... 
    // MS documentation indicates this internally uses a worker thread pool 
    // completes in 2.61 seconds ... or ~ 3.831 million/sec 
    var sw2 = Stopwatch.StartNew(); 
    Parallel.ForEach(polygons, p => evaluator.Evaluate(p)); 
    sw2.Stop(); 
    Console.WriteLine("Parallel.ForEach() loop: {0}", s2.ElapsedMilliseconds); 

    // now using PLINQ, er get slightly better results, but not by much 
    // completes in 2.21 seconds ... or ~ 4.524 million/second 
    var sw3 = Stopwatch.StartNew(); 
    polygons.AsParallel(Environment.ProcessorCount) 
      .AsUnordered() // no sure this is necessary... 
      .ForAll(h => evalautor.Evaluate(h)); 
    sw3.Stop(); 
    Console.WriteLine("PLINQ.AsParallel.ForAll: {0}", s3.EllapsedMilliseconds); 

    // now using four explicit threads: 
    // best, still short of expectations at 1.99 seconds = ~ 5 million/sec 
    ParameterizedThreadStart tsd = delegate(object pset) { foreach (var p in (IEnumerable<Card[]>) pset) evaluator.Evaluate(p); }; 
    var t1 = new Thread(tsd); 
    var t2 = new Thread(tsd); 
    var t3 = new Thread(tsd); 
    var t4 = new Thread(tsd); 

    var sw4 = Stopwatch.StartNew(); 
    t1.Start(hands); 
    t2.Start(hands); 
    t3.Start(hands); 
    t4.Start(hands); 
    t1.Join(); 
    t2.Join(); 
    t3.Join(); 
    t4.Join(); 
    sw.Stop(); 
    Console.WriteLine("Four Explicit Threads: {0}", s4.EllapsedMilliseconds); 

Odpowiedz

5

W końcu zrozumiałem, na czym polega problem - i myślę, że warto byłoby podzielić się nim ze społecznością SO.

Cały problem z wydajnością nieliniowej był wynikiem jednej linii wewnątrz metody Evaluate():

var coordMatrix = new long[100]; 

Od Evaluate() wywoływana jest miliony razy, to alokacja pamięci została występujące miliony razy. Tak się składa, że ​​CLR wewnętrznie wykonuje pewną synchronizację między wątkami podczas alokowania pamięci - w przeciwnym razie alokacja na wiele wątków może przypadkowo zachodzić na siebie. Zmiana tablicy z instancji metody na lokalną na instancję klasy, która jest przydzielana tylko raz (ale inicjowanie w pętli lokalnej), wyeliminowała problem skalowalności.

Zazwyczaj jest to unikalne narzędzie do tworzenia elementu na poziomie klasy dla zmiennej, która jest używana tylko (i znacząca) w ramach pojedynczej metody. Ale w tym przypadku, ponieważ potrzebuję największej możliwej skalowalności, będę żyć z (i dokumentować) tą optymalizację.

Epilog: Po wprowadzeniu tej zmiany proces równoczesny był w stanie osiągnąć 12,2 milionów obliczeń/sekundę.

P.S. Kudos do Igora Ostrovsky'ego za jego znaczący link do blogów MSDN, który pomógł mi zidentyfikować i zdiagnozować problem.

+0

Można to również rozwiązać, korzystając z puli zasobów. To pytanie pomogło mi zrozumieć, dlaczego pule mogą być ważne podczas próby wykonywania masowych równoległych operacji. – Will

+1

Sp twoja oryginalna nierównoległa implementacja działa z szybkością 2.4Mop/s, twoja najnowsza, zoptymalizowana wersja na 4 rdzeniach działa przy 12,2Mop/s. To super-liniowe skalowanie, które jest godne uwagi i warte zbadania. Czy po wprowadzeniu zmiany nie powtórzyłeś ponownie wykonania pojedynczego klucza kodu? –

+0

Zmiana alokacji pamięci poprawiła wydajność pojedynczego rdzenia do 3,2 Mopsa, więc wyniki 4-rdzeniowe 12.2 są uzasadnione. – LBushkin

0

pewno nie spodziewa się liniową zależność, ale myślałem, że widzieliście większy zysk niż że. Zakładam, że użycie procesora jest ograniczone na wszystkich rdzeniach. Tylko kilka myśli z mojej głowy.

  • Czy używasz dowolnych współdzielonych struktur danych (jawnie lub niejawnie), które wymagają synchronizacji?
  • Czy próbowałeś profilować lub rejestrować liczniki wydajności, aby określić, gdzie jest wąskie gardło? Czy możesz podać więcej wskazówek.

Edytuj: Przepraszam, zauważyłem, że zwróciłeś już uwagę na oba moje punkty.

+0

Ciekawym pomysłem @spender, co wydajność liczniki mogłem zbadać, aby określić, czy ja rzeczywiście maxing przepustowość pamięci? – LBushkin

+0

Jeśli maksymalizujesz przepustowość pamięci bez maksymalnego wykorzystania procesora, najprościej dowiesz się, że użycie procesora nie byłoby na poziomie 100% ... – configurator

3

Skalowanie nieliniowe należy oczekiwać w przypadku algorytmu równoległego w porównaniu z algorytmem sekwencyjnym, ponieważ w procesie równoległym występuje pewne nieodłączne obciążenie. (Idealnie, oczywiście, chcesz być tak blisko, jak to tylko możliwe.)

Dodatkowo, zwykle będą pewne rzeczy, którymi musisz się zająć w równoległym algorytmie, którego nie potrzebujesz w algorytmie sekwencyjnym .Poza synchronizacją (która może naprawdę zaszkodzić twojej pracy), mogą się zdarzyć jeszcze inne rzeczy:

  • Procesor i system operacyjny nie mogą poświęcić całego swojego czasu na aplikację. W związku z tym konieczne jest przełączanie kontekstu co jakiś czas, aby inne procesy mogły wykonać pewną pracę. Kiedy używasz tylko jednego rdzenia, jest mniej prawdopodobne, że twój proces zostanie wyłączony, ponieważ ma trzy inne rdzenie do wyboru. Zwróć uwagę, że nawet jeśli uważasz, że nic innego nie działa, system operacyjny lub niektóre usługi nadal mogą wykonywać pewne prace w tle.
  • Jeśli każdy z wątków uzyskuje dostęp do dużej ilości danych, a dane te nie są często spotykane między wątkami, najprawdopodobniej nie będzie można ich wszystkich zapisać w pamięci podręcznej procesora. Oznacza to, że wymagany jest znacznie większy dostęp do pamięci, który jest (relatywnie) powolny.

O ile wiem, twoje obecne, jawne podejście wykorzystuje współużytkowany iterator między wątkami. To jest w porządku rozwiązanie, jeśli przetwarzanie jest bardzo różne w całej macierzy, ale prawdopodobnie istnieje potrzeba synchronizacji narzutu, aby zapobiec pominięciu elementu (pobranie bieżącego elementu i przeniesienie wewnętrznego wskaźnika do następnego elementu musi być operacją atomową, aby zapobiec pomijanie elementu).

Dlatego lepszym rozwiązaniem może być podział tablicy, zakładając, że czas przetwarzania każdego elementu powinien być mniej więcej równy niezależnie od pozycji elementu. Biorąc pod uwagę, że masz 10 milionów rekordów, to znaczy, że wątek 1 działa na elementy od 0 do 2 499,999, wątek 2 działa na elementy od 2 500 000 do 4 999 999 itd. Możesz przypisać każdemu wątkowi identyfikator i użyć go do obliczenia rzeczywistego zakresu.

Innym drobnym usprawnieniem byłoby, aby główny wątek działał jako jeden z obliczanych wątków. Jednakże, jeśli dobrze pamiętam, jest to jedna mała rzecz.

5

Spójrz na ten artykuł: http://blogs.msdn.com/pfxteam/archive/2008/08/12/8849984.aspx

szczególności alokacje pamięci limitu w regionie równoległym i ostrożnie sprawdzać zapisy, aby upewnić się, że nie występują one w pobliżu miejsc pamięci, że inne wątki odczytu lub zapisu.

+0

i powinieneś być w stanie profilować to i zobaczyć, co się dzieje w widoki współbieżności profilera VS 2010. Oto blog ich zespołów http://blogs.msdn.com/visualizeparallel/default.aspx – Rick

Powiązane problemy