前言
最近写了点rankboost相关的代码,发现当weaklearner比较多且数据量巨大的时候,单纯的利用python+sklearn+numpy来fit是非常慢的,就想到了之前用过的cython,写完之后果然效率飞起啊。但是为什么python如此之慢呢?我这个菜鸡还是需要学习一下的。。。
Why python is slow?
归根结底,python是一种动态类型的解释性语言,它的值并不是存储在密集的buffer中,而是在分散的对象中。
python是动态类型的,而不是静态的
每次当程序执行时,解释器并不清楚所定义的数据类型。python变量和C语言变量的不同可以用下图来表示:
对于C语言来说,一个变量在定义时编译器就知道它的数据类型。而对于一个python变量来说,你在程序运行时只知道它时一个python的object。
举个例子,对于下面的c语言程序:
/* C code */
int a = 1;
int b = 2;
int c = a + b;
此时,C编译器知道和为int整数,它们不可能时任何其他的东西!于是,它可以简单的调用两个数加法的routine,并返回一个值——它只是内存中的一个简单值。一个粗略的步骤如下:
// C Addition
1. Assign <int> 1 to a
2. Assign <int> 2 to b
3. call binary_add<int, int>(a, b)
4. Assign the result to c
同样的,对于python来说:
# python code
a = 1
b = 2
c = a + b
这里,python解释器仅仅知道1和2是两个object,但是并不知道这两个object是什么类型的! 为此,解释器必须检查每个object的PyObject_HEAD来确定他们的类型,然后调用适合这两个object类型的加法routine。加法完成后,它还必须创建和初始化一个python obbject来保存返回值。整个过程粗略的表示为:
# Python Addition
1. Assign 1 to a
1). Set a->PyObject_HEAD->typecode to integer
2). Set a->val = 1
2. Assign 2 to b
1). Set b->PyObject_HEAD->typecode to integer
2). Set b->val = 2
3. call binary_add(a, b)
1) 3a. find typecode in a->PyObject_HEAD
2) a is an integer; value is a->val
3) find typecode in b->PyObject_HEAD
4) b is an integer; value is b->val
5) call binary_add<int, int>(a->val, b->val)
6) result of this is result, and is an integer.
4. Create a Python object c
1) set c->PyObject_HEAD->typecode to integer
2) set c->val to result
动态类型意味着任何操作都涉及到更多的步骤。这是Python对数值数据的操作比C慢的主要原因。
Python is interpreted rather than compiled
我们在上面看到了解释代码和编译代码之间的一个区别。智能的编译器可以向前看,并针对重复或不需要的操作进行优化,从而加快速度。所以对于python,也有很多成熟和有效的package对齐进行优化,比如numpy,numba,cython等,后面有时间会单独写出来比较一下他们的速度关系,以及简单介绍一点点用法。比如某大佬的这篇文章.
Python's object model can lead to inefficient memory access
上面我们看到了C和Python再进行整数加法时他们之间的差别。现在假设你有非常非常多的整数,并希望对它们执行某种批处理操作。在python里你会使用list object,而在C里你会使用buffer-based 数列。
最简单形式的NumPy数组是围绕C数组构建的Python对象。也就是说,它有一个指向值的连续数据缓冲区的指针。
Python list 有一个指向一个连续的指针缓冲区的指针,每个指针指向一个Python对象,该对象又指向了它的数据:
很容易看出,如果您正在执行一些按顺序遍历数据的操作,numpy布局将比Python布局更高效,无论是存储成本还是访问成本。
So Why Use Python?
动态类型使Python比C更容易上手。它非常灵活和宽容,这种灵活性可以有效地利用开发时间,在那些您真正需要优化C或Fortran的情况下,Python为编译后的库提供了简单的hooks。这就是为什么Python在许多科学界的使用一直在不断增长。所有这些加在一起,Python最终成为一种非常高效的语言,可以完成用代码进行科学研究的整个任务。
Python meta-hacking: Don't take my word for it
下面所有的分析都是基于python3.4的!
Digging into Python Integers
python中很容易创建和使用整数:
x = 42
print(x)
但是这个界面的简单性掩盖了幕后发生的事情的复杂性。 我们在上面简要讨论了Python整数的内存布局。在这里,我们将使用Python的内置ctypes模块从Python解释器本身来看Python的integer类型。但是首先,我们需要确切地知道Python整数在C语言的API级别上是什么样子。
CPython中实际的x变量存储在CPython源代码中定义的结构中,hg.python.org/cpython/fil…
struct _longobject {
PyObject_VAR_HEAD
digit ob_digit[1];
};
PyObject_VAR_HEAD是一个宏,它用以下结构启动对象,Include/object.h
typedef struct {
PyObject ob_base;
Py_ssize_t ob_size; /* Number of items in variable part */
} PyVarObject;
还包括一个PyObject,Include/object.h
typedef struct _object {
_PyObject_HEAD_EXTRA
Py_ssize_t ob_refcnt;
struct _typeobject *ob_type;
} PyObject;
这里的_PyObject_HEAD_EXTRA是一个通常在Python构建中不使用的宏。 将所有这些放在一起,integer对象的结构如下:
struct _longobject {
long ob_refcnt;
PyTypeObject *ob_type;
size_t ob_size;
long ob_digit[1];
};
ob_refcnt变量是对象的引用计数,ob_type变量是指向包含对象所有类型信息和方法定义的结构的指针,ob_digit保存实际的数值。
有了这些知识,我们将使用ctypes模块开始研究实际的对象结构并提取上面的一些信息。
我们从定义C结构的Python表示开始:
import ctypes
class IntStruct(ctypes.Structure):
_fields_ = [("ob_refcnt", ctypes.c_long),
("ob_type", ctypes.c_void_p),
("ob_size", ctypes.c_ulong),
("ob_digit", ctypes.c_long)]
def __repr__(self):
return ("IntStruct(ob_digit={self.ob_digit}, "
"refcount={self.ob_refcnt})").format(self=self)
现在让我们看看某个数的内部表示,比如42。在CPython中,id函数提供对象的内存位置:
num = 42
IntStruct.from_address(id(42))
# Output:
# IntStruct(ob_digit=42, refcount=35)
ob_digit属性指向内存中的正确位置!
回顾一下“ob_refcnt变量是对象的引用计数”,但是为什么这里refcount比1大这么多???
事实证明Python经常使用小整数。如果为每个整数创建一个新的PyObject,则需要大量内存。因此,Python将公共整数值实现为"singletons":也就是说,内存中只存在这些数字的一个副本。换句话说,每次在这个范围内创建一个新的Python整数,您只需创建一个对具有该值的singleton的引用:
x = 42
y = 42
id(x) == id(y)
# Output: True
这两个变量只是指向同一内存地址的指针。当你得到更大的整数(在Python 3.4中大于255)时,情况就不再是这样了:
x = 1234
y = 1234
id(x) == id(y)
# Output: False
只需启动Python解释器就可以创建许多整数对象;看看每个对象有多少个引用是很有趣的:
%matplotlib inline
import matplotlib.pyplot as plt
import sys
plt.loglog(range(1000), [sys.getrefcount(i) for i in range(1000)])
plt.xlabel('integer value')
plt.ylabel('reference count')
从上图可以看出,0被引用了很多次,而且随着数的不断增大,引用次数开始下降。
为了进一步确保它的行为符合我们的预期,让我们确保ob_digit字段包含正确的值:
all(i == IntStruct.from_address(id(i)).ob_digit
for i in range(256))
# Output: True
如果你再深入一点,你可能会注意到,对于大于256的数字来说,这并不成立:事实证明,一些位移位操作是在Objects/longobject.c中执行的,这些操作改变了大整数在内存中的表示方式。
我不能说我完全理解为什么会发生这种情况,但我想这与Python高效处理超过long int数据类型溢出限制的整数的能力有关,我们可以在这里看到:
2 ** 100
# Output: 1267650600228229401496703205376
这个数字太长了,已经超过了long,它只能保存64位的值(即最多∼)
Digging into Python Lists
让我们将上述思想应用到更复杂的类型:Python列表。与整数类似,我们在Include/listobject.h中找到了列表对象本身的定义:
typedef struct {
PyObject_VAR_HEAD
PyObject **ob_item;
Py_ssize_t allocated;
} PyListObject;
同样,我们可以展开宏并对类型进行模糊处理,以确保结构有效地变为:
typedef struct {
long ob_refcnt;
PyTypeObject *ob_type;
Py_ssize_t ob_size;
PyObject **ob_item;
long allocated;
} PyListObject;
这里PyObject **ob_item是指向列表的内容,ob_size的值告诉我们在这个list中有多少item。
class ListStruct(ctypes.Structure):
_fields_ = [("ob_refcnt", ctypes.c_long),
("ob_type", ctypes.c_void_p),
("ob_size", ctypes.c_ulong),
("ob_item", ctypes.c_long), # PyObject** pointer cast to long
("allocated", ctypes.c_ulong)]
def __repr__(self):
return ("ListStruct(len={self.ob_size}, "
"refcount={self.ob_refcnt})").format(self=self)
L = [1,2,3,4,5]
ListStruct.from_address(id(L))
# Output:
# ListStruct(len=5, refcount=1)
为了确保操作正确,让我们创建一些对列表的额外引用,看看它如何影响引用计数:
tup = [L, L] # two more references to L
ListStruct.from_address(id(L))
# Output:
ListStruct(len=5, refcount=3)
现在让我们看看如何在列表中查找实际的元素。 正如我们在上面看到的,元素是通过一个连续的PyObject指针数组存储的。使用ctypes,我们实际上可以创建一个由以前的IntStruct对象组成的复合结构:
# get a raw pointer to our list
Lstruct = ListStruct.from_address(id(L))
# create a type which is an array of integer pointers the same length as L
PtrArray = Lstruct.ob_size * ctypes.POINTER(IntStruct)
# instantiate this type using the ob_item pointer
L_values = PtrArray.from_address(Lstruct.ob_item)
现在让我们看一下每个item中的值:
[ptr[0] for ptr in L_values] # ptr[0] dereferences the pointer
'''
Output:
[IntStruct(ob_digit=1, refcount=5296),
IntStruct(ob_digit=2, refcount=2887),
IntStruct(ob_digit=3, refcount=932),
IntStruct(ob_digit=4, refcount=1049),
IntStruct(ob_digit=5, refcount=808)]
'''
我们已经恢复了列表中的PyObject整数!您可能希望花点时间回顾一下上面的列表内存布局的示意图,并确保您了解这些ctypes操作是如何映射到该图表上的。
Digging into NumPy arrays
现在,为了进行比较,让我们对numpy数组进行类似的操作。这里将跳过对NumPy C-API数组定义的详细介绍;如果您想了解它,可以在NumPy/core/include/NumPy/ndarraytypes.h中找到它。
请注意,我在这里使用的是NumPy版本1.8;这些内部结构可能在版本之间发生了变化。先来创建一个numpy array结构:
class NumpyStruct(ctypes.Structure):
_fields_ = [("ob_refcnt", ctypes.c_long),
("ob_type", ctypes.c_void_p),
("ob_data", ctypes.c_long), # char* pointer cast to long
("ob_ndim", ctypes.c_int),
("ob_shape", ctypes.c_voidp),
("ob_strides", ctypes.c_voidp)]
@property
def shape(self):
return tuple((self.ob_ndim * ctypes.c_int64).from_address(self.ob_shape))
@property
def strides(self):
return tuple((self.ob_ndim * ctypes.c_int64).from_address(self.ob_strides))
def __repr__(self):
return ("NumpyStruct(shape={self.shape}, "
"refcount={self.ob_refcnt})").format(self=self)
x = np.random.random((10, 20))
xstruct = NumpyStruct.from_address(id(x))
xstruct
# Output:
NumpyStruct(shape=(10, 20), refcount=1)
我们发现我们已经提取了正确的形状信息。让我们确保引用计数正确:
L = [x,x,x] # add three more references to x
xstruct
# Output:
NumpyStruct(shape=(10, 20), refcount=4)
现在我们可以完成提取数据缓冲区这一棘手的部分。为了简单起见,我们将忽略strides并假设它是一个C-连续数组;这可以通过一些工作来实现。
x = np.arange(10)
xstruct = NumpyStruct.from_address(id(x))
size = np.prod(xstruct.shape)
# assume an array of integers
arraytype = size * ctypes.c_long
data = arraytype.from_address(xstruct.ob_data)
[d for d in data]
# Output: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
数据变量现在是NumPy数组中定义的连续内存块的视图!为了显示这一点,我们将更改数组中的一个值:
x[4] = 555
[d for d in data]
# Output: [0, 1, 2, 3, 555, 5, 6, 7, 8, 9]
并观察数据视图也发生了变化。x和data都指向同一个连续内存块。 比较Python list和NumPy ndarray的内部结构,很明显NumPy的数组对于表示同类型数据的列表要简单得多。这个事实与编译器处理效率更高的因素有关。
致谢
本文整体算是对此文的翻译和学习,非常感谢作者的高质量文章!翻译水平有限,看英文的更香。。。
广告
博主其他平台:知乎和微信公众号,不定期分享同步免费文章,欢迎关注!
谢谢支持!!!