1. 程式人生 > >理解numpy中ndarray的記憶體佈局和設計哲學

理解numpy中ndarray的記憶體佈局和設計哲學

目錄

  • ndarray是什麼
  • ndarray的設計哲學
  • ndarray的記憶體佈局
  • 為什麼可以這樣設計
  • 小結
  • 參考

部落格:部落格園 | CSDN | blog

本文的主要目的在於理解numpy.ndarray的記憶體結構及其背後的設計哲學。

ndarray是什麼

NumPy provides an N-dimensional array type, the ndarray, which describes a collection of “items” of the same type. The items can be indexed using for example N integers.

—— from https://docs.scipy.org/doc/numpy-1.17.0/reference/arrays.html

ndarray是numpy中的多維陣列,陣列中的元素具有相同的型別,且可以被索引。

如下所示:

>>> import numpy as np
>>> a = np.array([[0,1,2,3],[4,5,6,7],[8,9,10,11]])
>>> a
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> type(a)
<class 'numpy.ndarray'>
>>> a.dtype   
dtype('int32')
>>> a[1,2]
6
>>> a[:,1:3]
array([[ 1,  2],
       [ 5,  6],
       [ 9, 10]])

>>> a.ndim    
2
>>> a.shape   
(3, 4)        
>>> a.strides 
(16, 4)       

注:np.array並不是類,而是用於建立np.ndarray物件的其中一個函式,numpy中多維陣列的類為np.ndarray

ndarray的設計哲學

ndarray的設計哲學在於資料儲存與其解釋方式的分離,或者說copyview的分離,讓儘可能多的操作發生在解釋方式上(view上),而儘量少地操作實際儲存資料的記憶體區域。

如下所示,像reshape操作返回的新物件babshape不同,但是兩者共享同一個資料block,c=b.Tcb的轉置,但兩者仍共享同一個資料block,資料並沒有發生變化,發生變化的只是資料的解釋方式。

>>> a
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> b = a.reshape(4, 3)
>>> b
array([[ 0,  1,  2],
       [ 3,  4,  5],
       [ 6,  7,  8],
       [ 9, 10, 11]])

# reshape操作產生的是view檢視,只是對資料的解釋方式發生變化,資料實體地址相同
>>> a.ctypes.data
80831392
>>> b.ctypes.data
80831392
>>> id(a) == id(b)
false

# 資料在記憶體中連續儲存
>>> from ctypes import string_at
>>> string_at(b.ctypes.data, b.nbytes).hex()
'000000000100000002000000030000000400000005000000060000000700000008000000090000000a0000000b000000'

# b的轉置c,c仍共享相同的資料block,只改變了資料的解釋方式,“以列優先的方式解釋行優先的儲存”
>>> c = b.T
>>> c
array([[ 0,  3,  6,  9],
       [ 1,  4,  7, 10],
       [ 2,  4,  8, 11]])
>>> c.ctypes.data
80831392
>>> string_at(c.ctypes.data, c.nbytes).hex()
'000000000100000002000000030000000400000005000000060000000700000008000000090000000a0000000b000000'
>>> a
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])

# copy會複製一份新的資料,其實體地址位於不同的區域
>>> c = b.copy()
>>> c
array([[ 0,  1,  2],
       [ 3,  4,  5],
       [ 6,  7,  8],
       [ 9, 10, 11]])
>>> c.ctypes.data
80831456
>>> string_at(c.ctypes.data, c.nbytes).hex()
'000000000100000002000000030000000400000005000000060000000700000008000000090000000a0000000b000000'

# slice操作產生的也是view檢視,仍指向原來資料block中的實體地址
>>> d = b[1:3, :]
>>> d
array([[3, 4, 5],
       [6, 7, 8]])
>>> d.ctypes.data
80831404
>>> print('data buff address from {0} to {1}'.format(b.ctypes.data, b.ctypes.data + b.nbytes))
data buff address from 80831392 to 80831440

副本是一個數據的完整的拷貝,如果我們對副本進行修改,它不會影響到原始資料,實體記憶體不在同一位置。

檢視是資料的一個別稱或引用,通過該別稱或引用亦便可訪問、操作原有資料,但原有資料不會產生拷貝。如果我們對檢視進行修改,它會影響到原始資料,實體記憶體在同一位置。

檢視一般發生在:

  • 1、numpy 的切片操作返回原資料的檢視。
  • 2、呼叫 ndarray 的 view() 函式產生一個檢視。

副本一般發生在:

  • Python 序列的切片操作,呼叫deepCopy()函式。
  • 呼叫 ndarray 的 copy() 函式產生一個副本。

—— from NumPy 副本和檢視

view機制的好處顯而易見,省記憶體,同時速度快。

ndarray的記憶體佈局

NumPy arrays consist of two major components, the raw array data (from now on, referred to as the data buffer), and the information about the raw array data. The data buffer is typically what people think of as arrays in C or Fortran, a contiguous (and fixed) block of memory containing fixed sized data items. NumPy also contains a significant set of data that describes how to interpret the data in the data buffer.

—— from NumPy internals

ndarray的記憶體佈局示意圖如下:

可大致劃分成2部分——對應設計哲學中的資料部分和解釋方式:

  • raw array data:為一個連續的memory block,儲存著原始資料,類似C或Fortran中的陣列,連續儲存
  • metadata:是對上面記憶體塊的解釋方式

metadata都包含哪些資訊呢?

  • dtype:資料型別,指示了每個資料佔用多少個位元組,這幾個位元組怎麼解釋,比如int32float32等;
  • ndim:有多少維;
  • shape:每維上的數量;
  • strides:維間距,即到達當前維下一個相鄰資料需要前進的位元組數,因考慮記憶體對齊,不一定為每個資料佔用位元組數的整數倍;

上面4個資訊構成了ndarray的indexing schema,即如何索引到指定位置的資料,以及這個資料該怎麼解釋。

除此之外的資訊還有:位元組序(大端小端)、讀寫許可權、C-order(行優先儲存) or Fortran-order(列優先儲存)等,如下所示,

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

ndarray的底層是C和Fortran實現,上面的屬性可以在其原始碼中找到對應,具體可見PyArrayObject和PyArray_Descr等結構體。

為什麼可以這樣設計

為什麼ndarray可以這樣設計?

因為ndarray是為矩陣運算服務的,ndarray中的所有資料都是同一種類型,比如int32float64等,每個資料佔用的位元組數相同、解釋方式也相同,所以可以稠密地排列在一起,在取出時根據dtype現copy一份資料組裝成scalar物件輸出。這樣極大地節省了空間,scalar物件中除了資料之外的域沒必要重複儲存,同時因為連續記憶體的原因,可以按秩訪問,速度也要快得多。

>>> a
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> a[1,1]
5
>>> i,j = a[1,1], a[1,1]

# i和j為不同的物件,訪問一次就“組裝一個”物件
>>> id(i)
102575536
>>> id(j)
102575584
>>> a[1,1] = 4
>>> i
5
>>> j
5
>>> a
array([[ 0,  1,  2,  3],
       [ 4,  4,  6,  7],
       [ 8,  9, 10, 11]])

# isinstance(val, np.generic) will return True if val is an array scalar object. Alternatively, what kind of array scalar is present can be determined using other members of the data type hierarchy.
>> isinstance(i, np.generic)
True

這裡,可以將ndarray與python中的list對比一下,list可以容納不同型別的物件,像stringinttuple等都可以放在一個list裡,所以list中存放的是物件的引用,再通過引用找到具體的物件,這些物件所在的實體地址並不是連續的,如下所示

所以相對ndarraylist訪問到資料需要多跳轉1次,list只能做到對物件引用的按秩訪問,對具體的資料並不是按秩訪問,所以效率上ndarraylist要快得多,空間上,因為ndarray只把資料緊密儲存,而list需要把每個物件的所有域值都存下來,所以ndarraylist要更省空間。

小結

下面小結一下:

  • ndarray的設計哲學在於資料與其解釋方式的分離,讓絕大部分多維陣列操作只發生在解釋方式上;
  • ndarray中的資料在實體記憶體上連續儲存,在讀取時根據dtype現組裝成物件輸出,可以按秩訪問,效率高省空間;
  • 之所以能這樣實現,在於ndarray是為矩陣運算服務的,所有資料單元都是同種型別。

參考

  • Array objects
  • NumPy internals
  • NumPy C Code Explanations
  • Python Types and C-Structures
  • How is the memory allocated for numpy arrays in python?
  • NumPy 副本和檢視