O sierotce, co chciała się mózgiem elektronowym wyręczyć
Wyobraźmy sobie biedną sierotkę, której macocha nakazała oddzielić groch od fasoli. Chcąc nie chcąc, dziewczę siada w kącie izby przed pokaźnym kopcem grochu pomieszanego z fasolą i zaczyna pracę. Praca jest niezwykle monotonna: sierotka bierze nasiono z górki i jeśli to fasola, odrzuca je na lewą stronę, a jeśli groch – na prawą; i tak w kółko...
Sierotka jest całkiem porządna, więc aby nie rzucać nasionkami po całej izbie, ustaliła, że nasionka fasoli odkłada na lewą stronę lewą ręką, a nasionka grochu odkłada prawą ręką. Ponieważ na wybór ręki, którą sięgnie po kolejne nasionko, musi się zdecydować, zanim rozpozna jego rodzaj (w izbie jest dość ciemno), więc nierzadko będzie zmuszona do przełożenia nasionka z jednej ręki do drugiej, co, oczywiście, będzie spowalniać jej pracę.
Powiedzmy, że sierotka w większości przypadków wyciąga z górki nasionko fasoli (takie już ma szczęście). Zauważywszy to, może postąpić praktycznie i na początku zawsze wyciągać nasionka lewą ręką, tak by zminimalizować liczbę przełożeń nasionka z ręki do ręki, a gdy w kopczyku zostanie już w większości groch, to będzie wyciągać nasionka prawą ręką. Sierotka może mieć szczęście innego rodzaju, np. prawie zawsze co trzecie wyciągnięte nasionko to groch. W tej sytuacji mądre dziewczę stwierdza, że najbardziej opłacalny jest schemat: lewa ręka, lewa ręka, prawa ręka itd. Niestety, znając bajkowe realia, sierotka najpewniej będzie wyciągała nasionka w zupełnie losowej kolejności, zatem żadne wymyślne strategie nie pomogą jej w uniknięciu marnego losu i średnio co drugie nasionko będzie musiało zostać przełożone z ręki do ręki.
Czytelnik Postępowy od razu zauważy, że wszystkie kłopoty skończyłyby się, gdyby na miejscu sierotki postawić robota z chwytnym ramieniem, fotokomórką i mózgiem elektronowym. Nie dość, że wykonałby on zadanie sprawniej i na pewno bezbłędnie, to nie rozwodziłby się długo nad wyborem strategii postępowania, a praca zajęłaby mu tyle samo czasu, niezależnie od kolejności, w jakiej wyciągałby ziarenka z górki.
I moglibyśmy zakończyć ten artykuł powyższym morałem, gdyby nie to, że nie jest on do końca prawdziwy. Okazuje się, że w świecie maszyn nie wszystko jest takie jasne i poukładane, a w elektronowym mózgu może kryć się coś ze sprytnej sierotki. Kto ciekaw, tego zapraszam do eksperymentu.
***
***
Ponieważ nie wszyscy Czytelnicy dysponują odpowiednim sprzętem tudzież zapasem grochu i fasoli, więc eksperyment zasymulujemy na domowym komputerze, pisząc prosty program. Program będzie wczytywał z wejścia zero-jedynkowy ciąg, który kodować będzie rodzaje kolejnych nasionek. Zamiast sortowania, program będzie miał na celu policzenie nasionek, tzn. policzenie, ile zer i ile jedynek występuje w wejściowym ciągu. Jeśli te wartości będzie przechowywał na zmiennych i to po każdorazowym wczytaniu nowego bitu należy wykonać taki kod:
Skompilujmy nasz program i uruchommy go dla różnych ciągów zero-jedynkowych równej długości, w których dokładnie połowa bitów to zera. Wydawałoby się, że czas działania naszego programu musi być zawsze taki sam, w końcu wykonamy dokładnie tyle samo operacji porównań i dodawania, co więcej, wartości zmiennych i zwiększymy tyle samo razy. Okazuje się jednak, że na moim komputerze dla ciągu długości w którym pierwsza połowa ciągu to zera, a druga połowa to jedynki, program działa s, natomiast dla ciągu w którym pozycje zer i jedynek są losowe, program działa s, czyli prawie o połowę dłużej! Nie jest to bynajmniej błąd pomiaru czasu, gdyż kolejne uruchomienia programu potwierdzają pierwotną obserwację. Ponadto w obu uruchomieniach programu wczytywanie danych zajmuje s, więc dysproporcja czasu „właściwych obliczeń” jest w rzeczywistości jeszcze większa.
Jaka jest przyczyna takiego zachowania? Czyżby domowy komputer z jakichś powodów radził sobie lepiej z „przewidywalnymi” danymi, tak jak nasza sierotka? A jeśli tak jest w istocie, to jakie są tego powody?
Jak wiemy, kompilacja powoduje przetworzenie kodu programu na ciąg instrukcji niskiego poziomu. Na przykład, zwiększenie wartości zmiennej znajdującej się w rejestrze procesora mogłoby zostać zapisane w wewnętrznym języku komputera jako
Na wykonanie powyższej instrukcji składa się kilka kroków. Po pierwsze, instrukcja musi być pobrana z pamięci. Następnie musi zostać zdekodowana: procesor odkrywa, że chodzi o zwiększenie zmiennej z rejestru. W kolejnym kroku należy wykonać instrukcję: przesłać wartość zmiennej z rejestru do jednostki arytmetycznej procesora i dokonać zwiększenia. I ostatecznie należy przesłać uaktualnioną wartość z powrotem do rejestru. (Jest to przykład dość uproszczony, w ogólnym przypadku wykonanie instrukcji może składać się z większej liczby kroków, tj. np. przekopiowanie danych z pamięci do rejestru procesora. We współczesnych procesorach liczba takich kroków może sięgać kilkudziesięciu.) Widać więc, że wykonanie pojedynczej instrukcji nie jest takie banalne. Jeśli założymy, że każdy krok wykonuje się w jednej jednostce czasu, to na wykonanie instrukcji potrzebujemy jednostek czasu (Rys. 1).
Zauważmy jednak, że skoro każdy z kroków jest wykonywany przez inną część procesora, to można przyspieszyć cały proces, umożliwiając tym częściom procesora pracę równoległą przez przetwarzanie kilku instrukcji naraz. (Tak jak na linii produkcyjnej w fabryce samochodów ekipa montująca podwozie pracuje równolegle z ekipą malującą karoserię – pracują po prostu na innych egzemplarzach.) Można postąpić tak: w pierwszym kroku pobieramy pierwszą instrukcję z pamięci. W drugim kroku dekodujemy tę instrukcję, ale w tym momencie część procesora odpowiedzialna za pobieranie instrukcji jest bezrobotna, wobec tego możemy jednocześnie pobrać z pamięci drugą instrukcję. W trzecim kroku wykonujemy pierwszą instrukcję, dekodujemy drugą i pobieramy z pamięci trzecią. Dzięki takiemu potokowemu przetwarzaniu instrukcji na wykonanie z nich potrzebujemy tylko jednostek czasu (Rys. 2).
Jak każdy świetny pomysł, tak i ten ma pewne wady. Zauważmy, że przyjęliśmy tu milczące założenie, że zanim zakończymy wykonywanie danej instrukcji, musimy być w stanie rozpocząć wykonywanie następnej, a nawet trzeciej instrukcji z kolei. W szczególności musimy wiedzieć, co to będą za instrukcje. O tym, że nie zawsze posiadamy tę wiedzę, możemy się przekonać, kompilując nasz pierwszy program:
W pierwszej instrukcji zapisanej pod adresem wykonujemy porównanie zmiennej z zerem. Następna instrukcja jest instrukcją skoku warunkowego: jeśli liczby porównywane ostatnio były równe, to instrukcja ta powoduje skok do instrukcji zapisanej pod adresem w przeciwnym przypadku nie dzieje się nic. Odpowiada to wybraniu odpowiedniej gałęzi w instrukcji if. Jeśli wykonaliśmy skok (pierwsza gałąź), to w instrukcji zwiększamy wartość Jeśli skoku nie wykonaliśmy (druga gałąź), to w instrukcji zwiększamy wartość a następnie wykonujemy bezwarunkowy skok do instrukcji Kluczowa jest w tym programie instrukcja skoku warunkowego IFEQJMP: mianowicie, dopóki jej nie wykonamy, nie wiemy, czy następną po niej będzie instrukcja czy też instrukcja nie wiemy więc, która z nich powinna być w potoku uwzględniona jako następna. Procesor jest zatem w sytuacji naszej sierotki, która, dopóki nie dotknie nowego ziarenka, nie będzie wiedziała, czy należało użyć lewej czy też prawej ręki.
Można ten kłopot spróbować rozwiązać następująco: procesor zawsze będzie zakładał, że po instrukcji zapisanej pod adresem następują instrukcje pod adresami itd. lub pod adresem, który wskazuje skok bezwarunkowy, i te właśnie instrukcje będzie uwzględniał w potoku. Jeśli jednak instrukcja była rozkazem warunkowego skoku i po jej wykonaniu okazało się, że należy skoczyć pod adres to częściowa praca związana z wykonaniem instrukcji i kolejnych jest unieważniana, a potok jest opróżniany i przetwarzanie zaczyna się na nowo od instrukcji (Rys. 3).
W przypadku programów, w których jest mało skoków warunkowych lub są rzadko wykonywane, taka strategia może się opłacać. Jednak w pesymistycznym przypadku, gdy każda kolejna instrukcja jest skokiem warunkowym, musimy opróżniać potok po wykonaniu każdej instrukcji, zatem czas wykonania instrukcji będzie wynosił jednostek – nasz potok nie będzie zatem wykorzystywany w pełni efektywnie.
Zauważmy jednak, że taka strategia nie może być stosowana w naszym procesorze: przecież program dla ciągów i powinien działać tak samo długo, gdyż w obu przypadkach potok byłby opróżniany w połowie instrukcji Taka strategia odpowiada sytuacji, w której nasza sierotka postanowiłaby zawsze używać lewej ręki.
Możemy przyjąć inną metodę postępowania i próbować zgadnąć, czy dany skok warunkowy będzie wykonany, czy też nie. Okazuje się, że współczesne procesory tak właśnie postępują. Co więcej, ponieważ nie znają przyszłości, więc zachowują się jak nasza sierotka i zakładają, że będą miały szczęście i na podstawie zachowań instrukcji skoku warunkowego w przeszłości będą w stanie przewidzieć, jak będzie się ona zachowywała w przyszłości.
Zapiszmy historię wykonywania skoku warunkowego w postaci ciągu złożonego z liter T i N. Jedna z najprostszych strategii zgadywania jest następująca: przyjmujemy, że skok będzie wykonany, jeśli ostatnim razem był wykonany (czyli ostatnia litera w ciągu to T). Ta strategia ma sens w przypadku długich sekwencji analogicznych wyborów, jak to ma miejsce dla ciągu Można ją trochę ulepszyć, aby była niewrażliwa na sporadyczne przekłamania w historii, za pomocą wskaźnika mogącego przyjmować cztery stany: każde wykonanie skoku powoduje zmianę stanu wskaźnika o jeden w prawo zgodnie z rysunkiem 4, a niewykonanie skoku – w lewo. Zgadujemy, że skok zostanie wykonany, jeśli wskaźnik znajduje się w stanie lub
Gdyby w naszym procesorze zastosowano powyższą strategię, wyjaśniałaby ona różnice w działaniu programu dla ciągów i : w przypadku pierwszego z nich nasza strategia zgadywania nie zadziała tylko kilka razy (w środku ciągu i, być może, na początku), z kolei jest ona zupełnie bezużyteczna (jak w zasadzie jakakolwiek strategia zgadywania) w przypadku drugiego z nich.
Niestety, to nie wyjaśnia, dlaczego dla ciągu w którym dokładnie co trzeci bit to czas działania jest taki sam jak dla ciągu Wszak dla ciągu musielibyśmy pomylić się dla każdej jedynki, czyli co najmniej razy. Okazuje się, że strategia stosowana przez współczesne procesory próbuje uwzględniać możliwość wystąpienia krótkich cykli w historii wykonywania skoku i jest z tego powodu trochę bardziej skomplikowana. Mianowicie, przy podejmowaniu decyzji patrzymy na ostatnich liter w historii i na podstawie tych liter wybieramy jeden z czterostanowych wskaźników, który uaktualniamy i podejmujemy decyzję zgodnie z jego wskazaniem. W ten sposób dostajemy mechanizm, który będzie zgadywał poprawnie, o ile w historii wykonywania skoku każde kolejnych bitów jednoznacznie wyznacza bit -szy. W moim komputerze jest zatem w ciągu którego historię można opisać regułami: po każdym TT jest N, po każdym TN jest T, a po każdym NT jest T, procesor nie pomyli się ani razu (z wyłączeniem kilku potencjalnych pomyłek na początku ciągu).
Opisane strategie nie są jedynymi, które są stosowane we współczesnych procesorach. Istnieje cała gama rozwiązań, które pozwalają przewidywać typowe historie wykonywania skoku. W niektórych procesorach do problemu podchodzi się zupełnie inaczej. Zakłada się, na przykład, że w skompilowanym kodzie po każdej instrukcji skoku warunkowego musi wystąpić pewna liczba zwykłych instrukcji, które będą wykonane niezależnie od tego, czy skok nastąpi, czy nie, aby po ich wykonaniu adres docelowy był już obliczony.
***
***
Morał z tej bajki jest taki, że nawet kolejność, w jakiej podajemy dane programowi komputerowemu, ma znaczenie (mimo że na pierwszy rzut oka może wydawać się to niewiarygodne). Drugi morał jest ważną wskazówką dla programisty: w kodzie, w którym ważna jest efektywność wykonania, należy, o ile to możliwe, unikać skoków. Okazuje się, że nasz pierwszy program możemy przepisać tak, by nie występowała w nim instrukcja if:
Tak napisany program działa w czasie s dla każdego z ciągów i