如何扩展 NumPy#
编写扩展模块#
虽然 ndarray 对象被设计为允许在 Python 中进行快速计算,但它也被设计为通用目的的,并且满足广泛的计算需求.因此,如果绝对速度至关重要,那么没有比针对您的应用程序和硬件量身定制的精心编译的循环更好的替代品了.这是 numpy 包含 f2py 的原因之一,因此可以轻松地将(简单的)C/C++ 和(任意的)Fortran 代码直接链接到 Python 中.我们鼓励您使用和改进此机制.本节的目的不是记录此工具,而是记录编写此工具所依赖的扩展模块的更基本步骤.
当扩展模块被编写,编译并安装到 Python 路径 (sys.path) 中的某个位置时,该代码就可以像标准的 python 文件一样导入到 Python 中.它将包含已在 C 代码中定义和编译的对象和方法.在 Python 中执行此操作的基本步骤已得到充分记录,您可以在 www.python.org 上提供的 Python 本身的文档中找到更多信息.
除了 Python C-API 之外,NumPy 还有一个完整而丰富的 C-API,允许在 C 级别进行复杂的操作.但是,对于大多数应用程序,通常只会使用少数几个 API 调用.例如,如果您只需要提取指向内存的指针以及一些形状信息以传递给另一个计算例程,那么您将使用与尝试创建新的类数组类型或为 ndarrays 添加新的数据类型非常不同的调用.本章记录了最常用的 API 调用和宏.
必需的子程序#
在您的 C 代码中必须定义一个函数,Python 才能将其用作扩展模块.该函数必须命名为 init{name},其中 {name} 是 Python 模块的名称.必须声明此函数,以便该函数对例程外的代码可见.除了添加所需的方法和常量之外,该子程序还必须包含像 import_array() 和/或 import_ufunc() 这样的调用,具体取决于需要的 C-API .忘记放置这些命令会导致一个丑陋的段错误(崩溃),只要实际调用任何 C-API 子程序.实际上,可以在单个文件中包含多个 init{name} 函数,在这种情况下,该文件将定义多个模块.但是,有一些技巧可以使其正确工作,此处未涵盖.
一个最小的 init{name} 方法如下所示:
PyMODINIT_FUNC
init{name}(void)
{
(void)Py_InitModule({name}, mymethods);
import_array();
}
mymethods 必须是一个 PyMethodDef 结构的数组(通常是静态声明的),其中包含方法名称,实际的 C 函数,一个指示该方法是否使用关键字参数的变量以及文档字符串.这些将在下一节中进行解释.如果要将常量添加到模块中,则可以存储来自 Py_InitModule (这是一个模块对象)的返回值.向模块添加项目的最通用方法是使用 PyModule_GetDict(module) 获取模块字典.使用模块字典,您可以手动向模块添加任何内容.向模块添加对象的更简单方法是使用另外三个 Python C-API 调用之一,这些调用不需要单独提取模块字典.这些已在 Python 文档中记录,但为方便起见,在此重复:
-
int PyModule_AddStringConstant(PyObject *module, char *name, char *value)#
All three of these functions require the module object (the return value of Py_InitModule). The name is a string that labels the value in the module. Depending on which function is called, the value argument is either a general object (
PyModule_AddObjectsteals a reference to it), an integer constant, or a string constant.
定义函数#
传递给 Py_InitModule 函数的第二个参数是一个结构,它可以轻松地定义模块中的函数. 在上面给出的示例中,mymethods 结构将在文件中较早的位置(通常在 init{name} 子例程之前)定义为:
static PyMethodDef mymethods[] = {
{ nokeywordfunc,nokeyword_cfunc,
METH_VARARGS,
Doc string},
{ keywordfunc, keyword_cfunc,
METH_VARARGS|METH_KEYWORDS,
Doc string},
{NULL, NULL, 0, NULL} /* Sentinel */
}
mymethods 数组中的每个条目都是一个 PyMethodDef 结构,包含 1) Python 名称,2) 实现该功能的 C 函数,3) 指示是否接受此函数关键字的标志,以及 4) 该函数的文档字符串. 通过向此表添加更多条目,可以为单个模块定义任意数量的函数. 最后一个条目必须全部为 NULL,如所示的作为哨兵. Python 查找此条目以了解模块的所有函数都已定义.
完成扩展模块的最后一件事实际上是编写执行所需功能的代码. 有两种函数:不接受关键字参数的函数和接受关键字参数的函数.
没有关键字参数的函数#
不接受关键字参数的函数应编写为:
static PyObject*
nokeyword_cfunc (PyObject *dummy, PyObject *args)
{
/* convert Python arguments */
/* do function */
/* return something */
}
在本示例中,dummy 参数未使用,可以安全地忽略. args 参数包含作为元组传递给函数的所有参数. 您可以在此时执行任何操作,但通常管理输入参数的最简单方法是调用 PyArg_ParseTuple (args,format_string,addresses_to_C_variables …)或 PyArg_UnpackTuple (tuple,”name”,min,max,…). Python C-API 参考手册的 5.5 节(解析参数和构造值)中包含有关如何使用第一个函数的详细说明. 您应该特别注意 "O&" 格式,该格式使用转换器函数在 Python 对象和 C 对象之间进行转换. 所有其他格式函数都可以(大部分)被认为是此一般规则的特例. 在 NumPy C-API 中定义了几个转换器函数,这些函数可能有用. 特别是, PyArray_DescrConverter 函数对于支持任意数据类型规范非常有用. 此函数将任何有效的数据类型 Python 对象转换为 PyArray_Descr* 对象. 请记住传入应填充的 C 变量的地址.
NumPy 源代码中有很多关于如何使用 PyArg_ParseTuple 的示例. 标准用法如下:
PyObject *input;
PyArray_Descr *dtype;
if (!PyArg_ParseTuple(args, "OO&", &input,
PyArray_DescrConverter,
&dtype)) return NULL;
重要的是要记住,使用"O"格式字符串时,您会获得对该对象的借用引用. 但是,转换器函数通常需要某种形式的内存处理. 在此示例中,如果转换成功,dtype 将保存对 PyArray_Descr* 对象的新引用,而 input 将保存借用引用. 因此,如果此转换与另一个转换(比如转换为整数)混合在一起,并且数据类型转换成功但整数转换失败,则在返回之前,您需要释放引用计数到数据类型对象. 一种典型的方法是在调用 PyArg_ParseTuple 之前将 dtype 设置为 NULL 然后在返回之前在 dtype 上使用 Py_XDECREF .
处理完输入参数后,编写实际执行工作的代码(可能会根据需要调用其他函数). C 函数的最后一步是返回一些内容. 如果遇到错误,则应返回 NULL (确保已实际设置错误). 如果不应返回任何内容,则递增 Py_None 并返回它. 如果应返回单个对象,则返回它(确保您首先拥有对它的引用). 如果应返回多个对象,则需要返回一个元组. Py_BuildValue (format_string, c_variables…) 函数可以轻松地从 C 变量构建 Python 对象元组. 特别注意格式字符串中 ‘N’ 和 ‘O’ 之间的区别,否则您很容易创建内存泄漏. ‘O’ 格式字符串会递增其对应的 PyObject* C 变量的引用计数,而 ‘N’ 格式字符串会窃取对相应 PyObject* C 变量的引用. 如果您已经为该对象创建了引用,并且只想将该引用提供给元组,则应使用 ‘N’. 如果您只有对对象的借用引用,并且需要创建一个来为元组提供,则应使用 ‘O’.
带关键字参数的函数#
这些函数与不带关键字参数的函数非常相似.唯一的区别在于函数签名是:
static PyObject*
keyword_cfunc (PyObject *dummy, PyObject *args, PyObject *kwds)
{
...
}
kwds 参数持有 Python 字典,其键是关键字参数的名称,其值是相应的关键字参数值.可以随意处理此字典.然而,处理它的最简单方法是用调用 PyArg_ParseTuple (args, kwds, format_string, char kwlist[], addresses…) 替换 PyArg_ParseTupleAndKeywords (args, format_string, addresses…) 函数.此函数的 kwlist 参数是以 NULL 结尾的字符串数组,提供预期的关键字参数.format_string 中的每个条目都应该有一个字符串.如果传入无效的关键字参数,使用此函数会引发 TypeError.
有关此函数的更多帮助,请参阅 Python 文档中扩展和嵌入教程的第 1.8 节(扩展函数的关键字参数).
引用计数#
编写扩展模块时,最大的困难是引用计数.这是 f2py,weave,Cython,ctypes 等流行的重要原因…. 如果你错误地处理引用计数,你可能会遇到从内存泄漏到段错误的问题.我所知道的正确处理引用计数的唯一策略是血汗和泪水.首先,你强迫自己认为每个 Python 变量都有一个引用计数.然后,你确切地理解每个函数如何影响对象的引用计数,以便在需要时正确地使用 DECREF 和 INCREF.引用计数可以真正检验你对编程工艺的耐心和勤奋程度.尽管描述很严峻,但大多数引用计数的情况都非常简单,最常见的困难是由于某些错误而在尽早退出例程之前没有对对象使用 DECREF.其次,常见的错误是没有取得传递给将要窃取引用的函数或宏的对象的所有权(例如 PyTuple_SET_ITEM ,以及大多数采用 PyArray_Descr 对象的函数).
通常,当创建一个新变量或者它是某个函数的返回值时,你会获得对该变量的新引用(但是,也有一些明显的例外 — 例如从元组或字典中获取一个项目).当你拥有该引用时,你有责任确保在不再需要该变量时调用 Py_DECREF (var)(并且没有其他函数"窃取"它的引用).此外,如果你要将一个 Python 对象传递给一个将"窃取"该引用的函数,那么你需要确保你拥有它(或使用 Py_INCREF 来获取你自己的引用).你还会遇到借用引用的概念.借用引用的函数不会改变对象的引用计数,也不希望"持有"该引用.它只是暂时使用该对象.当你使用 PyArg_ParseTuple 或 PyArg_UnpackTuple 时,你将收到对元组中对象的借用引用,并且不应在函数内部更改其引用计数.通过实践,你可以学会正确地进行引用计数,但一开始可能会令人沮丧.
引用计数错误的一个常见来源是 Py_BuildValue 函数.请仔细注意 ‘N’ 格式字符和 ‘O’ 格式字符之间的区别.如果在你的子程序中创建一个新对象(例如输出数组),并且你正在元组的返回值中将其传递回去,那么你最有可能应该在 Py_BuildValue 中使用 ‘N’ 格式字符.’O’ 字符会将引用计数增加 1.这将使调用者拥有一个全新数组的两个引用计数.当变量被删除并且引用计数递减 1 时,仍然会存在额外的引用计数,并且永远不会释放该数组.你将遇到引用计数引起的内存泄漏.使用 ‘N’ 字符可以避免这种情况,因为它会将具有单个引用计数(在元组内部)的对象返回给调用者.
处理数组对象#
NumPy 的大多数扩展模块都需要访问 ndarray 对象(或其子类之一)的内存.最简单的方法不需要您了解 NumPy 的内部结构.这个方法是
确保您正在处理一个行为良好的数组(已对齐,以机器字节顺序排列并且是单段的),具有正确的类型和维数.
通过使用
PyArray_FromAny或在其基础上构建的宏,从某些 Python 对象转换它.通过使用
PyArray_NewFromDescr或基于它的更简单的宏或函数,构造一个具有所需形状和类型的新 ndarray.
获取数组的形状以及指向其实际数据的指针.
将数据和形状信息传递给子例程或其他实际执行计算的代码段.
如果您正在编写算法,那么我建议您使用数组中包含的步幅信息来访问数组的元素(
PyArray_GetPtr宏可以轻松实现这一点).然后,您可以放宽您的要求,以便不强制使用单段数组以及可能导致的数据复制.
以下小节涵盖了这些子主题.
转换任意序列对象#
从可以转换为数组的任何 Python 对象中获取数组的主要例程是 PyArray_FromAny .此函数非常灵活,具有许多输入参数.一些宏使使用基本函数更容易. PyArray_FROM_OTF 可以说是这些宏中最有用的,适用于最常见的用途.它允许您将任意 Python 对象转换为特定内置数据类型(例如 float)的数组,同时指定一组特定的要求(例如,contiguous,aligned 和 writeable). 语法是
PyArray_FROM_OTF从任何可以转换为数组的 Python 对象 obj 返回一个 ndarray. 返回的数组的维数由对象确定. 返回的数组的所需数据类型在 typenum 中提供,它应该是枚举类型之一. 返回数组的要求可以是标准数组标志的任意组合. 下面将更详细地解释每个参数. 成功后,您将收到对数组的新引用. 失败时,返回
NULL并设置异常.- obj
该对象可以是任何可转换为 ndarray 的 Python 对象. 如果该对象已经是满足要求的 ndarray (的子类),则返回一个新引用. 否则,将构造一个新数组.除非使用数组接口,否则 obj 的内容将被复制到新数组,因此不必复制数据. 可以转换为数组的对象包括:1)任何嵌套的序列对象,2)任何暴露数组接口的对象,3)任何具有
__array__方法的对象(应该返回一个 ndarray),以及 4)任何标量对象(变成零维数组). 否则符合要求的 ndarray 的子类将被传递. 如果要确保基类 ndarray,请在 requirements 标志中使用NPY_ARRAY_ENSUREARRAY. 只有在必要时才进行复制. 如果要保证复制,请将NPY_ARRAY_ENSURECOPY传递给 requirements 标志.- typenum
枚举类型之一,或者如果应该从对象本身确定数据类型, 则为
NPY_NOTYPE. 可以使用基于 C 的名称:NPY_BOOL,NPY_BYTE,NPY_UBYTE,NPY_SHORT,NPY_USHORT,NPY_INT,NPY_UINT,NPY_LONG,NPY_ULONG,NPY_LONGLONG,NPY_ULONGLONG,NPY_DOUBLE,NPY_LONGDOUBLE,NPY_CFLOAT,NPY_CDOUBLE,NPY_CLONGDOUBLE,NPY_OBJECT.或者,可以使用平台上支持的位宽名称. 例如:
NPY_INT8,NPY_INT16,NPY_INT32,NPY_INT64,NPY_UINT8,NPY_UINT16,NPY_UINT32,NPY_UINT64,NPY_FLOAT32,NPY_FLOAT64,NPY_COMPLEX64,NPY_COMPLEX128.只有在不丢失精度的情况下,对象才会被转换为所需的类型.否则,将返回
NULL并引发错误.使用需求标志中的NPY_ARRAY_FORCECAST来覆盖此行为.- requirements
ndarray 的内存模型允许在每个维度上使用任意步幅来前进到数组的下一个元素.然而,通常你需要与期望 C 连续或 Fortran 连续内存布局的代码进行交互.此外,ndarray 可能会未对齐(元素的地址不是元素大小的整数倍),如果您尝试取消引用指向数组数据的指针,这可能会导致您的程序崩溃(或至少工作得更慢).这两个问题都可以通过将 Python 对象转换为对于您的特定用途来说更"易于操作"的数组来解决.
requirements 标志允许指定可接受的数组类型.如果传入的对象不满足这些要求,则会创建一个副本,以便返回的对象将满足这些要求.这些 ndarray 可以使用非常通用的内存指针.此标志允许指定返回的数组对象的所需属性.所有标志都在详细的 API 章节中进行了解释.最常用的标志是
NPY_ARRAY_IN_ARRAY,NPY_ARRAY_OUT_ARRAY和NPY_ARRAY_INOUT_ARRAY:NPY_ARRAY_IN_ARRAY此标志对于必须以 C 连续顺序排列且对齐的数组非常有用.这些类型的数组通常是某些算法的输入数组.
NPY_ARRAY_OUT_ARRAY此标志对于指定以 C 连续顺序排列,已对齐且可以写入的数组很有用.这样的数组通常作为输出返回(尽管通常这样的输出数组是从头开始创建的).
NPY_ARRAY_INOUT_ARRAY此标志对于指定将用于输入和输出的数组很有用.必须在接口例程结束时在
PyArray_ResolveWritebackIfCopy之前调用Py_DECREF,以将临时数据写回传入的原始数组. 使用NPY_ARRAY_WRITEBACKIFCOPY标志要求输入对象已经是数组(因为其他对象无法以这种方式自动更新).如果发生错误,请对设置了这些标志的数组使用PyArray_DiscardWritebackIfCopy(obj).这将使底层基本数组可写,而不会导致内容被复制回原始数组.
可以作为附加要求进行 OR 运算的其他有用标志是:
NPY_ARRAY_FORCECAST强制转换为所需的类型,即使这样做会丢失信息.
NPY_ARRAY_ENSURECOPY确保结果数组是原始数组的副本.
NPY_ARRAY_ENSUREARRAY确保结果对象是实际的 ndarray,而不是子类 .
备注
数组是否进行字节交换由数组的数据类型决定.原生字节序数组总是通过 PyArray_FROM_OTF 请求的,因此 requirements 参数中不需要 NPY_ARRAY_NOTSWAPPED 标志.也没有办法从此例程获取字节交换数组.
创建一个全新的 ndarray#
通常,必须从扩展模块代码中创建新数组.可能需要一个输出数组,并且您不希望调用者必须提供它.可能只需要一个临时数组来保存中间计算.无论需要什么,都有简单的方法来获取所需数据类型的 ndarray 对象.执行此操作的最通用函数是 PyArray_NewFromDescr .所有数组创建函数都通过此大量重复使用的代码.由于其灵活性,使用起来可能会有些混乱.因此,存在更简单的形式,这些形式更易于使用.这些形式是 PyArray_SimpleNew 系列函数的一部分,这些函数通过为常见用例提供默认值来简化接口.
获取 ndarray 内存并访问 ndarray 的元素#
如果 obj 是一个 ndarray ( PyArrayObject* ),那么 ndarray 的数据区域由 void 指针 PyArray_DATA (obj) 或 char 指针 PyArray_BYTES (obj) 指向.请记住,(通常)这个数据区域可能没有按照数据类型对齐,它可能表示字节交换的数据,和/或它可能不可写.如果数据区域已对齐且为本机字节序,则如何访问数组的特定元素仅由 npy_intp 变量数组 PyArray_STRIDES (obj) 决定.特别是,这个 c 整数数组显示了必须将多少字节添加到当前元素指针才能获得每个维度中的下一个元素.对于小于 4 维的数组,有 PyArray_GETPTR{k} (obj, …) 宏,其中 {k} 是整数 1,2,3 或 4,这使得使用数组 strides 更容易.参数 …. 表示数组的 {k} 个非负整数索引.例如,假设 E 是一个 3 维 ndarray.元素 E[i,j,k] 的 (void) 指针可以这样获得 PyArray_GETPTR3 (E, i, j, k).
如前所述,C 风格的连续数组和 Fortran 风格的连续数组具有特定的步长模式.两个数组标志 ( NPY_ARRAY_C_CONTIGUOUS 和 NPY_ARRAY_F_CONTIGUOUS ) 指示特定数组的步长模式是否与 C 风格的连续或 Fortran 风格的连续匹配,或者不匹配.可以使用 PyArray_IS_C_CONTIGUOUS (obj) 和 PyArray_ISFORTRAN (obj) 分别测试步长模式是否与标准 C 或 Fortran 匹配.大多数第三方库都希望使用连续数组.但是,通常支持通用步长并不难.我鼓励您尽可能在自己的代码中使用步长信息,并为包装第三方代码保留单段要求.使用 ndarray 提供的步长信息而不是要求连续步长可以减少必须进行的复制.
示例#
下面的例子展示了如何编写一个包装器,它接受两个输入参数(将被转换为数组)和一个输出参数(必须是一个数组).该函数返回 None 并更新输出数组.请注意 NumPy v1.14 及以上版本中 WRITEBACKIFCOPY 语义的更新用法.
static PyObject *
example_wrapper(PyObject *dummy, PyObject *args)
{
PyObject *arg1=NULL, *arg2=NULL, *out=NULL;
PyObject *arr1=NULL, *arr2=NULL, *oarr=NULL;
if (!PyArg_ParseTuple(args, "OOO!", &arg1, &arg2,
&PyArray_Type, &out)) return NULL;
arr1 = PyArray_FROM_OTF(arg1, NPY_DOUBLE, NPY_ARRAY_IN_ARRAY);
if (arr1 == NULL) return NULL;
arr2 = PyArray_FROM_OTF(arg2, NPY_DOUBLE, NPY_ARRAY_IN_ARRAY);
if (arr2 == NULL) goto fail;
#if NPY_API_VERSION >= 0x0000000c
oarr = PyArray_FROM_OTF(out, NPY_DOUBLE, NPY_ARRAY_INOUT_ARRAY2);
#else
oarr = PyArray_FROM_OTF(out, NPY_DOUBLE, NPY_ARRAY_INOUT_ARRAY);
#endif
if (oarr == NULL) goto fail;
/* code that makes use of arguments */
/* You will probably need at least
nd = PyArray_NDIM(<..>) -- number of dimensions
dims = PyArray_DIMS(<..>) -- npy_intp array of length nd
showing length in each dim.
dptr = (double *)PyArray_DATA(<..>) -- pointer to data.
If an error occurs goto fail.
*/
Py_DECREF(arr1);
Py_DECREF(arr2);
#if NPY_API_VERSION >= 0x0000000c
PyArray_ResolveWritebackIfCopy(oarr);
#endif
Py_DECREF(oarr);
Py_INCREF(Py_None);
return Py_None;
fail:
Py_XDECREF(arr1);
Py_XDECREF(arr2);
#if NPY_API_VERSION >= 0x0000000c
PyArray_DiscardWritebackIfCopy(oarr);
#endif
Py_XDECREF(oarr);
return NULL;
}