לדלג לתוכן

אופטימיזציות קומפיילר מוסברות - מה קורה כשמוסיפים O2 ומרגישים שהתוכנה רצה מהר יותר

כתבתם קוד C, הידרתם אותו עם -O2, והתוכנית פתאום רצה מהר יותר, בלי ששיניתם שורת קוד אחת. זה לא קסם - הקומפיילר שכתב מחדש חלקים מהקוד שלכם, בצורה שמייצרת בדיוק את אותה תוצאה, אבל מגיעה אליה מהר יותר או עם פחות משאבים. להבין מה בדיוק קורה שם זה להבין למה לפעמים אופטימיזציה משנה גם התנהגות שנראית לכם לא קשורה בכלל.

דגלי האופטימיזציה - כמה חזק לדחוף

ברוב הקומפיילרים, כולל gcc ו-clang, רמת האופטימיזציה נשלטת דרך דגל -O. -O0, שהיא ברירת המחדל, כמעט לא מבצעת שום אופטימיזציה - הקוד המהודר קרוב ככל האפשר לקוד המקורי, מה שהופך אותו לקל לדבג אבל איטי יחסית. -O1 עד -O3 מגבירות בהדרגה את מידת האגרסיביות של האופטימיזציות, כש--O3 כוללת גם טכניקות שדורשות יותר זמן קומפילציה ולפעמים מגדילות את גודל הקובץ הסופי. יש גם -Os, שמייעל דווקא לגודל קוד קטן, שימושי במיוחד במערכות משובצות עם מקום אחסון מוגבל.

Inlining - הטמעת פונקציה ישירות בקוד הקורא

כשפונקציה קטנה נקראת שוב ושוב, יש עלות לכל קריאה - קפיצה בזיכרון, שמירת מצב, וחזרה בסוף. Inlining לוקח את גוף הפונקציה, ומדביק אותו ישירות במקום שממנו היא נקראה, כאילו הקוד נכתב שם מלכתחילה. זה חוסך את עלות הקריאה, אבל במחיר - אם הפונקציה נקראת ממאה מקומות שונים בקוד, ה-inlining שלה מגדיל את גודל התוכנית הכוללת פי כמה.

Loop unrolling - פחות בדיקות תנאי, יותר עבודה בכל סיבוב

לולאה שרצה עשר פעמים, בודקת תנאי עשר פעמים - בכל סיבוב, המעבד צריך לבדוק אם להמשיך או לצאת. Loop unrolling כותב מחדש את הלולאה כך שכל סיבוב מבצע כמה איטרציות בבת אחת, ומצמצם את מספר בדיקות התנאי. במקום לולאה עם עשרה סיבובים ועשר בדיקות, אפשר לקבל לולאה עם חמישה סיבובים, כשכל סיבוב מבצע שתי איטרציות של הקוד המקורי ברצף. זה חוסך זמן בבדיקות תנאי, במחיר גודל קוד גדול יותר.

ביטול קוד מת וקיפול קבועים

אם משתנה מחושב אבל אף פעם לא נקרא, או אם ענף שלם בקוד לעולם לא יכול להתבצע, הקומפיילר פשוט מוחק אותו לגמרי - ביטול קוד מת, dead code elimination. באופן דומה, קיפול קבועים - constant folding, מחשב מראש, בזמן קומפילציה, כל ביטוי שהתוצאה שלו ידועה בוודאות מראש - אם כתבתם int x = 5 * 3, הקומפיילר פשוט יכתוב 15 ישירות, בלי לבצע את הכפל בזמן ריצה בכלל.

ביטול תת-ביטוי משותף

אם אותו חישוב מדויק מופיע כמה פעמים בקוד, הקומפיילר יכול לזהות את זה, לחשב אותו פעם אחת בלבד, ולהשתמש בתוצאה שוב בכל מקום שבו הוא היה אמור להתבצע מחדש. הטכניקה הזאת, ביטול תת-ביטוי משותף - common subexpression elimination, יעילה במיוחד בביטויים מתמטיים מורכבים שחוזרים על עצמם, לפעמים בלי שהמתכנת בכלל שם לב שהם זהים.

המחיר - למה קוד מהודר עם אופטימיזציה קשה יותר לדבג

כל האופטימיזציות האלה יכולות לשנות באופן דרמטי איך הקוד באמת נראה ברמת המעבד, לעומת איך שהוא נראה בקובץ המקור. משתנים שכתבתם יכולים להיעלם לגמרי כי הם קופלו לקבועים, פונקציות שקראתם להן יכולות להיעלם כי הן הוטמעו, וסדר הביצוע יכול להשתנות כל עוד התוצאה הסופית זהה. זו הסיבה שדיבוג עם -O2 או -O3 לפעמים מרגיש כמו לדבג תוכנית אחרת לגמרי - שורות בקוד המקור לא תמיד מתאימות באופן ישיר להוראות שרואים בדיבאגר. בגלל זה, מפתחים בדרך כלל מדבגים עם -O0, ורק כשבודקים ביצועים סופיים עוברים לרמת אופטימיזציה גבוהה.

למה זה שווה להכיר

הבנת אופטימיזציות קומפיילר עוזרת להבין למה לפעמים קוד "שווה ערך" מבצעי אחרת בפועל, ולמה מדידת ביצועים תמיד צריכה להתבצע על הבינארי הסופי, לא על קוד שנכתב בלי מחשבה על איך הוא יעבור אופטימיזציה. זה בדיוק סוג ההבנה שאנחנו בונים בקורס ליבת המחשב - מה קורה בין הקוד שכתבתם לקוד שבאמת רץ.

ואם קוד "מותאם" גרם לכם לבאג שקשה להבין, יש קהילה שכבר נתקלה בזה.

הצטרפו לקהילה בדיסקורד

לסיכום

אופטימיזציות קומפיילר, מ-inlining ועד ביטול קוד מת, משנות את הקוד שלכם ברמה עמוקה, בלי לגעת בשורת המקור, כדי לייצר את אותה תוצאה מהר יותר או בפחות מקום. ככל שרמת האופטימיזציה גבוהה יותר, כך המרחק בין קוד המקור לקוד שבאמת רץ גדל, מה שהופך דיבוג לקשה יותר אבל ביצועים למהירים יותר. זו בדיוק הסיבה שהבחירה בין -O0 לדיבוג ו--O2 או -O3 לייצור היא לא עניין של טעם, אלא החלטה מודעת בהתאם למה שבדיוק צריך באותו רגע.