3.3 Shared memory ו tiling תרגול
תרגול - Shared memory ו-tiling¶
בתרגול הזה תהפכו את ה-matmul הנאיבי מ-2.3 ל-kernel מרוצף (tiled) מהיר, ותמדדו את השיפור במו עיניכם. תממשו את ה-matmul עם shared memory, תמדדו GFLOP/s ותשוו לנאיבי, תסרקו ערכי TILE_WIDTH שונים, תמירו את ה-kernel ל-shared memory דינמי, ולבסוף תזריקו ותתקנו את הבאג הקלאסי של מחסום __syncthreads() חסר. עבדו לפי הסדר - כל תרגיל נשען על הקודם. הריצו הכל על GPU אמיתי; אם אין לכם, השתמשו ב-Google Colab (T4 חינמי) או במכונת ענן, כפי שראינו בשיעור 0.2. ה-H100 הוא כרטיס הייחוס שלנו, אך כל תרגיל כאן ירוץ על כל GPU של NVIDIA (החליפו sm_90a ב-sm_75 ל-T4).
הכנה¶
צרו קובץ matmul.cu והתחילו מהשלד הבא, הכולל את מקרו CUDA_CHECK, את ה-kernel הנאיבי מ-2.3, בדיקת נכונות מול ה-CPU, ומדידת זמן עם cudaEvent. את ה-kernel המרוצף mmTiled תשלימו בתרגיל 1.
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <cuda_runtime.h>
#define CUDA_CHECK(call) \
do { \
cudaError_t err_ = (call); \
if (err_ != cudaSuccess) { \
fprintf(stderr, "CUDA error at %s:%d\n '%s'\n -> %s\n", \
__FILE__, __LINE__, #call, cudaGetErrorString(err_)); \
exit(EXIT_FAILURE); \
} \
} while (0)
#define TILE_WIDTH 16
__global__ void mmNaive(const float* A, const float* B, float* C, int N) {
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
if (row < N && col < N) {
float sum = 0.0f;
for (int k = 0; k < N; k++)
sum += A[row * N + k] * B[k * N + col];
C[row * N + col] = sum;
}
}
// exercise 1: complete the tiled kernel here.
__global__ void mmTiled(const float* A, const float* B, float* C, int N) {
// ...
}
// simple correctness check on the CPU (slow - use a small N for verification only)
void mmHost(const float* A, const float* B, float* C, int N) {
for (int r = 0; r < N; r++)
for (int c = 0; c < N; c++) {
float s = 0.0f;
for (int k = 0; k < N; k++) s += A[r * N + k] * B[k * N + c];
C[r * N + c] = s;
}
}
int main(int argc, char** argv) {
int N = (argc > 1) ? atoi(argv[1]) : 1024;
size_t bytes = (size_t)N * N * sizeof(float);
float *h_A = (float*)malloc(bytes), *h_B = (float*)malloc(bytes);
float *h_C = (float*)malloc(bytes), *h_ref = (float*)malloc(bytes);
for (int i = 0; i < N * N; i++) { h_A[i] = 1.0f; h_B[i] = 2.0f; }
float *d_A, *d_B, *d_C;
CUDA_CHECK(cudaMalloc(&d_A, bytes));
CUDA_CHECK(cudaMalloc(&d_B, bytes));
CUDA_CHECK(cudaMalloc(&d_C, bytes));
CUDA_CHECK(cudaMemcpy(d_A, h_A, bytes, cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(d_B, h_B, bytes, cudaMemcpyHostToDevice));
dim3 block(TILE_WIDTH, TILE_WIDTH);
dim3 grid((N + TILE_WIDTH - 1) / TILE_WIDTH,
(N + TILE_WIDTH - 1) / TILE_WIDTH);
cudaEvent_t start, stop;
CUDA_CHECK(cudaEventCreate(&start));
CUDA_CHECK(cudaEventCreate(&stop));
// warm-up + measurement (replace mmNaive with mmTiled once you complete it)
mmNaive<<<grid, block>>>(d_A, d_B, d_C, N);
CUDA_CHECK(cudaDeviceSynchronize());
CUDA_CHECK(cudaEventRecord(start));
mmNaive<<<grid, block>>>(d_A, d_B, d_C, N);
CUDA_CHECK(cudaEventRecord(stop));
CUDA_CHECK(cudaEventSynchronize(stop));
float ms = 0.0f;
CUDA_CHECK(cudaEventElapsedTime(&ms, start, stop));
CUDA_CHECK(cudaMemcpy(h_C, d_C, bytes, cudaMemcpyDeviceToHost));
double gflops = (2.0 * N * N * N) / (ms / 1e3) / 1e9;
printf("N = %d, time = %.3f ms, %.1f GFLOP/s\n", N, ms, gflops);
printf("C[0] = %.1f (expected %.1f)\n", h_C[0], 2.0f * N);
free(h_A); free(h_B); free(h_C); free(h_ref);
CUDA_CHECK(cudaFree(d_A)); CUDA_CHECK(cudaFree(d_B)); CUDA_CHECK(cudaFree(d_C));
return 0;
}
הקומפילציה לאורך כל התרגול:
שימו לב לנוסחת ה-GFLOP/s: matmul מרובע בגודל N מבצע 2*N^3 FLOPs (כפל וחיבור לכל אחד מ-N^3 הצירופים). למטריצות שכולן 1 ו-2, כל איבר של C שווה בדיוק 2*N, מה שנותן בדיקת שפיות מיידית.
תרגיל 1 - מימוש ה-matmul המרוצף ומדידת המהירות¶
- השלימו את
mmTiledלפי ההרצאה: שני מערכי__shared__בגודלTILE_WIDTH x TILE_WIDTH, לולאה על השלבים, טעינה שיתופית עם ריפוד אפסים בשוליים, שני מחסומי__syncthreads(), וכתיבה מוגנת ב-if (row < N && col < N). - הריצו קודם עם
N = 512והשוו אתC[0]שהתקבל לערך הצפוי2*N. אם הוא נכון, המימוש תקין. - החליפו את שתי הקריאות ל-
mmNaiveב-main(החימום והמדידה) ל-mmTiled, וקמפלו מחדש. - הריצו את שתי הגרסאות (הנאיבי והמרוצף) עבור
N = 2048ואזN = 4096, ורשמו את ה-GFLOP/s של כל אחת. חשבו את מקדם ההאצה (speedup).
רמז: הטעינה השיתופית היא As[ty][tx] = (row < N && aCol < N) ? A[row*N + aCol] : 0.0f; כאשר aCol = t*TILE_WIDTH + tx, ובאופן דומה Bs[ty][tx] עם bRow = t*TILE_WIDTH + ty. הלולאה הפנימית היא for (int k = 0; k < TILE_WIDTH; k++) sum += As[ty][k] * Bs[k][tx];. אם קיבלתם תוצאה שגויה רק בקצוות, כנראה שכחתם את ריפוד האפסים לtiles חלקיים.
תרגיל 2 - אימות נכונות מול ה-CPU¶
- הוסיפו קריאה ל-
mmHost(הפניה שכבר בשלד) עבורNקטן, למשלN = 256, ומלאו אתh_A,h_Bבערכים אקראיים במקום קבועים (h_A[i] = (float)rand() / RAND_MAX;). - השוו איבר-איבר את
h_C(מה-GPU) ל-h_ref(מה-CPU) וחשבו את השגיאה המקסימלית:maxErr = fmax(maxErr, fabs(h_C[i] - h_ref[i]));. - ודאו שהשגיאה זעירה (בסדר גודל של
1e-3או פחות עבורfloat), לא אפס מוחלט. - הסבירו לעצמכם למה השגיאה אינה אפס מדויק גם כשהאלגוריתם נכון.
רמז: ב-float (32 סיביות) הסדר שבו מצטברים המחצבים משפיע על תוצאת העיגול, וה-GPU צובר בסדר שונה מה-CPU. שגיאה יחסית קטנה (ולא אפס) היא הסימן לנכונות, לא לבאג. אל תשוו ב-== בין תוצאות נקודה-צפה - השוו סף (tolerance).
תרגיל 3 - סריקת TILE_WIDTH¶
- הפכו את
TILE_WIDTHלפרמטר שקל לשנות (ערכו את ה-#defineוקמפלו מחדש בכל ערך), וסרקו את הערכים8,16,32. - לכל ערך, הריצו עבור
N = 4096ורשמו את ה-GFLOP/s. בנו טבלה:TILE_WIDTH, מספר threads ב-block, בתי shared לכל block, GFLOP/s. - חשבו לכל ערך את הarithmetic intensity התיאורטית (FLOPs לכל קריאת global) - היא אמורה לצאת
TILE_WIDTH. - איזה ערך היה הכי מהיר על הכרטיס שלכם? האם
32תמיד מנצח את16? הסבירו.
רמז: TILE_WIDTH = 8 נותן block של 64 threads (שני warps) - arithmetic intensity נמוכה ומעט ניצול-חוזר. TILE_WIDTH = 32 נותן block של 1024 threads (מקסימום) ו-8 KiB shared. לפעמים 32 מנצח ולפעמים 16, כי 32 צורך יותר משאבים ומוריד occupancy (ראו 8.1). בתי ה-shared הם 2 * TILE_WIDTH * TILE_WIDTH * 4.
תרגיל 4 - המרה ל-shared memory דינמי¶
- כתבו גרסה
mmTiledDyn(const float* A, const float* B, float* C, int N, int tile)שבה גודל הtile מגיע כפרמטר בזמן ריצה, וה-shared memory מוכרזextern __shared__ float smem[];. - חלקו את
smemלשני חצאים:float* As = smem;ו-float* Bs = smem + tile*tile;. השתמשו באינדוקס משוטחAs[ty*tile + tx]במקוםAs[ty][tx]. - השיקו עם הפרמטר השלישי בסוגריים המשולשים:
mmTiledDyn<<<grid, block, 2*tile*tile*sizeof(float)>>>(d_A, d_B, d_C, N, tile);כאשר גםblockבגודלtile x tile. - ודאו שהתוצאה זהה לגרסה הסטטית, והשוו את ה-GFLOP/s. האם יש הבדל ביצועים ניכר?
רמז: הפרמטר השלישי של <<< >>> הוא מספר הבתים של ה-shared הדינמי, לא מספר האיברים - אל תשכחו את sizeof(float). אם קיבלתם invalid argument בlaunch, כנראה ביקשתם יותר shared ממה שמותר; לחריגה מ-48 KiB צריך cudaFuncSetAttribute(mmTiledDyn, cudaFuncAttributeMaxDynamicSharedMemorySize, bytes). הביצועים אמורים להיות כמעט זהים לסטטי - האינדוקס הידני אינו יקר יותר.
תרגיל 5 - הזרקת הבאג הקלאסי ותיקונו¶
- קחו עותק של
mmTiledהתקין וקראו לוmmTiledBug. מחקו את המחסום השני__syncthreads()(זה שאחרי הלולאה הפנימית), והשאירו את הראשון. - הריצו עבור
N = 1024, כמה פעמים ברצף. ודאו שאתם משתמשים באימות מול ה-CPU מתרגיל 2. תעדו: האם התוצאה שגויה? האם היא אותה שגיאה בכל הרצה? - עכשיו מחקו במקום זאת את המחסום הראשון (והחזירו את השני). הריצו שוב ותעדו את ההתנהגות.
- החזירו את שני המחסומים ואמתו שהתוצאה שוב נכונה ויציבה.
- הסבירו במילים שלכם: איזו סכנה (hazard) כל מחסום מונע, ולמה השמטתו נותנת תוצאה לא דטרמיניסטית.
רמז: השמטת המחסום השני יוצרת סכנת כתיבה-לפני-קריאה (write-after-read): warp מהיר דורס את הtile בשלב הבא בעוד warp איטי עדיין קורא ממנו. התלות בתזמון ה-warps היא שהופכת את השגיאה ללא-יציבה בין הרצות. ל-N קטן שנכנס בtile יחיד (N <= TILE_WIDTH) יש רק שלב אחד, ואז הבאג עלול לא להתגלות - השתמשו ב-N גדול בהרבה מ-TILE_WIDTH כדי לכפות כמה שלבים.
תרגיל 6 (בונוס) - פרופיילינג ואחוז מהשיא¶
- הריצו את הגרסה המרוצפת תחת
ncuוהשוו לנאיבי, למשל:
- השוו בין הנאיבי למרוצף את המדדים:
DRAM Throughput,Compute (SM) Throughput, ו-Achieved Occupancy. ראו איך הנאיבי memory-bound (DRAM גבוה, SM נמוך) בעוד המרוצף מזיז את המשקל לכיוון החישוב. - חפשו בדוח את מדד ה-
Shared Memory Bank Conflicts. האם יש התנגשויות בגרסה שלכם? - חשבו את אחוז השיא: קחו את ה-GFLOP/s הטוב ביותר שלכם וחלקו ב-66.9 TFLOPS (שיא ה-FP32 של H100, או השיא של הכרטיס שלכם). כמה רחוקים אתם מ-cuBLAS?
רמז: ncu דורש הרשאות (על ריצה מקומית ייתכן sudo או הגדרת NVreg_RestrictProfilingToAdminUsers=0). את הטיפול המלא בהתנגשויות בנקים ובריפוד [TILE_WIDTH + 1] נעשה בפרק 8.4, ואת האיחוד של כל שכבות האופטימיזציה (מרוצף -> ריצוף register -> Tensor Cores) - בפרויקט 3.5. אחוז של כמה אחוזים בודדים מהשיא הוא צפוי ל-kernel פשוט על CUDA Cores; cuBLAS מגיע לעשרות אחוזים כי הוא משתמש ב-Tensor Cores וריצוף רב-שכבתי.