Kompilowanie i optymalizacja Wasm za pomocą Binaryen

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. Z tego posta dowiesz się, jak pisać moduły WebAssembly w języku JavaScript za pomocą interfejsu Binaryen.js API, korzystając z przykładowego języka zabawek syntetycznych o nazwie ExampleScript. Poznasz podstawy tworzenia modułów, dodawania do nich funkcji i eksportowania funkcji z modułów. W ten sposób poznasz ogólną mechanikę kompilowania rzeczywistych języków programowania do WebAssembly. Ponadto dowiesz się, jak optymalizować moduły Wasm za pomocą Binaryen.js i na wierszu poleceń za pomocą wasm-opt.

Informacje o aplikacji Binaryen

Binaryen ma intuicyjny interfejs C API w pojedynczym nagłówku, a także można go użyć w JavaScriptzie. Akceptuje dane wejściowe w formie WebAssembly, ale obsługuje też ogólne diagramy przepływu sterowania na potrzeby kompilatorów, które preferują ten format.

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ł wewnętrznego systemu Binaryen wykorzystuje kompaktowe struktury danych i został zaprojektowany z myślą o całkowicie równoległym generowaniu i optymalizowaniu kodu z wykorzystaniem wszystkich dostępnych rdzeni procesora. IR w Binaryen jest kompilowany do WebAssembly, ponieważ jest 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 zapoznaj się z plikiem binaryen.js na npm (lub pobierz go bezpośrednio z GitHub 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ń 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

Skompilowanie jednego języka na inny obejmuje zwykle kilka kroków. Oto lista najważniejszych z nich:

  • Analiza leksykalna: podziel kod źródłowy na tokeny.
  • Analiza składni: tworzenie drzewa abstrakcyjnej składni.
  • Analiza semantyczna: sprawdzanie błędów i egzekwowanie zasad językowych.
  • Średnio zaawansowane generowanie kodu: utwórz 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. Parsery tworzą zwykle abstrakcyjne drzewa składni (AST), które reprezentują 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 przygotować kod przykładowego dowolnego dodawania, np. 2 + 3.
  • Aby napisać funkcję multiply(), napisz na przykład 6 * 12.

Zgodnie z ostrzeżeniem wstępnym jest to całkowicie bezużyteczne, ale na tyle proste, że analizator leksykalny mógłby być pojedynczym wyrażeniem regularnym: /\d+\s*[\+\-\*\/]\s*\d+\s*/.

Następnie potrzebny jest parser. 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 dla średnio zaawansowanych

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 metod Module#addFunction() są następujące:

  • name: string, reprezentuje nazwę funkcji.
  • functionType: Signature, reprezentuje podpis funkcji.
  • varTypes: a Type[] wskazuje dodatkowe osoby z danej lokalizacji w podanej kolejności.
  • body: Expression – 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');
}

Uzyskiwanie wynikowego kodu Wasm

Aby uzyskać wynikowy kod Wasm, możesz użyć w Binaryen dwóch metod uzyskania reprezentacji tekstowej:jako pliku .watwyraż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ć rejestrowanie 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 z wyeksportowanymi danymi modułu WebAssembly zawierającymi 4 funkcje: dodawanie, dzielenie, mnożenie i odejmowanie (ale nie ujawniony martwy kod).

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ładowego przykładu zabawki ExampleScript nie będzie go już zawierać. 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ć tę usługę, użyj 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 najpopularniejszym spośród narzędzi. Jest używane przez kilka łańcuchów narzędzi do optymalizacji kodu Wasm, w tym Emscripten, J2CL, Kotlin/Wasm, dart2wasm czy wasm-pack.

wasm-opt --help

Aby dać Ci wyobrażenie o karnetach, oto fragment niektórych z tych, które są zrozumiałe bez specjalistycznej wiedzy:

  • Zwijanie kodu: zapobiega zduplikowaniu kodu przez jego scalenie (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ę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 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 oraz optymalizowania powstałego kodu. Biblioteka JavaScript i narzędzia wiersza poleceń są elastyczne i łatwe w użyciu. W tym poście przedstawiliśmy podstawowe zasady kompilacji Wasm, podkreślając skuteczność i potencjał działania aplikacji Binaryen w zakresie maksymalnej optymalizacji. 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 napisali Alon Zakai, Thomas Lively i Rachel Andrew.