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^3FLOPs. - המכנה - 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.
ה-N מתבטל. משמע: לא משנה כמה תגדילו את הווקטור, ה-arithmetic intensity נשאר 1/6 - הרחק, הרחק משמאל ל-ridge point של H100 (295). SAXPY, ובעצם כל פעולה וקטורית איבר-איבר, היא memory-bound תמיד ולנצח.
FFT - arithmetic intensity שגדלה לוגריתמית. ל-FFT ממשי בדיוק יחיד: (5/2)·N·log(N) FLOPs מול 16N בתים.
כאן ה-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 בתים.
וזה ההבדל המהותי: כאן ה-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 בתים לפרמטר). הגודל הכולל:
כמות החישוב: בכל forward pass, כל פרמטר משתתף בערך בפעולת MAC אחת לכל איבר ב-batch, כלומר כ-2 FLOPs לפרמטר:
שאלה 1 - decode ל-batch יחיד (הצד memory-bound). נניח GPU עם memory bandwidth של 10 TB/s. כדי לייצר token אחד, חייבים לטעון את כל 1 TB המשקלים מהזיכרון:
זה קורה פעם אחת לכל token פלט (אלא אם משתמשים ב-multi-token prediction או speculative decoding). לכן ה-intertoken latency (השהיה בין tokens, או time per output token) המינימלי הוא כ-100 ms/token. ומה ה-arithmetic intensity כאן? מונה של ~1 TFLOP חלקי מכנה של 1 TB:
הarithmetic intensity של 1 - הרחק משמאל לכל ridge point סביר. decode ל-batch יחיד הוא memory-bound קלאסי. יחידות החישוב יושבות בטלות ומחכות שהמשקלים יזרמו.
שאלה 2 - מה נדרש כדי להיות compute-bound ב-batch יחיד? נניח שה-GPU מסוגל ל-1 PFLOP/s (10^15 FLOP/s) בכפל מטריצות 16 ביט. זמן החישוב:
כדי שהחישוב (1 ms) יהיה צוואר הבקבוק ולא הזיכרון, היינו צריכים לטעון 1 TB תוך 1 ms:
1 פטהבייט לשנייה - פי אלף בערך מרוחבי הפס של ימינו (שהם בטווח ה-TB/s). המסקנה חדה: אין שום סיכוי להיות compute-bound ב-decode של batch יחיד. הפער בין 10 TB/s הזמינים ל-1 PB/s הנדרש הוא מדוע.
הפתרון - batching, והתפוקה שגדלה בחינם. כאן הקסם. אם נעבד batch של B קלטים יחד, נטען את אותם משקלים (1 TB) פעם אחת בלבד, אבל נעשה בהם B פעמים יותר עבודת חישוב. המכנה נשאר קבוע, המונה גדל פי B, ולכן ה-arithmetic intensity גדל לינארית:
והזמן? זהה. אותם 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) - שתיהן מחליפות עבודת זיכרון בעבודת חישוב, לפי הכלל "חישוב זול, זיכרון יקר".