JIT Compiler چیه و چطور کار میکنه؟ - قسمت ۴
احتمالا شنیدید که کامپایلر یا Interpreter یک زبانی موقع اجرا کد اونو بهینه میکنه. مثلا توی پایتون اگه یک فانکشن زیاد اجرا بشه، Python Interpreter تصمیم میگیره که اونو بهینه کنه.
همینطور برای Ruby. زبانهایی مثل Dart و C# که کامپایل میشن هم داخل خودشون JIT دارن.
کلا به هر کامپایلری که موقع اجرای برنامه کد تولید و اجرا میکنه، بهش JIT Compiler میگن. توی این مقاله میخوایم با JIT Compiler ها آشنا بشیم و توی مقاله بعدی خواهیم دید که TurboFan که JIT Compiler داخل v8 هست چطور کار میکنه.
اصلا چرا JIT کردن لازمه؟
بیایم اول برای ساده کردن مسئله فقط به زبانهایی که Dynamically Typed و بایتکد دارن نگاه کنیم.
مثل Python و JavaScript. امکان Dynamic Type بودن و Interpret شدن هر دو بی هزینه نیستن. بیایم اول ببینیم Interepret شدن چه هزینهای داره:
یک Interpreter یک برنامهای هست که یک استریم از بایتکد (که یک برنامه دیگست) رو دریافت میکنه و اونو اجرا میکنه.
با اینکار اومدیم بجای اینکه مستقیم CPU Instruction بدیم به CPU که اون برامون اجرا کنه، یک لایه Indirection به اجرای برناممون اضافه کردیم. برای فهم بهتر این قضیه به این کد C نگاه کنید:
void main() {int a = 0;for (;;) { a += 1; }}
کد بالا وقتی کامپایل میشه مستقیم تبدیل به همچینن کد اسمبلی خواهد شد:
main:push rbpmov rbp, rspmov DWORD PTR [rbp-4], 0.L2:add DWORD PTR [rbp-4], 1jmp .L2
که درنهایت CPU مستقیم این کد اسمبلی رو اجرا میکنه. حالا بیایم فرض کنیم که کد C بالا رو بخواد یک Interpreter اجرا کنه.
اول باید تبدیلش کنیم به بایت کد که فرض کنیم همین کد اسمبلی بایت کد ما هم هست. Interpreter مون همچین چیزی میشه:
struct Instruction {int type; // 0: Mov, 1: Add, 2: JMPint arg0; // A: 0int arg1;};int main() {struct Instruction code[] = {{ type: 0, arg0: 0, arg1: 0 }, // MOV A, #0{ type: 1, arg0: 1 }, // ADD #1{ type: 2, arg0: 1 }, // JMP :1};int ip = 0;int A = 0;struct Instruction current;for(;;) {if (ip >= sizeof(code)) { break; }current = code[ip];switch(current.type) {case 0:switch(current.arg0) {case 0:A = current.arg1;break;default: return -1;}break;case 1:A += current.arg0;break;case 2:ip = current.arg0;continue;default: return -1;}ip += 1;}}
خیلی واضح هست که کد بالا که نوشتیم نسبت به کد اولی که کد C ساده بود، کار بیشتری انجام میده. هرچقدرم کار بیشتری انجام بدیم خب، کندتر هم برنامه اجرا میشه. این موضوع رو میتونیم با استفاده از godbolt که یک ابزار تحلیل خروجی کامپایلر هاست میتونیم واضح ببینیم.
حالا چرا Dynamically Type برای پرفرمنس بده؟ بیایم به داکیومنت EcmaScript یک نگاهی بندازیم و ببینیم اپراتور + چیکار میتونه بکنه.
میدونیم که اگه توی جاوا اسکریپت دو تا استرینگ رو با
+
جمع کنیم، خروجی یک استرینگ دیگه خواهد بود.
یا اگه سمت چپ استرینگ و سمت راست عدد باشه، عدد تبدیل به استرینگ میشه و یک استرینگ دیگه تولید میشه و حالت های زیاد دیگه.
نکتش اینه که + توی جاوا اسکریپت با + توی C فرق میکنه.
توی C همیشه جمع عدد هست اما جاوا اسکریپت کارها و چکهای زیادی میکنه.
خیلی واضح هست اینجا اگه میدونستیم از قبل که اگه تایپ چپ و راستمون چی هست، خیلی چک هارو لازم نبود انجام بدیم.چطور یک JIT Compiler کار میکنه؟
حالا بیایم ببینیم چطوری میشه یک JIT Compiler نوشت.
فرض کنیم همچین تابعی داریم و میخوایم JIT Compile اش کنیم.
function isNumberBigEnough(x) {if (x > 5000) {return true;} else {return false;}}
هرجایی که ما این تابع رو صدا بزنیم interpreter میاد تابع رو پیدا میکنه و body داخلش رو Interept میکنه.
الآن ما یک اشاره گر داریم به تابعی که میخواد تابع مارو Interpret و اجرا کنه. اگه یک تابعی در زبان هاستمون که مثلا اینجا C هست نوشته باشیم که کارش دقیقا همین isNumberBigEnough هست و این اشارهگر رو به اون تغییر بدیم، دیگه لازم نیست از Interpreter برای اجرا این تابع استفاده کنیم.
مشکل اینجاست که ما نمیتونیم از قبل کلی تابع داشته باشیم و اینارو جایگزین کنیم چون کاربر میتونه هر نوع تابعی تعریف کنه. باید یک راه داینامیکی باشه که بشه کد اسمبلی توی راینتایم نوشت.
برای این مسئله، بیایم ببینیم بطور خیلی کلی و خلاصه یک باینری چطور اجرا میشه.
اجرای یک باینری توی سیستم عامل های مختلف متفاوته ولی تقریبا همشون یکسری کانسپت شبیه به هم دارن.
وقتی ما یک برنامه رو اجرا میکنیم، اون فایل باینری توی مموری لود میشه. توی این فایلی که لود کردیم یک قسمتی داریم
به نام text section. این text section که توی همون مموری لود میشه که متغییرهامون رو تعریف میکنیم،
کدهامون هستن که مستقیم CPU اجراشون قراره بکنه.
سکشن ها مختلف توی مموری یک برنامهمون یکسری فلگ دارن که مشخص میکنن مثلا این سکشن فقط خواندنی هست یا تایپش چیه.
یکی از این فلگ ها اینه که این تیکه از مموری قابل اجرا شدنه. text section هم همینطوره یک سکشن فقط خواندی قابل اجرا شدنه.
کد مثل استرینگ و عدد یک نوع داده هست، اگه ما بیایم یک تیکه از مموری رو داخلش کد بریزیم و مارکش کنیم
که این تیکه از مموری قابل اجرا شدنه، میتونیم یک تابع رو رانتایم به به برناممون اضافه کنیم!
اگه لینوکس دارید میتونید با این دستور سکشن های مختلف یک باینری رو ببینیدobjdump -d
اینکه دقیقا چطور میتونیم اینکارو بکنیم خارج از بحث این مقاله هست پس بیایم از یک کتابخونه استفاده کنیم که کارای سختو برای ما انجام داده و ما کافیه بهش کد اسمبلی بدیم
و اون برامون اونو توی یک تیکه مموری قرار میده و calling convention رو هندل میکنه و درنهایت یک پوینتر به اون فانکشن بهمون میده!
کتابخونه jit.js یک کتابخونه مخصوص nodejs هست که اجازه میده اینکارو بکنیم.
(قطعا این کتابخونه توی محیط ایزوله شده کروم کار نخواهد کرد!)
مثال زیر یک تابعی درست میکنه که مقدار ۴۲ رو بر میگردونه.
توی اسمبلی رجیستر rax یک رجیستر خاص هست که مقدار خروجی تابع قبل از return داخلش قرار میگیره.
var jit = require('jit.js');var fn = jit.compile(function() {this.Proc(function() {this.mov('rax', 42);this.Return();});});console.log(fn());
حالا کافیه ما یک jit compiler بنویسیم که اسمبلی که میخوایم رو تولید کنه و تابعمون رو به برنامه اضافه کنیم.
از آخر هم جای تابع interpretor برای
isNumberBigEnough
رو میتونیم با این تابعی که ساختیم عوض کنیم.این پست کوتاه یک پیشزمینه بود برای اینکه توی پست بعدی ببینیم TurboFan یا JitCompiler داخل v8 چطور کار میکنه و چطوری کدای جاوااسکریپت رو بهینه میکنه یا اصلا کی تصمیم میگیره این کارو بکنه.
خودم خیلی علاقه دارم خارج از این سری مربوط به v8 در مورد کامپیلرها و بهینه سازی پست های جدا و با جزئیات بیشتری بنویسم ولی نمیدونم چقدر برای مخاطب جالب میتونه باشه چون بلاگ پست های فوق العاده انگلیسی در مورد این موضوع هست و فرد علاقهمند از اون دسته پست احتمالا بهره بهتری میبره. در این زمان پیشنهاد میکنم اگر به موضوع علاقهمند هستید، به مراجع آخر نگاه بندازید