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

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

خلفية عن Binaryen

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

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

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

AssemblyScript كمثال لمستخدم 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

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

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

في عالم يونكس، تُستخدم الأدوات بشكل متكرر للتجميع lex و yacc:

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

هناك المزيد من التفاصيل للاسترخاء مستندات حول نظام بيناريين في التنقل في المساحة الإبداعية، وفي النهاية بالنسبة إلى + الخاص بـ 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 `/`.

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

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

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

عرض توضيحي

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

الاستنتاجات

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

شكر وتقدير

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