Jakiś czas temu wielką popularność zdobyło nagranie Casey Muratori “Clean Code, Horrible Performance” jeżeli jeszcze nie znasz, polecam obejrzeć! Autor stawia tam obrazoburczą tezę, że popularne zasady Clean Code przynoszą więcej szkody niż pożytku. A w szczególności powodują ogromny spadek wydajności. W tym artykule zapoznamy się z jego argumentami i zastanowimy się, czy mają zastosowanie w embedded.
Argumenty przeciw Clean Code
Clean Code jest wpajany już początkującym programistom, którzy nie mają doświadczenia i nie są w stanie samodzielnie ocenić słuszności danych zasad. A lista zasad jest długa i podana jako prawda objawiona bez wystarczającego uzasadnienia. Do tego nie jesteśmy w stanie w obiektywny sposób sprawdzić, czy kod jest czysty. (To nie jest do końca prawda – mamy narzędzia do statycznej analizy kodu, style checkery itp).
Za to wydajność jesteśmy w stanie obiektywnie zmierzyć. Dlatego autor wybrał pięć najczęściej przytaczanych zasad:
- Polimorfizm zamiast ifów i switchów
- Ukrywanie szczegółów implementacji za abstrakcją
- Krótkie funkcje
- Funkcja robi jedną rzecz (SRP – Single Responsibility Principle)
- Unikamy duplikacji kodu (DRY – Don’t Repeat Yourself)
Następnie wziął typowy przykład ze wszystkich tutoriali o programowaniu obiektowym – figury geometryczne i porównał wydajność z Clean Code i bez. W trakcie filmiku wychodzimy od kodu z tutoriala, łamiemy kolejne zasady i sprawdzamy wpływ na wydajność. Okazało się, że w kolejnych krokach zysk wydajności jest coraz większy. W ostatniej iteracji program wykonuje się ponad 20 razy szybciej! Aby lepiej sobie uzmysłowić skalę problemu – dowiadujemy się, że to cofnięcie się w rozwoju sprzętu komputerowego o kilkanaście lat.
Na koniec przyznaje również, że pisanie projektu tak, aby łatwo go było potem utrzymać samo w sobie jest dobrym celem. Po prostu koszt jaki ponosimy na wydajności jest za wysoki. A poza tym wcale nie mamy pewności, że przynosi on efekt. Kod “czysty” w dalszym ciągu może być trudny do edycji. A istnieją techniki, aby szybszy kod był również utrzymywalny.
Background autora
Zanim przejdziemy do analizy przydatności powyższych tez w embedded, warto umieścić je jeszcze w odpowiednim kontekście. Casey Muratori doszedł do nich na bazie swoich doświadczeń programistycznych. Jest on programistą gier od ponad 20 lat i pisze głównie w C++. Hardware na jakim uruchamia swoje aplikacje to najczęściej komputery osobiste, konsole do gier czy telefony. Wykorzystuje optymalizacje cache czy specjalne instrukcje do równoległych obliczeń np. SIMD czy AVX.
W jego niszy bez wątpienia wydajność jest kluczowa. Jeżeli gra tnie, albo ma chore wymagania sprzętowe to raczej sukcesu nie odniesie. Warto też zwrócić uwagę, że w grach mamy programy CPU intensive. Czyli przez większość czasu katujemy procesor (albo np. GPU) dużą ilością obliczeń. Z kolei większość programów (w tym większość embedded) przez 90% czasu czeka na zasoby i nie wykonuje wielkich obliczeń zarzynających CPU.
Aby dowiedzieć się więcej o autorze polecam świetny wywiad u Primeagena (szczególnie polecam ostatnie 10 minut):
Casey sam przyznaje, że to trochę clickbait. Jego celem było zwrócenie uwagi branży na problemy z wydajnością i że małym nakładem sił można wielokrotnie zwiększyć wydajność systemów (nie miał raczej na myśli embedded tylko PC, web itp).
Autor wcale nie chce zmusić każdego do wyciskania maksimum ze swojego procesora. I to kosztem czytelności kodu. Sam mówi w wywiadzie o stereotypie osoby odpowiedzialnej za performance, która analizuje na każdym kroku budowę chipa, instrukcje asemblerowe i sprytnie wykorzystuje jakieś hacki żeby urwać pojedyncze cykle procesora. Taki poziom optymalizacji jest przydatny w demoscenie, a nie w projektach komercyjnych.
Bardzo ciekawie mówi też na koniec wywiadu o przewidywaniu designu z góry. Że praktycznie nigdy nie udaje się tego zrobić nie rozwiązując najpierw problemu za pomocą kodu. Dlatego naturalną drogą są eksperymenty i zrobienie prostego rozwiązania problemu, a następnie refactor. Wtedy mamy większą wiedzę o problemie i rozwiązaniu i możemy faktycznie ułatwić sobie utrzymanie kodu. Nie ma też sensu rozwiązywać w kodzie problemów, których jeszcze nie mamy.
A jak to się ma do embedded?
Teraz dostosujmy to wszystko do naszych realiów. Czy w embedded również nie stosując się do zasad czystego kodu osiągniemy 20 razy lepszy performance? Czy stara szkoła programowania zawsze pod optymalizację jednak triumfuje?
No nie do końca…
O pierwszej różnicy już wspomniałem wcześniej. W embedded zwykle czekamy większość czasu na zasoby albo zdarzenia. Nie katujemy CPU obliczeniami. Jak uruchomimy profilowanie okazuje się, że Idle Task zajmuje po 90% i więcej czasu aplikacji. Dostajemy eventy (np. z przerwań) i musimy je szybko obsłużyć. Aby uniknąć długich obliczeń najczęściej musimy po prostu zadbać, żeby poprawnie wykorzystywać koprocesor FPU. Włączamy go odpowiednimi flagami procesora, sekwencją startową i trzymając się kilku prostych zasad w kodzie (więcej tutaj i tutaj).
Na przykład Cortex-M4 ma instrukcje SIMD (odpowiednie funkcje znajdziesz np. w cmsis_gcc.h) ale nigdy nie miałem potrzeby ich używać. Nie słyszałem też o projektach embedded, gdzie były używane w dużym stopniu. Jak już to skorzystamy z gotowych bibliotek np. cmsis_nn do sieci neuronowych i te funkcje są wykorzystywane wewnętrznie.
Po drugie – w mikrokontrolerach zwykle nie optymalizujemy cache. Tyczy się to Cortexów-M, RISC-V, AVR, PIC – czyli dosłownie wszystkich popularnych mikrokontrolerów. Często mamy przetwarzanie potokowe, cache instrukcji, ale cache danych już nie. Z kolei w optymalizacjach na x86 cache to jeden z głównych tematów.
Tak więc w najlepszym wypadku takie optymalizacje będą miały znaczenie w małych fragmentach kodu. Na przykład w procedurze obsługi kluczowego przerwania. A obliczenia matematyczne, szyfrowanie itp. gdzie są obliczenia na CPU najczęściej weźmiemy z gotowych bibliotek. Najlepiej takich, które korzystają z dobrodziejstw naszego HW.
Po trzecie (powiązane trochę z pierwszym) – wymagania na wydajność formułują zwykle limity poniżej których musimy się zmieścić. Najczęściej nie wymagają od nas wyciskania z procesora ile fabryka dała. Dlatego optymalizujemy tylko tam, gdzie to faktycznie potrzebne.
Po czwarte przykłady są w C++, a większość kodu w embedded jest w C. Dlatego zachowanie kompilatora w odniesieniu do implementacji polimorfizmu w C++ czyli metod wirtualnych niekoniecznie odnosi się również do C. A większość dyskusji w filmiku atakuje bezpośrednio polimorfizm w C++.
Pozostaje jeszcze kwestia, czy na naszym mikrokontrolerze też możemy się spodziewać dwudziestokrotnego wzrostu wydajności? Moim zdaniem nie. Oczywiście rozmowa o wydajności bez pomiarów jest bez sensu. Dlatego zanim podejmiesz decyzję w swoim projekcie wykonaj odpowiednie pomiary.
Słoń w pokoju
A jeszcze musimy wspomnieć o najważniejszej rzeczy. Ocenianie czystego kodu tylko na podstawie wydajności jest niesprawiedliwe. Cała idea polega na oddaniu części wydajności w celu łatwiejszego utrzymania i szybszego dostarczania funkcjonalności. Z resztą autor sam twierdzi, że idea jest dobra. Tylko problem z wykonaniem.
Ok to tyle uwag ogólnych. Teraz do konkretów. Omówimy ciekawsze elementy z przykładów.
Polimorfizm
W przykładzie mamy klasę bazową Shape oraz klasy pochodne Kwadrat, Trójkąt, Koło itp. Klasy zawierają prywatne pola i metody wirtualne. Przykład dotyczy stricte C++. W domyśle pewnie dalej można robić następne poziomy dziedziczenia jeszcze bardziej pogarszając wydajność. Dziedziczenie powoduje utworzenie vtable czyli tablicy z wskaźnikami do metod wirtualnych. A vtable jest głównym wrogiem wydajności w tych przykładach.
Moje doświadczenie z C++ w embedded było takie, że stosujemy interfejsy (w C++ implementujemy je jako klasy abstrakcyjne, czyli składające się tylko z wirtualnych metod). Zwykłego dziedziczenia lepiej było unikać. Kiedy część funkcji jest implementowana w klasie bazowej, a część w pochodnej. Robi się straszny bałagan. A po dodaniu kolejnych poziomów dziedziczenia to już w ogóle. Z drugiej strony jak mamy gotowy szkielet i musimy wprowadzić małą zmieną to kusi dodanie jednej metody. Kod zawsze się psuje małymi kroczkami.
Drugim rozwiązaniem było CRTP (Curiously Recurring Template Pattern) czyli polimorfizm czasu kompilacji korzystający z template’ów. Wtedy nie mamy żadnych vtable. Mamy za to dziwne (i nieraz bardzo długie) komunikaty o błędach.
Oczywiście w zwykłym C również stosujemy interfejsy. Tylko wykorzystujemy do tego inne konstrukcje języka – struktury, wskaźniki na funkcje czy tablice.
A teraz po co stosujemy interfejsy? W ten sposób odwracamy zależności i ukrywamy szczegóły. Nie pozwalamy innym modułom odwoływać się do konkretnych pól czy innych szczegółów implementacyjnych. Unikamy potrzeby dodawania dziesiątek include’ów. Możemy też podstawiać różne implementacje tego samego interfejsu. Na przykład komendy możemy otrzymywać z ekranu z przyciskami, Modbusa i TCPIP. Interfejs jest taki sam. Pod spodem siedzi zupełnie inna realizacja, a aplikacji to nie obchodzi. W ten sposób możemy zaoszczędzić całe tygodnie pracy nad kodem. Dodatkowo możemy łatwiej testować nasze moduły.
Ify i switche
Na początku w małym projekcie (takim jak przykład z filmiku) ify i switche są jak najbardziej ok. Jeszcze nie wiemy jak najlepiej utworzyć abstrakcje. Instrukcje warunkowe są tylko w kilku miejscach i możemy je łatwo edytować. Doskonały był również przykład pokazujący, że wszystko jest obok siebie i łatwiej zauważyć usprawnienia.
No ale do czasu…
Później mamy np. 20 różnych komend/stanów/trybów działania, czy czegokolwiek innego powodującego powstawanie w kodzie instrukcji warunkowych. I takie switche na 20 case mamy w 10 różnych funkcjach. Każdą nową komendę dodajemy w 10 miejscach. Modyfikacja obsługi zachodzi w 20 case danego switcha. A jeszcze to wszystko jest rozrzucone po różnych funkcjach, plikach i modułach. Zewnętrzne moduły muszą znać wartości stanów/komend aby obsługiwać switche. Zależności rozprzestrzeniają się po kodzie jak rak.
Dokładnie coś takiego pokazywałem w filmiku o mapperach:
Mój przykład korzysta z lookup table w C. Z mojej perspektywy to wykorzystanie zasad czystego kodu w C. Z kolei Casey Muratori uważa to za złamanie zasad czystego kodu w C++. Wszystko zależy od perspektywy. Doskonały przykład, że te zasady są subiektywne.
Aby podjąć decyzję w naszym projekcie musimy zastanowić się, czy dany kod jest data driven, czy flow driven. Jeżeli mamy wiele różnych danych, a procedura obsługi jest taka sama, tablica sprawdzi się jak złoto. Czasami przy miksie możemy do tablicy wrzucić wskaźnik na funkcję i w ten sposób obsłużyć różnice. Pewnie da się to również zrobić jakoś ładnie w C++.
Co robić, jak żyć?
Na pewno warto zapoznać się z “Clean Code Horrible Performance”. Jednak nie należy go traktować jako prawdę objawioną. Tak samo z resztą z zasadami Clean Code. Wszystko musimy dostosować do naszej konkretnej sytuacji. Inaczej kończymy z kodem, który nie jest ani wydajny ani utrzymywalny.
Casey Muratori chciał szokować i zwrócić uwagę na problem. Dlatego użył radykalnych środków działających na wyobraźnię. Jego background to C++ i development gier, architektura x86 i wiele obliczeń na CPU. Rzeczywistość w embedded jest inna. Po pierwsze wcale nie musimy osiągnąć aż takiego wzrostu wydajności. Po drugie w aplikacji spędzającej 90% czasu w Idle Tasku to może nie być potrzebne.
Dlatego w embedded polecam domyślnie jednak iść bardziej w stronę czytelności. A wydajność zachować na akceptowalnym poziomie. Tym bardziej, że dużo częściej zdarza nam się zapaskudzić kod odniesieniami do HW i zewnętrznych bibliotek, pisać funkcje na tysiące linii i popełniać inne zbrodnie na czytelności kodu. A każde odstępstwo powinniśmy uargumentować pomiarami. Na naszym sprzęcie w realiach naszego projektu.
W trakcie pisania tego artykułu przyszła mi do głowy jeszcze jedna myśl. Otóż być może kwestie dotyczące czytelności i wydajności wychodzą na pierwszy plan w nauce programowania zbyt szybko. Kiedyś na początku pisaliśmy kod, który po prostu miał poprawnie działać. Musieliśmy później cierpieć z powodu błędnych decyzji projektowych. Czy to wydajnościowych, czy utrzymaniowych. Dlatego powstała książka Clean Code oraz wiele innych materiałów w temacie.
Teraz może za szybko uczymy się tych reguł nie do końca rozumiejąc, po co zostały stworzone. Staramy się je stosować w naszym kodzie, ale nie bardzo wiemy jak. Nie wiemy nawet, czy są nam w ogóle potrzebne w danym momencie. Pamiętam nieraz jak miałem blokadę twórczą. Nie mogłem nic na pisać, bo każda próba łamała jakieś reguły i mi się nie podobała. Dobre praktyki mają nam pomagać, a nie stopować pracę.
Opisałem tutaj swój punkt widzenia, który na pasku bardziej przesuwa się w stronę czytelności. Oczywiście Twoje zdanie na ten temat może być zupełnie inne. Koniecznie podziel się nim w komentarzu!
Dodaj komentarz