Binaryen to kompilator i biblioteka infrastruktury dla WebAssembly napisana w C++. Ma ona na celu zapewnienie intuicyjnego, szybkiego i skutecznego kompilowania kodu na potrzeby WebAssembly. W tym artykule na przykładzie sztucznego języka zabawkowego o nazwie ExampleScript dowiesz się, jak pisać moduły WebAssembly w JavaScript za pomocą interfejsu Binaryen.js API. Poznasz podstawy tworzenia modułów, dodawania do nich funkcji i eksportowania funkcji z modułów. Dzięki temu poznasz ogólne mechanizmy kompilowania rzeczywistych języków programowania na WebAssembly. Ponadto dowiesz się, jak optymalizować moduły Wasm za pomocą Binaryen.js i na wierszu poleceń za pomocą wasm-opt
.
Wprowadzenie do Binaryen
Biblioteka Binaryen zawiera intuicyjny interfejs C API w jednym nagłówku i może być też używana z JavaScriptu. Akceptuje dane wejściowe w formie WebAssembly, ale obsługuje też ogólny diagram przepływu sterowania na potrzeby kompilatorów, które preferują tę opcję.
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 moduł IR w Binaryen korzysta z kompaktowych struktur danych i jest zaprojektowany do całkowicie równoległego generowania kodu oraz optymalizacji, wykorzystując wszystkie dostępne rdzenie procesora. IR w Binaryen jest kompilowany do WebAssembly, ponieważ jest podzbiorem WebAssembly.
Optymalizator Binaryen ma wiele przejść, które mogą poprawić rozmiar i szybkość kodu. Te optymalizacje mają na celu zwiększenie wydajności Binaryen, tak aby można go było używać jako samodzielnego backendu kompilatora. Zawiera optymalizacje specyficzne dla WebAssembly (których kompilatory uniwersalne mogą nie wykonywać), które można uznać za skompresowanie Wasm.
AssemblyScript jako przykładowy użytkownik Binaryen
Binaryen jest używany przez wiele projektów, np. AssemblyScript, który korzysta z Binaryen do kompilowania kodu z języka podobnego do TypeScript bezpośrednio na WebAssembly. Wypróbuj przykład w sali zabaw AssemblyScript.
Dane wejściowe AssemblyScript:
export function add(a: i32, b: i32): i32 {
return a + b;
}
Odpowiadający mu 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 JavaScript, jak i użytkowników wiersza poleceń. Poniżej znajdziesz podzbiór tych narzędzi. Pełną listę narzędzi znajdziesz w pliku README
projektu.
binaryen.js
: samodzielna biblioteka JavaScriptu, 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ń do kompilowania i rozkładania 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 elastycznego usuwania części plików Wasm w sposób zależny od sposobu użycia modułu.wasm-merge
: narzędzie wiersza poleceń, które scala wiele plików Wasm w jeden plik, łącząc odpowiednie pliki importowane z plikami eksportowanymi. Działa jak narzędzie do tworzenia pakietów w przypadku JavaScriptu, ale w przypadku Wasm.
Kompilowanie kodu na potrzeby WebAssembly
Kompilacja z jednego języka na inny zwykle obejmuje kilka kroków. Najważniejsze z nich to:
- Analiza leksykalna: kod źródłowy dzieli się na tokeny.
- Analiza składni: tworzenie drzewa abstrakcyjnej składni.
- Analiza semantyczna: sprawdzanie błędów i egzekwowanie zasad językowych.
- Generowanie kodu pośredniego: tworzy bardziej abstrakcyjną reprezentację.
- Generowanie kodu: przetłumacz na język docelowy.
- Optymalizacja kodu pod kątem wartości docelowych: optymalizuj pod kątem wartości docelowych.
W świecie Unixa często używane narzędzia do kompilacji to:
lex
i
yacc
:
lex
(Generatora analizy leksykalnej):lex
to narzędzie, które generuje analizatory leksykalne, zwane też lekserami lub skanerami. Jako dane wejściowe przyjmuje zbiór wyrażeń regularnych i odpowiednich działań, a następnie generuje kod dla analizatora leksykalnego, który rozpoznaje wzorce w podawanym kodzie źródłowym.yacc
(Yet Another Compiler Compiler):yacc
to narzędzie, które generuje parsery do analizy składni. Na wejściu przyjmuje formalny opis gramatyczny języka programowania, a na wyjściu generuje kod dla parsowania. Przetwarzacze zwykle generują abstrakcyjne drzewa składni (AST), które odzwierciedlają hierarchiczną strukturę kodu źródłowego.
Przykład praktyczny
Ze względu na zakres tego artykułu nie można w nim omówić całego języka programowania. Dla uproszczenia rozważysz 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()
, zaimplementuj dowolny przykład dodawania, np.2 + 3
. - Aby napisać funkcję
multiply()
, możesz użyć na przykład funkcji6 * 12
.
Zgodnie z ostrzeżeniem jest to całkowicie bezużyteczne, ale wystarczająco proste dla analizatora leksykalnego, który może być wyrażeniem regularnym: /\d+\s*[\+\-\*\/]\s*\d+\s*/
.
Następnie musi być analizator. W rzeczywistości bardzo uproszczoną wersję abstrakcyjnego drzewa składni można utworzyć, używając wyrażenia regularnego z nazwami grup wychwytywania:/(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/
.
Polecenia ExampleScript występują pojedynczo w każdym wierszu, więc parsowanie może być wykonywane wiersz po wierszu przez dzielenie kodu na podstawie znaków nowej linii. Wystarczy to do sprawdzenia pierwszych 3 kroków z listy, czyli analizy leksykalnej, analizy składni i analizy semantycznej. Kod tych czynności znajduje się w następującej 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 abstrakcyjne drzewo składni (chociaż 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 abstrakcyjnego drzewa składni zawiera potrój złożony z elementów firstOperand
, operator
i secondOperand
. W przypadku każdego z 4 możliwych operatorów w ExampleScript, czyli +
, -
, *
, /
, do modułu należy dodać nową funkcję za pomocą metody Module#addFunction()
z Binaryen. Parametry metody Module#addFunction()
:
name
:string
, reprezentuje nazwę funkcji.functionType
:Signature
, reprezentuje podpis funkcji.varTypes
: aType[]
wskazuje dodatkowe osoby z danej lokalizacji w podanej kolejności.body
:Expression
, czyli zawartość funkcji.
Więcej szczegółów znajdziesz w dokumentacji Binaryen, ale ostatecznie operator +
w ExampleScript kończy się na metodzie Module#i32.add()
, która jest jedną z kilku dostępnych operacji na liczbach całkowitych.
Dodawanie wymaga 2 operandów: pierwszego i drugiego składnika. Aby można było wywołać funkcję, musi ona zostać wyeksportowana za pomocą funkcji 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 abstraktnego drzewa składni moduł zawiera 4 metody: 3 metody operujące na liczbach całkowitych, a mianowicie add()
na podstawie Module#i32.add()
, subtract()
na podstawie Module#i32.sub()
, multiply()
na podstawie Module#i32.mul()
oraz wyjątek divide()
na podstawie Module#f64.div()
, ponieważ ExampleScript działa też z wynikami zmiennoprzecinkowymi.
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 prawdziwymi bazami kodu, czasami znajdziesz 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 bieżącym przykładzie kompilacji ExampleScript na potrzeby Wasm, wystarczy dodać funkcję, która nie została wyeksportowana.
// 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 konieczne, ale zdecydowanie warto zweryfikować moduł za pomocą metody Module#validate()
.
if (!module.validate()) {
throw new Error('Validation error');
}
Pobieranie kodu Wasm
Aby uzyskać wynikowy kod Wasm, możesz użyć w Binaryen dwóch metod uzyskania reprezentacji tekstowej:jako pliku .wat
w wyrażeniu S, czyli w formacie zrozumiałym dla człowieka, oraz reprezentacji binarnej, czyli pliku .wasm
, który można uruchomić bezpośrednio w przeglądarce. Kod binarny można uruchomić bezpośrednio w przeglądarce. Aby sprawdzić, czy wszystko działa prawidłowo, możesz spróbować przeprowadzić eksport z zapisywaniem logów.
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);
Poniżej znajduje się pełna reprezentacja tekstowa programu ExampleScript ze wszystkimi 4 operacjami. Zwróć uwagę, że martwy kod nadal jest obecny, ale nie jest widoczny na zrzucie ekranuWebAssembly.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 z nich domyślnie stosuje standardowy zestaw reguł optymalizacji i pozwala ustawić poziom optymalizacji oraz kompresji, a druga domyślnie nie używa żadnych reguł, ale umożliwia pełne dostosowanie, co oznacza, że po odpowiednim przetestowaniu możesz dostosować ustawienia, aby uzyskać optymalne wyniki na podstawie kodu.
Optymalizacja za pomocą biblioteki Binaryen.js
Najprostszym sposobem optymalizacji modułu Wasm za pomocą Binaryen jest bezpośrednie wywołanie metody Module#optimize()
w Binaryen.js i opcjonalnie ustawienie optymalizacji i poziomu 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ł sztucznie wprowadzony wcześniej, więc tekstowa reprezentacja wersji Wasm przykładowego przykładu zabawki ExampleScript nie będzie go już zawierać. Zwróć też uwagę, jak pary local.set/get
są usuwane przez kroki optymalizacji SimplifyLocals (różne optymalizacje związane z lokalami) i Vacuum (usuwanie niepotrzebnego kodu), a element return
jest usuwany przez RemoveUnusedBrs (usuwanie przerw w miejscach, które 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 przebiegów optymalizacji, a Module#optimize()
używa domyślnych zbiorów reguł poszczególnych poziomów optymalizacji i skracania. Aby w pełni dostosować usługę, musisz użyć narzędzia wiersza poleceń wasm-opt
.
Optymalizowanie za pomocą narzędzia wiersza poleceń wasm-opt
Aby w pełni dostosować używane przepustki, możesz użyć narzędzia wiersza poleceń wasm-opt
, które jest dostępne w Binaryen. Pełną listę dostępnych opcji optymalizacji znajdziesz w wiadomości pomocy narzędzia. Narzędzie wasm-opt
jest prawdopodobnie najpopularniejszym narzędziem 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 pokazać, jak wyglądają karty, przedstawiamy fragmenty niektórych z nich, które można zrozumieć bez specjalistycznej wiedzy:
- Zwijanie kodu: zapobiega zduplikowaniu kodu przez jego połączenie (np. jeśli 2
if
ramiona mają wspólne instrukcje na końcu). - DeadArgumentElimination: optymalizacja w czasie łączenia, która usuwa argumenty funkcji, jeśli jest ona zawsze wywoływana z tymi samymi stałymi.
- MinifyImportsAndExports: skompresuj je do
"a"
,"b"
. - DeadCodeElimination: usuwanie martwego kodu.
Dostępny jest przepis na optymalizację, który zawiera kilka wskazówek dotyczących tego, które flagi są ważniejsze i które warto wypróbować jako pierwsze. Czasami wielokrotne stosowanie funkcji wasm-opt
powoduje dalsze zmniejszanie wartości wejściowej. W takich przypadkach uruchamianie programu z flagą --converge
powoduje powtarzanie iteracji, dopóki nie nastąpi dalsza optymalizacja i nie zostanie osiągnięty punkt stały.
Prezentacja
Aby zobaczyć w akcji pojęcia omówione w tym poście, skorzystaj z wbudowanego demonstracyjnego pliku ExampleScript, podając w nim dowolne dane wejściowe. Pamiętaj też, aby wyświetlić kod źródłowy wersji demonstracyjnej.
Podsumowanie
Binaryen to potężny zestaw narzędzi do kompilowania języków na potrzeby WebAssembly i optymalizowania powstałego kodu. Biblioteka JavaScript i narzędzia wiersza poleceń zapewniają elastyczność i łatwość użytkowania. W tym poście omówiliśmy podstawowe zasady kompilacji Wasm, zwracając uwagę na skuteczność i potencjał optymalizacji maksymalnej w Binaryen. Chociaż wiele opcji dostosowywania optymalizacji Binaryen wymaga dogłębnej znajomości wewnętrznej struktury Wasm, zwykle domyślne ustawienia działają świetnie. Życzymy udanego kompilowania i optymalizowania za pomocą Binaryen.
Podziękowania
Ten post został sprawdzony przez Alona Zakai, Thomasa Lively i Rachel Andrew.