1.2 ה SM לעומק הרצאה
בשיעור 1.1 מיפינו את היררכיית החומרה של ה-GPU מלמעלה למטה: ה-GPU מכיל אשכולות (GPC), כל אשכול מכיל אשכולות טקסטורה (TPC), וכל TPC מכיל זוג SM - מעבד רב-זרמי (Streaming Multiprocessor). ראינו שם ש-SM הוא היחידה שעליה מתוזמן block שלם, אבל טרם פתחנו אותו. בשיעור הזה נצלול פנימה ונטען טענה אחת, מרכזית וקצת מפתיעה: ה-SM, ולא ה"ליבה" (CUDA Core - ליבת CUDA), הוא המקבילה האמיתית של ליבת מעבד. ליבת CUDA בודדת היא לא מעבד קטן - היא pipe שנתונים נכנסים אליו ויוצאים מומרים. המעבד השלם, זה שיש לו אוגרים, יחידות חישוב ומתזמנים משלו, הוא ה-SM. נבין מדוע ה-SM הוא בו-זמנית "מעבד שלם" וגם "מעבד פשוט וחלש" בהשוואה לליבת CPU, נפרק אותו לארבע תת-מחיצות, נחשב את המספרים של ה-H100 עד הסוף, ונסגור עם המושג שקובע איזה קוד בכלל ירוץ על ה-SM - ה-compute capability. בשיעור 1.3 נעמיק ביחידות הביצוע שבתוכו, ובשיעור 1.4 נתמקד במתזמן ה-warp.
הליבה היא pipe, ה-SM הוא המעבד - Core vs SM¶
השורש של רוב אי-ההבנות בתכנות GPU הוא המילה "core". כשכרטיס מפרסם "16,896 CUDA Cores", האינטואיציה מה-CPU לוחשת: "16,896 מעבדים, כל אחד מריץ תוכנית משלו". זו טעות. ליבת CUDA איננה מעבד עצמאי. היא pipe: יחידה פונקציונלית שמקבלת אופרנדים בכניסה ומחזירה תוצאה מומרת ביציאה, כפופה לפקודה שמישהו אחר החליט להנפיק לה. אין לה מונה-פקודות משלה, אין לה מתזמן משלה, ואין לה זיכרון פרטי מעבר לאוגרים שמוזנים אליה. הpipe, לא מוח.
מה כן מעבד? מעבד הוא יחידה עצמאית שיודעת לקחת זרם פקודות, להחליט מה להריץ ומתי, ולהחזיק את המצב של החישוב. שלושת המרכיבים ההכרחיים של מעבד כזה קיימים כולם בתוך ה-SM, ורק בתוכו:
- זיכרון אוגרים (register file - קובץ האוגרים): מקום פיזי לאחסן את המצב של כל הthreads החיים.
- ליבות שממירות נתונים (cores): יחידות החישוב עצמן - CUDA Cores, Tensor Cores (ליבות טנזור), ועוד.
- מתזמני פקודות (warp schedulers - מתזמני ה-warp): הרכיבים שמחליטים בכל מחזור איזו קבוצת threads תרוץ ומנפיקים לה פקודה.
ליבת CUDA בודדת מחזיקה רק את (2) - "תמיר לי את הנתון הזה". חסרים לה (1) ו-(3). לכן היא pipe, לא מעבד. ה-SM מחזיק את שלושתם, ולכן הוא היחידה שראוי להשוות לליבת CPU.
a single "core" (GPU) vs. the full SM
+------------------------+ +---------------------------------+
| pipe: operand -> result | | register file (state of all threads) |
| (just "convert a value")| | many cores (convert data) |
| no scheduler, no state | | warp schedulers (decide what runs) |
+------------------------+ +---------------------------------+
a functional unit = the true equivalent of a CPU core
נקודה למבחן: מספר ה-"CUDA Cores" שמפרסמים הוא ספירת נתיבים (lanes), לא ספירת מעבדים. מי שמודד את גודל המכונה במספר הליבות, מודל שגוי בידיו. המדד הנכון הוא מספר ה-SMs (ב-H100: 132), ובתוך כל SM - כמה יחידות מכל סוג יש.
מעבד שלם אבל פשוט וחלש - No Speculation, No Branch Prediction¶
ה-SM הוא מעבד, אבל מסמכי NVIDIA מתארים אותו במפורש כ"מעבד פשוט וחלש" בהשוואה לליבת CPU מודרנית. זה לא עלבון, זו בחירה תכנונית. ליבת CPU משקיעה את רוב הסיליקון שלה במנגנונים שמאיצים thread בודד, ואת כל אלה ה-SM מוותר עליהם:
- אין ביצוע ספקולטיבי (no speculative execution): ה-SM לא מנחש קדימה ולא מריץ פקודות "על תנאי" שאולי יזרוק.
- אין חיזוי הסתעפויות (no branch prediction) ואין ספקולציה על מונה-הפקודות: ה-SM לא מנסה לנחש אם
ifיילקח. הוא פשוט מחכה שהתנאי יחושב. - כן יש ביצוע pipelined: זה חשוב לדייק. ה-SM כן משתמש בpipeline בתוך הפקודות - שלבי fetch/decode/execute חופפים בזמן. מה שאין זו הספקולציה מעל הpipeline, לא הpipeline עצמו.
אם ה-SM ויתר על כל המנגנונים שמאיצים thread בודד, מאיפה מגיע הכוח שלו? לא מlatency נמוכה של thread בודד - שם ה-CPU מנצח בגדול - אלא משני מקורות אחרים:
- תפוקה (throughput): במקום ליבה אחת חכמה, ל-SM יש עשרות עד מאות יחידות חישוב פשוטות שרצות במקביל.
- החלפת הקשר זולה (cheap context switching): ה-SM מחליף בין קבוצות threads בעלות של מחזור שעון אחד, כי המצב של כל הthreads כבר יושב פיזית בקובץ האוגרים ואף פעם לא זז. זה מעל פי 1000 מהיר מהחלפת הקשר ב-CPU.
הצירוף הזה - הרבה pipes + החלפה כמעט חינמית - הוא שמאפשר ל-SM להסתיר latency במקום להימנע ממנה: כשקבוצת threads אחת תקועה בהמתנה לזיכרון, המתזמן מחליף אותה בקבוצה מוכנה בתוך מחזור, והpipes נשארים עסוקים. זהו בדיוק ההיפך מפילוסופיית ה-CPU, שמנסה למנוע את הlatency מלכתחילה בעזרת caches וניחוש. נחזור לפילוסופיה הזו לעומק בשיעור 1.4 על מתזמן ה-warp.
אנטומיה של SM - ארבע תת-מחיצות - Sub-partitions¶
ה-SM איננו גוש חישוב אחיד. הוא מחולק (בכל הארכיטקטורות המודרניות - Volta, Turing, Ampere, Hopper) לארבע תת-מחיצות (sub-partitions, שנקראות גם processing blocks). כל תת-מחיצה היא כמעט-מעבד בפני עצמה, ומכילה:
- מתזמן warp אחד משלה, שמנפיק פקודה ל-warp אחד (32 threads) בכל מחזור.
- פלח משלה מקובץ האוגרים - ב-H100, רבע מ-65,536 האוגרים של ה-SM, כלומר 16,384 אוגרים בני 32 סיביות לתת-מחיצה.
- את יחידות החישוב שלה: ב-H100, 32 נתיבי FP32, 16 נתיבי INT32, 16 נתיבי FP64.
- Tensor Core אחד (בכרטיסים חדשים) - סך הכל ארבעה Tensor Cores ל-SM, אחד לכל מתזמן.
- יחידות עזר: SFU - יחידת פונקציות מיוחדות (Special Function Unit) לפעולות טרנסצנדנטליות כמו
expו-sin, ו-LSU - יחידת טעינה ואחסון (Load/Store Unit) שמנתבת גישות לזיכרון. נכיר אותן לעומק בשיעור 1.3.
מעל ארבע תת-המחיצות יושב משאב אחד משותף לכל ה-SM: 256 KiB של L1/shared memory מאוחד (זיכרון משותף וזיכרון cache L1 חולקים את אותו סיליקון, והמתכנת מחלק ביניהם). זהו הזיכרון המהיר שבו נשלוט ידנית, נושא שיתפוס פרק שלם בהמשך.
Streaming Multiprocessor (SM) - H100
+---------------------------------------------------------------------+
| L1 Data Cache / Shared Memory - 256 KiB unified per SM |
+-----------------+-----------------+-----------------+---------------+
| Sub-partition 0 | Sub-partition 1 | Sub-partition 2 | Sub-partition 3|
| Warp Scheduler | Warp Scheduler | Warp Scheduler | Warp Scheduler|
| 16,384 registers| 16,384 registers| 16,384 registers| 16,384 registers|
| 32x FP32 | 32x FP32 | 32x FP32 | 32x FP32 |
| 16x INT32 | 16x INT32 | 16x INT32 | 16x INT32 |
| 16x FP64 | 16x FP64 | 16x FP64 | 16x FP64 |
| 1x Tensor Core | 1x Tensor Core | 1x Tensor Core | 1x Tensor Core|
| SFU + LSU | SFU + LSU | SFU + LSU | SFU + LSU |
+-----------------+-----------------+-----------------+---------------+
חשוב: warp "שייך" לתת-מחיצה אחת לאורך כל חייו. מתזמן של תת-מחיצה 0 לא יכול להנפיק פקודה ל-warp שמנוהל על ידי תת-מחיצה 1. חלוקה זו היא הסיבה שארבעה warps מתקדמים בו-זמנית בכל SM (אחד לכל תת-מחיצה) - צירוף שבחומרה החדשה יש לו אפילו שם, warpgroup (ארבעה warps, 128 threads), שנפגוש כשנדבר על Tensor Cores.
הנה הקטלוג המלא של SM אחד ב-H100, פעם לפי תת-מחיצה ופעם לכל ה-SM:
| רכיב | לתת-מחיצה | ל-SM שלם (x4) |
|---|---|---|
| מתזמן warp | 1 | 4 |
| נתיבי FP32 (CUDA Cores) | 32 | 128 |
| נתיבי INT32 | 16 | 64 |
| נתיבי FP64 | 16 | 64 |
| Tensor Core | 1 | 4 |
| אוגרים בני 32 סיביות | 16,384 | 65,536 (256 KiB) |
| L1 / shared memory | משותף | 256 KiB |
שימו לב שה"128 CUDA Cores" הרשמיים סופרים רק את נתיבי ה-FP32. נתיבי ה-INT32 וה-FP64 הם חצי מזה (64 כל אחד). לכן, כשמעריכים ביצועים, לא סופרים "CUDA Cores" באופן כללי אלא את מספר היחידות של הפעולה הספציפית שמעניינת אתכם - נושא שנרחיב בשיעור 1.3.
המספרים של ה-H100 - מקבילי מול מקביל-לכאורה - Parallel vs Concurrent¶
עכשיו שיש לנו את המבנה, נחשב את שני המספרים החשובים ביותר של ה-SM. הם שונים בסדר גודל, ולבלבל ביניהם זו טעות היסוד של מתחילים.
כמה threads מתקדמים פיזית בכל מחזור (parallel - מקביליים באמת)? כל תת-מחיצה מנפיקה פקודה ל-warp אחד (32 threads) בכל מחזור:
4 sub-partitions x 32 threads per warp = 128 threads issued per SM each cycle
128 threads/SM x 132 SM = 16,896 truly parallel threads each cycle
כמה threads יושבים על ה-SM בו-זמנית (concurrent - מקביליים-לכאורה / תושבים)? כל SM ב-H100 יכול להחזיק עד 64 warps תושבים:
64 warps x 32 threads = 2,048 resident threads per SM
2,048 threads/SM x 132 SM = 270,336 apparently-parallel threads
הפער עצום: מתוך 2,048 הthreads התושבים ב-SM, רק 128 מתקדמים בכל מחזור נתון (יחס של 16:1). מתוך רבע מיליון הthreads החיים על השבב, רק כ-16,000 זזים בכל תקתוק. הפער הזה איננו בזבוז - הוא מאגר ההסתרה (latency hiding). כל warp תושב שאינו רץ כרגע הוא עבודה מוכנה בהמתנה. כשה-warps הרצים נתקעים בהמתנה ל-global memory (זיכרון גלובלי) - latency של מאות מחזורים - המתזמן שולף warp אחר מהמאגר וממשיך. ככל שהמאגר מלא יותר (מדד ה-occupancy - תפוסה), קל יותר להסתיר את הlatencies.
מה שהופך את ההחלפה הזו לאפשרית הוא המחיר שלה: החלפת warp עולה מחזור שעון אחד (סדר גודל של ננושנייה). ב-CPU החלפת הקשר עולה מאות עד אלפי מחזורים, כי צריך לשמור ולשחזר אוגרים. ב-GPU אין מה לשמור: כל 2,048 הthreads התושבים כבר מחזיקים את האוגרים שלהם פיזית בקובץ האוגרים בו-זמנית. להחליף warp פירושו רק להצביע על קבוצת אוגרים אחרת. אין תנועת נתונים, ולכן ההחלפה כמעט חינמית. זו החלפת הקשר באפס overhead (zero-overhead context switch), והיא הלב של הסתרת הlatency. נסגור מעגל: בגלל שהחלפה זולה, כדאי להעמיס הרבה warps תושבים; אבל מספר האוגרים לכל thread מוגבל (65,536 לכל SM), ולכן ככל שכל thread צורך יותר אוגרים, פחות warps יכולים להיות תושבים. המתח הזה (register pressure - לחץ אוגרים) הוא שקובע את ה-occupancy בפועל, ונחזור אליו בפרק הביצועים.
טעות נפוצה: לחשוב שכל 2,048 הthreads "רצים במקביל". הם תושבים במקביל, אבל רק 128 מתקדמים במקביל בכל מחזור. השאר ממתינים לתורם. המילים "מקבילי" ו"מקביל-לכאורה" מפרידות בדיוק בין שני המצבים.
ארכיטקטורת ה-SM וה-Compute Capability - SASS Compatibility¶
עד כה תיארנו את המבנה הפיזי של ה-SM. אבל ל-SM יש גם "גרסה" - ארכיטקטורה ממוספרת שמגדירה עם איזה קוד מכונה הוא תואם. קוד המכונה הילידי של ה-GPU נקרא SASS (Streaming Assembler), וכל SM יודע להריץ רק SASS שנבנה לגרסת הארכיטקטורה שלו. הגרסה הזו היא בדיוק מה ש-NVIDIA מכנה במקום אחר compute capability.
המבנה של המספר: לרוב שני רכיבים, major.minor (גרסה ראשית ומשנית). ה-גרסה הראשית כמעט נרדפת למשפחת הארכיטקטורה. דוגמה: כל גרסאות ה-SM מסוג 6.x שייכות לארכיטקטורת Pascal. אבל "כמעט" איננו "תמיד", ויש חריגה חשובה שכדאי לזכור: כרטיסי Ada משתמשים ב-8.9 - אותה גרסה ראשית (8) כמו Ampere, ובכל זאת הם משפחה אחרת. כלומר גרסה ראשית משותפת אינה מבטיחה אותה משפחת ארכיטקטורה.
הנה טבלת ההתאמות המרכזיות (שתי השורות המסומנות הן מה שמסמכי המקור מציינים במפורש; השאר הן ידע CUDA סטנדרטי שנוסיף לנוחותכם):
| Compute Capability | משפחת ארכיטקטורה | כרטיס לדוגמה |
|---|---|---|
| 6.x (מצוין במקור) | Pascal | GTX 1080, P100 |
| 7.0 / 7.2 | Volta | V100 |
| 7.5 | Turing | T4, RTX 2080 |
| 8.0 / 8.6 / 8.7 | Ampere | A100, RTX 3090 |
| 8.9 (מצוין במקור) | Ada Lovelace | RTX 4090, L4 |
| 9.0 | Hopper | H100, H200 |
| 10.x / 12.x | Blackwell | B200, RTX 5090 |
שימו לב לשורות 8.x: Ampere (8.0, 8.6, 8.7) ו-Ada (8.9) חולקים גרסה ראשית 8 אבל הם משפחות נפרדות - זו החריגה מהכלל.
מדוע זה חשוב למתכנת? כי תאימות בין גרסאות ראשיות אינה מובטחת. SASS שנבנה לגרסה ראשית אחת אינו מובטח לרוץ על גרסה ראשית אחרת. את גרסת ה-SM שאליה מקמפלים בוחרים בזמן הפעלת nvcc (compiler CUDA). את התחביר המדויק נראה בתרגול, אבל בקצרה: -arch=sm_90a מכוון ל-Hopper (H100), -arch=sm_75 ל-Turing (T4), וכן הלאה. יש כאן עדינות שנפתח בפרק ה-PTX/SASS: nvcc יכול להטמיע גם SASS ילידי לארכיטקטורה מסוימת וגם PTX (ייצוג ביניים נייד) שעובר קומפילציה בזמן ריצה (JIT) לכל ארכיטקטורה חדשה יותר - וזה מנגנון התאימות קדימה. אם מטמיעים רק SASS של גרסה ראשית ישנה בלי PTX, והכרטיס הוא גרסה ראשית אחרת, ההרצה תיכשל.
איך קוראים את המספרים בעצמכם - deviceQuery¶
כל המספרים שהזכרנו נגישים בזמן ריצה דרך ה-CUDA Runtime, במבנה cudaDeviceProp. הנה תוכנית קצרה שמדפיסה את המאפיינים המרכזיים של ה-SM. שימו לב למקרו CUDA_CHECK - עוטף כל קריאת CUDA, ואם היא נכשלת מדפיס את הקובץ, השורה, הקריאה והשגיאה. נשתמש בו בכל תוכנית host לאורך הקורס:
#include <cstdio>
#include <cstdlib>
#include <cuda_runtime.h>
#define CUDA_CHECK(call) \
do { \
cudaError_t err_ = (call); \
if (err_ != cudaSuccess) { \
fprintf(stderr, "CUDA error at %s:%d\n '%s'\n -> %s\n", \
__FILE__, __LINE__, #call, cudaGetErrorString(err_)); \
exit(EXIT_FAILURE); \
} \
} while (0)
int main(void) {
int dev = 0;
cudaDeviceProp p;
CUDA_CHECK(cudaGetDeviceProperties(&p, dev));
int warpsPerSM = p.maxThreadsPerMultiProcessor / p.warpSize;
printf("GPU : %s\n", p.name);
printf("Compute Capability : %d.%d\n", p.major, p.minor);
printf("SMs : %d\n", p.multiProcessorCount);
printf("Warp size : %d\n", p.warpSize);
printf("Max threads / SM : %d\n", p.maxThreadsPerMultiProcessor);
printf("Max warps / SM : %d\n", warpsPerSM);
printf("Registers / SM : %d\n", p.regsPerMultiprocessor);
printf("Shared mem / SM : %zu KiB\n",
p.sharedMemPerMultiprocessor / 1024);
return 0;
}
קומפילציה והרצה (החליפו את הארכיטקטורה בזו של הכרטיס שלכם):
על H100 SXM נקבל את המספרים שפגשנו לאורך השיעור: 9.0, 132 SMs, 64 warps ל-SM, 65,536 אוגרים ל-SM. ה-API הזה הוא הדרך המהימנה לגלות את גודל המכונה בפועל - עדיף על שינון מספרים, כי הם משתנים מכרטיס לכרטיס. את הפירוק המלא של הפלט נראה בתרגול.
סיכום¶
- ה-SM, ולא ה"ליבה", הוא המקבילה האמיתית של ליבת CPU: ליבת CUDA היא pipe שממיר נתונים, בעוד שה-SM הוא מעבד שלם עם שלושת המרכיבים ההכרחיים - קובץ אוגרים (register file), ליבות שממירות נתונים (cores), ומתזמני פקודות (warp schedulers).
- מספר ה-"CUDA Cores" שמפרסמים הוא ספירת נתיבים, לא ספירת מעבדים; המדד הנכון לגודל המכונה הוא מספר ה-SMs (132 ב-H100) והרכב היחידות בכל SM.
- ה-SM הוא מעבד "פשוט וחלש": אין בו ביצוע ספקולטיבי ואין חיזוי הסתעפויות, אבל כן יש בו ביצוע pipelined; כוחו נובע מתפוקה ומהחלפת הקשר זולה, לא מlatency נמוכה של thread בודד.
- ה-SM מחולק לארבע תת-מחיצות (sub-partitions), ולכל אחת מתזמן warp משלה, פלח מקובץ האוגרים (16,384 אוגרים ב-H100), יחידות החישוב שלה (32 FP32, 16 INT32, 16 FP64) ו-Tensor Core אחד; מעליהן יושב 256 KiB של L1/shared משותף.
- warp שייך לתת-מחיצה אחת לכל חייו, ולכן ארבעה warps מתקדמים בו-זמנית בכל SM - אחד לכל תת-מחיצה.
- יש להבחין בין threads מקביליים באמת (parallel) - 128 ל-SM, 16,896 בכל ה-H100, שמתקדמים בכל מחזור - לבין threads מקביליים-לכאורה (concurrent) - עד 2,048 ל-SM, מעל 270,000 תושבים; הפער ביניהם (יחס 16:1) הוא מאגר הסתרת ה-latency.
- החלפת warp עולה מחזור שעון אחד כי כל האוגרים של כל הthreads התושבים יושבים פיזית בקובץ האוגרים בו-זמנית ולא זזים - החלפת הקשר באפס overhead; מנגד, לחץ האוגרים (register pressure) מגביל כמה warps יכולים להיות תושבים ובכך את ה-occupancy.
- ארכיטקטורת ה-SM היא הגרסה הממוספרת (compute capability,
major.minor) שקובעת תאימות SASS; הגרסה הראשית כמעט נרדפת למשפחת הארכיטקטורה (כל 6.x = Pascal), אך עם חריגות - Ada הוא 8.9, אותה גרסה ראשית כמו Ampere אך משפחה אחרת. - תאימות בין גרסאות ראשיות אינה מובטחת, ואת גרסת ה-SM שאליה מקמפלים בוחרים בזמן הפעלת nvcc; את המספרים של הכרטיס שלכם קוראים בזמן ריצה עם
cudaGetDeviceProperties, עטוף במקרוCUDA_CHECK.