如何擴展 NumPy#
編寫擴展模組#
雖然 ndarray 物件被設計為允許在 Python 中快速計算,但它也被設計為通用目的,並滿足廣泛的計算需求。因此,如果絕對速度至關重要,那麼沒有任何東西可以替代針對您的應用程式和硬體量身定制的、精心製作的編譯迴圈。這是 numpy 包含 f2py 的原因之一,以便提供易於使用的機制,將(簡單的)C/C++ 和(任意的)Fortran 程式碼直接連結到 Python 中。我們鼓勵您使用並改進此機制。本節的目的不是記錄此工具,而是記錄編寫此工具所依賴的擴展模組的更基本步驟。
當擴展模組被編寫、編譯並安裝到 Python 路徑(sys.path)中的某個位置時,程式碼隨後可以像標準 python 檔案一樣匯入到 Python 中。它將包含已在 C 程式碼中定義和編譯的物件和方法。在 Python 中執行此操作的基本步驟有詳細的文件記錄,您可以在 Python 本身的線上文件 www.python.org 中找到更多資訊。
除了 Python C-API 之外,NumPy 還有一個完整且豐富的 C-API,允許在 C 級別進行複雜的操作。但是,對於大多數應用程式,通常只會使用少數 API 呼叫。例如,如果您只需要提取指向記憶體的指標以及一些形狀資訊以傳遞給另一個計算常式,那麼您將使用與嘗試建立新的類陣列類型或為 ndarray 新增新的資料類型時非常不同的呼叫。本章記錄了最常用的 API 呼叫和巨集。
必要子例程#
在您的 C 程式碼中,恰好有一個函數必須被定義,以便 Python 可以將其用作擴展模組。該函數必須被稱為 init{name},其中 {name} 是來自 Python 的模組名稱。此函數必須被宣告為對常式外部的程式碼可見。除了新增您想要的方法和常數之外,此子例程還必須包含類似 import_array()
和/或 import_ufunc()
的呼叫,具體取決於需要哪個 C-API。忘記放置這些命令將會顯示為醜陋的區段錯誤(崩潰),只要實際呼叫任何 C-API 子例程。實際上,在單個檔案中可以有多個 init{name} 函數,在這種情況下,多個模組將由該檔案定義。但是,有一些技巧可以使它正確運作,這裡不作介紹。
最小的 init{name}
方法看起來像
PyMODINIT_FUNC
init{name}(void)
{
(void)Py_InitModule({name}, mymethods);
import_array();
}
mymethods 必須是 PyMethodDef 結構的陣列(通常是靜態宣告的),其中包含方法名稱、實際的 C 函數、指示該方法是否使用關鍵字引數的變數以及 docstring。這些將在下一節中說明。如果您想將常數新增到模組中,那麼您可以儲存來自 Py_InitModule 的傳回值,這是一個模組物件。將項目新增到模組的最通用方法是使用 PyModule_GetDict(module) 取得模組字典。使用模組字典,您可以手動將任何您喜歡的內容新增到模組中。將物件新增到模組的更簡單方法是使用三個額外的 Python C-API 呼叫之一,這些呼叫不需要單獨提取模組字典。這些已在 Python 文件中記錄,但為了方便起見,在此重複一遍
-
int PyModule_AddStringConstant(PyObject *module, char *name, char *value)#
所有這三個函數都需要 module 物件(Py_InitModule 的傳回值)。name 是一個字串,標記模組中的值。根據呼叫的函數,value 引數可以是通用物件(
PyModule_AddObject
竊取對它的引用)、整數常數或字串常數。
定義函數#
傳遞給 Py_InitModule 函數的第二個引數是一個結構,它可以輕鬆地在模組中定義函數。在上面給出的範例中,mymethods 結構將在檔案的較早位置(通常在 init{name} 子例程之前)定義為
static PyMethodDef mymethods[] = {
{ nokeywordfunc,nokeyword_cfunc,
METH_VARARGS,
Doc string},
{ keywordfunc, keyword_cfunc,
METH_VARARGS|METH_KEYWORDS,
Doc string},
{NULL, NULL, 0, NULL} /* Sentinel */
}
mymethods 陣列中的每個條目都是一個 PyMethodDef
結構,其中包含 1) Python 名稱,2) 實現該函數的 C 函數,3) 指示是否接受此函數的關鍵字標誌,以及 4) 該函數的 docstring。可以透過向此表格新增更多條目來為單個模組定義任意數量的函數。最後一個條目必須全部為 NULL,如所示,充當哨兵。Python 尋找此條目以了解模組的所有函數都已定義。
完成擴展模組的最後一件事是實際編寫執行所需功能的程式碼。函數有兩種:不接受關鍵字引數的函數和接受關鍵字引數的函數。
不帶關鍵字引數的函數#
不接受關鍵字引數的函數應編寫為
static PyObject*
nokeyword_cfunc (PyObject *dummy, PyObject *args)
{
/* convert Python arguments */
/* do function */
/* return something */
}
在此上下文中未使用虛擬引數,可以安全地忽略它。args 引數包含作為元組傳遞給函數的所有引數。此時您可以做任何您想做的事情,但通常管理輸入引數的最簡單方法是呼叫 PyArg_ParseTuple
(args, format_string, addresses_to_C_variables…) 或 PyArg_UnpackTuple
(tuple, “name”, min, max, …)。Python C-API 參考手冊第 5.5 節(解析引數和建構值)中包含了如何使用第一個函數的良好描述。您應該特別注意 “O&” 格式,它使用轉換器函數在 Python 物件和 C 物件之間進行轉換。所有其他格式函數都可以(大部分)被認為是此一般規則的特例。NumPy C-API 中定義了幾個轉換器函數,可能很有用。特別是,PyArray_DescrConverter
函數對於支援任意資料類型規範非常有用。此函數將任何有效的資料類型 Python 物件轉換為 PyArray_Descr* 物件。請記住傳遞應填入的 C 變數的位址。
在整個 NumPy 原始碼中,有很多關於如何使用 PyArg_ParseTuple
的範例。標準用法如下
PyObject *input;
PyArray_Descr *dtype;
if (!PyArg_ParseTuple(args, "OO&", &input,
PyArray_DescrConverter,
&dtype)) return NULL;
重要的是要記住,當使用 “O” 格式字串時,您會獲得對物件的借用引用。但是,轉換器函數通常需要某種形式的記憶體處理。在此範例中,如果轉換成功,dtype 將保存對 PyArray_Descr* 物件的新引用,而 input 將保存借用的引用。因此,如果此轉換與另一個轉換(例如轉換為整數)混合,並且資料類型轉換成功但整數轉換失敗,那麼您需要在傳回之前釋放資料類型物件的引用計數。一種典型的做法是在呼叫 PyArg_ParseTuple
之前將 dtype 設定為 NULL
,然後在傳回之前在 dtype 上使用 Py_XDECREF
。
在處理完輸入引數後,編寫實際執行工作的程式碼(可能會根據需要呼叫其他函數)。C 函數的最後一步是傳回某些內容。如果遇到錯誤,則應傳回 NULL
(確保實際上已設定錯誤)。如果應該不傳回任何內容,則遞增 Py_None
並傳回它。如果應該傳回單個物件,則傳回它(確保您首先擁有對它的引用)。如果應該傳回多個物件,則需要傳回一個元組。Py_BuildValue
(format_string, c_variables…) 函數可以輕鬆地從 C 變數建構 Python 物件的元組。請特別注意格式字串中 ‘N’ 和 ‘O’ 之間的差異,否則您很容易建立記憶體洩漏。‘O’ 格式字串會遞增它對應的 PyObject* C 變數的引用計數,而 ‘N’ 格式字串會竊取對應的 PyObject* C 變數的引用。如果您已經為物件建立了引用,並且只想將該引用提供給元組,則應使用 ‘N’。如果您只有物件的借用引用,並且需要建立一個引用以提供給元組,則應使用 ‘O’。
帶關鍵字引數的函數#
這些函數與不帶關鍵字引數的函數非常相似。唯一的區別是函數簽名是
static PyObject*
keyword_cfunc (PyObject *dummy, PyObject *args, PyObject *kwds)
{
...
}
kwds 引數保存一個 Python 字典,其鍵是關鍵字引數的名稱,其值是相應的關鍵字引數值。可以隨您認為合適的方式處理此字典。但是,處理它的最簡單方法是用呼叫 PyArg_ParseTupleAndKeywords
(args, kwds, format_string, char *kwlist[], addresses…) 來取代 PyArg_ParseTuple
(args, format_string, addresses…) 函數。此函數的 kwlist 參數是一個以 NULL
結尾的字串陣列,提供預期的關鍵字引數。format_string 中的每個條目都應該有一個字串。如果傳遞了無效的關鍵字引數,使用此函數將引發 TypeError。
有關此函數的更多幫助,請參閱 Python 文件中擴展和嵌入教程的第 1.8 節(擴展函數的關鍵字參數)。
引用計數#
編寫擴展模組時,最大的困難是引用計數。這是 f2py、weave、Cython、ctypes 等流行的重要原因…。如果您錯誤地處理引用計數,您可能會遇到從記憶體洩漏到區段錯誤的問題。我所知道的正確處理引用計數的唯一策略是血汗和淚水。首先,您強迫自己記住每個 Python 變數都有一個引用計數。然後,您確切地了解每個函數對物件的引用計數執行什麼操作,以便您可以在需要時正確使用 DECREF 和 INCREF。引用計數確實可以考驗您對程式設計技巧的耐心和勤奮程度。儘管描述很嚴峻,但大多數引用計數的情況都非常簡單,最常見的困難是由於某些錯誤而過早從常式退出之前未在物件上使用 DECREF。其次,常見的錯誤是不擁有傳遞給將竊取引用的函數或巨集的物件的引用(例如 PyTuple_SET_ITEM
,以及大多數接受 PyArray_Descr
物件的函數)。
通常,當變數被建立或作為某些函數的傳回值時,您會獲得對該變數的新引用(但是,有一些突出的例外情況,例如從元組或字典中取得項目)。當您擁有引用時,您有責任確保在不再需要變數時(並且沒有其他函數「竊取」其引用),呼叫 Py_DECREF
(var)。此外,如果您要將 Python 物件傳遞給將「竊取」引用的函數,那麼您需要確保您擁有它(或使用 Py_INCREF
以取得您自己的引用)。您還會遇到借用引用的概念。借用引用的函數不會改變物件的引用計數,也不期望「持有」引用。它只是暫時使用該物件。當您使用 PyArg_ParseTuple
或 PyArg_UnpackTuple
時,您會收到對元組中物件的借用引用,並且不應在函數內部更改其引用計數。透過練習,您可以學會正確地進行引用計數,但起初可能會令人沮喪。
引用計數錯誤的一個常見來源是 Py_BuildValue
函數。請仔細注意 ‘N’ 格式字元和 ‘O’ 格式字元之間的差異。如果您在子例程中建立一個新物件(例如輸出陣列),並且您在傳回值元組中將其傳回,那麼您應該最有可能在 Py_BuildValue
中使用 ‘N’ 格式字元。‘O’ 字元將使引用計數增加一。這將使呼叫者擁有一個全新陣列的兩個引用計數。當變數被刪除並且引用計數減一後,仍然會有額外的引用計數,並且永遠不會取消分配陣列。您將遇到引用計數引起的記憶體洩漏。使用 ‘N’ 字元將避免這種情況,因為它將向呼叫者傳回一個物件(在元組內部),該物件具有單個引用計數。
處理陣列物件#
NumPy 的大多數擴展模組將需要存取 ndarray 物件(或其子類之一)的記憶體。最簡單的方法不需要您了解太多關於 NumPy 內部的知識。方法是
確保您正在處理行為良好的陣列(對齊、機器位元組順序和單個區段),該陣列具有正確的類型和維度數。
透過使用
PyArray_FromAny
或在其上建構的巨集從某些 Python 物件轉換它。透過使用
PyArray_NewFromDescr
或基於它的更簡單的巨集或函數來建構具有所需形狀和類型的新 ndarray。
取得陣列的形狀以及指向其實際資料的指標。
將資料和形狀資訊傳遞給實際執行計算的子例程或其他程式碼區段。
如果您正在編寫演算法,那麼我建議您使用陣列中包含的步幅資訊來存取陣列的元素(
PyArray_GetPtr
巨集使這變得輕鬆)。然後,您可以放寬您的要求,以便不強制使用單個區段陣列以及可能導致的資料複製。
以下小節涵蓋了這些子主題中的每一個。
轉換任意序列物件#
從任何可以轉換為陣列的 Python 物件取得陣列的主要常式是 PyArray_FromAny
。此函數非常靈活,具有許多輸入引數。幾個巨集使使用基本函數更容易。PyArray_FROM_OTF
可以說是這些巨集中最有用的,適用於最常見的用途。它允許您將任意 Python 物件轉換為特定內建資料類型(例如 float)的陣列,同時指定一組特定的要求(例如 連續、對齊和可寫)。語法是
PyArray_FROM_OTF
從任何可以轉換為陣列的 Python 物件 obj 傳回 ndarray。傳回陣列中的維度數由物件決定。傳回陣列的所需資料類型在 typenum 中提供,它應該是列舉類型之一。傳回陣列的 requirements 可以是標準陣列標誌的任意組合。以下更詳細地解釋了每個引數。成功後,您會收到對陣列的新引用。失敗時,將傳回
NULL
並設定例外。- obj
物件可以是任何可轉換為 ndarray 的 Python 物件。如果物件已經是(ndarray 的子類別)滿足要求的物件,則傳回新引用。否則,將建構一個新陣列。除非使用陣列介面,否則 obj 的內容會複製到新陣列,以便不必複製資料。可以轉換為陣列的物件包括:1) 任何巢狀序列物件,2) 任何公開陣列介面的物件,3) 任何具有
__array__
方法(應傳回 ndarray)的物件,以及 4) 任何純量物件(變成零維陣列)。其他方面符合要求的 ndarray 的子類別將被傳遞。如果您想確保基類 ndarray,請在 requirements 標誌中使用NPY_ARRAY_ENSUREARRAY
。僅在必要時才進行複製。如果您想保證複製,請傳入NPY_ARRAY_ENSURECOPY
到 requirements 標誌。- typenum
列舉類型之一,如果應該從物件本身確定資料類型,則為
NPY_NOTYPE
。可以使用基於 C 的名稱或者,可以使用平台上支援的位元寬度名稱。例如
只有在轉換不會損失精確度的情況下,物件才會轉換為所需的類型。否則,將返回
NULL
並引發錯誤。在需求標誌中使用NPY_ARRAY_FORCECAST
以覆蓋此行為。- 需求 (requirements)
ndarray 的記憶體模型允許在每個維度中使用任意步幅 (strides) 來前進到陣列的下一個元素。然而,通常您需要與期望 C 相鄰 (C-contiguous) 或 Fortran 相鄰 (Fortran-contiguous) 記憶體佈局的代码介面。此外,ndarray 可能會未對齊 (misaligned) (元素的位址不是元素大小的整數倍),如果您嘗試取消引用指向陣列資料的指標,這可能會導致您的程式崩潰 (或至少工作速度變慢)。這兩個問題都可以通過將 Python 物件轉換為更符合您特定用途的「良好行為」陣列來解決。
需求 (requirements) 標誌允許指定可接受的陣列種類。如果傳入的物件不滿足這些需求,則會複製一份,以便返回的物件將滿足需求。這些 ndarray 可以使用非常通用的記憶體指標。此標誌允許指定返回的陣列物件的所需屬性。所有標誌都在詳細的 API 章節中說明。最常用的標誌是
NPY_ARRAY_IN_ARRAY
、NPY_ARRAY_OUT_ARRAY
和NPY_ARRAY_INOUT_ARRAY
NPY_ARRAY_IN_ARRAY
此標誌適用於必須為 C 相鄰順序且已對齊的陣列。這些陣列通常是某些演算法的輸入陣列。
NPY_ARRAY_OUT_ARRAY
此標誌適用於指定一個 C 相鄰順序、已對齊且可以寫入的陣列。這樣的陣列通常作為輸出返回 (儘管通常這種輸出陣列是從頭開始建立的)。
NPY_ARRAY_INOUT_ARRAY
此標誌適用於指定將用於輸入和輸出的陣列。在介面例程結束時調用
Py_DECREF
之前,必須先調用PyArray_ResolveWritebackIfCopy
,以將臨時資料寫回傳入的原始陣列中。使用NPY_ARRAY_WRITEBACKIFCOPY
標誌要求輸入物件已經是一個陣列 (因為其他物件無法以這種方式自動更新)。如果發生錯誤,請在設定了這些標誌的陣列上使用PyArray_DiscardWritebackIfCopy
(obj)。這將設定底層基礎陣列為可寫入,而不會導致內容複製回原始陣列。
可以作為額外需求進行 OR 運算的其它有用標誌是
NPY_ARRAY_FORCECAST
強制轉換為所需的類型,即使在不損失資訊的情況下無法完成。
NPY_ARRAY_ENSURECOPY
確保結果陣列是原始陣列的副本。
NPY_ARRAY_ENSUREARRAY
確保結果物件是實際的 ndarray,而不是子類別。
注意
陣列是否進行位元組交換 (byte-swapped) 取決於陣列的資料類型。本機位元組順序陣列始終由 PyArray_FROM_OTF
請求,因此需求參數中不需要 NPY_ARRAY_NOTSWAPPED
標誌。也沒有辦法從此例程中獲取位元組交換陣列。
建立全新的 ndarray#
通常,必須從擴充模組 (extension-module) 代码中建立新陣列。也許需要一個輸出陣列,而您不希望調用方必須提供它。也許只需要一個臨時陣列來保存中間計算結果。無論需要什麼,都有簡單的方法來獲取所需資料類型的 ndarray 物件。用於執行此操作的最通用函數是 PyArray_NewFromDescr
。所有陣列建立函數都經過此重度重複使用的程式碼。由於其靈活性,使用起來可能有些令人困惑。因此,存在更簡單的形式,更易於使用。這些形式是 PyArray_SimpleNew
系列函數的一部分,這些函數通過為常見用例提供預設值來簡化介面。
取得 ndarray 記憶體並存取 ndarray 的元素#
如果 obj 是一個 ndarray (PyArrayObject*),則 ndarray 的資料區域由 void* 指標 PyArray_DATA
(obj) 或 char* 指標 PyArray_BYTES
(obj) 指向。請記住,(通常) 此資料區域可能未根據資料類型對齊,它可能表示位元組交換資料,和/或它可能不可寫入。如果資料區域已對齊且為本機位元組順序,那麼如何存取陣列的特定元素僅由 npy_intp 變數陣列 PyArray_STRIDES
(obj) 決定。特別是,這個整數的 c 陣列顯示必須將多少位元組添加到當前元素指標,才能獲得每個維度中的下一個元素。對於小於 4 維的陣列,有 PyArray_GETPTR{k}
(obj, …) 巨集,其中 {k} 是整數 1、2、3 或 4,它們使使用陣列步幅更容易。參數 …. 表示 {k} 個非負整數索引到陣列中。例如,假設 E
是一個 3 維 ndarray。E[i,j,k]
元素的 (void*) 指標獲取為 PyArray_GETPTR3
(E, i, j, k)。
如前所述,C 樣式相鄰陣列和 Fortran 樣式相鄰陣列具有特定的步幅模式。兩個陣列標誌 (NPY_ARRAY_C_CONTIGUOUS
和 NPY_ARRAY_F_CONTIGUOUS
) 指示特定陣列的步幅模式是否與 C 樣式相鄰或 Fortran 樣式相鄰或兩者都不匹配。步幅模式是否與標準 C 或 Fortran 匹配可以使用 PyArray_IS_C_CONTIGUOUS
(obj) 和 PyArray_ISFORTRAN
(obj) 分別進行測試。大多數第三方函式庫都期望相鄰陣列。但是,通常支援通用步幅並不困難。我鼓勵您在自己的程式碼中盡可能使用步幅資訊,並為包裝第三方程式碼保留單一段需求。使用 ndarray 提供的步幅資訊,而不是要求相鄰步幅,可以減少否則必須進行的複製。
範例#
以下範例示範如何編寫一個包裝器 (wrapper),它接受兩個輸入參數 (將轉換為陣列) 和一個輸出參數 (必須是陣列)。該函數返回 None 並更新輸出陣列。請注意 NumPy v1.14 及更高版本 WRITEBACKIFCOPY 語義的更新用法
static PyObject *
example_wrapper(PyObject *dummy, PyObject *args)
{
PyObject *arg1=NULL, *arg2=NULL, *out=NULL;
PyObject *arr1=NULL, *arr2=NULL, *oarr=NULL;
if (!PyArg_ParseTuple(args, "OOO!", &arg1, &arg2,
&PyArray_Type, &out)) return NULL;
arr1 = PyArray_FROM_OTF(arg1, NPY_DOUBLE, NPY_ARRAY_IN_ARRAY);
if (arr1 == NULL) return NULL;
arr2 = PyArray_FROM_OTF(arg2, NPY_DOUBLE, NPY_ARRAY_IN_ARRAY);
if (arr2 == NULL) goto fail;
#if NPY_API_VERSION >= 0x0000000c
oarr = PyArray_FROM_OTF(out, NPY_DOUBLE, NPY_ARRAY_INOUT_ARRAY2);
#else
oarr = PyArray_FROM_OTF(out, NPY_DOUBLE, NPY_ARRAY_INOUT_ARRAY);
#endif
if (oarr == NULL) goto fail;
/* code that makes use of arguments */
/* You will probably need at least
nd = PyArray_NDIM(<..>) -- number of dimensions
dims = PyArray_DIMS(<..>) -- npy_intp array of length nd
showing length in each dim.
dptr = (double *)PyArray_DATA(<..>) -- pointer to data.
If an error occurs goto fail.
*/
Py_DECREF(arr1);
Py_DECREF(arr2);
#if NPY_API_VERSION >= 0x0000000c
PyArray_ResolveWritebackIfCopy(oarr);
#endif
Py_DECREF(oarr);
Py_INCREF(Py_None);
return Py_None;
fail:
Py_XDECREF(arr1);
Py_XDECREF(arr2);
#if NPY_API_VERSION >= 0x0000000c
PyArray_DiscardWritebackIfCopy(oarr);
#endif
Py_XDECREF(oarr);
return NULL;
}