
جهاز الكمبيوتر الخاص بك ليس إصدارًا سريعًا من PDP-11
مرحبا يا هبر!
اسمي أنطون دوفغال ، أنا مطور C (وليس فقط) في Badoo.
لقد صادفت مقالًا بقلم ديفيد تشيزنيل ، الباحث في جامعة كامبريدج ، حيث يجادل في الرأي المقبول عمومًا بأن لغة C هي لغة منخفضة المستوى ، وبدا أن حججه مثيرة للاهتمام بما يكفي بالنسبة لي.
في ضوء نقاط الضعف المكتشفة مؤخرًا ، يجب أن يستغرق
Meltdown و Spectre الوقت لمعرفة أسباب حدوثها. استغل كل من هذه الثغرات التنفيذ المضاربة للتعليمات من قبل المعالجات وسمحت للمهاجمين بتلقي النتائج من خلال قنوات الطرف الثالث. تمت إضافة نقاط الضعف في المعالجات ، إلى جانب العديد من العوامل الأخرى ، حتى يواصل المبرمجون C الاعتقاد بأنهم يبرمجون بلغة منخفضة المستوى ، على الرغم من أن هذا لم يحدث منذ عقود.
مصنعي المعالجات ليسوا وحدهم في ذلك. كما ساهم مطورو برامج التحويل البرمجي C / C ++.
ما هي اللغة ذات المستوى المنخفض؟
أعطى عالم الكمبيوتر الأمريكي وأول حائز على جائزة تورينج التعريف ألن بيرليس التعريف التالي:
"لغة البرمجة منخفضة المستوى إذا كانت البرامج المكتوبة عليها تتطلب الانتباه إلى ما هو غير أساسي."
على الرغم من أن هذا التعريف يشير إلى C ، إلا أنه لا يوفر فهمًا لما يريد الناس رؤيته بلغة منخفضة المستوى. خصائص مختلفة تجعل الناس يعتبرون اللغة منخفضة. تخيل نطاقًا من لغات البرمجة مع المجمّع في أحد طرفيه وواجهة إلى كمبيوتر Enterprise في الطرف الآخر. تقترب اللغات منخفضة المستوى من الحديد ، بينما تقترب اللغات ذات المستوى الأعلى من طريقة تفكير الناس.
لكي تكون "أقرب إلى الأجهزة" ، يجب أن تقدم اللغة ملخصات تتطابق مع تجريدات النظام الأساسي المستهدف. من السهل إثبات أن لغة C كانت لغة منخفضة المستوى في PDP-11. التنفيذ المتسلسل للبرامج ، مساحة عنوان ثابتة ، حتى عوامل ما قبل وبعد الزيادة ، تقع بشكل مثالي على أوضاع معالجة PDP-11.
محاكيات PDP-11 سريعة
السبب الرئيسي لثغرات Spectre و Meltdown هو أن منشئو المعالجات لم يصنعوا معالجات سريعة فحسب ، بل صنعوا معالجات سريعة باستخدام واجهة PDP-11. هذا مهم لأنه يسمح لمبرمجي C بالاستمرار في الاعتقاد بأن لغتهم قريبة من الأجهزة.
يوفر رمز C رمزًا آليًا تجريديًا متسلسلًا في الغالب (حتى C11 ، فهو متسلسل تمامًا ، إذا تم استبعاد الامتدادات غير القياسية). إنشاء خيط جديد هو استدعاء لوظيفة مكتبة ، وهي عملية مكلفة للغاية. لذلك ، تعتمد المعالجات ، الراغبة في مواصلة تنفيذ التعليمات البرمجية C ، على التوازي على مستوى التعليمات (ILP). يحللون العمليات المجاورة ويؤدون عمليات مستقلة بالتوازي. هذا يعقد بشكل كبير المعالجات ويؤدي إلى زيادة استهلاك الطاقة ، لكنه يسمح للمبرمجين بكتابة رمز متسلسل في الغالب. في المقابل ، تحقق المعالجات الرسومية (GPU) أداءً عاليًا بطريقة أخرى: فهي تتطلب كتابة برامج متوازية.
التزامن العالي على مستوى الأمر هو السبب المباشر لـ Spectre و Meltdown. ينفّذ معالج Intel الحديث ما يصل إلى 180 تعليمات في وقت واحد (على عكس الجهاز المتسلسل C المتسلسل ، الذي يتوقع تنفيذ التعليمات السابقة قبل بدء التشغيل التالي). يوضح الإرشادي النموذجي لرمز C أن هناك فرع واحد في المتوسط لكل سبع تعليمات. إذا كنت ترغب في الحفاظ على اكتمال مسار التعليمات ، فأنت بحاجة إلى تخمين الفروع الـ 25 التالية. هذا ، بدوره ، يضيف تعقيدًا - يقوم المعالج أولاً بحساب الفرع الذي تم تخمينه بشكل غير صحيح ، ثم يلقي بنتائج الحسابات ، مما يؤثر سلبًا على استهلاك الطاقة. تحتوي هذه البيانات التي تم طرحها على نتائج غير مباشرة مرئية ، والتي تم استخدامها في هجمات Spectre و Meltdown.
إعادة تسمية السجلات تستهلك الكثير من الطاقة ومساحة الشريحة في المعالجات الحديثة. لا يمكن إيقاف تشغيله أو تقليل استهلاكه للطاقة ، مما يجعله غير مريح في عصر
السيليكون الداكن ، عندما تكون الترانزستورات منخفضة ، لكن الترانزستورات المعنية هي مورد قيم. هذا الجهاز غير موجود في GPU ، حيث يتم تحقيق التزامن باستخدام مؤشرات الترابط بدلاً من محاولة تنفيذ رمز متسلسل في البداية. إذا كانت التعليمات لا تحتوي على تبعيات تحتاج إلى إعادة بنائها ، فلا داعي لإعادة تسمية السجلات أيضًا.
ضع في اعتبارك جزءًا أساسيًا آخر من تصميم C: الذاكرة المسطحة. لم يكن موجودًا منذ عقدين. غالبًا ما يحتوي المعالج الحديث على ثلاثة مستويات من التخزين المؤقت بين السجلات والذاكرة الرئيسية ، وبالتالي تقليل الوقت المستغرق للوصول إلى هذا الأخير.
ذاكرة التخزين المؤقت مخفية عن المبرمج وبالتالي لا يمكن الوصول إليها من C. استخدام فعال لذاكرة التخزين المؤقت هو إحدى الطرق لتسريع تنفيذ التعليمات البرمجية على معالج حديث ، ومع ذلك يتم إخفاؤها تمامًا من الجهاز المجرد ويضطر المبرمجون إلى الاعتماد على معرفة تفاصيل تنفيذ ذاكرة التخزين المؤقت (على سبيل المثال ، أن اثنين من المحاذاة 64 بت يمكن أن تظهر القيم في سطر واحد من ذاكرة التخزين المؤقت) لكتابة تعليمات برمجية فعالة.
التحسين C
السرعة هي إحدى الخصائص المشتركة المنسوبة إلى اللغات ذات المستوى المنخفض. على وجه الخصوص ، يجب أن يكون من السهل ترجمتها إلى رمز سريع بدون مترجم معقد. غالبًا ما يتم تجاهل الحجة القائلة بأن المترجم الذكي بما فيه الكفاية يمكنه جعل لغة سريعة بسرعة من قبل مؤيدي C عندما يتحدثون عن لغات أخرى.
لسوء الحظ ، باستخدام ترجمة بسيطة ، لا يمكنك الحصول على رمز سريع من C.
يبذل المهندسون المعماريون للمعالج جهودًا بطولية لإنشاء رقائق يمكنها تنفيذ كود C بسرعة. لكن مستويات الأداء التي يتوقع المبرمجون رؤيتها يتم تحقيقها فقط بمساعدة التحسينات المعقدة بشكل لا يصدق التي يقوم بها المترجم.
مترجم Clang (بما في ذلك الأجزاء المقابلة من LLVM) هو حوالي 2 مليون سطر من التعليمات البرمجية. لتحليل وتحويل الكود ، وهو أمر ضروري لتسريع C ، هناك حاجة إلى حوالي 200000 سطر من الكود (باستثناء التعليقات والأسطر الفارغة).
على سبيل المثال ، لمعالجة كمية كبيرة من البيانات في C ، تحتاج إلى كتابة حلقة تعالج كل عنصر على التوالي. من أجل التنفيذ الأمثل لهذه الدورة على معالج حديث ، يجب على المترجم أن يحدد أن تكرارات الدورة مستقلة عن بعضها البعض. يمكن أن تساعد الكلمة الأساسية المقيدة في هذه الحالة - فهي تضمن ألا تتداخل عمليات الكتابة إلى أحد المؤشرات مع القراءة من مؤشر آخر. هذه المعلومات في لغة C محدودة أكثر بكثير من لغة مثل فورتران ، وهذا هو السبب الرئيسي لعدم قدرة C على دفعها للخروج من الحوسبة عالية الأداء.
بعد أن يقرر المترجم أن التكرارات مستقلة عن بعضها البعض ، فإن الخطوة التالية هي محاولة لتوجيه النتيجة ، لأن إنتاجية المعالجات الحديثة أعلى من أربع إلى ثماني مرات للشفرة المتجهية من الشفرة العددية. إن اللغة ذات المستوى المنخفض لمثل هذه المعالجات سيكون لها أنواع متجهة خاصة بها من الطول التعسفي. هذه الأنواع موجودة في تمثيل LLVM المتوسط ، لأنه من الأسهل دائمًا تقسيم العمليات الكبيرة مع المتجهات إلى عدة نواقل صغيرة بدلاً من إنشاء عمليات ناقلات أكبر.
عند هذه النقطة ، يجب أن يتعامل محسّنو قواعد الذاكرة C. يضمن C إمكانية استخدام البنى بنفس البادئة بالتبادل ، ويتيح الوصول إلى حقول حقل الإزاحة للهياكل في اللغة. هذا يعني أن المحول البرمجي لا يمكنه تغيير ترتيب الحقول في الهيكل أو إضافة محاذاة لتحسين التوجيه (على سبيل المثال ، تحويل بنية من صفائف إلى مجموعة من الهياكل أو العكس). لا يمثل هذا عادةً مشكلة في اللغات منخفضة المستوى ، حيث يمكن التحكم في موقع الحقول في الهيكل ، ولكنه يجعل مهمة تسريع C. أكثر صعوبة.
تتطلب C أيضًا محاذاة في نهاية الهيكل ، حيث تضمن عدم وجود محاذاة في المصفوفات. تعد المحاذاة جزءًا معقدًا إلى حد ما من مواصفات C ، والتي تتفاعل بشكل سيئ مع أجزاء أخرى من اللغة. على سبيل المثال ، يجب أن تكون قادرًا على مقارنة بنيتين باستخدام طريقة المقارنة بدون كتابة (أي وظيفة memcmp ()) ، لذا يجب أيضًا محاذاة نسخة البنية. في بعض الحالات ، يستغرق نسخ المحاذاة وقتًا طويلاً.
ضع في اعتبارك
التحسينين الأساسيين اللذين ينتجهما المترجم C:
SROA (الاستبدال
الحجمي للركام ، الاستبدال
الحجمي للركام)
وفتح حلقة .
تحاول SROA استبدال الهياكل والمصفوفات ذات الحجم الثابت بمتغيرات منفصلة. هذا يسمح للمترجم بمعالجة الوصول إليهم بشكل مستقل عن بعضهم البعض وتجاهل العملية ، إذا كان من الواضح أن نتيجته ليست مستخدمة. في بعض الحالات ، يكون التأثير غير المباشر لهذا التحسين هو إزالة المحاذاة.
التحسين الثاني ، فتح الحلقة ، يحول الحلقة مع الشرط إلى حالة مع حلقات مختلفة في كلا الفرعين. هذا يغير ترتيب التنفيذ بدلاً من التأكيد على أن المبرمج يعرف ما سيتم تنفيذه بلغة منخفضة المستوى. وهذا يخلق أيضًا مشاكل خطيرة في كيفية تعامل C مع المتغيرات غير المحددة والسلوك غير المحدد.
في لغة C ، المتغير غير المهيأ له قيمة غير محددة ، والتي يمكن أن تختلف مع كل مكالمة. هذا مهم لأنه يسمح لك بتنفيذ إعادة التدوير البطيئة لصفحات الذاكرة. على سبيل المثال ، في FreeBSD ، يخبر تطبيق malloc () النظام أن الصفحات لم تعد قيد الاستخدام ، ويستخدم النظام الإدخال الأول في الصفحة كدليل على أن الأمر ليس كذلك. يمكن للنداء إلى الذاكرة المخصصة حديثًا الحصول على القيمة القديمة ، ثم يمكن لنظام التشغيل إعادة استخدام صفحة الذاكرة ، ثم استبدالها بصفحة مليئة بالأصفار في المرة التالية التي تكتب فيها إلى مكان آخر على الصفحة. ستحصل المكالمة الثانية إلى نفس المكان على الصفحة على قيمة صفرية.
إذا كان الشرط يستخدم قيمة غير محددة ، فلن يتم تحديد النتيجة أيضًا - يمكن أن يحدث أي شيء. تخيل تحسين حلقة مفتوحة حيث يتم تنفيذ حلقة صفر مرات. في الحلقة الأصلية ، الحلقة بأكملها هي شفرة ميتة. يوجد في النسخة المفتوحة الآن شرط بمتغير قد لا تتم تهيئته.
ونتيجة لذلك ، يمكن تحويل الرمز الميت إلى سلوك غير محدد. هذا مجرد واحد من العديد من التحسينات ، عندما تستكشف بشكل شامل دلالات لغة C ، يتبين أنها غير موثوقة.
في النهاية ، يمكنك جعل كود C يعمل بسرعة ، ولكن فقط بعد قضاء آلاف السنين في إنشاء مترجم ذكي بما فيه الكفاية. لكن هذا ممكن فقط إذا تم انتهاك قواعد معينة للغة. يسمح منشئو برامج التحويل البرمجي للمبرمجين C بالتخيل أنهم يكتبون تعليمات برمجية "قريبة من الأجهزة" ، ولكن يتعين عليهم إنشاء رمز جهاز يتصرف بشكل مختلف حتى يستمر المبرمجون في الاعتقاد بأنهم يكتبون بلغة سريعة.
فهم ج
إحدى السمات الأساسية للغة منخفضة المستوى هي أن المبرمجين يمكنهم بسهولة فهم كيفية نقل آلة لغة مجردة إلى آلة مادية. كان هذا هو الحال بالتأكيد في PDP-11 ، حيث تمت ترجمة تعبيرات C إلى تعليمات واحدة أو اثنين. وبالمثل ، قام المترجم بوضع المتغيرات في فتحات المكدس وتحويل الأنواع البسيطة إلى مفهومة لـ PDP-11.
منذ ذلك الحين ، أصبحت تطبيقات C أكثر تعقيدًا بكثير - للحفاظ على الوهم بأن C يتم نقله بسهولة إلى منصة الأجهزة ويعمل بسرعة. في عام 2015 ، أظهر مسح بين المبرمجين C والمؤلفين المترجمين وأعضاء لجنة التقييس أن هناك
مشاكل في فهم C. على سبيل المثال ، تسمح هذه اللغة للتنفيذ بإضافة محاذاة إلى الهياكل (ولكن ليس إلى المصفوفات) لضمان محاذاة جميع الحقول بشكل صحيح للنظام الأساسي الهدف. إذا قمت بملء هذه البنية بالأصفار ثم حددت قيمة لبعض الحقول ، فهل سيكون هناك أصفار في بتات المحاذاة؟ وبحسب الاستطلاع ، كان 36٪ متأكدين من ذلك ، و 29٪ لم يعرفوا الإجابة. اعتمادًا على المترجم ومستوى التحسين ، قد يكون هذا صحيحًا (أو لا).
هذا مثال تافه جدًا ، لكن العديد من المبرمجين إما يعطون إجابة خاطئة أو لا يمكنهم الإجابة على الإطلاق.
إذا قمت بإضافة مؤشرات ، تصبح دلالات C أكثر إرباكًا. كان
نموذج BCPL بسيطًا جدًا: جميع المعاني هي كلمات. كل كلمة إما بيانات أو عنوان في الذاكرة. الذاكرة عبارة عن مصفوفة مسطحة من الخلايا المفهرسة حسب العنوان.
يسمح النموذج C بالتنفيذ لمنصات مختلفة ، بما في ذلك البنيات المجزأة ، حيث يمكن أن يتكون المؤشر من معرفات المقطع والإزاحة ، وكذلك الأجهزة الافتراضية مع جامع القمامة. سمحت مواصفات C بعمليات المؤشر المسموح بها لتجنب المشاكل مع هذه الأنظمة. يشير الرد على
تقرير العيب 260 إلى أصل المؤشر:
"يمكن أن تتبع عمليات التنفيذ أصل مجموعة من البتات والتعامل مع تلك التي تحتوي على قيمة غير محددة بشكل مختلف عن تلك التي تحتوي على قيمة محددة. "يمكنهم التعامل مع المؤشرات بشكل مختلف اعتمادًا على أصلهم ، حتى لو كانوا متطابقين من حيث قيمة البت الخاصة بهم."
لسوء الحظ ، فإن كلمة "أصل" مفقودة من مواصفات C11 ، لذلك يقرر المترجمون بأنفسهم ما تعنيه. على سبيل المثال ، يختلف كل من GCC و Clang فيما إذا كان المؤشر الذي تم تحويله إلى العدد الصحيح والظهر يحتفظ بأصله. قد يقرر المحللون أن مؤشرين إلى نتائج malloc () يعطيان دائمًا نتائج سلبية عند المقارنة ، حتى إذا أشارا إلى نفس العنوان.
إن حالات سوء الفهم هذه ليست أكاديمية بحتة. على سبيل المثال ، تمت ملاحظة الثغرات بالفعل ، والتي كانت ناتجة عن تجاوز عدد صحيح موقّع (سلوك غير معرّف في C) أو
إلغاء الإشارة إلى مؤشر قبل التحقق منه من أجل NULL ، على الرغم من حقيقة أن المترجم قيل له أن المؤشر لا يمكن أن يكون NULL.
إذا كانت هناك مثل هذه المشاكل ، فمن الصعب أن نتوقع من المبرمج أن يفهم تمامًا كيف يترجم برنامج C إلى البنية المناسبة.
إدخال معالج ليس ل C
تسبب التصحيحات المقترحة للحماية من Spectre و Meltdown تدهورًا حادًا في الأداء ، مما أدى إلى إبطال جميع إنجازات العمارة المصغرة خلال العقد الماضي. ربما حان الوقت للتوقف عن التفكير في كيفية جعل رمز C أسرع ، وبدلاً من ذلك فكر في نماذج برمجة جديدة على المعالجات المصممة للسرعة.
هناك العديد من الأمثلة على البنيات التي لم تركز على الكود C التقليدي والتي يمكن من خلالها استلهام الأفكار. على سبيل المثال ، لا تتطلب المعالجات متعددة المسارات مثل Sun / Oracle UltraSPARC Tx الكثير من ذاكرة التخزين المؤقت لإبقاء مشغلاتها مشغولة. قام معالجو البحث بتوسيع هذا المفهوم ليشمل عددًا كبيرًا جدًا من سلاسل العمليات المخططة للأجهزة. الفكرة الرئيسية هي أنه ، مع وجود عدد كافٍ من سلاسل العمليات ، يمكن للمعالج إيقاف هذه الخيوط التي تنتظر البيانات وتعبئة المشغلات بإرشادات من مؤشرات الترابط الأخرى. المشكلة هي أن برامج C عادةً ما تحتوي على عدد قليل جدًا من مؤشرات الترابط.
ARM's SVE (Scalar Vector Extensions ، ملحقات المتجه الحجمي) هو عمل مماثل آخر من Berkeley ، والذي يقدم نظرة على الواجهة المحسنة بين البرنامج والأجهزة. تقوم كتل التوجيه المنتظمة بتنفيذ عمليات مع ناقلات ذات حجم ثابت وتتوقع من المحول البرمجي تكييف الخوارزمية مع الحجم المحدد. في المقابل ، تحث واجهة SVE المبرمج على وصف مستوى التوازي بشكل مستقل وتتوقع من الأجهزة تكييفه مع المحركات المتاحة. يعد استخدام هذا في لغة C أمرًا صعبًا نظرًا لأن المتجه التلقائي يحتاج إلى حساب التوازي استنادًا إلى الحلقات في التعليمات البرمجية.
ذاكرة التخزين المؤقت كبيرة ، ولكن هذا ليس السبب الوحيد لتعقيدها. يعد بروتوكول دعم
تماسك ذاكرة التخزين المؤقت أحد أكثر المكونات تعقيدًا في المعالج الحديث. تأتي معظم الصعوبة من الاضطرار إلى الحفاظ على لغة يمكن من خلالها مشاركة البيانات وقابلة للتغيير. كمثال معاكس ، يمكننا استخدام آلة تجريدية على غرار إرلانج ، حيث يكون كل كائن محليًا أو غير قابل للتغيير. لن يكون لبروتوكول تماسك ذاكرة التخزين المؤقت لمثل هذا النظام حالتين فقط: البيانات القابلة للتغيير والبيانات المشتركة. يجب تعطيل ذاكرة التخزين المؤقت لدفق البرنامج الذي تم نقله إلى معالج آخر بشكل صريح ، ولكن هذه عملية نادرة نسبيًا.
يمكن للأشياء غير القابلة للتغيير تبسيط ذاكرة التخزين المؤقت بشكل أكبر ، وكذلك جعل بعض العمليات أرخص. في مشروع Maxwell من Sun Labs ، لوحظ أن الكائنات الموجودة في ذاكرة التخزين المؤقت والكائنات التي تم إنشاؤها مؤخرًا هي نفسها دائمًا تقريبًا. إذا ماتت الكائنات قبل استبعادها من ذاكرة التخزين المؤقت ، فلا يمكنك كتابتها إلى الذاكرة الرئيسية وبالتالي توفير استهلاك الطاقة. اقترح مشروع Maxwell جامع القمامة الذي يعمل في ذاكرة التخزين المؤقت ويسمح لك بإعادة تدوير الذاكرة بسرعة. مع وجود أشياء ثابتة على كومة الذاكرة المؤقتة والكومة القابلة للتحول ، يصبح جامع القمامة آلة حالة بسيطة للغاية ، والتي يمكن تنفيذها بسهولة في الأجهزة وتسمح لك باستخدام ذاكرة التخزين المؤقت الصغيرة نسبيًا بكفاءة.
المعالج الذي تم تصميمه فقط للسرعة ، وليس للمفاضلة بين السرعة ودعم C ، ربما يجب أن يدعم عددًا كبيرًا من الخيوط ، ولديه كتل تحويلية كبيرة ونموذج ذاكرة أبسط. سيكون من الصعب تنفيذ كود C على هذا المعالج ، لذلك ، نظرًا لحجم رمز C القديم في العالم ، فمن غير المرجح أن يحقق نجاحًا تجاريًا.
في مجال تطوير البرمجيات ، هناك أسطورة مفادها أن البرمجة المتوازية صعبة. سوف يفاجأ ألان كاي بسماع هذا: لقد علم الأطفال استخدام نموذج الممثل ، الذي كتبوا به البرامج على أكثر من 200 تيارات. هذا غير معروف أيضًا لمبرمجي Erlang ، الذين غالبًا ما يكتبون برامج تحتوي على آلاف المكونات المتوازية. من الأصح القول أن البرمجة المتوازية صعبة في لغة مع آلة مجردة مثل C. وإذا كنت تهتم بغلبة الأجهزة الموازية (من المعالجات متعددة النواة إلى وحدات معالجة الرسوميات متعددة النواة) ، فهذه مجرد طريقة أخرى لقول أن C ليست مناسبة للأجهزة الحديثة تقديم.