4.2 SASS האסמבלי של החומרה תרגול
תרגול - SASS - האסמבלי של החומרה¶
בתרגול הזה תלמדו לפרק kernels מהודרים ולקרוא את ה-SASS שהחומרה באמת מריצה. תפרקו kernel עם cuobjdump --dump-sass ותזהו את משפחות הפקודות שהכרנו בהרצאה, תאלצו את הcompiler להוציא MUFU ו-HMMA, תקמפלו עם -lineinfo ותמפו שורת SASS חמה חזרה לשורת המקור עם nvdisasm, תייצרו גרף זרימת-בקרה (CFG) ותקראו את הענפים, ולבסוף תשוו SASS בין שתי compute capabilities שונות. עבדו לפי הסדר - כל תרגיל נשען על הקודם. הריצו הכל על GPU אמיתי; אם אין לכם, השתמשו ב-Google Colab (T4 חינמי) או במכונת ענן, כפי שראינו בשיעור 0.2. ה-H100 (sm_90a) הוא כרטיס הייחוס שלנו; אם אתם על T4 החליפו sm_90a ב-sm_75 (וזכרו ש-sm_75 לא תומך ב-wgmma; ה-HMMA של תרגיל 2 עדיין יעבוד).
הכנה¶
צרו קובץ kernels.cu עם שלושה kernels: saxpy (ייצר FFMA ו-LDG/STG), activate (ייצר MUFU דרך expf), ו-wgemm (ייצר HMMA דרך WMMA על Tensor Cores). הקובץ אינו חייב לרוץ נכון מבחינה אלגוריתמית - אנחנו רק מפרקים את ה-SASS שלו - אבל הוא קומפילבילי לחלוטין.
#include <cuda_runtime.h>
#include <mma.h>
using namespace nvcuda;
// a. FFMA + LDG/STG
__global__ void saxpy(float a, const float* x, const float* y,
float* out, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n)
out[i] = a * x[i] + y[i];
}
// b. MUFU (special function) - expf runs on the SFU
__global__ void activate(const float* x, float* out, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n)
out[i] = 1.0f / (1.0f + expf(-x[i])); // sigmoid
}
// c. HMMA - 16x16x16 matrix multiply on Tensor Cores
__global__ void wgemm(const half* A, const half* B, float* C) {
wmma::fragment<wmma::matrix_a, 16, 16, 16, half, wmma::row_major> a;
wmma::fragment<wmma::matrix_b, 16, 16, 16, half, wmma::col_major> b;
wmma::fragment<wmma::accumulator, 16, 16, 16, float> c;
wmma::fill_fragment(c, 0.0f);
wmma::load_matrix_sync(a, A, 16);
wmma::load_matrix_sync(b, B, 16);
wmma::mma_sync(c, a, b, c);
wmma::store_matrix_sync(C, c, 16, wmma::mem_row_major);
}
שתי צורות הcompilation שנשתמש בהן לאורך התרגול - בינארי-host (ל-cuobjdump) ו-cubin ישיר (ל-nvdisasm):
# full host binary, with line info
nvcc -O2 -arch=sm_90a -lineinfo -c -o kernels.o kernels.cu
# direct cubin (the input to nvdisasm), with line info
nvcc -O2 -arch=sm_90a -lineinfo -cubin -o kernels.cubin kernels.cu
תרגיל 1 - פירוק וזיהוי משפחות פקודות¶
- פרקו את ה-SASS של הבינארי:
cuobjdump --dump-sass kernels.o. - אתרו את הקטע של הפונקציה
saxpy(חפשו את השם המקושקש, למשל_Z5saxpyfPKfS0_Pfi). בתוכו, סמנו: את שתי פקודות ה-S2Rשקוראות אתblockIdx.xו-threadIdx.x, את ה-IMADשמחשב את האינדקסi, את ה-ISETP+@P0של בדיקת הגבולות, את שתי טעינות ה-LDG.E, את ה-FFMA, את ה-STG.E, ואת ה-EXIT. - ספרו: כמה
LDGוכמהSTGיש? האם המספר תואם למספר הגישות ל-global memory בקוד המקור? - הסבירו לעצמכם למה
blockDim.xמגיע ממאגר הקבועיםc[0x0][...]ולא מ-Special Register כמוthreadIdx.x.
רמז: כדי לסנן רק את ה-kernel שמעניין אתכם, הוסיפו -fun עם שם הפונקציה, או פשוט cuobjdump --dump-sass kernels.o | less וחפשו. אם אתם לא בטוחים מהו השם המקושקש, cuobjdump --dump-elf kernels.o | grep -i saxpy או cu++filt <symbol> יפענחו אותו. blockDim אחיד לכל ה-warp, ולכן הcompiler מניח אותו בקבועים; threadIdx שונה בכל thread ולכן נקרא מ-Special Register.
תרגיל 2 - לזהות MUFU ו-HMMA¶
- פרקו שוב את ה-SASS, הפעם של הפונקציה
activate. אתרו את פקודת ה-MUFU- איזו וריאנטה היא (MUFU.EX2?MUFU.RCP?). הסבירו למהexpfהפך ל-MUFU.EX2ולא ל-MUFU.EXP(רמז:e^x = 2^(x*log2(e))). - פרקו את הפונקציה
wgemmואתרו את פקודות ה-HMMA. שימו לב לצורת הפקודה, למשלHMMA.16816.F32- המספרים הם ממדי הtile (M, N, K) שה-Tensor Core מעבד בפקודה אחת. - השוו את מספר פקודות ה-
HMMAשנוצרו למספר פקודות ה-FFMAשהיו נדרשות לאותו כפל מטריצות16x16x16על CUDA Cores רגילות. כמה FFMAs חוסך HMMA אחד? - הריצו
cuobjdump --dump-sass kernels.o | grep -c -E 'FFMA|MUFU|HMMA|LDG|STG'וקבלו ספירה מהירה של כל אחת.
רמז: אם אתם על T4 (sm_75), ה-wgemm עדיין מקמפל ומייצר HMMA (Tensor Cores קיימים מ-Volta), אבל ה-.16816 עשוי להיות .1688 בגלל ממדי tile שונים לפי דור. כפל מטריצות 16x16x16 הוא 16*16*16 = 4096 פעולות כפל-חיבור; HMMA יחיד מבצע tile שלם, בעוד על CUDA Cores היו נדרשות אלפי FFMA. זה בדיוק היתרון של Tensor Cores שראינו ב-1.5.
תרגיל 3 - מיפוי SASS למקור עם -lineinfo¶
- ודאו שקימפלתם עם
-lineinfo(ראו ההכנה). הריצוnvdisasm -g kernels.cubinוהתמקדו ב-saxpy. - מצאו את השורה
out[i] = a * x[i] + y[i];בקוד המקור, ואתרו איזה בלוק פקודות SASS ממופה אליה. אילו פקודות SASS "נולדו" מהשורה הזו? - עשו את אותו הדבר עם
cuobjdump --dump-sass -lineinfo kernels.o(הדגל-lineinfoל-cuobjdumpמוסיף הערות מקור). השוו את שתי צורות המיפוי. - מדוע פקודות בודדות עשויות להתמפות לשורות מקור "לא צפויות" (למשל פקודת
IMADשממופה לשורת ההכרזה של הלולאה)? מה עשה הcompiler?
רמז: nvdisasm -g דורש cubin שקומפל עם -lineinfo (או -G). אם לא רואים מספרי שורות, שכחתם את הדגל בcompilation. אופטימיזציות כמו reordering ו-instruction scheduling מפזרות פקודות שמקורן בשורה אחת על פני הפלט, ולכן המיפוי לא תמיד רציף - זו הסיבה שקוראים SASS לצד המקור ולא במקומו.
תרגיל 4 - גרף זרימת-בקרה עם nvdisasm -cfg¶
- ייצרו CFG של ה-cubin:
nvdisasm -cfg kernels.cubin > kernels.dot. - אם מותקן אצלכם graphviz, המירו לתמונה:
dot -Tpng kernels.dot -o kernels.png(או-Tsvg). אם לא, פתחו את קובץ ה-.dotבעורך טקסט וקראו את הצמתים והקשתות ידנית. - ב-
saxpy, אתרו את הבלוק הבסיסי (basic block) שמסתיים ב-@P0 EXIT. לאן מובילות שתי הקשתות היוצאות ממנו - מה קורה כש-P0אמת ומה כשהוא שקר? - תארו במילים את מבנה ה-CFG של
saxpy: כמה בלוקים בסיסיים יש, והיכן נמצא הפיצול (branch) היחיד.
רמז: ב-CFG כל צומת הוא בלוק בסיסי (רצף פקודות בלי קפיצה פנימה או החוצה באמצע), וכל קשת היא קפיצה אפשרית. בדיקת הגבולות if (i < n) יוצרת פיצול: מסלול אחד ל-EXIT (כש-i>=n), מסלול שני להמשך החישוב. ל-saxpy אין לולאות, ולכן ה-CFG שלו פשוט - בלי מעגלים (back-edges). את ה-wgemm וה-activate השוו כדי לראות CFG עשירים יותר.
תרגיל 5 - השוואת SASS בין שתי compute capabilities¶
- קמפלו את
saxpyפעמיים - פעם ל-Volta (sm_70) ופעם ל-Hopper (sm_90a):
- פרקו את שתיהן (
cuobjdump --dump-sass saxpy_sm70.cubinוכן ל-sm90) והעמידו את ה-saxpyזו מול זו. - אתרו לפחות שלושה הבדלים. שימו לב במיוחד ל: צורת טעינת ה-global (
LDG.E [R.64]מולLDG.E desc[UR..][R.64]), לשימוש באוגריUR(uniform), ולמספר הפקודות הכולל. - הסבירו למה ה-SASS שונה בכלל, בעוד קוד ה-CUDA C++ זהה לחלוטין - וקשרו זאת להבחנה PTX מול SASS מ-4.1.
רמז: מ-Hopper ואילך, טעינות global עוברות דרך מתאר זיכרון (memory descriptor) שיושב ב-Uniform Register, ולכן תראו desc[UR4][...] ב-sm_90a אבל לא ב-sm_70. המסלול האחיד (uniform datapath) בשל יותר בדורות החדשים. זה בדיוק העניין ש-SASS צמוד-ארכיטקטורה: אותו PTX (ואותו מקור) עובר JIT/compilation ל-SASS שונה לכל דור. אם רוצים לראות שה-PTX זהה בין הדורות, הריצו cuobjdump --dump-ptx על שתיהן והשוו.
תרגיל 6 (בונוס) - לזהות register spilling¶
- כתבו kernel "רעב לאוגרים": לולאה שמחזיקה הרבה משתנים חיים בו-זמנית, למשל 40 מצברים (
float acc0..acc39) שכולם מתעדכנים בכל איטרציה. קמפלו עםnvcc -arch=sm_90a -Xptxas -v -cubin -o spill.cubin spill.cuוקראו את שורות ה-ptxas info- כמה registers ל-thread? האם דווחbytes spill stores/loads? - פרקו את ה-SASS וחפשו פקודות
STL/LDL(Store/Load Local) - אלו הן פקודות ה-spill: אחסון/טעינה של אוגרים שנשפכו ל-local memory (שהוא פיזית global memory). - הוסיפו
-maxrregcount=32לcompilation, פרקו שוב, וראו כיצד מספר ה-STL/LDLגדל (יותר spilling) בזמן שמספר האוגרים לכל thread קטן. הסבירו את הטרייד-אוף מול ה-occupancy.
רמז: ptxas מקצה אוגרים ומנסה לאזן בין מעט אוגרים לכל thread (יותר occupancy) לבין spilling יקר. פקודות STL/LDL ב-SASS הן הדגל האדום ל-spill - ראינו ב-4.1 שהאוגרים יכולים "להישפך" ל-global memory במחיר ביצועים כבד. -maxrregcount כופה תקרת אוגרים ולכן מכריח יותר spilling כשהתקרה נמוכה מדי; זהו בדיוק חישוב ה-occupancy שנעמיק בו בפרק 8.1.