可能使用 NVidia 使 C++ 中的 for 循环更快的方法?

vel*_*s14 1 c++ parallel-processing optimization performance cuda

我想让 C++ 函数更快。我正在向您询问可能的方法。

我最多可以使用 32 个 OMP 线程。

我可以使用 NVidia GPU。

该函数的 MWE 为:

#include <iostream>
#include <complex>
#include <cmath>

typedef std::numeric_limits<double> dbl;
#define _USE_MATH_DEFINES

#include <omp.h>

const std::complex<double> I(0.0, 1.0); // imaginary unit, I*I = -1
std::complex<double> zero_imag (0.0, 0.0);

const int N_rs = 1500;
const int l_max = 70;
const int lmax = 70;
const int N_thetas = l_max + 1;
const int N_phis = 2 * l_max + 2;
const int N_ps = 600;
const int nphi = 2 * l_max + 2;
const double sqrt_of_2_over_pi = sqrt( 2.0 / M_PI );


void rtop(std::complex<double> * Psi_outer_spec,
          std::complex<double> * Psi_outer_spec_plm,
          double * BJ,
          double * wrk,
          std::complex<double> * wrk2,
          double * ris_without_ends,
          double * r_primes_without_ends,
          double * weights_Lobatto_without_ends
         )
{


    int l, kk, kkk, m;
    long int idx, idxx, idxxx;

    // #pragma omp parallel for firstprivate (wrk2) private(l, kkk, idx, m, kk, idxx, idxxx) schedule(static)
    // #pragma omp target teams distribute parallel for firstprivate(wrk2) private(l, kkk, idx, m, kk, idxx, idxxx)
    for (int i = 0; i <= (N_ps - 1); i++) { // THIS IS THE BOTTLENECK !!!
       
        std::complex<double> sum1 = std::complex<double> (0.0, 0.0); // each thread creates a sum1 on its own

        for (l = 0; l <= lmax; l++) {

            for (kkk = 0; kkk <= (N_rs-1); kkk++) {
                idx = i * (N_rs*(l_max+1)) + kkk * (l_max+1) + l;
                wrk2[kkk] = pow(-I, l) * BJ[idx] * wrk[kkk];
            }

            for (m = 0; m <= (nphi-1); m++) {

                sum1 = zero_imag;
                for (kk = 0; kk <= (N_rs-1); kk++) {
                    idxx = kk * (N_thetas*N_phis) + l * N_phis + m;
                    sum1 += Psi_outer_spec[idxx] * wrk2[kk];

                }

                idxxx = i * (N_thetas*N_phis) + l * N_phis + m;
                Psi_outer_spec_plm[idxxx] = sum1 * sqrt_of_2_over_pi;
                                       
            }
            // END for m loop
        }
        // END for l loop
    }    
    // END for i loop
}

int main() {

    double * wrk = new double [N_rs];
    std::complex<double> * wrk2 = new std::complex<double> [N_rs];

    double * ris_without_ends = new double [N_rs];
    double * r_primes_without_ends = new double [N_rs];
    double * weights_Lobatto_without_ends = new double [N_rs];

    double * BJ = new double [N_ps * N_rs * (l_max+1)];

    std::complex<double> * Psi_outer_spec = new std::complex<double> [N_rs * N_thetas * N_phis];
    std::complex<double> * Psi_outer_spec_plm = new std::complex<double> [N_ps * N_thetas * N_phis];

    rtop(Psi_outer_spec, Psi_outer_spec_plm, BJ, wrk, wrk2, ris_without_ends, r_primes_without_ends, weights_Lobatto_without_ends);
   
    return 0;
}
Run Code Online (Sandbox Code Playgroud)

关联的 CMakeLists.txt 是:

cmake_minimum_required(VERSION 3.0 FATAL_ERROR)

set(CMAKE_VERBOSE_MAKEFILE ON)

set(CMAKE_C_COMPILER "gcc")
set(CMAKE_CXX_COMPILER "g++")

project(trial)

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -pedantic -Wall")

find_package(OpenMP)

add_executable(trial trial.cpp)

if(OpenMP_CXX_FOUND)
target_link_libraries(trial PUBLIC OpenMP::OpenMP_CXX)
endif()

set_property(TARGET trial PROPERTY CXX_STANDARD 17)
Run Code Online (Sandbox Code Playgroud)

编译为:$ cmake .. 那么$ cmake --build . --config Release.

我的输出是:

-- The C compiler identification is GNU 11.3.0
-- The CXX compiler identification is GNU 11.3.0
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /apps20/sw/eb/software/GCCcore/11.3.0/bin/gcc - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /apps20/sw/eb/software/GCCcore/11.3.0/bin/g++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Found OpenMP_C: -fopenmp (found version "4.5")
-- Found OpenMP_CXX: -fopenmp (found version "4.5")
-- Found OpenMP: TRUE (found version "4.5")
-- Configuring done
-- Generating done
-- Build files have been written to: /work4/clf/ouatu/trial_for_SO/build 
Run Code Online (Sandbox Code Playgroud)

然后进行构建:

[ 50%] Building CXX object CMakeFiles/trial.dir/trial.cpp.o
[100%] Linking CXX executable trial
[100%] Built target trial
Run Code Online (Sandbox Code Playgroud)

我尝试过的:

  • 通过 OpenMP 并行,我确实获得了加速。

  • 我的 OpenMP GPU 卸载失败(看来我的编译器标志无法实现卸载)。CMakeLists.txt(这些标志在该 MWE 的显示中是隐藏的)

  • 我愿意接受任何其他建议。

例如,rtop作为 CUDA 内核会受益吗?做到这一点很难吗?

谢谢你!

Hom*_*512 6

我建议使用经过一些优化和调整的 OpenMP 版本。快速回顾一些变化以及需要注意的事项:

整个事情wrk2[kkk] = pow(-I, l) * ... 是双重冗余的。首先,pow(-I, l)它是一种优雅但昂贵的表达 4 种不同值的方式。其次,它仅用作点积中的因子。您可以将整个结果折叠到最后的乘法中sum1 * sqrt_of_2_over_pi。这也允许wrk2实值化,这也将最内层循环从复数-复数点积转变为复数-实数点积。

像这样的多维索引计算idx = i * (N_rs*(l_max+1)) + kkk * (l_max+1) + l应该遵循Horner方法来完成,以避免冗余乘法。更多挑剔,但也更清晰。例如这里idx = (i * N_rs + kkk) * (l_max+1) + l。当我们这样做时,请小心您的索引变量。他们都是int。特别是 3 维数组的大小可以快速增长到多个 GiB,此时您将遇到整数溢出。std::ptrdiff_t如果您担心这可能会成为问题,请切换到。

BJ和的迭代顺序Psi_outer_spec_plm并不理想。如果可能,BJ应该交换两个内部维度以获得更好的数据局部性,这也将允许循环初始化的矢量化wrk2Psi_outer_spec更糟糕的是,因为您在最内层循环中沿着外部维度进行迭代。然而,我认为选择这个顺序是为了让它与 with 相同Psi_outer_spec_plm,并且因为它是好的。无论如何,这种更高的步幅会阻止矢量化。

我不明白为什么您在使用计数器和索引变量的范围之外声明它们。即使现代 C 标准也允许在 for 循环内声明它们,更不用说 C++ 了。对于并行化,您需要限制共享或意外共享变量的数量。

说到共享数据,据我所知,线程可能重叠的唯一共享内存是数组wrk2。可以简单地为每个线程分配它,这将我们带入最终的实现。

#   pragma omp parallel
    {
        auto wrk2 = std::make_unique<double[]>(N_rs);
#       pragma omp for collapse(2) nowait
        for (int i = 0; i <= (N_ps - 1); i++) {
            for (int l = 0; l <= lmax; l++) {
                for (int kkk = 0; kkk <= (N_rs-1); kkk++) {
                    int idx = (i * N_rs + kkk) * (lmax + 1) + l;
                    wrk2[kkk] = BJ[idx] * wrk[kkk];
                }
                constexpr std::complex<double> I(0., 1.);
                std::complex<double> factor(-sqrt_of_2_over_pi);
                if(l & 1)
                    factor *= I;
                if(l & 2)
                    factor = -factor;
                for (int m = 0; m <= (N_phis-1); m++) {
                    std::complex<double> sum1;
                    for (int kk = 0; kk <= (N_rs-1); kk++) {
                        int idx = (kk * N_thetas + l) * N_phis + m;
                        sum1 += Psi_outer_spec[idx] * wrk2[kk];
                    }
                    int idx = (i * N_thetas + l) * N_phis + m;
                    Psi_outer_spec_plm[idx] = sum1 * factor;
                }
            }
        }
    }
Run Code Online (Sandbox Code Playgroud)

请注意如何将通常pragma omp parallel for分为omp parallel和 单独的omp for以允许分配临时内存。这collapse(2)意味着两个外循环都是并行的。

其他需要考虑的事项:

  • 通过加速 BLAS 库或类似的东西可以更快地计算内部点积。我认为Eigen在这里应该工作得很好,但可能需要强制它使用这种内存布局
  • 看起来我们可以将m循环更改为矩阵向量乘积,这可能会通过 BLAS 库解决一些向量化/内存访问问题
  • 既然您询问了编译选项,-march=native或者您想要的任何基线架构,那么这里应该是值得的。-mavx2 -mfma可能是一个很好的折衷方案,可以处理所有相对较新的 CPU,而无需过多地专门化二进制文件

编辑:矩阵向量积

回到将循环卸载m到矩阵向量乘积的想法,我们必须重新解释Psi_outer_spec我们用作矩阵的切片。我选择列主矩阵,因为我想在这一步中使用 Eigen3。

  • 行数为N_phi(循环计数器m
  • 列数为N_rs(循环计数器kk
  • 从一列到下一列,我们有一个跨步/又称领先维度N_phi * N_theta
  • 左上角的偏移量是l * N_phis

假设这是正确的,我们可以将数组映射到特征向量和矩阵,并让它处理转置访问。这会将wrk2初始化下面的所有内容都变成此代码

using MatrixMap = Eigen::Map<const Eigen::MatrixXcd,
        Eigen::Unaligned, Eigen::OuterStride<>>;
MatrixMap Psi_slice(
        Psi_outer_spec + l * N_phis /*top left corner*/,
        N_phis /*rows*/, N_rs /*cols*/,
        Eigen::OuterStride<>(N_phis * N_thetas));
const auto wrk2_mapped = Eigen::VectorXd::Map(wrk2.get(), N_rs);
auto Psi_plm_mapped = Eigen::VectorXcd::Map(
        Psi_outer_spec_plm + (i * N_thetas + l) * N_phis, N_phis);
Psi_plm_mapped.noalias() = Psi_slice * wrk2_mapped * factor;
Run Code Online (Sandbox Code Playgroud)

现在,这一步显然提出了一个问题,即我们是否可以通过一些预处理或后处理将整个事物转变为矩阵-矩阵乘积,这可能会处理整个并行化和潜在的 GPU 卸载。这就是为什么我要求提供数学描述,而不是通过代码进行这种徒劳的追逐

编辑2:矩阵-矩阵乘积

确实可以将其重写为矩阵-矩阵乘积。Psi_outer_spec诀窍是独立于 的观察i。因此,如果我们切换两个外部循环,我们就可以在一次操作中计算出其中一个的所有li

在这样做的同时,我又回到了wrk2复杂化并包含了这个因素。从技术上讲,这需要更多的计算时间和内存,但对于矩阵-矩阵产品,您可能希望直接分派到 BLAS 后端,例如通过OpenBLAS、通过Eigen 的后端甚至 GPU 加速(例如CuBLAS)。为此,您需要复数乘法。

Eigen::MatrixXcd wrk2mat(N_rs, N_ps);
for (int l = 0; l <= lmax; l++) {
    std::complex<double> factor(-sqrt_of_2_over_pi);
    if(l & 1)
        factor *= I;
    if(l & 2)
        factor = -factor;
#   pragma omp parallel for
    for (int i = 0; i <= N_ps - 1; i++) {
        for (int k = 0; k <= N_rs - 1; ++k) {
            int idx = (i * N_rs + k) * (lmax + 1) + l;
            wrk2mat(k, i) = BJ[idx] * wrk[k] * factor;
        }
    }
    using ConstMatrixMap = Eigen::Map<const Eigen::MatrixXcd,
            Eigen::Unaligned, Eigen::OuterStride<>>;
    ConstMatrixMap Psi_slice(
            Psi_outer_spec + l * N_phis /*top left corner*/,
            N_phis /*rows*/, N_rs /*cols*/,
            Eigen::OuterStride<>(N_phis * N_thetas));
    using MatrixMap = Eigen::Map<Eigen::MatrixXcd,
            Eigen::Unaligned, Eigen::OuterStride<>>;
    MatrixMap Psi_plm_mapped(
            Psi_outer_spec_plm + l * N_phis,
            N_phis, N_ps,
            Eigen::OuterStride<>((lmax + 1) * N_phis));
    Psi_plm_mapped.noalias() = Psi_slice * wrk2mat;
}
Run Code Online (Sandbox Code Playgroud)

只要矩阵足够大,矩阵-矩阵乘积就应该在内部并行化。如果情况并非总是如此,您可以将整个事情包装到运行时可选的并行块中。大致是这样的:

bool small_matrices = ...;
#pragma omp parallel if(small_matrices)
{
    Eigen::MatrixXcd wrk2mat(N_rs, N_ps);
#   pragma omp for nowait
    for (int l = 0; l <= lmax; l++) {
        ...
    }
}
Run Code Online (Sandbox Code Playgroud)

由于 OpenMP 通常会停用嵌套并行化,因此这将自动停用所有内部parallel部分并按顺序运行它们。

  • `pow(-I, l)` 围绕单位圆顺时针旋转,分 4 个 90 度的步长 (-i, -1, +i, 1)。不能用一位操作来做到这一点。尽管如此,带有“constexpr I”的“phase *= -I;”在编译时是微不足道的。 (3认同)