5.3 Runtime API, streams ו events תרגול
תרגול - Runtime API, streams ו-events¶
בתרגול הזה תבנו את שלושת העמודים של תכנות Runtime API מקצועי: טיפול חסין בשגיאות, מדידת זמן מדויקת עם events, וקבילות אמיתית עם streams. תעברו מקוד שנכשל בשקט לקוד שצועק בדיוק היכן נשבר; תמדדו kernel ברזולוציה תת-מיקרו-שנייה; תבנו צנרת שמחפפת H2D, חישוב ו-D2H ותמדדו את ה-speedup מול הגרסה הסדרתית; ולבסוף תבנו תלות צולבת בין שני זרמים עם cudaStreamWaitEvent. עבדו לפי הסדר - כל תרגיל נשען על הקודם. כל התרגילים רצים על GPU של NVIDIA; אם אין לכם כרטיס מקומי, השתמשו ב-Google Colab (T4 חינם) והחליפו את דגל הארכיטקטורה בהתאם.
הכנה¶
צרו קובץ עבודה streams.cu. בראשו שימו את המאקרו והכותרות שבהם נשתמש בכל התרגילים:
#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 call: '%s'\n -> %s (%s)\n",\
__FILE__, __LINE__, #call, \
cudaGetErrorString(err_), cudaGetErrorName(err_)); \
exit(EXIT_FAILURE); \
} \
} while (0)
__global__ void vecAdd(const float* a, const float* b, float* c, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) c[i] = a[i] + b[i];
}
הcompilation (על H100; החליפו ל-sm_75 על T4, sm_80 על A100):
תרגיל 1 - מאקרו CUDA_CHECK ולכידת שגיאה אסינכרונית¶
מטרתכם להבין את ההבדל בין שגיאת launch לשגיאת הרצה, ולראות את המאקרו תופס את שתיהן.
- כתבו
mainשמקצהd_a, d_b, d_cבגודלn = 1<<20עםcudaMalloc, עוטף כל קריאה ב-CUDA_CHECK. - השיקו את
vecAddתקין עם תצורה חוקית, ואז הריצוCUDA_CHECK(cudaGetLastError())ו-CUDA_CHECK(cudaDeviceSynchronize()). ודאו שהתוכנית רצה נקי. - עכשיו זרעו שגיאת launch: השיקו kernel עם
1025threads לבלוק (מעל המקסימום 1024). הוסיפוcudaGetLastError()מיד אחרי הlaunch ובדקו איזו שגיאה נתפסת. - החזירו את התצורה לחוקית, אך זרעו שגיאת הרצה: השיקו
vecAddעםnגדול מגודל ההקצאה האמיתית (למשל העבירו ל-kernelnכפול 4), כך שייגש מחוץ לתחום. שימו לב היכן בדיוק השגיאה נתפסת - האם מיד אחרי הlaunch, או רק ב-cudaDeviceSynchronize? - אחרי שנתפסה שגיאת ההרצה, נסו להוסיף עוד
cudaMallocאחריה (בליexit) ובדקו: האם גם היא נכשלת? הסבירו במונחי "שגיאה דביקה".
רמז: שגיאת launch (תצורה) נתפסת ב-cudaGetLastError() מיד; שגיאת הרצה (out-of-bounds) צצה רק בקריאה המסנכרנת הבאה. כדי לראות את סעיף 5 בלי ש-CUDA_CHECK יסיים את התוכנית, בדקו שם ידנית עם cudaError_t e = cudaMalloc(...) והדפיסו את cudaGetErrorName(e).
תרגיל 2 - מדידת זמן kernel מדויקת עם events¶
השתמשו ב-cudaEvent כדי למדוד את vecAdd וחשבו ממנו רוחב פס אפקטיבי.
- הקצו והאתחלו
d_a, d_b, d_cבגודלn = 1<<26(כ-67 מיליון איברים). מלאו אתa,bבערכים כלשהם על ה-GPU או העתיקו מהhost. - הריצו איטרציית חימום אחת של
vecAddואחריהcudaDeviceSynchronize, בלי למדוד אותה. - צרו
startו-stopעםcudaEventCreate. הקליטוstart, השיקו אתvecAdd, הקליטוstop, סנכרנו אתstop, וקראוcudaEventElapsedTime. - הריצו את המדידה בלולאה של 20 חזרות ושמרו את הזמן המינימלי (הכי פחות רעש). הדפיסו אותו במילישניות.
- חשבו את רוחב הפס האפקטיבי:
vecAddנוגע ב-3 מערכיfloat(2 קריאות, כתיבה אחת), כלומרn * 12בתים. חלקו בזמן (בשניות) והדפיסו ב-GB/s. השוו לרוחב הפס של ה-HBM3 ב-H100 (בסדר גודל של 3.35 TB/s) - איזה אחוז יצא, והאם ה-kernel memory-bound?
רמז: cudaEventElapsedTime(&ms, start, stop) מחזיר float מילישניות ודורש ש-stop כבר הוקלט - מכאן cudaEventSynchronize(stop) לפניו. אל תשכחו לבדוק cudaGetLastError() אחרי הlaunch. הזמן המינימלי על פני חזרות מנקה רעש מערכת.
תרגיל 3 - צנרת חופפת מול סדרתית ומדידת speedup¶
זהו לב התרגול: תבנו שתי גרסאות של אותה עבודה ותמדדו כמה מהיר החופף.
- הקצו זיכרון host pinned ל-
h_a, h_b, h_cעםcudaMallocHost, בגודלn = 1<<26. אתחלוa,b. - גרסה סדרתית (baseline):
cudaMemcpyH2D של כלaו-b, launch אחת שלvecAddעל כלn,cudaMemcpyD2H שלc. עטפו את כל הבלוק ב-events ומדדו את זמנו הכולל. - גרסה חופפת: פרקו את
nל-nChunks(התחילו ב-8) ופזרו round-robin עלnStreamsזרמים שנוצרו עםcudaStreamCreateWithFlags(..., cudaStreamNonBlocking). לכל נתח:cudaMemcpyAsyncH2D שלa,b, launchvecAddעל הנתח,cudaMemcpyAsyncD2H שלc- הכל ב-stream של אותו נתח. מדדו את זמן כל הצנרת עם events (הקליטוstartלפני הלולאה ו-stopאחריה, וסנכרנו). - אמתו נכונות: השוו את
h_cמהגרסה החופפת מול הגרסה הסדרתית (או מול חישוב CPU), ודאו שהם זהים. - הדפיסו את זמן הסדרתי, זמן החופף, ואת ה-
speedup = t_serial / t_overlap. הריצו עםnStreamsשל 2, 3, ו-4 ורשמו כיצד ה-speedup משתנה. - ניסוי המלכוד: החליפו את
cudaMallocHostל-mallocרגיל (pageable) בגרסה החופפת בלבד, והריצו שוב. מה קרה ל-speedup, ולמה?
רמז: החפיפה דורשת בו-זמנית שלושה תנאים - זיכרון pinned, cudaMemcpyAsync, ו-streams לא-default. אם אחד מהם חסר, אין חפיפה. ה-speedup הצפוי כשזמן ההעתקה וזמן החישוב דומים הוא סביב פי 1.5 עד פי 2 - vecAdd הוא memory-bound, כך שה-H2D וה-D2H (על ה-PCIe) הם הרכיב הכבד, וחפיפתם עם החישוב היא הרווח.
תרגיל 4 - תלות צולבת בין זרמים עם cudaStreamWaitEvent¶
בנו תלות מפורשת בין שני זרמים בלי לחסום את הhost.
- צרו שני זרמים
sAו-sB(cudaStreamNonBlocking) ו-event יחידeעם הדגלcudaEventDisableTiming. - ב-
sAהשיקו kernelscaleשמכפיל מערךd_xבקבוע (כתבו kernel פשוטx[i] *= 2.0f). - הקליטו את ה-event:
cudaEventRecord(e, sA). - הורו ל-
sBלהמתין:cudaStreamWaitEvent(sB, e, 0), ואז ב-sBהשיקו kerneladdOneשמוסיף 1 לכל איבר של אותוd_x. - סנכרנו, העתיקו
d_xלhost ובדקו שהתוצאה היאx_init * 2 + 1לכל איבר - כלומר ש-sBבאמת חיכה ל-sA. - כדי לוודא שהתלות נחוצה, הסירו זמנית את
cudaStreamWaitEventוהריצו כמה פעמים; האם התוצאה יכולה להשתבש? הסבירו מדוע (מרוץ נתונים בין הזרמים).
רמז: cudaStreamWaitEvent(sB, e, 0) חוסם רק את צד ה-GPU של sB עד שה-event נחתם ב-sA; הhost לא נחסם. בלי ה-wait, addOne ב-sB יכול לרוץ לפני, אחרי, או במקביל ל-scale ב-sA - התוצאה תלויה בתזמון, וזו הגדרת מרוץ נתונים.
תרגיל 5 (בונוס) - פרופיל של הצנרת ב-Nsight Systems¶
אמתו חזותית שהחפיפה באמת קורית.
- הריצו את גרסת הצנרת החופפת מתרגיל 3 תחת
nsys:nsys profile -o pipe --stats=true ./streams. - פתחו את
pipe.nsys-repב-Nsight Systems GUI (או קראו את סיכום ה-stats בטרמינל), ואתרו את שורות ה-streams. - חפשו בציר הזמן את החפיפה: פסי ה-Memcpy HtoD, ה-kernels, וה-Memcpy DtoH צריכים להיות מדורגים - כלומר לרוץ בו-זמנית בזרמים שונים - ולא בשורה אחת רציפה.
- השוו לגרסה הסדרתית: פרפלו גם אותה וראו שהפסים שם צמודים ורציפים, בלי חפיפה.
רמז: ב-nsys stats חפשו את הטבלאות cuda_gpu_kern_sum ו-cuda_gpu_mem_time_sum; ב-GUI, שורת כל stream נפרדת בציר הזמן, וחפיפה נראית כפסים שמתחילים לפני שהקודם הסתיים. אם אין חפיפה גם בגרסה ה"חופפת", חזרו לבדוק שהזיכרון pinned ושהזרמים אינם default.