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) שמסבירה ומדגימה כמה בעיות אפשריות בביצוע מדידונים

מה *לא* לימדו אותנו באוניברסיטה על מבני-נתונים? חלק ב'

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

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

  • מדוע HashTable לא באמת פועל ב (Θ(1?
  • מדוע עבודה על איברים בודדים במערך ממוין – מהירה יותר מעבודה על מערך לא ממוין?
  • כיצד Regular Expressions עלולים להוסיף סיבוכיות מיותרת?
  • מהו אלגוריתם המיון היעיל והנפוץ ביותר – שאתם כנראה לא מכירים?
בואו נתחיל!
לא כל ה hashtables נולדו שווים. זמני הכנסה של מיליוני איברים.

מיתוס: HashTable מכניס ושולף איברים ב (Θ(1

בקורס מבני-נתונים כנראה ולימדו אותנו ש HashTable מכניס/מוחק/שולף איברים בזמן קבוע – ולכן ניתן להסיק שזה זמן טוב מאוד.

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

אבל, כאשר אנו מטפלים בכמויות גדולות של נתונים, חשוב להבין:

זמן הריצה של ה hash function איננו אפסי. פונקציית hash אורכות זמן, בד"כ כפונקציה יחסית לקלט.
אם נניח, לצורך הפשטות, שהמפתח (key) ב HashTable הוא מחרוזת, אזי יהיה נכון לומר שזמן הכנסה של איבר הוא (Θ(k כאשר הוא k תלוי באופן ישיר באורך המחרוזת. זמן הביצוע תלוי גם בפונקציית ה hash הספציפית שנבחרה, ופונקציות hash "איכותיות" המספקות פיזור קרוב יותר לאחיד – רצות לאורך זמן רב יותר.

במקרה והמפתח של ה HashTable הוא אובייקט מורכב – ייתכן וזמן הריצה יהיה משמעותי.

חשוב לזכור שאת פונקציית ה hash לא מחשבים רק בהכנסה של איבר, אלא גם בכל שליפה.
כאשר יש התנגשויות (collisions) אזי יש לקחת בחשבון גם n קטן של השוואות.

נניח ועלינו לשלוף מתוך סט של M איברים – כ m איברים. עומדות לפנינו 2 ברירות:

  • לשלוף m איברים מתוך HashTable, בזה אחר זה.
  • לסרוק את כל המערך בגודל M ולמצוא את האיברים.
    • להזכיר: ה HashTable משתמש במערך, מאחורי הקלעים.

בהסתכלות נאיבית נראה שהבחירה היא בין (Θ(M לבין (Θ(m (כאשר M > m) – בחירה קלה למדי.

בפועל הבחירה היא בין (Θ(M לבין (Θ(m*k, כאשר סביר להניח ש k (זמן הריצה של ה hash function כתלות באורך הקלט) יהיה גדול בעשרת מונים, לכל הפחות, מפעולת שליפה של איבר בודד ממערך.
בסריקה סדרתית של המערך, כפי שאנו יודעים – אנו נהנים גם Data locality של הנתונים. בלוקי-הזיכרון יובאו לזיכרון פעם אחת, וינוצלו במלואם.

אפשר לומר שאם M/m < 10 – אזי בוודאי עדיף לסרוק את המערך.
הדבר עשוי להיות נכון גם ל M/m < 100 ואולי אף יותר – יש לבדוק כל מקרה לגופו.

מכאן, כדאי לזכור:

  • כאשר יש לנו בעיות ביצועים, במיוחד בלולאה ששולפת ומכניסה ל HashTable – אל תניחו שזמן השליפה מתוך ה HashTable הוא זניח.
  • שימוש באובייקט עסקי (למשל: Customer) בתור מפתח ל HashTable עשוי להיות מיפוי עסקי מבריק.
    • כאשר חוק המספרים הקטנים פועל – אין בעיה.
    • כאשר אנו נדרשים לספק ביצועים גבוהים על כמויות גדולות של נתונים – אובייקט גדול כמפתח עשוי להיות רעה חולה.
  • שווה גם להזכיר את העניין הידוע בג'אווה שאם אתם דורסים את מתודת ()equals עליכם לדרוס גם את ()hashCode, וליהפך.
Benchmark פשוט שהרצתי כמה פעמים בכדי להראות שהכנסה ל HashTable היא לא כמו הכנסה ל ArrayList. להמחשה בלבד.

חזרה ל Data Locality

נושא מרכזי שעסקתי בו בפוסט הקודם היה Data Locality: איזו יתרון יש, בארכיטקטורת מחשבים בת-זמננו, לגישה לזיכרון רציף כך שהנתונים יוגשו מה Caches הקרובים ביותר (L1>L2>L3). אנו רוצים לצמצם ככל האפשר גישות לזיכרון הראשי או (חס וחלילה!) לדיסק.

כ 85% משטח ה CPU המודרני מוקצה ל Caches, וכמעט כל השטח קשור באופן ישיר לאכסון או העברה יעילה של נתונים. Data Locality איננו פרט שולי – אלא עקרון מרכזי בארכיטקטורה של מעבדים מודרנים.

הנה הרצאה של Herb Sutter (מחבר סדרת הספרים ++Exceptional C) בנושא.
עוד מקור מוצלח הוא המצגת Pitfalls of OO Programming – המיועדת במקור למפתחי מנועי משחקי-מחשב, היכן שהשיקולים הללו הם מלאכה יום-יומית.

החשיבה על Data Locality איננה נכונה רק למבני-נתונים, אלא לכל רצף ארוך של פעולות שאנו מבצעים. פעולות כאלו לרוב יכללו מבני-נתונים – אך לא תמיד.

עקרון ה Locality מגיע בשני אופנים:

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

למשל: כשעוברים על מערך דו-מימדי עדיף הרבה יותר לעבור שורה-שורה (כלומר: על איברים במערך הפנימי ברצף) מאשר לעבור על הטורים ו"לקפוץ" כל פעם בין המערכים הרציפים שהוקצו.

יעילות ה cache בשני מימושים דומים. סדר הגישה העדיף כמובן תלוי במימוש הספציפי של שפת התכנות / סביבת הריצה שאנו עובדים בה.


דוגמה עדכנית נוספת יכולה להיות Streams:

  • כל הפעולות ב Stream יפעלו ברצף איבר-איבר. הדבר מאפשר מקומיות זמנית ברמה הגבוהה ביותר של caching, ב registers של המעבד (ה cache המהיר ביותר) – מה ברוב הפעמים יתרום לביצועים.
  • כאשר יש ברצף הפעולות פעולות "רוחביות" (כגון sorting) אזי דווקא עדיף להשתמש ב collection ולא ב stream – בכדי ליהנות ממקומיות מרחבית.
בשפת קוטלין ברירת המחדש היא עבודה ב collections, ועל מנת לבחור ב stream יש להשתמש ב ()asSequence.
כמובן שכל היתרונות הללו בטלים בשישים – כאשר מדובר בחוק המספרים הקטנים. כלומר: אל תחשבו עליהם אפילו – אם מדובר במאות איברים או פחות.
כאשר אנו עובדים על כמות קטנה של נתונים – הם ככל הנראה יתאימו ל cache, גם אם יוגשו מעשרות בלוקים שונים של זיכרון. הדבר כן עשוי לידי ביטוי אם אנו מבצעים את הפעולה הזו שוב ושוב – עבור נתונים שונים.

מדוע עבודה על איברים בודדים במערך ממוין – מהירה יותר מעבודה על מערך לא ממוין?

כמובן שזה לא המקרה תמיד, אבל זה בהחלט עשוי לקרות.

הביטו שניה בקוד הבא ונסו לחשוב כיצד הדבר קורה:

העניין פה הוא אופטימיזציה ברמת המעבד הנקראת Branch Prediction.

בגדול, ה CPU עובד ב pipeline ארוך של פעולות. כלומר: הפעלת רצף פעולות יעלה רק מעט יותר מהפעלה של פעולה בודדת.
כאשר יש להמתין לתשובה בבחירת הפעולה הבאה – הרצף נשבר, והיתרון בהפעלה של pipeline ״באוטומט״ – אובד.

מתי זה שימושי?
למשל כאשר יש משפטי if בולאנים ולאחריהם פעולה פשוטה. בזמן שממתינים לתוצאה של תנאי ה if – המעבד יכול לבצע כבר פעולה נוספת באותו ה pipeline.

במקרה שלנו יש Branch prediction על הפעולה : (if (data[c] >= 128.
השורה העוקבת היא פעולה פשוטה שהמעבד יכול להפעיל בזמן שהוא ממתין לתוצאת ה if. האלטרנטיבה (תחילת איטרציה חדשה) – היא כבר פעולה כבדה יותר. מכאן סביר שהמעבד יבחר בשורה העוקבת ו״ידחוף״ אותה ל pipeline.

אם הוא צדק בניחוש – הוא ייקח את תוצאת החישוב שאליה הגיע (התוצאה של הפעלת ()data[c].toLong)  – וישתמש בה.
אם טעה – לא נורא. הוא "יזרוק" את מה שהכין – וימשיך ב branch השני (במקרה הזה – קידום הלולאה). בכל מקרה הוא לא היה מסוגל לפעול מהר יותר.

כאשר המערך ממוין, ובמיוחד במקרה כמו שלנו בו יש טווח מאוד מצומצם של 256*2 ערכים אפשריים – הניחושים של ה CPU עומדים להיות טובים מאוד (אפשר לומר: על סף האופטימליים).

לכן, כאשר המערך ממוין, הטרנספורמציה ל long מתרחשת בתוך אותו ה pipeline כמעט תמיד ובעלות זניחה, בעוד כאשר המערך לא ממוין, זה יקרה רק לפעמים (כ 50% מהמקרים).
כפי שניתן לראות – הפערים בזמני הביצוע הם משמעותיים למדי (ב ++C הפערים מגיעים לכמעט פי 10).

המסקנה היא לא לתכנן את הקוד שלכם בכדי שינצל נכון branch prediction. אם זה מה שהבנם – אז הבנתם לא נכון.
הבאתי את הדוגמה מכיוון שהיא מעניינת ועשויה לעורר את החשיבה.
לכו עם המעבד – ולא נגדו. זה ישתלם לכם. ברמה היום-יומית התרגום של זה הוא לנסות להקפיד על Data Locality – בעבודה על סטים גדולים של נתונים.

מילה על Regular Expression

Regex אינם מבני-נתונים. מה הם עושים כאן בפוסט?!

הכללתי את הנושא, כי הוא כולל אלמנטים משיקים.
פגשתי לא פעם אנשי-תוכנה שהיו משוכנעים שאם הם יגדירו ביטוי כ Regex ו״יעברו שלב קומפילציה" (בניית ה matcher) – אזי מובטח להם שה Regex יהיה יעיל יותר מקוד שיכתבו.

Regex הוא בגדול כלי productivity: לכתוב ביטוי Regex ייקח, ברוב הפעמים, פחות זמן מלכתוב קוד מקביל שיבצע פעולה דומה.
זמני הריצה של ה RegEx תלויים מאוד בביטוי, כאשר ביטויים מסוימים מחושבים ב (O(1, אחרים ב (O(n, אולי (O(n^2 ועד סיבוכיות שלא ניתן לתאר. הם בהחלט לא חייבים להיות (O(n.

למשל, לפני זמן לא רב נתקלתי ב Unit Test שזמן הריצה שלו עלה מ ms בודדים – לשלוש דקות בגלל הרצה של Regex מסובך למדי.
הנה סיפור על Regex שרץ לאורך 5 יממות – והוחלף ע"י כלי אחר שעשה את העבודה ב 15 דקות (פשוט ירידה בסיבוכיות – אין כאן קסמים).

בקיצור:

  • אל תניחו שזמן הריצה של Regex הוא לינאי או קרוב לכך. הכל תלוי בביטוי הספציפי.
  • כש Regex הופך לבעיה, בדקו כיצד ניתן לשפר את הביטוי בכדי לקבל סיבוכיות מסדר גודל קטן יותר.
  • תמיד יש את האופציה הלגיטימית לכתוב custom code – ברמת סיבוכיות ואופטימיזציה גבוהה יותר.

בקצרה: מבני-נתונים ואלגוריתמים מקובלים – שכדאי להיות מודעים אליהם

מיון

שתי שפות התכנות הנפוצות ביותר בעולם כיום הן, ככל הנראה: ג'אווה ופייטון [א].

מה אלגוריתם החיפוש של הספריה הסטנדרטית שלהן?

  • QuickSort (היה נכון פעם ל ++C) – לא.    עדכון: פרמיטיביים בג'אווה ממוינים בעזרת DualPivotQuicksort. יש לו עניין של instability – אך זה לא רלוונטי לפרמיטביים.
  • MergeSort (פעם היה בג'אווה) – לא.
  • BubbleSort? – אל תהיו מצחיקים!
אז מה? איזה אלגוריתם חיפוש הוא, אחד הרצים בעולם ואחד המוכרים פחות?
TimSort!
אל תתביישו אם לא שמעתם עליו – אבל כדאי להכיר.
בתיאוריה, קיימת הוכחה מתמטית לפיה כל אלגוריתם מיון שאין לו ידע על התפלגות הקלט (למשל: רק מספרים בטווח מסוים) לא יוכל להיות יעיל יותר מ (Θ(n*lgn.
לא קל להגיע לזמן ביצוע של (Θ(n*lgn – ובד"כ זה בא במחירים אחרים. למשל: זיכרון (כמו MergeSort).
TimSort מצליח "לנצח" את התאוריה במקרים מסוימים (Best Case), ובממוצע להציג שיפור לא רע, בזכות הנחה מעניינת אך בסיסית על הפלגות הנתונים: שהיא לא אקראית לחלוטין. ככל שהיא תהיה פחות אקראית – כך הביצועים שלו יהיו טובים יותר.
TimSort (שקרוי על שם מי שפיתח אותו, טים פיטר, ממפתחי פייטון – זה לא אלגוריתם שהגיע מהאקדמיה) בבסיסו מריץ MergeSort (אלגוריתם שלרוב מבצע טוב מהרגיל) בעוד הוא משתמש ב batches קטנים ב Insertion Sort (גרסה משופרת של ה BubbleSort) – היעיל במיוחד למיון קבוצות קטנות של נתונים.
בנתונים מהעולם האמיתי המידע לא מפוזר באופן אקראי לחלוטין. בעצם: קשה מאוד לייצר נתונים אקראיים לחלוטין. בנתונים ״רגילים״ של מערכים יש בו רצפים, גדולים או קטנים, של איברים ממוינים.
TimSort פועל בגדול באופן הבא:
  1. סריקה של הנתונים ואיתור רצפים עולים ורצפים יורדים. אם הרצף יורד – הוא פשוט יהפוך אותו.
    1. הנתונים כבר ממוינים? סיימנו ב (O(n. לא נשמע חוכמה, אבל QuickSort ו MergeSort יבזבזו כאן (O(n*lgn, זה יכול להתתרגם לפי 10 או פי 100 – יותר זמן ריצה.
  2. קבוצות של עד 64 איברים – ממיינים בעזרת Insertion Sort, היעיל לקבוצות קטנות של נתונים וגם נהנה מ Data Locality.
  3. שימוש ב Merge Sort על מנת למיין את הקבוצות הממוינות – כאשר נשמר איזון בין הקבוצות בעקבות המיון המוקדם.
שווה להכיר בקיומו: KD-TreeKD-Tree הוא מבנה נתונים דיי שימושי (אני השתמשתי כמה פעמים) המאפשר לאנדקס נתונים בכמה מימדים.
בעיקרון הוא מקרה כללי של Binary Search Tree (הרץ על מימד אחד), אבל מאפשר לרוץ על כמה מימדים.
אם אנו רוצים לאנדקס 2 מימדים – אז כל שכבה זוגית תבצע חיתוך על ציר x וכל שכבה אי-זוגית על ציר y.
את אותו רעיון אפשר להרחיב ל 3, 4 מימדים ויותר.

במקרה הזה הצומת הראשי מפצל את המרחב על ציר x, ואז הקודוד מתחתיו את ציר y, וחוזר חלילה.

KD-Trees משמשים בבסיסי נתונים, ובכלל, לאינדוקס מרחבים geospatial ("גאוגרפיים"). עצי KD-Tree מסדר 2 מתארים מרחב גאוגרפי (x ו y), בעוד עד מדרגה 3 למשל, עשוי לתאר מרחב + זמן (למשל: היכן הייתה כל מונית בכל רגע נתון).

הרצון לאנדקס מרחב רב-מימדי עשוי להיות רלוונטי למקרים רבים. אינדקס של כמה עמודות בבסיס-הנתונים – מתנהג בצורה דומה. לאינדוקס כשזה יש גם שימושים מדעיים שונים (פיסיקה, ניתוח צבעים כאשר R, G, ו B הם שלושת המימדים, וכו׳).

מבנה נתונים מקביל ל KD-Tree הוא ה R-Tree. בניגוד ל KD-Tree שבו כל node חוצה מרחב, ב R-Tree כל node מתאם מרחב תחום (Rectangle, ומכאן השם) ומכאן למרחבים שלו יכולים להיות חפיפות.

שווה להכיר בקיומו: Skip List

רשימת דילוג (Skip List) היא וריאציה של LinkedList הדומה יותר לעץ מאוזן (כמו עץ אדום-שחור או AVL) – אך המימוש שלה פשוט יותר.

מימוש פשוט לא מעניין אותנו כשיש שיתוף קוד (אחד כותב – רבים משתמשים). כמן כן, למדנו כבר להיזהר ממבני-נתונים עוייני cache כמו רשימות משורשרות ועצים. אז מה הטעם בו?

הייחודיות של ה Skip List היא ביכולת שלו לשרת כמבנה נתונים מוצלח למדי לעבודה מקבילית – תחום שהולך והופך חשוב ושימושי עם ריבוי ה cores בתעשייה.

בבסיס, רשימת דילוג היא כמו רשימה משורשרת. התבוננו רק על הרמה הראשונה (L1) – זו ממש רשימה משורשרת.
מה הבעיה ברשימה משורשרת (מלבד עוינות ל caches)? – שמציאת איבר ברשימה אורכת (O(n וזה יכול להיות יותר מדי.

הפתרון הוא להוסיף רמות דלילות לרשימה – שיאפשרו התקדמות מהירה בעת "סריקת" הרשימה.

הנה תסריט "צמיחת הרמה השנייה": כאשר אנו מוסיפים nodes לרשימה, אנו מבצעים הגרלה של 1:2 כמו הטלת מטבע. אם יצא "עץ" (במקור: "heads") נוסיף node גם ברמה השניה. כך תיווצר לנו רמה שניה דלילה יותר.
באופן דומה אם יש לנו 3 רמות, node שנוסף והוגרל להיות חבר ברמה 2, יוגרל שוב ביחס 1:2 להיות חבר גם ברמה 3.

מספר הרמות ברשימת הדילוג, ייקבע ביחס למספר האיברים שבה. גם ההגרלה (״רמת הדלילות״) לא חייבת להיות 1:2. היא יכולה, למשל, להיות 1:4 – רשימה דלילה יותר.

כאשר אנו מחפשים איבר, למשל בתרשים למעלה את מספר 8, אנו מתחילים מהרמה הגבוהה ביותר, ומבצעים חיפוש דומה מאוד לעץ בינארי. אם ה node הבא גדול מהערך שאני מחפש – נרד רמה ונבקש שם את ה node הבא – עד שמצאנו אותו (או בדוגמה לעיל – 8 לא נמצא ברשימה ולכן לא נמצא).

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

מקביליות

מכיוון שההחלטה כמה רמות להוסיף ל node חדש היא מבוססת על אקראיות (ולא תלויה בשאר המבנה של הרשימה) ל SkipList יש יתרון בהכנסה מקבילית של איברים, שבאמת יכולות להיות פעולה מקבילית ברמה גבוהה (כלומר: לאפשר הרבה מקביליות). במימוש בג'אווה (ConcurrentSkipListMap) משתמשים ב AtomicReference על מנת להגן על הקשר לשאר הרשימה – היכן שיכול להיות race condition. מעבר לכך אין צורך בשימוש ב synchronization או מנעולים (שמגבילים מאוד את כמות המקביליות).

חשוב לציין שהמבנה הזה אינו אידאלי לכל תסריט מקבילי. בג'אווה ה ConcurrentHashMap – מימוש HashTable עם מנעולים על טווחים על המערך שמאחורי-הקלעים, אולי לא יכול לעמוד באותה כמות מקבילית של הכנסות, אך שליפה של איבר היא פעולה מהירה בהרבה (O(k (מול (O(lgn ברשימת הדילוג).
אם למשל, המקביליות היא רק בקריאה – אזי HashMap רגיל יהיה היעיל ביותר.
בקיצור: מקביליות היא עניין מורכב, ולא נכסה אותו כאן…

הערת סיום: לזכותה של המחלקה למדעי המחשב באוניברסיטת בן-גוריון ארצה לציין שכן למדנו בקורס מבני-נתונים על KD-Trees ו SkipLists – וזה היה במקום. תודה רבה, לפרופ' קלרה קדם שלימדה אותנו (מפתיע, אבל אני עדיין זוכר את שמה אחרי הרבה שנים).

סיכום

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

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

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

—–

[א] נכון, גם ג׳אווהסקריפט נפוצה מאוד – אבל קשה לי להתייחס לאלגוריתם המיון המובנה שבה ברצינות.

ראשית הוא ממיין ע״פ סדר לקסיקוגרפי, גם מערך של מספרים:

[7, 44, 3].sort() = [3, 44, 7]
עד ממש לאחרונה, מנוע V8 הסופר-פופולארי לא היה יציב. כלומר: הוא עשוי היה, באופן אקראי, להחליף בין ערכים שהם זהים. זה לא מפריע במספרים – אך עשוי להפריע באובייקטים מורכבים.
דוגמה אחרונה, וחמורה למדי, היא זו:

בעוד מומחים בתחום טוענים בתוקף שהביצה היא זו שקדמה לתרנגולת. למשל: ביצי דינוזאור.

דפוס עיצוב: מתכון [מהקשור?]

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

מתכון

כיצד כדאי לתאר פעולה שחוזרת על עצמה מספר פעמים, ומכילה הוראות עבודה – בצורה הטובה ביותר לצריכה (consumption)?

הנה דוגמה:

"אלוהים ישמור! מתכון ב Excel?!"

ובכן.. אני מציע לכם לנסות כאן מעט גמישות מחשבתית. כמה הסברים על הפורמט:

  1. הרכיבים רשומים כשורות, בעוד הפעולות (על מקבץ רכיבים) מופיעים כעמודות נוספות (B עד E – בדוגמה הזו).
    1. פעולות מקדימות ארוכות מצוינות על שורה מלאה לפני שאר הרכיבים. (לדוגמה: שורה 3).
    2. הפורמט מאפשר לתאר בצורה אינטואטיבית פעולות שניתן לבצע במקביל (למשל: מנה עיקרית ורוטב).
  2. כמה כללים נוספים:
    1. הקפידו ששם הרכיב יהיה בתחילת המשפט (עמודה A) – ולא באמצע או בסוף. כך קל יותר לאתר אותו בסריקה מהירה.
      1. את הכמויות (מספרים) ניתן לציין גם בסוף. אם הם מופיעות בהתחלה – עדיף להשתמש בספרות ולא במילים (יקל על המוח לדלג עליהם בזמן הבישול)
    2. השתדלו לציין כמויות בצורה מדידה ומדויקת. אם חוזרים למתכון לאחר זמן ארוך, ביטויים כגון "בנדיבות" עשויים להתפרש לגמרי אחרת.
    3. הציבו את ההכנות הארוכות בפעולה לפני ההכנות הקשות בפעולה. למשל: "לקצוץ שום" היא בהחלט פעולה ארוכה יותר להכנה מאשר "הוספת מים".
    4. תקנו את המתכון ושפרו אותו. כמובן.
מה היתרונות, של דפוס העיצוב (/שימוש) הנ"ל?
  • מתכון משמש אותנו ב-2 תסריטים: 1. קניית הרכיבים   2. הבישול עצמו.
    • בשני התסריטים הללו אין לנו הרבה זמן וטקסט ארוך מגביר את הסיכוי ל"פספוסים".  הפורמט הנ"ל משפר את הקריאות ויכולת המעקב, גם כשיש לנו מעט קשב. כלומר: הוא read optimized.
  • מתכון (מוצלח) נכתב פעם אחת, אך נקרא עשרות פעמים. כאשר מתכון מוכיח את עצמו כמוצלח – שווה להעביר אותו לפורמט הנ"ל (השקעה שתחזיר את עצמה לאורך זמן).
    • ברור שתיאור מתכון בפורמט הנ"ל ידרוש השקעה של כמה דקות.
  • כמובן שיש כאן אלמנט ברור של DRY. כאשר כותבים את שמות הרכיבים גם ברשימת הקניות וגם מתוך התפריט אז:
    • זה גם גוזל יותר מקום (בקטנה)
    • כאשר מבצעים שינויים / מציעים רכיבים חלופיים – ציון שלהם רק במקום אחד עשוי להיות מבלבל. מניסיון.

סיכום

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

Slack It!

בפוסט הקצרצר הזה ארצה לתת כמה טיפים לשימוש ב Slack. כלי שרובנו (כולנו?) משתמשים בו על בסיס יומי, אבל אולי לא מנצלים אותו ב 100%.

למי יש בכלל זמן ללכת ולהתעמק בכלי עבודה… שמשתמשים בו רק כמה עשרות פעמים ביום?!

סלאק אמור להיות כיפי

אתם בוודאי יודעים שחלק מההצלחה של Slack היא אינטגרציה של אפליקציות רבות. האינטגרציות לא חייבות להסתכם בהודעה נרגזת מה Build Server על כישלון הבילד.
אצלנו, למשל, משתמשים הרבה ב Giphy. זו ממש תרבות. כשקורה משהו מעניין, מתחילים להופיע animated gifs על הערוץ. פשוט מקלידים: <giphy <keywords/ ואז אפשר לדפדף בין כמה אופציות, ובלחיצה להוסיף לערוץ:
זה נחמד ומוסיף (בד״כ. נא לא להגזים!).

יש גם את songg שמככב בערוץ team_music#, למרות שמוזיקה קצת פחות מיינסטרים – הוא מתקשה לעתים למצוא (מה כ״כ קשה ב Misa Criolla? או ב Crusify your mind של רודריגז?)

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

הערוץ הפרטי

כשאתם לא בטוחים איך plugin או עריכה של טקסט תעבוד בסלאק, פשוט תנסו!
לשם כך, יש לכל אחד ערוץ פרטי:
הערוץ משמש גם לכתיבת תזכורות, או אפילו להעביר בזריזות פיסות קוד כשאני עובר בין מחשבים (מייל הוא אטי יותר).
החיפוש עובד!
כשעובדים עם סלאק בצורה אינטנסיבית – נצבר בו המון מידע מאוד רלוונטי לעבודה שלכם. בהנחה שלא חסכתם והלכתם על המנוי החינמי – תוכלו לחפש ביעילות ובזריזות גם חודשים אחורה.
פשוט לכו לתיבת החיפוש והקלידו טקסט. אתם תראו שהתוצאות הן רלוונטיות וחוזרות במהירות.
ניתן להשתמש במילות מפתח כגון:
 in: from: has: before: after: on: during: 
על מנת לצמצם בצורה יעילה יותר את התוצאות.
כמקובל בחיפוש ניתן להשתמש ב:
  • מרכאות ״״ – על מנת לחפש אחר ביטוי מדויק
  • שלילת ביטוי ע״י הצבתו לאחר סימן מינוס –
  • שימוש ב * כ wildcard
בצד ימין של תוצאות החיפוש (לאחר שלחצתם Enter) – יש אופציות פילטור נוספות, שניתן להפעיל בקלות.
  • אם אתם מתכננים חיפוש פשוט, אתם יכולים להתחיל את החיפוש מתוך שורת ההודעה ע״י שימוש בפקודה s/ ולאחריה מילות החיפוש.
  • ע״י CMD+F ניתן לבצע חיפוש ב Channel הנוכחי
  • ע״י CMD+G ניתן לבצע חיפוש גלובאלי. תוצאות החיפוש האחרון יישמרו ל 5 דקות, ותוכלו לחזור אליהן בעזרת הקשה נוספת על CMD+G.

סלאק הוא Command Line tool, בעצם

כשאתם על ספידים, שימוש בעכבר רק מאט אתכם ועלול אף לקטוע את חוט המחשבה.
ישנם כמה קיצורים שיחסכו לכם עבודת עכבר מעצבנת. בעיקר (לטעמי האישי):
  • Cmd+K – מעבר ערוץ (dm או channel)
  • באותו הקשר: ]+Cmd – חזרה לערוץ הקודם שנבחר
  • fn + חצים למעלה ולמטה – מעבר מהיר בין ההודעות בערוץ.
  • : ולהתחיל להקליד – על מנת להגיע מהר ל emojis. הקיצור :1+: – הוא האהוב עלי. טאב יכול לקצר.
  • @ invite/ (בקיצור: i/ ואז טאב) – הוספת אדם לערוץ, וגם בלי ״ללכלך״ את הערוץ.
  • option+8 – הוספת bullet לטקסט
  • מתוך שורת ההודעה, חץ למעלה יערוך את ההודעה האחרונה. תיקון שגיאות כתיב וכאלו.
  • Option + Shift + חץ למטה – מעבר להודעה האחרונה שלא נקראה. רפרוף על הודעות אחרונות בזריזות.
  • msg @person text/ על מנת לשלוח הודעה מהירה למישהו, מבלי להחליף ערוץ / לאבד את ה context:

Formatting להודעה

כאן אני כנראה לא אחדש, אבל החלטתי בכל זאת:

  • הדגשה בעזרת כוכביות: *your text*
  • קו תחתון בעזרת.. קו תחתון: _your text_
  • ציטוט ע״י סימן ״גדול-מ״: your text <
    • ציטוט טקסט של מספר שורות ע״י שלושה סימני ״גדול-מ״: your text <<<
    • מעבר שורה הוא, כמובן, ע״י shift+Enter
  • הצגת טקסט ב monospace font ע״י עטיפה בגרש הפוך: `your text`
    • באופן דומה ניתן לעשות זאת לכמה שורות טקסט עם העטיפה "` your text "`
  • לרוע המזל אין syntax highlighting, בתוך הודעות. יש להוסיף code snippet ע״י כפתור ה +:
    • code snippets חשובים כי בלעדיהם סלאק יחליף גרשיים ב״גרשיים מעוצבים״ שאינם בטווח ה ASCII. התנהגות מעצבנת – מכלי שנועד למפתחים.

Starring

כלי שימושי בסלאק הוא Starring (מעין ״bookmarks״) – סימון הערוצים וההודעות שחשוב לכם לחזור אליהן,

מתחת לשם הערוץ, בראש הדף יש כוכב כבוי. הדליקו אותו וקבלו גישה נוחה (בעזרת עכבר):

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

תוכלו לחזור אליה בקלות אח״כ ע״י הקלקה על סימן הכוכב בפינה הימנית העליונה של המסך, או ע״י הקיצור CMD+Shift+S.

רק אל תשכחו to unstar הודעות חשובות שקראתם כבר. אחרת יצטבר לכם צביר הודעות שלא תוכלו באמת לטפל בו.

הודעות מתפרצות

רוצים לעקוב מהר יותר אחרי מה שקורה בערוצים השונים?

אם מזכירים את שמכם בערוץ מסוים (או פונים ל here/@channel@) – תקבלו על כך הדגשה (badge).
אם אתם רוצים להקשיב לעוד מילות מפתח, אתם יכולים להגדיר אותן ב Preferences/Notification:

סיכום

בטח יש עוד כל מיני קיצורים וטריקים בסלאק – אבל מבחינתי זה הבסיס השימושי.

הרגישו חופשיים להוסיף טריקים ושימושים בתגובות לפוסט.

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

מה *לא* לימדו אותנו באוניברסיטה על מבני-נתונים?

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

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

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

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

הכלל הראשון שארצה להדגיש הוא זה:

כלל חשוב לחיים המקצועיים!

כדי להסביר את הכלל, אפתח בדוגמה משעשעת של אלגוריתם חיפוש בשם Sleep Sort:

ע״פ האלגוריתם הזה, יש רשימת מקור (הקלט) ורשימת יעד (התוצאה). בעת ההרצה אנו סורקים את רשימת המקור ולכל איבר מפעילים פונקציה (למשל: co-routine) שתכניס את האיבר לרשימת היעד בעוד n שניות, כאשר n הוא גודל האיבר.

אם רשימת המקור היא 2, 4, ו 3 אזי האיבר 2 יכנס לרשימת היעד לאחר שתי שניות, האיבר ארבע לאחר 4 שניות, והאיבר 3 – לאחר 3 שניות. והנה ביצענו מיון!

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

למשל, עבור קלט של המספרים 2 ו 1,000,000,000 האלגוריתם ירוץ במשך כמעט 32 שנים – מה שבהחלט פחות יעיל אפילו מ Bubble Sort. 

מה היה לנו כאן? מחיר אמיתי מאוד, ומשמעותי מאוד, שלא לקחנו בחשבון בהערכת הזמן של האלגוריתם. יכולנו בהתאם לשכלל את התאוריה שלנו ולנסח זמן ריצה בנוסח:
(O(max(item_size) * sec) +n – כאשר בעיקרון אפשר להזניח את n.

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

  • HashTable
  • LinkedList
  • Binary Search Tree
  • Graphs

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

חוק המספרים הקטנים

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

אם אנחנו מסתכלים על זמני הריצה התאורטיים שאנו נוהגים להסתכל עליהם, תחת ההקשר ש CPU בימנו מבצע מיליארדי cycles בשנייה, אזי עבור מאות או אולי אלפי איברים – סיבוכיות האלגוריתם עד לרמת nlogn – לא ממש משנה:

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

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

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

חשוב לציין שלא כל "פקודת מחשב" מתבצעת ע"י cycle בודד של מעבד. ליהפך: תנאי if פשוט לכאורה, עלול בקלות להתתרגם לעשרות ומאות CPU cycles. כאמצעי ביטחון אפשר לחשוב על ה CPU כמבצע עשרות-מיליוני פעולות בשנייה בלבד.

"מספרים שכל מתכנת חייב להכיר" (2012). איפה הייתם בקורס מבני-נתונים?!

המחיר הנוסף

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

  • גישה לדיסק היא מאוד מאוד יקרה (לפני עידן ה SSD היא הייתה מאוד מאוד מאוד יקרה).
  • גישה לזיכרון היא יקרה, ובהחלט לא אקראית – על אף השם "Random Access Memory = RAM".
  • נתונים, גם מהרשת, גם מהדיסק, וגם משכבות הזיכרון השונות מוגשות בבלוקים רציפים. העלות להביא את הבלוק היא החלק היקר, בעוד ההבדל בין סריקה של בית בודד או את כל הבלוק – הוא לרוב משני עד זניח.
    • אפשר לראות את ההתנהגות הזו בנתונים למעלה, כאשר קריאה של בית בודד מ SSD תיקח 0.15ms בעוד קריאה של מיליון בתים מ SSD (עשרות עד מאות בלוקים) – תיקח רק מעט יותר: כ 1.0ms.
    • שווה מאוד לקרוא ולטפל בנתונים ברציפות, ולא בסדר אקראי
  • למעבדים יש היררכיה של זיכרונות Cache. נתון שלא נמצא ב Cache יובא קודם ל L3 ואז ל L2 ואז ל L1, ולכל הבאה שכזו, יקדם חיפוש ברחבי ה Cache Level שהנתון אכן לא שם.
    • זה בזבוז להביא מילה בודדת בזיכרון, ולכן כל פעם מביאים בלוק זיכרון רציף. גודל הבלוק – תלוי בארכיטקטורת המעבד.
נתחיל בתכלס, ונראה איך זה משפיע עלינו.

קרב ראשון: Vector/ArrayList מול LinkedList

אנחנו מעונינים להוסיף דינאמית איברית לרשימה. איזה מבנה-נתונים הכי מתאים? Vector (אשתמש בשם הקדום ב ++C) או רשימה משורשרת?

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

רשימה משורשרת פשוט מוסיפה עוד ועוד ערכים במחיר (O(1 לכל פעולה.

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

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

בואו נראה מה קורה בפועל:

הממ… לא בדיוק.

הכנסה באמצע הרשימה יקרה משמעותית ברשימה משורשרת, למרות שבאופן תאורטי לוקטור יש דווקא עלות נוספת (העתקת המערך) בכל גדילה. מה קרה פה?!

  • כל אלמנט ברשימה המשורשרת תופס יותר זיכרון: צריך להכניס לזיכרון גם את הערך וגם את המצביע לאלמנט הבא. כנ"ל אם הערך הוא מצביע לאובייקט ולא Int. זה אותו הדבר.
  • בתאוריה: העתקה של מרחבי זיכרון רציפים היא עלות (O(n או משהו יקר אחר.
    בפועל: במעבדים מודרניים יש פקודה יעילה למדי להעתקה של בלוקים של זיכרון. זו איננה פעולה יקרה כ"כ.
  • חיפוש המקום להכנסה ברשימה משורשרת הוא הנקודה החלשה הבולטת של הרשימה המשורשרת: ה nodes של הרשימה מפוזרים אקראית במרחב הזיכרון. כשאנו סורקים את הרשימה (בממוצע n/4 איברים בכל פעם) אנחנו "חוטפים" עוד ועוד cache misses הדורשים לעדכן את זיכרון המטמון.
    • כאשר אנחנו סורקים את הוקטור, ככמט כל בלוק של זיכרון שנביא ל cache – ינוצל במלואו.
    • במקרה של שימוש ב virtual memory (לא נכלל בגרף) – המקרה גרוע הרבה יותר: ייתכן ובגלל קפיצות בין דפים שונים של ה main memory "נחטוף" Page Fault ויהיה עלינו להביא את דף הזיכרון מהדיסק, רחמנא ליצלן!

שיפור עמודות לטובת הרשימה-המשורשרת

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

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

עד כמה עומדת הרשימה המשורשרת למחוץ את הוקטור? בתיאוריה זה אמור להיות אכזרי כלפי הוקטור. בואו נראה בפועל:

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

אבוי! הרשימה המשורשרת לא מצליחה אפילו במבחן שתוכנן לטובתה. ההכנסות למקומות אקראיים (ופנויים) בזיכרון גוזלים מחיר יקר של עדכון / איפוס caches.

אין כמעט תסריטים של scale בהם הרשימה המשורשרת יכולה לנצח את הוקטור בעולם האמיתי. רק כאשר גודל האלמנט בכל node הוא גדול מאוד (מאות בתים, למשל) ואז גם ה cache מתרפרש מהר בכל מקרה וגם התקורה של ה next-pointer של הרשימה המשורשרת הופכת לזניחה.

בעולם הג'אווה – המשחק משתנה

הדוגמאות מלמעלה הן ב ++C, וכנראה מייצגות גם את מה שמתרחש ב Rust או Go. שפות שעובדות עם הזיכרון מול מערכת ההפעלה ומושפעות ישירות מארכיטקטורת המעבד.
בג'אווה (או #C) ואולי גם בפייטון / רובי – הדברים מעט שונים. יש מפרשן או JVM/CLR שנמצאים בין מערכת ההפעלה לקוד. יש שימוש מאסיבי ב Heap – שאינו רציף.
איך הדברים נראים בג'אווה? אולי שם ההבדל בין וקטור לרשימה משורשרת נמחק?
בואו נבדוק שוב את המקרה של הכנסת איבר למקום הממוין ברשימה.

אין לי גרף מתאים (הנה המקור לנתונים), אבל מאיסוף ידני של הנתונים ממבחן ב ++C, ב 20,000 איברים היו התוצאות 617 מילישניות לרשימה משורשרת, ו 234 מילישניות לוקטור – שזה יחס דומה.

ה DynamicIntArray, אם תהיתם, הוא מימוש של ArrayList המאחסן איברים מסוג int (פרמיטיב) ולא ב Integer (אובייקט) – ולכן הוא ידידותי באמת ל cache. הנתונים באמת שמורים במערך רציף (כמו ב ++C) והם לא reference לאובייקט שתלוי ב Heap (שאותו ג'אווה אכן משתדלת למלא בצורה רציפה).

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

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

את זה לא סיפרו לי בקורס מבני-נתונים!

סיכום ביניים

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

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

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

מה עושים?

במקום עצי חיפוש בינריים, משתמשים ב B-Tree. עץ חיפוש שבו כל node מותאם לגודל בלוק בדיסק (ומכאן: כמה בלוקים של זיכרון, בהתאמה). כל בלוק מכיל רשימה של מצביעים רלוונטיים. האופטימיזציה היא לצמצום הגישה לבלוקים, כך שכל פעם טוענים בלוק מהדיסק / זיכרון – מנצלים אותו ביעילות ע"י שימוש בסריקה רציפה.

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

מפתחים של בסיסי נתונים משתמשים ב B-Tree (או B+Tree – וריאציה מעט שונה) כדרך קבע כעצי חיפוש למשל: בשימוש באינקסים.

פעם נתקלתי במימוש שבאמת הייתה בו חשיבות לשימוש ברשימה משורשרת. מה עשינו? הקצנו את הזיכרון עבור כל הרשימה בצורה רציפה (כמו וקטור) והשתמשנו רק בו. דבר דומה עשה חבר שדיברתי איתו – מה שגרם לי להבין שזה common sense ולא המצאה גדולה. חיפוש פשוט בגוגל העלה מימוש שכזה כקוד פתוח.
אלטרנטיבה אחרת, וקצת יותר ידועה: Unrolled Linked List.

מה עושים בגרפים? אני לא מכיר באופן אישי, אבל אני מניח שגם Neo4J או AWS Neptune מצאו את הדרך שלהם לבנות את מבנה הנתונים החשוב והנהדר הזה Graph – בצורה שאיננה עוינת לזיכרון. או לפחות: עוינת פחות ככל האפשר.

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

שיהיה בצלחה!

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

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