کامپایلر درجا (Just in Time Compiler) – از صفر تا صد


دنیای علوم رایانه همواره شاهد موازنه بین مزایا و معایب فناوریهای مختلف بوده است. گاهی اوقات مجبور بودهایم بین دو ساختمان داده، دو الگوریتم یا دو تابع که هر دو میتوانند کار مورد نظر را انجام دهند، اما ماهیتی کاملاً متفاوت دارند، انتخاب بکنیم. در انتها ما باید برخی چیزها را که اهمیت و ارزش بیشتری برایمان دارند انتخاب کرده و چیزهای دیگر را فدا کنیم.
اما این مفهوم فقط در مورد دنیای علوم رایانه مصداق ندارد؛ بلکه در همه شاخههای محاسبات چنین است. حتی اگر ارتباط مستقیم با مفاهیم علوم رایانه نداشته باشیم همچنان مجبور هستیم هنگام کدنویسی انتخابهای مختلفی انجام داده و گزینههای گوناگون را سبک و سنگین بکنیم. در مقیاس وسیعتر، ما باید مزیتها و معایب فناوریهای مختلف، تصمیمهای طراحی، و پیادهسازی راهبردها را نیز در نظر بگیریم. همان طور که میبینید باز هم به یک موازنه و مقایسه نیاز داریم.
اما این مقایسه و حفظ تعادل در فناوری چندان بد هم نیست. برخی اوقات این همان چیزی است که مستقیماً باعث رشد ما میشود. فریمورکها و زبانهای جدید در اغلب موارد چنان طراحی شدهاند که توسعهدهندگان باید بین چیزهای مختلف انتخاب کنند. به بیان دیگر در این موارد باید از مقایسه و موازنه در مورد چیزهایی استفاده کنیم که شاید ماهیتی چندان متمایز نداشته باشند. بسیاری از فناوریها باعث میشوند این انتخابها آسانتر شوند و از مصائب آنها کاسته شود و به این ترتیب برنامهنویسان مختلف دیگر نیاز ندارد بین دو روش کاملاً متفاوت برای حل یک مسئله، یکی را انتخاب کنند. بلکه این رویکردهای جدید تلاش میکنند بهترین جوانب هر دو دنیا را داشته باشند و یک روش میانی مناسب ارائه دهند که همزمان یادگیری و ترکیب مفاهیم هر دو طرف را میسر میسازند. در دنیای محاسبات این اتفاق بارها و بارها رخ داده است.
شاید جالبترین مثال از این حالت اتحاد کامپایلر و مفسرها بوده است. بدین ترتیب این دو فناوری قدرتمند با هم ترکیب شدهاند و یک مفهوم جدید خلق کردهاند که امروزه آن را به نام «کامپایلر درجا» (just-in-time compiler) میشناسیم.
آمیزهای از کامپایلر-مفسر
ما در نوشتههای متعددی در بلاگ فرادرس به بیان مفهوم کامپایلر و همین طور مفسر پرداختهایم و طرز کار آنها را بررسی کردهایم. کامپایلرها زمینه پیدایش مفسرها را فراهم ساختهاند و تاریخچه مفسرها نیز گویای این است که ارتباطی عمیق با آنچه پس از آنها معرفی شده یعنی کامپایلرهای درجا دارند.
مفسر در سال 1958 از سوی «استیو راسل» (Steve Russell) ابداع شده است. وی در آن زمان با یک استاد MIT به نام «جان مککارتی» (John McCarthy) همکاری میکرد. مککارتی مقالهای در زمینه زبان برنامهنویسی Lisp نوشته بود و راسل قصد داشت پس از مطالعه مقاله مک کارتی با وی در این زمینه همکاری کند.
با این حال، مککارتی مقاله دیگری با عنوان «تابعهای بازگشتی عبارتهای نمادین و محاسبات آنها از سوی ماشین» (+) نوشت که در سال 1960 منتشر شد. با این که نمیتوان کاملاً مطمئن بود؛ اما این مقاله احتمالاً یکی از نخستین مقالاتی است که در آن اشاراتی به فرایند کامپایل در جا شده است.

یکی دیگر از ارجاعهای نخستین به کامپایلرهای درجا در سال 1966 در راهنمای سیستم اجرایی دانشگاه میشیگان برای کامپیوتر IBM 7090 ارائه شده است. این راهنما که برای سیستم خاصی نوشته شده است، به توضیح این که چگونه میتوان کدی را در زمان اجرا، همزمان هم ترجمه و هم بارگذاری کرد پرداخته است و در واقع سرنخی به ما میدهد که تلاشها برای پیادهسازی کامپایلرهای درجا در سطح عملی از میانههای دهه 1960 میلادی آغاز شده است.
اما اگر میخواهید بدانید که مفهوم کامپایلر درجا، از کجا پیدا شده است باید بگوییم که این مفهوم فرزند والدین خود یعنی کامپایلر و مفسر است. JIT ترکیبی از مفسر و کامپایلر است که به دو نوع از ترجمه کد گفته میشود. یک کامپایلر درجا بسیاری از مزیتهای این دو تکنیک ترجمه را دارد که در یک نوع واحد ادغام شده است.
کامپایلر چه تفاوتی با مفسر دارد؟
میدانیم که هم کامپایلر و هم مفسر به کار ترجمه کد منبع برنامهنویسی شده به کد قابل اجرا از سوی ماشین میپردازند و این کار یا از طریق ترجمه در یک مرحله (کامپایلر) یا با تفسیر و اجرای خط به خط کد (مفسر) انجام مییابد.
کامپایلر را میتوان نوعی مترجم عالی دانست، زیرا باعث میشود کد سریعتر اجرا شود؛ اما کامپایلر باید همه کد منبع را ابتدا به فایلهای باینری تبدیل کند تا بتواند آنها را اجرا کند و این کار میتواند در مواردی که میخواهیم کدی را دیباگ کنیم و تنها کدی که داریم کد قابل اجرا از سوی ماشین است وضعیت دشواری پدید میآورد.
از سوی دیگر مفسر میتواند به طور مستقیم قطعات کد را در طی «زمان اجرا» (runtime)، به اجرا در آورد و این بدان معنی است که اگر چیزی اشتباه شود میتواند چارچوبی که هنگام فراخوانی کد اجرایی وجود داشت را حفظ کند. با این وجود، مفسر باید کد را چندین بار ترجمه کند که باعث میشود کند شده و کارایی کمی داشته باشد.
JIT چیست؟
بدین ترتیب مفهوم کامپایلر درجا متولد شده است. برای شروع توضیح این مفهوم باید بگوییم که JIT مانند یکی از والدین خود عمل میکند. برای نمونه JIT مانند یک مفسر عمل میکند و کد را در زمان فراخوانی اجرا میکند. با این وجود اگر کدی پیدا کند که بارها و بارها فراخوانی میشود، مانند والد دیگر خود یعنی کامپایلر عمل میکند.
JIT اساساً مانند یک مفسر عمل میکند؛ مگر این که متوجه شود که در موردی یک دسته از کارها را به صورت تکراری اجرا میکند. در این حالت JIT مانند یک کامپایلر عمل میکند و کدهای با فراخوانی مکرر را از طریق کامپایل مستقیم بهینهسازی میکند. بدین ترتیب JIT میتواند هر دو جنبه مثبت والدین خود یعنی کامپایلر و مفسر را داشته باشد. با این که JIT کار خود را با تفسیر کد آغاز میکند؛ اما این کار را به طرز خاصی انجام میدهد. JIT باید در مورد کدی که در طی تفسیر خط به خط اجرا میکند مراقب باشد. JIT باید بتواند به سؤال زیر پاسخ دهد:
آیا میتوانم این کد را مستقیماً تفسیر بکنم یا بهتر است یک مرحله جلوتر بروم و آن را کامپایل بکنم تا نیازی به اجرای مکرر عمل ترجمه نداشته باشم؟
پاسخ به این سؤال در برخی موارد کار دشواری است و برای این کار JIT دائماً همه اتفاقهایی که رخ میدهد را تحت نظر میگیرد و اصطلاحاً کد را در زمان اجرا کردن، monitor یا profile میکند.
زمانی که JIT کد را تفسیر میکند به طور همزمان آن را مانیتور میکند. زمانی که متوجه یک کار تکراری شد با خود فکر میکند: «تکرار مکرر این کارها احمقانه است و باید سعی کنم به طرز هوشمندانهای این کد را اجرا کنم».
این وضعیت در مقام تئوری عالی به نظر میرسد؛ اما این که JIT پاسخ این سؤال را عملاً از کجا میداند، در بخش بعدی به توضیح این موضوع میپردازیم.
دود نشانه آتش است و آتش نشانه کامپایل است
میدانیم که JIT باید همواره کدی که اجرا میکند را تحت نظر بگیرد. اما JIT چطور میتواند همه چیز را زیر نظر داشته باشد؟ ما میتوانیم این وضعیت را از بیرون تصور بکنیم. میتوانیم یک کاغذ یا دفترچه یادداشت داشته باشیم و کارهایی که اتفاق میافتند را در آن یادداشت کنیم تا همه اتفاقاتی که میافتند را ردگیری کنیم.
JIT نیز دقیقاً همین کار را انجام میدهد. JIT به طور معمول یک فرایند رصد درونی دارد که کدهایی که مشکوک به نظر میرسند را علامتگذاری میکند. برای نمونه اگر یک بخش از کد منبع ما چندین بار فراخوانی شود، JIT یک یادداشت از این واقعیت برمیدارد که کد به طور مکرر فراخوانی شده است و این کد غالباً «کد گرم» (warm code) نامیده میشود.
به همین ترتیب اگر برخی خطوط کد منبع ما بارها و بارها فراخوانی شوند، JIT یادداشتی برمیدارد و این کد را به صورت «کد داغ» (hot Code) مینامد. JIT با استفاده از این معیارها میتواند به سادگی بفهمد که کدام خطوط و بخشهای کد میتوانند بهینهسازی شوند. به بیان دیگر میتواند کد را به جای تفسیر، کامپایل کند.
مثال مفهومی
درک ارزش و مفید بودن کدهای «گرم» و «داغ» با ارائه یک مثال بهتر مشخص میشود. بنابراین در ادامه نگاهی به نسخه فشرده متن کد منبع میاندازیم که میتواند در هر زبانی و با هر اندازهای باشد. با توجه به مقاصدی که ما دنبال میکنیم، میتوانیم تصور کنیم که این یک برنامه بسیار کوتاه است که کلاً از 6 خط کد تشکیل یافته است.
با نگاه کردن به تصویر فوق میتوانیم مشاهده کنیم که خط 1 در موارد متعدد فراخوانی شده است. JIT به سرعت درک میکند که خط 1 یک کد داغ است.
از سوی دیگر خط 4 هرگز فراخوانی نشده است. این خط شاید شامل تعیین متغیری باشد که هرگز مورد استفاده قرار نگرفته است یا خط کدی است که هرگز فراخوانی نشده است. این کد غالباً به نام «کد مرده» (dead code) نامیده میشود.
در نهایت خط 5 برخی اوقات فراخوانی شده است؛ اما تعداد این فراخوانیها به اندازه خط 1 نبوده است. JIT تشخیص میدهد که این کد گرم است و میتواند به طور بالقوه به طرز مشابهی بهینهسازی شود.
JIT باید تشخیص دهد که باید با هر یک از این خط کد چه کار بکند و از این رو میتواند بهترین روش را برای بهینهسازی تعیین کند. دلیل این که چنین ملاحظاتی باید در نظر گرفته شوند، این است که اجرای همه بهینهسازیها در همه موارد مناسب نیست. JIT بسته به کارایی بهینهسازیها تصمیم میگیرد که کد را بهینهسازی کند و یا این که تشخیص میدهد اجرای بهینهسازی نمیتواند چندان مفید باشد.
انواع کامپایل
در ادامه به مثالی میپردازیم که چگونه عدم بهینهسازی هوشمند از سوی JIT میتواند منجر به بهینهسازی ضعیف شود.
ما کار خود را با خط 1 آغاز میکنیم. در این موقعیت کد موجود در خط 1 به طور مکرر اجرا میشود. فرض کنید JIT متوجه میشود که این خط به طور مکرر اجرا شده است. مشخص است که JIT آن را به عنوان کد داغ علامتگذاری کرده و تصمیم میگیرد که آن را کامپایل کند. اما روشی که JIT برای کامپایل کردن انتخاب میکند به همان اندازه تصمیم در مورد کامپایل کردن آن مهم است.
کامپایل مبنا
JIT میتواند انواع متفاوتی از کامپایل را اجرا کند که برخی از آنها سریع و برخی دیگر پیچیدهتر هستند. یک کامپایل سریع کد غالباً بهینهسازی عملکردی پایینتری به ارمغان میآورد و شامل کامپایل کردن کد و سپس ذخیرهسازی نتیجه کامپایل بدون صرف زمان زیاد است. این شکل از بهینهسازی سریع به نام «بهینهسازی مبنا» (Baseline Optimization) نامیده میشود.
با این وجود اگر JIT بخواهد بهینهسازی مبنای خط 1 را اجرا کند این وضعیت چه تأثیری روی زمان اجرای کلی کد خواهد داشت؟ نتیجه یک بهینهسازی ضعیف روی خط 1 میتواند موجب افزایش زمان اجرای کد به صوت خطی شود، چون تعداد فراخوانیهای کد موجود در خط 1 افزایش مییابد.
کامپایل بهینه
به طور جایگزین JIT میتواند نوع عمیقتر و طولانیتری از بهینهسازی عملکرد که به نام «کامپایل بهینه» (Optimizing Compilation) یا «opt-compiling» نامیده میشود را اجرا کند. opt-compiling شامل صرف زمان زیاد در ابتدا و سرمایهگذاری برای بهینهسازی یک بخش از کد به وسیله کامپایل کارآمدترین حالت ممکن برای کد و سپس استفاده از مقادیر ذخیرهشده آن بهینهسازی است.
کامپایل مبنا را میتوان به نوعی متضاد کامپایل بهینه دانست و با ارائه مثالی که دو رویکرد متفاوت برای ویرایش یک مقاله را نشان میدهد آن را بیشتر توضیح میدهیم.
کامپایل مبنا تا حدودی شبیه به ویرایش یک مقاله از نظر املا، علائم سجاوندی و دستور زبان است. در این حالت ما بهبودهای عمیقی روی مقاله ایجاد نمیکنیم؛ بلکه چند بهبود جزئی اجرا میکنیم. از سوی دیگر کامپایل بهینه به نوعی مانند ویرایش محتوایی، مفهومی و خوانایی یک مقاله است که علاوه بر موارد غلطهای املایی و دستور زبانی اجرا میشود. در کامپایل بهینه کار زیادی در ابتدا صوت میگیرد؛ اما منجر به نتیجه بهتر میشود.
نکته خوب در مورد کامپایل بهینه این است که وقتی نسخه کامپایل شده از کد را به بهینهترین روش ممکن داشته باشیم، میتوانیم نتیجه آن کد بهینهسازی شده را ذخیره کنیم و آن کد بهینه را متعاقباً بارها و بارها اجرا کنیم. این بدان معنی است که مهم نیست چندین بار یک متد را در بخشی از کد که بهینهسازی کردهایم فراخوانی کنیم، ما همواره زمان ثابتی برای اجرای آن کد صرف میکنیم زیرا میخواهیم همان فایل کامپایل شده را هر بار اجرا کنیم. حتی با این که تعداد فراخوانیهای متد افزایش مییابد؛ زمان اجرا برای اجرای کد همان مقدار باقی میماند و این وضعیت منجر به پیچیدگی زمانی ثابت (O(1 برای کدی میشود که به صورت بهینه کامپایل شده است.
بر اساس نمادگذاری O بزرگ، برای کامپایل بهینه به نظر میرسد که کامپایل بهینه باید همواره راهی برای ادامه مسیر خود داشته باشد، اما برخی نمونهها هستند که در آنها صرف تلاش برای کامپایل بهینه اتلاف وقت محسوب میشود.
برای نمونه اگر JIT بخواهد همه کد را به صورت بهینه کامپایل کند چه اتفاقی میافتد؟ در مثال قبلی دیدیم که خط 4 کد در عمل هرگز فراخوانی نمیشود و یک کد مرده است. اگر JIT زمان خود را صرف کامپایل بهینه خط 4 بکند که هرگز اجرا نخواهد شد در این صورت زمان خود را به اتلاف داده است، زیرا اقدام به بهینهسازی یک خط از کد کرده که هرگز فراخوانی نمیشود. در این وضعیت، کامپایل بهینه به صورت کورکورانه و بدون بررسی کامل آن چه در عمل اجرا میشود و داغ بودن کد، صورت گرفته است و نتیجه آن اتلاف وقت و تلاش بوده است.
داغ یا گرم؟
بنابراین همان طور که دیدیم یک کامپایلر JIT باید یک روش میانی مناسب بین کامپایل مبنا و کامپایل بهینه پیدا بکند. این دقیقاً همان جایی است که داغ بودن کد به کار میآید. JIT از داغ بودن خطوط کد جهت تصمیمگیری در مورد این که میزان اهمیت کامپایل شدن آن کد چه قدر است، استفاده میکند؛ همچنین تعیین میکند که این کامپایل باید به روش مبنا یا بهینه صورت بگیرد.
یک مسیر مناسب به کامپایل بهینه درجا منتهی میشود در بخشهای قبل دیدیم که JIT از معیار داغ بودن کد برای تصمیمگیری در مورد تعیین این که باید از کدام راهبرد کامپایل استفاده کند، بهره میگیرد. اینک سؤال این است که این تصمیم دقیقاً چگونه اتخاذ میشود؟
JIT در مورد کدهایی که داغ یا گرم نباشند و از آن جمله در مورد کدهای مرده، مانند یک مفسر عمل میکند و هیچ گونه کامپایلی روی آنها صورت نمیدهد.
اما در مورد کدی که گرم باشد؛ اما داغ نباشد JIT از بهینهسازی سریعتر مبنا در طی اجرای برنامه استفاده میکند. به بیان دیگر زمانی که کد را تفسیر میکند و متوجه میشود که این کد گرم است، همچنان که کد در حال اجرا است آن را برای کامپایل شدن ارسال میکند. این کد گرم به سادهترین و سریعترین روش کامپایل میشود که کمترین عملکرد ممکن را دارد. این بدان معنی است که بهبود جزئی در کد ایجاد میشود زیرا کامپایل مبنا در مورد کد گرم صرفاً بهتر از هیچ است.
با این وجود در مورد کدی که داغ است و به طور مکرر فراخوانی میشود، JIT متوجه این نکته میشود و هنگامی که به تعداد کافی فراخوانی شود، JIT اجرای برنامه (تفسیر) را متوقف میکند و آن کد را برای کامپایل بهینه به بهترین روش ممکن ارسال میکند. این بدان معنی است که زمان زیادی صرف بهینهسازی کد در ابتدا میشود. مزیت این روش آن است که کد داغ تنها یک بار باید بهینهسازی شود و این وضعیت با توجه به فراخوانی مکرر آن کد ارزش صرف وقت را دارد. زمانی که کد داغ بهینهسازی شد JIT در موارد آتی بارها و بارها از آن نسخه بهینهسازی شده کد ماشین استفاده میکند و دیگر لازم نیست که هر بار آن را برای کامپایل مجدد ارسال کند.
قاعده سرانگشتی برای به خاطر سپردن این وضعیت به صورت زیر است:
JIT در مورد کدی که به طور مکرر فراخوانی میشود، از کامپایل مبنا استفاده میکند که سریعتر است. با این وجود اگر یک کد از حدی بیشتر فراخوانی شود، JIT از روش کامپایل بهینه استفاده میکند، چون این کار ارزش صرف وقت را دارد.
کامپایلر جایزالخطاست
در موارد بسیار نادری ممکن است JIT یک فراخوانی نادرست در خصوص نوع کامپایل یک کد انجام دهد. یعنی تشخیص دهد که یک قطعه کد به قدر کافی برای اجرا بهینه شده است؛ اما در عمل چنین نباشد. برای نمونه اگر JIT به دنبال خطوطی از کد باشد که 5 بار فراخوانی شده باشند و ببیند که یک خط از کد 4 بار فراخوانی شده است در زمان فراخوانی پنجم احتمالاً آن را برای کامپایل بهینه ارسال میکند. در موارد بسیار نادری ممکن است خط کدی که کامپایل بهینه شده است، دیگر هرگز فراخوانی نشود. در این حالت همه کاری که برای کامپایل کردن آن خط کد اجرا شده، در واقع به هدر رفته است.
این حالت در واقع بخشی از مفهوم ترجمه دینامیک است که در زمان اجرای کامپایل درجا اجرا میشود. در موارد متعددی JIT ممکن است تصمیم بگیرد کدی را از پیش بهینهسازی کند که در عمل دیگر هرگز فراخوانی نخواهد شد. با این وجود، این وضعیت نادر است، زیرا اغلب خطوط کد همواره به دفعات یا دستکم چندین بار فراخوانی میشوند. این احتمال وجود دارد که JIT های مدرن امروزی این اشکال را نیز رفع کنند؛ اما این امکان نیز هست که JIT در مواردی اشتباه کند.
در اغلب موارد JIT در مورد تشخیص این که باید به صورت یک مفسر عمل کند یا باید یک قطعه کد را کامپایل کند به درستی عمل میکند. نکته جالب در این مورد آن است که JIT به ما امکان میدهد تنها به چیزهایی سرعت بدهیم که لازم است سریع باشند. کامپایل کردن درجا امکان بهینهسازی و کامپایل کدی که به طور مکرر استفاده میشود را فراهم میکند.
به علاوه JIT امکان نشانهگذاری مکانی از کد منبع که کد کامپایل شده از آنجا اجرا میشود را فراهم میکند. به بیان دیگر میتوانیم همچنان بدانیم که کد کامپایل شده در کدام بخش از سورس کد قرار دارد.
برای نمونه در کد فوق JIT تشخیص میدهد که ()function one دارای داغی بالایی است و میتواند برای اجرای کارآمدتر بهینهسازی شود. با این که ()function one کامپایل شده است؛ اما همچنان میتوانیم در متن سورس کد خود بدانیم که این کد کامپایل شده از کجا میآید. بدین ترتیب اگر هر گونه خطایی در این کد کامپایل شده وجود داشته باشد، میدانیم که دقیقاً در کجا قرار دارد. از آنجا که کامپایل در طی زمان اجرا صوت میگیرد، میتوانیم به آسانی هر خطا یا مشکلی را دیباگ کنیم زیرا میتوانیم به ()function one نگاه کنیم و سرنخ خطا را بیابیم. دلیل این امکان آن است که خطا از کد کامپایل شده تولیدی برای این خط خاص ناشی میشود.
سخن پایانی
کامپایلر درجا مزیتهای هر دو دنیای کامپایلرها و مفسرها را در اختیار ما قرار میدهد. بدین ترتیب میتوانیم کد سریعی را اجرا کنیم که بهینهسازی شده است و از طریق کامپایل اجرا میشود و همچنین چارچوب مفسر را نیز حفظ کنیم که به دیباگ کردن آسان کد کمک میکند.
JIT به عنوان یک نمونه کامل از راهحلهای ایدهآلی است که در دنیای رایانه به دست آوردهایم و نیازی نیست که به مقایسه و انتخاب ترجیحی یک گزینه و فدا کردن گزینههای دیگر بپردازیم. بدین ترتیب میتوانیم در کد خود هم کامپایلر و هم مفسر را داشته باشیم.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای مهندسی نرم افزار
- آموزش طراحی کامپایلر
- مجموعه آموزشهای دروس مهندسی کامپیوتر
- آموزش طراحی کامپایلر — مجموعه مقالات جامع وبلاگ فرادرس
- آموزش تجزیه انتقال (کاهش) در طراحی کامپایلر
- کامپایلر، طراحی و معماری آن — به زبان ساده
- آموزش طراحی کامپایلر (مرور و حل تست های کنکور کارشناسی ارشد)
- بهینه سازی کد (Code Optimization) در طراحی کامپایلر — راهنمای جامع
==