3.4 זיכרון קבוע וטקסטורה תרגול
תרגול - זיכרון קבוע וטקסטורה¶
בתרגול הזה תיישמו בידיים את שלושת מרחבי הזיכרון המיוחדים שראינו בהרצאה, ותמדדו את ההשפעה שלהם. תתחילו מסטנסיל 1D שמקדמיו בזיכרון קבוע ותשוו אותו לגרסה שקוראת אותם מ-global memory, תנתבו קלט לקריאה-בלבד עם const __restrict__ ותסתכלו ב-SASS כדי לוודא שקיבלתם LDG.E.CI, תדגמו תמונה עם אובייקט טקסטורה ואינטרפולציה בי-לינארית בחומרה, ולבסוף תראו במו עיניכם מתי זיכרון קבוע מזיק - כשהכתובות מתפצלות. עבדו לפי הסדר. הריצו הכל על GPU אמיתי; אם אין לכם, השתמשו ב-Google Colab (T4 חינמי) או במכונת ענן, כפי שראינו בשיעור 0.2. ה-H100 הוא כרטיס הייחוס, אבל כל תרגיל כאן ירוץ על כל GPU של NVIDIA מדור Kepler ומעלה.
הכנה¶
צרו קובץ stencil.cu. השתמשו במקרו CUDA_CHECK מההרצאה 0.3 בראש הקובץ (הוא יחזור בכל תרגיל). הקומפילציה לאורך התרגול (החליפו את sm_90a בארכיטקטורה שלכם - למשל sm_75 ל-T4):
לברור ה-compute capability של הכרטיס:
לכל המדידות בתרגול השתמשו באירועי CUDA (cudaEvent) כפי שראינו בשיעור 0.3, והריצו הרצת "חימום" אחת לפני המדידה כדי לא לכלול overhead של compilation-בזמן-ריצה חד-פעמית.
תרגיל 1 - מקדמי סטנסיל בזיכרון קבוע מול global¶
- ממשו סטנסיל 1D עם
RADIUS = 4(כלומרDIAM = 9מקדמים). כתבו שתי גרסאות של ה-kernel:stencil_constשקורא את המקדמים מ-__constant__ float c_coef[DIAM], ו-stencil_globalשמקבל את המקדמים כמצביעconst float* coefב-global memory. - אתחלו את המקדמים למסנן ממוצע פשוט (
1.0f / DIAMבכל תא). טענו אותם לזיכרון הקבוע עםcudaMemcpyToSymbol, ולגרסת ה-global עםcudaMemcpyרגיל. - הריצו את שתי הגרסאות על מערך של
n = 1 << 26(כ-67 מיליון איברים), אמתו שהפלט זהה בין השתיים, ומדדו את זמן ה-kernel של כל אחת עםcudaEvent. - חשבו את יחס המהירות והסבירו אותו: מדוע הגרסה עם זיכרון קבוע מהירה (או לפחות לא איטית) יותר?
רמז: בלולאה הפנימית האינדקס למקדם (k + RADIUS) אחיד על פני כל ה-warp - כל 32 הthreads קוראים את אותו c_coef[...] באותו רגע. זו תבנית ה-broadcast: קריאה אחת משרתת את כל ה-warp. בגרסת ה-global, אותה קריאה עוברת דרך נתיב ה-L1 הרגיל בלי מנגנון ה-broadcast הייעודי. ההפרש עשוי להיות קטן כאן כי המקדמים חמים ב-L1 בשתי הגרסאות - החשיבות של constant תתחדד בתרגיל 4.
תרגיל 2 - קלט לקריאה-בלבד ובדיקת SASS¶
- הוסיפו ל-
stencil_constגרסהstencil_roשבה מצביע הקלט מסומןconst float* __restrict__ in(וגם מצביע הפלטfloat* __restrict__ out). - קמפלו וחלצו את ה-SASS של שני ה-kernels:
nvcc -O2 -arch=sm_90a -cubin -o stencil.cubin stencil.cu
cuobjdump -sass stencil.cubin | grep -E "LDG|LDC"
- חפשו את ההבדל: בגרסה עם
const __restrict__טעינות הקלט אמורות להופיע כ-LDG.E.CI(הנתיב לקריאה-בלבד), ובגרסה בלי - כ-LDG.Eרגיל. - שימו לב גם היכן מופיעים המקדמים מזיכרון הקבוע: הם לרוב לא
LDGבכלל, אלאLDCאו אופרנד מסוגc[0x3][...](בנק קבוע).
רמז: אם שתי הגרסאות מפיקות LDG.E.CI גם בלי __restrict__, ייתכן שהcompiler הסיק לבד שהקלט לקריאה-בלבד (זה קורה כשאין כתיבה נראית לאותו מצביע). כדי לראות את ההבדל בבירור, נסו גרסה שבה מצביע הקלט אינו const, או השתמשו ב-__ldg(&in[i+k]) במפורש ובדקו שה-LDG.E.CI צץ. הסיומת .CI = Cache in Invalidate, כלומר cache הקריאה-בלבד הלא-קוהרנטי.
תרגיל 3 - דגימת תמונה עם אינטרפולציה בי-לינארית בחומרה¶
- צרו קובץ חדש
resample.cu. בנו תמונת קלט קטנה סינתטית בגודלW x H(למשל 4x4) עם ערכים ידועים - למשל gradient לינאריimg[y*W + x] = (float)(x + y). - הקימו
cudaTextureObject_tמעלcudaArray, עםfilterMode = cudaFilterModeLinear,addressMode = cudaAddressModeClamp, ו-normalizedCoords = 0, בדיוק כפי שראינו בהרצאה. - כתבו kernel שדוגם את התמונה בנקודות ביניים - למשל בקואורדינטות
(1.0f, 1.0f),(1.5f, 1.5f),(2.5f, 1.5f). זכרו שמרכז טקסל(px, py)נמצא בקואורדינטה(px + 0.5f, py + 0.5f). - הדפיסו את הערכים שהחזירה הדגימה ואמתו ידנית: דגימה בדיוק במרכז טקסל צריכה להחזיר את ערך הטקסל, ודגימה באמצע בין שני טקסלים צריכה להחזיר את ממוצעם.
רמז: בקואורדינטה (1.0f, 1.0f) (לא במרכז טקסל) אתם באמצע בין ארבעה טקסלים ותקבלו את הממוצע שלהם. עם img[y*W+x] = x + y, הטקסל (1,1) שווה 2, (0,0) שווה 0, וכו'; הדגימה ב-(1.0,1.0) (פינת ארבעת הטקסלים (0,0),(1,0),(0,1),(1,1)) תיתן (0+1+1+2)/4 = 1.0. אל תשכחו לשחרר עם cudaDestroyTextureObject ו-cudaFreeArray. אם קיבלתם אפסים, ודאו שאתחלתם את resDesc ו-texDesc ל-{} (אתחול לאפס) לפני מילוי השדות.
תרגיל 4 - מתי זיכרון קבוע מזיק - broadcast מול כתובות מתפצלות¶
- בנו טבלה קטנה בזיכרון קבוע:
__constant__ float c_table[256];, מאותחלת לערכים כלשהם. - כתבו שני kernels שכל אחד קורא איבר אחד מהטבלה לכל thread וכותב אותו לפלט:
read_broadcast: כל הthreads קוראים את אותה כתובת,c_table[0].read_divergent: כל thread קורא כתובת אחרת,c_table[threadIdx.x & 255].- הריצו את שניהם על
n = 1 << 26, מדדו את זמן ה-kernel של כל אחד עםcudaEvent, וחשבו את היחס ביניהם. - הסבירו את התוצאה במונחי מספר הטרנזקציות לכל warp. מה היה קורה אילו
c_tableהיה ב-global memory מאוחד במקום בזיכרון קבוע?
רמז: ב-read_broadcast כל warp מבקש כתובת אחת -> טרנזקציה אחת; ב-read_divergent כל warp מבקש עד 32 כתובות שונות -> עד 32 טרנזקציות מסודרות בטור. היחס יהיה משמעותי (עד פי כמה). לו הטבלה הייתה ב-global memory, ה-read_divergent דווקא היה מהיר יותר, כי 32 הכתובות threadIdx.x & 255 של warp רציפות ומתאחדות (coalesce) לטרנזקציה אחת - זו בדיוק ההמחשה לכך שזיכרון קבוע ממוטב ל-broadcast ולא לגישה מתפצלת.
תרגיל 5 (בונוס) - סטנסיל 2D: טקסטורה מול global¶
- ממשו את הסטנסיל הדו-ממדי הממוצע 5-נקודתי (
jacobi5pt) בשתי גרסאות: אחת שקוראת מ-cudaTextureObject_t(עםcudaAddressModeClampשמטפל בגבולות), ואחת שקוראת מ-global memory עם בדיקות גבול ידניות (if (x > 0)וכו'). - הריצו את שתיהן על תמונה בגודל
4096 x 4096, אמתו שהפלט זהה (עד הבדלי גבול), ומדדו את זמן ה-kernel של כל אחת. - חשבו את רוחב הפס האפקטיבי של כל גרסה (כל פיקסל קורא 5 ערכים וכותב 1, כלומר
6 * 4 = 24בתים) והשוו לרוחב הפס התיאורטי של הכרטיס. - הסיקו: מתי הייתם בוחרים בכל אחת מהגרסאות? חשבו גם על גרסת shared-memory tiling מ-3.3 - איך היא הייתה משתלבת?
רמז: הcache הטקסטורה עם הלוקליות הדו-ממדית שלו לרוב מנצח את global ה"נאיבי" כשהגישה האנכית (yc - 1, yc + 1) גורמת ל-cache misses ב-global. עם זאת, גרסת shared-memory tiling (טעינת tile משותף פעם אחת ל-block) בדרך כלל מנצחת את שתיהן בחומרה מודרנית - ה-texture מנצחת בעיקר בקלות הכתיבה ובטיפול-הגבולות-החינמי. את המסגרת המלאה של רוחב פס וניתוח roofline נעמיק בפרק 7.