Как честно сравнивать скорость C#, C++, Java и Go

· Обновлено: · · Бенчмарк, Производительность, C#, C++, Java, Go

История изменений (1 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI: 10.5281/zenodo.21619732)

Статья заархивирована на Zenodo. Ниже приведены DOI, который всегда ведёт к последней версии, и DOI, закреплённый за версией, которую вы читаете.

Го Комура (2026). Как честно сравнивать скорость C#, C++, Java и Go. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21619732 https://comcomponent.com/ru/blog/2026/03/17/000-language-benchmark-csharp-cpp-java-go/

DOI (последняя версия)
10.5281/zenodo.21619732
DOI (эта версия)
10.5281/zenodo.21619733

«Говорят, C++ быстрый» «Go лёгкий в эксплуатации» «Java довольно быстрая, если крутить её долго» «C# тоже неожиданно силён из-за JIT в .NET»

Такие разговоры звучат постоянно. Но хуже всего здесь — взять цифры, которые разные люди сняли в разных окружениях, выстроить их в ряд и по ним решить, какой язык лучше.

C# и Java легко зависят от JIT и прогрева (warm-up), а C++ и Go обычно уже скомпилированы заранее. Есть ли GC и как он устроен — тоже по-разному. Разница в реализации стандартной библиотеки и соседних библиотек бьёт сильно. Даже на одной машине результат легко разъезжается из-за схемы питания, нагрева, фоновой работы и перекоса во входных данных. На практике это довольно грязная, прикладная область.

Из-за чего результат разъезжаетсяРисунок показывает, что влияние JIT и прогрева, наличие и характер GC, разница реализаций стандартной и соседних библиотек, схема питания, нагрев, фоновая работа и перекос во входных данных накладываются, и результат измерения легко разъезжается.Влияние JIT и прогреваРезультат легко разъезжаетсяРазница GC и библиотекПитание, нагрев, шум, перекос входаНе выстраивать в ряд цифры из разных окружений

Рис. 1: Источников разброса слишком много, поэтому сравнение чужих цифр просто несостоятельно.

В этой статье собраны способы измерения, которые позволяют сравнить C# / C++ / Java / Go насколько возможно честно. Вывод сразу: важнее всего не пытаться решить «какой язык самый быстрый» одним числом.

Тема статьи — именно как устроить сравнение. Цифры, зависящие от окружения, легко меняются местами, едва меняются условия. Поэтому здесь нет рейтинга по реальным прогонам. Вместо этого разберём, как спроектировать сравнение, чтобы у него появилась ценность.

Кому адресована статья

Статья написана для разработчиков и технических лидеров, у которых есть несколько языков-кандидатов и нужно выбрать, на чём реализовывать, с точки зрения производительности, и для тех, кто хочет собрать измерение, про которое внутри компании или в отчёте можно сказать: «мы сравнили скорость». Это не углубление в один язык, а проектирование сравнения сразу по четырём, поэтому текст можно читать и тому, кто трогает только один из них.

Примеры кода: общий runner на PowerShell, языковой harness — BenchmarkDotNet для C# и JMH для Java. Для C++ и Go указано только, как выровнять условия.

Термины, которые стоит зафиксировать заранее

В тексте несколько терминов остаются по-английски. Чтобы не споткнуться на первом появлении, сводим их здесь.

Термин Смысл
p95 / p99 Перцентиль. Если выстроить все run от быстрого к медленному, это значение на отметке 95% / 99% снизу. «5 раз из 100 медленнее этого» — как раз p95
RSS (Resident Set Size) Сколько физической памяти процесс реально держит. Это не объём выделенной виртуальной памяти, а то, сколько RAM он занимает сейчас
LTO (Link Time Optimization) Оптимизация на этапе линковки сразу по нескольким единицам трансляции. У GCC / Clang это -flto, у MSVC — /GL и /LTCG
PGO (Profile-Guided Optimization) Профиль ветвлений и вызовов, собранный на одном прогоне, используют при решениях оптимизации на следующей сборке
Tiered Compilation JIT в .NET сначала запускает код, который можно скомпилировать быстро, и позже перекомпилирует только часто вызываемые методы. Одна из главных причин разрыва между cold и warm
Server GC / Workstation GC Режимы GC в .NET. Server GC держит кучу и потоки GC на каждый логический процессор и склоняется к throughput; Workstation GC — к отзывчивости
GOMAXPROCS Верхняя граница числа OS-потоков, на которых рантайм Go одновременно выполняет код Go. В параллельном бенчмарке без фиксации этого значения сравнивать нельзя
cgo Механизм вызова кода на C из Go. Включение меняет стоимость вызова, условия сборки и возможность статической линковки

Сначала вывод

В сравнении скорости C# / C++ / Java / Go по-настоящему работают эти семь пунктов.

  1. Сначала решить, какую именно скорость сравниваете Способ измерения меняется в зависимости от того, нужно ли время запуска, throughput в устойчивом режиме, задержка p95 или эффективность памяти.

  2. Не делать вывод по одному бенчмарку На вычислениях CPU, выделении памяти, параллельной работе и времени запуска сильными выглядят разные языки и рантаймы.

  3. Для C# и Java разделять cold и warm Если смешать сравнение, которое включает первый запуск, со сравнением устойчивого состояния после прогрева, разговор перекашивается.

  4. Измерять одним алгоритмом, одним входом и одной проверкой корректности Классика бенчмарков: реализация не быстрее — она решала другую задачу.

  5. Разделять микробенчмарк внутри языка и сквозной (end-to-end) бенчмарк между языками Специализированный harness каждого языка удобен, но сравнение между языками лучше гонять внешним общим runner-ом.

  6. Смотреть не только среднее, но и медиану и распределение Достаточно одного попадания GC или фоновой работы, чтобы среднее сломалось.

  7. Сохранять не только цифры, но и условия Результат бенчмарка — это одновременно запись о скорости и запись об условиях эксперимента. К результату без описанных условий потом очень трудно вернуться.

Карта знаний этой статьи

Статья указывает на риск сравнивать скорость выполнения C#, C++, Java и Go одним числом и разбирает проектирование измерений для честного сравнения. У C# и Java разрыв между cold и warm велик из-за JIT-компиляции и Tiered Compilation, а C++ и Go обычно заранее компилируются (AOT), поэтому эти два типа не смешивают в одной таблице. В C++ нужна защита от удаления мёртвого кода, когда компилятор выкидывает неиспользуемое вычисление, — через Google Benchmark и проверку checksum. Измерение внутри языка делают специализированным harness вроде BenchmarkDotNet, JMH, go test -bench и Google Benchmark; для сравнения между языками рекомендуют двухуровневую схему, где внешний общий runner рандомизирует порядок запусков и проверяет checksum. Кроме того, смотрят не только среднее, но и медиану и распределение вроде хвостовой задержки p95/p99.

Карта знаний межъязыкового бенчмарка C#/C++/Java/GoРисунок показывает, что JIT и Tiered Compilation у C# и Java против AOT у C++ и Go требуют разделять cold и warm; Google Benchmark и проверку checksum как защиту от удаления мёртвого кода в C++; двухуровневую схему с языковым harness и внешним общим runner; и оценку распределения вплоть до хвостовой задержки.используетиспользуетиспользуетиспользуетиспользуетиспользуетиспользуетиспользуетиспользуетиспользуетиспользуетиспользуетиспользуетпредотвращаетможет вызватьпредотвращаетиспользуетиспользуетиспользуетиспользуетиспользуетиспользуетиспользуетиспользуетпроектирование кросс-языковых бенчмарковC#JIT-компиляция (Just-In-Time)JavaC++AOT-компиляцияGoTiered CompilationGOMAXPROCScgoBenchmarkDotNetJMH (Java Microbenchmark Harness)go test -bench и benchstatGoogle Benchmarkизмерение после прогрева (warm)удаление мёртвого кода (dead code elimination)проверка корректности по checksumхвостовые перцентили задержки (p95/p99)сборка мусора (GC)

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 24, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

Что решить в первую очередь

Если обойтись одним словом «быстро», почти наверняка всё пойдёт не так. Сначала нужно решить, что именно вы называете быстрым.

Даже у одной и той же программы то, что хочется увидеть, может сильно различаться.

1. Нужно ли время запуска

Для CLI-инструмента, короткоживущего пакетного задания и вспомогательной утилиты, которая стартует один раз и сразу завершается, решают cold start и process startup. По этой оси результат сильно зависит от того, включать ли стоимость инициализации JIT и загрузки классов.

2. Нужен ли throughput при долгой работе

Для сервера, постоянно живущего процесса, воркера и долгой конвертации важен throughput в устойчивом режиме (steady-state). Здесь то, что первый запуск сам по себе медленный, не суть: тема в том, насколько стабильно показатель растёт после прогрева.

3. Нужна ли хвостовая задержка (tail latency)

В API, UI и обработке, близкой к реальному времени, p95 / p99 иногда важнее среднего. Даже если в среднем всё быстро, редкие крупные остановки болезненны и для пользовательского опыта, и для SLA.

4. Нужно ли смотреть ещё и на эффективность памяти

Если смотреть только на время CPU и не учитывать пиковый RSS, объём выделений, число сборок мусора и паузы GC, реальную тяжесть в эксплуатации легко прочитать неверно. «Быстро, но сильно ест память» и «чуть медленнее, но стабильно легче» меняются местами в зависимости от сценария.

Иными словами, вопрос, который нужно решить сначала, звучит так:

В этом сравнении нужно узнать не то, какой язык быстрее, а какой workload, при каких условиях и по какому показателю обрабатывается быстрее.

Если собирать цифры, оставляя это размытым, в конце ничего не сложится.

Сначала решить, что считать быстрымРисунок показывает, что способ измерения меняется в зависимости от того, смотрите ли вы время запуска, throughput в устойчивом режиме, хвостовую задержку или эффективность памяти, поэтому до сравнения нужно решить, какой workload при каких условиях и по какому показателю вы смотрите.Что именно нужно узнать этим сравнениемВремя запускаУстойчивый throughputЗадержка p95 / p99Эффективность памяти — отдельная осьПо каждой оси способ измерения свой

Рис. 2: Пока нет определения «быстро», цифры собирать рано.

Почему сравнивать языки сложно

Если смешать JIT и AOT, это уже другой эксперимент

C# и Java обычно зависят от JIT. C++ и Go, напротив, обычно уже скомпилированы заранее.

То есть измерение первого запуска захватывает не только скорость самой программы, но и старт рантайма, загрузку классов и подготовку JIT. И наоборот, если смотреть только на состояние после достаточного прогрева, сравнение становится вопросом о том, насколько далеко заходит оптимизация в устойчивом режиме.

Оба варианта имеют смысл. Но это не одно и то же.

Разница между группой JIT и группой AOTРисунок показывает, что C# и Java обычно зависят от JIT, поэтому в первый запуск смешиваются старт рантайма, загрузка классов и подготовка JIT, а C++ и Go обычно уже скомпилированы заранее, поэтому сравнение первого запуска и сравнение после прогрева — это не одно и то же.C# и Java (обычно JIT)В первый запуск смешиваются старт и подготовка JITC++ и Go (обычно AOT)Работают уже скомпилированнымиСравнение первого запуска и устойчивого режима — разные эксперименты

Рис. 3: У языков с разной моделью выполнения то, откуда начинать измерение, определяет содержимое эксперимента.

Разница реализаций нередко больше разницы языков

Даже для одной и той же «сортировки»:

  • одна сторона берёт стандартную библиотеку
  • другая — свою реализацию
  • одна делает лишние копии
  • другая каждый раз заново генерирует вход

Одного этого достаточно, чтобы результат сильно изменился.

Дальше, на JSON, сжатии, криптографии, регулярных выражениях разница реализации библиотек влияет куда сильнее, чем сам язык. Поэтому, если явно не сказать, что именно измеряется, задуманное как «сравнение языков» превращается в «сравнение библиотек».

Сравнение языков подменяется сравнением библиотекРисунок показывает, что даже при одной и той же задаче результат меняется от того, стандартная это библиотека или своя реализация, есть ли лишние копии и перегенерация входа, а на JSON, сжатии, криптографии и регулярных выражениях сильнее влияет реализация библиотек, поэтому без явного указания объекта измерения сравнение языков становится сравнением библиотек.нетдаРеализации, которые вроде решают одно и то жеСмешиваются разница реализации и библиотекЯвно сказали, что измеряем?Хотели сравнить языки, сравнили библиотекиМожно читать как сравнение

Рис. 4: Большая часть недоразумений пропадает, если правильно назвать то, что измеряете.

У C++ есть ловушка: оптимизация выкидывает работу

Особенно в микробенчмарке компилятор может решить, что «этот результат никто не использует», и удалить саму обработку. Тогда вы измеряете не скорость, а ситуацию, когда работы нет вообще. Типичная картина: исчезает весь цикл, и время выполнения выглядит почти нулевым.

В C++ это проявляется особенно откровенно, поэтому потребление результата, вывод checksum и средства benchmark-фреймворка, которые не дают оптимизации это стереть, здесь особенно важны.

Ловушка, когда оптимизация выкидывает работуРисунок показывает, что в микробенчмарке компилятор может удалить обработку, если решит, что результат никто не использует, и тогда измеряется не скорость, а отсутствие работы, поэтому важно потреблять результат, выводить checksum и пользоваться средствами подавления оптимизаций.Результат вычисления никто не используетКомпилятор выкидывает обработкуВремя выполнения выглядит почти нулевымЭто не быстро, это ничего не делалиПредотвращают выводом checksum и средствами подавления

Рис. 5: Подозрительно быстрый результат сначала проверяйте на то, не исчезла ли работа.

GC — не «минус» и не «плюс», а характеристика

В C#, Java и Go есть сборщик мусора (GC). Свести это к «раз есть GC, значит медленно» слишком грубо.

На практике сильнее влияют:

  • как обрабатывается большой поток короткоживущих объектов
  • настройка размера кучи
  • частота GC и паузы
  • раскладка объектов
  • привычки библиотек выделять память

C++ наоборот позволяет тонко управлять вручную и через RAII, но именно поэтому сильнее выходит разница проектирования и реализации. То есть разница способа управления сама по себе не есть хорошо или плохо.

GC — не минус, а характеристикаРисунок показывает, что в языках с GC сильнее влияют обработка короткоживущих объектов, настройки кучи, частота и паузы GC и привычки выделения, а C++ даёт тонкий контроль ценой большей разницы реализаций, поэтому разница способа управления сама по себе не есть превосходство.Языки с GCСильнее влияют настройки и привычки выделенияРучное управление и RAII в C++Контроль есть, но разница реализаций выходит сильнееРазница способа управления — не превосходство

Рис. 6: Зачем эта схема: не сводить всё к фразе «GC есть, значит медленно».

Чего нельзя делать при сравнении

1. Смешивать Debug и Release

Это даже не обсуждается. Объекты сравнения обязательно выравнивают на оптимизированную сборку, близкую к продакшену.

2. Не решать одну и ту же задачу

Разный формат входа, разный вывод, обработка ошибок есть только у одной стороны, разная политика повторного использования памяти. Если это оставить как есть, вы измерите не скорость, а разницу требований.

3. Делать вывод по одному запуску

Один запуск — это почти всегда шум.

  • JIT
  • кэш страниц
  • boost CPU
  • нагрев
  • фоновые задачи
  • GC
  • первое чтение файла

Всё это смешивается в одном запуске.

Что смешивается в одном запускеРисунок показывает, что в одном запуске смешиваются JIT, кэш страниц, boost CPU, нагрев, фоновые задачи, GC и первое чтение файла, поэтому результат одного запуска почти всегда шум.JIT и первое чтениеВ одном запуске смешивается всёКэш и boost CPUНагрев и фоновая работаРезультат одного запуска почти всегда шум

Рис. 7: В единственной цифре больше всего того, что измерять как раз не хотели.

4. Смешивать прогрев

Когда измеряете C# и Java, если оставить размытым, включать ли первый запуск или смотреть только состояние после прогрева, обсуждение разваливается. Cold и warm — разные вещи.

5. Не проверять корректность

Прежде чем быть «быстрым», бенчмарк должен «возвращать один и тот же результат». Обязательно проверяйте, что все сравниваемые реализации на одном входе дают один checksum или один вывод.

6. Строить всю картину по одному микробенчмарку

Победа только в tight loop не означает победу во всём реальном сервисе. И наоборот, проигрыш по времени запуска не мешает быть вполне сильным при долгой работе.

Базовый подход к сравнению C# / C++ / Java / Go

Этот момент довольно важен. Рекомендуемая схема — двухуровневая.

Внутренний слой: углубление внутри языкаВнешний слой: общий runner для сравнения между языкамиту же работу подробнее измеряют внутри языкату же работу подробнее измеряют внутри языкату же работу подробнее измеряют внутри языкату же работу подробнее измеряют внутри языкаGoogle BenchmarkBenchmarkDotNetJMHgo test -bench и benchstatОбщий runnerрандомизация порядка / разделение cold и warmпроверка checksum / сохранение сырых данныхисполняемый файл benchC++исполняемый файл benchC#исполняемый файл benchJavaисполняемый файл benchGo

Рис. 8: Сравнение между языками — внешним общим runner-ом, углубление внутри языка — специализированным harness-ом.

Цифры внешнего слоя — это цифры, которые можно сравнивать между языками. Цифры внутреннего слоя — цифры, по которым внутри языка гоняются улучшения. Главное — не смешивать эти два набора в одной таблице.

1. Внутри языка измеряйте harness-ом, который к нему подходит

У каждого языка есть benchmark-инструменты, которые берут на себя его особенности.

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

Они в той или иной мере закрывают особенности рантайма, статистическую обработку и типичные ловушки измерения. Для сравнения внутри одного языка и углублённого разбора реализации они очень полезны.

Например, минимальная конфигурация BenchmarkDotNet для «отсортировать 10 миллионов int32» выглядит так.

// C# / .NET 8 + BenchmarkDotNet 0.13.x
//   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 не рекомендует его в микробенчмарке, потому что он пачкает результат, и считает полезным для макробенчмарка, который занимает 100 ms и больше. Сортировка 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

Вывод тоже делают контрактом. Если все языковые реализации обещают печатать в стандартный вывод только эти две строки, разбор на стороне runner укладывается в одно регулярное выражение.

checksum=[шестнадцатеричная строка]
inner_ms=[миллисекунды с дробной частью]

checksum нужен для проверки корректности, inner_ms — время самой работы, которое реализация измерила сама. Runner отдельно меряет wall-clock всего процесса, поэтому остаются время со стартом и время только самой работы.

CLI-контракт и контракт выводаРисунок показывает, что если каждую языковую реализацию оформить исполняемым файлом с одним CLI-контрактом и печатать в стандартный вывод только две строки checksum и inner_ms, runner отдельно меряет время всего процесса, поэтому остаются и время со стартом, и время только самой работы.Исполняемые файлы с одним CLI-контрактомПечатают две строки: checksum и inner_msRunner меряет wall-clock всего процессаОстаются и старт, и только сама работа

Рис. 9: Если вызов и вывод сделать контрактом, четыре языка можно гонять на одной площадке.

А на стороне общего runner делают такой поток:

  • рандомизировать порядок запуска
  • разделять cold / warm
  • передавать один и тот же набор данных
  • проверять checksum
  • снимать wall-clock и память
  • сохранять сырые данные в CSV / JSON

Скелет выглядит так.

# run-bench.ps1 : общий runner для сравнения между языками (скелет)
# Работает в 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-контракт у всех четырёх реализаций
# выровнен один в один.
# Для 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

    # Контракт вывода с реализацией разбираем здесь, в одном месте
    $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.
    # Если не отсечь здесь, в CSV попадёт NaN, и сравнение внутреннего времени
    # молча сломается
    if ([double]::IsNaN($innerMs) -or [double]::IsInfinity($innerMs) -or $innerMs -lt 0) {
        throw "$($Impl.Language) / $Mode / run $Index не вернул inner_ms (значение: $innerMs). " +
              "Контракт вывода — две строки: checksum= и inner_ms="
    }

    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 тоже сверяем по 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 не совпал. $mode run #$i у $($impl.Language) = $($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
  • Пишут только сырые данные, без агрегации. Среднее и медиану потом считают по CSV

Так проще раздельно держать лучшие практики внутри каждого языка и честность сравнения между языками.

Три намерения общего runnerРисунок показывает, что общий runner намеренно ставит проверку корректности по checksum раньше измерения скорости, перемешивает порядок на каждом run и пишет только сырые данные без агрегации.Проверку корректности — раньше скоростиИзмерение, которое можно сравнивать честноПеремешивание на каждом runПисать сырые данные без агрегацииСглаживает перекос от нагрева и времени суток

Рис. 10: Работа runner — не прогнать быстрее, а снять подозрения.

Конкретный пример: какие сценарии бенчмарков готовить

Когда просят «сравнить C# / C++ / Java / Go», если тест один, лучше взять простой CPU-сценарий, который трудно истолковать неверно; если тестов несколько — подготовить 3–4 workload разного характера.

Рекомендуемый набор

1. sort_int32_10m

Цель: посмотреть CPU + пропускную способность памяти + использование временной области

  • Вход: 10 миллионов значений int32, сгенерированных с фиксированным seed
  • Обработка: отсортировать массив и вернуть checksum
  • На что смотреть: каждый раз возвращаться к одному и тому же неотсортированному входу

Это относительно понятный сценарий. Но в него входит и разница стандартных реализаций сортировки, поэтому это скорее сравнение вместе со стандартной библиотекой, чем сравнение самого языка.

2. hash_group_count

Цель: посмотреть хеш-таблицы, обработку строк, выделения и тенденции GC

  • Вход: фиксированные текстовые данные
  • Обработка: подсчёт частоты каждого слова
  • Вывод: верхние N записей и checksum

Это ближе к практике, но и разница строковых библиотек и реализаций map влияет заметно. Зато сравнение получается ближе к реальности.

3. parallel_sha256

Цель: посмотреть параллельную обработку, планировщик, пул воркеров и привычки синхронизации

  • Вход: последовательность двоичных чанков фиксированного размера
  • Обработка: хеширование в N потоках с возвратом итогового checksum
  • Условия: число потоков ступеньками 1 / 2 / 4 / 8 и т. д.

По сравнению с простым tight loop здесь гораздо лучше видно, как растёт производительность при параллельном выполнении.

4. startup_noop или startup_parse_small

Цель: посмотреть время запуска

  • noop: старт и сразу завершение
  • parse_small: один раз обработать небольшой вход и завершиться

Здесь хорошо видны JIT и стоимость инициализации C# / Java, и картина заметно отличается от C++ / Go. Иначе говоря, даже если здесь появится разница, это не победитель в задачах, которые работают долго.

Четыре сценария разного характераРисунок показывает набор из четырёх сценариев, у которых видно разное: сортировка — CPU и пропускная способность памяти, подсчёт слов — хеш, строки и выделения, параллельное хеширование — рост при параллельном выполнении, стартовый бенчмарк — время запуска.sort_int32_10mCPU, канал и временная областьhash_group_countСтроки, map, тенденции GCparallel_sha256Рост при параллельном выполнениисемейство startupСтоимость запуска и инициализации

Рис. 11: Одним сценарием всего не увидеть, поэтому пункты делят по тому, что хотите увидеть.

Как быть с бенчмарками JSON и HTTP

JSON и HTTP близки к практике, так что смысл в них, конечно, есть. Но тогда это уже не сравнение языков, а сравнение вместе с библиотеками, фреймворками и экосистемой.

Само по себе это не плохо. На практике такое сравнение нередко даже важнее. Но в статье или отчёте меньше поводов для недопонимания, если прямо написать:

Это не сравнение языков, а сравнение типовых реализаций вместе с основными библиотеками.

Условия, которые нужно выровнять для каждого языка

C++

  • Выровнять на оптимизированную сборку
  • Зафиксировать компилятор
  • Зафиксировать реализацию стандартной библиотеки
  • Явно указать условия вроде -O3 / /O2, LTO, PGO
  • Следить, чтобы результат не был выкинут оптимизацией
  • Проверить, не выглядит ли код быстрым из-за неопределённого поведения

У C++ много свободы, поэтому разница условий выходит особенно сильно. Поэтому крайне важно, каким компилятором, с какими флагами и с какой STL измеряли.

C#

  • Выровнять на 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
  • Записать размер кучи и опции JVM
  • Разделять cold start и steady-state

Java легко получает выгоду от JIT, но взамен картина первого запуска сильно варьируется. Поэтому обязательно разделять сравнение короткоживущих процессов и сравнение при долгой работе.

Go

  • Зафиксировать версию Go
  • Зафиксировать GOMAXPROCS
  • Явно указать CGO_ENABLED
  • Если меняете GOGC, обязательно это фиксировать
  • По возможности сохранять вывод в формате benchmark

С Go сравнительно легко работать, но в параллельных бенчмарках сильно сказывается GOMAXPROCS. Кроме того, использование cgo или его отсутствие полностью меняет картину, поэтому это обязательно оставляют в условиях.

Как выровнять окружение выполнения

На каком бы языке ни шла речь, сравнение без выровненного окружения на деле почти всегда сравнивает сами окружения.

Чем на самом деле является сравнение без выровненного окруженияРисунок показывает, что в сравнении, где не выровнены CPU, ОС, условия питания, входные данные, приоритет и число ядер, разница в результате оказывается разницей окружений, а не языков.если окружение выровненоесли не выровненоДва измерения с невыровненным окружениемВ результате есть разницаЧем вызвана эта разница?Можно читать как разницу реализации или языкаСравниваете только окружения

Рис. 12: Разницу можно списать на язык только после того, как убрали разницу окружений.

Что нужно выровнять

  • Один и тот же CPU / память / накопитель
  • Одна и та же версия ОС
  • Одни и те же условия питания
  • Условия, максимально близкие к одной комнатной температуре
  • Одни и те же входные данные
  • Один и тот же приоритет процесса
  • Одни и те же условия по числу ядер
  • Одни и те же условия контейнера или bare-metal

Что влияет особенно сильно

Параметры питания и частота CPU

На ноутбуке одно только различие «от сети или от батареи» создаёт совершенно разные условия. Если не выровнять CPU governor или power mode, результаты сравнения сильно разъезжаются.

Как в Windows выравнивать условия питания, уведомления, фоновый шум, нагрев и порядок запуска, подробно разобрано в отдельной статье Как правильно сравнивать скорость разных версий программы в Windows. Если измерения идут в Windows, этот момент имеет большое значение.

Нагрев

Если быстрыми оказываются только первые несколько запусков, а дальше показатели падают, стоит заподозрить нагрев и троттлинг. Вместо того чтобы прогнать все запуски A, а потом все запуски B, чередование в духе A / B / A / B снижает перекос.

Перекос от нагрева и порядок запускаРисунок показывает, что если быстрыми оказываются только первые запуски, а дальше результаты падают, стоит заподозрить нагрев и троттлинг и не гонять сначала все A, а чередовать A и B, чтобы снизить перекос.Падают только поздние результатыПодозревать нагрев и троттлингНе гонять сначала все A, потом BЧередовать A и B

Рис. 13: Нагрев не убрать, но порядком его можно распределить по обеим сторонам честно.

Фоновая работа

Обновления, индексирование, синхронизация, антивирусное сканирование, браузер, чат. Это неприметные вещи, но они вполне себе попадают в результат.

Что следует измерять

В сравнении языков рекомендуется как минимум рассматривать отдельно эти четыре показателя.

1. wall-clock time

Реальное время, которое ждёт пользователь. Это первый показатель, на который стоит смотреть.

2. CPU time

Показывает, «сколько CPU реально израсходовали». Если ускорился только wall-clock, а CPU time не изменилось, возможно, дело во времени ожидания или во влиянии I/O.

3. memory / allocations

  • Пиковый RSS
  • Суммарный объём выделений
  • Число вызовов alloc
  • Число сборок мусора
  • Паузы GC

Если посмотреть на эти показатели, становится видна цена, спрятанная за скоростью.

4. Распределение

  • Медиана
  • p95 / p99
  • min / max
  • Стандартное отклонение и разброс

Если судить только по среднему, не видно, что стоит за редкими выбросами.

Четыре показателя, которые смотрят отдельноРисунок показывает, что в сравнении языков отдельно смотрят wall-clock как реальное время ожидания пользователя, фактическое время CPU, память вроде пикового RSS и выделений, и распределение вроде медианы и перцентилей.wall-clock timeСмотреть четыре показателя отдельноCPU timeПамять и выделенияРаспределение (медиана и p95) тоже отдельной рамкой

Рис. 14: Цифра скорости бывает не одного вида; её можно читать только когда рядом лежит и скрытая цена.

Рекомендуемый порядок выполнения

Поток, удобный на практике, примерно такой.

нетданетданетда1. Зафиксировать workload2. Зафиксировать общий набор данных3. Сначала пройти проверку корректностиchecksum всех реализацийсовпал?4. Зафиксировать условия сборки5. Разделить cold и warm6. Запускать, рандомизируя порядокНабрали нужноечисло повторов?8. Сохранить сырые данныеПоявилась смысловаяразница?Записать условия и число повторов и закончить9. Снять profile и копать причину

Рис. 15: Порядок выполнения от выбора workload и проверки корректности через рандомизированный боевой прогон до сохранения сырых данных.

1. Зафиксировать workload

Сначала ясно сформулировать, что именно хотите сравнить.

  • время запуска
  • устойчивый throughput
  • хвостовая задержка
  • эффективность памяти
  • масштабирование при параллелизме

2. Зафиксировать общий набор данных

Входные данные выравнивают фиксированным seed или фиксированным файлом. Если в измерение входит и генерация данных, она тоже должна идти в одинаковых условиях на каждом языке.

3. Сначала пройти проверку корректности

Убедиться, что на небольших и на больших данных все реализации возвращают один и тот же результат. Удобно, если реализации выводят checksum или хеш.

4. Зафиксировать условия сборки

На каждом языке собрать Release / оптимизированный исполняемый файл и записать версии и флаги.

5. Разделить cold и warm

Для C# и Java это особенно важно.

  • cold: включает момент сразу после старта процесса
  • warm: устойчивое состояние после нескольких запусков

Если нарисовать, до куда доходит измерение, сразу видно, что это разные вещи.

Диапазон, который измеряют как warmДиапазон, который измеряют как coldСама работа в устойчивом состоянииИнициализация рантаймазагрузка классовСтарт процессаПервый проход самой работыПервая компиляция JITВторой и последующие проходыидёт Tiered Compilation

Рис. 16: Cold включает старт процесса и первую компиляцию JIT, warm измеряет только устойчивое состояние.

У C++ и Go, поскольку они уже скомпилированы заранее, нет шага, который соответствовал бы «первой компиляции JIT», и инициализация рантайма сравнительно лёгкая. Большая часть разницы cold рождается здесь. Именно поэтому эти два варианта лучше не смешивать в одной таблице.

6. Чередовать или рандомизировать порядок запуска

Пример:

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

Так снижается перекос от нагрева и шума.

7. Набрать достаточное число повторов

Для лёгких микробенчмарков повторов нужно заметно больше, для end-to-end — не меньше 10. Если разница мала, а повторов мало, интерпретация становится довольно шаткой.

8. Сохранять сырые данные

Оставлять не только агрегированные результаты, но и сырые данные каждого run. Позже по ним читаются выбросы и особенности прогрева.

9. Если появилась разница, снять profile

Только когда разница действительно обнаружилась, стоит копать причину.

  • CPU profile
  • allocation profile
  • логи GC
  • flame graph
  • трассировка на стороне ОС

Дойдя до этого этапа, можно говорить не «быстро / медленно», а почему так происходит.

Как читать результаты

Даже после того как цифры получены, неверное прочтение по-прежнему опасно.

Медленные только на первом запуске C# / Java

Стоит заподозрить влияние JIT, загрузки классов и инициализации. В этом случае:

  • если важно время запуска — это значимая разница
  • если тема — долгая работа — это разница, которую стоит вынести в отдельную таблицу

C++ силён в tight loop

Возможно, здесь сказываются низкоуровневые оптимизации, раскладка объектов и минимальные накладные расходы рантайма. Но делать вывод «значит, и в реальном сервисе он будет самым быстрым», опираясь только на это, — слишком большой скачок.

Go выглядит выгодно по времени запуска и удобству поставки

Здесь могут сказываться единый двоичный файл, сравнительно лёгкий старт и удобная модель параллелизма. Но это не означает преимущества во всех CPU-ориентированных workload.

C# / Java заметно догоняют или даже обгоняют в steady-state

Возможно, здесь работают оптимизации JIT. Это тоже не редкость. Поэтому важно не смешивать сравнение со стартом и сравнение в устойчивом режиме.

Большая разница на обработке с интенсивным выделением памяти

В этом случае обычно сильнее влияет не название языка, а:

  • раскладка данных в памяти
  • обработка строк и map
  • поведение GC
  • лишние копии
Как читать появившуюся разницуРисунок показывает, что если медленны только первый запуск C# или Java, подозревают влияние JIT и инициализации: при важном времени запуска это значимая разница, при долгой работе её выносят в отдельную таблицу, а причину копают профилем.разница со стартомразница в устойчивом режимеПоявилась разницаПри каких условиях эта разница?Если тема — время запуска, она значимаЧитать как сравнение при долгой работеКопать причину профилем

Рис. 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 time процесса. Сумма user и system миллисекунды с дробной частью
max_rss_mb Пиковый RSS целое или дробное число MB
alloc_bytes Суммарное число выделенных байт. Если язык не даёт метрику, оставляют пустым — и сам факт пустоты тоже сохраняют целое или пусто
gc_count Число GC. В C++ всегда пусто целое или пусто
checksum Для проверки корректности. Отдельно проверяют, что он совпал у всех реализаций шестнадцатеричная строка
compiler_or_runtime Тип инструментария msvc / dotnet / temurin / go
compiler_version Версия инструментария. Пишут до минора строка версии, которую выдаёт сам инструментарий
flags Условия оптимизации. /O2, -O3 -flto, Server GC, GOMAXPROCS=8 и т. д. строка, разделённая пробелами
os / cpu / threads Окружение выполнения имя ОС и номер сборки, модель CPU, число используемых потоков
input_id Идентификатор набора данных. Надёжнее сделать его хешем файла имя файла и хеш
notes Заметка о run, где было что-то странное свободный текст

В столбцах измерений пустые значения допустимы, и сам факт пустоты оставляют. «В C++ нет gc_count» — это информация, а если столбец выкинуть целиком, потом это уже не видно.

Что теряется, если смотреть только среднее

При агрегации одно среднее выкидывает информацию. Дальше — вымышленные цифры, чтобы объяснить арифметику; это не измерение ни одного языка. Пусть elapsed_ms десяти run одной реализации, выстроенных от быстрого к медленному, такие:

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

Типичные значения по этим десяти числам получаются такими.

Показатель Значение Как читать
Среднее 163.4 Последний один раз утащил его примерно на 60% выше привычной рабочей зоны
Медиана 101.5 Близко к тому, что ощущается в 9 запусках из 10
min / max 98 / 720 Разница больше чем в 7 раз, это сигнал сначала искать причину выброса
p95 / p99 посчитать нельзя На 10 выборках ни 95-й, ни 99-й перцентиль смысла не имеют

То есть таблица только со средним делает неотличимыми «реализацию, которая иногда сильно останавливается» и «реализацию, которая стабильно чуть медленнее». В том, как устроена таблица, работает такая разница.

Угол зрения Слабая таблица результатов Рабочая таблица результатов
Типичное значение Только среднее Медиана в центре, рядом min / max и разброс
Число попыток Не указано Указаны число run и политика, отсекали ли выбросы
cold / warm Смешаны или без различия Отдельные таблицы или отдельные строки
Корректность Не упоминается Явно сказано, что checksum совпал у всех реализаций
Условия «Измерили на одном ПК» До ОС, CPU, версии инструментария, флагов оптимизации и числа потоков
Сырые данные Только агрегаты Рядом указано, где лежит raw CSV

Если хотите класть p95 или p99, число run нужно увеличить заранее. Чтобы говорить о распределении, нужны выборки, на которых распределение видно — вот и всё.

В бенчмарке важнее иногда не сам факт измерения, а возможность потом интерпретировать результат.

Что прячет одно среднееРисунок показывает, что один крупный выброс уводит среднее далеко от рабочей зоны, медиана ближе к ощущению, а большой разрыв min и max — сигнал искать причину выброса, поэтому таблица только со средним опасна.Один крупный выбросСреднее уходит вверх от рабочей зоныТаблица только со средним прячет картинуРядом кладут медиану, min и maxВыброс — сигнал искать причину

Рис. 18: Среднее не врёт, но молчит о том, что выброс есть.

Заключение

По-настоящему важно в сравнении скорости C# / C++ / Java / Go — превратить грубый вопрос «какой язык самый быстрый» в форму эксперимента: «какой workload, при каких условиях и по какому показателю сравниваем».

Особенно надёжны такие пункты.

  • Разделять время запуска и устойчивое состояние
  • Измерять одним алгоритмом, одним входом и одной проверкой корректности
  • Не делать вывод по одному бенчмарку
  • Разделять бенчмарк внутри языка и бенчмарк между языками
  • Смотреть медиану и распределение, а не только среднее
  • Сохранять условия и сырые данные

И самое важное в конце: не пытаться слишком настойчиво решать победителя по одному лишь имени языка. Реальная производительность складывается из языка, рантайма, библиотек, условий сборки, данных, ОС и оборудования.

«C++ быстрый», «Java сильна», «Go лёгкий», «C# тоже вполне быстрый» — в каком-то смысле все эти утверждения верны. Но если из них выпадает, при каких условиях это сказано, разговор обычно не сходится.

Выровнять условия, взять несколько workload, разделить cold / warm и смотреть вплоть до распределения. Неэффектно, но в итоге именно это оказывается самым надёжным подходом.

Грубый вопрос переводят в форму экспериментаРисунок показывает, что грубый вопрос «какой язык самый быстрый» переводят в форму эксперимента «какой workload, при каких условиях и по какому показателю сравниваем», выравнивают условия, гоняют несколько workload, разделяя cold и warm, и смотрят вплоть до распределения.Какой язык самый быстрыйПереформулировать в форму экспериментаЗафиксировать workload, условия и показательГонять, разделяя cold и warmСмотреть распределение и сохранять вместе с условиями

Рис. 19: Переформулировка вопроса — главный вывод этой статьи.

Справочные материалы

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

  • BenchmarkDotNet Setup and Cleanup (условия, при которых можно использовать [IterationSetup]) https://benchmarkdotnet.org/articles/features/setup-and-cleanup.html

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

  • JMH Level javadoc (ограничения и предупреждения Level.Invocation) https://javadoc.io/doc/org.openjdk.jmh/jmh-core/latest/org/openjdk/jmh/annotations/Level.html

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

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

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

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

  • Как правильно сравнивать скорость разных версий программы в Windows https://comcomponent.com/ru/blog/2026/03/16/002-windows-benchmark-comparing-program-versions/

Смежные темы

Эти страницы легче понять, если смотреть их вместе с этой статьёй.

Где обсудить эту тему

Проектирование сравнения производительности, выравнивание условий измерения, интерпретация результатов и углублённый разбор причин хорошо сочетаются со следующими услугами.

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Какой язык быстрее — C#, C++, Java или Go?
Одним числом это не решить. Реальная производительность складывается из языка, рантайма, библиотек, условий сборки, данных, ОС и оборудования. Важно превратить вопрос «какой язык самый быстрый» в форму эксперимента: «какой workload, при каких условиях и по какому показателю сравниваем». По времени запуска хорошо видны JIT и стоимость инициализации у C# и Java. В установившемся режиме (steady-state) оптимизации JIT часто почти догоняют соперников, а иногда и обходят их — это не редкость.
Почему в бенчмарках C# и Java отдельно выделяют прогрев (warm-up)?
C# и Java обычно зависят от JIT, поэтому измерение первого запуска захватывает не только скорость самой программы, но и старт рантайма, загрузку классов и подготовку 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, передаёт один набор данных, проверяет checksum и сохраняет сырые данные.
На что смотреть в микробенчмарках C++?
Нужно остерегаться ловушки, когда оптимизация выкидывает работу целиком. Если компилятор решает, что «результат этого вычисления никто не использует», он может удалить саму обработку — и тогда число означает не «быстро», а «ничего не делали». Поэтому важно потреблять результат, выводить checksum и пользоваться средствами benchmark-фреймворка, которые не дают оптимизации это стереть. Кроме того, в C++ разница условий сразу бьёт по результату, поэтому нужно явно фиксировать, каким компилятором, с какими флагами (-O3/O2, LTO, PGO и т. д.) и с какой STL измеряли.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог