基本优化
首先,A
中使用了 B
表达式,因此可以先计算 B
并在 A
表达式中重复使用。
此外,varying_part * np.identity(m) + Y.T @ Y
可以更高效地计算。事实上,varying_part * np.identity(m)
创建了两个大的临时数组,填充速度较慢。这是因为如今的 RAM 与 CPU 性能相比较慢(参见"Memory Wall")。你只需要更新该数组的对角线即可。
另外,计算 B @ (U @ Fi)
要比 (B @ U) @ Fi
(等同于 B @ U @ Fi
)更快,尽管它们在解析上是等价的,因为矩阵乘法是结合的(虽然数值上略有不同)。
以下是优化后的实现:
def obtain_A_B_opt(x):
varying_part = (a * sig_on / np.linalg.norm(x)) ** 2 + L * sig_off
F = Y.T @ Y
np.fill_diagonal(F, F.diagonal()+varying_part)
Fi = np.linalg.inv(F)
tmp = Fi @ U.T
UFiUTi = np.linalg.inv(U @ tmp)
B = tmp @ UFiUTi
A = (Fi - B @ (U @ Fi)) @ Y.T
return A, B
这个优化使我的机器上的代码快了55%。近90%的时间花费在
np.linalg.inv(F)
函数调用中。
坏消息是矩阵求逆在CPU上很难进行优化。Numpy使用的默认OpenBLAS库执行此计算是次优的,但它相对较好,因为它使用相对优化的本机C代码并行运行。此外,改变对角线会强烈影响计算,因此我不认为可以编写逆的增量计算。实际上,将
F[0,0]
增加1会导致高斯约旦算法中所有行的权重都必须重新计算(因此所有后续权重计算)。
如果不减少浮点精度或算法的准确性(例如近似),很难编写更快的代码。如果您拥有服务器GPU或者可以减少精度,GPU可以显著提高速度。
基于GPU的实现
可以使用GPU使该代码在目标平台和输入浮点类型方面更快。离散GPU tend tend to have a significantly bigger computational power than mainstream CPUs. 然而,在主流PC GPU上双精度不成立。事实上,PC GPU针对简单精度浮点数据进行了优化(常用于游戏和3D应用程序)。对于快速双精度计算,需要服务器端GPU。此外,应该记住,CPU和GPU之间的数据传输是昂贵的,但矩阵求逆是瓶颈,并且它在
O(N**3)
中运行,因此数据传输在这里可以忽略不计。Cupy可用于轻松编写GPU实现(适用于Nvidia GPU)。以下是生成的GPU代码:
import cupy as cp
def obtain_A_B_gpu_opt(x):
varying_part = (a * sig_on / np.linalg.norm(x)) ** 2 + L * sig_off
device_Y = cp.array(Y)
device_U = cp.array(U)
F = device_Y.T @ device_Y
cp.fill_diagonal(F, F.diagonal()+varying_part)
Fi = cp.linalg.inv(F)
tmp = Fi @ device_U.T
UFiUTi = cp.linalg.inv(device_U @ tmp)
B = tmp @ UFiUTi
A = (Fi - B @ (device_U @ Fi)) @ device_Y.T
return A.get(), B.get()
请注意,Cupy在首次执行时可能会非常缓慢,但之后应该会明显加快(这对于迭代循环来说是可以接受的)。
实验结果
下面是在我的机器上使用i5-9600KF CPU和Nvidia 1660 Super GPU(主流离散式个人电脑GPU)得到的结果:
double-precision:
- obtain_A_B: 4.10 s
- obtain_A_B_opt: 2.64 s
- obtain_A_B_gpu_opt: 3.03 s
simple-precision:
- obtain_A_B: 3.17 s
- obtain_A_B_opt: 2.52 s
- obtain_A_B_gpu_opt: 0.28 s <----
可以看出,基于GPU的实现在使用单精度浮点输入时速度显着更快。这是因为我的GPU被优化过。在计算服务器上,obtain_A_B_gpu_opt
应该比其他实现要快得多。请注意,在我的GPU上,cp.linalg.inv
仍然占用了90%以上的时间。
np.linalg.inv(U @ Fi @ U.T)
两次 - 实际上,U.T @ np.linalg.inv(U @ Fi @ U.T)
可能可以被缓存。 - AKXnp.linalg.inv(U @ Fi @ U.T)
需要约0.093秒(如预期所示,不是问题所在)。我已经在Matlab中解决了这个问题,并彻底调查了时间:随着问题规模的增加(增加m),主要问题是求逆。 - Andreanumba
,因为该函数只涉及Numpy操作... - AKX