【问题标题】:Improving Numpy speed for Gauss-Seidel (Jacobi) Solver提高 Gauss-Seidel (Jacobi) 求解器的 Numpy 速度
【发布时间】:2013-07-08 23:23:24
【问题描述】:

此问题是recent question posted regarding MATLAB being twice as fast as Numpy 的后续问题。

我目前在 MATLAB 和 Numpy 中实现了一个 Gauss-Seidel 求解器,它作用于二维轴对称域(圆柱坐标)。代码最初是用 MATLAB 编写的,然后转移到 Python 中。 Matlab 代码运行时间约为 20 秒,而 Numpy 代码运行时间约为 30 秒。但是,我想使用 Numpy,因为这段代码是一个更大程序的一部分,几乎两倍长的模拟时间是一个很大的缺点。

该算法只是求解矩形网格上的离散拉普拉斯方程(在圆柱坐标中)。当网格更新之间的最大差异小于指定的容差时,它完成。

Numpy 中的代码是:

import numpy as np
import time

T = np.transpose

# geometry
length = 0.008
width = 0.002

# mesh
nz = 256
nr = 64

# step sizes
dz = length/nz
dr = width/nr

# node position matrices
r = np.tile(np.linspace(0,width,nr+1), (nz+1, 1)).T
ri = r/dr

# equation coefficients
cr = dz**2 / (2*(dr**2 + dz**2))
cz = dr**2 / (2*(dr**2 + dz**2))

# initial/boundary conditions
v = np.zeros((nr+1,nz+1))
v[:,0] = 1100
v[:,-1] = 0
v[31:,29:40] = 1000
v[19:,54:65] = -200

# convergence parameters
tol = 1e-4

# Gauss-Seidel solver
tic = time.time()
max_v_diff = 1;
while (max_v_diff > tol):

    v_old = v.copy()

    # left boundary updates
    v[0,1:nz] = cr*2*v[1,1:nz] + cz*(v[0,0:nz-1] + v[0,2:nz+2])
    # internal updates
    v[1:nr,1:nz] = cr*((1 - 1/(2*ri[1:nr,1:nz]))*v[0:nr-1,1:nz] + (1 + 1/(2*ri[1:nr,1:nz]))*v[2:nr+1,1:nz]) + cz*(v[1:nr,0:nz-1] + v[1:nr,2:nz+1])
    # right boundary updates
    v[nr,1:nz] = cr*2*v[nr-1,1:nz] + cz*(v[nr,0:nz-1] + v[nr,2:nz+1])
    # reapply grid potentials
    v[31:,29:40] = 1000
    v[19:,54:65] = -200

    # check for convergence
    v_diff = v - v_old
    max_v_diff = np.absolute(v_diff).max()

toc = time.time() - tic
print(toc)

这实际上不是我使用的完整算法。完整的算法使用连续的过松弛和棋盘迭代方案来提高速度并消除求解器的方向性,但为了简单起见,我提供了这个更易于理解的版本。 Numpy 的速度缺陷在完整版中更为明显(Numpy 和 MATLAB 中的仿真时间分别为 17 秒和 9 秒)。

我尝试了previous question 中的解决方案,将 v 更改为以列为主的顺序数组,但没有性能提升。

有什么建议吗?

编辑:Matlab 代码供参考:

% geometry
length = 0.008;
width = 0.002;

% mesh
nz = 256;
nr = 64;

% step sizes
dz = length/nz;
dr = width/nr;

% node position matrices
r = repmat(linspace(0,width,nr+1)', 1, nz+1);
ri = r./dr;

% equation coefficients
cr = dz^2/(2*(dr^2+dz^2));
cz = dr^2/(2*(dr^2+dz^2));

% initial/boundary conditions
v = zeros(nr+1,nz+1);
v(1:nr+1,1) = 1100;
v(1:nr+1,nz+1) = 0;
v(32:nr+1,30:40) = 1000;
v(20:nr+1,55:65) = -200;

% convergence parameters
tol = 1e-4;
max_v_diff = 1;

% Gauss-Seidel Solver
tic
while (max_v_diff > tol)
    v_old = v;

    % left boundary updates
    v(1,2:nz) = cr.*2.*v(2,2:nz) + cz.*( v(1,1:nz-1) + v(1,3:nz+1) );
    % internal updates
    v(2:nr,2:nz) = cr.*( (1 - 1./(2.*ri(2:nr,2:nz))).*v(1:nr-1,2:nz) + (1 + 1./(2.*ri(2:nr,2:nz))).*v(3:nr+1,2:nz) ) + cz.*( v(2:nr,1:nz-1) + v(2:nr,3:nz+1) );    
    % right boundary updates
    v(nr+1,2:nz) = cr.*2.*v(nr,2:nz) + cz.*( v(nr+1,1:nz-1) + v(nr+1,3:nz+1) );
    % reapply grid potentials
    v(32:nr+1,30:40) = 1000;
    v(20:nr+1,55:65) = -200;

    % check for convergence
    max_v_diff = max(max(abs(v - v_old)));

end
toc

【问题讨论】:

  • 您可以从分析代码和识别瓶颈开始。
  • 如果您希望提高性能,您是否考虑过 Cython(类似 Python 的代码编译成 C)或 Numba(使用 LLVM 的 JIT 编译)?这是一个有趣的比较:jakevdp.github.io/blog/2013/06/15/numba-vs-cython-take-2
  • 我应该提一下,这个实现实际上是 Jacobi 而不是 Gauss-Seidel,并且有应用潜力(但这些可以很容易地删除)。

标签: python performance matlab numpy


【解决方案1】:

在我的笔记本电脑上,您的代码运行大约需要 45 秒。通过尝试将中间数组的创建减少到最低限度,包括重用预先分配的工作数组,我设法将时间减少到 27 秒。这应该会让你回到 MATLAB 的水平,但你的代码可读性会降低。不管怎样,找到下面的代码来替换你# Gauss-Seidel solver评论下面的所有内容:

# work arrays
v_old = np.empty_like(v)
w1 = np.empty_like(v[0, 1:nz])
w2 = np.empty_like(v[1:nr,1:nz])
w3 = np.empty_like(v[nr, 1:nz])

# constants
A = cr * (1 - 1/(2*ri[1:nr,1:nz]))
B = cr * (1 + 1/(2*ri[1:nr,1:nz]))

# Gauss-Seidel solver
tic = time.time()
max_v_diff = 1;
while (max_v_diff > tol):

    v_old[:] = v

    # left boundary updates
    np.add(v_old[0, 0:nz-1], v_old[0, 2:nz+2], out=v[0, 1:nz])
    v[0, 1:nz] *= cz
    np.multiply(2*cr, v_old[1, 1:nz], out=w1)
    v[0, 1:nz] += w1
    # internal updates
    np.add(v_old[1:nr, 0:nz-1], v_old[1:nr, 2:nz+1], out=v[1:nr, 1:nz])
    v[1:nr,1:nz] *= cz
    np.multiply(A, v_old[0:nr-1, 1:nz], out=w2)
    v[1:nr,1:nz] += w2
    np.multiply(B, v_old[2:nr+1, 1:nz], out=w2)
    v[1:nr,1:nz] += w2
    # right boundary updates
    np.add(v_old[nr, 0:nz-1], v_old[nr, 2:nz+1], out=v[nr, 1:nz])
    v[nr, 1:nz] *= cz
    np.multiply(2*cr, v_old[nr-1, 1:nz], out=w3)
    v[nr,1:nz] += w3
    # reapply grid potentials
    v[31:,29:40] = 1000
    v[19:,54:65] = -200

    # check for convergence
    v_old -= v
    max_v_diff = np.absolute(v_old).max()

toc = time.time() - tic

【讨论】:

  • 这是一个很好的方式来展示临时数组有多昂贵。这可能会导致代码的可读性降低,但我会尽量记住使用 out=... 参数的可能性。
  • 是的,stackoverflow.com/questions/17527340/… 显示了同样的问题,(并且还得到了 jaime 的有趣回复;-)),一旦你开始在临时数组上使用大量 ram,你的计算时间上升非常快
  • 实际上,通过仅使用 A 和 B 的技巧,我机器上的计算时间从 55 秒变为 28 秒。 (注意,在 Matlab 上做同样的事情也会减少计算时间。)所有其他带有临时数组的东西在我的机器上都比较慢。
  • 好的,所以 A 和 B 方法提供了极大的改进:在 Python 中为 30s -> 16.2s,在 Matlab 中为 20s -> 13s。这让 Python 迎头赶上!但是,Jaime,您使用临时数组提供的代码实际上比前面的评论者建议的要慢。使用这种方法可以让 Python 在 21.8s 内模拟出来。
  • 这有点令人费解...不一遍又一遍地重新计算AB 有明显的优势,但是numpy 中有些地方不太对劲。尝试对以下时间进行计时以了解我的意思:a = np.arange(1000); b = np.arange(1000); c = np.empty_like(a); %timeit a + b (100000 loops, best of 3: 1.97 us per loop); %timeit np.add(a, b) (1000000 loops, best of 3: 2.94 us per loop); timeit np.add(a, b, out=c) (10000 loops, best of 3: 2.22 us per loop)a + b 应该是 np.add(a, b) 的简写,很难理解开销来自哪里...
【解决方案2】:

通过遵循以下流程,我已经能够将笔记本电脑的运行时间从 66 秒减少到 21 秒:

  1. 找到瓶颈。我使用IPython 控制台中的line_profiler 对代码进行了概要分析,以找到花费最多时间的行。事实证明,超过 80% 的时间都花在了“内部更新”这一行上。

  2. 选择一种优化方法。有几种工具可以加速 numpy 中的代码(Cython、numexpr、weave...)。特别是,scipy.weave.blitz 非常适合将 numpy 表达式(如违规行)编译为快速代码。理论上,该行可以包含在"..." 中并作为weave.blitz("...") 执行,但是正在更新的数组用于计算,因此如docs 中的第4 点所述,必须使用一个临时数组来保持同样的结果:

    expr = "temp = cr*((1 - 1/(2*ri[1:nr,1:nz]))*v[0:nr-1,1:nz] + (1 + 1/(2*ri[1:nr,1:nz]))*v[2:nr+1,1:nz]) + cz*(v[1:nr,0:nz-1] + v[1:nr,2:nz+1]); v[1:nr,1:nz] = temp"
    temp = np.empty((nr-1, nz-1))
    ...
    while ...
        # internal updates
        weave.blitz(expr)
    
  3. 检查结果是否正确后,使用weave.blitz(expr, check_size=0) 禁用运行时检查。代码现在在 34 秒内运行。

  4. 在 Jaime 的工作基础上,预先计算表达式中的常数因子 AB。代码在 21 秒内运行(改动很小,但现在需要编译器)。

这是代码的核心:

from scipy import weave

# [...] Set up code till "# Gauss-Seidel solver"

tic = time.time()
max_v_diff = 1;
A = cr * (1 - 1/(2*ri[1:nr,1:nz]))
B = cr * (1 + 1/(2*ri[1:nr,1:nz]))
expr = "temp = A*v[0:nr-1,1:nz] + B*v[2:nr+1,1:nz] + cz*(v[1:nr,0:nz-1] + v[1:nr,2:nz+1]); v[1:nr,1:nz] = temp"
temp = np.empty((nr-1, nz-1))
while (max_v_diff > tol):
    v_old = v.copy()
    # left boundary updates
    v[0,1:nz] = cr*2*v[1,1:nz] + cz*(v[0,0:nz-1] + v[0,2:nz+2])
    # internal updates
    weave.blitz(expr, check_size=0)
    # right boundary updates
    v[nr,1:nz] = cr*2*v[nr-1,1:nz] + cz*(v[nr,0:nz-1] + v[nr,2:nz+1])
    # reapply grid potentials
    v[31:,29:40] = 1000
    v[19:,54:65] = -200
    # check for convergence
    v_diff = v - v_old
    max_v_diff = np.absolute(v_diff).max()
toc = time.time() - tic

【讨论】:

  • 反之,使用常量我得到 28s 而不是 55s,而使用 weave 可以降低到 24s。主要的改进实际上是通过使用常量!
  • @J.Martinot-Lagarde 这很有趣:我没有测试过这种组合,它确实很快。不过,在我的笔记本电脑中,最终编织版本的速度要快 30%,而不是像您的计时那样快 15%。
  • 顺便问一下,你能检查一下Matlab在使用A和B时是否真的更快?也许它可以识别出它们没有改变?
  • 我在 Matlab 中实现了 A 和 B 的变化,时间从 20s 变成了 13s,一个明显的改进。
  • 感谢您的代码。现在大约 40% 的时间花在运行 weave.blitz 和大约 30% 的收敛性检查上,因此加快速度的一种简单方法(当您期望进行多次迭代时)是每隔一次迭代检查一次收敛性。
猜你喜欢
  • 1970-01-01
  • 2014-03-07
  • 2014-01-16
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多