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++ na WebAssembly.

WebAssembly to niskopoziomowa maszyna wirtualna, która uruchamia kod bajtowy przechowywany w plikach .wasm. Ten kod bajtowy ma ściśle określony typ i jest tak sformatowany, że można go skompilować i zoptymalizować pod kątem systemu docelowego znacznie szybciej niż w przypadku 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. W takich przypadkach może Ci pomóc 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 optymalizują wykonywanie kodu, inne optymalizują interakcje 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 dla przewidywalnej wydajności

Zasadniczo JavaScript i WebAssembly mogą osiągnąć tę samą maksymalną wydajność. Jednak w przypadku kodu JavaScript można osiągnąć taką wydajność tylko na „szybkiej ścieżce”, a utrzymanie się na tej ścieżce często jest trudne. Jedną z głównych zalet WebAssembly jest przewidywalna wydajność, nawet w różnych przeglądarkach. Dzięki ścisłemu typowaniu i architekturze niskiego poziomu kompilator może zapewnić większą pewność, dzięki czemu kod WebAssembly musi być optymalizowany tylko raz i zawsze będzie używać „szybkiej ścieżki”.

Pisanie kodu dla WebAssembly

Wcześniej pobieraliśmy biblioteki C/C++ i kompilowaliśmy je do WebAssembly, aby 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.

Cytat z WebAssembly.org:

Gdy skompilujesz fragment kodu C lub Rust na WebAssembly, otrzymasz plik .wasmzawierają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ś, czego nie wiedziałem, dopóki nie zajrzałem do kodu: stos, który sprawia, że WebAssembly jest „maszyną wirtualną opartą na stosie”, nie jest przechowywany w części pamięci używanej przez 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. Funkcje tego typu nazywamy też „funkcjami przydzielającymi”. 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 dostrzegliśmy pewną możliwość: tradycyjnie przekazujemy bufor RGBA obrazu wejściowego jako parametr do funkcji WebAssembly i zwracamy obracany obraz jako wartość zwracaną. 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ę JavaScriptu, którą chcemy przekształcić w WebAssembly, zobaczysz, że jest to kod wyłącznie obliczeniowy bez interfejsów API specyficznych dla JavaScriptu. W związku z tym przeniesienie tego kodu na dowolny język powinno być dość proste. Przetestowaliśmy 3 różne języki, które kompilują się do 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 główny cel Emscripten, który chce maksymalnie ułatwić kompilowanie istniejącego kodu C i C++ na 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). Dzięki temu zmienna ptr staje się tablicą, która zaczyna się od adresu pamięci 0x124. Możemy jej używać jak każdej innej tablicy, co pozwala nam uzyskać dostęp do poszczególnych bajtów na potrzeby 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 ułatwić sobie to zadanie, możemy utworzyć tablicę nieoznaczonych liczb całkowitych 32-bitowych. 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. Pamiętaj, że moduł wasm jest kompresowany do zaledwie około 260 bajtów, a kod łączący do około 3,5 KB. 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 wygodna funkcja, która obecnie działa tylko w przypadku 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 bajtów kodu klejącego (oba po skompresowaniu gzipem).

AssemblyScript

AssemblyScript to stosunkowo nowy projekt, którego celem jest skompilowanie TypeScriptu na WebAssembly. Pamiętaj jednak, że nie każda wersja TypeScript będzie obsługiwana. 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 oznacza to, że musisz się uczyć nowego języka programowania, aby pisać kod 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ę niewielką powierzchnię typu funkcji rotate(), przeniesienie kodu do AssemblyScript było dość łatwe. Funkcje load<T>(ptr: usize)store<T>(ptr: usize, value: T) są udostępniane przez AssemblyScript w celu uzyskania dostępu do 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ą pomóc w analizie plików WebAssembly (niezależnie od języka, w którym zostały utworzone) oraz poinformować o tym, co się dzieje, i pomóc w poprawie sytuacji.

Twiggy

Twiggy to kolejne narzędzie zespołu Rust WebAssembly, które wyodrębnia z modułu WebAssembly wiele przydatnych danych. To narzędzie nie jest przeznaczone tylko do Rusta i pozwala na sprawdzanie takich rzeczy jak wykres wywołań modułu, określanie nieużywanych lub zbędnych sekcji oraz ustalanie, które sekcje wpływają na łączny rozmiar 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 algorytmu alokacji. 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 kilka narzędzi, które umożliwiają sprawdzanie i modyfikowanie modułów WebAssembly. wasm2wat to deasembler, który zamienia binarny moduł wasm na format zrozumiały dla człowieka. Wabt zawiera też wat2wasm, który umożliwia przekształcenie tego formatu zrozumiałego dla człowieka z powrotem w binarny moduł wasm. Używaliśmy tych dwóch uzupełniających się narzędzi do sprawdzania plików WebAssembly, ale okazało się, że najbardziej przydatne jest narzędzie wasm-strip. wasm-strip usuwa niepotrzebne sekcje i metadane z modułu WebAssembly:

$ wasm-strip rotate_bg.wasm

Dzięki temu rozmiar pliku modułu Rust zmniejsza się z 7,5 KB do 6,6 KB (po skompresowaniu gzipem).

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 tego procesu było zoptymalizowanie wydajności, a nie rozmiar pliku. Jak mierzymy skuteczność i jakie są jej wyniki?

Jak przeprowadzić test porównawczy

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. Mówiąc w prosty sposób: pierwszy etap jest znacznie szybszy w kompilowaniu, ale generuje wolniejszy 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. W zdecydowanej większości przypadków nigdy nie będziemy mogli skorzystać z zalet kompilatora optymalizującego. 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, należy 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 zbyt dokładnej analizy tych wykresów widać wyraźnie, że udało nam się rozwiązać nasz pierwotny problem z wydajnością: wszystkie moduły WebAssembly działają w czasie poniżej 500 ms. 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:

AssemblyScript nie wymagało żadnych dodatkowych działań. 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 wasm-pack jest też bardzo wygodny, 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.

C i Emscripten stworzyły bardzo mały i wydajne moduł WebAssembly, ale bez odwagi, aby przejść do kodu łączącego i ograniczyć go do niezbędnego minimum, całkowity rozmiar (moduł WebAssembly + kod łączący) kończy się na tym, że jest dość duży.

Podsumowanie

Jakiego języka użyć, jeśli masz ścieżkę JS, która jest często wykonywana, i chcesz ją przyspieszyć lub dostosować do 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 języków, których używaliśmy, wskazuje, że najlepszym wyborem jest C lub AssemblyScript. Postanowiliśmy wdrożyć Rust. Ta decyzja ma kilka przyczyn: 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 uda się nam zmniejszyć rozmiar modułów w Rust.

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 uniemożliwiać Ci korzystania z tego, co najbardziej Ci odpowiada.

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. Zdecydowanie będziemy śledzić rozwój AssemblyScript w przyszłości.

Aktualizacja: Rdzawy

Po opublikowaniu tego artykułu Nick Fitzgerald z zespołu Rust zasugerował nam świetną książkę Rust Wasm, która zawiera sekcję na temat optymalizacji rozmiaru pliku. 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 moduł Rust zajmuje 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.