Kompilowanie i optymalizacja Wasm za pomocą Binaryen

Binaryen to kompilator i łańcuch narzędzi, biblioteki infrastruktury WebAssembly napisanej w języku C++. Ma to na celu intuicyjna, szybka i skuteczna kompilacja do WebAssembly. W tym poście przy użyciu funkcji Przykład syntetycznego języka zabawek o nazwie ExampleScript, naucz się pisać Moduły WebAssembly w JavaScript przy użyciu interfejsu API Binaryen.js. Omówisz: podstaw tworzenia modułów, dodawania do nich funkcji i eksportowania ich z funkcji dostępnych w module. Dzięki temu poznasz ogólne czyli kompilowania rzeczywistych języków programowania do WebAssembly. Dalej: dowiesz się, jak zoptymalizować moduły Wasm zarówno za pomocą plików Binaryen.js, jak i w wiersza poleceń z wasm-opt.

Informacje o aplikacji Binaryen

Binaryen ma intuicyjny C API w jednym nagłówku, a także używanych za pomocą JavaScriptu. Akceptuje on też Formularz WebAssembly, lecz także akceptuje ogólne kontrolny wykres przepływu dla kompilatorów, którzy preferują taką opcję.

Reprezentacja pośrednia (IR) to używana struktura lub kod danych wewnętrznie przez kompilatora lub maszynę wirtualną do reprezentowania kodu źródłowego. Binaryen's wewnętrzna podczerwień korzysta z kompaktowych struktur danych i jest zaprojektowany z myślą generowanie i optymalizowanie kodu z wykorzystaniem wszystkich dostępnych rdzeni procesora. IR Binaryena kompiluje się do formatu WebAssembly, ponieważ jest podzbiorem WebAssembly.

Optymalizator Binaryen ma wiele kart, które mogą zwiększyć rozmiar i szybkość kodu. Te mają na celu zapewnienie wystarczającej wydajności Binaryen, aby można było używać go jako kompilatora z backendu. Obejmuje optymalizacje typowe dla WebAssembly (które kompilatory ogólnego przeznaczenia mogą nie działać), które mogą być nazywane Wasmem. minifikacji.

AssemblyScript jako przykładowy użytkownik Binaryen

Binaryen jest używany w wielu projektach, na przykład AssemblyScript, który używa Binaryen do a następnie skompilować je z języka podobnego do TypeScriptu bezpośrednio do WebAssembly. Przykład na placu zabaw na placu zabaw.

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

Plac zabaw AssemblyScript wyświetlający kod WebAssembly wygenerowany na podstawie poprzedniego przykładu.

Łańcuch narzędzi Binaryen

Łańcuch narzędzi Binaryen zawiera wiele przydatnych narzędzi JavaScript programistów i użytkowników wiersza poleceń. Część tych narzędzi znajduje się na liście obserwowany; pełna lista dostępnych narzędzi jest dostępny w pliku README projektu.

  • binaryen.js: samodzielna biblioteka JavaScript, która udostępnia metody Binaryen. w przypadku tworzenie i optymalizowanie modułów Wasm. Informacje o kompilacjach znajdziesz w sekcji binaryen.js na npm. (lub pobierz ją bezpośrednio z GitHub lub unpkg).
  • wasm-opt: narzędzie wiersza poleceń, które wczytuje WebAssembly i uruchamia Binaryen IR i ją omija.
  • wasm-as i wasm-dis: narzędzia wiersza poleceń służące do montażu i demontażu WebAssembly.
  • wasm-ctor-eval: narzędzie wiersza poleceń, które może wykonywać funkcje (lub części ).
  • wasm-metadce: narzędzie wiersza poleceń do usuwania części plików Wasm z elastycznego narzędzia w zależności od sposobu użycia tego modułu.
  • wasm-merge: narzędzie wiersza poleceń, które scala wiele plików Wasm w jeden. , łącząc odpowiednie operacje importu z eksportami. Podobne do ale dla Wasm.

Kompiluję do WebAssembly

Skompilowanie jednego języka na inny obejmuje zwykle kilka kroków, ważne są wymienione na tej liście:

  • Analiza leksykalna: podziel kod źródłowy na tokeny.
  • Analiza składni: tworzenie abstrakcyjnego drzewa składni.
  • Analiza semantyczna: sprawdzanie błędów i egzekwowanie reguł 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: optymalizacja pod kątem wartości docelowej.

W świecie Unix często używanymi narzędziami do kompilacji są: lex i yacc:

  • lex (Generator analizatora lekarskiego): lex to narzędzie do generowania np. lekserów lub skanerów. Potrzebny jest zestaw zwykłych wyrażenia i odpowiadające im działania jako dane wejściowe, a następnie generuje kod dla argumentu analizator leksykalny, który rozpoznaje wzorce w wejściowym kodzie źródłowym.
  • yacc (Yet Other Compiler Compiler): yacc to narzędzie, które generuje do analizy składni. Występuje w nim formalny opis gramatyczny języka programowania jako danych wejściowych i generowania kodu dla parsera. Analizatory zwykle produkują abstrakcyjne drzewa składni (AST), które reprezentują hierarchiczną strukturę kodu źródłowego.
.

Praktyczny przykład

Biorąc pod uwagę zakres tego posta, nie możemy przedstawić pełnego programu, dlatego dla uproszczenia należy rozważyć bardzo ograniczone i bezużyteczne syntetyczny język programowania o nazwie ExampleScript, który wykorzystuje funkcje na konkretnych przykładach.

  • Aby napisać funkcję add(), musisz napisać kod przykładowego dowolnego dodawania, np. 2 + 3
  • Aby napisać funkcję multiply(), napisz na przykład 6 * 12.

Zgodnie z wstępnym ostrzeżeniem jest ona całkowicie bezużyteczna, ale wystarczająco prosta, aby jej leksyczność analizator, by były pojedynczym wyrażeniem regularnym: /\d+\s*[\+\-\*\/]\s*\d+\s*/.

Następnie potrzebny jest parser. Właściwie to bardzo uproszczona wersja abstrakcyjne drzewo składni można utworzyć za pomocą wyrażenia regularnego nazwane grupy przechwytywania: /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/.

Polecenia exampleScript są umieszczone po jednym w wierszu, więc parser może przetworzyć kod w kolejnych wierszach, dzieląc je na znaki nowego wiersza. To wystarczy, aby sprawdzić pierwsze 3 kroki z poprzedniej listy, czyli analiza leksykalna, składnia analiza i analiza semantyczna. Kod dla tych kroków znajduje się w poniżej.

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ć jako abstrakcyjne drzewo składni. (choć może to być dość uproszczone), następnym krokiem jest utworzenie abstrakcyjnego schematu pośrednia. Najpierw utwórz nowy moduł w binaryen:

const module = new binaryen.Module();

Każdy wiersz abstrakcyjnego drzewa składni zawiera potrójny ciąg: firstOperand, operator i secondOperand. Dla każdej z czterech możliwych z operatorem ExampleScript, czyli +, -, *, /, do modułu należy dodać funkcję za pomocą metody Module#addFunction() Binaryena. Parametry Module#addFunction():

  • name: string reprezentuje nazwę funkcji.
  • functionType: Signature, reprezentuje podpis funkcji.
  • varTypes: Type[] oznacza dodatkowe lokalne w podanej kolejności.
  • body: Expression – zawartość funkcji.

Zostało jeszcze kilka detali, które pozwolą Ci się rozluźnić, Dokumentacja pliku binarnego może ułatwić poruszanie się po pokoju, ale z czasem w przypadku skryptu + ExampleScript wyświetli się metoda Module#i32.add() jako jedna z kilku dostępne operacji liczb całkowitych. Dodawanie wymaga 2 operandów: pierwszego i drugiego sumy. W przypadku atrybutu aby funkcja była rzeczywiście wywoływana, musi to być wyeksportowane dzięki 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: trzy działają z liczbami całkowitymi, a konkretnie add() na podstawie Module#i32.add(), subtract() na podstawie danych z Module#i32.sub(), multiply() na podstawie Module#i32.mul() oraz wartość odstająca divide() na podstawie Module#f64.div() ponieważ ExampleScript działa też w przypadku wyników 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 zajmujesz się prawdziwymi bazami kodu, może się zdarzyć, że powstanie martwy kod, który nigdy zostanie wywołany. Sztuczne wprowadzanie martwego kodu (zoptymalizowanego i w późniejszym kroku) w uruchomionym przykładzie skryptu kompilację danych do Wasm, dodanie niewyeksportowanej funkcji załatwia sprawę.

// 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 na pewno sprawdzoną metodę Sprawdź moduł za pomocą metody Module#validate().

if (!module.validate()) {
  throw new Error('Validation error');
}

Uzyskiwanie wynikowego kodu Wasm

Do pozyskanie powstałego kodu Wasm, w Binaryen istnieją dwie metody pobierania reprezentacja tekstowa jako plik .wat w wyrażeniu S w formacie zrozumiałym dla człowieka, reprezentacja binarna jako plik .wasm, który można uruchomić bezpośrednio w przeglądarce. Kod binarny może być które można uruchomić bezpośrednio w przeglądarce. Aby się upewnić, że wszystko działa, logowanie eksportów może pomocy.

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 elementami poniżej. Zwróć uwagę na to, że kod martwy jest nadal dostępny. ale nie widać jej 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)
  )
 )
)

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. w samym pliku Binaryen.js, dla wiersza poleceń. Pierwszy z nich stosuje standardowy zestaw optymalizacji. reguły i pozwala ustawić poziom optymalizacji i zmniejszania ta druga opcja domyślnie nie używa żadnych reguł, ale umożliwia pełne dostosowanie, Oznacza to, że po przeprowadzeniu wystarczającej ilości eksperymentów można dostosować ustawienia na podstawie Twojego kodu.

Optymalizacja za pomocą Binaryen.js

Najprostszym sposobem optymalizacji modułu Wasm za pomocą narzędzia Binaryen jest bezpośrednio wywoływać metodę Module#optimize() w pliku Binaryen.js i opcjonalnie ustawianie optymalizacji 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();

Powoduje to usunięcie martwego kodu, który został sztucznie wprowadzony, przez co kod wersja tekstowa przykładu zabawki exampleScript w wersji Wasm – nie nie zawiera żadnego ciągu znaków. Zwróć też uwagę, jak pary local.set/get są usuwane przez funkcję kroki optymalizacji SimplifyLocals (różne optymalizacje związane z lokalnymi) oraz Odkurzacz (usuwa oczywiście niepotrzebny kod), a return jest usuwany przez RemoveUnusedBrs (usuwa przerwy z miejsc, 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)
  )
 )
)

Jest ich wiele przebiegi optymalizacji, a Module#optimize() korzysta z konkretnej optymalizacji i zmniejszania poziomów” domyślna zestawó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ć karty, które mają być używane, Binaryen zawiera: Narzędzie wiersza poleceń wasm-opt. Aby uzyskać pełną listę możliwych opcji optymalizacji, przeczytaj komunikat pomocy narzędzia. Narzędzie wasm-opt jest prawdopodobnie najpopularniejsze jest używany przez kilka łańcuchów narzędzi kompilacji do optymalizacji kodu Wasm, w tym Emscripten, J2CL, Kotlin/Wasm, dart2wasm, wasm-pack i inne.

wasm-opt --help

Aby ułatwić sobie posługiwanie się karnetami, oto fragment kilku z nich, są zrozumiałe bez wiedzy specjalistycznej:

  • CodeFolding: unika powielania kodu przez jego scalanie (na przykład jeśli 2 obiekty if grupy eksperymentalnej mają po swojej stronie udostępnione instrukcje).
  • DeadArgumentElimission: połączenie czasu optymalizacji czasu w celu usunięcia argumentów. do funkcji, jeśli jest ona zawsze wywoływana z tymi samymi stałymi.
  • MinifyImportsAndExports: minimalizuje je do formatu "a" w języku: "b".
  • DeadCodeElimission: usunięcie martwego kodu.

Jest książka kucharska dotycząca optymalizacji wraz z kilkoma wskazówkami dotyczącymi rozpoznawania, które flagi są bardziej warto sprawdzić w YouTube. Na przykład czasami uruchomiona jest usługa wasm-opt wielokrotnie zmniejsza wartość wejściową, W takich przypadkach uruchomienie z Flaga --converge cały czas powtarza się, dopóki optymalizacja nie zostanie zakończona, a stały punkt udało się dotrzeć.

Prezentacja

Aby zobaczyć w praktyce zasady przedstawione w tym poście, użyj z dowolnymi danymi wejściowymi ExampleScript. Pamiętaj też o wyświetl kod źródłowy wersji demonstracyjnej.

Podsumowanie

Binaryen zapewnia zaawansowany zestaw narzędzi do kompilowania języków do WebAssembly oraz na optymalizację 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 Kompilacja Wasm przedstawiająca skuteczność i potencjał aplikacji Binaryen podczas optymalizacji. Chociaż wiele opcji dostosowywania interfejsu Binaryen wymagają dogłębnej wiedzy o elementach wewnętrznych Wasm, ustawienia domyślne działają świetnie. Życzymy udanego kompilowania i optymalizacji dzięki Binaryen.

Podziękowania

Ten post zweryfikował Alon Zakai, Thomas Lively i Rachel Andrew