Binaryen to biblioteka infrastruktury kompilatora i zestawu narzędzi do WebAssembly napisana w C++. Jej celem jest sprawienie, aby kompilowanie do WebAssembly było intuicyjne, szybkie i skuteczne. Z tego artykułu dowiesz się, jak pisać moduły WebAssembly w JavaScript przy użyciu interfejsu Binaryen.js API. Jako przykładu użyjemy syntetycznego języka programowania o nazwie ExampleScript. Poznasz podstawy tworzenia modułów, dodawania do nich funkcji i eksportowania funkcji z modułów. Pozwoli Ci to poznać ogólne mechanizmy kompilowania rzeczywistych języków programowania do WebAssembly. Dowiesz się też, jak optymalizować moduły Wasm za pomocą Binaryen.js i w wierszu poleceń za pomocą wasm-opt
.
Informacje o Binaryen
Binaryen ma intuicyjny interfejs API w języku C w jednym pliku nagłówkowym, a także może być używany w JavaScript. Akceptuje dane wejściowe w formie WebAssembly, ale także ogólny graf przepływu sterowania dla kompilatorów, które go preferują.
Reprezentacja pośrednia (IR) to struktura danych lub kod używany wewnętrznie przez kompilator lub maszynę wirtualną do reprezentowania kodu źródłowego. Wewnętrzny IR Binaryen wykorzystuje kompaktowe struktury danych i jest przeznaczony do całkowicie równoległego generowania i optymalizacji kodu przy użyciu wszystkich dostępnych rdzeni procesora. IR Binaryen kompiluje się do WebAssembly, ponieważ jest podzbiorem tego języka.
Optymalizator Binaryen ma wiele przebiegów, które mogą poprawić rozmiar i szybkość kodu. Te optymalizacje mają na celu zwiększenie możliwości Binaryen, aby można było go używać jako samodzielnego kompilatora. Zawiera optymalizacje specyficzne dla WebAssembly (których kompilatory ogólnego przeznaczenia mogą nie wykonywać), co można uznać za minifikację Wasm.
AssemblyScript jako przykładowy użytkownik Binaryen
Binaryen jest używany w wielu projektach, np. w AssemblyScript, który wykorzystuje go do kompilowania z języka podobnego do TypeScriptu bezpośrednio do WebAssembly. Wypróbuj przykład w piaskownicy AssemblyScript.
Dane wejściowe AssemblyScript:
export function add(a: i32, b: i32): i32 {
return a + b;
}
Odpowiedni kod WebAssembly w formie tekstowej wygenerowany przez Binaryen:
(module
(type $0 (func (param i32 i32) (result i32)))
(memory $0 0)
(export "add" (func $module/add))
(export "memory" (memory $0))
(func $module/add (param $0 i32) (param $1 i32) (result i32)
local.get $0
local.get $1
i32.add
)
)
Łańcuch narzędzi Binaryen
Zestaw narzędzi Binaryen oferuje wiele przydatnych narzędzi zarówno dla deweloperów JavaScriptu, jak i użytkowników wiersza poleceń. Podzbiór tych narzędzi jest wymieniony poniżej. Pełna lista narzędzi jest dostępna w pliku README
projektu.
binaryen.js
: samodzielna biblioteka JavaScript, która udostępnia metody Binaryen do tworzenia i optymalizowania modułów Wasm. W przypadku kompilacji zobacz binaryen.js w npm (lub pobierz go bezpośrednio z GitHub lub unpkg).wasm-opt
: narzędzie wiersza poleceń, które wczytuje WebAssembly i uruchamia na nim przejścia Binaryen IR.wasm-as
iwasm-dis
: narzędzia wiersza poleceń, które montują i demontują WebAssembly.wasm-ctor-eval
: narzędzie wiersza poleceń, które może wykonywać funkcje (lub ich części) w czasie kompilacji.wasm-metadce
: narzędzie wiersza poleceń do usuwania części plików Wasm w elastyczny sposób, który zależy od sposobu użycia modułu.wasm-merge
: narzędzie wiersza poleceń, które łączy wiele plików Wasm w jeden plik, łącząc przy tym odpowiednie importy z eksportami. Działa podobnie jak narzędzie do łączenia plików JavaScript, ale w przypadku Wasm.
Kompilowanie do WebAssembly
Kompilowanie z jednego języka do drugiego zwykle obejmuje kilka kroków. Najważniejsze z nich są wymienione na poniższej liście:
- Analiza leksykalna: podział kodu źródłowego na tokeny.
- Analiza składni: tworzenie abstrakcyjnego drzewa składni.
- Analiza semantyczna: sprawdzanie błędów i egzekwowanie reguł językowych.
- Generowanie kodu pośredniego: tworzenie bardziej abstrakcyjnej reprezentacji.
- Generowanie kodu: tłumaczenie na język docelowy.
- Optymalizacja kodu pod kątem wartości docelowej: optymalizacja pod kątem wartości docelowej.
W środowisku Unix często używane narzędzia do kompilacji to:lex
i yacc
:
lex
(Lexical Analyzer Generator):lex
to narzędzie, które generuje analizatory leksykalne, zwane też lekserami lub skanerami. Przyjmuje on jako dane wejściowe zestaw wyrażeń regularnych i odpowiadających im działań oraz generuje kod analizatora leksykalnego, który rozpoznaje wzorce w wejściowym kodzie źródłowym.yacc
(Yet Another Compiler Compiler):yacc
to narzędzie, które generuje analizatory składni. Jako dane wejściowe przyjmuje formalny opis gramatyki języka programowania i generuje kod analizatora. Analizatory składniowe zwykle tworzą abstrakcyjne drzewa składniowe (AST), które reprezentują hierarchiczną strukturę kodu źródłowego.
Przykład
Ze względu na zakres tego posta nie można omówić całego języka programowania, więc dla uproszczenia rozważmy bardzo ograniczony i bezużyteczny syntetyczny język programowania o nazwie ExampleScript, który działa poprzez wyrażanie ogólnych operacji za pomocą konkretnych przykładów.
- Aby napisać funkcję
add()
, kodujesz przykład dowolnego dodawania, np.2 + 3
. - Aby napisać funkcję
multiply()
, wpisz na przykład6 * 12
.
Zgodnie z ostrzeżeniem jest to zupełnie bezużyteczne, ale wystarczająco proste, aby analizator leksykalny był pojedynczym wyrażeniem regularnym: /\d+\s*[\+\-\*\/]\s*\d+\s*/
.
Następnie musi być dostępny parser. W rzeczywistości bardzo uproszczoną wersję drzewa składni abstrakcyjnej można utworzyć za pomocą wyrażenia regularnego z nazwanymi grupami przechwytywania:/(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/
Polecenia ExampleScript są umieszczane w oddzielnych wierszach, więc parser może przetwarzać kod wiersz po wierszu, dzieląc go na znaki nowego wiersza. Wystarczy to, aby sprawdzić 3 pierwsze kroki z listy punktowanej powyżej, czyli analizę leksykalną, analizę składni i analizę semantyczną. Kod do tych kroków znajdziesz na poniższej liście.
export default class Parser {
parse(input) {
input = input.split(/\n/);
if (!input.every((line) => /\d+\s*[\+\-\*\/]\s*\d+\s*/gm.test(line))) {
throw new Error('Parse error');
}
return input.map((line) => {
const { groups } =
/(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/gm.exec(
line,
);
return {
firstOperand: Number(groups.first_operand),
operator: groups.operator,
secondOperand: Number(groups.second_operand),
};
});
}
}
Generowanie kodu pośredniego
Teraz, gdy programy ExampleScript można przedstawić jako drzewo składni abstrakcyjnej (choć dość uproszczone), następnym krokiem jest utworzenie abstrakcyjnej reprezentacji pośredniej. Pierwszym krokiem jest utworzenie nowego modułu w Binaryen:
const module = new binaryen.Module();
Każdy wiersz drzewa składni abstrakcyjnej zawiera trójkę składającą się z firstOperand
, operator
i secondOperand
. Dla każdego z 4 możliwych operatorów w skrypcie ExampleScript, czyli +
, -
, *
i /
, do modułu trzeba dodać nową funkcję za pomocą metody Module#addFunction()
w Binaryen. Parametry metod Module#addFunction()
są następujące:
name
:string
to nazwa funkcji.functionType
:Signature
reprezentuje sygnaturę funkcji.varTypes
:Type[]
oznacza dodatkowe ustawienia regionalne w podanej kolejności.body
:Expression
, zawartość funkcji.
Jest jeszcze kilka szczegółów, które warto rozwinąć i przeanalizować. Pomocna w tym będzie dokumentacja Binaryen. W przypadku operatora +
w ExampleScript ostatecznie trafisz na metodę Module#i32.add()
jako jedną z kilku dostępnych operacji na liczbach całkowitych.
Dodawanie wymaga 2 argumentów: pierwszego i drugiego składnika. Aby funkcja była wywoływalna, musi być eksportowana z parametrem Module#addFunctionExport()
.
module.addFunction(
'add', // name: string
binaryen.createType([binaryen.i32, binaryen.i32]), // params: Type
binaryen.i32, // results: Type
[binaryen.i32], // vars: Type[]
// body: ExpressionRef
module.block(null, [
module.local.set(
2,
module.i32.add(
module.local.get(0, binaryen.i32),
module.local.get(1, binaryen.i32),
),
),
module.return(module.local.get(2, binaryen.i32)),
]),
);
module.addFunctionExport('add', 'add');
Po przetworzeniu drzewa składni abstrakcyjnej moduł zawiera 4 metody, z których 3 działają na liczbach całkowitych: add()
na podstawie Module#i32.add()
, subtract()
na podstawie Module#i32.sub()
, multiply()
na podstawie Module#i32.mul()
i odstająca divide()
na podstawie Module#f64.div()
, ponieważ ExampleScript działa też na wynikach zmiennoprzecinkowych.
for (const line of parsed) {
const { firstOperand, operator, secondOperand } = line;
if (operator === '+') {
module.addFunction(
'add', // name: string
binaryen.createType([binaryen.i32, binaryen.i32]), // params: Type
binaryen.i32, // results: Type
[binaryen.i32], // vars: Type[]
// body: ExpressionRef
module.block(null, [
module.local.set(
2,
module.i32.add(
module.local.get(0, binaryen.i32),
module.local.get(1, binaryen.i32)
)
),
module.return(module.local.get(2, binaryen.i32)),
])
);
module.addFunctionExport('add', 'add');
} else if (operator === '-') {
module.subtractFunction(
// Skipped for brevity.
)
} else if (operator === '*') {
// Skipped for brevity.
}
// And so on for all other operators, namely `-`, `*`, and `/`.
Jeśli masz do czynienia z rzeczywistymi bazami kodu, czasami zdarza się, że jest w nich martwy kod, który nigdy nie jest wywoływany. Aby sztucznie wprowadzić martwy kod (który zostanie zoptymalizowany i usunięty w późniejszym kroku) w przykładzie kompilacji skryptu ExampleScript do Wasm, wystarczy dodać funkcję, która nie jest eksportowana.
// This function is added, but not exported,
// so it's effectively dead code.
module.addFunction(
'deadcode', // name: string
binaryen.createType([binaryen.i32, binaryen.i32]), // params: Type
binaryen.i32, // results: Type
[binaryen.i32], // vars: Type[]
// body: ExpressionRef
module.block(null, [
module.local.set(
2,
module.i32.div_u(
module.local.get(0, binaryen.i32),
module.local.get(1, binaryen.i32),
),
),
module.return(module.local.get(2, binaryen.i32)),
]),
);
Kompilator jest już prawie gotowy. Nie jest to bezwzględnie konieczne, ale zdecydowanie warto sprawdzić moduł za pomocą metody Module#validate()
.
if (!module.validate()) {
throw new Error('Validation error');
}
Uzyskiwanie wynikowego kodu Wasm
Aby uzyskać wynikowy kod Wasm, w Binaryen istnieją 2 metody uzyskiwania tekstowej reprezentacji w postaci pliku .wat
w S-expression jako formatu czytelnego dla człowieka oraz reprezentacji binarnej w postaci pliku .wasm
, który można bezpośrednio uruchomić w przeglądarce. Kod binarny można uruchomić bezpośrednio w przeglądarce. Aby sprawdzić, czy eksportowanie się udało, możesz rejestrować eksporty.
const textData = module.emitText();
console.log(textData);
const wasmData = module.emitBinary();
const compiled = new WebAssembly.Module(wasmData);
const instance = new WebAssembly.Instance(compiled, {});
console.log('Wasm exports:\n', instance.exports);
Pełna reprezentacja tekstowa programu ExampleScript ze wszystkimi 4 operacjami jest podana poniżej. Zwróć uwagę, że martwy kod nadal istnieje, ale nie jest widoczny, co widać na zrzucie ekranu WebAssembly.Module.exports()
.
(module
(type $0 (func (param i32 i32) (result i32)))
(type $1 (func (param f64 f64) (result f64)))
(export "add" (func $add))
(export "subtract" (func $subtract))
(export "multiply" (func $multiply))
(export "divide" (func $divide))
(func $add (param $0 i32) (param $1 i32) (result i32)
(local $2 i32)
(local.set $2
(i32.add
(local.get $0)
(local.get $1)
)
)
(return
(local.get $2)
)
)
(func $subtract (param $0 i32) (param $1 i32) (result i32)
(local $2 i32)
(local.set $2
(i32.sub
(local.get $0)
(local.get $1)
)
)
(return
(local.get $2)
)
)
(func $multiply (param $0 i32) (param $1 i32) (result i32)
(local $2 i32)
(local.set $2
(i32.mul
(local.get $0)
(local.get $1)
)
)
(return
(local.get $2)
)
)
(func $divide (param $0 f64) (param $1 f64) (result f64)
(local $2 f64)
(local.set $2
(f64.div
(local.get $0)
(local.get $1)
)
)
(return
(local.get $2)
)
)
(func $deadcode (param $0 i32) (param $1 i32) (result i32)
(local $2 i32)
(local.set $2
(i32.div_u
(local.get $0)
(local.get $1)
)
)
(return
(local.get $2)
)
)
)
Optymalizacja WebAssembly
Binaryen oferuje 2 sposoby optymalizacji kodu Wasm. Jeden w samym Binaryen.js, a drugi w wierszu poleceń. Pierwsza opcja domyślnie stosuje standardowy zestaw reguł optymalizacji i umożliwia ustawienie poziomu optymalizacji i kompresji, a druga domyślnie nie używa żadnych reguł, ale pozwala na pełne dostosowanie. Oznacza to, że dzięki eksperymentom możesz dostosować ustawienia, aby uzyskać optymalne wyniki na podstawie swojego kodu.
Optymalizacja za pomocą Binaryen.js
Najprostszym sposobem optymalizacji modułu Wasm za pomocą Binaryen jest bezpośrednie wywołanie metody Module#optimize()
w Binaryen.js i opcjonalne ustawienie poziomu optymalizacji i kompresji.
// Assume the `wast` variable contains a Wasm program.
const module = binaryen.parseText(wast);
binaryen.setOptimizeLevel(2);
binaryen.setShrinkLevel(1);
// This corresponds to the `-Os` setting.
module.optimize();
Spowoduje to usunięcie martwego kodu, który został wcześniej sztucznie wprowadzony, więc tekstowa reprezentacja wersji Wasm przykładowego skryptu nie będzie już go zawierać. Zwróć też uwagę, jak pary local.set/get
są usuwane przez kroki optymalizacji SimplifyLocals (różne optymalizacje związane z lokalizacjami) i Vacuum (usuwa oczywiście niepotrzebny kod), a return
jest usuwany przez RemoveUnusedBrs (usuwa przerwy w miejscach, w których nie są potrzebne).
(module
(type $0 (func (param i32 i32) (result i32)))
(type $1 (func (param f64 f64) (result f64)))
(export "add" (func $add))
(export "subtract" (func $subtract))
(export "multiply" (func $multiply))
(export "divide" (func $divide))
(func $add (; has Stack IR ;) (param $0 i32) (param $1 i32) (result i32)
(i32.add
(local.get $0)
(local.get $1)
)
)
(func $subtract (; has Stack IR ;) (param $0 i32) (param $1 i32) (result i32)
(i32.sub
(local.get $0)
(local.get $1)
)
)
(func $multiply (; has Stack IR ;) (param $0 i32) (param $1 i32) (result i32)
(i32.mul
(local.get $0)
(local.get $1)
)
)
(func $divide (; has Stack IR ;) (param $0 f64) (param $1 f64) (result f64)
(f64.div
(local.get $0)
(local.get $1)
)
)
)
Istnieje wiele etapów optymalizacji, a Module#optimize()
używa domyślnych zestawów poziomów optymalizacji i kompresji. Aby w pełni dostosować ustawienia, musisz użyć narzędzia wiersza poleceń wasm-opt
.
Optymalizacja za pomocą narzędzia wiersza poleceń wasm-opt
Aby w pełni dostosować używane przekształcenia, Binaryen zawiera narzędzie wiersza poleceń wasm-opt
. Pełną listę możliwych opcji optymalizacji znajdziesz w komunikacie pomocy narzędzia. Narzędzie wasm-opt
jest prawdopodobnie najpopularniejsze i jest używane przez kilka łańcuchów narzędzi kompilatora do optymalizacji kodu Wasm, w tym Emscripten, J2CL, Kotlin/Wasm, dart2wasm, wasm-pack i inne.
wasm-opt --help
Aby dać Ci wyobrażenie o tych kartach, przedstawiamy fragment niektórych z nich, które są zrozumiałe bez specjalistycznej wiedzy:
- CodeFolding: zapobiega duplikowaniu kodu przez jego scalanie (np. jeśli 2 gałęzie mają na końcu wspólne instrukcje).
if
- DeadArgumentElimination: optymalizacja czasu łączenia, która usuwa argumenty funkcji, jeśli jest ona zawsze wywoływana z tymi samymi stałymi.
- MinifyImportsAndExports: minimalizuje je do
"a"
,"b"
. - DeadCodeElimination: usuwanie martwego kodu.
Dostępny jest poradnik optymalizacji, w którym znajdziesz kilka wskazówek dotyczących określania, które z różnych flag są ważniejsze i warto je wypróbować w pierwszej kolejności. Na przykład wielokrotne uruchamianie wasm-opt
może dodatkowo zmniejszyć rozmiar danych wejściowych. W takich przypadkach uruchomienie z --converge
flagą
powoduje iterację, dopóki nie nastąpi dalsza optymalizacja i nie zostanie osiągnięty punkt stały.
Prezentacja
Aby zobaczyć w praktyce koncepcje przedstawione w tym poście, wypróbuj osadzone demo, wpisując dowolne dane wejściowe ExampleScript. Pamiętaj też, aby wyświetlić kod źródłowy wersji demonstracyjnej.
Podsumowanie
Binaryen to zaawansowany zestaw narzędzi do kompilowania języków do WebAssembly i optymalizowania wynikowego kodu. Biblioteka JavaScript i narzędzia wiersza poleceń zapewniają elastyczność i łatwość użycia. W tym poście przedstawiliśmy podstawowe zasady kompilacji Wasm, podkreślając skuteczność Binaryen i jego potencjał w zakresie maksymalnej optymalizacji. Wiele opcji dostosowywania optymalizacji Binaryen wymaga dogłębnej wiedzy o wewnętrznej strukturze Wasm, ale zwykle ustawienia domyślne działają już bardzo dobrze. Życzymy udanego kompilowania i optymalizowania za pomocą Binaryen.
Podziękowania
Ten post został sprawdzony przez Alona Zakaia, Thomasa Lively’ego i Rachel Andrew.