在 ARM / Raspberry PI 上的多个内核上运行特征密集矩阵乘法时性能下降

问题描述

我发现在 ARM 32 或 64 位 RaspBerry PI 4 上并行运行 2 或 3 个线程上的特征密集矩阵乘法时,性能显着下降。

我无法理解这个问题,因为 RPI 4 有 4 个内核,理论上可以在真正的并行处理中处理多达 4 个线程。此外,我无法在我的笔记本电脑(英特尔 I9 4 核处理器)上重现该问题,无论我并行运行 1 个、2 个还是 3 个线程,每个线程都保持相同的性能

在我的实验中(see this repo 了解详细信息),我在 4 核 RaspBerry Pi 4 buster 上运行不同的线程。该问题出现在 32 位和 64 位版本上。为了说明这个问题,我编写了一个程序,其中每个线程都拥有自己的数据,然后使用自己的数据作为一个完全独立的处理单元来处理密集矩阵乘法:

void worker(const std::string & id) {

  const MatrixXd A = 10 * MatrixXd::Random(size,size);
  const MatrixXd B = 10 * MatrixXd::Random(size,size);
  MatrixXd C;
  double test = 0;

  for (int step = 0; step < 30; ++step) {
      test += foo(A,B,C);
  }

  std::cout << "test value is:" << test << "\n";

}

其中 foo 只是一个包含 100 个矩阵乘法调用的循环:

const int size = 512;

float foo(const MatrixXd &A,const MatrixXd &B,MatrixXd &C) {
  float result = 0.0;
  for (int i = 0; i < 100; ++i)
  {
      C.noalias() = A * B;

      int x = 0;
      int y = 0;

      result += C(x,y);
  }
  return result;
}

使用 chrono 包我发现线程循环中的每一步:

test += foo(A,C);

如果我只运行一个线程,则需要将近 9.000 毫秒:

int main(int argc,char ** argv)
{

    Eigen::initParallel();

    std::cout << Eigen::nbThreads() << " eigen threads\n";

    std::thread t0(worker,"t-0");
    t0.join();

    return 0;
}

当我尝试并行运行 2 个或更多线程时出现问题:

std::thread t0(worker,"t-0");
std::thread t1(worker,"t-1");

t0.join();
t1.join();

根据我的测量(详细结果可以在提到的存储库中找到),当我并行运行两个线程时,100 次乘法的每个周期需要 11.000 毫秒或更长时间。当我运行 3 个线程时,性能会更差(~23.000 毫秒)。

在我的笔记本电脑(Ubuntu 20.04 64 位,4x Intel I9 9900K 处理器)上的同一个实验中,即使我只运行一个、两个或三个线程,每个线程的性能几乎相同(约 1600 毫秒)。

在这个实验中使用的代码 + 编译指令等可以在这个 repo 中找到:https://github.com/doleron/eigen3-multithread-arm-issue

编辑关于@Surt 的答案:

为了验证@Surt 的假设,我进行了一些稍微不同的实验。

  1. 运行 100 次循环 16x16 矩阵的 100,000 次乘法。结果如下图所示:

enter image description here

  1. 运行 100 个 64x64 矩阵的 100,000 次乘法循环。结果如下图所示:

enter image description here

据我所知,9 个 64x64 矩阵所需的总内存缓存为:

64 x 64 x sizeof(double) x 9 = 294,912 in bytes

此内存量占 1 MiB 缓存的 28.1%,为运行到处理器内存中的其他对象留出一些空间。 9 个矩阵是每个线程 3 个矩阵,即矩阵 A、B 和 C。注意我使用 C.noalias() = A * B; 来避免 A * B 的临时矩阵。

  1. 运行 100 次循环 128x128 矩阵的 100,000 次乘法。结果如下图所示:

enter image description here

九个 128x128 矩阵的预期内存量为 1,179,648 字节,超过可用缓存总量的 112%。因此,最后一种情况很可能会遇到处理器缓存瓶颈。

我认为之前图表中显示的结果证实了@Surt 假设,我会接受他/她的回答是正确的。仔细查看图表,当矩阵大小为 16 或 64 时,1 个单线程和 2 个或更多线程的场景之间可能会略有不同。我认为这是由于一般的 OS 调度程序开销。

解决方法

基本上,您的 PI 的缓存比台式 PC CPU 小得多,这意味着您在 PI 上的程序比 PC 更频繁地发生缓存冲突。

512*512*4 (sizeof(float)) or 1 MB per instance. 

在您的 PC 上可能有 12 MB 的 L3 缓存,您永远不会使用 RAM(可能在分配时),而 PI 上的小缓存将被吹走。

Raspberry Pi 4 使用 Broadcom BCM2711 SoC,配备 1.5 GHz 64 位四核 ARM Cortex-A72 处理器和 1 MiB 共享二级缓存。

因此 PI 将花费大量时间从 RAM 中提取数据。

另一方面,如果您在不同线程之间拆分相同的工作,那么您可能会看到性能有所提高(或至少没有降低),某些阻塞方案甚至可能利用了 L1 缓存(在两台机器上)。