代码之家  ›  专栏  ›  技术社区  ›  youpilat13 Ty Petrice

为什么矩阵的逆在C中的LAPACKE中如此缓慢++

  •  0
  • youpilat13 Ty Petrice  · 技术社区  · 3 年前

    我正在使用 LAPACK 逆矩阵:我做了一个引用传递,即通过处理地址。下面是函数的输入矩阵和输出矩阵的地址。

    问题是,我有义务转换 F_matrix 进入 1D array 我认为这是对运行时级别性能的浪费:如果我多次调用 作用 matrix_inverse_lapack .

    以下是相关功能:

    // Passing Matrixes by Reference
    void matrix_inverse_lapack(vector<vector<double>> const &F_matrix, vector<vector<double>> &F_output) {
    
      // Index for loop and arrays
      int i, j, ip, idx;
    
      // Size of F_matrix
      int N = F_matrix.size();
    
      int *IPIV = new int[N];
    
      // Statement of main array to inverse
      double *arr = new double[N*N];
    
      // Output Diagonal block
      double *diag = new double[N];
    
    for (i = 0; i<N; i++){
        for (j = 0; j<N; j++){
          idx = i*N + j;
          arr[idx] = F_matrix[i][j];
        }
      }
    
      // LAPACKE routines
      int info1 = LAPACKE_dgetrf(LAPACK_ROW_MAJOR, N, N, arr, N, IPIV);
      int info2 = LAPACKE_dgetri(LAPACK_ROW_MAJOR, N, arr, N, IPIV);
    
     for (i = 0; i<N; i++){
        for (j = 0; j<N; j++){
          idx = i*N + j;
          F_output[i][j] = arr[idx];
        }
      }
    
      delete[] IPIV;
      delete[] arr;
    }
    

    例如,我这样称呼它:

    vector<vector<double>> CO_CL(lsize*(2*Dim_x+Dim_y), vector<double>(lsize*(2*Dim_x+Dim_y), 0));
    
    ... some code
    
    matrix_inverse_lapack(CO_CL, CO_CL);
    

    反转的表现不是预期的,我认为这是由于这种转换 2D -> 1D 我在函数中描述的 矩阵_反转_ lapack .

    使现代化

    有人建议我在MacOS Big Sur 11.3上安装MAGMA,但我在安装它时遇到了很多困难。

    我有一个 AMD Radeon Pro 5600M 图形卡。我已经默认安装了Big Sur版本的所有Framework OpenCL(也许我说的不对)。任何人都可以告诉安装MAGMA时应遵循的程序。我在一个MAGMA软件上看到了 http://magma.maths.usyd.edu.au/magma/ 但它真的很贵,与我想要的不符:如果可能的话,我只需要所有的SDK(头和库),用我的GPU卡构建。我已经在我的MacOS上安装了所有的英特尔OpenAPI SDK。也许,我可以把它与MAGMA装置联系起来。

    我看到了另一个链接 https://icl.utk.edu/magma/software/index.html MAGMA似乎是公开的:上面的非免费版本没有任何链接,不是吗?

    0 回复  |  直到 3 年前
        1
  •  1
  •   zkoza    3 年前

    首先,让我抱怨OP没有提供所有必要的数据。程序是 几乎 完整,但不是 minimal, reproducible example 这很重要,因为(a)它浪费时间,(b)它隐藏潜在的相关信息,例如关于矩阵初始化的信息。其次,OP没有提供关于汇编的任何细节,这可能也是相关的。 最后,但并非最不重要的是,OP没有检查状态代码中Lapack函数可能存在的错误,这对正确解释结果也很重要。

    让我们从一个最小的可复制示例开始:

    #include <lapacke.h>
    #include <vector>
    #include <chrono>
    #include <iostream>
    
    using Matrix = std::vector<std::vector<double>>;
    
    std::ostream &operator<<(std::ostream &out, Matrix const &v)
    {
        const auto size = std::min<int>(10, v.size());
        for (int i = 0; i < size; i++)
        {
            for (int j = 0; j < size; j++)
            {
                out << v[i][j] << "\t";
            }
            if (size < std::ssize(v)) out << "...";
            out << "\n";
        }
        return out;
    }
    
    void matrix_inverse_lapack(Matrix const &F_matrix, Matrix &F_output, std::vector<int> &IPIV_buffer,
                               std::vector<double> &matrix_buffer)
    {
        //  std::cout << F_matrix << "\n";
        auto t0 = std::chrono::steady_clock::now();
    
        const int N = F_matrix.size();
    
        for (int i = 0; i < N; i++)
        {
            for (int j = 0; j < N; j++)
            {
                auto idx = i * N + j;
                matrix_buffer[idx] = F_matrix[i][j];
            }
        }
    
        auto t1 = std::chrono::steady_clock::now();
        // LAPACKE routines
        int info1 = LAPACKE_dgetrf(LAPACK_ROW_MAJOR, N, N, matrix_buffer.data(), N, IPIV_buffer.data());
        int info2 = LAPACKE_dgetri(LAPACK_ROW_MAJOR, N, matrix_buffer.data(), N, IPIV_buffer.data());
        auto t2 = std::chrono::steady_clock::now();
    
        for (int i = 0; i < N; i++)
        {
            for (int j = 0; j < N; j++)
            {
                auto idx = i * N + j;
                F_output[i][j] = matrix_buffer[idx];
            }
        }
        auto t3 = std::chrono::steady_clock::now();
    
        auto whole_fun_time = std::chrono::duration<double>(t3 - t0).count();
        auto lapack_time = std::chrono::duration<double>(t2 - t1).count();
        //   std::cout << F_output << "\n";
        std::cout << "status: " << info1 << "\t" << info2 << "\t" << (info1 == 0 && info2 == 0 ? "Success" : "Failure")
                  << "\n";
        std::cout << "whole function:            " << whole_fun_time << "\n";
        std::cout << "LAPACKE matrix operations: " << lapack_time << "\n";
        std::cout << "conversion:                " << (whole_fun_time - lapack_time) / whole_fun_time * 100.0 << "%\n";
    }
    
    int main(int argc, const char *argv[])
    {
        const int M = 5;  // numer of test repetitions
    
        const int N = (argc > 1) ? std::stoi(argv[1]) : 10;
        std::cout << "Matrix size = " << N << "\n";
    
        std::vector<int> IPIV_buffer(N);
        std::vector<double> matrix_buffer(N * N);
    
        // Test matrix_inverse_lapack M times
        for (int i = 0; i < M; i++)
        {
            Matrix CO_CL(N);
            for (auto &v : CO_CL) v.resize(N);
    
            int idx = 1;
            for (auto &v : CO_CL)
            {
                for (auto &x : v)
                {
                    x = idx + 1.0 / idx;
                    idx++;
                }
            }
            matrix_inverse_lapack(CO_CL, CO_CL, IPIV_buffer, matrix_buffer);
        }
    }
    

    在这里 operator<< 这是一种过分的做法,但对于任何想要半手动验证代码是否有效(通过取消第26行和第58行的注释)的人来说,这可能很有用,并且确保代码的正确性比衡量其性能更重要。

    可以使用编译代码

    g++ -std=c++20 -O3  main.cpp -llapacke
    

    该程序依赖于外部库, lapacke ,需要安装,头文件+二进制文件,才能编译和运行代码。

    我的代码与OP的有点不同:它更接近“现代C++”,因为它避免使用裸指针;我还将外部缓冲区添加到 matrix_inverse_lapack 以抑制内存分配器和解除分配器的连续启动,这是一个以可测量的方式减少2D-1D-2D转换开销的小改进。我还必须初始化矩阵,并找到一种方法在OP的脑海中读取的值 N 可能。我还添加了一些计时器读数用于基准测试。除此之外,代码的逻辑是不变的。

    现在在一个不错的工作站上进行了基准测试。它列出了转换所花费的时间相对于所花费的总时间的百分比 矩阵_反转_ lapack 换言之,我测量转换开销:

     N =   10, 3.5%   
     N =   30, 1.5%   
     N =  100, 1%   
     N =  300, 0.5%   
     N = 1000, 0.35%  
     N = 3000, 0.1%  
    

    拉帕克所花费的时间可以很好地缩放为N 3. ,如预期(数据未显示)。对于N=3000,反转矩阵的时间约为16秒,约为5秒 6. s(5微秒),其中N=10。

    我认为即使是3%的开销也是完全可以接受的。我相信OP使用大于100的矩阵,在这种情况下,1%或以下的开销当然是可以接受的。

    那么,什么OP(或任何有类似问题的人)可能做错了,从而获得了“不可接受的开销转换值”?这是我的短名单

    1. 汇编不当
    2. 矩阵初始化不正确(用于测试)
    3. 基准测试不当

    1.编制不当

    如果忘记在发布模式下编译,最终会出现优化的Lapacke与未优化的转换竞争。在我的机器上,当N=20时,这一开销达到33%的峰值。

    2.矩阵初始化不当(用于测试)

    如果这样初始化矩阵:

            for (auto &v : CO_CL)
            {
                for (auto &x : v)
                {
                    x = idx; // rather than, eg., idx + 1.0/idx
                    idx++;
                }
            }
    

    那么矩阵是奇异的,lapack很快返回,状态与0不同。这增加了转换部分的相对重要性。但奇异矩阵并不是人们想要反转的(这是不可能的)。

    3.标杆管理不当

    以下是N=10的程序输出示例:

     ./a.out 10 
     Matrix size = 10
     status: 0  0   Success
     whole function:            0.000127658
     LAPACKE matrix operations: 0.000126783
     conversion:                0.685425%
     status: 0  0   Success
     whole function:            1.2497e-05
     LAPACKE matrix operations: 1.2095e-05
     conversion:                3.21677%
     status: 0  0   Success
     whole function:            1.0535e-05
     LAPACKE matrix operations: 1.0197e-05
     conversion:                3.20835%
     status: 0  0   Success
     whole function:            9.741e-06
     LAPACKE matrix operations: 9.422e-06
     conversion:                3.27482%
     status: 0  0   Success
     whole function:            9.939e-06
     LAPACKE matrix operations: 9.618e-06
     conversion:                3.2297%
    

    可以看出,对lapack函数的第一次调用所花费的时间可能是后续调用的10倍。这是一个相当稳定的模式,就好像Lapack需要一些时间进行自我初始化一样。它会严重影响小N的测量。

    4.还能做什么?

    OP似乎认为他处理2D阵列的方法是好的,而Lapack将2D阵列打包为1D阵列是一种奇怪而过时的方法。不,拉帕克是对的。

    如果将二维阵列定义为 vector<vector<double>> ,获得一个优点:代码简单。这是有代价的。这样一个矩阵的每一行都是与其他行分开分配的。因此,矩阵100乘100可以存储在100个完全不同的存储器块中。这会对缓存(和预取器)利用率产生不良影响。Lack(和其他线性代数包)在单个连续数组中强制数据的紧凑化。这样可以最大限度地减少缓存和预取器未命中。如果OP从一开始就使用这种方法,他可能会获得他们现在为转换支付的1-3%以上的收益。

    这种紧凑化可以通过至少三种方式来实现。

    • 为2D矩阵编写一个自定义类,内部数据存储在1D数组中,并方便访问成员函数(例如: operator () ),或者找一个做这件事的图书馆
    • 为编写自定义分配器 std::vector (或者找一个图书馆)。这个分配器应该从预先分配的1D向量中分配内存,该向量与Lapack使用的数据存储模式完全匹配
    • 使用 std::vector<double*> 并且初始化具有指向预分配的1D阵列的适当元素的地址的指针。

    上述每个解决方案都会强制对周围的代码进行一些更改,而OP可能不想这样做。这一切都取决于代码的复杂性和预期的性能增益。

    EDIT:替代库

    另一种方法是使用 已知的 因为它是高度优化的。Lapack本身可以被重新归类为具有许多实现的标准接口,并且可能会发生OP使用未优化接口的情况。选择哪个库可能取决于OP感兴趣的硬件/软件平台,并且可能随时间变化。

    就目前(2021年年中)而言,一个不错的建议是:

    如果OP使用大小至少为100的集市,那么面向GPU的MAGMA可能值得一试。

    一种更容易(安装、运行)的方式可能是使用并行CPU库,例如Plasma。Plsama是Lapack兼容的,它是由包括Jack Dongarra在内的一个大型团队开发的,它也应该很容易在本地编译,因为它提供了CMake脚本。

    一个基于并行CPU的多核实现在多大程度上优于LU分解的单线程实现的例子可以在这里找到,例如: https://cse.buffalo.edu/faculty/miller/Courses/CSE633/Tummala-Spring-2014-CSE633.pdf (简短回答:对于大小为1000的矩阵,为5到15次)。