לדלג לתוכן

1.3 יחידות ביצוע CUDA cores, SFU ו LSU פתרון

פתרון - יחידות ביצוע - CUDA cores, SFU ו-LSU

כאן נעבור על כל תרגיל עם הפקודות, הפלט המדויק וההסבר. שימו לב: פלט ה-SASS ומספרי ה-ncu הם דוגמאות מהרצה על H100 עם גרסת nvcc מסוימת - הקידוד המדויק של SASS והאחוזים ישתנו בין גרסאות compiler וכרטיסים, אבל התבנית (אילו הוראות MUFU.* מופיעות, איזה pipe רווי) זהה. אם אתם על T4 ב-Colab, החליפו -arch=sm_90a ב--arch=sm_75.

פתרון תרגיל 1 - ציד הוראות MUFU ב-SASS

הפקודות:

nvcc -O2 -arch=sm_90a -c pipes.cu -o pipes.o
cuobjdump --dump-sass pipes.o | grep MUFU

פלט מייצג עבור ה-kernel transcendental (בקומפילציה רגילה, בלי fast math):

        /*0090*/    MUFU.EX2 R7, R6 ;          // expf  -> 2^(x*log2(e))
        /*0130*/    MUFU.RSQ R11, R0 ;         // sqrtf -> built on reciprocal-sqrt
        /*0220*/    MUFU.SIN R13, R12 ;        // sinf

שלוש הקריאות, שלוש הוראות MUFU.* שונות. אם תסתכלו על ה-SASS המלא (בלי ה-grep) תראו שכל MUFU עטוף בקוד עזר: לפני ה-MUFU.EX2 יש FFMA שכופל את הקלט ב-1.4426950408 (זה log2(e)) ומוסיף תיקון דיוק; סביב ה-MUFU.RSQ יש רצף FFMA/FMUL של עידון Newton-Raphson שהופך את הקירוב לשורש מדויק; ולפני ה-MUFU.SIN יש הפחתת-טווח (range reduction). (בכרטיסים/גרסאות מסוימות sqrtf המדויק עשוי להופיע כ-MUFU.SQRT במקום MUFU.RSQ - שני הנתיבים תקפים, שניהם MUFU.)

עם -use_fast_math:

nvcc -O2 -use_fast_math -arch=sm_90a -c pipes.cu -o pipes.o
cuobjdump --dump-sass pipes.o | grep MUFU
        /*0050*/    MUFU.EX2 R5, R4 ;          // __expf: a single instruction, no correction
        /*0070*/    MUFU.RSQ R7, R6 ;          // __fsqrt: fast approximation
        /*0090*/    MUFU.SIN R9, R8 ;          // __sinf

מספר הוראות ה-MUFU דומה, אבל קוד העזר סביבן כמעט נעלם - ה-FFMA של התיקון, עידון ה-Newton וההפחתת-טווח הזהירה הוחלפו בקירוב חומרתי ישיר. הכתובות (/*00xx*/) קרובות הרבה יותר זו לזו, כי ה-kernel כולו התכווץ.

למה זה עבד: הטרנסצנדנטיות לא מיושמות בהוראה אחת "אמיתית" אלא בקירוב חומרתי (MUFU.*) של ה-SFU, עטוף בקוד תיקון שמביא אותו לדיוק IEEE. expf הופך ל-2^(x*log2(e)), ולכן ההוראה היא MUFU.EX2 ולא "MUFU.EXP". grep MUFU הוא הדרך המהירה לראות בדיוק אילו פעולות מנותבות לpipe ה-SFU.

איך להכליל: כל פעם שאתם חושדים ש-kernel כבד-מתמטיקה, הריצו cuobjdump --dump-sass | grep MUFU וספרו את ההוראות. ריבוי MUFU מסמן תלות בpipe ה-SFU הנדיר. -use_fast_math מקצר את קוד העזר אבל לא משנה את הpipe - אתם עדיין תלויים ב-SFU, פשוט עם פחות overhead סביבו.

פתרון תרגיל 2 - יחס התפוקה FP64:FP32 ביד

החישוב:

FP32:  132 x 128 x 2 x 1.98e9 = 66,908 GFLOPS  = 66.9 TFLOPS
FP64:  132 x  64 x 2 x 1.98e9 = 33,454 GFLOPS  = 33.5 TFLOPS
יחס FP64 : FP32 = 33.5 : 66.9 = 1 : 2

היחס נובע ממילה אחת: נתיבים. יש ל-H100 חצי ממספר נתיבי ה-FP64 (64) לעומת FP32 (128), וזה מתורגם ישירות לחצי מהתפוקה. אימות מול ה-whitepaper של H100:

Peak FP32 ................. 66.9 TFLOPS   <-- matches
Peak FP64 ................. 33.5 TFLOPS   <-- matches, exactly half
Peak FP64 Tensor Core ..... 66.9 TFLOPS   <-- a different pipe! not relevant

שורת ה-"FP64 Tensor Core" מתייחסת ל-Tensor Core (ליבת טנזור), pipe נפרד לגמרי שמבצע כפל-מטריצות ב-FP64, ולא לנתיבי ה-FP64 הסקלריים של ה-CUDA Core. חישוב double רגיל ב-kernel (לא דרך cuBLAS/WMMA) רץ על נתיבי ה-FP64 הסקלריים, ולכן השיא הרלוונטי הוא 33.5 TFLOPS.

למה זה עבד: תפוקת שיא = מספר יחידות x פעולות-למחזור x תדר. מכיוון שה-FMA סופר כשתי פעולות נקודה צפה (כפל + חיבור), נכנס הפקטור 2. חצי הנתיבים ב-FP64 נותן בדיוק חצי מהתוצאה.

איך להכליל: הנוסחה #SM x #נתיבים-לפעולה x 2 x תדר נותנת את שיא הנקודה-צפה לכל סוג. החליפו את "#נתיבים" במספר של הפעולה שלכם (FP32=128, FP64=64, INT32=64 ל-SM ב-H100) כדי לקבל את התקרה הנכונה - זו בדיוק המתודולוגיה "ספרו את היחידה של הפעולה" מההרצאה.

פתרון תרגיל 3 - איזה pipe כל kernel מלחיץ

קטע הpipe הדומיננטי נימוק
A FP32 (CUDA Core) float FMA טהור - הוראת FFMA אחת לאיבר
B FP64 הצבירה ב-double רצה על 64 נתיבי ה-FP64 בלבד
C SFU שתי קריאות expf - שתי הוראות MUFU.EX2 לpipe הנדיר
D LSU העתקת בלוק - רק load ו-store, אפס חשבון
E INT32 כפל וחיבור שלמים (hashing) - נתיבי ה-INT32 (64 ל-SM)
  1. המספר "128 CUDA cores" מטעה במיוחד עבור B ו-E. שניהם רצים על 64 נתיבים בלבד (FP64 ב-B, INT32 ב-E), חצי מ-128 שהמפרט מפרסם. מי שמניח 128 יעריך פי-שניים יותר מדי תפוקה. (וכדאי להוסיף: עבור C המספר לא רק מטעה אלא לא רלוונטי בכלל - ה-SFU לא נספר ב-"128 CUDA cores" מלכתחילה.)

  2. C נראה קליל אבל עלול להיות bound כי שתי קריאות ה-expf מנותבות ל-SFU הנדיר (כ-16 יחידות ל-SM מול 128 FP32). גם אם החשבון סביבן זעיר, ה-SFU הוא צוואר הבקבוק: יחס SFU:FP32 של בערך 1:8 אומר שטרנסצנדנטית עולה בערך פי-שמונה מ-FMA בתפוקה.

למה זה עבד: סיווג pipe מסתכם בשאלה "מה הפעולה שחוזרת הכי הרבה, ולאיזו יחידה היא מנותבת". double -> FP64, expf/sinf -> SFU, load/store -> LSU, חשבון שלמים -> INT32, float FMA -> FP32.

איך להכליל: לפני שמאיצים kernel, זהו את הpipe הדומיננטי - כי מיטוב שמוסיף נתיבי FP32 ל-kernel SFU-bound או LSU-bound לא ישפר כלום. הpipe הנכון קובע איזה מיטוב בכלל רלוונטי.

פתרון תרגיל 4 - להוכיח שה-ALU יושב בטל

הקומפילציה והפרופיילינג:

nvcc -O2 -lineinfo -arch=sm_90a pipes.cu -o pipes
ncu --metrics \
  sm__inst_executed_pipe_lsu.avg.pct_of_peak_sustained_active,\
  sm__inst_executed_pipe_fma.avg.pct_of_peak_sustained_active,\
  gpu__dram_throughput.avg.pct_of_peak_sustained_elapsed \
  ./pipes

פלט מייצג (האחוזים ישתנו, היחס לא):

  copyKernel(const float *, float *, int)
    sm__inst_executed_pipe_lsu...pct_of_peak_sustained_active   %    82.4
    sm__inst_executed_pipe_fma...pct_of_peak_sustained_active   %     0.9
    gpu__dram_throughput...pct_of_peak_sustained_elapsed        %    91.7

  computeKernel(const float *, float *, int)
    sm__inst_executed_pipe_lsu...pct_of_peak_sustained_active   %     1.3
    sm__inst_executed_pipe_fma...pct_of_peak_sustained_active   %    88.5
    gpu__dram_throughput...pct_of_peak_sustained_elapsed        %     2.1

התמונה חד-משמעית. ב-copyKernel pipe ה-LSU ורוחב הפס של ה-DRAM כמעט רוויים (82% ו-92%), בזמן שpipe ה-FMA שואף לאפס (0.9%). ב-computeKernel המצב מתהפך: ה-FMA רווי (88%) וה-LSU וה-DRAM כמעט בטלים.

מה "יושב בטל" ב-copyKernel: כל נתיבי החישוב (ה-CUDA Cores). ה-kernel רק מעביר בתים - load ו-store - וההוראות האלה עוברות דרך ה-LSU, לא דרך ה-ALU. אז 128 נתיבי ה-FP32 של כל SM כמעט לא מבצעים כלום, וה-GPU כולו ממתין ל-HBM. זה בזבוז של רוב הסיליקון החישובי, וזו בדיוק ההגדרה של kernel memory-bound.

למה זה עבד: המדד pct_of_peak_sustained_active לכל pipe מראה כמה אחוז מתפוקת השיא של אותו pipe נוצלה. מכיוון שהוראות זיכרון עוברות ב-LSU והוראות חשבון ב-FMA, השוואת שני המדדים חושפת ישירות על איזה pipe ה-kernel bound. copyKernel בעל arithmetic intensity זעירה (חיבור אפס לכל load/store) ולכן LSU/DRAM-bound; computeKernel עם 256 FFMA לכל load בודד הוא FMA-bound.

איך להכליל: זו שיטת האבחון הסטנדרטית. פרופלו את pipe_lsu, pipe_fma, pipe_xu (טרנסצנדנטי) ו-dram_throughput יחד, וראו מי מתקרב ל-100%. הpipe הרווי הוא צוואר הבקבוק, וכל מיטוב חייב לכוון אליו - להגדיל arithmetic intensity ל-kernel memory-bound, או להפחית עבודה לpipe הרווי.

פתרון תרגיל 5 (בונוס) - צוואר בקבוק ה-SFU

הפרופיילינג של ה-transcendental:

ncu --metrics \
  sm__inst_executed_pipe_xu.avg.pct_of_peak_sustained_active,\
  sm__inst_executed_pipe_fma.avg.pct_of_peak_sustained_active \
  ./pipes
  transcendental(const float *, float *, int)
    sm__inst_executed_pipe_xu...pct_of_peak_sustained_active    %    86.3
    sm__inst_executed_pipe_fma...pct_of_peak_sustained_active   %    12.1

הpipe ה-xu (הטרנסצנדנטי, ה-SFU) כמעט רווי, וה-FMA נמוך. אחרי החלפה ל-__expf/__sinf:

  transcendental(const float *, float *, int)   // intrinsics version
    sm__inst_executed_pipe_xu...pct_of_peak_sustained_active    %    88.0
    sm__inst_executed_pipe_fma...pct_of_peak_sustained_active   %     3.4

זמן ה-kernel יורד (פחות הוראות עזר), אבל ניצול ה-xu נשאר גבוה - הpipe לא השתנה. הפחתנו את קוד התיקון סביב ה-MUFU, לא את התלות ב-SFU עצמו. ה-FMA אף ירד, כי הוראות ה-FFMA של התיקון נעלמו.

  1. אם ה-SFU רווי ב-100% וה-FMA נמוך, הוספת נתיבי חישוב לא תעזור בכלל - הם כבר בטלים. מה שיעזור: להפחית את מספר הקריאות הטרנסצנדנטיות (למשל לחשב sigmoid בזהות שמשתמשת ב-expf אחד במקום שניים), או להעלות occupancy כדי לפרוס את הקריאות על יותר warps ולהסתיר את ה-latency של ה-SFU מאחורי עבודה של warps אחרים.

למה זה עבד: __expf הוא MUFU.EX2 בודד בלי עטיפת התיקון, אז הוא מהיר יותר לכל קריאה - אבל הוא עדיין נכנס לאותו pipe xu נדיר. הרוויה של ה-xu היא מאפיין של כמות הקריאות הטרנסצנדנטיות, לא של הדיוק שלהן.

איך להכליל: כשpipe מסוים רווי, המיטוב היחיד שעוזר הוא להפחית עבודה שנכנסת אליו או להסתיר את ה-latency שלו בעוד warps - לא לשפר pipe אחר שכבר בטל. זהו את הpipe הרווי (grep MUFU ב-SASS, pipe_xu ב-ncu), ואז החליטו אם לקצץ קריאות, להחליף אלגוריתם, או להעלות תפוסה.