numpy 为什么在预转置矩阵上执行矩阵乘法比在非转置矩阵上执行矩阵乘法快?

ncecgwcz  于 2023-08-05  发布在  其他
关注(0)|答案(1)|浏览(124)

考虑以下Python代码,与乘以非转置矩阵相比,乘以预转置矩阵会产生更快的执行时间:

import numpy as np
import time

# Generate random matrix
matrix_size = 1000
matrix = np.random.rand(matrix_size, matrix_size)

# Transpose the matrix
transposed_matrix = np.transpose(matrix)

# Multiply non-transposed matrix
start = time.time()
result1 = np.matmul(matrix, matrix)
end = time.time()
execution_time1 = end - start

# Multiply pre-transposed matrix
start = time.time()
result2 = np.matmul(transposed_matrix, transposed_matrix)
end = time.time()
execution_time2 = end - start

print("Execution time (non-transposed):", execution_time1)
print("Execution time (pre-transposed):", execution_time2)

字符串
令人惊讶的是,乘以预转置矩阵更快。人们可能会认为乘法的顺序不应该显著影响性能,但似乎存在差异。
为什么处理预转置矩阵比处理非转置矩阵的执行时间更快?是否有任何潜在的原因或优化来解释这种行为?

更新

我已经考虑了关于cache的评论,并且在每个循环中生成新的矩阵:

import numpy as np
import time
import matplotlib.pyplot as plt

# Generate random matrices
matrix_size = 3000


# Variables to store execution times
execution_times1 = []
execution_times2 = []

# Perform matrix multiplication A @ B^T and measure execution time for 50 iterations
num_iterations = 50
for _ in range(num_iterations):
    matrix_a = np.random.rand(matrix_size, matrix_size)
    start = time.time()
    result1 = np.matmul(matrix_a, matrix_a)
    end = time.time()
    execution_times1.append(end - start)

# Perform matrix multiplication A @ B and measure execution time for 50 iterations
for _ in range(num_iterations):
    matrix_b = np.random.rand(matrix_size, matrix_size)
    start = time.time()
    result2 = np.matmul(matrix_b, matrix_b.T)
    end = time.time()
    execution_times2.append(end - start)

# Print average execution times
avg_execution_time1 = np.mean(execution_times1)
avg_execution_time2 = np.mean(execution_times2)
#print("Average execution time (A @ B^T):", avg_execution_time1)
#print("Average execution time (A @ B):", avg_execution_time2)

# Plot the execution times
plt.plot(range(num_iterations), execution_times1, label='A @ A')
plt.plot(range(num_iterations), execution_times2, label='B @ B.T')
plt.xlabel('Iteration')
plt.ylabel('Execution Time')
plt.title('Matrix Multiplication Execution Time Comparison')
plt.legend()
plt.show()

# Display BLAS configuration
np.show_config()


结果如下:


的数据

blas_mkl_info:
    libraries = ['mkl_rt']
    library_dirs = ['C:/Users/User/anaconda3\\Library\\lib']
    define_macros = [('SCIPY_MKL_H', None), ('HAVE_CBLAS', None)]
    include_dirs = ['C:/Users/User/anaconda3\\Library\\include']
blas_opt_info:
    libraries = ['mkl_rt']
    library_dirs = ['C:/Users/User/anaconda3\\Library\\lib']
    define_macros = [('SCIPY_MKL_H', None), ('HAVE_CBLAS', None)]
    include_dirs = ['C:/Users/User/anaconda3\\Library\\include']
lapack_mkl_info:
    libraries = ['mkl_rt']
    library_dirs = ['C:/Users/User/anaconda3\\Library\\lib']
    define_macros = [('SCIPY_MKL_H', None), ('HAVE_CBLAS', None)]
    include_dirs = ['C:/Users/User/anaconda3\\Library\\include']
lapack_opt_info:
    libraries = ['mkl_rt']
    library_dirs = ['C:/Users/User/anaconda3\\Library\\lib']
    define_macros = [('SCIPY_MKL_H', None), ('HAVE_CBLAS', None)]
    include_dirs = ['C:/Users/User/anaconda3\\Library\\include']
Supported SIMD extensions in this NumPy install:
    baseline = SSE,SSE2,SSE3
    found = SSSE3,SSE41,POPCNT,SSE42,AVX,F16C,FMA3,AVX2
    not found = AVX512F,AVX512CD,AVX512_SKX,AVX512_CLX,AVX512_CNL

j8ag8udp

j8ag8udp1#

在我的机器上看起来不太明显。
跑了1000次我得到这些时间(x=非转置,y=转置)。红点(在y=x轴下)比蓝点多。685/315更准确。因此,p值方面,毫无疑问,这不可能只是随机效应。(1000枚硬币,685个头像是一个明显的异常)
x1c 0d1x的数据
但时间上,这并不明显。集群主要集中在y=x轴上。
现在我开始回答这个问题,因为我很确定这是一个缓存问题。当我在工程学校的时候(很久以前,当这些考虑因素现在变得更加重要时,并且由教师教授,他们自己可以追溯到更重要的时候),在HPC课程中,我们被教导在从Fortran切换到C时要非常小心,因为缓存效应:当迭代一个数组时,按照它在内存中的顺序进行迭代是非常重要的(在numpy中仍然称为“C”顺序与“Fortran”顺序,证明对于那些比我更关心的人来说,这仍然是一个重要的考虑因素-我很少需要在日常工作中关心,因此我调用学校内存而不是作业内存)。
因为在处理内存中刚刚处理的数字旁边的数字时,该数字可能已经加载到缓存中。而如果您处理的下一个数字是1行之下(按C顺序,因此在内存中更远),那么它更有可能不在缓存中。以目前的高速缓存规模,需要很大的矩阵,这使得它的区别。
由于transpose不移动任何数据,而只是调整步长,因此处理转置矩阵的效果是改变了处理数据在内存中的顺序。所以如果你考虑一下简单的算法

for i in range(N):
    for j in range(N):
        res[i,j]=0
        for k in range(N):
            res[i,j] += A[i,k] * B[k,j]

字符串
如果AB是C阶的,那么矩阵A的迭代是按照存储器的顺序进行的(我们沿着一行,一列接一列地迭代,所以存储器中的相邻数字一个接一个),而B不是。
如果该顺序是颠倒的,例如,因为它们已经被转置,那么它是相反的。B以不会造成缓存问题的顺序迭代,而A则不会。
好吧,没有必要在这上面停留太久,因为我告诉所有这些是为了解释为什么我想研究缓存问题的可能性(我的意图是将相同的乘法与转置矩阵的副本进行比较,以便它是相同的矩阵乘法,只有顺序改变。并且还尝试查看是否存在矩阵大小的阈值,在该阈值下该现象不可见,这也将验证高速缓存问题,因为对于这一点,整个矩阵必须不适合高速缓存)
但是,这样做的第一步也是开始避免偏差,因为第一计算使用尚未在高速缓存中的数据,而第二计算使用已经在高速缓存中的数据(特别是在整个矩阵适合高速缓存的情况下)。
这是我尝试的第一件事:只是颠倒了计算顺序。首先计算transposed_matrix,然后计算matrix。



这一次,移位有利于蓝点(当然,我只改变了计算顺序,没有改变轴的含义。所以x仍然是matrix@matrix时序,y仍然是transposed_matrix
这次红点的数量是318对682。所以,几乎和以前完全相反。
所以,结论(至少对我的机器有效):这确实是高速缓存问题。但是,缓存问题仅由以下事实引起,即存在有利于transposed_matrix的偏见:当你用它来计算时,它已经在缓存中了(因为数据和矩阵的数据是一样的)。

编辑:关于问题更新。

正如我在评论中所说的(但由于这个问题已经更新了,这个答案,已经相当多了,我认为它也出现在这个答案中很重要,对于未来的读者来说,更新是不同的。
您的第一个问题是关于A@AA.T@A.T。第二个似乎更快。但这只是一次手术。所以,正如我所展示的,原因只是由于这样一个事实,即当第二个操作完成时,A已经在缓存内存中(当第一个操作完成时,情况并非如此)。因为A.TA是相同的数据(不是副本。但是相同的数据,在相同的存储器地址)。我之前的答案表明,如果你反过来,先计算A.T@A.T,然后计算A@A,那么它是,相反,A.T@A.T更慢,并且比例完全相同。
另一种表现方式是

import numpy as np
import timeit 

A=np.random.normal(0,1,(1000,1000))
B=A.copy()

A@A
print(timeit.timeit(lambda: A@A, number=20))
A.T@A.T
print(timeit.timeit(lambda: A.T@A.T, number=20))
B@B
print(timeit.timeit(lambda: B@B, number=20))


(The事实上,在timeit之前执行A@A,只是为了确保20次计算中的第一次不会因为缓存考虑而变慢)
在我的计算机上,所有这些操作几乎都需要1秒(选择number=20是为了花费1秒)
这一次,没有缓存效应,因为我们每次运行21次,第一次运行时不计算时间。.T无影响
现在,对于你的问题更新,这是另一回事

A@A.T
print(timeit.timeit(lambda: A@A.T, number=20))
A.T@A
print(timeit.timeit(lambda: A.T@A, number=20))
A@B.T
print(timeit.timeit(lambda: A@B.T, number=20))

这一次,前两个操作仅需要650 ms。而不是因为缓存:无论这些操作的顺序如何都是相同的。
这是因为numpy能够检测到A.TA是相同的矩阵,但只有一次转置操作。(这是很容易发现的:它是相同的数据地址,但是步幅和形状(这里的形状是正方形;但更重要的是,步幅是倒置的)是倒置的:A.strides(8000,8)A.T.strides(8, 8000)
因此,numpy很容易意识到这是一个A@A.T的情况。因此,应用一种算法,计算速度更快。正如在评论中所说的(在我之前在评论中说过,还有其他人,几天前,谁误解了你的第一个问题......但这样做是正确的,因为他们提前回答了现在的更新):A@A.T是对称的这里有一些简单的修正。
注意

timeit.timeit(lambda: A@B, number=20)
timeit.timeit(lambda: A@B.T, number=20)


都是1秒(如A@AA.T@A.T)。因此,很容易理解A@BA@AA.T@A.T都只使用一个标准的“矩阵乘法”算法。其中A@A.TA.T@A使用更快的一个。
由于BA的副本,因此A@B.TA@A.T具有相同的对称结果。但这一次,因为是副本,numpy无法意识到它是一个A@A.T的情况,无法意识到它是一个对称的结果(在计算结果之前)。因此A@B.TA@A具有相同的标准“1秒”计时。而A@A.T没有。
这证实了它确实依赖于same address, inverted strides标准。只要不是相同的地址,或者不是相同的步幅倒置,标准算法,1秒。如果是两个地址相同,但步幅相反,那么,特殊算法,650毫秒。

相关问题