היררכיית הזיכרון ב-GPU מוסברת - למה איפה שומרים חשוב יותר ממה שומרים¶
אם יש דבר אחד שקובע יותר מכל דבר אחר את הביצועים של קוד GPU, זה לא כמה threads אתם משגרים, זה איפה הנתונים שלכם יושבים. GPU מכיל כמה סוגי זיכרון שונים, שנבדלים זה מזה בגודל, במהירות, ובמי יכול לגשת אליהם. להבין את ההיררכיה הזו זה כמו לקבל מפה לכל עולם האופטימיזציה.
הכלל הבסיסי - מהיר וקטן, או איטי וגדול¶
הכלל שחוזר על עצמו בכל מערכת מחשוב, ו-GPU לא יוצא מהכלל, הוא שזיכרון מהיר מאוד תמיד קטן מאוד, וזיכרון ענק תמיד איטי יחסית. אי אפשר לקבל את שניהם ביחד, וכל ההיררכיה שאני עומד לתאר היא בעצם ניסיון לאזן בין השניים בצורה חכמה.
רגיסטרים - הזיכרון הכי מהיר שיש¶
בקצה המהיר ביותר של הסקאלה יושבים הרגיסטרים. כל thread מקבל קבוצה קטנה של רגיסטרים פרטיים לגמרי לעצמו, וגישה אליהם כמעט מיידית, בלי שום עיכוב שנרגיש בו. הבעיה היחידה היא שיש מהם מעט מאוד לכל SM, והם מתחלקים בין כל ה-threads שרצים עליו. ככל שה-kernel שלכם צורך יותר רגיסטרים לכל thread, פחות threads יכולים לרוץ במקביל, וזה בדיוק הקשר בין רגיסטרים ל-occupancy שדיברנו עליו בפוסט אחר.
Shared Memory - הזיכרון המשותף שכל block מנהל¶
צעד אחד למטה בסקאלת המהירות, אבל עדיין מהיר להפליא, נמצא ה-shared memory. בניגוד לרגיסטרים שהם פרטיים ל-thread בודד, זיכרון משותף שייך לכל ה-threads בתוך אותו block, והם יכולים לתקשר דרכו. הרעיון המרכזי הוא לטעון פעם אחת נתונים מהזיכרון הגלובלי האיטי אל תוך הזיכרון המשותף המהיר, ואז לתת לכל ה-threads בתוך ה-block לגשת אליהם שוב ושוב בלי לחזור לזיכרון האיטי בכל פעם. הטכניקה הזו, שנקראת tiling, היא הבסיס לכל אופטימיזציה רצינית של כפל מטריצות ב-GPU.
L1 ו-L2 Cache - הרשת הביטחון האוטומטית¶
כמו במעבדים רגילים, גם ל-GPU יש שכבות cache שעובדות באופן אוטומטי, בלי שאתם צריכים לנהל אותן ידנית. L1 יושב קרוב לכל SM ומהיר מאוד, L2 משותף לכל השבב ומעט איטי יותר אבל גדול בהרבה. ה-cache האלה תופסים בקשות חוזרות לזיכרון ומחזירים אותן מהר בלי לצאת עד לזיכרון הראשי, אבל הם פחות צפויים משליטה ידנית ב-shared memory כי אתם לא שולטים ישירות במה נשאר שם.
Global Memory - הזיכרון הגדול והאיטי¶
בתחתית ההיררכיה, מבחינת מהירות, אבל בראש מבחינת גודל, יושב הזיכרון הגלובלי - ה-HBM או GDDR שמותקן על כרטיס המסך עצמו, זה שהמפרט הטכני מתגאה בגודלו. הוא יכול להכיל עשרות ג'יגה-בייט, אבל הגישה אליו יכולה לקחת מאות מחזורי שעון, לעומת רגיסטר שנגיש כמעט מיידית. כל thread יכול לגשת אליו, אבל בגלל האיטיות היחסית, אחת המטרות המרכזיות של תכנות CUDA טוב היא פשוט - לצמצם כמה שיותר את מספר הפעמים שצריך לגשת לזיכרון הזה.
חשוב גם לדעת שיש דרך יעילה במיוחד לגשת אליו, שנקראת coalescing - כאשר threads סמוכים בתוך אותו warp ניגשים לכתובות זיכרון סמוכות, ה-GPU יכול לאחד את כל הבקשות לעסקה אחת גדולה במקום להריץ עסקאות נפרדות ואיטיות לכל thread.
Constant ו-Texture Memory - זיכרונות מיוחדים¶
יש עוד שני סוגי זיכרון ייעודיים - זיכרון קבוע, שמותאם לנתונים שכל ה-threads קוראים בו זמנית ולא משתנים, וזיכרון טקסטורה, שמותאם במקור לגישה גרפית עם לוקאליות מרחבית. שניהם משתמשים במנגנוני cache מיוחדים שמתאימים לדפוסי הגישה הספציפיים שלהם.
למה ההיררכיה הזו היא כל הסיפור¶
כמעט כל אופטימיזציה רצינית בקוד GPU מסתכמת בשאלה אחת - איך מזיזים כמה שפחות נתונים בין שכבות הזיכרון האיטיות לשכבות המהירות, ומנצלים כל בית שכבר נמצא קרוב ככל האפשר לליבות החישוב. זו הסיבה שספריות ביצועים כמו cuBLAS משקיעות כל כך הרבה מאמץ הנדסי דווקא בניהול הזיכרון, ולא רק בחישוב עצמו.
זה בדיוק המבנה שאנחנו לומדים לעומק, שכבה אחר שכבה, בקורס תכנות GPU, כולל פרויקט מעשי של כפל מטריצות עם tiling שממחיש בדיוק את הרעיון הזה בקוד אמיתי.
לסיכום¶
הזיכרון ב-GPU הוא לא בלוק אחד, הוא פירמידה שלמה - רגיסטרים מהירים ופרטיים בראש, זיכרון משותף שאתם שולטים בו ידנית, cache אוטומטי, וזיכרון גלובלי גדול ואיטי בתחתית. מתכנת GPU טוב הוא בעיקרו מישהו שיודע להזיז נתונים בין השכבות האלה בחוכמה.
בואו תלמדו לתזמן את זה נכון, עם קהילה שכבר שם.