NEP 55 — 為 NumPy 新增 UTF-8 可變寬度字串 DType#
- 作者:
Nathan Goldbaum <ngoldbaum@quansight.com>
- 作者:
Warren Weckesser
- 作者:
Marten van Kerkwijk
- 狀態:
最終
- 類型:
標準追蹤
- 建立時間:
2023-06-29
- 更新時間:
2024-01-18
- 決議:
摘要#
我們提議為 NumPy 新增一種新的字串資料型別,其中陣列中的每個項目都是任意長度的 UTF-8 編碼字串。這將為 NumPy 使用者帶來效能、記憶體使用量和可用性方面的改進,包括
對於目前使用固定寬度字串並主要儲存 ASCII 資料或單一 NumPy 陣列中短字串和長字串混合的工作流程,可節省記憶體。
下游程式庫和使用者將能夠擺脫目前用作可變長度字串陣列替代方案的物件陣列,透過避免在 NumPy 之外傳遞資料,並允許使用快速 GIL 釋放 C 轉換和字串 ufuncs 進行字串操作,來釋放效能改進。
更直覺的使用者介面 API,用於處理 Python 字串陣列,無需考慮記憶體中的陣列表示法。
動機與範疇#
首先,我們將描述 NumPy 中字串或類似字串資料的目前支援狀態是如何產生的。接下來,我們將總結上次關於此主題的主要討論。最後,我們將描述對 NumPy 提出的變更範疇,以及明確超出本提案範圍的變更。
NumPy 中字串支援的歷史#
NumPy 中對文字資料的支援是有機地發展的,以回應早期使用者的需求,然後是 Python 生態系統的變更。
在 NumPy 中新增了對字串的支援,以支援 NumArray chararray
型別的使用者。這方面的殘留仍然可以在 NumPy API 中看到:與字串相關的功能位於 np.char
中,以支援 np.char.chararray
類別。此類別並未正式棄用,但在模組文件字串中有一段註解,建議自 NumPy 1.4 以來改用字串 dtype。
NumPy 的 bytes_
DType 最初用於表示 Python 2 str
型別,然後才在 NumPy 中新增 Python 3 支援。當 bytes DType 用於表示 Python 2 字串或其他以 null 結尾的位元組序列時,它最有意義。但是,忽略尾隨 null 表示 bytes_
DType 僅適用於不包含尾隨 null 的固定寬度位元組串流,因此它可能不適合需要透過 NumPy 字串來回傳遞尾隨 null 的通用位元組串流。
unicode
DType 的新增是為了支援 Python 2 unicode
型別。它以 32 位元 UCS-4 碼點(例如 UTF-32 編碼)儲存資料,這使得實作變得簡單明瞭,但對於儲存可以使用單位元組 ASCII 或 Latin-1 編碼良好表示的文字來說,效率不高。這在 Python 2 中不是問題,在 Python 2 中,ASCII 或主要為 ASCII 的文字可以使用 str
DType。
隨著 NumPy 中 Python 3 支援的到來,字串 DType 在很大程度上被擱置,因為要考慮向後相容性問題,儘管 unicode DType 成為 str
資料的預設 DType,而舊的 string
DType 則重新命名為 bytes_
DType。此變更使 NumPy 處於次佳的狀況,即將最初用於以 null 結尾的位元組串的資料型別作為所有 python bytes
資料的資料型別發佈,以及預設字串型別的記憶體中表示法消耗的記憶體是可以使用單位元組 ASCII 或 Latin-1 編碼良好表示的資料所需記憶體的四倍。
固定寬度字串的問題#
現有的字串 DType 都表示固定寬度的序列,允許將字串資料儲存在陣列緩衝區中。這避免了向 NumPy 新增頻外儲存,但是,對於許多用例來說,它會產生笨拙的使用者介面。特別是,必須由 NumPy 推斷或由使用者估計最大字串大小,然後才能將資料載入到 NumPy 陣列中,或為字串操作選擇輸出 DType。在最壞的情況下,這需要對完整資料集進行昂貴的傳遞,以計算陣列元素的最大長度。當陣列元素具有不同長度時,也會浪費記憶體。陣列儲存許多短字串和少數非常長的字串的病態情況尤其不利於浪費記憶體。
NumPy 陣列中字串資料的下游使用已證明需要可變寬度字串資料型別。實際上,由於可用性問題,許多下游程式庫避免使用固定寬度字串,而是使用 object
陣列來儲存字串。特別是,Pandas 已明確棄用對 NumPy 固定寬度字串的支援,將 NumPy 固定寬度字串陣列強制轉換為 object
字串陣列或 PyArrow
-backed 字串陣列,並且將來將切換為僅透過 PyArrow
支援字串資料,PyArrow 原生支援 UTF-8 編碼的可變寬度字串陣列 [1]。
先前的討論#
專案上次公開深入討論此主題是在 2017 年,當時 Julian Taylor 提出了由編碼參數化的固定寬度文字資料型別 [2]。這引發了關於在 NumPy 中使用字串資料的痛點以及可能的未來方向的廣泛討論。
討論重點強調了當前對字串的支援在處理以下兩種用例時表現不佳 [3] [4] [5]
載入或記憶體對應具有未知編碼的科學資料集,
以允許在 NumPy 陣列和 Python 字串之間進行透明轉換的方式處理「Python 字串的 NumPy 陣列」,包括支援遺失的字串。
object
DType 部分滿足了這種需求,但代價是效能緩慢且沒有型別檢查。
由於這次討論,改進對字串資料的支援被新增到 NumPy 專案 roadmap [6],明確呼籲新增更適合記憶體對應具有任何或沒有編碼的位元組的 DType,以及支援遺失資料以取代物件字串陣列用法的可變寬度字串 DType。
提議的工作#
本 NEP 提議新增 StringDType
,這是一種在 NumPy 陣列中儲存可變寬度堆積分配字串的 DType,以取代下游針對字串資料的 object
DType 用法。這項工作將大量利用 NumPy 的最新改進來改進對使用者定義 DType 的支援,因此我們也必然會處理 NumPy 中的資料型別內部結構。特別是,我們提議
為 NumPy 新增一種新的可變長度字串 DType,目標是 NumPy 2.0。
解決與將使用實驗性 DType API 實作的 DType 新增到 NumPy 本身相關的問題。
支援使用者提供的遺失資料 Sentinel 值。
在新的
np.strings
命名空間中公開字串 ufuncs,以用於與字串支援相關的函數和型別,從而為未來棄用np.char
啟用遷移路徑。
以下內容超出此項工作的範圍
變更字串資料的 DType 推斷。
新增用於記憶體對應未知編碼文字的 DType,或嘗試修正
bytes_
DType 問題的 DType。完全同意遺失資料 Sentinel 值的語意,或將遺失資料 Sentinel 值新增到 NumPy 本身。
為字串操作實作 SIMD 優化。
更新
npy
和npz
檔案格式,以允許儲存任意長度的 sidecar 資料。
雖然我們明確排除將這些項目作為此工作的一部分來實作,但新增新的字串 DType 有助於建立未來的工作,以實作其中一些項目。
如果實作本 NEP,將使未來更容易新增新的固定寬度文字 DType,方法是將字串操作移至長期支援的命名空間,並改進 NumPy 中用於處理字串的內部基礎架構。我們也提出了一種記憶體佈局,在某些情況下應適用於 SIMD 優化,從而提高未來將字串操作編寫為 SIMD 優化 ufuncs 的效益。
雖然我們不提議將遺失資料 Sentinel 值新增到 NumPy,但我們提議新增對選用的使用者提供遺失資料 Sentinel 值的支援,因此這確實使 NumPy 更接近正式支援遺失資料。我們嘗試避免解決 NEP 26 中描述的分歧,並且此提案不要求或排除未來將遺失資料 Sentinel 值或基於位元旗標的遺失資料支援新增到 ndarray
。
用法與影響#
DType 旨在作為物件字串陣列的直接替代品。這表示我們打算支援盡可能多的物件字串陣列下游用法,包括所有受支援的 NumPy 功能。Pandas 是顯而易見的第一個使用者,並且已經進行了大量工作以在 Pandas 的分支中新增支援。scikit-learn
也使用物件字串陣列,並且將能夠遷移到具有保證陣列僅包含字串的 DType。h5py [7] 和 PyTables [8] 都將能夠在 HDF5 中新增對可變寬度 UTF-8 編碼字串資料集的一流支援。字串資料在機器學習工作流程中被大量使用,並且下游機器學習程式庫將能夠利用這種新的 DType。
希望將字串資料載入 NumPy 並利用 NumPy 功能(如精巧的高階索引)的使用者將有一個自然的選擇,與固定寬度 Unicode 字串相比,它可以提供顯著的記憶體節省,並且比物件字串陣列提供更好的驗證保證和整體整合。遷移到一流的字串 DType 也消除了在字串操作期間獲取 GIL 的需要,從而解鎖了物件字串陣列無法實現的未來優化。
效能#
在這裡,我們簡要描述了我們使用實驗性 DType API 在 NumPy 之外實作的 StringDType
原型版本的初步效能測量結果。本節中的所有基準測試均在 Dell XPS 13 9380 上執行,該電腦執行 Ubuntu 22.04 和使用 pyenv 編譯的 Python 3.11.3。NumPy、Pandas 和 StringDType
原型均使用 meson 發行版本建置進行編譯。
目前,StringDType
原型的效能與物件陣列和固定寬度字串陣列相當。一個例外是從 Python 字串建立陣列,效能比物件陣列稍慢,並且與固定寬度 Unicode 陣列相當
In [1]: from stringdtype import StringDType
In [2]: import numpy as np
In [3]: data = [str(i) * 10 for i in range(100_000)]
In [4]: %timeit arr_object = np.array(data, dtype=object)
3.15 ms ± 74.4 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
In [5]: %timeit arr_stringdtype = np.array(data, dtype=StringDType())
8.8 ms ± 12.7 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
In [6]: %timeit arr_strdtype = np.array(data, dtype=str)
11.6 ms ± 57.8 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
在此範例中,物件 DType 顯著更快,因為 data
清單中的物件可以直接在陣列中嵌入,而 StrDType
和 StringDType
需要複製字串資料,並且 StringDType
需要將資料轉換為 UTF-8 並在陣列緩衝區外部執行額外的堆積分配。未來,如果 Python 轉為 UTF-8 內部表示法來表示字串,StringDType
的字串載入效能應會提高。
字串操作具有相似的效能
In [7]: %timeit np.array([s.capitalize() for s in data], dtype=object)
31.6 ms ± 728 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [8]: %timeit np.char.capitalize(arr_stringdtype)
41.5 ms ± 84.1 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [9]: %timeit np.char.capitalize(arr_strdtype)
47.6 ms ± 386 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
此處的效能不佳反映了 np.char
中基於迭代器的操作實作速度緩慢。當我們完成將這些操作重寫為 ufuncs 時,我們將解鎖顯著的效能改進。以 add
ufunc 為例,我們已為 StringDType
原型實作了該 ufunc
In [10]: %timeit arr_object + arr_object
10.1 ms ± 400 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
In [11]: %timeit arr_stringdtype + arr_stringdtype
3.64 ms ± 258 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
In [12]: %timeit np.char.add(arr_strdtype, arr_strdtype)
17.7 ms ± 245 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
如下所述,我們已經更新了 Pandas 的分支以使用 StringDType
的原型版本。這展示了當資料已載入 NumPy 陣列並傳遞到第三方程式庫時可用的效能改進。目前 Pandas 嘗試預設將所有 str
資料強制轉換為 object
DType,並且必須檢查和清理傳入的現有 object
陣列。這需要複製或傳遞資料,而 NumPy 和 Pandas 中對可變寬度字串的一流支援使其變得不必要
In [13]: import pandas as pd
In [14]: %timeit pd.Series(arr_stringdtype)
18.8 µs ± 164 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
如果我們強制 Pandas 使用物件字串陣列(直到最近為止,這還是預設值),我們會看到在 NumPy 之外傳遞資料所帶來的顯著效能損失
In [15]: %timeit pd.Series(arr_object, dtype='string[python]')
907 µs ± 67 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each
Pandas 預設切換到 PyArrow-backed 字串陣列,特別是為了避免與物件字串陣列相關的這種效能成本和其他效能成本。
向後相容性#
我們未提議變更 Python 字串的 DType 推斷,並且預期不會對 NumPy 的現有用法產生任何影響。
詳細描述#
在這裡,我們提供了關於我們想要包含在 NumPy 中的 StringDType
版本的詳細描述。這與原型版本幾乎相同,但有一些差異,這些差異在 NumPy 外部存在的 DType 中是不可能實作的。
首先,我們描述了用於實例化 StringDType
實例的 Python API。接下來,我們將描述遺失資料處理支援和對陣列元素的嚴格字串型別檢查支援。接下來,我們將討論我們將定義的轉換和 ufunc 實作,並討論我們的新 np.strings
命名空間計畫,以在 Python API 中直接公開字串 ufuncs。最後,我們概述了我們想要公開的 C API,以及我們為初始實作選擇的記憶體佈局和堆積分配策略的詳細資訊。
StringDType
的 Python API#
新的 DType 將可透過 np.dtypes
命名空間存取
>>> from numpy.dtypes import StringDType
>>> dt = StringDType()
>>> dt
numpy.dtypes.StringDType()
此外,我們提議保留字元 "T"
(文字的縮寫)以用於 np.dtype
,因此以上內容與以下內容相同
>>> np.dtype("T")
numpy.dtypes.StringDType()
StringDType
可以開箱即用地用於表示 NumPy 陣列中的任意長度字串
>>> data = ["this is a very long string", "short string"]
>>> arr = np.array(data, dtype=StringDType())
>>> arr
array(['this is a very long string', 'short string'], dtype=StringDType())
請注意,與固定寬度字串不同,StringDType
不由陣列元素的最大長度參數化,任意長度或短字串都可以存在於同一個陣列中,而無需為短字串中的填充位元組保留儲存空間。
當類別作為 NumPy Python API 中的 dtype
引數傳遞時,StringDType
類別將是預設 StringDType
實例的同義詞。我們已經轉換了大部分 API 介面以像這樣工作,但仍有一些地方尚未轉換,並且第三方程式碼可能尚未轉換,因此我們不會在文件中強調這一點。強調 StringDType
是一個類別,而 StringDType()
是一個實例是更具前瞻性的 API,NumPy DType API 的其餘部分可以朝著這個方向發展,現在 DType 類別可以從 np.dtypes
命名空間匯入,因此我們將在文件中包含 StringDType
物件的明確實例化,即使它不是絕對必要的。
我們提議將 Python str
內建函式關聯為 DType 的純量型別
>>> StringDType.type
<class 'str'>
雖然這確實建立了一個 API 缺陷,因為從內建 DType 類別到 NumPy 中純量的對應將不再是一對一的(unicode
DType 的純量型別是 str
),但這避免了需要為此目的定義、優化或維護 str
子類別,或用於維護此一對一對應的其他駭客行為。為了保持向後相容性,為 Python 字串清單偵測到的 DType 將仍然是固定寬度 Unicode 字串。
如下所述,StringDType
支援兩個參數,可以調整 DType 的執行時間行為。我們不會嘗試透過字元程式碼支援 dtype 的參數。如果使用者需要不使用預設參數的 DType 實例,他們將需要使用 DType 類別來實例化 DType 的實例。
我們也將使用 NPY_VSTRING
條目擴展 C API 中的 NPY_TYPES
列舉(已經有一個 NPY_STRING
條目)。這不應干擾舊版使用者定義的 DType,因為這些資料型別的整數型別編號從 256 開始。原則上,在 NPY_TYPES
列舉中可用的整數範圍內,仍然有數百個內建 DType 的空間。
原則上,我們不需要保留字元程式碼,並且希望擺脫字元程式碼。但是,大量下游程式碼依賴於檢查 DType 字元程式碼來區分內建 NumPy DType,並且我們認為,如果使用者想要使用 StringDType
,則要求使用者重構其 DType 處理程式碼會損害採用。
我們也希望在未來,我們或許能夠新增 StringDType
的新固定寬度文字版本,該版本可以使用帶有長度或編碼修飾符的 "T"
字元程式碼。這將允許遷移到更靈活的文字 dtype,以用於結構化陣列和其他用例,其中固定寬度字串比可變寬度字串更適合。
遺失資料支援#
遺失資料可以使用 Sentinel 值表示
>>> dt = StringDType(na_object=np.nan)
>>> arr = np.array(["hello", nan, "world"], dtype=dt)
>>> arr
array(['hello', nan, 'world'], dtype=StringDType(na_object=nan))
>>> arr[1]
nan
>>> np.isnan(arr[1])
True
>>> np.isnan(arr)
array([False, True, False])
>>> np.empty(3, dtype=dt)
array(['', '', ''])
我們僅提議支援使用者提供的 Sentinel 值。預設情況下,空陣列將以空字串填充
>>> np.empty(3, dtype=StringDType())
array(['', '', ''], dtype=StringDType())
透過僅支援使用者提供的遺失資料 Sentinel 值,我們避免了確切解決 NumPy 本身應如何支援遺失資料以及遺失資料物件的正確語意,將其留給使用者決定。但是,我們確實偵測到使用者是否正在提供類似 NaN 的遺失資料值、字串遺失資料值,或兩者都不是。我們在下面說明了我們如何處理這些情況。
謹慎的讀者可能會擔心需要處理三種不同類別的遺失資料 Sentinel 值的複雜性。此處的複雜性反映了物件陣列的靈活性以及我們發現的下游使用模式。有些使用者希望與 Sentinel 值的比較產生錯誤,因此他們使用 None
。另一些使用者希望比較成功並具有某種有意義的排序,因此他們使用一些任意的、希望是唯一的字串。其他使用者希望使用類似於 NaN 在比較和算術運算中的行為或字面上是 NaN 的東西,以便 NumPy 專門尋找精確 NaN 的操作能夠工作,並且無需在 NumPy 之外重寫遺失資料處理。我們相信可以支援所有這些,但這需要一些希望可管理的複雜性。
類似 NaN 的 Sentinel 值#
類似 NaN 的 Sentinel 值會將自身作為算術運算的結果傳回。這包括 Python nan
浮點數和 Pandas 遺失資料 Sentinel 值 pd.NA
。我們選擇使類似 NaN 的 Sentinel 值在運算中繼承這些行為,因此加法的結果是 Sentinel 值
>>> dt = StringDType(na_object=np.nan)
>>> arr = np.array(["hello", np.nan, "world"], dtype=dt)
>>> arr + arr
array(['hellohello', nan, 'worldworld'], dtype=StringDType(na_object=nan))
我們也選擇使類似 NaN 的 Sentinel 值排序到陣列的末尾,這遵循了對包含 nan
的陣列進行排序的行為。
>>> np.sort(arr)
array(['hello', 'world', nan], dtype=StringDType(na_object=nan))
字串 Sentinel 值#
字串遺失資料值是 str
的實例或 str
的子型別。
運算將直接將 Sentinel 值用於遺失的條目。這是我們在下游程式碼中發現的此模式的主要用法,其中類似 "__nan__"
的遺失資料 Sentinel 值會傳遞到低階排序或分割演算法。
其他 Sentinel 值#
任何其他 Python 物件都會在運算或比較中引發錯誤,就像 None
作為目前物件陣列的遺失資料 Sentinel 值一樣
>>> dt = StringDType(na_object=None)
>>> np.sort(np.array(["hello", None, "world"], dtype=dt))
ValueError: Cannot compare null that is not a string or NaN-like value
由於比較需要引發錯誤,並且 NumPy 比較 API 沒有辦法在不持有 GIL 的情況下在排序期間發出基於值的錯誤訊號,因此對使用任意遺失資料 Sentinel 值的陣列進行排序將持有 GIL。我們也可能會嘗試放寬此限制,方法是重構 NumPy 的比較和排序實作,以允許在排序操作期間傳播基於值的錯誤。
對 DType 推斷的影響#
如果未來我們決定打破向後相容性,使 StringDType
成為 str
資料的預設 DType,則對任意物件作為遺失資料 Sentinel 值的支援似乎會對實作 DType 推斷造成問題。但是,鑑於對此 DType 的初始支援將需要直接使用 DType,並且將無法依賴 NumPy 來推斷 DType,我們認為這對於遺失資料功能的下游使用者來說不會是一個主要問題。若要使用 StringDType
,他們將需要在建立陣列時更新其程式碼以明確指定 DType,因此,如果 NumPy 未來變更 DType 推斷,他們的程式碼將不會變更行為,並且永遠不需要遺失資料 Sentinel 值參與 DType 推斷。
強制轉換非字串#
預設情況下,非字串資料會強制轉換為字串
>>> np.array([1, object(), 3.4], dtype=StringDType())
array(['1', '<object object at 0x7faa2497dde0>', '3.4'], dtype=StringDType())
如果不希望出現這種行為,則可以建立 DType 的實例來停用字串強制轉換
>>> np.array([1, object(), 3.4], dtype=StringDType(coerce=False))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: StringDType only allows string data when string coercion
is disabled
這允許在 NumPy 用於建立陣列的相同過程中進行嚴格的資料驗證,而無需下游函式庫實作它們自己的字串驗證,在一個獨立且昂貴的輸入類陣列的遍歷中。我們已選擇不將此設為預設行為,以遵循 NumPy 固定寬度字串的慣例,後者會強制轉換非字串。
轉換、ufunc 支援和字串操作函數#
將提供一整套往返轉換至內建 NumPy DType 的功能。此外,我們將為比較運算子新增實作,以及一個接受兩個字串陣列的 add
迴圈、接受字串和整數陣列的 multiply
迴圈、一個 isnan
迴圈,以及 str_len
、isalpha
、isdecimal
、isdigit
、isnumeric
、isspace
、find
、rfind
、count
、strip
、lstrip
、rstrip
和 replace
字串 ufunc 的實作,這些將在 NumPy 2.0 中新增可用。
isnan
ufunc 將針對類似 NaN 哨兵的條目返回 True
,否則返回 False
。比較將按照 Unicode 碼點的順序對資料進行排序,如同目前為固定寬度 Unicode DType 實作的方式。在未來,NumPy 或下游函式庫可能會為 NumPy Unicode 字串陣列新增感知地區設定的排序、大小寫摺疊和正規化,但我們目前不打算新增這些功能。
如果兩個 StringDType
實例是以相同的 na_object
和 coerce
參數建立的,則視為相等。對於接受多個字串參數的 ufunc,我們也引入了「相容」StringDType
實例的概念。如果它們具有相同的 na_object
,或者只有一個或另一個 DType 明確設定了 na_object
,我們允許在 ufunc 運算中一起使用不同的 DType 實例。我們不考慮字串強制轉換來判斷實例是否相容,儘管如果運算的結果是字串,則結果將繼承原始運算元更嚴格的字串強制轉換設定。
「相容」實例的這個概念將在二元 ufunc 的 resolve_descriptors
函數中強制執行。此選擇使使用非預設 StringDType
實例更容易,因為 Python 字串會被強制轉換為預設 StringDType
實例,因此允許以下慣用表達式
>>> arr = np.array(["hello", "world"], dtype=StringDType(na_object=None))
>>> arr + "!"
array(['hello!', 'world!'], dtype=StringDType(na_object=None))
如果我們僅考慮 StringDType
實例的相等性,這將會是一個錯誤,造成尷尬的使用者體驗。如果運算元具有不同的 na_object
設定,NumPy 將引發錯誤,因為結果 DType 的選擇不明確
>>> arr + np.array("!", dtype=StringDType(na_object=""))
TypeError: Cannot find common instance for incompatible dtype instances
np.strings
命名空間#
字串操作將在 np.strings
命名空間中可用,該命名空間將填充字串 ufunc
>>> np.strings.upper((np.array(["hello", "world"], dtype=StringDType())
array(['HELLO', 'WORLD'], dtype=StringDType())
>>> isinstance(np.strings.upper, np.ufunc)
True
我們認為 np.strings
比 np.char
更直覺,並且最終將取代 np.char
,一旦下游函式庫根據 SPEC-0 支援的最低 NumPy 版本夠新,它們就可以安全地切換到 np.strings
,而無需任何基於 NumPy 版本的條件邏輯。
序列化#
由於字串資料儲存在陣列緩衝區外部,因此序列化為 npy
格式將需要格式修訂,以支援儲存可變寬度的 sidecare 資料。我們不打算作為此工作的一部分執行此操作,因此我們不計劃在未指定 allow_pickle=True
的情況下支援序列化為 npy
或 npz
格式。
這延續了物件字串陣列目前的狀況,物件字串陣列只能使用 allow_pickle=True
選項儲存到 npy
檔案。
未來我們可能會決定新增對此的支援,但應注意不要破壞 NumPy 外部可能未維護的解析器。
StringDType
的 C API#
C API 的目標是向使用者隱藏字串資料如何在堆積上儲存的細節,並為讀取和寫入儲存在 StringDType
陣列中的字串提供執行緒安全介面。為了實現這一點,我們已決定將字串分成兩種不同的封裝和解封裝表示形式。封裝字串直接存在於陣列緩衝區中,並且可能包含足夠短的字串的字串資料,或儲存字串字元的堆積分配的中繼資料。解封裝字串公開字串的大小(以位元組為單位)以及指向字串資料的 char *
指標。
為了存取儲存在 NumPy 陣列中的字串的解封裝字串資料,使用者必須呼叫函數以將封裝字串載入到解封裝字串中,或呼叫另一個函數以將解封裝字串封裝到陣列中。這些操作都需要指向陣列條目的指標和對分配器結構的引用。分配器管理將字串資料儲存在堆積上所需的簿記。將此簿記集中在分配器中意味著我們可以自由地更改底層分配策略。我們也透過使用互斥鎖保護對分配器的存取來確保執行緒安全。
在下面,我們更詳細地描述此設計,列舉我們想要新增到 C API 的類型和函數。在下一節中,我們描述了我們計劃使用此 API 實作的記憶體佈局和堆積分配策略。
PyArray_StringDType
和 PyArray_StringDTypeObject
結構#
我們將公開 StringDType
元類別的結構,以及 StringDType
實例類型的結構。前一個 PyArray_StringDType
將在 C API 中可用,其方式與其他用於寫入 ufunc 和轉換迴圈的 PyArray_DTypeMeta
實例相同。此外,我們將公開以下結構
struct PyArray_StringDTypeObject {
PyArray_Descr base;
// The object representing a null value
PyObject *na_object;
// Flag indicating whether or not to coerce arbitrary objects to strings
char coerce;
// Flag indicating the na object is NaN-like
char has_nan_na;
// Flag indicating the na object is a string
char has_string_na;
// If nonzero, indicates that this instance is owned by an array already
char array_owned;
// The string data to use when a default string is needed
npy_static_string default_string;
// The name of the missing data object, if any
npy_static_string na_name;
// the allocator should only be directly accessed after
// acquiring the allocator_lock and the lock should
// be released immediately after the allocator is
// no longer needed
npy_string_allocator *allocator;
}
公開此定義可簡化未來與其他 dtype 的整合。
字串和分配器類型#
解封裝字串在 C API 中以 npy_static_string
類型表示,它將以以下定義公開
struct npy_static_string {
size_t size;
const char *buf;
};
其中 size
是字串的大小(以位元組為單位),而 buf
是指向包含字串資料的 UTF-8 編碼位元組串流開頭的 const 指標。這是字串的唯讀檢視,我們不會公開修改這些字串的公共介面。我們不會在位元組串流中附加尾隨空字元,因此嘗試將 buf
欄位傳遞給期望 C 字串的 API 的使用者必須建立帶有尾隨空字元的副本。未來,如果複製到以 null 終止的緩衝區中被證明對 C API 的下游使用者來說成本過高,我們可能會決定始終寫入尾隨空位元組。
此外,我們將公開兩個不透明的結構,npy_packed_static_string
和 npy_string_allocator
。StringDType
NumPy 陣列中的每個條目都將儲存 npy_packed_static_string
的內容;字串的封裝表示形式。字串資料直接儲存在封裝字串中,或儲存在堆積上,在與陣列相關聯的描述元實例相關聯的單獨 npy_string_allocator
結構管理的分配中。封裝字串的精確佈局和用於在堆積上分配資料的策略將不會公開,使用者不應依賴這些細節。
新的 C API 函數#
我們計劃公開的 C API 函數分為兩類:用於取得和釋放分配器鎖定的函數,以及用於載入和封裝字串的函數。
取得和釋放分配器#
用於取得和釋放分配器的主要介面是以下一對靜態內聯函數
static inline npy_string_allocator *
NpyString_acquire_allocator(PyArray_StringDTypeObject *descr)
static inline void
NpyString_release_allocator(npy_string_allocator *allocator)
第一個函數取得附加到描述元實例的分配器鎖定,並傳回指向與描述元關聯的分配器的指標。然後,執行緒可以使用分配器來載入現有的封裝字串或將新的字串封裝到陣列中。一旦完成需要分配器的操作,則必須釋放分配器鎖定。在呼叫 NpyString_release_allocator
後使用分配器可能會導致資料競爭或記憶體損壞。
在某些情況下,同時使用多個分配器很方便。例如,add
ufunc 接受兩個字串陣列並產生第三個字串陣列。這表示 ufunc 迴圈需要三個分配器,才能夠載入每個運算元的字串,並將結果封裝到輸出陣列中。由於輸入和輸出運算元不必是不同的物件,並且運算元可能因為是相同的陣列而共用分配器,因此這也變得更加棘手。原則上,我們可以要求使用者在 ufunc 迴圈內取得和釋放鎖定,但是與在迴圈設定中取得所有三個分配器並在迴圈結束後同時釋放它們相比,這將增加很大的效能開銷。
為了處理這些情況,我們也將公開這兩個函數的變體,這些變體接受任意數量的描述元和分配器(NpyString_acquire_allocators
和 NpyString_release_allocators
)。公開這些函數可以輕鬆地編寫同時處理多個分配器的程式碼。簡單地多次呼叫 NpyString_acquire_allocator
和 NpyString_release_allocator
的簡單方法將導致未定義的行為,因為當 ufunc 運算元共用描述元時,會嘗試在同一執行緒中多次取得相同的鎖定。多描述元變體會在嘗試取得鎖定之前檢查相同的描述元,從而避免未定義的行為。為了做正確的事情,使用者只需選擇變體來取得或釋放分配器,該變體接受與他們需要處理的描述元數量相同的數量。
封裝和載入字串#
存取字串由以下函數仲介
int NpyString_load(
npy_string_allocator *allocator,
const npy_packed_static_string *packed_string,
npy_static_string *unpacked_string)
如果發生執行緒錯誤或損壞阻止存取堆積分配,此函數會傳回 -1。成功時,它可以傳回 1 或 0。如果它傳回 1,則表示封裝字串的內容是空字串,並且在這種情況下可以發生處理空字串的特殊邏輯。如果函數傳回 0,則表示可以從 unpacked_string
讀取 packed_string
的內容。
封裝字串可以透過以下函數之一發生
int NpyString_pack(
npy_string_allocator *allocator,
npy_packed_static_string *packed_string,
const char *buf, size_t size)
int NpyString_pack_null(
npy_string_allocator *allocator,
npy_packed_static_string *packed_string)
第一個函數將 buf
的前 size
個元素的內容封裝到 packed_string
中。第二個函數將空字串封裝到 packed_string
中。這兩個函數都會使與封裝字串關聯的任何先前的堆積分配失效,並且仍在作用域中的舊解封裝表示形式在封裝字串後無效。這兩個函數在成功時傳回 0,在失敗時傳回 -1,例如,如果 malloc
失敗。
C API 用法範例#
載入字串#
假設我們正在為 StringDType
撰寫 ufunc 實作。如果給定指向 StringDType
陣列條目開頭的 const char *buf
指標,以及指向陣列描述元的 PyArray_Descr *
指標,則可以像這樣存取底層字串資料
npy_string_allocator *allocator = NpyString_acquire_allocator(
(PyArray_StringDTypeObject *)descr);
npy_static_string sdata = {0, NULL};
npy_packed_static_string *packed_string = (npy_packed_static_string *)buf;
int is_null = 0;
is_null = NpyString_load(allocator, packed_string, &sdata);
if (is_null == -1) {
// failed to load string, set error
return -1;
}
else if (is_null) {
// handle missing string
// sdata->buf is NULL
// sdata->size is 0
}
else {
// sdata->buf is a pointer to the beginning of a string
// sdata->size is the size of the string
}
NpyString_release_allocator(allocator);
封裝字串#
此範例示範如何將新字串封裝到陣列中
char *str = "Hello world";
size_t size = 11;
npy_packed_static_string *packed_string = (npy_packed_static_string *)buf;
npy_string_allocator *allocator = NpyString_acquire_allocator(
(PyArray_StringDTypeObject *)descr);
// copy contents of str into packed_string
if (NpyString_pack(allocator, packed_string, str, size) == -1) {
// string packing failed, set error
return -1;
}
// packed_string contains a copy of "Hello world"
NpyString_release_allocator(allocator);
Cython 支援和緩衝區協定#
StringDType
不可能支援 Python 緩衝區協定,因此除非未來在 Cython 中新增特殊支援,否則 Cython 將不支援用於 StringDType
陣列的慣用類型記憶體檢視語法。我們有一些初步的想法,可以更新緩衝區協定[9],或使用 Arrow C 資料介面[10]來公開對於在緩衝區協定中沒有意義的 DType 的 NumPy 陣列,但這些努力可能不會及時實現以用於 NumPy 2.0。這表示調整使用固定寬度字串陣列的舊版 Cython 程式碼以使用 StringDType
將會很麻煩。調整使用物件字串陣列的程式碼應該很簡單,因為物件陣列也不受緩衝區協定支援,並且可能沒有類型或在 Cython 中具有 object
類型。
我們將為作為此工作一部分新增的公共 C API 函數新增 Cython nogil
包裝函式,以簡化與下游 Cython 程式碼的整合。
記憶體佈局和管理堆積分配#
下面我們提供了我們選擇的記憶體佈局的詳細描述,但在深入探討之前,我們想觀察到上面描述的 C API 沒有公開任何這些細節。以下所有內容都可能在未來進行修訂、改進和更改,因為字串資料的精確記憶體佈局未公開。
記憶體佈局和小字串最佳化#
每個陣列元素都表示為一個聯合,在小端架構上具有以下定義
typedef struct _npy_static_vstring_t {
size_t offset;
size_t size_and_flags;
} _npy_static_string_t;
typedef struct _short_string_buffer {
char buf[sizeof(_npy_static_string_t) - 1];
unsigned char size_and_flags;
} _short_string_buffer;
typedef union _npy_static_string_u {
_npy_static_string_t vstring;
_short_string_buffer direct_buffer;
} _npy_static_string_u;
_npy_static_vstring_t
表示形式最適用於直接或在 arena 分配中表示堆積上的字串,offset
欄位包含地址的 size_t
表示形式,或 arena 分配中的整數偏移量。_short_string_buffer
表示形式最適用於小字串最佳化,字串資料儲存在 direct_buffer
欄位中,大小儲存在 size_and_flags
欄位中。在這兩種情況下,size_and_flags
欄位都儲存字串的 size
和位元旗標。小字串將大小儲存在緩衝區的最後四個位元中,為旗標保留 size_and_flags
的前四個位元。堆積字串或 arena 分配中的字串使用最高有效位元作為旗標,為字串大小保留前導位元組。值得指出的是,此選擇限制了允許儲存在陣列中的最大字串大小,尤其是在 32 位元系統上,其中限制為每個字串 16 MB - 小到足以擔心影響實際工作流程。
在大端系統上,佈局是相反的,size_and_flags
欄位首先出現在結構中。這允許實作始終使用 size_and_flags
欄位的最高有效位元作為旗標。這些結構的端序相關佈局是一個實作細節,並且未在 API 中公開公開。
字串是否直接儲存在 arena 緩衝區或堆積中,是透過在字串資料上設定 NPY_OUTSIDE_ARENA
和 NPY_STRING_LONG
旗標來發出訊號。由於堆積分配字串的最大大小限制為最大 7 位元組無符號整數的大小,因此對於有效的堆積字串,永遠不會設定這些旗標。
有關每種記憶體佈局中字串的一些視覺範例,請參閱記憶體佈局範例。
Arena 分配器#
在 64 位元系統上長度超過 15 個位元組,在 32 位元系統上長度超過 7 個位元組的字串儲存在陣列緩衝區外部的堆積上。分配的簿記由附加到與陣列關聯的 StringDType
實例的 arena 分配器管理。分配器將作為不透明的 npy_string_allocator
結構公開公開。在內部,它具有以下佈局
struct npy_string_allocator {
npy_string_malloc_func malloc;
npy_string_free_func free;
npy_string_realloc_func realloc;
npy_string_arena arena;
PyThread_type_lock *allocator_lock;
};
這允許我們將記憶體分配函數組合在一起,並在需要時在執行階段選擇不同的分配函數。分配器的使用受互斥鎖保護,請參閱下文以取得有關執行緒安全性的更多討論。
記憶體分配由 npy_string_arena
結構成員處理,該成員具有以下佈局
struct npy_string_arena {
size_t cursor;
size_t size;
char *buffer;
};
其中 buffer
是指向堆積分配 arena 開頭的指標,size
是該分配的大小,而 cursor
是 arena 中最後一個 arena 分配結束的位置。arena 使用指數擴展緩衝區填充,擴展因子為 1.25。
arena 中的每個字串條目都以大小為前綴,大小儲存在 char
或 size_t
中,具體取決於字串的長度。長度介於 16 或 8(取決於架構)和 255 之間的字串儲存在 char
大小中。我們在內部將這些稱為「中等」字串。此選擇將每個中等長度字串在堆積上儲存的開銷減少了 7 個位元組。arena 中長度超過 255 個位元組的字串已設定 NPY_STRING_LONG
旗標。
如果釋放了封裝字串的內容,然後將其分配給大小與最初儲存在封裝字串中的字串相同或更小的新字串,則會重複使用現有的短字串或 arena 分配。但是,有一個例外,當 arena 中的字串被短字串覆寫時,arena 中繼資料會遺失,並且 arena 分配無法重複使用。
如果字串被放大,則無法使用 arena 緩衝區中的現有空間,因此我們改為透過 malloc
直接在堆積上分配空間,並設定 NPY_STRING_OUTSIDE_ARENA
和 NPY_STRING_LONG
旗標。請注意,在這種情況下,即使對於長度小於 255 個位元組的字串,也可以設定 NPY_STRING_LONG
。由於堆積位址會覆寫 arena 偏移量,因此未來的字串替換將儲存在堆積上或直接在陣列緩衝區中作為短字串。
無論字串儲存在何處,一旦字串被初始化,它都會標記 NPY_STRING_INITIALIZED
旗標。這讓我們可以清楚地區分未初始化的空字串和已變異為空字串的字串。
分配的大小儲存在 arena 中,以便在字串變異時重複使用 arena 分配。原則上,我們可以不允許重複使用 arena 緩衝區,並且不將大小儲存在 arena 中。根據確切的使用模式,這可能會或可能不會節省記憶體或提高效能。就目前而言,我們傾向於避免在字串變異時進行不必要的堆積分配,但原則上,我們可以透過選擇始終將變異的 arena 字串儲存為堆積字串並忽略 arena 分配來簡化實作。請參閱下文以取得有關我們如何在多執行緒環境中處理 NumPy 陣列的可變性的更多詳細資訊。
使用每個陣列的 arena 分配器可確保附近陣列元素的字串緩衝區通常在堆積上附近。我們不保證相鄰的陣列元素在堆積上是連續的,以支援小字串最佳化、遺失資料,並允許陣列條目的變異。請參閱下文以取得有關這些主題如何影響記憶體佈局的更多討論。
變異和執行緒安全性#
當多個執行緒存取和變異陣列時,變異會引入資料競爭和釋放後使用錯誤的可能性。此外,如果我們在 arena 緩衝區中分配變異的字串,並強制舊字串被新字串取代的連續儲存,則變異單個字串可能會觸發重新分配整個陣列的 arena 緩衝區。與物件字串陣列或固定寬度字串相比,這是一種病態的效能下降。
一種解決方案是停用變異,但不可避免地,會有物件字串陣列的下游用途,我們會希望支援這些用途,這些用途會變異陣列元素。
相反,我們選擇將附加到 PyArray_StringDType
實例的 npy_string_allocator
實例與 PyThread_type_lock
互斥鎖配對。靜態字串 C API 中允許操作堆積分配資料的任何函數都接受 allocator
引數。為了正確使用 C API,執行緒必須在任何使用 allocator
之前取得分配器互斥鎖。
PyThread_type_lock
互斥鎖相對來說比較笨重,並且不提供更複雜的鎖定基本類型,這些基本類型允許同時進行多個讀取器。作為 GIL 移除專案的一部分,CPython 正在向 C API 新增新的同步基本類型,供像 NumPy 這樣的專案使用。當這種情況發生時,我們可以更新鎖定策略以允許同時進行多個讀取執行緒,以及 NumPy 中執行緒錯誤的其他修復,這些修復將在移除 GIL 後需要。
釋放字串#
在捨棄或重複使用封裝字串之前,必須釋放現有的字串。API 的建構要求所有字串都必須執行此操作,即使對於沒有堆積分配的短字串也是如此。在所有情況下,封裝字串中的所有資料都會歸零,旗標除外,旗標會保留。
記憶體佈局範例#
我們為三種可能的字串記憶體佈局建立了說明圖。所有圖表都假設為 64 位元小端架構。
短字串直接將字串資料儲存在陣列緩衝區中。在小端架構上,字串資料首先出現,然後是一個位元組,該位元組允許四個旗標的空間,並將字串的大小儲存為最後 4 個位元中的無符號整數。在此範例中,字串內容為「Hello world」,大小為 11。旗標表示此字串儲存在 arena 外部並且已初始化。
Arena 字串將字串資料儲存在由附加到陣列的 StringDType
實例管理的堆積分配 arena 緩衝區中。在此範例中,字串內容為「Numpy is a very cool library」,儲存在 arena 分配中的偏移量 0x94C
處。請注意,size
儲存了兩次,一次在 size_and_flags
欄位中,一次在 arena 分配中。如果字串變異,這有助於重複使用 arena 分配。另請注意,由於字串的長度足夠小,可以容納在 unsigned char
中,因此這是「中等」長度的字串,並且大小在 arena 分配中僅需要一個位元組。大於 255 個位元組的 arena 字串將需要在 arena 中 8 個位元組來以 size_t
儲存大小。唯一設定的旗標表示此字串已初始化。
堆積字串將字串資料儲存在 PyMem_RawMalloc
傳回的緩衝區中,並且不儲存 arena 緩衝區的偏移量,而是直接儲存 malloc
傳回的堆積位址。在此範例中,字串內容為「Numpy is a very cool library」,並儲存在堆積位址 0x4d3d3d3
處。字串設定了三個旗標,表示它是儲存在 arena 外部的「長」字串(例如,不是短字串),並且已初始化。請注意,如果此字串儲存在 arena 內部,則它不會設定長字串旗標,因為它需要少於 256 個位元組的儲存空間。
空字串和遺失資料#
我們選擇的佈局的好處是,由 calloc
傳回的新建立的陣列緩衝區將是一個填充空字串的陣列,因為沒有設定旗標的字串是未初始化的零長度 arena 字串。這不是空字串的唯一有效表示形式,因為可能會設定其他旗標來指示空字串與預先存在的短字串或 arena 字串相關聯。
遺失的字串將具有相同的表示形式,但它們始終在旗標欄位中設定一個旗標 NPY_STRING_MISSING
。使用者將需要在存取解封裝字串緩衝區之前檢查字串是否為 null,並且我們已設定 C API,以便在每次解封裝字串時強制執行 null 檢查。遺失和空字串都可以根據封裝字串表示形式中的資料偵測到,並且不需要 arena 分配中的相應空間或額外的堆積分配。
實作#
我們有一個開放的提取請求[12],已準備好合併到 NumPy 中,新增 StringDType。
我們建立了一個 Pandas 的開發分支,該分支支援使用 StringDType
[13]建立 Pandas 資料結構。這說明了在下游函式庫中支援 StringDType
所需的重構,這些函式庫大量使用物件字串陣列。
如果被接受,此 NEP 剩餘工作的大部分將是用於更新文件和潤飾 NumPy 2.0 版本。我們已經完成了以下事項:
建立一個
np.strings
命名空間,並在該命名空間中直接公開字串 ufuncs。將
StringDType
實作從外部擴充模組移至 NumPy 中,並在適當的地方重構 NumPy。這個新的 DType 將在一個大型 Pull Request 中加入,包含文件更新。在可能的情況下,我們會在發布主要 Pull Request 之前,將與StringDType
無關的修復和重構提取到較小的 Pull Request 中。
我們將繼續進行以下事項:
處理 NumPy 中與新 DType 相關的剩餘問題。特別是,我們已經意識到
NumPy
中剩餘的copyswap
用法應遷移為使用 cast 或尚未加入的單元素複製 DType API 插槽。我們還需要確保 DType 類別可以在 Python API 中與 DType 實例互換使用(在任何有意義的地方),並在所有其他可以傳入 DType 實例但 DType 類別不適用的地方加入有用的錯誤訊息。
替代方案#
主要的替代方案是維持現狀,並提供物件陣列作為變長字串陣列的解決方案。雖然這可以運作,但這意味著立即的記憶體使用量和效能改進,以及未來的效能改進,將不會在短期內實施,並且 NumPy 相較於其他對文字資料陣列有更好支援的生態系統,將會失去關聯性。
我們不認為提議的 DType 與經過改進的固定寬度二進位字串 DType(可以表示任意二進位資料或任何編碼的文字)互斥,並且一旦在加入 StringDType
後,NumPy 中對字串資料的整體支援得到改善,未來加入這樣的 DType 將會更容易。
討論#
參考文獻與腳註#
著作權#
本文檔已置於公共領域。