قبل التاريخ
مرة واحدة ، بدأت محادثة مع زميل حول تحسين أدوات العمل مع إشارات البت في تعدادات C ++. في ذلك الوقت ، كان لدينا بالفعل وظيفة IsEnumFlagSet ، والتي تأخذ المتغير الذي تم اختباره كوسيطة أولى والمجموعة الثانية من العلامات للتحقق. لماذا هو أفضل من القديم bitwise و؟
if (IsEnumFlagSet(state, flag)) { }  
في رأيي - سهولة القراءة. نادراً ما أتعامل مع إشارات البت وعمليات البت بشكل عام ، لذلك عند عرض رمز شخص آخر ، من الأسهل بكثير إدراك أسماء الوظائف المعتادة مقارنةً بـ cryptic & و | التي تستدعي على الفور النافذة الداخلية.alert () بعنوان "انتباه"! قد يكون هناك نوع من السحر يحدث ".
قليلا من الحزنلسوء الحظ ، لا يزال الإصدار C ++ لا يدعم أساليب الامتداد (على الرغم من وجود 
اقتراح مماثل بالفعل) - وإلا ، على سبيل المثال ، ستكون الطريقة la std :: bitset خيارًا مثاليًا:
 if (state.Test(particularFlags)) {} 
 تتفاقم سهولة القراءة بشكل خاص أثناء عمليات وضع العلامات أو إزالتها. قارن:
 state |= flag;  
أثناء المناقشة ، تم التعبير عن الفكرة أيضًا لإنشاء 
SetEnumFlag(state, flag, isSet) : اعتمادًا على الوسيطة الثالثة ، ستقوم 
state إما برفع علامات أو مسحها.
نظرًا لأنه كان من المفترض أن يتم تمرير هذه الوسيطة في 
RaiseEnumFlag/ClearEnumFlag ، فمن الواضح أنه لا يمكنك الاستغناء عن الحمل مقارنة 
RaiseEnumFlag/ClearEnumFlag . ولكن من أجل الاهتمام الأكاديمي ، أردت تقليله عن طريق النزول إلى 
المنجم إلى شيطان التحسينات الصغيرة.
تطبيق
1. تنفيذ ساذج
أولاً ، نقدم التعداد لدينا (لن نستخدم فئة التعداد لتبسيط):
 #include <limits> #include <random> enum Flags : uint32_t { One = 1u << 1, Two = 1u << 2, Three = 1u << 3, OneOrThree = One | Three, Max = 1u << 31, All = std::numeric_limits<uint32_t>::max() }; 
والتنفيذ نفسه:
 void SetFlagBranched(Flags& x, Flags y, bool cond) { if (cond) { x = Flags(x | y); } else { x = Flags(x & (~y)); } } 
2. Microoptimization
إن التطبيق الساذج له تباعد واضح ، أود نقله إلى الحساب ، والذي نحاول القيام به الآن.
أولاً ، نحتاج إلى تحديد بعض التعبيرات التي تتيح لنا التبديل من نتيجة إلى أخرى بناءً على المعلمة. على سبيل المثال
 (x | y) & ¬p 
- عندما p = 0نرفع الأعلام:
 
  (x | y) & ¬0 ≡ (x | y) & 1 ≡ x | y
 
 
- عند p = yتتم إزالة العلامات:
 
  (x | y) & ¬y ≡ (x & ¬y) | (y & ¬y) ≡ (x & ¬y) | 0 ≡ x & ¬y
 
 
الآن نحن بحاجة إلى "حزم" بطريقة أو بأخرى في الحساب التغيير في قيمة المعلمة اعتمادًا على متغير 
cond (تذكر - المتفرعة محظور).
دع 
p = y مبدئيًا ، وإذا كان 
cond صحيحًا ، فحاول إعادة تعيين 
p ، إذا لم يكن كذلك ، فاترك كل شيء كما هو.
لن نتمكن من العمل مباشرة مع متغير 
cond : عند التحويل إلى النوع الحسابي ، إذا كان ذلك صحيحًا ، فإننا نحصل على وحدة واحدة فقط بالترتيب المنخفض ، ومن الناحية المثالية نحن بحاجة إلى الحصول على وحدات في جميع وحدات البت (UPD: 
لا يزال بإمكانك ). نتيجة لذلك ، لم يتبادر إلى الذهن أي شيء أفضل من استخدام تحولات bitwise.
نحدد مقدار التحول: لا يمكننا تحويل جميع وحدات البت لدينا على الفور حتى 
p إعادة تعيين المعلمة 
p في عملية واحدة ، لأن المعيار يتطلب أن يكون مقدار التحول أقل من حجم الكتابة.
ليس له ما يبررهعلى سبيل المثال ، يقول أمر اليسار الحسابي shift (SAL) في وثائق asm "نطاق العد محدد من 0 إلى 31 (أو 63 في حالة استخدام وضع 64 بت و REX.W)"
 لذلك ، نحسب الحد الأقصى لحجم التحول ، وكتابة التعبير الأولي
 constexpr auto shiftSize = sizeof(std::underlying_type_t<Flags>) * 8 - 1; (x | y) & ~ ( y >> shiftSize * cond); 
ومعالجة بشكل منفصل بت ترتيب منخفض نتيجة التعبير 
y >> shiftSize * cond :
 (x | y) & ~ (( y >> shiftSize * cond) & ~cond); 
تم 
shiftSize * cond في 
shiftSize * cond - وفقًا 
shiftSize * cond أو الحقيقي في cond ، ستكون قيمة التحول إما 0 أو 31 ، على التوالي ، وستكون المعلمة لدينا تساوي 
y أو 0.
ماذا يحدث عندما 
shiftSize = 31 :
- مع cond = trueنقوم بتحويل البتاتyبمقدار 31 إلى اليمين ، ونتيجة لذلك تصبح البتة الأهم منyالأقل أهمية ، وتتم إعادة تعيين الباقي إلى صفر. في~condالعكس~condذلك ، فإن البتة الأقل دلالة هي 0 ، والباقي كلها واحدة. الضرب في هذه القيم سوف يعطي 0 نظيفة.
- عندما يكون cond = falseلا يحدث أي تحول ، في حين تحتوي~condفي جميع الأرقام على 1 ، وسيمنح ضرب القيم في هذه القيمy.
أود أن أشير إلى المفاضلة لهذا النهج ، وهو أمر غير واضح على الفور: دون استخدام الفروع ، نحسب 
x | y x | y (أي ، أحد فروع الإصدار الساذج) في 
أي حال ، وبعد ذلك ، بسبب العمليات الحسابية "الإضافية" ، نقوم بتحويلها إلى النتيجة المرجوة. وكل هذا منطقي إذا كانت النفقات الإضافية للحساب الإضافي أقل من المتفرعة.
لذلك ، كان القرار النهائي على النحو التالي:
 void SetFlagsBranchless(Flags& x, Flags y, bool cond) { constexpr auto shiftSize = sizeof(std::underlying_type_t<Flags>) * 8 - 1; x = Flags((x | y) & ~(( y >> shiftSize * cond) & ~cond)); } 
(يكون حجم الإزاحة أكثر دقة للقراءة من خلال 
std::numeric_limits::digits ، 
راجع التعليق )
3. مقارنة
بعد أن نفذت الحل دون تفرع ، ذهبت إلى 
quick-bench.com للتأكد من مصلحته. من أجل التطوير ، نستخدم clang بشكل أساسي ، لذلك قررت تشغيل المعايير (clang-9.0). ولكن بعد ذلك انتظرني مفاجأة ...

وهذا مع -O3. بدون تحسينات ، الأمر أسوأ. كيف حدث ذلك؟ على من يقع اللوم وماذا يفعل؟
نحن 
نأمر "نضع الذعر جانباً!" 
وانتقل إلى فهم 
godbolt.org (يوفر المقعد السريع أيضًا قائمة asm ، لكن يبدو godbolt أكثر ملاءمة في هذا الصدد).
بعد ذلك ، سنتحدث فقط عن مستوى التحسين -O3. إذن ، ما هي الشفرة التي تولدها clang لتنفيذنا الساذج؟
 SetFlagBranched(Flags&, Flags, bool): # @SetFlagBranched(Flags&, Flags, bool) mov eax, dword ptr [rdi] mov ecx, esi not ecx and ecx, eax or eax, esi test edx, edx cmove eax, ecx mov dword ptr [rdi], eax ret 
ليس سيئا ، أليس كذلك؟ يعرف Clang أيضًا كيفية المفاضلة ، ويفهم أنه سيكون من الأسرع استخدام أوامر الانتقال الشرطي لحساب 
كلا الفرعين واستخدام أمر النقل الشرطي ، والذي 
لا يتضمن تنبؤ الفرع في العمل.
كود التنفيذ بدون فروع:
 SetFlag(Flags&, Flags, bool): # @SetFlag(Flags&, Flags, bool) mov eax, dword ptr [rdi] or eax, esi test edx, edx mov ecx, 31 cmove ecx, edx shr esi, cl not esi or esi, edx and esi, eax mov dword ptr [rdi], esi ret 
"بلا فروع" تقريبًا - كما فعلت ، طلبت الضرب المعتاد هنا ، وأنت ، يا صديقي ، أحضرت خطوة مشروطة. ربما يكون المحول البرمجي صحيحًا ، وسيكون اختبار + cmove في هذه الحالة أسرع من imul ، لكنني لست جيدًا في المجمّع (الأشخاص ذوو المعرفة ، أخبرني ، من فضلك ، في التعليقات).
هناك شيء آخر مثير للاهتمام - في الواقع ، بالنسبة لكلتا العمليتين بعد التحسينات ، لم يولد المترجم ما طلبناه بالضبط ، ونتيجة لذلك حصلنا على شيء ما بين: cmove يُستخدم في كلا الخيارين ، لدينا فقط الكثير من العمليات الحسابية الإضافية في التطبيق دون الفروع ، والتي تفوق على المعيار.
يستخدم Clang من الإصدار الثامن والأقدم بشكل عام انتقالات مشروطة حقيقية ، "بسبب" يصبح الإصدار "branchless" أبطأ مرة ونصف تقريبًا:
 SetFlag(Flags&, Flags, bool): # @SetFlag(Flags&, Flags, bool) mov eax, dword ptr [rdi] or eax, esi mov cl, 31 test edx, edx jne .LBB0_2 xor ecx, ecx .LBB0_2: shr esi, cl not esi or esi, edx and eax, esi mov dword ptr [rdi], eax ret 
ما الاستنتاج الذي يمكن تحقيقه؟ بالإضافة إلى "عدم الانخراط في microoptimization الواضح دون داعٍ" ، ما لم يكن بإمكانك دائمًا النصح بالتحقق من نتيجة العمل في رمز الجهاز ، فقد يتضح أن المحول البرمجي قام بتحسين الإصدار الأولي بما فيه الكفاية بالفعل ، وأن تحسيناتك "المبدعة" لن تفهمها ، وعلى الرغم من ذلك ، فسوف تتأملها التحولات بدلا من الضرب.
في هذه المرحلة سيكون من الممكن الانتهاء ، إن لم يكن لأحد "لكن". إن رمز gcc للتطبيق الساذج مطابق لرمز clang ، ولكن النسخة بدون فروع هي::
 SetFlag(Flags&, Flags, bool): movzx edx, dl mov eax, esi or eax, DWORD PTR [rdi] mov ecx, edx sal ecx, 5 sub ecx, edx shr esi, cl not esi or esi, edx and esi, eax mov DWORD PTR [rdi], esi ret 
احترامي للمطورين لمثل هذه الطريقة الأنيقة لتحسين تعبيرنا دون استخدام إما 
imul أو 
cmove . ما يحدث هنا: يتم تبديل bit bool المتغير bwise إلى اليسار بخمسة أحرف (لأن نوع التعداد لدينا هو uint32_t ، وحجمه 32 بت ، أي 100000 
2 ) ، ثم يتم طرحه من النتيجة. وبالتالي ، نحصل على 11111 
2 = 31 
10 في حالة cond = true ، و 0 على خلاف ذلك. وغني عن القول أن مثل هذا الخيار أسرع من الخيار الساذج ، حتى مع الأخذ في الاعتبار تحسين الحركة الشرطية؟

حسنًا ، كانت النتيجة غريبة جدًا - وفقًا للمترجم ، يمكن أن يكون الخيار بدون فروع إما أسرع أو أبطأ من التنفيذ مع الفروع. دعونا نحاول مساعدة clang وتحويل تعبيرنا باستخدام طريقة gcc (في نفس الوقت ، قم بتبسيط الجزء 
~((y >> shiftSize * cond) & ~cond) وفقًا لـ de Morgan - يتم ذلك عن طريق clang و gcc):
 void SetFlagVerbose(Flags& x, Flags y, bool b) { constexpr auto shiftSize = sizeof(std::underlying_type_t<Flags>) + 1; x = Flags( (x | y) & ( ~(y >> ((b << shiftSize) - b)) | b) ); } 
مثل هذا التلميح له تأثير فقط على نسخة الجذع من clang ، حيث يُنشئ فعليًا رمزًا مشابهًا لـ gcc (على الرغم من أنه في "بلا فرع" الأصلي ، يكون نفس الاختبار + cmove)
ماذا عن MSVC؟ في كلا الإصدارين ، وبدون تشعب ، يتم استخدام imul صادق (لا أعرف مدى سرعة / أبطأ بكثير من الخيار clang / gcc - مقعد سريع لا يدعم هذا المحول البرمجي) ، وفي الوثب الساذج ظهرت قفزة مشروطة. حزين ولكن صحيح.
النتائج
ربما يمكن استنتاج أن الاستنتاج الرئيسي هو أن نوايا المبرمج في الكود العالي المستوى لا تنعكس دائمًا في كود الآلة - وهذا يجعل التقسيمات المجهرية بلا جدوى بدون علامات مرجعية وعرض القوائم. بالإضافة إلى ذلك ، يمكن أن تكون نتيجة التنبيهات المصغرة إما أفضل أو أسوأ من الإصدار المعتاد - كل هذا يتوقف على المترجم ، والذي يمكن أن يكون مشكلة خطيرة إذا كان المشروع متعدد المنصات.