השוואת ביצועים הוגנת בין C#, C++, Java ו-Go
· עודכן בתאריך: · Go Komura · Benchmark, Performance, C#, C++, Java, Go
היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 17 Mar 2026)
- פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22173598)
מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.
Go Komura (2026). השוואת ביצועים הוגנת בין C#, C++, Java ו-Go. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173598 https://comcomponent.com/he/blog/language-benchmark-csharp-cpp-java-go/
- DOI (הגרסה האחרונה)
- 10.5281/zenodo.22173598
- DOI (הגרסה הזו)
- 10.5281/zenodo.22173599
“אומרים ש-C++ מהירה” “Go קלה ב-production” “Java די מהירה כשמריצים אותה הרבה זמן” “גם C# חזקה יותר משנדמה, בזכות ה-JIT של .NET”
הטענות האלה חוזרות הרבה. הדבר הכי גרוע שאפשר לעשות איתן: לקחת מספרים שאנשים שונים מדדו בסביבות שונות, לשים אותם זה ליד זה, ולהכריע מי השפה הטובה יותר.
C# ו-Java מושפעות בקלות מ-JIT ומ-warm-up. C++ ו-Go בדרך כלל compiled מראש. גם ה-GC שונה — יש או אין, ואיך הוא מתנהג. גם מימוש ספריית התקן והספריות סביב משפיע חזק. ומעבר לזה, גם על אותה מכונה power settings, חום, עיבוד ברקע והטיה בנתוני הקלט מזיזים את התוצאה בלי מאמץ. זה עולם מלוכלך.
flowchart TB
accTitle: מה מזיז את התוצאה
accDescr: JIT ו-warm-up, הבדלי GC וספריות, ו-power/חום/רעש/הטיית קלט מצטרפים יחד, ולכן תוצאת המדידה זזה בקלות.
n1["השפעת JIT ו-warm-up"] --> n4["התוצאה זזה בקלות"]
n2["הבדלי GC וספריות"] --> n4
n3["חשמל, חום, רעש, הטיית קלט"] --> n4
n4 -.-> n5["לא שמים מספרים מסביבות שונות זה ליד זה"]
איור 1: יש יותר מדי מקורות לרעש, ולכן השוואה בין מספרים של אנשים אחרים פשוט לא מחזיקה.
כאן נעבור על דרך מדידה שמשווה בהגינות ככל האפשר בין C# / C++ / Java / Go. המסקנה מראש: לא מנסים להכריע “איזו שפה הכי מהירה” עם מספר אחד.
הנושא הוא איך בונים את ההשוואה. מספרים תלויי-סביבה מתהפכים ברגע שהתנאים משתנים. לכן אין כאן דירוג מדידות בפועל. במקום זה נתמקד באיך לתכנן כדי שלהשוואה יהיה ערך.
למי זה מיועד
המאמר מיועד למפתחים ו-tech leads שיש להם כמה שפות מועמדות ורוצים להחליט לפי ביצועים באיזו לממש, וגם למי שרוצה מדידה שאפשר להגיד עליה “השווינו מהירות” בתוך החברה או בדוח. זה לא העמקה בשפה אחת, אלא תכנון השוואה חוצת ארבע שפות — גם מי שנוגע רק בשפה אחת יכול לקרוא.
דוגמאות הקוד: ה-runner המשותף ב-PowerShell, וה-harness בתוך השפה — BenchmarkDotNet של C# ו-JMH של Java. ל-C++ ול-Go נציין רק איך מקבעים תנאים.
מונחים שכדאי להכיר מראש
בגוף המאמר מופיעים כמה מונחים באנגלית כמו שהם. כדי לא להיתקע בהופעה הראשונה, מרכזים אותם כאן.
| מונח | משמעות |
|---|---|
| p95 / p99 | percentile. כשמסדרים את כל ה-runs מהמהיר לאיטי, זה הערך ב-95% / 99% מהתחתית. “5 מתוך 100 פעמים איטי מזה” זה p95 |
| RSS (Resident Set Size) | כמה זיכרון פיזי התהליך מחזיק עכשיו. לא כמות ה-virtual memory שהוקצתה, אלא כמה RAM הוא תופס בפועל |
| LTO (Link Time Optimization) | אופטימיזציה חוצת translation units בזמן ה-link. -flto של GCC / Clang, ו-/GL עם /LTCG של MSVC |
| PGO (Profile-Guided Optimization) | פרופיל של branches וקריאות מהרצה אחת נכנס להחלטות אופטימיזציה ב-build הבא |
| Tiered Compilation | ה-JIT של .NET קודם מריץ קוד שאפשר לקמפל מהר, ורק methods חמות מקמפל מחדש אחר כך. אחד הגורמים העיקריים לפער בין cold ל-warm |
| Server GC / Workstation GC | מצבי GC ב-.NET. Server GC מחזיק heap ו-GC threads לכל logical processor ונוטה ל-throughput; Workstation GC נוטה ל-responsiveness |
| GOMAXPROCS | תקרת מספר ה-OS threads שבהם ה-runtime של Go מריץ קוד Go במקביל. ב-benchmark מקבילי, בלי לקבע את זה אי אפשר להשוות |
| cgo | המנגנון שבו Go קוראת לקוד C. הפעלה משנה עלות קריאה, תנאי build, ואפשרות static linking |
קודם המסקנה
בהשוואת מהירות בין C# / C++ / Java / Go, מה שבאמת עובד אלה שבע הנקודות האלה.
-
קודם מחליטים מה רוצים להשוות startup time, throughput ב-steady-state, השהיית p95, או יעילות זיכרון — אופן המדידה משתנה בהתאם.
-
לא מכריעים לפי benchmark בודד חישוב CPU, הקצאות זיכרון, parallelism, startup — בכל אחד מהם נראית שפה או runtime אחרת כחזקה.
-
ב-C# וב-Java מפרידים cold מ-warm אם מערבבים השוואה שכוללת הרצה ראשונה עם השוואת steady-state אחרי warm-up, הדיון מתעוות.
-
מודדים עם אותו אלגוריתם, אותו קלט, ואותה בדיקת נכונות “זה לא היה מימוש מהיר — זה פשוט פתר בעיה אחרת” הוא מלכודת קלאסית ב-benchmark.
-
מפרידים microbenchmark בתוך שפה מ-end-to-end חוצה שפות ה-harness הייעודי של כל שפה נוח, אבל השוואה חוצת שפות עדיף להריץ מ-runner משותף מבחוץ.
-
לא מסתכלים רק על ממוצע — גם על חציון ועל התפלגות GC אחד או עיבוד רקע אחד שתוקע פעם אחת שובר את הממוצע.
-
שומרים לא רק מספרים, אלא גם תנאים תוצאת benchmark היא גם תיעוד מהירות וגם תיעוד תנאי הניסוי. תוצאה בלי תנאים כתובים קשה מאוד לחזור אליה.
ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 24, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle
מה מחליטים קודם
אם מסתפקים במילה “מהיר”, לרוב זה נגמר רע. קודם מגדירים מה נחשב מהיר.
גם באותה תכנית, מה שרוצים לראות יכול להיות שונה לגמרי.
1. startup time?
בכלי CLI, batch קצר-חיים, או כלי עזר שעולה פעם אחת ויוצא מיד — cold start ו-process startup הם מה שקובע. בציר הזה התוצאה משתנה מאוד לפי השאלה אם כוללים את עלות אתחול ה-JIT ו-class loading.
2. throughput בהרצה ארוכה?
בשרת, process resident, worker, או עיבוד המרה שרץ זמן רב — throughput ב-steady-state הוא מה שחשוב. כאן איטיות בהרצה הראשונה לבדה היא לא העניין. העניין הוא עד כמה זה נשאר יציב ומהיר אחרי warm-up.
3. tail latency?
ב-API, UI, או עיבוד קרוב ל-realtime, לפעמים p95 / p99 חשובים יותר מהממוצע. גם אם הממוצע מהיר, עצירה גדולה מדי פעם כואבת ב-UX וב-SLA.
4. גם יעילות זיכרון?
לא רק CPU time. בלי RSS מקסימלי, כמות הקצאות, מספר GC, GC pause קל לטעות במשקל האמיתי ב-production. “מהיר אבל אוכל הרבה זיכרון” מול “קצת איטי אבל יציב וקל” — ההערכה מתהפכת לפי השימוש.
בקיצור, השאלה הראשונה היא לא
מי השפה המהירה בהשוואה הזו, אלא איזה workload, באילו תנאים, ולפי איזה מדד רץ מהר
אם אוספים מספרים בלי זה, בסוף אין מה לסכם.
flowchart TB
accTitle: קודם מגדירים מה נחשב מהיר
accDescr: startup, throughput ב-steady-state, tail latency ויעילות זיכרון דורשים מדידות שונות, ולכן לפני ההשוואה מחליטים איזה workload, באילו תנאים ולפי איזה מדד.
q1["מה רוצים לדעת מההשוואה הזו"] --> a1["startup time"]
q1 --> a2["throughput יציב"]
q1 --> a3["השהיית p95 / p99"]
a3 -.-> a4["יעילות זיכרון היא ציר נפרד"]
a1 -.-> a5["לכל ציר יש דרך מדידה אחרת"]
איור 2: עד שאין הגדרה ל”מהיר”, לא מתחילים לאסוף מספרים.
למה השוואת שפות קשה
לערבב JIT עם AOT זה ניסוי אחר
C# ו-Java בדרך כלל מושפעות מ-JIT. C++ ו-Go בדרך כלל compiled מראש.
כלומר: מדידת ההרצה הראשונה מודדת לא רק את מהירות התכנית, אלא גם אתחול runtime, class loading והכנה של ה-JIT. ולהפך: אם מסתכלים רק אחרי warm-up מספיק, זו כבר השוואה של עד כמה אופטימיזציית steady-state עובדת.
לשניהם יש משמעות. אבל זו לא אותה משמעות.
flowchart TB
accTitle: ההבדל בין קבוצת JIT לקבוצת AOT
accDescr: C# ו-Java בדרך כלל עם JIT, אז בהרצה הראשונה מתערבבים אתחול runtime, class loading והכנה של JIT; C++ ו-Go בדרך כלל AOT. לכן השוואת הרצה ראשונה והשוואת steady-state הן לא אותו ניסוי.
j1["C# ו-Java (בדרך כלל JIT)"] --> j2["בהרצה הראשונה מתערבבים אתחול ו-JIT"]
g1["C++ ו-Go (בדרך כלל AOT)"] --> g2["רצים compiled מראש"]
j2 --> mix["השוואת הרצה ראשונה והשוואת steady-state הן ניסויים שונים"]
g2 --> mix
איור 3: כשמודל הריצה שונה, מאיפה מתחילים למדוד קובע את תוכן הניסוי.
הבדלי מימוש לרוב גדולים מהבדלי שפה
גם באותו “sort”:
- צד אחד משתמש בספריית התקן
- צד אחד מממש לבד
- צד אחד עושה copy מיותר
- צד אחד מייצר את הקלט מחדש בכל פעם
רק זה מזיז את התוצאה חזק.
ומעבר לזה, בעיבודים כמו JSON, דחיסה, הצפנה, regex — מימוש הספרייה משפיע יותר מ-השפה עצמה. אם לא כותבים במפורש מה נמדד, “השוואת שפות” הופכת ל”השוואת ספריות”.
flowchart TB
accTitle: השוואת שפות מחליקה להשוואת ספריות
accDescr: גם באותו עיבוד, ספריית תקן מול מימוש עצמי, copy מיותר או יצירת קלט מחדש מזיזים את התוצאה. ב-JSON, דחיסה, הצפנה ו-regex הספרייה משפיעה יותר מהשפה. בלי לציין מה נמדד, זו כבר לא השוואת שפות.
d1["מימושים שאמורים לעשות אותו דבר"] --> d2["הבדלי מימוש וספרייה מתערבבים"]
d2 --> d3{"צוין במפורש מה נמדד?"}
d3 -->|"לא"| d4["השוואת שפות שהפכה להשוואת ספריות"]
d3 -->|"כן"| d5["אפשר לפרש כהשוואה"]
איור 4: שם נכון למה שנמדד מוחק כבר את רוב אי-ההבנות.
ב-C++ העבודה עלולה להיעלם באופטימיזציה
במיוחד ב-microbenchmark: אם ה-compiler מחליט שאף אחד לא צורך את תוצאת החישוב, הוא עלול למחוק את העבודה. אז מקבלים תוצאה שהיא לא מהירה — פשוט לא עשתה כלום. הסימן הקלאסי: הלולאה כולה נעלמת וזמן הריצה מתקרב לאפס.
ב-C++ זה בולט במיוחד. לכן צריכת התוצאה, הוצאת checksum, או DoNotOptimize של ה-framework חשובים מאוד.
flowchart TB
accTitle: מלכודת dead code elimination
accDescr: ב-microbenchmark, אם אף אחד לא צורך את תוצאת החישוב, ה-compiler עלול למחוק את העבודה. זמן הריצה נראה כמעט אפס — לא מהיר, אלא לא רץ. מונעים את זה עם checksum ועם DoNotOptimize.
o1["אף אחד לא צורך את התוצאה"] --> o2["ה-compiler מוחק את העבודה"]
o2 --> o3["זמן הריצה נראה כמעט אפס"]
o3 -.-> o4["זה לא מהיר, זה לא רץ"]
o3 --> o5["מונעים עם checksum ו-DoNotOptimize"]
איור 5: תוצאה חשודה-מהירה — קודם בודקים שהעבודה לא נמחקה.
GC הוא לא חיסרון ולא יתרון. זה מאפיין
ל-C#, Java ו-Go יש GC. לקבוע “יש GC ולכן זה איטי” זה גס מדי.
בפועל מה שקובע הוא:
- איך מטפלים בהרבה אובייקטים קצרי-חיים
- הגדרת גודל ה-heap
- תדירות ה-GC וה-pause
- object layout
- הרגלי ההקצאה של הספרייה
ב-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
זה מחוץ לדיון. כל הצדדים בהשוואה חייבים להיות optimized build ברמת production.
2. לא פותרים את אותה בעיה
פורמט קלט שונה, פלט שונה, טיפול בשגיאות שקיים רק בצד אחד, מדיניות שונה לשימוש חוזר בזיכרון. אם מתעלמים מזה, מודדים לא מהירות, אלא הבדל בדרישות.
3. להסיק מסקנה מהרצה אחת
הרצה אחת היא בדרך כלל רעש.
- JIT
- page cache
- CPU boost
- חום
- משימות רקע
- GC
- קריאת קובץ ראשונה
כל אלה מתערבבים בהרצה אחת.
flowchart TB
accTitle: מה מתערבב בהרצה אחת
accDescr: בהרצה אחת מתערבבים JIT וקריאה ראשונה, cache ו-CPU boost, חום ועיבוד רקע — ולכן תוצאה מהרצה אחת היא בדרך כלל רעש.
x1["JIT וקריאה ראשונה"] --> x4["הכול מתערבב בהרצה אחת"]
x2["cache ו-CPU boost"] --> x4
x3["חום ועיבוד רקע"] --> x4
x4 --> x5["תוצאה מהרצה אחת היא בדרך כלל רעש"]
איור 7: במספר אחד בודד נכנס הכי הרבה ממה שלא רציתם למדוד.
4. לערבב warm-up
כשמודדים C# ו-Java, אם לא ברור אם כוללים את ההרצה הראשונה או רק אחרי warm-up, הדיון נשבר. cold ו-warm הם שני דברים שונים.
5. בלי בדיקת נכונות
ב-benchmark, “אותה תוצאה” באה לפני “מהיר”. מוודאים ש-אותו checksum או אותו פלט יוצא מאותו קלט בכל המימושים.
6. לקבוע תמונת עולם מ-microbenchmark אחד
ניצחון ב-tight loop לא אומר ניצחון בשירות אמיתי. ולהפך: הפסד ב-startup לא אומר חולשה בהרצה ארוכה.
הגישה הבסיסית להשוואת C# / C++ / Java / Go
זו נקודה חשובה. ההמלצה: שתי שכבות.
flowchart TB
subgraph outer["השכבה החיצונית: runner משותף חוצה שפות"]
direction TB
R["runner משותף<br/>ערבוב סדר הרצה / הפרדת cold ו-warm<br/>אימות checksum / שמירת raw data"]
R --> E1["קובץ bench<br/>C++"]
R --> E2["קובץ bench<br/>C#"]
R --> E3["קובץ bench<br/>Java"]
R --> E4["קובץ bench<br/>Go"]
end
subgraph inner["השכבה הפנימית: חקירה בתוך השפה"]
direction TB
H1["Google Benchmark"]
H2["BenchmarkDotNet"]
H3["JMH"]
H4["go test -bench ו-benchstat"]
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; // מחזירים את התוצאה כדי שה-optimizer לא ימחק אותה
}
}
יש מגבלה ל-[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 צורך את הערך המוחזר, לכן ה-optimizer לא מוחק
}
}
ל-Level.Invocation יש מגבלה דומה. ה-javadoc של JMH כותב במפורש שהרמה הזו מתאימה רק ל-benchmark שבו קריאה בודדת ל-@Benchmark לוקחת יותר ממילישנייה. כי מודדים timestamp בכל קריאה, ובעיבוד קצר המדידה עצמה הופכת לצוואר בקבוק.
הערכים של @Warmup / @Measurement / @Fork ב-BenchmarkDotNet וב-JMH הם עצמם תנאי הניסוי. שומרים אותם יחד עם התוצאה.
2. בהשוואה חוצת שפות — runner משותף מבחוץ
לשים את תוצאת BenchmarkDotNet של C# ליד את תוצאת JMH של Java כמו שהן — קצת מסוכן. כי ה-harness עצמו עובד אחרת.
לכן בהשוואה חוצת שפות עדיף שכל מימוש יהיה executable עם אותו CLI contract, וש lap אותו מבחוץ באותם תנאים.
למשל, בכל שפה מכינים קובץ הרצה כזה:
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
גם צד הפלט הוא contract. אם כל מימוש בכל שפה מדפיס רק שתי שורות ל-stdout, הניתוח ב-runner מסתפק ב-regex אחד.
checksum=[16進の文字列]
inner_ms=[小数のミリ秒]
checksum לבדיקת נכונות, inner_ms הוא הזמן שהמימוש מדד לעיבוד עצמו. ה-runner מודד בנפרד wall-clock של כל ה-process, כך שנשארים גם הזמן כולל startup וגם הזמן של העבודה עצמה.
flowchart TB
accTitle: CLI contract וחוזה פלט
accDescr: כל מימוש הוא executable עם אותו CLI contract ומוציא רק checksum ו-inner_ms. ה-runner מודד wall-clock של כל ה-process, כך שנשארים גם זמן כולל startup וגם זמן העבודה עצמה.
ct1["executable עם אותו CLI contract"] --> ct2["מדפיס checksum ו-inner_ms"]
ct2 --> ct3["ה-runner מודד wall-clock כללי"]
ct3 --> ct4["נשארים גם הכולל וגם העבודה עצמה"]
איור 9: קריאה ופלט כ-contract מאפשרים להריץ ארבע שפות על אותה זירה.
ואז ב-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) 行"
יש כאן שלושה דברים שנעשים במכוון.
- בדיקת נכונות לפני מדידת מהירות. checksum לא תואם — אין טעם להשוות מהירות
- ערבוב בכל 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 הוא לא להריץ מהר. הוא להוריד ספקות.
דוגמה: אילו פריטי benchmark כדאי להכין
כשאומרים “רוצים להשוות C# / C++ / Java / Go”, אם זה פריט אחד — CPU פשוט שקשה לפרש לא נכון. אם כמה — 3–4 פריטים עם workload בעל אופי שונה.
ההרכב המומלץ
1. sort_int32_10m
מטרה: CPU + רוחב פס זיכרון + שימוש בשטח זמני
- קלט: 10 מיליון
int32עם seed קבוע - עיבוד: ממיינים מערך ומחזירים checksum
- שימו לב: בכל פעם חוזרים לאותו קלט לא ממוין
זה יחסית קל להבנה. אבל זה כולל גם הבדל במימוש ה-sort הסטנדרטי, אז זו יותר השוואה כולל ספריית תקן מאשר השפה עצמה.
2. hash_group_count
מטרה: hash table, עיבוד מחרוזות, הקצאות, מגמת GC
- קלט: נתוני טקסט קבועים
- עיבוד: סופרים הופעות לכל מילה
- פלט: N העליונים ו-checksum
זה קרוב יותר לשטח, אבל גם הבדלי ספריית מחרוזות ומימוש map משפיעים חזק. בגלל זה זו השוואה קרובה יותר למציאות.
3. parallel_sha256
מטרה: parallelism, scheduler, worker pool, הרגלי סנכרון
- קלט: רצף chunks בגודל קבוע
- עיבוד: hash עם N threads לפי הסדר, checksum סופי
- תנאי: מדרגים threads כמו 1 / 2 / 4 / 8
קל יותר לראות איך זה scale בריצה מקבילית, בהשוואה ל-tight loop פשוט.
4. startup_noop או startup_parse_small
מטרה: startup time
noop: עולה ויוצא מידparse_small: מעבד קלט קטן פעם אחת ויוצא
כאן רואים בקלות את עלות ה-JIT והאתחול של C# / Java, וזה שונה מאוד מ-C++ / Go. ולהפך: גם אם יש פער כאן, הוא נפרד מניצחון בעיבוד ארוך.
flowchart TB
accTitle: ארבעה פריטי benchmark עם אופי שונה
accDescr: sort מראה CPU ורוחב פס ושטח זמני, ספירת מילים מראה hash ומחרוזות והקצאות, hash מקבילי מראה scale, ו-startup מראה עלות הפעלה ואתחול.
w1["sort_int32_10m"] -.-> v1["CPU, רוחב פס, שטח זמני"]
w2["hash_group_count"] -.-> v2["מחרוזות, map, מגמת GC"]
w3["parallel_sha256"] -.-> v3["scale בריצה מקבילית"]
w4["startup"] -.-> v4["עלות הפעלה ואתחול"]
w1 --> w2
w2 --> w3
w3 --> w4
איור 11: פריט אחד לא מראה הכול, לכן מחלקים לפי מה שרוצים לראות.
מה עושים עם benchmark של JSON או HTTP
JSON ו-HTTP קרובים לשטח, אז יש בהם משמעות. אבל אז זו יותר השוואה שכוללת ספרייה, framework ואקוסיסטם מאשר השוואת שפות.
זה כשלעצמו לא רע. בשטח לפעמים זה חשוב יותר. רק שבמאמר או בדוח כותבים במפורש:
זו לא השוואת שפות, אלא השוואה שכוללת מימוש סטנדרטי וספריות מרכזיות
כך פחות אי-הבנות.
תנאים שכדאי לקבע לפי שפה
C++
- מיישרים ל-optimized build
- מקבעים compiler
- מקבעים מימוש ספריית התקן
- מציינים במפורש
-O3//O2, LTO, PGO - מוודאים שהתוצאה לא נעלמת באופטימיזציה
- בודקים שאין undefined behavior שמאיץ בטעות
ל-C++ יש חופש רב, ולכן הבדלי תנאים יוצאים חזק. לכן באיזה compiler, באילו flags, ובאיזה STL נמדד — חשוב מאוד.
C#
- מיישרים ל-Release
- מקבעים גרסת .NET
- רושמים Server GC / Workstation GC
- מציינים במפורש Tiered Compilation, ReadyToRun, Native AOT
- מפרידים cold ו-warm
ב-C#, הבדלי הגדרות .NET משנים את התמונה.
בפרט, C# עם JIT ו-C# עם Native AOT הם צירים שונים גם אם שניהם “C#”.
אם מערבבים, מה שמושווה כבר לא השפה אלא צורת ההפצה.
Java
- מקבעים vendor וגרסה של ה-JDK
- מציינים במפורש את ה-GC
- מקבעים warm-up / measurement / fork
- רושמים גודל heap ואפשרויות JVM
- מפרידים cold start מ-steady-state
Java נהנית מה-JIT, אבל ההרצה הראשונה נראית אחרת לגמרי. לכן חובה להפריד השוואת processes קצרי-חיים מ-השוואת הרצה ארוכה.
Go
- מקבעים גרסת Go
- מקבעים
GOMAXPROCS - מציינים במפורש
CGO_ENABLED - אם מכוונים
GOGC, חובה לרשום - אם אפשר, שומרים גם פלט בפורמט benchmark
Go יחסית נוחה, אבל ב-benchmark מקבילי ההשפעה של GOMAXPROCS גדולה.
וגם cgo משנה את העולם, אז חובה לשמור את זה כתנאי.
איך מיישרים את סביבת ההרצה
בכל שפה, השוואה בלי ליישר סביבה היא בעיקר השוואת סביבות.
flowchart TB
accTitle: מה זו בעצם השוואה בלי ליישר סביבה
accDescr: בשתי מדידות עם CPU, OS, power, קלט, עדיפות ומספר ליבות לא מיושרים, הפרש בתוצאה עלול להיות הפרש סביבה ולא הפרש שפה.
en1["שתי מדידות בסביבה לא מיושרת"] --> en2["מופיע הפרש בתוצאה"]
en2 --> en3{"ההפרש הזה הוא הפרש של מה?"}
en3 -->|"הסביבה מיושרת"| en4["אפשר לקרוא כהפרש מימוש או שפה"]
en3 -->|"לא מיושרת"| en5["פשוט משווים סביבות"]
איור 12: אפשר לתלות את ההפרש בשפה רק אחרי שמסירים את הפרש הסביבה.
מה ליישר
- אותו CPU / זיכרון / אחסון
- אותה גרסת OS
- אותם תנאי power
- תנאים קרובים לאותה טמפרטורת חדר
- אותם נתוני קלט
- אותה עדיפות process
- אותם תנאי מספר ליבות
- אותם תנאי container או bare metal
מה שמשפיע במיוחד
power settings ותדר CPU
במחשב נייד, גם רק AC מול סוללה זה עולם אחר. אם CPU governor או power mode לא מיושרים, תוצאות ההשוואה זזות חזק.
לגבי תנאי power, התראות, רעש ברקע, חום וסדר הרצה ב-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: אי אפשר למחוק את החום, אבל סדר חכם מחלק אותו בהגינות בין הצדדים.
עיבוד רקע
עדכונים, indexing, סנכרון, antivirus, דפדפן, כלי צ’אט. נשמע קטן. בפועל תוקע.
מה כדאי למדוד
בהשוואת שפות מומלץ לפחות להפריד את ארבעת אלה.
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: ארבעה מדדים שמפרידים ביניהם
accDescr: בהשוואת שפות מפרידים wall-clock שהמשתמש מחכה, CPU time בפועל, זיכרון כמו RSS מקסימלי והקצאות, והתפלגות כמו חציון ו-percentiles.
ms1["wall-clock time"] --> ms5["מסתכלים על ארבעתם בנפרד"]
ms2["CPU time"] --> ms5
ms3["זיכרון והקצאות"] --> ms5
ms5 -.-> ms4["גם התפלגות (חציון ו-p95) במסגרת נפרדת"]
איור 14: מספר המהירות הוא לא סוג אחד. קוראים אותו רק כשמסדרים לידו גם את העלות מאחוריו.
סדר הרצה מומלץ
הזרימה שעובדת בפועל היא בערך בסדר הזה.
flowchart TB
a1["1. קובעים workload"] --> a2["2. מקבעים dataset משותף"]
a2 --> a3["3. קודם עוברים בדיקת נכונות"]
a3 --> a4{"ה-checksum תואם<br/>בכל המימושים?"}
a4 -- לא --> a3
a4 -- כן --> a5["4. מקבעים תנאי build"]
a5 --> a6["5. מפרידים cold ו-warm"]
a6 --> a7["6. מריצים עם סדר מעורבב"]
a7 --> a8{"הגעתם למספר<br/>ההרצות הנדרש?"}
a8 -- לא --> a7
a8 -- כן --> a9["8. שומרים raw data"]
a9 --> a10{"יצא הפרש<br/>שיש לו משמעות?"}
a10 -- לא --> a11["רושמים תנאים ומספר הרצות ומסיימים"]
a10 -- כן --> a12["9. לוקחים profile וחופרים סיבה"]
איור 15: מסדר העבודה — מ-workload, דרך בדיקת נכונות והרצה מעורבבת, עד שמירת raw data.
1. קובעים workload
קודם מבהירים מה רוצים להשוות.
- startup time
- throughput יציב
- tail latency
- יעילות זיכרון
- scale מקבילי
2. מקבעים dataset משותף
נתוני הקלט מיושרים עם seed קבוע או קובץ קבוע. אם גם יצירת הנתונים נכללת, גם אותה צריך ליישר בכל שפה.
3. קודם עוברים בדיקת נכונות
על דאטה קטן ועל דאטה גדול, מוודאים שכל המימושים מחזירים אותה תוצאה. checksum או hash נוחים לזה.
4. מקבעים תנאי build
בכל שפה בונים executable ב-Release / optimized, ורושמים גרסה ו-flags.
5. מפרידים cold ו-warm
במיוחד ב-C# וב-Java זה קריטי.
- cold: כולל את הרגע אחרי process startup
- warm: מצב יציב אחרי כמה הרצות
אם מציירים מה נמדד, ברור ששני אלה דברים שונים.
flowchart LR
subgraph coldrange["הטווח שנמדד כ-cold"]
direction LR
s1["process startup"] --> s2["אתחול runtime<br/>class loading"]
s2 --> s3["קומפילציית JIT ראשונה"] --> s4["עיבוד ראשון"]
end
s4 --> s5["עיבוד שני ואילך<br/>Tiered Compilation מתקדם"]
subgraph warmrange["הטווח שנמדד כ-warm"]
direction LR
s6["עיבוד ב-steady-state"]
end
s5 --> s6
איור 16: cold כולל process startup עד קומפילציית JIT ראשונה; warm מודד רק steady-state.
ל-C++ ול-Go, כי הם compiled מראש, אין שלב שמקביל ל”קומפילציית 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. אחר כך אפשר לקרוא outliers והרגלי warm-up.
9. אם יצא הפרש — לוקחים profile
רק אז חופרים סיבה.
- CPU profile
- allocation profile
- לוג GC
- flame graph
- trace בצד ה-OS
משם אפשר לדבר לא על “מהיר / איטי”, אלא על למה זה כך.
איך קוראים את התוצאה
גם אחרי שיש מספרים, קריאה לא נכונה עדיין מסוכנת.
רק בהרצה הראשונה C# / Java איטיות
חושדים ב-JIT, class loading ואתחול. במקרה הזה:
- אם startup time חשוב — זה הפרש עם משמעות
- אם הנושא הוא הרצה ארוכה — זה הפרש שצריך טבלה נפרדת
C++ חזקה ב-tight loop
ייתכן שאופטימיזציה ברמה נמוכה, object layout ו-overhead מינימלי של runtime עובדים. אבל לקפוץ מזה ל”לכן גם ב-production זו הכי מהירה” זה קפיצה גדולה מדי.
Go נראית חזקה ב-startup ובקלות הפצה
binary אחד, עלייה יחסית קלה, מודל parallelism נוח — לפעמים זה באמת עובד. אבל זה לא אומר יתרון בכל workload של CPU.
C# / Java מדביקות ב-steady-state, או אפילו הופכות
ייתכן שאופטימיזציית JIT עובדת. זה לא נדיר. לכן חשוב לא לערבב השוואה כולל startup עם השוואת steady-state.
פער גדול בעיבוד allocation-heavy
במקרה הזה, יותר משם השפה, לרוב מה שקובע הוא:
- memory layout
- טיפול במחרוזות וב-map
- התנהגות GC
- copy מיותר
flowchart TB
accTitle: איך קוראים הפרש כשהוא מופיע
accDescr: אם רק בהרצה הראשונה C# או Java איטיות, חושדים ב-JIT ובאתחול. אם startup חשוב זה הפרש עם משמעות; אם הנושא הרצה ארוכה, שמים בטבלה נפרדת. ואז חופרים סיבה ב-profile.
rd1["יצא הפרש"] --> rd2{"באילו תנאים?"}
rd2 -->|"הפרש כולל startup"| rd3["אם startup הוא הנושא, יש משמעות"]
rd2 -->|"הפרש ב-steady-state"| rd4["קוראים כהשוואת הרצה ארוכה"]
rd3 --> rd5["חופרים סיבה ב-profile"]
rd4 --> rd5
איור 17: לפני גודל המספר, בודקים על איזו זירה נמדד ההפרש.
תבנית תיעוד
בתוצאת benchmark כדאי להשאיר לפחות את השדות האלה. אחר כך זה מציל.
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 כך:
micromacrostartupparallel
את cold_or_warm חשוב לציין במפורש:
coldwarm
אם מחליטים מראש מה נכנס לכל עמודה, פחות זזים.
| עמודה | מה נכנס | דוגמת פורמט |
|---|---|---|
timestamp |
זמן תחילת ה-run. לבדוק אחר כך תנודות לפי שעה ביום | ISO 8601. 2026-03-17T10:00:00+09:00 |
language |
מזהה המימוש. אוצר מילים קבוע כדי למנוע כתיב לא אחיד | cpp / csharp / java / go |
scenario |
שם פריט ה-benchmark | sort_int32_10m |
run_kind |
סוג המדידה | micro / macro / startup / parallel |
cold_or_warm |
האם כולל startup | cold / warm |
elapsed_ms |
wall-clock. שלוש ספרות אחרי הנקודה מונעות כאב אחר כך | מילישניות עשרוניות |
cpu_ms |
CPU time של ה-process. סכום user ו-system | מילישניות עשרוניות |
max_rss_mb |
RSS מקסימלי | MB שלם או עשרוני |
alloc_bytes |
סך בייטים שהוקצו. בשפות שאי אפשר להשיג, משאירים ריק ושומרים שזה ריק | שלם, או ריק |
gc_count |
מספר GC. ב-C++ תמיד ריק | שלם, או ריק |
checksum |
לבדיקת נכונות. מוודאים בנפרד שתואם בכל המימושים | מחרוזת hex |
compiler_or_runtime |
סוג ה-toolchain | msvc / dotnet / temurin / go |
compiler_version |
גרסת ה-toolchain, עד minor | מחרוזת הגרסה שה-toolchain מוציא |
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 runs של מימוש אחד, מסודרים מהמהיר לאיטי.
98, 99, 100, 101, 101, 102, 103, 104, 106, 720
הערכים הייצוגיים שיוצאים מ-10 האלה:
| מדד | ערך | איך קוראים |
|---|---|---|
| ממוצע | 163.4 | נגרר אחרי הפעם האחרונה, וסטה כ-60% מעל התחום השגור בפועל |
| חציון | 101.5 | קרוב לתחושה של 9 מתוך 10 הרצות |
| min / max | 98 / 720 | הפרש של פי 7 ומעלה — סימן לבדוק את ה-outlier |
| p95 / p99 | אי אפשר להוציא | עם 10 דגימות, גם p95 וגם p99 חסרי משמעות |
כלומר, טבלה עם ממוצע בלבד הופכת “מימוש שנתקע לפעמים בגדול” ו-“מימוש קצת איטי אבל יציב” לאותו פרצוף. במבנה הטבלה, ההבדל הבא משפיע.
| נקודה | טבלת תוצאות חלשה | טבלת תוצאות שימושית |
|---|---|---|
| ערך ייצוגי | רק ממוצע | חציון במרכז, יחד עם min / max ופיזור |
| מספר הרצות | לא כתוב | מספר runs, ומדיניות אם הוסרו outliers |
| cold / warm | מעורבב, או בלי הבחנה | טבלה נפרדת, או שורה נפרדת |
| נכונות | לא צוין | צוין שה-checksum תואם בכל המימושים |
| תנאים | “נמדד באותו PC” | כתוב עד OS, CPU, גרסת toolchain, דגלי אופטימיזציה, מספר threads |
| נתונים גולמיים | רק ערכי צבירה | מצוין איפה נשמר ה-CSV הגולמי |
אם רוצים p95 או p99, מלכתחילה צריך יותר runs. כדי לדבר על התפלגות, צריך מספיק דגימות שבהן רואים התפלגות. זה כל הסיפור.
ב-benchmark, לפעמים היכולת לפרש אחר כך חשובה יותר מ-המדידה עצמה.
flowchart TB
accTitle: מה מסתיר ממוצע יחיד
accDescr: outlier גדול פעם אחת מושך את הממוצע מעל התחום השגור. החציון קרוב יותר לתחושה, והפרש min/max הוא סימן לבדוק את ה-outlier. לכן טבלת ממוצע בלבד מסוכנת.
av1["מופיע outlier פעם אחת"] --> av2["הממוצע סוטה למעלה"]
av2 --> av3["טבלת ממוצע בלבד מסתירה את המציאות"]
av3 --> av4["מציינים חציון, min ו-max יחד"]
av4 -.-> av5["outlier הוא סימן לבדוק סיבה"]
איור 18: הממוצע לא משקר. הוא פשוט שותק על קיום ה-outlier.
סיכום
מה שבאמת חשוב בהשוואת מהירות בין C# / C++ / Java / Go הוא להוריד את השאלה הגסה מי הכי מהירה לצורת ניסוי: איזה workload, באילו תנאים, ולפי איזה מדד משווים.
הנקודות שהכי קשה לטעות בהן:
- מפרידים startup time מ-steady-state
- מודדים עם אותו אלגוריתם, אותו קלט, ואותה בדיקת נכונות
- לא מסיקים מסקנה מ-benchmark בודד
- מפרידים benchmark בתוך שפה מ-benchmark חוצה שפות
- מסתכלים על חציון והתפלגות, לא רק ממוצע
- שומרים תנאים ו-raw data
והכי חשוב בסוף: לא לנסות יותר מדי להכריע ניצחון לפי שם השפה. ביצועים אמיתיים נקבעים משילוב של שפה, runtime, ספריות, תנאי build, נתונים, OS וחומרה.
“C++ מהירה”, “Java חזקה”, “Go קלה”, “גם C# מהירה מספיק” — כל אלה נכונים במידה מסוימת. אבל בלי “באילו תנאים אומרים את זה”, הדיון נגמר בלי שהצדדים מדברים על אותו דבר.
מיישרים תנאים, כמה workloads, מפרידים cold / warm, ומסתכלים גם על ההתפלגות. זה נשמע טכני. בסוף זה מה שעובד.
flowchart TB
accTitle: מורידים שאלה גסה לצורת ניסוי
accDescr: הופכים את השאלה הגסה מי הכי מהירה לניסוי של איזה workload, באילו תנאים ולפי איזה מדד. מיישרים תנאים, מריצים כמה workloads, מפרידים 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/
נושאים קשורים
עמודים שכדאי לראות יחד עם המאמר הזה.
איפה לפנות בנושא הזה
תכנון השוואת ביצועים, יישור תנאי מדידה, פרשנות תוצאות, וחפירת סיבה — מתאים לשירותים הבאים.
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
למה arguments נשברים — כללי command-line arguments ב-Windows
Windows מעביר ל-CreateProcess מחרוזת אחת שהמקבל מפצל. מכסה את כללי CommandLineToArgvW, CRT ו-.NET, ArgumentList, ובניה ב-C++.
מה נשאר אחרי שה-parent מת — מחזיקים child processes ב-Job Object
למה SDK helpers שורדים UI שנהרג ומחזיקים את המצלמה או את ה-COM port? מתכננים משך חיים של child process עם Job Objects, KillOnJobClose ו-c...
Named Pipes בפועל — ה-IPC הסטנדרטי של Windows, מתכנון עד אבטחה
מדריך מעשי ל-Named Pipes, ה-IPC הסטנדרטי ב-Windows. המאמר מסדר לפי מקורות ראשוניים את הבחירה בין byte mode ל-message mode, תכנון server ל...
Spurious wakeup — למה condition variable מתעורר בלי notify, ואיך לחכות נכון ב-Windows
Wait של condition variable יכול להתעורר בלי notify (spurious wakeup). למה Windows מתיר זאת, וצורת ה-wait הנכונה עם while ו-predicate ב-Wi...
רשימת בדיקה לניהול בטוח של child processes באפליקציית Windows
בניהול בטוח של child processes באפליקציית Windows, בעלות על עץ התהליכים ותהליך הסיום חשובים יותר מבחירת API להפעלה. המאמר עובר על Job Obj...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
ייעוץ טכני וסקירת תכנון
תכנון השוואת ביצועים, איך מיישרים תנאי מדידה, ואיך קוראים warm-up וסטטיסטיקה — נושא שמתאים לייעוץ טכני ול-design review.
חקירת תקלות ואיתור גורמים
בידוד הפרשי ביצועים בין שפות וגרסאות, איתור צווארי בקבוק, ובדיקה אם הליך המדידה עצמו תקין — קל לקחת את זה כחקירת תקלה.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- מי הכי מהירה — C#, C++, Java או Go?
- אי אפשר להכריע עם מספר אחד. ביצועים אמיתיים הם שילוב של שפה, runtime, ספריות, תנאי build, נתונים, OS וחומרה. השאלה השימושית היא לא 'איזו שפה הכי מהירה', אלא ניסוי: איזה workload, באילו תנאים, ולפי איזה מדד. ב-startup רואים בקלות את עלות ה-JIT והאתחול של C#/Java; ב-steady-state אופטימיזציית ה-JIT לרוב סוגרת את הפער, ולפעמים הופכת אותו.
- למה ב-benchmark של C# ו-Java מפרידים cold מ-warm?
- כי C# ו-Java בדרך כלל רצות עם JIT. מדידת ההרצה הראשונה כוללת לא רק את התכנית עצמה, אלא גם אתחול runtime, class loading והכנה של ה-JIT. C++ ו-Go, לעומת זאת, בדרך כלל compiled מראש (AOT). גם ל-cold וגם ל-warm יש משמעות, אבל זו לא אותה משמעות. cold כולל את הרגע אחרי process startup; warm הוא מצב יציב אחרי כמה הרצות. לא שמים אותם באותה טבלה.
- איך מתכננים benchmark חוצה שפות?
- שתי שכבות. בתוך כל שפה מודדים עם harness שמתאים לה: BenchmarkDotNet (C#), JMH (Java), go test -bench ו-benchstat (Go), Google Benchmark (C++). בהשוואה חוצת שפות מסוכן לשים את תוצאות ה-harness זו ליד זו כמו שהן. עדיף שכל מימוש יהיה executable עם אותו CLI contract, וש-runner משותף מבחוץ יערבב את סדר ההרצה, יפריד cold/warm, יעביר את אותו dataset, יוודא checksum, וישמור raw data.
- על מה להיזהר ב-microbenchmark של C++?
- Dead code elimination. אם ה-compiler מחליט שאף אחד לא צורך את תוצאת החישוב, הוא עלול למחוק את העבודה עצמה — ואז המספר לא אומר 'מהיר' אלא 'לא רץ כלום'. לכן צריך לצרוך את התוצאה, להוציא checksum, ולהשתמש ב-DoNotOptimize / ClobberMemory של ה-framework. בנוסף, ב-C++ הבדלי תנאים יוצאים חזק: חובה לתעד compiler, flags (O3/O2, LTO, PGO) ו-STL.