المسارات
/
x86-64 Assembly
x86-64 Assembly
/
المنهج
/
الكود بلا تفرّع
ال

الكود بلا تفرّع في x86-64 Assembly

1 تمرين

نبذة عن الكود بلا تفرّع

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

لكن المعالجات الحديثة سريعة وقادرة على تنفيذ تعليمات كثيرة على التوازي. سيكون من المكلف جدًا إيقاف كل العمل بينما ينتظر المعالج التحقق من شرط في وقت التشغيل.

لهذا لا ينتظر المعالج داخليًا. بل يستخدم متنبئ التفريع لتخمين نتيجة الشرط، ويبدأ في تنفيذ المسار المتوقع تخمينيًا.

إذا كان التوقع صحيحًا، يمضي التنفيذ كما لو أنه لا يوجد تفريع. أما إذا كان التوقع خاطئًا، فعلى المعالج أن يتخلص من العمل المنجز على المسار المتوقع وأن يبدأ من جديد على المسار الصحيح.

يُسمى هذا خطأ توقع التفريع، وهو مكلف، لأنه يسبب تأخيرًا.

عندما يصعب توقع تفريع ما، تتراكم هذه الأخطاء. في تلك الحالات، يُفضَّل أحيانًا تجنّب التفريع تمامًا، بحيث تُنفَّذ التعليمات نفسها بغض النظر عن الشرط. ويُسمى الكود الذي يختار بين القيم دون استخدام jcc كودًا بلا تفريع.

ثمة أساليب عدة لبناء الكود بحيث يتفرّع أقل، أو بحيث تصبح التفريعات التي يحتوي عليها أكثر قابلية للتوقع. وعلى وجه الخصوص، توفّر معمارية x86-64 عائلتين من التعليمات تُستخدمان عادةً لكتابة الكود بلا تفريع.

النقل الشرطي

تنقل عائلة التعليمات cmovcc المعامل المصدر إلى الوجهة فقط إذا تحقق شرط معين. وإلا فتبقى الوجهة دون تغيير.

تتبع اللاحقة cc التسمية نفسها المتبعة في jcc، وبالمعنى نفسه:

التعليمة النقل إذا
cmove A == B بعد cmp A, B
cmovne A != B بعد cmp A, B
cmovl A < B (موقّع) بعد cmp A, B
cmovb A < B (غير موقّع) بعد cmp A, B
cmovg A > B (موقّع) بعد cmp A, B
cmova A > B (غير موقّع) بعد cmp A, B

والقواعد مشابهة لتلك الخاصة بـ jcc:

  1. تُستخدم l و g للمقارنات الموقّعة، بينما تُستخدم b و a للمقارنات غير الموقّعة.
  2. إضافة e إلى أي لاحقة تُضمّن المساواة.
  3. إضافة n تنفي الشرط.
  4. وهناك أيضًا صيغ تشير إلى ضبط راية ما. على سبيل المثال، cmovz و cmovc.

لنتأمل حساب القيمة المطلقة لعدد صحيح موقّع في rdi، تُرجع في rax. عند كتابته باستخدام jcc، تختار الدالة أحد مسارين:

abs_branch:
    mov rax, rdi
    cmp rax, 0
    jge .done
    neg rax
.done:
    ret

وعند كتابته باستخدام cmovcc، تُحسب القيمتان المرشحتان دون شرط، ويختار النقل الشرطي إحداهما:

abs_branchless:
    mov rax, rdi
    neg rax            ; rax = -rdi
    cmp rdi, 0
    cmovge rax, rdi    ; if rdi >= 0, rax = rdi
                       ; otherwise rax stays = -rdi
    ; now rax = abs(rdi)
    ret

ينفّذ الإصدار بلا تفريع التعليمات نفسها دائمًا. فلا وجود لأي jcc ليخمّنه المتنبئ.

يجب أن تكون وجهة cmovcc سجلًا. وقد يكون المصدر سجلًا أو موقعًا في الذاكرة، لكنه لا يكون قيمة فورية.

Note

في الكود أعلاه، تضبط التعليمة neg عدة رايات وفقًا للنتيجة، ومنها راية الإشارة (SF). وهذا يعني أنه يمكن الاستغناء عن cmp rdi, 0 تمامًا:

abs_branchless:
    mov rax, rdi
    neg rax            ; rax = -rdi. `neg` sets SF = 1 if the result is negative
    cmovs rax, rdi     ; if SF = 1 (i.e., rax < 0), replace rax with rdi
                       ; otherwise rax stays = -rdi, which is non-negative
    ; now rax = abs(rdi)
    ret

بالنسبة إلى rdi الموجب، ينتج neg نتيجة سالبة وتصبح SF = 1، فتستعيد cmovs قيمة rdi. وبالنسبة إلى rdi المساوي صفرًا أو السالب، ينتج neg نتيجة غير سالبة وتصبح SF = 0، فيحتفظ rax بالقيمة المنفية (وهي القيمة المطلقة الصحيحة).

ورغم أن cmp هي الوسيلة الأساسية لمقارنة القيم في كود التجميع، فإن تعليمات كثيرة تضبط الرايات أيضًا وفقًا لنتائجها. وعادةً ما تُدرج القائمة الكاملة للرايات التي تؤثر فيها التعليمة في المرجع الخاص بها. وبالنسبة إلى neg تحديدًا، فهي SF و ZF و CF و OF و PF.

الضبط الشرطي

تضبط عائلة التعليمات setcc المعامل الوجهة على 1 إذا تحقق شرط معين، وعلى 0 في غير ذلك. والوجهة دائمًا معامل بحجم 8 بت.

تتبع اللاحقة cc التسمية نفسها المتبعة في jcc و cmovcc. على سبيل المثال، تضبط setz الوجهة على 1 إذا كان ZF == 1، وعلى 0 في غير ذلك.

ولأن الوجهة بحجم 8 بت، فمن الشائع أن تتبع setcc التعليمة movzx عند الحاجة إلى قيمة أوسع:

cmp rdi, rsi
setg al             ; al = 1 if rdi > rsi (signed), 0 otherwise
movzx eax, al       ; eax (and rax) = 1 or 0, with the upper bits cleared

وهذا أسلوب شائع لتحويل نتيجة مقارنة إلى عدد صحيح يساوي 0 أو 1.

متى تستخدم الكود بلا تفريع

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

وعدّاد الحلقة هو المثال النموذجي. لنتأمل دالة تجمع rsi من الأعداد الصحيحة ذات 64 بت بدءًا من rdi:

sum_array:
    xor rax, rax
.loop:
    add rax, [rdi]
    add rdi, 8
    dec rsi
    jnz .loop          ; taken on every iteration except the last
    ret

هذا بالضبط نوع الأنماط التي تتعامل معها متنبئات التفريع ببراعة، لذا يكون التفريع شبه مجاني بغض النظر عن عدد العناصر التي تعالجها sum_array.

ويكون الكود بلا تفريع مجديًا عندما يصعب توقع التفريع، أو عندما يلزم توقيت ثابت بغض النظر عن المدخلات.

Advanced

يمكن العثور على مرجع وافٍ عن توقع التفريع في معالجات x86-64 في الفصل الثالث من هذا الكتاب المرجعي.

تعديل عبر GitHub يفتح الرابط في نافذة أو علامة تبويب جديدة

تعلّم الكود بلا تفرّع