Су­щес­тву­ют сот­ни прог­рамм для оцен­ки про­изво­дитель­нос­ти, но абсо­лют­ным лидером сре­ди них мож­но наз­вать PassMark — «индус­три­аль­ный стан­дарт с 1998 года», как его позици­они­рует сам раз­работ­чик. На сай­те прог­раммы — огромная база оце­нок самых раз­ных девай­сов. Давай пос­мотрим, что находит­ся под капотом у леген­дарной прог­раммы для бен­чмар­ков.

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

 

Общий обзор работы PassMark

Ес­ли зай­ти на офи­циаль­ную стра­ницу заг­рузки PassMark, мы уви­дим, что прог­рамма эта кросс‑плат­формен­ная. На пер­вый взгляд может показать­ся, что она собира­ется из еди­ной кодовой базы (по край­ней мере, ядро с набором бен­чмар­ков), одна­ко это не так — исходный код, как и интерфейс, силь­но раз­нятся от плат­формы к плат­форме. Тем не менее работа­ющее под управле­нием раз­ных ОС при­ложе­ние оце­нива­ет про­изво­дитель­ность при­мер­но оди­нако­во (в ходе ауди­та мы про­верим, так ли это на самом деле).

PassMark macOS
PassMark macOS
Windows
Windows

Windows-вер­сия PassMark, к при­меру, пред­став­лена в виде мно­жес­тва исполня­емых фай­лов под каж­дую груп­пу тес­тов, а вер­сия для macOS — как standalone-файл, содер­жащий в себе сра­зу все тес­ты.

Файловая структура PassMark в Windows
Фай­ловая струк­тура PassMark в Windows

При запус­ке любого тес­та при­ложе­ние PassMark вне зависи­мос­ти от плат­формы порож­дает новые про­цес­сы в количес­тве, соот­ветс­тву­ющем чис­лу ядер про­цес­сора, для дос­тижения мак­сималь­ной заг­рузки CPU — по одно­му про­цес­су на каж­дое ядро. Пос­ле отра­бот­ки каж­дого про­цес­са прог­рамма собира­ет целочис­ленные резуль­таты для даль­нейше­го сум­мирова­ния.

Спо­собы порож­дения новых про­цес­сов на раз­ных плат­формах отли­чают­ся. В macOS дела­ется форк основно­го про­цес­са PassMark, а в Windows запус­кает­ся отдель­ный про­цесс PT-CPUTest с парамет­рами коман­дной стро­ки, поз­воля­ющи­ми кон­тро­лиро­вать дли­тель­ность тес­та по вре­мени и чис­ло исполь­зуемых ядер про­цес­сора:

PT-CPUTest.exe [-slave|-standalone] [номер бенчмарка] [число ядер] [длительность в миллисекундах]

Это же отли­чие, кста­ти, поз­воля­ет задавать чис­ло ядер в нас­трой­ках Windows-вер­сии прог­раммы и не поз­воля­ет в macOS.

Та­ким обра­зом, спо­соб запус­ка любого бен­чмар­ка PassMark сво­дит­ся к запус­ку N про­цес­сов, где N — чис­ло ядер CPU, ожи­данию завер­шения всех про­цес­сов и сум­мирова­нию целочис­ленных резуль­татов от них (где каж­дый резуль­тат в сред­нем равен суммарному результату / N, но это не всег­да так, о чем мы еще погово­рим). Сум­марный резуль­тат — это и есть то, что PassMark отоб­ража­ет в сво­ей пуб­личной базе для каж­дого устрой­ства.

 

Алгоритм работы бенчмарков

Что­бы пос­мотреть, как устро­ены алго­рит­мы бен­чмар­ков, нуж­но заг­рузить исполня­емый файл PassMark в любой дизас­сем­блер.

Все бен­чмар­ки внут­ри 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. Оба бен­чмар­ка по струк­туре кода очень похожи. Уни­вер­саль­ный алго­ритм выг­лядит так:

  1. За­пол­нить буфер слу­чай­ными чис­лами, что­бы от ите­рации к ите­рации резуль­таты вычис­лений не ке­широ­вались внут­ри CPU.
  2. За­пус­тить цикл на 6 секунд, в ходе каж­дой ите­рации которо­го:
    • со­вер­шить N 32-бит­ных опе­раций;
    • со­вер­шить M 64-бит­ных опе­раций;
    • уве­личить счет­чик совер­шенных опе­раций на N + M.
  3. Пос­читать чис­ло опе­раций в мик­росекун­ду (по фор­муле выше).

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

  1. Раз­работ­чики PassMark учли, что кеш про­цес­сора сре­жет реаль­ные вычис­ления, из‑за чего резуль­таты будут недос­товер­ны. Что­бы обой­ти это, на каж­дой ите­рации цик­ла их код избы­точ­но обра­щает­ся к опе­ратив­ной памяти (буферу чисел), что при боль­шом количес­тве ите­раций (а их мно­го!) при­ведет к нес­коль­ко занижен­ным резуль­татам тес­та. У раз­ных устрой­ств раз­ная ско­рость обра­щения к ОЗУ.
  2. Под­счет опе­раций про­изво­дит­ся слиш­ком усреднен­но: сме­шива­ются в одну кучу 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... v11 для пос­ледней ите­рации, то есть нап­рочь сре­жут­ся все полез­ные вычис­ления, оце­нива­ющие про­изво­дитель­ность 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 (int j = 0; j<mulDivOpsCount; j++), то про­изво­дитель­ность ском­пилиро­ван­ного кода при про­чих рав­ных обру­шит­ся на 15%!

Кста­ти говоря, то, что про­ход цик­ла в обратном нап­равле­нии может зна­читель­но уве­личи­вать про­изво­дитель­ность, извес­тно уже дав­но (и раз­работ­чикам PassMark, оче­вид­но, тоже). Еще в далеком 2014 году этот при­ем раз­бирал­ся в статье «Повыша­ем про­изво­дитель­ность кли­ент­ской час­ти веб‑при­ложе­ния» для язы­ка JavaScript — каким бы высоко­уров­невым ни был язык, а прос­тые опе­рации вро­де усло­вий и цик­лов все рав­но кон­верти­руют­ся в при­мер­но оди­нако­вый машин­ный код.

 

Сравнение производительности языков программирования с помощью бенчмарков

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

Возь­мем для при­мера си‑подоб­ный язык с вир­туаль­ной машиной и IL — C# — и оце­ним, нас­коль­ко в нем работа с целыми и дроб­ными чис­лами мед­леннее, чем в натив­ном C++.

Прог­ресс пре­под­носит мно­го при­ятных сюр­при­зов, недос­тупных еще пару лет назад, а потому для «перево­да» C++ в C# боль­ше не нуж­но думать — дос­таточ­но поп­росить это сде­лать за нас ChatGPT, который справ­ляет­ся с подоб­ными задача­ми пре­вос­ходно. Получив код бен­чмар­ков на C#, ком­пилиру­ем его в release-режиме и оце­нива­ем резуль­таты.

Сравниваем C# и C++
Срав­нива­ем C# и C++

Ус­реднен­ный резуль­тат десяти тес­тов показал, что про­изво­дитель­ность C# (язык с IL) на 6–8% ниже по срав­нению с C++ (язык без IL) с точ­ки зре­ния чис­ловых опе­раций. Из‑за это­го отли­чия для вос­про­изве­дения кода бен­чмар­ков и был изна­чаль­но выб­ран C++.

 

Выводы

До начала иссле­дова­ния PassMark у меня было нес­коль­ко иное пред­став­ление об орга­низа­ции его кода: так как код бен­чмар­ков дол­жен быть оди­нако­вым вне зависи­мос­ти от плат­формы (для срав­нимос­ти резуль­татов по пуб­личной базе), казалось бы, наибо­лее пра­виль­ный спо­соб его орга­низа­ции — обо­соб­ление в динами­чес­ки под­клю­чаемые биб­лиоте­ки, которые сущес­тву­ют во всех под­держи­ваемых при­ложе­нием плат­формах. Одна­ко раз­работ­чики пред­почли под каж­дую плат­форму выпус­кать про­дукт с нуля, делая руч­ной копипаст кода бен­чмар­ков в плат­формен­но зависи­мый вари­ант прог­раммы. Это при­водит к тому, что для каж­дого бен­чмар­ка необ­ходимо про­водить отдель­ный аудит на раз­ных плат­формах, что­бы убе­дить­ся, что он дей­стви­тель­но вез­де работа­ет оди­нако­во.

В этой статье аудит был сде­лан для двух клю­чевых тес­тов CPU — IntegerMathTest и FloatingPointMathTest на плат­формах Windows и macOS. По его резуль­татам мож­но в целом пред­положить, что внут­ри оди­нако­вых бен­чмар­ков на раз­ных плат­формах находит­ся оди­нако­вый код, не зат­ронутый опти­миза­тором кон­крет­ного ком­пилято­ра и спе­цифи­кой плат­формен­ного ассем­бле­ра.

И отдель­ного вни­мания зас­лужива­ют пуб­личные резуль­таты бен­чмар­ков PassMark. Как уже упо­мина­лось выше, резуль­тат любого бен­чмар­ка — это прос­то сум­ма опе­раций за мик­росекун­ду со всех запущен­ных дочер­них про­цес­сов в количес­тве ядер про­цес­сора. Но что, если запус­тить про­цес­сов боль­ше, чем у про­цес­сора име­ется ядер?

Возь­мем для при­мера резуль­таты тес­тов PassMark на 1, 24, 48 и 64 потока для экспе­римен­таль­ного 24-ядер­ного Core I9-13950Hx, которые уже час­тично демонс­три­рова­лись ранее.

Результаты тестов PassMark на 1, 24, 48 и 64 потока
Ре­зуль­таты тес­тов PassMark на 1, 24, 48 и 64 потока

С их помощью мож­но уви­деть нес­коль­ко инте­рес­ных момен­тов работы PassMark и сов­ремен­ных про­цес­соров:

  1. Ре­зуль­таты на 24 ядра не рав­ны резуль­татам на одно ядро, умно­жен­ным на 24. Это про­исхо­дит из‑за того, что ядра сов­ремен­ных про­цес­соров не иден­тичны по про­изво­дитель­нос­ти — ядра делят­ся на performance и efficient.
  2. Опе­рации с дроб­ными чис­лами в один поток могут быть быс­трее опе­раций с целыми чис­лами в один поток на некото­рых про­цес­сорах. Наибо­лее веро­ятное объ­ясне­ние это­му — устрой­ство performance-ядер кон­крет­ного про­цес­сора.
  3. Ес­ли запус­тить потоков боль­ше, чем ядер про­цес­сора, резуль­таты бен­чмар­ка уве­личат­ся на 10–25%. Это покажет нас­тоящую про­изво­дитель­ность про­цес­сора под 100%-й наг­рузкой, и она не будет соот­ветс­тво­вать той, что мож­но уви­деть в пуб­личной базе дан­ных PassMark. Пуб­личная база PassMark собира­ется с устрой­ств по все­му миру со стан­дар­тны­ми нас­трой­ками прог­раммы PassMark.
  4. Ес­ли запус­тить еще боль­ше потоков, резуль­таты осо­бо не улуч­шатся, что сви­детель­ству­ет о пол­ной заг­рузке про­цес­сора.

Та­ким обра­зом, мож­но резюми­ровать, что раз­работ­чики PassMark изрядно пос­тарались соз­дать наибо­лее уни­вер­саль­ную оцен­ку про­изво­дитель­нос­ти раз­личных устрой­ств по все­му миру. Но в реаль­нос­ти же устрой­ства могут быть на 10–25% мощ­нее усреднен­ной пуб­личной оцен­ки, а заложен­ный в осно­ву бен­чмар­ков алго­ритм «заг­рязнен» побоч­ными опе­раци­ями, еще боль­ше занижа­ющи­ми реаль­ную оцен­ку.

В любом слу­чае, имея на руках алго­ритм любого бен­чмар­ка PassMark и пуб­личную базу резуль­татов с мил­лиона­ми устрой­ств, мож­но про­водить доволь­но инте­рес­ные иссле­дова­ния, нап­ример:

  1. Сколь­ко целочис­ленных опе­раций на C# в сре­де Mono из Unity смо­жет выпол­нить iPhone 14 Pro Max? А нас­коль­ко боль­ше, если перепи­сать код на C++? Сто­ит ли оно того?
  2. Ка­кой луч­ше выб­рать язык для раз­работ­ки сер­верно­го при­ложе­ния с боль­шим чис­лом опе­раций с пла­вающей точ­кой на базе про­цес­сора Xeon Bronze 3204: Python или JavaScript?

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

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии