לדלג לתוכן

0.1 למה GPU הרצאה

ברוכים הבאים לקורס תכנות GPU ו-CUDA. לפני שנכתוב שורת קוד אחת, צריך לענות על השאלה הבסיסית ביותר: למה בכלל קיים כרטיס מסך נפרד לחישוב, ולמה המעבד (CPU) שכבר יש לנו במחשב לא מספיק? התשובה איננה "כי ה-GPU מהיר יותר בחישובים מתמטים מסוימים" - זו תשובה שגויה.
המעבד שלכם מהיר בטירוף לthread בודד, הרבה יותר מכל ליבה בודדת ב-GPU. ההבדל האמיתי הוא פילוסופיית תכנון הפוכה לחלוטין: המעבד תוכנן להריץ מעט thread-ים במהירות מקסימלית (latency נמוכה - low latency), וה-GPU תוכנן להריץ עשרות אלפי threads בתפוקה מקסימלית (high throughput). כל הקורס הזה הוא בעצם לימוד של המשמעות של המשפט הזה: איך חושבים, מודדים ומתכנתים במונחים של תפוקה במקום latency. בהרצאה הזו נבנה את התמונה הגדולה - למה עומסי העבודה של היום דורשים את המעבר הזה, מה המחיר של כל צד, ואיך המסע של הקורס נפרש מהחומרה ועד לפרופיילינג של קרנלים אמיתיים.

  • נקודה חשובה: בקורס מוזכרת המילה ״קרנל״, יש להבדיל בין המשמעות שלה בקורס למשמעות שלה בקורס ״ליבת המחשב״- כשמוזכרת המילה ״קרנל״ בקורס לא מדובר על הקרנל של מערכת ההפעלה, אלה על קוד בסיסי שרץ על הgpu, המושג הזה יורחב בהמשך הקורס.

הבעיה שהולידה את ה-GPU - עומסי עבודה מקביליים

בואו נתחיל מהעומסים עצמם. שלוש משפחות של יישומים דוחפות את כל תעשיית החומרה קדימה, ולשלושתן יש מכנה משותף אחד:

  • למידה עמוקה (deep learning): אימון והרצה של רשתות נוירונים הם במהותם כפל מטריצות ענק. שכבה אחת ברשת היא כפל מטריצה בווקטור, ומודל שפה גדול מבצע מיליארדי כפל-והצטברות (multiply-accumulate) בכל אסימון (token) שהוא מפיק.
  • סימולציות מדעיות (scientific simulation): חיזוי מזג אוויר, דינמיקת נוזלים, קיפול חלבונים - כולם פותרים אותה משוואה על מיליוני נקודות רשת (grid points) בו-זמנית.
  • גרפיקה (graphics): הרינדור שבגללו נולד ה-GPU מלכתחילה - צביעת מיליוני פיקסלים במסך, כל פיקסל בחישוב עצמאי.

המכנה המשותף: הרבה מאוד פעולות אריתמטיות זהות על נתונים שונים, ללא תלות ביניהן. כשצריך לחשב y = a*x + y על מיליארד איברים, אין שום סיבה שאיבר מספר 5 יחכה לאיבר מספר 4. זו מקביליות נתונים (data parallelism) טהורה, וזה בדיוק סוג העבודה שבו מעבד רגיל עם שמונה ליבות חזקות מבזבז את רוב הפוטנציאל של הבעיה.

השאלה התכנונית היא אפוא: אם יש לי תקציב קבוע של טרנזיסטורים ושל וואטים, מה עדיף - להשקיע אותם בליבות מעטות וחכמות, או בליבות רבות ופשוטות? המעבד וה-GPU נותנים שתי תשובות הפוכות לאותה שאלה בדיוק.

המעבד - מכונה לlatency נמוכה - CPU

ליבת מעבד מודרנית היא יצירת מופת הנדסית שכל מטרתה אחת: לסיים thread ביצוע בודד כמה שיותר מהר. כדי להשיג את זה, היא משקיעה את רוב שטח הסיליקון שלה לא ביחידות חישוב, אלא במנגנונים שנועדו להסתיר ולעקוף latencies:

  • ביצוע ספקולטיבי (speculative execution): המעבד מנחש לאן הקוד ילך ומתחיל לעבוד על ההמשך עוד לפני שהוא בטוח.
  • חיזוי הסתעפויות (branch prediction): טבלאות חומרה שלמות שמנבאות אם if יילקח או לא, כדי לא לרוקן את הpipeline.
  • ביצוע מחוץ לסדר (out-of-order execution): חלון ביצוע עמוק שמסדר מחדש עשרות פקודות כדי למצוא עבודה בזמן שפקודה אחת תקועה בהמתנה לזיכרון.
  • הcaches הענקיים המנוהלים בחומרה (hardware-managed caches): היררכיית L1/L2/L3 בגודל עשרות מגה-בייט שכל מטרתה שהthread הבודד כמעט אף פעם לא יחכה ל-DRAM.

כל אלה מצוינים לthread אחד, אבל הם יקרים להחריד בשטח ובאנרגיה. התוצאה: מעבד טיפוסי מכיל מעט ליבות (יחידות עד עשרות). כשמריצים עליו עומס מקבילי בגודל מיליארד איברים, שמונה הליבות החזקות שלו הן צוואר בקבוק. הן פותרות בעיה שאין לנו - latency של thread בודד - במקום הבעיה שיש לנו, תפוקה על פני מיליארד איברים.

ה-GPU - מכונה לתפוקה גבוהה - GPU

ה-GPU בוחר בתשובה ההפוכה. במקום מעט ליבות חזקות, הוא בונה מערך גדול של יחידות עיבוד פשוטות וחלשות יחסית שנקראות SM - מעבד רב-זרמי (Streaming Multiprocessor). לפני שנמשיך, אזהרה חשובה שתלווה אותנו לכל אורך הקורס: ה"ליבה" שאתם רואים בשיווק (CUDA Core - ליבת CUDA) איננה המקבילה של ליבת מעבד. ליבת CUDA היא pipeline חישוב בודד. המקבילה האמיתית של ליבת מעבד היא ה-SM כולו - יחידה שלמה עם מתזמנים, אוגרים וcache משלה. נעמיק ב-SM בפרק 1.

ה-SM עצמו הוא, במילות מסמכי NVIDIA, "מעבד פשוט וחלש" בהשוואה לליבת מעבד. ההבדלים המהותיים:

  • אין ביצוע ספקולטיבי.
  • אין חיזוי הסתעפויות. (יש בהחלט ביצוע צינורי - pipelined - בתוך הפקודות, אבל בלי הניחוש.)
  • עלות אחזקה זעומה לthread. לthread ב-GPU כמעט אין מצב פרטי חוץ מהאוגרים שלו.

איך אפשר להיות מהיר בלי כל המנגנונים היקרים של המעבד? כאן נמצא הרעיון המרכזי של כל ארכיטקטורת ה-GPU: הסתרת latency במקום הימנעות ממנה. המעבד מנסה למנוע latency (caches, ניבוי). ה-GPU לא מנסה אפילו - הוא מקבל שהגישה לזיכרון איטית, ובמקום לחכות לה, פשוט מחליף לעבודה אחרת. כשקבוצת threads (warp - קבוצת 32 threads) תקועה בהמתנה לזיכרון, המתזמן מחליף אותה בקבוצה אחרת שהאופרנדים שלה מוכנים.

הקסם הוא במחיר ההחלפה. החלפת הקשר (context switch) במעבד עולה מאות עד אלפי מחזורי שעון - צריך לשמור ולשחזר אוגרים למחסנית. ב-GPU החלפת warp עולה מחזור שעון אחד, כי כל האוגרים של כל ה-warps כבר יושבים פיזית בקובץ האוגרים (register file) של ה-SM ואף פעם לא זזים. זה מעל פי 1000 מהר יותר מהחלפת הקשר במעבד. בגלל שההחלפה כמעט חינם, ה-SM יכול להחזיק מלאי עצום של warps ולדלג ביניהם בכל מחזור שבו מישהו נתקע - וכך יחידות החישוב כמעט אף פעם לא יושבות בטל.

Two design philosophies, same transistor budget

  CPU (low latency)                 GPU (high throughput)
+---------------------+          +---------------------+
| [core] [core]       |          | SM SM SM SM SM SM   |
| [core] [core]       |          | SM SM SM SM SM SM   |
|                     |          | SM SM SM SM SM SM   |
| Huge L3 cache       |          | SM SM SM SM SM SM   |
| Prediction + speculation |     |  ... (132 SM)  ...  |
+---------------------+          +---------------------+
 Little compute, lots of control      Lots of compute, little control, many threads

מקבילי מול מקביל-לכאורה - parallel מול concurrent

כאן חבויה אחת הנקודות שהכי מבלבלות מתחילים, ולכן נדייק אותה במספרים של ה-H100, כרטיס ה-Hopper שישמש אותנו כדוגמה הרצה לאורך כל הקורס. יש שני מספרים שונים לגמרי, וחשוב לא לבלבל ביניהם.

הthreads מקביליים באמת (parallel): אלה הthreads שמתקדמים פיזית במחזור השעון הנוכחי הזה. ל-H100 יש בכל SM ארבעה מתזמני warp (warp schedulers), וכל מתזמן מנפיק פקודה ל-warp אחד (32 threads) בכל מחזור:

4 schedulers  x  32 threads/warp  =  128 threads/SM per cycle
128 threads/SM  x  132 SM         =  16,896 truly parallel threads

זהו: קצת מעל 16,000 threads שבאמת מתקדמים בו-זמנית בכל תקתוק שעון.

הthreads מקביליים-לכאורה (concurrent): אלה הthreads שיושבים ("תושבים" - resident) על ה-SM בו-זמנית, כולם עם האוגרים שלהם שמורים, ומחכים לתורם להתחלף פנימה. כל SM ב-H100 יכול להחזיק עד 64 warps, כלומר 2,048 threads תושבים:

64 warps  x  32 threads  =  2,048 resident threads/SM
2,048 threads/SM  x  132 SM  =  270,336 concurrently-resident threads

מעל רבע מיליון threads חיים בו-זמנית על השבב, אבל רק כ-16,000 מתוכם מתקדמים בכל מחזור נתון. הפער בין שני המספרים - בין 16 אלף לבין 270 אלף - הוא בדיוק מאגר ההסתרה. כשה-16,000 שרצים כרגע נתקעים בהמתנה לזיכרון, המתזמנים שולפים מהמאגר של רבע המיליון threads אחרים שמוכנים לרוץ. ככל שהמאגר הזה מלא יותר (מדד שנקרא occupancy - תפוסה, שנעמיק בו בפרק 8), כך קל יותר להסתיר את הlatencies ולהשאיר את יחידות החישוב עסוקות. הסתרת ה-latency שדיברנו עליה בסעיף הקודם היא בדיוק שאיבה מהמאגר הזה.

הביצוע הוא המוצר - יעילות אנרגטית

כשמדברים על GPU קל להתמקד ב-FLOPS, אבל בעולם מרכזי הנתונים המדד האמיתי הוא לרוב יעילות אנרגטית - ביצועים לוואט. בתרבות ההנדסית של NVIDIA יש אמרה: "הביצוע הוא המוצר" (performance is the product). ביצוע מעולה איננו אופטימיזציה אופציונלית בסוף הפרויקט אלא תנאי הכרחי להצלחה, והוא נמדד תמיד ביחס למשאב: ביצוע לשנייה, ביצוע לדולר, או ביצוע לוואט.

נציב את שתי הפילוסופיות זו מול זו במספרים. ה-H100 מושך עד 700 וואט. נחלק את זה במספר הthreads המקביליים באמת:

H100:  700 W / ~16,896 parallel threads  ≈  0.041 W/thread  (about 0.05 W, i.e. ~5 centi-watts)

AMD EPYC 9965 (CPU):  ~480 W / 384 parallel threads  ≈  1.25 W/thread

הפער הוא בערך פי 25 לטובת ה-GPU בוואט לthread מקבילי. זו לא קסמים - זו התוצאה הישירה של הוויתור על הספקולציה, הניבוי והcaches הענקיים. כל טרנזיסטור שהמעבד משקיע בהסתרת latency של thread בודד הוא טרנזיסטור שצורך חשמל ולא מבצע חשבון. ה-GPU מוציא את התקציב הזה על יחידות חישוב וthreads, ולכן הוא משיג הרבה יותר עבודה שימושית לכל וואט - כל עוד יש לו מספיק מקביליות להאכיל אותן. אם אין מספיק עבודה מקבילית, ה-GPU דווקא בזבזני, כי רוב יחידותיו יושבות בטל.

למה זה היה בלתי נמנע - קיר הזיכרון וסוף Dennard

המעבר לתפוקה לא היה בחירה שיווקית אלא תוצאה של שלוש מגמות חומרה שהצטלבו בתחילת שנות ה-2000. שלושתן הן גם המוטיבציה למודל ה-Roofline (מודל קו-הגג) שנלמד בפרק 7, ולכן כדאי להכיר אותן כבר עכשיו:

  1. הlatency מפגרת אחרי רוחב פס (latency lags bandwidth): תצפית של Patterson מ-2004. לאורך ההיסטוריה, כל שיפור ליניארי בlatency לווה בשיפור ריבועי ברוחב הפס (bandwidth). המסקנה: אי אפשר לבנות מערכות מהירות על ידי קיצור הlatency, כי היא כמעט לא משתפרת. חייבים לבנות מערכות מוכוונות-תפוקה שמסתירות את הlatency - וזה בדיוק מה שה-GPU עושה.

  2. קיר הזיכרון (the memory wall): מונח מ-1994 (Wulf ו-McKee). מהירות החישוב גדלה הרבה יותר מהר ממהירות הזיכרון. הפער הלך והתרחב עד שהיום העברת נתונים, ולא החישוב עצמו, היא צוואר הבקבוק הנפוץ ביותר. נפגוש את זה שוב ושוב כשנדבר על kernels memory-bound.

  3. סוף קנה-המידה של Dennard (end of Dennard scaling): עד תחילת שנות ה-2000 יכולנו להעלות את תדר השעון בלי להעלות את צריכת החשמל. זה נגמר בגלל זרם הדליפה הקבוע של הטרנזיסטורים - מתחת לגודל מסוים, טרנזיסטורים דולפים חשמל גם כשהם כבויים, וזה יוצר בעיות הספק וחום. מרגע שאי אפשר עוד להעלות תדר בחינם, הדרך היחידה קדימה היא התמחות חומרתית (hardware specialization): לפרק את המחשב ליחידות מתמחות שכל אחת עושה משימה מסוימת ביעילות מרבית. ה-GPU, וה-Tensor Core (ליבת טנזור) בתוכו, הם בדיוק הביטוי של ההתמחות הזו.

שלוש המגמות מובילות לאותה מסקנה: העתיד מוכוון-תפוקה, צוואר הבקבוק העיקרי הוא הזיכרון, והדרך להאיץ היא חומרה מתמחה שמאכילים בעבודה מקבילית עצומה. זה ה-GPU.

המחיר שאתם משלמים - ניהול הזיכרון עובר אליכם

לכל בחירה תכנונית יש מחיר, וחשוב להגיד אותו בפה מלא כבר בהתחלה. המעבד השקיע בcaches ענקיים המנוהלים בחומרה, ולכן מתכנת ה-CPU כמעט לא צריך לחשוב על מקומיות (locality) - החומרה מטפלת בזה אוטומטית. ה-GPU ויתר על הcaches הענקיים האוטומטיים כדי לפנות סיליקון לחישוב. המשמעות: ניהול המקומיות עובר אליכם, המתכנתים.

זו לא בעיה, זו התכונה המרכזית של המודל. במקום להסתמך על cache שיחליט מה חשוב, אתם מקבלים זיכרון מהיר קטן שנשלט ידנית - shared memory (זיכרון משותף) - שיושב פיזית ב-SM, ואתם מחליטים מפורשות אילו נתחי נתונים להעלות אליו ומתי. הדוגמה הקלאסית: בכפל מטריצות, במקום לקרוא כל איבר מ-global memory (זיכרון גלובלי) האיטי שוב ושוב, טוענים tile של המטריצה ל-shared memory פעם אחת ומשתמשים בו שוב ושוב. זו הטכניקה שתתפוס פרק שלם (פרק 3), והיא ההבדל בין קרנל תמים לקרנל שמנצל את החומרה. הרעיון שנטיל אותו כאן: ב-GPU, המתכנת הוא מנהל הcache.

פקודה אחת, threads רבים, נתונים שונים - SIMT

נקודה אחרונה לפני שנסגור, ורק כטעימה - נעמיק בה בפרק 2. איך בכלל מריצים 32 threads ב-warp ביחד ביעילות? באמצעות מודל שנקרא SIMT - פקודה יחידה, threads מרובים (Single Instruction, Multiple Threads). כל 32 הthreads ב-warp מבצעים את אותה הפקודה בו-זמנית, אבל כל אחד על נתונים שונים משלו:

One instruction:  c = a + b      (issued once for the whole warp)

  thread 0:  c[0] = a[0] + b[0]
  thread 1:  c[1] = a[1] + b[1]
  thread 2:  c[2] = a[2] + b[2]
   ...
  thread 31: c[31] = a[31] + b[31]

זה מה שמאפשר ל-SM לנהל עשרות אלפי threads בזול: המתזמן מנפיק פקודה אחת בלבד, והיא חלה על 32 threads. המחיר: אם threads באותו warp לוקחים ענפים שונים ב-if (תופעה שנקראת warp divergence - התפצלות ה-warp), הביצועים צונחים, כי ה-warp צריך להריץ את שני הענפים בזה אחר זה. נפגוש את זה לעומק בפרק 2. בינתיים די לזכור: המקבילות ב-GPU היא מקבילות ברמת ה-warp, לא ברמת הthread הבודד.

מפת הדרכים של הקורס - device מול host

לאורך כל הקורס נשתמש בשני מונחי היסוד של NVIDIA, ונקפיד עליהם: ה-host הוא צד ה-CPU והזיכרון הראשי שלו, וה-device הוא ה-GPU. אנחנו כותבים קוד host שרץ על המעבד, ומשגרים ממנו kernel (קרנל - פונקציה שרצה על ה-GPU) שרץ על ה-device. הפרדה זו תלווה כל שורת קוד שנכתוב.

וכך נפרש המסע שלנו, שכל שלב בו בונה על הקודם:

פרק נושא מה נלמד
1 חומרה - hardware SM, מתזמני warp, קובץ האוגרים, היררכיית הזיכרון הפיזית
2 מודל התכנות - programming model thread, block, grid, warp, SIMT, warp divergence
3 זיכרון - memory global מול shared, coalescing (איחוד גישות), bank conflicts
4 PTX ו-SASS מה מייצר nvcc באמת, קריאת האסמבלי של ה-GPU
5 מחסנית ה-host - host stack CUDA Runtime, ניהול זיכרון, streams, CUDA_CHECK
6 ספריות - libraries cuBLAS, cuDNN, CUTLASS ו-CuTe - למה לא לכתוב הכל לבד
7-9 ביצועים ופרופיילינג - performance מודל ה-Roofline, occupancy, coalescing, ncu ו-nsys, פרויקטים

בכל פרק נחזור אל העיקרון היחיד שהצגנו כאן: אנחנו לא מנסים להיות מהירים לthread בודד. אנחנו מנסים להעמיס כמות עצומה של עבודה מקבילית, לשמור את מאגר ה-warps מלא, להסתיר את הlatencies, ולהאכיל את יחידות החישוב. כל כלי, כל מדד וכל אופטימיזציה בקורס משרתים את המטרה הזאת.

למי שאין בבית כרטיס NVIDIA - אין בעיה. אפשר להשתמש ב-Google Colab (שנותן חינם כרטיס T4), או לשכור GPU בענן (למשל Modal, Lambda, או מכונה וירטואלית בענן). את פרטי ההתקנה נסדר בשיעור ההתקנה הבא.

סיכום

  • ה-GPU לא קיים כי הוא "מהיר יותר" מהמעבד - המעבד מהיר יותר לthread בודד. ה-GPU קיים כי הוא מוכוון תפוקה (throughput) במקום latency.
  • המעבד משקיע את רוב הסיליקון שלו בביצוע ספקולטיבי, חיזוי הסתעפויות, ביצוע מחוץ לסדר וcaches ענקיים - כדי לסיים thread בודד מהר. זה יקר, ולכן יש בו מעט ליבות.
  • ה-GPU בונה מערך גדול של SMs "פשוטים וחלשים" בלי ספקולציה ובלי חיזוי הסתעפויות, ומסתיר latency במקום להימנע ממנה - על ידי החלפת warp בעלות של מחזור שעון אחד, מעל פי 1000 מהר יותר מהחלפת הקשר במעבד.
  • יש להבחין בין threads מקביליים באמת (parallel) - כ-16,000 ב-H100 שמתקדמים בכל מחזור - לבין threads מקביליים-לכאורה (concurrent) - מעל 270,000 תושבים; הפער ביניהם הוא מאגר ההסתרה שממנו שואבים כדי להסתיר latencies.
  • ביחידות אנרגטיות, ה-H100 צורך כ-0.05 וואט לthread מקבילי לעומת כ-1.25 וואט לthread במעבד EPYC 9965 - פער של פי 25, ישירות בזכות הוויתור על מנגנוני הlatency הנמוכה. "הביצוע הוא המוצר", ונמדד לשנייה, לדולר ולוואט.
  • המעבר לתפוקה היה בלתי נמנע בגלל שלוש מגמות חומרה: latency מפגרת אחרי רוחב פס, קיר הזיכרון, וסוף קנה-המידה של Dennard - שדחפו להתמחות חומרתית.
  • מכיוון שה-GPU ויתר על הcaches הגדולים האוטומטיים, ניהול המקומיות עובר למתכנת, שמנהל ידנית את ה-shared memory - נושא שנעמיק בו בפרק 3.
  • מודל הביצוע הוא SIMT - פקודה אחת מונפקת ל-warp שלם של 32 threads, כל אחד על נתונים שונים; המקבילות היא ברמת ה-warp, והתפצלות ה-warp (warp divergence) היא צוואר בקבוק שנפגוש בפרק 2.
  • לאורך הקורס נבחין תמיד בין ה-host (המעבד) ל-device (ה-GPU), ונתקדם מהחומרה, דרך מודל התכנות, הזיכרון, ה-PTX/SASS, מחסנית ה-host והספריות, ועד לניתוח ביצועים ופרופיילינג.