.. _w04-sync-design: ====================================================== WykĹad 4: UkĹady synchroniczne, przetwarzanie potokowe ====================================================== Data: 10.11.2020 .. toctree:: .. contents:: 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Ä: 1. Trzy dostÄpy do pamiÄci (``A``, ``B``, ``C``) 2. Trzy mnoĹźenia (``[1]``, ``[2]``, ``[3]``) 3. Dwa dodawania (``[2]``, ``[3]``) ProjektujÄ c taki ukĹad mamy dostÄpnych wiele sposobĂłw realizacji, ktĂłre róşniÄ siÄ: 1. PowierzchniÄ powstaĹego ukĹadu 2. MaksymalnÄ czÄstotliwoĹciÄ ukĹadu 3. Opóźnieniem ukĹadu w cyklach (czas od otrzymania ``x`` do obliczenia ``y``) 4. 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: 1. Powierzchnia: 3 mnoĹźniki, 2 ukĹady dodajÄ ce, 3 porty odczytu 2. Maksymalna czÄstotliwoĹÄ ukĹadu: 1 / (3 * opóźnienie mnoĹźnika + 2 * opóźnienie ukĹadu dodajÄ cego + opóźnienie portu odczytu) 3. Opóźnienie ukĹadu: â¤1 cykl 4. PrzepustowoĹÄ ukĹadu w obliczeniach na cykl: 1 Realizacja ukĹadu w ten sposĂłb jest zazwyczaj zĹym pomysĹem: 1. PowaĹźnie ogranicza to maksymalnÄ czÄstotliwoĹÄ zegara w naszym ukĹadzie (co wpĹywa na wydajnoĹÄ caĹej domeny zegarowej) 2. Wymagane sÄ asynchroniczne porty odczytu Maszyna stanĂłw -------------- Tworzymy maszynÄ stanĂłw, na przykĹad z nastÄpujÄ cymi stanami: 1. ``INPUT``: wczytujemy wejĹcie, liczymy ``x1, r, x2`` 2. ``READ_A``: wczytujemy ``A[r]`` 3. ``MUL_1``: liczymy ``A[r] * x2``, wczytujemy ``B[r]`` 4. ``ADD_1``: liczymy ``t`` 5. ``MUL_2``: liczymy ``t * x2``, wczytujemy ``C[r]`` 6. ``ADD_2``: liczymy ``y``, 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), ] # ... 1. Powierzchnia: 1 mnoĹźnik, 1 ukĹad dodajÄ cy, 1 port odczytu, trochÄ multiplekserĂłw, trochÄ przerzutnikĂłw, logika maszyny stanĂłw 2. Maksymalna czÄstotliwoĹÄ ukĹadu: 1 / max(opóźnienie mnoĹźnika, opóźnienie ukĹadu dodajÄ cego, opóźnienie portu odczytu) 3. Opóźnienie ukĹadu: 6 cykli 4. PrzepustowoĹÄ ukĹadu w obliczeniach na cykl: â MoĹźemy sobie wyobraziÄ wiele moĹźliwych wariantĂłw tego rozwiÄ zania: 1. Zmniejszamy liczbÄ stanĂłw, na przykĹad przez wykonywanie mnoĹźenia i dodawania w jednym cyklu (i dodanie dodatkowego portu odczytu) 2. Wykonujemy odczyt z pamiÄci asynchronicznie 3. 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. 1. 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) 2. Maksymalna czÄstotliwoĹÄ ukĹadu: 1 / max(opóźnienie mnoĹźnika, opóźnienie ukĹadu dodajÄ cego, opóźnienie portu odczytu) 3. Opóźnienie ukĹadu: 6 cykli 4. 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: 1. 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Ä. 2. 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: 1. Producent: - jeĹli nie ma gotowego pakietu danych, ustawia ``valid`` na 0 - jeĹli ma gotowy pakiet, wystawia go na sygnaĹach ``payload`` i ustawia ``valid`` na 1 2. Konsument: - jeĹli jest w stanie zaakceptowaÄ pakiet danych, ustawia ``ready`` na 1 - jeĹli nie jest, ustawia ``ready`` na 0 3. 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: :ref:`ex-fsm`. 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)