Binaryen ile Wasm için derleme ve optimize etme

Binaryen, C++ ile yazılmış bir WebAssembly derleyicisi 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 başlıkta 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 gösterim (IR), kaynak kodunu temsil etmek için bir derleyici veya sanal makine tarafından dahili olarak kullanılan veri yapısı veya kodudur. Binaryen'in dahili IR özelliği, kompakt veri yapılarını kullanır ve mevcut tüm CPU çekirdeklerini kullanarak tamamen paralel kod üretimi ve optimizasyonu için tasarlanmıştır. Binaryen'in IR işlevi, WebAssembly'nin bir alt kümesi olduğundan WebAssembly'de derlenir.

Binaryen'in optimize edicisi, kod boyutunu ve hızını artırabilecek birçok karta sahip. Bu optimizasyonlar, Binaryen'i tek başına derleyici arka ucu olarak kullanılabilecek kadar güçlü hale getirmeyi amaçlar. WebAssembly'ye özgü optimizasyonlar (genel amaçlı derleyicilerin yapamayacağı) içerir. Bu optimizasyonları Wasm küçültme olarak düşünebilirsiniz.

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

Binaryen, çeşitli projelerde (ör. AssemblyScript) kullanılmaktadır. Bu proje, TypeScript benzeri bir dilden doğrudan WebAssembly'de derleme yapmak için Binaryen'i kullanır. AssemblyScript oyun alanında örneği deneyin.

AssemblyScript girişi:

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

Binaryen tarafından oluşturulan metin biçiminde, karşılık gelen 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 listesine projenin README dosyasından ulaşabilirsiniz.

  • binaryen.js: Wasm modülleri oluşturmak ve optimize etmek için kullanılan Binaryen yöntemlerini gösteren bağımsız bir JavaScript kitaplığı. Derlemeler için npm'de binaryen.js bölümüne bakın (veya doğrudan GitHub ya da unpkg'dan indirin).
  • wasm-opt: WebAssembly'yi yükleyen ve üzerinde Binaryen IR geçişlerini ç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 için 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.
  • Orta seviye kod oluşturma: Daha soyut bir temsil oluşturun.
  • Kod oluşturma: Hedef dile çevirin.
  • Hedefe özel kod optimizasyonu: Hedef için optimize edin.

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şlemleri 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 gönderinin kapsamı göz önünde bulundurulduğunda programlama dilini eksiksiz bir şekilde ele almak mümkün değildir. Bu nedenle sadelik açısından, basitlik açısından, genel işlemleri somut örneklerle ifade ederek çalışan ExampleScript adlı çok sınırlı ve işe yaramayan bir sentetik programlama dilini göz önünde bulundurun.

  • 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 özet 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 kodunu aşağıdaki listede bulabilirsiniz.

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 temsil edilebildiğine göre (oldukça basit olsa da) bir sonraki adım soyut bir ara gösterim 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.

Gevşeyip çözmek için daha fazla ayrıntı vardır ve Binaryen belgeleri alanda gezinmenize yardımcı olabilir. Ancak exampleScript'in + operatörü için sonunda, mevcut birçok tam sayı işlemden biri olarak Module#i32.add() yöntemini kullanı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'a yaptığı derlemenin çalışan örneğinde ölü kodu (daha sonraki bir adımda optimize edilecek ve kaldırılacak) yapay olarak eklemek için dışa aktarılmayan bir işlev ekleyerek işi tamamlayın.

// 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 program kodu, 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)
  )
 )
)

Dört işlevin (toplama, bölme, çarpma ve çıkarma) gösterildiği WebAssembly modülü dışa aktarmalarının Geliştirici Araçları konsolu ekran görüntüsü (ancak açık olmayan ölü kodu değil).

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(), özel optimize etme 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: Bu dosyaları "a", "b" olarak küçültür.
  • DeadCodeEliification: Ölü kodu kaldırın.

Çeşitli işaretlerden hangisinin daha önemli olduğunu ve önce denemeye 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'de derlemek ve elde edilen kodu optimize etmek için güçlü bir araç seti sunar. JavaScript kitaplığı ve komut satırı araçları, esneklik ve kullanım kolaylığı sağlar. Bu gönderi, Wasm derlemesinin temel ilkelerini göstererek Binaryen'in etkinliğini ve maksimum optimizasyon potansiyelini vurguladı. 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.