memoryview
Python 有一个内置类型叫 memoryview(内存视图),它存在的唯一目的就是在 Python 级别表示 C 级的缓冲区(避免数据的复制)。我们可以向 memoryview 传递一个实现了缓冲区协议的对象(比如:bytes对象),来创建一个 memoryview 对象。
b = b"komeiji satori" m = memoryview(b) # 此时 m 和 b 之间是共享内存的 print(m) # <memory at 0x000001C3A54EFA00> # 通过索引访问,得到的是一个整数 print(m[0], m[-1]) # 107 105 print(f"{m[0]:c}", f"{m[-1]:c}") # k i # 还可以通过切片访问 # 得到的仍是一个 memoryview 对象 print(m[0: 2]) # <memory at 0x00000229D637F880> print(m[0: 2][1], f"{m[0: 2][1]:c}") # 111 o
可以通过索引来访问,也可以通过切片来对 memoryview 进行任意截取,使用这种方式,灵活性就变得非常高。
问题来了,memoryview 对象能不能修改呢?答案是不能,因为 bytes 对象不可以修改,所以 memoryview 对象也不可以修改,也就是只读的。
b = b"komeiji satori" m = memoryview(b) print(m.readonly) # True try: m[0] = "K" except Exception as e: print(e) """ cannot modify read-only memory """
bytes 对应的缓冲区是不可以修改的,如果想修改,我们应该使用 bytearray。
b = bytearray(b"my name is satori") m = memoryview(b) # 此时 m 和 b 共享内存 print(b) # bytearray(b'my name is satori') m[0] = ord("M") print(b) # bytearray(b'My name is satori') b[1] = ord("Y") print(chr(m[1])) # Y
当然我们还可以传一个 Numpy 的 ndarray,只要是实现了缓冲区协议的对象,都可以传递到 memoryview 中。
import numpy as np array = np.ones((10, 20, 30)) mv = memoryview(array) # 查看维度 print(mv.ndim) # 3 # 查看 shape print(mv.shape) # (10, 20, 30) # strides 属性表示某个维度中 # 一个元素和下一个元素之间差了多少个字节 print(mv.strides) # (4800, 240, 8) # 查看缓冲区每个元素的大小 print(mv.itemsize) # 8 # 查看缓冲区占用的字节数 # 等于 itemsize * 元素的个数 print(mv.nbytes) # 48000 # 查看缓冲区的元素类型 print(mv.format) # d # 缓冲区是否只读 print(mv.readonly) # False # 实现了缓冲区协议的对象,显然这里就是 array 本身 print(mv.obj is array) # True # 基于当前 memoryview 创建一个新的 memoryview # 但缓冲区是只读的 print(mv.toreadonly().readonly) # True # 将缓冲区转成列表 mv = memoryview(b"abc") print(mv.tolist()) # [97, 98, 99] # 将缓冲区的内容转成 bytes 对象 mv = memoryview(np.array([[1, 2], [3, 4]], dtype="int8")) # 虽然这里的 array 是二维的,但缓冲区永远是一个一维数组 print(mv.tobytes()) # b'\x01\x02\x03\x04' # 还能以16进制格式打印 print(mv.hex()) # 01020304
结构化数据也是支持的,首先我们来创建一个 Numpy 的 dtype,其中 name 和 age 的类型分别是 unicode 和 int8。
import numpy as np dt = np.dtype([("name", "U"), ("age", "int8")]) print(dt) """ [('name', '<U'), ('age', 'i1')] """ print(np.empty(5, dtype=dt)) """ [('', 0) ('', 0) ('', 0) ('', 0) ('', 0)] """ structured_mv = memoryview(np.empty(5, dtype=dt)) print(structured_mv.format) """ T{=0w:name:b:age:} """ # 这里的 format(格式化字符串) 来自标准库模块 struct 的规范 # 对于结构化类型来说是相当神秘的,读起来也让人头疼 # 所以我们将 memoryview 的格式化字符串的细节留给官方文档吧 # 我们不需要与它们直接打交道
以上就是 memoryview 的基本用法,那么问题来了,memoryview 对象和缓冲区如何转换到 Cython 中呢?考虑到 Cython 是专门用来连接 Python 和 C 的,所以它一定非常适合在 C 级别使用 memoryview 对象和缓冲区协议。
类型化 memoryview
Cython 有一个 C 级类型:类型化 memoryview,它在概念上和 Python 的 memoryview 重叠、并且在其基础上展开,用于查看(共享)来自缓冲区对象的数据。
并且类型化 memoryview 是在 C 级别操作,所以它有着最小的 Python 开销,因此非常高效,而且比直接使用 C 级缓冲区更方便。此外类型化 memoryview 是为了和缓冲区一起工作而被设计的,因此它可以有效支持任何来自缓冲区的对象,从而允许在不复制的情况下共享缓冲区数据。
假设我们想在 Cython 中有效地处理一维数据的缓冲区,而不关心如何在 Python 级别创建数据,我们只是想以一种有效的方式访问它。
# 文件名:cython_test.pyx def summer(double[:] numbers): cdef double res = 0 cdef double number # memoryview 对象可以像迭代器一样进行遍历 for number in numbers: res += number return res
double[:] numbers 声明了 numbers 是一个类型化 memoryview 对象,而 double 指定了该 memoryview 对象的基本类型,[:] 则表明这是一个一维的 memoryview 对象。
当我们调用 summer 函数时,会传入一个 Python 对象,并将该对象隐式地分配给参数 numbers。我们可以提供一个 memoryview 对象,但如果提供的不是,那么看该对象是否支持缓冲区协议,如果支持缓冲区协议,那么根据内部的 C 级缓冲区构建 memoryview 对象;如果不支持缓冲区协议(没有提供相应的缓冲区),那么引发 ValueError。
import pyximport pyximport.install(language_level=3) import numpy as np from cython_test import summer # 必须传递支持缓冲区协议的对象 print( summer(np.array([1.2, 2.3, 3.4, 4.5])) ) # 11.4 # 可以直接传入数组,也可以传入 memoryview 对象 print( summer(memoryview(np.array([1.2, 2.3, 3.4, 4.5]))) ) # 11.4 # 但传递列表是不行的,因为它不支持缓冲区协议 print(summer([1.2, 2.3, 3.4, 4.5])) """ def summer(double[:] numbers): File "stringsource", line 658, in View.MemoryView.memoryview_cwrapper File "stringsource", line 349, in View.MemoryView.memoryview.__cinit__ TypeError: a bytes-like object is required, not 'list' """
不过当我们在编译类型化 memoryview 对象时,Cython 本质上还是将它当成通用的迭代器来看待的,因为上面对 numbers 进行了遍历操作。所以还有优化空间,我们可以做得更好。
C 级访问类型化 memoryview 数据
类型化 memoryview 对象是为 C 风格的访问而设计的,没有开销,因此也可以用另一种方式去遍历 numbers。
def summer(double[:] numbers): cdef double res = 0 cdef Py_ssize_t i, N # 调用 shape 拿到其长度 N = numbers.shape[0] for i in range(N): res += numbers[i] return res
这个版本会有更好的性能:对于百万元素的数组来说大约是 1 毫秒,因为我们用了一个有类型的整数去作为索引。而基于索引访问类型化 memoryview 时,Cython 会生成绕过 Python/C API 调用的代码,直接操作底层缓冲区,所以速度进一步提升。
但是还没有结束,我们还能继续优化。
用安全换取性能
每次访问 memoryview 对象时,Cython 都会检测索引是否越界。如果越界,那么 Cython 将引发一个 IndexError,而且 Cython 也允许我们像 Python 一样通过负数索引对 memoryview 对象进行访问。
对于上面的 summer 函数,我们在访问内部 memoryview 对象之前就已经获取了它的元素个数,所以在遍历的时候永远不会越界。因此我们可以指示 Cython 关闭这些检查以获取更高的性能,而关闭检查可以使用上下文的方式:
from cython cimport boundscheck, wraparound def summer(double[:] numbers): cdef double res = 0 cdef int i, N N = numbers.shape[0] # 关闭检查 with boundscheck(False), wraparound(False): for i in range(N): res += numbers[i] return res
基于索引访问时,解释器会判断索引是否越界,可以通过 boundscheck(False) 关闭检查;如果索引是负数,解释器会自动转成对应的正数索引,而 wraparound(False) 则表示禁用这一逻辑。
所以这两者组合起来就相当于告诉解释器:索引是合法的,不会越界、并且也不是负数,你不要再花时间去检查了,赶紧执行吧。
关闭检测之后,性能会有小幅度的提高(当然我们这里数据很少,看不出来)。但性能提升的后果就是我们必须确保索引不会越界、并且不可以使用负数索引,否则的话可能会导致段错误(非常危险,不仅程序崩溃,解释器也直接退出)。因此如果没有百分之百的把握,不要关闭检查。
当然我们上面是通过上下文管理的方式,关闭检查这一功能仅局限在 with 语句内部。我们还可以给函数打上装饰器,让整个函数内部关闭检查。
from cython cimport boundscheck, wraparound @boundscheck(False) @wraparound(False) def summer(double[:] numbers): cdef double res = 0 cdef int i, N N = numbers.shape[0] for i in range(N): res += numbers[i] return res
如果想关闭全局的边界检测,那么可以在文件开头使用注释的形式。
# cython: boundscheck=False # cython: wraparound=False
所以关闭边界检测有多种方式,不同的方式对应不同的作用域。但是有了以上这些方法,我们的 summer 函数的性能,和 Numpy 中 sum 函数的性能便在一个数量级了。我们编译成扩展模块测试一下吧:
一个求和操作,Numpy 用时 203 微秒,Cython 用时 980 微秒,内置函数 sum 用时 82 毫秒。可以看到,我们自己实现的 summer 函数虽然没有 Numpy 的 sum 函数那么厉害,但至少在同一水平线上,反正都甩开内置函数 sum 一条街。
那么到目前为止,我们都了解到了什么呢?首先我们知道如何在 Cython 中声明一个简单的类型化 memoryview,以及对它进行索引、访问内部缓冲区的数据。并且还通过使用 boundscheck 和 wraparound 关闭边界检查,来生成更加高效的代码,但前提是我们能确保不会出现索引越界,否则还是不要关闭检查。因为为了安全,这些都是值得的。
但是还没完,我们还有更多的细节要讲。
类型化 memoryview 的声明
当我们声明一个类型化 memoryview 时,可以控制很多的属性。
1)元素类型
类型化 memoryview 的元素类型可以任意,在 Cython 中凡是能拿来做变量类型声明的都可以,因此也可以是 ctypedef 起的别名。
2)维度
类型化 memoryview 最多可以有7个维度,我们之前声明了一个一维的,使用的是 double[:] 这种形式,如果是 3 维,那么写成 double[:, :, :] 即可。当然类型不一定是 double,也可以是其它的。
3)C 和 Fortran 的连续性
通过指定数据打包约束的 C、Fortran 类型化内存视图是一个非常重要的特例,C 连续和 Fortran 连续都意味着缓冲区在内存中是连续的。但如果是多维度,C 连续的 memoryview 的最后一个维度是连续的,而 Fortran 连续的 memoryview 的第一个维度是连续的。
如果可能的话,从性能的角度上来说,将数组声明为 C 或者 Fortran 连续是有利的,因为这使得 Cython 可以生成更快的代码。如果不是 C 连续,也不是 Fortran 连续,那么我们称之为 full strided。还记得这个 full strided 吗?我们在介绍缓冲区协议的时候说过的。
下面通过 Numpy 来对比一下 C 连续和 Fortran 连续的区别。
import numpy as np arr = np.arange(16) print(arr.reshape((4 , 4), order="C")) # 默认是 C 连续, 即 order="C" # 最后一个维度是连续的 """ [[ 0 1 2 3] [ 4 5 6 7] [ 8 9 10 11] [12 13 14 15]] """ print(arr.reshape((4, 4), order="F")) # 如果 Fortran, 即 order="F" # 那么第一个维度是连续的 """ [[ 0 4 8 12] [ 1 5 9 13] [ 2 6 10 14] [ 3 7 11 15]] """ # 所以 C 连续的数组在转置之后就会变成 Fortran 连续
4)直接或间接访问
直接访问是默认的,涵盖了几乎所有的情况,它指定对应的维度可以通过索引的方式直接访问底层数据。如果将某个维度指定为间接访问,那么底层缓冲区将存储一个指向数组剩余部分的指针,该指针必须在访问时解引用(因此是间接访问)。
而 Numpy 不支持间接访问,所以我们不使用这种访问规范,因此直接访问是默认的。事实上,官方一般设置为默认的都是最好的。
下面我们来举例说明:
import numpy as np # 这是最灵活的声明方式 # 可以从任何一个元素类型为 int 的二维类型化 memoryview 对象中获取缓冲区 def func(int[:, :] ages): # 直接打印是一个 memoryview 对象,这里转成 ndarray # 这里说一句: Numpy 中数组的类型是 <class 'ndarray'> # 但为了方便,有时会叫它 array print(np.array(ages))
然后我们测试一下:
import pyximport pyximport.install(language_level=3) import numpy as np import cython_test # 这里类型要匹配,C 的 int 对应 numpy 的 int32 # 而 numpy 的整型默认是 int64,所以不指定 dtype 会类型不匹配 # 因为 memoryview 对类型的要求很严格 arr = np.random.randint(1, 10, (3, 3), dtype="int32") cython_test.func(arr) """ [[7 9 2] [9 6 9] [9 9 1]] """ # 也可以对 arr 进行切片 cython_test.func(arr[1: 3, 0: 2]) """ [[9 6] [9 9]] """
所以当对数据进行索引的时候,Cython 生成的索引代码默认会兼容数据的跨步访问。什么意思呢?我们举个例子:
import numpy as np import cython_test arr = np.array([1, 2, 3, 4, 5, 6], dtype="int32") arr2 = arr[:: 2] print(arr) # [1 2 3 4 5 6] print(arr2) # [1 3 5] # arr 和 arr2 的元素类型都是 int32,占 4 字节 # 所以 arr[0] 和 arr[1] 之间差了 4 字节 # 但是 arr2[0] 和 arr2[1] 之间差了 8 字节 # 因为 arr2 是基于 arr[:: 2] 得到的,它们指向的是同一个缓冲区 # 数据存储在缓冲区中,只有一份 # 所以对于 arr2 而言,从当前元素到下一个元素要跨 8 字节 # 否则的话,arr2[1] 不可能访问到缓冲区里的第三个元素 # 而缓冲区每个元素占 4 字节,但 arr2 每次却要跨 8 字节 # 当两者不相等的时候,我们就说出现了跨步访问 # 而 arr2 也被称为 full strided 数组 # 至于 arr,它从当前元素到下一个元素需要跨 4 字节 # 和缓冲区的每个元素大小相等,所以 arr 被称为连续数组 # 注:为了解释方便,这里以一维数组为例,多维数组也是同理
不管是连续数组,还是 full strided 数组,Cython 的 memoryview 都是支持的(具备一定的灵活性)。但如果我们愿意用一些灵活性来换取速度的话,也就是强制数组必须是连续的,那么在交给类型化 memoryview 之后可以建立更有效的索引。
import numpy as np def func(int[:, :: 1] ages): print(np.array(ages))
声明一个 C 连续的类型化内存视图,需要对最后一个维度进行修改。前 n - 1 个维度不变,还是一个冒号,最后一个维度换成两个冒号并跟一个数字 1。
举个栗子:之前的声明是 double [:, :, :],如果想要 C 连续,那么应该改成 double [:, :, :: 1] ,表示最后一个维度具有统一的步长。而 Numpy 的数组默认是 C 连续的。
import pyximport pyximport.install(language_level=3) import numpy as np import cython_test arr = np.random.randint(1, 10, (3, 3), dtype="int32") cython_test.func(arr) """ [[5 2 2] [8 8 4] [2 3 2]] """ try: cython_test.func(arr[1: 3, 0: 2]) except ValueError as e: print(e) """ ndarray is not C-contiguous """
我们看到将 arr 传进去一切正常,但是将 arr 进行切片之后就不行了,因为切片之后得到数组不再连续,而是 full strided。
除了 C 连续之外,还有 Fortran 连续,如果声明 Fortran 连续的数组,那么第一个维度需要指定为 :: 1。
import numpy as np def func(int[:: 1, :] ages): print(np.array(ages))
测试一下:
import pyximport pyximport.install(language_level=3) import numpy as np import cython_test arr = np.random.randint(1, 10, (3, 3), dtype="int32") try: # 默认是 C 连续的 cython_test.func(arr) except ValueError as e: print(e) """ ndarray is not C-contiguous """ # 对 C 连续的数组进行转置,即可 Fortran 连续 cython_test.func(arr.T) """ [[2 5 7] [2 3 2] [3 3 5]] """
一个多维数组要么 C 连续,要么 Fortran 连续,但不可能同时既 C 连续又 Fortran 连续。不过一维数组特殊,一维数组可以同时保证 C 连续和 Fortran 连续。
import numpy as np def func(int[::1] ages): print(np.array(ages))
到目前为止,我们已经介绍了三种类型化内存视图,分别是:C 连续、Fortran 连续、fully strided。常见的情况下,所有数组都是 C 连续的,这是最常见的内存布局。特别是在需要和外部的 C、C++ 库进行交互的时候,这种布局就显得尤为重要,可以提升速度。并且在传递了非 C 连续的数组时,比如:full strided 或者 Fortran 连续,将会引发一个 ValueError。
但如果你的程序是以 Fortran 为中心的,那么应该将数组声明为 Fortran 连续,这样会更好一些。
而 Numpy 也提供了两个转换函数,分别是 ascontiguousarray 和 asfortranarray,可以接收一个数组并返回一个 C 连续或者 Fortran 连续的数组。
import numpy as np # Numpy 数组默认是 C 连续 arr = np.arange(16).reshape((4, 4)) print(arr.flags["C_CONTIGUOUS"]) # True print(arr.flags["F_CONTIGUOUS"]) # False # 转成 Fortran 连续 arr = np.asfortranarray(arr) print(arr.flags["C_CONTIGUOUS"]) # False print(arr.flags["F_CONTIGUOUS"]) # True # 这两个函数底层都调用了 np.array # 所以下面这种做法也是可以的 arr2 = np.arange(16).reshape((4, 4)) print(arr2.flags["F_CONTIGUOUS"]) # False # 将 order 指定为 "F",表示 Fortran 连续 # 并且里面还有一个 copy 参数, 默认为 True # 表示拷贝数组的同时,还会拷贝底层的缓冲区 # 如果为 False,则表示不拷贝缓冲区 # 所以当指定 copy=True,新数组和老数组之间没有关系 # 指定 copy=False,由于两个数组共用一个缓冲区 # 那么任何一个进行了修改,都会影响另一个 arr2 = np.array(arr2, order="F", copy=False) print(arr2.flags["F_CONTIGUOUS"]) # True # 如果要将一个数组改成 C 连续或者 Fortran 连续, 推荐上面两种做法 # 另外, 我们说一维数组既是 C 连续又是 Fortran 连续 arr3 = np.arange(16) print(arr3.flags["C_CONTIGUOUS"]) # True print(arr3.flags["F_CONTIGUOUS"]) # True
注意:在 Cython 中声明 memoryview 为 C 连续或者 Fortran 连续时,虽然可以生成更快速的索引访问代码,但并不代表我们就一定要将其声明为 C 连续或者 Fortran 连续。因为这取决于接收的数组,如果接收的数组的连续性不确定时,应该采用 full strided 类型,也就是声明的时候不指定 :: 1。
因为一旦指定连续,不管是 C 连续、还是 Fortran 连续,那么你的数组必须要满足,否则就会报出我们之前出现的错误:ndarray is not C-contiguous 或者 ndarray is not Fortran contiguous。
这个时候就需要新创建一个 C 连续或者 Fortran 连续的数组,说白了就是将那些指定步长访问的数组对应的元素拷贝一份,建立一个新的连续数组。但这会带来额外的开销,甚至超过连续访问带来的性能收益。我们举个例子:
import numpy as np arr = np.arange(16).reshape((4, 4)) arr2 = arr[:: 2] arr2[0, 0] = 111 # arr 和 arr2 共享缓冲区,修改 arr2 会改变 arr print(arr[0, 0]) # 111 # 但 arr2 不是 C 连续 # 因为它实现了跨步访问,所以不再具备连续性 print(arr2.flags["C_CONTIGUOUS"]) # False # 还是以相同的方式创建,但是强行让 arr3 连续 # arr3 = np.ascontiguousarray(arr[:: 2]) # 或者使用 np.array 也行,两者等价 arr3 = np.array(arr[:: 2], order="C", copy=False) print(arr3.flags["C_CONTIGUOUS"]) # True arr3[0, 0] = 222 # 但是我们看到 arr 并没有被改变,还是之前的 111 # 原因就在于将一个不是连续的数组变成连续的数组 # 会将不是连续的数组中对应的元素拷贝一份,以此来构建一个连续的数组 print(arr[0, 0]) # 111
np.array 里面有个 copy 参数默认为 True,表示拷贝数组时会将缓冲区也拷贝一份;指定为 False,那么只拷贝数组结构本身,存储数据的缓冲区则不拷贝。
对数组进行切片操作,就不会拷贝缓冲区,所以上面的 arr2 修改之后会影响 arr,因为两者共享同一个缓冲区。
创建 arr3 的时候,我们指定了 copy=False,同样表示不拷贝缓冲区,但修改 arr3 的时候 arr 却并没有受到影响。原因就在于里面的 order 参数,我们强行让创建的数组是 C 连续的,但很明显 arr[:: 2] 实现了跨步访问,如果 arr3 还用 arr 的缓冲区,那么它就不可能 C 连续。于是 Numpy 只能将 arr[:: 2] 对应的元素全部拷贝出来,然后创建一个新的缓冲区,所以 copy 参数会无效化。
因此当 Cython 的类型化 memoryview 不要求连续性的时候,数组之间可以共享缓冲区。而如果要求连续性,那么虽然会失去灵活性,但却能获得连续访问带来的性能收益。不过这前提是数组应该已经是连续的,如果不连续,那么你必须基于不连续数组创建一个连续数组,而这会涉及缓冲区的拷贝,产生的消耗甚至会大于连续访问带来的收益。
对于类型化 memoryview,我们传递 None 也是合法的,因此需要有一步检测,或者使用 not None 子句声明。
混合类型
还记得我们之前提到的混合类型吗?假设我希望某一个参数既可以接收 list 对象,也可以接收 dict 对象,那么可以这么做。
cdef fused list_dict: list dict cpdef func(list_dict var): return var
而类型化 memoryview 的类型也可以是混合类型,这样可以保证更强的泛化能力和灵活性。但是很明显,所谓的混合类型无非就是创建了多个版本的函数。
from cython cimport floating cpdef floating generic_summer(floating[:] m): cdef floating f, s = 0.0 for f in m: s += f return s
编译测试一下:
import pyximport pyximport.install(language_level=3) import numpy as np from cython_test import generic_summer print( generic_summer(np.array([1, 2, 3], dtype="float64")) ) # 6.0 print( generic_summer(np.array([1, 2, 3], dtype="float32")) ) # 6.0
类型化 memoryview 对元素类型的要求是很严格的,float32 和 float 64 不可混用,因为占用的内存大小不同。但是我们通过混合类型的方式可以同时接收 float32 和 float64,也就是 C 中的 float 和 double。
注:类型化 memoryview 对混合类型的支持不是特别友好,个人不太建议使用。
使用类型化 memoryview
一旦声明了类型化 memoryview,就必须给它分配一个支持缓冲区协议的对象,然后两者共享底层缓冲区。那么问题来了,类型化 memoryview 都支持哪些操作呢?
首先我们可以像 Numpy 一样,对类型化 memoryview 进行访问和修改。
import numpy as np cpdef array(int[:, :] numbers): print("----------") print(np.array(numbers)) numbers[0, 0] = 66666 print("----------") print(np.array(numbers)) print("----------") print(np.array(numbers[1: 3, : 2]))
编译测试一下:
import pyximport pyximport.install(language_level=3) import numpy as np import cython_test # 必须指定 dtype="int32" # 因为 C 的 int 等价于 Numpy int32 arr = np.random.randint(1, 10, (3, 3), dtype="int32") cython_test.array(arr) """ ---------- [[4 3 1] [5 6 3] [4 6 1]] ---------- [[66666 3 1] [ 5 6 3] [ 4 6 1]] ---------- [[5 6] [4 6]] """
正如之前说的,类型化内存视图可以建立高效的索引,特别是当我们通过 boundscheck 和 wraparound 关闭检查的时候。
from cython cimport boundscheck, wraparound cpdef summer(int[:, :] numbers): cdef int N, M, i, j cdef long s=0 # 类型化 memoryview 的 shape 是一个含有 8 个元素的元组 # 但我们这里只有两个维度, 所以截取前两位, 至于后面的元素都是 0 N, M = numbers.shape[: 2] with boundscheck(False), wraparound(False): for i in range(N): for j in range(M): s += numbers[i, j] return s
编译测试一下:
import pyximport pyximport.install(language_level=3) import numpy as np import cython_test # 必须指定 dtype="int32" # 因为 C 的 int 等价于 Numpy int32 arr = np.random.randint(1, 10, (300, 300), dtype="int32") print(np.sum(arr)) print(cython_test.summer(arr)) """ 449467 449467 """
另外类型化 memoryview 和 Numpy 中的 array 一样,也支持 ... 语法糖,表示某个维度上、或者整体的全部筛选。
import numpy as np cdef int[:, :] m = np.zeros((2, 2), dtype="int32") # 直接打印会显示一个 memoryview 对象 # 需要转成 array 再进行打印 print(np.array(m)) """ [[0 0] [0 0]] """ # 通过 ... 表示全局筛选 # 因此 m[...] 等价于 m[:] m[...] = 123 print(np.array(m)) """ [[123 123] [123 123]] """ # 在某一个维度上使用 ..., 可以实现某个维度上的全局修改 # 等价于 m[0, :] = 456 m[0, ...] = 456 print(np.array(m)) """ [[456 456] [123 123]] """
因此在用法上,类型化 memoryview 和 Numpy 的 array 是一致的,当然我们也可以指定步长等等。
但在功能上还是有些差别的,类型化memoryview没有Numpy array 那么多的通用方法,并且在赋值的时候也只能赋一个简单的标量。
import numpy as np arr = np.arange(9).reshape((3, 3)) print(arr) """ [[0 1 2] [3 4 5] [6 7 8]] """ # 会将所有元素都赋值为 123, 因为是标量赋值 # 所以类型化 memoryview 是支持该操作的 arr[:] = 123 print(arr) """ [[123 123 123] [123 123 123] [123 123 123]] """ # 这里就涉及到了广播, 因为 (3, 3) 和 (3,) 两个维度明显不一致 # 所以会将 arr 的每一行都替换成 [11 22 33] # 但这个是 Numpy 的 array 的功能, 类型化 memoryview 是不支持的 # 因为它在广播的时候右边只能跟标量 arr[:] = [11, 22, 33] print(arr) """ [[11 22 33] [11 22 33] [11 22 33]] """
所以在操作上面,类型化 memoryview 有很多都是不支持的。不过办法总比困难多,我们可以根据类型化 memoryview 拿到对应的 Numpy array,然后对这个 array 进行操作不就行了。
那么这么做的效率会不会低呢?答案是不会的,因为类型化 memoryview 和 array 之间是共享内存的,这么做不会有什么性能损失。正如 torch 里面的 tensor 一样,它和 Numpy 的 array 之间也是共享内存的。由于 Numpy 的 API 用起来非常方便,已经习惯了,加上个人也懒得使用 tensor 的一些操作,所以我都会先将 tensor 转成 array,对 array 操作之后再转回 tensor。虽然多了两次转化,但还是那句话,它们是共享内存的,所以完全没问题。
from cython cimport boundscheck, wraparound import numpy as np cdef long[:, :] m = np.arange(9).reshape((3, 3)) # 这里一定要指定 copy=False # 否则在创建数组时,还会将缓冲区拷贝一份 # 而这么做的话, 就会有性能损失了, 因为两者本来就是共享内存的 # 直接操作就可以了, 为什么要再创建一个缓冲区呢 np.array(m, copy=False)[:] = [1, 2, 3] # 以上我们就实现了修改, 这里再来打印一下看看 print(np.array(m)) """ [[1 2 3] [1 2 3] [1 2 3]] """
因此我们可以把类型化 memoryview 看成是非常灵活的 Cython 空间,可以有效地共享、索引定位、以及修改同质数据。它具有很多 Numpy array 的特性,特别是通过索引定位数据。至于那些没有的特性,也很容易被两者之间转换的高效性所掩盖。
所以类型化 memoryview 构建在 memoryview 之上,并提供了很多新的功能。但实际上类型化 memoryview 也超越了缓冲区协议,因此它还有额外的特性,那就是对 C 一级的数组进行 view,我们下一篇文章再聊。