אחד החסרונות/יתרונות של דוט-נט הוא השימוש במנגנון ה-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