
«Существуют три вида лжи: ложь, наглая ложь и бенчмарки», — почти Марк Твен
Оглавление
Глава 14: Обработка строк и эффективность использования кэша
Глава 15: Графы и их обход с эффективным использованием кэша
Глава 20: Исследование бенчмарков
В два часа ночи ведущий архитектор процессорного стартапа Сара Чен получила письмо, которое изменит траекторию движения её компании. Конкурент опубликовал подробный технический анализ, развенчивающий заявления о производительности флагманского продукта компании. Заголовок был жёстким: «Маркетинговый хайп против реальности: как X искусственно увеличила результаты бенчмарков на 300%».
Проблема заключалась не в том, что процессор был медленным; на самом деле, он был вполне хорош. Проблема заключалась в бенчмарке Dhrystone, который компания выбрала для демонстрации. Конкурент подробно показал, как современные компиляторы могут оптимизировать бóльшую часть работы Dhrystone, из‑за чего его результаты становятся бессмысленными. Хуже того: конкурент продемонстрировал это на реальных нагрузках, с которыми работают заказчики, и все преимущества в производительности процессора испарились.
Следующую неделю Сара занималась тем, что следовало сделать несколько месяцев назад: разбиралась в том, что же на самом деле измеряют бенчмарки. Мой пост стал результатом этого исследования двух бенчмарков, Dhrystone и Coremark, ставших стандартом отрасли. Мы не только разберёмся, как их запускать, но и поймём, что они говорят и что скрывают о производительности процессоров.
1. Почему бенчмарки важны (и почему они нас подводят)
Предназначение бенчмарков
В идеальном мире мы бы замеряли производительность процессора, прогоняя на нём нагрузки каждого пользователя. В реальности же нам нужны стандартизованные тесты, которые:
Отражают реальную работу: показывают истинное поведение приложений.
Воспроизводимы: обеспечивают стабильные результаты во всех прогонах.
Портируемы: выполняются на разных архитектурах.
Понятны: чётко демонстрируют, что измеряется.
Проблема в том, что эти цели часто конфликтуют. Если сделать бенчмарк слишком простым, то он не будет отражать реальную работу. Если он будет слишком сложным, то пострадают воспроизводимость или понятность.
Отказы бенчмарков
Отказы бенчмарков предсказуемы:
Компиляторная оптимизация: компилятор распознаёт паттерн бенчмарка и устраняет его оптимизацией. При этом мы измеряем продуманность компилятора, а не производительность процессора.
Узкие рабочие нагрузки: бенчмарк тестирует только один аспект производительности (например, целочисленную арифметику), в то время как в реальных приложениях применяется широкий спектр операций.
Нереалистичные данные: бенчмарк использует небольшие датасеты, удобные для работы с кэшем, а реальные приложения имеют дело с большими разбросанными по памяти данными.
Подстройка под бенчмарк: производители оптимизируют свои продукты под бенчмарк, а не под реальные нагрузки.
Давайте посмотрим, как эти отказы проявляются на практике.
2. Dhrystone: Урок истории
Происхождение и предназначение
Dhrystone был создан Райнхольдом Вайкером в 1984 году в качестве синтетического бенчмарка для измерений скорости работы с целыми числами. Его название обыгрывает название написанного ранее бенчмарка Whetstone, измерявшего производительность при работе с числами с плавающей запятой.
Цели разработки:
Измерение скорости типичных операций с целыми числами.
Малый размер для того, чтобы данные умещались в кэш.
Простота портирования.
Избегание операций с числами с плавающей запятой (у многих процессоров встраиваемых систем отсутствовали FPU).
Состав рабочих нагрузок (из статьи Вайкера):
53% — присвоения
32% — поток управления (if/else, циклы)
15% — вызовы подпрограмм
Операции со строками
Копирование записей (struct)
Чем на самом деле занимается Dhrystone
Давайте взглянем на ядро Dhrystone (в упрощённом виде):
typedef struct record { struct record *ptr_comp; int discr; int enum_comp; int int_comp; char str_comp[31]; } Rec_Type, *Rec_Pointer; void Proc_1(Rec_Pointer ptr_val_par) { Rec_Pointer next_record = ptr_val_par->ptr_comp; // Присвоение структур *ptr_val_par->ptr_comp = *ptr_val_par; ptr_val_par->int_comp = 5; next_record->int_comp = ptr_val_par->int_comp; next_record->ptr_comp = ptr_val_par->ptr_comp; // Вызов подпрограммы Proc_3(&next_record->ptr_comp); // Условные операции if (next_record->discr == 0) { next_record->int_comp = 6; Proc_6(ptr_val_par->enum_comp, &next_record->enum_comp); next_record->ptr_comp = ptr_val_par->ptr_comp; Proc_7(next_record->int_comp, 10, &next_record->int_comp); } else { *ptr_val_par = *ptr_val_par->ptr_comp; } }
Операции со строками:
void Proc_2(int *int_par_ref) { int int_loc; char char_loc; int_loc = *int_par_ref + 10; do { if (Func_1('A', 'C') == 0) { char_loc = 'A'; int_loc++; } } while (char_loc != 'A'); *int_par_ref = int_loc; }
Фатальные недостатки
Проблема 1: избавление от мёртвого кода
Современные компиляторы могут заметить, что бóльшая часть работы Dhrystone не имеет наблюдаемого эффекта:
// Что видит компилятор: int x = 5; x = x + 10; x = x * 2; // Результат никогда не используется // Компилятор генерирует: // (ничего - все вычисления удалены)
Проблема 2: подстановка констант
// Исходный код: if (Func_1('A', 'C') == 0) { // ... } // Компилятор знает, что A и C - это константы // Вычисляет Func_1 в процессе компиляции // Заменяет всю конструкцию if безусловным переходом
Проблема 3: нереалистичный доступ к данным
Данные Dhrystone полностью умещаются в кэш L1 (размером несколько килобайт). У реальных приложений случаются промахи кэша. Dhrystone измеряет производительность в лучшем, а не в типичном случае.
Проблема 4: отсутствие следования по указателям
Хотя Dhrystone и использует указатели, их паттерны доступа предсказуемы. Современные процессоры по необходимости выполняют упреждающую выборку.
Катастрофа, вызванная компиляторными оптимизациями
Вот, что происходит при оптимизации -O3:
$ gcc -O0 dhrystone.c -o dhry_O0 $ gcc -O3 dhrystone.c -o dhry_O3 $ ./dhry_O0 Dhrystone в секунду: 500 000 $ ./dhry_O3 Dhrystone в секунду: 5 000 000
Десятикратное ускорение благодаря одним лишь флагам компилятора! Мы измеряем не производительность процессора, а способность компилятора обнаруживать и удалять паттерны Dhrystone.
Показатели разных компиляторов сильно разнятся (DMIPS — Dhrystone MIPS):
GCC 10.2: 4,2 DMIPS/МГц
Clang 12: 5,1 DMIPS/МГц
ICC 21: 5.8 DMIPS/МГц
Один и тот же процессор, разные результаты. Бенчмарк поломан.
Чему мы научились у Dhrystone
Dhrystone учит нас, чего делать не стоит:
❌ Не надо использовать предсказуемые, постоянные входящие данные.
❌ Не разрешать удаление мёртвого кода.
❌ Не использовать нереалистично малые датасеты.
❌ Не делать упор лишь на один тип операций.
Но это научило нас и тому, что бенчмарки делать должны, и это привело нас к Coremark.
3. Coremark: Современный подход
Философия разработки
Coremark был создан в 2009 году EEMBC (Embedded Microprocessor Benchmark Consortium) специально для устранения недостатков Dhrystone.
Цели разработки:
Противодействие компиляторным оптимизациям.
Отражение всего разнообразия реальных операций.
Портируемость между архитектурами.
Наличие чётких реализуемых правил прогонов.
Четыре вида нагрузки
Coremark состоит из четырёх видов нагрузки, каждый из которых тестирует отдельный аспект производительности процессора:
Нагрузка 1: операции со связанными списками
typedef struct list_data_s { int16_t data16; int16_t idx; } list_data; typedef struct list_head_s { struct list_head_s *next; struct list_data_s *info; } list_head; // Поиск элемента в списке list_head *core_list_find(list_head *list, list_data *info) { if (info->idx >= 0) { while (list && (list->info->idx != info->idx)) list = list->next; return list; } else { while (list && ((list->info->data16 & 0xff) != info->data16)) list = list->next; return list; } } // Разворот списка list_head *core_list_reverse(list_head *list) { list_head *next = NULL, *tmp; while (list) { tmp = list->next; list->next = next; next = list; list = tmp; } return next; }
Что она тестирует:
следование по указателям (промахи кэша);
непредсказуемые ветвления;
паттерны обхода списков.
Почему она устойчива к оптимизациям:
Содержимое списков определяется во время исполнения.
Варьирующиеся критерии поиска.
Результаты используются (в конце вычисляется CRC).
Нагрузка 2: матричные операции
typedef int16_t mat_elem; typedef mat_elem *matrix_row; // Перемножение матриц (упрощённое) void core_bench_matrix(mat_params *A, int16_t seed) { uint32_t N = A->N; matrix_row *C = A->C; matrix_row *A_mat = A->A; matrix_row *B = A->B; // C = A * B for (uint32_t i = 0; i < N; i++) { for (uint32_t j = 0; j < N; j++) { mat_elem temp = 0; for (uint32_t k = 0; k < N; k++) { temp += A_mat[i][k] * B[k][j]; } C[i][j] = temp; } } }
Что она тестирует:
интенсивность вычислений;
возможности блочной оптимизации кэша;
паттерны доступа к памяти.
Почему она устойчива к оптимизациям:
Размер матриц определяется во время исполнения.
Результаты верифицируются контрольной суммой.
Множественность операций препятствует сворачиванию констант.
Нагрузка 3: конечный автомат
enum CORE_STATE { CORE_START = 0, CORE_INVALID, CORE_S1, CORE_S2, CORE_INT, CORE_FLOAT, CORE_EXPONENT, CORE_SCIENTIFIC, NUM_CORE_STATES }; // Конечный автомат для парсинга чисел enum CORE_STATE core_state_transition(uint8_t **instr, uint32_t *transition_count) { uint8_t *str = *instr; uint8_t ch; enum CORE_STATE state = CORE_START; for (; *str && state != CORE_INVALID; str++) { ch = *str; (*transition_count)++; switch (state) { case CORE_START: if (isdigit(ch)) { state = CORE_INT; } else if (ch == '+' || ch == '-') { state = CORE_S1; } else if (ch == '.') { state = CORE_FLOAT; } else { state = CORE_INVALID; } break; case CORE_S1: if (isdigit(ch)) { state = CORE_INT; } else if (ch == '.') { state = CORE_FLOAT; } else { state = CORE_INVALID; } break; case CORE_INT: if (ch == '.') { state = CORE_FLOAT; } else if (!isdigit(ch)) { state = CORE_INVALID; } break; // ... остальные состояния } } *instr = str; return state; }
Что она тестирует:
предсказание ветвлений;
скорость выполнения конструкций switch;
обработку строк.
Почему она устойчива к оптимизациям:
Входные строки варьируются.
Переходы между состояниями непредсказуемы.
Подсчёт переходов предотвращает удаление кода.
Нагрузка 4: вычисление CRC
uint16_t crcu16(uint16_t newval, uint16_t crc) { uint8_t i; for (i = 0; i < 16; i++) { if ((crc & 0x8000) != 0) { crc = (crc << 1) ^ 0x1021; } else { crc = crc << 1; } if ((newval & 0x8000) != 0) { crc ^= 0x1021; } newval = newval << 1; } return crc; } // CRC всех результатов uint16_t core_bench_crc(void *memblock, uint32_t size) { uint16_t crc = 0; uint8_t *data = (uint8_t *)memblock; for (uint32_t i = 0; i < size; i++) { crc = crcu16(data[i], crc); } return crc; }
Что она тестирует:
манипуляции с битами;
оптимизацию циклов;
зависимые от данных операции.
Почему она устойчива к оптимизациям:
CRC зависит от всех предыдущих данных.
Нагрузку непросто распараллелить.
Результат должен совпадать с известным значением.
Предотвращение компиляторных оптимизаций
Чтобы предотвратить удаление мёртвого кода, Coremark использует множество разных методик:
1. Определение входных данных во время исполнения:
// Не так (компилятор может выполнить оптимизацию): int data[100] = {1, 2, 3, ...}; // А так (определяется во время исполнения): void init_data(int *data, int seed) { for (int i = 0; i < 100; i++) { data[i] = (seed * i) & 0xFF; seed = (seed * 1103515245 + 12345) & 0x7FFFFFFF; } }
2. Верификация результата:
// Все результаты подвергаются CRC uint16_t final_crc = 0; final_crc = crcu16(list_result, final_crc); final_crc = crcu16(matrix_result, final_crc); final_crc = crcu16(state_result, final_crc); // Должно соответствовать известному значению if (final_crc != EXPECTED_CRC) { printf("ERROR: Invalid results!\n"); return -1; }
3. Волатильные результаты:
// Предотвращает оптимизацию хранения результата volatile uint16_t results[4]; results[0] = list_crc; results[1] = matrix_crc; results[2] = state_crc; results[3] = crc_crc;
Правила прогонов
У Coremark есть строгие правила прогонов, обеспечивающие честное сравнение:
Минимум итераций: тест должен выполняться не менее 10 секунд.
Запрет на изменения исходников: базовые алгоритмы модифицировать нельзя.
Валидация: результаты должны соответствовать известным значениям CRC.
Отчётность: тест должен указывать производительность в итерациях на секунду или в итерациях на мегагерц.
Флаги компилятора: обязательно должны быть известны.
Пример валидного прогона:
CoreMark 1.0 : 12500.00 / GCC 10.2.0 -O3 -march=rv64gc / Heap CoreMark/MHz: 5.00
4. Анализ производительности
Что означают оценки
Dhrystone указывает результаты в DMIPS (Dhrystone MIPS):
DMIPS = (Dhrystone/с) / 1757.
1757 — это оценка VAX 11/780 (эталонной машины).
DMIPS/МГц обеспечивает нормализацию по тактовой частоте.
Coremark указывает результаты в итерациях в секунду:
Чем выше, тем лучше.
CoreMark/МГц обеспечивает нормализацию по тактовой частоте.
Типичный диапазон: 2.5–5.5 CoreMark/МГц.
Что влияет на оценки Coremark?
1. Компиляторные оптимизации:
# -O0 (без оптимизаций) CoreMark/MHz: 1.2 # -O2 (стандартные оптимизации) CoreMark/MHz: 4.5 # -O3 (агрессивные оптимизации) CoreMark/MHz: 5.0 # -O3 -funroll-loops CoreMark/MHz: 5.2
2. Расширения архитектуры набора команд:
# RV64GC (база) CoreMark/MHz: 4.8 # RV64GC + B extension (манипуляции с битами) CoreMark/MHz: 5.1 # RV64GC + V extension (векторные операции) - скалярный режим CoreMark/MHz: 5.0
3. Конфигурация кэша:
16 КБ I$ + 16 КБ D$: 4.2 CoreMark/МГц 32 КБ I$ + 32 КБ D$: 4.8 CoreMark/MHz 64 КБ I$ + 64 КБ D$: 5.0 CoreMark/MHz
4. Задержки памяти:
SRAM (1 такт): 5.2 CoreMark/MHz DRAM (100 тактов): 3.8 CoreMark/MHz
Типичные показатели (публичные данные)
По опубликованным EEMBC результатам и научным статьям:
Процессоры встраиваемых систем (RV32):
С простым последовательным исполнением: 2.5–3.0 CoreMark/MHz;
С кэшами: 3.0–3.5 CoreMark/MHz.
Процессоры общего назначения (RV64):
с последовательным исполнением команд и отправкой одной команды за такт: 3.5–4.0 CoreMark/MHz;
с последовательным исполнением команд и отправкой двух команд за такт: 4.0–4.5 CoreMark/MHz;
с внеочередным исполнением: 4.5–5.5 CoreMark/MHz.
Для сравнения (x86/ARM):
ARM Cortex‑A53: 3.5 CoreMark/MHz
ARM Cortex‑A72: 4.5 CoreMark/MHz
Intel Atom: 4.0 CoreMark/MHz
Intel Core i7: 5.0+ CoreMark/MHz
Чего Coremark не измеряет
Coremark лучше, чем Dhrystone, но и он неидеален:
Отсутствующие рабочие нагрузки:
❌ Операции с плавающей запятой.
❌ Векторные/SIMD‑операции.
❌ Системные вызовы.
❌ Операции ввода‑вывода.
❌ Многопоточность.
Нереалистичные аспекты:
Малый датасет (помещается в кэш).
Отсутствие оверхеда операционной системы.
Отсутствие прерываний.
Детерминированное исполнение.
Что он измеряет хорошо:
✅ Целочисленную арифметику.
✅ Следование по указателям.
✅ Прогнозирование ветвления.
✅ Эффективность компиляторов.
✅ Производительность кэша (для малых датасетов).
5. Принципы проектирования бенчмарков
Уроки из истории
Сравнение Dhrystone и Coremark даёт нам представление о том, как проектировать хорошие бенчмарки:
Принцип |
Dhrystone |
Coremark |
|---|---|---|
Разнообразные рабочие нагрузки |
❌ В основном присваивания |
✅ 4 различающихся нагрузок |
Устойчивость к оптимизациям |
❌ Легко оптимизируется |
✅ Множество разных методик |
Получение входных данных во время исполнения |
❌ Константы времени компиляции |
✅ Генерация на основе порождающих значений |
Верификация результатов |
❌ Слабая |
✅ Валидация CRC |
Правила исполнения |
❌ Неформальные |
✅ Строгие |
Портируемость |
✅ Хорошая |
✅ Превосходная |
Понятность |
✅ Простой |
⚠️ Более сложный |
Проектирование собственного бенчмарка
Что делать, если нужно создать бенчмарк под конкретный сценарий применения:
1. Идентифицируйте рабочую нагрузку:
// Выполняйте бенчмарк не общей "производительности", // а конкретных операций: // ❌ Слишком обобщённо void benchmark_processor(void); // ✅ Конкретная нагрузка void benchmark_packet_processing(void); void benchmark_image_filtering(void); void benchmark_crypto_operations(void);
2. Используйте реалистичные данные:
// ❌ Нереалистично int data[100] = {1, 2, 3, 4, ...}; // Умещается в кэш // ✅ Реалистично #define DATA_SIZE (1024 * 1024) // 1 МБ int *data = malloc(DATA_SIZE * sizeof(int)); init_random_data(data, DATA_SIZE, seed);
3. Предотвращайте оптимизации:
// ❌ Компилятор может устранить эти операции int sum = 0; for (int i = 0; i < n; i++) { sum += data[i]; } // sum больше нигде не используется // ✅ Принудительные вычисления volatile int result; int sum = 0; for (int i = 0; i < n; i++) { sum += data[i]; } result = sum; // Volatile предотвращает удаление
4. Валидируйте результаты:
// ✅ Валидация контрольной суммой uint32_t expected_crc = compute_expected_crc(seed); uint32_t actual_crc = run_benchmark(data, size); if (actual_crc != expected_crc) { fprintf(stderr, "ERROR: Benchmark validation failed!\n"); fprintf(stderr, "Expected: 0x%08x, Got: 0x%08x\n", expected_crc, actual_crc); return -1; }
5. Раскрывайте методологию:
printf("=== Benchmark Results ===\n"); printf("Workload: Packet processing\n"); printf("Data size: %d packets\n", num_packets); printf("Iterations: %d\n", iterations); printf("Compiler: %s %s\n", COMPILER_NAME, COMPILER_VERSION); printf("Flags: %s\n", COMPILER_FLAGS); printf("Time: %.2f ms\n", elapsed_ms); printf("Throughput: %.2f Mpps\n", packets_per_sec / 1e6);
Распространённые ошибки
Ошибка 1: измерение не того параметра:
// ❌ Измеряет malloc, а не вычисления start_timer(); int *data = malloc(size); compute(data, size); free(data); stop_timer(); // ✅ Измеряет только вычисления int *data = malloc(size); start_timer(); compute(data, size); stop_timer(); free(data);
Ошибка 2: недостаточный разогрев:
// ❌ Первый прогон использует холодный кэш for (int i = 0; i < 100; i++) { start_timer(); benchmark(); stop_timer(); } // ✅ Сначала необходим разогрев for (int i = 0; i < 10; i++) { benchmark(); // Разогрев, измерения не происходят } for (int i = 0; i < 100; i++) { start_timer(); benchmark(); stop_timer(); }
Ошибка 3: игнорирование разброса:
// ❌ Одно измерение double time = measure_once(); printf("Time: %.2f ms\n", time); // ✅ Статистический анализ double times[100]; for (int i = 0; i < 100; i++) { times[i] = measure_once(); } printf("Mean: %.2f ms\n", mean(times, 100)); printf("Median: %.2f ms\n", median(times, 100)); printf("Std dev: %.2f ms\n", stddev(times, 100)); printf("Min: %.2f ms\n", min(times, 100)); printf("Max: %.2f ms\n", max(times, 100));
6. Подведём итог
Основные выводы
Dhrystone устарел:
Современные компиляторы удаляют оптимизацией основную часть его работы.
Результаты сильно зависят от компиляторов.
Бенчмарк не отражает реальных нагрузок.
Следует использовать только для исторического сравнения.
Coremark лучше, но неидеален:
Устойчив к компиляторным оптимизациям благодаря использованию различных методик.
Отражает разнообразные целочисленные нагрузки.
Имеет строгие правила прогонов.
Но: малый датасет, отсутствие операций с плавающей запятой/SIMD, отсутствие оверхеда операционной системы.
Принципы проектирования бенчмарков:
Использовать разнообразные реалистичные нагрузки.
Предотвращать устранение мёртвого кода.
Использовать входные данные, определяемые во время исполнения.
Валидировать результаты.
Раскрывать полную методологию.
Осознавать ограничения.
Бенчмарки — это инструменты, а не самоцель:
Высокие результаты Coremark не гарантируют хорошей производительности с вашей нагрузкой.
Разберитесь, что конкретно измеряет бенчмарк.
Дополняйте измерения бенчмарками под конкретную область применения.
Профилируйте реальные области применения.
Общая картина
В этой главе мы подробно изучили два бенчмарка, но выводы можно применить и в более широком смысле:
Глава 3 (бенчмаркинг): важна статистическая строгость. Следует выполнять множество итераций, вычислять дисперсию, контролировать факторы, затрудняющие интерпретацию результатов.
Глава 2 (иерархия памяти): поведение кэша определяет производительность. Бенчмарки с нереалистичными паттернами доступа к данным (например, Dhrystone) упускают это.
Главы 5, 11, 13, 14: реальные приложения используют разнообразные структуры данных. Хорошие бенчмарки (наподобие Coremark) тестируют различные паттерны.
На будущее: при проектировании систем помните, что важны цели оптимизации. Оптимизировать систему под бенчмарк легко. Самое сложное — оптимизироваться под реальные нагрузки с их запутанными, непредсказуемыми паттернами доступа и разнообразными операциями.
Практическая рекомендация
При оценке производительности процессоров:
Обращайте внимание не только на числа в заголовках статей.
Задавайте вопросы: «Какой бенчмарк? Какой компилятор? С какими флагами?».
По возможности выполняйте прогоны с собственной рабочей нагрузкой.
Разберитесь в ограничениях бенчмарка.
При проектировании бенчмарков:
Начните с трассировок реальных областей применения.
Идентифицируйте критические операции.
Создайте минимальный воспроизводимый тест.
Валидируйте его относительно реальной области применения.
Документируйте всё.
При публикации результатов:
Обеспечьте полное раскрытие: оборудование, компилятор, флаги.
Статистический анализ: среднее, медианное, дисперсия.
Методология: разогрев, итерации, валидация.
Ограничения: чего бенчмарк не измеряет.
Компании Сары Чен пришлось усвоить эти уроки на собственном горьком опыте. После публичного скандала она переключилась на Coremark и, что более важно, разработала бенчмарки на основании нагрузок реальных клиентов. При запуске следующего продукта она сделала упор не на результаты бенчмарков, а на реальные улучшения производительности, и покупатели это оценили.