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

Wszystko jest szybkie, ho,

W poprzednich artykułach omówiłem, jak WebAssembly umożliwia przeniesienie ekosystemu bibliotek C/C++ do internetu. Jedną z aplikacji, która w dużym stopniu korzysta z bibliotek C/C++, jest squoosh. To nasza aplikacja internetowa, która umożliwia kompresowanie obrazów za pomocą różnych kodeków skompilowanych od C++ do WebAssembly.

WebAssembly to maszyna wirtualna niskiego poziomu, która uruchamia kod bajtowy przechowywany w plikach .wasm. Ten kod bajtowy jest silnie określony 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óre od samego początku kojarzy się z mechanizmem piaskownicy i umieszczania.

Z mojego doświadczenia wynika, że większość problemów z wydajnością w internecie jest spowodowana wymuszonym układem obrazu i nadmiernym wyrenderowaniem, ale od czasu do czasu aplikacja musi wykonać kosztowne zadanie, które zajmuje dużo czasu. WebAssembly może Ci w tym pomóc.

Gorąca ścieżka

W Squoosh napisano funkcję JavaScript, która obraca bufor obrazu o 90 stopni. Idealnie nadaje się do tego format OffscreenCanvas, ale nie jest obsługiwany w przeglądarkach, na które kierujemy reklamy, a w Chrome występuje też problem z błędem.

Ta funkcja powtarza się na każdym pikselu obrazu wejściowego i kopiuje do innej pozycji w obrazie wyjściowym, aby uzyskać obrót. Obraz o wymiarach 4094 x 4096 pikseli (16 megapikseli) wymagałby ponad 16 milionów iteracji wewnętrznego bloku kodu. Jest to tzw. „gorąca ścieżka”. Pomimo tak dużej liczby iteracji dwie z trzech testowanych przez nas przeglądarek wykonują zadanie w ciągu maksymalnie 2 sekund. Akceptowalny czas trwania w przypadku 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;
    }
}

Jednak w przypadku jednej przeglądarki trwa dłużej niż 8 sekund. Sposób optymalizacji JavaScriptu przez przeglądarki jest naprawdę skomplikowany, a różne wyszukiwarki optymalizują go pod kątem różnych aspektów. Niektóre są optymalizowane pod kątem nieprzetworzonego, a inne optymalizowane pod kątem interakcji z DOM. W tym przypadku w jednej przeglądarce trafiliśmy na niezoptymalizowaną ścieżkę.

Natomiast komponent WebAssembly opiera się w całości na nieprzetworzonej szybkości wykonywania. Jeśli więc zależy nam na szybkiej i przewidywalnej wydajności kodu takiego jak ten w różnych przeglądarkach, może Ci w tym pomóc WebAssembly.

WebAssembly zapewniający przewidywalną wydajność

Ogólnie rzecz biorąc, skrypty JavaScript i WebAssembly mogą osiągnąć taką samą maksymalną wydajność. W przypadku JavaScriptu wydajność ta jest jednak osiągana tylko na „szybkiej ścieżce” i często trudno jest jej utrzymać. Jedną z głównych zalet WebAssembly jest przewidywalna wydajność nawet w różnych przeglądarkach. Rygorystyczna architektura pisania i architektura niskiego poziomu pozwalają kompilatorowi na uzyskanie większych gwarancji. Dzięki temu kod WebAssembly musi być optymalizowany tylko raz i zawsze będzie korzystać z „szybkiej ścieżki”.

Pisanie w WebAssembly

Wcześniej korzystaliśmy z bibliotek C/C++ i skompilowaliśmy je do formatu WebAssembly, aby wykorzystać ich funkcje w internecie. W rzeczywistości nie dochodziliśmy do zmian w kodzie bibliotek. Napisano po prostu niewielkie ilości kodu w języku C/C++, które tworzy most między przeglądarką a biblioteką. Tym razem nasza motywacja jest inna: chcemy napisać coś od podstaw z myślą o WebAssembly, aby wykorzystać jego zalety.

Architektura WebAssembly

Przy pisaniu dla WebAssembly warto lepiej zrozumieć, czym jest WebAssembly.

Cytując WebAssembly.org:

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

Czego nie wiedziałem, dopóki tego nie zajrzałem: stos, który sprawia, że WebAssembly jest „maszyną wirtualną opartą na stosach”, nie jest przechowywany w części pamięci używanej przez moduły WebAssembly. Stos jest całkowicie wewnętrzny i niedostępny dla programistów stron internetowych (z wyjątkiem narzędzi deweloperskich). Można więc pisać moduły WebAssembly, które nie wymagają dodatkowej pamięci, i używają tylko wewnętrznego stosu maszyny wirtualnej.

W naszym przypadku będziemy musieli użyć dodatkowej pamięci, by umożliwić dowolny dostęp do pikseli obrazu i wygenerować obróconą wersję tego obrazu. Do tego służy usługa WebAssembly.Memory.

Zarządzanie pamięcią

Zwykle gdy używasz dodatkowej pamięci, musisz nią zarządzać. Które części pamięci są wykorzystywane? Które z nich są bezpłatne? Na przykład w języku C znajduje się funkcja malloc(n), która znajduje miejsce w pamięci z kolejnymi bajtami (n). Funkcje tego rodzaju są też nazywane „allokatorami”. Oczywiście implementacja potrzebnego przydziału musi być uwzględniona w module WebAssembly, co zwiększy rozmiar pliku. Rozmiar i wydajność tych funkcji zarządzania pamięcią mogą się znacznie różnić w zależności od zastosowanego algorytmu. Dlatego w wielu językach można wybrać kilka implementacji („dmalloc”, „emmalloc”, „wee_alloc” itd.).

W tym przypadku przed uruchomieniem modułu WebAssembly znamy wymiary obrazu wejściowego (a tym samym wymiary obrazu wyjściowego). Tutaj widzimy możliwość: standardowo przekazywaliśmy bufor RGBA obrazu wejściowego jako parametr do funkcji WebAssembly i zwracaliśmy obrócony obraz jako wartość zwracaną. Aby wygenerować taką wartość zwrotną, musielibyśmy użyć alokatora. Znamy jednak całkowitą ilość potrzebnej pamięci (dwukrotnie większy rozmiar obrazu wejściowego – raz na dane wejściowe i jeden na dane wyjściowe), więc 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ą JavaScriptu. Możemy uciec bez żadnego zarządzania pamięcią.

Pełen wybór

Jeśli spojrzysz na pierwotną funkcję JavaScript, którą chcemy uwzględnić w WebAssembly-fy, zauważ, że jest to kod czysto obliczeniowy bez interfejsów API związanych z JavaScriptem. Przeniesienie kodu do dowolnego języka powinno być proste. Oceniliśmy 3 języki kompilujące się do WebAssembly: C/C++, Rust i AssemblyScript. Jedyne pytanie, na jakie musimy odpowiedzieć, to: jak uzyskać dostęp do surowej pamięci bez używania funkcji zarządzania pamięcią.

C i Emscripten

Emscripten to kompilator C dla środowiska docelowego WebAssembly. Celem Emscripten jest to, aby działać jako stały zamiennik dobrze znanych kompilatorów C, takich jak GCC czy clang, i w większości przypadków zgodnych z flagami. Jest to ważnym elementem misji Emscripten, ponieważ zależy nam na tym, aby kompilowanie istniejących kodów w językach C i C++ na potrzeby WebAssembly było jak najprostsze.

Uzyskiwanie dostępu do nieprzetworzonej pamięci jest bardzo natury C i z tego powodu istnieją wskaźniki:

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

Teraz zmieniamy liczbę 0x124 na wskaźnik do niepodpisanych, 8-bitowych liczb całkowitych (czyli bajtów). W ten sposób zmienna ptr zmienia się w tablicę rozpoczynającą się od adresu pamięci 0x124, której możemy używać jak każda inna tablica, co daje nam dostęp do pojedynczych bajtów na potrzeby odczytu i zapisu. W naszym przypadku szukamy bufora RGBA obrazu, który chcemy zmienić, aby uzyskać obrót. Aby przesunąć piksel, musimy poruszać się po 4 kolejnych bajtach jednocześnie (po 1 bajcie na każdy kanał: R, G, B i A). Aby to ułatwić, możemy utworzyć tablicę 32-bitowych liczb całkowitych bez znaku. Zgodnie z konwencją obraz wejściowy rozpoczyna się od adresu 4, a obraz wyjściowy rozpoczyna się bezpośrednio po jego zakończeniu:

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 przeniesieniu całej funkcji JavaScript do środowiska C możemy skompilować plik C z emcc:

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

Jak zawsze emscripten generuje plik z kodem typu glue o nazwie c.js oraz moduł Wasm o nazwie c.wasm. Pamiętaj, że kod gzip dla modułu Wasm kompresuje się do około 260 bajtów, a kod glue ma około 3,5 KB po gzip. Po pewnym czasie udało nam się zrezygnować z kodu kleju i utworzyć instancję modułów WebAssembly za pomocą vanilla API. W Emscripten jest to często możliwe, o ile nie używasz niczego z biblioteki standardowej C.

Rust

Rust to nowy, nowoczesny język programowania z rozbudowanym systemem typów, brakiem środowiska wykonawczego i modelem własności, który gwarantuje bezpieczeństwo pamięci i bezpieczeństwa wątków. Rust obsługuje też technologię WebAssembly jako główną funkcję, a jej zespół udostępnia wiele świetnych narzędzi w ekosystemie WebAssembly.

Jednym z takich narzędzi jest wasm-pack prowadzona przez grupę roboczą rustwasm. wasm-pack pobiera kod i przekształca go w moduł, który jest gotowy do użytku w internecie i działa z pakietami, takimi jak webpack. Funkcja wasm-pack jest bardzo wygodna, ale obecnie działa tylko w przypadku Rusta. Grupa zastanawia się nad dodaniem obsługi innych języków kierowania na WebAssembly.

W języku Rust wycinki są tablicami w elemencie C. Tak jak w C, musimy utworzyć wycinki z adresem początkowym. Jest to sprzeczne z modelem bezpieczeństwa pamięci egzekwowanym przez Rust. Dlatego, aby zrobić to samodzielnie, musimy użyć słowa kluczowego unsafe, co pozwoli nam napisać kod niezgodny 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

otrzymuje moduł Wasm o rozmiarze 7,6 KB z około 100 bajtami kodu glue (oba kody po gzip).

AssemblyScript

AssemblyScript to dość młody projekt, który ma być kompilatorem TypeScript-to-WebAssembly. Trzeba jednak pamiętać, że nie będzie to samo korzystało z żadnego skryptu TypeScript. AssemblyScript używa tej samej składni co TypeScript, ale przełącza bibliotekę standardową na własną. Standardowe biblioteki modelują możliwości WebAssembly. Oznacza to, że nie można skompilować skryptu TypeScriptu powiązanego z WebAssembly, ale oznacza to, że do pisania WebAssembly nie trzeba uczyć się nowego języka programowania.

    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;
      }
    }

Ze względu na małą powierzchnię typu naszej funkcji rotate() można było dość łatwo przenieść ten kod do AssemblyScript. Funkcje load<T>(ptr: usize) i store<T>(ptr: usize, value: T) są udostępniane przez AssemblyScript, aby uzyskiwać dostęp do nieprzetworzonej pamięci. Aby skompilować nasz plik AssemblyScript, wystarczy zainstalować pakiet npm AssemblyScript/assemblyscript i uruchomić go

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

AssemblyScript dostarczy moduł Wasm o pojemności ok. 300 bajtów i bez kodu klejowego. Moduł współpracuje po prostu z vanilla WebAssembly API.

Kryminologia WebAssembly

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

Twiggy

Twiggy to kolejne narzędzie należące do zespołu WebAssembly, które wyodrębnia przydatne dane z modułu WebAssembly. Narzędzie to nie jest związane z Rustem i umożliwia sprawdzenie takich elementów jak wykres wywołań modułu, określenie nieużywanych lub zbędnych sekcji oraz sprawdzenie, które z nich przyczyniają się do całkowitego rozmiaru pliku modułu. To ostatnie można wykonać za pomocą polecenia top w Twiggy:

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

W tym przypadku widać, że większość rozmiarów plików pochodzi z alloca. Było to zaskakujące, ponieważ nasz kod nie korzysta z alokacji dynamicznej. Innym ważnym czynnikiem jest podsekcja „nazwy funkcji”.

Wasm-Strip

wasm-strip to narzędzie z pakietu WebAssembly Binary Toolkit, czyli wabt. Zawiera on kilka narzędzi, które umożliwiają sprawdzanie 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 formatu zrozumiałego dla człowieka z powrotem w binarny moduł Wasm. Do zbadania plików WebAssembly korzystaliśmy z tych 2 uzupełniających narzędzi, ale okazało się, że najbardziej przydatny będzie wasm-strip. wasm-strip usuwa niepotrzebne sekcje i metadane z modułu WebAssembly:

$ wasm-strip rotate_bg.wasm

Zmniejsza to rozmiar pliku modułu rdzy z 7,5 KB do 6,6 KB (po gzip).

wasm-opt

wasm-opt to narzędzie firmy Binaryen. Wykorzystuje moduł WebAssembly i próbuje go zoptymalizować pod względem rozmiaru i wydajności tylko na podstawie kodu bajtowego. Niektóre z nich, takie jak Emscripten, go używają, a inne nie. Zazwyczaj dobrze jest skorzystać z tych narzędzi, aby zaoszczędzić dodatkowe bajty.

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

Dzięki usłudze wasm-opt można zaoszczędzić jeszcze kilka bajtów, uzyskując w sumie 6,2 KB po gzip.

#![brak_standardu]

Po konsultacjach i badaniach zmieniliśmy nasz kod Rust, nie korzystając ze standardowej biblioteki Rusta, korzystając z funkcji #![no_std]. Spowoduje to też całkowite wyłączenie dynamicznych alokacji pamięci, a tym samym usunięcie kodu przydzielania z modułu. Kompilowanie tego pliku Rust

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

po wasm-opt, wasm-strip i gzip uzyskać moduł Wasm o rozmiarze 1,6 KB. Choć nadal jest większy niż moduły wygenerowane przez języki C i AssemblyScript, jest wystarczająco mały, aby można go uznać za lekki.

Występy

Zanim przejdziemy do wyciągania wniosków na podstawie samego rozmiaru pliku, skupiliśmy się na optymalizacji wydajności, a nie rozmiaru pliku. Jak więc mierzyliśmy skuteczność i jakie były wyniki?

Porównanie

Mimo że WebAssembly jest niskopoziomowym formatem kodu bajtowego, musi on zostać wysłany przez kompilator w celu wygenerowania kodu maszynowego określonego hosta. Tak jak w przypadku JavaScriptu, kompilator działa wieloetapowo. Powiedzieliśmy po prostu: Pierwszy etap przebiega znacznie szybciej podczas kompilowania, ale zwykle generuje wolniejszy kod. Po uruchomieniu modułu przeglądarka sprawdza, które części są często używane, i wysyła je za pomocą bardziej zoptymalizowanego, ale wolniejszego kompilatora.

Nasz przypadek użycia jest interesujący, ponieważ kod do obracania obrazu został użyty raz, może dwa razy. Dlatego w większości przypadków nie będziemy korzystać z zalet optymalizacji kompilatora. Warto o tym pamiętać przy analizie porównawczej. Uruchomienie modułów WebAssembly 10 000 razy w pętli dałoby nierealistyczne wyniki. Aby uzyskać realistyczne wyniki, uruchom moduł raz i podejmuj decyzje na podstawie wyników z jednego uruchomienia.

Porównanie skuteczności

Porównanie szybkości według języka
Porównanie szybkości w każdej przeglądarce

Te dwa wykresy to różne widoki tych samych danych. Na pierwszym wykresie porównujemy przeglądarki według przeglądarki, a na drugim – według języka. Zwróć uwagę, że wybrałem logarytmiczną skalę czasu. Ważne jest również, aby wszystkie testy porównawcze korzystały z tego samego obrazu testowego o rozdzielczości 16 megapikseli i tego samego hosta, z wyjątkiem jednej przeglądarki, której nie można uruchomić na tym samym komputerze.

Bez dokładnej analizy tych wykresów widać, że pierwotny problem z wydajnością został rozwiązany: wszystkie moduły WebAssembly działają w czasie maks. 500 ms. Jest to potwierdzenie tego, co zaproponowaliśmy na początku: WebAssembly zapewnia przewidywalną wydajność. Niezależnie od wybranego języka różnice między przeglądarkami i językami są niewielkie. Mówiąc dokładnie: odchylenie standardowe dla kodu JavaScriptu we wszystkich przeglądarkach wynosi około 400 ms, a odchylenie standardowe dla wszystkich modułów WebAssembly we wszystkich przeglądarkach to około 80 ms.

Sposób stosowania

Kolejnym wskaźnikiem jest nakład pracy, jaki musieliśmy włożyć w utworzenie i integrację modułu WebAssembly z Squoosh. Trudno jest przypisać efektywne wartości liczbowe, dlatego nie utworzę wykresów, ale chcę zwrócić uwagę na kilka rzeczy:

Obsługa AssemblyScript przebiegła bezproblemowo. Pozwala nie tylko pisać kod WebAssembly za pomocą TypeScriptu, co ułatwia współpracownikom sprawdzanie kodu, ale też umożliwia tworzenie bez kleju modułów WebAssembly, które są bardzo małe i wydajne. Narzędzia w ekosystemie TypeScript, takie jak prettier i tslint, prawdopodobnie będą działać po prostu.

Rzutowanie w połączeniu z metodą wasm-pack jest też niezwykle wygodne, ale w większych projektach WebAssembly sprawdza się najlepiej w przypadku większych projektów WebAssembly, w których wymagane są powiązania i konieczne jest zarządzanie pamięcią. Aby uzyskać konkurencyjny rozmiar plików, musieliśmy nieco odejść od szczęścia.

Twórcy C i Emscripten stworzyli bardzo mały i wydajny moduł WebAssembly, ale bez odwagi za pomocą kleju i zmniejszenia go do minimum ich całkowity rozmiar (moduł WebAssembly + kod typu glue) okazuje się dość duży.

Podsumowanie

Jakiego języka należy użyć, jeśli masz istniejącą ścieżkę JavaScriptu i chcesz, aby była ona szybsza lub bardziej spójna z WebAssembly. Jak zawsze w przypadku pytań o skuteczność, odpowiedź brzmi: to zależy. Więc co wysłaliśmy?

Wykres porównawczy

Porównując stosunek rozmiaru modułu do wydajności w różnych używanych przez nas językach, najlepszym wyborem jest prawdopodobnie C lub AssemblyScript. Zdecydowaliśmy się na dostawę Rust. Istnieje wiele powodów takiej decyzji. Wszystkie kodeki udostępnione do tej pory w oprogramowaniu Squoosh zostały skompilowane w formacie Emscripten. Chcieliśmy poszerzyć naszą wiedzę o ekosystemie WebAssembly i użyć innego języka w środowisku produkcyjnym. AssemblyScript jest silną alternatywą, ale projekt jest stosunkowo młody, a kompilator nie jest tak dojrzały jak kompilator Rust.

Choć różnica w rozmiarze pliku Rust i innych języków na wykresie punktowym jest dość znaczna, w rzeczywistości nie jest aż tak duża: Wczytywanie 500 B lub 1,6 KB nawet przy ponad 2G zajmuje mniej niż 1/10 s. Mam nadzieję, że wkrótce uda nam się wyeliminować tę lukę w rozmiarze modułu.

Jeśli chodzi o wydajność środowiska wykonawczego, Rust ma szybsze wyniki w przeglądarkach niż AssemblyScript. Szczególnie w większych projektach Rust ma większą szansę na generowanie szybszego kodu bez konieczności ręcznej optymalizacji kodu. Jednak to nie powinno przeszkodzić Ci w używaniu tego, co jest dla Ciebie najwygodniejsze.

Trzeba pamiętać, że AssemblyScript był wielkim odkryciem. Umożliwia programistom internetowym tworzenie modułów WebAssembly bez konieczności nauki nowego języka. Zespół AssemblyScript bardzo szybko reaguje i pracuje nad ulepszeniem łańcucha narzędzi. W przyszłości będziemy na pewno monitorować AssemblyScript.

Aktualizacja: rdzawy

Po opublikowaniu artykułu Nick Fitzgerald z zespołu Rust wskazał nam świetną książkę Rust Wasm, która zawiera sekcję na temat optymalizowania rozmiaru pliku. Postępując zgodnie z podanymi tam instrukcjami (w szczególności włączenie optymalizacji czasu połączenia i ręcznego działania), mogliśmy napisać „normalny” kod Rust i wrócić do używania Cargo (elementu npm klasy Rust) bez nadmiernego zwiększania rozmiaru pliku. Po użyciu gzip moduł Rust kończy się 370 B. Szczegółowe informacje znajdziesz w wiadomościach PR otwartych w Squoosh.

Specjalne podziękowania za pomoc: Ashley Williams, Steve Klabnik, Nick Fitzgerald i Max Graey za pomoc.