0.3 התוכנית הראשונה חיבור וקטורים תרגול
תרגול - התוכנית הראשונה - חיבור וקטורים¶
בתרגול הזה תכתבו, תקמפלו ותריצו את תוכנית ה-CUDA הראשונה שלכם, ואז תרחיבו אותה צעד-צעד: תוסיפו טיפול איתן בשגיאות ותגרמו לשגיאה בכוונה כדי לראות אותה, תממשו את SAXPY, תמירו את הקוד ללולאת grid-stride, ולבסוף תמדדו זמנים ותגלו מתי ה-GPU באמת משתלם. עבדו לפי הסדר - כל תרגיל נשען על הקודם. הריצו הכל על GPU אמיתי; אם אין לכם, השתמשו ב-Google Colab (T4 חינמי) או במכונת ענן, כפי שראינו בשיעור 0.2. ה-H100 הוא כרטיס הייחוס שלנו, אבל כל תרגיל כאן ירוץ על כל GPU של NVIDIA.
הכנה¶
צרו קובץ vecadd.cu והעתיקו אליו את התוכנית המלאה מההרצאה (הכוללת את מקרו CUDA_CHECK, את ה-kernel בשם vecAdd, ואת main). הקומפילציה לאורך כל התרגול (החליפו את sm_90a בארכיטקטורה של הכרטיס שלכם - למשל sm_75 ל-T4):
כדי לברר את ה-compute capability של הכרטיס:
תרגיל 1 - הקלדה, קומפילציה והרצה¶
- הקלידו את
vecadd.cu(אל תעתיקו בהדבקה עיוורת - הקלדה מלמדת). - קמפלו עם שורת ה-
nvccשלמעלה. - הריצו את
./vecaddוּודאו שהפלט הואmax error = 0.000000ושתי שורות הפלט שלc[0]ו-c[1048575]. - שנו את
nל-1 << 25(כ-33.5 מיליון איברים), קמפלו והריצו שוב. ודאו שהתוצאה עדיין נכונה.
רמז: 1 << 25 דורש שלושה מערכים של 128 MiB כל אחד על ה-GPU. אם cudaMalloc נכשל בגלל חוסר זיכרון, מקרו ה-CUDA_CHECK ידפיס out of memory עם הקובץ והשורה - זה בדיוק מה שהוא נועד לתפוס.
תרגיל 2 - שגיאה מכוונת וטיפול בה¶
- ודאו שהתוכנית שלכם עוטפת את הlaunch בשתי הבדיקות:
CUDA_CHECK(cudaGetLastError())ואזCUDA_CHECK(cudaDeviceSynchronize()). - גרמו לשגיאת launch בכוונה: שנו את
blockSizeמ-256 ל-2048 (מעל המקסימום של 1024 threads ל-block). קמפלו והריצו, ורשמו את ההודעה המדויקת שקיבלתם. - החזירו את
blockSizeל-256. עכשיו גרמו לשגיאת ריצה במקום launch: הסירו את שומר הגבולותif (i < n)(השאירו רק אתc[i] = a[i] + b[i];) ושנו אתnumBlocksכך שיושק יותר מ-nthreads - למשל הוסיפוnumBlocks += 100;. קמפלו, הריצו, וראו איזו שגיאה נתפסת ובאיזו בדיקה. - הסבירו לעצמכם: איזו שגיאה נתפסה על ידי
cudaGetLastErrorואיזו על ידיcudaDeviceSynchronize, ולמה.
רמז: תצורת הרצה לא חוקית מתגלה מיד בlaunch (cudaGetLastError מחזיר invalid configuration argument). גישה מחוץ לתחום מתגלה רק כשה-kernel רץ בפועל, ולכן היא נתפסת ב-cudaDeviceSynchronize (an illegal memory access was encountered). זכרו: שגיאת ריצה כזו "דביקה" - אחריה כל קריאת CUDA תיכשל.
תרגיל 3 - מימוש SAXPY¶
הפעולה SAXPY (Single-precision A times X Plus Y) מחשבת y = a*x + y, כאשר a סקלר ו-x, y וקטורים. זו אבן היסוד של אלגברה לינארית ומופיעה ב-cuBLAS.
- כתבו kernel חדש
saxpy(int n, float a, const float* x, float* y). שימו לב ש-yהוא גם קלט וגם פלט - הוא נקרא ונכתב באותו איבר. - בתוך
main, אתחלוx[i] = 1.0f,y[i] = 2.0f, ובחרוa = 3.0f. - השיקו את ה-kernel, העתיקו את התוצאה חזרה, ואמתו שכל איבר שווה ל-
3*1 + 2 = 5. - מהו ה-
max errorשאתם מצפים לראות?
רמז: מבנה ה-kernel זהה ל-vecAdd - חשבו את i בדיוק אותו הדבר, השתמשו בשומר גבולות if (i < n), והשורה היחידה בפנים היא y[i] = a * x[i] + y[i];. הסקלר a עובר by-value בתצורת הארגומנטים הרגילה, בלי cudaMalloc - רק מצביעים דורשים זיכרון התקן.
תרגיל 4 - המרה ללולאת grid-stride¶
- כתבו גרסה
vecAddStrideשל ה-kernel שמשתמשת בלולאת grid-stride (ראו את התבנית בהרצאה). - הריצו אותה עם grid קטן במכוון מהנתונים: קבעו
numBlocks = 128קבוע (במקום(n + blockSize - 1) / blockSize), עםblockSize = 256ו-n = 1 << 20. זהו grid של 32,768 threads בלבד לעומת מיליון איברים. - ודאו שהתוצאה עדיין נכונה (
max error = 0) למרות שיש הרבה פחות threads מאיברים. - חשבו: כמה איברים מטפל בהם כל thread בממוצע במקרה הזה?
רמז: stride = blockDim.x * gridDim.x = 256 * 128 = 32768. כל thread מטפל בערך n / stride = 1048576 / 32768 = 32 איברים. אם התוצאה שגויה, בדקו שהצעד בלולאה הוא i += stride (ולא i++) ושה-stride מחושב מ-gridDim.x ולא מקבוע.
תרגיל 5 - מדידת זמנים: CPU מול GPU¶
- כתבו לולאת CPU פשוטה שמחשבת
h_ref[i] = h_a[i] + h_b[i]ומדדו את זמנה עםstd::chrono(אוclock()). - מדדו את זמן ה-kernel ב-GPU עם אירועי CUDA -
cudaEvent:
cudaEvent_t start, stop;
CUDA_CHECK(cudaEventCreate(&start));
CUDA_CHECK(cudaEventCreate(&stop));
CUDA_CHECK(cudaEventRecord(start));
vecAdd<<<numBlocks, blockSize>>>(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));
- מדדו בנפרד גם את זמן ההעברות (
cudaMemcpyH2D ו-D2H) עם אותם אירועים. - הריצו עבור
nהולך וגדל:1 << 10,1 << 16,1 << 20,1 << 24,1 << 27. בנו טבלה של זמן CPU, זמן kernel בלבד, וזמן kernel + העברות. - ענו: באיזה
nמנצח ה-kernel לבדו את ה-CPU? ומה קורה למסקנה כשמוסיפים את זמן ההעברות?
רמז: מדדו את ה-kernel לבד (בלי ההעברות) כי אירוע CUDA מתזמן את הזרם (stream) של ה-GPU. הריצו את ה-kernel פעם אחת ל"חימום" לפני המדידה (הקומפילציה של PTX ל-SASS והקצאות ראשונות מוסיפות overhead חד-פעמית). זכרו ש-cudaEventElapsedTime מחזיר מילישניות כ-float. תראו שזמן ה-kernel זעום גם ל-n גדול, אבל זמן ההעברות על ה-PCIe שולט - זו הסיבה שחיבור וקטורים לבדו כמעט אף פעם לא "שווה" העברה ל-GPU.
תרגיל 6 (בונוס) - בדיקת רוחב פס¶
- עבור ההרצה של
n = 1 << 27(כ-134 מיליון איברים), חשבו את התעבורה מול הזיכרון: כל איבר קורא שניfloatוכותב אחד, כלומר3 * 4 = 12בתים לאיבר. - מתוך זמן ה-kernel שמדדתם, חשבו את רוחב הפס האפקטיבי ב-GB/s:
(12 * n) / (זמן בשניות) / 1e9. - השוו לרוחב הפס התיאורטי של הכרטיס שלכם (ב-H100 SXM כ-3.35 TB/s; אפשר לברר עם
nvidia-smi -qאו deviceQuery). איזה אחוז ניצלתם?
רמז: רוחב פס אפקטיבי גבוה (עשרות אחוזים מהתיאורטי) הוא סימן שהגישות מאוחדות (coalesced) והחישוב memory-bound כפי שצפינו. אם קיבלתם אחוז נמוך מאוד, ודאו שמדדתם את ה-kernel לבד ולא כללתם את זמן ההעברות. את המסגרת המלאה לניתוח - מודל ה-Roofline וarithmetic intensity - נלמד בפרק על ביצועים.