#0130（2025/05/11）numpyに迫る

# numpyに迫る



## はじめに

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

---

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

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

**検証コード（スクリプト形式）**

```python
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` にある定義抜粋です。

```c
// 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` で行われます。

```c
// 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実装の優位性を確認

```python
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）を通じて、さらなる性能向上を追求してください。

---

### 参考リンク

* GitHub: [`numpy/core/src/multiarray`](https://github.com/numpy/numpy/tree/main/numpy/core/src/multiarray)
* GitHub: [`numpy/core/src/umath/loops.c.src`](https://github.com/numpy/numpy/blob/main/numpy/core/src/umath/loops.c.src)
* 公式ドキュメント: [https://numpy.org/doc/](https://numpy.org/doc/)