چگونه سخت افزار، زبان هاي برنامه نويسي را شکل خواهد داد

تا همين يک دهه ي قبل، زبان هاي برنامه نويسي منجر به ايجاد نوآوري و طراحي سخت افزار مي شدند و ويژگي هاي جديد، به چيپ ها اضافه مي شد تا بکارگيري امکانات زبان هاي برنامه نويسي ساده تر شود. اين فرآيند اخيراً برعکس شده است. ما در اين مقاله، به بررسي چالش هايي که پردازنده هاي جديد، پيش روي طراحان زبان هاي برنامه نويسي مي گشايند، خواهيم پرداخت. طي يک دهه ي گذشته، ما شاهد تغييرات عمده اي در چگونگي طراحي پردازنده ها بوده
دوشنبه، 4 ارديبهشت 1391
تخمین زمان مطالعه:
موارد بیشتر برای شما
چگونه سخت افزار، زبان هاي برنامه نويسي را شکل خواهد داد

چگونه سخت افزار، زبان هاي برنامه نويسي را شکل خواهد داد
چگونه سخت افزار، زبان هاي برنامه نويسي را شکل خواهد داد


 






 
تا همين يک دهه ي قبل، زبان هاي برنامه نويسي منجر به ايجاد نوآوري و طراحي سخت افزار مي شدند و ويژگي هاي جديد، به چيپ ها اضافه مي شد تا بکارگيري امکانات زبان هاي برنامه نويسي ساده تر شود. اين فرآيند اخيراً برعکس شده است. ما در اين مقاله، به بررسي چالش هايي که پردازنده هاي جديد، پيش روي طراحان زبان هاي برنامه نويسي مي گشايند، خواهيم پرداخت. طي يک دهه ي گذشته، ما شاهد تغييرات عمده اي در چگونگي طراحي پردازنده ها بوده ايم. چيپ هاي اوليه براي زبان هاي برنامه نويسي خاصي طراحي مي شدند. به عنوان مثال Berkley RISC، براي اجراي کد C که در آن دوران بيش از برنامه هاي تيپيکال اسمبلي داراي دستورالعمل بود، طراحي شده بود. و بديهي است که ماشين هاي Lisp نيز براي Lisp طراحي شده بودند. چيزهايي که به مجموعه دستورالعمل ها اضافه شده بود، به خاطر به کارگيري ساده تر برخي ويژگي هاي زبان هاي برنامه نويسي بود. دستورالعمل هاي CALL، استفاده از زبان هاي برنامه نويسي رويه اي را ساده تر مي کرد. عمليات مميز شناور، پشتيباني از داده هاي مميز شناور را که در اکثر زبان هاي برنامه نويسي وجود داشت، به امري بديهي تبديل مي کرد.
اين يک راه بسيار ساده براي به کارگيري ترانزيستورها محسوب مي شد. چيپ هاي جديدتر حرکت خود را به سمتي کمي متفاوت تر آغاز کردند و به جاي زبان هاي جديدتر، ويژگي هايي را براي الگوريتم هاي مشخص فراهم نمودند. يونيت هاي Vector در بين اولين توسعه هايي که به اين طريق ايجاد شدند، قرار داشتند. آن ها به طور خاص براي ساده تر کردن اجراي برنامه هاي کاربردي چندرسانه اي ويژه افزوده شدند.
براي اولين بار از دهه ي 1980 ميلادي، نويسندگان کامپايلر، ديگر هدف اصلي اضافات مجموعه دستورالعمل ها نبودند بلکه به وجود آورندگان کتاب خانه ها، هدف اصلي محسوب مي شدند. شما مي توانيد نمونه ي واقعي چنين رويکردي را در SSE مشاهده کنيد. کامپايلرهاي مدرن x86 به جاي استفاده از کمک پردازنده براي انجام محاسبات مميز شناور، از دستورالعمل SSE استفاده مي کنند؛ زيرا هدف گرفتن SSE ساده تر است. با استفاده از SSE شما با همان هزينه ي اجرا براي يک عملوند مميز شناور، (معمولاً) يک عمل را بر روي 4 مقدار 32 بيتي يا دو مقدار 64 بيتي اجرا مي کنيد. متأسفانه، کد کامپايل شده ي اپتيکال، فقط بين يک چهارم تا يک دوم حداکثر خروجي مميز شناور پردازنده را دريافت مي کند. تکنيک هاي autovectorization مختلفي سعي کرده اند عمل ها را در يک گروه قرار دهند تا اين عمل ها بتوانند به طور موازي اجرا شوند اما اينکار، تقريباً دشوار است و کامپايلرها در انجام آن، زياد خوب عمل نمي کنند. استفاده ي سودمند از اين يونيت ها، به کوشش هاي برنامه نويسي زيادي نياز دارد. شما نمي توانيد فقط يک برنامه ي C خاص را بگيريد و تمام عمليات مميز شناور را بر روي يک يونيت Vector انجام دهيد؛ اما مي توانيد C را براي استفاده از Vector Typeها توسعه دهيد.
برخورداري از يک کمک پردازنده با تعداد ترانزيستور بيش تر، از پردازنده ي اصلي براي يک کامپيوتر مدرن، موضوعي عجيب و غيرعادي به شمار نمي رود. اسم اين کمک پردازنده، (Graphics Processing Unit) GPU يا پردازنده ي گرافيکي است و به جاي اين که براي زبان هاي برنامه نويسي خاصي طراحي شده باشد، براي اجراي الگوريتم هاي خاص به وجود آمده است. البته، به طور خاص چندين زبان برنامه نويسي ويژه براي اجرا بر روي اين کمک پردازنده ها طراحي شده اند: Cg, GLSL, HLSL و غيره از اين جمله اند. از برخي جهات اين موقعيت، نشان دهنده ي يک پسرفت در محاسبات کامپيوتري محسوب مي شود. تعهد زبان هاي برنامه نويسي سطح بالا اين بود که آن ها به شما اجازه مي دهند جزئيات زيرساختار بنيادين را ناديده بگيريد. متاسفانه، اين تعهد کاملاً برآورده نشد. زبان هايي مثل C، که اين روزها به سختي به عنوان يک زبان "سطح بالا" شناخته مي شود، جزئيات بيش از حدي را در مورد انواع خاصي از زيرساختار به نمايش مي گذارند در حالي که ساير زبان ها سيستم را آنچنان به خوبي مخفي مي کنند که حتي چيزهاي ساده نيز پر هزينه مي شوند.

مانع زبان
 

وقتي من براي اولين بار زبان C را فراگرفتم، اين زبان يک زبان برنامه نويسي سطح بالا محسوب مي شد؛ زيرا سيار بود. در مقابل، PL/M، زباني که من قبل از C فراگرفتم به دليل اينکه ويژگي هاي مختص پردازنده مثل سگمنت هاي حافظه را مستقيماً در معرض ديد برنامه نويسي مي گذاشت، يک زبان سطح پائين به شمار مي رفت. اين روزها معمولاً از C به عنوان يک زبان سطح پائين ياد مي شود.
اين تفاوت ها واقعاً چه معنايي دارند؟ از نظر واژه شناسي، در يک زبان سطح پائين، زبان به سادگي نقشه را برمبناي رشته ها يا توالي هاي دستورات بنا مي کند. يک کامپايلر بهينه کننده ي C، ترتيب دستوالعمل ها را از نو مي چيند، جايگزيني ها را انجام مي دهد و غيره اما هريک از ويژگي هاي اصلي زبان بر روي يک ساختار تيپيکال فقط مي توانند در چند دستورالعمل اندک به کار گرفته شوند. C فقط از عمليات بر روي انواع ابتدايي که نوعا به يک دستورالعمل واحد ترجمه مي شود، پشتيباني مي کند.
به همين دليل است که C عموما يک زبان سريع در نظر گرفته مي شود؛ نه به دليل هر چيز ديگري که اين زبان را کارآمد مي سازد. به عنوان مثال اگر يک زبان کنترل بر طرح اصلي حافظه را در اختيار برنامه نويس قرار ندهد براي استفاده از دستورالعمل هاي Vector که از طرف کامپايلر به هوشمندي زيادي نياز دارد، موقعيت هاي بسيار بيش تري را در اختيار کامپايلر مي گذارد. در مقابل C، با يک کامپايلر بسيار ساده و ابتدايي سريع تر اجرا مي شود.
به رغم انجام بهينه سازي هاي بسيار اندک، هنوز هم کدي ايجاد مي کند که به حد کافي و قابل قبول سريع نيست و از خروجي يک کامپايلر C، کندتر اما کماکان بسيار بهتر از يک به کارگيري ابتدايي و ساده از زباني مثل Smaltalk يا Haskell است. هرچند که سرعت، هميشه نتيجه ي کار نيست. اگر شما يک برنامه ي C تيپيکال را در نظر بگيرد و آن را براي يک پردازنده ي مدرن کامپايل کنيد، شاهد مشکلاتي خواهيد بود. يک پردازنده ي مدرن براي برنامه هاي سنگين و مدرن طراحي شده است. تست هاي تيپيکال نشان مي دهد که اغلب کدهاي نوشته شده در زبان هاي شبيه C به طور متوسط در هر 7 دستورالعمل منشعب مي شوند. براي پيش بيني اين که آيا اين منشعب شدن بر روي اين پردازنده ها به خوبي کار مي کند کار زيادي انجام مي گيرد. اگر شما نتوانيد هدف يک شاخه را پيش بيني کنيد، درنتيجه مجبور هستيد قبل از اين که بتوانيد آن را اجرا کنيد، منتظر بمانيد تا تمام دستورالعمل هاي Pipeline تکميل شوند و به اتمام برسند.
درمقابل، GPUها تحت تاثير پيش بيني انشعاب قرار نمي گيرند. آن ها انتظار دارند کدي را که در هر چند صد دستورالعمل، فقط داراي يک شاخه است، اجرا کنند. در عوض، آن ها از ترانزيستورها براي به کارگيري چيزهايي مثل عمليات trigonometric به عنوان يک دستورالعمل واحد استفاده مي کنند. آن ها همچنين انتظار دارند همان بخش از برنامه را بر روي مقادير بزرگ تري از داده ها اجرا کنند. در يک برنامه ي سه بعدي تيپيکال، آن ها همان vertex shader مشابه را بر هر نقطه ي صحنه، همان pixel shader مشابه را بر روي هر پيکسل texture شده، و غيره اجرا مي کنند. ساختار فعلي Nvidia تمام threadها را در گروه هاي چهارتايي اجرا مي کند و اين کار هنگامي که آن ها در حال اجراي دستورالعمل هاي مشابه در يک زمان هستند، کاملاً به شکل موازي انجام مي گيرد اما هزينه و جريمه ي آن در صورتي که از سينک خارج شوند يک افت بزرگ در کارآيي است. درنهايت، آن ها بخش بزرگي از اين گروه هاي thread را به شکل موازي به انجام مي رسانند. C يک زبان سطح پائين اين چنيني (از لحاظ ساختار) نيست. اين زبان فاقد vector types است، عمليات پيچيده اي را که در دسترس قرار دارد به نمايش نمي گذارد و فاقد هر نوع پشتيباني از همزماني است. در اين مورد خاص زبان C به دليل اين که يک مدل موجز و مختصر که کاملاً با سخت افزار هماهنگ نيست را به نمايش مي گذارد يک زبان سطح بالا در نظر گرفته مي شود.

هوشمندي کامپايلر
 

اين عدم تطابق بين مدل مرکزي سخت افزار و abstract model زبان، چيز جديدي نيست و هر زبان سطح بالايي آن را تجربه کرده است. زبان هاي خانواده ي Smaltalk نظير java، مدلي از حافظه را به عنوان مجموعه اي از بلاک هاي گسسته با message-passing براي کنترل جريان به نمايش مي گذارد. با در نظر گرفتن استثناي ساختار Burroughs Large System، هيچ پلتفرمي واقعاً شبيه آن نيست.
کوشش براي اين که يک زبان سطح بالا را در قالب ساختاري با Semanticهاي متفاوت درآوريم دشوار است اما غيرممکن نيست. به عنوان مثال گاهي اوقات امکان بيرون کشيدن موازي کاري از کد کاملا ترتيبي وجود دارد. چيزي مثل اين لوپ را در زبان C در نظر بگيريد:
for (int i=0 ; i<100 ; i++
}
j[i]=sin(k[i];
{
در نگاه اول، ممکن است فکر کنيد که مي توانيد هر لوپ را کاملاً به شکل موازي انجام دهيد. اگر کدي براي يک vector unit ايجاد کنيد، ممکن است اين کد را به شکل 25 تکرار که هر کدام يک vector-sine را انجام مي دهد بازنويسي کنيد. براي يک GPU، شما مي توانيد کرنل کوچکي که يک عمليات sine واحد را انجام داده است ايجاد کنيد و سپس آن را به شکل کاملاً موازي بر روي 100 ورودي اجرا نمائيد. (براي چنين برنامه ي ساده اي، هزينه ي کپي کردن از/به GPU به اندازه اي است که انجام آن را احمقانه مي کند!).
اما اگر K و J هر دو اشاره گرهايي به يک آرايه ي مشابه باشند و J توسط يک عنصر offset به جلو فوروارد شود چه؟ در چنين مواردي، لوپ با sine عنصر قبلي، هر ورودي را پر مي کند. به دليل اين که اين نتيجه کاملاً در C معتبر است، کامپايلر نمي تواند اين کد را به سادگي بهينه کند. يک راه حل بسيار ساده براي اين مشکل وجود دارد: از برنامه نويس بخواهيد مقدار بيش تري اطلاعات فراهم کند. اين روشي است که OpenMP براي همزمان سازي از آن بهره مي گيرد. OpenMP مجموعه اي از Annotationهايي را که شما مي توانيد براي مشخص نمودن پتانسيل همزماني به يک برنامه اضافه کنيد تعريف مي کند. در مثال بالا، شما مي توانيد مشخص کنيد که j و k نام مستعار (alias) نيستند و کامپايلر سپس قادر خواهد بود لوپ را به شکل موازي در آورد.
شما مي توانيد چيزي شبيه به اين را براي GPU انجام دهيد. اخيرا من بر روي يک استاندارد باز به نام HMMP کار مي کنم که در اصل توسط يک کمپاني فرانسوي به نام CAPS ايجاد شده و در حال حاضر توسط PathScale نيز پشتيباني مي شود. طراحي اين استاندارد بسيار شبيه OpenMP است. شما مي توانيد مثل برنامه هاي C/C++ و Fortran نشانه گذاري کنيد و آن ها را بر روي GPU اجرا کنيد. مزيت اين روش اين است که به شما اجازه مي دهد از codebase بسيار زيادي استفاده کنيد و بدون شکستن سازگاري منبع با سيستم هايي که فايد يک GPUي سازگار هستند به سرعت به آن شتاب بدهيد.
Annotationهايي که اين سيستم ها فراهم مي کنند فوق داده يا metadata هستند. آن ها به جاي "تشريح" semanticهاي برنامه ها، آن ها را "شفاف" مي کنند. آن ها چگونگي رفتار برنامه را تغيير نمي دهند؛ فقط مقداري اطلاعات در اختيار کامپايلر قرار مي دهند که خود کامپايلر به سادگي نمي تواند آن را براي خودش مشخص نمايد. در برخي از موارد، اين سيستم ها را مي توان با تحليل استاتيک و ديناميک برنامه جايگزين کرد. به عنوان مثال، در لوپ ساده اي که قبلاً در مورد آن صحبت کرديم، تصور کنيد اگر اين خط به شکل زير بود چه اتفاقي رخ مي داد:
float *j=calloc(sizeof(float), 100);
کامپايلر مي داند نشان گري که توسط تابع کتابخانه ي calloc() برگردانده شده با هيچ حافظه ي ديگري در سيستم alisas نخواهد بود. درنتيجه، مي تواند به شکل اتوماتيک مشخص کند که j و k با هم alisas نيستند و هر نوع بهينه سازي مرتبط را انجام مي دهد. اگر j و k هر دو پارامترهاي تابع باشند، اين سناريو کمي دشوار مي شود. شما براي مشخص نمودن اين که آيا آن ها alias هستند يا نه، به مقداري تحليل inter-procedural نياز داريد. ممکن است يک کامپايلر اين تحليل را با ويژه سازي تابع تلفيق کند و دو نسخه ي اين روتين را منتشر کند؛ يک نسخه ي امن در کنار نسخه اي که فرض مي کند پارامترها alias نيستند و سپس فراخوان هايي را که ثابت مي کند پارامترها alias نيستند به منظور فراخواني تابع سريع تر بازنويسي مي کند. اين گزينه در کامپايلر کمي پيچيده تر است و درواقع فقط نشان دهنده ي يک نمونه ي ساده براي بهينه سازي بالقوه است. کامپايلرها به تدريج از قابليت بيش تري در زمينه ي اين نوع اطلاعات برخوردار خواهند شد اما اين موضوع به اين زودي ها و به سرعت رخ نخواهد داد.

تکامل تدريجي زبان
 

يک گزينه ي ديگر، بازسازي و تغيير زبان است. به عنوان مثال GCC شامل تعدادي extension براي خانواده ي زبان هاي C است. يک extension تقريباً ساده به شما اجازه مي دهد vector typeها را تعريف کنيد. عمليات ساده و ابتدايي C براي کار با vector typeها تکامل پيدا خواهند کرد.
زبان هاي shader حتي زبان را بيش از اين تغيير خواهند داد. به عنوان مثال Open CL C که کمي شبيه C است تفاوت هاي کوچک بسيار زيادي با آن دارد و شامل vector typeهاي اوليه، مجموعه ي بزرگي از توابع توکار است و از فضاهاي آدرس متعددي پشتيباني مي کند. Open CL C به طور مجزا مورد استفاده قرار نمي گيرد؛ بلکه براي نوشتن قطعات کوچکي از برنامه ها که از ساير کدها اجرا مي شوند به کار مي رود. تصور به وجود آمدن تلفيقي از اين دو تکنيک نيز وجود دارد.
Open CL C هم ويژگي هايي از C را اضافه و هم تعدادي از اين ويژگي ها را کم مي کند. شما مي توانيد از extensionها در برنامه هاي عادي C استفاده کنيد و آن ها سپس براي تقسيم برنامه به گونه اي که بخش هاي مختلف فقط از زيرمجموعه ي سازگار با GPU که برروي GPU اجرا مي شود به کامپايلر متکي خواهند بود. جديدترين پيش نويس هاي استاندارد C شامل پشتيباني از همزماني هستند اما چيز واقعاً مفيدي در بر ندارند.
Erlang اساساً براي سيستم هاي ارتباطي طراحي شده بود اما در ساير سيستم هاي همزمان استفاده هاي زيادي از آن شده است. معروف ترين سرور XMPP که منبع باز يعني ejabberd به زبان Erlang نوشته شده است و به خوبي با آن سازگار است. Erlang از مدل actor براي همزماني بهره مي برد که در آن کد به فرآيندهاي مجزايي تقسيم مي شود که مي تواند فقط با کپي کردن داده ها بين خودشان ارتباط برقرار کنند؛ بدين معنا که هيچ چيزي به جز ارجاع هاي فرآيند بين فرآيندها alias نمي شود. فرآيندهاي Erlang به طور خالص يک ساختار source-language محسوب مي شوند. در عمل، آن ها به عنوان threadهاي سبک برفراز threadهاي سيستم عامل به کار گرفته مي شوند و عمليات کپي فقط يک اشاره گر را بين threadها عبور مي دهد. اگر چه Erlang به اشياء اجازه نمي دهد در سطح زبان تغيير پيدا کنند، اگر آن ها alias نباشند خود کامپايلر برخي از اشياء را در سطح به کارگيري تغيير مي دهد و اين امر به واسطه ي محدوديت هاي زبان براي اطمينان از alias بودن آن ها فعاليت نسبتاً ساده اي به شمار مي رود.
يک زبان بدون تاثيرات جانبي، زبان مفيدي نيست. چيزهايي مثل I/O غيرممکن هستند. در Haskell اين چيزها با استفاده از monads که مکانيزم موثري براي جمع آوري تاثيرات جانبي به منظور کاربردهاي بعدي است، تعريف مي شود. زبان فاقد تاثير جانبي (يا side-effect, monads) را مي سازد و سپس مي تواند آن ها را به کد side-effect-free اضافه کند. اين ها در کنار حافظه ي تبادلي نرم افزار به خوبي کار مي کنند. شما مي توانيد مجموعه اي از فراخوان هاي توابع را به صورت موازي به انجام برسانيد و نتايج آن ها را در صورتي که هيچ اتفاقي رخ ندهد تا در بين راه باعث شکست خوردن آن ها شود به حافظه اعمال کنيد. اين طراحي معادل مبادلات بانک اطلاعاتي است که در آن يا مجموعه ي کاملي از تغييرات اعمال مي شود يا هيچ تغييري رخ نمي دهد.

زبان هاي آينده
 

در همين زمان، کد CPU و GPU عموماً در يک برنامه از هم جدا هستند، بنا به اين دلايل ساده: هم CPU و هم GPU داراي حافظه ي محلي خود هستند. در عين حال، در حالي که هر دو سريع هستند کپي بين CPU و GPU پرهزينه است. اين ارتباط در سال هاي آينده تغيير خواهد کرد. درست مثل کمک پردازنده هاي مميز شناور قبل از آن ها، GPUها در حال حرکت به داخل CPU die اصلي هستند. اين موضوع قبلاً مدتي در (SoC) system-on-chip موبايل ديده شده بود؛ آن ها نوعا شامل تعدادي يونيت متفاوت نظير ARM core، يک GPU و يک DSP بر روي يک die بودند. مهم تر از همه، آن ها در پشت همان کنترولر حافظه مخفي هستند. درنتيجه، هزينه ي بسيار کمتري براي توزيع کار بين CPU و GPU صرف مي شود. به جاي نياز به کپي کردن داده ها، از يک باس PCle به GPU، کار برروي آن، و سپس کپي کردن آن از طريق همان باس، اين سيستم ها فقط به update تعدادي از ورودي هاي page-table نياز دارند. وقتي سيستم هاي اين چنيني محبوبيت و گسترش بيش تري پيدا کنند، حتي offloading سگمنت هاي کدهاي نسبتاً کوچک به GPU يک نقص محسوب خواهد شد. برخي از اين کارها مي تواند با استفاده از زبان هاي فعلي به انجام برسد اما براي رسيدن به بهترين نتايج شما بايد به دنبال چيزي که خصوصاً براي همين هدف طراحي شده است بگرديد.
متاسفانه Open CL راه حل اين مشکل نيست. به دليل اين که براي مدل کاملاً copy-execute-copy طراحي شده است، اگر به کپي نياز نداشته باشيد مقدار زيادي سربار اضافه مي کند. براي برنامه نويسي سطح پايين، بايد انتظار ديدن زبان هايي مثل C که چيزي مثل آن را در آينده ارائه مي کند داشته باشيد. اين ها قبلاً در مشخصات C++Ox وجود داشته اند.
با استفاده از اين نوع برنامه نويسي شما مي توانيد تابعي را که بر روي يک کمک پردازنده اجرا خواهد شد، فرابخوانيد، مقداري کار برروي آن انجام دهيد و تا وقتي که به نتيجه نياز داريد، منتظر بمانيد تا پردازنده آن را به اتمام برساند. از اواسط دهه ي 70 ميلادي به اين طرف، پردازنده ها مسير تکاملي نسبتاً ساده اي را پشت سر گذاشته اند و مشوق هاي کمي براي سازگاري زبان ها فراهم کرده اند. سيستم هاي چندهسته اي که طي سال هاي گذشته از محبوبيت زيادي برخوردار شده اند اين موقعيت را عوض کرده اند. دهه ي آينده عصر جذابي براي طراحان زبان هاي برنامه نويسي خواهد بود.
منبع: ماهنامه ي کامپيوتري بزرگراه رايانه، شماره ي 136.



 



ارسال نظر
با تشکر، نظر شما پس از بررسی و تایید در سایت قرار خواهد گرفت.
متاسفانه در برقراری ارتباط خطایی رخ داده. لطفاً دوباره تلاش کنید.