运行 在 ARM 上的多个内核上进行特征密集矩阵乘法时性能下降 / Raspberry PI
Performance drops when running Eigen dense matrix multiplications over multiple cores on ARM / Raspberry PI
我发现当 运行在 ARM 32 上并行 2 或 3 个线程或 Raspberry PI 4 上的 64 位并行本征密集矩阵乘法时,性能显着下降。
我无法理解这个问题,因为 RPI 4 有 4 个核心,理论上可以处理最多 4 个线程的真正并行。此外,我无法在我的笔记本电脑(Intel I9 4 核处理器)上重现该问题,无论我 运行 并行使用一个、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, B, 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.000ms)。
在我的笔记本电脑(Ubuntu 20.04 64 位,4x Intel I9 9900K 处理器)上进行的相同实验中,即使我 运行只有一个线程,两个或三个。
我在这个实验中使用的代码+编译指令等可以在这个 repo 中找到:https://github.com/doleron/eigen3-multithread-arm-issue
编辑@Surt 的回答:
为了验证@Surt 的假设,我进行了一些稍微不同的实验。
- 运行 100,000 次矩阵乘法 16x16 的 100 个循环。结果见下图:
- 运行 100,000 次矩阵乘法 64x64 的 100 个循环。结果见下图:
根据我的统计,9 个矩阵 64x64 所需的总内存缓存为:
64 x 64 x sizeof(double) x 9 = 294,912 in bytes
此内存量占 1 MiB 缓存的 28.1%,这为处理器内存中的其他对象 space 留下了一些 space。 9个矩阵是每个线程3个矩阵,即矩阵A、B和C。注意我使用C.noalias() = A * B;
来避免A * B
的临时矩阵。
- 运行 矩阵 128x128 的 100,000 次乘法的 100 个循环。结果见下图:
九个 128x128 矩阵的预期内存量为 1,179,648 字节,超过总可用缓存的 112%。因此,最后一种情况很可能会遇到处理器缓存瓶颈。
我认为前面图表中显示的结果证实了@Surt 假设,我会接受 he/she 答案是正确的。仔细检查图表,当矩阵的大小为 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 上的小缓存将被吹走。
The Raspberry Pi 4 uses a Broadcom BCM2711 SoC with a 1.5 GHz 64-bit quad-core ARM Cortex-A72 processor, with 1 MiB shared L2 cache.
因此 PI 将花费大量时间从 RAM 中提取数据。
另一方面,如果您在不同线程之间拆分了相同的工作,那么您可能会看到性能有所提高(或至少没有降低),某些阻塞方案甚至可能利用了 L1 缓存(在两台机器上) .
我发现当 运行在 ARM 32 上并行 2 或 3 个线程或 Raspberry PI 4 上的 64 位并行本征密集矩阵乘法时,性能显着下降。
我无法理解这个问题,因为 RPI 4 有 4 个核心,理论上可以处理最多 4 个线程的真正并行。此外,我无法在我的笔记本电脑(Intel I9 4 核处理器)上重现该问题,无论我 运行 并行使用一个、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, B, 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.000ms)。
在我的笔记本电脑(Ubuntu 20.04 64 位,4x Intel I9 9900K 处理器)上进行的相同实验中,即使我 运行只有一个线程,两个或三个。
我在这个实验中使用的代码+编译指令等可以在这个 repo 中找到:https://github.com/doleron/eigen3-multithread-arm-issue
编辑@Surt 的回答:
为了验证@Surt 的假设,我进行了一些稍微不同的实验。
- 运行 100,000 次矩阵乘法 16x16 的 100 个循环。结果见下图:
- 运行 100,000 次矩阵乘法 64x64 的 100 个循环。结果见下图:
根据我的统计,9 个矩阵 64x64 所需的总内存缓存为:
64 x 64 x sizeof(double) x 9 = 294,912 in bytes
此内存量占 1 MiB 缓存的 28.1%,这为处理器内存中的其他对象 space 留下了一些 space。 9个矩阵是每个线程3个矩阵,即矩阵A、B和C。注意我使用C.noalias() = A * B;
来避免A * B
的临时矩阵。
- 运行 矩阵 128x128 的 100,000 次乘法的 100 个循环。结果见下图:
九个 128x128 矩阵的预期内存量为 1,179,648 字节,超过总可用缓存的 112%。因此,最后一种情况很可能会遇到处理器缓存瓶颈。
我认为前面图表中显示的结果证实了@Surt 假设,我会接受 he/she 答案是正确的。仔细检查图表,当矩阵的大小为 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 上的小缓存将被吹走。
The Raspberry Pi 4 uses a Broadcom BCM2711 SoC with a 1.5 GHz 64-bit quad-core ARM Cortex-A72 processor, with 1 MiB shared L2 cache.
因此 PI 将花费大量时间从 RAM 中提取数据。
另一方面,如果您在不同线程之间拆分了相同的工作,那么您可能会看到性能有所提高(或至少没有降低),某些阻塞方案甚至可能利用了 L1 缓存(在两台机器上) .