Avatar ·

Как достичь теоретического максимума в 4 FLOP/такт на современных процессорах x86-64?

Всем привет! Я начинающий C++ разработчик и недавно наткнулся на статью о теоретическом пределе производительности вычислений с плавающей точкой. Там говорилось, что современные процессоры (например, Intel Core i7 или AMD Ryzen) способны выполнять до 4 операций с плавающей точкой (FLOP) за каждый такт. Но я никак не могу приблизиться к этому показателю. Вот мой простой тест: я суммирую два больших массива чисел double (по 10 миллионов элементов) в цикле. Код выглядит так: ```cpp #include #include #include int main() { const size_t N = 10'000'000; std::vector a(N, 1.0); std::vector b(N, 2.0); std::vector c(N, 0.0); auto start = std::chrono::high_resolution_clock::now(); for (size_t i = 0; i < N; ++i) { c[i] = a[i] + b[i]; } auto end = std::chrono::high_resolution_clock::now(); double elapsed = std::chrono::duration(end - start).count(); double flops = (double)N / elapsed; // 1 FLOP на итерацию std::cout << "FLOP/s: " << flops << std::endl; return 0; } ``` Я компилирую с флагами `-O3 -march=native`, но получаю около 1-2 GFLOPS на своём i7-10750H (6 ядер, 2.6 ГГц). По моим расчётам, должно быть около 4 FLOP/такт * 2.6 ГГц = 10.4 GFLOPS на одно ядро. В чём проблема? Как правильно организовать код, чтобы приблизиться к теоретическому максимуму? Может, нужно использовать intrinsics или ассемблер? Буду благодарен за подробное объяснение! <<>>

Понимание проблемы: почему ваш код далёк от пиковой производительности

Ваш тест показывает лишь малую часть возможностей процессора. Теоретические 4 FLOP/такт относятся к пиковой производительности при идеальных условиях, когда:

  • Данные уже находятся в кэше L1 (нет задержек памяти)
  • Используются SIMD-инструкции (AVX, AVX-512)
  • Конвейер загружен без простоев (нет зависимостей по данным)
  • Выполняются операции умножения и сложения (FMA — fused multiply-add)

Ваш код с c[i] = a[i] + b[i] — это всего 1 FLOP на итерацию (одно сложение). Даже если процессор выполнит его за 1 такт, вы получите 1 FLOP/такт. Для 4 FLOP/такт нужно выполнять 4 независимые операции за такт, что возможно только с FMA-инструкциями (2 умножения + 2 сложения) или двумя AVX-векторами за такт.

Шаг 1: Используйте FMA (fused multiply-add) для удвоения FLOP/такт

Современные процессоры имеют специальные блоки FMA, которые выполняют a = a + b * c за один такт. Это даёт 2 FLOP за одну инструкцию. Чтобы задействовать их, нужно переписать код так, чтобы умножение и сложение были объединены.

// Пример: c[i] = a[i] * b[i] + c[i] (FMA)
for (size_t i = 0; i < N; ++i) {
    c[i] = a[i] * b[i] + c[i]; // 2 FLOP
}

Однако компилятор может не всегда автоматически генерировать FMA. Используйте флаги -ffast-math -mfma или intrinsic-функции для гарантии.

Шаг 2: Векторизация с AVX/AVX-512

Одна AVX-инструкция (256-бит) обрабатывает 4 числа double одновременно. Если процессор может выполнять 2 таких инструкции за такт (например, Haswell и новее), то получаем:

  • 2 AVX-инструкции × 4 элемента × 2 FLOP (FMA) = 16 FLOP/такт
  • Или 2 AVX-инструкции × 4 элемента × 1 FLOP (сложение) = 8 FLOP/такт

Ваш процессор i7-10750H (Comet Lake) поддерживает AVX2 и FMA. Теоретически он может выполнять 2 AVX-инструкции за такт, что даёт до 16 FLOP/такт для FMA (но на практике из-за ограничений по питанию и теплу — около 8-12).

Шаг 3: Пример кода с intrinsics для достижения 4+ FLOP/такт

#include <immintrin.h>
#include <vector>
#include <iostream>

void saxpy_fma_avx(double* c, const double* a, const double* b, size_t n) {
    size_t i = 0;
    // Обрабатываем по 4 элемента за раз (AVX)
    for (; i + 4 <= n; i += 4) {
        __m256d va = _mm256_loadu_pd(&a[i]);   // загружаем a[i..i+3]
        __m256d vb = _mm256_loadu_pd(&b[i]);   // загружаем b[i..i+3]
        __m256d vc = _mm256_loadu_pd(&c[i]);   // загружаем c[i..i+3]
        vc = _mm256_fmadd_pd(va, vb, vc);      // c = a*b + c (2 FLOP на элемент)
        _mm256_storeu_pd(&c[i], vc);           // сохраняем
    }
    // Обработка остатка
    for (; i < n; ++i) {
        c[i] = a[i] * b[i] + c[i];
    }
}

int main() {
    const size_t N = 10'000'000;
    std::vector<double> a(N, 1.0), b(N, 2.0), c(N, 0.0);
    
    // Прогрев кэша
    saxpy_fma_avx(c.data(), a.data(), b.data(), N);
    
    auto start = std::chrono::high_resolution_clock::now();
    saxpy_fma_avx(c.data(), a.data(), b.data(), N);
    auto end = std::chrono::high_resolution_clock::now();
    
    double elapsed = std::chrono::duration<double>(end - start).count();
    double flops = (2.0 * N) / elapsed; // 2 FLOP на элемент (FMA)
    std::cout << "GFLOPS: " << flops / 1e9 << std::endl;
    return 0;
}

Компилируйте с флагами: -O3 -march=native -ffast-math -funroll-loops. На i7-10750H этот код даст ~8-12 GFLOPS на одном ядре (3-4 FLOP/такт).

Шаг 4: Дополнительные советы для максимума

  • Избегайте зависимостей по данным: Используйте несколько аккумуляторов для разрыва цепочек (например, 4 независимых суммы).
  • Выравнивание памяти: Используйте _mm256_load_pd (выровненный доступ) вместо _mm256_loadu_pd для ускорения на 5-10%.
  • Разворачивание циклов: Вручную разверните цикл на 4-8 итераций, чтобы загрузить конвейер.
  • Потоки: Используйте OpenMP (#pragma omp parallel for) для задействования всех ядер, но помните, что 4 FLOP/такт — это на одно ядро.

Помните: теоретический максимум — это только пик в идеальных условиях. На практике из-за ограничений по питанию (Turbo Boost), пропускной способности памяти и кэша вы получите 70-80% от пика. Но ваш код уже будет в 4-5 раз быстрее исходного!

<<>>

Альтернативный подход: использование библиотек для автоматической векторизации

Если вы не хотите писать intrinsic-код вручную, можно довериться оптимизирующим библиотекам, которые уже реализуют максимальную производительность. Самые популярные варианты:

  • Eigen — библиотека для линейной алгебры. Использует AVX, FMA и многопоточность автоматически. Пример: c = a.array() * b.array() + c.array();
  • Intel MKL (oneMKL) — коммерческая библиотека от Intel с ручной оптимизацией под каждый процессор. Даёт 90-95% от пика.
  • OpenBLAS — бесплатная альтернатива MKL.

Пример с Eigen:

#include <Eigen/Dense>
#include <i