360 likes | 549 Views
אופטימיזציה של תוכניות. הנושאים במצגת זאת: אופטימיזציות לא תלויות-מכונה הזזת קוד החלפת אופרטורים 'יקרים' צירוף תתי ביטויים משותפים כוונון בעזרת profiling : זיהוי צווארי בקבוק בזמני ריצה. מבוסס על Bryant & O’hallaron / Computer Systems: a programmer’s perspective.
E N D
אופטימיזציה של תוכניות • הנושאים במצגת זאת: • אופטימיזציות לא תלויות-מכונה • הזזת קוד • החלפת אופרטורים 'יקרים' • צירוף תתי ביטויים משותפים • כוונון בעזרת profiling: • זיהוי צווארי בקבוק בזמני ריצה מבוסס על Bryant & O’hallaron / Computer Systems: a programmer’s perspective
סיבוכיות: תיאוריה אל מול מציאות • בדר"כ אנחנו מודדים סיבוכיות אסימפטוטית של אלגוריתמים. • בפועל: אנחנו עובדים על תוכנה שלמה, לא רק אלגוריתם מבודד. • ניתן לשפר בקבוע – גם זה חשוב! • תכנות יעיל יכול להביא לשיפור ביצועים של פי 10 ויותר. • אופטימיזציה במספר רמות: אלגוריתם, מבנה נתונים, פרוצדורות ולולאות. • לא ניתן לעשות זאת ללא הבנה של איך המחשב עובד • כיצד תוכנה מהודרת ומקושרת (linked) • איך למדוד ביצועי תוכנה ולזהות צווארי בקבוק • איך לשפר קוד בלי להרוס את המודולריות שלו והכלליות שלו.
סוגי אופטימיזציות • שתי משפחות של אופטימיזציות: • אופטימיזציות לא תלויות מכונה (הרצאה זאת) • אופטימיזציות תלויות מכונה (שעור הבא) • האפקטיביות מאוד תלויה במבנה הספציפי של המעבד
מהדרים הם מתרגמים מרובי אופטימיזציות • ממפים ביעילות תוכניות לשפת מכונה • הקצאת רגיסטרים • אופטימיזציות 'זולות' לחישוב, מקומיות ושמרניות. • בדר"כ לא יכולים להשפיע על סיבוכיות אסימפטוטית • עדיין באחריות המתכנת לבחור את האלגוריתם הטוב ביותר • סיבוכיות O(…) עדיין יותר חשובה משיפורים בפקטור קבוע. • אבל גם אלה חשובים... • מרכיבים רבים בקוד לא מאפשרים להם לעשות אופטימיזציות מרחיקות ראות. • חשש מ memory aliasing • חשש מ "תופעות לוואי" (side-effects) של פרוצדורות.
מגבלות של מהדרים • נדרשים לעמוד במגבלות חמורות: • אסור להם לשנות את הסמנטיקה של התוכנית תחת כל תנאי. • דבר זה מונע מהם לבצע שינויים שהיו מתגלים כלא נכונים רק במקרים שלעולם לא קורים בפועל. • התנהגות של התוכנית שהיא מובנת מאליה למתכנת, יכולה להיות מוסתרת מפני המהדר בגלל סגנון תכנות • למשל: טווח ערכים במערך בפועל קטן יותר מאשר הטווח המוכרז.
מגבלות של מהדרים • רוב הניתוח המבוצע על ידי המהדר נעשה בתוך הפרוצדורות ולא ביניהן. • ניתוח של כלל התוכנית הוא לא מעשי בגלל אילוצי זמן. • מבוסס על מידע סטטי • לא יכול לזהות קלט שיתקבל בזמן ריצה, וכו'. • חייב להיות שמרני!
אופטימיזציות שאינן תלויות מכונה • הזזת קוד (code motion) • הקטן את מספר הפעמים שקוד מורץ • בתנאי שזה לא ישנה את התוצאה • במיוחד כשמדובר בהוצאת קוד מלולאות for (i = 0; i < n; i++) { int ni = n*i; for (j = 0; j < n; j++) a[ni + j] = b[j]; } for (i = 0; i < n; i++) for (j = 0; j < n; j++) a[n*i + j] = b[j];
הזזת קוד על ידי המהדר • בדר"כ מהדרים עושים עבודה טובה בטיפול במערכים ולולאות פשוטות. • Code Generated by gcc for (i = 0; i < n; i++) { int ni = n*i; int *p = a+ni; for (j = 0; j < n; j++) *p++ = b[j]; } for (i = 0; i < n; i++) for (j = 0; j < n; j++) a[n*i + j] = b[j]; multiply R1,R2 # i*n move 8(R3),R4 # a leal (R4,R2,4),R5 # p = a+i*n (scaled by 4) # Inner Loop .L40: move 12(R3),R4 # b move (R4,R6,4),R2 # b+j (scaled by 4) move R2,(R5) # *p = b[j] add $4,R5 # p++ (scaled by 4) increment R6 # j++ compare R2 R6 jl .L40 # loop if j<n
דוגמה נוספת ל Code Motion • Procedure to Convert String to Lower Case • את המקרה הזה, כפי שנראה, המהדר לא יצליח לפתור. • האחריות – על המתכנת... void lower(char *s) { int i; for (i = 0; i < strlen(s); i++) if (s[i] >= 'A' && s[i] <= 'Z') s[i] -= ('A' - 'a'); }
Lower Case Conversion ביצועים של • זמן ריבועי באורך המחרוזת.
נתרגם לתוכנית goto... void lower(char *s) { int i = 0; if (i >= strlen(s)) goto done; loop: if (s[i] >= 'A' && s[i] <= 'Z') s[i] -= ('A' - 'a'); i++; if (i < strlen(s)) goto loop; done: } • strlenמבוצע בכל איטרציה • strlenליניארי באורך המחרוזת: מחפש את '\0' • זמן ריבועי
שיפור הביצועים void lower(char *s) { int i; int len = strlen(s); for (i = 0; i < len; i++) if (s[i] >= 'A' && s[i] <= 'Z') s[i] -= ('A' - 'a'); } • הוצא קריאה ל strlen מחוץ ללולאה • שהרי התוצאה אינה משתנה בין האיטרציות. • סוג של code motion
זמני ריצה • זמן ריצה ריבועי לעומת ליניארי
החלפת אופרטורים 'יקרים' • Shift, add instead of multiply or divide 16*x --> x << 4 • ההשפעה תלויות מכונה: בכמה עולה כפל וחילוק • בפנטיום II כפל של מספר שלם צורך רק 4 CPU cycles. חילוק – הרבה יותר. • זהה סדרה של הכפלות: int ni = 0; for (i = 0; i < n; i++) { for (j = 0; j < n; j++) a[ni + j] = b[j]; ni += n; } for (i = 0; i < n; i++) for (j = 0; j < n; j++) a[n*i + j] = b[j]; קודם השתמשנו בכפל (ni = n *i)
שימוש ברגיסטרים • קריאה וכתיבה לרגיסטרים מהירה בהרבה מקריאה/כתיבה לזיכרון. • המהדר לא יכול לקבוע איזה משתנים כדאי לשמור ברגיסטרים. • נותן קדימות למשתנים זמניים • נותן קדימות למשתנים המוגדרים על ידי המשתמש בעזרת המילה register
צרוף ביטויים משותפים • בדר"כ מהדרים לא מנצלים ידע אריתמטי. /* Sum neighbors of i,j */ up = val[(i-1)*n + j]; down = val[(i+1)*n + j]; left = val[i*n + j-1]; right = val[i*n + j+1]; sum = up + down + left + right; int inj = i*n + j; up = val[inj - n]; down = val[inj + n]; left = val[inj - 1]; right = val[inj + 1]; sum = up + down + left + right; 3 multiplications: i*n, (i–1)*n, (i+1)*n 1 multiplication: i*n leal -1(R1),R2 # i-1 multiply R4,R2 # (i-1)*n leal 1(R1),R3 # i+1 multiply R4,R3 # (i+1)*n multiply R4,R1 # i*n
כלים חשובים: • מדידה: • חשב זמן ריצה הנצרך על ידי קטעי קוד מסויימים • לרב המעבדים יש מוני שעון • לא קל להוציא מהם מידע שימושי... • השימוש ב profiler: • מספר הקריאות לכל פונקציה, וכו' • visual studio 6.0: • Project/settings/link: enable profiling • - הדר • - Build profile • gcc –O2 –pg prog. –o prog • ./prog • Executes in normal fashion, but also generates file gmon.out • gprof prog • Generates profile information based on gmon.out
דוגמה ל profiling • המטרה • חשב תדירות של מלים במסמך טקסט • הצג רשימה ממוינת מהמילה התדירה ביותר לנדירה ביותר • צעדים: • הפוך ל lowercase • הפעל פונקציית גִבּוּב (hash function). • נתחיל עם סכום ערכי ASCII של המחרוזת. • קרא מלים והכנס לטבלת גבוב. • פונקציה רקורסיבית שמכניסה מלים חדשות בסוף. • עדכן מונה עבור כל מילה • מיין תוצאות • נפעיל אלגוריתם בשם insertion-sort. (אלגוריתם זה יוצר רשימה חדשה, כל פעם מוסיף אליה איברים מהרשימה המקורית במקום הנכון).
דוגמה ל profiling • הקלט: • Collected works of Shakespeare • 946,596 total words, 26,596 unique • מימוש ראשוני: 9.2 שניות • Shakespeare’s • most frequent words
Profilingועכשיו: • הוסף ל executable פונקציות למדידת זמן • מחשב בקירוב זמן בתוך כל פונקציה. • שיטת החישוב • כל זמן מה (לערך 10 ms) שולח פסיקה • קובע איזו פונקציה כרגע מבוצעת • מעדכן את מונה הזמן ב 10ms • גם מעדכן מונה של מספר הקריאות לפונקציה
תוצאות ה- Profiling % cumulative self self total time seconds seconds calls ms/call ms/call name 86.60 8.21 8.21 1 8210.00 8210.00 sort_words 5.80 8.76 0.55 946596 0.00 0.00 lower1 4.75 9.21 0.45 946596 0.00 0.00 find_ele_rec 1.27 9.33 0.12 946596 0.00 0.00 h_add • סטטיסטיקת קריאות • מספר קריאות וסה"כ זמן בכל פונקציה • הבעיה בביצועים: • המיון לוקח זמן רב מדי:אלג' לא יעיל. • קריאה בודדת לוקחת 87% מזמן הריצה.
אופטימיזציות • צעד ראשון: החלף פרוצדורת מיון • נשתמש בפונקציית הספרייה qsort
אופטימיזציות נוספות • Iter first: במקום פונקציה רקורסיבית – לולאה. מוסיף איבר חדש לראש הרשימה. • מאט את הקוד • Iter last: כנ"ל, אבל הוסף איבר חדש לסוף הרשימה. • מלים נפוצות יותר יהיו קרוב לתחילת הרשימה. • Big table: טבלת גיבוב גדולה יותר • Better hash: פונקצית גיבוב טובה יותר • Linear lower: הוצאת strlenמהלולאה
מספר מלים על Profiling • יתרונות • עוזר לזהות צווארי בקבוק בביצועים • שימושי בעיקר כשמדובר במערכת מורכבת עם רכיבים רבים. • מגבלות • תלוי קלט • למשל, הטכניקה של linear lower לא הראה את כוחו בגלל שרב המלים הן קצרות. • ייתכן שלא נזהה חוסר יעילות כגון זמן ריצה ריבועי במקום ליניארי. • מנגנון חישוב הזמן לא מאוד מדויק • לא עובד במקרים של תוכניות הרצות פחות מ 3 שניות.
מדדי זמן • זמן אבסולוטי • בדר"כ ננו-שניות: 10–9 sec • סדר גודל מציאותי • Clock Cycles • טווח טיפוסי • 100 MHz • 108 cycles per second • Clock period = 10ns • 2 GHz • 2 X 109 cycles per second • Clock period = 0.5ns
Cycles Per Element (CPE) • דרך נוחה למדידת ביצועים במקרה של וקטור / רשימת נתונים • Length = n • T = CPE*n + Overhead vsum1, vsum2 שתי תוכניות עם הפרש ליניארי בביצועים. vsum1 Slope = 4.0 vsum2 Slope = 3.5
length 0 1 2 length–1 data דוגמה:חיבור איברי וקטור • Procedures vec_ptr new_vec(int len) • Create vector of specified length int get_vec_element(vec_ptr v, int index, int *dest) • Retrieve vector element, store at *dest • Return 0 if out of bounds, 1 if successful int *get_vec_start(vec_ptr v) • Return pointer to start of vector data • Similar to array implementations in Pascal, ML, Java • E.g., always do bounds checking
המשך דוגמה void combine1(vec_ptr v, int *dest) { int i; *dest = 0; for (i = 0; i < vec_length(v); i++) { int val; get_vec_element(v, i, &val); *dest += val; } } • מטרה • חשב סכום איברי הוקטור • שמור תוצאה ב dest. • Pentium III Performance: Clock Cycles / Element • 42.06 (Compiled -g) 31.25 (Compiled -O2) • (-g: debug information, -O2 : אופטימיזציות ברמה 2)
לולאות... void combine1-goto(vec_ptr v, int *dest) { int i = 0; int val; *dest = 0; if (i >= vec_length(v)) goto done; loop: get_vec_element(v, i, &val); *dest += val; i++; if (i < vec_length(v)) goto loop done: } • חוסר יעילות: • השגרה vec_length נקראת בכל איטרציה • למרות שהתוצאה זהה. 1 iteration
הוצא את vec_lengthאל מחוץ ללולאה void combine2(vec_ptr v, int *dest) { int i; int length = vec_length(v); *dest = 0; for (i = 0; i < length; i++) { int val; get_vec_element(v, i, &val); *dest += val; } } • אופטימיזציה • הוצא את vec_length מהלולאה הפנימית. • הערך לא משתנה. • Code motion • CPE: from 31.25 down to20.66 (Compiled -O2) • vec_lengthדורש זמן קבוע, אבל עם תקורה משמעותית.
חוסמי אופטימיזציות: פונקציות • מדוע, בעצם, לא יכול המהדר להוציא את vec_len או את strlenמחוץ ללולאה הפנימית ? • לפונקציות יכול להיות side effects • ייתכן שהפונקציה משנה את המצב הגלובלי • ייתכן שהיא מחזירה ערכים שונים עבור קריאה עם אותם ארגומנטים. • ייתכן של - strlen יש אינטראקציה עם lower. • מדוע בעצם המהדר לא מסתכל על הקוד של vec_len,strlen ? • אם ההידור הוא לא סטטי, המקשר(linker) עלול לקשר עם גירסה אחרת של אותה פונקציה. • אופטימיזציות המערבות מספר פרוצדורות אינה מעשית בדר"כ בגלל זמן ריצה. • המהדר מתייחס לפונקציות כ- 'קופסה שחורה'
עוד אופטימיזציות... void combine3(vec_ptr v, int *dest) { int i; int length = vec_length(v); int *data = get_vec_start(v); *dest = 0; for (i = 0; i < length; i++) { *dest += data[i]; } • אופטימיזציות • הימנע/י מקריאה לפונקציה כדי להביא את האלמנט הבא • מצא מצביע לתחילת הרשימה לפני הלולאה • בתוך הלולאה התייחס למצביע. • (פחות מודולרי ומתאים ל abstract data types) • CPE: 20.66 6.00 • קריאות לפונקציות הן יקרות • הורדנו גם את זמן בדיקת האינדקס (בדיקה שהוא בגבולות המערך).
הקטן גישות לזיכרון void combine4(vec_ptr v, int *dest) { int i; int length = vec_length(v); int *data = get_vec_start(v); int sum = 0; for (i = 0; i < length; i++) sum += data[i]; *dest = sum; } • עוד אופטימיזציה... • אין צורך לשמור את התוצאה ב dest עד הסיום • המשתנה הלוקאלי sum נשמר ברגיסטר • חוסך קריאה אחת וכתיבה אחת לזיכרון בכל איטרציה. • CPE: 6.00 2.00 (we started from 31.25) • גישה לזיכרון צורכת זמן רב.
חוסמי אופטימיזציות: Memory Aliasing • מדוע המהדר לא הפך את combine3 ל combine4 בעצמו? • התשובה:חשש מ Aliasing (כִּנוּי) • שתי התייחסויות שונות לזיכרון מתייחסות לאותו מיקום. v: [2, 3, 6] • combine3(v, get_vec_start(v)+2) --> האיבר האחרון בווקטור עכשיו יאחסן את התוצאה. [2,3,0] [2,3,2] [2,3,5] [2,3,10] • combine4(v, get_vec_start(v)+2) --> התוצאה שונה. [2,3,6] [2,3,6] [2,3,6] [2,3,11] מסקנה: combine3 וcombine4 הן לא בהכרח זהות בנוכחות כינויים.
חוסמי אופטימיזציות: Memory Aliasing נשים לב: • ב C, aliasing נפוץ. • בגלל שניתן לבצע address arithmetic (כלומר, חישוב מפורש של כתובת) • יש גישה ישירה למבנה המאחסן את הנתונים. • כדאי להשתמש במשתנים לוקאליים • בעיקר בתוך לולאות • כך המהדר יודע שהוא לא צריך לבדוק aliasing.
סיכום: אופטימיזציות שאינן תלויות מכונה. • הזזת קוד (Code Motion) • מהדרים יודעים לעשות זאת במקרים מסוימים: מערכים פשוטים ולולאות פשוטות. • לא יודעים לעשות זאת בנוכחות של 'כינויים' וקריאות לפונקציות. • החלפת אופרטורים יקרים • Shift, Add, instead of Multiply or Divide • בדר"כ מהדרים מצליחים לעשות זאת לבד, אבל לא תמיד. • בכל זאת, תלוי מכונה • כשאפשר, שמור מידע ברגיסטרים במקום בזיכרון • למהדרים קשה לזהות הזדמנויות, בגלל חשש לשימוש בכינויים. • איחוד ביטויים משותפים. • למהדרים יש יכולת אלגבראית מוגבלת.