سنقوم اليوم بقياس أداء التطبيقات المختلفة لوظيفة toupper ، لأن هذا ما يفعلونه كل يوم ثلاثاء.
في الواقع ، لا أهتم بوظيفة
toupper ، لقد كتبت للتو وظيفة أخرى مؤخرًا وكنت بحاجة إلى نوع من النواة الشائعة ، ويبدو أن
toupper مرشح مثير للاهتمام وغير ضار للمعايير. حاولت أن أختار شيئًا بسيطًا بقدر الإمكان لن يدفعني جانباً ، لكن حدث ما حدث في هذا الاختبار أنني واجهت مشكلة غريبة.
ستكون هذه المشاركة صغيرة - من المتوقع أن يتم قريباً نشر مقال أكثر شمولًا حول الموضوع الأصلي ، وربما أكثر إثارة للاهتمام. إذا كنت ترغب في إعادة إنتاج النتائج معي ، يمكنك
أن تأخذ الكود المصدري
على جيثب .
لذلك ، دعونا نلقي نظرة على ثلاثة تطبيقات لوظيفة
toupper ، والتي تحول أحرف صفيف تتكون من عناصر type
char إلى أحرف كبيرة - وهي تأخذ صفيفًا كوسيطة وتغير عناصرها مباشرةً بحيث يتم تكبير الأحرف الكبيرة.
في التطبيق الأول ، ندعو ببساطة
الدالة toupper [1] للمكتبة القياسية C
وننفذ حلقة نمط C:
void toupper_rawloop(char* buf, size_t size) { for (size_t i = 0; i < size; i++) { buf[i] = toupper(buf[i]); } }
في التطبيق الثاني ، نستخدم نهجًا
أكثر حداثة مع استبدال الدورة الخام بـ
std :: convert :
void toupper_transform(char* buf, size_t size) { std::transform(buf, buf + size, buf, ::toupper); }
أخيرًا ، في التطبيق الثالث ، نستخدم خوارزمية خاصة تعمل مع أحرف ASCII. يتحقق ما إذا كان الحرف في النطاق
a - z ، وإذا نجح ، يستبدل نفس الحرف في الحالة العليا ، مع طرح الرقم 32 من رمز الحرف [2]:
void toupper_branch(char* buf, size_t size) { for (size_t i = 0; i < size; i++) { char c = buf[i]; if (c >= 'a' && c <= 'z') { buf[i] = c - 32; } } }
تبدو سهلة ، أليس كذلك؟
سنقوم الآن بقياس سرعة هذه التطبيقات على جهاز الكمبيوتر المحمول بواسطة معالج Skylake i7-6700HQ على برنامج التحويل البرمجي gcc 5.5 بالإعدادات الافتراضية. وترد النتائج في شكل مخطط مبعثر [3]:
على الفور ، سنتعامل مع ثلاثة أسئلة لا صلة لها بمهمتنا.
أولاً ، انظر إلى الرسم البياني لخوارزمية التفريع (كما هو موضح باللون الأخضر). يختلف اختلافًا كبيرًا وفقًا لحجم بيانات الإدخال - يبقى الرسمان الآخران مسطحين تقريبًا. هذا هو في الواقع مجرد قطعة أثرية الاختبار. يتم اختيار أحرف ASCII المدخلة بشكل عشوائي [4] ، وبالتالي ، فإن العامل الحاسم في حالة التنفيذ الثالث هو تشغيل خوارزمية تنبؤ الفرع. مع كمية صغيرة من البيانات ، فإنه يحفظ تماما تسلسل العناصر كما يتم تنفيذ التكرار ، وبالتالي فإن عدد يخطئ صغير وسرعة عالية ،
كما هو مبين في هذه الملاحظة . مع نمو حجم تسلسل البيانات ، تتذكر خوارزمية التنبؤ أقل وأقل حتى تبدأ أخيرًا في فقدان كل حرف كبير (0.27 حرف لكل حرف) ، ثم يتم تسوية الرسم البياني.
ثانياً ، انتبه إلى مجموعة البقع الخضراء في أعلى اليسار ، والتي تقابل سرعات أقل بكثير من المتغير مع
toupper_branch branching:
هذه ليست قطعة أثرية معزولة: ظهرت هذه البقع خلال عدة عمليات إطلاق. في الوقت نفسه ، لا يمكن إعادة إنتاجها إذا قمت باختبار الخوارزمية على وجه التحديد على أحجام البيانات هذه فقط - فهي تظهر فقط عند تشغيل الاختبار عبر جميع الأحجام. لكن في هذه الحالة ، لا تظهر دائمًا. لم أتطرق إليها بشكل خاص ، لكن يمكنني افتراض أن هذا يرجع إلى بعض تعارضات الأسماء أو الأسماء المستعارة في خوارزمية التنبؤ الفرعي أو عند تعيين الصفحات الفعلية لذاكرة 4 كيلوبايت إلى افتراضية (على الرغم من إيقاف تشغيل العشوائية لمساحة العنوان الافتراضية).
ثالثًا ، يبدو تنفيذ
toupper_rawloop (كما هو موضح باللون الأزرق) على الرسم البياني
كخطين منفصلين: أحدهما أعلى بقليل من علامة 2 مقاييس لكل حرف ، والآخر عند مستوى 1.5 مقاييس لكل حرف. ظهر هذان الخطان في جميع الاختبارات. الخيار الأسرع ، بسرعة 1.57 حرفًا في كل دورة ، يبطئ فعليًا على منافذ التنزيل: تحدث قراءة البيانات على المنفذين 2 و 3 بسرعة 1.54 من العمليات المصغرة لكل دورة ، بحيث تكون مشغولة بنسبة 98٪. لم أتمكن من تحديد سبب "النظام" الأبطأ.
بينما كنت أتعامل مع هذه القضية ، اختفى "النظام" السريع فجأة وبقي البطيء فقط. ربما أدرك المعالج ما كنت أحاول القيام به وقام بتنزيل التحديث سرا للرمز الصغير لإزالة التناقض ، لكن لدي (لا يزال) دليلًا - صورة متجهة بها رسوم بيانية.
ما الذي يهمنا إذن في هذا المثال؟
ولكن ما يهمنا هو أن الإصدار ذو الدورة الأولية أسرع من 3 إلى 4 مرات من الإصدار مع
std :: convert: 1.5-2 دورة لكل حرف مقابل 7 مع بضع دورات لكل حرف.
ما هي المسألة هنا؟ هل فشلت الخوارزميات القياسية؟ هل
الأمراض المنقولة جنسيا :: تحويل لديها أي عيب؟
ليس حقا بتعبير أدق ، ليس على الإطلاق.
اتضح أن هذه النتائج تظهر عندما يتم تجميع الوظائف في
ملفات مختلفة . إذا وضعتهم في نفس الملف ، يصبح أدائهم منخفضًا على حد سواء.
ولا ، لا علاقة للمحاذاة به.
ولكن هذا ليس كل شيء: النسخة السريعة ذات الدورة الأولية ، عندما يتم تجميعها في ملف منفصل ، تبطئ إذا قمت ببساطة بإرفاق ملف الرأس
<algorithm> به. نعم ، هذا صحيح: ما عليك سوى توصيل هذا الملف ، والذي لا يتم استخدامه مطلقًا ولا يُنشئ أي رمز في ملف الكائن النهائي ، وستنخفض سرعة الدورة "الأولية" 3-4 مرات. على العكس من ذلك ،
يتم تسريع الإصدار مع
std :: convert إلى الحد الأقصى إذا قمت بنسخ ولصق تطبيق
std :: convert من ملف
<algorithm> ، لكن لا تقم بتضمين هذا الملف.
الغايات لا تنتهي عند هذا الحد (لن يكون هناك المزيد ، فأعدك بذلك): بما في ذلك ملف
<algorithm> لا يؤدي دائمًا إلى التأثير الموصوف. يحدث انخفاض السرعة إذا كان
<algorithm> متصلًا قبل
<ctype.h> ، ولكن إذا قمت
بتبديلهما ، فلا:
رمز بطيء: #include <algorithm> #include <ctype.h>
كود سريع: #include <ctype.h> #include <algorithm>
في الواقع ، ظهر هذا الشذوذ في داخلي (في مشروع آخر) عندما قام clang-format تلقائيًا بفرز ملفات الرأس المضمنة ووضع
<algorithm> في بداية القائمة ، حيث ينتمي (ومن ثم رأس clickbait للمقال).
وبطبيعة الحال ، كان علينا أن نغرق في قائمة المجمّع عاجلاً أم آجلاً. لن نؤخر هذه اللحظة غير السارة.
يتم عرض الإصدارات
السريعة والبطيئة للوظائف [5] أدناه ، ويتم توفير حلقات صغيرة مع تعليقات توضيحية:
تتصل الخوارزمية أولاً: toupper_rawloop(char*, unsigned long): push rbp push rbx lea rbp, [rdi+rsi] sub rsp, 8 test rsi, rsi je .L1 mov rbx, rdi .L5: movsx edi, BYTE PTR [rbx] ; char- *buf add rbx, 1 ; buf++ call toupper ; toupper(c) mov BYTE PTR [rbx-1], al ; buf[-1] cmp rbp, rbx ; buf == buf_end jne .L5 ; .L1: add rsp, 8 pop rbx pop rbp ret
<الخوارزمية> متصلة الثانية: toupper_rawloop(char*, unsigned long): test rsi, rsi je .L7 push rbp push rbx mov rbp, rsi mov rbx, rdi sub rsp, 8 call __ctype_toupper_loc lea rsi, [rbx+rbp] mov rdi, rbx .L4: ; char- buf movsx rcx, BYTE PTR [rdi] ; toupper ; ( __ctype_toupper_loc) mov rdx, QWORD PTR [rax] ; buf++ add rdi, 1 ; toupper , ; mov edx, DWORD PTR [rdx+rcx*4] mov BYTE PTR [rdi-1], dl ; cmp rsi, rdi ; buf == end_buf jne .L4 ; add rsp, 8 pop rbx pop rbp .L7: rep ret
الفرق الرئيسي هو أنه في الإصدار البطيء ، يتم استدعاء وظيفة toupper ببساطة في حلقة ، بينما في النسخة السريعة ، تكون مكالمات الوظيفة غائبة تمامًا ، ولا يوجد سوى بحث في جدول المراسلات [6] ، أي
يتم استبدال نص الدالة
std :: toupper في مكان الاستدعاء.
إذا نظرت إلى
الكود المصدري لمكتبة glibc ، فإننا نجد تنفيذ وظيفة
toupper هناك:
__extern_inline int
كما نرى ، يُعرّف
toupper على أنه دالة
مضمّنة خارجية تتحقق أولاً من أن حجم الحرف char يتلاءم مع بايت واحد [7] ، ثم يبحث عن الحرف في جدول المراسلات الذي يتم إرجاعه بواسطة الدالة
__ctype_toupper_loc () . تقوم هذه الوظيفة بإرجاع مؤشر دفق محلي (من النوع
const int ** ) ، والذي بدوره يشير إلى جدول المراسلات ، والذي يتم من خلاله استجابة لطلب رمزنا ، إرجاع إصدار الحالة العلوي الخاص به [8].
أصبح من الواضح الآن ما يحدث في القائمة. في الإصدار السريع من الخوارزمية ، يستبدل المترجم
نص الدالة
toupper ، ولكن لا يمكن استبدال الاستدعاء
للدالة __ctype_toupper_loc () [9]. ومع ذلك ، يتم الإعلان عن هذه المكالمة كـ
__attribute __ ((const)) ، مما يعني أن قيمة الإرجاع تعتمد فقط على الوسيطات (التي ليست هنا). يعرف المترجم أن هذه الوظيفة تُرجع نفس القيمة في كل مرة ، وبالتالي تأخذ مكالمتها خارج الحلقة ، وفي الحلقة نفسها توجد فقط بضع عمليات قراءة مرتبطة بالوصول إلى جدول المراسلات ، وكتابة قيمة جديدة إلى المخزن المؤقت ، والتحكم في الحلقة.
في الإصدار البطيء ، تبقى الدعوة إلى
toupper () في نص الحلقة. إن الحلقة نفسها أقصر بأمر واحد ، لكن بالطبع ، لا يزال يتعين عليك الآن تنفيذ جميع التعليمات البرمجية داخل وظيفة
toupper . على نظامي ، يبدو كما يلي:
lea edx,[rdi+0x80] ; edx = rdi + 0x80 movsxd rax,edi ; c cmp edx,0x17f ; , c -128 255 ja 2a ; , mov rdx,QWORD PTR [rip+0x395f30] ; ; mov rdx,QWORD PTR fs:[rdx] ; ; mov rdx,QWORD PTR [rdx] ; ; mov rdx,QWORD PTR [rdx+0x48] ; toupper mov eax,DWORD PTR [rdx+rax*4+0x200] ; c 2a: ret
نظرًا لأن هذه مكالمة غير مضمنة ، فإن البرنامج يعمل أكثر. هناك ما لا يقل عن خمس عمليات متتالية للوصول إلى الذاكرة (ما يسمى السعي وراء المؤشرات ،
مطاردة المؤشر ). في الإصدار السريع ، بقي اثنان منهم فقط ، حيث يتم إخراج كل الآخرين من الحلقة. يجب أن يكون التأخير بين استدعاء وظيفة والخروج منها حوالي 25 دورة ، ولدينا حوالي 7 دورات تخرج - وهذا يعني أن المعالج كان قادرًا على موازنة المكالمة ، وهو أمر جيد جدًا ، نظرًا للظروف.
لماذا هذا هكذا؟
في سلسلة طويلة من ملفات التضمين ، تتضمن ملفات رأس C ++ ، مثل
<algorithm> ، بدورها ، ملف
<bits / os_defines.h> ، والذي يحتوي على السطر التالي:
عندما
يتم توصيل الملف
<ctype.h> أخيرًا ، بسبب هذا التوجيه ، لا يمكن تضمين التعليمات البرمجية التي
يتم تعريف
toupper على أنها
مضمنة خارجي :
#if !defined __NO_CTYPE # ifdef __isctype_f __isctype_f (alnum)
يرجى ملاحظة أنه عند الاتصال
<ctype.h> لا
يتم تعريف إصدار C ++ من
toupper على أنه ماكرو - الحد الأقصى كما هو
مضمّن خارجي - نظرًا لأن تعريفات وحدات الماكرو محمية بواسطة
التحقق! __cplusplus المحدد ، وبالتالي لن تسري أبدًا.
بشكل عام ، لا أعرف على وجه اليقين ما إذا كان
__NO_CTYPE في هذه الحالة يجب أن يستثني
جسدي وظائف
tolower و
toupper المعلنة
كخارجية مضمنة ، ولكن هذا هو بالضبط ما يحدث - وبالتالي انخفاض كبير في سرعة دورتنا. في الختام ، أستطيع القول أنه إذا قمت بتضمين
<cctype> بدلاً من
<ctype.h> (أي ، C ++ هو إصدار ملف الرأس C الذي يضع الوظائف في
مساحة std :: namesd ) ،
فسيعمل الرمز في هذه الحالة ببطء لأن
<cctype> يتضمن في النهاية
<bits / os_defines.h> .
هل كل هذا مهم؟ لا ، لا.
وظيفة
toupper غير مناسبة للعمل الجاد مع أحرف بلغات مختلفة ، لذلك إذا كنت بحاجة إلى معالجة أحرف ASCII فقط ، فيمكنك كتابة التنفيذ الأسرع. إذا كنت بحاجة إلى عمل جاد مع النص ، فمن المرجح أنك ستستخدم UTF-8 وتحتاج إلى استخدام نوع من وحدة العناية المركزة لدعم الإعدادات الإقليمية ، أو الانتظار حتى يظهر دعم Unicode في C ++ (قد يستغرق الانتظار وقتًا طويلاً) . لذا فإن عبارة "clang-format يمكن أن تسبب انخفاض أداء 4x" مناسبة فقط كرأس clickbait.
هل لوحظ هذا التأثير في جميع إصدارات libc؟ نعم ، في كل شيء تقريبًا ، لكن حتى هنا ليس بهذه البساطة.
النتائج الموضحة أعلاه صحيحة بالنسبة إلى gcc 5.5 و glibc 2.23 ، حيث أنني استخدمت هذه الإصدارات ، لكن هناك شيئًا جديدًا يحدث في الإصدارات الجديدة (بدءًا من حوالي glibc 2.27). هناك ، يؤدي تشغيل
<algorithm> قبل
<ctype.h> إلى إعطاء نفس التأثير ، ولكن
<stdlib.h> [10] الآن يخلق مشاكل أيضًا: إذا قمت
بتشغيله قبل
<ctype.h> ، فسوف ينخفض الأداء أيضًا ، وهو ليس كذلك لوحظ في الإصدارات السابقة. من الواضح ، في الإصدارات الأحدث ، يحتوي الملف
<stdlib.h> أيضًا على تعريف
__NO_CTYPE . على الأقل ، لن يكون من الممكن الآن إلقاء اللوم على تنسيق clang للفرز - هنا يمكن أن يساعد في حل المشكلة (في حالة عدم وجود ملفات مشاكل أخرى في قائمة الملفات المتصلة).
لقد نشرت
تقريرًا عن الأخطاء في libc ، لذا فمن المحتمل أن يتم إصلاح هذا الخطأ ، ولكن لا شك في أن الأخطاء المتعلقة بالترتيب الذي تتصل به ملفات الرأس ستزيد من قلقنا.
تعليقات
ليس لدي نظام للتعليق على موقعي ، لكنني أعمل على ذلك (وهذا يعني ، بشكل دوري ، من الصعب تقديم تعليقات على موقع ثابت).
في هذه الأثناء ، يمكنك مناقشة هذه المقالة على موقع
Hacker News على الويب أو
lobste.rs .
شكر
بفضل مستخدم ufo مع Hacker News ، الذي
أوضح أنه ليس من الضروري استخدام وظيفة lambda لتكييف
std :: toupper للاستخدام في
std :: convert ، وأيضًا إلى Jonathan Muller الذي
أوضح أنه لا تزال هناك حاجة إلى وظيفة lambda.
- نعم ، وظيفة toupper (3) من ملف الرأس <ctype.h> غير مناسبة للعمل مع معظم الأحرف غير ASCII ، مثل لا يمكن التعامل مع الأحرف الأكبر من بايت واحد ، لكنها مناسبة لمهمتنا ، حيث أننا سنمرر سلاسل أحرف ASCII فقط إليها.
- في جدول ASCII ، توجد أحرف الأحرف الكبيرة والصغيرة في مكان ملائم جدًا - على مسافة 32 موضعًا من بعضها البعض ، مما يعني أنه يمكنك نقل الأحرف من حالة إلى أخرى ببساطة بطرح أو إضافة 32. بشكل عام ، إذا علمنا بالتأكيد أن جميع المدخلات البيانات هي أحرف ASCII ، يمكننا إعادة تعيين البت الخامس دون أي اختبارات (على سبيل المثال ، c & 0b11011111 ) لتحويل أي حرف كبير إلى أحرف صغيرة ، في حين أن هذا لن ينعكس في أحرف صغيرة. لكن من المحتمل أننا لا نعرف ، لذلك يتعين علينا التحقق مما إذا كانت الشخصية عبارة عن حرف ، حتى لا يتم كسر الأحرف غير الحرفية بطريق الخطأ مثل حرف char .
- يجب أن يطلق عليه مؤامرة مبعثرة مع إضافة "ضوضاء" إلى موقع النقاط. في الواقع ، يعد هذا مخططًا مبعثرًا عاديًا يتم فيه رسم المعلمة التي تهمنا (حجم بيانات الإدخال) على المحور السيني ، وتكون سرعة العمل على المحور ص (المقاييس لكل رمز - كلما انخفضت القيمة ، ارتفعت السرعة ). الميزة الرئيسية لهذا المخطط هي أنه بالنسبة لكل قيمة معلمة على المحور س ، يتم أخذ العينات عدة مرات: في هذه الحالة ، يتم تكرار الاختبار 10 مرات لكل حجم صفيف.
- وهي ، يتم اختيار الحروف بشكل عشوائي وموحد من النطاق [32 ، 127] ، وبالتالي فإن الحالة في الوظيفة ستكون صحيحة في حوالي 27 ٪ من الحالات.
- تشير كلتا القائمتين إلى تطبيق دورة أولية وتختلف فقط بالترتيب الذي يتم به تضمين ملفات <algorithm> و <ctype.h> . شفرة المصدر التي تم إنشاؤها هي نفسها لجميع التطبيقات - سواء في الإصدارات السريعة أو البطيئة. على سبيل المثال ، سينتج عن تنفيذ باستخدام std :: convert نفس رمز التجميع البطيء إذا قمت بتضمين ملف <algorithm> ، والرمز السريع نفسه إذا قمت بنسخ تعريف الوظيفة فقط وعدم تضمين الملف.
- ومع ذلك ، فإن هذه الحلقة السريعة أبطأ مما يمكن ؛ لأن المؤشر إلى جدول المراسلات يقرأ عدة مرات ( mov rdx ، QWORD PTR [rax] ) داخل الحلقة. قد يكون هذا المؤشر مختلفًا وفقًا للإعدادات الإقليمية ، ولكن لا يتم تحديثه أثناء تنفيذ الدورة وبالتالي يمكن نقله خارج الدورة. يجب أن يكون المترجم يعتقد أنه لا توجد أسباب كافية لذلك ، لأننا نكتب إلى مجموعة من عناصر النوع char ، ويمكن استخدامها من حيث المبدأ كأسماء مستعارة لـ [rax] أي مؤشر إلى الجدول. على أي حال ، حتى __restrict__ لن يساعد هنا. ولكن في إصدار آخر من الحلقة ، حيث تتم إضافة قيم toupper ببساطة ولا تتم كتابة أي شيء إلى المصفوفة ، يتم تطبيق هذا التحسين - يتم قراءة المؤشر خارج الحلقة.
- لا ينعكس هذا الاختبار في رمز المجمّع القابل للاستبدال ، لأن المترجم يعرف بالفعل أن قيم char موجودة دائمًا في النطاق [-128 ، 255] . يعد الاختيار ضروريًا فقط لأن واجهة برمجة التطبيقات للدالة toupper © تقبل قيمة type int بدلاً من char ، بحيث يمكن للمستخدم تمرير أي عدد مألوف من النوع int إليها ، بينما يتم تصميم جداول المراسلات لقيم type char فقط ، وبالتالي فإن الفحص يساعد على تجنب القراءة خارج المخزن المؤقت .
- بالمناسبة ، يوضح هذا سبب استقلالية إجراءات std :: toupper عن حجم بيانات الإدخال: فهي لا تستخدم الفروع (باستثناء عمليات التحقق من النطاق ، والتي يتم التنبؤ بها بشكل ملحوظ) ، ولكن تستخدم جدول مراسلات مستقل عن الفروع. ↵
- استبدال هذه المكالمة لن يعمل حتى مع وجود رغبة قوية للغاية: نص الوظيفة غير متوفر في ملف الرأس.
- لا أجد خطأً بأي حال من الأحوال مع stdlib.h (أو <خوارزمية> ، لهذه المسألة) - من المحتمل جدًا أن تتسبب العديد من ملفات رأس C الأخرى وجميع ملفات رأس C ++ أيضًا في حدوث هذا السلوك ، لم أختبرها. أنا مرتبطة stdlib.h فقط من أجل تحديد size_t .
المذكرة. تم نشر هذا المقال لأول مرة على موقع
"مسائل الأداء" . يتم نشر المقالات المترجمة هنا بإذن من المؤلف.