# Jak zamiana jednej litery w asemblerze przyspiesza dzielenie trzykrotnie: techniczny rozbiór
Jedna zmieniona litera w instrukcji asemblera skraca czas wykonania operacji dzielenia o 66%. Eksperyment z rzeczywistymi pomiarami na nowoczesnych CPU pokazuje, jak fałszywe zależności między rejestrami przekształcają potencjał wykonywania out-of-order w wąskie gardło. Rozbieramy architektoniczne niuanse x86-64 wpływające na wydajność.
Architektoniczne cechy rejestrów x86-64
Nowoczesne procesory x86-64 stosują hierarchię rejestrów z dziedziczną strukturą. Rejestr rax (64 bity) zawiera w sobie:
┌───────────────────────────────────────────────────────────────┐
│ rax (64 bity) │
├───────────────────────────────┬───────────────────────────────┤
│ (górne 32) │ eax (32 bity) │
│ ├───────────────┬───────────────┤
│ │ │ ax (16 bitów) │
│ │ ├───────┬───────┤
│ │ │ ah (8) │ al (8) │
└───────────────────────────────┴───────────────┴───────┴───────┘
Kluczowa kwestia: zapis do młodszych części rejestru (ax, al) nie wpływa na starsze bity, natomiast operacje z 32-bitowymi rejestrami (eax) całkowicie nadpisują górne 32 bity. Ta cecha tworzy ukryte zależności krytyczne dla pipelinowania.
Instrukcje dzielenia div/idiv używają pary rejestrów:
┌──────────┬────────────────┬──────────────┬─────────────────────┐
│ Rozmiar │ Dzielna │ Iloraz w │ Reszta w │
├──────────┼────────────────┼──────────────┼─────────────────────┤
│ 16-bit │ dx:ax │ ax │ dx │
├──────────┼────────────────┼──────────────┼─────────────────────┤
│ 32-bit │ edx:eax │ eax │ edx │
├──────────┼────────────────┼──────────────┼─────────────────────┤
│ 64-bit │ rdx:rax │ rax │ rdx │
└──────────┴────────────────┴──────────────┴─────────────────────┘
Przed wykonaniem dzielenia należy wyzerować starszą część pary. Standardowy wzorzec:
mov edx, 0
mov eax, 536700
div dword [denominator]
Dane eksperymentalne: różnica w 3,7×
Testowy cykl przetwarzał 2 073 600 iteracji (1920×1080), modelując renderowanie pikseli. Wyniki w takach na iterację (po odjęciu kosztów pętli):
┌────────┬──────────────┬──────────────┐
│ │ idiv (netto) │ div (netto) │
├────────┼──────────────┼──────────────┤
│ 16-bit │ 22.2 │ 22.2 │
├────────┼──────────────┼──────────────┤
│ 32-bit │ 6.0 │ 6.0 │
├────────┼──────────────┼──────────────┤
│ 64-bit │ 26.2 │ 24.1 │
└────────┴──────────────┴──────────────┘
Nieoczekiwane: operacje 32-bitowe wykonują się 3,7 raza szybciej niż 16-bitowe przy tych samych danych wejściowych. Przyczyna tkwi nie w złożoności algorytmu dzielenia, a w cechach mikroarchitektury.
Mechanizm fałszywych zależności
Procesory używają Register Alias Table (RAT) do mapowania logicznych rejestrów na fizyczne. W tym:
- Zapis do 32-bitowego rejestru (
mov edx, ...) całkowicie nadpisuje wartość, przerywając zależności z poprzednimi operacjami - Zapis do 16-bitowego rejestru (
mov dx, ...) wymaga połączenia nowych danych ze starszymi bitami, tworząc fałszywą zależność
W wariancie 16-bitowym łańcuch zależności wygląda tak:
div word → merge → mov dx → merge → div word → ...
Każda iteracja musi czekać na zakończenie poprzedniej z powodu potrzeby scalania rejestrów. W wariancie 32-bitowym łańcuch jest przerywany, co pozwala procesorowi na pipelining.
Kluczowe metryki wydajności
- Latency: 21–22 takty dla 16-bitowego
div(czas do gotowości wyniku) - Throughput: 6 taktów dla 16-bitowego
div(interwał między uruchomieniami operacji)
Gdy operacje są zależne, latency staje się czynnikiem ograniczającym. Przy operacjach niezależnych — throughput. Wskaźnik 22/6 ≈ 3,7 wyjaśnia zaobserwowane przyspieszenie.
Weryfikacja hipotezy: zamiana jednej litery
Eksperyment potwierdził teorię. Zamiana 16-bitowych instrukcji na 32-bitowe w kontekście 16-bitowym:
; Wcześniej
mov dx, 0x0008
mov ax, 0x2B7C
; Po zmianie
mov edx, 8
mov eax, 0x2B7C
Powoduje radykalną zmianę wyników:
┌───────────────────────┬──────────────┬──────────────┐
│ │ idiv (netto) │ div (netto) │
├───────────────────────┼──────────────┼──────────────┤
│ 16-bit (mov dx) │ 22.2 │ 22.2 │
├───────────────────────┼──────────────┼──────────────┤
│ 16-bit (mov edx) │ 7.0 │ 7.0 │
└───────────────────────┴──────────────┴──────────────┘
Jedna litera e w instrukcji eliminuje fałszywą zależność, skracając czas wykonania z 22,2 do 7,0 taktu — dokładnie do poziomu throughput dzielnika.
Co ważne
- Wybór rozmiaru rejestru jest kluczowy: Używanie 32-bitowych operacji do przygotowania 16-bitowego dzielenia eliminuje niepotrzebne zależności
- Latency vs Throughput: Przy zależnościach wydajność zależy od latency, nie throughput
- Architektoniczne niuanse: Zrozumienie RAT i partial register merging jest niezbędne do niskopoziomowej optymalizacji
- Dzielnik produkuje resztę szybciej: W x86 realizowane jest równoległe obliczanie ilorazu i reszty, co pozwala użyć edx wcześniej niż rax
- Testowanie obowiązkowe: Teoretyczne szacunki często różnią się od rzeczywistych pomiarów z powodu cech mikroarchitektury
Praktyczne zalecenia dla programistów
- Przy pracy z dzieleniem zawsze używaj 32-bitowych operacji do zerowania starszych części rejestrów, nawet przy operowaniu danymi 16-bitowymi
- Unikaj mieszania operacji o różnych rozmiarach rejestrów w pętlach
- Dla krytycznych fragmentów kodu sprawdzaj listing asemblera generowany przez kompilator
- Pamiętaj, że throughput dzielnika jest wyższy niż latency, ale tylko bez zależności
- W nowoczesnych CPU (Ice Lake i nowsze) sprawdzaj specyfikacje uops.info pod kątem aktualnych metryk
Eksperyment pokazuje, jak głębokie zrozumienie mikroarchitektury pozwala znaleźć nieoczywiste punkty optymalizacji. Zamiana jednej litery w instrukcji, która na pierwszy rzut oka wydaje się bezsensowna, daje trzykrotne przyspieszenie dzięki eliminacji sztucznych ograniczeń potoku. Dla twórców oprogramowania systemowego to przypomnienie: nawet w tak niskopoziomowych konstrukcjach jak instrukcje asemblera kryją się okazje do optymalizacji, dostępne tylko dzięki zrozumieniu „żelaza” pod maską.
— Editorial Team
Brak komentarzy.