Binaryen ile Wasm için derleme ve optimize etme

Binaryen, C++ ile yazılmış bir WebAssembly derleyici ve araç zinciri altyapı kitaplığıdır. WebAssembly'e derlemeyi sezgisel, hızlı ve etkili hale getirmeyi amaçlar. Bu yayında, ExampleScript adlı sentetik oyuncak dilinin örneğini kullanarak Binaryen.js API'yi kullanarak JavaScript'de WebAssembly modüllerini nasıl yazacağınızı öğreneceksiniz. Modül oluşturma, modüle işlev ekleme ve modülden işlev dışa aktarma ile ilgili temel bilgileri öğreneceksiniz. Bu sayede, gerçek programlama dillerini WebAssembly'e derlemenin genel mekanizması hakkında bilgi edinebilirsiniz. Ayrıca, Wasm modüllerini hem Binaryen.js ile hem de wasm-opt ile komut satırında nasıl optimize edeceğinizi öğreneceksiniz.

Binaryen hakkında bilgi

Binaryen, tek bir üstbilgide sezgisel bir C API'ye sahiptir ve JavaScript'ten de kullanılabilir. WebAssembly biçiminde girişleri kabul eder ancak bunu tercih eden derleyiciler için genel bir kontrol akışı grafiği de kabul eder.

Ara temsil (IR), kaynak kodunu temsil etmek için bir derleyici veya sanal makine tarafından dahili olarak kullanılan veri yapısı ya da koddur. Binaryen'in dahili IR'si kompakt veri yapıları kullanır ve mevcut tüm CPU çekirdeklerini kullanarak tamamen paralel kod oluşturma ve optimizasyon için tasarlanmıştır. Binaryen'in IR'si, WebAssembly'in alt kümesi olduğu için WebAssembly'e derlenir.

Binaryen'in optimizasyon aracı, kod boyutunu ve hızını artırabilecek birçok geçişe sahiptir. Bu optimizasyonlar, Binaryen'i tek başına bir derleyici arka ucu olarak kullanılabilecek kadar güçlü hale getirmeyi amaçlar. Wasm minifleştirmesi olarak düşünebileceğiniz, WebAssembly'ye özgü optimizasyonlar (genel amaçlı derleyicilerin yapamayacağı) içerir.

Binaryen'in örnek kullanıcısı olarak AssemblyScript

Binaryen, TypeScript benzeri bir dilden doğrudan WebAssembly'e derlemek için Binaryen'i kullanan AssemblyScript gibi çeşitli projeler tarafından kullanılır. AssemblyScript oyun alanındaki örneği deneyin.

AssemblyScript girişi:

export function add(a: i32, b: i32): i32 {
  return a + b;
}

Binaryen tarafından oluşturulan metin biçimindeki ilgili WebAssembly kodu:

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

Önceki örneğe göre oluşturulan WebAssembly kodunu gösteren AssemblyScript oyun alanı.

Binaryen araç zinciri

Binaryen araç seti, hem JavaScript geliştiricileri hem de komut satırı kullanıcıları için çeşitli faydalı araçlar sunar. Bu araçların bir alt kümesi aşağıda listelenmiştir. İçerilen araçların tam listesini projenin README dosyasında bulabilirsiniz.

  • binaryen.js: Wasm modülleri oluşturmak ve optimize etmek için Binaryen yöntemlerini gösteren bağımsız bir JavaScript kitaplığı. Derlemeler için npm'deki binaryen.js başlıklı makaleyi inceleyin (veya doğrudan GitHub veya unpkg'den indirin).
  • wasm-opt: WebAssembly'i yükleyen ve üzerinde Binaryen IR geçişleri çalıştıran komut satırı aracı.
  • wasm-as ve wasm-dis: WebAssembly'i derleyen ve derlemeyi bozan komut satırı araçları.
  • wasm-ctor-eval: Derleme zamanında işlevleri (veya işlevlerin bölümlerini) yürütebilen komut satırı aracı.
  • wasm-metadce: Wasm dosyalarının bölümlerini, modülün nasıl kullanıldığına bağlı olarak esnek bir şekilde kaldırmak için kullanılan komut satırı aracı.
  • wasm-merge: Birden fazla Wasm dosyasını tek bir dosyada birleştiren ve bu işlemi yaparken ilgili içe aktarma işlemlerini dışa aktarma işlemlerine bağlayan komut satırı aracı. JavaScript için bir paketleyici gibidir ancak Wasm içindir.

WebAssembly'e derleme

Bir dili başka bir dile derlemek genellikle birkaç adımdan oluşur. En önemlileri aşağıdaki listede verilmiştir:

  • Kelime analizi: Kaynak kodunu jetonlara bölün.
  • Söz dizimi analizi: Soyut söz dizimi ağacı oluşturun.
  • Anlamsal analiz: Hataları kontrol eder ve dil kurallarını uygular.
  • Ara kod oluşturma: Daha soyut bir temsil oluşturun.
  • Kod oluşturma: Hedef dile çevirin.
  • Hedefe özel kod optimizasyonu: Hedef için optimizasyon yapın.

Unix dünyasında derleme için sık kullanılan araçlar lex ve yacc'tir:

  • lex (Kelime Dizinli Analiz Aracı Oluşturucu): lex, kelime dizili analiz araçları (lexer veya tarayıcı olarak da bilinir) oluşturan bir araçtır. Giriş olarak bir dizi normal ifade ve ilgili işlem alır ve giriş kaynak kodundaki kalıpları tanıyan bir söz dizimi analizörü için kod oluşturur.
  • yacc (Yet Another Compiler Compiler): yacc, söz dizimi analizi için ayrıştırıcılar oluşturan bir araçtır. Giriş olarak bir programlama dilinin resmi dil bilgisi açıklamasını alır ve ayrıştırıcı için kod oluşturur. Ayıraçlayıcılar genellikle kaynak kodun hiyerarşik yapısını temsil eden soyut söz dizimi ağaçları (AST'ler) oluşturur.

Çözümlü örnek

Bu makalenin kapsamı göz önüne alındığında, bir programlama dilinin tamamını ele almak mümkün değildir. Bu nedenle, basitlik açısından, genel işlemleri somut örneklerle ifade ederek çalışan ve çok sınırlı ve işe yaramaz bir sentetik programlama dili olan ExampleScript'i ele alacağız.

  • add() işlevi yazmak için herhangi bir toplama örneği (ör. 2 + 3) kodlarsınız.
  • multiply() işlevi yazmak için örneğin 6 * 12 yazarsınız.

Önceki uyarıya göre tamamen işe yaramaz ancak leksik analizörünün tek bir normal ifade (/\d+\s*[\+\-\*\/]\s*\d+\s*/) olması için yeterince basittir.

Ardından bir ayrıştırıcı olmalıdır. Aslında, adlandırılmış yakalama grupları içeren bir normal ifade kullanılarak soyut söz dizimi ağacının çok basitleştirilmiş bir sürümü oluşturulabilir: /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/.

ExampleScript komutları her satırda bir tane olduğundan ayrıştırıcı, yeni satır karakterlerine göre bölünerek kodu satır bazında işleyebilir. Bu, önceden madde listesinde yer alan ilk üç adımı (leksik analiz, söz dizimi analizi ve anlamsal analiz) kontrol etmek için yeterlidir. Bu adımların kodu aşağıdaki girişte yer almaktadır.

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),
      };
    });
  }
}

Ara kod oluşturma

ExampleScript programları artık soyut bir söz dizimi ağacı olarak (oldukça basitleştirilmiş olsa da) temsil edilebildiğine göre, bir sonraki adım soyut bir ara temsil oluşturmaktır. İlk adım, Binaryen'de yeni bir modül oluşturmaktır:

const module = new binaryen.Module();

Soyut söz dizimi ağacının her satırı firstOperand, operator ve secondOperand öğelerinden oluşan bir üçlü içerir. ExampleScript'teki dört olası operatör (+, -, *, /) için Binaryen'in Module#addFunction() yöntemiyle modüle yeni bir işlev eklenmelidir. Module#addFunction() yöntemlerinin parametreleri aşağıdaki gibidir:

  • name: string, işlevin adını temsil eder.
  • functionType: Signature, işlevin imzasını temsil eder.
  • varTypes: Type[], belirtilen sırada ek yerel öğeleri belirtir.
  • body: işlevin içeriği olan bir Expression.

Ayrıntılara inmek ve bunları incelemek için daha fazla bilgi var. Binaryen belgeleri bu alanda gezinmenize yardımcı olabilir. Ancak sonunda, ExampleScript'in + operatörü için mevcut tam sayı işlemlerinden biri olan Module#i32.add() yöntemine ulaşırsınız. Toplama işlemi için ilk ve ikinci toplama terimi olmak üzere iki operand gerekir. İşlevin gerçekten çağrılabilir olması için Module#addFunctionExport() ile dışa aktarılması gerekir.

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

Soyut söz dizimi ağacı işlendikten sonra modül dört yöntem içerir. Bu yöntemlerden üçü tam sayılarla çalışır: Module#i32.add() tabanlı add(), Module#i32.sub() tabanlı subtract(), Module#i32.mul() tabanlı multiply(). Örnek komut dosyası, kayan noktalı sonuçlarla da çalıştığından dördüncü yöntem, Module#f64.div() tabanlı aykırı değer divide()'dir.

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 `/`.

Gerçek kod tabanlarıyla çalışıyorsanız bazen hiç çağrılmayan ölü kodlar olabilir. ExampleScript'in Wasm'e derlenmesine ilişkin çalışan örnekte yapay olarak ölü kod (daha sonraki bir adımda optimize edilip kaldırılacak) eklemek için dışa aktarılmamış bir işlev eklemek yeterlidir.

// 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)),
  ]),
);

Derleyici neredeyse hazır. Bu kesinlikle gerekli değildir ancak Module#validate() yöntemiyle modülü doğrulamak iyi bir uygulamadır.

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

Elde edilen Wasm kodunu alma

Elde edilen Wasm kodunu almak için Binaryen'de, S ifadesi biçiminde insan tarafından okunabilir bir .wat dosyası olarak metin temsilini ve doğrudan tarayıcıda çalıştırılabilen bir .wasm dosyası olarak ikili temsili almak için iki yöntem vardır. İkili kod doğrudan tarayıcıda çalıştırılabilir. İşlemin başarılı olup olmadığını görmek için dışa aktarma işlemlerini günlüğe kaydetmeniz faydalı olabilir.

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

Dört işlemin de yer aldığı bir ExampleScript programının tam metinsel gösterimi aşağıda listelenmiştir. Ölü kodun hâlâ mevcut olduğunu ancak WebAssembly.Module.exports() ekran görüntüsüne göre gösterilmediğini unutmayın.

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

WebAssembly modülünün dışa aktarma işlemlerini gösteren DevTools Konsolu ekran görüntüsü. Ekran görüntüsünde dört işlev gösterilmektedir: toplama, bölme, çarpma ve çıkarma (ancak dışa aktarılmayan ölü kod gösterilmemektedir).

WebAssembly'i optimize etme

Binaryen, Wasm kodunu optimize etmenin iki yolunu sunar. Biri Binaryen.js'de, diğeri komut satırı içindir. İlki varsayılan olarak standart optimizasyon kuralları grubunu uygular ve optimize etme ve küçültme düzeyini ayarlamanıza olanak tanır. İkincisi ise varsayılan olarak hiçbir kural kullanmaz ancak bunun yerine tam özelleştirmeye olanak tanır. Yani yeterli denemeyle ayarları kodunuza göre optimum sonuçlar için özelleştirebilirsiniz.

Binaryen.js ile optimizasyon

Bir Wasm modülünü Binaryen ile optimize etmenin en kolay yolu, Binaryen.js'nin Module#optimize() yöntemini doğrudan çağırmak ve isteğe bağlı olarak optimizasyon ve sıkıştırma düzeyini ayarlamaktır.

// 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();

Bu işlem, daha önce yapay olarak tanıtılan ölü kodu kaldırır. Böylece, ExampleScript oyun örneğinin Wasm sürümünün metinsel temsili artık bu kodu içermez. local.set/get çiftlerinin, SimplifyLocals (yerel değişkenlerle ilgili çeşitli optimizasyonlar) ve Vacuum (açıkça gereksiz kodları kaldırır) optimizasyon adımları tarafından nasıl kaldırıldığını ve return'in RemoveUnusedBrs (gereksiz yerlerdeki araları kaldırır) tarafından nasıl kaldırıldığını da unutmayın.

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

Birçok optimizasyon geçişi vardır ve Module#optimize(), belirli optimizasyon ve küçültme düzeylerinin varsayılan ayarlarını kullanır. Tam özelleştirme için wasm-opt komut satırı aracını kullanmanız gerekir.

wasm-opt komut satırı aracıyla optimizasyon yapma

Kullanılacak geçişlerin tam olarak özelleştirilmesi için Binaryen'de wasm-opt komut satırı aracı bulunur. Olası optimizasyon seçeneklerinin tam listesini görmek için aracın yardım mesajını inceleyin. wasm-opt aracı, muhtemelen araçlar arasında en popüler olanıdır ve Emscripten, J2CL, Kotlin/Wasm, dart2wasm, wasm-pack ve diğerleri gibi çeşitli derleyici araç zincirleri tarafından Wasm kodunu optimize etmek için kullanılır.

wasm-opt --help

Kartlar hakkında fikir edinmeniz için uzman bilgisi olmadan anlaşılabilen kartlardan bazılarının bir kısmını aşağıda bulabilirsiniz:

  • CodeFolding: Kodu birleştirerek yinelenen koddan kaçınır (örneğin, iki if kolun sonunda bazı ortak talimatlar varsa).
  • DeadArgumentElimination: Her zaman aynı sabitlerle çağrılırsa bir işleve ait bağımsız değişkenleri kaldırmak için bağlantı zamanı optimizasyon geçişi.
  • MinifyImportsAndExports: Bunları "a", "b" olarak küçültür.
  • DeadCodeElimination: Ölü kodu kaldırın.

Çeşitli işaretlerden hangilerinin daha önemli olduğunu ve önce denenmeye değer olduğunu belirlemek için çeşitli ipuçları içeren bir optimizasyon yemek kitabı mevcuttur. Örneğin, bazen wasm-opt tekrar tekrar çalıştırmak girişi daha da küçütür. Bu tür durumlarda, --converge işaretiyle çalıştırma işlemi, daha fazla optimizasyon gerçekleşmeyene ve sabit bir noktaya ulaşılana kadar yinelemeye devam eder.

Demo

Bu gönderide açıklanan kavramları çalışırken görmek için yerleşik deneme sürümünü kullanarak aklınıza gelen her ExampleScript girişini sağlayın. Ayrıca demonun kaynak kodunu görüntülemeyi unutmayın.

Sonuçlar

Binaryen, dilleri WebAssembly'e derlemek ve elde edilen kodu optimize etmek için güçlü bir araç seti sağlar. JavaScript kitaplığı ve komut satırı araçları, esneklik ve kullanım kolaylığı sunar. Bu gönderide, Wasm derlemenin temel ilkeleri gösterilerek Binaryen'in etkinliği ve maksimum optimizasyon potansiyeli vurgulanmıştır. Binaryen'in optimizasyonlarını özelleştirme seçeneklerinin çoğu Wasm'in iç yapısı hakkında derin bilgi gerektirse de genellikle varsayılan ayarlar zaten mükemmel çalışır. Bu noktada, Binaryen ile derleme ve optimizasyon yapmanın keyfini çıkarabilirsiniz.

Teşekkür ederiz

Bu yayın Alon Zakai, Thomas Lively ve Rachel Andrew tarafından incelendi.