Interpreter Ignition چیه و توی v8 چطوری کار می‌کنه؟ - قسمت ۳


v8-engine-ignition


توی قسمت قبل در مورد این صحبت کردیم که چطور می‌تونیم کد جاوااسکریپت مون رو به یک فرمت قابل فهم برای کامپایلر تبدیل کنیم. حالا کامپایلر از این فرمت چطور می‌تونه استفاده کنه و یک کد رو اجرا کنه؟

AST Interpreter

یک زبان ساده رو در نظر بگیریم که فقط عبارات ساده ریاضی توش کار می‌کنه. مثلا ‍1 + 2 و یا (1 + 1 + 3) * 4. اگه برای مثال دومی بیایم یک AST درست کنیم، همچین چیزی ممکنه بدست بیاد:
v8-ignition-ast-1.png
برای اینکه بتونیم یک درخت رو اجرا کنیم، باید از بالاترین Node که اینجا Expression هست شروع کنیم و مقدارش رو حساب کنیم. بعضی از Node ها مثل همین Expression برای اینکه بتونن اجرا بشن نیاز دارن فرزندانشون اول اجرا بشن. این کار رو تا جایی انجام میدیم که به اعداد تنها برسیم که دیگه فرزندی ندارن، حالا اعداد حساب شده رو دونه دونه از پایین به بالا می‌بریم تا در نهایت جواب آخر رو داشته باشیم. حتمالا این توضیح زیاد واضح نبوده پس بیایم مثال بالا رو مرحله به مرحله حل بریم ببینم چی می‌شه. از Node ریشه که اینجا Expression هست شروع می‌کنیم. این Node یک فرزند داره که می‌تونه یک عدد یا یک عبارت ریاضی باشه. تو این مثال یک عبارت ریاضی هست به نام Mul که کار ضرب رو انجام می‌ده. س باید اول ببینیم جواب Mul چی می‌شه. خود Mul دو فرزند داره که از نوع Expression هستن. قبل از اینکه جواب Mul رو داشته باشیم، باید جواب فرزند هارو بدست بیاریم و نتیجه رو در هم ضرب کنیم. نتیجه می‌شه جواب Mul. فرزند سمت چپ اون یک عبارت دیگست که فرزندش یک عملیات ریاضی جمع هست. مانند Mul دو فرزند داره و برای جوابش نیاز به جواب این دو فرزند داره. به فرزند دست چپ اگر نگاه کنیم یک عدد هست پس جواب فرزند دست چپ می‌شه همون عدد:
v8-ignition-ast-2.png
حالا باید فرزند دست راست رو حل کنه که باز خودش یک عمل جمع دیگست که دو فرزند عدد داره. جواب اونو بدست میاریم و بجای خودش می‌زاریم:
v8-ignition-ast-3.png
حالا که جواب سمت چپ و راست رو داریم می‌تونیم جواب نهایی عملیات جمع رو هم بدست بیاریم:
v8-ignition-ast-4.png
حالا جواب عبارت و سمت چپ Mul هم داریم. فرزند سمت راست اون هم یک عدد هست پس جواب سمت راستش هم موجوده. کافیه این دوتا رو در هم ضرب کنیم تا جواب Mul بدست بیاد:
v8-ignition-ast-5.png
و در آخر چون جواب Node ریشه برابر با Mul هست، جواب آخر هم همان می‌شود:
v8-ignition-ast-6.png
به این روش اجرا کردن یک کد، AST Interpreter و یا Visitor Pattern می‌گن که میایم هر Node رو تا پایین حل می‌کنیم و جواب رو تا بالا بر می‌گردونیم. همچین روشی برای زبان ساده بالا ممکنه خوب جواب بده اما برای زبان پیچیده‌ای مثل جاوااسکریپت ممکنه خیلی مناسب نباشه. دلایل ریز زیادی داره اما چنتا از دلایل مهمش این ها هستن:
  • برای پیاده سازی یک AST Interpreter نیاز داریم که از تعداد زیادی recursive فانکشن استفاده کنیم که این برای برنامه های پیچیده می‌تونه خیلی راحت bottleneck بشه.
  • فرمت کد درخت هست یعنی خوندن خود کد از مموری غیرخطیه. این باعث می‌شه زیاد نتونیم از Cache CPU برای نگه داشتن خود کد استفاده کنیم. مثلا اگر یک loop داشته باشیم که محاسبات سنگینی داخلش داره، بیشتر زمان محاسبه صرف این می‌شه که بریم کد رو از مموری بخونیم تا اینکه خود کد رو اجرا کنیم.
  • درسته که AST یک فرمت ساده‌تر از javascript پارس نشده هست، اما خود این فرمت هم نسبت به فرمتی که در ادامه این مقاله بهش می‌پردازیم سطح بالا هستش و برای اجرای بعضی از کد های جاوااسکریپت نیاز هست یکسری تحلیل هارو runtime انجام بدیم. تو مقاله قبل گفتم که برای اینکه بتونیم یک چیزی رو سریع کنیم باید کار کمتری انجام بدیم پس هرچقدر کار پیچیده انجام بدیم، اجرامون هم کندتر می‌شه.

Bytecode Interpreter

احتمالا حتی اگر کد assembly نزدید، نمونه هاشو دیدید. کد زیر یک نسخه ساده شده یک کد assembly هست:
v8-ignition-asm-1.png
هر خط کد از دو قسمت OpCode یا کد دستور و پارامتر های اون تشکیل می‌شه. توی مثال بالا از ۳ دستور مختلف استفاده کردیم. دستور اول یا MOV میاد توی یک مکان حافظه یک مقدار رو منتقل می‌کنه. پس توی خط اول داریم مقدار 1 رو داخل مکان B می‌ریزیم. دستور ADD هم میاد پارامتر ورودی که بهش دادیم رو با A جمع می‌کنه و جوابشو داخل A می‌ریزه. دستور JMP هم مثل goto کار می‌کنه. وقتی بهش رسیدیم برمی‌گردیم به جایی که دستور اشاره کرده. گر دقت کرده باشید یک چیزی به نام PC داریم که یک اشاره گر به دستوری هست که قرارِ اجرا بشه. بیام یک قدم بریم جلو:
v8-ignition-asm-2.png
دستور اول اجرا شد و مقدار B برابر 1 شد. یک مرحله دیگه کد رو اجرا کنیم:
v8-ignition-asm-3.png
مقدار A با B جمع شد و حاصلش رو ریختیم داخل A. الان A مقدار 1 و B هم مقدار 1 رو داره. بریم بعدی:
v8-ignition-asm-4.png
مقدار A رو با عدد 2 جمع می‌کنیم. حاصل می‌شه 3 که اونو می‌ریزیم توی A. حالا می‌رسیم به دستور ‍. این دستور تنها کاری که می‌کنه PC (که خودش یک پوینتر هست و پوینتر هم یک عددِ) رو تغییر می‌ده. با این کار مشخص می‌کنه کد بعدی که باید اجرا بشه چیه:
v8-ignition-asm-5.png
بر می‌گردیم بالا و اگه دو قدم بریم جلو دوباره به JMP می‌رسیم و A برابر با 6 می‌شه:
v8-ignition-asm-7.png
کد بالا تا توی یک چرخه می‌تونه اجرا بشه و مقدار A مدام عوض می‌شه. حالا کد زیر رو نگاه کنید:
let A = 0;
let B = 0;
B = 1;
while (true) {
A += B;
A += 2;
}
کد اسمبلی مثال، مثل کد جاوااسکریپت بالا عمل می‌کنه. اجرا کردن کد اسمبلی خیلی ساده‌ هست. یکسری دستور هست که یکسری عدد رو تغییر می‌دن و نه بیشتر از این. گفتیم هرچه ساده‌تر و کار کمتر انجام بدیم، کد سریع‌تری خواهیم داشت. حالا اگه بتونیم کد جاوااسکریپت از AST به اسمبلی تبدیل کنیم، می‌تونیم خیلی ساده‌تر از AST Interpreting هم اجراش کنیم. مشکلی که اسمبلی داره اینه که برای هر CPU ای دستورات و معماری متفاوتی داره. مرورگری مثل کروم که روی دستگاه ها و CPU مختلفی اجرا می‌شه نمی‌تونه صرفا کد جاوااسکریپت رو به یک اسمبلی CPU خاص تبدیل کنه. برای همین خیلی از زبان ها تصمیم گرفتن بجای اینکه مستقیم تبدیل به اسمبلی کنن کد رو به Bytecode تبدیل کنن. این Bytecode یک زبانی مثل اسمبلی هست اما با این تفاوت که یک لایه Abstraction روی معماری CPU می‌کشه تا روی معماری مختلف بتونه اجرا بشه. اگر همچین چیزی داشته باشیم موقع تبدیل کد جاوااسکریپت به این مدل، نیاز نیست به معماری CPU هم فکر کنیم. زبان های مختلف Bytecode های مختلف خودشون رو دارن. مثلا C# فرمت IL و جاوا فرمت JVM Bytecode رو داره. جاوا اسکریپت هم Bytecode خودش رو داره که جلوتر فرمت و اینکه چطور اجرا می‌شه رو می‌بینیم.

Ignition

بیایم برای کد جاوااسکریپت زیر ببینیم V8 چه Bytecode هایی براش درست می‌کنه. کد زیر رو داخل یک فایل با نام test.js ذخیره می‌کنیم:
function test_fn() {
let A = 0;
let B = 0;
B = 1;
while (true) {
A += B;
A += 2;
}
}
test_fn();
سپس با با استفاده از NodeJs می‌تونیم با اجرای دستور زیر، Bytecode های تابع رو بدست بیاریم.
$ node --print-bytecode --print-bytecode-filter=test_fn test.js
v8-ignition-ignition-1.png

توی نگاه اول می‌بینیم یکسری تشابهاتی با کد اسمبلی فرضی مون داره. بیایم قسمت هایی که الان مهم نیستن رو پاک کنیم و ببینیم این Bytecode چه معنی ‌ای داره.
v8-ignition-ignition-2.png

دستور اول یا LdaZero میاد داخل رجیستر (بخوانید یک مکان حافظه در پردازنده با دسترسی سریع) A (بخوانید رجیستر Accumulator) مقدار 0 رو می‌ریزه. این رجیستر معمولا برای نگه داشتن نتیجه استفاده می‌شه. اسم دستور از سه قسمت Ld به معنی Load کردن، A که نام رجیستر هست و Zero که مقدار ثابت صفر هست تشکیل می‌شه. یعنی بیا مقدار 0 رو بریز توی A. اینکه چرا برای این رجیستر خاص و این مقدار خاص یک دستور جدا مشخص شده به این دلیل هست که این الگو خیلی استفاده می‌شه و کاربردی هست. ا ساخت یک دستور جدا می‌شه اونو بهینه‌تر هم اجراش کرد چون لازم نیست برای هر بار اجرا چک کنیم مکان حافظه تارگتمون چی هست و می‌دونیم که همیشه رجیستر A خواهد بود.
اگر به دستور بعدی نگاه کنیم، ساختار مشابهی داره. Star0. میاد مقدار A رو توی رجیستر r0 میریزه که توی دستور قبلی این مقدار صفر بود. دو خط بعدی هم همینکارو با رجیستر r1 انجام می‌دن.
می‌رسیم به دستور LdaSmi [1]. این دستور میاد داخل A یک عدد کوچیک می‌ریزه که اینجا عدد یک هست. دستور بعد هم مقدار A رو داخل r1 میریزه. اگر دقت کرده باشید این چند خط اول دقیقا کار چند خط اول تابع مارو انجام میدن. اینجا رجیستر r0 همون A (متغییر با نام A و نه رجیستر A) هست و رجیستر r1 همون B.
انجین تا جای ممکن سعی می‌کنه برای متغییر های اوکال از رجیستر استفاده کنه. اگه زیاد متغییر محلی یا برای تابع پارامتر داشتیم اونارو میاره توی استک.
v8-ignition-ignition-3.png

دستور Add و AddSmi اینطوری کار می‌کنن که پارامتری که بهشون می‌دیم رو با رجیستر A جمع می‌کنن و نتیجه رو داخل A می‌ریزن. ادامه کدمون پس این معنی رو می‌ده:
v8-ignition-ignition-4.png

دستور JumpLoop مثل JMP که گفته بودیم عمل می‌کنه و میاد PC یا IP رو عوض می‌کنه. با عوض کردن اون به قبل از دستور A += B در اصل ما همون حلقه بی‌نهیات تابعمون رو درست کردیم. در نهایت می‌دونیم که اگه برای یک تابع return نزاریم بیش‌فرض مقدار undefined برمی‌گردونه، دو خط آخر همینکارو می‌کنن.
این مواردی که گفته شد یعنی تبدیل کد جاوا اسکریپت از AST به Bytecode و اجرای اونو موتور Ignition یا Interpreter V8 انجام می‌ده. این نکته رو در نظر داشته باشید که اگه خودتون دستورات بالا رو اجرا کردید ممکنه خروجی متفاوتی دریافت کنید چون Bytecode ها توی هر ورژن ممکنه تغییر کنن همچنین یک نسخه جدید V8 می‌تونه استراتژی تولید کد متفاوتی داشته باشه.
تو قسمت بعدی به این میپردازیم که v8 چطوری تصمیم میگیره یک قسمتی از کد رو optimize کنه.

منابع

لیست بایت‌کد ها اینجا می‌تونید ببینید:
اینکه هر دستور چه چیزی رو اجرا می‌کنه هم می‌تونید کلیتش رو از اینجا متوجه بشید:
اگر براتون این بایت‌کد ها جالب بود حتما پیشنهاد میکنم این مقاله رو بخونید: