NEP 43 — 增強 UFuncs 的可擴展性#

標題:

增強 UFuncs 的可擴展性

作者:

Sebastian Berg

狀態:

草稿

類型:

標準

建立時間:

2020-06-20

注意

此 NEP 是系列中的第四篇

  • NEP 40 解釋 NumPy dtype 實作的缺點。

  • NEP 41 概述我們提議的替代方案。

  • NEP 42 描述新設計的資料型別相關 API。

  • NEP 43 (本文件) 描述新設計的通用函數 API。

摘要#

先前的 NEP 42 提議建立新的 DTypes,這些 DTypes 可以由 NumPy 外部的使用者定義。NEP 42 的實作將使使用者能夠建立具有自訂 dtype 和儲存值的陣列。此 NEP 概述 NumPy 未來將如何操作具有自訂 dtype 的陣列。在 NumPy 陣列上運作的最重要函數是所謂的「通用函數」(ufunc),其中包括所有數學函數,例如 np.addnp.multiply,甚至 np.matmul。這些 ufuncs 必須在具有不同資料型別的多個陣列上有效率地運作。

此 NEP 提議擴展 ufuncs 的設計。它在可以操作許多不同 dtype(例如浮點數或整數)的 ufunc 與新的 ArrayMethod 之間做出新的區別,後者定義了特定 dtype 的有效操作。

注意

私有和外部 API 的詳細資訊可能會變更,以反映使用者評論和實作限制。底層原則和選擇不應有顯著變化。

動機與範疇#

此 NEP 的目標是擴展通用函數以支援 NEP 41 和 42 中詳述的新 DType 系統。雖然主要動機是啟用新的使用者定義 DTypes,但這也將顯著簡化為 NumPy 字串或結構化 DTypes 定義通用函數的過程。到目前為止,由於其參數化性質(比較 NEP 41 和 42),例如字串長度所產生的困難,NumPy 的任何函數(例如 np.addnp.equal)都不支援這些 DTypes。

陣列上的函數必須處理許多不同的步驟,這些步驟在「UFunc 呼叫中涉及的步驟」章節中有更詳細的描述。最重要的步驟是

  • 組織定義特定 DTypes 的 ufunc 呼叫所需的所有功能。這通常稱為「內部迴圈」。

  • 處理找不到完全匹配實作的輸入。例如,當 int32float64 相加時,int32 會轉換為 float64。這需要一個不同的「提升」步驟。

在組織和定義這些之後,我們需要

  • 定義使用者 API 以自訂上述兩點。

  • 允許方便地重複使用現有功能。例如,表示物理單位的 DType(例如米)應該能夠回退到 NumPy 現有的數學實作。

此 NEP 詳細說明如何在 NumPy 中實現這些需求

  • 目前作為 ufunc 定義一部分的所有 DTyper 特定功能都將定義為新的 ArrayMethod 物件的一部分。此 ArrayMethod 物件將是描述任何在陣列上運作的函數的新首選方法。

  • Ufuncs 將調度到 ArrayMethod,並可能使用提升來尋找要使用的正確 ArrayMethod。這將在「提升和調度」章節中描述。

新的 C-API 將在每個章節中概述。未來的 Python API 預計會非常相似,並且 C-API 以 Python 程式碼的形式呈現,以提高可讀性。

此 NEP 提議對 NumPy ufunc 內部結構進行大規模但必要的重構。這種現代化不會直接影響終端使用者,不僅是新 DTypes 的必要步驟,本身也是一項維護工作,預計有助於未來改進 ufunc 機制。

雖然提議的最重要的重構是新的 ArrayMethod 物件,但最大的長期考量是提升和調度的 API 選擇。

向後相容性#

一般向後相容性問題也已在 NEP 41 中列出。

絕大多數使用者不應看到超出 NumPy 版本典型的任何變更。擬議的變更影響三個主要使用者或用例

  1. Numba 套件直接存取 NumPy C 迴圈,並直接修改 NumPy ufunc 結構以達到其自身目的。

  2. Astropy 使用自己的「型別解析器」,這表示從現有型別解析到新的預設 Promoter 的預設切換需要謹慎處理。

  3. 目前可以為 dtype *實例* 註冊迴圈。這在理論上對於結構化 dtype 很有用,並且是在此處提出的 DType 解析步驟*之後*發生的解析步驟。

此 NEP 將盡力保持盡可能高的向後相容性。然而,這兩個專案都已表示願意適應重大變更。

NumPy 能夠提供向後相容性的主要原因是

  • 現有的內部迴圈可以被包裝,為呼叫增加間接性,但保持完全的向後相容性。在這種情況下,get_loop 函數可以搜尋現有的內部迴圈函數(直接儲存在 ufunc 上),以便即使在可能直接存取結構的情況下也能保持完全相容性。

  • 舊式型別解析器可以作為後備調用(可能會快取結果)。解析器可能需要調用兩次(一次用於 DType 解析,一次用於 resolve_descriptor 實作)。

  • 在大多數情況下,後備到舊式型別解析器應該處理為此類結構化 dtype 實例定義的迴圈。這是因為如果沒有其他 np.Void 實作,則舊式後備至少在初始階段會保留舊行為。

遮罩型別解析器特別是*不會*繼續受到支援,但沒有已知的用戶(包括 NumPy 本身,它僅使用預設版本)。

此外,對於*調用*而不是提供正常或遮罩型別解析器,將不會進行任何相容性嘗試。因為 NumPy 將僅將其用作後備。沒有已知的此(未記錄)可能性的使用者。

雖然上述變更可能會破壞某些工作流程,但我們相信長期改進遠遠超過這一點。此外,astropy 和 Numba 等套件能夠適應,因此終端使用者可能需要更新其函式庫,但不需要更新其程式碼。

使用與影響#

此 NEP 重組了 NumPy 陣列上的操作在 NumPy 內部以及對於外部實作人員的定義方式。此 NEP 主要涉及那些為自訂 DTypes 擴展 ufuncs 或建立自訂 ufuncs 的人員。它並非旨在最終確定所有潛在用例,而是重組 NumPy 以使其可擴展,並允許在出現新問題或功能請求時解決這些問題或請求。

概述與終端使用者 API#

為了概述此 NEP 如何提議結構化 ufuncs,以下描述擬議的重組對終端使用者的潛在影響。

當考慮只有單一輸入的 ufunc 時,通用函數很像在陣列的 DType 上定義的 Python 方法

res = np.positive(arr)

可以(概念上)實作為

positive_impl = arr.dtype.positive
res = positive_impl(arr)

然而,與方法不同,positive_impl 不儲存在 dtype 本身。它實際上是特定 DType 的 np.positive 實作。目前的 NumPy 使用通用函數中的 dtype(或更精確的 signature)屬性部分公開了這種「實作選擇」,儘管這些很少使用

np.positive(arr, dtype=np.float64)

強制 NumPy 使用專為 Float64 DType 撰寫的 positive_impl

此 NEP 透過建立一個新物件來表示 positive_impl,使這種區別更加明確

positive_impl = np.positive.resolve_impl((type(arr.dtype), None))
# The `None` represents the output DType which is automatically chosen.

雖然建立 positive_impl 物件和 resolve_impl 方法是此 NEP 的一部分,但以下程式碼

res = positive_impl(arr)

可能最初不會實作,並且不是重新設計的核心。

一般來說,NumPy 通用函數可以接受許多輸入。這需要透過考慮所有輸入來查找實作,並使 ufuncs 成為相對於輸入 DTypes 的「多方法」

add_impl = np.add.resolve_impl((type(arr1.dtype), type(arr2.dtype), None))

此 NEP 定義 positive_impladd_impl 將如何表示為新的 ArrayMethod,該方法可以在 NumPy 外部實作。此外,它定義了 resolve_impl 將如何實作和解決調度和提升。

在查看「UFunc 呼叫中涉及的步驟」章節後,這種拆分的原因可能會更清楚。

定義新的 ufunc 實作#

以下是一個模擬,說明如何在 ufunc 中新增新的實作,在本例中是用於定義字串相等性。

class StringEquality(BoundArrayMethod):
    nin = 1
    nout = 1
    # DTypes are stored on the BoundArrayMethod and not on the internal
    # ArrayMethod, to reference cyles.
    DTypes = (String, String, Bool)

    def resolve_descriptors(self: ArrayMethod, DTypes, given_descrs):
        """The strided loop supports all input string dtype instances
        and always returns a boolean. (String is always native byte order.)

        Defining this function is not necessary, since NumPy can provide
        it by default.

        The `self` argument here refers to the unbound array method, so
        that DTypes are passed in explicitly.
        """
        assert isinstance(given_descrs[0], DTypes[0])
        assert isinstance(given_descrs[1], DTypes[1])
        assert given_descrs[2] is None or isinstance(given_descrs[2], DTypes[2])

        out_descr = given_descrs[2]  # preserve input (e.g. metadata)
        if given_descrs[2] is None:
            out_descr = DTypes[2]()

        # The operation is always "no" casting (most ufuncs are)
        return (given_descrs[0], given_descrs[1], out_descr), "no"

    def strided_loop(context, dimensions, data, strides, innerloop_data):
        """The 1-D strided loop, similar to those used in current ufuncs"""
        # dimensions: Number of loop items and core dimensions
        # data: Pointers to the array data.
        # strides: strides to iterate all elements
        n = dimensions[0]  # number of items to loop over
        num_chars1 = context.descriptors[0].itemsize
        num_chars2 = context.descriptors[1].itemsize

        # C code using the above information to compare the strings in
        # both arrays.  In particular, this loop requires the `num_chars1`
        # and `num_chars2`.  Information which is currently not easily
        # available.

np.equal.register_impl(StringEquality)
del StringEquality  # may be deleted.

此定義將足以建立新的迴圈,並且該結構允許未來擴展;這已經是在 NumPy 本身中實作轉換所必需的。我們在此處使用 BoundArrayMethodcontext 結構。這些將在稍後詳細描述和說明。簡而言之

  • context 是 Python 傳遞給其方法的 self 的概括。

  • BoundArrayMethod 等同於 Python 的區別,即 class.method 是一個方法,而 class().method 返回一個「綁定」方法。

自訂調度和提升#

當調用 np.positive.resolve_impl() 時查找正確的實作在很大程度上是一個實作細節。但是,在某些情況下,當沒有實作完全匹配請求的 DTypes 時,可能需要影響此過程

np.multiple.resolve_impl((Timedelta64, Int8, None))

將不會有完全匹配,因為 NumPy 僅具有將 Timedelta64Int64 相乘的實作。在簡單的情況下,NumPy 將使用預設的提升步驟來嘗試查找正確的實作,但為了實作上述步驟,我們將允許以下操作

def promote_timedelta_integer(ufunc, dtypes):
    new_dtypes = (Timdelta64, Int64, dtypes[-1])
    # Resolve again, using Int64:
    return ufunc.resolve_impl(new_dtypes)

np.multiple.register_promoter(
    (Timedelta64, Integer, None), promote_timedelta_integer)

其中 Integer 是一個抽象 DType(比較 NEP 42)。

UFunc 呼叫中涉及的步驟#

在深入研究更詳細的 API 選擇之前,有必要回顧 NumPy 中通用函數呼叫中涉及的步驟。

UFunc 呼叫分為以下步驟

  1. 處理 __array_ufunc__ 協定

  2. 提升和調度

    • 給定所有輸入的 DTypes,找到正確的實作。例如,float64int64 或使用者定義 DType 的實作。

    • 當不存在完全匹配的實作時,必須執行*提升*。例如,將 float32float64 相加是透過首先將 float32 轉換為 float64 來實作的。

  3. 參數化 dtype 解析

    • 一般來說,每當輸出 DType 是參數化的,就必須找到(解析)參數。

    • 例如,如果迴圈將兩個字串相加,則必須定義正確的輸出(以及可能的輸入)dtypes。S5 + S4 -> S9,而 upper 函數的簽名為 S5 -> S5

    • 當它們不是參數化的,則提供預設實作,該實作會填入預設 dtype 實例(例如,確保原生位元組順序)。

  4. 準備迭代

    • 此步驟主要由內部的 NpyIter(迭代器)處理。

    • 分配執行轉換所需的所有輸出和臨時緩衝區。這*需要*在步驟 3 中解析的 dtypes。

    • 找到最佳迭代順序,其中包括有效實作廣播的資訊。例如,將單一值加到陣列會重複相同的值。

  5. 設定和提取 C 級函數

    • 如果需要,分配臨時工作空間。

    • 找到 C 實作的輕量級內部迴圈函數。查找內部迴圈函數可以在未來實現專門的實作。例如,轉換目前優化連續轉換,而縮減具有目前在內部迴圈函數本身內處理的優化。

    • 指示內部迴圈是否需要 Python API,或者是否可以釋放 GIL(以允許執行緒)。

    • 清除浮點異常標誌。

  6. 執行實際計算

    • 執行 DType 特定的內部迴圈函數。

    • 內部迴圈可能需要存取其他資料,例如 dtypes 或上一步中設定的其他資料。

    • 內部迴圈函數可能會被調用未定義的次數。

  7. 最終化

    • 釋放步驟 5 中分配的任何臨時工作空間。

    • 檢查浮點異常標誌。

    • 返回結果。

ArrayMethod 提供了一個概念,用於將步驟 3 到 6 以及部分步驟 7 分組。然而,新的 ufunc 或 ArrayMethod 的實作人員通常不需要自訂步驟 4 或 6 中的行為,NumPy 可以並且確實提供了這些行為。對於 ArrayMethod 實作人員來說,要自訂的核心步驟是步驟 3 和步驟 5。這些步驟提供了自訂的內部迴圈函數以及潛在的內部迴圈特定設定。進一步的自訂是可能的,並且預期作為未來的擴展。

步驟 2 是提升和調度,將使用新的 API 進行重組,以便在必要時自訂此過程。

步驟 1 列出是為了完整性,並且不受此 NEP 的影響。

以下草圖概述了步驟 2 到 6,重點說明了如何處理 dtypes 以及哪些部分是可自訂的(「已註冊」),哪些部分由 NumPy 處理

_images/nep43-sketch.svg

ArrayMethod#

此 NEP 的核心提議是建立 ArrayMethod,作為一個描述每個特定於給定 DTypes 集合的實作的物件。我們使用 class 語法來描述建立新的 ArrayMethod 物件所需的信息

class ArrayMethod:
    name: str  # Name, mainly useful for debugging

    # Casting safety information (almost always "safe", necessary to
    # unify casting and universal functions)
    casting: Casting = "no"

    # More general flags:
    flags: int

    def resolve_descriptors(self,
            Tuple[DTypeMeta], Tuple[DType|None]: given_descrs) -> Casting, Tuple[DType]:
        """Returns the safety of the operation (casting safety) and the
        """
        # A default implementation can be provided for non-parametric
        # output dtypes.
        raise NotImplementedError

    @staticmethod
    def get_loop(Context : context, strides, ...) -> strided_loop_function, flags:
        """Returns the low-level C (strided inner-loop) function which
        performs the actual operation.

        This method may initially private, users will be able to provide
        a set of optimized inner-loop functions instead:

        * `strided_inner_loop`
        * `contiguous_inner_loop`
        * `unaligned_strided_loop`
        * ...
        """
        raise NotImplementedError

    @staticmethod
    def strided_inner_loop(
            Context : context, data, dimensions, strides, innerloop_data):
        """The inner-loop (equivalent to the current ufunc loop)
        which is returned by the default `get_loop()` implementation."""
        raise NotImplementedError

其中 Context 提供有關函數呼叫的大部分靜態資訊

class Context:
    # The ArrayMethod object itself:
    ArrayMethod : method

    # Information about the caller, e.g. the ufunc, such as `np.add`:
    callable : caller = None
    # The number of input arguments:
    int : nin = 1
    # The number of output arguments:
    int : nout = 1
    # The actual dtypes instances the inner-loop operates on:
    Tuple[DType] : descriptors

    # Any additional information required. In the future, this will
    # generalize or duplicate things currently stored on the ufunc:
    #  - The ufunc signature of generalized ufuncs
    #  - The identity used for reductions

以及 flags 儲存的屬性,用於指示是否

  • ArrayMethod 支援未對齊的輸入和輸出陣列

  • 內部迴圈函數需要 Python API (GIL)

  • NumPy 必須檢查浮點錯誤 CPU 標誌。

注意:預計會在必要時新增更多資訊。

呼叫 Context#

「context」物件類似於 Python 的 self,它被傳遞給所有方法。為了理解為什麼「context」物件是必要的及其內部結構,有必要記住 Python 方法可以用以下方式編寫(另請參閱 __get__ 的文件

class BoundMethod:
    def __init__(self, instance, method):
        self.instance = instance
        self.method = method

    def __call__(self, *args, **kwargs):
        return self.method.function(self.instance, *args, **kwargs)


class Method:
    def __init__(self, function):
        self.function = function

    def __get__(self, instance, owner=None):
        assert instance is not None  # unsupported here
        return BoundMethod(instance, self)

以下 method1method2 的行為相同

def function(self):
    print(self)

class MyClass:
    def method1(self):
        print(self)

    method2 = Method(function)

兩者都將印出相同的結果

>>> myinstance = MyClass()
>>> myinstance.method1()
<__main__.MyClass object at 0x7eff65436d00>
>>> myinstance.method2()
<__main__.MyClass object at 0x7eff65436d00>

這裡 self.instance 將是 Context 傳遞的所有資訊。Context 是一個概括,必須傳遞額外資訊

  • 與在單一類別實例上運作的方法不同,ArrayMethod 在許多輸入陣列以及多個 dtypes 上運作。

  • 上述 BoundMethod__call__ 僅包含對函數的單一呼叫。但是 ArrayMethod 必須調用 resolve_descriptors,然後將該資訊傳遞給內部迴圈函數。

  • Python 函數除了其外部範圍定義的狀態外,沒有其他狀態。在 C 語言中,Context 能夠在必要時提供額外狀態。

正如 Python 需要區分方法和綁定方法一樣,NumPy 將具有 BoundArrayMethod。這儲存了作為 Context 一部分的所有常數資訊,例如

幸運的是,大多數使用者甚至 ufunc 實作人員都不必擔心這些內部細節;就像很少有 Python 使用者需要了解 __get__ dunder 方法一樣。Context 物件或 C 結構為快速 C 函數提供所有必要的資料,NumPy API 根據需要建立新的 ArrayMethodBoundArrayMethod

ArrayMethod 規格#

這些規格提供了一個最小的初始 C-API,未來將會擴展,例如允許專門的內部迴圈。簡而言之,NumPy 目前依賴跨步內部迴圈,這將是最初定義 ufunc 的唯一允許方法。我們預計未來會新增 setup 函數或公開 get_loop

簡而言之,NumPy 目前依賴跨步內部迴圈,這將是最初定義 ufunc 的唯一允許方法。我們預計未來會新增 setup 函數或公開 get_loop

UFuncs 需要與轉換相同的資訊,給出以下定義(另請參閱 NEP 42 CastingImpl

  • 要傳遞給解析函數和內部迴圈的新結構

    typedef struct {
        PyObject *caller;  /* The ufunc object */
        PyArrayMethodObject *method;
    
        int nin, nout;
    
        PyArray_DTypeMeta **dtypes;
        /* Operand descriptors, filled in by resolve_desciptors */
        PyArray_Descr **descriptors;
    
        void *reserved;  // For Potential in threading (Interpreter state)
    } PyArrayMethod_Context
    

    此結構可能會附加以包含未來 NumPy 版本中的其他資訊,並包含所有常數迴圈元資料。我們可以對此結構進行版本控制,儘管對 ArrayMethod 本身進行版本控制可能更簡單。

    我們可以對此結構進行版本控制,儘管對 ArrayMethod 本身進行版本控制可能更簡單。

  • 與轉換類似,ufuncs 可能需要找到正確的迴圈 dtype 或指示迴圈僅能夠處理所涉及 DTypes 的某些實例(例如,僅限原生位元組順序)。這由 resolve_descriptors 函數處理(與 CastingImplresolve_descriptors 相同)

    NPY_CASTING
    resolve_descriptors(
            PyArrayMethodObject *self,
            PyArray_DTypeMeta *dtypes,
            PyArray_Descr *given_dtypes[nin+nout],
            PyArray_Descr *loop_dtypes[nin+nout]);
    

    該函數根據給定的 given_dtypes 填寫 loop_dtypes。這需要填寫輸出(s)的描述符。通常也必須找到輸入描述符,例如,確保內部迴圈需要時的原生位元組順序。

    在大多數情況下,ArrayMethod 將具有非參數化的輸出 DTypes,以便可以提供預設實作。

  • 額外的 void *user_data 通常會被類型化以擴展現有的 NpyAuxData * 結構

    struct {
        NpyAuxData_FreeFunc *free;
        NpyAuxData_CloneFunc *clone;
        /* To allow for a bit of expansion without breaking the ABI */
       void *reserved[2];
    } NpyAuxData;
    

    此結構目前主要用於 NumPy 內部轉換機制,到目前為止,必須提供 freeclone,儘管這可以放寬。與 NumPy 轉換不同,絕大多數 ufuncs 目前不需要此額外的暫存空間,但可能需要簡單的標記功能,例如用於實作警告(請參閱下面的「錯誤和警告處理」)。

    與 NumPy 轉換不同,絕大多數 ufuncs 目前不需要此額外的暫存空間,但可能需要簡單的標記功能,例如用於實作警告(請參閱下面的「錯誤和警告處理」)。為了簡化此操作,當未設定 user_data 時,NumPy 將傳遞單個零初始化的 npy_intp *。*請注意,可以將其作為 Context 的一部分傳遞。*

  • 為了簡化此操作,當未設定 user_data 時,NumPy 將傳遞單個零初始化的 npy_intp *。*請注意,可以將其作為 Context 的一部分傳遞。* 可選的 get_loop 函數最初不會公開,以避免最終確定 API,這也需要在轉換方面做出設計選擇

    innerloop *
    get_loop(
        PyArrayMethod_Context *context,
        int aligned, int move_references,
        npy_intp *strides,
        PyArray_StridedUnaryOp **out_loop,
        NpyAuxData **innerloop_data,
        NPY_ARRAYMETHOD_FLAGS *flags);
    

    NPY_ARRAYMETHOD_FLAGS 可以指示是否需要 Python API 以及是否必須檢查浮點錯誤。move_references 目前在 NumPy 轉換中內部使用。

  • 內部迴圈函數

    int inner_loop(PyArrayMethod_Context *context, ..., void *innerloop_data);
    

    將具有與目前內部迴圈相同的簽名,但有以下變更

    • 當返回 -1 而不是 0 時,返回一個值以指示錯誤。當返回 -1 時,必須設定 Python 錯誤。

    • 當返回 -1 時,必須設定 Python 錯誤。新的第一個參數 PyArrayMethod_Context * 用於方便地傳遞有關 ufunc 或描述符的潛在必要資訊。

    • void *innerloop_data 將會是 NpyAuxData **innerloop_data,由 get_loop 設定。如果 get_loop 沒有設定 innerloop_data,則會改為傳遞 npy_intp *(動機請見下方的錯誤處理)。

    注意: 由於 get_loop 預期為私有的,因此 innerloop_data 的確切實作方式可以修改,直到最終公開。

建立新的 BoundArrayMethod 將會使用 PyArrayMethod_FromSpec() 函數。簡寫方式將允許直接註冊到 ufunc,使用 PyUFunc_AddImplementationFromSpec()。規格預期包含以下內容(未來可能會擴展):

typedef struct {
    const char *name;  /* Generic name, mainly for debugging */
    int nin, nout;
    NPY_CASTING casting;
    NPY_ARRAYMETHOD_FLAGS flags;
    PyArray_DTypeMeta **dtypes;
    PyType_Slot *slots;
} PyArrayMethod_Spec;

討論與替代方案#

上述拆分為 ArrayMethodContext,以及額外要求的 BoundArrayMethod,是必要的拆分,反映了 Python 中方法和綁定方法的實作方式。

此要求的其中一個原因是,它允許在許多情況下儲存 ArrayMethod 物件,而無需持有對 DTypes 的參考,如果 DTypes 是動態建立(和刪除)的,這可能很重要。(這是一個複雜的主題,在目前的 Python 中沒有完整的解決方案,但此方法解決了關於型別轉換的問題。)

似乎沒有其他替代此結構的方案。將特定於 DType 的步驟與一般的 ufunc 分派和提升分離,對於允許未來的擴展和彈性絕對是必要的。此外,它允許統一型別轉換和 ufuncs。

由於 ArrayMethodBoundArrayMethod 的結構將是不透明的並且可以擴展,因此除了選擇將它們設為 Python 物件之外,幾乎沒有長期的設計意涵。

resolve_descriptors#

resolve_descriptors 方法可能是此 NEP 的主要創新,並且在 NEP 42 中型別轉換的實作中也至關重要。

透過確保每個 ArrayMethod 都提供 resolve_descriptors,我們為UFunc 呼叫中涉及的步驟中的步驟 3 定義了一個統一、清晰的 API。此步驟是分配輸出陣列所必需的,並且必須在準備型別轉換之前發生。

雖然傳回的型別轉換安全性(NPY_CASTING)對於通用函數幾乎總是「no」,但包含它有兩個很大的優點

  • -1 表示發生錯誤。如果設定了 Python 錯誤,則會引發該錯誤。如果沒有設定 Python 錯誤,則會將其視為「不可能」的轉換,並設定自訂錯誤。(這種區別對於 np.can_cast() 函數很重要,它應該引發第一個錯誤並在第二種情況下傳回 False,對於典型的 ufuncs 來說,這並不值得注意)。這一點正在考慮中,我們可能會使用 -1 來表示一般錯誤,並使用不同的傳回值來表示不可能的轉換。

  • 傳回型別轉換安全性對於 NEP 42 的型別轉換至關重要,並允許在那裡未修改地使用 ArrayMethod

  • 未來可能希望實作快速但不安全的實作。例如,對於 int64 + int64 -> int32,從型別轉換的角度來看是不安全的。目前,這將使用 int64 + int64 -> int64,然後轉換為 int32。跳過型別轉換的實作必須表明它有效地包含了「same-kind」轉換,因此不被視為「no」。

get_loop 方法#

目前,NumPy ufuncs 通常只提供單一跨步迴圈,因此 get_loop 方法可能看起來是不必要的。因此,我們計劃讓 get_loop 最初是一個私有函數。

然而,get_loop 是型別轉換所必需的,在型別轉換中,即使超出跨步和連續迴圈,也會使用專用迴圈。因此,get_loop 函數必須完全取代內部 PyArray_GetDTypeTransferFunction

在未來,get_loop 可能會公開,或者公開新的 setup 函數以允許更多控制,例如允許分配工作記憶體。此外,我們可以擴展 get_loop,並允許 ArrayMethod 實作者也控制外部迭代,而不僅僅是 1-D 內部迴圈。

擴展內部迴圈簽名#

擴展內部迴圈簽名是 NEP 的另一個核心且必要的部分。

傳遞 Context

傳遞 Context 可能允許透過在 context 結構中新增欄位來擴展簽名。此外,它還提供對內部迴圈運作的 dtype 實例的直接存取。對於參數化 dtype 來說,這是必要的資訊,因為例如比較兩個字串需要知道兩個字串的長度。Context 也可以保存可能有用的資訊,例如原始的 ufunc,這在報告錯誤時可能很有用。

原則上,傳遞 Context 並非必要,因為所有資訊都可以包含在 innerloop_data 中,並在 get_loop 函數中設定。在此 NEP 中,我們建議傳遞 struct 以簡化參數化 DType 的迴圈建立。

傳遞使用者資料

目前的型別轉換實作使用現有的 NpyAuxData * 來傳遞額外的資料,如 get_loop 所定義。當然,可以使用其他結構來替代此結構,但它提供了一個簡單的解決方案,該解決方案已在 NumPy 和公共 API 中使用。

NpyAyxData * 是一個輕量級的、已分配的結構,並且由於它已經存在於 NumPy 中用於此目的,因此似乎是一個自然的選擇。為了簡化某些用例(請參閱下方的「錯誤處理」),當未提供 innerloop_data 時,我們將傳遞 npy_intp *innerloop_data = 0

注意: 由於 get_loop 預期最初是私有的,因此我們可以在將 innerloop_data 作為公共 API 公開之前,先累積使用經驗。

傳回值

指示錯誤的傳回值是 NumPy 中一個重要但目前缺失的功能。錯誤處理因 CPU 發出浮點錯誤訊號的方式而變得更加複雜。兩者將在下一節中討論。

錯誤處理#

我們預期未來的內部迴圈通常會在發現錯誤時立即設定 Python 錯誤。當內部迴圈在未鎖定 GIL 的情況下執行時,這會變得複雜。在這種情況下,函數必須鎖定 GIL,設定 Python 錯誤,並傳回 -1 以指示發生錯誤:

int
inner_loop(PyArrayMethod_Context *context, ..., void *innerloop_data)
{
    NPY_ALLOW_C_API_DEF

    for (npy_intp i = 0; i < N; i++) {
        /* calculation */

        if (error_occurred) {
            NPY_ALLOW_C_API;
            PyErr_SetString(PyExc_ValueError,
                "Error occurred inside inner_loop.");
            NPY_DISABLE_C_API
            return -1;
        }
    }
    return 0;
}

浮點錯誤很特殊,因為它們需要檢查硬體狀態,如果在內部迴圈函數本身內完成,則成本太高。因此,如果 ArrayMethod 標記這些錯誤,NumPy 將會處理這些錯誤。如果 ArrayMethod 標記不應檢查這些錯誤,則它絕不應導致設定浮點錯誤標誌。當呼叫多個函數時,這可能會造成干擾;尤其是在需要型別轉換時。

另一種替代解決方案是允許僅在稍後的最終步驟中設定錯誤,屆時 NumPy 也將檢查浮點錯誤標誌。

此時我們決定反對這種模式。它似乎更複雜且通常是不必要的。雖然在迴圈中安全地抓取 GIL 可能需要在未來傳遞額外的 PyThreadStatePyInterpreterState(用於子直譯器支援),但這是可以接受的並且可以預期。在稍後的時間點設定錯誤會增加複雜性:例如,如果操作暫停(目前特別可能在型別轉換中發生),則每次發生這種情況時都需要明確執行錯誤檢查。

我們預期立即設定錯誤是最簡單且最方便的解決方案,並且更複雜的解決方案可能是未來的擴展。

處理警告稍微複雜一些:即使天真地認為會多次給出警告,也應該為每個函數呼叫(即對於整個陣列)只給出一次警告。為了簡化這種用例,我們預設將傳遞 npy_intp *innerloop_data = 0,可用於儲存標誌(或其他簡單的持久性資料)。例如,我們可以想像一個整數乘法迴圈,當發生溢位時會發出警告

int
integer_multiply(PyArrayMethod_Context *context, ..., npy_intp *innerloop_data)
{
    int overflow;
    NPY_ALLOW_C_API_DEF

    for (npy_intp i = 0; i < N; i++) {
        *out = multiply_integers(*in1, *in2, &overflow);

        if (overflow && !*innerloop_data) {
            NPY_ALLOW_C_API;
            if (PyErr_Warn(PyExc_UserWarning,
                    "Integer overflow detected.") < 0) {
                NPY_DISABLE_C_API
                return -1;
            }
            *innerloop_data = 1;
            NPY_DISABLE_C_API
    }
    return 0;
}

待辦事項: 當未設定 innerloop_data 時,傳遞 npy_intp 暫存空間的想法似乎很方便,但我不確定它,因為我不知道任何類似的先例。原則上,此「暫存空間」也可以是 context 的一部分。

重複使用現有的迴圈/實作方式#

對於許多 DType,上述用於新增額外 C 級迴圈的定義將足夠,並且只需要單一跨步迴圈實作,並且如果迴圈適用於參數化 DType,則必須額外提供 resolve_descriptors 函數。

然而,在某些用例中,希望回呼到現有的實作方式。在 Python 中,這可以透過簡單地呼叫原始 ufunc 來實現。

為了在 C 中獲得更好的效能,並針對大型陣列,希望盡可能直接地重複使用現有的 ArrayMethod,以便可以直接使用其內部迴圈函數,而無需額外開銷。因此,我們將允許從現有的 ArrayMethod 建立新的、包裝的 ArrayMethod

此包裝的 ArrayMethod 將具有兩個額外的方法

  • view_inputs(Tuple[DType]: input_descr) -> Tuple[DType] 使用與包裝迴圈相符的描述符取代使用者輸入描述符。必須可以將輸入檢視為輸出。例如,對於 Unit[Float64]("m") + Unit[Float32]("km"),這將傳回 float64 + int32。原始的 resolve_descriptors 會將其轉換為 float64 + float64

  • wrap_outputs(Tuple[DType]: input_descr) -> Tuple[DType] 使用所需的實際迴圈描述符取代已解析的描述符。原始的 resolve_descriptors 函數將在這兩個呼叫之間呼叫,因此輸出描述符可能未在第一個呼叫中設定。在上面的範例中,它將使用傳回的 float64(可能已變更位元組順序),並進一步解析物理單位,使最終簽名為

    Unit[Float64]("m") + Unit[Float64]("m") -> Unit[Float64]("m")
    

    UFunc 機制將負責將「km」輸入轉換為「m」。

view_inputs 方法允許將正確的輸入傳遞到原始的 resolve_descriptors 函數中,而 wrap_outputs 確保使用正確的描述符進行輸出分配和輸入緩衝型別轉換。

此方法的一個重要用例是抽象 Unit DType,其子類別用於每個數值 dtype(可以動態建立)

Unit[Float64]("m")
# with Unit[Float64] being the concrete DType:
isinstance(Unit[Float64], Unit)  # is True

Unit[Float64]("m") 實例在型別提升方面具有明確定義的簽名。Unit DType 的作者可以透過包裝現有的數學函數並使用上述兩個額外的方法來實作大多數必要的邏輯。使用提升步驟,這將允許為抽象 Unit DType 和 ufunc 建立註冊單一提升器。然後,提升器可以在提升時動態新增包裝的具體 ArrayMethod,NumPy 可以在第一次呼叫後快取(或儲存它)。

替代用例

不同的用例是 Unit(float64, "m") DType,其中數值型別是 DType 參數的一部分。這種方法是可行的,但需要自訂的 ArrayMethod,它包裝了現有的迴圈。它也必須始終需要兩個分派步驟(一個用於 Unit DType,第二個用於數值型別)。

此外,有效率的實作方式將需要能夠從另一個 ArrayMethod 擷取和重複使用內部迴圈函數。(這對於像 Numba 這樣的用戶來說可能是必要的,但不確定它是否應該是一種常見模式,並且無法從 Python 本身存取。)

提升和分派#

NumPy ufuncs 是多重方法,因為它們一次對多個 DType 進行運算(或使用)。雖然輸入(和輸出)dtypes 附加到 NumPy 陣列,但 ndarray 型別本身不攜帶要將哪個函數應用於資料的資訊。

例如,給定輸入

int_arr = np.array([1, 2, 3], dtype=np.int64)
float_arr = np.array([1, 2, 3], dtype=np.float64)
np.add(int_arr, float_arr)

必須找到正確的 ArrayMethod 來執行運算。理想情況下,存在已定義的完全匹配,例如,對於 np.add(int_arr, int_arr)ArrayMethod[Int64, Int64, out=Int64] 完全匹配並且可以使用。但是,對於 np.add(int_arr, float_arr),沒有直接匹配,需要提升步驟。

提升和分派過程#

一般來說,ArrayMethod 是透過搜尋所有輸入 DType 的完全匹配來找到的。輸出 dtype 應該影響計算,但是如果多個已註冊的 ArrayMethod 完全匹配,則輸出 DType 將用於尋找更好的匹配。這將允許目前對於 np.equal 迴圈的區別,這些迴圈同時定義了 Object, Object -> Bool(預設值)和 Object, Object -> Object

最初,ArrayMethod 將僅針對具體 DType 定義,並且由於這些 DType 無法子類別化,因此保證完全匹配。在未來,我們預期 ArrayMethod 也可以針對抽象 DType 定義。在這種情況下,最佳匹配將如下詳述般找到。

提升

如果存在相符的 ArrayMethod,則分派很簡單。但是,當不存在時,需要額外的定義來實作此「提升」。

  • 預設情況下,任何 UFunc 都具有使用所有輸入的通用 DType 並第二次分派的提升。這對於大多數數學函數來說是明確定義的,但可以在必要時停用或自訂。例如,int32 + float64 會嘗試再次使用 float64 + float64,這是通用的 DType。

  • 使用者可以像註冊新的 ArrayMethod 一樣註冊新的提升器。這些提升器將使用抽象 DType 來允許匹配各種簽名。提升函數的傳回值應為新的 ArrayMethodNotImplemented。在具有相同輸入的多個呼叫中,它必須是一致的,以允許快取結果。

提升函數的簽名將為

promoter(np.ufunc: ufunc, Tuple[DTypeMeta]: DTypes): -> Union[ArrayMethod, NotImplemented]

請注意,DType 可能包含輸出的 DType,但是,通常輸出 DType 將影響選擇哪個 ArrayMethod

在大多數情況下,不應該需要新增自訂的提升函數。需要這樣做的範例是與單位相乘:在 NumPy 中,timedelta64 可以與大多數整數相乘,但 NumPy 僅定義了 timedelta64 * int64 的迴圈 (ArrayMethod),因此與 int32 相乘將會失敗。

為了允許這樣做,可以為 (Timedelta64, Integral, None) 註冊以下提升器

def promote(ufunc, DTypes):
    res = list(DTypes)
    try:
        res[1] = np.common_dtype(DTypes[1], Int64)
    except TypeError:
        return NotImplemented

    # Could check that res[1] is actually Int64
    return ufunc.resolve_impl(tuple(res))

在這種情況下,就像需要 Timedelta64 * int64int64 * timedelta64 ArrayMethod 一樣,將需要註冊第二個提升器來處理整數首先傳遞的情況。

ArrayMethod 和提升器的分派規則

提升器和 ArrayMethod 是透過尋找由 DType 類別階層定義的最佳匹配來發現的。如果滿足以下條件,則定義為最佳匹配

  • 簽名與所有輸入 DType 相符,因此 issubclass(input_DType, registered_DType) 傳回 true。

  • 沒有其他提升器或 ArrayMethod 在任何輸入中更精確:issubclass(other_DType, this_DType) 為 true(這可能包括兩者相同的情況)。

  • 此提升器或 ArrayMethod 在至少一個輸入或輸出 DType 中更精確。

如果傳回 NotImplemented 或如果兩個提升器與輸入的匹配程度相同,則會發生錯誤。當現有的提升器對於新功能不夠精確時,必須新增新的提升器。為了確保此提升器優先,可能有必要將新的抽象 DType 定義為現有 DType 更精確的子類別。

如果提供輸出或指定完整迴圈,則上述規則可實現專業化。這通常不是必要的,但允許解析 np.logic_or 等,它們同時具有 Object, Object -> BoolObject, Object -> Object 迴圈(預設使用第一個)。

討論與替代方案#

除了解析和傳回新的實作方式之外,我們也可以傳回一組新的 DType 以用於分派。這是可行的,但是,它的缺點是無法分派到在不同 ufunc 上定義的迴圈,或動態建立新的 ArrayMethod

已拒絕的替代方案

在上面,提升器使用多重分派樣式型別解析,而目前的 UFunc 機制使用階層式順序中的第一個「安全」迴圈(另請參閱 NEP 40)。

雖然「安全」型別轉換規則的限制不夠,但我們可以想像使用新的「提升」型別轉換規則,或通用 DType 邏輯,透過根據需要向上轉換輸入來尋找最佳匹配迴圈。

這種方法的一個缺點是,僅向上轉換就允許將結果向上轉換到超出使用者預期的範圍:目前(這將繼續作為後備方案支援),任何僅定義 float64 迴圈的 ufunc 也將透過向上轉換適用於 float16 和 float32

>>> from scipy.special import erf
>>> erf(np.array([4.], dtype=np.float16))  # float16
array([1.], dtype=float32)

並產生 float32 結果。如果不變更以下程式碼的結果,就不可能變更 erf 函數以傳回 float16 結果。一般來說,我們認為在可以定義不太精確的迴圈的情況下,不應發生自動向上轉換,除非 ufunc 作者使用提升有意這樣做。

此考量意味著向上轉換必須受到某些額外方法的限制。

替代方案 1

假設不打算進行一般向上轉換,則必須定義一個規則來限制從 float16 -> float32 向上轉換輸入,可以使用 DType 或 UFunc 本身的通用邏輯(或兩者的組合)。UFunc 無法單獨輕鬆地做到這一點,因為它無法知道註冊迴圈的所有可能 DType。請考慮以下兩個範例

第一個(應拒絕)

  • 輸入:float16 * float16

  • 現有迴圈:float32 * float32

第二個(應接受)

  • 輸入:timedelta64 * int32

  • 現有迴圈:timedelta64 * int16

這需要以下其中一項

  1. timedelta64 以某種方式表示,如果 int64 向上轉換涉及運算,則始終支援該向上轉換。

  2. float32 * float32 迴圈拒絕向上轉換。

實作第一種方法需要在特定上下文中表示向上轉換是可以接受的。這將需要額外的掛鉤,並且對於複雜的 DType 可能並不簡單。

對於第二種方法,在大多數情況下,簡單的 np.common_dtype 規則將適用於初始分派,但是,即使對於同質迴圈,情況也僅是如此。此選項將需要新增一個函數來檢查輸入是否是每個迴圈的有效向上轉換,這似乎有問題。在許多情況下,可以提供預設值(同質簽名)。

替代方案 2

替代的「提升」步驟是確保在首先找到正確的輸出 DType 後,輸出 DType 與迴圈相符。如果輸出 DType 是已知的,則尋找安全迴圈變得容易。在大多數情況下,這是可行的,正確的輸出 dtype 只是

np.common_dtype(*input_DTypes)

或某些固定的 DType(例如,邏輯函數的 Bool)。

但是,例如,在上面的 timedelta64 * int32 情況下,它會失敗,因為先驗無法知道此輸出的「預期」結果型別確實是 timedelta64np.common_dtype(Datetime64, Int32) 失敗)。這需要一些關於 timedelta64 精度為 int64 的額外知識。由於 ufunc 可以具有任意數量的(相關)輸入,因此至少需要在 Datetime64(和所有 DType)上新增一個額外的 __promoted_dtypes__ 方法。

遮罩 DType 顯示了另一個限制。當涉及遮罩時,邏輯函數沒有布林值結果,因此這將需要原始 ufunc 作者在此方案中預期遮罩 DType。同樣地,為複數值定義的某些函數將傳回實數,而其他函數將傳回複數。如果原始作者沒有預期到複數,則對於稍後新增的複數迴圈,提升可能是不正確的。

我們認為,雖然提升器允許巨大的理論複雜性,但它們是最佳解決方案

  1. 促昇允許動態新增迴圈。例如,可以定義一個抽象的 Unit DType,它會動態建立類別來包裝其他現有的 DType。使用單一的 promoter,這個 DType 可以動態包裝現有的 ArrayMethod,使其能夠在單次查找中找到正確的迴圈,而不是兩次。

  2. 促昇邏輯通常會偏向安全的一方:除非也新增了 promoter,否則新增加的迴圈不會被誤用。

  3. 他們將仔細思考邏輯是否正確的責任,放在將新迴圈添加到 UFunc 的程式設計師身上。(與替代方案 2 相比)

  4. 如果現有的促昇不正確,可以編寫 promoter 來限制或精煉通用規則。一般來說,促昇規則絕不應返回*不正確*的促昇,但如果現有的促昇邏輯失敗或對於新增加的迴圈不正確,則迴圈可以新增一個新的 promoter 來精煉邏輯。

讓每個迴圈驗證是否發生向上轉型的選項可能是最佳的替代方案,但不包含動態新增新迴圈的能力。

通用 promoter 的主要缺點是它們可能導致非常高的複雜性。第三方函式庫*可能*會向 NumPy 新增不正確的促昇,然而,這已經可以透過新增新的不正確迴圈來實現。一般來說,我們相信我們可以依靠下游專案謹慎且負責任地使用這種能力和複雜性。

使用者指南#

一般來說,向 UFunc 新增 promoter 必須非常謹慎。Promoter 絕不應影響可以由其他資料類型合理定義的迴圈。定義一個假設性的 erf(UnitFloat16) 迴圈絕不能導致 erf(float16)。一般來說,promoter 應滿足以下要求

  • 在定義新的促昇規則時,請保持保守。不正確的結果比意外的錯誤更危險。

  • 新增的(抽象)DType 之一通常應與您的專案定義的 DType(或 DType 族系)精確匹配。絕不要新增超出正常通用 DType 規則的促昇規則!如果您編寫 int24 dtype,則為 int16 + uint16 -> int24 新增迴圈是*不*合理的。此操作的結果先前已定義為 int32,並將以此假設使用。

  • Promoter(或迴圈)絕不應影響現有的迴圈結果。這包括新增更快但精度較低的迴圈/promoter 來取代現有的迴圈/promoter。

  • 對於所有與促昇(和轉型)相關的邏輯,請嘗試保持在清晰、線性的層次結構中。NumPy 本身打破了整數和浮點數的這種邏輯(它們不是嚴格線性的,因為 int64 無法促昇為 float32)。

  • 迴圈和 promoter 可以由任何專案新增,可能是

    • 定義 ufunc 的專案

    • 定義 DType 的專案

    • 第三方專案

    嘗試找出哪個專案最適合新增迴圈。如果定義 ufunc 的專案和定義 DType 的專案都沒有新增迴圈,則可能會出現多重定義(會被拒絕)的問題,應注意迴圈行為始終比錯誤更可取。

在某些情況下,這些規則的例外情況可能是有道理的,但是,一般來說,我們要求您格外小心,如有疑問,請改為建立新的 UFunc。這清楚地通知使用者不同的規則。如有疑問,請在 NumPy 郵件列表或問題追蹤器上提問!

實作#

此 NEP 的實作將需要對目前的 ufunc 機制(以及轉型)進行大規模的重構和重組。

不幸的是,實作將需要對 UFunc 機制進行大量維護,因為必須修改實際的 UFunc 迴圈呼叫以及初始分派步驟。

一般來說,正確的 ArrayMethod,以及 promoter 返回的那些,將被快取(或儲存)在雜湊表內,以實現高效查找。

討論#

在許多地方都有關於可能的實作方案的大量討論,以及初步的想法和設計文件。這些都列在 NEP 40 的討論中,為了簡潔起見,此處不再重複。

github issue 12518 “What should be the calling convention for ufunc inner loop signatures?” 中可以找到一個長時間的討論,其中觸及了許多這些要點,並指向了類似的解決方案。

參考文獻#

更多討論和參考文獻,請參閱 NEP 40 和 41。