השוואה הוגנת של מהירות ריצה - ‏C#,‏ C++,‏ Java,‏ Go

· עודכן בתאריך: · · Benchmark, Performance, ‏C#, ‏C++, Java, Go

“אומרים ש-‏C++ מהירה” “‏Go קלה בשימוש בפועל” “‏Java די מהירה כשמריצים אותה זמן רב” “גם ‏C# חזקה יותר משנדמה, בזכות ה-JIT של .NET”

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

‏C# ו-Java נוטות להיות מושפעות מ-JIT ומ-warm-up, ואילו C++ ו-Go בדרך כלל מקומפלות מראש. גם קיום ה-GC ומאפייניו שונים. גם הבדלי מימוש בספריית התקן ובספריות נלוות משפיעים משמעותית. ומעבר לכל זה, גם באותה מכונה, הגדרות חשמל, חום, עיבוד ברקע, והטיה בנתוני הקלט משנים בקלות את התוצאה. זה עולם מלוכלך למדי.

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

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

המאמר הזה מסדר דרך מדידה שמשווה בהוגנת ככל האפשר בין C#‏ / C++‏ / Java‏ / Go. אם אומרים את המסקנה מראש, הדבר החשוב ביותר הוא לא לנסות להכריע “איזו שפה הכי מהירה” עם מספר אחד.

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

קהל היעד

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

דוגמאות הקוד: ה-runner המשותף ב-PowerShell, וה-harness בתוך השפה - BenchmarkDotNet של C# ו-JMH של Java. עבור C++ ו-Go, נציין רק את אופן קיבוע התנאים.

מונחים שכדאי להבין מראש

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

מונח משמעות
p95 /‏ p99 אחוזון (percentile). כשמסדרים את כל ה-runs מהמהיר לאיטי, זהו הערך שנמצא במיקום 95%/99% מהתחתית. “5 מתוך 100 פעמים איטי מזה” זה p95
‏RSS (‏Resident Set Size) הגודל שהתהליך בפועל טוען לזיכרון הפיזי. לא כמות הזיכרון הווירטואלי שהוקצה, אלא “כמה זיכרון אמיתי הוא תופס כרגע”
‏LTO (‏Link Time Optimization) מנגנון שמבצע אופטימיזציה חוצת יחידות תרגום בזמן ה-link. ‏-flto של GCC‏ / Clang, ו-/GL יחד עם /LTCG של MSVC שייכים לכאן
‏PGO (‏Profile-Guided Optimization) מנגנון שמשתמש בפרופיל של ענפים וקריאות שנאסף מהרצה אחת, כדי לקבל החלטות אופטימיזציה בבנייה הבאה
‏Tiered Compilation מנגנון שבו ה-JIT של .NET מריץ תחילה קוד שאפשר לקמפל מהר, ורק את השיטות שנקראות הרבה מקמפל מחדש בהמשך באופן מותאם. אחד הגורמים העיקריים להבדל בין cold ל-warm
‏Server GC /‏ Workstation GC מצבי הפעולה של ה-GC ב-.NET.‏ Server GC מחזיק heap ו-threads ל-GC לכל מעבד לוגי ונוטה לתפוקה, ו-Workstation GC נוטה לתגובתיות
‏GOMAXPROCS התקרה של מספר ה-OS threads שבהם ה-runtime של Go מריץ קוד Go בו-זמנית. בבנצ’מארק מקבילי, אם לא מקבעים את זה, אי אפשר להשוות תוצאות
‏cgo המנגנון שבו Go קוראת לקוד C. הפעלתו משנה את עלות הקריאה, תנאי הבנייה, ואפשרות ה-linking הסטטי

קודם כל - המסקנה

מה שבאמת משפיע בהשוואת מהירות בין C#‏ / C++‏ / Java‏ / Go, זה שבעת הדברים האלה.

  1. קודם כל, קובעים מה רוצים להשוות האם זה זמן הפעלה, throughput במצב יציב, השהיית p95, או יעילות זיכרון - אופן המדידה משתנה בהתאם.

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

  3. מפרידים cold מ-warm ב-C# וב-Java אם מערבבים השוואה שכוללת הרצה ראשונה עם השוואת מצב יציב אחרי warm-up, הדיון מתעוות.

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

  5. מפרידים microbenchmark בתוך שפה, מבנצ’מארק end-to-end חוצה שפות ה-harness הייעודי של כל שפה נוח, אבל השוואה חוצת שפות עדיפה שתרוץ ב-runner משותף חיצוני.

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

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

מפת הידע של המאמר

המאמר מצביע על הסיכון שבהשוואת מהירות ריצה בין C#,‏ C++,‏ Java ו-Go במספר אחד, ומסדר תכנון מדידה להשוואה הוגנת. ל-C# ול-Java יש פער גדול בין cold ל-warm בגלל השפעת קומפילציית ה-JIT וה-Tiered Compilation, ואילו C++ ו-Go מקומפלות מראש (AOT) בדרך כלל, ולכן דורשים לא לערבב את שניהם באותה טבלה. ב-C++ יש צורך באמצעי נגד סילוק קוד מת שבו המהדר מוחק חישוב שלא נעשה בו שימוש, וזה נעשה עם Google Benchmark ואימות באמצעות checksum. המדידה בתוך כל שפה משתמשת ב-harness ייעודי כמו BenchmarkDotNet,‏ JMH,‏ go test -bench, ו-Google Benchmark, וההשוואה חוצת השפות מומלצת בהרכב דו-שכבתי שבו runner משותף חיצוני מבצע ערבוב סדר הרצה ואימות checksum. בנוסף, נדרש להסתכל לא רק על הממוצע אלא גם על חציון ועל התפלגות דוגמת אחוזוני זנב ההשהיה p95/p99, כתנאי לפרשנות נכונה של התוצאה.

מפת הידע של בנצ'מארק חוצה שפות בין C#‏/C++‏/Java‏/Goתרשים שמראה שקומפילציית ה-JIT וה-Tiered Compilation של C# ו-Java, לעומת הקומפילציה המוקדמת של C++ ו-Go, מחייבים הבחנה בין cold ל-warm, את Google Benchmark ואימות checksum כאמצעי נגד סילוק קוד מת ב-C++, את הרכב שתי השכבות של harness ייעודי לשפה ו-runner משותף חיצוני, ואת הערכת ההתפלגות כולל אחוזוני זנב ההשהיה.משתמש במשתמש במשתמש במשתמש במשתמש במשתמש במשתמש במשתמש במשתמש במשתמש במשתמש במשתמש במשתמש במונעעלול לגרום למונעמשתמש במשתמש במשתמש במשתמש במשתמש במשתמש במשתמש במשתמש בתכנון מדידת ביצועים חוצת שפותC#קומפילציית JIT‏ (Just-In-Time)JavaC++קומפילציית AOT (מוקדמת)GoTiered CompilationGOMAXPROCScgoBenchmarkDotNetJMH(Java Microbenchmark Harness)‏go test -bench ו-benchstatGoogle Benchmarkמדידה במצב יציב אחרי חימום (warm)סילוק קוד מת (היעלמות עיבוד עקב אופטימיזציה)אימות תקינות באמצעות checksumאחוזוני זנב ההשהיה (p95/p99)איסוף אשפה (GC)

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

הדבר הראשון שקובעים

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

לדוגמה, גם באותה תכנית, מה שרוצים לראות שונה מאוד.

1. רוצים לראות זמן הפעלה?

בכלי CLI, batch קצר-חיים, כלי עזר שמופעל פעם אחת ומסתיים מיד - cold start ו-process startup משפיעים. בציר הזה, התוצאה משתנה מאוד בהתאם לשאלה אם כוללים את עלות אתחול ה-JIT וטעינת המחלקות.

2. רוצים לראות throughput בהרצה ארוכה?

בשרת, תהליך שרץ ברקע, worker, עיבוד המרה שרץ זמן רב - throughput במצב יציב (steady-state) חשוב. כאן, איטיות בהרצה הראשונה בלבד היא לא המהות, אלא עד כמה יציב ומהיר זה נשאר אחרי warm-up.

3. רוצים לראות tail latency?

ב-API, UI, עיבוד קרוב לזמן אמת, לעיתים p95 /‏ p99 חשובים יותר מהממוצע. גם אם הממוצע מהיר, אם לפעמים יש עצירה גדולה, זה קשה מבחינת חוויית משתמש ו-SLA.

4. רוצים לראות גם יעילות זיכרון?

לא רק זמן CPU, אלא גם ‏RSS מקסימלי, כמות הקצאה, מספר GC,‏ GC pause - בלעדיהם, קל לטעות בהערכת המשקל בפועל. “מהיר אבל אוכל הרבה זיכרון” ו-“קצת איטי אבל יציב וקל” - ההערכה מתהפכת לפי השימוש.

בקיצור, השאלה הראשונה שצריך לקבוע היא לא

מי השפה שמהירה בהשוואה הזו, אלא איזה workload, באילו תנאים, ולפי איזה מדד אפשר לעבד מהר יותר

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

קובעים מראש מה קוראים מהירתרשים שמראה שאופן המדידה משתנה בהתאם לשאלה אם רוצים לראות זמן הפעלה, throughput במצב יציב, tail latency, או יעילות זיכרון, ולכן לפני ההשוואה קובעים איזה workload, באילו תנאים, ולפי איזה מדד בודקים.מה רוצים לדעת בהשוואה הזוזמן הפעלהthroughput במצב יציבהשהיית p95 / p99יעילות זיכרון היא ציר נפרדאופן המדידה משתנה לפי הציר

איור 2: עד שלא קובעים את הגדרת “מהיר”, לא מתחילים לאסוף מספרים.

למה השוואת שפות קשה

ערבוב JIT ו-AOT הופך לניסוי שונה

‏C# ו-Java בדרך כלל מושפעות מ-JIT. מצד שני, C++ ו-Go בדרך כלל מקומפלות מראש.

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

לשניהם יש משמעות. אבל, הם לא אותה משמעות.

ההבדל בין קבוצת JIT לקבוצת AOTתרשים שמראה שב-C# וב-Java, שבדרך כלל מושפעות מ-JIT, ההרצה הראשונה מערבבת אתחול runtime, טעינת מחלקות והכנת JIT, ואילו C++ ו-Go, שבדרך כלל מקומפלות מראש, רצות ישירות - ולכן השוואת הרצה ראשונה והשוואה אחרי warm-up הן שני ניסויים שונים.C# ו-Java (בדרך כלל JIT)ההרצה הראשונה מערבבת אתחול והכנת JITC++ ו-Go (בדרך כלל AOT)רצות ישירות אחרי קימפול מראשהשוואת ראשונה והשוואת מצב יציב שונות

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

הבדל מימוש גדול יותר מהבדל שפה - זה קורה הרבה

גם ב”מיון” זהה,

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

רק זה משנה משמעותית את התוצאה.

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

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

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

ב-C++ יש מלכודת שבה עיבוד נעלם עקב אופטימיזציה

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

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

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

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

קיום ה-GC הוא לא “חיסרון” ולא “יתרון”, אלא מאפיין

ל-C#,‏ Java ו-Go יש GC. לקבוע בפשטות “יש GC ולכן איטי” זה גס מדי.

בפועל, מה שבאמת משפיע הוא:

  • איך מטפלים בכמות גדולה של אובייקטים קצרי-חיים
  • הגדרת גודל ה-heap
  • תדירות ה-GC וה-pause שלו
  • פריסת האובייקטים
  • הרגלי ההקצאה של הספרייה

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

GC הוא לא חיסרון, אלא מאפייןתרשים שמראה שבשפות עם GC, אופן הטיפול באובייקטים קצרי-חיים, הגדרות heap, תדירות GC ו-pause, והרגלי הקצאה הם מה שבאמת משפיע, ואילו ב-C++ עם ניהול ידני ו-RAII אפשר לשלוט אבל קל יותר שיבלטו הבדלי מימוש, כך שהבדל שיטת הניהול הוא לא עליונות או נחיתות.שפה עם GCהגדרות והרגלי הקצאה משפיעיםניהול ידני ו-RAII ב-C++אפשר לשלוט, אבל הבדלי מימוש בולטיםהבדל שיטת ניהול הוא לא עליונות

איור 6: סידור שמונע להסתפק ב-“יש GC אז זה איטי”.

מה אסור לעשות בהשוואה

1. לערבב Debug ו-Release

זה מחוץ לדיון בכלל. מוודאים שכל הצדדים בהשוואה הם build עם אופטימיזציה ברמת ייצור.

2. לא פותרים את אותה בעיה

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

3. מסיקים מסקנה מהרצה בודדת

הרצה בודדת היא לרוב רעש.

  • JIT
  • מטמון עמודים
  • boost של ה-CPU
  • חום
  • משימת רקע
  • GC
  • קריאת קובץ ראשונה

כל אלה מתערבבים יחד בהרצה בודדת.

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

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

4. מערבבים warm-up

כשמודדים C# ו-Java, אם נשאר לא ברור אם כוללים את ההרצה הראשונה או מסתכלים רק אחרי warm-up, הדיון נשבר. מתייחסים ל-cold ול-warm כשני דברים שונים.

5. לא מבצעים בדיקת נכונות

בנצ’מארק צריך “להחזיר את אותה תוצאה” לפני “להיות מהיר”. מוודאים תמיד ש-אותו checksum או אותו פלט מתקבל מאותו קלט בכל המימושים שמשווים.

6. קובעים תמונת עולם מ-microbenchmark בודד

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

הגישה הבסיסית להשוואת C#‏ / C++‏ / Java‏ / Go

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

מבנה שתי השכבות של ההשוואהתרשים שמראה שהשכבה החיצונית היא runner משותף חוצה שפות שמריץ קובצי bench לכל שפה, ומתחתיה השכבה הפנימית שבה כל שפה נחקרת לעומק עם ה-harness הייעודי שלה - Google Benchmark, BenchmarkDotNet, JMH, ו-go test -bench.השכבה הפנימית: חקירה בתוך כל שפההשכבה החיצונית: runner משותף חוצה שפותמדידה מעמיקה באותה שפהמדידה מעמיקה באותה שפהמדידה מעמיקה באותה שפהמדידה מעמיקה באותה שפהGoogle BenchmarkBenchmarkDotNetJMHgo test -benchrunner משותףbench - C++bench - C#bench - Javabench - Go

איור 8: השוואה חוצת שפות ב-runner המשותף החיצוני, וחקירה בתוך שפה עם ה-harness הייעודי.

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

1. במדידה בתוך השפה, משתמשים ב-harness המתאים לשפה הזו

לכל שפה יש כלי benchmark שסופג עבורה את הענייני ה-runtime.

  • ‏C#:‏ BenchmarkDotNet
  • Java:‏ JMH
  • Go:‏ go test -bench ו-benchstat
  • C++:‏ Google Benchmark

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

לדוגמה, “למיין 10 מיליון int32” ב-BenchmarkDotNet, ההרכב המינימלי נראה כך.

// C# / .NET 8 + BenchmarkDotNet 0.13 系
//   dotnet add package BenchmarkDotNet
//   dotnet run -c Release
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<SortBench>();

[MemoryDiagnoser]                       // מוציא גם כמות הקצאה ומספר GC
public class SortBench
{
    private int[] _source = Array.Empty<int>();
    private int[] _work = Array.Empty<int>();

    [GlobalSetup]
    public void Setup()
    {
        var rng = new Random(12345);    // מקבעים seed כדי לקבל אותו קלט בכל פעם
        _source = new int[10_000_000];
        for (int i = 0; i < _source.Length; i++)
        {
            _source[i] = rng.Next();
        }
        _work = new int[_source.Length];
    }

    [IterationSetup]                    // בכל פעם, מחזירים למצב לא ממוין
    public void ResetInput() => Array.Copy(_source, _work, _source.Length);

    [Benchmark]
    public long SortInt32()
    {
        Array.Sort(_work);

        long checksum = 0;
        foreach (int v in _work)
        {
            checksum = checksum * 31 + v;
        }
        return checksum;                // מחזירים את התוצאה כדי שלא תיעלם עקב אופטימיזציה
    }
}

יש מגבלה ל-[IterationSetup]. התיעוד הרשמי של BenchmarkDotNet לא ממליץ להשתמש בזה ב-microbenchmark כי זה מזהם את התוצאה, אבל מציין שזה שימושי ב-macrobenchmark שלוקח מעל 100ms. מיון של 10 מיליון פריטים עומד בתנאי הזה, אבל כשמודדים עיבוד קצר, כדאי להעביר את זה לצד [GlobalSetup].

גם ב-JMH של Java, החשיבה זהה.

// Java / JMH。pom.xml に jmh-core と jmh-generator-annprocess を入れます
//   mvn clean verify
//   java -jar target/benchmarks.jar SortBench
package bench;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;

import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(3)                                 // מפרידים JVM כדי לאזן את הפיזור של ה-JIT
public class SortBench {

    private int[] source;
    private int[] work;

    @Setup(Level.Trial)
    public void setUp() {
        Random rng = new Random(12345);  // מקבעים seed כדי לקבל אותו קלט בכל פעם
        source = new int[10_000_000];
        for (int i = 0; i < source.length; i++) {
            source[i] = rng.nextInt();
        }
        work = new int[source.length];
    }

    @Setup(Level.Invocation)             // בכל פעם, מחזירים למצב לא ממוין
    public void resetInput() {
        System.arraycopy(source, 0, work, 0, source.length);
    }

    @Benchmark
    public long sortInt32() {
        Arrays.sort(work);

        long checksum = 0;
        for (int v : work) {
            checksum = checksum * 31 + v;
        }
        return checksum;                 // JMH צורך את הערך המוחזר, לכן לא נעלם עקב אופטימיזציה
    }
}

ל-Level.Invocation יש מגבלה דומה. ה-javadoc של JMH מציין במפורש שהרמה הזו ניתנת לשימוש רק בבנצ’מארק שבו קריאה בודדת ל-@Benchmark לוקחת יותר ממילישנייה אחת. מכיוון שמודדים חותמת זמן בכל קריאה, בעיבוד קצר, המדידה עצמה הופכת לצוואר בקבוק.

הערכים של @Warmup /‏ @Measurement /‏ @Fork ב-BenchmarkDotNet וב-JMH הם בעצמם תנאי הניסוי. חובה לשמור אותם יחד עם התוצאה.

2. בהשוואה חוצת שפות, מציבים runner משותף בחוץ

מצד שני, להציב את תוצאת BenchmarkDotNet של C# ו-את תוצאת JMH של Java זה לצד זה כמו שהן, קצת מסוכן. כי דרך העבודה של ה-harness עצמו שונה.

לכן, בהשוואה חוצת שפות, מומלץ להפוך כל מימוש לקובץ הרצה שנקרא עם אותו חוזה CLI, ולהריץ אותם מבחוץ תחת אותם תנאים.

לדוגמה, בכל שפה מכינים קובץ הרצה בצורה כזו.

bench --scenario sort_int32 --dataset data/sort_10m.bin --mode warm
bench --scenario group_words --dataset data/words_100mb.txt --mode cold
bench --scenario parallel_hash --dataset data/blob_1gb.bin --threads 8

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

checksum=[16進の文字列]
inner_ms=[小数のミリ秒]

checksum הוא לבדיקת נכונות, ו-inner_ms הוא הזמן שהמימוש עצמו מדד עבור עיבוד הגוף. ה-runner מודד בנפרד את ה-wall-clock של כל התהליך, ולכן נשארים גם הזמן כולל ההפעלה וגם הזמן של הגוף בלבד.

חוזה ה-CLI וחוזה הפלטתרשים שמראה שהופכים כל מימוש בשפה לקובץ הרצה עם אותו חוזה CLI, שמוציא רק שתי שורות - checksum ו-inner_ms, וה-runner מודד בנפרד את ה-wall-clock של כל התהליך, כך שנשארים גם הזמן כולל ההפעלה וגם הזמן של הגוף בלבד.קובץ הרצה עם אותו חוזה CLIמוציא checksum ו-inner_msה-runner מודד wall-clock כללינשארים גם הכללי וגם הגוף בלבד

איור 9: אם אופן הקריאה והפלט הופכים לחוזה, אפשר להריץ 4 שפות על אותה זירה.

ואז, בצד ה-runner המשותף,

  • מערבבים את סדר ההרצה
  • מפרידים cold /‏ warm
  • מעבירים את אותו dataset
  • מאמתים checksum
  • אוספים wall-clock וזיכרון
  • שומרים raw data ב-CSV / JSON

זו הזרימה. אם כותבים רק את השלד, זה נראה כך.

# run-bench.ps1 : 言語横断の共通ランナー(骨格)
# PowerShell 7.4 で動きます。
#   pwsh ./run-bench.ps1 -Scenario sort_int32 -Dataset ./data/sort_10m.bin -Runs 15

param(
    [Parameter(Mandatory = $true)][string]$Scenario,
    [Parameter(Mandatory = $true)][string]$Dataset,
    [int]$Runs = 15,
    [int]$WarmupRuns = 3,
    [string]$OutCsv = "./results/raw.csv"
)

# 各言語の実行ファイル。CLI 契約は 4 実装ともまったく同じにそろえます。
# Java は java -jar を包むだけの薄いラッパを置いて、呼び方を統一します。
$Implementations = @(
    @{ Language = "cpp";    Exe = "./build/cpp/bench.exe" },
    @{ Language = "csharp"; Exe = "./build/csharp/bench.exe" },
    @{ Language = "java";   Exe = "./build/java/bench.cmd" },
    @{ Language = "go";     Exe = "./build/go/bench.exe" }
)

function Invoke-OneRun {
    param(
        [Parameter(Mandatory = $true)][hashtable]$Impl,
        [Parameter(Mandatory = $true)][string]$Mode,
        [Parameter(Mandatory = $true)][int]$Index
    )

    # $Scenario と $Dataset は、先頭の param ブロックで定義したものを使います
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    $stdout = & $Impl.Exe --scenario $Scenario --dataset $Dataset --mode $Mode
    $sw.Stop()
    $exitCode = $LASTEXITCODE

    # 実装との出力契約を、ここで 1 か所だけ解析します
    $checksum = ""
    $innerMs = [double]::NaN
    foreach ($line in $stdout) {
        if ($line -match '^checksum=(\S+)$') { $checksum = $Matches[1] }
        if ($line -match '^inner_ms=(\S+)$') { $innerMs = [double]$Matches[1] }
    }

    if ($exitCode -ne 0 -or [string]::IsNullOrEmpty($checksum)) {
        throw "$($Impl.Language) / $Mode / run $Index が失敗しました。exit=$exitCode"
    }
    # inner_ms を出し忘れた実装は、終了コード 0 で checksum だけ返してきます。
    # ここで弾かないと NaN のまま CSV に入り、内側時間の比較が黙って壊れます
    if ([double]::IsNaN($innerMs) -or [double]::IsInfinity($innerMs) -or $innerMs -lt 0) {
        throw "$($Impl.Language) / $Mode / run $Index が inner_ms を返していません(値: $innerMs)。" +
              "出力契約は checksum= と inner_ms= の 2 行です"
    }

    return [pscustomobject]@{
        timestamp    = (Get-Date).ToString("o")
        language     = $Impl.Language
        scenario     = $Scenario
        cold_or_warm = $Mode
        run_index    = $Index
        process_ms   = [math]::Round($sw.Elapsed.TotalMilliseconds, 3)
        inner_ms     = $innerMs
        checksum     = $checksum
    }
}

# 1. まず正しさ確認。全実装が同じ checksum を返さないなら、速度を測る意味がありません
$expected = $null
foreach ($impl in $Implementations) {
    for ($i = 1; $i -le $WarmupRuns; $i++) {
        $r = Invoke-OneRun -Impl $impl -Mode "warm" -Index $i
        if ($null -eq $expected) {
            $expected = $r.checksum
        }
        elseif ($r.checksum -ne $expected) {
            throw "checksum が一致しません。$($impl.Language)$($r.checksum)、基準は $expected"
        }
    }
}

# 2. 本番。run ごとに実行順をシャッフルして、熱と時間帯の偏りをならします
#    1 の確認は捨てる warm run に対するものなので、記録する run も 1 本ずつ
#    checksum を照合します。ここを省くと、途中から違う仕事をするようになった
#    実装の時間が CSV に混ざり、比較そのものが無効になります
$rows = [System.Collections.Generic.List[object]]::new()
foreach ($mode in @("cold", "warm")) {
    for ($i = 1; $i -le $Runs; $i++) {
        $shuffled = $Implementations | Get-Random -Count $Implementations.Count
        foreach ($impl in $shuffled) {
            $r = Invoke-OneRun -Impl $impl -Mode $mode -Index $i
            if ($r.checksum -ne $expected) {
                throw "checksum が一致しません。$($impl.Language)$mode run #$i$($r.checksum)、基準は $expected"
            }
            $rows.Add($r)
        }
    }
}

# 3. 生データを必ず残す。集計はこの CSV から後で行います
# -OutCsv raw.csv のように親ディレクトリを持たない名前を渡されると
# Split-Path -Parent は空文字を返し、New-Item がそれを拒否します。
# ここで倒れるのは全 run が終わったあとなので、測定結果を丸ごと失います
$outDir = Split-Path -Parent $OutCsv
if ($outDir) { New-Item -ItemType Directory -Force -Path $outDir | Out-Null }
$rows | Export-Csv -Path $OutCsv -NoTypeInformation -Encoding utf8
Write-Host "raw data: $OutCsv / $($rows.Count) 行"

יש כאן 3 דברים שנעשים בכוונה תחילה.

  • בדיקת הנכונות ממוקמת לפני מדידת המהירות. אם ה-checksum לא תואם, אין טעם להשוות מהירות
  • מערבבים ב-1 run בכל פעם. המטרה היא לא לעבור סדר של “מריצים את כל A, ואז את כל B”
  • כותבים רק raw data בלי לצבור. ממוצע וחציון מחושבים אחר כך מתוך ה-CSV

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

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

איור 10: תפקיד ה-runner הוא לא להריץ מהר, אלא להסיר ספקות.

דוגמה קונקרטית - אילו פריטי בנצ’מארק כדאי להכין

כשמבקשים “רוצים להשוות בין C#‏ / C++‏ / Java‏ / Go”, אם זה רק אחד, מומלץ פריט CPU פשוט שקשה לפרש לא נכון, ואם כמה - מומלץ 3-4 פריטים עם workload בעל אופי שונה.

ההרכב המומלץ

1. sort_int32_10m

מטרה: לראות CPU + רוחב פס זיכרון + שימוש בשטח זמני

  • קלט: 10 מיליון int32 שנוצרו עם seed קבוע
  • עיבוד: ממיינים מערך ומחזירים checksum
  • נקודה לתשומת לב: לחזור בכל פעם לאותו קלט לא ממוין

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

2. hash_group_count

מטרה: לראות טבלת hash, עיבוד מחרוזות, הקצאה, מגמת GC

  • קלט: נתוני טקסט קבועים
  • עיבוד: סופרים כמות הופעה לכל מילה
  • פלט: N העליונים ו-checksum

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

3. parallel_sha256

מטרה: לראות עיבוד מקבילי, scheduler, worker pool, מאפייני סנכרון

  • קלט: רצף chunk בגודל קבוע
  • עיבוד: hash עם N threads לפי סדר, ומחזירים checksum סופי
  • תנאי: מדרגים את מספר ה-threads כמו 1 /‏ 2 /‏ 4 /‏ 8

קל יותר לראות את קצב הגידול בעת ריצה מקבילית, בהשוואה ל-tight loop פשוט.

4. startup_noop או startup_parse_small

מטרה: לראות זמן הפעלה

  • noop: מפעילים ומסיימים מיד
  • parse_small: מעבדים קלט קטן פעם אחת ומסיימים

כאן קל לראות את עלות ה-JIT וההכנה של C#‏ / Java, וזה שונה מאוד מהמראה של C++‏ / Go. מנקודת מבט הפוכה, גם אם יש הבדל כאן, זה נפרד מהניצחון בעיבוד ארוך.

4 פריטי בנצ'מארק עם אופי שונהתרשים שמראה שהמיון מגלה CPU ורוחב פס וזיכרון זמני, ספירת מילים מגלה hash ומחרוזות והקצאות, ה-hash המקבילי מגלה קצב גידול בעיבוד מקבילי, ובנצ'מארק ההפעלה מגלה זמן הפעלה ועלות אתחול.sort_int32_10mCPU, רוחב פס, שטח זמניhash_group_countמחרוזות, map, מגמת GCparallel_sha256קצב גידול בעיבוד מקביליstartupעלות הפעלה ואתחול

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

מה עושים עם בנצ’מארק JSON או HTTP

‏JSON ו-HTTP קרובים לשטח, ולכן יש בהם משמעות כמובן. אבל, במקרה כזה, זו יותר השוואה שכוללת ספרייה, framework ואקוסיסטם מאשר השוואת שפות.

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

זו לא השוואת שפות, אלא השוואה שכוללת מימוש סטנדרטי וספריות מרכזיות

כך פחות אי-הבנות.

תנאים שכדאי להתאים לפי שפה

C++

  • מיישרים ל-build עם אופטימיזציה
  • מקבעים את המהדר
  • מקבעים את מימוש ספריית התקן
  • מציינים במפורש תנאים כמו -O3 /‏ /O2,‏ LTO,‏ PGO
  • שמים לב שהתוצאה לא נעלמת עקב אופטימיזציה
  • חושדים אם המהירות נובעת מהתנהגות לא מוגדרת (undefined behavior)

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

C#

  • מיישרים ל-build מסוג Release
  • מקבעים גרסת .NET
  • רושמים תנאים כמו Server GC /‏ Workstation GC
  • מציינים במפורש האם יש Tiered Compilation,‏ ReadyToRun,‏ Native AOT
  • מפרידים cold ו-warm

ב-C#, הבדלי הגדרות .NET משנים את המראה. בפרט, C# עם JIT ו-C# עם Native AOT הם צירים שונים גם אם שניהם “C#”. אם מערבבים את זה, מה שמושווה כבר לא השפה, אלא צורת ההפצה.

Java

  • מקבעים ספק וגרסה של ה-JDK
  • מציינים במפורש את ה-GC
  • מקבעים warm-up /‏ measurement /‏ fork
  • רושמים גודל heap ואפשרויות JVM
  • מפרידים cold start ו-steady-state

Java נהנית ביתרון מה-JIT, אך המראה של ההרצה הראשונה משתנה מאוד. לכן, הפרדה בין השוואת תהליכים קצרי-חיים ל-השוואת הרצה ארוכה היא הכרחית.

Go

  • מקבעים גרסת Go
  • מקבעים GOMAXPROCS
  • מציינים במפורש CGO_ENABLED
  • אם מכוונים GOGC, חובה לרשום זאת
  • אם אפשר, שומרים גם פלט בפורמט benchmark

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

איך מיישרים את סביבת ההרצה

בכל שפה, השוואה בלי יישור סביבה היא בעצם בעיקר השוואת סביבות.

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

איור 12: אפשר לתלות את ההפרש בשפה רק אחרי שמסירים את הפרש הסביבה.

מה שכדאי ליישר

  • אותו CPU / זיכרון / אחסון
  • אותה גרסת מערכת הפעלה
  • אותם תנאי חשמל
  • תנאים קרובים לאותה טמפרטורת חדר
  • אותם נתוני קלט
  • אותה עדיפות תהליך
  • אותם תנאי מספר ליבות
  • אותם תנאי container או bare metal

מה שמשפיע במיוחד

הגדרות חשמל ותדר CPU

במחשב נייד, גם רק חיבור ל-AC לעומת סוללה הופך לעולם שונה לגמרי. אם ה-CPU governor או power mode לא מיושרים, תוצאות ההשוואה מתנודדות משמעותית.

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

חום

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

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

איור 13: לא ניתן למחוק את החום, אבל סדר חכם מחלק אותו בהוגנות בין הצדדים.

עיבוד רקע

עדכון, אינדוקס, סנכרון, סריקת וירוסים, דפדפן, כלי צ’אט. אלה קטנים לכאורה, אבל בפועל תוקעים.

מה כדאי למדוד

בהשוואת שפות, מומלץ לפחות להפריד את ארבעת אלה.

1. wall-clock time

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

2. CPU time

“כמה CPU בפועל נוצל”. אם רק ה-wall-clock מהיר אבל CPU time לא משתנה, ייתכן שזו השפעת המתנה או I/O.

3. זיכרון / הקצאות

  • ‏RSS מקסימלי
  • כמות הקצאה כוללת
  • מספר alloc
  • מספר GC
  • ‏GC pause

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

4. התפלגות

  • חציון
  • p95 /‏ p99
  • ‏min / max
  • סטיית תקן ופיזור

אם מדברים רק על ממוצע, לא רואים את המהות של עיבוד שמדי פעם קופץ.

4 מדדים שמפרידים ביניהםתרשים שמראה שבהשוואת שפות מפרידים בין wall-clock שהמשתמש ממתין לו, CPU time שבפועל נוצל, זיכרון והקצאות, והתפלגות כמו חציון ואחוזונים - ומסתכלים על ארבעתם בנפרד.wall-clock timeמסתכלים על 4 מדדים בנפרדCPU timeזיכרון והקצאותהתפלגות (חציון, p95) גם בנפרד

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

סדר ההרצה המומלץ

הזרימה שנוחה בפועל היא בערך בסדר הזה.

סדר ההרצה המומלץ מקצה לקצהתרשים שמראה את סדר ההרצה - קביעת workload, קיבוע data set משותף, מעבר בדיקת נכונות, קיבוע תנאי build, הפרדת cold ו-warm, הרצה בסדר אקראי, שמירת raw data, ובדיקה אם יש הפרש משמעותי שמצדיק חפירה עם profile.לאכןלאכןלאכן1. קביעת workload2. קיבוע data set משותף3. בדיקת נכונות תחילהכל היישומים נתנו checksum זהה?4. קיבוע תנאי build5. הפרדת cold ו-warm6. הרצה בסדר אקראיהגענו למספר ההרצות הנדרש?8. שמירת raw dataיש הפרש משמעותי?מתעדים תנאים ומספר, ומסיימים9. חפירת סיבה עם profile

איור 15: סדר ההרצה מקביעת workload ובדיקת נכונות ועד להרצה אקראית ושמירת raw data.

1. קביעת workload

תחילה, מבהירים מה רוצים להשוות.

  • זמן הפעלה
  • throughput במצב יציב
  • tail latency
  • יעילות זיכרון
  • קנה מידה מקבילי

2. קיבוע dataset משותף

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

3. מעבירים קודם בדיקת נכונות

מוודאים שכל המימושים מחזירים אותה תוצאה בנתונים קטנים וגדולים. נוח אם מוציאים checksum או hash.

4. קיבוע תנאי build

בכל שפה יוצרים תבנית הרצה מסוג Release / מותאם, ורושמים גרסה ודגלים.

5. הפרדת cold ו-warm

בפרט ב-C# וב-Java, זו נקודה חשובה.

  • cold: כולל את הרגע שאחרי הפעלת התהליך
  • warm: מצב יציב אחרי כמה הרצות

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

הטווח שנמדד כ-cold וכ-warmתרשים שמראה שהטווח הנמדד כ-cold כולל הפעלת תהליך, אתחול runtime וטעינת מחלקות, קומפילציית JIT ראשונה, ועיבוד ראשון, ואילו הטווח הנמדד כ-warm הוא רק עיבוד במצב יציב אחרי שה-Tiered Compilation התקדם.הטווח הנמדד כ-warmהטווח הנמדד כ-coldעיבוד גוף במצב יציבאתחול runtime, טעינת מחלקותהפעלת תהליךעיבוד גוף 1קומפילציית JIT ראשונהעיבוד גוף 2 ואילך, Tiered Compilation מתקדם

איור 16: cold כולל הפעלת תהליך עד קימפול JIT ראשון, ו-warm מודד רק את המצב היציב.

ל-C++ ול-Go, מכיוון שהן מקומפלות מראש, אין שלב מקביל ל”קימפול JIT ראשון”, וגם אתחול ה-runtime קל יחסית. רוב ההבדל ב-cold נוצר בדיוק כאן. בגלל זה, טוב יותר לא לערבב את שתי אלה באותה טבלה.

6. הרצה לסירוגין או בסדר אקראי

לדוגמה:

cpp -> csharp -> java -> go
go -> java -> cpp -> csharp
csharp -> go -> java -> cpp
...

כך פוחתת הטיית חום ורעש.

7. מבטיחים מספיק הרצות

ב-microbenchmark קל, כדאי הרבה מאוד, וב-end-to-end לפחות 10 ומעלה. אם ההפרש קטן ומספר ההרצות קטן, הפרשנות הופכת מסוכנת מאוד.

8. שומרים raw data

לא רק תוצאת צבירה, אלא נתונים גולמיים של כל run. כשמסתכלים בהמשך, אפשר לקרוא ערכים חריגים והרגלי warm-up.

9. כשיש הפרש, לוקחים profile

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

  • ‏CPU profile
  • ‏allocation profile
  • לוג GC
  • ‏flame graph
  • trace בצד ה-OS

עד לכאן, אפשר לדבר לא רק על “מהיר / איטי”, אלא על למה זה קורה.

איך קוראים את התוצאה

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

רק בהרצה ראשונה, C#‏ / Java איטיות

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

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

C++ חזקה ב-tight loop

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

Go נראית עדיפה בזמן הפעלה או קלות הפצה

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

C#‏ / Java די מדביקות פער או אפילו מתהפכות ב-steady-state

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

הפרש גדול בעיבוד עתיר-הקצאות

במקרה כזה, לרוב יותר משם השפה, מה שמשפיע הוא:

  • פריסת זיכרון
  • הטיפול במחרוזות ו-map
  • התנהגות GC
  • העתקה מיותרת
איך קוראים כשיש הפרשתרשים שמראה שאם רק בהרצה ראשונה C# או Java איטיות, חושדים בהשפעת JIT ואתחול, ומטפלים בזה כהפרש בעל משמעות אם זמן ההפעלה חשוב, או כהפרש שמפרידים לטבלה נפרדת אם הרצה ארוכה היא הנושא, ואם יש הפרש חופרים סיבה עם profile.הפרש כולל הפעלההפרש במצב יציבהופיע הפרשתחת איזה תנאי?בעל משמעות אם זמן הפעלה חשובמטפלים כהשוואת הרצה ארוכהחופרים סיבה עם profile

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

תבנית תיעוד

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

timestamp,language,scenario,run_kind,cold_or_warm,elapsed_ms,cpu_ms,max_rss_mb,alloc_bytes,gc_count,checksum
compiler_or_runtime,compiler_version,flags,os,cpu,threads,input_id,notes

לדוגמה, אפשר לחלק את run_kind כך.

  • micro
  • macro
  • startup
  • parallel

את cold_or_warm חשוב לציין במפורש איזה משניהם.

  • cold
  • warm

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

עמודה מה נכנס דוגמת פורמט
timestamp זמן תחילת ה-run. משמש לבדוק בדיעבד תנודות לפי שעה ביום פורמט ISO 8601.‏ 2026-03-17T10:00:00+09:00
language מזהה המימוש. אוצר מילים קבוע כדי למנוע כתיב לא אחיד cpp /‏ csharp /‏ java /‏ go
scenario שם פריט הבנצ’מארק sort_int32_10m
run_kind סוג המדידה micro /‏ macro /‏ startup /‏ parallel
cold_or_warm האם כולל הפעלה cold /‏ warm
elapsed_ms ‏wall-clock. אם שומרים עד הספרה השלישית אחרי הנקודה, לא נתקעים בהמשך מילישניות עשרוניות
cpu_ms זמן CPU של התהליך. סכום user ו-system מילישניות עשרוניות
max_rss_mb ‏RSS מקסימלי MB שלם או עשרוני
alloc_bytes סך כמות בייטים שהוקצו. בשפות שאי אפשר להשיג, משאירים ריק ושומרים את זה שריק שלם, או ריק
gc_count מספר GC. ב-C++ תמיד ריק שלם, או ריק
checksum לבדיקת נכונות. מוודאים בנפרד שתואם בכל המימושים מחרוזת הקסדצימלית
compiler_or_runtime סוג המערכת msvc /‏ dotnet /‏ temurin /‏ go
compiler_version גרסת המערכת. כותבים עד ה-minor מחרוזת הגרסה שהמערכת מוציאה
flags תנאי אופטימיזציה. למשל /O2,‏ -O3 -flto,‏ Server GC,‏ GOMAXPROCS=8 מחרוזת מופרדת ברווחים
os /‏ cpu /‏ threads סביבת ההרצה שם OS ומספר build, דגם CPU, מספר threads בשימוש
input_id מזהה ה-dataset. hash של הקובץ הוא ודאי שם קובץ ו-hash
notes הערה על run חריג טקסט חופשי

מותר שהעמודות הכמותיות יישארו ריקות, אבל שומרים גם את זה שהן ריקות - זו הנקודה החשובה. “‏C++ אז אין gc_count” זה מידע, אבל אם מוחקים את העמודה כולה, אי אפשר לדעת את זה בהמשך.

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

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

98, 99, 100, 101, 101, 102, 103, 104, 106, 720

הערכים הייצוגיים שיוצאים מ-10 האלה הם:

מדד ערך קריאה
ממוצע 163.4 נגרר על ידי הפעם האחת האחרונה, וסטה כ-60% מעל התחום השגור בפועל
חציון 101.5 ערך קרוב לתחושה של 9 מתוך 10 הרצות
min / max 98 / 720 ההפרש גדול פי 7 ומעלה, סימן להתחיל לבדוק סיבה לערך חריג
p95 / p99 לא ניתן להוציא עם 10 דגימות, גם p95 וגם p99 חסרי משמעות

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

נקודת מבט טבלת תוצאות חלשה טבלת תוצאות שימושית
ערך ייצוגי רק ממוצע חציון כערך מרכזי, יחד עם min / max ופיזור
מספר הרצות לא כתוב מספר run, ומדיניות אם הוסרו ערכים חריגים
cold / warm מעורבב, או ללא הבחנה טבלה נפרדת, או שורה נפרדת
נכונות לא צוין צוין שה-checksum תואם בכל המימושים
תנאים “נמדד באותו PC” כתוב עד OS, CPU, גרסת מערכת, דגלי אופטימיזציה, מספר threads
נתונים גולמיים רק ערכי צבירה מצוין מיקום שמירת ה-CSV הגולמי

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

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

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

איור 18: הממוצע לא משקר, אבל הוא שותק לגבי קיום הערך החריג.

סיכום

מה שבאמת חשוב בהשוואת מהירות בין C#‏ / C++‏ / Java‏ / Go, זה להפוך את השאלה הגסה מי הכי מהירה לצורת ניסוי: איזה workload, באילו תנאים, ולפי איזה מדד משווים.

הנקודות שהכי קשה לטעות בהן הן:

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

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

“‏C++ מהירה”, “‏Java חזקה”, “‏Go קלה”, “גם C# מהירה מספיק” - כל אלה נכונים במידה מסוימת. אבל, אם חסר “באילו תנאים אומרים את זה”, הדיון נגמר בלי שהצדדים מבינים זה את זה.

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

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

איור 19: ניסוח מחדש של השאלה הוא באמת המסקנה החשובה ביותר במאמר הזה.

מקורות

  • BenchmarkDotNet Getting Started https://benchmarkdotnet.org/articles/guides/getting-started.html

  • BenchmarkDotNet Setup and Cleanup (התנאים שבהם מותר להשתמש ב-[IterationSetup]) https://benchmarkdotnet.org/articles/features/setup-and-cleanup.html

  • OpenJDK JMH Project https://openjdk.org/projects/code-tools/jmh/

  • JMH Level javadoc (המגבלה והאזהרה של Level.Invocation) https://javadoc.io/doc/org.openjdk.jmh/jmh-core/latest/org/openjdk/jmh/annotations/Level.html

  • JMH GitHub Repository / README https://github.com/openjdk/jmh

  • Go testing package https://pkg.go.dev/testing

  • Go benchstat https://pkg.go.dev/golang.org/x/perf/cmd/benchstat

  • Google Benchmark User Guide https://google.github.io/benchmark/user_guide.html

  • איך משווים מהירות ריצה בין גרסאות שונות של תכנית ב-Windows https://comcomponent.com/blog/2026/03/16/002-windows-benchmark-comparing-program-versions/

נושאים קשורים

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

איפה לפנות בנושא הזה

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

מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.

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

המאמר קשור ישירות לשירותים הבאים.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

מי הכי מהירה - C#,‏ C++,‏ Java או Go?
אי אפשר להכריע עם מספר אחד. הביצועים במציאות נקבעים משילוב של שפה, runtime, ספריות, תנאי בנייה, נתונים, מערכת הפעלה וחומרה. מה שחשוב הוא להפוך את השאלה 'איזו שפה הכי מהירה' לצורת ניסוי - 'איזה workload, באילו תנאים, ולפי איזה מדד משווים'. בזמן הפעלה קל לראות את עלות ה-JIT והאתחול של C#/Java, אבל ב-steady-state האופטימיזציה של ה-JIT מספיקה לרוב להדביק את הפער, ולפעמים אף להפוך אותו.
למה מפרידים cold מ-warm בבנצ'מארק של C# ו-Java?
כי C# ו-Java בדרך כלל מושפעות מ-JIT, ולכן מדידת ההרצה הראשונה מודדת לא רק את מהירות התכנית עצמה, אלא גם את עלות אתחול ה-runtime, טעינת המחלקות וההכנה של ה-JIT. לעומת זאת C++ ו-Go בדרך כלל מקומפלות מראש. ל-cold ול-warm יש שניהם משמעות, אבל לא אותה משמעות, ולכן מתייחסים ל-cold שכולל את הרגע שאחרי הפעלת התהליך ול-warm שהוא מצב יציב אחרי כמה הרצות כשני דברים נפרדים, ולא מערבבים אותם באותה טבלה.
איך צריך לתכנן בנצ'מארק חוצה שפות?
מומלץ הרכב דו-שכבתי. המדידה בתוך כל שפה נעשית עם harness מותאם לשפה - BenchmarkDotNet (‏C#‏),‏ JMH (‏Java‏),‏ go test -bench ו-benchstat (‏Go‏),‏ Google Benchmark (‏C++‏). בהשוואה חוצת שפות, מסוכן להציב את תוצאות ה-harness-ים זה לצד זה ישירות, ולכן עדיף להפוך כל מימוש לקובץ הרצה שנקרא עם אותו חוזה CLI, ולהריץ אותו מ-runner משותף חיצוני שמבצע ערבוב סדר ההרצה, הפרדת cold/warm, שימוש באותו dataset, אימות checksum, ושמירת raw data.
מה כדאי לשים לב אליו ב-microbenchmark של C++?
צריך להיזהר ממלכודת שבה העיבוד נעלם עקב אופטימיזציה. אם המהדר קובע ש'אף אחד לא משתמש בתוצאת החישוב הזו', הוא עלול למחוק את העיבוד עצמו, ואז מקבלים תוצאה שהיא לא 'מהיר' אלא 'בכלל לא עשה כלום'. לכן חשוב להשתמש בתוצאה, להוציא checksum, ולהשתמש בפיצ'רים למניעת אופטימיזציה שמספק ה-framework של הבנצ'מארק. בנוסף, ב-C++ הבדלי תנאים משפיעים ישירות וחזק, ולכן חשוב מאוד לתעד באיזה מהדר, באילו דגלים (‏-O3/O2,‏ LTO,‏ PGO וכדומה), ובאיזה STL נמדד.

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

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

קישורים ציבוריים

חזרה לבלוג