Miłosz Orzeł

.net, js, html, arduino, java... no rants or clickbaits.

Jak szybki jest .NET Garbage Collector? Część 2.

Przeczytaj poprzednią cześć tego artykułu jeśli jeszcze tego nie zrobiłeś. Część 1 zawiera krótkie podsumowanie tego czym jest GC i w jaki sposób działa. Mieści się tam również test wydajności w obsłudze dużej tablicy bajtów i dokładny opis mojego środowiska testowego…

Ten post skupia się na scenariuszach, które zmuszają GC do większego wysiłku i występują częściej w normalnych (nietestowych) aplikacjach. Zobaczysz, że nawet drzewo składające się z ponad 100 milionów obiektów jest obsługiwane szybko. Na początek sprawdźmy jednak jak GC radzi sobie z dużą tablicą elementów typu object:

static void TestSpeedForLargeObjectArray()
{
    Stopwatch sw = new Stopwatch();

    Console.Write(string.Format("GC.GetTotalMemory before creating array: {0:N0}. Press key any to create array...", GC.GetTotalMemory(true)));
    Console.Read();
    object[] array = new object[100 * 1000 * 1000]; // About 800 MB (3.2 GB when filled with object instances)
             
    sw.Start();
    for (int i = 0; i < array.Length; i++)
    {
        array[i] = new object();
    }
    Console.WriteLine("Setup time: " + sw.Elapsed);
    Console.WriteLine(string.Format("GC.GetTotalMemory after creating array: {0:N0}. Press Enter to set array to null...", GC.GetTotalMemory(true)));

    if (Console.ReadKey().Key == ConsoleKey.Enter)
    {
        Console.WriteLine("Setting array to null");
        array = null;
    }
    
    sw.Restart();
    GC.Collect();
    Console.WriteLine("Collection time: " + sw.Elapsed);
    Console.WriteLine(string.Format("GC.GetTotalMemory after GC.Collect: {0:N0}. Press any key to finish...", GC.GetTotalMemory(true)));

    Console.WriteLine(array); // To avoid compiler optimization...
    Console.ReadKey();
}

Powyższy test tworzy tablicę 100 milionów elementów. Początkowo tablica zajmuje 800 megabajtów pamięci (na platformie x64). Ta część jest alokowana na LOH. Gdy instancje obiektów zostaną utworzone, zużycie pamięci wzrasta do 3,2 GB. Elementy tablicy są niewielkie więc są częścią Small Object Heap i początkowo należą do Gen 0.

Oto rezultaty testu dla przypadku gdy referencja do tablicy jest ustawiona na null:

GC.GetTotalMemory before creating array: 41,736. Press key any to create array...
Press any key to fill array...
Setup time: 00:00:07.7910574
GC.GetTotalMemory after creating array: 3,200,057,616. Press Enter to set array to null...
Setting array to null
Collection time: 00:00:00.7481998
GC.GetTotalMemory after GC.Collect: 57,624. Press any key to finish...

Odzyskanie ponad 3 GB pamięci zajęło jednie 740 ms!

Spójrz na wykres z Monitora wydajności:

Liczniki pamięci zarządzanej dla dużej tablicy object[]; ustawienie tablicy na null. Kliknij aby powiększyć...

Możesz zauważyć, że gdy tablica była wypełniana, Gen 0 i Gen 1 zmieniały rozmiar (zwróć jednak uwagę, że ich skala jest 100x większa niż skala pozostałych liczników). Oznacza to, że cykle zbiórki GC były wywoływane podczas tworzenia elementów tablicy - jest to oczekiwane zachowanie. Zauważ jak rozmiar Gen 2 i LOH pokrywa się z całkowitą ilością pamięci na stercie zarządzanej.

A co gdyby zamiast ustawienia tablicy na null ustawić jej elementy na null?

Wykres:

Liczniki pamięci zarządzanej dla dużej tablicy object[]; ustawienie elementów tablicy na null. Kliknij aby powiększyć...

Zauważ, że po GC.Collect 800 MB nadal pozostaje w użyciu - to pamięć LOH zajęta przez samą tablicę.

Rezultaty testu:

GC.GetTotalMemory before creating array: 41,752. Press key any to create array...
Press any key to fill array...
Setup time: 00:00:07.7707024
GC.GetTotalMemory after creating array: 3,200,057,632. Press Enter to set array elements to null...
Setting array elements to null
Collection time: 00:00:01.0926220
GC.GetTotalMemory after GC.Collect: 800,057,672. Press any key to finish...

Ok, koniec z tablicami. Można się domyślać, że jako ciągłe bloki pamięci są one łatwiejsze w obsłudze niż złożone struktury obiektów występujące powszechnie w rzeczywistych aplikacjach.

Stwórzmy więc bardzo duże drzewo małych elementów referencyjnych:

static int _itemsCount = 0;

class Item
{
    public Item ChildA { get; set; }
    public Item ChildB { get; set; }
    
    public Item()
    {
        _itemsCount++;
    }           
}

static void AddChildren(Item parent, int depth) 
{
    if (depth == 0)
    {
        return;
    }
    else
    {
        parent.ChildA = new Item();
        parent.ChildB = new Item();

        AddChildren(parent.ChildA, depth - 1);
        AddChildren(parent.ChildB, depth - 1);                
    }
}

static void TestSpeedForLargeTreeOfSmallObjects()
{
    Stopwatch sw = new Stopwatch();

    Console.Write(string.Format("GC.GetTotalMemory before building object tree: {0:N0}. Press any key to build tree...", GC.GetTotalMemory(true)));
    Console.ReadKey();

    sw.Start();
    _itemsCount = 0;       
    Item root = new Item();            
    AddChildren(root, 26);
    Console.WriteLine("Setup time: " + sw.Elapsed);
    Console.WriteLine("Number of items: " + _itemsCount.ToString("N0"));

    Console.WriteLine(string.Format("GC.GetTotalMemory after building object tree: {0:N0}. Press Enter to set root to null...", GC.GetTotalMemory(true)));

    if (Console.ReadKey().Key == ConsoleKey.Enter)
    {
        Console.WriteLine("Setting tree root to null");
        root = null;                
    }
    
    sw.Restart();
    GC.Collect();
    Console.WriteLine("Collection time: " + sw.Elapsed);
    Console.WriteLine(string.Format("GC.GetTotalMemory after GC.Collect: {0:N0}. Press any key to finish...", GC.GetTotalMemory(true)));
                
    Console.WriteLine(root); // To avoid compiler optimization...            
    Console.ReadKey();
}

Powyższy test tworzy trzewo mające ponad 130 milionów węzłów, które zajmują niemal 4,3 GB pamięci.

Oto co stanie się gdy root ustawimy na null:

GC.GetTotalMemory before building object tree: 41,616. Press any key to build tree...
Setup time: 00:00:14.3355583
Number of items: 134,217,727
GC.GetTotalMemory after building object tree: 4,295,021,160. Press Enter to set root to null...
Setting tree root to null
Collection time: 00:00:01.1069927
GC.GetTotalMemory after GC.Collect: 53,856. Press any key to finish...

Liczniki pamięci zarządzanej dla dużego drzewa małych obiektów; ustawienie root na null. Kliknij aby powiększyć...

Zebranie wszystkich śmieci zajęło jedynie 1,1 sekundy! Gdy referencja do root została ustawiona na null wszystkie elementy drzewa znajdujące się pod nią stały się zbędne zgodnie z regułami algorytmu mark and sweep… Zauważ, że tym razem LOH nie jest używana ponieważ żaden z obiektów nie przekracza progu 85 KB.

Teraz sprawdźmy co się stanie gdy root nie zostanie ustawiony na null i wszystkie obiekty przetrwają cykl GC:

GC.GetTotalMemory before building object tree: 41,680. Press any key to build tree...
Setup time: 00:00:14.3915412
Number of items: 134,217,727
GC.GetTotalMemory after building object tree: 4,295,021,224. Press Enter to set root to null...
Collection time: 00:00:03.7172580
GC.GetTotalMemory after GC.Collect: 4,295,021,184. Press any key to finish...

Tym razem wykonanie GC.Collect zajęło około 3,7 sek (mniej niż 28 nanosekund na referencję) – pamiętaj, że obsługa żyjących obiektów jest kosztowniejsza od obsługi tych, których pamięć można zwolnić!

Jest jeszcze jeden scenariusz wart sprawdzenia. Zamiast root = null ustawmy root.ChildA = null. W ten sposób tylko połowa drzewa stanie się nieosiągalna. GC będzie mógł zwolnić pamięć i scalić ją by uniknąć fragmentacji. Oto wyniki:

GC.GetTotalMemory before building object tree: 41,696. Press any key to build tree...
Setup time: 00:00:15.1326459
Number of items: 134,217,727
GC.GetTotalMemory after creating array: 4,295,021,240. Press Enter to set root.ChildA to null...
Setting tree root.ChildA to null
Collection time: 00:00:02.5062596
GC.GetTotalMemory after GC.Collect: 2,147,537,584. Press any key to finish...

Czas na ostatni test. Zbudujmy drzewo ponad 2 milionów węzłów o złożonej strukturze. Elementy będą zawierać referencje do obiektów, małą tablicę i unikalny string. Dodatkowo niektóre instancje MixedItem będą posiadać tablicę bajtów na tyle dużą, że zostanie ona umieszczona na Large Object Heap.

static int _itemsCount = 0;

class MixedItem
{
    byte[] _smallArray;
    byte[] _bigArray;
    string _uniqueString;

    public MixedItem ChildA { get; set; }
    public MixedItem ChildB { get; set; }

    public MixedItem()
    {
        _itemsCount++;

        _smallArray = new byte[1000];
        if (_itemsCount % 1000 == 0)
        {
            _bigArray = new byte[1000 * 1000];
        }

        _uniqueString = DateTime.Now.Ticks.ToString();
    }
}

static void AddChildren(MixedItem parent, int depth)
{
    if (depth == 0)
    {
        return;
    }
    else
    {
        parent.ChildA = new MixedItem();
        parent.ChildB = new MixedItem();

        AddChildren(parent.ChildA, depth - 1);
        AddChildren(parent.ChildB, depth - 1);
    }
}

static void TestSpeedForLargeTreeOfMixedObjects()
{
    Stopwatch sw = new Stopwatch();

    Console.Write(string.Format("GC.GetTotalMemory before building object tree: {0:N0}. Press any key to build tree...", GC.GetTotalMemory(true)));
    Console.ReadKey();

    sw.Start();
    _itemsCount = 0;
    MixedItem root = new MixedItem();
    AddChildren(root, 20);
    Console.WriteLine("Setup time: " + sw.Elapsed);
    Console.WriteLine("Number of items: " + _itemsCount.ToString("N0"));

    Console.WriteLine(string.Format("GC.GetTotalMemory after building object tree: {0:N0}. Press Enter to set root to null...", GC.GetTotalMemory(true)));

    if (Console.ReadKey().Key == ConsoleKey.Enter)
    {
        Console.WriteLine("Setting tree root to null");
        root = null;
    }

    sw.Restart();
    GC.Collect();
    Console.WriteLine("Collection time: " + sw.Elapsed);
    Console.WriteLine(string.Format("GC.GetTotalMemory after GC.Collect: {0:N0}. Press any key to finish...", GC.GetTotalMemory(true)));

    Console.WriteLine(root); // To avoid compiler optimization...
    Console.ReadKey();
}

Jak GC poradzi sobie z niemal 4,5 GB pamięci o tak nieregularnej budowie? Wyniki testu dla ustawienia root na null:

GC.GetTotalMemory before building object tree: 41,680. Press any key to build tree...
Setup time: 00:00:11.5479202
Number of items: 2,097,151
GC.GetTotalMemory after building object tree: 4,496,245,632. Press Enter to set root to null...
Setting tree root to null
Collection time: 00:00:00.5055634
GC.GetTotalMemory after GC.Collect: 54,520. Press any key to finish...

Liczniki pamięci zarządzanej dla dużego drzewa mieszanych obiektów; ustawienie root na null. Kliknij aby powiększyć...

I na wypadek gdybyś się zastanawiał, oto wyniki dla przypadku gdy root nie zostaje ustawiony na null:

GC.GetTotalMemory before building object tree: 41,680. Press any key to build tree...
Setup time: 00:00:11.6676969
Number of items: 2,097,151
GC.GetTotalMemory after building object tree: 4,496,245,632. Press Enter to set root to null...
Collection time: 00:00:00.5617486
GC.GetTotalMemory after GC.Collect: 4,496,245,592. Press any key to finish...

Co z tego wszystkiego wynika? Wniosek jest taki, że o ile nie piszesz aplikacji wymagających ekstremalnej wydajności bądź absolutnej gwarancji nieprzerwanego wykonania, powinieneś być zadowolony, że .NET używa automatycznego zarządzania pamięcią. GC to kawał świetnego softu, który uwalnia Cię od konieczności manualnego zwalniania pamięci (czynność nudna i błędogenna). Dzięki GC możesz skupić się na tym co naprawdę istotne: dostarczaniu funkcjonalności dla użytkowników aplikacji. Zawodowo programuje w .NET od 8 lat (rzeczy „enterprise”, głównie aplikacje webowe i usługi systemowe) i jeszcze nie spotkałem1 się z sytuacja gdzie prędkość GC byłaby istotnym czynnikiem. Zazwyczaj problemy z wydajnością tkwią w takich sprawach jak: zła konfiguracja bazy danych, nieefektywne zapytania SQL/ORM, wolne usługi zdalne, złe wykorzystanie sieci, brak współbieżności, złe cache’owanie, powolne renderowanie w przeglądarce itp. Jeśli unikasz podstawowych błędów takich jak tworzenie zbyt wielu stringów pewnie nawet nie zauważysz, że Garbage Collector istnieje :)

Aktualizacja: 31.08.2014: Właśnie uruchomiłem najcięższy test (duże drzewo małych elementów referencyjnych, które nie zostają zwolnione przez GC) na nowym laptopie. Wynik to 3,3s w porównaniu do wyniku 3,7 zaprezentowanego w poście. Program testowy: aplikacja konsolowa .NET 4.5 w trybie Release uruchomiona bez debuggera. Sprzęt: i7-4700HQ 2.4-3.4GHz 4 Core CPU, 8GB DDR3/1600MHz RAM. System: Windows 7 Home Premium x64.

1. Zdarzały się z wyjątki braku pamięci powiązanyne z fragmentacją LOH. Na szczęście jednak algorytmy obsługi LOH są usprawniane a platforma x86, która jest szczególnie podatna na tego typu błędy, przechodzi do historii…

Jak szybki jest .NET Garbage Collector? Część 1.

.NET GC jest bardzo szybki! Mam nadzieje, że ta pocieszająca informacja Ci nie wystarcza i potrzebujesz nieco więcej szczegółów :) Pokaże Ci kilka testów, które dowodzą, że nie kłamię. Na początek jednak przyda się krótkie przypomnienie tego czym jest GC:

Garbage Collector to podstawowy komponent .NET CLR. Odpowiada on za zwalnianie pamięci na stercie zarządzanej tak by programista nie musiał troszczyć się o dealokację. W przeciwieństwie do obiegowej opinii, GC zajmuje się zarówno pamięcią typów referencyjnych jak i wartościowych. Dlaczego? Często przestrzeń dla takich rzeczy jak np. struktury i typy numeryczne jest alokowana na stercie. Dzieje się tak na przykład gdy typ wartościowy jest elementem tablicy lub polem w instancji klasy.

GC w .NET używa algorytmu mark and sweep: przemierza graf obiektów począwszy od tzw. rootów (np. zmiennych statycznych, referencji na stosie lub rejestrach) i oznacza wszystkie elementy do może dotrzeć. Kiedy obieg jest zakończony, GC wie, że może bezpiecznie zwolnić pamięć zajętą przez nieoznaczone obiekty – ponieważ jako nieosiągalne są one bezużyteczne dla aplikacji.

Ze względów wydajnościowych GC wspiera pojęcie generacji. Świeżo utworzony obiekt przynależy do Gen 0 (za wyjątkiem tzw. dużych obiektów). Jeśli obiekt z Gen 0 przetrwa cykl zbiórki jest przenoszony do Gen 1. Jeśli przetrwa kolejny cykl trafia do Gen 2 i w niej zostaje (nie ma Gen 31). Większość obiektów żyje krótko – nie wychodzą poza Gen 0 lub Gen 1 więc .NET najpierw zwalnia pamięć z niższych generacji. Zbieranie nieużytków z Gen 2 (tzw. full collection) odbywa się znacznie rzadziej niż zbieranie w Gen 0. Jeśli obiekt jest duży tj. powyżej 850002 bajtów jest on alokowany na LOH (Large Object Heap) i trafia od razu do Gen 2. Traktowanie dużych obiektów tak samo jak małych miałoby negatywny wpływ na algorytmy defragmentacji3 sterty ponieważ przenoszenie takich obiektów jest czasochłonne.

GC wspiera różne tryby pracy dla stacji roboczych i serwerów, potrafi wykonać cześć pracy w działających w tle wątkach, musi brać pod uwagę specjalne przypadki takie jak istnienie finalizatorów i przypiętych buforów pamięciowych, posiada wyspecjalizowane konfiguracje dla różnych platform (CLR na PC różni się na przykład od tego na Xbox)… Ok, starczy, obiecałem przecież "krótkie przypomnienie"!

Teraz czas na test!

W tym pierwszym poście pokażę Ci jak szybko .NET GC radzi sobie z dużymi tablicami typów wartościowych. Test będzie dotyczył tablicy bajtów zajmującej około dwóch giga. Pomimo swoich wielkich rozmiarów taki obiekt nie obciąża znacznie Garbage Collectora. Jest tak ponieważ jedyną referencją którą musi sprawdzić GC jest ta do tablicy. Jeśli tablica staje się niedostępna cała pamięć zajęta przez jej elementy może bez problemu zostać użyta ponownie. Dodatkowo, tablica taka, jako przynależna do LOH, nie jest kopiowana (w celu uniknięcia fragmentacji sterty) gdy przeżywa ona cykl zbiórki GC. W drugim odcinku tego artykułu pokaże Ci jak GC działa dla bardziej złożonych scenariuszy. Przetestujemy wydajność dla tablicy obiektów, oraz co ważniejsze, dla drzewa obiektów z tysiącami referencji…

Info o środowisku testowym: stacjonarny PC z procesorem Intel i-5 2400 3.1 GHz 4 Core CPU, 12 GB RAM DDR 3/1333 i Windows 7 Ultimate x64. Program testowy to konsolowa aplikacja .NET 4.0 skompilowana w trybie Relase i uruchomiona bez podłączania debuggera

Oto kod testowy:

static void TestSpeedForLargeByteArray()
{
    Stopwatch sw = new Stopwatch();

    Console.Write(string.Format("GC.GetTotalMemory before creating array: {0:N0}. Press any key to create array...", GC.GetTotalMemory(true)));
    Console.ReadKey();
    byte[] array = new byte[2000 * 1000 * 1000]; // About 2 GB
   
    sw.Start();
    for (int i = 0; i < array.Length; i++)
    {
        array[i] = 1; // Touch array elements to fill working set              
    }          
    Console.WriteLine("Setup time: " + sw.Elapsed);

    Console.WriteLine(string.Format("GC.GetTotalMemory after creating array: {0:N0}. Press Enter to set array to null...", GC.GetTotalMemory(true)));
    if (Console.ReadKey().Key == ConsoleKey.Enter)
    {
        Console.WriteLine("Setting array to null");
        array = null;
    }
               
    sw.Restart();
    GC.Collect();                 
    Console.WriteLine("Collection time: " + sw.Elapsed);
    Console.WriteLine(string.Format("GC.GetTotalMemory after GC.Collect: {0:N0}. Press any key to finish...", GC.GetTotalMemory(true)));

    Console.WriteLine(array); // To avoid compiler optimization...
    Console.ReadKey();
}

Jak widzisz test jest bardzo prosty. Kod używa dwóch metod ze statycznej klasy GC: GC.GetTotalMemory oraz GC.Collect. Pierwsza zwraca ilość zaalokowanej pamięci zarządzanej a druga zmusza Garbage Collector do zwolnienia pamięci. Jedyna rzecz, która może Cię zaskoczyć to pętla odwołująca się do elementów tablicy. Bez niej zaobserwowałbyś “dziwne” zjawisko: po zdefiniowaniu tablicy GC.GetTotalMemory zaraportowałaby około 2 GB ale nie zauważyłbyś zwiększenia zużycia pamięci w Menadżerze zadań Windows! Jest tak ponieważ taksmgr.exe pokazuje dane zestawu roboczego (working set). Możesz użyć bardziej zaawansowanego narzędzia: Monitora zasobów (taskmgr.exe) by zobaczyć co się dzieje:

To screenshot sprzed “dotknięcia” elementów tablicy:

Zużycie pamięci przed dostępem do elementów tablicy. Kliknij aby powiększyć...

a to zrzut zrobiony po wykonaniu pętli:

Zużycie pamięci po dostępie do elementów tablicy. Kliknij aby powiększyć...

Pora na rezultaty testu:

GC.GetTotalMemory before creating array: 41,568. Press any key to create array...
Setup time: 00:00:01.0633903
GC.GetTotalMemory after creating array: 2,000,053,864. Press Enter to set array to null...
Setting array to null
Collection time: 00:00:00.1443391
GC.GetTotalMemory after GC.Collect: 53,800. Press any key to finish...

Możesz zauważyć, że wyczyszczenie około 2 GB pamięci zajęło GC około 150 milisekund. Nieźle, co? Prędkość tą można szczególnie docenić gdy zwróci się uwagę na to, że samo przejście w pętli przez elementy tablicy zabrało ponad sekundę!

Oto ekran z Monitora wydajności (perfmon.exe) z kilkoma licznikami dla pamięci .NET:

Monitor wydajności z licznikami pamięci zarządzanej. Kliknij aby powiększyć...

Nasza tablica jest dużym obiektem (znacznie większym niż próg LOH) – widać więc, że po jej zdefiniowaniu pamięć LOH zwiększa się a pamięć Gen 0 pozostaje bez zmian.

Poniżej znajdują się rezultaty wywołania GC.Collect dla przypadku gdy referencja do tablicy nie zostaje ustawiona na null:

GC.GetTotalMemory before creating array: 41,568. Press any key to create array..
Setup time: 00:00:01.0385779
GC.GetTotalMemory after creating array: 2,000,053,864. Press Enter to set array to null...
Collection time: 00:00:00.0001287
GC.GetTotalMemory after GC.Collect: 2,000,053,824. Press any key to finish...

Ułamek milisekundy. Zupełnie pomijalna wartość! Dlaczego w ogóle wspominam o sytuacji w kórej pamięć nie podlega zebraniu? W następnym poście zobaczysz, że GC ma zazwyczaj więcej pracy gdy elementy na strecie przeżywają cykl zbiórki nieużytków.

Planuje dodać drugą cześć artykułu za tydzień albo dwa. Niczego jednak nie obiecuję – wiesz, życie… ;)

Update 26.07.2014: Zmieniłem ilustrację z monitora wydajności (zwiększona skala Gen 0 i Gen 1)..

Update 24.06.2014: Kilka dni temu napisałem drugą cześć artykułu. Kliknij tutaj.

1. Możesz użyć metody GC.GC.MaxGeneration by to sprawdzić.

2. Próg LOH to detal implementacyjny. Większość źródeł wymienia 85KB jako limit ale nie jest to zawsze prawda - tablica double długości zaledwie 1000 elementów trafia na LOH (na x86)...

3..NET 4.5 wprowadza zmiany w LOH usprawniające zapobieganie fragmentacji poprzez balansowanie i usprawnione użycie listy wolnych bloków. Przyszłe edycje wprowadzą prawdopodobnie również opcje kompaktowania.

Dlaczego użycie GetPixel i SetPixel jest tak bardzo nieefektywne?

Klasa Bitmap dostarcza dwie proste metody: GetPixel i SetPixel służące odpowiednio do pobierania koloru punktu obrazu (jako struktury Color) oraz ustawienia punktu obrazu. Poniższy kod ilustruje sposób pobrania/ustawiania wszystkich pikseli bitmapy:

private void GetSetPixel(Bitmap image) {
   for (int x = 0; x < image.Width; x++) {
      for (int y = 0; y < image.Height; y++) {
         Color pixel = image.GetPixel(x, y);
         image.SetPixel(x, y, Color.Black);
      }
   } 
}

Jak widać przeglądnięcie i modyfikacja pikseli jest niezwykle prosta. Niestety za prostotą kodu kryje się poważna pułapka wydajnościowa. O ile dla niewielkiej ilości odwołań do punktów obrazu, prędkość z jaką działają metody GetPixel i SetPixel jest zadowalająca, o tyle dla większych rozmiarów obrazu jest ona zdecydowanie za mała. Za dowód może posłużyć wykres z wynikami 10 testów*, które polegały na 10-krotnym wywołaniu w/w metody GetSetPixel na obrazach 100x100 i 1000x1000 pikseli:

Wyniki testów prędkości operacji na pikselach obrazu z użyciem metod GetPixel i SetPixel klasy Bitmap.

Średni czas testu dla obrazu o wymiarach 100 na 100 pikseli wyniósł 543 milisekundy. Taka wydajność jest możliwa do zaakceptowania o ile przetwarzanie obrazu nie będzie wykonywane często. Problem wydajnościowy jest natomiast jasno widoczny przy próbie obsługi obrazu o rozmiarach 1000 na 1000 pikseli. Wykonanie testu zabiera w tym przypadku średnio ponad 41 sekund – ponad 4 sek. na jedno wywołanie GetSetPixel (sic!) .


Dlaczego tak wolno?

Niska wydajność spowodowana jest tym, że dostęp do piksela nie jest prostym odwołaniem do obszaru pamięci. Każde pobranie lub ustawienie koloru wiąże się z wywołaniem metody .NET Framework, będącej oprawą dla natywnej funkcji zawartej w bibliotece gdiplus.dll. Wywołanie to następuje za pomocą mechanizmu P/Invoke (Platform Invocation), który służy do komunikacji kodu zarządzanego z API niezarządzanym (API z poza .NET Framework). Tak więc np. dla bitmapy o rozmiarze 1000x1000 pikseli dojdzie do miliona wywołań metody GetPixel, która prócz walidacji parametrów korzysta z funkcji natywnej GdipBitmapGetPixel. Metoda z API GDI+ musi z kolei przed zwróceniem informacji o kolorze wykonać takie operację jak np. wyliczenie położenia bajtów odpowiedzialnych za opis szukanego piksela... Sytuacja analogiczna zachodzi w przypadku metody SetPixel.

Spójrz na poniższy kod metody Bitmap.GetPixel uzyskany dzięki .NET Reflector (System.Drawing.dll, .NET Framework 2.0):

public Color GetPixel(int x, int y) {
   int argb = 0;
   if ((x < 0) || (x >= base.Width)) {
      throw new ArgumentOutOfRangeException(“x”, SR.GetString(“ValidRangeX”));
   }
   if ((y < 0) || (y >= base.Height)) {
      throw new ArgumentOutOfRangeException(“y”, SR.GetString(“ValidRangeY”));
   }
   
   int status = SafeNativeMethods.Gdip.GdipBitmapGetPixel(new HandleRef(this, base.nativeImage), x, y, out argb);
   if (status != 0) {
      throw SafeNativeMethods.Gdip.StatusException(status);
   }
   return Color.FromArgb(argb);
}

A oto import funkcji z GDI+:

[DllImport(“gdiplus.dll”, CharSet=CharSet.Unicode, SetLastError=true, 
ExactSpelling=true)]
internal static extern int GdipBitmapGetPixel(HandleRef bitmap, int x, int y, out int argb);

Teraz już wiesz dlaczego masowe użycie Get/SetPixel jest takie powolne. Na szczęście istnieją inne (dużo szybsze) sposoby obsługi pikseli z poziomu .NET. Przy pewnym wysiłku można napisać kod, który szybciej obsłuży obraz megapikselowy niż prymitywna metoda poradzi sobie z bitmapą 100x100!. Ale o tym, gdy znajdę nieco czasu... ;)

Aktualizacja 2013-11-07: Napisałem artykuł o szybkich operacjach pikselowych. Koniec z ślamazarnym Get/SetPixel :)

Aktualizacja 2018-01-08: Jeśli zależy Ci na naprawdę wydajnym przetwarzaniu obrazu powinieneś sięgnąć po wyspecjalizowaną bibliotekę, taką jak OpenCV. Kilka miesięcy temu napisałem serie postów "Detecting a Drone - OpenCV in .NET for Beginners (Emgu CV 3.2, Visual Studio 2017)", które pomogą Ci zacząć...

* Testowałem na takim laptopie: HP Pavilion dv5, procesor AMD Turion X2 Dual-Core Mobile RM-70, 3 GB RAM, Vista Home Premium