理解numpy中ndarray的內存布局和設計哲學
- 2020 年 2 月 17 日
- 筆記
本文的主要目的在於理解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的設計哲學在於數據存儲與其解釋方式的分離,或者說copy
和view
的分離,讓儘可能多的操作發生在解釋方式上(view
上),而盡量少地操作實際存儲數據的內存區域。
如下所示,像reshape
操作返回的新對象b
,a
和b
的shape
不同,但是兩者共享同一個數據block,c=b.T
,c
是b
的轉置,但兩者仍共享同一個數據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
:數據類型,指示了每個數據佔用多少個位元組,這幾個位元組怎麼解釋,比如int32
、float32
等;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
中的所有數據都是同一種類型,比如int32
、float64
等,每個數據佔用的位元組數相同、解釋方式也相同,所以可以稠密地排列在一起,在取出時根據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
可以容納不同類型的對象,像string
、int
、tuple
等都可以放在一個list
里,所以list
中存放的是對象的引用,再通過引用找到具體的對象,這些對象所在的物理地址並不是連續的,如下所示

所以相對ndarray
,list
訪問到數據需要多跳轉1次,list
只能做到對對象引用的按秩訪問,對具體的數據並不是按秩訪問,所以效率上ndarray
比list
要快得多,空間上,因為ndarray
只把數據緊密存儲,而list
需要把每個對象的所有域值都存下來,所以ndarray
比list
要更省空間。
小結
下面小結一下:
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 副本和視圖