2018-11-24

Microbenchmarking 101

ספריית Jackson היא ספריה פופולרית בעולם ה JVM ליצירה / פענוח של JSON (וגם XML) - פעולה חשובה ושימושית.

במדריך הביצועים של הספרייה מצוין בכלל מספר 1:


בגלל שמדובר בכלל הראשון ברשימה, אני מניח שהוא חשוב - ומתחיל לבצע reuse בקוד ל ObjectMapper, כפי שהמליצו.
  • עד כמה זה חשוב? עד כמה זה משמעותי? 
  • האם עלי להימנע לגמרי מיצירה של מופעים ב ObjectMapper באותה המחלקה?
  • האם כדאי ללכת צעד אחד הלאה, ולשתף את המופע בין מחלקות שונות ?(כאן נוספת תלות בין מחלקות - מחיר לאיכות הקוד שאעדיף מאוד שלא לשלם)

טוב... ברוכים הבאים לעולם המתעתע והחמקמק של microbenchmarks (לצורף הפוסט: מדידונים).
  • כאשר אנחנו רוצים למדוד ביצועים של מערכת / מודול / ספריה - אנו עושים בדיקת ביצועים.
  • כאשר אנחנו רוצים למדוד ביצועים של פעולה קטנה / נקודתית - אנו משתמשים במדידון.

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

אז איך בונים מבחני-ביצועים טובים ויעילים, או במקרה שלנו: מדידונים טובים ויעילים לסביבת ה JVM?

ע"פ המלצת המומחים (לפחות אחד מהם), הצעד הראשון הוא לממש JVM בעצמכם - על מנת להבין את ה JIT בצורה טובה, ניהול זיכרון, ואת האופטימיזיות השונות שיכולות בקלות להפוך את המבחן שלכם ללא-רלוונטי.

העצה הזו היא טובה - אך לא שימושית ל 99% ויותר מהאוכלוסייה.
עוד עצה טובה ומקובלת היא לבחון את ה Byte Code שהופק מהמדידון - גם עליה כ 98% מהאוכלוסייה תוותר.

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


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



מדידון - הבסיס


כאשר אנו עושים בדיקת ביצועים פשוטה, חשוב להקפיד על כמה כללי בסיס:

מדידון לא-מושלם מאחד הפוסטים האחרונים. כבר במעבר ראשון עולות כמה נקודות לשיפור.

  1. לעבוד עם כמות גדולה של איטרציות, על מנת לקבל תוצאה חזקה יותר סטטיסטית ולבטל במידת האפשר רעשי-מדידה.
    1. למשל: שעון החומרה המספק את הזמן, והבאת הערך ממנו, יכולה לספק טעות מסויימת. מקרה נדיר אך אפשרי הוא NTP שפועל תוך כדי המדידה ומשנה את השעון בעשרות מילי-שניות, ויותר.
    2. כלל האצבע הוא להריץ בדיקה שתארוך כמה שניות לפחות - על מנת לבטל טעויות שעון בבטחה.
  2. כל פעולה שאיננה קשורה לבדיקה, למשל אתחולים או הכנת נתונים - יש להכין לפני.
    1. נקודה לשיפור בדוגמה: גם את היצירה של a,b ו map - היה כדאי לייצר מחוץ למדידה.
  3. לרוץ על הנתונים לפחות פעם אחת על מנת "לחמם" caches. בשפה כמו C - מעבר פשוט על המערך הוא מספיק. בשפת JVM זה לא מספיק, ויש שתי "תקלות" בחימום הזה:
    1. נקודה חשובה לשיפור בדוגמה: להריץ יותר מפעם אחת, ולהריץ את הקוד המלא בכל המסלולים האפשריים - על מנת לתת ל JVM לבצע אופטימיזציות לפני שאנו מתחילים למדוד. אוי.
    2. נקודה חשובה לשיפור בדוגמה: אין קריאה של הנתונים מחוץ ללולאה, וה compiler עלול להסיר את הלולאה הזו לגמרי מהקוד (כי אין לה השפעה על כלל המערכת). אאוץ.
  4. למדוד את זמן הריצה: mesureTimeMillis היא פונקציה פשוטה בקוטלין שמקבלת למבדה, לוקחת שעות לפני, מריצה, אחרי - ומחזירה את ההפרש בין השעונים.
    1. נקודה קטנה לשיפור בדוגמה: למרות ששימוש ב ()System.currentTimeMillis נשמע מספיק טוב, עדיף להשתמש ב ()System.nanoTime.
      Milis - מתאימה לזמן השעון, פחות מדויקת, וזמן השעון עלול להשתנות בזמן הריצה (למשל NTP).
      nano - לא באמת מחזירה רזולוציה של nanos (תלוי בחומרה + זה רק ה scale של המספר), ולא מבטיחה שאכן מדובר בשעה המדויקת (לכן חסרה את המילה current). בכל זאת: יותר מדויקת, ומתאימה יותר למדידות ביצועים.
      אם אתם מתעניינים - הנה נתונים נוספים.
  5. לצמצם את ה overhead של האיטרציה במידת האפשר.
    1. נקודה קטנה לשיפור בדוגמה: היה עדיף לרוץ ב index-based for loop, היעילה יותר מעבודה עם iterator.
    2. ספציפית בקוטלין איטרציה על Range או מערך דווקא כן מתרגמת ל index-based loop. במקרה שלנו מדובר ב collection.
  6. להריץ את כל ה benchmark כמה פעמים. כפי שניתן לראות מה comments, הייתה שונות בין של עשרות אחוזים כבר בין כמה הרצות בודדות.


סה"כ זהו מדידון בינוני מבחינת דיוק. על כן צוין בכתבה המקורית:

ניתן היה לכתוב מדידון נכון יותר.






הסכנות שבמדידון על גבי ה JVM



ביצוע מדידות על גבי קוד המקומפל לקוד מכונה (למשל: כמו שפת C) - הוא דבר אחד, וכיסינו למעלה את עיקרי הדברים שיש לשים לב אליהם.

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

סביבת ה JVM היא סביבה קשה במיוחד עבור מדידונים.

הנה דוגמאות עיקריות לבעיות, וכיצד ניתן להתמודד עימן:
  • ה JVM יראה את אופן ריצת הקוד ויבצע שיפורים והתאמות (deoptimization, recompilation effects, וכו') תוך כדי ריצה.
    mitigation: הרצת המדידה עצמה, בשלב ה warmup - על מנת שה JIT יעשה את השיפורים שם ולא בזמן המדידה. כלל האצבע: לעשות כמה עשרות אלפי איטרציות (כלומר: בלולאה) בשלב החימום. כל מסלול קוד אפשרי - צריך להתבצע עוד בשלב החימום.
    שווה גם להשתמש ב XX:+AggressiveOpts- על מנת שהאופטימיזציות יקרו מהר. אתם ממש תראו איך בכמה cycles של ה warmup יש שיפור - ואז יציבות. (ואם לא - הגדילו את ה warmups).
  • עשויות להתבצע פעולות קומפילציה ו/או GC בזמן הרצת המדידון - פעולות שישבשו את התוצאות.
    mitigation השתמשו ב XX:+PrintCompilation- ו verbose:gc- + הדפסה לפני ואחרי המדידה, על מנת שאם זה קרה זה יודפס בזמן ההרצה ותדאו להתעלם מהתוצאות.
    • כמובן שאתם יודעים שהדפסה בפעם הראשונה בתהליך JVM אחראי לחלק מהתאחולים במערכת - וחשוב שהדפסה ראשונה תהיה בשלב החימום.
  • חשוב לרוץ על סביבה נקיה, בה ה JVM "שקט" ככל האפשר.
    mitigation: נסו שהמדידון יהיה ה JVM היחידי, השתמשו ב Xbatch- על מנת שהקומפילציה תפעל רק לפני ההרצה, ו -XX:CICompilerCount=1 שלא יפעל במקביל, דאגו של JVM יש מספיק זכרון ושלא יהיה עליו להקצות בזמן הבדיקה. (Xmx==Mxs). מומלץ גם לקרוא ל ()System.gc בין הבדיקות. יש גם גישה שאומרת: הפעילו JVM חדש בכל הרצת בדיקה.
  • ישנן אופטימיזציות רבות על לולאות, למשל: hoisting של קוד מתוך הלולאה על מנת להפחית את ה overhead שלה. במקרים מסוימים פי 10 הרצות - יגרמו לאופטימיזציה לפעול, ולקוד לרוץ יותר מהר.
    mitigation: להריץ הרבה מאוד איטרציות בדי להנות מכל אופטימיזציה אפשרית. הרבה יותר קשה: לא להשתמש בלולאות של השפה, אלא לקרוא לפונקציה ב reflection ולמדוד את ההרצה בניקוי זמן הקריאה לפונקציה. בעיקרון קריאה לפונקציה, למרות התקורה הגבוהה יותר, נחשבת הדרך המדוייקת יותר לביצוע בדיקות. הנה דוגמה למקרה בו המעבר לפונקציה הפך בדיקה מחסרת חשיבות (אותה תוצאה להרצה ריקה מול הרצת לוגיקה) - למשמעותית.
  • Dead Code elimination: קוד שלא באמת בשימוש, ואין לו השפעה חיצונית - יבוטל ע"י אופטימיזציות של ה JVM.
    mitigation: חשוב שכל משתנה ששמנו בו ערך, יקרא לפחות פעם אחת - אחרת הקומפיילר עשוי "לסלק" את כל הקוד שרק מבצע השמה.
  • Constant Folding optimization - אם המעבד חישוב המבוסס על ערכים שלא משתנים, הוא יכול לשמור את תוצאות החישוב ולהשתמש בה שוב. האופטימיזציה הזו תקרה כאשר קוראים לאותו קוד שוב ושוב, ופחות בתסריט אמיתי ומורכב.
    mitigation: לא קל. עלינו לבלבל את ה JVM שלא יוכל לעשות את האופטימיזציה הזו...
  • אם הקוד צפוי, יהיו אופטימזציות אחרות של ה JVM ושל המעבד, שינצלו pipelining ארוך ו branch prediction.
    mitigation: דורשת מומחיות ברמת ה JVM... אין פתרון קל.

בקיצור: אם אתם לא מחליפים מקצוע למומחי בדיקות-ביצועים, אבל עדיין רוצים להריץ פה ושם מדידונים, מומלץ:
  • להתייחס בזהירות לתוצאות של מדידונים. 
    • להסתמך בעיקר על מגמות ("x מהיר משמעותית מ y" או "x מהיר רק מעט יותר מ y") - ולא על מספרים מדויקים ("x מהיר ב 10ns" או "x מהיר ב 10%", הן מסקנות לא חזקות לניבוי תוצאות בהרצה בסביבה אפילו מעט שונה).
    • לזכור שהתוצאות הן לחומרה ספציפית, מערכת הפעלה ספציפית, וגרסת תוכנה ספציפית (כל הרכיבים השונים). לא בהכרח התוצאות יתורגמו בצורה טובה למערכות אחרות.
    • להיות תמיד פתוחים לכך שהמדידון בעצם איננו מתאר את המצב במדיוק, ויש לשקול שנית את המסקנות (בעיקר כאשר ההבדלים בין ההרצות אינם שונים בסדר גודל).
  • להשתמש ב Framework שיגן עליכם בפני חלק גדול מהטעויות וההטיות האפשריות. ספיציפית: jmh.
    • jmh מסייע להתמודד עם כל הבעיות המסומנות בירוק למעלה. חלקן דורשות מעט חשיבה והתייחסות בקוד - אבל זה עולם אחר לגמרי מלנסות להתמודד איתן לבד.
    • יש גם את Caliper, אבל הוא נחשב פחות מדויק. הנה הסבר מפורט מדוע.



חזרה לשאלה המקורית: כמה "יקר" ליצור מופע של ObjectMapper בכל שימוש?


הכל התחיל מכך שרצינו לדעת כמה משמעותי הוא לעשות caching למופע של ObjectMapper מול יצירה של מופע עבור כל פעולת de/)serialization). כפי שאמרנו, בדיקה מספרית היא לא מדוייקת ולא נכון להשתמש בנתון המספרי כמדויק. למשל: "יצירה של מופע ObjectMapper אורך כ 10 מיקרו-שניות"

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

אם יצירה של מופע ObjectMapper אורכת כ 10 מילי-שניות (פלוס מינוס), אזי ברור לי שכדאי לצמצם מופעים באותו סדר גודל של דחיפות של צמצום פעולות לבסיס הנתונים - וזו כנראה תהיה עבודה קלה הרבה יותר.


בואו נצא לדרך:



  1. המפתח לשימוש ב jmh הוא ה Annotation בשם Benchmark@. הוא יגרום ל jmh לג'נרט את קוד הבדיקה בצורה "בטוחה מהטיות", במידת האפשר.
    פעם אחת (newMapper) אני בודק יצירה של mapper ושימוש בו.
    בפעם אחרת (reuseMapper) שימוש-חוזר ב mapper ואז שימוש בו.
    השתמשתי בפונקציה בשם ()improvedJsonMapper בה אנחנו משתמשים ליצור ObjectMapper ולהגדיר עליו כמה מודולים והגדרות נוסופות. אם כבר - אז כבר.
    1. שימו לב לאובייקט Blackhole, המוזר לכאורה. הוא מסייע לי לבטל אופטימיזציות של Dead code elimination שהזכרנו למעלה, ותפקידו הוא לא-לספק ל JIT שום מידע האם בערך שהועבר אליו נעשה שימוש.
    2. אני מנסה לתת הקשר יותר הגיוני לבדיקה, ולא רק לייצר מופע ל ObjectMapper. כל מופע דורש שימוש. אם השימוש היה יקר בסדרי גודל מהיצירה - לא משנה לי כמה היצירה היא יקרה. זה יהיה "בטל בשישים".
    3. יצרתי שימוש קטן (tiny) ושימוש קטן (small). ראיתי בינהם הבדל לא מוסבר תוצאות (עוד בהמשך), ולכן הוספתי מקרה עם הרבה נתונים (large).
      מבחני-ביצועים הם, בפוטנציה - מחקר בלתי-נגמר.
      דיונים בתוצאות מבחני-ביצועים - עלולים להיגרר לדיון בלתי-נגמר.
      לכן, כדאי לזכור מה רוצים להשיג - ולשים גבולות לזמן המושקע.
  2. אנחנו מעבירים גם אובייקט state המגיע מבחוץ על מנת להתמודד עם אופטימיזציית Constant Folding. חשוב  שכל הפרמטרים שבשימש יועברו מבחוץ ובאופן זהה. למשל: גם לבדיקה newMapper העברתי את ה mapper הגלובאלי שנוצר - על מנת "ליישר קו".
    1. בחרתי ב scope גלובאלי (Benchmark) כי אין פה נושא של מקביליות. שימוש ב scope של thread יצר שונות גדולה יותר בתוצאות הבדיקות, כנראה בגלל אתחולים נוספים של ה state.
    2. Params@ הם המקבילים ב Parameterized Tests של JUnit - לכל ערך תרוץ איטרציה נפרדת של הבדיקה עם הערך הנתון. הפרמטרים תמיד יוגדרו כמחרוזות, אך יומרו לטיפוס של השדה.
      שמות משמעותיים - עוזרים לעקוב ולהבין את התוצאות.
    3. את אתחול ה state מומלץ לעשות במתודת Setup@ - המובטח שתקרא מחוץ ל scope של המדידות.
      ישנן שלוש רמות של הרצה:
      1. trial - כל ההרצות של Benchmark@ לפי Param@ (הרמה הגבוהה ביותר).
      2. iteration - כל הפעלה של warm-up או מדידה אמיתית בתוך ה trial
      3. invocation - הקריאה הבודדת לקוד שב Benchmark@
  3. בצל התוצאות המעט מוזרות, וגם כדי להראות שימוש ביכולת ה TearDown - רציתי לוודא שהבדיקה רצה כפי שהתווכנתי. מה יותר טבעי מהדפסה?
    בכדי לא להפריע, את ההדפסה עושים מחוץ לזמן הבדיקות. הנתונים אכן כפי שציפיתי שיהיו.
  4. כמה הגדרות אחרונות ששוה להזכיר:
    1. אני רוצה למדוד זמן ריצה ממוצע. גישה אחרת היא למדוד Throughput, ויש גם אפשרויות נוספות.
    2. בקלט של הבדיקה נוח לעבוד עם מידת זמן בסדר גודל רלוונטי. במקרה שלנו : מיקרו-שניות.
    3. ה hint ל JIT מבקש ממנו לא לעשות inline לקוד. הוא לא נדרש בבדיקה הזו ספציפית - אך זה הרגל טוב.
    4. שימו לב שה class צריך להיות פתוח (ברירת המחדל ב Java) - על מנת שה generated code יוכל לרשת ממנו.



והנה התוצאה:


יש אנומליה בתוצאה, שאני לא יודע (כרגע) להסביר: הייתי מצפה שיצירה של mapper תוסיף זמן קבוע, ולא זמן שתלוי בגודל הקלט. הרצתי את הבדיקה כמה פעמים / וריאציות - והתוצאה אמינה.

הסבר יחידי שאני יכול לחשוב עליו, הוא שה mapper מבצע אתחולים נוספים בתוכו בשל הקלט השונה. למשל: טיפול בממשק Map (שלא מופיע ב tiny data) או כמות נתונים גדולה יותר. זו ספקולציה בלבד.


אתמקד בצורך שהביא אותי לכאן: האם / באיזו מידה אני רוצה לעשות שימוש חוזר ב ObjectMapper?
אני יכול להגיע כבר להחלטה שאני מרגיש נוח איתה:
  • אשתדל להשתמש במופע של mapper בתוך אותה מחלקה. כדאי - אבל לא חובה (גם 30 מיקרו-שניות, זה לא הרבה)
  • לא אצור, בשום מקרה, אובייקט mapper בתוך לולאה.
  • בהחלט לא אנסה לשתף מופעים של mapper בין מחלקות. השיפור לא מצדיק את פריצת ההכמסה.


את הקוד של ה benchmark, כולל הגדרות maven שלקח לי קצת זמן להגיע אליהן בכדי להריץ את הקוד בקוטלין ניתן למצוא ב github.
ב repository גם תמצאו קובץ shell script שבו אני משתמש להרצה, עם הגדרות ברירת-מחדל שאני מאמין שהן טובות למדי.
שווה לציין שיש plugin ל IntelliJ ל jmh, אבל הוא לא עובד עם קוטלין, ובכלל - אני מאמין שנכון וטוב יותר להריץ את jmh מתוך java -jar.


שיהיה בהצלחה!


תודה מיוחדת לחיים ידיד, מומחה ביצועים מהמעלה הראשונה, חבר, ואיש תוכנה בכלל - שנתן כמה הערות מועילות.



----

קישורים רלוונטיים

המדריך המועדף עלי ל jmh
JVM Anatomy Park - מדריך לאופטימיזציות השונות של ה JVM
קישור למצגת מאת Aleksey Shipilёv (מומחה עולמי בתחום ה microbenchmarking) שמסבירה ומדגימה כמה בעיות אפשריות בביצוע מדידונים


3 תגובות:

  1. סיפרתי לגיל טנא על הבלוג פוסט. נקווה שהוא ירים את הכפפה ויהיה לנו את ה״ויכוח הסוער על microbenchmarking הראשון״ בעברית.

    השבמחק
  2. כמה הערות
    1. שווה לנסות לשחק במספר ה threads שה benchmark מריץ לדוגמה להוסיף את הפרמטר t max-
    2. בדרך כלל כדאי לבדוק על גרסאות שונות של ה JDK (בעיקר אם טוענים טענות גלובליות)
    3. כנ״ל לגבי פלטפורמות חומרה
    4. הבנצ׳מרק בודק סיראליזציה. מה עם דה סריאליזציה ?

    השבמחק
    תשובות
    1. תודה על ההערות!

      אני חושב שחשוב מאוד להסתכל על הבנצ׳מרק מתוך הצורך:
      אם הכוונה היתה לצאת בהצהרה כללית על הביצועים של jackson - אני מסכים שהיה נכון להחיל את כל הצעדים שציינת, וכנראה אף יותר.
      ב scope של החלטה משנית עד כמה לעשות שימוש חוזר, ב scope של פרויקט עם קונפיגורציה ספציפית וידועה (הכל יכול להשתנות כמובן, אבל לא נריץ את ה benchmark שוב ושוב) - אני מרגיש נוח עם מה שיש.
      הייתי מרגיש טוב יותר אם רוב ההחלטות שאנו לוקחים היו מבוססות ברמה הזו.

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

      מחק