معمولا یکی از پر هزینه ترین کدهای دستوری که پردازشگر باید آنها را اجرا کند، به طور قطع حلقه ها هستند که بیشترین کلاک را مصرف میکنند بنابراین یکی از مهم ترین بهینه سازی هایی که میتوان در حلقه ها انجام داد رفع زنجیره وابستگی ها بین هر دستور می باشد.
همانطور که می دانیم پردازشگر های جدید امروزی از تکنیکی به نام piping جهت اجرای دستورات استفاده میکنند یعنی هر چهار عمل واکشی و دیکد و اجرا و نوشتن (fetch , decode , execute , write) را می توانند برای چند دستور به شرط اینکه زنجیره ای از وابستگی ها نداشته باشند را در هر کلاک همزمان باهم اجرا نماید، البته بحث راجع به تکنیک های پردازشگر از حوصله این مطلب خارج هست برای اطلاعات دقیق تر می توانید به مستندات سازنده پردازنده مورد نظر خودتون مراجعه کنید.
بنابراین با توضیح کوتاهی که عرض کردم میخواهیم یک نمونه بسیار کاربردی از این موضوع زنجیره وابستگی ها را بیان کنم و همچنین به رفع این موضوع هم با استفاده از بهینه سازی های کامپایلر وهم با استفاده از نوشتن کدهای بهینه تر بپردازم.
تصور کنید که چنین حلقه ای را در زبان سی پلاس پلاس داشته باشیم...

double sumDouble = 0.0;
double darray[100];
for (int i = 0; i< 100; i++) {
sumDouble += darray[i];
}
std::cout << sumDouble << '\n';


همانطور که مشخص هست در این حلقه یک زنجیره ای از وابستگی ها رخ خواهد داد، در هربار اضافه کردن مقدار قبلی آرایه در مقدار قبلی متغیر اتوماتیک، اگر نمونه کد حلقه اشاره شده را در کامپایلر های GCC نسخه 32 بیتی قبل از سال 2010 در لینوکس با سطح بهینه سازی O3 کامپایل کنیم خروجی کد ما به صورت زیر خواهد بود...

0x08049030 <main()+16>: fldz
0x08049032 <main()+18>: lea 0x18(%esp),%edx
0x08049036 <main()+22>: xchg %ax,%ax
for(int i=0; i< 100 ;i++){
0x0804903b <main()+27>: add $0x1,%eax
0x0804903e <main()+30>: cmp $0x64,%eax
0x08049041 <main()+33>: jne 0x8049038 <main()+24>
sumDouble += darray[i];
0x08049038 <main()+24>: faddl (%edx,%eax,8)



بنابراین همانطور که مشاهده میشود، کامپایلر چون از رجیستر های سری SSE استفاده نکرده است کدی را که تولید کرده است به شدت زنجیره ای از وابستگی بین دستورات وجود دارد وهمینطور حلقه ما همچنان تعداد 100 بار اجرا خواهد شد، بنابراین پردازشگر زمانی را باید صرف برگشت جواب از دستورات قبلی تلف نماید، حالا تصور کنید که این حلقه بخواهد چند میلیون بار اجرا بشود و درست در همین نقطه هست که پردازشگر باید هزینه زیادی برای محاسبه و اجرای کد فوق لحاظ کند.

توجه : به علت اینکه از سطح بسیار بالایی از بهینه سازی ها در کامپایلر استفاده کرده ایم کدهای اسمبلی به شدت ناخوانا می شوند، بنابراین لطفا به این نکته توجه داشته باشید که در کد اسمبلی اون آدرس jump قطعا به ناکجا آباد نخواهد بود. با اینکه کد بسیار زشت دیده میشود ولی معمولا چاره ای نیست وباید به این روش از کد گذاری اسمبلی در زمان دیباگ عادت کرد.

و اگر همین حلقه را با کامپایلر های MSVC++‎ نسخه 32 بیتی و همچنین اگر از toolset های قبل از 2012 مانند toolset2010 و اونهم با فلگ full optimization کامپایل کنیم، بنابراین چنین کد اسمبلی را خواهیم داشت...


for (int i = 0; i< 100; i++) {
012721CA 33 C0 xor eax,eax
012721CC EB 04 jmp main+22h (012721D2h)
012721CE 8B FF mov edi,edi
012721D0 DD D8 fstp st(0)
sumDouble = darray[i];
012721D2 DD 44 C4 60 fld qword ptr [esp+eax*8+60h]
012721D6 83 C0 0A add eax,0Ah
sumDouble = darray[i];
012721D9 83 F8 64 cmp eax,64h
012721DC 7C F2 jl main+20h (012721D0h)


همانطور که مشاهده میکنید وقتی از toolset2010 برای کامپایل استفاده کردیم خروجی کامپایلر مایکروسافت کمی بهتر از خروجی کامپایلر gcc می باشد، بطوریکه همانطور که مشخص هست، با اینکه کامپایلر بازهم از رجیسترهای سری SSE استفاده نکرده است ولی تعداد اجرای دستورات حلقه را تقسیم بر 10 کرده است بنابراین حلقه فوق فقط 10 بار اجرا خواهد شد، ولی بازهم پردازشگر زمانی را برای برگشت محاسبات رجیسترهای وابسته تلف خواهد کرد.
در خیلی از پروژه ها نمیتوان به سادگی کامپایلر را تغییر داد یا حتی toolset های کامپایل را تغییر داد چه برسد به تغییر سکوی سیستم به همین علت معمولا تغییرات بهینه سازی در سطح کد باید انجام شود بنابراین مثلا شاید شما سیستمی که توسعه میدهید در لینوکس باشد و برای اینکه بتوانیم از یک سطح بهینه سازی خوبی برای کد فوق بهره ببریم میتوان کد را به شکل زیر تغییر داد...


double sumDouble =0.0;
double darray[100];
for(int i=0; i< 100 ;i+=4){
sumDouble += darray[i];
sumDouble += darray[i+1];
sumDouble += darray[i+2];
sumDouble += darray[i+3];
}
std::cout << sumDouble << '\n';


همانطور که از کد تغییر یافته مشخص هست، با استفاده از کم کردن تعداد وابستگی ها در زمان جمع کردن مقادیر تعداد دفعات اجرای حلقه را به 25 تکرار تقلیل دادیم بنابراین کامپایلر هم برای ما چنین کدی تولید خواهد کرد...

0x08049039 <main()+25>: fldz
0x0804903b <main()+27>: nop
0x0804903c <main()+28>: lea 0x0(%esi,%eiz,1),%esi
for(int i=0; i< 100 ;i+=4){
0x0804904e <main()+46>: cmp %edx,%eax
0x08049050 <main()+48>: jne 0x8049040 <main()+32>
sumDouble += darray[i];
0x08049040 <main()+32>: faddl (%eax)
sumDouble += darray[i+1];
0x08049042 <main()+34>: faddl 0x8(%eax)
sumDouble += darray[i+2];
0x08049045 <main()+37>: faddl 0x10(%eax)
sumDouble += darray[i+3];
0x08049048 <main()+40>: faddl 0x18(%eax)
0x0804904b <main()+43>: add $0x20,%eax


که به مراتب کد سریعتری از کد خروجی حلقه اول که در اول آموزش ذکر شد خواهد بود.
ولی اگر همان حلقه مثال اول را با همان شرایط در کامپایلر MSVC++‎ با toolset2012 به بعد کامپایل نماییم خروجی کد اسمبلی حلقه به شکل زیر خواهد بود...


for (int i = 0; i< 100; i++) {
00D635FA 33 C0 xor eax,eax
00D635FC 8D 64 24 00 lea esp,[esp]
sumDouble = darray[i];
00D63600 F2 0F 10 44 C4 60 movsd xmm0,mmword ptr [esp+eax*8+60h]
00D63606 83 C0 0A add eax,0Ah
00D63609 83 F8 64 cmp eax,64h
00D6360C 7C F2 jl main+20h (0D63600h)


همانطور که مشخص هست کد فوق به مراتب سریعتر خواهد بود به این علت که کامپایلر چون از سری دستورات SSE استفاده کرده است بنابراین توانسته در هر بار اجرای حلقه 96 بایت را محاسبه و ذخیره کند که بدین صورت فقط با 10 بار پیمایش حلقه کل آرایه ما محاسبه خواهد شد، که این مهم یعنی اجرای برنامه با بازدهی بالایی رو به رو خواهد بود، البته توجه داشته باشید که تعداد بایت کد ماشینی که در این کد اجرا میشود 14 بایت می باشد که با تعداد بایت کدهای حلقه های قبلی یکسان می باشد بنابراین توجه داشته باشید که در کد برنامه فوق ما با استفاده ازتکنیک آدرس دهی بیشتر به لطف سری رجیسترهای SSE برنامه را بهینه کرده ایم نه با تعداد بایت کمتر دستورات (قابل ذکر هست که معمولا دستورات با طول کمتر برای اجرا در پردازشگر هزینه کمتری دارند.)
حتی اگر بازهم این بهینه سازی مد نظرتون نباشه میتوانید به صورت مستقیم از کدهای اسمبلی استفاده کنید و بجای آدرس دهی 96 بایت از دو الی 3 دستور آدرس دهی 96 بایتی استفاده کنید در هربار اجرای حلقه بنابراین فقط در 6 بار تکرار حلقه میتوان محاسبه را انجام داد بنابراین اگر تعداد آرایه را 1 میلیون در نظر بگیرید با استفاده از کدهای بهینه فقط با 6 میلیون یا 10 میلیون آدرس دهی موفق به محاسبه خواهید شد که این یعنی تا 90 الی 95 درصد سریعتر از کد حلقه اول برنامه اجرا خواهد شد. (و قابل ذکر هست که از toolset2015 ها ویا حتی جدیدترهم می توانید جهت بهینه سازی های بیشتر استفاده کنید)
لازم هست که یاد آور شوم که استفاده از این سطوح از بهینه سازی ها در سطح کامپایلر خیلی از مواقع به راحتی امکانپذیر نمی باشد مخصوصا اگر یک سیستم نرم افزاری پیاده سازی و اجرا شده باشد ولی ریفکتور کردن کدها در همین سطوح ساده در برخی از مواقع بسیار کاربردی خواهد بود.
در پایان اشاره به این نکته که عمیق شدن در بحث ریفکتورینگ کدها در نرم افزارهای پیاده سازی شده و همچنین استفاده از تکنیک های بهینه سازی سطوح مختلف کامپایلر گاهی می تواند یک نرم افزار را چنان با سخت افزارهای جدید همگام نماید که از سقوط سیستم جلوگیری نماید، البته توجه داشته باشید که رفرنس معتبری جهت ریفکتور کردن بهینه کدها در هر زبانی به صورت مجزا و کامل وجود ندارد بهترین روش استفاده از تجربیات معماران نرم افزار حرفه ای تر و همچنین تسلط به معماری کامپیوتر و همچنین سعی وخطا های بسیار بدست خواهد آمد.

امیدوارم مفید فایده واقع شود.