
با سلام به همه امروز میخواییم درباره ی ویژگی Specializing Adaptive Interpreter که در پایتون نسخه 3.11 اضافه شده و در نسخه ی 3.12 بهینه تر شده کمی صحبت کنیم. Core Developer های پایتون از نسخه 3.10 شروع به بهینه سازی هایی در مفسر پایتون کردن و این بهینه سازی ها از افزایش سرعت در نسخه 3.10 نسبت به نسخه 3.9 شروع شد. بر طبق اسناد رسمی پایتون، نسخه پایتون 3.11 بین 10 الی 60 درصد سریعتر از نسخه 3.10 میباشد. خب قبل از اینکه به بحث اصلی بپردازیم بهتره که ابتدا با چند مفهوم شروع کنیم که مربوط به همین موضوع میشود.
مفسر پایتون مانند خیلی از زبان های برنامه نویسی دیگر، کد های نوشته شده به این زبان رو مستقیما به زبان ماشین ترجمه نمیکنه. پایتون در واقع کد نوشته شده رو به تبدیل به بایت کد میکنه. در بعضی از زبان های استاتیک تایپ و کامپایلری مثل جاوا، که کد های این زبان هم به بایت کد تبدیل میشه، کامپایل دستورات قبل از اجرا شدن آن صورت میگیرد. در مقابل، پایتون، کامپایل رو در زمان اجرا و زمانی که ماژول برای اولین بار load میشه انجام میده. خب قبل از اینکه بریم سراغ اینکه ببینیم بایت کد واقعا چیه بذارید یک قدم به عقب تر برگردیم و ببینیم کد ماشین چیه؟
کد ماشین در واقع کدی است که توسط تراشه یا پردازنده در کامپیوتر اجرا میشه. پردازنده کدهای خاصی رو برای یکسری اقدامات مختلف تعریف میکنه و زمانی که آن کدها به پردازنده ارائه میشه، عمل مربوطه توسط پردازنده انجام میشه. در کامپایل سنتی، برای مثال کامپایل یک برنامه ی نوشته شده به زبان C، کامپایلر، کد های نوشته شده رو دریافت میکنه و به instruction ها یا دستورالعمل های قابل اجرا توسط پردازنده تبدیل میکنه که کد ماشین نامیده میشه. یک مشکلی که در کامپایل سنتی وجود دارد این است که شما باید برای هر پردازنده یا سخت افزاری که قصد دارید کد را روی آن اجرا کنید، برنامه را به طور جداگانه کامپایل کنید! زیرا هر کدام ممکن است یکسری از دستورالعمل های کد ماشین را تعریف کنند که با یکدیگر همخوانی نداشته باشند و متفاوت از یکدیگر باشند. متخصصان و طراحان زبان برای حل این مشکل مفهوم بایت کد را ارائه دادند.
بایت کد ها، مجموعه ای از دستورالعمل های مستقل از سخت افزار هستند که سطح بالاتری از کد ماشین دارند. در این روش، برنامه ی شما به بایت کد کامپایل میشود و این بایت کد ها توسط یک برنامه ی دیگر (ماشین مجازی) تبدیل به کد ماشین شده و به موازات آن باعث اجرا شدن برنامه میشود. از آنجایی که بایت کد مستقل از سخت افزار است، کد را می توان روی هر سخت افزاری اجرا کرد. برای مثال زبانی مانند جاوا با استفاده از ماشین مجازی مخصوص خود به اسم jvm این کار را انجام میدهد. همچنین پایتون، با استفاده از ماشین مجازی خود به اسم pvm بایت کد ها را اجرا میکند.
ما در پایتون با استفاده از پکیج dis، قادر به دیدن بایت کد های تولید شده توسط مفسر پایتون هستیم.
نکته: نسخه پایتون استفاده شده در این مثال، 3.11 است.
بیایید یک نمونه رو باهم ببینیم. ابتدا یک فایل با نام دلخواه بسازید در این مثال من اسم فایل رو util.py قرار میدم. سپس، کد زیر رو داخل همون فایل بنویسید :
def add(a, b): return a + b
حالا یک فایل دیگه بسازید با نام test.py و کد زیر رو داخلش قرار بدید :
from util import add print(add(3, 5))
حالا فایل test.py رو در داخل ترمینال با دستور زیر اجرا کنید :
> python test.py
بعد از اجرای این دستور یک folder در همان directory ای که این دو فایل رو ساختید با نام __pycache__ ایجاد خواهد شد که شامل یک یا چند فایل با پسوند pyc. می باشد که در این مثال، یک فایل با نام util.cpython-311.pyc میباشد. این فایل pyc. که مخفف python-compiled است حاصل خروجی فرآیند کامپایل است. این فایل شامل بایت کد های تولید شده حاصل از کامپایل util.py است. حالا کاربرد این فایل چیه؟ دفعه ی بعد که ما دوباره test.py را اجرا کنیم، فایل util.py مجددا کامپایل نمی شود. به جای کامپایل مجدد، بایت کد را مستقیماً از فایل util.cpython-311.pyc اجرا می کند.
شما میتوانید فایل pyc. را باز کنید اما به احتمال زیاد محتوای آن قابل خواندن نیست و فقط با یکسری از کاراکتر های نامفهوم مواجه خواهید شد. خب راه حل چیه؟ استفاده از پکیج dis!
نکته 1: دقت داشته باشید که در پایتون وقتی ما یک ماژول رو مستقیما اجرا میکنیم، دیگه code object اون ماژول در file system ذخیره نمیشه! فایل pyc. زمانی ایجاد میشه که ما یک ماژول رو import میکنیم. تمام فایل های pyc. در __pycache__ ذخیره میشوند.
نکته 2: برای اینکه code object در file system ذخیره بشه نیاز داره که serialization بشه که این serialization توسط یک ماژول موجود در standard library پایتون به اسم marshal انجام میشه! پس میتونیم نتیجه بگیریم که فایل های pyc. در واقع code object های serialize شده توسط marshal هستند.
نکته 3: مفهوم code object رو در ادامه توضیح شده است.
بسیار خب حالا، در همان فایل test.py کد زیر را جایگزین کد فعلی کنید :
import dis from util import add dis.dis(add, adaptive=True)
در واقع کاری که پکیج dis انجام میده این است که بایت کد های تولید شده رو به دستورالعمل یا instruction های قابل خواندن برای انسان تبدیل میکنه و بهتره بگیم اصطلاحا disassemble شون میکنه.
بعد از اجرای فایل test.py با خروجی زیر مواجه خواهید شد :
1 0 RESUME 0 2 2 LOAD_FAST 0 (a) 4 LOAD_FAST 1 (b) 6 BINARY_OP 0 (+) 10 RETURN_VALUE
همینطور که مشاهده میکنید چهار بایت کد برای تابع add ما تولید شده است. این بایت کد شامل 5 ستون است. ستون اول اشاره به شماره خط کد اصلی نوشته شده در فایل ما دارد. ستون دوم مکان دستور در کد پایتون را به صورت مکان فیزیکی در بایت کد نشان میدهد. ستون سوم یا میانی نام بایت کد ها را نشان می دهد. دو ستون آخر جزئیاتی را در مورد پارامتر ها (parameters)، در صورت وجود پارامتر، ارائه می دهد.
قبل از اینکه ادامه بدیم، ببینیم بایت کد های موجود در ستون سوم چی هستن :
نکته مهم: دقت کنید، بایت کدی که در اینجا میبینید همان بایت کدی نیست که توسط مفسر اجرا میشود! این بایت کد ها صرفا یک نمایش human-readable از بایت کد اصلی برای ما است.
خب قبل از اینکه درباره ی دو ستون آخر جزئیات بیشتری بگم، بهتره یه توضیح خیلی کوتاه در مورد code object در پایتون بدم.
در پایتون، یک code object یک شیء است که نمایانگر کد کامپایل شده میباشد. این object، یک internal object یا بهتره بگیم یک executable unit است که دستورات بایت کد، ثابتها، نامها و سایر اطلاعات مربوط به کد را ذخیره میکند.
تفاوت code object و bytecode چیست؟
بایت کد مجموعه ای از دستورالعمل های ایجاد شده توسط کامپایلر داخلی پایتون است که توسط ماشین مجازی داخلی اجرا می شود و در واقع یکی از attribute های code object است. ما میتوانیم برای دسترسی به code object یک تابع از attribute عه __code__ اون تابع استفاده کنیم. برای مثال، برای دیدن بایت کد اصلی تابع add ما میتونیم از کد زیر استفاده کنیم :
print(add.__code__.co_code)
خروجی کد بالا چیزی مشابه با خروجی زیر است :
b'\x97\x00|\x00|\x01z\x00\x00\x00S\x00'
بسیار خب حالا بریم سراغ دو ستون آخر بایت کد تا ببینیم برای نمایش چه چیزی هستند. ستون چهارم، شماره ی index آن پارامتر را در attribute های مربوط به اون پارامتر ها در code object را نشان میدهد. برای مثال شماره 0 برای پارامتر a نشان دهنده ی شماره ی این پارامتر در attribute عه co_varnames است.
این attribute اسامی پارامتر هارا در قالب یک tuple به ما نشان میدهد :
print(add.__code__.co_varnames)
خروجی :
('a', 'b')
نکته: لزوما همیشه در ستون چهارم شماره index پارامتر های تابع قرار نمیگیرد و ممکن است معنی های مختلفی داشته باشند. در مثالی که زده شد، عددی که در مقابل بایت کد BINARY_OP قرار دارد در واقع مشخص کننده ی نوع عملیات میباشد و برابر با 0 که نشان هنده ی عملیات جمع است می باشد. اگر در بخش return تابع add به جای + از / استفاده کنید میبینید که این عدد به 11 تغییر میکند که نشان دهنده ی عملیات تقسیم با اعشار میباشد. در واقع این اعداد اشاره به argument های همون instruction دارند.
در نهایت، در ستون پنجم، dis بر اساس index ها و اعداد ستون چهارم، ثابت ها (const variables)، متغیر ها و سایر مقادیری را در که ستون چهارم مشخص کرده است، جستجو کرده و به ما می گوید که در ستون پنجم چه چیزی پیدا کرده است.
خب بریم سراغ بحث اصلی یعنی Specializing Adaptive Interpreter و اینکه ببینیم چه کاری برای ما انجام میده. بایت کد BINARY_OP (که نمایانگر عملیات باینری است) به عنوان یک generic bytecode instruction شناخته می شود. تابع add ای که در مثال قبل نوشتیم فقط برای جمع دو عدد نیست. ما برای جمع کردن دو string هم میتوانیم از این تابع استفاده کنیم و پایتون هیچ گونه خطایی از این بابت به ما نمیدهد چون string ها هم از عملیات جمع پشتیبانی میکنند.
برای مثال:
print(add('abc', 'xyz'))
بنابراین BINARY_OP با هر نوع داده کار می کند و محاسبات لازم به هر داده را انجام می دهد.
حالا بیایید این کد را اجرا کنیم :
for _ in range(10): add(3, 5) dis.dis(add, adaptive=True)
ما در این کد 10 بار تابع add رو با argument های 3 و 5 صدا زدیم.
حالا بیایید بایت کدش رو ببینیم :
4 0 RESUME_QUICK 0 5 2 LOAD_FAST__LOAD_FAST 0 (a) 4 LOAD_FAST 1 (b) 6 BINARY_OP_ADD_INT 0 (+) 10 RETURN_VALUE
در این بایت کد جدید، ما میبینیم که LOAD_FAST با LOAD_FAST__LOAD_FAST و BINARY_OP با BINARY_OP_ADD_INT جایگزین شده است. در حقیقت بایت کد LOAD_FAST__LOAD_FAST یک نسخه ی بهینه شده برای load کرن دو آرگومان a و b است. پایتون با این کار میاد در همون بایت کد اول آرگومان های a و b رو load میکنه و دیگه برای هر آرگومان بایت کد LOAD_FAST رو ایجاد نمیکنه!
نکته: دلیل اینکه در خط بعد از LOAD_FAST__LOAD_FAST بازم برای آرگومان b یک بایت کد تولید کرده این است که این بایت کد از قبل از بهینه شدن تولید شده و پایتون دیگه نمیاد اون رو بازنویسی کنه چون مفسر پایتون از همون بایت کد بهینه شده اول (LOAD_FAST__LOAD_FAST) برای load کردن هر دو آرگومان استفاده میکنه و نیازی به استفاده از بایت کد دوم نداره و اون رو skip میکنه. وقتی تو بایت کد اول هر دو آرگومان رو load میکنه و میره سراغ بایت کد دوم میبینه که خب این بایت کد تو بایت کد قبلی load شده و نیاز نداره ازش استفاده کنه یا بهینه اش کنه پس ازش رد میشه.
بایت کد BINARY_OP_ADD_INT یک نسخه بهینه شده از عملیات جمع (+) برای انجام عملیات جمع بین دو عدد صحیح با هم است، که بسیاری از بررسیهایی را که معمولا انجام میشوند را دور میزند، به عنوان مثال بررسی برای اینکه این دو دیتا تایپ از عملیات جمع پشتیبانی میکنند یا نه، بررسی و جستجوی متد __add__ در این دو آرگومان، برابر بودن تایپ این دو آرگومان و غیره. در این بایت کد، بایت کد BINARY_OP_ADD_INT یک نمونه از specialized bytecode instruction میباشد که برای عملیات جمع (+) دو عدد صحیح بهینه سازی شده است. اتفاقی که در اینجا افتاده این است که پایتون متوجه شده است که ما مکرراً عملیات جمع (+) را بین دو عدد صحیح انجام میدهیم و بنابراین (در زمان اجرا) بایت کد را تغییر داده و آن را اختصاصی یا به اصطلاح specialized میکند. پایتون در دفعات بعد بایت کد بهینه شده را اجرا می کند و به این شکل کد سریعتر اجرا می شود. این دقیقا کاری است که Specializing Adaptive Interpreter انجام میدهد. مفسر اجرای بایت کد را تجزیه و تحلیل می کند. اگر الگوهای خاصی را ببیند که می توانند بهینه شوند، بایت کد مربوط به اون الگو را در زمان اجرا به نسخه اختصاصی شده (specialized version) تغییر می دهد. پایتون زمانی عمل specializing را انجام میدهد که کد << Hot >> (کد هایی که چندین بار اجرا شدند) ببیند. این ویژگی باعث میشود که پایتون زمان خود صرف اجرای یک کد با عملیات ثابت نکند و به جای آن، آن کد را specialize میکند و از نسخه ی بهنیه شده ی آن استفاده میکند.
نکته: دفعات اجرای پایه برای بهینه سازی 8 بار میباشد. یعنی اگر یک تابع بدون هیچ تغییری با مقادیر ثابت 8 بار یا بیشتر اجرا شود در صورت امکان، پایتون شروع به بهینه سازی اون تابع میکند. (ممکن است این عدد در نسخه های بعد بهینه شده یا شده باشد.)
خب چی میشه اگه ما آرگومان های تابع add رو که از نوع integer هستند با string جایگزین کنیم!؟ بیایید امتحانش کنیم:
# after loop add('abc', 'xyz') dis.dis(add, adaptive=True)
خروجی رو ببینیم :
23 0 RESUME_QUICK 0 24 2 LOAD_FAST__LOAD_FAST 0 (a) 4 LOAD_FAST 1 (b) 6 BINARY_OP_ADD_INT 0 (+) 10 RETURN_VALUE
خب در خروجی جدید میبینیم که بایت کد فرقی نکرده و همچنان برای جمع دو عدد بایت کد تولید میکنه. دلیل این رفتار پایتون همین Specializing Adaptive Interpreter هست. پایتون وقتی برای چندین بار تابع add رو با اعداد صحیح جمع میکند اون تابع رو برای دفعات بعدی بهینه سازی میکند (برای همین عملیات جمع بین دو عدد integer) و وقتی ما بهش به جای integer به عنوان آرگومان های ورودی، string ارسال میکنیم پایتون چون انتظار همون آرگومان های integer رو از ما داشت، بایت کد بهینه شده رو به حالت عمومیش (generic version) بر میگردونه (BINARY_OP) و از اون برای انجام عملیات جمع (+) بین این دو string استفاده میکنه ولی بایت کد فعلیش (specialized bytecode) رو حفظ میکنه چون این وضعیت (جمع دو string) رو به عنوان یک استثناء در نظر میگیره و اینطور پیش بینی میکنه که این وضعیت، یک وضعیت موقت هست و تابع add ممکنه باز هم در آینده با دو عدد integer اجرا بشه.
حالا اگه ما بخواییم 100 بار از این تابع برای جمع دو string استفاده کنیم چی میشه!؟
for _ in range(100): add('abc', 'xyz') dis.dis(add, adaptive=True)
خروجی رو ببینیم :
23 0 RESUME_QUICK 0 24 2 LOAD_FAST__LOAD_FAST 0 (a) 4 LOAD_FAST 1 (b) 6 BINARY_OP_ADD_UNICODE 0 (+) 10 RETURN_VALUE
حالا در این بایت کد میبینیم که مفسر پایتون بایت کد BINARY_OP_ADD_INT رو به BINARY_OP_ADD_UNICODE تغییر داده و اون رو برای جمع دو رشته بهینه سازی کرده است چون در این حالت پایتون مطمئن شده که دیگر آرگومان های ورودی integer نیستن و به string تغییر کردند پس بایت کد باید برای جمع دو string بهینه بشه. پس فهمیدیم که specialized adaptive interpreter به دنبال الگوها قابل بهینه سازی میگردد و بایت کد را بهینه سازی میکند تا در اجرا های آینده بتوانند به شکلی بهینه اجرا شوند. بایت کد های مختلفی مانند BINARY_OP وجود دارند که 'Adaptive Instructions' نامیده می شوند و می توانند در زمان اجرا بهینه سازی یا اختصاصی شوند.
نکته مهم: هنگام استفاده از dis، برای دیدن کد specialize شده حتما باید پارامتر adaptive رو برابر با True قرار بدید تا بایت کد بیهنه شده نمایش داده بشه!
بسیار خب امیدوارم این توضیحات براتون مفید بوده باشه.
برای مطالعه ی بیشتر این لینک ها مفید خواهند بود :
PEP 659: Specializing Adaptive Interpreter
Python 3.11 What's New: Specializing-adaptive-interpreter