Почему копия for-loop не достигает максимальной пропускной способности CPU-RAM на одном ядре?

Я ожидал, что копирование массива с использованием простого цикла 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)

Будет ли здесь иметь значение выравнивание буфера?

500 - Internal Server Error 21.06.2024 15:49

Я не знаю конкретно о Zen2, но во многих многоядерных процессорах одно ядро ​​не может использовать всю пропускную способность памяти.

prl 21.06.2024 16:00
stackoverflow.com/questions/66719426/…
Simon Goater 21.06.2024 16:35
За пределами сигналов Angular: Сигналы и пользовательские стратегии рендеринга
За пределами сигналов Angular: Сигналы и пользовательские стратегии рендеринга
TL;DR: Angular Signals может облегчить отслеживание всех выражений в представлении (Component или EmbeddedView) и планирование пользовательских...
Sniper-CSS, избегайте неиспользуемых стилей
Sniper-CSS, избегайте неиспользуемых стилей
Это краткое руководство, в котором я хочу поделиться тем, как я перешел от 212 кБ CSS к 32,1 кБ (сокращение кода на 84,91%), по-прежнему используя...
0
3
67
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Ваш тест является предвзятым и содержит неопределенное поведение (упомянутое Саймоном Гоатером в комментариях), не говоря уже о том, что он не освобождает память.

Прежде всего, такие компиляторы, как 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, которая обычно выше при большем количестве каналов.

Peter Cordes 22.06.2024 06:30

Связанный: Почему Skylake намного лучше, чем Broadwell-E по однопоточной пропускной способности памяти? и Улучшенный REP MOVSB ​​для memcpy (включая некоторые детали более тонких различий в том, чем хранилища NT отличаются от обычных хранилищ, например, возможно, более высокая задержка для ядра, чтобы передать их и прекратить их отслеживание.)

Peter Cordes 22.06.2024 06:34

@PeterCordes «зависит от процессора». Конечно, и от загрузки + реализации тоже. Я отредактировал ответ, добавив «часто»;). Я должен сказать, что обычно я провожу тесты на своей машине (и серверах), и до сих пор я всегда выбирал высокочастотные DRAM, которые процессору немного сложнее насыщать (и не оптимальную задержку, как у большинства людей, потому что это это слишком дорого). До сих пор я практически никогда не видел эффективности >92% при чтении и >84% при записи (на нескольких процессорах Intel).

Jérôme Richard 22.06.2024 13:40

@PeterCordes Я выбираю 5%-ный разрыв для предоставленного порога эффективности, чтобы быть в безопасности, а также потому, что не каждый напишет идеальный код, и я думаю, что он часто не хуже (часто гораздо больше, менее портативен, его значительно сложнее поддерживать для небольшого выигрыш).

Jérôme Richard 22.06.2024 13:43

Какие флаги вы используете, чтобы gcc заменил его на memmove? Попробовал это на Godbolt, но не смог заставить gcc сделать это. Мой компилятор производит сборку, указанную в вопросе, поэтому с нетемпоральными хранилищами ничего не происходит. Это действительно похоже на ошибки страниц. Для моего процессора (AMD 4600H) одного ядра достаточно, чтобы насытить полосу пропускания вашим тестом и моим при инициализации массивов. Разве средство предварительной выборки не должно скрывать задержку при таком простом шаблоне доступа?

user25664889 22.06.2024 15:17

DDR4-2666 — это более высокочастотная DRAM, чем была рассчитана на мой i7-6700k: P intel.com/content/www/us/en/products/sku/88195/… показывает официальную поддержку до DDR4-2133. Но да, новые процессоры серьезно увеличили частоты DRAM, увеличив соотношение задержки и пропускной способности, но не настолько увеличили способность каждого ядра отслеживать строки кэша в полете.

Peter Cordes 22.06.2024 23:17

@ user25664889: Основная предварительная выборка аппаратного обеспечения осуществляется на уровне L2, который находится внутри ядра ЦП. Количество строк кэша, которые могут находиться в полете, ограничено «суперочередью» в интерфейсе между L2 и кольцевой шиной или межсетевым соединением (во всяком случае, на процессорах Intel; AMD может использовать другие имена). Линии в полете = задержка x пропускная способность, поэтому для фиксированной задержки пропускная способность ограничена количеством записей в суперочереди, поскольку она недостаточно велика, чтобы поддерживать полную пропускную способность всех контроллеров DRAM на современных процессорах. Несколько лет назад одно ядро ​​Skylake-клиента с немного более медленной оперативной памятью могло почти насытить DRAM.

Peter Cordes 22.06.2024 23:23

Другие вопросы по теме