Zastępowanie ścieżki aktywnej w kodzie JavaScript aplikacji elementem WebAssembly

Jest zawsze szybki

poprzednich artykułach omawiałem, jak WebAssembly umożliwia przeniesienie do sieci internetowej ekosystemu bibliotek C/C++. Jedną z aplikacji, która intensywnie korzysta z bibliotek C/C++, jest squoosh, nasza aplikacja internetowa, która umożliwia kompresowanie obrazów za pomocą różnych kodeków skompilowanych z języka C++ do WebAssembly.

WebAssembly to niskopoziomowa maszyna wirtualna, która uruchamia kod bajtowy przechowywany w plikach .wasm. Kod ten jest typowany i skonstruowany w taki sposób, że można go skompilować i zoptymalizować pod kątem systemu hosta znacznie szybciej niż JavaScript. WebAssembly zapewnia środowisko do uruchamiania kodu, który od samego początku był tworzony z myślą o piaskownicy i osadzeniu.

Z mojego doświadczenia wynika, że większość problemów z wydajnością w internecie jest spowodowana wymuszonym układem i nadmiernym odświeżaniem, ale od czasu do czasu aplikacja musi wykonać zadanie wymagające dużych zasobów obliczeniowych, które zajmuje dużo czasu. Pomoże w tym WebAssembly.

Ścieżka często używana

W squoosh napisaliśmy funkcję JavaScript, która obraca bufor obrazu o wielokrotność 90 stopni. Chociaż OffscreenCanvas byłby idealny do tego celu, nie jest obsługiwany we wszystkich przeglądarkach, na których nam zależy, i jest trochę niestabilny w Chrome.

Ta funkcja przetwarza każdy piksel obrazu wejściowego i kopiuje go w innej pozycji na obrazie wyjściowym, aby uzyskać efekt obrotu. W przypadku obrazu o wymiarach 4094 x 4096 pikseli (16 megapikseli) trzeba by wykonać ponad 16 milionów iteracji wewnętrznego bloku kodu, który nazywamy „ścieżką szybkiego dostępu”. Pomimo tak dużej liczby iteracji 2 z 3 przetestowanych przez nas przeglądarek wykonało zadanie w mniej niż 2 sekundy. Dopuszczalny czas trwania tego typu interakcji.

for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    const in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

Jedna przeglądarka potrzebuje jednak ponad 8 sekund. Sposób optymalizacji JavaScriptu przez przeglądarki jest bardzo skomplikowany, a różne mechanizmy optymalizują strony pod kątem różnych elementów. Niektóre są optymalizowane pod kątem nieprzetworzonego wykonania, a inne pod kątem interakcji z DOM. W tym przypadku w jednej przeglądarce wystąpiła nieoptymalizowana ścieżka.

Z drugiej strony WebAssembly jest zbudowany wyłącznie pod kątem szybkości wykonania. Jeśli więc chcemy uzyskać szybką i przewidywalną wydajność takiego kodu we wszystkich przeglądarkach, WebAssembly może nam w tym pomóc.

WebAssembly zapewnia przewidywalną wydajność

Zasadniczo JavaScript i WebAssembly mogą osiągnąć tę samą maksymalną wydajność. Jednak w przypadku JavaScriptu skuteczność może zostać osiągnięta tylko na „szybkiej ścieżce”, a jej utrzymanie na tej „szybkiej ścieżce” jest trudne. Jedną z głównych zalet WebAssembly jest przewidywalna wydajność, nawet w różnych przeglądarkach. Rygorystyczne pisanie i architektura niskiego poziomu sprawiają, że kompilator może wzmocnić gwarancje, tak by kod WebAssembly trzeba było optymalizować tylko raz i zawsze korzystać z „szybkiej ścieżki”.

Pisanie kodu dla WebAssembly

Wcześniej pobieraliśmy biblioteki C/C++ i kompilowaliśmy je do WebAssembly, aby móc korzystać z ich funkcji w internecie. Nie ingerowaliśmy w kod bibliotek, tylko napisaliśmy niewielką ilość kodu C/C++, aby połączyć przeglądarkę z biblioteką. Tym razem motywacja jest inna: chcemy napisać coś od podstaw z uwzględnieniem standardu WebAssembly, aby móc korzystać z jego zalet.

Architektura WebAssembly

Podczas pisania dla WebAssembly warto dowiedzieć się więcej o tym, czym jest WebAssembly.

Aby zacytować WebAssembly.org:

Gdy skompilujesz fragment kodu C lub Rust do WebAssembly, otrzymasz plik .wasm zawierający deklarację modułu. Ta deklaracja składa się z listy „importów”, których moduł oczekuje od swojego środowiska, listy eksportów, które moduł udostępnia hostowi (funkcje, stałe, fragmenty pamięci) oraz oczywiście rzeczywistych instrukcji binarnych dla zawartych w nim funkcji.

Coś, z czego nie zdałam sobie sprawy, dopóki się tego nie zastanowiłam: stos, dzięki któremu WebAssembly jest „opartą na stosami maszyną wirtualną”, nie jest przechowywany w tej porcji pamięci, której używają moduły WebAssembly. Stos jest całkowicie wewnętrzny dla maszyny wirtualnej i niedostępny dla deweloperów stron internetowych (z wyjątkiem Narzędzi deweloperskich). Dzięki temu można pisać moduły WebAssembly, które nie potrzebują żadnej dodatkowej pamięci i korzystają tylko ze stosu wewnętrznego maszyny wirtualnej.

W naszym przypadku musimy użyć dodatkowej pamięci, aby umożliwić dowolny dostęp do pikseli obrazu i wygenerować jego wersję po przekształceniu. Do tego służy WebAssembly.Memory.

Zarządzanie pamięcią

Po użyciu dodatkowej pamięci zwykle trzeba jakoś nią zarządzać. Które części pamięci są używane? Które z nich są bezpłatne? W języku C np. funkcja malloc(n) znajduje miejsce w pamięci o długości n ciągłych bajtów. Tego rodzaju funkcje są również nazywane „alokatorami”. Oczywiście w Twoim module WebAssembly musi być zawarte używane w nim rozwiązanie alokacji, które zwiększy rozmiar pliku. Wielkość i wydajność tych funkcji zarządzania pamięcią mogą się znacznie różnić w zależności od użytego algorytmu, dlatego wiele języków udostępnia kilka implementacji do wyboru (np. „dmalloc”, „emmalloc”, „wee_alloc”).

W naszym przypadku przed uruchomieniem modułu WebAssembly znamy wymiary obrazu wejściowego (a zatem wymiary obrazu wyjściowego). W tym przypadku widzieliśmy pewną możliwość: tradycyjnie przekazujemy bufor RGBA obrazu wejściowego jako parametr do funkcji WebAssembly i zwracamy obracany obraz jako wartość zwracana. Aby wygenerować tę wartość zwracaną, musimy użyć alokatora. Ponieważ jednak znamy łączną ilość potrzebnej pamięci (jest ona równa podwójnej wielkości obrazu wejściowego, raz dla wejścia i raz dla wyjścia), możemy umieścić obraz wejściowy w pamięci WebAssembly za pomocą JavaScript, uruchomić moduł WebAssembly, aby wygenerować drugi, obrócony obraz, a potem odczytać wynik za pomocą JavaScripta. Możemy to zrobić bez korzystania z żadnego zarządzania pamięcią.

Wybór

Jeśli spojrzysz na pierwotną funkcję JavaScript, którą chcemy zastosować w WebAssembly, możesz zauważyć, że jest to kod czysto obliczowy bez interfejsów API związanych z JavaScriptem. Dlatego przeniesienie kodu na dowolny język powinno być dość proste. Oceniliśmy 3 różne języki, które kompilują się z WebAssembly: C/C++, Rust i AssemblyScript. Jedynym pytaniem, na które musimy odpowiedzieć w przypadku każdego języka, jest: „Jak uzyskać dostęp do surowej pamięci bez używania funkcji zarządzania pamięcią?”

C i Emscripten

Emscripten to kompilator C dla celu WebAssembly. Emscripten ma zastępować znane kompilatory C, takie jak GCC czy clang, i jest w większości przypadków zgodny z flagami. Jest to kluczowa część misji Emscripten, ponieważ chce maksymalnie ułatwić kompilację istniejącego kodu w C i C++ w WebAssembly.

Dostęp do pamięci w postaci surowych danych jest wbudowany w język C, a wskaźniki istnieją właśnie z tego powodu:

uint8_t* ptr = (uint8_t*)0x124;
ptr[0] = 0xFF;

Tutaj zamieniamy liczbę 0x124 na wskaźnik do bez znaku 8-bitowych liczb całkowitych (lub bajtów). Spowoduje to przekształcenie zmiennej ptr w tablicę rozpoczynającą się od adresu pamięci 0x124, której możemy używać jak każdej innej tablicy. Dzięki temu możemy uzyskiwać dostęp do poszczególnych bajtów w celu ich odczytu i zapisu. W naszym przypadku mamy do czynienia z buforem RGBA obrazu, który chcemy zmienić, aby uzyskać obrót. Aby przesunąć piksel, musimy przesunąć 4 kolejne bajty naraz (po jednym bajcie dla każdego kanału: R, G, B i A). Aby to sobie ułatwić, możemy utworzyć tablicę 32-bitowych liczb całkowitych bez znaku. Zgodnie z konwencją obraz wejściowy rozpoczyna się na adresie 4, a obraz wyjściowy – bezpośrednio po zakończeniu obrazu wejściowego:

int bpp = 4;
int imageSize = inputWidth * inputHeight * bpp;
uint32_t* inBuffer = (uint32_t*) 4;
uint32_t* outBuffer = (uint32_t*) (inBuffer + imageSize);

for (int d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (int d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    int in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

Po przeportowaniu całej funkcji JavaScript na C możemy skompilować plik C za pomocą emcc:

$ emcc -O3 -s ALLOW_MEMORY_GROWTH=1 -o c.js rotate.c

Jak zawsze emscripten generuje plik kodu łączącego o nazwie c.js i moduł wasm o nazwie c.wasm. Zauważ, że moduł Wasm po rozpakowaniu gzip zajmuje tylko ok.260 bajtów, a kod glue zajmuje około 3,5 KB po gzip. Po pewnym czasie udało nam się pozbyć kodu łączącego i utworzyć instancje modułów WebAssembly za pomocą standardowych interfejsów API. Jest to często możliwe w przypadku Emscripten, o ile nie używasz niczego z biblioteki standardowej C.

Rust

Rust to nowy, nowoczesny język programowania z bogatym systemem typów, bez czasu wykonywania i modelu własności, który gwarantuje bezpieczeństwo pamięci i wątków. Rust obsługuje też WebAssembly jako podstawową funkcję, a zespół Rust stworzył wiele doskonałych narzędzi dla ekosystemu WebAssembly.

Jednym z tych narzędzi jest wasm-pack, opracowane przez grupę roboczą rustwasm. wasm-packprzekształca kod w moduł gotowy do użycia w witrynie, który działa od razu z pakietarzami takimi jak webpack. wasm-pack to bardzo wygodne rozwiązanie, ale obecnie działa tylko w przypadku platformy Rust. Grupa rozważa dodanie obsługi innych języków docelowych dla WebAssembly.

W Rust wycinki są tym, czym tablice są w C. I tak jak w języku C, musimy utworzyć przedziały, które używają naszych adresów początkowych. Jest to sprzeczne z modelem bezpieczeństwa pamięci, który narzuca Rust, więc aby uzyskać pożądany efekt, musimy użyć słowa kluczowego unsafe, co pozwoli nam napisać kod, który nie jest zgodny z tym modelem.

let imageSize = (inputWidth * inputHeight) as usize;
let inBuffer: &mut [u32];
let outBuffer: &mut [u32];
unsafe {
    inBuffer = slice::from_raw_parts_mut::<u32>(4 as *mut u32, imageSize);
    outBuffer = slice::from_raw_parts_mut::<u32>((imageSize * 4 + 4) as *mut u32, imageSize);
}

for d2 in 0..d2Limit {
    for d1 in 0..d1Limit {
    let in_idx = (d1Start + d1 * d1Advance) * d1Multiplier + (d2Start + d2 * d2Advance) * d2Multiplier;
    outBuffer[i as usize] = inBuffer[in_idx as usize];
    i += 1;
    }
}

Kompilowanie plików Rust za pomocą

$ wasm-pack build

daje moduł wasm o rozmiarze 7,6 KB z około 100 bajtami kodu klejącego (oba po kompresji gzip).

AssemblyScript

AssemblyScript to stosunkowo nowy projekt, którego celem jest skompilowanie TypeScriptu na WebAssembly. Warto jednak pamiętać, że nie wykorzystuje on tylko żadnego skryptu TypeScript. AssemblyScript używa tej samej składni co TypeScript, ale zastępuje standardową bibliotekę własną. Ich standardowa biblioteka emuluje możliwości WebAssembly. Oznacza to, że nie możesz po prostu skompilować dowolnego kodu TypeScript na WebAssembly, ale nie musisz uczyć się nowego języka programowania, aby pisać w WebAssembly.

    for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
      for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
        let in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
        store<u32>(offset + i * 4 + 4, load<u32>(in_idx * 4 + 4));
        i += 1;
      }
    }

Biorąc pod uwagę małą powierzchnię naszej funkcji rotate(), przeniesienie tego kodu do AssemblyScript było dość łatwe. Funkcje load<T>(ptr: usize) i store<T>(ptr: usize, value: T) są udostępniane przez AssemblyScript, aby umożliwić dostęp do nieprzetworzonej pamięci. Aby skompilować nasz plik AssemblyScript, wystarczy zainstalować pakiet AssemblyScript/assemblyscript npm i uruchomić

$ asc rotate.ts -b assemblyscript.wasm --validate -O3

AssemblyScript dostarczy nam moduł wasm o długości około 300 bajtów i brak kodu klejącego. Moduł działa tylko z interfejsami API WebAssembly.

Analiza sądowa kodu WebAssembly

W porównaniu z 2 innymi językami 7,6 KB Rust jest zaskakująco duży. W ekosystemie WebAssembly jest kilka narzędzi, które mogą Ci pomóc analizować pliki WebAssembly (niezależnie od języka, w którym zostały utworzone) i przekazać Ci informacje o tym, co się dzieje, oraz poprawić sytuację.

Twiggy

Twiggy to kolejne narzędzie stworzone przez zespół WebAssembly firmy Rust, które wyodrębnia sporą ilość szczegółowych danych z modułu WebAssembly. To narzędzie nie jest typowe dla środowiska Rust i umożliwia przeglądanie takich elementów jak wykres wywołań modułu, określanie nieużywanych lub nadmiarowych sekcji oraz informacje o tym, które z nich mają udział w łącznym rozmiarze pliku modułu. To drugie można zrobić za pomocą polecenia top w Twiggy:

$ twiggy top rotate_bg.wasm
Zrzut ekranu z instalacją Twiggy

W tym przypadku widzimy, że większość rozmiaru pliku pochodzi z alokatora. To było zaskakujące, ponieważ nasz kod nie korzysta z dynamicznych alokacji. Kolejnym ważnym czynnikiem jest podsekcja „Nazwy funkcji”.

wasm-strip

wasm-strip to narzędzie z pakietu WebAssembly Binary Toolkit (w skrócie wabt). Zawiera on narzędzia umożliwiające badanie modułów WebAssembly i manipulowanie nimi. wasm2wat to program do dezasemblowania, który przekształca binarny moduł Wasm w format zrozumiały dla człowieka. Wabt zawiera też tag wat2wasm, który umożliwia przekształcenie zrozumiałego dla człowieka formatu z powrotem w binarny moduł Wasm. Mimo że korzystaliśmy z tych 2 uzupełniających się narzędzi do badania plików WebAssembly, okazało się, że najbardziej przydatnym narzędziem jest wasm-strip. wasm-strip usuwa niepotrzebne sekcje i metadane z modułu WebAssembly:

$ wasm-strip rotate_bg.wasm

Zmniejsza to rozmiar pliku modułu Rust z 7,5 KB do 6,6 KB (po zakończeniu programu gzip).

wasm-opt

wasm-opt to narzędzie Binaryen. Zaczyna od modułu WebAssembly i próbuje go zoptymalizować pod kątem rozmiaru i wydajności na podstawie tylko kodu bajtowego. Niektóre narzędzia, takie jak Emscripten, już korzystają z tego narzędzia, ale inne nie. Zwykle warto spróbować zaoszczędzić dodatkowe bajty, korzystając z tych narzędzi.

wasm-opt -O3 -o rotate_bg_opt.wasm rotate_bg.wasm

Dzięki wasm-opt możemy jeszcze zmniejszyć liczbę bajtów, co po zastosowaniu kompresji gzip da w sumie 6,2 KB.

#![no_std]

Po konsultacjach i przeprowadzeniu badań napisaliśmy kod Rust bez użycia standardowej biblioteki Rust, korzystając z funkcji #![no_std]. Spowoduje to też całkowite wyłączenie dynamicznego przydzielania pamięci i usunięcie kodu alokatora z naszego modułu. Kompilowanie tego pliku Rust za pomocą

$ rustc --target=wasm32-unknown-unknown -C opt-level=3 -o rust.wasm rotate.rs

wasm-opt, wasm-strip i gzip wygenerowały moduł wasm o rozmiarze 1,6 KB. Chociaż jest nadal większy niż moduły wygenerowane przez C i AssemblyScript, jest na tyle mały, że można go uznać za lekki.

Wyniki

Zanim wyciągniemy wnioski na podstawie samego rozmiaru pliku, wyjaśnijmy, że celem tej podróży było zoptymalizowanie wydajności, a nie rozmiaru pliku. Jak mierzymy skuteczność i jakie są jej wyniki?

Analiza porównawcza

Mimo że WebAssembly jest formatem kodu bajtowego niskiego poziomu, nadal musi być wysyłany przez kompilator, aby wygenerować kod maszynowy dla hosta. Podobnie jak w przypadku JavaScriptu, kompilator działa na kilku etapach. Krótko: pierwszy etap działa dużo szybciej, ale zazwyczaj wolniej generuje się kod. Gdy moduł zacznie działać, przeglądarka sprawdza, które części są często używane, i przesyła je do bardziej optymalizującego, ale wolniejszego kompilatora.

Nasz przypadek użycia jest interesujący, ponieważ kod służący do obracania obrazu będzie używany raz lub może dwa razy. Dlatego w większości przypadków nigdy nie będziemy korzystać z zalet kompilatora optymalizacji. Należy o tym pamiętać podczas porównywania wyników. Uruchomienie naszych modułów WebAssembly 10 tysięcy razy w pętli dałoby nierealistyczne wyniki. Aby uzyskać realistyczne liczby, powinniśmy uruchomić moduł raz i podjąć decyzje na podstawie liczb z tego pojedynczego uruchomienia.

Porównanie skuteczności

Porównanie szybkości w poszczególnych językach
Porównanie szybkości w poszczególnych przeglądarkach

Te 2 wykresy to różne widoki tych samych danych. Na pierwszym wykresie porównujemy dane według przeglądarki, a na drugim – według języka. Zwróć uwagę, że wybrałem skalę logarytmiczną. Ważne jest też to, że wszystkie testy porównawcze zostały przeprowadzone przy użyciu tego samego 16-megapikselowego obrazu testowego i tego samego hosta, z wyjątkiem jednej przeglądarki, której nie można było uruchomić na tym samym komputerze.

Bez zbytniej analizy tych wykresów widać, że rozwiązaliśmy pierwotny problem z wydajnością: wszystkie moduły WebAssembly działają w czasie ok. 500 ms lub mniej. Potwierdza to to, co zostało powiedziane na początku: WebAssembly zapewnia przewidywalną wydajność. Niezależnie od wybranego języka różnice między przeglądarkami i językami są minimalne. Dokładnie: odchylenie standardowe JavaScriptu we wszystkich przeglądarkach wynosi około 400 ms, a odchylenie standardowe wszystkich modułów WebAssembly we wszystkich przeglądarkach wynosi około 80 ms.

Sposób stosowania

Innym wskaźnikiem jest ilość pracy, jaką musieliśmy włożyć w stworzenie i zintegrowanie naszego modułu WebAssembly z squoosh. Trudno jest przypisać liczbową wartość do wysiłku, więc nie będę tworzyć żadnych wykresów, ale chcę zwrócić uwagę na kilka kwestii:

Obsługa AssemblyScript przebiegała bezproblemowo. Pozwala ona nie tylko używać TypeScript do pisania kodu WebAssembly, co ułatwia moim współpracownikom sprawdzanie kodu, ale też generuje moduły WebAssembly bez kodu łączącego, które są bardzo małe i mają przyzwoite osiągi. Narzędzia w ekosystemie TypeScript, takie jak prettier i tslint, powinny działać bez problemów.

Rust w połączeniu z funkcją wasm-pack jest niezwykle wygodne, ale sprawdza się lepiej w większych projektach WebAssembly, w których potrzebne są powiązania i zarządzanie pamięcią. Aby osiągnąć konkurencyjny rozmiar pliku, musieliśmy nieco odstąpić od ścieżki szczęśliwego zakończenia.

Twórcy z C i Emscripten stworzyli bardzo mały i bardzo wydajny moduł WebAssembly, ale bez odwagi, by wkleić kod do kleju i zredukować go do absolutnej potrzeby, całkowity rozmiar (moduł WebAssembly + kod glue) okazał się dość duży.

Podsumowanie

Czego więc użyjesz, jeśli masz ścieżkę JavaScriptu i chcesz, by była ona szybsza lub bardziej spójna z WebAssembly. Jak zawsze w przypadku pytań dotyczących skuteczności, odpowiedź brzmi: „To zależy”. Co wysłaliśmy?

Wykres porównawczy

Porównanie rozmiaru modułu i wydajności różnych używanych języków wskazuje, że najlepszym wyborem jest C lub AssemblyScript. Postanowiliśmy wdrożyć Rust. Istnieje kilka powodów tej decyzji: wszystkie kodeki wykorzystane do tej pory w Squaresh są skompilowane przy użyciu Emscripten. Chcieliśmy poszerzyć naszą wiedzę na temat ekosystemu WebAssembly i użyć innego języka w wersji produkcyjnej. AssemblyScript to dobra alternatywa, ale projekt jest stosunkowo młody, a kompilator nie jest tak dopracowany jak kompilator Rust.

Chociaż różnica w rozmiarze pliku między Rust a pozostałymi językami wygląda na wykresie rozrzutu dość drastycznie, w rzeczywistości nie jest aż tak duża: wczytywanie 500 B lub 1,6 KB nawet w sieci 2G zajmuje mniej niż 1/10 sekundy. Mamy nadzieję, że wkrótce Rust wyrówna różnice w stosunku do rozmiaru modułów.

Pod względem wydajności w czasie wykonywania Rust ma średnio szybsze działanie w różnych przeglądarkach niż AssemblyScript. W przypadku większych projektów Rust będzie prawdopodobnie generować szybszy kod bez konieczności ręcznej optymalizacji. Nie powinno to jednak powstrzymywać przed wybieraniem opcji, która będzie dla Państwa najwygodniejsza.

Podsumowując: AssemblyScript to świetne odkrycie. Umożliwia on programistom tworzenie modułów WebAssembly bez konieczności nauki nowego języka. Zespół AssemblyScript bardzo szybko odpowiada na pytania i aktywnie pracuje nad ulepszaniem narzędzia. W przyszłości będziemy oglądać AssemblyScript.

Aktualizacja: Rdzawy

Po opublikowaniu tego artykułu Nick Fitzgerald z zespołu Rust pokazał nam swoją świetną książkę „Rust Wasm”, która zawiera sekcję o optymalizowaniu rozmiaru plików. Postępowanie zgodnie z tymi instrukcjami (zwłaszcza włączenie optymalizacji w czasie łączenia i ręcznego obsługiwania błędów) pozwoliło nam napisać „normalny” kod Rust i wrócić do używania Cargo (npm Rust) bez zwiększania rozmiaru pliku. Po skompresowaniu za pomocą gzip rozmiar modułu Rust wynosi 370 B. Szczegółowe informacje znajdziesz w problemie, który otworzyłem w Squoosh.

Specjalne podziękowania dla Ashley Williams, Steve’a Klabnika, Nicka FitzgeraldaMaxa Graeya za pomoc w tej podróży.