Kurs programowania w asemblerze
Optymalizacja w asemblerze
Na tej stronie zajmiemy się optymalizacją kodu w Asemblerze. Zastanowimy się jak przyspieszyć działanie naszego programu i jak zmniejszyć jego objętość.
Strona jest podzielona na:
Do optymalizacji kodu przyda się również tabela czasu wykonania poszczególnych instrukcji procesora.
Szybkość
Programy napisane w Asemblerze z reguły są bardzo szybkie. Na pewno szybsze niż te napisane w BASIC-u, czy Pascal-u. Czasami zachodzi jednak potrzeba stworzenia programu nadzwyczaj szybkiego. Gdy np. chcemy stworzyć jakąś animację w wysokiej rozdzielczości, z teksturowaniem szybkość naszego kodu ma ogromne znaczenie.
Na początek kilka ogólnych reguł:
- Procesor 386
- zawsze kiedy jest możliwość wyrównuj kod i dane do granicy podwójnego słowa
- adresowanie 32-bitowe w kodzie 16-bitowym powoduje opóźnienie
- używaj 32-bitowy tryb adresowania do dużych segmentów
- Procesor 486
- często wykonywane procedury i adresy skoków powinny być wyrównane do granicy 16 bajtów
- dane zawsze wyrównuj do granicy zgodnej z wielkością danej (słowa do granicy słowa)
- zamiast stosowania wielokrotnego przesuwania (obrotu) bitów o jeden przesuń o od razu więcej (już można)
1. Rejestry
Podczas programowania często zachodzi potrzeba zapamiętania jakiejś wartości. Wówczas niektórzy z nas tworzą sobie zmienne. Oprócz tego, że te zmienne zajmują pewną pamięć, odczytanie z nich wartości jest trudniejsze i stwarza możliwość popełnienia błędu, operacje na nich są znacznie wolniejsze, niż na rejestrach procesora. Rejestry są najszybsze w naszym komputerze, dlatego wykorzystujmy je w 100%. Jeśli jakaś liczba ma zostać zapamiętana na krótką chwilę, a nie mamy wolnych rejestrów, to użyjmy stosu. Jest on również szybszy od zmiennych. Róbmy więc zmiennych jak najmniej.
2. Makrodefinicje
Jak działają makrodefinicje nie trzeba chyba tłumaczyć. Wstawiają w odpowiednie miejsce, odpowiedni kod. Stosujmy więc makrodefinicję wszędzie tam, gdzie zależy nam na prędkości. Używanie procedur jest wygodne, jednak wywołanie procedury zajmuje wiele czasu. Położenie na stos odpowiednich rejestrów i wykonanie bardzo powolnego skoku, to dla nas za dużo. Jeśli więc użyjemy makrodefinicji, nie wykonamy żadnego skoku. Program jest czytelny i szybki. Jedyna wada, to objętość, która rośnie w zadziwiająco szybkim tempie.
3. Pętle
Istnieje taka instrukcja w Asemblerze, która się nazywa LOOP. Służy ona do zmniejszania licznika CX i wykonywania skoku, dopóty dopóki CX<>0. Jest ona bardzo wolna, zajmuje na 486 6/7 cykli, a na PENTIUM 5NP. Można czasami pętle znacznie przyspieszyć, przykładowo mamy napisać program, który wykona pętlę pięć razy i za każdym razem zwiększy rejestr AX o jeden. Normalnie napisalibyśmy tak:
MOV CX,5 ET1: INC AX LOOP ET1
Można go przyspieszyć pisząc tak:
MOV CX,5 ET1: INC AX DEC CX JNZ ET1
Dlaczego drugi sposób jest szybszy? Bo dodatkowa instrukcja DEC (na 486 i PENTIUM) trwa 1 cykl, a JNZ na 486 trwa 1/3 cykli, a na PENTIUM 1V, co oznacza całkowity czas na 486 max. 4 cykle (poprzednie rozwiązanie 7), a na PENTIUM 1 cykl, bo za jednym razem zostanie wykonana instrukcja DEC i JNZ (podczas gdy LOOP 5NP).
4. Koprocesor
Do skomplikowanych operacji używajmy koprocesora arytmetycznego. Wykonuje on obliczenia na liczbach zmiennoprzecinkowych znacznie szybciej, niż procesor. Opis programowania koprocesora zamieściłem w oddzielnym artykule.
Objętość
Optymalizacja pod względem objętości jest również bardzo ważna. Czasami zachodzi potrzeba napisania programu, który nie ważne ile będzie wykonywał daną operację, ważne by działał i był mały. Zazwyczaj jednak programy małe są i szybkie, ale nie zawsze.
1. Procedury
Jednym ze sposobów zmniejszenia objętości programu jest stosowanie procedur. Jeśli jakiś fragment programu jest wykonywany wiele razy można zrobić z niego procedurę i w razie potrzeby wykonywać tylko skok. Ten sposób znacznie zmniejszy objętość naszego kodu, jednak skoki są powolne, przez co szybkość programu spadnie.
2. Stos
Stos jest bardzo poręcznym narzędziem. Zapamiętanie dowolnego rejestru 16 bitowego na stosie zajmuje jeden bajt, 32 bitowego dwa bajty, a zapamiętanie rejestru 16 bitowego w zmiennej zajmuje 3 bajty, a 32 bitowego 4 bajty. Jeśli więc mamy do zapamiętanie jakąś zmienną, na krótki czas, to jak się da to stosujmy stos. Skracamy kod i nie ma konieczności tworzenia zmiennych. Mamy również mniejsze ryzyko wystąpienia błędu.
3. Segmenty
Wiemy, że do rejestrów segmentowych nie wolno zapamiętywać wartości stałych, np. konkretnej liczby. Trzeba to uczynić korzystając np. z rejestru. Zapamiętujemy wówczas liczbę w rejestrze i ten przesyłamy do rejestru segmentowego. Przykładowy program może wyglądać tak:
MOV AX,0A000H MOV DS,AX
Jest na to wolniejszy (o jeden cykl), ale mniejszy objętościowo sposób. Można wykorzystać tą właściwość stosu i instrukcji PUSH, że można kłaść wartość stałą na stos i to, że można zdjąć coś ze stosu i zapamiętać w rejestrze segmentowym. Nasz program z wykorzystaniem stosu wyglądać może tak:
PUSH 0A000H POP DS
Sposób pierwszy zajmuje 5 bajtów (2 cykle), natomiast drugi 4 bajty (3 cykle).
4. Zerowanie
Bardzo często podczas programowania zachodzi potrzeba wyzerowania jakiegoś rejestru. Wówczas niektórzy piszą:
MOV AX,0
Jest to niepotrzebne zwiększanie objętości programu. Dużo lepszym sposobem jest zastosowanie instrukcji logicznej XOR lub AND. W XOR wykorzystujemy tą właściwość, że jeżeli para bitów jest taka sama (równa 0 lub 1), to wynik jest 0. Jeśli więc ksorujemy liczbę przez samą siebie, to wszystkie odpowiadające sobie bity są identyczne, więc wynik jest równy 0. Używając AND wykorzystujemy tą właściwość, że jeżeli pomnożymy logicznie dowolny bit przez 0, to wynik będzie 0. Dlatego wystarczy pomnożyć liczbę przez 000000B, czyli 0. Powinniśmy więc napisać:
XOR AX,AX lub AND AX,0
Obie te instrukcje zajmują dwa bajty, a instrukcja MOV trzy.
Przy programowaniu 32 bitowym:
MOV EAX,0 ; 5 bajtów xor eax,eax ; 2 bajty
Zamienniki
Poniżej w tabeli wypisałem różne komendy oraz odpowiedniki, na które warto je zamienić (wykonają to samo, ale szybciej lub zajmą mniej!)
| Komenda | Zamiennik | Opis |
|---|---|---|
| cwd | mov ah,al sar ah,7 | od 386 |
| cdq | mov edx,eax sar edx,31 | od 486 |
| cmp mem,reg | cmp reg,mem | na 286 szybsze o 1 cykl |
| cmp reg,mem | cmp mem,reg | na 386 szybsze o 1 cykl |
| lodsb | mov al,[si] inc si | od 486 |
| lodsw | mov ax,[si] add si,2 | od 486 |
| lodsd | mov eax,[si] add si,4 | od 486 |
| loop | dec (s)cx jnz etykieta | od 386 |
| movsb | mov al,[si] inc si mov [di],al inc di | od 486 |
| movsw | mov ax,[si] add si,2 mov [di],ax add di,2 | od 486 |
| movsd | mov eax,[esi] add esi,4 mov [edi],eax add edi,4 | od 486 |
| pop mem | pop reg mov mem,reg | od 486 |
| push mem | mov reg,mem push reg | od 486 |
| shl reg,1 | add reg,reg | od Pentium |
Czas wykonania - strona z czasami wykonania niektórych instrukcji
Intel - Dokładny opis wszystkich instrukcji procesora (w tym czasy wykonania) 23.KB
PentOpt - programy Michaela L. Schmit'a obliczające, ile czasu wykonuje się dany program
