blog.garaż.net

16 marzec 2014

Mikrokontolery - pierwsze poważne starcie cz. 2

Jakiś czas temu pisałem o małym projekcie wykorzystującym mikrokontrolery. Przez ten weekend postanowiłem posiedzieć trochę i zgodnie z postanowieniami nieco przepisać całość aby pozbyć się kilku potencjalnych problemów - lepiej założyć, że warunki laboratoryjne są sprzyjające. ;)

Część czasu spędziłem oczywiście nad optymalizacją i analizą wygenerowanego kodu. I to prawda, że kompilator potrafi zrobić naprawdę sporo świetnej roboty, ale trzeba mu w tym czasami pomóc…

Przykładowa funkcja:

void checkAndSendPing ()
{
    if (activeChannals)
    {
        ticksToSendPingLow = 0;
        ticksToSendPingHigh = 0;
    }
    else if ( ! ++ticksToSendPingLow && ++ticksToSendPingHigh >= 22)
    {
        ticksToSendPingLow = 0;
        ticksToSendPingHigh = 0;
        addByteToQueue(0xff);
    }
}

Przy odrobinie chęci można byłoby to zapisać tak:

void checkAndSendPing ()
{
    if (activeChannals)
        goto CHECK_AND_SEND_PING_RESET;
    else if ( ! ++ticksToSendPingLow && ++ticksToSendPingHigh >= 22)
    {
        addByteToQueue(0xff);
        CHECK_AND_SEND_PING_RESET:
        ticksToSendPingLow = 0;
        ticksToSendPingHigh = 0;
    }
}

Różnica przy tak niewielkiej funkcji jest dosyć znaczna. Kod programu skurczył się o 10 bajtów (3 instrukcje). Niektórzy zaczną jednak kręcić nosem na goto, spróbujmy więc inaczej. Okazuje się, że kompilator wygeneruje dokładnie taki sam kod jeśli zapiszemy całość tak:

void checkAndSendPing ()
{
    if (activeChannals)
    {
        ticksToSendPingLow = 0;
        ticksToSendPingHigh = 0;
    }
    else if ( ! ++ticksToSendPingLow && ++ticksToSendPingHigh >= 22)
    {
        addByteToQueue(0xff);
        ticksToSendPingLow = 0;
        ticksToSendPingHigh = 0;
    }
}

Przed dalszym komentarze zaznaczę, że funkcja jest statyczna i została rozwinięta. Dla tej samej funkcji tylko nie inline i nie statycznej, GCC wstawił instrukcję powrotu co przyspieszy wykonanie o 1 instrukcję kosztem wydłużenia o dodatkowe dwie. W przypadku rozwinięcia i tak trzeba wykonać skok (aby pominąć blok else).

Krótko mówiąc kompilator mógł teraz bez obaw wyciągnąć wspólną część kodu. Wcześniej było to niemożliwe ze względu na wywołanie funkcji. Gdyby funkcja addByteToQueue była inline najpewniej całość nie miałaby znaczenia (funkcja nie zmienia wartości tych dwóch zmiennych globalnych), a tak najwyraźniej reguły kompilatora wymuszają zachowanie ostrożności i nie ingerowanie w kolejność instrukcji.

Ze względu na takie reguły warto przejrzeć najbardziej krytyczne fragmenty aplikacji pod kontem tego typu usprawnień. Nie zawsze trzeba sięgać bezpośrednio do optymalizacji na poziomie Asemblera, często wystarczy odpowiednio uszeregować instrukcje i dorzucić kilka słów kluczowych aby kompilator miał szansę zastosować jak najwięcej reguł optymalizujących nie zmieniając jednocześnie końcowego wyniku. Tyczy się to także "jednolinijkowców". Lepiej rozbić coś na kilka linii. Z -O0 wygenerowany kod będzie na pewno wyglądał strasznie, ale na wyższych poziomach wszystkie nadmiarowe przypisania, kopiowania, etc. i tak zostaną wyeliminowane.

Warto pamiętać o słowie kluczowym static. Obowiązkowo do funkcji wydzielonych w ramach polepszenia czytelności kodu. Jeśli funkcja wywoływana jest tylko raz to przy włączonej optymalizacji można spokojnie odpuścić sobie używanie słowa kluczowego inline, a nawet atrybutów wymuszających rozwinięcie funkcji w miejscu wywołania - efekt będzie dokładnie taki sam.

No i jednak czasami trzeba się trochę postarać i pomóc kompilatorowi bardziej. Spodziewałem się, że GCC poradzi sobie np. z takim wyrażeniem zapisanym w postaci makra:

(((((byte1)>>(bit1))&1)|(((byte2)>>(bit2))&1))!=(((checksumByte)>>(checksumBit))&1))

Ale lepiej samemu trochę się wysilić i pozbyć się kilku zbędnych instrukcji ręcznie:

((((byte1)>>(bit1))|((byte2)>>(bit2)))&1)!=(((checksumByte)>>(checksumBit))&1)

Niby nic, ale kod programu skrócił się o 14 bajtów (makro użyte jest 4 razy), i to w miejscu, które powodowało problemy. :)

No i na sam koniec. Eksperymentuj! Warto pamiętać o prostych regułach, które jednocześnie nie zaciemniają kodu i stosować je jeśli się tylko da. Zaś w krytycznych fragmentach (o ile rzeczywiście jest to potrzebne) przed przejściem na niższy poziom należy się zastanowić czy po prostu nie wystarczy ułatwić pracy kompilatorowi lub wykorzystać wiedzy o platformie w taki sposób aby napisany kod na wyższym poziomie odpowiadał temu co chcielibyśmy uzyskać pisząc bezpośrednio np. w Asemblerze. Spowoduje to zaciemnienie kodu ale i tak sprawi mniejsze problemy niż wstawki asemblerowe, w których dodatkowo musimy pilnować sposobu w jaki używamy rejestrów.

Comments !