web-dev-qa-db-ja.com

Pythonのctypesよりもnumpyの方が行列の乗算が速いのはなぜですか?

私は行列乗算を行う最速の方法を見つけようとしていて、3つの異なる方法を試しました:

  • 純粋なpython実装:ここで驚くことはありません。
  • numpy.dot(a, b)を使用したナンピー実装
  • Pythonのctypesモジュールを使用したCとのインターフェース。

これは、共有ライブラリに変換されるCコードです。

#include <stdio.h>
#include <stdlib.h>

void matmult(float* a, float* b, float* c, int n) {
    int i = 0;
    int j = 0;
    int k = 0;

    /*float* c = malloc(nay * sizeof(float));*/

    for (i = 0; i < n; i++) {
        for (j = 0; j < n; j++) {
            int sub = 0;
            for (k = 0; k < n; k++) {
                sub = sub + a[i * n + k] * b[k * n + j];
            }
            c[i * n + j] = sub;
        }
    }
    return ;
}

そして、それを呼び出すPythonコード:

def C_mat_mult(a, b):
    libmatmult = ctypes.CDLL("./matmult.so")

    dima = len(a) * len(a)
    dimb = len(b) * len(b)

    array_a = ctypes.c_float * dima
    array_b = ctypes.c_float * dimb
    array_c = ctypes.c_float * dima

    suma = array_a()
    sumb = array_b()
    sumc = array_c()

    inda = 0
    for i in range(0, len(a)):
        for j in range(0, len(a[i])):
            suma[inda] = a[i][j]
            inda = inda + 1
        indb = 0
    for i in range(0, len(b)):
        for j in range(0, len(b[i])):
            sumb[indb] = b[i][j]
            indb = indb + 1

    libmatmult.matmult(ctypes.byref(suma), ctypes.byref(sumb), ctypes.byref(sumc), 2);

    res = numpy.zeros([len(a), len(a)])
    indc = 0
    for i in range(0, len(sumc)):
        res[indc][i % len(a)] = sumc[i]
        if i % len(a) == len(a) - 1:
            indc = indc + 1

    return res

Cを使用したバージョンのほうが高速だったに違いないと思いますが...負けてしまったでしょう!以下は私のベンチマークであり、間違って実行したか、numpyが愚かであることを示しています。

benchmark

numpyバージョンがctypesバージョンよりも高速である理由を理解したいのですが、純粋なPython明らかです。

48
Charles Menguy

Numpyについてはあまり詳しくありませんが、ソースはGithubにあります。ドット積の一部は https://github.com/numpy/numpy/blob/master/numpy/core/src/multiarray/arraytypes.c.src で実装されていますが、これは各データ型の特定のC実装に変換されます。例えば:

/**begin repeat
 *
 * #name = BYTE, UBYTE, SHORT, USHORT, INT, UINT,
 * LONG, ULONG, LONGLONG, ULONGLONG,
 * FLOAT, DOUBLE, LONGDOUBLE,
 * DATETIME, TIMEDELTA#
 * #type = npy_byte, npy_ubyte, npy_short, npy_ushort, npy_int, npy_uint,
 * npy_long, npy_ulong, npy_longlong, npy_ulonglong,
 * npy_float, npy_double, npy_longdouble,
 * npy_datetime, npy_timedelta#
 * #out = npy_long, npy_ulong, npy_long, npy_ulong, npy_long, npy_ulong,
 * npy_long, npy_ulong, npy_longlong, npy_ulonglong,
 * npy_float, npy_double, npy_longdouble,
 * npy_datetime, npy_timedelta#
 */
static void
@name@_dot(char *ip1, npy_intp is1, char *ip2, npy_intp is2, char *op, npy_intp n,
           void *NPY_UNUSED(ignore))
{
    @out@ tmp = (@out@)0;
    npy_intp i;

    for (i = 0; i < n; i++, ip1 += is1, ip2 += is2) {
        tmp += (@out@)(*((@type@ *)ip1)) *
               (@out@)(*((@type@ *)ip2));
    }
    *((@type@ *)op) = (@type@) tmp;
}
/**end repeat**/

これは、1次元のドット積、つまりベクトルを計算するように見えます。 Githubのブラウジングの数分で、マトリックスのソースを見つけることができませんでしたが、結果マトリックスの各要素に対してFLOAT_dotの呼び出しを1回使用する可能性があります。つまり、この関数のループは最も内側のループに対応します。

それらの間の1つの違いは、「ストライド」-入力内の連続する要素間の差-は、関数を呼び出す前に一度明示的に計算されることです。あなたの場合、ストライドはなく、各入力のオフセットは毎回計算されますa[i * n + k]。私はそれをNumpyストライドに似たものに最適化する優れたコンパイラーを期待していましたが、おそらくステップが一定である(または最適化されていない)ことは証明できません。

Numpyは、この関数を呼び出す高レベルのコードでキャッシュ効果を使ってスマートに何かをしているかもしれません。一般的なトリックは、各行が連続しているか、または各列かを考え、最初に各連続部分を反復処理することです。完全に最適化するのは難しいようです。各ドット積について、1つの入力行列を行で、もう1つを列で移動する必要があります(異なる順序で格納された場合を除く)。しかし、結果要素については少なくともそれを行うことができます。

Numpyには、さまざまな基本実装から「ドット」を含む特定の操作の実装を選択するコードも含まれています。たとえば、 [〜#〜] blas [〜#〜] ライブラリを使用できます。上記の議論から、CBLASが使用されているように思えます。これはFortranからCに翻訳されました。テストで使用された実装は、ここにあるものだと思います: http://www.netlib.org/clapack/cblas/sdot.c

このプログラムは、別のマシンが読み取るためにマシンによって作成されていることに注意してください。しかし、一番下にあるのは、展開されたループを使用して一度に5つの要素を処理していることです。

for (i = mp1; i <= *n; i += 5) {
stemp = stemp + SX(i) * SY(i) + SX(i + 1) * SY(i + 1) + SX(i + 2) * 
    SY(i + 2) + SX(i + 3) * SY(i + 3) + SX(i + 4) * SY(i + 4);
}

この展開要因は、いくつかのプロファイリング後に選択された可能性があります。しかし、理論上の利点の1つは、各分岐点間でより多くの算術演算が実行されることです。また、コンパイラーとCPUは、可能な限り多くの命令パイプラインを取得するために最適にスケジュールする方法についてより多くの選択肢があることです。

21
Edmund

NumPyは、高度に最適化され、慎重に調整されたBLASメソッドを行列乗算に使用します( [〜#〜] atlas [〜#〜] も参照)。この場合の特定の関数はGEMM(一般的な行列乗算用)です。 dgemm.f(Netlibにあります)を検索することで、オリジナルを検索できます。

ところで、最適化はコンパイラの最適化を超えています。上記で、フィリップはコッパースミス–ウィノグラードに言及しました。私の記憶が正しければ、これはATLASでの行列乗算のほとんどの場合に使用されるアルゴリズムです(ただし、コメンターはStrassenのアルゴリズムである可能性があります)。

つまり、matmultアルゴリズムは簡単な実装です。同じことをするより速い方法があります。

30
Doctor Mohawk

特定の機能を実装するために使用される言語は、それ自体ではパフォーマンスの悪い尺度です。多くの場合、より適切なアルゴリズムを使用することが決定要因です。

あなたの場合、学校で教えられているように、O(n ^ 3)にある素朴な行列乗算アプローチを使用しています。ただし、特定の種類の行列、たとえば正方行列、予備行列など。

Coppersmith–Winogradアルゴリズム (O(n ^ 2.3737)の正方行列乗算)を見て、高速行列乗算の適切な開始点を確認してください。 「参照」セクションも参照してください。これには、さらに高速なメソッドへのポインターがリストされています。


驚異的なパフォーマンス向上のより素朴な例については、高速strlen()を記述して、それをglibc実装と比較してみてください。あなたがそれを打ち負かすことができなければ、glibcのstrlen()ソースを読んでください、それはかなり良いコメントを持っています。

9
Philip

Numpyは高度に最適化されたコードでもあります。その本の一部についてのエッセイがあります Beautiful Code

Ctypesは、CからPythonに動的に変換し、オーバーヘッドを追加する必要があります。Numpyでは、ほとんどの行列演算は完全に内部で行われます。

2
Keith

NumPyを書いた人は、明らかに彼らが何をしているか知っています。

行列の乗算を最適化する方法は多数あります。たとえば、マトリックスを走査する順序は、パフォーマンスに影響するメモリアクセスパターンに影響します。
SSEの適切な使用は、NumPyがおそらく採用している最適化の別の方法です。
NumPyの開発者が知っている方法と知らない方法があります。

ところで、最適化を使用してCコードをコンパイルしましたか?

Cに対して次の最適化を試すことができます。それは並行して動作し、NumPyは同じ行に沿って何かを行うと思います。
注:偶数サイズでのみ機能します。余分な作業を行うことで、この制限を取り除き、パフォーマンスの改善を維持できます。

for (i = 0; i < n; i++) {
        for (j = 0; j < n; j+=2) {
            int sub1 = 0, sub2 = 0;
            for (k = 0; k < n; k++) {
                sub1 = sub1 + a[i * n + k] * b[k * n + j];
                sub1 = sub1 + a[i * n + k] * b[k * n + j + 1];
            }
            c[i * n + j]     = sub;
            c[i * n + j + 1] = sub;
        }
    }
}
2
ugoren

Fortranの数値コードafaikでの速度の優位性の最も一般的な理由は、言語によって検出が容易になることです エイリアス -コンパイラーは、乗算される行列が同じメモリを共有しないことを認識できるキャッシュの改善に役立ちます(結果が「共有」メモリにすぐに書き戻されることを確認する必要はありません)。これが、C99が restrict を導入した理由です。

しかし、この場合、numpyコードでもCコードが使用されていないことを 特別な指示 を使用して管理しているのではないかと思います(違いは特に大きいようです)。

1
andrew cooke