בסיכום זה נתמקד באחד מהכלים לבצע תכנות מקבילי העונה לשם SIMD - single instruction multiple data. בד״כ מדובר בחומרה ופקודות מעבד (משתנה לפי ISA) שמבצעות את אותה פקודה על מספר יחידות מידע בבת אחת.
הדיאגרמה הבאה מציגה את סוגי התכנות המקבילי הקיימים לפי Flynn's taxonomy
למשל, נוכל לקחת שני וקטורים A,B בגודל 4 ולבצע סכימה של
אחד הרכיבי חומרה העיקריים במערכות x86 לביצוע פקודות SIMD הם רכיבי הAVX Advanced Vector Extensions.
נזכיר שב x86-64 Assembly ישנם general purpose registers לחישובים אריתמטיים של integers בייצוג signed ו unsigned ולאריתמטיקת כתובות. החומרה הזאת לא תמכה בחישובים אריתמטיים של Floating Point ברמת החומרה.
לאחר מספר עשורים הוכנסו רכיבי ה AVX שתומכים גם באריתמטיקה של מספרים עשרוניים וגם באריתמטיקה וקטורית לוקטורים בגודל של עד 256 ביט.
הregisters הללו הם מעין תחליף לכל מה שקשור לקוד שמבצע מניפולציה על float ו double. כמו שיש registers שיש להם תפקיד ב רגיסטרים ה״רגילים״ שאנחנו מכירים (rax כערך החזרה למשל). כך גם לregisters הנ״ל יש תפקידים
כפי שציינו הרגיסטרים האלה תומכים גם באריתמטיקה סקלרית של floats וגם באריתמטיקה וקטורית. לכן ה ISA צריך לתמוך בפקודות שמאפשרות לחומרה להבין בשביל מה אנחנו רוצים להשתמש בחומרה הזאת.
מבנה ההוראות הכללי עבור הפקודות האלה נראה ככה
דוגמה:
בניגוד לפקודות מהרגיסטרים של integers, כאן התוצאה יכולה גם להכנס לרגיסטר שלישי שנרצה (נראה דוגמה בהמשך)
אם נחליף ל single precision נקבל:
החישובים המצויינים בתמונה נעשים באופן מקבילי לחלוטין.
אם היינו משתמשים ב scalar mode היינו מבצעים את פעולת החיבור רק על 32/64 הביטים הראשונים.
האופציה של scalar קיימת מכיוון שהרגיסטרים האילו מיועדים לכל הסוגים של חישובים float-point ולא בהכרח חישובים וקטוריים.
הפקודות הנ״ל הם פקודות אריתמטיקה בין מספרים עשרוניים מסוג float או double (הפקודות ל packed data זהות, רק צריך לשנות את ה s ל p כפי שהסברנו)
נזכיר שmove אלא פקודות שמאפשרות להזיז תוכן בין רגיסטרים או בין memory לרגיסטר או בין רגיסטר לmemory.
סינתקס של פקודה:
addressing mode זהה לפקודת mov הרגיל
מידע בזכרון חייב להיות alignedלפי הגודל של הרגיסטר בביטים. הסיבה לכך היא ביצועים שקשורים לCache, השמירה ב cache היא ברמת הבלוקים שנקבע לפי כמות הביטים בכתובת שמיועדת לblock offset. לכן מידע שהוא לא aligned עלול לגרום לכך שdata struct אחד יתפוס שתי lines של cache. דבר שפוגע מאוד בביצועים.
המרה לintegers:
המרה מinteger לfloat/double
המרה מ float ל double
אפשר גם להמיר מספרים עשרוניים ולשנות את רמת הדיוק שלהם באופן הבא
הסיבה שבאפור נראה כאילו עברנו ממערך בגודל 4 למערך בגודל 2 כאשר המרנו ל double והפוך כאשר המרנו ל float היא שבפועל זה באמת מה שקורה, בגלל שמדובר גם בוקטורים אמרנו שיכול להיות לי בdouble שתי מספרים וב float ארבעה, אבל אם אני ממיר אני או מרפס באפסים או מוותר על שתי המספרים בבתים הגבוהים
כמובן שהפעולה הזאת עלולה לגרום לנו או לאבד דיוק של המספר או להרחיב את הדיוק שלו במחיר של עוד 4 בתים.
דוגמה
נסתכל על קטע הקוד הבא
double func(double w, int x, float y, long z) {
return y*x - w/z;
}
נשים לב לכמה דגשים:
func:
vcvtsi2ss edi, xmm2, xmm2
vmulss xmm1, xmm2, xmm1
vcvtss2sd xmm1,xmm2
vcvtsi2sdq rsi xmm1, xmm1
vdivsd xmm1, xmm0, xmm0
vsubsd xmm0 xmm2 xmm0
ret
דוגמה 2:
.section .data .align 16
val: .float 42.42
.text
.globl main
main:
vmovss val(%rip), %xmm0 # xmm0 <- [0,0,0,42.42]
vcvttss2si %xmm0, %eax # eax <- 42
ret
ב AVX לא ניתן לרשום immediate כמו בregisters הרגילים בפעולות אריתמטיות. לכן יש צורך לטעון אותם ישירות מהזכרון.
השמירה בזכרון תעשה או על ידי שמירת הייצוג הבינארי
למשל עבור הפונקציה
double f(double temp) {
return 1.8 * temp + 32.0;
}
הקוד יראה ככה
הסיבה שכאן שמרנו ה4 בתים בנפרד היא בגלל endianness. היה אפשר גם לשמור את כל ה8 בתים בבת אחת אבל היה צריך לשים שהייצוג צריך להיות לפי האינדיאניות של המערכת.
צריך לשים לב שהפקודות הנ״ל עובדות על packed data ולכן הן מעדכנות את כל הבתים ב xmm registers גם אם השתמשנו רק בחלק מהם.
קטע הקוד הבא הופך מספרים בוקטור לתצורה השלילית שלהם באמצעות xor. המספרים שרשומים שם פשוט מרכיבים מספר בינארי שה MSB שלו הוא 1 וכל השאר אפסים. לכן הוא יהפוך את הביט סימן עבור כל float בוקטור וכל השאר יחזיר את אותו הערך של הביט שהיה מקודם בעת ביצוע xor.
נראה קוד c שבודק האם מספר הוא NAN ואיך זה נעשה באסמבלי
f(float x) {
if(x<0) return -1;
if(x==0) return 0;
if(x>0) return 1;
if(isnan(x)) return 2;
}
הקוד בודק אם jp דולק (parity flag) ואם כן הוא קופץ לקוד NaN ומחזירים 2.
Printf על float numbers
נסתכל בקוד האסמבלי הבא
נחשב את הנוסחה של שונות משותפת .
בקוד c סדרתי הפתרון די פשוט
#include <math.h>
double corr ( double x[ ], double y[ ], long n ) {
double sum_x, sum_y, sum_xx, sum_yy, sum_xy;
sum_x = sum_y = sum_xx = sum_yy = sum_xy = 0.0;
for (long i = 0; i < n; i++ ) {
sum_x += x[i];
sum_y += y[i];
sum_xx += x[i]*x[i];
sum_yy += y[i]*y[i];
sum_xy += x[i]*y[i];
}
return (n*sum_xy-sum_x*sum_y) /
sqrt((n*sum_xx-sum_x*sum_x) *
(n*sum_yy-sum_y*sum_y));
}
נשים לב לרמז שמאפשר לנו להבין שניתן להשתמש ברכיבי AVX לייעול האלגוריתם -
יש כאן מספר סכומים שמצטברים בכל איטרציה בנפרד ולבסוף מתחברים ביחס באיזשהו אופן.
הרעיון:
בסוף האיטרציות נקבל בערך משהו כזה
וכעת נוכל לסכום את כולם ביחד
horizontal add
כעת לחלק האחרון שנשאר, לחבר את האיבר הראשון בכל וקטור עם האיבר השני שלו, לשים את התוצאה ב8 הבתים הראשונים ולקבל מספר double שהוא התוצאה בכל הרצויה לכל חלק (מעין מעבר בין packed ל scalar).
את זה נעשה עם הפקודה haddpd. הקלט שלה הוא שני רגיסטרים/ רגיסטר ווקטור והיא מבצעת בידיוק את הנ״ל עבורם ושמה את התוצאה בכל איבר בוקטור הקלט הראשון.
כעת כל שנשאר לעשות הוא
הקוד הנ״ל משתמש ברגיסטרים הבסיסיים ביותר בגודל 128 ביט. יכלנו להשתמש ברגיסטרים של AVX (ymm) כדי לשפר את התוצאה אף יותר
הרבה פעמים נרצה לבצע פעולה מתמטית של וקטור עם פרמוטציה על עצמו. לשם כך משתמשים בפקודה vshufps שמקבלת וקטור של ארבעה floats כקלט, רגיסטר יעד, ומספר בקרה (מספר שקובע את ההתנהגות של הפקודה ברמה מסויימת, במקרה הזה את סדר האיברים).
לדוגמה, הפקודה
תיקח את הוקטור xmm0 ותמקם את האיברים בxmm1 לפי המספר הבינארי 01001110, כל שני ביטים מייצגים את האינדקס בוקטור xmm1 מבחינת המיקום שלהם (שני הביטים הראשון מייצגים את המיקום ה 0 בxmm1) וגם מבחינת הערך שלהם (למשל 01 מייצג את האינדקס 1 בxmm0). כך בעצם מתבצע הshuffle , באינדקס i בxmm1 נמקם את המספר לפי האינדקס שנקבע לפי מספר הבקרה.
נשים לב ששמנו את xmm0 כמקור נוסף, בקונבנצייה שמים בשניהם את אותו הרגיסטר.
נראה דוגמה שמשתמשת בפקודה הזאת, נבנה תוכנית שנקראת xysum, מקבלת שני וקטורים x,y של floats באורך n ומחשבת את
.intel_syntax noprefix
.section .text
.globl calc
# float calc (float x[], float[] y, long n)
# calculating sum(xy) - sqrt(sum(x^2) + sum(y^2))
calc:
# x - rdi
# y - rsi
# n - rdx, assuming n divides 4 for simplicity
# calculating xy in xmm2
# calculating x^2 in xmm3
# calculating y^2 in xmm4
vxorps xmm2, xmm2, xmm2
vxorps xmm3, xmm3, xmm3
vxorps xmm4, xmm4, xmm4
# the loop
xor r8, r8 # defining byte counter in r8
xor r9, r9 # defining loop counter in r9
.for_loop:
cmp r9, rdx # r9 - rdx
jge .end_for_loop # if r9 - rdx >= 0 then jump
# inside the loop
vmovaps xmm0, [rdi + r8] # loading x value
vmovaps xmm1, [rsi + r8] # loading y value
# calculating xy
vmulps xmm5, xmm0, xmm1
# adding xy to sum(xy)
vaddps xmm2, xmm5, xmm2
# calculating x^2, and adding it to sum(x^2)
vmulps xmm0, xmm0, xmm0
vaddps xmm3, xmm0, xmm3
# calculating y^2, and adding it to sum(y^2)
vmulps xmm1, xmm1, xmm1
vaddps xmm4, xmm1, xmm4
add r8, 16 # increasing byte counter
add r9, 4 # increasing loop counter
jmp .for_loop # jumping to the start of the loop
.end_for_loop:
vaddps xmm3, xmm4, xmm3 # calculating sum(x^2) + sum(y^2)
# summing the 4 seperate values in xmm2, to one value
vshufps xmm6, xmm2, xmm2, 0b01001110
vaddps xmm2, xmm6, xmm2
vshufps xmm6, xmm2, xmm2, 0b10110001
vaddps xmm2, xmm6, xmm2
# summing the 4 seperate values in xmm3, to one value
vshufps xmm6, xmm3, xmm3, 0b01001110
vaddps xmm3, xmm6, xmm3
vshufps xmm6, xmm3, xmm3, 0b10110001
vaddps xmm3, xmm6, xmm3
vsqrtss xmm3, xmm3, xmm3 #calculating sqrt(sum(x^2) + sum(y^2))
vsubss xmm0, xmm2, xmm3 # calculating the final value
ret
רגיסטרים וקטוריים עושים עבודה מאוד טובה כאשר רוצים לעשות מניפולציה על מחרוזות.
נניח והיינו רוצים שבהינתן מחרוזת כלשהי, לקבל mask שקובע את המיקומים שבהם יש uppercase char במחרוזת.
למשל עבור המחרוזת Ab1cDE23f4gHi5J6 הmask יהיה 1000110000010010.
כדי להשיג תוצאה כזאת נשתמש בפקודות AVX הבאות
0\
או שהיא תופסת את כל הregister).ננסה לפתור את הבעיה המתוארת למעלה עם הכלים הללו:
נסתכל מה קורה עבור הפקודה הבאה
pcmpistrm xmm1, xmm2, 00000100b
כאשר הרגיסטרים מכילים את התוכן הבא
הפעלת הפקודה תתן את הפלט הבא
אם נסתכל על המספרים 48 ו 31 בייצוג בינארי נקבל
נשים לב שהביט הראשון דולק עבור התו האחרון במחרוזת:
היתרון הגדול כאן הוא שכל הפעולה הזאת קוראת בצורה מקבילית בגלל השימוש בAVX.
כעת נכתוב קוד אסמבלי שלוקח מחרוזת וממיר את כל האותיות שלה לאותיות קטנות
section .data
str: db ‘Ab1cDE23f4gHi5J6’
AZ_mask: db ‘A', ‘Z’
times 14 db 0
imm: equ 01000100b
AZ2az_mask: times 16 db ('a' - 'A’)
result: times 16 db 0
db `\n\0`
extern printf
section .text
global main
main:
enter
movdqu xmm1, [AZ_mask]
movdqu xmm2, [str]
pcmpistrm xmm1, xmm2, imm
movdqu xmm3, [AZ2az_mask]
pand xmm0, xmm3
paddb xmm2, xmm0
movdqu [result], xmm2
mov rdi, result
mov rax, 0
call printf
leave
ret
ב data section מגדירים את המחרוזת, את שני הmasks ואת הcontrol number.
שומרים ב xmm1 את AZ_mask ואת המחרוזת ב xmm2.
מריצים את הפקודה pcmpistrm xmm1, xmm2, imm.
לאחר מכן טוענים את AZ2az_mask ל xmm3.
כעת עושים and בין xmm0 שמכיל את הmask הראשון לבין xmm3 שמכיל 20 בכל בית.
paddb - עושה חיבור וקטורי ל packed bytes , כאשר עושים את זה בין xmm0 ל xmm2 התוצאה תהיה הערך הascii של התו בתוספת 20 אם הוא תו מסוג upper case או כלום עבור כל תו אחר.
מזיזים את התוצאה לכתובת שבה נמצא result ומעבירים אותו כקלט ל printf.
דגשים:
0\
באמצע הריצה כלומר המחרוזת קטנה מ16 בתים, אוטומטית הפקודה תפסיק את פעולה ההשוואה ויסומן 0 בכל בית/ביט i שגדולים מאורך המחרוזת. כמו כן ה ZF יהיה דלוק.הסיבה שעבדנו עם טווחים עד עכשיו היא בגלל ה control number והביטים שבחרנו שיהיו דלוקים בו. כאשר עובדים עם הcontrol number במקרה של pcmps מסתכלים על צמדים של ביטים- הצמד
ביטים נוספים יובילו להשוואות מצורה אחרת למשל:
אם שני הביטים יהיו כבויים כלומר 00. ההשוואה תהיה מסוג equal any כלומר בודקת האם התו ב XMM2 נמצא איפה שהוא ב XMM1.
אם הביט ה3 היה דולק אבל הביט השני לא כלומר 10 ההשוואה הינה מדויקת- equal each כלומר האם הבית הi ב xmm2 שווה לבית הi ב xmm1
אם שני הביטים דולקים אז השוואה הינה equal ordered כלומר האם xmm1 הוא substring של השני ומסמנים ב mask את כל הבתים שבהם מתחיל הsubstring
מה המשמעות של כל צמד ביטים בcontrol number
הביטים 0,1 קובעים כיצד להתייחס למידע בתוך הוקטור (הפורמט)
הביטים 2 ו 3 עושים את מה שתיארנו למעלה
בפסודו קוד כל אחד מהmodes האלה עושה את הבא
ניתן לראות שנעשת בדיקה על הביט ה 0 כדי לדעת כיצד להתייחס לdata מבחינת גדלים.
הביטים ה 4 וה5 הם מניפולציות שאפשר לעשות על index התוצאה במידה והפקודה היא מסוג index ולא mask
הביט ה6 הוא control byte והוא עושה דברים שונים בין אם עובדים עם mask ובין אם אינדקס
כפי שאמרנו התוצאה יכולה להיות mask או אינדקס מספרי ב RCX. נדגים איך להשתמש בפקודה ששומרת את התוצאה באינדקס מספרי. למשל חישוב אורך מחרוזת.
section .data
str: db ‘Ab1cDE23f4gHi5J6’
db ‘Ab1cDE23f4g\0’
EOS_mask: db 0x1,0xFF
times 14 db 0
imm: equ 00010100b
section .text
global s
s:
enter
xor rax, rax
xor rcx, rcx
movdqu xmm1, [EOS_mask]
.loop
add rax, rcx
pcmpistri xmm1, [str+rax], imm
jnz .loop
add rax, rcx
leave
ret
המקבילה של פקודות SSE אבל בשפת c. במדעי המחשב Intrinsics הם בד״כ פונקציות שהמימוש שלהם מטופל על ידי הcompiler. בדומה לפונקציות inline כאשר הקומפילר רואה פונקציה מהסוג הזה הוא מחליף פשוט את הקריאה בקוד של הפונקציה הזמן בזמן קומפילציה. ל C יש Intrinsics שממפים קריאות פונקציה לפקודות SIMD.
מבנה הפקודה הוא מהצורה
למשל נסתכל על הפקודה
_mm256d_add_pd(__mm256 a , __mm256 b)
את המידע המדויק ניתן לראות בדוקומנטציה של SIMD Intrinsics
נראה קוד c פשוט שמחבר בין שני מערכים מסוג float
float a[8] = {2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0};
float b[8] = {1.0, 3.0, 5.0, 7.0, 9.0, 11.0, 13.0, 15.0};
for(int i=0; i<8; i++)
a[i] = a[i] + b[i];
וכעת נדגים את המקביל אליו באמצעות פקודות SSE בשפת C.
#include <immintrin.h>
#include <stdio.h>
int main() {
/* Initialize input vectors */
__m256 a = _mm256_set_ps(2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0);
__m256 b = _mm256_set_ps(1.0, 3.0, 5.0, 7.0, 9.0, 11.0, 13.0, 15.0);
/* Call add function */
__m256 result = _mm256_add_ps(a, b);
/* Display the elements of the result vector */
float* f = (float*)&result;
printf("%f %f %f %f %f %f %f %f\n",
f[0], f[1], f[2], f[3], f[4], f[5], f[6], f[7]);
return 0;
}
include immintrin.h
שמביאה את כל ההרחבות של SSE/AVX בשפת C.mm256__
באמצעות שורת הקוד mm256_set_ps_
(כמובן שלוקטורים בגדלים אחרים עם data type אחרים יש פקודה שקולה).הפלט בהדפסה יהיה:
הסיבה שזה בסדר הפוך היא בגלל שהמערכת שבה זה הורץ היא מסוג Little Endian.
נסתכל על דוגמה נוספת
__mm256d_mm256_permute_pd(__mm256 d , int mm8)
למשל עבור השורת קוד vec2 = _mm256_permute_pd(vec2, 0x5)
-
דוגמה 1
נכתוב תוכנית עם c Intrinsics שמחשבת את האינדקס של ערך מינימלי במערך של 8 floats.
int sse_min_index(unsigned short arr[8]) {
/* load the elements into a __m128i variable */
__m128i input = _mm_loadu_si128((__m128i*)arr);
/* Get the position of the minimum value in the input register */
__m128i minpos = _mm_minpos_epu16(input);
/* Extract the position of the minimum value */
return ((unsigned char*) &minpos)[2];
}
דוגמה 2
נרצה לכתוב את xysum שכתבנו מקודם גם בשפת c. בגדול הרעיון הוא אותו הרעיון רק נציג את הקוד ואת הפקודות החדשות שנראה בעקבות הקוד
#include <immintrin.h>
float intrinsics_calc(float x[], float y[], long n) {
/* Initialize sums */
__m128 xy_sum = _mm_setzero_ps();
__m128 x2_sum = _mm_setzero_ps();
__m128 y2_sum = _mm_setzero_ps();
for (long i = 0; i < n; i += 4) {
/* Load 4 floats at a time */
__m128 x_vec = _mm_load_ps(x + i);
__m128 y_vec = _mm_load_ps(y + i);
/* Update vector sums */
xy_sum = _mm_add_ps(xy_sum, _mm_mul_ps(x_vec, y_vec));
x2_sum = _mm_add_ps(x2_sum, _mm_mul_ps(x_vec, x_vec));
y2_sum = _mm_add_ps(y2_sum, _mm_mul_ps(y_vec, y_vec));
}
/* Calculate xy sum and convert to float */
xy_sum = _mm_hadd_ps(xy_sum, xy_sum); // (x1 + x2, x3 + x4, x1 + x2, x3 + x4)
xy_sum = _mm_hadd_ps(xy_sum, xy_sum); // (x1 + x2 + x3 + x4, x1 + x2 + x3 + x4, x1 + x2 + x3 + x4, x1 + x2 + x3 + x4)
float xy_sum_float = _mm_cvtss_f32(xy_sum);
/* Calculate sqrt(x2_sum + y2_sum) and convert to float */
__m128 square_sums = _mm_hadd_ps(x2_sum, y2_sum);
square_sums = _mm_hadd_ps(square_sums, square_sums);
square_sums = _mm_hadd_ps(square_sums, square_sums);
__m128 square_sums_sqrt = _mm_sqrt_ps(square_sums);
float square_sums_sqrt_float = _mm_cvtss_f32(square_sums_sqrt);
/* Return xy_sum - sqrt(x2_sum + y2_sum) */
return xy_sum_float - square_sums_sqrt_float;
}
_mm_setzero_ps
מאתחל וקטור של 0_mm_hadd_ps
מבצע horizontal sum שמקבל שני וקטורים x,y ומחשב _mm_cvtss_f32
ממיר וקטור ל single scalar float32 ._mm_sqrt_ps
מבצע שורש על כל איבר בוקטור.