לדלג לתוכן

1.3 יחידות ביצוע CUDA cores, SFU ו LSU הרצאה

בשיעור 1.2 פירקנו את ה-SM (מעבד רב-זרמי - Streaming Multiprocessor) לרכיביו וראינו שהוא, ולא ה"ליבה" הבודדת, הוא המקבילה האמיתית לליבת CPU (מעבד): יש לו קובץ אוגרים (register file), יש לו מתזמנים, ויש לו יחידות ביצוע. בשיעור הזה אנחנו נכנסים אל תוך יחידות הביצוע האלה - ה"pipes" שבתוך ה-SM - ולומדים לחשוב עליהן נכון. נפגוש שלושה סוגים: ה-CUDA Core (ליבת CUDA) שמבצע אריתמטיקה סקלרית, ה-SFU (יחידת פונקציות מיוחדות - Special Function Unit) שמאיץ פונקציות טרנסצנדנטיות כמו exp ו-sin, וה-LSU (יחידת טעינה ואחסון - Load/Store Unit) שמדבר עם הזיכרון. הרעיון המרכזי שכל השיעור נשען עליו הוא שכל "ליבה" ב-GPU היא pipe מיוחד - נתונים נכנסים, נתונים מעובדים יוצאים - ולא מעבד עצמאי. וכשמבינים את זה, מבינים גם למה הספירה השיווקית "כך וכך CUDA cores" מטעה, ולמה כדי להעריך ביצועים צריך לספור את היחידה שמבצעת בדיוק את הפעולה שלכם, לא את המספר הכולל.

הליבה כpipe - The Core as a Pipe

הטעות הנפוצה ביותר של מי שמגיע מעולם ה-CPU היא לקרוא "16,896 CUDA cores" במפרט של H100 ולחשוב "יש כאן 16,896 מעבדים קטנים שכל אחד מריץ תוכנית משלו". זו תמונה שגויה מיסודה. ליבת GPU אינה מעבד עצמאי. הדרך הנכונה לחשוב עליה, כפי שמנסח זאת מילון המונחים, היא כpipe שנתונים נכנסים אליו ונתונים מעובדים יוצאים ממנו. לכל pipe כזה יש שני מאפיינים שמגדירים אותו:

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

ליבה בודדת יודעת רק דבר אחד: להפוך נתונים. אין לה אוגרים משלה בנפרד, אין לה מתזמן משלה, אין לה זרם הוראות עצמאי. כל אלה שייכים ל-SM שמסביבה. לכן, כפי שהדגשנו בשיעור 1.2, ה-SM הוא המקבילה ל"ליבת מעבד" - הוא זה שמחזיק את שלושת המרכיבים של מעבד עצמאי (זיכרון אוגרים, יחידות שממירות נתונים, ומתזמנים שמפקדים על ההמרות). הליבה הבודדת היא רק אחד מהpipes שבתוכו.

התמונה המנטלית הזו היא לב השיעור. כשנסתכל בהמשך על CUDA Core, SFU ו-LSU, נראה שלושה pipes שונים - כל אחד עם הוראות משלו ותפוקה משלו - שיושבים זה לצד זה בתוך אותו SM וניזונים מאותו מתזמן warp:

      one SM sub-partition (one of four in H100)
  +------------------------------------------------+
  |  Warp Scheduler  (picks a warp, issues one instruction)   |
  +---------+--------------+-----------+-----------+
            |              |           |
            v              v           v
     +-------------+  +---------+  +---------+
     | 32x CUDA    |  |  SFU    |  |  LSU    |
     | Core (ALU)  |  | (MUFU)  |  | (memory)|
     +------+------+  +----+----+  +----+----+
            |              |            |
            v              v            v
     scalar            exp/sin/sqrt   load/store
     arithmetic                       to L1/global
            \______________|____________/
                           |
                    Register File (shared register file)

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

ליבת CUDA - מה באמת סופרים

ה-CUDA Core הוא הpipe שמבצע הוראות אריתמטיות סקלריות - חיבור, חיסור, כפל, כפל-חיבור מאוחד (FMA), השוואות. הוא הניגוד ל-Tensor Core (ליבת טנזור), שמבצע פעולות מטריצה שלמות ולא סקלרים בודדים (נעסוק בו בשיעור נפרד). עד כאן פשוט. אבל כאן מגיעה הדקות שרוב האנשים מפספסים: "CUDA Core" הוא לא הגדרת חומרה אחידה. הוא שם-מטרייה. מילון המונחים מנסח זאת במפורש: CUDA Core יכול להיות מורכב מיחידות שונות - תערובת משתנה של יחידות שלמים ב-32 סיביות (INT32), יחידות נקודה צפה ב-32 סיביות (FP32), ויחידות נקודה צפה ב-64 סיביות (FP64). התערובת המדויקת משתנה בין דורות הארכיטקטורה - Volta, Turing, Ampere, Hopper, Blackwell.

ומכאן הנקודה הכי חשובה בקטע הזה: המספר המפורסם "CUDA cores" סופר רק את נתיבי ה-FP32. ניקח את H100 כדוגמה הקונקרטית. ה-whitepaper של NVIDIA מתאר כל SM כבעל 128 "FP32 CUDA Cores". אבל אותו SM מכיל בערך חצי מזה יחידות INT32 וחצי מזה יחידות FP64:

סוג יחידה כמות ל-SM ב-H100 יחס ל-FP32
נתיבי FP32 (נקודה צפה 32 סיביות) 128 1x (המספר המפורסם)
נתיבי INT32 (שלם 32 סיביות) 64 חצי
נתיבי FP64 (נקודה צפה 64 סיביות) 64 חצי

המסקנה המעשית חדה: תפוקת ה-FP64 היא שבריר מתפוקת ה-FP32 - חצי, בגלל חצי מספר הנתיבים. אם הקוד שלכם עושה חשבון בדיוק כפול (double), אתם עובדים על 64 נתיבים ל-SM, לא 128, וזה עוד לפני ששקללנו ש-FP64 עשוי לצרוך יותר ממחזור אחד. באותו אופן, kernel שעושה הרבה אינדקסים וחשבון שלמים כבד עלול להיות bound על 64 נתיבי ה-INT32, בזמן שנתיבי ה-FP32 יושבים בטלים. המספר "128 CUDA cores" לא מספר לכם על שני המצבים האלה כלום.

מודל SIMT - הוראה אחת, נתונים רבים

איך בעצם משגרים עבודה ל-128 נתיבי ה-FP32? כאן נכנס מודל ה-SIMT (Single Instruction, Multiple Threads). מתזמן ה-warp (Warp Scheduler) לוקח קבוצה של CUDA Cores ומשגר אליהם הוראה אחת ויחידה בו-זמנית, אבל כל ליבה מפעילה אותה על אוגרים אחרים. כלומר: אותה הוראת FADD, 32 threads (threads), 32 זוגות מספרים שונים, תוצאה אחת לכל thread. קבוצת 32 הthreads הזו היא ה-warp (קבוצת 32 threads) - זו בדיוק הסיבה שגודל ה-warp הוא 32, כי זו רוחב הלהקה שהמתזמן מזין בהוראה אחת.

זה שונה מהותית ממודל ה-CPU. ב-CPU כל ליבה מביאה (fetch) ומפענחת (decode) את ההוראה שלה בעצמה, מזרם הוראות עצמאי. ב-GPU ה-fetch וה-decode קורים פעם אחת במתזמן, והתוצאה משודרת ל-32 נתיבים. זה מוזיל אדירות את חומרת הבקרה ומשאיר יותר שטח סיליקון ליחידות החשבון עצמן - וזה בדיוק המקור לתפוקה האדירה של GPU.

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

   one warp = 32 threads, one instruction (FFMA), 32 different data values
   ---------------------------------------------------------
   instruction:   FFMA R2, R0, R1, R3     (on all 32 lanes)
   lane 0:  R0=a0  R1=b0  ->  a0*b0+c0
   lane 1:  R0=a1  R1=b1  ->  a1*b1+c1
   ...
   lane 31: R0=a31 R1=b31 ->  a31*b31+c31

יחידת הפונקציות המיוחדות - SFU

לא כל פעולה מתמטית היא חיבור או כפל. מה קורה כשה-kernel קורא ל-expf(x), sinf(x) או sqrtf(x)? הפונקציות האלה, שנקראות פונקציות טרנסצנדנטיות, יקרות מכדי לחשב אותן בסדרה ארוכה של הוראות FMA רגילות. לכן ל-SM יש pipe ייעודי בשבילן: ה-SFU (יחידת פונקציות מיוחדות). ה-SFU מאיץ בחומרה בדיוק את המשפחה הזו - מילון המונחים נוקב במפורש ב-exp, sin ו-cos, ומדגיש את הרלוונטיות שלהן לרשתות נוירונים (האקספוננטים ב-softmax ובפונקציות אקטיבציה).

איך מזהים שימוש ב-SFU? כאן מגיעה העובדה הכי מעשית של הקטע. ההוראות ב-SASS (השפה האסמבלית המקורית של ה-GPU) שקשורות ל-SFU מתחילות ב-MUFU (ראשי תיבות של Multi-Function Unit). דוגמאות:

הוראת SASS מה היא מחשבת מאחורי איזה קוד CUDA
MUFU.EX2 חזקה בבסיס 2 (2^x) expf (דרך כפל ב-log2(e))
MUFU.SQRT שורש ריבועי חלק מנתיבי sqrtf
MUFU.RSQ הופכי השורש (1/sqrt) rsqrtf, ובסיס לחישוב sqrtf מדויק
MUFU.SIN / MUFU.COS סינוס / קוסינוס sinf / cosf
MUFU.LG2 לוגריתם בבסיס 2 logf (דרך כפל)

שימו לב לפרט מלמד: expf אינו מיושם על ידי הוראת "exp" ישירה. הcompiler מחשב expf(x) בתור 2^(x * log2(e)) - כלומר כפל אחד ואז MUFU.EX2. זו הסיבה שכשתחפשו את האקספוננט ב-SASS תמצאו MUFU.EX2 ולא איזו "MUFU.EXP".

עכשיו לגוטצ'ה הגדולה: ה-SFU נדיר יחסית לנתיבי ה-FP32. מילון המונחים לא מתחייב למספר, אבל ההקשר החומרתי הידוע הוא שיש כ-4 יחידות SFU לכל תת-מחיצה של SM - כלומר בסביבות 16 ל-SM, מול 128 נתיבי FP32. היחס הזה, בערך 8:1, אומר שפעולה טרנסצנדנטית עולה בערך פי-שמונה בתפוקה מפעולת FMA רגילה. המשמעות: kernel שעמוס בטרנסצנדנטיות - softmax, GELU, sigmoid, שכבות אקטיבציה - עלול להיות bound על תפוקת ה-SFU, בזמן שנתיבי ה-FP32 השופעים יושבים חלקית בטלים. הדרך לזהות את זה בפרופיילינג היא לחפש ריבוי של MUFU.* ב-SASS, ולהסתכל על ניצול pipe ה-xu (הpipe הטרנסצנדנטי) בכלי הפרופיילינג ncu. טרנסצנדנטיות הן לא "פעולות סקלריות חינם" - הן נתב לpipe הנדיר.

יחידת הטעינה והאחסון - LSU

הpipe השלישי לא עושה חשבון בכלל - הוא מדבר עם הזיכרון. ה-LSU (יחידת טעינה ואחסון) הוא הרכיב ב-SM שמשגר בקשות לטעון או לאחסן נתונים אל תת-מערכות הזיכרון של ה-GPU. הוא השער של ה-SM אל הזיכרון. כשthread מבצע c[i] = a[i] + b[i], שתי הטעינות (a[i], b[i]) והאחסון (c[i]) לא עוברים דרך ה-CUDA Cores - הם עוברים דרך ה-LSU. ה-CUDA Core עושה רק את החיבור עצמו שבאמצע.

ה-LSU נוגע בשני קצות היררכיית הזיכרון:

   thread performs load/store
          |
          v
      +--------+     directly     +------------------+
      |  LSU   | -------------> |  L1 Data Cache   |  (on-chip SRAM, fast)
      +--------+                +------------------+
          |                              |
          | indirectly                    v
          +------------------------> Global GPU RAM  (HBM3, 80 GiB, relatively slow)

כלומר ה-LSU מקשר בין החישוב לשני הקצוות של היררכיית הזיכרון במודל התכנות של CUDA: הרמה הנמוכה והמהירה (ה-L1 שהוא SRAM על השבב, מדובר עליו יחד עם shared memory) והרמה הגבוהה והגדולה (ה-global memory ב-HBM3 מחוץ לשבב). ב-H100 ה-L1/shared הם 256 KiB ל-SM, וה-global הוא 80 GiB HBM3 - הבדל של סדרי גודל במהירות ובקיבולת.

והנה הגוטצ'ה המרכזית של כל השיעור, זו שמצדיקה את כל המסגור של "pipes": הוראות זיכרון לא עוברות דרך ה-CUDA Cores. יש להן pipe נפרד ומוגבל משלהן - ה-LSU - ומאחוריו תת-מערכת הזיכרון. לכן kernel יכול להיות memory-bound - כלומר צוואר הבקבוק שלו הוא ה-LSU ורוחב הפס של ה-HBM - בזמן שיחידות החשבון (ה-ALU) יושבות בטלות לגמרי. חיבור וקטורים הוא הדוגמה הקלאסית: על כל טעינה-טעינה-אחסון (שלוש פעולות LSU) יש חיבור אחד בלבד (פעולת ALU אחת). היחס הזה, שנקרא arithmetic intensity, נמוך כל כך שה-ALU כמעט ולא עובד וה-GPU כולו ממתין ל-HBM. נבנה על התובנה הזו את מודל ה-Roofline בפרק מאוחר יותר.

מתודולוגיה - לספור את היחידה הנכונה

עכשיו נאסוף הכל לכלל שיטת עבודה אחת, וזו העצה המפורשת של מילון המונחים: כדי להעריך ביצועים, אל תסתכלו על המספר הכולל "CUDA cores". ספרו את יחידות החומרה של הפעולה הספציפית שלכם. כי "CUDA Core" הוא תערובת, והמספר הכולל מסתיר כמה יחידות באמת יש לכם עבור ה-INT32, ה-FP32, ה-FP64, הטרנסצנדנטיות או הזיכרון שאתם משתמשים בהם בפועל.

בואו נראה את זה במספרים על H100 SXM (132 SM, שעון boost בערך 1.98 GHz). נחשב את תפוקת השיא לכל סוג פעולה:

FP32 (the famous operation):
   132 SM  x  128 FP32 lanes  x  2 (multiply+add in FMA)  x  1.98 GHz
   = 66.9 TFLOPS                         <-- this is the "66.9 TFLOPS" in the spec

FP64 (half the lanes):
   132 SM  x  64 FP64 lanes   x  2 (FMA)  x  1.98 GHz
   = 33.5 TFLOPS                         <-- exactly half of FP32

ratio FP64 : FP32 = 33.5 : 66.9 = 1 : 2

היחס 1:2 הזה הוא לא צירוף מקרים - הוא נובע ישירות מכך שיש חצי מספר נתיבי FP64. וזו בדיוק דוגמה למה ש"128 CUDA cores" לא אומר לכם: אם ה-kernel שלכם ב-double, השיא התיאורטי שלכם הוא 33.5 TFLOPS, לא 66.9. אפשר (וכדאי) לאמת את זה מול טבלת המפרט ב-whitepaper של H100 - שם רשום FP32 של 66.9 TFLOPS ו-FP64 (לא-טנזור) של 33.5 TFLOPS. (הערה: ה-whitepaper מציין גם FP64 Tensor Core של 67 TFLOPS - זה pipe אחר לגמרי, ה-Tensor Core, לא נתיבי ה-FP64 הרגילים; אל תבלבלו ביניהם.)

השיטה בפעולה, שלב אחר שלב, מול kernel נתון:

  1. מה סוג הפעולה הדומיננטית? שלם, FP32, FP64, טרנסצנדנטית, או גישת זיכרון?
  2. כמה יחידות יש לפעולה הזו ל-SM? (FP32: 128, INT32/FP64: 64, SFU: כ-16, LSU: כמה, HBM: רוחב פס).
  3. חשבו את השיא לפי היחידה הזו - לא לפי המספר הכולל.
  4. מדדו בפועל עם ncu והשוו לשיא כדי לדעת כמה רחוקים אתם, ועל איזה pipe אתם באמת bound.

זו המהות: המספר הכולל הוא סיסמה שיווקית. הpipe הספציפי הוא המציאות.

טעויות נפוצות ואיך להימנע מהן

  • "יש לי 16,896 מעבדים". לא. יש לכם 132 SM (המקבילה לליבות), וכל אחד מנהל pipes של נתיבים. חשבו ברמת ה-SM ורמת הpipe, לא ברמת ה"ליבה" הבודדת.
  • "double זה כמו float, רק גדול יותר". מבחינת תפוקה, double רץ על חצי מהנתיבים - שיא של חצי. kernel ב-FP64 שנראה "איטי" אולי פשוט רץ על הpipe הצר יותר.
  • "exp ו-sin הן פעולות חשבון רגילות". הן נתב ל-SFU הנדיר (MUFU.*), לא ל-CUDA Cores השופעים. עומס טרנסצנדנטי כבד bound על pipe ה-SFU.
  • "ה-GPU שלי איטי כי ה-ALU חלש". אולי ה-ALU בכלל לא עובד. אם ה-kernel memory-bound, הpipe הפעיל הוא ה-LSU וה-HBM, וה-ALU יושב בטל. תמדדו לפני שתאשימו.
  • "אחפש MUFU.EXP ב-SASS". אין חיה כזו. expf מיושם עם MUFU.EX2 (חזקת בסיס 2) אחרי כפל ב-log2(e). חפשו את התבנית הזו.

סיכום

  • כל "ליבה" ב-GPU היא pipe - נתונים נכנסים, נתונים מעובדים יוצאים - עם קבוצת הוראות משלה ותפוקה משלה, ולא מעבד עצמאי; המקבילה האמיתית לליבת CPU היא ה-SM כולו.
  • ה-CUDA Core מבצע אריתמטיקה סקלרית והוא שם-מטרייה לתערובת משתנה של יחידות INT32, FP32 ו-FP64; התערובת משתנה בין דורות ארכיטקטורה.
  • המספר המפורסם "CUDA cores" סופר רק את נתיבי ה-FP32; ב-H100 יש 128 FP32 ל-SM, שהם פי-שניים ממספר נתיבי ה-INT32 ופי-שניים ממספר נתיבי ה-FP64.
  • מודל ה-SIMT: מתזמן ה-warp משגר הוראה אחת ל-32 CUDA Cores שמפעילים אותה על אוגרים שונים; dispatch לthread בודד אפשרי אך איטי ומאבד את רוחב ה-SIMT.
  • ה-SFU מאיץ פונקציות טרנסצנדנטיות (exp, sin, cos, sqrt); ההוראות שלו ב-SASS מתחילות ב-MUFU (למשל MUFU.EX2 מאחורי expf, MUFU.SQRT), והוא נדיר יחסית לנתיבי ה-FP32 - ולכן kernel טרנסצנדנטי כבד (softmax, אקטיבציות) עלול להיחסם עליו.
  • ה-LSU הוא pipe הגישה לזיכרון; הוא משגר load/store, מדבר ישירות עם ה-L1 (SRAM על השבב) ובעקיפין עם ה-global memory (HBM3) - הוראות הזיכרון לא עוברות דרך ה-CUDA Cores.
  • מכיוון שהזיכרון עובר דרך pipe ה-LSU הנפרד, kernel יכול להיות memory-bound בזמן שיחידות ה-ALU יושבות בטלות לחלוטין - זו הסיבה שarithmetic intensity נמוכה משאירה את ה-GPU ממתין ל-HBM.
  • המתודולוגיה המרכזית: כדי להעריך ביצועים, ספרו את יחידות החומרה של הפעולה הספציפית (INT32 מול FP32 מול FP64 מול טרנסצנדנטית מול זיכרון), לא את המספר הכולל של "CUDA cores".
  • דוגמה מספרית ל-H100: 132x128x2x1.98 GHz נותן 66.9 TFLOPS ב-FP32, ומחצית הנתיבים נותנת 33.5 TFLOPS ב-FP64 - יחס 1:2, בדיוק כפי שרשום ב-whitepaper.