多进程调用 numpy 共轭期间的奇怪行为

Tim*_*Tim 5 python numpy multiprocessing cpu-cache python-multiprocessing

附加的脚本针对不同大小的矩阵上不同数量的并行进程评估 numpy.conjugate 例程,并记录相应的运行时间。矩阵形状仅在第一维上有所不同(从 1,64,64 到 256,64,64)。共轭调用始终在 1,64,64 个子矩阵上进行,以确保正在处理的部分适合我系统上的 L2 缓存(每个核心 256 KB,在我的情况下 L3 缓存为 25MB)。运行脚本会生成下图(轴标签和颜色略有不同)。

在此输入图像描述

正如您所看到的,从大约 100,64,64 的形状开始,运行时间取决于所使用的并行进程的数量。

这可能是什么原因造成的?
或者为什么 (100,64,64) 以下的矩阵对进程数的依赖性如此之低?
我的主要目标是找到对此脚本的修改,以便运行时尽可能独立于任意大小的矩阵“a”的进程数量。

如果有 20 个进程:
所有“a”矩阵最多占用:20 * 16 * 256 * 64 * 64 字节 = 320MB
所有“b”子矩阵最多占用:20 * 16 * 1 * 64 * 64 字节 = 1.25MB
因此,所有子矩阵同时适合 L3 缓存,并且单独适合 CPU 每个核心的 L2 缓存。我在这些测试中只使用了物理核心,没有使用超线程。

这是脚本:

from multiprocessing import Process, Queue
import time
import numpy as np
import os
from matplotlib import pyplot as plt
os.environ['OPENBLAS_NUM_THREADS'] = '1'
os.environ['MKL_NUM_THREADS'] = '1'

def f(q,size):
    a = np.random.rand(size,64,64) + 1.j*np.random.rand(size,64,64)
    start = time.time()
    n=a.shape[0]
    for i in range(20):
        for b in a:
            b.conj()
    duration = time.time()-start
    q.put(duration)

def speed_test(number_of_processes=1,size=1):
    number_of_processes = number_of_processes
    process_list=[]
    queue = Queue()
    #Start processes
    for p_id in range(number_of_processes):
        p = Process(target=f,args=(queue,size))
        process_list.append(p)
        p.start()
    #Wait until all processes are finished
    for p in process_list:
        p.join()

    output = []
    while queue.qsize() != 0:
        output.append(queue.get())
    return np.mean(output)

if __name__ == '__main__':
    processes=np.arange(1,20,3)
    data=[[] for i in processes]
    for p_id,p in enumerate(processes):
        for size_0 in range(1,257):
            data[p_id].append(speed_test(number_of_processes=p,size=size_0))

    fig,ax = plt.subplots()
    for d in data:
        ax.plot(d)
    ax.set_xlabel('Matrix Size: 1-256,64,64')
    ax.set_ylabel('Runtime in seconds')

    fig.savefig('result.png')
Run Code Online (Sandbox Code Playgroud)

Jér*_*ard 2

该问题至少是由两种复杂效应的组合造成的:缓存抖动频率缩放。我可以在我的 6 核 i5-9600KF 处理器上重现该效果。


缓存抖动

最大的影响来自缓存抖动问题。通过查看 RAM 吞吐量可以轻松跟踪它。事实上,1 个进程为 4 GiB/s,6 个进程为 20 GiB/s。读取吞吐量与写入吞吐量类似,因此吞吐量是对称的。我的 RAM 最高可达约 40 GiB/s,但通常仅适用于混合读/写模式约 32 GiB/s。这意味着RAM压力相当大。此类用例通常发生在两种情况下:

  • 因为缓存不够大,所以从 RAM 读取数组或将数组写回 RAM;
  • 对内存中不同位置进行了多次访问,但它们映射到 L3 中的相同高速缓存行中。

乍一看,第一种情况更有可能发生在这里,因为数组是连续的并且非常大(不幸的是,另一种效果也会发生,见下文)。事实上,主要问题是a阵列太大,无法放入 L3。事实上,当大小 >128 时,a需要的时间超过128*64*64*8*2 = 8 MiB/process. 实际上,a它是由两个必须读取的数组构建的,因此缓存中所需的空间比这个大 3 倍:即。>24 MiB/进程。问题是所有进程分配相同数量的内存,因此进程数量越多,占用的累积空间就越大a。当累积空间大于缓存时,处理器需要将数据写入RAM并读回,速度较慢。

事实上,这更糟糕:进程没有完全同步,因此某些进程可能会因a.

此外,b.conj()创建一个新数组可能不会每次都在相同的内存分配中分配,因此处理器还需要写回数据。此效果取决于所使用的低级分配器。可以使用out参数 so 来解决这个问题。话虽这么说,这个问题在我的机器上并不重要(使用out6 个进程时速度提高了 2%,使用 1 个进程时速度同样快)。

简而言之,更多的进程访问更多的数据,并且全局数据量不适合 CPU 缓存,从而降低性能,因为需要一遍又一遍地重新加载数组。


频率缩放

现代处理器使用频率缩放(如涡轮增压)来使(相当)顺序应用程序更快,但它们在进行计算时不能对所有内核使用相同的频率,因为处理器的功率预算有限。这导致理论上的可扩展性较低。问题是所有进程都在做相同的工作,因此在 N 个核心上运行的 N 个进程并不比在 1 个核心上运行的 1 个进程花费更多时间。

当创建 1 个进程时,两个内核运行在 4550-4600 MHz(其他内核运行在 3700 MHz),而当运行 6 个进程时,所有内核运行在 4300 MHz。这足以解释我的机器上高达 7% 的差异。

您几乎无法控制涡轮频率,但您可以完全禁用它或控制频率,以便最小-最大频率都设置为基础频率。请注意,在异常情况下(即,当达到临界温度时进行节流),处理器可以自由地使用低得多的频率。我确实看到通过调整频率可以改善行为(实践中改善 7~10%)。


其他效果

当进程数量等于核心数量时,与一个核心空闲用于其他任务相比,操作系统会执行更多的进程上下文切换。上下文切换会稍微降低进程的性能。当所有核心都被分配时尤其如此,因为操作系统调度程序很难避免不必要的迁移。这种情况通常发生在有许多正在运行的进程的 PC 上,但在计算机上则很少发生。这个开销在我的机器上大约是 5-10%。

请注意,进程数不应超过核心数(而不是超线程数)。超过这个限制,性能就很难预测,并且会出现许多复杂的开销(主要是调度问题)。