L1 缓存与 Numba 带来的优化挑战

问题描述 投票:0回答:1

我一直致力于优化 NumPy 数组中元素之间差异的计算。我一直在使用 Numba 来提高性能,但当数组大小超过 1 MB 时,我会出现 100 微秒的跳跃。我认为这是由于我的 CPU 的 Ryzen 7950X 1 MB L1 缓存大小所致。

这是示例代码:

@jit(nopython=True)
def extract_difference_1(random_array):
    shape0, shape1 = random_array.shape
    difference_arr = np.empty((shape0, shape1), dtype=np.float64)
    for i in range(shape0):
        difference_arr[i] = random_array[i,0] - random_array[i,1], random_array[i,1] - random_array[i,2], random_array[i,2] - random_array[i,3], random_array[i,3] - random_array[i,4], random_array[i,4] - random_array[i,5], random_array[i,5] - random_array[i,6], random_array[i,6] - random_array[i,0]

    return difference_arr

@jit(nopython=True)
def extract_difference_2(random_array):
    shape0, shape1 = random_array.shape
    split_index = shape0 // 2
    part_1 = extract_difference_1(random_array[:split_index])
    part_2 = extract_difference_1(random_array[split_index:])

    return part_1 , part_2

x_list = [18500, 18700, 18900]
y = 7
for x in x_list:
    random_array = np.random.rand(x, y)
    print(f"\nFor (x,y) = ({x}, {y}), random_array size is {array_size_string(random_array)}:\n")
    for func in [extract_difference_1, extract_difference_2]:
        func(random_array) # compile the function
        timing_result = %timeit -q -o func(random_array)
        print(f"{func.__name__}:\t {timing_result_message(timing_result)}")

计时结果为:

For (x,y) = (18500, 7), random_array size is 0.988 MB, 1011.72 KB:

extract_difference_1:    32.4 µs ± 832 ns,   b: 31.5 µs,    w: 34.3 µs,     (l: 7, r: 10000),
extract_difference_2:    33.8 µs ± 279 ns,   b: 33.5 µs,    w: 34.3 µs,     (l: 7, r: 10000),

For (x,y) = (18700, 7), random_array size is 0.999 MB, 1022.66 KB:

extract_difference_1:    184 µs ± 2.15 µs,   b: 181 µs,     w: 188 µs,  (l: 7, r: 10000),
extract_difference_2:    34.4 µs ± 51.2 ns,  b: 34.3 µs,    w: 34.5 µs,     (l: 7, r: 10000),

For (x,y) = (18900, 7), random_array size is 1.009 MB, 1033.59 KB:

extract_difference_1:    201 µs ± 3.3 µs,    b: 196 µs,     w: 205 µs,  (l: 7, r: 10000),
extract_difference_2:    34.5 µs ± 75.2 ns,  b: 34.4 µs,    w: 34.6 µs,     (l: 7, r: 10000),

将生成的 Difference_arr 分成两个即可,但我更喜欢结果是单个数组。特别是稍后,我将把 y 增加到 10、50、100、1000,将 x 增加到 20000。当将分割数组part_1和part_2合并到difference_arr时,我发现它比extract_difference_1慢。我认为速度变慢是由于 extract_difference_1 大于 1 MB,导致 L1 缓存未被使用。

有没有办法在使用 Python、Numba 或任何其他包将结果保持为单个数组的同时保持性能?或者有没有一种方法可以让我重新组合这些数组,而不会因为结果数组超出一级缓存大小而导致性能损失?

python numpy optimization numba cpu-cache
1个回答
0
投票

TL;DR:性能问题不是由您的CPU缓存引起。它来自目标平台上的分配器的行为,当然是Windows


我认为这是由于我的 CPU 的 Ryzen 7950X 1 MB L1 缓存大小所致。

首先,AMD Ryzen 7950X CPU是Zen4 CPU。该架构具有 32 KiB 而不是 1 MiB 的 L1D 缓存。话虽如此,此架构上的 L2 缓存为 1 MiB。

虽然缓存大小假设乍一看是一个诱人的想法。它有两个主要问题:

首先,两个函数读取和写入的数据量相同。数组分为两部分的事实并没有改变这一事实。因此,如果第一个函数由于 L2 容量而发生缓存未命中,那么其他函数也应该出现这种情况。关于内存访问,两个函数之间唯一的主要区别是访问的顺序,无论如何都不会对性能产生重大影响(因为数组足够大,因此可以缓解延迟问题)。

此外,Zen4 上的 L2 缓存并不比 L3 慢很多。事实上,它的速度不应慢两倍以上,而实验结果显示执行时间增加了 5 倍以上。

我可以在 Windows 上的 Cascade Lake CPU(二级缓存也为 1 MiB)上重现这一点。这是结果:

For (x,y) = (18500, 7), random_array size is 0.988006591796875:

extract_difference_1:    68.6 µs ± 3.63 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
extract_difference_2:    70.8 µs ± 5.2 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

For (x,y) = (18700, 7), random_array size is 0.998687744140625:

extract_difference_1:    342 µs ± 8.31 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
extract_difference_2:    69.7 µs ± 2.67 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

For (x,y) = (18900, 7), random_array size is 1.009368896484375:

extract_difference_1:    386 µs ± 7.34 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
extract_difference_2:    67 µs ± 4.51 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

将得到的 Difference_arr 分成两部分就可以了

这两个函数之间的主要区别在于,一个函数执行 2 个小分配,而不是 1 个大分配。这就提出了一个新的假设:分配时机能解释这个问题吗?

我们可以根据之前的文章轻松回答这个问题:

为什么使用 np.empty 进行分配而不是 O(1)。我们可以看到 0.76 MiB (np.empty(10**5)

) 的分配与下一个大于 1 MiB 的分配之间存在很大的性能差距。以下是目标答案提供的结果:

np.empty(10**5) # 620 ns ± 2.83 ns per loop (on 7 runs, 1000000 loops each) np.empty(10**6) # 9.61 µs ± 34.2 ns per loop (on 7 runs, 100000 loops each)
更准确地说,这是我当前机器上的新基准:

%timeit -n 10_000 np.empty(1000*1024, np.uint8) 793 ns ± 18.8 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each) %timeit -n 10_000 np.empty(1024*1024, np.uint8) 6.6 µs ± 173 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
我们可以看到差距接近 1 MiB。请注意,1000 KiB 和 1024 之间的计时不是很稳定(表明结果取决于隐藏的低级参数——可能是打包/对齐问题)。

这种 Numpy 分配行为是 AFAIK 特定于 Windows 的,而 AFAIR 在 Linux 上不可见(可能会看到差距,但不是那么大,并且不在相同的阈值)。

链接答案中提供了解释:

内核调用成本高昂(大页面也可能发挥作用)。

有没有办法在保持性能的同时让Python的结果成为单个数组

是的。您可以

预分配输出数组内存,这样就不必支付昂贵的分配开销。另一种解决方案是使用另一个分配器(例如 jemalloc)。

这是预分配内存的修改代码:

@nb.jit(nopython=True) def extract_difference_1(random_array, scratchMem): shape0, shape1 = random_array.shape difference_arr = scratchMem[:shape0*shape1].reshape((shape0, shape1))#np.empty((shape0, shape1), dtype=np.float64) for i in range(shape0): difference_arr[i] = random_array[i,0] - random_array[i,1], random_array[i,1] - random_array[i,2], random_array[i,2] - random_array[i,3], random_array[i,3] - random_array[i,4], random_array[i,4] - random_array[i,5], random_array[i,5] - random_array[i,6], random_array[i,6] - random_array[i,0] return difference_arr @nb.jit(nopython=True) def extract_difference_2(random_array, scratchMem): shape0, shape1 = random_array.shape split_index = shape0 // 2 part_1 = extract_difference_1(random_array[:split_index], np.empty((split_index, shape1))) part_2 = extract_difference_1(random_array[split_index:], np.empty((split_index, shape1))) return part_1 , part_2 x_list = [18500, 18700, 18900] y = 7 scratchMem = np.empty(16*1024*1024) for x in x_list: random_array = np.random.rand(x, y) print(f"\nFor (x,y) = ({x}, {y}), random_array size is {x*y*8/1024/1024}:\n") for func in [extract_difference_1, extract_difference_2]: func(random_array, scratchMem) # compile the function timing_result = %timeit -q -o func(random_array, scratchMem) print(f"{func.__name__}:\t {timing_result}")
结果如下:

For (x,y) = (18500, 7), random_array size is 0.988006591796875: extract_difference_1: 65.1 µs ± 2.48 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each) extract_difference_2: 71 µs ± 2.36 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each) For (x,y) = (18700, 7), random_array size is 0.998687744140625: extract_difference_1: 69.3 µs ± 4.05 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each) extract_difference_2: 68.3 µs ± 3.06 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each) For (x,y) = (18900, 7), random_array size is 1.009368896484375: extract_difference_1: 68.5 µs ± 1.98 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each) extract_difference_2: 68.7 µs ± 3.14 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
    
© www.soinside.com 2019 - 2024. All rights reserved.