“אומרים ש-C++ מהירה” “Go קלה בשימוש בפועל” “Java די מהירה כשמריצים אותה זמן רב” “גם C# חזקה יותר משנדמה, בזכות ה-JIT של .NET”
הטענות מהסוג הזה נשמעות הרבה. אבל, הדבר הכי גרוע שאפשר לעשות כאן הוא לקחת מספרים שאנשים שונים מדדו בסביבות שונות, להציב אותם זה לצד זה, ולהחליט על סמך זה מי השפה הטובה יותר.
C# ו-Java נוטות להיות מושפעות מ-JIT ומ-warm-up, ואילו C++ ו-Go בדרך כלל מקומפלות מראש. גם קיום ה-GC ומאפייניו שונים. גם הבדלי מימוש בספריית התקן ובספריות נלוות משפיעים משמעותית. ומעבר לכל זה, גם באותה מכונה, הגדרות חשמל, חום, עיבוד ברקע, והטיה בנתוני הקלט משנים בקלות את התוצאה. זה עולם מלוכלך למדי.
flowchart TB
accTitle: הגורמים שמערבבים את התוצאה
accDescr: תרשים שמראה שהשפעת JIT ו-warm-up, הבדלי GC וספריות, והגדרות חשמל וחום ורעש והטיית קלט מצטרפים יחד וגורמים לתוצאת המדידה להיות לא יציבה בקלות.
n1["השפעת JIT ו-warm-up"] --> n4["התוצאה לא יציבה בקלות"]
n2["הבדלי GC וספריות"] --> n4
n3["חשמל, חום, רעש, הטיית קלט"] --> n4
n4 -.-> n5["לא מציבים מספרים מסביבות שונות זה לצד זה"]
איור 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, זה שבעת הדברים האלה.
-
קודם כל, קובעים מה רוצים להשוות האם זה זמן הפעלה, throughput במצב יציב, השהיית p95, או יעילות זיכרון - אופן המדידה משתנה בהתאם.
-
לא מכריעים על סמך בנצ’מארק בודד חישוב CPU, הקצאת זיכרון, עיבוד מקבילי, זמן הפעלה - הופכת השפה או ה-runtime החזקים בכל אחד מהם.
-
מפרידים cold מ-warm ב-C# וב-Java אם מערבבים השוואה שכוללת הרצה ראשונה עם השוואת מצב יציב אחרי warm-up, הדיון מתעוות.
-
מודדים עם אותו אלגוריתם, אותו קלט, ואותה בדיקת נכונות “זה לא היה מימוש מהיר, זה פשוט פתר בעיה אחרת” - זה מוקש נפוץ בבנצ’מארק.
-
מפרידים microbenchmark בתוך שפה, מבנצ’מארק end-to-end חוצה שפות ה-harness הייעודי של כל שפה נוח, אבל השוואה חוצת שפות עדיפה שתרוץ ב-runner משותף חיצוני.
-
לא מסתכלים רק על ממוצע, אלא גם על חציון והתפלגות אם GC אחד או עיבוד רקע אחד תוקעים פעם אחת, הממוצע נשבר.
-
שומרים לא רק את המספרים, אלא גם את התנאים תוצאת בנצ’מארק היא גם תיעוד מהירות וגם תיעוד תנאי הניסוי. תוצאה בלי תנאים כתובים - קשה מאוד לחזור אליה בהמשך.
מפת הידע של המאמר
המאמר מצביע על הסיכון שבהשוואת מהירות ריצה בין 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, כתנאי לפרשנות נכונה של התוצאה.
flowchart LR
accTitle: מפת הידע של בנצ'מארק חוצה שפות בין C#/C++/Java/Go
accDescr: תרשים שמראה שקומפילציית ה-JIT וה-Tiered Compilation של C# ו-Java, לעומת הקומפילציה המוקדמת של C++ ו-Go, מחייבים הבחנה בין cold ל-warm, את Google Benchmark ואימות checksum כאמצעי נגד סילוק קוד מת ב-C++, את הרכב שתי השכבות של harness ייעודי לשפה ו-runner משותף חיצוני, ואת הערכת ההתפלגות כולל אחוזוני זנב ההשהיה.
cross_language_benchmarking["תכנון מדידת ביצועים חוצת שפות"]
csharp["C#"]
jit_compilation["קומפילציית JIT (Just-In-Time)"]
java_lang["Java"]
cpp["C++"]
ahead_of_time_compilation["קומפילציית AOT (מוקדמת)"]
golang["Go"]
tiered_compilation["Tiered Compilation"]
gomaxprocs["GOMAXPROCS"]
cgo["cgo"]
benchmarkdotnet["BenchmarkDotNet"]
jmh["JMH(Java Microbenchmark Harness)"]
go_benchmark_testing["go test -bench ו-benchstat"]
google_benchmark["Google Benchmark"]
warm_up_measurement["מדידה במצב יציב אחרי חימום (warm)"]
dead_code_elimination["סילוק קוד מת (היעלמות עיבוד עקב אופטימיזציה)"]
checksum_verification["אימות תקינות באמצעות checksum"]
tail_latency_percentile["אחוזוני זנב ההשהיה (p95/p99)"]
garbage_collection["איסוף אשפה (GC)"]
csharp -.->|"משתמש ב"| jit_compilation
java_lang -.->|"משתמש ב"| jit_compilation
cpp -->|"משתמש ב"| ahead_of_time_compilation
golang -->|"משתמש ב"| ahead_of_time_compilation
csharp -->|"משתמש ב"| tiered_compilation
golang -->|"משתמש ב"| gomaxprocs
golang -.->|"משתמש ב"| cgo
csharp -.->|"משתמש ב"| benchmarkdotnet
java_lang -.->|"משתמש ב"| jmh
golang -.->|"משתמש ב"| go_benchmark_testing
cpp -.->|"משתמש ב"| google_benchmark
benchmarkdotnet -->|"משתמש ב"| warm_up_measurement
jmh -->|"משתמש ב"| warm_up_measurement
google_benchmark -.->|"מונע"| dead_code_elimination
cpp -.->|"עלול לגרום ל"| dead_code_elimination
checksum_verification -->|"מונע"| dead_code_elimination
cross_language_benchmarking -->|"משתמש ב"| tail_latency_percentile
csharp -->|"משתמש ב"| garbage_collection
java_lang -->|"משתמש ב"| garbage_collection
golang -->|"משתמש ב"| garbage_collection
cross_language_benchmarking -.->|"משתמש ב"| warm_up_measurement
cross_language_benchmarking -->|"משתמש ב"| checksum_verification
cross_language_benchmarking -->|"משתמש ב"| jit_compilation
cross_language_benchmarking -->|"משתמש ב"| ahead_of_time_compilation
בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 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, באילו תנאים, ולפי איזה מדד אפשר לעבד מהר יותר
אם אוספים מספרים כשזה נשאר עמום, בסוף לא מתקבצים לכלל תמונה.
flowchart TB
accTitle: קובעים מראש מה קוראים מהיר
accDescr: תרשים שמראה שאופן המדידה משתנה בהתאם לשאלה אם רוצים לראות זמן הפעלה, throughput במצב יציב, tail latency, או יעילות זיכרון, ולכן לפני ההשוואה קובעים איזה workload, באילו תנאים, ולפי איזה מדד בודקים.
q1["מה רוצים לדעת בהשוואה הזו"] --> a1["זמן הפעלה"]
q1 --> a2["throughput במצב יציב"]
q1 --> a3["השהיית p95 / p99"]
a3 -.-> a4["יעילות זיכרון היא ציר נפרד"]
a1 -.-> a5["אופן המדידה משתנה לפי הציר"]
איור 2: עד שלא קובעים את הגדרת “מהיר”, לא מתחילים לאסוף מספרים.
למה השוואת שפות קשה
ערבוב JIT ו-AOT הופך לניסוי שונה
C# ו-Java בדרך כלל מושפעות מ-JIT. מצד שני, C++ ו-Go בדרך כלל מקומפלות מראש.
כלומר, אם מודדים את ההרצה הראשונה, לא מודדים רק את מהירות התכנית עצמה, אלא גם את אתחול ה-runtime, טעינת המחלקות, הכנת ה-JIT. ולהפך, אם מסתכלים רק אחרי warm-up מספיק, ההשוואה הופכת ל-עד כמה האופטימיזציה במצב יציב עובדת.
לשניהם יש משמעות. אבל, הם לא אותה משמעות.
flowchart TB
accTitle: ההבדל בין קבוצת JIT לקבוצת AOT
accDescr: תרשים שמראה שב-C# וב-Java, שבדרך כלל מושפעות מ-JIT, ההרצה הראשונה מערבבת אתחול runtime, טעינת מחלקות והכנת JIT, ואילו C++ ו-Go, שבדרך כלל מקומפלות מראש, רצות ישירות - ולכן השוואת הרצה ראשונה והשוואה אחרי warm-up הן שני ניסויים שונים.
j1["C# ו-Java (בדרך כלל JIT)"] --> j2["ההרצה הראשונה מערבבת אתחול והכנת JIT"]
g1["C++ ו-Go (בדרך כלל AOT)"] --> g2["רצות ישירות אחרי קימפול מראש"]
j2 --> mix["השוואת ראשונה והשוואת מצב יציב שונות"]
g2 --> mix
איור 3: בשפות עם מודל ריצה שונה, מאיפה מודדים קובע את מהות הניסוי.
הבדל מימוש גדול יותר מהבדל שפה - זה קורה הרבה
גם ב”מיון” זהה,
- אחד משתמש בספריית התקן
- אחד במימוש עצמי
- אחד עושה העתקה מיותרת
- אחד יוצר מחדש את הקלט בכל פעם
רק זה משנה משמעותית את התוצאה.
חוץ מזה, בעיבודים כמו JSON, דחיסה, הצפנה, ביטוי רגולרי, מימוש הספרייה משפיע חזק יותר מ-השפה עצמה. לכן, אם לא מציינים במפורש מה בודקים, “השוואת שפות” הופכת ל”השוואת ספריות”.
flowchart TB
accTitle: השוואת שפות הופכת להשוואת ספריות
accDescr: תרשים שמראה שגם באותו עיבוד, ההבדל בין שימוש בספריית תקן למימוש עצמי, או העתקה מיותרת ויצירת קלט מחדש, משנה את התוצאה, ובעיבודים כמו JSON ודחיסה והצפנה וביטוי רגולרי מימוש הספרייה משפיע יותר מהשפה עצמה, ולכן בלי ציון מפורש מה נבדק, השוואת שפות הופכת להשוואת ספריות.
d1["מימושים שאמורים לעשות אותו דבר"] --> d2["הבדלי מימוש וספרייה מתערבבים"]
d2 --> d3{"צויין במפורש מה נבדק?"}
d3 -->|"לא צויין"| d4["השוואת שפות הופכת להשוואת ספריות"]
d3 -->|"צויין"| d5["אפשר לפרש כהשוואה"]
איור 4: אם רק נותנים שם נכון למה שנמדד, רוב אי-ההבנות נעלמות.
ב-C++ יש מלכודת שבה עיבוד נעלם עקב אופטימיזציה
בפרט ב-microbenchmark, אם המהדר קובע “אף אחד לא משתמש בתוצאת החישוב הזו”, הוא עלול למחוק את העיבוד. אז, מקבלים תוצאה שהיא לא מהירה, אלא לא עשתה כלום מלכתחילה. הצורה האופיינית לכך היא שכל הלולאה נעלמת וזמן הריצה מתקרב לאפס.
ב-C++ הבעיה הזו נוטה להיות בולטת במיוחד, ולכן שימוש בתוצאה, הוצאת checksum, או פיצ’ר לדיכוי אופטימיזציה מ-framework הבנצ’מארק, חשובים מאוד.
flowchart TB
accTitle: המלכודת שבה עיבוד נעלם עקב אופטימיזציה
accDescr: תרשים שמראה שב-microbenchmark, אם המהדר קובע שאף אחד לא משתמש בתוצאת החישוב, הוא מוחק את העיבוד עצמו, וזמן הריצה נראה כמעט אפס, וזה לא מהיר אלא לא עשה כלום - ולכן מונעים זאת עם checksum ופיצ'רי דיכוי אופטימיזציה.
o1["אף אחד לא משתמש בתוצאה"] --> o2["המהדר מוחק את העיבוד"]
o2 --> o3["זמן הריצה נראה כמעט אפס"]
o3 -.-> o4["זה לא מהיר, זה לא עשה כלום"]
o3 --> o5["מונעים עם checksum ופיצ'רי דיכוי"]
איור 5: תוצאה מהירה בצורה חשודה - קודם כל בודקים שהיא לא נעלמה.
קיום ה-GC הוא לא “חיסרון” ולא “יתרון”, אלא מאפיין
ל-C#, Java ו-Go יש GC. לקבוע בפשטות “יש GC ולכן איטי” זה גס מדי.
בפועל, מה שבאמת משפיע הוא:
- איך מטפלים בכמות גדולה של אובייקטים קצרי-חיים
- הגדרת גודל ה-heap
- תדירות ה-GC וה-pause שלו
- פריסת האובייקטים
- הרגלי ההקצאה של הספרייה
לעומת זאת, ב-C++ אפשר לשלוט בפירוט עם ניהול ידני ו-RAII, אבל דווקא בגלל זה קל יותר שהבדלי תכנון ומימוש יבלטו. כלומר, ההבדל בשיטת הניהול הוא לא בהכרח טוב או רע.
flowchart TB
accTitle: GC הוא לא חיסרון, אלא מאפיין
accDescr: תרשים שמראה שבשפות עם GC, אופן הטיפול באובייקטים קצרי-חיים, הגדרות heap, תדירות GC ו-pause, והרגלי הקצאה הם מה שבאמת משפיע, ואילו ב-C++ עם ניהול ידני ו-RAII אפשר לשלוט אבל קל יותר שיבלטו הבדלי מימוש, כך שהבדל שיטת הניהול הוא לא עליונות או נחיתות.
gc1["שפה עם GC"] --> gc2["הגדרות והרגלי הקצאה משפיעים"]
gp1["ניהול ידני ו-RAII ב-C++"] --> gp2["אפשר לשלוט, אבל הבדלי מימוש בולטים"]
gc2 --> gv1["הבדל שיטת ניהול הוא לא עליונות"]
gp2 --> gv1
איור 6: סידור שמונע להסתפק ב-“יש GC אז זה איטי”.
מה אסור לעשות בהשוואה
1. לערבב Debug ו-Release
זה מחוץ לדיון בכלל. מוודאים שכל הצדדים בהשוואה הם build עם אופטימיזציה ברמת ייצור.
2. לא פותרים את אותה בעיה
פורמט קלט שונה, פלט שונה, טיפול בשגיאות שקיים רק בצד אחד, מדיניות שונה לשימוש חוזר בזיכרון. אם מתעלמים מזה, מודדים לא מהירות, אלא הבדל בדרישות.
3. מסיקים מסקנה מהרצה בודדת
הרצה בודדת היא לרוב רעש.
- JIT
- מטמון עמודים
- boost של ה-CPU
- חום
- משימת רקע
- GC
- קריאת קובץ ראשונה
כל אלה מתערבבים יחד בהרצה בודדת.
flowchart TB
accTitle: מה מתערבב בהרצה בודדת
accDescr: תרשים שמראה שבהרצה בודדת מתערבבים יחד JIT וקריאה ראשונה, מטמון ו-boost של CPU, חום ועיבוד רקע, ולכן תוצאה מהרצה בודדת היא לרוב רעש.
x1["JIT וקריאה ראשונה"] --> x4["הכול מתערבב בהרצה אחת"]
x2["מטמון ו-boost של CPU"] --> x4
x3["חום ועיבוד רקע"] --> x4
x4 --> x5["תוצאה מהרצה בודדת היא לרוב רעש"]
איור 7: במספר אחד ובודד, מתערבב הכי הרבה מה שלא רוצים למדוד.
4. מערבבים warm-up
כשמודדים C# ו-Java, אם נשאר לא ברור אם כוללים את ההרצה הראשונה או מסתכלים רק אחרי warm-up, הדיון נשבר. מתייחסים ל-cold ול-warm כשני דברים שונים.
5. לא מבצעים בדיקת נכונות
בנצ’מארק צריך “להחזיר את אותה תוצאה” לפני “להיות מהיר”. מוודאים תמיד ש-אותו checksum או אותו פלט מתקבל מאותו קלט בכל המימושים שמשווים.
6. קובעים תמונת עולם מ-microbenchmark בודד
ניצחון ב-tight loop לבד לא בהכרח אומר ניצחון בשירות אמיתי שלם. ולהפך, גם אם מפסידים בזמן הפעלה, אפשר להיות חזק מספיק בהרצה ארוכה.
הגישה הבסיסית להשוואת C# / C++ / Java / Go
זו נקודה חשובה מאוד. ההמלצה היא הרכב דו-שכבתי.
flowchart TB
accTitle: מבנה שתי השכבות של ההשוואה
accDescr: תרשים שמראה שהשכבה החיצונית היא runner משותף חוצה שפות שמריץ קובצי bench לכל שפה, ומתחתיה השכבה הפנימית שבה כל שפה נחקרת לעומק עם ה-harness הייעודי שלה - Google Benchmark, BenchmarkDotNet, JMH, ו-go test -bench.
subgraph outer["השכבה החיצונית: runner משותף חוצה שפות"]
direction TB
R["runner משותף"]
R --> E1["bench - C++"]
R --> E2["bench - C#"]
R --> E3["bench - Java"]
R --> E4["bench - Go"]
end
subgraph inner["השכבה הפנימית: חקירה בתוך כל שפה"]
direction TB
H1["Google Benchmark"]
H2["BenchmarkDotNet"]
H3["JMH"]
H4["go test -bench"]
end
E1 -.->|"מדידה מעמיקה באותה שפה"| H1
E2 -.->|"מדידה מעמיקה באותה שפה"| H2
E3 -.->|"מדידה מעמיקה באותה שפה"| H3
E4 -.->|"מדידה מעמיקה באותה שפה"| H4
איור 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 של כל התהליך, ולכן נשארים גם הזמן כולל ההפעלה וגם הזמן של הגוף בלבד.
flowchart TB
accTitle: חוזה ה-CLI וחוזה הפלט
accDescr: תרשים שמראה שהופכים כל מימוש בשפה לקובץ הרצה עם אותו חוזה CLI, שמוציא רק שתי שורות - checksum ו-inner_ms, וה-runner מודד בנפרד את ה-wall-clock של כל התהליך, כך שנשארים גם הזמן כולל ההפעלה וגם הזמן של הגוף בלבד.
ct1["קובץ הרצה עם אותו חוזה CLI"] --> ct2["מוציא checksum ו-inner_ms"]
ct2 --> ct3["ה-runner מודד wall-clock כללי"]
ct3 --> ct4["נשארים גם הכללי וגם הגוף בלבד"]
איור 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 בתוך כל שפה ל-הוגנות חוצת שפות.
flowchart TB
accTitle: שלוש הכוונות של ה-runner המשותף
accDescr: תרשים שמראה שה-runner המשותף מבצע בכוונה שלושה דברים - בדיקת נכונות לפני מדידת מהירות, ערבוב סדר ה-run, וכתיבת raw data בלי צבירה - כך שאפשר להשוות באופן הוגן.
rn1["בדיקת נכונות לפני מהירות"] --> rn4["מדידה שאפשר להשוות בהוגנות"]
rn2["ערבוב בכל run"] --> rn4
rn3["כתיבת raw data בלי צבירה"] --> rn4
rn2 -.-> rn5["מאזן חום והטיית זמן"]
איור 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. מנקודת מבט הפוכה, גם אם יש הבדל כאן, זה נפרד מהניצחון בעיבוד ארוך.
flowchart TB
accTitle: 4 פריטי בנצ'מארק עם אופי שונה
accDescr: תרשים שמראה שהמיון מגלה CPU ורוחב פס וזיכרון זמני, ספירת מילים מגלה hash ומחרוזות והקצאות, ה-hash המקבילי מגלה קצב גידול בעיבוד מקבילי, ובנצ'מארק ההפעלה מגלה זמן הפעלה ועלות אתחול.
w1["sort_int32_10m"] -.-> v1["CPU, רוחב פס, שטח זמני"]
w2["hash_group_count"] -.-> v2["מחרוזות, map, מגמת GC"]
w3["parallel_sha256"] -.-> v3["קצב גידול בעיבוד מקבילי"]
w4["startup"] -.-> v4["עלות הפעלה ואתחול"]
w1 --> w2
w2 --> w3
w3 --> w4
איור 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, ולכן חובה לשמור את זה כתנאי.
איך מיישרים את סביבת ההרצה
בכל שפה, השוואה בלי יישור סביבה היא בעצם בעיקר השוואת סביבות.
flowchart TB
accTitle: מה זה בעצם השוואה בלי יישור סביבה
accDescr: תרשים שמראה שבשתי מדידות עם סביבה לא מיושרת - CPU, מערכת הפעלה, תנאי חשמל, נתוני קלט, עדיפות ומספר ליבות - הפרש בתוצאה עשוי להיות הבדל סביבה ולא הבדל שפה.
en1["שתי מדידות בסביבה לא מיושרת"] --> en2["מופיע הפרש בתוצאה"]
en2 --> en3{"ההפרש הזה הוא הפרש של מה?"}
en3 -->|"הסביבה מיושרת"| en4["אפשר לקרוא כהפרש מימוש או שפה"]
en3 -->|"לא מיושרת"| en5["פשוט משווים סביבות"]
איור 12: אפשר לתלות את ההפרש בשפה רק אחרי שמסירים את הפרש הסביבה.
מה שכדאי ליישר
- אותו CPU / זיכרון / אחסון
- אותה גרסת מערכת הפעלה
- אותם תנאי חשמל
- תנאים קרובים לאותה טמפרטורת חדר
- אותם נתוני קלט
- אותה עדיפות תהליך
- אותם תנאי מספר ליבות
- אותם תנאי container או bare metal
מה שמשפיע במיוחד
הגדרות חשמל ותדר CPU
במחשב נייד, גם רק חיבור ל-AC לעומת סוללה הופך לעולם שונה לגמרי. אם ה-CPU governor או power mode לא מיושרים, תוצאות ההשוואה מתנודדות משמעותית.
לגבי תנאי חשמל, התראות, רעש ברקע, חום, ואופן יישור סדר ההרצה ב-Windows, יש סידור מפורט במאמר נפרד - איך משווים מהירות ריצה בין גרסאות שונות של תכנית ב-Windows. אם מודדים ב-Windows, זה חלק שמשפיע מאוד.
חום
אם רק כמה הרצות ראשונות מהירות ומאוחר יותר יורדות, חושדים בחום או ב-throttling. במקום להריץ את כל A ואז את כל B, הרצה לסירוגין כמו A / B / A / B מפחיתה הטיה.
flowchart TB
accTitle: הטיית חום וסדר ההרצה
accDescr: תרשים שמראה שאם רק כמה הרצות ראשונות מהירות והתוצאה יורדת בהמשך, חושדים בחום או ב-throttling, ובמקום להריץ את כל A ואז את כל B, מריצים לסירוגין כדי להפחית הטיה.
th1["רק ההתחלה מהירה, יורד בהמשך"] --> th2["חושדים בחום או throttling"]
th2 --> th3["לא מריצים את כל A ואז את כל B"]
th3 --> th4["מריצים 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
- סטיית תקן ופיזור
אם מדברים רק על ממוצע, לא רואים את המהות של עיבוד שמדי פעם קופץ.
flowchart TB
accTitle: 4 מדדים שמפרידים ביניהם
accDescr: תרשים שמראה שבהשוואת שפות מפרידים בין wall-clock שהמשתמש ממתין לו, CPU time שבפועל נוצל, זיכרון והקצאות, והתפלגות כמו חציון ואחוזונים - ומסתכלים על ארבעתם בנפרד.
ms1["wall-clock time"] --> ms5["מסתכלים על 4 מדדים בנפרד"]
ms2["CPU time"] --> ms5
ms3["זיכרון והקצאות"] --> ms5
ms5 -.-> ms4["התפלגות (חציון, p95) גם בנפרד"]
איור 14: מספר המהירות הוא לא סוג אחד - קוראים אותו נכון רק כשמסדרים גם את העלות שמאחוריו.
סדר ההרצה המומלץ
הזרימה שנוחה בפועל היא בערך בסדר הזה.
flowchart TB
accTitle: סדר ההרצה המומלץ מקצה לקצה
accDescr: תרשים שמראה את סדר ההרצה - קביעת workload, קיבוע data set משותף, מעבר בדיקת נכונות, קיבוע תנאי build, הפרדת cold ו-warm, הרצה בסדר אקראי, שמירת raw data, ובדיקה אם יש הפרש משמעותי שמצדיק חפירה עם profile.
a1["1. קביעת workload"] --> a2["2. קיבוע data set משותף"]
a2 --> a3["3. בדיקת נכונות תחילה"]
a3 --> a4{"כל היישומים נתנו checksum זהה?"}
a4 -- "לא" --> a3
a4 -- "כן" --> a5["4. קיבוע תנאי build"]
a5 --> a6["5. הפרדת cold ו-warm"]
a6 --> a7["6. הרצה בסדר אקראי"]
a7 --> a8{"הגענו למספר ההרצות הנדרש?"}
a8 -- "לא" --> a7
a8 -- "כן" --> a9["8. שמירת raw data"]
a9 --> a10{"יש הפרש משמעותי?"}
a10 -- "לא" --> a11["מתעדים תנאים ומספר, ומסיימים"]
a10 -- "כן" --> a12["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: מצב יציב אחרי כמה הרצות
אם מציירים בתרשים עד היכן מודדים, ברור מיד ששני אלה הם דברים שונים.
flowchart LR
accTitle: הטווח שנמדד כ-cold וכ-warm
accDescr: תרשים שמראה שהטווח הנמדד כ-cold כולל הפעלת תהליך, אתחול runtime וטעינת מחלקות, קומפילציית JIT ראשונה, ועיבוד ראשון, ואילו הטווח הנמדד כ-warm הוא רק עיבוד במצב יציב אחרי שה-Tiered Compilation התקדם.
subgraph coldrange["הטווח הנמדד כ-cold"]
direction LR
s1["הפעלת תהליך"] --> s2["אתחול runtime, טעינת מחלקות"]
s2 --> s3["קומפילציית JIT ראשונה"] --> s4["עיבוד גוף 1"]
end
s4 --> s5["עיבוד גוף 2 ואילך, Tiered Compilation מתקדם"]
subgraph warmrange["הטווח הנמדד כ-warm"]
direction LR
s6["עיבוד גוף במצב יציב"]
end
s5 --> s6
איור 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
- העתקה מיותרת
flowchart TB
accTitle: איך קוראים כשיש הפרש
accDescr: תרשים שמראה שאם רק בהרצה ראשונה C# או Java איטיות, חושדים בהשפעת JIT ואתחול, ומטפלים בזה כהפרש בעל משמעות אם זמן ההפעלה חשוב, או כהפרש שמפרידים לטבלה נפרדת אם הרצה ארוכה היא הנושא, ואם יש הפרש חופרים סיבה עם profile.
rd1["הופיע הפרש"] --> rd2{"תחת איזה תנאי?"}
rd2 -->|"הפרש כולל הפעלה"| rd3["בעל משמעות אם זמן הפעלה חשוב"]
rd2 -->|"הפרש במצב יציב"| rd4["מטפלים כהשוואת הרצה ארוכה"]
rd3 --> rd5["חופרים סיבה עם profile"]
rd4 --> rd5
איור 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. כדי לדבר על התפלגות, צריך מספיק דגימות שבהן אפשר לראות התפלגות - זה כל הסיפור.
בנצ’מארק, לעיתים היכולת לפרש בדיעבד חשובה יותר מהמדידה עצמה.
flowchart TB
accTitle: מה מסתיר ממוצע יחיד
accDescr: תרשים שמראה שאם יש ערך חריג גדול פעם אחת, הממוצע סוטה מעל התחום השגור, החציון קרוב יותר לתחושה, וההפרש בין min ל-max הוא סימן לבדוק את הסיבה לערך החריג - ולכן טבלה שמסתמכת רק על ממוצע מסוכנת.
av1["מופיע ערך חריג פעם אחת"] --> av2["הממוצע סוטה למעלה"]
av2 --> av3["טבלת ממוצע בלבד מסתירה את המציאות"]
av3 --> av4["מציינים חציון, min ו-max יחד"]
av4 -.-> av5["ערך חריג הוא סימן לבדוק סיבה"]
איור 18: הממוצע לא משקר, אבל הוא שותק לגבי קיום הערך החריג.
סיכום
מה שבאמת חשוב בהשוואת מהירות בין C# / C++ / Java / Go, זה להפוך את השאלה הגסה מי הכי מהירה לצורת ניסוי: איזה workload, באילו תנאים, ולפי איזה מדד משווים.
הנקודות שהכי קשה לטעות בהן הן:
- מפרידים זמן הפעלה ממצב יציב
- מודדים עם אותו אלגוריתם, אותו קלט, ואותה בדיקת נכונות
- לא מסיקים מסקנה מבנצ’מארק בודד
- מפרידים בין benchmark בתוך שפה ל-benchmark חוצה שפות
- מסתכלים על חציון והתפלגות, לא רק ממוצע
- שומרים תנאים ו-raw data
והדבר הכי חשוב בסוף הוא לא לנסות יותר מדי להכריע ניצחון לפי שם השפה. הביצועים במציאות נקבעים משילוב של שפה, runtime, ספריות, תנאי בנייה, נתונים, מערכת הפעלה וחומרה.
“C++ מהירה”, “Java חזקה”, “Go קלה”, “גם C# מהירה מספיק” - כל אלה נכונים במידה מסוימת. אבל, אם חסר “באילו תנאים אומרים את זה”, הדיון נגמר בלי שהצדדים מבינים זה את זה.
מיישרים תנאים, עם כמה workload, מפרידים cold / warm, ומסתכלים גם על ההתפלגות. זה טכני מאוד, אבל בסופו של דבר זה הכי חזק.
flowchart TB
accTitle: הופכים שאלה גסה לצורת ניסוי
accDescr: תרשים שמראה שמהפכים את השאלה הגסה מי הכי מהירה לצורת ניסוי של איזה workload באילו תנאים ולפי איזה מדד משווים, ומיישרים תנאים עם כמה workload, מפרידים cold ו-warm, ומסתכלים גם על ההתפלגות - וזה הכי חזק בסוף.
sq1["מי הכי מהירה"] --> sq2["מנסחים מחדש כניסוי"]
sq2 --> sq3["קובעים workload, תנאים ומדד"]
sq3 --> sq4["מריצים בהפרדת cold ו-warm"]
sq4 --> sq5["מסתכלים על ההתפלגות ושומרים תנאים"]
איור 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
Leveljavadoc (המגבלה והאזהרה של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
testingpackage https://pkg.go.dev/testing -
Go
benchstathttps://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/
נושאים קשורים
עמודים שמומלץ לראות יחד עם המאמר הזה, כדי להבין טוב יותר.
איפה לפנות בנושא הזה
תכנון השוואת ביצועים, יישור תנאי מדידה, פרשנות תוצאות, וחפירת סיבה - כל אלה נושאים שמתאימים לשירותים הבאים.
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
המלכודות של הזיכרון המשותף ושיטות העבודה המומלצות בפועל
המאמר מסכם את המלכודות בשימוש בזיכרון משותף בעבודה בפועל, ואת התכנון שמוריד את שיעור התקלות - כולל סנכרון, נראות (visibility), אורך חיים,...
איך משווים נכון מהירות בין גרסאות תוכנה ב-Windows
מסדרים נוהל בעל שחזוריות גבוהה להשוואת הבדלי גרסה ב-Windows, כולל power mode, power plan, חום, רעש ברקע, מדדי מדידה ועד ETW / WPR.
המלכודות של יישום תקשורת טורית - עד לתכנון חיבור מחדש ויומן
המאמר מסדר, מנקודת מבט מעשית, את המלכודות שכדאי להימנע מהן ביישום תקשורת טורית לחיבור ציוד ובקרת מכשירי מדידה - מבניית מסגרות, timeout, ...
איך בוחרים בין WinForms, WPF ו-WinUI - טבלת החלטה מהשטח
המאמר מסדר את הבחירה בין WinForms, WPF ו-WinUI מנקודות המבט של פיתוח חדש, נכסים קיימים, הפצה, ביטוי ה-UI ומבנה הצוות.
צריבת תמונה וטקסט על MP4 עם Media Foundation
המאמר מסדר את הגישה לצריבת תמונה וטקסט על כל מסגרת בסרטון MP4 ויצירת MP4 חדש עם Media Foundation - חלוקת התפקידים בין Source Reader, ציור...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
ייעוץ טכני וסקירת תכנון
תכנון השוואת ביצועים, איך משווים תנאי מדידה, ואיך קוראים warm-up וסטטיסטיקה - נושא שמתאים היטב לייעוץ טכני ולסקירת תכנון.
חקירת תקלות ואיתור גורמים
בידוד הסיבה להפרשי ביצועים בין שפות וגרסאות, איתור צווארי בקבוק, ואימות תקינות הליך המדידה - קל להתקדם בזה כחקירת תקלות וניתוח שורש.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- מי הכי מהירה - 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 נמדד.