סיפורי אופטימיזציה: לעקוף ת'עז

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

עיזים (למצולמות אין קשר לכתבה)
עיזים (למצולמות אין קשר לכתבה)

רקע

כזכור, הגרסה החינמית של XC8 מתרגמת כל פקודת השמה בקוד ה-C שלנו (אופרטורים כגון = או ++) לרצף של פקודות אסמבלי, ששתיים מתוכן מיותרות לחלוטין מבחינה פונקציונלית. הסיבה היחידה לתוספת היא לגרום לקוד הסופי לרוץ לאט יותר, וזאת כדי שתוצרי הגרסה בתשלום – שכוללת, חייבים לציין, גם אופטימיזציות אמתיות – ייראו יותר מרשימים בהשוואה, וישכנעו ביתר קלות לקוחות פוטנציאליים להוציא כסף.

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

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

בלינק לדוגמה

כדי להדגים את הנושא כתבתי תוכנית קטנה ב-C ל-PIC12F675, שמגדילה מונה גלובלי בשם x מטיפוס char בלולאה הראשית. בכל פעם ש-x עולה על גדותיו וחוזר ל-0, התוכנה משנה את הפלט בפין GP0. המיקרו-בקר מוגדר לעבודה עם המתנד הפנימי שלו, בתדר שעון של 4MHz (שמיתרגם לתדר פעולות של 1MHz, כי ככה זה ב-PIC). הנה החלק המעניין בקוד:

    for ( ; ; ) {
        x++;
        if (!x) GPIO ^= 1;
    }

אחרי קימפול, מהתפריט Window->Debugging->Output->Disassembly Listing File אנחנו מגיעים לפלט האסמבלי. הקטע שמקביל לקוד הנ"ל הוא זה:

47:                    x++;
03EF  3001     MOVLW 0x1
03F0  1283     BCF STATUS, 0x5
03F1  00A1     MOVWF __pcstackBANK0
03F2  0821     MOVF __pcstackBANK0, W
03F3  07A0     ADDWF x, F
48:                    if (!x) GPIO ^= 1;
03F4  0820     MOVF x, W
03F5  1D03     BTFSS STATUS, 0x2
03F6  2BEF     GOTO 0x3EF
03F7  3001     MOVLW 0x1
03F8  00A1     MOVWF __pcstackBANK0
03F9  0821     MOVF __pcstackBANK0, W
03FA  0685     XORWF GPIO, F
49:                }
03FB  2BEF     GOTO 0x3EF

השורות שממוספרות 47, 48 ו-49 הן קוד ה-C המקורי, ומה שאחרי כל אחת מהן זה קוד האסמבלי שלה: חמש פקודות להגדלת x, שבע פקודות עבור כל אפשרויות ה-if, ופקודת goto יחידה עבור הלולאה האינסופית. רוב פקודות האסמבלי האלה, אם לא כולן, נמשכות מחזור פעולה יחיד. כלומר, אם נתעלם מהפקודות שמתרחשות רק לעתים רחוקות (כשתנאי ה-if מתמלא), כל סיבוב בלולאה אמור לקחת משהו כמו 8 מחזורים. מכאן שפין הפלט אמור לשנות את מצבו כ-488 פעמים בשניה (מיליון מחזורים בשניה, חלקי 8 פקודות, חלקי 256 צעדים לאיפוס המונה), או פעם ב-2.05 אלפיות שניה. בואו נסתכל בלוגי'ק אנלייזר:

פלט HIGH ו-LOW לסירוגין בקוד עם אנטי-אופטימיזציה
פלט HIGH ו-LOW לסירוגין בקוד עם אנטי-אופטימיזציה

שינוי כל 1.917 אלפיות שניה – מהיר בכמה אחוזים מהחישוב התיאורטי, אך בהחלט באותו מגרש (ייתכן שההבדל נובע מחוסר דיוק של המתנד הפנימי).

באסמבלי זה נשמע יותר טוב

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

a = 1;
asm("NOP");
b = 2;

אז בקוד האסמבלי, בין פקודות ההשמה של a לפקודות ההשמה של b, תופיע הפקודה NOP (שהיא, אגב, פקודת "אל תעשה כלום למשך מחזור אחד" – No OPeration).

מבין חמש הפקודות שזיהינו קודם, הראשונה (MOVLW) שמה את הערך 1 ברגיסטר W. השניה (BCF) מנקה את הדגלים ברגיסטר סטטוס. הדגלים האלה משמשים לזיהוי גלישה של ערכים, איפוס ותוצאות משמעותיות אחרות של חישובים, ובדרך כלל אסור לוותר עליהם – אם כי לדוגמה הספציפית שלנו נתעלם מהכלל הזה ומהדגלים. שתי הבאות הן פקודות ה"עז" המיותרות, והאחרונה (ADDWF) מוסיפה ל-x את הערך שב-W. מאיפה אני יודע את כל זה? קצת היגיון, והרבה קריאה במסמך המפורט של ה-Instruction Set של משפחת המיקרו-בקרים PICmicro mid-range, שה-PIC12F675 שייך אליה.

בלי העז ובלי הדגלים אפשר, אם כן, לרדת לשתי פקודות בלבד – אך למעשה, יש פקודה אחרת, שמאפשרת לנו להגדיל ערך של משתנה ב-1 ישירות. היא נקראת INCF ומקבלת שני פרמטרים: כתובת בזיכרון, וערך שאומר אם לשמור את תוצאת החיבור חזרה באותה כתובת, או להעביר אותה לרגיסטר W. במסמך הנ"ל הערך הזה מוצג כ-1 או 0, אבל בפועל הסתבר שהקומפיילר מזהה רק את הקודים F או W, בהתאמה. כמו כן, הסתבר שכדי לשלוח את הכתובת של המשתנה x במחרוזת של asm, צריך להוסיף לפניו קו תחתון אחד – וגם אז, זה יעבוד אך ורק אם x הוא משתנה גלובלי.  קוד ה-C החדש נראה ככה:

    for ( ; ; ) {
        asm("INCF _x, F");        
        if (!x) GPIO ^= 1;
    }

ובתרגום של הקומפיילר לאסמבלי, מקבלים:

46:                    asm("INCF _x, F");        
03F2  0AA0     INCF x, F
47:                    if (!x) GPIO ^= 1;
03F3  1283     BCF STATUS, 0x5
03F4  0820     MOVF x, W
03F5  1D03     BTFSS STATUS, 0x2
03F6  2BF2     GOTO 0x3F2
03F7  3001     MOVLW 0x1
03F8  00A1     MOVWF __pcstackBANK0
03F9  0821     MOVF __pcstackBANK0, W
03FA  0685     XORWF GPIO, F
48:                }
03FB  2BF2     GOTO 0x3F2

בלי שנגענו בכלל ב-if ובפעולת ה-xor (שגם בה הושתלו פקודות מיותרות), ירדנו משמונה פקודות בלולאה טיפוסית לחמש בלבד. לפי הפרופורציה הזו, משך הזמן בין שינוי לשינוי בפין הפלט צריך לרדת עכשיו ל-1.198 אלפיות השניה. נחזור ללוג'יק ונראה:

שינויי מצב בפין הפלט אחרי אופטימיזציה של קוד האסמבלי
שינויי מצב בפין הפלט אחרי אופטימיזציה של קוד האסמבלי

ירדנו ל-1.275 אלפיות שניה, איטי מעט מהצפוי אבל שוב, בהחלט בסביבות הנכונות.

אז מה?

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

להרשמה
הודע לי על
4 Comments
מהכי חדשה
מהכי ישנה לפי הצבעות
Inline Feedbacks
הראה את כל התגובות

נכון, אבל גם בפתרון שלך אתה לא מתחמק מהם לגמרי (3F8 & 3F9) – אני מציע פשוט במקום לנסות להתחמק מהפקודות הללו באמצעות אופרטורים בקוד (שהם לא כל כך נוחים, ומחייבים החלפה של כל קוד שארצה להריץ ולנסות…) להחליף את כל המופעים של צמד הפקודות הללו ב NOP.

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

זיגי, אתה מדרום ירושלים במקור?

כיון שזהו CONST קבוע (שתי פקודות שמרכיבות DWORD 0x00A10221) אפשר להחליף אותו בקובץ הריצה ל NOPs. מגעיל, ועדין יקח 4 מחזורים ריקים על כל פקודה כזו, אבל גם פתרון.

אולי אפשר גם לשחק עם הקובץ של הלינקר שיעשה את זה אוטומטית.