Technologiczna bieda kompilatora Go

Lubię babrać się w low levelu, to zarówno moja pasja jak i praca. Jestem w trakcie testowania nowego exe protectora i jak to zwykle bywa – testuje nim co popadnie, wliczając wszelkie nietypowe pliki w formacie PE, w tym pliki z różnych kompilatorów i środowisk programistycznych jak np. DMD, Lazarus / FPC, PureBasic, a nawet takie antyki jak WATCOM.

Różne kompilatory stosują całą mase magicznych zabiegów, aby czasami nieświadomie uniemożliwić zabezpieczenie wygenerowanego pliku PE EXE, a ja staram się to ładnie obsłużyć, ominąć, hookować etc.

Dzisiaj przysiadłem sobie do kompilatora języka wprost od Google, czyli – Go.

…i zdębiałem. Ściągnąłem paczkę v1.3.1 dla Windows w wersji 32 bitowej.

O ile protector poradził sobie bez zająknięcia z testowym zabezpieczeniem pliku kompilatora – go.exe, o tyle nie byłbym sobą jeśli bym nie zajrzał do środka, jak strukturalnie zbudowany jest plik z Go (biorąc pod uwagę, że kompilator Go zbudowany jest ze źródeł w C oraz samym języku Go).

A w środku szalone lata 90 :), spójrzmy na to razem:

1. Mania używania CLD

CLD to instrukcja procesora ustawiająca flagę kierunku wykonywania operacji takich jak rep stosb, rep movsd, scasb, jej zresetowanie powoduje, że operacje te wykonują się bajt po bajcie w kolejności rosnącej, jej ustawienie powoduje, że operacje te wykonują się odwrotnie (czyli od końca do przodu).

Problem w tym, że na systemach Windows ta flaga jest domyślnie już wyzerowana, a funkcje WinApi nigdy jej nie ustawiają, rzadkością są w ogóle przypadki, że kiedykolwiek jest ona ustawiana instrukcją STD, chyba jedynie w jakichś egzotycznych przykładach napisanych w assemblerze.

W binarce go.exe znajdziemy tego całą masę:

.text:00473CF8                 call    runtime_emptyfunc
.text:00473CFD                 cld
.text:00473CFE                 call    runtime_check

W 1 bloku kodu (a co, kto bogatemu zabroni):

.text:004BCB1E                 lea     esi, off_742838
.text:004BCB24                 lea     edi, [esp+88h+var_80]
.text:004BCB28                 cld
.text:004BCB29                 movsd
.text:004BCB2A                 movsd
.text:004BCB2B                 lea     esi, off_740DF8
.text:004BCB31                 lea     edi, [esp+88h+var_78]
.text:004BCB35                 cld
.text:004BCB36                 movsd
.text:004BCB37                 movsd
.text:004BCB38                 mov     [esp+88h+var_70], 0FFFFFFFFh

Jeszcze tutaj:

.text:00650751                 lea     esi, [esp+68h+var_44]
.text:00650755                 lea     edi, [esp+68h+var_24]
.text:00650759                 cld
.text:0065075A                 call    sub_475CA0

I w całej masie kodu wszędzie CLD. Co ciekawe nie znalazłem w binarce ani jednej instrukcji STD, która sprawiałaby, że trzeba użyć CLD 😉

2. Undefined behaviour

W assemblerze znajdziemy instrukcje, których znaczenia nie zna nawet taki wymiatacz assemblera jak Rysio z Klanu. Wśród nich znajdziemy tajemniczą instrukcję UD2, lata temu stosowałem ją w metodach antydebug ze względu na to, że deasemblery w popularnych debuggerach nie wiedziały co z nią robić. Obecnie jest udokumentowana jako celowo nieprawidłowa instrukcja do testowania (sprytnie).

Jej zastosowanie w kodzie generowanym w nowoczesnych kompilatorach praktycznie nie istnieje. Na co komuś instrukcja, która niczego nie robi (oprócz linuxiarzy)? W kodzie kompilatora Go znalazła zastosowanie:

.text:004BC84B loc_4BC84B:                             ; CODE XREF: path_filepath_volumeNameLen+1BAj
.text:004BC84B                 call    runtime_panicindex
.text:004BC850                 ud2
.text:004BC852 ; ---------------------------------------------------------------------------
.text:004BC852
.text:004BC852 loc_4BC852:                             ; CODE XREF: path_filepath_volumeNameLen+1C8j
.text:004BC852                                         ; path_filepath_volumeNameLen+1CFj
.text:004BC852                 mov     ebp, 1
.text:004BC857                 jmp     short loc_4BC833
.text:004BC859 ; ---------------------------------------------------------------------------
.text:004BC859
.text:004BC859 loc_4BC859:                             ; CODE XREF: path_filepath_volumeNameLen+198j
.text:004BC859                 call    runtime_panicindex
.text:004BC85E                 ud2
.text:004BC860 ; ---------------------------------------------------------------------------
.text:004BC860
.text:004BC860 loc_4BC860:                             ; CODE XREF: path_filepath_volumeNameLen+184j
.text:004BC860                                         ; path_filepath_volumeNameLen+18Bj
.text:004BC860                 mov     ebp, 1
.text:004BC865                 jmp     short loc_4BC7EF
.text:004BC867 ; ---------------------------------------------------------------------------
.text:004BC867
.text:004BC867 loc_4BC867:                             ; CODE XREF: path_filepath_volumeNameLen+172j
.text:004BC867                 call    runtime_panicindex
.text:004BC86C                 ud2

Jest ona tu zastosowana jako bariera, upewniająca, że po runtime_panicindex nic nie zostanie wykonane. Tak dla picu i marnotrastwa miejsca. To tak jakby po ExitProcess() dać dla bajery int 3.

3. Optymalizacyjne potworki

Są takie instrukcje, których manuale zarówno Intela jak i AMD rekomendują omijać szerokim łukiem. Dlaczego? Bo to archaiczne twory z początków procesorów 386, które przetrwały na liście instrukcji, jednak nikt ich już od tamtych czasów nie optymalizował w silikonie i nikt ich nie używa, oprócz paru szalonych programistów assemblera, którzy optymalizują kod pod względem rozmiaru.

Jedną z takich instrukcji jest xchg, która wymienia zawartość 2 rejestrów, od czasów MS-DOS nie widziałem, żeby jakikolwiek kompilator generował tą instrukcję w kodzie (oprócz ręcznych wstawek w assemblerze). A tutaj mamy tego całą masę:

.text:004BC7C4                 xchg    eax, ebp
.text:004BC7C5                 cmp     al, 0
.text:004BC7C8                 xchg    eax, ebp
.text:004BC7C9                 jz      loc_4BC86E
.text:004BC7CF                 inc     eax
.text:004BC7D0                 cmp     eax, ecx
.text:004BC7D2                 jnb     loc_4BC867
.text:004BC7D8                 lea     ebp, [edx+eax]
.text:004BC7DB                 movzx   ebp, byte ptr [ebp+0]
.text:004BC7DF                 xchg    eax, ebp
.text:004BC7E0                 cmp     al, 5Ch
.text:004BC7E3                 xchg    eax, ebp
.text:004BC7E4                 jz      short loc_4BC860
.text:004BC7E6                 xchg    eax, ebp
.text:004BC7E7                 cmp     al, 2Fh
.text:004BC7EA                 xchg    eax, ebp
.text:004BC7EB                 jz      short loc_4BC860
.text:004BC7ED                 xor     ebp, ebp

Na pohybel  manualom :P, już nawet nie mówię, że ten kod wygląda tak biednie, że mam ochotę dać darowiznę, nawet niewprawiony programista assemblera lepiej by to napisał.

Kolejny:

.text:00473F19                 mov     eax, offset runtime_call16
.text:00473F1E                 jmp     eax

Podpowiem tym razem: jmp runtime_call16 i jesteście 2 bajty do przodu, niczego nie tracąc, a wręcz zyskując, jmp w tym wypadku to relatywny skok, więc wynikowy plik PE EXE będzie jeszcze mniejszy o rozmiar relokacji używany w instrukcji mov eax,adres_w_pamieci.

I znowu:

.text:0066A88E                 and     ebx, 7
.text:0066A891                 cmp     ebx, 0
.text:0066A894                 jz      short loc_66A89F

Każdy lamer zaczynający programować w assemblerze później czy prędzej dowie się, że instrukcje takie jak and, or, xor ustawiają również flagi w zależności od wyniku wykonanej operacji, nie trzeba dodatkowo stosować porównania do sprawdzenia flagi.

Go lubi nadmiarowy kod:

.text:005FEE6C                 xor     ebx, ebx
.text:005FEE6E                 cmp     eax, ebx
.text:005FEE70                 jz      short loc_5FEEA4

Komentarz? 3 instrukcje do sprawdzenia czy rejestr EAX zawiera wartość 0. Na całym świecie kompilatorowym załatwiają to 2 instrukcje test eax,eax lub cmp eax,0 + skok.

4. Na pohybel Windowsowi

Nikt o zdrowych zmysłach nie korzysta w produkcyjnym kodzie ze struktur takich jak np. PEB czy TEB, gdyż wskutek ewolucji systemów operacyjnych, ich pola zawsze mogą się zmienić. Nikt oprócz kompilatora go.exe, który radośnie wykorzystuje go do śledzenia stosu i w razie potrzeby zwiększania jego rozmiaru:

.text:00473E10                 mov     ecx, large fs:14h
.text:00473E17                 mov     ebx, [ecx+4]
.text:00473E1D                 mov     esi, [ebx]

Kto o zdrowych zmysłach i wbrew dokumentacji, która wyraźnie zaznacza, że struktury te mogą się zmienić w kolejnych wersjach systemu operacyjnego decyduje się na korzystanie z nich? Chyba ktoś, kto nigdy swojego oprogramowania nie testował na 2 różnych wersjach systemu Windows, albo nie zakłada, że będzie on musiał działać na innym OS.

5. Mamy swój standard

W świecie Windows istnieje określony sposób wywoływania funkcji WinApi – określany mianem stdcall, który zakłada, że kolejne parametry są wrzucane na stos i następuje wywołanie zadanej funkcji. Seria instrukcji push zakończona instrukcją call. Proste? Za proste! Skomplikujmy to. Oto jak wygląda wywoływanie funkcji WinApi w go.exe:

.text:00458C33                 mov     eax, SetConsoleCtrlHandler
.text:00458C39                 mov     [esp+14h+var_14], eax
.text:00458C3C                 mov     eax, 2
.text:00458C41                 mov     [esp+14h+var_10], eax
.text:00458C45                 mov     eax, offset runtime_ctrlhandler
.text:00458C4A                 mov     [esp+14h+var_C], eax
.text:00458C4E                 mov     eax, 1
.text:00458C53                 mov     [esp+14h+var_8], eax
.text:00458C57                 call    runtime_stdcall

Czyli istnieje sobie wrapper o sweetaśnej nazwie runtime_stdcall(), któremu przekazywana jest funkcja jako wskaźnik, parametry, a wrapper sam wywołuje instrukcję call. Po co? Kto bogatemu zabroni!

Wnioski

Pierwszy jest taki, że jestem cholernie zawiedziony jakością kodu Go, jak widać można mieć górę pieniędzy, a i tak być technologicznie zacofanym do epoki kamienia łupanego.

Wiem, że w dobie nowych procesorów Intel Core i7 mało kto zwraca uwagę na takie detale, jednak nie wiem czy chciałbym aby moje oprogramowanie i algorytmy były kompilowane do takiej formy jaką obecnie oferuje najnowszy kompilator Go. To się po prostu nie dodaje jak mówi Mariusz Max Kolonko…

Na koniec jeszcze dump z programu Hello World w Go:

package main

import "fmt"

func main() {
	fmt.Println("Hello, 世界")
}
.text:00401000 main_main       proc near               ; CODE XREF: main_main+1Aj
.text:00401000                                         ; runtime_main+119p
.text:00401000                                         ; DATA XREF: runtime_sighandler+42o
.text:00401000                                         ; .text:off_4CFD0Co
.text:00401000
.text:00401000 var_40          = dword ptr -40h
.text:00401000 var_3C          = dword ptr -3Ch
.text:00401000 var_38          = dword ptr -38h
.text:00401000 var_1C          = dword ptr -1Ch
.text:00401000 var_18          = dword ptr -18h
.text:00401000 var_14          = byte ptr -14h
.text:00401000 var_C           = dword ptr -0Ch
.text:00401000 var_8           = dword ptr -8
.text:00401000 var_4           = dword ptr -4
.text:00401000
.text:00401000                 mov     ecx, large fs:14h
.text:00401007                 mov     ecx, [ecx+0]
.text:0040100D                 cmp     esp, [ecx]
.text:0040100F                 ja      short loc_40101C
.text:00401011                 xor     edi, edi
.text:00401013                 xor     eax, eax
.text:00401015                 call    runtime_morestack_noctxt
.text:0040101A                 jmp     short main_main
.text:0040101C ; ---------------------------------------------------------------------------
.text:0040101C
.text:0040101C loc_40101C:                             ; CODE XREF: main_main+Fj
.text:0040101C                 sub     esp, 40h
.text:0040101F                 lea     ebx, off_4B1260
.text:00401025                 mov     ebp, [ebx]
.text:00401027                 mov     [esp+40h+var_1C], ebp
.text:0040102B                 mov     ebp, [ebx+4]
.text:0040102E                 mov     [esp+40h+var_18], ebp
.text:00401032                 lea     edi, [esp+40h+var_14]
.text:00401036                 xor     eax, eax
.text:00401038                 stosd
.text:00401039                 stosd
.text:0040103A                 lea     ebx, [esp+40h+var_14]
.text:0040103E                 cmp     ebx, 0
.text:00401041                 jz      short loc_401096
.text:00401043
.text:00401043 loc_401043:                             ; CODE XREF: main_main+98j
.text:00401043                 mov     [esp+40h+var_C], ebx
.text:00401047                 mov     [esp+40h+var_8], 1
.text:0040104F                 mov     [esp+40h+var_4], 1
.text:00401057                 mov     [esp+40h+var_40], offset dword_48CEE0
.text:0040105E                 lea     ebx, [esp+40h+var_1C]
.text:00401062                 mov     [esp+40h+var_3C], ebx
.text:00401066                 call    runtime_convT2E
.text:0040106B                 mov     edx, [esp+40h+var_C]
.text:0040106F                 lea     ebx, [esp+40h+var_38]
.text:00401073                 mov     esi, ebx
.text:00401075                 mov     edi, edx
.text:00401077                 cld
.text:00401078                 movsd
.text:00401079                 movsd
.text:0040107A                 mov     [esp+40h+var_40], edx
.text:0040107D                 mov     ebx, [esp+40h+var_8]
.text:00401081                 mov     [esp+40h+var_3C], ebx
.text:00401085                 mov     ebx, [esp+40h+var_4]
.text:00401089                 mov     [esp+40h+var_38], ebx
.text:0040108D                 call    fmt_Println
.text:00401092                 add     esp, 40h
.text:00401095                 retn
.text:00401096 ; ---------------------------------------------------------------------------
.text:00401096
.text:00401096 loc_401096:                             ; CODE XREF: main_main+41j
.text:00401096                 mov     [ebx], eax
.text:00401098                 jmp     short loc_401043
.text:00401098 main_main       endp

Jeśli i wy doceniacie piękno assemblera i gardzicie lipnym kodem wpiszcie w komentarzach waszą wersję tej funkcji, najlepszy wpis czeka nagrodapierwszy plakat formatu PE (bo już nie mam go gdzie powiesić). Nie spodziewam się rewelacji z waszej strony, ale kto wie 😉

Komentarze (31)

antek

Nie samym wynikowym kodem asm kompilator żyje ;), szczególnie, gdy za cel ma się produkowanie oprogramowania głównie wielowątkowego.

Odpowiedz
bartek

@antek: Co ma wielowątkowość do miernej jakości kodu wynikowego? Jakby wszyscy myśleli tak jak ty, to kompilatorów nikt by nie rozwijał… bo po co? A tak mamy LLVM + clang-a, Intel ICC, które zmiatają ten mierny kompilator Go bez większego problemu oferując wielowątkowe programowanie oparte np. na OpenMP.

@OoOo: a teraz przeczytaj jeszcze raz co napisałem, bo mi ręce opadają. W ogóle mówisz, że twój kod przejdzie kodując te chińskie znaki unicode jako db i deklarując ich rozmiar na 1 bajt? Chodziło o przepisanie tego kodu z Go na bardziej zoptymalizowaną wersję.

Odpowiedz
bartek

@MarMar: Ty na serio? Powstał w wyniku kompilacji kodu Go i C do kodu wynikowego ;), jest skutkiem ubocznym nieudolnych zdolności optymalizacyjnych kompilatora Go. Nic więcej. Nie ma niczego na celu.

Odpowiedz
OoOo

“Jeśli i wy doceniacie piękno assemblera i gardzicie lipnym kodem wpiszcie w komentarzach waszą wersję tej funkcji…” – ja to zrozumiałem, że chcesz hello world w asm.

A co do wykonania wklejonego kodu to:
$ nasm -f elf64 *.s && ld *.
$ ./a.out
Hello, 世界

Odpowiedz
antek

@bartek: ale co to za porównania ;). Go osiągnęło stabilność w 2012r, a najmłodszy clang/LLVM istnieje od 10 lat i dopiero od niedawna zyskał popularność, jako alternatywa gcc, pomijając zupełnie fakt, że jest to czysty kompilator, bez projektu nowego języka.

Gdyby każdy myślał jak ja, to każdy skupiałby się na jednej rzeczy, a nie na wszystkich jednocześnie, czyli tak jak jest teraz – twórcy Go skupiają się na języku a nie na kompilatorze, a twórcy kompilatorów skupiają się na kompilatorach, nie językach 🙂

Odpowiedz
bartek

@Antek: Zauważ, że ja słowem nie wspomniałem o języku programowania Go jako takim.

Analizowałem jedynie kompilator i jego kod wynikowy.

Język Go posiada dedykowany kompilator, który także ktoś zbudować musiał.

Za wszystkim stoi Google z niewyobrażalnym potencjałem ludzkim i finansowym, a mimo tego takiego potworka wyprodukowali, dlatego w ogóle przysiadłem do tego tematu, bo wydało mi się to po prostu dziwne.

A jak wspomniałem lubię grzebać się w low levelu 🙂

Odpowiedz
MarMar

Może na linuksie wygląda to lepiej. Co oni nie przepuszczają tego przez jakies optymalizatory z LLVM? Ale generalnie od komilatora nie wymaga sie jakies super wydajnosci czy urody, wazne zeby buildboty nadążały z produkcją binarek. Google i tak ma rozproszone systemy do budowania.

Odpowiedz
bartek

@MarMar: No ale co z tego, że Google ma rozproszone systemy budowania? Jak ty masz ten kompilator u siebie i chcesz sobie zbudować coś. LLVM to to nie jest najwyraźniej. Czemu na linuxie ma to inaczej wyglądać, czyżby tam instrukcje x86 nagle inaczej wyglądały?

Odpowiedz
wmu

@bartek: “Język Go posiada dedykowany kompilator, który także ktoś zbudować musiał.”

Tak, ale wszystko wskazuje, że ten kompilator jest po prostu stary – ma z 5 lat lub nawet więcej. I jest raczej przenośny, więc nie ma takiej wiedzy, że pod windowsami cli jest zbędne. Po prostu wzięli, co było (coś z Plan9) i dostosowali [tak piszą w FAQ].

Poza tym pytanie, czy jest sens to coś poprawiać, skoro powstaje front na GCC, który ma świetną optymalizację by default.

Odpowiedz
bartek

@wmu: kompialtor Go nie ma 5 lat. Sprawdź sam:

go1.3 (released 2014/06/18)https://golang.org/doc/go1.3

“The latest Go release, version 1.3, arrives six months after 1.2, and contains no language changes. It focuses primarily on implementation work, providing precise garbage collection, a major refactoring of the compiler tool chain…”

Ten syf z powtarzaniem CLD to nie jedyne co wskazałem.

Odpowiedz
R

W jakim manualu jest napisany by unikać xchg? W manualu Intela znalazłem tylko, że przy wymianie z pamięcią zawsze ustawia sygnał lock i jest przydatna w implementacji semaforów (co prawda tylko pobieżnie przeglądnąłem, ale zalecenie by nie używać jakiejś instrukcji powinno bić po oczach).

Co do TEB, to kod generowany przez Microsoft Visual C++ używa bezpośrednio kilku pól tej struktury, a biblioteka standardowa jeszcze kilku następnych, więc co do części pól można mieć pewność, że na pewno nie ulegną zmianie (chyba Microsoft nie chciałby, żeby na którejś wersji Windowsa ot tak przestały działać programy skompilowane jego narzędziami)

Odpowiedz
bartek

@R: W żadnym nie jest napisane żeby nie używać, możesz używać czego chcesz, jednak spójrz sobie na czasy wykonywania instrukcji XCHG i zrozumiesz dlaczego żaden kompilator nie używa tej instrukcji poza implementacją semaforów (czyli jak piszesz bezpośrednio z wymianą z pamięcią), jednak xchg reg1,reg2 nigdzie nie znajdziesz poza badziewnymi kompilatorami jak ten z Go.

Z tym TEB masz rację. Przejrzałem źródła CRT i nic innego nie znalazłem oprócz SEH i StackBase, tak się akurat składa, że to 2 pierwsze pola struktury TEB no i tego raczej nigdy nie zmienią, co do reszty pól to już nie byłbym taki pewien, struktura jest nieudokumentowana oficjalnie więc po co cisnąć się do używania czegoś co może w każdej kolejnej wersji Windows ulec zmianie?

Odpowiedz
wmu

@bartek: ok, popatrzyłem na historię generatora kodu dla x86 (src/cmd/6c) i widać, że jednak coś tam robią.

Co do xchg nie masz do końca racji, ta instrukcja jest bardzo szybka w nowych procesorach gdy działa tylko na rejestrach (1,5 cykli opóźnienia, 0.25 cykli przepustowości).

Używanie nieudokumentowanych pól zwykle ma jakiś cel, nikt tego nie robi bo można. 🙂 Kolega np. ustawia nazwy wątków, które ładnie wyświetlają się w debuggerze; łatwiej się zorientować, co się wywala.

Odpowiedz
bartek

@wmu: może robią, jednak rezultaty jak widzisz nie są zachwycające.

Co do xchg i nowych procesorów, np. na AMD xchg jest wykonywanie w trybie VectorPath, w przeciwieństwie do instrukcji wykonywanych w trybie DirectPath, które mogą być dekodowane kilka na raz, nie bez przyczyny trudno znaleźć taką instrukcję w binarkach, ja szczerze mówiąc jak siedzę w deadlistingach tyle lat, oprócz semaforów implementowanych na xchg reg,[mem] nie widziałem w kompilatorach VC, Borlanda, Delphi żeby gdziekolwiek używane były instrukcje xchg reg,reg. Poza tym konieczność użycia xchg często świadczy o źle zaprojektowanym / zoptymalizowanym fragmencie kodu co widać po serii wielu xchg w podanym przykładzie dla Go.

Nazwy wątków i ich ustawianie jest akurat udokumentowane oficjalnie na MSDN

http://msdn.microsoft.com/en-us/library/xcb2z8hs.aspx

Nie trzeba korzystać z TEB i pvArbitrary.

Odpowiedz
wmu

@bartek: co do użycia xchg to też nie widziałem, a siedzę na MSVC i GCC… sam nie używałem xchg od nie wiadomo ilu lat. Dlatego bardzo się zdziwiłem, że xchg reg, reg został przez intela tak miło potraktowany. 🙂

Z tym użyciem nieudokumentowanych coś poplątałem, w każdym razie koledzy się chwalili, że robili jakieś nielegalne rzeczy. 🙂 Ale teraz jestem naocznym świadkiem, jak nowy kolega przygotowuje przyzwoite API na nieudokumentowane bebechy MSVC. To nam pomoże, a ryzyko, że się może zepsuć po aktualizacji mamy wliczone.

Odpowiedz
bartek

@wmu: a to heheszki :), ja się wyleczyłem z używania undocumented features w trakcie pisania exe-protectora, połowa metod antydebug na coraz nowszych systemach Windows wywalała się, druga połowa okazała się zupełnie nieskuteczna, a jeszcze jedna oprócz wykrywania debuggerów wykrywała przypadkowo VMware (co moim klientom nie bardzo się spodobało). Korzystanie z PEB i TEB jak już robię to bardzo ostrożnie i po uprzednim sprawdzeniu wersji Windows, bo też cuda nie raz wychodziły np. przy opcjach bootowania z parametrem /3GB.

Odpowiedz
Piotr

Bez urazy Bartoszu, ale Twoja krytyka kompilatora Go w tym poście jest mniej więcej tak samo głęboka jak Twoja krytyka Kościoła Katolickiego w innym poście. Przyczepiłeś się kilku niskopoziomowych szczegółów – ignorując kompletnie okoliczności powstania Go oraz priorytetów jakie zostały postawione przed tym językiem. A jednym z głównych priorytetów Go jest szybkość kompilacji, prostota języka oraz łatwa integracja systemów (stąd zarąbiscie rozwiązana współbieżność – z tym OpenMP to już strzeliłeś kulą w płot; to się nawet nie umywa do modelu Go; ba, ten archaizm się w ogóle nie umywa do niczego w nowoczesnych językach wysokopoziomowych np. Erlang, Scala, Closure).

Dlatego porównywać Go do innych kompilatorów (np. gcc albo VS) będziesz mógł, jak te kompilatory będą kompilować porównywalnie szybko jak Go, a języki będą oferować podobne udogodnienia. Póki co są one koszmarnie powolne, są jednymi z najpowolniejszych kompilatorów – nawet kompilator Scala krytykowany często za powolność jest przy nich demonem szybkości. Porównujesz po prostu gruszki z jabłkami.

Zresztą, abstrahując nawet od nieuwzględnienia przez Ciebie kontekstu, Twoja krytyka jest od strony technicznej bardzo biedna. W dużej części postu wałkujesz to nieszczęsne xchg albo nadmiarowe xor/cmp, które nie zeżre nawet 1 cyklu CPU w większości przypadków. W innej części postu rozpaczasz nad dodatkową warstwą abstrakcji nad WinAPI i dodatkowym callem + kilkoma instrukcjami push/pop, co jest śmiesznie niskim narzutem na typowy czas wywołania funkcji WinAPI; nikt tego nie zauważy. Pozostałe części to tylko wywody nad tym, że ktoś użył instrukcji nieużywanej gdzieś indziej (ud2) i zmarnował kilka bajtów, co jest bez najmniejszego znaczenia w projektach, do których używa się Go. Oczywiście, z każdą z tych rzecz *masz jakąś tam rację*, tylko że to jest zpełnie bez znaczenia. Sprawia to ogólnie wrażenie, jakbyś chciał strasznie zaszpanować znajomością książki opcodów (czego nie kwestionuję).

Jak już chcesz krytykować kompilator pod względem jakości generowanego kodu, to powinieneś skupić się na rzeczach, które *mają duże znaczenie* i przekładają się na wydajność: inlining kodu i danych, rozwijanie i wektoryzacja pętli, alokacja rejestrów, prefetching, aliasing, usuwanie null/range checków oraz optymalizacje związane z zagadnieniami wysokopoziomowymi np. lock-elision / biased-locking / unikanie alokacji dynamicznej itp. Ale tego nie zrobiłeś, bo to jednak wymaga znacznie większej wiedzy o kompilatorach, CPU, pamięci, językach programowania (zwłaszcza wysokopoziomowych) niż tylko znajomość zestawu instrukcji procesora oraz znacznie więcej czasu na przygotowanie.

“Za wszystkim stoi Google z niewyobrażalnym potencjałem ludzkim i finansowym, a mimo tego takiego potworka wyprodukowali, dlatego w ogóle przysiadłem do tego tematu, bo wydało mi się to po prostu dziwne.”

Wydało Ci się to dziwne, bo masz nikłe doświadczenie w zespołowych projektach komercyjnych, a tym bardziej w projektach tej skali, co robi Google. Podejrzewam też, że w życiu nie napisałeś kompilatora, a jeśli już to jedynie jako ćwiczenie, a nie kompilator działający w projekcie produkcyjnym, który ma realnych użytkowników.

Ale skoro się dziwisz, to Ci troszkę przybliżę temat. Google traktuje Go jako projekt eksperymentalny, stworzony głównie na własne potrzeby (jeden z bardzo wielu zwariowanych projektów, obok samojeżdżących samochodów, internetu opartego na balonach i okularów z wbudowanym smartfonem). Wszystkie krytyczne systemy Google, na których Google zarabia pieniądze *nie są napisane w Go* i długo nie będą. Google jest świadom niskiej wydajności kodu produkowanego przez Go (w porównaniu z Javą i C++), nigdzie nie zaleca używania Go do projektów wymagających wysokiej wydajności i nigdy wydajność ani wielkość kodu wynikowego Go nie była priorytetem dla Google, bo Go do takich rzeczy nie służy. Google zarządzają pragmatycy, a nie idealiści, i nikt nie będzie pakował milionów dolarów w kompilator języka, tylko dlatego bo jeden koleś gdzieś na Internetach nie lubi instrukcji ud2 albo xchg, albo nawet dlatego, że to samo napisane w Javie/C++ wykonałoby się 10x szybciej. Po prostu, jak wydajność będzie problemem, użyją Javy/C++; a jeśli mimo wszystko ludzie będą bardzo chcieli mieć szybkie Go, to kompilator pozostawią społeczności (i tak się właśnie dzieje). Teraz kumasz?

Podsumowując, Twoja krytyka wygląda mniej więcej tak: Pewna firma wyprodukowała samochód do jazdy po mieście, a Ty się czepiasz, że nie zrobili odpowiedniego spoilera a silnik nie ma turbo i nie da się przez to tym autem jechać 250 km/h na torze wyścigowym. W skrócie: bez sensu.

Odpowiedz
bartek

@Piotr: tytuł wpisu mówi o kompilatorze, nie o języku. Drobne szczegóły *mają znaczenie* czy Ci się to podoba, czy nie. Dlaczego kompilatory Microsoft czy Intela nie produkują takiego badziewnego kodu, skoro jak mówisz 1 czy 2 cykle tu i tam “nie mają znaczenia”? Bo po prostu mają znaczenie i główni gracze na tym rynku doskonale o tym wiedzą. Z twojego komentarza wynika, że wydajność i jakość kodu nie liczą się, gdy jakiś język oferuje dodatkowe funkcje programiście. Tylko na co komu te funkcje jak kod będzie po prostu działał wolno, sam mówisz, że Google nie używa Go do żadnego poważnego projektu (!).

“Książka” z opcodami to ciekawa lektura i jak widać niektórzy powinni jeszcze raz do niej zajrzeć, zwłaszcza jeśli komentują analizę kompilatora, nie mówiąc o twórcach kompilatorów.

Skoro masz takie doświadczenie w komercyjnych projektach grupowych, proponuję abyś ze swoim akademickim dorobkiem 3 programów, zapukał do drzwi Intela lub Microsoft i przekonał ich o swojej genialnej teorii paru cykli bez znaczenia i że przez te wszystkie lata marnowali czas na dopracowywanie kompilatorów.

Reasumując – mamy sobie język Go z zajebistymi funkcjami, wielowątkowością i nikt go nie używa do poważnych projektów. Gorszej rekomendacji chyba nie można mieć.

Odpowiedz
Piotr

> Dlaczego kompilatory Microsoft czy Intela nie produkują takiego badziewnego kodu, skoro jak mówisz 1 czy 2 cykle tu i tam “nie mają znaczenia”?

1. Istnieją na rynku znacznie dłużej niż kompilator Go. Pierwsze wersje gcc też produkowały beznadziejny kod. Pamiętam czasy jak gcc regularnie dostawało bęcki od icc i to tak gdzieś 2x-3x. Teraz sytuacja się wyrównała, a nawet odwróciła. Zresztą, ostatnio widziałem jak gcc dostało bęcki na ponad 2x od JVM w wektoryzacji pętli… 😀 Więc gcc, też jeszcze daleko do optymalności.

2. Język, który kompilują jest językiem znacznie niższego poziomu – kompilator ma znacznie mniej do optymalizowania i znacznie bardziej związane ręce; więc może się skupić na szczegółach takich jak pojedyncze instrukcje, bo “ciekawe” optymalizacje leżą niemalże całkowicie w gestii programisty.

3. Mają inne priorytety – przedkładają jakość kodu wynikowego nad szybkość kompilacji.

Poza tym 1 czy 2 cykle bardzo rzadko mają znaczenie – obecnie optymalizacja kodu to właściwie wyłącznie:
a) optymalizacje algorytmiczne
b) optymalizacje dostępu do dysku,
c) optymalizacje dostępu do pamięci (cache awareness)
d) współbieżność/równoległość i optymalizacja (unikanie) blokad

Zły algorytm to mogą być miliony zmarnowanych cykli CPU, contended lock to setki tysięcy cykli mogą być, uncontended lock to 200-1000 cykli CPU, CAS to 50-200 cykli, cache L2 miss to 500-1000 cykli, cache L1 miss to 20-50 cykli, źle przewidziany skok to 7-15 cykli. A Ty się na jakimś głupim xchg skoncentrowałeś, które zajmie 0 cykli w większości sytuacji, bo poleci w równoległym potoku, albo instrukcjami, które w ogóle nie są wykonywane (ud2).

“nikt go nie używa do poważnych projektów”
Nadinterpretujesz to co napisałem. Nikt Go nie używa do projektów wymagających bardzo wysokiej wydajności np. obliczeń numerycznych. Tak jak nikt nie używa Pythona do projektów, gdzie liczy się równoległość (GIL). Nie zmienia to faktu, że są zastosowania w bardzo poważnych projektach, gdzie Go i Python sprawdzą się znacznie lepiej niż C/C++.

A jakość produkowanego kodu to tylko jedno z bardzo wielu kryteriów; najczęściej nienajważniejsze. Większość projektów ma być wystarczająco szybka, a nie najszybsza, za wszelką cenę. Jakby było tak ważne jak piszesz, to wszyscy by programowali tylko w C, C++ i Fortranie, a inne języki by nie istniały.

Odpowiedz
bartek

@Piotr: ty cały czas o algorytmach i języku, a mój wpis dotyczył niskopoziomowej struktury wygenerowanego kodu, która jest po prostu z lamusa w przypadku kompilatora Go w porównaniu do ICC, Intela, GCC czy LLVM. Ciekawie piszesz i masz rację co do tego, że algorytmy, cache czy dostęp do dysku to podstawa, ale co to ma wspólnego z generowanym kodem i jakością, twoje porównanie do auta jest jak najbardziej na miejscu, wyprodukowali auto, a ja się czepiam kątów frezowania elementów silnika, bez lepszych parametrów twoje auto pojedzie, ale szybko zostanie w tyle za całą resztą.

Myślę, że np dla twórców gier, silników przeglądarek internetowych (gdzie toczy się bitwa o prędkość wykonywania JavaScript), kodeków wideo (SSE) jakość generowanego kodu jest bardzo ważna i sam na co dzień masz okazję korzystać z dobrodziejstw możliwie jak najszybszego kodu.

Odpowiedz
Piotr

Nie, ja nie o algorytmach i języku, a o kompilatorach. O algorytmach było tylko w kontekście tego na ile ważne są pojedyncze cykle CPU. Nikt obecnie nie optymalizuje kodu “pod pojedyncze instrukcje”, nawet w zastosowaniach o których piszesz (gry, kodeki, silniki JavaScript); a jeśli już, to taka optymalizacja jest jedną z ostatnich, mających najmniejsze znaczenie. W kompilatorze te optymalizacje o których Ty piszesz też mają jeden z najniższych priorytetów. Kod wymagający najwyższej wydajności optymalizuje się pod względem cache-misses, bo to one zwykle są wąskim gardłem. Przypominam, że 1 cache miss = duże kilkaset cykli; jedna instrukcja = ok. 0.5 cyklu.

Innymi słowy – czepiasz się, że Cinquecento nie jest samochodem wyścigowym, argumentując, że ma źle wyprofilowany spoiler, pomijając zupełnie fakt, że silnik nie ma nawet turbo, a jego pojemność jest śmiesznie niska.

Po prostu z Twojego postu wcale nie wynika, że kompilator Go jest słaby; a co najwyżej że Twórcy kompilatora pominęli parę kosmetycznych szczegółów w ostatnim przebiegu generacji kodu mających nikły wpływ na wydajność. Żeby sensownie pokazać, że kompilator jest słaby, powienieneś przeanalizować jak radzi sobie z inliningiem i dewirtualizacją metod*, jak radzi sobie z rozwijaniem i wektoryzacją pętli, eliminacją niepotrzebnego kodu (np. range-checków itp.) – jest całe multum optymalizacji, które mają potężny wpływ na wydajność; wielokrotnie większy niż nadmiarowe xchg czy test. Oczywiście bez kodu źródłowego też nie da się za bardzo ocenić, czy kompilator sobie dobrze poradził, czy nie.

*) Tu jest mała niespodzianka GCC oraz ICC radzą sobie z dewirtualizacją znacznie słabiej niż np. Hotspot C2 JVM – więc jakbym się skupił tylko na tym jednym, to mógłbym wysnuć wniosek, że GCC jest technologicznie biedne. Z wektoryzacją pętli na SSE też nie zawsze radzą sobie dobrze.

Odpowiedz
bartek

@Piotr: nie masz racji i z tego co mówisz chyba w ogóle rzadko zaglądasz do generowanego kodu przez różne kompilatory, mówiąc o kodekach i o tym, że nikt nie optymalizuje pod pojedyncze instrukcje widać, że chyba nie interesujesz się tym jak pisze się wysokowydajne algorytmy z wykorzystaniem najnowszych dobrodziejstw kompilatorów, gdzie pojedyncze instrukcje mają ogromne znacznie, biorąc pod uwagę wysoką specjalizację nowoczesnych instrukcji jak SSE4, AVX itp.

“Żeby sensownie pokazać, że kompilator jest słaby, powienieneś przeanalizować jak radzi sobie z inliningiem” – pokazałem jak marnuje cykle na zbędną pracę, gdzie cały kod zapycha instrukcjami CLD i XCHG i to nie pojedyncze procedury, ale jeśli sam zajrzeć do wnętrzna programu po Go zobaczysz, że jest tego cała masa, ciekawa forma inlilingu 🙂

“eliminacją niepotrzebnego kodu” – po prostu sobie nie radzi o czym świadczą niepotrzebnie powtarzane bloki kodu, błędnie generowane fragmenty z porównaniami, nadmiarowy kod do wywoływania funkcji systemowych i masa innych zapychaczy bez celu.

Tu 5 cykli, tam 50 cykli * kilkadziesiąt tysięcy powtórzeń w całym kodzie * pętle i już z tych małych nieistotnych fragmentów robi się parę istotnych sekund.

Jakbyś miał ochotę na głębszą analizę kompilatora Go nie widzę problemów żebyś sam sprawdził co siedzi w środku i jeśli masz rację i pokażesz, że naprawdę kompilator Go nie jest tak zły jak go przedstawiłem to uderzę się w pierś i opublikuję twoje własne analizy tutaj na SecNews. Kontakt w menu jakby co.

Odpowiedz
Piotr

Ale ja wcale nie twierdzę, że nie jest zły! Jest słaby, bo sam Google robił benchmarki i Go dostało solidne bęcki nie tylko od C++, ale też od Scali, Javy – Ameryki nie odkryłeś. Śmieszy mnie tylko sposób w jaki argumentujesz, bo w wielu sytuacjach wyciągnąłeś drobiazgi kosmetyczne (np. czepianie się narzutów na WinAPI). Poza Tym z Twojego posta ja nadal nie wiem, kiedy mogę się spodziewać po Go, że wstawi mi te bezsensowne xchg, bo pokazałeś tylko kod wynikowy, bez kodu źródłowego. Z tego co napisałeś mógłbym się spodziewać, że, zakładając wstawianie bezsensownych instrukcji nawet co drugą instrukcję, Go jest co najwyżej o 30-50% powolniejsze od zoptymalizowanego kodu w C i wielokrotnie szybsze od Pythona – byłby to rewelacyjny wynik jak na język do integracji systemów (gdzie wąskim gardłem jest niemal zawsze I/O).

Co do SSE/AVX, itp: masz rację, że te optymalizacje mają znaczenie, ale raczej nie o nie rozchodziło się w Twoim poście. Poza tym krytycznych optymalizacji SIMD lepiej nie powierzać kompilatorom, ponieważ kompilatory są w nich nadal dość słabe i nieprzewidywalne (tak, czasem GCC zrobi coś 2x-4x lepiej od ICC, a czasem na odwrót; a czasem oba dostaną bęcki nawet od JVMa, który ma 10x mniej czasu na optymalizację, a czasem oczywiście na odwrót – ogólnie jest to loteria). No i kodeków nie pisze się w Go, ba, ostatnio nawet nie pisze się w C, tylko po prostu korzysta się z dedykowanych kodeków sprzętowych. I tu najlepsza wektoryzacja na zwykłym CPU nie ma szans tego przebić.

Odpowiedz
Piotr

“Tu 5 cykli, tam 50 cykli * kilkadziesiąt tysięcy powtórzeń w całym kodzie * pętle i już z tych małych nieistotnych fragmentów robi się parę istotnych sekund.”

Zgoda, ale parę sekund nie musi być wcale istotne z punktu widzenia zastosowań do jakich służy Go. Python ma narzut >50x, a jakoś nie przeszkadzało mu to zdobyć popularności bliskiej C++.

Podsumowując:
1. mocno przeceniasz rolę wydajności generowanego kodu
2. czepiasz się drobiazgów, pomijając rzeczy duże
3. i tak Ameryki nie odkryłeś; wszyscy wiedzą od dawna, że Go super szybkie nie jest
4. Go nie musi być tak szybkie jak C, z racji zastosowań do których służy; wystarczy że jest znacznie szybsze od Pythona; jeżeli będzie kiedyś szybsze, to miło, ale nikt z wielkiej radości skakać nie będzie
5. jak ktoś chce mieć wysoką wydajność, to użyje Javy albo C albo C++

Odpowiedz

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *