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

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

معلومات أساسية عن Binaryen

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

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

يحتوي محسِّن Binaryen على العديد من عمليات التمرير التي يمكن أن تحسِّن حجم الرمز وسرعته. تهدف عمليات التحسين هذه إلى جعل Binaryen فعّالة بما يكفي لاستخدامها كبرنامج خلفي للمترجم البرمجي. وتتضمّن تحسينات خاصة بلغة WebAssembly (قد لا تنفّذها برامج الترجمة ذات الأغراض العامة)، ويمكن اعتبارها تصغيرًا لملفات 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 وتنفّذ عمليات Binaryen IR عليها.
  • 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 كشجرة بناء مجردة (وإن كانت مبسطة إلى حد كبير)، فإن الخطوة التالية هي إنشاء تمثيل وسيط مجرد. تتمثّل الخطوة الأولى في إنشاء وحدة جديدة في 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-expression يمكن قراءته بالعين، والتمثيل الثنائي كملف .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();

سيؤدي ذلك إلى إزالة الرمز البرمجي غير المستخدَم الذي تم إدخاله بشكل مصطنع من قبل، وبالتالي لن يتضمّن التمثيل النصي لإصدار Wasm من المثال البسيط ExampleScript هذا الرمز البرمجي. لاحظ أيضًا كيف تتم إزالة أزواج 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 يتضمّنان بعض التعليمات المشتركة في نهايتهما).
  • DeadArgumentElimination: هي عملية تحسين في وقت الربط لإزالة وسيطات دالة إذا تم استدعاؤها دائمًا باستخدام الثوابت نفسها.
  • MinifyImportsAndExports: يتم تصغيرها إلى "a" و"b".
  • DeadCodeElimination: إزالة الرمز البرمجي غير المستخدَم

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

عرض توضيحي

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

الاستنتاجات

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

الإقرارات

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