Bądźmy szczerzy — czasy, kiedy dało się oszacować liczbę cykli potrzebną na wykonanie kodu skończyły się na przełomie lat osiemdziesiątych i dziewięćdziesiątych. Procesory przestały być taktowane tym samym zegarem, co pamięć, przestały również wykonywać jedną instrukcję w pełni i dopiero się zabierać za następną. Powstały i wciąż powstają też takie, które nie przejmują się kolejnością instrukcji i same sobie tą kolejność zmieniają w zależności od tego, jak im wygodnie i czy można to zrobić udając, że instrukcje jednak były wykonywane po kolei.
20, czy nawet 50 instrukcji nie wciągnie Ci 800 cyklów procesora |
Ale jak najbardziej może, wystarczy kilka cache missów – czekanie na odczyt z albo zapis do pamięci to nawet kilkaset cykli! – a spudłować może praktycznie każda instrukcja odwołująca się do pamięci.
Innym dużym problemem są skoki. Bezpieczne są tylko bezwarunkowe skoki pod stały adres, ponieważ nie różnią się zbytnio od dodania stałej do rejestru, gdzie wartość stałej jest znana już po zdekodowaniu instrukcji. Jakiekolwiek skoki warunkowe (np.
if i pętle) i pośrednie (np.
switch, wskaźniki na funkcje i funkcje wirtualne) sprawiają już trudność, bo procesor albo nie wie, czy powinien skoczyć, albo nie wie, dokąd skoczyć. Procesory próbują zgadywać takie rzeczy. Jeśli im się uda, to super, skok jest darmowy lub prawie darmowy. Jeśli nie, trzeba opróżnić potoki i cofnąć procesor do stanu sprzed rozpoczęcia spekulacyjnego wykonania, na co potrzeba od kilkunastu do kilkudziesięciu cykli.
Kod jest liniowy i nie ma odwołań do pamięci? Oby nie było w nim dzielenia, bo dzielenie dwóch liczb całkowitych może bez problemu zająć 60 cykli, a w przypadku niektórych procesorów, jak intelowskie Atomy, znacznie więcej. Nie trzeba mieć doktoratu z matematyki, by policzyć, że 60∙20 to 1200, czyli liczba wyraźnie większa niż 800.
Ty obecnie próbujesz udowodnić, że operacja dodawania może zabrać procesorowi 1 cykl jak i 50 cykli, co jest nieprawdą |
Mowa była o linijkach kodu, nie o dodawaniu, prawda?
Ale na x86 nawet dodawanie może tyle potrzebować, a nawet więcej – wszak istnieją instrukcje postaci
add reg, mem i
add mem, reg/imm, gdzie dostęp do pamięci jest, cóż, dostępem do pamięci.
No ale w algorytmie do wykrywania kolizji, np. okrąg/okrąg masz co najwyżej 3 odejmowaia + 3 dodawania + 3 mnożenia + jedną instrukcję porównania, a to daje 9 operacji. Nawet jeżeli mechanizm wykrywania kolizji między dwoma obiektami będzie zrealizowany o metody wirtualne, to i tak nie wciągnie Ci wywołanie metody 41 cyklów |
Tu nawet nie potrzeba funkcji wirtualnych, by zgubić tyle cykli. Jeśli odejmowanie i dodawanie potrzebują jednego cyklu a mnożenie czterech, to biorąc pod uwagę fakt, że takie porównanie nie jest łatwe do przewidzenia przez procesor i jako takie często powoduje mispredykcje, wychodzi 3∙1 + 3∙1 + 3∙4 + 20, czyli 38.
To są rzeczy, których kompilator nie jest w stanie zoptymalizować. Masz w kodzie odwołanie do pamięci? Trzeba wygenerować odwołanie do pamięci. Masz warunek? Będzie skok warunkowy. Kompilator choćby chciał, to nie potrafi zagwarantować, że wykonanie kodu nie będzie musiało czekać na ściągnięcie danych z RAMu lub cofnięcie stanu CPU do tego sprzed mispredykcji.
tl;dr: procesory są zbyt skomplikowane, by dało się nawet z grubsza cokolwiek założyć.
linker będzie potrafił zoptymalizować istotnie jakiś fragment kodu |
Kompilator. Dla linkera kod to tylko bajty, które w podany przez kompilator sposób musi zmienić, by się zgadzały adresy.