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

Jest niezmiernie szybka,

poprzednich artykułach omawiałem, jak WebAssembly umożliwia przeniesienie do sieci ekosystemu bibliotek C/C++. Jedna aplikacja, która korzysta z bibliotek C/C++ squoosh. pozwalająca kompresować obrazy różnymi kodekami, która została skompilowana z języka C++ do WebAssembly.

WebAssembly to niskopoziomowa maszyna wirtualna, która uruchamia kod bajtowy przechowywany w .wasm plikach. 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 udostępnia środowisko do uruchamiania kodu, z myślą o trybie piaskownicy i umieszczaniu treści na stronach.

Z moich doświadczeń wynika, że większość problemów z wydajnością w internecie jest spowodowana wymuszonym układem 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. Z pomocy WebAssembly tutaj.

Ścieżka często używana

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

Ta funkcja iteruje każdy piksel obrazu wejściowego i kopiuje go do w celu uzyskania obrotu. Dla 4094 pikseli na Obraz ma rozdzielczość 4096 pikseli (16 megapikseli). Potrzeba on ponad 16 milionów iteracji blok kodu wewnętrznego, czyli tzw. „gorącą ścieżkę”. Pomimo tak dużej liczby iteracji 2 z 3 przetestowanych przez nas przeglądarek wykonało zadanie w mniej niż 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 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 Mamy więc do czynienia z niezoptymalizowaną ścieżką w jednej przeglądarce.

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

WebAssembly zapewnia przewidywalną wydajność

Ogólnie w językach JavaScript i WebAssembly osiągają taką samą szczytową wydajność. W przypadku JavaScriptu skuteczność może być jednak dostępna tylko na „szybkiej ścieżce”, i często trudno jest ją podążać. Jedną z głównych zalet Oferty WebAssembly cechują się przewidywalną wydajnością 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 na potrzeby WebAssembly

Wcześniej skompilowaliśmy biblioteki C/C++ do WebAssembly, aby używać ich dostępnych w sieci. 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 .wasm który zawiera deklarację modułu. Deklaracja zawiera listę „importy” których moduł oczekuje ze swojego środowiska, zawiera listę eksportów, które ten który jest udostępniany hostowi (funkcje, stałe, fragmenty pamięci) oraz oczywiście rzeczywistych instrukcji binarnych dla funkcji, które się w nim znajdują.

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, z której korzystają 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 przydzielającymi”. Oczywiście implementacja używanego przydziału musi być uwzględniona w WebAssembly. Zwiększy on 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 tym przypadku znamy wymiary obrazu wejściowego (a więc obrazu wyjściowego), zanim uruchomimy moduł WebAssembly. Oto dostrzegliśmy możliwość: tradycyjnie buforowaliśmy bufor RGBA obrazu wejściowego jako do funkcji WebAssembly i zwracają obrócony obraz . 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ą JavaScripta, 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 oglądasz pierwotną funkcję JavaScript który chcemy przeprowadzić w WebAssembly, widać, że jest to bez interfejsów API specyficznych dla JavaScript. W związku z tym przeniesienie tego kodu na dowolny język powinno być dość proste. Oceniliśmy 3 różne języki które kompilują się w języku 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 w języku C dla celu WebAssembly. Celem firmy Emscripten jest mogą stanowić zamiennik dobrze znanych kompilatorów C, takich jak GCC czy clang. i w większości zgodnych z flagami. Jest to kluczowy element misji Emscripten, ponieważ celem projektu jest jak największe uproszczenie kompilowania istniejącego kodu C i C++ do WebAssembly.

Dostęp do nieprzetworzonej pamięci ma tę samą naturę, co język C. Istnieją też wskaźniki, przyczyna:

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ę zaczynając od adresu pamięci 0x124, którego możemy użyć jak każdej innej tablicy, co pozwala nam na dostęp do poszczególnych bajtów w celu odczytu i zapisu. W naszym przypadku widzimy bufor RGBA obrazu, który chcemy zmienić i rotacji. Aby przesunąć piksel, musimy przesunąć 4 kolejne bajty naraz (po jednym bajcie dla każdego kanału: R, G, B i A). Aby to ułatwić, możemy utworzyć tablica 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 przeniesieniu całej funkcji JavaScriptu do języka C możemy skompilować plik C z emcc:

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

Narzędzie emscripten tworzy plik kodu typu glue o nazwie c.js i moduł Wasm. pod tytułem c.wasm. Zauważ, że moduł Wasm po rozpakowaniu gzip ma tylko ok. 260 bajtów, a Jest to około 3,5 KB po pliku 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 korzystasz z żadnego elementu ze standardowej biblioteki C.

Rust

Rust to nowy, nowoczesny język programowania z zaawansowanym systemem, bez środowiska wykonawczego oraz model 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 bez potrzeby korzystania z pakietu, takiego jak webpack. wasm-pack to bardzo wygodna funkcja, która obecnie działa tylko w przypadku Rust. Grupa jest rozważać wprowadzenie obsługi innych języków kierowania WebAssembly.

W języku Rust wycinki to tablice w języku C. I tak jak w C, musimy utworzyć wycinki, 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

Format AssemblyScript jest dość projektu, którego celem jest stworzenie kompilatora TypeScript-to-WebAssembly. Jest pamiętaj jednak, że nie wykorzystuje on tylko żadnego języka TypeScript. AssemblyScript korzysta z takiej samej składni jak TypeScript, ale zastępuje standard do własnej biblioteki. Ich standardowa biblioteka naśladuje możliwości WebAssembly. Oznacza to, że nie możesz po prostu skompilować dowolnego kodu TypeScriptu, z WebAssembly, ale to, że nie musisz poznawać lub języka programowania do napisania 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 tego kodu do AssemblyScript było dość łatwe. Funkcje load<T>(ptr: usize)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 klejącego. Moduł po prostu współpracuje z vanilla WebAssembly API.

WebAssembly Forensics

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 związane z Rust i umożliwia sprawdzanie na wykresie wywołania modułu, określić używane lub zbędne sekcje i dowiedzieć się, które sekcje mają wpływ na całkowity rozmiar plików modułu. co można zrobić za pomocą polecenia top Twiggy'ego:

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

W tym przypadku widzimy, że większość naszych rozmiarów plików pochodzi alokatora. To zaskoczenie, bo nasz kod nie korzysta z alokacji dynamicznej. Kolejnym ważnym czynnikiem są „nazwy funkcji” podpunktu.

wasm-strip

wasm-strip to narzędzie z pakietu WebAssembly Binary Toolkit (w skrócie wabt). Zawiera ona To 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 zrozumiały dla człowieka. Wabt zawiera również funkcję wat2wasm, która umożliwia zrozumiały dla człowieka format z powrotem do binarnego modułu Wasm. Używaliśmy tych dwóch uzupełniających się narzędzi do sprawdzania plików WebAssembly, ale okazało się, że wasm-strip jest najbardziej przydatne. 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. Potrzebny jest moduł WebAssembly i próbuje zoptymalizować go zarówno pod kątem rozmiaru, na podstawie 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 zaoszczędzić jeszcze kilka bajtów, pozostawiając w sumie 6,2 KB po gzip.

#![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 również całkowite wyłączenie alokacji pamięci dynamicznej, usuwając z kodu przydzielającego. Kompiluję ten plik Rust z

$ 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 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

Choć WebAssembly jest niskopoziomowym kodem bajtowym, nadal trzeba go wysłać za pomocą kompilatora do wygenerowania kodu maszynowego zależnego od hosta. Tak jak JavaScript, działa wieloetapowo. Mówiąc w prosty sposób: pierwszy etap jest znacznie szybszy w kompilowaniu, ale generuje wolniejszy kod. Po rozpoczęciu modułu uruchomionych, analizuje, które części są często używane, i wysyła je używając lepiej zoptymalizowanego, ale wolniejszego kompilatora.

Nasz przypadek użycia jest interesujący, ponieważ do obracania obrazu zostanie użyty kod raz, może dwa razy. W zdecydowanej większości przypadków nigdy nie będziemy mogli skorzystać z zalet kompilatora optymalizującego. Warto o tym pamiętać analiz porównawczych. Uruchomienie naszych modułów WebAssembly 10 tysięcy razy w pętli dałoby nierealistyczne wyniki. Aby uzyskać realistyczne wyniki, moduł musi zostać uruchomiony raz i podejmować decyzje na podstawie wyników.

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 na przeglądarkę, na drugim wykresie porównamy dane według używanego języka. Zwróć uwagę, że wybrałem skalę logarytmiczną. Ważne jest też, aby wszystkie przy użyciu tego samego obrazu testowego o rozdzielczości 16 megapikseli i tego samego hosta. na komputerze, z wyjątkiem jednej, której nie można uruchomić na tym samym komputerze.

Bez zbytniej analizy tych wykresów widać, że udało nam się rozwiązać problem z wydajnością: wszystkie moduły WebAssembly uruchamiają się w maksymalnie 500 ms. Ten potwierdza to, co ustaliliśmy na początku: WebAssembly zapewnia przewidywalne skuteczność reklam. Niezależnie od wybranego języka różnice między przeglądarkami i językami są minimalne. Precyzując: odchylenie standardowe JavaScriptu dla wszystkich przeglądarek wynosi ok. 400 ms, a odchylenie standardowe wszystkich Czas pracy 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ć wartość liczbową do funkcji Nie chcę tworzyć wykresów, ale chcę zobaczyć kilka rzeczy, zwrócić uwagę:

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 ładniejsze czy tslint, prawdopodobnie po prostu zadziała.

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ą. Musieliśmy odejść trochę od szczęśliwej ścieżki, by osiągnąć konkurencyjną rozmiar pliku.

Twórcy z C i Emscripten stworzyli bardzo mały i wysoce wydajny moduł WebAssembly prosto z pudełka, ale bez odwagi, żeby wskoczyć w kod do klejenia i ograniczyć go do niezbędny jest całkowity rozmiar (moduł WebAssembly + kod glue). jest dość duża.

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?

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. Zdecydowaliśmy się wysłać wersję Rust. Ta decyzja ma kilka przyczyn: Chcieliśmy poszerzyć naszą wiedzę na temat w ekosystemie WebAssembly i w wersji produkcyjnej używaj innego języka. AssemblyScript to dobra alternatywa, ale projekt jest stosunkowo młody, a kompilator nie jest tak dopracowany jak kompilator Rust.

Różnica w rozmiarze pliku między językiem Rust a rozmiarem innych języków Na wykresie punktowym wygląda to dość drastycznie, ale w rzeczywistości nie jest to aż takie straszne: Wczytywanie 500 B lub 1,6 KB nawet w sieci 2G trwa mniej niż 1/10 sekundy. oraz Mamy nadzieję, że Rust wkrótce uzupełni braki 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 szybsze tworzenie kodu bez konieczności jego ręcznej optymalizacji. Nie powinno to jednak uniemożliwiać Ci korzystania z tego, co najbardziej Ci odpowiada.

Podsumowując: AssemblyScript to świetne odkrycie. Pozwala na pozwalają tworzyć moduły WebAssembly bez konieczności poznawania język. Zespół AssemblyScript wykazywał się dużym zainteresowaniem i aktywnie nad ulepszeniem swojego łańcucha narzędzi. Z pewnością będziemy śledzić w przyszłości, na przykład:

Aktualizacja: Rust

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 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.