总而言之:从 NumPy 数组中获取项目需要创建新的 Python 对象,而列表并非如此。此外,对于 NumPy 数组,索引比列表稍微复杂一些,这可能会增加一些额外的开销。
回顾一下,您列出的 NumPy 操作执行以下操作:
-
b[100][100] 将 b 的第 100 行作为数组返回,然后获取该行索引 100 处的值,将值作为对象返回(例如 np.int64 类型)。
-
b[100,100]直接返回第100行第100列的值(不先返回中间数组)。
-
b.item(100,100) 与上面的 b[100,100] 相同,只是将值转换为原生 Python 类型并返回。
在这些操作中,(1) 是最慢的,因为它需要两个连续的 NumPy 索引操作(我将在下面解释为什么这比列表索引慢)。 (2) 是最快的,因为只执行一个索引操作。操作 (3) 可能较慢,因为它是一个方法调用(这些在 Python 中通常很慢)。
为什么 list 访问仍然比b[100,100] 快?
对象创建
Python 列表是指向内存中对象的指针数组。例如,[1, 2, 3] 列表不直接包含这些整数,而是指向内存地址的指针,每个整数对象都已经存在。为了从列表中获取项目,Python 只返回对该对象的引用。
NumPy 数组不是对象的集合。数组np.array([1, 2, 3]) 只是一个连续的内存块,其中的位设置为表示这些整数值。要从此数组中获取整数,必须在与数组分开的内存中构造一个新的 Python 对象。例如,索引操作可能会返回np.int64 的对象:此对象以前不存在,必须创建。
索引复杂度
a[100][100](从列表中获取)比b[100,100](从数组中获取)更快的另外两个原因是:
下面将更详细地描述使用a[100][100] 和b[100,100] 访问列表和数组中的元素的步骤。
字节码
列表和数组都触发了相同的四个字节码操作码:
0 LOAD_NAME 0 (a) # the list or array
3 LOAD_CONST 0 (100) # index number (tuple for b[100,100])
6 BINARY_SUBSCR # find correct "getitem" function
7 RETURN_VALUE # value returned from list or array
注意:如果您开始对多维列表进行链式索引,例如a[100][100][100],你开始重复这些字节码指令。使用元组索引的 NumPy 数组不会发生这种情况:b[100,100,100] 仅使用四个指令。这就是为什么随着维度数量的增加,时序差距开始缩小的原因。
找到正确的“getitem”函数
访问列表和数组的函数不同,需要在每种情况下找到正确的函数。此任务由BINARY_SUBSCR 操作码处理:
w = POP(); // our index
v = TOP(); // our list or NumPy array
if (PyList_CheckExact(v) && PyInt_CheckExact(w)) { // do we have a list and an int?
/* INLINE: list[int] */
Py_ssize_t i = PyInt_AsSsize_t(w);
if (i < 0)
i += PyList_GET_SIZE(v);
if (i >= 0 && i < PyList_GET_SIZE(v)) {
x = PyList_GET_ITEM(v, i); // call "getitem" for lists
Py_INCREF(x);
}
else
goto slow_get;
}
else
slow_get:
x = PyObject_GetItem(v, w); // else, call another function
// to work out what is needed
Py_DECREF(v);
Py_DECREF(w);
SET_TOP(x);
if (x != NULL) continue;
break;
此代码针对 Python 列表进行了优化。如果函数看到一个列表,它会快速调用函数PyList_GET_ITEM。现在可以在所需的索引处访问此列表(请参阅下面的下一部分)。
但是,如果它没有看到列表(例如,我们有一个 NumPy 数组),它会采用“slow_get”路径。这又会调用另一个函数PyObject_GetItem 来检查对象映射到哪个“getitem”函数:
PyObject_GetItem(PyObject *o, PyObject *key)
{
PyMappingMethods *m;
if (o == NULL || key == NULL)
return null_error();
m = o->ob_type->tp_as_mapping;
if (m && m->mp_subscript)
return m->mp_subscript(o, key);
...
对于 NumPy 数组,正确的函数位于 mp_subscript 结构中的 PyMappingMethods 中。
在调用这个正确的“get”函数之前,请注意额外的函数调用。这些调用增加了b[100] 的开销,但多少取决于 Python/NumPy 的编译方式、系统架构等。
从 Python 列表中获取
上面可以看到函数PyList_GET_ITEM被调用了。这是一个简短的函数,基本上看起来像这样*:
PyList_GetItem(PyObject *op, Py_ssize_t i)
{
if (!PyList_Check(op)) { // check if list
PyErr_BadInternalCall();
return NULL;
}
if (i < 0 || i >= Py_SIZE(op)) { // check i is in range
if (indexerr == NULL) {
indexerr = PyUnicode_FromString(
"list index out of range");
if (indexerr == NULL)
return NULL;
}
PyErr_SetObject(PyExc_IndexError, indexerr);
return NULL;
}
return ((PyListObject *)op) -> ob_item[i]; // return reference to object
}
* PyList_GET_ITEM 实际上是这个函数的宏形式,它做同样的事情,减去错误检查。
这意味着在 Python 列表的索引 i 处获取项目相对简单。在内部,Python 检查项目的类型是否为列表,i 是否在列表的正确范围内,然后返回对列表中对象的引用。
从 NumPy 数组中获取
相比之下,NumPy 必须做更多的工作才能返回所请求索引处的值。
可以通过多种不同的方式对数组进行索引,而 NumPy 必须决定需要哪个索引例程。各种索引例程主要由mapping.c 中的代码处理。
用于索引 NumPy 数组的任何内容都通过函数prepare_index 开始解析索引并存储有关广播、维数等信息。这是函数的调用签名:
NPY_NO_EXPORT int
prepare_index(PyArrayObject *self, PyObject *index,
npy_index_info *indices,
int *num, int *ndim, int *out_fancy_ndim, int allow_boolean)
/* @param the array being indexed
* @param the index object
* @param index info struct being filled (size of NPY_MAXDIMS * 2 + 1)
* @param number of indices found
* @param dimension of the indexing result
* @param dimension of the fancy/advanced indices part
* @param whether to allow the boolean special case
*/
该函数必须进行大量检查。即使对于像b[100,100] 这样相对简单的索引,也必须推断出很多信息,以便 NumPy 可以将引用(视图)返回到正确的值。
总之,找到 NumPy 的“getitem”函数需要更长的时间,而且处理数组索引的函数必然比 Python 列表的单个函数更复杂。