Binaryen ile Wasm için derleme ve optimize etme

Binaryen, WebAssembly için kullanılan ve C++ dilinde yazılmış bir derleyici ve araç zinciri altyapı kitaplığıdır. WebAssembly'yi derleme işlemini sezgisel, hızlı ve etkili hale getirmeyi amaçlar. Bu yayında, ExampleScript adlı sentetik oyuncak dili örneğini kullanarak Binaryen.js API'yi kullanarak JavaScript'te WebAssembly modüllerini yazmayı öğrenin. Modül oluşturma ile ilgili temel bilgileri, modüle ek işlev eklemeyi ve modülden dışa aktarma işlevlerini işleyeceğiz. Bu bölümde, gerçek programlama dillerini WebAssembly'de derleyen genel mekanikler hakkında bilgi edineceksiniz. Ayrıca, Wasm modüllerini hem Binaryen.js hem de wasm-opt ile komut satırında optimize etmeyi öğreneceksiniz.

Binaryen arka planı

Binaryen, tek bir başlıkta sezgisel bir C API'sine sahiptir ve JavaScript'ten de kullanılabilir. WebAssembly formunda girişleri kabul eder ancak bunu tercih eden derleyiciler için genel bir kontrol akışı grafiğini de kabul eder.

Ara gösterim (IR), kaynak kodu temsil etmek için bir derleyici veya sanal makine tarafından dahili olarak kullanılan veri yapısı veya koddur. Binaryen'in dahili IR sistemi, kompakt veri yapıları kullanır ve mevcut tüm CPU çekirdeklerini kullanarak tamamen paralel kod üretimi ve optimizasyonu için tasarlanmıştır. Binaryen'in IR'si, WebAssembly'nin alt kümesi olduğu için WebAssembly'ye doğru derlenir.

Binaryen'in optimize edicisinde, kod boyutunu ve hızını iyileştirebilecek birçok geçiş bulunmaktadır. Bu optimizasyonların amacı, Binaryen'i kendi başına derleyici arka ucu olarak kullanılabilecek kadar güçlü hale getirmektir. Wasm küçültme olarak düşünebileceğiniz WebAssembly'ye özgü optimizasyonlar (genel amaçlı derleyicilerin yapamadığı) içerir.

Örnek Binaryen kullanıcısı olarak AssemblyScript

Binaryen çeşitli projelerde kullanılır. Örneğin, TypeScript benzeri bir dilden doğrudan WebAssembly'ye derlemek için Binaryen'i kullanan AssemblyScript. 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ç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ı.

İkili araç zinciri

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

  • binaryen.js: Wasm modüllerini oluşturmak ve optimize etmek için Binaryen yöntemlerini sunan bağımsız bir JavaScript kitaplığı. Derlemeler için npm'debinaryen.js'ye bakın (veya doğrudan GitHub'dan ya da unpkg'dan indirin).
  • wasm-opt: WebAssembly'yi yükleyen ve Binaryen IR geçişlerini çalıştıran komut satırı aracı.
  • wasm-as ve wasm-dis: WebAssembly'yi monte eden ve söken komut satırı araçları.
  • wasm-ctor-eval: Derleme zamanında işlevleri (veya işlev bölümlerini) yürütebilen komut satırı aracı.
  • wasm-metadce: Modülün nasıl kullanıldığına bağlı olarak Wasm dosyalarının parçalarını esnek bir şekilde kaldıran komut satırı aracı.
  • wasm-merge: Birden fazla Wasm dosyasını tek bir dosyada birleştiren komut satırı aracı. İlgili içe aktarmaları, olduğu gibi dışa aktarmalara bağlar. JavaScript için bir paketleyici gibi, ancak Wasm için.

WebAssembly'ye derleme

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

  • Leksik analiz: Kaynak kodunu jetonlara ayırın.
  • Söz dizimi analizi: Soyut bir söz dizimi ağacı oluşturun.
  • Anlamsal analiz: Hataları kontrol edin ve dil kurallarını uygulayın.
  • Orta düzey 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ça kullanılan araçlar şunlardır: lex ve yacc:

  • lex (Leksik Analiz Oluşturucu): lex, lexer veya tarayıcı olarak da bilinen sözlük analiz araçları üreten bir araçtır. Bir dizi normal ifadeyi ve karşılık gelen işlemleri giriş olarak alır ve giriş kaynağı kodundaki kalıpları tanıyan bir sözlük analizcisi için kod oluşturur.
  • yacc (Yine de Başka Bir Derleyici Derleyici): yacc, söz dizimi analizi için ayrıştırıcılar oluşturan bir araçtır. Giriş olarak bir programlama dilinin resmi bir dilbilgisi açıklamasını alır ve ayrıştırıcı için kod oluşturur. Ayrıştırıcılar genellikle kaynak kodun hiyerarşik yapısını temsil eden soyut söz dizimi ağaçları (AST'ler) üretir.

İşe yarayan bir örnek

Bu yayının kapsamı düşünüldüğünde, bir programlama dilini bütünüyle ele almak mümkün değildir. Bu nedenle, basitlik açısından, genel işlemleri somut örneklerle ifade ederek çalışan ExampleScript adlı, son derece sınırlı ve yararsız bir sentetik programlama dili kullanmayı düşünün.

  • Bir add() işlevi yazmak için 2 + 3 gibi herhangi bir eklemenin bir örneğini kodluyorsunuz.
  • Bir multiply() işlevi yazmak için 6 * 12 yazın.

Ön uyarıya göre, tamamen faydasız, ancak sözlük analizini tek bir normal ifade olacak kadar basit: /\d+\s*[\+\-\*\/]\s*\d+\s*/.

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

ExampleScript komutları her satıra bir giriştir. Böylece ayrıştırıcı, kodu yeni satır karakterlerine göre satır bazında işleyebilir. Bu işlem, madde işaretli listedeki ilk üç adımı (söz dizimi analizi, sözdizimi 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),
      };
    });
  }
}

Orta düzey kod oluşturma

ExampleScript programları artık soyut bir söz dizimi ağacı olarak temsil edilebildiğine göre (son derece basit olsalar 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 içeren bir üçlü içerir. ExampleScript'teki olası dört operatör (+, -, *, /) için Binaryen'in Module#addFunction() yöntemiyle modüle yeni bir işlev eklenmesi gerekir. Module#addFunction() yöntemlerinin parametreleri aşağıdaki gibidir:

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

Gevşeyip rahatlamak için başka ayrıntılar daha vardır ve Binaryen dokümanları bu alanda gezinmenize yardımcı olabilir ancak nihayetinde ExampleScript'in + operatörü için mevcut birçok tamsayı işlemden biri olarak Module#i32.add() yöntemini kullanmaya başlarsınız. Toplama işlemi için, birinci ve ikinci toplam olmak üzere iki işlem gören gerekir. İşlevin ç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 üçü tamsayı sayılarla çalışır, diğer bir deyişle Module#i32.add() için add(), Module#i32.sub() temelli subtract(), Module#i32.mul() temeline göre multiply() ve Module#f64.div() değerine göre aykırı divide() değeri kullanılır. Bunun nedeni, ExampleScript'in kayan nokta sonuçlarıyla da çalışmasıdır.

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 uğraşıyorsanız bazen asla çağrılmayan ölü kodlar olabilir. ExampleScript'in Wasm derlemesinin çalışan örneğine ölü kodu (sonraki bir adımda optimize edilecek ve kaldırılacak) yapay olarak eklemek için dışa aktarılmayan bir işlevin eklenmesi işi yapar.

// 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 şimdi neredeyse hazır. Mutlaka gerekli değildir, ancak Module#validate() yöntemiyle modülü doğrulamak kesinlikle iyi bir uygulamadır.

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

Ortaya çıkan Wasm kodunu elde etme

Elde edilen Wasm kodunu elde etmek için İkili'de metinsel temsili kullanıcılar tarafından okunabilir biçimde S ifadesinde .wat dosyası olarak, doğrudan tarayıcıda çalıştırılabilen bir .wasm dosyası olarak ikili gösterim'i almak için iki yöntem bulunur. İkili kod doğrudan tarayıcıda çalıştırılabilir. İşe yarayıp yaramadığını görmek için dışa aktarma işlemlerini günlüğe kaydetmek 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);

Bir ExampleScript programının dört işlemin tamamı ile metinsel gösterimi aşağıda listelenmiştir. Geçersiz kodun hâlâ orada olduğunu, ancak WebAssembly.Module.exports() ekran görüntüsündeki gibi görünmediğ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şlevi gösteren WebAssembly modülünün dışa aktarımının DevTools Konsolu ekran görüntüsünde: toplama, bölme, çarpma ve çıkarma (ancak açığa çıkmamış ölü kod değil).

WebAssembly'yi optimize etme

Binaryen, Wasm kodunu optimize etmek için iki yol sunuyor. Biri Binaryen.js'nin kendisinde, diğeri de komut satırı içindir. İlk kampanya, varsayılan olarak standart optimizasyon kuralları grubunu uygular ve optimizasyon ile küçültme düzeylerini ayarlamanıza olanak tanır. İkincisi ise varsayılan olarak hiçbir kural kullanmaz. Tam özelleştirme olanağı sunar. Bu da yeterli miktarda deneme yaparak kodunuza göre ayarları en uygun sonuçlara göre uyarlayabileceğiniz anlamına gelir.

Binaryen.js ile Optimizasyon

Bir Wasm modülünü Binaryen ile optimize etmenin en basit yolu, Binaryen.js'nin Module#optimize() yöntemini doğrudan çağırmak ve isteğe bağlı olarak optimize etme ve küçültme seviyesini 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, önceden yapay olarak eklenen geçersiz kodu kaldırır. Böylece, ExampleScript oyuncak örneğinin Wasm sürümünün metinsel gösterimi artık bu kodu içermez. Ayrıca local.set/get çiftlerinin, SimplifyLocals (yerel bölgeyle ilgili çeşitli optimizasyonlar) ve Vacuum (bariz bir şekilde gereksiz kodu kaldırır) optimizasyon adımlarıyla ve return'nin RemoveUnusedBrs (ihtiyaç duyulmayan konumlardaki araları kaldırır) ile nasıl kaldırıldığını da not edin.

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

Çok sayıda optimizasyon geçişi vardır ve Module#optimize(), belirli optimizasyon ve daraltma seviyelerinin varsayılan kümelerini 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

Binaryen, kullanılacak kartların tam olarak özelleştirilebilmesi için wasm-opt komut satırı aracını içeriyor. Olası optimizasyon seçeneklerinin tam listesini öğrenmek için aracın yardım mesajına göz atın. wasm-opt aracı muhtemelen bu araçların en popüleridir ve Wasm kodunu optimize etmek için Emscripten, J2CL, Kotlin/Wasm, dart2wasm, wasm-pack gibi çeşitli derleyici araç zinciri tarafından kullanılır.

wasm-opt --help

Kartlar hakkında fikir vermek için, uzman bilgisi olmadan anlaşılabilir seçeneklerden bazılarını burada bulabilirsiniz:

  • CodeFolding: Birleştirme yaparak yinelenen koddan kaçınır (örneğin, iki if kolunun sonunda paylaşılan talimatlar varsa).
  • DeadArgumentElimulation: Bir işlev her zaman aynı sabit değerlerle çağrılıyorsa işlevin bağımsız değişkenlerini kaldırmak için bağlantı zamanı optimizasyonu pası.
  • MinifyImportsAndExports: Bunları "a", "b" olacak şekilde küçültür.
  • DeadCodeElimination: Ölü kodu kaldırın.

Çeşitli işaretlerden hangilerinin daha önemli ve ilk önce denemeye değer olduğunu belirlemek için çeşitli ipuçları içeren bir optimizasyon tarif defteri var. Örneğin, bazen wasm-opt öğesini tekrar tekrar çalıştırmak girişi daha da daraltır. Bu tür durumlarda, --converge işaretiyle çalıştırmak, başka optimizasyon yapılmayana ve sabit bir noktaya ulaşılana kadar yinelenmeye devam eder.

Demografi

Bu yayında ele alınan kavramları uygulamalı olarak görmek için, aklınıza gelen herhangi bir ExampleScript girişini sağlayarak yerleştirilmiş demoyu izleyin. Ayrıca demonun kaynak kodunu görüntülemeyi de unutmayın.

Sonuçlar

Binaryen, dilleri WebAssembly'ye derleyip ortaya çıkan kodu optimize etmek için güçlü bir araç seti sunar. JavaScript kitaplığı ve komut satırı araçları, esneklik ve kullanım kolaylığı sunar. Bu gönderi, Wasm derlemesinin temel ilkelerini gösterdi ve Binaryen'in etkililiğini ve maksimum optimizasyon için potansiyelini vurguladı. Binaryen'in optimizasyonlarını özelleştirme seçeneklerinin çoğu, Wasm'ın içindekiler hakkında derin bilgi sahibi olmayı gerektirse de genellikle varsayılan ayarlar zaten çok işe yarıyor. Sonuç olarak, Binaryen ile derleme ve optimizasyon yapmaktan mutluluk duyarız!

Teşekkür

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