Przeskocz do treści

Delta mi!

O sierotce, co chciała się mózgiem elektronowym wyręczyć

Tomasz Idziaszek

o artykule ...

  • Publikacja w Delcie: sierpień 2012
  • Publikacja elektroniczna: 31-07-2012

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

obrazek

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 math i  math to po każdorazowym wczytaniu nowego bitu math należy wykonać taki kod:

pict

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 math i  math zwiększymy tyle samo razy. Okazuje się jednak, że na moim komputerze dla ciągu math długości math w którym pierwsza połowa ciągu to zera, a druga połowa to jedynki, program działa math s, natomiast dla ciągu math w którym pozycje zer i jedynek są losowe, program działa math 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 math 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?

obrazek

Rys. 1 Na wykonanie instrukcji procesora składają się cztery kroki: IF (ang. instruction fetch), ID (instruction decode), EX (execute), WB (write back). Na wykonanie dwóch instrukcji potrzebujemy math jednostek czasu.

Rys. 1 Na wykonanie instrukcji procesora składają się cztery kroki: IF (ang. instruction fetch), ID (instruction decode), EX (execute), WB (write back). Na wykonanie dwóch instrukcji potrzebujemy math jednostek czasu.

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 math procesora mogłoby zostać zapisane w wewnętrznym języku komputera jako

display-math

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 math instrukcji potrzebujemy math jednostek czasu (Rys. 1).

obrazek

Rys. 2 Dzięki przetwarzaniu potokowemu (ang. pipelining) w  math jednostkach czasu jesteśmy w stanie wykonać math instrukcji w przypadku pustego potoku i aż math instrukcji w przypadku wypełnionego potoku.

Rys. 2 Dzięki przetwarzaniu potokowemu (ang. pipelining) w  math jednostkach czasu jesteśmy w stanie wykonać math instrukcji w przypadku pustego potoku i aż math instrukcji w przypadku wypełnionego potoku.

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 math z nich potrzebujemy tylko math 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:

pict

W pierwszej instrukcji zapisanej pod adresem math wykonujemy porównanie zmiennej math 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 math 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 math zwiększamy wartość math Jeśli skoku nie wykonaliśmy (druga gałąź), to w instrukcji math zwiększamy wartość math a następnie wykonujemy bezwarunkowy skok do instrukcji math 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 math czy też instrukcja math 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.

obrazek

Rys. 3 Sytuacja w potoku w przypadku, gdy skok warunkowy w instrukcji math nie nastąpił i gdy nastąpił. Liczby w nawiasach oznaczają numery instrukcji; w pierwszym przypadku w instrukcji (4) występuje bezwarunkowy skok do instrukcji (6).

Rys. 3 Sytuacja w potoku w przypadku, gdy skok warunkowy w instrukcji math nie nastąpił i gdy nastąpił. Liczby w nawiasach oznaczają numery instrukcji; w pierwszym przypadku w instrukcji (4) występuje bezwarunkowy skok do instrukcji (6).

Można ten kłopot spróbować rozwiązać następująco: procesor zawsze będzie zakładał, że po instrukcji zapisanej pod adresem math następują instrukcje pod adresami math 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 math była rozkazem warunkowego skoku i po jej wykonaniu okazało się, że należy skoczyć pod adres math to częściowa praca związana z wykonaniem instrukcji math i kolejnych jest unieważniana, a potok jest opróżniany i przetwarzanie zaczyna się na nowo od instrukcji math (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 math instrukcji będzie wynosił math 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 math i  math powinien działać tak samo długo, gdyż w obu przypadkach potok byłby opróżniany w połowie instrukcji math 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 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 math 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: math  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 math lub math

obrazek

Rys. 4 Czterostanowy wskaźnik zgadujący, czy skok zostanie wykonany.

Rys. 4 Czterostanowy wskaźnik zgadujący, czy skok zostanie wykonany.

Gdyby w naszym procesorze zastosowano powyższą strategię, wyjaśniałaby ona różnice w działaniu programu dla ciągów math i  math: 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 math w którym dokładnie co trzeci bit to math czas działania jest taki sam jak dla ciągu math Wszak dla ciągu math musielibyśmy pomylić się dla każdej jedynki, czyli co najmniej math 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 math ostatnich liter w historii i na podstawie tych liter wybieramy jeden z  math 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 math kolejnych bitów jednoznacznie wyznacza bit math -szy. W moim komputerze jest math zatem w ciągu math 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:

pict

Tak napisany program działa w czasie math s dla każdego z ciągów math i  math