کامپایل و بهینه سازی Wasm با Binaryen

Binaryen یک کتابخانه زیرساخت کامپایلر و زنجیره ابزار برای WebAssembly است که به زبان C++ نوشته شده است. هدف آن ایجاد کامپایل در WebAssembly بصری، سریع و موثر است. در این پست، با استفاده از مثال زبان اسباب بازی مصنوعی به نام ExampleScript، یاد بگیرید که چگونه ماژول های WebAssembly را در جاوا اسکریپت با استفاده از Binaryen.js API بنویسید. شما اصول ایجاد ماژول، اضافه کردن عملکرد به ماژول، و صادرات توابع از ماژول را پوشش خواهید داد. این به شما اطلاعاتی در مورد مکانیک کلی کامپایل زبان های برنامه نویسی واقعی در WebAssembly می دهد. علاوه بر این، نحوه بهینه سازی ماژول های Wasm را هم با Binaryen.js و هم در خط فرمان با wasm-opt یاد خواهید گرفت.

پس زمینه باینرین

Binaryen دارای یک API C بصری در یک هدر است و همچنین می تواند از جاوا اسکریپت استفاده شود. ورودی را در فرم 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 تعدادی ابزار مفید برای توسعه دهندگان جاوا اسکریپت و کاربران خط فرمان ارائه می دهد. زیر مجموعه ای از این ابزارها در زیر فهرست شده است. لیست کامل ابزارهای موجود در فایل README پروژه موجود است.

  • binaryen.js : یک کتابخانه جاوا اسکریپت مستقل که روش های 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 را در یک فایل ادغام می‌کند و واردات مربوطه را به صادرات متصل می‌کند. مانند یک باندلر برای جاوا اسکریپت، اما برای Wasm.

کامپایل به WebAssembly

کامپایل یک زبان به زبان دیگر معمولاً شامل چندین مرحله است که مهمترین آنها در لیست زیر ذکر شده است:

  • تجزیه و تحلیل واژگانی: کد منبع را به نشانه ها تقسیم کنید.
  • تجزیه و تحلیل نحو: یک درخت نحو انتزاعی ایجاد کنید.
  • تحلیل معنایی: خطاها را بررسی کنید و قوانین زبان را اجرا کنید.
  • تولید کد میانی: یک نمایش انتزاعی تر ایجاد کنید.
  • تولید کد: به زبان مقصد ترجمه کنید.
  • بهینه سازی کد خاص برای هدف: بهینه سازی برای هدف.

در دنیای یونیکس، ابزارهای پرکاربرد برای کامپایل عبارتند از lex و yacc :

  • lex (Lexical Analyzer Generator): lex ابزاری است که تحلیلگرهای واژگانی را تولید می کند که به عنوان lexers یا اسکنر نیز شناخته می شوند. مجموعه ای از عبارات منظم و اقدامات مربوطه را به عنوان ورودی می گیرد و کدی را برای یک تحلیلگر واژگانی تولید می کند که الگوها را در کد منبع ورودی تشخیص می دهد.
  • 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 در هر خط یکی هستند، بنابراین تجزیه کننده می تواند کد را به صورت خطی با تقسیم بر روی کاراکترهای خط جدید پردازش کند. این برای بررسی سه مرحله اول از فهرست bullet قبل از آن، یعنی تحلیل واژگانی ، تحلیل نحوی و تحلیل معنایی کافی است. کد این مراحل در لیست زیر است.

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، یعنی + , - , * , / باید یک تابع جدید با متد Binaryen's Module#addFunction() به ماژول اضافه شود . پارامترهای متدهای Module#addFunction() به شرح زیر است:

  • name : یک string ، نشان دهنده نام تابع است.
  • functionType : یک Signature ، نشان دهنده امضای تابع است.
  • varTypes : a Type[] ، محلی‌های اضافی را به ترتیب داده شده نشان می‌دهد.
  • body : یک Expression ، محتویات تابع.

جزئیات بیشتری برای باز کردن و شکستن وجود دارد و مستندات Binaryen می‌تواند به شما کمک کند تا در فضا پیمایش کنید، اما در نهایت، برای اپراتور ExampleScript's + ، به متد 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)
  )
 )
)

اسکرین شات DevTools Console از ماژول WebAssembly که چهار عملکرد را نشان می دهد: جمع، تقسیم، ضرب و تفریق (اما نه کد مرده آشکار نشده).

بهینه سازی WebAssembly

Binaryen دو راه برای بهینه سازی کد Wasm ارائه می دهد. یکی در خود Binaryen.js و یکی برای خط فرمان. اولی مجموعه استاندارد قوانین بهینه‌سازی را به‌طور پیش‌فرض اعمال می‌کند و به شما امکان می‌دهد سطح بهینه‌سازی و کوچک شدن را تنظیم کنید، و دومی به‌طور پیش‌فرض از هیچ قانونی استفاده نمی‌کند، اما در عوض امکان سفارشی‌سازی کامل را فراهم می‌کند، به این معنی که با آزمایش کافی، می‌توانید تنظیمات را انجام دهید. برای نتایج بهینه بر اساس کد شما.

بهینه سازی با Binaryen.js

ساده ترین راه برای بهینه سازی یک ماژول Wasm با Binaryen این است که مستقیماً متد Module#optimize() Binaryen.js را فراخوانی کنید و به صورت اختیاری سطح Optimize و Shrink را تنظیم کنید.

// 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 و بهینه سازی کدهای به دست آمده ارائه می دهد. کتابخانه جاوا اسکریپت و ابزارهای خط فرمان آن انعطاف پذیری و سهولت استفاده را ارائه می دهد. این پست اصول اصلی کامپایل Wasm را نشان می‌دهد و اثربخشی و پتانسیل Binaryen برای حداکثر بهینه‌سازی را برجسته می‌کند. در حالی که بسیاری از گزینه‌ها برای سفارشی‌سازی بهینه‌سازی‌های Binaryen به دانش عمیق در مورد داخلی Wasm نیاز دارند، معمولاً تنظیمات پیش‌فرض از قبل عالی عمل می‌کنند. با آن، کامپایل و بهینه سازی با Binaryen مبارک!

قدردانی ها

این پست توسط Alon Zakai , Thomas Lively و Rachel Andrew بررسی شده است .