Wykład 4: Układy synchroniczne, przetwarzanie potokowe¶
Data: 10.11.2020
Treść
Przetwarzanie sekwencyjne a przetwarzanie potokowe¶
Załóżmy, że chcemy zbudować układ liczący aproksymację funkcji sin
przez
przybliżenie wielomianami drugiego stopnia:
# Liczby tutaj są stałoprzecinkowe — dla uproszczenia przykładu,
# pomijam takie szczegóły jak przesunięcie "przecinka" w odpowiednie
# miejsce czy sprecyzowanie ich szerokości.
x = input()
# [1] mapujemy x do przedziału [0, tau), mapujemy na [0, 1)
x1 = fract(x * (1 / TAU))
# Dzielimy przedział [0, tau) na 256 równych przedziałów, dla każdego
# wybieramy wielomian drugiego stopnia najlepiej przybliżający sin(x)
# na danym przedziale.
A = [...256 liczb...] # współczynniki przy x**2
B = [...256 liczb...] # współczynniki przy x
C = [...256 liczb...] # współczynniki stałe
# Górne 8 bitów x1 wybiera przedział i współczynniki z powyższej tabeli,
# reszta bitów to parametr wielomianu
r = x1[-8:]
x2 = x1[:-8]
# [2] liczymy A * x + B
t = A[r] * x2 + B[r]
# [3] liczymy A * x**2 + B * x + C
y = t * x2 + C[r]
output(y)
Jak widzimy, nasz układ będzie musiał dla każdego obliczenia wykonać:
Trzy dostępy do pamięci (
A
,B
,C
)Trzy mnożenia (
[1]
,[2]
,[3]
)Dwa dodawania (
[2]
,[3]
)
Projektując taki układ mamy dostępnych wiele sposobów realizacji, które różnią się:
Powierzchnią powstałego układu
Maksymalną częstotliwością układu
Opóźnieniem układu w cyklach (czas od otrzymania
x
do obliczeniay
)Przepustowością układu w obliczeniach na cykl
Prosty układ kombinacyjny¶
Możemy po prostu zapisać powyższy pseudokod jako układ kombinacyjny, który będzie wykonywał całe obliczenie w jednym cyklu zegara. Tak skonstruowany układ będzie miał następujące cechy:
Powierzchnia: 3 mnożniki, 2 układy dodające, 3 porty odczytu
Maksymalna częstotliwość układu: 1 / (3 * opóźnienie mnożnika + 2 * opóźnienie układu dodającego + opóźnienie portu odczytu)
Opóźnienie układu: ≤1 cykl
Przepustowość układu w obliczeniach na cykl: 1
Realizacja układu w ten sposób jest zazwyczaj złym pomysłem:
Poważnie ogranicza to maksymalną częstotliwość zegara w naszym układzie (co wpływa na wydajność całej domeny zegarowej)
Wymagane są asynchroniczne porty odczytu
Maszyna stanów¶
Tworzymy maszynę stanów, na przykład z następującymi stanami:
INPUT
: wczytujemy wejście, liczymyx1, r, x2
READ_A
: wczytujemyA[r]
MUL_1
: liczymyA[r] * x2
, wczytujemyB[r]
ADD_1
: liczymyt
MUL_2
: liczymyt * x2
, wczytujemyC[r]
ADD_2
: liczymyy
, dajemy wynik
Aby zredukować powierzchnię, współdzielimy układ mnożący i dodający oraz port odczytu między stanami. W nMigen mogłoby to wyglądać np. następująco:
mul_in_a = Signal(...)
mul_in_b = Signal(...)
mul_out = Signal(...)
add_in_a = Signal(...)
add_in_b = Signal(...)
add_out = Signal(...)
m.d.sync += [
mul_out.eq(mul_in_a * mul_in_b),
add_out.eq(add_in_a * add_in_b),
]
with m.FSM():
# ...
with m.State('INPUT'):
m.d.comb += [
mul_in_a.eq(x),
mul_in_b.eq(CONST_1_BY_TAU),
]
# ...
with m.State('MUL_1'):
m.d.comb += [
mul_in_a.eq(rd_port.data),
mul_in_b.eq(x2),
]
# ...
with m.State('MUL_2'):
m.d.comb += [
mul_in_a.eq(add_out),
mul_in_b.eq(x2),
]
# ...
Powierzchnia: 1 mnożnik, 1 układ dodający, 1 port odczytu, trochę multiplekserów, trochę przerzutników, logika maszyny stanów
Maksymalna częstotliwość układu: 1 / max(opóźnienie mnożnika, opóźnienie układu dodającego, opóźnienie portu odczytu)
Opóźnienie układu: 6 cykli
Przepustowość układu w obliczeniach na cykl: ⅙
Możemy sobie wyobrazić wiele możliwych wariantów tego rozwiązania:
Zmniejszamy liczbę stanów, na przykład przez wykonywanie mnożenia i dodawania w jednym cyklu (i dodanie dodatkowego portu odczytu)
Wykonujemy odczyt z pamięci asynchronicznie
Zwiększamy liczbę stanów, dodając dodatkowe rejestry przed albo po mnożeniu — synteza może być w stanie „przesunąć” te rejestry gdzieś w środek układu mnożącego, zwiększając jego maksymalną częstotliwość
Spowodują one odpowiednią zmianę kompromisu między powierzchnią, częstotliwością zegara, a przepustowością.
Potok¶
Realizujemy podobny układ do naszego pierwszego pomysłu (układ kombinacyjny), ale tym razem dodajemy rejestry między jego etapami:
# Etap 1 — wejście
m.d.sync += x.eq(... input ...),
# Etap 2 — obliczenie [1]
m.d.sync += x1.eq(x * CONST_1_BY_TAU)
# Etap 3 — wczytanie współczynników z pamięci
# To jest tylko wycięcie bitów — brak opóźnienia.
m.d.comb += r.eq(x1[:-8])
m.d.comb += x2.eq(x1[:-8])
# Podłączamy odpowiednie adresy do (synchronicznych) portów odczytu.
m.d.comb += A_read_port.addr.eq(r)
m.d.comb += B_read_port.addr.eq(r)
m.d.comb += C_read_port.addr.eq(r)
m.d.sync += x2_3.eq(x2)
# Etap 4 — obliczenie [2], mnożenie
m.d.sync += t_m.eq(x2_3 * A_read_port.data)
m.d.sync += x2_4.eq(x2_3)
m.d.sync += B_4.eq(B_read_port.data)
m.d.sync += C_4.eq(C_read_port.data)
# Etap 5 — obliczenie [2], dodawanie
m.d.sync += t.eq(t_m + B_4)
m.d.sync += x2_5.eq(x2_4)
m.d.sync += C_5.eq(C_4)
# Etap 6 — obliczenie [3], mnożenie
m.d.sync += y_m.eq(x2_5 * t)
m.d.sync += C_6.eq(C_5)
# Etap 7 — obliczenie [3], dodawanie
m.d.sync += y.eq(y_m + C_6)
W powyższym kodzie należy zauważyć jawne „przekazywanie” wartości między
kolejnymi etapami potoku — nie możemy np. w etapie 6 użyć po prostu sygnału
x2
, gdyż ten jest już 3 cykle do przodu i zawiera wartość dotyczącą
innego obliczenia. Musimy mieć więc odpowiednią liczbę rejestrów między
każdą produkcją i konsumpcją wartości, która „wyrówna” etapy naszego potoku.
Odpowiada to sygnałom x2_*
w powyższym przykładzie.
Powierzchnia: 3 mnożniki, 2 układy dodające, 3 porty odczytu, dużo przerzutników (choć należy zauważyć, że FPGA Xilinxa mają na takie okazje specjalne rejestry przesuwne, dość efektywne w swojej funkcji)
Maksymalna częstotliwość układu: 1 / max(opóźnienie mnożnika, opóźnienie układu dodającego, opóźnienie portu odczytu)
Opóźnienie układu: 6 cykli
Przepustowość układu w obliczeniach na cykl: 1
Podobnie jak przy maszynie stanów, możemy stworzyć wiele wariantów tego potoku (scalając ze sobą bądź dzieląc etapy).
Sterowanie przetwarzaniem¶
Należy zauważyć, że powyższe implementacje algorytmu nie uwzględniają interfejsu i integracji z resztą układu. O ile użycie układu kombinacyjnego jest dosyć proste (dajemy mu wejście, dostajemy wynik), użycie maszyny stanów czy potoku jest nieco bardziej skomplikowane.
Jest dość oczywiste, że moduły naszych układów często będą miały różne tempo pracy (nawet przy wspólnym zegarze) — w danym cyklu nasz moduł może nie mieć dostępnych danych wejściowych (gdyż poprzedni moduł jescze ich nie obliczył, jeszcze nie przyszedł pakiet sieciowy z nimi, itp), bądź też nie mieć możliwości wysłania swoich danych wyjściowych (gdyż docelowy moduł jest „zajęty”). Analogicznie, nasz własny moduł może nie mieć możliwości w danym momencie przyjąć danych.
W przypadku maszyny stanów, która produkuje jedno wyjście z jednego wejścia (jak ta powyżej), rozwiązanie tego problemu jest dosyć proste:
Dajemy naszej maszynie sygnał wejściowy
start
bądź podobny (patrz zadanie 1), mówiący kiedy dane wejściowe są dostępne i powinna ona zacząć pracę.Dajemy naszej maszynie sygnał wyjściowy
busy
mówiący, kiedy jest ona zajęta (i nie powinniśmy zlecać jej więcej zadań ani używać jej wyjść).
W przypadku bardziej skomplikowanych maszyn stanów (wczytujących wiele wejść bądź produkujących wiele wyjść) potrzebujemy bardziej skomplikowanych sygnałów sterujących.
Interfejs valid/ready¶
Dość popularnym i wygodnym mechanizmem kontroli przepływu w układach cyfrowych jest interfejs valid/ready. Składa się on z następujących sygnałów:
ready
(od konsumenta do producenta)valid
(od producenta do konsumenta)payload
: dowolny zbiór sygnałów z danymi (od producenta do konsumenta)
Semantyka tego interfejsu jest następująca:
Producent:
jeśli nie ma gotowego pakietu danych, ustawia
valid
na 0jeśli ma gotowy pakiet, wystawia go na sygnałach
payload
i ustawiavalid
na 1
Konsument:
jeśli jest w stanie zaakceptować pakiet danych, ustawia
ready
na 1jeśli nie jest, ustawia
ready
na 0
W momencie nastąpienia zbocza zegara, jeśli zarówno producent jak i konsument byli gotowi (zachodzi
valid & ready
), pakiet danych uważa się za przesłany. W przeciwnym wypadku, nic się nie dzieje.
Implementacja takiego interfejsu w maszynie stanów jest prosta — dla interfejsów
konsumujących dane, ustawiamy (kombinacyjnie) ready
na 1, gdy jesteśmy w stanie
w którym oczekujemy danych, po czym uzależniamy przejście do następnego stanu
(i całą naszą logikę, w tym pobranie danych payload
) od prawdziwości
valid
. Dla interfejsów produkujących dane, robimy na odwrót.
Przykład maszyny stanów z takim interfejsem możemy zobaczyć tutaj: Maszyny stanów.
Obsługa potoków, bąbelki¶
W przypadku potoków, sterowanie robi się bardziej skomplikowane — aby poprawnie obsługiwać potok, musimy wiedzieć ile on ma etapów, i na których etapach potoku znajduje się która paczka naszych danych. Jest jasne, że nie zawsze wszystkie etapy potoku będą zawierały sensowne dane — w szczególności nawet, jeśli mamy nieskończony i nieprzerwany strumień danych wejściowych, przy starcie układu będziemy mieć „puste” etapy. Takie puste etapy (zawierające śmieciowe dane) nazywa się „bąbelkami” potoku.
W praktyce jako część potoku zazwyczaj przekazuje się między etapami 1-bitową flagę, czy dany etap zawiera bąbelek. Można też analogicznie przekazywać bardziej skomplikowane metadane (choć na to bywają lepsze sposoby).
Potoki a interfejsy valid/ready¶
Gdybyśmy mieli dostosować nasz pokazany wyżej potok do interfejsów valid/ready na obu końcach, musielibyśmy oczywiście dodać do niego informację o bąbelkach. Okazuje się jednak, że wciąż jest to nietrywialne.
Stwierdzenie, czy ostatni etap naszego potoku zawiera sensowne dane i ustawienie
flagi out_valid
jest dość trywialne — ustawiamy ją, jeśli na końcu nie mamy
bąbelka. Zauważmy jednak, że jeśli out_ready
nie jest ustawione, a out_valid
jest, musimy zablokować cały potok (nie wykonywać żadnych obliczeń, efektywnie
zawierając wszystkie nasze synchroniczne obliczenia w wielkim m.If
). Jednak
oznacza to też, że możliwość zaakceptowania danych na wejściu (czyli in_ready
)
zależy (kombinacyjnie!) od możliwośći wysłania danych na wyjściu:
# UWAGA: niezalecane
m.d.comb += out_data.eq(data_7)
m.d.comb += out_valid.eq(valid_7)
m.d.comb += in_ready.eq(0)
with m.If(~valid_7 | out_ready):
# Wejście
m.d.comb += in_ready.eq(1)
m.d.sync += [
data_1.eq(in_data),
valid_1.eq(in_valid),
]
# Etapy potoku
# .. cała logika synchroniczna ..
m.d.sync += [
valid_2.eq(valid_1),
valid_3.eq(valid_2),
# ...
valid_7.eq(valid_6),
]
Tworzenie układów, w których wyjściowe sygnały sterujące zależą kombinacyjnie od wejściowych sygnałów sterujących nie jest jednak dobrym pomysłem — przy łączeniu kilku takich układów opóźnienia kombinacyjne dodają się, a w niektórych przypadkach łatwo też o pętlę kombinacyjną. Należy raczej zapewnić, żeby wszystkie sygnały sterujące były synchroniczne bez żadnych ścieżek kombinacyjnych od wejścia (wiele standardów interfejsu jak np. AXI ma to jako twarde wymaganie).
Przydatną konstrukcją, która pozwala dostosować nasz potok do takiego wymagania jest dodatnie dodatkowego bufora, który będzie „łapał” dane gdy potok zostanie zablokowany, pozwalając uniezależnić decyzje o pracy układu od gotowości konsumenta w danym cyklu (potok zostanie zablokowany dopiero w następnym cyklu, po zapełnieniu dodatkowego bufora):
buf_valid = Signal()
buf_data = Signal(out_data.shape())
# Jeśli nasz dodatkowy bufor został zapełniony,
# próbujemy wysłać dane z bufora; w przeciwnym wypadku
# z końca potoku.
m.d.comb += out_data.eq(Mux(buf_valid, buf_data, data_7))
m.d.comb += out_valid.eq(buf_valid | valid_7)
m.d.comb += in_ready.eq(0)
# Potok działa (i akceptuje wejście) gdy dodatkowy bufor
# jest pusty bądź na końcu jest bąbelek.
with m.If(~valid_7 | ~buf_valid):
# Wejście
m.d.comb += in_ready.eq(1)
m.d.sync += [
data_1.eq(in_data),
valid_1.eq(in_valid),
]
# Etapy potoku
# .. cała logika synchroniczna ..
m.d.sync += [
valid_2.eq(valid_1),
valid_3.eq(valid_2),
# ...
valid_7.eq(valid_6),
]
# Jeśli konsument blokuje, a mamy dane, zapełnij bufor.
with m.If(~out_ready & valid_7):
m.d.sync += buf_valid.eq(1)
m.d.sync += buf_data.eq(data_7)
with m.If(out_ready & buf_valid):
# Jeśli dane z bufora zostały zaakceptowane, zwolnij bufor.
m.d.sync += buf_valid.eq(0)