Powrót do strony głównej

Optymalizacja asemblera: jak jedna litera przyspiesza kod 3 razy

Badanie eksperymentalne demonstruje, jak zamiana operacji 16-bitowych na 32-bitowe w asemblerze eliminuje fałszywe zależności między rejestrami, przyspieszając wykonanie dzielenia trzykrotnie. Analiza pracy Register Alias Table i cech potokowości.

Sekret 3-krotnego przyspieszenia: mikrooptymalizacja w asemblerze
Advertisement 728x90

# 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.

Google AdInline article slot

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):

Google AdInline article slot
┌────────┬──────────────┬──────────────┐
│        │ 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:

Google AdInline article slot
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

Advertisement 728x90

Czytaj dalej