为什么我的矩阵求逆在 C++ 中使用 LAPACKE 如此缓慢:MAGMA Alternative and set up

问题描述

我使用 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,arr,IPIV);
  int info2 = LAPACKE_dgetri(LAPACK_ROW_MAJOR,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 中描述的这种转换 matrix_inverse_lapack

更新

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

我有一张 AMD Radeon Pro 5600M 显卡。我已经认安装了 Big Sur 版本的所有 Framework OpenCL(也许我这么说是错的)。任何人都可以告诉安装 MAGMA 的程序。我在 http://magma.maths.usyd.edu.au/magma/ 上的 MAGMA 软件上看到了它,但它确实很昂贵,而且与我想要的不符:我只需要所有 SDK(头文件和库),如果可能的话,我的 GPU 卡可以构建它。我已经在我的 MacOS 上安装了所有的 Intel OpenAPI SDK。也许,我可以将它链接到 MAGMA 安装。

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

解决方法

首先让我抱怨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,matrix_buffer.data(),IPIV_buffer.data());
    int info2 = LAPACKE_dgetri(LAPACK_ROW_MAJOR,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 的值可能是什么。我还添加了一些用于基准测试的计时器读数。除此之外,代码逻辑不变。

现在在一个像样的工作站上进行了一个基准测试。它列出了转化所花费的时间相对于 matrix_inverse_lapack 所花费的总时间的百分比。换句话说,我衡量的是转化开销:

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

Lapack 花费的时间很好地缩放为 N3,正如预期的那样(数据未显示)。对于 N = 3000,反转矩阵的时间约为 16 秒,对于 N = 10,约为 5-6 s(5 微秒)。

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

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

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

1.编译不当

如果忘记在 Release 模式下编译,最终会得到优化的 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 个完全不同的存储块中。这对缓存(和预取器)利用率有不良影响。 Lapck(和其他线性代数包)在单个连续数组中强制压缩数据。这是为了最大限度地减少缓存和预取器未命中。如果 OP 从一开始就使用这种方法,他可能会获得超过他们现在为转换支付的 1-3% 的收益。

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

  • 为二维矩阵编写一个自定义类,将内部数据存储在一维数组中并方便地访问成员函数(例如:operator ()),或者找到一个可以做到这一点的库
  • std::vector 编写自定义分配器(或查找库)。此分配器应从与 Lapack 使用的数据存储模式完全匹配的预分配一维向量中分配内存
  • 使用 std::vector<double*> 并初始化指针,其地址指向预分配的一维数组的适当元素。

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

编辑:替代库

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

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

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

一个更简单的(安装、运行)方式可能与并行 CPU 库一起使用,例如等离子体。 Plsama 是 Lapack 兼容的,它已经由包括 Jack Dongarra 在内的一大群人开发,它也应该很容易在本地编译,因为它提供了一个 CMake 脚本。

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