Który język jest szybszy? Test wydajności C# i C++

Dosyć często słyszy się, że programy pisane w C++ są szybsze od tych pisanych w C#. Głównym powodem takiego stanu rzeczy ma być fakt, że kod pisany w C++ jest kompilowany bezpośrednio do kodu natywnego, natomiast w przypadku C# programy działają na platformie .NET. Co za tym idzie, pisząc aplikację w C++ musimy sami zadbać o rzeczy takie jak na przykład zarządzanie pamięcią. Środowisko .NET robi tego typu rzeczy za nas (Garbage Collector). Niewątpliwie oszczędza to masę czasu podczas tworzenia kodu i sprawia pisanie przyjemniejszym, jednak czy ma istotny wpływ na wydajność końcowych aplikacji? Postanowiłem to osobiście sprawdzić! 😉

Jak przeprowadzałem testy?

Moje testy nie były zbyt skomplikowane. Polegały na stworzeniu kodu o dokładnie tej samej funkcjonalności przy użyciu bibliotek standardowych obu języków. Następnie stworzony kod kompilowałem przy użyciu kilku kompilatorów. Odpowiednio:

  • Wbudowanego w VS2015 dla C# i C++ (32 i 64-bit)
  • Kompilatora mingw32-gcc dla C++, w wersjach 4.9.2(32bit), oraz 5.1.0(32 i 64-bit)

Do zbudowania aplikacji poprzez kompilatory MiniGW użyłem programu Code::Blocks. Jeżeli chodzi o ich ustawienia, to wszędzie(także w VS) ustawiłem jedynie flagę -o2. W teorii ma to optymalizować kod wynikowej aplikacji. Aplikacje pisane w C# kompilowałem na platformę .NET w wersji 4.5. Przed testami starałem się jak najbardziej odciążyć system i uruchamiałem je pojedynczo. Każdy test wykonywałem trzykrotnie i uśredniałem wyniki, żeby zminimalizować ryzyko przekłamań. Jednak mimo to różnice pomiędzy nimi nie były zbyt duże, więc była to czysta formalność.

Komputer, na którym dokonywałem testów prezentuje się następująco:

  • CPU: AMD Phenom II X4 945 3Ghz
  • RAM: DDR3 1333 8GB
  • OS: Windows 10 Home w wersji 64-bit

Pragnę tutaj zaznaczyć, że przeprowadzając testy na innej konfiguracji sprzętowej, (w szczególności na innym procesorze i pamięci) wyniki będą się różnić od moich. Wynika to oczywiście z szybkości tych komponentów. W teorii im procesor i RAM słabsze tym testy powinny być bardziej miarodajne – bo różnice w szybkości wykonania się kodu będą bardziej widoczne. „Niestety” nie posiadam wolniejszego komputera na wyposażeniu, więc musi wystarczyć mi taka konfiguracja. Jednak jeżeli sami będziecie chcieli przeprowadzić testy na swoich maszynach, to pod każdym testem znajdziecie linki do skompilowanych wersji aplikacji, jak i ich kod źródłowy.

Tworzenie i usuwanie obiektów

Pierwszą rzeczą jaką postanowiłem sprawdzić, była szybkość stworzenia 20 000 000 obiektów typu „Car”. Do tego celu użyłem takiego kodu C#:

Oraz kodu napisanego w C++ o tej samej funkcjonalności:

Wyniki tego testu prezentują się następująco:

Pierwszą rzeczą, jaka mnie zdziwiła to zachowanie kompilatora GCC 4.9.2 x32 – kod skompilowany przez niego nie był w stanie przechować aż 20 000 000 obiektów, musiałem więc zmniejszyć tą wartość o połowę. Jednak mimo tego, że kod po tym zabiegu już działał… to czas jego wykonania był rozczarowujący. Przy tworzeniu o połowę mniej obiektów kod wykonywał się 2x dłużej niż w przypadku kompilatora GCC 5.1.0 x32! Wygląda na to, że ta wersja (chyba?) posiada jakiegoś ukrytego „buga”, który niszczy optymalizację wynikowego kodu.

Pomińmy jednak ten „wybryk natury” i weźmy się za interpretację reszty wyników. Zacznijmy może od czasu, jakiego potrzebowały aplikacje na stworzenie obiektów. Widzimy tutaj, że najszybszy okazał się kod C++ skompilowany przez GCC 5.1.0 x32, natomiast wersja 64-bitowa pozostała delikatnie w tyle. Jednak różnica jest na tyle mała, że może wynikać z niedokładności pomiarowej. Kompilatory GCC tym samym przebiły zarówno Visual’a C++, jak i C# pod względem szybkości działania. Najwolniejszy okazał się C#/.NET x32, jednak już wersja x64 zrównała się z Visual’em C++, a w wersji x64 nawet go przebiła(choć różnica jest bardzo niewielka).

Co do prędkości usuwania stworzonych obiektów, to czasy te są zbliżone właściwie we wszystkich przypadkach. W C# czasy te są tak dobre, ponieważ obiekty nie są usuwanie ręcznie, (czego w C# się nie robi) lecz są wypełniane null’ami, aby Garbage Collector wiedział, że należy je zniszczyć. Następnie GC jest ręcznie wywoływany. Podejście trochę inne, jednak cel jest identyczny – zwolnienie używanej pamięci.

Użycie RAM

Sprawdziłem też ile pamięci operacyjnej potrzebowały poszczególne aplikacje do przechowania tak dużej ilości obiektów. Wygląda to tak:

Na tym polu C# z platformą .NET pozostają bezkonkurencyjne – potrzebują znacznie mniej pamięci do przechowania tej samej ilości obiektów. Dotyczy to zarówno wersji 32, jak i 64-bitowej.

A gdyby tak… zwiększyć liczbę obiektów?

Pokusiłem się także o zwiększenie ilości obiektów w teście z 20 000 000, do 50 000 000. W tym momencie wszystkie kompilatory C++ w wersji 32-bitowej odpadły z gry. Nie były w stanie zaalokować wymaganej ilości pamięci. Nie dotyczyło to jednak C#:

Wyniki są bardzo podobne do tych przeprowadzonych z mniejszą ilością obiektów. Wygrywa C++ z kompilatorem GCC na pokładzie, C# x64 nieznacznie wyprzedza Visual C++ x64. Jednak różnice w wynikach tego testu są na tyle małe, że właściwie pomijalne. W końcu liczba tworzonych obiektów jest ogromna! Biorąc to pod uwagę można przyjąć, że w tej kwestii wszystkie przetestowane języki/kompilatory są równie dobre.

Użycie RAM:

Fibonacci, pętle zagnieżdżone, kopiowanie tablicy

Drugą rzeczą, jaką postanowiłem sprawdzić, było to, w jakim czasie aplikacje poradzą sobie z zadaniami takimi, jak: obliczanie sumy n wyrazów ciągu Fibonaccie’go, wykonanie kilku zagnieżdżonych pętli, oraz skopiowanie dużej ilości elementów z jednej do drugiej tablicy. W tym celu stworzyłem tak, jak w pierwszym przypadku, taki oto kod w C#:

Oraz odpowiadający mu w C++:

Wyniki prezentują się następująco:

Jeżeli chodzi o ciąg Fibonacci’ego, pierwszą rzeczą, jaka rzuca się w oczy, jest fakt, że aplikacje 64-bitowe są znacznie szybsze niż ich 32-bitowe odpowiedniki. C# w przypadku aplikacji 64-bitowej zrównuje się ze swoim odpowiednikiem napisanym w C++ i skompilowanym 32-bitowym kompilatorem GCC.  Kompilator Visual C++ x32 wypadł tak samo, jak C#/.NET x32. Natomiast wersja 64-bitowa VC++ okazała się szybsza, od C#/.NET x64. Podsumowując, kompilatory GCC są w tym przypadku sporo lepsze.

Niestety, gorszy dla C# okazał się test wykonania zagnieżdżonych pętli (których ponoć nie powinno się używać). Okazało się bowiem, że operacje, które aplikacja napisana w C++ wykonywała w niecałą sekundę, tej w C# zajęło… okolice minuty. To już nie są drobne różnice. Nie wiem co o tym sądzić… coś jest nie tak u mnie z ustawieniami Visual’a, czy faktycznie jest aż tak źle?(przecież mam zaznaczoną opcję „optimize code”!) Jeżeli macie chwilę czasu to skompilujcie kod sami i dajcie znać, czy u Was test wypadł tak samo.

Kopiowanie tablicy

Tak natomiast wyglądają wyniki testu kopiowania tablicy do drugiej tablicy:

Także tutaj C++ wypada nieznacznie lepiej, jednak różnice są na tyle małe, że w realnych zastosowaniach nie powinno mieć to żadnego znaczenia. W końcu kogo zbawi 0,2s, przy takich ilościach danych 😉

Podsumowanie

Pomijając kwestię tych nieszczęsnych pętli, bo nie wiem jak się do nich odnieść. Po przeanalizowaniu powyższych wykresów, można wyciągnąć wnioski, że faktycznie C++ jest językiem wydajniejszym od C# – oczywiście pod warunkiem wybrania dobrego kompilatora. Jednak różnice w szybkości działania pomiędzy tymi językami nie są zbyt znaczące. Dlatego należy sobie zadać pytanie: Czy warto poświęcić więcej czasu na pisanie aplikacji w C++, dla tych kilku procent wydajności, czy lepiej zrobić to samo szybciej w C# tracąc te kilka procent? Moim zdaniem w przypadku zwykłych aplikacji i prostych gier – lepiej zaoszczędzić czas. Sprawa może wyglądać inaczej w przypadku rozbudowanych gier 3D – tam większa szybkość C++, będzie jeszcze bardziej procentować, bo wiele rzeczy wykonywanych jest jednocześnie.

Pomijając już inne aspekty, takie jak język i kompilatory warto również pamiętać o tym, że największy wpływ na wydajność aplikacji mamy my sami. To w jaki sposób używamy języka, czy tworzymy algorytmy ma największy wpływ na wydajność. Przecież nawet w najszybszy i najlepszy język nie uchroni nas, przed napisaniem jakiegoś optymalizacyjnego potworka. Pozostaje też kwestia bibliotek do obsługi XML’a, JSON’ów, sieci, kryptografii itp. Czasami może zdarzyć się, że biblioteka napisana w C++, będzie wolniejsza od jej C# odpowiednika. Będzie to oczywiście wynikało z jej implementacji, a nie z wydajności samego języka – o czym wcześniej wspomniałem. Jak widać cały temat jest dosyć bardziej złożony i nie można jednoznacznie stwierdzić, który język jest szybszy/lepszy. Język jest właściwie tylko narzędziem i to my musimy dobrać odpowiednie narzędzie do wykonania zamierzonego celu. Jest przecież oczywiste, że gwoździe wbija się młotkiem, a nie śrubokrętem – taka sama analogia występuje i tutaj 😉

Cóż, to by było na tyle… Nie sądziłem, że ten wpis będzie tak długi i jego stworzenie zabierze mi tak wiele czasu. Mam nadzieję, że choć trochę Was zaciekawił. Jak zwykle czekam na wasze uwagi w komentarzach 😉

1,212 total views, 2 views today

16 przemyśleń nt. „Który język jest szybszy? Test wydajności C# i C++

  1. Benchmarkować też trzeba umieć (i przygotowywać i interpretować).

    Pomijając już sam pomysł tworzenia miliona obiektów i na tej podstawie oceniania „szybkości” języka

    1) GC.collect() sprytnie odbywa się już po zmierzeniu czasu
    2) piszesz kompletnie antyidiomatyczny kod (przyjmowanie stringów przez kopię, frywolna alokacja przez new zamiast użyć std::vector jak człowiek)
    3) W przypadku NestedLoopTest kompilator to po prostu optymalizuje do zera. https://godbolt.org/g/UhRGLX Dziwne, że w C# tego nie robi. Może po rozgrzaniu JITa?
    4) jeśli już musisz używać new i delete (nie musisz), to każde new musi mieć odpowiadające delete, a new[] swoje delete[]. Inaczej mówiąc: masz UB w kodzie.

    Jedyne z czym się bezsprzecznie zgadzam to podsumowanie, że różnica złożoności algorytmicznej prawie zawsze przebija jakiekolwiek niewydajności języka.

    • 1) Doszedłem do wniosku, że nie ma sensu tego sprawdzać, bo GC i tak w normalnych warunkach wywoływany jest bez naszej ingerencji. Jednak przed chwilą to sprawdziłem i na wykresie, tak czy siak nie byłoby widać różnicy 🙂
      2) Dzięki za uwagę. Nie siedzę w C++ i wielu rzeczy nie wiem. Tym bardziej dzięki! Z tym new, to chyba takie przyzwyczajenie z C#.
      3) Właśnie też nie za bardzo rozumiem, czemu tego nie zoptymalizował…
      4) Hmm… nie za bardzo rozumiem. Przecież usuwam najpierw wszystkie elementy z tablicy przez delete, a później samą tablicę.

      Jeszcze raz dzięki za uwagi! 🙂

      • 1) Sensowność sensownością – ale jak mówisz, że coś mierzysz to mierz ;​)
        4) delete i delete[] to zupełnie różne rzeczy. Tak samo jak new i new[]. W idiomatycznym kodzie powinieneś ogólnie unikać ich używania w ogóle ⟵ o tym się nawet rozpisałem w artykule do programisty #55 (jak kupujesz/prenumerujesz to polecam lekturę, jak nie to jest bardzo dużo na ten temat napisane w sieci)

        • 1) Napisałem przecież, że w przypadku C# nie usuwam obiektów, tylko wypełniam je null’ami. Ale fakt, faktem mogłem linijkę z GC.Collect() uwzględnić w pomiarze czasu 🙂
          2) Dzięki za info. W wolnej chwili na pewno doczytam.

  2. Testy tego typu należą do typu „kompletnie bezsensownych”. Wystarczy zaimplementować jakiś z prostych algorytmów kodowania / dekodowania wideo. Dlaczego w C# są dostępne jedynie kontenery opakowujące biblioteki C / CPP ? Na to pytanie można odpowiedzieć sobie samemu…

    • Testy przeprowadziłem z czystej ciekawości, dlatego też ich wyniki należy traktować tylko jako ciekawostkę. Do jednych zastosowań lepszy jest C++, a do innych C# – bez względu na ich teoretyczną „szybkość” 😉

  3. Krytykować każdy potrafi. 🙂 Wspólnie z kumplem robimy podobny benchmark języków, konkretnie C#, Javy i Python w ramach pracy naukowej którą zamierzamy opublikować na ResearchGate. Od tak z ciekawości. 🙂 My co prawda koncentrujemy się na czasie wykonania kodu w milisekundach i mamy inne testy, ale twój wpis również chętnie przejrzę. Tylko muszę przypomnieć sobie C++. 🙂

  4. Mierzenie czasu przez DateTime zamiast przykładowo Stopwatch, w C# przekazywanie parametrów przez konstruktor, w C++ wywoływanie dodatkowej metody, w C# ulong, w C++ long long (różnica w znakach), brak wywoływania GC — w tych kodach jest tak dużo popularnych błędów benchmarkowych, że wyniki są kompletnie niereprezentatywne.

    • 1) Jeżeli chodzi o czas, to przy takich wielkościach, nie ma to najmniejszego znaczenia.
      2) Jak w C++ przekazać parametry od razu przez konstruktor? Głowiłem się trochę czasu nad tym i nie chciało działać… Jeżeli przedstawisz rozwiązanie będę bardzo wdzięczny 🙂
      3) Co do ulong i long long… Fakt, moje przeoczenie. Dzięki za zwrócenie uwagi, bo bym tego nie zauważył 🙂
      4) O wywoływaniu GC pisałem już wyżej

      • 1. Mierzysz ułamki sekund i twierdzisz, że dokładność pomiaru czasu nie ma znaczenia?
        2. No jak to jak? Tak samo jak do metod.
        4. No pisałeś, ale wykresy nie są poprawione, więc co chcesz pokazywać? Porównujesz alokację i zwalnianie pamięci, więc zrób testy do końca.
        5. Wykonywałeś testy trzykrotnie i uśredniałeś wyniki, to też bez sensu. Testy powinno się zrobić przynajmniej kilkanaście razy, odrzucić wyniki skrajne i dopiero resztę uśredniać, liczyć mediany i takie tam.

        • 1) Może mieć tylko i wyłącznie w przypadku wykresu z czasami kopiowania tablic. Tutaj przyznaję rację. Dokładność DateTime.Now() to ~15ms. Wcześniej tego nie sprawdzałem, mój błąd.
          2) Kurczę… tak sobie to napisałem teraz jeszcze raz i… działa. Nie mam pojęcia dlaczego wcześniej miałem z tym problemy. W każdym razie: W wynikach różnicy nie ma, przynajmniej w przypadku GCC.
          4)Wyraźnie napisałem we wpisie, że nie usuwam obiektów, tylko wypełniam je null’ami… Ale ok, poprawiłem wykresy i kod. Teraz przedstawiony jest czas po usunięciu obiektów przez GC.
          5) Przy takich wielkościach (poza testem z kopiowaniem tablic), różnica i tak nie byłaby dostrzegalna… Aczkolwiek zgadzam się z tym, że tak byłoby lepiej. Jeżeli masz na takie zabawy czas to zachęcam do przeprowadzenia własnych testów, tym razem „z sensem” 🙂

          PS: Pomijając wszelkie moje błędy… W większości testów C++ i tak wygrywa, pomimo tego, że mój kod optymalny nie był… Z tego chyba można też wyciągnąć jakieś wnioski? 😉

          Pozdrawiam!

          • „Z tego chyba można też wyciągnąć jakieś wnioski?”
            No właśnie o to chodzi w benchmarkach, że wydaje się, że mimo niedociągnięć można wyciągać sensowne wnioski, ale tak nie jest. Wystarczy jakiś UB w C++ i podróżujesz w czasie https://blogs.msdn.microsoft.com/oldnewthing/20140627-00/?p=633 ; wystarczy jakaś usługa w systemie i wszystkie próby są zaburzone przez indeksowanie dysku; wystarczy drobna zmiana w kodzie i ze względu na model pamięci procesor może wykonać zupełnie inny program: https://vimeo.com/171942746 .

            Robienie benchmarków nie jest łatwe, trzeba dokładnie wiedzieć, co i jak się robi, bo rezultaty mogą zwariować na każdym poziomie (począwszy od procesora, a na kodzie C# skończywszy). Ty nie ustrzegłeś się podstawowych błędów, więc nie dziw się, że czytelnicy podchodzą z rezerwą do uzyskanych wyników.

          • Nie dziwię się, że podchodzą do wyników z rezerwą – w końcu mają do tego prawo 🙂
            Kod służący do testów udostępniłem właśnie po to, żeby każdy mógł go przejrzeć i zgłosić swoje uwagi. Dzięki temu mniej świadomy czytelnik może poczytać co sądzą o wpisie inni. Nie chcę nikogo wprowadzać w błąd, dlatego dokładnie napisałem, jak testy przeprowadzałem i przy użyciu jakiego kodu. Za wszystkie uwagi jestem wdzięczny, bo dzięki nim mogę wyciągnąć wnioski na przyszłość i więcej ich nie popełniać. Zresztą jak mniemam przydadzą się one też innym czytającym, tym bardziej jestem za nie wdzięczny 🙂

            Pozdrawiam! 🙂

Możliwość komentowania jest wyłączona.