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

Binaryen هي مكتبة بنية أساسية لنظام الأدوات والمجمع لـ WebAssembly، وهي مكتوبة بلغة C++. وتهدف إلى جعل عملية compiling (التحويل البرمجي) إلى 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 هي أداة تنشئ المحلّلين لتحليل البنية. ويأخذ هذا الأسلوب وصفًا رسميًا للقواعد النحوية لإحدى لغات البرمجة كإدخال وينشئ رمزًا لمعترِف. تُنشئ الأدوات التحليلية عادةً أشجار البنية المجردة التي تمثّل البنية الهرمية للرمز المصدر.

مثال عملي

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

الشكر والتقدير

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