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

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

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