Jest zawsze szybki
W poprzednich artykułach omawiałem, jak WebAssembly umożliwia przeniesienie do sieci 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 hosta 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 moich doświadczeń wynika, że większość problemów z wydajnością w internecie jest spowodowana wymuszeniem układu i nadmiarowym 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 takiej sytuacji 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óre się nastawialiśmy, 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ć obrót. 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ły 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ą interakcję 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ć szybkie i przewidywalne działanie 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 JavaScripta 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ą gwarancję, 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.
Cytując WebAssembly.org:
Gdy skompilujesz fragment kodu C lub Rust na 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 gospodarzowi (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 z komputera wirtualnego.
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 alokacji”.
Oczywiście w Twoim module WebAssembly musi być zawarte używane 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 oferuje kilka implementacji do wyboru (np. „dmalloc”, „emmalloc”, „wee_alloc”).
W naszym przypadku wymiary obrazu wejściowego (a zatem wymiary obrazu wyjściowego) są znane przed uruchomieniem modułu WebAssembly. 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 dużej mierze zgodny z flagami. Jest to kluczowy element misji Emscripten, ponieważ celem projektu jest jak największe uproszczenie kompilowania istniejącego kodu C i C++ na WebAssembly.
Dostęp do pamięci w postaci surowych danych jest wpisany w naturę języka 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 260 bajtów, a kod łączący do około 3,5 KB. Po pewnym czasie udało nam się pozbyć kodu pośredniczą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-pack
przekształ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 podobnie 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. 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 naśladuje 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ę niewielką powierzchnię typu funkcji rotate()
, przeniesienie 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 uzyskać dostęp do surowej 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 łączą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
z Twiggy:
$ twiggy top rotate_bg.wasm

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 skompresowaniu gzipem 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, usuwając kod 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
uzyskał moduł wasm o rozmiarze 1,6 KB po zastosowaniu wasm-opt
, wasm-strip
i gzip. 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?
Jak przeprowadzić test porównawczy
Mimo że WebAssembly jest formatem bajtkodów 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 optymalnego, 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 optymalizacyjnego. 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
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 zbytniego analizowania 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, by przejść do kodu łączącego i zredukować go do niezbędnego minimum, całkowity rozmiar (moduł WebAssembly + kod łączący) okazuje się dość duży.
Podsumowanie
Jakiego języka użyć, jeśli masz ścieżkę JS, którą chcesz przyspieszyć lub uczynić bardziej spójną z WebAssembly? Jak zawsze w przypadku pytań dotyczących skuteczności, odpowiedź brzmi: „To zależy”. Co wysłaliśmy?
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 Rust wyrówna różnice w stosunku do rozmiaru modułów.
Pod względem wydajności w czasie wykonywania kod Rust jest szybszy średnio w różnych przeglądarkach niż kod 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 w przyszłości zwracać uwagę na AssemblyScript.
Aktualizacja: Rdzawy
Po opublikowaniu tego artykułu Nick Fitzgerald z zespołu Rust wskazał 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
Rusta) 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 Fitzgeralda i Maxa Graeya za pomoc w tej podróży.