إنشاء مؤشرات ذكية معبرة للذاكرة عن بعد في C ++

مرحبا يا هبر!

ننشر اليوم ترجمة لدراسة مثيرة للاهتمام حول العمل مع الذاكرة ومؤشرات في C ++. هذه المادة أكاديمية قليلاً ، لكن من الواضح أنها ستهتم بقراء كتب غالويتز وليامز .

اتبع الإعلان!

في كلية الدراسات العليا ، أشارك في بناء هياكل البيانات الموزعة. لذلك ، فإن التجريد الذي يمثل المؤشر البعيد مهم للغاية في عملي لإنشاء رمز نظيف ومرتب. في هذه المقالة ، سأشرح سبب الحاجة إلى مؤشرات ذكية ، وأشرح كيف كتبت كائنات مؤشر عن بعد لمكتبتي في C ++ ، وتأكد من أنها تعمل تمامًا مثل مؤشرات C ++ العادية ؛ يتم ذلك باستخدام كائنات الارتباط البعيد. علاوة على ذلك ، سأشرح في الحالات التي يفشل فيها هذا التجريد لسبب بسيط هو أن مؤشر بلدي (حتى الآن) لا يتعامل مع تلك المهام التي يمكن أن تقوم بها المؤشرات العادية. آمل أن يثير هذا المقال اهتمام القراء المشاركين في تطوير أعمال تجريدية عالية المستوى.

واجهات برمجة التطبيقات منخفضة المستوى


عند العمل مع أجهزة الكمبيوتر الموزعة أو مع أجهزة الشبكة ، غالبًا ما يكون لديك حق الوصول للقراءة والكتابة إلى جزء من الذاكرة من خلال واجهة برمجة تطبيقات C. ومن الأمثلة على هذا النوع واجهة برمجة تطبيقات MPI للاتصال أحادي الاتجاه. تستخدم واجهة برمجة التطبيقات هذه الوظائف التي تتيح الوصول المباشر للقراءة والكتابة من ذاكرة العقد الأخرى الموجودة في كتلة موزعة. إليك كيف يبدو بطريقة مبسطة قليلاً.

void remote_read(void* dst, int target_node, int offset, int size); void remote_write(void* src, int target_node, int offset, int size); 

عند الإزاحة المشار إليها في مقطع الذاكرة المشتركة للعقدة الهدف ، remote_read عن بعد عددًا معينًا من وحدات البايت منه ، ويكتب remote_write عددًا معينًا من وحدات البايت.

تعد واجهات برمجة التطبيقات (APIs) هذه رائعة لأنها تتيح لنا الوصول إلى العناصر الأولية المهمة المفيدة لنا لتنفيذ البرامج التي تعمل على مجموعة من أجهزة الكمبيوتر. كما أنها جيدة جدًا لأنها تعمل بسرعة فائقة وتعكس بدقة القدرات المتوفرة على مستوى الأجهزة: الوصول إلى الذاكرة عن بُعد (RDMA). تسمح شبكات الكمبيوتر العملاقة الحديثة ، مثل Cray Aries و Mellanox EDR ، بحساب أن التأخير في القراءة / الكتابة لن يتجاوز 1-2 ساعات. يمكن تحقيق هذا المؤشر نظرًا لحقيقة أن بطاقة الشبكة (NIC) يمكنها القراءة والكتابة مباشرة إلى ذاكرة الوصول العشوائي ، دون انتظار أن تستيقظ وحدة المعالجة المركزية عن بُعد وتستجيب لطلب الشبكة.

ومع ذلك ، فإن واجهات برمجة التطبيقات هذه ليست جيدة من حيث برمجة التطبيقات. حتى في حالة واجهات برمجة التطبيقات البسيطة كما هو موضح أعلاه ، فإنه لا يكلف أي شيء محو البيانات بطريق الخطأ ، حيث لا يوجد اسم منفصل لكل كائن محدد مخزّن في الذاكرة ، سوى مخزن مؤقت واحد كبير متجاور. بالإضافة إلى ذلك ، الواجهة غير مطبوعة ، أي أنك محروم من مساعدة ملموسة أخرى: عندما يقسم المترجم ، إذا كتبت قيمة النوع الخطأ في المكان الخطأ. سيكون رمزك ببساطة خاطئًا ، وستكون الأخطاء ذات الطبيعة الغامضة والكارثية. يكون الموقف أكثر تعقيدًا لأن واجهات برمجة التطبيقات هذه في الواقع أكثر تعقيدًا ، وعند العمل معها من الممكن تمامًا إعادة ترتيب معلمتين أو أكثر عن طريق الخطأ.

المؤشرات المحذوفة


المؤشرات هي مستوى مهم وضروري من التجريد اللازم عند إنشاء أدوات البرمجة عالية المستوى. من الصعب في بعض الأحيان استخدام المؤشرات بشكل مباشر ، ويمكنك القيام بالكثير من الأخطاء ، ولكن المؤشرات هي لبنات البناء الأساسية في الكود. غالبًا ما تستخدم هياكل البيانات وحتى روابط C ++ مؤشرات أسفل الغطاء.

إذا افترضنا أنه سيكون لدينا واجهة برمجة تطبيقات مماثلة لتلك المذكورة أعلاه ، فسيتم تحديد موقع فريد في الذاكرة بواسطة "إحداثيات": (1) معرف الترتيب أو العملية و (2) الإزاحة التي تم إجراؤها على الجزء المشترك من الذاكرة البعيدة التي تشغلها العملية مع هذا الترتيب . لا يمكنك التوقف عند هذا الحد وجعل هيكل متكامل.

  template <typename T> struct remote_ptr { size_t rank_; size_t offset_; }; 

في هذه المرحلة ، من الممكن بالفعل تصميم واجهة برمجة تطبيقات للقراءة والكتابة إلى المؤشرات البعيدة ، وستكون واجهة برمجة التطبيقات هذه أكثر أمانًا من تلك التي استخدمناها في الأصل.

  template <typename T> T rget(const remote_ptr<T> src) { T rv; remote_read(&rv, src.rank_, src.offset_, sizeof(T)); return rv; } template <typename T> void rput(remote_ptr<T> dst, const T& src) { remote_write(&src, dst.rank_, dst.offset_, sizeof(T)); } 

تبدو عمليات النقل المجمعة متشابهة جدًا ، وهنا أغفلها للإيجاز. الآن ، لقراءة القيم وكتابتها ، يمكنك كتابة التعليمات البرمجية التالية:

  remote_ptr<int> ptr = ...; int rval = rget(ptr); rval++; rput(ptr, rval); 

إنه بالفعل أفضل من واجهة برمجة التطبيقات الأصلية ، حيث أننا نعمل هنا مع الكائنات المكتوبة. الآن ليس من السهل الكتابة أو قراءة قيمة من النوع الخطأ أو كتابة جزء من كائن فقط.

المؤشر الحسابي


حساب المؤشر هو الأسلوب الأكثر أهمية الذي يسمح للمبرمج بإدارة مجموعات القيم في الذاكرة ؛ إذا كنا نكتب برنامجًا للعمل الموزع في الذاكرة ، فمن المفترض أننا سنعمل مع مجموعات كبيرة من القيم.
ماذا تعني زيادة أو تقليل مؤشر محذوف من جانب واحد؟ أبسط خيار هو النظر في حساب المؤشرات المحذوفة كحساب للمؤشرات العادية: p + 1 يشير ببساطة إلى الذاكرة التالية من محاذاة الحجم sizeof(T) بعد p في المقطع المشترك من الترتيب الأصلي.

على الرغم من أن هذا ليس هو التعريف الوحيد الممكن لحساب المؤشرات البعيدة ، إلا أنه تم اعتماده مؤخراً بشكل أكثر نشاطًا ، كما أن المؤشرات البعيدة المستخدمة بهذه الطريقة موجودة في مكتبات مثل UPC ++ و DASH و BCL. ومع ذلك ، فإن لغة Unified Parallel C (UPC) ، والتي تركت إرثًا غنيًا في مجتمع متخصصي الحوسبة عالية الأداء (HPC) ، تحتوي على تعريف أكثر تفصيلًا لحساب المؤشر [1].

يعد تنفيذ حساب المؤشر بهذه الطريقة أمرًا بسيطًا ، ويشمل فقط تغيير إزاحة المؤشر.

  template <typename T> remote_ptr<T> remote_ptr<T>::operator+(std::ptrdiff_t diff) { size_t new_offset = offset_ + sizeof(T)*diff; return remote_ptr<T>{rank_, new_offset}; } 

في هذه الحالة ، لدينا الفرصة للوصول إلى صفائف البيانات في الذاكرة الموزعة. لذلك ، يمكننا تحقيق أن كل عملية في برنامج SPMD ستقوم بإجراء عملية للكتابة أو القراءة على المتغير الخاص بها في الصفيف الذي يتم توجيه المؤشر البعيد إليه [2].

 void write_array(remote_ptr<int> ptr, size_t len) { if (my_rank() < len) { rput(ptr + my_rank(), my_rank()); } } 

كما أنه من السهل تنفيذ المشغلين الآخرين ، حيث يوفر الدعم للمجموعة الكاملة من العمليات الحسابية التي تتم في حساب المؤشر العادي.

حدد nullptr


بالنسبة للمؤشرات العادية ، تكون قيمة nullptr هي NULL ، مما يعني عادةً تقليل #define إلى 0x0 ، نظرًا لأنه من غير المحتمل استخدام هذا القسم في الذاكرة. في nullptr الذي يحتوي على مؤشرات عن بُعد ، يمكننا إما تحديد قيمة مؤشر معين كـ nullptr ، وبالتالي جعل هذا الموقع في الذاكرة غير مستخدم ، أو تضمين عضو منطقي خاص سيشير إلى ما إذا كان المؤشر فارغًا. على الرغم من أن إنشاء موقع معين في الذاكرة غير مستخدمة ليس هو أفضل طريقة للخروج ، فإننا نأخذ أيضًا في الاعتبار أنه عند إضافة قيمة منطقية واحدة فقط ، سيتضاعف حجم المؤشر البعيد من وجهة نظر معظم المترجمين وينمو من 128 إلى 256 بت للحفاظ على المحاذاة. هذا غير مرغوب فيه خاصة. في مكتبتي ، اخترت {0, 0} ، أي إزاحة 0 برتبة 0 ، كقيمة nullptr .

قد يكون من الممكن التقاط خيارات أخرى لـ nullptr والتي ستعمل nullptr . بالإضافة إلى ذلك ، في بعض بيئات البرمجة ، مثل UPC ، يتم تطبيق مؤشرات ضيقة تناسب 64 بت لكل منهما. وبالتالي ، يمكن استخدامها في عمليات المقارنة الذرية مع التبادل. عند العمل بمؤشر ضيق ، عليك تسوية: إما أن يكون معرف الإزاحة أو معرف الترتيب ملائمين في 32 بت أو أقل ، وهذا يحد من قابلية التوسع.

الروابط المحذوفة


في لغات مثل Python ، يُعد بيان الأقواس بمثابة سكر نحوي لاستدعاء __setitem__ و __getitem__ ، اعتمادًا على ما إذا كنت تقرأ الكائن أو تكتب إليه. في C ++ ، لا يميز operator[] أي من فئات القيم التي ينتمي إليها كائن وما إذا كانت القيمة التي تم إرجاعها ستقع مباشرة تحت القراءة أو الكتابة. لحل هذه المشكلة ، ترجع بنيات بيانات C ++ الارتباطات التي تشير إلى الذاكرة الموجودة في الحاوية ، والتي يمكن كتابتها أو قراءتها. قد يبدو تطبيق operator[] لـ std::vector مثل هذا.

  T& operator[](size_t idx) { return data_[idx]; } 

الحقيقة الأكثر أهمية هنا هي أننا نقوم بإرجاع كيان من النوع T& ، وهو رابط C ++ خام يمكنك من خلاله الكتابة ، وليس كيانًا من النوع T ، الذي يمثل فقط قيمة البيانات المصدر.

في حالتنا ، لا يمكننا إرجاع ارتباط C ++ خام ، لأننا نشير إلى الذاكرة الموجودة على عقدة أخرى ولا يتم تمثيلها في مساحة العنوان الافتراضية الخاصة بنا. صحيح ، يمكننا إنشاء كائنات مرجعية مخصصة خاصة بنا.
الارتباط هو كائن يعمل كالتفاف حول مؤشر ، ويؤدي وظيفتين مهمتين: يمكن تحويله إلى قيمة من النوع T ، ويمكنك أيضًا تعيينه إلى قيمة من النوع T لذلك ، في حالة وجود مرجع بعيد ، نحتاج فقط إلى تنفيذ مشغل تحويل ضمني يقوم بقراءة القيمة ، وأيضًا جعل عامل تعيين يكتب إلى القيمة.

 template <typename T> struct remote_ref { remote_ptr<T> ptr_; operator T() const { return rget(ptr_); } remote_ref& operator=(const T& value) { rput(ptr_, value); return *this; } }; 

وبالتالي ، يمكننا إثراء مؤشرنا البعيد بميزات قوية جديدة ، في حالة أنه يمكن إلغاء تأشيرته تمامًا مثل المؤشرات العادية.

 template <typename T> remote_ref<T> remote_ptr<T>::operator*() { return remote_ref<T>{*this}; } template <typename T> remote_ref<T> remote_ptr<T>::operator[](ptrdiff_t idx) { return remote_ref<T>{*this + idx}; } 

والآن ، استعدنا الصورة بأكملها التي توضح كيف يمكنك استخدام المؤشرات البعيدة كالمعتاد. يمكننا إعادة كتابة البرنامج البسيط أعلاه.

 void write_array(remote_ptr<int> ptr, size_t len) { if (my_rank() < len) { ptr[my_rank()] = my_rank(); } } 

بالطبع ، تتيح لنا واجهة برمجة تطبيقات المؤشر الجديدة كتابة برامج أكثر تعقيدًا ، على سبيل المثال ، وظيفة لإجراء الاختزال المتوازي استنادًا إلى شجرة [3]. تعتبر التطبيقات التي تستخدم فئة المؤشر البعيد أكثر أمانًا ونظافة من تلك التي يتم الحصول عليها عادةً باستخدام واجهة برمجة تطبيقات C الموصوفة أعلاه.

التكاليف الناشئة في وقت التشغيل (أو عدم وجودها!)


ومع ذلك ، ما سيكلفنا استخدام مثل هذا التجريد رفيع المستوى؟ في كل مرة نصل فيها إلى الذاكرة ، نسميها طريقة dereference ، ونعيد كائنًا متوسطًا يلتف المؤشر ، ثم ندعو مشغل التحويل أو مشغل المهمة الذي يؤثر على الكائن الوسيط. ما سيكلفنا في وقت التشغيل؟

اتضح أنه إذا قمت بتعيين فئات المؤشر والمرجع بعناية ، فلن يكون هناك أي حمل لهذا التجريد في وقت التشغيل - يقوم مترجمو C ++ الحديثة بمعالجة هذه الكائنات الوسيطة واستدعاءات الطريقة من خلال التضمين العدواني. لتقييم ما سيكلفه هذا التجريد ، يمكننا تجميع برنامج مثال بسيط والتحقق من كيفية انتقال التجميع لمعرفة الكائنات والطرق التي ستوجد في وقت التشغيل. في المثال الموصوف هنا مع الاختزال المستند إلى الأشجار والذي تم تجميعه مع فئات من المؤشرات والمراجع عن بُعد ، يعمل remote_read على تقليل الاختزال المستند إلى الأشجار إلى عدة remote_write [4]. يتم استدعاء أساليب فئة لا توجد كائنات مرجعية في وقت التشغيل.

التفاعل مع مكتبات بنية البيانات


يتذكر مبرمجو C ++ المتمرسون أن مكتبة قوالب C ++ القياسية: يجب أن تدعم حاويات STL مخصصات C ++ المخصصة . يسمح لك Allocators بتخصيص الذاكرة ، ومن ثم يمكن الإشارة إلى هذه الذاكرة باستخدام أنواع المؤشرات التي نتخذها. هل هذا يعني أنه يمكنك ببساطة إنشاء "مخصص عن بعد" وتوصيله لتخزين البيانات في الذاكرة البعيدة باستخدام حاويات STL؟

لسوء الحظ ، لا. من المفترض ، لأسباب تتعلق بالأداء ، أن معيار C ++ لم يعد يتطلب دعمًا لأنواع المراجع المخصصة ، وفي معظم تطبيقات المكتبة القياسية C ++ لم تكن مدعومة بالفعل. لذلك ، على سبيل المثال ، إذا كنت تستخدم libstdc ++ من GCC ، فيمكنك اللجوء إلى مؤشرات مخصصة ، ولكن في نفس الوقت يمكنك استخدام ارتباطات C ++ العادية فقط ، والتي لا تسمح لك باستخدام حاويات STL في الذاكرة البعيدة. تحتوي بعض مكتبات قوالب C ++ عالية المستوى ، على سبيل المثال ، Agency ، التي تستخدم أنواع المؤشر المخصصة وأنواع المراجع ، على تطبيقاتها الخاصة لبعض هياكل البيانات من STL والتي تسمح لك حقًا بالعمل مع أنواع المراجع عن بُعد. في هذه الحالة ، يحصل المبرمج على قدر أكبر من الحرية في طريقة مبتكرة لإنشاء أنواع من أدوات التخصيص والمؤشرات والروابط ، بالإضافة إلى ذلك ، يحصل على مجموعة من هياكل البيانات التي يمكن استخدامها تلقائيًا معهم.

سياق واسع


في هذه المقالة ، عالجنا عددًا من المشكلات الأوسع نطاقًا والتي لم يتم حلها بعد.

  • تخصيص الذاكرة . والآن بعد أن أصبح بإمكاننا الرجوع إلى الأشياء الموجودة في الذاكرة البعيدة ، كيف يمكننا حجز هذه الذاكرة البعيدة أو تخصيصها؟
  • دعم للكائنات . ماذا عن التخزين في الذاكرة البعيدة لهذه الأشياء ذات الأنواع الأكثر تعقيدًا من int؟ هو دعم أنيق لأنواع معقدة ممكن؟ هل يمكن دعم الأنواع البسيطة في نفس الوقت دون إضاعة الموارد على التسلسل؟
  • تصميم هياكل البيانات الموزعة . الآن وقد أصبحت لديك هذه التجريدات ، ما هي هياكل البيانات والتطبيقات التي يمكنك إنشاؤها معها؟ ما هي التجريدات التي يجب استخدامها لتوزيع البيانات؟

الملاحظات


[1] في UPC ، تحتوي المؤشرات على مرحلة تحدد الترتيب الذي سيتم توجيه المؤشر إليه بعد الزيادة بواحد. بسبب المراحل ، يمكن تغليف الصفائف الموزعة في مؤشرات ، ويمكن أن تكون أنماط التوزيع فيها مختلفة للغاية. هذه الميزات قوية للغاية ، لكنها قد تبدو ساحرة للمستخدم المبتدئ. على الرغم من أن بعض أدوات UPC تفضل هذا النهج ، إلا أن الطريقة الأكثر منطقية المنحى هي كتابة فئة مؤشر بعيد بسيطة أولاً ثم التأكد من تخصيص البيانات بناءً على هياكل البيانات المصممة خصيصًا لهذا الغرض.

[2] تتم كتابة معظم التطبيقات في HPC بأسلوب SPMD ، وهذا الاسم يعني "برنامج واحد ، بيانات مختلفة." تقدم واجهة برمجة تطبيقات SPMD وظيفة أو متغير my_rank() يخبر العملية التي تنفذ البرنامج مرتبة أو my_rank() فريدًا ، استنادًا إلى أنه يمكن بعد ذلك أن يتفرع من البرنامج الرئيسي.

[3] فيما يلي تخفيض بسيط للأشجار مكتوب بنمط SPMD باستخدام فئة المؤشر البعيد. يتم تكييف الرمز بناءً على برنامج كتبه في الأصل زميلي أندرو بيلت .

  template <typename T> T parallel_sum(remote_ptr<T> a, size_t len) { size_t k = len; do { k = (k + 1) / 2; if (my_rank() < k && my_rank() + k < len) { a[my_rank()] += a[my_rank() + k]; } len = k; barrier(); } while (k > 1); return a[0]; } 

[4] النتيجة المترجمة للرمز أعلاه يمكن العثور عليها هنا .

Source: https://habr.com/ru/post/ar467421/


All Articles