Kompilowanie i optymalizacja Wasm za pomocą Binaryen

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

Plac zabaw w AssemblyScript przedstawiający wygenerowany kod WebAssembly na podstawie poprzedniego przykładu.

Ł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 i wasm-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ład 6 * 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)
  )
 )
)

Zrzut ekranu z konsoli narzędzi deweloperskich przedstawiający eksport modułu WebAssembly z 4 funkcjami: dodawaniem, dzieleniem, mnożeniem i odejmowaniem (ale bez ujawnionego, martwego kodu).

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.