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

Jest niezmiernie szybka,

W poprzednich artykułów o tym, jak WebAssembly pozwala przenieść ekosystem biblioteki języka C/C++ do internetu. 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 zapisany kod bajtowy w .wasm plikach. Ten kod bajtowy jest dobrze wpisany i zorganizowany w następujący sposób: że można go skompilować i zoptymalizować dla systemu hosta o wiele szybciej niż JavaScript może. WebAssembly udostępnia środowisko do uruchamiania kodu, z myślą o trybie piaskownicy i umieszczaniu treści na stronach.

Z mojego doświadczenia wynika, że większość problemów z wydajnością w witrynach jest spowodowanych z układem strony i z nadmiernym wyrenderowaniem, ale co jakiś czas aplikacja jest to kosztowne obliczeniowo zadanie, które wymaga dużo czasu. Może pomóc WebAssembly tutaj.

Gorąca ścieżka

W języku squoosh napisaliśmy funkcję JavaScript który obraca bufor obrazu o kilka kątów 90 stopni. Choć Format OffscreenCanvas będzie idealny dla: ale nie jest obsługiwany w żadnej z przeglądarek, na które była kierowana. błędami 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ę”. Mimo że jest dość duży liczby iteracji, dwie z trzech testowanych przez nas przeglądarek ukończyły zadanie w 2 sekund lub mniej. 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ą skuteczność pod kątem różnych kwestii. Niektóre są optymalizowane pod kątem nieprzetworzonego wykonania, a inne pod kątem interakcji 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. No więc jeśli zależy nam na szybkiej, przewidywalnej wydajności w różnych przeglądarkach dla takiego kodu, Może Ci w tym pomóc WebAssembly.

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. Rygorystyczne pisanie i architektura niskiego poziomu sprawiają, że kompilator co daje gwarancję, że kod WebAssembly będzie zoptymalizowany tylko raz zawsze używaj „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 dotykaliśmy kodu bibliotek, napisałem kilka fragmentów kodu w C/C++, aby utworzyć most między przeglądarką. i bibliotekę. Tym razem nasza motywacja jest inna: chcemy napisać projektując od podstaw WebAssembly, dzięki WebAssembly.

Architektura WebAssembly

Pisząc 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ś, z czego nie wiedziałam, dopóki się tego nie zastanowiłam: stos, dzięki któremu WebAssembly to „maszyna wirtualna oparta na stosie” nie jest przechowywana we fragmencie pamięci używanej przez moduły WebAssembly. Stos jest całkowicie wewnętrzny i ma charakter niedostępne dla programistów stron internetowych (z wyjątkiem Narzędzi deweloperskich). Dzięki temu jest to możliwe, do pisania modułów WebAssembly, które nie wymagają żadnej dodatkowej pamięci, tylko wewnętrzny stos maszyn wirtualnych.

W tym przypadku będziemy potrzebować dodatkowej pamięci, aby umożliwić dowolny dostęp do pikseli naszego obrazu i wygenerować obróconą wersję tego obrazu. To jest Do czego służy WebAssembly.Memory.

Zarządzanie pamięcią

Zazwyczaj po wykorzystaniu dodatkowej pamięci trzeba w jakiś sposób i zarządzać nią. Które części pamięci są używane? Które z nich są bezpłatne? Na przykład w C mamy funkcję malloc(n), która znajduje miejsce w pamięci. z n kolejnych bajtów. Tego rodzaju funkcje są również nazywane „alokatorami”. Oczywiście implementacja używanego przydziału musi być uwzględniona w WebAssembly. Zwiększy on rozmiar pliku. Ten rozmiar i takie wyniki tych funkcji zarządzania pamięcią mogą się znacznie różnić w zależności zastosowanym algorytmie, dlatego wiele języków ma różne opcje implementacji. do wyboru („dmalloc”, „emmalloc”, „wee_alloc” itp.).

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ść zwrotną, musimy użyć funkcji przydzielającej. Znamy jednak łączną ilość potrzebnej pamięci (2 razy większy rozmiar niż dane wejściowe). raz dla danych wejściowych, a raz dla danych wyjściowych), możemy umieścić obraz wejściowy w w JavaScript i w pamięci WebAssembly, uruchom moduł WebAssembly, aby wygenerować Drugi, obrócony obraz, a potem odczytaj wynik za pomocą JavaScriptu. Możemy uzyskać możesz z niego korzystać bez konieczności zarządzania pamięcią.

Swobodny wybór

Jeśli oglądasz pierwotną funkcję JavaScript który chcemy przeprowadzić w WebAssembly, widać, że jest to czysto obliczony bez interfejsów API specyficznych dla JavaScript. Dlatego też zalecenie powinno być proste w celu przeniesienia tego kodu na dowolny język. Oceniliśmy 3 różne języki które kompilują się w języku WebAssembly: C/C++, Rust i AssemblyScript. Jedyne pytanie w przypadku każdego języka musimy odpowiedzieć sobie na: 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 mogą stanowić zamiennik dobrze znanych kompilatorów C, takich jak GCC czy clang. i w większości zgodnych z flagami. To podstawowy element misji Emscripten Chce, aby kompilacja istniejącego kodu C i C++ do WebAssembly była tak prosta, jak to tylko możliwe.

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;

Zamieniamy tu liczbę 0x124 na wskaźnik do niepodpisanego, 8-bitowego 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 przenieść piksel, musimy przenieść 4 kolejne bajty naraz (po jednym bajcie na każdy kanał: 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 będzie pod adresem 4, a obraz wyjściowy zacznie się bezpośrednio po obrazie wejściowym kończy się:

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 kilku chwilach zrezygnowaliśmy w kodzie glue i utworzeniu instancji modułów WebAssembly za pomocą interfejsów API Vanilla. 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. Rdza obsługuje także WebAssembly jako główną funkcję, a zespół Rust przyczyniły się do rozwoju ekosystemu WebAssembly.

Jednym z tych narzędzi jest wasm-pack. grupie roboczej ds. Rustwa. wasm-pack pobiera kod i przekształca go w przyjazny dla sieci moduł, który działa z pakietami takimi jak Webpack. wasm-pack to niezwykle jest wygodne, ale obecnie działa tylko w systemie 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 niezgodne z modelem bezpieczeństwa pamięci które wymusza Rust, więc w użyciu słowa kluczowego unsafe co pozwala nam na pisanie kodu niezgodnego 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

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 modeluje 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ę małą powierzchnię naszej funkcji rotate(), dość łatwo przenieść ten kod do AssemblyScript. Funkcje load<T>(ptr: usize) i store<T>(ptr: usize, value: T) są udostępniane przez AssemblyScript do uzyskać dostęp do nieprzetworzonej pamięci. Aby skompilować nasz plik AssemblyScript, musimy tylko 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. OK znajdziesz w ekosystemie WebAssembly kilka narzędzi, które pomogą Ci analizować plików WebAssembly (niezależnie od języka, w którym zostały utworzone) oraz zrozumieć, co się dzieje, a także pomóc Ci w rozwiązaniu problemu.

Twiggy,

Twiggy to kolejne narzędzie firmy Rusta Zespół WebAssembly, który wyodrębnia sporo szczegółowych danych z zasobu WebAssembly . 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łego 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. Mimo że korzystaliśmy tych dwóch uzupełniających się narzędzi do sprawdzania plików WebAssembly wasm-strip. 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 od Binaryen. Wykorzystuje moduł WebAssembly i próbuje zoptymalizować go pod kątem rozmiaru na podstawie kodu bajtowego. Niektóre narzędzia, takie jak Emscripten, już działają to narzędzie, niektórzy nie. Zwykle warto spróbować zapisać bajtów.

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 badaniach przeredagowaliśmy kod Rust bez użycia standardowej biblioteki Rusta, #![no_std] funkcji. 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. Wprawdzie jest jeszcze większy niż moduły wygenerowane przez C i AssemblyScript, na tyle lekkich, żeby można było uznać je za lekką.

Wyniki

Zanim przejdziemy do wyciągania pochopnych wniosków na podstawie samego rozmiaru pliku, aby zoptymalizować wydajność, a nie rozmiar pliku. Jak więc mierzyliśmy skuteczność Jakie były 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 wprost: na pierwszym etapie podczas kompilacji, ale zwykle 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 większości przypadków nigdy nie otrzymamy z kompilatora optymalizacji. Warto o tym pamiętać analiz porównawczych. Uruchomienie modułów WebAssembly 10 tys. razy w pętli pozwolił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. Proszę że wybrałem logarytmiczną skalę czasu. 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. Bez względu na to, który język wybierzemy, różnice między przeglądarkami a języków jest minimalny. Precyzując: odchylenie standardowe JavaScriptu dla wszystkich przeglądarek wynosi ok. 400 ms, a odchylenie standardowe wszystkich Moduł WebAssembly we wszystkich przeglądarkach trwa około 80 ms.

Sposób stosowania

Innym rodzajem danych jest nakład pracy, jaki musieliśmy włożyć, moduł WebAssembly w tryb 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 nie tylko na korzystanie z języka TypeScript, napisanie WebAssembly, co znacznie ułatwi moim współpracownikom weryfikację kodu, ale będzie też tworzy bezklejowe moduły WebAssembly, które są bardzo małe i dobrze skuteczność reklam. Narzędzia w ekosystemie TypeScript, takie jak ładniejsze czy tslint, prawdopodobnie po prostu będzie działać.

Rust w połączeniu z technologią wasm-pack jest niezwykle wygodne, ale świetnie sprawdza się w większych projektach WebAssembly więcej było powiązań, a zarządzanie pamięcią niezbędną. 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 należy użyć, jeśli masz ścieżkę JS i chcesz ją wykorzystać są szybsze lub bardziej spójne z WebAssembly. Jak zawsze w przypadku skuteczności Odpowiedź brzmi: to zależy. Co wysłaliśmy?

Wykres porównawczy

Porównanie rozmiaru modułu i wydajności w różnych językach. więc najlepszym wyborem wydaje się C lub AssemblyScript. Zdecydowaliśmy się wysłać wersję Rust. OK jest wiele powodów tej decyzji: wszystkie kodeki wysłane do tej pory w Squaresh są skompilowane za pomocą narzędzia Emscripten. Chcieliśmy poszerzyć naszą wiedzę na temat w ekosystemie WebAssembly i w wersji produkcyjnej używaj innego języka. AssemblyScript to doskonała alternatywa, ale projekt jest stosunkowo młody i nie jest tak dojrzały 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 tworzyć szybszy kod bez konieczności jego ręcznej optymalizacji. Ale to nie powinny uniemożliwiać Ci korzystania z tego,

AsemblyScript to dla nas ś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 od zespołu Rust wskaże nam świetną książkę o nazwie Rust Wasm, która zawiera o optymalizowaniu rozmiaru plików. Postępując zgodnie z (zwłaszcza optymalizację czasu linków oraz ręczne radzenia sobie z włamaniami) pozwoliło nam napisać „normalny” kod Rust i powrócić do używania Cargo (npm systemu Rust) bez nadmiernego rozmiaru pliku. Moduł Rust kończy się do 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.