השוואת ביצועים הוגנת בין C#, C++, Java ו-Go

· עודכן בתאריך: · · 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, חום, עיבוד ברקע והטיה בנתוני הקלט מזיזים את התוצאה בלי מאמץ. זה עולם מלוכלך.

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

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

  1. קודם מחליטים מה רוצים להשוות startup time, throughput ב-steady-state, השהיית p95, או יעילות זיכרון — אופן המדידה משתנה בהתאם.

  2. לא מכריעים לפי benchmark בודד חישוב CPU, הקצאות זיכרון, parallelism, startup — בכל אחד מהם נראית שפה או runtime אחרת כחזקה.

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

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

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

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

  7. שומרים לא רק מספרים, אלא גם תנאים תוצאת 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, באילו תנאים, ולפי איזה מדד רץ מהר

אם אוספים מספרים בלי זה, בסוף אין מה לסכם.

קודם מגדירים מה נחשב מהירstartup, throughput ב-steady-state, tail latency ויעילות זיכרון דורשים מדידות שונות, ולכן לפני ההשוואה מחליטים איזה workload, באילו תנאים ולפי איזה מדד.מה רוצים לדעת מההשוואה הזוstartup timethroughput יציבהשהיית p95 / p99יעילות זיכרון היא ציר נפרדלכל ציר יש דרך מדידה אחרת

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

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

לערבב JIT עם AOT זה ניסוי אחר

C# ו-Java בדרך כלל מושפעות מ-JIT. C++ ו-Go בדרך כלל compiled מראש.

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

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

ההבדל בין קבוצת JIT לקבוצת AOTC# ו-Java בדרך כלל עם JIT, אז בהרצה הראשונה מתערבבים אתחול runtime, class loading והכנה של JIT; C++ ו-Go בדרך כלל AOT. לכן השוואת הרצה ראשונה והשוואת steady-state הן לא אותו ניסוי.C# ו-Java (בדרך כלל JIT)בהרצה הראשונה מתערבבים אתחול ו-JITC++ ו-Go (בדרך כלל AOT)רצים compiled מראשהשוואת הרצה ראשונה והשוואת steady-state הן ניסויים שונים

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

הבדלי מימוש לרוב גדולים מהבדלי שפה

גם באותו “sort”:

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

רק זה מזיז את התוצאה חזק.

ומעבר לזה, בעיבודים כמו JSON, דחיסה, הצפנה, regex — מימוש הספרייה משפיע יותר מ-השפה עצמה. אם לא כותבים במפורש מה נמדד, “השוואת שפות” הופכת ל”השוואת ספריות”.

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

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

ב-C++ העבודה עלולה להיעלם באופטימיזציה

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

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

מלכודת dead code eliminationב-microbenchmark, אם אף אחד לא צורך את תוצאת החישוב, ה-compiler עלול למחוק את העבודה. זמן הריצה נראה כמעט אפס — לא מהיר, אלא לא רץ. מונעים את זה עם checksum ועם DoNotOptimize.אף אחד לא צורך את התוצאהה-compiler מוחק את העבודהזמן הריצה נראה כמעט אפסזה לא מהיר, זה לא רץמונעים עם checksum ו-DoNotOptimize

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

GC הוא לא חיסרון ולא יתרון. זה מאפיין

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

בפועל מה שקובע הוא:

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

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

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

איור 6: למה אי אפשר לסגור את זה במשפט “יש GC אז זה איטי”.

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

1. לערבב Debug ו-Release

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

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

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

3. להסיק מסקנה מהרצה אחת

הרצה אחת היא בדרך כלל רעש.

  • JIT
  • page cache
  • CPU boost
  • חום
  • משימות רקע
  • GC
  • קריאת קובץ ראשונה

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

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

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

4. לערבב warm-up

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

5. בלי בדיקת נכונות

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

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

ניצחון ב-tight loop לא אומר ניצחון בשירות אמיתי. ולהפך: הפסד ב-startup לא אומר חולשה בהרצה ארוכה.

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

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

השכבה הפנימית: חקירה בתוך השפההשכבה החיצונית: runner משותף חוצה שפותאותו עיבוד, מדידה מעמיקה בתוך השפהאותו עיבוד, מדידה מעמיקה בתוך השפהאותו עיבוד, מדידה מעמיקה בתוך השפהאותו עיבוד, מדידה מעמיקה בתוך השפהGoogle BenchmarkBenchmarkDotNetJMHgo test -bench ו-benchstatrunner משותףערבוב סדר הרצה / הפרדת cold ו-warmאימות checksum / שמירת raw dataקובץ benchC++קובץ benchC#קובץ benchJavaקובץ benchGo

איור 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 וגם הזמן של העבודה עצמה.

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

איור 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 בתוך כל שפה לבין הגינות חוצת שפות.

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

איור 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. ולהפך: גם אם יש פער כאן, הוא נפרד מניצחון בעיבוד ארוך.

ארבעה פריטי benchmark עם אופי שונהsort מראה CPU ורוחב פס ושטח זמני, ספירת מילים מראה hash ומחרוזות והקצאות, hash מקבילי מראה scale, ו-startup מראה עלות הפעלה ואתחול.sort_int32_10mCPU, רוחב פס, שטח זמניhash_group_countמחרוזות, map, מגמת GCparallel_sha256scale בריצה מקביליתstartupעלות הפעלה ואתחול

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

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

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

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

איור 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 מורידה הטיה.

הטיית חום וסדר ההרצהאם רק כמה הרצות ראשונות מהירות והתוצאה יורדת בהמשך, חושדים בחום או throttling. במקום כל A ואז כל B, מריצים לסירוגין כדי להוריד הטיה.רק ההתחלה מהירה, יורד בהמשךחושדים בחום או throttlingלא מריצים את כל A ואז את כל Bמריצים 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
  • סטיית תקן ופיזור

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

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

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

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

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

לאכןלאכןלאכן1. קובעים workload2. מקבעים dataset משותף3. קודם עוברים בדיקת נכונותה-checksum תואםבכל המימושים?4. מקבעים תנאי build5. מפרידים cold ו-warm6. מריצים עם סדר מעורבבהגעתם למספרההרצות הנדרש?8. שומרים raw dataיצא הפרששיש לו משמעות?רושמים תנאים ומספר הרצות ומסיימים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: מצב יציב אחרי כמה הרצות

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

הטווח שנמדד כ-warmהטווח שנמדד כ-coldעיבוד ב-steady-stateאתחול runtimeclass loadingprocess startupעיבוד ראשוןקומפילציית JIT ראשונהעיבוד שני ואילךTiered Compilation מתקדם

איור 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 מיותר
איך קוראים הפרש כשהוא מופיעאם רק בהרצה הראשונה C# או Java איטיות, חושדים ב-JIT ובאתחול. אם startup חשוב זה הפרש עם משמעות; אם הנושא הרצה ארוכה, שמים בטבלה נפרדת. ואז חופרים סיבה ב-profile.הפרש כולל startupהפרש ב-steady-stateיצא הפרשבאילו תנאים?אם startup הוא הנושא, יש משמעותקוראים כהשוואת הרצה ארוכהחופרים סיבה ב-profile

איור 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 כך:

  • 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 שם פריט ה-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, לפעמים היכולת לפרש אחר כך חשובה יותר מ-המדידה עצמה.

מה מסתיר ממוצע יחידoutlier גדול פעם אחת מושך את הממוצע מעל התחום השגור. החציון קרוב יותר לתחושה, והפרש min/max הוא סימן לבדוק את ה-outlier. לכן טבלת ממוצע בלבד מסוכנת.מופיע outlier פעם אחתהממוצע סוטה למעלהטבלת ממוצע בלבד מסתירה את המציאותמציינים חציון, min ו-max יחד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, ומסתכלים גם על ההתפלגות. זה נשמע טכני. בסוף זה מה שעובד.

מורידים שאלה גסה לצורת ניסויהופכים את השאלה הגסה מי הכי מהירה לניסוי של איזה workload, באילו תנאים ולפי איזה מדד. מיישרים תנאים, מריצים כמה workloads, מפרידים 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, ספריות, תנאי 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.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג