Single instruction, multiple data (SIMD) شكلٌ من أشكال التوازي على مستوى العتاد. فالمعالج ينفّذ الحساب نفسه على عناصر متعددة في الوقت نفسه.
لنأخذ مثالًا: جمع مصفوفتين، كل منهما مكوّنة من أربعة أعداد فاصلة عائمة من 32 بت، عنصرًا بعنصر.
ستُصدر طريقة تنفيذ عددية أربع تعليمات addss منفصلة، تعليمة واحدة لكل عنصر.
أما طريقة التنفيذ باستخدام SIMD فتستخدم تعليمة addps واحدة لإجراء عمليات الجمع الأربع كلها على التوازي.
وهذا يعني أربعة أضعاف العمل لكل تعليمة.
في لغة التجميع x86-64، تُنفَّذ تعليمات SIMD عادةً باستخدام مسجّلات xmm.
بعض هذه التعليمات تعمل على أعداد صحيحة.
وبعضها الآخر يعمل على أعداد الفاصلة العائمة.
وتُسمّى هذه الأعداد الصحيحة أو أعداد الفاصلة العائمة معبّأة. يشغل كل منها مساحةً داخل المسجّل نفسه تُسمّى المسار. تعمل تعليمات SIMD عادةً على التوازي على القيم الواقعة في الموضع نفسه من المسار في المعاملين المصدرين. وتُخزَّن نتيجة كل عملية في المسار ذي الموضع نفسه في المعامل الوجهة.
المسار ليس حدًّا صارمًا. بل هو تفسير يمنحه كل تعليمة لتسلسل من البايتات. يحتوي المعامل بعرض 128 بت على 16 بايتًا. وتبعًا للتعليمة، يمكن تفسير هذه البايتات على أنها 16 مسارًا من بايت واحد، أو 8 مسارات من بايتين، أو 4 مسارات من 4 بايتات، أو مسارين من 8 بايتات، أو حتى مسار واحد من 16 بايتًا.
على سبيل المثال، تجمع التعليمة addps قيم الفاصلة العائمة المعبّأة أحادية الدقة (4 بايتات).
وبالنسبة لهذه التعليمة، تُعدّ كل قطعة من 4 بايتات في المعامل مسارًا مختلفًا.
في معامل بعرض 128 بت (16 بايتًا):
| المعامل | المسار الرابع | المسار الثالث | المسار الثاني | المسار الأول |
|---|---|---|---|---|
| المصدر الأول | 1.5 | 2.1 | 6.2 | 3.4 |
| + | + | + | + | + |
| المصدر الثاني | 0.5 | 8.26 | -13.4 | 22.9 |
| = | = | = | = | = |
| النتيجة | 2.0 | 10.36 | -7.2 | 26.3 |
وفي المقابل، سيُفسَّر المعامل بعرض 128 بت على أنه يحتوي على مسارين في حالة mulpd.
وذلك لأن هذه التعليمة تُجري ضربًا معبّأ على قيم مزدوجة الدقة (8 بايتات):
| المعامل | المسار الثاني | المسار الأول |
|---|---|---|
| المصدر الأول | 4.2 | 2.1 |
| * | * | * |
| المصدر الثاني | 1.0 | 92.4 |
| = | = | = |
| النتيجة | 4.2 | 194.04 |
في هذا المفهوم سنركّز على مجموعة صغيرة مختارة من تعليمات SIMD.
وهي تعمل على قيم الفاصلة العائمة المعبّأة باستخدام مسجّلات xmm.
كقاعدة عامة، تأخذ تعليمات SIMD التي تعمل على قيم الفاصلة العائمة صورة المعاملين. ويتبع اسم كل منها بنية معيّنة:
add، mul، وما إلى ذلك).p للدلالة على أن التعليمة تعمل على بيانات معبّأة، في الموضع نفسه الذي كانت تشغله سابقًا s (الخاصة بالبيانات العددية).s للدقة الأحادية وd للدقة المزدوجة).لذا يُشتقّ الاسم المعبّأ من الاسم العددي باستبدال s (العددية) في وسط الرمز التذكيري بـ p (المعبّأة).
ويبقى اسم العملية ولاحقة الحجم دون تغيير:
| العددي | المعبّأ | المعنى |
|---|---|---|
addss |
addps |
جمع، دقة أحادية (مسارات من 32 بت) |
addsd |
addpd |
جمع، دقة مزدوجة (مسارات من 64 بت) |
mulss |
mulps |
ضرب، دقة أحادية |
mulsd |
mulpd |
ضرب، دقة مزدوجة |
تتبع تعليمات SIMD الحسابية القاعدة أعلاه. وفيما يلي بعض الأمثلة:
addps xmm0, xmm1 ; xmm0 = xmm0 + xmm1 (each 32-bit float at the same position)
subpd xmm2, xmm3 ; xmm2 = xmm2 - xmm3 (each 64-bit float at the same position)
mulps xmm4, xmm5 ; xmm4 = xmm4 * xmm5 (each 32-bit float at the same position)
divpd xmm6, xmm7 ; xmm6 = xmm6 / xmm7 (each 64-bit float at the same position)
توجد أيضًا تعليمات SIMD للتحويل بين قيم الفاصلة العائمة المعبّأة أحادية الدقة وقيم الفاصلة العائمة المعبّأة مزدوجة الدقة.
وهي تتبع قاعدة التسمية نفسها، مع استبدال كل s الخاصة بـ العددية بـ p الخاصة بـ المعبّأة:
| العددي | المعبّأ | المعنى |
|---|---|---|
cvtss2sd |
cvtps2pd |
تحويل من الدقة الأحادية إلى الدقة المزدوجة |
cvtsd2ss |
cvtpd2ps |
تحويل من الدقة المزدوجة إلى الدقة الأحادية |
توضّح هذه التعليمات ما ذكرناه سابقًا من أن المسار تفسير، لا حدّ صارم. ويختلف حجم المسار في المعامل المصدر عن المعامل الوجهة، ولذلك يختلف عدد المسارات فيهما.
في التعليمة cvtps2pd، يحتوي المصدر (ps) على 4 مسارات أحادية الدقة.
وتحتوي الوجهة (pd) على مسارين مزدوجي الدقة.
ولا يُقرأ إلا المساران الأدنيان من المصدر.
أما المساران الأعليان فيُتجاهلان:
| المعامل | المسار الرابع | المسار الثالث | المسار الثاني | المسار الأول |
|---|---|---|---|---|
المصدر بصورة ps
|
مُتجاهَل | مُتجاهَل | 2.5 | 1.5 |
الوجهة بصورة pd
|
2.5 | 1.5 |
في التعليمة cvtpd2ps، يحتوي المصدر (pd) على مسارين مزدوجي الدقة.
وتحتوي الوجهة (ps) على 4 مسارات أحادية الدقة.
ويُحوَّل كلا مساري المصدر ويُكتبان في المسارين الأدنيين من الوجهة.
ويُضبط المساران الأعليان من الوجهة على صفر:
| المعامل | المسار الرابع | المسار الثالث | المسار الثاني | المسار الأول |
|---|---|---|---|---|
المصدر بصورة pd
|
4.0 | 3.0 | ||
الوجهة بصورة ps
|
0 | 0 | 4.0 | 3.0 |
بعض الأمثلة:
cvtps2pd xmm0, xmm1 ; xmm0 = xmm1 as 2 doubles (low 2 lanes of xmm1 are read)
cvtpd2ps xmm0, xmm1 ; xmm0 = xmm1 as 2 floats (in low 2 lanes; upper 2 zeroed)
عُرضت تعليمات SIMD أعلاه جميعها بمعاملات من مسجّل إلى مسجّل. ويمكنها أيضًا أن تأخذ معامل ذاكرة كمصدر لها (ولكن ليس كوجهة).
ويُحدَّد عدد البايتات المقروءة من الذاكرة وفق التعليمة.
على سبيل المثال، تقرأ addps 16 بايتًا (4 أعداد فاصلة عائمة أحادية الدقة).
أما cvtps2pd، في المقابل، فلا تقرأ إلا 8 بايتات (عددين من الفاصلة العائمة أحاديَي الدقة)، لأن هذا كل ما تحتاجه من مدخلات.
addps xmm0, [rsi] ; xmm0 = xmm0 + 4 floats at [rsi]
mulpd xmm1, [rsi + 16] ; xmm1 = xmm1 * 2 doubles at [rsi + 16]
cvtps2pd xmm2, [rsi + 8] ; xmm2 = (2 floats at [rsi + 8]) converted to 2 doubles
عندما يكون معامل الذاكرة بعرض 16 بايتًا، وجب أن يكون محاذيًا لـ 16 بايت. والموقع المحاذي في الذاكرة هو موقع يكون عنوانه من مضاعفات حجم العملية (16 بايتًا في هذه الحالة).
لتعريف ذاكرة محاذية، يمكنك استخدام التوجيه align في القسم .data و.rodata، أو alignb في القسم .bss:
section .rodata
align 16
mem1: dd 4.0, 3.5, 2.0, -1.0
section .data
align 16
mem2: dq 6.0, 2.1
section .bss
alignb 16
mem3: resb 16
section .text
fn:
movaps xmm0, oword [rel mem1] ; loads 4 packed 32-bit floats in xmm0
movapd xmm1, oword [rel mem2] ; loads 2 packed 64-bit floats in xmm1
movaps oword [rel mem3], xmm0 ; stores 4 packed 32-bit floats in mem3
ret
لا تشترط معاملات الذاكرة الأصغر من 16 بايتًا، مثل المصدر ذي 8 بايتات في cvtps2pd، أي محاذاة.
هناك أربع تعليمات تنسخ 128 بت من قيم الفاصلة العائمة المعبّأة.
وهي تعمل بين مسجّلَي xmm، أو بين مسجّل xmm واحد وموقع في الذاكرة:
| التعليمة | الوصف |
|---|---|
movaps |
تنسخ بيانات معبّأة أحادية الدقة من موقع ذاكرة محاذٍ أو إليه |
movups |
تنسخ بيانات معبّأة أحادية الدقة من موقع ذاكرة غير محاذٍ أو إليه |
movapd |
تنسخ بيانات معبّأة مزدوجة الدقة من موقع ذاكرة محاذٍ أو إليه |
movupd |
تنسخ بيانات معبّأة مزدوجة الدقة من موقع ذاكرة غير محاذٍ أو إليه |
يمكن استخدام كل هذه التعليمات لنسخ القيم بين مسجّلَي xmm.
إذا تضمّنت العملية موقعًا في الذاكرة، اشترطت movaps وmovapd أن يكون محاذيًا.
وستُطلق movaps وmovapd خطأً إذا أُعطيَ لهما عنوان غير محاذٍ.
وتعملان أيضًا كفحص أمان يتحقق من أن تخطيط بياناتك هو ما تظنه.
في المقابل، يمكن استخدام movups وmovupd مع أي موقع في الذاكرة، محاذيًا كان أم غير محاذٍ.
بادئة الحجم لقيمة من 16 بايتًا في NASM هي oword.
ظلّ SIMD هدفًا لتطوير نشط في منظومة x86-64 على مدى عقود. ويجلب كل امتداد للمعمارية تعليمات جديدة أو استخدامات جديدة لتعليمات قديمة.
وعلى وجه الخصوص، الامتدادان AVX وAVX2 يُدخلان بعض التغييرات ذات الصلة.
أولًا، دعم مسجّلات SIMD بعرض 256 بت، وتُسمّى ymm.
وهي توسّع مسجّلات xmm المناظرة، تمامًا كما يحتوي rax على eax.
ثانيًا، وهما يتضمّنان دعمًا لصيغ بثلاثة معاملات لمعظم التعليمات التي تعمل على مسجّلات xmm (وymm).
ولا يشمل ذلك تعليمات SIMD فحسب، بل أيضًا التعليمات التي تعمل على قيم الفاصلة العائمة العددية.
وتتبع هذه الصيغ الترميز نفسه الذي يتبعه نظيرها في SSE، مع إضافة v قبلها:
vaddsd xmm0, xmm1, xmm2 ; xmm0 = xmm1 + xmm2 (scalar sum of 64-bit floats)
vmulps xmm1, xmm3, xmm4 ; xmm1 = xmm3 * xmm4 (packed multiplication of 32-bit floats)
vmovupd ymm5, yword [mem] ; this copies 32 bytes from the location at mem to ymm5
ثالثًا، خُفِّفت المحاذاة المطلوبة لمعاملات ذاكرة SIMD.
ولا تتطلّب هذه المحاذاة إلا عمليات النقل المحاذية صراحةً، مثل vmovaps.
في هذا المسار التعليمي، اقتصرنا على تعليمات ومسجّلات تصل إلى امتدادات SSE السابقة. وهذا يكفي لتعليم الأساسيات ويضمن التوافق مع مجموعة واسعة من المعالجات.
ومع ذلك، يدعم مشغّل الاختبارات AVX وAVX2 بالكامل، وحتى الامتدادات الأحدث التي تشكّل جزءًا من AVX-512. ولك مطلق الحرية في استخدامها.
من الممارسات الجيدة أن تكون متسقًا في استخدامك لصيغ SIMD. حاول ألا تخلط بين صيغ SSE وAVX للتعليمات نفسها.
إذا خلطت قاعدة الكود لديك بين هذه الصيغ، فمن الممارسات الجيدة استخدام vzeroupper بعد الانتهاء من عملك مع AVX وقبل استخدام SSE.
فهذا يقطع سلاسل الاعتماد ويتجنّب توقّفًا مكلفًا عند انتقال المعالج بين حالتَي AVX وSSE.
أنت تكتب الكود لنظام البيانات في مصنع لقطع السيارات. تنتقل مكوّنات المحرك والهيكل عبر عدد من محطات التشغيل الآلي. تُنتج كل محطة قياسات يجب دمجها ومقارنتها ومعايرتها وتطبيعها قبل أن تبدأ المرحلة التالية. والدوال التي تنفذها هنا هي دالة واحدة لكل محطة.
يجب إجراء الحسابات في هذا التمرين باستخدام تعليمات SIMD، لا باستخدام العمليات العددية المفردة.
يشغّل المصنع خطَّي تجميع متوازيين. في نهاية كل وردية، يبلّغ الخطان عن نواتجهما لأربع سلال من القطع في صورة أعداد مفردة الدقة. تعرض لوحة تحكم المشرف الناتج المدمج لكل سلة، لذا يُجمع التقريران سلة بسلة. يدير نظام التقارير المخازن الثلاثة جميعها، وهي دائمًا محاذية بمقدار 16 بايت.
عرّف دالة sum_yields تكتب، عند إعطائها النواتج لكل سلة من line_a وline_b، المجموع سلة بسلة في result.
الوسائط، بالترتيب، هي:
line_a: عنوان ذاكرة محاذٍ بمقدار 16 بايت ويحمل 4 أعداد عائمة بطول 32 بتline_b: عنوان ذاكرة محاذٍ بمقدار 16 بايت ويحمل 4 أعداد عائمة بطول 32 بتresult: عنوان ذاكرة محاذٍ بمقدار 16 بايت ستكتب فيه الدالة 4 أعداد عائمة بطول 32 بتلا تُرجع هذه الدالة أي قيمة، وإنما يُخزَّن الناتج في المخزن المؤقت الممرَّر (result):
line_a = {10.0, 20.0, 30.0, 40.0};
line_b = { 5.0, 15.0, 25.0, 35.0};
sum_yields(line_a, line_b, result);
// => result == {15.0, 35.0, 55.0, 75.0};
بين العمليات، تقيس محطة أخذ العينات القطع المشغولة آليًا وتقارنها بالأبعاد المستهدفة. تتدفق القيم المقيسة من مقياس دقيق إلى مخزن محاذٍ دائمًا بمقدار 16 بايت. تأتي القيم المستهدفة من حزمة أمر العمل، وهي لا تضمن المحاذاة. يُضرب الانحراف في معامل حساسية لكل محور قبل تمريره إلى فاحص التفاوتات اللاحق. في هذه المرحلة، يقرأ الفاحص اللاحق الانحراف مباشرةً من مسجّل SIMD بدلًا من الذاكرة.
عرّف دالة scaled_deviation تكتب، عند إعطائها measured وtarget وsensitivity، الانحراف المُقيَّس في result.
الوسائط، بالترتيب، هي:
measured: عنوان ذاكرة محاذٍ بمقدار 16 بايت ويحمل عددين عائمين بطول 64 بتtarget: عنوان ذاكرة يحمل عددين عائمين بطول 64 بت (المحاذاة غير مضمونة)sensitivity: عنوان ذاكرة يحمل عددين عائمين بطول 64 بت (المحاذاة غير مضمونة)result: عنوان ذاكرة لمخزن ستكتب فيه الدالة عددين عائمين بطول 64 بت (المحاذاة غير مضمونة)الانحراف المُقيَّس هو (measured - target) * sensitivity، مسارًا بمسار.
لا تُرجع هذه الدالة أي قيمة، وإنما يُخزَّن الناتج في المخزن المؤقت الممرَّر (result) في صورة عددين عائمين بطول 64 بت.
measured = {10.5, 20.5};
target = {10.0, 20.0};
sensitivity = { 2.0, 4.0};
scaled_deviation(measured, target, sensitivity, result);
// => result == {1.0, 2.0};
قبل دخول القطع إلى منطقة الفحص النهائي، تُعاد قراءات الأبعاد لدفعة من 4 قطع إلى الدقة المزدوجة وتُصحَّح لمراعاة الانزياح الحراري المعروف.
تصل القراءات الخام الأربع في صورة أعداد مفردة الدقة.
لكل صف من قراءتين، تحسب المعايرة reference / (raw - offset)، ثم تضرب هذا الناتج في معامل تحويل ثابت قيمته 0.5.
لاحظ أن المرجع والإزاحة متماثلان في صفَّي الدفعة. معامل التحويل متماثل في كل معايرة، لذا بدلًا من تمريره كوسيط، عليك تعريفه كثابت مُحزَّم بطول 16 بايت في الذاكرة.
عرّف دالة calibrate_batch تكتب، عند إعطائها دفعة raw، وreference، وoffset، ومخزن result، القيم المعايَرة في result.
الوسائط، بالترتيب، هي:
raw: عنوان ذاكرة يحمل 4 أعداد عائمة بطول 32 بت (المحاذاة غير مضمونة)reference: عنوان ذاكرة محاذٍ بمقدار 16 بايت ويحمل عددين عائمين بطول 64 بتoffset: عنوان ذاكرة محاذٍ بمقدار 16 بايت ويحمل عددين عائمين بطول 64 بتresult: عنوان ذاكرة محاذٍ بمقدار 16 بايت ستكتب فيه الدالة 4 أعداد عائمة بطول 64 بت (32 بايت إجمالًا)يجب أن تنفّذ الدالة، لكل صف من صفَّي الدفعة، ما يلي:
offset من الزوج المحوَّل.reference على الناتج.0.5، مسارًا بمسار.result.لا تُرجع هذه الدالة أي قيمة، وإنما يُخزَّن الناتج في المخزن المؤقت الممرَّر (result):
raw = {10.0, 20.0, 25.0, 50.0};
reference = {100.0, 100.0};
offset = { 5.0, 10.0};
calibrate_batch(raw, reference, offset, result);
// => result == {10.0, 5.0, 2.5, 1.25};
في نهاية الخط، لكل قطعة درجة جودة تحتاج إلى تطبيع قبل الإبلاغ عنها. تُضرب كل درجة في معامل الكسب الخاص بها لكل قطعة، ثم تُقسم على مقياس إنتاج خاص بكل محور. يحمل مخزن معاملات الكسب معامل كسب واحدًا لكل قطعة.
مقياس الإنتاج متماثل في الدورة كاملة ويُمرَّر في عنوان ذاكرة يحمل عددين عائمين بطول 64 بت. تُحدَّث الدرجات في مكانها: المخزن نفسه الذي كان يحمل الدرجات الخام يحمل الآن الدرجات المطبَّعة.
عرّف دالة normalize_scores تطبّع الدرجات في مكانها، عند إعطائها مصفوفة scores، ومصفوفة gains، وscale، وعددًا n.
الوسائط، بالترتيب، هي:
scores: عنوان ذاكرة محاذٍ بمقدار 16 بايت يحمل n من الأعداد العائمة بطول 64 بت (يُعدَّل في مكانه)gains: عنوان ذاكرة محاذٍ بمقدار 16 بايت يحمل n من الأعداد العائمة بطول 64 بتscale: عنوان ذاكرة محاذٍ بمقدار 16 بايت يحمل عددين عائمين بطول 64 بتn: عدد صحيح موجب زوجي بطول 64 بت (بحد أدنى 2)، وهو عدد الدرجات المطلوب تطبيعهاينطبق مسارا المقياس على عنصري كل زوج.
يطبّق المسار الأول المقياس على العنصر الأول من كل زوج (المسارات في المواضع الزوجية)، ويطبّق المسار الثاني المقياس على العنصر الثاني (المسارات في المواضع الفردية).
تُضرب كل درجة في معامل الكسب المقابل لها، ثم تُقسم على مسار المقياس المناسب: score * gain / scale_lane.
لا تُرجع هذه الدالة أي قيمة، وإنما يُخزَّن الناتج في مخزن الإدخال (scores)، في الموضع نفسه للدرجة score المقابلة التي طُبِّعت:
n = 4
scores = {10.0, 20.0, 30.0, 40.0};
gains = { 2.0, 1.5, 1.0, 0.5};
scale = { 2.0, 4.0};
normalize_scores(scores, gains, scale, n);
// => scores == {10.0, 7.5, 15.0, 5.0};
سجّل في Exercism لتتعلّم وتتقن x86-64 Assembly عبر 22 مفهومًا130 تمرينًا، وإرشاد بشري حقيقي، وكل ذلك مجانًا.