درک JIT Compiler در نسخه ۸ زبان PHP


کامپایلر JIT (Just in Time) که در نسخه ۸ زبان PHP به عنوان بخشی از Opcache extension پیاده‌سازی خواهد شد، قصد دارد تا برخی از Opcode ها را در زمان اجرا مستقیما به دستورالعمل های CPU تبدیل کند.

این بدان معنی است که با JIT برخی از Opcode ها لازم نیست توسط ماشین مجازی Zend (Zend VM) تفسیر شوند و این دستورالعمل ها مستقیماً به عنوان دستورالعمل هایی در سطح CPU اجرا می شوند.

یکی از مهمترین ویژگی‌هایی که PHP 8 به ارمغان خواهد آورد، JIT یا Just In Time کامپایلر است. این روزها بسیاری از وبلاگ ها و انجمن ها دارند در مورد این ویژگی صحبت می‌کنند و کلی سر و صدا راه انداختند... اما من تا اینجا جزئیات بسیار کمی در مورد کاری که قراره JIT انجام بده، پیدا کردم. بعد از خوندن چندین مقاله و بالا و پایین های بسیار تصمیم گرفتم که سورس کد خود PHP رو بررسی کنم.

با اندک دانشی که من در مورد زبان برنامه نویسی C دارم و اطلاعات پراکنده ای که جمع کردم، تصمیم گرفتم این پست رو بنویسم و اطلاعاتم رو با شما به اشتراک بگذارم; به امید اینکه به درک این ویژگی جدید کمکی کرده باشم.

به شکل خیلی ساده: وقتی که JIT آنگونه که باید عمل کند، کد شما از طریق ماشین مجازی Zend اجرا نمیشه.. در عوض کد شما به عنوان یک سری دستور العمل ها، مستقیما در سطح CPU اجرا میشه.

و کل ماجرا همینه.

اما برای درک بهتر این موضوع، ابتدا نیاز داریم تا بدونیم PHP چطوری کار می‌کنه... موضوع پیچیده‌ای نیست اما نیاز به کمی مقدمه داره.



کد های PHP چگونه اجرا می‌شوند؟

همه ما می‌دونیم که PHP یک زبانی تفسیری (interpreted)‌ هست، اما این به چه معناست؟

هر وقت که شما قطعه کدی رو اجرا می‌کنید، چه یک کد کوتاه و ساده باشه.. و چه یک وب اپلیکیشن کامل.. این پروسه از طریق interpreter یا مفسر PHP صورت میگیره. که متداول ترین اونها PHP FPM و مفسر CLI هستند.

کار این مفسر ها بسیار ساده ست: کدهای PHP رو میگیرند.. تفسیر میکنند و نتایج رو بر می‌گردونند.

این پروسه به طور معمول برای تمام زبان های تفسیری اتفاق میفته. در بعضی از زبان ها ممکنه که برخی از این مراحل حذف بشه; اما در نهایت ایده و روال کلی کار همینه.

در PHP این پروسه به شکل زیر اتفاق میفته:

  1. کد شما خونده میشه و تبدیل میشه به یه سری کلیدواژه ها، که بهشون توکن (Token) میگیم. این فرایند به مفسر اجازه میده تا بدونه، کدوم قطعه کد در کدوم قسمت از برنامه قرار گرفته; که نام این مرحله Lexing یا Tokenizing هست.
  2. حالا که توکن‌ها رو داریم، مفسر PHP مجموعه توکن‌ها رو آنالیز میکنه و سعی میکنه اونها رو درک کنه. در نتیجه یک "Abstract Syntax Tree" یا یک AST در پروسه ای به نام Parsing به وجود میاد. این AST مجموعه ای از گِره (Node) هاست که مشخص میکنه چه عملیاتی باید اجرا بشه. برای مثال “echo 1 + 1” رو در نظر بگیرید. این دستور در واقع به این معناست که: “نتیجه ۱ + ۱ رو پرینت کن“... یا واقع بینانه تر: “یک عملیات رو پرینت کن، این عملیات ۱ + ۱ هست“
  3. حالا که AST رو داریم، درک عملیات ها و تقدم (precedence) اونها خیلی ساده‌تر میشه.. برای تبدیل این AST به کدی که قابل اجرا باشه، نیاز به یک واسطه یا Intermediate Representation (IR) داریم; که در PHP اون رو به نام Opcode می‌شناسیم... و پروسه تبدیل AST به Opcode رو کامپایل کردن (Compilation) میگیم.
  4. حالا که Opcode ها رو داریم، به قسمت جالب کار می‌رسیم: اجرای کد... PHP موتوری به نام Zend VM داره، که لیستی از Opcode ها رو دریافت و اونها رو اجرا میکنه. پس از اجرای تمام Opcode ها، موتور Zend VM به کارش پایان میده و مراحل اجرای برنامه ما به پایان میرسه.
ZendVM without Opcache
ZendVM without Opcache

همون طور که می‌بینید مراحل کار ساده ست، اما یک نکته وجود داره:

وقتی که کد های PHP ما اونقدر ها هم تغییر نمی‌کنند، پس چه فایده ای داره که ما هر بار مراحل Lexing و Parsing رو طی کنیم؟ در نهایت این Opcode ها هستند که برای ما مهم هستند...

و به همین خاطر هست که در PHP یک Opcache extension داریم.



اکستنشن Opcache

این اکستنشن به شکل پیش‌فرض در PHP موجوده و کارش اینه که: یک لایه کَش (cache) مشترک رو برای Opcode ها در حافظه اضافه میکنه; و در ادامه Opcode هایی رو که به تازگی از AST ساخته شدند رو کَش میکنه. پس اجراهای بعدی به راحتی میتونند مراحل Lexing و Parsing رو نادیده بگیرند و به مرحله اجرا برسند. پس با توجه به کارهایی که این اکستنشن انجام میده، به دیاگرام زیر میرسیم:

ZendVM with Opcache
ZendVM with Opcache

می‌بینید که این اکستنشن به زیبایی از چنگ مراحل Lexing ، Parsing و Compiling فرار میکنه.

نکته جانبی: اینجا جایی هست که ویژگی Preloading در نسخه ۷.۴ PHP ، شروع به درخشیدن می‌کنه. ? با استفاده از این ویژگی میتونیم به مفسر PHP FPM بگیم که:

  1. کدهای ما رو Parse کنه
  2. سپس اونها رو به Opcode ها تبدیل کنه
  3. و Opcode ها رو در کش ذخیره کنه

حتی قبل از اینکه هیچ کدی رو اجرا کنیم


تا اینجا عملکرد کلی PHP رو بررسی کردیم.. که چیز خیلی جدیدی نبوده و سالها به همین منوال ادامه داشته. شاید براتون سوال پیش اومده باشه که: پس JIT در کجای این تصویر قرار میگیره؟

امیدوارم با این سوال روبرو شده باشید.. چون علت اصلی که این مقاله رو می‌نویسم، همین هست.



کامپایلر JIT به شکل موثر چه کاری می‌کنه؟

اخیرا به پادکستی گوش میدادم که مصاحبه‌ای بود با زیو سوراسکی (Zeev Suraski). این شخص از توسعه دهندگان نسخه ۳ PHP بوده که بعدا در سال ۱۹۹۹ موتور Zend رو مینویسه (هسته نسخه ۴ PHP) و شرکت Zend Technologies رو تاسیس میکنه. نتیجه ای که من از این پادکست گرفتم به شرح زیره:

اکستنشن Opcache دستیابی به Opcode ها رو سریع‌تر می‌کنه.. که بتونند مستقیما به موتور Zend فرستاده بشند. حالا با استفاده از JIT، قراره که Opcode ها بدون موتور Zend و مستقیم اجرا بشند.

موتور Zend، برنامه ایه که به زبان C نوشته شده است و به عنوان لایه ای بین Opcode ها و خود CPU عمل می کنه. کاری که JIT انجام میده تولید کد کامپایل شده در زمان اجراست.. بنابراین PHP میتونه موتور Zend رو نادیده بگیره و مستقیما به سراغ CPU بره; این ویژگی از لحاظ تئوری باید با پرفرمنس خوبی همراه باشه.

برای کامپایل کردن کدهای ماشین، باید برای هر معماری یک سبک پیاده‌سازی خیلی خاص بنویسیم. برای پیاده سازی JIT در PHP از کتابخونه ای به نام "Dynamic Assembler" استفاده شده.. این کتابخونه مجموعه‌ای از دستورالعمل های CPU (که در یک فرمت خاص هستند)، رو به کدهای اسمبلی تبدیل میکنه که برای انواع مختلف CPU ها قابل استفاده هستند.

پس کامپایلر JIT با استفاده از DynASM در واقع Opcode ها رو به کدهای ماشین، برای یک معماری خاص تبدیل می‌کنه.


حالا این سوال پیش میاد:

اگر Preloading در PHP می‌تونه کدهای ما رو Parse و قبل از اجرا تبدیل به Opcode کنه....

و اگر داینامیک اسمبلر میتونه Opcode ها رو کامپایل و تبدیل به کدهای ماشین کنه....

چرا از همون ابتدا تمام کد هامون رو به شکل "Ahead of Time" کامپایل نکنیم؟


یکی از دلایلش میتونه این باشه که در PHP متغیر ها Weakly-typed هستند. هر چند که در نسخه های جدید میتونیم نوع متغیر ها رو مشخص کنیم، اما باز هم اگر نوعشون رو مشخص نکنیم: اتفاقی نمی‌افته و زمانی که موتور Zend بخواد یک سری Opcode ها رو اجرا کنه.. ابتدا نوع متغیر رو مشخص میکنه که این پروسه کمی کار رو پیچیده می‌کنه.

برای مثال: Zend VM Handler قراره که یک عبارت کوچکتر یا مساوی (=>)‌ رو مدیریت کنه. اما اگر کدهاش رو بررسی کنیم،‌ می‌بینیم که تنها برای حدس زدن نوع عملوند (operand) به شاخه های مختلفی تبدیل میشه.

پس تکرار چنین منطقی ... با کدهای ماشین میتونه خیلی جالب نباشه و عملا خیلی چیزها رو کندتر کنه و کامپایل کردن همه چیز بعد از ارزیابی انواع داده ها هم، گزینه خیلی خوبی نیست. چون کامپایل کردن کد ها به کدهای ماشین، یه تَسک CPU-Intensive هست.


کامپایلر JIT چگونه رفتار می‌کنه؟

حالا که می‌دونیم، نمیشه روی type ها برای یک کامپایل خوب از نوع ahead of time حساب کرد... و کامپایل کردن در زمان اجرا هم هزینه‌ های خودش رو داره... پس JIT قراره به چه شکل برای PHP مفید باشه؟

برای ایجاد تعادل در این معادله، JIT سعی میکنه تنها بعضی از Opcode ها ، که به نظرش منطقی میرسند رو کامپایل کنه. برای این کار Opcode هایی که توسط موتور Zend اجرا میشند رو بررسی میکنه و تصمیم میگیره که کامپایل کردن کدام Opcode ها، ممکنه منطقی باشه (بر اساس تنظیمات)

پس هنگامی که یک Opcode خاص کامپایل میشه، به جای محول کردن اون به موتور Zend، حالا Opcode کامپایل شده مستقیما اجرا میشه.. که دیاگرامش شبیه به زیر میشه:

ZendVM with Opcache and JIT
ZendVM with Opcache and JIT

در اکستنشن Opcache چند دستورالعمل وجود داره که تشخیص میده یک Opcode خاص باید کامپایل بشه یا خیر. اگر این Opcode باید کامپایل بشه، کامپایلر این Opcode رو با استفاده از DynASM به کدهای ماشین تبدیل می‌کنه و این کد ماشین تازه تولید شده رو اجرا می کنه.

نکته جالب اینجاست که: از اونجا که برای کدهای کامپایل شده محدودیتی در حجم (قابل تنظیم) وجود داره ، پس برای اجرای کدها، ما باید بتونیم به راحتی بین استفاده از JIT و حالت تفسیری همیشگی سوئیچ کنیم.



بهبود ها در پرفرمنس

امیدوارم در این نقطه براتون واضح باشه که چرا اکثرا می‌گند: بیشتر اپلیکیشن های PHP سود چندانی از استفاده کردن از Just In Time Compiler نمی‌برند. و چرا زیو سوراسکی پیشنهاد میده که امتحان کردن و پروفایل بندی تنظیمات مختلف JIT بهترین راه برای بهبود در پرفرمنس اپلیکیشن شماست.

وقتی از PHP FPM استفاده کنیم، Opcode های کامپایل شده در بین درخواست ها به اشتراک گذاشته میشند; اما این نکته به خودی خود، یک تغییر دهنده بازی نیست. چون JIT به شکل عمده عملیات های محدود به CPU رو بهینه می‌کنه.

امروزه بیشتر اپلیکیشن های PHP بیش از هر چیز دیگه، در محدوده I / O (input/output) فعال هستند. پس اگر قرار باشه از دیسک و یا شبکه استفاده کنیم، خیلی تفاوتی نداره که کدهامون کامپایل شده باشند یا نه; و زمان‌بندی ما در واقع تفاوت چندانی نمی‌کنه.

اما...

اگر کاری که با PHP انجام میدید، محدود به I / O نیست.. مثلا قصد دارید از پردازش تصویر استفاده کنید و یا مواردی مثل یادگیری ماشین رو امتحان کنید، مطمئنا JIT کامپایلر باعث بهبود های پرفرمنسی خیلی خوب، در کار شما خواهد شد. یعنی دقیقا در جایی که PHP همیشه ضعف داشته، حالا حرف‌هایی برای گفتن خواهد داشت.

همچنین اگر از جنبه دیگه‌ای به ماجرا نگاه کنیم: دیگه نوشتن توابع عادی در PHP با نوشتن توابع بومی PHP در زبان C تفاوت چندانی نخواهد داشت. چون نهایتا کدهای ما کامپایل میشند.