Treści programowe
Wykłady
- Podstawowe typy architektur i pojęcia z nimi związane.
- Problemy w implementacji architektury potokowej i superskalarnej; metody ich rozwiązywania i wynikające z nich podukłady mikroprocesorów.
- Charakterystyka architektury wybranych współczesnych procesorów wykorzystywanych w urządzeniach stacjonarnych, mobilnych oraz superkomputerach.
- Komunikacja pomiędzy procesorem, pamięcią a urządzeniami wejścia/wyjścia.
- Charakterystyka architektury wybranych komputerów stacjonarnych oraz urządzeń mobilnych.
- Współczesne pamięci operacyjne / podstawowe parametry statyczne i dynamiczne.
- Typy magistral i ich parametry.
- Benchmarki.
- Charakterystyka mikrokomputerów jednoukładowych i ich przeznaczenie.
Ćwiczenia
Analiza szybkości wykonywania operacji stało- i zmiennoprzecinkowych.
Analiza cyklu rozkazowego i potoku rozkazowego w jednym rdzeniu procesora jedno i wielordzeniowego.
Komunikacja z pamięcią oraz z urządzeniami wejścia/wyjścia oraz obsługa przerwań.
Analiza działania wybranych instrukcji i przykładowych programów w asemblerze wybranego procesora.
Tworzenie przykładowych programów w asemblerze z wykorzystaniem instrukcji warunkowych i pętli.
Oprogramowanie układu sterującego dla przykładowego zadania (np. sterowanie światłami na skrzyżowaniu, obsługą żądań przywołania windy itp.).
Organizacja
- Jeśli ktoś jest zainteresowany, proponuję włączyć kamerę - na tej podstawie sprawdzam obecność
- Aktywność generuje plusy
- Wyniki, rezultaty z ćwiczeń proszę wklejać na czat
- Zaliczenie przedmiotu - kolokwium lub projekt
- Jeśli kolokwium to odbędzie się na przedostatnich zajęciach (zakres ściśle powiązany ze skryptem)
- Jeśli projekt, to prezentacja na ostatnich zajęciach - najprawdopodobniej stworzenie symulatora, powiązanego z tematyką zajęć.
- Zajęcia mają formułę warsztatów
- Trochę omawiamy temat
- Przeklejamy przykładowe programy lub programujemy je
- W dowolnym momencie proszę pytać o cokolwiek co jest związane z tematyką zajęć
Wstęp
Kilka problemów związanych z architekturą procesora i organizacją systemu komputerowego:
Reordering instrukcji: Procesory mogą zmieniać kolejność wykonywania instrukcji, aby zoptymalizować wydajność. Może to prowadzić do problemów związanych z synchronizacją wątków i niezdefiniowanym zachowaniem. Aby temu zapobiec, można użyć odpowiednich metod synchronizacji, takich jak muteksy, blokady czy bariery pamięci.
Efekty związane z pamięcią cache: Dostęp do pamięci ma istotny wpływ na wydajność programu. Wysokiej wydajności algorytmy powinny być zaprojektowane z uwzględnieniem hierarchii pamięci, tak aby minimalizować opóźnienia związane z odwołaniami do pamięci. Można to osiągnąć, stosując techniki optymalizacji, takie jak blokowanie, użycie pamięci lokalnej czy cache-oblivious algorithms.
Równoczesność i współbieżność: Wykorzystanie wielowątkowości i wielordzeniowości procesorów może prowadzić do problemów związanych z dostępem do wspólnych zasobów, takich jak dane czy urządzenia wejścia/wyjścia. Aby zapewnić poprawną współpracę wątków, można stosować mechanizmy synchronizacji (muteksy, semafory, blokady) oraz techniki programowania równoległego, takie jak zadania, przetwarzanie równoległe czy potoki.
Wydajność arytmetyki zmiennoprzecinkowej: Obliczenia zmiennoprzecinkowe różnią się wydajnością w zależności od architektury procesora. Aby uzyskać najlepszą wydajność, można stosować optymalizacje specyficzne dla danego procesora, takie jak wektoryzacja, instrukcje SIMD czy optymalizacja obliczeń matematycznych.
Obsługa błędów sprzętowych: Błędy sprzętowe, takie jak błędy parzystości czy uszkodzenia pamięci, mogą wpłynąć na poprawność działania programu. Można zastosować techniki wykrywania i naprawy błędów, takie jak korekcja błędów pamięci czy sprawdzanie parzystości, aby zminimalizować ryzyko wprowadzenia błędów przez sprzęt.
Aby uniknąć tych problemów i zapewnić poprawne działanie programu, ważne jest zapoznanie się z architekturą procesora, na którym ma być uruchomiony program, oraz zastosowanie odpowiednich technik programowania i optymalizacji. Oto kilka dodatkowych wskazówek:
Endianness: Kolejność bajtów w reprezentacji liczb wielobajtowych (little-endian lub big-endian) różni się między różnymi architekturami procesorów.
Wydajność operacji wejścia/wyjścia: Operacje wejścia/wyjścia mają znaczący wpływ na wydajność aplikacji. W przypadku programów wymagających intensywnego przepływu danych, należy zastosować techniki buforowania, asynchronicznych operacji wejścia/wyjścia oraz optymalizować użycie systemów plików i urządzeń wejścia/wyjścia.
Granice stronicowania: Dostęp do pamięci może być niejednorodny z powodu granic stronicowania. Aby uniknąć opóźnień związanych z przenoszeniem danych między stronami pamięci, można zastosować techniki takie jak wyrównywanie stronicowania (page alignment), stosowanie dużych stron (huge pages) czy ograniczenie fragmentacji pamięci.
Architektura NUMA (Non-Uniform Memory Access): W przypadku systemów wieloprocesorowych z architekturą NUMA, dostęp do pamięci może być wolniejszy dla niektórych rdzeni procesora. Aby zoptymalizować wydajność programów na takich systemach, można stosować techniki takie jak lokalność danych, afinitet rdzeni czy specjalne funkcje alokacji pamięci, takie jak
numa_alloc_onnode
inuma_bind
.Wsparcie dla instrukcji specyficznych dla procesora: Niektóre procesory mają instrukcje, które pozwalają na bardziej efektywne wykonanie określonych operacji. Można skorzystać z tych instrukcji, pisząc kod w asemblerze lub korzystając z bibliotek, które wykorzystują te instrukcje, takie jak biblioteki matematyczne czy algorytmiczne.
Aby uniknąć problemów związanych z architekturą procesora i organizacją systemu komputerowego, ważne jest, aby programować z uwzględnieniem tych zagadnień. Dobrym podejściem jest też korzystanie z bibliotek i narzędzi, które już zostały zoptymalizowane pod kątem różnych architektur i systemów.
Ref:
- Architektura systoliczna - np. Edge TPU
- Google Coral Edge TPU explained in depth
- Cell (PS3) - PowerPC (PPE) + 7 x „Synergistic Processing Elements” (SPEs) PlayStation 3
- Hardware for Deep Learning
- Unlocking the Secrets of GPU Architecture
- A CPU is a compiler
Reprezentacja liczb
System dziesiętny
Liczba 13910 jest zapisana w systemie dziesiętnym, co oznacza, że każda pozycja odpowiada potędze liczby 10. System pozycyjny to sposób zapisu liczb, w którym wartość liczby zależy od wartości cyfr i ich pozycji. W systemie wagowym wartość liczby jest sumą iloczynów cyfr i wag pozycji, gdzie wagi pozycji są potęgami podstawy systemu.
Liczba 13910 z rozbiciem na wagi poszczególnych pozycji:
1 * 10^4 +
3 * 10^3 +
9 * 10^2 +
1 * 10^1 +
0 * 10^0 =
10000 + 3000 + 900 + 10 + 0 = 13910
System binarny
Konwersja liczby 13910 na zapis binarny:
13910 : 2 = 6955 (reszta: 0)
6955 : 2 = 3477 (reszta: 1)
3477 : 2 = 1738 (reszta: 1)
1738 : 2 = 869 (reszta: 0)
869 : 2 = 434 (reszta: 1)
434 : 2 = 217 (reszta: 0)
217 : 2 = 108 (reszta: 1)
108 : 2 = 54 (reszta: 0)
54 : 2 = 27 (reszta: 0)
27 : 2 = 13 (reszta: 1)
13 : 2 = 6 (reszta: 1)
6 : 2 = 3 (reszta: 0)
3 : 2 = 1 (reszta: 1)
1 : 2 = 0 (reszta: 1)
Liczba binarna: 11 0110 0101 0110
Ćwiczenie
Zamienić na liczbę w notacji binarnej za pomocą powyższej metody:
- 19
- 1977
Naturalny kod binarny to sposób zapisu liczb, w którym wartość liczby jest sumą iloczynów cyfr i wag pozycji, gdzie wagi pozycji są potęgami liczby 2.
Liczba 1011 0101 z rozbiciem na wagi poszczególnych pozycji:
1 * 2^7 +
0 * 2^6 +
1 * 2^5 +
1 * 2^4 +
0 * 2^3 +
1 * 2^2 +
0 * 2^1 +
1 * 2^0 =
128 + 32 + 16 + 4 + 1 = 181
System szesnastkowy
0 | 0000 | 8 | 1000 |
1 | 0001 | 9 | 1001 |
2 | 0010 | A | 1010 |
3 | 0011 | B | 1011 |
4 | 0100 | C | 1100 |
5 | 0101 | D | 1101 |
6 | 0110 | E | 1110 |
7 | 0111 | F | 1111 |
Aby przekonwertować liczbę binarną 1011011100101101 na szesnastkowy, można ją podzielić na grupy po 4 bity i przypisać im wartości szesnastkowe:
1011 0111 0010 1101
B 7 2 D
Liczba szesnastkowa: B72D
Przechowywanie danych w komputerach
Narysować pamięć
Dane w komputerze są przechowywane w postaci binarnej, czyli za pomocą bitów (0 i 1). System binarny został wybrany ze względu na prostotę realizacji elektronicznej (wysokie i niskie napięcie) oraz łatwość przetwarzania informacji.
Rejestr to mały obszar pamięci w procesorze, używany do przechowywania wartości, które są obecnie przetwarzane. Słowo to ciąg bitów, który odpowiada długości rejestru lub jednostki przetwarzania danych.
Załóżmy, że mamy rejestr 8-bitowy. Aby zapisać wartość 139 (10001011) w rejestrze, wystarczy ustawić bity na odpowiednie wartości: 10001011
Ile bitów jest potrzebnych na zapisanie liczby 139, 255, 269, 513?
139: 8 bitów (10001011)
255: 8 bitów (11111111)
269: 9 bitów (100001101)
513: 10 bitów (1000000001)
Liczba potrzebnych bitów do zapisania każdej z tych liczb zależy od ich wartości w postaci binarnej. Aby określić minimalną ilość bitów potrzebną do zapisania danej liczby, można zastosować funkcję logarytmu dwójkowego (lub log2), a następnie zaokrąglić wynik w górę do najbliższej liczby całkowitej.
|
|
Co to jest rejestr?
Nadmiar i niedomiar
Ćwiczenie
- Czy wynik operacji mieści się w 8-bitowym rejestrze? (nadmiar czy niedomiar)
- Ile bitów potrzeba do reprezentacji wyniku?
Dodawanie:
A = 127 (01111111) B = 4 (00000100) A + B = 131 (10000011)
Dodawanie:
A = 139 (10001011) B = 147 (10010011) A + B = 286 (1 00010010) efekt nadmiaru, wynik nie mieści się w 8-bitowym rejestrze.
W drugim przypadku mamy do czynienia z nadmiarem, co oznacza, że wynik przekracza maksymalną wartość, która może być przechowywana w 8-bitowym rejestrze (255).
Odejmowanie:
A = 115 (01110011) B = 5 (00000101) C = A - B = 110 (01101110)
Odejmowanie:
A = 129 (10000001) B = 167 (10100111) C = A - B = -38 (11011010) efekt niedomiaru, ponieważ wynik jest liczbą ujemną.
Liczby ujemne
- Z bitem znaku
- U2
Aby zapisać liczbę -2 w 8-bitowym rejestrze, używamy kodu U2 (Uzupełnienie do 2):
-2 = 11111110
Przykład dla liczby 10110101:
Jako liczba dodatnia:
1 * 2^7 + 0 * 2^6 + 1 * 2^5 + 1 * 2^4 + 0 * 2^3 + 1 * 2^2 + 0 * 2^1 + 1 * 2^0 = 128 + 32 + 16 + 4 + 1 = 181
Jako liczba ujemna (w kodzie U2):
~1011 0101 + 1 = 0100 1010 + 1 = 0100 1011 co daje: 64 + 8 + 2 + 1 = 75, więc liczba to -75.
Interpretacja liczby zależy od kontekstu. W przypadku liczb ujemnych często stosowany jest kod U2.
Kod U2
Uzupełnienie do 2, to metoda reprezentacji liczb całkowitych, zarówno dodatnich, jak i ujemnych, w systemie binarnym. W kodzie U2 liczby dodatnie są reprezentowane tak samo jak w naturalnym kodzie binarnym, natomiast liczby ujemne są reprezentowane jako dopełnienie do 2 liczby bezwzględnej.
Obliczanie reprezentację liczby ujemnej w kodzie U2:
- Przekształć wartość bezwzględną liczby ujemnej na postać binarną.
- Odwróć bity (zmień 0 na 1 i 1 na 0).
- Dodaj 1 do wyniku.
Przykłady:
Przykład dla liczby -3:
1. Wartość bezwzględna: 3 (w binarnym: 0000 0011) 2. Odwrócenie bitów: 1111 1100 3. Dodanie 1: 1111 1100 + 1 = 1111 1101 Kod U2 dla liczby -3: 1111 1101
Przykład dla liczby -7:
1. Wartość bezwzględna: 7 (w binarnym: 0000 0111) 2. Odwrócenie bitów: 1111 1000 3. Dodanie 1: 1111 1000 + 1 = 1111 1001 Kod U2 dla liczby -7: 1111 1001
Kod U2 jest często stosowany w komputerach, ponieważ pozwala na wykonywanie operacji dodawania i odejmowania przy użyciu tego samego sprzętu, niezależnie od znaku liczb.
Ćwiczenie
- -7
- -117
Liczby rzeczywiste
11 i 5/16 = 11.3125
Liczba 11 w postaci binarnej to 1011.
5/16 jako ułamek binarny to 0.0101 ponieważ
1 * (1/2)^3 +
0 * (1/2)^4 +
1 * (1/2)^5)
0.3125 * 2 = 0.625 -> 0
0.625 * 2 = 1.25 -> 1
0.25 * 2 = 0.5 -> 0
0.5 * 2 = 1 -> 1
Liczba 11 i 5/16 w postaci binarnej to: 1011.0101
Potrzebujemy 8 bitów na zapisanie tej liczby. Można zapisać ją w rejestrze jako liczba stałoprzecinkowa (zakładając, że przecinek jest zawsze pomiędzy bitami 3 i 4) lub używając notacji zmiennoprzecinkowej, która pozwala na zapisanie liczby z określoną precyzją i zakresem.
Zamiana na postać binarną
Aby przekształcić liczbę dziesiętną 10.75 na liczbę binarną, należy przekształcić osobno jej część całkowitą i część ułamkową.
Konwersja części całkowitej:
Liczba całkowita: 10 Postępujemy według metody dzielenia przez 2: 10 / 2 = 5, reszta: 0 5 / 2 = 2, reszta: 1 2 / 2 = 1, reszta: 0 1 / 2 = 0, reszta: 1 Czytamy reszty od dołu do góry: 1010
Konwersja części ułamkowej:
Liczba ułamkowa: 0.75 Postępujemy według metody mnożenia przez 2: 0.75 * 2 = 1.5 (zapisujemy cyfrę 1) 0.50 * 2 = 1.0 (zapisujemy cyfrę 1) Czytam od góry do dołu: 11
Teraz, aby uzyskać postać binarną liczby 10.75, łączymy część całkowitą (1010) i część ułamkową (11) za pomocą przecinka binarnego: 1010.11
Stąd wynika, że liczba dziesiętna 10.75 jest równa liczbie binarnej 1010.11
Uwaga Podczas konwersji liczby ułamkowej dziesiętnej na liczbę binarną za pomocą metody mnożenia przez 2, należy zakończyć mnożenie w jednym z następujących przypadków:
Gdy wartość ułamkowa po mnożeniu wynosi dokładnie 0. Wtedy wszystkie kolejne mnożenia również będą dawały 0, a kolejne cyfry binarne po przecinku będą się składały wyłącznie z zer. W takiej sytuacji nie > ma potrzeby kontynuować obliczeń.
Gdy osiągniesz żądaną precyzję. W praktyce, liczby binarne używane w komputerach mają ograniczoną precyzję (np. w przypadku liczb zmiennoprzecinkowych w standardzie IEEE-754). W związku z tym możemy > zakończyć mnożenie po osiągnięciu określonej liczby cyfr binarnych po przecinku.
Gdy zauważysz cykliczne powtarzanie się wzoru cyfr. W niektórych przypadkach, liczby ułamkowe dziesiętne mają nieskończoną reprezentację binarną, która powtarza się cyklicznie. Kiedy zauważysz taki wzór, > możesz zakończyć mnożenie i zaokrąglić wynik do żądanej precyzji.
Należy pamiętać, że niektóre liczby ułamkowe dziesiętne mają nieskończoną reprezentację binarną, która nie wykazuje cyklicznego powtarzania się wzoru. W takich przypadkach, można zakończyć mnożenie, gdy > osiągnięta zostanie żądana precyzja, biorąc pod uwagę ograniczenia sprzętowe lub wymagania dotyczące zaokrąglenia.
Przykłady
Liczba dziesiętna 0.5:
0.5 * 2 = 1.0 (zapisujemy cyfrę 1) Binarnie: 0.1
Liczba dziesiętna 0.25:
0.25 * 2 = 0.5 (zapisujemy cyfrę 0) 0.5 * 2 = 1.0 (zapisujemy cyfrę 1) Binarnie: 0.01
Liczba dziesiętna 0.625:
0.625 * 2 = 1.25 (zapisujemy cyfrę 1) 0.25 * 2 = 0.5 (zapisujemy cyfrę 0) 0.5 * 2 = 1.0 (zapisujemy cyfrę 1) Binarnie: 0.101
Liczba dziesiętna 0.375:
0.375 * 2 = 0.75 (zapisujemy cyfrę 0) 0.75 * 2 = 1.5 (zapisujemy cyfrę 1) 0.5 * 2 = 1.0 (zapisujemy cyfrę 1) Binarnie: 0.011
Liczba dziesiętna 0.1977:
0.1977 * 2 = 0.3954 -> (0.0) 0.3954 * 2 = 0.7908 -> (0.00) 0.7908 * 2 = 1.5816 -> (0.001) 0.5816 * 2 = 1.1632 -> (0.0011) 0.1632 * 2 = 0.3264 -> (0.00110) 0.3264 * 2 = 0.6528 -> (0.001100) 0.6528 * 2 = 1.3056 -> (0.0011001)
Warto zauważyć, że niektóre liczby ułamkowe w systemie dziesiętnym nie mają dokładnej reprezentacji w systemie binarnym.
Liczba dziesiętna 0.1:
0.1 * 2 = 0.2 (zapisujemy cyfrę 0) 0.2 * 2 = 0.4 (zapisujemy cyfrę 0) 0.4 * 2 = 0.8 (zapisujemy cyfrę 0) 0.8 * 2 = 1.6 (zapisujemy cyfrę 1) 0.6 * 2 = 1.2 (zapisujemy cyfrę 1) 0.2 * 2 = 0.4 (zapisujemy cyfrę 0) ... Binarnie: 0.0001100110011... (cyfry się powtarzają)
W takich przypadkach reprezentacja binarna będzie nieskończoną liczbą cyfr po przecinku i musi zostać zaokrąglona, aby zmieścić się w określonym formacie, takim jak standard IEEE-754.
Notacja zmiennoprzecinkowa
W komputerach najczęściej stosowaną notacją zmiennoprzecinkową jest standard IEEE-754. Standard ten definiuje formaty reprezentacji liczb zmiennoprzecinkowych, takie jak pojedyncza precyzja (32 bity) i podwójna precyzja (64 bity). Standard IEEE-754 pozwala na zapisanie liczb z określoną precyzją, zakresem i specjalnymi wartościami (takimi jak nieskończoność i NaN). Na niektórych platformach dostępny jest typ half-float (NVidia CUDA) o połowicznej precyzji (16 bitów)
Reprezentacja liczby zmiennoprzecinkowej w standardzie IEEE-754 składa się z trzech części:
- Znak (Sign) - jeden bit, który reprezentuje znak liczby (0 - dodatnia, 1 - ujemna).
- Wykładnik (Exponent) - ciąg bitów reprezentujący wykładnik liczby, przesunięty o wartość stałą (bias). Dla formatu pojedynczej precyzji przesunięcie wynosi 127, a dla podwójnej precyzji wynosi 1023.
- Mantysa (Significand) - ciąg bitów reprezentujący wartość liczby znormalizowanej, ale bez pierwszego bitu (1), który jest zawsze niejawny (chyba że liczba jest denormalizowana).
Tworzenie reprezentacji binarnej liczby zmiennoprzecinkowej w standardzie IEEE-754:
- Określ znak liczby (0 dla dodatniej, 1 dla ujemnej).
- Zamienić część dziesiętną oraz ułamkową na postać binarną.
- Przekształć liczbę na postać znormalizowaną w notacji naukowej (1.x * 2^n).
- Oblicz wykładnik, dodając przesunięcie (bias) do n (wykładnik notacji naukowej).
- Przekształć mantysę na postać binarną, pomijając pierwszy bit (1).
- Złożyć reprezentację z części znakowej, wykładnika i mantysy.
Przykład:
Chcemy zapisać liczbę 10.75 jako liczbę zmiennoprzecinkową w standardzie IEEE-754 (pojedyncza precyzja).
- Liczba jest dodatnia, więc znak to 0.
- Postać binarną: 10.75 = 1010.11
- Postać znormalizowana: 1.01011 * 2^3
- Wykładnik: 3 + 127 (bias) = 130, w postaci binarnej: 10000010
- Mantysa (bez pierwszego bitu): 0101100… (w sumie 23 bity)
- Składamy reprezentację: 0 10000010 01011000000000000000000
Reprezentacja liczby 10.75 w standardzie IEEE-754 (pojedyncza precyzja) to:
32 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1
0 1 0 0 0 0 0 1 0 0 1 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
1 * 2^3 * 1.34375 = 10.75
Ref:
Kolejność bajtów
Przykład reprezentacji liczby 32-bitowej (4-bajtowej) w różnych kolejnościach bajtów:
Wartość: 0x12345678
- Little-endian: 78 56 34 12
- Big-endian: 12 34 56 78
Poniżej znajduje się przykładowy program w C++, który demonstruje różne kolejności bajtów:
Lab_01:
|
|
Różne procesory używają różnych kolejności bajtów:
- Little-endian: Procesory Intel x86, x86-64 oraz większość mikrokontrolerów ARM (w trybie little-endian).
- Big-endian: Procesory Motorola 68k, PowerPC, IBM z/Architecture, a także niektóre mikrokontrolery ARM (w trybie big-endian).
Wybór kolejności bajtów jest często wynikiem decyzji projektowych, które miały na celu ułatwienie lub optymalizację wykonywania określonych operacji. Little-endian jest popularne ze względu na prostotę dodawania i odejmowania liczb o zmiennej długości oraz łatwiejszą obsługę liczb o mniejszej precyzji w reprezentacji o większej precyzji. Big-endian jest częściej stosowany w systemach, które muszą współpracować z sieciami komputerowymi, gdzie bajty są przesyłane od najbardziej znaczącego do najmniej znaczącego, co ułatwia interpretację wartości przesyłanych danych.
Ref:
Wykonywania operacji stało- i zmiennoprzecinkowych:
FPU (Floating Point Unit) to podzespoły procesora odpowiedzialne za wykonywanie operacji arytmetycznych na liczbach zmiennoprzecinkowych. Liczba jednostek FPU może się różnić w zależności od architektury i modelu procesora. Oto kilka przykładów:
- Intel Core i7-8700K (8. generacja Core)
- 6 rdzeni, każdy z jednostką FPU - 6 jednostek FPU.
- AMD Ryzen 7 3700X (3. generacja Ryzen)
- 8 rdzeni, każdy z jednostką FPU - 8 jednostek FPU.
- Apple M1 (ARM-based)
- 8 rdzeni CPU (4 wysokiej wydajności i 4 energooszczędne), każdy z jednostką FPU - 8 jednostek FPU.
- IBM POWER9
- 12 lub 24 rdzenie, w zależności od konfiguracji. Każdy rdzeń ma jedną jednostkę FPU - 12 lub 24 jednostki FPU.
- NVIDIA A100 (GPU)
- W przypadku GPU, jednostki FPU są zintegrowane z procesorami strumieniowymi (CUDA cores). NVIDIA A100 posiada 6912 rdzeni CUDA.
Warto zauważyć, że w przypadku GPU, jednostki FPU mogą być wykorzystywane do obliczeń równoległych, ale zwykle nie są one traktowane jako oddzielne jednostki, tak jak w przypadku CPU.
Porównanie czasu wykonywania
Porównanie czasu wykonywania dodawania, odejmowania, mnożenia i dzielenia dla liczby stałoprzecinkowej (int) i zmiennoprzecinkowej (float, double).
- Utwórz program, który generuje losowe liczby int oraz float (lub double) i wykonuje podstawowe operacje matematyczne.
- Zmierz czas wykonania każdej operacji dla różnych precyzji.
- Porównaj wyniki i zastanów się nad wpływem precyzji na czas wykonania operacji.
Lab_02:
|
|
Ten program w C++ analizuje wpływ precyzji zmiennoprzecinkowej na czas wykonania operacji. Wykonuje on podstawowe operacje matematyczne (+
, -
, *
, /
) na liczbach całkowitych
oraz zmiennoprzecinkowych o różnych precyzjach (float
, double
) i mierzy czas wykonania tych operacji za pomocą biblioteki <chrono>
.
Po skompilowaniu i uruchomieniu programu, na konsoli zostaną wyświetlone wyniki czasu wykonania operacji dla różnych precyzji. Należy przeanalizować wyniki i zastanowić się nad wpływem precyzji na czas wykonania operacji.
Obliczanie czasu wykonywania operacji na macierzach stało- i zmiennoprzecinkowych.
- Napisz program, który mnoży dwie macierze o ustalonych rozmiarach, używając typów stałoprzecinkowych (int) oraz zmiennoprzecinkowych (float, double).
- Zmierz czas wykonania mnożenia macierzy dla różnych rozmiarów macierzy.
- Porównaj wyniki i omów znaczenie różnic w czasie wykonania dla różnych typów danych.
Lab_04:
|
|
Program mnoży dwie macierze o ustalonych rozmiarach, używając typów stałoprzecinkowych (int
) oraz zmiennoprzecinkowych (float
, double
). Następnie, mierzy czas wykonania mnożenia macierzy dla różnych rozmiarów macierzy (100x100, 500x500, 1000x1000) i porównuje wyniki dla różnych typów danych.
Po skompilowaniu i uruchomieniu programu, na konsoli zostaną wyświetlone wyniki czasu wykonania mnożenia macierzy dla różnych rozmiarów macierzy i typów danych. Porównaj wyniki i omów znaczenie różnic w czasie wykonania dla różnych typów danych.
Wyniki mogą się różnić w zależności od sprzętu i kompilatora. Analizuj różnice między typami danych (stałoprzecinkowe i zmiennoprzecinkowe) oraz rozmiarami macierzy. Zwróć uwagę na to, jak zmienia się czas wykonania w zależności od typu danych i rozmiaru macierzy. Zastanów się, jak te różnice wpływają na wydajność w praktycznych zastosowaniach, takich jak obliczenia naukowe, analiza danych czy grafika komputerowa.
W praktyce, wybór między typami danych zależy od wymagań dotyczących precyzji, wydajności i dostępnych zasobów. W przypadku obliczeń naukowych i analizy danych, precyzja może być kluczowa, ale jednocześnie może wpłynąć na czas wykonania. W grafice komputerowej, mniejsza precyzja może być akceptowalna, ale szybsze obliczenia mogą być kluczowe dla płynności działania aplikacji.
Eksperyment z optymalizacją kodu.
- Napisz prosty program, który wykonuje operacje matematyczne na liczbach stało- i zmiennoprzecinkowych.
- Skompiluj i uruchom program z różnymi poziomami optymalizacji (np. -O0, -O1, -O2, -O3).
- Porównaj czas wykonania programu dla różnych poziomów optymalizacji i zastanów się, jak optymalizacja wpływa na wydajność operacji stało- i zmiennoprzecinkowych.
Wprowadzenie do instrukcji SIMD.
- Zapoznaj się z instrukcjami SIMD (Single Instruction, Multiple Data) dostępnymi dla Twojego procesora (np. SSE, AVX).
- Napisz program, który wykonuje operacje matematyczne na wektorach liczb stało- i zmiennoprzecinkowych, wykorzystując instrukcje SIMD.
- Porównaj czas wykonania operacji wektorowych z użyciem SIMD do czasu wykonania operacji
Aby użyć instrukcji SIMD w C++, możemy wykorzystać bibliotekę immintrin.h
, która obsługuje instrukcje SSE, SSE2, SSE3, SSSE3, SSE4.1, SSE4.2, AVX, AVX2 i AVX-512.
Poniżej znajduje się przykład programu w C++, który wykonuje operacje dodawania na wektorach liczb stało- i zmiennoprzecinkowych, wykorzystując instrukcje SIMD. Porównuje czas wykonania operacji wektorowych z użyciem SIMD do czasu wykonania operacji bez SIMD.
|
|
W tym przypadku, używamy instrukcji SIMD dla operacji dodawania wektorów zmiennoprzecinkowych (float
). Program porównuje czas wykonania operacji wektorowych z użyciem SIMD do czasu wykonania operacji bez SIMD dla typów int
, float
i double
. Po skompilowaniu i uruchomieniu programu, na konsoli zostaną wyświetlone wyniki czasu wykonania dodawania wektorów dla różnych typów danych, zarówno z użyciem SIMD, jak i bez.
Wyniki mogą się różnić w zależności od sprzętu i kompilatora, ale zazwyczaj SIMD przyspiesza obliczenia wektorowe, gdyż pozwala na wykonywanie operacji na wielu elementach danych jednocześnie.
W praktyce, wykorzystanie SIMD może prowadzić do znacznego przyspieszenia obliczeń wektorowych, szczególnie w zastosowaniach, takich jak grafika komputerowa, analiza danych czy przetwarzanie sygnałów. Jednak warto pamiętać, że nie wszystkie operacje mogą być przyspieszone przy użyciu SIMD, a także, że optymalizacja przy użyciu SIMD może zwiększyć złożoność kodu i utrudnić jego utrzymanie.
Ref:
Porównawczy kod w asemblerze
Poniżej znajduje się przykład programu napisanego w asemblerze x86, który porównuje czas wykonania operacji dodawania, odejmowania, mnożenia i dzielenia dla liczby stałoprzecinkowej (int) i zmiennoprzecinkowej (float, double). Program korzysta z polecenia RDTSC do mierzenia czasu wykonania operacji.
|
|
Aby skompilować i uruchomić program, można użyć narzędzi NASM i LD lub fasm
Należy pamiętać, że ten program nie wypisuje wyników na ekran, ale przechowuje je w zarezerwowanych zmiennych int_duration
i float_duration
. Aby wyświetlić wyniki, można dodać kod odpowiedzialny za wypisanie wartości na ekran, korzystając z systemu wywołań (syscalls) lub użyć debuggera, aby przejrzeć wartości tych zmiennych
Ref:
Architektura von Neumana
Architektura komputerów Von-Neumanna, zaproponowana przez Johna von Neumanna, opiera się na koncepcji przechowywania programów w pamięci komputera. Wcześniej, maszyny liczące były projektowane tak, aby wykonywać jedno konkretne zadanie i nie można było ich przeprogramować. Architektura von Neumanna wprowadziła założenie, że programy i dane mogą być przechowywane w tej samej pamięci, co umożliwiło rozwój maszyn uniwersalnych, które mogą wykonywać różne zadania.
Podstawowe elementy architektury von Neumanna obejmują:
Pamięć: Służy do przechowywania danych i programów. Pamięć może być podzielona na dwie części: pamięć operacyjną (RAM) i pamięć trwałą (np. dysk twardy).
Procesor: Składa się z jednostki arytmetyczno-logicznej (ALU) oraz jednostki sterującej. ALU wykonuje operacje arytmetyczne i logiczne na danych, podczas gdy jednostka sterująca zarządza sekwencją operacji, odczytuje instrukcje z pamięci i decyduje, jakie operacje mają zostać wykonane.
Wejście/wyjście (I/O): Odpowiada za komunikację z urządzeniami zewnętrznymi, takimi jak klawiatura, mysz, monitor, drukarka itp.
System komunikacji: Składa się z szyny danych, szyny adresowej i szyny kontrolnej, które łączą poszczególne elementy komputera i umożliwiają wymianę informacji między nimi.
Architektura komputerów Von-Neumanna jest podstawą dla większości współczesnych komputerów i urządzeń, takich jak komputery osobiste, laptopy, serwery czy smartfony. Wraz z postępem technologicznym i rozwojem układów scalonych oraz mikroprocesorów, architektura von Neumanna została udoskonalona i przekształcona, ale jej podstawowe zasady wciąż pozostają aktualne.
ISA (Instruction set architecture) i posiada trzy podstawowe jednostki:
- Centralna Jednostka Przetwarzająca (CPU)
- Control Unit(CU)
- Jednostka arytmetyczna i logiczna (ALU)
- Rejestry
- Jednostka pamięci głównej
- Urządzenie wejścia/wyjścia
Przykładowa symulacja architektury
|
|
Przykład Lab_31.py
Instrukcje symulatora
Procesor posiada tylko jeden rejestr, akumulator A. Możliwe jest adresowanie bezpośrednie i pośrednie. Załóżmy, że akumulator jest 32bitowy.
- HLT - Zatrzymanie pracy
- LDA <> - Załadowanie do A danych z adresu
- STA - Wpisanie pod zadany adres w argumencie, wartości z rejestru A
- LDI - Załadowanie do A danych z adresu wskzanego pod podanym adresem
- INP - Załadowanie danych do A z urządzenia we/wy
- OUT - Wysłanie wartości A na wyjście
- ADD - Dodanie wartości z pamięci wskazanej przez argument do A
- SUB - Odjęcie od A wartości z pamięci wskazanej przez argument
- CMP - Porównanie A z wartoścą pamięci wskazanej przez argument, wynik w A 0 jesli równe, 1 jeśli różne
- MOV - Wpisanie do A bezpośredniej wartości
- INC - Zwiększenie A o jeden
- DEC - Zmniejszenie A o jeden
- JMP - Skok bezwarunkowy
- JZ - Skok jeśli A jest równe zero
Składnia assemblera:
:etykieta - etykieta, wskazująca na miejsce w pamięci
@etykieta - poinformowanie kompilator, aby zamienił wskazanie na adres etykiety w pamięci
Przykłady:
Wypisanie liczby 10 na wyjście
|
|
Dodawanie
|
|
Adresowanie bezpośrednie
|
|
Adresowanie pośrednie
|
|
Pętla
|
|
Ćwiczenia
dodac dwie liczby znajdujące się w pamięci
|
|
- wypisac liczby znajdujące się w pamięci az napotkamy 0:
|
|
- zsumować liczby znajdujące się w pamięci, zakończyć jak napotkamy 0:
|
|
- wypisac na wyjście kolejne wartości ciągu fibonacciego, kilka pierwszych. Dla uproszczenia można dwie początkowe zapisać od razu w kodzie, np.:
|
|
Symulator
|
|
Analiza cyklu rozkazowego
Zdefiniowanie cyklu rozkazowego i jego faz (pobieranie, dekodowanie, wykonanie, dostęp do pamięci i zapis wyniku).
Cykl rozkazowy to sekwencja kroków, które wykonuje procesor w celu pobrania, zdekodowania i wykonania pojedynczego rozkazu. Cykl rozkazowy składa się z pięciu głównych faz, które są realizowane sekwencyjnie:
Pobieranie (Fetch): W tej fazie procesor pobiera rozkaz z pamięci. Wartość licznika rozkazów (Program Counter, PC) wskazuje na adres pamięci, z którego rozkaz ma być pobrany. Po pobraniu rozkazu, licznik rozkazów jest zwiększany o rozmiar rozkazu, aby wskazać na kolejny rozkaz.
Dekodowanie (Decode): Pobrany rozkaz jest dekodowany przez jednostkę dekodującą procesora, która interpretuje go i ustala, jakie operacje należy wykonać oraz które rejestry i/lub wartości są używane jako argumenty.
Wykonanie (Execute): W tej fazie jednostka wykonawcza procesora przeprowadza operacje zdefiniowane przez rozkaz. Może to obejmować działania arytmetyczne, logiczne, przesunięcia bitowe lub inne operacje. Wynik tych operacji jest przechowywany w rejestrze tymczasowym.
Dostęp do pamięci (Memory Access): Jeśli rozkaz wymaga dostępu do pamięci, na przykład w celu odczytania lub zapisania wartości, w tej fazie procesor odczytuje dane z pamięci lub zapisuje je w odpowiednim miejscu.
Zapis wyniku (Write-back): W tej ostatniej fazie wynik operacji, przechowywany w rejestrze tymczasowym, jest zapisywany do odpowiedniego rejestru procesora lub do pamięci, jeśli jest to wymagane.
Przykład analizy cyklu rozkazowego:
Rozważmy prosty przykład dodawania dwóch liczb, przechowywanych w rejestrach R1 i R2, a wynik ma być zapisany w rejestrze R3. Rozkaz może wyglądać tak: ADD R3, R1, R2
- Pobieranie: Procesor pobiera rozkaz ADD z adresu wskazanego przez licznik rozkazów (PC) i zwiększa wartość PC.
- Dekodowanie: Jednostka dekodująca interpretuje rozkaz jako operację dodawania, gdzie R1 i R2 są argumentami, a wynik ma być zapisany w R3.
- Wykonanie: Jednostka wykonawcza dodaje wartości z rejestru R1 i R2, a wynik przechowuje w rejestrze tymczasowym.
- Dostęp do pamięci: W tym przypadku nie ma potrzeby dostępu do pamięci, ponieważ wszystkie wartości są przechowywane w rejestrach.
- Zapis wyniku: Wynik dodawania, przechowywany w rejestrze tymczasow
Etapy cyklu rozkazowego.
Przyjmijmy prosty zestaw rozkazów oparty na hipotetycznym procesorze z ograniczoną architekturą. Rozkazy:
- LOAD R1, (ADDR) - Ładuje wartość z pamięci o adresie ADDR do rejestru R1.
- STORE (ADDR), R1 - Zapisuje wartość z rejestru R1 do pamięci o adresie ADDR.
- ADD R1, R2 - Dodaje wartości z rejestru R1 i R2, a wynik zapisuje w rejestrze R1.
- SUB R1, R2 - Odejmuje wartość rejestru R2 od wartości rejestru R1, a wynik zapisuje w rejestrze R1.
- JUMP ADDR - Bezwarunkowy skok do adresu ADDR.
- JNZERO R1, ADDR - Skok warunkowy do względnego adresu ADDR, jeśli wartość w rejestrze R1 jest różna od 0.
Omówienie poszczególnych etapów cyklu rozkazowego dla każdego z rozkazów:
LOAD R1, (ADDR):
- Pobieranie: Procesor pobiera rozkaz LOAD z pamięci.
- Dekodowanie: Dekodowanie rozkazu LOAD i identyfikacja rejestru R1 oraz adresu pamięci ADDR.
- Wykonanie: Brak operacji wykonawczych dla tego rozkazu.
- Dostęp do pamięci: Odczyt wartości z pamięci o adresie ADDR.
- Zapis wyniku: Zapisanie odczytanej wartości do rejestru R1.
STORE (ADDR), R1:
- Pobieranie: Procesor pobiera rozkaz STORE z pamięci.
- Dekodowanie: Dekodowanie rozkazu STORE i identyfikacja rejestru R1 oraz adresu pamięci ADDR.
- Wykonanie: Brak operacji wykonawczych dla tego rozkazu.
- Dostęp do pamięci: Zapis wartości z rejestru R1 do pamięci o adresie ADDR.
- Zapis wyniku: Brak operacji zapisu wyniku, ponieważ wynik został już zapisany w pamięci.
ADD R1, R2:
- Pobieranie: Procesor pobiera rozkaz ADD z pamięci.
- Dekodowanie: Dekodowanie rozkazu ADD i identyfikacja rejestrów R1 i R2.
- Wykonanie: Dodawanie wartości z rejestru R1 i R2, a wynik przechowuje w rejestrze tymczasowym.
- Dostęp do pamięci: Brak dostępu do pamięci, ponieważ wszystkie wartości są przechowywane w rejestrach.
- Zapis wyniku: Zapisanie wyniku dodawania do rejestru R1.
SUB R1, R2:
- Pobieranie: Procesor pobiera rozkaz SUB z pamięci.
- Dekodowanie: Dekodowanie rozkazu SUB i identyfikacja rejestrów R1 i R2.
- Wykonanie: Odejmowanie wartości rejestru R2 od wartości rejestru R1, a wynik przechowuje w rejestrze tymczasowym.
- Dostęp do pamięci: Brak dostępu do pamięci, ponieważ wszystkie wartości są przechowywane w rejestrach.
- Zapis wyniku: Zapisanie wyniku odejmowania do rejestru R1.
JUMP ADDR:
- Pobieranie: Procesor pobiera rozkaz JUMP z pamięci.
- Dekodowanie: Dekodowanie rozkazu JUMP i identyfikacja adresu ADDR.
- Wykonanie: Aktualizacja licznika rozkazów (PC) na wartość ADDR.
- Dostęp do pamięci: Brak dostępu do pamięci.
- Zapis wyniku: Brak operacji zapisu wyniku, ponieważ wynik został już zaktualizowany w liczniku rozkazów (PC).
JNZERO R1, ADDR:
- Pobieranie: Procesor pobiera rozkaz JZERO z pamięci.
- Dekodowanie: Dekodowanie rozkazu JZERO i identyfikacja rejestru R1 oraz adresu ADDR.
- Wykonanie: Sprawdzenie, czy wartość w rejestrze R1 wynosi 0.
- Dostęp do pamięci: Brak dostępu do pamięci.
- Zapis wyniku: Jeśli wartość w rejestrze R1 wynosi 0, aktualizacja licznika rozkazów (PC) o wartość ADDR. W przeciwnym razie PC zostaje zwiększone do kolejnego rozkazu.
Dla każdego z tych rozkazów analiza cyklu rozkazowego obejmuje omówienie poszczególnych faz (pobieranie, dekodowanie, wykonanie, dostęp do pamięci i zapis wyniku) oraz zrozumienie, jak procesor wykonuje te rozkazy sekwencyjnie. W przypadku procesorów z potokiem rozkazowym, kolejne rozkazy mogą być przetwarzane równocześnie w różnych fazach, zwiększając przepustowość procesora i efektywnie przyspieszając wykonywanie programu.
Prosty symulator cyklu rozkazowego
Lab_06:
Ćwiczenie:
- Proszę uruchomić pierwszy program: Lab_06_a
- Proszę opisać kolejne cykle rozkazowe, jakie wykonuje procesor
|
|
- Proszę uruchomić drugi program: Lab_06_b
|
|
- Proszę opisać cykle rozkazowe dla ostatniej instrukcji.
- Jakie jest działa drugi program? Proszę opisać.
Analiza potoku rozkazowego:
Potok rozkazowego i jego cele (zwiększenie przepustowości, równoległe wykonanie rozkazów).
Potok rozkazowy (ang. instruction pipeline) to technika wykorzystywana w mikroarchitekturze procesorów, mająca na celu zwiększenie przepustowości i wydajności przez równoczesne wykonywanie kilku rozkazów w różnych etapach ich przetwarzania. Potok rozkazowy dzieli cykl rozkazowy na kilka etapów, z których każdy może być przetwarzany równocześnie przez różne jednostki wykonawcze procesora. Dzięki temu, zamiast czekać na zakończenie jednego rozkazu, procesor może przetwarzać kolejne rozkazy, zwiększając przepustowość i efektywnie przyspieszając wykonywanie programu.
Cele potoku rozkazowego:
Zwiększenie przepustowości: Potok rozkazowy pozwala na równoczesne przetwarzanie kilku rozkazów w różnych etapach cyklu rozkazowego, co zwiększa przepustowość procesora i pozwala na szybsze wykonanie programu.
Równoległe wykonanie rozkazów: Potok rozkazowy pozwala na równoczesne wykonywanie różnych operacji w różnych jednostkach wykonawczych procesora, co oznacza, że procesor może jednocześnie wykonywać operacje na różnych danych, zwiększając wydajność obliczeń.
Przykład potoku rozkazowego
Załóżmy, że mamy prosty procesor z czterema etapami cyklu rozkazowego: pobieranie (IF - Instruction Fetch), dekodowanie (ID - Instruction Decode), wykonanie (EX - Execute) i zapis wyniku (WB - Write Back). Potok rozkazowy dla tego procesora będzie miał cztery etapy, które można wykonać równocześnie dla różnych rozkazów.
|
|
Jak widać na powyższym przykładzie, potok rozkazowy pozwala na równoczesne przetwarzanie kilku rozkazów w różnych etapach cyklu rozkazowego. W momencie, gdy pierwszy rozkaz jest w fazie wykonania (EX), drugi rozkaz jest w fazie dekodowania (ID), a trzeci rozkaz jest pobierany (IF). Dzięki temu procesor może jednocześnie pracować nad różnymi rozkazami, zwiększając swoją przepustowość i wydajność.
Jednakże, potok rozkazowy wprowadza również pewne wyzwania, takie jak zarządzanie hazardami (ang. hazards). Hazardy to sytuacje, w których wynik jednego rozkazu jest potrzebny przez następny rozkaz, a potok rozkazowy musi zarządzać tymi zależnościami, aby uniknąć konflikty lub zależności.
Istnieją trzy główne rodzaje hazardów:
Hazardy strukturalne: Występują, gdy różne rozkazy próbują jednocześnie korzystać z tego samego zasobu procesora, takiego jak jednostka wykonawcza, pamięć czy rejestry. Hazardy strukturalne mogą prowadzić do opóźnień lub konieczności oczekiwania przez niektóre rozkazy na dostęp do zasobów.
Sposoby minimalizowania hazardów strukturalnych:
- Wprowadzenie dodatkowych zasobów: Zwiększenie liczby jednostek wykonawczych, pamięci czy rejestrów może zmniejszyć ryzyko wystąpienia konfliktów.
- Replikacja zasobów: Dodatkowe zasoby pozwalają na równoczesne korzystanie z nich przez różne rozkazy.
- Wykorzystanie buforów i kolejkowania: Buforowanie i kolejkowanie operacji może pomóc w zarządzaniu dostępem do zasobów i zmniejszyć opóźnienia.
Hazardy danych: Występują, gdy jeden rozkaz jest zależny od wyniku innego rozkazu, który jeszcze nie został zakończony. Hazardy danych mogą prowadzić do nieprawidłowych wyników, jeśli nie zostaną odpowiednio obsłużone.
Sposoby minimalizowania hazardów danych:
- Przesunięcie potoku (stalling): Wstrzymywanie wykonywania zależnego rozkazu aż do momentu, gdy wynik potrzebny dla tego rozkazu będzie dostępny.
- Przekształcanie potoku (forwarding): Przekazanie wyniku zależnego rozkazu bezpośrednio do kolejnego rozkazu, bez konieczności zapisywania wyniku w rejestrze.
- Przeplanowanie rozkazów: Kompilator lub procesor może próbować zmienić kolejność rozkazów, aby zmniejszyć zależności i poprawić wydajność potoku rozkazowego.
Hazardy kontroli: Występują, gdy kolejność wykonywania rozkazów ulega zmianie z powodu instrukcji warunkowych, takich jak skoki czy instrukcje warunkowe. Hazardy kontroli mogą prowadzić do niepotrzebnego wypełnienia potoku rozkazów i straty wydajności.
Ref:
Sposoby minimalizowania hazardów kontroli:
- Przewidywanie rozgałęzień (branch prediction): Procesor próbuje przewidywać, czy dane rozgałęzienie zostanie wykonane, i wstępnie ładuje odpowiednie rozkazy do potoku rozkazowego.
- Opóźnianie rozgałęzienia (branch delay slot): Procesor pozwala na wykonanie jednego lub kilku rozkazów po instrukcji rozgałęzienia, zanim rzeczywista zmiana przepływu sterowania zostanie wprowadzona. Kompilator może umieścić niezależne rozkazy w tych opóźnionych miejscach, aby zmniejszyć straty wydajności spowodowane hazardami kontroli.
- Wykonanie spekulatywne (speculative execution): Procesor wykonuje obie ścieżki po rozgałęzieniu warunkowym, jednocześnie śledząc poprawność wyników. Gdy warunek rozgałęzienia zostanie ostatecznie wyznaczony, procesor odrzuca wyniki niewłaściwej ścieżki i kontynuuje wykonanie poprawnej ścieżki.
Ref: Interesująca podatność związana z SE (side-channel) - Spectre - wiki Spectre Attacks: Exploiting Speculative Execution Meltdown Security Vulnerability Meltdown Proof-of-Concept
Podatność Spectre dotyczy złośliwego wykorzystania spekulatywnego wykonania instrukcji przez procesor w celu uzyskania dostępu do poufnych informacji.
Przetwarzanie wielowątkowe (multithreading): Procesor może przełączać się między różnymi wątkami wykonawczymi, gdy napotyka hazardy kontroli. Pozwala to na utrzymanie potoku rozkazowego aktywnym i wydajnym, nawet gdy jeden wątek oczekuje na wynik rozgałęzienia.
W praktyce zaawansowane mikroarchitektury procesorów stosują różne techniki, aby zarządzać hazardami potoku rozkazowego i zwiększać wydajność procesora. Optymalizacja wykorzystania potoku rozkazowego, zarządzanie hazardami oraz przyspieszenie wykonywania programów są kluczowe dla osiągnięcia wysokiej wydajności w nowoczesnych procesorach.
Przykładowe sposoby minimalizacji hazardów
- Hazard strukturalny: Załóżmy, że mamy procesor z jednym modułem ALU (Arithmetic Logic Unit) i poniższymi rozkazami do wykonania:
|
|
Wszystkie trzy rozkazy wymagają dostępu do ALU. W celu zapobieżenia hazardom strukturalnym, procesor może:
Wprowadzić dodatkowe moduły ALU, aby umożliwić równoczesne wykonywanie różnych operacji.
Zastosować buforowanie i kolejkowanie operacji, aby zarządzać dostępem do ALU i zmniejszyć opóźnienia.
Hazard danych: Załóżmy, że mamy następujący ciąg rozkazów:
|
|
Wynik rozkazu 1 (ADD) jest używany jako wejście dla rozkazu 2 (SUB), a wynik rozkazu 2 jest używany jako wejście dla rozkazu 3 (MUL). W celu zarządzania hazardami danych, procesor może:
Wstrzymać wykonywanie rozkazu 2 (stalling), aż do momentu, gdy wynik rozkazu 1 będzie dostępny.
Zastosować przekształcanie potoku (forwarding), przekazując wynik rozkazu 1 bezpośrednio do rozkazu 2, bez konieczności zapisywania wyniku w rejestrze.
Hazard kontroli: Załóżmy, że mamy następujący ciąg rozkazów z instrukcją warunkowego skoku:
|
|
W przypadku hazardu kontroli spowodowanego przez instrukcję warunkowego skoku (JNE), procesor może:
- Zastosować przewidywanie rozgałęzień (branch prediction), próbując przewidzieć, czy skok zostanie wykonany, i wstępnie ładując odpowiednie rozkazy do potoku rozkazowego.
- Wykorzystać opóźnienie rozgałęzienia (branch delay slot), umieszczając jeden lub więcej niezależnych rozkazów po instrukcji skoku, które zostaną wykonane przed rzeczywistym skokiem
W praktyce, zaawansowane mikroarchitektury procesorów stosują różne techniki, aby zarządzać hazardami potoku rozkazowego i zwiększać wydajność procesora, takie jak przewidywanie rozgałęzień, optymalizacja wykonywania potoku czy wykorzystanie wielowątkowości.
Zadanie z symulacją: symulator potoku rozkazowego dla prostego procesora, uwzględniając hazardy.
Hazard danych
Prosty procesor z potokiem rozkazowym o 4 etapach (IF - pobieranie rozkazu, ID - dekodowanie, EX - wykonanie, WB - zapis wyniku)
Lab_07
Powyższy symulator wykonuje sekwencję rozkazów (program) na prostym procesorze. Wprowadza wstrzymanie (stalling) potoku, gdy wykryje hazard danych, co pozwala na zaktualizowanie wartości rejestru przed przekazaniem go do kolejnego rozkazu. Symulator wyświetla etap potoku dla każdego rozkazu oraz numer cyklu. Po zakończeniu symulacji wyświetlane są końcowe wartości rejestrów procesora.
Hazard kontroli
Symulator prostego procesora z uwzględnieniem hazardu kontroli (branch hazard). Procesor ten ma prosty potok rozkazów o pięciu etapach: pobieranie rozkazu (IF), dekodowanie (ID), wykonanie (EX), dostęp do pamięci (MEM) i zapis wyniku (WB).
Lab_07 + branch predictor
Symulator wykonuje sekwencję rozkazów na prostym procesorze z potokiem rozkazowym. Wprowadza wstrzymanie (stalling) potoku, gdy wykryje hazard kontroli (branch hazard), co pozwala na zaktualizowanie wartości PC przed pobraniem kolejnego rozkazu. Symulator wyświetla etap potoku dla każdego rozkazu oraz numer cyklu. Po zakończeniu symulacji wyświetlane są końcowe wartości rejestrów procesora.
Instruction reordering
Uwaga: Warto zauważyć, że efekty takie jak reordering instrukcji są zależne od architektury procesora, a w przypadku niektórych implementacji może być trudno uzyskać te efekty w sposób powtarzalny.
Oto prosty program w C++, który ilustruje reordering instrukcji na procesorze x86:
Lab_10
|
|
W tym programie używamy dwóch zmiennych atomowych x
i y
, które są inicjowane wartością 0. Wątek t1
zapisuje wartość 1 do x
i y
, podczas gdy wątek t2
wczytuje wartości y
i x
. Używamy relaksowanej kolejności pamięci (std::memory_order_relaxed
), która pozwala na reordering instrukcji.
Jeśli procesor przestawi kolejność instrukcji w taki sposób, że odczyt y
zostanie wykonany przed odczytem x
, program zlicza taką sytuację jako reordering.
Po wielokrotnym uruchomieniu programu liczba reorderingów może być różna. Procesor x86 ma silną kolejność pamięci, więc reordering instrukcji może być rzadko spotykany. Przetestuj ten program na różnych konfiguracjach sprzętowych, aby zobaczyć, jak efekty reorderingu instrukcji różnią się w zależności od sprzętu.
Ref:
Porównanie jedno- i wielordzeniowych procesorów:
Różnice
Jednordzeniowe i wielordzeniowe procesory to dwa różne typy układów scalonych stosowanych w komputerach. Definicje, zalety i wady każdego z nich.
Jednordzeniowy procesor (single-core processor)
Definicja: Procesor jednordzeniowy to układ scalony z jednym rdzeniem CPU, który może wykonywać tylko jedną instrukcję na raz.
Zalety:
- Mniejsza złożoność układu: Jednordzeniowe procesory są mniej skomplikowane w porównaniu do wielordzeniowych, co może prowadzić do mniejszego zużycia energii i niższych kosztów produkcji.
- Łatwiejsza implementacja: Programowanie dla jednordzeniowych procesorów jest prostsze, ponieważ nie ma konieczności zarządzania równoczesnością.
Wady:
- Niska wydajność: W przypadku zadań wielowątkowych, jednordzeniowe procesory mają niższą wydajność niż ich wielordzeniowe odpowiedniki, ponieważ mogą wykonywać tylko jedno zadanie na raz.
- Szybsze osiąganie limitów wydajności: Ponieważ jednordzeniowe procesory mają tylko jeden rdzeń, mogą szybciej osiągnąć swoje limity wydajności, co prowadzi do niższej wydajności w porównaniu do wielordzeniowych procesorów.
Przykład w C++:
Lab_11
|
|
Wielordzeniowy procesor (multi-core processor)
Definicja: Wielordzeniowy procesor to układ scalony z dwoma lub więcej rdzeniami CPU, które mogą równocześnie wykonywać wiele instrukcji.
Zalety:
- Wyższa wydajność: Wielordzeniowe procesory są bardziej wydajne dla zadań wielowątkowych, ponieważ mogą równocześnie wykonywać wiele zadań.
- Skalowalność: Wielordzeniowe procesory mogą być łatwiej skalowane, co prowadzi do wyższej wydajności w porównaniu do jednordzeniowych procesorów.
- Energooszczędność: W przypadku zastosowań o małym obciążeniu, wielordzeniowe procesory mogą oszczędzać energię poprzez wyłączanie nieużywanych rdzeni.
Wady:
- Wyższa złożoność układu: Wielordzeniowe procesory są bardziej skomplikowane niż jednordzeniowe, co może prowadzić do wyższego zużycia energii i wyższych kosztów produkcji.
- Trudniejsza implementacja: Programowanie dla wielordzeniowych procesorów może być trudniejsze, ponieważ wymaga zarządzania równoczesnością i synchronizacją między wątkami.
Przykład w C++:
Lab_12
|
|
W powyższym przykładzie używamy biblioteki <thread>
w języku C++ do tworzenia dwóch wątków, które wykonują funkcje add()
i sub()
równocześnie na wielordzeniowym procesorze. Dzięki temu można równocześnie obliczyć sumę i różnicę dwóch liczb, co pokazuje zaletę wielordzeniowego procesora w porównaniu do jednordzeniowego procesora.
Jednordzeniowe procesory są prostsze, ale mniej wydajne, szczególnie w przypadku zadań wielowątkowych. Wielordzeniowe procesory oferują wyższą wydajność dla zadań wielowątkowych, ale wymagają bardziej zaawansowanego zarządzania równoczesnością i synchronizacją w kodzie programu. Wybór pomiędzy nimi zależy od konkretnych wymagań i ograniczeń projektu.
UWAGA: Problem wyścigu (race condition)
Ref:
Hyper-threading
Hyper-threading to technologia opracowana przez firmę Intel, która pozwala na wykonywanie dwóch lub więcej wątków na jednym rdzeniu procesora, zwiększając wydajność procesora w zadaniach wielowątkowych. Technologia ta polega na tym, że jeden rdzeń procesora posiada dwa lub więcej zestawów rejestrów, które są niezależne dla każdego wątku. Dzięki temu rdzeń może wykonywać wiele wątków równocześnie, wykorzystując jednocześnie swoje zasoby sprzętowe.
W uproszczonym procesorze z technologią Hyper-threading można uwzględnić następujące elementy:
Rdzeń procesora: Jednostka wykonawcza (ALU), jednostka sterująca oraz pamięć podręczna (cache). Rdzeń procesora wykonuje instrukcje z kolejnych wątków.
Zestawy rejestrów: Każdy wątek posiada swój własny zestaw rejestrów, takich jak licznik programu (PC) oraz rejestry ogólnego przeznaczenia. W przypadku procesora z technologią Hyper-threading, rdzeń posiada dwa lub więcej zestawów rejestrów, które są niezależne dla każdego wątku.
Mechanizm przemiennego wykonania instrukcji (Instruction interleaving): Procesor z technologią Hyper-threading wykonuje instrukcje z różnych wątków w sposób przemienny. Gdy jeden wątek jest zablokowany, np. na wynik operacji I/O, procesor może kontynuować wykonywanie instrukcji z innego wątku, dzięki czemu zasoby sprzętowe rdzenia są lepiej wykorzystane.
Mechanizm równoczesnego dekodowania (Simultaneous multithreading, SMT): Procesor z technologią Hyper-threading może równocześnie dekodować i wykonywać instrukcje z różnych wątków, co przyczynia się do zwiększenia wydajności procesora w zadaniach wielowątkowych.
Mechanizm zarządzania wątkami: Procesor z technologią Hyper-threading posiada mechanizm zarządzania wątkami, który kontroluje ich wykonanie, przydzielanie zasobów oraz obsługę zdarzeń, takich jak przerwania czy wyjątki.
Podsumowując, technologia Hyper-threading pozwala na efektywne wykorzystanie zasobów sprzętowych rdzenia procesora, zwiększając jego wydajność w zadaniach wielowątkowych. W procesorze z technologią Hyper-threading znajduje się rdzeń, który posiada dwa lub więcej niezależnych zestawów rejestrów, umożliwiających równoczesne wykonywanie instrukcji z różnych wątków. Procesor z Hyper-threading posiada także mechanizmy takie jak Instruction interleaving, Simultaneous multithreading (SMT) oraz zarządzanie wątkami, które kontrolują wykonanie, przydzielanie zasobów oraz obsługę zdarzeń.
Oto kilka zalet i wad technologii Hyper-threading:
Zalety:
- Zwiększona wydajność: Technologia Hyper-threading pozwala na lepsze wykorzystanie zasobów rdzenia procesora, co prowadzi do zwiększenia wydajności w zadaniach wielowątkowych.
- Lepsza przepustowość: Dzięki możliwości równoczesnego wykonywania instrukcji z różnych wątków, procesor z technologią Hyper-threading może osiągnąć wyższą przepustowość w porównaniu do procesora bez tej technologii.
- Efektywność energetyczna: Procesory z technologią Hyper-threading mogą wykorzystywać swoje zasoby sprzętowe efektywniej, co może prowadzić do oszczędności energii.
Wady:
- Zwiększona złożoność: Technologia Hyper-threading wprowadza dodatkową złożoność w architekturze procesora, co może prowadzić do wyższych kosztów produkcji i trudności w debugowaniu.
- Potencjalne problemy z synchronizacją: W przypadku równoczesnego wykonywania wielu wątków na jednym rdzeniu, mogą wystąpić problemy z synchronizacją oraz konkurencją o zasoby sprzętowe.
- Wydajność w zależności od rodzaju zadania: Technologia Hyper-threading przynosi korzyści głównie w zadaniach wielowątkowych, a jej wpływ na wydajność w zadaniach jednowątkowych może być ograniczony.
Warto zauważyć, że technologia Hyper-threading nie jest jedyną dostępną techniką SMT (Simultaneous multithreading) - inne firmy, takie jak AMD, opracowały własne technologie SMT, takie jak SMT w procesorach AMD Ryzen, które oferują podobne możliwości wykonywania wielu wątków na jednym rdzeniu procesora. W zastosowaniach praktycznych, wybór procesora z technologią Hyper-threading czy innymi technologiami SMT zależy od potrzeb konkretnego projektu oraz rodzaju zadań, które mają być realizowane.
W poniższym przykładzie przedstawiam uproszczony symulator procesora z technologią Hyper-threading. W tym przypadku zakładamy, że procesor ma jeden rdzeń i może jednocześnie wykonywać dwie operacje dodawania.
Diagram procesora z technologią Hyper-threading
|
|
Symulacja działania procesora z HT
Lab_13
|
|
W powyższym kodzie, klasa Processor
symuluje prosty procesor z technologią Hyper-threading. Ma jeden rdzeń, który może wykonywać operacje dodawania. Rejestry są podzielone na dwa zestawy, po jednym dla każdego wątku. W funkcji execute
, procesor dekoduje i wykonuje operacje na podstawie identyfikatora wątku.
W funkcji main
, tworzymy dwa wątki, które wykonują operacje dodawania równocześnie. Dzięki temu symulator ilustruje, jak procesor z technologią Hyper-threading może wykonywać wiele wątków na jednym rdzeniu procesora.
Scenariusz problemu i porównanie rozwiązania na procesorze jedno- i wielordzeniowym.
Komunikacja z pamięcią - odczyt
Ref:
Nomenklatura
Level 1 data cache - 48 kB, 12 way, 64 sets, 64 B line size, latency 5, per core
48 kB: The size of the L1 data cache is 48 kilobytes. This is the total amount of data that can be stored in the cache. Smaller caches are faster but can store less data, while larger caches can store more data but may be slower.
12 way: This refers to the cache’s associativity, which determines how cache lines can be mapped to cache sets. In a 12-way associative cache, each cache set can hold 12 different cache lines. Higher associativity can reduce the likelihood of cache conflicts (when multiple cache lines map to the same cache set), but may increase access latency.
64 sets: The cache is divided into 64 sets. Each set can store multiple cache lines, as determined by the cache’s associativity (in this case, 12-way).
64 B line size: The size of each cache line is 64 bytes. When data is fetched from the main memory, it is brought into the cache in blocks called cache lines. A larger cache line size can improve spatial locality (the likelihood that nearby memory locations will be accessed), but may also result in more unused data being fetched.
Latency 5: The cache latency is 5 clock cycles. This is the time it takes for the processor to access data stored in the L1 cache.
Per core: This specification is for an L1 data cache that is dedicated to each core in a multicore processor. Each core has its own L1 data cache, which can help improve performance by reducing contention for cache resources between cores.
Lab_15
W poniższym programie przetestujemy dwa różne sposoby dostępu do pamięci: sekwencyjny i losowy. Porównamy czas wykonania obu podejść, które mogą wpływać na wykorzystanie pamięci cache.
Ćwiczenie: Proszę uruchomić program, opisać w sprawozdaniu jego wynik.
|
|
Należy modyfikować ilość iteracji: 50 75 100, uruchamiając dla dostępu sekwencyjnego i losowego.
Ref:
W symulatorze mamy prostą pamięć cache, która przechowuje wartości z pamięci. Procesor korzysta z cache podczas sekwencyjnego i losowego dostępu do pamięci. Symulator mierzy czas dostępu do pamięci oraz liczbę trafień cache dla obu podejść.
Uruchomienie tego symulatora pokaże, że sekwencyjny dostęp do pamięci jest szybszy niż losowy, ponieważ lepiej wykorzystuje pamięć cache. Ponadto, liczba trafień cache jest większa dla sekwencyjnego dostępu do pamięci w porównaniu z losowym dostępem.
Zmiana parametrów symulatora (rozmiar pamięci, rozmiar cache, liczba iteracji) w celu zrozumienia, jak wpływają na wyniki.
Symulator pamięci podręcznej (cache) typu direct-mapped
Lab_17
Proszę uruchomić program, opisać w sprawozdaniu jego wynik. Należy kilka razy zmodyfikować parametry:
-c 1024 -b 32 -n 10000 -m 2048
-c 1024 -b 64 -n 10000 -m 2048
-c 2048 -b 32 -n 10000 -m 4096
-c 2048 -b 64 -n 10000 -m 4096
- inna konfiguracja wg uznania z taką samą liczbą iteracji
- inna konfiguracja wg uznania z taką samą liczbą iteracji
|
|
Lab_18 Prefetch
Proszę uruchomić program, opisać w sprawozdaniu jego wynik. Należy kilka razy zmodyfikować parametry:
-c 1024 -b 32 -n 10000 -m 2048 -d 1
-c 1024 -b 64 -n 10000 -m 2048 -d 2
-c 2048 -b 32 -n 10000 -m 4096 -d 4
-c 2048 -b 64 -n 10000 -m 4096 -d 8
- inna konfiguracja wg uznania z taką samą liczbą iteracji
- inna konfiguracja wg uznania z taką samą liczbą iteracji
$lab_18.exe -c 1024 -b 32 -n 10000 -m 1024 -d 8
-c, –cache size -b, –block size -n, –iteration -m, –memory size -d, –prefetch distance
|
|