HN 日本語サマリー

← 一覧へ戻る
プログラミング

np.addのトレース、すべてを下まで

Tracing np.add, all the way down (blog.veitheller.de)

50 pointsby luu2 コメント

要約

この記事では、NumPyのnp.add関数がPythonの呼び出しからSIMDカーネルまで、どのように動作するかを詳細に追跡します。NumPy 2.5.2を基に、np.addが単なるPython関数ではなく、Cで定義されたufuncオブジェクトであること、そしてその内部でどのように引数が解析され、適切な内部ループ(この場合はfloat64の加算用)が選択され、実行されるのかを解説しています。このプロセスには、__array_ufunc__によるオーバーライドチェックや、型推論とキャッシュの仕組みが含まれます。

全文翻訳

np.add をトレースする、徹底的に

この記事のメモは、ドラフトフォルダに半年ほど眠っていました。過去1年ほど、NumPy自体に少しだけ手を加えてきました。大したものではありませんが、ソースコードを読み解くには十分な作業でした。それがこの記事を書くきっかけになったのですが、他の義務がNumPyへの貢献を影で覆い隠すようになり、静かに腐り始めていました。NumPyのリリースが1つか2つ過ぎた後、ようやく再び手に取り、自分の足跡をたどり直し、ここにたどり着きました。

前提はこうです:np.add(a, b) は、おそらく世界で最も実行される数値計算Pythonコードの一部であり、ほとんどの人は「配列をC言語で、高速に加算する」という動作モデルを持っています。そのモデルは正しいのですが、Pythonの呼び出しと加算を行うループの間には多くの仕組みがあり、私はそれが分解するのが楽しい機械だと考えています。そこで今日は、2つのfloat64配列に対する単一の呼び出し、np.add(a, b) を、PythonのエントリポイントからSIMDカーネルまで、実際のNumPyソースコードを読みながらトレースしていきます。多くのことを学べるはずです!

以下はすべて、執筆時点での最新リリースであるNumPy 2.5.2にピン留めされており、すべてのリンクはそのタグを指しています。内部構造はバージョン間で移動します1ので、自宅で探求している場合は、対応するタグを確認してください。C言語を読むことに少なくともある程度慣れていることを前提としますが、NumPyの内部知識は必要ありません。それが私たちの目的ですから。

マップ

飛び込む前に、宝の地図を用意しました。これで、常に現在地がわかります。

np.add(a, b)                      (Python)
   │
   ▼
ufunc_generic_fastcall            (C: 引数を解析)
   │
   ▼
__array_ufunc__ オーバーライドチェック    (他のライブラリに分岐する可能性あり)
   │
   ▼
プロモーション & ディスパッチ              (float64 ループを見つけ、キャッシュする)
   │
   ▼
trivial loop  または  NpyIter         (イテレーション戦略)
   │
   ▼
DOUBLE_add                        (実際の内部ループ、SIMD)

これらはすべて以下のセクションです。一番上から始めましょう。

np.add はオブジェクトである

最初に知っておくべきことは、np.add が通常のPython関数ではないということです。それはnumpy.ufuncというC言語で定義された型のインスタンスです2。

>>> type(np.add)
<class 'numpy.ufunc'>
>>> np.add.nin, np.add.nout
(2, 1)
>>> len(np.add.types)
22
>>> np.add.types[11:14]
['ee->e', 'ff->f', 'dd->d']

ufunc は、その核心において、内部ループのバンドルです。サポートされる型シグネチャごとに小さなC関数があり、それに加えて入力と出力の数に関するメタデータがあります。np.add は22個のループを提供しますが、types はクラシックなものだけをリストしていることに注意してください。最新の方法で登録されたループ(この区別の詳細については後述)は、ufuncの内部マッピングに存在し、Pythonからは決して見えません。今日私たちが追跡しているのは dd->d、つまり double, double, to double です。この記事の残りの部分は、NumPyがあなたの呼び出しからそのエントリにどのように到達し、それが一度見つかると何が起こるかについてです。他のパスは異なる場合があります。これは大きなキットの一部です!

洞窟に深く潜りましょう。

C言語の中へ

Pythonが np.add(a, b) を見ると、ufuncオブジェクトを呼び出します。ufunc 型はvectorcallプロトコルを実装しているため、呼び出しは ufunc_generic_vectorcall に着地し、それがすぐに実際の作業馬である ufunc_generic_fastcall に転送されます。この関数は長いですが、チェックリストのように読め、それは操作全体の骨格です。大幅に省略すると、次のようになります。

static PyObject *
ufunc_generic_fastcall(PyUFuncObject *ufunc,
        PyObject *const *args, Py_ssize_t len_args, PyObject *kwnames,
        npy_bool outer)
{
    /* ... 入力、出力、およびキーワード引数を抽出 ... */

    /* オーバーライドをチェックするために必要なすべての情報が揃いました */
    PyObject *override = NULL;
    errval = PyUFunc_CheckOverride(ufunc, method,
            full_args.in, full_args.out, where_obj,
            args, len_args, kwnames, &override);
    /* ... オーバーライドが見つかった場合、その結果を返す ... */

    /* ... 引数を配列に変換し、DTypeを抽出 ... */

    PyArrayMethodObject *ufuncimpl = promote_and_get_ufuncimpl(ufunc,
            operands, signature, operand_DTypes, ...);

    /* 操作の正しいディスクリプタを見つける */
    if (resolve_descriptors(nop, ufunc, ufuncimpl, ...) < 0) {
        goto fail;
    }

    /*
     * 最終準備を行い、内部ループを呼び出す。
     */
    errval = PyUFunc_GenericFunctionInternal(ufunc, ufuncimpl,
            operation_descrs, operands, casting, order, wheremask);

    /* ... 出力をラップして返す ... */
}

解析、オーバーライドのチェック、ループの選択、実行、結果のラップ。計画が立ったようです!さて、興味深い部分に移りましょう。

洞窟の明かりが薄くなってきました。

エスケープハッチ

NumPyが作業をコミットする前に、引数に「自分でやりたいか」と尋ねます(これは常に良い運営方針です)。PyUFunc_CheckOverride はすべての入出力を見て、デフォルト以外の __array_ufunc__ メソッドを探します。これはNEP 133で定義されているプロトコルです。引数のいずれかがこれを持っている場合、NumPyはそれを呼び出し、それが生成したものを返します。そして、以下で説明する仕組みは一切実行されません。

これは、np.add(dask_array, cupy_array) がサードパーティと連携して動作するフックです!DaskやCuPyのようなライブラリは __array_ufunc__ を実装し、処理を引き継ぎます。私たちは4行でそれを自分でプレイできます。

class Diverted:
    def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
        return f"intercepted {ufunc.__name__}.{method}"

>>> np.add(np.arange(3), Diverted())
'intercepted add.__call__'

私たちのオブジェクトは、誰が計算するかという議論に勝つことしか行いません。トレースでは、両方の引数が通常の ndarray であると仮定するため、何もオーバーライドされず、ラペリングを続行します。

ループの選択

次に、NumPyは「2つの float64 配列」から「その dd->d エントリ」に到達する必要があります。これはプロモーションとディスパッチであり、dispatching.cpp にあります。そのヘッダーコメントは、このプロセスに関する私がどこで見つけた中でも最高のドキュメントなので、そのまま引用します(タイプミスも含めて)。

/*
The process of dispatching and promotion can be summarized in the following
steps:

1. Override any `operand_DTypes` from `signature`.
2. Check if the new `operand_DTypes` is cached (if it is, got to 4.)
3. Find the best matching "loop".  This is done using multiple dispatching
   on all `operand_DTypes` and loop `dtypes`.  A matching loop must be
   one whose DTypes are superclasses of the `operand_DTypes` (that are
   defined).  The best matching loop must be better than any other matching
   loop.  This result is cached.
4. If the found loop is a promoter: We call the promoter. It can modify
   the `operand_DTypes` currently.  Then go back to step 2.
5. The final `ArrayMethod` is found, its registered `dtypes` is copied
   into the `signature` so that it is available to the ufunc loop.
*/

いくつかの翻訳が必要です。signature は、np.add(a, b, dtype=...) のように明示的に固定するものです。私たちの呼び出しでは空です。「プロモーター」は、直接一致するループがない場合に、要求された型を書き換えてディスパッチを再度実行させる登録済みのヘルパーです。紛らわしいことに、日常的な混合ケース np.add(int32_array, float64_array) はそれさえ使用しません。ディスパッチが空の場合、ufuncの古い型解決メカニズム(この場合は PyUFunc_AdditionTypeResolver)にフォールバックして共通の型を選択し、それらでディスパッチに再突入して dd->d に着地します。

そして、ステップ2のキャッシュは非常に重要です!完全な解決は、指定された型の組み合わせでufuncを初めて呼び出したときにのみ発生します。私たちのような通常のキャッシュ可能なケースでは、それ以降の同じ型でのすべての呼び出しは、DTypeクラスに対する単一のハッシュルックアップになります。それは純粋な仕組みですが、有用です。

今持っているものを理解するために、考古学と言葉遊びに従事する必要があります。

promote_and_get_ufuncimpl が返すのは PyArrayMethodObject であり、これは「具体的なDTypeに対する操作の具体的な実装」の最新(NEP 43以降)の表現です。しかし、float64 加算の場合、ArrayMethodはさらに古いものの薄いラッパーです。実際のループが必要になると、get_wrapped_legacy_ufunc_loop は PyUFunc_DefaultLegacyInnerLoopSelector を呼び出します。これは、最初に書くであろうこととまったく同じことを行います!ufuncの types テーブルをエントリごとに歩き、dd->d を見つけるまで、そして ufunc->functions[i]、つまりプレーンなC関数ポインタを返します。それを最新のインターフェースに適応させるラッパーは愛らしいです。

static int
generic_wrapped_legacy_loop(PyArrayMethod_Context *NPY_UNUSED(context),
        char *const *data, const npy_intp *dimensions, const npy_intp *strides,
        NpyAuxData *auxdata)
{
    legacy_array_method_auxdata *ldata = (legacy_array_method_auxdata *)auxdata;

    ldata->loop((char **)data, dimensions, strides, ldata->user_data);
    if (ldata->pyerr_check && PyErr_Occurred()) {
        return -1;
    }
    return 0;
}

その ldata->loop の呼び出しは、「クラシック」ufunc内部ループインターフェースである PyUFuncGenericFunction であり、何十年も変更されていません。それはデータポインタの配列、要素数、オペランドごとのストライド、および不透明なペイロードです。NumPyのすべての組み込み数値ループは、依然としてこの形状を持っています。その上のすべては、それを正しく呼び出すためにメモリを整列させるために存在します4。

私たちは洞窟の奥深くにいます。ほとんど完全に暗闇です。

イテレートするかしないか

ループができたので、それを供給する必要があります。それは PyUFunc_GenericFunctionInternal で、興味深い決定を1つ行います。

/*
 * This checks whether a trivial loop is ok, making copies of
 * scalar and one dimensional operands if that should help.
 */
int trivial_ok = check_for_trivial_loop(ufuncimpl,
        op, operation_descrs, casting, buffersize);
/* ... */
if (trivial_ok && context.method->nout == 1) {
    /* Try to handle everything without using the (heavy) iterator */
    int retval = try_trivial_single_output_loop(&context,
            op, order, errormask);
    if (retval != -2) {
        return retval;
    }
}

return execute_ufunc_loop(&context, 0,
        op, order, buffersize, casting, op_flags, errormask);

高速パスが最初にきます。形状が一致し、ブロードキャストやキャスティングが必要なく、すべてのオペランドが1次元または連続している場合、try_trivial_single_output_loop はデータ全体に対して内部ループを1回呼び出します。イテレータはまったく構築されません。2つの正常な同じ形状の配列の日常的な np.add(a, b) の場合、これがあなたの通るパスです。

それ以外はすべて execute_ufunc_loop と NpyIter、つまりNumPyの汎用配列イテレータを通過します。構築フラグは、それが内部ループのために処理するすべてのもののコンパクトな要約です。

npy_uint32 iter_flags = ufunc->iter_flags |
             NPY_ITER_EXTERNAL_LOOP |
             NPY_ITER_REFS_OK |
             NPY_ITER_ZEROSIZE_OK |
             NPY_ITER_BUFFERED |
             NPY_ITER_GROWINNER |
             NPY_ITER_DELAY_BUFALLOC |
             NPY_ITER_COPY_IF_OVERLAP;

ブロードキャスト、配置ずれまたはキャスティングオペランドのバッファリング、入力と出力間のオーバーラップ検出など、すべてがイテレータの問題です。その後の実際の実行は、ほとんど劇的ではありません。

int res;
do {
    res = strided_loop(context, dataptr, countptr, strides, auxdata);
} while (res == 0 && iternext(iter));

イテレータはループに一度に1つの連続した(またはそれに近い)チャンクを渡し、その間にポインタを進めます。手を洗いましょう、これで終わりです。

しかし、両方のパスは知っておく価値のある2つのブックキーピングビットも共有しています。第一に、ループがPython APIを必要としない限り(私たちの float64 ループは必要としませんが、object DTypeループは必要とします)、NumPyは非常に小さな配列がオーバーヘッドを支払わないように、しきい値を使用してその全体をGILから解放します。第二に、浮動小数点ステータスフラグはループの前にクリアされ、後にチェックされます。これが、古い信頼できる RuntimeWarning: overflow encountered in add が発生する場所です。内部ループ自体は何もチェックせず、計算してCPUフラグを設定するだけです。

さらに深く潜る前に1つの注意点があります(この記事のベンチマークで私を悩ませました)。ストライドだけでは、トリビアルパスから追い出されるわけではありません。トリビアルループは任意の1次元配列を受け入れ、実際のストライドを内部ループに渡すだけなので、a[::2] でさえ単一の呼び出しで処理され、イテレータは不要です。この記事のトレースを有効にしてNumPyをビルドしたところ、トリビアルパスが実際に拒否するもの、つまり非連続なN次元ビュー(base[:, ::2] を考える)や、入力と安全にオーバーラップしない out= を必要とします。そうしないと、「Making iterator」と表示されます。(オーバーラップチェックは予想以上に細かいです:out=a[1:] が a[:-1] から読み取る場合はイテレータが起動しますが、逆方向、つまり書き込みが読み取りに安全に遅れる場合は起動しません)。その考えを覚えておいてください。すぐに重要になります。

私たちは洞窟の中央の部屋にいます。静かなささやきだけ、天井は非常に壊れやすいです。

ループそのもの

内部ループを見る前に、それを噛み砕くものを提供しましょう。要素数は同じ、操作も同じですが、一方のバージョンは2番目の要素ごとにストライドします。

n = 10_000_000
a, b, out = (np.random.rand(2 * n) for _ in range(3))
contig_a, contig_b, contig_out = (x[:n].copy() for x in (a, b, out))
strided_a, strided_b, strided_out = (x[::2] for x in (a, b, out))

np.add(contig_a, contig_b, out=contig_out)     # 1呼び出しあたり3.30 ms
np.add(strided_a, strided_b, out=strided_out)  # 1呼び出しあたり8.58 ms

私のマシンでは2.6倍の差が出ます。同じ数の加算で。そして、前のセクションから、両方の呼び出しが同じ方法でここに到達することはわかっています。トリビアルパス、内部ループへの1回の呼び出し、実際のストライドなど(それが覚えておくべき考えでした、もう大丈夫です)。したがって、ギャップを説明するものは、DOUBLE_add 自体の中に存在しなければなりません。その一部はメモリ帯域幅です—ストライドバージョンは2倍のメモリにアクセスします!—しかし、残りはどうでしょうか。

では、DOUBLE_add は実際にはどのようになっているのでしょうか?ここでまずあなたを失望させなければなりません。リポジトリには DOUBLE_add という名前の関数を含むファイルはありません。要素ごとのループは、loops_arithm_fp.dispatch.c.src のようなテンプレートファイルにあります。これはNumPy独自のテンプレート言語を使用しています。なぜなら、すべての大きなプロジェクトは設定形式とテンプレート言語を成長させるからです。

/**begin repeat
 * Float types
 *  #type = npy_float, npy_double#
 *  #TYPE = FLOAT, DOUBLE#
 *  #sfx  = f32, f64#
 */
/**begin repeat1
 * Arithmetic
 * # kind = add, subtract, multiply, divide#
 * # intrin = add, sub, mul, div#
 * # OP = +, -, *, /#
 */
NPY_NO_EXPORT void NPY_CPU_DISPATCH_CURFX(@TYPE@_@kind@)
(char **args, npy_intp const *dimensions, npy_intp const *steps, void *NPY_UNUSED(func))
{
    npy_intp len = dimensions[0];
    char *src0 = args[0], *src1 = args[1], *dst = args[2];
    npy_intp ssrc0 = steps[0], ssrc1 = steps[1], sdst = steps[2];
    ...

ビルド時に、小さなスクリプト(conv_template.py)が各 repeat ブロックをすべての値の組み合わせに対して展開します。したがって、この1つの関数本体は、FLOAT_add、DOUBLE_add、FLOAT_subtract などになり、1つのテンプレートから8つの関数が生成されます。シグネチャに注意してください。これは、2つのセクション前に会った PyUFuncGenericFunction です。私たちはたどり着きました。洞窟の壁に触れないでください。

内部では、関数は特殊化のカスケードです。削減ケースの分岐、次に一般的なストライドパターン(両方の入力が連続、1つの入力がスカラー)のためにSIMDで高速化されたバージョンがあります。これはNumPyの「ユニバーサルイントリンシック」に対して書かれており、npyv_add_f64 はターゲットCPUがdoubleのベクトルの加算を呼び出すものにマッピングされます。そして、どの特殊化も適合しない場合、それはあなたが書いたであろうループにフォールバックします(ただし、あなたはもう少し簡潔かもしれません)。

loop_scalar:
    for (; len > 0; --len, src0 += ssrc0, src1 += ssrc1, dst += sdst) {
        const @type@ a = *((@type@*)src0);
        const @type@ b = *((@type@*)src1);
        *((@type@*)dst) = a @OP@ b;
    }

そして、私たちの2.6倍のパフォーマンスギャップの残りがここにあります。SIMD特殊化はすべて、オペランドが連続しているかスカラーであるかに依存しているため、ベンチマークからのストライド呼び出しはすべてのチェックに失敗し、このスカラーブランチ全体で時間を費やします。一方、連続呼び出しはベクトル化されたものを受け取ります。帯域幅税の後に残ったギャップは、この悲しいループの底にたどり着いた代償です。

すべてこの深さまで来て、NumPyの底にはポインタを増分する for ループがあり、2つのdoubleを加算します。結局、私たちは皆、暗闇の中で手探りしているだけです。

しかし、メインホールからぶら下がっている小さな洞窟があります。行く前に見てみましょう。私たちと一緒に深淵に潜むような恐ろしいものがあるはずがありません。

ループはどこから来るのか

1つの謎が残っています。それらの生成された関数は、ディスパッチが歩いた ufunc->functions テーブルにどのように配置されるのでしょうか?答えは、いつものように、さらにコード生成です。組み込みufuncの在庫全体は、generate_umath.py のPython辞書で定義されています。これが add の非常に読みやすいエントリです。

'add':
    Ufunc(2, 1, Zero,
          docstrings.get('numpy._core.umath.add'),
          'PyUFunc_AdditionTypeResolver',
          TD('?', cfunc_alias='logical_or', dispatch=[('loops_logical', '?')]),
          TD(no_bool_times_obj, dispatch=[
              ('loops_arithm_fp', 'fdFD'),
              ('loops_autovec', ints),
          ]),
          [TypeDescription('M', FullTypeDescr, 'Mm', 'M'),
           TypeDescription('m', FullTypeDescr, 'mm', 'm'),
           TypeDescription('M', FullTypeDescr, 'mM', 'M'),
          ],
          TD(O, f='PyNumber_Add'),
          indexed=intfltcmplx
          ),

私は一杯飲む必要があるかもしれません。

これは、投稿の冒頭にあった22エントリの types リストを、ソース形式で示したものです。add の設計全体をそこから読み取ることができます。ブール値は logical_or を再利用し、浮動小数点型と複素数型('fdFD')は loops_arithm_fp からループを取得し、整数は自動ベクトル化ループを取得し、日付時刻は手書きの特殊ケースを取得し、object 配列は単にPython独自の PyNumber_Add を呼び出します。ビルド時に、この辞書はCファイル __umath_generated.c にレンダリングされ、関数テーブルと、NumPyをインポートしたときに np.add オブジェクトを構築する呼び出しが含まれます。

生成されたファイルには、最後の(約束します!)トリックが隠されています。上記の dispatch= マーカーは、ループが複数回コンパイルされることを意味します。興味深いCPU機能セットごとに1回(ファイルサフィックス .dispatch.c.src はビルドシステムにこれを実行するように指示します)。実際にテーブルにインストールされるコンパイル済みバリアントは、インポート時に、ランタイムCPUチェックのチェーンに展開されるマクロによって決定されます。ビルド設定のドキュメントは、架空の例(説明のみ、機能はループごとであり、add のAVX-512バリアントはありません)で展開の形状を示しています。

NPY_CPU_DISPATCH_CALL(func = add);

// Unwrapped version:
func = NPY_CPU_HAVE(AVX512_SKX) ? add_AVX512_SKX :
           (NPY_CPU_HAVE(AVX2) ? add_AVX2 :
               add);  // baseline

各ループファミリーの実際のターゲットは meson.build にあります。loops_arithm_fp の場合、それらは X86_V3, X86_V2, ASIMD, NEON です。ここで X86_V3 は x86-64-v3 マイクロアーキテクチャレベル、つまりAVX2を意味します。したがって、テーブルにある DOUBLE_add ポインタは、numpy がインポートされたときに、あなたの特定のCPU用にすでに選択されています。NumPyにどのバリアントを取得したかを尋ねることさえできます。

>>> from numpy.lib import introspect
>>> introspect.opt_func_info(func_name="add", signature="float64")
{'add': {'ddd': {'current': 'baseline(NEON NEON_FP16 NEON_VFPV4 ASIMD)',
                 'available': 'baseline(NEON NEON_FP16 NEON_VFPV4 ASIMD)'}}}

私のARMベースのMacでは、NEONは無条件で利用可能なので、候補は1つだけです。x86マシンでは、代わりに X86_V3/X86_V2 のはしごが表示されます。

よしよし、もう上がる時間です。ここは不気味になってきました。

終わり

ルートをもう一度歩いて、要約しましょう。np.add は22個のループのテーブルを持つオブジェクトです。呼び出しは引数を解析し、全員にオーバーライドの機会を提供し、入力型を1つのテーブルエントリに解決し(初回以降はキャッシュされる)、イテレーション戦略を選択し(メモリが友好的なら単一の呼び出し、そうでないなら NpyIter)、GILを解放し、CPU固有のテンプレート生成C関数を実行します。その最も一般的なブランチは、プレーンな for ループです。

私たちの比喩を続けると、この探求中に最も感銘を受けたのは、地質がどれほど目に見えるかということです。内部ループのシグネチャと types テーブルは数十年前にさかのぼり、依然として機械の中心です。ArrayMethod レイヤー、オーバーライドプロトコル、SIMDディスパッチはそれぞれ、その歴史を置き換えることなく上に堆積されました。古いインターフェースをラップするファイルは文字通り legacy_array_method.c と呼ばれており、それは非推奨の仕組みではなく、毎日信じられないほどの数の加算を実行する仕組みそのものです。それはまさにレガシーです。

しかし、古い地層は凍結されておらず、証拠があります。なぜなら、私はそれに遭遇したからです。メインブランチでソースを読み、引用する前に2.5.2タグに対してすべてを再検証しましたが、私の検索の1つが失敗しました。なぜなら、以前のレガシーループルックアップはすでにメインでリファクタリングされていたからです。ループが設定されるたびにセレクタが types テーブルをスキャンする代わりに、スキャンは ArrayMethod が登録されるときに一度実行され、見つかったループはその上にキャッシュされます。セレクタと、「ループ選択アクセラレーション構造、ハッシュテーブルのようなもの」を願うその TODO コメントは、両方ともそのままですが、それらは今や呼び出しごとではなく、一度だけ作業を行います。

動機が最も良い部分です。これは、NumPyをフリー・スレッド・パイソン向けに準備する変更のファミリーに属しており、遅延初期化されたグローバル状態はコードの臭いから実際の problèmes に変わります5。誰かが、数十年前に、何よりも証明された、数値Pythonで最も実行されるコードパスの1つを見て、世界が変わったので、それを改修しました。最も古い層は、上に堆積されたすべてのものにもかかわらず、まだ手入れされています。それは美しいです。

この下降を楽しんでいただけたなら幸いです!この投稿がさらに深く探求したいと思わせたなら、NumPyをソースからビルドし、ufunc_generic_fastcall にブレークポイントを設定するのは、驚くほど楽しい午後になるでしょう。飲み物を手元に置いておいてください。また会いましょう!

このブログ記事をレビューしてくれたNathan Goldbaumに感謝します!

フットノート
  1. 本気です!この記事で説明されている仕組みの一部は、NumPyのメインブランチですでに改修されています。Finで詳しく見ます。リリース間でも np.add をハックする人はいます。

  2. 具体的には、C拡張モジュール numpy._core._multiarray_umath にあります。その扱いにくい名前は、 apparently、歴史的な遺物です。配列の仕組み(multiarray)とufuncの仕組み(umath)は、NumPyの生涯のほとんどで別々のCモジュールでしたが、NEP 15によってマージされました。古いNumPyガイドが現在存在しないモジュールを参照している理由を不思議に思ったことがあるなら、それが理由です。ソフトウェアのリファクタリングの喜び。

  3. __array_function__(NEP 18より)と混同しないでください。これは、np.concatenate のようなufunc以外の関数のアナログプロトコルです。この投稿の私の古いメモは、両方がufuncパスでチェックされると自信を持って主張していましたが、そうではありません。ufuncは __array_ufunc__ のみを参照します。メモはLLMとの会話から来ており、それ自体が教訓です。

  4. ArrayMethodレイヤーは古いスタイルのループをラップする必要があるわけではありません。新しいDTypeがネイティブにループを登録できるようにするためだけに存在します。NumPy 2.0の文字列DTypeの作業は、最新のループの例です。StringDType が add ループを登録する場所はここにあり、それは np.add.types には決して現れません。これはクラシックテーブルのみを反映しています。トップで言及した、Pythonが決して見ないマッピングは、そのようなループが存在する場所です。組み込み数値型の場合、しかし、「レガシー」インターフェースは依然として本物です。

  5. これは、NumPyコア開発者であるNathan Goldbaumがこの投稿のドラフトをレビューしてくれたおかげで(ありがとう!)私のもとに届きました。同じファミリーからの例:修正は、マルチスレッド削減を動機として、削減の初期値設定を呼び出し時間からufunc初期化に移動しました。ディスパッチキャッシュ自体、PyArrayIdentityHash も最近、並行性を考慮したオーバーホールを受けました。これはQuansightの「Scaling NumPy on free-threaded Python」で説明されています。