分享

高性能的Python扩展(1)

pig2 发表于 2014-11-25 23:14:31 [显示全部楼层] 只看大图 回帖奖励 阅读模式 关闭右栏 1 17489
本帖最后由 pig2 于 2014-11-27 22:41 编辑

问题导读


1.数组访问宏有什么作用?
2.Python提高性能有什么方法?









简介
通常来说,Python不是一种高性能的语言,在某种意义上,这种说法是真的。但是,随着以Numpy为中心的数学和科学软件包的生态圈的发展,达到合理的性能不会太困难。
当性能成为问题时,运行时间通常由几个函数决定。用C重写这些函数,通常能极大的提升性能。
在本系列的第一部分中,我们来看看如何使用NumPy的C API来编写C语言的Python扩展,以改善模型的性能。在以后的文章中,我们将在这里提出我们的解决方案,以进一步提升其性能。
文件
这篇文章中所涉及的文件可以在Github上获得。
模拟
作为这个练习的起点,我们将在像重力的力的作用下为N体来考虑二维N体的模拟。
以下是将用于存储我们世界的状态,以及一些临时变量的类。
  1. # lib/sim.py
  2. class World(object):
  3.     """World is a structure that holds the state of N bodies and
  4.     additional variables.
  5.     threads : (int) The number of threads to use for multithreaded
  6.               implementations.
  7.     STATE OF THE WORLD:
  8.     N : (int) The number of bodies in the simulation.
  9.     m : (1D ndarray) The mass of each body.
  10.     r : (2D ndarray) The position of each body.
  11.     v : (2D ndarray) The velocity of each body.
  12.     F : (2D ndarray) The force on each body.
  13.     TEMPORARY VARIABLES:
  14.     Ft : (3D ndarray) A 2D force array for each thread's local storage.
  15.     s  : (2D ndarray) The vectors from one body to all others.
  16.     s3 : (1D ndarray) The norm of each s vector.
  17.     NOTE: Ft is used by parallel algorithms for thread-local
  18.           storage. s and s3 are only used by the Python
  19.           implementation.
  20.     """
  21.     def __init__(self, N, threads=1,
  22.                  m_min=1, m_max=30.0, r_max=50.0, v_max=4.0, dt=1e-3):
  23.         self.threads = threads
  24.         self.N  = N
  25.         self.m  = np.random.uniform(m_min, m_max, N)
  26.         self.r  = np.random.uniform(-r_max, r_max, (N, 2))
  27.         self.v  = np.random.uniform(-v_max, v_max, (N, 2))
  28.         self.F  = np.zeros_like(self.r)
  29.         self.Ft = np.zeros((threads, N, 2))
  30.         self.s  = np.zeros_like(self.r)
  31.         self.s3 = np.zeros_like(self.m)
  32.         self.dt = dt
复制代码


在开始模拟时,N体被随机分配质量m,位置r和速度v。对于每个时间步长,接下来的计算有:
  • 合力F,每个体上的合力根据所有其他体的计算。
  • 速度v,由于力的作用每个体的速度被改变。
  • 位置R,由于速度每个体的位置被改变。
第一步是计算合力F,这将是我们的瓶颈。由于世界上存在的其他物体,单一物体上的力是所有作用力的总和。这导致复杂度为O(N^2)。速度v和位置r更新的复杂度都是O(N)。

纯Python
在纯Python中,使用NumPy数组是时间演变函数的一种实现方式,它为优化提供了一个起点,并涉及测试其他实现方式。
  1. # lib/sim.py
  2. def compute_F(w):
  3.     """Compute the force on each body in the world, w."""
  4.     for i in xrange(w.N):
  5.         w.s[:] = w.r - w.r[i]
  6.         w.s3[:] = (w.s[:,0]**2 + w.s[:,1]**2)**1.5
  7.         w.s3[i] = 1.0 # This makes the self-force zero.
  8.         w.F[i] = (w.m[i] * w.m[:,None] * w.s / w.s3[:,None]).sum(0)
  9. def evolve(w, steps):
  10.     """Evolve the world, w, through the given number of steps."""
  11.     for _ in xrange(steps):
  12.         compute_F(w)
  13.         w.v += w.F * w.dt / w.m[:,None]
  14.         w.r += w.v * w.dt
复制代码


合力计算的复杂度为O(N^2)的现象被NumPy的数组符号所掩盖。每个数组操作遍历数组元素。
可视化
这里是7个物体从随机初始状态开始演化的路径图:
10-19-example-paths.png
性能
为了实现这个基准,我们在项目目录下创建了一个脚本,包含如下内容:
  1. import lib
  2. w = lib.World(101)
  3. lib.evolve(w, 4096)
复制代码


我们使用cProfile模块来测试衡量这个脚本。
  1. python -m cProfile -scum bench.py
复制代码


前几行告诉我们,compute_F确实是我们的瓶颈,它占了超过99%的运行时间。
  1. 428710 function calls (428521 primitive calls) in 16.836 seconds
  2. Ordered by: cumulative time
  3. ncalls  tottime  percall  cumtime  percall filename:lineno(function)
  4.      1    0.000    0.000   16.837   16.837 bench.py:2(<module>)
  5.      1    0.062    0.062   16.756   16.756 sim.py:60(evolve)
  6.   4096   15.551    0.004   16.693    0.004 sim.py:51(compute_F)
  7. 413696    1.142    0.000    1.142    0.000 {method 'sum' ...
  8.      3    0.002    0.001    0.115    0.038 __init__.py:1(<module>)
  9.    ...
复制代码


[tr][td]
[/td][/tr]
[tr][td][/td][/tr]
[tr][td]
在Intel i5台式机上有101体,这种实现能够通过每秒257个时间步长演化世界。
简单的C扩展 1
在本节中,我们将看到一个C扩展模块实现演化的功能。当看完这一节时,这可能帮助我们获得一个C文件的副本。文件src/simple1.c,可以在GitHub上获得。
关于NumPy的C API的其他文档,请参阅NumPy的参考。Python的C API的详细文档在这里
样板
文件中的第一件事情是先声明演化函数。这将直接用于下面的方法列表。
  1. static PyObject *evolve(PyObject *self, PyObject *args);
复制代码
[/td][/tr]
[tr][td][/td][/tr]
[tr][td]
接下来是方法列表。
  1. static PyMethodDef methods[] = {
  2.    { "evolve", evolve, METH_VARARGS, "Doc string."},
  3.    { NULL, NULL, 0, NULL } /* Sentinel */
  4. };
复制代码
[/td][/tr]
[tr][td][/td][/tr]
[tr][td]

这是为扩展模块的一个导出方法列表。这只有一个名为evolve方法。
样板的最后一部分是模块的初始化。
  1. PyMODINIT_FUNC initsimple1(void) {
  2.    (void) Py_InitModule("simple1", methods);
  3.    import_array();
  4. }
复制代码


[tr][td]
[/td][/tr]
[tr][td][/td][/tr]
[tr][td]
另外,正如这里显示,initsimple1中的名称必须与Py_InitModule中的第一个参数匹配。对每个使用NumPy API的扩展而言,调用import_array是有必要的。
数组访问宏
数组访问的宏可以在数组中被用来正确地索引,无论数组被如何重塑或分片。这些宏也使用如下的代码使它们有更高的可读性。
  1. #define m(x0) (*(npy_float64*)((PyArray_DATA(py_m) + \
  2. (x0) * PyArray_STRIDES(py_m)[0])))
  3. #define m_shape(i) (py_m->dimensions[(i)])
  4. #define r(x0, x1) (*(npy_float64*)((PyArray_DATA(py_r) + \
  5. (x0) * PyArray_STRIDES(py_r)[0] + \
  6. (x1) * PyArray_STRIDES(py_r)[1])))
  7. #define r_shape(i) (py_r->dimensions[(i)])
复制代码
[/td][/tr]
[tr][td][/td][/tr]
[tr][td]
在这里,我们看到访问宏的一维和二维数组。具有更高维度的数组可以以类似的方式被访问。
在这些宏的帮助下,我们可以使用下面的代码循环r:
  1. for(i = 0; i < r_shape(0); ++i) {
  2.     for(j = 0; j < r_shape(1); ++j) {
  3.         r(i, j) = 0; // Zero all elements.
  4.     }
  5. }
复制代码
[/td][/tr]
[tr][td][/td][/tr]
[tr][td]

命名标记
上面定义的宏,只在匹配NumPy的数组对象定义了正确的名称时才有效。在上面的代码中,数组被命名为py_mpy_r。为了在不同的方法中使用相同的宏,NumPy数组的名称需要保持一致。
计算力
特别是与上面五行的Python代码相比,计算力数组的方法显得颇为繁琐。
  1. static inline void compute_F(npy_int64 N,
  2.                              PyArrayObject *py_m,
  3.                              PyArrayObject *py_r,
  4.                              PyArrayObject *py_F) {
  5.   npy_int64 i, j;
  6.   npy_float64 sx, sy, Fx, Fy, s3, tmp;
  7.   // Set all forces to zero.
  8.   for(i = 0; i < N; ++i) {
  9.     F(i, 0) = F(i, 1) = 0;
  10.   }
  11.   // Compute forces between pairs of bodies.
  12.   for(i = 0; i < N; ++i) {
  13.     for(j = i + 1; j < N; ++j) {
  14.       sx = r(j, 0) - r(i, 0);
  15.       sy = r(j, 1) - r(i, 1);
  16.       s3 = sqrt(sx*sx + sy*sy);
  17.       s3 *= s3 * s3;
  18.       tmp = m(i) * m(j) / s3;
  19.       Fx = tmp * sx;
  20.       Fy = tmp * sy;
  21.       F(i, 0) += Fx;
  22.       F(i, 1) += Fy;
  23.       F(j, 0) -= Fx;
  24.       F(j, 1) -= Fy;
  25.     }
  26.   }
  27. }
复制代码


请注意,我们使用牛顿第三定律(成对出现的力大小相等且方向相反)来降低内环范围。不幸的是,它的复杂度仍然为O(N^2)。
演化函数
该文件中的最后一个函数是导出的演化方法。
  1. static PyObject *evolve(PyObject *self, PyObject *args) {
  2.   // Declare variables.
  3.   npy_int64 N, threads, steps, step, i;
  4.   npy_float64 dt;
  5.   PyArrayObject *py_m, *py_r, *py_v, *py_F;
  6.   // Parse arguments.
  7.   if (!PyArg_ParseTuple(args, "ldllO!O!O!O!",
  8.                         &threads,
  9.                         &dt,
  10.                         &steps,
  11.                         &N,
  12.                         &PyArray_Type, &py_m,
  13.                         &PyArray_Type, &py_r,
  14.                         &PyArray_Type, &py_v,
  15.                         &PyArray_Type, &py_F)) {
  16.     return NULL;
  17.   }
  18.   // Evolve the world.
  19.   for(step = 0;  step< steps; ++step) {
  20.     compute_F(N, py_m, py_r, py_F);
  21.     for(i = 0; i < N; ++i) {
  22.       v(i, 0) += F(i, 0) * dt / m(i);
  23.       v(i, 1) += F(i, 1) * dt / m(i);
  24.       r(i, 0) += v(i, 0) * dt;
  25.       r(i, 1) += v(i, 1) * dt;
  26.     }
  27.   }
  28.   Py_RETURN_NONE;
  29. }
复制代码


在这里,我们看到了Python参数如何被解析。在该函数底部的时间步长循环中,我们看到的速度和位置向量的x和y分量的显式计算。
性能
C版本的演化方法比Python版本更快,这应该不足为奇。在上面提到的相同的i5台式机中,C实现的演化方法能够实现每秒17972个时间步长。相比Python实现,这方面有70倍的提升。
观察
注意,C代码一直保持尽可能的简单。输入参数和输出矩阵可以进行类型检查,并分配一个Python装饰器函数。删除分配,不仅能加快处理,而且消除了由Python对象不正确的引用计数造成的内存泄露(或更糟)。



已有(1)人评论

跳转到指定楼层
anyhuayong 发表于 2014-11-26 08:19:42
牛,学习了,楼主辛苦
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

关闭

推荐上一条 /2 下一条