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

Jest niezmiernie szybka,

W poprzednich artykułach mówiłem o tym, jak WebAssembly umożliwia przeniesienie ekosystemu biblioteki języka C/C++ do sieci. Jedną z aplikacji, która intensywnie korzysta z bibliotek C/C++, jest squoosh. To nasza aplikacja internetowa umożliwiająca kompresowanie obrazów za pomocą różnych kodeków skompilowanych od 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óre od samego początku było myślane dla piaskownicy i umieszczenia.

Z mojego doświadczenia wynika, że większość problemów z wydajnością stron internetowych jest spowodowana wymuszonym układem i nadmiernym wyrenderowaniem, ale od czasu do czasu aplikacja musi wykonać kosztowne obliczeniowo zadanie, które wymaga dużo czasu. Pomoże w tym WebAssembly.

Gorąca ścieżka

W grze Squoosh napisaliśmy funkcję JavaScript, która obraca bufor obrazu o kilka kątów 90 stopni. Idealnie byłoby, gdyby tag OffscreenCanvas był w tym przypadku idealnym rozwiązaniem, ale nie jest obsługiwany w wybranych przeglądarkach i może powodować błędy w Chrome.

Ta funkcja wykonuje iterację po każdym pikselu obrazu wejściowego i kopiuje go w inne miejsce w obrazie wyjściowym w celu uzyskania obrotu. W przypadku obrazu o wymiarach 4094 na 4096 pikseli (16 megapikseli) potrzeba ponad 16 milionów iteracji wewnętrznego bloku kodu, co nazywamy „gorącą ścieżką”. Pomimo dużej liczby iteracji w 2 z 3 przeglądarek testowaliśmy wykonanie zadania w maksymalnie 2 sekundy. 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 to ponad 8 sekund. Sposób, w jaki przeglądarki optymalizują JavaScript jest naprawdę skomplikowany, a różne wyszukiwarki optymalizują 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 z przeglądarek mamy niezoptymalizowaną ścieżkę.

Z kolei WebAssembly opiera się w całości na nieprzetworzonej szybkości wykonywania. Jeśli chcemy mieć szybką i przewidywalną wydajność w przypadku takiego kodu w różnych przeglądarkach, WebAssembly może pomóc.

WebAssembly zapewnia przewidywalną wydajność

Ogólnie w językach JavaScript i WebAssembly osiągają taką samą szczytową 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 na potrzeby WebAssembly

Wcześniej skompilowaliśmy biblioteki języka C/C++ i skompilowaliśmy je do formatu WebAssembly, aby umożliwić korzystanie z ich funkcji w internecie. Nie dotykaliśmy kodu bibliotek, po prostu napisaliśmy niewielkie ilości kodu w C/C++, aby utworzyć most między przeglądarką a biblioteką. Tym razem motywujemy się inaczej: chcemy napisać coś od zera z myślą o WebAssembly, aby móc wykorzystać jego zalety.

Architektura WebAssembly

Pisząc dla WebAssembly, warto dowiedzieć się czegoś więcej o tym, czym właściwie jest WebAssembly.

Aby zacytować WebAssembly.org:

Gdy skompilujesz fragment kodu C lub Rust do WebAssembly, otrzymasz plik .wasm zawierający deklarację modułu. 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 (funkcji, stałych, fragmentów pamięci) oraz oczywiście rzeczywistych instrukcji binarnych dla funkcji, które się w nim znajdują.

Coś, z czego nie zdałam sobie sprawy, dopóki się tego nie zastanowiłam: stos, który sprawia, że WebAssembly jest „opartą na stosami maszyną wirtualną”, nie jest przechowywany w tej porcji pamięci, której używają moduły WebAssembly. Ten stos jest całkowicie wewnętrzny i niedostępny dla programistów stron internetowych (z wyjątkiem Narzędzi deweloperskich). W związku z tym można pisać moduły WebAssembly, które nie wymagają dodatkowej pamięci i korzystają tylko z wewnętrznego stosu maszyn wirtualnych.

W naszym przypadku będziemy potrzebować więcej pamięci, aby umożliwić dowolny dostęp do pikseli obrazu i wygenerować obróconą wersję tego obrazu. Właśnie do tego służy WebAssembly.Memory.

Zarządzanie pamięcią

Zwykle, gdy wykorzystasz dodatkową pamięć, musisz w jakiś sposób nią zarządzać. Które części pamięci są używane? Które z nich są bezpłatne? Na przykład w C masz funkcję malloc(n), która znajduje przestrzeń w pamięci o liczbie kolejnych bajtów (n). Tego rodzaju funkcje są również nazywane „alokatorami”. Oczywiście implementacja używanego przydziału będzie musiała 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 użytego algorytmu, dlatego wiele języków oferuje wiele implementacji do wyboru (np. „dmalloc”, „emmalloc”, „wee_alloc” itp.).

W naszym przypadku znamy wymiary obrazu wejściowego (a tym samym wymiary obrazu wyjściowego) przed uruchomieniem modułu WebAssembly. Oto możliwość: tradycyjnie przekazujemy bufor RGBA obrazu wejściowego jako parametr do funkcji WebAssembly i zwracamy obrócony obraz jako wartość zwrotną. Aby wygenerować tę wartość zwrotną, musimy użyć funkcji przydzielającej. Znamy jednak łączną ilość potrzebnej pamięci (dwukrotnie większy rozmiar obrazu wejściowego, raz dla danych wejściowych, a drugi raz dla danych wyjściowych), możemy więc umieścić obraz wejściowy w pamięci WebAssembly za pomocą JavaScript, uruchomić moduł WebAssembly, by wygenerować drugi, obrócony obraz, a potem użyć JavaScriptu do odczytania wyniku. Możemy wyjść z zarządzania pamięcią.

Swobodny 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. Jedyne pytanie, jakie musimy uzyskać w przypadku każdego z języków, to: jak uzyskać dostęp do nieprzetworzonej pamięci bez korzystania z funkcji zarządzania pamięcią?

C i Emscripten

Emscripten to kompilator w języku C dla celu WebAssembly. Celem firmy Emscripten jest zamiennik popularnych kompilacji C, takich jak GCC czy clang. W większości jest kompatybilny 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 „surowej pamięci” ma właśnie charakter C i z tego powodu istnieją wskazówki:

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

Zamieniamy tu liczbę 0x124 na wskaźnik do niepodpisanych, 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 chodzi o bufor RGBA obrazu, którego kolejność chcemy zmienić, by uzyskać obrót. Aby przenieść piksel, musimy przenieść 4 kolejne bajty naraz (po 1 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 zaczyna się od adresu 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 przeniesieniu całej funkcji JavaScriptu do języka C możemy skompilować plik C z elementem emcc:

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

Narzędzie emscripten tworzy plik z kodem typu glue o nazwie c.js i moduł Wasm o nazwie c.wasm. Zauważ, że moduł Wasm po rozpakowaniu pliku gzip zajmuje tylko ok.260 bajtów, a kod glue zajmuje około 3,5 KB po gzip. Po jakimś czasie udało nam się zrezygnować z kodu glue i utworzyć instancje modułów WebAssembly za pomocą interfejsów API Vanilla. Często jest to możliwe w przypadku Emscripten, o ile nie korzystasz z niczego ze standardowej biblioteki C.

Rust

Rust to nowy, nowoczesny język programowania z zaawansowanym systemem, bez środowiska wykonawczego i modelem własności, który gwarantuje bezpieczeństwo pamięci i bezpieczeństwo wątków. Rust obsługuje też WebAssembly jako główną funkcję, a zespół Rust przyczynił się do rozwoju ekosystemu WebAssembly przy użyciu doskonałych narzędzi.

Jednym z nich jest wasm-pack organizowane przez grupę roboczą rustwasm. wasm-pack pobiera kod i przekształca go w przyjazny dla sieci moduł, który od razu działa przy użyciu pakietów programów takich jak webpack. wasm-pack to bardzo wygodne rozwiązanie, ale obecnie działa tylko w przypadku platformy Rust. Członkowie grupy rozważają dodanie obsługi innych języków kierowania WebAssembly.

W języku Rust wycinki to tablice w języku C. I podobnie jak w C musisz utworzyć wycinki z adresami początkowymi. Jest to sprzeczne z modelem bezpieczeństwa pamięci, jaki egzekwuje Rust, więc jeśli chodzi o słowa kluczowe unsafe, możemy 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

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

AssemblyScript

AssemblyScript to dość młody projekt, który ma być kompilatorem TypeScript-to-WebAssembly. Warto jednak pamiętać, że nie będzie wykorzystywał tylko żadnego skryptu TypeScript. AssemblyScript używa tej samej składni co TypeScript, ale zastępuje własną bibliotekę standardową. Jej standardowa biblioteka modeluje możliwości WebAssembly. Oznacza to, że nie możesz po prostu skompilować żadnego skryptu TypeScript działającego w ramach WebAssembly, ale oznacza to, że nie musisz uczyć się nowego języka programowania, aby pisać 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 npm AssemblyScript/assemblyscript i uruchomić

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

AssemblyScript udostępni nam moduł Wasm o rozmiarze ok. 300 bajtów i bez kodu typu glue. Moduł po prostu współpracuje z vanilla WebAssembly API.

WebAssembly Forensics

Plik Rusta ma 7,6 KB – jest zaskakująco duży 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 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. Tego drugiego można użyć za pomocą polecenia top Twiggy'ego:

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

W tym przypadku widzimy, że większość rozmiaru pliku pochodzi z allokatora. To zaskoczenie, bo nasz kod nie korzysta z alokacji dynamicznej. Kolejnym ważnym czynnikiem są 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. Używaliśmy tych 2 uzupełniających narzędzi do sprawdzania plików WebAssembly, ale najbardziej przydatny był program wasm-strip. wasm-strip usuwa zbędne 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 od Binaryen. Wykorzystuje moduł WebAssembly i próbuje zoptymalizować go zarówno pod kątem rozmiaru, jak i wydajności tylko na podstawie kodu bajtowego. Niektóre z nich, np. Emscripten, już go obsługują, a inne nie. Zwykle warto spróbować zaoszczędzić trochę dodatkowych bajtów za pomocą tych narzędzi.

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

Dzięki wasm-opt możemy zaoszczędzić jeszcze kilka bajtów, pozostawiając łącznie 6,2 KB po gzip.

#![no_std]

Po konsultacjach i badaniach przeredagowaliśmy kod Rust bez użycia standardowej biblioteki Rusta przy użyciu funkcji #![no_std]. Spowoduje to też całkowite wyłączenie przydziałów pamięci dynamicznej i usunięcie z modułu kodu alokatora. Kompiluję ten plik Rust

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

uzyskano moduł Wasm o rozmiarze 1,6 KB po plikach wasm-opt, wasm-strip i gzip. Chociaż jest większy od modułów wygenerowanych przez C i AssemblyScript, na tyle mały, że można go uznać za lekki.

Wydajność

Zanim będziemy wyciągać pochopne wnioski tylko na podstawie rozmiaru pliku, przeszliśmy do optymalizacji wydajności, a nie rozmiaru pliku. Jak więc mierzyliśmy skuteczność i jakie były rezultaty?

Analiza porównawcza

Mimo że WebAssembly jest niskopoziomowym formatem kodu bajtowego, w celu wygenerowania kodu maszyny zależnego od hosta trzeba go przesłać za pomocą kompilatora. Tak jak JavaScript, kompilator działa w kilku etapach. Krótko: pierwszy etap działa dużo szybciej, ale zazwyczaj wolniej generuje się kod. Po uruchomieniu modułu przeglądarka obserwuje, które części są często używane, i wysyła je za pomocą wolniejszego, optymalizowanego, ale wolniejszego kompilatora.

Nasz przypadek użycia jest interesujący, ponieważ kod do obracania obrazu jest wykorzystywany raz, może dwa razy. Dlatego w większości przypadków nigdy nie będziemy korzystać z zalet kompilatora optymalizacji. Warto o tym pamiętać w przypadku porównań. Uruchomienie modułów WebAssembly 10 tys. razy w pętli dałoby nierealistyczne wyniki. Aby uzyskać realistyczne wyniki, należy uruchomić moduł raz i podjąć decyzje na podstawie danych z jego pojedynczego przebiegu.

Porównanie skuteczności

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

Te dwa wykresy to różne widoki tych samych danych. Na pierwszym wykresie porównujemy dane według przeglądarki, a na drugim – według używanego języka. Zwróć uwagę, że wybrałem logarytmiczną skalę czasu. Ważne jest też, aby wszystkie testy porównawcze korzystały z 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 wskazujemy, ż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, o czym mówiliśmy na początku: WebAssembly zapewnia przewidywalną wydajność. Bez względu na to, który język wybierzemy, różnice między przeglądarkami a językami będą znikome. Dla jasności: odchylenie standardowe JavaScriptu we wszystkich przeglądarkach wynosi ok. 400 ms, a odchylenie standardowe dla wszystkich modułów WebAssembly we wszystkich przeglądarkach wynosi ok. 80 ms.

Sposób stosowania

Innym wskaźnikiem jest nakład pracy, jaki musieliśmy włożyć, aby utworzyć i zintegrować nasz moduł WebAssembly z usługą squoosh. Trudno jest przypisać do wysiłku wartość liczbową, więc nie tworzę żadnych wykresów, ale chcę zwrócić uwagę na kilka rzeczy:

Obsługa AssemblyScript przebiegała bezproblemowo. Umożliwia on nie tylko pisanie WebAssembly przy użyciu języka TypeScript, co ułatwia współpracownikom weryfikację kodu, ale umożliwia również tworzenie bezklejowych modułów WebAssembly, które są bardzo małe i dobrze działają. Narzędzia w ekosystemie TypeScript, takie jak ładniejsze czy tslint, prawdopodobnie po prostu będą działać.

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ą. Musieliśmy trochę odbiegać od „szczęśliwej ścieżki”, aby osiągnąć konkurencyjny rozmiar pliku.

Twórcy C i Emscripten stworzyli bardzo mały i bardzo wydajny moduł WebAssembly, ale bez odwagi, by wkleić kod do kleju i zredukować go do zera, 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

Jeśli porównamy rozmiar modułu na wydajność różnych używanych języków, najlepszym wyborem będzie C lub AssemblyScript. Zdecydowaliśmy się wysłać wersję 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ę o ekosystemie WebAssembly i w produkcji używać innego języka. AssemblyScript to silna alternatywa, ale projekt jest stosunkowo młody, a kompilator nie jest tak dojrzały jak kompilator Rust.

Na wykresie punktowym różnica w rozmiarze pliku między Rust a innymi językami może wydawać się bardzo duża, ale w rzeczywistości nie jest aż tak wielka: wczytanie 500 B czy 1,6 KB nawet w sieci 2G zajmuje mniej niż 1/10 sekundy. Mamy też nadzieję, że wkrótce rozwinie to niedokładność pod względem rozmiaru modułów.

Jeśli chodzi o wydajność środowiska wykonawczego, Rust działa szybciej w przeglądarkach niż AssemblyScript. Szczególnie w przypadku większych projektów Rust będzie bardziej skłonny do tworzenia szybszego kodu bez konieczności ręcznej optymalizacji. Nie powinno to jednak powstrzymywać przed wybieraniem opcji, która będzie dla Państwa najwygodniejsza.

AsemblyScript to dla nas świetne odkrycie. Umożliwia programistom internetowym tworzenie modułów WebAssembly bez konieczności nauki nowego języka. Zespół AssemblyScript jest bardzo responsywny i aktywnie pracuje nad ulepszeniem łańcucha narzędzi. W przyszłości będziemy oglądać AssemblyScript.

Aktualizacja: Rust

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. Zgodnie z podanymi tam instrukcjami (w szczególności przy włączonej optymalizacji czasu połączenia i ręcznej obsłudze włamań) mogliśmy napisać „normalny” kod Rust i wrócić do używania Cargo (npm systemu Rust) bez nadmiernego rozmiaru pliku. Moduł Rust kończy się na 370B po gzip. Aby dowiedzieć się więcej, zerknij na listę rozwijaną przeze mnie w Squaresh.

Specjalne podziękowania dla Ashley Williams, Steve'a Klabnika, Nicka Fitzgeralda i Maxa Graeya za pomoc w tej podróży.