O optymalizacji za pomocą memoizacji

Cache kusi, bo brzmi jak szybka odpowiedź na niezoptymalizowany kod. Tylko że zapamiętanie wyniku to dopiero początek. Trzeba jeszcze wiedzieć, co jest kluczem, kiedy wynik traci ważność i czy nie oddamy danych nie temu użytkownikowi. O tym właśnie jest memoizacja.

O optymalizacji za pomocą memoizacji PAGES.POST.COVER_THUMBNAIL.BY_WHOM jarmoluk

Z tego artykułu dowiesz się:

  • Co to jest memoizacja?

  • Czym memoizacja różni się od zwykłego cache?

  • Dlaczego memoizacja nie jest tylko narzędziem frontendowym?

  • Jak memoizacja wygląda w React, Vue, Angularze i Javie?

  • Czy memoizacja ma sens na backendzie?

  • Jakie są koszty, pułapki i ograniczenia tej techniki?

Optymalizacja bardzo często zaczyna się od niewygodnego uczucia, że program robi za dużo. Na pierwszy rzut oka wszystko działa poprawnie. Dane się wyświetlają, odpowiedź z API wraca, raport się generuje, a komponent się renderuje. Dopiero po chwili zauważamy, że ta sama praca wykonywana jest kilka, kilkanaście albo kilkaset razy.

Może to być filtrowanie dużej listy produktów w aplikacji frontendowej. Może to być przeliczanie uprawnień użytkownika w kilku miejscach jednego requestu. Może to być parser, który za każdym razem od nowa buduje tę samą strukturę. Może to być też klasyczny przykład z rekurencyjnym liczeniem liczb Fibonacciego, który świetnie pokazuje problem, nawet jeśli rzadko jest prawdziwym problemem biznesowym.

W takich sytuacjach pojawia się memoizacja. Nie jako magia, nie jako dekoracja do kodu i nie jako funkcja z Reacta. Memoizacja to pomysł, żeby nie liczyć drugi raz tego samego, jeśli wynik już znamy.

Brzmi prosto. I właśnie dlatego warto zatrzymać się na chwilę, bo w realnych projektach najtrudniejsze nie jest zapisanie wyniku. Najtrudniejsze jest odpowiedzenie na pytanie, czy ten wynik nadal jest prawdziwy.

Skąd bierze się problem?

Wyobraźmy sobie prostą aplikację sklepową. Mamy listę produktów i chcemy wyświetlić tylko te, które są dostępne.

Nie ma tutaj nic podejrzanego. Funkcja jest krótka, czytelna i prawdopodobnie wystarczająco szybka dla małej listy. Problem zaczyna się dopiero wtedy, gdy lista jest duża, funkcja jest wywoływana często, a dane wejściowe przez większość czasu pozostają takie same.

To jest moment, w którym warto zadać pytanie: czy naprawdę musimy wykonywać tę samą pracę ponownie

Odpowiedź nie zawsze brzmi "nie". Czasem obliczenie jest tak tanie, że dokładanie cache bardziej zaszkodzi niż pomoże. Czasem dane zmieniają się tak często, że prawie nigdy nie trafimy w zapamiętany wynik. Czasem prawdziwy problem leży gdzie indziej: w źle napisanym zapytaniu do bazy, zbyt dużej liczbie renderów albo nieoptymalnym algorytmie.

Memoizacja ma sens dopiero wtedy, gdy mamy powtarzalną pracę i potrafimy bezpiecznie rozpoznać, że wejście jest takie samo.

Czym jest memoizacja?

Memoizacja to technika optymalizacji polegająca na zapamiętywaniu wyniku funkcji dla konkretnych danych wejściowych. Jeśli później wywołamy tę samą funkcję z tymi samymi danymi, możemy zwrócić wcześniej obliczony wynik zamiast liczyć go od nowa.

W dużym skrócie:

  • pierwsze wywołanie nadal kosztuje tyle samo,

  • kolejne wywołania mogą być tańsze,

  • płacimy pamięcią za czas,

  • potrzebujemy klucza, po którym rozpoznamy dane wejściowe,

  • musimy wiedzieć, kiedy zapamiętany wynik traci ważność.

Memoizacja jest szczególnym przypadkiem cache'owania. Cache jest pojęciem szerszym. Możemy cache'ować odpowiedzi HTTP, wyniki zapytań do bazy, pliki statyczne, fragmenty widoków albo dane w Redisie. Memoizacja jest bliżej funkcji: dla argumentów A zapamiętaj wynik B.

Najlepiej działa wtedy, gdy funkcja jest deterministyczna, czyli dla tych samych argumentów zawsze zwraca ten sam wynik.

Jeśli wywołamy square(4), zawsze dostaniemy 16. Tutaj memoizacja jest łatwa do zrozumienia. Pytanie brzmi tylko, czy ma sens wydajnościowy, bo samo mnożenie jest tańsze niż obsługa cache.

Prosty przykład w JavaScript

Spróbujmy napisać najprostszą funkcję memoize.

Możemy jej użyć w taki sposób:

Pierwsze wywołanie dla 100 wykonuje właściwe obliczenie. Drugie wywołanie dla 100 zwraca wynik z pamięci (Map). Dla 200 mamy już inny klucz, więc funkcja musi policzyć wynik od nowa.

Ten przykład dobrze pokazuje ideę, ale nie rozwiązuje jeszcze kilku praktycznych problemów. Co zrobić z funkcją, która ma kilka argumentów? Co z obiektami? Czy cache może rosnąć bez końca? Czy wynik po godzinie nadal jest prawdziwy? To są pytania, które w prawdziwym kodzie pojawiają się bardzo szybko.

Problem z kluczami

W memoizacji klucz jest równie ważny jak wynik. Jeśli źle zbudujemy klucz, możemy albo nie korzystać z cache, albo, co gorsza, zwrócić wynik dla nie tych danych, dla których powinniśmy.

W JavaScript łatwo wpaść w pułapkę z obiektami.

Na pierwszy rzut oka oba obiekty wyglądają tak samo. Dla Map to jednak dwa różne klucze, bo obiekty porównywane są po referencji, a nie po strukturze.

Można próbować rozwiązać ten problem przez JSON.stringify.

To czasem wystarczy, ale nie traktowałbym tego jako uniwersalnej odpowiedzi. Kolejność pól, typy danych, daty, funkcje, wartości undefined, struktury cykliczne i duże obiekty mogą sprawić, że taka strategia będzie zawodna albo zbyt kosztowna.

Innymi słowy: memoizacja zaczyna się od cache, ale bardzo szybko dochodzi do modelowania tożsamości danych.

Memoizacja poza frontendem

W ostatnich latach wielu programistów kojarzy memoizację głównie z Reactem. Trudno się dziwić. useMemo, useCallback i memo są bardzo widocznymi elementami API. Tylko że memoizacja nie jest pomysłem Reacta i nie jest nawet pomysłem frontendowym.

Weźmy klasyczny przykład w Javie.

Bez memoizacji rekurencyjne liczenie Fibonacciego wykonuje wiele tych samych obliczeń. calculate(40) potrzebuje calculate(39) i calculate(38), ale calculate(39) znowu potrzebuje calculate(38). I tak dalej. Program kręci się wokół tych samych wartości.

Po dodaniu mapy każda wartość zostaje policzona raz. Później jest już tylko odczytywana z cache.

To dobry przykład dydaktyczny, ale warto zachować zdrowy rozsądek. W produkcyjnym kodzie musielibyśmy zapytać o kilka rzeczy: czy HashMap jest bezpieczna przy współbieżnym dostępie, czy cache ma limit, czy wynik może się zdezaktualizować i czy rekurencja jest najlepszym sposobem rozwiązania tego konkretnego problemu.

Przykład z Javą jest ważny z jednego powodu: pokazuje, że memoizacja jest ideą, a nie funkcją konkretnego frameworka.

Czy memoizacja ma sens na backendzie?

Tak, ale na backendzie trzeba być ostrożniejszym niż w typowym przykładzie frontendowym.

Na froncie memoizacja często chroni nas przed ponownym renderem, filtrowaniem listy albo tworzeniem nowej referencji do funkcji. Na backendzie zapamiętany wynik może być współdzielony między requestami, użytkownikami, tenantami albo instancjami aplikacji. To od razu podnosi stawkę.

Memoizacja na backendzie ma sens, gdy:

  • w ramach jednego requestu powtarzamy kosztowną operację,

  • mamy długowieczny proces, na przykład worker, kolejkę, aplikację JVM, Node.js, RoadRunner albo Swoole,

  • wynik jest deterministyczny albo ma jasno określoną ważność,

  • umiemy opisać klucz i moment unieważnienia wyniku,

  • wiemy, że wynik jest bezpieczny dla requestu, który go odczytuje.

Przykłady? Przeliczanie uprawnień użytkownika, parsowanie schematów, kompilowanie walidatorów, transformowanie konfiguracji, kosztowne obliczenia domenowe, dane referencyjne albo wyniki zapytań, jeśli mamy jasną regułę odświeżania.

Zwróćmy uwagę na ostatni fragment: "jeśli mamy jasną regułę odświeżania". To jest punkt, w którym memoizacja zaczyna przechodzić w szerszy temat cache'owania.

Jeśli funkcja zależy od bazy danych, zewnętrznego API, czasu, konfiguracji albo aktualnego użytkownika, prosta mapa w pamięci może być za słabym narzędziem. Wtedy potrzebujemy strategii: TTL, unieważniania wyniku, limitu rozmiaru, metryk, ochrony przed cache stampede i czasem zewnętrznego cache, takiego jak Redis albo Memcached.

Przypadek PHP: request żyje krótko

Warto osobno wspomnieć o PHP, bo to dobry przykład języka, w którym odpowiedź "czy warto memoizować na backendzie?" brzmi: to zależy od modelu uruchamiania aplikacji.

W klasycznym PHP, na przykład przy PHP-FPM (Magento, Wordpress), każdy request ma własny cykl życia aplikacji. Proces FPM może później obsłużyć kolejne żądanie, a mechanizmy takie jak OPcache albo APCu mogą przechowywać dane poza cyklem życia pojedynczego requestu. Stan obiektów utworzonych przez aplikację dla konkretnego requestu nie jest jednak zachowywany między żądaniami.

To oznacza, że taki cache:

może pomóc tylko w ramach jednego requestu. Jeśli w jednym przebiegu aplikacji kilka warstw systemu pyta o to samo uprawnienie, oszczędzamy powtarzanie pracy. Po zakończeniu requestu ta pamięć znika.

Czyli nieprawdą jest, że memoizacja w PHP nigdy nie ma sensu. Prawdą jest natomiast, że w klasycznym modelu PHP memoizacja w pamięci procesu nie jest cache'em między requestami.

Jeśli chcemy przyspieszyć kolejne żądania, potrzebujemy innego mechanizmu: Redis, Memcached, APCu, Symfony Cache, cache HTTP, cache bazy danych albo materializowanych danych po stronie infrastruktury. Warto przy tym pamiętać, że APCu jest lokalne dla procesu lub serwera, więc nie rozwiązuje tych samych problemów co współdzielony cache w rodzaju Redisa w środowisku z wieloma instancjami aplikacji.

Sytuacja zmienia się przy długowiecznych procesach: workerach kolejek, aplikacjach CLI, RoadRunnerze, Swoole czy Laravel Octane. Tam pamięć może żyć dłużej, więc memoizacja zaczyna przypominać cache między operacjami. Zyskujemy więcej możliwości, ale też więcej ryzyk: stare dane, wycieki pamięci i przypadkowe współdzielenie stanu.

Memoizacja w React, Vue i Angularze

Skoro memoizacja jest ogólną ideą, zobaczmy, jak różne frameworki frontendowe rozwiązują podobny problem.

React: jawna optymalizacja

W React najczęściej sami wskazujemy, co ma zostać zapamiętane i od czego to zależy.

useMemo zapamiętuje wynik obliczenia, dopóki nie zmieni się lista zależności. Nie należy jednak opierać na tym poprawności programu. React traktuje useMemo jako optymalizację wydajnościową i w pewnych sytuacjach może odrzucić zapamiętaną wartość. useCallback działa podobnie, ale zapamiętuje referencję do funkcji. memo pozwala ograniczyć ponowne renderowanie komponentu, jeśli jego propsy się nie zmieniły. Domyślnie React porównuje propsy przez Object.is, więc nowy obiekt, tablica albo funkcja utworzona podczas renderowania nadal będą traktowane jako zmiana referencji.

To daje dużą kontrolę, ale wymaga ostrożności. Jeśli lista zależności jest błędna, wynik może być nieaktualny. Jeśli memoizujemy wszystko na zapas, kod staje się trudniejszy, a zysk może być żaden.

Vue: cache jako część reaktywności

W Vue naturalnym miejscem dla takich obliczeń jest computed.

Vue samo śledzi reaktywne zależności użyte wewnątrz computed. Jeśli products się nie zmieni, wynik zostanie zwrócony z cache. Jeśli zależność się zmieni, Vue przeliczy wartość przy kolejnym odczycie.

W praktyce oznacza to mniej ręcznego zarządzania zależnościami niż w React. Vue ma też v-memo, które pozwala pominąć aktualizację fragmentu drzewa szablonu, ale dokumentacja traktuje ten mechanizm jako mikrooptymalizację do rzadkich, wydajnościowo krytycznych przypadków.

Angular: computed signals i pure pipes

W nowszym Angularze podobną rolę pełnią computed signals.

Angular zapamiętuje wynik computed i unieważnia go, gdy zmieni się zależny sygnał. computed jest też leniwe (lazy), więc obliczenie nie musi wykonać się od razu po deklaracji, tylko dopiero wtedy, gdy ktoś odczyta wynik.

Angular ma również pure pipes. Domyślnie pipe nie musi wykonywać transformacji ponownie, jeśli wejście się nie zmieniło. To nie jest identyczne z ręcznie napisaną memoizacją wielu wyników w mapie, ale rozwiązuje podobny problem w template'ach: nie wykonuj transformacji drugi raz, jeśli dane wejściowe pozostają takie same.

Co z tego wynika?

React częściej każe nam ręcznie opisać, co i kiedy ma zostać zapamiętane. Vue i Angular częściej opierają się na reaktywności, która sama śledzi zależności.

Nie znaczy to, że jeden model jest zawsze lepszy. React daje kontrolę, Vue i Angular zdejmują część pracy z programisty. We wszystkich przypadkach chodzi jednak o tę samą zasadę: nie wykonuj ponownie pracy, jeśli dane wejściowe się nie zmieniły i wynik nadal jest ważny.

Co może pójść nie tak?

Memoizacja jest kusząca, bo łatwo ją pokazać na krótkim przykładzie. Niestety, w realnym kodzie cache rzadko pozostaje tylko małą mapą z trzema wpisami.

Pierwszy problem to pamięć. Jeśli cache nie ma limitu, może rosnąć bez końca. W aplikacji frontendowej skończy się to większym zużyciem pamięci w przeglądarce. W backendzie może doprowadzić do problemów z procesem, workerem albo całą instancją aplikacji.

Drugi problem to aktualność danych. Wynik może być poprawny o 10:00 i błędny o 10:05. Dotyczy to zwłaszcza danych z bazy, uprawnień, cen, stanów magazynowych i konfiguracji.

Trzeci problem to bezpieczeństwo. Jeśli klucz cache nie uwzględnia użytkownika, tenanta, języka, waluty albo kontekstu uprawnień, możemy zwrócić dane przygotowane dla kogoś innego. To nie jest już drobna optymalizacja, tylko błąd bezpieczeństwa.

Czwarty problem to koszt samej memoizacji. Przy tanich funkcjach obsługa cache może być droższa niż ponowne wykonanie obliczenia. Jeśli funkcja robi proste dodawanie, memoizacja jest jak stawianie magazynu na jedną śrubkę.

I wreszcie: memoizacja może utrudnić debugowanie. Program nie tylko wykonuje kod, ale też niesie ze sobą stan poprzednich wywołań. Gdy ten stan jest ukryty, znalezienie błędu potrafi być dużo trudniejsze.

Kiedy warto memoizować?

Według mnie memoizacja ma sens wtedy, gdy potrafimy odpowiedzieć twierdząco na kilka pytań.

Czy funkcja jest często wywoływana z tymi samymi danymi? Czy obliczenie jest kosztowne? Czy wynik jest przewidywalny? Czy koszt pamięci jest akceptowalny? Czy wiemy, kiedy wynik traci ważność? Czy potrafimy zmierzyć zysk?

Jeśli odpowiedzi są konkretne, memoizacja może być bardzo dobrym narzędziem.

Przykłady dobrych kandydatów:

  • kosztowne obliczenia matematyczne,

  • parsowanie lub kompilowanie schematów,

  • transformacje konfiguracji,

  • wartości pochodne w systemie reaktywnym,

  • powtarzane sprawdzanie uprawnień w ramach jednego requestu,

  • przeliczanie danych referencyjnych,

  • filtrowanie dużych list, jeśli dane wejściowe często się powtarzają.

Warto jednak zauważyć, że memoizacja nie zastępuje lepszego algorytmu. Jeśli problem polega na tym, że wykonujemy złe zapytanie SQL albo pobieramy z API dziesięć razy więcej danych niż trzeba, cache może tylko zamaskować problem.

Kiedy lepiej odpuścić?

Lepiej odpuścić, gdy funkcja jest tania, argumenty prawie zawsze są inne albo wynik zależy od czasu, losowości, bazy danych, zewnętrznego API czy aktualnego użytkownika, a ta zależność nie jest jawnie uwzględniona w kluczu i strategii unieważniania wyniku.

Ostrożność jest też potrzebna wtedy, gdy nie umiemy bezpiecznie zbudować klucza. Jeśli nie wiemy, co dokładnie odróżnia jeden wynik od drugiego, cache będzie opierał się na zaufaniu. A to słaba strategia dla optymalizacji.

Nie memoizowałbym też "na wszelki wypadek". To częsty błąd w React, ale nie tylko tam. Ktoś widzi useMemo, uznaje, że więcej memoizacji oznacza więcej wydajności, i po kilku tygodniach projekt jest pełen zależności, których nikt nie chce dotykać. Zysk jest niewidoczny, koszt czytelności zostaje.

W idealnym świecie optymalizację zaczynamy od pomiaru. W praktyce nie zawsze mamy pełny profil wydajnościowy, ale nadal warto postawić sobie prostą barierę: czy potrafię wskazać powtarzalną, kosztowną pracę, którą memoizacja rzeczywiście eliminuje?

Podsumowanie

Memoizacja nie jest techniką stricte frontendową czy backendową. Jest sposobem myślenia o powtarzalnej pracy. Jeśli program drugi raz wykonuje to samo kosztowne obliczenie dla tych samych danych, możemy rozważyć zapamiętanie wyniku.

Nie zaczynałbym jednak od memoizacji. Najpierw warto zrozumieć problem, potem znaleźć powtarzalne obliczenie, a dopiero później zdecydować, czy cache wyniku rzeczywiście uprości sytuację.

W React zobaczymy useMemo, useCallback i memo. W Vue będzie to głównie computed, czasem v-memo. W Angularze computed signals i pure pipes. Na backendzie może to być mapa w procesie, cache w ramach requestu, Redis albo inny mechanizm współdzielony. Narzędzia są różne, ale pytanie pozostaje to samo.

Co jest kluczem? Co jest wartością? Kiedy wynik traci ważność? Ile jesteśmy gotowi za to zapłacić (pamięcią oczywiście)?

Jeśli nie potrafimy odpowiedzieć na te pytania, memoizacja prawdopodobnie nie jest jeszcze rozwiązaniem. Jeśli potrafimy, może być jedną z najprostszych i najbardziej eleganckich optymalizacji, jakie mamy pod ręką.

Udostępnij ten artykuł:

Komentarze (0)

    Jeszcze nikt nic nie napisał, ale to znaczy że... możesz być pierwszy/pierwsza.

Powiązane treści

Jeżeli ten artykuł Cię zainteresował sprawdź inne materiały powiązane z nim tematycznie. Poniżej znajdziesz artykuły i odcinki podcastów mojego autorstwa oraz polecane przeze mnie książki, które rozszerzają ten temat.

SSR, SSG, SPA czy MPA? by Mateusz Jabłoński
Podcast
31 stycznia 2023

SSR, SSG, SPA czy MPA?

W pierwszym odcinku podcastu PiwnicaIT rozmawiamy o różnych podejściach do tworzenia aplikacji webowych. Poruszamy tematy związane z SPA, SSG, SSR czy MPA w ujęciu webdevelopmentu. Omawiamy nasze doświadczenia w pracy z różnymi bibliotekami i frameworkami dostępnymi na rynku.

Posłuchaj
NextJS i co dalej by Mateusz Jabłoński
Podcast
01 czerwca 2023

NextJS i co dalej

NextJS wprowadził React'a na nowe ścieżki. Śmiało można powiedzieć, że twórcy Next'a zmieniają frontend. O tym rozmawiamy w piątym odcinku podcastu Piwnica IT.

Posłuchaj
Children by trilemedia
Artykuł
28 lipca 2022

Czy warto używać typu FC w React?

React daje nam różne możliwości dodawania specyficznych typów do różnych jego elementów. Możemy to osiągnąć na kilka sposobów. Dziś chciałbym się skupić na typowaniu statycznym i dynamicznym oraz typie FC, który spotkamy w React.

Czytaj więcej

Zapisz się do newslettera

Bądź na bieżąco z nowymi materiałami, ćwiczeniami i ciekawostkami ze świata IT. Dołącz do mnie.