Как честно сравнивать скорость 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 и как он устроен — тоже по-разному. Разница в реализации стандартной библиотеки и соседних библиотек бьёт сильно. Даже на одной машине результат легко разъезжается из-за схемы питания, нагрева, фоновой работы и перекоса во входных данных. На практике это довольно грязная, прикладная область.
flowchart TB
accTitle: Из-за чего результат разъезжается
accDescr: Рисунок показывает, что влияние JIT и прогрева, наличие и характер GC, разница реализаций стандартной и соседних библиотек, схема питания, нагрев, фоновая работа и перекос во входных данных накладываются, и результат измерения легко разъезжается.
n1["Влияние JIT и прогрева"] --> n4["Результат легко разъезжается"]
n2["Разница GC и библиотек"] --> n4
n3["Питание, нагрев, шум, перекос входа"] --> n4
n4 -.-> n5["Не выстраивать в ряд цифры из разных окружений"]
Рис. 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 по-настоящему работают эти семь пунктов.
-
Сначала решить, какую именно скорость сравниваете Способ измерения меняется в зависимости от того, нужно ли время запуска, throughput в устойчивом режиме, задержка p95 или эффективность памяти.
-
Не делать вывод по одному бенчмарку На вычислениях CPU, выделении памяти, параллельной работе и времени запуска сильными выглядят разные языки и рантаймы.
-
Для C# и Java разделять cold и warm Если смешать сравнение, которое включает первый запуск, со сравнением устойчивого состояния после прогрева, разговор перекашивается.
-
Измерять одним алгоритмом, одним входом и одной проверкой корректности Классика бенчмарков: реализация не быстрее — она решала другую задачу.
-
Разделять микробенчмарк внутри языка и сквозной (end-to-end) бенчмарк между языками Специализированный harness каждого языка удобен, но сравнение между языками лучше гонять внешним общим runner-ом.
-
Смотреть не только среднее, но и медиану и распределение Достаточно одного попадания GC или фоновой работы, чтобы среднее сломалось.
-
Сохранять не только цифры, но и условия Результат бенчмарка — это одновременно запись о скорости и запись об условиях эксперимента. К результату без описанных условий потом очень трудно вернуться.
Карта знаний этой статьи
Статья указывает на риск сравнивать скорость выполнения C#, C++, Java и Go одним числом и разбирает проектирование измерений для честного сравнения. У C# и Java разрыв между cold и warm велик из-за JIT-компиляции и Tiered Compilation, а C++ и Go обычно заранее компилируются (AOT), поэтому эти два типа не смешивают в одной таблице. В C++ нужна защита от удаления мёртвого кода, когда компилятор выкидывает неиспользуемое вычисление, — через Google Benchmark и проверку checksum. Измерение внутри языка делают специализированным harness вроде BenchmarkDotNet, JMH, go test -bench и Google Benchmark; для сравнения между языками рекомендуют двухуровневую схему, где внешний общий runner рандомизирует порядок запусков и проверяет checksum. Кроме того, смотрят не только среднее, но и медиану и распределение вроде хвостовой задержки p95/p99.
flowchart LR
accTitle: Карта знаний межъязыкового бенчмарка C#/C++/Java/Go
accDescr: Рисунок показывает, что JIT и Tiered Compilation у C# и Java против AOT у C++ и Go требуют разделять cold и warm; Google Benchmark и проверку checksum как защиту от удаления мёртвого кода в C++; двухуровневую схему с языковым harness и внешним общим runner; и оценку распределения вплоть до хвостовой задержки.
cross_language_benchmarking["проектирование кросс-языковых бенчмарков"]
csharp["C#"]
jit_compilation["JIT-компиляция (Just-In-Time)"]
java_lang["Java"]
cpp["C++"]
ahead_of_time_compilation["AOT-компиляция"]
golang["Go"]
tiered_compilation["Tiered Compilation"]
gomaxprocs["GOMAXPROCS"]
cgo["cgo"]
benchmarkdotnet["BenchmarkDotNet"]
jmh["JMH (Java Microbenchmark Harness)"]
go_benchmark_testing["go test -bench и benchstat"]
google_benchmark["Google Benchmark"]
warm_up_measurement["измерение после прогрева (warm)"]
dead_code_elimination["удаление мёртвого кода (dead code elimination)"]
checksum_verification["проверка корректности по checksum"]
tail_latency_percentile["хвостовые перцентили задержки (p95/p99)"]
garbage_collection["сборка мусора (GC)"]
csharp -.->|"использует"| jit_compilation
java_lang -.->|"использует"| jit_compilation
cpp -->|"использует"| ahead_of_time_compilation
golang -->|"использует"| ahead_of_time_compilation
csharp -->|"использует"| tiered_compilation
golang -->|"использует"| gomaxprocs
golang -.->|"использует"| cgo
csharp -.->|"использует"| benchmarkdotnet
java_lang -.->|"использует"| jmh
golang -.->|"использует"| go_benchmark_testing
cpp -.->|"использует"| google_benchmark
benchmarkdotnet -->|"использует"| warm_up_measurement
jmh -->|"использует"| warm_up_measurement
google_benchmark -.->|"предотвращает"| dead_code_elimination
cpp -.->|"может вызвать"| dead_code_elimination
checksum_verification -->|"предотвращает"| dead_code_elimination
cross_language_benchmarking -->|"использует"| tail_latency_percentile
csharp -->|"использует"| garbage_collection
java_lang -->|"использует"| garbage_collection
golang -->|"использует"| garbage_collection
cross_language_benchmarking -.->|"использует"| warm_up_measurement
cross_language_benchmarking -->|"использует"| checksum_verification
cross_language_benchmarking -->|"использует"| jit_compilation
cross_language_benchmarking -->|"использует"| ahead_of_time_compilation
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 24, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
Что решить в первую очередь
Если обойтись одним словом «быстро», почти наверняка всё пойдёт не так. Сначала нужно решить, что именно вы называете быстрым.
Даже у одной и той же программы то, что хочется увидеть, может сильно различаться.
1. Нужно ли время запуска
Для CLI-инструмента, короткоживущего пакетного задания и вспомогательной утилиты, которая стартует один раз и сразу завершается, решают cold start и process startup. По этой оси результат сильно зависит от того, включать ли стоимость инициализации JIT и загрузки классов.
2. Нужен ли throughput при долгой работе
Для сервера, постоянно живущего процесса, воркера и долгой конвертации важен throughput в устойчивом режиме (steady-state). Здесь то, что первый запуск сам по себе медленный, не суть: тема в том, насколько стабильно показатель растёт после прогрева.
3. Нужна ли хвостовая задержка (tail latency)
В API, UI и обработке, близкой к реальному времени, p95 / p99 иногда важнее среднего. Даже если в среднем всё быстро, редкие крупные остановки болезненны и для пользовательского опыта, и для SLA.
4. Нужно ли смотреть ещё и на эффективность памяти
Если смотреть только на время CPU и не учитывать пиковый RSS, объём выделений, число сборок мусора и паузы GC, реальную тяжесть в эксплуатации легко прочитать неверно. «Быстро, но сильно ест память» и «чуть медленнее, но стабильно легче» меняются местами в зависимости от сценария.
Иными словами, вопрос, который нужно решить сначала, звучит так:
В этом сравнении нужно узнать не то, какой язык быстрее, а какой workload, при каких условиях и по какому показателю обрабатывается быстрее.
Если собирать цифры, оставляя это размытым, в конце ничего не сложится.
flowchart TB
accTitle: Сначала решить, что считать быстрым
accDescr: Рисунок показывает, что способ измерения меняется в зависимости от того, смотрите ли вы время запуска, throughput в устойчивом режиме, хвостовую задержку или эффективность памяти, поэтому до сравнения нужно решить, какой workload при каких условиях и по какому показателю вы смотрите.
q1["Что именно нужно узнать этим сравнением"] --> a1["Время запуска"]
q1 --> a2["Устойчивый throughput"]
q1 --> a3["Задержка p95 / p99"]
a3 -.-> a4["Эффективность памяти — отдельная ось"]
a1 -.-> a5["По каждой оси способ измерения свой"]
Рис. 2: Пока нет определения «быстро», цифры собирать рано.
Почему сравнивать языки сложно
Если смешать JIT и AOT, это уже другой эксперимент
C# и Java обычно зависят от JIT. C++ и Go, напротив, обычно уже скомпилированы заранее.
То есть измерение первого запуска захватывает не только скорость самой программы, но и старт рантайма, загрузку классов и подготовку JIT. И наоборот, если смотреть только на состояние после достаточного прогрева, сравнение становится вопросом о том, насколько далеко заходит оптимизация в устойчивом режиме.
Оба варианта имеют смысл. Но это не одно и то же.
flowchart TB
accTitle: Разница между группой JIT и группой AOT
accDescr: Рисунок показывает, что C# и Java обычно зависят от JIT, поэтому в первый запуск смешиваются старт рантайма, загрузка классов и подготовка JIT, а C++ и Go обычно уже скомпилированы заранее, поэтому сравнение первого запуска и сравнение после прогрева — это не одно и то же.
j1["C# и Java (обычно JIT)"] --> j2["В первый запуск смешиваются старт и подготовка JIT"]
g1["C++ и Go (обычно AOT)"] --> g2["Работают уже скомпилированными"]
j2 --> mix["Сравнение первого запуска и устойчивого режима — разные эксперименты"]
g2 --> mix
Рис. 3: У языков с разной моделью выполнения то, откуда начинать измерение, определяет содержимое эксперимента.
Разница реализаций нередко больше разницы языков
Даже для одной и той же «сортировки»:
- одна сторона берёт стандартную библиотеку
- другая — свою реализацию
- одна делает лишние копии
- другая каждый раз заново генерирует вход
Одного этого достаточно, чтобы результат сильно изменился.
Дальше, на JSON, сжатии, криптографии, регулярных выражениях разница реализации библиотек влияет куда сильнее, чем сам язык. Поэтому, если явно не сказать, что именно измеряется, задуманное как «сравнение языков» превращается в «сравнение библиотек».
flowchart TB
accTitle: Сравнение языков подменяется сравнением библиотек
accDescr: Рисунок показывает, что даже при одной и той же задаче результат меняется от того, стандартная это библиотека или своя реализация, есть ли лишние копии и перегенерация входа, а на JSON, сжатии, криптографии и регулярных выражениях сильнее влияет реализация библиотек, поэтому без явного указания объекта измерения сравнение языков становится сравнением библиотек.
d1["Реализации, которые вроде решают одно и то же"] --> d2["Смешиваются разница реализации и библиотек"]
d2 --> d3{"Явно сказали, что измеряем?"}
d3 -->|"нет"| d4["Хотели сравнить языки, сравнили библиотеки"]
d3 -->|"да"| d5["Можно читать как сравнение"]
Рис. 4: Большая часть недоразумений пропадает, если правильно назвать то, что измеряете.
У C++ есть ловушка: оптимизация выкидывает работу
Особенно в микробенчмарке компилятор может решить, что «этот результат никто не использует», и удалить саму обработку. Тогда вы измеряете не скорость, а ситуацию, когда работы нет вообще. Типичная картина: исчезает весь цикл, и время выполнения выглядит почти нулевым.
В C++ это проявляется особенно откровенно, поэтому потребление результата, вывод checksum и средства benchmark-фреймворка, которые не дают оптимизации это стереть, здесь особенно важны.
flowchart TB
accTitle: Ловушка, когда оптимизация выкидывает работу
accDescr: Рисунок показывает, что в микробенчмарке компилятор может удалить обработку, если решит, что результат никто не использует, и тогда измеряется не скорость, а отсутствие работы, поэтому важно потреблять результат, выводить checksum и пользоваться средствами подавления оптимизаций.
o1["Результат вычисления никто не использует"] --> o2["Компилятор выкидывает обработку"]
o2 --> o3["Время выполнения выглядит почти нулевым"]
o3 -.-> o4["Это не быстро, это ничего не делали"]
o3 --> o5["Предотвращают выводом checksum и средствами подавления"]
Рис. 5: Подозрительно быстрый результат сначала проверяйте на то, не исчезла ли работа.
GC — не «минус» и не «плюс», а характеристика
В C#, Java и Go есть сборщик мусора (GC). Свести это к «раз есть GC, значит медленно» слишком грубо.
На практике сильнее влияют:
- как обрабатывается большой поток короткоживущих объектов
- настройка размера кучи
- частота GC и паузы
- раскладка объектов
- привычки библиотек выделять память
C++ наоборот позволяет тонко управлять вручную и через RAII, но именно поэтому сильнее выходит разница проектирования и реализации. То есть разница способа управления сама по себе не есть хорошо или плохо.
flowchart TB
accTitle: GC — не минус, а характеристика
accDescr: Рисунок показывает, что в языках с GC сильнее влияют обработка короткоживущих объектов, настройки кучи, частота и паузы GC и привычки выделения, а C++ даёт тонкий контроль ценой большей разницы реализаций, поэтому разница способа управления сама по себе не есть превосходство.
gc1["Языки с GC"] --> gc2["Сильнее влияют настройки и привычки выделения"]
gp1["Ручное управление и RAII в C++"] --> gp2["Контроль есть, но разница реализаций выходит сильнее"]
gc2 --> gv1["Разница способа управления — не превосходство"]
gp2 --> gv1
Рис. 6: Зачем эта схема: не сводить всё к фразе «GC есть, значит медленно».
Чего нельзя делать при сравнении
1. Смешивать Debug и Release
Это даже не обсуждается. Объекты сравнения обязательно выравнивают на оптимизированную сборку, близкую к продакшену.
2. Не решать одну и ту же задачу
Разный формат входа, разный вывод, обработка ошибок есть только у одной стороны, разная политика повторного использования памяти. Если это оставить как есть, вы измерите не скорость, а разницу требований.
3. Делать вывод по одному запуску
Один запуск — это почти всегда шум.
- JIT
- кэш страниц
- boost CPU
- нагрев
- фоновые задачи
- GC
- первое чтение файла
Всё это смешивается в одном запуске.
flowchart TB
accTitle: Что смешивается в одном запуске
accDescr: Рисунок показывает, что в одном запуске смешиваются JIT, кэш страниц, boost CPU, нагрев, фоновые задачи, GC и первое чтение файла, поэтому результат одного запуска почти всегда шум.
x1["JIT и первое чтение"] --> x4["В одном запуске смешивается всё"]
x2["Кэш и boost CPU"] --> x4
x3["Нагрев и фоновая работа"] --> x4
x4 --> x5["Результат одного запуска почти всегда шум"]
Рис. 7: В единственной цифре больше всего того, что измерять как раз не хотели.
4. Смешивать прогрев
Когда измеряете C# и Java, если оставить размытым, включать ли первый запуск или смотреть только состояние после прогрева, обсуждение разваливается. Cold и warm — разные вещи.
5. Не проверять корректность
Прежде чем быть «быстрым», бенчмарк должен «возвращать один и тот же результат». Обязательно проверяйте, что все сравниваемые реализации на одном входе дают один checksum или один вывод.
6. Строить всю картину по одному микробенчмарку
Победа только в tight loop не означает победу во всём реальном сервисе. И наоборот, проигрыш по времени запуска не мешает быть вполне сильным при долгой работе.
Базовый подход к сравнению C# / C++ / Java / Go
Этот момент довольно важен. Рекомендуемая схема — двухуровневая.
flowchart TB
subgraph outer["Внешний слой: общий runner для сравнения между языками"]
direction TB
R["Общий runner<br/>рандомизация порядка / разделение cold и warm<br/>проверка checksum / сохранение сырых данных"]
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-инструменты, которые берут на себя его особенности.
- 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 всего процесса, поэтому остаются время со стартом и время только самой работы.
flowchart TB
accTitle: CLI-контракт и контракт вывода
accDescr: Рисунок показывает, что если каждую языковую реализацию оформить исполняемым файлом с одним CLI-контрактом и печатать в стандартный вывод только две строки checksum и inner_ms, runner отдельно меряет время всего процесса, поэтому остаются и время со стартом, и время только самой работы.
ct1["Исполняемые файлы с одним CLI-контрактом"] --> ct2["Печатают две строки: checksum и inner_ms"]
ct2 --> ct3["Runner меряет wall-clock всего процесса"]
ct3 --> ct4["Остаются и старт, и только сама работа"]
Рис. 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
Так проще раздельно держать лучшие практики внутри каждого языка и честность сравнения между языками.
flowchart TB
accTitle: Три намерения общего runner
accDescr: Рисунок показывает, что общий runner намеренно ставит проверку корректности по checksum раньше измерения скорости, перемешивает порядок на каждом run и пишет только сырые данные без агрегации.
rn1["Проверку корректности — раньше скорости"] --> rn4["Измерение, которое можно сравнивать честно"]
rn2["Перемешивание на каждом run"] --> rn4
rn3["Писать сырые данные без агрегации"] --> rn4
rn2 -.-> rn5["Сглаживает перекос от нагрева и времени суток"]
Рис. 10: Работа runner — не прогнать быстрее, а снять подозрения.
Конкретный пример: какие сценарии бенчмарков готовить
Когда просят «сравнить C# / C++ / Java / Go», если тест один, лучше взять простой CPU-сценарий, который трудно истолковать неверно; если тестов несколько — подготовить 3–4 workload разного характера.
Рекомендуемый набор
1. sort_int32_10m
Цель: посмотреть CPU + пропускную способность памяти + использование временной области
- Вход: 10 миллионов значений
int32, сгенерированных с фиксированным seed - Обработка: отсортировать массив и вернуть checksum
- На что смотреть: каждый раз возвращаться к одному и тому же неотсортированному входу
Это относительно понятный сценарий. Но в него входит и разница стандартных реализаций сортировки, поэтому это скорее сравнение вместе со стандартной библиотекой, чем сравнение самого языка.
2. hash_group_count
Цель: посмотреть хеш-таблицы, обработку строк, выделения и тенденции 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. Иначе говоря, даже если здесь появится разница, это не победитель в задачах, которые работают долго.
flowchart TB
accTitle: Четыре сценария разного характера
accDescr: Рисунок показывает набор из четырёх сценариев, у которых видно разное: сортировка — CPU и пропускная способность памяти, подсчёт слов — хеш, строки и выделения, параллельное хеширование — рост при параллельном выполнении, стартовый бенчмарк — время запуска.
w1["sort_int32_10m"] -.-> v1["CPU, канал и временная область"]
w2["hash_group_count"] -.-> v2["Строки, map, тенденции GC"]
w3["parallel_sha256"] -.-> v3["Рост при параллельном выполнении"]
w4["семейство startup"] -.-> v4["Стоимость запуска и инициализации"]
w1 --> w2
w2 --> w3
w3 --> w4
Рис. 11: Одним сценарием всего не увидеть, поэтому пункты делят по тому, что хотите увидеть.
Как быть с бенчмарками JSON и HTTP
JSON и HTTP близки к практике, так что смысл в них, конечно, есть. Но тогда это уже не сравнение языков, а сравнение вместе с библиотеками, фреймворками и экосистемой.
Само по себе это не плохо. На практике такое сравнение нередко даже важнее. Но в статье или отчёте меньше поводов для недопонимания, если прямо написать:
Это не сравнение языков, а сравнение типовых реализаций вместе с основными библиотеками.
Условия, которые нужно выровнять для каждого языка
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 или его отсутствие полностью меняет картину, поэтому это обязательно оставляют в условиях.
Как выровнять окружение выполнения
На каком бы языке ни шла речь, сравнение без выровненного окружения на деле почти всегда сравнивает сами окружения.
flowchart TB
accTitle: Чем на самом деле является сравнение без выровненного окружения
accDescr: Рисунок показывает, что в сравнении, где не выровнены CPU, ОС, условия питания, входные данные, приоритет и число ядер, разница в результате оказывается разницей окружений, а не языков.
en1["Два измерения с невыровненным окружением"] --> en2["В результате есть разница"]
en2 --> en3{"Чем вызвана эта разница?"}
en3 -->|"если окружение выровнено"| en4["Можно читать как разницу реализации или языка"]
en3 -->|"если не выровнено"| en5["Сравниваете только окружения"]
Рис. 12: Разницу можно списать на язык только после того, как убрали разницу окружений.
Что нужно выровнять
- Один и тот же CPU / память / накопитель
- Одна и та же версия ОС
- Одни и те же условия питания
- Условия, максимально близкие к одной комнатной температуре
- Одни и те же входные данные
- Один и тот же приоритет процесса
- Одни и те же условия по числу ядер
- Одни и те же условия контейнера или bare-metal
Что влияет особенно сильно
Параметры питания и частота CPU
На ноутбуке одно только различие «от сети или от батареи» создаёт совершенно разные условия. Если не выровнять CPU governor или power mode, результаты сравнения сильно разъезжаются.
Как в Windows выравнивать условия питания, уведомления, фоновый шум, нагрев и порядок запуска, подробно разобрано в отдельной статье Как правильно сравнивать скорость разных версий программы в Windows. Если измерения идут в Windows, этот момент имеет большое значение.
Нагрев
Если быстрыми оказываются только первые несколько запусков, а дальше показатели падают, стоит заподозрить нагрев и троттлинг. Вместо того чтобы прогнать все запуски A, а потом все запуски B, чередование в духе A / B / A / B снижает перекос.
flowchart TB
accTitle: Перекос от нагрева и порядок запуска
accDescr: Рисунок показывает, что если быстрыми оказываются только первые запуски, а дальше результаты падают, стоит заподозрить нагрев и троттлинг и не гонять сначала все A, а чередовать A и B, чтобы снизить перекос.
th1["Падают только поздние результаты"] --> th2["Подозревать нагрев и троттлинг"]
th2 --> th3["Не гонять сначала все A, потом B"]
th3 --> th4["Чередовать 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
- Стандартное отклонение и разброс
Если судить только по среднему, не видно, что стоит за редкими выбросами.
flowchart TB
accTitle: Четыре показателя, которые смотрят отдельно
accDescr: Рисунок показывает, что в сравнении языков отдельно смотрят wall-clock как реальное время ожидания пользователя, фактическое время CPU, память вроде пикового RSS и выделений, и распределение вроде медианы и перцентилей.
ms1["wall-clock time"] --> ms5["Смотреть четыре показателя отдельно"]
ms2["CPU time"] --> ms5
ms3["Память и выделения"] --> ms5
ms5 -.-> ms4["Распределение (медиана и p95) тоже отдельной рамкой"]
Рис. 14: Цифра скорости бывает не одного вида; её можно читать только когда рядом лежит и скрытая цена.
Рекомендуемый порядок выполнения
Поток, удобный на практике, примерно такой.
flowchart TB
a1["1. Зафиксировать workload"] --> a2["2. Зафиксировать общий набор данных"]
a2 --> a3["3. Сначала пройти проверку корректности"]
a3 --> a4{"checksum всех реализаций<br/>совпал?"}
a4 -- нет --> a3
a4 -- да --> a5["4. Зафиксировать условия сборки"]
a5 --> a6["5. Разделить cold и warm"]
a6 --> a7["6. Запускать, рандомизируя порядок"]
a7 --> a8{"Набрали нужное<br/>число повторов?"}
a8 -- нет --> a7
a8 -- да --> a9["8. Сохранить сырые данные"]
a9 --> a10{"Появилась смысловая<br/>разница?"}
a10 -- нет --> a11["Записать условия и число повторов и закончить"]
a10 -- да --> a12["9. Снять profile и копать причину"]
Рис. 15: Порядок выполнения от выбора workload и проверки корректности через рандомизированный боевой прогон до сохранения сырых данных.
1. Зафиксировать workload
Сначала ясно сформулировать, что именно хотите сравнить.
- время запуска
- устойчивый throughput
- хвостовая задержка
- эффективность памяти
- масштабирование при параллелизме
2. Зафиксировать общий набор данных
Входные данные выравнивают фиксированным seed или фиксированным файлом. Если в измерение входит и генерация данных, она тоже должна идти в одинаковых условиях на каждом языке.
3. Сначала пройти проверку корректности
Убедиться, что на небольших и на больших данных все реализации возвращают один и тот же результат. Удобно, если реализации выводят checksum или хеш.
4. Зафиксировать условия сборки
На каждом языке собрать Release / оптимизированный исполняемый файл и записать версии и флаги.
5. Разделить cold и warm
Для C# и Java это особенно важно.
- cold: включает момент сразу после старта процесса
- warm: устойчивое состояние после нескольких запусков
Если нарисовать, до куда доходит измерение, сразу видно, что это разные вещи.
flowchart LR
subgraph coldrange["Диапазон, который измеряют как cold"]
direction LR
s1["Старт процесса"] --> s2["Инициализация рантайма<br/>загрузка классов"]
s2 --> s3["Первая компиляция JIT"] --> s4["Первый проход самой работы"]
end
s4 --> s5["Второй и последующие проходы<br/>идёт Tiered Compilation"]
subgraph warmrange["Диапазон, который измеряют как warm"]
direction LR
s6["Сама работа в устойчивом состоянии"]
end
s5 --> s6
Рис. 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
- лишние копии
flowchart TB
accTitle: Как читать появившуюся разницу
accDescr: Рисунок показывает, что если медленны только первый запуск C# или Java, подозревают влияние JIT и инициализации: при важном времени запуска это значимая разница, при долгой работе её выносят в отдельную таблицу, а причину копают профилем.
rd1["Появилась разница"] --> rd2{"При каких условиях эта разница?"}
rd2 -->|"разница со стартом"| rd3["Если тема — время запуска, она значима"]
rd2 -->|"разница в устойчивом режиме"| rd4["Читать как сравнение при долгой работе"]
rd3 --> rd5["Копать причину профилем"]
rd4 --> rd5
Рис. 17: Прежде чем смотреть на величину цифр, проверьте, на какой площадке эта разница.
Шаблон записи результатов
Если в результатах бенчмарка оставлять хотя бы такие поля, потом это очень помогает.
timestamp,language,scenario,run_kind,cold_or_warm,elapsed_ms,cpu_ms,max_rss_mb,alloc_bytes,gc_count,checksum
compiler_or_runtime,compiler_version,flags,os,cpu,threads,input_id,notes
Например, run_kind можно разделить так:
micromacrostartupparallel
Для cold_or_warm обязательно нужно явно указывать, о каком из вариантов идёт речь.
coldwarm
Что класть в каждый столбец, лучше решить заранее — тогда запись не разъедется.
| Столбец | Что класть | Пример формата |
|---|---|---|
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 нужно увеличить заранее. Чтобы говорить о распределении, нужны выборки, на которых распределение видно — вот и всё.
В бенчмарке важнее иногда не сам факт измерения, а возможность потом интерпретировать результат.
flowchart TB
accTitle: Что прячет одно среднее
accDescr: Рисунок показывает, что один крупный выброс уводит среднее далеко от рабочей зоны, медиана ближе к ощущению, а большой разрыв min и max — сигнал искать причину выброса, поэтому таблица только со средним опасна.
av1["Один крупный выброс"] --> av2["Среднее уходит вверх от рабочей зоны"]
av2 --> av3["Таблица только со средним прячет картину"]
av3 --> av4["Рядом кладут медиану, min и max"]
av4 -.-> av5["Выброс — сигнал искать причину"]
Рис. 18: Среднее не врёт, но молчит о том, что выброс есть.
Заключение
По-настоящему важно в сравнении скорости C# / C++ / Java / Go — превратить грубый вопрос «какой язык самый быстрый» в форму эксперимента: «какой workload, при каких условиях и по какому показателю сравниваем».
Особенно надёжны такие пункты.
- Разделять время запуска и устойчивое состояние
- Измерять одним алгоритмом, одним входом и одной проверкой корректности
- Не делать вывод по одному бенчмарку
- Разделять бенчмарк внутри языка и бенчмарк между языками
- Смотреть медиану и распределение, а не только среднее
- Сохранять условия и сырые данные
И самое важное в конце: не пытаться слишком настойчиво решать победителя по одному лишь имени языка. Реальная производительность складывается из языка, рантайма, библиотек, условий сборки, данных, ОС и оборудования.
«C++ быстрый», «Java сильна», «Go лёгкий», «C# тоже вполне быстрый» — в каком-то смысле все эти утверждения верны. Но если из них выпадает, при каких условиях это сказано, разговор обычно не сходится.
Выровнять условия, взять несколько workload, разделить cold / warm и смотреть вплоть до распределения. Неэффектно, но в итоге именно это оказывается самым надёжным подходом.
flowchart TB
accTitle: Грубый вопрос переводят в форму эксперимента
accDescr: Рисунок показывает, что грубый вопрос «какой язык самый быстрый» переводят в форму эксперимента «какой workload, при каких условиях и по какому показателю сравниваем», выравнивают условия, гоняют несколько workload, разделяя cold и warm, и смотрят вплоть до распределения.
sq1["Какой язык самый быстрый"] --> sq2["Переформулировать в форму эксперимента"]
sq2 --> sq3["Зафиксировать workload, условия и показатель"]
sq3 --> sq4["Гонять, разделяя cold и warm"]
sq4 --> sq5["Смотреть распределение и сохранять вместе с условиями"]
Рис. 19: Переформулировка вопроса — главный вывод этой статьи.
Справочные материалы
-
BenchmarkDotNet Getting Started https://benchmarkdotnet.org/articles/guides/getting-started.html
-
BenchmarkDotNet Setup and Cleanup (условия, при которых можно использовать
[IterationSetup]) https://benchmarkdotnet.org/articles/features/setup-and-cleanup.html -
OpenJDK JMH Project https://openjdk.org/projects/code-tools/jmh/
-
JMH
Leveljavadoc (ограничения и предупрежденияLevel.Invocation) https://javadoc.io/doc/org.openjdk.jmh/jmh-core/latest/org/openjdk/jmh/annotations/Level.html -
JMH GitHub Repository / README https://github.com/openjdk/jmh
-
Go
testingpackage https://pkg.go.dev/testing -
Go
benchstathttps://pkg.go.dev/golang.org/x/perf/cmd/benchstat -
Google Benchmark User Guide https://google.github.io/benchmark/user_guide.html
-
Как правильно сравнивать скорость разных версий программы в Windows https://comcomponent.com/ru/blog/2026/03/16/002-windows-benchmark-comparing-program-versions/
Смежные темы
Эти страницы легче понять, если смотреть их вместе с этой статьёй.
Где обсудить эту тему
Проектирование сравнения производительности, выравнивание условий измерения, интерпретация результатов и углублённый разбор причин хорошо сочетаются со следующими услугами.
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Именованные каналы на практике — от проектирования до безопасности IPC в Windows
Практический разбор именованных каналов — стандартного IPC в Windows. По первоисточникам: выбор байтового режима и режима сообщений, серв...
Ложное пробуждение: почему wait на условной переменной возвращается без уведомления и как ждать в Windows
wait на условной переменной может вернуться и без уведомления (ложное пробуждение). Разбираем по реализации Windows, почему спецификация ...
Обратная совместимость интерфейсов DLL и COM — таблица: какие изменения ломают вызывающий код
Какие изменения DLL или COM-компонента ломают вызывающий код. Разбираем три слоя совместимости — бинарную, исходного кода и поведенческую...
Чек-лист безопасной работы с дочерними процессами в Windows-приложении
Чтобы безопасно работать с дочерними процессами в Windows-приложении, важнее не API запуска, а владение деревом процессов и процедура зав...
Разделяемая память: подводные камни и практические рекомендации
Подводные камни разделяемой памяти на практике и проектирование, которое снижает частоту сбоев: синхронизация, видимость, время жизни, AB...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Технические консультации и ревью дизайна
Тема хорошо стыкуется с технической консультацией и ревью проектирования: от схемы сравнения производительности и выравнивания условий измерения до прогрева и чтения статистики.
Расследование ошибок и причин
Разделение причин разницы в производительности между языками и версиями, поиск узких мест и проверка, насколько корректна сама процедура измерения, удобно вести как расследование сбоя и анализ первопричин.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Какой язык быстрее — 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.