类型化 memoryview

简介: 类型化 memoryview


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,我们下一篇文章再聊。


相关文章
|
存储
基础数据类型和引用数据类型区别?
基础数据类型和引用数据类型区别?
|
7月前
|
存储 Java Python
引用数据类型和基础数据类型的区别
引用数据类型和基础数据类型的区别
|
7月前
|
存储 Java
基础数据类型和引用数据类型的区别
基础数据类型和引用数据类型的区别
|
7月前
|
存储 JavaScript 前端开发
对象字面量和对象的封装(结合柯里化)
对象字面量和对象的封装(结合柯里化)
56 0
|
7月前
|
存储 C++
c++复合类型(一)
c++复合类型(一)
67 0
|
7月前
|
存储 C++
c++复合类型(二)
c++复合类型(二)
44 0
|
程序员 编译器 C++
|
C# 索引
C#编程-96:索引器的使用
C#编程-96:索引器的使用
108 0
C#编程-96:索引器的使用
7-4python函数-嵌套使用
一个函数里面又调用了 另外一个函数,这就是函数嵌套调用。
|
C++
【C++ 语言】引用数据类型 ( 引用数据类型定义 | 引用数据类型使用 | 引用类型参数 )
【C++ 语言】引用数据类型 ( 引用数据类型定义 | 引用数据类型使用 | 引用类型参数 )
316 0
【C++ 语言】引用数据类型 ( 引用数据类型定义 | 引用数据类型使用 | 引用类型参数 )