Kompilowanie i optymalizacja Wasm za pomocą Binaryen

Binaryen to biblioteka do kompilatora i infrastruktury łańcucha narzędzi dla WebAssembly napisana w języku C++. Ma na celu zapewnienie intuicyjnej, szybkiej i efektywnej kompilacji w WebAssembly. W tym artykule na przykładzie syntetycznego języka zabawkowego o nazwie ExampleScript nauczysz się pisać moduły WebAssembly w JavaScript za pomocą interfejsu Binaryen.js API. Poznasz podstawy tworzenia modułów, dodawania do nich funkcji oraz eksportowania z niego funkcji. Dzięki temu poznasz ogólne mechanizmy kompilowania rzeczywistych języków programowania na WebAssembly. Dowiesz się też, jak zoptymalizować moduły Wasm zarówno za pomocą pliku Binaryen.js, jak i w wierszu poleceń z użyciem wasm-opt.

Wprowadzenie do Binaryen

Binaryen ma intuicyjny interfejs C API w pojedynczym nagłówku, a także można go użyć w JavaScriptzie. Akceptuje on dane wejściowe w formie WebAssembly, ale akceptuje też ogólny wykres kontrolny dla kompilatorów, które preferują taki sposób.

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. Dane IR Binaryen kompilują się do WebAssembly, ponieważ są podzbiorem WebAssembly.

Optymalizator w 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 ona 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
 )
)

Playground AssemblyScript pokazujący wygenerowany kod WebAssembly na podstawie poprzedniego przykładu.

Łańcuch narzędzi Binaryen

Zestaw narzędzi Binaryen oferuje wiele przydatnych narzędzi zarówno dla programistó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 JavaScript, która udostępnia metody Binaryen do tworzenia i optymalizowania modułów Wasm. W przypadku kompilacji zobacz binaryen.js na npm (lub pobierz go bezpośrednio z GitHuba lub unpkg).
  • wasm-opt: narzędzie wiersza poleceń, które wczytuje WebAssembly i uruchamia na nim przekształcenia Binaryen IR.
  • wasm-aswasm-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ń służące do usuwania części plików Wasm w elastyczny 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.

Kompiluję do 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 (Lexical Analyzer Generator): lex to narzędzie generujące analizator leksykalny, nazywane 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ładowy przykład

Biorąc pod uwagę zakres tego postu, nie da się przedstawić całego języka programowania, więc dla uproszczenia rozważ użycie bardzo ograniczonego i bezużytecznego języka programowania syntetycznego o nazwie ExampleScript, w którym sposób działania jest przedstawiany na podstawie konkretnych przykładów.

  • Aby napisać funkcję add(), musisz zaimplementować przykład dowolnego dodawania, np. 2 + 3.
  • Aby napisać funkcję multiply(), możesz użyć na przykład funkcji 6 * 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ładnianalizy semantycznej. Kod tych czynności znajdziesz 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 (choć dość uproszczone), kolejnym 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, operatorsecondOperand. 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() w Binaryen. Parametry metody Module#addFunction():

  • name: string, reprezentuje nazwę funkcji.
  • functionType: Signature, reprezentuje podpis funkcji.
  • varTypes: Type[] oznacza dodatkowe lokalne 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 sumy. 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 zajmujesz się rzeczywistymi bazami kodu, może się zdarzyć, że powstanie martwy kod, który nigdy nie zostanie wywołany. 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, w Binaryen dostępne są 2 metody pobierania reprezentacji tekstowej w postaci pliku .wat w wyrażeniu S w postaci zrozumiałej dla człowieka formatu oraz binarnej reprezentacji w postaci pliku .wasm, które można uruchomić bezpośrednio w przeglądarce. Kod binarny można uruchomić bezpośrednio w przeglądarce. Aby sprawdzić, czy udało się rozwiązać problem, pomóc może 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);

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

Zrzut ekranu konsoli DevTools z eksportowanymi modułami WebAssembly, które zawierają 4 funkcje: dodawanie, dzielenie, mnożenie i odejmowanie (ale nie nieużywany kod martwy).

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 przeprowadzeniu odpowiedniej liczby eksperymentów 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ładu zabawki ExampleScript nie zawiera go. Zwróć też uwagę, że 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 karnetów optymalizacji, a Module#optimize() korzysta z konkretnych ustawień domyślnych optymalizacji i zmniejszania poziomów. Aby w pełni dostosować usługę, 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 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 najbardziej popularnym 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 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:

  • CodeFolding: unika powielania kodu przez jego scalanie (np. jeśli 2 grupy if mają po swojej stronie wspólne instrukcje).
  • 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".
  • DeadCodeElimission: usunięcie martwego kodu.

Dostępna jest książka kucharska na temat optymalizacji z kilkoma wskazówkami, które pomogą Ci określić, które z tych flag są ważniejsze i warto wypróbować je w pierwszej kolejności. Czasami wielokrotne uruchamianie funkcji wasm-optpowoduje dalsze zmniejszanie wartości wejściowej. W takich przypadkach uruchamianie programu z opcją --converge powoduje powtarzanie iteracji do momentu, gdy nie nastąpi dalsza optymalizacja i nie zostanie osiągnięty punkt stały.

Prezentacja

Aby przekonać się, jak koncepcje przedstawione w tym poście pojawiają się w praktyce, użyj umieszczonej na stronie wersji demonstracyjnej, dodając do niej dowolne dane wejściowe exampleScript, które przychodzą Ci do głowy. Pamiętaj też, by zobaczyć kod źródłowy wersji demonstracyjnej.

Podsumowanie

Binaryen to potężny zestaw narzędzi do kompilowania języków na potrzeby WebAssembly oraz 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.