2.1.7 内存结构

下面让我们看看数组对象是如何在内存中存储的。如图2-3所示,数组的描述信息保存在一个数据结构中,这个结构引用两个对象:用于保存数据的存储区域和用于描述元素类型的dtype对象。

图2-3 ndarray数组对象在内存中的存储方式

数据存储区域保存着数组中所有元素的二进制数据,dtype对象则知道如何将元素的二进制数据转换为可用的值。数组的维数和形状等信息都保存在ndarray数组对象的数据结构中。图2-3中显示的是下面的数组a的内存结构:

    a = np.array([[0,1,2],[3,4,5],[6,7,8]], dtype=np.float32)

数组对象使用strides属性保存每个轴上相邻两个元素的地址差,即当某个轴的下标增加1时,数据存储区中的指针所增加的字节数。例如图2-3中的strides为(12,4),即第0轴的下标增加1时,数据的地址增加12个字节。也就是a[1,0]的地址比a[0,0]的地址大12,正好是3个单精度浮点数的总字节数。第1轴的下标增加1时,数据的地址增加4个字节,正好是一个单精度浮点数的字节数。

如果strides属性中的数值正好和对应轴所占据的字节数相同,那么数据在内存中是连续存储的。通过切片下标得到的新数组是原始数组的视图,即它和原始数组共享数据存储区域,但是新数组的strides属性会发生变化:

    b = a[::2, ::2]
         b      b.strides
    ------------  ---------
    [[ 0.,  2.],  (24, 8)  
     [ 6.,  8.]]           

由于数组b和数组a共享数据存储区,而数组b中的第0轴和第1轴都是从a中隔一个元素取一个,因此数组b的strides变成了(24, 8),正好都是数组a的两倍。对照前面的图2-3很容易看出数据0和2的地址相差8个字节,而数据0和6的地址相差24个字节。

元素在数据存储区中的排列格式有两种:C语言格式和Fortran语言格式。在C语言中,多维数组的第0轴是最上位的,即第0轴的下标增加1时,元素的地址增加的字节数最多;而Fortran语言中的多维数组的第0轴是最下位的,即第0轴的下标增加1时,地址只增加一个元素的字节数。在NumPy中默认以C语言格式存储数据,如果希望改为Fortran格式,只需要在创建数组时,设置order参数为"F":

    c = np.array([[0,1,2],[3,4,5],[6,7,8]], dtype=np.float32, order="F")
    c.strides
    (4, 12)

了解了数组的内存结构,就可以解释使用下标取得数据时的复制和引用问题:

●当下标使用整数和切片时,所取得的数据在数据存储区域中是等间隔分布的。因为只需要修改图2-3所示的数据结构中的dim count、dimensions、stride等属性以及指向数据存储区域的指针data,就能实现整数和切片下标,所以新数组和原始数组能够共享数据存储区域。

●当使用整数序列、整数数组和布尔数组时,不能保证所取得的数据在数据存储区域中是等间隔的,因此无法和原始数组共享数据,只能对数据进行复制。

数组的flags属性描述了数据存储区域的一些属性,直接查看flags属性将输出各个标志的值,也可以单独获得其中的某个标志值:

    print a.flags
    print "c_contiguous:", a.flags.c_contiguous
      C_CONTIGUOUS : True
      F_CONTIGUOUS : False
      OWNDATA : True
      WRITEABLE : True
      ALIGNED : True
      UPDATEIFCOPY : False
    c_contiguous: True

下面是几个比较重要的标志:

●C_CONTIGUOUS:数据存储区域是否是C语言格式的连续区域。

●F_CONTIGUOUS:数据存储区域是否是Fortran语言格式的连续区域。

●OWNDATA:数组是否拥有此数据存储区域,当一个数组是其他数组的视图时,它不拥有数据存储区域。

由于数组a是通过array()直接创建的,因此它的数据存储区域是C语言格式的连续区域,并且它拥有数据存储区域。下面我们看看数组a的转置标志,数组的转置可以通过其T属性获得,转置数组将其数据存储区域看作Fortran语言格式的连续区域,并且它不拥有数据存储区域。

    a.T.flags
    C_CONTIGUOUS : False
      F_CONTIGUOUS : True
      OWNDATA : False
      WRITEABLE : True
      ALIGNED : True
      UPDATEIFCOPY : False

下面查看数组b的标志,它不拥有数据存储区域,其数据也不是连续存储的。通过视图数组的base属性可以获得保存数据的原始数组:

    b.flags
    C_CONTIGUOUS : False
      F_CONTIGUOUS : False
      OWNDATA : False
      WRITEABLE : True
      ALIGNED : True
      UPDATEIFCOPY : False
    id(b.base)    id(a)  
    ----------  ---------
    119627272   119627272

我们还可以通过view()方法从同一块数据区创建不同的dtype的数组对象,也就是使用不同的数值类型查看同一段内存中的二进制数据:

由于数组a的元素类型是单精度浮点数,占用4个字节,通过a.view(np.uint32),我们创建了一个新的数组,它和数组a使用同一段数据内存,但是它将每4个字节的数据当作无符号32位整数处理。而a.view(np.uint8)将每个字节都当作一个单字节的无符号整数,因此得到一个形状为(3, 8)的数组。通过view()方法获得的新数组与原数组共享内存,当a[0, 0]被修改时,b[0, 0]和c[0, :4]都会改变:

    a[0, 0] = 3.14
    b[0, 0]          c[0, :4]      
    ----------  --------------------
    1078523331  [195, 245,  72,  64]

下面我们看一个使用view()方法的有趣的例子。在《雷神之锤III:竞技场》的C语言源代码中有这样一个神奇的计算平方根倒数的函数Q_rsqrt()。代码中使用牛顿迭代法计算平方根倒数,这并没有任何神奇之处,但是其中包含了一个神奇的数字0x5f3759df,并将单精度浮点数当作32位的整数进行了一次令人毫无头绪的运算:

http://zh.wikipedia.org/wiki/平方根倒数速算法

维基百科关于《雷神之锤》中使用0x5f3759df计算平方根倒数算法的解释。

    float Q_rsqrt( float number )
    {
        long i;
        float x2, y;
        const float threehalfs = 1.5F;
    
        x2 = number * 0.5F;
        y  = number;
        i  = * ( long * ) &y;                          // 对浮点数的邪恶位级hack
        i  = 0x5f3759df - ( i >> 1 );            // 这到底是怎么回事?
        y  = * ( float * ) &i;
        y  = y * ( threehalfs - ( x2 * y * y ));       // 第一次牛顿迭代
        return y;
    }

下面我们用NumPy实现同样的计算:

    number = np.linspace(0.1, 10, 100)
    y = number.astype(np.float32) ❶
    x2 = y * 0.5
    i = y.view(np.int32) ❷
    i[:] = 0x5f3759df - (i >> 1) ❸
    y = y * (1.5 - x2 * y * y) ❹
    np.max(np.abs(1 / np.sqrt(number) - y)) ❺
    0.0050456140410597428

❶由于linspace()创建的数组的类型为双精度浮点数,因此这里首先通过astype()方法将其转换成单精度浮点数数组y。❷通过view()方法创建一个与y共享内存的32位整数数组i。❸对整数数组i进行那段完全摸不着头脑的运算,并且将结果重新写入数组i中。由于i和y共享内存,此时y中的值也发生了变化。注意这里的赋值不能使用i=0x5f3759df -(i >> 1),如果这样写,那么数组i就是一个全新的数组了。❹进行一次牛顿迭代运算,这里由于使用y=...的写法,因此y将变成一个全新的数组,和原来的i不再共享内存。在这段代码中有很多数组运算,关于这方面的内容将在下一节进行详细说明。❺最后输出真实值和近似值之间的最大误差。图2-4显示了绝对误差与自变量的关系,当number很小时绝对误差较大,但此时的函数值也较大,因此相对误差的变化并不大。

图2-4 《雷神之锤》中计算平方根倒数算法的绝对误差

除了使用切片从同一块数据区创建不同的shape和strides的数组对象之外,还可以直接设置这些属性,从而得到用切片实现不了的效果,例如:

    from numpy.lib.stride_tricks import as_strided
    a = np.arange(6)
    b = as_strided(a, shape=(4, 3), strides=(4, 4))
            a                b     
    ------------------  -----------
    [0, 1, 2, 3, 4, 5]  [[0, 1, 2],
    [1, 2, 3],
    [2, 3, 4],
    [3, 4, 5]]

这个例子中,我们从NumPy的辅助模块中载入了as_strided()函数,并使用它从一个长度为6的一维数组a创建了一个shape为(4, 3)的二维数组b。由于通过strides参数直接指定了数组b的strides属性,因此不仅数组b和数组a共享数据区,而且b中的前后两行有两个元素是重合的。例如下面修改a[2]的值,b中的前三行中对应的元素也发生改变:

    a[2] = 20
    b
    array([[ 0,  1, 20],
           [ 1, 20,  3],
           [20,  3,  4],
           [ 3,  4,  5]])

在对数据进行处理时,可能经常需要对数据进行分块处理,而且为了保持平滑,每块数据之间需要有一定的重叠部分。这时可以使用上面介绍的方法对数据进行带重叠的分块。需要注意的是,使用as_strided()时NumPy不会进行内存越界检查,因此shape和strides设置不当可能会发生意想不到的错误。