2018-12-24

MySQL Transactions - חלק א'

פוסטים בסדרה:
"תסביר לי" - גרסת ה SQL
לקחת את הביצועים ברצינות, בעזרת MySQL Performance Schema
נושאים מתקדמים ב MySQL: חלק א' - עבודה עם JSON
נושאים מתקדמים ב MySQL: חלק ב' - json_each  ו Generated Columns



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

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

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

איך משתמשים בטרנזקציה? בבסיס, באופן הבא:


START TRANSACTION; 
-- do something  

COMMIT; 
-- or: ROLLBACK; 



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






למה להשתמש בטרנזקציות?



כיום, טרנזקציות הוא דבר "לא-קולי" ("not cool").

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

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

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

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

ובכן: 
  • טרנזקציות הן אויב ל Scalability (ברוב המקרים), וכאשר צריך הרבה Scalability - עלינו להימנע מהן.
    • גם במערכות המתמודדות עם Scalability, ישנם flows ותסריטים שעובדים ב volume נמוך יותר - ויש להם בעיות שטרנזקציות יכולות לפתור.
  • חטאנו (גם) בשנות האלפיים, ועשינו שימוש-יתר, ביכולות שונות של בסיסי-הנתונים הרלציוניים: כמו טרנזקציות, Foreign Keys, או Triggers. עדיין, בשימוש מידתי - אלו כלים שימושים שיכולים לפתור בעיות רבות.
    • הסיפור של Overselling של כלים וטכניקות, הוא לא מקרה יחידני. הוא קורה שוב ושוב, ויקרה שוב ושוב. תתרגלו.
  • טרנזקציות הן פעמים רבות, הכלי הנכון והטוב ביותר לפתור בעיות.
    • אם אתם מסוגלים להתגבר על הקושי שלא המציאו את הטרנזקציות בשנת 2018 בגוגל, והן לא מככבות במצעד ה"טכנולוגיות היפות והנכונות של 2019 [א]" - אז יש לכם סיכוי טוב להעשיר את סט הכלים שלכם בצורה מועילה.



נחזור לשאלה המקורית, שמסתבר שהיא לא טריוויאלית: "מדוע / מתי להשתמש בטרנזקציות"?


הנה שימושים מרכזיים לדוגמה:
  • אנו רוצים לעדכן שתיים (או יותר) טבלאות בבסיס הנתונים בצורה עקבית: שלא יוותר מצב שאחת מעודכנת, אבל של תקלה - השנייה לא עודכנה, ויש לנו אי-עקביות בנתונים / נתונים חסרים.
  • אנו רוצים לעדכן ערך בטבלה, בצורה עקבית ותחת racing condition אפשרי: שני threads (או מנגנון מקבילי אחר) רוצים לבצע שינוי שתלוי במצב הקיים - אבל ללא הגנה התוצאה יכולה להיות שגויה.
  • אנו רוצים להשתמש בבסיס-הנתונים כמגנון סנכרון פשוט בין כמה שרתים
    • אין לנו מנגנון אחר, ובסיס-הנתונים הוא מספיק טוב למשימה.
  • שיפור ביצועים - זה נשמע לא אינטואטיבי, אבל במקרים מסוימים טרנזקציות דווקא יכולות לעזור לשפר ביצועים.
  • ביצוע שינוי בנתונים בצורה זהירה ומפוקחת - (בסביבת פרודקשיין, למשל). נבצע את השינוי, נבחן את השלכותיו, ורק אז נעשה commit.

לכאורה אפשר לחשוב (בתמימות) שלכל המקרים יש פתרון זהה. שבשימוש בפקודות START TRANSACTION ... COMMIT - נפתרו בעיותינו. מובטח לנו שמה שציפינו, אכן יקרה, וללא תופעות לוואי מיותרות - כמובן.

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

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

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

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

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

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



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


זו הזדמנות טובה להזכיר את Amdahl's law המראה את הקשר בין החלק בפעולה שאינו מקבילי - לחסמים על מקביליות, לא משנה בכמה threads נשמש....
כדי להשלים את התמונה, שווה להכיר גם את ה Universal Scalability Law (בקיצור USL) שמפרמל מהי מקביליות, וממנו ניתן לראות שניסיון לדחוף יותר עבודה מקיבולת מסויימת - דווקא תאט את המערכת עוד יותר.


מודל המקביליות של InnoDB


לב מודל המקביליות של InnoDB, בדומה לבסיסי-נתונים רבים אחרים, מבוסס על שני כלים עיקריים:
  • נעילה פסימיסטית - לקיחת מנעול על משאב (טבלה, רשומה, וכו') בצורה שתגביל פעולות מקביליות אחרות - אך תמנע בעיות של עקביות-נתונים.
    • מחלקים את הנעילות ל-2 סוגים:
      • נעילה משותפת (Shared lock) עבור פעולות קריאה. אני קורא ערך ואוסר על שינוי הערך - אבל לא יפריע לי שטרנזקציות נוספות יקראו את הערך גם.
      • נעילה בלעדית (Exclusion lock) - לצורך שינוי הערך. אני תופס את המנעול, ולא אתן לשום טרנזקציה אחרת אפילו לקרוא את הערך (כי הוא עומד להשתנות)
  • נעילה אופטימיסטית - מאפשרת לתת לפעולות לרוץ במקביל, לגלות "התנגשויות" ואז להתמודד איתן.
    • לפעמים נרצה להכשיל את אחת הטרנזקציות (או יותר - אם יש כמה). לפעמים נסכים לקבל חוסר אחידות בקוד.
    • למרות שיש עבודה משמעותית נוספת בגילוי וטיפול ב"התנגשויות", אנו מאפשרים מקביליות בין הפעולות - שזה יתרון שלא יסולא בפז (הציצו שוב בתרשים שלמעלה - כמה צמצום החלקים ה"בלעדיים" הוא חשוב).

מבחינת אלגוריתמים MySQL, בדומה לרוב בסיסי-הנתונים הרלציוניים משתמשים בשני אלגוריתמים עיקריים:
  • 2PL (קיצור של Two-Phase Locking) עבור נעילה פסימיסטית. הרעיון הוא שנחלק את הפעולה לשני שלבים:
    • שלב ראשון - בו ניתן רק לתפוס מנעולים.
    • שלב שני - בו ניתן רק לשחרר מנעולים.
    • לרוב נרצה לתפוס מנעולים ע"פ סדר מסוים ("קודם אובייקט מסוג X ורק אז אובייקט מסוג Y") - על מנת להימנע מ deadlocks.
      במקרים אחרים, אנו יכולים להחליט לתפוס בסדר לא-קבוע על מנת לצמצם זמני-נעילה ולהגביר מקביליות, במחיר שטרנזקציות יבוטלו לנו. המשמעות: נצטרך לפעמים לנסות להריץ אותן כמה ניסיונות - עד שנצליח לתפוס את המנעולים הרצויים.
  • MVCC (קיצור של Multi-version concurrency control) הרעיון שבו אני "מעתיק" הנתונים שטרנקזציה צריכה הצידה, ואז היא חיה ב״סביבה וירטואלית משלה״, ללא התעסקות ב racing conditions או צורך בנעילות.
    • במימוש של InnoDB, לא באמת מעתיקים נתונים, אלא משתמשים בעמודות טכנית של המערכת לכל טבלה, המנהלת איזה עותק של הנתונים שייך לכל טרנזציה, ואלו ערכים נמחקו.
    • כמובן שיש מחיר שנוסף בניהול "העותק הוירטואלי". למשל: כאשר טרנזקציה מבקשת ערך שבטיפול של טרנזקציה אחרת - על בסיס הנתונים לבצע הדמיה של rollback של הטרנזקציה האחרת על מנת לדעת אלו ערכים צריכים להיות לטרנזקציה הנוכחית.
      • למרות המחיר הנוסף בפעולות הללו - הוא אינו דורש בלעדיות ולכן scales well.


בעיות חוסר-עקביות


כל טרנזקציה ב MySQL היא, כברירת מחדל, ברמת הפרדה (Isolation Level) שנקראת Repeatable Read.
ישנן 4 רמות הפרדה שהוגדרו ע"י התקן ANSI-SQL 92 ומקובלות בכל בסיסי-הנתונים הרלציוניים המוכרים.


למרות שרמות ההפרדה והתופעות האפשריות[ב] הוגדרו בתקן ה ANSI-SQL - ההתנהגויות בין בסיסי-הנתונים עדיין מעט שונות.
למשל: SQL Server לא מגן בפני Phantom Reads ברמת הפרדה של Repeatable Read, אבל כן מגן בפני Write Skew או Lost Update. אורקל משתמש רק ב MVCC ולא ב 2PL, מה שתורם למקביליות - אבל גם אומר שרמת הפרדה של Serializable לא מגנה בפני Write Skew....

בקיצור: Tradeoffs. Tradeoffs everywhere.




רשימת התופעות הבעייתיות האפשריות:

  • Dirty Write - כאשר שתי טרנזקציות יכולות לשנות את אותו השדה, כך שטרנזקציה אחת משנה את הערך לשנייה. 
    • בגלל השימוש ב MVCC (או גישה יותר מחמירה: 2PL) - זה לא יקרה אף פעם בטרנזקציה של MySQL.
  • Dirty Read - הטרנזקציה יכולה לקרוא שינוי של טרנזקציה אחרת שהוא עדיין לא committed. הערך הזה יכול להתבטל (rollback) מאוחר יותר - בעוד הטרנזקציה שלנו משתמשת בו. התוצאה עשויה להיות שנשתמש בערך שאין לו ייצוג תקין בשאר המערכת (למשל: id לרשומה שלא קיימת). לא משהו...
  • Not Repeatable Read (בקיצור: NRR) - הטרנזקציה קוראת ערך משדה x בנקודת זמן t1, וכאשר היא קוראת שוב את השדה הזה בנקודת זמן מאוחרת יותר, t2 - ערך השדה הוא שונה. כלומר: טרנזקציה אחרת עשתה commit (אולי autocommit) - והערך שאנו רואים איננו עקבי. ההתנהגות ה NRR שוברת את תמונת "העולם המבודד" שרצינו ליצור לטרנזקציה שלנו - ובמקרים רבים היא יכולה להיות בעייתית.
  • Phantom Read - הטרנזקציה ביצעה קריאה של תנאי מסוים (נניח: year between 2016 and 2018) וקיבלה סדרה של רשומות, אך בינתיים טרנזקציה אחרת עשתה commit והוסיפה רשומות חדשות לטווח. כלומר: אם ניגש שוב לטווח - התוצאה תהיה שונה.
    • זו וריאציה מורכבת יותר של NRR. בעוד NRR ניתן לפתור בעזרת נעילה המאפשרת קריאות-בלבד מרשומה שאליה ניגשה הטרנזקציה, נעילה של טווח שנובע מפרדיקט היא דבר מסובך למדי - גם עבור בסיס נתונים משוכלל.
  • Read Skew - וריאציה נוספת של NRR: ישנן 2 טבלאות עם קשר לוגי ביניהן. שדה x באחת משפיע או מושפע על שדה y בטבלה השנייה. הטרנזקציה קוראת ערך x מטבלה אחת, אך בינתיים טרנזקציה אחרת משנה את x וגם את y (בצורה עקבית). בסיס הנתונים לא יודע בוודאות על הקשר, וכאשר אנו קוראים את y - עדיין עלולים לקבל את הערך הישן - לפני העדכון של הטרנזקציה השנייה. התסריט הזה מעט מבלבל - הנה דוגמה מפורטת.
  • Write Skew - וריאציה דומה, בה שתי טרנזקציות קוראות את שני הערכים x ו y, ואז אחת מעדכנת את x - בעוד השנייה מעדכנת את y. התוצאה - עקביות הנתונים נשברה. הלינק מלמעלה מספק גם דוגמה מפורטת ל Write skew.
  • Lost update - שתי טרנזקציות קוראות ערך ומעדכנות אותו. אחד העדכונים יאבד - מבלי שנדע שכך אכן קרה. במקרה של counter, למשל - הנזק מוחשי מאוד. גם בשדות המכילים ריבוי פריטים (כמו json) - הנזק הוא ברור. ישנם גם מקרים נוספים בהם התוצאה היא בעייתית.
למרות ההגנה הרבה שהן מספקות, חשוב לנסות ולהימנע מרמת הפרדה של Serializable - היכולה לפגוע משמעותית במקביליות, במיוחד כאשר הטרנזקציות אינן קצרות.



חזרה לתסריטים מתחילת הפוסט


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



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

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


אם מעוניינים ב Atomicity, היכולת של טרנזקציה להתבטל מבלי להשאיר "שאריות" אחריה בפעולת Rollback, אזי רמת ההפרדה של Read Committed עשויה להספיק - ולאפשר מקביליות רבה.


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


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


בשתי הדוגמאות האחרונות, אם אנו יודעים בוודאות שהגישה הקריטית היא רק לרשומה בודדת (למשל: קריאת ערך - ואז עדכון) - אזי Read committed היא רמה מספיק טובה. כל הרמות הגבוהות יותר מגינות מפני תסריטים של ריבוי רשומות / טבלאות.
עם אופטימיזציות כאלו כדאי להיות זהירים: משהו עשוי להשתנות בתוכן הטרנזצקיה, מבלי שמבצע השינוי יזכור / יבין שעליו להעלות את רמת ההפרדה.
מצד שני, יש כמה יתרונות משמעותיים לשימוש ב Read Committed (בקיצור RC) על פני Repeatable Read (בקיצור RR) מבחינת ביצועים:
  • ב RR, מנוע InnoDB ינעל כל רשומת אינדקס ששימשה את הטרנזקציה. אם חיפוש הרשימה מתבצע באינדקס לא יעיל (סריקה של הרבה רשומות) - אזי חסמנו הרבה מקביליות.
  • ב RR, המנוע מחזיק את כל הנעילות עד סוף הטרנזקציה. ככל שהטרנזקציה ארוכה יותר - כך זה בעייתי יותר.
  • ב RR, המנוע יוצר gap lock, על רשומות באינדקס שעלו בטווח של השאילתה (גם אם לא נסרקו). שוב - נעילה שעשויה להיות משמעותית למקביליות. נדבר על gap lock בחלק השני של הפוסט.
הנעילות על האינדקסים הן דוגמה למצב בו טרנזקציה אחת מאיטה פעולות אחרות בבסיס הנתונים, שלא ניגשים לאותן הרשומות. הנה דוגמה נוספת (history length).


עוד שני תסריטים שציינתי ולא כיסינו הם אלו:

שיפור ביצועים - זה מקרה ייחודי - אך שימושי.
InnoDB מחזיק binary log, על כל שינוי שבוצע בינתיים עבור התאוששות מהתרסקות ועבור replications. כדי לשמור על הלוג ככשיר להתאוששות, עליו לבצע flush ללוג (כלומר: לדיסק) על כל טרנזקציה שמתבצעת.
אם אנו מבצעים עשרות או מאות שינויים (למשל: הכנסה של רשומות חדשות), בשל ה autocommit - אנו נחייב את המנוע לבצע עשרות או מאות flushes לדיסק (פעולה יקרה).
שימוש בטרנזקציה (הכי פשוטה) - יאפשר לבסיס הנתונים לבצע flush יחיד.

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

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

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


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

כיצד עושים "התערבות ידנית"?
אלו מנגנוני-נעילה נוספים, הרצים מ"אחורי-הקלעים", קיימים ב InnoDB? (למשל: הזכרנו את ה gap lock)
כיצד מאתרים בעיות של נעילות בעייתיות / עודפות?

על כל זה - נדבר בפוסט ההמשך.



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






------


[א] החל מחודש ספטמבר, נהוג כבר להתמקד רק בטכנולוגיות השנה הבאה. #שנה_נוכחית_זה_פח

[ב] התקן המקורי של ANSI-SQL 92 זיהה רק 3 תופעות אפשריות של חוסר עקביות בנתונים, אך מאמר שהגיע שלוש שנים אחריו, הציף עוד 4 מקרים בעייתיים נוספים.

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




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


2018-11-03

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


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

בפוסט הזה אני רוצה לגעת בעוד כמה עניינים מעוררי-מחשבה:
  • מדוע 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-Tree

KD-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 הסופר-פופולארי לא היה יציב. כלומר: הוא עשוי היה, באופן אקראי, להחליף בין ערכים שהם זהים. זה לא מפריע במספרים - אך עשוי להפריע באובייקטים מורכבים.

דוגמה אחרונה, וחמורה למדי, היא זו:


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



2018-10-27

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


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


מתכון


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

הנה דוגמה:


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

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


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


סיכום


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

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

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

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

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



2018-10-20

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:





סיכום


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

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

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



2018-10-13

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

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

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

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

לאחר כמעט 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 עמודים שמסביר הכל, אבל לא נראה לי שווה את ההשקעה. במיוחד כי דברים גם משתנים.