Binaryen ile Wasm için derleme ve optimize etme

Binaryen, WebAssembly için C++ ile yazılmış bir derleyici ve araç zinciri altyapısı kitaplığıdır. WebAssembly'ye derlemeyi sezgisel, hızlı ve etkili hale getirmeyi amaçlar. Bu yayında, ExampleScript adlı sentetik bir oyuncak dil örneğini kullanarak Binaryen.js API'si ile JavaScript'te 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 işlemlerinin temellerini öğreneceksiniz. Bu sayede, gerçek programlama dillerini WebAssembly'ye derlemenin genel mekanizmaları hakkında bilgi edineceksiniz. 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

Binaryen, tek bir başlıkta sezgisel bir C API'sine sahiptir ve JavaScript'ten de kullanılabilir. WebAssembly biçiminde giriş kabul etmesinin yanı sıra, bunu tercih eden derleyiciler için genel bir kontrol akışı grafiği de kabul eder.

Ara gösterim (IR), kaynak kodu temsil etmek için 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, WebAssembly'nin bir alt kümesi olduğundan WebAssembly'ye derlenir.

Binaryen'in optimizasyon aracı, kod boyutunu ve hızını iyileştirebilecek birçok geçişe sahiptir. Bu optimizasyonlar, Binaryen'in tek başına derleyici arka ucu olarak kullanılabilecek kadar güçlü olmasını amaçlar. Bu, 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, örneğin AssemblyScript gibi çeşitli projelerde kullanılır. AssemblyScript, TypeScript benzeri bir dilden doğrudan WebAssembly'ye derlemek için Binaryen'i kullanır. AssemblyScript Playground'da ö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 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 dayalı olarak oluşturulan WebAssembly kodunu gösteren AssemblyScript playground'u.

Binaryen araç zinciri

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

  • binaryen.js: Binaryen yöntemlerini Wasm modülleri oluşturmak ve optimize etmek için kullanıma sunan bağımsız bir JavaScript kitaplığıdır. Derlemeler için npm'deki binaryen.js'ye bakın (veya doğrudan GitHub ya da unpkg'den 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'yi birleştiren ve ayrıştıran 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: Modülün kullanım şekline bağlı olarak Wasm dosyalarının bölümlerini 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, bunu yaparken de karşılık gelen içe aktarmaları dışa aktarmalara bağlayan bir komut satırı aracı. JavaScript için kullanılan bir paketleyiciye benzer ancak Wasm için kullanılır.

WebAssembly'ye derleme

Bir dili başka bir dile derlemek genellikle birkaç adım içerir. En önemli adımlar aşağıdaki listede verilmiştir:

  • Sözcüksel analiz: Kaynak kodu jetonlara ayırın.
  • Söz dizimi analizi: Soyut söz dizimi ağacı oluşturun.
  • Anlamsal analiz: Hataları kontrol edin ve dil kurallarını uygulayın.
  • Ara kod oluşturma: Daha soyut bir gösterim 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'dır:

  • lex (Lexical Analyzer Generator): lex, sözcüksel analizörler (lexer veya tarayıcı olarak da bilinir) oluşturan bir araçtır. Bir dizi normal ifadeyi ve karşılık gelen işlemleri giriş olarak alır ve giriş kaynak kodundaki kalıpları tanıyan bir sözcük çözümleyici 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. Bir programlama dilinin resmi dil bilgisi açıklamasını giriş olarak 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) oluşturur.

Çözülmüş bir örnek

Bu yayının kapsamı göz önüne alındığında, eksiksiz bir programlama dilini ele almak mümkün değildir. Bu nedenle, basitlik adına, genel işlemleri somut örneklerle ifade ederek çalışan ExampleScript adlı çok sınırlı ve işe yaramaz bir sentetik programlama dilini ele alalım.

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

Ön uyarıda belirtildiği gibi tamamen işe yaramaz ancak sözcük çözümleyicisinin tek bir normal ifade olması için yeterince basit: /\d+\s*[\+\-\*\/]\s*\d+\s*/.

Ardından, bir ayrıştırıcı olması gerekir. 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ı satır başına bir tane olacak şekilde düzenlenir. Bu nedenle ayrıştırıcı, yeni satır karakterlerine göre bölerek kodu satır satır işleyebilir. Bu, yukarıdaki madde işaretli listedeki ilk üç adımı (yani sözcüksel analiz, söz dizimi analizi ve anlamsal analiz) kontrol etmek için yeterlidir. Bu adımların kodu aşağıdaki listede 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 söz dizimi ağacı (oldukça basitleştirilmiş olsa da) olarak temsil edilebildiğine göre, 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ında firstOperand, operator ve secondOperand öğelerinden oluşan bir üçlü bulunur. ExampleScript'teki dört olası operatörün (+, -, *, /) her biri için Binaryen'in Module#addFunction() yöntemiyle modüle yeni bir işlev eklenmesi gerekir. Module#addFunction() yöntemlerinin parametreleri şunlardır:

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

Açıklanması ve analiz edilmesi gereken başka ayrıntılar da var. Binaryen belgeleri bu konuda size yardımcı olabilir. Ancak sonuç olarak, ExampleScript'in + operatörü için Module#i32.add() yöntemini, mevcut tam sayı işlemlerinden biri olarak kullanırsınız. Toplama işlemi için iki işlenen gerekir: birinci ve ikinci toplanan. İş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ülde dört yöntem bulunur. Bunlardan üçü tam sayılarla çalışır. Bu yöntemler, Module#i32.add() tabanlı add(), Module#i32.sub() tabanlı subtract() ve Module#i32.mul() tabanlı multiply()'dir. Ayrıca, ExampleScript kayan nokta sonuçlarıyla da çalıştığı için Module#f64.div() tabanlı divide() aykırı değer yöntemi de bulunur.

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çbir zaman çağrılmayan ölü kodlar olabilir. ExampleScript'in Wasm'ye derlenmesinin çalışan örneğine yapay olarak ölü kod (daha sonraki bir adımda optimize edilip kaldırılacak) eklemek için dışa aktarılmayan 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 artık neredeyse hazır. Kesinlikle gerekli olmasa da 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 iki yöntem vardır. Bunlar, metin gösterimini S-expression'da .wat dosyası olarak (insan tarafından okunabilir biçimde) ve ikili gösterimi doğrudan tarayıcıda çalıştırılabilen .wasm dosyası olarak alma yöntemleridir. İkili kod doğrudan tarayıcıda çalıştırılabilir. İşlemin başarılı olduğunu 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);

Dört işlemin tamamını içeren bir ExampleScript programının metin olarak tam gösterimi aşağıda verilmiştir. Ölü kodun hâlâ orada olduğunu ancak WebAssembly.Module.exports() ekran görüntüsünde gösterildiği gibi kullanıma sunulmadığını 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ü dışa aktarmalarının dört işlevi (toplama, bölme, çarpma ve çıkarma) gösterdiği (ancak kullanılmayan ölü kodu göstermediği) Geliştirici Araçları Konsolu ekran görüntüsü.

WebAssembly'yi optimize etme

Binaryen, Wasm kodunu optimize etmek için iki yöntem sunar. Biri Binaryen.js'nin kendisinde, diğeri ise komut satırında. Birincisi, varsayılan olarak standart optimizasyon kurallarını uygular ve optimize etme ile küçültme düzeyini ayarlamanıza olanak tanır. İkincisi ise varsayılan olarak herhangi bir kural kullanmaz ancak tam özelleştirmeye izin verir. Bu da yeterli deneme yapıldığında kodunuza göre en iyi sonuçları elde etmek için ayarları uyarlayabileceğiniz anlamına gelir.

Binaryen.js ile optimizasyon

Binaryen ile bir Wasm modülünü optimize etmenin en basit yolu, Binaryen.js'nin Module#optimize() yöntemini doğrudan çağırmak ve isteğe bağlı olarak optimizasyon ve küçültme 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 eklenen kullanılmayan kodu kaldırır. Böylece, ExampleScript oyuncak örneğinin Wasm sürümünün metin gösterimi artık bu kodu içermez. Ayrıca, local.set/get çiftlerinin SimplifyLocals (çeşitli yerel öğelerle ilgili optimizasyonlar) ve Vacuum (açıkça gerekmeyen kodu kaldırır) optimizasyon adımlarıyla, return öğesinin ise RemoveUnusedBrs (gerekmeyen konumlardaki satır sonlarını kaldırır) optimizasyon adımıyla nasıl kaldırıldığına da dikkat 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)
  )
 )
)

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

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

Binaryen, kullanılacak kartların tamamen özelleştirilmesi için wasm-opt komut satırı aracını içerir. Olası optimizasyon seçeneklerinin tam listesini görmek için aracın yardım mesajını inceleyin. wasm-opt aracı, muhtemelen en popüler araçtır ve Emscripten, J2CL, Kotlin/Wasm, dart2wasm, wasm-pack 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ı aşağıda paylaşıyoruz:

  • CodeFolding: Kodu birleştirerek yinelenmesini önler (örneğin, iki ifkolun sonunda bazı ortak talimatlar varsa).
  • DeadArgumentElimination: Bir işlev her zaman aynı sabitlerle çağrılıyorsa işlevin bağımsız değişkenlerini kaldırmak için bağlantı zamanı optimizasyonu geçişi.
  • MinifyImportsAndExports: "a", "b" olarak küçültür.
  • DeadCodeElimination: Ölü kodu kaldırır.

Çeşitli işaretlerden hangilerinin daha önemli olduğunu ve önce hangilerinin denenmesi gerektiğini belirlemeyle ilgili birçok ipucu içeren bir optimizasyon kılavuzu mevcuttur. Örneğin, bazen wasm-opt işleminin tekrar tekrar çalıştırılması girişi daha da küçültür. Bu gibi durumlarda, --converge işaretiyle çalıştırma, daha fazla optimizasyon yapılmayana ve sabit bir noktaya ulaşılana kadar yinelemeye devam eder.

Demo

Bu yayında tanıtılan kavramları uygulamalı olarak görmek için yerleştirilmiş demoyu oynatın ve aklınıza gelen herhangi bir ExampleScript girişini sağlayın. Ayrıca demonun kaynak kodunu görüntülediğinizden emin olun.

Sonuçlar

Binaryen, dilleri WebAssembly'ye derlemek ve 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önderide, Binaryen'in etkinliğini ve maksimum optimizasyon potansiyelini vurgulayarak Wasm derlemesinin temel ilkeleri gösterildi. Binaryen'in optimizasyonlarını özelleştirmek için birçok seçenek olsa da genellikle varsayılan ayarlar yeterli olur. Binaryen ile derleme ve optimizasyon işlemlerinizde başarılar dileriz.

Teşekkür

Bu gönderi Alon Zakai, Thomas Lively ve Rachel Andrew tarafından incelenmiştir.