
Czasami przychodzi taka chwila w życiu kodera, gdy musi policzyć pierwiastek. Koder scenowy powie teraz „żaden problem, wystarczy stablicować i odczytać”, koder systemowy natomiast „otwieram math#?trans.library i po kłopocie”. Posiadacz koprocesora matematycznego stwierdzi z wyższością „to przecież kwestia jednej instrukcji koproca...”. Niestety wszystkie podejścia mają swoje wady. Tablica, owszem jest bardzo szybka, ale zajmuje trochę pamięci, zwłaszcza gdy wymagana jest nieco większa precyzja. Z drugiej strony wykorzystanie bibliotek matematycznych jest wygodne i wymaga w miarę małej ilości pamięci. Pierwiastki są liczone bardzo dokładnie, ale też przy okazji bardzo wolno. Z kolei zastosowanie koprocesora matematycznego ma podstawową wadę, którą jest konieczność posiadania owego koprocesora :-). No i wreszcie nie zawsze mamy ochotę na przeliczanie liczby całkowitej na format zmiennoprzecinkowy. W takich przypadkach nie pozostaje nam nic innego, jak zasiąść do klawiatury i napisać samemu taką procedurkę.
Starsi wyjadacze pamiętają zapewne artykuł na ten temat, który swego czasu ukazał się w „Amigowcu” Autor zaproponował tam wykorzystanie algorytmu pierwiastkowania pisemnego. Kod źródłowy zajmował całą stronę gazety, a i najszybszy też chyba nie był. Błąd polegał na złym podejściu do problemu. Pierwiastkowanie pisemne to algorytm dla człowieka, który liczy w układzie dziesiętnym. Komputer liczy w systemie dwójkowym i potrzebny jest algorytm, który to weźmie pod uwagę.
Zastanówmy się teraz nad definicją pierwiastka kwadratowego. Jest to taka liczba, która podniesiona do kwadratu daje nam liczbę wejściową. Proste. Ale nie zapominajmy, że działamy na liczbach całkowitych. Obcinamy wszystko po przecinku i dlatego wynik podniesiony do kwadratu może być trochę mniejszy niż liczba pierwiastkowana. Można więc powiedzieć, że pierwiastek całkowity to taka możliwie największa liczba, której kwadrat jest nie większy niż liczba pierwiastkowana. Druga ważna sprawa to tzw. przeliczalność zbioru liczb n-bitowych. Co to oznacza? Jeżeli mamy rejestr np. 16-bitowy, to możemy tam zapisać skończoną ilość różnych liczb (dokładnie 65536 i ani grama więcej). Czyli problem pierwiastkowania sprowadza się do wybrania jednej odpowiedniej liczby spośród skończonego ich zbioru. Tylko jak wybrać tę liczbę? Jaki jest najszybszy algorytm znalezienia takiej liczby? Oczywiście połowienie przedziału, jest on algorytmem jak najbardziej „komputerowym” opartym na dwójkowym systemie liczenia. A więc robimy tak:
- Wybieramy liczbę w połowie przedziału.
- Podnosimy ją do kwadratu i porównujemy z liczbą pierwiastkowaną.
- Jeżeli jest mniejsza to przedziałem staje się jego dolna połowa, w przeciwnym wypadku górna.
- Jeżeli przedział zwęził się do jednej liczby, to obliczyliśmy pierwiastek.
- A jeśli nie to skok do punktu 1.
Jaki przedział wybrać? Jeżeli pierwiastkujemy liczbę n-bitową, pierwiastek na pewno zmieści się w n/2 bitach, oczywiście jeżeli pierwiastkujemy liczbę o nieparzystej liczbie bitów, to powstające pół bita należy zaokrąglić w górę. Jeżeli ktoś nie wierzy, to proszę sobie to rozpisać korzystając z twierdzenia o potędze potęgi: sqrt(2n) = (2n)1/2 = 2n/2. Teraz uprościmy sobie sprawę jeszcze bardziej i popracujemy nad samym połowieniem przedziału. Oblatanym w elektronice podpowiem, że zastosujemy metodę stosowaną w aproksymujących przetwornikach C/A. Zamiast bawić się w dwie stopniowo zawężające się granice przedziału będziemy jechać jedną liczbą jego środkiem. Dzięki temu wynik będziemy tworzyć kasując i ustawiając kolejne jego bity, zaczynając od najstarszego.
Wyjaśnię to na przykładzie wyciągania pierwiastka czterobitowego z liczby ośmiobitowej. Przedział wyniku to 0–15. Ustawiając najstarszy bit wyniku otrzymamy liczbę 8, czyli środek przedziału. Podnosimy ją do kwadratu i sprawdzamy, czy nie jest większa od liczby pierwiastkowanej, jeżeli tak, to kasujemy bit. Potem przechodzimy do następnego bitu i znowu sprawdzamy. Jeżeli liczba jest za duża, to kasujemy ten bit i przechodzimy do następnego itd. Oto przykład, liczymy pierwiastek z 174:
wynik dwójkowo | wynik dziesiętnie | kwadrat dziesiętnie | decyzja wyniku |
---|---|---|---|
1000 | 8 | 64 | ok, nie kasuj bitu |
1100 | 12 | 144 | ok. nie kasuj bitu |
1110 | 14 | 196 | za dużo, skasuj bit |
1101 | 13 | 169 | ok. nie kasuj bitu |
1101 | 13 | wynik |
Algorytm dla pierwiastka z liczby n-bitowej wymaga wykonania n/2 mnożeń liczb n/2-bitowych, n/2 porównań liczb n-bitowych, n/2 ustawień bitu i od 0 do n/2 skasowań bitu. Szybkość obliczeń w małym stopniu zależy od liczby pierwiastkowanej.
Od teorii pora więc przejść do praktyki. Oto procedura w asemblerze pierwiastkowania liczby 32-bitowej:
; liczba w d0, wynik też w d0. ; pierwiastkowanie liczby 32-bitowej. Root32: MOVEM.L d2/d3,-(sp) CLR.L; d1 MOVEQ #15,d3 Root32Loop: BSET d3,d1 MOVE.W d1,d2 MULU d2,d2 CMP.L d0,d2 BLS.S NoClear32 BCLR d3,d1 NoClear32: DBF d3,Root32Loop MOVE.L d1,d0 MOVEM.L (sp)+,d2/d3 RTS
Jak widać, z całej strony wydruku zrobiło się raptem 14 rozkazów. Jednak na tym nie poprzestaniemy. Jak wiadomo, gdy potrzebna jest większa dokładność obliczeń, a liczby zmiennoprzecinkowe należy omijać z daleka, stosuje się skalowanie liczb. Mnożymy liczby przed obliczeniami, z reguły przesuwając je bitowo w lewo o ileś tam bitów, a po skończonych obliczeniach dzielimy je przesuwając w prawo. Dzięki temu błąd zaokrąglenia popełniamy tylko raz. Pisał o tym swego czasu Miklesz w Magazynie Amiga. Przy pierwiastkowaniu, podobnie jak przy mnożeniu i dzieleniu zmienia się czynnik skalujący. Jeżeli chcemy wynik pierwiastkowania mieć przeskalowany o n pozycji, powinniśmy liczbę pierwiastkowaną przesunąć o 2n miejsc dwójkowych. Łatwo może się wtedy okazać, że zabraknie nam 32 bitów. Co robić? Nic prostszego, zastosujemy pierwiastkowanie liczby 64-bitowej z wynikiem 32 bitowym:
INCLUDE "utility/utility_lib.i" XREF _UtilityBase ; pierwiastkowanie liczby 64-bitowej. ; Liczba: starsze długie słowo w d1, ; młodsze w d0. Wynik w d0. Root64: MOVEM.L d2-d5,-(sp) MOVE.L d0,d2 MOVE.L d1,d3 MOVEA.L _UtilityBase,a6 CLR.L d4 MOVEQ #31,d5 Root64Loop: BSET d5,d4 MOVE.L d4,d0 MOVE.L d4,d1 JSR _LVOUMult64(a6) CMP.L d1,d3 BCS.S Clear64 BNE.S NoClear64 CMP.L d0,d2 BHI.S NoClear64 Clear64: BCLR d5,d4 NoClear64: DBF d5,Root64Loop MOVE.L d4,d0 MOVEM.L (sp)+,d2-d5 RTS
Jak widać posłużyłem się tu mnożeniem 64-bitowym z utility.library. To umożliwia działanie procedury na wszystkich procesorach od 68000. Jeżeli ktoś nie ma ochoty na otwieranie biblioteki, a pisze pod 020 lub 030, to może wstawić zamiast JSR-a „MULU.L d1,d1:d0”. Niestety tego rozkazu nie ma w procesorze 060 i chyba też w 040, tak więc UMult64() jest rozwiązaniem najbezpieczniejszym. Ewentualnie można rozpisać ręcznie to mnożenie na rozkazach MULU.W (choćby przepisując kod z biblioteki), ale nie wiem, czy warto tak wojować o jedną parę JSR – RTS. I to tyle tym razem.