Temat: LabVIEW - zrównoleglanie czasochłonnych operacji
Załóżmy, że mamy program, w którym dokonujemy akwizycji danych, ich przetwarzania oraz zapisu wyników do pliku. Każda z tych operacji charakteryzuje się określonym czasem trwania. Dla lepszego zobrazowania symulujemy poszczególne operacje przy pomocy podprogramów przedstawionych poniżej.
Rysunek 1 Akwizycja danych, czas trwania 20ms
Rysunek 2 Przetwarzanie danych, czas trwania 50ms
Rysunek 3 Logowanie wyników, czas trwania 20ms
Jeżeli umieścimy poszczególne operacje w jednej pętli while tak, aby były wykonywane sekwencyjnie, całkowity czas wykonania pojedynczej iteracji pętli będzie określony przez sumę czasów wykonania poszczególnych podprogramów, wyniesie więc 90ms.
Rysunek 4 Podprogramy wykonywane sekwencyjnie, pomiar czasu wykonania pojedynczej iteracji pętli
Rysunek 5 Panel frontowy programu, wynik pomiaru czasu wykonania
Taka sytuacja może być dla nas bardzo niepożądana, jeżeli zależy nam na tym, aby akwizycja danych wykonywana była przy najmniejszym możliwym cyklu czasowym. Przy powyższym rozwiązaniu, cykl ten jest ponad 4-krotnie dłuższy.
Rozwiązaniem tego problemu jest zrównoleglenie wykonania poszczególnych operacji poprzez umieszczenie każdego z podprogramów w osobnej, działającej równolegle pętli while.
W tym celu tworzymy na diagramie blokowym dwie dodatkowe pętle while, jako warunek stopu do każdej z nich podpinamy zmienną lokalną dla przycisku Stop Button.
Rysunek 6 Równoległe pętle while
Poszczególne podprogramy nie mogą działać jednak zupełnie niezależnie od siebie, gdyż są ściśle powiązane przepływem danych. W celu przekazania danych do poszczególnych podprogramów, wykorzystamy mechanizm kolejki FIFO (first in first out). Pętla dokonująca akwizycji danych umieszcza pozyskaną w podprogramie Acquire Data tablicę danych w kolejce, z której będą one pobierane w pętli przetwarzającej. Z kolei przetworzone dane są umieszczane w drugiej kolejce, z której następnie pobiera je pętla logująca do pliku.
W celu oprogramowania tego mechanizmu, przenosimy poszczególne podprogramy do osobnych pętli, a następnie wstawiamy na diagram blokowy dwa bloczki Obtain Queue, odpowiedzialne za utworzenie kolejek. Pierwszej kolejce nadajemy nazwę Acquired Data, drugiej Processed Data. Jako typ danych dla pierwszej kolejki podłączamy tablicę liczb typu double, natomiast w przypadku drugiej kolejki – pojedynczą liczbę typu double.
W pierwszej pętli (Acquisition) umieszczamy bloczek Enqueue Element, odpowiedzialny za dodanie elementu do kolejki. Na jego wejścia podpinamy kolejkę oraz tablicę wyjściową z podprogramu Acquire Data.
Elementy dodane do pierwszej kolejki, pobierane są w drugiej pętli (Processing). W tym celu umieszczamy w niej bloczek Dequeue Element, realizujący pobranie pierwszego elementu z kolejki. Element ten następnie podajemy na wejście podprogramu przetwarzającego, a wynik przetwarzania dodajemy do drugiej kolejki wykorzystując ponownie bloczek Enqueue Element.
Wyniki przetwarzania są pobierane z kolejki w pętli Logging w analogiczny sposób przy pomocy bloczka Dequeue Element.
Dodatkowo w każdej z pętli umieszczone zostały bloczki Tick Count, służące do pomiaru czasu iteracji pętli.
Po uruchomieniu programu możemy zauważyć, że poszczególne pętle pracują z najkrótszym możliwym cyklem czasowym.
Rysunek 8 Czasy cyklu każdej z pętli równoległych
Powyższe rozwiązanie nie jest jednak całkowicie poprawne, ponieważ naciśnięcie przycisku Stop powoduje natychmiastowe przerwania działania wszystkich pętli. Efektem tego jest brak przetworzenia i zapisu do pliku wszystkich wygenerowanych danych.
W celu zobrazowania tego efektu możemy dodać do każdej z pętli liczniki iteracji i porównać ich wartości po zakończeniu programu.
Rysunek 9 Pętle równoległe z licznikami iteracji
Rysunek 10 Rezultat działania programu po jego zatrzymaniu
Jeżeli zależy nam na tym, aby wszystkie pozyskane dane zostały przetworzone przed zakończeniem programu, nie możemy zatrzymywać wszystkich pętli w tym samym momencie czasowym.
Aby rozwiązać ten problem, oprócz danych do przetworzenia, do kolejki dodawać będziemy komunikaty, informujące pętle równoległe o czynnościach do wykonania. W sytuacji, gdy tą operacją jest przetworzenie lub zapis danych, do kolejki trafią dane wraz z komunikatem odpowiednio Process lub Log, natomiast w przypadku zakończenia, w kolejce umieszczone zostaną puste dane oraz komunikat Stop. Takie rozwiązanie zapewni nam działanie pętli równoległych do momentu przetworzenia wszystkich danych znajdujących się w kolejkach.
Rysunek 11 Kolejki z komunikatami
W celu oprogramowania tego rozwiązania, zmieniamy typ danych kolejki na klaster złożony ze stałej Enum oraz odpowiednio tablicy i liczby typu double. Do przechowywania wszystkich możliwych komunikatów wykorzystujemy kontrolkę Enum, którą zapisujemy jako definicję typu, a po wykorzystaniu (utworzeniu poszczególnych stałych) usuwamy z diagramu blokowego.
W pętli Acqisition po pozyskaniu danych, pakujemy je w klaster (kontrolka Bundle) razem z komunikatem Process, a następnie tak przygotowany klaster wstawiamy do kolejki. W przypadku naciśnięcia przez użytkownika przycisku Stop Button, do kolejki wstawiamy klaster składający się z komunikatu Stop oraz pustej tablicy oraz zatrzymujemy działanie pętli.
W pętli Processing, pobieramy element z kolejki, a następnie rozpakowujemy klaster (kontrolka Unbundle). W zależności od wartości komunikatu, dokonujemy przetworzenia danych i wstawienia ich do kolejki z komunikatem Log lub zatrzymania pętli i wstawienia do kolejki pojedynczej liczby z komunikatem Stop dla pętli Logging.
W pętli Logging pobieramy w analogiczny sposób dane z kolejki, a w zależności od otrzymanego komunikatu zapisujemy je do pliku lub przerywamy działanie pętli.
Rysunek 12 Reakcja na komunikat Stop
Po uruchomieniu programu i naciśnięciu przycisku Stop możemy zauważyć, że licznik pierwszej pętli zostaje zatrzymany, natomiast pozostałe pętle działają dalej do momentu przetworzenia wszystkich danych z kolejki.
Rysunek 13 Efekt działania programu z komunikatami
Opracowany w ten sposób program możemy dalej ulepszyć, stosując zamiast typu danych specyficznego dla każdej kolejki, typ Variant. Dzięki temu unikniemy konieczności definiowania typu dla każdej kolejki, zastępując go jednym uniwersalnym typem, będącym klastrem złożonym z komunikatu oraz elementu Variant.
W tym celu zmieniamy dane w klastrze na pustą stałą typu Variant, a w poszczególnych pętlach w momencie dodawania danych do kolejki korzystamy z bloczka To Variant. Natomiast pobierając dane z kolejki, przekształcamy typ Variant na pożądany typ danych przy pomocy bloczka Variant To Data.
Rysunek 14 Wykorzystanie typu Variant
Propozycje ćwiczeń i modyfikacji
• Zmodyfikować pętlę Acquisition tak, aby generowanie danych nie odbywało się w sposób ciągły, a zdarzeniowo z wykorzystaniem struktury Event
• Dodać do programu paski postępu, informujące użytkownika o aktualnym stanie przetwarzania i logowania danych
• Zapoznać się z dostępnym w LabVIEW szablonem aplikacji Queued Message Handler