Я ожидал, что копирование массива с использованием простого цикла for позволит достичь максимальной пропускной способности моей машины, но это не так. Я запустил следующий пример кода с входными 3 ГБ, гарантируя, что он не будет заменен. Он получил 13 ГБ/с. (Выполнено 10 раз, стандартное отклонение было < 1 ГБ/с).
Мой процессор — zen2, работающий на частоте 4 ГГц. vmovupd
имеет обратную пропускную способность, равную 1, поэтому ЦП должен быть в состоянии обрабатывать 4 * 32 = 128 GB/s
на одноядерном процессоре, а это означает, что пропускная способность ОЗУ должна быть узким местом. У меня есть две планки по 4 ГБ (один канал) со скоростью 3200 МТ/с, так что это должно быть 25 ГБ/с, а не 13 ГБ/с.
Так что же происходит и что я могу сделать, чтобы добиться здесь максимальной производительности?
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
int main(int argc, char **argv)
{
if (argc != 2) {
printf("Usage: %s <BYTES>\n", argv[0]);
return EXIT_FAILURE;
}
size_t n = atol(argv[1]) / sizeof(double);
double *source = malloc(n * sizeof(double));
double *target = malloc(n * sizeof(double));
struct timeval tv1, tv2;
gettimeofday(&tv1, NULL);
for (size_t i = 0; i < n; i++) {
target[i] = source[i];
}
gettimeofday(&tv2, NULL);
double duration = (double)(tv2.tv_usec - tv1.tv_usec) / 1e6 +
(double)(tv2.tv_sec - tv1.tv_sec);
/* 3 because cache write-back policy causes target to be loaded to the CPU */
fprintf(stderr, "Bandwidth is %lf GB/s\n", 3 * n * sizeof(double) / duration / 1e9);
/* Anti-optimisation */
fprintf(stderr, "%lf\n", target[0]);
return EXIT_SUCCESS;
}
Скомпилировано с помощью -O3 -march=native -mtune=native
, gcc 12.2.0, плотный цикл
13e0: c4 c1 7d 10 0c 09 vmovupd (%r9,%rcx,1),%ymm1
13e6: c4 c1 7d 11 0c 0a vmovupd %ymm1,(%r10,%rcx,1)
13ec: 48 83 c1 20 add $0x20,%rcx
13f0: 49 39 cb cmp %rcx,%r11
13f3: 75 eb jne 13e0 <main._omp_fn.0+0xd0>
С OpenMP я смог увеличить скорость до 18 ГБ/с, что все еще довольно далеко от пика. (Убежал с OMP_PLACES=cores
)
Я не знаю конкретно о Zen2, но во многих многоядерных процессорах одно ядро не может использовать всю пропускную способность памяти.
Ваш тест является предвзятым и содержит неопределенное поведение (упомянутое Саймоном Гоатером в комментариях), не говоря уже о том, что он не освобождает память.
Прежде всего, такие компиляторы, как GCC (v13.2), могут заменить ваш цикл на memmove
(и даже, возможно, на memcpy
). Фактически, GCC так и делает. Хорошая реализация memmove
будет использовать невременное хранилище, поэтому нет записи-распределения строк кэша (т. е. строки кэша не загружаются из ОЗУ). Это означает, что в данном случае расчет пропускной способности неверен. Это должно быть 2*n*...
, а не 3*n*...
. В настоящее время я ожидаю, что все реализации x86-64 будут использовать вневременное хранилище при копировании больших массивов в памяти. Вот что происходит на моей машине (тестирование Debian с процессором i5-9600KF). Это видно в профилировщиках: функция __memmove_avx_unaligned_erms
вызывается во время бенчмарка и занимает значительную часть общего времени (~50%).
Более того, ваш тест включает в себя накладные расходы на ошибки страниц. Действительно, malloc
не сопоставляет виртуальные страницы напрямую с физическими в оперативной памяти. Это сопоставление выполняется лениво во время выполнения во время первого касания, то есть в середине теста. Это особенно дорого. Это можно увидеть с помощью низкоуровневого профилировщика: функция ядра clear_page_erms
вызывается во время теста и занимает значительную часть общего времени (~45%).
Вдобавок ко всему, одно ядро часто не может насытить память. Это связано с тем, что скорость доступа к памяти ограничена формулой concurent_accesses / latency
. В частности, задержка при доступе к ОЗУ довольно велика, а буферы для одновременного доступа AFAIK часто недостаточно велики, чтобы насытить ОЗУ на большинстве основных платформ. По этой причине для насыщения оперативной памяти нередко требуется несколько ядер (1-3 часто бывает достаточно на обычных ПК x86-64).
Наконец, вам нужно быть осторожным с тем, как страницы сопоставляются с каким узлом NUMA в архитектуре NUMA. Разделение — это ключ, позволяющий избежать скрытых эффектов NUMA. AFAIK, процессоры AMD являются NUMA (из-за CCX/CCD, хотя на практике я не проверял).
Простое решение — использовать лучший тест, такой как Stream Triad (разработанный несколько десятилетий назад и до сих пор неплохой, если его правильно настроить).
В качестве альтернативы вы можете использовать следующий лучший тест:
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <sys/time.h>
#include <omp.h>
void __attribute__ ((noinline)) init(double* target, double* source, size_t size)
{
#pragma omp parallel
{
const size_t start = (size * omp_get_thread_num()) / omp_get_num_threads();
const size_t stop = (size * (omp_get_thread_num() + 1)) / omp_get_num_threads();
memset(&source[start], 0, (stop - start) * sizeof(double));
memset(&target[start], 0, (stop - start) * sizeof(double));
}
}
void __attribute__ ((noinline)) benchmark_classical_stores(double* target, double* source, size_t size)
{
// GCC generate a SIMD loop using classical load/store with OpenMP but not with a sequential code
#pragma omp parallel for // Clause ignored by GCC: simd nontemporal(target)
for (size_t i = 0; i < size; i++) {
target[i] = source[i];
}
}
void __attribute__ ((noinline)) benchmark_nt_stores(double* target, double* source, size_t size)
{
#pragma omp parallel
{
const size_t start = (size * omp_get_thread_num()) / omp_get_num_threads();
const size_t stop = (size * (omp_get_thread_num() + 1)) / omp_get_num_threads();
memcpy(&target[start], &source[start], (stop - start) * sizeof(double));
}
}
int main(int argc, char **argv)
{
if (argc != 2) {
printf("Usage: %s <BYTES>\n", argv[0]);
return EXIT_FAILURE;
}
size_t n = atol(argv[1]) / sizeof(double);
double *source = malloc(n * sizeof(double));
double *target = malloc(n * sizeof(double));
init(target, source, n);
for (int j = 0; j < 10; ++j)
{
struct timeval tv1, tv2;
gettimeofday(&tv1, NULL);
benchmark_nt_stores(target, source, n);
gettimeofday(&tv2, NULL);
double duration = (double)(tv2.tv_usec - tv1.tv_usec) / 1e6 +
(double)(tv2.tv_sec - tv1.tv_sec);
fprintf(stderr, "Bandwidth is %lf GB/s\n", 2 * n * sizeof(double) / duration / 1e9);
fprintf(stderr, "%lf\n", target[0]);
}
free(source);
free(target);
return EXIT_SUCCESS;
}
Я получаю 37,9 ГиБ/с на своей машине для решения с использованием невременной записи и 41,2 ГиБ/с для решения с использованием классической загрузки/сохранения. Максимальная практическая пропускная способность моей (двухканальной) оперативной памяти (3200 МГц) составляет 42–43 ГиБ/с, а теоретическая пропускная способность — 47,7 ГиБ/с. Результаты хорошие, поскольку мой процессор i5-9600KF рассчитан на пропускную способность 38,7 ГиБ/с. Я думаю, что нетемпоральное хранилище приводит к более низкой пропускной способности из-за ограниченного параллелизма по сравнению с альтернативным решением (которое может извлечь выгоду из целого кеша).
Немного лучших результатов, безусловно, можно достичь с помощью огромных страниц (чтобы избежать промахов TLB) и выравниваемых загрузок (memcpy
выполняет невыровненную загрузку на моей машине).
Обратите внимание, что насыщение оперативной памяти крайне сложно для процессоров, особенно когда нагрузки и хранилища смешаны (особенно невременные). Если вы достигаете >80% теоретической пропускной способности при смешанных чтениях/сохранениях, то можете считать, что код насыщает вашу память (>85% только для чтения).
одно ядро не может насытить память - зависит от ЦП. Мой i7-6700k Skylake с двухканальной DDR2666 может приблизиться, по крайней мере, к чистому чтению, как минимум 90% IIRC, а может и выше. Я забыл, насколько это близко к копированию. Intel Xeon намного хуже: у них более низкая пропускная способность одноядерной памяти (из-за более высокой задержки без ядра, среди других факторов), а также общая пропускная способность DRAM, которая обычно выше при большем количестве каналов.
Связанный: Почему Skylake намного лучше, чем Broadwell-E по однопоточной пропускной способности памяти? и Улучшенный REP MOVSB для memcpy (включая некоторые детали более тонких различий в том, чем хранилища NT отличаются от обычных хранилищ, например, возможно, более высокая задержка для ядра, чтобы передать их и прекратить их отслеживание.)
@PeterCordes «зависит от процессора». Конечно, и от загрузки + реализации тоже. Я отредактировал ответ, добавив «часто»;). Я должен сказать, что обычно я провожу тесты на своей машине (и серверах), и до сих пор я всегда выбирал высокочастотные DRAM, которые процессору немного сложнее насыщать (и не оптимальную задержку, как у большинства людей, потому что это это слишком дорого). До сих пор я практически никогда не видел эффективности >92% при чтении и >84% при записи (на нескольких процессорах Intel).
@PeterCordes Я выбираю 5%-ный разрыв для предоставленного порога эффективности, чтобы быть в безопасности, а также потому, что не каждый напишет идеальный код, и я думаю, что он часто не хуже (часто гораздо больше, менее портативен, его значительно сложнее поддерживать для небольшого выигрыш).
Какие флаги вы используете, чтобы gcc заменил его на memmove? Попробовал это на Godbolt, но не смог заставить gcc сделать это. Мой компилятор производит сборку, указанную в вопросе, поэтому с нетемпоральными хранилищами ничего не происходит. Это действительно похоже на ошибки страниц. Для моего процессора (AMD 4600H) одного ядра достаточно, чтобы насытить полосу пропускания вашим тестом и моим при инициализации массивов. Разве средство предварительной выборки не должно скрывать задержку при таком простом шаблоне доступа?
DDR4-2666 — это более высокочастотная DRAM, чем была рассчитана на мой i7-6700k: P intel.com/content/www/us/en/products/sku/88195/… показывает официальную поддержку до DDR4-2133. Но да, новые процессоры серьезно увеличили частоты DRAM, увеличив соотношение задержки и пропускной способности, но не настолько увеличили способность каждого ядра отслеживать строки кэша в полете.
@ user25664889: Основная предварительная выборка аппаратного обеспечения осуществляется на уровне L2, который находится внутри ядра ЦП. Количество строк кэша, которые могут находиться в полете, ограничено «суперочередью» в интерфейсе между L2 и кольцевой шиной или межсетевым соединением (во всяком случае, на процессорах Intel; AMD может использовать другие имена). Линии в полете = задержка x пропускная способность, поэтому для фиксированной задержки пропускная способность ограничена количеством записей в суперочереди, поскольку она недостаточно велика, чтобы поддерживать полную пропускную способность всех контроллеров DRAM на современных процессорах. Несколько лет назад одно ядро Skylake-клиента с немного более медленной оперативной памятью могло почти насытить DRAM.
Будет ли здесь иметь значение выравнивание буфера?