Jak dobrać stopień zrównoleglenia?

Zrównoleglenie danego algorytmu to jeszcze nie koniec wyzwań. Pytanie jakie należy postawić, to jak wiele stworzyć wątków? Musimy wziąć pod uwagę synchronizacje i problemy z tym związane.

Jeśli mamy tylko 4 procesory, wtedy tworzenie więcej niż 4 wątków nie przyśpieszy obliczeń, jeśli wszystkie one zawsze będą zajęte. Tworzenie większej liczy wątków niż CPU, ma sens wyłącznie jak część z nich musi czekać na jakieś dane i tym samym, nie wykorzystują one w pełni cykli CPU.

Liczba wątków, zależy od tego jak zaprojektowaliśmy nasz algorytm. Jeśli mamy do dyspozycji komputer z 20 procesorami, nie znaczy to, że 20 wątków zawsze warto tworzyć. W jednym, z ostatnich wpisów pokazałem jakiego typu optymalizacje można osiągnąć. Wniosek był taki, że najczęściej jest to poziom subliniowy. Innymi słowy, jeśli praca na jednym CPU zajęła 10 sekund, to na 5 osiągniemy wynik >2s. Bardzo często jakaś część algorytmu będzie wykonywana równolegle. Oznacza to, że dodając nowe wątki, przyśpieszamy wyłącznie pracę wykonywaną równolegle. Jeśli  10 sekund zajmuje algorytm sekwencyjny, a operacje, które muszą zostać wykonane liniowo zajmują 4 sekundy, to mowa jest tylko o przyśpieszeniu pozostałych operacji, które zajmują 6s.

Prowadzi to do wniosku, że w pewnym momencie, dodawanie nowych rdzeni traci sens, ponieważ wyłącznie sekcja krytyczna będzie wąskim gardłem algorytmu.

Przyśpieszenie algorytmu można wyliczyć za pomocą następującego wzoru:  1 / (S+ (1-S)/P).

S to procent operacji wykonywanych sekwencyjnie (sekcja krytyczna), a 1-S to pozostały procent, przeznaczony na operacje, które mogą zostać wykonane równolegle. P to liczba wątków, czyli stopień zrównoleglenia. Jeśli S=1 to algorytm jest typowo sekwencyjny i nie ważne jak wiele wątków użyjemy, wynik będzie taki sam. Jeśli z kolei 1-S równa się zero, to tworząc 5 nowych wątków, przyśpieszymy algorytm 5-krotnie.

Spójrzmy na przykład. Zakładamy, że 90% operacji muszą zostać wykonane sekwencyjnie. Podstawiając do wzoru, otrzymamy następujące rezultaty:

P

Przyśpieszenie

1 1
2

1.052631579

3

1.071428571

4

1.081081081

5

1.086956522

Jak widać, dodawanie kolejnych rdzeni nie ma zbytniego sensu. Należy jednak pamiętać, że jest to wielkie przybliżenie. W praktyce mamy do czynienia z dodatkowymi czynnikami. Każdy nowy rdzeń to nie tylko dodatkowe cykle, ale również pamięć podręczna. Nie wzięliśmy również pod uwagę, że część kodu może zostać wykonywana dłużej niż inna. Nie da się tego tak łatwo opisać, że 80% kodu można zrównoleglić. Co jeśli czas algorytmu zależy od danych wejściowych? Ma na myśli sytuacje, gdzie np. przetworzenie pierwszej części tablicy zajmuje więcej niż drugiej?

Wzór to wyłącznie przybliżenie ale daje nam konkretną informację na temat korzyści płynących z zrównoleglenia danego algorytmu.

Kilka ciekawostek w Visual Studio

Dzisiaj kilka rzeczy z Visual Studio, które przydają się a nie zawsze wszyscy mają świadomość, że są one dostępne.

Zauważyłem, że często ustawiam breakpoint w jakimś miejscu a potem odpalam debugger, aby zacząć proces debugowania właśnie w tym miejscu. W VS istnieje coś takiego jak tymczasowy breakpoint. Wystarczy nacisnąć kombinację klawiszy CTRL+F10, a aplikacja uruchomi się i debugger przejdzie do danej linii (w zależności, w której był kursor podczas wykonywania tej operacji). Jedną kombinacją klawiszy możemy uruchomić aplikację i ustawić breakpoint na danej linii – w praktyce często taki scenariusz ma miejsce.

Inną przydatną rzeczą w dużych projektach są zakładki (bookmarks). Osobiście, często zaglądam do kilku miejsc w projekcie.  Bez zakładek musiałbym najpierw przejść do danej klasy, a potem odszukać interesującą mnie metodę. Zakładki to po prostu odnośniki do naszego kodu. Standardowy skrót to Ctrl+B+T:

image

Ustawi on zakładkę na daną linię, co symbolizowane jest przez prostokąt po lewej stronie. Następnie w oknie bookmarks mamy pełny pogląd wszystkich zakładek:

image

Klikając na daną pozycję przejdziemy natychmiast do wskazywanego kodu.

Kolejną ciekawostką są Data Tooltips. Każdy chyba korzysta z nich na co dzień – po najechaniu myszką na daną zmienną pojawia się po prostu tooltip z wartością. Nie każdy jednak wie, że można je przypinać, co jest przydatne gdy debuggujemy kilkakrotnie ten obszar. Przykład:

image

Co więcej, nawet po restarcie, tooltipy wciąż będę przyklejone do okna. Jeśli jesteśmy zainteresowani jakimiś zmiennymi to możemy mieć łatwy i szybki podgląd.

Ostatnia rzecz, którą chciałem zaprezentować to możliwość przemieszczenia się podczas debuggowania. Załóżmy, że mamy następujący kod:

MethodA();
MethodB();
MethodC();
MethodD();

Zwykle klikamy “step through”, aby przejść do kolejnych metod. Co jeśli któraś z nich wyrzuci wyjątek? Standardowym podejściem byłoby zrestartowanie aplikacji i przed wywołaniem metody powodującej wyjątek, kliknąć Step Into zamiast Step Through. Dzięki VS nie musimy tego robić. Wystarczy kliknąć strzałkę po lewej stronie i przeciągnąć ją do góry:

image

Umożliwia to ustawienie debuggera na konkretną linie kodu i ponowne wykonanie danej logiki. W podobny sposób, możemy wchodzić w różne gałęzie warunków, pętli itp.

Pętla wykonywana równolegle–część II

W poprzednim poście pisałem  o statycznej dekompozycji tablicy na kilka wątków. Główną wadą podejścia było przypuszczenie, że wszystkie iteracje są tak samo skomplikowane.

W niektórych algorytmach należy znaleźć element spełniający podane wymagania. Wyobraźmy sobie, że mamy 100 elementową tablicę i dzielimy ją na 10 wątków. Ponadto element szukany znajduje się pod indeksem 9. Wniosek taki, że NIC nie zyskamy ze zrównoleglenia. Dziewięć wątków będzie szukało w złym miejscu, a pierwszy z nich będzie wykonywał prace jak w podejściu sekwencyjnym.

Powyższe problemy można łatwo ominąć. Zamiast statycznie przydzielać wątki do iteracji, możemy wykonać to w sposób bardziej dynamiczny. Wystarczy, że iteracje będą przetwarzane na zmianę przez różne wątki.

Załóżmy, że tablica ma 10 elementów a mamy do dyspozycji 3 wątki. Możemy zatem do T1 przydzielić elementy 1,4,7,10, do T2 2,5,8 a do T3 3,6,9. Innymi słowy wątek wykonuje swoją porcję a następnie pomija wszystkie elementy, które przydzielone są do innego wątku. Oczywiście wspomniane porcję danych nie muszą być równe jednej iteracji. Może być, że pierwsze 5 elementów jest przydzielonych do T1, następne 5 do T2 itd.

Mała zmiana a zlikwiduje problemy wspomniane na początku wpisu. Implementacja jest również prosta:

private static void Main(string[] args)
{
  For(Console.WriteLine, 1, 10, 3);
  Console.WriteLine("Koniec");
}

private static void For(Action<int> iteration, int startIndex, int endIndex, int threadsNumber)
{
  const int chunkSize = 4;

  var countdownEvent = new CountdownEvent(threadsNumber);

  for (int i = 0; i < threadsNumber; i++)
  {
      Task.Factory.StartNew(delegate(object threadIndexState)
      {
          int start = startIndex + (int)threadIndexState * chunkSize;

          for (int j = start; j < endIndex; j += chunkSize * threadsNumber)
          {
              for (int k = 0; k < chunkSize && j + k < endIndex; k++)
              {
                  iteration(j + k);
              }
          }
          countdownEvent.Signal();
      }, i);
  }
  countdownEvent.Wait();
}

Kod jest podobny do tego z poprzedniego wpisu. Tak naprawdę wciąż jest to statyczna dekompozycja. Każdy wątek ma z góry określone iteracje do przetworzenia ale nie są one przylegające do siebie – co niweluje wiele problemów. Wciąż jednak musimy znać rozmiary tablicy – dla IEnumerable podejście nie sprawdzi się ponieważ nie mamy tam do dyspozycji indeksów.

Jaki wpływ na wydajność ma programowanie współbieżne

Dzisiaj kilka rozważań na temat korzyści płynących z wielowątkowości. Zastanówmy się, jak  bardzo może nam pomóc albo zaszkodzić wprowadzenie nowych wątków w aplikacji.

Jeśli wykonanie danej pracy na jednym procesorze zajmuje T(1) a wykonanie jej na n procesorach zajmuje T(n) wtedy możemy oszacować korzyści płynące z nowych wątków.

W przypadku gdy T(1)/T(n) daje wynik <1 wtedy mamy do czynienia z powolnieniem – im więcej wątków wprowadzamy tym wolniejszy algorytm otrzymujemy. Oczywiście w takim scenariuszu nie ma sensu wprowadzać dodatkowych wątków. Niektóre rzeczy muszą zostać po prostu wykonane sekwencyjne ze względu na swoją specyfikę – np. dalsze obliczenia bazują na poprzednich.

Dużo częściej w praktyce mamy do czynienia gdy T(1)/T(n) < n. Oznacza to, że gdy praca na jednym rdzeniu zajęła 5 sekund, wtedy na 5 rdzeniach zajmie np. 1.2 sekundy. Czyli wydajność ma charakter subliniowy. Bardzo często spotykamy się z tym ponieważ musimy spędzić trochę czasu na synchronizację między wieloma wątkami. W sortowaniu przez scalanie, niektóre części muszą zostać wykonanie sekwencyjnie (scalanie). Wtedy, wprowadzając 5 nowych wątków, nie przyśpieszymy algorytmu dokładnie o 5 razy.

Jeśli np. problemy są kompletnie od siebie niezależne wtedy mamy szanse uzyskać przyśpieszenie liniowe. Chyba łatwo to zrozumieć. Jak tylko możliwe jest rozdzielenie problemu na kompletnie niezależne podproblemy, wtedy każdy z nich może zostać wykonany równolegle – nie potrzebujemy czasu na kod sekwencyjny. Sytuacja w praktyce dość rzadko spotykana.

Przyśpieszenie podliniowe i liniowe są łatwe w wyobrażeniu. Czy istnieje jednak grupa problemów, w której możemy zyskać nadliniowo? Innymi słowy, jeśli na jednym wątku zadanie zajęło 5 sekund to czy na 5 wątkach możemy to wykonać w mniej niż jedną sekundę?

Do rozwiązania niektórych problemów wątki mogą ze sobą współpracować, aby dostarczyć nadliniowe przyśpieszenie. Wyobraźmy sobie, że przeszukujemy tablicę aby znaleźć element spełniający podane kryteria. Jeśli podzielimy zadanie na kilka wątków to będziemy przeszukiwać różne rejony tablicy jednocześnie. Co jeśli taki element zajmuje się gdzieś na końcu tablicy? W podejściu sekwencyjnym musielibyśmy przeszukać wszystkie elementy a w równoległym ostatni wątek znajdzie wartość bardzo szybko.

Więcej procesorów to również więcej zasobów a konkretniej pamięci podręcznej. Pisałem kilka wpisów wcześniej, że dzisiaj bardzo wielką rolę odgrywa jak korzystamy z pamięci i ile mamy tzw. cache misses.  Wiadomo, że każdy procesor posiada pamięć podręczna a zatem gdy mamy ich więcej to mniej danych musimy przechowywać bezpośrednio w pamięci operacyjnej, która jest dużo wolniejsza.

Pętla wykonywana równolegle–statyczne przydzielanie wątków

W .NET istnieje metoda do wykonywania pętli równolegle. Pisałem ogólne o niej kilka miesięcy temu. Temat jest jednak dużo bardziej skomplikowany i z pewnością należy zrozumieć różne podejścia do problemu.

Przed zrównolegleniem pętli, należy zastanowić się czy na prawdę przyniesie to pozytywne efekty. Złe rozpoznanie przypadku spowoduje znaczącą degradację wydajności. Zastanówmy się na co należy zwracać uwagę:

  1. Czy poszczególne elementy tablicy można przetwarzać w sposób bezpieczny (thread-safe). Jeśli nie, wtedy musielibyśmy użyć elementów synchronizacyjnych co mija się z celem.
  2. Czy kolejność z jaką przetwarzamy elementy ma znaczenie? Jeśli drugi element tablicy musi zostać koniecznie wykonany po pierwszym, zrównoleglenie nie ma sensu.
  3. Jakie są relacje między poszczególnymi iteracjami? W przypadku, gdy kolejne iteracje muszą operować na danych z poprzednich, nie będziemy mieli żadnych korzyści z optymalizacji.

Zadanie to wykonanie pętli przez wszystkie elementy w kolekcji z użyciem wątków. Pierwsze pytanie jakie nasuwa się to ile wątków chcemy stworzyć? W jaki sposób je przydzielić do zadań?

Istnieje wiele rozwiązań, ale zacznijmy od najprostszego. Podzielmy tablicę na kilka równych części, w zależności od liczby dostępnych wątków. Jeśli mamy 5 wątków, to każdy z nich będzie przetwarzał 20 elementów dla tablicy 100 elementowej.

Powstały kod wygląda prosto, a mianowicie:

private static void Main(string[] args)
{
  For(Console.WriteLine,1,10,3);
  Console.WriteLine("Koniec");
}

private static void For(Action<int> iteration, int startIndex, int endIndex, int threadsNumber)
{
  int chunkSize = (endIndex - startIndex)/threadsNumber;

  var countdownEvent = new CountdownEvent(threadsNumber);

  for (int i = 0; i < threadsNumber; i++)
  {
      Task.Factory.StartNew(delegate(object threadIndexState)
      {
          int threadIndex = (int)threadIndexState;
          int threadStartIndex = threadIndex * chunkSize + startIndex;
          int threadEndIndex = threadIndex == threadsNumber - 1 ? endIndex : threadStartIndex + chunkSize;

          for (int j = threadStartIndex; j < threadEndIndex; j++)
          {
              iteration(j);
          }
          countdownEvent.Signal();
      }, i);
  }
  countdownEvent.Wait();
}

CountdownEvent używamy w celu blokowania funkcji, aż wszystkie iteracje zostaną wykonane. Kod jest bardzo prosty ale w niektórych przypadkach stanowi optymalne rozwiązanie. Zastanówmy się, kiedy powyższe, statyczne rozwiązanie nie sprawdzi się:

  1. Nie znamy rozmiaru kolekcji. W przypadku tablic, zawsze wiemy jaki jest całkowity rozmiar kolekcji. Wiele źródeł danych to jednak IEnumerable, który może być np. nieskończony. W takiej sytuacji nie jesteśmy w stanie dokonać statycznej dekompozycji, jak to zostało powyżej pokazane.
  2. Powyższa dekompozycja zakłada, że wszystkie iteracje mają taką samą liczbę operacji. A co jeśli iteracja zależy np. od indeksu? Mam ma myśli, że niektóre iteracje mogą mieć złożoność obliczeniową dużo wyższą niż pozostałe. W takim scenariuszu, statyczne przydzielenie wątków do iteracji nie jest optymalne. Co jeśli 5 pierwszych iteracji wykonają się po jednej sekundzie, a ostatnie 5 zajmą po kilka godzin?

Kolejnym problemem jest dobranie optymalnej liczby wątków wykonującej operacje. Jeśli mamy do dyspozycji 4 procesory wtedy nie ma sensu tworzyć 20 wątków ponieważ zmarnujemy czas na zmianę kontekstu a operacje  i tak będą mogły być wykonywane wyłącznie na 4 CPU.

Czy to znaczy, że dla 4 rdzeniowego procesora, 4 wątki wykonujące pętle są optymalne? Niestety nie… System Windows jak wiemy może wywłaszczyć wątki i jest to zjawisko bardzo częste. W OS jest masa wątków, które mają dużo wyższy priorytet np. te  wykonujące GC. Jeśli zatem stworzyliśmy 4 wątki i jeden lub więcej zostały zablokowane to marnujemy trochę zasoby.  Inny scenariusz to sytuacja gdzie wykorzystujemy zdarzenia do blokowania wątków. Jeśli każda iteracja musi czekać na jakieś dane wtedy zdecydowanie lepiej stworzyć więcej wątków, ponieważ część z nich i tak będzie zablokowana na jakiś czas. Zjawisko blokowania wątków jest powszechne i odbywa się niezależnie od nas (system decyduje o tym).

Ze względu na możliwość blokowania, rozsądnym podejściem jest przekazanie Environment.ProcessorCount*2 jako liczby wątków. Wtedy zwiększamy szansę, że w przypadku blokowania któryś z wątków, duża cześć danych jest wciąż przetwarzana.

Ponadto powyższy kod można lekko zoptymalizować ale o tym pisałem już tutaj:

http://www.pzielinski.com/?p=1738

Anulowanie wątków a synchronizacja

W .NET można anulować wątki za pomocą tokena. Oczywiście nie należy używać metody Abort czy Cancel, ale o tym już wiele razy pisałem – w skrócie nie wiadomo kiedy taki wątek zostanie przerwany. Użycie tokena jest proste tzn. (przykład MSDN):

class Program
{
    static void Main()
    {

        var tokenSource2 = new CancellationTokenSource();
        CancellationToken ct = tokenSource2.Token;

        var task = Task.Factory.StartNew(() =>
        {

            // Were we already canceled?
            ct.ThrowIfCancellationRequested();

            bool moreToDo = true;
            while (moreToDo)
            {
                // Poll on this property if you have to do 
                // other cleanup before throwing. 
                if (ct.IsCancellationRequested)
                {
                    // Clean up here, then...
                    ct.ThrowIfCancellationRequested();
                }

            }
        }, tokenSource2.Token); // Pass same token to StartNew.

        tokenSource2.Cancel();

        // Just continue on this thread, or Wait/WaitAll with try-catch: 
        try
        {
            task.Wait();
        }
        catch (AggregateException e)
        {
            foreach (var v in e.InnerExceptions)
                Console.WriteLine(e.Message + " " + v.Message);
        }

        Console.ReadKey();
    }
}

Należy sprawdzać czy flaga IsCancellationRequested jest ustawiona i wtedy odpowiednio zareagować. Daje nam to pełną kontrolę nad tym, kiedy wątek zakończy działanie.

Sprawa prosta. Ale co jeśli w naszej logice musimy czekać na jakieś inne wątki? Jeśli mamy obiekty synchronizacyjne wtedy sprawa nieco komplikuje się .  Wyobraźmy sobie taką pętle:


while (true)
{
    _event.Wait(); 
    // czekaj na jakies zdarzenie
    // wykonanie pracy
    if (ct.IsCancellationRequested)
    {
        ct.ThrowIfCancellationRequested();
    }
}

Powyższy kod może być implementacją wzorca producent\konsument. Jeden wątek czeka na porcje danych a drugi generuje te dane. Każdy z nich ma ten sam token sterujący. Co jeśli producent prawidłowo zostanie anulowany a następnie powyższy wątek utknie na _event.Wait? Jeśli wspieramy anulowanie wątków musimy być szczególnie ostrożni, gdy używamy jakichkolwiek mechanizmów synchronizacji.

Jeśli nasz obiekt synchronizujący nie wspiera anulowania wtedy możemy skorzystać WaitHandle.WaitAny. Metoda ta czeka aż jakieś zdarzenia zostanie ustawione. Token również eksponuje WaitHandle, zatem możemy powyższy kod przepisać na:

   int eventThatSignaledIndex =
                WaitHandle.WaitAny(new WaitHandle[] { _event, token.WaitHandle },
                                    new TimeSpan(0, 0, 20));

Metoda zwraca indeks zdarzenia, które zostało odebrane. Jeśli zatem token ustawił zdarzenie to oczywiście przerywamy pracę. Pełny przykład (MSDN):

class CancelOldStyleEvents
{
    // Old-style MRE that doesn't support unified cancellation. 
    static ManualResetEvent mre = new ManualResetEvent(false);

    static void Main()
    {
        var cts = new CancellationTokenSource();

        // Pass the same token source to the delegate and to the task instance.
        Task.Run(() => DoWork(cts.Token), cts.Token);
        Console.WriteLine("Press s to start/restart, p to pause, or c to cancel.");
        Console.WriteLine("Or any other key to exit.");

        // Old-style UI thread. 
        bool goAgain = true;
        while (goAgain)
        {
            char ch = Console.ReadKey().KeyChar;

            switch (ch)
            {
                case 'c':
                    cts.Cancel();
                    break;
                case 'p':
                    mre.Reset();
                    break;
                case 's':
                    mre.Set();
                    break;
                default:
                    goAgain = false;
                    break;
            }

            Thread.Sleep(100);
        }
    }

    static void DoWork(CancellationToken token)
    {
        while (true)
        {
            // Wait on the event if it is not signaled. 
            int eventThatSignaledIndex =
                WaitHandle.WaitAny(new WaitHandle[] { mre, token.WaitHandle },
                                    new TimeSpan(0, 0, 20));

            // Were we canceled while waiting? 
            if (eventThatSignaledIndex == 1)
            {
                Console.WriteLine("The wait operation was canceled.");
                throw new OperationCanceledException(token);

            }
            // Were we canceled while running? 
            else if (token.IsCancellationRequested)
            {
                Console.WriteLine("I was canceled while running.");
                token.ThrowIfCancellationRequested();

            }
            // Did we time out? 
            else if (eventThatSignaledIndex == WaitHandle.WaitTimeout)
            {
                Console.WriteLine("I timed out.");
                break;
            }
            else
            {
                Console.Write("Working... ");
                // Simulating work.
                Thread.SpinWait(5000000);
            }
        }
    }
}

Cześć obiektów wspiera bezpośrednio tokeny i zamiast WaitAny możemy (przykład z MSDN):

static void DoWork(CancellationToken token)
{

   while (true)
   {
       if (token.IsCancellationRequested)
       {
           Console.WriteLine("Canceled while running.");
           token.ThrowIfCancellationRequested();
       }

       // Wait on the event to be signaled  
       // or the token to be canceled, 
       // whichever comes first. The token 
       // will throw an exception if it is canceled 
       // while the thread is waiting on the event. 
       try
       {
           // mres is a ManualResetEventSlim
           mres.Wait(token);
       }
       catch (OperationCanceledException)
       {
           // Throw immediately to be responsive. The 
           // alternative is to do one more item of work, 
           // and throw on next iteration, because  
           // IsCancellationRequested will be true.
           Console.WriteLine("The wait operation was canceled.");
           throw;
       }

       Console.Write("Working...");
       // Simulating work.
       Thread.SpinWait(500000);
   }
}

Po prostu metoda Wait przyjmuje jako parametr token do anulowania. W przypadku, gdy token zostanie ustawiony to Wait wyrzuci OperactionCanceledException.

Wniosek z wpisu jest taki, że przed użyciem tokenu należy szczególnie przyjrzeć się używanym metodom synchronizacyjnym ponieważ łatwo o zakleszczenie i wyciek pamięci.

Alokacja pamięci a false sharing

Kiedyś pisałem już o false sharing. Jeśli problem nie jest znany, najpierw zachęcam do przeczytania tego wpisu, ponieważ nie będę tutaj pisał o teoretycznych zagadnieniach:

http://www.pzielinski.com/?p=1489

Oprócz wyjaśnienia podstaw, podałem przykład struktury danych składających się z dwóch Int32. Pokazałem również jakie pułapki czekają nas przy pracy z tablicami. To zadziwiające, że kolejność w jakiej przeglądamy tablicę ma tak ogromne znaczenie w wydajności (kod może być nawet kilkakrotnie wolniejszy). Oczywiście mowa tutaj tylko o wielowątkowym dostępie.

Dzisiaj kolejny przykład, na który możemy natrafić każdego dnia, pisząc kod wielowątkowy.

Alokacja pamięci w .NET jest zaawansowanym mechanizmem i bierze pod uwagę architekturę CPU. Jak napisałem (patrz powyższy post), zmienne będące blisko siebie w programie, również zostaną zaalokowane blisko siebie w pamięci (patrz przykład dwóch Int32). W większości przypadków, ma  to korzystny wpływ na wydajność. Alokacja obiektów na stercie, bierze pod uwagę wątek, z którego jest alokowana. Ma to bardzo pozytywne skutki, ponieważ dzięki temu nie mamy problemów ze wspomnianym false sharing i cache misses. Obiekty z różnych miejsc, zostaną zadeklarowane w możliwie dalekich od siebie miejscach. Napiszmy dwa programy, jeden mający problemy z false sharing:

internal class CacheLineSample
{
   public int Number { get; set; }
}

internal class Program
{
   private static void Main(string[] args)
   {
       var stopwatch = Stopwatch.StartNew();

       var data = new[] {new CacheLineSample(), new CacheLineSample(), new CacheLineSample(), new CacheLineSample()};

       Task t1 = Task.Factory.StartNew(() => Run(0, data));
       Task t2 = Task.Factory.StartNew(() => Run(1, data));
       Task t3 = Task.Factory.StartNew(() => Run(2, data));
       Task t4 = Task.Factory.StartNew(() => Run(3, data));

       Task.WaitAll(t1, t2, t3, t4);
       stopwatch.Stop();
       Console.WriteLine(stopwatch.ElapsedMilliseconds);
   }

   private static void Run(int index,CacheLineSample[] data)
   {
       CacheLineSample cacheLineSample = data[index];

       for (int i = 0; i < 10000000; i++)
       {
           cacheLineSample.Number++;
       }
   }
}

Deklarujemy tutaj pamięć w głównym wątku. A co za tym idzie, naturalne jest, że GC będzie spodziewał się, że elementy tablicy powinny być zadeklarowane koło siebie w pamięci. Następnie w różnych wątkach, zwiększamy licznik – każdy wątek operuje na osobnej klasie, której obiekt należy do wspólnej tablicy. Na swoim komputerze dostałem wynik ~550 ms.

Następnie spróbujmy zoptymalizować kod tak, że alokacja będzie dokonywana na osobnym wątkach – a co za tym idzie, logiczne jest umieszczenie obiektów w różnych miejscach w pamięci:

internal class CacheLineSample
{
    public int Number { get; set; }
}

internal class Program
{
    private static void Main(string[] args)
    {
        var stopwatch = Stopwatch.StartNew();

        var data = new CacheLineSample[4];

        Task t1 = Task.Factory.StartNew(() => Run(0, data));
        Task t2 = Task.Factory.StartNew(() => Run(1, data));
        Task t3 = Task.Factory.StartNew(() => Run(2, data));
        Task t4 = Task.Factory.StartNew(() => Run(3, data));

        Task.WaitAll(t1, t2, t3, t4);
        stopwatch.Stop();
        Console.WriteLine(stopwatch.ElapsedMilliseconds);
    }

    private static void Run(int index, CacheLineSample[] data)
    {
        CacheLineSample cacheLineSample = data[index] = new CacheLineSample();

        for (int i = 0; i < 10000000; i++)
        {
            cacheLineSample.Number++;
        }
    }
}

Otrzymany wynik to ~160 ms. Optymalizacja jest znacząca i nie należy jej bagatelizować. Każdy kto interesuje się procesorami wie jakie są trendy na rynku od wielu lat. Czasy kiedy co miesiąc wychodził procesor z szybszym zegarem już dawno minęły. Kolejną rewolucją w wydajności było doczepianie kilka dodatkowych rdzeni, co umożliwia nam realną pracę w środowisku wielowątkowym. Szybko jednak okazało się, że prawdziwą przeszkodzą jest dostęp do pamięci. Procesory były na tyle szybkie, że optymalizacje należało skierować w stronę buforowania i  unikania cache misses a nie na częstotliwość zegara czy nawet liczbę rdzeni. Problem false sharing jest znakomitym przykładem, jak dysponując szybkim procesorem, można zdegradować wydajność poprzez problemy z buforowaniem.

Szablony T4–pluginy

Pisanie szablonów, bez podpowiadania składni jest dość niewygodne. Na szczęście w łatwy sposób można zainstalować plugin, ułatwiający pracę z T4.

Użytkownicy VS 2010 mają do dyspozycji Visual T4:

http://visualstudiogallery.msdn.microsoft.com/40a887aa-f3be-40ec-a85d-37044b239591

Jeśli z kolei pracujemy na VS 2012, wtedy mamy do dyspozycji tangible T4 Editor:

http://visualstudiogallery.msdn.microsoft.com/b0e2dde6-5408-42c2-bc92-ac36942bbee9

Powyższy plugin wspiera kolorowanie składni, IntelliSense oraz kilka narzędzi do modelowania i debugowania. Po zainstalowaniu, od razu zobaczymy, że zamiast jednolitego, czarnego tekstu, składnia jest pokolorowana:

image

Więcej informacji tutaj:

http://t4-editor.tangible-engineering.com/T4-Editor-Visual-T4-Editing.html

Code Review: Wykonywanie wielu zadań i czekanie na wynik

Wielowątkowość jest na tyle łatwo dostępna, że programiści próbują zrównoleglić jak największą liczbę zadań. Przykład:

internal class Program
{
   private static void Main(string[] args)
   {
       Task.WaitAll(Task.Factory.StartNew(Run1), Task.Factory.StartNew(Run2), Task.Factory.StartNew(Run3));
   }

   private static void Run1()
   {
   }

   private static void Run2()
   {            
   }

   private static void Run3()
   {

   }
}

W tym konkretnym przykładzie, lepsze byłoby prawdopodobnie Parallel.Invoke. Ale nie o to mi chodzi. Nie chciałem pokazywać realnego przykładu, ponieważ zbyt skomplikowałoby to post. Załóżmy, że musimy wykonać kilka zadań równoległe a potem czekać na wynik. Prostym sposobem można zoptymalizować kod, wykonując jedno zadanie na wątku macierzystym. Poprawiony kod:

Task t1 = Task.Factory.StartNew(Run1);
Task t2 = Task.Factory.StartNew(Run2);
Run3();
Task.WaitAll(t1,t2);

Skoro i tak musimy blokować macierzysty wątek, to nie ma potrzeby tworzyć kolejnego na Run3. Lepiej wykorzystać macierzysty wątek, niż marnować go na czekanie. Oszczędzamy tutaj zmianę kontekstu, co w systemach low-latency\high frequency może mieć znaczenie. Dla wielu biznesowych aplikacji, nie ma to dużego wpływu. Wciąż jednak, lepiej pisać kod wydajniejszy, zwłaszcza, że w tym przypadku jest to po prostu łatwiejsze.

Szablony T4–bloki i dyrektywy

Jeśli  ktoś nie czytał jeszcze, zapraszam najpierw do wprowadzenia. Opisywałem w nim czym jest T4, komu może się przydać itp. Dzisiaj zajmiemy się kilkoma dyrektywami, które wykorzystamy do budowania szablonów. Pierwsze bloki, poznaliśmy już w poprzednim poście. Dowiedzieliśmy się, że w celu zaimportowania jakieś biblioteki dll należy:

<#@ assembly name="System.Core" #>

Z kolei odpowiednikiem using z c# jest:

<#@ import namespace="System.Text" #>

Jeśli korzystamy z szablonów, które zostaną przetworzone na etapie projektowania, musimy określić rozszerzenie wygenerowanego pliku za pomocą:

<#@ output extension=".txt" #>

Każdy szablon, zawiera również pewne parametry konfiguracyjne (takie jak nazwa języka):

<#@ template debug="false" hostspecific="false" language="C#" #>

Ostatni post, skończyliśmy na następującym szablonie:

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".txt" #>

Hello World

Spróbujmy dodać jakieś elementy dynamiczne. Pierwszy blok to <#= Wyrażenie #>. W miejsce wyrażenia wstawiamy dowolny kod, który zwraca jakiś wynik. W najprostszej postaci będzie to po prostu tekst (string):

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".txt" #>

<#="Witaj swiecie!"#>

Oczywiście w praktyce, wykorzystamy jakiś kod c# np.:

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".txt" #>

Data: <#=DateTime.Now#>

Ktoś mający styczność z ASP.NET MVC z pewnością zna takie konstrukcje. Innymi słowy, “<#=” wyświetla tekst, zwrócony przez dany kod C# lub VB. Nie należy umieszczać średnika na końcu wyrażenia.

Kolejny blok to <# KOD #>. Umieszczamy w nim dowolny kod C#, np:

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Threading" #>
<#@ output extension=".txt" #>


<#
StringBuilder strBuilder=new StringBuilder();

foreach(string dayName in Thread.CurrentThread.CurrentUICulture.DateTimeFormat.DayNames)
{
    strBuilder.AppendLine(dayName);
}#>

<#= strBuilder.ToString() #>

Jeśli chcemy zadeklarować klasy to również istnieje taka możliwość. Wystarczy skorzystać z <#+ #>. Możemy w tym bloku zdefiniować klasy i metody, wykorzystywane potem w szablonie np.:

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Threading" #>
<#@ output extension=".txt" #>

<#

ItemInfo itemInfo = new ItemInfo();
itemInfo.Id=1;
itemInfo.Name="Test";

#>

<#= itemInfo.Name #>

<#+
class ItemInfo
{
    public int Id{get;set;}
    public string Name{get;set;}
}
#>

Należy tylko pamiętać, że deklaracja klasy musi zostać umieszczona na samym dole szablonu (patrz powyższy przykład). Jest to po prostu wymóg T4.

Jeśli szablon jest duży, ma sens podzielenie go na  kilka logicznych części. Następnie w głównym szablonie, możemy dołączyć go za pomocą specjalnej dyrektywy:

<#@ include file="TextTemplate1.tt"#>

W praktyce moim zdaniem, bardziej sensowne jest umieszczenie metod (kodu) w jednym ze szablonów, a potem wywoływanie go w miejscach, w których potrzebujemy.