Jak duże spowolnienie wynika używania języków wysokiego poziomu?

3
kmph napisał(a):

Jednak: Ja postawiłem hipotezę, że ten (zrozumiały) trend odbywa się kosztem wydajności; i że przyspieszanie sprzętu pozwala w zasadzie na wejście na wyższy poziom abstrakcji a nie na przyspieszenie działania programów, bo wejcie na wyższy poziom abstrakcji znosi korzyści z przyspieszenia sprzętu. Ktoś zapodał prawo Wrighta, które zdawałoby się to potwierdzać.

Ty postawiłeś tezę, że jest wręcz odwrotnie: że dla nietrywialnych aplikacji to przejście na wyższe poziomy abstrakcji umożliwia wysoką wydajność, podczas gdy pozostawanie na niskim poziomie abstrakcji tę wydajność zabija - niemal zawsze na odcinku ASM - C, niekiedy także na odcinku C - Java.

W sumie to się zgadzam, przyspieszenie sprzętu pozwala robić o wiele więcej dodaktowych rzeczy, których kiedyś nie robiono. Np. tony dodatkowych sprawdzeń związanych z bezpieczeństwem programu. I się z tego korzysta.
A co do wydajności w językach wyższego poziomu:
To ja w sumie formułuję to tak - jeśli mamy problem z wydajnością w czymś typu Java, C#, C++, w jakimś małym fragmencie kodu to na ogól najprosztszym sposobem poprawienia tej wydajności jest przepisanie tego fragmentu lepiej w tym samym jezyku. Schodzenie niżej jest zwykle nieopłacalne, są wyjątki, ale dość specyficzne. Przez 20 lat kilka razy miałem sytuację, że zastanawiałem się, żeby fragment w Javie przerobić na C/Asm. Zawsze wystarczyło poprawić kod w Javie. Sytuację, że wyniesienie na ASM było opłacalne miałem prawie raz (obługa SSE w Javie 1.6 SUN to była kpina).
(Btw. z drugiej strony kilka razy miałem przypadek gdzie sensowne było przejście na GPU, ale to nie były typowe enterprise projekty, co więcej robiłem to GPU w javie :-) (bo można)).

To, że programy robią tony rzeczy bez sensu i po prostu obecnie te tony uchodzą płazem, bo tylko ocieplają planetę, a użytkownikowi zwisa czy word odpala się jedną czy dwie sekundy to inna sprawa. Gdyby nie wymyślono nic innego niż assembler to pewnie byłoby dokładnie tak samo, tylko programiści więcej by pili. (Jeszcze więcej). Dodatkowo wspólczesny assembler to makra, makra i makra i funkcje systemowe. W zasadzie zastanawiasz się czy to w ogóle jeszcze jest jezyk niskiego poziomu. Czy naprawdę wiesz co faktycznie jest produkowane jako kod maszynowy, i czy masz faktycznie jakikolwiek kontakt ze sprzętem (o ile nie jest to assembler na małe Atari, C64 lub Spectrum to możesz przyjąc, że nie masz).

5

Oszczędzaj RAM gdziekolwiek jesteś. Załóżmy sytuacje teoretyczną - mamy webappke.

Przejście zapytania przez sieć zajmuje, załóżmy, sekundę. Wykonanie i zwrócenie odpowiedzi przez Pythona zajmie około 0,1 sekundy. Potem znowu powrót do użytkownika, czyli kolejna sekunda. Łącznie 2,1 sekundy.

Możemy przepisać ten kod w innym języku, załóżmy, C – kod będzie kilka razy dłuższy, pisanie zajmie go więcej czasu, ale za to wykona się, powiedzmy, 100 razy szybciej. Czyli użytkownik, zamiast poczekać 2,1 sekundy, poczeka 2,001 sekundy, gdyż zazwyczaj to nie sam serwer i kod naszej aplikacji, jest wąskim gardłem, a np. baza danych, połączenie sieciowe czy dysk.

Czy ma to sens w większości przypadków? Przeskok z 2,1 do 2,001s? Sami sobie odpowiedzcie.

Obecnie to raczej nie same języki są jakimiś wąskimi gardłami a baza danych, jakiś zapis odczyt, przesył informacji, albo częściej, niż rzadziej, nieumiejętny/niedbały programista itd. Można by wszystko przepisać z Javy/Pythona itd. na nie wiem, C, ale... Po co?
Jak zejdziemy z 80ms do 72 ms to czy ktoś zauważy różnice, poza nami na benchmarkach? W 99% przypadkach nie, pozostałe przypadki to nie są rzeczy dla mózgów naszego pokroju.

Poza tym nie jest przecież też tak, że ten sam program w asmie wykona się 100 razy szybciej niż w C/Pythonie. Absolutnie nie.

Także, zwłaszcza w webapkach, to nie języki są wąskimi gardłami a bazy danych, I/O, network itd.

Czemu programy nie przyśpieszyły, często wręcz zwolniły mimo komputera kilak razy mocniejszego?

Kiedyś twój program robił wodotrysk.

Dziś klient wymaga by twój program robił wodotrysk, gotował obiad w tle zajmując się dzieckiem i dokonując operacji na otwartym mózgu.

To plus pewne ustępstwa na które się idzie, by szybciej dowieźć produkt - coś tam się zrobi mniej wydajniej, ale załóżmy kilka razy szybciej. Później przyśpieszymy. Co prawda często to później nie następuje, ale wciąż, liczy sie to, że uda ci się wypuścić coś szybciej niż twój konkurent i że to zadziała. Zwłascza w obecnych czasach tfu startupów.

W przykładzie, który rzuciłem wyżej - apka w pythonie co to dwóch studentów ją skleiło w kilka tygodni za jakieś grosze 2.1 s vs super appka w C z 2.001 czasu respona, którą kleić musiało wielu doświadczonych programistów, w kilka miesięcy, budżet miliony. Czas mija wreszcie wypuszczacie to cudo techniki. W międzyczasie ta apka sklecona przez dwóch studenciaków zdobyła jakiś klientów, co prawda miała różne błędy, bywała wolna, ale ogółem jakoś działała, generując jakąś wartość dla biznesu i rozwiązując problemy użyszkodników, powodując, że ludzie się do niej przyzwyczaili. Wtedy wchodzicie wasza piękna nowa błyszcząca appka w C, szybsza, lepsza, ale... Nikt jej nie używa. Proste. Biznes upada, jesteś bankrutem.

Zysk niewspółmierny do kosztu. Proste. Monnies się nie zgadzają, no one gives a shit. W gruncie rzeczy klient (znowu - zaznaczam 99% przypadków) ma to w poważaniu czy ta appka jest w C, Pythonie czy czym tam, czy odpowiada w 2.1 s czy 2.001 s. Ma działać, rozwiązywać jakiś jego problem i koniec.

Co nie zmienia faktu, że teraz za bardzo sobie na pewne rzeczy pozwalamy przez co, jak to ktoś już napisał, mamy bloated software. Szlag mnie przez to często trafia, Tak samo jak landing page ważący po 20 mb psia mać. Albo proces wytwarzania oprogramowani, który często jest biedny i popsuty (jakoś-to-będzie-bylejakizm) czy inne scrum-slavy. Ale co zrobić. Pozostaje swoją robotę robić dobrze i koniec.

Ogółem polecam bardzo w tym temacie ten artykuł: https://tonsky.me/blog/disenchantment/
Wyraża więcej niż 1000 słów.

Tldr; to nie języki są zazwyczaj wąskim gardłem a jak coś się da zrobić prościej w języku wyższego poziomu, to zazwyczaj nie warto sie kłopotać dla mizernego zysku, który jest niewspółmierny do włożonego wysiłku.

Okej, koniec mojego rantu.

0

Zwracam Wam (częściowo) honor. Postanowiłem wreszcie to po prostu sprawdzić. Benchmark C++ vs C#, funkcja Ackermanna:

#include <iostream>
#include <vector>
#include <utility>
#include <stack>
#include <chrono>

using cache_t = std::vector<std::vector<int>>;
using stack_t = std::stack<std::pair<int, int>>;

void ensure_cache_large_enough(int m, int n, cache_t& cache)
{
    if (cache.size() <= m)
    {
        cache.resize(m + 1, std::vector<int>{});
    }

    if (cache[m].size() <= n)
    {
        cache[m].resize(n + 1, 0);
    }
}

int retrieve_or_queue(int m, int n, cache_t& cache, stack_t& stack)
{
    ensure_cache_large_enough(m, n, cache);

    if (cache[m][n] != 0)
    {
        return cache[m][n];
    }
    else
    {
        stack.emplace(m, n);
        return 0;
    }
}

void process_stack(cache_t& cache, stack_t& stack)
{
    while (!stack.empty())
    {
        auto [m, n] = stack.top();

        if (retrieve_or_queue(m, n, cache, stack) != 0)
        {
            stack.pop();
        }
        else if (m == 0)
        {
            cache[m][n] = n + 1;
            stack.pop();
        }
        else if (n == 0)
        {
            auto r = retrieve_or_queue(m - 1, 1, cache, stack);
            if (r != 0)
            {
                cache[m][n] = r;
                stack.pop();
            }
        }
        else
        {
            auto r_in = retrieve_or_queue(m, n - 1, cache, stack);
            if (r_in != 0)
            {
                auto r_out = retrieve_or_queue(m - 1, r_in, cache, stack);
                if (r_out != 0)
                {
                    cache[m][n] = r_out;
                    stack.pop();
                }
            }
        }
    }
}

int ack(int m, int n)
{
    cache_t cache;
    stack_t stack;
    stack.emplace(m, n);

    process_stack(cache, stack);

    return cache[m][n];
}

int main()
{
    int m = 3, n = 20;

    auto start = std::chrono::system_clock::now();
    int res = ack(m, n);
    auto end = std::chrono::system_clock::now();

    std::cout << "A(" << m << ", " << n << ") = " << res << std::endl;
    std::cout << "Took " << std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count() << "ms" << std::endl;
}

Versus:

using System;
using System.Linq;
using System.Collections.Generic;

using Cache = System.Collections.Generic.List<System.Collections.Generic.List<int>>;
using Stack = System.Collections.Generic.Stack<(int, int)>;

namespace csharpackermann
{
    class Program
    {
        static void EnsureCacheLargeEnough(int m, int n, Cache cache)
        {
            if (cache.Count <= m)
            {
                cache.AddRange(Enumerable.Repeat(0, m - cache.Count + 1).Select(_ => new List<int> { }));
            }

            if (cache[m].Count <= n)
            {
                cache[m].AddRange(Enumerable.Repeat(0, n - cache[m].Count + 1));
            }
        }

        static int RetrieveOrQueue(int m, int n, Cache cache, Stack stack)
        {
            EnsureCacheLargeEnough(m, n, cache);

            if (cache[m][n] != 0)
            {
                return cache[m][n];
            }
            else
            {
                stack.Push((m, n));
                return 0;
            }
        }

        static void ProcessStack(Cache cache, Stack stack)
        {
            while (stack.Any())
            {
                var (m, n) = stack.Peek();

                if (RetrieveOrQueue(m, n, cache, stack) != 0)
                {
                    stack.Pop();
                }
                else if (m == 0)
                {
                    cache[m][n] = n + 1;
                    stack.Pop();
                }
                else if (n == 0)
                {
                    var r = RetrieveOrQueue(m - 1, 1, cache, stack);
                    if (r != 0)
                    {
                        cache[m][n] = r;
                        stack.Pop();
                    }
                }
                else
                {
                    var r_in = RetrieveOrQueue(m, n - 1, cache, stack);
                    if (r_in != 0)
                    {
                        var r_out = RetrieveOrQueue(m - 1, r_in, cache, stack);
                        if (r_out != 0)
                        {
                            cache[m][n] = r_out;
                            stack.Pop();
                        }
                    }
                }
            }
        }

        static int Ack(int m, int n)
        {
            var cache = new Cache();
            var stack = new Stack();
            stack.Push((m, n));

            ProcessStack(cache, stack);

            return cache[m][n];
        }

        static void Main(string[] args)
        {
            int m = 3, n = 20;

            var start = DateTime.Now;
            int res = Ack(m, n);
            var end = DateTime.Now;

            Console.WriteLine($"A({m}, {n}) = {res}");
            Console.WriteLine($"Took {Math.Round((end - start).TotalMilliseconds)}ms");
        }
    }
}

Wyniki:

C:\Users\m>source\repos\cppackerman\x64\Release\cppackerman.exe
A(3, 20) = 8388605
Took 2009ms

C:\Users\m>source\repos\csharpackermann\bin\Release\netcoreapp3.1\csharpackermann.exe
A(3, 20) = 8388605
Took 5718ms

Tak więc różnica między C# a C++ ~2.8 krotna. (Przynajmniej na kompilatorach z rodziny Visual Studio na Windowsie). Istotnie, nie jest to wiele.

Włączyłem optymalizacje zarówno na C# jak i na C++ - z tym, że dla C# były to dwie opcje do odptaszkowania w IDE (optymalizacja + wyrzucenie symboli debugowania), dla C++ było to multum opcji które odznaczałem nieco na czuja, może więc by się dało C++ przyspieszyć jeszcze bardziej (np. nie włączyłem optymalizacji na podstawie profilowania).

Przyznaję, że jestem mile zdziwiony: pamiętam, że ileś lat temu robiłem podobny benchmark, tym razem C++ vs Java na Linuxie, i - o ile pamiętam - różnica wyszła dramatyczna na korzyść C++. Niestety, nie mam już tamtego kodu ani dokładnych wyników. Na podstawie tamtych dawniejszych benchmarków wbiłem sobie do głowy, że fakt wykonywania kodu na maszynie wirtualnej musi spowalniać makabrycznie. Może wprowadzenie JITów ulepszyło sytuację pod tym względem?

Dla pełnego obrazu oczywiście przydałoby się dodać jeszcze asm z mikrooptymalizacjami na jednym ekstremum i Haskella na drugim ekstremum. Niestety, to pierwsze wymaga dość ezoterycznej wiedzy, której nie posiadam, więc nie wstawię (AFAIK przyspieszenia między asemblerem wklepanym na pałę a asemblerem napisanym przez kogoś, kto naprawdę wie od czego zależy wydajność mogą być dramatyczne). Co się zaś tyczy Haskella, to obawiam się, że nieco zaburzyłoby to miarodajność porównania: kod C# i C++ tutaj jest dokładnie analogiczny, tymczasem przełożyć takiego kodu na Haskella się nie da tak łatwo (tzn da się, ale byłby to Haskell bardzo nieidiomatyczny), a poza tym znowu - nie do końca wiem, jak żyłować wydajność w Haskellu (pewnie trzeba by użyć mutowalnych tablic do cache, ale czy wtedy w ogóle jest sens używać Haskella?)

0

Przyznaję, że jestem mile zdziwiony: pamiętam, że ileś lat temu robiłem podobny benchmark, tym razem C++ vs Java na Linuxie, i - o ile pamiętam - różnica wyszła dramatyczna na korzyść C++

A pamiętasz kiedy mniej więcej to bylo? Tzn 3 lata temu czy 10 :P

2

Języki typu C++ czy Java mogą być też szybsze od ASM.
1) pod względem czasu implementacji
2) ze względu na szereg optymalizacji które mogą robić w czasie kompilacji, w przypadku C++ to np. eliminacja obliczeń, alokacja rejestrów, zrównoleglanie SIMD
3) ze względu na czytelniejsze struktury łatwiej alokować specyficznie pod procesor (C++)

C w zasadzie nie powinno być szybsze od C++, C++ ma zasadę "zero cost abstractions".
Można specyficzne procedury (CPU-bound) lepiej zoptymalizować pod ASM czy C ale to są bardzo specyficzne przypadki.

Kiedy Java może być szybsza od ASM:

2
kmph napisał(a):

wirtualnej musi spowalniać makabrycznie. Może wprowadzenie JITów ulepszyło sytuację pod tym względem?

Wprowadzenie JITów rzeczywicie dramatycznie polepszyło. I to było w roku 1999 :/ Od tego czasu oczywiście stale JVM (oracle i inne) się poprawia, ale to był najwiekszy przełom (java 1.3.0).

Generalnie JVM i JITy to świnie przy robieniu benchmarków - łatwo dostać wyniki, które nie odpowiadają temu co będzie na produkcji. Jak czasy benchmarku są poniżej 5sekund to na JVM mierzony jest zwykle czas kompilacji i rozgrzewania maszyny, ludzie często robią benchmarki na milisekundy.... Kody benchmarkowe, jesli np. prowadzą do błedów przepełnien itp. dostają kary od JITa (w sensie pzostają nieskompilowane)

Z drugiej strony może się zdarzyć (kilka procent mikrobenchmarków), że czas na JVM wyjdzie nierealistycznie dobry (dead code elimination).

tymczasem przełożyć takiego kodu na Haskella się nie da tak łatwo (tzn da się, ale byłby to Haskell bardzo nieidiomatyczny), a poza tym znowu - nie do końca wiem, jak żyłować wydajność w Haskellu (pewnie trzeba by użyć mutowalnych tablic do cache, ale czy wtedy w ogóle jest sens używać Haskella?)

Nie chiało mi się przekładać tego kodu na haskell - tu masz dyskusje w temacie:
https://stackoverflow.com/que[...]-inefficient-with-haskell-ghc

Dość "naiwne" podejście:

import Data.Function.Memoize

ack :: Int -> Int -> Int
ack 0 n = succ n
ack m 0 = mack (pred m) 1
ack m n = mack (pred m) (mack m (pred n))

mack::Int->Int->Int
mack = memoize2 ack

main = print $ mack 3 20 

dało mi takie czasy (na brudno, na vmce (jeszcze robiłem inne pierdółki)):

8388605

real    2m7.956s
user    7m43.072s
sys 8m7.765s

//ghc 8.6.5 

Czyli - troche to trwało, ale to jednak jest raczej naiwna wersja (nie wiem nawet czy ta memoizacja jest zrobiona dobrze).
(czasy pokazują, że użyte było kilka procków w trakcie obliczeń).

1
kmph napisał(a):

Tak więc różnica między C# a C++ ~2.8 krotna. (Przynajmniej na kompilatorach z rodziny Visual Studio na Windowsie). Istotnie, nie jest to wiele.

3x szybciej to niewiele? Że różnica 50km/h a 150km/h to niewiele? ;)
Jasne, że jak coś się odpala raz na ruski rok to tego nie zauważysz, ale jak coś hula praktycznie non-stop to jednak czuć różnicę.
Problemem jest raczej wszechobecny źle rozumiany OOP oraz bezmyślne wciskanie wszystkiego(często na siłę) we wszelkiego rodzaju frameworki.
Tak z głowy to mogę polecić dwie prezentacje. Dotyczą stricte C++, ale pokazują ile można zyskać nie tyle na zmianie języka co samym podejściu i zrozumieniu konkretnego problemu.
CppCon 2019: Matt Godbolt “Path Tracing Three Ways: A Study of C++ Style”
Pacific++ 2018: Kirit Sælensminde "Designing APIs for performance"

10

To ja się podzielę ostatnim doświadczeniem.
Mamy w naszym projekcie pewien task, który czyta pliki z danymi, obrabia je i zapisuje przetworzone dane na dysku jako nowe pliki. Nie jest istotne co dokładnie robi, ale musi wczytać całe pliki od deski do deski, zdekodować pewien dość mocno pokrecony format danych, wykonać dość prosta logikę na tych danych a następnie zserializować do innego formatu.

Kod napisano w Javie i osiąga zawrotną predkość 10 MB/s... na dysku SSD o wydajności 2+ GB/s i 3GHz CPU. Problemem jest CPU.

Firma zatrudniła eksperta od tuningu Javy. Ekspert w ciągu dwóch miesięcy przepisał kluczowy fragment kodu tak, że nie używa alokacji na stercie, nie używa praktycznie dziedziczenia i interfejsow a obiekty są jedynie miejscami na trzymanie danych w pamięci. Kod przyspieszył jakieś 5-8x. Niestety kod jest bardzo niebezpieczny i nieidiomatyczny - wszystkie obiekty są mutowalne i dostępne w publicznym API. Zalecana ostrożność jak przy kodzie w C. Ba, ten kod wygląda jak C ze składnią Javy.

Później ja dla jaj wziąłem ten kod i w kilka dni przeportowałem go na Rust, jednak bez uciekania się do unsafe i starając się zachować oryginalne, wygodne i bezpieczne API. Pokazałem go ludziom nie znającym Rusta i stwierdzili, że rozumieją jak ten kod działa, więc chyba był wystarczająco czytelny.

Efekt - kod jest kolejne 5x szybszy od tego zoptymalizowanego kodu w Javie. Łącznie jakieś 20-30x szybszy od oryginału. Btw nie jestem ekspertem od Rust - właściwie uczyłem się tego języka jak pisałem kod.

I jeszcze wisienka na torcie - zużycie pamięci. Oryginalny kod w Javie potrzebował minimum 0.5 GB, inaczej wywalał się z OOM.
Kod w Rust potezebował 20 MB, z czego większość to pliki wejściowe zmapowane do pamięci oraz sam segment kodu. Wykorzystanie sterty: 0. Oczywiście to trochę nie fair porównanie, bo oryginalny kod miał więcej ficzerów i prawdopodobnie ładował niepotrzebne rzeczy do pamięci (nieużywane w tym teście).

Przejście zapytania przez sieć zajmuje, załóżmy, sekundę. Wykonanie i zwrócenie odpowiedzi przez Pythona zajmie około 0,1 sekundy. Potem znowu powrót do użytkownika, czyli kolejna sekunda. Łącznie 2,1 sekundy.

Wiem o co Ci chodzi, ale w praktyce realne wartości mają znaczenie i mocno zmieniają wnioski z takiej analizy.

Po pierwsze - sieć 2s? Na edge na wsi czy modemie wdzwanianym na 0202122? Bo ja nawet na lichym ADSL mam 20-40 ms.
Natomiast na kablówce można zejść do 2-10 ms roundtrip. Sieć nie jest na ogół problemem.

Zwrócenie odpowiedzi z Pythona 0,1s - jesli to prawda to jest to masakrycznie wolno. Heca polega na tym, że na serwerze nie jesteś sam. Wystarczy że w tej samej sekundzie przyjdzie akurat 20 zapytań i już poczekasz sobie znacznie dłużej. Poza tym obecne apki webowe nie wykonują jednego zapytania. Często leci sekwencja kilkudziesięciu lub kilkuset zapytań. Większość równolegle, ale teraz wystarczy że jedno z nich trafi na większego laga i użytkownik zobaczy to jako mulenie strony. Dlatego nie powinno się patrzeć wyłącznie na średni czas odpowiedzi, a na percentyle - najlepiej co najmniej 99.9%. I pod tym względem takie wynalazki jak Java/Python itp wypadają bardzo blado. Jeżeli co setne zapytanie potrzebuje 2 sekund, a spa wysyła 100 zapytań na akcje użytkownika, to niemal każdy użytkownik będzie widział taki właśnie beznadziejny czas odpowiedzi.

Teoretyzuję? No nie. JIRA cloud właśnie tak działa w godzinach szczytu. Masakra.

0

Erlang VM. Możesz sobie pisać w Elixirze. Wydajna technologia.

1 użytkowników online, w tym zalogowanych: 0, gości: 1, botów: 0