Embedded RTOS - testy wydajności
March 26, 2024

Czas na porównanie RTOSów w boju. Sprawdzimy jak radzą sobie pod względem wydajnościowym. Zmierzymy jak zachowują się przy elementarnych operacjach jak: przełączanie kontekstu przy braku obciążenia, okresowość czy powrót z przerwania. Na końcu każdy z nich będzie musiał sprostać zajęciu wymagającym krzepy - rzeczywistej aplikacji.

Po przeglądzie kwestii teoretycznych dotyczących doboru RTOS-a, nadeszła pora na część bardziej praktyczną. Wszystkie testy zostały przeprowadzone na STM32F429-DISC1 z głównym zegarem ustawionym na 84 MHz. Większość RTOSów integrowana była z CubeIDE przy użyciu dostarczanych przez ST odpowiednich middleware’ów. Zephyr ze względu na swoją specyfikę i brak wsparcia w CubeIDE uruchamiany był oddzielnie.
Pomiary wykonywane zostały przy użyciu aplikacji przeznaczonych do analizy działań takich systemów – SystemView dla FreeRTOSa i embOSa oraz TraceX dla ThreadXa.

Test #1 - Context switch

Według literatury traktującej o RTOSach, pierwszym ważnym bastionem w analizie wydajności systemu  jest czas, jaki potrzebuje system do zmiany kontekstu. Co odpowie nam bezpośrednio na pytanie - jak długo musi działać Scheduler danego RTOSa, by wykonywać natywną dla niego pracę. Warto będzie również sprawdzić, czy ilość zadań (tasków) będzie miała znaczenie.

Na początek prosty test:
+ taski są puste;
+ wywłaszczanie włączone;
+ SysTick ustawiony na generowanie przerwań co 1ms;

Pomiary czasów przełączania wykonane dla:
+ 2 zadań;
+ 5 zadań;
+ 10 zadań:

wg powyższej konfiguracji.

Wyniki pomiarów

Obserwacje
Zaskakujące duże różnice pomiędzy ThreadX/Zephyr a embOS (prawie 6x dłużej wykonuje się context switch). Zaświeciła nam się żółta lampka. Czas sprawdzić czy problem ten wzrośnie wraz z ilością zadań i obciążeniem systemu.

Test #2 - Periodicity

W aplikacjach typu hard RTOS, wywoływanie zadań i reakcja w odpowiednim czasie są najważniejszymi aspektami systemu.
W "soft RTOS" możemy liczyć, że dany system wrzuci zadanie najszybciej jak to możliwe do kolejki do wykonywania. 
W tym teście szukamy odpowiedzi na pytanie "Jak blisko zadanego terminu task zaczyna się wykonywać?".

Ustawienia:
+ Task jest pusty;
+ wywłaszczanie włączone;
+ SysTick ustawiony na generowanie cyklów 100ms;

Wyniki:

Okresowość wywołania tasków

Obserwacje

Jak widać, błąd RTOS-ów oscyluje wokół 0.1% co jest bardzo małą wartością i może pozwolić na uznanie niedokładności za pomijalną. Co ciekawe, embOS znowu odstaje – nie jest to duża wartość ale niesmak pozostaje. Przy zmianie SysTicka na 10ms ten błąd rośnie proporcjonalnie (prawie o rząd). Narzut RTOSa jest w przybliżeniu stały.

Test #3 - Context switch from ISR

Kolejną wartą sprawdzenia rzeczą jest czas jaki jest przeznaczany na wyjście z przerwania. Jest to o tyle istotne że przerwania nie są częścią kernela RTOS-a, obsługiwane są poza jego kontrolą, a każdy ze sprawdzanych systemów radzi sobie z wejściem do ISR-a nieco inaczej.
Aby stworzyć warunki do testów można wykorzystać semafor i zewnętrzne przerwanie w następujący sposób:

  • RTOS inicjalizuje semafor jako zajęty;
  • główny task rozpoczyna pracę i blokuje się czekając na zwolnienie semafora;
  • zewnętrzne przerwanie (w naszym wypadku wciśnięcie przycisku) zwalnia semafor;
  • task przejmuje semafor;

Mierzymy czas potrzebny na zwolnienie semafora oraz zmianę kontekstu. Poniżej idea:

FreeRTOS

API FreeRTOSa dostarcza osobne, bezpieczne dla obsługi przerwań wersje często używanych funkcji. Można je rozpoznać po sufiksie „FromISR”. Jest to potrzebne aby poinformować kernel że użyta funkcja została wywołana w przerwaniu, co pozwala mu odpowiednio zareagować. Użycie w przerwaniach standardowej wersji funkcji z reguły powoduje błędy, najczęściej HardFault. API w przypadku oddania semafora w przerwaniu wymaga też bezpośredniego wywołania funkcji mówiącej o opuszczeniu przerwania i wymuszającej kontynuowanie porzuconego zadania. Kod załączony jest poniżej, podobnie jak wizualizacja wyniku w SystemView (do pomiaru dobrze użyć jest markerów).

void HAL_GPIO_EXTI_Callback ( uint16_t GPIO_Pin ) { SEGGER_SYSVIEW_MarkStart (1); BaseType_t xHigherPriorityTaskWoken = pdFALSE; xSemaphoreGiveFromISR ( LED_semaphore , &xHigherPriorityTaskWoken ); SEGGER_SYSVIEW_MarkStop (1); portYIELD_FROM_ISR ( xHigherPriorityTaskWoken ); }

A tak wyniki prezentują się w SystemView:

W przypadku zwalniania semafora w przerwaniu, FreeRTOS tak naprawdę wywołuje funkcję xQueueGiveFromISR – sprawdza w niej takie rzeczy jak:
+ czy kolejka nie jest pusta,
+ czy blokuje jakiś task,
+ czy trzeba będzie przełączyć kontekst.
FreeRTOS wprowadza koncept limitu priorytetu który nie pozwala wywoływać jego funkcji w przerwaniach o priorytecie wyższym – warunek ten sprawdzany jest na początku funkcji. Przed wyjściem, xQueueGiveFromISR sprawdza też czy w międzyczasie nie odblokowany został task o wyższym priorytecie niż dotychczasowy - to wymuszałoby zmianę kontekstu. Dodatkowo, każda funkcja kernela wołana w trakcie przerwania także posiada sufiks -FromISR.

ThreadX

RTOS Microsoftu (Ecliipse Foundation od niedawna) oferuje znacznie prostsze podejście do obsługi przerwań. API nie wymaga informowania kernela o niczym, wystarczy zwykłe wywołanie funkcji jak w trakcie normalnej pracy RTOS-a. Sam TraceX też nie wymagał użycia żadnych markerów, ponieważ wprost podawał czas spędzony w obsłudze przerwania.

void HAL_GPIO_EXTI_Callback ( uint16_t GPIO_Pin ) { tx_semaphore_put ( &LED_semaphore ); }

A tak wyniki prezentują się w TraceX:

Na starcie funkcji, za pomocą makra zostają wyłączone przerwania. Następnie następuje tradycyjna obsługa semafora – inkrementacja, sprawdzenie czy jakiś task jest blokowany. Przed wyjściem - przerwania włączane są z powrotem.

embOS

Podejście embOSa jest stosunkowo zbliżone do stosowanego przez FreeRTOSa. Kernel potrzebuje informacji, że funkcja wywoływana jest z przerwania. API oferuje w tym celu specjalne funkcje którymi należy otoczyć obszar wywoływania funkcji RTOS-a. Pominięcie ich wywołuje podobne problemy jak we FreeRTOSie.

void HAL_GPIO_EXTI_Callback ( uint16_t GPIO_Pin ) { SEGGER_SYSVIEW_MarkStart (1); OS_INT_Enter (); OS_SEMAPHORE_Give (&LED_semaphore ); SEGGER_SYSVIEW_MarkStop (1); OS_INT_Leave (); }

A tak wyniki prezentują sie w SystemView:

EmbOS wykorzystuje OS_INT_Enter, który woła szereg makr:
+ sprawdzenie czy kernel jest zainicjalizowany,
+ postawienie flagi że kontrola przeniosła się od przerwania,
+ wyłączenie przerwań niskiego poziomu oraz włączenie tych wysokiego.

Zephyr

Zephyr podchodzi do sprawy ISR w bardzo podobny sposób co ThreadX - funkcje kernela mogą być wywoływane bezpośrednio w przerwaniu.

void ISR_callback(const struct device *dev, struct gpio_callback *cb, uint32_t pins) { k_sem_give(&LED_semaphore); }

Zephyr i SystemView:

Na grafice powyżej, koniec ciemniejszego prostokąta w ISR22 to moment wywołania funkcji k_sem_give - czas jest policzony od tej chwili do rozpoczęcia się thread_a. Jak widać Zephyr "stracił" kilka mikrosekund przełączając się na moment do Idle’a.

Zephyr nadaje odpowiednim funkcjom atrybut isr-ok jeżeli można użyć ich w wewnątrz przerwania. Sama implementacja k_sem_give z kolei opiera się na tzw. spinlocku - dzięki temu na wejściu do funkcji gwarantowane jest że wywołujący task nie zostanie wywłaszczony ani przerwany.

Context switch from ISR - podsumowanie

Contex switch from ISR

Trend jak widać jest zgodny z wynikami z pierwszego testu - prowadzi ThreadX, za nim Zephyr, daleko dalej FreeRTOS a na końcu embOS. Na czas potrzebny na bazowe przełączenie kontekstu, tj. obsługę schedulera nakłada się jeszcze ten spędzony w przerwaniu zwalniając semafor. Odnosząc to do wspomnianych poprzednich wyników możemy dowiedzieć się że w przypadku FreeRTOSa czas wzrósł o ok. 80 µs, a Zephyra, ThreadXa i embOSa o ok. 30 µs.

Jeśli chodzi o RTOS vs przerwania - najmniejsze zmiany względem "normalnego" kodu, zachodzą w ThreadX-ie. Ma to pozytywny wpływ na szybkość. Może nieść negatywne skutki ponieważ, wyłączenie przerwań uniemożliwia obsługę zagnieżdżonych przerwań wyższego priorytetu. Zephyr radzi sobie podobnie wołając spinlock. FreeRTOS stara się nie ominąć żadnego przerwania, ale fakt że każda wołana funkcja jest typu -FromISR może powodować nakładanie się opoźnień. embOS zdaje się pozostawać pomiędzy, wyłączając przerwania niskiego poziomu a upewniając się że te wysokiego pozostaną obsłużone.

Test #4 - Real life application

Po poprzednich 3 testach, które poruszały mocno wyizolowane przypadki, warto sprawdzić jak RTOS-y zachowają się w sytuacji bardziej zbliżonej do rzeczywistego obciążenia.

Po zbadaniu wydajności czterech popularnych RTOSów w kontekście zmiany kontekstu, okresowości zadań i czasu powrotu z przerwań, nadszedł czas na testy z aplikacją. Wykorzystamy tutaj szybką transformatę fouriera (FFT) o złożoności obliczeniowej O(n log n), co znacznie obciąży mikrokontroler (nawet z modułem DSP). Wynik transformaty wysyłamy w drugim tasku na UARTa i podglądamy na analizatorze logicznym.

Szczegóły konfiguracji:
+ biblioteka do obliczeń FFT - ARM CMSIS-DSP;
+ ADC 12bit z częstotliwością próbkowania 1kHz (sygnał próbkowany pochodzi z generatora);
+ UART 115200kb/s, klasyczne ustawienie;

Task odpowiedzialny za UARTA czeka na otrzymanie przez kolejkę obliczonej przez FFT_task częstotliwości. Gdy to się stanie, wartość jest wysyłana na surowo na UART. 

void FFT_task_entry () { uint32_t received_from_ADC; uint32_t maxFFTValueIndex = 0; float32_t maxFFTValue = 0; uint16_t freqBufferIndex = 0; //sampling frequency as set in .ioc file - 84MHz / 8400 / 10 = 1 kHz float32_t f_s = APB1_CLOCK / ( htim3.Init.Prescaler + 1) / (htim3.Init.Period + 1); //prepare frequency value (y axis) for each sample FFT_init( f_s ); //initialization function for the floating-point real FFT; FFTHandler is provided by library //number of samples must be 2^n arm_rfft_fast_init_f32( &FFTHandler , FFT_SAMPLES ); while (1) { queue_get( ADC_queue , &received_from_ADC ); HAL_GPIO_TogglePin( LD3_GPIO_Port , LD3_Pin ); FFT_input[ freqBufferIndex ] = (float32_t)received_from_ADC; freqBufferIndex ++; if( freqBufferIndex >= FFT_SAMPLES ) { SEGGER_SYSVIEW_MarkStart(0); HAL_GPIO_WritePin( FFT_pin_GPIO_Port , FFT_pin_Pin , GPIO_PIN_SET ); freqBufferIndex = 0; //processing function for the floating-point real FFT; outputs complex values arm_rfft_fast_f32( &FFTHandler , FFT_input , FFT_output , 0); //calculate complex magnitude arm_cmplx_mag_f32( FFT_output , freqTable , FFT_SIZE ); / /retrieve max value to obtain base frequency arm_max_f32( freqTable + 1 , FFT_SIZE - 1, & maxFFTValue , &maxFFTValueIndex ); baseFreq = freqOrder[ maxFFTValueIndex ]; queue_put( UART_queue , (void*)&baseFreq ); HAL_GPIO_WritePin( FFT_pin_GPIO_Port , FFT_pin_Pin , GPIO_PIN_RESET ); SEGGER_SYSVIEW_MarkStop(0); } delay(1); } }

UART_task oczekuje aż DMA umieści w kolejce gotowy pomiar zewnętrznego sygnału i następnie umieszcza go w buforze. Gdy bufor osiągnie wystarczającą do obliczeń wielkość, funkcje z biblioteki ARM pozwalają obliczyć częstotliwość sygnału. Ta z kolei umieszczana jest w kolejce która blokuje UART_task.

void HAL_ADC_ConvCpltCallback( ADC_HandleTypeDef * AdcHandle )

Sposób używania API RTOS-a w obsłudze przerwania różni się w każdym systemie.
Dodatkowo dodaliśmy w kodzie PIN Toggle, by sprawdzić poszczególne wyniki na analizatorze sygnału (Salea Logic).

Wyniki pomiarów.

RTOSy vs FFT

Obserwacje

Analiza wyników z SystemView w porównaniu z danymi z analizatora logicznego pokazuje rozbieżność nie przekraczającą 2%.
Przyjmujemy analizator jako punkt odniesienia, ze względu na jego zdolność do precyzyjnego określenia czasu startu i zakończenia pomiaru.

ThreadX i Zpehyr ponownie wyróżniają się jako liderzy, choć ich przewaga jest niewielka.
Zdaje się, że w realnej aplikacji, gdy system jest obciążony, operacje kernela RTOSa mają marginalny wpływ na performance całego systemu.