Содержание статьи
В этой статье мы изучим алгоритмы тестирования PassMark и воспроизведем их самостоятельно на других языках программирования, чтобы получить независимую оценку производительности.
Общий обзор работы PassMark
Если зайти на официальную страницу загрузки PassMark, мы увидим, что программа эта кросс‑платформенная. На первый взгляд может показаться, что она собирается из единой кодовой базы (по крайней мере, ядро с набором бенчмарков), однако это не так — исходный код, как и интерфейс, сильно разнятся от платформы к платформе. Тем не менее работающее под управлением разных ОС приложение оценивает производительность примерно одинаково (в ходе аудита мы проверим, так ли это на самом деле).
Windows-версия PassMark, к примеру, представлена в виде множества исполняемых файлов под каждую группу тестов, а версия для macOS — как standalone-файл, содержащий в себе сразу все тесты.
При запуске любого теста приложение PassMark вне зависимости от платформы порождает новые процессы в количестве, соответствующем числу ядер процессора, для достижения максимальной загрузки CPU — по одному процессу на каждое ядро. После отработки каждого процесса программа собирает целочисленные результаты для дальнейшего суммирования.
Способы порождения новых процессов на разных платформах отличаются. В macOS делается форк основного процесса PassMark, а в Windows запускается отдельный процесс PT-CPUTest
с параметрами командной строки, позволяющими контролировать длительность теста по времени и число используемых ядер процессора:
PT-CPUTest.exe [-slave|-standalone] [номер бенчмарка] [число ядер] [длительность в миллисекундах]
Это же отличие, кстати, позволяет задавать число ядер в настройках Windows-версии программы и не позволяет в macOS.
Таким образом, способ запуска любого бенчмарка PassMark сводится к запуску N процессов, где N — число ядер CPU, ожиданию завершения всех процессов и суммированию целочисленных результатов от них (где каждый результат в среднем равен суммарному
, но это не всегда так, о чем мы еще поговорим). Суммарный результат — это и есть то, что PassMark отображает в своей публичной базе для каждого устройства.
Алгоритм работы бенчмарков
Чтобы посмотреть, как устроены алгоритмы бенчмарков, нужно загрузить исполняемый файл PassMark в любой дизассемблер.
Все бенчмарки внутри PassMark вне зависимости от платформы организованы следующим образом: по номеру бенчмарка запускается нужный тест. Дизассемблированный код, запускающий тесты PassMark, выглядит следующим образом.
Каждый бенчмарк, в свою очередь, организован так: запускается цикл длительностью в заданное количество миллисекунд (обычно 6000), на каждой итерации бенчмарка производится некоторое количество вычислительных операций (полезная нагрузка), а в конце цикла сумма произведенных операций делится на время по следующей формуле:
результат на одно ядро = суммарное количество выполненных вычислительных операций / затраченное время в микросекундах
Таким образом, все результаты бенчмарков на скриншоте ниже — это не что иное, как количество вычислительных операций определенного типа (зависит от бенчмарка) в микросекунду на заданном числе ядер процессора (по умолчанию — на всех ядрах).
Воспроизведение кода бенчмарков
Приступаем к самому интересному. Возьмем пару бенчмарков со скриншота выше — IntegerMathTest
и FloatingPointMathTest
. Воспроизводить любые тесты лучше всего на низкоуровневом языке программирования без виртуальной машины, чтобы сократить вычислительные затраты на трансляцию кода из IL в нативный код, которая снизит итоговый результат бенчмарков. Далее мы сравним результаты бенчмарков, реализованных на C# (с IL), а пока остановимся на C++ (без IL).
Код бенчмарка IntegerMathTest
(С++) будет выглядеть так:
#include <iostream>#include <vector>#include <random>#include <chrono>int main() { // Время работы бенчмарка по умолчанию long long milliseconds = 6000; // Размер буфера чисел для бенчмарка в тесте (нужен, чтобы избежать работы L1/L2/L3-кеша процессора) int randomBufferSize = 20000; // Заполнение буфера 32-битных чисел для участия в бенчмарке std::vector<int> mem1; std::mt19937 rnd1(777); for (int n = 0; n < randomBufferSize; n++) { int a = static_cast<int>(rnd1() % INT_MAX) << 17; int b = static_cast<int>(rnd1() % INT_MAX); int r = a + b; mem1.push_back(r == 0 ? 1 : r); } // Заполнение буфера 64-битных чисел для участия в бенчмарке std::vector<long long> mem2; std::mt19937 rnd2(777); for (int n = 0; n < randomBufferSize; n++) { long long a = static_cast<long long>(rnd2() % INT_MAX) << 15; long long b = (a + static_cast<long long>(rnd2() % INT_MAX)) << 15; long long c = (b + static_cast<long long>(rnd2() % INT_MAX)) << 15; long long d = static_cast<long long>(rnd2() % INT_MAX); long long r = c + d; mem2.push_back(r == 0 ? 1 : r); } // Числа, участвующие в бенчмарке, вынесены из цикла, чтобы не тратить ресурсы на выделение памяти на каждой итерации int mem_index = 0; long long v27 = 0; const int addSubOpsCount = 1000; const int mulDivOpsCount = 500; int v11, v17, v4, v10, v12, v13, v18; long long v15, v16, v14, v8, v9, v31, i; // Таймер для подсчета прошедшего времени auto start_time = std::chrono::high_resolution_clock::now(); while (true) { // Значения для 32-битного эксперимента v4 = mem1[mem_index + 1]; v10 = mem1[mem_index + 0]; v11 = mem1[mem_index + 2]; v12 = mem1[mem_index + 3]; v13 = mem1[mem_index + 4]; v17 = mem1[mem_index + 5]; v18 = v4; // Значения для 64-битного эксперимента v14 = mem2[mem_index + 0]; v8 = mem2[mem_index + 1]; v15 = mem2[mem_index + 2]; v31 = mem2[mem_index + 3]; i = v31; v9 = mem2[mem_index + 4]; v16 = mem2[mem_index + 5]; mem_index = (mem_index + 6) % (randomBufferSize - 6); int v19 = addSubOpsCount; // 32-битный эксперимент: сложение/вычитание (66 операций) do { v10 = (~(v11 + (v18 ^ ((v11 & (v11 + ((v11 - v18 + v10) << 8))) - v18))) - v18) | 0x337; v12 = (~(v17 + (v13 ^ ((v17 & (v17 + ((v17 - v13 + v12) << 8))) - v13))) - v13) | 0x337; v18 = (~(v11 + (v10 ^ ((v11 & (v11 + ((v11 - v10 + v18) << 8))) - v10))) - v10) | 0x1C9; v13 = (~(v17 + (v12 ^ ((v17 & (v17 + ((v17 - v12 + v13) << 8))) - v12))) - v12) | 0x1C9; v11 = (~(v10 + (v18 ^ ((v10 & (v10 + ((v10 + v11 - v18) << 8))) - v18))) - v18) | 0x2A1; v17 = (~(v12 + (v13 ^ ((v12 & (v12 + ((v12 + v17 - v13) << 8))) - v13))) - v13) | 0x2A1; --v19; } while (v19 > 0); long long v20 = addSubOpsCount; long long i = v31; // 64-битный эксперимент: сложение/вычитание (66 операций) for (; v20 > 0; --v20) { v14 = (~(v15 + (v8 ^ ((v15 & (v15 + ((v15 - v8 + v14) << 8))) - v8))) - v8) | 0x337; i = (~(v16 + (v9 ^ ((v16 & (v16 + ((v16 - v9 + i) << 8))) - v9))) - v9) | 0x337; v8 = (~(v15 + (v14 ^ ((v15 & (v15 + ((v15 - v14 + v8) << 8))) - v14))) - v14) | 0x1C9; v9 = (~(v16 + (i ^ ((v16 & (v16 + ((v16 - i + v9) << 8))) - i))) - i) | 0x1C9; v15 = (~(v14 + (v8 ^ ((v14 & (v14 + ((v14 + v15 - v8) << 8))) - v8))) - v8) | 0x2A1; v16 = (~(i + (v9 ^ ((i & (i + ((i + v16 - v9) << 8))) - v9))) - v9) | 0x2A1; } int v22 = mulDivOpsCount; // 32-битный эксперимент: умножение/деление (10 операций) do { v11 = v18 | (v18 * v10 * v11 * v10 * v11 / v10); v17 = v13 | (v13 * v12 * v17 * v12 * v17 / v12); --v22; } while (v22 > 0); // 64-битный эксперимент: умножение/деление (10 операций) for (int j = mulDivOpsCount; j > 0; --j) { v15 = v8 | (v8 * v14 * v15 * v14 * v15 / v14); v16 = v9 | (v9 * i * v16 * i * v16 / i); } // Суммирование всех операций за итерацию (66 — сложение/вычитание, 10 — умножение/деление) v27 += 66 * (addSubOpsCount + addSubOpsCount) + 10 * (mulDivOpsCount + mulDivOpsCount); // Если время бенчмарка подошло к концу, выйти из цикла auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::high_resolution_clock::now() - start_time).count(); if (elapsed >= milliseconds) { break; } } auto end_time = std::chrono::high_resolution_clock::now(); auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(end_time - start_time).count(); // Итоговый результат = число операций в микросекунду double result = (static_cast<double>(v27) / elapsed) / 1000.0; // Выводим все значения, // чтобы компилятор не срезал вычисления в ходе оптимизации std::cout << "IntegerMath Test: " << result << " | " << v11 + v17 + v4 + v10 + v12 + v13 + v18 + v15 + v16 + v14 + v8 + v9 + v31 + i << std::endl; std::cin.get(); return 0;}
А вот код бенчмарка FloatingPointMathTest
(С++):
#include <iostream>#include <vector>#include <random>#include <chrono>int main() { // Время работы бенчмарка по умолчанию long long milliseconds = 6000; // Размер буфера чисел для бенчмарка в тесте (нужен, чтобы избежать работы L1/L2/L3-кеша процессора) int randomBufferSize = 20000; // Заполнение 32-битных чисел с плавающей запятой (float) для участия в бенчмарке std::vector<float> mem1; std::mt19937 rnd1(777); for (int n = 0; n < randomBufferSize; n++) { float a = static_cast<float>(rnd1() % INT_MAX) * 1000.0f; float b = static_cast<float>(rnd1() % INT_MAX) + a; float c = static_cast<float>(rnd1() % INT_MAX) / 10000.0f + b; mem1.push_back(c == 0.0f ? 1.0f : c); } // Заполнение 64-битных чисел с плавающей запятой (double) для участия в бенчмарке std::vector<double> mem2; std::mt19937 rnd2(777); for (int n = 0; n < randomBufferSize; n++) { double a = static_cast<double>(rnd2() % INT_MAX) * 1000.0; double b = static_cast<double>(rnd2() % INT_MAX) + a; double c = static_cast<double>(rnd2() % INT_MAX) / 10000.0 + b; mem2.push_back(c == 0.0 ? 1.0 : c); } // Числа, участвующие в бенчмарке, вынесены из цикла, чтобы не тратить ресурсы на выделение памяти на каждой итерации long long v4 = 0; float v11, r1 = 0.0f; double v10, r2 = 0.0; int v5 = 300; int v6 = 0; int v7, v8, v9; // Таймер для подсчета прошедшего времени auto start_time = std::chrono::high_resolution_clock::now(); while (true) { v7 = v6; v8 = v6 + 1; v9 = v8; v6 = v8 + 1; v10 = mem2[v9]; v11 = mem1[v9]; // 32-битный эксперимент: сложение/вычитание/умножение/деление (20 операций) r1 = (((((((((((((((((((v11 + mem1[v7]) - v11) * v11) + v11) - v11) * v11) + v11) - v11) * v11) / v11) + v11) - v11) * v11) + v11) - v11) * v11) + v11) - v11) * v11) / v11; // 64-битный эксперимент: сложение/вычитание/умножение/деление (20 операций) r2 = ((((((v10 + mem2[v7] - v10) * v10 + v10 - v10) * v10 + v10 - v10) * v10 / v10 + v10 - v10) * v10 + v10 - v10) * v10 + v10 - v10) * v10 / v10; // Суммирование всех операций за итерацию (20 + 20) v4 += 40; // Бессмысленная проверка r1 и r2, чтобы компилятор не срезал полезные вычисления в ходе оптимизации if(r1 == 0 && r2 == 0){ break; } if (--v5 == 0) { // Если время бенчмарка подошло к концу, выйти из цикла auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::high_resolution_clock::now() - start_time).count(); if (elapsed >= milliseconds) { break; } v5 = 300; } if (v6 >= 20000) { v6 = 0; } } auto end_time = std::chrono::high_resolution_clock::now(); auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(end_time - start_time).count(); // Итоговый результат = число операций в микросекунду double result = (static_cast<double>(v4) / elapsed) / 1000.0; // Выводим числа r1 и r2, чтобы компилятор не срезал полезные вычисления в ходе оптимизации std::cout << "FloatingPointMath Test: " << result << " | " << r1 << ", " << r2 << std::endl; getchar(); return 0;}
Код был восстановлен на основе реверс‑инжиниринга PassMark под Windows и macOS. Оба бенчмарка по структуре кода очень похожи. Универсальный алгоритм выглядит так:
- Заполнить буфер случайными числами, чтобы от итерации к итерации результаты вычислений не кешировались внутри CPU.
- Запустить цикл на 6 секунд, в ходе каждой итерации которого:
- совершить N 32-битных операций;
- совершить M 64-битных операций;
- увеличить счетчик совершенных операций на N + M.
- Посчитать число операций в микросекунду (по формуле выше).
Здесь есть несколько замечаний, которые могут быть любопытны всем, кто интересуется сравнимостью оценки между различными устройствами.
- Разработчики PassMark учли, что кеш процессора срежет реальные вычисления, из‑за чего результаты будут недостоверны. Чтобы обойти это, на каждой итерации цикла их код избыточно обращается к оперативной памяти (буферу чисел), что при большом количестве итераций (а их много!) приведет к несколько заниженным результатам теста. У разных устройств разная скорость обращения к ОЗУ.
- Подсчет операций производится слишком усредненно: смешиваются в одну кучу 32- и 64-битные операции сложения, вычитания, умножения и деления, хотя на уровне языка ассемблера эти операции, конечно же, неравнозначны — в идеале их нужно суммировать с коэффициентом друг относительно друга. Процессоры разных устройств могут быть оптимизированы под определенные типы операций.
Сравнение результатов
Восстановленный код работает в один поток (на одном ядре), а PassMark запускает этот же код, распараллеливая его в соответствии с количеством ядер процессора, поэтому для сравнения результатов необходимо либо заставить PassMark запускаться в одноядерном режиме, либо запускать наш код в нескольких потоках. Первый вариант выглядит более привлекательным — это не создает избыточную нагрузку.
Так как набор функций PassMark отличается на разных платформах, для каждой из них надо будет искать свой метод запуска на одном ядре. В Windows, например, можно задать число потоков (процессов, ядер) в настройках интерфейса PassMark. В macOS придется «обманывать» PassMark внешними способами — отключая ядра процессора из терминала:
// Отключить все, кроме одного главного ядраfor I in `seq 0 48`; do sudo cpuctl offline $I; done;// Включить все ядра обратноfor I in `seq 0 48`; do sudo cpuctl online $I; done;
На других платформах нужно искать что‑то свое. Но скорее всего, метод для macOS будет работать везде, где получится найти альтернативу команде cpuctl
.
Добившись запуска на одном ядре, мы увидим примерно одинаковые результаты бенчмарков.
Результаты тестов восстановленного кода на C++ сошлись с результатами PassMark на том же компьютере практически идеально. Но чтобы достичь этого, недостаточно просто закинуть код C++ в любую IDE и скомпилировать. Когда совершаются миллиарды итераций, каждая дополнительная операция в цикле и «телодвижение» компилятора могут вызвать «эффект бабочки» на пути к общему результату.
Игры с кодом бенчмарков и особенности работы современных компиляторов
Код, написанный на C++, компилируется в язык ассемблера и имеет, соответственно, не меньше различных «машинных» представлений, чем существует архитектур процессоров (языков ассемблера). На практике производительность скомпилированного ассемблерного кода зависит от еще большего числа параметров.
Компилятор
Разные компиляторы генерируют различный машинный код из‑за различий алгоритмов построения AST-дерева.
Опции компилятора
Современные IDE позволяют варьировать тип сборки (debug
/release
). Debug-сборка отключает любые оптимизации компилятора для более удобной отладки, в то время как release-сборка включает самые важные оптимизации, но далеко не все возможные. Из‑за задействованных оптимизаций компилятора производительность одного и того же кода на C++ может отличаться в три‑четыре раза!
Разные IDE имеют различные дефолтные настройки оптимизации: в Visual Studio у режима release
по умолчанию второй уровень оптимизации (-O2
), а IDE CLion — третий (-O3
), из‑за чего следующий код бенчмарка FloatingPointMathTest
необходим для CLion, но необязателен для Visual Studio:
...// Бессмысленная проверка r1 и r2, чтобы компилятор не срезал полезные вычисления в ходе оптимизацииif(r1 == 0 && r2 == 0){ break;}...
Этот код обязывает оптимизатор компилятора CLion выполнять вычисления r1
и r2
на каждой итерации цикла. Если же указанный блок убрать, в ассемблерном коде вычисление r1
и r2
выполнится только на последней итерации, а на предыдущих будут просчитываться лишь переменные v7...
для последней итерации, то есть напрочь срежутся все полезные вычисления, оценивающие производительность CPU.
Следующий код уже необходим для любого уровня оптимизации >= -O2
, поскольку компилятор в ходе оптимизации выбрасывает все переменные, которые никак не используются:
...// Выводим все значения, чтобы компилятор не срезал вычисления в ходе оптимизацииstd::cout << "IntegerMath Test: " << result << " | " << v11 + v17 + v4 + v10 + v12 + v13 + v18 + v15 + v16 + v14 + v8 + v9 + v31 + i << std::endl;...
Скорее всего, PassMark был скомпилирован с опцией оптимизации -O2
, что было подтверждено эмпирически. В итоге результаты бенчмарков сошлись с результатами, полученными с использованием восстановленного кода.
Структура кода
Каждая избыточная операция в восстановленном коде (любое дополнительное условие, обращение к памяти, математическая процедура) нарушает результаты тестов. Разработчики PassMark уже поработали с кодом своих бенчмарков за нас и сумели организовать его структуру оптимально. Рассмотрим несколько интересных фрагментов.
В IntegerMathTest
алгоритм подсчета индекса буфера организован таким образом:
mem_index = (mem_index + 6) % (randomBufferSize - 6);
А в FloatingPointMathTest
— таким:
v8 = v6 + 1;v6 = v8 + 1;...if (v6 >= 20000) { v6 = 0;}
Если в FloatingPointMathTest
сделать так же, как в IntegerMathTest
, производительность падает на 3–5%. А если в IntegerMathTest
так же, как в FloatingPointMathTest
, производительность не меняется.
Другой пример — уродливые реверсивные циклы в IntegerMathTest
:
do {} while (v19 > 0);...for (; v20 > 0; --v20) {}...do {} while (v22 > 0);...for (int j = mulDivOpsCount; j > 0; --j) {
Если их сделать красивыми — for (
, то производительность скомпилированного кода при прочих равных обрушится на 15%!
Кстати говоря, то, что проход цикла в обратном направлении может значительно увеличивать производительность, известно уже давно (и разработчикам PassMark, очевидно, тоже). Еще в далеком 2014 году этот прием разбирался в статье «Повышаем производительность клиентской части веб‑приложения» для языка JavaScript — каким бы высокоуровневым ни был язык, а простые операции вроде условий и циклов все равно конвертируются в примерно одинаковый машинный код.
Сравнение производительности языков программирования с помощью бенчмарков
Имея на руках восстановленный код бенчмарков PassMark, мы можем использовать его для любых своих экстравагантных нужд, в том числе для сравнения производительности языков программирования.
Возьмем для примера си‑подобный язык с виртуальной машиной и IL — C# — и оценим, насколько в нем работа с целыми и дробными числами медленнее, чем в нативном C++.
Прогресс преподносит много приятных сюрпризов, недоступных еще пару лет назад, а потому для «перевода» C++ в C# больше не нужно думать — достаточно попросить это сделать за нас ChatGPT, который справляется с подобными задачами превосходно. Получив код бенчмарков на C#, компилируем его в release-режиме и оцениваем результаты.
Усредненный результат десяти тестов показал, что производительность C# (язык с IL) на 6–8% ниже по сравнению с C++ (язык без IL) с точки зрения числовых операций. Из‑за этого отличия для воспроизведения кода бенчмарков и был изначально выбран C++.
Выводы
До начала исследования PassMark у меня было несколько иное представление об организации его кода: так как код бенчмарков должен быть одинаковым вне зависимости от платформы (для сравнимости результатов по публичной базе), казалось бы, наиболее правильный способ его организации — обособление в динамически подключаемые библиотеки, которые существуют во всех поддерживаемых приложением платформах. Однако разработчики предпочли под каждую платформу выпускать продукт с нуля, делая ручной копипаст кода бенчмарков в платформенно зависимый вариант программы. Это приводит к тому, что для каждого бенчмарка необходимо проводить отдельный аудит на разных платформах, чтобы убедиться, что он действительно везде работает одинаково.
В этой статье аудит был сделан для двух ключевых тестов CPU — IntegerMathTest
и FloatingPointMathTest
на платформах Windows и macOS. По его результатам можно в целом предположить, что внутри одинаковых бенчмарков на разных платформах находится одинаковый код, не затронутый оптимизатором конкретного компилятора и спецификой платформенного ассемблера.
И отдельного внимания заслуживают публичные результаты бенчмарков PassMark. Как уже упоминалось выше, результат любого бенчмарка — это просто сумма операций за микросекунду со всех запущенных дочерних процессов в количестве ядер процессора. Но что, если запустить процессов больше, чем у процессора имеется ядер?
Возьмем для примера результаты тестов PassMark на 1, 24, 48 и 64 потока для экспериментального 24-ядерного Core I9-13950Hx, которые уже частично демонстрировались ранее.
С их помощью можно увидеть несколько интересных моментов работы PassMark и современных процессоров:
- Результаты на 24 ядра не равны результатам на одно ядро, умноженным на 24. Это происходит из‑за того, что ядра современных процессоров не идентичны по производительности — ядра делятся на performance и efficient.
- Операции с дробными числами в один поток могут быть быстрее операций с целыми числами в один поток на некоторых процессорах. Наиболее вероятное объяснение этому — устройство performance-ядер конкретного процессора.
- Если запустить потоков больше, чем ядер процессора, результаты бенчмарка увеличатся на 10–25%. Это покажет настоящую производительность процессора под 100%-й нагрузкой, и она не будет соответствовать той, что можно увидеть в публичной базе данных PassMark. Публичная база PassMark собирается с устройств по всему миру со стандартными настройками программы PassMark.
- Если запустить еще больше потоков, результаты особо не улучшатся, что свидетельствует о полной загрузке процессора.
Таким образом, можно резюмировать, что разработчики PassMark изрядно постарались создать наиболее универсальную оценку производительности различных устройств по всему миру. Но в реальности же устройства могут быть на 10–25% мощнее усредненной публичной оценки, а заложенный в основу бенчмарков алгоритм «загрязнен» побочными операциями, еще больше занижающими реальную оценку.
В любом случае, имея на руках алгоритм любого бенчмарка PassMark и публичную базу результатов с миллионами устройств, можно проводить довольно интересные исследования, например:
- Сколько целочисленных операций на C# в среде Mono из Unity сможет выполнить iPhone 14 Pro Max? А насколько больше, если переписать код на C++? Стоит ли оно того?
- Какой лучше выбрать язык для разработки серверного приложения с большим числом операций с плавающей точкой на базе процессора Xeon Bronze 3204: Python или JavaScript?
На эти и другие подобные вопросы можно будет получить ответ путем нехитрых манипуляций с результатами тестов, выполненных с использованием разных языков программирования и устройств.