Tajemnice ATARI

PROGRAMOWANIE PROCESORA 6502
W KOMPUTERACH ATARI XL/XE


Optymalizacja programów

    Optymalizacją nazywamy takie zmiany w gotowym, działającym programie, by pewne (założone) parametry tego programu zmieniły się na lepsze. Warunek poprawnego działania jest bardzo ważny, ponieważ optymalizowanie programu na ogół zmniejsza jego czytelność, utrudnia przeto późniejsze poprawki i uzupełnienia; zwiększa się podatność programu na wprowadzenie trudnych do zlokalizowania błędów. Optymalizowanie programu działającego źle (lub wcale) mija się w ogóle z celem.

    Możemy tylko zazdrościć "dużym" komputerom, dla których napisano wiele systemów automatycznej optymalizacji. Niestety, my musimy to robić ręcznie (tak się mówi, choć w pracy tej największy udział ma głowa). Optymalizowanie jest czynnością trudną i niebezpieczną. Przed rozpoczęciem tęgo procesu dobrze jest wykonać kopię tekstu programu, bo być może trzeba będzie do mej wrócić, gdy "przeoptymallzowany" program w ogóle przestanie działać.

    Z wielu możliwych celów optymalizacji najczęściej stawiamy sobie dwa: zwiększenie szybkości lub zmniejszenie rozmiarów programu. Niestety, rzadko udaje się pogodzić te postulaty.

    Aby złagodzić zjawisko pogarszania się przejrzystości programu stosujemy komentarze opisujące istotę dokonanej modyfikacji, np.:
      LDA #0
      JMP DALEJ
zamieniamy na:
      LDA #0
      BEQ DALEJ (zawsze, bo Z=l)
    Tym sposobem zyskujemy jeden bajt, ale uzależniamy rozkaz BEQ od poprzedniego. Jeżeli w przyszłości zajdzie potrzeba zmiany pierwszego rozkazu na:
      LDA #1
mamy szansę, dzięki komentarzowi, pamiętać o konieczności zmiany warunku skoku.

Zwiększenie szybkości przy niezmienionych rozmiarach

    Ten typ optymalizacji jest pierwszym, najłagodniejszym sposobem przyspieszania bez zbytniego zaburzania struktury programu. Polega on na takim przegrupowaniu rozkazów, aby wyrzucić z wnętrza pętli te rozkazy, które nie muszą być wykonywane za każdym razem. Napisałem tak:
      LDY #39 
ZERUJ LDA #0
      STA (EKR),Y
      DEY
      BPL ZERUJ
    dla wyzerowania na ekranie pewnego wiersza, którego adres znajduje się w słowie EKR. Odzwierciedla to mój sposób myślenia w chwili tworzenia algorytmu: umieszczam w akumulatorze 0, przesyłam je do pamięci, to samo dla sąsiedniej komórki, i tak dalej... Ale uważny rzut oka upewnia nas, że zawartość akumulatora nie jest nigdzie wewnątrz pętli zmieniana. Nie ma więc powodu, by odnawiać ją tyle razy. W zupełności wystarczy:
      LDY #39
      LDA #0
ZERUJ STA (EKR),Y
      DEY
      BPL ZERUJ
    Teraz pętla ZERUJ wykonuje się znacznie szybciej. Inny przykład: zerujemy 10 stron pamięci:
      LDX #10
      LDA #0
Z     LDY #0 
ZERUJ STA (ADDR),Y
      INY 
      BNE ZERUJ
      INC ZERUJ ADDR+1
      DEX 
      BNE Z
    Widać jednak, że zewnętrzna pętla zaczyna się od nadania rejestrowi Y wartości 0 i taka też wartość pozostaje w nim w chwili opuszczenia pętli. Można więc z powodzeniem usunąć etykietę Z, a ostami wiersz zamienić na:
      BNE ZERUJ
    Oczywiście dziewięciokrotne niewykonanie rozkazu LDY daje niewielki zysk, ale może się on kumulować, jeżeli taki fragment jest w programie wywoływany wielokrotnie.

Jednoczesne zwiększenie szybkości i zmniejszenie rozmiarów

    Najbardziej pożądany wariant. Polega na usuwaniu zbędnych rozkazów lub zamianie dłuższych sekwencji na krótsze o takim samym działaniu. Przeciętny, dobrze napisany program zawiera całe mnóstwo zbędnych rozkazów. Na etapie konstruowania i testowania programu nie troszczymy się o to, ponieważ nadrzędnym celem jest przejrzystość. Potem zaczyna się zabawa w "wyciskanie wody". Nieraz zakładam się z kolegami, że KAŻDY program można skrócić o jeden bajt. Oczywiście, nie jest to prawda absolutna, lecz w praktyce sprawdza się na tyle często, że wygrywam prawie każdy taki zakład. Nie sposób opisać wszystkich możliwości, pokażę więc kilka typowych przykładów.

    Kopiowanie zawartości rejestru polega na "pożyczeniu" potrzebnej wartości z innego rejestru zamiast pobierania jej z pamięci. W poprzednim przykładzie
      LDA #0
      LDY #0
można zastąpić przez:
      LDA #0
      TAY
z zyskiem jednego bajtu.

    Wykorzystanie zawartości rejestru polega na zaniechaniu umieszczania w rejestrze nowej wartości, gdy wiemy, że poprzednia była taka sama. Przykład: umieszczenie w pamięci adresów dwóch kolejnych wierszy ekranu.
      LDA EKRAN 
      STA ADR1 
      STX ADR1+1
      LDA EKRAN+40 (l)
      STA ADR2 
      STX ADR2+1
    W przeważającej większości przypadków oba adresy dotyczą tej samej strony pamięci i rozkaz oznaczony (!) można usunąć z programu.

    Bardzo często pętle kończą się z wartością 255 (czyli -1) w rejestrze indeksowym, jak to ma miejsce w pierwszym z przykładów zerowania. Jeżeli po takiej pętli następuje rozkaz
      LDY #0
to można go z powodzeniem zastąpić przez
      INY
    Wykorzystanie stanu znacznika polega na zaniechaniu ustawiania znacznika, którego stan jest znany. Przytaczana już na tych stronach uproszczona konwersja ATASCII na kod ANTIC-a może zawierać sprawdzenie, czy liczba jest większa od 96, bo wtedy pozostawiamy ją bez zmian:
      CMP #96
      BCS OK
    W przeciwnym razie trzeba od niej odjąć 32:
      SEC 
      SBC #32
    Ponieważ jednak po nieskutecznym skoku BCS znacznik C ma zawsze wartość 0, możemy powyższą parę rozkazów zastąpić jednym, z takim samym skutkiem:
      SBC #31
    Zawsze skasowany znacznik C spowoduje odjęcie dodatkowej jedynki, dlatego niezbędne jest skorygowanie argumentu.

    Użycie właściwego trybu indeksowego dla strony zerowej. Jak wiemy, w repertuarze rozkazów naszego procesora brak jest krótkiej formy rozkazu
      LDA Z0, Y
dla Z0 zadeklarowanego jako wartość z zakresu 0..255 i dlatego asembler tłumaczy to na trzybajtowy rozkaz maszynowy (dwubajtowy argument). Zamiana rejestru
      LDA Z0,X
sprawi, że rozkaz ten będzie miał 2 bajty długości (argument jednobajtowy). Ten zabieg może niestety wymagać zmian w koncepcji wykorzystania rejestrów w okolicznych częściach programu, dlatego lepiej mieć to na uwadze już podczas wstępnego komponowania algorytmu.

    Wiązanie procedur polega na zmianie sposobu wywoływania procedur z wnętrza innych procedur. Jeżeli procedura X wywołuje procedurę Y tak:
X     LDY #0
      RTS
Y     LDX #0
      JSR Y   (!) 
      RTS     (!!)
to można z powodzeniem pominąć rozkaz oznaczony (!!), a poprzedni zamienić na
      JMP Y   (!)
    Teraz jednak wystarczy zamienić kolejność tych dwóch procedur, aby rozkaz oznaczony (!) można było całkowicie pominąć.

Zwiększenie szybkości kosztem rozmiarów

    Rozwijanie pętli polega na zamianie pętli na niecykliczną sekwencję rozkazów. Przykładowo, zerowanie pięciu kolejnych bajtów od adresu TAB:
      LDY #4
      LDA #0 
ZERUJ STA TAB,Y 
      DEY 
      BPL ZERUJ
zajmuje 10 bajtów i wymaga od procesora wykonania 17 rozkazów. Równoważny temu fragment:
      LDA #0 
      STA TAB
      STA TAB+1 
      STA TAB+2 
      STA TAB+3
      STA TAB+4
zajmuje 17 bajtów, lecz wymaga wykonania tylko 6 rozkazów.

Stosowanie tablic zamiast obliczeń polega na wstępnym przygotowaniu tablic wartości, z których trzeba często korzystać. Wykorzystanie tablicy do konwersji kodów ATASCII na kody ANTIC-a pokazałem w TA 9/92. Innym popularnym zastosowaniem tej metody jest tablicowanie wartości funkcji trygonometrycznych. Na początku programu "na sucho" oblicza się WSZYSTKIE wartości, z których będziemy później korzystać. Później, podczas normalnej pracy program nie odwołuje się już do znanych ze swej chyżości funkcji w ROM-ie ATARI, lecz czerpie gotowe wartości z tablicy.

Zmniejszanie rozmiarów kosztem szybkości

    Zwijanie sekwencji w pętlę polega na zastępowaniu serii rozkazów pętlą. Typowym przykładem jest przechowywanie wektorów dwóch przerwań:
      LDA VVBLKI
      STA OLDVBI
      LDA VVBLKI+1
      STA OLDVBI+1
      LDA VVBLKD
      STA OLDVBD
      LDA VVBLKD+1
      STA OLDVBD+1
    Zajmuje to 24 bajty, gdy słowa OLDVBI i OLDVBD znajdują się poza stroną zerową. Po zwinięciu w pętlę:
      LDX #1
CHOWA LDA VVBLKI,X
      STA OLDVBI,X
      LDA VVBLKD,X
      STA OLDVBD,X
      DEX 
      BPL CHOWA
fragment równoważny pod względem efektu zajmuje już tylko 17 bajtów.

Zastępowanie podobnych sekwencji procedurą polega na odszukiwaniu fragmentów programu podobnych do siebie i zastępowaniu ich wywołaniem osobno stojącej procedury. Typowym przykładem jest dodawanie do pewnego słowa w pamięci różnych liczb jednobajtowych. Taka potrzeba występuje nieraz podczas bezpośredniego wyświetlania danych na ekranie:
      CLC
      LDA ADDR
      ADC #n
      STA ADDR
      BCC OK
      INC ADDR+1
OK    EQU *
    Jeżeli takich fragmentów jest kilka (wiele?), a zmienia się tylko n, to można je zastąpić przez:
      LDA #n
      JSR DODAJ
i stworzyć osobną procedurę:
DODAJ CLC
      ADC ADDR
      STA ADDR
      BCC OK
      INC ADDR+1 
OK    RTS
Podane przykłady nie wyczerpują wszystkich sposobów optymalizacji - jest ich bez liku. Tym większe pole do popisu dla miłych Czytelników. Pamiętajcie: każdy program można udoskonalać bez końca. Ale po co?...

Janusz B. Wiśniewski



Powrót na start | Powrót do spisu treści | Powrót na stronę główną

Pixel 2002