Więcej o firmie ELESOFTROM

Firma ELESOFTROM specjalizuje się w wykonywaniu i naprawianiu oprogramowania dla sterowników mikroprocesorowych.

Posiadamy ponad 10-letnie doświadczenie w tej dziedzinie.

W realizowanych projektach stawiamy na niezawodność i wydajność programów.

Naprawa oprogramowania

Oprogramowanie na zamówienie


Strona główna

Pełna oferta

Doświadczenie

O firmie

Kontakt


DioneOS - RTOS dla urządzeń embedded

Firma ELESOFTROM opracowała system RTOS dla procesorów ARM Cortex-M3 oraz msp430.

System jest zoptymalizowany pod względem wydajności, dzięki czemu uzyskano krótki czas przełączania pomiędzy wątkami.
System dostarcza elementy (np. semafory, mutexy, timery, kolejki itp.) niezbędne do tworzenia wielowątkowego firmware'u.

Wersja dla Cortexa została całkowicie przetestowana przy pomocy środowiska do automatycznego testowania.

Przeczytaj więcej:

Strona o systemie DioneOS

Niezawodność

Wydajność

Dokumentacja DioneOSa

Prezentacje n.t. DioneOS


^ Blog index    << VideoCoreIV 3D, IDE i narzędzia    >> KONIK gra logiczna

Programowanie QPU

2018-01-04   Piotr Romaniuk, Ph.D.

Spis treści

Wstęp
Zmienne
Wywołanie podprogramu
Kod relokowalny
Synchronizacja rdzeni QPU
Potok instrukcji procesora
Rozróżnianie elementów wektora
Optymalizacja kodu
Linki

Wstęp

Broadcom VideoCoreIV-3D jest procesorem graficznym (GPU), który zawiera wiele rdzeni, Quad Processing Units (QPUs). Programowanie tych rdzeni jest zadaniem specyficznym, ponieważ wymaga radzenia sobie z równoległością wynikającą z wielu rdzeni jak również ze sprzętem typu SIMD. Architektura pojedynczego QPU jest oparta na dwudrożnym ALU, które przetwarza współbieżnie dwie operacje na wektorach wartości zmiennoprzecinkowych.
Aby poznać jak programować QPU, polecane są nastepujące źródła:

  1. Dokumentacja producenta Broadcom VideoCoreIV-3D,
  2. Przykładowe programy (najbardziej wartościowy jest hello_fft, razem z jego opisem)
  3. Dodatek do dokumentacji producenta oraz towarzyszące mu strony autorstwa Marcela Mullera.

Przeglądanie i analiza przykładu hello_fft jest bardzo kształcąca, są tam użyte różne techniki i specyficzne dla QPU tricki.
Spróbujmy wyjaśnić je poniżej i zobaczmy jak realizują na QPU one znane pojęcia programowania.

Zmienne

Każdy rdzeń QPU posiada dwa duże zestawy rejestrów (64 rejestry). Te zasoby mogą być rozumiane jako lokalna pamięć per QPU, która może być uzyta do alokacji zmiennych. Zmienne są dostępne z dowolnego miejsca programu pojedynczego QPU, więc przypominają zmienne globalne w języku wyższego poziomu.
Dobrą praktyką jest utworzenie osobnego pliku źródłowego zawierającego przypisanie nazw symbolicznych zmiennych do poszczególnych rejestrów. Dla czytelności programu warto nadawać znaczące nazwy zmiennym, np.in_data, qpu_id, counter zamiast a, b, x. W programie można używać tych nazw jak zmiennych. To przypisanie jest wpisane na stałe w program i niezmienne w całym programie. Jedynie kilka rejestrów może być używanych do różnych celów, ale tylko wtedy jeśli nie muszą one przechowywać wartości przed cały czas działania programu.
Podczas gdy numer rejestru w zestawie może być swobodnie wybrany, wybranie zestawu A lub B jest już określone przez program, miejsce gdzie taka zmienna jest użyta (ściśle rzecz biorąc instrukcję QPU). Należy pamiętać, że te dwa zestawy nie są symetryczne:

  • tylko zestaw A ma moduły realizujące pakowania/rozpakowania,
  • tylko rejestry z zestawu A mogą być użyte jako adres docelowy skoku,
  • rejestry z zestawu B nie mogą być łączone z małymi stałymi wartościami w jednej instrukcji

Jest jeszcze więcej ograniczeń, które wpływają na przydzielanie zmiennych:

  • jedna instrukcja nie może używać więcej niż jednego rejestru z każdego zestawu
  • wyniki współbieżnych części ALU muszą być zapisane do innych zestawów.

Poniżej są przykłady, niewłasciwego kodu, który łamie te zasady:

        add ra0, ra1, ra2 ;                # źle, argumenty ra1 i ra2 są z tego samego zestawu 
        add ra0, ra1, rb1 ; mov rb0, ra3   # źle, argumenty ra1 i ra3 sa z tego samego zestawu
        add ra0, rb0, 2   ; mov ra2, 100   # źle, wyniki z dwu ścieżek ALU są w tym samym zestawie (ra0, ra2)
UWAGA: średnik rozdziela części instrukcji dla dwóch torów ALU

Nie należy się tym zbytnio martwić, takie błędy zostaną zasygnalizowane podczas kompilacji. Aby to poprawić, trzeba jedynie zmienić odpowiednio przypisanie do rejestrów albo użyć akumulatorów razem z osobnym odczytem/zapisem do zestawu rejestrów. Jest też ogólna zasada: należy często używać akumulatorów do lokalnych obliczeń, a wyniki przechowywać w zestawach rejestrów.

Wywołanie podprogramów

Architektura QPU nie ma stosu, radzi sobie jednak wywoływaniem podprogramów (funkcji). Rozwiązanie jest podobne do pomysłu rejestru LR (link register) w procesorach ARM, jednakże jako taki rejestr może być wybrany dowolny z zestawu rejestrów.

        brr ra0, r:subroutine          # ra0 jako link register
        nop
        nop
        nop
                                #--- tu wskazuje adres zapisany w ra0
        ...
        
:subroutine                     # początek podprogramu 
        
        ...
        bra -, ra0              # powrót z podprogramu
        nop
        nop
        nop
UWAGA: trzy dodatkowe instrukcje NOP zostały dodane po każdym skoku ponieważ QPU posiada potok instrukcji, który nie jest opróżniany gdy skok jest wykonywany.

Możliwe jest użycie zagnieżdzonych wywołań ale drzewo wywołań musi być zaplanowane i zapisane na stałe. Różne rejestry jako LR muszą być użyte na poszczególnych poziomach zagnieżdzenia.

Kod relokowalny

Programista nie ma kontroli nad tym w jakie miejsce pamięci będzie załadowany kod QPU. Aplikacje jedynie zgłasza żądanie uzyskania pamięci, która jest alokowana ze specjalnego regionu przez driver a jej adres jest zwracany. Z tego powodu pojawia się wymóg relokowalnego kodu. Jeśli gdzieś w kodzie jest używany adres bezwględny są dwie możliwości aby go ustawić właściwie: przez zmianę w skompilowanym kodzie binarnym, tuż przed załadowaniem do zaalokowanej pamięci albo przez wyznaczenie tych adresów w czasie działania w programie QPU. Ta druga metoda jest ciekawsza, gdyż nie wymaga modyfikowania kodu wykonywalnego:

.set r_proc1, ra0          # r_proc1 - symb. nazwa rej. do przechowywania adresu procedury proc1
.set r_link,  ra1          # r_link - symboliczna nazwa rejestru przechowyjącego adres powrotu

        brr r_proc1, r:1f  # ten skok zapamięta bezwzględny adres proc1 w rejestrze r_proc1
        nop
        nop
        nop
:proc1                                
        # tutaj kod procedury proc1 
:1      
        ...
        
        bra r_link, r_proc1     # wywołanie procedury proc1 przez adres bezwzględny

Kod przeskakuje przez podprogram proc1 i ładuje adres tej procedury do rejestru. Należy zwrócić uwagę, że miejsce do którego skacze nie jest istotne. Jeśli jest więcej procedur, które mają być wywoływane poprzez bezwględny adres, to wszystkie adresy należy wyznaczyć przedstawioną metodą.
Dlaczego skok z adresem bezwzględnym jest istotny? Jest tak, ponieważ może być on traktowany jako wskaźnik do funkcji i może służyć do rozróżniania kodu dla poszczególnych QPU. Jest to metoda aby stworzyć uogólniony kod z fragmentami specyficznymi dla rdzeni QPU. Na podstawie numeru QPU (przekazanego przez tablicę uniforms) łatwo jest wybrać różne wersje procedury.
Ale dlaczego nie przygotować osobnych programów dla różnych rdzeni QPU? Należy pamiętać, że QPU posiada pamięć cache instrukcji o względnie niewielkim rozmiarze - tylko 4KB, a jest ona wspólna dla 4 rdzeni QPU znajdujących się na jednym slice'ie. Jeśli programy dla tych rdzeni nie zmieszczą się w pamięci cache, a QPU zaczną współzawodniczyć, efektywność obliczeń zostanie zdegradowana. Dlatego też jeden program dla wszystkich QPU jest lepszy.

Synchronizacja rdzeni QPU

Rdzenie QPU z jednego slice'a posiadają wspólne moduły, takie jak: Texture and Memory Lookup Unit - TMU, Special Function Unit - SFU oraz pamięć Vertex Pipe Memory (VPM).
TMU jest przygotowane do współpracy z wieloma rdzeniami, ma nawet osobne kolejki dla każdego QPU.
Dostęp do SFU musi być nadzorowany, zwraca wynik w trzeciej instrukcji po zapisaniu argumentu, i nie może być modyfikowany w tym czasie.
VPM jest pamięcią, gdzie zbierane są wyniki obliczeń ze wszystkich QPU. Dostęp do QPU jest sekwencyjny i musi być konfigurowany. W celu uzyskania większej elastyczności, często każdy QPU konfiguruje VPM tuż przed zapisaniem danych. To jest sytuacja gdzie dostęp do QPU powinien być synchronizowany, tak aby QPU nie przeszkadzały sobie nawzajem. Drugi moment kiedy synchronizacja jest niezbędna, to zapis całego wyniku obliczeń do pamięci współdzielonej z CPU. Może to być wykonane tylko przez jeden rdzeń i to dopiero wtedy gdy wszystkie QPU zapiszą swoje wyniki do VPM. Jeden QPU może być wyróżniony i odpowiedzialny za to zadanie.
Są dwa rodzaje obiektów do synchronizacji:

  • 1 mutex,
  • 16 semaforów (liczących do 16).

Dostęp do VPM może być chroniony przez mutex, podczas gdy końcowy zapis może wykorzystywać semafor liczący (zobacz przykład DWT [5]). Można też uzyskać lepszą kontrolę nad synchronizacją, gdzie uzyska się wyzwalanie rdzeni w określonej kolejności (zobacz hello_fft [4]).

Potok instrukcji procesora

QPU posiada czteroelementowy potok instrukcji. Gdy wykonywany jest skok, jego wpływ jest widoczny, ponieważ potok nie jest opróżniany:

        brr -, r_link      # powrót z podprogramu
        srel -, 7          # ta instrukcja i dwie następne będą wykonane zanim nastąpi powrót
        nop
        nop

To wyglada trochę dziwnie, ale oznacza, że trzy instrukcje po instrukcji skoku (branch) są wykonywane jakby były zapisane przed. Dzieje się tak ponieważ te instrukcje pozostają w potoku podczas wykonywania instrukcji skoku.
Istnienie potoku jest też widoczne w przypadku zestawu rejestrów. Nie można mieć do nich dostępu w następnej instrukcji po ich zapisaniu, a to dlatego, że ich wartość nie jest jeszcze gotowa. Podobnie przy wykonywaniu rotacji wektora elementów akumulator nie może być zapisany w poprzedniej instrukcji.

Rozróżnianie elementów wektora

QPU operuje na wektorach elementów. Mogą to być liczby zmiennoprzecinkowe albo całkowite (pomińmy na razie pakowanie w rejestrach) Każdy rejestr przechowuje 16-elementowe wektory.
Kiedy dane wejściowe są przetwarzane przez operacje SIMD, przetwarzany jest 16-elementowy blok. Może okazać się konieczne rozróżnienie poszczególnych elementów wektora. Tę funkcje osiąga się przy pomocy rejestru elem_num. Ładowanie wartości z tego specjalnego rejestru powoduje załadowanie kolejnych liczb do elementów:

        mov     r0, elem_num
        
 # wynik w akumulatorze r0
 # r0=[ 0, 1, 2, 3,  4, 5, 6, 7,  8, 9, 10, 11,  12, 13, 14, 15]    
 

Jak selektywnie wykonać operację tylko na częsci wektora, np. na 8 elementach:

        mov       r1, 0.0           # załadowanie 0.0 do wszystkich elementów wektora w akumulatorze r1
        and.setf  -, elem_num, 0x8  # ustawienie flag zgodnie z wynikiem operacji AND z numerem elementu
        mov.ifnz  r1, 1.0           # wybiórcze załadowanie 1.0 tylko do 8 najstarszych elementów
        
 # wynik w akumulatorze r1
 # elem: 0    1    2    3     4    5    6    7     8    9    10  11    12   13   14    15
 # r1=[ 0.0, 0.0, 0.0, 0.0,  0.0, 0.0, 0.0, 0.0,  1.0, 1.0, 1.0, 1.0,  1.0, 1.0, 1.0, 1.0 ]
 

Można zaobserwować, że flagi Z (zero flags) są skasowane tylko dla 8 ostatnich elementów, ponieważ w formacie binarnym ich numer ma 1-kę w tym samym miejscu co maska (8 = 1000b). Przez manipulację maską można wybrać inne elementy, np. mask=1 pozwala rozróżnićdifferentiates elementy parzyste i nieparzyste, a mask=12 wybierze 4 najstarsze elementy.

Optymalizacja kodu

Ogólne zasady optymalizacji dla kodu QPU można przedstawic następująco:

  • należy przygotowac jeden program dla wszystkich QPU (który zmieści się w pamięci cache),
  • należy unikać szeregowych zależności danych,
  • należy unikać zależności pomiędzy rdzeniami QPU,
  • należy unikać konfliktów argumentów i wyników w instrukcjach,
  • należy właściwie rozmieścić zmienne w rejestrach zestawu A i B,
  • należy często używać akumulatorów w obliczeniach,
  • należy używać obu torów ALU gdiekolwiek jest to możliwe,
  • należy efektywnie wykorzystywać SIMD i wektorową organizację danych,
  • należy używać pakowanych typów danych w celu oszczędności rejestrów (dla całkowitych danych),
  • należy wykorzystywać instrukcje po skoku, które zostaną wpisane do potoku

W celu efektywnego użycia dwudrożnego ALU, dobrze jest zacząć od prostego programu, który wykorzytuje tylko jedną ścieżkę ALU (nie jest wspólbieżne). Gdy taki program dobrze się przetestuje i będzie działał właściwie, można spróbować 'podciągać' instrukcje do góry i łaczyć je z poprzednimi. Należy to robić, aż pojawi się zależność od poprzedniego wyniku. Można też próbować zmieniać kolejność wykonywania lub przeplatac instrukcje. Przydatne jest także użycie akumulatorów, które dostarczają wynik już dla nastepnej instrukcji, podczas gdy rejestry z zestawów nie.


Linki

[1] Broadcom VideoCore IV 3D, Architecture Reference Guide - dokumentacja producenta VideoCore
[2] Addendum to the Broadcom VideoCore IV documentation, Marcel Muller
[3] VideoCoreIV 3D, QPU Instructions by Marcel Muller
[4] Hello_fft - przyklad obliczeń FFT na rdzeniach QPU. To jest również w obrazie Raspbiana w /opt/vc/src/hello_pi/hello_fft.
[5] Dyskretna Transformata Falkowa (DWT) - przykład obliczeń w wykorzystaniem rdzeni QPU.
[6] Obliczanie SHA256 z wykorzystaniem rdzeni QPU.