DCSIMG
September 2009 - Posts - Liran Chen's Blog

Liran Chen's Blog

.Net Internals, Debugging, Multithreading - and More!

September 2009 - Posts

Forcing JIT Compilation During Runtime

אחד החסרונות/יתרונות של דוט-נט הוא השימוש במנגנון ה-JIT (הלא הוא ה-Just in Time Compilation). למעשה, התהליך שתפקידו להפוך את ה-CIL לשפת מכונה. אפשר להסתכל על המנגנון הזה בתור יתרון מאחר ובצורה הזאת התוכנית מקומפלת על מחשב היעד שבאמת מריץ את התוכנה שלנו. בצורה הזאת, בזמן הקומפילציה ניתן להשתמש  בכל היכולות שמעבד היעד תומך בהן. כלומר, בצורה הזאת אנחנו יכולים להגיע לקוד יעיל, ומהיר יותר בהשוואה לאם היינו מקמפלים את התוכנית על מחשב נפרד, בלי לדעת על אילו מחשבים יריצו את התוכנה שלנו (מאחר והיינו צריכים לקמפל את הקוד למכנה המשותף הנמוך ביותר, ולא היינו יכולים להשתמש ביכולות ייחודיות של מעבדים שונים).
החסרון הבולט של ה-JIT, הוא הזמן שאנחנו מבזבזים בעצם ביצוע הקומפילציה הזאת אצל המשתמש. מה שקורה, זה שעבור כל MethodDesc (מבנה נתונים הקיים עבור כל פונקציה דוט-נטית, ומכיל מעט Metadata על הפונקציה. נמצא בתוך ה-EE Memory), קיים שדה בוליאני הנקרא IsJitted. כמו שמשתמע מהשם שלו, הוא למעשה אומר האם ה-JIT כבר הופעל על אותה הפונקציה (נעזר בשדה הזה בהמשך). כשהקוד שלנו קורא לפונקציה בפעם הראשונה, הוא מופנה דרך stub מיוחד אל ה-JIT שגורם לו לחולל את קוד המכונה המתאים. לאחר מכן, אותו stub נדרס עם פקודת jmp שמביאה אותנו הישר לקוד שחולל על ידי ה-JIT (למעשה, מדובר במנגנון שמאוד מזכיר את יכולת ה-delay load הקיימת ב-CPP), אז ה-JIT נכנס לפעולה ומקמפל את הקוד הנ"ל. כך שאנחנו למעשה סובלים מאיזשהו Overhead קטן בהרצה הראשונה של הקוד שלנו (זאת הסיבה שכאשר עושים Benchmark'ים לקוד דוט-נטי, לא מתייחסים להרצה הראשונה של קטע הקוד).
זה המקום להבהיר שה-Overhead שה-JIT גורם לו הוא בדרך כלל מזערי, מאחר ואנחנו חווים אותו בהרצה הראשונה של הקוד בלבד, ולאחר מכן נהנה מהביצועים המשופרים של הקוד שעבר אופטימיזציה למכונת המשתמש. כך שהתרחיש הכנראה יחיד בו בכל זאת יהיה יכול לעניין אותנו כיצד אפשר להפטר מה-Overhead הזה בכל זאת, הוא כשמדובר באפליקציות עם ממשק משתמש כלשהו. במקרה הזה, המשתמש עשוי לקבל את הרושם שהתוכנית שלנו מעט "עצלה" בהרצה הראשונה, כך שבתרחיש הזה יש לנו איזשהיא מוטיבציה לשפר את הביצועים במובן הזה.

הפתרון הקלאסי לבעיה הזאת, הוא שימוש ב-NGen. מדובר בכלי המסופק על ידי מיקרוסופט, והרעיון שעומד מאחוריו הוא שבזמן התקנת התוכנה אצל מחשב הלקוח, אנחנו נריץ את ה-JIT על כל הקוד שלנו ולמעשה נחולל Native Image עבור האפליקציה. בזמן הטעינה, הסביבה כבר תדאג לטעון את ה-Image הנכון מהדיסק, ולהמנע מ-Jitting מיותר. הבעיה עם השימוש ב-NGen הוא שהוא מסורבל למדי, ואם אנחנו באמת מעוניינים לנצל אותו כמו שצריך ולהמנע מ-Overhead שהוא יכול להוסיף בעצמו, נצטרך כבר באמת להקדיש לא מעט זמן ומחשבה לעניין (קביעת Base Addresses נכונים, המנעות מ-ReBasing, רישום האסמבלי ב-GAC ועוד...)
אולם, קיימת אלטרנטיבה לשימוש ב-NGen, והיא לאלץ את ה-JIT לעבוד בזמן הרצת התוכנית. לשיטה הזאת גם כן לא חסרים חסרונות משלה, ובתמונה הכוללת היא לא בהכרח טובה משימוש ב-NGen, אבל במידה ואנחנו מעוניינים לזרז את ה-JIT תוך כדי מאמץ מינימלי מצידנו, זאת בהחלט אפשרות שניתן לשקול (ובכלל, תמיד מעניין לדעת מה באמת אפשר לעשות בדוט-נט).

אז כדי לקפוץ ישר למים, אני אגש להסבר בצורת Bottom-Up, כך שקודם נבין איך נוכל לבצע את האילוץ עצמו, ואחר כך איך אפשר להשתמש ביכולת הזאת באפליקציה קיימת.
אם כן, הצעד הראשון שלנו להפעיל את ה-JIT על פונקציה בודדת. לשם כך נשתמש בפונקציה PrepareMethod. באופן טבעי נעשה בה שימוש כאשר רוצים להפעיל פונקציות וירטואליות מתוך אזורי CER, אבל מה שמעניין אותנו הוא זה שאותה פונקציה למעשה גורמת ל-JIT לקמפל את פונקצית היעד שהעברנו לה. פרט נוסף שכדאי להיות מודעים אליו, הוא שקריאה ל-PrepareMethod יכולה לגרור הפעלה של הבנאי הסטאטי במידה והמחלקה מממשת אותו.
היות ובמקרה שלנו אנחנו לא מעוניינים לפנות פונקציה ספציפית, אלא לכל הפונקציות הקיימות בשלל האסמבלים שהתוכנית שלנו משתמשת בהם, נצטרך לכתוב פונקציית עזר שמקבלת אסמבלי, מחלצת את כל הטיפוסים והפונקציות שמעניינות אותנו, ואז מפעילה על כל פונקציה שמצאנו את PrepareMethod. לדוגמה:

public static void PreJITMethods(Assembly assembly)

{

    Type[] types = assembly.GetTypes();

    foreach (Type curType in types)

    {

        MethodInfo[] methods = curType.GetMethods(

                BindingFlags.DeclaredOnly |

                BindingFlags.NonPublic |

                BindingFlags.Public |

                BindingFlags.Instance |

                BindingFlags.Static);

 

        foreach (MethodInfo curMethod in methods)

        {

            if (curMethod.IsAbstract ||

                curMethod.ContainsGenericParameters)

                continue;

 

            RuntimeHelpers.PrepareMethod(curMethod.MethodHandle);

        }

    }

}


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

השלב הבא יהיה להטמיע את הקוד הזה בתוך תוכנית דוגמה. כשאנחנו באים לעשות את זה, עומדות לפנינו מספר נקודות למחשבה לגבי הצורה בה אנו מעונינים להפעיל את התהליך הזה. האם אנחנו רוצים לבצע אותו פעם אחת, בעליית האפליקציה? או אולי בכל פעם שנטען אסמבלי חדש? האם אנחנו רוצים שהפעולה תהיה סינכרונית, או שולי נעדיף שתרוץ במקביל להרצה הרגילה, בת'רד נפרד (שיתכן ונעדיף להעניק לו עדיפות נמוכה מאשר שאר הת'רדים בהם בהם אנחנו משתמשים), והאם אנחנו מעוניינים לעשות זאת רק על הקוד שלנו? או גם על הקוד שאנחנו טוענים מה-BCL? עולות כאן הרבה שאלות, שהתשובה אליהן יכולה להשתנות מתרחיש לתרחיש.
אני אתייחס לשאלות האלה בעוד רגע, אבל קודם כל, בואו נראה מה עלינו לעשות כדי שכל זה יהיה אפשרי בכלל.
קודם כל עלינו לזכור שבתור ברירת מחדל, ה-CLR נוהג באופן חסכוני הכל הנוגע לטעינת אסמבלים. ועל פי הקו המנחה שלו, כל עוד לא השתמשנו בטיפוס הנמצא באסמבלי נפרד, אין צורך לטעון אותו. כלומר, גם אם התוכנית שלנו משתמשת ב-100 אסמבלים נוספים ל-exe הראשי, אם נקרא ל-GetAssemblies ברגע שהתוכנית הופעלה, נגלה שאף אחד מאותם 100 אסמבלים בכלל לא נטענו עדיין ל-AppDomain, מאותה הסיבה בדיוק. לכן, אם אנחנו רוצים להפעיל את ה-JIT על כל האסמבלים שעומדים להטען בעתיד, אנחנו נצטרך לטעון אותם באופן מפורש. נוכל לעשות זאת על ידי קריאה באופן רקורסיבי ל-GetReferencedAssemblies ו-Load. לדוגמה:

// recursively load all of assemblies referenced by the given assembly

public static void ForceLoadAll(Assembly assembly)

{

    ForceLoadAll(assembly, new HashSet<Assembly>());

}

 

private static void ForceLoadAll(Assembly assembly,

                                 HashSet<Assembly> loadedAssmblies)

{

    bool alreadyLoaded = !loadedAssmblies.Add(assembly);

    if (alreadyLoaded)

        return;

 

    AssemblyName[] refrencedAssemblies =

        assembly.GetReferencedAssemblies();

 

    foreach (AssemblyName curAssemblyName in refrencedAssemblies)

    {

        Assembly nextAssembly = Assembly.Load(curAssemblyName);

        if (nextAssembly.GlobalAssemblyCache)

            continue;

 

        ForceLoadAll(nextAssembly, loadedAssmblies);

    }

}

>
אפשר לשים לב שבדוגמה הזאת, הפונקציה מסננת אסמבלים הנמצאים ב-GAC, כך שהיא תתעלם באופן אוטומטי מאסמבלים השייכים ל-BCL למשל (וע"י כך חסכון משמעותית בגודל ה-Working Set). כמו שאפשר להבין, את הפונקציה הזאת (כמו דוגמת הקוד הקודמת) אפשר לכוון ולערוך למטרות ספציפיות. יתכן ותרצו לטעון אסמבלים מסויימים, ולהתעלם מאחרים. בכל אופן, מה שלא תרצו לעשות, זה המקום לעשות זאת.
חשוב לשים לב שקריאה לפונקציה הזאת תגרום לטעינת כל ה-Statically Referenced Assemblies. כלומר, רק במידה והקוד שלכם מתייחס לטיפוסים מאותו אסמבלי, תוכלו לטעון אותו בצורה הזאת. אולם, קיימת אפשרות שתרצו לטעון חלק מהאסמבלים שלכם בצורה דינאמית (על ידי שימוש ב-Reflection). במידה ותרצו להיות מודעים לכל טעינת אסמבלי שקוראת אצלכם בתוכנית (ולהגיב בהפעלת ה-JIT על אותו אסמבלי למשל), תוכלו לעשות זאת דרך רישום לאירוע AssemblyLoad שיופעל בזמן טעינת אסמבלים חדשים.

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

> !name2ee OtherAssmebly.dll C.ClassC
Module: 00a457b8 (OtherAssmebly.dll)
Token: 0x02000002
MethodTable: 00a48bbc
EEClass: 00f283d4
Name: C.ClassC

> !dumpmt -md 00a48bbc
EEClass: 00f283d4
Module: 00a457b8
Name: C.ClassC
mdToken: 02000002  (C:\Documents and Settings\Liran\My Documents\VisualStudio2008\Projects\ConsoleApplication1\ConsoleApplication1\bin\Debug\OtherAssmebly.dll)
BaseSize: 0xc
ComponentSize: 0x0
Number of IFaces in IFaceMap: 0
Slots in VTable: 6
--------------------------------------
MethodDesc Table
   Entry MethodDesc      JIT Name
00a4c838   00a48bb0     NONE C.ClassC..ctor()
00a4c830   00a48ba0      JIT C.ClassC.FooMethod()

> !dumpmd 00a48ba0
Method Name: C.ClassC.FooMethod()
Class: 00f283d4
MethodTable: 00a48bbc
mdToken: 06000001
Module: 00a457b8
IsJitted: yes
CodeAddr: 00f50778

Mysteries with Circular Dependencies

אם יש משהו אחד בסיסי שכולם יודעים על פיתוח תוכנה, הוא שלא ניתן ליצור תלות מעגלית בין פרוייקטים (DLL'ים).
אם למשל יש לנו פרוייקט A, שפונה לפרוייקט B, אז לא יהיה ניתן שאותו פרוייקט B יפנה בחזרה לפרוייקט A בתור רפרנס. אם היינו עושים דבר כזה, היתה נוצרת לנו תלות מעגלית בין שני הפרוייקטים. וזה רע, בגלל שכשהקומפיילר ירצה לקמפל את A, הוא יראה שהוא תלוי ב-B, אז הוא יגש ל-B וינסה לקמפל אותו. אבל אז הוא יגלה ש-B למעשה תלוי ב-A, ואז הוא יחזור חלילה עד אין קץ..

האמנם?
אם יצא לכם לפשפש מספיק במבנה הספריות של ה-BCL, יתכן ושמתם לב לכל מיני תלויות לא הגיוניות, אם לא "בלתי אפשריות". קחו למשל את המקרה הזה:

  • Xml - תלוי ב-System
  • Configuration - תלוי ב-System
  • System - תלוי ב-Xml ו-Configuration
אכן, תמונות קשות. ברגע אחד, נראה שכל ההבנה שלנו על מה שאפשר, או יותר נכון: אי אפשר לעשות עם תלויות בין DLL'ים, התנפצה לרסיסים. אבל מה לעשות, זה המצב, ולמרות שאנחנו נמצאים ב-State of mind שמה שראינו לפני רגע לא יכול להתקיים ... נראה שהוא מתקיים בכל זאת. והכל בחסות ה-BCL.

אז אחרי ששיפשפנו את העיניים, ושטפנו את הפנים במעט המים, הגיע הזמן להבין איך המצב הזה באמת מתאפשר בפועל.
נחזור לדוגמה איתה פתחתי את הפוסט. לצורך העניין נאמר שיש לנו שני DLL'ים, A ו-B. בתוך A, יש לנו את המחלקות ClassA, ClassB. לעומת זאת, בתוך B, יש לנו את המחלקה ClassC. עכשיו, אנחנו רוצים ש-ClassB תירש מ-ClassC בעוד שזאתי תירש מ-ClassA. כלומר, נוצרת לנו כאן תלות מעגלית קלאסית.


 
 
כדי לאפשר את התלות הזאת, נצטרך לעזוב לרגע את Visual Studio, ולעבור ל-Command Prompt כדי שנוכל לעבוד ישירות מול הקומפיילר (ברמת העקרון ניתן לעשות זאת גם דרך VS, רק שזה הופך את התהליך להרבה יותר מסורבל).
הרעיון הוא שנבצע את הקומפילציה בשני שלבים. בשלב הראשון נקמפל את A, ללא החלקים שתלויים ב-B. כלומר, רק את ClassA. לאחר מכן, נקמפל את B כרגיל (הוא יוכל להשתמש ברפרנס ל-A בגלל שהחלקים שמעניינים אותו כבר קומפלו בצעד הקודם). הצעד האחרון, הוא לקמפל מחדש את A. הפעם את כל הקוד, גם זה שתלוי ב-B (שימו לב ש-B הוא כבר DLL מקומפל ומלא לכל דבר).
דרך ה-Command Prompt, זה נראה כך:
 
>csc /target:library /out:A.dll ClassA.cs                                         // compile a "thin" version of A
>csc /target:library /reference:A.dll /out:B.dll ClassC.cs                  // compile full B
>csc /target:library /reference:B.dll /out:A.dll ClassA.cs ClassB.cs  // compile full A
 
 
בתוצאה הסופית, קיבלנו בדיוק מה שרצינו. יש לנו כעת שני DLL'ים, A ו-B. ובניגוד לכל מה שהגיוני בעולם, קיימת ביניהם תלות מעגלית אחת ונפלאה.
אחרי מסכת חיפושים קצרה שערכתי בגוגל, הגעתי לקצה חוט שלפיו, בתהליך ה-Build הפנימי של מיקרוסופט נעשה שימוש ב-Metadata Assemblies (אסמבלים המכילים אך ורק Metadata, ללא פרטי מימוש), במקום אסמבלים אמיתיים. ובצורה זאת התלות המעגלית "נשברת" והסיטואציה המשונה הזאת מתאפשרת. עם זאת, לא נתקלתי עדיין במאמר מסודר מספיק שמתאר את התהליך בפרטי פרטים, כך שאין לשער באילו קסמים אחרים יתכן ומשתמשים ברדמונד.