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

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

معلومات عن منصة Binaryen

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

التمثيل المتوسط (IR) هو بنية البيانات أو التعليمات البرمجية المستخدمة داخليًا بواسطة برنامج تجميع أو جهاز افتراضي لتمثيل رمز المصدر. يستخدم نظام IR الداخلي في Binaryen هياكل بيانات مدمجة وتم تصميمه لإنشاء الرموز وتحسينها بشكل متوازٍ بالكامل باستخدام كل النوى المتاحة في وحدة المعالجة المركزية (CPU). يتم تجميع 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 Other 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)
  )
 )
)

لقطة شاشة لوحدة تحكّم أدوات مطوّري البرامج لعمليات تصدير وحدة 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 (تحسينات متنوعة متعلقة بالمنطقة المحلية) والمكنسة الكهربائية (تزيل الرموز التي من الواضح أنها غير ضرورية)، وتتم إزالة 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.

شكر وتقدير

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