Binaryen to kompilator i biblioteka infrastruktury łańcucha narzędzi dla WebAssembly napisana w języku C++. Dzięki niej kompilowanie w WebAssembly jest intuicyjne, szybkie i skuteczne. Korzystając z przykładowego syntetycznego języka zabawkowego o nazwie ExampleScript, nauczysz się pisać moduły WebAssembly w języku JavaScript przy użyciu interfejsu Binaryen.js API. Omówimy podstawy tworzenia modułów, dodawania do nich funkcji oraz eksportowania ich z niego. Dzięki temu poznasz ogólną mechanizm kompilowania
prawdziwych języków programowania w WebAssembly. Z dalszej części dowiesz się, jak optymalizować moduły Wasm zarówno za pomocą pliku Binaryen.js, jak i z poziomu wiersza poleceń w wasm-opt
.
Informacje o usłudze Binaryen
Binaryen ma intuicyjny interfejs C API w jednym nagłówku i można go używać z JavaScriptu. Akceptuje dane wejściowe w formie WebAssembly, ale akceptuje też ogólny schemat przepływu kontroli na potrzeby kompilatorów, którzy preferują takie działanie.
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ętrzna funkcja IR w Binaryen wykorzystuje kompaktowe struktury danych i została zaprojektowana pod kątem całkowicie równoległego generowania i optymalizacji kodu z wykorzystaniem wszystkich dostępnych rdzeni procesora. Funkcja IR Binaryen kompiluje się do WebAssembly, ponieważ jest podzbiorem WebAssembly.
Optymalizator Binaryen ma wiele przebiegów, które mogą zwiększyć rozmiar kodu i szybkość działania. Optymalizacje te mają na celu zwiększenie skuteczności pliku Binaryen, aby można go było używać samodzielnie jako backendu kompilatora. Zawiera optymalizacje specyficzne dla WebAssembly (które mogą nie działać w przypadku zwykłych kompilatorów), co można porównać do minifikacji Wasm.
AssemblyScript jako przykładowy użytkownik Binaryen
Plik Binaryen jest używany przez wiele projektów, na przykład AssemblyScript, który korzysta z Binaryen do skompilowania kodu z języka podobnego do TypeScriptu bezpośrednio do WebAssembly. Wypróbuj ten przykład na placu zabaw 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
Łańcuch narzędzi Binaryen udostępnia wiele przydatnych narzędzi zarówno dla programistów JavaScript, jak i użytkowników korzystających z wiersza poleceń. Zestaw tych narzędzi jest wymieniony poniżej. Pełna lista narzędzi, które zawiera, 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 zapoznaj się z plikiem binaryen.js na npm (lub pobierz go bezpośrednio z GitHub bądź unpkg).wasm-opt
: narzędzie wiersza poleceń, które wczytuje WebAssembly i uruchamia w jej przypadku przekazywanie Binaryen IR.wasm-as
iwasm-dis
: narzędzia wiersza poleceń, które pozwalają na kompilację i dezasemblowanie WebAssembly.wasm-ctor-eval
: narzędzie wiersza poleceń, które może wykonywać funkcje (lub ich części) podczas kompilacji.wasm-metadce
: narzędzie wiersza poleceń służące do usuwania części plików Wasm w elastyczny sposób w zależności 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 importy z eksportami. Jak w przypadku języka JavaScript, ale w przypadku Wasm.
Kompiluję do WebAssembly
Kompilowanie jednego języka na inny zwykle składa się z kilku etapów. Najważniejsze z nich są wymienione na tej liście:
- Analiza seksualna: podziel kod źródłowy na tokeny.
- Analiza składni: tworzenie abstrakcyjnego drzewa składni.
- Analiza semantyczna: sprawdzaj występowanie błędów i egzekwuj reguły językowe.
- Generowanie kodu na poziomie średnio zaawansowanym: tworzenie bardziej abstrakcyjnej reprezentacji.
- Generowanie kodu: tłumaczenie na język docelowy.
- Optymalizacja kodu pod kątem celu: optymalizacja pod kątem celu.
W świecie uniksowym często używanymi narzędziami do kompilacji są lex
i yacc
:
lex
(Lexical Analysis Generator):lex
to narzędzie do generowania analizatorów leksykacyjnych znanych również jako leksyk lub skanery. Wykorzystuje zestaw wyrażeń regularnych i odpowiadających im działań jako dane wejściowe oraz generuje kod na potrzeby analizatora leksykańskiego, który rozpoznaje wzorce w wejściowym kodzie źródłowym.yacc
(kolejny kompilator kompilatora):yacc
to narzędzie, które generuje parsery do analizy składni. Wykorzystuje on formalny opis gramatyczny języka programowania jako dane wejściowe i generuje kod dla parsera. Parsery zwykle tworzą abstrakcyjne drzewa składni (AST), które reprezentują hierarchiczną strukturę kodu źródłowego.
Praktyczny przykład
Biorąc pod uwagę zakres tego postu, nie jesteśmy w stanie zająć się pełnym językiem programowania. Aby go uprościć, uwzględnij bardzo ograniczony i bezużyteczny syntetyczny język programowania o nazwie ExampleScript, który wyraża działania ogólne za pomocą konkretnych przykładów.
- Aby napisać funkcję
add()
, musisz przygotować przykładową dowolną funkcję, np.2 + 3
. - Aby napisać funkcję
multiply()
, musisz napisać na przykład6 * 12
.
Zgodnie z wstępnym ostrzeżeniem jest to zupełnie bezużyteczne, ale na tyle proste, że jego analizator leksyktyczny może stanowić pojedyncze wyrażenie regularne: /\d+\s*[\+\-\*\/]\s*\d+\s*/
.
Następnie potrzebny jest parser. W rzeczywistości bardzo uproszczoną wersję abstrakcyjnego drzewa składni można utworzyć za pomocą wyrażenia regularnego z nazwanymi grupami przechwytywania: /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/
.
Polecenia ExampleScript są podane po jednym w wierszu, więc parser może przetworzyć kod w wierszu, dzieląc go na znaki nowego wiersza. To wystarczy, aby zapoznać się z pierwszymi 3 krokami z poprzedniej listy punktowanej, czyli analizą leksyczną, analizą składniową i analizą semantyczną. Kod tych kroków znajduje się na tej 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 dla średnio zaawansowanych
Programy ExampleScript można przedstawić w postaci abstrakcyjnego drzewa składni (chociaż jest to dość uproszczone), więc kolejnym krokiem jest utworzenie abstrakcyjnej reprezentacji pośredniej. Pierwszym krokiem jest utworzenie nowego modułu w Bbinaryen:
const module = new binaryen.Module();
Każdy wiersz abstrakcyjnego drzewa składni zawiera potrójny element składający się z firstOperand
, operator
i secondOperand
. Dla każdego z 4 możliwych operatorów w języku ExampleScript, czyli +
, -
, *
, /
, należy dodać do modułu nową funkcję za pomocą metody Module#addFunction()
Binaryena. Metody Module#addFunction()
mają te parametry:
name
:string
– reprezentuje nazwę funkcji.functionType
:Signature
reprezentuje podpis funkcji.varTypes
:Type[]
wskazuje dodatkowe lokalne w podanej kolejności.body
:Expression
– zawartość funkcji.
Jest trochę więcej szczegółów, które warto poznać, a dokumentacja Binaryen może pomóc Ci w poruszaniu się po przestrzeni. W końcu operator +
przykładu Przykładowego skryptu kończy się na metodzie Module#i32.add()
jako jednej z kilku dostępnych operacji dotyczących liczb całkowitych.
Dodawanie wymaga 2 operandów – pierwszego i drugiego sumy. Aby można było wywołać tę funkcję, trzeba ją wyeksportować 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 abstrakcyjnego drzewa składni moduł zawiera 4 metody: 3 z liczbą całkowitą, czyli add()
na podstawie Module#i32.add()
, subtract()
na podstawie Module#i32.sub()
, multiply()
na podstawie Module#i32.mul()
oraz wynik divide()
na podstawie Module#f64.div()
,
ponieważ ExampleScript działa też z wynikami liczb 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 faktycznymi bazami kodu, czasami pojawia się 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 kodu ExampleScript w Wasm, dodanie niewyeksportowanej funkcji spełnia to zadanie.
// 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 prawie gotowy. Nie jest to bezwzględnie konieczne, ale zdecydowanie dobrze jest zweryfikować moduł za pomocą metody Module#validate()
.
if (!module.validate()) {
throw new Error('Validation error');
}
Uzyskiwanie otrzymanego kodu Wasm
Do uzyskania wynikowego kodu Wasm w systemie Binaryen służą 2 metody uzyskiwania reprezentacji tekstowej jako pliku .wat
w formacie S w formacie zrozumiałym dla człowieka oraz pliku binarnego, który można uruchomić bezpośrednio w przeglądarce..wasm
Kod binarny
można uruchomić bezpośrednio w przeglądarce. Aby sprawdzić, czy wszystko zadziałało, pomocne może być logowanie eksportó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);
Pełna reprezentacja tekstowa programu ExampleScript ze wszystkimi 4 operacjami znajduje się poniżej. Zwróć uwagę, że martwy kod wciąż tam jest, ale jest go nie widać, jak 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 udostępnia 2 sposoby optymalizacji kodu Wasm. Jeden dla pliku Binaryen.js i drugi w wierszu poleceń. Pierwszy z nich domyślnie stosuje standardowy zestaw reguł optymalizacji i umożliwia ustawienie poziomu optymalizacji i zmniejszania, natomiast drugi domyślnie nie korzysta z żadnych reguł, ale umożliwia pełne dostosowanie, dzięki czemu po wystarczającej ilości eksperymentów można dostosować ustawienia w celu uzyskania optymalnych wyników na podstawie kodu.
Optymalizacja za pomocą pliku Binaryen.js
Najprostszym sposobem optymalizacji modułu Wasm za pomocą Binaryen jest bezpośrednie wywołanie metody Module#optimize()
pliku Binaryen.js i opcjonalnie ustawienie poziomu Optimize i zmniejszania.
// 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();
W ten sposób usuniesz martwy kod, który został sztucznie wprowadzony wcześniej, dzięki czemu nie zawiera już go w formie tekstowej wersji Wasm przykładowej zabawki z przykładowego skryptu. Zwróć też uwagę na to, w jaki sposób pary local.set/get
są usuwane w ramach kroków optymalizacji SimplifyLocals (różne optymalizacje związane z lokalnymi) i Vacuum (usuwa oczywiście niepotrzebny kod), a return
usuwa oczywiście niepotrzebny kod, a element return
jest usuwany przez metodę RemoveUnusedBrs (Usuwaj nieużywany brs) (usuwa przerwy w niepotrzebnych lokalizacjach).
(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)
)
)
)
Jest wiele karnetów optymalizacji, a Module#optimize()
używa konkretnych domyślnych ustawień optymalizacji i zmniejszania poziomów. Aby w pełni dostosować usługę, użyj narzędzia wiersza poleceń wasm-opt
.
Optymalizacja za pomocą narzędzia wiersza poleceń wasm-opt
Binaryen zawiera narzędzie wiersza poleceń wasm-opt
, które umożliwia pełne dostosowanie używanych kart. Pełną listę możliwych opcji optymalizacji znajdziesz w komunikacie pomocy dotyczącym narzędzia. Narzędzie wasm-opt
jest prawdopodobnie najpopularniejsze i jest używane przez różne łańcuchy narzędzi kompilatora do optymalizacji kodu Wasm, w tym Emscripten, J2CL, Kotlin/Wasm, dart2wasm czy wasm-pack.
wasm-opt --help
Aby przybliżyć Ci zasady posługiwania się kartami, oto kilka z nich, które są zrozumiałe bez specjalistycznej wiedzy:
- CodeFolding: unika powielania kodu przez scalanie go (np. jeśli 2 grupy
if
mają po stronie wspólne instrukcje). - DeadArgumentEliulation:optymalizacja pod kątem czasu pozwala usunąć argumenty funkcji, jeśli jest ona zawsze wywoływana z tymi samymi stałymi.
- MinifyImportsAndExports: minifikuje do formatu
"a"
("b"
). - DeadCodeEliulation (DeadCodeElimin): usuń martwy kod.
Dostępna jest książka kucharska na temat optymalizacji, w której znajdziesz kilka wskazówek, które pomogą Ci określić, które z flag są ważniejsze i warto wypróbować najpierw. Na przykład wielokrotne wykonywanie polecenia wasm-opt
powoduje jeszcze większe zmniejszenie danych wejściowych. W takich przypadkach uruchomienie z flagą --converge
trwa iterację, dopóki optymalizacja nie zostanie przerwana i nie zostanie osiągnięty ustalony punkt.
Pokaz
Aby zobaczyć, jak pojęcia przedstawione w tym poście są w praktyce, użyj umieszczonej wersji demonstracyjnej z podanymi w niej przykładowymi danymi wejściowymi. 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 powstałego kodu. Jej biblioteka JavaScript i narzędzia wiersza poleceń zapewniają elastyczność i łatwość obsługi. W tym poście przedstawiliśmy podstawowe zasady kompilacji Wasm, podkreślając skuteczność Binaryen i możliwości jej maksymalnej optymalizacji. Wiele opcji dostosowywania optymalizacji usługi Binaryen wymaga dogłębnej wiedzy o samym systemie Wasm, ale zwykle ustawienia domyślne już teraz działają świetnie. Życzę udanego kompilowania i optymalizowania w Binaryen.
Podziękowania
Ten post został opublikowany przez Alon Zakai, Thomas Lively i Rachel Andrew.