التحويل إلى Wasm وتحسينه باستخدام Binaryen

Binaryen هي مكتبة للبنية الأساسية لسلسلة الأدوات وتجميع البيانات من أجل WebAssembly، وهي مكتوبة بلغة C ، وتهدف إلى جعل عملية التجميع إلى WebAssembly سهلة وسريعة وفعّالة. في هذه المشاركة، باستخدام مثال على لغة اصطناعية تُسمى ExampleScript، يمكنك التعرّف على كيفية كتابة وحدات WebAssembly في JavaScript باستخدام واجهة برمجة التطبيقات Binaryen.js API. ستتناول أساسيات إنشاء الوحدة والوظائف بالإضافة إلى الوحدة ودوال التصدير من الوحدة. سيعطيك هذا معرفة حول الآليات العامة لتجميع لغات البرمجة الفعلية إلى WebAssembly. بالإضافة إلى ذلك، ستتعرّف على كيفية تحسين وحدات Wasm باستخدام Binaryen.js وعلى سطر الأوامر باستخدام wasm-opt.

خلفية عن Binaryen

توفّر Binaryen واجهة برمجة تطبيقات سهلة الاستخدام C API في عنوان واحد، ويمكن أيضًا استخدامها من JavaScript. يقبل هذا المترجم الإدخال في شكل WebAssembly، ولكنه يقبل أيضًا مخطّط تدفق التحكّم عامًا للمترجمات التي تفضّل ذلك.

التمثيل الوسيط (IR) هو بنية البيانات أو الرمز البرمجي المستخدَم داخليًا من قِبل المُجمِّع أو الجهاز الافتراضي لتمثيل الرمز المصدر. يستخدم رمز المعالجة الداخلي في Binaryen بنى بيانات مضغوطة وهو مصمّم لإنشاء رمز مُحسَّن ومتوازٍ تمامًا باستخدام جميع نوى وحدة المعالجة المركزية المتاحة. يتم تجميع IR في Binaryen إلى WebAssembly لأنّه مجموعة فرعية من WebAssembly.

يمتلك مُحسِّن Binaryen العديد من البطاقات التي يمكنها تحسين حجم الرمز البرمجي وسرعته. تهدف هذه التحسينات إلى جعل Binaryen فعّالًا بما يكفي لاستخدامه كمحرِّر ترجمة خلفي بحد ذاته. ويشمل ذلك تحسينات خاصة بـ WebAssembly (قد لا تُجريها compilers المخصّصة للأغراض العامة)، والتي يمكنك اعتبارها عملية تصغير لـ Wasm.

AssemblyScript كمثال على مستخدم Binaryen

يستخدم عدد من المشاريع Binaryen، مثل AssemblyScript الذي يستخدم Binaryen للقيام بالترجمة من لغة مشابهة لـ TypeScript مباشرةً إلى WebAssembly. جرِّب المثال في ساحة AssemblyScript.

إدخال AssemblyScript:

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

رمز WebAssembly المقابل في صيغة نصية تم إنشاؤها بواسطة 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
 )
)

ساحة AssemblyScript التي تعرض رمز WebAssembly الذي تم إنشاؤه استنادًا إلى المثال السابق

سلسلة أدوات Binaryen

توفّر مجموعة أدوات Binaryen عددًا من الأدوات المفيدة لكل من مطوّري JavaScript ومستخدمي سطر الأوامر. تم إدراج مجموعة فرعية من هذه الأدوات في الجدول التالي، ويمكنك الاطّلاع على القائمة الكاملة للأدوات المضمّنة في ملف README الخاص بالمشروع.

  • binaryen.js: مكتبة JavaScript مستقلة تعرض طرق Binaryen لإنشاء وحدات Wasm وتحسينها. بالنسبة إلى الإصدارات، يُرجى الاطّلاع على binaryen.js على npm (أو تنزيلها مباشرةً من GitHub أو unpkg).
  • wasm-opt: أداة سطر الأوامر التي تحمِّل WebAssembly وتعمل على تنفيذ خطوات معالجة IR لـ Binaryen عليه.
  • wasm-as وwasm-dis: أدوات سطر الأوامر التي تُجمِّع رمز WebAssembly وتُفكّكه
  • wasm-ctor-eval: أداة سطر الأوامر التي يمكنها تنفيذ دوال (أو أجزاء من الدوال) في وقت التجميع.
  • wasm-metadce: أداة سطر الأوامر لإزالة أجزاء من ملفات Wasm بطريقة انسيابية تعتمد على كيفية استخدام الوحدة.
  • wasm-merge: أداة سطر أوامر تدمج عدة ملفات Wasm في ملف واحد، وتعمل على ربط عمليات الاستيراد المقابلة بعمليات التصدير أثناء إجراء ذلك. وهي تشبه أداة تجميع JavaScript، ولكن لتنسيق Wasm.

التحويل إلى WebAssembly

عادةً ما تشتمل عملية تحويل لغة إلى لغة أخرى على عدة خطوات، وتم سرد أهمها في القائمة التالية:

  • التحليل اللغوي: قسِّم رمز المصدر إلى رموز مميّزة.
  • تحليل البنية: إنشاء شجرة بنية مجردة
  • التحليل الدلالي: يتحقّق من الأخطاء ويفرض قواعد اللغة.
  • إنشاء رمز وسيط: يمكنك إنشاء تمثيل أكثر تجريديًا.
  • إنشاء الرموز: ترجمة المحتوى إلى اللغة الهدف
  • تحسين الرمز المخصّص للاستهداف: يمكنك إجراء تحسين للاستهداف.

في عالم Unix، إنّ الأدوات الأكثر استخدامًا للتجميع هي lex وyacc:

  • lex (أداة إنشاء التحليل اللغوي): lex هي أداة تنشئ أدوات تحليل معنى، تُعرف أيضًا باسم أدوات القياس أو الماسحات الضوئية. يأخذ هذا التحليل مجموعة من التعبيرات العادية والإجراءات المقابلة كإدخال، وينشئ رمزًا لتحليل نحوي يتعرّف على الأنماط في رمز المصدر الذي تم إدخاله.
  • yacc (Yet Another Compiler Compiler): yacc هي أداة تنشئ المحلّلات لتحليل البنية. ويأخذ هذا الأسلوب وصفًا رسميًا للقواعد النحوية لإحدى لغات البرمجة كإدخال وينشئ رمزًا لمحلِّل نحوي. وينتج عادةً المحللون أشجار البنية المجردة (AST) التي تمثل البنية الهرمية لرمز المصدر.

مثال عملي

وبالنظر إلى النطاق الذي تتضمّنه هذه المشاركة، من المستحيل أن نتناول لغة برمجة كاملة، لتبسيط الأمر، يمكنك استخدام لغة برمجة اصطناعية محدودة جدًا وعديمة الفائدة تُسمى ExampleScript والتي تعمل من خلال التعبير عن العمليات العامة من خلال أمثلة ملموسة.

  • لكتابة دالة add()، عليك ترميز مثال على أي عملية إضافة، مثل 2 3.
  • لكتابة دالة multiply()، يمكنك كتابة 6 * 12 على سبيل المثال.

وفقًا للتحذير المسبق، لا فائدة تمامًا، ولكنها بسيطة بما يكفي لأن يكون المحلل المعجم تعبيرًا عاديًا واحدًا: /\d \s*[\ \-\*\/]\s*\d \s*/.

بعد ذلك، يجب أن يكون هناك أداة تحليل. في الواقع، يمكن إنشاء نسخة بسيطة جدًا من شجرة بنية مجردة باستخدام تعبير عادي مع مجموعات اصطياد مُسمّاة: /(?<first_operand>\d )\s*(?<operator>[\ \-\*\/])\s*(?<second_operand>\d )/.

تكون أوامر ExampleScript واحدة في كل سطر، لذلك يمكن للمحلل اللغوي معالجة التعليمات البرمجية في كل سطر من خلال التقسيم على أحرف السطر الجديد. وهذا كافٍ للتحقّق من الخطوات الثلاث الأولى من قائمة النقاط السابقة، وهي التحليل المعجمي وتحليل بنية الجملة والتحليل الدلالي. يمكنك العثور على رمز هذه الخطوات في الجدول التالي.

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

المستوى المتوسط من إنشاء الرموز

الآن بعد أن أصبح بإمكاننا تمثيل برامج ExampleScript على شكل شجرة بنية مجردة (على الرغم من أنّها شجرة مبسّطة جدًا)، تكون الخطوة التالية هي إنشاء تمثيل intermediate مجرّد. الخطوة الأولى هي إنشاء وحدة جديدة في Binaryen:

const module = new binaryen.Module();

يحتوي كل سطر من شجرة البنية التجريدية على ثلاثي يتكون من firstOperand وoperator وsecondOperand. بالنسبة إلى كلّ من العوامل الشارحة الأربعة المحتملة في ExampleScript، أي و- و* و/، يجب إضافة دالة جديدة إلى الوحدة باستخدام طريقة Module#addFunction() في Binaryen. في ما يلي مَعلمات طرق Module#addFunction():

  • name: string، يمثّل اسم الدالة.
  • functionType: يمثّل Signature توقيع الدالة.
  • varTypes: يشير الرمز Type[] إلى أماكن إقامة إضافية للسكان المحليين، وذلك بالترتيب المحدّد.
  • body: عنصر Expression، محتوى الدالة

هناك بعض التفاصيل الإضافية التي يجب تحليلها ومعرفة كيفية استخدامها، ويمكن أن تساعدك مستندات Binaryen في التنقّل في هذه المساحة، ولكن في النهاية، بالنسبة إلى عامل التشغيل في ExampleScript، ستنتهي إلى الطريقة Module#i32.add() كإحدى عمليات الأعداد الصحيحة المتوفّرة المتعددة. تتطلّب عملية الإضافة مَعلمتَين، وهما المَعلمتَان الأولى والثانية. لكي تكون الدالة قابلاً للاتّصال بها، يجب تصديرها باستخدام 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');

بعد معالجة شجرة البنية المجردة، تحتوي الوحدة على أربع طرق، تتضمّن ثلاث طرق أرقامًا صحيحة، وهي add() استنادًا إلى Module#i32.add()، subtract() استنادًا إلى Module#i32.sub()، وmultiply() استنادًا إلى Module#i32.mul()، والقيمة الشاذة divide() استنادًا إلى Module#f64.div() لأنّ ExampleScript يعمل مع نتائج النقطة العائمة أيضًا.

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

إذا كنت تتعامل مع قواعد رموز برمجية فعلية، سيكون هناك أحيانًا رموز برمجية غير مفيدة لن يتم أبدًا استدعاؤها. لإدخال رمز برمجي غير ذي فائدة بشكل مصطنع (سيتم تحسينه و إزالته في خطوة لاحقة) في المثال الذي يتم تشغيله لتجميع ExampleScript إلى Wasm، يمكنك إضافة دالة غير مُصدَّرة.

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

أصبح المُجمِّع جاهزًا تقريبًا الآن. ليس هذا الإجراء ضروريًا بشكل صارم، ولكن من الممارسات الجيدة التحقّق من الوحدة باستخدام طريقة Module#validate().

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

الحصول على رمز Wasm الناتج

لالحصول على رمز Wasm الناتج، تتوفّر طريقتان في Binaryen للحصول على التمثيل النصي كملف .wat في تعبير S بتنسيق يمكن لشخص عادي قراءته، والتمثيل الثنائي كملف .wasm يمكن تشغيله مباشرةً في المتصفّح. يمكن تشغيل الرمز الثنائي مباشرةً في المتصفح. للتأكّد من نجاح العملية، يمكن أن يكون تسجيل عمليات التصدير مفيداً.

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

في ما يلي التمثيل النصي الكامل لبرنامج ExampleScript مع جميع العمليات الأربع. يُرجى ملاحظة أنّ الرمز غير القابل للتنفيذ لا يزال متوفّرًا، ولكنه غير معروض وفقًا لقطة الشاشة الخاصة بالملف 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)
  )
 )
)

لقطة شاشة لوحدة تحكّم &quot;أدوات مطوّري البرامج&quot; لعمليات تصدير وحدة WebAssembly تعرِض أربع دوالّ: الإضافة والقسمة والضرب والطرح (ولكن ليس الرمز غير المعروض غير القابل للتنفيذ)

تحسين WebAssembly

يوفّر Binaryen طريقتَين لتحسين رمز Wasm. واحد في Binaryen.js نفسه، وواحد لسطر الأوامر. يطبّق الخيار الأول مجموعة قواعد التحسين المعمول بها تلقائيًا ويسمح لك بضبط مستوى التحسين والتقليص، ولا يستخدم الخيار الأخير أي قواعد تلقائيًا، ولكنه يسمح بدلاً من ذلك بالتخصيص الكامل، ما يعني أنّه من خلال إجراء تجارب كافية، يمكنك تخصيص الإعدادات للحصول على أفضل النتائج استنادًا إلى الرمز البرمجي.

التحسين باستخدام Binaryen.js

وأبسط طريقة لتحسين وحدة Wasm باستخدام Binaryen هي استدعاء طريقة Module#optimize() في Binaryen.js مباشرةً، واختياريًا ضبط مستوى التحسين والتقليص.

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

يؤدي ذلك إلى إزالة الرمز غير القابل للتنفيذ الذي تم إدخاله بشكل مصطنع من قبل، وبالتالي لن يحتوي التمثيل النصي لإصدار ExampleScript من Wasm على الرمز. يُرجى ملاحظة كيفية إزالة أزواج local.set/get من خلال خطوات التحسين SimplifyLocals (تحسينات متنوعة متعلقة بالعناصر المحلية) و Vacuum (إزالة الرموز البرمجية غير الضرورية)، وإزالة return من خلال RemoveUnusedBrs (إزالة الفواصل من المواقع غير الضرورية).

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

هناك العديد من عمليات التحسين، ويستخدم Module#optimize() الإعدادات التلقائية لمستويات التحسين والتقليص. لإجراء تخصيص كامل، عليك استخدام أداة سطر الأوامر wasm-opt.

التحسين باستخدام أداة سطر الأوامر wasm-opt

لتخصيص البطاقات التي يتم استخدامها بشكل كامل، يتضمّن Binaryen أداة سطر الأوامر wasm-opt. للحصول على قائمة كاملة بخيارات التحسين الممكنة، راجِع رسالة المساعدة الخاصة بالأداة. من المحتمل أن تكون أداة wasm-opt هي الأكثر رواجًا بين الأدوات، وتستخدمها العديد من سلاسل أدوات التجميع لتحسين رمز Wasm، من بينها Emscripten وJ2CL وKotlin/Wasm وdart2wasm وwasm-pack وغير ذلك.

wasm-opt --help

لمنحك لمحة عن البطاقات، إليك مقتطفًا من بعض البطاقات التي يمكن فهمها بدون معرفة خبير:

  • CodeFolding: يتجنّب هذا الخيار الرموز المكرّرة من خلال دمجها (على سبيل المثال، إذا كان هناك if armان يتضمّنان بعض التعليمات المشتركة في نهايتيهما).
  • DeadArgumentElimination: عملية تحسين وقت الربط لإزالة الوسيطات إلى دالة إذا كانت يتم استدعاؤها دائمًا باستخدام الثوابت نفسها
  • MinifyImportsAndExports: تصغيرها إلى "a" و"b"
  • DeadCodeElimination: إزالة الرموز غير الصالحة

يتوفّر كتاب تحسين يحتوي على العديد من النصائح لتحديد الإشارات المختلفة التي تُعدّ أكثر أهمية وتستحق المحاولة أولاً. على سبيل المثال، في بعض الأحيان، يؤدي تكرار استخدام wasm-opt إلى تصغير الإدخال أكثر. في هذه الحالات، يستمر تكرار تنفيذ الإجراء باستخدام العلامة --converge إلى أن يتم إيقاف التحسين ووصول الإجراء إلى نقطة ثابتة.

عرض توضيحي

للاطّلاع على المفاهيم التي تم تقديمها في هذه المشاركة، يمكنك استخدام الإصدار التمهيدي المضمّن، مع تقديم أي إدخال ExampleScript يمكنك التفكير فيه. احرص أيضًا على عرض رمز المصدر للتطبيق التجريبي.

الاستنتاجات

توفّر Binaryen مجموعة أدوات فعّالة لتجميع اللغات إلى WebAssembly و تحسين الرمز البرمجي الناتج. وتوفر مكتبة JavaScript وأدوات سطر الأوامر المرونة وسهولة الاستخدام. توضّح هذه المشاركة المبادئ الأساسية لمعالجة ملف ‎Wasm، مع التركيز على فعالية Binaryen وإمكانية تحقيق معالجة ملف ‎Wasm بأعلى كفاءة. على الرغم من أنّ العديد من خيارات تخصيص تحسينات Binaryen تتطلّب معرفة عميقة عن الأجزاء الداخلية لواسم Wasm، فإنّ الإعدادات التلقائية تعمل بشكل جيد في العادة. مع أطيب التحيّات،

شكر وتقدير

راجع هذه المشاركة كلّ من ألون زاكي و توماس لايفلي و راشيل أندرو.