לדלג לתוכן

7.2 Compute bound, memory bound ו arithmetic intensity הרצאה

בשיעור 7.1 בנינו את מודל ה-Roofline (מודל קו-הגג): גרף עם שני גגות - גג חישוב אופקי ששווה ל-arithmetic bandwidth (רוחב הפס האריתמטי) של החומרה, וגג זיכרון משופע ששיפועו שווה ל-memory bandwidth (רוחב פס הזיכרון). ראינו גם את ה-ridge point (נקודת הרכס), המקום שבו שני הגגות נפגשים. אבל השארנו שאלה פתוחה: בהינתן kernel קונקרטי, איך יודעים מראש מתחת לאיזה גג הוא נופל - האם הוא compute-bound (חסום-חישוב) או memory-bound (חסום-זיכרון)? בשיעור הזה נענה על כך במדויק. נראה שיש מספר יחיד - ה-arithmetic intensity (עצימות אריתמטית) - שקובע לבדו את הצד: הוא ציר ה-X של ה-Roofline, והשוואתו ל-ridge point מכריעה את הדין. נגדיר את המספר, נחשב אותו לשלושה גרעיני חישוב אמיתיים, נראה למה כפל מטריצות "מנצח" ורשתות נוירונים משגשגות בזכות זה, ונפרוס את דוגמת ה-LLM המפורסמת של מודל 500 מיליארד פרמטרים - זו שמסבירה למה batch של אחד תמיד memory-bound ולמה batching משפר תפוקה כמעט בחינם. את המשמעות המעשית של כל זה - איך לכתוב kernel שיושב בצד הנכון - נעמיק בכל פרק 8.

המספר היחיד שמכריע - arithmetic intensity

ה-arithmetic intensity של kernel מוגדר כיחס בין כמות פעולות החישוב לכמות תנועת הנתונים:

                  floating-point operations the kernel performs   [FLOPs]
arithmetic  =  ----------------------------------------------
 intensity        bytes the kernel moves from/to memory   [bytes]

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

  • המונה - FLOPs. סופרים את פעולות הנקודה-צפה בפועל. מוסכמה חשובה: פעולת MAC (כפל-וצבירה, d = a*b + c) נספרת כשתי פעולות - כפל אחד וחיבור אחד. זו הסיבה שכפל מטריצות של N x N נספר כ-2N^3 ולא N^3 FLOPs.
  • המכנה - bytes moved. לא כמה זיכרון מוקצה, אלא כמה בתים נעים בפועל דרך צוואר הבקבוק הרלוונטי. ב-Roofline הרלוונטי הוא המסלול בין GPU RAM (זיכרון ה-GPU, ה-DRAM) לבין הcache המקומי של ה-SM (מעבד רב-זרמי - Streaming Multiprocessor). קלט שנקרא פעם אחת ואז יושב ב-shared memory (זיכרון משותף) ונקרא ממנו שוב ושוב - נספר פעם אחת בלבד במכנה. זה בדיוק המפתח לכל מה שיבוא.

הרעיון הגדול שמאחורי המספר הזה מגיע היישר מ-7.1: החומרה המודרנית מספקת arithmetic bandwidth גבוה בהרבה מ-memory bandwidth. ב-H100 (כרטיס הייחוס שלנו) יש כ-989 TFLOP/s בכפל מטריצות BF16 מול כ-3.35 TB/s רוחב פס לזיכרון - יחס של כמעט 300 פעולות לכל בית. משמע: אם גרעין מבצע פחות מ-295 FLOPs לכל בית שהוא מזיז, הוא ירעב לנתונים ויבזבז את יחידות החישוב היקרות. arithmetic intensity גבוה הוא, אפוא, המדד ליעילות - הוא אומר שאנחנו מעסיקים את מנועי החישוב המהירים במקום להרעיב אותם על הזיכרון האיטי.

חסום-חישוב - compute-bound

גרעין (kernel) הוא compute-bound כאשר ה-arithmetic intensity שלו גבוה מה-ridge point של ה-Roofline הרלוונטי. במילים של ה-glossary: גרעינים compute-bound "מוגבלים על ידי ה-arithmetic bandwidth של ה-CUDA Cores (ליבות CUDA) או ה-Tensor Cores (ליבות טנזור)". המאפיין המגדיר הוא הרבה פעולות אריתמטיות לכל בית שנטען או נשמר.

הגורם החוסם כאן הוא pipe utilization - ניצול צינורות האריתמטיקה. כשגרעין compute-bound, יחידות החישוב עסוקות עד צוואר; הזיכרון מספיק להביא נתונים, והשאלה היחידה היא כמה מהר הpipelines מסוגלים לטחון. זה הצד המבורך של ה-Roofline - הצד שבו אתם ממצים את החומרה היקרה.

דגש על תחולה. ה-glossary מזהיר: compute-boundedness מוגדר רק עבור kernel יחיד, כחלק ממודל ה-Roofline. אפשר להרחיב אותו לעומס עבודה שלם המורכב מהרבה גרעינים רק "בקירוב", תוך "מצמוץ עיניים". שימו לב לכך כשאתם אומרים "האפליקציה שלי compute-bound" - זו אמירה מקורבת על תערובת של גרעינים.

דוגמאות מהעולם האמיתי:

  • הסקה במודלי דיפוזיה (diffusion). עומסי הסקה גדולים של מודלי דיפוזיה הם בדרך כלל compute-bound - הרבה כפלי מטריצות כבדים על מעט נתונים יחסית.
  • שלב ה-prefill בהסקת LLM. הסקה של מודלי שפה גדולים היא לרוב compute-bound דווקא בשלב עיבוד הפרומפט (batch prefill / prompt processing). הסיבה עמוקה וכדאי לתפוס אותה: בשלב ה-prefill, כל משקל נטען פעם אחת ל-shared memory ואז נעשה בו שימוש חוזר על פני הרבה tokens בבת אחת. אותו משקל שנקרא פעם אחת מהזיכרון משרת עשרות או מאות פעולות - ולכן המכנה (בתים) קטן, המונה (FLOPs) גדול, וה-arithmetic intensity מרקיע. זו בדיוק ההפוכה מ-decode/generation, שנראה מיד שהוא memory-bound.

חסום-זיכרון - memory-bound

גרעין (kernel) הוא memory-bound כאשר ה-arithmetic intensity שלו נמוך מה-ridge point. ה-glossary מדייק: הגרעין מוגבל על ידי "רוחב הפס בין GPU RAM לבין הcache המקומי של ה-SM" - כלומר מסלול ה-DRAM אל cache ה-L1 של ה-SM. יחידות החישוב יושבות ומחכות; המנוע החוסם הוא כמה מהר בתים מגיעים.

מדוע זה תרחיש כה נפוץ? בגלל גודל ה-working set. ה-glossary קובע שבעיות מעניינות בביצועי GPU נוטות להיות עם working set "גדול בהרבה מכל רמה גבוהה יותר בהיררכיית הזיכרון". הנתונים פשוט לא נכנסים לcaches, ולכן חייבים להזרים אותם שוב ושוב מ-DRAM - והזרימה הזו היא צוואר הבקבוק. גם memory-boundedness, בדיוק כמו אחיו, מוגדר פורמלית רק ל-kernel יחיד ומורחב לעומס שלם "במצמוץ".

דוגמאות מהעולם האמיתי:

  • פעולות וקטוריות מכל סוג - חיבור וקטורים, SAXPY, פונקציות אקטיבציה איבר-איבר. נראה מיד ש-arithmetic intensity שלהן קבוע וזעום.
  • שלב ה-decode / generation בהסקת LLM. זו התאומה של ה-prefill, ובדיוק ההיפך ממנו. בשלב יצירת ה-tokens, בכל צעד מייצרים token אחד (ל-batch של אחד), ולשם כך צריך לטעון את כל משקלי המודל מהזיכרון - ולעשות בהם שימוש בודד לפני שנטענים מחדש לצעד הבא. שימוש בודד לכל משקל טעון פירושו arithmetic intensity זעום, ולכן decode ל-batch יחיד הוא memory-bound באופן מובהק. את המספרים המדויקים נראה בפרק הבא.

טבלת הסקיילינג - איך הarithmetic intensity גדלה עם הגודל

עכשיו נחשב את ה-arithmetic intensity לשלושה גרעינים קלאסיים ונראה תופעה מכרעת: הקצב שבו הarithmetic intensity גדלה עם גודל הבעיה N שונה מהותית מגרעין לגרעין. זו הטבלה החשובה ביותר בשיעור:

גרעין FLOPs בתים שזזים arithmetic intensity סקיילינג ב-N
SAXPY (y = a*x + y) 2N 12N 1/6 O(1) - קבוע
FFT ממשי, דיוק יחיד (5/2)·N·log(N) 16N (5/32)·log(N) O(log N)
SGEMM (C = A@B + C) 2N³ 16N² N/8 O(N) - לינארי

בואו נגזור כל שורה, כי הגזירה היא כל הסיפור.

SAXPY - arithmetic intensity קבועה וזעומה. הפעולה y = a*x + y על וקטורים באורך N, בדיוק float (4 בתים). לכל איבר: כפל אחד (a*x) וחיבור אחד (+y) = 2 FLOPs, ובסך הכל 2N. תנועת הזיכרון לכל איבר: קוראים x (4 בתים), קוראים y (4 בתים), כותבים y (4 בתים) = 12 בתים, ובסך הכל 12N.

AI(SAXPY) = 2N / 12N = 1/6 ≈ 0.167 FLOPs/byte   (N cancels out - constant!)

ה-N מתבטל. משמע: לא משנה כמה תגדילו את הווקטור, ה-arithmetic intensity נשאר 1/6 - הרחק, הרחק משמאל ל-ridge point של H100 (295). SAXPY, ובעצם כל פעולה וקטורית איבר-איבר, היא memory-bound תמיד ולנצח.

FFT - arithmetic intensity שגדלה לוגריתמית. ל-FFT ממשי בדיוק יחיד: (5/2)·N·log(N) FLOPs מול 16N בתים.

AI(FFT) = (5/2)·N·log(N) / 16N = (5/32)·log(N)   (N cancels out, log N remains)

כאן ה-N מתבטל אבל נשאר log(N). הarithmetic intensity גדלה עם הגודל, אבל לאט מאוד - לוגריתמית. גם FFT ענק יתקשה לחצות את ה-ridge point.

SGEMM - arithmetic intensity שגדלה לינארית. כפל מטריצות מרובעות N x N: מספר פעולות ה-MAC הוא N^3, ובכל MAC שתי פעולות, ומכאן 2N^3 FLOPs. תנועת הזיכרון: קוראים A (N² איברים × 4 בתים), קוראים B, קוראים C, כותבים C = ארבע מטריצות של N^2 איברים = 16N^2 בתים.

AI(SGEMM) = 2N³ / 16N² = N/8 FLOPs/byte   (N remains - linear!)

וזה ההבדל המהותי: כאן ה-N לא מתבטל. ה-arithmetic intensity של כפל מטריצות גדל לינארית עם הגודל. הסיבה עמוקה: כפל מטריצות עושה O(N^3) פעולות על O(N^2) נתונים - כל איבר קלט משתתף ב-N מכפלות. ככל שהמטריצה גדלה, כל בית טעון "עובד קשה יותר".

למה זה קריטי לרשתות נוירונים. נציב מספרים ל-H100: כדי לחצות את ה-ridge point של BF16 (295), צריך N/8 >= 295, כלומר N >= 2360. מטריצה בגודל 2360 x 2360 ומעלה כבר compute-bound על H100. וזה בדיוק למה מודלים גדולים משגשגים: ככל שהמטריצות גדֵלות, הarithmetic intensity גדֵלה, קל יותר להיות compute-bound, וניצול ה-Tensor Cores משתפר. הסקיילינג הלינארי של SGEMM הוא הסיבה האלגוריתמית שבזכותה חומרת ה-GPU והלמידה העמוקה נישאו זו את זו להצלחה. פעולה וקטורית (arithmetic intensity קבועה) לעולם לא תמצה את החומרה; כפל מטריצות ענק - כן.

הדוגמה המשולבת - מודל LLM בגודל 1 TB

נגיע כעת לדוגמה המכוננת שה-glossary בונה, ושמסבירה בבת אחת את שני הצדדים של ה-Roofline. נגדיר מודל LLM אחד ונשאל עליו שתי שאלות תאומות.

המודל: 500 מיליארד פרמטרים, בדיוק של 16 ביט (2 בתים לפרמטר). הגודל הכולל:

500·10^9 parameters × 2 bytes = 10^12 bytes = 1 TB of weights

כמות החישוב: בכל forward pass, כל פרמטר משתתף בערך בפעולת MAC אחת לכל איבר ב-batch, כלומר כ-2 FLOPs לפרמטר:

FLOPs per batch element ≈ 2 × 500·10^9 = 10^12 FLOP = ~1 TFLOP

שאלה 1 - decode ל-batch יחיד (הצד memory-bound). נניח GPU עם memory bandwidth של 10 TB/s. כדי לייצר token אחד, חייבים לטעון את כל 1 TB המשקלים מהזיכרון:

weight loading time = 1 TB / 10 TB/s = 0.1 s = 100 ms

זה קורה פעם אחת לכל token פלט (אלא אם משתמשים ב-multi-token prediction או speculative decoding). לכן ה-intertoken latency (השהיה בין tokens, או time per output token) המינימלי הוא כ-100 ms/token. ומה ה-arithmetic intensity כאן? מונה של ~1 TFLOP חלקי מכנה של 1 TB:

AI(decode, batch=1) = 10^12 FLOP / 10^12 bytes = 1 FLOP/byte

הarithmetic intensity של 1 - הרחק משמאל לכל ridge point סביר. decode ל-batch יחיד הוא memory-bound קלאסי. יחידות החישוב יושבות בטלות ומחכות שהמשקלים יזרמו.

שאלה 2 - מה נדרש כדי להיות compute-bound ב-batch יחיד? נניח שה-GPU מסוגל ל-1 PFLOP/s (10^15 FLOP/s) בכפל מטריצות 16 ביט. זמן החישוב:

compute time = 10^12 FLOP / 10^15 FLOP/s = 10^-3 s = 1 ms

כדי שהחישוב (1 ms) יהיה צוואר הבקבוק ולא הזיכרון, היינו צריכים לטעון 1 TB תוך 1 ms:

required bandwidth = 1 TB / 1 ms = 1 PB/s (!)

1 פטהבייט לשנייה - פי אלף בערך מרוחבי הפס של ימינו (שהם בטווח ה-TB/s). המסקנה חדה: אין שום סיכוי להיות compute-bound ב-decode של batch יחיד. הפער בין 10 TB/s הזמינים ל-1 PB/s הנדרש הוא מדוע.

הפתרון - batching, והתפוקה שגדלה בחינם. כאן הקסם. אם נעבד batch של B קלטים יחד, נטען את אותם משקלים (1 TB) פעם אחת בלבד, אבל נעשה בהם B פעמים יותר עבודת חישוב. המכנה נשאר קבוע, המונה גדל פי B, ולכן ה-arithmetic intensity גדל לינארית:

AI(decode, batch=B) = B × 10^12 FLOP / 10^12 bytes = B FLOPs/byte

והזמן? זהה. אותם 100 ms לטעינת המשקלים משרתים עכשיו B tokens במקום אחד. משמע ה-throughput (תפוקה) גדל לינארית ב-B בלי לשלם ולו מילישנייה נוספת של latency. זה נמשך "בחינם" עד שמגיעים ל-ridge point - הנקודה שבה הarithmetic intensity B משתווה ל-ridge, ומשם והלאה כבר נהיים compute-bound ואי אפשר להוציא עוד. עבור ה-GPU ההיפותטי הזה (ridge = 1 PFLOP/s ÷ 10 TB/s = 100), צריך batch בסדר גודל של ~100 קלטים כדי להתקרב לcompute-bound. זו הסיבה שמערכות הסקת LLM עובדות ב-batching אגרסיבי: לא כדי לחסוך latency לבקשה בודדת, אלא כדי לרכוב על העלייה הלינארית בarithmetic intensity ולמצות את החומרה.

טבלת ה-ridge point - החומרה והדיוק מזיזים את הרכס

ה-ridge point אינו מספר אחד. הוא arithmetic bandwidth חלקי memory bandwidth, ולכן משתנה עם הכרטיס, עם סוג הזיכרון, ובעיקר עם דיוק החישוב. הנה הטבלה הקונקרטית, ישירות מנתוני החומרה:

מערכת / תצורה arithmetic BW memory BW ridge point (FLOPs/byte)
A100 80GB SXM, BF16 / HBM2e 312 TFLOP/s 2 TB/s 156
H100 SXM, BF16 / HBM3 989 TFLOP/s 3.35 TB/s 295
B200, BF16 / HBM3e 2250 TFLOP/s 8 TB/s 281
H100 SXM, FP8 / HBM3 1979 TFLOP/s 3.35 TB/s 592
B200, FP8 / HBM3e 4500 TFLOP/s 8 TB/s 562
B200, FP4 / HBM3e 9000 TFLOP/s 8 TB/s 1125

כל ערך בעמודה האחרונה הוא פשוט חלוקה: 312/2 = 156, 989/3.35 ≈ 295, 2250/8 ≈ 281, וכן הלאה. שתי מגמות עולות מהטבלה, ושתיהן אומרות אותו דבר - קשה יותר ויותר להיות compute-bound:

מגמה 1 - הרכס נודד ימינה בין דורות. A100 (Ampere) עם 156, H100 (Hopper) עם 295. ככל שהחומרה מתקדמת, ה-arithmetic bandwidth גדל מהר יותר מה-memory bandwidth (זו בדיוק "חומת הזיכרון" מ-7.1: החישוב מדביק את הזיכרון). התוצאה: ה-ridge point נודד ימינה, ונדרשת arithmetic intensity גבוהה יותר כדי לחצות אותו. שימו לב ש-B200 (281) נראה מעט נמוך מ-H100 - זה משום ש-HBM3e של Blackwell קפץ ל-8 TB/s וקצת פיצה, אבל המגמה ההיסטורית הכוללת Ampere -> Hopper -> Blackwell היא של רכס עולה.

מגמה 2 - דיוק נמוך דוחף את הרכס ימינה בחדות. הביטו ב-B200: BF16 נותן ridge 281, FP8 נותן 562, FP4 נותן 1125. הסיבה: ירידה בדיוק מכפילה את ה-arithmetic bandwidth (FP8 מכפיל את FP16, FP4 מכפיל שוב) בעוד שה-memory bandwidth אינו משתנה - אותו HBM3e של 8 TB/s. ולכן היחס, שהוא ה-ridge point, קופץ. המסקנה מפתיעה ולא-אינטואיטיבית: ככל שיורדים בדיוק, קשה יותר להישאר compute-bound. ב-FP4 אתם צריכים arithmetic intensity של 1125 FLOPs לכל בית כדי למצות את החומרה - סף אדיר. הדיוק הנמוך נותן לכם המון כוח חישוב, אבל דורש מכם עוד יותר arithmetic intensity כדי בכלל להזין אותו. את זה נראה בפועל כשנכוונן GEMM ב-FP8 בפרק 8.

להחליף עבודת זיכרון בעבודת חישוב - להעלות arithmetic intensity בכוונה

מהמודל נובעת אסטרטגיית אופטימיזציה עוצמתית. כאשר גרעין memory-bound, החישוב ממילא יושב בטל. אז אפשר להעביר עבודה מתת-מערכת הזיכרון לתת-מערכת החישוב - לחסוך ברוחב פס הזיכרון על חשבון עומס נוסף על יחידות האריתמטיקה שבין כה וכה פנויות. אנחנו מזיזים ביודעין את ה-arithmetic intensity ימינה, לכיוון ה-ridge point. שתי טכניקות קלאסיות:

דחיסה - compression. מאחסנים ומעבירים נתונים דחוסים, כך שפחות בתים זזים דרך צוואר הבקבוק. המחיר: צריך decompress - חישוב פענוח שנוסף ל-arithmetic units. אם הגרעין היה memory-bound, החישוב הנוסף כמעט "חינם" (הpipelines היו בטלים), והחיסכון בבתים מזיז אותנו ימינה על ה-Roofline. הרווחנו.

gradient checkpointing - נקודות ביקורת בגרדיאנט. טכניקה מפורסמת מאימון רשתות נוירונים (Arxiv:1604.06174). בזמן backpropagation צריך את ערכי הביניים (activations) של ה-forward pass. במקום לאחסן את כולם ב-global memory ולקרוא אותם שוב (הרבה בתים זזים), מחשבים אותם מחדש מנקודות ביקורת ספורות בזמן ה-backward. מחליפים תעבורת זיכרון בחישוב חוזר - וכך מעלים את ה-arithmetic intensity ובד בבד חוסכים זיכרון. זה בדיוק אותו עיקרון: recompute במקום store, חישוב במקום זיכרון.

הכלל הכללי: חישוב זול, זיכרון יקר. כל עוד אתם משמאל ל-ridge point, כדאי כמעט תמיד לשלם בעוד FLOPs כדי לחסוך בבתים - עד שאתם מגיעים לרכס, ומשם והלאה כבר לא (כי אז החישוב עצמו נהיה צוואר הבקבוק). זה בדיוק הרעיון שמאחורי fused kernels, Flash Attention, וכל טכניקת ה-fusion שנראה בפרק 8 - כולן, בבסיסן, מהלכים להעלאת arithmetic intensity.

סיכום

  • ה-arithmetic intensity (עצימות אריתמטית) הוא היחס FLOPs / bytes moved, ציר ה-X של ה-Roofline, והמספר היחיד שמכריע אם kernel compute-bound (arithmetic intensity מעל ה-ridge point) או memory-bound (arithmetic intensity מתחת לו); מונים MAC כשתי פעולות, וסופרים במכנה רק בתים שזזים בפועל דרך צוואר הבקבוק DRAM אל cache ה-SM.
  • גרעין חסום-חישוב (compute-bound) מוגבל ב-arithmetic bandwidth של ה-CUDA/Tensor Cores, מאופיין בarithmetic intensity גבוהה, וגורם החסימה שלו הוא pipe utilization; דוגמאות: הסקת דיפוזיה גדולה ושלב ה-prefill ב-LLM, שבו כל משקל נטען פעם אחת ומשמש הרבה tokens.
  • גרעין חסום-זיכרון (memory-bound) מוגבל ברוחב הפס בין GPU RAM לcache ה-SM, מאופיין בarithmetic intensity נמוכה ובעל working set הגדול מcaches; דוגמאות: פעולות וקטוריות ושלב ה-decode/generation ב-LLM (batch יחיד).
  • טבלת הסקיילינג: SAXPY היא 2N/12N = 1/6, arithmetic intensity O(1) קבועה וזעומה (memory-bound תמיד); FFT ממשי הוא (5/32)log(N), גדל O(log N); SGEMM הוא 2N³/16N² = N/8, גדל O(N) לינארית - ולכן מטריצות גדולות יותר קלות יותר להיות compute-bound, וזו הסיבה האלגוריתמית להצלחת רשתות הנוירונים.
  • בדוגמת ה-LLM של 1 TB (500 מיליארד פרמטרים × 16 ביט): ב-10 TB/s ה-decode ל-batch יחיד memory-bound ב-~100 ms/token; כדי להיות compute-bound ב-batch יחיד היה נדרש רוחב פס של ~1 PB/s (פי אלף מהמצוי).
  • קיבוץ (batching) מעלה את הarithmetic intensity לינארית - אותם משקלים נטענים פעם אחת ומשמשים B קלטים - ולכן ה-throughput גדל לינארית ב-batch כמעט בחינם, עד שמגיעים ל-ridge point ונהיים compute-bound.
  • טבלת ה-ridge point (arithmetic BW / memory BW): A100 BF16 = 156, H100 BF16 = 295, B200 BF16 = 281, H100 FP8 = 592, B200 FP8 = 562, B200 FP4 = 1125; הרכס נודד ימינה בין דורות (החישוב מדביק את הזיכרון) ובחדות בדיוק נמוך (FP8/FP4 מכפילים arithmetic BW בעוד memory BW קבוע) - ולכן קשה יותר להישאר compute-bound.
  • אפשר להעלות arithmetic intensity בכוונה כדי לברוח מmemory-boundedness: דחיסה (חוסכת בתים, מוסיפה עלות decompress) ו-gradient checkpointing (recompute במקום store) - שתיהן מחליפות עבודת זיכרון בעבודת חישוב, לפי הכלל "חישוב זול, זיכרון יקר".