0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

#0130(2025/05/11)numpyに迫る

Posted at

numpyに迫る

numpyの本質に迫る


はじめに

科学技術計算や機械学習の分野で標準的に用いられるNumPyは、Pythonの高速配列操作を可能にするライブラリです。しかし、その圧倒的な性能はPythonの文法やデータ構造だけでは実現できません。本記事では、上級エンジニアにも新たな発見があるよう、NumPyの内部構造やC言語実装を深掘りし、なぜ高速なのかを徹底解説します。


1. ベクトル演算とPythonループの限界

Pythonのリストに対するforループは、各要素に対して逐一インタプリタが介在し、動的型チェックやメモリアクセスが発生します。一方、NumPyの配列(ndarray)は、あらかじめ確保された連続メモリ領域上で一度に演算を行います。

検証コード(スクリプト形式)

import timeit
lst = list(range(1_000_000))
py_time = timeit.timeit("[x**2 for x in lst]", globals=globals(), number=10)

import numpy as np
arr = np.array(lst)
np_time = timeit.timeit("arr**2", globals=globals(), number=10)

print(f"Python list: {py_time:.4f}s, NumPy array: {np_time:.4f}s")

2. メモリモデルの違い

Pythonのリストは、各要素へのポインタをヒープ上に散在配置する可変長配列です。これに対し、NumPyのndarray連続した型付きバッファ上でデータを扱い、高速演算を実現しています。
以下はヘッダー numpy/_core/include/numpy/ndarraytypes.h にある定義抜粋です。

// ndarraytypes.h より
typedef struct PyArrayObject {
    PyObject_HEAD
    char *data;             /* データ先頭へのポインタ */
    npy_intp nd;            /* 次元数 (rank) */
    npy_intp *dimensions;   /* 各軸サイズ (shape) */
    npy_intp *strides;      /* 各軸ステップ幅 (bytes) */
    PyObject *base;         /* メモリ所有者参照 */
    int flags;              /* C_CONTIGUOUSなど */
    /* ... その他 ... */
} PyArrayObject;
  • data: 連続データへのポインタ
  • nd: 配列次元数
  • dimensions/strides: shapeとステップ幅
  • flags: 連続性や書き込み許可のフラグ

このモデルはCPUキャッシュの局所性を最大化し、連続読み書きやSIMD演算に最適です。


3. ufuncのC実装

NumPyの核心は "ufunc(Universal Function)" と呼ばれるCレベルのループ処理エンジンです。例えば加算np.addの定義は、テンプレートファイル numpy/core/src/umath/loops.c.src で行われます。

// loops.c.src より
@loop ufunc
add_loops:
    BINARY_LOOP {
        *out = *in1 + *in2;
    }

このテンプレートは複数データ型向けに展開され、型安全かつ最適化された純Cループが生成されます。Python層は関数呼び出しを介するだけで、このC実装が直接実行されるため、オーバーヘッドが最小限です。

補足: ヘッダー(.h) とソース(.c) の役割分担
C言語では、インターフェースを宣言するヘッダファイル(.h) と、実装を記述するソースファイル(.c) を分離します。NumPyも同様で、

  • ヘッダ(*.h): 関数宣言や構造体定義
  • ソース(*.c): 実処理ロジック
    を分けることで、ビルド依存管理と再利用性・ABI互換性を高めています。

4. 上級者向け実験:カスタムufuncの性能検証

本節では、NumPyのネイティブな ufunc(Cで実装された演算関数)と、Pythonのコールバックを使ってPython層で実装される frompyfunc によるユーザー定義 ufunc性能差を検証します。

  • 目的: ネイティブ ufunc がCレベルで最適化されたループ処理を行うのに対し、frompyfunc は各要素ごとにPython呼び出しを行うため、どれほどのオーバーヘッドがあるかを定量化します。

  • 検証内容:

    1. numpy.frompyfunc を使って、純Python関数 py_add(x, y)ufunc
    2. 乱数配列2つ(長さ1,000,000)に対して、Python層の ufunc とネイティブ np.add をそれぞれ10回実行
    3. 実行時間を比較し、C実装の優位性を確認
import numpy as np
import timeit

def py_add(x, y):
    # Python関数で足し算
    return x + y

# Python層で動作するユーザー定義ufuncを生成
pyuf = np.frompyfunc(py_add, 2, 1)

# テスト用データ
arr1 = np.random.rand(1_000_000)
arr2 = np.random.rand(1_000_000)

# Python層 ufunc の実行時間計測
time_pyuf = timeit.timeit("pyuf(arr1, arr2)", globals=globals(), number=10)
# ネイティブ C 実装 ufunc の実行時間計測
time_npadd = timeit.timeit("np.add(arr1, arr2)", globals=globals(), number=10)

print(f"frompyfunc: {time_pyuf:.4f}s")
print(f"native ufunc: {time_npadd:.4f}s")

# [出力結果]
# frompyfunc: 1.7096s
# native ufunc: 0.0103s

期待される結果:

  • frompyfunc では、要素ごとにPythonの呼び出しと戻り処理が発生するため、ネイティブ ufunc と比較して数桁以上遅くなる
  • ネイティブ ufunc の高速化要因を再認識し、Python層の柔軟性とC層の性能を比較する

この実験により、C言語レベルで実装されたループ処理の有効性と、Pythonコールバックに伴うオーバーヘッドの大きさを明確に理解できます。


5. まとめと次の一歩

  • ベクトル化: Pythonループを置き換え一括演算を実現
  • 連続メモリ: ndarrayの設計でキャッシュ効率化
  • C実装のufunc: 少ないオーバーヘッドで高性能演算

NumPyの高速化の核心は、「Pythonの利便性」と「C言語エンジン」の両立です。次のステップとして、BLAS/MKL連携やGPU活用(Numba/CuPy)を通じて、さらなる性能向上を追求してください。


参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?